// Where the footage is at a moment of the cut, for umtool's live preview. // // `posts.shift` moves the footage aside while a clip's posts are up: the // schedule's `moves` (deck.mjs `footageMoves`) say when and from which box to // which. The BUILD draws the move; the preview only has to put its backdrop -- // a still of the built segment, or the neutral frame -- where the build will // have put the footage, so a scrubbed moment reads as the render. Pure: no // DOM, no React, so the arithmetic is unit-tested and the page only applies it. // // The rule the build follows, read here the same way: // - a move belongs to ONE segment, and only that segment's footage moves: // the next one comes in at the normal box through the transition, so once // the scrubber is in the next segment there is no move; // - before `at` the footage is in its box; over `seconds` it eases to `to`; // after that it stays at `to` to the end of the segment (the hold included). // // The easing is smoothstep, p²(3 − 2p): the curve the build's move uses. /** @typedef {{ x: number, y: number, width: number, height: number }} Rect */ /** @typedef {{ segment: string, at: number, segmentAt: number, seconds: number, from: Rect, to: Rect }} Move */ /** smoothstep on [0, 1], clamped: 0 and 1 outside it. */ export function ease(p) { if (!(p > 0)) return 0; if (p >= 1) return 1; return p * p * (3 - 2 * p); } /** * The segment on screen at `t`: the last one that has started. Past the end, * the last; before the first, the first. The preview's own rule (its * `segmentAt`), so the backdrop and the move agree on whose footage it is. * * @param {{ segments: Array<{ id: string, start: number }> }} schedule * @param {number} t */ export function segmentIdAt(schedule, t) { const segs = schedule?.segments ?? []; for (let i = segs.length - 1; i >= 0; i -= 1) if (t >= segs[i].start) return segs[i].id; return segs[0]?.id ?? null; } const lerp = (a, b, p) => a + (b - a) * p; /** * The footage's box at `t`, when the segment on screen has a move: its * eased progress (0 before the move, 1 after it) and the rect between `from` * and `to`. null when the segment on screen has no move -- the footage is in * its box and nothing needs drawing differently. * * @param {{ segments: Array<{ id: string, start: number }>, moves?: Move[] }} schedule * @param {number} t seconds in the cut's clock * @returns {{ segment: string, progress: number, rect: Rect, from: Rect, to: Rect } | null} */ export function footageAt(schedule, t) { const moves = schedule?.moves ?? []; if (!moves.length) return null; const id = segmentIdAt(schedule, t); const m = moves.find((x) => x.segment === id); if (!m) return null; const progress = m.seconds > 0 ? ease((t - m.at) / m.seconds) : t >= m.at ? 1 : 0; const rect = { x: lerp(m.from.x, m.to.x, progress), y: lerp(m.from.y, m.to.y, progress), width: lerp(m.from.width, m.to.width, progress), height: lerp(m.from.height, m.to.height, progress), }; return { segment: m.segment, progress, rect, from: m.from, to: m.to }; } /** * The CSS transform that takes a whole-frame backdrop (W×H, `transform-origin: * 0 0`) with its footage at `from` and puts that footage at `rect`. Translate * is in percent of the element -- the frame -- so it holds at any displayed * size. "none" when nothing moves. * * @param {Rect} from * @param {Rect} rect * @param {{ W: number, H: number }} frame */ export function backdropTransform(from, rect, { W, H }) { const sx = rect.width / from.width; const sy = rect.height / from.height; const tx = rect.x - sx * from.x; const ty = rect.y - sy * from.y; if (Math.abs(sx - 1) < 1e-6 && Math.abs(sy - 1) < 1e-6 && Math.abs(tx) < 1e-6 && Math.abs(ty) < 1e-6) return "none"; const pct = (v, of) => `${Math.round((v / of) * 100 * 10000) / 10000}%`; const n = (v) => Math.round(v * 1e6) / 1e6; return `translate(${pct(tx, W)}, ${pct(ty, H)}) scale(${n(sx)}, ${n(sy)})`; }