Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit 9dc1bd697f8948aa1dfcf6e798f62880240a1a24
parent 1cd09317405758b6257d1b732a68390df71502f0
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Fri,  2 Oct 2026 17:04:31 -0400

Merge deck/post-links (report-to-video: a post's QR links its archive page by default, one-row posts feed header, no empty feed plate, the teaser's tail hangs off a centred last line; MCP get_post names the archive page; reviewed SHIP)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Diffstat:
Mcommon/lib/momentUrl.ts | 22++++++++++++++++++++++
Meditor/CHANGELOG.md | 5+++--
Mmcp/src/momentUrl.test.ts | 21+++++++++++++++++++++
Mmcp/src/search.test.ts | 22++++++++++++++++++++++
Mmcp/src/server.ts | 10+++++++---
Mplans/deck-posts.md | 110+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/components/projects/OnscreenSection.tsx | 1+
Mumtool/e2e/fixtures/make-fixture.mjs | 17++++++++++-------
Mumtool/lib/report/onscreen.mjs | 46+++++++++++++++++++++++++++++++++++++++++-----
Mumtool/lib/report/onscreen.test.mjs | 46++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/report-to-video/README.md | 87++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Mumtool/report-to-video/build-video.mjs | 23+++++++++++++++++++++++
Mumtool/report-to-video/chrome-feed.mjs | 44++++++++++++--------------------------------
Mumtool/report-to-video/chrome-feed.test.mjs | 13++++++++-----
Mumtool/report-to-video/chrome-teaser.mjs | 19+++++++++++++++----
Mumtool/report-to-video/chrome-teaser.test.mjs | 60++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Mumtool/report-to-video/cues.mjs | 95++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
Mumtool/report-to-video/deck.mjs | 149+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Mumtool/report-to-video/package.json | 1+
Aumtool/report-to-video/post-links.mjs | 200+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/report-to-video/post-links.test.mjs | 327+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
21 files changed, 1205 insertions(+), 113 deletions(-)

diff --git a/common/lib/momentUrl.ts b/common/lib/momentUrl.ts @@ -101,6 +101,28 @@ export function viewerMomentUrl( return u.toString(); } +// An archived post's page on the viewer: the post modal for `<channel>/<id>` +// (`?v=<slug>&vm=post`), which shows the post and links on to the original. +// Null when the origin or slug is missing or the origin can't be parsed. +export function viewerPostUrl( + siteOrigin: string | null | undefined, + slug: string | null | undefined, +): string | null { + if (!siteOrigin || !slug) return null; + let u: URL; + try { + u = new URL(siteOrigin); + } catch { + return null; + } + u.pathname = "/"; + u.search = ""; + u.hash = ""; + u.searchParams.set("v", slug); + u.searchParams.set("vm", "post"); + return u.toString(); +} + // The preferred moment link: viewer deep link when we have an origin+slug, // else the platform fallback. Null when neither can be built. export function momentUrl(input: MomentUrlInput): string | null { diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md @@ -8,12 +8,13 @@ - **umtool's report videos keep every clip's sound on its picture.** In a crossfaded cut each clip's audio was placed by the audio's own length and its picture by the picture's, and an encoded clip's audio is routinely a few to twenty milliseconds shorter or longer than its video, so the sound drifted further ahead clip by clip: by the end of a seventeen-clip cut it was a third of a second early, and two seconds on one with title and sources cards. Each clip's sound is now padded or trimmed to exactly its picture's length before the crossfade. Every crossfaded report video changes when it is rebuilt, and is in sync; a hard-cut video was not affected. - **umtool's report videos can wear an on-screen deck: one panel under the footage for the whole cut, with a pip timeline, a title per clip, its source and date, and its QR.** A report manifest whose `render` says `"chrome": { "engine": "hyperframes", "layout": "deck" }` scales the footage into a box above a 190 px panel (both sizes are settings) and draws, over the whole cut, one unlabelled pip per clip on a track that fills as the cut plays, the clip's own title from `onscreen.title`, a subtitle naming the recording and its date (the channel too when the cut spans more than one; `onscreen.subtitle` replaces it), and the clip's QR. At each clip change the marker travels to the next pip and the title, subtitle and QR hand over; over a card the panel slides away and comes back after. The citation header, the corner QR and the section footer are not drawn on such a cut, and chapters take the clip's on-screen title. Every setting (sizes, spacing, date format, what the subtitle names, whether cards keep the panel, the motion's timings) is in `render.chrome.deck` and checked when it is saved; an unknown or out-of-range one is refused with a sentence saying why. The panel is rendered once per cut by HyperFrames (pinned to 0.8.24; `HYPERFRAMES_PKG` or `HYPERFRAMES_BIN` override it) and reused until its text or settings change. `build-video.mjs --chrome-only` redraws it over the built segments without rebuilding or fetching anything, `--no-chrome` builds the framed cut without it, and `--chrome-preview <at> <dur>` renders a short window. In umtool, the report page has an **On-screen** section — a switch, the settings, a table of every entry's title and subtitle with the automatic subtitle as its placeholder and a character counter, a live preview with a scrubber, a true still, **Re-render on-screen** and the built video — and the clip bench has on-screen title and subtitle fields with the panel previewed over the clip. The deck changes nothing, byte for byte, in a cut whose manifest has no `render.chrome`. - **A report cut that wears the on-screen deck can show posts — Bluesky or X statements — as cards over the footage.** A report manifest's `posts` list (each with its platform, handle, date, words and link) is drawn near the end of the clip each post belongs with: the clip whose recording most closely precedes it by date, unless the post names one with `attachTo`; `hide` leaves one out. A clip's posts appear four seconds apart and stack down a column at the frame's top right; as the first appears, the footage eases aside (to 86 % of its box, at the far side) to make room, and the clip's last frame is held, in silence, for 2.5 seconds so the last post can be read; then they all leave together in the change to the next clip, which comes in at the normal size. When the column is full the oldest slide up and out. Each card slides in from the edge of the frame and flares in the deck's accent as it lands; it has an accent rail down its edge and shows the post's date, a platform label ("Bluesky" or "X") beside `@handle`, its words in paragraphs up to seven lines with an ellipsis, and a QR of the post's link, in the deck's colours and faces. The hold and the move are made where the cut is joined, not in a clip, so `--chrome-only` changes them without rebuilding one; chapters and the deck's timing count the hold. The timing, the hold (`hold`, 0 turns it off), the move (`shift`: its scale and seconds, or `false`), the column's side, width and inset, the QR size and the line limit are settings under `render.chrome.deck.posts`, and a bad post or setting is refused with a sentence before a build fetches anything. Only the seconds the cards are up are rendered, one short sequence per clip, cached like the deck; `--chrome-only`, `--chrome-preview` and a hard-cut cut lay them as they lay the deck, and `--no-chrome` draws neither — though it still holds and moves the footage, which are part of the cut rather than the chrome. A first post that appears inside the hold still moves the footage, and a hold is a whole number of frames. `posts` changes nothing in a cut that has none, and without the deck it is not drawn at all. -- **A report cut's posts can be a feed: a column beside the footage for the whole cut, each post ticking in as its clip starts, with no pause.** `render.chrome.deck.posts.layout: "feed"` (the default, `"popup"`, is the cards described above) puts every post in one column on the right, standing on the deck so the two read as one L-shaped panel around the picture. The footage of every clip and still is framed, for the whole cut, into the box left beside the column (1272×716 at 1920×1080 with the default 600 px column, against 1574×886 under the deck alone); nothing moves and nothing is held, so the cut is as long as its clips. Before the first post the column shows its header — the platform and the handle, or "Posts" when there are several authors — and an empty state. Each post ticks in at the start of the clip it belongs with, a crossfade's length in (just after the crossfade into it, and as far into the first clip, which has none), a clip's next ones `posts.seconds` apart (closer on a short clip): it lands at the top with an accent flare and keeps a lit rail while it is the newest, and the posts already up slide down to make room; when the column is full the oldest fade out at the bottom. Each card shows the post's date, its words up to `maxLines`, and its QR. Over a card or the teaser the column slides out of the frame with the deck and comes back after. The column is drawn by one composition for the whole cut, cached like the deck's. Switching the layout reframes every segment, so it takes a normal build (with `--skip-fetch` it re-cuts from the cached windows); `--chrome-only` over segments framed for the other layout is refused with a sentence naming them, because each segment's `<id>.cut.json` now records the box it was framed into. In umtool, the posts settings have a **layout** switch, and the live preview shows the column for the whole scrub with the footage in its box. A cut in the popup layout, or without posts, builds exactly as before. +- **A report cut's posts can be a feed: a column beside the footage for the whole cut, each post ticking in as its clip starts, with no pause.** `render.chrome.deck.posts.layout: "feed"` (the default, `"popup"`, is the cards described above) puts every post in one column on the right, standing on the deck so the two read as one L-shaped panel around the picture. The footage of every clip and still is framed, for the whole cut, into the box left beside the column (1272×716 at 1920×1080 with the default 600 px column, against 1574×886 under the deck alone); nothing moves and nothing is held, so the cut is as long as its clips. Before the first post the column is just its header — the platform and the handle, or "Posts" when there are several authors — over its ground. Each post ticks in at the start of the clip it belongs with, a crossfade's length in (just after the crossfade into it, and as far into the first clip, which has none), a clip's next ones `posts.seconds` apart (closer on a short clip): it lands at the top with an accent flare and keeps a lit rail while it is the newest, and the posts already up slide down to make room; when the column is full the oldest fade out at the bottom. Each card shows the post's date, its words up to `maxLines`, and its QR. Over a card or the teaser the column slides out of the frame with the deck and comes back after. The column is drawn by one composition for the whole cut, cached like the deck's. Switching the layout reframes every segment, so it takes a normal build (with `--skip-fetch` it re-cuts from the cached windows); `--chrome-only` over segments framed for the other layout is refused with a sentence naming them, because each segment's `<id>.cut.json` now records the box it was framed into. In umtool, the posts settings have a **layout** switch, and the live preview shows the column for the whole scrub with the footage in its box. A cut in the popup layout, or without posts, builds exactly as before. - **A report clip can go silent partway through, a report cut can fade out at its end, and the deck's QR names its site in larger type.** A clip's `muteFrom` (in the recording's own seconds, inside the clip) silences it from that second to its end while the picture plays on, after a 40 ms fade that ends there, so nothing clicks and no next word leaks in; a hold on that clip stays silent. `render.endFade` (seconds; 0, the default, is off) fades the cut's last segment, whatever it is — a clip with its hold, a closing card or a teaser — to the background colour and to silence over its final seconds, all of it when the segment is shorter, and the deck stays drawn over it. Both are applied where the cut is joined, so `--chrome-only` changes them without rebuilding a clip, and a value out of range is refused with a sentence before a build fetches anything. Each clip build now writes `<id>.cut.json` beside its segment, saying where in the recording the segment really starts after its cut was snapped to a silence; `muteFrom` is measured from it, and a segment built before this measures from the clip's unsnapped start and says so. The site's name beside the deck's QR is now exactly as long as the code is tall, for any site. Neither key changes a cut that does not set it. - **umtool's clip bench stops exactly where a range ends, and sets a clip's mute mark.** The bench's **play selection**, the edge auditions, the auto-audition and a click on a transcript line now play the window's sound through the browser's Web Audio, from a decode made on the server by ffmpeg — the same timeline the build cuts on — and each stops on the audio clock where its range ends, at every speed. They used to play on the video element and were stopped when it next reported its time, which overran the end by up to a quarter of a second, by a different amount each time. The picture follows, muted. If the sound cannot be decoded, the video element plays as before and the bench says the playback is approximate and why. The mute mark sets the clip's `muteFrom`: `m` puts it at the playhead, **pick on waveform** puts it where you click, `;` and `'` nudge it (with shift, by half a second), and `M` or **clear mute** removes it. It is saved with the window like the edges, every playback goes silent at it with the build's own 40 ms fade, and a window save that would leave it outside the clip is refused unless the same save moves or clears it — or, when it is within 0.02 s of the new edge, moves it onto that edge. The decoded sound is served by a new `GET /api/report/audio`, at most 120 seconds of a cached window at a time, as WAV. -- **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 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 on its own off the right of the last line, which stays centred by its own words; `"tailWait"` (0.3–6 seconds) sets how long after the last hit it comes. 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/mcp/src/momentUrl.test.ts b/mcp/src/momentUrl.test.ts @@ -7,6 +7,7 @@ import { momentBaseUrl, viewerMomentBaseUrl, platformMomentBaseUrl, + viewerPostUrl, } from "yt-dlp-transcript-common/lib/momentUrl"; // ─── Archilyzer viewer link (preferred) ─── @@ -217,3 +218,23 @@ test("momentBaseUrl: viewer base wins, platform fallback, else null", () => { assert.equal(momentBaseUrl({ siteOrigin: null, slug: "ch/vid" }), null); }); + +// ─── An archived post's page ─── + +test("viewerPostUrl: origin + post slug → ?v=<channel>%2F<id>&vm=post", () => { + assert.equal( + viewerPostUrl("https://jasolyzer.pages.dev", "piratesoftware-bsky/3l6uxj6esfr2z"), + "https://jasolyzer.pages.dev/?v=piratesoftware-bsky%2F3l6uxj6esfr2z&vm=post", + ); + // An origin with its own path is normalized to the viewer's root. + assert.equal( + viewerPostUrl("https://site.example/search?q=x", "ch-x/123"), + "https://site.example/?v=ch-x%2F123&vm=post", + ); +}); + +test("viewerPostUrl: no origin, no slug, or a bad origin → null", () => { + assert.equal(viewerPostUrl(null, "a/b"), null); + assert.equal(viewerPostUrl("https://x.example", ""), null); + assert.equal(viewerPostUrl("not a url", "a/b"), null); +}); diff --git a/mcp/src/search.test.ts b/mcp/src/search.test.ts @@ -1192,6 +1192,28 @@ test("server: get_post returns the post with no timestamps", async () => { await client.close(); }); +test("server: get_post names the post's archive page when the source has a viewer", async () => { + // A stub has no viewer origin: no archive line. + const bare = await connectClient(new StubSource()); + assert.doesNotMatch(firstText(await bare.callTool({ name: "get_post", arguments: { post_id: "p1" } })), /- archive:/); + await bare.close(); + // A site with one: its post modal for <channel>/<id>. + class SiteSource extends StubSource { + publicOrigin(): string | null { + return "https://site.example"; + } + } + const site = await connectClient(new SiteSource()); + const out = firstText(await site.callTool({ name: "get_post", arguments: { post_id: "p1" } })); + const line = /- archive: (\S+)/.exec(out); + assert.ok(line, "archive line present"); + const u = new URL(line![1]); + assert.equal(u.origin, "https://site.example"); + assert.equal(u.searchParams.get("vm"), "post"); + assert.match(u.searchParams.get("v") ?? "", /\/p1$/); + await site.close(); +}); + test("server: get_thread returns parent + replies", async () => { const client = await connectClient(new StubSource()); const res = await client.callTool({ diff --git a/mcp/src/server.ts b/mcp/src/server.ts @@ -8,7 +8,7 @@ import { transcriptToMarkdown } from "yt-dlp-transcript-common/lib/transcriptToM import { formatDate, formatDuration } from "yt-dlp-transcript-common/lib/format"; import { manifestHasDigest } from "yt-dlp-transcript-common/lib/digests"; import type { VideoStat } from "yt-dlp-transcript-common/lib/stats"; -import { momentUrl, momentBaseUrl } from "yt-dlp-transcript-common/lib/momentUrl"; +import { momentUrl, momentBaseUrl, viewerPostUrl } from "yt-dlp-transcript-common/lib/momentUrl"; import type { Platform } from "yt-dlp-transcript-common/lib/platform"; import type { SearchAlias } from "yt-dlp-transcript-common/lib/searchAliases"; import { isTagId, type PublishedTag } from "yt-dlp-transcript-common/lib/curatedTags"; @@ -2136,7 +2136,7 @@ async function handleGetTranscripts( // Render one post as markdown. No timestamps anywhere — a post has no timeline, // and emitting a 0:00 would invite a bogus `@ mm:ss` citation. -function postToMarkdown(post: Post, heading = true): string { +function postToMarkdown(post: Post, heading = true, archive: string | null = null): string { const lines: string[] = []; if (heading) lines.push(`# Post by ${post.authorName || post.author}`); lines.push( @@ -2146,6 +2146,9 @@ function postToMarkdown(post: Post, heading = true): string { lines.push(`- platform: ${post.platform}`); lines.push(`- post_id: ${post.id}`); lines.push(`- source: ${post.url}`); + // The post's page on the archive, when the source has a viewer: a citation + // that survives the post, or the platform, going away. + if (archive) lines.push(`- archive: ${archive}`); if (post.isRepost) lines.push(`- repost: yes`); if (post.isReply) lines.push(`- reply: yes`); if (post.threadId && post.threadId !== post.id) { @@ -2186,7 +2189,8 @@ async function handleGetPost( typeof args.channel === "string" ? args.channel : undefined, ); if (!found) return errorText(`post not found: ${postId}`); - return text(postToMarkdown(found.post)); + const archive = viewerPostUrl(found.ch.siteUrl ?? source.publicOrigin(), found.post.slug); + return text(postToMarkdown(found.post, true, archive)); } async function handleGetThread( diff --git a/plans/deck-posts.md b/plans/deck-posts.md @@ -672,3 +672,113 @@ 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 shown + post. A miss or an archive that does not answer links the original; it never + fails a build. Hidden posts and a cut without the deck are not looked up. + Reads go through `createJsonCache`, factored out of `cues.mjs` (the cue walk's + memory + disk cache, which now also shares an in-flight fetch between callers): + a miss in a cached copy is re-read fresh once per process (per + `refreshAfterMs` for a server). 15 s a request. +- **The failure memo** (after review). One memo, `url -> { err, at, refresh }`, + read by both passes and expiring after `refreshAfterMs` (Infinity in a build, + ten minutes in the server); a success clears it. A failed cached read went to + the network, so it bars both passes; a failed fresh read bars only fresh + reads, so the copy in hand still answers the cached pass (and a cached hit + does not clear it). An archive that stalls therefore costs one timeout per + URL per build, not one per post. `resolvePostLinks` takes `deadlineMs` for a + bound on the whole call; the preview passes 10 s. +- **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, with the request's posts draft applied (so an un-hidden post + is linked), through the same resolver (one per server process: memory across + requests, the build's disk cache, 8 s a request, 10 s for the whole request, + failures and misses retried at most every ten minutes) before + `previewSchedule`, built schedule or estimate. The found `siteChannel` goes on + the request's copy of the posts, not the draft's. 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. +- **`null` is unset** for `siteChannel`, `siteUrl` and `postId`, as for + `attachTo`, so the README's example validates. +- **The shared cache.** A post miss refreshes the cue walk's on-disk + `corpus.json` (and the posts manifests); `cues.mjs`'s header says so. Manifest + URLs carry no version, so no cue window moves; a later cue lookup can find a + channel a stale copy lacked. +- **Known, left as is.** + - The archive is only ever `provenance.siteOrigin`: a hub origin (whose + `corpus.json` lists no channels), a manifest naming its archive only as + `corpus: "remote:…"`, or a build run with `--site-origin` all link the + originals. Clip QRs behave the same way. The no-origin note says to set + `provenance.siteOrigin`. + - With no handle match, corpus order decides between channels that hold the + same id, and a handle whose first label is generic (`bsky.app` → `bsky`) + matches every `*-bsky` channel. X ids are global and bsky rkeys are TIDs, so + a collision is unlikely. + +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. + +## Teaser tail and feed plate + +Branch `deck/post-links`. Two asks after the ferret previews: "2027" centred, held a couple of +seconds, then the "?" fading in; and no "No posts yet" plate in the feed. + +| Commit | What | +|---|---| +| 43f7de98 | The tail hangs off its row: the last line is centred by its own words, the tail in a zero-width `tail-hang` box on its baseline at its 0.32 em gap. The validator counts the tail and its gap TWICE on the row that carries it (that room is needed on both sides of the centre), and the page's fit measures the words plus twice the tail, so a wide last line still cannot push the tail past the frame. `tailWait` (0.3–6 s, only with a tail): seconds from the last hit (the last line's impact, or its second tier's pop) to the tail; it sets the motion's `tailAfter`, so the swell and the card's length follow. Without it the beat's wait stands. The ferret page hash is re-pinned on purpose (the year moved to the centre) | +| 2d75445d | The feed's empty plate is gone (element, rule, init, the first post's cue, `FEED_MOTION.emptyOut`): before the first post the column is its header over its ground. README and the `[Unreleased]` bullets | + +The ferret finale as the manifest now stands (`beat: 1.05`, dip `{0.6, 0.6}`, D 0.5) with +`tailWait: 2`: the year's impact at 4.0 s into the teaser segment, the tail from 6.0 s (fading +over 1.7 s), the card 7.8 s, the segment 8.9 s (7.9 s without `tailWait`). Stills from +compose-chrome: the year's horizontal centre is 959.5 px at +1 s after its impact, mid tail fade +and the last frame (the frame's centre is 960), the "?" to its right ending at 1193 px. + +Review of 14739ad7..b4a2fc09 (read-only): SHIP. M1 accepted as a one-time cost: the +`.tail-hang` rule and the fit script's new text are in every teaser page, tail or not, so a +teaser without a tail misses the render cache once and re-renders with the same frames (its +data, hits, seconds and validation are unchanged). LOWs left: below beat ~0.47 a break on +the last line pops before the line's impact, so `tailWait` counts from the earlier pop; the +beat's own wait at 0.4 (0.257 s) is below `tailWait`'s 0.3 floor; the too-short and over-20 s +refusals do not name `tailWait`; `teaserTailWait` and `dippedMotion` repeat one rule. The +README's tail-wait wording now says the 0.2 s slam applies without a `break`, and its ferret +numbers are the dipped ones. The ferret cut holds the finished line 2 s more with +`seconds: 9.8` (card), a 10.9 s teaser segment, 360.967 s in all. diff --git a/umtool/components/projects/OnscreenSection.tsx b/umtool/components/projects/OnscreenSection.tsx @@ -559,6 +559,7 @@ const GROUPS: { name: string; fields: Field[] }[] = [ { key: "posts.position", label: "side", kind: "select", options: ["top-right", "top-left"], hint: "the side the column hangs from: the frame's edge when making room, else the footage's" }, { key: "posts.width", label: "width", kind: "int", hint: "px, 320–900" }, { key: "posts.qrSize", label: "qr", kind: "num", hint: "px, 80–200, at most half the card" }, + { key: "posts.links", label: "qr links", kind: "select", options: ["archive", "original"], hint: "archive: each post's QR opens its page on the archive the manifest names (which links on to the original), when the build finds the post there; original: the bsky.app / x.com link itself" }, { key: "posts.maxLines", label: "lines", kind: "int", hint: "2–14: longer posts end in an ellipsis" }, { key: "posts.inset", label: "inset", kind: "num", hint: "px, 0–80 from the edges" }, { key: "posts.shift", label: "make room", kind: "bool", hint: "the footage moves aside while a clip's posts are up; off leaves it in its box under the column" }, diff --git a/umtool/e2e/fixtures/make-fixture.mjs b/umtool/e2e/fixtures/make-fixture.mjs @@ -1518,7 +1518,9 @@ const ONSCREEN_BUILD = writeProject( // p-early Aug 1 older than every clip -> c01 ("first") // p-mid Sep 5 after c01's Sep 3 -> c01 ("date") // p-late Sep 12 after c02's Sep 10 -> c02 ("date") -// Never built, so the posts' timing is the estimate's. +// Never built, so the posts' timing is the estimate's. Every post pins its +// archive channel (`siteChannel`), so its QR is the archive's page for it and +// neither the preview nor a build asks archive.example where the post is kept. const ONSCREEN_POSTS = writeProject( "onscreen-posts-fixture", { @@ -1531,17 +1533,17 @@ const ONSCREEN_POSTS = writeProject( { id: "p-early", platform: "bluesky", author: "Fixture Author", handle: "fixture.example", date: "2024-08-01T12:00:00.000Z", text: "Older than every clip in the cut.", - url: "https://bsky.app/profile/fixture.example/post/early", + url: "https://bsky.app/profile/fixture.example/post/early", siteChannel: "fixture-bsky", }, { id: "p-mid", platform: "bluesky", author: "Fixture Author", handle: "fixture.example", date: "2024-09-05T09:30:00.000Z", text: "Two days after the first clip.\nA second line.", - url: "https://bsky.app/profile/fixture.example/post/mid", + url: "https://bsky.app/profile/fixture.example/post/mid", siteChannel: "fixture-bsky", }, { id: "p-late", platform: "x", author: "Fixture Author", handle: "fixture", date: "2024-09-12T18:00:00.000Z", text: "Two days after the second clip.", - url: "https://x.com/fixture/status/1", + url: "https://x.com/fixture/status/1", siteChannel: "fixture-x", }, ], }, @@ -1551,7 +1553,8 @@ const ONSCREEN_POSTS = writeProject( // onscreen-posts.spec.ts -- every segment framed into the feed's box, one feed // sequence for the whole cut from the stub renderer, no hold -- then refused // a --chrome-only once the layout says popup. Clips only, as the build -// fixture above: a card needs Pango. +// fixture above: a card needs Pango. Its posts pin `siteChannel` too: the +// build looks nothing up on the network. const ONSCREEN_FEED = (() => { const m = deckManifest("onscreen-feed-fixture", "The On-screen Feed Fixture", [ { type: "clip", id: "c01", video: "vid1", start: 3.0, end: 6.0, cite: 3, section: 0, lock: true, quote: "and because", date: "2024-09-03" }, @@ -1564,12 +1567,12 @@ const ONSCREEN_FEED = (() => { { id: "f-one", platform: "bluesky", author: "Fixture Author", handle: "fixture.example", date: "2024-09-05T09:30:00.000Z", text: "Rides on the first clip, in from its start.", - url: "https://bsky.app/profile/fixture.example/post/one", + url: "https://bsky.app/profile/fixture.example/post/one", siteChannel: "fixture-bsky", }, { id: "f-two", platform: "bluesky", author: "Fixture Author", handle: "fixture.example", date: "2024-09-12T18:00:00.000Z", text: "Rides on the second clip.", - url: "https://bsky.app/profile/fixture.example/post/two", + url: "https://bsky.app/profile/fixture.example/post/two", siteChannel: "fixture-bsky", }, ], }); diff --git a/umtool/lib/report/onscreen.mjs b/umtool/lib/report/onscreen.mjs @@ -19,6 +19,7 @@ import { readFile, rm } from "node:fs/promises"; import path from "node:path"; import { composeChrome } from "umtool-report-to-video/compose-chrome"; import { selectVariant } from "umtool-report-to-video/build-video"; +import { createPostChannelResolver, resolvePostLinks } from "umtool-report-to-video/post-links"; import { attachPosts, clipDay, @@ -183,7 +184,7 @@ export function previewSchedule({ variantManifest, built, draft, metas, postsDra }); const total = round(built.total + shift); const placed = deck.posts.show - ? postSchedule({ posts, entries: patchedEntries, metas, segments, D: built.transition, total, render }) + ? postSchedule({ posts, entries: patchedEntries, metas, segments, D: built.transition, total, render, provenance }) : []; // The layout as it is NOW: a feed (no holds, no moves) only with posts to draw. const feed = placed.length > 0 && deck.posts.layout === "feed"; @@ -327,16 +328,51 @@ async function readBuiltSchedule(dir, variant) { * @param {string} variant * @param {Map<string, { title?: string, subtitle?: string } | null>} draft */ -export async function scheduleForPreview(project, manifest, variant, draft = new Map(), postsDraft = {}) { - const variantManifest = selectVariant(manifest, variant); - const entries = variantManifest.timeline ?? []; - const [built, metas] = await Promise.all([ +export async function scheduleForPreview(project, manifest, variant, draft = new Map(), postsDraft = {}, { resolver = previewPostResolver() } = {}) { + const selected = selectVariant(manifest, variant); + const entries = selected.timeline ?? []; + const posts = Array.isArray(selected.posts) ? selected.posts : []; + const [built, metas, linked] = await Promise.all([ readBuiltSchedule(project.dir, variant), deckMetas(project.dir, manifest, entries), + // The posts as the draft leaves them, so a post the draft un-hides is + // linked too (a hidden one is not looked up). + resolvePostLinks({ + posts: applyPostsDraft(posts, postsDraft), + provenance: selected.provenance ?? {}, + render: selected.render ?? {}, + resolver, + deadlineMs: PREVIEW_LINK_DEADLINE_MS, + }), ]); + // The archive channel each post is kept in, found as the build finds it + // (post-links.mjs, the same cache on disk), so the preview's QRs are the + // build's. Set on this request's copy of the posts only, never written. + const found = new Map(linked.posts.filter((p) => p.siteChannel).map((p) => [p.id, p.siteChannel])); + const variantManifest = found.size + ? { ...selected, posts: posts.map((p) => (!p.siteChannel && found.has(p.id) ? { ...p, siteChannel: found.get(p.id) } : p)) } + : selected; return { variantManifest, metas, schedule: previewSchedule({ variantManifest, built, draft, metas, postsDraft }) }; } +/** The most a preview request waits on the archive for its posts' links, all of them together. */ +const PREVIEW_LINK_DEADLINE_MS = 10000; + +/** + * The preview's post-link resolver: one for the server's life, so its memory + * cache spans requests. Each archive request may take eight seconds; a URL + * that failed is not asked again for ten minutes, and a post missing from the + * cached archive is looked for in a fresh copy at most that often. A request + * waits at most PREVIEW_LINK_DEADLINE_MS for all its posts; one not found by + * then links the original, as the build's would when the archive does not + * answer, and its lookup finishes in the background for the next request. + */ +let previewResolver = null; +function previewPostResolver() { + previewResolver ??= createPostChannelResolver({ timeoutMs: 8000, refreshAfterMs: 10 * 60 * 1000 }); + return previewResolver; +} + // compose-chrome writes one directory per cut. Two requests composing into it // at once would interleave their writes, so they queue per directory. /** @type {Map<string, Promise<unknown>>} */ diff --git a/umtool/lib/report/onscreen.test.mjs b/umtool/lib/report/onscreen.test.mjs @@ -17,6 +17,7 @@ import { normalizePostsDraft, postRows, previewSchedule, + scheduleForPreview, scheduleMatches, segmentBoxes, stillTimeOf, @@ -384,3 +385,48 @@ test("segmentBoxes: each segment's recorded framing box, else the deck's; an odd await rm(dir, { recursive: true, force: true }); } }); + +// ---- post links --------------------------------------------------------------- + +test("a post that names its archive channel previews with the archive QR, built or estimated", () => { + const m = withPosts([{ ...POSTS[1], siteChannel: "b-x" }]); + for (const b of [built(), null]) { + const s = previewSchedule({ variantManifest: m, built: b, draft: new Map(), metas }); + assert.equal(s.posts[0].qrUrl, "https://example.test/?v=b-x%2F2&vm=post"); + assert.equal(s.posts[0].url, "https://x.com/b/status/2"); + } +}); + +test("scheduleForPreview: the posts are linked by the resolver the build uses, in memory only", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "onscreen-links-")); + try { + const manifest = withPosts([{ ...POSTS[1] }]); + const asked = []; + const resolver = { find: async (origin, post) => (asked.push([origin, post.id]), "b-x") }; + const { variantManifest, schedule } = await scheduleForPreview({ dir }, manifest, "sourced", new Map(), {}, { resolver }); + assert.deepEqual(asked, [["https://example.test", "p2"]]); + assert.equal(variantManifest.posts[0].siteChannel, "b-x"); + assert.equal(manifest.posts[0].siteChannel, undefined, "the manifest is not changed"); + assert.equal(schedule.posts[0].qrUrl, "https://example.test/?v=b-x%2F2&vm=post"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("scheduleForPreview: a post the draft un-hides is linked; a hidden one is not asked about", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "onscreen-links-")); + try { + const manifest = withPosts([{ ...POSTS[1], hide: true }]); + const asked = []; + const resolver = { find: async (origin, post) => (asked.push(post.id), "b-x") }; + const hidden = await scheduleForPreview({ dir }, manifest, "sourced", new Map(), {}, { resolver }); + assert.deepEqual(asked, []); + assert.ok(!("posts" in hidden.schedule)); + const shown = await scheduleForPreview({ dir }, manifest, "sourced", new Map(), { p2: { hide: false } }, { resolver }); + assert.deepEqual(asked, ["p2"]); + assert.equal(shown.schedule.posts[0].qrUrl, "https://example.test/?v=b-x%2F2&vm=post"); + assert.equal(shown.variantManifest.posts[0].hide, true, "the draft is the preview's, not the returned manifest's"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); 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,49 @@ 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. Hidden posts, and a cut + without the deck, are not looked up. +- **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 and the QR links the original. Each request may take + 15 s, and a URL that failed is not asked again in that build — whichever + pass or post asks next — so an archive that does not answer costs at most one + timeout per URL it has (its `corpus.json` and each posts manifest), not one + per post. +- **umtool's preview** links its posts through the same resolver and the same + disk cache, so the On-screen preview draws the build's QRs. A request waits + at most 10 s for all its posts together (8 s a request); a post not found by + then links the original for that request, while its lookup finishes in the + background for the next. A URL that failed is not asked again for ten + minutes, and a miss is re-read fresh at most that often; a post a draft + un-hides is linked too. 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 @@ -438,7 +486,8 @@ falls on the teaser. { "text": "The Largest Ferret Rescue in the United States", "break": "in the United States" }, "February 2027"], - "tail": "?", // optional: appended to the LAST line, fades in on its own + "tail": "?", // optional: hangs off the LAST line, fades in on its own + "tailWait": 2, // optional: seconds from the last hit to the tail (default: the beat's) "beat": 0.7, // optional, default 0.7: seconds from one pop to the next "hits": true, // optional, default true: false makes the card silent "dip": { "fade": 1.2, "black": 0.6 } } // optional: go to black before it (below) @@ -457,7 +506,9 @@ falls on the teaser. frame shrinks to its role's floor and no further, so each row is also held to what fits at that floor — a **title** at most **34** characters, a **kicker** 56, an **overline** 64, a `break`'s second tier 66; the tail and - the space before it count on the row that carries it. An 80-character title + the space before it count TWICE on the row that carries it, because that row + is centred by its own words and the tail hangs off its right (so a tail can + never be pushed past the frame's edge by a wide line). An 80-character title spilled past both edges of the frame. A longer title fits by setting its end as a `break`. The counts were measured in the face on ordinary words in capitals (a title holds 36–37 there); a row of only wide capitals (M, W) can @@ -482,7 +533,22 @@ falls on the teaser. 8.1 at 1.3 — its `"seconds": 7` (a little over a second more still at the end) holds beats up to about 0.99. - **`tail`** — at most 8 characters, in the accent, set a little apart from - the last line. + the last line (0.32 em). **The last line is centred by its own words**: the + tail hangs off its right in a zero-width box on its baseline, so "2027" + sits on the frame's centre and the "?" beside it, not the two centred + as one unit. (Before, the year sat left of centre by half the tail.) +- **`tailWait`** — optional, 0.3 to 6 seconds, and only with a tail: the time + from the last hit — the last line's impact, or its second tier's pop when it + has a `break` — to the tail's entrance. Left out, it is what the beat gives + (8/7 of a beat from the last pop, less the 0.2 s slam when the last line has + no `break`: 0.6 s at 0.7, 1.0 s at 1.05), so a teaser without it keeps its + timing. The swell under the tail and + the card's length follow it; the tail's 1.7 s fade and the 1.2 s end room do + not change. Without a dip, a teaser at beat 1.05 with `tailWait: 2` hits its + last line at 3.3 s, starts the tail at 5.3 s and needs 8.2 s (7.2 without + it); the ferret finale also has a dip, so in its own clock the year hits at + 4.0 s, the tail starts at 6.0 s and the card needs 7.8 s — its `seconds: + 9.8` holds the finished line 2 s more before the end fade. - **The chapter** is the lines joined with " — ", the tail after the last (an authored `chapter` still wins). The deck slides away over a teaser whatever `overCards` says — it is full frame, never framed into the footage @@ -1300,9 +1366,10 @@ moves: no hold, no footage move, and the cut is as long as its segments. posts to draw, so a popup schedule and one without posts are byte-identical to what they were. `postWindows` is empty for a feed. - **The column** (`chrome-feed.mjs`, one composition for the whole cut, - region-local at the column, transparent outside its panel): a header (the - platform and `@handle`, or "Posts" over several authors, a count, "posts as - the timeline reaches them"), then an empty state until the first post. Each + region-local at the column, transparent outside its panel): a one-row header + (the platform and `@handle`, or "Posts" over several authors -- no running + count and no caption, which a viewer reads off the column itself), over its + ground and nothing else until the first post (no empty plate). Each post TICKS IN at the top, newest first: the cards already in slide down by its height over `push` while it enters from the column's outer edge, its accent rim flares and settles to a lit rail, which goes out when the next diff --git a/umtool/report-to-video/build-video.mjs b/umtool/report-to-video/build-video.mjs @@ -77,6 +77,7 @@ import { cardWidth, contentWidth, reservedFooterHeight, } from "./render-cards.mjs"; import { createCueSource, siteOriginFromManifest } from "./cues.mjs"; +import { createPostChannelResolver, resolvePostLinks } from "./post-links.mjs"; import { ensureWriteDir } from "../lib/report/storage.mjs"; // The deck (`render.chrome`): its geometry, validation and schedule are pure // and live in deck.mjs. This file only frames segments into its box and writes @@ -3360,6 +3361,25 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly // beside the pictures it cites -- not to the cwd the build was started from. const manifestDir = path.dirname(path.resolve(manifestPath)); + // Where each post's QR goes: its page on the archive (`posts.links` + // "archive", the default), which needs the archive channel that keeps it. + // Found once, just before the first schedule is written (a full build, + // --chrome-only, --chrome-preview: after every refusal, so nothing is asked + // of the network for a run that stops), and set on this run's copy of the + // posts -- never written back to the manifest. A post the archive does not + // have, or an archive that does not answer, links the original: a note, + // not a failure. + let postsLinked = false; + const linkPosts = async () => { + if (postsLinked) return; + postsLinked = true; + if (!Array.isArray(manifest.posts) || !manifest.posts.length) return; + const resolver = opts.postResolver ?? createPostChannelResolver({ log: (m) => EMIT("log", { message: m }) }); + const linked = await resolvePostLinks({ posts: manifest.posts, provenance, render, resolver }); + manifest.posts = linked.posts; + for (const message of linked.notes) EMIT("note", { message }); + }; + // The manifest already records which archive it was built against, so a clone // with no corpus needs no extra configuration to read cue windows. CUES = createCueSource({ @@ -3496,6 +3516,7 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly 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) { @@ -3566,6 +3587,7 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly EMIT("card", { id: e.id, i: entries.indexOf(e), n: entries.length }); await buildTeaserSegment(e, { manifestPath, render, outDir, variant, transition: D }); } + await linkPosts(); const schedule = await writeChromeSchedule({ manifest, entries, segments: segs, D, outDir }); EMIT("chrome", { phase: "schedule", total: schedule.total, segments: schedule.segments.length }); // The holds, the moves, the mutes and the end fade, joined on their @@ -3694,6 +3716,7 @@ export async function buildVideo({ manifestPath, opts = {}, out, only, fetchOnly // laid in the concat itself (or, for hard cuts, in one pass after it). let schedule = null; if (deck) { + await linkPosts(); schedule = await writeChromeSchedule({ manifest, entries, segments, D, outDir }); EMIT("chrome", { phase: "schedule", total: schedule.total, segments: schedule.segments.length }); } diff --git a/umtool/report-to-video/chrome-feed.mjs b/umtool/report-to-video/chrome-feed.mjs @@ -9,8 +9,8 @@ // --------------------------------------------------------------------------- // What the column does // --------------------------------------------------------------------------- -// Before the first post it shows its header (who posted, on what) and an -// empty state. Each post TICKS IN at its `in` (deck.mjs postSchedule: its +// Before the first post it is its header (who posted, on what) over its +// ground, and nothing else. Each post TICKS IN at its `in` (deck.mjs postSchedule: its // clip's start + D, the transition's length -- the first clip's too, which // has no dissolve into it; several on one clip a `posts.seconds` apart): it lands at the TOP, newest first, as a timeline // reads, and every card already in slides DOWN by its height. The newest @@ -55,16 +55,17 @@ const r4 = (v) => Math.round(v * 10000) / 10000; * in over it -- the two never overlap on screen. Its highlight flares `glowAt` * into the entrance over `glowUp`, settles to `newest` over `glowDown`, and * goes out over `calm` when the next post arrives. A card pushed past the - * column's bottom fades over `leave`. The empty state fades over `emptyOut`. + * column's bottom fades over `leave`. */ export const FEED_MOTION = Object.freeze({ gap: 16, push: 0.4, lag: 0.22, enter: 0.6, glowAt: 0.2, glowUp: 0.2, glowDown: 1.2, newest: 0.55, calm: 0.8, - leave: 0.45, emptyOut: 0.35, + leave: 0.45, }); /** - * The column's inner layout, region-local px: the padding, the header's rows - * and the stack area under it (`stack`: where cards are, and its height -- + * The column's inner layout, region-local px: the padding, the header (one + * row: who posted, on what -- nothing a viewer can't see for themselves) and + * the stack area under it (`stack`: where cards are, and its height -- * what `feedCues` fits them to). The card's own sizes: its text, meta and QR * cell. */ @@ -74,7 +75,7 @@ export function feedLayout(render) { const W = g.column.width, H = g.column.height; const padX = 22; const headerTop = 26; - const headerH = 66; + const headerH = 36; const ruleY = headerTop + headerH + 10; const stackY = ruleY + 18; const bottom = 22; @@ -105,8 +106,7 @@ export function feedLayout(render) { * * Card j of n sits at y = Σ (height + gap) of the cards newer than it that * are in; it is `c<j>` (autoAlpha, x, y), its highlight `h<j>` (opacity), the - * count `n<k>` (k posts in; autoAlpha), the empty state `empty`, the whole - * column `col` (x). + * whole column `col` (x). * * @returns {{ init: Record<string, object>, gone: Array<number|null>, * cues: Array<{ k: string, at: number, dur: number, from: object, to: object, ease: string, why: string }> }} @@ -114,17 +114,16 @@ export function feedLayout(render) { export function feedCues({ posts, heights, column, visibility = [], startsHidden = false, slideX = 600, enterX = 600, gap = 16, push = 0.4, lag = 0.22, enter = 0.6, glowAt = 0.2, glowUp = 0.2, glowDown = 1.2, newest = 0.55, - calm = 0.8, leave = 0.45, emptyOut = 0.35, + calm = 0.8, leave = 0.45, }) { const R = (v) => Math.round(v * 10000) / 10000; const MIN = 0.001; const n = posts.length; - const init = { col: { x: startsHidden ? slideX : 0 }, empty: { autoAlpha: 1 } }; + const init = { col: { x: startsHidden ? slideX : 0 } }; for (let j = 0; j < n; j += 1) { init[`c${j}`] = { autoAlpha: 0, x: enterX, y: 0 }; init[`h${j}`] = { opacity: 0 }; } - for (let k = 0; k <= n; k += 1) init[`n${k}`] = { autoAlpha: k === 0 ? 1 : 0 }; const ev = []; const add = (k, at, dur, to, ease, why) => ev.push({ k, at: R(at), dur: R(Math.max(MIN, dur)), to, ease, why }); @@ -151,9 +150,6 @@ export function feedCues({ } // The newest hands its highlight on. if (m > 0 && gone[m - 1] === null) add(`h${m - 1}`, t, calm, { opacity: 0 }, "power1.out", `calm for ${id}`); - if (m === 0) add("empty", t, emptyOut, { autoAlpha: 0 }, "power1.out", `first ${id}`); - add(`n${m}`, t + lag, MIN, { autoAlpha: 0 }, "none", `count ${id}`); - add(`n${m + 1}`, t + lag, MIN, { autoAlpha: 1 }, "none", `count ${id}`); add(`c${m}`, t + lag, enter, { autoAlpha: 1, x: 0 }, "expo.out", `enter ${id}`); add(`h${m}`, t + lag + glowAt, glowUp, { opacity: 1 }, "power2.out", `glow ${id}`); add(`h${m}`, t + lag + glowAt + glowUp, glowDown, { opacity: newest }, "power2.inOut", `settle ${id}`); @@ -264,8 +260,6 @@ export function feedHtml(schedule, render, opts = {}) { ); }) .join("\n "); - const countHtml = Array.from({ length: posts.length + 1 }, (_, k) => - `<span class="n" data-k="n${k}">${k} of ${posts.length}</span>`).join(""); const vis = deckChoreography(schedule, render).visibility; const data = { @@ -321,21 +315,10 @@ export function feedHtml(schedule, render, opts = {}) { background: ${rgba(pal.accent, 0.26)}; box-shadow: inset 0 0 0 1px ${rgba(pal.accent, 0.7)}; } .title { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; font-family: 'DeckSansBold', sans-serif; font-size: 26px; line-height: 36px; letter-spacing: -0.005em; } - .count { flex: none; position: relative; width: 84px; height: 36px; font-size: 16px; line-height: 36px; - color: ${pal.muted}; font-variant-numeric: tabular-nums; } - .count .n { position: absolute; right: 0; top: 0; visibility: hidden; opacity: 0; } - .caption { margin-top: 8px; font-size: 14px; line-height: 20px; letter-spacing: 0.12em; text-transform: uppercase; - color: ${rgba(pal.muted, 0.9)}; white-space: nowrap; } .rule { position: absolute; left: ${lay.padX}px; top: ${lay.header.ruleY}px; width: ${W - 2 * lay.padX}px; height: 1px; background: ${rgba(pal.fg, 0.12)}; } .stack { position: absolute; left: ${lay.stack.x}px; top: ${lay.stack.y}px; width: ${lay.stack.width}px; height: ${lay.stack.height}px; overflow: hidden; } - .empty { position: absolute; left: 0; top: 0; width: ${lay.stack.width}px; height: ${c.plate}px; - border-radius: 10px; border: 1px dashed ${rgba(pal.fg, 0.22)}; - display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 6px; - visibility: hidden; opacity: 0; } - .empty b { font-family: 'DeckSansBold', sans-serif; font-weight: 400; font-size: 20px; color: ${rgba(pal.fg, 0.7)}; } - .empty span { font-size: 16px; color: ${pal.muted}; } /* A card: lifted off the column with a touch of the accent, its rail dim until it is the newest. Hidden until the timeline places it. */ .post { position: absolute; left: 0; top: 0; width: ${lay.stack.width}px; min-height: ${c.plate}px; @@ -385,13 +368,10 @@ export function feedHtml(schedule, render, opts = {}) { <div class="who-row"> ${who.platforms.map((p) => `<span class="platform">${esc(p)}</span>`).join("")} <span class="title">${esc(who.title)}</span> - <span class="count">${countHtml}</span> </div> - <div class="caption">Posts as the timeline reaches them</div> </div> <div class="rule"></div> <div class="stack"> - <div class="empty" data-k="empty"><b>No posts yet</b><span>They appear here as the cut reaches them</span></div> ${cardHtml} </div> </div> @@ -449,7 +429,7 @@ export function feedHtml(schedule, render, opts = {}) { posts: F.posts, heights, column: F.column, visibility: F.visibility, startsHidden: F.startsHidden, slideX: F.slideX, enterX: F.enterX, gap: M.gap, push: M.push, lag: M.lag, enter: M.enter, glowAt: M.glowAt, glowUp: M.glowUp, glowDown: M.glowDown, newest: M.newest, calm: M.calm, - leave: M.leave, emptyOut: M.emptyOut, + leave: M.leave, }); for (const k of Object.keys(plan.init)) if (byK[k]) gsap.set(byK[k], plan.init[k]); const inner = gsap.timeline({ paused: true }); diff --git a/umtool/report-to-video/chrome-feed.test.mjs b/umtool/report-to-video/chrome-feed.test.mjs @@ -152,9 +152,11 @@ test("feedCues: each card ticks in at its time, at the top, and the ones in move // The highlight: the newest flares and settles; the one before it goes out. assert.deepEqual(byWhy(plan.cues, /^calm for b$/).map((c) => [c.k, c.to.opacity]), [["h0", 0]]); assert.deepEqual(byWhy(plan.cues, /^settle z$/).map((c) => [c.k, c.to.opacity]), [["h3", FEED_MOTION.newest]]); - // The empty state leaves with the first post; the count follows each one. - assert.deepEqual(byWhy(plan.cues, /^first a$/).map((c) => [c.k, c.at, c.to.autoAlpha]), [["empty", 10, 0]]); - assert.deepEqual(byWhy(plan.cues, /^count z$/).map((c) => [c.k, c.to.autoAlpha]), [["n3", 0], ["n4", 1]]); + // No empty plate: before the first post the column is its header over its ground. + assert.ok(!("empty" in plan.init)); + assert.ok(!plan.cues.some((c) => c.k === "empty" || /^first /.test(c.why))); + assert.equal(plan.cues.some((c) => /^n\d+$/.test(c.k)), false); + assert.equal(Object.keys(plan.init).some((k) => /^n\d+$/.test(k)), false); assert.equal(plan.gone.every((x) => x === null), true); }); @@ -227,8 +229,9 @@ test("the page: one card per post with its date, words and QR; the header; the c assert.ok(html.includes('<span class="platform">Bluesky</span>')); assert.equal((html.match(/class="handle"/g) ?? []).length, 0); assert.ok(html.includes('<span class="date">Oct 19, 2024</span>')); - assert.ok(html.includes('data-k="empty"')); - assert.ok(html.includes('<span class="n" data-k="n4">4 of 4</span>')); + assert.ok(!html.includes('data-k="empty"') && !html.includes("No posts yet") && !html.includes(".empty")); + // No running count and no caption: the header is who posted, on what. + assert.equal(/ of 4<|data-k="n\d|class="caption"|class="count"/.test(html), false); // The cue times are the schedule's. assert.deepEqual(dataOf(html).posts, s.posts.map((p) => ({ id: p.id, in: p.in }))); assert.equal(dataOf(html).column, feedLayout(FEED).stack.height); diff --git a/umtool/report-to-video/chrome-teaser.mjs b/umtool/report-to-video/chrome-teaser.mjs @@ -259,9 +259,11 @@ export function teaserHtml(entry, render, opts = {}) { const rules = l.role === "overline" ? `<div class="rules" data-k="${k}.rules"><span class="rule l"></span><span class="rule r"></span></div>` : ""; + // The tail hangs off the right of its row in a zero-width box, so the + // row is centred by its own words and the tail adds nothing to it. const tailHtml = isLast && tail - ? `<span class="tail" data-k="tail"><span class="tail-glow" data-k="tail.glow"></span>` + - `<span class="tail-t" data-k="tail.t">${esc(tail)}</span></span>` + ? `<span class="tail-hang"><span class="tail" data-k="tail"><span class="tail-glow" data-k="tail.glow"></span>` + + `<span class="tail-t" data-k="tail.t">${esc(tail)}</span></span></span>` : ""; return ( `<div class="line ${l.role}" data-line="${i}" data-role="${l.role}" data-k="${k}.o">` + @@ -350,7 +352,10 @@ export function teaserHtml(entry, render, opts = {}) { .streak { position: absolute; left: -24%; right: -24%; top: 52%; height: 3px; margin-top: -1px; background: linear-gradient(90deg, ${rgba(pal.accent, 0)} 0%, ${rgba(pal.accent, 0.9)} 30%, ${rgba(pal.fg, 0.95)} 50%, ${rgba(pal.accent, 0.9)} 70%, ${rgba(pal.accent, 0)} 100%); box-shadow: 0 0 18px 3px ${rgba(pal.accent, 0.55)}; transform-origin: 50% 50%; mix-blend-mode: screen; } - /* The tail: apart from the words, in the accent, arriving on its own. */ + /* The tail: apart from the words, in the accent, arriving on its own -- + hung in a zero-width box on the row's baseline, so the words are + centred alone and the tail sits off their right at its gap. */ + .tail-hang { display: inline-block; width: 0; white-space: nowrap; } .tail { position: relative; display: inline-block; margin-left: 0.32em; transform-origin: 30% 60%; } .tail-t { font-weight: 900; font-stretch: 112%; letter-spacing: 0; color: ${pal.accent}; font-size: 1.18em; line-height: 1; } .tail-glow { position: absolute; left: -140%; right: -140%; top: -90%; bottom: -90%; @@ -415,7 +420,13 @@ export function teaserHtml(entry, render, opts = {}) { const role = row.parentElement.classList.contains("sub") ? "sub" : row.closest(".line").dataset.role; let s = parseFloat(getComputedStyle(row).fontSize); const floor = D.floors[role] || 12; - while (s > floor && row.scrollWidth > D.maxW + 0.5) { + // A row carrying the tail is centred by its words: it needs the + // tail and its gap on both sides of the centre. + const hang = row.querySelector(".tail"); + const need = () => hang + ? row.firstElementChild.offsetWidth + 2 * (hang.offsetWidth + parseFloat(getComputedStyle(hang).marginLeft)) + : row.scrollWidth; + while (s > floor && need() > D.maxW + 0.5) { s -= 1; row.style.fontSize = s + "px"; } diff --git a/umtool/report-to-video/chrome-teaser.test.mjs b/umtool/report-to-video/chrome-teaser.test.mjs @@ -12,7 +12,7 @@ import { createHash } from "node:crypto"; import { CARD_TYPES, deckChoreography, deckSchedule, deckText, estimatedDuration, hidesDeck, resolveDeck, TEASER_BEAT, - TEASER_MOTION, teaserHits, teaserLines, teaserMotion, teaserSeconds, teaserTail, teaserTimes, teaserTitle, + TEASER_MOTION, teaserHits, teaserLines, teaserMotion, teaserMotionOf, teaserSeconds, teaserTail, teaserTimes, teaserTitle, validateTeaser, validateTeasers, } from "./deck.mjs"; import { teaserCues, teaserHtml } from "./chrome-teaser.mjs"; @@ -53,23 +53,34 @@ test("a valid teaser has nothing to say; every bad shape is a sentence", () => { assert.match(bad({ tail: "" }), /tail must be a short string/); assert.match(bad({ tail: "?????????" }), /tail is 9 characters/); assert.match(bad({ hits: "yes" }), /hits must be true or false/); + // tailWait: seconds from the last hit to the tail, only with a tail. + assert.deepEqual(validateTeaser({ ...FERRET, seconds: undefined, tailWait: 2 }), []); + assert.match(bad({ tailWait: 2 }), /seconds is 7, and at a beat of 0\.7s its lines need 7\.4s/); + assert.match(bad({ tailWait: 0.2 }), /tailWait must be from 0\.3 to 6 seconds, or absent/); + assert.match(bad({ tailWait: 6.5 }), /tailWait must be from 0\.3 to 6/); + assert.match(bad({ tailWait: "2" }), /tailWait must be/); + assert.match(bad({ tailWait: 2, tail: undefined }), /tailWait is the wait before the tail, and there is no tail/); // What fits the frame at each role's floor (TEASER_LIMITS.fit): an // 80-character title spilled past both edges. const T80 = "The Largest Ferret Rescue Operation Ever Attempted Anywhere in the United States"; assert.match(bad({ lines: ["Pirate Software", T80, "February 2027"] }), /lines\[1\] is 80 characters, and at most 34 fit the frame as the title -- shorten it, or set its end as a break/); assert.deepEqual(validateTeaser({ ...FERRET, lines: ["Pirate Software", "x".repeat(34), "February 2027"] }), []); - // The tail counts on the row that carries it: the last, or its second tier. - assert.match(bad({ lines: ["Pirate Software", "x".repeat(34)] }), /lines\[1\] is 36 characters with the tail/); + // The tail counts on the row that carries it -- the last, or its second + // tier -- TWICE: the row is centred by its own words and the tail hangs off + // the right, so it needs the tail and its gap on both sides of the centre. + assert.match(bad({ lines: ["Pirate Software", "x".repeat(34)] }), /lines\[1\] is 38 characters with the tail/); + assert.deepEqual(validateTeaser({ ...FERRET, lines: ["Pirate Software", "Title", "y".repeat(52)] }), []); + assert.match(bad({ lines: ["Pirate Software", "Title", "y".repeat(53)] }), /lines\[2\] is 57 characters with the tail, and at most 56 fit the frame as the kicker/); assert.deepEqual(validateTeaser({ ...FERRET, lines: ["Pirate Software", "x".repeat(34)], tail: undefined }), []); - assert.match(bad({ lines: ["Pirate Software", "Title", "y".repeat(56)] }), /lines\[2\] is 58 characters with the tail.*as the kicker/); + assert.match(bad({ lines: ["Pirate Software", "Title", "y".repeat(56)] }), /lines\[2\] is 60 characters with the tail.*as the kicker/); assert.match(bad({ lines: ["o".repeat(65), "Title", "Date"] }), /lines\[0\] is 65 characters.*as the overline/); // A break: the head in the line's role, the break in the second tier. assert.deepEqual(validateTeaser({ ...FERRET, lines: ["P", { text: T80, break: "Operation Ever Attempted Anywhere in the United States" }, "D"] }), []); assert.match(bad({ lines: ["P", { text: T80, break: "in the United States" }, "D"] }), /lines\[1\] before its break is 59 characters, and at most 34 fit the frame as the title$/m); assert.match(bad({ lines: ["P", { text: `A ${"s".repeat(66)}`, break: "s".repeat(66) }] }), - /lines\[1\]\.break is 68 characters with the tail, and at most 66 fit the frame as the second tier/); + /lines\[1\]\.break is 70 characters with the tail, and at most 66 fit the frame as the second tier/); assert.match(bad({ id: "../x" }), /id must be letters/); // The manifest's validator names where. const errs = validateTeasers({ timeline: [{ type: "clip", id: "c1" }, { ...FERRET, seconds: 1 }] }); @@ -235,13 +246,20 @@ test("the length: `seconds` when set, else what the beats need, up to a tenth an assert.ok(t.tailAt + t.tailDur <= 8.1 - TEASER_MOTION.endRoom + 1e-9); }); -test("a teaser without a beat composes the page it did before beats existed, byte for byte", () => { +test("a teaser without a beat or a tailWait composes one page, byte for byte", () => { const sha = (s) => createHash("sha256").update(s).digest("hex"); - // The ferret teaser's page at 07d1fa08, before `beat`: its render key and frames are these. - const BEFORE = "a683c6414b5cff9816608829b71d8b3e09f8755e86e3b9b0e7300c132a413810"; + // The ferret teaser's page. Re-pinned on purpose when the tail stopped + // counting in its row's centring (it hangs off the right in a zero-width + // box, and the fit measures the words plus the tail on both sides): the + // year now sits on the frame's centre. Before that, at 07d1fa08, it was + // a683c641…; `beat` and `tailWait` left at their defaults keep this one. + const BEFORE = "7560538c8eeee0b5f5ad34bd4d67a5aede07b4f312fc2307764cf70b2ca01e90"; assert.equal(sha(teaserHtml(FERRET, RENDER)), BEFORE); assert.equal(sha(teaserHtml({ ...FERRET, beat: TEASER_MOTION.gap }, RENDER)), BEFORE); assert.notEqual(sha(teaserHtml({ ...FERRET, beat: 0.9 }, RENDER)), BEFORE); + // A tailWait equal to the beat's own (1.0 s from the kicker's impact at 0.7) is the same page. + assert.equal(sha(teaserHtml({ ...FERRET, tailWait: 0.6 }, RENDER)), BEFORE); + assert.notEqual(sha(teaserHtml({ ...FERRET, tailWait: 2 }, RENDER)), BEFORE); // The page's data carries the times it always did, and nothing new. const json = JSON.parse(teaserHtml(FERRET, RENDER).match(/<script id="teaser-data" type="application\/json">([\s\S]*?)<\/script>/)[1]); assert.deepEqual(Object.keys(json.beats).sort(), ["end", "lines", "scale", "tailAt", "tailDur"]); @@ -356,3 +374,29 @@ test("the cache key: the composed page changes with the words, the segment key w rmSync(dir, { recursive: true, force: true }); } }); + +test("tailWait: the tail enters that long after the last hit; the swell and the card's length follow; without it, the beat's wait", async () => { + const { teaserTailWait } = await import("./deck.mjs"); + const free = { ...FERRET, seconds: undefined, beat: 1.05 }; + // The beat's own wait, from the kicker's impact: 1.2 − 0.2 at 1.05, 0.8 − 0.2 at the default. + assert.equal(teaserTailWait(free), 1); + assert.equal(teaserTailWait(FERRET), 0.6); + assert.equal(teaserTailWait({ ...FERRET, tail: undefined }), null); + // A last line with a break: its hit is the second tier's pop, so the wait is tailAfter itself. + assert.equal(teaserTailWait({ ...FERRET, lines: ["A", { text: "B c", break: "c" }] }), 0.8); + const t = teaserTimes(teaserLines(free), "?", teaserMotionOf({ ...free, tailWait: 2 })); + assert.equal(t.lines[2].impact, 3.3); + assert.equal(t.tailAt, 5.3); + assert.equal(t.end, 7); + assert.equal(teaserSeconds({ ...free, tailWait: 2 }), 8.2); + assert.equal(teaserSeconds(free), 7.2); + const swell = teaserHits({ ...free, tailWait: 2 }).find((h) => h.kind === "swell"); + assert.equal(swell.at, 5.3); + // From a second tier's pop when the last line has one. + const brk = { type: "teaser", id: "x", lines: ["A", { text: "B c", break: "c" }], tail: "?", tailWait: 1.5 }; + const tb = teaserTimes(teaserLines(brk), "?", teaserMotionOf(brk)); + assert.equal(tb.tailAt, tb.lines[1].subAt + 1.5); + // The page hangs the tail in a zero-width box. + assert.match(teaserHtml(FERRET, RENDER), /<span class="tail-hang"><span class="tail" data-k="tail">/); + assert.match(teaserHtml(FERRET, RENDER), /\.tail-hang \{ display: inline-block; width: 0; white-space: nowrap; \}/); +}); diff --git a/umtool/report-to-video/cues.mjs b/umtool/report-to-video/cues.mjs @@ -47,6 +47,14 @@ // reader following the citation will actually see, and the only // option that is reproducible on a machine with no corpus. // Whatever answers, the returned record carries `from` so a caller can record it. +// +// THE ON-DISK CACHE IS SHARED, AND ONE READER REFRESHES IT. post-links.mjs (a +// post's archive channel, for its QR) reads `/corpus.json` and the posts +// manifests through createJsonCache below, the same files on disk, and on a +// post it cannot find it fetches them again and rewrites them. The cue walk +// never refreshes, but it can read a corpus.json post-links rewrote: newer, so +// a channel the stale copy lacked is found. Manifest and shard URLs carry no +// version, so a cue window does not move because of it. import { readFile, writeFile, mkdir, lstat, readlink } from "node:fs/promises"; import path from "node:path"; @@ -63,7 +71,7 @@ const REPO_ROOT = path.resolve(/* turbopackIgnore: true */ path.dirname(/* turbo export const DEFAULT_CHANNELS_DIR = process.env.CHANNELS_DIR ?? path.join(/* turbopackIgnore: true */ REPO_ROOT, "transcripts", "channels"); -const DEFAULT_CACHE_DIR = +export const DEFAULT_CACHE_DIR = process.env.REPORT_CACHE_DIR ?? path.join(os.homedir(), ".cache", "archilyzer-report-to-video"); @@ -116,23 +124,36 @@ export class CueLookupError extends Error { } } -export function createCueSource({ - channelsDir = DEFAULT_CHANNELS_DIR, - siteOrigin = null, +/** + * A JSON GET with an in-memory and an on-disk cache, keyed by URL: the cache + * the cue walk reads the archive through, and the one the post-link resolver + * (post-links.mjs) reads it through too: the same files on disk, so an + * archive's corpus.json fetched for one is on disk for the other. + * + * `getJson(url, { refresh: true })` goes back to the network for a URL -- + * unless this cache already fetched it from the network less than + * `refreshAfterMs` ago. The default, Infinity, makes that "at most once per + * process": a build re-reads a stale cached document once, never twice. A + * long-lived server passes a window instead. + */ +export function createJsonCache({ cacheDir = DEFAULT_CACHE_DIR, fetchImpl = globalThis.fetch, log = () => {}, - // "auto" | "local" | "http" — see the note on divergence above. - prefer = "auto", - // Opt-in, because it is expensive: see resolveSiteId below. - resolveSiteIds = false, + refreshAfterMs = Infinity, } = {}) { const mem = new Map(); + /** URL -> when this cache last fetched it from the network (ms). */ + const fetchedAt = new Map(); + /** URL -> the network fetch of it in progress. */ + const inflight = new Map(); - async function getJson(url) { - if (mem.has(url)) return mem.get(url); + async function getJson(url, { refresh = false } = {}) { + const recent = fetchedAt.has(url) && Date.now() - fetchedAt.get(url) < refreshAfterMs; + const useCache = !refresh || recent; + if (useCache && mem.has(url)) return mem.get(url); const disk = cacheDir ? path.join(cacheDir, cacheKey(url)) : null; - if (disk) { + if (useCache && disk) { try { const cached = JSON.parse(await readFile(disk, "utf8")); mem.set(url, cached); @@ -141,22 +162,50 @@ export function createCueSource({ /* cold cache */ } } - log(`fetch ${url}`); - const res = await fetchImpl(url); - if (!res.ok) throw new Error(`GET ${url} -> ${res.status}`); - const json = await res.json(); - mem.set(url, json); - if (disk) { - try { - await mkdir(path.dirname(disk), { recursive: true }); - await writeFile(disk, JSON.stringify(json)); - } catch { - // A cache we cannot write is a slow run, not a failed one. + // Callers that ask for the same URL while it is on its way share the one + // fetch (umtool's preview routes run side by side). + if (inflight.has(url)) return inflight.get(url); + const pending = (async () => { + log(`fetch ${url}`); + const res = await fetchImpl(url); + if (!res.ok) throw new Error(`GET ${url} -> ${res.status}`); + const json = await res.json(); + mem.set(url, json); + fetchedAt.set(url, Date.now()); + if (disk) { + try { + await mkdir(path.dirname(disk), { recursive: true }); + await writeFile(disk, JSON.stringify(json)); + } catch { + // A cache we cannot write is a slow run, not a failed one. + } } + return json; + })(); + inflight.set(url, pending); + try { + return await pending; + } finally { + inflight.delete(url); } - return json; } + return { getJson }; +} + +export function createCueSource({ + channelsDir = DEFAULT_CHANNELS_DIR, + siteOrigin = null, + cacheDir = DEFAULT_CACHE_DIR, + fetchImpl = globalThis.fetch, + log = () => {}, + // "auto" | "local" | "http" — see the note on divergence above. + prefer = "auto", + // Opt-in, because it is expensive: see resolveSiteId below. + resolveSiteIds = false, +} = {}) { + const { getJson } = createJsonCache({ cacheDir, fetchImpl, log }); + async function channelEntry(origin, channelSlug) { const corpus = await getJson(`${origin}/corpus.json`); const found = (corpus.channels ?? []).find((c) => c.slug === channelSlug); diff --git a/umtool/report-to-video/deck.mjs b/umtool/report-to-video/deck.mjs @@ -45,9 +45,13 @@ export const DECK_DEFAULTS = Object.freeze({ // `layout` "popup" draws them as above; "feed" keeps them in a column of // their own beside the footage for the whole cut (feedGeometry), each one // ticking in as its clip starts -- no hold, no move. + // `links` is where a post's QR goes: "archive" (the post's page on the + // archive the manifest names, `provenance.siteOrigin`, which survives the + // post or the platform going away, and links on to the original) or + // "original" (the bsky.app / x.com link itself). posts: Object.freeze({ show: true, layout: "popup", seconds: 4, hold: 2.5, position: "top-right", width: 600, qrSize: 120, maxLines: 7, - inset: 24, shift: Object.freeze({ scale: 0.86, seconds: 0.6 }), + inset: 24, shift: Object.freeze({ scale: 0.86, seconds: 0.6 }), links: "archive", }), }); @@ -190,10 +194,13 @@ export function validateChrome(chrome, render = {}) { errors.push(`${w}.qr.size ${size} does not fit a ${h}px deck (at most ${h - 20})`); } }); - sub("posts", ["show", "layout", "seconds", "hold", "position", "width", "qrSize", "maxLines", "inset", "shift"], (p) => { + sub("posts", ["show", "layout", "seconds", "hold", "position", "width", "qrSize", "maxLines", "inset", "shift", "links"], (p) => { if (p.layout !== undefined && !POST_LAYOUTS.includes(p.layout)) { errors.push(`${w}.posts.layout must be ${POST_LAYOUTS.map((l) => `"${l}"`).join(" or ")}`); } + if (p.links !== undefined && !POST_LINKS.includes(p.links)) { + errors.push(`${w}.posts.links must be ${POST_LINKS.map((l) => `"${l}"`).join(" or ")}`); + } if (p.hold !== undefined && !numIn(p.hold, 0, 10)) errors.push(`${w}.posts.hold must be from 0 to 10 seconds`); if (p.shift !== undefined && p.shift !== false) { if (!isObj(p.shift)) errors.push(`${w}.posts.shift must be false or { scale, seconds }`); @@ -525,7 +532,7 @@ export function deckSchedule({ const multiChannel = isMultiChannel(entries, provenance); const round = (v) => Math.round(v * 1000) / 1000; const segs = entries.map((e, i) => ({ id: e.id, start: starts[i], duration: full[i] })); - const placed = deck.posts.show ? postSchedule({ posts, entries, metas, segments: segs, D, total, render }) : []; + const placed = deck.posts.show ? postSchedule({ posts, entries, metas, segments: segs, D, total, render, provenance }) : []; // The feed is a layout only when it has posts to draw: a feed with none is // the deck alone, and writes the schedule a deck without posts always did. const feed = placed.length > 0 && deck.posts.layout === "feed"; @@ -671,7 +678,9 @@ export function pipSegments(schedule) { export const POST_PLATFORMS = Object.freeze(["bluesky", "x"]); /** How posts are drawn (`posts.layout`): cards at the end of a clip, or a column for the whole cut. */ export const POST_LAYOUTS = Object.freeze(["popup", "feed"]); -const POST_KEYS = ["id", "platform", "author", "handle", "date", "text", "url", "attachTo", "hide"]; +/** Where a post's QR links (`posts.links`): its page on the archive, or the platform's own link. */ +export const POST_LINKS = Object.freeze(["archive", "original"]); +const POST_KEYS = ["id", "platform", "author", "handle", "date", "text", "url", "attachTo", "hide", "siteChannel", "siteUrl", "postId"]; /** * Every reason the manifest's `posts` cannot be built, as sentences. `timeline` @@ -707,6 +716,19 @@ export function validatePosts(posts, timeline = [], render = null) { errors.push(`${w}.attachTo ${JSON.stringify(p.attachTo)} is not a clip in the timeline`); } if (p.hide !== undefined && typeof p.hide !== "boolean") errors.push(`${w}.hide must be true or false`); + // Where the archive keeps the post: its own channel's slug (a post channel + // is not the video channel -- `piratesoftware-bsky`, not `piratesoftware`), + // a page to link instead, or the post's id when its url does not carry one. + // `null` is unset, as `attachTo: null` is. + if (p.siteChannel != null && (typeof p.siteChannel !== "string" || !SLUG_RE.test(p.siteChannel))) { + errors.push(`${w}.siteChannel must be the archive's channel slug (letters, digits, dots, dashes, underscores)`); + } + if (p.siteUrl != null && (typeof p.siteUrl !== "string" || !/^https?:\/\/\S+$/.test(p.siteUrl))) { + errors.push(`${w}.siteUrl must be an http(s) link`); + } + if (p.postId != null && (typeof p.postId !== "string" || !POST_ID_RE.test(p.postId))) { + errors.push(`${w}.postId must be the post's id on its platform (letters, digits, dashes, underscores)`); + } }); // Posts that will be drawn need a column that fits the footage box. if (render && deckOn(render) && resolveDeck(render).posts.show && posts.some((p) => isObj(p) && !p.hide)) { @@ -799,11 +821,15 @@ export function attachPosts({ posts = [], entries = [], metas = [] }) { * short for that shares what it has after its incoming dissolve evenly. They * all leave together over `out`. * + * Each one's `qrUrl` is postQrUrl's: its archive page under `posts.links` + * "archive" when the post names its archive channel, else its own `url`. + * * @returns {Array<{ id, segment, slot, of, appear, out: [number, number], date, text, * author, handle, platform, url, qrUrl }>} */ -export function postSchedule({ posts = [], entries, metas = [], segments, D, total, render }) { +export function postSchedule({ posts = [], entries, metas = [], segments, D, total, render, provenance = {} }) { const settings = resolveDeck(render).posts; + const postFields = (p) => postFieldsOf(p, provenance, settings.links); const feed = settings.layout === "feed"; const byId = new Map(posts.map((p) => [p.id, p])); const groups = new Map(); @@ -848,7 +874,7 @@ export function postSchedule({ posts = [], entries, metas = [], segments, D, tot } /** What a placed post carries for the page that draws it. */ -function postFields(p) { +function postFieldsOf(p, provenance, links) { return { date: p.date, text: p.text, @@ -856,10 +882,63 @@ function postFields(p) { handle: p.handle ?? "", platform: p.platform, url: p.url, - qrUrl: p.url, + qrUrl: postQrUrl(p, provenance, { links }), }; } +const SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; +const POST_ID_RE = /^[A-Za-z0-9_-]{1,128}$/; + +/** + * The post's id on its platform -- what the archive keys it by (a post + * channel's `slugToPage`): an explicit `postId`, else read off its url -- a + * Bluesky `/post/<rkey>`, an X (or twitter.com) `/status/<id>`. Null when + * neither says. + * + * @returns {string|null} + */ +export function postNativeId(post) { + if (typeof post?.postId === "string" && POST_ID_RE.test(post.postId)) return post.postId; + let u; + try { + u = new URL(String(post?.url ?? "")); + } catch { + return null; + } + const m = /\/post\/([A-Za-z0-9]+)\/?$/.exec(u.pathname) ?? /\/status(?:es)?\/(\d+)(?:\/|$)/.exec(u.pathname); + return m ? m[1] : null; +} + +/** + * A post's page on an archive: the site's post modal for `<channel>/<id>`, + * which shows the post and links on to the original. + */ +export function postArchiveUrl(siteOrigin, siteChannel, nativeId) { + const origin = String(siteOrigin).replace(/\/+$/, ""); + return `${origin}/?v=${encodeURIComponent(`${siteChannel}/${nativeId}`)}&vm=post`; +} + +/** + * Where a post's QR (and any link to it the cut draws) goes. + * + * `links: "original"` (the deck's `posts.links`) is the post's own `url`, + * always. Under "archive" (the default): the post's explicit `siteUrl`; else, + * with an archive to link (`provenance.siteOrigin`), the channel it is kept in + * there (`siteChannel` -- pinned in the manifest, or found by the build's + * resolver, post-links.mjs) and its id, the post's archive page; else its own + * `url`, as before. + * + * @returns {string} + */ +export function postQrUrl(post, provenance = {}, { links = DECK_DEFAULTS.posts.links } = {}) { + if (links === "original") return post.url; + if (typeof post.siteUrl === "string" && post.siteUrl) return post.siteUrl; + const origin = typeof provenance?.siteOrigin === "string" ? provenance.siteOrigin.trim() : ""; + const id = postNativeId(post); + if (origin && post.siteChannel && id) return postArchiveUrl(origin, post.siteChannel, id); + return post.url; +} + /** * The windows the posts region is rendered for: one per clip that carries * posts, from its first post's appearance to the end of its leave. Frames are @@ -1126,8 +1205,9 @@ export function muteSegmentSeconds({ entry, record = null, render = {}, seconds // "tail": "?", "hits": true } // // A line is a string, or `{ text, break }`: `break` is the END of `text` set -// as a smaller second tier under the rest, a beat later. The tail is appended -// to the last line and fades in on its own. `hits` (default true) puts a +// as a smaller second tier under the rest, a beat later. The tail hangs off +// the right of the last line -- which is centred by its own words -- and +// fades in on its own, `tailWait` (optional) after the last hit. `hits` (default true) puts a // trailer hit under each pop and a swell under the tail; false is silence. // `beat` (optional) is the seconds from one line's pop to the next // (`teaserMotion`); `seconds` (optional) is the card's length, and without it @@ -1143,7 +1223,9 @@ export function muteSegmentSeconds({ entry, record = null, render = {}, seconds * The teaser's limits: lines, seconds, characters per line, the tail's length, * and `fit`: the characters one ROW may hold in its role -- the first tier * (`head`) in the line's role, a `break` in `sub`, the tail and its gap - * counted on the row that carries it. A row wider than 80 % of the frame + * counted TWICE on the row that carries it (the row is centred by its words + * and the tail hangs off the right, so it needs that room on both sides of + * the centre). A row wider than 80 % of the frame * shrinks to its role's floor (TEASER_TYPE in chrome-teaser.mjs) and no * further, so a longer row spilled past the frame's edges. Measured at the * floor in the face, on ordinary headline words in capitals: the title holds @@ -1214,6 +1296,25 @@ export const TEASER_MOTION = Object.freeze({ /** The beats an entry's `beat` may be: from 0.4 s (packed) to 2.5 s (a pause between each). */ export const TEASER_BEAT = Object.freeze({ min: 0.4, max: 2.5 }); +/** An entry's `tailWait` may be: seconds from the last hit to the tail's entrance. */ +export const TEASER_TAIL_WAIT = Object.freeze({ min: 0.3, max: 6 }); + +/** + * The seconds from a teaser's last hit -- its last line's impact, or that + * line's second tier's pop when it has one (where its hit sounds) -- to the + * tail's entrance: the entry's `tailWait`, else what the beat gives + * (`tailAfter` counted from the last pop, less the slam's `hit` when the last + * pop is a slam). Null without a tail. The ferret kicker at beat 1.05: 1.0 s. + */ +export function teaserTailWait(entry) { + if (!teaserTail(entry)) return null; + if (entry?.tailWait !== undefined && entry?.tailWait !== null) return Number(entry.tailWait); + const m = teaserMotion(entry?.beat); + const lines = teaserLines(entry); + const lastSub = !!lines[lines.length - 1]?.sub; + return Math.round((m.tailAfter - (lastSub ? 0 : m.hit)) * 10000) / 10000; +} + /** * The motion at an entry's beat: `gap` is the beat, and the two waits that * read as part of it scale with it in the default's proportion -- the second @@ -1377,11 +1478,22 @@ export function teaserMotionOf(entry, D = 0.5, fps = 30) { return dippedMotion(entry, teaserLead(entry, D, fps)); } -/** `teaserMotion(beat)`, its first line moved to `lead + before − hit` when the entry dips. */ +/** + * `teaserMotion(beat)`, its first line moved to `lead + before − hit` when the + * entry dips, and its `tailAfter` set from the entry's `tailWait` when it has + * one (`teaserTailWait`, counted from the last hit). Without either it is + * `teaserMotion(beat)` itself. + */ function dippedMotion(entry, lead) { - const m = teaserMotion(entry?.beat); + let m = teaserMotion(entry?.beat); + const r = (v) => Math.round(v * 10000) / 10000; + if (entry?.tailWait !== undefined && entry?.tailWait !== null && teaserTail(entry)) { + const lines = teaserLines(entry); + const lastSub = !!lines[lines.length - 1]?.sub; + m = Object.freeze({ ...m, tailAfter: r(Number(entry.tailWait) + (lastSub ? 0 : m.hit)) }); + } if (!dipOf(entry)) return m; - return Object.freeze({ ...m, first: Math.round((lead + DIP_RISE.before - m.hit) * 10000) / 10000 }); + return Object.freeze({ ...m, first: r(lead + DIP_RISE.before - m.hit) }); } /** @@ -1471,6 +1583,13 @@ export function validateTeaser(entry, where = `timeline entry ${entry?.id ?? "?" if (hasBeat && !numIn(entry.beat, TEASER_BEAT.min, TEASER_BEAT.max)) { errors.push(`${where}.beat must be from ${TEASER_BEAT.min} to ${TEASER_BEAT.max} seconds, or absent`); } + if (entry?.tailWait !== undefined && entry?.tailWait !== null) { + if (!numIn(entry.tailWait, TEASER_TAIL_WAIT.min, TEASER_TAIL_WAIT.max)) { + errors.push(`${where}.tailWait must be from ${TEASER_TAIL_WAIT.min} to ${TEASER_TAIL_WAIT.max} seconds, or absent`); + } else if (!(typeof entry.tail === "string" && entry.tail.trim())) { + errors.push(`${where}.tailWait is the wait before the tail, and there is no tail`); + } + } const oneLine = (s, w) => { if (typeof s !== "string" || !s.trim()) { errors.push(`${w} must be words, not empty`); return false; } if (/[\r\n]/.test(s)) { errors.push(`${w} must be one line`); return false; } @@ -1512,9 +1631,11 @@ export function validateTeaser(entry, where = `timeline entry ${entry?.id ?? "?" const rows = teaserLines(entry); const tail = teaserTail(entry); const fit = TEASER_LIMITS.fit; + // The row that carries the tail is centred by its own words and the tail + // hangs off its right: the row needs the tail and its gap on BOTH sides. rows.forEach((l, i) => { const w = `${where}.lines[${i}]`; - const tailHere = tail && i === rows.length - 1 ? tail.length + 1 : 0; + const tailHere = tail && i === rows.length - 1 ? 2 * (tail.length + 1) : 0; const head = l.head.length + (l.sub ? 0 : tailHere); if (head > fit[l.role]) { errors.push( diff --git a/umtool/report-to-video/package.json b/umtool/report-to-video/package.json @@ -26,6 +26,7 @@ "./ledger-totals": "./ledger-totals.mjs", "./mute": "./mute.mjs", "./package.json": "./package.json", + "./post-links": "./post-links.mjs", "./render-cards": "./render-cards.mjs", "./resolve-windows": "./resolve-windows.mjs", "./verify-build": "./verify-build.mjs" diff --git a/umtool/report-to-video/post-links.mjs b/umtool/report-to-video/post-links.mjs @@ -0,0 +1,200 @@ +// Which archive channel keeps each post, so its QR can link the post's page +// on the archive rather than bsky.app / x.com. +// +// A post channel is a channel of its own on the archive, with its own slug +// (`piratesoftware-bsky`, not the video channel `piratesoftware`), and a manifest +// rarely says which: it carries the platform's link. So the build finds it, by +// the walk `/corpus.json` publishes under `postScheme`: +// +// 1. GET <origin>/corpus.json -> channels[] with `manifests.posts` +// 2. GET each one's posts manifest -> { slugToPage: { <postId>: N } } +// 3. the first channel whose slugToPage has the post's id keeps it +// +// Channels whose slug or name carries the post's handle are asked first, so a +// post that several channels hold (a mirror, a repost) links the account that +// posted it. Nothing here writes the manifest: a found channel is set on the +// build's in-memory copy of the post, and deck.mjs `postQrUrl` makes the link. +// A post the manifest pins (`siteChannel` or `siteUrl`) is not looked up. +// +// Failure is never the build's: a post not in the archive, or an archive that +// does not answer, links the original, and says so in a note. +// +// The fetches go through cues.mjs's JSON cache -- the same files on disk the +// cue walk reads -- with one refresh: a post missing from a CACHED corpus or +// manifest is looked for again in a fresh copy (once per process for a build, +// once per `refreshAfterMs` for a server), so a post published since the cache +// was filled is found. A URL that failed is not asked again for as long (the +// failure memo in createPostChannelResolver). +import { createJsonCache } from "./cues.mjs"; +import { deckOn, postNativeId, resolveDeck } from "./deck.mjs"; + +/** How long one archive request may take before the post links its original. */ +export const POST_LINK_TIMEOUT_MS = 15000; + +const origin = (provenance) => { + const o = typeof provenance?.siteOrigin === "string" ? provenance.siteOrigin.trim() : ""; + return o ? o.replace(/\/+$/, "") : null; +}; + +const squash = (v) => String(v ?? "").toLowerCase().replace(/^@/, ""); + +/** Does this corpus channel look like the account that posted? Its slug or name carries the handle. */ +export function channelMatchesHandle(channel, handle) { + const h = squash(handle); + if (!h) return false; + const first = h.split(".")[0]; + const hay = [squash(channel.slug), squash(channel.name)]; + return hay.some((s) => s.includes(h) || (first.length >= 3 && s.includes(first))); +} + +/** + * The resolver. `getJson` (url, { refresh }) => document is injectable for + * tests; by default a cues.mjs JSON cache over `fetchImpl` with a timeout. + * + * @returns {{ find(origin: string, post: Record<string, any>): Promise<string|null> }} + * `find` resolves the slug of the channel that keeps the post, or null when + * none does; it rejects when the archive cannot be read. + */ +export function createPostChannelResolver({ + getJson = null, + cacheDir, + fetchImpl = globalThis.fetch, + log = () => {}, + timeoutMs = POST_LINK_TIMEOUT_MS, + refreshAfterMs = Infinity, + now = Date.now, +} = {}) { + const timed = (url) => fetchImpl(url, { signal: AbortSignal.timeout(timeoutMs) }); + const get = getJson ?? createJsonCache({ ...(cacheDir !== undefined ? { cacheDir } : {}), fetchImpl: timed, log, refreshAfterMs }).getJson; + // ONE memo of failed reads, `url -> { err, at, refresh }`, consulted by both + // passes (the cached read and the fresh one): a URL that failed is not asked + // again until `refreshAfterMs` has passed -- never again in a build + // (Infinity), after ten minutes in umtool's server -- and a read of it that + // succeeds clears it. So each URL of an archive that does not answer costs + // one timeout per build, not one per post. + // + // A cached read that failed went to the network (nothing was cached), so it + // bars both passes. A fresh read that failed bars only fresh reads: the copy + // already in hand still answers the cached pass, and a cached read that + // succeeds does not clear it -- it proves nothing about the network. + const failed = new Map(); + + async function read(url, refresh) { + const f = failed.get(url); + const live = f && now() - f.at < refreshAfterMs; + if (live && (refresh || !f.refresh)) throw f.err; + try { + const doc = await get(url, { refresh }); + if (f && (refresh || !f.refresh || !live)) failed.delete(url); + return doc; + } catch (e) { + const err = e instanceof Error ? e : new Error(String(e)); + failed.set(url, { err, at: now(), refresh }); + throw err; + } + } + + async function lookIn(base, id, handle, refresh) { + const corpus = await read(`${base}/corpus.json`, refresh); + const channels = (corpus?.channels ?? []).filter((c) => typeof c?.manifests?.posts === "string" && c.postCount !== 0); + const ordered = [ + ...channels.filter((c) => channelMatchesHandle(c, handle)), + ...channels.filter((c) => !channelMatchesHandle(c, handle)), + ]; + for (const c of ordered) { + let manifest; + try { + manifest = await read(c.manifests.posts, refresh); + } catch { + // One channel's manifest missing is that channel not answering, not the archive. + continue; + } + if (manifest?.slugToPage && Object.hasOwn(manifest.slugToPage, id)) return c.slug; + } + return null; + } + + return { + async find(base, post) { + const id = postNativeId(post); + if (!id) return null; + const at = String(base).replace(/\/+$/, ""); + return (await lookIn(at, id, post.handle, false)) ?? (await lookIn(at, id, post.handle, true)); + }, + }; +} + +/** + * The posts with their archive channel found, and a note per post saying + * where its QR goes. Only under the deck, with `posts.links: "archive"`, an + * archive named (`provenance.siteOrigin`) and posts to draw; otherwise the + * posts come back as they were, with no notes. A hidden post is not drawn, so + * it is not looked up either. + * + * Never throws for the archive: a post it cannot place keeps its own link. + * `deadlineMs` bounds the whole call (umtool's preview passes one): a post + * still unresolved when it passes keeps its own link, and its lookup goes on + * in the background, filling the cache for the next call. + * + * @param {{ posts?: Array<Record<string, any>>, provenance?: Record<string, any>, + * render?: Record<string, any>, resolver: ReturnType<typeof createPostChannelResolver>, + * deadlineMs?: number }} args + * @returns {Promise<{ posts: Array<Record<string, any>>, notes: string[] }>} + */ +export async function resolvePostLinks({ posts = [], provenance = {}, render = {}, resolver, deadlineMs = Infinity }) { + const settings = resolveDeck(render).posts; + const base = origin(provenance); + if (!deckOn(render) || !Array.isArray(posts) || !posts.length || !settings.show || settings.links !== "archive") { + return { posts, notes: [] }; + } + if (!base) { + return { + posts, + notes: ["posts: no provenance.siteOrigin names an archive -- every post's QR links the original (set provenance.siteOrigin to the site that keeps them)"], + }; + } + const notes = []; + const out = []; + const until = Date.now() + deadlineMs; + const timeUp = Symbol("time up"); + const inTime = (promise) => { + const left = until - Date.now(); + if (!Number.isFinite(left)) return promise; + let timer; + const t = new Promise((res) => { timer = setTimeout(() => res(timeUp), left); }); + promise.catch(() => {}); + return Promise.race([promise, t]).finally(() => clearTimeout(timer)); + }; + for (const p of posts) { + if (p.hide) { out.push(p); continue; } + if (p.siteUrl) { notes.push(`${p.id}: QR links ${p.siteUrl} (siteUrl)`); out.push(p); continue; } + if (p.siteChannel) { notes.push(`${p.id}: archive link via ${p.siteChannel} (pinned)`); out.push(p); continue; } + if (!postNativeId(p)) { + notes.push(`${p.id}: no post id in its url -- QR links the original`); + out.push(p); + continue; + } + if (Date.now() >= until) { + notes.push(`${p.id}: no time left to ask the archive at ${base} -- QR links the original`); + out.push(p); + continue; + } + try { + const slug = await inTime(resolver.find(base, p)); + if (slug === timeUp) { + notes.push(`${p.id}: the archive at ${base} was still being asked when time ran out -- QR links the original`); + out.push(p); + } else if (slug) { + notes.push(`${p.id}: archive link via ${slug}`); + out.push({ ...p, siteChannel: slug }); + } else { + notes.push(`${p.id}: not in the archive at ${base} -- QR links the original`); + out.push(p); + } + } catch (e) { + notes.push(`${p.id}: the archive at ${base} did not answer (${e instanceof Error ? e.message : String(e)}) -- QR links the original`); + out.push(p); + } + } + return { posts: out, notes }; +} diff --git a/umtool/report-to-video/post-links.test.mjs b/umtool/report-to-video/post-links.test.mjs @@ -0,0 +1,327 @@ +// Where a post's QR goes: deck.mjs's pure postQrUrl, the deck's `posts.links` +// setting and the post keys it reads, and post-links.mjs's resolver (with a +// stub archive -- nothing here touches the network). +// +// Run with: pnpm test:scripts +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { createJsonCache } from "./cues.mjs"; +import { + DECK_DEFAULTS, + estimateSchedule, + postArchiveUrl, + postNativeId, + postQrUrl, + validateChrome, + validatePosts, +} from "./deck.mjs"; +import { channelMatchesHandle, createPostChannelResolver, resolvePostLinks } from "./post-links.mjs"; + +const ORIGIN = "https://jasolyzer.pages.dev"; +const PROV = { siteOrigin: ORIGIN, channelSlug: "piratesoftware" }; +const BSKY = { + id: "bs-3l6uxj6esfr2z", platform: "bluesky", handle: "piratesoftware.live", date: "2024-10-19T17:01:17.640Z", + text: "words", url: "https://bsky.app/profile/piratesoftware.live/post/3l6uxj6esfr2z", +}; +const X = { + id: "x-1", platform: "x", handle: "PirateSoftware", date: "2024-10-20", text: "words", + url: "https://x.com/PirateSoftware/status/1847712345678901234", +}; + +// ---- the pure half (deck.mjs) ------------------------------------------------ + +test("postNativeId: a bsky rkey, an X status id, an explicit postId, else null", () => { + assert.equal(postNativeId(BSKY), "3l6uxj6esfr2z"); + assert.equal(postNativeId(X), "1847712345678901234"); + assert.equal(postNativeId({ url: "https://twitter.com/a/status/42/photo/1" }), "42"); + assert.equal(postNativeId({ ...X, postId: "999" }), "999"); + assert.equal(postNativeId({ url: "https://bsky.app/p/1" }), null); + assert.equal(postNativeId({ url: "not a url" }), null); +}); + +test("postArchiveUrl: the site's post modal for <channel>/<id>", () => { + assert.equal( + postArchiveUrl(`${ORIGIN}/`, "piratesoftware-bsky", "3l6uxj6esfr2z"), + "https://jasolyzer.pages.dev/?v=piratesoftware-bsky%2F3l6uxj6esfr2z&vm=post", + ); +}); + +test("postQrUrl: the archive page when the post names its channel, else its own url", () => { + // bsky, with its archive channel: the archive page. + assert.equal( + postQrUrl({ ...BSKY, siteChannel: "piratesoftware-bsky" }, PROV), + "https://jasolyzer.pages.dev/?v=piratesoftware-bsky%2F3l6uxj6esfr2z&vm=post", + ); + // X, the same. + assert.equal( + postQrUrl({ ...X, siteChannel: "piratesoftware-x" }, PROV), + "https://jasolyzer.pages.dev/?v=piratesoftware-x%2F1847712345678901234&vm=post", + ); + // An explicit siteUrl wins over everything but links "original". + assert.equal(postQrUrl({ ...BSKY, siteChannel: "c", siteUrl: "https://elsewhere.example/p" }, PROV), "https://elsewhere.example/p"); + assert.equal(postQrUrl({ ...BSKY, siteUrl: "https://elsewhere.example/p" }, {}), "https://elsewhere.example/p"); + // links "original": today's behaviour, whatever the post names. + assert.equal(postQrUrl({ ...BSKY, siteChannel: "piratesoftware-bsky", siteUrl: "https://e/p" }, PROV, { links: "original" }), BSKY.url); + // No siteOrigin, no channel, or no id: the post's own url. + assert.equal(postQrUrl({ ...BSKY, siteChannel: "piratesoftware-bsky" }, {}), BSKY.url); + assert.equal(postQrUrl(BSKY, PROV), BSKY.url); + assert.equal(postQrUrl({ ...BSKY, url: "https://bsky.app/p/1", siteChannel: "c" }, PROV), "https://bsky.app/p/1"); + // A postId supplies the id a url does not carry. + assert.equal( + postQrUrl({ ...BSKY, url: "https://bsky.app/p/1", siteChannel: "c", postId: "abc" }, PROV), + "https://jasolyzer.pages.dev/?v=c%2Fabc&vm=post", + ); +}); + +test("the schedule's posts carry postQrUrl's link; links \"original\" keeps the post's own", () => { + const render = { fps: 30, transition: 0.5, chrome: { engine: "hyperframes", layout: "deck", deck: { posts: { layout: "feed" } } } }; + const timeline = [{ type: "clip", id: "c01", video: "v1", start: 0, end: 10, date: "2024-10-01" }]; + const posts = [{ ...BSKY, siteChannel: "piratesoftware-bsky" }, X]; + const s = estimateSchedule({ render, provenance: PROV, timeline, posts }); + const by = Object.fromEntries(s.posts.map((p) => [p.id, p])); + assert.equal(by[BSKY.id].qrUrl, "https://jasolyzer.pages.dev/?v=piratesoftware-bsky%2F3l6uxj6esfr2z&vm=post"); + assert.equal(by[BSKY.id].url, BSKY.url, "the post's own link stays beside it"); + assert.equal(by[X.id].qrUrl, X.url, "a post with no archive channel links its original"); + const orig = { ...render, chrome: { ...render.chrome, deck: { posts: { layout: "feed", links: "original" } } } }; + const o = estimateSchedule({ render: orig, provenance: PROV, timeline, posts }); + assert.ok(o.posts.every((p) => p.qrUrl === p.url)); +}); + +test("posts.links: archive by default; archive or original, nothing else", () => { + assert.equal(DECK_DEFAULTS.posts.links, "archive"); + assert.deepEqual(validateChrome({ engine: "hyperframes", layout: "deck", deck: { posts: { links: "original" } } }, { width: 1920, height: 1080 }), []); + assert.deepEqual(validateChrome({ engine: "hyperframes", layout: "deck", deck: { posts: { links: "archive" } } }, { width: 1920, height: 1080 }), []); + const bad = validateChrome({ engine: "hyperframes", layout: "deck", deck: { posts: { links: "platform" } } }, { width: 1920, height: 1080 }); + assert.ok(bad.some((e) => /posts\.links must be "archive" or "original"/.test(e)), bad.join("; ")); +}); + +test("validatePosts: siteChannel, siteUrl and postId are optional and checked", () => { + assert.deepEqual(validatePosts([{ ...BSKY, siteChannel: "piratesoftware-bsky", siteUrl: "https://a.example/p", postId: "3l6uxj6esfr2z" }]), []); + const errors = validatePosts([{ ...BSKY, siteChannel: "has space", siteUrl: "ftp://a/p", postId: "a/b" }]); + assert.equal(errors.length, 3, errors.join("; ")); + assert.ok(errors.some((e) => /siteChannel/.test(e))); + assert.ok(errors.some((e) => /siteUrl must be an http\(s\) link/.test(e))); + assert.ok(errors.some((e) => /postId/.test(e))); + assert.ok(validatePosts([{ ...BSKY, siteChannel: 7 }]).some((e) => /siteChannel/.test(e))); +}); + +// ---- the resolver (post-links.mjs) ------------------------------------------- + +/** A stub archive: corpus.json plus each channel's posts manifest, as getJson answers them. */ +function archive(channels, { failing = new Set() } = {}) { + const docs = new Map(); + docs.set(`${ORIGIN}/corpus.json`, { + channels: channels.map((c) => ({ + slug: c.slug, name: c.name ?? c.slug, videoCount: 0, + ...(c.ids ? { postCount: c.ids.length, manifests: { posts: `${ORIGIN}/posts/${c.slug}/manifest.json` } } : { manifests: {} }), + })), + }); + for (const c of channels) { + if (c.ids) docs.set(`${ORIGIN}/posts/${c.slug}/manifest.json`, { slugToPage: Object.fromEntries(c.ids.map((id) => [id, 0])) }); + } + const asked = []; + const getJson = async (url, { refresh = false } = {}) => { + asked.push({ url, refresh }); + if (failing.has(url)) throw new Error(`GET ${url} -> 503`); + if (!docs.has(url)) throw new Error(`GET ${url} -> 404`); + return docs.get(url); + }; + return { getJson, asked, docs }; +} + +const DECK = { chrome: { engine: "hyperframes", layout: "deck", deck: {} } }; + +test("resolver: finds the channel that keeps the post, and the QR is its archive page", async () => { + const a = archive([ + { slug: "piratesoftware" }, + { slug: "piratesoftware-bsky", name: "piratesoftware.live (BlueSky)", ids: ["3l6uxj6esfr2z", "other"] }, + ]); + const resolver = createPostChannelResolver({ getJson: a.getJson }); + const r = await resolvePostLinks({ posts: [BSKY], provenance: PROV, render: DECK, resolver }); + assert.equal(r.posts[0].siteChannel, "piratesoftware-bsky"); + assert.deepEqual(r.notes, [`${BSKY.id}: archive link via piratesoftware-bsky`]); + assert.equal(postQrUrl(r.posts[0], PROV), "https://jasolyzer.pages.dev/?v=piratesoftware-bsky%2F3l6uxj6esfr2z&vm=post"); + assert.equal(BSKY.siteChannel, undefined, "the manifest's post is not changed"); +}); + +test("resolver: a post the archive does not have links its original, with a note", async () => { + const a = archive([{ slug: "piratesoftware-bsky", ids: ["nope"] }]); + const resolver = createPostChannelResolver({ getJson: a.getJson }); + const r = await resolvePostLinks({ posts: [BSKY], provenance: PROV, render: DECK, resolver }); + assert.equal(r.posts[0].siteChannel, undefined); + assert.deepEqual(r.notes, [`${BSKY.id}: not in the archive at ${ORIGIN} -- QR links the original`]); + // A miss is looked for again in a fresh copy, once. + assert.deepEqual(a.asked.map((x) => x.refresh), [false, false, true, true]); +}); + +test("resolver: an archive that does not answer links the original, says why, and is asked once", async () => { + const a = archive([], { failing: new Set([`${ORIGIN}/corpus.json`]) }); + const resolver = createPostChannelResolver({ getJson: a.getJson }); + const r = await resolvePostLinks({ posts: [BSKY, { ...X }], provenance: PROV, render: DECK, resolver }); + assert.ok(r.posts.every((p) => p.siteChannel === undefined)); + assert.equal(r.notes.length, 2); + assert.match(r.notes[0], /did not answer \(GET .*corpus\.json -> 503\) -- QR links the original/); + // The failure is remembered: the second post asks nothing new without refresh. + assert.equal(a.asked.filter((x) => !x.refresh).length, 1); +}); + +test("resolver: several channels hold the id -- the one whose name carries the handle wins", async () => { + const a = archive([ + { slug: "mirror-bsky", name: "a mirror (BlueSky)", ids: ["3l6uxj6esfr2z"] }, + { slug: "piratesoftware-bsky", name: "piratesoftware.live (BlueSky)", ids: ["3l6uxj6esfr2z"] }, + ]); + const resolver = createPostChannelResolver({ getJson: a.getJson }); + assert.equal(await resolver.find(ORIGIN, BSKY), "piratesoftware-bsky"); + // With no handle to go by, corpus order. + assert.equal(await resolver.find(ORIGIN, { ...BSKY, handle: "" }), "mirror-bsky"); + assert.equal(channelMatchesHandle({ slug: "piratesoftware-x", name: "PirateSoftware (X)" }, "PirateSoftware"), true); + assert.equal(channelMatchesHandle({ slug: "other", name: "Other" }, "piratesoftware.live"), false); +}); + +test("resolvePostLinks: pinned posts, links \"original\", no siteOrigin, no id", async () => { + const a = archive([{ slug: "piratesoftware-bsky", ids: ["3l6uxj6esfr2z"] }]); + const resolver = createPostChannelResolver({ getJson: a.getJson }); + // Pinned: not looked up. + const pinned = await resolvePostLinks({ + posts: [{ ...BSKY, siteChannel: "pinned-bsky" }, { ...X, siteUrl: "https://e.example/x" }], provenance: PROV, render: DECK, resolver, + }); + assert.deepEqual(pinned.notes, [`${BSKY.id}: archive link via pinned-bsky (pinned)`, `${X.id}: QR links https://e.example/x (siteUrl)`]); + assert.equal(a.asked.length, 0); + // links "original": nothing to find, nothing said. + const orig = await resolvePostLinks({ + posts: [BSKY], provenance: PROV, render: { chrome: { ...DECK.chrome, deck: { posts: { links: "original" } } } }, resolver, + }); + assert.deepEqual(orig, { posts: [BSKY], notes: [] }); + // No archive named: one note for the lot. + const none = await resolvePostLinks({ posts: [BSKY], provenance: {}, render: DECK, resolver }); + assert.equal(none.posts[0], BSKY); + assert.equal(none.notes.length, 1); + assert.match(none.notes[0], /^posts: no provenance\.siteOrigin names an archive -- every post's QR links the original \(set provenance\.siteOrigin/); + // No id in the url. + const noId = await resolvePostLinks({ posts: [{ ...BSKY, url: "https://bsky.app/p/1" }], provenance: PROV, render: DECK, resolver }); + assert.deepEqual(noId.notes, [`${BSKY.id}: no post id in its url -- QR links the original`]); + assert.equal(a.asked.length, 0); +}); + +test("createJsonCache: a refresh refetches once per process, then reads what it fetched", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "post-links-cache-")); + try { + let n = 0; + const fetchImpl = async () => ({ ok: true, status: 200, json: async () => ({ n: ++n }) }); + const url = "https://archive.example/corpus.json"; + // A first process fills the disk cache. + assert.deepEqual(await createJsonCache({ cacheDir: dir, fetchImpl }).getJson(url), { n: 1 }); + // A second reads it from disk; a refresh goes to the network once, and only once. + const c = createJsonCache({ cacheDir: dir, fetchImpl }); + assert.deepEqual(await c.getJson(url), { n: 1 }); + assert.deepEqual(await c.getJson(url, { refresh: true }), { n: 2 }); + assert.deepEqual(await c.getJson(url, { refresh: true }), { n: 2 }); + assert.deepEqual(await c.getJson(url), { n: 2 }); + // A window lets a long-lived reader refresh again once it has passed. + const w = createJsonCache({ cacheDir: dir, fetchImpl, refreshAfterMs: 0 }); + assert.deepEqual(await w.getJson(url, { refresh: true }), { n: 3 }); + assert.deepEqual(await w.getJson(url, { refresh: true }), { n: 4 }); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +// ---- the failure memo, the deadline, what is not looked up ------------------- + +test("failure memo: an archive that hangs costs each URL one try per build, across both passes and every post", async () => { + // corpus.json is cached (the cached pass answers); every fresh read and the + // uncached posts manifest fail, as an archive that connects and stalls would. + const a = archive([{ slug: "piratesoftware-bsky", name: "piratesoftware.live (BlueSky)", ids: ["other"] }]); + const manifest = `${ORIGIN}/posts/piratesoftware-bsky/manifest.json`; + const asked = []; + const getJson = async (url, { refresh = false } = {}) => { + asked.push({ url, refresh }); + if (url.endsWith("/corpus.json") && !refresh) return a.docs.get(url); + throw new Error(`GET ${url} -> timed out`); + }; + const resolver = createPostChannelResolver({ getJson }); + const posts = [BSKY, { ...BSKY, id: "b2", url: "https://bsky.app/profile/piratesoftware.live/post/rk2" }, { ...BSKY, id: "b3", url: "https://bsky.app/profile/piratesoftware.live/post/rk3" }]; + const r = await resolvePostLinks({ posts, provenance: PROV, render: DECK, resolver }); + assert.ok(r.posts.every((p) => p.siteChannel === undefined)); + assert.ok(r.notes.every((n) => /did not answer .* -- QR links the original/.test(n)), r.notes.join("\n")); + // The manifest: asked once, by the first post's cached pass, never again. + assert.deepEqual(asked.filter((x) => x.url === manifest), [{ url: manifest, refresh: false }]); + // corpus.json fresh: asked once, by the first post's second pass. + assert.equal(asked.filter((x) => x.url.endsWith("/corpus.json") && x.refresh).length, 1); +}); + +test("failure memo: a server's resolver asks again once refreshAfterMs has passed, and a success re-arms it", async () => { + let t = 0; + let down = true; + const a = archive([{ slug: "piratesoftware-bsky", name: "piratesoftware.live (BlueSky)", ids: ["3l6uxj6esfr2z"] }]); + let asks = 0; + const getJson = async (url, opts) => { + asks += 1; + if (down) throw new Error(`GET ${url} -> 503`); + return a.getJson(url, opts); + }; + const resolver = createPostChannelResolver({ getJson, refreshAfterMs: 600_000, now: () => t }); + await assert.rejects(resolver.find(ORIGIN, BSKY), /503/); + assert.equal(asks, 1); + // Within the window: refused from the memo, nothing asked, even after the archive is back. + down = false; + t = 599_999; + await assert.rejects(resolver.find(ORIGIN, BSKY), /503/); + assert.equal(asks, 1); + // Past it: asked, found. + t = 600_000; + assert.equal(await resolver.find(ORIGIN, BSKY), "piratesoftware-bsky"); + // The success cleared the memo; a new failure is remembered from its own time. + down = true; + const before = asks; + await assert.rejects(resolver.find(ORIGIN, { ...BSKY, url: "https://bsky.app/profile/piratesoftware.live/post/zz" }), /503/); + t = 600_001; + await assert.rejects(resolver.find(ORIGIN, { ...BSKY, url: "https://bsky.app/profile/piratesoftware.live/post/zz" }), /503/); + assert.equal(asks, before + 1, "one failed ask, then the memo"); +}); + +test("resolvePostLinks: a deadline bounds the whole call; the rest keep their own links", async () => { + const resolver = { find: () => new Promise(() => {}) }; // an archive that never answers + const posts = [BSKY, { ...BSKY, id: "b2" }]; + const started = Date.now(); + const r = await resolvePostLinks({ posts, provenance: PROV, render: DECK, resolver, deadlineMs: 50 }); + assert.ok(Date.now() - started < 1000); + assert.ok(r.posts.every((p) => p.siteChannel === undefined)); + assert.match(r.notes[0], /still being asked when time ran out -- QR links the original/); + assert.match(r.notes[1], /no time left to ask the archive/); +}); + +test("resolvePostLinks: hidden posts and a cut without the deck are not looked up", async () => { + const asked = []; + const resolver = { find: async (o, p) => (asked.push(p.id), "piratesoftware-bsky") }; + const r = await resolvePostLinks({ posts: [{ ...BSKY, hide: true }, X], provenance: PROV, render: DECK, resolver }); + assert.deepEqual(asked, [X.id]); + assert.equal(r.posts[0].siteChannel, undefined); + assert.equal(r.notes.length, 1); + const off = await resolvePostLinks({ posts: [BSKY], provenance: PROV, render: {}, resolver }); + assert.deepEqual(off, { posts: [BSKY], notes: [] }); + assert.deepEqual(asked, [X.id]); +}); + +test("validatePosts: null is unset for siteChannel, siteUrl and postId, as the README's example writes them", () => { + assert.deepEqual(validatePosts([{ ...BSKY, attachTo: null, hide: false, siteChannel: null, siteUrl: null, postId: null }]), []); + assert.equal(postQrUrl({ ...BSKY, siteChannel: null, siteUrl: null, postId: null }, PROV), BSKY.url); +}); + +test("createJsonCache: callers asking for one URL at once share one fetch", async () => { + let n = 0; + let release; + const gate = new Promise((r) => { release = r; }); + const fetchImpl = async () => { n += 1; await gate; return { ok: true, status: 200, json: async () => ({ n }) }; }; + const c = createJsonCache({ cacheDir: null, fetchImpl }); + const both = Promise.all([c.getJson("https://a.example/x"), c.getJson("https://a.example/x", { refresh: true })]); + release(); + assert.deepEqual(await both, [{ n: 1 }, { n: 1 }]); + assert.equal(n, 1); + // Once it has landed, a refresh is a new fetch only when allowed (once per process here: not again). + assert.deepEqual(await c.getJson("https://a.example/x", { refresh: true }), { n: 1 }); +});