From cf348c3ea456d0e46ad5fa8e958f2f80ff9b503d Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Mon, 13 Jul 2026 21:03:38 +0100 Subject: [PATCH] new storyboard --- .../public/video/twin-beckenham-croydon.vtt | 17 +- frontend/public/video/twin-ha7-2-vs-ha3-0.vtt | 17 +- frontend/public/video/twin-l16-7-vs-l14-6.vtt | 17 +- video/src/storyboard.ts | 404 ++++++++++++------ video/tts/mux.py | 66 ++- 5 files changed, 362 insertions(+), 159 deletions(-) diff --git a/frontend/public/video/twin-beckenham-croydon.vtt b/frontend/public/video/twin-beckenham-croydon.vtt index b75b2fe..a3dd665 100644 --- a/frontend/public/video/twin-beckenham-croydon.vtt +++ b/frontend/public/video/twin-beckenham-croydon.vtt @@ -1,14 +1,17 @@ WEBVTT -00:00:00.203 --> 00:00:06.283 -Beckenham and Croydon sit side by side: same trains, same school catchment. +00:00:00.211 --> 00:00:07.251 +Beckenham and Croydon sit two kilometres apart, on the same trains, in the same school catchments. -00:00:06.683 --> 00:00:10.523 -Rank them by what each pound of floor space actually buys. +00:00:07.551 --> 00:00:12.591 +Now colour every postcode by what a square metre of home actually costs there. -00:00:11.823 --> 00:00:16.943 -One postcode over, the same home quietly costs about a third less. +00:00:13.671 --> 00:00:19.831 +Green means cheaper floor space. Croydon runs about a third under Beckenham, street for street. -00:00:18.093 --> 00:00:22.893 +00:00:20.261 --> 00:00:24.101 +That's over two hundred thousand pounds back on an average home. + +00:00:24.701 --> 00:00:30.701 Beckenham's cheaper twin is on this map. Find yours, free. diff --git a/frontend/public/video/twin-ha7-2-vs-ha3-0.vtt b/frontend/public/video/twin-ha7-2-vs-ha3-0.vtt index 1ecb5bb..e8eac77 100644 --- a/frontend/public/video/twin-ha7-2-vs-ha3-0.vtt +++ b/frontend/public/video/twin-ha7-2-vs-ha3-0.vtt @@ -1,14 +1,17 @@ WEBVTT -00:00:00.202 --> 00:00:05.882 -Stanmore and Kenton sit right next door, with the same schools and transport links. +00:00:00.203 --> 00:00:06.683 +Stanmore and Kenton sit two and a half kilometres apart, with the same schools and transport links. -00:00:06.282 --> 00:00:10.842 -Rank every postcode by what each pound of floor space actually buys. +00:00:06.983 --> 00:00:12.023 +Now colour every postcode by what a square metre of home actually costs there. -00:00:12.142 --> 00:00:17.422 -One postcode over, the same home quietly costs about a sixth less. +00:00:13.103 --> 00:00:19.103 +Green means cheaper floor space. Kenton runs about a sixth under Stanmore, street for street. -00:00:18.572 --> 00:00:22.652 +00:00:19.533 --> 00:00:23.213 +That's more than a hundred thousand pounds back on an average home. + +00:00:23.813 --> 00:00:28.853 Stanmore's cheaper twin is on this map. Find yours, free. diff --git a/frontend/public/video/twin-l16-7-vs-l14-6.vtt b/frontend/public/video/twin-l16-7-vs-l14-6.vtt index 1ce3afc..4cd7759 100644 --- a/frontend/public/video/twin-l16-7-vs-l14-6.vtt +++ b/frontend/public/video/twin-l16-7-vs-l14-6.vtt @@ -1,14 +1,17 @@ WEBVTT -00:00:00.203 --> 00:00:06.843 -Childwall and Broadgreen sit right next door, with the same schools and transport links. +00:00:00.201 --> 00:00:07.081 +Childwall and Broadgreen sit under two kilometres apart, with the same schools and transport links. -00:00:07.243 --> 00:00:11.803 -Rank every postcode by what each pound of floor space actually buys. +00:00:07.381 --> 00:00:12.421 +Now colour every postcode by what a square metre of home actually costs there. -00:00:13.103 --> 00:00:18.223 -One postcode over, the same home quietly costs about a third less. +00:00:13.501 --> 00:00:19.901 +Green means cheaper floor space. Broadgreen runs nearly a third under Childwall, street for street. -00:00:19.373 --> 00:00:24.493 +00:00:20.331 --> 00:00:24.571 +That's well over a hundred thousand pounds back on an average home. + +00:00:25.171 --> 00:00:30.691 Childwall's cheaper twin is on this map. Find yours, free. diff --git a/video/src/storyboard.ts b/video/src/storyboard.ts index 25a51d4..51c574a 100644 --- a/video/src/storyboard.ts +++ b/video/src/storyboard.ts @@ -777,12 +777,6 @@ const sheetUp = (durationMs = 700): Activity => ({ }); const touchShow = (): Activity => ({ kind: 'cursorScale', scale: 1, durationMs: 150 }); const touchHide = (): Activity => ({ kind: 'cursorScale', scale: 0, durationMs: 150 }); -const mapZoomIn = (durationMs = 1500, steps = 5): Activity => ({ - kind: 'mapZoom', - target: vfrac(0.5, 0.34), - steps, - durationMs, -}); /** * Tap the centre of the highest-priority visible postcode polygon from the * latest map response (robust to zoom drift: a fixed pixel target at deep @@ -796,6 +790,38 @@ const tapHex = (durationMs = 1000): Activity => ({ timeoutMs: 8000, }); +/** + * Toggle the map choropleth onto a filter card's feature (the little + * "Colour map" eye button). This is THE payoff visual for the ads: the + * whole city floods with the metric (green = the good end of every scale + * we use) so the "cheaper/quieter/closer nearby" claim is visible instead + * of asserted. IfVisible + generous timeout so a missing card costs the + * colour, not the take; the runner scrolls the card into view first. + */ +const colourMapAct = (featureName: string, durationMs = 700): Activity => ({ + kind: 'clickIfVisible', + target: el(`[data-filter-name="${featureName}"] button[title="Colour map"]`), + durationMs, + timeoutMs: 2500, +}); + +/** + * The final "the value is right here" zoom. Anchored on the strongest + * visible match (hex()) and centered, so the camera always lands on + * populated, matching postcodes. The old fixed-pixel zoom regularly dove + * into parks or filtered-out suburbs and the payoff frame read as an + * empty, broken map. + */ +const payoffZoom = (durationMs = 1700, steps = 4): Activity => ({ + kind: 'mapZoom', + target: hex(), + steps, + durationMs, + center: true, + waitForMapSettled: true, + timeoutMs: 9000, +}); + // Right-pane / drawer used after a postcode tap. const RIGHT_PANE_SELECTOR = '[data-tutorial="right-pane"]'; const scrollDrawer = (top: number, durationMs = 800): Activity => ({ @@ -850,14 +876,20 @@ function createDemoAdStoryboard(ad: DemoAdStoryboardConfig): Storyboard { ...ad.cues.map((cue, index) => ({ text: cue.text, caption: cue.caption, - gapBeforeMs: cue.gapBeforeMs ?? (index === 0 ? 0 : 250), + // 180ms inter-cue gap: tight enough that the voice feels continuous + // (the old 250ms gaps + long tails added ~2s of dead air per ad). + gapBeforeMs: cue.gapBeforeMs ?? (index === 0 ? 0 : 180), during: cue.during, - tail: cue.tail ?? [wait(300)], + tail: cue.tail ?? [wait(200)], })), { text: ad.outroLine, - gapBeforeMs: 250, + gapBeforeMs: 200, during: [ + // Let the closer start over the live product; the brand card pops + // half a second in. A static card for the whole outro line plus a + // 2.2s hold was 6+ seconds of dead frame, a third of the ad. + wait(500), { kind: 'showOutro', brand: AD_BRAND.name, @@ -869,8 +901,8 @@ function createDemoAdStoryboard(ad: DemoAdStoryboardConfig): Storyboard { durationMs: 0, }, ], - // Hold the brand card long enough to read the URL + closer with sound off. - tail: [wait(2200)], + // Just long enough to read the URL with sound off. + tail: [wait(1100)], }, ], }; @@ -901,7 +933,7 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [ { mode: 'transit', slug: 'london', label: 'Central London', max: 35 }, ], initialZoom: 10.2, - posterTimeS: 8, + posterTimeS: 12, outroLine: 'The underpriced twin is on this map. Find it.', cues: [ { @@ -910,19 +942,22 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [ during: [ typeAct('Flat under £600k, 35 min to central London, low crime, quiet street', 2600), ], - tail: [wait(150)], + tail: [wait(120)], }, { + // Colour the map by price right after the search lands: the + // "sorted by value" claim becomes a visible green-to-purple ramp + // instead of the same orange map with a smaller count. text: 'Every postcode in England that fits, sorted by value.', caption: 'All of England, by value', - during: [submitSettled(1400)], - tail: [sheetDown(800), wait(250)], + during: [submitSettled(1400), colourMapAct('Estimated current price'), wait(300)], + tail: [sheetDown(700), wait(200)], }, { text: 'Same schools, same commute, the price quietly drops nearby.', caption: 'The cheaper twin nearby', - during: [mapZoomIn(1400, 4)], - tail: [wait(400)], + during: [payoffZoom(1600, 4)], + tail: [wait(300)], }, ], }, @@ -967,13 +1002,13 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [ text: "Here's what twenty minutes actually leaves you.", caption: 'What 20 minutes buys', during: [sheetUp(700), touchShow(), ttDragAct(20, 1700)], - tail: [touchHide(), sheetDown(800), wait(300)], + tail: [touchHide(), sheetDown(700), wait(200)], }, { text: "Same twenty minutes wherever it's lit, but not the same price.", caption: 'Same ride, lower price', - during: [mapZoomIn(1300, 3)], - tail: [wait(400)], + during: [payoffZoom(1500, 3)], + tail: [wait(300)], }, ], }, @@ -991,7 +1026,7 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [ [SCHOOL_OUTSTANDING_PRIMARY]: [1, 10], }, initialZoom: 10.6, - posterTimeS: 11, + posterTimeS: 13, outroLine: 'Every postcode, proven on the numbers, not its reputation.', prePrime: [ { kind: 'clearVignette', durationMs: 0 }, @@ -1020,16 +1055,31 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [ tail: [wait(150)], }, { + // Breathe after the tap: the drawer needs a beat to render Street + // View + the price chart. Scrolling during that window put a blank + // white pane on screen, which read as the product breaking. text: 'Sold prices, schools, crime, even Street View, for this exact postcode.', caption: 'The whole postcode file', - during: [touchShow(), tapHex(1000), scrollDrawer(420, 850)], + during: [touchShow(), tapHex(1100), wait(600), scrollDrawer(420, 800)], tail: [wait(200)], }, { + // Open a real section instead of scrolling a wall of collapsed + // headers: expanded school data IS the "evidence" the line claims. text: 'The same evidence a pricey postcode has, sitting quietly cheaper here.', caption: 'Same proof, less money', - during: [scrollDrawer(820, 900), touchHide()], - tail: [wait(350)], + during: [ + { + kind: 'clickIfVisible', + target: el(`${RIGHT_PANE_SELECTOR} button:has-text("Schools")`), + durationMs: 700, + timeoutMs: 1500, + }, + wait(300), + scrollDrawer(700, 900), + touchHide(), + ], + tail: [wait(300)], }, ], }, @@ -1047,7 +1097,7 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [ 'Serious crime (/yr, 7y)': [0, 35], }, initialZoom: 10.6, - posterTimeS: 7, + posterTimeS: 11, outroLine: 'The quiet street nobody bid up.', cues: [ { @@ -1057,16 +1107,18 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [ tail: [wait(150)], }, { + // Paint the survivors by decibel level: green = hush. The claim + // gets a visual instead of a subtly thinner orange map. text: 'This is London under fifty-five decibels.', caption: 'Under 55 decibels', - during: [submitSettled(1400)], - tail: [sheetDown(800), wait(250)], + during: [submitSettled(1400), colourMapAct('Noise (dB)'), wait(300)], + tail: [sheetDown(700), wait(200)], }, { text: 'Nearby, just as quiet, and overlooked.', caption: 'Quiet, and overlooked', - during: [mapZoomIn(1600, 5)], - tail: [wait(400)], + during: [payoffZoom(1700, 4)], + tail: [wait(300)], }, ], }, @@ -1097,16 +1149,18 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [ tail: [wait(150)], }, { + // Colour by price: the "minus the premium" story needs the cheap + // pockets visible inside the school-filtered map. text: 'Everything still on the map passes all three filters.', caption: 'Passes all three', - during: [submitSettled(1400)], - tail: [sheetDown(800), wait(250)], + during: [submitSettled(1400), colourMapAct('Estimated current price'), wait(300)], + tail: [sheetDown(700), wait(200)], }, { text: 'Same primary, same low crime, without the name everyone else is bidding up.', caption: 'Same catchment, less money', - during: [mapZoomIn(1600, 5)], - tail: [wait(400)], + during: [payoffZoom(1700, 4)], + tail: [wait(300)], }, ], }, @@ -1126,7 +1180,7 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [ 'Distance to nearest amenity (Park) (km)': [0, 0.5], }, initialZoom: 10.4, - posterTimeS: 7, + posterTimeS: 12, outroLine: 'Same Waitrose. Same tube. Cheaper postcode.', cues: [ { @@ -1136,16 +1190,22 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [ tail: [wait(150)], }, { + // Colour by Waitrose distance: green = walkable. The lifestyle + // claim becomes a readable map instead of a count change. text: 'London, narrowed to the postcodes that fit the way you live.', caption: 'Fits how you live', - during: [submitSettled(1400)], - tail: [sheetDown(800), wait(250)], + during: [ + submitSettled(1400), + colourMapAct('Distance to nearest amenity (Waitrose) (km)'), + wait(300), + ], + tail: [sheetDown(700), wait(200)], }, { text: 'Same amenities, lower price nearby.', caption: 'Same life, lower price', - during: [mapZoomIn(1400, 4)], - tail: [wait(400)], + during: [payoffZoom(1500, 4)], + tail: [wait(300)], }, ], }, @@ -1176,16 +1236,18 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [ tail: [wait(150)], }, { + // Colour by rent: green pockets = the cheap streets the closer + // line promises. text: 'Letting sites rank flats. This ranks the streets around them.', caption: 'Areas, not just flats', - during: [submitSettled(1400)], - tail: [sheetDown(800), wait(250)], + during: [submitSettled(1400), colourMapAct('Estimated monthly rent'), wait(300)], + tail: [sheetDown(700), wait(200)], }, { text: 'Same commute, same quiet, lower rent nearby.', caption: 'Same street, lower rent', - during: [mapZoomIn(1600, 5)], - tail: [wait(400)], + during: [payoffZoom(1600, 4)], + tail: [wait(300)], }, ], }, @@ -1207,7 +1269,7 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [ { mode: 'transit', slug: 'london', label: 'Central London', max: 35 }, ], initialZoom: 10.4, - posterTimeS: 7, + posterTimeS: 12, outroLine: 'Buy value, not reputation.', cues: [ { @@ -1219,14 +1281,14 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [ { text: "You pay for the postcode's name; the value sits a postcode away.", caption: 'You pay for the name', - during: [submitSettled(1400)], - tail: [sheetDown(800), wait(250)], + during: [submitSettled(1400), colourMapAct('Estimated current price'), wait(300)], + tail: [sheetDown(700), wait(200)], }, { text: 'Pay once, find the bargain, stop overpaying for the name.', caption: 'Find the value instead', - during: [mapZoomIn(1600, 5)], - tail: [wait(400)], + during: [payoffZoom(1600, 4)], + tail: [wait(300)], }, ], }, @@ -1236,115 +1298,187 @@ const AD_STORYBOARDS = AD_CONFIGS.map(createDemoAdStoryboard); // --------------------------------------------------------------------------- // Cheaper-twin spots: pair-centred value reveals generated from the -// analysis/ twin index. Filter names verified against live /api/features. +// analysis/ twin index (analysis/out/cheaper_twins.csv, sector-grain +// averages; keep the figures in sync when the index is rebuilt). +// +// Beat structure, tuned after the first cut failed to convince: +// 1. hook + type : the pair claim over a live typed brief. +// 2. colour reveal : submit, then flip the choropleth to £/m². The old cut +// capped the £/m² filter so tightly it deleted the +// pricey twin from the map: nothing to compare. Now the +// cap is wide (both twins visible) and the COLOUR +// carries the contrast (green = cheap floor space). +// 3. payoff zoom : ease onto the pair, colours side by side. +// 4. receipt card : AdScene split with the real sector figures. The +// claim stops being a voice-over and becomes numbers. // Render with: VIDEO_STORYBOARD_SET=twins ./render.sh --prod // --------------------------------------------------------------------------- -const TWIN_AD_CONFIGS: DemoAdStoryboardConfig[] = [ + +const PSQM_FEATURE = 'Est. price per sqm'; +const SCHOOL_GOOD_SECONDARY = 'Good+ secondary school catchments'; + +interface TwinPair { + name: string; + city: CityKey; + /** The sector whose name gets bid up, and its cheaper twin. £/m² values + * are sector averages from the twin index, rounded to the nearest £10. */ + pricey: { sector: string; place: string; psqm: number }; + twin: { sector: string; place: string; psqm: number }; + /** % gap between the two, and how the narration says it. */ + gapPct: number; + gapSpoken: string; + /** £ gap on an average home (rounded down to £1k) + spoken form. */ + saving: string; + savingSpoken: string; + /** Pair distance for the card + the hook line's phrasing of it. */ + distMeta: string; + hook: string; + /** Pair midpoint shifted ~0.02° south so the pair sits in the upper + * (sheet-free) half of the 9:16 frame. */ + center: { lat: number; lon: number }; + initialZoom: number; + matchCount: number; +} + +const TWIN_PAIRS: TwinPair[] = [ { name: 'twin-beckenham-croydon', - matchCount: 1840, city: 'london', + pricey: { sector: 'BR3 3', place: 'Beckenham', psqm: 7150 }, + twin: { sector: 'CR0 7', place: 'Croydon', psqm: 4910 }, + gapPct: 31, + gapSpoken: 'about a third', + saving: '£214,000', + savingSpoken: "That's over two hundred thousand pounds back on an average home.", + distMeta: '2 km apart', + hook: + 'Beckenham and Croydon sit two kilometres apart, on the same trains, in the same school catchments.', center: { lat: 51.36969, lon: -0.04244 }, initialZoom: 12.2, - promptText: 'Good schools, best value per square metre near Beckenham and Croydon', - filters: { - 'Est. price per sqm': [0, 5200], - 'Good+ secondary school catchments': [1, 11], - }, - posterTimeS: 13, - outroLine: "Beckenham's cheaper twin is on this map. Find yours, free.", - cues: [ - { - text: 'Beckenham and Croydon sit side by side: same trains, same school catchment.', - caption: 'Same station. Same schools.', - during: [ - typeAct('Good schools, best value per square metre near Beckenham and Croydon', 2600), - ], - tail: [wait(150)], - }, - { - text: 'Rank them by what each pound of floor space actually buys.', - caption: 'Ranked by £ per m²', - during: [submitSettled(1400)], - tail: [sheetDown(800), wait(250)], - }, - { - text: 'One postcode over, the same home quietly costs about a third less.', - caption: 'The cheaper twin', - during: [mapZoomIn(1400, 2)], - tail: [wait(900)], - }, - ], + matchCount: 1840, }, { name: 'twin-ha7-2-vs-ha3-0', - matchCount: 1620, city: 'london', + pricey: { sector: 'HA7 2', place: 'Stanmore', psqm: 6830 }, + twin: { sector: 'HA3 0', place: 'Kenton', psqm: 5650 }, + gapPct: 17, + gapSpoken: 'about a sixth', + saving: '£108,000', + savingSpoken: "That's more than a hundred thousand pounds back on an average home.", + distMeta: '2.6 km apart', + hook: + 'Stanmore and Kenton sit two and a half kilometres apart, with the same schools and transport links.', center: { lat: 51.57199, lon: -0.3079 }, initialZoom: 12.0, - promptText: 'Good schools, best value per square metre near Stanmore and Kenton', - filters: { - 'Est. price per sqm': [0, 5900], - 'Good+ secondary school catchments': [1, 11], - }, - posterTimeS: 13, - outroLine: "Stanmore's cheaper twin is on this map. Find yours, free.", - cues: [ - { - text: 'Stanmore and Kenton sit right next door, with the same schools and transport links.', - caption: 'Same area. Same schools.', - during: [typeAct('Good schools, best value per square metre near Stanmore and Kenton', 2600)], - tail: [wait(150)], - }, - { - text: 'Rank every postcode by what each pound of floor space actually buys.', - caption: 'Ranked by £ per m²', - during: [submitSettled(1400)], - tail: [sheetDown(800), wait(250)], - }, - { - text: 'One postcode over, the same home quietly costs about a sixth less.', - caption: 'The cheaper twin', - during: [mapZoomIn(1400, 2)], - tail: [wait(900)], - }, - ], + matchCount: 1620, }, { name: 'twin-l16-7-vs-l14-6', - matchCount: 1740, city: 'liverpool', + pricey: { sector: 'L16 7', place: 'Childwall', psqm: 4030 }, + twin: { sector: 'L14 6', place: 'Broadgreen', psqm: 2840 }, + gapPct: 29, + gapSpoken: 'nearly a third', + saving: '£117,000', + savingSpoken: "That's well over a hundred thousand pounds back on an average home.", + distMeta: '1.9 km apart', + hook: + 'Childwall and Broadgreen sit under two kilometres apart, with the same schools and transport links.', center: { lat: 53.38344, lon: -2.88529 }, initialZoom: 12.0, - promptText: 'Good schools, best value per square metre near Childwall and Broadgreen', - filters: { - 'Est. price per sqm': [0, 3000], - 'Good+ secondary school catchments': [1, 11], - }, - posterTimeS: 13, - outroLine: "Childwall's cheaper twin is on this map. Find yours, free.", - cues: [ - { - text: 'Childwall and Broadgreen sit right next door, with the same schools and transport links.', - caption: 'Same area. Same schools.', - during: [typeAct('Good schools, best value per square metre near Childwall and Broadgreen', 2600)], - tail: [wait(150)], - }, - { - text: 'Rank every postcode by what each pound of floor space actually buys.', - caption: 'Ranked by £ per m²', - during: [submitSettled(1400)], - tail: [sheetDown(800), wait(250)], - }, - { - text: 'One postcode over, the same home quietly costs about a third less.', - caption: 'The cheaper twin', - during: [mapZoomIn(1400, 2)], - tail: [wait(900)], - }, - ], + matchCount: 1740, }, ]; + +const gbpPerSqm = (psqm: number): string => `£${psqm.toLocaleString('en-GB')} per m²`; + +function createTwinConfig(pair: TwinPair): DemoAdStoryboardConfig { + const promptText = + `Good schools, best value per square metre near ${pair.pricey.place} and ${pair.twin.place}`; + return { + name: pair.name, + matchCount: pair.matchCount, + city: pair.city, + center: pair.center, + initialZoom: pair.initialZoom, + promptText, + filters: { + // Wide cap: still a real filter (so the card and its Colour-map + // button render) but it keeps BOTH twins on the map. + [PSQM_FEATURE]: [0, 9000], + [SCHOOL_GOOD_SECONDARY]: [1, 11], + }, + // The coloured pair, mid payoff zoom. + posterTimeS: 13, + outroLine: `${pair.pricey.place}'s cheaper twin is on this map. Find yours, free.`, + cues: [ + { + text: pair.hook, + caption: 'Same trains. Same schools.', + during: [typeAct(promptText, 2600)], + tail: [wait(120)], + }, + { + text: 'Now colour every postcode by what a square metre of home actually costs there.', + caption: 'Coloured by £ per m²', + during: [submitSettled(1400), colourMapAct(PSQM_FEATURE), wait(300)], + tail: [sheetDown(700), wait(200)], + }, + { + // The legend chip is tiny, so the voice does the decode once: + // green = cheap floor space. Then the pair does the talking. + text: + `Green means cheaper floor space. ${pair.twin.place} runs ${pair.gapSpoken} ` + + `under ${pair.pricey.place}, street for street.`, + caption: `${pair.gapPct}% cheaper next door`, + during: [ + { + kind: 'mapZoom', + target: vfrac(0.5, 0.4), + steps: 2, + durationMs: 1500, + center: true, + waitForMapSettled: true, + timeoutMs: 9000, + }, + ], + tail: [wait(250)], + }, + { + // Receipt: the real sector figures, burned in for muted viewers. + text: pair.savingSpoken, + during: [ + { + kind: 'showAdScene', + scene: { + mode: 'split', + accent: 'teal', + kicker: 'The cheaper twin', + title: `${pair.distMeta}. Same catchments.`, + left: { + title: `${pair.pricey.sector} · ${pair.pricey.place}`, + subtitle: gbpPerSqm(pair.pricey.psqm), + tone: 'bad', + }, + right: { + title: `${pair.twin.sector} · ${pair.twin.place}`, + subtitle: gbpPerSqm(pair.twin.psqm), + meta: `${pair.gapPct}% cheaper`, + tone: 'good', + }, + footer: `${pair.saving} back on an average home`, + }, + durationMs: 0, + }, + ], + tail: [wait(400)], + }, + ], + }; +} + +const TWIN_AD_CONFIGS: DemoAdStoryboardConfig[] = TWIN_PAIRS.map(createTwinConfig); const TWIN_AD_STORYBOARDS = TWIN_AD_CONFIGS.map(createDemoAdStoryboard); const STORYBOARD_SET = process.env.VIDEO_STORYBOARD_SET ?? 'homepage-en'; diff --git a/video/tts/mux.py b/video/tts/mux.py index bfd9f54..bd3ada4 100644 --- a/video/tts/mux.py +++ b/video/tts/mux.py @@ -20,11 +20,49 @@ from __future__ import annotations import argparse import json +import re import shutil import subprocess import sys from pathlib import Path +# Social platforms (Instagram/TikTok/YouTube) normalise to roughly -14 LUFS. +# Mixing the narration WAVs raw left the muxed track at ~-24 LUFS: viewers +# had to crank the volume, which reads as low production quality. Target a +# touch under the platform norm so speech-only audio never pumps. +LOUDNORM_TARGET = "I=-15:TP=-1.5:LRA=11" + + +def measure_loudness(cmd_head: list[str], filter_complex: str) -> dict[str, str] | None: + """First loudnorm pass: measure the mixed track's loudness stats. + + Returns the measured values for the linear second pass, or None if the + measurement failed (in which case the caller falls back to one-pass). + """ + probe_cmd = cmd_head + [ + "-filter_complex", + f"{filter_complex};[aout]loudnorm={LOUDNORM_TARGET}:print_format=json[anorm]", + "-map", + "[anorm]", + "-f", + "null", + "-", + ] + result = subprocess.run(probe_cmd, capture_output=True, text=True) + if result.returncode != 0: + return None + match = re.search(r"\{[^{}]*\"input_i\"[^{}]*\}", result.stderr, re.DOTALL) + if not match: + return None + try: + stats = json.loads(match.group(0)) + except json.JSONDecodeError: + return None + keys = ("input_i", "input_tp", "input_lra", "input_thresh", "target_offset") + if not all(k in stats for k in keys): + return None + return stats + def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) @@ -134,9 +172,10 @@ def main() -> int: + "\n - ".join(overlaps) ) - cmd: list[str] = ["ffmpeg", "-y", "-loglevel", "warning", "-i", str(video_path)] + cmd_head: list[str] = ["ffmpeg", "-y", "-loglevel", "warning", "-i", str(video_path)] for it in items: - cmd += ["-i", str(audio_dir / it["wav"])] + cmd_head += ["-i", str(audio_dir / it["wav"])] + cmd = list(cmd_head) filter_parts: list[str] = [] mix_inputs: list[str] = [] @@ -156,13 +195,34 @@ def main() -> int: ) filter_complex = ";".join(filter_parts + [mix]) + # Two-pass EBU R128 normalisation: measure the mix, then apply a linear + # gain against the measured stats. linear=true avoids the dynamic + # (pumping) mode that one-pass loudnorm falls into on speech with pauses. + measured = measure_loudness(cmd_head, filter_complex) + if measured: + loudnorm = ( + f"loudnorm={LOUDNORM_TARGET}" + f":measured_I={measured['input_i']}" + f":measured_TP={measured['input_tp']}" + f":measured_LRA={measured['input_lra']}" + f":measured_thresh={measured['input_thresh']}" + f":offset={measured['target_offset']}" + f":linear=true" + ) + else: + print("[mux] loudness measurement failed; using one-pass loudnorm", file=sys.stderr) + loudnorm = f"loudnorm={LOUDNORM_TARGET}" + filter_complex = ( + f"{filter_complex};[aout]{loudnorm},aresample=48000[anorm]" + ) + cmd += [ "-filter_complex", filter_complex, "-map", "0:v:0", "-map", - "[aout]", + "[anorm]", "-c:v", "copy", "-c:a",