commit 3f5a9f50f512dbcfc15b9b96a22ed5bc21586db7
parent 9d38f625e5cf7b01a99cdba857b74aa52927477e
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 5 Oct 2026 02:17:45 -0400
common: the verdict vocabulary moves to common/lib/report/verdicts.mjs; factcheck.mjs takes it from there
One copy of the verdicts, their default labels and colours and the override
rule, shared by umtool's fact-check stamps and tally and by report.json.
Plain JS, like ytdlp/platformArgs.mjs, so the report-to-video scripts can
import it under bare node; verdicts.ts re-exports it typed.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
5 files changed, 151 insertions(+), 30 deletions(-)
diff --git a/common/lib/report/verdicts.mjs b/common/lib/report/verdicts.mjs
@@ -0,0 +1,81 @@
+// THE VERDICT VOCABULARY — THE one copy: the rulings a fact-check claim may
+// carry, their default labels and colours, and the limits on an override.
+//
+// Plain JS (with JSDoc types) for the reason ytdlp/platformArgs.mjs is: umtool's
+// report-to-video scripts are `.mjs` run by bare `node` and cannot import
+// TypeScript, and factcheck.mjs (the video's stamps and tally) takes its
+// defaults from here. `./verdicts.ts` re-exports it with types for every TS
+// caller (report.json's schema and validator, the export site's report pages).
+// Never copy these values into another file.
+
+/** The verdicts a claim may carry, in the order a tally lists them. */
+export const VERDICTS = Object.freeze(["CORROBORATED", "PARTLY", "CONTRADICTED", "NOT_FOUND", "UNTESTABLE"]);
+
+/** Each verdict's default label and colour. An override names only what it changes. */
+export const VERDICT_DEFAULTS = Object.freeze({
+ CORROBORATED: Object.freeze({ label: "Corroborated", color: "#3fbf7f" }),
+ PARTLY: Object.freeze({ label: "Partly true", color: "#e3b23c" }),
+ CONTRADICTED: Object.freeze({ label: "Contradicted", color: "#e5534b" }),
+ NOT_FOUND: Object.freeze({ label: "Not found", color: "#8b93a7" }),
+ UNTESTABLE: Object.freeze({ label: "Untestable", color: "#7d8fd6" }),
+});
+
+/** The longest label an override may set, in characters (trimmed). */
+export const VERDICT_LABEL_MAX = 24;
+
+/** An override's colour: `#rgb` or `#rrggbb`. */
+export const VERDICT_COLOR_RE = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
+
+/** @param {unknown} v */
+export function isVerdict(v) {
+ return typeof v === "string" && VERDICTS.includes(v);
+}
+
+const isObj = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
+
+/**
+ * Every verdict's label and colour with the overrides laid over the defaults,
+ * one verdict at a time and one key at a time. A non-object override, or one
+ * for a verdict that is not in the vocabulary, is ignored: validation is the
+ * caller's (factcheck.mjs `validateFactcheck`, report/validate.ts).
+ *
+ * @param {unknown} overrides
+ * @returns {Record<string, { label: string, color: string }>}
+ */
+export function resolveVerdicts(overrides) {
+ const given = isObj(overrides) ? overrides : {};
+ /** @type {Record<string, { label: string, color: string }>} */
+ const out = {};
+ for (const v of VERDICTS) {
+ out[v] = { ...VERDICT_DEFAULTS[v], ...(isObj(given[v]) ? given[v] : {}) };
+ }
+ return out;
+}
+
+/**
+ * Every reason a `{ label?, color? }` override cannot be used, as sentences
+ * naming `where` (empty: it can). Unknown keys are refused. The one rule for
+ * both a report's `verdicts` and a video manifest's `render.chrome.factcheck.verdicts`.
+ *
+ * @param {unknown} v
+ * @param {string} where
+ * @returns {string[]}
+ */
+export function verdictOverrideProblems(v, where) {
+ if (!isObj(v)) return [`${where} must be { label, color }`];
+ const errors = [];
+ for (const k of Object.keys(v)) {
+ if (k !== "label" && k !== "color") errors.push(`${where}.${k} is not a verdict setting (label, color)`);
+ }
+ if (v.label !== undefined) {
+ if (typeof v.label !== "string" || !v.label.trim()) errors.push(`${where}.label must be words, not empty`);
+ else if (/[\r\n]/.test(v.label)) errors.push(`${where}.label must be one line`);
+ else if (v.label.trim().length > VERDICT_LABEL_MAX) {
+ errors.push(`${where}.label is ${v.label.trim().length} characters (at most ${VERDICT_LABEL_MAX})`);
+ }
+ }
+ if (v.color !== undefined && !(typeof v.color === "string" && VERDICT_COLOR_RE.test(v.color))) {
+ errors.push(`${where}.color must be a hex colour, #rgb or #rrggbb`);
+ }
+ return errors;
+}
diff --git a/common/lib/report/verdicts.ts b/common/lib/report/verdicts.ts
@@ -0,0 +1,40 @@
+// The verdict vocabulary, typed. The values live in ./verdicts.mjs — plain JS so
+// umtool's report-to-video scripts can import the same copy — and this module
+// is how every TS caller reads them: with the verdict as a union, not a string.
+
+import {
+ VERDICT_COLOR_RE as VERDICT_COLOR_RE_JS,
+ VERDICT_DEFAULTS as VERDICT_DEFAULTS_JS,
+ VERDICT_LABEL_MAX as VERDICT_LABEL_MAX_JS,
+ VERDICTS as VERDICTS_JS,
+ resolveVerdicts as resolveVerdictsJs,
+ verdictOverrideProblems as verdictOverrideProblemsJs,
+} from "./verdicts.mjs";
+
+export type Verdict = "CORROBORATED" | "PARTLY" | "CONTRADICTED" | "NOT_FOUND" | "UNTESTABLE";
+
+export type VerdictStyle = { label: string; color: string };
+
+export type VerdictOverride = { label?: string; color?: string };
+
+export const VERDICTS = VERDICTS_JS as readonly Verdict[];
+
+export const VERDICT_DEFAULTS = VERDICT_DEFAULTS_JS as Readonly<Record<Verdict, Readonly<VerdictStyle>>>;
+
+export const VERDICT_LABEL_MAX: number = VERDICT_LABEL_MAX_JS;
+
+export const VERDICT_COLOR_RE: RegExp = VERDICT_COLOR_RE_JS;
+
+export function isVerdict(v: unknown): v is Verdict {
+ return typeof v === "string" && (VERDICTS as readonly string[]).includes(v);
+}
+
+// Defaults with the overrides laid over them, one key at a time.
+export function resolveVerdicts(overrides: unknown): Record<Verdict, VerdictStyle> {
+ return resolveVerdictsJs(overrides) as Record<Verdict, VerdictStyle>;
+}
+
+// Every reason an override cannot be used, as sentences naming `where`.
+export function verdictOverrideProblems(v: unknown, where: string): string[] {
+ return verdictOverrideProblemsJs(v, where);
+}
diff --git a/common/package.json b/common/package.json
@@ -33,6 +33,7 @@
"./components/*": "./components/*.tsx",
"./lib/detectPlatform.mjs": "./lib/detectPlatform.mjs",
"./lib/ports.mjs": "./lib/ports.mjs",
+ "./lib/report/verdicts.mjs": "./lib/report/verdicts.mjs",
"./lib/toolProbe.mjs": "./lib/toolProbe.mjs",
"./ytdlp/platformArgs.mjs": "./ytdlp/platformArgs.mjs",
"./lib/*": "./lib/*.ts",
diff --git a/umtool/report-to-video/factcheck.mjs b/umtool/report-to-video/factcheck.mjs
@@ -17,8 +17,20 @@
// round -- so the build, the composition and umtool all read one copy.
// chrome-stamp.mjs draws the stamps; chrome-deck.mjs draws the tally.
+// THE VERDICT VOCABULARY IS COMMON'S (common/lib/report/verdicts.mjs): the
+// verdicts, their default labels and colours and the override rules are one
+// copy shared with report.json and the export site's report pages. This file
+// re-exports the list and lays the stamp and tally settings beside the labels.
+import {
+ VERDICT_DEFAULTS,
+ VERDICT_LABEL_MAX,
+ VERDICTS,
+ resolveVerdicts,
+ verdictOverrideProblems,
+} from "yt-dlp-transcript-common/lib/report/verdicts.mjs";
+
/** The verdicts a claim may carry, in the order the tally lists them. */
-export const VERDICTS = Object.freeze(["CORROBORATED", "PARTLY", "CONTRADICTED", "NOT_FOUND", "UNTESTABLE"]);
+export { VERDICTS };
/** Where the stamp sits in the footage box. */
export const STAMP_POSITIONS = Object.freeze(["top-left", "top-right", "bottom-left", "bottom-right", "center"]);
@@ -33,19 +45,13 @@ export const TALLY_POSITIONS = Object.freeze(["right", "left"]);
* (top-right by default) do not use.
*/
export const FACTCHECK_DEFAULTS = Object.freeze({
- verdicts: Object.freeze({
- CORROBORATED: Object.freeze({ label: "Corroborated", color: "#3fbf7f" }),
- PARTLY: Object.freeze({ label: "Partly true", color: "#e3b23c" }),
- CONTRADICTED: Object.freeze({ label: "Contradicted", color: "#e5534b" }),
- NOT_FOUND: Object.freeze({ label: "Not found", color: "#8b93a7" }),
- UNTESTABLE: Object.freeze({ label: "Untestable", color: "#7d8fd6" }),
- }),
+ verdicts: VERDICT_DEFAULTS,
stamp: Object.freeze({ seconds: 3, position: "top-left" }),
tally: Object.freeze({ show: true, position: "right" }),
});
/** The limits: a stamp's seconds on screen, a label's characters. */
-export const FACTCHECK_LIMITS = Object.freeze({ seconds: Object.freeze([1, 10]), label: 24 });
+export const FACTCHECK_LIMITS = Object.freeze({ seconds: Object.freeze([1, 10]), label: VERDICT_LABEL_MAX });
/**
* The stamp's motion, in seconds: it slams in over `slam` (landing is when the
@@ -60,7 +66,6 @@ const CLAIM_ID_RE = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,63}$/;
const isObj = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
const numIn = (v, lo, hi) => typeof v === "number" && Number.isFinite(v) && v >= lo && v <= hi;
-const HEX_RE = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
function unknownKeys(obj, allowed, where, errors) {
for (const k of Object.keys(obj)) {
@@ -71,13 +76,8 @@ function unknownKeys(obj, allowed, where, errors) {
/** The fact-check settings with every default filled in. */
export function resolveFactcheck(render) {
const f = isObj(render?.chrome?.factcheck) ? render.chrome.factcheck : {};
- const given = isObj(f.verdicts) ? f.verdicts : {};
- const verdicts = {};
- for (const v of VERDICTS) {
- verdicts[v] = { ...FACTCHECK_DEFAULTS.verdicts[v], ...(isObj(given[v]) ? given[v] : {}) };
- }
return {
- verdicts,
+ verdicts: resolveVerdicts(f.verdicts),
stamp: { ...FACTCHECK_DEFAULTS.stamp, ...(isObj(f.stamp) ? f.stamp : {}) },
tally: { ...FACTCHECK_DEFAULTS.tally, ...(isObj(f.tally) ? f.tally : {}) },
};
@@ -101,18 +101,7 @@ export function validateFactcheck(f, where = "render.chrome.factcheck") {
for (const [k, v] of Object.entries(f.verdicts)) {
const w = `${where}.verdicts.${k}`;
if (!VERDICTS.includes(k)) { errors.push(`${w} is not a verdict (${VERDICTS.join(", ")})`); continue; }
- if (!isObj(v)) { errors.push(`${w} must be { label, color }`); continue; }
- unknownKeys(v, ["label", "color"], w, errors);
- if (v.label !== undefined) {
- if (typeof v.label !== "string" || !v.label.trim()) errors.push(`${w}.label must be words, not empty`);
- else if (/[\r\n]/.test(v.label)) errors.push(`${w}.label must be one line`);
- else if (v.label.trim().length > FACTCHECK_LIMITS.label) {
- errors.push(`${w}.label is ${v.label.trim().length} characters (at most ${FACTCHECK_LIMITS.label})`);
- }
- }
- if (v.color !== undefined && !(typeof v.color === "string" && HEX_RE.test(v.color))) {
- errors.push(`${w}.color must be a hex colour, #rgb or #rrggbb`);
- }
+ errors.push(...verdictOverrideProblems(v, w));
}
}
}
diff --git a/umtool/report-to-video/factcheck.test.mjs b/umtool/report-to-video/factcheck.test.mjs
@@ -11,10 +11,13 @@ import path from "node:path";
import test from "node:test";
import {
- claimOf, FACTCHECK_DEFAULTS, normalizeClaim, originalUrlAt, resolveFactcheck, roundStamps, STAMP_MOTION, stampSchedule,
+ claimOf, FACTCHECK_DEFAULTS, FACTCHECK_LIMITS, normalizeClaim, originalUrlAt, resolveFactcheck, roundStamps, STAMP_MOTION, stampSchedule,
tallyCues, tallyOf, tallySteps, tallyVerdicts, validateClaims, validateFactcheck, VERDICTS,
} from "./factcheck.mjs";
import {
+ VERDICT_DEFAULTS, VERDICT_LABEL_MAX, VERDICTS as COMMON_VERDICTS,
+} from "yt-dlp-transcript-common/lib/report/verdicts.mjs";
+import {
deckGeometry, deckLayout, deckQrUrl, deckSchedule, DECK_DEFAULTS, estimateSchedule, feedGeometry, stampGeometry,
validateChrome,
} from "./deck.mjs";
@@ -42,6 +45,13 @@ const sched = (render = RENDER, entries = ENTRIES, D = 0.5, metas = []) =>
// ---- the claim ----------------------------------------------------------------
+test("the verdict vocabulary is common's, one copy: the same objects, not equal copies", () => {
+ assert.equal(VERDICTS, COMMON_VERDICTS);
+ assert.equal(FACTCHECK_DEFAULTS.verdicts, VERDICT_DEFAULTS);
+ assert.deepEqual(Object.keys(FACTCHECK_DEFAULTS.verdicts), [...VERDICTS]);
+ assert.equal(FACTCHECK_LIMITS.label, VERDICT_LABEL_MAX);
+});
+
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);
@@ -114,7 +124,7 @@ test("validateChrome refuses a bad factcheck block in sentences, unknown keys in
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\.size is not a verdict 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 \}/,