// 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) }), ] }) }), ]; }