Skip to content

Commit a181345

Browse files
tallnatoclaude
andauthored
feat(catalog): emit schemaVersion, thumbnailUrl, and per-item media specs (#71)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 10386cc commit a181345

2 files changed

Lines changed: 172 additions & 5 deletions

File tree

‎SCHEMA.md‎

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,10 +47,12 @@ content/_<category>/<slug>.md # front matter — sits BESIDE the folder, no
4747

4848
`npm run build` emits (into the gitignored `public/api/`, copied to `/api/` at deploy):
4949

50-
- **`catalog.json`** — `{ generatedAt, totalItems, lightshows[], locksounds[], boombox[], wraps[], hornsounds[] }`
51-
- **`<category>.json`** — `{ category, totalItems, items[] }`
50+
- **`catalog.json`** — `{ schemaVersion, generatedAt, totalItems, lightshows[], locksounds[], boombox[], wraps[], hornsounds[] }`
51+
- **`<category>.json`** — `{ schemaVersion, category, totalItems, items[] }`
5252
- **`tags.json`** — `{ totalTags, tags: { <tag>: ["<category>/<slug>", …] } }`
5353

54+
`schemaVersion` (currently **`1`**) is a top-level integer on `catalog.json` and each `<category>.json`. It is bumped whenever the emitted JSON shape changes so the app can gate parsing of newer optional fields. The current contract is version 1 (adds `thumbnailUrl` + `specs` to each item).
55+
5456
Each **item**:
5557
```jsonc
5658
{
@@ -61,14 +63,35 @@ Each **item**:
6163
"description": "…",
6264
"tags": ["rock", "synchronized"],
6365
"thumbnail": null, // string filename for wraps, else null
66+
"thumbnailUrl": null, // root-relative URL when thumbnail set, else null
6467
"files": [ // derived from declared meta.files ∩ disk
6568
{ "name": "…​.fseq", "downloadUrl": "/Volts/content/lightshows/<slug>/<slug>.fseq" }
6669
],
70+
"specs": { // best-effort media specs, all fields nullable
71+
"channels": 0,
72+
"sampleRate": null,
73+
"durationSeconds": 0.025
74+
},
6775
"meta": { /* full front matter; meta.files[] also gets downloadUrl + keeps label */ }
6876
}
6977
```
7078

79+
### `thumbnailUrl`
80+
When front matter declares `thumbnail`, `build.ts` emits a root-relative `thumbnailUrl` of `<baseurl>/content/<category>/<slug>/<thumbnail>` — the **same convention and `/Volts` prefix as `files[].downloadUrl`**. When no `thumbnail` is declared it is `null`. The raw `thumbnail` field (bare filename or `null`) is kept as-is alongside it.
81+
82+
### `specs` (best-effort, nullable)
83+
`specs` is parsed best-effort from an item's binaries. **Every field is nullable and the build never throws on a malformed/stub asset** — today's committed fixtures are tiny placeholder stubs, so expect `null` (or degenerate) values until real assets land. Shape: `{ channels, sampleRate, durationSeconds }`.
84+
85+
| Category | Source binary | `channels` | `sampleRate` | `durationSeconds` |
86+
|---|---|---|---|---|
87+
| `lightshows` | `.fseq` (FSEQ v2.x, `PSEQ` magic) | `channelCount` (uint32 @10) | `null` | `frameCount`(@14) × `stepTime`(@18) / 1000 |
88+
| `locksounds`, `hornsounds`, `boombox`, any `.wav` | first `.wav` (RIFF chunk-walk) | `fmt ` channels | `fmt ` sampleRate | `data` size / (`sampleRate` × `channels` × `bitsPerSample`/8) |
89+
| mp3-only items (and items with no parseable binary) | — | `null` | `null` | `null` |
90+
91+
MP3 is intentionally **not** parsed (variable bitrate makes header-derived duration/sampleRate unreliable), so an mp3-only item yields all-`null` specs. Any parse failure (bad magic, truncated stub, missing chunk) also yields `null` for the affected field(s).
92+
7193
**Cross-repo invariants (do not break without updating the app):**
7294
1. The five **category key names** are fixed. A new category is silently ignored by the app until it ships an update (`CatalogApi.kt` has a hardcoded `CATEGORY_KEYS`).
73-
2. `downloadUrl` is **root-relative and includes the `/Volts` prefix** (driven by `_config.yml`'s `baseurl`). The app resolves it against the host only (`https://natobytes.com`). Changing `baseurl` breaks the app's URL resolution.
95+
2. `downloadUrl` (and `thumbnailUrl`) is **root-relative and includes the `/Volts` prefix** (driven by `_config.yml`'s `baseurl`). The app resolves it against the host only (`https://natobytes.com`). Changing `baseurl` breaks the app's URL resolution.
7496
3. `meta.files[].label` is relied on by the app for display.
97+
4. `schemaVersion`, `thumbnailUrl`, and `specs` are **additive** (version 1). They don't displace any existing field, and `specs` values are best-effort/nullable — the app must tolerate `null` for any spec.

‎scripts/build.ts‎

Lines changed: 146 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ const CATEGORIES = [
2626
"hornsounds",
2727
];
2828

29+
// Bumped whenever the emitted JSON shape changes in a way the app should know
30+
// about. The app may read this to gate parsing of newer optional fields.
31+
const SCHEMA_VERSION = 1;
32+
2933
interface ItemMeta {
3034
title: string;
3135
author?: string;
@@ -40,6 +44,15 @@ interface FileEntry {
4044
downloadUrl: string;
4145
}
4246

47+
// Best-effort media specs parsed from an item's binaries. Every field is
48+
// nullable: today's fixtures are placeholder stubs, so most values are null,
49+
// and any parse failure leaves the relevant field null rather than throwing.
50+
interface ItemSpecs {
51+
channels: number | null;
52+
sampleRate: number | null;
53+
durationSeconds: number | null;
54+
}
55+
4356
interface CatalogItem {
4457
slug: string;
4558
category: string;
@@ -48,7 +61,9 @@ interface CatalogItem {
4861
description: string;
4962
tags: string[];
5063
thumbnail: string | null;
64+
thumbnailUrl: string | null;
5165
files: FileEntry[];
66+
specs: ItemSpecs;
5267
meta: ItemMeta;
5368
}
5469

@@ -75,6 +90,126 @@ function urlFor(baseUrl: string, category: string, slug: string, name: string):
7590
return `${baseUrl}/content/${category}/${slug}/${name}`;
7691
}
7792

93+
// --- Best-effort media-spec parsing ----------------------------------------
94+
//
95+
// These helpers crack open an item's binaries to surface display specs
96+
// (channels / sampleRate / durationSeconds). They mirror the magic-byte
97+
// approach in scripts/validate.ts, but here every parse is wrapped in a
98+
// try/catch and returns nulls on ANY failure: the committed fixtures are
99+
// tiny placeholder stubs (e.g. a 36-byte .fseq), so a header read may be
100+
// truncated or nonsensical. We must NEVER throw — a malformed asset should
101+
// just yield null specs, not break the whole catalog build.
102+
103+
const EMPTY_SPECS: ItemSpecs = {
104+
channels: null,
105+
sampleRate: null,
106+
durationSeconds: null,
107+
};
108+
109+
function matches(buf: Buffer, ascii: string, offset = 0): boolean {
110+
if (buf.length < offset + ascii.length) return false;
111+
for (let i = 0; i < ascii.length; i++) {
112+
if (buf[offset + i] !== ascii.charCodeAt(i)) return false;
113+
}
114+
return true;
115+
}
116+
117+
// FSEQ v2.x light-show sequence ("PSEQ" magic). Per the Tesla/xLights layout:
118+
// offset 0 : "PSEQ" magic
119+
// offset 10 : uint32 LE channelCount
120+
// offset 14 : uint32 LE frameCount
121+
// offset 18 : uint8 stepTime (ms per frame)
122+
// durationSeconds = frameCount * stepTime / 1000.
123+
function parseFseqSpecs(filePath: string): ItemSpecs {
124+
try {
125+
const buf = fs.readFileSync(filePath);
126+
if (!matches(buf, "PSEQ")) return EMPTY_SPECS;
127+
if (buf.length < 19) return EMPTY_SPECS;
128+
const channelCount = buf.readUInt32LE(10);
129+
const frameCount = buf.readUInt32LE(14);
130+
const stepTime = buf.readUInt8(18);
131+
return {
132+
channels: channelCount,
133+
sampleRate: null,
134+
durationSeconds: (frameCount * stepTime) / 1000,
135+
};
136+
} catch {
137+
return EMPTY_SPECS;
138+
}
139+
}
140+
141+
// WAV (RIFF/WAVE). Walk the chunk list to find `fmt ` (sampleRate, channels,
142+
// bitsPerSample) and `data` (byte size), then derive duration for PCM:
143+
// durationSeconds = dataSize / (sampleRate * channels * bitsPerSample / 8).
144+
function parseWavSpecs(filePath: string): ItemSpecs {
145+
try {
146+
const buf = fs.readFileSync(filePath);
147+
if (!matches(buf, "RIFF") || !matches(buf, "WAVE", 8)) return EMPTY_SPECS;
148+
149+
let sampleRate: number | null = null;
150+
let channels: number | null = null;
151+
let bitsPerSample: number | null = null;
152+
let dataSize: number | null = null;
153+
154+
// Chunks start after "RIFF"(4) + size(4) + "WAVE"(4) = offset 12.
155+
let off = 12;
156+
while (off + 8 <= buf.length) {
157+
const id = buf.toString("ascii", off, off + 4);
158+
const size = buf.readUInt32LE(off + 4);
159+
const body = off + 8;
160+
if (id === "fmt " && body + 16 <= buf.length) {
161+
channels = buf.readUInt16LE(body + 2);
162+
sampleRate = buf.readUInt32LE(body + 4);
163+
bitsPerSample = buf.readUInt16LE(body + 14);
164+
} else if (id === "data") {
165+
dataSize = size;
166+
}
167+
// Chunks are word-aligned: an odd size carries a trailing pad byte.
168+
off = body + size + (size % 2);
169+
}
170+
171+
let durationSeconds: number | null = null;
172+
if (
173+
dataSize !== null &&
174+
sampleRate &&
175+
channels &&
176+
bitsPerSample &&
177+
sampleRate > 0 &&
178+
channels > 0 &&
179+
bitsPerSample > 0
180+
) {
181+
durationSeconds = dataSize / (sampleRate * channels * (bitsPerSample / 8));
182+
}
183+
184+
return { channels, sampleRate, durationSeconds };
185+
} catch {
186+
return EMPTY_SPECS;
187+
}
188+
}
189+
190+
// Picks the binary worth probing for an item and returns its specs. Lightshows
191+
// are described by their .fseq; the audio categories by their .wav. MP3 is
192+
// intentionally left unparsed (VBR makes duration/sampleRate unreliable from a
193+
// header read), so an mp3-only item yields all-null specs. Any item with no
194+
// parseable binary also yields all-null specs.
195+
function buildSpecs(category: string, itemDir: string, files: FileEntry[]): ItemSpecs {
196+
const find = (ext: string): string | null => {
197+
const hit = files.find((f) => path.extname(f.name).toLowerCase() === ext);
198+
return hit ? path.join(itemDir, hit.name) : null;
199+
};
200+
201+
if (category === "lightshows") {
202+
const fseq = find(".fseq");
203+
return fseq ? parseFseqSpecs(fseq) : EMPTY_SPECS;
204+
}
205+
206+
// locksounds / hornsounds / boombox (and anything else that ships a .wav).
207+
const wav = find(".wav");
208+
if (wav) return parseWavSpecs(wav);
209+
210+
return EMPTY_SPECS;
211+
}
212+
78213
// Build the catalog's top-level files[] from the DECLARED meta.files (the
79214
// contract the app consumes), intersected with what's actually on disk. Falls
80215
// back to a disk glob only when an item declares no files, and warns loudly on
@@ -158,15 +293,22 @@ function buildCatalog(): CatalogItem[] {
158293
console.warn(` ! ${category}/${slug}: missing "author" — defaulting to "Unknown"`);
159294
}
160295

296+
const thumbnail = typeof meta.thumbnail === "string" ? meta.thumbnail : null;
297+
const files = buildFiles(meta, itemDir, baseUrl, category, slug);
298+
161299
items.push({
162300
slug,
163301
category,
164302
title: meta.title || slug,
165303
author: meta.author || "Unknown",
166304
description: meta.description || "",
167305
tags: Array.isArray(meta.tags) ? meta.tags : [],
168-
thumbnail: typeof meta.thumbnail === "string" ? meta.thumbnail : null,
169-
files: buildFiles(meta, itemDir, baseUrl, category, slug),
306+
thumbnail,
307+
// Root-relative, same convention as files[].downloadUrl; null when no
308+
// thumbnail is declared in front matter.
309+
thumbnailUrl: thumbnail ? urlFor(baseUrl, category, slug, thumbnail) : null,
310+
files,
311+
specs: buildSpecs(category, itemDir, files),
170312
meta,
171313
});
172314
}
@@ -212,6 +354,7 @@ function main(): void {
212354

213355
// Full catalog — grouped by category
214356
const catalog: Record<string, unknown> = {
357+
schemaVersion: SCHEMA_VERSION,
215358
generatedAt: new Date().toISOString(),
216359
totalItems: items.length,
217360
};
@@ -224,6 +367,7 @@ function main(): void {
224367
for (const category of CATEGORIES) {
225368
const categoryItems = items.filter((i) => i.category === category);
226369
writeJSON(path.join(OUTPUT_DIR, `${category}.json`), {
370+
schemaVersion: SCHEMA_VERSION,
227371
category,
228372
totalItems: categoryItems.length,
229373
items: categoryItems,

0 commit comments

Comments
 (0)