fix(ui): left-edge trim direction, one source for length/fps (roles 'drive' it), whole-number settings only, stop/queue/no-prompt/adopt messages, ghost remove, cut halves read-only prompt, preview start+end; font test sees app/ui fonts

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-10-03 19:32:36 +03:00
co-authored by Claude Sonnet 5.5
parent 4f614a96dc
commit d14bc51c2d
12 changed files with 73 additions and 29 deletions
+21 -3
View File
@@ -16,7 +16,8 @@ export default {
needs: ["project", "pipeline", "generate"],
setup({ host, app }) {
const p = app.project, g = app.generate;
const tell = (text) => c.toast.warn({ text });
let said = false;
const tell = (text) => { said = true; 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;
@@ -33,17 +34,26 @@ export default {
if (busy) return;
busy = true; stopped = false; draw();
const pid = p.project.id;
let made = 0;
try {
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 });
const { inputs, unwired, noPrompt } = await buildInputs({ project: p.project, scene: root, slots: app.pipeline.slots(), expand });
if (stopped) break;
if (noPrompt && !made) tell("This pipeline has no prompt input, so the scene text is not sent.");
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: root.id, projectId: pid, inputs }))) { done.cancel(); break; }
said = false;
if (!(await g.generate({ sceneId: root.id, projectId: pid, inputs }))) {
done.cancel();
if (!said) tell(g.run.state.error || "Could not queue the run. Is ComfyUI running, and is a run already going?");
break;
}
if (stopped) g.cancel(); // Stop landed while this one was being queued
made += 1;
const end = await done;
if (end === g.CANCELLED) break;
const images = g.run.state.images;
@@ -51,6 +61,7 @@ export default {
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]);
}
if (!made && !stopped) tell("Nothing to generate: every scene is left out or is a video clip.");
} finally { busy = false; draw(); }
}
const unitsOf = (scenes) => [...new Set(scenes.map(genUnitId))];
@@ -66,6 +77,13 @@ export default {
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;
}
// A run found after a reload belongs to this page's last session: its result still goes on its clips.
g.on("adopt", ({ sceneId, projectId }) => {
const sc = p.project && p.project.id === projectId && p.project.scenes.find((s) => s.id === sceneId);
if (!sc) return;
const unit = genUnitId(sc), done = g.waitForTerminal();
done.then((end) => { const im = g.run.state.images; if (end === g.DONE && im.length) record(projectId, unit, im[im.length - 1]); });
});
draw();
return app.on(draw);
},
+3 -2
View File
@@ -1,6 +1,7 @@
// What one run sends: the scene's prompt (expanded with anchor/postfix/$variables), the project's video
// settings, the scene's picked media -- each placed by the ROLE a pipeline slot declares, never by slot id.
import { wireReferences } from "../../shell/reference_wiring.js";
import { DRIVEN } from "../../shell/scenes.js";
const rolesAt = (slots, at) => (slots || []).flatMap((s) => (s.roles || []).filter((r) => r.at === at).map((role) => ({ slot: s, role })));
@@ -9,7 +10,7 @@ export async function buildInputs({ project, scene, slots, expand }) {
const put = (slot, name, value) => { raw[slot.id] = { ...raw[slot.id], [name]: value }; };
for (const { slot, role } of (slots || []).flatMap((s) => (s.roles || []).filter((r) => (r.at || "").startsWith("project.") && r.input).map((role) => ({ slot: s, role })))) {
const v = role.at === "project.negative" ? project.negative : (project.video || {})[role.input];
const v = role.at === "project.negative" ? project.negative : DRIVEN[role.drives] ? project[DRIVEN[role.drives]] : (project.video || {})[role.input];
if (v !== undefined && v !== null && v !== "") put(slot, role.input, v);
}
const source = rolesAt(slots, "assets.source_image")[0];
@@ -24,5 +25,5 @@ export async function buildInputs({ project, scene, slots, expand }) {
.catch(() => null); // a failing prompt-craft feature must not block the run: send what was typed
put(prompt.slot, prompt.role.input, body && typeof body.text === "string" ? body.text : text);
}
return { inputs: raw, unwired };
return { inputs: raw, unwired, noPrompt: !prompt };
}
+11
View File
@@ -18,3 +18,14 @@ test("a failing expander sends the typed text", async () => {
const { inputs } = await buildInputs({ project: {}, scene: { text: "x" }, slots, expand: async () => { throw new Error("down"); } });
assert.equal(inputs.p.text, "x");
});
test("a role that drives length is fed from the project number the timeline draws", async () => {
const s = [{ id: "k", roles: [{ at: "project.video", input: "length", drives: "frames" }, { at: "project.video", input: "width" }] }];
const { inputs } = await buildInputs({ project: { num_frames_per_scene: 49, video: { length: 97, width: 640 } }, scene: {}, slots: s, expand: async () => null });
assert.deepEqual(inputs.k, { length: 49, width: 640 });
});
test("no prompt role is reported, not silently ignored", async () => {
const { noPrompt } = await buildInputs({ project: {}, scene: { text: "x" }, slots: [], expand: async () => null });
assert.equal(noPrompt, true);
});
+15 -11
View File
@@ -1,6 +1,6 @@
// The Properties zone: what this scene is, and what the whole project makes.
import { composer as c } from "../../composer/composer.js";
import { effFrames, effFps } from "../../shell/scenes.js";
import { effFrames, effFps, isSubclip, DRIVEN } from "../../shell/scenes.js";
const SOURCES = [{ value: "carry", label: "Continue from previous" }, { value: "image", label: "Image" }, { value: "video", label: "Video" }];
@@ -9,32 +9,35 @@ const sceneRows = (p) => {
if (!sc) return [c.emptyState.default({ icon: "▭", title: "No scene", hint: "Add one on the timeline." })];
return [
c.label.section({ text: `Scene ${p.scenes.indexOf(sc) + 1}` }),
...(isSubclip(sc) ? [c.hint.default({ text: "This is a cut of a longer clip: its prompt and source belong to the first part." })] : [
c.textarea.md({ label: "Scene prompt", value: sc.text || "", rows: 6, autoGrow: true, onInput: (v) => p.setText(sc.id, v) }),
c.settingsRow.default({ label: "Source", control: c.select.md({ label: "Source", options: SOURCES, value: (sc.source || {}).type || "carry",
onChange: (v) => p.setScene(sc.id, "source", { ...(sc.source || {}), type: v }) }) }),
onChange: (v) => p.setScene(sc.id, "source", { ...(sc.source || {}), type: v }) }) })]),
c.settingsRow.default({ label: "Frames", hint: `${effFrames(sc, open)} frames at ${effFps(sc, open)} fps. Trim on the timeline to change.` }),
c.toggle.default({ label: "Leave out of the cut", checked: Boolean(sc.excluded), onChange: (v) => p.setScene(sc.id, "excluded", v) }),
];
};
// What the pipeline lets the project decide (width, length, steps...): one control per slot input that declares the role.
// What the pipeline lets the project decide (width, length, rate...): one whole-number control per slot input
// that declares the role. A role that `drives` length or rate edits the number the timeline draws.
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 field = DRIVEN[role.drives];
const now = field ? p.project[field] : p.video[role.input] ?? (slot.inputs || {})[role.input];
if (!Number.isInteger(now)) return null; // the project file keeps whole numbers only
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 });
}));
return c.settingsRow.default({ label, control: c.number.md({ label, min: 1, value: now,
onChange: (v) => (field ? p.setField(field, v) : p.setVideo(role.input, v)) }) });
}).filter(Boolean));
const projectRows = (p, slots) => {
const open = p.project;
if (!open) return [c.emptyState.default({ icon: "▭", title: "No project", hint: "Open or create one." })];
const rows = videoRows(p, slots);
const num = (label, key, fallback, min) => c.settingsRow.default({ label, control: c.number.md({ label, min, value: open[key] || fallback,
onChange: (v) => p.setField(key, v) }) });
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),
...(rows.length ? rows : [num("Frames per scene", "num_frames_per_scene", 97, 9), num("Frame rate", "frame_rate", 25, 1)]),
c.label.section({ text: "Negative prompt" }),
c.textarea.md({ label: "Negative prompt", value: p.negative, rows: 3, autoGrow: true, onInput: (v) => p.setNegative(v) }),
];
@@ -52,7 +55,8 @@ export default {
onChange: (v) => { tab = v; draw(true); } });
host.append(tabs.node, body.node);
function draw(force) { // not on every keystroke: that would rebuild the box being typed in
const next = `${tab}|${p.project && p.project.id}|${p.selectedId}`;
const sc = p.selected, open = p.project;
const next = `${tab}|${open && open.id}|${p.selectedId}|${tab === "scene" && sc && open ? [effFrames(sc, open), effFps(sc, open), p.scenes.indexOf(sc)] : ""}`; // a trim or reorder changes what the panel says
if (!force && next === key) return;
key = next;
body.set((tab === "scene" ? sceneRows : projectRows)(p, app.pipeline && app.pipeline.slots()));
+4 -1
View File
@@ -1,6 +1,9 @@
// The monitor: plays the render of the selected scene.
import { composer as c } from "../../composer/composer.js";
import { viewUrl } from "../../shell/run.js";
import { seconds } from "../../shell/scenes.js";
const start = (sc, r, open) => (r.inSec || 0) + (sc.source_in || 0); // where this clip begins in its render
export default {
id: "preview",
@@ -13,7 +16,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)}#t=${media.inSec || 0}` : ""; // a split clip starts where its half does
const src = media && media.media ? `${viewUrl(media.media)}#t=${start(sc, media, p.project)},${start(sc, media, p.project) + seconds(sc, p.project)}` : ""; // 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);
+4 -3
View File
@@ -26,7 +26,7 @@ export default {
const sc = p.project.scenes.find((s) => s.id === id);
if (!sc) return;
const dur = segments(p.project).find((s) => s.id === id).dur;
edit((pr) => edge === "out" ? edits.resize(pr, id, dur + d) : edits.trimLeft(pr, id, -d));
edit((pr) => edge === "out" ? edits.resize(pr, id, dur + d) : d > 0 && edits.trimLeft(pr, id, d)); // the left edge only cuts in
},
onReorder: (id, index) => edit((pr) => { // the stage counts ghosts and pauses too; the cut counts scenes
const all = segments(pr).filter((s) => s.id !== id);
@@ -46,7 +46,7 @@ export default {
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));
if (!edit((pr) => edits.split(pr, seg.id, inside ? framesAt(seg.scene, pr, at - seg.start) : undefined))) c.toast.warn({ text: "Too close to the clip's edge to cut there." });
};
toolbar = swap(toolbar, [
c.text.sm({ text: `${clock(at)} / ${clock(open ? totalSeconds(open) : 0)}` }),
@@ -64,7 +64,8 @@ export default {
const draw = () => {
const open = p.project;
stage.setLanes(open ? [videoLane(open, sel.ids, sel.focus)] : []);
stage.setLanes(open ? [videoLane(open, sel.ids, sel.focus, undefined, (g) => [{ label: "✕", title: "Remove from the timeline",
onClick: () => edit((pr) => { pr.scene_ghosts = (pr.scene_ghosts || []).filter((x) => x.id !== g.id); return true; }) }])] : []);
bar();
};
host.append(toolbar.node, zoomBar.node, stage.node);
+2 -2
View File
@@ -3,13 +3,13 @@ import { segments, isVideoClip, effFps } from "../../shell/scenes.js";
const first = (t) => ((t || "").split("\n")[0] || "").slice(0, 80);
export function videoLane(p, picked, focus, actions = () => []) {
export function videoLane(p, picked, focus, actions = () => [], ghostActions = () => []) {
const renders = p.scene_renders || {};
return {
id: "video", label: "Video", kind: "video", reorder: true,
clips: segments(p).map((seg) => {
if (seg.kind === "gap") return { id: seg.id, start: seg.start, dur: seg.dur, title: "pause", ghost: true };
if (seg.kind === "ghost") return { id: seg.id, start: seg.start, dur: seg.dur, title: first(seg.ghost.text) || "removed", ghost: true };
if (seg.kind === "ghost") return { id: seg.id, start: seg.start, dur: seg.dur, title: first(seg.ghost.text) || "removed", ghost: true, actions: ghostActions(seg.ghost) };
const sc = seg.scene;
const rendered = Boolean(renders[sc.id]);
return {
+3
View File
@@ -2,6 +2,9 @@
// each begins on the timeline. Pure -- every function takes the project (and renders) it reads, so the same
// answers come out on the timeline, in the player and in a run.
/** A pipeline role that `drives` the plan's length or rate is fed from the project number the timeline draws. */
export const DRIVEN = { frames: "num_frames_per_scene", fps: "frame_rate" };
export const genUnitId = (sc) => (sc && (sc.gen_unit_id || sc.id)) || "";
export const isVideoClip = (sc) => Boolean(sc && sc.source && sc.source.type === "video");
export const isGenerative = (sc) => Boolean(sc) && !isVideoClip(sc);
+5 -2
View File
@@ -17,9 +17,12 @@ COMPOSER_CSS = config.APP_DIR / "composer" / "composer.css"
URL_PREFIX = config.UI_PREFIX + "/app/"
UI_FONTS_CSS = config.APP_DIR / "ui" / "composer" / "tokens" / "fonts.css"
def font_urls():
text = FONTS_CSS.read_text()
return re.findall(r"url\('([^']+)'\)", text)
"""Every font either composer declares (the old shell's and the new frontend's)."""
return [u for css in (FONTS_CSS, UI_FONTS_CSS) if css.exists() for u in re.findall(r"url\('([^']+)'\)", css.read_text())]
def test_fonts_css_is_imported_first():
+3 -3
View File
@@ -17,7 +17,7 @@ DISTILLED_SIGMAS = "1.0, 0.99375, 0.9875, 0.98125, 0.975, 0.909375, 0.725, 0.421
def _base(image_to_video: bool):
video = {"at": "project.video", "input": "frame_rate", "label": "FPS"}
video = {"at": "project.video", "input": "frame_rate", "label": "FPS", "drives": "fps"}
latent_source = ["latent", 0]
model_source, positive_source, negative_source = ["modifiers", 0], ["conditioning", 0], ["conditioning", 1]
slots = [
@@ -39,7 +39,7 @@ def _base(image_to_video: bool):
{"id": "latent", "group": "Preparation", "node": "FunPackEmptyLatent",
"roles": [{"at": "project.video", "input": "width", "label": "Width"},
{"at": "project.video", "input": "height", "label": "Height"},
{"at": "project.video", "input": "length", "label": "Length"},
{"at": "project.video", "input": "length", "label": "Length", "drives": "frames"},
video],
"inputs": {"model": ["model", 0], "audio_vae": ["audio_vae", 0],
"width": 768, "height": 512, "length": 121, "frame_rate": 25.0, "batch_size": 1}},
@@ -89,7 +89,7 @@ def _base(image_to_video: bool):
{"id": "decode", "group": "Render", "node": "FunPackDecode", "inputs": {
"samples": ["sampler", 0], "vae": ["model", 2], "model": ["model", 0], "audio_vae": ["audio_vae", 0]}},
{"id": "video", "group": "Render", "node": "CreateVideo",
"roles": [{"at": "project.video", "input": "fps", "label": "FPS"}],
"roles": [{"at": "project.video", "input": "fps", "label": "FPS", "drives": "fps"}],
"inputs": {"images": ["decode", 0], "fps": 25.0, "audio": ["decode", 1]}},
{"id": "save", "group": "Render", "node": "SaveVideo", "inputs": {
"video": ["video", 0], "filename_prefix": "FunPack", "format": "auto"}},
+1 -1
View File
@@ -91,7 +91,7 @@ def h3_reference_to_video():
{"id": "markup", "group": "Preparation", "node": "FunPackPromptMarkup",
"inputs": {"text": ["enhance", 0]}},
{"id": "r2v", "group": "Preparation", "node": "MiniMaxH3ReferenceToVideo",
"roles": [{"at": "project.video", "input": "length", "label": "Length"}],
"roles": [{"at": "project.video", "input": "length", "label": "Length", "drives": "frames"}],
"inputs": {
"clip": ["clip", 0], "vae": ["vae", 0], "audio_vae": ["audio_vae", 0],
"prompt": ["markup", 0], "width": ["image_transform", 4], "height": ["image_transform", 5],
+1 -1
View File
@@ -53,7 +53,7 @@ DEFAULT = [
{"id": "latent", "group": "Preparation", "node": "FunPackEmptyLatent",
"roles": [{"at": "project.video", "input": "width", "label": "Width"},
{"at": "project.video", "input": "height", "label": "Height"},
{"at": "project.video", "input": "length", "label": "Length"}],
{"at": "project.video", "input": "length", "label": "Length", "drives": "frames"}],
"inputs": {
"model": ["model", 0], "width": 512, "height": 512, "length": 1, "batch_size": 1}},