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

77 lines
6.5 KiB
JavaScript

// The Scene tab: this clip's prompt, where it starts from, how long it runs.
import { composer as c } from "../../composer/composer.js";
import { namesOf, pick } from "./mediapick.js";
import { effFrames, effFps, genUnitId, isSubclip, seconds, snapFrames } from "../../shell/scenes.js";
import { gridOf } from "../../shell/frame_grid.js";
import { restoreToPlan } from "../../shell/edits.js";
const SOURCES = [{ value: "image", label: "Image · i2v anchor" }, { value: "carry", label: "From generated frame" }, { value: "video", label: "Video clip" }];
const MODES = [{ value: "project", label: "Project default" }, { value: "timeline", label: "Timeline trim" }, { value: "custom", label: "Custom" }];
/** A field that holds media from the bin: what is chosen, Browse to change it, ✕ to clear it. */
const mediaField = (app, p, sc, { label, hint, key, multiple, kinds, empty }) => {
const ids = multiple ? sc[key] || [] : sc[key] ? [sc[key]] : [];
const set = (next) => p.setScene(sc.id, key, multiple ? next : next[0] || null);
return c.field.default({ label, hint, control: c.toolbar.default({
items: [c.text.sm({ text: ids.length ? `◇ ${namesOf(ids, app.api, () => app.say("bin")).join(", ")}` : `◇ ${empty}` })],
trailing: [c.button.sm({ label: "Browse", tone: "ghost", onClick: async () => { const got = await pick(app.api, { title: label, kinds, multiple, current: ids }); if (got) set(got); } }),
c.button.sm({ label: "✕", tone: "ghost", disabled: !ids.length, title: "Clear", onClick: () => set([]) })] }) });
};
/** A number-or-mode pair: "Project default / Timeline trim / Custom", and the number only when Custom. */
const lengthField = (p, sc, label, modeKey, valueKey, shown, grid) => {
const mode = sc[modeKey] || "project";
return c.field.default({ label, control: c.region.stack({ gap: "xs", children: [
c.select.md({ label, options: MODES, value: mode, onChange: (v) => p.setScene(sc.id, modeKey, v) }),
mode === "custom" ? c.number.md({ label: `${label} (custom)`, min: grid ? grid.step + grid.base : 1, max: 16384, step: grid ? grid.step : 1, precision: 0, value: sc[valueKey] ?? shown,
snap: grid ? (v) => snapFrames(v, "round", grid) : undefined, onChange: (v) => p.setScene(sc.id, valueKey, v) }) : null,
] }) });
};
/** "Rendered ≈ Xs · plan Ys — Regenerate to apply" when the render was made at another length than the plan now says. */
function drift(sc, open) {
const plan = effFrames(sc, open) / effFps(sc, open), shown = seconds(sc, open);
return c.hint.default({ text: Math.abs(shown - plan) > 0.05 ? `Rendered ≈ ${shown.toFixed(2)}s · plan ${plan.toFixed(2)}s — Regenerate to apply` : `Duration ≈ ${plan.toFixed(2)}s · trim on the timeline` });
}
/** The slip: where in the rendered file this clip starts and how much of it plays. Only a clip that has a render has one. */
function trimRows(p, sc, open) {
if (!((open.scene_renders || {})[sc.id] || {}).media) return [];
return [c.label.section({ text: "Source trim (slip)" }), card(c.field.row({ fields: [
c.field.default({ label: "Source in (s)", control: c.number.md({ label: "Source in", value: sc.source_in || 0, min: 0, step: 0.05, onChange: (v) => p.setScene(sc.id, "source_in", v) }) }),
c.field.default({ label: "Source dur (s, 0 = full)", control: c.number.md({ label: "Source duration", value: sc.source_dur != null ? sc.source_dur : 0, min: 0, step: 0.05,
snap: (v, was) => (v > 0 && v < 0.1 ? (v < was ? 0 : 0.1) : v), onChange: (v) => p.setScene(sc.id, "source_dur", v > 0 ? v : null) }) })] }), // 0 = full; nothing shorter than 0.1 s
c.button.sm({ label: "Reset source trim", tone: "ghost", disabled: !sc.source_in && sc.source_dur == null, onClick: () => { p.setScene(sc.id, "source_in", 0); p.setScene(sc.id, "source_dur", null); } }))];
}
/** A group of fields on one rounded card. */
export const card = (...children) => { const n = c.region.stack({ gap: "md", children: children.flat().filter(Boolean) }); n.node.classList.add("fp-card"); return n; };
export function sceneRows(p, app) {
const sc = p.selected, open = p.project;
if (!sc) return [c.emptyState.default({ icon: "▭", title: "No scene", hint: "Add one on the timeline." })];
const cuts = p.scenes.filter((s) => genUnitId(s) === genUnitId(sc)).length > 1;
return [
c.banner.info({ text: "Made with the project's pipeline and Engine settings", action: { label: "Engine settings →", onClick: () => app.openSettings && app.openSettings("engine") } }),
...(sc.removed_from_plan ? [c.banner.info({ text: "Removed from plan — its generated clip stays on the timeline.", action: { label: "Restore to plan", onClick: () => p.edit((pr) => restoreToPlan(pr, sc.id)) } })] : []),
...(cuts ? [c.hint.default({ text: "This scene has editorial cuts — Generate regens the whole uncut scene." })] : []),
...(isSubclip(sc) ? [c.hint.default({ text: "This is a cut: its prompt and source belong to the first part." })] : [card(
c.field.default({ label: "Prompt", control: c.textarea.md({ label: "Prompt", value: sc.text || "", rows: 4, onInput: (v) => p.setText(sc.id, v) }) }),
c.field.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 }) }) }))]),
c.label.section({ text: "Pictures" }),
card(mediaField(app, p, sc, { label: "Aspect ratio source", hint: "The scene's picture. Its shape sets this scene's aspect ratio (the project's Width/Height set the scale). A model that starts from a picture, such as LTX image-to-video, also begins the clip from it; the prompt enhancer can read it.",
key: "source_image", multiple: false, kinds: ["image"], empty: "— choose aspect ratio source —" }),
mediaField(app, p, sc, { label: "References", key: "references", multiple: true, kinds: ["image", "video", "audio"], empty: "+ Add reference" })),
c.label.section({ text: "Length" }),
card(c.field.row({ fields: [
lengthField(p, sc, "Frames", "frames_mode", "frames", effFrames(sc, open), gridOf(app.pipeline && app.pipeline.slots())),
lengthField(p, sc, "FPS", "fps_mode", "fps", effFps(sc, open)),
] }),
drift(sc, open),
c.button.md({ label: "Generate this scene", tone: "primary", onClick: () => app.say("generate.scene") })),
...trimRows(p, sc, open),
card(c.checkboxRow.default({ label: "Exclude from full generation", hint: "Generate leaves this scene out.", checked: Boolean(sc.excluded), onChange: (v) => p.setScene(sc.id, "excluded", v) })),
];
}