perfect-postcode/video/src/narration.ts
2026-06-22 22:04:20 +01:00

72 lines
2.2 KiB
TypeScript

import { writeFileSync } from 'node:fs';
export interface NarrationCue {
text: string;
videoTimeMs: number;
durationMs: number;
}
/**
* Narration manifest writer.
*
* The runner knows the exact video-time of each narration block from the
* storyboard itself, so cues come in with an explicit `videoTimeMs` instead
* of being stamped against a wall-clock origin. That keeps the manifest in
* lockstep with the trimmed video even if step durations drift slightly.
*/
class NarrationLog {
private cues: NarrationCue[] = [];
reset(): void {
this.cues = [];
}
add(cue: NarrationCue): void {
if (cue.videoTimeMs < 0) return;
this.cues.push(cue);
}
flush(path: string, totalDurationMs: number): NarrationCue[] {
const sorted = [...this.cues].sort((a, b) => a.videoTimeMs - b.videoTimeMs);
const manifest = { totalDurationMs, cues: sorted };
writeFileSync(path, JSON.stringify(manifest, null, 2));
return sorted;
}
}
export const narrationLog = new NarrationLog();
function formatVttTimestamp(ms: number): string {
const clamped = Math.max(0, Math.round(ms));
const totalSeconds = Math.floor(clamped / 1000);
const millis = clamped % 1000;
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
const pad = (n: number, width: number): string =>
n.toString().padStart(width, '0');
return `${pad(hours, 2)}:${pad(minutes, 2)}:${pad(seconds, 2)}.${pad(millis, 3)}`;
}
/**
* Render narration cues as a WebVTT document.
*
* Pure helper: takes cues (already ordered by the caller) and produces a
* caption sidecar string. Cue text is trimmed and any internal blank lines
* are collapsed so a single cue stays a single VTT block.
*/
export function cuesToVtt(cues: NarrationCue[]): string {
let out = 'WEBVTT\n\n';
for (const cue of cues) {
const start = formatVttTimestamp(cue.videoTimeMs);
const end = formatVttTimestamp(cue.videoTimeMs + cue.durationMs);
const text = cue.text
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0)
.join('\n')
.trim();
out += `${start} --> ${end}\n${text}\n\n`;
}
return out;
}