// The alternate tracks of one video dir, read from disk (lib/captionTracks.ts // says what an alternate is). SERVER-ONLY (node:fs). import path from "node:path"; import { readdir, readFile } from "node:fs/promises"; import { parseVtt, type Cue } from "./vtt"; import { parseTranscriptJson } from "./whisper"; import { TRANSCRIPT_PIN_FILENAME, VTT_FILENAME, WHISPER_FILENAME, englishVttsByPreference, readEnglishVttCues, } from "./videoStatus"; import { PINNED_TRACK, TRANSCRIPTION_TRACK, distinctAltTracks, trackOfVttFile, type AltTrack, type TrackFields, } from "./captionTracks"; // The track id of an English caption file in a listing: its language code, or // `pinned` for transcript.en.vtt while the operator's pin stands. export function trackIdOfVtt(filename: string, entries: readonly string[]): string { if (filename === VTT_FILENAME && entries.includes(TRANSCRIPT_PIN_FILENAME)) { return PINNED_TRACK; } return trackOfVttFile(filename) ?? filename; } // Whether a listing can hold an alternate at all — no file is read. A caption // record needs two English VTTs; a transcribed one, one. export function mayHaveAltTracks( primaryKind: "vtt" | "whisper", entries: readonly string[], ): boolean { const n = englishVttsByPreference(entries).length; return primaryKind === "whisper" ? n >= 1 : n >= 2; } // Every English VTT of a listing, parsed, in preference order. One that cannot // be read is left out. export async function readEnglishVttTracks( videoDir: string, entries: readonly string[], ): Promise<{ filename: string; cues: Cue[] }[]> { const out: { filename: string; cues: Cue[] }[] = []; for (const filename of englishVttsByPreference(entries)) { try { out.push({ filename, cues: parseVtt(await readFile(path.join(videoDir, filename), "utf8")), }); } catch { // unreadable: not a track } } return out; } // A record's track fields: the primary's id and the English tracks whose words // differ from it. `primaryCues` is what the record's transcript holds (the // dedupe compares against it); for a caption record, the primary is the first // track in preference order that has a cue — the caption-track rule's content // fallback, so the id names the track the words really came from. Empty // (no fields) when nothing differs. export async function readTrackFields( videoDir: string, primaryKind: "vtt" | "whisper", primaryCues: readonly Cue[] | undefined, entries?: readonly string[], ): Promise { const listing = entries ?? (await readdir(videoDir).catch(() => [] as string[])); if (!mayHaveAltTracks(primaryKind, listing)) return {}; const vtts = await readEnglishVttTracks(videoDir, listing); let primaryTrack: string; let primary: readonly Cue[] | undefined = primaryCues; let candidates: { filename: string; cues: Cue[] }[]; if (primaryKind === "whisper") { primaryTrack = TRANSCRIPTION_TRACK; candidates = vtts; } else { if (vtts.length === 0) return {}; const idx = Math.max(0, vtts.findIndex((t) => t.cues.length > 0)); primaryTrack = trackIdOfVtt(vtts[idx].filename, listing); primary ??= vtts[idx].cues; candidates = vtts.filter((_, i) => i !== idx); } const alts: AltTrack[] = distinctAltTracks( primary, candidates.map((t) => ({ track: trackIdOfVtt(t.filename, listing), cues: t.cues })), ); if (alts.length === 0) return {}; return { track: primaryTrack, altTracks: alts }; } // Every track of a video dir, read from disk, primary first: what the editor's // transcript reader shows and switches between. The primary is the record's // transcript by the same rules the index reads it with — a local // transcription (transcript.json) over captions, captions by the caption-track // rule — and the rest are the alternates readTrackFields keeps. Null when the // dir holds no transcript. export async function readVideoTracks( videoDir: string, ): Promise<{ tracks: AltTrack[] } | null> { const entries = await readdir(videoDir).catch(() => [] as string[]); let primary: AltTrack | null = null; let kind: "vtt" | "whisper" = "vtt"; if (entries.includes(WHISPER_FILENAME)) { try { primary = { track: TRANSCRIPTION_TRACK, cues: parseTranscriptJson(await readFile(path.join(videoDir, WHISPER_FILENAME), "utf8")), }; kind = "whisper"; } catch { primary = null; } } if (!primary) { const read = await readEnglishVttCues(videoDir, entries); if (!read) return null; primary = { track: trackIdOfVtt(read.filename, entries), cues: read.cues }; } const fields = await readTrackFields(videoDir, kind, primary.cues, entries); return { tracks: [primary, ...(fields.altTracks ?? [])] }; }