// Fact-check chrome for the deck: each claim's verdict, STAMPED over the // footage at the end of the claim's last segment, and a running TALLY in the // deck whose counts step up as each stamp lands. // // timeline entry: "claim": { "id": "k3", "verdict": "CONTRADICTED" } // render.chrome: "factcheck": { "verdicts": { "PARTLY": { "label": "Half true" } }, // "stamp": { "seconds": 3, "position": "top-left" }, // "tally": { "show": true, "position": "right" } } // // `claim` is its own key: an entry's `verdict` is the clip review's // ("confirmed" / "incorrect"), a ruling on the CUT, not on what was said. // Several entries may carry one claim (its evidence, clip after clip); the // stamp lands once, on the last of them in the cut. // // PURE, like deck.mjs: validation, the defaults, the stamp schedule and the // cues. No fs, no ffmpeg. deck.mjs imports this file -- never the other way // round -- so the build, the composition and umtool all read one copy. // chrome-stamp.mjs draws the stamps; chrome-deck.mjs draws the tally. // THE 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 { VERDICTS }; /** Where the stamp sits in the footage box. */ export const STAMP_POSITIONS = Object.freeze(["top-left", "top-right", "bottom-left", "bottom-right", "center"]); /** Which end of the deck's text column the tally takes. */ export const TALLY_POSITIONS = Object.freeze(["right", "left"]); /** * Every fact-check setting and its default. A manifest names only what it * changes; `resolveFactcheck` fills the rest, a verdict's label and colour * each on its own. The stamp's default corner is the one the popup posts * (top-right by default) do not use. */ export const FACTCHECK_DEFAULTS = Object.freeze({ verdicts: 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: VERDICT_LABEL_MAX }); /** * The stamp's motion, in seconds: it slams in over `slam` (landing is when the * tally steps), flashes up over `flashUp` and down over `flashDown`; the * tally's number rolls over `roll`. A stamp leaves with its segment's * outgoing transition, as a popup post does. */ export const STAMP_MOTION = Object.freeze({ slam: 0.28, flashUp: 0.06, flashDown: 0.55, roll: 0.4 }); /** A claim id: what names it in a report. Letters, digits and `_ . : -`. */ const CLAIM_ID_RE = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,63}$/; const isObj = (v) => v !== null && typeof v === "object" && !Array.isArray(v); const numIn = (v, lo, hi) => typeof v === "number" && Number.isFinite(v) && v >= lo && v <= hi; function unknownKeys(obj, allowed, where, errors) { for (const k of Object.keys(obj)) { if (!allowed.includes(k)) errors.push(`${where}.${k} is not a factcheck setting`); } } /** The fact-check settings with every default filled in. */ export function resolveFactcheck(render) { const f = isObj(render?.chrome?.factcheck) ? render.chrome.factcheck : {}; return { verdicts: resolveVerdicts(f.verdicts), stamp: { ...FACTCHECK_DEFAULTS.stamp, ...(isObj(f.stamp) ? f.stamp : {}) }, tally: { ...FACTCHECK_DEFAULTS.tally, ...(isObj(f.tally) ? f.tally : {}) }, }; } /** * Every reason `render.chrome.factcheck` cannot be built, as sentences (empty: * it can). Unknown keys are refused, as the deck's are. validateChrome calls * this, so umtool's writer and the build refuse the same things. * * @returns {string[]} */ export function validateFactcheck(f, where = "render.chrome.factcheck") { if (f === undefined) return []; if (!isObj(f)) return [`${where} must be an object`]; const errors = []; unknownKeys(f, ["verdicts", "stamp", "tally"], where, errors); if (f.verdicts !== undefined) { if (!isObj(f.verdicts)) errors.push(`${where}.verdicts must be an object of verdict → { label, color }`); else { for (const [k, v] of Object.entries(f.verdicts)) { const w = `${where}.verdicts.${k}`; if (!VERDICTS.includes(k)) { errors.push(`${w} is not a verdict (${VERDICTS.join(", ")})`); continue; } errors.push(...verdictOverrideProblems(v, w)); } } } const sub = (key, allowed, check) => { if (f[key] === undefined) return; if (!isObj(f[key])) { errors.push(`${where}.${key} must be an object`); return; } unknownKeys(f[key], allowed, `${where}.${key}`, errors); check(f[key]); }; sub("stamp", ["seconds", "position"], (s) => { const [lo, hi] = FACTCHECK_LIMITS.seconds; if (s.seconds !== undefined && !numIn(s.seconds, lo, hi)) errors.push(`${where}.stamp.seconds must be from ${lo} to ${hi}`); if (s.position !== undefined && !STAMP_POSITIONS.includes(s.position)) { errors.push(`${where}.stamp.position must be ${STAMP_POSITIONS.map((p) => `"${p}"`).join(", ")}`); } }); sub("tally", ["show", "position"], (t) => { if (t.show !== undefined && typeof t.show !== "boolean") errors.push(`${where}.tally.show must be true or false`); if (t.position !== undefined && !TALLY_POSITIONS.includes(t.position)) { errors.push(`${where}.tally.position must be ${TALLY_POSITIONS.map((p) => `"${p}"`).join(" or ")}`); } }); return errors; } // --------------------------------------------------------------------------- // The claim on an entry. // --------------------------------------------------------------------------- /** * A `claim` value, normalised: `{ id, verdict }` with the id trimmed, or null * for none (`undefined`, `null`). Throws a sentence on a shape no writer * should store. * * @returns {{ id: string, verdict: string } | null} */ export function normalizeClaim(v) { if (v === undefined || v === null) return null; if (!isObj(v)) throw new Error("claim must be { id, verdict }"); for (const k of Object.keys(v)) { if (k !== "id" && k !== "verdict") throw new Error(`claim.${k} is not a claim field (id, verdict)`); } const id = typeof v.id === "string" ? v.id.trim() : ""; if (!CLAIM_ID_RE.test(id)) throw new Error("claim.id must be letters, digits, dots, colons, dashes or underscores (at most 64)"); if (!VERDICTS.includes(v.verdict)) throw new Error(`claim.verdict must be one of ${VERDICTS.join(", ")}`); return { id, verdict: v.verdict }; } /** An entry's claim when it carries a sound one, else null. Never throws. */ export function claimOf(entry) { if (!entry || entry.type === "teaser") return null; try { return normalizeClaim(entry.claim); } catch { return null; } } /** * Every `claim` in the timeline, checked: its shape, that it is not on a * teaser (a full-frame finale is no evidence), and that every entry naming a * claim id names the same verdict. The build refuses with these before it * fetches; umtool's writer refuses with them before it writes. * * @returns {string[]} */ export function validateClaims(manifest) { const errors = []; const verdictOf = new Map(); (manifest?.timeline ?? []).forEach((e, i) => { if (e?.claim === undefined || e?.claim === null) return; const where = `timeline[${i}] (${e.id ?? "?"})`; if (e.type === "teaser") { errors.push(`${where}.claim: a teaser is not evidence for a claim`); return; } let c; try { c = normalizeClaim(e.claim); } catch (err) { errors.push(`${where}.${err.message}`); return; } const seen = verdictOf.get(c.id); if (seen && seen.verdict !== c.verdict) { errors.push(`${where}.claim ${c.id} is ${c.verdict} here and ${seen.verdict} at ${seen.where} -- one claim, one verdict`); } else if (!seen) verdictOf.set(c.id, { verdict: c.verdict, where }); }); return errors; } // --------------------------------------------------------------------------- // The stamps and the tally, in the cut's clock. // --------------------------------------------------------------------------- /** * When each claim's verdict is stamped: once per claim id, on the LAST * segment carrying it, in cut order. * * The stamp leaves with that segment's outgoing transition -- the next * segment's start to its start + D with a crossfade, the 0.3 s before the cut * on a hard cut, the last 0.3 s of the last segment -- and comes in `seconds` * before it leaves (`stamp.seconds`), or as the segment's incoming dissolve * ends when the segment is shorter than that. It has LANDED -- and the tally * steps -- `STAMP_MOTION.slam` after it starts, never after it starts leaving. * * `segments` are the schedule's, in order, each `{ id, start, duration, * claim? }` (`claim` as `claimOf` gives it). * * @returns {Array<{ claim: string, verdict: string, segment: string, at: number, * landed: number, out: [number, number] }>} */ export function stampSchedule({ segments, D, total, render }) { const seconds = resolveFactcheck(render).stamp.seconds; const last = new Map(); segments.forEach((s, i) => { if (s.claim) last.set(s.claim.id, i); }); return [...last.entries()] .sort((a, b) => a[1] - b[1]) .map(([claim, i]) => { const seg = segments[i]; const next = segments[i + 1]; const out = !next ? [total - 0.3, total] : D > 0 ? [next.start, next.start + D] : [next.start - 0.3, next.start]; const from = seg.start + (i > 0 ? D : 0); const at = Math.min(Math.max(from, out[0] - seconds), out[0]); const landed = Math.min(at + STAMP_MOTION.slam, out[0]); return { claim, verdict: seg.claim.verdict, segment: seg.id, at, landed, out }; }); } /** `stampSchedule`'s stamps as a schedule writes them: seconds to the millisecond. */ export function roundStamps(stamps) { const round = (v) => Math.round(v * 1000) / 1000; return stamps.map((s) => ({ ...s, at: round(s.at), landed: round(s.landed), out: s.out.map(round) })); } /** The verdicts a cut's stamps use, in VERDICTS order: the tally's cells. */ export function tallyVerdicts(stamps = []) { const used = new Set(stamps.map((s) => s.verdict)); return VERDICTS.filter((v) => used.has(v)); } /** * The tally's steps, in landing order: at each stamp's `landed`, its verdict's * count goes up by one. `counts` is every verdict's count after the step. * * @returns {Array<{ at: number, verdict: string, count: number, counts: Record }>} */ export function tallySteps(stamps = []) { const counts = Object.fromEntries(VERDICTS.map((v) => [v, 0])); return [...stamps] .sort((a, b) => a.landed - b.landed) .map((s) => { counts[s.verdict] += 1; return { at: s.landed, verdict: s.verdict, count: counts[s.verdict], counts: { ...counts } }; }); } /** * The verdicts a deck schedule's tally shows, or null for no tally: the * setting is on and the cut stamps at least one claim. */ export function tallyOf(schedule, render) { const stamps = schedule?.factcheck?.stamps ?? []; if (!stamps.length || !resolveFactcheck(render).tally.show) return null; return tallyVerdicts(stamps); } /** A tally cell's element keys, shared by the page and its cues. */ export const tallyKey = (verdict, part = null) => (part === null ? `ty.${verdict}` : `ty.${verdict}.${part}`); /** * The tally's part of the deck's timeline, as data the deck's cue walk takes * (chrome-deck.mjs `deckCues` states every from). * * Each cell is `ty.`, dim until its first count; each count it will show * is its own number `ty..n`, stacked in one box: at a step the number * on it rolls up and out and the next rolls up in, and the cell's flash * (`ty..flash`) fires. Absolute times, the cut's clock. * * @returns {{ init: Record, events: Array<{ k: string, at: number, dur: number, * to: object, ease: string, why: string }> }} */ export function tallyCues(stamps, verdicts = tallyVerdicts(stamps)) { const steps = tallySteps(stamps); const finals = Object.fromEntries(verdicts.map((v) => [v, 0])); for (const s of steps) finals[s.verdict] = s.count; const init = {}; for (const v of verdicts) { init[tallyKey(v)] = { opacity: 0.42 }; init[tallyKey(v, "flash")] = { opacity: 0 }; for (let k = 0; k <= finals[v]; k += 1) { init[tallyKey(v, `n${k}`)] = k === 0 ? { yPercent: 0, autoAlpha: 1 } : { yPercent: 100, autoAlpha: 0 }; } } const m = STAMP_MOTION; const events = []; for (const s of steps) { const why = `tally ${s.verdict} ${s.count}`; if (s.count === 1) events.push({ k: tallyKey(s.verdict), at: s.at, dur: 0.2, to: { opacity: 1 }, ease: "power1.out", why }); events.push({ k: tallyKey(s.verdict, `n${s.count - 1}`), at: s.at, dur: m.roll, to: { yPercent: -100, autoAlpha: 0 }, ease: "power2.in", why }); events.push({ k: tallyKey(s.verdict, `n${s.count}`), at: s.at, dur: m.roll, to: { yPercent: 0, autoAlpha: 1 }, ease: "power3.out", why }); events.push({ k: tallyKey(s.verdict, "flash"), at: s.at, dur: m.flashUp, to: { opacity: 1 }, ease: "none", why }); events.push({ k: tallyKey(s.verdict, "flash"), at: s.at + m.flashUp, dur: m.flashDown, to: { opacity: 0 }, ease: "power2.out", why }); } return { init, events }; } /** * The QR's link to a clip's ORIGINAL (`deck.qr.links: "original"`): the * record's `webpageUrl` at the clip's start -- YouTube with `t=` set (a * `watch?v=` page gains `&t=`, a youtu.be link `?t=`), anything else * as-is: a Rumble page has no time parameter a QR can carry, and an X post's * link is the post. Null without a usable https link. * * @returns {string|null} */ export function originalUrlAt(webpageUrl, seconds) { let u; try { u = new URL(String(webpageUrl ?? "")); } catch { return null; } if (u.protocol !== "https:" && u.protocol !== "http:") return null; const host = u.hostname.replace(/^(www|m)\./, ""); if ((host === "youtube.com" || host === "youtu.be") && Number.isFinite(seconds)) { u.searchParams.set("t", String(Math.max(0, Math.floor(seconds)))); } return u.toString(); }