feat: length fields and timeline trims/cuts follow the model's frame grid, read from its length input (H3 17k+5: 22, 39, 56…)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
b015ce3f87
commit
54ca164926
@@ -4,6 +4,7 @@ import { effFrames, effFps, genUnitId } from "../../shell/scenes.js";
|
||||
import { sceneRows } from "./scene.js";
|
||||
import { bin } from "../../shell/bin.js";
|
||||
import { projectRows } from "./project.js";
|
||||
import { gridOf, learnGrid } from "../../shell/frame_grid.js";
|
||||
|
||||
export default {
|
||||
id: "inspector",
|
||||
@@ -32,8 +33,9 @@ export default {
|
||||
// between its mouse-down and mouse-up dropped the click; the change it makes redraws afterwards anyway.
|
||||
body.node.addEventListener("focusout", (e) => { if (!(e.relatedTarget && body.node.contains(e.relatedTarget))) setTimeout(() => { if (!typing()) draw(); }); });
|
||||
function draw(force) { // not on every keystroke: that would rebuild the box being typed in
|
||||
const sc = p.selected, open = p.project;
|
||||
const next = `${tab}|${open && open.id}|${p.selectedId}|${tab === "project" && open ? [open.num_frames_per_scene, open.frame_rate, JSON.stringify(open.video || {}), open.postfix_enabled !== false, open.generation_mode, open.export_size_from, open.width, open.height, open.scenes.map((s) => s.frames_mode === "custom"), Object.keys(open.scene_renders || {}), open.scenes.length] : ""}|${tab === "scene" && sc && open ? [effFrames(sc, open), effFps(sc, open), p.scenes.indexOf(sc), sc.frames_mode, sc.fps_mode, p.scenes.length, sc.source_image, (sc.references || []).join(), sc.source_in, sc.source_dur, sc.removed_from_plan, (sc.source || {}).type, sc.excluded, typing() ? shownText : (shownText = sc.text), ((open.scene_renders || {})[sc.id] || {}).durationSec, Boolean(((open.scene_renders || {})[sc.id] || {}).media), bin.version, ...sections.map((s) => { try { return s.key(sc, open); } catch { return ""; } })] : ""}`;
|
||||
const sc = p.selected, open = p.project, slots = app.pipeline && app.pipeline.slots();
|
||||
learnGrid(app.api, slots).then((learned) => { if (learned) draw(true); }); // the length arrows follow the model's frame grid once its node is described
|
||||
const next = `${tab}|${open && open.id}|${p.selectedId}|${JSON.stringify(gridOf(slots))}|${tab === "project" && open ? [open.num_frames_per_scene, open.frame_rate, JSON.stringify(open.video || {}), open.postfix_enabled !== false, open.generation_mode, open.export_size_from, open.width, open.height, open.scenes.map((s) => s.frames_mode === "custom"), Object.keys(open.scene_renders || {}), open.scenes.length] : ""}|${tab === "scene" && sc && open ? [effFrames(sc, open), effFps(sc, open), p.scenes.indexOf(sc), sc.frames_mode, sc.fps_mode, p.scenes.length, sc.source_image, (sc.references || []).join(), sc.source_in, sc.source_dur, sc.removed_from_plan, (sc.source || {}).type, sc.excluded, typing() ? shownText : (shownText = sc.text), ((open.scene_renders || {})[sc.id] || {}).durationSec, Boolean(((open.scene_renders || {})[sc.id] || {}).media), bin.version, ...sections.map((s) => { try { return s.key(sc, open); } catch { return ""; } })] : ""}`;
|
||||
titled();
|
||||
if (!force && next === key) return;
|
||||
key = next;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// The Project tab: what the whole project makes.
|
||||
import { composer as c } from "../../composer/composer.js";
|
||||
import { DRIVEN, isVideoClip, segments } from "../../shell/scenes.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" }];
|
||||
@@ -11,9 +12,11 @@ const videoControls = (p, slots) => (slots || []).flatMap((slot) => (slot.roles
|
||||
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;
|
||||
return { key: field || role.input, label, field: c.field.default({ label, control: c.number.md({ label, min: 1, max: role.drives === "fps" ? 1000 : 16384, step: 1, precision: 0, value: now,
|
||||
onChange: (v) => (field ? p.setField(field, v) : p.setVideo(role.input, v)) }) }) };
|
||||
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,
|
||||
onChange: (v) => { if (grid) v = snapFrames(v, "round", grid); return 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,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// 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 } from "../../shell/scenes.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" }];
|
||||
@@ -18,12 +19,12 @@ const mediaField = (app, p, sc, { label, hint, key, multiple, kinds, empty }) =>
|
||||
};
|
||||
|
||||
/** A number-or-mode pair: "Project default / Timeline trim / Custom", and the number only when Custom. */
|
||||
const lengthField = (p, sc, label, modeKey, valueKey, shown) => {
|
||||
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: 1, max: 16384, precision: 0, value: sc[valueKey] ?? shown,
|
||||
onChange: (v) => p.setScene(sc.id, valueKey, v) }) : null,
|
||||
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,
|
||||
onChange: (v) => p.setScene(sc.id, valueKey, grid ? snapFrames(v, "round", grid) : v) }) : null,
|
||||
] }) });
|
||||
};
|
||||
|
||||
@@ -63,7 +64,7 @@ export function sceneRows(p, app) {
|
||||
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)),
|
||||
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),
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
// that act on the cut, the zoom controls, and the count in the zone's head. They share only the app's
|
||||
// selection, playhead and message bus.
|
||||
import { composer as c } from "../../composer/composer.js";
|
||||
import { segments, totalSeconds, clock } from "../../shell/scenes.js";
|
||||
import { segments, totalSeconds, clock, LTX_GRID } from "../../shell/scenes.js";
|
||||
import { gridOf } from "../../shell/frame_grid.js";
|
||||
|
||||
const gridFor = (app) => gridOf(app.pipeline && app.pipeline.slots()) || LTX_GRID; // trims and cuts land on frame counts this model can make
|
||||
import { inPlace } from "../../shell/place.js";
|
||||
import * as edits from "../../shell/edits.js";
|
||||
import { videoLane, audioLane, framesAt } from "./lanes.js";
|
||||
@@ -30,7 +33,7 @@ const stage = {
|
||||
if (own) return own.trim(id, edge, d);
|
||||
if (edge === "slip") return edit((pr) => Boolean(((pr.scene_renders || {})[id] || {}).media) && edits.slip(pr, id, d)); // slides what plays; only a rendered clip has more to show
|
||||
const dur = segments(p.project).find((s) => s.id === id).dur;
|
||||
edit((pr) => edge === "out" ? edits.resize(pr, id, dur + d) : d > 0 && edits.trimLeft(pr, id, d)); // the left edge only cuts in
|
||||
edit((pr) => edge === "out" ? edits.resize(pr, id, dur + d, gridFor(app)) : d > 0 && edits.trimLeft(pr, id, d, gridFor(app))); // the left edge only cuts in
|
||||
},
|
||||
onReorder: (id, index) => edit((pr) => { // the stage counts ghosts and pauses too; the cut counts scenes
|
||||
const all = segments(pr).filter((s) => s.id !== id);
|
||||
@@ -71,7 +74,7 @@ const tools = {
|
||||
const seg = segments(p.project).find((s) => s.id === sel.focus && s.kind === "scene");
|
||||
if (!seg) return;
|
||||
const inside = head.at > seg.start && head.at < seg.start + seg.dur;
|
||||
if (!p.edit((pr) => edits.split(pr, seg.id, inside ? framesAt(seg.scene, pr, head.at - seg.start) : undefined))) c.toast.warn({ text: "Too close to the clip's edge to cut there." });
|
||||
if (!p.edit((pr) => edits.split(pr, seg.id, inside ? framesAt(seg.scene, pr, head.at - seg.start) : undefined, gridFor(app)))) c.toast.warn({ text: "Too close to the clip's edge to cut there." });
|
||||
};
|
||||
const draw = () => {
|
||||
const open = p.project, ids = open ? sel.ids : [];
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
// The frame counts the current model can make, read from its own length input (min + step): MiniMax H3's node says
|
||||
// min 5 step 17 (22, 39, 56 …), LTX's min 1 step 8 (9, 17, 25 …). No family is named here: the node is the truth.
|
||||
const specs = {};
|
||||
const frameInputs = (slots) => (slots || []).flatMap((s) => (s.roles || []).filter((r) => r.drives === "frames" && r.input).map((r) => ({ node: s.node, input: r.input })));
|
||||
|
||||
/** {step, base} of the strictest frames input, or null when none steps by more than 1 (or its node is not described yet). */
|
||||
export function gridOf(slots) {
|
||||
let best = null;
|
||||
for (const { node, input } of frameInputs(slots)) {
|
||||
const w = ((specs[node] || {}).widgets || []).find((x) => x.name === input);
|
||||
const step = w && Number(w.step), min = w ? Number(w.min) || 0 : 0;
|
||||
if (step > 1 && (!best || step > best.step)) best = { step, base: ((min % step) + step) % step, fps: null };
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** Asks for the length inputs' node descriptions not known yet. -> true when something new was learned. */
|
||||
export async function learnGrid(api, slots) {
|
||||
const missing = [...new Set(frameInputs(slots).map((f) => f.node))].filter((n) => n && !(n in specs));
|
||||
if (!missing.length || !api || !api.describeNodes) return false;
|
||||
try { Object.assign(specs, (await api.describeNodes(missing)).nodes || {}); } catch { missing.forEach((n) => { specs[n] = null; }); }
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { gridOf, learnGrid } from "./frame_grid.js";
|
||||
|
||||
test("the frame grid is read from the model's own length input: H3 17k+5, LTX 8k+1, a plain count none", async () => {
|
||||
const slots = [{ id: "r2v", node: "H3Node", roles: [{ at: "project.video", input: "length", drives: "frames" }] },
|
||||
{ id: "cam", node: "Plain", roles: [{ at: "project.video", input: "length", drives: "frames" }] }];
|
||||
const api = { describeNodes: async () => ({ nodes: { H3Node: { widgets: [{ name: "length", min: 5, step: 17 }] }, Plain: { widgets: [{ name: "length", min: 1, step: 1 }] } } }) };
|
||||
assert.equal(gridOf(slots), null, "not described yet: no grid, nothing snaps");
|
||||
assert.equal(await learnGrid(api, slots), true);
|
||||
assert.deepEqual(gridOf(slots), { step: 17, base: 5, fps: null }, "the strictest input wins");
|
||||
assert.equal(await learnGrid(api, slots), false, "asked once");
|
||||
const ltx = [{ node: "LtxNode", roles: [{ drives: "frames", input: "length" }] }];
|
||||
await learnGrid({ describeNodes: async () => ({ nodes: { LtxNode: { widgets: [{ name: "length", min: 1, step: 8 }] } } }) }, ltx);
|
||||
assert.deepEqual(gridOf(ltx), { step: 8, base: 1, fps: null });
|
||||
});
|
||||
Reference in New Issue
Block a user