From 54ca164926935e331edde3fb766fa6966b9cdeda Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Wed, 7 Oct 2026 14:58:58 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20length=20fields=20and=20timeline=20trim?= =?UTF-8?q?s/cuts=20follow=20the=20model's=20frame=20grid,=20read=20from?= =?UTF-8?q?=20its=20length=20input=20(H3=2017k+5:=2022,=2039,=2056?= =?UTF-8?q?=E2=80=A6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- app/ui/features/inspector/index.js | 6 ++++-- app/ui/features/inspector/project.js | 11 +++++++---- app/ui/features/inspector/scene.js | 11 ++++++----- app/ui/features/timeline/index.js | 9 ++++++--- app/ui/shell/frame_grid.js | 23 +++++++++++++++++++++++ app/ui/shell/frame_grid.test.js | 16 ++++++++++++++++ 6 files changed, 62 insertions(+), 14 deletions(-) create mode 100644 app/ui/shell/frame_grid.js create mode 100644 app/ui/shell/frame_grid.test.js diff --git a/app/ui/features/inspector/index.js b/app/ui/features/inspector/index.js index a7d6a710..c78251ba 100644 --- a/app/ui/features/inspector/index.js +++ b/app/ui/features/inspector/index.js @@ -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; diff --git a/app/ui/features/inspector/project.js b/app/ui/features/inspector/project.js index 0fd7f7e2..7eb68893 100644 --- a/app/ui/features/inspector/project.js +++ b/app/ui/features/inspector/project.js @@ -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, diff --git a/app/ui/features/inspector/scene.js b/app/ui/features/inspector/scene.js index 690dc75e..9a1d000f 100644 --- a/app/ui/features/inspector/scene.js +++ b/app/ui/features/inspector/scene.js @@ -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), diff --git a/app/ui/features/timeline/index.js b/app/ui/features/timeline/index.js index 81ae649b..56ca1924 100644 --- a/app/ui/features/timeline/index.js +++ b/app/ui/features/timeline/index.js @@ -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 : []; diff --git a/app/ui/shell/frame_grid.js b/app/ui/shell/frame_grid.js new file mode 100644 index 00000000..6bee9470 --- /dev/null +++ b/app/ui/shell/frame_grid.js @@ -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; +} diff --git a/app/ui/shell/frame_grid.test.js b/app/ui/shell/frame_grid.test.js new file mode 100644 index 00000000..a35c5980 --- /dev/null +++ b/app/ui/shell/frame_grid.test.js @@ -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 }); +});