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:
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": [
{