new storyboard
This commit is contained in:
parent
f14981afee
commit
cf348c3ea4
5 changed files with 362 additions and 159 deletions
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue