// The fact-check STAMPS' composition: one HyperFrames page for a whole report // cut, a small transparent region over the footage box (deck.mjs // stampGeometry) where each claim's verdict slams in at the end of the // claim's last segment and leaves with that segment's transition. // // PURE, like chrome-deck.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: "stamp"`); the build lays the frames over the cut as it lays // the deck's. // // The timeline is cues, computed here, each stating its from -- a render is a // seek per frame, from parallel workers, in any order (chrome-deck.mjs says // why at length). The times are factcheck.mjs `stampSchedule`'s, untouched. import { pageDuration, stampGeometry } from "./deck.mjs"; import { mix, rgba } from "./chrome-deck.mjs"; import { resolveFactcheck, STAMP_MOTION } from "./factcheck.mjs"; /** Instant cues still take a millisecond: a zero-duration tween has its own seek rules. */ const INSTANT = 0.001; const esc = (s) => String(s ?? "") .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); const r4 = (v) => Math.round(v * 10000) / 10000; /** The stamp's tilt at rest, and how far past it (and how large) it starts its slam. */ export const STAMP_POSE = Object.freeze({ rest: -6, from: -14, scale: 1.7, leaveScale: 0.96 }); /** * Everything the stamps' timeline does, as data. Stamp j is `st` (autoAlpha, * scale, rotation) with its flash `fl` (opacity): it slams in from * `STAMP_POSE.scale` × and `from`° over `STAMP_MOTION.slam`, ending at its * `landed` -- the second the tally steps -- flashes, holds, and fades over its * `out`. * * @returns {{ init: Record, cues: Array<{ k: string, at: number, * dur: number, from: object, to: object, ease: string, why: string }> }} */ export function stampCues(schedule) { const stamps = schedule.factcheck?.stamps ?? []; const P = STAMP_POSE; const m = STAMP_MOTION; const init = {}; const ev = []; const add = (k, at, dur, to, ease, why) => ev.push({ k, at: r4(at), dur: r4(Math.max(INSTANT, dur)), to, ease, why }); stamps.forEach((s, j) => { init[`st${j}`] = { autoAlpha: 0, scale: P.scale, rotation: P.from }; init[`fl${j}`] = { opacity: 0 }; const why = `stamp ${s.claim}`; add(`st${j}`, s.at, s.landed - s.at, { autoAlpha: 1, scale: 1, rotation: P.rest }, "power4.in", `${why} in`); add(`fl${j}`, s.landed, m.flashUp, { opacity: 1 }, "none", `${why} flash`); add(`fl${j}`, s.landed + m.flashUp, m.flashDown, { opacity: 0 }, "power2.out", `${why} flash`); add(`st${j}`, s.out[0], s.out[1] - s.out[0], { autoAlpha: 0, scale: P.leaveScale }, "power1.in", `${why} out`); }); // Order, clamp (a cue never starts before the last on its element ends), // and state every from: the last to on its element, or its init. ev.forEach((e, n) => { e.n = n; }); ev.sort((a, b) => a.at - b.at || a.n - b.n); const state = Object.fromEntries(Object.entries(init).map(([k, v]) => [k, { ...v }])); const freeAt = new Map(); const cues = []; for (const e of ev) { let { at, dur } = e; const free = freeAt.get(e.k) ?? 0; if (at < free) { const end = at + dur; at = r4(free); dur = r4(Math.max(INSTANT, end - at)); } const cur = state[e.k] ?? (state[e.k] = {}); const from = {}; for (const p of Object.keys(e.to)) from[p] = cur[p]; Object.assign(cur, e.to); freeAt.set(e.k, r4(at + dur)); cues.push({ k: e.k, at, dur, from, to: e.to, ease: e.ease, why: e.why }); } return { init, cues }; } /** * The stamps composition'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. * The region is `stampGeometry(render, { feed })`, region-local, transparent * outside the stamp. `?still=` and the preview's `deck:seek` take CUT * seconds. */ export function stampHtml(schedule, render, opts = {}) { const stamps = schedule.factcheck?.stamps ?? []; if (!stamps.length) throw new Error("stamp: the schedule stamps no claim"); const geo = stampGeometry(render, { feed: schedule.layout === "feed" }); const fc = resolveFactcheck(render); const pal = render.palette; const W = geo.width, H = geo.height; 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(`stamp: nothing to render from ${from}s of a ${total}s cut`); const windowed = from > 0 || Math.abs(dur - total) > 1e-6; const { init, cues } = stampCues(schedule); // The stamp's box: inside the region with room for its tilt, so no corner // is clipped at rest (the slam's overscale passes the edges for a moment). const bw = Math.round(W * 0.84); const bh = Math.round(H * 0.62); const size = Math.round(bh * 0.46); const stampHtmlList = stamps .map((s, j) => { const v = fc.verdicts[s.verdict] ?? { label: s.verdict, color: pal.accent }; return ( `
` + `
${esc(v.label)}
` + `
` ); }) .join("\n "); const data = { total: r4(total), window: windowed ? { from: r4(from), dur } : null, size, floor: Math.ceil(size * 0.5), ids: stamps.map((s) => s.claim), init, cues: cues.map(({ why, ...c }) => c), }; // `` inside a JSON string would close the tag; a claim id is the manifest's. const json = JSON.stringify(data).replace(/
${stampHtmlList}
`; }