From 3166012cf6d0cc30dfffe69106a0cb216bf09752 Mon Sep 17 00:00:00 2001 From: sckid1108 Date: Mon, 21 Sep 2026 22:24:46 -0700 Subject: [PATCH] fix(workflows): map audio slots of multi-input nodes to the file path In executeExtensionNode the multi-input branch resolved every slot's file path but only assigned mesh and image slots; an audio slot was computed and dropped, so a node declaring e.g. inputs ["audio", "text"] failed at run time with " needs an incoming audio connection" although preflight passed. Extract the slot-to-path assignment into slotInputs.ts (pure, tested) and give audio the same primary-path rule as the first image slot. Co-Authored-By: Claude Fable 5.1 --- src/areas/workflows/slotInputs.test.mjs | 54 +++++++++++++++++++++++++ src/areas/workflows/slotInputs.ts | 36 +++++++++++++++++ src/areas/workflows/workflowRunStore.ts | 15 +++---- 3 files changed, 95 insertions(+), 10 deletions(-) create mode 100644 src/areas/workflows/slotInputs.test.mjs create mode 100644 src/areas/workflows/slotInputs.ts diff --git a/src/areas/workflows/slotInputs.test.mjs b/src/areas/workflows/slotInputs.test.mjs new file mode 100644 index 00000000..16adecd2 --- /dev/null +++ b/src/areas/workflows/slotInputs.test.mjs @@ -0,0 +1,54 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { buildSync } from 'esbuild' +import { createRequire } from 'node:module' +import { mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' + +// slotInputs.ts has no runtime imports, so esbuild bundles it standalone. +function loadModule() { + const outfile = join(mkdtempSync(join(tmpdir(), 'modly-slotinputs-test-')), 'slotInputs.cjs') + const require = createRequire(import.meta.url) + const result = buildSync({ + entryPoints: [resolve('src/areas/workflows/slotInputs.ts')], + bundle: true, + platform: 'node', + format: 'cjs', + write: false, + }) + writeFileSync(outfile, result.outputFiles[0].text, 'utf8') + return require(outfile) +} + +const { assignSlotFilePaths } = loadModule() + +test('a mesh slot becomes the mesh path', () => { + const r = assignSlotFilePaths(['mesh', 'image'], ['a.glb', 'b.png']) + assert.equal(r.nodeInputMeshPath, 'a.glb') + assert.equal(r.nodeInputPath, 'b.png') + assert.deepEqual(r.extraImagePaths, []) +}) + +test('the first image slot is the primary path and later images are extras', () => { + const r = assignSlotFilePaths(['image', 'image', 'image'], ['1.png', '2.png', '3.png']) + assert.equal(r.nodeInputPath, '1.png') + assert.deepEqual(r.extraImagePaths, ['2.png', '3.png']) + assert.equal(r.nodeInputMeshPath, undefined) +}) + +test('an audio slot becomes the primary path', () => { + // Regression: audio slots were resolved and then dropped, so a multi-input + // node such as [audio, text] failed with "needs an incoming audio connection". + const r = assignSlotFilePaths(['audio', 'text'], ['song.wav', undefined]) + assert.equal(r.nodeInputPath, 'song.wav') + assert.equal(r.nodeInputMeshPath, undefined) + assert.deepEqual(r.extraImagePaths, []) +}) + +test('text slots never claim a file path, and empty slots are skipped', () => { + const r = assignSlotFilePaths(['text', 'image'], ['leaked.png', undefined]) + assert.equal(r.nodeInputPath, undefined) + assert.equal(r.nodeInputMeshPath, undefined) + assert.deepEqual(r.extraImagePaths, []) +}) diff --git a/src/areas/workflows/slotInputs.ts b/src/areas/workflows/slotInputs.ts new file mode 100644 index 00000000..c07705e9 --- /dev/null +++ b/src/areas/workflows/slotInputs.ts @@ -0,0 +1,36 @@ +// Multi-input slot → file-path assignment for extension nodes. +// +// `inputTypes` is the node's declared `inputs` (one per handle); `inputPaths` is +// the file path resolved for each slot, indexed the same way (undefined where the +// slot carries text or nothing). Pure so it can be tested without the store. + +export type SlotInputType = 'image' | 'text' | 'mesh' | 'audio' + +export interface SlotFilePaths { + /** Primary file: what the extension receives as `filePath` when no mesh is present. */ + nodeInputPath?: string + /** Mesh slot, if any. Takes precedence as `filePath`; the primary file then rides in params. */ + nodeInputMeshPath?: string + /** Every image beyond the first resolved image slot. */ + extraImagePaths: string[] +} + +export function assignSlotFilePaths( + inputTypes: readonly SlotInputType[], + inputPaths: readonly (string | undefined)[], +): SlotFilePaths { + const out: SlotFilePaths = { extraImagePaths: [] } + for (let i = 0; i < inputTypes.length; i++) { + const fp = inputPaths[i] + if (!fp) continue + if (inputTypes[i] === 'mesh') { + out.nodeInputMeshPath = fp + } else if (inputTypes[i] === 'image') { + if (!out.nodeInputPath) out.nodeInputPath = fp + else out.extraImagePaths.push(fp) + } else if (inputTypes[i] === 'audio') { + if (!out.nodeInputPath) out.nodeInputPath = fp + } + } + return out +} diff --git a/src/areas/workflows/workflowRunStore.ts b/src/areas/workflows/workflowRunStore.ts index d6e6d8fc..15cd9ace 100644 --- a/src/areas/workflows/workflowRunStore.ts +++ b/src/areas/workflows/workflowRunStore.ts @@ -6,6 +6,7 @@ import { showCompletionNotification } from '@shared/utils/notification' import type { WorkflowExtension } from './mockExtensions' import type { Workflow, WFNode, WFEdge } from '@shared/types/electron.d' import { isBranchStarter, isSceneOutput, resolveDataSource, reachesSceneOutput, nearestUpstreamWaits } from './nodeBehaviors' +import { assignSlotFilePaths } from './slotInputs' // ─── Types ──────────────────────────────────────────────────────────────────── @@ -344,16 +345,10 @@ async function executeExtensionNode( } } - for (let i = 0; i < inputTypes.length; i++) { - const fp = inputPaths[i] - if (!fp) continue - if (inputTypes[i] === 'mesh') { - nodeInputMeshPath = fp - } else if (inputTypes[i] === 'image') { - if (!nodeInputPath) nodeInputPath = fp - else extraImagePaths.push(fp) - } - } + const slots = assignSlotFilePaths(inputTypes, inputPaths) + nodeInputPath = slots.nodeInputPath + nodeInputMeshPath = slots.nodeInputMeshPath + extraImagePaths.push(...slots.extraImagePaths) } else { for (const edge of incomingEdges) { const src = resolveSource(edge.source)