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