diff --git a/app/ui/catalogue/demos.js b/app/ui/catalogue/demos.js index f2d616d7..9f5a08bf 100644 --- a/app/ui/catalogue/demos.js +++ b/app/ui/catalogue/demos.js @@ -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" }, diff --git a/app/ui/composer/composer.css b/app/ui/composer/composer.css index ff344820..8b391865 100644 --- a/app/ui/composer/composer.css +++ b/app/ui/composer/composer.css @@ -15,4 +15,5 @@ @import "./elements/overlay.css"; @import "./elements/gallery.css"; @import "./elements/track.css"; +@import "./elements/nle.css"; @import "./elements/wheel.css"; diff --git a/app/ui/composer/elements/index.js b/app/ui/composer/elements/index.js index 78e56aa3..be47997c 100644 --- a/app/ui/composer/elements/index.js +++ b/app/ui/composer/elements/index.js @@ -17,4 +17,5 @@ import "./popover.js"; import "./floating.js"; import "./gallery.js"; import "./track.js"; +import "./nle.js"; import "./wheel.js"; diff --git a/app/ui/composer/elements/nle.css b/app/ui/composer/elements/nle.css new file mode 100644 index 00000000..e65568d8 --- /dev/null +++ b/app/ui/composer/elements/nle.css @@ -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); +} diff --git a/app/ui/composer/elements/nle.js b/app/ui/composer/elements/nle.js new file mode 100644 index 00000000..7b772047 --- /dev/null +++ b/app/ui/composer/elements/nle.js @@ -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(); }, + }; +}); diff --git a/app/ui/shell/edits.js b/app/ui/shell/edits.js new file mode 100644 index 00000000..3793614e --- /dev/null +++ b/app/ui/shell/edits.js @@ -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; +} diff --git a/app/ui/shell/project.js b/app/ui/shell/project.js index cf6ab5d3..02f6831c 100644 --- a/app/ui/shell/project.js +++ b/app/ui/shell/project.js @@ -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 } = {}) { diff --git a/app/ui/shell/scenes.js b/app/ui/shell/scenes.js new file mode 100644 index 00000000..3c02860d --- /dev/null +++ b/app/ui/shell/scenes.js @@ -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)}`; +} diff --git a/app/ui/shell/selection.js b/app/ui/shell/selection.js new file mode 100644 index 00000000..a8e82df7 --- /dev/null +++ b/app/ui/shell/selection.js @@ -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(); }, + }; +} diff --git a/app/ui/shell/tests/edits.test.js b/app/ui/shell/tests/edits.test.js new file mode 100644 index 00000000..554001df --- /dev/null +++ b/app/ui/shell/tests/edits.test.js @@ -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"); +}); diff --git a/app/ui/shell/tests/scenes.test.js b/app/ui/shell/tests/scenes.test.js new file mode 100644 index 00000000..29f7cdcf --- /dev/null +++ b/app/ui/shell/tests/scenes.test.js @@ -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 }); +}); diff --git a/app/ui/shell/tests/selection.test.js b/app/ui/shell/tests/selection.test.js new file mode 100644 index 00000000..5e5aa4a2 --- /dev/null +++ b/app/ui/shell/tests/selection.test.js @@ -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]); +});