fix(takes): takes follow a new unit root and an upscale, keep their own length, the batch is frozen at its start, a pre-takes render is kept, rated takes survive the cap, a batch with no seed input or during a run says so

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-10-04 12:44:38 +03:00
co-authored by Claude Sonnet 5.5
parent 7c3faf7294
commit f2e8c6f002
9 changed files with 68 additions and 16 deletions
+26 -4
View File
@@ -14,7 +14,7 @@ const rig = (phase, extra = {}) => {
waitForTerminal: () => Object.assign(Promise.resolve("done"), { cancel() {} }), ...extra };
const project = { project: doc, get scenes() { return doc.scenes; }, selected: null,
edit: (fn) => fn(doc), editFor: async (_id, fn) => fn(doc) };
const app = { project, selection: { ids: [] }, lastRun: { n: 0 }, runner: {}, pipeline: { slots: () => [] }, generate: g, api: { newTasteGeneration: () => Promise.resolve({}) }, on: () => () => {} };
const app = { project, selection: { ids: [] }, lastRun: { n: 0 }, runner: {}, pipeline: { slots: () => [{ id: "s", roles: [{ at: "generation.seed", input: "seed" }], inputs: {} }] }, generate: g, api: { newTasteGeneration: () => Promise.resolve({}) }, on: () => () => {} };
const host = document.createElement("div");
hub.setup({ host, app });
return { handlers, doc, host, app };
@@ -49,7 +49,7 @@ test("a batch of takes runs the unit N times, keeps every render as a take, and
assert.equal(doc.scene_renders.a.media.filename, "t3.mp4");
});
test("the take that was on the clip keeps its rating when a newer one replaces it; only the last twelve are kept", async () => {
test("the take that was on the clip keeps its rating when a newer one replaces it; only the last twenty-four are kept", async () => {
const { app, doc } = rig("idle", { generate: async () => true });
doc.scenes[0].rating = "10";
doc.scene_renders = { a: { media: { filename: "old.mp4" }, promptId: "p0" } };
@@ -57,7 +57,29 @@ test("the take that was on the clip keeps its rating when a newer one replaces i
app.generate.run.state.promptId = "p1";
await app.runner.units(["a"], 1);
assert.equal(doc.scene_variants.a[0].rating, "10");
doc.scene_variants.a = Array.from({ length: 15 }, (_, i) => ({ media: { filename: `${i}.mp4` }, promptId: `q${i}`, rating: "" }));
doc.scene_variants.a = Array.from({ length: 30 }, (_, i) => ({ media: { filename: `${i}.mp4` }, promptId: `q${i}`, rating: "" }));
await app.runner.units(["a"], 1);
assert.equal(doc.scene_variants.a.length, 12);
assert.equal(doc.scene_variants.a.length, 24);
});
test("a render from before takes becomes a take; the oldest unrated take goes first when the cap is hit, never a rated one", async () => {
const { app, doc } = rig("idle", { generate: async () => true });
doc.scenes[0].rating = "10";
doc.scene_renders = { a: { media: { filename: "legacy.mp4" } } };
app.generate.run.state.promptId = "p1";
await app.runner.units(["a"], 1);
assert.deepEqual(doc.scene_variants.a.map((t) => [t.media.filename, t.rating]), [["legacy.mp4", "10"], ["a.mp4", ""]]);
doc.scene_variants.a = Array.from({ length: 24 }, (_, i) => ({ media: { filename: `${i}.mp4` }, promptId: `q${i}`, rating: i === 0 ? "10" : "" }));
await app.runner.units(["a"], 1);
assert.equal(doc.scene_variants.a[0].media.filename, "0.mp4", "the rated one stays");
assert.ok(!doc.scene_variants.a.some((t) => t.media.filename === "1.mp4"), "the oldest unrated went");
});
test("a batch with no seed input says so and makes nothing", async () => {
let queued = 0;
const { app } = rig("idle", { generate: async () => { queued += 1; return true; } });
app.pipeline.slots = () => [];
await app.runner.units(["a"], 3);
assert.equal(queued, 0);
assert.match(document.body.textContent, /no seed input/);
});
+14 -10
View File
@@ -3,10 +3,10 @@ import { composer as c } from "../../composer/composer.js";
import { isGenerative, isVideoClip, genUnitId, unitRoot, effFrames, effFps } from "../../shell/scenes.js";
import { QUEUED, RUNNING } from "../../shell/run.js";
import { inPlace } from "../../shell/place.js";
import { buildInputs } from "./inputs.js";
import { buildInputs, rolesAt } from "./inputs.js";
import { offer } from "../../shell/actions.js";
const MAX_TAKES = 12;
const MAX_TAKES = 24;
const expand = (body) => fetch("/funpack/api/prompt/expand", { method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...body, seed: Math.floor(Math.random() * 2 ** 31) || 1 }) }).then((r) => (r.ok ? r.json() : null));
@@ -34,10 +34,12 @@ export default {
const head = group.find((s) => !(s.cut_offset_frames > 0)) || group[0];
const takes = ((pr.scene_variants ||= {})[head.id] ||= []); // every render stays as a take: the one that was on the clip keeps its rating
const was = (pr.scene_renders || {})[head.id];
const old = was && was.promptId && takes.find((t) => t.promptId === was.promptId);
const sameTake = (t) => was && (was.promptId ? t.promptId === was.promptId : t.media.filename === (was.media || {}).filename);
const old = was && was.media && takes.find(sameTake);
if (old) old.rating = head.rating || "";
takes.push({ media, ...(promptId ? { promptId } : {}), rating: "" });
if (takes.length > MAX_TAKES) takes.splice(0, takes.length - MAX_TAKES);
else if (was && was.media) takes.push({ media: was.media, ...(was.promptId ? { promptId: was.promptId } : {}), rating: head.rating || "", ...(was.durationSec ? { secs: was.durationSec } : {}) }); // a render from before takes is a take too
takes.push({ media, ...(promptId ? { promptId } : {}), rating: "", secs });
while (takes.length > MAX_TAKES) { const at = takes.findIndex((t) => !t.rating); takes.splice(at < 0 || at === takes.length - 1 ? 0 : at, 1); } // the oldest unrated goes first; a rated take stays as long as anything else can go
group.forEach((s) => {
(pr.scene_renders ||= {})[s.id] = renderFor(s, pr, media, secs, promptId);
if (!isVideoClip(s)) s.source_in = 0;
@@ -48,23 +50,25 @@ export default {
// `times` > 1: the same shots again, only the seed differing (the prompt's random picks are drawn once), so the takes can be compared.
async function runUnits(units, times = 1) {
if (busy) return;
if (busy) { if (times > 1) tell("A run is already going: wait for it, or Stop it."); return; }
if (times > 1 && !rolesAt(app.pipeline.slots(), "generation.seed").length) return tell("This pipeline has no seed input the app controls, so the takes would all come out the same.");
busy = true; stopped = false; draw();
const pid = p.project.id;
let made = 0;
said = false;
try {
const snap = times > 1 ? structuredClone(p.project) : null; // takes differ in the seed alone: later edits to the project do not reach them
const drawn = new Map();
const same = times > 1 ? (body) => { const key = JSON.stringify(body); if (!drawn.has(key)) drawn.set(key, expand(body)); return drawn.get(key); } : expand;
for (const unit of Array.from({ length: times }, () => units).flat()) {
if (stopped) break;
if (!p.project || p.project.id !== pid) { tell("Stopped: another project was opened."); break; }
const root = unitRoot(p.project, unit);
const group = p.project.scenes.filter((s) => genUnitId(s) === unit);
const root = unitRoot(snap || p.project, unit);
const group = (snap || p.project).scenes.filter((s) => genUnitId(s) === unit);
if (!root || group.every((s) => s.excluded) || !isGenerative(root)) continue; // removed, 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, p.project), 0) - (group.length - 1);
const { inputs, unwired, noPrompt, notes } = await buildInputs({ project: p.project, scene: root, slots: app.pipeline.slots(), expand: same, frames, hooks: app.inputHooks });
const frames = group.reduce((t, s) => t + effFrames(s, snap || p.project), 0) - (group.length - 1);
const { inputs, unwired, noPrompt, notes } = await buildInputs({ project: snap || p.project, scene: root, slots: app.pipeline.slots(), expand: same, frames, hooks: app.inputHooks });
app.lastRun.typed = (root.text || "").trim(); // what a Chat comment made now would be about
if (stopped) break;
if (noPrompt && !made) tell("This pipeline has no prompt input, so the scene text is not sent.");
+1 -1
View File
@@ -3,7 +3,7 @@
import { wireReferences } from "../../shell/reference_wiring.js";
import { DRIVEN } from "../../shell/scenes.js";
const rolesAt = (slots, at) => (slots || []).flatMap((s) => (s.roles || []).filter((r) => r.at === at).map((role) => ({ slot: s, role })));
export const rolesAt = (slots, at) => (slots || []).flatMap((s) => (s.roles || []).filter((r) => r.at === at).map((role) => ({ slot: s, role })));
/** `frames`: how long this unit is (its clips together); a fresh `seed` per call so a re-roll is a different result. */
export async function buildInputs({ project, scene, slots, expand, frames, hooks = [], seed = () => Math.floor(Math.random() * 2 ** 31) }) {
+1
View File
@@ -22,6 +22,7 @@ export function swap(p, from, to) {
const same = (m) => !!m && keyOf(m) === keyOf(from);
let n = 0;
for (const [id, r] of Object.entries(p.scene_renders || {})) if (r && same(r.media)) { p.scene_renders[id] = { ...r, media: to }; n += 1; }
for (const list of Object.values(p.scene_variants || {})) list.forEach((t) => { if (same(t.media)) { t.media = to; n += 1; } }); // a take stepped back to must be the upscaled file too
p.scene_ghosts = (p.scene_ghosts || []).map((g) => (same(g.media) ? (n += 1, { ...g, media: to }) : g));
return n;
}
+9
View File
@@ -26,3 +26,12 @@ test("a job that vanishes is reported after a few looks, a dropped reply is retr
const flaky = { queueUpscale: async () => "id", upscaleResult: async () => (++n < 5 ? { retry: true } : { videos: [m("u.mp4")] }) };
assert.equal((await upscale(flaky, m("x.mp4"), "mdl", async () => {})).filename, "u.mp4");
});
test("a take that plays the old file gets the upscaled one too, so stepping back does not undo it", async () => {
const { swap } = await import("./job.js");
const old = { filename: "a.mp4", subfolder: "", type: "output" }, up = { filename: "funpack_upscaled_a.mp4", subfolder: "", type: "output" };
const p = { scene_renders: { s: { media: old } }, scene_variants: { s: [{ media: old, promptId: "p" }, { media: { filename: "other.mp4" }, promptId: "q" }] } };
swap(p, old, up);
assert.equal(p.scene_variants.s[0].media.filename, "funpack_upscaled_a.mp4");
assert.equal(p.scene_variants.s[1].media.filename, "other.mp4");
});
+1
View File
@@ -45,6 +45,7 @@ export function removeScene(p, id, { pending = false } = {}) {
next.text = sc.text || next.text;
next.rating = sc.rating || next.rating;
next.source = clone(sc.source || next.source || {});
if (p.scene_variants && p.scene_variants[id]) { p.scene_variants[next.id] = p.scene_variants[id]; delete p.scene_variants[id]; } // the takes follow the unit's new root
const removedFrames = sc.frames || 0;
mates.filter((s) => s.id !== id && (s.cut_offset_frames || 0) > (sc.cut_offset_frames || 0))
.forEach((s) => { s.cut_offset_frames = Math.max(0, s.cut_offset_frames - removedFrames); });
+1 -1
View File
@@ -19,7 +19,7 @@ export function stepTake(p, scene, dir) {
const take = list[to];
p.scenes.filter((s) => genUnitId(s) === genUnitId(head)).forEach((s) => {
const r = (p.scene_renders || {})[s.id];
if (r) p.scene_renders[s.id] = { ...r, media: take.media, ...(take.promptId ? { promptId: take.promptId } : {}) };
if (r) p.scene_renders[s.id] = { ...r, media: take.media, ...(take.promptId ? { promptId: take.promptId } : {}), ...(take.secs && r.durationSec !== undefined ? { durationSec: take.secs } : {}) }; // a take made at another length plays at its own
});
head.rating = take.rating || "";
return true;
+7
View File
@@ -79,3 +79,10 @@ test("a split makes two clips of one unit that keep playing the same render", ()
assert.deepEqual(segments(p).map((s) => s.id).slice(0, 2), ["a", second.id]);
assert.equal(split(p, "a", 5), false, "no cut inside the first grid step");
});
test("removing the first cut of a unit hands its takes to the clip that becomes the root", async () => {
const { removeScene } = await import("../edits.js");
const p = { scenes: [{ id: "a", text: "x", frames: 8 }, { id: "b", gen_unit_id: "a", cut_offset_frames: 8, frames: 8 }], scene_variants: { a: [{ promptId: "p" }] }, scene_renders: {} };
removeScene(p, "a");
assert.deepEqual(Object.keys(p.scene_variants), ["b"]);
});
+8
View File
@@ -32,3 +32,11 @@ test("a clip whose render is not one of its takes cannot be stepped", () => {
p.scene_renders.a.promptId = "other";
assert.equal(stepTake(p, p.scenes[0], 1), false);
});
test("a take made at another length plays at its own", () => {
const p = proj();
p.scene_renders.a.durationSec = 4;
p.scene_variants.a[1].secs = 2.5;
stepTake(p, p.scenes[0], -1);
assert.equal(p.scene_renders.a.durationSec, 2.5);
});