Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
180 lines
8.1 KiB
JavaScript
180 lines
8.1 KiB
JavaScript
// 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(),
|
|
};
|
|
});
|