Properties: Project and Scene tabs keep the everyday fields up front; Video input, trim, final render size and split preview sit in groups that stay open across redraws

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tw9fjV2UVj2u3F3mbnYrQW
This commit is contained in:
Claude
2026-11-05 10:23:25 +00:00
parent d42bcd269f
commit 4a028717e6
5 changed files with 44 additions and 14 deletions
+9
View File
@@ -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) }) });
+17
View File
@@ -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();
});
+6 -6
View File
@@ -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)]),
];
}
+6 -4
View File
@@ -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") } }),
];
}
+6 -4
View File
@@ -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"),
];
}