// THREADS: a cut's clips grouped into the few lines of argument they make, // drawn as a rail of cards down the frame's left side. The card of the thread // on screen is lit and strung to the footage; a dot fills for each of its // clips as it plays; when the thread's last clip ends, its outcome is stamped // on its card. By the last clip the rail is the whole argument at a glance. // // render.chrome: "threads": { "list": [ { "id": "bet", "label": "The bet", // "outcome": { "verdict": "CONTRADICTED" } } ] } // timeline entry: "thread": "bet" // // An outcome names a verdict of the shared vocabulary; its label and colour // are the cut's (`factcheck.verdicts`), or the outcome's own `label`. A clip // with no `thread` belongs to none: nothing is lit while it plays. // // With threads on, the footage box moves to the frame's right edge and the // rail takes the left (deck.mjs deckGeometry, threadsGeometry). A popup post // moves the footage over the rail, so the rail steps aside while one is up. // // PURE, like factcheck.mjs: the settings, their validation, the schedule and // the cues. No fs. deck.mjs imports this file -- never the other way round. // chrome-threads.mjs draws the rail. import { resolveFactcheck, VERDICTS } from "./factcheck.mjs"; /** The limits: how many threads, a label's and an outcome label's characters. */ export const THREADS_LIMITS = Object.freeze({ threads: Object.freeze([1, 8]), label: 32, outcome: 24 }); /** * The rail's motion, in seconds: a card lights over `light` and its string * draws over `string`; a clip's dot fills over `dot`; an outcome slams in over * `slam` and flashes; the rail steps aside (and back) over `aside`. The cards * come up one after another at the start, `stagger` apart. */ export const THREAD_MOTION = Object.freeze({ light: 0.45, string: 0.5, dot: 0.3, slam: 0.3, flashUp: 0.06, flashDown: 0.6, aside: 0.4, intro: 0.5, stagger: 0.08, }); /** How long before its thread's last clip ends the outcome lands, at most. */ export const OUTCOME_LEAD = 1.8; /** A thread id: letters, digits and `_ -`. */ const THREAD_ID_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,47}$/; const isObj = (v) => v !== null && typeof v === "object" && !Array.isArray(v); const oneLine = (v) => typeof v === "string" && v.trim() !== "" && !/[\n\r]/.test(v); /** Is the rail on: a `render.chrome.threads` with at least one thread. */ export function threadsOn(render) { const t = render?.chrome?.threads; return isObj(t) && Array.isArray(t.list) && t.list.length > 0; } /** * The threads with their outcomes resolved: `[{ id, label, outcome: { verdict, * label, color } | null }]`, in the rail's order (the list's). */ export function resolveThreads(render) { if (!threadsOn(render)) return []; const verdicts = resolveFactcheck(render).verdicts; return render.chrome.threads.list.map((t) => { const o = isObj(t.outcome) ? t.outcome : null; const v = o ? verdicts[o.verdict] : null; return { id: t.id, label: t.label, outcome: o && v ? { verdict: o.verdict, label: o.label ?? v.label, color: v.color } : null, }; }); } /** * Every reason `render.chrome.threads` cannot be built, as sentences (empty: * it can). validateChrome calls this, so umtool's writer and the build refuse * the same things. * * @returns {string[]} */ export function validateThreads(t, where = "render.chrome.threads") { if (t === undefined) return []; if (!isObj(t)) return [`${where} must be an object`]; const errors = []; for (const k of Object.keys(t)) if (k !== "list") errors.push(`${where}.${k} is not a threads setting`); const [lo, hi] = THREADS_LIMITS.threads; if (!Array.isArray(t.list) || t.list.length < lo || t.list.length > hi) { return [...errors, `${where}.list must be ${lo} to ${hi} threads`]; } const seen = new Set(); t.list.forEach((th, i) => { const w = `${where}.list[${i}]`; if (!isObj(th)) { errors.push(`${w} must be an object`); return; } for (const k of Object.keys(th)) if (!["id", "label", "outcome"].includes(k)) errors.push(`${w}.${k} is not a thread key`); if (typeof th.id !== "string" || !THREAD_ID_RE.test(th.id)) errors.push(`${w}.id must be letters, digits, _ or -`); else if (seen.has(th.id)) errors.push(`${w}.id ${th.id} is listed twice`); else seen.add(th.id); if (!oneLine(th.label) || th.label.length > THREADS_LIMITS.label) { errors.push(`${w}.label must be one line of at most ${THREADS_LIMITS.label} characters`); } if (th.outcome !== undefined) { const o = th.outcome; if (!isObj(o)) { errors.push(`${w}.outcome must be an object`); return; } for (const k of Object.keys(o)) if (!["verdict", "label"].includes(k)) errors.push(`${w}.outcome.${k} is not an outcome key`); if (!VERDICTS.includes(o.verdict)) errors.push(`${w}.outcome.verdict must be one of ${VERDICTS.join(", ")}`); if (o.label !== undefined && (!oneLine(o.label) || o.label.length > THREADS_LIMITS.outcome)) { errors.push(`${w}.outcome.label must be one line of at most ${THREADS_LIMITS.outcome} characters`); } } }); return errors; } /** The thread an entry belongs to, or null. */ export function threadOf(entry) { return typeof entry?.thread === "string" && entry.thread ? entry.thread : null; } /** * Every `thread` in the timeline, checked against the list: it names a listed * thread, it is not on a teaser, and every listed thread has a clip. The * build refuses with these before it fetches. * * @returns {string[]} */ export function validateThreadEntries(manifest) { const errors = []; const render = manifest?.render ?? {}; const listed = threadsOn(render) ? new Set(render.chrome.threads.list.map((t) => t?.id)) : new Set(); const used = new Set(); (manifest?.timeline ?? []).forEach((e, i) => { if (e?.thread === undefined || e?.thread === null) return; const where = `timeline[${i}] (${e.id ?? "?"}).thread`; if (typeof e.thread !== "string" || !e.thread) { errors.push(`${where} must be a thread id`); return; } if (e.type === "teaser") { errors.push(`${where}: a teaser belongs to no thread`); return; } if (!listed.has(e.thread)) { errors.push(`${where} ${e.thread} is not in render.chrome.threads.list`); return; } used.add(e.thread); }); for (const id of listed) if (!used.has(id)) errors.push(`render.chrome.threads: thread ${id} has no clip`); return errors; } /** * The rail's schedule, in the cut's clock. `segments` are the schedule's, in * order, each `{ id, start, duration, thread }`; `moves` are the footage's * moves for popup posts (deck.mjs footageMoves). * * - `threads`: each listed thread with its `clips` (`{ segment, at }`: when * its dot fills -- halfway into the dissolve that brings the clip in) and, * with an outcome, `closeAt`: when it is stamped -- `OUTCOME_LEAD` before * its last clip ends, but never in that clip's first half second, and never * while the rail is aside (then as it is back); * - `runs`: when each thread is the one on screen (`{ thread, from, to }`: * consecutive clips of one thread make one run, ending as the next clip * starts to come in); * - `asides`: when the rail steps aside (`{ from, to }`: a popup post's move * to the end of its clip). * * @returns {{ threads: Array<{ id: string, label: string, outcome: object|null, * clips: Array<{ segment: string, at: number }>, closeAt?: number }>, * runs: Array<{ thread: string, from: number, to: number }>, * asides: Array<{ from: number, to: number }> }} */ export function threadSchedule({ segments, D, total, render, moves = [] }) { const R = (v) => Math.round(v * 1000) / 1000; const endOf = (i) => (i + 1 < segments.length ? segments[i + 1].start : total); const threads = resolveThreads(render).map((t) => ({ ...t, clips: [] })); const byId = new Map(threads.map((t) => [t.id, t])); const last = new Map(); segments.forEach((s, i) => { const t = s.thread ? byId.get(s.thread) : null; if (!t) return; t.clips.push({ segment: s.id, at: R(i === 0 ? s.start : s.start + D / 2) }); last.set(t.id, i); }); const asides = moves.map((m) => { const i = segments.findIndex((s) => s.id === m.segment); return { from: R(m.at), to: R(i < 0 ? total : endOf(i)) }; }); for (const t of threads) { if (!t.outcome || !last.has(t.id)) continue; const i = last.get(t.id); const s = segments[i]; let at = Math.max(s.start + 0.5, endOf(i) - D - OUTCOME_LEAD); // Stamped where it can be seen: a rail stepped aside for a post gets it as it comes back. const hidden = asides.find((a) => at >= a.from - 0.2 && at < a.to + THREAD_MOTION.aside); if (hidden) at = hidden.to + THREAD_MOTION.aside + 0.1; t.closeAt = R(at); } const runs = []; segments.forEach((s, i) => { if (!s.thread || !byId.has(s.thread)) return; const prev = runs[runs.length - 1]; if (prev && prev.thread === s.thread && prev.lastIdx === i - 1) { prev.to = R(endOf(i)); prev.lastIdx = i; } else runs.push({ thread: s.thread, from: R(s.start), to: R(endOf(i)), lastIdx: i }); }); return { threads, runs: runs.map(({ lastIdx, ...r }) => r), asides }; } /** * Order a page's cue events, clamp each so it never starts before the last on * its own element ends, and state every from (the last `to` on its element, * or its `init`): a render is a seek per frame, in any order, so a cue must * never depend on what played before it. The same bookkeeping as the * stamps' and the posts'. */ export function planCues(init, events, instant = 0.001) { const r4 = (v) => Math.round(v * 10000) / 10000; const ev = events.map((e, n) => ({ ...e, at: r4(e.at), dur: r4(Math.max(instant, e.dur)), n })); ev.sort((a, b) => a.at - b.at || a.n - b.n); const state = Object.fromEntries(Object.entries(init).map(([k, v]) => [k, { ...v }])); const freeAt = new Map(); const cues = []; for (const e of ev) { let { at, dur } = e; const free = freeAt.get(e.k) ?? 0; if (at < free) { const end = at + dur; at = r4(free); dur = r4(Math.max(instant, end - at)); } const cur = state[e.k] ?? (state[e.k] = {}); const from = {}; for (const p of Object.keys(e.to)) from[p] = cur[p]; Object.assign(cur, e.to); freeAt.set(e.k, r4(at + dur)); cues.push({ k: e.k, at, dur, from, to: e.to, ease: e.ease, why: e.why }); } return cues; } /** A card's resting opacity before its thread plays, and after. */ export const CARD_REST = Object.freeze({ ahead: 0.42, done: 0.8 }); /** A dot before its clip plays: there, hollow-looking and small, so a card shows how many clips its thread has. */ export const DOT_AHEAD = Object.freeze({ opacity: 0.22, scale: 0.7 }); /** * Everything the rail's timeline does, as data. Card j is `c` (opacity), its * lit wash `w` and string `s` (scaleX from its left), its dots * `d-`, its outcome `o` (autoAlpha, scale) with its flash `f`; the * whole rail is `rail` (autoAlpha, x). * * @returns {{ init: Record, cues: Array }} */ export function threadCues(sched) { const m = THREAD_MOTION; const init = { rail: { autoAlpha: 1, x: 0 } }; const ev = []; const add = (k, at, dur, to, ease, why) => ev.push({ k, at, dur, to, ease, why }); const firstRun = new Map(); for (const r of sched.runs) if (!firstRun.has(r.thread)) firstRun.set(r.thread, r.from); sched.threads.forEach((t, j) => { init[`c${j}`] = { opacity: 0 }; init[`w${j}`] = { opacity: 0 }; init[`s${j}`] = { scaleX: 0 }; t.clips.forEach((_, n) => { init[`d${j}-${n}`] = { ...DOT_AHEAD }; }); // A card whose thread is already on screen by the end of its intro comes up lit. const lit = (firstRun.get(t.id) ?? Infinity) <= m.stagger * j + m.intro; add(`c${j}`, m.stagger * j, m.intro, { opacity: lit ? 1 : CARD_REST.ahead }, "power2.out", `intro ${t.id}`); t.clips.forEach((c, n) => add(`d${j}-${n}`, c.at, m.dot, { opacity: 1, scale: 1 }, "back.out(2)", `dot ${t.id} ${c.segment}`)); if (t.outcome && t.closeAt != null) { init[`o${j}`] = { autoAlpha: 0, scale: 1.6 }; init[`f${j}`] = { opacity: 0 }; add(`o${j}`, t.closeAt, m.slam, { autoAlpha: 1, scale: 1 }, "power4.in", `outcome ${t.id}`); add(`f${j}`, t.closeAt + m.slam, m.flashUp, { opacity: 1 }, "none", `outcome ${t.id} flash`); add(`f${j}`, t.closeAt + m.slam + m.flashUp, m.flashDown, { opacity: 0 }, "power2.out", `outcome ${t.id} flash`); } }); const idx = new Map(sched.threads.map((t, j) => [t.id, j])); for (const r of sched.runs) { const j = idx.get(r.thread); add(`c${j}`, r.from, m.light, { opacity: 1 }, "power2.out", `light ${r.thread}`); add(`w${j}`, r.from, m.light, { opacity: 1 }, "power2.out", `light ${r.thread}`); add(`s${j}`, r.from + 0.1, m.string, { scaleX: 1 }, "power3.out", `string ${r.thread}`); add(`s${j}`, r.to - m.string * 0.6, m.string * 0.6, { scaleX: 0 }, "power2.in", `unstring ${r.thread}`); add(`w${j}`, r.to, m.light, { opacity: 0 }, "power2.inOut", `dim ${r.thread}`); add(`c${j}`, r.to, m.light, { opacity: CARD_REST.done }, "power2.inOut", `dim ${r.thread}`); } for (const a of sched.asides) { add("rail", a.from, m.aside, { autoAlpha: 0, x: -40 }, "power2.in", "aside for a post"); add("rail", a.to, m.aside, { autoAlpha: 1, x: 0 }, "power2.out", "back after a post"); } return { init, cues: planCues(init, ev) }; }