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

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