diff --git a/app/ui/features/generate/hub.test.js b/app/ui/features/generate/hub.test.js index 88fe222a..e145bacd 100644 --- a/app/ui/features/generate/hub.test.js +++ b/app/ui/features/generate/hub.test.js @@ -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/); }); diff --git a/app/ui/features/generate/index.js b/app/ui/features/generate/index.js index 570705bc..c0af43b6 100644 --- a/app/ui/features/generate/index.js +++ b/app/ui/features/generate/index.js @@ -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."); diff --git a/app/ui/features/generate/inputs.js b/app/ui/features/generate/inputs.js index 0675a577..c76a8d64 100644 --- a/app/ui/features/generate/inputs.js +++ b/app/ui/features/generate/inputs.js @@ -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) }) { diff --git a/app/ui/features/upscale/job.js b/app/ui/features/upscale/job.js index 2a346719..2ca9a894 100644 --- a/app/ui/features/upscale/job.js +++ b/app/ui/features/upscale/job.js @@ -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; } diff --git a/app/ui/features/upscale/job.test.js b/app/ui/features/upscale/job.test.js index 8de856b5..30e9c7a4 100644 --- a/app/ui/features/upscale/job.test.js +++ b/app/ui/features/upscale/job.test.js @@ -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"); +}); diff --git a/app/ui/shell/edits.js b/app/ui/shell/edits.js index b47c2a25..e252d111 100644 --- a/app/ui/shell/edits.js +++ b/app/ui/shell/edits.js @@ -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); }); diff --git a/app/ui/shell/takes.js b/app/ui/shell/takes.js index 559fcae8..bd10b3a6 100644 --- a/app/ui/shell/takes.js +++ b/app/ui/shell/takes.js @@ -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; diff --git a/app/ui/shell/tests/edits.test.js b/app/ui/shell/tests/edits.test.js index 554001df..447ac47d 100644 --- a/app/ui/shell/tests/edits.test.js +++ b/app/ui/shell/tests/edits.test.js @@ -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"]); +}); diff --git a/app/ui/shell/tests/takes.test.js b/app/ui/shell/tests/takes.test.js index d2f05cb6..998123cb 100644 --- a/app/ui/shell/tests/takes.test.js +++ b/app/ui/shell/tests/takes.test.js @@ -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); +});