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:
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, "&")
+ .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<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 } : {}),
};