// A single time-based track: clips laid out by real duration, a ruler in // seconds, and a playhead -- the shape of an actual video timeline, not a // filmstrip of equal-sized thumbnails with a tick per item. // // One track, on purpose: v5 generates one clip per scene, in order, with no // separate audio/overlay layers to line up against it. A second track is a // different, larger feature (waveforms, compositing, independent trims) -- // this is only ever asked to place things that already play back to back. import { define } from "../internals/register.js"; import { el } from "../internals/el.js"; import { wireDragReorder } from "../internals/dragReorder.js"; /** A clip's face: the result so far, or the glyph that stands in for one -- * the same visual language cx-cell-thumb uses elsewhere in the kit, but * filling the clip's own fixed-height box rather than a 16:10 box of its * own: a clip's width tracks real duration, and that box can run to * hundreds of pixels wide. */ function faceOf(item) { const thumb = el("div", { cls: "cx-track-face" }); const glyph = () => el("span", { cls: "cx-cell-glyph", text: item.icon || "▦", attrs: { "aria-hidden": "true" } }); if (item.thumb) { const img = el("img", { cls: "cx-cell-img", attrs: { src: item.thumb, alt: "", loading: "lazy" } }); // A thumbnail that fails to load falls back to the glyph, not the // browser's broken-image icon -- which would read as a damaged file. img.addEventListener("error", () => img.replaceWith(glyph())); thumb.append(img); } else { thumb.append(glyph()); } if (item.badge) thumb.append(el("span", { cls: "cx-cell-badge", text: item.badge })); return thumb; } // A tick every 1/2/5/10... seconds, whichever is the smallest that still // leaves room to read the label beside it. Fixed intervals rather than a tick // per clip: what a person reads off a ruler is elapsed TIME, and a tick per // scene is the scene-index ruler this replaces. const NICE_INTERVALS = [1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 900, 1800, 3600]; const MIN_TICK_PX = 56; function tickInterval(pxPerSecond) { for (const s of NICE_INTERVALS) if (s * pxPerSecond >= MIN_TICK_PX) return s; return NICE_INTERVALS[NICE_INTERVALS.length - 1]; } function formatTime(seconds) { const whole = Math.max(0, Math.round(seconds)); const m = Math.floor(whole / 60); const s = whole % 60; return `${m}:${String(s).padStart(2, "0")}`; } // A floor, not a cap: a scene under a second would otherwise render as a // sliver nothing could click or even see next to a normal one. Everything // else stays exactly proportional -- this only rescues the degenerate case. // // Known, accepted limit: several floored clips in a row still paint on top of // each other (later DOM order wins), so a run of very short adjacent scenes // can visually collapse into just the last one. Fixing that by shifting later // clips right to make room was tried and rejected -- the pushed offset never // resolves once introduced (nothing shrinks to give the room back), which // desyncs every clip after the run from the ruler and playhead for the rest // of the track. That failure is worse than an occasional occluded sliver, so // exact position over the seconds axis wins; seeking by click still resolves // correctly either way, since that reads time, not which clip is on top. const MIN_CLIP_PX = 24; /** * track.default({ items, pxPerSecond, selection, playhead, label, * onSeek, onReorder }) * * `items`: [{ id, label, thumb, icon, badge, rating, excluded, start, * duration }] -- start/duration in SECONDS, contiguous (each * item's start is the previous one's start + duration), which is what lets * one click handler double as both "seek" and "select": every point on the * track belongs to exactly one clip. The track's own span is read off the * items themselves (the last one's start + duration) -- nothing else here * knows the project's length. * * `onSeek(seconds, item)` fires on any click on the track -- the ruler, the * empty background, or a clip. `item` is whichever clip's span the point * (or, for a clip itself, the clip clicked) falls in. */ define("track", "default", ({ items = [], pxPerSecond: initialPxPerSecond = 40, selection = [], playhead = 0, label, onSeek, onReorder, } = {}) => { let pxPerSecond = initialPxPerSecond; const chosen = new Set(selection); let current = items; const spanOf = () => current.reduce((max, i) => Math.max(max, i.start + i.duration), 0); const node = el("div", { cls: "cx-track", attrs: { role: "presentation", "aria-label": label } }); const inner = el("div", { cls: "cx-track-inner" }); const ruler = el("div", { cls: "cx-track-ruler", attrs: { role: "presentation" } }); const clips = el("div", { cls: "cx-track-clips", attrs: { role: "listbox", "aria-label": label } }); const head = el("div", { cls: "cx-track-playhead", attrs: { "aria-hidden": "true" } }); inner.append(ruler, clips, head); node.append(inner); function drawRuler(span) { ruler.replaceChildren(); const interval = tickInterval(pxPerSecond); for (let t = 0; t <= span; t += interval) { const tick = el("span", { cls: "cx-track-tick", text: formatTime(t) }); tick.style.insetInlineStart = `${t * pxPerSecond}px`; ruler.append(tick); } } function drawClips() { clips.replaceChildren(); for (const item of current) { const on = chosen.has(item.id); const cell = el("button", { cls: ["cx-track-clip", on ? "cx-on" : null, item.rating ? "cx-rated" : null, item.excluded ? "cx-excluded" : null, "cx-focusable"], attrs: { type: "button", role: "option", "aria-selected": String(on), title: item.label, "aria-label": item.label, "data-rating": item.rating || undefined, "data-id": item.id, // Draggable whether or not `onReorder` is given, matching the // strip: a drag with nowhere to report to is inert, not wired. draggable: "true", }, children: [faceOf(item)], }); // Positioned from its own start, not stacked after the previous clip in // normal flow -- a clip under the pixel floor would otherwise push // every clip after it further right than the ruler and the playhead, // which both stay in pure seconds*pxPerSecond, ever agree it is. cell.style.insetInlineStart = `${item.start * pxPerSecond}px`; cell.style.width = `${Math.max(MIN_CLIP_PX, item.duration * pxPerSecond)}px`; clips.append(cell); } } function setPlayhead(seconds) { head.style.insetInlineStart = `${Math.max(0, seconds) * pxPerSecond}px`; } function draw() { const span = spanOf(); inner.style.width = `${span * pxPerSecond}px`; drawRuler(span); drawClips(); setPlayhead(playhead); } wireDragReorder(clips, ".cx-track-clip", onReorder); // One handler for the whole track: a click's OWN position is turned into a // time, the same way a scrub bar would, whether it landed on the ruler, the // empty background, or a clip -- a clip covers most of the track's area, so // reading its id instead of where on it the click landed would make the // ruler the only place a real, exact-second seek ever reaches. Keyboard // activation (Enter/Space on a focused clip) still resolves correctly: the // browser reports the click at that button's own on-screen position, which // is well inside the right clip's span even if not at its exact start. inner.addEventListener("click", (e) => { if (!onSeek) return; const rect = inner.getBoundingClientRect(); const seconds = Math.min(spanOf(), Math.max(0, (e.clientX - rect.left) / pxPerSecond)); const item = current.find((i) => seconds < i.start + i.duration) || current[current.length - 1] || null; onSeek(seconds, item); }); draw(); return { node, setItems(next = []) { current = next; draw(); }, setValue(next = []) { chosen.clear(); for (const id of next) chosen.add(id); drawClips(); }, setPlayhead(seconds) { playhead = seconds; setPlayhead(seconds); }, setZoom(next) { pxPerSecond = next; draw(); }, destroy: () => node.remove(), }; });