From 716e5d414f3b1fe8de2b16e9225ea57d6549343a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 13:48:48 +0000 Subject: [PATCH] Continuity: say which reference the carried frame went into Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01Tw9fjYBYFyV8YG5YDfYPRb --- app/ui/features/generate/index.js | 3 ++- app/ui/features/generate/inputs.js | 13 ++++++++++--- app/ui/features/generate/inputs.test.js | 2 ++ 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/app/ui/features/generate/index.js b/app/ui/features/generate/index.js index 3de195d0..c87f47ca 100644 --- a/app/ui/features/generate/index.js +++ b/app/ui/features/generate/index.js @@ -100,13 +100,14 @@ export default { if (group.every((s) => s.excluded) || !isGenerative(root)) continue; // all left out, or not made by the model // The unit is made once at the length of its clips together (each cut adds one shared frame). const frames = group.reduce((t, s) => t + effFrames(s, snap), 0) - (group.length - 1); - const { inputs, unwired, left, noPrompt, notes } = await buildInputs({ project: snap, scene: root, slots: frozen, expand: same, nodeInfo: app.api.wireTargets, frames, hooks: app.inputHooks, ...(Number.isInteger(once.seed) ? { seed: () => once.seed } : fixedSeed ? { seed: null } : {}), prefix: (app.promptPrefix || []).flatMap((f) => { try { return f(root, snap); } catch { return []; } }) }); + const { inputs, unwired, left, noPrompt, notes, info } = await buildInputs({ project: snap, scene: root, slots: frozen, expand: same, nodeInfo: app.api.wireTargets, frames, hooks: app.inputHooks, ...(Number.isInteger(once.seed) ? { seed: () => once.seed } : fixedSeed ? { seed: null } : {}), prefix: (app.promptPrefix || []).flatMap((f) => { try { return f(root, snap); } catch { return []; } }) }); app.lastRun.typed = (root.text || "").trim(); // what a Chat comment made now would be about const seeded = rolesAt(frozen, "generation.seed")[0]; if (seeded) app.lastRun.seed = (inputs[seeded.slot.id] || {})[seeded.role.input] ?? (seeded.slot.inputs || {})[seeded.role.input]; // shown in ⏱ Sampler, to keep a liked one if (stopped) break; if (noPrompt && !made) tell("This pipeline has no prompt input, so the scene text is not sent."); notes.forEach(tell); + (info || []).forEach((text) => c.toast.info({ text })); // what was done as asked, said plainly, not as a warning if (unwired) tell(`${unwired} reference(s) did not fit this pipeline and are not used: ${(left || []).map((l) => `${names.get(l.id) || l.id} (looked for ${l.at.replace("assets.", "")}: ${l.why})`).join("; ")}.`); // The page's own guard does not see another tab: ComfyUI's queue does. Asked per shot: between this run's shots it is empty. if (app.api.projectQueued && (await app.api.projectQueued(pid))) { tell("This project already has a run in ComfyUI's queue (another tab or window?). Wait for it, or Stop it there."); break; } diff --git a/app/ui/features/generate/inputs.js b/app/ui/features/generate/inputs.js index 3c1bb826..84f32ae4 100644 --- a/app/ui/features/generate/inputs.js +++ b/app/ui/features/generate/inputs.js @@ -24,12 +24,12 @@ export async function buildInputs({ project, scene, slots, expand, nodeInfo, fra const plan = sceneReferences(scene, project.fixed_references); // Hooks run first: one may add pictures to this run's references (a carried frame takes the next free slot), which the // start and the wiring below must see. What else they return (inputs, notes) is merged further down, where it always was. - const outs = [], hookNotes = []; + const outs = [], hookNotes = [], carried = []; for (const hook of hooks) { let out; try { out = (await hook({ project, scene, slots })) || {}; } catch (err) { hookNotes.push(`An add-on's input could not be added to this run: ${err.message}`); continue; } outs.push(out); - for (const id of out.references || []) if (id && !plan.ids.includes(id)) { plan.ids = [...plan.ids, id]; if (!kinds.has(id)) kinds.set(id, "image"); } + for (const id of out.references || []) if (id && !plan.ids.includes(id)) { plan.ids = [...plan.ids, id]; if (!kinds.has(id)) kinds.set(id, "image"); carried.push(id); } } // A pipeline that takes whole media where it takes a picture (LTX): with no start picked, the first reference is the start: // a VIDEO first (it is then not also sent as a reference: nothing takes it there), else the first picture (which stays a @@ -122,10 +122,17 @@ export async function buildInputs({ project, scene, slots, expand, nodeInfo, fra } // Features that add inputs of their own (a Chat comment for the enhancer, say) say so through a hook: { inputs: {slot: {name: value}}, notes: [text] }. const noVideo = takesVideo && videoOnly && !startId ? ["This pipeline works from a video: pick one as the start, or add it under References."] : []; + const told = []; const notes = failedExpand ? ["The prompt could not be expanded (shortcuts, $variables, anchor, postfix): the text was sent as typed."] : []; if (!textOnly) notes.push(...noVideo); else notes.push("Nothing to feed the model, so this shot is made from the prompt alone (text to video)."); notes.push(...hookNotes); + // A picture a hook added to the references: said where it landed (or, if it found no place, by the "did not fit" note). + for (const id of carried) { + const slotId = Object.keys(overrides).find((k) => overrides[k].media_id === id); + const at = slotId && ((slots || []).find((x) => x.id === slotId)?.roles || []).map((r) => /^assets\.reference_(\d+)$/.exec(r.at || "")).find(Boolean); + if (at) told.push(`Continuity: the last frame of the clip before it (${id}) went into reference ${at[1]}.`); + } for (const out of outs) { for (const [id, fields] of Object.entries(out.inputs || {})) raw[id] = { ...raw[id], ...fields }; notes.push(...(out.notes || [])); @@ -134,5 +141,5 @@ export async function buildInputs({ project, scene, slots, expand, nodeInfo, fra // Judged on what is SENT (a carried frame counts; a picture t2v blanks, or one this pipeline has no input for, does not). const picture = (source && (raw[source.slot.id] || {}).media_id) || Object.keys(overrides).length; if (prompt && !String((raw[prompt.slot.id] || {})[prompt.role.input] ?? "").trim() && !picture) notes.push("This shot has no text and no start picture, so the model makes whatever it likes."); - return { inputs: raw, unwired: unwired + (ignored ? 1 : 0), left, noPrompt: !prompt, notes }; + return { inputs: raw, unwired: unwired + (ignored ? 1 : 0), left, noPrompt: !prompt, notes, info: told }; } diff --git a/app/ui/features/generate/inputs.test.js b/app/ui/features/generate/inputs.test.js index 73e032d6..4336ba79 100644 --- a/app/ui/features/generate/inputs.test.js +++ b/app/ui/features/generate/inputs.test.js @@ -264,7 +264,9 @@ test("a hook's carried frame takes the next free reference slot, after the scene const run = (scene, extra = {}) => buildInputs({ project: {}, scene, slots: refs, expand: async ({ text }) => ({ text }), hooks: carry, ...extra }); const one = await run({ text: "", references: ["mine"] }); assert.deepEqual([one.inputs.r1.media_id, one.inputs.r2.media_id, one.unwired], ["mine", "frame", 0]); + assert.ok(one.info.some((n) => /went into reference 2\./.test(n) && n.includes("frame"))); const full = await run({ text: "", references: ["a", "b"] }); assert.deepEqual([full.inputs.r1.media_id, full.inputs.r2.media_id, full.unwired], ["a", "b", 1]); + assert.ok(!full.info.some((n) => /went into/.test(n))); assert.equal(kinds.get("frame"), "image"); });