Archilyzer · Source

archilyzer

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

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

umtool: the On-screen table and its preview learn the fact-check claim -- a row carries `claim: {id, verdict}` beside its text (splitOnscreenRow; updateOnscreen sets or removes it and refuses the batch on validateClaims), the GET rows round-trip it, the draft's claims restamp the preview over the build's segments, the stamps compose into stamp-preview/ and lie over the footage, the deck's layout makes room for the tally, and deckMetas carries webpageUrl for qr.links "original"; tests for the claim, the tally, the stamps, the QR's original links and post screenshots

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

Diffstat:
Mumtool/app/api/report/chrome/preview/route.ts | 38++++++++++++++++++++++++++++++++++----
Mumtool/app/api/report/onscreen/route.ts | 15++++++++++-----
Mumtool/components/projects/OnscreenSection.tsx | 163+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
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/chrome-deck.test.mjs | 25+++++++++++++++++++++++++
Mumtool/report-to-video/chrome-feed.test.mjs | 15+++++++++++++++
Mumtool/report-to-video/chrome-posts.test.mjs | 22++++++++++++++++++++++
Mumtool/report-to-video/deck.test.mjs | 28++++++++++++++++++++++++++++
Aumtool/report-to-video/factcheck.test.mjs | 389+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/report-to-video/package.json | 1+
15 files changed, 929 insertions(+), 57 deletions(-)

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> @@ -665,9 +698,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 +731,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 +876,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 +899,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 +911,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 +1025,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); @@ -1031,16 +1081,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 +1194,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 +1308,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 +1322,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 +1398,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 +1723,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/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/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.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.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/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.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",