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:
co-authored by
Claude Sonnet 5.5
parent
b44f031c6f
commit
e861f2ddbf
@@ -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" },
|
||||
|
||||
@@ -15,4 +15,5 @@
|
||||
@import "./elements/overlay.css";
|
||||
@import "./elements/gallery.css";
|
||||
@import "./elements/track.css";
|
||||
@import "./elements/nle.css";
|
||||
@import "./elements/wheel.css";
|
||||
|
||||
@@ -17,4 +17,5 @@ import "./popover.js";
|
||||
import "./floating.js";
|
||||
import "./gallery.js";
|
||||
import "./track.js";
|
||||
import "./nle.js";
|
||||
import "./wheel.js";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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(); },
|
||||
};
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 } = {}) {
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
@@ -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(); },
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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]);
|
||||
});
|
||||
Reference in New Issue
Block a user