Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit 03f8fe659db12b71c30ca12f88ce08b6893aa4ac
parent b99630d909dab9bfca1e0cdb36a9898d92a562a4
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Sun,  4 Oct 2026 16:04:57 -0400

report-to-video: fact-check chrome -- a per-entry `claim: {id, verdict}` (CORROBORATED, PARTLY, CONTRADICTED, NOT_FOUND, UNTESTABLE), validated with one verdict per claim id; `render.chrome.factcheck` (verdict labels and colours, stamp seconds and position, tally show and position) validated in validateChrome; a verdict stamp over the footage on the last segment of each claim (chrome-stamp.mjs, the `stamp` region, laid last over the cut); a tally in the deck whose counts step as each stamp lands; `deck.qr.links: "original"` (the record's page, YouTube at the second); optional `posts[].shot` drawn in place of a post's text card

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

Diffstat:
Mumtool/report-to-video/build-video.mjs | 46++++++++++++++++++++++++++++++++++++++--------
Mumtool/report-to-video/chrome-deck.mjs | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mumtool/report-to-video/chrome-feed.mjs | 30++++++++++++++++++++++--------
Mumtool/report-to-video/chrome-posts.mjs | 29+++++++++++++++++++++--------
Aumtool/report-to-video/chrome-stamp.mjs | 246+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/report-to-video/compose-chrome.mjs | 66++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Mumtool/report-to-video/deck.mjs | 124+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Aumtool/report-to-video/factcheck.mjs | 344+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/report-to-video/verify-build.mjs | 10++++++++++
9 files changed, 910 insertions(+), 48 deletions(-)

diff --git a/umtool/report-to-video/build-video.mjs b/umtool/report-to-video/build-video.mjs @@ -84,10 +84,11 @@ import { ensureWriteDir } from "../lib/report/storage.mjs"; // the schedule down -- it never has a copy of the arithmetic. import { assertChrome, deckGeometry, deckOn, deckSchedule, endFadeOf, feedGeometry, feedOn, frameCount, hidesDeck, MUTE_FADE, - dipOf, muteSegmentSeconds, playWindow, postsGeometry, postWindows, resolveDeck, scheduleFrom, snapWindow, teaserHits, teaserSeconds, - teaserTitle, + dipOf, muteSegmentSeconds, playWindow, postsGeometry, postWindows, resolveDeck, scheduleFrom, snapWindow, stampGeometry, + teaserHits, teaserSeconds, teaserTitle, validateCutEdits, validatePosts, validateTeasers, } from "./deck.mjs"; +import { validateClaims } from "./factcheck.mjs"; // The per-platform yt-dlp args (Rumble's `--impersonate chrome`): the ONE table, // in common, plain JS so bare `node` can load it. import { platformArgsForUrl } from "yt-dlp-transcript-common/ytdlp/platformArgs.mjs"; @@ -2000,9 +2001,10 @@ export function chromeOverlayChain(render, regions, inLabel, firstInputIdx, opts // (snapWindow), and nothing past the main's end is drawn: the deck's // `shortest=1` overlay ahead of it already ends the stream there. Same // frame-kind trap as the deck, same two guards. - // The posts feed (`name: "feed"`) is a whole-cut sequence like the deck's, - // laid exactly as the deck's is. - const deck = r.name === "deck" || r.name === "feed"; + // The posts feed (`name: "feed"`) and the fact-check stamps (`name: + // "stamp"`) are whole-cut sequences like the deck's, laid exactly as the + // deck's is. + const deck = r.name === "deck" || r.name === "feed" || r.name === "stamp"; const posts = r.name === "posts"; inputs.push( ...(deck || posts ? ["-reinit_filter", "0"] : []), @@ -2067,6 +2069,11 @@ export function feedRegion(render, frames) { return { name: "feed", frames, ...feedGeometry(render).column }; } +/** The fact-check stamps as an overlay region: their frames at stampGeometry's box. */ +export function stampRegion(render, frames, schedule) { + return { name: "stamp", frames, ...stampGeometry(render, { feed: schedule.layout === "feed" }) }; +} + /** * Where each rendered chrome region sits in the frame. * @@ -3007,8 +3014,9 @@ export function previewFromSegmentsArgs({ segments, durs, starts, D, at, dur, re /** * Compose and render the deck (cached by compose-chrome's key), and check the * sequence is as long as the cut -- or the window -- it will be laid over; - * then the posts windows the schedule carries, the same way. Returns the - * overlay plan: the deck's region first, then each posts window's. + * then the posts windows the schedule carries, the same way, and the + * fact-check stamps when it stamps a claim. Returns the overlay plan: the + * deck's region first, then the feed's, each posts window's, the stamps'. * Dynamic import: compose-chrome imports this file. */ async function renderDeck({ manifestPath, render, outDir, variant, schedule, from = 0, duration = null }) { @@ -3084,6 +3092,28 @@ async function renderDeck({ manifestPath, render, outDir, variant, schedule, fro const { window: _w, frameCount: _n, ...region } = pr; regions.push(region); } + + // The fact-check stamps (a schedule whose `factcheck` stamps a claim): one + // sequence for the whole cut -- or the deck's window -- laid last, over the + // posts, at stampGeometry's box in the footage. + if (schedule.factcheck?.stamps?.length) { + EMIT("chrome", { phase: "compose", region: "stamp", ...(duration != null ? { from, duration } : {}) }); + const t1 = Date.now(); + const st = await composeChrome({ + manifestPath, outDir, variant, region: "stamp", doRender: true, + fps: render.fps, workers: 2, quality: "high", format: "png-sequence", + ...(duration != null ? { from, duration } : {}), + }); + if (st.frameCount !== want) { + throw new Error(`the stamps' sequence is ${st.frameCount} frames but the deck's is ${want}`); + } + EMIT("chrome", { + phase: st.cached ? "cached" : "render", region: "stamp", + frames: st.frameCount, key: st.key, dir: st.frames, + seconds: Number(((Date.now() - t1) / 1000).toFixed(1)), + }); + regions.push(stampRegion(render, st.frames, schedule)); + } return { regions, outLabel: "[hfout]" }; } @@ -3347,7 +3377,7 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly // A clip's `muteFrom` and `render.endFade`, checked against the WHOLE // manifest, deck or not: both are made where the cut is joined. { - const errors = [...validateCutEdits(whole), ...validateTeasers(whole)]; + const errors = [...validateCutEdits(whole), ...validateTeasers(whole), ...validateClaims(whole)]; if (errors.length) throw new Error(`manifest: ${errors.join("; ")}`); } const deck = deckOn(render); diff --git a/umtool/report-to-video/chrome-deck.mjs b/umtool/report-to-video/chrome-deck.mjs @@ -38,6 +38,7 @@ import { fileURLToPath } from "node:url"; import { deckChoreography, deckLayout, pageDuration, pipSegments, pipXs, resolveDeck } from "./deck.mjs"; +import { resolveFactcheck, tallyCues, tallyKey, tallyOf } from "./factcheck.mjs"; /** * GSAP, vendored, for every chrome region. @@ -144,7 +145,8 @@ const keyOf = (i, part) => (part === "seg" ? `s${i}` : `s${i}.${part}`); */ export function deckCues(schedule, render) { const deck = resolveDeck(render); - const lay = deckLayout(render); + const tally = tallyOf(schedule, render); + const lay = deckLayout(render, { tally }); const segs = schedule.segments; const ch = deckChoreography(schedule, render); const pipped = pipSegments(schedule); @@ -250,6 +252,14 @@ export function deckCues(schedule, render) { } } + // The fact-check tally (factcheck.mjs): its cells' counts step as each + // stamp lands. Part of the panel, so it slides away and back with it. + if (tally) { + const t = tallyCues(schedule.factcheck.stamps, tally); + for (const [k, v] of Object.entries(t.init)) put(k, v); + for (const e of t.events) add(e.k, e.at, e.dur, e.to, e.ease, e.why); + } + // The fuse: from pip k to pip k+1 over the time the marker rests on k. if (fuseW > 0) { for (let k = 0; k < pips.length - 1; k += 1) { @@ -306,7 +316,8 @@ export function subtitleMarkup(text) { */ export function deckHtml(schedule, render, opts = {}) { const deck = resolveDeck(render); - const lay = deckLayout(render); + const tally = tallyOf(schedule, render); + const lay = deckLayout(render, { tally }); const pal = render.palette; const W = lay.width, H = lay.height; const fonts = opts.fonts ?? {}; @@ -366,6 +377,36 @@ export function deckHtml(schedule, render, opts = {}) { `<div class="pip lit" data-k="pip${k}" style="left:${r4(p.x - pip.size / 2)}px"></div>`, ) .join(""); + // The tally: one cell per verdict the cut stamps, each with every count it + // will show stacked in one box (`n0` … `n<final>`), its label under it. + const tb = lay.tally ?? null; + const verdictSet = tb ? resolveFactcheck(render).verdicts : null; + const countSize = tb ? Math.round(tb.height * 0.42) : 0; + const countBox = Math.round(countSize * 1.12); + const labelSize = 13; + const labelBox = 17; + const cellTop = tb ? Math.round((tb.height - (countBox + 8 + labelBox)) / 2) + 2 : 0; + const tallyHtml = tb + ? `<div class="tally">` + + tally + .map((v, j) => { + const finals = schedule.factcheck.stamps.filter((s) => s.verdict === v).length; + const color = verdictSet[v].color; + const nums = Array.from({ length: finals + 1 }, (_, k) => + `<div class="tn" data-k="${tallyKey(v, `n${k}`)}">${k}</div>`).join(""); + return ( + `<div class="tcell" data-verdict="${esc(v)}" data-k="${tallyKey(v)}" ` + + `style="left:${j * (tb.cell + tb.gap)}px; --vc:${esc(color)}; --vg:${rgba(color, 0.55)}; --vr:${rgba(color, 0.4)}; ` + + `--vl:${mix(color, pal.fg, 0.35)}">` + + `<div class="tbar"></div><div class="tcount">${nums}</div>` + + `<div class="tlabel">${esc(verdictSet[v].label)}</div>` + + `<div class="tflash" data-k="${tallyKey(v, "flash")}"></div></div>` + ); + }) + .join("") + + `</div>` + : ""; + const fuseLeft = pips[0]?.x ?? tr.x0; const fuseWidth = pips.length > 1 ? pips[pips.length - 1].x - fuseLeft : 0; @@ -468,6 +509,23 @@ export function deckHtml(schedule, render, opts = {}) { height: ${qr?.size ?? 0}px; writing-mode: vertical-rl; transform: rotate(180deg); text-align: start; white-space: nowrap; font-size: 11px; line-height: 18px; letter-spacing: ${HOST_TRACKING}em; text-transform: uppercase; color: ${rgba(pal.muted, 0.85)}; } + ${tb ? ` + /* The fact-check tally: a cell per verdict, its count over its label in + the verdict's colour. Dim until its first count; the flash fires as + each stamp lands. */ + .tally { position: absolute; left: ${tb.x}px; top: ${tb.y}px; width: ${tb.width}px; height: ${tb.height}px; } + .tcell { position: absolute; top: 0; width: ${tb.cell}px; height: ${tb.height}px; border-radius: 8px; + background: ${rgba(pal.fg, 0.04)}; box-shadow: inset 0 0 0 1px var(--vr); overflow: hidden; } + .tbar { position: absolute; left: 0; top: 0; width: 100%; height: 4px; background: var(--vc); } + .tcount { position: absolute; left: 0; top: ${cellTop}px; width: 100%; height: ${countBox}px; overflow: hidden; } + .tn { position: absolute; left: 0; top: 0; width: 100%; text-align: center; font-family: 'DeckSansBold', sans-serif; + font-size: ${countSize}px; line-height: ${countBox}px; color: var(--vc); font-variant-numeric: tabular-nums; } + .tlabel { position: absolute; left: 6px; top: ${cellTop + countBox + 8}px; width: ${tb.cell - 12}px; text-align: center; + font-family: 'DeckSansBold', sans-serif; font-size: ${labelSize}px; line-height: ${labelBox}px; + letter-spacing: 0.08em; text-transform: uppercase; white-space: nowrap; overflow: hidden; + text-overflow: ellipsis; color: var(--vl); } + .tflash { position: absolute; inset: 0; border-radius: 8px; opacity: 0; pointer-events: none; + box-shadow: inset 0 0 0 2px var(--vc), inset 0 0 22px var(--vg); }` : ""} .deck-qr img { display: block; width: ${qr?.size ?? 0}px; height: ${qr?.size ?? 0}px; border-radius: 6px; image-rendering: pixelated; backface-visibility: hidden; box-shadow: 0 0 0 1px ${rgba(pal.fg, 0.25)}, 0 6px 18px rgba(0, 0, 0, 0.35); } @@ -487,6 +545,7 @@ export function deckHtml(schedule, render, opts = {}) { ${pipHtml} <div class="marker" data-k="marker"></div> </div> + ${tallyHtml} ${segHtml} </div> </div> diff --git a/umtool/report-to-video/chrome-feed.mjs b/umtool/report-to-video/chrome-feed.mjs @@ -207,6 +207,8 @@ export function feedWho(posts) { * * `fonts` = `{ regular, bold }` asset-relative paths (DeckSans / DeckSansBold), * `qrSrcs` = `{ [postId]: "assets/qrNN.png" }`, `gsap` = the vendored script. + * `shotSrcs` = `{ [postId]: "assets/shotNN.png" }`: a post with a screenshot + * (`posts[].shot`) draws it in place of its text card, its QR cell kept. * The region is `feedGeometry(render).column`, region-local, transparent * outside the panel. `?still=<t>` and the preview's `deck:seek` take CUT seconds. */ @@ -219,6 +221,7 @@ export function feedHtml(schedule, render, opts = {}) { const W = lay.width, H = lay.height; const fonts = opts.fonts ?? {}; const qrSrcs = opts.qrSrcs ?? {}; + const shotSrcs = opts.shotSrcs ?? {}; const gsapSrc = opts.gsap ?? "assets/gsap.min.js"; const total = schedule.total; const from = Number(opts.from ?? 0); @@ -243,15 +246,18 @@ export function feedHtml(schedule, render, opts = {}) { .map((p, j) => { const { name, platform } = postWho(p); const src = qrSrcs[p.id]; + const shot = shotSrcs[p.id]; return ( - `<article class="post" data-post="${esc(p.id)}" data-k="c${j}">` + - `<div class="body">` + - `<div class="meta">` + - (who.single ? "" : (platform ? `<span class="platform">${esc(platform)}</span>` : "") + - `<span class="who"><span class="handle">${esc(name)}</span></span>`) + - `<span class="date">${esc(postDate(p, deck.subtitle.dateFormat))}</span></div>` + - `<div class="text">${postParagraphs(p.text).map((t) => `<p class="para">${esc(t)}</p>`).join("")}</div>` + - `</div>` + + `<article class="post${shot ? " has-shot" : ""}" data-post="${esc(p.id)}" data-k="c${j}">` + + (shot + ? `<div class="body shot-body"><img class="shot" src="${esc(shot)}" alt=""></div>` + : `<div class="body">` + + `<div class="meta">` + + (who.single ? "" : (platform ? `<span class="platform">${esc(platform)}</span>` : "") + + `<span class="who"><span class="handle">${esc(name)}</span></span>`) + + `<span class="date">${esc(postDate(p, deck.subtitle.dateFormat))}</span></div>` + + `<div class="text">${postParagraphs(p.text).map((t) => `<p class="para">${esc(t)}</p>`).join("")}</div>` + + `</div>`) + `<div class="plate">` + (src ? `<img src="${esc(src)}" width="${c.qrSize}" height="${c.qrSize}" alt="">` : "") + `</div>` + @@ -347,6 +353,12 @@ export function feedHtml(schedule, render, opts = {}) { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: ${c.maxLines}; } .para + .para { margin-top: ${Math.round(c.lineH * 0.34)}px; } .para.gone { display: none; } + /* A post's screenshot in place of its words: as wide as the words would + be, no taller than a full card of them. */ + .shot-body { padding: ${c.pad - 6}px; } + .shot { display: block; width: 100%; height: auto; + max-height: ${Math.round(c.metaSize * 1.3) + 6 + c.maxLines * c.lineH + 2 * c.pad}px; + object-fit: contain; object-position: left top; border-radius: 6px; } /* The source cell: the QR in a cell a shade down, as on the deck. */ .plate { position: absolute; ${left ? "left" : "right"}: 0; top: 0; bottom: 0; width: ${c.plate}px; display: flex; align-items: center; justify-content: center; @@ -417,9 +429,11 @@ export function feedHtml(schedule, render, opts = {}) { // Measure once the faces are in, then plan, place, build and register. // The renderer awaits document.fonts.ready before it seeks a frame. + // A screenshot's height is its picture's: decoded before anything is measured. const ready = Promise.all([ document.fonts.load(F.textSize + "px DeckSans"), document.fonts.load(F.metaSize + "px DeckSansBold"), + ...[...document.querySelectorAll("img.shot")].map((i) => i.decode().catch(() => {})), ]).catch(() => {}).then(() => { const cards = F.ids.map((_, j) => byK["c" + j]); cards.forEach(clampText); diff --git a/umtool/report-to-video/chrome-posts.mjs b/umtool/report-to-video/chrome-posts.mjs @@ -204,6 +204,8 @@ export function postDate(post, dateFormat = "long") { * * `fonts` = `{ regular, bold }` asset-relative paths (DeckSans / DeckSansBold), * `qrSrcs` = `{ [postId]: "assets/pqrNN.png" }`, `gsap` = the vendored script. + * `shotSrcs` = `{ [postId]: "assets/shotNN.png" }`: a post with a screenshot + * (`posts[].shot`) draws it in place of its text card, its QR cell kept. * The region is `postsGeometry(render)`, region-local and transparent outside * the cards. `?still=<t>` and the preview's `deck:seek` take CUT seconds. */ @@ -215,6 +217,7 @@ export function postsHtml(schedule, render, window, opts = {}) { const W = geo.width, H = geo.height; const fonts = opts.fonts ?? {}; const qrSrcs = opts.qrSrcs ?? {}; + const shotSrcs = opts.shotSrcs ?? {}; const gsapSrc = opts.gsap ?? "assets/gsap.min.js"; const posts = windowPosts(schedule, window.segment); if (!posts.length) throw new Error(`posts: no post rides on ${window.segment}`); @@ -234,15 +237,18 @@ export function postsHtml(schedule, render, window, opts = {}) { .map((p, j) => { const { name, platform } = postWho(p); const src = qrSrcs[p.id]; + const shot = shotSrcs[p.id]; return ( - `<article class="post" data-post="${esc(p.id)}" data-k="c${j}">` + - `<div class="body">` + - `<div class="meta">` + - (platform ? `<span class="platform">${esc(platform)}</span>` : "") + - `<span class="who"><span class="handle">${esc(name)}</span></span>` + - `<span class="date">${esc(postDate(p, deck.subtitle.dateFormat))}</span></div>` + - `<div class="text">${postParagraphs(p.text).map((t) => `<p class="para">${esc(t)}</p>`).join("")}</div>` + - `</div>` + + `<article class="post${shot ? " has-shot" : ""}" data-post="${esc(p.id)}" data-k="c${j}">` + + (shot + ? `<div class="body shot-body"><img class="shot" src="${esc(shot)}" alt=""></div>` + : `<div class="body">` + + `<div class="meta">` + + (platform ? `<span class="platform">${esc(platform)}</span>` : "") + + `<span class="who"><span class="handle">${esc(name)}</span></span>` + + `<span class="date">${esc(postDate(p, deck.subtitle.dateFormat))}</span></div>` + + `<div class="text">${postParagraphs(p.text).map((t) => `<p class="para">${esc(t)}</p>`).join("")}</div>` + + `</div>`) + `<div class="plate">` + (src ? `<img src="${esc(src)}" width="${set.qrSize}" height="${set.qrSize}" alt="">` : "") + `</div>` + @@ -328,6 +334,11 @@ export function postsHtml(schedule, render, window, opts = {}) { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: ${set.maxLines}; } .para + .para { margin-top: ${Math.round(lineH * 0.36)}px; } .para.gone { display: none; } + /* A post's screenshot in place of its words: as wide as the words would + be, no taller than a full card of them. */ + .shot-body { padding: ${pad - 6}px; } + .shot { display: block; width: 100%; height: auto; max-height: ${Math.round(metaSize * 1.3) + 8 + set.maxLines * lineH + 2 * pad}px; + object-fit: contain; object-position: left top; border-radius: 6px; } /* The source cell: the QR in a cell a shade down, as on the deck. */ .plate { position: absolute; right: 0; top: 0; bottom: 0; width: ${plateW}px; display: flex; align-items: center; justify-content: center; @@ -386,9 +397,11 @@ export function postsHtml(schedule, render, window, opts = {}) { } } + // A screenshot's height is its picture's: decoded before anything is measured. const ready = Promise.all([ document.fonts.load("${textSize}px DeckSans"), document.fonts.load("${metaSize}px DeckSansBold"), + ...[...document.querySelectorAll("img.shot")].map((i) => i.decode().catch(() => {})), ]).catch(() => {}).then(() => { const cards = P.ids.map((_, j) => byK["c" + j]); cards.forEach(clampText); diff --git a/umtool/report-to-video/chrome-stamp.mjs b/umtool/report-to-video/chrome-stamp.mjs @@ -0,0 +1,246 @@ +// 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, "&amp;") + .replace(/</g, "&lt;") + .replace(/>/g, "&gt;") + .replace(/"/g, "&quot;"); + +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<j>` (autoAlpha, + * scale, rotation) with its flash `fl<j>` (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<string, object>, 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=<t>` 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 ( + `<div class="stamp" data-claim="${esc(s.claim)}" data-verdict="${esc(s.verdict)}" data-k="st${j}" ` + + `style="--vc:${esc(v.color)}; --vg:${rgba(v.color, 0.6)}; --vb:${rgba(mix(pal.bg, v.color, 0.12), 0.82)}">` + + `<div class="word">${esc(v.label)}</div>` + + `<div class="flash" data-k="fl${j}"></div></div>` + ); + }) + .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), + }; + // `</script>` inside a JSON string would close the tag; a claim id is the manifest's. + const json = JSON.stringify(data).replace(/</g, "\\u003c"); + const fps = schedule.fps ?? render.fps ?? 30; + + return `<!doctype html> +<html lang="en"> + <head> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=${W}, height=${H}" /> + <script src="${esc(gsapSrc)}"></script> + <style> + /* The deck's faces, under the deck's private names -- see docs/quirks.md. */ + @font-face { font-family: 'DeckSans'; font-weight: 400; font-style: normal; + src: url('${esc(fonts.regular ?? "")}'); } + @font-face { font-family: 'DeckSansBold'; font-weight: 400; font-style: normal; + src: url('${esc(fonts.bold ?? "")}'); } + * { margin: 0; padding: 0; box-sizing: border-box; } + html, body { width: ${W}px; height: ${H}px; overflow: hidden; background: transparent; } + body { font-family: 'DeckSansBold', sans-serif; font-synthesis: none; + -webkit-font-smoothing: antialiased; text-rendering: geometricPrecision; } + #root { position: relative; width: ${W}px; height: ${H}px; overflow: hidden; } + #stamp-clip { position: absolute; left: 0; top: 0; width: ${W}px; height: ${H}px; } + /* A rubber stamp: a double rule in the verdict's colour round its word, + on a dark wash so it reads over any footage. Hidden until its cue. */ + .stamp { position: absolute; left: ${Math.round((W - bw) / 2)}px; top: ${Math.round((H - bh) / 2)}px; + width: ${bw}px; height: ${bh}px; border-radius: 12px; visibility: hidden; opacity: 0; + display: flex; align-items: center; justify-content: center; + background: var(--vb); border: 5px solid var(--vc); + box-shadow: inset 0 0 0 4px var(--vb), inset 0 0 0 7px var(--vc), 0 10px 30px rgba(0, 0, 0, 0.45); } + .word { max-width: ${bw - 48}px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + font-size: ${size}px; line-height: ${Math.round(size * 1.15)}px; letter-spacing: 0.06em; + text-transform: uppercase; color: var(--vc); } + .flash { position: absolute; inset: -5px; border-radius: 12px; opacity: 0; pointer-events: none; + box-shadow: 0 0 0 3px var(--vc), 0 0 36px 10px var(--vg); } + </style> + </head> + <body> + <div id="root" data-composition-id="stamp" data-start="0" data-duration="${pageDuration(dur, fps)}" + data-width="${W}" data-height="${H}"> + <div id="stamp-clip" class="clip" data-start="0" data-duration="${pageDuration(dur, fps)}" data-track-index="1"> + ${stampHtmlList} + </div> + </div> + + <script id="stamp-data" type="application/json">${json}</script> + <script> + const S = JSON.parse(document.getElementById("stamp-data").textContent); + const byK = {}; + for (const el of document.querySelectorAll("[data-k]")) byK[el.dataset.k] = el; + + // t = 0, then the cues. Every cue states its from, so a seek from + // anywhere to anywhere lands on the same pixels. + for (const k of Object.keys(S.init)) if (byK[k]) gsap.set(byK[k], S.init[k]); + const inner = gsap.timeline({ paused: true }); + for (const c of S.cues) { + const el = byK[c.k]; + if (!el) continue; + inner.fromTo(el, c.from, { ...c.to, duration: c.dur, ease: c.ease, immediateRender: false }, c.at); + } + // The timeline runs to the cut's end, whatever its last cue. + inner.set({}, {}, S.total); + let tl = inner; + if (S.window) { + tl = gsap.timeline({ paused: true }); + tl.add(inner.tweenFromTo(S.window.from, S.window.from + S.window.dur, { duration: S.window.dur, ease: "none" }), 0); + } + window.__timelines = window.__timelines || {}; + window.__timelines["stamp"] = tl; + + // A label wider than its stamp shrinks a pixel at a time to half its + // size, then ellipsizes (the CSS already does), once the face is in. + function fitWord(node) { + let s = S.size; + node.style.fontSize = s + "px"; + while (s > S.floor && node.scrollWidth > node.clientWidth + 0.5) { + s -= 1; + node.style.fontSize = s + "px"; + } + } + const ready = document.fonts.load(S.size + "px DeckSansBold").catch(() => {}).then(() => { + document.querySelectorAll(".word").forEach(fitWord); + document.documentElement.dataset.fit = "1"; + }); + + const params = new URLSearchParams(location.search); + // A window plays the cut's [from, from + dur]; the page is seeked in its own seconds. + const local = (t) => { + const v = Number(t) || 0; + return S.window ? Math.max(0, Math.min(S.window.dur, v - S.window.from)) : Math.max(0, v); + }; + const still = params.get("still"); + if (still !== null) { + tl.seek(local(still), false); + ready.then(() => tl.seek(local(still), false)); + } + + // umtool's live preview: the parent seeks in cut seconds, as it seeks the deck. + if (params.get("preview") === "1") { + window.addEventListener("message", (e) => { + const m = e.data || {}; + if (m.type === "deck:seek") tl.seek(local(m.t), false); + }); + ready.then(() => { + if (window.parent !== window) { + window.parent.postMessage({ type: "stamp:ready", total: S.total, ids: S.ids }, "*"); + } + }); + } + </script> + </body> +</html> +`; +} diff --git a/umtool/report-to-video/compose-chrome.mjs b/umtool/report-to-video/compose-chrome.mjs @@ -46,6 +46,7 @@ import { import { deckHtml, GSAP_FILE } from "./chrome-deck.mjs"; import { postsHtml, snapWindow, windowPosts } from "./chrome-posts.mjs"; import { feedHtml } from "./chrome-feed.mjs"; +import { stampHtml } from "./chrome-stamp.mjs"; const run = promisify(execFile); @@ -612,11 +613,38 @@ async function copyFonts(render, assetsDir, names, { strict }) { } /** + * The posts' screenshots (`posts[].shot`, relative to the manifest), copied + * in beside the page as `shotNN<ext>`, one per distinct file. Returns + * `{ [postId]: "assets/shotNN.png" }`. A shot that cannot be copied refuses + * the region: a card drawn without the picture its post names is not the + * card the manifest asks for. + */ +async function copyShots(posts, manifestDir, assetsDir) { + const out = {}; + const byFile = new Map(); + for (const p of posts) { + if (typeof p.shot !== "string" || !p.shot) continue; + const file = path.resolve(manifestDir, p.shot); + if (!byFile.has(file)) { + const name = `shot${String(byFile.size).padStart(2, "0")}${path.extname(file).toLowerCase()}`; + try { + await copyFile(file, path.join(assetsDir, name)); + } catch (e) { + throw new Error(`post ${p.id}: its shot ${p.shot} cannot be copied: ${e.message}`); + } + byFile.set(file, `assets/${name}`); + } + out[p.id] = byFile.get(file); + } + return out; +} + +/** * Which HTML each chrome region is, with the assets it needs written into * `projDir/assets`. The chart's branch is the band as it shipped; only where * its GSAP comes from has changed. */ -async function regionHtml(region, { manifest, base, projDir, assetsDir, schedule, duration, from, window, teaser, transition }) { +async function regionHtml(region, { manifest, manifestDir, base, projDir, assetsDir, schedule, duration, from, window, teaser, transition }) { if (region === "chart") { const sched = schedule ?? JSON.parse(await readFile(path.join(base, "schedule.json"), "utf8")); const fonts = await copyFonts(manifest.render, assetsDir, { regular: "regular", bold: "bold" }, { strict: false }); @@ -643,7 +671,8 @@ async function regionHtml(region, { manifest, base, projDir, assetsDir, schedule const byUrl = await qrPngsFor(posts.map((p) => p.qrUrl), render, resolveDeck(render).posts.qrSize, assetsDir); const qrSrcs = {}; for (const p of posts) if (p.qrUrl) qrSrcs[p.id] = byUrl.get(p.qrUrl); - return postsHtml(schedule, render, window, { fonts, qrSrcs }); + const shotSrcs = await copyShots(posts, manifestDir, assetsDir); + return postsHtml(schedule, render, window, { fonts, qrSrcs, shotSrcs }); } if (region === "feed") { // The posts feed: the deck's faces and QR maker, one page for the whole cut. @@ -653,7 +682,13 @@ async function regionHtml(region, { manifest, base, projDir, assetsDir, schedule const byUrl = await qrPngsFor(posts.map((p) => p.qrUrl), render, resolveDeck(render).posts.qrSize, assetsDir); const qrSrcs = {}; for (const p of posts) if (p.qrUrl) qrSrcs[p.id] = byUrl.get(p.qrUrl); - return feedHtml(schedule, render, { fonts, qrSrcs, from, duration }); + const shotSrcs = await copyShots(posts, manifestDir, assetsDir); + return feedHtml(schedule, render, { fonts, qrSrcs, shotSrcs, from, duration }); + } + if (region === "stamp") { + // The fact-check stamps (chrome-stamp.mjs): the deck's faces, no QR. + const fonts = await copyFonts(manifest.render, assetsDir, { regular: "DeckSans", bold: "DeckSansBold" }, { strict: true }); + return stampHtml(schedule, manifest.render, { fonts, from, duration }); } if (region === "teaser") { // A page module reached by a dynamic import, so nothing that imports this @@ -729,6 +764,12 @@ function runRenderer(cmd, args) { * (`feed-preview/` when `preview`), frames `chrome/feed-frames/` and their * `.key`, a window `feed-from<s>[-frames]`, cached as the deck's are. * + * Stamp (`region: "stamp"`, a schedule whose `factcheck.stamps` stamps a + * claim): the fact-check stamps for the whole cut, exactly as the deck -- + * project `chrome/stamp/` (`stamp-preview/` when `preview`), frames + * `chrome/stamp-frames/` and their `.key`, a window `stamp-from<s>[-frames]`, + * cached as the deck's are. + * * Teaser (`region: "teaser"`, `segment` = the entry's id): the whole frame, * `seconds` long, drawn from the entry alone (no schedule): * - project `chrome/teaser-<id>/` (`teaser-preview-<id>/` when `preview`), @@ -766,7 +807,9 @@ export async function composeChrome({ // The regions keyed by the render cache: the two drawn from the deck's // schedule, and a teaser, drawn from its own timeline entry. - const keyed = region === "deck" || region === "feed" || region === "posts" || region === "teaser"; + const keyed = region === "deck" || region === "feed" || region === "posts" || region === "teaser" || region === "stamp"; + // The regions drawn over the whole cut from its schedule, windowable alike. + const wholeCut = region === "deck" || region === "feed" || region === "stamp"; let teaser = null; if (region === "teaser") { teaser = (manifest.timeline ?? []).find((e) => e.id === segment && e.type === "teaser") ?? null; @@ -775,7 +818,7 @@ export async function composeChrome({ if (errors.length) throw new Error(`teaser ${teaser.id}: ${errors.join("; ")}`); } let sched = schedule; - if ((region === "deck" || region === "feed" || region === "posts") && !sched) { + if ((wholeCut || region === "posts") && !sched) { const p = path.join(base, "schedule.json"); try { sched = JSON.parse(await readFile(p, "utf8")); @@ -787,11 +830,14 @@ export async function composeChrome({ if (region === "feed" && sched.layout !== "feed") { throw new Error("the feed region needs a feed's schedule (layout \"feed\": posts.layout \"feed\" and posts to draw)"); } + if (region === "stamp" && !sched.factcheck?.stamps?.length) { + throw new Error("the stamp region needs a schedule that stamps a claim (an entry with a `claim`)"); + } const D = transition ?? transitionOf(manifest.render); const rate = Number(fps ?? sched?.fps ?? manifest.render.fps ?? 30); const total = teaser ? teaserSeconds(teaser, D, rate) : keyed ? sched.total : null; const windowed = - (region === "deck" || region === "feed") && + wholeCut && (from > 0 || (duration != null && Math.abs(Number(duration) - total) > 1e-6)); const suffix = windowed ? `-from${fmtSeconds(from)}` : ""; @@ -813,7 +859,7 @@ export async function composeChrome({ ? `posts-${preview ? "preview-" : ""}${win.segment}` : region === "teaser" ? `teaser-${preview ? "preview-" : ""}${teaser.id}` - : (region === "deck" || region === "feed") && preview ? `${region}-preview` : `${region}${suffix}`; + : wholeCut && preview ? `${region}-preview` : `${region}${suffix}`; const projDir = path.join(base, "chrome", projName); const assetsDir = path.join(projDir, "assets"); // The deck's assets are rebuilt every time: a QR from a clip that has since @@ -823,7 +869,7 @@ export async function composeChrome({ await copyFile(GSAP_FILE, path.join(assetsDir, "gsap.min.js")); const html = await regionHtml(region, { - manifest, base, projDir, assetsDir, schedule: sched, + manifest, manifestDir: path.dirname(path.resolve(manifestPath)), base, projDir, assetsDir, schedule: sched, duration: duration != null ? Number(duration) : null, from, window: win, teaser, transition: D, }); await writeFile(path.join(projDir, "index.html"), html, "utf8"); @@ -912,7 +958,7 @@ if (import.meta.url === `file://${process.argv[1]}`) { const manifestPath = argv.find((a, i) => !a.startsWith("--") && !VALUED.has(argv[i - 1])); if (!manifestPath) { console.error( - "usage: compose-chrome.mjs <manifest.json> [--region chart|deck|feed|posts|teaser] [--variant sourced|full]\n" + + "usage: compose-chrome.mjs <manifest.json> [--region chart|deck|feed|stamp|posts|teaser] [--variant sourced|full]\n" + " [--segment <id>] (posts: the clip whose window to compose; teaser: its entry)\n" + " [--from <s>] [--duration <s>] [--out <dir>] [--preview]\n" + " [--still <s> --png <path>]\n" + @@ -935,7 +981,7 @@ if (import.meta.url === `file://${process.argv[1]}`) { still: num("--still"), png: flag("--png"), // The deck renders four-wide by default; the band keeps the renderer's own default. - workers: num("--workers") ?? (region === "deck" || region === "feed" || region === "teaser" ? 4 : region === "posts" ? 2 : null), + workers: num("--workers") ?? (region === "deck" || region === "feed" || region === "teaser" ? 4 : region === "posts" || region === "stamp" ? 2 : null), quality: flag("--quality") ?? "high", format: flag("--format") ?? "png-sequence", fps: num("--fps"), diff --git a/umtool/report-to-video/deck.mjs b/umtool/report-to-video/deck.mjs @@ -17,6 +17,9 @@ import { createHash } from "node:crypto"; import { attributionParts, deckSubtitle } from "./attribution.mjs"; +import { + claimOf, originalUrlAt, resolveFactcheck, roundStamps, stampSchedule, validateFactcheck, +} from "./factcheck.mjs"; /** The renderer version, pinned. It is part of the cache key: a new renderer is new frames. */ export const HYPERFRAMES_PKG_DEFAULT = "hyperframes@0.8.24"; @@ -33,7 +36,10 @@ export const DECK_DEFAULTS = Object.freeze({ pip: Object.freeze({ spacing: "even", size: 6, activeSize: 12 }), // spacing | "time" title: Object.freeze({ size: 54, maxChars: 48 }), subtitle: Object.freeze({ parts: "auto", dateFormat: "long" }), // dateFormat | "iso" - qr: Object.freeze({ show: true, size: 150 }), + // `links` is where a clip's QR goes: "site" (the archive's page at the + // clip's start) or "original" (the record's own page -- YouTube at the + // second, a Rumble page, an X post). An entry's `citeUrl` wins either way. + qr: Object.freeze({ show: true, size: 150, links: "site" }), overCards: "hide", // | "show" motion: Object.freeze({ out: 0.3, in: 0.45, pip: 0.7 }), // The manifest's `posts`, drawn as cards over the footage at the end of the @@ -55,6 +61,9 @@ export const DECK_DEFAULTS = Object.freeze({ }), }); +/** Where a clip's deck QR links (`qr.links`): the archive at the clip, or the original. */ +export const QR_LINKS = Object.freeze(["site", "original"]); + /** Subtitle tokens a `subtitle.parts` array may name. "auto" picks from these. */ export const SUBTITLE_TOKENS = Object.freeze(["channel", "title", "date", "clock"]); @@ -118,7 +127,7 @@ export function validateChrome(chrome, render = {}) { const errors = []; if (chrome === undefined || chrome === null) return errors; if (!isObj(chrome)) return ["render.chrome must be an object"]; - unknownKeys(chrome, ["engine", "layout", "deck"], "render.chrome", errors); + unknownKeys(chrome, ["engine", "layout", "deck", "factcheck"], "render.chrome", errors); if (chrome.engine !== "hyperframes") errors.push('render.chrome.engine must be "hyperframes"'); if (chrome.layout !== "deck") errors.push('render.chrome.layout must be "deck"'); if (render.rail) errors.push("render.chrome (the deck) and render.rail cannot both be set"); @@ -128,6 +137,8 @@ export function validateChrome(chrome, render = {}) { // The end fade is a render key the deck's cut is finished with; checked // here too, so a writer that validates the deck refuses a bad one. errors.push(...validateEndFade(render)); + // The fact-check stamps and tally (factcheck.mjs): drawn only by the deck. + errors.push(...validateFactcheck(chrome.factcheck)); const d = chrome.deck ?? {}; if (!isObj(d)) return [...errors, "render.chrome.deck must be an object"]; const w = "render.chrome.deck"; @@ -185,8 +196,11 @@ export function validateChrome(chrome, render = {}) { errors.push(`${w}.subtitle.dateFormat must be "long" or "iso"`); } }); - sub("qr", ["show", "size"], (q) => { + sub("qr", ["show", "size", "links"], (q) => { if (q.show !== undefined && typeof q.show !== "boolean") errors.push(`${w}.qr.show must be true or false`); + if (q.links !== undefined && !QR_LINKS.includes(q.links)) { + errors.push(`${w}.qr.links must be ${QR_LINKS.map((l) => `"${l}"`).join(" or ")}`); + } if (q.size !== undefined && !numIn(q.size, 80, 380)) errors.push(`${w}.qr.size must be from 80 to 380`); const h = d.height ?? DECK_DEFAULTS.height; const size = q.size ?? DECK_DEFAULTS.qr.size; @@ -319,8 +333,12 @@ export function deckGeometry(render) { * Where things sit INSIDE the deck region (region-local pixels). The * composition draws from this; umtool's preview frames the same rect. A * starting layout -- the composition may tune the numbers here, never copy them. + * + * `tally` is the fact-check tally's verdicts (factcheck.mjs `tallyOf`), or + * null for none: with one, the tally takes a block of cells at one end of the + * text column (`factcheck.tally.position`) and the text keeps the rest. */ -export function deckLayout(render) { +export function deckLayout(render, { tally = null } = {}) { const { W, deck: rect } = deckGeometry(render); const d = resolveDeck(render); const padX = 48; @@ -333,8 +351,26 @@ export function deckLayout(render) { const qr = d.qr.show ? { x: W - padX - d.qr.size, y: Math.round(bodyTop + (bodyH - d.qr.size) / 2), size: d.qr.size } : null; - const textX = padX; - const textRight = qr ? qr.x - 48 : W - padX; + let textX = padX; + let textRight = qr ? qr.x - 48 : W - padX; + let tallyBox = null; + if (tally?.length) { + // A cell per verdict the cut uses: its count over its label. Beside the + // QR's cell (whose plate starts 46 px before the code) on the right, or + // ahead of the title on the left; 40 px of air to the text either way. + const cell = 124, gap = 10; + const width = tally.length * cell + (tally.length - 1) * gap; + const height = Math.min(bodyH - 8, 118); + const y = Math.round(bodyTop + (bodyH - height) / 2); + if (resolveFactcheck(render).tally.position === "left") { + tallyBox = { x: padX, y, width, height, cell, gap }; + textX = padX + width + 40; + } else { + const right = qr ? qr.x - 46 - 24 : W - padX; + tallyBox = { x: right - width, y, width, height, cell, gap }; + textRight = tallyBox.x - 40; + } + } // Line boxes, not font sizes: the title's box holds its descenders inside the // wipe's overflow clip, and the rule sits in the gap between the two lines. // The subtitle scales with the title (26 px at the default 54). @@ -361,6 +397,8 @@ export function deckLayout(render) { subtitleBox, }, qr, + // Only with a tally, so a deck without claims lays out as it always did. + ...(tallyBox ? { tally: tallyBox } : {}), }; } @@ -492,10 +530,17 @@ export function deckText(entry, meta, provenance, deck, multiChannel) { * per-clip corner QR's rule unchanged (`citeUrl` wins, else the site link at * the clip's start); a still has one only when it carries a `citeUrl`; a card * has none. + * + * With `links: "original"` (the deck's `qr.links`) a clip without a `citeUrl` + * links its record's own page instead (`meta.webpageUrl`, factcheck.mjs + * `originalUrlAt`: YouTube at the clip's start, a Rumble page or an X post + * as-is) -- or the site link still, when its record names none. */ -export function deckQrUrl(entry, provenance = {}) { +export function deckQrUrl(entry, provenance = {}, { links = DECK_DEFAULTS.qr.links, meta = null } = {}) { if (entry.type === "clip") { - return entry.citeUrl ?? + if (entry.citeUrl) return entry.citeUrl; + const original = links === "original" ? originalUrlAt(meta?.webpageUrl, entry.start) : null; + return original ?? `${provenance.siteOrigin}/?v=${encodeURIComponent( `${entry.channel ?? provenance.channelSlug}/${entry.video}`, )}&t=${Math.floor(entry.start)}`; @@ -507,7 +552,8 @@ export function deckQrUrl(entry, provenance = {}) { /** * The schedule document (`out/<variant>/schedule.json` when the deck is on). * - * `metas[i]` is entry i's source metadata (`{ title, uploadDate, channel }`) + * `metas[i]` is entry i's source metadata (`{ title, uploadDate, channel, + * webpageUrl }`; the last read only by `qr.links: "original"`) * or null. The build passes probed durations and real metadata; an estimate * passes `estimatedDuration`s and whatever metadata it has. * @@ -515,7 +561,9 @@ export function deckQrUrl(entry, provenance = {}) { * transition: number, total: number, multiChannel: boolean, * segments: Array<{ id: string, type: string, start: number, duration: number, * end: number, title: string, subtitle: string, qrUrl: string|null, - * hideDeck: boolean }> }} + * hideDeck: boolean, claim?: { id: string, verdict: string } }>, + * factcheck?: { stamps: Array<{ claim: string, verdict: string, segment: string, + * at: number, landed: number, out: [number, number] }> } }} */ export function deckSchedule({ entries, durs, D, render, provenance = {}, metas = [], estimated = false, posts = [], @@ -537,6 +585,7 @@ export function deckSchedule({ // the deck alone, and writes the schedule a deck without posts always did. const feed = placed.length > 0 && deck.posts.layout === "feed"; const moves = placed.length && !feed ? footageMoves({ posts: placed, segments: segs, render }) : []; + const stamps = stampSchedule({ segments: segs.map((s, i) => ({ ...s, claim: claimOf(entries[i]) })), D, total, render }); return { version: 1, kind: "deck", @@ -558,12 +607,16 @@ export function deckSchedule({ ...(holds.get(e.id) ? { hold: round(holds.get(e.id)) } : {}), title, subtitle, - qrUrl: deck.qr.show ? deckQrUrl(e, provenance) : null, + qrUrl: deck.qr.show ? deckQrUrl(e, provenance, { links: deck.qr.links, meta: metas[i] ?? null }) : null, hideDeck: hidesDeck(e, deck), // Only on a teaser that dips, so a cut without one writes the schedule it always did. ...(dipOf(e, fps) ? { dip: dipOf(e, fps) } : {}), + // Only on an entry that carries a claim (factcheck.mjs), likewise. + ...(claimOf(e) ? { claim: claimOf(e) } : {}), }; }), + // The fact-check stamps: present only when a claim is stamped. + ...(stamps.length ? { factcheck: { stamps: roundStamps(stamps) } } : {}), // Present only when there are posts to draw, so a cut without them writes // the schedule it always did. ...(placed.length ? { posts: roundPosts(placed) } : {}), @@ -680,7 +733,27 @@ export const POST_PLATFORMS = Object.freeze(["bluesky", "x"]); export const POST_LAYOUTS = Object.freeze(["popup", "feed"]); /** Where a post's QR links (`posts.links`): its page on the archive, or the platform's own link. */ export const POST_LINKS = Object.freeze(["archive", "original"]); -const POST_KEYS = ["id", "platform", "author", "handle", "date", "text", "url", "attachTo", "hide", "siteChannel", "siteUrl", "postId"]; +const POST_KEYS = ["id", "platform", "author", "handle", "date", "text", "url", "attachTo", "hide", "siteChannel", "siteUrl", "postId", "shot"]; + +/** The pictures a post's `shot` may be. */ +export const POST_SHOT_EXTS = Object.freeze([".png", ".jpg", ".jpeg", ".webp"]); + +/** + * Why a post's `shot` cannot be drawn, or null: a path to a screenshot of the + * post, RELATIVE to the manifest (it is checked in beside it, as an image + * entry's `src` is), inside the manifest's folder, and a picture by its name. + */ +function shotError(shot) { + if (typeof shot !== "string" || !shot.trim()) return "must be a path to the post's screenshot, relative to the manifest"; + const parts = shot.split(/[\\/]+/); + if (/^([A-Za-z]:)?[\\/]/.test(shot) || parts.includes("..")) { + return "must be relative to the manifest and inside its folder (no leading /, no ..)"; + } + const dot = shot.lastIndexOf("."); + const ext = dot < 0 ? "" : shot.slice(dot).toLowerCase(); + if (!POST_SHOT_EXTS.includes(ext)) return `must be a ${POST_SHOT_EXTS.join(", ")} picture`; + return null; +} /** * Every reason the manifest's `posts` cannot be built, as sentences. `timeline` @@ -729,6 +802,8 @@ export function validatePosts(posts, timeline = [], render = null) { if (p.postId != null && (typeof p.postId !== "string" || !POST_ID_RE.test(p.postId))) { errors.push(`${w}.postId must be the post's id on its platform (letters, digits, dashes, underscores)`); } + // A screenshot drawn instead of the text card; the text stays the post's words. + if (p.shot != null && shotError(p.shot)) errors.push(`${w}.shot ${shotError(p.shot)}`); }); // Posts that will be drawn need a column that fits the footage box. if (render && deckOn(render) && resolveDeck(render).posts.show && posts.some((p) => isObj(p) && !p.hide)) { @@ -883,6 +958,8 @@ function postFieldsOf(p, provenance, links) { platform: p.platform, url: p.url, qrUrl: postQrUrl(p, provenance, { links }), + // Only with a screenshot, so a post without one places as it always did. + ...(typeof p.shot === "string" && p.shot ? { shot: p.shot } : {}), }; } @@ -1050,6 +1127,29 @@ export function feedGeometry(render) { } /** + * Where the fact-check stamp region sits in the frame (chrome-stamp.mjs): a + * box in the footage box -- the feed's when `feed` (the schedule's `layout: + * "feed"`), else the deck's -- at `factcheck.stamp.position`, `inset` clear of + * its edges. 560×200 at 1920 wide, scaled with the frame; the stamp is drawn + * tilted inside it with room to spare. + * + * @returns {{ x: number, y: number, width: number, height: number }} + */ +export function stampGeometry(render, { feed = false } = {}) { + const { W } = deckGeometry(render); + const f = feed ? feedGeometry(render).footage : deckGeometry(render).footage; + const k = W / 1920; + const width = Math.min(even(560 * k), even(f.width)); + const height = Math.min(even(200 * k), even(f.height)); + const inset = Math.round(28 * k); + const pos = resolveFactcheck(render).stamp.position; + const [v, h] = pos === "center" ? ["center", "center"] : pos.split("-"); + const x = h === "left" ? f.x + inset : h === "right" ? f.x + f.width - inset - width : f.x + (f.width - width) / 2; + const y = v === "top" ? f.y + inset : v === "bottom" ? f.y + f.height - inset - height : f.y + (f.height - height) / 2; + return { x: Math.round(x), y: Math.round(y), width, height }; +} + +/** * Is this cut a FEED: the deck on, its posts shown in the `feed` layout, and * at least one post to draw on a clip of `entries` (the cut's whole timeline, * never an `--only` slice of it)? The build frames its footage into diff --git a/umtool/report-to-video/factcheck.mjs b/umtool/report-to-video/factcheck.mjs @@ -0,0 +1,344 @@ +// Fact-check chrome for the deck: each claim's verdict, STAMPED over the +// footage at the end of the claim's last segment, and a running TALLY in the +// deck whose counts step up as each stamp lands. +// +// timeline entry: "claim": { "id": "k3", "verdict": "CONTRADICTED" } +// render.chrome: "factcheck": { "verdicts": { "PARTLY": { "label": "Half true" } }, +// "stamp": { "seconds": 3, "position": "top-left" }, +// "tally": { "show": true, "position": "right" } } +// +// `claim` is its own key: an entry's `verdict` is the clip review's +// ("confirmed" / "incorrect"), a ruling on the CUT, not on what was said. +// Several entries may carry one claim (its evidence, clip after clip); the +// stamp lands once, on the last of them in the cut. +// +// PURE, like deck.mjs: validation, the defaults, the stamp schedule and the +// cues. No fs, no ffmpeg. deck.mjs imports this file -- never the other way +// round -- so the build, the composition and umtool all read one copy. +// chrome-stamp.mjs draws the stamps; chrome-deck.mjs draws the tally. + +/** The verdicts a claim may carry, in the order the tally lists them. */ +export const VERDICTS = Object.freeze(["CORROBORATED", "PARTLY", "CONTRADICTED", "NOT_FOUND", "UNTESTABLE"]); + +/** Where the stamp sits in the footage box. */ +export const STAMP_POSITIONS = Object.freeze(["top-left", "top-right", "bottom-left", "bottom-right", "center"]); + +/** Which end of the deck's text column the tally takes. */ +export const TALLY_POSITIONS = Object.freeze(["right", "left"]); + +/** + * Every fact-check setting and its default. A manifest names only what it + * changes; `resolveFactcheck` fills the rest, a verdict's label and colour + * each on its own. The stamp's default corner is the one the popup posts + * (top-right by default) do not use. + */ +export const FACTCHECK_DEFAULTS = Object.freeze({ + verdicts: Object.freeze({ + CORROBORATED: Object.freeze({ label: "Corroborated", color: "#3fbf7f" }), + PARTLY: Object.freeze({ label: "Partly true", color: "#e3b23c" }), + CONTRADICTED: Object.freeze({ label: "Contradicted", color: "#e5534b" }), + NOT_FOUND: Object.freeze({ label: "Not found", color: "#8b93a7" }), + UNTESTABLE: Object.freeze({ label: "Untestable", color: "#7d8fd6" }), + }), + stamp: Object.freeze({ seconds: 3, position: "top-left" }), + tally: Object.freeze({ show: true, position: "right" }), +}); + +/** The limits: a stamp's seconds on screen, a label's characters. */ +export const FACTCHECK_LIMITS = Object.freeze({ seconds: Object.freeze([1, 10]), label: 24 }); + +/** + * The stamp's motion, in seconds: it slams in over `slam` (landing is when the + * tally steps), flashes up over `flashUp` and down over `flashDown`; the + * tally's number rolls over `roll`. A stamp leaves with its segment's + * outgoing transition, as a popup post does. + */ +export const STAMP_MOTION = Object.freeze({ slam: 0.28, flashUp: 0.06, flashDown: 0.55, roll: 0.4 }); + +/** A claim id: what names it in a report. Letters, digits and `_ . : -`. */ +const CLAIM_ID_RE = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,63}$/; + +const isObj = (v) => v !== null && typeof v === "object" && !Array.isArray(v); +const numIn = (v, lo, hi) => typeof v === "number" && Number.isFinite(v) && v >= lo && v <= hi; +const HEX_RE = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/; + +function unknownKeys(obj, allowed, where, errors) { + for (const k of Object.keys(obj)) { + if (!allowed.includes(k)) errors.push(`${where}.${k} is not a factcheck setting`); + } +} + +/** The fact-check settings with every default filled in. */ +export function resolveFactcheck(render) { + const f = isObj(render?.chrome?.factcheck) ? render.chrome.factcheck : {}; + const given = isObj(f.verdicts) ? f.verdicts : {}; + const verdicts = {}; + for (const v of VERDICTS) { + verdicts[v] = { ...FACTCHECK_DEFAULTS.verdicts[v], ...(isObj(given[v]) ? given[v] : {}) }; + } + return { + verdicts, + stamp: { ...FACTCHECK_DEFAULTS.stamp, ...(isObj(f.stamp) ? f.stamp : {}) }, + tally: { ...FACTCHECK_DEFAULTS.tally, ...(isObj(f.tally) ? f.tally : {}) }, + }; +} + +/** + * Every reason `render.chrome.factcheck` cannot be built, as sentences (empty: + * it can). Unknown keys are refused, as the deck's are. validateChrome calls + * this, so umtool's writer and the build refuse the same things. + * + * @returns {string[]} + */ +export function validateFactcheck(f, where = "render.chrome.factcheck") { + if (f === undefined) return []; + if (!isObj(f)) return [`${where} must be an object`]; + const errors = []; + unknownKeys(f, ["verdicts", "stamp", "tally"], where, errors); + if (f.verdicts !== undefined) { + if (!isObj(f.verdicts)) errors.push(`${where}.verdicts must be an object of verdict → { label, color }`); + else { + for (const [k, v] of Object.entries(f.verdicts)) { + const w = `${where}.verdicts.${k}`; + if (!VERDICTS.includes(k)) { errors.push(`${w} is not a verdict (${VERDICTS.join(", ")})`); continue; } + if (!isObj(v)) { errors.push(`${w} must be { label, color }`); continue; } + unknownKeys(v, ["label", "color"], w, errors); + if (v.label !== undefined) { + if (typeof v.label !== "string" || !v.label.trim()) errors.push(`${w}.label must be words, not empty`); + else if (/[\r\n]/.test(v.label)) errors.push(`${w}.label must be one line`); + else if (v.label.trim().length > FACTCHECK_LIMITS.label) { + errors.push(`${w}.label is ${v.label.trim().length} characters (at most ${FACTCHECK_LIMITS.label})`); + } + } + if (v.color !== undefined && !(typeof v.color === "string" && HEX_RE.test(v.color))) { + errors.push(`${w}.color must be a hex colour, #rgb or #rrggbb`); + } + } + } + } + const sub = (key, allowed, check) => { + if (f[key] === undefined) return; + if (!isObj(f[key])) { errors.push(`${where}.${key} must be an object`); return; } + unknownKeys(f[key], allowed, `${where}.${key}`, errors); + check(f[key]); + }; + sub("stamp", ["seconds", "position"], (s) => { + const [lo, hi] = FACTCHECK_LIMITS.seconds; + if (s.seconds !== undefined && !numIn(s.seconds, lo, hi)) errors.push(`${where}.stamp.seconds must be from ${lo} to ${hi}`); + if (s.position !== undefined && !STAMP_POSITIONS.includes(s.position)) { + errors.push(`${where}.stamp.position must be ${STAMP_POSITIONS.map((p) => `"${p}"`).join(", ")}`); + } + }); + sub("tally", ["show", "position"], (t) => { + if (t.show !== undefined && typeof t.show !== "boolean") errors.push(`${where}.tally.show must be true or false`); + if (t.position !== undefined && !TALLY_POSITIONS.includes(t.position)) { + errors.push(`${where}.tally.position must be ${TALLY_POSITIONS.map((p) => `"${p}"`).join(" or ")}`); + } + }); + return errors; +} + +// --------------------------------------------------------------------------- +// The claim on an entry. +// --------------------------------------------------------------------------- + +/** + * A `claim` value, normalised: `{ id, verdict }` with the id trimmed, or null + * for none (`undefined`, `null`). Throws a sentence on a shape no writer + * should store. + * + * @returns {{ id: string, verdict: string } | null} + */ +export function normalizeClaim(v) { + if (v === undefined || v === null) return null; + if (!isObj(v)) throw new Error("claim must be { id, verdict }"); + for (const k of Object.keys(v)) { + if (k !== "id" && k !== "verdict") throw new Error(`claim.${k} is not a claim field (id, verdict)`); + } + const id = typeof v.id === "string" ? v.id.trim() : ""; + if (!CLAIM_ID_RE.test(id)) throw new Error("claim.id must be letters, digits, dots, colons, dashes or underscores (at most 64)"); + if (!VERDICTS.includes(v.verdict)) throw new Error(`claim.verdict must be one of ${VERDICTS.join(", ")}`); + return { id, verdict: v.verdict }; +} + +/** An entry's claim when it carries a sound one, else null. Never throws. */ +export function claimOf(entry) { + if (!entry || entry.type === "teaser") return null; + try { + return normalizeClaim(entry.claim); + } catch { + return null; + } +} + +/** + * Every `claim` in the timeline, checked: its shape, that it is not on a + * teaser (a full-frame finale is no evidence), and that every entry naming a + * claim id names the same verdict. The build refuses with these before it + * fetches; umtool's writer refuses with them before it writes. + * + * @returns {string[]} + */ +export function validateClaims(manifest) { + const errors = []; + const verdictOf = new Map(); + (manifest?.timeline ?? []).forEach((e, i) => { + if (e?.claim === undefined || e?.claim === null) return; + const where = `timeline[${i}] (${e.id ?? "?"})`; + if (e.type === "teaser") { errors.push(`${where}.claim: a teaser is not evidence for a claim`); return; } + let c; + try { + c = normalizeClaim(e.claim); + } catch (err) { + errors.push(`${where}.${err.message}`); + return; + } + const seen = verdictOf.get(c.id); + if (seen && seen.verdict !== c.verdict) { + errors.push(`${where}.claim ${c.id} is ${c.verdict} here and ${seen.verdict} at ${seen.where} -- one claim, one verdict`); + } else if (!seen) verdictOf.set(c.id, { verdict: c.verdict, where }); + }); + return errors; +} + +// --------------------------------------------------------------------------- +// The stamps and the tally, in the cut's clock. +// --------------------------------------------------------------------------- + +/** + * When each claim's verdict is stamped: once per claim id, on the LAST + * segment carrying it, in cut order. + * + * The stamp leaves with that segment's outgoing transition -- the next + * segment's start to its start + D with a crossfade, the 0.3 s before the cut + * on a hard cut, the last 0.3 s of the last segment -- and comes in `seconds` + * before it leaves (`stamp.seconds`), or as the segment's incoming dissolve + * ends when the segment is shorter than that. It has LANDED -- and the tally + * steps -- `STAMP_MOTION.slam` after it starts, never after it starts leaving. + * + * `segments` are the schedule's, in order, each `{ id, start, duration, + * claim? }` (`claim` as `claimOf` gives it). + * + * @returns {Array<{ claim: string, verdict: string, segment: string, at: number, + * landed: number, out: [number, number] }>} + */ +export function stampSchedule({ segments, D, total, render }) { + const seconds = resolveFactcheck(render).stamp.seconds; + const last = new Map(); + segments.forEach((s, i) => { if (s.claim) last.set(s.claim.id, i); }); + return [...last.entries()] + .sort((a, b) => a[1] - b[1]) + .map(([claim, i]) => { + const seg = segments[i]; + const next = segments[i + 1]; + const out = !next + ? [total - 0.3, total] + : D > 0 ? [next.start, next.start + D] : [next.start - 0.3, next.start]; + const from = seg.start + (i > 0 ? D : 0); + const at = Math.min(Math.max(from, out[0] - seconds), out[0]); + const landed = Math.min(at + STAMP_MOTION.slam, out[0]); + return { claim, verdict: seg.claim.verdict, segment: seg.id, at, landed, out }; + }); +} + +/** `stampSchedule`'s stamps as a schedule writes them: seconds to the millisecond. */ +export function roundStamps(stamps) { + const round = (v) => Math.round(v * 1000) / 1000; + return stamps.map((s) => ({ ...s, at: round(s.at), landed: round(s.landed), out: s.out.map(round) })); +} + +/** The verdicts a cut's stamps use, in VERDICTS order: the tally's cells. */ +export function tallyVerdicts(stamps = []) { + const used = new Set(stamps.map((s) => s.verdict)); + return VERDICTS.filter((v) => used.has(v)); +} + +/** + * The tally's steps, in landing order: at each stamp's `landed`, its verdict's + * count goes up by one. `counts` is every verdict's count after the step. + * + * @returns {Array<{ at: number, verdict: string, count: number, counts: Record<string, number> }>} + */ +export function tallySteps(stamps = []) { + const counts = Object.fromEntries(VERDICTS.map((v) => [v, 0])); + return [...stamps] + .sort((a, b) => a.landed - b.landed) + .map((s) => { + counts[s.verdict] += 1; + return { at: s.landed, verdict: s.verdict, count: counts[s.verdict], counts: { ...counts } }; + }); +} + +/** + * The verdicts a deck schedule's tally shows, or null for no tally: the + * setting is on and the cut stamps at least one claim. + */ +export function tallyOf(schedule, render) { + const stamps = schedule?.factcheck?.stamps ?? []; + if (!stamps.length || !resolveFactcheck(render).tally.show) return null; + return tallyVerdicts(stamps); +} + +/** A tally cell's element keys, shared by the page and its cues. */ +export const tallyKey = (verdict, part = null) => (part === null ? `ty.${verdict}` : `ty.${verdict}.${part}`); + +/** + * The tally's part of the deck's timeline, as data the deck's cue walk takes + * (chrome-deck.mjs `deckCues` states every from). + * + * Each cell is `ty.<V>`, dim until its first count; each count it will show + * is its own number `ty.<V>.n<k>`, stacked in one box: at a step the number + * on it rolls up and out and the next rolls up in, and the cell's flash + * (`ty.<V>.flash`) fires. Absolute times, the cut's clock. + * + * @returns {{ init: Record<string, object>, events: Array<{ k: string, at: number, dur: number, + * to: object, ease: string, why: string }> }} + */ +export function tallyCues(stamps, verdicts = tallyVerdicts(stamps)) { + const steps = tallySteps(stamps); + const finals = Object.fromEntries(verdicts.map((v) => [v, 0])); + for (const s of steps) finals[s.verdict] = s.count; + const init = {}; + for (const v of verdicts) { + init[tallyKey(v)] = { opacity: 0.42 }; + init[tallyKey(v, "flash")] = { opacity: 0 }; + for (let k = 0; k <= finals[v]; k += 1) { + init[tallyKey(v, `n${k}`)] = k === 0 ? { yPercent: 0, autoAlpha: 1 } : { yPercent: 100, autoAlpha: 0 }; + } + } + const m = STAMP_MOTION; + const events = []; + for (const s of steps) { + const why = `tally ${s.verdict} ${s.count}`; + if (s.count === 1) events.push({ k: tallyKey(s.verdict), at: s.at, dur: 0.2, to: { opacity: 1 }, ease: "power1.out", why }); + events.push({ k: tallyKey(s.verdict, `n${s.count - 1}`), at: s.at, dur: m.roll, to: { yPercent: -100, autoAlpha: 0 }, ease: "power2.in", why }); + events.push({ k: tallyKey(s.verdict, `n${s.count}`), at: s.at, dur: m.roll, to: { yPercent: 0, autoAlpha: 1 }, ease: "power3.out", why }); + events.push({ k: tallyKey(s.verdict, "flash"), at: s.at, dur: m.flashUp, to: { opacity: 1 }, ease: "none", why }); + events.push({ k: tallyKey(s.verdict, "flash"), at: s.at + m.flashUp, dur: m.flashDown, to: { opacity: 0 }, ease: "power2.out", why }); + } + return { init, events }; +} + +/** + * The QR's link to a clip's ORIGINAL (`deck.qr.links: "original"`): the + * record's `webpageUrl` at the clip's start -- YouTube with `t=<s>` set (a + * `watch?v=` page gains `&t=<s>`, a youtu.be link `?t=<s>`), anything else + * as-is: a Rumble page has no time parameter a QR can carry, and an X post's + * link is the post. Null without a usable https link. + * + * @returns {string|null} + */ +export function originalUrlAt(webpageUrl, seconds) { + let u; + try { + u = new URL(String(webpageUrl ?? "")); + } catch { + return null; + } + if (u.protocol !== "https:" && u.protocol !== "http:") return null; + const host = u.hostname.replace(/^(www|m)\./, ""); + if ((host === "youtube.com" || host === "youtu.be") && Number.isFinite(seconds)) { + u.searchParams.set("t", String(Math.max(0, Math.floor(seconds)))); + } + return u.toString(); +} diff --git a/umtool/report-to-video/verify-build.mjs b/umtool/report-to-video/verify-build.mjs @@ -104,6 +104,7 @@ export async function verifyBuild(manifestPath, { outDir, variant = "sourced", n * file is as long as the schedule -- the SCHEDULE's total, holds included. * When the schedule carries posts, each window's `chrome/posts-<segment>-frames` * holds that window's frame count, and each held clip's freeze is in the file. + * When it stamps a claim, `chrome/stamp-frames` is as long as the deck's. */ export async function verifyDeck(variantDir, render, file, problems) { const schedPath = path.join(variantDir, "schedule.json"); @@ -149,6 +150,14 @@ export async function verifyDeck(variantDir, render, file, problems) { problems.push(`the posts feed never pauses the cut, but the schedule holds ${held.join(", ") || "nothing"} and moves ${schedule.moves?.length ?? 0}`); } } + // The fact-check stamps: one sequence for the whole cut, as long as the deck's. + let stamp = null; + if (schedule.factcheck?.stamps?.length) { + const dir = path.join(variantDir, "chrome", "stamp-frames"); + const got = await countFrames(dir); + stamp = { frames: got, expectedFrames: want, stamps: schedule.factcheck.stamps.length }; + if (got !== want) problems.push(`${dir} holds ${got} frames; the fact-check stamps run the whole cut, ${want}`); + } // The posts windows: each laid at its own second, each as long as snapWindow says. const posts = []; for (const r of postsRegions(render, variantDir, schedule)) { @@ -162,6 +171,7 @@ export async function verifyDeck(variantDir, render, file, problems) { return { total: schedule.total, frames, expectedFrames: want, videoFrames, segments: schedule.segments.length, ...(feed ? { feed } : {}), + ...(stamp ? { stamp } : {}), ...(posts.length ? { posts } : {}), ...(holds.length ? { holds } : {}), };