From 208c92f5caa17d52fb8258153bda525e2f6afa06 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 13:52:04 +0000 Subject: [PATCH] Continuity: a scene's aspect-source picture no longer blocks the carry; log extraction and slot instead of a toast Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01Tw9fjYBYFyV8YG5YDfYPRb --- app/ui/features/continuity/hub.test.js | 8 +++----- app/ui/features/continuity/index.js | 2 +- app/ui/features/generate/index.js | 4 ++-- app/ui/features/generate/inputs.js | 8 ++++---- app/ui/features/generate/inputs.test.js | 4 ++-- app/ui/shell/api.js | 1 + modules/output/render/__init__.py | 14 +++++++++++++- modules/output/render/serving.py | 3 ++- modules/output/render/tests/test_serving.py | 12 ++++++++++++ 9 files changed, 40 insertions(+), 16 deletions(-) diff --git a/app/ui/features/continuity/hub.test.js b/app/ui/features/continuity/hub.test.js index b9d654b3..8f67c34c 100644 --- a/app/ui/features/continuity/hub.test.js +++ b/app/ui/features/continuity/hub.test.js @@ -7,22 +7,20 @@ const slots = [{ id: "7", roles: [{ at: "assets.reference_1", input: "media_id" const project = (extra = {}) => ({ id: "pj", num_frames_per_scene: 97, frame_rate: 25, scenes: [{ id: "a" }, { id: "b", source: { type: "carry" } }], scene_renders: { a: { media, inSec: 0 } }, ...extra }); const make = (values, lastFrame) => { const app = { api: { lastFrame }, pipeline: { currentValues: () => ({ continuity: values }) }, inputHooks: [], mediaMenu: [], project: { project: null, edit: () => true } }; hub.setup({ app }); return app.inputHooks[0]; }; -test("a carry shot gets the previous clip's last frame as an extra reference", async () => { +test("a carry shot gets the previous clip's last frame as an extra reference, also when a picture (the aspect source) is set on it", async () => { const calls = []; const hook = make({ carry: true, dark_guard: true }, async (pid, body) => { calls.push([pid, body.scene_id, body.dur]); return { media_id: "m9", dark: false }; }); const p = project(); + p.scenes[1].source_image = "aspect"; assert.deepEqual((await hook({ project: p, scene: p.scenes[1], slots })).references, ["m9"]); assert.deepEqual([calls[0][0], calls[0][1], Math.round(calls[0][2] * 25)], ["pj", "a", 97]); }); -test("nothing happens when it is off, the shot has its own picture, the source is another kind, or the pipeline has no start picture", async () => { +test("nothing happens when it is off or the source is another kind", async () => { const hook = make({ carry: false }, async () => assert.fail("must not ask")); const p = project(); assert.deepEqual(await hook({ project: p, scene: p.scenes[1], slots }), { references: [], notes: [] }); const on = make({}, async () => assert.fail("must not ask")); - p.scenes[1].source_image = "mine"; - assert.deepEqual(await on({ project: p, scene: p.scenes[1], slots }), { references: [], notes: [] }); - p.scenes[1].source_image = ""; p.scenes[1].source = { type: "image" }; assert.deepEqual(await on({ project: p, scene: p.scenes[1], slots }), { references: [], notes: [] }); }); diff --git a/app/ui/features/continuity/index.js b/app/ui/features/continuity/index.js index 0b12eb71..02893b0d 100644 --- a/app/ui/features/continuity/index.js +++ b/app/ui/features/continuity/index.js @@ -15,7 +15,7 @@ export default { const mine = (app.pipeline.currentValues(slots) || {}).continuity; if (!mine) return {}; // the module that holds the choices is not here: this does nothing at all const carry = mine.carry !== false, guard = mine.dark_guard !== false; - if (!carry || project.generation_mode === "t2v" || ((scene.source || {}).type || "carry") !== "carry" || scene.source_image) return {}; + if (!carry || project.generation_mode === "t2v" || ((scene.source || {}).type || "carry") !== "carry") return {}; // a picture on the scene is the aspect source here (a pinned one is on every scene), not a start: it does not stop the carry const prev = previousClip(project, scene); if (!prev.sceneId) return prev.quiet ? {} : { notes: [`Starts without a picture: ${prev.why}.`] }; let got; diff --git a/app/ui/features/generate/index.js b/app/ui/features/generate/index.js index c87f47ca..c08d3908 100644 --- a/app/ui/features/generate/index.js +++ b/app/ui/features/generate/index.js @@ -100,14 +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, 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 []; } }) }); + const { inputs, unwired, left, noPrompt, notes, placed } = 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 + for (const x of placed || []) app.api.carryLog(pid, { scene_id: root.id, ...x }).catch(() => {}); // the Log records where a carried picture went; no toast 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 84f32ae4..88346b89 100644 --- a/app/ui/features/generate/inputs.js +++ b/app/ui/features/generate/inputs.js @@ -122,16 +122,16 @@ 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 placed = []; 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). + // A picture a hook added to the references: where it landed is recorded in the Log (if it found no place, the "did not fit" note says so). 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]}.`); + if (at) placed.push({ media_id: id, reference: Number(at[1]) }); } for (const out of outs) { for (const [id, fields] of Object.entries(out.inputs || {})) raw[id] = { ...raw[id], ...fields }; @@ -141,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, info: told }; + return { inputs: raw, unwired: unwired + (ignored ? 1 : 0), left, noPrompt: !prompt, notes, placed }; } diff --git a/app/ui/features/generate/inputs.test.js b/app/ui/features/generate/inputs.test.js index 4336ba79..db7fae9e 100644 --- a/app/ui/features/generate/inputs.test.js +++ b/app/ui/features/generate/inputs.test.js @@ -264,9 +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"))); + assert.deepEqual(one.placed, [{ media_id: "frame", reference: 2 }]); 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.deepEqual(full.placed, []); assert.equal(kinds.get("frame"), "image"); }); diff --git a/app/ui/shell/api.js b/app/ui/shell/api.js index 467b1224..be8a0185 100644 --- a/app/ui/shell/api.js +++ b/app/ui/shell/api.js @@ -151,6 +151,7 @@ export const api = { }, renderLibrary: () => call("GET", "/api/m/render/library"), lastFrame: (pid, body) => call("POST", `/api/m/render/projects/${encodeURIComponent(pid)}/last-frame`, body), + carryLog: (pid, body) => call("POST", `/api/m/render/projects/${encodeURIComponent(pid)}/carry-log`, body), suggestionStats: () => call("GET", "/api/shortcuts/suggestion_stats"), expandPrompt: (body) => call("POST", "/api/prompt/expand", { ...body, seed: 1 }), packs: () => call("GET", "/api/packs"), diff --git a/modules/output/render/__init__.py b/modules/output/render/__init__.py index 46295673..44409c37 100644 --- a/modules/output/render/__init__.py +++ b/modules/output/render/__init__.py @@ -13,7 +13,7 @@ import asyncio import os import time -from ..._core import jobs, media, projects +from ..._core import jobs, log, media, projects from . import files, library, rtx, serving, stitch, upscale try: @@ -77,6 +77,18 @@ def routes(table, base, web): except (files.ClipError, ValueError, OSError) as exc: return bad(str(exc), 502) + @table.post(base + "/projects/{pid}/carry-log") + async def _carry_log(req): + """The Log's record that a carried last frame went into a reference slot (the page knows the slot; the node does not).""" + proj = project_of(req) + body = await body_of(req) + scene_id, mid, ref = body.get("scene_id"), body.get("media_id"), body.get("reference") + if not isinstance(scene_id, str) or not media.is_id(mid) or isinstance(ref, bool) or not isinstance(ref, int) or ref < 1: + return bad("Send {scene_id, media_id, reference}.") + entry = media.get(mid) or {} + log.info("Continuity", f"last frame of the clip before scene {projects.safe_part(scene_id)[:40]} ({proj.id[:8]}) -> reference {ref}: {entry.get('name', mid)} [{mid}]") + return web.json_response({"ok": True}) + @table.get(base + "/upscale_models") async def _upscale_models(_req): from ..._core import addons diff --git a/modules/output/render/serving.py b/modules/output/render/serving.py index 88b6aa6e..8a688703 100644 --- a/modules/output/render/serving.py +++ b/modules/output/render/serving.py @@ -19,7 +19,7 @@ import os import time from collections import OrderedDict -from ..._core import projects +from ..._core import log, projects from . import effects, files try: @@ -262,6 +262,7 @@ def last_frame(project, scene_id: str, render: dict | None = None, window: dict os.remove(out) except OSError: pass + log.info("Continuity", f"took the last frame of scene {projects.safe_part(scene_id)[:40]} -> {entry['filename']} (brightness {brightness:.0f})") _frames[key] = (entry["id"], brightness, peak) return {"media_id": entry["id"], "brightness": brightness, "dark": brightness < DARK_MEAN and peak < DARK_PEAK} diff --git a/modules/output/render/tests/test_serving.py b/modules/output/render/tests/test_serving.py index 7121f651..90d44065 100644 --- a/modules/output/render/tests/test_serving.py +++ b/modules/output/render/tests/test_serving.py @@ -261,3 +261,15 @@ def test_a_poster_is_a_small_still_made_once_and_a_missing_file_is_a_404(comfy, assert get(server, f"{BASE}/poster?filename=p.mp4&type=output")[2] == body assert get(server, f"{BASE}/poster?filename=nope.mp4")[0] == 404 assert get(server, f"{BASE}/poster?filename=../secret.mp4")[0] == 404 + + +def test_carry_log_route_records_the_slot_in_the_log_and_refuses_junk(comfy, server): + from core import log, media + proj = _project(comfy, frames=50, frames_mode="timeline") + entry = media.save_scratch("last_frame_s1.png", b"\x89PNG\r\n\x1a\n", comfy.temp) + log._reset() + status, out = _post(server, f"{BASE}/projects/{proj.id}/carry-log", {"scene_id": "s2", "media_id": entry["id"], "reference": 3}) + assert status == 200 and out["ok"] is True + assert any("reference 3" in r["message"] and entry["id"] in r["message"] for r in log.history()) + for bad in ({"scene_id": "s2", "media_id": "nope", "reference": 3}, {"scene_id": "s2", "media_id": entry["id"], "reference": 0}, {"media_id": entry["id"], "reference": 1}): + assert _post(server, f"{BASE}/projects/{proj.id}/carry-log", bad)[0] == 400