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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tw9fjYBYFyV8YG5YDfYPRb
This commit is contained in:
Claude
2026-11-02 13:52:04 +00:00
parent 716e5d414f
commit 208c92f5ca
9 changed files with 40 additions and 16 deletions
+3 -5
View File
@@ -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: [] });
});
+1 -1
View File
@@ -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;
+2 -2
View File
@@ -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; }
+4 -4
View File
@@ -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 };
}
+2 -2
View File
@@ -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");
});
+1
View File
@@ -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"),
+13 -1
View File
@@ -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
+2 -1
View File
@@ -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}
@@ -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