diff --git a/frontend/public/video/recording-mobile.jpg b/frontend/public/video/recording-mobile.jpg index 7dfc031..b393617 100644 --- a/frontend/public/video/recording-mobile.jpg +++ b/frontend/public/video/recording-mobile.jpg @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:776c00b545fe878c1cfae7ff4a1d7a2f646f2fbd68677a2b689136bb52134a9c -size 178290 +oid sha256:4e7afc422aab4fb57df4c57c20e5d6bc86c42b79b3a1de12709264233b5dc901 +size 391997 diff --git a/frontend/public/video/recording-mobile.mp4 b/frontend/public/video/recording-mobile.mp4 index 5f9b524..1e8a270 100644 --- a/frontend/public/video/recording-mobile.mp4 +++ b/frontend/public/video/recording-mobile.mp4 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:97beb4788a3b97ae4e081ea1c6ba20700f1bc1eb5a9b5b8b3732058de802ddd2 -size 37294899 +oid sha256:9564072fa3d3154a75121fdae9622d9e3edc15f2ef9dc5c44e11c566462c6682 +size 33920230 diff --git a/frontend/public/video/recording-mobile.vtt b/frontend/public/video/recording-mobile.vtt index 572ec62..0147ba4 100644 --- a/frontend/public/video/recording-mobile.vtt +++ b/frontend/public/video/recording-mobile.vtt @@ -1,26 +1,26 @@ WEBVTT -00:00:00.240 --> 00:00:05.200 +00:00:00.263 --> 00:00:04.663 A postcode's reputation is priced in. Its value isn't. -00:00:05.550 --> 00:00:12.110 +00:00:05.013 --> 00:00:11.413 So start with the brief. Budget, commute, schools, even how quiet the street is. -00:00:12.480 --> 00:00:17.200 +00:00:11.783 --> 00:00:16.023 One brief, and England narrows to the postcodes worth your money. -00:00:17.750 --> 00:00:22.630 +00:00:16.573 --> 00:00:20.813 Tighten the commute, and the keepers narrow further in seconds. -00:00:23.230 --> 00:00:29.150 +00:00:21.413 --> 00:00:25.733 Down at street level, the strongest streets start to stand out. -00:00:29.600 --> 00:00:37.920 +00:00:26.418 --> 00:00:35.218 Open one, and it shows its work. Sold prices, schools, crime, noise, broadband. -00:00:44.031 --> 00:00:49.871 +00:00:37.418 --> 00:00:42.698 Keep the best-value few, export them, and scout where it actually counts. -00:00:50.871 --> 00:00:53.991 +00:00:43.822 --> 00:00:46.942 Stop paying for the name. Find the value. diff --git a/frontend/public/video/recording.jpg b/frontend/public/video/recording.jpg index 1bf13b7..48d6d33 100644 --- a/frontend/public/video/recording.jpg +++ b/frontend/public/video/recording.jpg @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:740b77c88c67041aa5d93a2cc4974415e79200cc35317e1c20ccbc064d7f7389 -size 338350 +oid sha256:65b8888b12faaf0df233744f04e019a6e666fbe8ead9ab947803b406c3c6dc3a +size 482104 diff --git a/frontend/public/video/recording.mp4 b/frontend/public/video/recording.mp4 index 4836378..0b04270 100644 --- a/frontend/public/video/recording.mp4 +++ b/frontend/public/video/recording.mp4 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:26a4e14323da141a1af372a830fb7ef569d9e19cd124715e608a3affccf2d4ed -size 49203079 +oid sha256:714ec9070d77bc1fd3724d5dde6775b2b123b894cd7651f5370290d6aacf8861 +size 54162468 diff --git a/frontend/public/video/recording.vtt b/frontend/public/video/recording.vtt index f36fe6e..0344215 100644 --- a/frontend/public/video/recording.vtt +++ b/frontend/public/video/recording.vtt @@ -1,26 +1,26 @@ WEBVTT -00:00:00.241 --> 00:00:05.201 +00:00:00.249 --> 00:00:04.649 A postcode's reputation is priced in. Its value isn't. -00:00:05.551 --> 00:00:12.111 +00:00:04.999 --> 00:00:11.399 So start with the brief. Budget, commute, schools, even how quiet the street is. -00:00:12.481 --> 00:00:17.201 +00:00:11.769 --> 00:00:16.009 One brief, and England narrows to the postcodes worth your money. -00:00:17.751 --> 00:00:22.631 +00:00:16.559 --> 00:00:20.799 Tighten the commute, and the keepers narrow further in seconds. -00:00:23.231 --> 00:00:29.151 +00:00:21.399 --> 00:00:25.719 Down at street level, the strongest streets start to stand out. -00:00:29.601 --> 00:00:37.921 +00:00:27.010 --> 00:00:35.810 Open one, and it shows its work. Sold prices, schools, crime, noise, broadband. -00:00:40.021 --> 00:00:45.861 +00:00:37.910 --> 00:00:43.190 Keep the best-value few, export them, and scout where it actually counts. -00:00:46.861 --> 00:00:49.981 +00:00:44.190 --> 00:00:47.310 Stop paying for the name. Find the value. diff --git a/frontend/scripts/prerender.mjs b/frontend/scripts/prerender.mjs index 5462f0a..ca60f54 100644 --- a/frontend/scripts/prerender.mjs +++ b/frontend/scripts/prerender.mjs @@ -28,7 +28,7 @@ const ROUTES = [ output: 'pricing/index.html', title: 'Perfect Postcode pricing - Lifetime property search map access', description: - 'Get lifetime access to the postcode property search map for England, including filters, saved searches, exports, and future data updates.', + 'Get lifetime access to the postcode property search map for England, including filters, saved searches, exports and future data updates.', }, { path: '/property-price-map', @@ -105,19 +105,19 @@ const ROUTES = [ output: 'privacy-security/index.html', title: 'Perfect Postcode privacy and security - Saved searches and account data', description: - 'Learn how Perfect Postcode treats saved searches, account data and property research workflows with privacy and security in mind.', + 'Learn how Perfect Postcode keeps your saved searches and account data private and secure, and what it does and does not store.', }, { path: '/terms', output: 'terms/index.html', - title: 'Terms of Service | Perfect Postcode', + title: 'Terms of service - Perfect Postcode', description: 'The terms that govern your use of Perfect Postcode, including lifetime access, acceptable use, data accuracy, payments and refunds.', }, { path: '/privacy', output: 'privacy/index.html', - title: 'Privacy Policy | Perfect Postcode', + title: 'Privacy policy - Perfect Postcode', description: 'How Perfect Postcode collects, uses and protects your data: account details, payments, saved searches, AI queries, analytics and your UK GDPR rights.', }, @@ -132,7 +132,7 @@ const FAQ_SCHEMA_ITEMS = [ { question: 'What should I do when my search returns too many or too few areas?', answer: - 'Start with hard limits, then colour the map by a trade-off such as price per sqm, road noise, school score, or commute time. If the map gets too narrow, relax one slider and you can see exactly which compromise opens up more options.', + 'Start with hard limits, then colour the map by a trade-off such as price per sqm, road noise, school score, or commute time. If the map gets too narrow, relax one slider to see exactly which compromise opens up more options.', }, { question: 'How are the travel times calculated?', @@ -145,9 +145,9 @@ const FAQ_SCHEMA_ITEMS = [ 'The estimate starts with the last HM Land Registry sale price, adjusts it to current-market terms using repeat-sales modelling and fallback models, then blends that result with a nearest-neighbour estimate from nearby, recently sold, same-type homes.', }, { - question: 'Should I use this before or after checking Rightmove?', + question: 'Does Perfect Postcode replace Rightmove, Zoopla and OnTheMarket?', answer: - 'Use Perfect Postcode before and alongside listing portals. Rightmove, Zoopla, and OnTheMarket are still where you check live availability, photos, agent contact, viewings, and alerts.', + 'No. Perfect Postcode helps you choose the right postcode from area data; the listing portals are still where you check live availability, photos, agent contact, viewings and alerts.', }, ]; diff --git a/video/src/storyboard.ts b/video/src/storyboard.ts index 0ce057b..9486f5b 100644 --- a/video/src/storyboard.ts +++ b/video/src/storyboard.ts @@ -28,7 +28,7 @@ type FormFactor = 'desktop' | 'mobile'; * real data). On 9:16 cuts the bottom sheet gets dragged out of the way * whenever the map is the story. * 4. Filter names MUST match live /api/features exactly (e.g. - * "Serious crime (avg/yr)", "Distance to nearest amenity (Waitrose) + * "Serious crime (/yr, 7y)", "Distance to nearest amenity (Waitrose) * (km)") — wrong names silently no-op and the map never changes. * preflight.ts validates every stubbed name against the live API. * @@ -592,7 +592,7 @@ function createRecordingStoryboard( // Loose enough to keep the central-London map richly populated — a // cap of 20 emptied the city centre and left the zoom with nothing // to land on. - 'Serious crime (avg/yr)': [0, 40], + 'Serious crime (/yr, 7y)': [0, 40], [SCHOOL_GOOD_PRIMARY]: [1, 10], 'Noise (dB)': [0, 65], // ≥30 Mbps, not ≥100: FTTP coverage gaps make a 100 floor empty @@ -888,7 +888,7 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [ filters: { 'Property type': ['Flats/Maisonettes'], 'Estimated current price': [0, 600000], - 'Serious crime (avg/yr)': [0, 35], + 'Serious crime (/yr, 7y)': [0, 35], 'Noise (dB)': [0, 60], }, travelTimeFilters: [ @@ -1038,7 +1038,7 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [ promptText: 'Quiet London street under 55 decibels, low crime', filters: { 'Noise (dB)': [0, 55], - 'Serious crime (avg/yr)': [0, 35], + 'Serious crime (/yr, 7y)': [0, 35], }, initialZoom: 10.6, posterTimeS: 7, @@ -1076,7 +1076,7 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [ filters: { 'Estimated current price': [0, 350000], [SCHOOL_GOOD_PRIMARY]: [2, 10], - 'Serious crime (avg/yr)': [0, 30], + 'Serious crime (/yr, 7y)': [0, 30], }, initialZoom: 11.0, posterTimeS: 13, @@ -1194,7 +1194,7 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [ promptText: 'Under £500k, 35 min to central London, low crime, good schools', filters: { 'Estimated current price': [0, 500000], - 'Serious crime (avg/yr)': [0, 35], + 'Serious crime (/yr, 7y)': [0, 35], [SCHOOL_GOOD_PRIMARY]: [1, 10], }, travelTimeFilters: [ diff --git a/video/tts/synth.py b/video/tts/synth.py index b5f3493..5b11f61 100644 --- a/video/tts/synth.py +++ b/video/tts/synth.py @@ -28,6 +28,7 @@ Run from the ``video/`` directory: from __future__ import annotations import argparse +import hashlib import json import os import random @@ -64,6 +65,25 @@ def _safe_load_json(path: Path) -> object | None: return None +def _file_sha256(path: Path) -> str: + """Content hash of a file, used to pin cached cues to a reference WAV. + + The cue cache keys off the *settings* that produced the reference + (instruct/seed/…), but a re-mint of VoiceDesign — or render.sh copying a + different storyboard's reference into this audio dir — can swap the actual + reference waveform out from under those settings. Cloning some cues from + reference A and others from reference B yields two audibly different + speakers in one video. Hashing the bytes of the reference that was + actually used closes that gap: any change to the reference invalidates + every cue, so all cues in a render share one timbre. + """ + h = hashlib.sha256() + with path.open("rb") as fh: + for chunk in iter(lambda: fh.read(1 << 20), b""): + h.update(chunk) + return h.hexdigest() + + def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( @@ -120,6 +140,7 @@ def cached_index_matches( design_model: str, clone_model: str, reference_audio: str, + reference_hash: str, seed: int, temperature: float, top_p: float, @@ -127,10 +148,11 @@ def cached_index_matches( """Return True iff index_path's cue list lines up with `cues` 1:1. Compared fields: ``cueIndex``, ``text``, ``gapBeforeMs`` plus the synth - settings (``instruct``, ``language``, reference text, models, ``seed``, - ``temperature``, ``top_p``). + settings (``instruct``, ``language``, reference text, models, the hash of + the reference WAV the cues were cloned from, ``seed``, ``temperature``, + ``top_p``). All cue WAV files must also exist on disk. Mismatched length, reordered - cues, or a missing WAV invalidate the cache. + cues, a swapped reference waveform, or a missing WAV invalidate the cache. """ if not index_path.exists(): return False @@ -146,6 +168,8 @@ def cached_index_matches( return False if cached.get("referenceAudio", "") != reference_audio: return False + if cached.get("referenceHash", "") != reference_hash: + return False if int(cached.get("seed", -1)) != seed: return False if float(cached.get("temperature", -1)) != temperature: @@ -177,6 +201,7 @@ def load_reusable_items( design_model: str, clone_model: str, reference_audio: str, + reference_hash: str, seed: int, temperature: float, top_p: float, @@ -184,7 +209,10 @@ def load_reusable_items( """Return cue-indexed cached items that match the current synth settings. Unlike ``cached_index_matches`` this accepts a partial index, so a long - CPU synthesis run can be resumed cue-by-cue after an interruption. + CPU synthesis run can be resumed cue-by-cue after an interruption. The + reference-hash gate is what stops a re-mint from leaving some cues cloned + from the previous reference (a second voice) while only the edited cues + regenerate from the new one. """ if not index_path.exists(): return {} @@ -200,6 +228,8 @@ def load_reusable_items( return {} if cached.get("referenceAudio", "") != reference_audio: return {} + if cached.get("referenceHash", "") != reference_hash: + return {} if int(cached.get("seed", -1)) != seed: return {} if float(cached.get("temperature", -1)) != temperature: @@ -343,13 +373,44 @@ def main() -> int: audio_dir.mkdir(parents=True, exist_ok=True) + print(f"[synth] [{args.storyboard}] persona: {instruct}", flush=True) + print( + f"[synth] [{args.storyboard}] sampling: temperature={temperature} top_p={top_p} seed={seed} language={language}", + flush=True, + ) + + # Resolve the reference FIRST, before the cache check, so every downstream + # decision keys off the exact bytes the cues are cloned from. Two-stage + # generation: + # 1. VoiceDesign mints a single reference clip in the target persona + # (or the user supplies one via --reference-audio). + # 2. Base + generate_voice_clone(x_vector_only_mode=True) conditions + # every cue on the reference's speaker embedding. + # Without (2), batched generation drifts timbre across cues — a persona + # prompt anchors style but not identity, so each batch item picks its + # own voice. The reference WAV is cached so subsequent runs only load + # the clone model (saves ~20s + 3.4 GB of disk download); when it is + # cached, _resolve_reference returns without loading any model, so doing + # this ahead of the skip check is cheap. + ref_wav_path, ref_text = _resolve_reference( + args, audio_dir, instruct, language, reference_text, seed, temperature, top_p + ) + # Pin the cue cache to this exact reference waveform. A re-mint (or a + # reference copied in from another storyboard by render.sh) changes these + # bytes; without this gate the cue cache would keep cloned-from-the-old- + # reference WAVs alongside freshly regenerated ones — two voices in one + # video. See _file_sha256. + reference_hash = _file_sha256(ref_wav_path) + + index_path = audio_dir / "index.json" + # Skip generation when the existing audio matches the script — same cue # texts and same gapBeforeMs values in the same order, AND same synth - # settings (instruct/language/reference/model/seed/temperature/top_p). - # Saves ~30s of GPU time when iterating on activity timing without - # changing narration or persona. + # settings (instruct/language/reference text + reference-WAV hash/model/ + # seed/temperature/top_p). Saves ~30s of GPU time when iterating on + # activity timing without changing narration or persona. if cached_index_matches( - audio_dir / "index.json", + index_path, cues, instruct, language, @@ -357,6 +418,7 @@ def main() -> int: args.design_model, args.clone_model, reference_audio_cache_key, + reference_hash, seed, temperature, top_p, @@ -368,25 +430,6 @@ def main() -> int: return 0 texts = [c["text"].strip() for c in cues] - print(f"[synth] [{args.storyboard}] persona: {instruct}", flush=True) - print( - f"[synth] [{args.storyboard}] sampling: temperature={temperature} top_p={top_p} seed={seed} language={language}", - flush=True, - ) - - # Two-stage generation: - # 1. VoiceDesign mints a single reference clip in the target persona - # (or the user supplies one via --reference-audio). - # 2. Base + generate_voice_clone(x_vector_only_mode=True) conditions - # every cue on the reference's speaker embedding. - # Without (2), batched generation drifts timbre across cues — a persona - # prompt anchors style but not identity, so each batch item picks its - # own voice. The reference WAV is cached so subsequent runs only load - # the clone model (saves ~20s + 3.4 GB of disk download). - ref_wav_path, ref_text = _resolve_reference( - args, audio_dir, instruct, language, reference_text, seed, temperature, top_p - ) - print( f"[synth] cloning {len(texts)} cues from reference (x_vector_only)", flush=True, @@ -403,11 +446,11 @@ def main() -> int: "cloneModel": args.clone_model, "referenceAudio": reference_audio_cache_key, "referenceText": ref_text, + "referenceHash": reference_hash, "seed": seed, "temperature": temperature, "topP": top_p, } - index_path = audio_dir / "index.json" reusable = load_reusable_items( index_path, cues, @@ -417,6 +460,7 @@ def main() -> int: args.design_model, args.clone_model, reference_audio_cache_key, + reference_hash, seed, temperature, top_p,