diff --git a/video/render.sh b/video/render.sh index dbf6ab5..df85c8d 100755 --- a/video/render.sh +++ b/video/render.sh @@ -14,11 +14,13 @@ # bootstrap step; you supply real account credentials. # # Usage: -# ./render.sh # local stack, English homepage landscape + portrait -# ./render.sh --prod # prod, English homepage landscape + portrait +# ./render.sh # local stack, EVERY video (see the set below) +# ./render.sh --prod # prod, every video # ./render.sh --target prod # same as --prod -# VIDEO_STORYBOARD_SET=ads ./render.sh --prod # render social ads instead -# VIDEO_STORYBOARD_SET=demo ./render.sh --prod # render every homepage locale +# VIDEO_STORYBOARD_SET=homepage-en ./render.sh # just the English homepage cut +# VIDEO_STORYBOARD_SET=ads ./render.sh --prod # just the social ads +# VIDEO_STORYBOARD_SET=demo ./render.sh --prod # just the homepage locales +# VIDEO_STORYBOARD_SET=twins ./render.sh --prod # just the cheaper-twin ads # ./render.sh --fresh-auth # force re-auth even if cache is fresh # ./render.sh --resume # preserve completed recordings and continue # ./render.sh --no-encode # stop at WebM, skip MP4 encode @@ -37,6 +39,17 @@ set -euo pipefail +# -- storyboard set ----------------------------------------------------------- +# A bare ./render.sh means "re-render the videos", so it renders all of them: +# every ad, every cheaper-twin ad, and every homepage locale in both aspects. +# It used to default to the English homepage pair alone, which left everything +# else to drift until someone remembered to set this by hand. Narrow it with +# VIDEO_STORYBOARD_SET (the sets are enumerated in src/storyboard.ts, which +# rejects an unknown name rather than guessing). Nothing here is cheap: this is +# 23 storyboards, so expect a long run and reach for a narrower set when you +# only changed one video. +export VIDEO_STORYBOARD_SET="${VIDEO_STORYBOARD_SET:-all}" + # -- target ------------------------------------------------------------------- TARGET="${TARGET:-local}" parsed_args=() diff --git a/video/src/storyboard.ts b/video/src/storyboard.ts index a35aed4..3d11b7f 100644 --- a/video/src/storyboard.ts +++ b/video/src/storyboard.ts @@ -92,7 +92,28 @@ const DESKTOP_MAP_ZOOM_MS = 2800; const SHEET_DOWN = 0.12; const SHEET_UP = 0.5; -type RecordingLocale = 'en' | 'de' | 'zh' | 'hi'; +/** + * Sampling shared by every narration track, homepage recordings and ads alike. + * It lives in one place because there is no reason for the two sets to drift: + * the seed is the only knob that legitimately differs between them, so it stays + * out of here (it is per-set, and re-rolling it with CONSECUTIVE values is the + * documented escape from a rambling take). + * + * cfgWeight 1 tightens the delivery: the same cue reads ~15% shorter than at + * Chatterbox's default 0.5, and the runner sizes each cue's wall-time to the + * measured audio, so the cuts shorten to match rather than desync. + * + * These values feed synth.py's cache fingerprint, so changing any of them + * re-synthesizes every cue of every storyboard on the next render. + */ +const NARRATION_SAMPLING = { + temperature: 0.8, + exaggeration: 0.6, + cfgWeight: 1, + speedFactor: 1, +} as const; + +type RecordingLocale = 'en' | 'fr' | 'de' | 'zh' | 'hi' | 'hu'; interface RecordingLocalization { name: string; @@ -165,6 +186,38 @@ const RECORDING_LOCALIZATIONS: Record = outro: 'Stop paying for the name. Find the value.', }, }, + fr: { + name: 'recording-fr', + appLanguage: 'fr', + ttsLanguage: 'fr', + voice: 'French-F.wav', + promptText: + 'Appartement à moins de £600k, 35 min du centre de Londres, bonnes écoles, faible criminalité, rues calmes', + travelTimeLabel: 'Centre de Londres', + exportButtonTitle: 'Exporter vers Excel', + exportConfirmLabel: 'Exporter', + closeDrawerLabel: 'Fermer le tiroir', + colourMapTitle: 'Colorer la carte', + brand: { + name: 'Perfect Postcode', + tagline: 'Trouvez le code postal sous-estimé.', + url: BRAND_URL, + }, + cues: { + hook: "La réputation d'un quartier est déjà dans le prix. Sa vraie valeur, non.", + brief: + 'Alors commencez par l’essentiel. Budget, trajet, écoles, et même le calme de la rue.', + search: + 'Une demande, et l’Angleterre se réduit aux codes postaux qui valent votre argent.', + commute: 'Resserrez le trajet, et il n’en reste que quelques-uns, en quelques secondes.', + streets: 'Au niveau de la rue, les meilleures adresses ressortent d’elles-mêmes.', + open: + 'Ouvrez-en un, et il montre ses preuves. Prix de vente, écoles, criminalité, bruit, internet.', + shortlist: + 'Gardez les meilleures affaires, exportez-les, et allez voir sur place.', + outro: 'Ne payez plus pour le nom. Trouvez la valeur.', + }, + }, de: { name: 'recording-de', appLanguage: 'de', @@ -256,6 +309,43 @@ const RECORDING_LOCALIZATIONS: Record = outro: 'Stop paying for the name. Find the value.', }, }, + hu: { + name: 'recording-hu', + appLanguage: 'hu', + // Hungarian UI, English narration, exactly like `hi` above. Not a + // compromise we chose: the multilingual checkpoint speaks 23 languages + // and Hungarian is not one of them, so an accented English read is the + // only way to voice this cut. `en` therefore keeps it on the original + // checkpoint, and Hungarian-F.wav carries the accent. + ttsLanguage: 'en', + voice: 'Hungarian-F.wav', + // English on screen too, matching `hi`: the typed brief is read aloud by + // the English VO, so a Hungarian brief would drift from the narration. + promptText: 'Flat under £600k, 35 min to central London, good schools, low crime, quiet streets', + travelTimeLabel: 'Central London', + exportButtonTitle: 'Exportálás Excelbe', + exportConfirmLabel: 'Exportálás', + closeDrawerLabel: 'Panel bezárása', + colourMapTitle: 'Térkép színezése', + brand: { + name: 'Perfect Postcode', + tagline: 'Find the hidden-gem postcode.', + url: BRAND_URL, + }, + cues: { + hook: "A postcode's reputation is priced in. Its value isn't.", + brief: + 'So start with the brief. Budget, commute, schools, even how quiet the street is.', + search: 'One brief, and England narrows to the postcodes worth your money.', + commute: 'Tighten the commute, and the keepers narrow further in seconds.', + streets: 'Down at street level, the strongest streets start to stand out.', + open: + 'Open one, and it shows its work. Sold prices, schools, crime, noise, broadband.', + shortlist: + 'Keep the best-value few, export them, and scout where it actually counts.', + outro: 'Stop paying for the name. Find the value.', + }, + }, }; /** @@ -560,10 +650,7 @@ function createRecordingStoryboard( voice: { voice: copy.voice, language: copy.ttsLanguage, - temperature: 0.8, - exaggeration: 0.5, - cfgWeight: 0.5, - speedFactor: 1, + ...NARRATION_SAMPLING, seed: 42, }, content: { @@ -617,7 +704,7 @@ function createRecordingStoryboard( }; } -const RECORDING_LOCALES: readonly RecordingLocale[] = ['en', 'de', 'zh', 'hi']; +const RECORDING_LOCALES: readonly RecordingLocale[] = ['en', 'fr', 'de', 'zh', 'hi', 'hu']; const RECORDING_FORM_FACTORS: readonly FormFactor[] = ['desktop', 'mobile']; const ENGLISH_HOMEPAGE_STORYBOARDS: Storyboard[] = RECORDING_FORM_FACTORS.map((formFactor) => @@ -714,10 +801,7 @@ const AD_BRAND = { const AD_VOICE = { voice: 'Southern-M.wav', language: 'en', - temperature: 0.8, - exaggeration: 0.5, - cfgWeight: 0.5, - speedFactor: 1, + ...NARRATION_SAMPLING, seed: 87, }; @@ -1550,6 +1634,10 @@ function createTwinConfig(pair: TwinPair): DemoAdStoryboardConfig { const TWIN_AD_CONFIGS: DemoAdStoryboardConfig[] = TWIN_PAIRS.map(createTwinConfig); const TWIN_AD_STORYBOARDS = TWIN_AD_CONFIGS.map(createDemoAdStoryboard); +// render.sh exports `all`, so this fallback is only what a bare `node +// dist/record.js` gets. The two defaults differ on purpose: render.sh is the +// "re-render the videos" entry point, while an ad-hoc node run that surprised +// you with 23 recordings would be hostile. const STORYBOARD_SET = process.env.VIDEO_STORYBOARD_SET ?? 'homepage-en'; export const storyboards: Storyboard[] = (() => { @@ -1558,13 +1646,23 @@ export const storyboards: Storyboard[] = (() => { return ENGLISH_HOMEPAGE_STORYBOARDS; case 'demo': return DEMO_STORYBOARDS; - case 'all': - return [...AD_STORYBOARDS, ...DEMO_STORYBOARDS]; + case 'ads': + return AD_STORYBOARDS; case 'twins': return TWIN_AD_STORYBOARDS; - case 'ads': + case 'all': + // Every publishable slug, twins included. `all` is what render.sh runs by + // default, so a set left out here is one that quietly stops being + // re-rendered and rots in frontend/public/video. + return [...AD_STORYBOARDS, ...TWIN_AD_STORYBOARDS, ...DEMO_STORYBOARDS]; default: - return AD_STORYBOARDS; + // Never fall back to a set. This used to default to the ads, so a typo'd + // VIDEO_STORYBOARD_SET spent a long render producing the wrong videos and + // said nothing. + throw new Error( + `Unknown VIDEO_STORYBOARD_SET "${STORYBOARD_SET}". ` + + 'Known: homepage-en, demo, ads, twins, all.' + ); } })(); diff --git a/video/tts/synth.py b/video/tts/synth.py index 4e5a7cc..ddba636 100644 --- a/video/tts/synth.py +++ b/video/tts/synth.py @@ -47,6 +47,7 @@ import urllib.request import wave from dataclasses import dataclass from pathlib import Path +from typing import Any DEFAULT_SERVER = "http://host.docker.internal:8004" @@ -163,7 +164,7 @@ def _post(url: str, payload: dict, timeout: float) -> bytes: return response.read() -def _get_json(url: str, timeout: float) -> dict: +def _get_json(url: str, timeout: float) -> Any: with urllib.request.urlopen(url, timeout=timeout) as response: return json.loads(response.read()) @@ -438,14 +439,26 @@ def check_voice_exists(server: str, voice: str) -> None: /tts does reject an unknown predefined voice, but this names the mistake and lists the alternatives instead of surfacing a server stack trace. + + Ask /get_predefined_voices, NOT /v1/audio/voices. The latter looks right but + answers a different, wider list (it also carries reference clips and the + bundled sample pack) and it lags behind uploads: on 2026-07-15 it omitted + French-F.wav and Hungarian-F.wav while /tts synthesized both happily. So it + fails a perfectly good voice and blames the storyboard. The filenames from + /get_predefined_voices are exactly the `predefined_voice_id` vocabulary. """ try: - voices = _get_json(f"{server}/v1/audio/voices", INFO_TIMEOUT_S).get("voices", []) + listed = _get_json(f"{server}/get_predefined_voices", INFO_TIMEOUT_S) except (urllib.error.URLError, OSError, json.JSONDecodeError): return # Non-fatal: /tts still validates the name. + voices = sorted( + str(entry["filename"]) + for entry in listed + if isinstance(entry, dict) and entry.get("filename") + ) if voices and voice not in voices: raise SystemExit( - f"[synth] unknown predefined voice {voice!r}. Available: {', '.join(sorted(voices))}" + f"[synth] unknown predefined voice {voice!r}. Available: {', '.join(voices)}" )