commit 44528b0e124392aafabcfd76aa72a989d77037ce
parent 3f74c143c755983276bda90c84da94c21a57152f
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Wed, 19 Aug 2026 20:39:55 -0400
umtool: two cuts from one manifest, and a rail that rolls one number at a time
The report-video pipeline learns `--variant sourced|full`. `selectVariant()`
runs immediately after the manifest is read and is the whole mechanism: drop
timeline entries tagged for the other variant, keep only claims whose `entryId`
survived, merge `card.variants[<name>]` copy overrides. Nothing downstream —
ledgerTotals, the rail, the chart band, scheduleClaims, the chapters, the
closing cards — learns that variants exist.
Two answers to the same question, and both get cut. `sourced` shows only the
claims with footage behind them; `full` stacks the rest onto a new `ledger`
card type, one card per run of consecutive unclipped claims, rows revealed by a
walking curtain. Both end on stated 5, implied 23, gap 18, which is the point
of shipping both: if the totals moved when the unsourced rows came off, the
thesis would rest on rows nobody can check.
`out/clips-raw` and `out/availability.json` stay at the root and are shared —
the fetches are the only expensive thing here and the two cuts overlap almost
entirely. Everything else moved under `out/<variant>/`. `sourced` still writes
`out/<slug>.mp4`, so umtool's build probe is unchanged.
The rail stops sliding as one slab. Each track gets its own rolling cell and
its own y expression; the swatch and company label move into the static chrome,
because a coffee figure changing must not drag "The Quartering" up the screen.
Roll DIRECTION is a property of the strip's layout, not of the ramp: lay the
pair [old, new] for a rise and [new, old] for a fall, and the crop's walk does
the rest. Beside it, a roster line that mostly does not move — five times he
enumerates who works for him and five times it is two video editors and a
graphics designer, while the total he attaches goes three, four, ten.
Also here: a sixth coherence predicate (`status_flip`), the QR moved off the
picture and into a bordered tile in the rail's foot, the closing scroll rebuilt
as one chronological line with a column per company, and `railGeometry` reading
`reservedFooterHeight` — it was running the column 100px past the band's top
edge, the fourth renderer to get that wrong.
Two defects found only by extracting frames, after the tests and `umtool check`
were both green:
* the band's flag box had a FIXED width that clipped the plain-words reason
the moment the rail widened;
* the readout's build-time bookkeeping shared an object with its runtime
state, so it painted the cut's closing total in its opening seconds —
"STATED 5" for 53 seconds in `sourced`, "IMPLIED 23" beside an empty plot
in `full`. Precisely what the band's own sweep exists to prevent.
Severity in the inbox now follows the CLIPS, not the source. Widening the
availability probe to ledger sources put six blocking rows in front of a
manifest that builds cleanly, reading "0 clip(s) cite it; the build dies here"
— a sentence that refutes itself.
This commit also carries the adjudication and claim-bench work that was already
uncommitted in the tree; it is the same feature line and the two are interleaved
in the same files, so they cannot be split apart now.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Diffstat:
26 files changed, 6079 insertions(+), 109 deletions(-)
diff --git a/package.json b/package.json
@@ -21,7 +21,7 @@
"e2e": "node scripts/worktree.mjs run -- pnpm --filter editor run e2e",
"wt": "node scripts/worktree.mjs",
"e2e:sharded": "node scripts/run-sharded-e2e.mjs",
- "test:scripts": "node --test scripts/*.test.mjs",
+ "test:scripts": "node --test scripts/*.test.mjs scripts/report-to-video/*.test.mjs",
"lint": "pnpm --filter export run lint"
},
"devDependencies": {
diff --git a/scripts/report-to-video/README.md b/scripts/report-to-video/README.md
@@ -113,6 +113,10 @@ Everything is cached by content, so iteration is cheap:
and containing-window reuse is what makes that fetch double as the build's cache.
- **Force a refetch** — delete `out/clips-raw/`, or pass `--no-reuse` to require an
exact-window file.
+- **Iterate on the rail** — `--rail-only` re-runs only the rail chain over a cached
+ `out/<slug>.prerail.mp4`; `--preview <start> <dur>` does the same over a window.
+ `--no-rail` builds the cut without one. See
+ [The claim rail](#the-claim-rail-renderrail).
Containing-window reuse was retrofitted, and the waste it removes is measurable:
`ferret-rescue/out/clips-raw` holds **31 files for 10 clips** because every window
@@ -200,7 +204,9 @@ Clips also carry `section` and (auto-set) `sectionEnter`. Card styles — `title
`timeline`, `status`, `bullets`, `sources` — still work, but the ferret-rescue cut
uses none of them. `render` holds resolution, fps, fonts, palette and the knobs
(`fetchPad`, `snapWindow`, `silenceRelDb`, `transition`, `slideSeconds`,
-`headerHeight`, `footerHeight`); `provenance` holds the sweep's scope and counts.
+`headerHeight`, `footerHeight`, and the optional `rail`); `provenance` holds the
+sweep's scope and counts. Two further entry `type`s, `scroll` and `chart`, close a
+cut off a top-level `ledger[]` — see [The claim rail](#the-claim-rail-renderrail).
## Chrome, not cards
@@ -222,8 +228,27 @@ thin chrome rather than overlaid by it:
- **The marker slides.** On the first clip of each section (`sectionEnter`, set
automatically), the fill bar and the amber marker animate from the previous node
to the current one over `slideSeconds`. Everywhere else they hold position. The
- motion is ffmpeg expressions on `drawbox`/`overlay`, so it costs nothing beyond
- the encode that was happening anyway.
+ motion is ffmpeg expressions on `crop`/`overlay`, so it costs nothing beyond the
+ encode that was happening anyway.
+
+ > **This used to be half true.** Until 2026-08-19 only the marker moved. The
+ > fill bar was a `drawbox` whose width was `if(lt(t,0.9),…)` — but **`drawbox`
+ > has no time variable**: its `t` is the box *thickness*, and with `t=fill`
+ > that is effectively `INT_MAX`, so the comparison was always false and the
+ > width expression collapsed to its end value. (`drawbox=w='t*10':h=8:t=2` and
+ > `drawbox=w=20:h=8:t=2` produce an identical YAVG.)
+ >
+ > It was worse than "always full", because **`drawbox` reads `w=0` as *the
+ > input width***. Section 0's fill is 0 px, so every clip in the first section
+ > drew the bar across the **whole frame** — the progress track read 100 %
+ > complete on the opening clip of every cut that has a footer. Measured on
+ > `quartering-flagging-takedowns/n02`: 1708 accent px on the track row before,
+ > 0 after (the correct value), with the amber marker parked on node 0.
+ >
+ > It is now a `_bar.png` strip of `2·trackLen × 3` — accent on the left half,
+ > transparent on the right — translated under a fixed-width `crop`, which
+ > *does* evaluate `x` per frame. Verified on `ferret-rescue/c07`: 608 → 698 →
+ > 798 → 912 px across t = 0.0 … 0.9 s, then parked.
Clips carry `section` (index into `timelineNodes`); nodes supply `label` and
`date`. Ordering clips chronologically is the author's job — the manifest plays in
@@ -257,6 +282,433 @@ parsing — wrapping the expression in single quotes is what protects them.
stream-copy hard cuts. Pass `--no-xfade` for a fast hard-cut build while
iterating; the last pass can add the transitions back.
+## Two cuts from one manifest (`--variant`)
+
+A sweep finds more claims than a cut can show footage for. There are two
+defensible answers to that and they make different videos, so the manifest
+describes both and one filter picks between them.
+
+| variant | output | ledger | what the viewer sees |
+|---|---|---|---|
+| `sourced` (default) | `out/<slug>.mp4` | claims with a clip | every row on screen has footage behind it |
+| `full` | `out/<slug>-full.mp4` | every claim | the unclipped ones are stacked onto `ledger` cards |
+
+`selectVariant(manifest, variant)` runs **immediately after the manifest is read**
+and is the whole mechanism. Three rules, in this order:
+
+1. a timeline entry tagged `"variant": "full"` survives only in that variant;
+2. a claim survives only if the entry its `entryId` names survived — which is what
+ makes `sourced` a sourced-only ledger, because in `full` every claim is pinned,
+ to a clip or to a `ledger` card;
+3. `card.variants[<name>]` field overrides are merged in and the key dropped.
+
+Nothing downstream learns about variants. `ledgerTotals`, the rail, the chart
+band, `scheduleClaims`, the chapters and the closing cards already take the
+ledger and the timeline as inputs.
+
+**Rule 3 exists because copy can be false in one cut.** A title card saying "48
+dated claims" is a lie in a cut that shows nineteen, and the closing sources card
+says two claims stayed ambiguous — both of which happen to be unclipped, so in
+`sourced` there are none.
+
+### Output layout
+
+```
+out/
+ clips-raw/ SHARED — the only expensive thing in a build
+ availability.json SHARED — a fact about the manifest, not about a cut
+ <slug>.mp4 sourced
+ <slug>-full.mp4 full
+ sourced/{cards,segments,qr,chrome,schedule.json}
+ full/{cards,segments,qr,chrome,schedule.json}
+```
+
+`clips-raw` is shared deliberately: `sourced`'s clips are a subset of `full`'s, so
+no clip is ever fetched twice. `sourced` writes `out/<slug>.mp4` because that is
+the path umtool's build probe already looks for.
+
+`compose-chrome.mjs` and `verify-build.mjs` both take `--variant` for the same
+reason: the band plots the ledger the cut carries, and verifying the whole
+manifest against one variant's file would report a missing chapter for every
+entry the other cut has.
+
+### The `ledger` entry type
+
+`full`'s answer to the claims no clip covers. One card per **run of consecutive
+unclipped claims**, so 29 claims cost 13 cards and about 66 seconds.
+
+```jsonc
+{ "type": "ledger", "id": "L10", "variant": "full", "seconds": 4.8,
+ "kicker": "November 2024",
+ "heading": "Ten and eight, named separately, in one breath",
+ "sub": "…", // optional
+ "claims": ["c12", "m14"] } // ledger ids, in ledger order
+```
+
+Each row draws `date · scope pill · why it is not footage · the quote · his
+figure`, plus a right-hand **arithmetic column**: `media / coffee / publica` as
+they stand, the implied total, the layer this claim just moved lit, and its delta.
+The arithmetic is **read from `ledgerTotals`, never recomputed** — one walk, or
+the card and the band disagree about the same sum.
+
+Rows **reveal in sequence** behind an opaque `pal.bg` rectangle walking down the
+card: the rail curtain's device, exact because the card ground is flat.
+`seconds` is derived (`2.2 + 1.3·rows`) rather than authored, because the rail
+pins to the same clock — see below.
+
+**"Why it is not footage" comes from a probe, not from a hand-written kicker.**
+`check-availability.mjs` now probes every **ledger** source as well as every clip
+source, so a row says `source deleted`, `source unreachable` or `not clipped`
+because `yt-dlp --simulate` said so on a recorded date. Several unclipped claims
+are cut from videos this cut clips elsewhere, i.e. demonstrably live; saying "the
+upload is gone" about one of those is the kind of error that discredits the whole
+compilation.
+
+**Pins gain a within-segment offset.** A claim on a `ledger` card is pinned to its
+own row's reveal (`ledgerRevealAt(r)`), not to the segment's mid-dissolve.
+Otherwise four rail rows land on one frame, and the pin-order guard's strict
+monotonicity breaks for no reason. In `full` this pins the rail almost exactly:
+every claim has a segment, so `scheduleClaims` interpolates almost nothing.
+
+**`status` is retired.** It existed to quote a claim whose source had gone; a
+`ledger` card does the same thing better, alongside the arithmetic the claim moves
+and with the reason coming from the probe.
+
+## The claim rail (`render.rail`)
+
+A cut whose whole point is *which company a number was about* has a problem: the
+dates and the figures are **spoken**, and shown only in the header's citation
+line. A viewer can hear "nearly ten" three times without ever seeing that the
+three refer to three different payrolls.
+
+`render.rail` adds a persistent **vertical ledger down the right edge**. It
+appends one row per claim as the video runs, keeps a live per-company tally
+beside it, and lists **every claim the sweep found** — not just the ones with a
+clip behind them. Claims with no clip are dimmed (muted ink, hollow dot) and pass
+with no audio; they are what stops the rail from implying the cut is the corpus.
+
+It is **entirely opt-in**. With no `render.rail` key the filtergraph is the one
+that was there before, and output is byte-for-byte unchanged.
+
+```jsonc
+"render": {
+ "rail": {
+ "width": 420, // picture shrinks to width - 420
+ "rowHeight": 46, // one claim row; window height is a whole multiple
+ "tallyRowHeight": 44,
+ "tallyTop": 96, // y of the tally block inside the rail column
+ "pad": 22,
+ "slide": 0.55, // seconds per row-change ease
+ "rule": "#2A322F",
+ "tracks": [ // one per company; ORDER is the rail/legend order
+ { "key": "media", "label": "The Quartering · media", "color": "#22AB83" }
+ ]
+ }
+}
+```
+
+and a top-level `ledger[]`, in **playback order** — each track's claims contiguous
+and date-sorted within the track:
+
+```jsonc
+{ "id": "m02", "date": "2022-09-17", "company": "media",
+ "value": 4, // null for a qualitative claim; the tally ignores those
+ "display": "4", // the badge
+ "label": "counts them out: one, two, three, four",
+ "quote": "…", "src": "…",
+ "hedged": false, // a hedge word, not a figure -> hollow dot on the chart
+ "plotted": true, // appears in the step chart
+ "entryId": "a01" } // pins the row to that timeline entry's segment
+```
+
+Pinned entries carry a `claim` back-reference so the link reads both ways.
+
+### How it is put together
+
+Everything that moves is **one tall strip walked by a fixed-size `crop`**, not a
+per-state still, because swapping stills can only cut and a crop can ease. Five
+strips, all bounded `-loop 1 -framerate <fps> -t <total+2>`:
+
+| strip | size | what it is |
+|---|---|---|
+| `_rail_chrome.png` | `RW × RHGT` | opaque panel, title, rules, **and the tally swatches and labels**. Runs the whole video — no `enable=` gates |
+| `_rail_log.png` | `RW × N·rowHeight` | every claim, stacked, no padding |
+| `_rail_curtain.png` | `RW × LOGH` | opaque `pal.bg` |
+| `_rail_hl.png` | `RW × rowHeight` | the amber current-row marker |
+| `_rail_tally.png` | `ΣlaneW × rows·cellH` | one COLUMN per lane — four rolling cells and the roster line |
+| `_rail_qr.png` | `TILEW × segments·TILEH` | one provenance tile per segment |
+
+### The tally rolls one number at a time
+
+It used to be a column of four-row slabs walked by one `crop`: when the coffee
+company's number changed, all four rows moved, and "The Quartering" slid up the
+screen for a reason that had nothing to do with it. Text that has not changed
+must not move.
+
+So the **swatch and the company label went into the static chrome**, and each
+track got its own **rolling cell** — number, delta triangle, `as of <date>` and a
+population chip, right-aligned in a ~170 px column. All the lanes live side by
+side in **one** PNG, so it is still one input: five `crop`s at different `x`, five
+overlays.
+
+**The direction of a roll is decided by the strip's LAYOUT, not by the ramp.**
+
+| | rows | the crop | what you see |
+|---|---|---|---|
+| rise | `[old, new]` | walks **down** | content moves **up** |
+| fall | `[new, old]` | walks **up** | content moves **down** |
+
+Between transitions a one-frame `gte()` step repositions to the next pair's
+starting row. That step is invisible **because both endpoint rows hold identical
+content** — which is why every pair repeats the value it starts from instead of
+sharing a row with its neighbour, and why the delta chip rides on both rows and
+therefore stays on screen until the next change.
+
+```
+y_j(t) = r_j0 + Σ_k [ (a_k − b_{k−1})·gte(t,t_k) + (b_k − a_k)·ease(t_k) ]
+```
+
+Every term is cumulative and saturating — the rail's hard rule.
+
+A repeated identical figure still rolls, upward: he said it again on a new date,
+and the `as of` line underneath is what changed.
+
+### The roster line
+
+A fifth lane under the tally, `2 editors · 1 designer`, in `pal.muted`. It moves
+**only when the rendered line changes**, which in this corpus means it stands
+still through October and December 2023 while the total above it goes from three
+to four. That is the finding, drawn rather than asserted.
+
+It comes from an optional `roles` field on a ledger claim, and `rosterAt()` /
+`rosterLine()` in `ledger-totals.mjs` are the one implementation, because a
+chapter card states the same thing in words.
+
+```jsonc
+"roles": [{ "role": "video editor", "count": 2, "verbatim": "two video editors" }]
+```
+
+`verbatim` is his words; `role` and `count` are our reading. `roles` is **not** one
+of the six adjudication fields — most claims are a number and nothing else, and
+gating the inbox on a field a handful of entries can carry would leave it
+permanently red.
+
+### The QR moved into the rail's foot
+
+It used to float over the bottom-right of the **picture**, which is the one part
+of the frame this cut promises never to draw on. It is now a bordered tile parked
+at the foot of the rail column — `pal.accent` rule, `SCAN → JERALYZER` above the
+code, what it opens below it — and one more strip: one tile per segment,
+crop-walked with **instantaneous `gte()` steps** at segment mid-dissolves. A code
+that eased into place would spend the ease unscannable.
+
+The tile overlays **after the curtain**, which is what stops the parked curtain
+painting over it.
+
+> **A card cannot carry the report's share link.** That link carries all 23
+> channel filters and is ~1.4 k characters: a version-40 symbol, 177 modules in a
+> 132 px tile, about 0.7 px per module. Cards get `provenance.qrLink` — the same
+> query without the channel list, ~200 chars, 63 modules, verified scannable at
+> this size — and fall back to `provenance.siteOrigin`. Manifests with **no**
+> rail keep the old per-clip overlay in `buildClipSegment`, byte for byte.
+
+> **`railGeometry` used to derive its height from `render.footerHeight`.** With
+> the chart band on, the band reserves 200 px and `footerHeight` says 100, so the
+> rail column ran a hundred pixels — about two rows of its log window — past the
+> line every other renderer letterboxes to. It reads `reservedFooterHeight(render)`
+> now, the same fix the closing cards needed for the same reason.
+
+**The curtain is why one strip is enough.** With the window parked at the top
+while the list is still filling, rows `i+1 … K-1` would show claims the video has
+not made yet. The curtain is an opaque rectangle riding just below the last
+revealed row; once the list is full it parks exactly one window-height down,
+which is the bottom of the rail column — permanently outside the window. It is
+`pal.bg` precisely so that parking there is invisible against the footer band.
+Curtain and log **must share the same eased `P`**, or the curtain lags the rows
+mid-slide and unrevealed claims flash into view.
+
+**Ramps are cumulative and saturating, never gated.** A piecewise sum of
+`gte(t,sᵢ)·lt(t,sᵢ₊₁)·…` terms flashes to `y=0` for one frame at any boundary
+gap, because every gate evaluates false at once and the sum collapses. Terms that
+rise to their delta and stay cannot do that.
+
+**Scheduling.** Playback is ONE chronology across every company, and the ledger is
+sorted the same way, so a claim's position in the rail *is* its position in time.
+A claim with a clip behind it is pinned to that clip's segment; a claim on a
+`ledger` card is pinned to its own row's reveal; the rest are spread evenly
+between their neighbouring pins. A pin that runs backwards is refused — the
+ledger and the timeline disagreeing about the order of events is a manifest bug,
+and the whole cut rests on the two agreeing.
+
+Segment-level pins land at **`starts[i] + D/2`** — mid-dissolve, where the picture
+is already crossfading and a ±3-frame error is invisible.
+
+The chain attaches **after the last `xfade` node**, inside the concat pass. `t`
+there is absolute and continuous from 0, and nothing downstream of the last xfade
+is dissolved — so it already has post-pass semantics without a second encode,
+which would re-quantize crf-20 output at exactly the content that hurts most
+(antialiased text on flat colour).
+
+### `--rail-only` and `--preview`
+
+`--rail-only` re-runs just the rail over a cached `out/<slug>.prerail.mp4`,
+building that file from the existing segments the first time. Seconds instead of
+the full concat. The hard-cut base is a separate file
+(`<slug>.prerail-hardcut.mp4`), because the two timelines are different lengths
+and a cached base from the wrong mode is a stale-cache trap the length assertion
+would otherwise have to explain.
+
+**It is mandatory for `--no-xfade`**, not an optimisation: `concatHardCut` is
+`-c copy` and a stream-copy mux cannot host a filtergraph at all. That path
+concats to `.prerail.mp4`, asserts its length against `segmentOffsets().total`,
+and then applies the rail.
+
+`--preview <start> <dur>` renders a window. `-ss` restarts `t` near zero, which
+would put every absolute-time ramp in the wrong place — so the preview path
+inserts `setpts=PTS+<start>/TB` before the rail chain and rebases afterwards.
+Getting that wrong makes a working rail look broken.
+
+## The end sequence: `scroll` and `chart`
+
+Two timeline entry `type`s that exist to close a cut, both driven off the same
+`ledger[]`:
+
+- **`scroll`** — the whole ledger as one tall PNG, walked by an animated `crop`.
+ **One chronological line, a column per company**: it used to group by company,
+ which re-told the cut's own order backwards and hid the only thing worth seeing
+ there — that the four payrolls were being described in the same weeks. Company
+ is read from COLUMN POSITION, so colour is the secondary encoding.
+ `hold` (default 2 s) buys a still moment at both ends;
+ `clip()` in the expression provides it for free, and crop's own clamping
+ degrades an off-by-a-few content height into a static last frame, not an error.
+- **`chart`** — the four-series step chart over the `plotted` claims, authored as
+ SVG and rasterized with `rsvg-convert` (deterministic about output size in a
+ way ImageMagick's RSVG delegate is not). Fonts inside the SVG resolve through
+ **fontconfig, not `render.fontRegular`** — use the family name the Pango cards
+ use.
+
+The chart's wipe **cannot** be `crop=w='<ramp>'`: crop's `w` is config-time and
+`t` is undefined there. It is a curtain instead — an opaque `pal.bg` rectangle
+slid rightwards off the plot, which is exact because the card ground is flat.
+
+**Colour is not the only encoding on that chart, and that is a requirement.** No
+four-colour categorical palette clears the data-viz all-pairs CVD gate (three
+slots is the documented ceiling), so every series also carries a distinct dash
+pattern and a direct end-of-line label with a leader elbow. The four hues are the
+published artifact's, re-validated against this video's darker ground (`#0F1312`)
+on the *adjacent* pairlist — the pairlist for line charts — where all five checks
+pass (worst adjacent CVD ΔE 10.2 against a ≥8 target; normal-vision ΔE 17.4
+against a ≥15 floor).
+
+**`hideRail: true`** on a closing card slides the whole rail column off to the
+right over that card's dissolve — one offset expression shared by every rail
+overlay, so the column moves as one object — and lets the card render at the full
+`width` instead of `contentWidth`. Not an `enable=` pop: a column that vanishes
+between two frames reads as a dropped frame. The slide is cumulative and
+saturating like every other ramp, so the rail does not come back; every card
+after the first `hideRail` one should carry the flag too, or it lays out inside a
+content width whose rail is no longer there.
+
+Both kinds go through the same `fps=,setsar=1` and the same `encodeArgs` as every
+other segment. They have to: `xfade` rejects a mismatched link with *"First input
+link parameters do not match"*, and that surfaces at concat time, after every
+fetch has been paid for.
+
+## The ledger is adjudicated, and both totals depend on it
+
+`ledger[].company` used to be an **undocumented interpretation**, and four
+different hazards were riding on it:
+
+| hazard | example | why it mattered |
+|---|---|---|
+| **scope ambiguity** | *"I have 10 employees, my coffee company employees… my editors"* | all-companies or coffee-only, depending on where the comma falls |
+| **derived, not stated** | *"10 at coffee brand coffee, I've got eight staff for the live stream"* recorded as **18** | he never says 18 |
+| **population drift** | 5 *"full-time salaried"*, 10 *"employees"*, 10 *"all basically contractors"* | different denominators, one series |
+| **synthetic values** | 10.5 for *"about 10 people, 11 people"* | a midpoint we invented and attributed to him |
+
+So every ledger entry now carries six adjudicated fields — `scope`,
+`scopeBasis`, `scopeConfidence`, `population`, `valueKind`, `flags` — settled
+against **±90 s of surrounding context, never the quote alone**. A first-person
+quote is routinely the host reading someone else's words or being sarcastic, and
+neither is visible inside the quote. One claim in this corpus is a guest's
+payroll rather than his, and it reads identically until you listen either side.
+
+`scopeConfidence: "unresolved"` is a legitimate outcome and **feeds neither
+total**.
+
+**The rule that follows:** the *stated* series may contain only **a figure he
+utters as a single number for a named scope**. Sums and midpoints are ours, and
+live in the *implied* series, which says so on screen.
+
+`ledger-totals.mjs` is the one implementation of that arithmetic — the umtool
+inbox, the chart band and the closing card all import it, so none of them can
+disagree. It **refuses to run on an unadjudicated ledger**, because both totals
+lie if you act on one. **Six** named predicates compute incoherence rather than
+asserting it (`contradicts_component`, `same_day_conflict`, `self_negating`,
+`population_mismatch`, `not_his_number`, `status_flip`). Deliberately **not** a
+predicate: a large rise or fall between claims. Fluctuation is the subject, not a
+defect.
+
+`status_flip` is the sixth: the same people described as staff and then as
+contractors, or the reverse. Three details in it are load-bearing.
+
+- **`employees` and `people` are in NEITHER camp.** They are what he says when he
+ is not making a claim about status at all, and reading them as one side or the
+ other manufactures a reversal out of a change of vocabulary.
+- **An `all` claim is comparable with any company; two companies are not
+ comparable with each other.** Without that asymmetry the corpus's clearest
+ reversal is invisible: December 2024's ten are the *channel's*, May 2025's ten
+ or eleven are *everything's*.
+- **Only against the most recent comparable claim that carries a camp.** Fire on
+ every earlier pair and one 2022 "all 1099 and not full-time" flags each of the
+ next seven claims in turn — seven findings where there is one. Bounded this
+ way it fires at the TRANSITIONS, which is what a flip-flop is.
+
+It is not gated on the claim having a figure. *"That's why all my workers are
+contract workers"* names no number and is the single clearest status claim here.
+
+Work the adjudication in umtool, at `/browse/<project>/claim/<id>`. Sign-off is
+"the inbox is empty": `claim-unadjudicated` is **blocking**.
+
+## `render.chromeEngine: "hyperframes"` — the chart band
+
+Opt-in, and absent it the ffmpeg chrome path is byte-for-byte unchanged.
+
+The chart band **replaces** the footer node track, which only moved at section
+handovers — precisely the fault it exists to fix. It takes the footer's ground
+and 100 px more, and the picture loses that height (1500×924 → 1500×824).
+
+`compose-chrome.mjs` emits a HyperFrames project per region and renders it to a
+**lossless RGBA PNG sequence**; `build-video.mjs` overlays the frames. Three
+things about that are load-bearing:
+
+- **Footage never enters Chrome.** HyperFrames pre-extracts source video to JPEG
+ q95, which is unacceptable when the picture *is* the cited evidence. Only
+ chrome is composed there — about 37 % of full-frame pixels rather than 100 %.
+- **The PNG regions overlay BEFORE the rail chain, not after.** The rail chain
+ ends in `format=yuv420p`, and overlaying an alpha sequence onto yuv420p is the
+ same alpha-subsampling trap the rail already documents, one layer later.
+- **The playhead is driven by `out/schedule.json`**, which the build writes.
+ Recomputing claim times here would be a second implementation of
+ `segmentOffsets()` and would drift the first time `transition` changed. Same
+ rule as `widen()`: imported, never reimplemented.
+
+One clip-path sweeps the whole plot rather than a `stroke-dashoffset` per series.
+The obvious build animates each path's dash offset, and it looks right for the
+strokes and wrong for everything else: the gap band between the two totals is a
+filled polygon with no stroke to offset, so it appears whole the moment it fades
+in and the chart is showing an answer the playhead has not reached.
+
+**The flag colour is not the palette's amber.** `#E8A33F` sits at ΔE 12.4 from
+the coffee series' `#D2732F` at *normal* vision — below the 15 floor — so a flag
+badge beside a coffee mark was hard to tell from the coffee mark. `#E0E24A`
+replaces it and adds **no new worst pair**: the worst CVD pair
+(`#C55F9C↔#22AB83`, ΔE 5.4 deutan) and the worst normal-vision pair
+(`#C55F9C↔#D2732F`, ΔE 16.3) are identical with and without it. The implied
+total's `#EDF0EC` fails the categorical lightness and chroma checks *by design* —
+it is an aggregate, not a categorical peer, so it is encoded by weight and
+consumes no palette slot.
+
## Things that cost time to find out
**yt-dlp picks VP9 at `height<=720`, and that is a trap.** `--download-sections`
@@ -318,6 +770,16 @@ usual reason a naive concat produces a broken or audio-desynced file.
a speaker who does not pause gets the unsnapped cut. Forced alignment against
the transcript would be exact; `silencedetect` is a tenth of the work and
handles the cases that were actually audible.
+- **Only the chart band is a HyperFrames region.** `chromeRegions()` returns one
+ entry. The rail and the header are still drawn by the ffmpeg chain, and porting
+ them is the rest of the job — the rail needs the ghost/hop convention (a row
+ with no clip fades in dimmed under a dashed rule and the amber highlight *hops
+ over* it to the next cited row) which the strip builders cannot express. Until
+ then `railFilterChain` and `renderFooterAssets` stay; they must not be retired
+ on the strength of the band alone.
+- **`timelineNodes` / `section` / `sectionEnter` are still live.** They lose their
+ only consumer when the ffmpeg footer goes, not when the band arrives — so they
+ retire with `renderFooterAssets`, in that same commit.
- **Manifests are written by hand** from verified cue data. Deriving a first-draft
manifest automatically from a report's citations is the obvious next step; the
report parse is straightforward (`> "quote"` followed by
diff --git a/scripts/report-to-video/build-video.mjs b/scripts/report-to-video/build-video.mjs
@@ -31,6 +31,7 @@
//
// Options:
// --out <dir> Output root (default: manifest dir + /out)
+// --variant <name> Which cut to build (sourced | full; default sourced)
// --skip-fetch Fail instead of downloading anything not already cached
// --only <id> Build a single entry's segment and stop (for iterating)
// --no-xfade Hard cuts instead of crossfades (much faster; concat copy)
@@ -38,6 +39,9 @@
// --continue-on-error Record a failed entry and carry on, instead of aborting
// --fetch-only <id> Fetch one clip's window into clips-raw and stop
// --pad <s> Override render.fetchPad (the clip bench fetches wide)
+// --no-rail Skip the claim rail even when the manifest configures one
+// --rail-only Re-run just the rail over out/<slug>.prerail.mp4
+// --preview <s> <d> Rail-only, over a <d>-second window starting at <s>
//
// Requires: yt-dlp, ffmpeg/ffprobe, ImageMagick with Pango.
@@ -46,7 +50,11 @@ import { promisify } from "node:util";
import { mkdir, writeFile, readFile, access, readdir, rename } from "node:fs/promises";
import path from "node:path";
-import { renderCard, renderFooterAssets } from "./render-cards.mjs";
+import {
+ renderCard, renderFooterAssets, renderRailAssets, renderScrollCard, renderChartCard,
+ renderLedgerCard, ledgerRevealAt, ledgerSeconds,
+ cardWidth, contentWidth, reservedFooterHeight,
+} from "./render-cards.mjs";
const execFileP = promisify(execFile);
@@ -61,6 +69,77 @@ const CHANNELS_DIR =
const exists = (p) => access(p).then(() => true, () => false);
+// ---- variants ------------------------------------------------------------
+// ONE manifest, two cuts, one filter, applied once.
+//
+// The question the two variants answer differently is what to do with a claim
+// the sweep found but no clip covers. `sourced` refuses to put it on screen at
+// all -- every row the viewer sees has footage behind it. `full` gives each one
+// a slot on a stacked ledger card, so nothing is dropped and the arithmetic of
+// each layer is shown rather than asserted.
+//
+// Both end on the same three numbers. That is the point of shipping both: if
+// the totals moved when the unsourced rows came off, the thesis would rest on
+// rows nobody can check.
+//
+// The filter runs IMMEDIATELY after the manifest is read, and nothing
+// downstream learns about variants. `ledgerTotals`, the rail, the chart band,
+// `scheduleClaims`, the chapters and the scroll already take the ledger and the
+// timeline as inputs, so selecting is the whole of the mechanism.
+export const VARIANTS = ["sourced", "full"];
+/** The cut a caller means when it does not say. `out/<slug>.mp4`. */
+export const DEFAULT_VARIANT = "sourced";
+
+/**
+ * The manifest as one variant sees it.
+ *
+ * Three things happen, in this order:
+ *
+ * 1. A timeline entry tagged `variant` survives only in that variant. (The
+ * stacked ledger cards are `variant: "full"`.)
+ * 2. A claim survives only if the entry it is pinned to survived. That single
+ * rule is what makes `sourced` a sourced-only ledger: in `full` every claim
+ * is pinned -- to a clip or to a ledger card -- so nothing is dropped.
+ * 3. `card.variants[<name>]` field overrides are merged in. The title and
+ * sources cards have to state their own scope honestly, and "50 dated
+ * claims" is simply false in `sourced`.
+ */
+export function selectVariant(manifest, variant = DEFAULT_VARIANT) {
+ if (!VARIANTS.includes(variant)) {
+ throw new Error(`unknown variant \`${variant}\` — one of ${VARIANTS.join(", ")}`);
+ }
+ const timeline = (manifest.timeline ?? [])
+ .filter((e) => !e.variant || e.variant === variant)
+ .map((e) => {
+ if (!e.variants) return e;
+ const { variants, ...rest } = e;
+ return { ...rest, ...(variants[variant] ?? {}) };
+ });
+ const kept = new Set(timeline.map((e) => e.id));
+ const ledger = (manifest.ledger ?? []).filter((c) => c.entryId && kept.has(c.entryId));
+ return { ...manifest, variant, timeline, ledger };
+}
+
+/**
+ * Where a variant's own working files live.
+ *
+ * `clips-raw` stays at the ROOT and is shared: it holds the only expensive
+ * thing in the build (network fetches), and `sourced`'s clips are a subset of
+ * `full`'s, so a shared cache means no clip is ever fetched twice. Everything
+ * else is per-variant, because every one of them differs between the two cuts.
+ *
+ * `sourced` writes `out/<slug>.mp4` -- the path umtool's build probe already
+ * looks for -- and `full` writes `out/<slug>-full.mp4` beside it.
+ */
+export function variantPaths(outRoot, slug, variant) {
+ return {
+ root: outRoot,
+ dir: path.join(outRoot, variant),
+ rawDir: path.join(outRoot, "clips-raw"),
+ final: path.join(outRoot, variant === "sourced" ? `${slug}.mp4` : `${slug}-${variant}.mp4`),
+ };
+}
+
// ---- progress protocol ---------------------------------------------------
// This has two audiences: a human watching a terminal, and umtool's build driver
// reading the pipe. Rather than have the driver scrape prose (which would make
@@ -154,7 +233,26 @@ async function videoMeta(videoId, channelSlug) {
return { title: d.title, uploadDate: d.uploadDate, webpageUrl: d.webpageUrl, duration: d.duration };
}
-async function probeDuration(file) {
+// The CONTAINER's duration is max(video, audio), and the audio is longer: the
+// AAC encoder pads the front with ~21 ms of decoder delay, and a video duration
+// is rarely an exact multiple of the frame interval. Either way the excess is
+// small — and it ACCUMULATES through segmentOffsets, which subtracts one
+// transition per segment and hands the result to xfade, the chapter marks and
+// (now) the rail. A few hundred ms of drift by segment 20 is enough to land a
+// rail row-change on the wrong side of a cut.
+//
+// The video stream's frame COUNT is the number the timeline actually runs on,
+// so derive the duration from it. nb_frames is absent on some demuxers; fall
+// back to the container rather than failing a build over a probe.
+async function probeDuration(file, fps) {
+ if (fps) {
+ const { stdout } = await execFileP(FFPROBE, [
+ "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=nb_frames",
+ "-of", "default=nw=1:nk=1", file,
+ ]);
+ const n = Number(stdout.trim());
+ if (Number.isFinite(n) && n > 0) return n / fps;
+ }
const { stdout } = await execFileP(FFPROBE, [
"-v", "error", "-show_entries", "format=duration",
"-of", "default=nw=1:nk=1", file,
@@ -215,7 +313,7 @@ export async function findContainingWindow(rawDir, video, from, to) {
return best;
}
-async function fetchClip(entry, meta, render, outDir, opts) {
+async function fetchClip(entry, meta, render, rawDir, opts) {
// Deliberately over-fetch: the snapping pass below needs room on both sides to
// find a silence, and a clip that has no slack can only be cut where the cue
// happened to break — which is what put words in half in the first place.
@@ -223,7 +321,8 @@ async function fetchClip(entry, meta, render, outDir, opts) {
const from = Math.max(0, entry.start - pad);
const to = entry.end + pad;
- const rawDir = path.join(outDir, "clips-raw");
+ // Shared across variants, and deliberately so: this is the only expensive
+ // thing in a build, and the two cuts overlap almost entirely.
const name = `${entry.video}_${from.toFixed(2)}-${to.toFixed(2)}.mp4`;
const dest = path.join(rawDir, name);
if (await exists(dest)) {
@@ -386,8 +485,22 @@ const encodeArgs = (render) => [
"-movflags", "+faststart",
];
-async function buildClipSegment(entry, meta, render, outDir, opts, chrome, nodes, provenance) {
- const { path: raw, fetchStart } = await fetchClip(entry, meta, render, outDir, opts);
+// The rail pass runs over an ALREADY ENCODED file, so its audio is already the
+// finished AAC. Re-encoding it would cost a whole generation for nothing — and
+// would make "the rail does not touch the audio" untrue.
+const encodeArgsVideoOnly = (render) => [
+ "-c:v", "libx264",
+ "-preset", render.preset ?? "medium",
+ "-crf", String(render.crf ?? 20),
+ "-pix_fmt", "yuv420p",
+ "-r", String(render.fps),
+ "-c:a", "copy",
+ "-movflags", "+faststart",
+];
+
+async function buildClipSegment(entry, meta, render, dirs, opts, chrome, nodes, provenance) {
+ const outDir = dirs.dir;
+ const { path: raw, fetchStart } = await fetchClip(entry, meta, render, dirs.rawDir, opts);
const seg = path.join(outDir, "segments", `${entry.id}.mp4`);
const pal = render.palette;
const { width, height } = render;
@@ -422,6 +535,9 @@ async function buildClipSegment(entry, meta, render, outDir, opts, chrome, nodes
// between a thin citation header and a thin timeline footer, so the source
// material plays unobstructed and the additions stay subtle.
const HH = render.headerHeight ?? 56;
+ // The picture lives left of the rail column; the rail's own pixels are painted
+ // by the rail chain at concat time, over ground this pad leaves for it.
+ const VW = contentWidth(render);
const FH = chrome.footerHeight;
const hasFooter = FH > 0 && chrome.footer;
// headerHeight:0 drops the citation line too, leaving the clips alone on screen.
@@ -440,12 +556,28 @@ async function buildClipSegment(entry, meta, render, outDir, opts, chrome, nodes
// quotes around the expression is what protects them.
const ramp = (a, b) =>
a === b ? String(b) : `'if(lt(t,${T}),${a}+(${b}-${a})*t/${T},${b})'`;
- const fillW = ramp(xFrom - chrome.x0, xTo - chrome.x0);
const markX = ramp(xFrom - chrome.markerRadius, xTo - chrome.markerRadius);
+ // The fill bar CANNOT be a drawbox with a `t`-dependent width. drawbox has no
+ // time variable at all: its `t` is the box THICKNESS, and with `t=fill` that
+ // is effectively INT_MAX, so the old `if(lt(t,0.9),…)` was always false and
+ // the bar was always drawn at its final width. (Proof: `drawbox=w='t*10'` and
+ // `drawbox=w=20` produce an identical YAVG.) Only the amber marker ever moved.
+ //
+ // So do it the way the rail does: a 2*LEN-wide strip, accent on the left half
+ // and transparent on the right, translated under a fixed-width crop. crop's
+ // x IS per-frame in `t`, and it clamps, so the ends are self-parking.
+ const fillA = xFrom - chrome.x0;
+ const fillB = xTo - chrome.x0;
+ const fillExpr = fillA === fillB
+ ? String(fillB)
+ : `${fillA}+(${fillB - fillA})*clip(t/${T},0,1)`;
+
const base = [
- `scale=${width}:${VH}:force_original_aspect_ratio=decrease`,
- `pad=${width}:${VH}:(ow-iw)/2:(oh-ih)/2:color=${pal.bg}`,
+ `scale=${VW}:${VH}:force_original_aspect_ratio=decrease`,
+ `pad=${VW}:${VH}:(ow-iw)/2:(oh-ih)/2:color=${pal.bg}`,
+ // Widen back to the full frame, leaving the rail column (if any) as ground.
+ `pad=${width}:${VH}:0:0:color=${pal.bg}`,
`pad=${width}:${height}:0:${HH}:color=${pal.bg}`,
"setsar=1",
`fps=${render.fps}`,
@@ -464,23 +596,52 @@ async function buildClipSegment(entry, meta, render, outDir, opts, chrome, nodes
: []),
].join(",");
- const qr = render.qr === false ? null : await qrForEntry(entry, provenance, render, outDir);
- const qrIdx = hasFooter ? 3 : 1;
+ // A manifest with a RAIL draws the code in the rail's foot instead, as one
+ // more strip: bottom-right of the frame, bordered, one per clip. It used to
+ // float over the bottom-right of the picture — the one part of the frame this
+ // cut promises never to draw on. Manifests with no rail keep the old overlay,
+ // byte for byte.
+ const qr =
+ render.qr === false || render.rail ? null : await qrForEntry(entry, provenance, render, outDir);
const qrM = render.qr?.margin ?? 28;
+ // Bound the bar strip SHORTER than the clip. An overlay secondary that outruns
+ // the main extends the output, and the fix for that (shortest=1) would instead
+ // truncate the clip to the strip. Ending early is free: overlay's default
+ // eof_action=repeat holds the strip's last frame, which is the parked bar.
+ const barT = Math.max(0.2, cutB - cutA - 0.25);
+
+ const inputs = ["-ss", cutA.toFixed(3), "-to", cutB.toFixed(3), "-i", raw];
+ let nextIdx = 1;
+ let footerIdx, markerIdx, barIdx, qrIdx;
+ if (hasFooter) {
+ footerIdx = nextIdx++; inputs.push("-i", chrome.footer);
+ markerIdx = nextIdx++; inputs.push("-i", chrome.marker);
+ // A PNG fed with a plain -i through an ANIMATED crop is frozen: the crop
+ // sees one frame at t=0 and repeatlast repeats the already-cropped result.
+ // -loop 1 -framerate is what makes the strip a video the crop can walk.
+ barIdx = nextIdx++;
+ inputs.push(
+ "-loop", "1", "-framerate", String(render.fps), "-t", barT.toFixed(3),
+ "-i", chrome.bar,
+ );
+ }
+ if (qr) { qrIdx = nextIdx++; inputs.push("-i", qr.png); }
+
const parts = hasFooter
? [
`[0:v]${base}[b]`,
- `[b][1:v]overlay=0:${height - FH}[f]`,
- `[f]drawbox=x=${chrome.x0}:y=${trackAbsY - 1}:w=${fillW}:h=3:color=${pal.accent}:t=fill[g]`,
- `[g][2:v]overlay=x=${markX}:y=${trackAbsY - chrome.markerRadius}[q]`,
+ `[b][${footerIdx}:v]overlay=0:${height - FH}[f]`,
+ `[${barIdx}:v]crop=w=${chrome.trackLen}:h=3:x='${chrome.trackLen}-(${fillExpr})':y=0[bar]`,
+ `[f][bar]overlay=x=${chrome.x0}:y=${trackAbsY - 1}[g]`,
+ `[g][${markerIdx}:v]overlay=x=${markX}:y=${trackAbsY - chrome.markerRadius}[q]`,
]
: [`[0:v]${base}[q]`];
// Sit above the footer when there is one, so the code never straddles the chrome.
parts.push(
qr
- ? `[q][${qrIdx}:v]overlay=x=W-w-${qrM}:y=H-h-${FH + qrM}[v]`
+ ? `[q][${qrIdx}:v]overlay=x=${VW}-w-${qrM}:y=H-h-${FH + qrM}[v]`
: `[q]null[v]`,
);
@@ -488,9 +649,7 @@ async function buildClipSegment(entry, meta, render, outDir, opts, chrome, nodes
FFMPEG,
[
"-nostdin", "-v", "error", "-y",
- "-ss", cutA.toFixed(3), "-to", cutB.toFixed(3), "-i", raw,
- ...(hasFooter ? ["-i", chrome.footer, "-i", chrome.marker] : []),
- ...(qr ? ["-i", qr.png] : []),
+ ...inputs,
"-filter_complex", parts.join(";"),
"-map", "[v]", "-map", "0:a",
...encodeArgs(render),
@@ -540,7 +699,10 @@ async function buildCardSegment(card, render, outDir, nodes) {
FFMPEG,
[
"-nostdin", "-v", "error", "-y",
- "-loop", "1", "-t", dur, "-i", png,
+ // Without -framerate the image demuxer runs at its 25 fps default and the
+ // `-vf fps=30` below DUPLICATES a frame — at the segment's first frame,
+ // which is exactly where the next xfade seam lands.
+ "-loop", "1", "-framerate", String(render.fps), "-t", dur, "-i", png,
"-f", "lavfi", "-t", dur,
"-i", `anullsrc=channel_layout=stereo:sample_rate=${render.audioRate}`,
"-vf", `fps=${render.fps},setsar=1`,
@@ -553,13 +715,580 @@ async function buildCardSegment(card, render, outDir, nodes) {
return seg;
}
+// ===========================================================================
+// The claim rail
+// ===========================================================================
+// A persistent vertical ledger down the right edge, appending one row per claim
+// as the video runs. It is folded into the concat pass rather than added as a
+// second encode: the chain attaches AFTER the final xfade node, which already
+// has post-pass semantics (nothing downstream of the last xfade is dissolved,
+// and `t` there is absolute and continuous from 0). A separate pass would
+// re-quantize crf-20 output, and antialiased text on flat colour is exactly the
+// content that costs most.
+//
+// Five hard-won rules are load-bearing here; breaking any one produces a hang,
+// a silently wrong-length file or a frozen overlay:
+//
+// 1. crop's w/h are CONFIG-TIME (`t` is undefined there) but x/y are
+// per-frame. So every moving part is a fixed-size window walking a strip.
+// 2. crop clamps x/y into range, so over-scroll is safe and self-parking.
+// 3. A PNG on a plain -i through an animated crop is FROZEN. Every strip
+// needs `-loop 1 -framerate <fps> -t <bound>`.
+// 4. An UNBOUNDED `-loop 1` input deadlocks ffmpeg once several are chained.
+// Hence `-t` on all five.
+// 5. An overlay secondary longer than the main EXTENDS the output. The strips
+// are deliberately longer (bound = total + 2), so `shortest=1` is required
+// on EVERY rail overlay, not just the first.
+//
+// And two rendering ones: overlay's default `format=yuv420` subsamples alpha as
+// well as chroma, which fringes 14–23 px rail text — so every rail overlay is
+// `format=yuv444`, with a single `format=yuv420p` before the encoder.
+
+/**
+ * Fill an evenly-spaced schedule between known anchors.
+ *
+ * `known` holds the entries that are pinned to a segment; everything else is
+ * distributed linearly between its neighbouring pins, with `lo`/`hi` acting as
+ * virtual anchors just outside the run.
+ */
+function distribute(known, n, lo, hi) {
+ const at = new Array(n).fill(null);
+ for (const [i, t] of known) at[i] = t;
+ const pts = [[-1, lo], ...[...known].sort((a, b) => a[0] - b[0]), [n, hi]];
+ for (let k = 0; k < pts.length - 1; k += 1) {
+ const [a, ta] = pts[k];
+ const [b, tb] = pts[k + 1];
+ for (let j = a + 1; j < b; j += 1) at[j] = ta + ((tb - ta) * (j - a)) / (b - a);
+ }
+ return at;
+}
+
+/**
+ * When each ledger row appears, in finished-timeline seconds.
+ *
+ * ONE CHRONOLOGY. The cut plays in date order across every company, and the
+ * ledger is sorted the same way, so a claim's position in the rail IS its
+ * position in time. That collapses what used to live here: there is no longer a
+ * per-company span to bound a claim to, no contiguity rule to enforce, and no
+ * risk of scheduling a media claim over a coffee clip -- because "over a coffee
+ * clip" now means "later in the same chronology", which is exactly right.
+ *
+ * What remains is the part that was always doing the work: a claim WITH a clip
+ * behind it is pinned to that clip's segment, and the rest are spread evenly
+ * between their neighbouring pins. Monotonicity holds by construction, since
+ * both the pins and the rows are in date order.
+ *
+ * Every state change lands at `starts[i] + D/2` -- MID-DISSOLVE -- where the
+ * picture is already crossfading and a ±3-frame error is invisible.
+ */
+export function scheduleClaims(ledger, entries, starts, D, endBound) {
+ const segOf = new Map(entries.map((e, i) => [e.id, i]));
+ const mid = (seg) => starts[seg] + D / 2;
+
+ // A stacked ledger card carries several claims, and each one has a MOMENT
+ // inside that card: the reveal of its own row. Pinning all of them to the
+ // segment's mid-dissolve would land four rail rows on one frame and, worse,
+ // break the pin-order guard's strict monotonicity for no reason. So a claim
+ // on such a card is pinned to its own row's reveal.
+ const within = new Map();
+ for (const e of entries) {
+ if (e.type !== "ledger") continue;
+ (e.claims ?? []).forEach((cid, r) => within.set(`${e.id}|${cid}`, ledgerRevealAt(r)));
+ }
+
+ const known = new Map();
+ ledger.forEach((c, i) => {
+ const seg = c.entryId ? segOf.get(c.entryId) : undefined;
+ if (seg === undefined) return;
+ const off = within.get(`${c.entryId}|${c.id}`);
+ known.set(i, off === undefined ? mid(seg) : starts[seg] + off);
+ });
+ if (!known.size) throw new Error("ledger: no claim is pinned to a clip, so nothing anchors the rail");
+
+ // A pin that runs backwards means the ledger and the timeline disagree about
+ // the order of events, which is a manifest bug rather than something to
+ // silently smooth over -- the whole cut rests on the two agreeing.
+ const pins = [...known].sort((a, b) => a[0] - b[0]);
+ for (let i = 1; i < pins.length; i += 1) {
+ if (pins[i][1] <= pins[i - 1][1]) {
+ throw new Error(
+ `ledger: ${ledger[pins[i][0]].id} is pinned to ${ledger[pins[i][0]].entryId}, which plays ` +
+ `before ${ledger[pins[i - 1][0]].id}'s clip — the ledger is not in the cut's order`,
+ );
+ }
+ }
+
+ // The first card is the title; the rail's own run opens just after it.
+ const times = distribute(known, ledger.length, mid(0), endBound);
+
+ for (let i = 1; i < times.length; i += 1) {
+ if (times[i] <= times[i - 1]) times[i] = times[i - 1] + 1 / 30;
+ }
+ return times;
+}
+
+/**
+ * The rail's filtergraph, as one builder with two call sites — the concat pass
+ * and `--rail-only` — so the two paths cannot drift.
+ *
+ * Ramps are CUMULATIVE AND SATURATING, never gated. A piecewise sum of
+ * `gte(t,s)*lt(t,s')*…` terms flashes to y=0 for one frame at any boundary gap,
+ * because every gate evaluates false at once and the sum collapses. Terms that
+ * rise to their delta and stay there cannot do that.
+ */
+export function railFilterChain(rail, assets, times, render, inLabel, firstInputIdx, bound, opts = {}) {
+ const g = assets.geom;
+ const SLIDE = rail.slide ?? 0.55;
+ const fps = render.fps;
+
+ const P = (s) => `clip((t-${s.toFixed(3)})/${SLIDE},0,1)`;
+ // smoothstep() does not exist in ffmpeg's expression language. This is it.
+ const ease = (s) => { const p = P(s); return `${p}*${p}*(3-2*${p})`; };
+ // Signed, and explicitly so. Joining terms with "+" was fine while every
+ // delta was a positive row height; a rolling cell FALLS as often as it rises,
+ // and `…+-40*x` is at best relying on ffmpeg's unary minus.
+ const sum = (y0, terms) =>
+ terms.reduce(
+ (acc, t) => `${acc}${t.d < 0 ? "-" : "+"}${Math.abs(t.d)}*${t.f}`,
+ String(y0),
+ );
+ const ramp = (y0, steps) =>
+ sum(y0, steps.filter((st) => st.delta !== 0).map((st) => ({ d: st.delta, f: ease(st.at) })));
+
+ const { K, ROWH, RW, RX, RTOP, LOGH, LOGTOP, TALLYTOP } = g;
+
+ const logY = ramp(0, times.map((t, i) => ({ at: t, delta: i + 1 > K ? ROWH : 0 })));
+ // The curtain and the log MUST share the same eased P, or the curtain visibly
+ // lags the rows mid-slide and unrevealed claims flash into view.
+ const curtainY = ramp(LOGTOP, times.map((t, i) => ({ at: t, delta: i + 1 <= K ? ROWH : 0 })));
+ const hlY = ramp(LOGTOP, times.map((t, i) => ({ at: t, delta: i > 0 && i < K ? ROWH : 0 })));
+
+ /**
+ * One lane's y, in the strip's own pixels.
+ *
+ * y(t) = r0 + Σ_k [ (a_k − b_{k−1})·gte(t,t_k) + (b_k − a_k)·ease(t_k) ]
+ *
+ * The first term is the instantaneous reposition to the next pair's starting
+ * row; the second is the roll itself. Both are CUMULATIVE AND SATURATING,
+ * which is the rail's hard rule: a gated piecewise sum flashes to y=0 for one
+ * frame at any boundary gap, because every gate goes false at once.
+ */
+ const laneY = (lane) => {
+ const terms = [];
+ let prevB = 0;
+ lane.steps.forEach((st, i) => {
+ if (!st) return;
+ const at = times[i];
+ const jump = (st.a - prevB) * lane.cellH;
+ const roll = (st.b - st.a) * lane.cellH;
+ if (jump !== 0) terms.push({ d: jump, f: `gte(t,${at.toFixed(3)})` });
+ if (roll !== 0) terms.push({ d: roll, f: ease(at) });
+ prevB = st.b;
+ });
+ return sum(0, terms);
+ };
+
+ // The rail leaves by SLIDING OFF to the right, not by an enable= pop. One
+ // offset expression shared by every overlay, so the column moves as one
+ // object; `overlay`'s x is per-frame in `t`, which is what makes that
+ // possible at all. Cumulative and saturating, like everything else here.
+ const hideAt = opts.hideAt ?? null;
+ const OFF = hideAt == null ? "" : `+${RW + 8}*${ease(hideAt)}`;
+ const X = (x) => (OFF ? `'${x}${OFF}'` : String(x));
+
+ const i0 = firstInputIdx;
+ const files = [assets.chrome, assets.log, assets.curtain, assets.hl, assets.tally];
+ if (assets.qr) files.push(assets.qr.path);
+ const inputs = files.flatMap((f) => [
+ "-loop", "1", "-framerate", String(fps), "-t", bound.toFixed(3), "-i", f,
+ ]);
+
+ const chain = [
+ `[${i0 + 1}:v]crop=w=${RW}:h=${LOGH}:x=0:y='${logY}'[rlog]`,
+ `${inLabel}[${i0}:v]overlay=x=${X(RX)}:y=${RTOP}:format=yuv444:shortest=1[rr0]`,
+ `[rr0][rlog]overlay=x=${X(RX)}:y=${LOGTOP}:format=yuv444:shortest=1[rr1]`,
+ // The highlight goes UNDER the curtain: while the list is still filling, the
+ // row it marks has not been revealed yet, and the curtain is what hides it.
+ `[rr1][${i0 + 3}:v]overlay=x=${X(RX)}:y='${hlY}':format=yuv444:shortest=1[rr2]`,
+ `[rr2][${i0 + 2}:v]overlay=x=${X(RX)}:y='${curtainY}':format=yuv444:shortest=1[rr3]`,
+ ];
+
+ // One crop per lane out of the SINGLE tally strip. Four numbers that roll
+ // independently and a roster line that mostly does not, for one more input
+ // than the slab cost.
+ //
+ // `split` first, and it is NOT optional: a filtergraph link may be consumed
+ // exactly once, so five crops reading `[N:v]` is a parse error, not a
+ // shortcut. This is the whole reason the lanes share one PNG and still cost
+ // one input.
+ chain.push(
+ `[${i0 + 4}:v]split=${assets.lanes.length}${assets.lanes.map((_, j) => `[ts${j}]`).join("")}`,
+ );
+ let lab = "[rr3]";
+ assets.lanes.forEach((lane, j) => {
+ const isRoster = lane.kind === "roster";
+ const h = isRoster ? g.ROSTERH : g.TALLYROWH;
+ const y = isRoster ? g.ROSTERTOP : TALLYTOP + j * g.TALLYROWH;
+ const x = isRoster ? RX + g.ROSTERX : RX + g.CELLX;
+ chain.push(
+ `[ts${j}]crop=w=${lane.w}:h=${h}:x=${lane.x}:y='${laneY(lane)}'[rc${j}]`,
+ `${lab}[rc${j}]overlay=x=${X(x)}:y=${y}:format=yuv444:shortest=1[rt${j}]`,
+ );
+ lab = `[rt${j}]`;
+ });
+
+ // The provenance tile LAST, so the parked curtain cannot paint over it.
+ if (assets.qr) {
+ const qrY = sum(0, assets.qr.steps.map((st) => ({ d: st.delta, f: `gte(t,${st.at.toFixed(3)})` })));
+ chain.push(
+ `[${i0 + 5}:v]crop=w=${g.TILEW}:h=${g.TILEH}:x=0:y='${qrY}'[rqr]`,
+ `${lab}[rqr]overlay=x=${X(RX + g.PAD)}:y=${g.TILETOP}:format=yuv444:shortest=1[rq]`,
+ );
+ lab = "[rq]";
+ }
+
+ chain.push(`${lab}format=yuv420p[vout]`);
+
+ return { inputs, chain: chain.join(";"), outLabel: "[vout]" };
+}
+
+/**
+ * The chrome as PNG-sequence overlays, for `render.chromeEngine: "hyperframes"`.
+ *
+ * OPT-IN, and absent it nothing below runs -- the ffmpeg chrome path is left
+ * byte-for-byte alone, which is the same bargain the rail was added under.
+ *
+ * The five ffmpeg traps the rail documents apply here unchanged, and two of them
+ * bite harder with an image sequence:
+ *
+ * * `format=yuv444` on EVERY overlay. overlay's default yuv420 subsamples
+ * ALPHA as well as chroma, which fringes small text -- and the band is
+ * nothing but small text.
+ * * `shortest=1` on EVERY overlay. A secondary longer than the main EXTENDS
+ * the output; the sequence is rendered to the same length as the concat, but
+ * a one-frame rounding difference either way must not change the duration.
+ * * One `format=yuv420p` before the encoder, once, at the end.
+ *
+ * A finite image sequence needs no `-t`: unlike `-loop 1` it ends by itself, so
+ * the deadlock the rail's five chained loops hit cannot happen here.
+ */
+export function chromeOverlayChain(render, regions, inLabel, firstInputIdx, opts = {}) {
+ const { outLabel = "[hfout]", final = true } = opts;
+ const inputs = [];
+ const parts = [];
+ let lab = inLabel;
+ regions.forEach((r, i) => {
+ inputs.push(
+ "-framerate", String(render.fps),
+ "-start_number", "1",
+ "-i", path.join(r.frames, "frame_%06d.png"),
+ );
+ const idx = firstInputIdx + i;
+ const last = i === regions.length - 1;
+ const out = last && !final ? outLabel : `[hf${i}]`;
+ parts.push(`${lab}[${idx}:v]overlay=x=${r.x}:y=${r.y}:format=yuv444:shortest=1${out}`);
+ lab = out;
+ });
+ if (final) parts.push(`${lab}format=yuv420p[vout]`);
+ return {
+ inputs,
+ chain: parts.join(";"),
+ outLabel: final ? "[vout]" : outLabel,
+ count: regions.length,
+ };
+}
+
+/**
+ * Where each rendered chrome region sits in the frame.
+ *
+ * The chart band REPLACES the footer node track rather than joining it: the
+ * track only moved at section handovers, which is precisely the fault the band
+ * exists to fix. So it takes the footer's ground and 100px more of it, and the
+ * picture loses that height.
+ */
+export function chromeRegions(render, outDir) {
+ const H = render.chart?.height ?? 200;
+ return [
+ {
+ name: "chart",
+ frames: path.join(outDir, "chrome", "chart-frames"),
+ x: 0,
+ y: render.height - H,
+ width: contentWidth(render),
+ height: H,
+ },
+ ];
+}
+
+/**
+ * The footer's stand-in when the chrome is drawn in a browser.
+ *
+ * It reserves the band's HEIGHT and draws nothing, so every segment letterboxes
+ * to the same picture box the overlay expects and the ground under the band is
+ * the palette background. `footer: null` is what switches the whole ffmpeg
+ * footer -- image, marker and fill bar -- off; the degenerate shape is the one
+ * renderFooterAssets already returns for a manifest with no nodes, so this path
+ * is not new.
+ */
+function reservedFooter(render) {
+ return {
+ footer: null, marker: null, bar: null, trackLen: 0,
+ footerHeight: render.chart?.height ?? 200,
+ trackY: 0, xs: [], x0: 0, markerRadius: 0,
+ };
+}
+
+/**
+ * Everything the rail chain needs that depends on the built segments. Returns
+ * null when the manifest does not ask for a rail — which is what keeps this
+ * whole feature opt-in and every existing report byte-for-byte unchanged.
+ */
+async function buildRailPlan(manifest, render, entries, segments, D, outDir) {
+ const rail = render.rail;
+ if (!rail || !manifest.ledger?.length) return null;
+ const { starts, total } = await segmentOffsets(segments, D, render.fps);
+ const assets = await renderRailAssets(
+ render, manifest.ledger, outDir, entries, manifest.provenance,
+ );
+ // Every claim must be on the board before the closing ledger scroll reads it
+ // back, so the last section's spare rows are spread up to that segment.
+ const endIdx = entries.findIndex((e) => e.type === "scroll" || e.type === "chart");
+ const endBound = endIdx > 0 ? starts[endIdx] : total;
+ const times = scheduleClaims(manifest.ledger, entries, starts, D, endBound);
+
+ // The QR tile changes at the MID-DISSOLVE of every segment, instantaneously
+ // — a code that eased into place would spend the ease unscannable, and the
+ // picture is already crossfading there.
+ if (assets.qr) {
+ assets.qr.steps = entries.slice(1).map((_, i) => ({
+ at: starts[i + 1] + D / 2,
+ delta: assets.geom.TILEH,
+ }));
+ }
+
+ // Where the rail leaves. The closing ledger is a full-width card and the rail
+ // is the one thing on screen it would have to be read around, so the column
+ // slides off over that card's dissolve and does not come back.
+ const hideIdx = entries.findIndex((e) => e.hideRail);
+ const hideAt = hideIdx > 0 ? starts[hideIdx] : null;
+
+ // The schedule, written down.
+ //
+ // The chart band has to sweep in step with the rail -- a playhead that tracks
+ // the current moment is the whole point of it -- and the only way it and the
+ // rail can be guaranteed to agree is for one of them to compute the schedule
+ // and the other to READ it. Recomputing from segment durations would be a
+ // second implementation of segmentOffsets(), and it would drift the first time
+ // the crossfade changed. Same rule as widen(): imported, never reimplemented.
+ await writeFile(
+ path.join(outDir, "schedule.json"),
+ JSON.stringify(
+ {
+ fps: render.fps,
+ transition: D,
+ total,
+ endBound,
+ segments: entries.map((e, i) => ({ id: e.id, type: e.type, start: starts[i] })),
+ claims: manifest.ledger.map((c, i) => ({ id: c.id, at: times[i], entryId: c.entryId ?? null })),
+ },
+ null,
+ 2,
+ ) + "\n",
+ );
+
+ EMIT("note", {
+ message: `rail: ${manifest.ledger.length} claims, ${times.filter((_, i) => manifest.ledger[i].entryId).length} pinned, ` +
+ `window ${assets.geom.K} rows` + (hideAt == null ? "" : `, hides at ${hideAt.toFixed(1)}s`),
+ });
+ return { assets, times, total, hideAt };
+}
+
+// ---- the end sequence ----------------------------------------------------
+// Two segment kinds that exist only to close the cut: the whole ledger read
+// back in one scroll, then the same claims plotted. Both go through the SAME
+// `fps=,setsar=1` and the SAME encodeArgs as every other segment — xfade
+// rejects a mismatched link with "First input link parameters do not match",
+// which would surface only at concat time, after every fetch has been paid for.
+
+async function buildScrollSegment(card, render, outDir, ledger) {
+ const { path: png, contentHeight, width: VW } = await renderScrollCard(card, render, ledger, outDir);
+ const seg = path.join(outDir, "segments", `${card.id}.mp4`);
+ const pal = render.palette;
+ const { width, height } = render;
+ const HH = render.headerHeight ?? 56;
+ // The band's height, not the manifest's footerHeight — otherwise the last
+ // 100px of the scroll play underneath the chart.
+ const FH = reservedFooterHeight(render);
+ const winH = height - HH - FH;
+ const dur = String(card.seconds);
+
+ // clip() buys a free hold at BOTH ends, and crop's own clamping degrades an
+ // off-by-a-few contentHeight into a static last frame rather than an error.
+ const hold = card.hold ?? 2.0;
+ const travel = Math.max(0, contentHeight - winH);
+ const denom = Math.max(0.1, card.seconds - 2 * hold);
+
+ await execFileP(
+ FFMPEG,
+ [
+ "-nostdin", "-v", "error", "-y",
+ "-loop", "1", "-framerate", String(render.fps), "-t", dur, "-i", png,
+ "-f", "lavfi", "-t", dur, "-i", `color=c=${pal.bg}:s=${width}x${height}:r=${render.fps}`,
+ "-f", "lavfi", "-t", dur,
+ "-i", `anullsrc=channel_layout=stereo:sample_rate=${render.audioRate}`,
+ "-filter_complex", [
+ `[0:v]crop=w=${VW}:h=${winH}:x=0:y='${travel}*clip((t-${hold})/${denom.toFixed(3)},0,1)'[win]`,
+ `[1:v][win]overlay=x=0:y=${HH}:shortest=1,fps=${render.fps},setsar=1[v]`,
+ ].join(";"),
+ "-map", "[v]", "-map", "2:a",
+ ...encodeArgs(render),
+ "-shortest",
+ seg,
+ ],
+ { maxBuffer: 1 << 24 },
+ );
+ return seg;
+}
+
+/**
+ * A stacked ledger card: rows revealed in sequence by a walking curtain.
+ *
+ * The curtain is an opaque `pal.bg` rectangle that starts covering every row
+ * and steps down one row-height per reveal. Same device as the rail's, and for
+ * the same reason: the card ground is flat, so an opaque rectangle over it is
+ * an exact in-place wipe with no per-pixel filter.
+ *
+ * The ramp is CUMULATIVE AND SATURATING, like every other ramp here.
+ */
+async function buildLedgerSegment(card, render, outDir, ledger, avail) {
+ const geo = await renderLedgerCard(card, render, ledger, outDir, avail);
+ const seg = path.join(outDir, "segments", `${card.id}.mp4`);
+ const pal = render.palette;
+ const { width, height } = render;
+ // `seconds` is DERIVED, not authored: the pins that land claims on their own
+ // rows read the same clock, so a hand-set duration would silently move them.
+ const dur = String(card.seconds ?? ledgerSeconds(geo.rows));
+
+ const curtainH = height;
+ const curtain = path.join(outDir, "cards", `${card.id}.curtain.png`);
+ await execFileP("magick", [
+ "-size", `${geo.width}x${curtainH}`, `xc:${pal.bg}`, curtain,
+ ]);
+
+ const SLIDE = render.rail?.slide ?? 0.55;
+ const ease = (at) => {
+ const p = `clip((t-${at.toFixed(3)})/${SLIDE},0,1)`;
+ return `${p}*${p}*(3-2*${p})`;
+ };
+ const y = [
+ String(geo.rowsTop),
+ ...Array.from({ length: geo.rows }, (_, r) => `${geo.rowHeight}*${ease(ledgerRevealAt(r))}`),
+ ].join("+");
+
+ await execFileP(
+ FFMPEG,
+ [
+ "-nostdin", "-v", "error", "-y",
+ "-f", "lavfi", "-t", dur, "-i", `color=c=${pal.bg}:s=${width}x${height}:r=${render.fps}`,
+ "-loop", "1", "-framerate", String(render.fps), "-t", dur, "-i", geo.path,
+ "-loop", "1", "-framerate", String(render.fps), "-t", dur, "-i", curtain,
+ "-f", "lavfi", "-t", dur,
+ "-i", `anullsrc=channel_layout=stereo:sample_rate=${render.audioRate}`,
+ "-filter_complex", [
+ `[0:v][1:v]overlay=x=0:y=0:shortest=1[a]`,
+ `[a][2:v]overlay=x=0:y='${y}':shortest=1,fps=${render.fps},setsar=1[v]`,
+ ].join(";"),
+ "-map", "[v]", "-map", "3:a",
+ ...encodeArgs(render),
+ "-shortest",
+ seg,
+ ],
+ { maxBuffer: 1 << 24 },
+ );
+ return seg;
+}
+
+async function buildChartSegment(card, render, outDir, ledger) {
+ const chart = await renderChartCard(card, render, ledger, outDir);
+ const seg = path.join(outDir, "segments", `${card.id}.mp4`);
+ const pal = render.palette;
+ const { width, height } = render;
+ const VW = cardWidth(card, render);
+ const dur = String(card.seconds);
+
+ // The wipe CANNOT be `crop=w='<ramp>'` — crop's w is config-time and `t` is
+ // undefined there ("Error when evaluating the expression"). So: overlay the
+ // finished chart, then slide an opaque pal.bg rectangle rightwards off it.
+ // The card ground is flat pal.bg, so this is an exact in-place wipe with no
+ // per-pixel filter, and it draws the plot in like a plotter.
+ // `hold: true` -- the closing chart is a HOLD, not a reveal.
+ //
+ // The wipe existed because this card was the first and only time the viewer
+ // saw the numbers plotted. With the chart band drawing live under the whole
+ // cut, wiping it in again would re-tell a story the viewer has just watched
+ // happen. So the card opens on the finished plot and the seconds go to
+ // reading the final gap and its flags instead.
+ if (card.hold) {
+ await execFileP(
+ FFMPEG,
+ [
+ "-nostdin", "-v", "error", "-y",
+ "-f", "lavfi", "-t", dur, "-i", `color=c=${pal.bg}:s=${width}x${height}:r=${render.fps}`,
+ "-loop", "1", "-framerate", String(render.fps), "-t", dur, "-i", chart.path,
+ "-f", "lavfi", "-t", dur,
+ "-i", `anullsrc=channel_layout=stereo:sample_rate=${render.audioRate}`,
+ "-filter_complex",
+ `[0:v][1:v]overlay=x=0:y=0:shortest=1,fps=${render.fps},setsar=1[v]`,
+ "-map", "[v]", "-map", "2:a",
+ ...encodeArgs(render),
+ "-shortest",
+ seg,
+ ],
+ { maxBuffer: 1 << 24 },
+ );
+ return seg;
+ }
+
+ const wipeW = VW - chart.plotX;
+ const wipe = path.join(outDir, "cards", `${card.id}.wipe.png`);
+ await execFileP("magick", [
+ "-size", `${wipeW}x${Math.round(chart.plotH)}`, `xc:${pal.bg}`, wipe,
+ ]);
+
+ const wipeStart = card.wipeStart ?? 0.8;
+ const wipeDur = card.wipeSeconds ?? Math.max(1, card.seconds - wipeStart - 3.0);
+
+ await execFileP(
+ FFMPEG,
+ [
+ "-nostdin", "-v", "error", "-y",
+ "-f", "lavfi", "-t", dur, "-i", `color=c=${pal.bg}:s=${width}x${height}:r=${render.fps}`,
+ "-loop", "1", "-framerate", String(render.fps), "-t", dur, "-i", chart.path,
+ "-loop", "1", "-framerate", String(render.fps), "-t", dur, "-i", wipe,
+ "-f", "lavfi", "-t", dur,
+ "-i", `anullsrc=channel_layout=stereo:sample_rate=${render.audioRate}`,
+ "-filter_complex", [
+ `[0:v][1:v]overlay=x=0:y=0:shortest=1[a]`,
+ `[a][2:v]overlay=x='${chart.plotX}+${wipeW}*clip((t-${wipeStart})/${wipeDur.toFixed(3)},0,1)'` +
+ `:y=${Math.round(chart.plotY)}:shortest=1,fps=${render.fps},setsar=1[v]`,
+ ].join(";"),
+ "-map", "[v]", "-map", "3:a",
+ ...encodeArgs(render),
+ "-shortest",
+ seg,
+ ],
+ { maxBuffer: 1 << 24 },
+ );
+ return seg;
+}
+
// Crossfade every segment into the next. This is a full re-encode of the
// timeline — the concat demuxer can only stream-copy hard cuts — so --no-xfade
// stays available for quick iteration.
-async function concatWithXfade(segments, render, outPath) {
+async function concatWithXfade(segments, render, outPath, railPlan, chrome = null) {
const D = render.transition ?? 0.5;
const durs = [];
- for (const s of segments) durs.push(await probeDuration(s));
+ for (const s of segments) durs.push(await probeDuration(s, render.fps));
const inputs = segments.flatMap((s) => ["-i", s]);
const parts = [];
@@ -576,13 +1305,43 @@ async function concatWithXfade(segments, render, outPath) {
acc = acc + durs[i] - D;
}
+ // The rail attaches to the LAST xfade node, so it runs after every dissolve
+ // and sees an absolute, continuous `t`. One encode, not two.
+ //
+ // When the chrome is rendered rather than drawn, the PNG regions go on FIRST
+ // and the rail chain reads their output. Not the other way round: the rail
+ // chain ends in `format=yuv420p`, and overlaying an alpha sequence onto
+ // yuv420p is the fringing trap the rail already documents, one layer later.
+ const chromeIn = chrome ?? null;
+ const railIn = chromeIn ? chromeIn.outLabel : vlab;
+ const rc = railPlan
+ ? railFilterChain(
+ render.rail, railPlan.assets, railPlan.times, render,
+ railIn, segments.length, railPlan.total + 2,
+ { hideAt: railPlan.hideAt },
+ )
+ : null;
+ const railInputs = rc ? rc.inputs.filter((a) => a === "-i").length : 0;
+ const hf = chromeIn
+ ? chromeOverlayChain(render, chromeIn.regions, vlab, segments.length + railInputs, {
+ outLabel: chromeIn.outLabel,
+ final: !rc,
+ })
+ : null;
+ if (hf) parts.push(hf.chain);
+ if (rc) parts.push(rc.chain);
+
+ const tail = rc ? rc.outLabel : hf ? hf.outLabel : vlab;
+
await execFileP(
FFMPEG,
[
"-nostdin", "-v", "error", "-y",
...inputs,
+ ...(rc ? rc.inputs : []),
+ ...(hf ? hf.inputs : []),
"-filter_complex", parts.join(";"),
- "-map", vlab, "-map", alab,
+ "-map", tail, "-map", alab,
...encodeArgs(render),
outPath,
],
@@ -590,6 +1349,48 @@ async function concatWithXfade(segments, render, outPath) {
);
}
+/**
+ * Run the rail chain over an already-concatenated file.
+ *
+ * Two callers need this. `--rail-only` iterates on the rail in seconds instead
+ * of re-running the whole concat; and `--no-xfade` has no choice, because
+ * concatHardCut is `-c copy` and a stream-copy mux cannot host a filtergraph
+ * at all.
+ */
+async function applyRail(inPath, outPath, render, railPlan, preview) {
+ const rc = railFilterChain(
+ render.rail, railPlan.assets, railPlan.times, render,
+ preview ? "[base]" : "[0:v]", 1, railPlan.total + 2,
+ { hideAt: railPlan.hideAt },
+ );
+ const parts = [];
+ if (preview) {
+ // -ss restarts `t` near zero, which would put every absolute-time ramp in
+ // the wrong place — the rail would look broken while being correct. Shift
+ // the timestamps back to where the expressions think they are, then rebase
+ // them so the preview file still starts at 0.
+ parts.push(`[0:v]setpts=PTS+${preview.start.toFixed(3)}/TB[base]`);
+ }
+ parts.push(rc.chain);
+ const tail = preview ? "[vshift]" : rc.outLabel;
+ if (preview) parts.push(`${rc.outLabel}setpts=PTS-STARTPTS[vshift]`);
+
+ await execFileP(
+ FFMPEG,
+ [
+ "-nostdin", "-v", "error", "-y",
+ ...(preview ? ["-ss", String(preview.start), "-t", String(preview.dur)] : []),
+ "-i", inPath,
+ ...rc.inputs,
+ "-filter_complex", parts.join(";"),
+ "-map", tail, "-map", "0:a",
+ ...encodeArgsVideoOnly(render),
+ outPath,
+ ],
+ { maxBuffer: 1 << 26 },
+ );
+}
+
// ---- chapter markers -----------------------------------------------------
// A compilation like this is a reference document as much as a video: the report
// cites moments, and a viewer wants to jump to them. Every clip therefore becomes
@@ -600,9 +1401,9 @@ async function concatWithXfade(segments, render, outPath) {
// title carrying any of them has to be escaped or the file silently mis-parses.
const ffmetaEscape = (s) => String(s).replace(/([=;#\\])/g, "\\$1").replace(/\n/g, " ");
-async function segmentOffsets(segments, D) {
+export async function segmentOffsets(segments, D, fps) {
const durs = [];
- for (const s of segments) durs.push(await probeDuration(s));
+ for (const s of segments) durs.push(await probeDuration(s, fps));
const starts = [];
let acc = 0;
for (let i = 0; i < durs.length; i += 1) {
@@ -614,7 +1415,7 @@ async function segmentOffsets(segments, D) {
async function chapterTitle(entry, index, provenance) {
if (entry.chapter) return entry.chapter;
- if (entry.type === "card") return entry.title ?? `Card ${index + 1}`;
+ if (entry.type !== "clip") return entry.title ?? entry.heading ?? `Card ${index + 1}`;
try {
const meta = await videoMeta(entry.video, entry.channel ?? provenance.channelSlug);
const d = String(meta.uploadDate ?? "");
@@ -626,9 +1427,9 @@ async function chapterTitle(entry, index, provenance) {
}
}
-async function muxChapters(finalPath, entries, segments, D, outDir, provenance) {
+async function muxChapters(finalPath, entries, segments, D, outDir, provenance, fps) {
if (segments.length < 2) return;
- const { starts, total } = await segmentOffsets(segments, D);
+ const { starts, total } = await segmentOffsets(segments, D, fps);
const lines = [";FFMETADATA1", ""];
for (let i = 0; i < entries.length; i += 1) {
// Land just PAST the crossfade, so the marker opens on the incoming clip
@@ -664,11 +1465,36 @@ async function concatHardCut(segments, outDir, outPath) {
await writeFile(listPath, segments.map((s) => `file '${s}'`).join("\n") + "\n", "utf8");
await execFileP(
FFMPEG,
- ["-nostdin", "-v", "error", "-y", "-f", "concat", "-safe", "0", "-i", listPath, "-c", "copy", outPath],
+ ["-nostdin", "-v", "error", "-y", "-f", "concat", "-safe", "0",
+ // The concat demuxer stitches per-file timestamps; without generated PTS a
+ // stream copy can hand the next stage a discontinuous timeline, which the
+ // rail's absolute-time expressions would then read off by that much.
+ "-fflags", "+genpts",
+ "-i", listPath, "-c", "copy", outPath],
{ maxBuffer: 1 << 24 },
);
}
+// A hard-cut concat and a crossfaded one are different lengths, so a cached
+// prerail from one is a wrong base for the other. Keeping them in separate files
+// means the mode can be switched without a stale-cache trap -- and without the
+// length assertion below having to be the thing that explains it.
+const prerailPath = (outDir, slug, D) =>
+ path.join(outDir, `${slug}.prerail${D === 0 ? "-hardcut" : ""}.mp4`);
+
+// The finished timeline must be exactly as long as segmentOffsets says. Anything
+// else means a filter changed the length behind our backs.
+async function assertConcatLength(file, expected, fps, what) {
+ const got = await probeDuration(file, fps);
+ if (Math.abs(got - expected) > 1.5 / fps) {
+ throw new Error(
+ `${what}: duration ${got.toFixed(3)}s but the timeline is ${expected.toFixed(3)}s ` +
+ `(${((got - expected) * fps).toFixed(1)} frames out)` +
+ (/prerail/.test(what) ? " — delete it and let this rebuild it" : ""),
+ );
+ }
+}
+
/**
* Build a manifest into a video.
*
@@ -678,32 +1504,81 @@ async function concatHardCut(segments, outDir, outPath) {
* grandchild would outlive the request that started it.
*/
export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly } = {}) {
- const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
+ const variant = opts.variant ?? "sourced";
+ const whole = JSON.parse(await readFile(manifestPath, "utf8"));
+ const manifest = selectVariant(whole, variant);
const { render, provenance } = manifest;
- const outDir = out ?? path.join(path.dirname(path.resolve(manifestPath)), "out");
+ const outRoot = out ?? path.join(path.dirname(path.resolve(manifestPath)), "out");
+ const dirs = variantPaths(outRoot, manifest.slug, variant);
+ const outDir = dirs.dir;
- for (const d of ["cards", "clips-raw", "segments", "qr"]) {
+ await mkdir(dirs.rawDir, { recursive: true });
+ for (const d of ["cards", "segments", "qr"]) {
await mkdir(path.join(outDir, d), { recursive: true });
}
// Fetch one clip's window and stop. This is what the clip bench's "fetch 20s
// more" runs, so a bench fetch and a build fetch can never disagree about
// naming, format selection, the VP9 trap or the Rumble HLS retry.
+ // Reads the WHOLE manifest, not the variant's view of it: a clip bench fetch
+ // is about a moment in the corpus, and which cut happens to carry it is
+ // beside the point.
if (fetchOnly) {
- const entry = manifest.timeline.find((e) => e.id === fetchOnly);
- if (!entry) throw new Error(`no timeline entry with id ${fetchOnly}`);
- if (entry.type === "card") throw new Error(`${fetchOnly} is a card, not a clip`);
+ let entry = whole.timeline.find((e) => e.id === fetchOnly);
+ // `!== "clip"`, not `=== "card"`. The timeline's vocabulary is OPEN -- one
+ // real manifest carries `scroll` and `chart` entries -- and the card-only
+ // check sent `undefined` into the fetcher for either of those.
+ if (entry && entry.type !== "clip") {
+ throw new Error(`${fetchOnly} is a ${entry.type ?? "non-clip"} entry, not a clip`);
+ }
+ if (!entry) {
+ // A LEDGER CLAIM. Adjudicating one means listening around the moment, and
+ // most of the ledger is cited by no clip at all -- so the claim page asks
+ // for a window the timeline has no entry for. It is fetched through this
+ // same path so the file lands in clips-raw under the build's own naming,
+ // inherits the format pin and the Rumble HLS retry, and is REUSED by a
+ // later build rather than fetched a second time.
+ const claim = (whole.ledger ?? []).find((e) => e.id === fetchOnly);
+ if (!claim) throw new Error(`no timeline entry or ledger claim with id ${fetchOnly}`);
+ if (!claim.video) throw new Error(`ledger claim ${fetchOnly} has no \`video\` to fetch`);
+ const at = Number(claim.cite);
+ if (!Number.isFinite(at)) throw new Error(`ledger claim ${fetchOnly} has no \`cite\` second`);
+ // A claim is a MOMENT, not a window: the pad is the whole point, so the
+ // entry is a hair either side of the cite and --pad does the rest.
+ entry = {
+ id: claim.id,
+ video: claim.video,
+ channel: claim.channel ?? null,
+ start: Math.max(0, at - 1),
+ end: at + 1,
+ };
+ }
const meta = await videoMeta(entry.video, entry.channel ?? provenance.channelSlug);
- const r = await fetchClip(entry, meta, render, outDir, opts);
+ const r = await fetchClip(entry, meta, render, dirs.rawDir, opts);
EMIT("done", { out: r.path, fetchStart: r.fetchStart, cached: r.cached });
return { out: r.path, failures: [] };
}
// Footer chrome is shared by every clip, so build it once up front.
- const chrome = await renderFooterAssets(render, manifest.timelineNodes, outDir);
+ const hyper = render.chromeEngine === "hyperframes";
+ const chrome = hyper
+ ? reservedFooter(render)
+ : await renderFooterAssets(render, manifest.timelineNodes, outDir);
const entries = manifest.timeline.filter((e) => !only || e.id === only);
if (only && !entries.length) throw new Error(`no timeline entry with id ${only}`);
+
+ // Read once, at the ROOT: a source's state is a fact about the manifest, not
+ // about a variant. A stacked ledger card says why each claim is text rather
+ // than footage, and this is where that answer comes from.
+ const availability = new Map(
+ (
+ await readFile(path.join(dirs.root, "availability.json"), "utf8").then(
+ (j) => JSON.parse(j).sources ?? [],
+ () => [],
+ )
+ ).flatMap((src) => (src.claims ?? []).map((id) => [id, src.state])),
+ );
const segments = [];
const failures = [];
@@ -712,16 +1587,51 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly
// Retro-fit chapters onto an already-built file without re-encoding it. The
// per-clip segments are still on disk, which is all the offsets need.
if (opts.chaptersOnly) {
- const finalPath = path.join(outDir, `${manifest.slug}.mp4`);
+ const finalPath = dirs.final;
const segs = entries.map((e) => path.join(outDir, "segments", `${e.id}.mp4`));
for (const seg of segs) {
if (!(await exists(seg)))
throw new Error(`--chapters-only needs ${seg}, which is missing — run a full build first`);
}
- await muxChapters(finalPath, entries, segs, D, outDir, provenance);
+ await muxChapters(finalPath, entries, segs, D, outDir, provenance, render.fps);
return { out: finalPath, failures: [] };
}
+ // Re-run the rail over a cached concat instead of rebuilding the timeline.
+ // The rail is the part that gets iterated on; the 40-minute concat is not.
+ if (opts.railOnly) {
+ if (!render.rail) throw new Error("--rail-only needs render.rail in the manifest");
+ const finalPath = dirs.final;
+ const prerail = prerailPath(outDir, manifest.slug, D);
+ const segs = entries.map((e) => path.join(outDir, "segments", `${e.id}.mp4`));
+ for (const seg of segs) {
+ if (!(await exists(seg)))
+ throw new Error(`--rail-only needs ${seg}, which is missing — run a full build first`);
+ }
+ if (!(await exists(prerail))) {
+ EMIT("concat", { mode: D === 0 ? "hardcut" : "xfade", n: segs.length });
+ if (D === 0) await concatHardCut(segs, outDir, prerail);
+ else await concatWithXfade(segs, render, prerail, null);
+ }
+ const railPlan = await buildRailPlan(manifest, render, entries, segs, D, outDir);
+ await assertConcatLength(prerail, railPlan.total, render.fps,
+ `cached ${path.basename(prerail)}`);
+ const out = opts.preview
+ ? path.join(outDir, `${manifest.slug}.preview.mp4`)
+ : finalPath;
+ await applyRail(prerail, out, render, railPlan, opts.preview ?? null);
+ if (!opts.preview) {
+ await assertConcatLength(out, railPlan.total, render.fps, "rail build");
+ // applyRail re-encodes, so the chapters muxed onto the previous final are
+ // gone. Put them back, or --rail-only quietly ships a chapterless cut.
+ if (!opts.noChapters) {
+ await muxChapters(out, entries, segs, D, outDir, provenance, render.fps);
+ }
+ }
+ EMIT("done", { out, failures: [] });
+ return { out, failures: [] };
+ }
+
EMIT("start", { title: manifest.title, entries: entries.length, out: outDir });
for (let i = 0; i < entries.length; i += 1) {
const entry = entries[i];
@@ -729,6 +1639,17 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly
if (entry.type === "card") {
EMIT("card", { id: entry.id, i, n: entries.length });
segments.push(await buildCardSegment(entry, render, outDir, manifest.timelineNodes));
+ } else if (entry.type === "scroll" || entry.type === "chart" || entry.type === "ledger") {
+ EMIT("card", { id: entry.id, i, n: entries.length });
+ if (!manifest.ledger?.length)
+ throw new Error(`${entry.id} is type:${entry.type} but the manifest has no ledger[]`);
+ segments.push(
+ entry.type === "scroll"
+ ? await buildScrollSegment(entry, render, outDir, manifest.ledger)
+ : entry.type === "chart"
+ ? await buildChartSegment(entry, render, outDir, manifest.ledger)
+ : await buildLedgerSegment(entry, render, outDir, manifest.ledger, availability),
+ );
} else {
const meta = await videoMeta(entry.video, entry.channel ?? provenance.channelSlug);
EMIT("clip", {
@@ -737,7 +1658,7 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly
});
segments.push(
await buildClipSegment(
- entry, meta, render, outDir, opts, chrome, manifest.timelineNodes, provenance,
+ entry, meta, render, dirs, opts, chrome, manifest.timelineNodes, provenance,
),
);
}
@@ -769,15 +1690,56 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly
return { out: null, failures };
}
- const final = path.join(outDir, `${manifest.slug}.mp4`);
+ const final = dirs.final;
+ const railPlan = opts.noRail ? null : await buildRailPlan(manifest, render, entries, segments, D, outDir);
+
+ // The rendered chrome, if this manifest asks for it. Absent, `chromePlan` is
+ // null and every line below behaves exactly as it did -- which is the claim
+ // the MD5 check tests.
+ let chromePlan = null;
+ if (hyper) {
+ const regions = chromeRegions(render, outDir);
+ for (const r of regions) {
+ if (!(await exists(path.join(r.frames, "frame_000001.png")))) {
+ throw new Error(
+ `render.chromeEngine is "hyperframes" but ${r.name} has no frames at ${r.frames}. ` +
+ `Run compose-chrome.mjs --region ${r.name} --render first.`,
+ );
+ }
+ }
+ chromePlan = { regions, outLabel: "[hfout]" };
+ EMIT("note", { message: `chrome: ${regions.map((r) => `${r.name} ${r.width}x${r.height}`).join(", ")} as png-sequence` });
+ }
+
// `transition: 0` is a real editorial choice, not just a speed knob: hard cuts
// hit harder on a compilation whose point is repetition. Honouring it here keeps
// the manifest the source of truth, so a rebuild does not silently re-add fades.
EMIT("concat", { mode: D === 0 ? "hardcut" : "xfade", n: segments.length });
- if (D === 0) await concatHardCut(segments, outDir, final);
- else await concatWithXfade(segments, render, final);
+ if (D === 0) {
+ if (chromePlan) {
+ throw new Error(
+ 'render.chromeEngine "hyperframes" needs a filtergraph, and `transition: 0` concatenates with ' +
+ "-c copy, which cannot host one. Give the manifest a transition, or drop chromeEngine.",
+ );
+ }
+ // concatHardCut is `-c copy`, which cannot host a filtergraph, so the rail
+ // has to be a second pass here whether we like it or not.
+ const prerail = prerailPath(outDir, manifest.slug, D);
+ await concatHardCut(segments, outDir, railPlan ? prerail : final);
+ if (railPlan) {
+ await assertConcatLength(prerail, railPlan.total, render.fps, "hard-cut concat");
+ await applyRail(prerail, final, render, railPlan, null);
+ }
+ } else {
+ await concatWithXfade(segments, render, final, railPlan, chromePlan);
+ }
- if (!opts.noChapters) await muxChapters(final, entries, segments, D, outDir, provenance);
+ // Length is the canary for the two ways a rail input can go wrong: a file
+ // LONGER than the timeline means a strip outran the main (a missing
+ // shortest=1), and a hang means an unbounded -loop 1.
+ if (railPlan) await assertConcatLength(final, railPlan.total, render.fps, "rail build");
+
+ if (!opts.noChapters) await muxChapters(final, entries, segments, D, outDir, provenance, render.fps);
const { stdout } = await execFileP(FFPROBE, [
"-v", "error", "-show_entries", "format=duration,size",
@@ -795,9 +1757,11 @@ async function main() {
const manifestPath = argv.find((a) => !a.startsWith("--"));
if (!manifestPath) {
console.error(
- "usage: build-video.mjs <manifest.json> [--out <dir>] [--only <id>] [--fetch-only <id>]\n" +
+ "usage: build-video.mjs <manifest.json> [--out <dir>] [--variant sourced|full]\n" +
+ " [--only <id>] [--fetch-only <id>]\n" +
" [--pad <s>] [--skip-fetch] [--no-xfade] [--no-chapters] [--chapters-only]\n" +
- " [--progress ndjson] [--continue-on-error] [--no-reuse]",
+ " [--progress ndjson] [--continue-on-error] [--no-reuse]\n" +
+ " [--no-rail] [--rail-only] [--preview <start> <dur>]",
);
process.exit(2);
}
@@ -809,14 +1773,26 @@ async function main() {
const padArg = flag("--pad");
const opts = {
+ variant: flag("--variant") ?? "sourced",
skipFetch: argv.includes("--skip-fetch"),
continueOnError: argv.includes("--continue-on-error"),
noXfade: argv.includes("--no-xfade"),
noChapters: argv.includes("--no-chapters"),
chaptersOnly: argv.includes("--chapters-only"),
noReuse: argv.includes("--no-reuse"),
+ noRail: argv.includes("--no-rail"),
+ railOnly: argv.includes("--rail-only"),
pad: padArg === undefined ? undefined : Number(padArg),
};
+ const pv = argv.indexOf("--preview");
+ if (pv >= 0) {
+ opts.preview = { start: Number(argv[pv + 1]), dur: Number(argv[pv + 2]) };
+ opts.railOnly = true;
+ if (!Number.isFinite(opts.preview.start) || !Number.isFinite(opts.preview.dur)) {
+ console.error("--preview takes <start> <dur> in seconds");
+ process.exit(2);
+ }
+ }
const { failures } = await buildVideo({
manifestPath,
diff --git a/scripts/report-to-video/check-availability.mjs b/scripts/report-to-video/check-availability.mjs
@@ -64,12 +64,23 @@ export async function checkAvailability(manifestPath, { outDir, maxAgeDays = 0 }
// across more than one archive, and the same id under a different slug is a
// different file. So the unit of work is (channel, video), never video alone.
const wanted = new Map();
+ const want = (channel, video) => {
+ const key = `${channel}/${video}`;
+ if (!wanted.has(key)) wanted.set(key, { key, channel, video, clips: [], claims: [] });
+ return wanted.get(key);
+ };
for (const e of manifest.timeline ?? []) {
if (e.type !== "clip") continue;
- const channel = e.channel ?? slug;
- const key = `${channel}/${e.video}`;
- if (!wanted.has(key)) wanted.set(key, { key, channel, video: e.video, clips: [] });
- wanted.get(key).clips.push(e.id);
+ want(e.channel ?? slug, e.video).clips.push(e.id);
+ }
+ // LEDGER SOURCES TOO, not just the clipped ones. A cut that stacks its
+ // unclipped claims onto cards has to say WHY each one is a line of text
+ // rather than footage, and "the upload is gone" and "we did not cut it" are
+ // different sentences. Guessing which is which is how a live source ends up
+ // labelled deleted on screen.
+ for (const c of manifest.ledger ?? []) {
+ if (!c.video) continue;
+ want(c.channel ?? slug, c.video).claims.push(c.id);
}
const prev = await readFile(file, "utf8").then(
@@ -83,7 +94,7 @@ export async function checkAvailability(manifestPath, { outDir, maxAgeDays = 0 }
for (const w of wanted.values()) {
const was = prevBy.get(w.key);
if (freshMs > 0 && was?.checkedAt && Date.now() - Date.parse(was.checkedAt) < freshMs) {
- sources.push({ ...was, clips: w.clips, reused: true });
+ sources.push({ ...was, clips: w.clips, claims: w.claims, reused: true });
continue;
}
diff --git a/scripts/report-to-video/compose-chrome.mjs b/scripts/report-to-video/compose-chrome.mjs
@@ -0,0 +1,599 @@
+#!/usr/bin/env node
+// Build the chrome regions as HyperFrames compositions and render them to
+// lossless PNG sequences the ffmpeg pass overlays.
+//
+// ---------------------------------------------------------------------------
+// Why the chrome is a browser and the picture is not
+// ---------------------------------------------------------------------------
+// HyperFrames pre-extracts source video to JPEG q95 before compositing, which is
+// unacceptable when the picture IS the cited evidence -- the whole argument of
+// the cut is that you are watching the man say it. So FOOTAGE NEVER ENTERS
+// CHROME. Each chrome region is its own small composition over a transparent
+// background, rendered to RGBA PNG, and composited by the existing ffmpeg pass.
+// That is ~37% of full-frame pixels rather than 100%.
+//
+// ---------------------------------------------------------------------------
+// Why the sweep is one clip-path and not a dash offset per series
+// ---------------------------------------------------------------------------
+// The obvious build animates every path's stroke-dashoffset. It looks right for
+// the strokes and wrong for everything else: the gap band between the two
+// totals is a filled polygon with no stroke to offset, so it appears at full
+// width the moment it fades in and the chart is already showing you an answer
+// the playhead has not reached. One clip rect over the whole plot makes the
+// reveal a property of the SWEEP rather than of each mark, and nothing can get
+// ahead of it.
+//
+// ---------------------------------------------------------------------------
+// Why the playhead is driven by out/schedule.json and not by dates
+// ---------------------------------------------------------------------------
+// The band has to move every frame and be in the right place when a rail row
+// lands. Only the build knows when that is -- it depends on segment durations
+// and the crossfade -- so the build writes the schedule and this reads it.
+// Recomputing it here would be a second implementation of segmentOffsets() and
+// would drift the first time the transition changed.
+import { copyFile, mkdir, readFile, writeFile } from "node:fs/promises";
+import { execFile } from "node:child_process";
+import { promisify } from "node:util";
+import path from "node:path";
+
+import { ledgerTotals, dateKey } from "./ledger-totals.mjs";
+import { selectVariant } from "./build-video.mjs";
+
+const run = promisify(execFile);
+
+const esc = (s) =>
+ String(s ?? "").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
+
+const num = (v) => (Number.isInteger(v) ? String(v) : v.toFixed(1).replace(/\.0$/, ""));
+
+/** Days since epoch, so the x scale is linear in real time and not in claims. */
+const dayOf = (d) => Date.parse(`${dateKey(d)}T00:00:00Z`) / 86400000;
+
+// ---------------------------------------------------------------------------
+// The chart band.
+// ---------------------------------------------------------------------------
+
+const DEFAULT_CHART = {
+ height: 200,
+ yMax: 30,
+ from: "2020-01-01",
+ to: "2026-12-31",
+ statedColor: "#C55F9C",
+ impliedColor: "#EDF0EC",
+ impliedWidth: 4.5,
+ // NOT the palette's amber. #E8A33F sits at ΔE 12.4 from the coffee series'
+ // #D2732F at NORMAL vision -- below the 15 floor, so a flag badge beside a
+ // coffee mark was hard to tell from the coffee mark. #E0E24A adds no new
+ // worst pair on either the CVD or the normal-vision all-pairs check: the two
+ // worst pairs are identical with and without it.
+ flagColor: "#E0E24A",
+ gapFill: "#C55F9C",
+ gapOpacity: 0.1,
+ series: [
+ { scope: "media", color: "#22AB83", dash: null },
+ { scope: "coffee", color: "#D2732F", dash: "7 4" },
+ { scope: "publica", color: "#6A82DC", dash: "2 4" },
+ ],
+};
+
+const SCOPE_LABEL = { media: "The Quartering", coffee: "Coffee Brand", publica: "The Publica" };
+
+/** The predicates that get a glyph on the mark. See the note at the call site. */
+const BADGED = new Set([
+ "contradicts_component",
+ "same_day_conflict",
+ "self_negating",
+ "not_his_number",
+ // The same people, staff one month and contractors the next. It earns a
+ // badge for the same reason self_negating does: the mark is drawn at a
+ // height the claim's own words undercut.
+ "status_flip",
+]);
+
+/**
+ * A step path. The figure he gave HOLDS until he gives another one, so the
+ * segment between two claims is flat and the change is vertical. A smooth line
+ * would draw a fortnight of intermediate headcounts nobody ever claimed.
+ */
+function stepPath(points, X, Y) {
+ if (!points.length) return "";
+ const d = [`M${X(points[0].date).toFixed(1)},${Y(points[0].value).toFixed(1)}`];
+ for (let i = 1; i < points.length; i += 1) {
+ d.push(`L${X(points[i].date).toFixed(1)},${Y(points[i - 1].value).toFixed(1)}`);
+ d.push(`L${X(points[i].date).toFixed(1)},${Y(points[i].value).toFixed(1)}`);
+ }
+ return d.join(" ");
+}
+
+/** The same walk, but returning the polyline vertices — the gap band needs them. */
+function stepVerts(points, X, Y) {
+ const out = [];
+ points.forEach((p, i) => {
+ if (i > 0) out.push([X(p.date), Y(points[i - 1].value)]);
+ out.push([X(p.date), Y(p.value)]);
+ });
+ return out;
+}
+
+export function chartBandHtml(manifest, totals, schedule, opts = {}) {
+ const fonts = opts.fonts ?? null;
+ const render = manifest.render;
+ const cfg = { ...DEFAULT_CHART, ...(render.chart ?? {}) };
+ const W = opts.width ?? render.width - (render.rail?.width ?? 0);
+ const H = cfg.height;
+ const pal = render.palette;
+ const DUR = opts.duration ?? schedule.total;
+
+ const L = 92, R = 1120, T = 26, B = 150;
+ const AXIS = 158;
+ const d0 = dayOf(cfg.from), d1 = dayOf(cfg.to);
+ const X = (date) => L + ((dayOf(date) - d0) / (d1 - d0)) * (R - L);
+ // Headroom. The implied total peaks at exactly cfg.yMax in this corpus, and a
+ // series drawn along the top gridline reads as clipped rather than as a peak.
+ const peak = Math.max(
+ cfg.yMax,
+ ...(totals.series.implied ?? []).map((p) => p.value),
+ ...(totals.series.stated ?? []).map((p) => p.value),
+ );
+ const yTop = peak > cfg.yMax - 1 ? Math.ceil(peak * 1.1) : cfg.yMax;
+ const Y = (v) => B - (Math.max(0, Math.min(yTop, v)) / yTop) * (B - T);
+
+ const atOf = new Map(schedule.claims.map((c) => [c.id, c.at]));
+ const steps = totals.steps.filter((s) => atOf.has(s.id));
+
+ // ---- the series ---------------------------------------------------------
+ const seriesSvg = cfg.series
+ .map((s) => {
+ const pts = totals.series[s.scope] ?? [];
+ if (!pts.length) return "";
+ return (
+ `<path d="${stepPath(pts, X, Y)}" fill="none" stroke="${s.color}" stroke-width="2.2" ` +
+ `stroke-linejoin="round"${s.dash ? ` stroke-dasharray="${s.dash}"` : ""}/>`
+ );
+ })
+ .join("");
+
+ const statedPts = totals.series.stated;
+ const impliedPts = totals.series.implied;
+
+ // ---- the gap between the two totals -------------------------------------
+ // Only where BOTH are defined: before his first total there is nothing to be
+ // a gap from, and a band anchored to zero would read as a claim of its own.
+ let gapSvg = "";
+ const firstStated = statedPts[0] ? dayOf(statedPts[0].date) : Infinity;
+ const firstImplied = impliedPts[0] ? dayOf(impliedPts[0].date) : Infinity;
+ const gapFrom = Math.max(firstStated, firstImplied);
+ if (Number.isFinite(gapFrom)) {
+ const up = stepVerts(impliedPts, X, Y).filter((p) => p[0] >= X(cfg.from) && true);
+ const dn = stepVerts(statedPts, X, Y);
+ const clipX = L + ((gapFrom - d0) / (d1 - d0)) * (R - L);
+ const poly =
+ `M${up.map((p) => `${p[0].toFixed(1)},${p[1].toFixed(1)}`).join(" L")} ` +
+ `L${R},${up.length ? up[up.length - 1][1].toFixed(1) : Y(0)} ` +
+ `L${R},${dn.length ? dn[dn.length - 1][1].toFixed(1) : Y(0)} ` +
+ `L${[...dn].reverse().map((p) => `${p[0].toFixed(1)},${p[1].toFixed(1)}`).join(" L")} Z`;
+ gapSvg =
+ `<clipPath id="gapstart"><rect x="${clipX.toFixed(1)}" y="0" width="${(R - clipX + 2).toFixed(1)}" height="${H}"/></clipPath>` +
+ `<g clip-path="url(#gapstart)">` +
+ `<path id="gapband" d="${poly}" fill="${cfg.gapFill}" opacity="${cfg.gapOpacity}"/>`;
+
+ // Where the total is BELOW one of its own parts the gap is not a gap, it is
+ // an impossibility — so it is hatched rather than tinted. Same geometry, a
+ // different claim about what it means.
+ const bad = steps.filter((s) => s.flags.some((f) => f.rule === "contradicts_component"));
+ const bands = bad.map((s) => {
+ const x = X(s.date);
+ const next = statedPts.find((p) => dayOf(p.date) > dayOf(s.date));
+ const x2 = next ? X(next.date) : R;
+ return `<rect x="${x.toFixed(1)}" y="0" width="${Math.max(2, x2 - x).toFixed(1)}" height="${H}"/>`;
+ });
+ if (bands.length) {
+ gapSvg +=
+ `<clipPath id="impossible">${bands.join("")}</clipPath>` +
+ `<g clip-path="url(#impossible)"><g clip-path="url(#gapclip)">` +
+ `<path d="${poly}" fill="url(#hatch)"/></g></g>` +
+ `<clipPath id="gapclip"><path d="${poly}"/></clipPath>`;
+ }
+ gapSvg += `</g>`;
+ }
+
+ // ---- marks --------------------------------------------------------------
+ // Two orthogonal encodings, not four shapes. FILL is evidence: filled means a
+ // clip plays behind it, hollow means the claim is counted but not quoted.
+ // BADGE is coherence. A qualitative claim has no y position at all.
+ const colourOf = (s) =>
+ s.scope === "all" ? cfg.statedColor : (cfg.series.find((x) => x.scope === s.scope)?.color ?? pal.muted);
+
+ const marks = [];
+ const ticks = [];
+ const badges = [];
+ for (const s of steps) {
+ const x = X(s.date);
+ const c = colourOf(s);
+ const live = !!s.entryId;
+ if (s.qualitative || s.value == null) {
+ ticks.push(
+ `<line class="qt" id="qt-${s.id}" x1="${x.toFixed(1)}" y1="${AXIS + 3}" x2="${x.toFixed(1)}" y2="${AXIS + 12}" ` +
+ `stroke="${c}" stroke-width="2" opacity="0"/>`,
+ );
+ continue;
+ }
+ const y = Y(s.value);
+ marks.push(
+ live
+ ? `<circle class="mk" id="mk-${s.id}" cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="4" fill="${c}" opacity="0"/>`
+ : `<circle class="mk" id="mk-${s.id}" cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="4" fill="none" ` +
+ `stroke="${c}" stroke-width="1.8" stroke-dasharray="2 2" opacity="0"/>`,
+ );
+ // Not every predicate earns a badge. `population_mismatch` already rides
+ // under the stated number as its qualifier ("salaried"), and
+ // `adjudicator` notes are for the inbox, not the screen. Badging all five
+ // put a triangle on half the marks, which is the same as badging none.
+ if (s.flags.some((f) => BADGED.has(f.rule))) {
+ badges.push(
+ `<path class="fg" id="fg-${s.id}" d="M${(x + 5).toFixed(1)},${(y - 6).toFixed(1)} l0,-10 l8,3 l-8,3 z" ` +
+ `fill="${cfg.flagColor}" stroke="${cfg.flagColor}" stroke-width="1.2" opacity="0"/>`,
+ );
+ }
+ }
+
+ // ---- direct end labels --------------------------------------------------
+ // Direct end labels, pushed apart.
+ //
+ // Four of the five series end within a couple of people of each other, so
+ // their labels land on top of one another and the band becomes unreadable
+ // exactly where it is making its point. Same fix the closing card already
+ // uses: spread them, then elbow a leader back to the value each belongs to.
+ // Direct labels are the secondary encoding that lets the palette be legible
+ // at all under CVD, so an unreadable stack defeats the point of having them.
+ const wanted = [
+ { pts: impliedPts, colour: cfg.impliedColor, text: "implied", weight: true },
+ { pts: statedPts, colour: cfg.statedColor, text: "stated", weight: true },
+ ...cfg.series.map((sr) => ({
+ pts: totals.series[sr.scope] ?? [],
+ colour: sr.color,
+ text: SCOPE_LABEL[sr.scope] ?? sr.scope,
+ weight: false,
+ })),
+ ].filter((l) => l.pts.length);
+
+ const LBLH = 15;
+ const placed = wanted
+ .map((l) => ({ ...l, lineY: Y(l.pts[l.pts.length - 1].value) }))
+ .sort((a, b) => a.lineY - b.lineY)
+ .map((l) => ({ ...l, y: l.lineY }));
+ for (let i = 1; i < placed.length; i += 1) {
+ placed[i].y = Math.max(placed[i].y, placed[i - 1].y + LBLH);
+ }
+ const over = placed.length ? placed[placed.length - 1].y - (B + 4) : 0;
+ if (over > 0) for (const l of placed) l.y -= over;
+
+ const endLabels = placed
+ .map((l) => {
+ const elbow =
+ Math.abs(l.y - l.lineY) > 1.5
+ ? `<path d="M${R} ${l.lineY.toFixed(1)} L${R + 5} ${l.lineY.toFixed(1)} ` +
+ `L${R + 5} ${l.y.toFixed(1)} L${R + 9} ${l.y.toFixed(1)}" fill="none" ` +
+ `stroke="${l.colour}" stroke-width="1" opacity="0.75"/>`
+ : "";
+ return (
+ elbow +
+ `<text x="${R + 13}" y="${(l.y + 3.5).toFixed(1)}" font-size="11" fill="${l.colour}"` +
+ `${l.weight ? ' font-weight="700"' : ""}>${esc(l.text)}</text>`
+ );
+ })
+ .join("");
+
+ // ---- the year axis ------------------------------------------------------
+ const y0 = Number(cfg.from.slice(0, 4)), y1 = Number(cfg.to.slice(0, 4));
+ const years = [];
+ for (let y = y0; y <= y1; y += 1) {
+ const x = X(`${y}-01-01`);
+ if (x < L - 1 || x > R + 1) continue;
+ years.push(
+ `<line x1="${x.toFixed(1)}" y1="${AXIS}" x2="${x.toFixed(1)}" y2="${AXIS + 4}" stroke="${pal.muted}" stroke-width="1"/>` +
+ `<text x="${x.toFixed(1)}" y="${AXIS + 22}" font-size="12" fill="${pal.muted}" text-anchor="middle">${y}</text>`,
+ );
+ }
+
+ const gridStep = yTop > 34 ? 10 : yTop > 12 ? 10 : 5;
+ const gridVals = [];
+ for (let v = 0; v <= yTop; v += gridStep) gridVals.push(v);
+ const grid = gridVals
+ .filter((v) => v <= yTop)
+ .map(
+ (v) =>
+ `<line x1="${L}" y1="${Y(v).toFixed(1)}" x2="${R}" y2="${Y(v).toFixed(1)}" stroke="${render.rail?.rule ?? "#2A322F"}" stroke-width="1"/>` +
+ `<text x="${L - 10}" y="${(Y(v) + 4).toFixed(1)}" font-size="12" fill="${pal.muted}" text-anchor="end">${v}</text>`,
+ )
+ .join("");
+
+ // ---- the sweep ----------------------------------------------------------
+ // A piecewise-linear map from finished-video seconds to chart x, through the
+ // schedule's own (claim time, claim date) pairs. That is what makes the
+ // playhead track the CURRENT MOMENT rather than crawling at a constant rate:
+ // where the cut lingers, the playhead lingers.
+ const keys = steps.map((s) => ({ t: atOf.get(s.id), x: X(s.date) })).sort((a, b) => a.t - b.t);
+ const sweep = [{ t: 0, x: L }, ...keys, { t: DUR, x: R }];
+
+ // ---- the readout --------------------------------------------------------
+ const RX = 1215;
+ const rollTweens = [];
+ let prevStated = null, prevImplied = null;
+ for (const s of steps) {
+ const t = atOf.get(s.id);
+ if (s.implied !== prevImplied) {
+ rollTweens.push({ t, k: "imp", v: s.implied, d: s.impliedDelta });
+ prevImplied = s.implied;
+ }
+ if (s.stated !== prevStated) {
+ rollTweens.push({ t, k: "sta", v: s.stated, d: s.statedDelta, pop: s.population });
+ prevStated = s.stated;
+ }
+ }
+ const flagCues = steps
+ .map((s) => {
+ const f = s.flags.find((x) => BADGED.has(x.rule));
+ return f ? { t: atOf.get(s.id), text: f.text } : null;
+ })
+ .filter(Boolean);
+
+ const data = { sweep, marks: steps.map((s) => ({ id: s.id, t: atOf.get(s.id) })), rollTweens, flagCues, dur: DUR };
+
+ return `<!doctype html>
+<html lang="en">
+ <head>
+ <meta charset="UTF-8" />
+ <meta name="viewport" content="width=${W}, height=${H}" />
+ <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
+ <style>
+ /* The REAL files, copied in beside the composition. A bare local()
+ resolves in a desktop browser and FAILS in the render browser, which
+ then silently falls back and shifts every metric in the band. */
+ @font-face { font-family: 'Band'; font-weight: 400; font-style: normal;
+ src: url('${fonts?.regular ?? ""}') format('truetype'); }
+ @font-face { font-family: 'Band'; font-weight: 700; font-style: normal;
+ src: url('${fonts?.bold ?? ""}') format('truetype'); }
+ * { margin: 0; padding: 0; box-sizing: border-box; }
+ html, body { margin: 0; width: ${W}px; height: ${H}px; overflow: hidden; background: transparent; }
+ body { font-family: 'Band', sans-serif; }
+ #root { position: relative; width: ${W}px; height: ${H}px; overflow: hidden; }
+ text { font-family: 'Band', sans-serif; }
+ .cap { position: absolute; font-size: 11px; letter-spacing: .06em; color: ${pal.muted}; }
+ .roll { position: absolute; font-size: 30px; font-weight: 700; color: ${pal.fg};
+ font-variant-numeric: tabular-nums; }
+ .chip { position: absolute; font-size: 12px; font-weight: 700; padding: 1px 6px; border-radius: 3px;
+ opacity: 0; }
+ .qual { position: absolute; font-size: 11px; color: ${pal.muted}; }
+ /* Width is DERIVED, not fixed. The band is as wide as the frame less the
+ rail, so widening the rail narrows it — and a fixed 275px box that fit
+ at 1500 runs off the frame at 1420, clipping the plain-words reason
+ mid-sentence. Which is the one thing a predicate exists to say. */
+ .why { position: absolute; font-size: 11px; color: ${cfg.flagColor}; width: ${W - RX - 14}px; line-height: 1.35; opacity: 0; }
+ </style>
+ </head>
+ <body>
+ <div id="root" data-composition-id="band" data-start="0" data-duration="${DUR.toFixed(2)}"
+ data-width="${W}" data-height="${H}">
+ <div id="band" class="clip" data-start="0" data-duration="${DUR.toFixed(2)}" data-track-index="1"
+ style="position:absolute; inset:0;">
+ <svg width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
+ <defs>
+ <pattern id="hatch" width="7" height="7" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">
+ <line x1="0" y1="0" x2="0" y2="7" stroke="${cfg.flagColor}" stroke-width="1.6" opacity="0.55"/>
+ </pattern>
+ <clipPath id="sweep"><rect id="sweeprect" x="0" y="0" width="${L}" height="${H}"/></clipPath>
+ </defs>
+
+ ${grid}
+ ${years.join("")}
+ <line x1="${L}" y1="${AXIS}" x2="${R}" y2="${AXIS}" stroke="${pal.muted}" stroke-width="1.4"/>
+
+ <!-- everything that is DRAWN is inside the sweep, so nothing can get
+ ahead of the playhead -- including the filled gap band, which has
+ no stroke to offset and would otherwise appear whole. -->
+ <g clip-path="url(#sweep)">
+ ${gapSvg}
+ <path id="s-implied" d="${stepPath(impliedPts, X, Y)}" fill="none" stroke="${cfg.impliedColor}"
+ stroke-width="${cfg.impliedWidth}" stroke-linejoin="round"/>
+ <path id="s-stated" d="${stepPath(statedPts, X, Y)}" fill="none" stroke="${cfg.statedColor}"
+ stroke-width="2.6" stroke-linejoin="round"/>
+ ${seriesSvg}
+ </g>
+
+ ${ticks.join("")}
+ ${marks.join("")}
+ ${badges.join("")}
+ ${endLabels}
+
+ <!-- The playhead wears the palette amber, NOT the flag colour. They
+ are different jobs -- one is chrome that says "here", the other is
+ data that says "this figure cannot be true" -- and a viewer who
+ has learned that the yellow triangle means trouble must not read
+ the same yellow sweeping across the plot every frame. -->
+ <line id="head" x1="${L}" y1="14" x2="${L}" y2="${AXIS + 14}" stroke="${pal.amber}" stroke-width="1.6"/>
+ </svg>
+
+ <div class="cap" style="left:${RX}px; top:12px;">IMPLIED · OUR SUM</div>
+ <div class="roll" id="r-imp" style="left:${RX}px; top:26px;">—</div>
+ <div class="chip" id="c-imp" style="left:${RX}px; top:64px; background:${pal.accent}; color:${pal.bg};">+0</div>
+
+ <div class="cap" style="left:${RX + 118}px; top:12px;">STATED</div>
+ <div class="roll" id="r-sta" style="left:${RX + 118}px; top:26px; color:${cfg.statedColor};">—</div>
+ <div class="chip" id="c-sta" style="left:${RX + 118}px; top:64px; background:${cfg.statedColor}; color:${pal.bg};">+0</div>
+ <div class="qual" id="q-sta" style="left:${RX + 118}px; top:88px;"></div>
+
+ <div class="cap" style="left:${RX + 218}px; top:12px;">GAP</div>
+ <div class="roll" id="r-gap" style="left:${RX + 218}px; top:26px; color:${pal.muted};">—</div>
+
+ <div class="why" id="why" style="left:${RX}px; top:112px;"></div>
+ <div class="cap" style="left:${RX}px; top:${H - 22}px; width:280px;">our sum of his per-company claims</div>
+ </div>
+ </div>
+
+ <script>
+ const DATA = ${JSON.stringify(data)};
+ window.__timelines = window.__timelines || {};
+ const tl = gsap.timeline({ paused: true });
+
+ // The sweep. One tween per schedule leg, so the playhead moves at the rate
+ // the CUT moves rather than at a constant rate across the axis.
+ const rect = document.getElementById("sweeprect");
+ const head = document.getElementById("head");
+ for (let i = 1; i < DATA.sweep.length; i += 1) {
+ const a = DATA.sweep[i - 1], b = DATA.sweep[i];
+ const d = Math.max(1 / 60, b.t - a.t);
+ tl.fromTo(rect, { attr: { width: a.x } }, { attr: { width: b.x }, duration: d, ease: "none" }, a.t);
+ tl.fromTo(head, { attr: { x1: a.x, x2: a.x } },
+ { attr: { x1: b.x, x2: b.x }, duration: d, ease: "none" }, a.t);
+ }
+
+ // Marks land as the playhead reaches them.
+ for (const m of DATA.marks) {
+ const dot = document.getElementById("mk-" + m.id);
+ if (dot) tl.fromTo(dot, { opacity: 0, scale: 0, transformOrigin: "center" },
+ { opacity: 1, scale: 1, duration: 0.3, ease: "back.out(2)" }, m.t);
+ const tick = document.getElementById("qt-" + m.id);
+ if (tick) tl.fromTo(tick, { opacity: 0 }, { opacity: 0.9, duration: 0.25 }, m.t);
+ const flag = document.getElementById("fg-" + m.id);
+ if (flag) tl.fromTo(flag, { opacity: 0, scale: 0.4, transformOrigin: "center" },
+ { opacity: 1, scale: 1, duration: 0.3, ease: "back.out(2)" }, m.t + 0.12);
+ }
+
+ // The readout. Only the number that CHANGED rolls; the other holds, and a
+ // value:null claim moves neither.
+ const st = { imp: null, sta: null };
+ const el = (id) => document.getElementById(id);
+ const paint = () => {
+ el("r-imp").textContent = st.imp == null ? "—" : String(Math.round(st.imp));
+ el("r-sta").textContent = st.sta == null ? "—" : String(Math.round(st.sta));
+ el("r-gap").textContent =
+ st.imp == null || st.sta == null ? "—" : String(Math.round(st.imp) - Math.round(st.sta));
+ };
+ paint();
+ // seen is BUILD-time bookkeeping and st is RUNTIME state, and they must
+ // not be the same object.
+ //
+ // They were, and the readout showed its own conclusion before drawing a
+ // term of it: the loop below walked st to the FINAL value while wiring
+ // the tweens, so the first paint() any tween triggered rendered the other
+ // series' closing number. Measured: the sourced cut showed "STATED 5" for
+ // its first 53 seconds, and the full cut showed "IMPLIED 23" five seconds
+ // in, beside an empty plot. That is precisely the "showing an answer the
+ // playhead has not reached" this band's sweep exists to prevent.
+ const seen = { imp: null, sta: null };
+ for (const r of DATA.rollTweens) {
+ const key = r.k;
+ if (seen[key] === null && r.v !== null) {
+ // First appearance: set rather than roll, because rolling up from a
+ // number that was never on screen invents a history.
+ tl.call(() => { st[key] = r.v; paint(); }, [], r.t);
+ } else {
+ const box = { v: seen[key] ?? 0 };
+ tl.to(box, {
+ v: r.v ?? 0, duration: 0.45, ease: "power2.out",
+ onUpdate: () => { st[key] = box.v; paint(); },
+ }, r.t);
+ }
+ seen[key] = r.v;
+ if (r.d != null) {
+ const chip = el(key === "imp" ? "c-imp" : "c-sta");
+ const sign = r.d > 0 ? "▲ +" : "▼ ";
+ tl.call((c, s) => { c.textContent = s; }, [chip, sign + r.d], r.t);
+ tl.fromTo(chip, { opacity: 0, y: 5 }, { opacity: 1, y: 0, duration: 0.22 }, r.t);
+ tl.to(chip, { opacity: 0, duration: 0.3 }, r.t + 1.6);
+ }
+ if (key === "sta" && r.pop) {
+ tl.call((e, s) => { e.textContent = s; }, [el("q-sta"), r.pop], r.t);
+ }
+ }
+
+ // The plain-words reason, on screen only while its claim is current.
+ for (const f of DATA.flagCues) {
+ tl.call((e, s) => { e.textContent = s; }, [el("why"), "⚑ " + f.text], f.t);
+ tl.fromTo(el("why"), { opacity: 0 }, { opacity: 1, duration: 0.3 }, f.t);
+ tl.to(el("why"), { opacity: 0, duration: 0.35 }, f.t + 3.2);
+ }
+
+ window.__timelines["band"] = tl;
+ </script>
+ </body>
+</html>
+`;
+}
+
+// ---------------------------------------------------------------------------
+// CLI
+// ---------------------------------------------------------------------------
+
+const HF_JSON = JSON.stringify(
+ { $schema: "https://hyperframes.heygen.com/schema/hyperframes.json", paths: { blocks: "compositions", assets: "assets" } },
+ null,
+ 2,
+);
+
+export async function composeChrome({ manifestPath, outDir, region = "chart", duration = null, doRender = false, fps = null, variant = "sourced" }) {
+ // The variant's view, and its own out directory. The band plots the ledger
+ // the cut carries; handed the whole manifest it would draw marks for claims
+ // this cut never makes and put the playhead schedule out by that many.
+ const manifest = selectVariant(JSON.parse(await readFile(manifestPath, "utf8")), variant);
+ const base = outDir ?? path.join(path.dirname(path.resolve(manifestPath)), "out", variant);
+ const schedule = JSON.parse(await readFile(path.join(base, "schedule.json"), "utf8"));
+ const totals = ledgerTotals(manifest.ledger);
+
+ const projDir = path.join(base, "chrome", region);
+ await mkdir(path.join(projDir, "assets"), { recursive: true });
+ if (region !== "chart") throw new Error(`unknown chrome region: ${region}`);
+
+ // The SAME faces the ffmpeg cards use, copied in beside the composition.
+ // Chrome will not resolve a bare local() in the render browser, and the
+ // failure is silent: it falls back and every metric in the band shifts.
+ const fonts = {};
+ for (const [slot, src] of [["regular", manifest.render.fontRegular], ["bold", manifest.render.fontBold]]) {
+ if (!src) continue;
+ const name = `${slot}${path.extname(src) || ".ttf"}`;
+ await copyFile(src, path.join(projDir, "assets", name)).catch(() => {});
+ fonts[slot] = `assets/${name}`;
+ }
+
+ const html = chartBandHtml(manifest, totals, schedule, {
+ fonts,
+ ...(duration ? { duration } : {}),
+ });
+ await writeFile(path.join(projDir, "index.html"), html, "utf8");
+ await writeFile(path.join(projDir, "hyperframes.json"), HF_JSON + "\n", "utf8");
+
+ if (!doRender) return { projDir, frames: null };
+
+ const frames = path.join(base, "chrome", `${region}-frames`);
+ await run("npx", [
+ "--yes", "hyperframes@latest", "render",
+ "--format", "png-sequence", "--quality", "high",
+ "--fps", String(fps ?? manifest.render.fps),
+ "--output", frames, projDir,
+ ], { maxBuffer: 1 << 26 });
+ return { projDir, frames };
+}
+
+if (import.meta.url === `file://${process.argv[1]}`) {
+ const argv = process.argv.slice(2);
+ const flag = (n) => { const i = argv.indexOf(n); return i < 0 ? null : argv[i + 1]; };
+ const VALUED = new Set(["--out", "--region", "--duration", "--variant"]);
+ 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] [--variant sourced|full]\n" +
+ " [--duration <s>] [--out <dir>] [--render]",
+ );
+ process.exit(2);
+ }
+ const r = await composeChrome({
+ manifestPath,
+ outDir: flag("--out"),
+ region: flag("--region") ?? "chart",
+ variant: flag("--variant") ?? "sourced",
+ duration: flag("--duration") ? Number(flag("--duration")) : null,
+ doRender: argv.includes("--render"),
+ });
+ console.log(r.frames ? `frames -> ${r.frames}` : `project -> ${r.projDir}`);
+}
diff --git a/scripts/report-to-video/ledger-totals.mjs b/scripts/report-to-video/ledger-totals.mjs
@@ -0,0 +1,540 @@
+// The ledger, adjudicated, turned into two running totals and five coherence
+// flags.
+//
+// Plain ESM with no I/O and no rendering: `umtool`'s decisions reducer imports
+// it from a Next server component, `compose-chrome.mjs` imports it from a CLI,
+// and `node --test` runs it with a literal array. Every consumer gets the same
+// arithmetic, which is the only way the chart band and the inbox can agree.
+//
+// ---------------------------------------------------------------------------
+// Why an adjudication is required before any of this runs
+// ---------------------------------------------------------------------------
+// Both totals used to rest on `ledger[].company`, an undocumented interpretation
+// that quietly mixed four different things:
+//
+// * SCOPE AMBIGUITY -- "I have 10 employees, my coffee company employees, my
+// editors" is all-companies or coffee-only depending on where the comma
+// falls, and nothing recorded which reading was taken.
+// * DERIVED VALUES -- 18 and 20 are OUR sums of his per-company claims. He
+// never utters either. The old manifest called 18 "the only explicit sum in
+// the corpus", which is exactly backwards.
+// * POPULATION DRIFT -- "full-time salaried", "employees", "all basically
+// contractors" and "all 1099 and not full-time" were compared as one series.
+// * SYNTHETIC VALUES -- 10.5 for "about 10 people, 11 people" and 5.5 for
+// "five or six" are midpoints we invented and then attributed to him.
+//
+// So the STATED series may contain only a figure he utters as a single number
+// for a named scope. Our arithmetic lives in the IMPLIED series, which says on
+// screen that it is ours. That weakens "his stated total swings wildly" a little
+// and makes it survive scrutiny.
+
+export const SCOPES = ["media", "coffee", "publica", "all"];
+/** The three that sum. `all` is a claim ABOUT the sum, never a term in it. */
+export const COMPANY_SCOPES = ["media", "coffee", "publica"];
+export const POPULATIONS = ["employees", "full-time", "salaried", "contractor", "1099", "people"];
+export const VALUE_KINDS = ["uttered", "derived", "synthetic"];
+export const SCOPE_CONFIDENCE = ["clear", "read", "unresolved"];
+
+/** The six fields an entry must carry before it may feed a total. */
+export const ADJUDICATION_FIELDS = [
+ "scope",
+ "scopeBasis",
+ "scopeConfidence",
+ "population",
+ "valueKind",
+ "flags",
+];
+
+const isStr = (v) => typeof v === "string" && v.trim().length > 0;
+
+/**
+ * Which of the six an entry is missing or has wrong. Empty array == adjudicated.
+ *
+ * Returned rather than thrown because this is what the inbox renders: one
+ * `claim-unadjudicated` row per entry, naming the fields still outstanding.
+ */
+export function adjudicationGaps(entry) {
+ const gaps = [];
+ if (!SCOPES.includes(entry?.scope)) gaps.push("scope");
+ // The phrase that settles it. An adjudication with no basis is an opinion,
+ // and the whole point of the exercise was to stop shipping those.
+ if (!isStr(entry?.scopeBasis)) gaps.push("scopeBasis");
+ if (!SCOPE_CONFIDENCE.includes(entry?.scopeConfidence)) gaps.push("scopeConfidence");
+ if (!POPULATIONS.includes(entry?.population)) gaps.push("population");
+ if (!VALUE_KINDS.includes(entry?.valueKind)) gaps.push("valueKind");
+ if (!Array.isArray(entry?.flags)) gaps.push("flags");
+ return gaps;
+}
+
+export const isAdjudicated = (entry) => adjudicationGaps(entry).length === 0;
+
+/** Entries that carry a number nobody has ruled on yet. */
+export const unadjudicatedOf = (ledger) =>
+ (ledger ?? []).filter((e) => !isAdjudicated(e));
+
+export class UnadjudicatedLedger extends Error {
+ constructor(entries) {
+ const ids = entries.map((e) => e?.id ?? "?");
+ super(
+ `${ids.length} ledger entr${ids.length === 1 ? "y is" : "ies are"} unadjudicated ` +
+ `(${ids.slice(0, 6).join(", ")}${ids.length > 6 ? ", …" : ""}). ` +
+ "Both totals lie if you act on an unadjudicated ledger — work the inbox first.",
+ );
+ this.name = "UnadjudicatedLedger";
+ this.entries = entries;
+ this.ids = ids;
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Dates. Two entries are month-only ("2024-02", "2025-06") because that is all
+// the source supports; both are qualitative, so they order the rail without
+// touching a total. Sorting pads them to the first of the month, which puts them
+// before any dated claim in the same month rather than guessing a day.
+// ---------------------------------------------------------------------------
+export function dateKey(d) {
+ const s = String(d ?? "");
+ const m = /^(\d{4})(?:-(\d{2}))?(?:-(\d{2}))?$/.exec(s);
+ if (!m) return "9999-99-99";
+ return `${m[1]}-${m[2] ?? "01"}-${m[3] ?? "01"}`;
+}
+
+/** Chronological, ties broken by ledger id so the order is total and stable. */
+export const chronological = (ledger) =>
+ [...(ledger ?? [])].sort((a, b) => {
+ const d = dateKey(a.date).localeCompare(dateKey(b.date));
+ return d !== 0 ? d : String(a.id).localeCompare(String(b.id));
+ });
+
+// ---------------------------------------------------------------------------
+// The five predicates.
+//
+// "Doesn't make sense" is COMPUTED, never asserted. Each one is named, each one
+// renders as plain words on screen, and each one is a decision the human takes
+// rather than a fact the video states.
+//
+// Deliberately NOT a rule: a large rise or fall between claims. Fluctuation is
+// the SUBJECT of the video, not a defect, and flagging it would be putting a
+// thumb on the scale.
+// ---------------------------------------------------------------------------
+
+const SCOPE_LABEL = {
+ media: "The Quartering",
+ coffee: "Coffee Brand Coffee",
+ publica: "The Publica",
+ all: "all companies",
+};
+
+/** Populations that describe someone who is not, on his own account, staff. */
+const UNDERCUTTING = new Set(["contractor", "1099"]);
+
+export const PREDICATES = [
+ "contradicts_component",
+ "same_day_conflict",
+ "self_negating",
+ "population_mismatch",
+ "not_his_number",
+ "status_flip",
+];
+
+// ---------------------------------------------------------------------------
+// Employment status, as two camps.
+//
+// `employees` and `people` are in NEITHER. They are what he says when he is not
+// making a claim about status at all, and reading them as one side or the other
+// would manufacture reversals out of a change of vocabulary.
+// ---------------------------------------------------------------------------
+const STAFF = new Set(["full-time", "salaried"]);
+const CONTRACT = new Set(["contractor", "1099"]);
+const campOf = (p) => (STAFF.has(p) ? "staff" : CONTRACT.has(p) ? "contract" : null);
+const STATUS_WORD = {
+ "full-time": "full time",
+ salaried: "salaried",
+ contractor: "contractors",
+ 1099: "1099, not full-time",
+};
+
+/**
+ * Whether two claims are about overlapping people.
+ *
+ * An `all` claim is about every payroll he has, so it is comparable with any
+ * company; two different companies are not comparable with each other. Without
+ * that asymmetry the corpus's clearest reversal -- December 2024's ten at the
+ * channel are "all basically contractors", May 2025's ten or eleven are "full
+ * time" -- is invisible, because one is scoped to the channel and the other to
+ * everything.
+ */
+const comparableScopes = (a, b) => a === b || a === "all" || b === "all";
+
+/**
+ * A stated total below his own most recent claim for ONE company.
+ *
+ * The canonical case: 2023-04-21 "how will I pay my six employees?" against
+ * 2023-04-19 "already… almost 10 staff members" at The Publica, two days
+ * earlier. Six cannot contain ten.
+ */
+function contradictsComponent(step, state) {
+ if (step.scope !== "all" || step.value == null) return null;
+ const worst = COMPANY_SCOPES.map((c) => state[c])
+ .filter((s) => s && s.value != null && s.value > step.value)
+ .sort((a, b) => b.value - a.value)[0];
+ if (!worst) return null;
+ return {
+ rule: "contradicts_component",
+ text:
+ `he says ${fmt(step.value)} total; he said ${fmt(worst.value)} at ` +
+ `${SCOPE_LABEL[worst.scope]} ${gapWords(worst.date, step.date)}`,
+ against: worst.id,
+ };
+}
+
+/** Two claims, same scope, same date, different values. */
+function sameDayConflict(step, byScopeDate) {
+ if (step.value == null) return null;
+ const peers = (byScopeDate.get(`${step.scope}|${step.date}`) ?? []).filter(
+ (o) => o.id !== step.id && o.value != null && o.value !== step.value,
+ );
+ if (!peers.length) return null;
+ const other = peers[0];
+ return {
+ rule: "same_day_conflict",
+ text:
+ step.scope === "all"
+ ? `two different totals the same day — ${fmt(step.value)} and ${fmt(other.value)}`
+ : `two different ${SCOPE_LABEL[step.scope]} counts the same day — ` +
+ `${fmt(step.value)} and ${fmt(other.value)}`,
+ against: other.id,
+ };
+}
+
+/**
+ * The quote's own qualifier undercuts the count: he names a number of
+ * "employees" and then says in the same breath that they are not employees.
+ *
+ * Derived from the adjudicated `population`, not from a regex over the quote —
+ * whether "basically contractors" undercuts "10 employees" is exactly the
+ * reading a human is signing off on.
+ */
+function selfNegating(step) {
+ if (!UNDERCUTTING.has(step.population)) return null;
+ if (step.value == null) return null;
+ return {
+ rule: "self_negating",
+ text: `${fmt(step.value)} ${step.population === "1099" ? "— “all 1099 and not full-time”" : "— “all basically contractors”"}`,
+ };
+}
+
+/** A different denominator from the rest of its own series. */
+function populationMismatch(step, baseline) {
+ const base = baseline.get(step.scope) ?? "employees";
+ if (step.population === base) return null;
+ return {
+ rule: "population_mismatch",
+ text: `${step.population}, not ${base}`,
+ baseline: base,
+ };
+}
+
+/**
+ * The same people, described as staff and then as contractors, or the reverse.
+ *
+ * Only against the MOST RECENT comparable claim that carries a camp at all --
+ * not against every earlier one. Fire on every pair and a single 2022 "all 1099
+ * and not full-time" flags each of the next seven claims in turn, which reads
+ * as seven findings when it is one. Bounded this way, the predicate fires at
+ * the TRANSITIONS, which is what a flip-flop is.
+ */
+function statusFlip(step, priorCamps) {
+ const camp = campOf(step.population);
+ if (!camp) return null;
+ let prev = null;
+ for (let i = priorCamps.length - 1; i >= 0; i -= 1) {
+ if (comparableScopes(priorCamps[i].scope, step.scope)) { prev = priorCamps[i]; break; }
+ }
+ if (!prev || prev.camp === camp) return null;
+ const when = gapWords(prev.date, step.date);
+ const same =
+ prev.value != null && step.value != null && prev.value === step.value
+ ? `the same ${fmt(step.value)} were`
+ : "they were";
+ return {
+ rule: "status_flip",
+ text: `${when} ${same} ${STATUS_WORD[prev.population]}, now ${STATUS_WORD[step.population]}`,
+ against: prev.id,
+ };
+}
+
+/** Our arithmetic or our midpoint, wearing his voice. */
+function notHisNumber(step) {
+ if (step.valueKind === "uttered") return null;
+ return {
+ rule: "not_his_number",
+ text:
+ step.valueKind === "derived"
+ ? "our sum, not his figure"
+ : "our midpoint, not his figure",
+ };
+}
+
+const fmt = (n) => (Number.isInteger(n) ? String(n) : String(n));
+
+function gapWords(from, to) {
+ const a = new Date(`${dateKey(from)}T00:00:00Z`).getTime();
+ const b = new Date(`${dateKey(to)}T00:00:00Z`).getTime();
+ const days = Math.round((b - a) / 86400000);
+ if (!Number.isFinite(days)) return "earlier";
+ if (days <= 0) return "the same day";
+ if (days === 1) return "a day earlier";
+ if (days < 31) return `${days} days earlier`;
+ const months = Math.round(days / 30.44);
+ if (months < 24) return `${months} month${months === 1 ? "" : "s"} earlier`;
+ return `${Math.round(days / 365.25)} years earlier`;
+}
+
+// ---------------------------------------------------------------------------
+// The walk.
+// ---------------------------------------------------------------------------
+
+/**
+ * Running per-company state, both totals, per-step deltas and every fired
+ * predicate — one pass, in date order.
+ *
+ * @param {Array<object>} ledger
+ * @param {{ strict?: boolean }} [opts] strict:false computes over whatever is
+ * adjudicated so the inbox can show its own coherence rows while the rest of
+ * the ledger is still being worked. Any renderer must use strict:true.
+ */
+export function ledgerTotals(ledger, { strict = true } = {}) {
+ const all = chronological(ledger);
+ const pending = all.filter((e) => !isAdjudicated(e));
+ if (strict && pending.length) throw new UnadjudicatedLedger(pending);
+
+ // Only adjudicated entries take part. An `unresolved` scope is a legitimate
+ // outcome and MUST NOT silently feed a total, so it is carried on the step
+ // (the rail still shows the row) and skipped by both series.
+ const usable = all.filter(isAdjudicated);
+
+ // The baseline population per scope: the most common one in that scope's own
+ // series, ties going to `employees`. Computed over uttered values only, so a
+ // pile of derived rows cannot move the baseline the real claims are judged by.
+ const baseline = new Map();
+ for (const scope of SCOPES) {
+ const counts = new Map();
+ for (const e of usable) {
+ if (e.scope !== scope || e.valueKind !== "uttered") continue;
+ counts.set(e.population, (counts.get(e.population) ?? 0) + 1);
+ }
+ let best = "employees";
+ let bestN = counts.get("employees") ?? 0;
+ for (const [p, n] of counts) if (n > bestN) [best, bestN] = [p, n];
+ baseline.set(scope, best);
+ }
+
+ const byScopeDate = new Map();
+ for (const e of usable) {
+ if (e.scopeConfidence === "unresolved") continue;
+ const k = `${e.scope}|${e.date}`;
+ if (!byScopeDate.has(k)) byScopeDate.set(k, []);
+ byScopeDate.get(k).push(e);
+ }
+
+ /** Most recent usable claim per company scope, as we walk. */
+ const state = { media: null, coffee: null, publica: null };
+ /** Every claim so far that says what its people ARE, in order. */
+ const priorCamps = [];
+ let stated = null;
+ let implied = null;
+
+ const steps = [];
+ const series = { media: [], coffee: [], publica: [], stated: [], implied: [] };
+
+ for (const e of usable) {
+ const counts = e.value != null;
+ const usableHere = counts && e.scopeConfidence !== "unresolved";
+
+ const flags = [];
+ // `not_his_number` and `population_mismatch` judge the entry alone;
+ // `contradicts_component` and `same_day_conflict` judge it against the walk.
+ if (usableHere) {
+ const f1 = contradictsComponent({ ...e }, state);
+ if (f1) flags.push(f1);
+ const f2 = sameDayConflict({ ...e }, byScopeDate);
+ if (f2) flags.push(f2);
+ const f3 = selfNegating(e);
+ if (f3) flags.push(f3);
+ const f5 = notHisNumber(e);
+ if (f5) flags.push(f5);
+ }
+ // OUTSIDE the `usableHere` gate, deliberately. "All my workers are contract
+ // workers" carries no figure at all, and it is the single clearest status
+ // claim in the corpus — gating this on a number would drop exactly the
+ // rows the predicate exists to read.
+ if (e.scopeConfidence !== "unresolved") {
+ const f6 = statusFlip(e, priorCamps);
+ if (f6) flags.push(f6);
+ }
+ // Recorded AFTER the predicate reads it, so a claim never flips against
+ // itself, and only for entries whose scope is settled -- an unresolved
+ // scope cannot say whose status reversed.
+ if (e.scopeConfidence !== "unresolved" && campOf(e.population)) {
+ priorCamps.push({
+ id: e.id, scope: e.scope, date: e.date, value: e.value ?? null,
+ population: e.population, camp: campOf(e.population),
+ });
+ }
+ const f4 = populationMismatch(e, baseline);
+ if (f4) flags.push(f4);
+ // Anything the adjudicator wrote by hand rides alongside the computed ones.
+ for (const raw of e.flags ?? []) {
+ if (isStr(raw)) flags.push({ rule: "adjudicator", text: raw });
+ }
+
+ const beforeStated = stated;
+ const beforeImplied = implied;
+
+ if (usableHere && COMPANY_SCOPES.includes(e.scope)) {
+ state[e.scope] = { id: e.id, scope: e.scope, value: e.value, date: e.date };
+ series[e.scope].push({ id: e.id, date: e.date, value: e.value });
+ }
+
+ // The IMPLIED total is our sum of his most recent per-company claims. It is
+ // recomputed after every company step, and it is defined as soon as ONE
+ // company has a number — a sum of one is still our sum.
+ const basis = {};
+ let sum = null;
+ for (const c of COMPANY_SCOPES) {
+ basis[c] = state[c] ? { ...state[c] } : null;
+ if (state[c]) sum = (sum ?? 0) + state[c].value;
+ }
+ implied = sum;
+
+ // The STATED total moves only on an `all`-scope claim he actually utters.
+ if (usableHere && e.scope === "all" && e.valueKind === "uttered") {
+ stated = e.value;
+ series.stated.push({ id: e.id, date: e.date, value: e.value });
+ }
+
+ if (implied !== beforeImplied) {
+ series.implied.push({ id: e.id, date: e.date, value: implied });
+ }
+
+ const movedStated = stated !== beforeStated;
+ const movedImplied = implied !== beforeImplied;
+
+ steps.push({
+ id: e.id,
+ date: e.date,
+ scope: e.scope,
+ scopeConfidence: e.scopeConfidence,
+ scopeBasis: e.scopeBasis,
+ population: e.population,
+ valueKind: e.valueKind,
+ value: e.value ?? null,
+ display: e.display ?? null,
+ label: e.label ?? null,
+ quote: e.quote ?? null,
+ src: e.src ?? null,
+ roles: Array.isArray(e.roles) && e.roles.length ? e.roles : null,
+ entryId: e.entryId ?? null,
+ // No y position, ever. "several", "very few" and "+1" are a tick below the
+ // time axis and a rail row; forcing them onto the value axis would be
+ // inventing a number, which is the thing this whole module exists to stop.
+ qualitative: !counts,
+ stated,
+ implied,
+ statedDelta: movedStated && beforeStated != null ? stated - beforeStated : null,
+ impliedDelta: movedImplied && beforeImplied != null ? implied - beforeImplied : null,
+ gap: stated != null && implied != null ? implied - stated : null,
+ impliedBasis: basis,
+ // Which number rolled. A `value: null` claim moves neither, and the
+ // readout must hold both rather than animate a change that did not happen.
+ moved: movedStated && movedImplied ? "both" : movedStated ? "stated" : movedImplied ? "implied" : null,
+ flags,
+ });
+ }
+
+ return {
+ steps,
+ series,
+ baseline: Object.fromEntries(baseline),
+ unresolved: usable.filter((e) => e.scopeConfidence === "unresolved").map((e) => e.id),
+ unadjudicated: pending.map((e) => e.id),
+ final: {
+ stated,
+ implied,
+ gap: stated != null && implied != null ? implied - stated : null,
+ },
+ };
+}
+
+// ---------------------------------------------------------------------------
+// The roster
+// ---------------------------------------------------------------------------
+// Every time he enumerates WHO works for him it is two video editors and a
+// graphics designer -- in July 2023, September 2023, October 2023, December
+// 2023 and December 2024. The totals attached to that roster are three, then
+// four, then ten. So the roster is the control: it is the thing that does not
+// move while the numbers above it do, and drawing it is what makes that
+// visible without the video having to assert it.
+//
+// `roles` is OPTIONAL and is not one of the six. A claim with no roster is not
+// unadjudicated -- most claims are a number and nothing else, and gating on a
+// field that only five entries can ever carry would block the inbox forever.
+
+/** One enumerated role. `verbatim` is his words; the rest is our reading. */
+export const ROLE_FIELDS = ["role", "count", "verbatim"];
+
+/** Is this a usable roster? Returns the reasons it is not. */
+export function rolesGaps(roles) {
+ if (roles === undefined) return [];
+ if (!Array.isArray(roles) || !roles.length) return ["roles must be a non-empty array"];
+ const bad = [];
+ roles.forEach((r, i) => {
+ if (!isStr(r?.role)) bad.push(`roles[${i}].role`);
+ if (!Number.isFinite(r?.count) || r.count < 0) bad.push(`roles[${i}].count`);
+ if (!isStr(r?.verbatim)) bad.push(`roles[${i}].verbatim`);
+ });
+ return bad;
+}
+
+/**
+ * The roster he last enumerated at or before `date`, or null.
+ *
+ * One implementation, because the rail draws it under the tally and a chapter
+ * card states it in words, and the two disagreeing would be the video arguing
+ * with itself on screen.
+ */
+export function rosterAt(ledger, date) {
+ const key = dateKey(date);
+ let best = null;
+ for (const e of chronological(ledger)) {
+ if (!Array.isArray(e.roles) || !e.roles.length) continue;
+ if (dateKey(e.date) > key) break;
+ best = e;
+ }
+ return best ? { id: best.id, date: best.date, roles: best.roles } : null;
+}
+
+/**
+ * `2 editors · 1 designer` — the roster in as few words as it can be said in.
+ *
+ * The LAST word of the role is the label, because that is the part that carries
+ * the meaning ("video editor" → editors, "graphics designer" → designers) and
+ * the rail column has room for nothing else.
+ */
+export function rosterLine(roles) {
+ if (!Array.isArray(roles) || !roles.length) return null;
+ return roles
+ .map((r) => {
+ const head = String(r.role).trim().split(/\s+/).pop();
+ return `${r.count} ${head}${r.count === 1 ? "" : "s"}`;
+ })
+ .join(" · ");
+}
+
+/** Every fired predicate, flattened — what the `claim-incoherent` rows are. */
+export function coherenceFlags(ledger, opts) {
+ return ledgerTotals(ledger, opts).steps.flatMap((s) =>
+ s.flags.map((f) => ({ id: s.id, date: s.date, scope: s.scope, ...f })),
+ );
+}
diff --git a/scripts/report-to-video/ledger-totals.test.mjs b/scripts/report-to-video/ledger-totals.test.mjs
@@ -0,0 +1,705 @@
+// Tests for ledger-totals.mjs.
+//
+// The fixture is a hand-built miniature of the real quartering-employee-count
+// ledger: the same shapes, the same two named landmines, and nothing else. Run
+// with: pnpm test:scripts
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import {
+ ADJUDICATION_FIELDS,
+ PREDICATES,
+ UnadjudicatedLedger,
+ adjudicationGaps,
+ chronological,
+ coherenceFlags,
+ dateKey,
+ isAdjudicated,
+ ledgerTotals,
+ rolesGaps,
+ rosterAt,
+ rosterLine,
+} from "./ledger-totals.mjs";
+import { scheduleClaims, selectVariant } from "./build-video.mjs";
+import { ledgerRevealAt, ledgerSeconds } from "./render-cards.mjs";
+
+/** An adjudicated entry, with the six fields defaulted to the boring answer. */
+const claim = (o) => ({
+ scope: "media",
+ scopeBasis: "names the channel",
+ scopeConfidence: "clear",
+ population: "employees",
+ valueKind: "uttered",
+ flags: [],
+ ...o,
+});
+
+// ---------------------------------------------------------------------------
+// The gate
+// ---------------------------------------------------------------------------
+
+test("adjudicationGaps names every missing field", () => {
+ assert.deepEqual(adjudicationGaps({ id: "x" }).sort(), [...ADJUDICATION_FIELDS].sort());
+ assert.deepEqual(adjudicationGaps(claim({ id: "x" })), []);
+ assert.equal(isAdjudicated(claim({ id: "x" })), true);
+});
+
+test("an empty scopeBasis is not an adjudication", () => {
+ // An adjudication with no basis is an opinion, and the point of the exercise
+ // was to stop shipping those.
+ assert.deepEqual(adjudicationGaps(claim({ id: "x", scopeBasis: " " })), ["scopeBasis"]);
+});
+
+test("each of the six is checked against its own vocabulary", () => {
+ assert.deepEqual(adjudicationGaps(claim({ scope: "everything" })), ["scope"]);
+ assert.deepEqual(adjudicationGaps(claim({ population: "staff" })), ["population"]);
+ assert.deepEqual(adjudicationGaps(claim({ valueKind: "guessed" })), ["valueKind"]);
+ assert.deepEqual(adjudicationGaps(claim({ scopeConfidence: "maybe" })), ["scopeConfidence"]);
+ assert.deepEqual(adjudicationGaps(claim({ flags: "none" })), ["flags"]);
+});
+
+test("ledgerTotals refuses to run on an unadjudicated ledger", () => {
+ const led = [claim({ id: "a", date: "2022-01-01", value: 4 }), { id: "b", date: "2022-02-01", value: 5 }];
+ assert.throws(() => ledgerTotals(led), UnadjudicatedLedger);
+ assert.throws(() => ledgerTotals(led), /b/);
+ // …but computes over what it has when the inbox asks.
+ const soft = ledgerTotals(led, { strict: false });
+ assert.deepEqual(soft.unadjudicated, ["b"]);
+ assert.equal(soft.steps.length, 1);
+});
+
+// ---------------------------------------------------------------------------
+// Ordering
+// ---------------------------------------------------------------------------
+
+test("month-only dates sort to the first of the month", () => {
+ assert.equal(dateKey("2024-02"), "2024-02-01");
+ assert.equal(dateKey("2024-02-15"), "2024-02-15");
+ assert.equal(dateKey("garbage"), "9999-99-99");
+ const ids = chronological([
+ { id: "b", date: "2024-02-15" },
+ { id: "a", date: "2024-02" },
+ ]).map((e) => e.id);
+ assert.deepEqual(ids, ["a", "b"]);
+});
+
+test("ties break on id, so the order is total and stable", () => {
+ const ids = chronological([
+ { id: "z", date: "2024-01-01" },
+ { id: "a", date: "2024-01-01" },
+ ]).map((e) => e.id);
+ assert.deepEqual(ids, ["a", "z"]);
+});
+
+// ---------------------------------------------------------------------------
+// The two series
+// ---------------------------------------------------------------------------
+
+const SERIES_FIXTURE = [
+ claim({ id: "m1", date: "2022-11-02", scope: "media", value: 5 }),
+ claim({ id: "c1", date: "2023-04-19", scope: "coffee", value: 5, scopeBasis: "names coffee" }),
+ claim({ id: "p1", date: "2023-04-19", scope: "publica", value: 10, scopeBasis: "names the publica" }),
+ claim({ id: "e0", date: "2022-08-30", scope: "all", value: 4, scopeBasis: "no company named" }),
+];
+
+test("the implied total is our sum of his most recent per-company claims", () => {
+ const t = ledgerTotals(SERIES_FIXTURE);
+ const at = (id) => t.steps.find((s) => s.id === id);
+ // 2022-08-30 is the FIRST entry: no company has spoken, so there is nothing
+ // to sum. Our sum of no claims is not zero, it is undefined.
+ assert.equal(at("e0").implied, null);
+ assert.equal(at("e0").gap, null);
+ // 2022-11-02: media alone. A sum of one is still our sum.
+ assert.equal(at("m1").implied, 5);
+ // 2023-04-19: media 5 + coffee 5 + publica 10.
+ assert.equal(at("p1").implied, 20);
+ assert.equal(t.final.implied, 20);
+});
+
+test("the stated total is the last figure he utters for the whole payroll", () => {
+ const t = ledgerTotals(SERIES_FIXTURE);
+ assert.equal(t.final.stated, 4);
+ // The exact landmark the plan calls for: implied 20 against stated 4.
+ assert.equal(t.steps.find((s) => s.id === "p1").gap, 16);
+ assert.deepEqual(
+ t.series.stated.map((p) => [p.id, p.value]),
+ [["e0", 4]],
+ );
+});
+
+test("a derived sum never enters the stated series", () => {
+ // This is the correction the whole re-cut turns on: 18 and 20 are OUR
+ // arithmetic. He never utters either, so neither may be plotted as his claim.
+ const led = [
+ claim({ id: "c", date: "2024-11-23", scope: "coffee", value: 10, scopeBasis: "names coffee" }),
+ claim({ id: "m", date: "2024-11-23", scope: "media", value: 8 }),
+ claim({
+ id: "e8",
+ date: "2024-11-23",
+ scope: "all",
+ value: 18,
+ valueKind: "derived",
+ scopeBasis: "10 + 8, summed by us",
+ }),
+ ];
+ const t = ledgerTotals(led);
+ assert.deepEqual(t.series.stated, []);
+ assert.equal(t.final.stated, null);
+ assert.equal(t.final.implied, 18);
+ const flags = t.steps.find((s) => s.id === "e8").flags.map((f) => f.rule);
+ assert.ok(flags.includes("not_his_number"));
+});
+
+test("a synthetic midpoint never enters the stated series either", () => {
+ const t = ledgerTotals([
+ claim({
+ id: "e11",
+ date: "2025-05-30",
+ scope: "all",
+ value: 10.5,
+ valueKind: "synthetic",
+ population: "people",
+ scopeBasis: "“about 10 people, 11 people full time”",
+ }),
+ ]);
+ assert.equal(t.final.stated, null);
+ const f = t.steps[0].flags.find((x) => x.rule === "not_his_number");
+ assert.equal(f.text, "our midpoint, not his figure");
+});
+
+test("an unresolved scope feeds neither total", () => {
+ const t = ledgerTotals([
+ claim({ id: "m1", date: "2022-01-01", scope: "media", value: 5 }),
+ claim({
+ id: "e?",
+ date: "2024-08-14",
+ scope: "all",
+ value: 10,
+ scopeConfidence: "unresolved",
+ scopeBasis: "“I have 10 employees, my coffee company employees…” — comma decides it",
+ }),
+ ]);
+ assert.equal(t.final.stated, null, "an unresolved claim must not become the stated total");
+ assert.equal(t.final.implied, 5);
+ assert.deepEqual(t.unresolved, ["e?"]);
+});
+
+test("a qualitative claim has no y position and moves neither number", () => {
+ const t = ledgerTotals([
+ claim({ id: "m1", date: "2022-01-01", scope: "media", value: 5 }),
+ claim({ id: "m12", date: "2024-02", scope: "media", value: null, scopeBasis: "hires a publisher" }),
+ ]);
+ const q = t.steps.find((s) => s.id === "m12");
+ assert.equal(q.qualitative, true);
+ assert.equal(q.value, null);
+ assert.equal(q.moved, null, "a value:null claim must move neither number");
+ assert.equal(q.implied, 5);
+});
+
+test("deltas report the step, and only on the number that moved", () => {
+ const t = ledgerTotals([
+ claim({ id: "m1", date: "2022-01-01", scope: "media", value: 5 }),
+ claim({ id: "m2", date: "2022-02-01", scope: "media", value: 3 }),
+ ]);
+ const s = t.steps.find((x) => x.id === "m2");
+ assert.equal(s.impliedDelta, -2);
+ assert.equal(s.statedDelta, null);
+ assert.equal(s.moved, "implied");
+});
+
+// ---------------------------------------------------------------------------
+// The five predicates
+// ---------------------------------------------------------------------------
+
+test("contradicts_component: stated 6 against The Publica's 10 two days earlier", () => {
+ // The case the plan names. Six cannot contain ten.
+ const t = ledgerTotals([
+ claim({ id: "p01", date: "2023-04-19", scope: "publica", value: 10, scopeBasis: "names the publica" }),
+ claim({ id: "e04", date: "2023-04-21", scope: "all", value: 6, scopeBasis: "no company named" }),
+ ]);
+ const f = t.steps.find((s) => s.id === "e04").flags.find((x) => x.rule === "contradicts_component");
+ assert.ok(f, "contradicts_component must fire");
+ assert.equal(f.against, "p01");
+ assert.equal(f.text, "he says 6 total; he said 10 at The Publica 2 days earlier");
+});
+
+test("contradicts_component does not fire when the total covers every component", () => {
+ const t = ledgerTotals([
+ claim({ id: "p", date: "2023-04-19", scope: "publica", value: 4, scopeBasis: "b" }),
+ claim({ id: "e", date: "2023-04-21", scope: "all", value: 9, scopeBasis: "b" }),
+ ]);
+ assert.equal(
+ t.steps.find((s) => s.id === "e").flags.filter((f) => f.rule === "contradicts_component").length,
+ 0,
+ );
+});
+
+test("same_day_conflict: two different totals on 2024-12-18", () => {
+ // The other case the plan names: 10, then 10+10 as 20, same day, same scope.
+ const t = ledgerTotals([
+ claim({ id: "e09", date: "2024-12-18", scope: "all", value: 10, scopeBasis: "no company named" }),
+ claim({
+ id: "e10",
+ date: "2024-12-18",
+ scope: "all",
+ value: 20,
+ valueKind: "derived",
+ scopeBasis: "10 + coffee's 10, summed by us",
+ }),
+ ]);
+ for (const id of ["e09", "e10"]) {
+ const f = t.steps.find((s) => s.id === id).flags.find((x) => x.rule === "same_day_conflict");
+ assert.ok(f, `same_day_conflict must fire on ${id}`);
+ assert.match(f.text, /two different totals the same day/);
+ }
+});
+
+test("same_day_conflict ignores two claims about different companies", () => {
+ const t = ledgerTotals([
+ claim({ id: "m", date: "2023-08-09", scope: "media", value: 4 }),
+ claim({ id: "c", date: "2023-08-09", scope: "coffee", value: 10, scopeBasis: "names coffee" }),
+ ]);
+ assert.equal(coherenceFlags([]).length, 0);
+ for (const s of t.steps) {
+ assert.equal(s.flags.filter((f) => f.rule === "same_day_conflict").length, 0);
+ }
+});
+
+test("same_day_conflict ignores a repeated identical figure", () => {
+ const t = ledgerTotals([
+ claim({ id: "a", date: "2023-08-09", scope: "coffee", value: 10, scopeBasis: "x" }),
+ claim({ id: "b", date: "2023-08-09", scope: "coffee", value: 10, scopeBasis: "x" }),
+ ]);
+ for (const s of t.steps) {
+ assert.equal(s.flags.filter((f) => f.rule === "same_day_conflict").length, 0);
+ }
+});
+
+test("self_negating: a count of employees who are not employees", () => {
+ const t = ledgerTotals([
+ claim({
+ id: "e09",
+ date: "2024-12-18",
+ scope: "all",
+ value: 10,
+ population: "contractor",
+ scopeBasis: "no company named",
+ }),
+ ]);
+ const f = t.steps[0].flags.find((x) => x.rule === "self_negating");
+ assert.ok(f);
+ assert.match(f.text, /basically contractors/);
+});
+
+test("population_mismatch: salaried against an employees baseline", () => {
+ const led = [
+ claim({ id: "a", date: "2022-01-01", scope: "all", value: 4, scopeBasis: "x" }),
+ claim({ id: "b", date: "2023-01-01", scope: "all", value: 6, scopeBasis: "x" }),
+ claim({ id: "c", date: "2024-09-10", scope: "all", value: 5, population: "salaried", scopeBasis: "x" }),
+ ];
+ const t = ledgerTotals(led);
+ assert.equal(t.baseline.all, "employees");
+ const f = t.steps.find((s) => s.id === "c").flags.find((x) => x.rule === "population_mismatch");
+ assert.equal(f.text, "salaried, not employees");
+ // …and the two that match the baseline stay clean.
+ for (const id of ["a", "b"]) {
+ assert.equal(
+ t.steps.find((s) => s.id === id).flags.filter((x) => x.rule === "population_mismatch").length,
+ 0,
+ );
+ }
+});
+
+test("the baseline is per scope, not global", () => {
+ const t = ledgerTotals([
+ claim({ id: "a", date: "2022-01-01", scope: "media", value: 4, population: "full-time" }),
+ claim({ id: "b", date: "2022-02-01", scope: "media", value: 5, population: "full-time" }),
+ claim({ id: "c", date: "2022-03-01", scope: "coffee", value: 6, scopeBasis: "x" }),
+ ]);
+ assert.equal(t.baseline.media, "full-time");
+ assert.equal(t.baseline.coffee, "employees");
+ assert.equal(t.steps.find((s) => s.id === "c").flags.length, 0);
+});
+
+test("a large swing is deliberately NOT a flag", () => {
+ // Fluctuation is the subject of the video, not a defect. Flagging it would be
+ // putting a thumb on the scale.
+ const t = ledgerTotals([
+ claim({ id: "a", date: "2025-02-12", scope: "media", value: 10 }),
+ claim({ id: "b", date: "2025-06-20", scope: "media", value: 3 }),
+ ]);
+ assert.deepEqual(t.steps.find((s) => s.id === "b").flags, []);
+});
+
+test("an adjudicator's hand-written flag rides alongside the computed ones", () => {
+ const t = ledgerTotals([
+ claim({ id: "a", date: "2025-02-12", scope: "media", value: 10, flags: ["reading someone else's tweet"] }),
+ ]);
+ assert.deepEqual(t.steps[0].flags, [{ rule: "adjudicator", text: "reading someone else's tweet" }]);
+});
+
+test("coherenceFlags flattens every fired predicate with its claim id", () => {
+ const flags = coherenceFlags([
+ claim({ id: "p", date: "2023-04-19", scope: "publica", value: 10, scopeBasis: "x" }),
+ claim({
+ id: "e",
+ date: "2023-04-21",
+ scope: "all",
+ value: 6,
+ population: "salaried",
+ valueKind: "derived",
+ scopeBasis: "x",
+ }),
+ ]);
+ const mine = flags.filter((f) => f.id === "e").map((f) => f.rule).sort();
+ assert.deepEqual(mine, ["contradicts_component", "not_his_number", "population_mismatch"]);
+});
+
+// ---------------------------------------------------------------------------
+// The editorial decisions this corpus actually turned on.
+//
+// These are shaped like the real ledger rather than reading it: the file lives
+// outside the repo, and a test that loads it would fail for whoever does not
+// have that report checked out. What they pin is the RULING, so a later change
+// to the adjudication has to come past a red test rather than quietly restating
+// the video's argument.
+// ---------------------------------------------------------------------------
+
+test("December 2024: two tens on one day, and no stated total at all", () => {
+ // He says ten at the channel and ten at the coffee company. Our sum is 20 and
+ // he never utters it, so the implied line steps and the stated line does not
+ // move — which is the whole correction, in one day of the corpus.
+ const t = ledgerTotals([
+ claim({ id: "m", date: "2024-11-27", scope: "media", value: 10 }),
+ claim({ id: "c13", date: "2024-12-18", scope: "coffee", value: 10, scopeBasis: "names coffee" }),
+ claim({
+ id: "e09", date: "2024-12-18", scope: "media", value: 10, population: "contractor",
+ scopeBasis: "“plus Coffee Brand Coffee, which ALSO has 10” — coffee is outside the ten",
+ }),
+ claim({
+ id: "e10", date: "2024-12-18", scope: "all", value: 20, valueKind: "derived",
+ scopeBasis: "10 + 10, summed by us",
+ }),
+ ]);
+ assert.equal(t.final.implied, 20);
+ assert.equal(t.final.stated, null, "no figure he utters covers the whole payroll that day");
+ assert.deepEqual(t.series.stated, []);
+ // e09 and c13 are the SAME day but different scopes, so this is not a
+ // same-day conflict — it is two companies, which is the point.
+ const e09 = t.steps.find((s) => s.id === "e09");
+ assert.equal(e09.flags.filter((f) => f.rule === "same_day_conflict").length, 0);
+ assert.ok(e09.flags.some((f) => f.rule === "self_negating"));
+});
+
+test("a stated total below a component fires even months later", () => {
+ // 2024-09-10: five salaried across four ventures, against ten at the coffee
+ // company eleven months earlier. The component claim is stale, not wrong, and
+ // a total that cannot contain it is still incoherent.
+ const t = ledgerTotals([
+ claim({ id: "c09", date: "2023-09-27", scope: "coffee", value: 10, scopeBasis: "names coffee" }),
+ claim({
+ id: "e07", date: "2024-09-10", scope: "all", value: 5, population: "salaried",
+ scopeBasis: "enumerates meme intros, thumbnails, tailgates and thepublica.com",
+ }),
+ ]);
+ const f = t.steps.find((s) => s.id === "e07").flags.find((x) => x.rule === "contradicts_component");
+ assert.ok(f);
+ assert.equal(f.against, "c09");
+ assert.match(f.text, /11 months earlier/);
+});
+
+test("the implied total carries a company forward until he speaks about it again", () => {
+ // The Publica is claimed once, in April 2023, and never again. Our sum keeps
+ // carrying that ten for years — which is honest arithmetic over his claims and
+ // exactly why the series must be labelled as ours.
+ const t = ledgerTotals([
+ claim({ id: "p", date: "2023-04-19", scope: "publica", value: 10, scopeBasis: "launch video" }),
+ claim({ id: "m", date: "2025-06-20", scope: "media", value: 3, population: "people" }),
+ ]);
+ assert.equal(t.final.implied, 13);
+ assert.equal(t.series.publica.length, 1, "one Publica claim in the whole corpus");
+});
+
+// ---------------------------------------------------------------------------
+// status_flip — the sixth predicate
+// ---------------------------------------------------------------------------
+
+test("status_flip: December's contractors are May's full-timers", () => {
+ // The corpus's clearest reversal, and the reason the predicate compares
+ // across scopes when one of them is `all`: the ten are the CHANNEL's in
+ // December and the WHOLE payroll's in May, and reading those as unrelated
+ // series is how the flip stayed invisible.
+ const t = ledgerTotals([
+ claim({
+ id: "e09", date: "2024-12-18", scope: "media", value: 10, population: "contractor",
+ scopeBasis: "“plus Coffee Brand Coffee, which ALSO has 10”",
+ }),
+ claim({
+ id: "e11", date: "2025-05-30", scope: "all", value: 10.5, population: "full-time",
+ valueKind: "synthetic", scopeBasis: "“about 10 people, 11 people full time”",
+ }),
+ ]);
+ const f = t.steps.find((s) => s.id === "e11").flags.find((x) => x.rule === "status_flip");
+ assert.ok(f, "status_flip must fire on the reversal");
+ assert.equal(f.against, "e09");
+ assert.equal(f.text, "5 months earlier they were contractors, now full time");
+ // …and the December claim itself has nothing before it to reverse.
+ assert.equal(
+ t.steps.find((s) => s.id === "e09").flags.filter((x) => x.rule === "status_flip").length,
+ 0,
+ );
+});
+
+test("status_flip names the figure when it is the same one", () => {
+ const t = ledgerTotals([
+ claim({ id: "a", date: "2024-01-01", scope: "all", value: 10, population: "contractor", scopeBasis: "x" }),
+ claim({ id: "b", date: "2024-03-01", scope: "all", value: 10, population: "full-time", scopeBasis: "x" }),
+ ]);
+ const f = t.steps.find((s) => s.id === "b").flags.find((x) => x.rule === "status_flip");
+ assert.equal(f.text, "2 months earlier the same 10 were contractors, now full time");
+});
+
+test("status_flip fires on a claim carrying no figure at all", () => {
+ // "That's why all my workers are contract workers" is the single clearest
+ // status claim in the corpus and it names no number. Gating the predicate on
+ // a value would drop exactly the rows it exists to read.
+ const t = ledgerTotals([
+ claim({ id: "e11", date: "2025-05-30", scope: "all", value: 10.5, population: "full-time",
+ valueKind: "synthetic", scopeBasis: "x" }),
+ claim({ id: "e12", date: "2025-10-29", scope: "all", value: null, population: "contractor",
+ scopeBasis: "“all my workers are contract workers”" }),
+ ]);
+ const f = t.steps.find((s) => s.id === "e12").flags.find((x) => x.rule === "status_flip");
+ assert.ok(f);
+ assert.equal(f.against, "e11");
+});
+
+test("status_flip only fires at the transition, not against every earlier claim", () => {
+ // One 2022 "all 1099" would otherwise flag each of the next four claims in
+ // turn, which reads as four findings when it is one.
+ const led = [
+ claim({ id: "e01", date: "2022-03-16", scope: "all", value: null, population: "1099", scopeBasis: "x" }),
+ claim({ id: "a", date: "2023-01-01", scope: "all", value: 4, population: "full-time", scopeBasis: "x" }),
+ claim({ id: "b", date: "2023-06-01", scope: "all", value: 5, population: "full-time", scopeBasis: "x" }),
+ claim({ id: "c", date: "2023-09-01", scope: "all", value: 6, population: "salaried", scopeBasis: "x" }),
+ ];
+ const fired = ledgerTotals(led).steps
+ .filter((s) => s.flags.some((f) => f.rule === "status_flip"))
+ .map((s) => s.id);
+ assert.deepEqual(fired, ["a"]);
+});
+
+test("status_flip does not read `employees` or `people` as a status at all", () => {
+ // He says "employees" when he is not making a claim about status. Treating
+ // it as one side or the other manufactures a reversal out of vocabulary.
+ const t = ledgerTotals([
+ claim({ id: "a", date: "2024-01-01", scope: "media", value: 4, population: "full-time" }),
+ claim({ id: "b", date: "2024-06-01", scope: "media", value: 5, population: "employees" }),
+ claim({ id: "c", date: "2024-09-01", scope: "media", value: 6, population: "people" }),
+ ]);
+ for (const s of t.steps) {
+ assert.equal(s.flags.filter((f) => f.rule === "status_flip").length, 0);
+ }
+});
+
+test("status_flip does not compare two different companies", () => {
+ const t = ledgerTotals([
+ claim({ id: "c", date: "2023-06-30", scope: "coffee", value: 6, population: "full-time", scopeBasis: "x" }),
+ claim({ id: "p", date: "2023-09-01", scope: "publica", value: 3, population: "contractor", scopeBasis: "x" }),
+ ]);
+ assert.equal(
+ t.steps.find((s) => s.id === "p").flags.filter((f) => f.rule === "status_flip").length,
+ 0,
+ );
+});
+
+test("an unresolved scope cannot say whose status reversed", () => {
+ const t = ledgerTotals([
+ claim({ id: "u", date: "2022-03-16", scope: "all", value: null, population: "salaried",
+ scopeConfidence: "unresolved", scopeBasis: "spoken on someone else's channel" }),
+ claim({ id: "v", date: "2022-08-30", scope: "all", value: 4, population: "contractor", scopeBasis: "x" }),
+ ]);
+ for (const s of t.steps) {
+ assert.equal(s.flags.filter((f) => f.rule === "status_flip").length, 0);
+ }
+});
+
+test("status_flip is one of the named predicates", () => {
+ assert.ok(PREDICATES.includes("status_flip"));
+ assert.equal(PREDICATES.length, 6);
+});
+
+// ---------------------------------------------------------------------------
+// The roster
+// ---------------------------------------------------------------------------
+
+const ROSTERED = [
+ claim({
+ id: "m07", date: "2023-10-17", scope: "media", value: 3, population: "full-time",
+ roles: [
+ { role: "video editor", count: 2, verbatim: "two video editors" },
+ { role: "graphics designer", count: 1, verbatim: "a graphics designer" },
+ ],
+ }),
+ claim({
+ id: "m08", date: "2023-12-04", scope: "media", value: 4, population: "full-time",
+ roles: [
+ { role: "video editor", count: 2, verbatim: "two video editors" },
+ { role: "graphic designer", count: 1, verbatim: "a graphic designer" },
+ ],
+ }),
+];
+
+test("rosterAt: the same two editors and one designer, whatever the total says", () => {
+ // This is the whole finding. October's three and December's four are the SAME
+ // roster; the total moved and the people did not.
+ assert.equal(rosterAt(ROSTERED, "2023-10-17").id, "m07");
+ assert.equal(rosterAt(ROSTERED, "2023-11-30").id, "m07");
+ assert.equal(rosterAt(ROSTERED, "2023-12-04").id, "m08");
+ assert.equal(rosterAt(ROSTERED, "2026-01-01").id, "m08");
+ assert.equal(rosterAt(ROSTERED, "2023-01-01"), null, "nothing enumerated yet is not a roster");
+ assert.equal(rosterLine(rosterAt(ROSTERED, "2023-10-17").roles), "2 editors · 1 designer");
+ assert.equal(rosterLine(rosterAt(ROSTERED, "2023-12-04").roles), "2 editors · 1 designer");
+});
+
+test("rosterAt ignores claims that enumerate nothing", () => {
+ const led = [...ROSTERED, claim({ id: "m19", date: "2025-06-20", scope: "media", value: 3, population: "people" })];
+ assert.equal(rosterAt(led, "2025-12-01").id, "m08");
+});
+
+test("roles are optional, and checked when present", () => {
+ assert.deepEqual(rolesGaps(undefined), []);
+ assert.deepEqual(adjudicationGaps(claim({ id: "x" })), [], "a claim with no roster is still adjudicated");
+ assert.deepEqual(rolesGaps([{ role: "video editor", count: 2, verbatim: "two video editors" }]), []);
+ assert.deepEqual(rolesGaps([{ role: "", count: 2, verbatim: "x" }]), ["roles[0].role"]);
+ assert.deepEqual(rolesGaps([{ role: "a", count: null, verbatim: "" }]), ["roles[0].count", "roles[0].verbatim"]);
+ assert.deepEqual(rolesGaps([]), ["roles must be a non-empty array"]);
+});
+
+// ---------------------------------------------------------------------------
+// Deleting the two derived rows
+// ---------------------------------------------------------------------------
+
+test("removing a derived `all` row moves neither series", () => {
+ // 18 ("10+8") and 20 ("10+10") are our arithmetic over rows that are already
+ // in the ledger. Taking them out has to change nothing at all — which is the
+ // whole justification for taking them out.
+ const real = [
+ claim({ id: "c12", date: "2024-11-23", scope: "coffee", value: 10, scopeBasis: "names coffee" }),
+ claim({ id: "m14", date: "2024-11-23", scope: "media", value: 8 }),
+ claim({ id: "e07", date: "2024-09-10", scope: "all", value: 5, population: "salaried", scopeBasis: "x" }),
+ ];
+ const derived = claim({
+ id: "e08", date: "2024-11-23", scope: "all", value: 18, valueKind: "derived",
+ scopeBasis: "10 + 8, summed by us",
+ });
+ const withRow = ledgerTotals([...real, derived]);
+ const without = ledgerTotals(real);
+ assert.deepEqual(without.final, withRow.final);
+ assert.deepEqual(
+ without.series.implied.map((p) => p.value),
+ withRow.series.implied.map((p) => p.value),
+ );
+ assert.deepEqual(without.series.stated, withRow.series.stated);
+});
+
+// ---------------------------------------------------------------------------
+// The variant filter
+// ---------------------------------------------------------------------------
+
+const VARIANT_MANIFEST = {
+ timeline: [
+ { type: "card", id: "t00", style: "title", heading: "all 50", variants: { sourced: { heading: "the 20" } } },
+ { type: "clip", id: "a01" },
+ { type: "ledger", id: "L1", variant: "full" },
+ ],
+ ledger: [
+ { id: "m02", entryId: "a01" },
+ { id: "m03", entryId: "L1" },
+ { id: "m04" },
+ ],
+};
+
+test("selectVariant keeps only the claims whose entry survives", () => {
+ const sourced = selectVariant(VARIANT_MANIFEST, "sourced");
+ assert.deepEqual(sourced.timeline.map((e) => e.id), ["t00", "a01"]);
+ assert.deepEqual(sourced.ledger.map((c) => c.id), ["m02"]);
+
+ const full = selectVariant(VARIANT_MANIFEST, "full");
+ assert.deepEqual(full.timeline.map((e) => e.id), ["t00", "a01", "L1"]);
+ // m04 is pinned to nothing at all, so it is in neither cut — in `full` every
+ // claim earns an entry, which is what makes the same filter serve both.
+ assert.deepEqual(full.ledger.map((c) => c.id), ["m02", "m03"]);
+});
+
+test("selectVariant merges a card's per-variant copy and drops the override key", () => {
+ const t = selectVariant(VARIANT_MANIFEST, "sourced").timeline[0];
+ assert.equal(t.heading, "the 20");
+ assert.equal(t.variants, undefined);
+ assert.equal(selectVariant(VARIANT_MANIFEST, "full").timeline[0].heading, "all 50");
+});
+
+test("selectVariant refuses a variant nobody defined", () => {
+ assert.throws(() => selectVariant(VARIANT_MANIFEST, "director's cut"), /unknown variant/);
+});
+
+test("selectVariant does not mutate the manifest it was given", () => {
+ const before = JSON.stringify(VARIANT_MANIFEST);
+ selectVariant(VARIANT_MANIFEST, "sourced");
+ selectVariant(VARIANT_MANIFEST, "full");
+ assert.equal(JSON.stringify(VARIANT_MANIFEST), before);
+});
+
+// ---------------------------------------------------------------------------
+// Scheduling claims onto the timeline
+// ---------------------------------------------------------------------------
+
+test("a claim on a stacked ledger card is pinned to its own row's reveal", () => {
+ // Pinning all three to the segment's mid-dissolve lands three rail rows on
+ // one frame AND breaks the pin-order guard, which needs strict monotonicity.
+ const entries = [
+ { type: "card", id: "t00" },
+ { type: "clip", id: "a01" },
+ { type: "ledger", id: "L01", claims: ["m03", "c02", "c03"] },
+ { type: "clip", id: "b01" },
+ ];
+ const starts = [0, 6, 16, 30];
+ const ledger = [
+ { id: "m02", entryId: "a01" },
+ { id: "m03", entryId: "L01" },
+ { id: "c02", entryId: "L01" },
+ { id: "c03", entryId: "L01" },
+ { id: "c01", entryId: "b01" },
+ ];
+ const at = scheduleClaims(ledger, entries, starts, 0.4, 60);
+ assert.equal(at[0], 6.2, "a clipped claim still lands mid-dissolve");
+ assert.equal(at[1], 16 + ledgerRevealAt(0));
+ assert.equal(at[2], 16 + ledgerRevealAt(1));
+ assert.equal(at[3], 16 + ledgerRevealAt(2));
+ assert.equal(at[4], 30.2);
+ for (let i = 1; i < at.length; i += 1) assert.ok(at[i] > at[i - 1], `pin ${i} must move forward`);
+});
+
+test("a card's own reveals all finish inside the card", () => {
+ // `seconds` is derived from the same clock the pins read; if it were authored
+ // by hand a short card would schedule rail rows past its own last frame.
+ for (const n of [1, 2, 3, 4]) {
+ assert.ok(ledgerRevealAt(n - 1) < ledgerSeconds(n), `${n} rows must all reveal`);
+ }
+ // Float arithmetic, so compare to the tolerance a frame actually has.
+ assert.ok(Math.abs(ledgerSeconds(4) - (2.2 + 1.3 * 4)) < 1e-9);
+});
+
+test("a pin that runs backwards is refused, not smoothed over", () => {
+ // The ledger and the timeline disagreeing about the order of events is a
+ // manifest bug, and the whole cut rests on the two agreeing.
+ const entries = [{ type: "clip", id: "a" }, { type: "clip", id: "b" }];
+ assert.throws(
+ () =>
+ scheduleClaims(
+ [{ id: "x", entryId: "b" }, { id: "y", entryId: "a" }],
+ entries, [0, 10], 0.4, 30,
+ ),
+ /not in the cut's order/,
+ );
+});
diff --git a/scripts/report-to-video/package.json b/scripts/report-to-video/package.json
@@ -8,14 +8,17 @@
"report-build-video": "./build-video.mjs",
"report-resolve-windows": "./resolve-windows.mjs",
"report-check-availability": "./check-availability.mjs",
- "report-verify-build": "./verify-build.mjs"
+ "report-verify-build": "./verify-build.mjs",
+ "report-compose-chrome": "./compose-chrome.mjs"
},
"exports": {
- "./resolve-windows": "./resolve-windows.mjs",
"./build-video": "./build-video.mjs",
- "./render-cards": "./render-cards.mjs",
"./check-availability": "./check-availability.mjs",
- "./package.json": "./package.json",
- "./verify-build": "./verify-build.mjs"
+ "./compose-chrome": "./compose-chrome.mjs",
+ "./ledger-totals": "./ledger-totals.mjs",
+ "./render-cards": "./render-cards.mjs",
+ "./resolve-windows": "./resolve-windows.mjs",
+ "./verify-build": "./verify-build.mjs",
+ "./package.json": "./package.json"
}
}
diff --git a/scripts/report-to-video/render-cards.mjs b/scripts/report-to-video/render-cards.mjs
@@ -9,10 +9,14 @@
// Card styles (manifest `style` field):
// title — the opening card: big heading, subtitle, provenance footer
// chapter — an act break: small amber kicker over a large heading
-// status — the bottom-line card: kicker, amber heading, subtitle
// bullets — heading plus a list of caveats
// sources — closing attribution
//
+// `status` is RETIRED. It existed to quote a claim whose source had gone, and
+// the `ledger` entry type does that better: it says the same words, alongside
+// the arithmetic the claim moves, and it says WHY there is no footage from a
+// probe rather than from a hand-written kicker that nothing re-checks.
+//
// In the app: not used. On the CLI:
// node scripts/report-to-video/render-cards.mjs <manifest.json> [--out <dir>]
//
@@ -26,9 +30,31 @@ import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { mkdir, writeFile, readFile } from "node:fs/promises";
import path from "node:path";
+import { dateKey, ledgerTotals, rosterLine } from "./ledger-totals.mjs";
const execFileP = promisify(execFile);
+const RSVG = process.env.RSVG_BIN ?? "rsvg-convert";
+const QRENCODE = process.env.QRENCODE_BIN ?? "qrencode";
+
+// Every card is drawn at the manifest's full `width`, but once a rail column is
+// configured the RIGHT `rail.width` pixels of the frame belong to it. Text,
+// rules and timeline nodes therefore lay out inside the CONTENT width, while the
+// canvas stays full-width — the card ground is flat `pal.bg`, which is exactly
+// what the rail wants behind it.
+export const railWidth = (render) => render.rail?.width ?? 0;
+export const contentWidth = (render) => render.width - railWidth(render);
+
+/**
+ * How wide a card's content may be.
+ *
+ * `hideRail` cards slide the rail off over their own dissolve, so they get the
+ * WHOLE frame. Everything else lays out inside the content width and leaves the
+ * rail column as ground.
+ */
+export const cardWidth = (card, render) =>
+ card?.hideRail ? render.width : contentWidth(render);
+
// Pango markup is XML-ish, so anything we interpolate has to be escaped first.
// Curly quotes and the ellipsis pass through fine; only these five matter.
function esc(s) {
@@ -79,17 +105,6 @@ function markupFor(card, pal) {
.filter((l) => l !== null)
.join("\n");
- case "status":
- return [
- card.kicker ? KICKER(card.kicker) : null,
- card.kicker ? "" : null,
- span(esc(card.heading), { size: 58, color: pal.amber, weight: "bold" }),
- card.sub ? "" : null,
- card.sub ? SUB(card.sub, 32) : null,
- ]
- .filter((l) => l !== null)
- .join("\n");
-
case "bullets": {
const items = (card.bullets ?? []).flatMap((b) => [
`${span("— ", { size: 30, color: pal.accent, weight: "bold" })}${span(
@@ -135,8 +150,9 @@ async function renderTimelineCard(card, render, nodes, outDir) {
const outPath = path.join(outDir, "cards", `${card.id}.png`);
const dir = path.join(outDir, "cards");
+ const VW = cardWidth(card, render);
const x0 = 260;
- const x1 = width - 260;
+ const x1 = VW - 260;
const axisY = Math.round(height * 0.56);
const gap = (x1 - x0) / (nodes.length - 1);
const xs = nodes.map((_, i) => Math.round(x0 + i * gap));
@@ -184,11 +200,11 @@ async function renderTimelineCard(card, render, nodes, outDir) {
const headPath = path.join(dir, `${card.id}.head.pango`);
await writeFile(headPath, headMarkup, "utf8");
args.push(
- "(", "-size", `${width - 460}x`, "-background", "none",
- "-define", `pango:width=${width - 460}`,
+ "(", "-size", `${VW - 460}x`, "-background", "none",
+ "-define", `pango:width=${VW - 460}`,
`pango:@${headPath}`, ")",
"-gravity", "NorthWest",
- "-geometry", `+${Math.round(width * 0.09) + 58}+${Math.round(height * 0.19)}`,
+ "-geometry", `+${Math.round(VW * 0.09) + 58}+${Math.round(height * 0.19)}`,
"-composite",
);
@@ -233,11 +249,16 @@ export async function renderFooterAssets(render, nodes, outDir) {
// place. No nodes (or an explicit zero height) means no footer at all — the
// caller letterboxes against the header alone.
if (!nodes?.length || FH === 0) {
- return { footer: null, marker: null, footerHeight: 0, trackY: 0, xs: [], x0: 0, markerRadius: 0 };
+ return {
+ footer: null, marker: null, bar: null, trackLen: 0,
+ footerHeight: 0, trackY: 0, xs: [], x0: 0, markerRadius: 0,
+ };
}
+ // The band itself stays full-frame so it reads as one strip running under the
+ // rail; only the TRACK is pulled in to the content width.
const x0 = 200;
- const x1 = width - 200;
+ const x1 = contentWidth(render) - 200;
const trackY = 26;
const gap = (x1 - x0) / (nodes.length - 1);
const xs = nodes.map((_, i) => Math.round(x0 + i * gap));
@@ -278,6 +299,20 @@ export async function renderFooterAssets(render, nodes, outDir) {
args.push(footer);
await execFileP("magick", args, { maxBuffer: 1 << 24 });
+ // The fill bar, as a strip to be TRANSLATED under a fixed crop rather than a
+ // drawbox whose width depends on `t`. drawbox has no time variable — its `t`
+ // is the box thickness — so the width expression the encoder used to build
+ // never evaluated and the bar was always full. Left half accent, right half
+ // transparent: sliding the crop window left across it grows the accent run.
+ const trackLen = xs[xs.length - 1] - x0;
+ const bar = path.join(dir, "_bar.png");
+ await execFileP("magick", [
+ "-size", `${trackLen * 2}x3`, "xc:none",
+ "-fill", pal.accent, "-stroke", "none",
+ "-draw", `rectangle 0,0 ${trackLen - 1},2`,
+ bar,
+ ]);
+
// The travelling marker.
const marker = path.join(dir, "_marker.png");
const r = 11;
@@ -288,7 +323,1122 @@ export async function renderFooterAssets(render, nodes, outDir) {
marker,
]);
- return { footer, marker, footerHeight: FH, trackY, xs, x0, markerRadius: r };
+ return { footer, marker, bar, trackLen, footerHeight: FH, trackY, xs, x0, markerRadius: r };
+}
+
+// ===========================================================================
+// The claim rail
+// ===========================================================================
+// A vertical ledger down the right edge that appends one row per claim as the
+// video runs, with a live per-company tally beside it. The dates and the numbers
+// are SPOKEN in the clips and shown only in the header citation line, so a viewer
+// can hear "nearly ten" three times without ever seeing that the three refer to
+// three different companies. The rail is what makes that visible.
+//
+// Every asset here is a STRIP, not a per-state still: one tall PNG whose window
+// ffmpeg slides with an animated `crop`. That is the whole trick — swapping
+// stills can only cut, but a crop can ease, and one input per moving part keeps
+// the filtergraph small enough that ffmpeg does not deadlock on chained
+// `-loop 1` inputs. See railFilterChain in build-video.mjs for the ramps.
+//
+// Assets are authored as SVG and rasterized with rsvg-convert rather than drawn
+// with ImageMagick primitives: the strips need right-aligned columns, hairlines
+// and ~500 individually-placed text runs, and one rsvg call beats four hundred
+// `magick` invocations. Fonts inside the SVG resolve through fontconfig, so the
+// family name has to MATCH the Pango cards ("Fira Sans"), not the font FILE that
+// render.fontRegular points at.
+
+const RAIL_FONT = "Fira Sans";
+
+// A tiny SVG text run. Everything is placed absolutely — no flow, no wrapping.
+function svgText(x, y, text, o = {}) {
+ const a = [
+ `x="${x}"`, `y="${y}"`,
+ `font-family="${o.family ?? RAIL_FONT}"`,
+ `font-size="${o.size ?? 15}"`,
+ `fill="${o.color}"`,
+ ];
+ if (o.weight) a.push(`font-weight="${o.weight}"`);
+ if (o.anchor) a.push(`text-anchor="${o.anchor}"`);
+ if (o.ls) a.push(`letter-spacing="${o.ls}"`);
+ if (o.opacity != null) a.push(`opacity="${o.opacity}"`);
+ return `<text ${a.join(" ")}>${esc(text)}</text>`;
+}
+
+const svgDoc = (w, h, body) =>
+ `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" ` +
+ `viewBox="0 0 ${w} ${h}">${body}</svg>`;
+
+// rsvg-convert is deterministic about output size in a way ImageMagick's RSVG
+// delegate is not (its -density is ignored for sizing in some builds), so the
+// pixel dimensions ffmpeg's crop arithmetic depends on are guaranteed here.
+async function rasterize(svg, svgPath, pngPath, w, h) {
+ await writeFile(svgPath, svg, "utf8");
+ await execFileP(RSVG, ["-w", String(w), "-h", String(h), "-o", pngPath, svgPath], {
+ maxBuffer: 1 << 26,
+ });
+ return pngPath;
+}
+
+// Truncate to a pixel budget. Fira Sans at these sizes averages ~0.50em per
+// character; a hair conservative is right, because an overflowing row would run
+// under the value column rather than wrap.
+function fit(text, size, maxPx) {
+ const max = Math.max(4, Math.floor(maxPx / (size * 0.5)));
+ const t = String(text ?? "");
+ return t.length <= max ? t : `${t.slice(0, max - 1).trimEnd()}…`;
+}
+
+/**
+ * Every pixel measurement the rail needs, derived once so the asset builder and
+ * the filtergraph builder cannot disagree about a single one of them.
+ *
+ * The log window is deliberately sized to a WHOLE number of rows and butted
+ * against the bottom of the rail column: that is what lets the curtain (below)
+ * park exactly one window-height down and end up outside the rail entirely.
+ */
+export function railGeometry(render, nClaims) {
+ const rail = render.rail ?? {};
+ const RW = rail.width ?? 500;
+ const HH = render.headerHeight ?? 56;
+ // reservedFooterHeight, NOT render.footerHeight. The two differ by 100px the
+ // moment the chart band is on (the band takes the footer's ground and 100
+ // more), and deriving the rail's height from the smaller one ran the column
+ // a hundred pixels past the band's own top edge -- about two rows of the log
+ // window, drawn below the line everything else letterboxes to.
+ const FH = reservedFooterHeight(render);
+ const ROWH = rail.rowHeight ?? 38;
+ const PAD = rail.pad ?? 22;
+ const TALLYROWH = rail.tallyRowHeight ?? 40;
+ const nTracks = (rail.tracks ?? []).length;
+
+ const RHGT = render.height - HH - FH;
+ // The tally's header belongs to the CHROME, not to the rolling block. Put it
+ // in the block and every roll scrolls a duplicate copy of it up through the
+ // window, which reads as noise rather than as a counter changing.
+ const TALLYHEAD_REL = rail.tallyTop ?? 76;
+ const TALLYTOP_REL = TALLYHEAD_REL + 26;
+ const TALLYH = nTracks * TALLYROWH;
+
+ // The rolling CELL: the only part of a tally row that ever changes. The
+ // swatch and the company name sit left of it and belong to the chrome, so
+ // that a number moving does not drag its own label up the screen with it.
+ const CELLW = rail.cellWidth ?? 170;
+ const CELLX = RW - PAD - CELLW;
+
+ // The roster he last enumerated. One line, and the point of it is that it
+ // stands still while the numbers above it move.
+ const ROSTERH = rail.rosterHeight ?? 26;
+ const ROSTERTOP_REL = TALLYTOP_REL + TALLYH + 14;
+ const ROSTERX = PAD + (rail.rosterLabelWidth ?? 62);
+ const ROSTERW = RW - PAD - ROSTERX;
+
+ // The provenance tile, parked at the foot of the column: one QR per clip,
+ // bordered so it reads as a link rather than as decoration.
+ const QRSIZE = rail.qrSize ?? 132;
+ const TILEW = RW - 2 * PAD;
+ const TILEH = QRSIZE + 22;
+ const TILETOP_REL = RHGT - (rail.qrBottom ?? 12) - TILEH;
+
+ const LOGTOP_REL = ROSTERTOP_REL + ROSTERH + 34;
+ // The log window is a WHOLE number of rows and stops short of the tile, so
+ // the curtain parks exactly one window-height down and the QR overlay (which
+ // comes after it in the chain) is never painted over.
+ const K = Math.max(1, Math.floor((TILETOP_REL - 12 - LOGTOP_REL) / ROWH));
+ const LOGH = K * ROWH;
+
+ return {
+ RW, PAD, ROWH, TALLYROWH, K, LOGH, TALLYH, RHGT,
+ CELLW, CELLX,
+ ROSTERH, ROSTERTOP_REL, ROSTERTOP: HH + ROSTERTOP_REL, ROSTERX, ROSTERW,
+ QRSIZE, TILEW, TILEH, TILETOP_REL, TILETOP: HH + TILETOP_REL,
+ VW: render.width - RW,
+ RX: render.width - RW,
+ RTOP: HH,
+ LOGTOP_REL, LOGTOP: HH + LOGTOP_REL,
+ TALLYTOP_REL, TALLYTOP: HH + TALLYTOP_REL, TALLYHEAD_REL,
+ nClaims,
+ logStripH: Math.max(LOGH, nClaims * ROWH),
+ };
+}
+
+/**
+ * How much of the frame the chrome band owns at the bottom.
+ *
+ * ONE definition, because three different renderers need it and they were
+ * already disagreeing: the closing chart drew its footnotes into the bottom
+ * 100px and the ledger scroll sized its window to `height - header - 100`, both
+ * of which are wrong the moment the band takes 200. The symptom is a card that
+ * looks finished in isolation and has its last two lines sitting under the
+ * chart in the cut.
+ */
+export function reservedFooterHeight(render) {
+ return render.chromeEngine === "hyperframes"
+ ? (render.chart?.height ?? 200)
+ : (render.footerHeight ?? 100);
+}
+
+/**
+ * Every roll each tally cell will perform, as rows of one shared strip.
+ *
+ * ---------------------------------------------------------------------------
+ * Why the strip's LAYOUT carries the direction
+ * ---------------------------------------------------------------------------
+ * The whole block used to slide as one slab: when the coffee company's number
+ * changed, all four rows moved. Text that has not changed must not move, so
+ * each track now gets its own cell and its own y expression.
+ *
+ * A crop window can only walk a strip, and it walks in whichever direction its
+ * y expression takes it. So the DIRECTION of a roll is decided when the rows
+ * are laid out, not when the ramp is written:
+ *
+ * rise rows [old, new] crop walks DOWN the strip, content moves UP
+ * fall rows [new, old] crop walks UP the strip, content moves DOWN
+ *
+ * Between two transitions the crop steps instantly to the next pair's starting
+ * row. That step is invisible because the row it leaves and the row it arrives
+ * at hold IDENTICAL content — which is the reason every pair repeats the value
+ * it starts from rather than sharing a row with its neighbour.
+ *
+ * The delta chip rides along on both rows of the pair, and therefore stays on
+ * screen until the next change. That is deliberate: it reads as "how this
+ * number last moved", and blanking it at the step would make the invisible
+ * reposition visible.
+ *
+ * A repeated identical figure still rolls, upward. He said it again on a new
+ * date, and the `as of` line underneath is what changed.
+ *
+ * @returns {{lanes: Array<object>, rows: number}} one lane per track plus the
+ * roster lane, each with `rows` (the cells to draw) and `steps` (per claim,
+ * `null` or `{a, b}` — the row the roll starts on and the row it ends on).
+ */
+export function tallyTracks(ledger, tracks, opts = {}) {
+ const rosterLineOf = opts.rosterLine ?? (() => null);
+ const lanes = tracks.map((tr) => ({
+ key: tr.key, track: tr, kind: "tally",
+ rows: [{ empty: true, track: tr }],
+ steps: new Array(ledger.length).fill(null),
+ cur: null,
+ }));
+ const byKey = Object.fromEntries(lanes.map((l) => [l.key, l]));
+
+ const roster = {
+ key: "__roster", kind: "roster",
+ rows: [{ empty: true }],
+ steps: new Array(ledger.length).fill(null),
+ cur: null,
+ };
+
+ /** Lay a transition down as a pair of rows and record where it starts/ends. */
+ const transition = (lane, from, to, rise) => {
+ const p = lane.rows.length;
+ if (rise) {
+ lane.rows.push(from, to);
+ return { a: p, b: p + 1 };
+ }
+ lane.rows.push(to, from);
+ return { a: p + 1, b: p };
+ };
+
+ ledger.forEach((c, i) => {
+ // ---- the four company cells ----
+ const lane = byKey[c.scope ?? c.company];
+ // UTTERED only. The tally says "the latest figure he has given", and a sum
+ // we performed is not one — showing 18 here while a card beside it says he
+ // never said eighteen makes the video contradict itself on screen.
+ if (lane && c.value != null && (!c.valueKind || c.valueKind === "uttered")) {
+ const prev = lane.cur;
+ const next = {
+ track: lane.track,
+ display: c.display ?? String(c.value),
+ value: c.value,
+ date: c.date,
+ population: c.population ?? null,
+ delta: prev ? Number((c.value - prev.value).toFixed(2)) : null,
+ };
+ const from = prev ? { ...prev, delta: prev.delta } : { empty: true, track: lane.track };
+ lane.steps[i] = transition(lane, from, next, !prev || c.value >= prev.value);
+ lane.cur = next;
+ }
+
+ // ---- the roster ----
+ // Only when the LINE changes. He enumerates the same two editors and one
+ // designer in October and again in December; rolling the line to arrive at
+ // the words it already said would animate the one thing that held still.
+ const line = Array.isArray(c.roles) && c.roles.length ? rosterLineOf(c.roles) : null;
+ if (line && line !== roster.cur?.line) {
+ const next = { line, date: c.date };
+ const from = roster.cur ? { ...roster.cur } : { empty: true };
+ roster.steps[i] = transition(roster, from, next, true);
+ roster.cur = next;
+ }
+ });
+
+ const all = [...lanes, roster];
+ return { lanes: all, rows: Math.max(...all.map((l) => l.rows.length)) };
+}
+
+/**
+ * The colour of the population word under a figure. Never a new hue -- the
+ * chip is `pal.muted` text, so the dataviz gate does not have to be re-run.
+ */
+const POP_WORD = {
+ employees: "employees",
+ "full-time": "full time",
+ salaried: "salaried",
+ contractor: "contractors",
+ 1099: "1099",
+ people: "people",
+};
+
+/** A small solid triangle, because a font may not carry ▲ and tofu is worse. */
+function svgTri(x, y, up, color) {
+ const d = up
+ ? `M${x},${y + 7} L${x + 4.5},${y} L${x + 9},${y + 7} z`
+ : `M${x},${y} L${x + 4.5},${y + 7} L${x + 9},${y} z`;
+ return `<path d="${d}" fill="${color}"/>`;
+}
+
+/** One rolling tally cell, drawn into a CELLW x TALLYROWH box at (x, y). */
+function tallyCellSvg(cell, x, y, g, pal, h) {
+ const right = x + g.CELLW;
+ const out = [`<rect x="${x}" y="${y}" width="${g.CELLW}" height="${h}" fill="${pal.bg}"/>`];
+ if (cell.empty) {
+ out.push(
+ svgText(right, y + 24, "—", { size: 22, color: pal.muted, weight: "bold", anchor: "end", opacity: 0.5 }),
+ svgText(right, y + 37, "not yet stated", { size: 10.5, color: pal.muted, opacity: 0.6, anchor: "end" }),
+ );
+ return out.join("");
+ }
+ const colour = cell.track?.color ?? pal.fg;
+ out.push(
+ svgText(right, y + 24, cell.display, { size: 22, color: colour, weight: "bold", anchor: "end" }),
+ );
+ if (cell.delta != null && cell.delta !== 0) {
+ const up = cell.delta > 0;
+ out.push(
+ svgTri(x, y + 11, up, colour),
+ svgText(x + 14, y + 22, `${up ? "+" : "−"}${Math.abs(cell.delta)}`, {
+ size: 13, color: colour, weight: "bold",
+ }),
+ );
+ }
+ const chip = cell.population ? ` · ${POP_WORD[cell.population] ?? cell.population}` : "";
+ out.push(
+ svgText(right, y + 37, `as of ${cell.date}${chip}`, {
+ size: 10.5, color: pal.muted, anchor: "end", opacity: 0.9,
+ }),
+ );
+ return out.join("");
+}
+
+/** One roster line, drawn into a ROSTERW x ROSTERH box. */
+function rosterCellSvg(cell, x, y, g, pal) {
+ const out = [`<rect x="${x}" y="${y}" width="${g.ROSTERW}" height="${g.ROSTERH}" fill="${pal.bg}"/>`];
+ out.push(
+ cell.empty
+ ? svgText(x, y + 18, "not yet enumerated", { size: 12.5, color: pal.muted, opacity: 0.55 })
+ : svgText(x, y + 18, fit(cell.line, 13, g.ROSTERW), { size: 13, color: pal.muted }),
+ );
+ return out.join("");
+}
+
+// ---------------------------------------------------------------------------
+// The provenance tile
+// ---------------------------------------------------------------------------
+// A compilation asks the viewer to take the edit on trust. The QR is the
+// antidote: it resolves to this clip's exact start in the archive's own viewer,
+// so anyone can pull up the surrounding hour and check the cut is fair.
+//
+// It used to float over the bottom-right of the PICTURE, which is the one place
+// in the frame the cut promises never to draw on. In the rail's foot it is a
+// bordered tile that reads as a link, and it becomes one more strip walked by a
+// crop -- one tile per segment, stepped instantaneously at the mid-dissolve,
+// exactly like the other four.
+//
+// Two rules learned the hard way: it must be FULLY OPAQUE (a translucent QR
+// will not scan) and it must keep its quiet zone (the white border is part of
+// the symbol, not decoration).
+export function qrUrlFor(entry, provenance) {
+ if (entry.type === "clip") {
+ // A mirror's LOCAL slug is not the id the site serves, so an explicit
+ // per-clip citeUrl always wins over the derived one.
+ return (
+ entry.citeUrl ??
+ `${provenance.siteOrigin}/?v=${encodeURIComponent(
+ `${entry.channel ?? provenance.channelSlug}/${entry.video}`,
+ )}&t=${Math.floor(entry.cite ?? entry.start)}`
+ );
+ }
+ // A card is not a moment, so it gets the search rather than a timestamp.
+ //
+ // NOT `provenance.shareLink`. That link carries all 23 channel filters and is
+ // ~1.4k characters — a version-40 symbol, 177 modules inside a 132 px tile,
+ // which is roughly 0.7 px per module and unscannable. `qrLink` is the same
+ // query without the channel list (~200 chars, 63 modules, verified scannable
+ // at this size); the site origin is the fallback. A code nobody can scan is
+ // worse than a short one.
+ return provenance.qrLink ?? provenance.siteOrigin ?? "";
+}
+
+async function qrTileStrip(entries, provenance, render, g, outDir) {
+ const pal = render.palette;
+ const dir = path.join(outDir, "cards");
+ const qrDir = path.join(outDir, "qr");
+ const q = render.qr ?? {};
+ const QR = g.QRSIZE;
+ const TH = g.TILEH;
+
+ // One PNG per DISTINCT url, then a tile per segment referencing it.
+ const seen = new Map();
+ const urls = entries.map((e) => qrUrlFor(e, provenance));
+ for (const url of urls) {
+ if (seen.has(url)) continue;
+ const png = path.join(qrDir, `q${seen.size.toString().padStart(2, "0")}.png`);
+ await execFileP(QRENCODE, [
+ "-o", png, "-s", String(q.scale ?? 4), "-m", String(q.quiet ?? 3),
+ "-l", q.ecc ?? "M", url,
+ ]);
+ // Nearest-neighbour to an exact box: a resampled QR blurs its module edges
+ // and stops scanning, and the geometry has to be known before this runs.
+ const sized = path.join(qrDir, `q${seen.size.toString().padStart(2, "0")}.${QR}.png`);
+ await execFileP("magick", [png, "-filter", "point", "-resize", `${QR}x${QR}!`, sized]);
+ seen.set(url, sized);
+ }
+
+ const stripH = entries.length * TH;
+ const body = [`<rect x="0" y="0" width="${g.TILEW}" height="${stripH}" fill="${pal.bg}"/>`];
+ const images = [];
+ entries.forEach((e, i) => {
+ const y = i * TH;
+ const isClip = e.type === "clip";
+ body.push(
+ `<rect x="0.5" y="${y + 0.5}" width="${g.TILEW - 1}" height="${TH - 1}" fill="${pal.bg}" ` +
+ `stroke="${pal.accent}" stroke-width="1"/>`,
+ svgText(14, y + 32, "SCAN → JERALYZER", {
+ size: 12.5, color: pal.accent, weight: "bold", ls: 1.3,
+ }),
+ svgText(14, y + 58, isClip ? "this exact moment," : "the sweep this is cut from,", {
+ size: 13.5, color: pal.fg,
+ }),
+ svgText(14, y + 78, isClip ? "in the archive's own viewer" : "every claim, searchable", {
+ size: 13.5, color: pal.fg,
+ }),
+ svgText(14, y + 106, "the archive outlives the platform", {
+ size: 11.5, color: pal.muted, opacity: 0.8,
+ }),
+ );
+ images.push({ png: seen.get(urls[i]), x: g.TILEW - QR - 11, y: y + 11 });
+ });
+
+ const svgPath = path.join(dir, "_rail_qr.svg");
+ const basePng = path.join(dir, "_rail_qr.base.png");
+ await rasterize(svgDoc(g.TILEW, stripH, body.join("")), svgPath, basePng, g.TILEW, stripH);
+
+ // The codes are composited rather than inlined: an <image href> in the SVG
+ // would be resampled by rsvg, and a resampled QR does not scan.
+ const out = path.join(dir, "_rail_qr.png");
+ const args = [basePng];
+ for (const im of images) args.push(im.png, "-geometry", `+${im.x}+${im.y}`, "-composite");
+ args.push(out);
+ await execFileP("magick", args, { maxBuffer: 1 << 26 });
+ return { path: out, tileH: TH, urls };
+}
+
+/**
+ * Build the rail strips. Returns their paths plus the geometry, so the caller
+ * never re-derives a size the pixels already committed to.
+ *
+ * `entries` and `provenance` are needed for the QR strip only; without them the
+ * tile is skipped and the rail is the four strips it always was.
+ */
+export async function renderRailAssets(render, ledger, outDir, entries = null, provenance = null) {
+ const pal = render.palette;
+ const rail = render.rail;
+ const tracks = rail.tracks ?? [];
+ const byKey = Object.fromEntries(tracks.map((t) => [t.key, t]));
+ const g = railGeometry(render, ledger.length);
+ const dir = path.join(outDir, "cards");
+ const rule = rail.rule ?? "#2A322F";
+ const P = (n) => path.join(dir, n);
+
+ // ---- chrome: opaque, full rail column, never gated -------------------
+ // It runs for the whole video rather than being switched on with enable=,
+ // which removes four expressions and four off-by-one opportunities.
+ //
+ // The tally SWATCH and LABEL live here. They never change, and while they
+ // rode in the rolling block a coffee figure changing dragged "The Quartering"
+ // up the screen with it — text moving for a reason that was not about it.
+ const chromeBody = [
+ `<rect x="0" y="0" width="${g.RW}" height="${g.RHGT}" fill="${pal.bg}"/>`,
+ `<rect x="0" y="0" width="1" height="${g.RHGT}" fill="${rule}"/>`,
+ svgText(g.PAD, 32, "THE CLAIM LEDGER", { size: 15, color: pal.amber, weight: "bold", ls: 1.6 }),
+ svgText(g.PAD, 55, "every count he has given, as he gave it", { size: 14, color: pal.muted }),
+ `<rect x="${g.PAD}" y="70" width="${g.RW - 2 * g.PAD}" height="1" fill="${rule}"/>`,
+ svgText(g.PAD, g.TALLYHEAD_REL + 16, "LATEST FIGURE HE HAS GIVEN", {
+ size: 12, color: pal.muted, weight: "bold", ls: 1.4,
+ }),
+ ...tracks.map((tr, j) => {
+ const ry = g.TALLYTOP_REL + j * g.TALLYROWH;
+ return (
+ `<rect x="${g.PAD}" y="${ry + 13}" width="10" height="10" fill="${tr.color}"/>` +
+ svgText(g.PAD + 20, ry + 22, fit(tr.label, 14, g.CELLX - g.PAD - 24), {
+ size: 14, color: pal.fg,
+ })
+ );
+ }),
+ `<rect x="${g.PAD}" y="${g.ROSTERTOP_REL - 8}" width="${g.RW - 2 * g.PAD}" height="1" fill="${rule}"/>`,
+ svgText(g.PAD, g.ROSTERTOP_REL + 19, "ROSTER", {
+ size: 11, color: pal.muted, weight: "bold", ls: 1.4,
+ }),
+ `<rect x="${g.PAD}" y="${g.LOGTOP_REL - 26}" width="${g.RW - 2 * g.PAD}" height="1" fill="${rule}"/>`,
+ svgText(g.PAD, g.LOGTOP_REL - 8, "IN THE ORDER STATED", {
+ size: 12, color: pal.muted, weight: "bold", ls: 1.4,
+ }),
+ ].join("");
+ const chrome = await rasterize(
+ svgDoc(g.RW, g.RHGT, chromeBody), P("_rail_chrome.svg"), P("_rail_chrome.png"), g.RW, g.RHGT,
+ );
+
+ // ---- log strip: every claim, stacked, no padding ---------------------
+ const valX = g.RW - g.PAD;
+ const textX = g.PAD + 20;
+ const textBudget = valX - textX - 74;
+ const rows = ledger.map((c, i) => {
+ const y = i * g.ROWH;
+ // `scope` is the ADJUDICATED answer and `company` the undocumented guess it
+ // replaced. Falls back so a manifest with no adjudication yet still renders.
+ const tr = byKey[c.scope ?? c.company];
+ // In `sourced` every row has a clip behind it, so `live` is always true
+ // there; `full` keeps the distinction because a stacked ledger card is a
+ // weaker citation than footage and must not look like one.
+ const live = !!c.entryId && !c.unsourced;
+ const ink = live ? pal.fg : pal.muted;
+ const dotFill = live ? (tr?.color ?? pal.accent) : "none";
+ return [
+ `<rect x="0" y="${y}" width="${g.RW}" height="${g.ROWH}" fill="${pal.bg}"/>`,
+ `<circle cx="${g.PAD + 5}" cy="${y + 19}" r="4.5" fill="${dotFill}" ` +
+ `stroke="${tr?.color ?? pal.muted}" stroke-width="1.5" opacity="${live ? 1 : 0.55}"/>`,
+ svgText(textX, y + 17, c.date, { size: 13.5, color: pal.muted, opacity: live ? 1 : 0.7 }),
+ svgText(valX, y + 19, c.display ?? "—", {
+ size: 18, color: live ? (tr?.color ?? pal.fg) : pal.muted,
+ weight: "bold", anchor: "end", opacity: live ? 1 : 0.65,
+ }),
+ svgText(textX, y + 33, fit(c.label ?? c.quote ?? "", 13, textBudget + 74), {
+ size: 13, color: ink, opacity: live ? 1 : 0.6,
+ }),
+ `<rect x="${g.PAD}" y="${y + g.ROWH - 1}" width="${g.RW - 2 * g.PAD}" height="1" fill="${rule}"/>`,
+ ].join("");
+ }).join("");
+ const log = await rasterize(
+ svgDoc(g.RW, g.logStripH, rows), P("_rail_log.svg"), P("_rail_log.png"), g.RW, g.logStripH,
+ );
+
+ // ---- curtain ---------------------------------------------------------
+ // With ONE log strip and the window parked at the top while the list is still
+ // filling, rows i+1…K-1 would show claims the video has not made yet. This
+ // opaque rectangle rides just below the last revealed row and, once the list
+ // is full, parks exactly one window-height down — outside the window. It is
+ // pal.bg precisely so that parking there is invisible; the QR tile overlays
+ // AFTER it, which is what stops the parked curtain covering the code.
+ const curtain = P("_rail_curtain.png");
+ await execFileP("magick", ["-size", `${g.RW}x${g.LOGH}`, `xc:${pal.bg}`, curtain]);
+
+ // ---- highlight -------------------------------------------------------
+ const hl = await rasterize(
+ svgDoc(g.RW, g.ROWH, [
+ `<rect x="0" y="0" width="${g.RW}" height="${g.ROWH}" fill="${pal.amber}" opacity="0.10"/>`,
+ `<rect x="${g.PAD - 12}" y="4" width="3" height="${g.ROWH - 8}" fill="${pal.amber}"/>`,
+ ].join("")),
+ P("_rail_hl.svg"), P("_rail_hl.png"), g.RW, g.ROWH,
+ );
+
+ // ---- tally strip: one COLUMN per lane, side by side -------------------
+ // Four cells and the roster in one PNG: five crops at different x out of one
+ // input, rather than five inputs. Every column is as tall as the tallest, so
+ // a single strip height serves them all.
+ const { lanes, rows: nRows } = tallyTracks(ledger, tracks, { rosterLine });
+ const cellH = g.TALLYROWH;
+ const laneX = [];
+ let sx = 0;
+ for (const lane of lanes) {
+ const w = lane.kind === "roster" ? g.ROSTERW : g.CELLW;
+ laneX.push({ x: sx, w });
+ sx += w;
+ }
+ const stripW = sx;
+ const stripH = nRows * cellH;
+ const strip = [`<rect x="0" y="0" width="${stripW}" height="${stripH}" fill="${pal.bg}"/>`];
+ lanes.forEach((lane, li) => {
+ const { x } = laneX[li];
+ lane.rows.forEach((cell, ri) => {
+ strip.push(
+ lane.kind === "roster"
+ ? rosterCellSvg(cell, x, ri * cellH, g, pal)
+ : tallyCellSvg(cell, x, ri * cellH, g, pal, cellH),
+ );
+ });
+ });
+ const tally = await rasterize(
+ svgDoc(stripW, stripH, strip.join("")), P("_rail_tally.svg"), P("_rail_tally.png"), stripW, stripH,
+ );
+
+ // ---- the provenance tile ---------------------------------------------
+ const qr =
+ entries && provenance && render.qr !== false
+ ? await qrTileStrip(entries, provenance, render, g, outDir)
+ : null;
+
+ return {
+ chrome, log, curtain, hl, tally, qr,
+ geom: g,
+ lanes: lanes.map((lane, li) => ({
+ key: lane.key, kind: lane.kind, steps: lane.steps,
+ x: laneX[li].x, w: laneX[li].w, cellH,
+ })),
+ };
+}
+
+// ===========================================================================
+// Stacked ledger cards
+// ===========================================================================
+// The `full` cut's answer to the 28 claims the sweep found and no clip covers.
+//
+// Dimmed rail rows were the old answer, and they were confusing: a row with no
+// audio behind it slid past with nothing to say for itself, and 28 of them read
+// as padding rather than as evidence. So each one gets SCREEN TIME instead --
+// its date, its scope, its quote, his figure, and what that figure does to our
+// running sum. Consecutive unclipped claims share a card and reveal in
+// sequence, which is why 28 claims cost 14 cards and about 67 seconds.
+//
+// The reveal is the rail curtain's device: an opaque `pal.bg` rectangle walking
+// down the card. Nothing fades, nothing moves; rows simply stop being covered.
+
+/** The reveal clock. One definition, because `scheduleClaims` pins to it. */
+export const LEDGER_LEAD = 0.9;
+export const LEDGER_STEP = 1.3;
+export const LEDGER_TAIL = 2.6;
+export const ledgerRevealAt = (r) => LEDGER_LEAD + LEDGER_STEP * r;
+export const ledgerSeconds = (n) => LEDGER_LEAD + LEDGER_STEP * n + LEDGER_TAIL - LEDGER_STEP;
+
+/**
+ * Why this claim is a line of text and not footage.
+ *
+ * "The upload is gone" and "we did not cut it" are different sentences, and
+ * saying the first about a live source is the kind of error that makes a whole
+ * compilation untrustworthy. So the words come from a `yt-dlp --simulate`
+ * probe, recorded in out/availability.json with the date it ran.
+ */
+export const SOURCE_TAG = {
+ ok: "not clipped",
+ deleted: "source deleted",
+ private: "source private",
+ "members-only": "members only",
+ restricted: "age-restricted",
+ "geo-blocked": "geo-blocked",
+ "no-cues": "no archived transcript",
+ maybe_missing: "source unreachable",
+};
+
+/** Greedy wrap to a pixel budget, at most `maxLines`, last line elided. */
+function wrapPx(text, size, maxPx, maxLines) {
+ const perChar = size * 0.5;
+ const cols = Math.max(8, Math.floor(maxPx / perChar));
+ const words = String(text ?? "").split(/\s+/).filter(Boolean);
+ const lines = [];
+ let line = "";
+ for (const w of words) {
+ if (line && (line + " " + w).length > cols) {
+ lines.push(line);
+ line = w;
+ if (lines.length === maxLines) break;
+ } else {
+ line = line ? line + " " + w : w;
+ }
+ }
+ if (lines.length < maxLines && line) lines.push(line);
+ if (lines.length === maxLines) {
+ const used = lines.join(" ").split(/\s+/).length;
+ if (used < words.length) lines[maxLines - 1] = fit(lines[maxLines - 1] + " …", size, maxPx);
+ }
+ return lines;
+}
+
+/**
+ * One card carrying a run of consecutive unclipped claims.
+ *
+ * Returns the geometry the encoder needs to walk the curtain: where the rows
+ * start and how tall each one is.
+ */
+export async function renderLedgerCard(card, render, ledger, outDir, avail = null) {
+ const pal = render.palette;
+ const tracks = render.rail?.tracks ?? [];
+ const byKey = Object.fromEntries(tracks.map((t) => [t.key, t]));
+ const VW = cardWidth(card, render);
+ const H = render.height;
+ const dir = path.join(outDir, "cards");
+ const rule = render.rail?.rule ?? "#2A322F";
+ const RESERVED = reservedFooterHeight(render);
+
+ const ids = card.claims ?? [];
+ const rows = ids.map((id) => ledger.find((c) => c.id === id)).filter(Boolean);
+ if (rows.length !== ids.length) {
+ const missing = ids.filter((id) => !ledger.some((c) => c.id === id));
+ throw new Error(`ledger card ${card.id} names claims that are not in the ledger: ${missing.join(", ")}`);
+ }
+
+ // The arithmetic is READ, never recomputed: one implementation of the walk,
+ // or the card and the chart band can disagree about the same sum.
+ const steps = new Map(ledgerTotals(ledger).steps.map((st) => [st.id, st]));
+
+ const M = 96;
+ const ARITHW = 320;
+ const arithX = VW - M - ARITHW;
+ const quoteW = arithX - 70 - M;
+
+ const body = [`<rect x="0" y="0" width="${VW}" height="${H}" fill="${pal.bg}"/>`];
+ body.push(
+ svgText(M, 80, (card.kicker ?? "found in the sweep, not clipped here").toUpperCase(), {
+ size: 22, color: pal.amber, weight: "bold", ls: 1.4,
+ }),
+ svgText(M, 132, card.heading ?? "What the sweep found and this cut cannot show you", {
+ size: 40, color: pal.fg, weight: "bold",
+ }),
+ svgText(M, 168, card.sub ?? "his own words, and what they do to our running sum", {
+ size: 20, color: pal.muted,
+ }),
+ `<rect x="${M}" y="${192}" width="${VW - 2 * M}" height="1" fill="${rule}"/>`,
+ );
+
+ // Rows are a fixed height and the BLOCK is centred in what is left of the
+ // frame. Stretching two rows to fill 640px puts a hand's width of nothing
+ // between them; packing them at the top leaves the same gap in one lump at
+ // the bottom. Centring is the only arrangement that reads as deliberate.
+ const top = 214;
+ const available = H - RESERVED - top - 24;
+ const ROWH = Math.min(180, Math.floor(available / rows.length));
+ const rowsTop = top + Math.floor((available - ROWH * rows.length) / 2);
+
+ rows.forEach((c, r) => {
+ const y = rowsTop + r * ROWH;
+ const tr = byKey[c.scope ?? c.company];
+ const st = steps.get(c.id);
+ const state = avail?.get(c.id) ?? null;
+ const tag = SOURCE_TAG[state] ?? "not clipped";
+
+ body.push(
+ svgText(M, y + 32, c.date, { size: 20, color: pal.muted }),
+ // The scope, as a bordered pill in its own colour. Which payroll a number
+ // is about is the whole argument, so it is never left to the ink alone.
+ `<rect x="${M + 148}" y="${y + 12}" width="${Math.max(120, (tr?.label?.length ?? 8) * 7.6 + 22)}" ` +
+ `height="26" rx="13" fill="none" stroke="${tr?.color ?? pal.muted}" stroke-width="1.2"/>`,
+ svgText(M + 159, y + 30, tr?.label ?? c.scope ?? "", { size: 14.5, color: tr?.color ?? pal.muted }),
+ svgText(M + 148 + Math.max(120, (tr?.label?.length ?? 8) * 7.6 + 22) + 16, y + 30, tag, {
+ size: 14.5, color: pal.muted, opacity: 0.85,
+ }),
+ svgText(arithX - 70, y + 40, c.display ?? "—", {
+ size: 34, color: tr?.color ?? pal.fg, weight: "bold", anchor: "end",
+ }),
+ );
+ wrapPx(`“${c.quote ?? c.label ?? ""}”`, 25, quoteW, 2).forEach((line, li) => {
+ body.push(svgText(M, y + 76 + li * 33, line, { size: 25, color: pal.fg }));
+ });
+
+ // ---- the arithmetic column ----
+ // Which layer this claim moved, lit; the others held, dimmed. The point is
+ // that the total on the right is OURS and is made of his own figures.
+ body.push(
+ svgText(arithX, y + 24, "OUR RUNNING SUM", {
+ size: 11, color: pal.muted, weight: "bold", ls: 1.3,
+ }),
+ );
+ const basis = st?.impliedBasis ?? {};
+ const companies = tracks.filter((t) => t.key !== "all");
+ // Before any company has given a figure, our sum is not zero — it is
+ // undefined, and four dashes in a column say that far less clearly than
+ // one sentence does.
+ if (!companies.some((t) => basis[t.key])) {
+ body.push(
+ svgText(arithX, y + 52, "no company figure yet,", { size: 14, color: pal.muted }),
+ svgText(arithX, y + 74, "so our sum is not defined", { size: 14, color: pal.muted }),
+ );
+ if (r < rows.length - 1) {
+ body.push(
+ `<rect x="${M}" y="${y + ROWH - 1}" width="${VW - 2 * M}" height="1" fill="${rule}" opacity="0.6"/>`,
+ );
+ }
+ return;
+ }
+ companies.forEach((t, k) => {
+ const b = basis[t.key];
+ const moved = (c.scope ?? c.company) === t.key;
+ const ty = y + 48 + k * 24;
+ body.push(
+ svgText(arithX, ty, fit(t.shortLabel ?? t.label, 13, ARITHW - 90), {
+ size: 13, color: moved ? t.color : pal.muted, opacity: moved ? 1 : 0.55,
+ }),
+ svgText(arithX + ARITHW, ty, b ? String(b.value) : "—", {
+ size: 17, color: moved ? t.color : pal.muted, weight: "bold", anchor: "end",
+ opacity: moved ? 1 : 0.55,
+ }),
+ );
+ });
+ const sy = y + 48 + companies.length * 24;
+ body.push(
+ `<rect x="${arithX}" y="${sy + 6}" width="${ARITHW}" height="1" fill="${rule}"/>`,
+ svgText(arithX, sy + 30, "IMPLIED", { size: 13, color: pal.fg, weight: "bold", ls: 1.2 }),
+ svgText(arithX + ARITHW, sy + 32, st?.implied == null ? "—" : String(st.implied), {
+ size: 22, color: pal.fg, weight: "bold", anchor: "end",
+ }),
+ );
+ if (st?.impliedDelta) {
+ const up = st.impliedDelta > 0;
+ body.push(
+ svgTri(arithX + 84, sy + 22, up, pal.amber),
+ svgText(arithX + 98, sy + 30, `${up ? "+" : "−"}${Math.abs(st.impliedDelta)}`, {
+ size: 14, color: pal.amber, weight: "bold",
+ }),
+ );
+ }
+
+ if (r < rows.length - 1) {
+ body.push(
+ `<rect x="${M}" y="${y + ROWH - 1}" width="${VW - 2 * M}" height="1" fill="${rule}" opacity="0.6"/>`,
+ );
+ }
+ });
+
+ const outPath = path.join(dir, `${card.id}.png`);
+ await rasterize(svgDoc(VW, H, body.join("")), path.join(dir, `${card.id}.svg`), outPath, VW, H);
+ return { path: outPath, width: VW, rowsTop, rowHeight: ROWH, rows: rows.length };
+}
+
+// ===========================================================================
+// End sequence: the ledger scroll and the step chart
+// ===========================================================================
+
+/**
+ * The whole ledger as one tall PNG for an animated crop to walk.
+ *
+ * ---------------------------------------------------------------------------
+ * One chronological line, a column per company
+ * ---------------------------------------------------------------------------
+ * It used to group by company: four blocks, each date-sorted inside itself. The
+ * cut plays in ONE chronology, and grouping at the end re-tells it in an order
+ * the viewer has not just watched — and it hides the only thing worth seeing
+ * here, which is that the four payrolls were being described in the same weeks.
+ *
+ * So: one date-ordered list, and the company is read from COLUMN POSITION. That
+ * makes colour the secondary encoding rather than the only one, which is the
+ * same rule the chart already runs under.
+ *
+ * The rail hides for this card (`hideRail`), so it is drawn at the FULL frame
+ * width rather than the content width.
+ *
+ * Returns the CONTENT HEIGHT because the scroll expression is written against
+ * it — crop clamps its own y, so an off-by-a-few degrades into a static last
+ * frame rather than an error, but only if the caller knows the real number.
+ */
+export async function renderScrollCard(card, render, ledger, outDir) {
+ const pal = render.palette;
+ const tracks = render.rail?.tracks ?? [];
+ const VW = cardWidth(card, render);
+ const dir = path.join(outDir, "cards");
+ const rule = render.rail?.rule ?? "#2A322F";
+
+ const M = 96;
+ const ROWH = 42;
+ const body = [];
+
+ // Columns. The value columns are right-aligned on their own gridline, so a
+ // number's horizontal position IS its company even before the colour reads.
+ const COLW = 152;
+ const dateX = M;
+ const colX = tracks.map((_, i) => M + 168 + i * COLW);
+ const popX = M + 168 + tracks.length * COLW + 24;
+ const labelX = popX + 132;
+ const labelW = VW - M - labelX;
+
+ let y = 66;
+ body.push(svgText(M, y, card.heading ?? "THE COMPLETE LEDGER", {
+ size: 30, color: pal.fg, weight: "bold", ls: 1.5,
+ }));
+ y += 32;
+ body.push(svgText(M, y, card.sub ?? `${ledger.length} dated claims, in the order he made them`, {
+ size: 19, color: pal.muted,
+ }));
+ y += 44;
+
+ // The column heads, which are the legend. No separate key: a company name
+ // over its own column of figures is the shortest legend there is.
+ body.push(`<rect x="${M}" y="${y - 4}" width="${VW - 2 * M}" height="1" fill="${rule}"/>`);
+ body.push(svgText(dateX, y + 26, "DATE", { size: 13, color: pal.muted, weight: "bold", ls: 1.3 }));
+ // No swatch beside the head: the head is already IN the track's colour, and
+ // the column position is the primary encoding either way. A swatch would only
+ // land on top of the words, since a right-anchored run cannot be measured
+ // here to leave room for one.
+ tracks.forEach((tr, i) => {
+ body.push(
+ svgText(colX[i], y + 26, fit(tr.shortLabel ?? tr.label, 13, COLW - 12), {
+ size: 13, color: tr.color, weight: "bold", anchor: "end", ls: 0.6,
+ }),
+ );
+ });
+ body.push(
+ svgText(popX, y + 26, "AS WHAT", { size: 13, color: pal.muted, weight: "bold", ls: 1.3 }),
+ svgText(labelX, y + 26, "WHAT HE SAID", { size: 13, color: pal.muted, weight: "bold", ls: 1.3 }),
+ );
+ y += 40;
+ body.push(`<rect x="${M}" y="${y}" width="${VW - 2 * M}" height="1" fill="${rule}"/>`);
+ y += 8;
+
+ const byKey = Object.fromEntries(tracks.map((t, i) => [t.key, i]));
+ const rows = [...ledger].sort((a, b) => dateKey(a.date).localeCompare(dateKey(b.date)));
+ for (const c of rows) {
+ const live = !!c.entryId && !c.unsourced;
+ const i = byKey[c.scope ?? c.company];
+ const tr = tracks[i];
+ body.push(
+ svgText(dateX, y + 26, c.date, { size: 18, color: pal.muted, opacity: live ? 1 : 0.7 }),
+ );
+ if (tr) {
+ body.push(
+ svgText(colX[i], y + 26, c.display ?? "—", {
+ size: 21, color: live ? tr.color : pal.muted, weight: "bold", anchor: "end",
+ opacity: live ? 1 : 0.6,
+ }),
+ );
+ }
+ body.push(
+ svgText(popX, y + 26, POP_WORD[c.population] ?? c.population ?? "", {
+ size: 15, color: pal.muted, opacity: live ? 0.9 : 0.6,
+ }),
+ svgText(labelX, y + 26, fit(c.label ?? "", 18, labelW), {
+ size: 18, color: live ? pal.fg : pal.muted, opacity: live ? 1 : 0.6,
+ }),
+ `<rect x="${M}" y="${y + ROWH - 1}" width="${VW - 2 * M}" height="1" fill="${rule}" opacity="0.5"/>`,
+ );
+ y += ROWH;
+ }
+ y += 60;
+
+ const contentHeight = y;
+ const outPath = path.join(dir, `${card.id}.png`);
+ await rasterize(
+ svgDoc(VW, contentHeight, `<rect x="0" y="0" width="${VW}" height="${contentHeight}" fill="${pal.bg}"/>${body.join("")}`),
+ path.join(dir, `${card.id}.svg`), outPath, VW, contentHeight,
+ );
+ return { path: outPath, contentHeight, width: VW };
+}
+
+/**
+ * The four-series step chart, over the claims flagged `plotted`.
+ *
+ * COLOUR IS NOT THE ONLY ENCODING here, and that is a hard requirement rather
+ * than a flourish: no four-colour categorical palette clears the data-viz
+ * all-pairs CVD gate (three slots is the documented ceiling), so each series
+ * also carries a distinct dash pattern and a direct end-of-line label. The four
+ * hues themselves are the published artifact's, re-validated against this
+ * video's darker ground (#0F1312) on the adjacent pairlist — the pairlist for
+ * line charts — where all five checks pass.
+ */
+export async function renderChartCard(card, render, ledger, outDir) {
+ const pal = render.palette;
+ const tracks = render.rail?.tracks ?? [];
+ const VW = cardWidth(card, render);
+ const H = render.height;
+ const dir = path.join(outDir, "cards");
+ const rule = render.rail?.rule ?? "#2A322F";
+
+ // The series come from ledger-totals, not from the legacy `plotted` flag.
+ // `plotted` was set under the OLD reading, in which a sum we performed sat in
+ // the same series as a figure he uttered. Drawing from it now would put 18 and
+ // 20 back on his line, after the whole point of the adjudication was to take
+ // them off it.
+ let totals = null;
+ try {
+ totals = ledgerTotals(ledger);
+ } catch {
+ // An unadjudicated ledger still renders -- as the three company series only,
+ // because the two totals are exactly what it cannot be trusted about.
+ totals = null;
+ }
+ const pts = ledger.filter((c) => c.value != null && (c.scope ?? c.company) !== "all");
+ const yr = (d) => {
+ const [Y, M2, D2] = d.split("-").map(Number);
+ return Y + (M2 - 1) / 12 + (D2 - 1) / 365;
+ };
+ const X0 = yr("2020-01-01"), X1 = yr("2026-12-31");
+ const YMAX =
+ Math.max(21, ...pts.map((p) => p.value), ...(totals?.series.implied ?? []).map((p) => p.value)) + 1;
+
+ const RESERVED = reservedFooterHeight(render);
+ const box = { l: 150, r: 300, t: 190, b: 130 + RESERVED };
+ const plotW = VW - box.l - box.r;
+ const plotH = H - box.t - box.b;
+ const px = (v) => box.l + ((v - X0) / (X1 - X0)) * plotW;
+ const py = (v) => H - box.b - (v / YMAX) * plotH;
+
+ const body = [`<rect x="0" y="0" width="${VW}" height="${H}" fill="${pal.bg}"/>`];
+ body.push(
+ svgText(box.l, 78, "WHAT HE SAID, AND WHAT IT ADDS UP TO", {
+ size: 34, color: pal.fg, weight: "bold", ls: 1.5,
+ }),
+ svgText(box.l, 112, "every figure he utters, against the company he was talking about", {
+ size: 20, color: pal.muted,
+ }),
+ svgText(box.l, 146, "the heavy line is ours — his own per-company claims, added up", {
+ size: 18, color: pal.amber,
+ }),
+ );
+
+ // grid + axes
+ for (let gv = 0; gv <= YMAX - 1; gv += 5) {
+ body.push(
+ `<rect x="${box.l}" y="${py(gv)}" width="${plotW}" height="1" fill="${rule}"/>`,
+ svgText(box.l - 16, py(gv) + 6, String(gv), { size: 17, color: pal.muted, anchor: "end" }),
+ );
+ }
+ body.push(svgText(box.l - 16, py(YMAX - 1) - 22, "PEOPLE", {
+ size: 13, color: pal.muted, weight: "bold", anchor: "end", ls: 1.2,
+ }));
+ for (let Y = 2020; Y <= 2026; Y += 1) {
+ const x = px(yr(`${Y}-01-01`));
+ body.push(
+ `<rect x="${x}" y="${box.t}" width="1" height="${py(0) - box.t}" fill="${rule}" opacity="0.7"/>`,
+ svgText(x, py(0) + 30, String(Y), { size: 17, color: pal.muted, anchor: "middle" }),
+ );
+ }
+ body.push(`<rect x="${box.l}" y="${py(0)}" width="${plotW}" height="2" fill="${pal.muted}"/>`);
+
+ // One step path per series, plus a dot per claim and a direct end label.
+ //
+ // FIVE series, not four. The three companies are his, drawn as before. The
+ // fourth is what he says the WHOLE payroll is -- only ever a figure he utters
+ // as one number. The fifth is what his own per-company claims add up to, and
+ // it is ours: a heavy neutral step, because an aggregate is not a categorical
+ // peer of the things it aggregates and must not consume a palette slot.
+ const DASH = ["", "12 6", "3 7", "18 5 4 5"];
+ const labels = [];
+ const drawn = [];
+ tracks.forEach((tr, ti) => {
+ if (tr.key === "all") return;
+ drawn.push({
+ tr, dash: DASH[ti % 4], width: 3.5,
+ pts: pts.filter((c) => (c.scope ?? c.company) === tr.key)
+ .slice().sort((a, b) => a.date.localeCompare(b.date))
+ .map((c) => ({ date: c.date, value: c.value, display: c.display, hedged: c.hedged })),
+ });
+ });
+ if (totals) {
+ const allTrack = tracks.find((t) => t.key === "all");
+ drawn.push({
+ tr: { key: "stated", color: allTrack?.color ?? pal.accent, label: "stated total" },
+ dash: "18 5 4 5", width: 3.5, dots: true,
+ pts: totals.series.stated.map((p) => ({ date: p.date, value: p.value, display: String(p.value) })),
+ });
+ drawn.push({
+ tr: { key: "implied", color: pal.fg, label: "implied — our sum" },
+ dash: "", width: 6, dots: false,
+ pts: totals.series.implied.map((p) => ({ date: p.date, value: p.value, display: String(p.value) })),
+ });
+ }
+
+ for (const sr of drawn) {
+ const mine = sr.pts;
+ if (!mine.length) continue;
+ // A step, not a line: the figure he gave holds until he gives another one,
+ // so the segment between two claims must be flat and the change vertical.
+ let d = "";
+ let prevY = null;
+ for (const [i, c] of mine.entries()) {
+ const x = px(yr(c.date));
+ const yv = py(c.value);
+ d += i === 0
+ ? `M ${x.toFixed(1)} ${yv.toFixed(1)}`
+ : ` L ${x.toFixed(1)} ${prevY.toFixed(1)} L ${x.toFixed(1)} ${yv.toFixed(1)}`;
+ prevY = yv;
+ }
+ const last = mine[mine.length - 1];
+ const lastY = py(last.value);
+ d += ` L ${(box.l + plotW).toFixed(1)} ${lastY.toFixed(1)}`;
+ body.push(
+ `<path d="${d}" fill="none" stroke="${sr.tr.color}" stroke-width="${sr.width}" ` +
+ `stroke-linejoin="round"${sr.dash ? ` stroke-dasharray="${sr.dash}"` : ""}/>`,
+ );
+ if (sr.dots !== false) {
+ for (const c of mine) {
+ body.push(
+ `<circle cx="${px(yr(c.date)).toFixed(1)}" cy="${py(c.value).toFixed(1)}" r="${c.hedged ? 5 : 6}" ` +
+ `fill="${c.hedged ? pal.bg : sr.tr.color}" stroke="${sr.tr.color}" stroke-width="2.5"/>`,
+ );
+ }
+ }
+ labels.push({ tr: sr.tr, last, lineY: lastY, y: lastY });
+ }
+
+ // The closing hold annotates the gap it has just finished drawing.
+ if (card.hold && totals && totals.final.stated != null && totals.final.implied != null) {
+ const xR = box.l + plotW;
+ const yS = py(totals.final.stated);
+ const yI = py(totals.final.implied);
+ body.push(
+ `<rect x="${(xR - 190).toFixed(1)}" y="${Math.min(yI, yS).toFixed(1)}" width="170" ` +
+ `height="${Math.abs(yS - yI).toFixed(1)}" fill="${tracks.find((t) => t.key === "all")?.color ?? pal.accent}" opacity="0.12"/>`,
+ `<path d="M ${(xR - 105).toFixed(1)} ${yI.toFixed(1)} L ${(xR - 105).toFixed(1)} ${yS.toFixed(1)}" ` +
+ `stroke="${pal.amber}" stroke-width="2"/>`,
+ svgText(xR - 96, (yI + yS) / 2 - 4, `gap ${Math.round(totals.final.implied - totals.final.stated)}`, {
+ size: 22, color: pal.amber, weight: "bold",
+ }),
+ svgText(xR - 96, (yI + yS) / 2 + 22, "between his last total and our sum", {
+ size: 14, color: pal.muted,
+ }),
+ );
+ }
+
+ // Three of the four series end within a couple of people of each other, so
+ // their direct labels land on top of one another. Push them apart and elbow a
+ // leader line back to the value each one actually belongs to — direct labels
+ // are the secondary encoding that lets a four-colour palette be legible at
+ // all, so an unreadable stack would defeat the point of having them.
+ const LBLH = 48;
+ labels.sort((a, b) => a.y - b.y);
+ for (let i = 1; i < labels.length; i += 1) {
+ labels[i].y = Math.max(labels[i].y, labels[i - 1].y + LBLH);
+ }
+ const overshoot = labels.length ? labels[labels.length - 1].y - (H - box.b - 10) : 0;
+ if (overshoot > 0) for (const l of labels) l.y -= overshoot;
+ for (const l of labels) {
+ const lx = box.l + plotW;
+ if (Math.abs(l.y - l.lineY) > 2) {
+ body.push(
+ `<path d="M ${lx} ${l.lineY.toFixed(1)} L ${lx + 9} ${l.lineY.toFixed(1)} ` +
+ `L ${lx + 9} ${l.y.toFixed(1)} L ${lx + 14} ${l.y.toFixed(1)}" fill="none" ` +
+ `stroke="${l.tr.color}" stroke-width="1.5" opacity="0.75"/>`,
+ );
+ }
+ body.push(
+ svgText(lx + 20, l.y + 2, l.tr.label, { size: 18, color: l.tr.color, weight: "bold" }),
+ svgText(lx + 20, l.y + 22, `last stated ${l.last.display}`, { size: 14, color: pal.muted }),
+ );
+ }
+
+ body.push(
+ svgText(box.l, H - RESERVED - 56, "hollow dot = a hedge word (“nearly ten”, “a handful”), not a figure", {
+ size: 16, color: pal.muted,
+ }),
+ svgText(box.l, H - RESERVED - 30, "each series is dashed as well as coloured — the shapes carry the reading on their own; " +
+ "sums and midpoints are ours and are never drawn as his", {
+ size: 16, color: pal.muted,
+ }),
+ );
+
+ const outPath = path.join(dir, `${card.id}.png`);
+ await rasterize(svgDoc(VW, H, body.join("")), path.join(dir, `${card.id}.svg`), outPath, VW, H);
+ return {
+ path: outPath,
+ plotX: box.l, plotY: box.t, plotW, plotH: py(0) - box.t + 2,
+ };
}
export async function renderCard(card, render, outDir, nodes) {
@@ -302,7 +1452,8 @@ export async function renderCard(card, render, outDir, nodes) {
async function renderPlainCard(card, render, outDir) {
const pal = render.palette;
const { width, height } = render;
- const textWidth = Math.round(width * 0.74);
+ const VW = cardWidth(card, render);
+ const textWidth = Math.round(VW * 0.74);
const outPath = path.join(outDir, "cards", `${card.id}.png`);
// Pango reads its markup from a file to keep it clear of shell/argv quoting.
@@ -312,7 +1463,7 @@ async function renderPlainCard(card, render, outDir) {
// One magick invocation: solid ground, an accent rule down the left margin,
// then the Pango block composited over it. The rule is what keeps the cards
// recognisably one family across styles.
- const barX = Math.round(width * 0.09);
+ const barX = Math.round(VW * 0.09);
const barTop = Math.round(height * 0.28);
const barBottom = Math.round(height * 0.72);
diff --git a/scripts/report-to-video/verify-build.mjs b/scripts/report-to-video/verify-build.mjs
@@ -10,20 +10,29 @@
// So the last step of a build measures the deliverable and compares it to the
// manifest. Cheap (one ffprobe) and the only thing that closes the loop.
//
-// node scripts/report-to-video/verify-build.mjs <manifest.json> [--out <dir>] [--json]
+// node scripts/report-to-video/verify-build.mjs <manifest.json> [--out <dir>]
+// [--variant sourced|full] [--json]
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { readFile, stat } from "node:fs/promises";
import path from "node:path";
+import { selectVariant, variantPaths } from "./build-video.mjs";
+
const execFileP = promisify(execFile);
const FFPROBE = process.env.FFPROBE_BIN ?? "ffprobe";
-export async function verifyBuild(manifestPath, { outDir } = {}) {
- const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
- const dir = outDir ?? path.join(path.dirname(path.resolve(manifestPath)), "out");
- const file = path.join(dir, `${manifest.slug}.mp4`);
+export async function verifyBuild(manifestPath, { outDir, variant = "sourced" } = {}) {
+ // The SAME filter the build ran. Verifying the whole manifest against one
+ // variant's file would report a missing chapter for every entry the other cut
+ // carries -- i.e. it would be red exactly when the build was right.
+ const manifest = selectVariant(
+ JSON.parse(await readFile(manifestPath, "utf8")),
+ variant,
+ );
+ const root = outDir ?? path.join(path.dirname(path.resolve(manifestPath)), "out");
+ const file = variantPaths(root, manifest.slug, variant).final;
const problems = [];
const st = await stat(file).catch(() => null);
@@ -55,31 +64,36 @@ export async function verifyBuild(manifestPath, { outDir } = {}) {
// real duration because snapping moves the cuts, but a file that came out at
// half the expected length did not build what was asked for.
const wanted = (manifest.timeline ?? []).reduce(
- (n, e) => n + (e.type === "card" ? (e.seconds ?? 0) : Math.max(0, (e.end ?? 0) - (e.start ?? 0))),
+ // `seconds` covers cards and the two end-sequence kinds (scroll, chart);
+ // only a clip's length has to be derived from its window.
+ (n, e) => n + (e.type === "clip" ? Math.max(0, (e.end ?? 0) - (e.start ?? 0)) : (e.seconds ?? 0)),
0,
);
if (wanted > 0 && duration < wanted * 0.5) {
problems.push(`${duration.toFixed(1)}s out of a timeline that asks for about ${wanted.toFixed(0)}s`);
}
- return { ok: problems.length === 0, file, duration, chapters, entries, size: st.size, problems };
+ return { ok: problems.length === 0, variant, file, duration, chapters, entries, size: st.size, problems };
}
async function main() {
const argv = process.argv.slice(2);
const manifestPath = argv.find((a) => !a.startsWith("--"));
if (!manifestPath) {
- console.error("usage: verify-build.mjs <manifest.json> [--out <dir>] [--json]");
+ console.error("usage: verify-build.mjs <manifest.json> [--out <dir>] [--variant sourced|full] [--json]");
process.exit(2);
}
- const i = argv.indexOf("--out");
- const res = await verifyBuild(manifestPath, { outDir: i >= 0 ? argv[i + 1] : undefined });
+ const flag = (n) => { const i = argv.indexOf(n); return i >= 0 ? argv[i + 1] : undefined; };
+ const res = await verifyBuild(manifestPath, {
+ outDir: flag("--out"),
+ variant: flag("--variant") ?? "sourced",
+ });
if (argv.includes("--json")) {
console.log(JSON.stringify(res, null, 2));
} else {
console.log(
- `${res.file}\n ${res.duration?.toFixed(1) ?? "?"}s · ${res.chapters ?? 0} chapter(s) for ` +
+ `${res.file} (${res.variant})\n ${res.duration?.toFixed(1) ?? "?"}s · ${res.chapters ?? 0} chapter(s) for ` +
`${res.entries ?? 0} entr(ies) · ${((res.size ?? 0) / 1e6).toFixed(1)} MB`,
);
for (const p of res.problems) console.log(` ** ${p}`);
diff --git a/umtool/app/api/report/claim/route.ts b/umtool/app/api/report/claim/route.ts
@@ -0,0 +1,65 @@
+import { StaleToken, manifestToken, updateClaim } from "@/lib/report/manifest.mjs";
+import { resolveClaim } from "@/lib/report/serve.mjs";
+import { readClaimDetail } from "@/lib/projects/report.mjs";
+
+export const dynamic = "force-dynamic";
+
+// One ledger claim, and the ruling on it.
+//
+// GET mirrors /api/report/clip: everything the bench needs in one request, plus
+// the manifest's mtime as a WRITE TOKEN, so a save can be refused when somebody
+// -- another tab, an agent, a `resolve-windows --write` -- has written in
+// between. Losing that write would be losing human judgement, which is the one
+// thing this whole surface exists to collect.
+
+export async function GET(request: Request) {
+ const url = new URL(request.url);
+ const projectId = url.searchParams.get("project") ?? "";
+ const claimId = url.searchParams.get("claim") ?? "";
+ const padArg = Number(url.searchParams.get("pad"));
+
+ const r = await resolveClaim(projectId, claimId);
+ if ("error" in r) return Response.json({ error: r.error }, { status: r.status });
+
+ const detail = await readClaimDetail(r.project.dir, claimId, {
+ manifest: r.manifest,
+ ...(Number.isFinite(padArg) && padArg > 0 ? { pad: Math.min(600, padArg) } : {}),
+ });
+ if (!detail) return Response.json({ error: "no such claim" }, { status: 404 });
+
+ return Response.json(
+ { project: r.project.id, ...detail, token: await manifestToken(r.project.dir) },
+ { headers: { "cache-control": "no-store" } },
+ );
+}
+
+export async function PUT(request: Request) {
+ const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
+ const projectId = String(body.project ?? "");
+ const claimId = String(body.claim ?? "");
+
+ const r = await resolveClaim(projectId, claimId);
+ if ("error" in r) return Response.json({ error: r.error }, { status: r.status });
+
+ const patch: Record<string, unknown> = {};
+ for (const k of ["scope", "scopeBasis", "scopeConfidence", "population", "valueKind", "flags", "roles", "note"]) {
+ if (body[k] !== undefined) patch[k] = body[k];
+ }
+ if (!Object.keys(patch).length) {
+ return Response.json({ error: "nothing to write" }, { status: 400 });
+ }
+
+ try {
+ const { entry, token } = await updateClaim(r.project.dir, claimId, patch, {
+ token: body.token === undefined ? null : String(body.token),
+ });
+ const detail = await readClaimDetail(r.project.dir, claimId);
+ return Response.json({ claim: entry, gaps: detail?.gaps ?? [], token });
+ } catch (err) {
+ // A stale token is a 409 and never a silent overwrite.
+ if (err instanceof StaleToken) {
+ return Response.json({ error: err.message, stale: true }, { status: 409 });
+ }
+ return Response.json({ error: (err as Error).message }, { status: 400 });
+ }
+}
diff --git a/umtool/app/api/report/raw/route.ts b/umtool/app/api/report/raw/route.ts
@@ -1,7 +1,8 @@
import { createReadStream } from "node:fs";
import { stat } from "node:fs/promises";
import { Readable } from "node:stream";
-import { absOf, pickWindow, resolveClip, windowsFor } from "@/lib/report/serve.mjs";
+import { absOf, pickWindow, resolveClaim, resolveClip, windowsFor } from "@/lib/report/serve.mjs";
+import { readClaimDetail } from "@/lib/projects/report.mjs";
export const dynamic = "force-dynamic";
@@ -18,13 +19,31 @@ export const dynamic = "force-dynamic";
export async function GET(request: Request) {
const url = new URL(request.url);
- const r = await resolveClip(url.searchParams.get("project") ?? "", url.searchParams.get("clip") ?? "");
- if ("error" in r) return new Response(r.error, { status: r.status });
+ const projectId = url.searchParams.get("project") ?? "";
+ const claimId = url.searchParams.get("claim");
+
+ // A CLAIM names a moment rather than a window, so its candidate files are the
+ // cached windows that CONTAIN its cite. Everything below -- membership,
+ // root-resolution, ranges, the immutable cache header -- is the clip path's,
+ // unchanged: the two differ only in which list of windows they may pick from.
+ let windows: Array<{ name: string; from: number; to: number; path: string }>;
+ if (claimId) {
+ const c = await resolveClaim(projectId, claimId);
+ if ("error" in c) return new Response(c.error, { status: c.status });
+ const detail = await readClaimDetail(c.project.dir, claimId, { manifest: c.manifest });
+ windows = (detail?.windows ?? []) as typeof windows;
+ // readClaimDetail returns names, not paths; re-resolve against the scan.
+ const all = await windowsFor(c.project, { video: c.claim.video });
+ windows = all.filter((w: { name: string }) => windows.some((x) => x.name === w.name));
+ } else {
+ const r = await resolveClip(projectId, url.searchParams.get("clip") ?? "");
+ if ("error" in r) return new Response(r.error, { status: r.status });
+ windows = await windowsFor(r.project, r.clip);
+ }
- const windows = await windowsFor(r.project, r.clip);
// A member of the server's own scan, never a path from the client.
const win = pickWindow(windows, url.searchParams.get("file"));
- if (!win) return new Response("no cached window for this clip", { status: 404 });
+ if (!win) return new Response("no cached window covering that moment", { status: 404 });
const abs = absOf(win);
if (!abs) return new Response("outside the roots", { status: 400 });
diff --git a/umtool/components/projects/ClaimBench.tsx b/umtool/components/projects/ClaimBench.tsx
@@ -0,0 +1,481 @@
+"use client";
+
+import Link from "next/link";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import {
+ POPULATIONS,
+ SCOPES,
+ SCOPE_CONFIDENCE,
+ VALUE_KINDS,
+ rosterLine,
+} from "report-to-video/ledger-totals";
+
+// Ruling on one claim.
+//
+// The layout puts the CONTEXT first and the controls second, on purpose. The
+// temptation this page exists to resist is adjudicating from the quote -- the
+// quote is what produced the four hazards in the first place, and every one of
+// them is invisible inside it. So the cited cue is highlighted inside a
+// ninety-second paragraph, and the audio starts a few seconds early.
+
+export type ClaimBenchData = {
+ project: string;
+ claim: {
+ id: string;
+ date: string;
+ company: string | null;
+ value: number | null;
+ display: string | null;
+ label: string | null;
+ quote: string | null;
+ src: string | null;
+ video: string | null;
+ channel: string | null;
+ cite: number | null;
+ scope: string | null;
+ scopeBasis: string | null;
+ scopeConfidence: string | null;
+ population: string | null;
+ valueKind: string | null;
+ flags: string[] | null;
+ roles: Array<{ role: string; count: number; verbatim: string }> | null;
+ };
+ at: number | null;
+ view: { from: number; to: number };
+ pad: number;
+ cues: Array<{ start: number; end: number; text: string; cited: boolean }>;
+ source: { title: string | null; uploadDate: string | null; duration: number | null; webpageUrl: string | null } | null;
+ noCues: boolean;
+ windows: Array<{ name: string; from: number; to: number }>;
+ gaps: string[];
+ fired: Array<{ rule: string; text: string }>;
+ prev: string | null;
+ next: string | null;
+ token: string | null;
+};
+
+const hms = (t: number) => {
+ const s = Math.max(0, Math.floor(t));
+ const h = Math.floor(s / 3600);
+ const m = Math.floor((s % 3600) / 60);
+ const r = s % 60;
+ return h ? `${h}:${String(m).padStart(2, "0")}:${String(r).padStart(2, "0")}` : `${m}:${String(r).padStart(2, "0")}`;
+};
+
+/** What each vocabulary term means, so nobody has to guess at the radio. */
+const HELP: Record<string, string> = {
+ media: "The Quartering — the media team",
+ coffee: "Coffee Brand Coffee",
+ publica: "The Publica",
+ all: "spans everything he owns",
+ clear: "the quote itself settles it",
+ read: "the context settles it; the quote alone would not",
+ unresolved: "genuinely ambiguous — feeds NEITHER total",
+ uttered: "he says this number, as one number, for this scope",
+ derived: "our arithmetic over his per-company claims",
+ synthetic: "our midpoint of a range he gave",
+ employees: "“employees”",
+ "full-time": "“full-time”",
+ salaried: "“salaried”",
+ contractor: "“contractors”",
+ "1099": "“1099”",
+ people: "“people”",
+};
+
+function Choice({
+ label,
+ options,
+ value,
+ onChange,
+ name,
+}: {
+ label: string;
+ options: readonly string[];
+ value: string | null;
+ onChange: (v: string) => void;
+ name: string;
+}) {
+ return (
+ <div>
+ <div className="micro mb-1">{label}</div>
+ <div className="flex flex-wrap gap-1">
+ {options.map((o) => (
+ <button
+ key={o}
+ type="button"
+ data-testid={`${name}-${o}`}
+ aria-pressed={value === o}
+ title={HELP[o] ?? o}
+ onClick={() => onChange(o)}
+ className={
+ "rounded border px-2 py-1 text-[11px] " +
+ (value === o
+ ? "border-[var(--color-sel)] bg-[var(--color-sel)]/15 text-[var(--color-sel)]"
+ : "border-[var(--color-line)] text-[var(--color-dim)] hover:text-[var(--color-fg)]")
+ }
+ >
+ {o}
+ </button>
+ ))}
+ </div>
+ {value && HELP[value] ? (
+ <div className="mt-1 text-[10px] text-[var(--color-dim)]">{HELP[value]}</div>
+ ) : null}
+ </div>
+ );
+}
+
+type Role = { role: string; count: number; verbatim: string };
+
+const rolesToText = (roles: ClaimBenchData["claim"]["roles"]) =>
+ (roles ?? []).map((r) => `${r.count} | ${r.role} | ${r.verbatim}`).join("\n");
+
+/**
+ * `2 | video editor | two video editors` per line.
+ *
+ * `verbatim` is required and is the point of the field: the count and the role
+ * name are OUR reading, and without his own words beside them nobody can check
+ * the reading against the audio.
+ */
+function parseRoles(text: string): { ok: true; list: Role[] } | { ok: false; error: string } {
+ const lines = text.split("\n").map((l) => l.trim()).filter(Boolean);
+ const list: Role[] = [];
+ for (const [i, line] of lines.entries()) {
+ const parts = line.split("|").map((s) => s.trim());
+ if (parts.length !== 3) return { ok: false, error: `line ${i + 1} needs count | role | his words` };
+ const count = Number(parts[0]);
+ if (!Number.isFinite(count) || count < 0) return { ok: false, error: `line ${i + 1}: count is not a number` };
+ if (!parts[1]) return { ok: false, error: `line ${i + 1}: no role` };
+ if (!parts[2]) return { ok: false, error: `line ${i + 1}: quote his words for it` };
+ list.push({ count, role: parts[1], verbatim: parts[2] });
+ }
+ return { ok: true, list };
+}
+
+export default function ClaimBench({ data }: { data: ClaimBenchData }) {
+ const { claim, at, view, cues } = data;
+ const video = useRef<HTMLVideoElement>(null);
+
+ const [scope, setScope] = useState(claim.scope);
+ const [confidence, setConfidence] = useState(claim.scopeConfidence);
+ const [population, setPopulation] = useState(claim.population);
+ const [valueKind, setValueKind] = useState(claim.valueKind);
+ const [basis, setBasis] = useState(claim.scopeBasis ?? "");
+ const [flagText, setFlagText] = useState((claim.flags ?? []).join("\n"));
+ // The roster, as one line per role: `2 | video editor | two video editors`.
+ // A textarea rather than a row of inputs because most claims have none and
+ // the handful that do are a two-line list -- a repeater widget would be more
+ // chrome than the field it edits.
+ const [roleText, setRoleText] = useState(rolesToText(claim.roles));
+ const [token, setToken] = useState(data.token ?? "");
+ const [busy, setBusy] = useState<string | null>(null);
+ const [note, setNote] = useState<string | null>(null);
+ const [fetching, setFetching] = useState(false);
+
+ // The widest cached window containing the cite. When there is one the moment
+ // is playable with no download at all -- which is most of them once a build
+ // has run, because the build already over-fetched around every clip.
+ const cached = data.windows[0] ?? null;
+
+ const roles = useMemo(() => parseRoles(roleText), [roleText]);
+ const rosterPreview = roles.ok ? rosterLine(roles.list) : null;
+
+ const dirty =
+ roleText !== rolesToText(claim.roles) ||
+ scope !== claim.scope ||
+ confidence !== claim.scopeConfidence ||
+ population !== claim.population ||
+ valueKind !== claim.valueKind ||
+ basis !== (claim.scopeBasis ?? "") ||
+ flagText !== (claim.flags ?? []).join("\n");
+
+ const complete = !!(scope && confidence && population && valueKind && basis.trim());
+
+ // Start a few seconds EARLY. Landing exactly on the cited word is landing
+ // mid-sentence, which is the position this page exists to get out of.
+ const seekTo = useCallback(
+ (t: number) => {
+ const el = video.current;
+ if (!el || !cached) return;
+ el.currentTime = Math.max(0, t - cached.from);
+ void el.play().catch(() => {});
+ },
+ [cached],
+ );
+
+ useEffect(() => {
+ if (at != null && cached) {
+ const el = video.current;
+ if (el) el.currentTime = Math.max(0, at - cached.from - 4);
+ }
+ }, [at, cached]);
+
+ const save = useCallback(async () => {
+ setBusy("saving…");
+ setNote(null);
+ const r = await fetch("/api/report/claim", {
+ method: "PUT",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({
+ project: data.project,
+ claim: claim.id,
+ token,
+ scope,
+ scopeConfidence: confidence,
+ population,
+ valueKind,
+ scopeBasis: basis,
+ flags: flagText.split("\n").map((s) => s.trim()).filter(Boolean),
+ roles: roles.ok ? roles.list : undefined,
+ }),
+ });
+ const j = (await r.json()) as Record<string, unknown>;
+ setBusy(null);
+ if (!r.ok) {
+ setNote(
+ j.stale
+ ? "the manifest changed since you opened this — reload before saving, or your ruling would overwrite whatever was written"
+ : `could not save: ${String(j.error ?? r.status)}`,
+ );
+ return;
+ }
+ setToken(String(j.token ?? ""));
+ const gaps = (j.gaps as string[]) ?? [];
+ setNote(gaps.length ? `saved — still missing ${gaps.join(", ")}` : "saved — adjudicated");
+ }, [data.project, claim.id, token, scope, confidence, population, valueKind, basis, flagText, roles]);
+
+ const fetchWindow = useCallback(async () => {
+ setFetching(true);
+ setNote(null);
+ const r = await fetch("/api/report/fetch", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ project: data.project, clip: claim.id, pad: data.pad + 5 }),
+ });
+ const j = (await r.json()) as Record<string, unknown>;
+ if (!r.ok) {
+ setFetching(false);
+ setNote(`could not fetch: ${String(j.error ?? r.status)}`);
+ return;
+ }
+ // The job runs on the server; poll it rather than guess how long it takes.
+ const job = j.job as { id: string } | undefined;
+ for (let i = 0; i < 300 && job; i += 1) {
+ await new Promise((res) => setTimeout(res, 1000));
+ const s = await fetch(`/api/report/fetch?job=${job.id}`, { cache: "no-store" });
+ const sj = (await s.json()) as { job?: { state?: string } };
+ if (!sj.job || sj.job.state === "done" || sj.job.state === "failed") break;
+ }
+ setFetching(false);
+ window.location.reload();
+ }, [data.project, claim.id, data.pad]);
+
+ const paragraph = useMemo(
+ () =>
+ cues.map((c, i) => (
+ <span
+ key={`${c.start}-${i}`}
+ data-cited={c.cited ? "1" : undefined}
+ onClick={() => seekTo(c.start)}
+ className={
+ "cursor-pointer " +
+ (c.cited
+ ? "bg-[var(--color-sel)]/25 text-[var(--color-fg)]"
+ : "text-[var(--color-dim)] hover:text-[var(--color-fg)]")
+ }
+ >
+ {c.text}{" "}
+ </span>
+ )),
+ [cues, seekTo],
+ );
+
+ return (
+ <div className="space-y-3" data-claim={claim.id}>
+ <div className="flex items-baseline gap-3">
+ <div className="font-mono text-[13px] text-[var(--color-fg)]">{claim.id}</div>
+ <div className="text-[12px] text-[var(--color-dim)]">{claim.date}</div>
+ <div className="text-[13px] text-[var(--color-meter)]">
+ {claim.display ?? (claim.value == null ? "—" : String(claim.value))}
+ </div>
+ <div className="flex-1" />
+ {data.prev ? (
+ <Link className="text-[11px] text-[var(--color-sel)] hover:underline" href={`/browse/${data.project}/claim/${data.prev}`}>
+ ← {data.prev}
+ </Link>
+ ) : null}
+ {data.next ? (
+ <Link className="text-[11px] text-[var(--color-sel)] hover:underline" href={`/browse/${data.project}/claim/${data.next}`}>
+ {data.next} →
+ </Link>
+ ) : null}
+ </div>
+
+ <div className="grid gap-3 lg:grid-cols-[minmax(0,1fr)_360px]">
+ <div className="space-y-3">
+ {cached ? (
+ <video
+ ref={video}
+ data-testid="claim-video"
+ src={`/api/report/raw?project=${encodeURIComponent(data.project)}&claim=${encodeURIComponent(claim.id)}&file=${encodeURIComponent(cached.name)}`}
+ className="aspect-video w-full rounded border border-[var(--color-line)] bg-black"
+ preload="metadata"
+ controls
+ />
+ ) : (
+ <div className="flex aspect-video w-full flex-col items-center justify-center gap-2 rounded border border-dashed border-[var(--color-line)] text-[12px] text-[var(--color-dim)]">
+ <div>no cached window covers this moment</div>
+ <button
+ type="button"
+ data-testid="claim-fetch"
+ disabled={fetching || !claim.video}
+ onClick={() => void fetchWindow()}
+ className="rounded border border-[var(--color-line)] px-2 py-1 text-[11px] hover:text-[var(--color-fg)] disabled:opacity-40"
+ >
+ {fetching ? "fetching…" : `fetch ±${data.pad}s`}
+ </button>
+ </div>
+ )}
+
+ <div className="rounded border border-[var(--color-line)] p-3">
+ <div className="micro mb-2">
+ ±{data.pad}s of context · {hms(view.from)}–{hms(view.to)}
+ {at != null ? ` · cited at ${hms(at)}` : ""}
+ </div>
+ {data.noCues ? (
+ <div className="text-[12px] text-[var(--color-dim)]">
+ no transcript.cues.json for {claim.channel}/{claim.video} — on Rumble, check the
+ directory is the URL slug and not the site id
+ </div>
+ ) : (
+ <p
+ data-testid="claim-context"
+ className="max-h-[320px] overflow-y-auto text-[12px] leading-relaxed"
+ >
+ {paragraph}
+ </p>
+ )}
+ </div>
+
+ <div className="rounded border border-[var(--color-line)] p-3 text-[12px]">
+ <div className="micro mb-1">as recorded</div>
+ <div className="text-[var(--color-fg)]">“{claim.quote}”</div>
+ <div className="mt-1 text-[11px] text-[var(--color-dim)]">
+ {claim.label} · {claim.src}
+ </div>
+ </div>
+ </div>
+
+ <div className="space-y-3 text-[12px]">
+ {data.gaps.length ? (
+ <div className="rounded border border-[var(--color-warn,#E8A33F)] p-2 text-[11px] text-[var(--color-dim)]">
+ unadjudicated — missing {data.gaps.join(", ")}
+ </div>
+ ) : (
+ <div className="rounded border border-[var(--color-line)] p-2 text-[11px] text-[var(--color-dim)]">
+ adjudicated
+ </div>
+ )}
+
+ <Choice name="scope" label="scope" options={SCOPES} value={scope} onChange={setScope} />
+ <Choice
+ name="confidence"
+ label="scope confidence"
+ options={SCOPE_CONFIDENCE}
+ value={confidence}
+ onChange={setConfidence}
+ />
+ <Choice
+ name="population"
+ label="population"
+ options={POPULATIONS}
+ value={population}
+ onChange={setPopulation}
+ />
+ <Choice
+ name="valuekind"
+ label="value kind"
+ options={VALUE_KINDS}
+ value={valueKind}
+ onChange={setValueKind}
+ />
+
+ <div>
+ <div className="micro mb-1">scope basis — the phrase that settles it</div>
+ <textarea
+ data-testid="claim-basis"
+ value={basis}
+ onChange={(e) => setBasis(e.target.value)}
+ rows={3}
+ className="w-full rounded border border-[var(--color-line)] bg-transparent p-2 text-[11px]"
+ placeholder="quote the words from the context that decide the scope"
+ />
+ </div>
+
+ <div>
+ <div className="micro mb-1">
+ roster — one role per line: <span className="font-mono">count | role | his words</span>
+ </div>
+ <textarea
+ data-testid="claim-roles"
+ value={roleText}
+ onChange={(e) => setRoleText(e.target.value)}
+ rows={3}
+ className="w-full rounded border border-[var(--color-line)] bg-transparent p-2 text-[11px]"
+ placeholder="2 | video editor | two video editors"
+ />
+ <div className="mt-1 text-[10px] text-[var(--color-dim)]">
+ {roleText.trim() === ""
+ ? "only for a claim where he ENUMERATES who works for him"
+ : roles.ok
+ ? `reads as “${rosterPreview}”`
+ : `not saveable yet — ${roles.error}`}
+ </div>
+ </div>
+
+ <div>
+ <div className="micro mb-1">flags — one per line, free text</div>
+ <textarea
+ data-testid="claim-flags"
+ value={flagText}
+ onChange={(e) => setFlagText(e.target.value)}
+ rows={2}
+ className="w-full rounded border border-[var(--color-line)] bg-transparent p-2 text-[11px]"
+ placeholder="e.g. reading someone else's tweet aloud"
+ />
+ </div>
+
+ {data.fired.length ? (
+ <div className="rounded border border-[var(--color-line)] p-2">
+ <div className="micro mb-1">predicates firing on this claim</div>
+ <ul className="space-y-1 text-[11px] text-[var(--color-dim)]">
+ {data.fired.map((f, i) => (
+ <li key={i}>
+ <span className="font-mono text-[10px] text-[var(--color-meter)]">{f.rule}</span>{" "}
+ {f.text}
+ </li>
+ ))}
+ </ul>
+ </div>
+ ) : null}
+
+ <div className="flex items-center gap-2">
+ <button
+ type="button"
+ data-testid="claim-save"
+ disabled={!dirty || !!busy || !roles.ok}
+ onClick={() => void save()}
+ className="rounded border border-[var(--color-sel)] px-3 py-1 text-[11px] text-[var(--color-sel)] disabled:opacity-40"
+ >
+ {busy ?? "save ruling"}
+ </button>
+ {!complete ? (
+ <span className="text-[10px] text-[var(--color-dim)]">all six fields, or it stays blocking</span>
+ ) : null}
+ </div>
+ {note ? <div data-testid="claim-note" className="text-[11px] text-[var(--color-dim)]">{note}</div> : null}
+ </div>
+ </div>
+ </div>
+ );
+}
diff --git a/umtool/components/projects/ClaimBenchPage.tsx b/umtool/components/projects/ClaimBenchPage.tsx
@@ -0,0 +1,101 @@
+import { notFound } from "next/navigation";
+import BrowseHeader from "@/components/BrowseHeader";
+import ClaimBench, { type ClaimBenchData } from "./ClaimBench";
+import { manifestToken } from "@/lib/report/manifest.mjs";
+import { readClaimDetail, readManifest } from "@/lib/projects/report.mjs";
+import { ledgerTotals } from "report-to-video/ledger-totals";
+import type { ProjectRef } from "@/lib/project-types";
+
+// The server half of the claim bench.
+//
+// Same rule as the clip bench: the id is validated as a MEMBER of the ledger,
+// never as a path. The difference is what gets read -- a clip wants its window
+// and its waveform, a claim wants ROOM. Ninety seconds either side, because a
+// first-person quote is routinely the host reading someone else's words or
+// being sarcastic, and the quote alone cannot show you which.
+
+export default async function ClaimBenchPage({
+ project,
+ claimId,
+}: {
+ project: ProjectRef;
+ claimId: string;
+}) {
+ const manifest = await readManifest(project.dir);
+ if (!manifest) notFound();
+
+ const detail = await readClaimDetail(project.dir, claimId, { manifest });
+ if (!detail) notFound();
+
+ const ledger = (manifest.ledger ?? []) as Array<Record<string, unknown>>;
+ const i = ledger.findIndex((e) => e.id === claimId);
+
+ // Which predicates fire on THIS claim, computed over whatever has been ruled
+ // on so far. `strict: false` because the ledger is by definition half-worked
+ // while somebody is standing on this page.
+ let fired: Array<{ rule: string; text: string }> = [];
+ try {
+ const totals = ledgerTotals(ledger, { strict: false });
+ fired = totals.steps.find((s: { id: string }) => s.id === claimId)?.flags ?? [];
+ } catch {
+ /* a malformed ledger shows as gaps, not as a broken page */
+ }
+
+ const data: ClaimBenchData = {
+ project: project.id,
+ claim: {
+ id: String(detail.claim.id),
+ date: String(detail.claim.date ?? ""),
+ company: (detail.claim.company as string) ?? null,
+ value: (detail.claim.value as number | null) ?? null,
+ display: (detail.claim.display as string) ?? null,
+ label: (detail.claim.label as string) ?? null,
+ quote: (detail.claim.quote as string) ?? null,
+ src: (detail.claim.src as string) ?? null,
+ video: (detail.claim.video as string) ?? null,
+ channel: (detail.claim.channel as string) ?? null,
+ cite: (detail.claim.cite as number | null) ?? null,
+ scope: (detail.claim.scope as string) ?? null,
+ scopeBasis: (detail.claim.scopeBasis as string) ?? null,
+ scopeConfidence: (detail.claim.scopeConfidence as string) ?? null,
+ population: (detail.claim.population as string) ?? null,
+ valueKind: (detail.claim.valueKind as string) ?? null,
+ flags: Array.isArray(detail.claim.flags) ? (detail.claim.flags as string[]) : null,
+ roles: Array.isArray(detail.claim.roles)
+ ? (detail.claim.roles as ClaimBenchData["claim"]["roles"])
+ : null,
+ },
+ at: detail.at,
+ view: detail.view,
+ pad: detail.pad,
+ cues: detail.cues,
+ source: detail.source,
+ noCues: detail.noCues,
+ windows: detail.windows,
+ gaps: detail.gaps,
+ fired,
+ prev: i > 0 ? String(ledger[i - 1].id) : null,
+ next: i >= 0 && i < ledger.length - 1 ? String(ledger[i + 1].id) : null,
+ token: await manifestToken(project.dir),
+ };
+
+ const outstanding = ledger.filter(
+ (e) => !e.scope || !e.scopeBasis || !e.scopeConfidence || !e.population || !e.valueKind || !Array.isArray(e.flags),
+ ).length;
+
+ return (
+ <div className="flex h-full flex-col">
+ <BrowseHeader
+ crumbs={[
+ { href: "/browse", label: "projects" },
+ { href: `/browse/${project.id}`, label: project.name },
+ { label: claimId },
+ ]}
+ note={`claim ${i + 1} of ${ledger.length} · ${outstanding} unadjudicated`}
+ />
+ <main className="deck-main flex-1 p-4">
+ <ClaimBench data={data} />
+ </main>
+ </div>
+ );
+}
diff --git a/umtool/components/projects/ProjectView.tsx b/umtool/components/projects/ProjectView.tsx
@@ -4,6 +4,7 @@ import SongProject from "./SongProject";
import CutPage from "./CutPage";
import ReportProject from "./ReportProject";
import ClipBenchPage from "./ClipBenchPage";
+import ClaimBenchPage from "./ClaimBenchPage";
import SweepProject from "./SweepProject";
// ---------------------------------------------------------------------------
@@ -43,6 +44,11 @@ export default async function ProjectView({
if (rest.length === 2 && rest[0] === "clip") {
return <ClipBenchPage project={project} clipId={rest[1]} />;
}
+ // `/browse/<project>/claim/<id>` -- ruling on one ledger entry against
+ // the audio, rather than editing a window.
+ if (rest.length === 2 && rest[0] === "claim") {
+ return <ClaimBenchPage project={project} claimId={rest[1]} />;
+ }
return notFound();
}
case "sweep-report": {
diff --git a/umtool/docs/README.md b/umtool/docs/README.md
@@ -35,6 +35,10 @@ node scripts/report-to-video/resolve-windows.mjs ~/reports/<slug>/video.manifest
# 4. bench any clip whose edges you are unsure of
# /browse/<slug>/clip/<id>
+# 4b. if the manifest has a ledger, rule on every claim in it. `umtool check`
+# BLOCKS until it is empty, because both totals lie on an unadjudicated one.
+# /browse/<slug>/claim/<id>
+
# 5. build: a fast pass to watch, then the real one.
# The BUTTON on the project page runs it -- cancellation, the per-step
# timeouts and the process-group kill live in the server's job runner.
@@ -42,6 +46,13 @@ node scripts/report-to-video/resolve-windows.mjs ~/reports/<slug>/video.manifest
umtool build <slug> --preset fast
```
+**A manifest may describe more than one cut.** `build-video.mjs --variant
+sourced|full` selects between them; `sourced` is the default and still writes
+`out/<slug>.mp4`, so the button and `umtool build` are unchanged. The working
+files moved under `out/<variant>/` while `clips-raw` and `availability.json`
+stayed at the root. See
+[report-video.md](report-video.md#one-manifest-two-cuts).
+
**Run step 2 before step 5, always.** It is a few seconds and it catches the two
defects that have already shipped in real videos: a manifest with no `siteOrigin`
(19 QR codes encoding `undefined/?v=…`) and one pointing at `http://localhost:3000`
@@ -56,6 +67,7 @@ defects that have already shipped in real videos: a manifest with no `siteOrigin
| [browse.md](browse.md) | The project index, the four filters, cards per kind |
| [report-video.md](report-video.md) | The manifest as an EDL, the three-stage window model, `lock` |
| [clip-bench.md](clip-bench.md) | Editing a clip's window against the waveform and the cues |
+| [claim-bench.md](claim-bench.md) | Ruling on a `ledger[]` claim against ±90 s of context |
| [build.md](build.md) | The four-step chain, presets, cancellation, overwrite |
| [decisions.md](decisions.md) | What earns a severity, and how a kind contributes |
| [mix-from-a-project.md](mix-from-a-project.md) | Deep-linking a clip into `/mix` |
diff --git a/umtool/docs/claim-bench.md b/umtool/docs/claim-bench.md
@@ -0,0 +1,119 @@
+# The claim bench
+
+`/browse/<project>/claim/<id>`.
+
+The clip bench asks *"where exactly does this cut?"*. This one asks *"what did he
+mean by that?"* — and the two want opposite things. A clip wants sample-accurate
+edges; a claim wants **room**.
+
+It exists because a `ledger[]` entry used to carry an undocumented interpretation
+in its `company` field, and four different hazards were riding on it.
+
+## Why the quote is not enough
+
+The standing corpus rule: a first-person quote is routinely the host **reading
+someone else's words**, or being sarcastic, and neither is visible inside the
+quote. One claim in the employee-count corpus is a *guest's* payroll recorded as
+the subject's, and it reads identically until you listen either side of it.
+
+So the page opens on ±90 s of cues with the cited one marked **inside** the
+paragraph. That mark is the whole point: it shows the quote had someone else
+talking round it.
+
+`CLAIM_CONTEXT_PAD = 90`, overridable with `?pad=` up to 600.
+
+## The six fields
+
+| field | vocabulary | what it settles |
+|---|---|---|
+| `scope` | `media` `coffee` `publica` `all` | which payroll the number is about |
+| `scopeBasis` | free text, **required non-blank** | the phrase from the context that settles it |
+| `scopeConfidence` | `clear` `read` `unresolved` | did the quote settle it, the context, or nothing |
+| `population` | `employees` `full-time` `salaried` `contractor` `1099` `people` | the denominator |
+| `valueKind` | `uttered` `derived` `synthetic` | is the number his, our sum, or our midpoint |
+| `flags` | array, **always written** even when empty | anything a predicate cannot compute |
+
+Two of these are load-bearing in a way that is easy to miss:
+
+- **`scopeBasis` is refused when blank.** An adjudication with no basis is an
+ opinion, and a reviewer cannot check an opinion against the audio.
+- **`flags: []` is written, not omitted.** An absent array reads as "nobody has
+ looked", which is exactly the state an adjudication is supposed to leave behind.
+
+**`scopeConfidence: "unresolved"` is a legitimate outcome**, not a failure to
+finish. It feeds *neither* total. Forcing a reading on a genuinely ambiguous
+sentence is the failure mode this whole surface exists to prevent.
+
+## The seventh field, which is optional
+
+`roles` records **who** he named, when he enumerated them rather than counting
+them.
+
+```jsonc
+"roles": [{ "role": "video editor", "count": 2, "verbatim": "two video editors" }]
+```
+
+The bench edits it as one line per role — `count | role | his words` — because
+most claims have none and the handful that do are a two-line list; a repeater
+widget would be more chrome than the field it edits. The preview under the box
+shows what the rail will draw (`2 editors · 1 designer`), and a line that will not
+parse disables the save rather than writing half a roster.
+
+**`verbatim` is required and is the point of the field.** The count and the role
+name are our reading; without his own words beside them nobody can check the
+reading against the audio — the same rule `scopeBasis` exists for.
+
+**It is deliberately NOT one of the six.** Most claims are a number and nothing
+else, and gating the inbox on a field only a handful of entries can ever carry
+would leave it permanently red. `adjudicationGaps()` ignores it; `rolesGaps()`
+checks it when it is there, and a bad roster is a **400**.
+
+Why it is worth collecting at all: in the employee-count corpus the roster is the
+control. Five times he names who works for him and five times it is two video
+editors and a graphics designer; the totals he attaches are three, then four, then
+ten. The rail draws the roster under the tally precisely so it can be seen
+standing still while the numbers above it move.
+
+## The vocabularies are imported, never restated
+
+`SCOPES` / `SCOPE_CONFIDENCE` / `POPULATIONS` / `VALUE_KINDS` come from
+`report-to-video/ledger-totals`, into both the page and `updateClaim()`. A page
+offering a seventh population the arithmetic has never heard of is the silent
+divergence the shared module exists to stop. A value outside the vocabulary is a
+**400**, never a coercion.
+
+## Writing
+
+Same contract as the clip bench: `updateClaim()` re-reads inside a process-wide
+lock, writes tmp+rename, preserves the CLI's `JSON.stringify(m, null, 2)`
+formatting, and **guards on the manifest's own mtime**. A stale token is a `409`,
+never a silent overwrite — somebody may have run `resolve-windows --write` in
+between, and losing that is losing human judgement.
+
+It is deliberately *not* `updateClip()` with more fields. A clip edit moves a
+window; a claim edit records a ruling on what a sentence meant. Sharing a function
+would mean one of them could quietly write the other's fields.
+
+## Audio, when there is any
+
+A claim is a **moment**, not a window, so its playable candidates are the cached
+`out/clips-raw` files that *contain* its `cite`. Most claims have one already,
+because the build over-fetches around every clip.
+
+When none does, the page offers a fetch that runs the pipeline's own
+`--fetch-only`, which now accepts a **ledger id** as well as a timeline id: it
+synthesises a hair-wide entry around `cite` and lets `--pad` do the rest. The file
+lands in `clips-raw` under the build's own naming and a later build reuses it.
+
+That path needs `channel` / `video` / `cite` on the ledger entry. They were
+recoverable for the employee-count corpus from the markdown report's own links —
+but 20 of 50 needed the **Rumble site-id → URL-slug** remap first (see
+[report-video.md](report-video.md)).
+
+## Sign-off is "the inbox is empty"
+
+`claim-unadjudicated` is **blocking** and is emitted one row per entry. Work it to
+empty; see [decisions.md](decisions.md).
+
+Never auto-apply an adjudication. Every ruling carries its `scopeBasis` precisely
+so the next person can check it against the audio in one click.
diff --git a/umtool/docs/decisions.md b/umtool/docs/decisions.md
@@ -40,9 +40,26 @@ every future kind editing it to say a word only it uses.
| `clip-mid-sentence` | open | the cut lands inside a cue that does not close a sentence, and `lockEnd` is unset |
| `window-overlap` | open/info | two clips from one source overlap |
| `no-punctuation` | info | a source's ASR has no terminators — one row per project |
+| `claim-unadjudicated` | blocking | a `ledger[]` entry missing any of the six adjudication fields |
+| `claim-incoherent` | open | a fired coherence predicate, in plain words |
| `stale-build` | open | the output is older than the manifest |
| `unbuilt` | info | never built. A normal state, not a decision |
+`claim-unadjudicated` **earns** blocking: both the stated and the implied total
+lie if you act on an unadjudicated ledger, and they lie quietly, in a chart, with
+somebody's name on it. It is also the one kind that is deliberately **one row per
+entry** rather than collapsed — fifty unpunctuated sources are the same true
+thing said fifty times, but fifty unadjudicated claims are fifty *different*
+decisions, and the inbox being empty is the sign-off.
+
+`claim-incoherent` is `open` rather than blocking because the decision is
+**editorial**: is the flag right, and does it belong on screen? Neither answer
+stops a build.
+
+Both come from `ledger-totals.mjs` in `report-to-video`, which is pure arithmetic
+over JSON already parsed into memory — so the reducer stays a reducer. Audio is
+fetched only when a claim page is opened, one claim at a time.
+
**song** — the nine the existing reducer emits: `unjudged-variant`,
`missing-cut`, `no-recipe`, `stale-recipe`, `spec-problem`, `no-plan`,
`unattributed`, `thumb-unaccepted`, `loudness`.
@@ -70,6 +87,15 @@ how many projects it only checked the routing of.
`no-punctuation` row per *source* — forty-odd across six projects, all correct,
burying two blocking rows off the top of the list. One per project now.
+**A reducer that says the opposite of the card next to it.** The first
+adjudicated build shipped a rail tally reading "Spans everything **18**" directly
+beside a card reading "He never said eighteen". The adjudication had set
+`valueKind: "derived"` and nothing downstream was reading it: the tally still took
+the latest value of any kind, and the ledger's `label` still said "the only
+explicit sum in the corpus". A field that changes what something *means* has to be
+chased through every renderer that prints it, and the only way this surfaced was
+looking at a frame.
+
**Do not assume where a project reads its cues from.** The first run confidently
reported three sources of `quartering-flagging-takedowns` as having no cue file.
They cite deleted YouTube uploads, are cut from live Rumble mirrors, and build
diff --git a/umtool/docs/quirks.md b/umtool/docs/quirks.md
@@ -85,6 +85,19 @@ nothing on anyone's phone. `umtool check` exists largely for this.
several archived channels, and cue files are keyed by channel, so one manifest-wide
slug cannot find them all.
+**Building the Rumble site-id -> directory-slug map takes three seconds.** Every
+`transcript.cues.json` carries its site id in the first ~400 bytes, so you never
+have to parse the whole file — read the head of each and index it. On 7,925
+Rumble videos that is 3.6 s, and it is what turns a report's share links (which
+carry the *site* id) into manifest `video` fields (which must be the *slug*):
+
+```py
+for d in os.listdir("."): # transcripts/channels/<chan>/data
+ head = open(f"{d}/transcript.cues.json", "rb").read(400).decode("utf8", "replace")
+ m = re.search(r'"id"\s*:\s*"([^"]+)"', head)
+ if m: out[m.group(1)] = d # site id -> directory slug
+```
+
**Rumble ids: the manifest's `video` must be the local directory slug.** Cue files
live under the URL slug, not the MCP video id. A manifest using the id finds
nothing — and finds it twenty minutes into a build, unless `umtool check` ran first.
@@ -108,11 +121,96 @@ broken or audio-desynced file.
**A QR must be fully opaque and must keep its quiet zone.** A translucent QR will
not scan, and the white border is part of the symbol, not decoration.
+**A long URL cannot be a small QR, and the failure is silent.** The
+employee-count share link carries 23 channel filters and is ~1.4 k characters:
+that is a version-40 symbol, **177 modules**, which inside a 132 px tile is
+0.7 px per module. It renders, it looks like a QR, and nothing on Earth scans it.
+Keep a short `provenance.qrLink` for card tiles (~200 chars → 63 modules → 2.1 px
+per module, which `zbarimg` reads off the raster) and **scan a rendered frame**
+rather than trusting that a code appeared.
+
+**Resize a QR with `-filter point`.** Any resampling filter blurs the module
+edges, and a blurred QR stops scanning. The geometry has to be decided before the
+code is generated, because the tile it sits in is sized in the rail's arithmetic.
+
+**An opaque curtain parks OUTSIDE the window it covers, which may be on top of
+something.** The rail's log curtain ends one window-height below the log — which
+was empty ground until the QR tile moved into the rail's foot. The fix is
+ordering, not geometry: the tile overlays after the curtain.
+
**ffmetadata is line-based and `=`, `;`, `#`, `\` are structural.** A chapter title
carrying any of them has to be escaped or the file silently mis-parses.
+## Chrome rendered in a browser (`chromeEngine: "hyperframes"`)
+
+**A bare `local()` `@font-face` silently falls back in the render browser.** It
+resolves fine on a desktop, so a snapshot looks right and every metric in the
+rendered band is wrong. Copy the real `.ttf` in beside the composition and
+`url()` it.
+
+**Naming a real family anywhere in the fallback stack makes the compiler go and
+FETCH it from Google Fonts.** That is a network dependency at render time *and* a
+different cut of the face from the local file the ffmpeg cards use — so the two
+halves of the same frame disagree about metrics. Give the embedded face a private
+family name (`'Band'`) and let the stack fall through to `sans-serif`.
+
+**Animate the reveal with ONE clip-path, not a `stroke-dashoffset` per path.** The
+obvious build offsets each series' dash. It looks right for the strokes and wrong
+for everything else: a filled region (a gap band between two series) has no stroke
+to offset, so it appears whole the instant it fades in and the chart shows an
+answer the playhead has not reached.
+
+**PNG regions overlay BEFORE the rail chain, not after.** The rail chain ends in
+`format=yuv420p`, and overlaying an alpha sequence onto yuv420p is the same
+alpha-subsampling trap the rail already documents, one layer later. `format=yuv444`
+and `shortest=1` on every overlay; one `format=yuv420p` at the very end.
+
+**A finite image sequence needs no `-t`.** Unlike `-loop 1` it ends by itself, so
+the deadlock five chained loops hit cannot happen — but `shortest=1` is still
+required, because a secondary longer than the main extends the output.
+
+**Reserving the band's height is not the same as `render.footerHeight`.** Three
+renderers were reading the manifest's 100 while the band owned 200, so the closing
+chart drew its footnotes underneath it and the ledger scroll cropped 100 px short.
+One `reservedFooterHeight()` helper now serves all of them — and `railGeometry`
+was the fourth, found later: the rail column ran 100 px past the band's own top
+edge, about two rows of its log window.
+
+**Cost, measured.** 1500×200 alpha, 30 fps: ~30 ms/frame, ~32 KB/frame. The full
+374 s band is 11,460 frames, 348 s of wall clock and 372 MB, on a box already
+running something else.
+
+## Rail strips and rolling counters
+
+**A slab that slides moves text that did not change.** The tally used to be four
+rows walked by one `crop`, so a coffee figure changing dragged "The Quartering"
+up the screen with it. Static parts (swatch, company label) belong in the chrome;
+only the cell that changes may move.
+
+**A crop can only walk, so the DIRECTION of a roll is a property of the strip's
+layout.** Lay the pair as `[old, new]` and the window walks down (content moves
+up, a rise); lay it as `[new, old]` and it walks up (a fall). There is no
+"direction" term in the ramp at all.
+
+**Two rows of a rolling strip must hold identical content wherever the crop
+steps.** Between transitions the window repositions instantly to the next pair's
+first row; that step is invisible only because the row it leaves and the row it
+arrives at are the same picture. It also means a delta chip has to ride on both
+rows — blanking it at the step is what makes an invisible reposition visible.
+
+**A manifest value beats a default, which is obvious and still cost twenty
+minutes.** Changing `rowHeight`'s default in `railGeometry` changed nothing,
+because the manifest set it explicitly. Print the geometry rather than reasoning
+about which value won.
+
## The tool itself
+**`ffmpeg` inside a `while read` loop eats the loop's stdin** and the loop stops
+early, silently, having "passed". Use `ffmpeg -nostdin`. (`ffprobe` has no such
+flag and errors if given one.)
+
+
+
**`node -e console.log(<number>)` emits ANSI escapes on a TTY**, which corrupt
ffmpeg filtergraphs and shell tests silently.
diff --git a/umtool/docs/report-video.md b/umtool/docs/report-video.md
@@ -155,6 +155,96 @@ preference: cut the same moment from a live mirror (via a `CHANNELS_DIR` shadow
directory — but check the archive's alignment statement first, because mirrors are
not assumed to share a clock), or convert the clip to a quote card.
+## The ledger, and why it must be adjudicated
+
+`ledger[]` is the claim set the rail and the closing cards read. Beside the
+descriptive fields (`date`, `value`, `display`, `label`, `quote`, `src`,
+`entryId`) every entry carries **six adjudication fields** — `scope`,
+`scopeBasis`, `scopeConfidence`, `population`, `valueKind`, `flags` — plus the
+`channel` / `video` / `cite` triple that lets a claim page fetch its own audio.
+
+They exist because `company` was an **undocumented interpretation** and four
+hazards were riding on it:
+
+| hazard | what it looked like |
+|---|---|
+| scope ambiguity | *"I have 10 employees, my coffee company employees…"* — all-companies or coffee-only, depending on where the comma falls |
+| derived, not stated | *"10 at coffee brand coffee, I've got eight staff for the live stream"* recorded as **18**, a figure nobody utters |
+| population drift | "full-time salaried", "employees" and "all basically contractors" compared as one series |
+| synthetic values | 10.5 for *"about 10 people, 11 people"* — a midpoint we invented and attributed to him |
+
+**The rule that follows:** a *stated* total may contain only **a figure he utters
+as a single number for a named scope**. Sums and midpoints are ours and belong to
+the *implied* series, which says so on screen.
+
+`scripts/report-to-video/ledger-totals.mjs` is the single implementation of that
+arithmetic — the umtool reducer, the chart band and the closing card all import
+it, so they cannot disagree. It **refuses to run on an unadjudicated ledger**
+(`strict: true`); the inbox passes `strict: false` so it can show coherence rows
+while the rest is still being worked.
+
+**Six** named predicates compute incoherence rather than asserting it:
+`contradicts_component`, `same_day_conflict`, `self_negating`,
+`population_mismatch`, `not_his_number`, `status_flip`. Deliberately **not** a
+predicate: a large rise or fall between claims — fluctuation is usually the
+*subject*, and flagging it would be putting a thumb on the scale.
+
+A seventh **optional** field, `roles`, records who he named when he enumerated
+rather than counted. It is not part of the gate — see [claim-bench.md](claim-bench.md).
+
+Rule on them at [`/browse/<project>/claim/<id>`](claim-bench.md).
+
+## One manifest, two cuts
+
+`timeline` entries may carry `"variant": "full"`, and cards may carry
+`variants: { sourced: { … } }` field overrides. `selectVariant()` in
+`build-video.mjs` applies both once, immediately after the manifest is read, and
+then filters `ledger` to the claims whose `entryId` survived.
+
+What umtool has to know about it:
+
+- **`out/<slug>.mp4` is still the `sourced` deliverable**, which is why
+ `buildStateOf()` keeps working unchanged. `full` writes `out/<slug>-full.mp4`
+ beside it, and registering that as a second output is a follow-up.
+- **`out/clips-raw` and `out/availability.json` stay at the root** and are shared;
+ `cards/`, `segments/`, `qr/`, `chrome/` and `schedule.json` moved under
+ `out/<variant>/`. Anything reading a raw window (the clip bench, the claim
+ bench) is unaffected; anything reading a segment needs a variant.
+- **The driver passes `--out <project>/out`**, which is the ROOT, so it is correct
+ as written and builds the default `sourced` cut.
+- A **`ledger`** timeline entry is a new type, and the vocabulary is open, so
+ `clipsOf`/`cardsOf`/the "other entries" bucket already handle it. A window
+ cannot be written to one — `updateClip()` refuses any non-clip.
+
+## Deleted sources are a probe result, not an annotation
+
+`check-availability.mjs` probes every **ledger** source as well as every clip
+source, and `out/availability.json` records the verdict with the date it ran. The
+`full` cut prints that verdict on screen (`source deleted` / `source unreachable`
+/ `not clipped`), so a hand-written "source since removed" in a card kicker is now
+a bug: several unclipped claims come from videos the cut clips elsewhere, i.e.
+demonstrably live.
+
+**Severity follows the CLIPS, not the source.** Widening the probe to ledger
+sources immediately put six permanent `blocking` rows in the inbox reading
+*"0 clip(s) cite it; the build dies here"* — a sentence that refutes itself. A
+dead source that no clip cites is exactly the case the `full` cut quotes on a
+card **because** it is gone, so it is `info`: named, dated, and not in anybody's
+way. It becomes blocking the moment a clip cites it.
+
+## A field that changes meaning has to be chased through every renderer
+
+`valueKind: "derived"` was set on the 18, and the first build still shipped a rail
+tally reading **"Spans everything 18"** directly beside a card reading *"He never
+said eighteen"*. Three separate readers were still taking the old meaning: the
+tally took the latest value of any kind, the ledger's own `label` still said "the
+only explicit sum in the corpus", and the closing chart plotted off a legacy
+`plotted` flag.
+
+Nothing caught it. Not the tests, not `verify-build`, not `umtool check` — every
+one of them was true about a video that contradicted itself on screen. **Looking
+at a frame** was the only thing that found it.
+
## Discovered by getting it wrong once
- **A build that loses a clip is worse than a build that fails.** `--continue-on-error`
diff --git a/umtool/e2e/claim-bench.spec.ts b/umtool/e2e/claim-bench.spec.ts
@@ -0,0 +1,182 @@
+import { test, expect } from "@playwright/test";
+import { readFileSync } from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+// ---------------------------------------------------------------------------
+// The claim bench, and the two decision kinds that drive it.
+//
+// The fixture ledger is built so every answer here is exact:
+//
+// k01 is fully adjudicated and pinned to c01 — the settled case.
+// k02 is adjudicated but `valueKind: "derived"`, so `not_his_number` must
+// fire on it and it must stay OUT of the stated series.
+// k03 carries none of the six fields, so it is the one blocking row.
+//
+// Every test in this file writes, so it uses bench-fixture rather than
+// report-fixture — the same split the clip bench already keeps.
+// ---------------------------------------------------------------------------
+
+const HERE = path.dirname(fileURLToPath(import.meta.url));
+const UMTOOL = path.join(HERE, "..");
+const FIXTURE = path.join(UMTOOL, ".e2e-song");
+const PROJECT = "reports/bench-fixture";
+const MANIFEST = path.join(FIXTURE, "reports", "bench-fixture", "video.manifest.json");
+const claimPage = (id: string) => `/browse/${PROJECT}/claim/${id}`;
+
+type Claim = {
+ id: string;
+ scope?: string;
+ scopeBasis?: string;
+ scopeConfidence?: string;
+ population?: string;
+ valueKind?: string;
+ flags?: string[];
+ roles?: Array<{ role: string; count: number; verbatim: string }>;
+};
+
+const readClaim = (id: string): Claim => {
+ const m = JSON.parse(readFileSync(MANIFEST, "utf8")) as { ledger: Claim[] };
+ return m.ledger.find((c) => c.id === id)!;
+};
+
+const tokenFor = async (
+ request: { get: (u: string) => Promise<{ json: () => Promise<unknown> }> },
+ claim: string,
+) => {
+ const r = await request.get(`/api/report/claim?project=${encodeURIComponent(PROJECT)}&claim=${claim}`);
+ return (await r.json()) as { token: string; gaps: string[]; cues: unknown[]; at: number };
+};
+
+test("an unadjudicated claim is one BLOCKING row, naming the fields it lacks", async ({ page }) => {
+ await page.goto("/browse/decisions");
+ const row = page.getByText(/no ruling on/).first();
+ await expect(row).toBeVisible();
+ // All six, because nobody has touched k03.
+ await expect(row).toContainText("scope");
+ await expect(row).toContainText("population");
+ await expect(row).toContainText("valueKind");
+});
+
+test("the claim page opens on the quote inside its context, not on the quote alone", async ({ page }) => {
+ await page.goto(claimPage("k01"));
+ await expect(page.locator("[data-claim='k01']")).toBeVisible();
+ const ctx = page.getByTestId("claim-context");
+ await expect(ctx).toBeVisible();
+ // The cited cue is marked INSIDE the paragraph — that mark is the whole
+ // point, because it is what shows the quote had someone else talking round it.
+ await expect(ctx.locator("[data-cited='1']").first()).toBeVisible();
+});
+
+test("an adjudicated claim shows its ruling, an unadjudicated one shows the gap", async ({ page }) => {
+ await page.goto(claimPage("k01"));
+ await expect(page.getByText("adjudicated", { exact: true })).toBeVisible();
+ await expect(page.getByTestId("scope-media")).toHaveAttribute("aria-pressed", "true");
+ await expect(page.getByTestId("valuekind-uttered")).toHaveAttribute("aria-pressed", "true");
+
+ await page.goto(claimPage("k03"));
+ await expect(page.getByText(/unadjudicated — missing/)).toBeVisible();
+ await expect(page.getByTestId("scope-media")).toHaveAttribute("aria-pressed", "false");
+});
+
+test("a ruling round-trips to the ledger", async ({ page }) => {
+ await page.goto(claimPage("k03"));
+ await page.getByTestId("scope-coffee").click();
+ await page.getByTestId("confidence-read").click();
+ await page.getByTestId("population-contractor").click();
+ await page.getByTestId("valuekind-synthetic").click();
+ await page.getByTestId("claim-basis").fill("the phrase that settles it");
+ await page.getByTestId("claim-flags").fill("a hand-written flag");
+ await page.getByTestId("claim-save").click();
+
+ await expect(page.getByTestId("claim-note")).toContainText("saved");
+
+ const c = readClaim("k03");
+ expect(c.scope).toBe("coffee");
+ expect(c.scopeConfidence).toBe("read");
+ expect(c.population).toBe("contractor");
+ expect(c.valueKind).toBe("synthetic");
+ expect(c.scopeBasis).toBe("the phrase that settles it");
+ expect(c.flags).toEqual(["a hand-written flag"]);
+});
+
+test("a blank scopeBasis is refused — an adjudication with no basis is an opinion", async ({ request }) => {
+ const { token } = await tokenFor(request, "k01");
+ const r = await request.put("/api/report/claim", {
+ data: { project: PROJECT, claim: "k01", token, scopeBasis: " " },
+ });
+ expect(r.status()).toBe(400);
+ expect(await r.text()).toContain("scopeBasis");
+});
+
+test("a value outside the vocabulary is refused, not coerced", async ({ request }) => {
+ const { token } = await tokenFor(request, "k01");
+ const r = await request.put("/api/report/claim", {
+ data: { project: PROJECT, claim: "k01", token, population: "staff" },
+ });
+ expect(r.status()).toBe(400);
+ expect(await r.text()).toContain("population");
+ // …and the stored ruling is untouched.
+ expect(readClaim("k01").population).toBe("employees");
+});
+
+test("a stale token is a 409, never a silent overwrite", async ({ request }) => {
+ const r = await request.put("/api/report/claim", {
+ data: { project: PROJECT, claim: "k01", token: "0", scope: "coffee" },
+ });
+ expect(r.status()).toBe(409);
+ expect(readClaim("k01").scope).toBe("media");
+});
+
+test("a fired predicate is an OPEN row, in plain words", async ({ page }) => {
+ await page.goto("/browse/decisions");
+ // k02 is `derived`, so the sum is ours and the inbox has to say so.
+ await expect(page.getByText(/our sum, not his figure/)).toBeVisible();
+});
+
+test("the context read is ±90s around the cite, not the clip's window", async ({ request }) => {
+ const d = await tokenFor(request, "k01");
+ expect(d.at).toBe(3);
+ expect(Array.isArray(d.cues)).toBe(true);
+ expect(d.gaps).toEqual([]);
+});
+
+// ---------------------------------------------------------------------------
+// The roster — the seventh field, and the only optional one.
+// ---------------------------------------------------------------------------
+
+test("a roster round-trips, and is not part of the gate", async ({ page }) => {
+ await page.goto(claimPage("k01"));
+ await page.getByTestId("claim-roles").fill(
+ "2 | video editor | two video editors\n1 | graphics designer | a graphics designer",
+ );
+ // The preview is what the rail will draw, so it is worth seeing before saving.
+ await expect(page.getByText("reads as “2 editors · 1 designer”")).toBeVisible();
+ await page.getByTestId("claim-save").click();
+ await expect(page.getByTestId("claim-note")).toContainText("saved");
+
+ expect(readClaim("k01").roles).toEqual([
+ { count: 2, role: "video editor", verbatim: "two video editors" },
+ { count: 1, role: "graphics designer", verbatim: "a graphics designer" },
+ ]);
+ // …and the claim was already adjudicated without one, which is the point:
+ // `roles` is not one of the six.
+ await expect(page.getByText("adjudicated", { exact: true })).toBeVisible();
+});
+
+test("a roster line with no verbatim cannot be saved", async ({ page }) => {
+ await page.goto(claimPage("k02"));
+ await page.getByTestId("claim-roles").fill("2 | video editor");
+ await expect(page.getByText(/needs count \| role \| his words/)).toBeVisible();
+ await expect(page.getByTestId("claim-save")).toBeDisabled();
+});
+
+test("a malformed roster is refused by the API too, not just by the page", async ({ request }) => {
+ const { token } = await tokenFor(request, "k02");
+ const r = await request.put("/api/report/claim", {
+ data: { project: PROJECT, claim: "k02", token, roles: [{ role: "", count: 2, verbatim: "x" }] },
+ });
+ expect(r.status()).toBe(400);
+ expect(await r.text()).toContain("roles");
+ expect(readClaim("k02").roles).toBeUndefined();
+});
diff --git a/umtool/e2e/fixtures/make-fixture.mjs b/umtool/e2e/fixtures/make-fixture.mjs
@@ -691,7 +691,7 @@ const FONT_CANDIDATES = [
];
const FONT = FONT_CANDIDATES.find((f) => existsSync(f)) ?? null;
-const manifest = (slug, title, provenance, timeline) => ({
+const manifest = (slug, title, provenance, timeline, ledger = null) => ({
schemaVersion: 1,
slug,
title,
@@ -719,6 +719,7 @@ const manifest = (slug, title, provenance, timeline) => ({
},
timelineNodes: [],
timeline,
+ ...(ledger ? { ledger } : {}),
});
const writeProject = (rel, doc, root = reports) => {
@@ -800,6 +801,29 @@ const BENCH = writeProject(
{ type: "clip", id: "c02", video: "vid1", start: 9.0, end: 12.0, cite: 9, section: 0, quote: "another whole sentence" },
{ type: "clip", id: "c03", video: "vid2", start: 1.0, end: 4.0, cite: 1, section: 0, quote: "no punctuation" },
{ type: "clip", id: "c04", video: "vid1", start: 15.0, end: 18.0, cite: 15, section: 0, lockEnd: true, quote: "trailing off" },
+ ],
+ // A three-row ledger, so the claim bench and the two claim decision kinds
+ // have something exact to assert against:
+ //
+ // k01 is fully adjudicated and pinned to a clip -- the settled case.
+ // k02 is adjudicated but DERIVED, so `not_his_number` must fire on it and
+ // it must stay out of the stated series.
+ // k03 carries none of the six fields, so it is the blocking row the inbox
+ // has to show and the page has to offer controls for.
+ [
+ { id: "k01", date: "2022-01-01", company: "media", value: 4, display: "4",
+ label: "counts four", quote: "and because", src: "vid1 @ 0:03",
+ channel: "testchan", video: "vid1", cite: 3, entryId: "c01",
+ scope: "media", scopeBasis: "names the channel", scopeConfidence: "clear",
+ population: "employees", valueKind: "uttered", flags: [] },
+ { id: "k02", date: "2022-02-01", company: "all", value: 9, display: "9",
+ label: "4 + 5, summed by us", quote: "another whole sentence", src: "vid1 @ 0:09",
+ channel: "testchan", video: "vid1", cite: 9, entryId: "c02",
+ scope: "all", scopeBasis: "no company named", scopeConfidence: "read",
+ population: "employees", valueKind: "derived", flags: [] },
+ { id: "k03", date: "2022-03-01", company: "media", value: 5, display: "5",
+ label: "nobody has ruled on this one", quote: "no punctuation", src: "vid2 @ 0:01",
+ channel: "testchan", video: "vid2", cite: 1 },
]),
);
diff --git a/umtool/lib/projects/kinds.mjs b/umtool/lib/projects/kinds.mjs
@@ -67,8 +67,10 @@ export const PROJECT_KINDS = [
summarise: summariseReport,
signature: reportSignature,
decisions: reportDecisions,
- // Views reachable under the project's own URL: /browse/<project>/clip/<id>.
- views: ["clip"],
+ // Views reachable under the project's own URL:
+ // /browse/<project>/clip/<id> the window bench
+ // /browse/<project>/claim/<id> the adjudication bench
+ views: ["clip", "claim"],
},
{
id: "song",
diff --git a/umtool/lib/projects/report.mjs b/umtool/lib/projects/report.mjs
@@ -7,7 +7,22 @@
// and neither parses JSON.
import { readdir, readFile, stat } from "node:fs/promises";
import path from "node:path";
-import { cachedWindowsFor, findContainingWindow } from "report-to-video/build-video";
+import { DEFAULT_VARIANT, cachedWindowsFor, findContainingWindow } from "report-to-video/build-video";
+
+/**
+ * Where a build's per-entry segments live.
+ *
+ * They moved under `out/<variant>/` when the pipeline learned to cut two
+ * versions of the same manifest. `out/segments` is still checked, because every
+ * other report on disk was built before that and its files are still there —
+ * and a poster tile silently going blank is exactly the kind of regression
+ * nothing would have reported.
+ */
+const segmentDirs = (outDir) => [
+ path.join(outDir, DEFAULT_VARIANT, "segments"),
+ path.join(outDir, "segments"),
+];
+import { adjudicationGaps, ledgerTotals, unadjudicatedOf } from "report-to-video/ledger-totals";
import { widen } from "report-to-video/resolve-windows";
// widen() and the cache's window naming are IMPORTED, never reimplemented. The
@@ -157,11 +172,19 @@ export async function buildStateOf(dir, manifest) {
let segCount = 0;
let firstSeg = null;
let firstRaw = null;
+ let segRel = path.posix.join("out", "segments");
if (!fin) {
- const [raws, segs] = await Promise.all([
+ const dirs = segmentDirs(outDir);
+ const [raws, ...segLists] = await Promise.all([
readdir(path.join(outDir, "clips-raw")).catch(() => []),
- readdir(path.join(outDir, "segments")).catch(() => []),
+ ...dirs.map((d) => readdir(d).catch(() => [])),
]);
+ const which = segLists.findIndex((l) => l.length);
+ const segs = which < 0 ? [] : segLists[which];
+ segRel =
+ which === 0
+ ? path.posix.join("out", DEFAULT_VARIANT, "segments")
+ : path.posix.join("out", "segments");
const rawMp4 = raws.filter((n) => n.endsWith(".mp4")).sort();
const segMp4 = segs.filter((n) => n.endsWith(".mp4")).sort();
rawCount = rawMp4.length;
@@ -184,6 +207,7 @@ export async function buildStateOf(dir, manifest) {
segCount,
firstSeg,
firstRaw,
+ segRel,
};
}
@@ -271,7 +295,7 @@ export async function summariseReport(ctx) {
posterRel: build.built
? path.posix.join("out", `${build.slug}.mp4`)
: build.firstSeg
- ? path.posix.join("out", "segments", build.firstSeg)
+ ? path.posix.join(build.segRel, build.firstSeg)
: build.firstRaw
? path.posix.join("out", "clips-raw", build.firstRaw)
: null,
@@ -314,6 +338,14 @@ export const REPORT_DECISION_KINDS = [
"clip-mid-sentence",
"window-overlap",
"no-punctuation",
+ // A ledger entry nobody has ruled on. BLOCKING, which is earned here: both
+ // the stated and the implied total lie if you act on an unadjudicated ledger,
+ // and they lie quietly, in a chart, with his name on it.
+ "claim-unadjudicated",
+ // A fired coherence predicate. `open`, not blocking, because the decision is
+ // EDITORIAL rather than mechanical: is the flag right, and does it belong on
+ // screen? Neither answer stops a build.
+ "claim-incoherent",
"stale-build",
"unbuilt",
];
@@ -404,11 +436,24 @@ export async function reportDecisions(ctx, summary) {
if (avail) {
for (const src of avail.sources ?? []) {
if (src.ok) continue;
+ // SEVERITY FOLLOWS THE CLIPS, not the source.
+ //
+ // The probe covers ledger sources as well as clip sources now, and most
+ // dead ledger sources are cited by no clip at all — the cut quotes them on
+ // a card precisely BECAUSE they are gone. Calling those blocking made the
+ // inbox say "0 clip(s) cite it; the build dies here", which refutes itself
+ // in its own sentence, and put six permanent red rows in front of a
+ // manifest that builds cleanly.
+ const cited = src.clips?.length ?? 0;
+ const claimed = src.claims?.length ?? 0;
add(
src.state === "no-cues" ? "clip-no-cues" : "clip-unfetchable",
src.key,
- `${src.state} — ${src.clips?.length ?? 0} clip(s) cite it; the build dies here`,
- "blocking",
+ cited
+ ? `${src.state} — ${cited} clip(s) cite it; the build dies here`
+ : `${src.state} — no clip cites it${claimed ? `, ${claimed} ledger claim(s) do` : ""}. ` +
+ "A cut that quotes it on a card is unaffected; one that adds a clip from it will not build",
+ cited ? "blocking" : "info",
);
}
}
@@ -498,6 +543,51 @@ export async function reportDecisions(ctx, summary) {
);
}
+ // --- the ledger ----------------------------------------------------------
+ // The same rule the availability read follows: this reducer NEVER measures.
+ // ledgerTotals() is pure arithmetic over JSON already parsed into memory, so
+ // the inbox opens in the time it did before -- audio is fetched only when a
+ // claim page is opened, one claim at a time.
+ const ledger = Array.isArray(m.ledger) ? m.ledger : [];
+ if (ledger.length) {
+ const claimHref = (cid) => `/browse/${id}/claim/${cid}`;
+
+ // One row per entry, not one collapsed row. Forty unpunctuated sources are
+ // the same true thing said forty times; forty unadjudicated claims are
+ // forty DIFFERENT decisions, and the inbox being empty is the sign-off.
+ for (const e of unadjudicatedOf(ledger)) {
+ const gaps = adjudicationGaps(e);
+ add(
+ "claim-unadjudicated",
+ e.id,
+ `${e.date ?? "undated"} · ${e.display ?? e.value ?? "—"} — no ruling on ` +
+ `${gaps.join(", ")}. Settle it against ±90s of context, not the quote`,
+ "blocking",
+ { href: claimHref(e.id) },
+ );
+ }
+
+ // Coherence over whatever HAS been ruled on, so these appear as the work
+ // proceeds rather than all at once at the end.
+ let totals = null;
+ try {
+ totals = ledgerTotals(ledger, { strict: false });
+ } catch {
+ /* a malformed ledger is already reported as unadjudicated rows */
+ }
+ for (const step of totals?.steps ?? []) {
+ for (const f of step.flags) {
+ add(
+ "claim-incoherent",
+ step.id,
+ `${f.rule.replace(/_/g, " ")} — ${f.text}`,
+ "open",
+ { href: claimHref(step.id) },
+ );
+ }
+ }
+ }
+
// --- the build -----------------------------------------------------------
if (s.build.stale) {
add(
@@ -528,9 +618,14 @@ export async function readClipDetail(dir, { manifest = null } = {}) {
const channelsDir = channelsDirFor(dir, m, { shadowExists });
const build = await buildStateOf(dir, m);
const rawDir = path.join(dir, "out", "clips-raw");
- const segDir = path.join(dir, "out", "segments");
-
- const segNames = new Set(await readdir(segDir).catch(() => []));
+ const segDirs = segmentDirs(path.join(dir, "out"));
+ const segLists = await Promise.all(segDirs.map((d) => readdir(d).catch(() => [])));
+ const segWhich = segLists.findIndex((l) => l.length);
+ const segNames = new Set(segWhich < 0 ? [] : segLists[segWhich]);
+ const segRel =
+ segWhich === 0
+ ? path.posix.join("out", DEFAULT_VARIANT, "segments")
+ : path.posix.join("out", "segments");
const cueCache = new Map();
const entries = [];
@@ -607,7 +702,7 @@ export async function readClipDetail(dir, { manifest = null } = {}) {
proposed,
cached: cached ? { name: cached.name, from: cached.from, to: cached.to } : null,
widest: widest ? { name: widest.name, from: widest.from, to: widest.to } : null,
- segment: segNames.has(`${e.id}.mp4`) ? path.posix.join("out", "segments", `${e.id}.mp4`) : null,
+ segment: segNames.has(`${e.id}.mp4`) ? path.posix.join(segRel, `${e.id}.mp4`) : null,
wantFrom: from,
wantTo: to,
});
@@ -638,3 +733,68 @@ export async function cuesInWindow(dir, clipId, from, to) {
})),
};
}
+
+// ---------------------------------------------------------------------------
+// One CLAIM, ready to adjudicate.
+//
+// The claim bench asks a different question from the clip bench. A clip asks
+// "where exactly does this cut?", so it wants sample-accurate edges. A claim
+// asks "what did he mean by that?", so it wants ROOM -- the standing rule is
+// that a first-person quote is routinely the host reading someone else's words
+// or being sarcastic, and neither is visible inside the quote itself. Hence a
+// default of +/-90s, and hence context is returned as cues rather than as a
+// waveform: the words on either side are what settle a scope.
+// ---------------------------------------------------------------------------
+
+export const CLAIM_CONTEXT_PAD = 90;
+
+export async function readClaimDetail(dir, claimId, { manifest = null, pad = CLAIM_CONTEXT_PAD } = {}) {
+ const m = manifest ?? (await readManifest(dir));
+ if (!m) return null;
+ const claim = (m.ledger ?? []).find((e) => e.id === claimId);
+ if (!claim) return null;
+
+ const shadowExists = await hasShadowChannels(dir);
+ const channelsDir = channelsDirFor(dir, m, { shadowExists });
+ const file = claim.video ? cuePathFor(m, claim, channelsDir) : null;
+ const doc = file ? await readCues(file) : null;
+
+ const at = Number.isFinite(Number(claim.cite)) ? Number(claim.cite) : null;
+ const from = at == null ? 0 : Math.max(0, at - pad);
+ const to = at == null ? 0 : at + pad;
+
+ // Which cached raw windows already cover this moment. A claim that lands
+ // inside one a clip fetched earlier is playable with no download at all,
+ // which is most of them once a build has run.
+ const rawDir = path.join(dir, "out", "clips-raw");
+ const windows = claim.video
+ ? (await cachedWindowsFor(rawDir, claim.video)).filter(
+ (w) => at != null && w.from <= at && w.to >= at,
+ )
+ : [];
+ windows.sort((a, b) => b.to - b.from - (a.to - a.from));
+
+ return {
+ claim,
+ at,
+ view: { from, to },
+ pad,
+ // Every cue in the window, with the cited one marked. The mark is what
+ // makes "he is quoting a tweet here" visible: the quote sits in a paragraph
+ // rather than alone.
+ cues: (doc?.cues ?? [])
+ .filter((c) => c.end >= from && c.start <= to)
+ .map((c) => ({
+ start: c.start,
+ end: c.end,
+ text: c.text,
+ cited: at != null && c.start <= at && c.end >= at,
+ })),
+ source: doc
+ ? { title: doc.title ?? null, uploadDate: doc.uploadDate ?? null, duration: doc.duration ?? null, webpageUrl: doc.webpageUrl ?? null }
+ : null,
+ noCues: !doc,
+ windows: windows.map((w) => ({ name: w.name, from: w.from, to: w.to })),
+ gaps: adjudicationGaps(claim),
+ };
+}
diff --git a/umtool/lib/report/manifest.mjs b/umtool/lib/report/manifest.mjs
@@ -22,6 +22,13 @@
// between, and losing that is losing human judgement.
import { copyFile, readFile, rename, stat, writeFile } from "node:fs/promises";
import path from "node:path";
+import {
+ POPULATIONS,
+ SCOPES,
+ SCOPE_CONFIDENCE,
+ VALUE_KINDS,
+ rolesGaps,
+} from "report-to-video/ledger-totals";
// Its own write queue, not lib/state.ts's.
//
@@ -162,3 +169,102 @@ export async function updateClip(dir, clipId, patch, { token = null } = {}) {
return { entry, before, token: nextToken };
});
}
+
+
+// ---------------------------------------------------------------------------
+// Writing an ADJUDICATION back into a ledger entry.
+//
+// Separate from updateClip() on purpose. A clip edit moves a window; a claim
+// edit records a RULING on what a sentence meant, and the two have nothing in
+// common but the file they land in. Sharing a function would mean one of them
+// could quietly write the other's fields.
+//
+// Every value is checked against the vocabulary ledger-totals.mjs publishes,
+// imported rather than restated -- a page offering a seventh population that
+// the arithmetic has never heard of is exactly the silent divergence this whole
+// module exists to prevent.
+// ---------------------------------------------------------------------------
+
+const CLAIM_ENUMS = {
+ scope: SCOPES,
+ scopeConfidence: SCOPE_CONFIDENCE,
+ population: POPULATIONS,
+ valueKind: VALUE_KINDS,
+};
+
+/**
+ * Patch ONE ledger claim's six adjudication fields.
+ *
+ * @param {string} dir
+ * @param {string} claimId
+ * @param {Record<string, unknown>} patch
+ * @param {{ token?: string | null }} [opts]
+ */
+export async function updateClaim(dir, claimId, patch, { token = null } = {}) {
+ return withManifestLock(async () => {
+ const current = await manifestToken(dir);
+ if (token !== null && current !== token) throw new StaleToken(token, current);
+
+ const raw = await readFile(manifestFile(dir), "utf8");
+ const manifest = JSON.parse(raw);
+ const entry = (manifest.ledger ?? []).find((e) => e.id === claimId);
+ if (!entry) throw new Error(`no ledger claim with id ${claimId}`);
+
+ for (const [field, allowed] of Object.entries(CLAIM_ENUMS)) {
+ if (patch[field] === undefined) continue;
+ const v = String(patch[field]);
+ if (!allowed.includes(v)) {
+ throw new Error(`${field} must be one of ${allowed.join(", ")} (got \`${v}\`)`);
+ }
+ entry[field] = v;
+ }
+
+ if (patch.scopeBasis !== undefined) {
+ // The phrase from the quote that settles it. Refused when blank: an
+ // adjudication with no basis is an opinion, and a reviewer cannot check
+ // an opinion against the audio.
+ const v = String(patch.scopeBasis ?? "").trim();
+ if (!v) throw new Error("scopeBasis must quote the phrase that settles the scope");
+ entry.scopeBasis = v;
+ }
+
+ if (patch.flags !== undefined) {
+ if (!Array.isArray(patch.flags)) throw new Error("flags must be an array");
+ const list = patch.flags.map((f) => String(f).trim()).filter(Boolean);
+ // Always WRITTEN, even empty. `flags` is one of the six fields the gate
+ // checks, so an absent array reads as "nobody has looked" -- which is
+ // exactly the state an adjudication is supposed to leave behind.
+ entry.flags = list;
+ }
+
+ // ---- the roster ----
+ // Optional, and NOT one of the six: most claims are a number and nothing
+ // else, and gating the inbox on a field only a handful of entries can carry
+ // would leave it permanently red. But when it IS written it is a ruling
+ // like any other -- who he named, how many of them, and his words for it --
+ // so it is checked here rather than trusted.
+ if (patch.roles !== undefined) {
+ if (patch.roles === null || (Array.isArray(patch.roles) && !patch.roles.length)) {
+ delete entry.roles;
+ } else {
+ if (!Array.isArray(patch.roles)) throw new Error("roles must be an array");
+ const list = patch.roles.map((r) => ({
+ role: String(r?.role ?? "").trim(),
+ count: Number(r?.count),
+ verbatim: String(r?.verbatim ?? "").trim(),
+ }));
+ const bad = rolesGaps(list);
+ if (bad.length) throw new Error(`roles: ${bad.join(", ")}`);
+ entry.roles = list;
+ }
+ }
+
+ if (patch.note !== undefined) {
+ if (patch.note) entry.note = String(patch.note);
+ else delete entry.note;
+ }
+
+ const nextToken = await writeManifestAtomic(dir, manifest);
+ return { entry, token: nextToken };
+ });
+}
diff --git a/umtool/lib/report/serve.mjs b/umtool/lib/report/serve.mjs
@@ -48,3 +48,21 @@ export function pickWindow(windows, wantedName) {
export function absOf(win) {
return win ? resolveInRoots(win.path) : null;
}
+
+/**
+ * The same membership rule, for a LEDGER claim.
+ *
+ * A claim id is checked against the ledger of the project named in the request,
+ * exactly as a clip id is checked against its timeline. Nothing here takes a
+ * path either.
+ */
+export async function resolveClaim(projectId, claimId) {
+ const projects = await walkProjects(REPORTS_ROOT);
+ const project = projects.find((p) => p.id === projectId);
+ if (!project) return { error: "no such project", status: 404 };
+ const manifest = await readManifest(project.dir);
+ if (!manifest) return { error: "no manifest", status: 404 };
+ const claim = (manifest.ledger ?? []).find((e) => e.id === claimId);
+ if (!claim) return { error: "no such claim", status: 404 };
+ return { project, manifest, claim };
+}