commit 2d6b38b445f177b227864e22aa772452e406efb0
parent b05d3399bfc1d74fb99d821c11aa40002c060f2e
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Fri, 2 Oct 2026 10:07:04 -0400
records: post links -- the README's "Where a post's QR links" (posts.links, siteChannel/siteUrl/postId, the resolver, notes, the preview), an [Unreleased] bullet, and "Post links, as built" in plans/deck-posts.md
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
3 files changed, 100 insertions(+), 4 deletions(-)
diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md
@@ -13,6 +13,7 @@
- **A report cut can end on a teaser card: a few lines popping in over a dark cinematic ground, with a trailer hit under each.** A report manifest's `teaser` entry (`lines`, `seconds`, an optional `tail`) is a full-frame card drawn from its own words, one to five lines each popping in top to bottom with a scale overshoot, a blur that sharpens, and a flash of the accent; with three or more lines the first is a small overline, the last a mid-size date, and the ones between a big title. A line written as `{ "text": …, "break": … }` draws its ending as a smaller second tier a beat later, and the tail fades in after the last line on its own. Under each pop is a synthesised boom, the title's the biggest, and under the tail a low swell; `"hits": false` makes the card silent. `"beat"` (0.4–2.5 seconds, default 0.7) sets the time from one pop — and its hit — to the next, the second tier and the tail's wait slowing with it; `seconds` may be left out for exactly the length the beats need, and a `seconds` too short for them is refused with that length rather than played faster. Put after the last clip, it joins with the ordinary crossfade and takes the cut's end fade. A line too long to fit the frame at its smallest size is refused with a sentence saying how many characters fit (a title holds 34). It is rendered once and re-rendered when its words change, `--chrome-only` included, and its chapter is its lines. umtool shows it as a card row named by its lines; its words are edited in the manifest.
- **A report cut can go to black before its teaser, and the teaser rises out of the black.** A teaser entry's `"dip": { "fade": …, "black": … }` fades the whole frame before it — the footage, the on-screen deck, the posts feed and anything else drawn over the cut — to black over the previous segment's last `fade` seconds (0.3–4), its sound to silence with it, then holds `black` seconds (0–3) of black, taken to the nearest whole frame at the cut's frame rate. The teaser then opens out of it: the letterbox is already closed, the ground and its light stay dark until the first line slams in and come up with its hit, and a synthesised riser swells under the black into that first hit. The deck and the feed leave under the black instead of sliding away over the crossfade. The black is the start of the teaser's own segment, so the teaser is that much longer and nothing else moves; with a dip, the teaser's `seconds` counts from where the light comes up. The fade is made where the cut is joined, so `--chrome-only` changes it without rebuilding a clip. A dip anywhere but on a teaser, on the first entry, or out of range is refused with a sentence before a build fetches anything. A cut without a dip builds exactly as before.
- **A report cut with `transition: 0` builds when its output folder was given as a relative path.** The hard-cut concat listed its segments relative to the working directory, and ffmpeg reads that list relative to the list file's own folder, so every hard-cut build with a relative `--out` failed at the concat. The list now names each segment by its full path.
+- **A post's QR in a report video opens the post's page on the archive, not on Bluesky or X.** A post drawn by a report cut's on-screen deck (the popup cards or the feed) used to carry a QR of its bsky.app or x.com link; it now opens the post on the archive the report was made from (`provenance.siteOrigin`), the site's post view at `?v=<channel>%2F<id>&vm=post`, which shows the post and links on to the original, so the QR keeps working if the post or the platform goes away. The build finds the archive channel that keeps each post (a post channel such as `piratesoftware-bsky` is its own channel on the archive) from the archive's `corpus.json` and its posts manifests, cached on disk like the cue lookups, and writes the link into `schedule.json`, so `--chrome-only` and umtool's On-screen preview show the same QR; the manifest is not rewritten. Each post gets a line in the build's log saying which channel it was found in, or that the archive does not have it, or did not answer, and that its QR then links the original — never a failed build. A post can pin its channel with `siteChannel`, or a page of its own with `siteUrl` (and give `postId` when its link does not carry its id), and `render.chrome.deck.posts.links: "original"` (the **qr links** setting in umtool) keeps the old QRs. Rebuilding a cut with posts, or `--chrome-only`, re-renders its posts with the new QRs. The archive MCP's `get_post` also names the post's archive page, as `- archive: <url>`, when the source is a site.
- **`archilyzer doctor` checks the image Build all builds sites in.** When a container engine answers, a new **build image** section says whether the image named under **Settings → Build pipeline** is there, when it was built and how big it is. It warns when the image is missing, or older than the last change to its Dockerfile, and prints the one command that rebuilds it. Build all still builds or refreshes the image itself before it builds any site; the warning tells you ahead of time that the next Build all will spend that time. With no container engine the check is skipped in one line, and with no corpus it is only a note. It never fails the doctor.
- **The site build image runs Node 22 and pnpm 11**, the versions the rest of the workspace runs on, instead of Node 20 and pnpm 9, which did not read the workspace's install rules. The next Build all rebuilds the image from its first step, reinstalling every dependency, before it builds any site.
- **Build all sites works in containers again.** Every site's container build had been failing while it prerendered `/favicon.ico`. Each site now builds from the data composed for it, never from files baked into the build image. A bundle whose `site.json` and `corpus.json` do not both name its site is refused before it is handed back or deployed. The image carries no corpus data, and its build context is about 7 MB from any checkout.
diff --git a/plans/deck-posts.md b/plans/deck-posts.md
@@ -672,3 +672,58 @@ Gates at 2093ef9b:
and feed 10739/10739 frames, teaser 237/237), QR 17/17 deck and 7/7 posts; the fade starts
the frame after the mute mark and the frame is black (Y 16) from c20's last frame through
the black.
+
+## Post links, as built
+
+A post's QR links its page on the archive by default, not bsky.app / x.com: the
+archive page survives the post or the platform going away, and it links on to
+the original ("Open original" in the post view; unchanged).
+
+- **The link** (`deck.mjs`, pure). `postQrUrl(post, provenance, { links })`:
+ `links: "original"` is the post's `url`, always. Under `"archive"` (the
+ default, `posts.links`): the post's `siteUrl`; else, with
+ `provenance.siteOrigin`, a `siteChannel` and the post's id,
+ `<origin>/?v=<siteChannel>%2F<id>&vm=post`; else its `url`. The id is
+ `postNativeId`: `postId`, else the url's bsky `/post/<rkey>` or X
+ `/status/<id>`. `postSchedule` takes `provenance` and every placed post's
+ `qrUrl` is this; `url` stays beside it.
+- **The keys.** On a post, optional: `siteChannel` (a slug), `siteUrl` (http(s)),
+ `postId` (letters, digits, `-`, `_`). On the deck, `posts.links`
+ `"archive" | "original"`. `validatePosts` / `validateChrome` refuse anything
+ else; umtool's deck form has the setting as **qr links**.
+- **The resolver** (`post-links.mjs`). A post channel is its own channel on the
+ archive (`piratesoftware-bsky`), which a manifest rarely names, so
+ `resolvePostLinks` finds it for every post that pins neither `siteChannel` nor
+ `siteUrl`: `/corpus.json`'s channels with `manifests.posts`, handle-matching
+ slugs/names first, the first whose `slugToPage` has the id. One note per
+ post. A miss or an archive that does not answer links the original; it never
+ fails a build. Reads go through `createJsonCache`, factored out of
+ `cues.mjs` (the cue walk's memory + disk cache): a miss in a cached copy is
+ re-read fresh once per process (per `refreshAfterMs` for a server); a failed
+ read is remembered for the resolver's life. 15 s a request.
+- **The build.** `linkPosts()` runs once, just before the first
+ `writeChromeSchedule` (full build, `--chrome-only`, `--chrome-preview`): after
+ every refusal, so a refused run asks nothing of the network. It sets
+ `siteChannel` on the run's copy of the posts only. `schedule.json` carries the
+ resolved `qrUrl`, which compose-chrome reads; the QR is an asset of the page,
+ so a changed link is a new chrome key. `opts.postResolver` is the injection
+ point.
+- **umtool's preview: resolved, not pinned.** `scheduleForPreview` links the
+ variant's posts through the same resolver (one per server process: memory
+ across requests, the build's disk cache, 8 s a request, a miss re-read at most
+ every ten minutes) before `previewSchedule`, built schedule or estimate. Chosen
+ over a writer action that pins `siteChannel`: a pin needs a route, a control
+ and the same network lookup, and until someone pressed it the preview and the
+ build would disagree. Pinning by hand still works and skips the lookup.
+- **e2e.** The onscreen-posts and onscreen-feed fixtures pin `siteChannel`, so
+ neither the preview nor the feed build asks `archive.example` anything. The
+ spec's one URL assertion is the table's "open the post" link, the original,
+ unchanged.
+- **MCP.** `get_post` adds `- archive: <url>` (common `viewerPostUrl`) when the
+ source has a viewer origin: the channel's member site on a hub, the site's
+ own otherwise.
+
+First use: the ferret-rescue manifest's seven Bluesky posts all resolve to
+`piratesoftware-bsky` on its archive (each id on page 0 of that channel's posts
+manifest, each record's `url` the post's own); a feed still at 140 s decodes to
+the two archive links on screen.
diff --git a/umtool/report-to-video/README.md b/umtool/report-to-video/README.md
@@ -243,7 +243,8 @@ whatever a manifest omits, one level deep:
"seconds": 4, "hold": 2.5, // the manifest's `posts` (below); seconds 0.5–10; hold 0–10 (0: none; the feed never holds)
"shift": { "scale": 0.86, "seconds": 0.6 }, // the footage makes room: scale 0.5–1, seconds 0–3; or false
"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
+ "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)
}
}
}
@@ -287,9 +288,12 @@ footage, so it rides on a clip.
"author": "Pirate Software", "handle": "piratesoftware.live",
"date": "2026-08-13T19:05:26.424Z", // ISO date or date-time
"text": "We just signed off on 51 page document …", // newlines kept; ≤ 3000 characters
- "url": "https://bsky.app/profile/piratesoftware.live/post/3msydljwjis2a", // the QR
+ "url": "https://bsky.app/profile/piratesoftware.live/post/3msydljwjis2a", // the post itself
"attachTo": null, // a clip id, to override the date rule
- "hide": false } ]
+ "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
```
- **Which clip.** The one whose recording most closely PRECEDES the post: the
@@ -319,7 +323,8 @@ footage, so it rides on a clip.
(24, 64).
- **What.** The post's date (as the deck writes dates; a date-time is drawn as
its day), `@handle · Bluesky` (or X), the words — paragraphs kept, clamped to
- `maxLines` with an ellipsis — and a QR of the post's own `url`.
+ `maxLines` with an ellipsis — and a QR of the post's page on the archive
+ (below), or of its own `url`.
- **Where.** A column `inset` from the top of the footage box and from the
FRAME's edge on the `position` side (inside the footage box when `shift` is
`false`), `width` wide. Cards stack top-down; when the next would overflow the
@@ -329,6 +334,41 @@ footage, so it rides on a clip.
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.
+#### Where a post's QR links
+
+By default (`posts.links: "archive"`) a post's QR opens the post's page on the
+archive the manifest names (`provenance.siteOrigin`) —
+`<origin>/?v=<channel>%2F<id>&vm=post`, the site's post view, which shows the
+post and links on to the original — so the QR keeps working if the post or the
+platform goes away. `deck.mjs` `postQrUrl` makes the link: the post's
+`siteUrl` when it has one; else the archive page, when there is a `siteOrigin`,
+an archive channel and the post's id; else the post's own `url`, as before.
+`"original"` always links the post's own `url`.
+
+- **The archive channel.** A post channel is a channel of its own on the
+ archive (`piratesoftware-bsky`, not the video channel `piratesoftware`), and
+ a manifest rarely says which. The build finds it (`post-links.mjs`), just
+ before it writes the schedule: `/corpus.json`'s channels with a posts
+ manifest, each manifest's `slugToPage` asked for the post's id — a Bluesky
+ rkey (`/post/<rkey>`) or an X status id (`/status/<id>`), or `postId` —
+ channels whose slug or name carries the post's handle first. Through the cue
+ walk's cache (memory and disk, `REPORT_CACHE_DIR`); a post missing from a
+ cached copy is looked for once more in a fresh one.
+- **In memory only.** The found channel is set on the run's copy of the post
+ and written to `schedule.json` as the post's `qrUrl` (beside its own `url`),
+ so a `--chrome-only` re-render draws the link the build found; the manifest
+ is never rewritten. A post that pins `siteChannel` (or `siteUrl`) is not looked
+ up — pin one to make a cut that never asks the network.
+- **Never a failure.** Each post gets a note: `<id>: archive link via <slug>`,
+ `<id>: not in the archive at <origin> -- QR links the original`, or the
+ archive did not answer (15 s a request) and the QR links the original.
+- **umtool's preview** links its posts through the same resolver and the same
+ disk cache (8 s a request; a miss re-read at most every ten minutes), so the
+ On-screen preview draws the build's QRs. The deck form's `qr links` field
+ is `posts.links`.
+- **The chrome cache** follows: a QR is an asset of the page, so a post whose
+ link changes is a new key and a re-render.
+
`validatePosts()` (`deck.mjs`) is the one validator, unknown keys refused; a
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