// The THREAD RAIL's composition: one HyperFrames page for a whole cut, the // frame's left side beside the footage (deck.mjs threadsGeometry), a card per // thread (threads.mjs). The card of the thread on screen is lit and strung // across to the picture; its dots fill clip by clip; its outcome is stamped on // it as its last clip ends. The rail steps aside while a popup post has moved // the footage over it. // // PURE, like chrome-stamp.mjs: a schedule and a render block in, an HTML // string out. compose-chrome.mjs copies the assets in beside it, writes it and // renders it (`region: "threads"`); the build lays the frames over the cut as // it lays the deck's. // // The timeline is threads.mjs `threadCues`, each cue stating its from -- a // render is a seek per frame, from parallel workers, in any order. import { pageDuration, threadsGeometry } from "./deck.mjs"; import { mix, rgba } from "./chrome-deck.mjs"; import { threadCues } from "./threads.mjs"; const esc = (s) => String(s ?? "") .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); const r4 = (v) => Math.round(v * 10000) / 10000; /** * The cards' sizes in the rail's height for `n` threads: each card `h` px tall * (at most `max`), `gap` apart, the stack centred (`top`). */ export function railLayout(height, n, { gap = 14, max = 190 } = {}) { const h = Math.min(max, Math.floor((height - (n - 1) * gap) / n)); const total = n * h + (n - 1) * gap; return { h, gap, top: Math.max(0, Math.floor((height - total) / 2)) }; } /** * The rail's HTML, for the whole cut -- or a window of it (`from`/`duration`, * as the deck's). `fonts` = `{ regular, bold }` asset-relative paths * (DeckSans / DeckSansBold); `gsap` the vendored script. `?still=` and the * preview's `deck:seek` take CUT seconds. */ export function threadsHtml(schedule, render, opts = {}) { const sched = schedule.threads; if (!sched?.threads?.length) throw new Error("threads: the schedule has no thread rail"); const geo = threadsGeometry(render); const pal = render.palette; const W = geo.width, H = geo.height; const C = geo.cards; const fonts = opts.fonts ?? {}; const gsapSrc = opts.gsap ?? "assets/gsap.min.js"; const total = schedule.total; const from = Number(opts.from ?? 0); const dur = opts.duration != null ? r4(Number(opts.duration)) : r4(total - from); if (!(dur > 0)) throw new Error(`threads: nothing to render from ${from}s of a ${total}s cut`); const windowed = from > 0 || Math.abs(dur - total) > 1e-6; const { init, cues } = threadCues(sched); const n = sched.threads.length; const lay = railLayout(C.height, n); // Type scales with the card: a rail of eight is denser than a rail of three. const k = Math.min(1, lay.h / 170); const idxSize = Math.round(17 * Math.max(0.8, k)); const labelSize = Math.round(27 * Math.max(0.72, k)); const labelLine = Math.round(labelSize * 1.12); const outSize = Math.round(16 * Math.max(0.8, k)); const dot = Math.round(11 * Math.max(0.8, k)); const pad = Math.round(16 * Math.max(0.75, k)); const rail = 5; const cardsHtml = sched.threads .map((t, j) => { const top = lay.top + j * (lay.h + lay.gap); const oc = t.outcome?.color ?? pal.accent; const dots = t.clips.map((_, d) => ``).join(""); return ( `
` + `
` + `
${String(j + 1).padStart(2, "0")}${dots}
` + `
${esc(t.label)}
` + (t.outcome ? `
${esc(t.outcome.label)}` + `
` : "") + `
` + `
` ); }) .join("\n "); const data = { total: r4(total), window: windowed ? { from: r4(from), dur } : null, labelSize, ids: sched.threads.map((t) => t.id), init, cues: cues.map(({ why, ...c }) => c), }; const json = JSON.stringify(data).replace(/
${cardsHtml}
`; }