72 lines
2.2 KiB
TypeScript
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;
|
|
}
|