commit ef89d816b56c77ec2b4f1d7bdde073690a413200
parent 3b49a45d29fe553294898791542a91c79d3a1381
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Fri, 9 Oct 2026 09:57:40 -0400
Merge main into umtool/articles
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
30 files changed, 1641 insertions(+), 111 deletions(-)
diff --git a/common/controller/transcribeFile.test.ts b/common/controller/transcribeFile.test.ts
@@ -133,6 +133,7 @@ const {
transcribeWorkerFilter,
windowOf,
windowWavArgs,
+ wordsFromTranscript,
} = await import("./transcribeFile");
const { getPaths } = await import("../lib/paths");
const paths = getPaths();
@@ -184,6 +185,34 @@ test("workerId and out are checked for shape", () => {
assert.match(err({ out: 1 })!, /"out" must be a non-empty string/);
});
+test("words is a boolean, kept only when true", () => {
+ const parsed = (b: Record<string, unknown>) => parseTranscribeFileBody({ path: MEDIA, ...b });
+ assert.match((parsed({ words: "yes" }) as { error: string }).error, /"words" must be true or false/);
+ const on = parsed({ words: true });
+ assert.ok(on.ok && on.value.words === true);
+ const off = parsed({ words: false });
+ assert.ok(off.ok && !("words" in off.value));
+});
+
+test("wordsFromTranscript shifts the engine's words onto the file's clock", () => {
+ const raw = JSON.stringify({
+ chunk_data: [{ start_time: 0, end_time: 1, text: "um so" }],
+ words: [
+ { w: " um", start: 0.12, end: 0.4, conf: 0.9 },
+ { w: "so", start: 0.5, end: 0.7 },
+ { w: " ", start: 0.8, end: 0.9 },
+ { w: "bad", start: "x", end: 1 },
+ ],
+ });
+ assert.deepEqual(wordsFromTranscript(raw, 120), [
+ { w: "um", start: 120.12, end: 120.4, conf: 0.9 },
+ { w: "so", start: 120.5, end: 120.7 },
+ ]);
+ // Another engine's document, or an older wrapper: no words, not a failure.
+ assert.deepEqual(wordsFromTranscript(JSON.stringify({ chunk_data: [] }), 0), []);
+ assert.deepEqual(wordsFromTranscript("not json", 0), []);
+});
+
// --- the disk checks --------------------------------------------------------
const ctx = { paths, workers: WORKERS };
diff --git a/common/controller/transcribeFile.ts b/common/controller/transcribeFile.ts
@@ -74,6 +74,7 @@ export const TRANSCRIBE_FILE_BODY_KEYS = [
"end",
"workerId",
"out",
+ "words",
] as const;
const AUDIO_NAME = "audio.wav";
@@ -87,8 +88,14 @@ export type TranscribeFileRequest = {
end?: number;
workerId?: string;
out?: string;
+ // True returns the engine's word timestamps as well as the cues. Only an
+ // engine that keeps them (parakeet) answers with any; the rest give none.
+ words?: boolean;
};
+// One word as the engine timed it, on the source file's clock (seconds).
+export type TranscribedWord = { w: string; start: number; end: number; conf?: number };
+
export type TranscribeWorkerInfo = {
id: string;
name: string;
@@ -108,6 +115,8 @@ export type TranscribeFileResult = {
durationMs: number;
cues: Cue[];
text: string;
+ // Present only when the request asked for words: [] when the engine has none.
+ words?: TranscribedWord[];
};
type Check<T> = { ok: true; value: T } | { ok: false; error: string };
@@ -157,6 +166,10 @@ export function parseTranscribeFileBody(
return { ok: false, error: `"out" must be an absolute path (got "${out}")` };
}
}
+ const words = body.words;
+ if (words !== undefined && typeof words !== "boolean") {
+ return { ok: false, error: '"words" must be true or false' };
+ }
return {
ok: true,
value: {
@@ -165,6 +178,7 @@ export function parseTranscribeFileBody(
...(end.value !== undefined ? { end: end.value } : {}),
...(typeof workerId === "string" ? { workerId: workerId.trim() } : {}),
...(typeof out === "string" ? { out: path.resolve(out) } : {}),
+ ...(words === true ? { words: true } : {}),
},
};
}
@@ -364,6 +378,35 @@ export function windowWavArgs(
const ms = (n: number) => Math.round(n * 1000) / 1000;
// Cues from a window start at zero; shift them back onto the source's clock.
+// The top-level `words` an engine asked with `words` wrote (parakeet's
+// wrapper, --words), shifted by `offset` onto the source file's clock. A
+// document without them -- another engine, or an older wrapper -- gives [].
+export function wordsFromTranscript(raw: string, offset: number): TranscribedWord[] {
+ let doc: unknown;
+ try {
+ doc = JSON.parse(raw);
+ } catch {
+ return [];
+ }
+ const list = (doc as { words?: unknown } | null)?.words;
+ if (!Array.isArray(list)) return [];
+ const out: TranscribedWord[] = [];
+ for (const w of list) {
+ if (!w || typeof w !== "object") continue;
+ const { w: text, start, end, conf } = w as Record<string, unknown>;
+ if (typeof text !== "string" || !text.trim()) continue;
+ if (typeof start !== "number" || typeof end !== "number") continue;
+ if (!Number.isFinite(start) || !Number.isFinite(end)) continue;
+ out.push({
+ w: text.trim(),
+ start: ms(start + offset),
+ end: ms(end + offset),
+ ...(typeof conf === "number" && Number.isFinite(conf) ? { conf } : {}),
+ });
+ }
+ return out;
+}
+
export function offsetCues(cues: readonly Cue[], offset: number): Cue[] {
return cues.map((c) => ({
start: ms(c.start + offset),
@@ -445,6 +488,7 @@ export async function runTranscribeFile(
used.worker = w;
},
skipInlineDiarization: true,
+ ...(req.words ? { words: true } : {}),
});
const worker = used.worker;
if (outcome !== "transcribed" || !worker) {
@@ -467,6 +511,7 @@ export async function runTranscribeFile(
durationMs: Date.now() - started,
cues,
text: cues.map((c) => c.text.trim()).filter(Boolean).join(" "),
+ ...(req.words ? { words: wordsFromTranscript(raw, req.start ?? 0) } : {}),
};
} finally {
await rm(scratch, { recursive: true, force: true }).catch(() => {});
diff --git a/common/controller/transcribeOne.ts b/common/controller/transcribeOne.ts
@@ -99,6 +99,9 @@ export type TranscribeOneOptions = {
// one-off file transcription (controller/transcribeFile.ts) runs in a scratch
// dir that is deleted afterwards, so a diarization there is work thrown away.
skipInlineDiarization?: boolean;
+ // True asks the engine to keep word timestamps in transcript.json (see
+ // TranscribeBuildInput.words). Only the one-off file transcription asks.
+ words?: boolean;
};
export type TranscribeOneOutcome = "transcribed" | "already-exists" | "paused";
@@ -197,6 +200,7 @@ export async function transcribeOneVideo(
audioFile: resolvedAudio,
outputBase: tmpBase,
config: appConfig,
+ ...(opts.words ? { words: true } : {}),
});
const child = execa(bin, build.argv, {
cwd: opts.videoDir,
@@ -380,6 +384,8 @@ export type TranscribeWithWorkerOptions = {
onWorker?: (worker: Worker) => void;
// See TranscribeOneOptions.skipInlineDiarization.
skipInlineDiarization?: boolean;
+ // See TranscribeOneOptions.words.
+ words?: boolean;
};
// Acquire a worker from the global pool and transcribe one video through it,
@@ -444,6 +450,7 @@ export async function transcribeWithWorker(
signal: opts.signal,
partialSignal: partialController?.signal,
skipInlineDiarization: opts.skipInlineDiarization,
+ words: opts.words,
});
pool.markSuccess(worker.id);
return outcome;
diff --git a/common/lib/transcriptionApps.test.ts b/common/lib/transcriptionApps.test.ts
@@ -0,0 +1,27 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { getTranscriptionApp } from "./transcriptionApps";
+
+const input = { audioFile: "audio.wav", outputBase: "transcript.tmp", config: { model: "/m.gguf" } };
+
+test("parakeet keeps its words only when asked", () => {
+ const app = getTranscriptionApp("parakeet");
+ assert.ok(!app.build(input).argv.includes("--words"));
+ const asked = app.build({ ...input, words: true }).argv;
+ assert.ok(asked.includes("--words"));
+ // The audio file stays the last argument: the wrapper reads it positionally.
+ assert.equal(asked.at(-1), "audio.wav");
+});
+
+test("an engine with no words to give ignores the ask", () => {
+ for (const id of ["whisper.cpp", "chough"]) {
+ let app;
+ try {
+ app = getTranscriptionApp(id);
+ } catch {
+ continue;
+ }
+ if (app.id !== id) continue;
+ assert.deepEqual(app.build({ ...input, words: true }).argv, app.build(input).argv);
+ }
+});
diff --git a/common/lib/transcriptionApps.ts b/common/lib/transcriptionApps.ts
@@ -73,6 +73,11 @@ export type TranscribeBuildInput = {
audioFile: string; // basename relative to videoDir
outputBase: string; // e.g. "transcript.tmp-<pid>" (NO extension)
config: AppInstanceConfig;
+ // Ask the engine to keep its word timestamps in the output document (a
+ // top-level `words` array) as well as the grouped cues. Only parakeet has
+ // them to give; the others ignore it. Off for corpus transcriptions, whose
+ // transcript.json would otherwise carry every word twice.
+ words?: boolean;
};
export type TranscriptionApp = {
@@ -244,10 +249,11 @@ const parakeet: TranscriptionApp = {
supportsPartialStop: true,
defaultBin: () => getPaths().parakeetBin,
resolveModel: (config) => config.model?.trim() || getPaths().parakeetModel,
- build({ audioFile, outputBase, config }) {
+ build({ audioFile, outputBase, config, words }) {
const paths = getPaths();
const model = parakeet.resolveModel(config) as string;
const argv = ["--model", model, "--output", outputBase];
+ if (words) argv.push("--words");
if (typeof config.chunkSize === "number" && config.chunkSize > 0) {
argv.push("--segment", String(Math.floor(config.chunkSize)));
}
diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md
@@ -1,7 +1,9 @@
# Changelog
## [Unreleased]
+- **A file transcription can return word timings.** `pnpm ops transcribe` takes `"words": true` and adds `words: [{w, start, end, conf?}]` to its result, on the source file's clock like the cues. parakeet keeps the word timestamps it stitches from its windows (its wrapper's new `--words` writes them beside the cues); an engine without them answers `[]`. Corpus transcriptions do not ask, so their `transcript.json` is unchanged.
- **A single file can be transcribed through the editor.** `pnpm ops transcribe` (`POST /api/ops/transcribe`) takes `{"path"}` — an absolute path to a local audio or video file — with `"start"`/`"end"` (seconds) for a window, `"workerId"` (a configured local worker; default: the one auto-transcribe would get) and `"out"` (an absolute path for the result). It runs as a "Transcribe file" job on `/jobs`, through the worker pool and the same engine, model and command line the corpus is transcribed with. A window is cut by ffmpeg to a temporary 16 kHz mono WAV, removed afterwards. The result is `{path, window, worker: {id, name, appId, model, device}, transcriptFormat, cues: [{start, end, text}], text, …}`, cue times on the source file's clock; it ends the job's log, is written to `out` when given, and `--wait` prints it on stdout. Refused before any job: a relative or unreadable path, an `end` not after `start`, an unknown or remote worker, a named worker switched off, an `out` inside the corpus (resolved through symlinks, storage locations included). Nothing is written to the corpus or to settings.
+- **Page titles no longer carry a paragraph of explanation.** /channels used to open with three lines of prose about where its numbers come from, and at a narrow window with the sidebar open its four buttons took the row and squeezed that prose to one word a line. It is now one status line — how many channels, how old the oldest report is, how many have none — and when the buttons do not fit beside the title they drop below it. Workers, Sites, Review and Operations lose their lead paragraph; Tags, Storage and Saved videos keep one line each, the instruction (rule edits apply at the next index build; media moves from a channel's Storage panel; the keep-latest window is in a channel's settings); the Monitor widget builder loses its own, which repeated the board's. Needs a restart of the editor.
- **Clip windows can be fetched as a list, paced, in one job per platform.** `pnpm ops fetch-windows` (`POST /api/ops/fetch-windows`) takes `{"siteId"}` — every window a site's published reports cite and the disk does not hold — or `{"items": [{"slug", "id", "from", "to", "clipId"?, "reason"?}], "requestedBy", "manifest"?}`, with `"maxHeight"` and `"dryRun"`. A window already on disk is answered at once and joins no job; the rest are grouped by platform queue and fetched as one `fetch-windows` job per platform, so YouTube and Rumble run side by side, each window through the same managed fetch as a single one (cookie policy, auth and HLS retries, provenance). Between two fetches on a platform the job waits that platform's batch gap, at least 30 s and up to half again at random; before each it checks the platform's cooldown and hold and stops when either is set. A 429 backs the platform off and stops the job; one 403 is that window's failure, two in a row back the platform off and stop it. A platform cooling down or held is refused for its group before anything starts. The job is drainable, shows its progress as clip windows, and Retry or running the same body again fetches only what is still missing. A dry run lists the windows per platform, the ones on disk, and the spans no window can fill (a video the source says is deleted, private or members-only, a channel off the site, an unmounted drive). A site's **Reports** tab has **Fetch missing evidence** and **Preview missing evidence** beside **Prepare evidence media**, and umtool's `fetch-via-editor.mjs --all` sends a manifest's whole timeline as one request. A window fetch whose cookie retry runs into a 429 now records the cooldown too. Needs a restart of the editor.
- **A social channel can be renamed and deleted from its page.** A posts channel's page (X, Bluesky, a forum thread) had no Danger zone, so it could not be renamed or deleted in the editor at all. It now has the one a video channel has, collapsed under the posts panel and opened by the same `?stage=danger` link: **Rename channel** and **Delete channel**, typing the slug to confirm, refused while the channel is busy, in the same sentences. Needs a restart of the editor.
- **A channel can be created, renamed, deleted and put on sites over `pnpm ops`.** `create-channel` is the New channel form (`{"fields": {"name", "handling", "url", …}}`, the same field names as `channel-config`'s `patch`; `"slug"` and `"sites"` optional; the form's "Fetch playlist now", "Fetch posts now" and "Add to top of auto-queue" are off unless asked for, and a job they start is returned so `--wait` follows it). `rename-channel` (`{"slug", "newSlug"}`) and `delete-channel` (`{"slug", "confirm"}`, `confirm` repeating the slug) are the Danger zone's two forms. `channel-config` takes `"sites"` — the whole membership set, `[]` for on no site; an unknown site is refused rather than skipped — and `"excludeFromBuild"` / `"excludeFromCleanup"`, set to the value given rather than toggled, with or without a `patch`. `pnpm ops get channels` lists every channel with its kind, platform and the sites that carry it. Each runs the form's own action, so it refuses what the form refuses, in the same words. Needs a restart of the editor.
diff --git a/editor/app/channels/page.tsx b/editor/app/channels/page.tsx
@@ -357,55 +357,44 @@ export default async function ChannelsPage({
// fixed chrome and the rack between them is the only thing that moves.
// Below md there is no height and the document scrolls as it always has.
<div className="flex flex-col gap-3 md:h-[calc(100vh-3rem)] md:min-h-0">
- {/* NOWRAP ON md+, AND THE SUBTITLE IS WHAT GIVES. The freshness note is
- three lines of prose, not a tagline: at its max-w-3xl ceiling it
- claimed 768px of a 1216px content column, which left no room for the
- corpus-wide cluster and dropped it onto a band of its own — the same
- undifferentiated row of pills this change exists to remove. So the
- left block takes whatever is left and the paragraph rewraps into it
- (max-w-3xl stays a ceiling, never a floor), and the cluster keeps its
- intrinsic width at the top right. Below md the header still wraps and
- the cluster still stacks full-width. */}
- <header className="flex flex-wrap items-start justify-between gap-x-4 gap-y-2 shrink-0 md:flex-nowrap">
- <div className="min-w-0 md:flex-1">
+ {/* THE HEADER WRAPS; IT NEVER SQUEEZES. The title block has a 16rem
+ basis, so when the corpus-wide cluster cannot sit beside it at full
+ width the cluster drops onto its own line. It used to be nowrap with
+ the cluster shrink-0, and at a narrow md+ column (sidebar open) the
+ title block was left ~60px and its text ran one word per line. */}
+ <header className="flex flex-wrap items-start justify-between gap-x-4 gap-y-2 shrink-0">
+ <div className="min-w-0 flex-[1_1_16rem]">
<h1 className="text-2xl font-semibold">Channels</h1>
- {/* THE FRESHNESS NOTE IS THE PAGE'S CAVEAT, so it sits with the
- page's title. It used to be the last thing on the page, in 11px
- type under a floating bar, ~6,000px below the numbers it is a
- caveat ABOUT — which is to say it was written but not said. */}
+ {/* A STATUS LINE, NOT A LEAD PARAGRAPH. Every pipeline on the page is
+ read from each channel's last report, so the line says how old the
+ oldest one is and how many have none (the rack's Report column names
+ them). */}
{channels.length > 0 && (
<p
- className="max-w-3xl text-xs text-muted-foreground"
+ className="text-xs text-muted-foreground"
data-testid="channels-freshness"
>
<span className="tabular-nums">{channels.length}</span>{" "}
{channels.length === 1 ? "channel" : "channels"} ·{" "}
{freshness.oldest ? (
<>
- Every pipeline on this page — downloads, transcripts,
- digests and the speaker lanes — is read from each
- channel’s last report; the oldest on this page was
- generated{" "}
+ oldest report{" "}
<time dateTime={freshness.oldest}>
{new Date(freshness.oldest).toLocaleString()}
</time>
- . A just-finished job can take a moment to show up here.
</>
) : (
- <>
- No channel on this page has a generated report yet, so every
- pipeline reads empty. Run <em>Refresh report</em> from a row
- here, or <em>Update all reports</em> above, to populate them.
- </>
+ <>no reports yet — run <em>Update all reports</em></>
)}
{freshness.missing.length > 0 && freshness.oldest && (
<>
{" "}
- No report yet for {freshness.missing.join(", ")} — those rows
- read zero
- {sections
- ? ", and a group figure that counts one of them is marked with a trailing + to say it is a floor rather than a total."
- : "."}
+ ·{" "}
+ <span className="tabular-nums">
+ {freshness.missing.length}
+ </span>{" "}
+ without a report
+ {sections ? " (a group figure marked + is a floor)" : ""}
</>
)}
</p>
diff --git a/editor/app/operations/page.tsx b/editor/app/operations/page.tsx
@@ -27,11 +27,6 @@ export default async function OperationsPage() {
<h1 className="font-display text-2xl font-semibold tracking-tight">
Operations
</h1>
- <p className="text-sm text-muted-foreground">
- Every operation that turns this archive into a derived corpus, and what
- each one is doing right now. Open one to set its rules, arm its sweep or
- hold its lane.
- </p>
<OperationsBoard initial={initial} sync={sync} />
</div>
);
diff --git a/editor/app/review/page.tsx b/editor/app/review/page.tsx
@@ -34,9 +34,6 @@ export default async function ReviewPage() {
return (
<div className="flex flex-col gap-6">
<h1 className="text-2xl font-semibold">Review</h1>
- <p className="text-sm text-muted-foreground">
- Corpus review — findings a human decides, not work a lane runs.
- </p>
<AutoPausedSection rows={review.autoPaused} />
<DuplicatesSection
report={review.duplicates}
diff --git a/editor/app/saved-videos/page.tsx b/editor/app/saved-videos/page.tsx
@@ -57,10 +57,7 @@ export default async function SavedVideosPage() {
<header className="flex flex-col gap-1">
<h1 className="text-xl font-semibold">Saved videos</h1>
<p className="text-sm text-muted-foreground">
- Source video containers persisted by the keep-latest retention rule
- live in a separate store, leaving the main data volume holding only
- audio + transcripts. Configure a channel's keep-latest window and
- per-channel store dir under that channel's settings.
+ Set a channel's keep-latest window and store dir in its settings.
</p>
</header>
diff --git a/editor/app/sites/page.tsx b/editor/app/sites/page.tsx
@@ -94,11 +94,6 @@ export default async function SitesPage() {
+ New site
</Link>
</div>
- <p className="text-sm text-muted-foreground max-w-2xl">
- Each site is a selection + branding over the shared channel pool. The
- same channel can appear on several sites; its downloads are stored once
- and reused.
- </p>
{sites.length === 0 && (
<div className="flex flex-col gap-3 rounded border border-border p-4">
<p className="text-sm">
diff --git a/editor/app/storage/page.tsx b/editor/app/storage/page.tsx
@@ -22,17 +22,9 @@ export default async function StoragePage() {
<h1 className="text-2xl font-semibold">Storage</h1>
</div>
- <p className="text-sm text-muted-foreground max-w-3xl">
- A storage location is a named place a channel’s media — its big
- files, the audio and the raw live chat — may live, usually a second
- drive; a channel’s text never leaves the corpus volume. A channel
- is on a location when its <code>mediaDir</code> is under that
- location’s root; nothing is tagged, so moving a channel on or off
- one is a move, not a setting. When a drive comes back at a different
- mountpoint, <strong>re-point</strong> the location: it rewrites every
- channel’s symlink and <code>mediaDir</code> and moves no bytes. Move media onto a location from
- a channel’s <Link href="/channels" className="underline">Storage
- panel</Link>.
+ <p className="text-sm text-muted-foreground">
+ Move a channel’s media onto a location from its{" "}
+ <Link href="/channels" className="underline">Storage panel</Link>.
</p>
<StorageLocationsTable payload={payload} />
diff --git a/editor/app/tags/page.tsx b/editor/app/tags/page.tsx
@@ -49,13 +49,8 @@ export default async function TagsPage() {
<section className="flex flex-col gap-4">
<header className="flex flex-col gap-1">
<h1 className="text-2xl font-semibold">Tags</h1>
- <p className="max-w-3xl text-sm text-muted-foreground">
- A curated vocabulary that cuts across channels. A tag lands on a video
- three ways: a <strong>rule</strong> re-evaluated at every index build,
- a <strong>pin</strong> somebody made by hand, or an import from umtool
- — and a <strong>suppression</strong> rejects a rule's hit. Pins
- and suppressions are stored with their provenance; rule hits never
- are. Edit a rule, rebuild the index, done.
+ <p className="text-sm text-muted-foreground">
+ Rule edits apply at the next index build.
</p>
</header>
<EditorTagsClient
diff --git a/editor/app/widget/builder/page.tsx b/editor/app/widget/builder/page.tsx
@@ -27,16 +27,6 @@ export default async function WidgetBuilderPage() {
<div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold">Monitor widget</h1>
</div>
- <p className="max-w-2xl text-sm text-muted-foreground">
- A read-only monitor with no sidebar, sized to sit in a small pinned
- window or an embedded <code className="font-mono"><iframe></code>.
- The board below is laid out the way the widget will be — drag the strips
- into the order you want to read them in, split them across rows and
- columns if you're giving it the width, and drop the ones you
- don't want into the tray. Give a row the leftover height with the
- rail on its left and the sections inside it scroll under their own
- headings. Then save it as a preset, or copy the link.
- </p>
<WidgetBuilder initialPresets={presets} />
</div>
);
diff --git a/editor/app/workers/page.tsx b/editor/app/workers/page.tsx
@@ -29,12 +29,6 @@ export default async function WorkersPage() {
return (
<div className="flex flex-col gap-4">
<h1 className="text-2xl font-semibold">Workers</h1>
- <p className="text-sm text-muted-foreground">
- Transcription workers — one slot each. Enable, disable, or drain a worker
- to free up a CPU/GPU for other programs, then turn it back on when done.
- Disabling every worker pauses running batches (they wait for a worker)
- instead of failing.
- </p>
<WorkersView initial={initial} />
<WorkersConfigForm
initial={settings.workers}
diff --git a/scripts/archilyzer-ops.mjs b/scripts/archilyzer-ops.mjs
@@ -529,6 +529,8 @@ export function usage() {
' would get), "out" (an absolute path for the result JSON, never inside the',
" corpus). The result is {path, window, worker: {id, appId, model, device},",
" cues: [{start, end, text}], text, ...}, cue times on the file's own clock.",
+ ' "words": true adds words: [{w, start, end, conf?}] on the same clock -- from',
+ " parakeet, which keeps its word timestamps; [] from an engine that does not.",
" With --wait it is printed on stdout (the response and the log go to",
" stderr), so `pnpm ops transcribe ... --wait | jq -r .text` works.",
"",
diff --git a/scripts/parakeet-stitch.mjs b/scripts/parakeet-stitch.mjs
@@ -48,6 +48,9 @@
// --max-cue <sec> cap a single cue's duration (default 8)
// -o, --output <f> output file (alternative to the positional arg)
// --keep-temp keep the temp working dir (for debugging)
+// --words also write the stitched words, as a top-level
+// `words: [{w, start, end, conf}]` beside chunk_data
+// (seconds; readers of chunk_data ignore it)
// -h, --help show this help
import { execFile } from "node:child_process";
@@ -92,6 +95,7 @@ function parseArgs(argv) {
gap: 0.8,
maxCue: 8,
keepTemp: false,
+ words: false,
output: undefined,
audio: undefined,
};
@@ -118,6 +122,7 @@ function parseArgs(argv) {
case "--max-cue": opts.maxCue = Number(next()); break;
case "-o": case "--output": opts.output = next(); break;
case "--keep-temp": opts.keepTemp = true; break;
+ case "--words": opts.words = true; break;
default:
if (a.startsWith("-")) fail(`unknown option ${a}`);
positional.push(a);
@@ -136,6 +141,19 @@ function numEnv(v, dflt) {
const round3 = (n) => Math.round(n * 1000) / 1000;
+// The stitched words as --words writes them: absolute seconds, empty words
+// dropped, conf kept only when the engine gave one.
+function wordsOut(stitched) {
+ return stitched
+ .filter((w) => String(w.w ?? "").trim() !== "")
+ .map((w) => ({
+ w: String(w.w).trim(),
+ start: round3(w.start),
+ end: round3(w.end),
+ ...(typeof w.conf === "number" ? { conf: w.conf } : {}),
+ }));
+}
+
// Seconds -> m:ss (or h:mm:ss). Used for the per-video ETA in progress lines.
function formatClock(totalSeconds) {
const s = Math.max(0, Math.floor(totalSeconds));
@@ -402,6 +420,7 @@ async function main() {
chunks: chunkData.length,
text,
chunk_data: chunkData,
+ ...(opts.words ? { words: wordsOut(stitched) } : {}),
};
const json = JSON.stringify(doc);
diff --git a/umtool/report-to-video/README.md b/umtool/report-to-video/README.md
@@ -411,7 +411,9 @@ characters, trimmed; an empty value deletes the key, the way the attribution
fields do. Without it the deck's title is empty (a card falls back to
`heading`) and the subtitle is auto-built: a clip's channel · title · date (the
channel only when the cut spans more than one), an image's `title · date`, a
-card's `sub`. The QR follows a clip's corner-QR rule unchanged (`citeUrl`, else
+card's `sub`. A line too long for its column gives way in its middle parts
+(the title ends in "…"): its first part and its last (the date) always show
+whole. The QR follows a clip's corner-QR rule unchanged (`citeUrl`, else
the site link at the clip's start); an image draws one only with an explicit
`citeUrl`; a card never does.
@@ -481,8 +483,10 @@ footage, so it rides on a clip.
its day), `@handle · Bluesky` (or X), the words — paragraphs kept, clamped to
`maxLines` with an ellipsis — and a QR of the post's page on the archive
(below), or of its own `url`. A post with a `shot` draws the screenshot in
- place of the words, as wide as the card's text and no taller than a full card
- of words, or than `shotMaxHeight` px when that is set.
+ place of the words, always as wide as the card's text (so it reads), in a
+ viewport no taller than a full card of words, or than `shotMaxHeight` px when
+ that is set; a taller screenshot holds on its top for 1.2 s once the card has
+ landed, pans to its bottom, and holds there 1.2 s before the card leaves.
- **Marks.** A post's own `accent`, `logo` and `flag` set one kind of card apart
from another at a glance — a source document's sentence beside a platform post,
say: the rail and rim in the accent, the logo above the QR in the top corner,
@@ -597,6 +601,62 @@ a verdict named without a colour keeps the default's), the stamp's seconds and
corner, and whether and where the tally is drawn; `validateChrome()` refuses an
unknown key there as everywhere in the block.
+### `thread` and `render.chrome.threads` — the thread rail
+
+A cut whose clips make a few lines of argument can draw them as a rail of cards
+down the frame's left side. Each clip names its thread; the list names the
+threads in the rail's order, each with an optional outcome.
+
+```jsonc
+"render": { "chrome": { …, "threads": { "list": [
+ { "id": "bet", "label": "The bet: her career", // ≤ 32 characters, one line
+ "outcome": { "verdict": "CONTRADICTED", "label": "Walked back" } }, // label optional (≤ 24): else the verdict's
+ { "id": "aside", "label": "An aside" } ] } } } // no outcome: never stamped
+{ "type": "clip", "id": "c07", …, "thread": "bet" }
+```
+
+- **The layout.** With the rail on, the footage box moves to the frame's right
+ edge (24 px in) and the rail takes the left, as tall as the footage: a card
+ per thread (1–8), each its number, a dot per clip and its label.
+- **The motion.** The card of the thread on screen is lit and a string draws
+ from it to the picture; a clip's dot fills as the clip comes in; when the
+ thread's last clip ends (1.8 s before it hands over), its outcome is stamped
+ on its card in the verdict's colour. Before its thread plays a card is dim,
+ after it rests quieter, so by the last clip the rail is the whole argument.
+ The rail steps aside while a popup post has moved the footage over it.
+- **Checks.** `validateThreads()` (via `validateChrome()`) refuses a bad list
+ and the feed layout beside it; `validateThreadEntries()` refuses an entry
+ naming an unlisted thread, a teaser in a thread and a listed thread with no
+ clip. `schedule.json` gains `threads: { threads, runs, asides }` only when
+ the rail is on. `chrome-threads.mjs` is the page; `compose-chrome.mjs
+ --region threads` renders it; the build lays it last, like the stamps.
+
+### `render.chrome.flips` — THEN and NOW, back to back
+
+A cut built of pairs: a line from THEN and the opposite line from NOW. A panel
+down the frame's left (the rail's region: a cut has the rail or the panel)
+holds one pair while it plays.
+
+```jsonc
+"render": { "transition": 0.12, "chrome": { …, "deck": { "footageScale": 0.78 }, "flips": { "pairs": [
+ { "id": "vax", "topic": "Vaccines", // ≤ 32 characters
+ "then": { "entry": "c01", "when": "2019", "words": "…" }, // when ≤ 18, words ≤ 110: verbatim
+ "now": { "entry": "c02", "when": "2025", "words": "…" } } ] } } }
+```
+
+- **The motion.** The pair rises in with its THEN clip: its place (`03 / 11`),
+ the topic, the THEN card (tag, when, words) lit. As the NOW clip starts its
+ card slams in under it with a flash, the THEN card dims, and when both
+ `when`s carry a year the years between roll up on an odometer
+ ("+7 years later"). The pair lifts away as its NOW clip ends.
+- **Checks.** `validateFlips()` (via `validateChrome()`) refuses a bad pair
+ and the rail beside it; `validateFlipEntries()` refuses a side naming no
+ timeline entry, a teaser, a THEN after its NOW and an entry in two pairs.
+ `schedule.json` gains `flips: { pairs, asides }`. `chrome-flips.mjs` is the
+ page; `compose-chrome.mjs --region flips` renders it.
+- **Punch.** The panel is built for short clips — the line and nothing else —
+ and near-hard cuts (`transition` 0.12); a smaller `footageScale` gives it room.
+
### The `image` entry type
A still: the receipts a clip cannot say out loud — a post, a thread, a DM, a
diff --git a/umtool/report-to-video/build-video.mjs b/umtool/report-to-video/build-video.mjs
@@ -109,10 +109,12 @@ import { ensureWriteDir } from "../lib/report/storage.mjs";
import {
assertChrome, deckGeometry, deckOn, deckSchedule, endFadeOf, feedGeometry, feedOn, frameCount, hidesDeck, MUTE_FADE,
dipOf, muteSegmentSeconds, playWindow, postsGeometry, postWindows, resolveDeck, scheduleFrom, snapWindow, stampGeometry,
- teaserHits, teaserSeconds, teaserTitle,
+ teaserHits, teaserSeconds, teaserTitle, threadsGeometry,
validateCutEdits, validatePosts, validateTeasers,
} from "./deck.mjs";
import { validateClaims } from "./factcheck.mjs";
+import { validateThreadEntries } from "./threads.mjs";
+import { validateFlipEntries } from "./flips.mjs";
// The per-platform yt-dlp args (Rumble's `--impersonate chrome`): the ONE table,
// in common, plain JS so bare `node` can load it.
import { platformArgsForUrl } from "yt-dlp-transcript-common/ytdlp/platformArgs.mjs";
@@ -2298,7 +2300,7 @@ export function chromeOverlayChain(render, regions, inLabel, firstInputIdx, opts
// The posts feed (`name: "feed"`) and the fact-check stamps (`name:
// "stamp"`) are whole-cut sequences like the deck's, laid exactly as the
// deck's is.
- const deck = r.name === "deck" || r.name === "feed" || r.name === "stamp";
+ const deck = r.name === "deck" || r.name === "feed" || r.name === "stamp" || r.name === "threads" || r.name === "flips";
const posts = r.name === "posts";
inputs.push(
...(deck || posts ? ["-reinit_filter", "0"] : []),
@@ -2363,6 +2365,12 @@ export function feedRegion(render, frames) {
return { name: "feed", frames, ...feedGeometry(render).column };
}
+/** The thread rail as an overlay region: its frames at threadsGeometry's box. */
+export function threadsRegion(render, frames, name = "threads") {
+ const { cards: _c, ...box } = threadsGeometry(render);
+ return { name, frames, ...box };
+}
+
/** The fact-check stamps as an overlay region: their frames at stampGeometry's box. */
export function stampRegion(render, frames, schedule) {
return { name: "stamp", frames, ...stampGeometry(render, { feed: schedule.layout === "feed" }) };
@@ -3584,6 +3592,47 @@ async function renderDeck({ manifestPath, render, outDir, variant, schedule, fro
});
regions.push(stampRegion(render, st.frames, schedule));
}
+
+ // The thread rail (a schedule with `threads`): one sequence for the whole
+ // cut -- or the deck's window -- beside the footage, laid like the deck's.
+ if (schedule.threads?.threads?.length) {
+ EMIT("chrome", { phase: "compose", region: "threads", ...(duration != null ? { from, duration } : {}) });
+ const t1 = Date.now();
+ const th = await composeChrome({
+ manifestPath, outDir, variant, region: "threads", doRender: true,
+ fps: render.fps, workers: 2, quality: "high", format: "png-sequence",
+ ...(duration != null ? { from, duration } : {}),
+ });
+ if (th.frameCount !== want) {
+ throw new Error(`the thread rail's sequence is ${th.frameCount} frames but the deck's is ${want}`);
+ }
+ EMIT("chrome", {
+ phase: th.cached ? "cached" : "render", region: "threads",
+ frames: th.frameCount, key: th.key, dir: th.frames,
+ seconds: Number(((Date.now() - t1) / 1000).toFixed(1)),
+ });
+ regions.push(threadsRegion(render, th.frames));
+ }
+
+ // The flips panel (a schedule with `flips`): the same region as the rail.
+ if (schedule.flips?.pairs?.length) {
+ EMIT("chrome", { phase: "compose", region: "flips", ...(duration != null ? { from, duration } : {}) });
+ const t1 = Date.now();
+ const fl = await composeChrome({
+ manifestPath, outDir, variant, region: "flips", doRender: true,
+ fps: render.fps, workers: 2, quality: "high", format: "png-sequence",
+ ...(duration != null ? { from, duration } : {}),
+ });
+ if (fl.frameCount !== want) {
+ throw new Error(`the flips panel's sequence is ${fl.frameCount} frames but the deck's is ${want}`);
+ }
+ EMIT("chrome", {
+ phase: fl.cached ? "cached" : "render", region: "flips",
+ frames: fl.frameCount, key: fl.key, dir: fl.frames,
+ seconds: Number(((Date.now() - t1) / 1000).toFixed(1)),
+ });
+ regions.push(threadsRegion(render, fl.frames, "flips"));
+ }
return { regions, outLabel: "[hfout]" };
}
@@ -3882,7 +3931,10 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly
// A clip's `muteFrom` and `render.endFade`, checked against the WHOLE
// manifest, deck or not: both are made where the cut is joined.
{
- const errors = [...validateCutEdits(whole), ...validateTeasers(whole), ...validateClaims(whole)];
+ const errors = [
+ ...validateCutEdits(whole), ...validateTeasers(whole), ...validateClaims(whole), ...validateThreadEntries(whole),
+ ...validateFlipEntries(whole),
+ ];
if (errors.length) throw new Error(`manifest: ${errors.join("; ")}`);
}
const deck = deckOn(render);
diff --git a/umtool/report-to-video/chrome-deck.mjs b/umtool/report-to-video/chrome-deck.mjs
@@ -485,9 +485,15 @@ export function deckHtml(schedule, render, opts = {}) {
text-overflow: ellipsis; font-family: 'DeckSansBold', sans-serif;
font-size: ${t.titleSize}px; line-height: ${t.titleBox}px; letter-spacing: -0.012em;
color: ${pal.fg}; }
- .deck-sub { display: block; max-width: ${t.width}px; white-space: nowrap; overflow: hidden;
- text-overflow: ellipsis; font-size: ${t.subtitleSize}px; line-height: ${t.subtitleBox}px;
+ /* The source line is a row of parts: when it runs long, the middle parts
+ (the title) give way and the first (who) and last (the date) always
+ show whole; a line of one or two parts shrinks its first. */
+ .deck-sub { display: flex; max-width: ${t.width}px; white-space: nowrap; overflow: hidden;
+ font-size: ${t.subtitleSize}px; line-height: ${t.subtitleBox}px;
letter-spacing: 0.005em; color: ${pal.muted}; }
+ .deck-sub .part, .deck-sub .sep { flex: none; }
+ .deck-sub .part:not(:first-child):not(:last-child), .deck-sub .part:first-child:nth-last-child(-n+3) {
+ flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
.deck-sub .sep { color: ${pal.accent}; padding: 0 0.42em; font-family: 'DeckSansBold', sans-serif; }
.blade { position: absolute; right: 0; top: ${Math.round(t.titleBox * 0.14)}px; width: 4px;
height: ${Math.round(t.titleBox * 0.72)}px; border-radius: 2px; background: ${pal.accent};
diff --git a/umtool/report-to-video/chrome-flips.mjs b/umtool/report-to-video/chrome-flips.mjs
@@ -0,0 +1,190 @@
+// The FLIPS panel's composition: one HyperFrames page for a whole cut, the
+// frame's left side beside the footage (deck.mjs threadsGeometry -- the region
+// the thread rail would take), one pair on it at a time (flips.mjs): its
+// topic, the THEN card, the years between, the NOW card slamming in.
+//
+// PURE, like chrome-threads.mjs: a schedule and a render block in, an HTML
+// string out; compose-chrome.mjs renders it (`region: "flips"`).
+import { pageDuration, threadsGeometry } from "./deck.mjs";
+import { mix, rgba } from "./chrome-deck.mjs";
+import { flipCues } from "./flips.mjs";
+
+const esc = (s) =>
+ String(s ?? "")
+ .replace(/&/g, "&")
+ .replace(/</g, "<")
+ .replace(/>/g, ">")
+ .replace(/"/g, """);
+
+const r4 = (v) => Math.round(v * 10000) / 10000;
+
+/** THEN's colour when the palette names none (`palette.then`): a cool blue against the accent's NOW. */
+export const THEN_COLOR = "#6fa8dc";
+
+/**
+ * The panel's HTML, for the whole cut -- or a window of it (`from`/`duration`).
+ * `fonts` = `{ regular, bold }`; `gsap` the vendored script. `?still=<t>` and
+ * the preview's `deck:seek` take CUT seconds.
+ */
+export function flipsHtml(schedule, render, opts = {}) {
+ const sched = schedule.flips;
+ if (!sched?.pairs?.length) throw new Error("flips: the schedule has no pairs");
+ const geo = threadsGeometry(render);
+ const pal = render.palette;
+ const W = geo.width, H = geo.height;
+ const C = geo.cards;
+ const fonts = opts.fonts ?? {};
+ const gsapSrc = opts.gsap ?? "assets/gsap.min.js";
+ const total = schedule.total;
+ const from = Number(opts.from ?? 0);
+ const dur = opts.duration != null ? r4(Number(opts.duration)) : r4(total - from);
+ if (!(dur > 0)) throw new Error(`flips: nothing to render from ${from}s of a ${total}s cut`);
+ const windowed = from > 0 || Math.abs(dur - total) > 1e-6;
+ const { init, cues } = flipCues(sched);
+ const thenC = /^#[0-9a-fA-F]{6}$/.test(pal.then ?? "") ? pal.then : THEN_COLOR;
+ const nowC = pal.accent;
+ // Type from the panel's width: 298 px at the deck's default footage, wider with less footage.
+ const k = Math.max(0.8, Math.min(1.4, C.width / 300));
+ const S = (v) => Math.round(v * k);
+ const topicSize = S(30), idxSize = S(15), whenSize = S(50), wordsSize = S(27), tagSize = S(14), gapSize = S(22);
+ const wordsLine = Math.round(wordsSize * 1.22);
+ const gapLine = Math.round(gapSize * 1.2);
+
+ const odo = (years) =>
+ Array.from({ length: years + 1 }, (_, i) => `<span class="digit">+${i}</span>`).join("");
+ const sideHtml = (cls, tag, s, color, key) =>
+ `<div class="card ${cls}" data-k="${key}" style="--c:${color}; --cg:${rgba(color, 0.55)}; --cb:${rgba(mix(pal.bg, color, 0.16), 0.95)}">` +
+ `<div class="row"><span class="tag">${tag}</span><span class="when">${esc(s.when)}</span></div>` +
+ `<div class="words">“${esc(s.words)}”</div>`;
+
+ const pairsHtml = sched.pairs
+ .map((p, j) =>
+ `<div class="pair" data-pair="${esc(p.id)}" data-k="p${j}">` +
+ `<div class="top"><span class="idx">${String(p.index).padStart(2, "0")} / ${String(p.of).padStart(2, "0")}</span>` +
+ `<div class="topic">${esc(p.topic)}</div></div>` +
+ sideHtml("then", "THEN", p.then, thenC, `t${j}`) + `</div>` +
+ (p.years != null
+ ? `<div class="gap" data-k="g${j}"><span class="odo"><span class="strip" data-k="gc${j}">${odo(p.years)}</span></span>` +
+ `<span class="unit">${p.years === 1 ? "year" : "years"} later</span></div>`
+ : `<div class="gap blank"></div>`) +
+ sideHtml("now", "NOW", p.now, nowC, `n${j}`) + `<div class="flash" data-k="nf${j}"></div></div>` +
+ `</div>`)
+ .join("\n ");
+
+ const data = {
+ total: r4(total),
+ window: windowed ? { from: r4(from), dur } : null,
+ wordsSize,
+ ids: sched.pairs.map((p) => p.id),
+ init,
+ cues: cues.map(({ why, ...c }) => c),
+ };
+ const json = JSON.stringify(data).replace(/</g, "\\u003c");
+ const fps = schedule.fps ?? render.fps ?? 30;
+
+ return `<!doctype html>
+<html lang="en">
+ <head>
+ <meta charset="UTF-8" />
+ <meta name="viewport" content="width=${W}, height=${H}" />
+ <script src="${esc(gsapSrc)}"></script>
+ <style>
+ @font-face { font-family: 'DeckSans'; font-weight: 400; font-style: normal; src: url('${esc(fonts.regular ?? "")}'); }
+ @font-face { font-family: 'DeckSansBold'; font-weight: 400; font-style: normal; src: url('${esc(fonts.bold ?? "")}'); }
+ * { margin: 0; padding: 0; box-sizing: border-box; }
+ html, body { width: ${W}px; height: ${H}px; overflow: hidden; background: transparent; }
+ body { font-family: 'DeckSans', sans-serif; font-synthesis: none; color: ${pal.fg};
+ -webkit-font-smoothing: antialiased; text-rendering: geometricPrecision; }
+ #root { position: relative; width: ${W}px; height: ${H}px; overflow: hidden; }
+ #flips-clip, .panel { position: absolute; left: 0; top: 0; width: ${W}px; height: ${H}px; }
+ /* One pair at a time, centred in the panel's height. */
+ .pair { position: absolute; left: ${C.x}px; top: 0; width: ${C.width}px; height: ${H}px; visibility: hidden; opacity: 0;
+ display: flex; flex-direction: column; justify-content: center; gap: ${S(10)}px; }
+ .top { margin-bottom: ${S(6)}px; }
+ .idx { font-family: 'DeckSansBold', sans-serif; font-size: ${idxSize}px; letter-spacing: 0.14em; color: ${pal.muted};
+ font-variant-numeric: tabular-nums; }
+ .topic { margin-top: ${S(4)}px; font-family: 'DeckSansBold', sans-serif; font-size: ${topicSize}px;
+ line-height: ${Math.round(topicSize * 1.1)}px; letter-spacing: 0.04em; text-transform: uppercase; color: ${pal.fg};
+ display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; }
+ /* A side: its tag and when on a row, then the words. */
+ .card { position: relative; border-radius: 10px; padding: ${S(12)}px ${S(14)}px ${S(14)}px;
+ background: var(--cb); border-left: 6px solid var(--c);
+ box-shadow: inset 0 0 0 1px ${rgba(pal.fg, 0.08)}, 0 10px 26px rgba(0, 0, 0, 0.35); transform-origin: 20% 50%; }
+ .now { visibility: hidden; opacity: 0; }
+ .row { display: flex; align-items: baseline; gap: ${S(10)}px; }
+ .tag { font-family: 'DeckSansBold', sans-serif; font-size: ${tagSize}px; line-height: ${tagSize + 8}px; letter-spacing: 0.14em;
+ padding: 0 ${S(8)}px; border-radius: 4px; color: ${pal.bg}; background: var(--c); }
+ .when { font-family: 'DeckSansBold', sans-serif; font-size: ${whenSize}px; line-height: ${Math.round(whenSize * 1.05)}px;
+ color: var(--c); font-variant-numeric: tabular-nums; letter-spacing: -0.01em; }
+ .words { margin-top: ${S(6)}px; font-family: 'DeckSansBold', sans-serif; font-size: ${wordsSize}px; line-height: ${wordsLine}px;
+ color: ${pal.fg}; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 5; overflow: hidden; }
+ .flash { position: absolute; inset: -4px; border-radius: 12px; opacity: 0; pointer-events: none;
+ box-shadow: 0 0 0 3px ${nowC}, 0 0 34px 10px ${rgba(nowC, 0.6)}; }
+ /* The years between: an odometer, then the unit. */
+ .gap { display: flex; align-items: center; justify-content: center; gap: ${S(8)}px; height: ${gapLine + S(6)}px;
+ visibility: hidden; opacity: 0; }
+ .gap.blank { visibility: hidden; }
+ .odo { display: inline-block; height: ${gapLine}px; overflow: hidden; }
+ .strip { display: flex; flex-direction: column; }
+ .digit { display: block; height: ${gapLine}px; font-family: 'DeckSansBold', sans-serif; font-size: ${gapSize}px;
+ line-height: ${gapLine}px; color: ${nowC}; font-variant-numeric: tabular-nums; text-align: right; }
+ .unit { font-size: ${gapSize}px; line-height: ${gapLine}px; color: ${pal.muted}; letter-spacing: 0.02em; }
+ </style>
+ </head>
+ <body>
+ <div id="root" data-composition-id="flips" data-start="0" data-duration="${pageDuration(dur, fps)}"
+ data-width="${W}" data-height="${H}">
+ <div id="flips-clip" class="clip" data-start="0" data-duration="${pageDuration(dur, fps)}" data-track-index="1">
+ <div class="panel" data-k="panel">
+ ${pairsHtml}
+ </div>
+ </div>
+ </div>
+
+ <script id="flips-data" type="application/json">${json}</script>
+ <script>
+ const S = JSON.parse(document.getElementById("flips-data").textContent);
+ const byK = {};
+ for (const el of document.querySelectorAll("[data-k]")) byK[el.dataset.k] = el;
+ for (const k of Object.keys(S.init)) if (byK[k]) gsap.set(byK[k], S.init[k]);
+ const inner = gsap.timeline({ paused: true });
+ for (const c of S.cues) {
+ const el = byK[c.k];
+ if (!el) continue;
+ inner.fromTo(el, c.from, { ...c.to, duration: c.dur, ease: c.ease, immediateRender: false }, c.at);
+ }
+ inner.set({}, {}, S.total);
+ let tl = inner;
+ if (S.window) {
+ tl = gsap.timeline({ paused: true });
+ tl.add(inner.tweenFromTo(S.window.from, S.window.from + S.window.dur, { duration: S.window.dur, ease: "none" }), 0);
+ }
+ window.__timelines = window.__timelines || {};
+ window.__timelines["flips"] = tl;
+ const ready = document.fonts.load(S.wordsSize + "px DeckSansBold").catch(() => {}).then(() => {
+ document.documentElement.dataset.fit = "1";
+ });
+ const params = new URLSearchParams(location.search);
+ const local = (t) => {
+ const v = Number(t) || 0;
+ return S.window ? Math.max(0, Math.min(S.window.dur, v - S.window.from)) : Math.max(0, v);
+ };
+ const still = params.get("still");
+ if (still !== null) {
+ tl.seek(local(still), false);
+ ready.then(() => tl.seek(local(still), false));
+ }
+ if (params.get("preview") === "1") {
+ window.addEventListener("message", (e) => {
+ const m = e.data || {};
+ if (m.type === "deck:seek") tl.seek(local(m.t), false);
+ });
+ ready.then(() => {
+ if (window.parent !== window) window.parent.postMessage({ type: "flips:ready", total: S.total, ids: S.ids }, "*");
+ });
+ }
+ </script>
+ </body>
+</html>
+`;
+}
diff --git a/umtool/report-to-video/chrome-posts.mjs b/umtool/report-to-video/chrome-posts.mjs
@@ -93,12 +93,19 @@ export function embedFn(name, fn) {
* every card still up leaves over its `out` (a front-loaded fade and a slight
* shrink).
*
+ * `pans` are, per card, how many px its screenshot runs past its viewport (0:
+ * it fits). A tall screenshot is drawn at the card's full width -- readable --
+ * in a viewport no taller than the column allows, and pans up through the
+ * rest (`s<j>`, its y): it holds `panHold` s on its top once the card has
+ * landed, glides to its bottom, and holds there `panHold` s before it leaves.
+ * With too little time for the holds, the glide takes all of it.
+ *
* @returns {{ tops: number[], init: Record<string, object>,
* cues: Array<{ k: string, at: number, dur: number, from: object, to: object, ease: string, why: string }> }}
*/
export function postsCues({
posts, heights, column, gap = 14, enter = 0.55, slide = 0.35, enterX = 624,
- glowAt = 0.3, glowUp = 0.18, glowDown = 1.1, glowRest = 0.3,
+ glowAt = 0.3, glowUp = 0.18, glowDown = 1.1, glowRest = 0.3, pans = [], panHold = 1.2,
}) {
const R = (v) => Math.round(v * 10000) / 10000;
const MIN = 0.001;
@@ -112,6 +119,7 @@ export function postsCues({
for (let j = 0; j < posts.length; j += 1) {
init[`c${j}`] = { autoAlpha: 0, x: enterX, scale: 1 };
init[`g${j}`] = { opacity: 0 };
+ if (pans[j] > 0) init[`s${j}`] = { y: 0 };
}
const ev = [];
@@ -135,6 +143,14 @@ export function postsCues({
// The flare, as the card lands; then it settles to a quiet rim.
add(`g${j}`, t + glowAt, glowUp, { opacity: 1 }, "power2.out", `glow ${p.id}`);
add(`g${j}`, t + glowAt + glowUp, glowDown, { opacity: glowRest }, "power2.inOut", `settle ${p.id}`);
+ if (pans[j] > 0) {
+ const landed = t + enter;
+ const leave = p.out[0];
+ let a = landed + panHold;
+ let b = leave - panHold;
+ if (b - a < 1) { a = landed; b = Math.max(landed + MIN, leave); }
+ add(`s${j}`, a, b - a, { y: -pans[j] }, "sine.inOut", `pan ${p.id}`);
+ }
visible.push(j);
}
for (const j of visible) {
@@ -252,7 +268,7 @@ export function postsHtml(schedule, render, window, opts = {}) {
return (
`<article class="post${shot ? " has-shot" : ""}${logo ? " has-logo" : ""}" data-post="${esc(p.id)}" data-k="c${j}"${style}>` +
(shot
- ? `<div class="body shot-body">${flag}<img class="shot" src="${esc(shot)}" alt=""></div>`
+ ? `<div class="body shot-body">${flag}<div class="shot-view"><img class="shot" data-k="s${j}" src="${esc(shot)}" alt=""></div></div>`
: `<div class="body">${flag}` +
`<div class="meta">` +
(platform ? `<span class="platform">${esc(platform)}</span>` : "") +
@@ -355,11 +371,14 @@ export function postsHtml(schedule, render, window, opts = {}) {
display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: ${set.maxLines}; }
.para + .para { margin-top: ${Math.round(lineH * 0.36)}px; }
.para.gone { display: none; }
- /* A post's screenshot in place of its words: as wide as the words would
- be, no taller than a full card of them -- or than \`shotMaxHeight\`. */
+ /* A post's screenshot in place of its words: always as wide as the words
+ would be, so it reads; its viewport is no taller than a full card of
+ them -- or than \`shotMaxHeight\` -- and a taller one pans up through
+ it (postsCues \`pans\`). */
.shot-body { padding: ${pad - 6}px; }
- .shot { display: block; width: 100%; height: auto; max-height: ${set.shotMaxHeight ?? Math.round(metaSize * 1.3) + 8 + set.maxLines * lineH + 2 * pad}px;
- object-fit: contain; object-position: left top; border-radius: 6px; }
+ .shot-view { position: relative; overflow: hidden; border-radius: 6px;
+ max-height: ${set.shotMaxHeight ?? Math.round(metaSize * 1.3) + 8 + set.maxLines * lineH + 2 * pad}px; }
+ .shot { display: block; width: 100%; height: auto; }
/* The source cell: the QR in a cell a shade down, as on the deck. */
.plate { position: absolute; right: 0; top: 0; bottom: 0; width: ${plateW}px;
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px;
@@ -428,7 +447,14 @@ export function postsHtml(schedule, render, window, opts = {}) {
const cards = P.ids.map((_, j) => byK["c" + j]);
cards.forEach(clampText);
const heights = cards.map((c) => c.offsetHeight);
- const plan = postsCues({ posts: P.posts, heights, column: P.column, gap: P.gap,
+ // How far each screenshot runs past its viewport: that much to pan.
+ const pans = cards.map((c) => {
+ const view = c.querySelector(".shot-view");
+ const img = view && view.querySelector("img.shot");
+ return img ? Math.max(0, Math.round(img.offsetHeight - view.clientHeight)) : 0;
+ });
+ document.documentElement.dataset.pans = pans.join(",");
+ const plan = postsCues({ posts: P.posts, heights, pans, column: P.column, gap: P.gap,
enter: P.enter, slide: P.slide, enterX: P.enterX, glowAt: P.glowAt,
glowUp: P.glowUp, glowDown: P.glowDown, glowRest: P.glowRest });
cards.forEach((c, j) => { c.style.top = plan.tops[j] + "px"; });
diff --git a/umtool/report-to-video/chrome-posts.test.mjs b/umtool/report-to-video/chrome-posts.test.mjs
@@ -467,7 +467,7 @@ test("a post with a screenshot draws it in place of its text card, its QR cell k
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.match(card, /<div class="shot-view"><img class="shot" data-k="s\d+" src="assets\/shot00\.png" alt=""><\/div>/);
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.
@@ -495,7 +495,7 @@ test("a post's accent, logo and flag mark its card; a post without them is drawn
const card = html.slice(open - 60, html.indexOf("<article", open + 1));
assert.match(card, /class="post has-shot has-logo"/);
assert.match(card, /style="--acc: #c98fd6; --acc-rim: rgba\(201, ?143, ?214, ?0\.95\)/);
- assert.ok(card.includes('<div class="flag">No source in the article</div><img class="shot"'), "the flag heads the picture");
+ assert.ok(card.includes('<div class="flag">No source in the article</div><div class="shot-view"><img class="shot"'), "the flag heads the picture");
assert.ok(card.includes('<img class="logo" src="assets/logo00.png" alt=""><img src="assets/qr00.png"'), "logo above the QR");
const b = html.slice(html.indexOf('data-post="b"') - 60);
assert.doesNotMatch(b.slice(0, b.indexOf("</article>")), /--acc|class="flag"|class="logo"/);
@@ -508,11 +508,43 @@ test("a post's accent, logo and flag mark its card; a post without them is drawn
assert.match(bad, /flag must be a short label/);
});
-test("shotMaxHeight caps a screenshot in px; unset, a full card of words does", () => {
+test("shotMaxHeight caps a screenshot's viewport in px; unset, a full card of words does", () => {
const sched = schedule([POST("a", "2024-10-19T17:01:17.640Z", { shot: "shots/a.png" })]);
const win = snapWindow(postWindows(sched)[0], { fps: 30, total: sched.total });
- const cap = (render) => /\.shot \{[^}]*max-height: (\d+)px/.exec(postsHtml(sched, render, win, { fonts: FONTS, shotSrcs: { a: "assets/shot00.png" } }))[1];
+ const cap = (render) => /\.shot-view \{[^}]*max-height: (\d+)px/.exec(postsHtml(sched, render, win, { fonts: FONTS, shotSrcs: { a: "assets/shot00.png" } }))[1];
const tall = { ...RENDER, chrome: { ...RENDER.chrome, deck: { ...RENDER.chrome.deck, posts: { ...RENDER.chrome.deck?.posts, shotMaxHeight: 820 } } } };
assert.equal(cap(tall), "820");
assert.notEqual(cap(RENDER), "820");
});
+
+test("postsCues: a tall screenshot holds on its top, pans to its bottom, and holds before it leaves", () => {
+ const posts = [{ id: "p0", appear: 10, out: [30, 30.5] }, { id: "p1", appear: 12, out: [30, 30.5] }];
+ const plan = postsCues({ posts, heights: [800, 200], pans: [600, 0], column: 2000, enter: 0.5, panHold: 1.2 });
+ assert.deepEqual(plan.init.s0, { y: 0 });
+ assert.equal(plan.init.s1, undefined, "a screenshot that fits does not pan");
+ const pan = plan.cues.filter((c) => c.k === "s0");
+ assert.equal(pan.length, 1);
+ near(pan[0].at, 10 + 0.5 + 1.2, "after the card lands and a hold on its top");
+ near(pan[0].at + pan[0].dur, 30 - 1.2, "a hold on its bottom before it leaves");
+ assert.deepEqual([pan[0].from, pan[0].to], [{ y: 0 }, { y: -600 }]);
+ // Too little time for the holds: the glide takes all of it.
+ const tight = postsCues({ posts: [{ id: "q", appear: 0, out: [2, 2.5] }], heights: [800], pans: [300], column: 2000, enter: 0.5 });
+ const g = tight.cues.find((c) => c.k === "s0");
+ near(g.at, 0.5, "from the landing");
+ near(g.at + g.dur, 2, "to the leave");
+});
+
+test("postsHtml: a screenshot sits in a viewport at the card's width, and the page measures its pan", () => {
+ const html = postsHtml(
+ { fps: 30, total: 40, segments: [{ id: "c1", start: 0, duration: 40 }], posts: [
+ { id: "x1", segment: "c1", slot: 0, appear: 2, out: [30, 30.5], platform: "x", handle: "a", date: "2026-01-01", text: "t", shot: "s.png" },
+ ] },
+ { ...RENDER, chrome: { ...RENDER.chrome, deck: { posts: { shotMaxHeight: 800 } } } },
+ { segment: "c1", from: 0, to: 31 },
+ { shotSrcs: { x1: "assets/shot0.png" } },
+ );
+ assert.match(html, /<div class="shot-view"><img class="shot" data-k="s0" src="assets\/shot0.png"/);
+ assert.match(html, /\.shot \{ display: block; width: 100%; height: auto; \}/);
+ assert.doesNotMatch(html, /\.shot \{[^}]*object-fit/, "never shrunk to fit");
+ assert.match(html, /const pans = cards\.map/);
+});
diff --git a/umtool/report-to-video/chrome-threads.mjs b/umtool/report-to-video/chrome-threads.mjs
@@ -0,0 +1,218 @@
+// The THREAD RAIL's composition: one HyperFrames page for a whole cut, the
+// frame's left side beside the footage (deck.mjs threadsGeometry), a card per
+// thread (threads.mjs). The card of the thread on screen is lit and strung
+// across to the picture; its dots fill clip by clip; its outcome is stamped on
+// it as its last clip ends. The rail steps aside while a popup post has moved
+// the footage over it.
+//
+// PURE, like chrome-stamp.mjs: a schedule and a render block in, an HTML
+// string out. compose-chrome.mjs copies the assets in beside it, writes it and
+// renders it (`region: "threads"`); the build lays the frames over the cut as
+// it lays the deck's.
+//
+// The timeline is threads.mjs `threadCues`, each cue stating its from -- a
+// render is a seek per frame, from parallel workers, in any order.
+import { pageDuration, threadsGeometry } from "./deck.mjs";
+import { mix, rgba } from "./chrome-deck.mjs";
+import { threadCues } from "./threads.mjs";
+
+const esc = (s) =>
+ String(s ?? "")
+ .replace(/&/g, "&")
+ .replace(/</g, "<")
+ .replace(/>/g, ">")
+ .replace(/"/g, """);
+
+const r4 = (v) => Math.round(v * 10000) / 10000;
+
+/**
+ * The cards' sizes in the rail's height for `n` threads: each card `h` px tall
+ * (at most `max`), `gap` apart, the stack centred (`top`).
+ */
+export function railLayout(height, n, { gap = 14, max = 190 } = {}) {
+ const h = Math.min(max, Math.floor((height - (n - 1) * gap) / n));
+ const total = n * h + (n - 1) * gap;
+ return { h, gap, top: Math.max(0, Math.floor((height - total) / 2)) };
+}
+
+/**
+ * The rail's HTML, for the whole cut -- or a window of it (`from`/`duration`,
+ * as the deck's). `fonts` = `{ regular, bold }` asset-relative paths
+ * (DeckSans / DeckSansBold); `gsap` the vendored script. `?still=<t>` and the
+ * preview's `deck:seek` take CUT seconds.
+ */
+export function threadsHtml(schedule, render, opts = {}) {
+ const sched = schedule.threads;
+ if (!sched?.threads?.length) throw new Error("threads: the schedule has no thread rail");
+ const geo = threadsGeometry(render);
+ const pal = render.palette;
+ const W = geo.width, H = geo.height;
+ const C = geo.cards;
+ const fonts = opts.fonts ?? {};
+ const gsapSrc = opts.gsap ?? "assets/gsap.min.js";
+ const total = schedule.total;
+ const from = Number(opts.from ?? 0);
+ const dur = opts.duration != null ? r4(Number(opts.duration)) : r4(total - from);
+ if (!(dur > 0)) throw new Error(`threads: nothing to render from ${from}s of a ${total}s cut`);
+ const windowed = from > 0 || Math.abs(dur - total) > 1e-6;
+ const { init, cues } = threadCues(sched);
+ const n = sched.threads.length;
+ const lay = railLayout(C.height, n);
+ // Type scales with the card: a rail of eight is denser than a rail of three.
+ const k = Math.min(1, lay.h / 170);
+ const idxSize = Math.round(17 * Math.max(0.8, k));
+ const labelSize = Math.round(27 * Math.max(0.72, k));
+ const labelLine = Math.round(labelSize * 1.12);
+ const outSize = Math.round(16 * Math.max(0.8, k));
+ const dot = Math.round(11 * Math.max(0.8, k));
+ const pad = Math.round(16 * Math.max(0.75, k));
+ const rail = 5;
+
+ const cardsHtml = sched.threads
+ .map((t, j) => {
+ const top = lay.top + j * (lay.h + lay.gap);
+ const oc = t.outcome?.color ?? pal.accent;
+ const dots = t.clips.map((_, d) => `<i class="dot" data-k="d${j}-${d}"></i>`).join("");
+ return (
+ `<div class="card" data-thread="${esc(t.id)}" data-k="c${j}" style="top:${top}px; --oc:${esc(oc)}; ` +
+ `--og:${rgba(oc, 0.6)}; --ob:${rgba(mix(pal.bg, oc, 0.14), 0.92)}">` +
+ `<div class="wash" data-k="w${j}"></div>` +
+ `<div class="head"><span class="idx">${String(j + 1).padStart(2, "0")}</span><span class="dots">${dots}</span></div>` +
+ `<div class="label">${esc(t.label)}</div>` +
+ (t.outcome
+ ? `<div class="outcome" data-k="o${j}"><span class="word">${esc(t.outcome.label)}</span>` +
+ `<span class="oflash" data-k="f${j}"></span></div>`
+ : "") +
+ `</div>` +
+ `<div class="string" data-k="s${j}" style="top:${top + Math.round(lay.h / 2) - 1}px"></div>`
+ );
+ })
+ .join("\n ");
+
+ const data = {
+ total: r4(total),
+ window: windowed ? { from: r4(from), dur } : null,
+ labelSize,
+ ids: sched.threads.map((t) => t.id),
+ init,
+ cues: cues.map(({ why, ...c }) => c),
+ };
+ const json = JSON.stringify(data).replace(/</g, "\\u003c");
+ const fps = schedule.fps ?? render.fps ?? 30;
+ const cardBg = mix(mix(pal.bg, pal.fg, 0.07), pal.accent, 0.05);
+ const litBg = mix(mix(pal.bg, pal.fg, 0.12), pal.accent, 0.14);
+
+ return `<!doctype html>
+<html lang="en">
+ <head>
+ <meta charset="UTF-8" />
+ <meta name="viewport" content="width=${W}, height=${H}" />
+ <script src="${esc(gsapSrc)}"></script>
+ <style>
+ /* The deck's faces, under the deck's private names -- see docs/quirks.md. */
+ @font-face { font-family: 'DeckSans'; font-weight: 400; font-style: normal;
+ src: url('${esc(fonts.regular ?? "")}'); }
+ @font-face { font-family: 'DeckSansBold'; font-weight: 400; font-style: normal;
+ src: url('${esc(fonts.bold ?? "")}'); }
+ * { margin: 0; padding: 0; box-sizing: border-box; }
+ html, body { width: ${W}px; height: ${H}px; overflow: hidden; background: transparent; }
+ body { font-family: 'DeckSans', sans-serif; font-synthesis: none; color: ${pal.fg};
+ -webkit-font-smoothing: antialiased; text-rendering: geometricPrecision; }
+ #root { position: relative; width: ${W}px; height: ${H}px; overflow: hidden; }
+ #threads-clip { position: absolute; left: 0; top: 0; width: ${W}px; height: ${H}px; }
+ .rail { position: absolute; left: 0; top: 0; width: ${W}px; height: ${H}px; }
+ /* A card: its number, a dot per clip, its label; the outcome stamped at
+ its foot. Ahead of its thread it is dim; lit while it plays; after,
+ it keeps its outcome at a quieter rest. */
+ .card { position: absolute; left: ${C.x}px; width: ${C.width}px; height: ${lay.h}px; opacity: 0;
+ border-radius: 10px; overflow: hidden; background: ${cardBg};
+ border-left: ${rail}px solid ${pal.accent};
+ box-shadow: inset 0 0 0 1px ${rgba(pal.fg, 0.1)}; padding: ${pad - 2}px ${pad}px ${pad}px ${pad}px; }
+ .wash { position: absolute; inset: 0; opacity: 0; pointer-events: none; background: ${litBg};
+ box-shadow: inset 0 0 0 2px ${rgba(pal.accent, 0.9)}, inset 0 0 26px ${rgba(pal.accent, 0.35)}; }
+ .head { position: relative; display: flex; align-items: center; justify-content: space-between; gap: 8px;
+ height: ${idxSize + 8}px; }
+ .idx { font-family: 'DeckSansBold', sans-serif; font-size: ${idxSize}px; line-height: ${idxSize + 8}px;
+ letter-spacing: 0.12em; color: ${pal.accent}; font-variant-numeric: tabular-nums; }
+ .dots { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: ${Math.round(dot * 0.55)}px; }
+ .dot { display: block; width: ${dot}px; height: ${dot}px; border-radius: 50%; background: ${pal.fg};
+ box-shadow: 0 0 8px ${rgba(pal.fg, 0.45)}; }
+ .label { position: relative; margin-top: ${Math.round(pad * 0.45)}px; font-family: 'DeckSansBold', sans-serif;
+ font-size: ${labelSize}px; line-height: ${labelLine}px; letter-spacing: -0.005em; color: ${pal.fg};
+ display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; }
+ /* The outcome: a small rubber stamp in its verdict's colour. */
+ .outcome { position: absolute; left: ${pad}px; bottom: ${pad - 4}px; visibility: hidden; opacity: 0;
+ max-width: ${C.width - 2 * pad - rail}px; padding: 3px 10px 4px; border-radius: 5px;
+ background: var(--ob); border: 2px solid var(--oc); transform-origin: 30% 50%; rotate: -4deg; }
+ .outcome .word { display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
+ font-family: 'DeckSansBold', sans-serif; font-size: ${outSize}px; line-height: ${outSize + 6}px;
+ letter-spacing: 0.08em; text-transform: uppercase; color: var(--oc); }
+ .oflash { position: absolute; inset: -3px; border-radius: 6px; opacity: 0; pointer-events: none;
+ box-shadow: 0 0 0 2px var(--oc), 0 0 22px 6px var(--og); }
+ /* The string: from the lit card across to the picture's edge. */
+ .string { position: absolute; left: ${C.x + C.width}px; width: ${W - C.x - C.width}px; height: 3px;
+ border-radius: 2px; transform-origin: 0% 50%; transform: scaleX(0);
+ background: linear-gradient(90deg, ${pal.accent} 0%, ${rgba(pal.accent, 0.35)} 100%);
+ box-shadow: 0 0 10px ${rgba(pal.accent, 0.7)}; }
+ </style>
+ </head>
+ <body>
+ <div id="root" data-composition-id="threads" data-start="0" data-duration="${pageDuration(dur, fps)}"
+ data-width="${W}" data-height="${H}">
+ <div id="threads-clip" class="clip" data-start="0" data-duration="${pageDuration(dur, fps)}" data-track-index="1">
+ <div class="rail" data-k="rail">
+ ${cardsHtml}
+ </div>
+ </div>
+ </div>
+
+ <script id="threads-data" type="application/json">${json}</script>
+ <script>
+ const S = JSON.parse(document.getElementById("threads-data").textContent);
+ const byK = {};
+ for (const el of document.querySelectorAll("[data-k]")) byK[el.dataset.k] = el;
+
+ for (const k of Object.keys(S.init)) if (byK[k]) gsap.set(byK[k], S.init[k]);
+ const inner = gsap.timeline({ paused: true });
+ for (const c of S.cues) {
+ const el = byK[c.k];
+ if (!el) continue;
+ inner.fromTo(el, c.from, { ...c.to, duration: c.dur, ease: c.ease, immediateRender: false }, c.at);
+ }
+ inner.set({}, {}, S.total);
+ let tl = inner;
+ if (S.window) {
+ tl = gsap.timeline({ paused: true });
+ tl.add(inner.tweenFromTo(S.window.from, S.window.from + S.window.dur, { duration: S.window.dur, ease: "none" }), 0);
+ }
+ window.__timelines = window.__timelines || {};
+ window.__timelines["threads"] = tl;
+
+ const ready = document.fonts.load(S.labelSize + "px DeckSansBold").catch(() => {}).then(() => {
+ document.documentElement.dataset.fit = "1";
+ });
+
+ const params = new URLSearchParams(location.search);
+ const local = (t) => {
+ const v = Number(t) || 0;
+ return S.window ? Math.max(0, Math.min(S.window.dur, v - S.window.from)) : Math.max(0, v);
+ };
+ const still = params.get("still");
+ if (still !== null) {
+ tl.seek(local(still), false);
+ ready.then(() => tl.seek(local(still), false));
+ }
+ if (params.get("preview") === "1") {
+ window.addEventListener("message", (e) => {
+ const m = e.data || {};
+ if (m.type === "deck:seek") tl.seek(local(m.t), false);
+ });
+ ready.then(() => {
+ if (window.parent !== window) window.parent.postMessage({ type: "threads:ready", total: S.total, ids: S.ids }, "*");
+ });
+ }
+ </script>
+ </body>
+</html>
+`;
+}
diff --git a/umtool/report-to-video/compose-chrome.mjs b/umtool/report-to-video/compose-chrome.mjs
@@ -47,6 +47,8 @@ import { deckHtml, GSAP_FILE } from "./chrome-deck.mjs";
import { postsHtml, snapWindow, windowPosts } from "./chrome-posts.mjs";
import { feedHtml } from "./chrome-feed.mjs";
import { stampHtml } from "./chrome-stamp.mjs";
+import { threadsHtml } from "./chrome-threads.mjs";
+import { flipsHtml } from "./chrome-flips.mjs";
const run = promisify(execFile);
@@ -692,6 +694,16 @@ async function regionHtml(region, { manifest, manifestDir, base, projDir, assets
const fonts = await copyFonts(manifest.render, assetsDir, { regular: "DeckSans", bold: "DeckSansBold" }, { strict: true });
return stampHtml(schedule, manifest.render, { fonts, from, duration });
}
+ if (region === "threads") {
+ // The thread rail (chrome-threads.mjs): the deck's faces, no QR.
+ const fonts = await copyFonts(manifest.render, assetsDir, { regular: "DeckSans", bold: "DeckSansBold" }, { strict: true });
+ return threadsHtml(schedule, manifest.render, { fonts, from, duration });
+ }
+ if (region === "flips") {
+ // The flips panel (chrome-flips.mjs): the deck's faces, no QR.
+ const fonts = await copyFonts(manifest.render, assetsDir, { regular: "DeckSans", bold: "DeckSansBold" }, { strict: true });
+ return flipsHtml(schedule, manifest.render, { fonts, from, duration });
+ }
if (region === "teaser") {
// A page module reached by a dynamic import, so nothing that imports this
// file -- umtool's preview helper, the build -- loads its face's URL
@@ -722,9 +734,21 @@ async function framesOnDisk(dir) {
}
/** Run the renderer, its chatter to stderr (stdout may be a build's NDJSON). */
+/**
+ * The renderer's environment: ours without DISPLAY and WAYLAND_DISPLAY. It is
+ * headless and needs no display, and a stale one breaks it: with DISPLAY naming
+ * an X server that has gone (Xwayland killed by the OOM killer, say), ANGLE's
+ * SwiftShader tries to connect to it, fails, and every render dies with
+ * "assertSwiftShader ... vendor=''".
+ */
+export function rendererEnv(env = process.env) {
+ const { DISPLAY: _d, WAYLAND_DISPLAY: _w, ...rest } = env;
+ return rest;
+}
+
function runRenderer(cmd, args) {
return new Promise((resolve, reject) => {
- const child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
+ const child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"], env: rendererEnv() });
let tail = "";
const keep = (b) => {
process.stderr.write(b);
@@ -772,6 +796,10 @@ function runRenderer(cmd, args) {
* `chrome/stamp-frames/` and their `.key`, a window `stamp-from<s>[-frames]`,
* cached as the deck's are.
*
+ * Threads (`region: "threads"`, a schedule with `threads`): the thread rail
+ * for the whole cut, exactly as the stamps -- project `chrome/threads/`,
+ * frames `chrome/threads-frames/`, windowed and cached alike.
+ *
* Teaser (`region: "teaser"`, `segment` = the entry's id): the whole frame,
* `seconds` long, drawn from the entry alone (no schedule):
* - project `chrome/teaser-<id>/` (`teaser-preview-<id>/` when `preview`),
@@ -809,9 +837,10 @@ export async function composeChrome({
// The regions keyed by the render cache: the two drawn from the deck's
// schedule, and a teaser, drawn from its own timeline entry.
- const keyed = region === "deck" || region === "feed" || region === "posts" || region === "teaser" || region === "stamp";
+ const keyed = region === "deck" || region === "feed" || region === "posts" || region === "teaser" || region === "stamp" ||
+ region === "threads" || region === "flips";
// The regions drawn over the whole cut from its schedule, windowable alike.
- const wholeCut = region === "deck" || region === "feed" || region === "stamp";
+ const wholeCut = region === "deck" || region === "feed" || region === "stamp" || region === "threads" || region === "flips";
let teaser = null;
if (region === "teaser") {
teaser = (manifest.timeline ?? []).find((e) => e.id === segment && e.type === "teaser") ?? null;
@@ -832,6 +861,12 @@ export async function composeChrome({
if (region === "feed" && sched.layout !== "feed") {
throw new Error("the feed region needs a feed's schedule (layout \"feed\": posts.layout \"feed\" and posts to draw)");
}
+ if (region === "flips" && !sched.flips?.pairs?.length) {
+ throw new Error("the flips region needs a schedule with flip pairs (render.chrome.flips)");
+ }
+ if (region === "threads" && !sched.threads?.threads?.length) {
+ throw new Error("the threads region needs a schedule with a thread rail (render.chrome.threads)");
+ }
if (region === "stamp" && !sched.factcheck?.stamps?.length) {
throw new Error("the stamp region needs a schedule that stamps a claim (an entry with a `claim`)");
}
@@ -905,7 +940,7 @@ export async function composeChrome({
"--virtual-time-budget=6000",
`--screenshot=${out}`,
`file://${path.join(projDir, "index.html")}?still=${Number(still)}`,
- ], { maxBuffer: 1 << 26 });
+ ], { maxBuffer: 1 << 26, env: rendererEnv() });
return { ...result, still: out };
}
@@ -960,7 +995,7 @@ if (import.meta.url === `file://${process.argv[1]}`) {
const manifestPath = argv.find((a, i) => !a.startsWith("--") && !VALUED.has(argv[i - 1]));
if (!manifestPath) {
console.error(
- "usage: compose-chrome.mjs <manifest.json> [--region chart|deck|feed|stamp|posts|teaser] [--variant sourced|full]\n" +
+ "usage: compose-chrome.mjs <manifest.json> [--region chart|deck|feed|stamp|threads|flips|posts|teaser] [--variant sourced|full]\n" +
" [--segment <id>] (posts: the clip whose window to compose; teaser: its entry)\n" +
" [--from <s>] [--duration <s>] [--out <dir>] [--preview]\n" +
" [--still <s> --png <path>]\n" +
@@ -983,7 +1018,7 @@ if (import.meta.url === `file://${process.argv[1]}`) {
still: num("--still"),
png: flag("--png"),
// The deck renders four-wide by default; the band keeps the renderer's own default.
- workers: num("--workers") ?? (region === "deck" || region === "feed" || region === "teaser" ? 4 : region === "posts" || region === "stamp" ? 2 : null),
+ workers: num("--workers") ?? (region === "deck" || region === "feed" || region === "teaser" ? 4 : region === "posts" || region === "stamp" || region === "threads" || region === "flips" ? 2 : null),
quality: flag("--quality") ?? "high",
format: flag("--format") ?? "png-sequence",
fps: num("--fps"),
diff --git a/umtool/report-to-video/deck.mjs b/umtool/report-to-video/deck.mjs
@@ -20,6 +20,11 @@ import { attributionParts, deckSubtitle } from "./attribution.mjs";
import {
claimOf, originalUrlAt, resolveFactcheck, roundStamps, stampSchedule, validateFactcheck,
} from "./factcheck.mjs";
+import { threadOf, threadSchedule, threadsOn, validateThreads } from "./threads.mjs";
+import { flipSchedule, flipsOn, validateFlips } from "./flips.mjs";
+
+/** Is the left panel on: the thread rail (threads.mjs) or the flips panel (flips.mjs) -- one region, one or the other. */
+export const railOn = (render) => threadsOn(render) || flipsOn(render);
/** The renderer version, pinned. It is part of the cache key: a new renderer is new frames. */
export const HYPERFRAMES_PKG_DEFAULT = "hyperframes@0.8.24";
@@ -132,7 +137,7 @@ export function validateChrome(chrome, render = {}) {
const errors = [];
if (chrome === undefined || chrome === null) return errors;
if (!isObj(chrome)) return ["render.chrome must be an object"];
- unknownKeys(chrome, ["engine", "layout", "deck", "factcheck"], "render.chrome", errors);
+ unknownKeys(chrome, ["engine", "layout", "deck", "factcheck", "threads", "flips"], "render.chrome", errors);
if (chrome.engine !== "hyperframes") errors.push('render.chrome.engine must be "hyperframes"');
if (chrome.layout !== "deck") errors.push('render.chrome.layout must be "deck"');
if (render.rail) errors.push("render.chrome (the deck) and render.rail cannot both be set");
@@ -144,6 +149,11 @@ export function validateChrome(chrome, render = {}) {
errors.push(...validateEndFade(render));
// The fact-check stamps and tally (factcheck.mjs): drawn only by the deck.
errors.push(...validateFactcheck(chrome.factcheck));
+ // The thread rail (threads.mjs): the deck's layout only, beside the footage.
+ errors.push(...validateThreads(chrome.threads));
+ // The flips panel (flips.mjs): the same region as the rail, so not both.
+ errors.push(...validateFlips(chrome.flips));
+ if (threadsOn({ chrome }) && flipsOn({ chrome })) errors.push("render.chrome.threads and render.chrome.flips share the left panel: set one");
const d = chrome.deck ?? {};
if (!isObj(d)) return [...errors, "render.chrome.deck must be an object"];
const w = "render.chrome.deck";
@@ -266,6 +276,14 @@ export function validateChrome(chrome, render = {}) {
// sees the posts.
if (d.posts !== undefined && resolveDeck({ chrome }).posts.show) errors.push(...postsFitErrors({ ...render, chrome }));
const room = g.H - g.deck.height;
+ if (railOn({ chrome })) {
+ const which = threadsOn({ chrome }) ? "render.chrome.threads" : "render.chrome.flips";
+ if (resolveDeck({ chrome }).posts.layout === "feed") errors.push(`${which} needs the popup posts, not the feed`);
+ const rail = threadsGeometry({ ...render, chrome });
+ if (rail.cards.width < 220) {
+ errors.push(`${which} leaves the rail ${rail.cards.width}px wide (at least 220: a smaller footageScale)`);
+ }
+ }
if (g.footage.height > room) {
const max = Math.floor((room / g.H) * 1000) / 1000;
errors.push(
@@ -321,7 +339,9 @@ export const even = (v) => Math.round(v / 2) * 2;
*
* The footage box keeps the FRAME's aspect, is `footageScale` of its width
* (evened), and is centred in the area above the deck. For 1920×1080 at 0.82
- * with a 190 px deck that is 1574×886 at (173, 2).
+ * with a 190 px deck that is 1574×886 at (173, 2). With the thread rail on
+ * (threads.mjs) the box moves to the frame's right edge, `railGap` in, and the
+ * rail takes the left: 1574×886 at (322, 2).
*
* @returns {{ W:number, H:number,
* footage:{x:number,y:number,width:number,height:number},
@@ -334,13 +354,33 @@ export function deckGeometry(render) {
const dh = deck.height;
const fw = even(W * deck.footageScale);
const fh = even((fw * H) / W);
+ const fx = railOn(render) ? W - fw - railGap(render) : Math.floor((W - fw) / 2);
return {
W, H,
- footage: { x: Math.floor((W - fw) / 2), y: Math.floor((H - dh - fh) / 2), width: fw, height: fh },
+ footage: { x: fx, y: Math.floor((H - dh - fh) / 2), width: fw, height: fh },
deck: { x: 0, y: H - dh, width: W, height: dh },
};
}
+/** The air between the frame's edges, the thread rail and the footage. */
+export const railGap = (render) => Math.round(24 * ((render?.width ?? 1920) / 1920));
+
+/**
+ * The thread rail's region (threads.mjs, chrome-threads.mjs): the frame's left
+ * side beside the footage, as tall as the footage box, from the frame's edge
+ * to the footage's -- so a lit card's string can reach the picture. `cards`
+ * is where the cards sit inside it (region-local), `railGap` in from its left
+ * and short of the footage.
+ */
+export function threadsGeometry(render) {
+ const { footage } = deckGeometry(render);
+ const gap = railGap(render);
+ return {
+ x: 0, y: footage.y, width: footage.x, height: footage.height,
+ cards: { x: gap, y: 0, width: footage.x - 2 * gap, height: footage.height },
+ };
+}
+
/**
* Where things sit INSIDE the deck region (region-local pixels). The
* composition draws from this; umtool's preview frames the same rect. A
@@ -607,6 +647,10 @@ export function deckSchedule({
const feed = placed.length > 0 && deck.posts.layout === "feed";
const moves = placed.length && !feed ? footageMoves({ posts: placed, segments: segs, render }) : [];
const stamps = stampSchedule({ segments: segs.map((s, i) => ({ ...s, claim: claimOf(entries[i]) })), D, total, render });
+ const threads = threadsOn(render)
+ ? threadSchedule({ segments: segs.map((s, i) => ({ ...s, thread: threadOf(entries[i]) })), D, total, render, moves })
+ : null;
+ const flips = flipsOn(render) ? flipSchedule({ segments: segs, total, render, moves }) : null;
return {
version: 1,
kind: "deck",
@@ -638,6 +682,10 @@ export function deckSchedule({
}),
// The fact-check stamps: present only when a claim is stamped.
...(stamps.length ? { factcheck: { stamps: roundStamps(stamps) } } : {}),
+ // The thread rail: present only when it is on.
+ ...(threads ? { threads } : {}),
+ // The flips panel: present only when it is on.
+ ...(flips ? { flips } : {}),
// Present only when there are posts to draw, so a cut without them writes
// the schedule it always did.
...(placed.length ? { posts: roundPosts(placed) } : {}),
diff --git a/umtool/report-to-video/flips.mjs b/umtool/report-to-video/flips.mjs
@@ -0,0 +1,188 @@
+// FLIPS: a cut built of pairs -- a line from THEN and the opposite line from
+// NOW, back to back -- with a panel down the frame's left that holds the pair
+// while it plays: the topic, the THEN card (when, and the words) lit as the
+// first clip plays, then the NOW card slamming in under it as the second
+// starts, the years between them counting up. When the pair ends it lifts
+// away and the next one comes in. Nothing is said for her: the panel's words
+// are hers, verbatim, a few of them.
+//
+// render.chrome: "flips": { "pairs": [ { "id": "vax", "topic": "Vaccines",
+// "then": { "entry": "f-vax-then", "when": "2019", "words": "…" },
+// "now": { "entry": "f-vax-now", "when": "2025", "words": "…" } } ] }
+//
+// With flips on, the footage box moves to the frame's right edge and the panel
+// takes the left, as the thread rail's does (deck.mjs deckGeometry,
+// threadsGeometry); the two are one region and a cut has one or the other.
+//
+// PURE, like threads.mjs. chrome-flips.mjs draws the panel.
+import { planCues } from "./threads.mjs";
+
+/** The limits: pairs, a topic's, a `when`'s and a side's words' characters. */
+export const FLIPS_LIMITS = Object.freeze({ pairs: Object.freeze([1, 24]), topic: 32, when: 18, words: 110 });
+
+/**
+ * The panel's motion, in seconds: a pair rises in over `enter`, its NOW card
+ * slams over `slam` (from `slamScale`) with a flash, the gap counts over
+ * `count`, the THEN card dims over `dim`; a pair lifts away over `leave`.
+ */
+export const FLIP_MOTION = Object.freeze({
+ enter: 0.35, slam: 0.22, slamScale: 1.35, flashUp: 0.05, flashDown: 0.5, count: 0.6, dim: 0.3, leave: 0.3, aside: 0.4,
+});
+
+/** How bright the THEN card rests once NOW has landed. */
+export const THEN_DIM = 0.5;
+
+const 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, max) => typeof v === "string" && v.trim() !== "" && !/[\n\r]/.test(v) && v.length <= max;
+
+/** Is the flips panel on: a `render.chrome.flips` with at least one pair. */
+export function flipsOn(render) {
+ const f = render?.chrome?.flips;
+ return isObj(f) && Array.isArray(f.pairs) && f.pairs.length > 0;
+}
+
+/**
+ * Every reason `render.chrome.flips` cannot be built, as sentences. Shape
+ * only; `validateFlipEntries` checks the pairs against the timeline.
+ *
+ * @returns {string[]}
+ */
+export function validateFlips(f, where = "render.chrome.flips") {
+ if (f === undefined) return [];
+ if (!isObj(f)) return [`${where} must be an object`];
+ const errors = [];
+ for (const k of Object.keys(f)) if (k !== "pairs") errors.push(`${where}.${k} is not a flips setting`);
+ const [lo, hi] = FLIPS_LIMITS.pairs;
+ if (!Array.isArray(f.pairs) || f.pairs.length < lo || f.pairs.length > hi) return [...errors, `${where}.pairs must be ${lo} to ${hi} pairs`];
+ const ids = new Set();
+ f.pairs.forEach((p, i) => {
+ const w = `${where}.pairs[${i}]`;
+ if (!isObj(p)) { errors.push(`${w} must be an object`); return; }
+ for (const k of Object.keys(p)) if (!["id", "topic", "then", "now"].includes(k)) errors.push(`${w}.${k} is not a pair key`);
+ if (typeof p.id !== "string" || !ID_RE.test(p.id)) errors.push(`${w}.id must be letters, digits, _ or -`);
+ else if (ids.has(p.id)) errors.push(`${w}.id ${p.id} is listed twice`);
+ else ids.add(p.id);
+ if (!oneLine(p.topic, FLIPS_LIMITS.topic)) errors.push(`${w}.topic must be one line of at most ${FLIPS_LIMITS.topic} characters`);
+ for (const side of ["then", "now"]) {
+ const s = p[side];
+ const ws = `${w}.${side}`;
+ if (!isObj(s)) { errors.push(`${ws} must be an object`); continue; }
+ for (const k of Object.keys(s)) if (!["entry", "when", "words"].includes(k)) errors.push(`${ws}.${k} is not a side key`);
+ if (typeof s.entry !== "string" || !s.entry) errors.push(`${ws}.entry must name a timeline entry`);
+ if (!oneLine(s.when, FLIPS_LIMITS.when)) errors.push(`${ws}.when must be one line of at most ${FLIPS_LIMITS.when} characters`);
+ if (!oneLine(s.words, FLIPS_LIMITS.words)) errors.push(`${ws}.words must be one line of at most ${FLIPS_LIMITS.words} characters`);
+ }
+ });
+ return errors;
+}
+
+/**
+ * The pairs against the timeline: each side names an entry of the cut, a clip
+ * (not a teaser), the THEN side before the NOW side, and no entry in two pairs.
+ *
+ * @returns {string[]}
+ */
+export function validateFlipEntries(manifest) {
+ const render = manifest?.render ?? {};
+ if (!flipsOn(render)) return [];
+ const errors = [];
+ const at = new Map((manifest.timeline ?? []).map((e, i) => [e?.id, { e, i }]));
+ const used = new Map();
+ render.chrome.flips.pairs.forEach((p, i) => {
+ const w = `render.chrome.flips.pairs[${i}] (${p?.id ?? "?"})`;
+ const idx = {};
+ for (const side of ["then", "now"]) {
+ const id = p?.[side]?.entry;
+ const hit = at.get(id);
+ if (!hit) { errors.push(`${w}.${side}.entry ${id} is not in the timeline`); continue; }
+ if (hit.e.type === "teaser") errors.push(`${w}.${side}.entry ${id} is a teaser, not a clip`);
+ if (used.has(id)) errors.push(`${w}.${side}.entry ${id} is already in pair ${used.get(id)}`);
+ used.set(id, p.id);
+ idx[side] = hit.i;
+ }
+ if (idx.then !== undefined && idx.now !== undefined && idx.then >= idx.now) errors.push(`${w}: its THEN entry must come before its NOW entry`);
+ });
+ return errors;
+}
+
+/** The years between two `when`s, when both carry one (`2019`, `May 2019`): else null. */
+export function yearsBetween(a, b) {
+ const y = (s) => {
+ const m = /(19|20)\d\d/.exec(String(s ?? ""));
+ return m ? Number(m[0]) : null;
+ };
+ const ya = y(a), yb = y(b);
+ return ya != null && yb != null && yb > ya ? yb - ya : null;
+}
+
+/**
+ * The panel's schedule, in the cut's clock: each pair with its two sides'
+ * spans (`from`: the clip's start; `to`: the next clip's start, or the cut's
+ * end) and the years between; `asides` as the thread rail's (a popup post's
+ * move to the end of its clip).
+ *
+ * @returns {{ pairs: Array<{ id: string, topic: string, index: number, of: number, years: number|null,
+ * then: { segment: string, from: number, to: number, when: string, words: string },
+ * now: { segment: string, from: number, to: number, when: string, words: string } }>,
+ * asides: Array<{ from: number, to: number }> }}
+ */
+export function flipSchedule({ segments, total, render, moves = [] }) {
+ const R = (v) => Math.round(v * 1000) / 1000;
+ const endOf = (i) => (i + 1 < segments.length ? segments[i + 1].start : total);
+ const idx = new Map(segments.map((s, i) => [s.id, i]));
+ const list = flipsOn(render) ? render.chrome.flips.pairs : [];
+ const side = (s) => {
+ const i = idx.get(s.entry);
+ return i === undefined ? null : { segment: s.entry, from: R(segments[i].start), to: R(endOf(i)), when: s.when, words: s.words };
+ };
+ const pairs = list
+ .map((p) => ({ id: p.id, topic: p.topic, then: side(p.then), now: side(p.now), years: yearsBetween(p.then.when, p.now.when) }))
+ .filter((p) => p.then && p.now)
+ .sort((a, b) => a.then.from - b.then.from)
+ .map((p, i, all) => ({ ...p, index: i + 1, of: all.length }));
+ const asides = moves.map((m) => {
+ const i = idx.get(m.segment);
+ return { from: R(m.at), to: R(i === undefined ? total : endOf(i)) };
+ });
+ return { pairs, asides };
+}
+
+/**
+ * Everything the panel's timeline does, as data. Pair j is `p<j>` (autoAlpha,
+ * y); its THEN card `t<j>` (opacity), NOW card `n<j>` (autoAlpha, scale, x)
+ * with its flash `nf<j>`, the gap `g<j>` (autoAlpha) and its count `gc<j>`
+ * (an odometer strip of 0..years, rolled by yPercent -- a transform, so a
+ * seek from anywhere lands on the same digit); the whole panel is `panel`.
+ *
+ * @returns {{ init: Record<string, object>, cues: Array<object> }}
+ */
+export function flipCues(sched) {
+ const m = FLIP_MOTION;
+ const init = { panel: { autoAlpha: 1, x: 0 } };
+ const ev = [];
+ const add = (k, at, dur, to, ease, why) => ev.push({ k, at, dur, to, ease, why });
+ sched.pairs.forEach((p, j) => {
+ init[`p${j}`] = { autoAlpha: 0, y: 60 };
+ init[`t${j}`] = { opacity: 1 };
+ init[`n${j}`] = { autoAlpha: 0, scale: m.slamScale, x: 40 };
+ init[`nf${j}`] = { opacity: 0 };
+ init[`g${j}`] = { autoAlpha: 0 };
+ init[`gc${j}`] = { yPercent: 0 };
+ add(`p${j}`, p.then.from, m.enter, { autoAlpha: 1, y: 0 }, "power3.out", `${p.id} in`);
+ add(`n${j}`, p.now.from, m.slam, { autoAlpha: 1, scale: 1, x: 0 }, "power4.in", `${p.id} now`);
+ add(`nf${j}`, p.now.from + m.slam, m.flashUp, { opacity: 1 }, "none", `${p.id} flash`);
+ add(`nf${j}`, p.now.from + m.slam + m.flashUp, m.flashDown, { opacity: 0 }, "power2.out", `${p.id} flash`);
+ add(`t${j}`, p.now.from, m.dim, { opacity: THEN_DIM }, "power2.out", `${p.id} then dims`);
+ if (p.years != null) {
+ add(`g${j}`, p.now.from, m.slam, { autoAlpha: 1 }, "power2.out", `${p.id} gap`);
+ add(`gc${j}`, p.now.from + 0.05, m.count, { yPercent: -100 * (p.years / (p.years + 1)) }, "power2.out", `${p.id} count`);
+ }
+ add(`p${j}`, p.now.to - m.leave, m.leave, { autoAlpha: 0, y: -50 }, "power2.in", `${p.id} out`);
+ });
+ for (const a of sched.asides) {
+ add("panel", a.from, m.aside, { autoAlpha: 0, x: -40 }, "power2.in", "aside for a post");
+ add("panel", a.to, m.aside, { autoAlpha: 1, x: 0 }, "power2.out", "back after a post");
+ }
+ return { init, cues: planCues(init, ev) };
+}
diff --git a/umtool/report-to-video/flips.test.mjs b/umtool/report-to-video/flips.test.mjs
@@ -0,0 +1,110 @@
+// Tests for the flips panel: its settings and validation (flips.mjs, through
+// validateChrome), the pairs against the timeline, the schedule, the cues and
+// the page (chrome-flips.mjs).
+//
+// Run with: pnpm test:scripts
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { flipCues, flipSchedule, flipsOn, FLIPS_LIMITS, THEN_DIM, validateFlipEntries, validateFlips, yearsBetween } from "./flips.mjs";
+import { deckGeometry, deckSchedule, railGap, validateChrome } from "./deck.mjs";
+import { flipsHtml } from "./chrome-flips.mjs";
+
+const PALETTE = { bg: "#12101a", fg: "#f4f1ea", muted: "#9a93ad", accent: "#e5534b", amber: "#ffc860" };
+const PAIRS = [
+ { id: "vax", topic: "Vaccines", then: { entry: "a", when: "2019", words: "I trust doctors" }, now: { entry: "b", when: "2025", words: "Never again" } },
+ { id: "dw", topic: "The Daily Wire", then: { entry: "c", when: "May 2021", words: "Best place" }, now: { entry: "d", when: "Ep 300", words: "Worst place" } },
+];
+const CHROME = { engine: "hyperframes", layout: "deck", deck: {}, flips: { pairs: PAIRS } };
+const RENDER = { width: 1920, height: 1080, fps: 30, transition: 0.12, palette: PALETTE, chrome: CHROME };
+const TIMELINE = ["a", "b", "c", "d"].map((id) => ({ id, type: "clip" }));
+
+test("validation: the pairs' shape, and not beside the thread rail", () => {
+ assert.equal(flipsOn(RENDER), true);
+ assert.deepEqual(validateFlips({ pairs: PAIRS }), []);
+ assert.deepEqual(validateChrome(CHROME, RENDER), []);
+ assert.match(validateFlips({ pairs: [] }).join(";"), /1 to 24 pairs/);
+ const bad = validateFlips({
+ pairs: [
+ { id: "x", topic: "t", then: { entry: "a", when: "2019", words: "w".repeat(FLIPS_LIMITS.words + 1) }, now: { entry: "b", when: "x", words: "y" }, extra: 1 },
+ { id: "x", topic: "", then: null, now: { entry: "", when: "", words: "y" } },
+ ],
+ }).join(";");
+ assert.match(bad, /pairs\[0\]\.extra is not a pair key/);
+ assert.match(bad, /then\.words must be one line/);
+ assert.match(bad, /x is listed twice/);
+ assert.match(bad, /pairs\[1\]\.topic must be/);
+ assert.match(bad, /pairs\[1\]\.then must be an object/);
+ assert.match(bad, /now\.entry must name a timeline entry/);
+ const both = { ...CHROME, threads: { list: [{ id: "t", label: "T" }] } };
+ assert.match(validateChrome(both, RENDER).join(";"), /share the left panel/);
+});
+
+test("the pairs against the timeline: entries exist, THEN first, one pair each", () => {
+ assert.deepEqual(validateFlipEntries({ render: RENDER, timeline: TIMELINE }), []);
+ const errs = validateFlipEntries({ render: RENDER, timeline: [{ id: "b", type: "clip" }, { id: "a", type: "teaser" }] }).join(";");
+ assert.match(errs, /vax\)?: its THEN entry must come before its NOW entry/);
+ assert.match(errs, /then\.entry a is a teaser/);
+ assert.match(errs, /then\.entry c is not in the timeline/);
+ const twice = { ...RENDER, chrome: { ...CHROME, flips: { pairs: [PAIRS[0], { ...PAIRS[1], then: { ...PAIRS[1].then, entry: "b" } }] } } };
+ assert.match(validateFlipEntries({ render: twice, timeline: TIMELINE }).join(";"), /b is already in pair vax/);
+});
+
+test("the footage moves right for the panel, as for the rail", () => {
+ const g = deckGeometry(RENDER).footage;
+ assert.equal(g.x, 1920 - g.width - railGap(RENDER));
+});
+
+test("years between two whens, when both carry a year", () => {
+ assert.equal(yearsBetween("2019", "2025"), 6);
+ assert.equal(yearsBetween("May 2021", "October 2026"), 5);
+ assert.equal(yearsBetween("Ep 12", "2025"), null);
+ assert.equal(yearsBetween("2025", "2019"), null);
+});
+
+const SEGS = [
+ { id: "a", start: 0, duration: 6 },
+ { id: "b", start: 5.88, duration: 5 },
+ { id: "c", start: 10.76, duration: 7 },
+ { id: "d", start: 17.64, duration: 4 },
+];
+
+test("the schedule: each pair's spans, its years, its place", () => {
+ const s = flipSchedule({ segments: SEGS, total: 21.64, render: RENDER, moves: [{ segment: "c", at: 11 }] });
+ assert.deepEqual(s.pairs.map((p) => [p.id, p.index, p.of, p.years]), [["vax", 1, 2, 6], ["dw", 2, 2, null]]);
+ assert.deepEqual(s.pairs[0].then, { segment: "a", from: 0, to: 5.88, when: "2019", words: "I trust doctors" });
+ assert.deepEqual([s.pairs[1].now.from, s.pairs[1].now.to], [17.64, 21.64]);
+ assert.deepEqual(s.asides, [{ from: 11, to: 17.64 }]);
+});
+
+test("the cues: a pair rises with THEN, NOW slams and THEN dims, the count rolls to its year, the pair leaves", () => {
+ const s = flipSchedule({ segments: SEGS, total: 21.64, render: RENDER });
+ const { init, cues } = flipCues(s);
+ for (const c of cues) for (const k of Object.keys(c.to)) assert.notEqual(c.from[k], undefined, `${c.k} ${k} has a from`);
+ assert.equal(cues.find((c) => c.k === "p0").at, 0);
+ const slam = cues.find((c) => c.k === "n0");
+ assert.equal(slam.at, 5.88);
+ assert.deepEqual(slam.to, { autoAlpha: 1, scale: 1, x: 0 });
+ assert.equal(cues.find((c) => c.k === "t0").to.opacity, THEN_DIM);
+ const count = cues.find((c) => c.k === "gc0");
+ assert.ok(Math.abs(count.to.yPercent - -100 * (6 / 7)) < 1e-9);
+ assert.equal(cues.find((c) => c.k === "gc1"), undefined, "no years, no count");
+ assert.deepEqual(init.gc0, { yPercent: 0 });
+ const leaves = cues.filter((c) => c.k === "p0" && c.to.autoAlpha === 0);
+ assert.equal(leaves.length, 1);
+ assert.ok(leaves[0].at + leaves[0].dur <= 10.76 + 1e-9, "gone by the next pair");
+});
+
+test("the page: one pair block per pair, both sides' words, an odometer of the years", () => {
+ const entries = SEGS.map((s) => ({ id: s.id, type: "clip" }));
+ const schedule = deckSchedule({ entries, durs: SEGS.map((s) => s.duration), D: 0.12, render: RENDER });
+ assert.ok(schedule.flips, "the deck's schedule carries the pairs");
+ const html = flipsHtml(schedule, RENDER, { fonts: { regular: "r.ttf", bold: "b.ttf" } });
+ assert.equal((html.match(/class="pair"/g) ?? []).length, 2);
+ assert.match(html, /“I trust doctors”/);
+ assert.match(html, /“Never again”/);
+ assert.equal((html.match(/class="digit"/g) ?? []).length, 7);
+ assert.match(html, /years later/);
+ assert.match(html, /01 \/ 02/);
+ assert.throws(() => flipsHtml({ ...schedule, flips: undefined }, RENDER), /no pairs/);
+});
diff --git a/umtool/report-to-video/threads.mjs b/umtool/report-to-video/threads.mjs
@@ -0,0 +1,283 @@
+// 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<j>` (opacity), its
+ * lit wash `w<j>` and string `s<j>` (scaleX from its left), its dots
+ * `d<j>-<n>`, its outcome `o<j>` (autoAlpha, scale) with its flash `f<j>`; the
+ * whole rail is `rail` (autoAlpha, x).
+ *
+ * @returns {{ init: Record<string, object>, cues: Array<object> }}
+ */
+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) };
+}
diff --git a/umtool/report-to-video/threads.test.mjs b/umtool/report-to-video/threads.test.mjs
@@ -0,0 +1,201 @@
+// Tests for the thread rail: its settings and their validation (threads.mjs,
+// through validateChrome), the footage box moving aside for it, its schedule
+// (dots, runs, outcomes, asides), its cues, its page (chrome-threads.mjs) and
+// its overlay region.
+//
+// Run with: pnpm test:scripts
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import {
+ CARD_REST, OUTCOME_LEAD, planCues, resolveThreads, threadCues, threadOf, threadSchedule, threadsOn, THREADS_LIMITS,
+ validateThreadEntries, validateThreads,
+} from "./threads.mjs";
+import { deckGeometry, deckSchedule, railGap, threadsGeometry, validateChrome } from "./deck.mjs";
+import { railLayout, threadsHtml } from "./chrome-threads.mjs";
+import { threadsRegion } from "./build-video.mjs";
+
+const PALETTE = { bg: "#12101a", fg: "#f4f1ea", muted: "#9a93ad", accent: "#a97bff", amber: "#ffc860" };
+const LIST = [
+ { id: "bet", label: "The bet", outcome: { verdict: "CONTRADICTED" } },
+ { id: "proof", label: "Definitive proof", outcome: { verdict: "NOT_FOUND", label: "Still coming" } },
+ { id: "aside", label: "An aside" },
+];
+const CHROME = { engine: "hyperframes", layout: "deck", deck: {}, threads: { list: LIST } };
+const RENDER = { width: 1920, height: 1080, fps: 30, transition: 0.5, palette: PALETTE, chrome: CHROME };
+const PLAIN = { ...RENDER, chrome: { engine: "hyperframes", layout: "deck", deck: {} } };
+
+test("the rail is on only with a list of threads", () => {
+ assert.equal(threadsOn(RENDER), true);
+ assert.equal(threadsOn(PLAIN), false);
+ assert.equal(threadsOn({ chrome: { threads: { list: [] } } }), false);
+});
+
+test("an outcome takes its verdict's colour and the cut's label, or its own label", () => {
+ const t = resolveThreads({
+ ...RENDER, chrome: { ...CHROME, factcheck: { verdicts: { CONTRADICTED: { label: "Walked back" } } } },
+ });
+ assert.equal(t[0].outcome.label, "Walked back");
+ assert.match(t[0].outcome.color, /^#[0-9a-f]{6}$/i);
+ assert.equal(t[1].outcome.label, "Still coming");
+ assert.equal(t[2].outcome, null);
+});
+
+test("validation: the list, ids, labels and outcomes", () => {
+ assert.deepEqual(validateThreads(undefined), []);
+ assert.deepEqual(validateThreads({ list: LIST }), []);
+ assert.match(validateThreads({ list: [] }).join(";"), /1 to 8 threads/);
+ assert.match(validateThreads({ list: LIST, side: "left" }).join(";"), /side is not a threads setting/);
+ const bad = validateThreads({
+ list: [
+ { id: "a b", label: "x" },
+ { id: "dup", label: "x" },
+ { id: "dup", label: "y".repeat(THREADS_LIMITS.label + 1) },
+ { id: "o", label: "z", outcome: { verdict: "MAYBE", label: "two\nlines" } },
+ ],
+ }).join(";");
+ assert.match(bad, /list\[0\]\.id must be/);
+ assert.match(bad, /dup is listed twice/);
+ assert.match(bad, /list\[2\]\.label must be one line/);
+ assert.match(bad, /outcome\.verdict must be one of/);
+ assert.match(bad, /outcome\.label must be one line/);
+ // validateChrome reads it, and refuses the feed beside it.
+ assert.deepEqual(validateChrome(CHROME, RENDER), []);
+ assert.match(
+ validateChrome({ ...CHROME, deck: { posts: { layout: "feed" } } }, RENDER).join(";"),
+ /threads needs the popup posts/,
+ );
+ assert.match(validateChrome({ ...CHROME, deck: { footageScale: 0.95 } }, RENDER).join(";"), /rail \d+px wide/);
+});
+
+test("entries: a listed thread, not on a teaser, and every thread used", () => {
+ const timeline = [
+ { id: "a", type: "clip", thread: "bet" },
+ { id: "b", type: "clip", thread: "proof" },
+ { id: "c", type: "clip", thread: "aside" },
+ ];
+ assert.deepEqual(validateThreadEntries({ render: RENDER, timeline }), []);
+ const errs = validateThreadEntries({
+ render: RENDER,
+ timeline: [{ id: "a", type: "clip", thread: "nope" }, { id: "t", type: "teaser", thread: "bet" }],
+ }).join(";");
+ assert.match(errs, /nope is not in render\.chrome\.threads\.list/);
+ assert.match(errs, /a teaser belongs to no thread/);
+ assert.match(errs, /thread proof has no clip/);
+ assert.equal(threadOf({ thread: "bet" }), "bet");
+ assert.equal(threadOf({}), null);
+});
+
+test("the footage moves to the right edge and the rail takes the left", () => {
+ const plain = deckGeometry(PLAIN).footage;
+ const g = deckGeometry(RENDER).footage;
+ assert.equal(g.width, plain.width);
+ assert.equal(g.x, 1920 - g.width - railGap(RENDER));
+ const rail = threadsGeometry(RENDER);
+ assert.deepEqual([rail.x, rail.y, rail.width, rail.height], [0, g.y, g.x, g.height]);
+ assert.equal(rail.cards.x, railGap(RENDER));
+ assert.equal(rail.cards.x + rail.cards.width + railGap(RENDER), g.x);
+ const { cards: _c, ...box } = rail;
+ assert.deepEqual(threadsRegion(RENDER, "/f"), { name: "threads", frames: "/f", ...box });
+});
+
+const SEGS = [
+ { id: "s0", start: 0, duration: 10, thread: "bet" },
+ { id: "s1", start: 9.5, duration: 10, thread: "bet" },
+ { id: "s2", start: 19, duration: 10, thread: null },
+ { id: "s3", start: 28.5, duration: 10, thread: "proof" },
+ { id: "s4", start: 38, duration: 10, thread: "bet" },
+ { id: "s5", start: 47.5, duration: 6, thread: "aside" },
+];
+
+test("the schedule: dots, runs, outcomes on the last clip, asides for posts", () => {
+ const moves = [{ segment: "s3", at: 30, segmentAt: 1.5, seconds: 0.6 }];
+ const s = threadSchedule({ segments: SEGS, D: 0.5, total: 53.5, render: RENDER, moves });
+ const bet = s.threads[0];
+ assert.deepEqual(bet.clips.map((c) => [c.segment, c.at]), [["s0", 0], ["s1", 9.75], ["s4", 38.25]]);
+ // Stamped OUTCOME_LEAD (and the dissolve) before its last clip hands over.
+ assert.equal(bet.closeAt, 47.5 - 0.5 - OUTCOME_LEAD);
+ // A thread without an outcome is never closed.
+ assert.equal(s.threads[2].closeAt, undefined);
+ assert.deepEqual(s.runs, [
+ { thread: "bet", from: 0, to: 19 },
+ { thread: "proof", from: 28.5, to: 38 },
+ { thread: "bet", from: 38, to: 47.5 },
+ { thread: "aside", from: 47.5, to: 53.5 },
+ ]);
+ assert.deepEqual(s.asides, [{ from: 30, to: 38 }]);
+ // An outcome that would land while the rail is aside lands as it comes back.
+ const hid = threadSchedule({
+ segments: SEGS, D: 0.5, total: 53.5, render: RENDER, moves: [{ segment: "s4", at: 40 }],
+ });
+ assert.equal(hid.threads[0].closeAt, 47.5 + 0.4 + 0.1);
+ // A clip too short for the lead is stamped half a second in, never before.
+ const short = threadSchedule({
+ segments: [{ id: "x", start: 0, duration: 1.2, thread: "proof" }], D: 0.5, total: 1.2, render: RENDER,
+ });
+ assert.equal(short.threads[1].closeAt, 0.5);
+});
+
+test("the cues state every from, light and dim each run, and stamp each outcome once", () => {
+ const sched = threadSchedule({ segments: SEGS, D: 0.5, total: 53.5, render: RENDER, moves: [{ segment: "s3", at: 30 }] });
+ const { init, cues } = threadCues(sched);
+ for (const c of cues) {
+ for (const k of Object.keys(c.to)) assert.notEqual(c.from[k], undefined, `${c.k} ${k} has a from`);
+ }
+ // Its thread opens the cut, so its card comes up lit; it lights again for its second run.
+ const lit = cues.filter((c) => c.k === "c0" && c.to.opacity === 1).map((c) => c.at);
+ assert.equal(lit[0], 0);
+ assert.equal(lit[lit.length - 1], 38);
+ assert.equal(cues.find((c) => c.k === "c1").to.opacity, CARD_REST.ahead);
+ const rests = cues.filter((c) => c.k === "c0" && c.to.opacity === CARD_REST.done);
+ assert.equal(rests.length, 2);
+ assert.equal(cues.filter((c) => c.k === "o0" && c.to.autoAlpha === 1).length, 1);
+ assert.equal(init.o2, undefined, "no outcome, no stamp");
+ assert.deepEqual(init["d0-0"], { opacity: 0.22, scale: 0.7 }, "a dot is there, small and dim, before its clip");
+ assert.deepEqual(cues.filter((c) => c.k === "rail").map((c) => c.to.autoAlpha), [0, 1]);
+ // One element's cues never overlap.
+ const byK = {};
+ for (const c of cues) {
+ if (byK[c.k] !== undefined) assert.ok(c.at >= byK[c.k] - 1e-9, `${c.k} overlaps at ${c.at}`);
+ byK[c.k] = c.at + c.dur;
+ }
+});
+
+test("planCues clamps a cue behind its element's last and carries the state", () => {
+ const cues = planCues({ a: { x: 0 } }, [
+ { k: "a", at: 0, dur: 2, to: { x: 1 }, ease: "none" },
+ { k: "a", at: 1, dur: 2, to: { x: 2 }, ease: "none" },
+ ]);
+ assert.deepEqual(cues.map((c) => [c.at, c.dur, c.from.x, c.to.x]), [[0, 2, 0, 1], [2, 1, 1, 2]]);
+});
+
+test("the page: a card per thread, a dot per clip, an outcome where one is set", () => {
+ const segments = SEGS.map(({ thread, ...s }) => s);
+ const entries = SEGS.map((s) => ({ id: s.id, type: "clip", ...(s.thread ? { thread: s.thread } : {}) }));
+ const schedule = deckSchedule({ entries, durs: SEGS.map((s) => s.duration), D: 0.5, render: RENDER });
+ assert.ok(schedule.threads, "the deck's schedule carries the rail");
+ assert.equal(segments.length, schedule.segments.length);
+ const html = threadsHtml(schedule, RENDER, { fonts: { regular: "assets/r.ttf", bold: "assets/b.ttf" } });
+ assert.equal((html.match(/class="card"/g) ?? []).length, 3);
+ assert.equal((html.match(/class="dot"/g) ?? []).length, 5);
+ assert.equal((html.match(/class="outcome"/g) ?? []).length, 2);
+ assert.match(html, /data-composition-id="threads"/);
+ assert.match(html, />Still coming</);
+ assert.throws(() => threadsHtml({ ...schedule, threads: undefined }, RENDER), /no thread rail/);
+ // No rail, no `threads` in the schedule: a cut without one writes what it always did.
+ const plain = deckSchedule({ entries, durs: SEGS.map((s) => s.duration), D: 0.5, render: PLAIN });
+ assert.equal("threads" in plain, false);
+});
+
+test("railLayout: cards share the height, capped, the stack centred", () => {
+ assert.deepEqual(railLayout(886, 5), { h: 166, gap: 14, top: 0 });
+ const three = railLayout(886, 3);
+ assert.equal(three.h, 190);
+ assert.equal(three.top, Math.floor((886 - 3 * 190 - 2 * 14) / 2));
+});
+
+test("the renderer runs without a display: a stale DISPLAY would break SwiftShader", async () => {
+ const { rendererEnv } = await import("./compose-chrome.mjs");
+ const env = rendererEnv({ DISPLAY: ":0.0", WAYLAND_DISPLAY: "wayland-0", PATH: "/usr/bin", HOME: "/h" });
+ assert.deepEqual(env, { PATH: "/usr/bin", HOME: "/h" });
+});