From d14bc51c2d1666d5ffd5ffe4238a63c8baa1e70d Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Sat, 3 Oct 2026 19:32:36 +0300 Subject: [PATCH] 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 --- app/ui/features/generate/index.js | 24 ++++++++++++++++++++--- app/ui/features/generate/inputs.js | 5 +++-- app/ui/features/generate/inputs.test.js | 11 +++++++++++ app/ui/features/inspector/index.js | 26 ++++++++++++++----------- app/ui/features/preview/index.js | 5 ++++- app/ui/features/timeline/index.js | 7 ++++--- app/ui/features/timeline/lanes.js | 4 ++-- app/ui/shell/scenes.js | 3 +++ core/tests/test_assets.py | 7 +++++-- modules/models/ltx/pipeline.py | 6 +++--- modules/models/minimax_h3/pipeline.py | 2 +- modules/system/pipeline/__init__.py | 2 +- 12 files changed, 73 insertions(+), 29 deletions(-) diff --git a/app/ui/features/generate/index.js b/app/ui/features/generate/index.js index a63b7b9c..8576d8d9 100644 --- a/app/ui/features/generate/index.js +++ b/app/ui/features/generate/index.js @@ -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); }, diff --git a/app/ui/features/generate/inputs.js b/app/ui/features/generate/inputs.js index 1a9ea363..f995ab4a 100644 --- a/app/ui/features/generate/inputs.js +++ b/app/ui/features/generate/inputs.js @@ -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 }; } diff --git a/app/ui/features/generate/inputs.test.js b/app/ui/features/generate/inputs.test.js index 7d5fe43c..bba37ca9 100644 --- a/app/ui/features/generate/inputs.test.js +++ b/app/ui/features/generate/inputs.test.js @@ -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); +}); diff --git a/app/ui/features/inspector/index.js b/app/ui/features/inspector/index.js index f8d10616..388159c7 100644 --- a/app/ui/features/inspector/index.js +++ b/app/ui/features/inspector/index.js @@ -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())); diff --git a/app/ui/features/preview/index.js b/app/ui/features/preview/index.js index 635b6b34..174d0346 100644 --- a/app/ui/features/preview/index.js +++ b/app/ui/features/preview/index.js @@ -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); diff --git a/app/ui/features/timeline/index.js b/app/ui/features/timeline/index.js index 0eb171c0..6961e3c8 100644 --- a/app/ui/features/timeline/index.js +++ b/app/ui/features/timeline/index.js @@ -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); diff --git a/app/ui/features/timeline/lanes.js b/app/ui/features/timeline/lanes.js index 9b0072f2..f439718f 100644 --- a/app/ui/features/timeline/lanes.js +++ b/app/ui/features/timeline/lanes.js @@ -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 { diff --git a/app/ui/shell/scenes.js b/app/ui/shell/scenes.js index 3c02860d..c82ae1f8 100644 --- a/app/ui/shell/scenes.js +++ b/app/ui/shell/scenes.js @@ -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); diff --git a/core/tests/test_assets.py b/core/tests/test_assets.py index df010d88..17263942 100644 --- a/core/tests/test_assets.py +++ b/core/tests/test_assets.py @@ -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(): diff --git a/modules/models/ltx/pipeline.py b/modules/models/ltx/pipeline.py index c9e51e15..40223366 100644 --- a/modules/models/ltx/pipeline.py +++ b/modules/models/ltx/pipeline.py @@ -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"}}, diff --git a/modules/models/minimax_h3/pipeline.py b/modules/models/minimax_h3/pipeline.py index cf81b54b..c0d416b1 100644 --- a/modules/models/minimax_h3/pipeline.py +++ b/modules/models/minimax_h3/pipeline.py @@ -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], diff --git a/modules/system/pipeline/__init__.py b/modules/system/pipeline/__init__.py index 33a01990..76b6d9c4 100644 --- a/modules/system/pipeline/__init__.py +++ b/modules/system/pipeline/__init__.py @@ -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}},