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:
co-authored by
Claude Sonnet 5.5
parent
5798ac3b1b
commit
4f614a96dc
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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"));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: "" }),
|
||||
]);
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user