fix(ui): generate by unit with stop/errors/project guard, edit rollback, selection after remove, split selected clip, failed feature unsubscribed, project.video controls, preview starts at the cut

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-10-03 19:22:55 +03:00
co-authored by Claude Sonnet 5.5
parent 5798ac3b1b
commit 4f614a96dc
10 changed files with 115 additions and 41 deletions
+37 -27
View File
@@ -1,57 +1,67 @@
// Generate / Selected: queue the plan's scenes one after another and put each result on its clip.
// 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, genUnitId, effFrames, effFps } from "../../shell/scenes.js";
import { isGenerative, genUnitId, unitRoot, effFrames, effFps } from "../../shell/scenes.js";
import { buildInputs } from "./inputs.js";
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));
/** The render a scene gets from its unit's one clip: a cut half plays from where its half begins. */
export const renderFor = (sc, p, media, unitSec) => ({ media, inSec: (sc.cut_offset_frames || 0) / effFps(sc, p),
...((sc.frames_mode || "project") === "project" ? { durationSec: unitSec } : {}) });
export default {
id: "generate",
mount: "timeline.actions",
needs: ["project", "pipeline", "generate"],
setup({ host, app }) {
const p = app.project, g = app.generate;
let busy = false;
const tell = (text) => c.toast.warn({ text });
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, id, image) => {
if (!image || !p.project || p.project.id !== pid) return;
p.edit((pr) => {
const sc = pr.scenes.find((s) => s.id === id);
if (!sc) return false;
(pr.scene_renders ||= {})[id] = { media: { filename: image.filename, subfolder: image.subfolder || "", type: image.type || "output" },
durationSec: effFrames(sc, pr) / effFps(sc, pr), inSec: 0 };
return true;
});
};
const record = (pid, unit, image) => p.project && p.project.id === pid && p.edit((pr) => {
const group = pr.scenes.filter((s) => genUnitId(s) === unit);
if (!group.length) return false;
const secs = group.reduce((t, s) => t + effFrames(s, pr) / effFps(s, pr), 0);
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); });
return true;
});
async function runAll(scenes) {
async function runUnits(units) {
if (busy) return;
busy = true; draw();
const pid = p.project.id, seen = new Set();
busy = true; stopped = false; draw();
const pid = p.project.id;
try {
for (const sc of scenes) {
if (!isGenerative(sc) || sc.excluded || seen.has(genUnitId(sc))) continue;
seen.add(genUnitId(sc));
const { inputs, unwired } = await buildInputs({ project: p.project, scene: sc, slots: app.pipeline.slots(), expand });
if (unwired) c.toast.warn({ text: `${unwired} reference(s) did not fit this pipeline and are not used.` });
for (const unit of units) {
if (stopped) break;
if (!p.project || p.project.id !== pid) { tell("Stopped: another project was opened."); break; }
const root = unitRoot(p.project, unit);
if (!root || root.excluded || !isGenerative(root)) continue; // removed, left out, or not made by the model
const { inputs, unwired } = await buildInputs({ project: p.project, scene: root, slots: app.pipeline.slots(), expand });
if (stopped) break;
if (unwired) tell(`${unwired} reference(s) did not fit this pipeline and are not used.`);
const done = g.waitForTerminal(); // listening before the run starts, so a fast one is not missed
if (!(await g.generate({ sceneId: sc.id, projectId: pid, inputs }))) { done.cancel(); break; }
if (!(await g.generate({ sceneId: root.id, projectId: pid, inputs }))) { done.cancel(); break; }
const end = await done;
if (end !== g.DONE) break;
if (end === g.CANCELLED) break;
const images = g.run.state.images;
record(pid, sc.id, images[images.length - 1]);
if (end !== g.DONE) { tell("Generation failed. The log has ComfyUI's message."); break; }
if (!images.length) { tell("ComfyUI finished without a result (a cached run makes none). Change the prompt or seed and try again."); break; }
record(pid, unit, images[images.length - 1]);
}
} finally { busy = false; draw(); }
}
const unitsOf = (scenes) => [...new Set(scenes.map(genUnitId))];
let nodes = [];
function draw() {
const open = p.project;
const go = c.button.sm({ label: busy ? "Generating…" : "▶ Generate", tone: "primary", disabled: busy || !open,
onClick: () => runAll(p.scenes) });
const one = c.button.sm({ label: "Selected", disabled: busy || !p.selected, onClick: () => runAll([p.selected]) });
const stop = c.button.sm({ label: "■ Stop", tone: "danger", disabled: !busy, onClick: () => g.cancel() });
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 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;
+14
View File
@@ -0,0 +1,14 @@
import test from "node:test";
import assert from "node:assert/strict";
import { renderFor } from "./index.js";
import { split } from "../../shell/edits.js";
test("both halves of a split unit get the one clip, the second from its cut", () => {
const p = { num_frames_per_scene: 97, frame_rate: 24, scenes: [{ id: "a", text: "cat" }] };
split(p, "a");
const [a, b] = p.scenes;
const media = { filename: "x.mp4" };
assert.equal(renderFor(a, p, media, 4).inSec, 0);
assert.ok(renderFor(b, p, media, 4).inSec > 0);
assert.equal(b.gen_unit_id, a.gen_unit_id);
});
+12 -2
View File
@@ -17,7 +17,16 @@ const sceneRows = (p) => {
];
};
const projectRows = (p) => {
// What the pipeline lets the project decide (width, length, steps...): one control per slot input that declares the role.
const videoRows = (p, slots) => (slots || []).flatMap((slot) => (slot.roles || []).filter((r) => r.at === "project.video" && r.input).map((role) => {
const now = p.video[role.input] ?? (slot.inputs || {})[role.input];
const set = (v) => p.setVideo(role.input, v);
const label = role.label || role.input;
const control = typeof now === "number" ? c.number.md({ label, value: now, onChange: set }) : c.input.md({ label, value: now ?? "", onCommit: set });
return c.settingsRow.default({ label, control });
}));
const projectRows = (p, slots) => {
const open = p.project;
if (!open) return [c.emptyState.default({ icon: "▭", title: "No project", hint: "Open or create one." })];
const num = (label, key, fallback, min) => c.settingsRow.default({ label, control: c.number.md({ label, min, value: open[key] || fallback,
@@ -25,6 +34,7 @@ const projectRows = (p) => {
return [
c.label.section({ text: "Project" }),
num("Frames per scene", "num_frames_per_scene", 97, 9), num("Frame rate", "frame_rate", 25, 1),
...videoRows(p, slots),
c.label.section({ text: "Negative prompt" }),
c.textarea.md({ label: "Negative prompt", value: p.negative, rows: 3, autoGrow: true, onInput: (v) => p.setNegative(v) }),
];
@@ -45,7 +55,7 @@ export default {
const next = `${tab}|${p.project && p.project.id}|${p.selectedId}`;
if (!force && next === key) return;
key = next;
body.set((tab === "scene" ? sceneRows : projectRows)(p));
body.set((tab === "scene" ? sceneRows : projectRows)(p, app.pipeline && app.pipeline.slots()));
}
draw(true);
return app.on((what) => draw(what === "open"));
+1 -1
View File
@@ -13,7 +13,7 @@ export default {
let shown = "";
const draw = () => {
const sc = p.selected, media = sc && p.project && (p.project.scene_renders || {})[sc.id];
const src = media && media.media ? viewUrl(media.media) : "";
const src = media && media.media ? `${viewUrl(media.media)}#t=${media.inSec || 0}` : ""; // a split clip starts where its half does
if (src === shown) return; // a keystroke elsewhere must not restart the video
shown = src;
viewer.setSource(src || null, "video", src ? media.media : null);
+7 -6
View File
@@ -42,15 +42,16 @@ export default {
const btn = (label, onClick, disabled = false, title) => c.button.sm({ label, onClick, disabled, title });
function bar() {
const open = p.project, ids = open ? sel.ids : [];
const splitAt = () => {
const seg = segments(open).find((s) => s.kind === "scene" && at >= s.start && at < s.start + s.dur) || segments(open).find((s) => s.id === sel.focus);
if (!seg || !seg.scene) return;
edit((pr) => { const r = edits.split(pr, seg.id, framesAt(seg.scene, pr, Math.max(0, at - seg.start))); return r; });
const splitAt = () => { // the selected clip, at the playhead if it is over it, else in the middle
const seg = segments(open).find((s) => s.id === sel.focus && s.kind === "scene");
if (!seg) return;
const inside = at > seg.start && at < seg.start + seg.dur;
edit((pr) => edits.split(pr, seg.id, inside ? framesAt(seg.scene, pr, at - seg.start) : undefined));
};
toolbar = swap(toolbar, [
c.text.sm({ text: `${clock(at)} / ${clock(open ? totalSeconds(open) : 0)}` }),
btn("+ Add", () => edit((pr) => edits.addScene(pr)), !open),
btn("Split", splitAt, !ids.length, "Split the selected clip at the playhead (S)"),
btn("+ Add", () => { const sc = edit((pr) => edits.addScene(pr)); if (sc) p.select(sc.id); }, !open),
btn("Split", splitAt, !ids.length, "Split the selected clip at the playhead"),
btn("Remove", () => ids.forEach((id) => edit((pr) => edits.removeScene(pr, id))), !ids.length),
ids.length > 1 ? c.chip.neutral({ label: `${ids.length} selected` }) : c.text.sm({ text: "" }),
]);
+2 -2
View File
@@ -1,5 +1,5 @@
// The project as lanes of clips: what the timeline stage draws. Pure.
import { segments, isVideoClip, effFps, effFrames } from "../../shell/scenes.js";
import { segments, isVideoClip, effFps } from "../../shell/scenes.js";
const first = (t) => ((t || "").split("\n")[0] || "").slice(0, 80);
@@ -24,4 +24,4 @@ export function videoLane(p, picked, focus, actions = () => []) {
}
/** Frames at `sec` into scene `sc`, for Split. */
export const framesAt = (sc, p, sec) => Math.round(sec * effFps(sc, p)) || effFrames(sc, p) / 2;
export const framesAt = (sc, p, sec) => Math.round(sec * effFps(sc, p));
+5 -2
View File
@@ -10,10 +10,13 @@ function mountOne(hub, has, app) {
const lacking = (hub.needs || []).filter((name) => !has(name));
if (lacking.length) throw new Error(`needs ${lacking.join(", ")}`);
const before = [...host.childNodes];
const heard = []; // what the feature subscribed to, so a failed one can be unsubscribed
const mine = { ...app, on: (fn) => { const off = app.on(fn); heard.push(off); return off; } };
try {
return { id: hub.id, teardown: hub.setup({ host, app }) };
} catch (err) { // whatever it put on the page goes with it
return { id: hub.id, teardown: hub.setup({ host, app: mine }) };
} catch (err) { // whatever it put on the page and listened to goes with it
[...host.childNodes].filter((n) => !before.includes(n)).forEach((n) => n.remove());
heard.forEach((off) => off());
throw err;
}
}
+9
View File
@@ -25,3 +25,12 @@ test("what a feature put on the page before it threw is taken away", async () =>
await run(["throws"]);
assert.equal(h.childNodes.length, 0);
});
test("a feature that subscribed and then threw stops hearing the app", async () => {
_reset(); offer("a", host());
const heard = new Set();
const app = { on: (fn) => { heard.add(fn); return () => heard.delete(fn); } };
hubs.throws.setup = ({ app: a }) => { a.on(() => {}); throw new Error("late"); };
await loadFeatures(["throws"], { load: async (n) => ({ default: hubs[n] }), app, has: () => true });
assert.equal(heard.size, 0);
});
+8 -1
View File
@@ -301,8 +301,15 @@ export function createProject({ onChange, onError, onOpen } = {}) {
if (!project) return false;
const before = JSON.stringify(project);
remember();
const result = fn(project);
let result;
try { result = fn(project); } catch (err) { // a half-made edit must not stay in memory to be saved by the next one
Object.keys(project).forEach((k) => delete project[k]);
Object.assign(project, JSON.parse(before));
past.pop();
throw err;
}
if (!result || JSON.stringify(project) === before) { past.pop(); return result; }
if (!sceneAt(selected)) selected = ((project.scenes || [])[0] || {}).id || null; // the selected scene was removed
scheduleSave();
changed();
return result;
+20
View File
@@ -609,3 +609,23 @@ test("taking an edit back saves it, and tells whoever draws it", async () => {
await p.flush();
assert.equal(sent.at(-1).scenes.at(-1).text, "", "the version saved was not the one on screen");
});
test("an edit that throws leaves the project as it was, and the next edit does not save it", async () => {
const { sent } = server();
const p = createProject({});
await p.start();
p.project.scenes.push({ id: "a", text: "keep" });
assert.throws(() => p.edit((pr) => { pr.scenes[0].text = "half"; throw new Error("boom"); }), /boom/);
assert.equal(p.project.scenes[0].text, "keep");
assert.equal(p.canUndo, false);
});
test("removing the selected scene selects another", async () => {
server();
const p = createProject({});
await p.start();
p.project.scenes.push({ id: "a" }, { id: "b" });
p.select("a");
p.edit((pr) => { pr.scenes.shift(); return true; });
assert.equal(p.selectedId, "b");
});