Archilyzer · Source

archilyzer

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

commit 92cc9d6d94a6ff1c069d9109091b6b7e8a6e1750
parent 1e7f5c9847a7c190f28b7f2897d8fa0a01a9fbbc
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Sat, 26 Sep 2026 13:09:16 -0400

plans: FACTS — the sw.js-copy and compose-output-swap workarounds superseded by L1 (link-safe playwright config + _publicFile); release 10 facts for S4 (renderSvgPng, the glyph-outline lockup and its fontTools pin, FONTCONFIG_FILE scope, opt-in render.brand + end card, the ImageMagick date-chunk identity method), L1 (subs 404/retry/settled, the empty-scope rule, the homepage's order) and L2 (isSoftBlock and its consumers, unprobed = unverified, the 60 s boot wait, the hung mount)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

Diffstat:
Mplans/FACTS.md | 149++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
1 file changed, 145 insertions(+), 4 deletions(-)

diff --git a/plans/FACTS.md b/plans/FACTS.md @@ -968,6 +968,9 @@ client) and `mcp/src/search.ts` (server). MCP has 11 tools (`mcp/src/server.ts:1 worktree then runs against a hub, not a site. Run the export suites first, or restore the site fixture (`corpus.json` from the duplicates-page worktree, remove the hub files, recopy `site-sw.js`) before them. All of those paths are gitignored, so nothing shows in `git status`. +**Since release 10 L1 (2026-09-26)** the compose writes land in the worktree's own files, never +through its `export/public` links (`common/bin/_publicFile.ts`), so the restore is the per-path +seed again (`plans/tools/implementer-rules.md`); the export config re-copies `sw.js` itself. - `editor/playwright.config.ts` — editor on `PORT ?? 3011`, export on `EXPORT_PORT ?? 3010`. `E2E_MODE=start` → `pnpm start:test`, otherwise `pnpm dev:test` (`:10-11`). @@ -6467,9 +6470,15 @@ Line numbers are `plans/FACTS.md` lines at `e172749b`, before this record's in-p C1b): `export/playwright.config.ts` copies `service-worker/site-sw.js` to `public/sw.js` when the config loads, and in a worktree that path is a link to the primary's generated hub `sw.js`. A site-mode export e2e in a worktree therefore overwrites the primary's hub service worker (10,027 → - 10,043 B). Replace `export/public/sw.js` with a copy before any export e2e in a worktree; the - proper fix (unlink before copy in the config) is a release-10 low. `build-hub` regenerates the + 10,043 B). ~~Replace `export/public/sw.js` with a copy before any export e2e in a worktree; the + proper fix (unlink before copy in the config) is a release-10 low.~~ `build-hub` regenerates the file, so a deploy is never wrong, only a worktree run in between. + **SUPERSEDED 2026-09-26 (release 10 L1, on `main` since `5c0a6ef9`): do NOT copy `sw.js` any + more.** `export/playwright.config.ts:51-55` `fs.rmSync`s `public/sw.js` before its + `copyFileSync` (`rmSync` reads the path with lstat, so it removes the link, never its target). + Proved on the integrated tree: the export suite ran with the worktree's `sw.js` a LINK (to a + sentinel in the job's scratch) and left the target byte-identical (`release-10.md`, "Integration + (S4 + L1 + L2), as merged"). The per-path seed of the `export/public` links stays. - **The homepage build refuses symlinks that point outside the project**, so a worktree's `homepage/public` data (~60 MB, `homepage-summary.json` + stats pages) must be COPIED from the primary, not linked. @@ -6575,10 +6584,19 @@ Next is 16.2.3 (`export/node_modules/next/package.json`). The plan and every sli build:hub` (`export/e2e-2origin/globalSetup.ts:142`), whose `compose:hub` `writeFile`s `hub-sites.json`, `corpus.json`, `llms.txt`, `robots.txt`, `_headers` and `sw.js` into `paths.exportPublicDir` (`common/bin/compose-hub.ts:79-137`). `writeFile` follows a symlink, so a - worktree with no corpus would overwrite the primary's live hub pool with an empty one. Swap those - links for plain copies before the run and relink after (every brand slice did; the primary's files + worktree with no corpus would overwrite the primary's live hub pool with an empty one. ~~Swap those + links for plain copies before the run and relink after~~ (every brand slice did; the primary's files kept their size and mtime). `rm` of the no-index `hub-summary.json` (`:59-62`) removes the link, not the target. + **SUPERSEDED 2026-09-26 (release 10 L1, on `main` since `5c0a6ef9`): do NOT swap the compose + outputs for copies any more.** `common/bin/_publicFile.ts` — `writePublicFile` / `copyPublicFile` + `rm` the path first (`:19-30`), `ownDir` replaces a linked directory with an empty real one + (`:32-39`) — and every public write of `compose-hub.ts` (`:70`, `:106-136`; `sw.js` was already + `rm`-first at `:142`) and `compose-site.ts` (`:78-189`, `:611`, `:743-766`, `:789`, `:820`, `:912`) + goes through them. So a worktree compose replaces each link with its own file and the primary's + target is untouched. Proved on the integrated tree: `e2e:2origin` with `TWO_ORIGIN_REBUILD=1` and + the links left in place (`release-10.md`, "Integration (S4 + L1 + L2), as merged"; L1's own runs + before it). A worktree run leaves plain files where it wrote; the next per-path seed relinks them. - **`pnpm wt` port blocks are `git worktree list` order**, which is the admin dirs under `.git/worktrees/` sorted by name (`scripts/worktree.mjs:101-109`). Adding `brand-found-line` put it at #1 ahead of `diet-series`, which moved to #2 (reconfirms the release 9 fact above); removing @@ -6596,3 +6614,126 @@ Next is 16.2.3 (`export/node_modules/next/package.json`). The plan and every sli families are gone; the hub opens on the dark base in Signal (`export/app/layout.tsx:36-44`, `defaultBase: "dark", accent: DEFAULT_ACCENT`), server-rendered, and `--chart-3` is fixed per base (a violet), no longer the archilyzer block's green. Historical, left in place. + +## Release 10 — brand S4, lows L1 + L2 (verified 2026-09-26, `main` @ `5c0a6ef9`) + +Merged S4 `dcb04f61` → L2 `41ddc382` → L1 `5c0a6ef9`, then gated together on `brand/found-line` +(`release-10.md`, "Integration (S4 + L1 + L2), as merged"). Records: `brand-and-themes.md` "Slice +S4, as shipped"; `release-10.md` "Slice L2 / L1, as shipped". Every anchor below was re-read on +`5c0a6ef9`. + +### S4 — Archilyzer Media + +- **`renderSvgPng(svg, {width, height})` renders ANY SVG to a PNG** (`common/lib/brandIcons.ts:31-42`: + satori lays out one `<img>` of the SVG as a data URI, resvg rasterises it). `renderIconPng` + (`:44-50`) is now a call to it, and its bytes did not change (the nine icon PNGs checked, S4). + It runs under tsx, so a CLI renders brand PNGs with no `rsvg-convert` (`common/bin/brand-media.ts`). +- **The Archilyzer Media lockup's letters are outlines, not text** (`mediaLockupSvg`, + `common/lib/brandMedia.ts:232`), so drawing it needs no font anywhere (rsvg, satori, ImageMagick). + The outlines are the generated `common/lib/brandMediaGlyphs.ts` (do-not-edit header; + `common/bin/gen-media-glyphs.py`, a fontTools instancer at wdth 118, reading the Archivo umtool + vendors). `MEDIA_GLYPH_SOURCE` (`:28-33`) records the font's sha256 and **the fontTools that made + it, `4.65.0`**. The parity test (`brandMedia.test.ts:147-165`) diffs the file against a fresh + `--stdout` run, and SKIPS, saying why, without python3 + fontTools or under another fontTools + version (another version may round the instancer differently; that is not a stale file). It ran, + not skipped, on this machine. +- **The spec's 5.334 em wordmark width is unkerned.** Archivo's GPOS kerns `rc` −14 and `ze` −20 + units, which gives 5.300 em. `mediaLockupGeometry` (`brandMedia.ts:125`) keeps the spec's width + and spreads the difference as one adjustment per gap (the canvas's `textLength` + + `lengthAdjust="spacing"`); MEDIA at `MEDIA_RATIO` 1.035 (`:72`) then gets 0.3002 em tracking. +- **`FONTCONFIG_FILE` is set only on the brand preset's own `magick` / `rsvg-convert` children**, + never process-wide: `childOpts(render, opts)` (`umtool/report-to-video/brand.mjs:161-164`) returns + `opts` itself when the manifest has no `render.brand`, else the same with + `FONTCONFIG_FILE = fonts/fonts.conf` (`:44`). Every such call site passes through it + (`brand-cards.mjs:51,63,174,211,293`; `render-cards.mjs:241,248,308,318`). ffmpeg's `drawtext` + takes the vendored face as a `fontfile=` path (`MONO_FONT_FILE`, `:45`), which is why the one + static Plex Mono is vendored. The fonts (`umtool/report-to-video/fonts/`, OFL, unmodified: Plex + carries a Reserved Font Name) are NOT installed system-wide. +- **`render.brand` is opt-in, and no brand means the same object back.** `resolveBrandRender` + (`brand.mjs:97`), `brandManifest` (`:122`), `childOpts` and `brandFaces` each return their input + itself when there is no `render.brand`. `brandManifest` is called at the END of `selectVariant` + (`build-video.mjs:155`), the one door the build, `verify-build.mjs:30`, `compose-chrome.mjs:539` + and umtool's export go through, and by `render-cards.mjs:1532`. **Under a brand it appends an end + card** (`{type:"card", id:"end", style:"end", hideRail:true, chapter:"End"}`, 20 s by default, + `END_CARD_DEFAULT_SECONDS`, `:48`), unless the timeline has its own `style:"end"` card or + `render.endCard` is `false` / `null` (`endCardConfig`, `:83`; `null` is off so resolving twice keeps + it off). A timeline entry already named `end` is an error. umtool's project page lists the raw + timeline, so the appended card is built and chaptered but not listed there. +- **Byte identity of a report-to-video render: ImageMagick's PNG date chunks are the only noise.** + ImageMagick 7 stamps the wall clock into three `tEXt` chunks (`date:create`, `date:modify`, + `date:timestamp`) of every PNG it writes and ignores `SOURCE_DATE_EPOCH`, so the UNCHANGED code + run twice differs in exactly those chunks (S4's control: 334 files, 201 raw-identical, 133 + date-chunk-only, 0 different). Compare PNGs with those chunks and any `tIME` dropped and every + other byte (IHDR, IDAT) kept; x264 mp4s here are raw-identical run to run. The method is S4's + `$T/s4-idiff.mjs` (job scratch, not in the repo); rewrite it from this paragraph if needed. +- **umtool's new-project form offers the brand from data**: `BRAND_CHOICES` in + `umtool/report-to-video/brand-ids.mjs` (no imports: the registry is read by client components), + declared on the report-video kind (`umtool/lib/projects/kinds.mjs:83`), rendered by + `components/NewProjectMenu.tsx:155-170`; `umtool new --brand archilyzer-media`. The server page + passes the registry to the client form, so the served `/` HTML carries the choice's label — a + curl check tells whether a running umtool has the build. + +### L1 — hub lows + +- **A member's subs manifest: 404 = empty, one retry, never fails the archive** + (`common/components/SearchDataContext.tsx:343-378`, the rule at `:415-425`). A 404 + (`ArchiveHttpError` status 404) resolves at once to an empty manifest, one request. Any other error + (network, 5xx, or a 404 served WITHOUT CORS — `serve` on a self-hosted archive does that, and the + browser reports it as a network error) is retried once (`retry: 1`) and then counts as SETTLED: + the archive is `ready`, its videos are searched, only its live chat is missing, and nothing says + so (a new low). Official members send CORS on their 404s (checked live on + `https://jeralyzer.pages.dev/subs/<missing>.json`). +- **An empty scope is never ready unless the provider is `progressive`** + (`SearchDataContext.tsx:456-466`: `allSettled = (progressive || scoped.length > 0) && …`). + `MultiSiteDataProvider` has two users: the hub's front page (`HubHome.tsx:40`, `progressive`: + settles on an empty scope, results read "No videos match", the chips say why) and `/ask` + (`AskHub.tsx:44`, not progressive: with every chip off the composer stays disabled and shows + `NO_ARCHIVES_IN_SCOPE`, `export/app/ask/hubScopeCopy.ts:7`, `data-testid="ask-blocked"`, + `role="status"`; the [bracketed] words render as a link to `/`). The same rule closes the instant + before the hub's list arrives. A hub with zero archives therefore reads "Loading transcripts…" + forever on `/ask` (a new low). Both surfaces take their list from `useFederatedSites()` + (`export/app/components/hub/useHubSites.ts:49`), so an archive off on the front page is not fetched + on `/ask`. +- **The official instances' order is the homepage's key and tiebreak.** The homepage sorts its + public sites by `transcribed.total` descending, then `siteTitle.localeCompare` + (`common/lib/homepageSummary.ts:437-441`); `hub-summary.json` keeps that order, and + `officialInstances(members, summary)` (`common/lib/hubSummary.ts:190-214`) sorts the hub's members + by their index in `summary.sites` (tiebreak: the `hub-sites.json` order), then appends any member + the summary does not name. Colour: the site's accent, else the summary's, else + `seriesColor(index in the summary)` — the homepage card's colour; an unnamed member takes + `seriesColor(listed + k)`, so it never shares a named one's. `useHubSites` lists the official + instances only once `/hub-summary.json` has SETTLED (found, missing or unreadable; + `useHubSummary.ts:45`, `networkMode: "always"`), so they never reorder a moment later. The order + is applied in the browser: `hub-sites.json` on disk is unchanged. + +### L2 — runner lows + +- **YouTube's soft block is `isSoftBlock`, `/isn['’]?t available,? try again later/i`** + (`common/lib/availability.ts:179-181`), checked FIRST by `parseUnavailableFromStderr` (→ `error`, + `:189`) and `classifyDownloadFailure` (→ `rate_limit`, `:271`, even over a per-video class the + caller already holds). Its consumers are the existing `rate_limit` paths: the manual download batch + (cooldown + abort), the auto runner (`common/jobs/unitOutcome.ts:63-69`: platform backoff, the + video deferred), `fetchWindowManaged.ts:246-250`, the metadata scan (`metadataScan.ts:492-500`, + block kind `"soft-block"`, which changes only the log wording; the one cookie retry is kept), a + metadata prefetch (`downloadOneManaged.ts:722-745`: a `rate_limit` prefetch ends the video with + no attempt 1), and the availability check (`checkAvailability.ts:~300`: the first `rate_limit` + probe sets `blocked`, the rest are skipped, one `recordDownloadBackoff`, `:337`). "Try again + later" is what marks it: a bare "Video unavailable", "…removed by the uploader", a terminated + account, a ToS removal, Rumble's `HTTP Error 410: Gone` and "This content isn't available." with + no retry advice stay `deleted` / per_video. +- **`verifyBeforeClean`: a tier C suspect the check did not probe is `unverified`** + (`common/controller/verifyBeforeClean.ts:186-211`; `probedIds` from `checkAvailability.ts:92`). + Two ways to be unprobed: the check was blocked before it, or it has no `webpage_url`. It is never + judged on its old `availability.json`, which could say `public` and clear an irreversible audio + delete. It is skipped with no marker and retried next sweep. +- **The boot pass waits at most `STORAGE_PASS_WAIT_MS` = 60 s for the storage pass** + (`common/jobs/bootQueuedJobs.ts:128`; `waitForStoragePass` `:130-169` races a DERIVED promise, so + the storage pass is never cancelled and a throw counts as finished; `settleAfterStoragePass` + `:173`, called from `editor/instrumentation.ts:138`). Timeout line: `[boot] storage pass still + running after 60 s; settling queued jobs without it (…)`, then `[boot] storage pass finished N s + after …` when it ends. +- **A hung mount still holds the re-queues behind it.** After the timeout, a job re-queued for a + channel on an UNMOUNTED drive is refused at once (the media guard's `stat` gets ENOENT). On a HUNG + mount the guard's own `stat` (`common/lib/channelMedia.ts`) hangs on the same syscall, and since + re-queues run one at a time, every re-queue after it stays `queued` until the mount answers. + Cancels all run first. Left, not fixed (`bootQueuedJobs.ts:113-125`).