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:
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("<b>"half"</b>"));
+ 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",