import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { OUTPUT_DIR } from './config.js'; import { publishedSizeFor, type Storyboard } from './script.js'; import { storyboards } from './storyboard.js'; /** * Emit per-storyboard narration scripts for the synth step. * * Synth (tts/synth.py) runs BEFORE recording, so it needs the full ordered * narration list (text + per-cue gaps + voice config) without depending * on Playwright, the dashboard, or auth. Walk each storyboard's cues, write * a flat manifest under `output//narration-script.json`, then write * an index manifest at `output/storyboards.json` so render.sh knows which * storyboard slugs to loop over. * * The cue index in each manifest is the source of truth: the runner later * matches storyboard cues to measured durations by index. */ // Em/en-dashes and ellipses make Qwen3-TTS produce dramatic pauses, sighs, // or audible breaths. The captions still render the original (unicode-rich) // text from the storyboard; only the synth input is sanitised. function normalizeForTts(text: string): string { return text .replace(/\s*[\u2014\u2013]\s*/g, ', ') .replace(/…/g, '.') .replace(/\.{3,}/g, '.') .replace(/\s{2,}/g, ' ') .trim(); } function emitScript(storyboard: Storyboard): string { const dir = join(OUTPUT_DIR, storyboard.name); if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); const items = storyboard.cues.map((cue, cueIndex) => ({ cueIndex, text: normalizeForTts(cue.text), gapBeforeMs: cue.gapBeforeMs, })); // The voice block is consumed by tts/synth.py. See _resolve_reference and // the cache check there for which fields invalidate cached audio. const manifest = { storyboard: storyboard.name, voice: { instruct: storyboard.voice.instruct, language: storyboard.voice.language, referenceText: storyboard.voice.referenceText, temperature: storyboard.voice.temperature ?? 0.6, topP: storyboard.voice.topP ?? 0.9, seed: storyboard.voice.seed ?? 42, }, items, }; const path = join(dir, 'narration-script.json'); writeFileSync(path, JSON.stringify(manifest, null, 2)); console.log(`[preflight] [${storyboard.name}] wrote ${items.length} cues → ${path}`); return path; } /** * Validate every stubbed/initial filter name and travel-destination slug * against the LIVE API. Wrong names don't error in the app: they silently * no-op, the map never changes, and you only find out after a full render. * Fails hard on a mismatch; soft-warns if the API is unreachable (render.sh * has already health-checked it by the time preflight runs). */ async function validateAgainstLiveApi(): Promise { const apiBase = process.env.API_URL ?? process.env.APP_URL; if (!apiBase) { console.warn('[preflight] no API_URL/APP_URL set, skipping live filter validation'); return; } let featureNames: Set; try { const res = await fetch(`${apiBase}/api/features`); if (!res.ok) throw new Error(`HTTP ${res.status}`); const body = (await res.json()) as { groups: { features: { name: string }[] }[]; }; featureNames = new Set(body.groups.flatMap((g) => g.features.map((f) => f.name))); } catch (err) { console.warn(`[preflight] could not fetch ${apiBase}/api/features (${err}), skipping validation`); return; } const problems: string[] = []; for (const sb of storyboards) { const filterNames = [ ...Object.keys(sb.content.stubbedFilters), ...Object.keys(sb.content.initialFilters ?? {}), ]; for (const name of filterNames) { if (!featureNames.has(name)) { problems.push(`[${sb.name}] filter "${name}" is not a live /api/features name`); } } } const modes = new Set( storyboards.flatMap((sb) => sb.content.stubbedTravelTimeFilters.map((tt) => tt.mode)) ); for (const mode of modes) { try { const res = await fetch(`${apiBase}/api/travel-destinations?mode=${mode}`); if (!res.ok) throw new Error(`HTTP ${res.status}`); const body = (await res.json()) as { destinations: { slug: string }[] }; const slugs = new Set(body.destinations.map((d) => d.slug)); for (const sb of storyboards) { for (const tt of sb.content.stubbedTravelTimeFilters) { if (tt.mode === mode && !slugs.has(tt.slug)) { problems.push(`[${sb.name}] travel destination "${tt.slug}" (${mode}) not on live API`); } } } } catch (err) { console.warn(`[preflight] could not validate travel destinations for ${mode}: ${err}`); } } if (problems.length > 0) { for (const p of problems) console.error(`[preflight] FAIL: ${p}`); process.exit(1); } console.log('[preflight] all stubbed filter names and travel slugs match the live API'); } async function main(): Promise { if (!existsSync(OUTPUT_DIR)) mkdirSync(OUTPUT_DIR, { recursive: true }); await validateAgainstLiveApi(); for (const sb of storyboards) emitScript(sb); // Index for shell loops: each entry has every field render.sh needs to // address per-storyboard outputs without re-parsing the TS source. const index = { storyboards: storyboards.map((sb) => ({ name: sb.name, locale: sb.locale ?? sb.content.appLanguage, aspect: sb.video.aspect, outputFps: sb.video.outputFps, minDurationS: sb.video.minDurationS, maxDurationS: sb.video.maxDurationS, posterTimeS: sb.video.posterTimeS, // Final mp4 dimensions after render.sh's lanczos upscale. publishedSize: publishedSizeFor(sb.video), })), }; const indexPath = join(OUTPUT_DIR, 'storyboards.json'); writeFileSync(indexPath, JSON.stringify(index, null, 2)); console.log(`[preflight] wrote storyboard index → ${indexPath}`); } main().catch((err) => { console.error(err); process.exit(1); });