Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/artist-intent-experimental.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ on:
push:
branches:
- integration/artist-intent-core-2026-10-10
- integration/artist-intent-propagation-2026-10-10
workflow_dispatch:

permissions:
Expand Down Expand Up @@ -73,6 +74,8 @@ jobs:
run: node --test --experimental-strip-types --experimental-loader ./scripts/node-ts-extensionless-loader.mjs electron/main/storage-directory-guard.test.ts
- name: Windows artist-intent regression
run: node --test src/areas/workflows/nodes/intent-travel/processor.test.mjs
- name: Windows intent propagation safety tests
run: node --experimental-strip-types --test src/areas/workflows/intentPropagation.test.mjs
- name: Windows main-process typecheck
run: npx --no-install tsc --noEmit -p tsconfig.node.json
- name: Windows built-ins typecheck
Expand Down
63 changes: 63 additions & 0 deletions docs/experimental-artist-intent-propagation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# Artist Context — Explicit Start, Automatic Stage Carry

Source: [OmarB97's Modly #252](https://github.com/lightningpixel/modly/pull/252).
This experimental slice stacks on the standalone
[Artist Context core](https://github.com/JayceeB1/modly/pull/6).

## What changes

Only a workflow with the **Remember Artist Context** (intent-travel/start)
node is opted in. Its output is a staged image containing a new
\`modly.intent.v1\` sidecar. A processing stage receiving that image or a
derived image/mesh automatically runs intent-travel/carry before publishing
its output, appending a stage event and retaining the immutable request ID,
artist words, style and project.

The goal is to preserve the original brief through:

Image → Remember Artist Context → Style → Generator → Rig/Optimize → GLB Export

The **Carry Artist Context** node also remains available for an explicit
source/destination transfer when a workflow cannot infer the connection.

## Fail-closed rules

- Workflows without an explicit start node remain unchanged.
- A generated stage never invents an original request or ancestry.
- Multiple input asset paths must **all** carry the same intent marker.
Mixed tagged/untagged files or different roots produce an explicit error
rather than silently attributing the output to the wrong source.
- Text-only edge inputs do not invalidate one tagged image/mesh.
- Processors can return the original path unchanged (e.g. Full detail);
no copy is needed then.
- If the processor fails to carry the record, the workflow returns an
error rather than declaring completion with missing metadata.
- The renderer cannot choose arbitrary artist-intent sidecar paths;
the underlying process extension rechecks physical workspace location
and sidecar size limits.
- A drag/drop image in \`selectedImageData\` can initialize the record,
with the same image size cap as the source processor.

## Tests and evidence

- Seven pure Node regression cases validate the decision logic for opt-in,
common origins, conflicting sources, orphaned/unmarked sources and text.
- The helper is wired into the existing Node test command and runs in
Windows and Linux checks.
- Integration compilation (Electron, TypeScript) ensures the runner and
extension signatures remain compatible.

## Remaining acceptance work

- [ ] Real Windows desktop run with a reference PNG and installed generators
(Style → 3D model → optimizer → exporter)
- [ ] Inspect the exported \`.tags.json\`: stable \`intent_id\`, original
sentences, stage events, project, \`derived_from\` unchanged
- [ ] Verify restart/continue and interrupted stage scenarios, including
source-output races and large inputs
- [ ] Review storage retention: staged source image copies currently remain
in \`Workflows/intent-sources\` until cleaned intentionally
- [ ] Validate compatibility with multi-input texture and scene generators

Not an automatic change to all existing Modly workflows; **not approved for
release** until runtime smoke tests and storage retention policy are reviewed.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
"prepare-resources": "node scripts/download-python-embed.js",
"test": "npm run test:py && npm run test:node",
"test:py": "node scripts/run-pytests.mjs",
"test:node": "node --test --experimental-strip-types --experimental-loader ./scripts/node-ts-extensionless-loader.mjs src/shared/types/assetLibrary.test.ts src/areas/generate/assetLibraryProjection.test.ts src/areas/generate/assetLibraryService.test.ts src/areas/generate/assetLibraryUi.test.ts src/areas/generate/orcaSlicerLink.test.ts electron/main/artifact-registry-service.test.ts electron/main/extension-path-guard.test.ts electron/main/storage-directory-guard.test.ts electron/preload/artifact-registry-preload.test.ts && node --test electron/main/*.test.mjs src/**/*.test.mjs && node --experimental-strip-types --test src/areas/workflows/workflowParamBindings.test.mjs src/areas/workflows/nodes/mesh-exporter/lineage.test.mjs && node --test src/areas/workflows/nodes/intent-travel/processor.test.mjs",
"test:node": "node --test --experimental-strip-types --experimental-loader ./scripts/node-ts-extensionless-loader.mjs src/shared/types/assetLibrary.test.ts src/areas/generate/assetLibraryProjection.test.ts src/areas/generate/assetLibraryService.test.ts src/areas/generate/assetLibraryUi.test.ts src/areas/generate/orcaSlicerLink.test.ts electron/main/artifact-registry-service.test.ts electron/main/extension-path-guard.test.ts electron/main/storage-directory-guard.test.ts electron/preload/artifact-registry-preload.test.ts && node --test electron/main/*.test.mjs src/**/*.test.mjs && node --experimental-strip-types --test src/areas/workflows/workflowParamBindings.test.mjs src/areas/workflows/nodes/mesh-exporter/lineage.test.mjs && node --test src/areas/workflows/nodes/intent-travel/processor.test.mjs && node --experimental-strip-types --test src/areas/workflows/intentPropagation.test.mjs",
"package": "cross-env CSC_IDENTITY_AUTO_DISCOVERY=false npm run build && npm run prepare-resources && electron-builder",
"package:mac": "cross-env CSC_IDENTITY_AUTO_DISCOVERY=false npm run build && npm run prepare-resources && electron-builder --mac --arm64",
"lint": "eslint ."
Expand Down
49 changes: 49 additions & 0 deletions src/areas/workflows/intentPropagation.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { assessIntentSources } from './intentPropagation.ts'

test('opt-out workflows do not receive arbitrary artist intent', () => {
assert.deepEqual(assessIntentSources([]), { source: undefined, conflict: false })
assert.deepEqual(assessIntentSources([{ filePath: 'external.glb' }]), { source: undefined, conflict: false })
})

test('a single stamped asset propagates its own request', () => {
assert.deepEqual(assessIntentSources([
{ filePath: 'stage.png', intentAssetPath: 'stage.png' },
]), { source: 'stage.png', conflict: false })
})

test('two matching stamped copies remain attributable to the same request', () => {
assert.deepEqual(assessIntentSources([
{ filePath: 'stage1.glb', intentAssetPath: 'root.png' },
{ filePath: 'stage2.glb', intentAssetPath: 'root.png' },
]), { source: 'root.png', conflict: false })
})

test('two distinct stamped assets fail closed rather than forging ownership', () => {
assert.deepEqual(assessIntentSources([
{ filePath: 'first.glb', intentAssetPath: 'first.png' },
{ filePath: 'second.glb', intentAssetPath: 'second.png' },
]), { source: undefined, conflict: true })
})

test('a mixed stamped and unstamped image is an explicit conflict', () => {
assert.deepEqual(assessIntentSources([
{ filePath: 'stamped.glb', intentAssetPath: 'stamped.glb' },
{ filePath: 'other.png' },
]), { source: undefined, conflict: true })
})

test('non-file text inputs do not erase attached intent', () => {
assert.deepEqual(assessIntentSources([
{ filePath: 'staged.png', intentAssetPath: 'staged.png' },
{ filePath: undefined },
undefined,
]), { source: 'staged.png', conflict: false })
})

test('blank metadata markers are not treated as a source', () => {
assert.deepEqual(assessIntentSources([
{ filePath: 'model.glb', intentAssetPath: '' },
]), { source: undefined, conflict: false })
})
26 changes: 26 additions & 0 deletions src/areas/workflows/intentPropagation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/**
* Pure, fail-closed resolver for artists' intent provenance across workflow
* edges. A text-only input never dilutes an asset's intent.
*/
export interface IntentBearingOutput {
filePath?: string
intentAssetPath?: string
}

export type IntentSourceDecision =
| { source: string; conflict: false }
| { source: undefined; conflict: boolean }

export function assessIntentSources(
inputs: readonly (IntentBearingOutput | undefined)[],
): IntentSourceDecision {
const assets = inputs.filter((value): value is IntentBearingOutput =>
typeof value?.filePath === 'string' && value.filePath.length > 0)
const tagged = assets.filter((value) => typeof value.intentAssetPath === 'string'
&& value.intentAssetPath.length > 0)
if (tagged.length === 0) return { source: undefined, conflict: false }
if (tagged.length !== assets.length) return { source: undefined, conflict: true }
const sources = new Set(tagged.map((value) => value.intentAssetPath!))
if (sources.size !== 1) return { source: undefined, conflict: true }
return { source: [...sources][0], conflict: false }
}
58 changes: 55 additions & 3 deletions src/areas/workflows/workflowRunStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type { Workflow, WFNode, WFEdge } from '@shared/types/electron.d'
import { isBranchStarter, isSceneOutput, resolveDataSource, reachesSceneOutput, nearestUpstreamWaits } from './nodeBehaviors'
import { assignSlotFilePaths } from './slotInputs'
import { resolveBoundWorkflowParams } from './workflowParamBindings'
import { assessIntentSources } from './intentPropagation'
import type { SlotInputType } from './slotInputs'

// ─── Types ────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -49,7 +50,32 @@ function flushResume(): void {
if (fn) fn()
}

interface NodeOutput { filePath?: string; text?: string; outputType?: string; sourceAssetPath?: string }
interface NodeOutput { filePath?: string; text?: string; outputType?: string; sourceAssetPath?: string; intentAssetPath?: string }

/** Carry only an explicitly initiated modly.intent.v1 across generated assets. */
async function carryArtistIntent(
sourcePath: string,
destinationPath: string,
node: WFNode,
ext: WorkflowExtension,
): Promise<void> {
const result = await window.electron.extensions.runProcess(
'intent-travel',
{ filePath: sourcePath, nodeId: 'carry' },
{
destinationPath,
stage: node.data.label ?? ext.name,
details: {
node_id: node.id,
extension_id: node.data.extensionId ?? null,
output_type: ext.output,
},
},
)
if (!result.success || !result.result?.filePath) {
throw new Error(result.error ?? 'Artist context could not be carried to the next stage')
}
}

function isSceneMeshOutput(output: NodeOutput | undefined): output is NodeOutput & { filePath: string } {
return output?.outputType === 'mesh' && typeof output.filePath === 'string'
Expand Down Expand Up @@ -348,6 +374,10 @@ async function executeExtensionNode(
const nodeInputSourceAssetPath = distinctSources.size === 1 ? [...distinctSources][0] : undefined

const incomingEdges = workflow.edges.filter((e) => e.target === node.id)
const intentDecision = assessIntentSources(incomingEdges.map((edge) => resolveSource(edge.source)))
if (intentDecision.conflict) {
throw new Error('Artist context has ambiguous inputs. Use a single staged source instead of silently losing attribution.')
}

if (ext?.inputs && ext.inputs.length > 1) {
// Scene is intentionally a single-input-only model contract. The guard
Expand Down Expand Up @@ -480,8 +510,12 @@ async function executeExtensionNode(
useAppStore.getState().updateCurrentJob({ status: 'generating', progress: st.progress, step: st.step })
}
} else {
const isIntentStart = ext?.extensionId === 'intent-travel' && ext?.nodeId === 'start'
if (isIntentStart && !nodeInputPath && selectedImageData) nodeInputText = selectedImageData
if (ext?.input === 'mesh' && !nodeInputPath) throw new Error(`${ext.name} needs an incoming mesh connection`)
if (ext?.input === 'image' && !nodeInputPath) throw new Error(`${ext.name} needs an incoming image connection`)
if (ext?.input === 'image' && !nodeInputPath && !(isIntentStart && selectedImageData)) {
throw new Error(`${ext.name} needs an incoming image connection`)
}
if (ext?.input === 'audio' && !nodeInputPath) throw new Error(`${ext.name} needs an incoming audio connection`)
if (ext?.input === 'text' && !nodeInputText) throw new Error(`${ext.name} needs an incoming text connection`)

Expand Down Expand Up @@ -518,7 +552,25 @@ async function executeExtensionNode(
}

const outputType = ext?.output ?? (nodeInputPath ? 'mesh' : undefined)
nodeOutputs.set(node.id, { filePath: nodeInputPath, text: nodeInputText, outputType, sourceAssetPath: nodeInputSourceAssetPath })
const isArtistStart = ext?.extensionId === 'intent-travel' && ext.nodeId === 'start'
const isArtistCarry = ext?.extensionId === 'intent-travel' && ext.nodeId === 'carry'
let outputIntentPath: string | undefined
if ((isArtistStart || isArtistCarry) && nodeInputPath) {
// The explicit process node verified/created its modly.intent.v1 sidecar.
outputIntentPath = nodeInputPath
} else if (
intentDecision.source && nodeInputPath && ext
&& (outputType === 'image' || outputType === 'mesh')
) {
if (intentDecision.source !== nodeInputPath) {
await carryArtistIntent(intentDecision.source, nodeInputPath, node, ext)
}
outputIntentPath = nodeInputPath
}
nodeOutputs.set(node.id, {
filePath: nodeInputPath, text: nodeInputText, outputType,
sourceAssetPath: nodeInputSourceAssetPath, intentAssetPath: outputIntentPath,
})

const output = nodeOutputs.get(node.id)
const url = isSceneMeshOutput(output) ? toWorkspaceUrl(output.filePath, workspaceDir) : undefined
Expand Down
Loading