feat(ui): timeline stage kit element, scene model, edits and selection services

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-10-03 19:11:17 +03:00
co-authored by Claude Sonnet 5.5
parent b44f031c6f
commit e861f2ddbf
12 changed files with 730 additions and 0 deletions
+7
View File
@@ -339,6 +339,13 @@ export const DEMOS = {
{ at: 0, label: "1", hint: "0" }, { at: 0.4, label: "2", hint: "96" },
{ at: 0.75, label: "3", hint: "180" },
] },
"timeline.stage": { pxPerSecond: 60, lanes: [
{ id: "video", label: "Video", kind: "video", reorder: true, clips: [
{ id: "a", start: 0, dur: 3, head: ["▶", "00:03"], title: "01", body: "empty scene", selected: true, trim: true },
{ id: "b", start: 3, dur: 2, head: ["▦", "00:02"], title: "02", trim: true },
] },
{ id: "audio", label: "Audio", kind: "audio", clips: [{ id: "s", start: 0, dur: 3, title: "S1" }] },
] },
"track.default": { label: "Timeline", pxPerSecond: 40, playhead: 6.5, selection: ["2"], items: [
{ id: "1", icon: "▦", label: "Scene 1", badge: "1", start: 0, duration: 4 },
{ id: "2", icon: "▦", label: "Scene 2", badge: "2", start: 4, duration: 6, rating: "liked" },
+1
View File
@@ -15,4 +15,5 @@
@import "./elements/overlay.css";
@import "./elements/gallery.css";
@import "./elements/track.css";
@import "./elements/nle.css";
@import "./elements/wheel.css";
+1
View File
@@ -17,4 +17,5 @@ import "./popover.js";
import "./floating.js";
import "./gallery.js";
import "./track.js";
import "./nle.js";
import "./wheel.js";
+59
View File
@@ -0,0 +1,59 @@
/* The cutting-room timeline. One sticky gutter, one time origin shared by the ruler and every lane. */
.cx-nle { block-size: 100%; min-block-size: 0; overflow: auto; background: var(--elev-1); }
.cx-nle-stage { display: flex; min-inline-size: 100%; position: relative; }
.cx-nle-gutter {
position: sticky; inset-inline-start: 0; z-index: 3; flex: none; inline-size: 56px;
background: var(--elev-1); border-inline-end: var(--bw) solid var(--line);
}
.cx-nle-gutter-ruler { block-size: var(--sp-9); border-block-end: var(--bw) solid var(--line); }
.cx-nle-gutter-lane {
display: flex; align-items: center; justify-content: flex-end; padding-inline-end: var(--sp-3);
font-size: var(--fs-6); color: var(--faint); border-block-end: var(--bw) solid var(--line);
}
.cx-nle-lane-video { block-size: 96px; }
.cx-nle-lane-audio { block-size: 44px; }
.cx-nle-lane-overlay { block-size: 40px; }
.cx-nle-content { position: relative; flex: none; min-inline-size: 0; }
.cx-nle-ruler { position: relative; block-size: var(--sp-9); border-block-end: var(--bw) solid var(--line); }
.cx-nle-tick {
position: absolute; inset-block-start: 0; padding-inline-start: var(--sp-2);
border-inline-start: var(--bw) solid var(--line); font-size: var(--fs-7); line-height: var(--sp-9);
color: var(--faint); font-variant-numeric: tabular-nums; user-select: none;
}
.cx-nle-lane { position: relative; border-block-end: var(--bw) solid var(--line); }
.cx-nle-clip {
position: absolute; inset-block: var(--sp-2); box-sizing: border-box; overflow: hidden; cursor: grab;
display: flex; flex-direction: column; gap: var(--sp-1); padding: var(--sp-2);
border: var(--bw) solid var(--line-hover); border-radius: var(--r-3);
background: linear-gradient(var(--elev-3), var(--elev-2)); color: var(--text);
touch-action: none; user-select: none;
}
.cx-nle-clip:hover { border-color: var(--muted); }
.cx-nle-clip.cx-on { border-color: var(--accent); background: linear-gradient(var(--accent-soft), var(--elev-2)); }
.cx-nle-clip.cx-focus { box-shadow: inset 0 0 0 1px var(--accent); }
.cx-nle-clip.cx-excluded { opacity: 0.45; }
.cx-nle-clip.cx-ghost { border-style: dashed; opacity: 0.6; }
.cx-nle-clip[data-rating="liked"] { border-color: var(--good); }
.cx-nle-clip[data-rating="disliked"] { border-color: var(--danger); }
.cx-nle-clip-audio, .cx-nle-clip-overlay { padding-block: 0; justify-content: center; border-color: color-mix(in srgb, var(--teal) 40%, var(--line)); }
.cx-nle-clip-head { display: flex; align-items: center; gap: var(--sp-2); min-inline-size: 0; font-size: var(--fs-7); color: var(--muted); }
.cx-nle-clip-tag { white-space: nowrap; font-variant-numeric: tabular-nums; }
.cx-nle-clip-actions { margin-inline-start: auto; display: flex; gap: var(--sp-1); }
.cx-nle-clip-action { border: 0; background: transparent; color: var(--faint); font-size: var(--fs-7); cursor: pointer; padding: 0 var(--sp-1); border-radius: var(--r-2); }
.cx-nle-clip-action:hover { color: var(--text); background: var(--hover); }
.cx-nle-clip-title { font-size: var(--fs-6); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cx-nle-clip-body { font-size: var(--fs-6); color: var(--faint); font-style: italic; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cx-nle-strip { display: flex; flex: 1 1 auto; min-block-size: 0; overflow: hidden; }
.cx-nle-thumb { block-size: 100%; inline-size: auto; flex: none; object-fit: cover; }
.cx-nle-trim { position: absolute; inset-block: 0; inline-size: var(--sp-3); cursor: ew-resize; z-index: 1; }
.cx-nle-trim-in { inset-inline-start: 0; }
.cx-nle-trim-out { inset-inline-end: 0; }
.cx-nle-trim:hover { background: var(--accent-soft); }
.cx-nle-playhead {
position: absolute; inset-block: 0; inline-size: 0; z-index: 2; pointer-events: none;
border-inline-start: 2px solid var(--accent-hi);
}
+157
View File
@@ -0,0 +1,157 @@
// The cutting-room timeline: a ruler in seconds over lanes of clips, a sticky gutter naming each lane, and a
// playhead through all of it. Draws and reports -- what a clip IS, and what a drag MEANS, belong to the feature.
//
// lanes: [{ id, label, kind: "video" | "audio" | "overlay", reorder?, clips: [clip] }]
// clip: { id, start, dur, title?, head?: [text], badge?, body?, thumbs?: [url],
// actions?: [{ label, title, onClick }], selected?, focus?, excluded?, ghost?, rating?, trim? }
// callbacks: onSeek(sec) onSelect(id, { additive, range }) onTrim(id, "in"|"out", deltaSec) onReorder(id, index)
import { define } from "../internals/register.js";
import { el } from "../internals/el.js";
import { drag } from "../internals/drag.js";
const NICE = [0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 900, 1800, 3600];
const MIN_TICK_PX = 64;
const MIN_CLIP_PX = 12;
const TAIL_SECONDS = 2; // room past the last clip to drop or seek into
const MOVE_THRESHOLD_PX = 12; // a press that travels less than this is a click, not a reorder
const tickEvery = (px) => NICE.find((s) => s * px >= MIN_TICK_PX) || NICE[NICE.length - 1];
const clock = (s) => {
const whole = Math.max(0, Math.floor(s));
return `${String(Math.floor(whole / 60)).padStart(2, "0")}:${String(whole % 60).padStart(2, "0")}`;
};
define("timeline", "stage", ({ pxPerSecond = 80, label = "Timeline", lanes: initial = [], onSeek, onSelect, onTrim, onReorder } = {}) => {
let px = pxPerSecond;
let lanes = initial;
let playhead = 0;
let disposers = [];
const gutter = el("div", { cls: "cx-nle-gutter" });
const ruler = el("div", { cls: "cx-nle-ruler" });
const laneBox = el("div", { cls: "cx-nle-lanes" });
const head = el("div", { cls: "cx-nle-playhead", attrs: { "aria-hidden": "true" } });
const content = el("div", { cls: "cx-nle-content", children: [ruler, laneBox, head] });
const stage = el("div", { cls: "cx-nle-stage", children: [gutter, content] });
const node = el("div", { cls: "cx-nle", attrs: { role: "application", "aria-label": label }, children: [stage] });
const spanOf = () => Math.max(1, ...lanes.flatMap((l) => l.clips.map((c) => c.start + c.dur))) + TAIL_SECONDS;
const timeAt = (clientX) => Math.max(0, (clientX - content.getBoundingClientRect().left) / px);
function drawRuler(span) {
ruler.replaceChildren();
const every = tickEvery(px);
for (let s = 0; s <= span; s += every) {
const tick = el("span", { cls: "cx-nle-tick", text: clock(s) });
tick.style.insetInlineStart = `${s * px}px`;
ruler.append(tick);
}
}
function clipNode(lane, clip) {
const parts = [];
if (clip.head || clip.actions) {
parts.push(el("div", { cls: "cx-nle-clip-head", children: [
...(clip.head || []).map((t) => el("span", { cls: "cx-nle-clip-tag", text: t })),
el("span", { cls: "cx-nle-clip-actions", children: (clip.actions || []).map((a) => el("button", {
cls: ["cx-nle-clip-action", "cx-focusable"], text: a.label,
attrs: { type: "button", title: a.title, "data-action": "" },
on: { click: (e) => { e.stopPropagation(); a.onClick(); } },
})) }),
] }));
}
if (clip.thumbs && clip.thumbs.length) {
parts.push(el("div", { cls: "cx-nle-strip", children: clip.thumbs.map((url) => el("img", { cls: "cx-nle-thumb", attrs: { src: url, alt: "", loading: "lazy" } })) }));
}
if (clip.title) parts.push(el("div", { cls: "cx-nle-clip-title", text: clip.title }));
if (clip.body) parts.push(el("div", { cls: "cx-nle-clip-body", text: clip.body }));
if (clip.trim) {
for (const edge of ["in", "out"]) parts.push(el("span", { cls: ["cx-nle-trim", `cx-nle-trim-${edge}`], attrs: { "data-trim": edge } }));
}
const cell = el("div", {
cls: ["cx-nle-clip", `cx-nle-clip-${lane.kind}`, clip.selected ? "cx-on" : null, clip.focus ? "cx-focus" : null,
clip.excluded ? "cx-excluded" : null, clip.ghost ? "cx-ghost" : null, clip.rating ? "cx-rated" : null],
attrs: { role: "option", tabindex: "0", "aria-selected": String(Boolean(clip.selected)), "aria-label": clip.title || clip.id,
"data-id": clip.id, "data-rating": clip.rating || undefined },
children: parts,
});
cell.style.insetInlineStart = `${clip.start * px}px`;
cell.style.width = `${Math.max(MIN_CLIP_PX, clip.dur * px)}px`;
cell.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onSelect && onSelect(clip.id, {}); } });
// Trim handles first, then the body: a press on a handle must not also start a reorder.
cell.querySelectorAll("[data-trim]").forEach((h) => disposers.push(drag(h, {
onStart: ({ event }) => event.stopPropagation(),
onMove: ({ dx }) => {
const edge = h.dataset.trim;
const dur = Math.max(0.1, clip.dur + (edge === "out" ? dx : -dx) / px);
cell.style.width = `${Math.max(MIN_CLIP_PX, dur * px)}px`;
if (edge === "in") cell.style.insetInlineStart = `${(clip.start + (clip.dur - dur)) * px}px`;
},
onEnd: ({ dx, cancelled }) => {
if (!cancelled && onTrim && dx) onTrim(clip.id, h.dataset.trim, dx / px);
draw();
},
})));
let moved = false;
disposers.push(drag(cell, {
onStart: ({ event }) => { if (event.target.closest("[data-action],[data-trim]")) { moved = null; return; } moved = false; },
onMove: ({ dx }) => {
if (moved === null) return;
if (Math.abs(dx) >= MOVE_THRESHOLD_PX && lane.reorder) moved = true;
if (moved) cell.style.transform = `translateX(${dx}px)`;
},
onEnd: ({ dx, event, cancelled }) => {
cell.style.transform = "";
if (moved === null || cancelled) return;
if (!moved) {
if (onSelect) onSelect(clip.id, { additive: event.metaKey || event.ctrlKey, range: event.shiftKey });
return;
}
// Dropped where its middle now sits among the lane's other clips.
const mid = clip.start + clip.dur / 2 + dx / px;
const others = lane.clips.filter((c) => c.id !== clip.id);
const index = others.filter((c) => c.start + c.dur / 2 < mid).length;
if (onReorder) onReorder(clip.id, index);
draw();
},
}));
return cell;
}
function draw() {
disposers.forEach((d) => d());
disposers = [];
const span = spanOf();
content.style.width = `${span * px}px`;
drawRuler(span);
gutter.replaceChildren(el("div", { cls: "cx-nle-gutter-ruler" }));
laneBox.replaceChildren();
for (const lane of lanes) {
gutter.append(el("div", { cls: ["cx-nle-gutter-lane", `cx-nle-lane-${lane.kind}`], text: lane.label }));
const row = el("div", { cls: ["cx-nle-lane", `cx-nle-lane-${lane.kind}`], attrs: { role: "listbox", "aria-label": lane.label }, children: lane.clips.map((c) => clipNode(lane, c)) });
laneBox.append(row);
}
head.style.insetInlineStart = `${Math.max(0, playhead) * px}px`;
}
// The ruler and every lane's empty space seek; a clip's own press selects instead.
content.addEventListener("click", (e) => {
if (e.target.closest(".cx-nle-clip") || !onSeek) return;
onSeek(Math.min(spanOf(), timeAt(e.clientX)));
});
draw();
return {
node,
get span() { return spanOf(); },
get pxPerSecond() { return px; },
timeAt,
setLanes(next = []) { lanes = next; draw(); },
setZoom(next) { px = Math.max(4, next); draw(); },
setPlayhead(seconds) { playhead = seconds; head.style.insetInlineStart = `${Math.max(0, seconds) * px}px`; },
destroy() { disposers.forEach((d) => d()); node.remove(); },
};
});
+195
View File
@@ -0,0 +1,195 @@
// Changes to a project's scenes, as plain functions on the open project: `project.edit((p) => addScene(p))`.
// Each mutates in place and answers truthy when it changed something. They know what a scene IS (gen units,
// ghosts, the cut order) and nothing about the screen.
import {
genUnitId, isVideoClip, isSubclip, effFps, effFrames, seconds, snapFrames, LTX_GRID,
} from "./scenes.js";
const clone = (x) => JSON.parse(JSON.stringify(x));
const newId = () => "c" + Math.random().toString(36).slice(2, 10) + Date.now().toString(36);
const index = (p, id) => (p.scenes || []).findIndex((s) => s.id === id);
/** A fresh, empty scene on the end of the plan. */
export function addScene(p, sourceType = "carry") {
const sc = { id: newId(), text: "", transition_to_next: "", source: { type: sourceType }, excluded: false, frames_mode: "project", fps_mode: "project" };
p.scenes.push(sc);
return sc;
}
// A scene that had a render leaves a ghost clip behind; one that never rendered just goes.
function ghostOrDrop(p, sc, afterId, pending) {
const renders = p.scene_renders || (p.scene_renders = {});
const r = renders[sc.id];
delete renders[sc.id];
p.scene_ghosts = (p.scene_ghosts || []).filter((g) => g.id !== sc.id);
if (!(r && r.media) && !pending) return;
p.scene_ghosts.push({
id: sc.id, afterSceneId: afterId || null, gen_unit_id: genUnitId(sc), text: sc.text || "",
frames: sc.frames, frames_mode: sc.frames_mode, fps: sc.fps, fps_mode: sc.fps_mode,
effects: clone(sc.effects || {}), audio_volume: sc.audio_volume,
media: (r && r.media) || null, inSec: (r && r.inSec) || 0, pendingGen: Boolean(pending && !(r && r.media)),
});
}
/** Remove one scene. A root that owned a cut unit hands its prompt, source and offset to the next cut. */
export function removeScene(p, id, { pending = false } = {}) {
const at = index(p, id);
if (at < 0) return false;
const sc = p.scenes[at];
const unit = genUnitId(sc);
const mates = p.scenes.filter((s) => genUnitId(s) === unit);
if (!isSubclip(sc) && mates.length > 1) {
const next = mates.find((s) => s.id !== id);
next.cut_offset_frames = 0;
next.text = sc.text || next.text;
next.rating = sc.rating || next.rating;
next.source = clone(sc.source || next.source || {});
const removedFrames = sc.frames || 0;
mates.filter((s) => s.id !== id && (s.cut_offset_frames || 0) > (sc.cut_offset_frames || 0))
.forEach((s) => { s.cut_offset_frames = Math.max(0, s.cut_offset_frames - removedFrames); });
}
const anchor = at > 0 ? p.scenes[at - 1].id : null;
p.scene_ghosts = (p.scene_ghosts || []).map((g) => (g.afterSceneId === id ? { ...g, afterSceneId: anchor } : g));
ghostOrDrop(p, sc, anchor, pending);
p.scenes = p.scenes.filter((s) => s.id !== id);
if (p.timeline_order) p.timeline_order = p.timeline_order.filter((x) => x !== id);
return true;
}
/** Out of the plan but still on the timeline: a unit that has a render is kept, excluded from generation. */
export function removeFromPlan(p, id) {
const sc = p.scenes.find((s) => s.id === id);
if (!sc) return false;
const unit = p.scenes.filter((s) => genUnitId(s) === genUnitId(sc));
const rendered = isVideoClip(sc) || unit.some((s) => p.scene_renders && p.scene_renders[s.id] && p.scene_renders[s.id].media);
if (!rendered) return removeScene(p, id);
unit.forEach((s) => { s.excluded = true; s.removed_from_plan = true; });
return true;
}
export function restoreToPlan(p, id) {
const sc = p.scenes.find((s) => s.id === id);
if (!sc) return false;
p.scenes.filter((s) => genUnitId(s) === genUnitId(sc)).forEach((s) => { s.excluded = false; s.removed_from_plan = false; });
return true;
}
/** Move a clip within the CUT (never the plan) to `to`, an index among the other clips. */
export function reorder(p, id, to, order) {
const ids = order.slice();
const from = ids.indexOf(id);
if (from < 0) return false;
ids.splice(from, 1);
const at = Math.max(0, Math.min(ids.length, to));
ids.splice(at, 0, id);
if (ids.every((x, i) => x === order[i])) return false;
p.timeline_order = ids;
p.timeline_manually_ordered = true; // from now on an edit to the plan must keep this order
return true;
}
const modeToTimeline = (sc) => { if ((sc.frames_mode || "project") === "project") sc.frames_mode = "timeline"; };
/** Run for `sec` (from dragging an edge): whole grid steps, at least one step in the direction of the drag. */
export function resize(p, id, sec, grid = LTX_GRID) {
const sc = p.scenes.find((s) => s.id === id);
if (!sc || sc.frames_mode === "custom") return false;
const fps = effFps(sc, p);
const now = seconds(sc, p);
const target = Math.max(0.1, sec);
if (Math.abs(target - now) <= 0.02) return false;
const cur = Math.max(1, Math.round(now * fps));
if (isVideoClip(sc)) {
sc.source_dur = target;
sc.frames = snapFrames(target * fps, "round", grid);
} else {
const raw = target * fps;
let frames = raw < cur - 0.01 ? snapFrames(raw, "floor", grid) : raw > cur + 0.01 ? snapFrames(raw, "ceil", grid) : cur;
if (frames === cur) frames = raw < cur ? snapFrames(cur - grid.step, "floor", grid) : cur + grid.step;
if (frames === cur) return false;
sc.frames = frames;
}
modeToTimeline(sc);
return true;
}
/** Cut `sec` off the start: the clip begins later in its source and runs shorter. */
export function trimLeft(p, id, sec, grid = LTX_GRID) {
const sc = p.scenes.find((s) => s.id === id);
if (!sc || sc.frames_mode === "custom") return false;
const trim = Math.max(0.05, sec);
if (isVideoClip(sc)) {
const dur = sc.source_dur != null ? sc.source_dur : seconds(sc, p);
if (trim >= dur - 0.05) return false;
sc.source_in = (sc.source_in || 0) + trim;
sc.source_dur = Math.max(0.1, dur - trim);
modeToTimeline(sc);
return true;
}
const fps = effFps(sc, p);
const cur = Math.max(1, Math.round(seconds(sc, p) * fps));
const raw = cur - Math.max(1, Math.round(trim * fps));
if (raw < grid.step + grid.base) return false;
let next = snapFrames(raw, "floor", grid);
if (next >= cur) next = Math.max(grid.step + grid.base, cur - grid.step);
if (next >= cur) return false;
sc.source_in = (sc.source_in || 0) + (cur - next) / fps;
sc.frames = next;
modeToTimeline(sc);
return true;
}
/** Slide what plays within the clip without changing its length. */
export function slip(p, id, deltaSec) {
const sc = p.scenes.find((s) => s.id === id);
if (!sc || !deltaSec) return false;
sc.source_in = Math.max(0, (sc.source_in || 0) + deltaSec);
return true;
}
/** Cut a clip in two at `atFrames` (default the middle). Both halves stay one generated unit. */
export function split(p, id, atFrames, grid = LTX_GRID) {
const at = index(p, id);
if (at < 0) return false;
const sc = p.scenes[at];
const fps = effFps(sc, p);
let frames = effFrames(sc, p);
if (isVideoClip(sc) && sc.source_dur != null) frames = snapFrames(sc.source_dur * fps, "round", grid);
const cut = snapFrames(atFrames != null ? atFrames : frames / 2, "round", grid);
if (cut <= grid.step + grid.base || cut >= frames) return false;
const cutSec = cut / fps;
const unit = genUnitId(sc);
const second = clone(sc);
second.id = newId();
second.gen_unit_id = unit;
second.cut_offset_frames = (sc.cut_offset_frames || 0) + cut;
second.frames = snapFrames(frames - cut, "round", grid);
second.text = "";
second.rating = "";
sc.gen_unit_id = unit;
// The seam between the halves is an internal hard cut: the outgoing edge now belongs to the second half.
sc.frames = cut;
sc.transition_to_next = ""; sc.transition_frames = null; sc.video_transition = "";
modeToTimeline(sc); modeToTimeline(second);
const playsFromSource = isVideoClip(sc) || (sc.source && sc.source.type === "v2v" && sc.source.media_ref);
if (playsFromSource) {
const srcIn = sc.source_in || 0;
const total = sc.source_dur != null ? sc.source_dur : frames / fps;
sc.source_dur = cutSec;
second.source_in = srcIn + cutSec;
second.source_dur = Math.max(0.1, Math.min(second.frames / fps, total - cutSec));
}
p.scenes.splice(at + 1, 0, second);
if (p.timeline_manually_ordered && Array.isArray(p.timeline_order)) {
const oi = p.timeline_order.indexOf(sc.id);
if (oi >= 0) p.timeline_order.splice(oi + 1, 0, second.id);
}
// The render survives the cut: the second half plays the same video from where the first ends.
const renders = p.scene_renders || {};
const r = renders[id];
if (r && r.media) {
delete r.durationSec; // a split is an explicit re-length: both halves go back to plan layout
renders[second.id] = { media: r.media, inSec: (r.inSec || 0) + cutSec, renderPrompt: r.renderPrompt ? { ...r.renderPrompt } : null };
}
return second;
}
+13
View File
@@ -295,6 +295,19 @@ export function createProject({ onChange, onError, onOpen } = {}) {
changed();
},
/** One undo step for a compound change: `fn(project)` mutates the open project in place
* (see edits.js). Returns what fn returned; a falsy answer means nothing changed, and nothing is kept. */
edit(fn) {
if (!project) return false;
const before = JSON.stringify(project);
remember();
const result = fn(project);
if (!result || JSON.stringify(project) === before) { past.pop(); return result; }
scheduleSave();
changed();
return result;
},
/** Any project-level field. `quiet` is for what the app maintains by itself (the pipeline's copy):
* saved, but not an edit a person made, so not an undo step. */
setField(key, value, { quiet = false } = {}) {
+103
View File
@@ -0,0 +1,103 @@
// What a project's scenes are, as numbers: how long each runs, in what order the cut plays them, and where
// each begins on the timeline. Pure -- every function takes the project (and renders) it reads, so the same
// answers come out on the timeline, in the player and in a run.
export const genUnitId = (sc) => (sc && (sc.gen_unit_id || sc.id)) || "";
export const isVideoClip = (sc) => Boolean(sc && sc.source && sc.source.type === "video");
export const isGenerative = (sc) => Boolean(sc) && !isVideoClip(sc);
/** A timeline cut of one generated clip: only its root owns the prompt, rating and source. */
export const isSubclip = (sc) => Boolean(sc) && (sc.cut_offset_frames || 0) > 0;
/** The unit's root scene (the one that owns text/source), or the first of the unit. */
export function unitRoot(p, unit) {
const group = (p.scenes || []).filter((s) => genUnitId(s) === unit);
return group.find((s) => !isSubclip(s)) || group[0] || null;
}
export const unitSceneIds = (p, unit) => (p.scenes || []).filter((s) => genUnitId(s) === unit && !s.excluded).map((s) => s.id);
/** Frame counts a model can make: `base + k * step`. LTX is 8k+1; a family says its own (see the probe). */
export const LTX_GRID = { step: 8, base: 1, fps: null };
export function snapFrames(n, mode = "round", grid = LTX_GRID) {
const k = (Math.round(Number(n) || 0) - grid.base) / grid.step;
const k2 = mode === "floor" ? Math.floor(k) : mode === "ceil" ? Math.ceil(k) : Math.round(k);
return Math.max(grid.step + grid.base, k2 * grid.step + grid.base);
}
// "project" follows the project; a trimmed scene (timeline/custom) owns its number.
export function effFrames(sc, p) {
if (!sc || (sc.frames_mode || "project") === "project") return p.num_frames_per_scene || 97;
return sc.frames != null ? sc.frames : p.num_frames_per_scene || 97;
}
export function effFps(sc, p) {
if (!sc || (sc.fps_mode || "project") === "project") return p.frame_rate || 25;
return sc.fps != null ? sc.fps : p.frame_rate || 25;
}
/** What the next Generate would make: ignores any render already there. */
export function planSeconds(sc, p) {
if (isVideoClip(sc) && sc.source_dur != null) return sc.source_dur;
return effFrames(sc, p) / (effFps(sc, p) || 25);
}
/** How long the clip plays. A render is IMMUTABLE: it keeps the length it was made at, so changing the project
* can never cut off its tail -- unless the scene has an explicit length of its own (trim/slip/split). */
export function seconds(sc, p, renders = p.scene_renders || {}) {
const made = renders[sc.id] && renders[sc.id].durationSec;
if (made != null && !isVideoClip(sc) && sc.source_dur == null && (sc.frames_mode || "project") === "project") return made;
return planSeconds(sc, p);
}
/** The cut: scenes in timeline order. Until a person pins an order by hand it simply follows the plan. */
export function orderedScenes(p) {
const scenes = p.scenes || [];
if (!p.timeline_manually_ordered) return scenes;
const byId = new Map(scenes.map((s) => [s.id, s]));
const seen = new Set();
const out = [];
for (const id of p.timeline_order || []) { const s = byId.get(id); if (s && !seen.has(id)) { out.push(s); seen.add(id); } }
for (const s of scenes) if (!seen.has(s.id)) out.push(s);
return out;
}
const ghostSeconds = (g, p) => g.durationSec != null ? g.durationSec
: ((g.frames_mode !== "project" && g.frames != null ? g.frames : p.num_frames_per_scene) || 1)
/ ((g.fps_mode !== "project" && g.fps != null ? g.fps : p.frame_rate) || 25);
/** What the timeline plays, in order, with where each part starts: scenes, the ghosts of removed scenes that
* still have a clip, and the pauses between. -> [{ kind, id, scene?, ghost?, start, dur }] */
export function segments(p) {
const ghosts = p.scene_ghosts || [];
const after = new Map();
for (const g of ghosts) {
const key = g.afterSceneId || "";
if (!after.has(key)) after.set(key, []);
after.get(key).push(g);
}
const placed = new Set();
const flat = [];
const addGhosts = (key) => (after.get(key) || []).forEach((g) => {
if (!placed.has(g.id)) { placed.add(g.id); flat.push({ kind: "ghost", id: `ghost:${g.id}`, ghost: g, dur: ghostSeconds(g, p) }); }
});
addGhosts("");
for (const sc of orderedScenes(p)) {
flat.push({ kind: "scene", id: sc.id, scene: sc, dur: seconds(sc, p) });
addGhosts(sc.id);
const gap = Math.max(0, +sc.gap_after_sec || 0);
if (gap > 0.001) flat.push({ kind: "gap", id: `gap:${sc.id}`, dur: gap });
}
for (const g of ghosts) addGhosts(g.afterSceneId || ""); // orphans (stale anchor) still show, after the rest
for (const g of ghosts) if (!placed.has(g.id)) { placed.add(g.id); flat.push({ kind: "ghost", id: `ghost:${g.id}`, ghost: g, dur: ghostSeconds(g, p) }); }
let at = 0;
return flat.map((seg) => { const out = { ...seg, start: at }; at += seg.dur; return out; });
}
export const totalSeconds = (p) => { const s = segments(p); return s.length ? s[s.length - 1].start + s[s.length - 1].dur : 0; };
/** Seconds to `HH:MM:SS` (or `MM:SS` under an hour). */
export function clock(sec) {
const whole = Math.max(0, Math.floor(sec));
const h = Math.floor(whole / 3600), m = Math.floor(whole / 60) % 60, s = whole % 60;
const two = (n) => String(n).padStart(2, "0");
return h ? `${two(h)}:${two(m)}:${two(s)}` : `${two(m)}:${two(s)}`;
}
+39
View File
@@ -0,0 +1,39 @@
// Which clips are selected: a set, one of them in focus (what the inspector shows), and an anchor for shift-ranges.
// Click picks one, cmd/ctrl toggles, shift extends from the anchor. Pure; the project says which scene is focused.
export function createSelection({ project }) {
let ids = [];
let anchor = null;
const heard = new Set();
const say = () => heard.forEach((fn) => { try { fn(); } catch { /* one listener must not stop the rest */ } });
const live = () => new Set(project.scenes.map((s) => s.id));
const heal = () => { const have = live(); ids = ids.filter((id) => have.has(id)); if (anchor && !have.has(anchor)) anchor = project.selectedId; };
return {
get ids() { heal(); return ids.length ? [...ids] : (project.selectedId ? [project.selectedId] : []); },
has(id) { return this.ids.includes(id); },
get focus() { return project.selectedId; },
on: (fn) => { heard.add(fn); return () => heard.delete(fn); },
/** `order`: scene ids as the timeline shows them, which is what a range means. */
pick(id, { additive = false, range = false } = {}, order = project.scenes.map((s) => s.id)) {
if (!project.scenes.some((s) => s.id === id)) return;
const was = this.ids;
if (range && anchor) {
const a = order.indexOf(anchor), b = order.indexOf(id);
ids = a < 0 || b < 0 ? [id] : order.slice(Math.min(a, b), Math.max(a, b) + 1);
} else if (additive) {
ids = was.includes(id) ? was.filter((x) => x !== id) : [...was, id];
anchor = ids.includes(id) ? id : ids[ids.length - 1] || null; // after un-picking, the last one still picked
} else {
ids = [id];
anchor = id;
}
const focus = ids.includes(id) ? id : ids[ids.length - 1] || null;
if (focus) project.select(focus);
say();
},
clear() { ids = []; anchor = null; say(); },
/** The project was replaced or restructured: drop what no longer exists. */
heal() { heal(); say(); },
};
}
+81
View File
@@ -0,0 +1,81 @@
import test from "node:test";
import assert from "node:assert/strict";
import { addScene, removeScene, removeFromPlan, restoreToPlan, reorder, resize, trimLeft, slip, split } from "../edits.js";
import { seconds, segments } from "../scenes.js";
const mk = (extra = {}) => ({
num_frames_per_scene: 97, frame_rate: 25, scene_renders: {}, scene_ghosts: [],
scenes: [{ id: "a", text: "A", source: { type: "empty" } }, { id: "b", text: "B", source: { type: "carry" } }],
...extra,
});
test("a new scene goes on the end, empty", () => {
const p = mk();
const sc = addScene(p, "carry");
assert.deepEqual([p.scenes.length, sc.text, sc.source.type], [3, "", "carry"]);
});
test("removing a scene with a render leaves a ghost where it was; one without just goes", () => {
const p = mk({ scene_renders: { b: { media: { filename: "x.mp4" }, inSec: 2 } } });
removeScene(p, "b");
assert.deepEqual(p.scenes.map((s) => s.id), ["a"]);
assert.deepEqual([p.scene_ghosts[0].id, p.scene_ghosts[0].afterSceneId, p.scene_ghosts[0].inSec], ["b", "a", 2]);
removeScene(p, "a");
assert.equal(p.scene_ghosts.length, 1, "a scene that never rendered leaves nothing");
});
test("removing the root of a cut hands its prompt and source to the next cut", () => {
const p = mk();
const second = split(p, "a", 49);
assert.ok(second);
removeScene(p, "a");
const left = p.scenes.find((s) => s.id === second.id);
assert.deepEqual([left.cut_offset_frames, left.text, left.source.type], [0, "A", "empty"]);
});
test("a unit with a render stays on the timeline when taken out of the plan, and can come back", () => {
const p = mk({ scene_renders: { a: { media: { filename: "x.mp4" } } } });
removeFromPlan(p, "a");
assert.deepEqual([p.scenes[0].excluded, p.scenes[0].removed_from_plan], [true, true]);
restoreToPlan(p, "a");
assert.deepEqual([p.scenes[0].excluded, p.scenes[0].removed_from_plan], [false, false]);
});
test("reordering the cut pins an order and never touches the plan", () => {
const p = mk();
assert.equal(reorder(p, "a", 1, ["a", "b"]), true);
assert.deepEqual(p.timeline_order, ["b", "a"]);
assert.equal(p.timeline_manually_ordered, true);
assert.deepEqual(p.scenes.map((s) => s.id), ["a", "b"]);
assert.equal(reorder(p, "b", 0, ["b", "a"]), false, "dropping where it already is changes nothing");
});
test("dragging an edge lands on the model's frame grid and pins the scene's own length", () => {
const p = mk();
assert.equal(resize(p, "a", 2), true);
const a = p.scenes[0];
assert.equal(a.frames_mode, "timeline");
assert.equal(a.frames % 8, 1, "8k+1");
assert.ok(Math.abs(seconds(a, p) - 2) < 0.3);
assert.equal(resize(p, "a", seconds(a, p) + 0.01), false, "a nudge under the tolerance is nothing");
});
test("trimming the start begins later in the source and runs shorter", () => {
const p = mk();
assert.equal(trimLeft(p, "a", 1), true);
const a = p.scenes[0];
assert.ok(a.source_in > 0.5 && a.frames < 97);
assert.equal(slip(p, "a", -100), true);
assert.equal(a.source_in, 0, "never before the start of the source");
});
test("a split makes two clips of one unit that keep playing the same render", () => {
const p = mk({ scene_renders: { a: { media: { filename: "x.mp4" }, inSec: 1, durationSec: 4 } } });
const second = split(p, "a", 49);
assert.equal(p.scenes.length, 3);
assert.deepEqual([p.scenes[0].frames, second.frames, second.gen_unit_id, second.cut_offset_frames], [49, 49, "a", 49]);
assert.equal(p.scene_renders[second.id].inSec, 1 + 49 / 25);
assert.equal(p.scene_renders.a.durationSec, undefined);
assert.deepEqual(segments(p).map((s) => s.id).slice(0, 2), ["a", second.id]);
assert.equal(split(p, "a", 5), false, "no cut inside the first grid step");
});
+44
View File
@@ -0,0 +1,44 @@
import test from "node:test";
import assert from "node:assert/strict";
import { seconds, planSeconds, snapFrames, segments, orderedScenes, totalSeconds, clock, genUnitId, LTX_GRID } from "../scenes.js";
const P = { num_frames_per_scene: 97, frame_rate: 25, scenes: [{ id: "a" }, { id: "b" }, { id: "c", gap_after_sec: 1 }], scene_renders: {} };
test("a scene follows the project's length until trimmed", () => {
assert.equal(planSeconds(P.scenes[0], P), 97 / 25);
assert.equal(planSeconds({ id: "x", frames_mode: "timeline", frames: 49 }, P), 49 / 25);
});
test("a render keeps the length it was made at when the project changes", () => {
const p = { ...P, scene_renders: { a: { durationSec: 5 } } };
assert.equal(seconds(p.scenes[0], p), 5);
assert.equal(seconds({ id: "a", frames_mode: "timeline", frames: 25 }, p), 1, "an explicit trim wins over the frozen length");
});
test("frames snap onto the model's grid, never below one step", () => {
assert.equal(snapFrames(100), 97);
assert.equal(snapFrames(100, "ceil"), 105);
assert.equal(snapFrames(1), 9);
assert.equal(snapFrames(40, "round", { step: 17, base: 5 }), 39);
});
test("the cut follows the plan until a person orders it, then keeps their order and heals around deletions", () => {
assert.deepEqual(orderedScenes(P).map((s) => s.id), ["a", "b", "c"]);
const p = { ...P, timeline_manually_ordered: true, timeline_order: ["c", "gone", "a"] };
assert.deepEqual(orderedScenes(p).map((s) => s.id), ["c", "a", "b"]);
});
test("segments lay scenes, ghosts and pauses end to end", () => {
const p = { ...P, scene_ghosts: [{ id: "g", afterSceneId: "a", durationSec: 2 }] };
const segs = segments(p);
assert.deepEqual(segs.map((s) => s.id), ["a", "ghost:g", "b", "c", "gap:c"]);
assert.equal(segs[2].start, 97 / 25 + 2);
assert.equal(totalSeconds(p), 3 * (97 / 25) + 2 + 1);
});
test("a gen unit is its own id unless a cut says otherwise", () => {
assert.equal(genUnitId({ id: "a" }), "a");
assert.equal(genUnitId({ id: "b", gen_unit_id: "a" }), "a");
assert.equal(clock(125), "02:05");
assert.deepEqual(LTX_GRID, { step: 8, base: 1, fps: null });
});
+30
View File
@@ -0,0 +1,30 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createSelection } from "../selection.js";
function fake(ids) {
let focus = ids[0];
return { scenes: ids.map((id) => ({ id })), get selectedId() { return focus; }, select(id) { focus = id; } };
}
const order = ["a", "b", "c", "d"];
test("a click picks one; cmd toggles; shift extends from where you started", () => {
const sel = createSelection({ project: fake(order) });
sel.pick("b", {}, order);
assert.deepEqual(sel.ids, ["b"]);
sel.pick("d", { additive: true }, order);
assert.deepEqual(sel.ids, ["b", "d"]);
sel.pick("d", { additive: true }, order);
assert.deepEqual(sel.ids, ["b"]);
sel.pick("a", { range: true }, order);
assert.deepEqual(sel.ids, ["a", "b"]);
});
test("a clip that is gone drops out of the selection, and nothing selected falls back to the focus", () => {
const project = fake(order);
const sel = createSelection({ project });
sel.pick("c", {}, order);
project.scenes = project.scenes.filter((s) => s.id !== "c");
sel.heal();
assert.deepEqual(sel.ids, [project.selectedId]);
});