/** * Transcript loading for the assemble TTS stage (resolves the `load_transcript` * 3b deferral in `tts.ts`). * * Ported from `skills/video-replicator/scripts/generate_tts.py` * (`load_transcript`, `load_edited_transcript`, `get_scene_texts`): * * - Standalone transcript files (from the transcribe pipeline): * `{ "transcript": { "full_text": ... }, "scene_transcripts": [...] }` * - SEALCAM+-embedded transcripts (from analyze --transcribe): * `{ "scenes": [{ "scene_number": N, "transcript": { "text": ... } | "..." }] }` * - Editable per-scene overrides (`editable_transcript.json`): * `{ "scenes": { "1": "text" | [{ "speaker": ..., "text": ... }] } }` * * Multi-voice scenes (an array of `{speaker, text}` segments) are FLATTENED to * a single joined text with a warning — per-speaker multi-voice synthesis is a * separately deferred 3b sub-commit, and a single voice reading every line is * the documented degradation until then. * * The parsing core is pure (takes parsed JSON); only the `*File` wrappers do * I/O. Mirrors the Python's lenient posture: unknown shapes produce an empty * transcript plus a warning rather than throwing. */ import { readFile } from 'node:fs/promises'; import type { TtsSegment } from './tts.js'; export interface MultiVoiceSegment { speaker: string; text: string; } /** A scene's narration: plain text, or multi-voice speaker segments. */ export type SceneText = string | MultiVoiceSegment[]; export interface SceneTranscript { sceneNumber: number; text: SceneText; hasSpeech: boolean; } export interface LoadedTranscript { fullText: string; sceneTranscripts: SceneTranscript[]; warnings: string[]; } function sceneTextString(text: SceneText): string { return typeof text === 'string' ? text : text.map((segment) => segment.text ?? '').join(' '); } function normalizeSceneText(raw: unknown): SceneText { if (typeof raw === 'string') return raw; if (Array.isArray(raw)) { return raw .filter((seg): seg is { speaker?: unknown; text?: unknown } => typeof seg === 'object' && seg !== null) .map((seg) => ({ speaker: typeof seg.speaker === 'string' ? seg.speaker : '', text: typeof seg.text === 'string' ? seg.text : '', })); } if (typeof raw === 'object' && raw !== null && 'text' in raw) { const text = (raw as { text?: unknown }).text; return typeof text === 'string' ? text : ''; } return ''; } /** * Normalize a parsed transcript JSON into {@link LoadedTranscript}. Supports * the standalone and SEALCAM+-embedded shapes; anything else yields an empty * transcript with a warning (mirrors the Python's logged warning). */ export function parseTranscript(data: unknown): LoadedTranscript { const warnings: string[] = []; const obj = (typeof data === 'object' && data !== null ? data : {}) as Record; // Standalone transcript file (from the transcribe pipeline). if ('transcript' in obj) { const transcript = (obj.transcript ?? {}) as Record; const fullText = typeof transcript.full_text === 'string' ? transcript.full_text : ''; const rawScenes = Array.isArray(obj.scene_transcripts) ? obj.scene_transcripts : []; const sceneTranscripts = rawScenes .filter((s): s is Record => typeof s === 'object' && s !== null) .map((s) => { const text = normalizeSceneText(s.text); return { sceneNumber: typeof s.scene_number === 'number' ? s.scene_number : 0, text, hasSpeech: sceneTextString(text).trim().length > 0, }; }); return { fullText, sceneTranscripts, warnings }; } // Embedded in SEALCAM+ analysis (from analyze --transcribe). if (Array.isArray(obj.scenes)) { const sceneTranscripts = obj.scenes .filter((s): s is Record => typeof s === 'object' && s !== null) .map((s) => { const text = normalizeSceneText(s.transcript); return { sceneNumber: typeof s.scene_number === 'number' ? s.scene_number : 0, text, hasSpeech: sceneTextString(text).trim().length > 0, }; }); return { fullText: sceneTranscripts .map((s) => sceneTextString(s.text)) .filter((t) => t.length > 0) .join(' '), sceneTranscripts, warnings, }; } warnings.push('Could not find transcript data (expected a "transcript" or "scenes" key); using an empty transcript.'); return { fullText: '', sceneTranscripts: [], warnings }; } /** * Parse `editable_transcript.json`-style per-scene overrides into a * sceneNumber → text map. Non-numeric scene keys are skipped with a warning * (the Python would have thrown on `int(key)`). */ export function parseEditedTranscript(data: unknown): { edits: Map; warnings: string[] } { const warnings: string[] = []; const edits = new Map(); const scenes = (typeof data === 'object' && data !== null ? (data as Record).scenes : undefined) as Record | undefined; if (typeof scenes !== 'object' || scenes === null || Array.isArray(scenes)) { return { edits, warnings }; } for (const [key, value] of Object.entries(scenes)) { const sceneNumber = Number(key); if (!Number.isInteger(sceneNumber)) { warnings.push(`Edited transcript: skipping non-numeric scene key "${key}".`); continue; } edits.set(sceneNumber, normalizeSceneText(value)); } return { edits, warnings }; } export interface SceneTextEntry { sceneNumber: number; text: SceneText; edited: boolean; } /** * Extract per-scene text, applying edits and skips — the Python * `get_scene_texts`. With no per-scene data, the full text becomes a single * scene 1 (the Python fallback). */ export function sceneTexts( transcript: LoadedTranscript, options: { edits?: Map; skipScenes?: Set } = {}, ): SceneTextEntry[] { if (transcript.sceneTranscripts.length === 0) { if (transcript.fullText.trim().length > 0) { return [{ sceneNumber: 1, text: transcript.fullText, edited: false }]; } return []; } const result: SceneTextEntry[] = []; for (const scene of transcript.sceneTranscripts) { if (options.skipScenes?.has(scene.sceneNumber)) continue; const override = options.edits?.get(scene.sceneNumber); result.push({ sceneNumber: scene.sceneNumber, text: override !== undefined ? override : scene.text, edited: override !== undefined, }); } return result; } /** * Resolve a transcript (plus optional edits) into TTS segments. Multi-voice * scenes are flattened to one joined text with a warning; scenes with no * speech are skipped (the Python's `has_speech`). */ export function transcriptToTtsSegments( transcript: LoadedTranscript, options: { edits?: Map; skipScenes?: Set } = {}, ): { segments: TtsSegment[]; warnings: string[] } { const warnings = [...transcript.warnings]; const segments: TtsSegment[] = []; for (const entry of sceneTexts(transcript, options)) { if (Array.isArray(entry.text) && entry.text.length > 0) { warnings.push( `Scene ${entry.sceneNumber}: multi-voice segments flattened to a single voice (multi-voice synthesis is not implemented yet).`, ); } const text = sceneTextString(entry.text).trim(); if (text.length === 0) continue; segments.push({ sceneIndex: entry.sceneNumber, text }); } return { segments, warnings }; } /** Read + parse a transcript file (standalone or SEALCAM+-embedded shape). */ export async function loadTranscriptFile(path: string): Promise { let raw: string; try { raw = await readFile(path, 'utf8'); } catch (error) { throw new Error(`loadTranscriptFile: cannot read ${path}: ${(error as Error).message}`); } let data: unknown; try { data = JSON.parse(raw); } catch (error) { throw new Error(`loadTranscriptFile: ${path} is not valid JSON: ${(error as Error).message}`); } return parseTranscript(data); } /** Read + parse an editable-transcript overrides file. */ export async function loadEditedTranscriptFile( path: string, ): Promise<{ edits: Map; warnings: string[] }> { let raw: string; try { raw = await readFile(path, 'utf8'); } catch (error) { throw new Error(`loadEditedTranscriptFile: cannot read ${path}: ${(error as Error).message}`); } let data: unknown; try { data = JSON.parse(raw); } catch (error) { throw new Error(`loadEditedTranscriptFile: ${path} is not valid JSON: ${(error as Error).message}`); } return parseEditedTranscript(data); }