From 01d75a1abf3292c8af27a88573ad5f063eaecffb Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Sat, 3 Oct 2026 20:09:10 +0300 Subject: [PATCH] fix(ui): a result finishing in a project that is no longer open is saved there; Stop and Generate follow the run's own state; adopted runs without a result say so; progress ticks no longer rebuild the buttons Co-Authored-By: Claude Sonnet 5.5 --- app/ui/features/generate/hub.test.js | 28 ++++++++++++++++++++++++++++ app/ui/features/generate/index.js | 24 ++++++++++++++++-------- app/ui/shell/project.js | 10 ++++++++++ app/ui/shell/tests/project.test.js | 9 +++++++++ 4 files changed, 63 insertions(+), 8 deletions(-) create mode 100644 app/ui/features/generate/hub.test.js diff --git a/app/ui/features/generate/hub.test.js b/app/ui/features/generate/hub.test.js new file mode 100644 index 00000000..75e5bcaa --- /dev/null +++ b/app/ui/features/generate/hub.test.js @@ -0,0 +1,28 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { setupDom, teardownDom } from "../../composer/tests/_dom.js"; + +let hub; +test.before(async () => { setupDom(); hub = (await import("./index.js")).default; }); +test.after(() => teardownDom()); + +const rig = (phase) => { + const handlers = {}; + const doc = { id: "P", num_frames_per_scene: 97, frame_rate: 25, scenes: [{ id: "a", text: "x" }, { id: "b", gen_unit_id: "a", cut_offset_frames: 49 }] }; + const g = { DONE: "done", FAILED: "failed", CANCELLED: "cancelled", run: { state: { phase, images: [{ filename: "a.mp4" }] } }, + state() { return this.run.state; }, on: (k, fn) => { handlers[k] = fn; }, subscribe: () => () => {}, cancel() {}, + waitForTerminal: () => Promise.resolve("done") }; + const project = { project: doc, get scenes() { return doc.scenes; }, selected: null, + edit: (fn) => fn(doc), editFor: async (_id, fn) => fn(doc) }; + const app = { project, pipeline: { slots: () => [] }, generate: g, on: () => () => {} }; + hub.setup({ host: document.createElement("div"), app }); + return { handlers, doc }; +}; + +test("a run that finished before the page reloaded lands on every clip of its unit", async () => { + const { handlers, doc } = rig("done"); + handlers.adopt({ sceneId: "a", projectId: "P" }); + await new Promise((r) => setTimeout(r, 0)); + assert.equal(doc.scene_renders.a.media.filename, "a.mp4"); + assert.ok(doc.scene_renders.b.inSec > 0, "the cut half plays from its cut"); +}); diff --git a/app/ui/features/generate/index.js b/app/ui/features/generate/index.js index 8f1457da..cf6a6623 100644 --- a/app/ui/features/generate/index.js +++ b/app/ui/features/generate/index.js @@ -1,6 +1,7 @@ // Generate / Selected / Stop: queue generated units one after another and put each result on its clips. 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 { buildInputs } from "./inputs.js"; const expand = (body) => fetch("/funpack/api/prompt/expand", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -21,17 +22,17 @@ export default { g.on("say", tell); g.on("warn", tell); // the pipeline check's refusals: said where the person is looking let busy = false, stopped = false; - const record = (pid, unit, image, frames) => p.project && p.project.id === pid && p.edit((pr) => { + const record = (pid, unit, image, frames) => p.editFor(pid, (pr) => { const group = pr.scenes.filter((s) => genUnitId(s) === unit); if (!group.length) return false; - const secs = (frames || group.reduce((t, s) => t + effFrames(s, pr), 0) - (group.length - 1)) / effFps(group[0], pr); // as queued, not as the project reads now + const secs = (frames || group.reduce((t, s) => t + effFrames(s, pr), 0) - (group.length - 1)) / (pr.frame_rate || 25); // as queued, not as the project reads now const media = { filename: image.filename, subfolder: image.subfolder || "", type: image.type || "output" }; group.forEach((s) => { (pr.scene_renders ||= {})[s.id] = renderFor(s, pr, media, secs); if (!isVideoClip(s)) s.source_in = 0; // a fresh render was made at this length: an earlier trim's window no longer applies }); return true; - }); + }).catch(() => tell("The result could not be saved to its project.")); async function runUnits(units) { if (busy) return; @@ -73,13 +74,17 @@ export default { } const unitsOf = (scenes) => [...new Set(scenes.map(genUnitId))]; - let nodes = []; + let nodes = [], drawn = ""; function draw() { const open = p.project; - const go = c.button.sm({ label: busy ? "Generating…" : "▶ Generate", tone: "primary", disabled: busy || !open, + const key = `${busy}|${g.state().phase}|${Boolean(open)}|${Boolean(p.selected)}`; + if (key === drawn) return; // progress ticks must not rebuild the buttons + drawn = key; + const working = busy || g.state().phase === QUEUED || g.state().phase === RUNNING; // also a run this page did not start + const go = c.button.sm({ label: working ? "Generating…" : "▶ Generate", tone: "primary", disabled: working || !open, onClick: () => runUnits(unitsOf(p.scenes)) }); - const one = c.button.sm({ label: "Selected", disabled: busy || !p.selected, onClick: () => runUnits(unitsOf([p.selected])) }); - const stop = c.button.sm({ label: "■ Stop", tone: "danger", disabled: !busy, onClick: () => { stopped = true; g.cancel(); } }); + const one = c.button.sm({ label: "Selected", disabled: working || !p.selected, onClick: () => runUnits(unitsOf([p.selected])) }); + const stop = c.button.sm({ label: "■ Stop", tone: "danger", disabled: !working, onClick: () => { stopped = true; g.cancel(); } }); const next = [go, one, stop].map((b) => b.node); if (nodes.length) nodes.forEach((n, i) => n.replaceWith(next[i])); else host.append(...next); // in place: keeps its spot in the row nodes = next; @@ -92,6 +97,7 @@ export default { const finish = (end) => { const im = g.run.state.images; if (end === g.DONE && im.length) record(projectId, unit, im[im.length - 1]); + else tell("A run from before the reload ended without a result that could be attached. Generate again."); busy = false; draw(); }; const now = g.run.state.phase; // a run that ended while the page reloaded is over already: nothing to wait for @@ -106,6 +112,8 @@ export default { const off = app.on(() => { if (p.project) { claim(a); off(); } }); }); draw(); - return app.on(draw); + const offRun = g.subscribe(draw); + const offApp = app.on(draw); + return () => { offRun(); offApp(); }; }, }; diff --git a/app/ui/shell/project.js b/app/ui/shell/project.js index 0b90942a..e7e5a1c6 100644 --- a/app/ui/shell/project.js +++ b/app/ui/shell/project.js @@ -481,6 +481,16 @@ export function createProject({ onChange, onError, onOpen } = {}) { } }, + /** `edit(fn)` on a project that may not be the open one: a run finishes for the project it was started in, + * whatever is open by then. The open project takes the normal edit; another is read, changed and saved. */ + async editFor(projectId, fn) { + if (project && project.id === projectId) return this.edit(fn); + const doc = await read(projectId); + if (!fn(doc)) return false; + await put(doc); + return true; + }, + /** Step back, or forward. True when something moved. */ undo() { return step(past, future); }, redo() { return step(future, past); }, diff --git a/app/ui/shell/tests/project.test.js b/app/ui/shell/tests/project.test.js index 98a0b59a..2a5652e0 100644 --- a/app/ui/shell/tests/project.test.js +++ b/app/ui/shell/tests/project.test.js @@ -629,3 +629,12 @@ test("removing the selected scene selects another", async () => { p.edit((pr) => { pr.scenes.shift(); return true; }); assert.equal(p.selectedId, "b"); }); + +test("an edit for a project that is not open is read, changed and saved there", async () => { + const { sent } = server(); + const p = createProject({}); + await p.start(); + const done = await p.editFor("0123456789ab", (doc) => { doc.name = "other"; return true; }); + assert.equal(done, true); + assert.equal(sent.at(-1).name, "other"); +});