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:
@@ -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: [] });
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user