Files
olivv-cs-ComfyUI-FunPack/app/ui/features/inspector/project.js
T

86 lines
7.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// The Project tab: what the whole project makes.
import { composer as c } from "../../composer/composer.js";
import { DRIVEN, isVideoClip, segments, snapFrames } from "../../shell/scenes.js";
import { gridOf } from "../../shell/frame_grid.js";
import { card } from "./scene.js";
const STARTS = [{ value: "i2v", label: "From an image" }, { value: "t2v", label: "From a prompt" }];
// 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 videoControls = (p, slots) => (slots || []).flatMap((slot) => (slot.roles || []).filter((r) => r.at === "project.video" && r.input).map((role) => {
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, grid = role.drives === "frames" ? gridOf(slots) : null;
// On a model with a frame grid the arrows walk it (H3: 22, 39, 56 …) and a typed number lands on it: what runs is what shows.
return { key: field || role.input, label, field: c.field.default({ label, hint: grid ? `This model makes ${grid.step}k+${grid.base} frames; other numbers snap to the nearest.` : undefined,
control: c.number.md({ label, min: grid ? grid.step + grid.base : 1, max: role.drives === "fps" ? 1000 : 16384, step: grid ? grid.step : 1, precision: 0, value: now,
snap: grid ? (v) => snapFrames(v, "round", grid) : undefined, onChange: (v) => (field ? p.setField(field, v) : p.setVideo(role.input, v)) }) }) };
}).filter(Boolean)).filter((r, i, all) => all.findIndex((x) => x.key === r.key) === i);
const num = (p, label, key, fallback) => ({ key, label, field: c.field.default({ label, control: c.number.md({ label, min: 1, max: key === "frame_rate" ? 1000 : 16384, step: 1, precision: 0,
value: p.project[key] || fallback, onChange: (v) => p.setField(key, v) }) }) });
/** What each shot's prompt becomes at generation (anchor + shortcuts + $variables + postfix), asked for on demand. */
function splitPreview(p, api) {
const out = c.region.stack({ gap: "xs" });
const show = async () => {
out.set([c.hint.default({ text: "Working…" })]);
try {
const open = p.project, roots = open.scenes.filter((s) => !s.excluded && !(s.cut_offset_frames > 0) && !(s.source && s.source.type === "video"));
const lines = await Promise.all(roots.map(async (s, i) => ({ n: open.scenes.indexOf(s) + 1, text: (await api.expandPrompt({ text: s.text || "", anchor: open.anchor, postfix: open.postfix, postfix_enabled: open.postfix_enabled, variables: open.variables })).text })));
out.set(lines.length ? lines.map((l) => c.field.default({ label: `S${l.n}`, control: c.text.sm({ text: l.text || "(empty)" }) })) : [c.hint.default({ text: "No scenes to preview." })]);
} catch (err) { out.set([c.hint.default({ text: `Could not build the preview: ${err.message}` })]); }
};
return c.region.stack({ gap: "xs", children: [c.button.sm({ label: "Refresh preview", tone: "ghost", onClick: show }), out] });
}
/** The final render's size can come from the first clip, the project, or any rendered clip's own. */
function sizes(open) {
const rendered = segments(open).filter((s) => s.kind === "scene" && !s.scene.excluded && (open.scene_renders || {})[s.scene.id]);
const list = [{ value: "", label: "The first clip's own size" }, { value: "project", label: `The project's size (${open.width || 768}×${open.height || 512})` },
...rendered.map((s, i) => ({ value: s.scene.id, label: `Clip ${i + 1}'s own size` }))];
if (open.export_size_from && !list.some((o) => o.value === open.export_size_from)) list.push({ value: open.export_size_from, label: "A clip that is no longer here (the first clip is used)" });
return list;
}
/** Shots set to Custom length ignore the project's Frames: say so, and offer to put them back. */
function custom(p, open) {
const gen = open.scenes.filter((s) => !isVideoClip(s)), over = gen.filter((s) => s.frames_mode === "custom");
if (!over.length) return [];
return [c.banner.warn({ text: over.length >= gen.length ? `Frames doesn't reach any shot — all ${over.length} are set to Custom.` : `Frames doesn't reach ${over.length} of ${gen.length} shots — they're set to Custom.`,
action: { label: "Use project length everywhere", onClick: () => p.edit((pr) => { let hit = false; for (const s of pr.scenes) if (!isVideoClip(s) && s.frames_mode === "custom") { s.frames_mode = "project"; hit = true; } return hit; }) } })];
}
export function projectRows(p, slots, api) {
const open = p.project;
if (!open) return [c.emptyState.default({ icon: "▭", title: "No project", hint: "Open or create one." })];
const controls = videoControls(p, slots);
const pick = (...keys) => keys.map((k) => controls.find((x) => x.key === k)).filter(Boolean);
const frames = pick("num_frames_per_scene")[0] || num(p, "Frames / scene", "num_frames_per_scene", 97);
const fps = pick("frame_rate")[0] || num(p, "FPS", "frame_rate", 25);
const size = controls.filter((x) => x !== frames && x !== fps);
const per = (open.num_frames_per_scene || 97) / (open.frame_rate || 25);
return [
c.label.section({ text: "Project" }),
card(c.field.default({ label: "Project name", control: c.input.md({ label: "Project name", value: open.name || "", onCommit: (v) => p.rename(v) }) })),
c.label.section({ text: "Video settings" }),
card(c.field.row({ fields: [frames.field, fps.field] }),
c.hint.default({ text: `≈ ${per.toFixed(2)} s per shot` }),
size.length ? c.field.row({ fields: size.map((x) => x.field) }) : null,
...custom(p, open),
c.field.default({ label: "Final render size", hint: "The size of the exported video: the first clip's, the project's, or any rendered clip's own.", control: c.select.md({ label: "Final render size", options: sizes(open), value: open.export_size_from || "",
onChange: (v) => p.setField("export_size_from", v) }) }),
c.field.default({ label: "Start shots", hint: "From an image: a scene starts from its picture, or the last frame of the clip before it. From a prompt: every scene starts without a picture.", control: c.select.md({ label: "Start shots", options: STARTS, value: open.generation_mode === "t2v" ? "t2v" : "i2v", onChange: (v) => p.setField("generation_mode", v) }) })),
c.label.section({ text: "Prompt" }),
card(c.field.default({ label: "Anchor", hint: "Put in front of every scene's prompt.", control: c.textarea.md({ label: "Anchor", value: p.anchor, rows: 2, onInput: (v) => p.setAnchor(v) }) }),
c.field.default({ label: "Negative prompt", hint: "What to steer away from. A model that ignores negative prompts (MiniMax H3) uses it only with Shadow negative or Negative erase on.", control: c.textarea.md({ label: "Negative prompt", value: p.negative, rows: 2, onInput: (v) => p.setNegative(v) }) }),
c.toggle.default({ label: "Postfix", hint: "Appended to every scene (a style or quality tag, say).", checked: open.postfix_enabled !== false, onChange: (v) => p.setField("postfix_enabled", v) }),
c.textarea.md({ label: "Postfix", value: p.postfix, rows: 2, disabled: open.postfix_enabled === false, onInput: (v) => p.setPostfix(v) })),
c.collapsible.default({ label: "ADVANCED PROJECT SETTINGS", body: c.region.stack({ gap: "sm", children: [
c.collapsible.default({ label: "Split preview (generation prompt)", body: splitPreview(p, api) }),
] }) }),
];
}