commit f64e77db6b491f26fa391a1c79fa20474d69a008
parent 1ecb8d06f703c0202b6dd4ff775d5f55469079f5
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Sun, 4 Oct 2026 16:13:50 -0400
records: fact-check chrome -- the README's `claim` and `render.chrome.factcheck` (the stamp, the tally, the schedule's `factcheck`, the stamp region), `qr.links`, `posts[].shot`; report-video.md's On-screen claim column; an [Unreleased] bullet
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
3 files changed, 86 insertions(+), 5 deletions(-)
diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md
@@ -1,6 +1,7 @@
# Changelog
## [Unreleased]
+- **A report cut can be a fact-check: each claim gets a verdict stamp over the footage, and a tally in the on-screen deck counts them.** Any clip, still or card entry of a report manifest can say which claim it is evidence for and the verdict, `"claim": { "id": "k3", "verdict": "CONTRADICTED" }` (one of CORROBORATED, PARTLY, CONTRADICTED, NOT_FOUND, UNTESTABLE). At the end of the last entry carrying each claim, the verdict slams in over the picture as a stamp in its colour and leaves with the transition, and a row of counts in the deck, one per verdict the cut uses, steps up as each stamp lands. `render.chrome.factcheck` sets each verdict's label and colour, how long the stamp is up (1–10 seconds, 3 by default) and which corner of the footage it sits in, and whether the tally is drawn beside the QR or before the title. A claim is a new key, separate from the clip review's `verdict`; one claim id given two verdicts, a claim on a teaser, or an unknown setting is refused with a sentence before a build fetches anything. umtool's On-screen table has a **claim** column (an id and a verdict per row), saved with the titles, and its preview shows the stamps and the tally before a build. The deck's QR can link each clip's original instead of the archive with `render.chrome.deck.qr.links: "original"` — a YouTube video at the clip's second, a Rumble page or an X post as they are; a clip's `citeUrl` still wins. A manifest post can carry `shot`, a screenshot (PNG, JPEG or WebP, beside the manifest), which its card draws in place of the post's text, its QR kept. A cut with none of these builds exactly as before.
- **X posts are fetched more slowly, with random gaps.** Every read of X now waits a random 4 to 10 seconds before each request to X, where it used to page as fast as X answered, and always waits out a rate limit rather than pushing through. When fetching older posts, the pause between one three-month window and the next is a random 45 to 120 seconds instead of a fixed 15. A deep walk of an account's history takes longer; a routine fetch of new posts takes a few seconds more.
- **The MCP's search tools take `date_from` and `date_to` as `2024-10-26` as well as `20241026`, and refuse a date they cannot read.** `search_transcripts` and `enumerate_matches` used to accept only `YYYYMMDD`: any other spelling was dropped with a footer warning and the search ran with no date bound, so a whole-corpus count could be read as the bounded one. Dashed, slashed and dotted dates and ISO timestamps are now normalised, and anything else is an error and nothing is searched.
- **An X channel can fetch posts older than its timeline reaches.** X's timeline only pages back so far, so a fetch could end, and call the history done, well short of an account's first post. The new **Fetch older posts** button on an X channel's page (or `pnpm ops fetch-posts --json '{"slug":"<channel>","older":true}'`) walks back from the oldest archived post through X search, three months at a time, and saves posts the same way a normal fetch does; posts already archived are skipped. It needs a login, as search does: without one it stops at once and the channel shows **Needs credentials**. A run saves its place as it goes and stops after three hours; the next run continues from there. The walk ends at the account's creation date, after a year of windows with no posts, or at a date you give as `"floor": "YYYY-MM-DD"`, and the page's **Older posts** line then says it is complete; running it again says so and fetches nothing. A normal **Fetch posts** is unaffected and still fetches new posts from the top. Bluesky channels have no such button: their fetch already reads the whole history.
diff --git a/umtool/docs/report-video.md b/umtool/docs/report-video.md
@@ -336,6 +336,19 @@ DELETES the key — the same rule an empty attribution field follows.
`updateClip` takes `onscreen` too, so a clip-bench save and an On-screen-table
save are one rule, not two.
+A row may also carry the entry's fact-check claim beside its text,
+`{ title?, subtitle?, claim: { id, verdict } | null }` (`splitOnscreenRow`):
+`claim` is set on the entry (never inside `onscreen`), `null` removes it, and
+a row without the key leaves it as it is. After applying the batch the writer
+runs `validateClaims()` over the whole timeline — one verdict per claim id,
+none on a teaser — and a refusal writes nothing. The On-screen table shows a
+**claim** column (an id and a verdict) per row, `GET /api/report/onscreen`
+returns each row's `claim`, and PUT returns the `claims` it stored. The
+preview applies a draft's claims too: the build's stamps are placed again over
+its segments (`stampSchedule`), the deck's page draws the tally from them, and
+the stamps' own composition (`chrome/stamp-preview/`, served under
+`stamp-preview/`) is laid over the footage for the whole scrub.
+
`updateChrome(dir, chrome | null, { token })` writes `render.chrome` itself:
`validateChrome()` first, so a block umtool saves is one the build accepts,
and `null` removes the key — turning the deck off without touching anything
diff --git a/umtool/report-to-video/README.md b/umtool/report-to-video/README.md
@@ -236,7 +236,8 @@ whatever a manifest omits, one level deep:
"pip": { "spacing": "even", "size": 6, "activeSize": 12 }, // spacing "even" | "time"; activeSize ≥ size
"title": { "size": 54, "maxChars": 48 }, // size 24–96; maxChars 10–120 (the editor's counter, not a refusal)
"subtitle": { "parts": "auto", "dateFormat": "long" }, // parts "auto" | a distinct list of channel/title/date/clock; dateFormat "long" | "iso"
- "qr": { "show": true, "size": 150 }, // size 80–380, and at most height − 20
+ "qr": { "show": true, "size": 150, // size 80–380, and at most height − 20
+ "links": "site" }, // | "original": a clip's QR opens its record's own page (below)
"overCards": "hide", // "hide" | "show"
"motion": { "out": 0.3, "in": 0.45, "pip": 0.7 }, // seconds; out/in 0–2, pip 0–3
"posts": { "show": true, "layout": "popup", // | "feed": a column beside the footage for the whole cut (below)
@@ -245,6 +246,11 @@ whatever a manifest omits, one level deep:
"position": "top-right", // | "top-left"
"width": 600, "qrSize": 120, "maxLines": 7, "inset": 24, // width 320–900 px; qrSize 80–200 and ≤ width/2; maxLines 2–14; inset 0–80
"links": "archive" } // | "original": where a post's QR goes (below)
+ },
+ "factcheck": { // the fact-check stamps and tally (below); every key optional
+ "verdicts": { "PARTLY": { "label": "Partly true", "color": "#e3b23c" } }, // per verdict: label ≤ 24 characters, a #rgb/#rrggbb colour
+ "stamp": { "seconds": 3, "position": "top-left" }, // seconds 1–10; top-left | top-right | bottom-left | bottom-right | center
+ "tally": { "show": true, "position": "right" } // position "right" (beside the QR) | "left" (before the title)
}
}
}
@@ -275,6 +281,12 @@ card's `sub`. The QR follows a clip's corner-QR rule unchanged (`citeUrl`, else
the site link at the clip's start); an image draws one only with an explicit
`citeUrl`; a card never does.
+`qr.links: "original"` points a clip's QR at its source instead of the archive:
+the record's own `webpageUrl` — a YouTube page at the clip's start (`&t=<s>`,
+or `?t=<s>` on a youtu.be link), a Rumble page as it is (it has no time
+parameter a QR can carry), an X post's link as it is. A clip's `citeUrl` still
+wins, and a clip whose record names no page keeps the site link.
+
### `posts` — written statements on the deck
A cut that wears the deck can carry a post — a Bluesky or X statement — as a
@@ -293,7 +305,8 @@ footage, so it rides on a clip.
"hide": false,
"siteChannel": "piratesoftware-bsky", // optional: the archive channel that keeps it
"siteUrl": null, // optional: an http(s) page the QR links instead
- "postId": null } ] // optional: its id, when `url` does not carry one
+ "postId": null, // optional: its id, when `url` does not carry one
+ "shot": "shots/post-1.png" } ] // optional: a screenshot drawn instead of the text card
```
- **Which clip.** The one whose recording most closely PRECEDES the post: the
@@ -333,6 +346,13 @@ footage, so it rides on a clip.
column's side and its accent rim flares as it lands, then settles to a quiet
glow; an accent rail runs down its leading edge and the platform is a pill
beside the handle. The QR is fully opaque once the card is in.
+- **A screenshot.** `shot` — a `.png`, `.jpg`, `.jpeg` or `.webp`, relative to
+ the manifest and inside its folder (no leading `/`, no `..`) — is drawn in
+ place of the date, handle and words, as wide as they would be and no taller
+ than a full card of them; the QR cell stays. The popup and the feed both
+ draw it, the page waits for the picture before it measures the stack, and a
+ shot that cannot be read refuses that region's compose. `text` is still
+ required: it is the post's words wherever they are listed.
#### Where a post's QR links
@@ -382,6 +402,51 @@ deck build refuses a bad `posts` before a single fetch. Posts are drawn only
under the deck: without `render.chrome` they are data for the report, and
nothing in a build reads them.
+### `claim` and `render.chrome.factcheck` — fact-check stamps and the tally
+
+A cut that wears the deck can be a fact-check: any `clip`, `image` or card
+entry may say which claim it is evidence for, and the claim's verdict.
+
+```jsonc
+{ "type": "clip", "id": "c07", …,
+ "claim": { "id": "k3", "verdict": "CONTRADICTED" } } // CORROBORATED | PARTLY | CONTRADICTED | NOT_FOUND | UNTESTABLE
+```
+
+`claim` is its own key: an entry's `verdict` is the clip review's
+(`confirmed` / `incorrect`) and is untouched. The id is letters, digits and
+`_ . : -` (≤ 64); a claim id carries ONE verdict across the timeline, a teaser
+carries none, and `validateClaims()` (`factcheck.mjs`) refuses either before a
+build fetches — umtool's writer refuses with the same function.
+
+- **The stamp.** Each claim is stamped once, on the LAST segment that carries
+ it (its evidence may run over several clips): the verdict's label, in its
+ colour, slams in over the footage `stamp.seconds` (3) before that segment's
+ outgoing transition — or as its incoming dissolve ends, on a shorter one —
+ flashes as it lands, and leaves with the transition, as a popup post does.
+ It sits in a 560×200 box (at 1920 wide, scaled with the frame) `28` px in
+ from the footage box's `stamp.position` corner (`top-left` by default, the
+ corner the popup posts do not use), the feed's footage box in a feed cut.
+- **The tally.** A cell per verdict the cut stamps, in the deck beside the QR's
+ cell (`tally.position: "left"`: before the title), each a count over its
+ label. A count steps up — the old number rolls out, the new one in, the cell
+ flashes — at the moment its stamp lands; a cell is dim until its first. It
+ is part of the deck's panel, so it slides away over a card with it, and the
+ title's column gives it its room only in a cut that stamps something.
+- **The schedule.** A stamped segment carries `claim`, and `schedule.json`
+ gains `factcheck: { stamps: [{ claim, verdict, segment, at, landed, out }] }`
+ (`stampSchedule`) — only when a claim is stamped, so a cut without one
+ writes the schedule it always did.
+- **The render.** `chrome-stamp.mjs` is the stamps' page (pure, like the
+ deck's), `compose-chrome.mjs --region stamp` composes and renders it for the
+ whole cut (`chrome/stamp/`, `chrome/stamp-frames/`, cached by its key, a
+ window `stamp-from<s>`), and the build lays it last, over the posts, exactly
+ as it lays the deck's. `verify-build.mjs` checks its frame count.
+
+`render.chrome.factcheck` sets the verdicts' labels and colours (one at a time:
+a verdict named without a colour keeps the default's), the stamp's seconds and
+corner, and whether and where the tally is drawn; `validateChrome()` refuses an
+unknown key there as everywhere in the block.
+
### The `image` entry type
A still: the receipts a clip cannot say out loud — a post, a thread, a DM, a
@@ -1222,10 +1287,11 @@ implementation of the arithmetic to drift.
`compose-chrome.mjs --region deck` is `--region chart`'s sibling: the same
CLI, the same render cache, the same vendored GSAP, a different composition
-(`chrome-deck.mjs` instead of the chart band's SVG).
+(`chrome-deck.mjs` instead of the chart band's SVG). `--region feed`, `stamp`,
+`posts` and `teaser` are the deck's other regions (below).
```
-node umtool/report-to-video/compose-chrome.mjs <manifest.json> [--region chart|deck] [--variant sourced|full]
+node umtool/report-to-video/compose-chrome.mjs <manifest.json> [--region chart|deck|feed|stamp|posts|teaser] [--variant sourced|full]
[--from <s>] [--duration <s>] [--out <dir>] [--preview]
[--still <s> --png <path>]
[--render] [--workers 4] [--quality high] [--format png-sequence] [--fps 30]
@@ -1489,7 +1555,8 @@ substitution happened.
### `verify-build.mjs`'s deck checks, and their limit
Confirms `schedule.json` is a *measured* (not estimated) deck schedule, that
-`chrome/deck-frames` holds exactly `frameCount(schedule.total, fps)` frames,
+`chrome/deck-frames` (and `chrome/feed-frames` / `chrome/stamp-frames` when the
+schedule has a feed or stamps) holds exactly `frameCount(schedule.total, fps)` frames,
and that the finished file's video stream is that many frames long — laid
with `shortest=1`, so a short sequence shortens the cut silently rather than
erroring. It **cannot tell whether the overlay was actually laid onto the