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:
@@ -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) }) });
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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)]),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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") } }),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user