Archilyzer · Source

archilyzer

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

commit c97042fbb08eb8e948902e6223b57fcab4ad22de
parent 0c1a529882eb6bdb7037de9f8d1b346a2f019169
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Sun,  4 Oct 2026 16:22:07 -0400

Merge deck-factcheck (report-to-video fact-check chrome: per-entry claim {id, verdict}, verdict stamp + rolling tally on the deck, deck.qr.links original, posts[].shot; umtool On-screen learns claim)

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

# Conflicts:
#	editor/CHANGELOG.md

Diffstat:
Meditor/CHANGELOG.md | 1+
Mumtool/app/api/report/chrome/preview/route.ts | 38++++++++++++++++++++++++++++++++++----
Mumtool/app/api/report/onscreen/route.ts | 15++++++++++-----
Mumtool/components/projects/OnscreenSection.tsx | 169+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
Mumtool/docs/report-video.md | 13+++++++++++++
Mumtool/lib/report/manifest.mjs | 47+++++++++++++++++++++++++++++++++++++++++++----
Mumtool/lib/report/manifest.test.mjs | 43+++++++++++++++++++++++++++++++++++++++++++
Mumtool/lib/report/onscreen.mjs | 115++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
Mumtool/lib/report/onscreen.test.mjs | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/lib/report/serve.mjs | 17++++++++++++++++-
Mumtool/lib/report/serve.test.mjs | 11+++++++++++
Mumtool/report-to-video/README.md | 77++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mumtool/report-to-video/build-video.mjs | 46++++++++++++++++++++++++++++++++++++++--------
Mumtool/report-to-video/chrome-deck.mjs | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mumtool/report-to-video/chrome-deck.test.mjs | 25+++++++++++++++++++++++++
Mumtool/report-to-video/chrome-feed.mjs | 30++++++++++++++++++++++--------
Mumtool/report-to-video/chrome-feed.test.mjs | 15+++++++++++++++
Mumtool/report-to-video/chrome-posts.mjs | 29+++++++++++++++++++++--------
Mumtool/report-to-video/chrome-posts.test.mjs | 22++++++++++++++++++++++
Aumtool/report-to-video/chrome-stamp.mjs | 246+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/report-to-video/compose-chrome.mjs | 66++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Mumtool/report-to-video/deck.mjs | 133+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Mumtool/report-to-video/deck.test.mjs | 28++++++++++++++++++++++++++++
Aumtool/report-to-video/factcheck.mjs | 344+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/report-to-video/factcheck.test.mjs | 389+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/report-to-video/package.json | 1+
Mumtool/report-to-video/verify-build.mjs | 10++++++++++
27 files changed, 1939 insertions(+), 111 deletions(-)

diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md @@ -3,6 +3,7 @@ ## [Unreleased] - **Capture specific X posts: a screenshot of each, and its attached media.** `pnpm ops capture-posts --json '{"slug":"<channel>","ids":["<post id>", …]}'` shoots each post as X shows it, through the connected X profile, and downloads its pictures and videos with gallery-dl, into the channel's `posts-media/<post id>/` beside a `capture.json` that records when, from which URLs, and each file's size and SHA-256. Every id must already be in the channel's posts archive; one that is not is refused by name and nothing runs. `"shots": false` or `"media": false` skips that half, and posts already captured are skipped unless `"force": true`. The job runs on the X queue with a post fetch, so the two never run at once, and waits a random 4–10 seconds before each request to X, as fetches do. A deleted post, or one behind its account's wall (protected, suspended, gone), is recorded as such in the channel's deleted-post record; a post behind a sensitive-media warning is opened and shot. If X asks to log in, or answers "Something went wrong", the job stops at that post and leaves the rest for a later run. Captures are never published: the export does not read them. - **A source video can be saved at 720p for clip and editing work.** **Persist source video** on a video's page has a **Quality** select: **Original** (the best video and audio, as every persist has been) or **Video 720p (H.264, for clips/editing)**, which saves an H.264 mp4 at most 720p tall — smaller, and quick to cut. When a source has nothing at or under 720p in H.264 it takes 480p, and when it has neither it takes whatever is best and says so in the job's log, with the height it got. The default is the new **Source video quality** under **Settings**, which a channel can override in its Advanced settings; the whole-recording fetch (`full: true`) and **Persist kept now** follow the channel's, else the global, choice. A persisted video's **Source video** card now shows the format that was saved (height and codec) and the quality asked for; videos persisted before this show nothing new. "Video 720p" is also offered as a download format. +- **A report cut can be a fact-check: each claim gets a verdict stamp over the footage, and a tally in the on-screen deck counts them.** Any clip, still or card entry of a report manifest can say which claim it is evidence for and the verdict, `"claim": { "id": "k3", "verdict": "CONTRADICTED" }` (one of CORROBORATED, PARTLY, CONTRADICTED, NOT_FOUND, UNTESTABLE). At the end of the last entry carrying each claim, the verdict slams in over the picture as a stamp in its colour and leaves with the transition, and a row of counts in the deck, one per verdict the cut uses, steps up as each stamp lands. `render.chrome.factcheck` sets each verdict's label and colour, how long the stamp is up (1–10 seconds, 3 by default) and which corner of the footage it sits in, and whether the tally is drawn beside the QR or before the title. A claim is a new key, separate from the clip review's `verdict`; one claim id given two verdicts, a claim on a teaser, or an unknown setting is refused with a sentence before a build fetches anything. umtool's On-screen table has a **claim** column (an id and a verdict per row), saved with the titles, and its preview shows the stamps and the tally before a build. The deck's QR can link each clip's original instead of the archive with `render.chrome.deck.qr.links: "original"` — a YouTube video at the clip's second, a Rumble page or an X post as they are; a clip's `citeUrl` still wins. A manifest post can carry `shot`, a screenshot (PNG, JPEG or WebP, beside the manifest), which its card draws in place of the post's text, its QR kept. A cut with none of these builds exactly as before. - **X posts are fetched more slowly, with random gaps.** Every read of X now waits a random 4 to 10 seconds before each request to X, where it used to page as fast as X answered, and always waits out a rate limit rather than pushing through. When fetching older posts, the pause between one three-month window and the next is a random 45 to 120 seconds instead of a fixed 15. A deep walk of an account's history takes longer; a routine fetch of new posts takes a few seconds more. - **The MCP's search tools take `date_from` and `date_to` as `2024-10-26` as well as `20241026`, and refuse a date they cannot read.** `search_transcripts` and `enumerate_matches` used to accept only `YYYYMMDD`: any other spelling was dropped with a footer warning and the search ran with no date bound, so a whole-corpus count could be read as the bounded one. Dashed, slashed and dotted dates and ISO timestamps are now normalised, and anything else is an error and nothing is searched. - **An X channel can fetch posts older than its timeline reaches.** X's timeline only pages back so far, so a fetch could end, and call the history done, well short of an account's first post. The new **Fetch older posts** button on an X channel's page (or `pnpm ops fetch-posts --json '{"slug":"<channel>","older":true}'`) walks back from the oldest archived post through X search, three months at a time, and saves posts the same way a normal fetch does; posts already archived are skipped. It needs a login, as search does: without one it stops at once and the channel shows **Needs credentials**. A run saves its place as it goes and stops after three hours; the next run continues from there. The walk ends at the account's creation date, after a year of windows with no posts, or at a date you give as `"floor": "YYYY-MM-DD"`, and the page's **Older posts** line then says it is complete; running it again says so and fetches nothing. A normal **Fetch posts** is unaffected and still fetches new posts from the top. Bluesky channels have no such button: their fetch already reads the whole history. diff --git a/umtool/app/api/report/chrome/preview/route.ts b/umtool/app/api/report/chrome/preview/route.ts @@ -2,13 +2,18 @@ import { composeDeckPreview, composeFeedPreview, composePostsPreviews, + composeStampPreview, + normalizeClaimsDraft, normalizeDraft, normalizePostsDraft, scheduleForPreview, segmentBoxes, } from "@/lib/report/onscreen.mjs"; -import { deckPreviewSrc, feedPreviewSrc, postsPreviewSrc, resolveReport } from "@/lib/report/serve.mjs"; -import { deckGeometry, deckLayout, deckOn, feedGeometry, postsGeometry, validateChrome } from "umtool-report-to-video/deck"; +import { deckPreviewSrc, feedPreviewSrc, postsPreviewSrc, resolveReport, stampPreviewSrc } from "@/lib/report/serve.mjs"; +import { + deckGeometry, deckLayout, deckOn, feedGeometry, postsGeometry, stampGeometry, validateChrome, +} from "umtool-report-to-video/deck"; +import { tallyOf } from "umtool-report-to-video/factcheck"; export const dynamic = "force-dynamic"; @@ -45,6 +50,11 @@ export const dynamic = "force-dynamic"; // was framed into, so a backdrop built for the deck is carried into the // feed's box. A feed has no windows. // +// The fact-check STAMPS (a draft row's or an entry's `claim`, the schedule's +// `factcheck.stamps`) are one more composition for the whole cut, +// `stamp.src`, loaded at `stamp.geometry` over the footage for the whole +// scrub; the deck's own page draws the tally, and `layout` makes room for it. +// // The client sends a project id, a variant and the drafts. Never a path. export async function POST(request: Request) { let body: Record<string, unknown>; @@ -58,8 +68,10 @@ export async function POST(request: Request) { if ("error" in r) return Response.json({ error: r.error }, { status: r.status }); let draft; + let claimsDraft; try { draft = normalizeDraft(body.draft); + claimsDraft = normalizeClaimsDraft(body.draft); } catch (e) { return Response.json({ error: e instanceof Error ? e.message : String(e) }, { status: 400 }); } @@ -71,7 +83,7 @@ export async function POST(request: Request) { return Response.json({ error: e instanceof Error ? e.message : String(e) }, { status: 400 }); } - const { variantManifest, schedule } = await scheduleForPreview(r.project, r.manifest, r.variant, draft, postsDraft); + const { variantManifest, schedule } = await scheduleForPreview(r.project, r.manifest, r.variant, draft, postsDraft, { claimsDraft }); const render = (variantManifest.render ?? {}) as Record<string, unknown>; if (!deckOn(render)) { return Response.json( @@ -111,6 +123,23 @@ export async function POST(request: Request) { }; } + // The fact-check stamps, when the cut stamps a claim -- over the footage, + // so not on the clip bench's strip either. + const stamps = (schedule as { factcheck?: { stamps?: unknown[] } }).factcheck?.stamps ?? []; + let stampDoc: Record<string, unknown> | null = null; + if (body.posts !== false && stamps.length) { + let error: string | null = null; + try { + await composeStampPreview(r.project, r.variant, schedule); + } catch (e) { + error = e instanceof Error ? e.message : String(e); + } + stampDoc = { + geometry: stampGeometry(render, { feed: (schedule as { layout?: string }).layout === "feed" }), + ...(error ? { error } : { src: `${stampPreviewSrc(r.project.id, r.variant)}?v=${stamp}` }), + }; + } + return Response.json( { // `v` so the iframe reloads a recomposed preview; the files themselves @@ -120,7 +149,7 @@ export async function POST(request: Request) { geometry: deckGeometry(render), // The ground a footage moved aside for the posts (schedule.moves) leaves showing. background: typeof (render.palette as { bg?: unknown } | undefined)?.bg === "string" ? (render.palette as { bg: string }).bg : null, - layout: deckLayout(render), + layout: deckLayout(render, { tally: tallyOf(schedule, render) }), schedule, posts: { geometry: postsGeometry(render), @@ -132,6 +161,7 @@ export async function POST(request: Request) { })), }, ...(feed ? { feed } : {}), + ...(stampDoc ? { stamp: stampDoc } : {}), }, { headers: { "cache-control": "no-store" } }, ); diff --git a/umtool/app/api/report/onscreen/route.ts b/umtool/app/api/report/onscreen/route.ts @@ -6,16 +6,20 @@ import { deckText, isMultiChannel, resolveDeck } from "umtool-report-to-video/de export const dynamic = "force-dynamic"; // The deck's per-entry text: `onscreen: { title?, subtitle? }` on any -// timeline entry, clip, still or card. +// timeline entry, clip, still or card -- and its fact-check `claim: { id, +// verdict }` (factcheck.mjs), which a row carries beside its text. // // PUT is the On-screen table's one save: a map of entry id → the row, or null // to clear it. A row REPLACES the entry's whole `onscreen` (`{ title }` alone // clears a subtitle override), blanks are dropped, and an entry left with -// nothing loses the key. One unknown id or one bad value refuses the WHOLE +// nothing loses the key. A row's `claim` sets the entry's claim (null removes +// it); a row without one leaves it. One unknown id or one bad value -- a +// claim on a teaser, one claim id given two verdicts -- refuses the WHOLE // batch -- nothing is written -- because a table that saved all but one row // reads as saved. -type Entry = Record<string, unknown> & { id: string; type?: string; onscreen?: Record<string, string> }; +type Claim = { id: string; verdict: string }; +type Entry = Record<string, unknown> & { id: string; type?: string; onscreen?: Record<string, string>; claim?: Claim }; const noStore = { "cache-control": "no-store" }; @@ -50,6 +54,7 @@ export async function GET(request: Request) { id: e.id, type: e.type ?? "entry", onscreen: onscreen ?? null, + claim: e.claim ?? null, auto: deckText(bare, metas[i] ?? null, provenance, deck, multi), }; }); @@ -83,10 +88,10 @@ export async function PUT(request: Request) { try { const res = await updateOnscreen( r.project.dir, - body.onscreen as Record<string, { title?: string; subtitle?: string } | null>, + body.onscreen as Record<string, { title?: string; subtitle?: string; claim?: Claim | null } | null>, { token: body.token === undefined ? null : String(body.token) }, ); - return Response.json({ ok: true, onscreen: res.onscreen, token: res.token }, { headers: noStore }); + return Response.json({ ok: true, onscreen: res.onscreen, claims: res.claims, token: res.token }, { headers: noStore }); } catch (e) { if (e instanceof StaleToken) { return Response.json( diff --git a/umtool/components/projects/OnscreenSection.tsx b/umtool/components/projects/OnscreenSection.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { badgeVariants } from "@/components/ui/badge"; import { buttonVariants } from "@/components/ui/button"; import { backdropTransform, footageAt } from "@/lib/report/footage-move.mjs"; +import { VERDICTS } from "umtool-report-to-video/factcheck"; // --------------------------------------------------------------------------- // ON-SCREEN: the persistent panel under the footage of a report cut. @@ -54,7 +55,11 @@ export type DeckSegment = { hideDeck: boolean; /** Seconds this segment is held on its last frame for its posts (part of `duration`); absent when none. */ hold?: number; + /** The fact-check claim this entry carries; absent when none. */ + claim?: Claim; }; +/** One claim's verdict stamp, in the cut's clock (factcheck.mjs stampSchedule). */ +export type Stamp = { claim: string; verdict: string; segment: string; at: number; landed: number; out: [number, number] }; /** The footage moving aside for a clip's posts (`posts.shift`), as the schedule says. */ export type FootageMove = { segment: string; at: number; segmentAt: number; seconds: number; from: Rect; to: Rect }; export type DeckSchedule = { @@ -67,6 +72,8 @@ export type DeckSchedule = { multiChannel: boolean; segments: DeckSegment[]; moves?: FootageMove[]; + /** The fact-check stamps, when an entry carries a claim. */ + factcheck?: { stamps: Stamp[] }; }; /** A placed post: the popup's `appear` and `out`, or the feed's tick-in, `in`. */ export type PostSlot = { id: string; segment: string; slot: number; of: number; appear?: number; out?: [number, number]; in?: number }; @@ -85,12 +92,18 @@ export type DeckPreviewDoc = { posts?: { geometry: Rect; windows: PostsWindow[] }; /** The posts feed, when the cut is one: a single composition for the whole cut. */ feed?: FeedPreview; + /** The fact-check stamps, when the cut stamps a claim: one composition for the whole cut, over the footage. */ + stamp?: { geometry: Rect; src?: string; error?: string }; }; /** An unsaved change to a post, as PUT /api/report/posts takes it. */ export type PostPatch = { attachTo?: string | null; hide?: boolean }; /** What each segment's panel should say right now, by entry id. */ export type DeckTexts = Record<string, { title: string; subtitle: string }>; export type Onscreen = { title?: string; subtitle?: string }; +/** An entry's fact-check claim (factcheck.mjs): its id in the report and its verdict. */ +export type Claim = { id: string; verdict: string }; +/** A row as PUT /api/report/onscreen takes it: the text, and the claim beside it (null removes it). */ +export type OnscreenRow = Onscreen & { claim?: Claim | null }; /** The middle of a segment: where a still of it, and a jump to it, lands. */ export const midOf = (s: DeckSegment) => Math.round((s.start + s.duration / 2) * 1000) / 1000; @@ -118,7 +131,7 @@ export const onscreenValue = (d: { title: string; subtitle: string }): Onscreen export async function composePreview( project: string, variant: string | null, - draft: Record<string, Onscreen | null> = {}, + draft: Record<string, OnscreenRow | null> = {}, { postsDraft = {}, posts = true }: { postsDraft?: Record<string, PostPatch>; posts?: boolean } = {}, ): Promise<DeckPreviewDoc | { error: string }> { const r = await fetch("/api/report/chrome/preview", { @@ -391,8 +404,26 @@ export function PostsOverlay({ // of the scrub. PostsOverlay's contract with one message renamed: the page // posts `{type: "feed:ready"}` once it can be seeked and takes `deck:seek` in // the cut's clock. Laid out at its own pixel size and scaled. +// +// The fact-check STAMPS' composition is laid the same way (`ready` +// "stamp:ready", its own testid and name): one page for the whole cut, at +// its box over the footage. // --------------------------------------------------------------------------- -export function FeedOverlay({ feed, frame: g, t }: { feed: FeedPreview; frame: DeckGeometry; t: number }) { +export function FeedOverlay({ + feed, + frame: g, + t, + ready = "feed:ready", + testid = "onscreen-feed-preview", + name = "posts feed", +}: { + feed: { geometry: Rect; src?: string; error?: string }; + frame: DeckGeometry; + t: number; + ready?: string; + testid?: string; + name?: string; +}) { const box = useRef<HTMLDivElement | null>(null); const el = useRef<HTMLIFrameElement | null>(null); const [width, setWidth] = useState(0); @@ -412,11 +443,11 @@ export function FeedOverlay({ feed, frame: g, t }: { feed: FeedPreview; frame: D useEffect(() => { const onMsg = (e: MessageEvent) => { if (e.source !== el.current?.contentWindow || e.origin !== window.location.origin) return; - if ((e.data as { type?: string } | null)?.type === "feed:ready") setReadySrc(src); + if ((e.data as { type?: string } | null)?.type === ready) setReadySrc(src); }; window.addEventListener("message", onMsg); return () => window.removeEventListener("message", onMsg); - }, [src]); + }, [src, ready]); const live = !!src && readySrc === src; useEffect(() => { @@ -427,7 +458,7 @@ export function FeedOverlay({ feed, frame: g, t }: { feed: FeedPreview; frame: D return ( <div ref={box} - data-testid="onscreen-feed-preview" + data-testid={testid} data-feed-ready={live ? "1" : "0"} className="pointer-events-none absolute" style={{ @@ -442,8 +473,8 @@ export function FeedOverlay({ feed, frame: g, t }: { feed: FeedPreview; frame: D ref={el} key={src} src={src} - title="posts feed preview" - data-testid="onscreen-feed-preview-iframe" + title={`${name} preview`} + data-testid={`${testid}-iframe`} tabIndex={-1} aria-hidden style={{ @@ -462,11 +493,13 @@ export function FeedOverlay({ feed, frame: g, t }: { feed: FeedPreview; frame: D /> ) : ( <div - data-testid="onscreen-feed-preview-error" + data-testid={`${testid}-error`} className="absolute inset-0 flex items-start justify-center border border-dashed border-[var(--color-dirty)] p-2 text-center text-[11px] text-[var(--color-dirty)]" title={feed.error} > - <span className="rounded bg-black/70 px-1.5 py-0.5">posts feed: not composed — {feed.error}</span> + <span className="rounded bg-black/70 px-1.5 py-0.5"> + {name}: not composed — {feed.error} + </span> </div> )} </div> @@ -484,7 +517,7 @@ type DeckSettings = { pip: { spacing: string; size: number; activeSize: number }; title: { size: number; maxChars: number }; subtitle: { parts: "auto" | string[]; dateFormat: string }; - qr: { show: boolean; size: number }; + qr: { show: boolean; size: number; links: string }; overCards: string; motion: { out: number; in: number; pip: number }; posts: { @@ -547,6 +580,7 @@ const GROUPS: { name: string; fields: Field[] }[] = [ fields: [ { key: "qr.show", label: "show", kind: "bool", hint: "off drops every clip's QR" }, { key: "qr.size", label: "size", kind: "num", hint: "px, 80–380 and at most height − 20" }, + { key: "qr.links", label: "links", kind: "select", options: ["site", "original"], hint: "site: each clip's QR opens the archive at the clip; original: its source page — YouTube at the clip's second, a Rumble page, an X post. A clip's citeUrl wins either way" }, ], }, { @@ -665,9 +699,11 @@ type Row = { id: string; type: string; onscreen: Onscreen | null; + claim: Claim | null; auto: { title: string; subtitle: string }; }; -type Draft = { title: string; subtitle: string }; +/** A row as the form holds it: the text, and the claim's id and verdict ("" for none). */ +type Draft = { title: string; subtitle: string; claimId: string; verdict: string }; type PostWhere = { entryId: string; rule: "attachTo" | "date" | "first"; clipDay: string | null; label: string }; type PostRow = { id: string; @@ -696,8 +732,23 @@ type JobView = { next: number; }; -const draftOf = (o: Onscreen | null): Draft => ({ title: o?.title ?? "", subtitle: o?.subtitle ?? "" }); -const sameDraft = (a: Draft, b: Draft) => a.title.trim() === b.title.trim() && a.subtitle.trim() === b.subtitle.trim(); +const draftOf = (o: Onscreen | null, c: Claim | null = null): Draft => ({ + title: o?.title ?? "", + subtitle: o?.subtitle ?? "", + claimId: c?.id ?? "", + verdict: c?.verdict ?? "", +}); +const rowDraft = (row: Row) => draftOf(row.onscreen, row.claim); +const sameDraft = (a: Draft, b: Draft) => + a.title.trim() === b.title.trim() && + a.subtitle.trim() === b.subtitle.trim() && + a.claimId.trim() === b.claimId.trim() && + a.verdict === b.verdict; +/** A row's claim as the writer takes it: null with neither part, else both (a half-filled one is refused there, in words). */ +const claimValue = (d: Draft): Claim | null => + d.claimId.trim() || d.verdict ? { id: d.claimId.trim(), verdict: d.verdict } : null; +/** A row's draft as the writer takes it: the text, and the claim beside it. */ +const rowValue = (d: Draft): OnscreenRow => ({ ...(onscreenValue(d) ?? {}), claim: claimValue(d) }); const postDraftOf = (p: PostRow): PostDraft => ({ attachTo: p.attachTo ?? "", hide: p.hide }); const samePost = (a: PostDraft, b: PostDraft) => a.attachTo === b.attachTo && a.hide === b.hide; /** Only what changed, in the writer's shape. */ @@ -826,7 +877,7 @@ export default function OnscreenSection({ setLoadError(String(j.error ?? r.status)); return; } - const before = new Map(rowsRef.current.map((row) => [row.id, draftOf(row.onscreen)])); + const before = new Map(rowsRef.current.map((row) => [row.id, rowDraft(row)])); const postsBefore = new Map(postsRef.current.map((p) => [p.id, postDraftOf(p)])); const nextPosts = j.posts ?? []; setPosts(nextPosts); @@ -849,7 +900,7 @@ export default function OnscreenSection({ setDrafts((prev) => { const next: Record<string, Draft> = {}; for (const row of j.rows) { - const saved = draftOf(row.onscreen); + const saved = rowDraft(row); const old = prev[row.id]; const was = before.get(row.id); next[row.id] = keep && old && was && !sameDraft(old, was) ? old : saved; @@ -861,7 +912,7 @@ export default function OnscreenSection({ ); const recompose = useCallback( - async (draft: Record<string, Onscreen | null> = {}, postsDraft: Record<string, PostPatch> = {}) => { + async (draft: Record<string, OnscreenRow | null> = {}, postsDraft: Record<string, PostPatch> = {}) => { setComposing(true); setPreviewError(null); const res = await composePreview(project, variant || null, draft, { postsDraft }); @@ -975,9 +1026,9 @@ export default function OnscreenSection({ [doc, putChrome, loadChrome, loadRows, recompose], ); - const dirtyIds = rows.filter((r) => drafts[r.id] && !sameDraft(drafts[r.id], draftOf(r.onscreen))).map((r) => r.id); + const dirtyIds = rows.filter((r) => drafts[r.id] && !sameDraft(drafts[r.id], rowDraft(r))).map((r) => r.id); const draftMap = useCallback( - () => Object.fromEntries(dirtyIds.map((id) => [id, onscreenValue(drafts[id])])) as Record<string, Onscreen | null>, + () => Object.fromEntries(dirtyIds.map((id) => [id, rowValue(drafts[id])])) as Record<string, OnscreenRow | null>, [dirtyIds, drafts], ); const dirtyPostIds = posts.filter((p) => postDrafts[p.id] && !samePost(postDrafts[p.id], postDraftOf(p))).map((p) => p.id); @@ -995,6 +1046,9 @@ export default function OnscreenSection({ engine: (doc.chrome?.engine as string) ?? "hyperframes", layout: (doc.chrome?.layout as string) ?? "deck", deck: deckOf(form, doc.defaults), + // The fact-check settings are the manifest's (this form does not edit + // them): carried through as saved, never dropped by a deck save. + ...(doc.chrome?.factcheck !== undefined ? { factcheck: doc.chrome.factcheck } : {}), }; const ok = await putChrome(chrome); if (!ok) return; @@ -1031,16 +1085,20 @@ export default function OnscreenSection({ token.current = String(j.token ?? ""); setStale(false); const saved = (j.onscreen ?? {}) as Record<string, Onscreen | null>; - setRows((prev) => prev.map((row) => (row.id in saved ? { ...row, onscreen: saved[row.id] } : row))); + const savedClaims = (j.claims ?? {}) as Record<string, Claim | null>; + const claimOfRow = (row: Row) => (row.id in savedClaims ? savedClaims[row.id] : row.claim); + setRows((prev) => + prev.map((row) => (row.id in saved ? { ...row, onscreen: saved[row.id], claim: claimOfRow(row) } : row)), + ); // Re-sync from what the writer stored, which is trimmed. setDrafts((prev) => { const next = { ...prev }; - for (const [id, v] of Object.entries(saved)) next[id] = draftOf(v); + for (const row of rows) if (row.id in saved) next[row.id] = draftOf(saved[row.id], claimOfRow(row)); return next; }); setNote(`saved ${Object.keys(saved).length} row${Object.keys(saved).length === 1 ? "" : "s"}`); }), - [queued, dirtyIds, project, draftMap], + [queued, dirtyIds, project, draftMap, rows], ); /** The Posts table's one save: only the posts that changed, only the keys that changed. */ @@ -1140,7 +1198,7 @@ export default function OnscreenSection({ const texts = useMemo<DeckTexts>(() => { const out: DeckTexts = {}; for (const row of rows) { - const d = drafts[row.id] ?? draftOf(row.onscreen); + const d = drafts[row.id] ?? rowDraft(row); out[row.id] = { title: d.title.trim() || row.auto.title, subtitle: d.subtitle.trim() || row.auto.subtitle }; } return out; @@ -1254,7 +1312,7 @@ export default function OnscreenSection({ data-testid="onscreen-discard" className={buttonVariants({ size: "sm" })} disabled={!!busy} - onClick={() => setDrafts(Object.fromEntries(rows.map((r) => [r.id, draftOf(r.onscreen)])))} + onClick={() => setDrafts(Object.fromEntries(rows.map((r) => [r.id, rowDraft(r)])))} > discard </button> @@ -1268,16 +1326,22 @@ export default function OnscreenSection({ <th className="px-1.5 py-0.5">title</th> <th className="w-14 px-1.5 py-0.5 text-right">chars</th> <th className="px-1.5 py-0.5">subtitle</th> + <th className="w-56 px-1.5 py-0.5" title="the fact-check claim this entry is evidence for, and its verdict: stamped on the claim's last entry, counted in the tally"> + claim + </th> </tr> </thead> <tbody> {rows.map((row) => { - const d = drafts[row.id] ?? draftOf(row.onscreen); - const dirty = !sameDraft(d, draftOf(row.onscreen)); + const d = drafts[row.id] ?? rowDraft(row); + const dirty = !sameDraft(d, rowDraft(row)); const shown = d.title.trim() || row.auto.title; const isCurrent = current?.id === row.id; const set = (k: keyof Draft, v: string) => - setDrafts((prev) => ({ ...prev, [row.id]: { ...(prev[row.id] ?? draftOf(row.onscreen)), [k]: v } })); + setDrafts((prev) => ({ ...prev, [row.id]: { ...(prev[row.id] ?? rowDraft(row)), [k]: v } })); + // A teaser is a finale, never evidence: the writer refuses a claim on one. + const claimable = row.type !== "teaser"; + const stamped = schedule?.factcheck?.stamps.find((s) => s.segment === row.id) ?? null; return ( <tr key={row.id} @@ -1338,6 +1402,48 @@ export default function OnscreenSection({ className={`${input} w-full`} /> </td> + <td className="px-1.5 py-1"> + {claimable ? ( + <div className="flex items-center gap-1"> + <input + type="text" + data-testid="onscreen-claim-id" + name={`claim-${row.id}`} + value={d.claimId} + maxLength={64} + placeholder="claim id" + onFocus={() => jump(row.id)} + onChange={(e) => set("claimId", e.target.value)} + className={`${input} w-20 font-mono`} + /> + <select + data-testid="onscreen-claim-verdict" + name={`verdict-${row.id}`} + value={d.verdict} + onChange={(e) => set("verdict", e.target.value)} + className={`${input} min-w-0 flex-1`} + > + <option value="">—</option> + {VERDICTS.map((v) => ( + <option key={v} value={v}> + {v} + </option> + ))} + </select> + {stamped && ( + <span + data-testid="onscreen-claim-stamped" + className="micro shrink-0 text-[var(--color-dim)]" + title={`${stamped.claim}'s verdict is stamped at the end of this entry (${stamped.at.toFixed(1)}s in the cut)`} + > + stamp + </span> + )} + </div> + ) : ( + <span className="micro text-[var(--color-dim)]">—</span> + )} + </td> </tr> ); })} @@ -1621,6 +1727,17 @@ export default function OnscreenSection({ t={t} /> )} + {preview.stamp && ( + <FeedOverlay + key={`stamp:${preview.stamp.src ?? "none"}`} + feed={preview.stamp} + frame={preview.geometry} + t={t} + ready="stamp:ready" + testid="onscreen-stamp-preview" + name="fact-check stamps" + /> + )} </DeckFrame> ) : ( <div className="flex aspect-video w-full items-center justify-center rounded border border-dashed border-[var(--color-line)] text-[12px] text-[var(--color-dim)]"> diff --git a/umtool/docs/report-video.md b/umtool/docs/report-video.md @@ -336,6 +336,19 @@ DELETES the key — the same rule an empty attribution field follows. `updateClip` takes `onscreen` too, so a clip-bench save and an On-screen-table save are one rule, not two. +A row may also carry the entry's fact-check claim beside its text, +`{ title?, subtitle?, claim: { id, verdict } | null }` (`splitOnscreenRow`): +`claim` is set on the entry (never inside `onscreen`), `null` removes it, and +a row without the key leaves it as it is. After applying the batch the writer +runs `validateClaims()` over the whole timeline — one verdict per claim id, +none on a teaser — and a refusal writes nothing. The On-screen table shows a +**claim** column (an id and a verdict) per row, `GET /api/report/onscreen` +returns each row's `claim`, and PUT returns the `claims` it stored. The +preview applies a draft's claims too: the build's stamps are placed again over +its segments (`stampSchedule`), the deck's page draws the tally from them, and +the stamps' own composition (`chrome/stamp-preview/`, served under +`stamp-preview/`) is laid over the footage for the whole scrub. + `updateChrome(dir, chrome | null, { token })` writes `render.chrome` itself: `validateChrome()` first, so a block umtool saves is one the build accepts, and `null` removes the key — turning the deck off without touching anything diff --git a/umtool/lib/report/manifest.mjs b/umtool/lib/report/manifest.mjs @@ -31,6 +31,7 @@ import { } from "umtool-report-to-video/ledger-totals"; import { isCalendarDate } from "umtool-report-to-video/attribution"; import { normalizeOnscreen, validateChrome, validatePosts } from "umtool-report-to-video/deck"; +import { normalizeClaim, validateClaims } from "umtool-report-to-video/factcheck"; import { parseMuteFrom } from "./playback.mjs"; import { DELIVERABLES_MODES } from "./storage.mjs"; @@ -483,6 +484,24 @@ function setOnscreen(entry, value) { } /** + * A row of the On-screen table split into its two keys: the entry's + * `onscreen` text, normalised (null: delete it), and -- only when the row + * names one -- its fact-check `claim` (`{ id, verdict }`, or null to delete + * it; factcheck.mjs). A row without a `claim` key leaves the entry's claim + * alone. Throws on a value either normaliser refuses. + * + * @param {unknown} row + * @returns {{ onscreen: { title?: string, subtitle?: string } | null, claim?: { id: string, verdict: string } | null }} + */ +export function splitOnscreenRow(row) { + if (row === null || row === undefined || typeof row !== "object" || Array.isArray(row) || !("claim" in row)) { + return { onscreen: normalizeOnscreen(row) }; + } + const { claim, ...text } = /** @type {Record<string, unknown>} */ (row); + return { onscreen: normalizeOnscreen(text), claim: normalizeClaim(claim) }; +} + +/** * Patch the on-screen text of any number of timeline entries, in one write. * * Any entry type: a card's or a still's deck title is as much the author's as @@ -490,13 +509,20 @@ function setOnscreen(entry, value) { * normalizeOnscreen refuses, fails the whole call before anything is written, * because a table saved with one row silently dropped reads as saved. * + * A row may also carry the entry's fact-check `claim` (`{ id, verdict }`, or + * null to remove it; splitOnscreenRow): set beside `onscreen`, never inside + * it, and checked with validateClaims against the whole timeline once + * applied -- a claim on a teaser, or one claim id given two verdicts, refuses + * the batch. A row without `claim` leaves it as it is. + * * Ids are matched against the WHOLE timeline, every variant's entries * included: an entry only the `full` cut shows still has a title. * * @param {string} dir - * @param {Record<string, { title?: string, subtitle?: string } | null>} onscreen + * @param {Record<string, { title?: string, subtitle?: string, claim?: { id: string, verdict: string } | null } | null>} onscreen * @param {{ token?: string | null }} [opts] - * @returns {Promise<{ onscreen: Record<string, { title?: string, subtitle?: string } | null>, token: string | null }>} + * @returns {Promise<{ onscreen: Record<string, { title?: string, subtitle?: string } | null>, + * claims: Record<string, { id: string, verdict: string } | null>, token: string | null }>} */ export async function updateOnscreen(dir, onscreen, { token = null } = {}) { if (!onscreen || typeof onscreen !== "object" || Array.isArray(onscreen)) { @@ -506,10 +532,15 @@ export async function updateOnscreen(dir, onscreen, { token = null } = {}) { if (!ids.length) throw new Error("nothing to change"); // Normalised BEFORE the lock: a bad value is the caller's error whatever the // file says, and refusing it needs no read. + /** @type {Record<string, { title?: string, subtitle?: string } | null>} */ const next = {}; + /** @type {Record<string, { id: string, verdict: string } | null>} */ + const claims = {}; for (const id of ids) { try { - next[id] = normalizeOnscreen(onscreen[id]); + const row = splitOnscreenRow(onscreen[id]); + next[id] = row.onscreen; + if (row.claim !== undefined) claims[id] = row.claim; } catch (e) { throw new Error(`${id}: ${e instanceof Error ? e.message : String(e)}`); } @@ -530,10 +561,18 @@ export async function updateOnscreen(dir, onscreen, { token = null } = {}) { // are one moment in two cuts. for (const e of manifest.timeline) { if (e.id in next) setOnscreen(e, next[e.id]); + if (e.id in claims) { + if (claims[e.id]) e.claim = claims[e.id]; + else delete e.claim; + } } + // The claims as the build will read them: one verdict per claim id, none + // on a teaser. Refused before the write, so the file is as it was. + const bad = validateClaims(manifest); + if (bad.length) throw new Error(`claim: ${bad.join("; ")} — nothing was written`); const nextToken = await writeManifestAtomic(dir, manifest); - return { onscreen: next, token: nextToken }; + return { onscreen: next, claims, token: nextToken }; }); } diff --git a/umtool/lib/report/manifest.test.mjs b/umtool/lib/report/manifest.test.mjs @@ -449,3 +449,46 @@ test("updatePosts: no posts in the manifest, or a patch it does not take, is ref assert.throws(() => normalizePostPatches({ p1: { attachTo: 3 } }), /p1: attachTo must be a clip id/); assert.deepEqual(normalizePostPatches({ p1: { attachTo: "" }, p2: {} }), { p1: { attachTo: null }, p2: {} }); }); + +test("updateOnscreen: a row's claim is set beside onscreen, round-trips, and null removes it; a row without one keeps it", async () => { + const dir = await project(); + try { + const res = await updateOnscreen(dir, { + c01: { title: "T", claim: { id: " k1 ", verdict: "PARTLY" } }, + i1: { claim: { id: "k1", verdict: "PARTLY" } }, + }); + assert.deepEqual(res.claims, { c01: { id: "k1", verdict: "PARTLY" }, i1: { id: "k1", verdict: "PARTLY" } }); + let m = await read(dir); + assert.deepEqual(entry(m, "c01").claim, { id: "k1", verdict: "PARTLY" }); + assert.deepEqual(entry(m, "c01").onscreen, { title: "T" }, "the claim is not inside onscreen"); + assert.equal("onscreen" in entry(m, "i1"), false); + // The clip review's `verdict` is a different key, and untouched. + assert.equal("verdict" in entry(m, "c01"), false); + // A row without `claim` leaves it; null removes it. + await updateOnscreen(dir, { c01: { title: "T2" } }); + m = await read(dir); + assert.deepEqual(entry(m, "c01").claim, { id: "k1", verdict: "PARTLY" }); + await updateOnscreen(dir, { c01: { title: "T2", claim: null } }); + m = await read(dir); + assert.equal("claim" in entry(m, "c01"), false); + assert.equal(entry(m, "c01").onscreen.title, "T2"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("updateOnscreen: a bad claim, or one claim id given two verdicts, refuses the batch and writes nothing", async () => { + const dir = await project(); + try { + const before = await readRaw(dir); + await assert.rejects(updateOnscreen(dir, { c01: { claim: { id: "k1", verdict: "MAYBE" } } }), /c01: claim\.verdict must be one of/); + await assert.rejects(updateOnscreen(dir, { c01: { claim: { id: "k1", verdict: "PARTLY", why: "x" } } }), /c01: claim\.why is not a claim field/); + await assert.rejects( + updateOnscreen(dir, { c01: { claim: { id: "k1", verdict: "PARTLY" } }, c02: { title: "Kept", claim: { id: "k1", verdict: "CONTRADICTED" } } }), + /claim: .*claim k1 is CONTRADICTED here and PARTLY at .* — nothing was written/, + ); + assert.equal(await readRaw(dir), before); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); diff --git a/umtool/lib/report/onscreen.mjs b/umtool/lib/report/onscreen.mjs @@ -27,13 +27,13 @@ import { deckText, estimateSchedule, footageMoves, - normalizeOnscreen, postHolds, postSchedule, postWindows, resolveDeck, roundPosts, } from "umtool-report-to-video/deck"; +import { claimOf, roundStamps, stampSchedule } from "umtool-report-to-video/factcheck"; import { channelsDirFor, cuePathFor, @@ -41,8 +41,8 @@ import { manifestPath, readCues, } from "../projects/report.mjs"; -import { normalizePostPatches } from "./manifest.mjs"; -import { deckPreviewDir, feedPreviewDir, postsPreviewDir } from "./serve.mjs"; +import { normalizePostPatches, splitOnscreenRow } from "./manifest.mjs"; +import { deckPreviewDir, feedPreviewDir, postsPreviewDir, stampPreviewDir } from "./serve.mjs"; import { ensureWriteDir } from "./storage.mjs"; /** The schedule document deck.mjs defines, built or estimated. */ @@ -52,25 +52,48 @@ import { ensureWriteDir } from "./storage.mjs"; * A draft from the client, normalised: entry id → `{title?, subtitle?}` or * null. Same rule as updateOnscreen -- a row REPLACES the entry's whole * `onscreen` -- so the preview of a draft is the build of its save. Throws, - * naming the id, on a value the writer would refuse. + * naming the id, on a value the writer would refuse. A row's `claim` is not + * text: it is read by normalizeClaimsDraft, and dropped here. * * @param {unknown} draft * @returns {Map<string, { title?: string, subtitle?: string } | null>} */ export function normalizeDraft(draft) { const out = new Map(); - if (draft === undefined || draft === null) return out; + for (const [id, row] of draftRows(draft)) out.set(id, row.onscreen); + return out; +} + +/** + * The fact-check claims a draft's rows carry, normalised as updateOnscreen + * stores them: entry id → `{ id, verdict }`, or null to remove it. A row + * without a `claim` key is not in the map -- its entry keeps the claim it has. + * + * @param {unknown} draft + * @returns {Map<string, { id: string, verdict: string } | null>} + */ +export function normalizeClaimsDraft(draft) { + const out = new Map(); + for (const [id, row] of draftRows(draft)) if (row.claim !== undefined) out.set(id, row.claim); + return out; +} + +/** A draft's rows through the writer's own split, each refusal naming its id. */ +function draftRows(draft) { + /** @type {Array<[string, ReturnType<typeof splitOnscreenRow>]>} */ + const rows = []; + if (draft === undefined || draft === null) return rows; if (typeof draft !== "object" || Array.isArray(draft)) { throw new Error("draft must be an object of entry id → { title, subtitle } or null"); } for (const [id, v] of Object.entries(draft)) { try { - out.set(id, normalizeOnscreen(v)); + rows.push([id, splitOnscreenRow(v)]); } catch (e) { throw new Error(`${id}: ${e instanceof Error ? e.message : String(e)}`); } } - return out; + return rows; } /** @@ -96,7 +119,13 @@ export async function deckMetas(dir, manifest, entries) { if (!file) return null; if (!reads.has(file)) reads.set(file, readCues(file).catch(() => null)); const doc = await reads.get(file); - return doc ? { title: doc.title ?? null, uploadDate: doc.uploadDate ?? null, channel: doc.channel ?? null } : null; + return doc + ? { + title: doc.title ?? null, uploadDate: doc.uploadDate ?? null, channel: doc.channel ?? null, + // What the deck's QR links under `qr.links: "original"`. + webpageUrl: doc.webpageUrl ?? null, + } + : null; }), ); } @@ -142,20 +171,35 @@ export function scheduleMatches(schedule, entries) { * `postsDraft` (post id → `{attachTo?, hide?}`, the shape PUT * /api/report/posts takes) is applied to the manifest's posts in both cases. * + * `claimsDraft` (entry id → `{id, verdict}` or null, normalizeClaimsDraft's) + * is applied to the entries' fact-check claims in both cases, and the build's + * stamps are placed again from them over its segments (factcheck.mjs + * stampSchedule), as the posts are: a claim saved or drafted since the build + * moves the stamps and the tally. + * * @param {{ variantManifest: Record<string, any>, built: Record<string, any> | null, * draft: Map<string, { title?: string, subtitle?: string } | null>, * metas: Array<Record<string, any> | null>, - * postsDraft?: Record<string, { attachTo?: string | null, hide?: boolean }> }} args + * postsDraft?: Record<string, { attachTo?: string | null, hide?: boolean }>, + * claimsDraft?: Map<string, { id: string, verdict: string } | null> }} args * @returns {DeckSchedule} */ -export function previewSchedule({ variantManifest, built, draft, metas, postsDraft = {} }) { +export function previewSchedule({ variantManifest, built, draft, metas, postsDraft = {}, claimsDraft = new Map() }) { const entries = variantManifest.timeline ?? []; const posts = applyPostsDraft(variantManifest.posts ?? [], postsDraft); const patched = (e) => { - if (!draft.has(e.id)) return e; - const v = draft.get(e.id); - const { onscreen: _o, ...rest } = e; - return v ? { ...rest, onscreen: v } : rest; + let out = e; + if (draft.has(e.id)) { + const v = draft.get(e.id); + const { onscreen: _o, ...rest } = out; + out = v ? { ...rest, onscreen: v } : rest; + } + if (claimsDraft.has(e.id)) { + const c = claimsDraft.get(e.id); + const { claim: _c, ...rest } = out; + out = c ? { ...rest, claim: c } : rest; + } + return out; }; if (built && scheduleMatches(built, entries)) { @@ -163,7 +207,7 @@ export function previewSchedule({ variantManifest, built, draft, metas, postsDra const deck = resolveDeck(render); const provenance = variantManifest.provenance ?? {}; const patchedEntries = entries.map(patched); - const { posts: _builtPosts, moves: _builtMoves, layout: _builtLayout, ...rest } = built; + const { posts: _builtPosts, moves: _builtMoves, layout: _builtLayout, factcheck: _builtFactcheck, ...rest } = built; const round = (v) => Math.round(v * 1000) / 1000; const holds = deck.posts.show ? postHolds({ posts, entries: patchedEntries, metas, render }) : new Map(); let shift = 0; @@ -189,17 +233,25 @@ export function previewSchedule({ variantManifest, built, draft, metas, postsDra // The layout as it is NOW: a feed (no holds, no moves) only with posts to draw. const feed = placed.length > 0 && deck.posts.layout === "feed"; const moves = placed.length && !feed ? footageMoves({ posts: placed, segments, render }) : []; + // The claims as they are NOW, stamped over the build's segments. + const claimed = segments.map((s, i) => { + const { claim: _c, ...bare } = s; + const c = claimOf(patchedEntries[i]); + return c ? { ...bare, claim: c } : bare; + }); + const stamps = stampSchedule({ segments: claimed, D: built.transition, total, render }); return { ...rest, ...(feed ? { layout: "feed" } : {}), total, - segments: segments.map((s, i) => { + segments: claimed.map((s, i) => { const e = patchedEntries[i]; const meta = metas[i] ?? null; const { title, subtitle } = deckText(e, meta, provenance, deck, built.multiChannel); const keepBuilt = e.type === "clip" && e.onscreen?.subtitle === undefined && !meta; return { ...s, title, subtitle: keepBuilt ? s.subtitle : subtitle }; }), + ...(stamps.length ? { factcheck: { stamps: roundStamps(stamps) } } : {}), ...(placed.length ? { posts: roundPosts(placed) } : {}), ...(moves.length ? { moves: moves.map((m) => ({ ...m, at: round(m.at), segmentAt: round(m.segmentAt) })) } : {}), }; @@ -328,7 +380,9 @@ async function readBuiltSchedule(dir, variant) { * @param {string} variant * @param {Map<string, { title?: string, subtitle?: string } | null>} draft */ -export async function scheduleForPreview(project, manifest, variant, draft = new Map(), postsDraft = {}, { resolver = previewPostResolver() } = {}) { +export async function scheduleForPreview( + project, manifest, variant, draft = new Map(), postsDraft = {}, { resolver = previewPostResolver(), claimsDraft = new Map() } = {}, +) { const selected = selectVariant(manifest, variant); const entries = selected.timeline ?? []; const posts = Array.isArray(selected.posts) ? selected.posts : []; @@ -352,7 +406,7 @@ export async function scheduleForPreview(project, manifest, variant, draft = new const variantManifest = found.size ? { ...selected, posts: posts.map((p) => (!p.siteChannel && found.has(p.id) ? { ...p, siteChannel: found.get(p.id) } : p)) } : selected; - return { variantManifest, metas, schedule: previewSchedule({ variantManifest, built, draft, metas, postsDraft }) }; + return { variantManifest, metas, schedule: previewSchedule({ variantManifest, built, draft, metas, postsDraft, claimsDraft }) }; } /** The most a preview request waits on the archive for its posts' links, all of them together. */ @@ -448,6 +502,31 @@ export async function composeFeedPreview(project, variant, schedule, { compose = } /** + * Compose the PREVIEW of the fact-check stamps (a schedule whose `factcheck` + * stamps a claim): compose-chrome's `stamp` region, one project for the whole + * cut under out/<variant>/chrome/stamp-preview/. No render. `compose` is + * injectable for the unit test; the routes never pass it. + * + * @param {{ dir: string }} project + * @param {string} variant + * @param {Record<string, any>} schedule + * @param {{ compose?: (args: Record<string, unknown>) => Promise<any> }} [opts] + */ +export async function composeStampPreview(project, variant, schedule, { compose = composeChrome } = {}) { + const outDir = path.join(project.dir, "out", variant); + const want = stampPreviewDir(project.dir, variant); + return serialised(want, async () => { + /** @type {Record<string, unknown>} */ + const args = { manifestPath: manifestPath(project.dir), outDir, variant, region: "stamp", schedule, preview: true }; + const r = await compose(/** @type {any} */ (args)); + if (r?.projDir && path.resolve(r.projDir) !== path.resolve(want)) { + throw new Error(`compose-chrome wrote the stamps preview to ${r.projDir}, not ${want}`); + } + return r; + }); +} + +/** * The box each built segment's footage was framed into, by entry id: the * `framing` its cut record names (build-video's segmentFraming), else -- a * segment built before records named it, or none built yet -- the deck's own diff --git a/umtool/lib/report/onscreen.test.mjs b/umtool/lib/report/onscreen.test.mjs @@ -12,7 +12,9 @@ import { clipLabel, composeFeedPreview, composePostsPreview, + composeStampPreview, composePostsPreviews, + normalizeClaimsDraft, normalizeDraft, normalizePostsDraft, postRows, @@ -430,3 +432,58 @@ test("scheduleForPreview: a post the draft un-hides is linked; a hidden one is n await rm(dir, { recursive: true, force: true }); } }); + +// ---- the fact-check claims --------------------------------------------------------- + +test("a draft row's claim: kept out of the text, read by normalizeClaimsDraft, refusals naming the entry", () => { + const raw = { c01: { title: "T", claim: { id: " k1 ", verdict: "PARTLY" } }, c02: { claim: null }, k1: { title: "x" } }; + assert.deepEqual([...normalizeDraft(raw)], [["c01", { title: "T" }], ["c02", null], ["k1", { title: "x" }]]); + assert.deepEqual([...normalizeClaimsDraft(raw)], [["c01", { id: "k1", verdict: "PARTLY" }], ["c02", null]]); + assert.throws(() => normalizeClaimsDraft({ c01: { claim: { id: "k1", verdict: "SURE" } } }), /^Error: c01: claim\.verdict must be one of/); + assert.throws(() => normalizeDraft({ c01: { claim: { id: "k1" } } }), /^Error: c01: claim\.verdict/); +}); + +test("the claims draft stamps the preview: estimated, and over a matching build's segments", () => { + const claimsDraft = normalizeClaimsDraft({ c01: { claim: { id: "k1", verdict: "CONTRADICTED" } } }); + const est = previewSchedule({ variantManifest: cut(), built: null, draft: new Map(), metas, claimsDraft }); + assert.deepEqual(est.factcheck.stamps.map((x) => [x.claim, x.verdict, x.segment]), [["k1", "CONTRADICTED", "c01"]]); + const b = previewSchedule({ variantManifest: cut(), built: built(), draft: new Map(), metas, claimsDraft }); + // c01 runs 4.5–14.4 in the build; it leaves with the dissolve into c02 (13.9). + assert.deepEqual(b.factcheck.stamps, [{ claim: "k1", verdict: "CONTRADICTED", segment: "c01", at: 10.9, landed: 11.18, out: [13.9, 14.4] }]); + assert.deepEqual(b.segments.find((x) => x.id === "c01").claim, { id: "k1", verdict: "CONTRADICTED" }); + // A build that stamped a claim since removed in the draft stamps nothing now. + const stampedBuild = { + ...built(), + segments: built().segments.map((x) => (x.id === "c02" ? { ...x, claim: { id: "k2", verdict: "PARTLY" } } : x)), + factcheck: { stamps: [{ claim: "k2", verdict: "PARTLY", segment: "c02", at: 22.6, landed: 22.88, out: [25.6, 25.9] }] }, + }; + const cleared = previewSchedule({ variantManifest: cut(), built: stampedBuild, draft: new Map(), metas }); + assert.equal("factcheck" in cleared, false); + assert.equal("claim" in cleared.segments.find((x) => x.id === "c02"), false); + // No claims anywhere: the schedule the preview always drew. + assert.equal("factcheck" in previewSchedule({ variantManifest: cut(), built: built(), draft: new Map(), metas }), false); +}); + +test("composeStampPreview: compose-chrome's stamp region, into stamp-preview, refused anywhere else", async () => { + const calls = []; + const project = { dir: "/proj" }; + const claimsDraft = normalizeClaimsDraft({ c01: { claim: { id: "k1", verdict: "PARTLY" } } }); + const schedule = previewSchedule({ variantManifest: cut(), built: built(), draft: new Map(), metas, claimsDraft }); + const compose = async (args) => { + calls.push(args); + return { projDir: path.join(args.outDir, "chrome", "stamp-preview") }; + }; + await composeStampPreview(project, "sourced", schedule, { compose }); + assert.deepEqual(calls[0], { + manifestPath: path.join("/proj", "video.manifest.json"), + outDir: path.join("/proj", "out", "sourced"), + variant: "sourced", + region: "stamp", + schedule, + preview: true, + }); + await assert.rejects( + composeStampPreview(project, "sourced", schedule, { compose: async () => ({ projDir: "/elsewhere" }) }), + /not \/proj\/out\/sourced\/chrome\/stamp-preview/, + ); +}); diff --git a/umtool/lib/report/serve.mjs b/umtool/lib/report/serve.mjs @@ -336,6 +336,19 @@ export const feedPreviewDir = (projectDir, variant) => export const feedPreviewSrc = (projectId, variant) => `/api/report/chrome/files/${encodeProjectSegment(projectId)}/${variant}/${FEED_PREVIEW}/index.html`; +// The fact-check STAMPS' preview composition (a schedule that stamps a +// claim): one project for the whole cut, out/<variant>/chrome/stamp-preview/, +// served as the feed's is, `stamp-preview/…`. +const STAMP_PREVIEW = "stamp-preview"; + +/** The preview project of a cut's fact-check stamps. */ +export const stampPreviewDir = (projectDir, variant) => + path.join(projectDir, "out", variant, "chrome", STAMP_PREVIEW); + +/** The iframe src for a cut's fact-check stamps preview composition. */ +export const stampPreviewSrc = (projectId, variant) => + `/api/report/chrome/files/${encodeProjectSegment(projectId)}/${variant}/${STAMP_PREVIEW}/index.html`; + /** The preview project of the posts window on one segment. */ export const postsPreviewDir = (projectDir, variant, segment) => path.join(projectDir, "out", variant, "chrome", `${POSTS_PREVIEW_PREFIX}${segment}`); @@ -348,7 +361,8 @@ export const postsPreviewSrc = (projectId, variant, segment) => * Which preview directory a files request is for, and the segments left to * resolve inside it. * - * `feed-preview/…` is the posts feed's project (one per cut). + * `feed-preview/…` is the posts feed's project (one per cut), and + * `stamp-preview/…` the fact-check stamps' (one per cut). * `posts-preview-<segment>/…` is a posts window's project when `<segment>` is * one of `segmentIds` -- the cut's own entry ids, which the caller reads from * the manifest, so a name the client made up is not a directory this serves. @@ -367,6 +381,7 @@ export function previewDirFor(projectDir, variant, rest, segmentIds) { if (!Array.isArray(rest) || !rest.length) return null; const head = rest[0]; if (head === FEED_PREVIEW) return { dir: feedPreviewDir(projectDir, variant), rest: rest.slice(1) }; + if (head === STAMP_PREVIEW) return { dir: stampPreviewDir(projectDir, variant), rest: rest.slice(1) }; if (typeof head === "string" && head.startsWith(POSTS_PREVIEW_PREFIX)) { const seg = head.slice(POSTS_PREVIEW_PREFIX.length); if (!seg || /[\/\\\0]/.test(seg) || seg === "." || seg === "..") return null; diff --git a/umtool/lib/report/serve.test.mjs b/umtool/lib/report/serve.test.mjs @@ -21,6 +21,8 @@ import { postsPreviewSrc, previewDirFor, rangeResponse, + stampPreviewDir, + stampPreviewSrc, } from "./serve.mjs"; const req = (range) => new Request("http://x/", range ? { headers: { range } } : {}); @@ -199,6 +201,15 @@ test("previewDirFor: feed-preview/ is the posts feed's project", () => { assert.match(feedPreviewSrc("reports/x", "sourced"), /\/sourced\/feed-preview\/index\.html$/); }); +test("previewDirFor: stamp-preview/ is the fact-check stamps' project", () => { + assert.deepEqual(previewDirFor("/p", "sourced", ["stamp-preview", "assets", "DeckSans.ttf"], []), { + dir: stampPreviewDir("/p", "sourced"), + rest: ["assets", "DeckSans.ttf"], + }); + assert.equal(stampPreviewDir("/p", "full"), path.join("/p", "out", "full", "chrome", "stamp-preview")); + assert.match(stampPreviewSrc("reports/x", "sourced"), /\/sourced\/stamp-preview\/index\.html$/); +}); + test("previewDirFor: posts-preview-<segment> is a window's project only for an entry of the cut", () => { const ids = ["c01", "c02", "k1"]; assert.deepEqual(previewDirFor("/p", "sourced", ["posts-preview-c02", "index.html"], ids), { diff --git a/umtool/report-to-video/README.md b/umtool/report-to-video/README.md @@ -236,7 +236,8 @@ whatever a manifest omits, one level deep: "pip": { "spacing": "even", "size": 6, "activeSize": 12 }, // spacing "even" | "time"; activeSize ≥ size "title": { "size": 54, "maxChars": 48 }, // size 24–96; maxChars 10–120 (the editor's counter, not a refusal) "subtitle": { "parts": "auto", "dateFormat": "long" }, // parts "auto" | a distinct list of channel/title/date/clock; dateFormat "long" | "iso" - "qr": { "show": true, "size": 150 }, // size 80–380, and at most height − 20 + "qr": { "show": true, "size": 150, // size 80–380, and at most height − 20 + "links": "site" }, // | "original": a clip's QR opens its record's own page (below) "overCards": "hide", // "hide" | "show" "motion": { "out": 0.3, "in": 0.45, "pip": 0.7 }, // seconds; out/in 0–2, pip 0–3 "posts": { "show": true, "layout": "popup", // | "feed": a column beside the footage for the whole cut (below) @@ -245,6 +246,11 @@ whatever a manifest omits, one level deep: "position": "top-right", // | "top-left" "width": 600, "qrSize": 120, "maxLines": 7, "inset": 24, // width 320–900 px; qrSize 80–200 and ≤ width/2; maxLines 2–14; inset 0–80 "links": "archive" } // | "original": where a post's QR goes (below) + }, + "factcheck": { // the fact-check stamps and tally (below); every key optional + "verdicts": { "PARTLY": { "label": "Partly true", "color": "#e3b23c" } }, // per verdict: label ≤ 24 characters, a #rgb/#rrggbb colour + "stamp": { "seconds": 3, "position": "top-left" }, // seconds 1–10; top-left | top-right | bottom-left | bottom-right | center + "tally": { "show": true, "position": "right" } // position "right" (beside the QR) | "left" (before the title) } } } @@ -275,6 +281,12 @@ card's `sub`. The QR follows a clip's corner-QR rule unchanged (`citeUrl`, else the site link at the clip's start); an image draws one only with an explicit `citeUrl`; a card never does. +`qr.links: "original"` points a clip's QR at its source instead of the archive: +the record's own `webpageUrl` — a YouTube page at the clip's start (`&t=<s>`, +or `?t=<s>` on a youtu.be link), a Rumble page as it is (it has no time +parameter a QR can carry), an X post's link as it is. A clip's `citeUrl` still +wins, and a clip whose record names no page keeps the site link. + ### `posts` — written statements on the deck A cut that wears the deck can carry a post — a Bluesky or X statement — as a @@ -293,7 +305,8 @@ footage, so it rides on a clip. "hide": false, "siteChannel": "piratesoftware-bsky", // optional: the archive channel that keeps it "siteUrl": null, // optional: an http(s) page the QR links instead - "postId": null } ] // optional: its id, when `url` does not carry one + "postId": null, // optional: its id, when `url` does not carry one + "shot": "shots/post-1.png" } ] // optional: a screenshot drawn instead of the text card ``` - **Which clip.** The one whose recording most closely PRECEDES the post: the @@ -333,6 +346,13 @@ footage, so it rides on a clip. column's side and its accent rim flares as it lands, then settles to a quiet glow; an accent rail runs down its leading edge and the platform is a pill beside the handle. The QR is fully opaque once the card is in. +- **A screenshot.** `shot` — a `.png`, `.jpg`, `.jpeg` or `.webp`, relative to + the manifest and inside its folder (no leading `/`, no `..`) — is drawn in + place of the date, handle and words, as wide as they would be and no taller + than a full card of them; the QR cell stays. The popup and the feed both + draw it, the page waits for the picture before it measures the stack, and a + shot that cannot be read refuses that region's compose. `text` is still + required: it is the post's words wherever they are listed. #### Where a post's QR links @@ -382,6 +402,51 @@ deck build refuses a bad `posts` before a single fetch. Posts are drawn only under the deck: without `render.chrome` they are data for the report, and nothing in a build reads them. +### `claim` and `render.chrome.factcheck` — fact-check stamps and the tally + +A cut that wears the deck can be a fact-check: any `clip`, `image` or card +entry may say which claim it is evidence for, and the claim's verdict. + +```jsonc +{ "type": "clip", "id": "c07", …, + "claim": { "id": "k3", "verdict": "CONTRADICTED" } } // CORROBORATED | PARTLY | CONTRADICTED | NOT_FOUND | UNTESTABLE +``` + +`claim` is its own key: an entry's `verdict` is the clip review's +(`confirmed` / `incorrect`) and is untouched. The id is letters, digits and +`_ . : -` (≤ 64); a claim id carries ONE verdict across the timeline, a teaser +carries none, and `validateClaims()` (`factcheck.mjs`) refuses either before a +build fetches — umtool's writer refuses with the same function. + +- **The stamp.** Each claim is stamped once, on the LAST segment that carries + it (its evidence may run over several clips): the verdict's label, in its + colour, slams in over the footage `stamp.seconds` (3) before that segment's + outgoing transition — or as its incoming dissolve ends, on a shorter one — + flashes as it lands, and leaves with the transition, as a popup post does. + It sits in a 560×200 box (at 1920 wide, scaled with the frame) `28` px in + from the footage box's `stamp.position` corner (`top-left` by default, the + corner the popup posts do not use), the feed's footage box in a feed cut. +- **The tally.** A cell per verdict the cut stamps, in the deck beside the QR's + cell (`tally.position: "left"`: before the title), each a count over its + label. A count steps up — the old number rolls out, the new one in, the cell + flashes — at the moment its stamp lands; a cell is dim until its first. It + is part of the deck's panel, so it slides away over a card with it, and the + title's column gives it its room only in a cut that stamps something. +- **The schedule.** A stamped segment carries `claim`, and `schedule.json` + gains `factcheck: { stamps: [{ claim, verdict, segment, at, landed, out }] }` + (`stampSchedule`) — only when a claim is stamped, so a cut without one + writes the schedule it always did. +- **The render.** `chrome-stamp.mjs` is the stamps' page (pure, like the + deck's), `compose-chrome.mjs --region stamp` composes and renders it for the + whole cut (`chrome/stamp/`, `chrome/stamp-frames/`, cached by its key, a + window `stamp-from<s>`), and the build lays it last, over the posts, exactly + as it lays the deck's. `verify-build.mjs` checks its frame count. + +`render.chrome.factcheck` sets the verdicts' labels and colours (one at a time: +a verdict named without a colour keeps the default's), the stamp's seconds and +corner, and whether and where the tally is drawn; `validateChrome()` refuses an +unknown key there as everywhere in the block. + ### The `image` entry type A still: the receipts a clip cannot say out loud — a post, a thread, a DM, a @@ -1222,10 +1287,11 @@ implementation of the arithmetic to drift. `compose-chrome.mjs --region deck` is `--region chart`'s sibling: the same CLI, the same render cache, the same vendored GSAP, a different composition -(`chrome-deck.mjs` instead of the chart band's SVG). +(`chrome-deck.mjs` instead of the chart band's SVG). `--region feed`, `stamp`, +`posts` and `teaser` are the deck's other regions (below). ``` -node umtool/report-to-video/compose-chrome.mjs <manifest.json> [--region chart|deck] [--variant sourced|full] +node umtool/report-to-video/compose-chrome.mjs <manifest.json> [--region chart|deck|feed|stamp|posts|teaser] [--variant sourced|full] [--from <s>] [--duration <s>] [--out <dir>] [--preview] [--still <s> --png <path>] [--render] [--workers 4] [--quality high] [--format png-sequence] [--fps 30] @@ -1489,7 +1555,8 @@ substitution happened. ### `verify-build.mjs`'s deck checks, and their limit Confirms `schedule.json` is a *measured* (not estimated) deck schedule, that -`chrome/deck-frames` holds exactly `frameCount(schedule.total, fps)` frames, +`chrome/deck-frames` (and `chrome/feed-frames` / `chrome/stamp-frames` when the +schedule has a feed or stamps) holds exactly `frameCount(schedule.total, fps)` frames, and that the finished file's video stream is that many frames long — laid with `shortest=1`, so a short sequence shortens the cut silently rather than erroring. It **cannot tell whether the overlay was actually laid onto the 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-deck.test.mjs b/umtool/report-to-video/chrome-deck.test.mjs @@ -344,3 +344,28 @@ test("the QR's host label: its ink runs the code's full height, top edge to bott rmSync(dir, { recursive: true, force: true }); } }); + +test("the fact-check tally is in the panel, so it slides with it; a cut without claims has none", () => { + const s = schedule(); + assert.doesNotMatch(deckHtml(s, RENDER, { fonts: FONTS, qrSrcs: QRS }), /class="tally"|data-k="ty\./); + const stamped = { + ...s, + segments: s.segments.map((x) => (x.id === "c02" ? { ...x, claim: { id: "k1", verdict: "CORROBORATED" } } : x)), + factcheck: { stamps: [{ claim: "k1", verdict: "CORROBORATED", segment: "c02", at: 18.5, landed: 18.78, out: [21.5, 22] }] }, + }; + const html = deckHtml(stamped, RENDER, { fonts: FONTS, qrSrcs: QRS }); + const panel = html.indexOf('data-k="panel"'); + const tally = html.indexOf('<div class="tally">'); + assert.ok(panel > 0 && tally > panel && tally < html.indexOf('data-seg="t00"'), "inside the panel, before the segments"); + assert.match(html, /data-verdict="CORROBORATED" data-k="ty\.CORROBORATED"/); + assert.match(html, />Corroborated</); + // The count steps as the stamp lands, and the page carries that cue. + const { cues } = deckCues(stamped, RENDER); + const step = cues.find((c) => c.k === "ty.CORROBORATED.n1"); + assert.equal(step.at, 18.78); + assert.ok(dataOf(html).cues.some((c) => c.k === "ty.CORROBORATED.n1" && c.at === 18.78)); + // The title's column gives the tally its room. + const narrowed = deckLayout(RENDER, { tally: ["CORROBORATED"] }); + assert.ok(html.includes(`max-width: ${narrowed.text.width}px`)); + assert.ok(narrowed.text.width < deckLayout(RENDER).text.width); +}); 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-feed.test.mjs b/umtool/report-to-video/chrome-feed.test.mjs @@ -429,3 +429,18 @@ for (let i = 1; i <= Math.round(dur * fps); i += 1) writeFileSync(out + "/frame_ rmSync(dir, { recursive: true, force: true }); } }); + +test("the page: a post with a screenshot draws it in place of its words, its QR cell kept", () => { + const posts = POSTS.map((p) => (p.id === "b" ? { ...p, shot: "shots/b.webp" } : p)); + const { s, html } = page(posts, { shotSrcs: { b: "assets/shot00.webp" } }); + assert.equal(s.posts.find((p) => p.id === "b").shot, "shots/b.webp"); + const j = s.posts.findIndex((p) => p.id === "b"); + const open = html.indexOf(`data-post="b" data-k="c${j}"`); + const next = html.indexOf("<article", open + 1); + const card = html.slice(open, next > 0 ? next : undefined); + assert.match(html, /<article class="post has-shot" data-post="b"/); + assert.ok(card.includes('<img class="shot" src="assets/shot00.webp" alt="">')); + assert.doesNotMatch(card, /class="para"|class="date"/); + assert.match(card, /<img src="assets\/qr0\d\.png"/); + assert.equal((html.match(/class="para"/g) ?? []).length, 3, "the other three keep their words"); +}); 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-posts.test.mjs b/umtool/report-to-video/chrome-posts.test.mjs @@ -456,3 +456,25 @@ test("embedFn: the page declares the planner under its own name, whatever the bu const { postsCues } = await import("./chrome-posts.mjs"); assert.match(embedFn("postsCues", postsCues), /^const postsCues = \(function postsCues\(/); }); + +test("a post with a screenshot draws it in place of its text card, its QR cell kept; the page waits for it", () => { + const sched = schedule([POST("a", "2024-10-19T17:01:17.640Z", { shot: "shots/a.png" }), HOSTILE, POST("z", "2026-01-22")]); + assert.equal(windowPosts(sched, "c2").find((p) => p.id === "a").shot, "shots/a.png"); + const win = snapWindow(postWindows(sched).find((w) => w.segment === "c2"), { fps: 30, total: sched.total }); + const html = postsHtml(sched, RENDER, win, { + fonts: FONTS, qrSrcs: { a: "assets/qr00.png", evil: "assets/qr01.png" }, shotSrcs: { a: "assets/shot00.png" }, + }); + const open = html.indexOf('data-post="a"'); + const card = html.slice(open, html.indexOf("<article", open + 1)); + assert.match(html, /<article class="post has-shot" data-post="a"/); + assert.ok(card.includes('<img class="shot" src="assets/shot00.png" alt="">')); + assert.doesNotMatch(card, /class="para"|class="meta"/, "no words beside the picture"); + assert.ok(card.includes('<img src="assets/qr00.png"'), "the QR stays"); + // The post without one is the text card it always was. + const evil = html.slice(html.indexOf('data-post="evil"')); + assert.match(evil, /class="para"/); + // Measured only once the picture is decoded. + assert.match(html, /img\.shot"\)\]\.map\(\(i\) => i\.decode\(\)/); + // Without shotSrcs the page is the text cards, as before. + assert.doesNotMatch(postsHtml(sched, RENDER, win, { fonts: FONTS }), /class="shot"/); +}); 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,15 @@ 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. + * + * @param {any} render + * @param {{ tally?: string[] | null }} [opts] */ -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 +354,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 +400,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 +533,21 @@ 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. + * + * @param {any} entry + * @param {any} [provenance] + * @param {{ links?: string, meta?: { webpageUrl?: string | null } | null }} [opts] */ -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 +559,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 +568,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 +592,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 +614,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 +740,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 +809,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 +965,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 +1134,31 @@ 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. + * + * @param {any} render + * @param {{ feed?: boolean }} [opts] + * @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/deck.test.mjs b/umtool/report-to-video/deck.test.mjs @@ -442,3 +442,31 @@ test("the deck page states pageDuration of the cut, not its millisecond total", assert.equal(s.total, 14.467); assert.match(deckHtml(s, R), /data-composition-id="deck" data-start="0" data-duration="14\.4666"/); }); + +test("posts[].shot: a picture relative to the manifest, inside its folder; carried onto the placed post", () => { + assert.deepEqual(validatePosts([POST("p1", "2024-10-19", { shot: "shots/p1.png" })], CLIPS), []); + assert.deepEqual(validatePosts([POST("p1", "2024-10-19", { shot: null })], CLIPS), []); + for (const [shot, re] of [ + ["/abs/p1.png", /shot must be relative to the manifest and inside its folder/], + ["../p1.png", /shot must be relative to the manifest and inside its folder/], + ["shots/../../p1.png", /inside its folder/], + ["shots/p1.gif", /shot must be a \.png, \.jpg, \.jpeg, \.webp picture/], + ["", /shot must be a path to the post's screenshot/], + ]) { + assert.match(validatePosts([POST("p1", "2024-10-19", { shot })], CLIPS).join(" | "), re, shot); + } + const segs = [{ id: "c1", start: 0, duration: 10 }, { id: "c2", start: 9.5, duration: 10 }]; + const placed = postSchedule({ + posts: [POST("p1", "2024-10-19", { shot: "shots/p1.png" }), POST("p2", "2024-10-20")], + entries: CLIPS.slice(0, 2), segments: segs, D: 0.5, total: 19.5, render: RENDER, + }); + assert.equal(placed.find((p) => p.id === "p1").shot, "shots/p1.png"); + assert.equal("shot" in placed.find((p) => p.id === "p2"), false, "a post without one places as it always did"); +}); + +test("qr.links: \"site\" by default, \"original\" accepted, anything else refused", () => { + assert.equal(DECK_DEFAULTS.qr.links, "site"); + assert.deepEqual(resolveDeck({ chrome: { ...CHROME, deck: { qr: { size: 120 } } } }).qr, { show: true, size: 120, links: "site" }); + assert.deepEqual(validateChrome({ ...CHROME, deck: { qr: { links: "original" } } }, RENDER), []); + assert.match(validateChrome({ ...CHROME, deck: { qr: { links: "x" } } }, RENDER)[0], /qr\.links must be "site" or "original"/); +}); 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/factcheck.test.mjs b/umtool/report-to-video/factcheck.test.mjs @@ -0,0 +1,389 @@ +// Tests for the fact-check chrome: the claim and its validation, the settings +// (factcheck.mjs, through validateChrome), the stamp on the last segment of +// each claim, the tally's steps, the deck QR's "original" links, the stamp +// page (chrome-stamp.mjs), compose-chrome's stamp region and its overlay. +// +// Run with: pnpm test:scripts +import assert from "node:assert/strict"; +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { + claimOf, FACTCHECK_DEFAULTS, normalizeClaim, originalUrlAt, resolveFactcheck, roundStamps, STAMP_MOTION, stampSchedule, + tallyCues, tallyOf, tallySteps, tallyVerdicts, validateClaims, validateFactcheck, VERDICTS, +} from "./factcheck.mjs"; +import { + deckGeometry, deckLayout, deckQrUrl, deckSchedule, DECK_DEFAULTS, estimateSchedule, feedGeometry, stampGeometry, + validateChrome, +} from "./deck.mjs"; +import { deckCues, deckHtml } from "./chrome-deck.mjs"; +import { STAMP_POSE, stampCues, stampHtml } from "./chrome-stamp.mjs"; +import { chromeOverlayChain, chromeRegions, stampRegion } from "./build-video.mjs"; + +const PALETTE = { bg: "#12101a", fg: "#f4f1ea", muted: "#9a93ad", accent: "#a97bff", amber: "#ffc860" }; +const CHROME = { engine: "hyperframes", layout: "deck", deck: {} }; +const RENDER = { width: 1920, height: 1080, fps: 30, transition: 0.5, palette: PALETTE, chrome: CHROME }; +const PROV = { siteOrigin: "https://example.test", channelSlug: "demo-channel" }; +const near = (a, b, msg) => assert.ok(Math.abs(a - b) < 1e-3, `${msg}: ${a} != ${b}`); + +// k1 is argued over two clips; k2 is a card; c3 carries none; k3 a still. +const ENTRIES = [ + { type: "clip", id: "c1", video: "abc123", start: 10, end: 20, claim: { id: "k1", verdict: "PARTLY" } }, + { type: "clip", id: "c2", video: "abc123", start: 30, end: 40, claim: { id: "k1", verdict: "PARTLY" } }, + { type: "card", id: "t1", seconds: 5, heading: "A card", claim: { id: "k2", verdict: "CONTRADICTED" } }, + { type: "clip", id: "c3", video: "def456", start: 50, end: 58 }, + { type: "image", id: "i1", seconds: 4, src: "a.png", claim: { id: "k3", verdict: "PARTLY" } }, +]; +const DURS = [10, 10, 5, 8, 4]; +const sched = (render = RENDER, entries = ENTRIES, D = 0.5, metas = []) => + deckSchedule({ entries, durs: DURS, D, render, provenance: PROV, metas }); + +// ---- the claim ---------------------------------------------------------------- + +test("normalizeClaim: trims the id, refuses a bad shape, an unknown field, a verdict off the list", () => { + assert.equal(normalizeClaim(undefined), null); + assert.equal(normalizeClaim(null), null); + assert.deepEqual(normalizeClaim({ id: " k1 ", verdict: "NOT_FOUND" }), { id: "k1", verdict: "NOT_FOUND" }); + assert.throws(() => normalizeClaim("k1"), /claim must be \{ id, verdict \}/); + assert.throws(() => normalizeClaim({ id: "k1", verdict: "PARTLY", note: "x" }), /claim\.note is not a claim field/); + assert.throws(() => normalizeClaim({ id: "", verdict: "PARTLY" }), /claim\.id must be/); + assert.throws(() => normalizeClaim({ id: "a b", verdict: "PARTLY" }), /claim\.id must be/); + assert.throws(() => normalizeClaim({ id: "k1", verdict: "partly" }), /claim\.verdict must be one of CORROBORATED/); + assert.deepEqual(VERDICTS, ["CORROBORATED", "PARTLY", "CONTRADICTED", "NOT_FOUND", "UNTESTABLE"]); +}); + +test("claimOf: a sound claim, never a teaser's, never a throw", () => { + assert.deepEqual(claimOf(ENTRIES[0]), { id: "k1", verdict: "PARTLY" }); + assert.equal(claimOf(ENTRIES[3]), null); + assert.equal(claimOf({ type: "clip", claim: { id: "k1" } }), null); + assert.equal(claimOf({ type: "teaser", claim: { id: "k1", verdict: "PARTLY" } }), null); +}); + +test("validateClaims: shape, never on a teaser, one verdict per claim id; the review verdict is untouched", () => { + assert.deepEqual(validateClaims({ timeline: ENTRIES }), []); + // `verdict` is the clip review's field, not the claim's: it is not read here. + assert.deepEqual(validateClaims({ timeline: [{ ...ENTRIES[0], verdict: "incorrect" }] }), []); + const errs = validateClaims({ + timeline: [ + ENTRIES[0], + { ...ENTRIES[1], claim: { id: "k1", verdict: "CONTRADICTED" } }, + { type: "teaser", id: "fin", lines: ["x"], claim: { id: "k9", verdict: "PARTLY" } }, + { type: "clip", id: "c9", claim: { id: "k8", verdict: "MAYBE" } }, + ], + }); + assert.equal(errs.length, 3, errs.join("\n")); + assert.match(errs[0], /timeline\[1\] \(c2\)\.claim k1 is CONTRADICTED here and PARTLY at timeline\[0\] \(c1\) -- one claim, one verdict/); + assert.match(errs[1], /timeline\[2\] \(fin\)\.claim: a teaser is not evidence/); + assert.match(errs[2], /timeline\[3\] \(c9\)\.claim\.verdict must be one of/); +}); + +// ---- the settings --------------------------------------------------------------- + +test("resolveFactcheck: every default, a verdict's label and colour filled on their own", () => { + assert.deepEqual(resolveFactcheck(RENDER), { + verdicts: Object.fromEntries(VERDICTS.map((v) => [v, { ...FACTCHECK_DEFAULTS.verdicts[v] }])), + stamp: { seconds: 3, position: "top-left" }, + tally: { show: true, position: "right" }, + }); + const f = resolveFactcheck({ chrome: { ...CHROME, factcheck: { verdicts: { PARTLY: { label: "Half true" } }, stamp: { seconds: 5 } } } }); + assert.deepEqual(f.verdicts.PARTLY, { label: "Half true", color: FACTCHECK_DEFAULTS.verdicts.PARTLY.color }); + assert.deepEqual(f.stamp, { seconds: 5, position: "top-left" }); +}); + +test("validateChrome refuses a bad factcheck block in sentences, unknown keys included", () => { + assert.deepEqual(validateChrome({ ...CHROME, factcheck: {} }, RENDER), []); + assert.deepEqual(validateChrome({ + ...CHROME, + factcheck: { + verdicts: { CORROBORATED: { label: "Holds up", color: "#0a0" } }, + stamp: { seconds: 2.5, position: "center" }, + tally: { show: false, position: "left" }, + }, + }, RENDER), []); + const errs = validateChrome({ + ...CHROME, + factcheck: { + colour: 1, + verdicts: { MAYBE: {}, PARTLY: { color: "orange", label: "x".repeat(25), size: 2 }, NOT_FOUND: "grey" }, + stamp: { seconds: 0, position: "middle", spin: true }, + tally: { show: "yes", position: "top" }, + }, + }, RENDER); + for (const want of [ + /render\.chrome\.factcheck\.colour is not a factcheck setting/, + /render\.chrome\.factcheck\.verdicts\.MAYBE is not a verdict/, + /render\.chrome\.factcheck\.verdicts\.PARTLY\.size is not a factcheck setting/, + /render\.chrome\.factcheck\.verdicts\.PARTLY\.label is 25 characters \(at most 24\)/, + /render\.chrome\.factcheck\.verdicts\.PARTLY\.color must be a hex colour/, + /render\.chrome\.factcheck\.verdicts\.NOT_FOUND must be \{ label, color \}/, + /render\.chrome\.factcheck\.stamp\.seconds must be from 1 to 10/, + /render\.chrome\.factcheck\.stamp\.position must be "top-left"/, + /render\.chrome\.factcheck\.stamp\.spin is not a factcheck setting/, + /render\.chrome\.factcheck\.tally\.show must be true or false/, + /render\.chrome\.factcheck\.tally\.position must be "right" or "left"/, + ]) assert.ok(errs.some((e) => want.test(e)), `${want} in\n${errs.join("\n")}`); + assert.deepEqual(validateFactcheck("on"), ["render.chrome.factcheck must be an object"]); + assert.match(validateChrome({ ...CHROME, factcheks: {} }, RENDER).join(), /render\.chrome\.factcheks is not a deck setting/); +}); + +// ---- the stamps ------------------------------------------------------------------- + +test("stampSchedule: once per claim, on its LAST segment, `seconds` before that segment's transition out", () => { + const s = sched(); + // Starts: c1 0, c2 9.5, t1 19, c3 23.5, i1 31; total 35. + assert.deepEqual(s.segments.map((x) => x.start), [0, 9.5, 19, 23.5, 31]); + assert.equal(s.total, 35); + const [k1, k2, k3] = s.factcheck.stamps; + assert.equal(s.factcheck.stamps.length, 3); + // k1 lands on c2, not c1; it leaves with the dissolve into t1 (19 → 19.5). + assert.deepEqual(k1, { claim: "k1", verdict: "PARTLY", segment: "c2", at: 16, landed: 16.28, out: [19, 19.5] }); + assert.deepEqual(k2, { claim: "k2", verdict: "CONTRADICTED", segment: "t1", at: 20.5, landed: 20.78, out: [23.5, 24] }); + // The last segment's leaves over the cut's last 0.3 s. + assert.deepEqual(k3, { claim: "k3", verdict: "PARTLY", segment: "i1", at: 31.7, landed: 31.98, out: [34.7, 35] }); + // The segments carry their claim; the one without has no key. + assert.deepEqual(s.segments.map((x) => x.claim?.id ?? null), ["k1", "k1", "k2", null, "k3"]); + assert.equal("claim" in s.segments[3], false); +}); + +test("stampSchedule: a short segment stamps after its incoming dissolve; a hard cut leaves 0.3 s before the cut", () => { + const short = roundStamps(stampSchedule({ + segments: [{ id: "a", start: 0, duration: 5 }, { id: "b", start: 4.5, duration: 2, claim: { id: "k", verdict: "UNTESTABLE" } }, + { id: "c", start: 6, duration: 5 }], + D: 0.5, total: 11, render: RENDER, + })); + assert.deepEqual(short, [{ claim: "k", verdict: "UNTESTABLE", segment: "b", at: 5, landed: 5.28, out: [6, 6.5] }]); + const hard = roundStamps(stampSchedule({ + segments: [{ id: "a", start: 0, duration: 10, claim: { id: "k", verdict: "CORROBORATED" } }, { id: "b", start: 10, duration: 5 }], + D: 0, total: 15, render: { ...RENDER, chrome: { ...CHROME, factcheck: { stamp: { seconds: 5 } } } }, + })); + assert.deepEqual(hard, [{ claim: "k", verdict: "CORROBORATED", segment: "a", at: 4.7, landed: 4.98, out: [9.7, 10] }]); +}); + +test("a cut without claims writes the schedule it always did", () => { + const plain = ENTRIES.map(({ claim: _c, ...e }) => e); + const s = sched(RENDER, plain); + assert.equal("factcheck" in s, false); + assert.ok(s.segments.every((x) => !("claim" in x))); + assert.equal(tallyOf(s, RENDER), null); + // And its deck lays out exactly as before. + assert.deepEqual(deckLayout(RENDER, { tally: tallyOf(s, RENDER) }), deckLayout(RENDER)); +}); + +test("the estimate stamps too, from the manifest alone", () => { + const e = estimateSchedule({ render: RENDER, provenance: PROV, timeline: ENTRIES }); + assert.equal(e.estimated, true); + assert.deepEqual(e.factcheck.stamps.map((x) => [x.claim, x.segment]), [["k1", "c2"], ["k2", "t1"], ["k3", "i1"]]); +}); + +// ---- the tally --------------------------------------------------------------------- + +test("tallySteps: each verdict counts up as its stamps land, in landing order", () => { + const stamps = sched().factcheck.stamps; + assert.deepEqual(tallyVerdicts(stamps), ["PARTLY", "CONTRADICTED"]); + const steps = tallySteps(stamps); + assert.deepEqual(steps.map((s) => [s.at, s.verdict, s.count]), [ + [16.28, "PARTLY", 1], [20.78, "CONTRADICTED", 1], [31.98, "PARTLY", 2], + ]); + assert.deepEqual(steps.at(-1).counts, { CORROBORATED: 0, PARTLY: 2, CONTRADICTED: 1, NOT_FOUND: 0, UNTESTABLE: 0 }); +}); + +test("tallyCues: a number per count, each rolling in as its stamp lands and out at the next", () => { + const stamps = sched().factcheck.stamps; + const { init, events } = tallyCues(stamps); + assert.deepEqual(Object.keys(init).filter((k) => /\.n\d+$/.test(k)).sort(), [ + "ty.CONTRADICTED.n0", "ty.CONTRADICTED.n1", "ty.PARTLY.n0", "ty.PARTLY.n1", "ty.PARTLY.n2", + ]); + assert.deepEqual(init["ty.PARTLY.n0"], { yPercent: 0, autoAlpha: 1 }); + assert.deepEqual(init["ty.PARTLY.n2"], { yPercent: 100, autoAlpha: 0 }); + const inAt = (k) => events.find((e) => e.k === k && e.to.autoAlpha === 1)?.at; + const outAt = (k) => events.find((e) => e.k === k && e.to.autoAlpha === 0)?.at; + assert.equal(inAt("ty.PARTLY.n1"), 16.28); + assert.equal(outAt("ty.PARTLY.n1"), 31.98); + assert.equal(inAt("ty.PARTLY.n2"), 31.98); + assert.equal(inAt("ty.CONTRADICTED.n1"), 20.78); + // A cell lights at its first count only. + assert.deepEqual(events.filter((e) => e.k === "ty.PARTLY").map((e) => e.at), [16.28]); + // The flash fires at every step. + assert.equal(events.filter((e) => e.k.endsWith(".flash") && e.to.opacity === 1).length, 3); +}); + +test("the deck draws the tally: cells for the verdicts the cut uses, the text column narrowed, cues with stated froms", () => { + const s = sched(); + const tally = tallyOf(s, RENDER); + const lay = deckLayout(RENDER, { tally }); + // Right of the text, left of the QR's cell (its plate starts 46 px before the code). + assert.deepEqual(lay.tally, { x: 1394, y: 47, width: 258, height: 118, cell: 124, gap: 10 }); + assert.equal(lay.text.x + lay.text.width, 1394 - 40); + assert.ok(lay.tally.x + lay.tally.width <= lay.qr.x - 46); + const left = deckLayout({ ...RENDER, chrome: { ...CHROME, factcheck: { tally: { position: "left" } } } }, { tally }); + assert.equal(left.tally.x, 48); + assert.equal(left.text.x, 48 + 258 + 40); + const html = deckHtml(s, RENDER, {}); + assert.match(html, /class="tally"/); + assert.equal((html.match(/class="tcell"/g) ?? []).length, 2); + assert.match(html, /data-verdict="PARTLY"[^>]*data-k="ty\.PARTLY"/); + assert.match(html, />Partly true</); + assert.match(html, />Contradicted</); + assert.equal((html.match(/class="tn"/g) ?? []).length, 5, "PARTLY 0–2, CONTRADICTED 0–1"); + // Every tally cue states the from the one before it left. + const { init, cues } = deckCues(s, RENDER); + const state = Object.fromEntries(Object.entries(init).map(([k, v]) => [k, { ...v }])); + for (const c of cues) { + if (!c.k.startsWith("ty.")) continue; + for (const p of Object.keys(c.to)) assert.deepEqual(c.from[p], state[c.k][p], `${c.k}.${p} at ${c.at}`); + Object.assign(state[c.k], c.to); + } + // tally.show false: no tally, the deck as it always was. + const off = { ...RENDER, chrome: { ...CHROME, factcheck: { tally: { show: false } } } }; + assert.doesNotMatch(deckHtml(s, off, {}), /class="tally"/); + assert.ok(deckCues(s, off).cues.every((c) => !c.k.startsWith("ty."))); +}); + +// ---- the stamp page -------------------------------------------------------------- + +test("stampGeometry: a box in the footage at its position; the feed's box for a feed cut", () => { + const f = deckGeometry(RENDER).footage; + assert.deepEqual(stampGeometry(RENDER), { x: f.x + 28, y: f.y + 28, width: 560, height: 200 }); + const br = stampGeometry({ ...RENDER, chrome: { ...CHROME, factcheck: { stamp: { position: "bottom-right" } } } }); + assert.deepEqual(br, { x: f.x + f.width - 28 - 560, y: f.y + f.height - 28 - 200, width: 560, height: 200 }); + const feed = { ...RENDER, chrome: { ...CHROME, deck: { posts: { layout: "feed" } } } }; + const ff = feedGeometry(feed).footage; + assert.deepEqual(stampGeometry(feed, { feed: true }), { x: ff.x + 28, y: ff.y + 28, width: 560, height: 200 }); +}); + +test("stampCues: each stamp slams in to land at its `landed`, flashes, and leaves over its `out`", () => { + const s = sched(); + const { init, cues } = stampCues(s); + assert.deepEqual(init.st0, { autoAlpha: 0, scale: STAMP_POSE.scale, rotation: STAMP_POSE.from }); + const of = (k) => cues.filter((c) => c.k === k); + const [slam, leave] = of("st0"); + near(slam.at, 16, "slam starts"); + near(slam.at + slam.dur, 16.28, "lands"); + assert.deepEqual(slam.to, { autoAlpha: 1, scale: 1, rotation: STAMP_POSE.rest }); + near(leave.at, 19, "leaves"); + near(leave.dur, 0.5, "over the dissolve"); + assert.deepEqual(leave.from, { autoAlpha: 1, scale: 1 }); + near(of("fl0")[0].at, 16.28, "flash at landing"); + near(of("fl0")[1].at, 16.28 + STAMP_MOTION.flashUp, "flash down"); + assert.equal(cues.filter((c) => c.k.startsWith("st")).length, 6); +}); + +test("stampHtml: a stamp per claim in its verdict's words and colour; escaped; the composition contract", () => { + const entries = ENTRIES.map((e, i) => (i === 2 ? { ...e, claim: { id: "k2", verdict: "CONTRADICTED" } } : e)); + const render = { ...RENDER, chrome: { ...CHROME, factcheck: { verdicts: { PARTLY: { label: '<b>"half"</b>', color: "#123456" } } } } }; + const s = sched(render, entries); + const html = stampHtml(s, render, { fonts: { regular: "assets/DeckSans.ttf", bold: "assets/DeckSansBold.ttf" } }); + assert.equal((html.match(/class="stamp"/g) ?? []).length, 3); + assert.match(html, /data-claim="k1" data-verdict="PARTLY" data-k="st0" style="--vc:#123456;/); + assert.ok(html.includes("&lt;b&gt;&quot;half&quot;&lt;/b&gt;")); + assert.match(html, /CONTRADICTED[^]*>Contradicted</); + assert.match(html, /data-composition-id="stamp" data-start="0" data-duration="35"/); + assert.match(html, /data-width="560" data-height="200"/); + assert.match(html, /window\.__timelines\["stamp"\] = tl/); + assert.equal((html.match(/<\/script>/g) ?? []).length, 3, "gsap, data, runtime -- and no fourth"); + assert.doesNotMatch(html, /https?:\/\//, "nothing leaves the machine"); + const win = stampHtml(s, render, { from: 15, duration: 5 }); + assert.match(win, /"window":\{"from":15,"dur":5\}/); + assert.throws(() => stampHtml(sched(RENDER, ENTRIES.map(({ claim: _c, ...e }) => e)), RENDER), /stamps no claim/); +}); + +test("the stamps' overlay is laid like the deck's: whole cut, reinit off, rgba, shortest, at stampGeometry", () => { + const s = sched(); + const region = stampRegion(RENDER, "/o/chrome/stamp-frames", s); + assert.deepEqual(region, { name: "stamp", frames: "/o/chrome/stamp-frames", ...stampGeometry(RENDER) }); + const hf = chromeOverlayChain(RENDER, [...chromeRegions(RENDER, "/o"), region], "[v]", 3); + assert.deepEqual(hf.inputs.slice(8), [ + "-reinit_filter", "0", "-framerate", "30", "-start_number", "1", "-i", "/o/chrome/stamp-frames/frame_%06d.png", + ]); + const { x, y } = stampGeometry(RENDER); + assert.match(hf.chain, new RegExp(`\\[4:v\\]format=rgba\\[hfa1\\];\\[hf0\\]\\[hfa1\\]overlay=x=${x}:y=${y}:format=yuv444:shortest=1\\[hf1\\]`)); +}); + +// ---- the deck QR's "original" links ------------------------------------------------ + +test("originalUrlAt: YouTube at the second; Rumble and X as they are; nothing for no link", () => { + assert.equal(originalUrlAt("https://www.youtube.com/watch?v=abc123", 51.9), "https://www.youtube.com/watch?v=abc123&t=51"); + assert.equal(originalUrlAt("https://www.youtube.com/watch?v=abc123&t=9", 51), "https://www.youtube.com/watch?v=abc123&t=51"); + assert.equal(originalUrlAt("https://youtu.be/abc123", 7), "https://youtu.be/abc123?t=7"); + assert.equal(originalUrlAt("https://rumble.com/v1abc-demo-video.html", 51), "https://rumble.com/v1abc-demo-video.html"); + assert.equal(originalUrlAt("https://x.com/demo/status/1234567890", 51), "https://x.com/demo/status/1234567890"); + assert.equal(originalUrlAt(undefined, 5), null); + assert.equal(originalUrlAt("not a url", 5), null); + assert.equal(originalUrlAt("javascript:alert(1)", 5), null); +}); + +test("deckQrUrl: qr.links \"original\" links the record's page; citeUrl still wins; no record falls back to the site", () => { + assert.equal(DECK_DEFAULTS.qr.links, "site"); + const clip = { type: "clip", id: "c1", video: "abc123", start: 51.4, end: 60 }; + const yt = { webpageUrl: "https://www.youtube.com/watch?v=abc123" }; + const site = "https://example.test/?v=demo-channel%2Fabc123&t=51"; + assert.equal(deckQrUrl(clip, PROV), site); + assert.equal(deckQrUrl(clip, PROV, { meta: yt }), site, "site is the default"); + assert.equal(deckQrUrl(clip, PROV, { links: "original", meta: yt }), "https://www.youtube.com/watch?v=abc123&t=51"); + assert.equal(deckQrUrl(clip, PROV, { links: "original", meta: { webpageUrl: "https://rumble.com/v1abc-demo.html" } }), + "https://rumble.com/v1abc-demo.html"); + assert.equal(deckQrUrl(clip, PROV, { links: "original", meta: { webpageUrl: "https://x.com/demo/status/42" } }), + "https://x.com/demo/status/42"); + assert.equal(deckQrUrl({ ...clip, citeUrl: "https://example.test/cited" }, PROV, { links: "original", meta: yt }), + "https://example.test/cited"); + assert.equal(deckQrUrl(clip, PROV, { links: "original", meta: null }), site); + // The schedule threads the setting and each clip's record through. + const render = { ...RENDER, chrome: { ...CHROME, deck: { qr: { links: "original" } } } }; + const s = deckSchedule({ + entries: [clip], durs: [8.6], D: 0.5, render, provenance: PROV, metas: [yt], + }); + assert.equal(s.segments[0].qrUrl, "https://www.youtube.com/watch?v=abc123&t=51"); + assert.match(validateChrome({ ...CHROME, deck: { qr: { links: "platform" } } }, RENDER).join(), /qr\.links must be "site" or "original"/); +}); + +// ---- compose-chrome's stamp region, with the renderer stubbed ---------------------- + +test("compose-chrome: the stamp region composes, renders through the stub and caches by its key", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "stamp-compose-")); + const env = process.env.HYPERFRAMES_BIN; + try { + const stub = path.join(dir, "hf-stub.mjs"); + writeFileSync(stub, `#!/usr/bin/env node +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +const a = process.argv.slice(2); +const out = a[a.indexOf("--output") + 1]; +const fps = Number(a[a.indexOf("--fps") + 1]); +const html = readFileSync(a.at(-1) + "/index.html", "utf8"); +const dur = Number(/data-composition-id="[^"]*"[^>]*data-duration="([\\d.]+)"/.exec(html)[1]); +mkdirSync(out, { recursive: true }); +for (let i = 1; i <= Math.round(dur * fps); i += 1) writeFileSync(out + "/frame_" + String(i).padStart(6, "0") + ".png", "x"); +`); + chmodSync(stub, 0o755); + process.env.HYPERFRAMES_BIN = stub; + // Any file serves as a face here: the page only names it. + const font = path.join(dir, "face.ttf"); + writeFileSync(font, "face"); + const s = sched(); + const mp = path.join(dir, "video.manifest.json"); + writeFileSync(mp, JSON.stringify({ slug: "f", render: { ...RENDER, fontRegular: font, fontBold: font }, timeline: ENTRIES })); + const out = path.join(dir, "out", "sourced"); + mkdirSync(out, { recursive: true }); + writeFileSync(path.join(out, "schedule.json"), JSON.stringify(s)); + const { composeChrome } = await import("./compose-chrome.mjs"); + const r = await composeChrome({ manifestPath: mp, region: "stamp", doRender: true, fps: 30 }); + assert.equal(r.projDir, path.join(out, "chrome", "stamp")); + assert.equal(r.frames, path.join(out, "chrome", "stamp-frames")); + assert.equal(r.frameCount, Math.round(s.total * 30)); + assert.equal(r.cached, false); + assert.match(readFileSync(path.join(r.projDir, "index.html"), "utf8"), /data-composition-id="stamp"/); + assert.equal((await composeChrome({ manifestPath: mp, region: "stamp", doRender: true, fps: 30 })).cached, true); + assert.equal((await composeChrome({ manifestPath: mp, region: "stamp", preview: true })).projDir, + path.join(out, "chrome", "stamp-preview")); + assert.equal((await composeChrome({ manifestPath: mp, region: "stamp", from: 9, duration: 4 })).projDir, + path.join(out, "chrome", "stamp-from9")); + // A schedule with nothing stamped has no stamp region. + writeFileSync(path.join(out, "schedule.json"), JSON.stringify(sched(RENDER, ENTRIES.map(({ claim: _c, ...e }) => e)))); + await assert.rejects(composeChrome({ manifestPath: mp, region: "stamp" }), /needs a schedule that stamps a claim/); + } finally { + if (env === undefined) delete process.env.HYPERFRAMES_BIN; + else process.env.HYPERFRAMES_BIN = env; + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/umtool/report-to-video/package.json b/umtool/report-to-video/package.json @@ -23,6 +23,7 @@ "./compose-chrome": "./compose-chrome.mjs", "./cues": "./cues.mjs", "./deck": "./deck.mjs", + "./factcheck": "./factcheck.mjs", "./ledger-totals": "./ledger-totals.mjs", "./mute": "./mute.mjs", "./package.json": "./package.json", 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 } : {}), };