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 WEBVTT
00:00:00.203 --> 00:00:06.283 00:00:00.211 --> 00:00:07.251
Beckenham and Croydon sit side by side: same trains, same school catchment. Beckenham and Croydon sit two kilometres apart, on the same trains, in the same school catchments.
00:00:06.683 --> 00:00:10.523 00:00:07.551 --> 00:00:12.591
Rank them by what each pound of floor space actually buys. Now colour every postcode by what a square metre of home actually costs there.
00:00:11.823 --> 00:00:16.943 00:00:13.671 --> 00:00:19.831
One postcode over, the same home quietly costs about a third less. 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. Beckenham's cheaper twin is on this map. Find yours, free.

View file

@ -1,14 +1,17 @@
WEBVTT WEBVTT
00:00:00.202 --> 00:00:05.882 00:00:00.203 --> 00:00:06.683
Stanmore and Kenton sit right next door, with the same schools and transport links. 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 00:00:06.983 --> 00:00:12.023
Rank every postcode by what each pound of floor space actually buys. Now colour every postcode by what a square metre of home actually costs there.
00:00:12.142 --> 00:00:17.422 00:00:13.103 --> 00:00:19.103
One postcode over, the same home quietly costs about a sixth less. 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. Stanmore's cheaper twin is on this map. Find yours, free.

View file

@ -1,14 +1,17 @@
WEBVTT WEBVTT
00:00:00.203 --> 00:00:06.843 00:00:00.201 --> 00:00:07.081
Childwall and Broadgreen sit right next door, with the same schools and transport links. Childwall and Broadgreen sit under two kilometres apart, with the same schools and transport links.
00:00:07.243 --> 00:00:11.803 00:00:07.381 --> 00:00:12.421
Rank every postcode by what each pound of floor space actually buys. Now colour every postcode by what a square metre of home actually costs there.
00:00:13.103 --> 00:00:18.223 00:00:13.501 --> 00:00:19.901
One postcode over, the same home quietly costs about a third less. 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. 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 touchShow = (): Activity => ({ kind: 'cursorScale', scale: 1, durationMs: 150 });
const touchHide = (): Activity => ({ kind: 'cursorScale', scale: 0, 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 * 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 * latest map response (robust to zoom drift: a fixed pixel target at deep
@ -796,6 +790,38 @@ const tapHex = (durationMs = 1000): Activity => ({
timeoutMs: 8000, 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. // Right-pane / drawer used after a postcode tap.
const RIGHT_PANE_SELECTOR = '[data-tutorial="right-pane"]'; const RIGHT_PANE_SELECTOR = '[data-tutorial="right-pane"]';
const scrollDrawer = (top: number, durationMs = 800): Activity => ({ const scrollDrawer = (top: number, durationMs = 800): Activity => ({
@ -850,14 +876,20 @@ function createDemoAdStoryboard(ad: DemoAdStoryboardConfig): Storyboard {
...ad.cues.map((cue, index) => ({ ...ad.cues.map((cue, index) => ({
text: cue.text, text: cue.text,
caption: cue.caption, 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, during: cue.during,
tail: cue.tail ?? [wait(300)], tail: cue.tail ?? [wait(200)],
})), })),
{ {
text: ad.outroLine, text: ad.outroLine,
gapBeforeMs: 250, gapBeforeMs: 200,
during: [ 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', kind: 'showOutro',
brand: AD_BRAND.name, brand: AD_BRAND.name,
@ -869,8 +901,8 @@ function createDemoAdStoryboard(ad: DemoAdStoryboardConfig): Storyboard {
durationMs: 0, durationMs: 0,
}, },
], ],
// Hold the brand card long enough to read the URL + closer with sound off. // Just long enough to read the URL with sound off.
tail: [wait(2200)], tail: [wait(1100)],
}, },
], ],
}; };
@ -901,7 +933,7 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
{ mode: 'transit', slug: 'london', label: 'Central London', max: 35 }, { mode: 'transit', slug: 'london', label: 'Central London', max: 35 },
], ],
initialZoom: 10.2, initialZoom: 10.2,
posterTimeS: 8, posterTimeS: 12,
outroLine: 'The underpriced twin is on this map. Find it.', outroLine: 'The underpriced twin is on this map. Find it.',
cues: [ cues: [
{ {
@ -910,19 +942,22 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
during: [ during: [
typeAct('Flat under £600k, 35 min to central London, low crime, quiet street', 2600), 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.', text: 'Every postcode in England that fits, sorted by value.',
caption: 'All of England, by value', caption: 'All of England, by value',
during: [submitSettled(1400)], during: [submitSettled(1400), colourMapAct('Estimated current price'), wait(300)],
tail: [sheetDown(800), wait(250)], tail: [sheetDown(700), wait(200)],
}, },
{ {
text: 'Same schools, same commute, the price quietly drops nearby.', text: 'Same schools, same commute, the price quietly drops nearby.',
caption: 'The cheaper twin nearby', caption: 'The cheaper twin nearby',
during: [mapZoomIn(1400, 4)], during: [payoffZoom(1600, 4)],
tail: [wait(400)], tail: [wait(300)],
}, },
], ],
}, },
@ -967,13 +1002,13 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
text: "Here's what twenty minutes actually leaves you.", text: "Here's what twenty minutes actually leaves you.",
caption: 'What 20 minutes buys', caption: 'What 20 minutes buys',
during: [sheetUp(700), touchShow(), ttDragAct(20, 1700)], 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.", text: "Same twenty minutes wherever it's lit, but not the same price.",
caption: 'Same ride, lower price', caption: 'Same ride, lower price',
during: [mapZoomIn(1300, 3)], during: [payoffZoom(1500, 3)],
tail: [wait(400)], tail: [wait(300)],
}, },
], ],
}, },
@ -991,7 +1026,7 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
[SCHOOL_OUTSTANDING_PRIMARY]: [1, 10], [SCHOOL_OUTSTANDING_PRIMARY]: [1, 10],
}, },
initialZoom: 10.6, initialZoom: 10.6,
posterTimeS: 11, posterTimeS: 13,
outroLine: 'Every postcode, proven on the numbers, not its reputation.', outroLine: 'Every postcode, proven on the numbers, not its reputation.',
prePrime: [ prePrime: [
{ kind: 'clearVignette', durationMs: 0 }, { kind: 'clearVignette', durationMs: 0 },
@ -1020,16 +1055,31 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
tail: [wait(150)], 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.', text: 'Sold prices, schools, crime, even Street View, for this exact postcode.',
caption: 'The whole postcode file', caption: 'The whole postcode file',
during: [touchShow(), tapHex(1000), scrollDrawer(420, 850)], during: [touchShow(), tapHex(1100), wait(600), scrollDrawer(420, 800)],
tail: [wait(200)], 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.', text: 'The same evidence a pricey postcode has, sitting quietly cheaper here.',
caption: 'Same proof, less money', caption: 'Same proof, less money',
during: [scrollDrawer(820, 900), touchHide()], during: [
tail: [wait(350)], {
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], 'Serious crime (/yr, 7y)': [0, 35],
}, },
initialZoom: 10.6, initialZoom: 10.6,
posterTimeS: 7, posterTimeS: 11,
outroLine: 'The quiet street nobody bid up.', outroLine: 'The quiet street nobody bid up.',
cues: [ cues: [
{ {
@ -1057,16 +1107,18 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
tail: [wait(150)], 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.', text: 'This is London under fifty-five decibels.',
caption: 'Under 55 decibels', caption: 'Under 55 decibels',
during: [submitSettled(1400)], during: [submitSettled(1400), colourMapAct('Noise (dB)'), wait(300)],
tail: [sheetDown(800), wait(250)], tail: [sheetDown(700), wait(200)],
}, },
{ {
text: 'Nearby, just as quiet, and overlooked.', text: 'Nearby, just as quiet, and overlooked.',
caption: 'Quiet, and overlooked', caption: 'Quiet, and overlooked',
during: [mapZoomIn(1600, 5)], during: [payoffZoom(1700, 4)],
tail: [wait(400)], tail: [wait(300)],
}, },
], ],
}, },
@ -1097,16 +1149,18 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
tail: [wait(150)], 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.', text: 'Everything still on the map passes all three filters.',
caption: 'Passes all three', caption: 'Passes all three',
during: [submitSettled(1400)], during: [submitSettled(1400), colourMapAct('Estimated current price'), wait(300)],
tail: [sheetDown(800), wait(250)], tail: [sheetDown(700), wait(200)],
}, },
{ {
text: 'Same primary, same low crime, without the name everyone else is bidding up.', text: 'Same primary, same low crime, without the name everyone else is bidding up.',
caption: 'Same catchment, less money', caption: 'Same catchment, less money',
during: [mapZoomIn(1600, 5)], during: [payoffZoom(1700, 4)],
tail: [wait(400)], tail: [wait(300)],
}, },
], ],
}, },
@ -1126,7 +1180,7 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
'Distance to nearest amenity (Park) (km)': [0, 0.5], 'Distance to nearest amenity (Park) (km)': [0, 0.5],
}, },
initialZoom: 10.4, initialZoom: 10.4,
posterTimeS: 7, posterTimeS: 12,
outroLine: 'Same Waitrose. Same tube. Cheaper postcode.', outroLine: 'Same Waitrose. Same tube. Cheaper postcode.',
cues: [ cues: [
{ {
@ -1136,16 +1190,22 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
tail: [wait(150)], 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.', text: 'London, narrowed to the postcodes that fit the way you live.',
caption: 'Fits how you live', caption: 'Fits how you live',
during: [submitSettled(1400)], during: [
tail: [sheetDown(800), wait(250)], submitSettled(1400),
colourMapAct('Distance to nearest amenity (Waitrose) (km)'),
wait(300),
],
tail: [sheetDown(700), wait(200)],
}, },
{ {
text: 'Same amenities, lower price nearby.', text: 'Same amenities, lower price nearby.',
caption: 'Same life, lower price', caption: 'Same life, lower price',
during: [mapZoomIn(1400, 4)], during: [payoffZoom(1500, 4)],
tail: [wait(400)], tail: [wait(300)],
}, },
], ],
}, },
@ -1176,16 +1236,18 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
tail: [wait(150)], 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.', text: 'Letting sites rank flats. This ranks the streets around them.',
caption: 'Areas, not just flats', caption: 'Areas, not just flats',
during: [submitSettled(1400)], during: [submitSettled(1400), colourMapAct('Estimated monthly rent'), wait(300)],
tail: [sheetDown(800), wait(250)], tail: [sheetDown(700), wait(200)],
}, },
{ {
text: 'Same commute, same quiet, lower rent nearby.', text: 'Same commute, same quiet, lower rent nearby.',
caption: 'Same street, lower rent', caption: 'Same street, lower rent',
during: [mapZoomIn(1600, 5)], during: [payoffZoom(1600, 4)],
tail: [wait(400)], tail: [wait(300)],
}, },
], ],
}, },
@ -1207,7 +1269,7 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
{ mode: 'transit', slug: 'london', label: 'Central London', max: 35 }, { mode: 'transit', slug: 'london', label: 'Central London', max: 35 },
], ],
initialZoom: 10.4, initialZoom: 10.4,
posterTimeS: 7, posterTimeS: 12,
outroLine: 'Buy value, not reputation.', outroLine: 'Buy value, not reputation.',
cues: [ cues: [
{ {
@ -1219,14 +1281,14 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
{ {
text: "You pay for the postcode's name; the value sits a postcode away.", text: "You pay for the postcode's name; the value sits a postcode away.",
caption: 'You pay for the name', caption: 'You pay for the name',
during: [submitSettled(1400)], during: [submitSettled(1400), colourMapAct('Estimated current price'), wait(300)],
tail: [sheetDown(800), wait(250)], tail: [sheetDown(700), wait(200)],
}, },
{ {
text: 'Pay once, find the bargain, stop overpaying for the name.', text: 'Pay once, find the bargain, stop overpaying for the name.',
caption: 'Find the value instead', caption: 'Find the value instead',
during: [mapZoomIn(1600, 5)], during: [payoffZoom(1600, 4)],
tail: [wait(400)], tail: [wait(300)],
}, },
], ],
}, },
@ -1236,115 +1298,187 @@ const AD_STORYBOARDS = AD_CONFIGS.map(createDemoAdStoryboard);
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Cheaper-twin spots: pair-centred value reveals generated from the // 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 // 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', name: 'twin-beckenham-croydon',
matchCount: 1840,
city: 'london', 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 }, center: { lat: 51.36969, lon: -0.04244 },
initialZoom: 12.2, initialZoom: 12.2,
promptText: 'Good schools, best value per square metre near Beckenham and Croydon', matchCount: 1840,
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)],
},
],
}, },
{ {
name: 'twin-ha7-2-vs-ha3-0', name: 'twin-ha7-2-vs-ha3-0',
matchCount: 1620,
city: 'london', 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 }, center: { lat: 51.57199, lon: -0.3079 },
initialZoom: 12.0, initialZoom: 12.0,
promptText: 'Good schools, best value per square metre near Stanmore and Kenton', matchCount: 1620,
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)],
},
],
}, },
{ {
name: 'twin-l16-7-vs-l14-6', name: 'twin-l16-7-vs-l14-6',
matchCount: 1740,
city: 'liverpool', 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 }, center: { lat: 53.38344, lon: -2.88529 },
initialZoom: 12.0, initialZoom: 12.0,
promptText: 'Good schools, best value per square metre near Childwall and Broadgreen', matchCount: 1740,
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)],
},
],
}, },
]; ];
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 TWIN_AD_STORYBOARDS = TWIN_AD_CONFIGS.map(createDemoAdStoryboard);
const STORYBOARD_SET = process.env.VIDEO_STORYBOARD_SET ?? 'homepage-en'; const STORYBOARD_SET = process.env.VIDEO_STORYBOARD_SET ?? 'homepage-en';

View file

@ -20,11 +20,49 @@ from __future__ import annotations
import argparse import argparse
import json import json
import re
import shutil import shutil
import subprocess import subprocess
import sys import sys
from pathlib import Path 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: def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__) parser = argparse.ArgumentParser(description=__doc__)
@ -134,9 +172,10 @@ def main() -> int:
+ "\n - ".join(overlaps) + "\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: 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] = [] filter_parts: list[str] = []
mix_inputs: list[str] = [] mix_inputs: list[str] = []
@ -156,13 +195,34 @@ def main() -> int:
) )
filter_complex = ";".join(filter_parts + [mix]) 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 += [ cmd += [
"-filter_complex", "-filter_complex",
filter_complex, filter_complex,
"-map", "-map",
"0:v:0", "0:v:0",
"-map", "-map",
"[aout]", "[anorm]",
"-c:v", "-c:v",
"copy", "copy",
"-c:a", "-c:a",