Archilyzer · Source

archilyzer

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

commit ac6a1878c3db30a4275f12008dcfe2c4e7287cf2
parent a76f297932e7dda61a05555eff75447f676eb90b
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Mon, 28 Sep 2026 12:03:02 -0400

umtool: the thumb manifests record paths relative to their roots

A one-off rewrote thumb-manifest.json and thumb-accepted.json: `out`
relative to ~/reports/quartering-uh-song, `bg` relative to the song data
dir (26 paths; the diff is those lines only). resolveInRoots binds a
relative path to SONG_REPORTS first, so every out resolves to the same
absolute file and thumbView / thumbFor give the same labels as before.
make-thumb.mjs writes out: relTo(SONG_REPORTS, OUT), bg: relTo(SONG_DATA,
BG); accept-thumb.mjs (the route hands it an absolute file) records out
the same way. SONG_REPORTS moves into song/paths.mjs beside relTo -- the
song scripts import only siblings -- and lib/paths.mjs re-exports it. The
fixture's accepted cover carries the relative form, so the suite reads
both.

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

Diffstat:
Mumtool/app/api/browse/thumbs/route.ts | 5+++--
Mumtool/e2e/fixtures/make-fixture.mjs | 6+++++-
Mumtool/lib/browse.ts | 7++++---
Mumtool/lib/paths.mjs | 12+++++-------
Mumtool/song/accept-thumb.mjs | 8+++++++-
Mumtool/song/make-thumb.mjs | 10++++++++--
Mumtool/song/paths.mjs | 46++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/song/thumb-accepted.json | 16++++++++--------
Mumtool/song/thumb-manifest.json | 36++++++++++++++++++------------------
9 files changed, 104 insertions(+), 42 deletions(-)

diff --git a/umtool/app/api/browse/thumbs/route.ts b/umtool/app/api/browse/thumbs/route.ts @@ -66,8 +66,9 @@ export async function POST(request: Request) { } // `file` names WHICH RENDERED VARIANT is being accepted, and accept-thumb - // takes it verbatim as the new `out`. It must resolve inside the roots, and - // it is passed absolute so the script cannot resolve it against its own cwd. + // records it as the new `out` (relative to SONG_REPORTS when inside it). It + // must resolve inside the roots, and it is passed absolute so the script + // cannot resolve it against its own cwd. const argv = [process.execPath, path.join(SONG_CODE, "accept-thumb.mjs"), name]; if (file) { const abs = resolveInRoots(file); diff --git a/umtool/e2e/fixtures/make-fixture.mjs b/umtool/e2e/fixtures/make-fixture.mjs @@ -543,12 +543,16 @@ const thumbManifest = { used: [], }; writeFileSync(path.join(dest, "code", "thumb-manifest.json"), JSON.stringify(thumbManifest, null, 1)); +// The accepted alpha-c records its `out` RELATIVE to the reports tree, as the +// tracked manifests do since release 12 (make-thumb and accept-thumb write it +// that way), while the run log above keeps the older absolute form -- so the +// suite reads both. deck.spec's "serves the ACCEPTED cover" is the relative one. writeFileSync( path.join(dest, "code", "thumb-accepted.json"), JSON.stringify( { version: 1, - thumbs: { "alpha-c": thumbManifest.thumbs["alpha-c"] }, + thumbs: { "alpha-c": { ...thumbManifest.thumbs["alpha-c"], out: path.join("thumbs", "alpha-c.jpg") } }, used: [1, 2, 3, 4].map(slot), }, null, diff --git a/umtool/lib/browse.ts b/umtool/lib/browse.ts @@ -250,9 +250,10 @@ async function acceptedDoc(): Promise<ThumbDoc> { export async function thumbFor(id: string): Promise<string | null> { const accepted = acceptedFor(await acceptedDoc(), id, thumbAliasesFor(id)); if (accepted?.entry?.out) { - // `out` is an ABSOLUTE path recorded on the machine that rendered it, and - // the poster route resolves a thumb against SONG_REPORTS -- so anything - // outside that root (a moved tree, a stale path) falls through rather than + // `out` is recorded relative to SONG_REPORTS (since release 12; older + // entries hold an ABSOLUTE path from the machine that rendered it), and the + // poster route resolves a thumb against SONG_REPORTS -- so anything outside + // that root (a moved tree, a stale path) falls through rather than // producing a join that silently points at the wrong file. const abs = resolveInRoots(accepted.entry.out); if (abs && (abs === SONG_REPORTS || abs.startsWith(SONG_REPORTS + path.sep))) { diff --git a/umtool/lib/paths.mjs b/umtool/lib/paths.mjs @@ -7,9 +7,12 @@ import { existsSync } from "node:fs"; import os from "node:os"; import path from "node:path"; -import { SONG_DATA } from "../song/paths.mjs"; +import { SONG_DATA, SONG_REPORTS } from "../song/paths.mjs"; -export { SONG_DATA }; +// SONG_REPORTS -- the um-song deliverables tree, and BROWSE_ROOT's parent -- is +// defined in song/paths.mjs beside SONG_DATA, because the song scripts that +// record paths relative to it (make-thumb, accept-thumb) import only siblings. +export { SONG_DATA, SONG_REPORTS }; // Derived output (sliced mp3s, waveform peaks, the project index). Lives with // the data, not in the repo, and is safe to delete at any time. @@ -20,11 +23,6 @@ export const CACHE_DIR = path.join(SONG_DATA, ".cache", "umtool"); // to its logs. export const SONG_SCRATCH = path.dirname(SONG_DATA); -/** The um-song deliverables tree. Also BROWSE_ROOT's parent. */ -export const SONG_REPORTS = path.resolve( - process.env.SONG_REPORTS_DIR ?? path.join(os.homedir(), "reports", "quartering-uh-song"), -); - // --------------------------------------------------------------------------- // REPORTS_ROOT -- the tree every PROJECT hangs off. // diff --git a/umtool/song/accept-thumb.mjs b/umtool/song/accept-thumb.mjs @@ -17,6 +17,7 @@ // means candidates are free and the guarantee still holds where it matters. import { readFileSync, writeFileSync, existsSync } from "node:fs"; import path from "node:path"; +import { SONG_REPORTS, relTo } from "./paths.mjs"; const DIR = path.resolve(path.dirname(new URL(import.meta.url).pathname)); const MF = path.join(DIR, "thumb-manifest.json"); @@ -87,7 +88,12 @@ if (clash.length) { process.exit(1); } -const out = arg ?? entry.out; +// Recorded relative to SONG_REPORTS when it lies inside it, as make-thumb.mjs +// records it: this file is tracked, and /api/browse/thumbs hands over an +// ABSOLUTE `file` (resolved inside the roots), which would otherwise put one +// machine's home directory back into it. The readers resolve a relative `out` +// against SONG_REPORTS first (lib/paths.mjs resolveInRoots). +const out = relTo(SONG_REPORTS, arg ?? entry.out); if (arg && !existsSync(arg)) console.log(`note: ${arg} does not exist yet`); accepted.thumbs[cmd] = { ...entry, out }; accepted.used = [...new Set(Object.values(accepted.thumbs).flatMap(cornersOf))]; diff --git a/umtool/song/make-thumb.mjs b/umtool/song/make-thumb.mjs @@ -13,7 +13,7 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync } from "node import { execFileSync } from "node:child_process"; import path from "node:path"; -import { SONG_DATA } from "./paths.mjs"; +import { SONG_DATA, SONG_REPORTS, relTo } from "./paths.mjs"; const DIR = path.resolve(path.dirname(new URL(import.meta.url).pathname)); const [NAME, BG, CSV, OUT] = process.argv.slice(2); const BG_TIME = process.argv[6] !== undefined ? Number(process.argv[6]) : null; @@ -255,7 +255,13 @@ manifest.thumbs[NAME] = { // left with a frame nobody could re-cut -- the obvious guess, that a cover's // background is its own song video, is false for all four. Optional in exactly // the way `corners[].crop` is: absent means "not recorded", not "no background". - out: OUT, bg: BG, bgAt: +bgAt.toFixed(2), + // + // Both paths are recorded RELATIVE TO THEIR ROOT, because this file is tracked + // and an absolute path is one machine's home directory: `out` to SONG_REPORTS + // (the um-song deliverables tree, which lib/paths.mjs resolveInRoots binds a + // relative path to first), `bg` to SONG_DATA (the song's bulk data; nothing + // reads it back yet). A path outside its root stays absolute. + out: relTo(SONG_REPORTS, path.resolve(OUT)), bg: relTo(SONG_DATA, path.resolve(BG)), bgAt: +bgAt.toFixed(2), // THE BOX IS RECORDED NOW. It used to be computed and thrown away -- the // script kept only p[3] and p[4] -- so a corner could be cut and never cut // again the same way, which is the state two accepted corners are in today. diff --git a/umtool/song/paths.mjs b/umtool/song/paths.mjs @@ -41,3 +41,49 @@ const realOrAsGiven = (p) => { }; export const SONG_DATA = realOrAsGiven(requested); + +/** + * The um-song deliverables tree (thumbs/, videos/, the .plan.json files). + * Defined HERE rather than in lib/paths.mjs, which re-exports it, because the + * song scripts import only their siblings: the e2e fixture copies this + * directory's .mjs files into its own code/ and runs them from there, where + * ../lib does not exist. + */ +export const SONG_REPORTS = path.resolve( + process.env.SONG_REPORTS_DIR ?? path.join(os.homedir(), "reports", "quartering-uh-song"), +); + +/** + * `p` relative to `root` when it lies inside it, else `p` unchanged. + * + * For the paths a song script RECORDS in a tracked JSON file + * (thumb-manifest.json, thumb-accepted.json): an absolute path there is one + * machine's home directory. The readers take either form -- lib/paths.mjs + * resolveInRoots binds a relative path to the FIRST root, SONG_REPORTS. Both + * sides are compared as given AND through realpath (of the deepest part that + * exists, so a file not written yet still counts), so a path spelled via the + * ~/.local/share symlink is inside the realpath'd SONG_DATA. A relative `p` is + * returned as it is. + */ +export function relTo(root, p) { + if (typeof p !== "string" || !path.isAbsolute(p)) return p; + const real = (x) => { + const rest = []; + for (let cur = path.resolve(x); ; cur = path.dirname(cur)) { + try { + return path.join(realpathSync(cur), ...rest); + } catch { + if (path.dirname(cur) === cur) return path.resolve(x); + rest.unshift(path.basename(cur)); + } + } + }; + const roots = [...new Set([path.resolve(root), real(root)])]; + const paths = [...new Set([path.resolve(p), real(p)])]; + for (const r of roots) { + for (const abs of paths) { + if (abs.startsWith(r + path.sep)) return path.relative(r, abs); + } + } + return p; +} diff --git a/umtool/song/thumb-accepted.json b/umtool/song/thumb-accepted.json @@ -2,8 +2,8 @@ "version": 1, "thumbs": { "yoshi": { - "out": "/home/user/reports/quartering-uh-song/thumbs/yoshi-c.jpg", - "bg": "/home/user/.claude/jobs/efbe67a7/tmp/song/intro/yoshi-bg.mp4", + "out": "thumbs/yoshi-c.jpg", + "bg": "intro/yoshi-bg.mp4", "bgAt": 16.79, "corners": [ { @@ -53,8 +53,8 @@ ] }, "mario-rpg": { - "out": "/home/user/reports/quartering-uh-song/thumbs/rpg-c.jpg", - "bg": "/home/user/.claude/jobs/efbe67a7/tmp/song/intro/smrpg-bg.mp4", + "out": "thumbs/rpg-c.jpg", + "bg": "intro/smrpg-bg.mp4", "bgAt": 63.36, "corners": [ { @@ -104,8 +104,8 @@ ] }, "metal-slug": { - "out": "/home/user/reports/quartering-uh-song/thumbs/ms2-c.jpg", - "bg": "/home/user/.claude/jobs/efbe67a7/tmp/song/intro/ms2-play-bg.mp4", + "out": "thumbs/ms2-c.jpg", + "bg": "intro/ms2-play-bg.mp4", "bgAt": 82.3, "corners": [ { @@ -155,8 +155,8 @@ ] }, "mortal-kombat-fatality": { - "out": "/home/user/reports/quartering-uh-song/thumbs/mk-c-fight.jpg", - "bg": "/home/user/.claude/jobs/efbe67a7/tmp/song/intro/mk-bg-fatal3.mp4", + "out": "thumbs/mk-c-fight.jpg", + "bg": "intro/mk-bg-fatal3.mp4", "bgAt": 53.66, "corners": [ { diff --git a/umtool/song/thumb-manifest.json b/umtool/song/thumb-manifest.json @@ -2,8 +2,8 @@ "version": 1, "thumbs": { "mortal-kombat": { - "out": "/home/user/reports/quartering-uh-song/thumbs/mortal-kombat.jpg", - "bg": "/home/user/.claude/jobs/efbe67a7/tmp/song/intro/mk-bg.mp4", + "out": "thumbs/mortal-kombat.jpg", + "bg": "intro/mk-bg.mp4", "bgAt": 134.4, "corners": [ { @@ -53,8 +53,8 @@ ] }, "mortal-kombat-fatality": { - "out": "/home/user/reports/quartering-uh-song/thumbs/mk-c-fight.jpg", - "bg": "/home/user/.claude/jobs/efbe67a7/tmp/song/intro/mk-bg-fatal3.mp4", + "out": "thumbs/mk-c-fight.jpg", + "bg": "intro/mk-bg-fatal3.mp4", "bgAt": 53.66, "corners": [ { @@ -104,8 +104,8 @@ ] }, "yoshi": { - "out": "/home/user/reports/quartering-uh-song/thumbs/yoshi-c.jpg", - "bg": "/home/user/.claude/jobs/efbe67a7/tmp/song/intro/yoshi-bg.mp4", + "out": "thumbs/yoshi-c.jpg", + "bg": "intro/yoshi-bg.mp4", "bgAt": 16.79, "corners": [ { @@ -155,8 +155,8 @@ ] }, "mario-rpg": { - "out": "/home/user/reports/quartering-uh-song/thumbs/rpg-c.jpg", - "bg": "/home/user/.claude/jobs/efbe67a7/tmp/song/intro/smrpg-bg.mp4", + "out": "thumbs/rpg-c.jpg", + "bg": "intro/smrpg-bg.mp4", "bgAt": 63.36, "corners": [ { @@ -206,8 +206,8 @@ ] }, "metal-slug": { - "out": "/home/user/reports/quartering-uh-song/thumbs/ms2-c.jpg", - "bg": "/home/user/.claude/jobs/efbe67a7/tmp/song/intro/ms2-play-bg.mp4", + "out": "thumbs/ms2-c.jpg", + "bg": "intro/ms2-play-bg.mp4", "bgAt": 82.3, "corners": [ { @@ -257,8 +257,8 @@ ] }, "pokemon-catch": { - "out": "/home/user/reports/quartering-uh-song/thumbs/pokemon-a-catch.jpg", - "bg": "/home/user/.claude/jobs/efbe67a7/tmp/song/intro/pkmn-bg-full.mkv", + "out": "thumbs/pokemon-a-catch.jpg", + "bg": "intro/pkmn-bg-full.mkv", "bgAt": 10.22, "corners": [ { @@ -308,8 +308,8 @@ ] }, "pokemon-battle": { - "out": "/home/user/reports/quartering-uh-song/thumbs/pokemon-b-battle.jpg", - "bg": "/home/user/.claude/jobs/efbe67a7/tmp/song/intro/pkmn-bg-full.mkv", + "out": "thumbs/pokemon-b-battle.jpg", + "bg": "intro/pkmn-bg-full.mkv", "bgAt": 43.43, "corners": [ { @@ -359,8 +359,8 @@ ] }, "pokemon-run": { - "out": "/home/user/reports/quartering-uh-song/thumbs/pokemon-d-run.jpg", - "bg": "/home/user/.claude/jobs/efbe67a7/tmp/song/intro/pkmn-bg-full.mkv", + "out": "thumbs/pokemon-d-run.jpg", + "bg": "intro/pkmn-bg-full.mkv", "bgAt": 150.71, "corners": [ { @@ -410,8 +410,8 @@ ] }, "pokemon-battle2": { - "out": "/home/user/reports/quartering-uh-song/thumbs/pokemon-c-battle2.jpg", - "bg": "/home/user/.claude/jobs/efbe67a7/tmp/song/intro/pkmn-bg-full.mkv", + "out": "thumbs/pokemon-c-battle2.jpg", + "bg": "intro/pkmn-bg-full.mkv", "bgAt": 63.86, "corners": [ {