new storyboard
Some checks failed
Build and publish Docker image / build-and-push (push) Successful in 8m50s
CI / Check (push) Failing after 10m22s

This commit is contained in:
Andras Schmelczer 2026-07-13 21:03:38 +01:00
parent f14981afee
commit cf348c3ea4
5 changed files with 362 additions and 159 deletions

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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';

View file

@ -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",