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 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-10-03 20:09:10 +03:00
co-authored by Claude Sonnet 5.5
parent 69c678e97e
commit 01d75a1abf
4 changed files with 63 additions and 8 deletions
+28
View File
@@ -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");
});
+16 -8
View File
@@ -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(); };
},
};
+10
View File
@@ -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); },
+9
View File
@@ -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");
});