perfect-postcode/video/src/preflight.ts
2026-06-28 11:59:44 +01:00

160 lines
5.8 KiB
TypeScript

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/<name>/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<void> {
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<string>;
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<void> {
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);
});