diff --git a/app/ui/features/inspector/fold.js b/app/ui/features/inspector/fold.js new file mode 100644 index 00000000..e3c2d3ac --- /dev/null +++ b/app/ui/features/inspector/fold.js @@ -0,0 +1,9 @@ +// A collapsible group for the Properties panel. The panel redraws on most edits, so whether a group is open is +// remembered here and a redraw does not close what the person just opened. +import { composer as c } from "../../composer/composer.js"; + +const opened = new Map(); + +/** `id`: a stable name for the group. `hint`: one short line shown beside the label while it is closed. */ +export const fold = (id, label, children, hint) => c.collapsible.default({ label, hint, open: opened.get(id) === true, + onToggle: (open) => opened.set(id, open), body: c.region.stack({ gap: "sm", children: children.flat().filter(Boolean) }) }); diff --git a/app/ui/features/inspector/hub.test.js b/app/ui/features/inspector/hub.test.js index e1b14a78..20f83bc9 100644 --- a/app/ui/features/inspector/hub.test.js +++ b/app/ui/features/inspector/hub.test.js @@ -124,3 +124,20 @@ test("the Project tab offers Short side / Long side only when the pipeline fits assert.ok(shown.includes("1344") && shown.includes("768"), "the size saved under the old name shows"); host.remove(); }); + +test("a group opened in the panel stays open when the panel redraws", async () => { + let fire; + const doc = { id: "P", frame_rate: 25, num_frames_per_scene: 49, scenes: [{ id: "a", text: "x" }], scene_renders: {} }; + const p = { project: doc, get scenes() { return doc.scenes; }, get selected() { return doc.scenes[0]; }, selectedId: "a", + setText: () => {}, setScene: () => {}, edit: (fn) => fn(doc) }; + const host = document.createElement("div"); + document.body.append(host); + hub.setup({ host, app: { dom: createDom(), project: p, on: (fn) => { fire = fn; return () => {}; }, api: {}, say: () => {} } }); + const group = () => [...host.querySelectorAll("details")].find((d) => d.textContent.includes("Video input")); + assert.equal(group().open, false, "closed to begin with"); + group().open = true; + group().dispatchEvent(new window.Event("toggle")); + doc.scenes[0].excluded = true; fire("change"); + assert.equal(group().open, true); + host.remove(); +}); diff --git a/app/ui/features/inspector/project.js b/app/ui/features/inspector/project.js index 66b73028..d223dbe6 100644 --- a/app/ui/features/inspector/project.js +++ b/app/ui/features/inspector/project.js @@ -4,6 +4,7 @@ import { DRIVEN, isVideoClip, segments, snapFrames } from "../../shell/scenes.js import { gridOf } from "../../shell/frame_grid.js"; import { card } from "./scene.js"; import { projectVideoInput } from "./videoinput.js"; +import { fold } from "./fold.js"; const BASES = [{ value: "short", label: "Short side" }, { value: "long", label: "Long side" }, { value: "area", label: "Pixel count" }]; const BASIS_HINT = { short: "With Short side: the clip's shorter side is the smaller of Width and Height; the scene's picture sets the shape. 1024 × 1024 and a 16:9 picture make 1824 × 1024.", @@ -94,17 +95,16 @@ export function projectRows(p, slots, api) { flip(p, size), basis(p, slots), ...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 uses its picture, or the last frame of the clip before it. From a prompt: every scene starts without a picture. (MiniMax H3 takes only the picture's shape.)", control: c.select.md({ label: "Start shots", options: STARTS, value: open.generation_mode === "t2v" ? "t2v" : "i2v", onChange: (v) => p.setField("generation_mode", v) }) })), - ...projectVideoInput(p), 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) }), - ] }) }), + c.label.section({ text: "More" }), + fold("project-export", "Final render size", [card(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) }) }))]), + ...projectVideoInput(p), + fold("project-split", "Split preview (generation prompt)", [splitPreview(p, api)]), ]; } diff --git a/app/ui/features/inspector/scene.js b/app/ui/features/inspector/scene.js index bbf925f7..01035948 100644 --- a/app/ui/features/inspector/scene.js +++ b/app/ui/features/inspector/scene.js @@ -5,6 +5,7 @@ import { effFrames, effFps, genUnitId, isSubclip, seconds, snapFrames } from ".. import { gridOf } from "../../shell/frame_grid.js"; import { restoreToPlan } from "../../shell/edits.js"; import { sceneVideoInput } from "./videoinput.js"; +import { fold } from "./fold.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" }]; @@ -38,11 +39,11 @@ function drift(sc, open) { /** 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: [ + return [fold("scene-trim", "Source trim", [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); } }))]; + 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. */ @@ -53,7 +54,6 @@ export function sceneRows(p, app) { 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( @@ -64,7 +64,6 @@ export function sceneRows(p, app) { card(mediaField(app, p, sc, { label: "Aspect ratio source", hint: "The scene's picture (or a video's first frame). 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", "video"], empty: "— choose aspect ratio source —" }), mediaField(app, p, sc, { label: "References", key: "references", multiple: true, kinds: ["image", "video", "audio"], empty: "+ Add reference" })), - ...sceneVideoInput(p, sc), 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.view || app.pipeline.slots)())), @@ -72,7 +71,10 @@ export function sceneRows(p, app) { ] }), drift(sc, open), c.button.md({ label: "Generate this scene", tone: "primary", onClick: () => app.say("generate.scene") })), + c.label.section({ text: "More" }), ...trimRows(p, sc, open), + ...sceneVideoInput(p, sc), 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) })), + c.banner.info({ text: "Made with the project's pipeline and Engine settings", action: { label: "Engine settings →", onClick: () => app.openSettings && app.openSettings("engine") } }), ]; } diff --git a/app/ui/features/inspector/videoinput.js b/app/ui/features/inspector/videoinput.js index 857e1847..52945bfb 100644 --- a/app/ui/features/inspector/videoinput.js +++ b/app/ui/features/inspector/videoinput.js @@ -2,6 +2,7 @@ // One card for the project and one per scene; the project's dropdown says whose is used. import { composer as c } from "../../composer/composer.js"; import { card } from "./scene.js"; +import { fold } from "./fold.js"; import { BLANK, FITS, FROM, RATES, activeVideoInput } from "../../shell/video_input.js"; /** `use`: the settings shown; `save(next)` stores them. `size`: the project's width/height, offered as a one-tap target. */ @@ -32,10 +33,10 @@ export function projectVideoInput(p) { const open = p.project, use = { ...BLANK, ...(open.video_input || {}) }; const v = open.video || {}, size = [v.width || v.target_width || open.width, v.height || v.target_height || open.height]; return [ - c.label.section({ text: "Video input" }), + fold("project-video-input", "Video input", [ card(c.field.default({ label: "Reference videos use", hint: "Where the preparation comes from. Automatic: the project's when it is on, otherwise each scene's own.", control: c.select.md({ label: "Reference videos use", options: FROM, value: open.video_input_from || "auto", onChange: (x) => p.setField("video_input_from", x) }) })), - settingsCard(use, (next) => p.setField("video_input", next), size[0] && size[1] ? size : null), + settingsCard(use, (next) => p.setField("video_input", next), size[0] && size[1] ? size : null)], use.on ? "On" : "Off"), ]; } @@ -43,11 +44,12 @@ export function sceneVideoInput(p, sc) { const open = p.project, use = { ...BLANK, ...(sc.video_input || {}) }, live = activeVideoInput(open, sc); const from = open.video_input_from || "auto"; return [ - c.label.section({ text: "Video input" }), + fold("scene-video-input", "Video input", [ from === "project" || (from === "auto" && (open.video_input || {}).on) ? c.hint.default({ text: "The project's video input settings are used; this scene's own are ignored. Change that in Project settings." }) : null, settingsCard(use, (next) => p.setScene(sc.id, "video_input", next), null), live ? null : c.hint.default({ text: "Switched off: reference videos load whole at 24 fps." }), - ].filter(Boolean); + ], live ? "On" : "Off"), + ]; }