videos
This commit is contained in:
parent
6c6780fc60
commit
f7e0814a38
8 changed files with 206 additions and 92 deletions
|
|
@ -148,7 +148,12 @@ async function suppressDevServerNoise(context: BrowserContext) {
|
|||
iframe[id*="webpack"],
|
||||
[id*="webpack-dev-server-client"],
|
||||
[class*="error-overlay"],
|
||||
[class*="webpack-error"] {
|
||||
[class*="webpack-error"],
|
||||
/* Recording-only: hide dashboard chrome that reads as noise/loading
|
||||
on camera (the "Finding the Perfect Postcode" AI-status CTA). The
|
||||
data attribute is inert in production — only this injected rule
|
||||
targets it, and only during a recording. */
|
||||
[data-video-hide] {
|
||||
display: none !important;
|
||||
visibility: hidden !important;
|
||||
opacity: 0 !important;
|
||||
|
|
|
|||
|
|
@ -146,12 +146,16 @@ export async function installCursor(
|
|||
At the 540-wide CSS viewport this renders ~30px type → 60px in the
|
||||
published 1080-wide mp4: a proper social-video hook size. */
|
||||
body.__demo-aspect-vertical #__demo-caption {
|
||||
top: 8%;
|
||||
top: 14%;
|
||||
max-width: 88vw;
|
||||
font-size: 30px;
|
||||
font-weight: 850;
|
||||
padding: 12px 18px;
|
||||
border-radius: 12px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
#__demo-caption.visible {
|
||||
opacity: 1;
|
||||
|
|
@ -209,6 +213,13 @@ export async function installCursor(
|
|||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
#__demo-outro-subtitle {
|
||||
font: 500 22px/1.34 "Inter", system-ui, sans-serif;
|
||||
color: #cbd5e1;
|
||||
max-width: 30ch;
|
||||
margin: 2px auto 4px;
|
||||
text-align: center;
|
||||
}
|
||||
#__demo-outro-url {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
|
@ -228,6 +239,7 @@ export async function installCursor(
|
|||
comfortably inside the platform-safe centre column. */
|
||||
body.__demo-aspect-vertical #__demo-outro-brand { font-size: 64px; }
|
||||
body.__demo-aspect-vertical #__demo-outro-tagline { font-size: 26px; max-width: 22ch; }
|
||||
body.__demo-aspect-vertical #__demo-outro-subtitle { font-size: 20px; max-width: 24ch; }
|
||||
body.__demo-aspect-vertical #__demo-outro-url { font-size: 30px; padding: 16px 22px; }
|
||||
|
||||
.__ad-scene {
|
||||
|
|
@ -816,10 +828,11 @@ export async function showOutro(
|
|||
page: Page,
|
||||
brand: string,
|
||||
tagline: string,
|
||||
url: string
|
||||
url: string,
|
||||
subtitle?: string
|
||||
): Promise<void> {
|
||||
await page.evaluate(
|
||||
({ brand, tagline, url }) => {
|
||||
({ brand, tagline, url, subtitle }) => {
|
||||
document.getElementById('__demo-caption')?.classList.remove('visible');
|
||||
const el = document.createElement('div');
|
||||
el.id = '__demo-outro';
|
||||
|
|
@ -835,14 +848,22 @@ export async function showOutro(
|
|||
urlEl.id = '__demo-outro-url';
|
||||
// Drop the protocol so the CTA reads as a bare domain.
|
||||
urlEl.textContent = url.replace(/^https?:\/\//, '').replace(/\/$/, '');
|
||||
// Optional spoken-outro line, burned in for muted social viewers.
|
||||
if (subtitle && subtitle.length > 0) {
|
||||
const subtitleEl = document.createElement('div');
|
||||
subtitleEl.id = '__demo-outro-subtitle';
|
||||
subtitleEl.textContent = subtitle;
|
||||
card.append(brandEl, taglineEl, subtitleEl, urlEl);
|
||||
} else {
|
||||
card.append(brandEl, taglineEl, urlEl);
|
||||
}
|
||||
el.appendChild(card);
|
||||
document.body.appendChild(el);
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => el.classList.add('visible'));
|
||||
});
|
||||
},
|
||||
{ brand, tagline, url }
|
||||
{ brand, tagline, url, subtitle }
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -35,3 +35,38 @@ class NarrationLog {
|
|||
}
|
||||
|
||||
export const narrationLog = new NarrationLog();
|
||||
|
||||
function formatVttTimestamp(ms: number): string {
|
||||
const clamped = Math.max(0, Math.round(ms));
|
||||
const totalSeconds = Math.floor(clamped / 1000);
|
||||
const millis = clamped % 1000;
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
const pad = (n: number, width: number): string =>
|
||||
n.toString().padStart(width, '0');
|
||||
return `${pad(hours, 2)}:${pad(minutes, 2)}:${pad(seconds, 2)}.${pad(millis, 3)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render narration cues as a WebVTT document.
|
||||
*
|
||||
* Pure helper: takes cues (already ordered by the caller) and produces a
|
||||
* caption sidecar string. Cue text is trimmed and any internal blank lines
|
||||
* are collapsed so a single cue stays a single VTT block.
|
||||
*/
|
||||
export function cuesToVtt(cues: NarrationCue[]): string {
|
||||
let out = 'WEBVTT\n\n';
|
||||
for (const cue of cues) {
|
||||
const start = formatVttTimestamp(cue.videoTimeMs);
|
||||
const end = formatVttTimestamp(cue.videoTimeMs + cue.durationMs);
|
||||
const text = cue.text
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0)
|
||||
.join('\n')
|
||||
.trim();
|
||||
out += `${start} --> ${end}\n${text}\n\n`;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { existsSync, mkdirSync, statSync } from 'node:fs';
|
||||
import { existsSync, mkdirSync, statSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { AUTH_STATE_PATH, LEAD_IN_S, OUTPUT_DIR } from './config.js';
|
||||
import { assertHardwareWebGL, launchRecordingBrowser } from './browser.js';
|
||||
import { narrationLog } from './narration.js';
|
||||
import { cuesToVtt, narrationLog } from './narration.js';
|
||||
import { installDemoRoutes } from './routes.js';
|
||||
import type { Storyboard } from './script.js';
|
||||
import { storyboards } from './storyboard.js';
|
||||
|
|
@ -105,6 +105,11 @@ async function recordOne(storyboard: Storyboard): Promise<void> {
|
|||
console.log(
|
||||
`[${storyboard.name}] wrote ${cues.length} narration cues → ${join(dir, 'narration.json')}`
|
||||
);
|
||||
const vttPath = join(dir, 'narration.vtt');
|
||||
writeFileSync(vttPath, cuesToVtt(cues));
|
||||
console.log(
|
||||
`[${storyboard.name}] wrote ${cues.length} WebVTT captions → ${vttPath}`
|
||||
);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ async function stubAiFilters(page: Page, storyboard: Storyboard) {
|
|||
filters: storyboard.content.stubbedFilters,
|
||||
travel_time_filters: storyboard.content.stubbedTravelTimeFilters,
|
||||
notes: '',
|
||||
match_count: 1247,
|
||||
match_count: storyboard.content.matchCount ?? 1247,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -379,7 +379,7 @@ async function runActivity(ctx: ScriptCtx, step: Activity): Promise<void> {
|
|||
return;
|
||||
}
|
||||
case 'showOutro':
|
||||
await showOutro(ctx.page, step.brand, step.tagline, step.url);
|
||||
await showOutro(ctx.page, step.brand, step.tagline, step.url, step.subtitle);
|
||||
return;
|
||||
case 'showAdScene':
|
||||
await showAdScene(ctx.page, step.scene);
|
||||
|
|
|
|||
|
|
@ -172,7 +172,7 @@ export type Activity =
|
|||
timeoutMs?: number;
|
||||
}
|
||||
/** Reveal the closing brand card. */
|
||||
| { kind: 'showOutro'; brand: string; tagline: string; url: string; durationMs: number }
|
||||
| { kind: 'showOutro'; brand: string; tagline: string; url: string; subtitle?: string; durationMs: number }
|
||||
/** Reveal a full-screen ad-style overlay over the live map. */
|
||||
| { kind: 'showAdScene'; scene: AdScene; durationMs: number }
|
||||
/** Fade the ad overlay away. */
|
||||
|
|
@ -318,6 +318,13 @@ export interface ContentConfig {
|
|||
travelTimeDragFromMin: number;
|
||||
travelTimeDragToMin: number;
|
||||
brand: BrandConfig;
|
||||
/**
|
||||
* Stubbed "{{count}} properties match" total shown in the AI summary.
|
||||
* Set PER storyboard so different searches/cities don't all display the
|
||||
* same number (an identical count across cuts reads as fake). Falls back
|
||||
* to a default in routes.ts when omitted.
|
||||
*/
|
||||
matchCount?: number;
|
||||
}
|
||||
|
||||
export interface TravelTimeFilter {
|
||||
|
|
|
|||
|
|
@ -148,21 +148,21 @@ const RECORDING_LOCALIZATIONS: Record<RecordingLocale, RecordingLocalization> =
|
|||
colourMapTitle: 'Colour map',
|
||||
brand: {
|
||||
name: 'Perfect Postcode',
|
||||
tagline: 'Find the area before the house.',
|
||||
tagline: 'Find the hidden-gem postcode.',
|
||||
url: BRAND_URL,
|
||||
},
|
||||
cues: {
|
||||
hook: "You can't view your way to the right area.",
|
||||
hook: "A postcode's reputation is priced in. Its value isn't.",
|
||||
brief:
|
||||
'So start with the brief. Price, commute, schools, even how quiet the street is.',
|
||||
search: 'One search, and England shrinks to the postcodes that fit.',
|
||||
commute: 'Push the commute down, and the map answers in seconds.',
|
||||
'So start with the brief. Budget, commute, schools, even how quiet the street is.',
|
||||
search: 'One brief, and England narrows to the postcodes worth your money.',
|
||||
commute: 'Tighten the commute, and the keepers narrow further in seconds.',
|
||||
streets: 'Down at street level, the strongest streets start to stand out.',
|
||||
open:
|
||||
'Open one, and it shows its work. Sold prices, schools, crime, noise, broadband.',
|
||||
shortlist:
|
||||
'Shortlist the winners, export them, and only spend Saturdays where it counts.',
|
||||
outro: 'Stop guessing postcodes. Start choosing them.',
|
||||
'Keep the best-value few, export them, and scout where it actually counts.',
|
||||
outro: 'Stop paying for the name. Find the value.',
|
||||
},
|
||||
},
|
||||
de: {
|
||||
|
|
@ -183,21 +183,22 @@ const RECORDING_LOCALIZATIONS: Record<RecordingLocale, RecordingLocalization> =
|
|||
colourMapTitle: 'Karte einfärben',
|
||||
brand: {
|
||||
name: 'Perfect Postcode',
|
||||
tagline: 'Erst die Gegend, dann das Haus.',
|
||||
tagline: 'Finde die unterschätzte Postleitzahl.',
|
||||
url: BRAND_URL,
|
||||
},
|
||||
cues: {
|
||||
hook: 'Das richtige Viertel findest du nicht durch Besichtigungen.',
|
||||
hook: 'Der Ruf einer Gegend steckt im Preis. Ihr wahrer Wert nicht.',
|
||||
brief:
|
||||
'Fang mit dem Briefing an. Preis, Pendelzeit, Schulen, sogar wie ruhig die Straße ist.',
|
||||
search: 'Eine Suche, und England schrumpft auf die Postleitzahlen, die passen.',
|
||||
commute: 'Verkürze den Arbeitsweg, und die Karte antwortet in Sekunden.',
|
||||
'Also sag, was du suchst. Budget, Arbeitsweg, Schulen, sogar wie ruhig die Straße ist.',
|
||||
search:
|
||||
'Eine Vorgabe, und England schrumpft auf die Postleitzahlen, die dein Geld wert sind.',
|
||||
commute: 'Verschärf den Arbeitsweg, und in Sekunden bleiben nur die besten übrig.',
|
||||
streets: 'Auf Straßenebene stechen die besten Ecken von selbst hervor.',
|
||||
open:
|
||||
'Öffne eine, und sie zeigt ihre Daten. Verkaufspreise, Schulen, Kriminalität, Lärm, Internet.',
|
||||
'Öffne eine, und sie zeigt die Belege. Verkaufspreise, Schulen, Kriminalität, Lärm, Internet.',
|
||||
shortlist:
|
||||
'Speichere die Favoriten, exportiere sie, und verbringe deine Samstage nur dort, wo es zählt.',
|
||||
outro: 'Hör auf, Postleitzahlen zu raten. Fang an, sie zu wählen.',
|
||||
'Behalte die paar mit dem besten Preis-Leistungs-Verhältnis, exportier sie und schau sie dir vor Ort an.',
|
||||
outro: 'Zahl nicht für den Namen. Finde den Wert.',
|
||||
},
|
||||
},
|
||||
zh: {
|
||||
|
|
@ -216,18 +217,18 @@ const RECORDING_LOCALIZATIONS: Record<RecordingLocale, RecordingLocalization> =
|
|||
colourMapTitle: '地图着色',
|
||||
brand: {
|
||||
name: 'Perfect Postcode',
|
||||
tagline: '先选对街区,再选房子。',
|
||||
tagline: '发现那个被低估的邮区。',
|
||||
url: BRAND_URL,
|
||||
},
|
||||
cues: {
|
||||
hook: '光靠看房,看不出哪个街区适合你。',
|
||||
brief: '先列条件。预算、通勤、学校,甚至街道安不安静。',
|
||||
search: '搜索一次,全英格兰只剩下符合条件的邮编。',
|
||||
commute: '把通勤时间调短,地图几秒内就给出答案。',
|
||||
hook: '一个邮区的名气,早就算进了房价里。它真正的价值,却没有。',
|
||||
brief: '所以,把你的需求告诉它。预算、通勤、学校,甚至这条街够不够安静。',
|
||||
search: '一份需求,整个英格兰就缩小到那些真正值这个价的邮区。',
|
||||
commute: '把通勤再收紧一点,几秒钟,留下的好选择又少了一批。',
|
||||
streets: '放大到街道层面,好街区自己浮现出来。',
|
||||
open: '点开一个,它会摆出证据。成交价、学校、犯罪率、噪音、宽带。',
|
||||
shortlist: '把心仪的区域导出,周末只去值得去的地方。',
|
||||
outro: '别再猜邮编,开始挑邮编。',
|
||||
open: '点开一个,它把依据都摆给你看。成交价、学校、治安、噪音、宽带。',
|
||||
shortlist: '留下最划算的那几个,导出来,去真正值得的地方实地看房。',
|
||||
outro: '别再为名气买单。找到真正的价值。',
|
||||
},
|
||||
},
|
||||
hi: {
|
||||
|
|
@ -247,21 +248,21 @@ const RECORDING_LOCALIZATIONS: Record<RecordingLocale, RecordingLocalization> =
|
|||
colourMapTitle: 'मानचित्र रंगें',
|
||||
brand: {
|
||||
name: 'Perfect Postcode',
|
||||
tagline: 'Find the area before the house.',
|
||||
tagline: 'Find the hidden-gem postcode.',
|
||||
url: BRAND_URL,
|
||||
},
|
||||
cues: {
|
||||
hook: "You can't view your way to the right area.",
|
||||
hook: "A postcode's reputation is priced in. Its value isn't.",
|
||||
brief:
|
||||
'So start with the brief. Price, commute, schools, even how quiet the street is.',
|
||||
search: 'One search, and England shrinks to the postcodes that fit.',
|
||||
commute: 'Push the commute down, and the map answers in seconds.',
|
||||
'So start with the brief. Budget, commute, schools, even how quiet the street is.',
|
||||
search: 'One brief, and England narrows to the postcodes worth your money.',
|
||||
commute: 'Tighten the commute, and the keepers narrow further in seconds.',
|
||||
streets: 'Down at street level, the strongest streets start to stand out.',
|
||||
open:
|
||||
'Open one, and it shows its work. Sold prices, schools, crime, noise, broadband.',
|
||||
shortlist:
|
||||
'Shortlist the winners, export them, and only spend Saturdays where it counts.',
|
||||
outro: 'Stop guessing postcodes. Start choosing them.',
|
||||
'Keep the best-value few, export them, and scout where it actually counts.',
|
||||
outro: 'Stop paying for the name. Find the value.',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
@ -432,8 +433,8 @@ function createCues(locale: RecordingLocale, formFactor: FormFactor): Storyboard
|
|||
{
|
||||
kind: 'mapZoom',
|
||||
target: mapFocus,
|
||||
steps: 6,
|
||||
durationMs: 1800,
|
||||
steps: 5,
|
||||
durationMs: 1300,
|
||||
direction: 'out',
|
||||
},
|
||||
]
|
||||
|
|
@ -468,17 +469,19 @@ function createCues(locale: RecordingLocale, formFactor: FormFactor): Storyboard
|
|||
},
|
||||
{
|
||||
text: copy.cues.outro,
|
||||
gapBeforeMs: 450,
|
||||
gapBeforeMs: 350,
|
||||
during: [
|
||||
{
|
||||
kind: 'showOutro',
|
||||
brand: copy.brand.name,
|
||||
tagline: copy.brand.tagline,
|
||||
url: copy.brand.url,
|
||||
// Burn the spoken closer for sound-off / accessibility.
|
||||
subtitle: copy.cues.outro,
|
||||
durationMs: 0,
|
||||
},
|
||||
],
|
||||
tail: [{ kind: 'wait', durationMs: 1300 }],
|
||||
tail: [{ kind: 'wait', durationMs: 1700 }],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
@ -523,8 +526,10 @@ function buildVideoConfig(formFactor: FormFactor): VideoConfig {
|
|||
minDurationS: 10,
|
||||
maxDurationS: 75,
|
||||
// Right-pane inspection: London map, filters applied, data pane
|
||||
// open — the clearest paused-state preview.
|
||||
posterTimeS: 31,
|
||||
// FULLY populated (Street View + price history). 31s caught the pane
|
||||
// mid-load on prod (empty white + spinner); 35s lands a few seconds into
|
||||
// the "open" cue once the evidence has rendered — the strongest preview.
|
||||
posterTimeS: 35,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -537,11 +542,13 @@ function createRecordingStoryboard(
|
|||
formFactor: FormFactor
|
||||
): Storyboard {
|
||||
const copy = RECORDING_LOCALIZATIONS[locale];
|
||||
// Mobile bumps the initial zoom from 11.5 → 12 so the narrower 540-wide
|
||||
// viewport still shows an inner-London slice densely populated with
|
||||
// hexagons (otherwise the visible map gets dominated by the low-density
|
||||
// outer edges with no matches).
|
||||
const initialZoom = formFactor === 'mobile' ? 12 : 11.5;
|
||||
// Both form factors open at zoom 12 so the cold-open frame lands on a
|
||||
// densely-populated inner-London slice rather than the low-density outer
|
||||
// edges. Desktop was 11.5, but the opening lean-in panned the visible map
|
||||
// toward the sparse NW suburbs (Wembley/Ealing) — a soft first frame for
|
||||
// the hero. 12 keeps central London (rich default-density colouring) in
|
||||
// shot from t=0.
|
||||
const initialZoom = 12;
|
||||
|
||||
// On mobile the MobileBottomSheet covers the bottom ~44% of the
|
||||
// viewport, so the map's geographic centre sits roughly at the seam
|
||||
|
|
@ -609,6 +616,9 @@ function createRecordingStoryboard(
|
|||
travelTimeDragFromMin: TT_DRAG_FROM_MIN,
|
||||
travelTimeDragToMin: TT_DRAG_TO_MIN,
|
||||
brand: copy.brand,
|
||||
// A tight 7-filter inner-London search; a plausible total (not the
|
||||
// shared stub default) so the on-screen count reads as real.
|
||||
matchCount: 1843,
|
||||
},
|
||||
pre: buildPre(),
|
||||
cues: createCues(locale, formFactor),
|
||||
|
|
@ -665,6 +675,8 @@ interface DemoAdStoryboardConfig {
|
|||
travelTimeFilters?: TravelTimeFilter[];
|
||||
posterTimeS?: number;
|
||||
initialZoom?: number;
|
||||
/** Plausible per-city "{{count}} properties match" total for the AI summary. */
|
||||
matchCount?: number;
|
||||
/** Activities to run before the cue loop starts (silent — keep short). */
|
||||
prePrime?: Activity[];
|
||||
/** Spoken line during the outro card. Must NOT repeat the card's text. */
|
||||
|
|
@ -688,7 +700,7 @@ const AD_VIDEO: VideoConfig = {
|
|||
|
||||
const AD_BRAND = {
|
||||
name: 'Perfect Postcode',
|
||||
tagline: 'Search the area before you search the listings.',
|
||||
tagline: 'Find the hidden-gem postcode.',
|
||||
url: BRAND_URL,
|
||||
};
|
||||
|
||||
|
|
@ -825,6 +837,7 @@ function createDemoAdStoryboard(ad: DemoAdStoryboardConfig): Storyboard {
|
|||
travelTimeDragFromMin: TT_DRAG_FROM_MIN,
|
||||
travelTimeDragToMin: TT_DRAG_TO_MIN,
|
||||
brand: AD_BRAND,
|
||||
matchCount: ad.matchCount,
|
||||
},
|
||||
pre: ad.prePrime ?? AD_PRE,
|
||||
cues: [
|
||||
|
|
@ -844,10 +857,14 @@ function createDemoAdStoryboard(ad: DemoAdStoryboardConfig): Storyboard {
|
|||
brand: AD_BRAND.name,
|
||||
tagline: AD_BRAND.tagline,
|
||||
url: AD_BRAND.url,
|
||||
// Burn the spoken closer onto the card so muted social viewers
|
||||
// (the ads' real channel) still read the CTA.
|
||||
subtitle: ad.outroLine,
|
||||
durationMs: 0,
|
||||
},
|
||||
],
|
||||
tail: [wait(900)],
|
||||
// Hold the brand card long enough to read the URL + closer with sound off.
|
||||
tail: [wait(2200)],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
@ -865,6 +882,7 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
|
|||
// -------------------------------------------------------------------
|
||||
{
|
||||
name: 'ad-01-say-it',
|
||||
matchCount: 2410,
|
||||
city: 'london',
|
||||
promptText: 'Flat under £600k, 35 min to central London, low crime, quiet street',
|
||||
filters: {
|
||||
|
|
@ -878,23 +896,25 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
|
|||
],
|
||||
initialZoom: 10.2,
|
||||
posterTimeS: 8,
|
||||
outroLine: 'Your area is on this map. Go find it.',
|
||||
outroLine: 'The underpriced twin is on this map. Find it.',
|
||||
cues: [
|
||||
{
|
||||
text: 'This is my whole house brief. One sentence.',
|
||||
caption: 'One sentence. Every postcode.',
|
||||
text: 'My whole house brief is one plain sentence.',
|
||||
caption: "Reputation is priced in. Value isn't.",
|
||||
during: [
|
||||
typeAct('Flat under £600k, 35 min to central London, low crime, quiet street', 2600),
|
||||
],
|
||||
tail: [wait(150)],
|
||||
},
|
||||
{
|
||||
text: "And that's every postcode in England that fits it.",
|
||||
text: 'Every postcode in England that fits, sorted by value.',
|
||||
caption: 'All of England, by value',
|
||||
during: [submitSettled(1400)],
|
||||
tail: [sheetDown(800), wait(250)],
|
||||
},
|
||||
{
|
||||
text: 'Now I scroll a map, not nine hundred listings.',
|
||||
text: 'Same schools, same commute, the price quietly drops nearby.',
|
||||
caption: 'The cheaper twin nearby',
|
||||
during: [mapZoomIn(1400, 4)],
|
||||
tail: [wait(400)],
|
||||
},
|
||||
|
|
@ -907,6 +927,7 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
|
|||
// -------------------------------------------------------------------
|
||||
{
|
||||
name: 'ad-02-twenty-minute-map',
|
||||
matchCount: 38600,
|
||||
city: 'london',
|
||||
promptText: 'Within an hour of central London',
|
||||
filters: {},
|
||||
|
|
@ -915,7 +936,7 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
|
|||
],
|
||||
initialZoom: 10.2,
|
||||
posterTimeS: 9,
|
||||
outroLine: 'Check the commute before you fall in love.',
|
||||
outroLine: 'The commute is priced in. The bargain is not.',
|
||||
prePrime: [
|
||||
{ kind: 'clearVignette', durationMs: 0 },
|
||||
{ kind: 'cursorScale', scale: 0, durationMs: 0 },
|
||||
|
|
@ -932,17 +953,19 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
|
|||
cues: [
|
||||
{
|
||||
text: 'Every colour on this map is a commute to central London.',
|
||||
caption: 'The 20-minute map',
|
||||
caption: 'Same commute, cheaper postcode',
|
||||
during: [wait(400)],
|
||||
tail: [wait(150)],
|
||||
},
|
||||
{
|
||||
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)],
|
||||
},
|
||||
{
|
||||
text: "If it's not lit, you'd live on the train. Now you know before you view.",
|
||||
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)],
|
||||
},
|
||||
|
|
@ -955,6 +978,7 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
|
|||
// -------------------------------------------------------------------
|
||||
{
|
||||
name: 'ad-03-postcode-files',
|
||||
matchCount: 1290,
|
||||
city: 'london',
|
||||
initialFilters: {
|
||||
'Estimated current price': [0, 700000],
|
||||
|
|
@ -962,7 +986,7 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
|
|||
},
|
||||
initialZoom: 10.6,
|
||||
posterTimeS: 11,
|
||||
outroLine: "Read the area's file before you book the viewing.",
|
||||
outroLine: 'Every postcode, proven on the numbers, not its reputation.',
|
||||
prePrime: [
|
||||
{ kind: 'clearVignette', durationMs: 0 },
|
||||
{ kind: 'cursorScale', scale: 0, durationMs: 0 },
|
||||
|
|
@ -971,8 +995,8 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
|
|||
],
|
||||
cues: [
|
||||
{
|
||||
text: 'Estate agents say, up and coming. The data is more specific.',
|
||||
caption: 'Every postcode has a file',
|
||||
text: 'One name costs more. The block next door scores the same.',
|
||||
caption: 'See the proof, not the postcode',
|
||||
during: [
|
||||
{
|
||||
// Zoom around the strongest visible match so the destination
|
||||
|
|
@ -991,11 +1015,13 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
|
|||
},
|
||||
{
|
||||
text: 'Sold prices, schools, crime, even Street View, for this exact postcode.',
|
||||
caption: 'The whole postcode file',
|
||||
during: [touchShow(), tapHex(1000), scrollDrawer(420, 850)],
|
||||
tail: [wait(200)],
|
||||
},
|
||||
{
|
||||
text: 'Thirty seconds here saves you a Saturday on the wrong street.',
|
||||
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)],
|
||||
},
|
||||
|
|
@ -1007,6 +1033,7 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
|
|||
// -------------------------------------------------------------------
|
||||
{
|
||||
name: 'ad-04-quiet-streets',
|
||||
matchCount: 6420,
|
||||
city: 'london',
|
||||
promptText: 'Quiet London street under 55 decibels, low crime',
|
||||
filters: {
|
||||
|
|
@ -1015,21 +1042,23 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
|
|||
},
|
||||
initialZoom: 10.6,
|
||||
posterTimeS: 7,
|
||||
outroLine: 'Quiet is searchable now.',
|
||||
outroLine: 'The quiet street nobody bid up.',
|
||||
cues: [
|
||||
{
|
||||
text: 'Listing photos are silent. Main roads are not.',
|
||||
caption: "You can't hear a photo",
|
||||
caption: "The quiet that's overlooked",
|
||||
during: [typeAct('Quiet London street under 55 decibels, low crime', 2400)],
|
||||
tail: [wait(150)],
|
||||
},
|
||||
{
|
||||
text: 'This is London under fifty-five decibels.',
|
||||
caption: 'Under 55 decibels',
|
||||
during: [submitSettled(1400)],
|
||||
tail: [sheetDown(800), wait(250)],
|
||||
},
|
||||
{
|
||||
text: "The quiet streets were always there. Now they're searchable.",
|
||||
text: 'Nearby, just as quiet, and overlooked.',
|
||||
caption: 'Quiet, and overlooked',
|
||||
during: [mapZoomIn(1600, 5)],
|
||||
tail: [wait(400)],
|
||||
},
|
||||
|
|
@ -1041,6 +1070,7 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
|
|||
// -------------------------------------------------------------------
|
||||
{
|
||||
name: 'ad-05-school-run',
|
||||
matchCount: 3380,
|
||||
city: 'leeds',
|
||||
promptText: 'Family home in Leeds under £350k, good primary schools, low crime',
|
||||
filters: {
|
||||
|
|
@ -1049,24 +1079,26 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
|
|||
'Serious crime (avg/yr)': [0, 30],
|
||||
},
|
||||
initialZoom: 11.0,
|
||||
posterTimeS: 7,
|
||||
outroLine: 'Pick the area first. Then the house.',
|
||||
posterTimeS: 13,
|
||||
outroLine: 'The schools are real. The premium is optional.',
|
||||
cues: [
|
||||
{
|
||||
text: 'Good primary school, low crime, under three hundred and fifty. The actual family brief.',
|
||||
caption: 'The school-run map',
|
||||
text: 'Good primary, low crime, under three hundred and fifty. The actual family brief.',
|
||||
caption: 'Catchment, minus the premium',
|
||||
during: [
|
||||
typeAct('Family home in Leeds under £350k, good primary schools, low crime', 2800),
|
||||
],
|
||||
tail: [wait(150)],
|
||||
},
|
||||
{
|
||||
text: 'Everything still on the map passes all three.',
|
||||
text: 'Everything still on the map passes all three filters.',
|
||||
caption: 'Passes all three',
|
||||
during: [submitSettled(1400)],
|
||||
tail: [sheetDown(800), wait(250)],
|
||||
},
|
||||
{
|
||||
text: 'No more falling for a house, then googling the catchment at midnight.',
|
||||
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)],
|
||||
},
|
||||
|
|
@ -1079,6 +1111,7 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
|
|||
// -------------------------------------------------------------------
|
||||
{
|
||||
name: 'ad-06-waitrose-test',
|
||||
matchCount: 880,
|
||||
city: 'london',
|
||||
promptText: 'Walking distance to a Waitrose, a tube station and a park',
|
||||
filters: {
|
||||
|
|
@ -1088,21 +1121,23 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
|
|||
},
|
||||
initialZoom: 10.4,
|
||||
posterTimeS: 7,
|
||||
outroLine: 'Filter for the life, not just the floor plan.',
|
||||
outroLine: 'Same Waitrose. Same tube. Cheaper postcode.',
|
||||
cues: [
|
||||
{
|
||||
text: "Near a Waitrose, a tube stop and a park. Yes, that's a real search.",
|
||||
text: 'A Waitrose, a tube stop, a park nearby. Search by what you want.',
|
||||
caption: 'The Waitrose test',
|
||||
during: [typeAct('Walking distance to a Waitrose, a tube station and a park', 2800)],
|
||||
tail: [wait(150)],
|
||||
},
|
||||
{
|
||||
text: 'London, cut down to the postcodes that live the way you do.',
|
||||
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)],
|
||||
},
|
||||
{
|
||||
text: 'Snobby? Maybe. Efficient? Extremely.',
|
||||
text: 'Same amenities, lower price nearby.',
|
||||
caption: 'Same life, lower price',
|
||||
during: [mapZoomIn(1400, 4)],
|
||||
tail: [wait(400)],
|
||||
},
|
||||
|
|
@ -1114,6 +1149,7 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
|
|||
// -------------------------------------------------------------------
|
||||
{
|
||||
name: 'ad-07-renters-map',
|
||||
matchCount: 2160,
|
||||
city: 'london',
|
||||
promptText: 'Rent under £1,600, 30 min to central London, quiet street',
|
||||
filters: {
|
||||
|
|
@ -1124,22 +1160,24 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
|
|||
{ mode: 'transit', slug: 'london', label: 'Central London', max: 30 },
|
||||
],
|
||||
initialZoom: 10.3,
|
||||
posterTimeS: 7,
|
||||
outroLine: 'Know the postcode before the viewing queue.',
|
||||
posterTimeS: 13,
|
||||
outroLine: 'The name costs more. The street does not.',
|
||||
cues: [
|
||||
{
|
||||
text: 'Rent under sixteen hundred, half an hour to work, on a quiet street.',
|
||||
caption: 'Renters get a map too',
|
||||
text: 'Rent under sixteen hundred, half an hour to central, on a quiet street.',
|
||||
caption: 'Stop renting the reputation',
|
||||
during: [typeAct('Rent under £1,600, 30 min to central London, quiet street', 2800)],
|
||||
tail: [wait(150)],
|
||||
},
|
||||
{
|
||||
text: 'Every letting site shows you flats. This shows you areas.',
|
||||
text: 'Letting sites rank flats. This ranks the streets around them.',
|
||||
caption: 'Areas, not just flats',
|
||||
during: [submitSettled(1400)],
|
||||
tail: [sheetDown(800), wait(250)],
|
||||
},
|
||||
{
|
||||
text: 'Turn up to the right viewings, and skip the rest.',
|
||||
text: 'Same commute, same quiet, lower rent nearby.',
|
||||
caption: 'Same street, lower rent',
|
||||
during: [mapZoomIn(1600, 5)],
|
||||
tail: [wait(400)],
|
||||
},
|
||||
|
|
@ -1147,10 +1185,11 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
|
|||
},
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// 08 — Value. £9.99 against the real cost of a wasted viewing.
|
||||
// 08 — Value. The cost of overpaying for a name vs the one-off fee.
|
||||
// -------------------------------------------------------------------
|
||||
{
|
||||
name: 'ad-08-cheap-insurance',
|
||||
name: 'ad-08-value',
|
||||
matchCount: 1540,
|
||||
city: 'london',
|
||||
promptText: 'Under £500k, 35 min to central London, low crime, good schools',
|
||||
filters: {
|
||||
|
|
@ -1163,21 +1202,23 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
|
|||
],
|
||||
initialZoom: 10.4,
|
||||
posterTimeS: 7,
|
||||
outroLine: 'Skip the wrong viewings.',
|
||||
outroLine: 'Buy value, not reputation.',
|
||||
cues: [
|
||||
{
|
||||
text: 'A bad viewing costs a train ticket and half a weekend.',
|
||||
caption: '£9.99 vs a wasted Saturday',
|
||||
text: 'Two streets, same schools, same commute, priced tens of thousands apart.',
|
||||
caption: 'Two streets. One is overpriced.',
|
||||
during: [typeAct('Under £500k, 35 min to central London, low crime, good schools', 2600)],
|
||||
tail: [wait(150)],
|
||||
},
|
||||
{
|
||||
text: 'This costs nine ninety-nine, and tells you where not to go.',
|
||||
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)],
|
||||
},
|
||||
{
|
||||
text: 'Cheap insurance, for the biggest purchase of your life.',
|
||||
text: 'Pay once, find the bargain, stop overpaying for the name.',
|
||||
caption: 'Find the value instead',
|
||||
during: [mapZoomIn(1600, 5)],
|
||||
tail: [wait(400)],
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue