// local-media.mjs — a clip whose media is a FILE BESIDE THE MANIFEST. // // Most clips name a recording in the corpus (`video`, `channel`), and the build // finds its cues, its metadata and its media through that. A clip may instead // carry its own: // // { "type": "clip", "id": "p01", // "src": "media/episode-12.mp3", // video or audio-only // "cues": "media/episode-12.json", // optional // "start": 61.2, "end": 74.9 } // optional: default the whole file // // Both paths are RELATIVE TO THE MANIFEST, like an image entry's `src`, and may // not climb out of the manifest's directory: a manifest is checked in beside // the media it cites, and a `../../` that happens to resolve on one machine is // a build that breaks on the next. An absolute path is refused too, unless the // manifest says `render.allowAbsoluteSrc: true` -- media too big to keep beside // the manifest (a podcast archive on another drive) is a decision to write // down, not an accident to discover. // // The window is the whole file, [0, its probed duration]; `start`/`end` cut // inside it and default to its two ends. No channel, no corpus lookup, no QR to // a site (the file has no page there) unless the entry carries `citeUrl`. // // `cues` is read in either of two shapes and normalised by ONE function, so the // sentence-widening and the cut-to-quote passes read a manifest-local cue file // exactly as they read the corpus's: // // - the corpus's `transcript.cues.json`: `{ title, channel, uploadDate, // duration, platform, cues: [{ start, end, text }] }` // - the transcript `scripts/parakeet-stitch.mjs` writes: `{ duration_seconds, // chunks, text, chunk_data: [{ start_time, end_time, text }] }` // // A LEAF, like sources.mjs: no imports from build-video.mjs, and no path // computed from the cwd -- the manifest's directory arrives as an argument. import { readFile, stat } from "node:fs/promises"; import path from "node:path"; /** Does this clip carry its own media (rather than a corpus `video`)? Ask it of clips only. */ export const hasLocalMedia = (entry) => entry != null && entry.src != null; /** What a clip is called in a log line: its corpus video, else its file. */ export const clipLabel = (entry) => entry?.video ?? entry?.src ?? entry?.id ?? "?"; /** * One cue document in either shape, as the corpus's: `{ cues: [{start, end, * text}], duration?, title?, channel?, uploadDate?, platform?, webpageUrl? }`. * A cue with no finite start or end is dropped, never guessed at; a document in * neither shape throws. * * @param {any} doc parsed JSON * @param {string} [label] what the error names */ export function normaliseCueDoc(doc, label = "cues") { if (!doc || typeof doc !== "object") throw new Error(`${label}: not a cue document`); let raw; let duration; if (Array.isArray(doc.cues)) { raw = doc.cues.map((c) => ({ start: c?.start, end: c?.end, text: c?.text })); duration = doc.duration; } else if (Array.isArray(doc.chunk_data)) { raw = doc.chunk_data.map((c) => ({ start: c?.start_time, end: c?.end_time, text: c?.text })); duration = doc.duration_seconds; } else { throw new Error( `${label}: neither a transcript.cues.json (\`cues\`) nor a parakeet transcript (\`chunk_data\`)`, ); } const cues = []; for (const c of raw) { const start = Number(c.start); const end = Number(c.end); if (!Number.isFinite(start) || !Number.isFinite(end)) continue; cues.push({ start, end, text: String(c.text ?? "") }); } cues.sort((a, b) => a.start - b.start); const d = Number(duration); const out = { cues }; if (Number.isFinite(d) && d > 0) out.duration = d; for (const k of ["title", "channel", "uploadDate", "platform", "webpageUrl"]) { if (typeof doc[k] === "string" && doc[k]) out[k] = doc[k]; } return out; } /** Read and normalise one cue file. */ export async function readCueFile(file, label = file) { return normaliseCueDoc(JSON.parse(await readFile(file, "utf8")), label); } /** * A manifest-relative path, resolved -- or why it is refused. Relative paths * must stay inside `baseDir`; an absolute one needs `allowAbsolute`. * * @returns {{ file: string } | { error: string }} */ export function resolveManifestPath(baseDir, p, { allowAbsolute = false } = {}) { if (typeof p !== "string" || !p.trim()) return { error: "must be a non-empty path" }; if (path.isAbsolute(p)) { if (!allowAbsolute) { return { error: `${p} is absolute; set render.allowAbsoluteSrc to allow it` }; } return { file: path.normalize(p) }; } const file = path.resolve(/* turbopackIgnore: true */ baseDir, p); const rel = path.relative(/* turbopackIgnore: true */ baseDir, file); if (rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) { return { error: `${p} climbs out of the manifest's directory` }; } return { file }; } const isFile = (p) => stat(p).then((s) => s.isFile(), () => false); /** * Every problem with the timeline's manifest-local media, as sentences naming * the entry: a path that escapes or is absolute without leave, a file that is * not there, a cue file in no known shape, `src` beside a `video`, `cues` * without `src`. Empty when there is nothing wrong (or nothing local). * * @param {any[]} timeline * @param {string} baseDir the manifest's directory * @param {{ allowAbsolute?: boolean, isClip?: (e: any) => boolean }} [opts] * `isClip` is the build's own predicate (its vocabulary is open: anything it * has no other branch for is a clip); other entries' `src` is not this one. */ export async function validateLocalMedia(timeline, baseDir, { allowAbsolute = false, isClip = (e) => e?.type === "clip" } = {}) { const errors = []; for (const [i, e] of (timeline ?? []).entries()) { if (!e || !isClip(e)) continue; const who = `timeline[${i}] ${e.id ?? "(no id)"}`; if (e.src == null) { if (e.cues != null) errors.push(`${who}: \`cues\` belongs to a clip with \`src\``); continue; } if (e.video != null) errors.push(`${who}: a clip takes \`src\` or \`video\`, not both`); const src = resolveManifestPath(baseDir, e.src, { allowAbsolute }); if (src.error) errors.push(`${who}: src ${src.error}`); else if (!(await isFile(src.file))) errors.push(`${who}: no media at ${src.file} (src: ${e.src})`); if (e.cues == null) continue; const cues = resolveManifestPath(baseDir, e.cues, { allowAbsolute }); if (cues.error) { errors.push(`${who}: cues ${cues.error}`); } else if (!(await isFile(cues.file))) { errors.push(`${who}: no cue file at ${cues.file} (cues: ${e.cues})`); } else { try { await readCueFile(cues.file, e.cues); } catch (err) { errors.push(`${who}: ${err?.message ?? err}`); } } } return errors; } /** * The reader a build (or resolve-windows) holds for one manifest's local * media: each entry's resolved file, its cue document (read once) and the * metadata the header, the chapters and the deck read in place of a corpus * record's. Assumes validateLocalMedia passed. * * @param {{ baseDir: string, allowAbsolute?: boolean }} config */ export function createLocalMedia({ baseDir, allowAbsolute = false }) { const docs = new Map(); const resolveOrThrow = (entry, key) => { const r = resolveManifestPath(baseDir, entry[key], { allowAbsolute }); if (r.error) throw new Error(`${entry.id}: ${key} ${r.error}`); return r.file; }; return { baseDir, /** The entry's media file, absolute. */ file: (entry) => resolveOrThrow(entry, "src"), /** Its normalised cue document, or null when it names none. */ async doc(entry) { if (entry.cues == null) return null; const file = resolveOrThrow(entry, "cues"); if (!docs.has(file)) docs.set(file, readCueFile(file, entry.cues)); return docs.get(file); }, /** Its cues alone ([] without a cue file). */ async cues(entry) { return (await this.doc(entry))?.cues ?? []; }, /** * The metadata a corpus record would have given: the cue document's when it * carries any, the file's name as the title otherwise. The entry's own * `title`, `date` and `channelTitle` still win where the header reads them. */ async meta(entry) { const d = await this.doc(entry); return { title: d?.title ?? path.basename(entry.src, path.extname(entry.src)), uploadDate: d?.uploadDate ?? null, webpageUrl: null, duration: d?.duration ?? null, channel: d?.channel ?? null, platform: d?.platform ?? null, }; }, }; }