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:
co-authored by
Claude Sonnet 5.5
parent
69c678e97e
commit
01d75a1abf
@@ -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");
|
||||
});
|
||||
@@ -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(); };
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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); },
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user