wip
This commit is contained in:
parent
f1601257c7
commit
c2070693fb
68 changed files with 2305 additions and 212 deletions
|
|
@ -8,7 +8,7 @@ import { ensureRecorderAdminUser } from './pb-admin.js';
|
|||
* 1. Programmatic (preferred for CI / non-interactive runs): set
|
||||
* LOGIN_EMAIL and LOGIN_PASSWORD env vars (the same email/password you'd
|
||||
* type into the dashboard's login modal). We drive the actual login form
|
||||
* in a headless browser — same path a real user takes, no knowledge of
|
||||
* in a headless browser: the same path a real user takes, no knowledge of
|
||||
* the PocketBase REST endpoint required.
|
||||
*
|
||||
* 2. Interactive: no env vars, we open a headed browser, you log in by hand,
|
||||
|
|
@ -49,7 +49,7 @@ async function programmatic() {
|
|||
{ timeout: 15_000 }
|
||||
);
|
||||
|
||||
// Skip the react-joyride product tour — its spotlight overlay intercepts
|
||||
// Skip the react-joyride product tour: its spotlight overlay intercepts
|
||||
// pointer events and breaks the recording.
|
||||
await page.evaluate(() => { localStorage.setItem('tutorial_completed', '1'); });
|
||||
|
||||
|
|
|
|||
|
|
@ -151,7 +151,7 @@ async function suppressDevServerNoise(context: BrowserContext) {
|
|||
[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
|
||||
data attribute is inert in production: only this injected rule
|
||||
targets it, and only during a recording. */
|
||||
[data-video-hide] {
|
||||
display: none !important;
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ function requiredEnv(name: string): string {
|
|||
}
|
||||
|
||||
// Environment-only knobs. Per-storyboard tuning (aspect, fps, bitrate,
|
||||
// voice, prompts, brand…) lives on the Storyboard object itself — see
|
||||
// voice, prompts, brand…) lives on the Storyboard object itself. See
|
||||
// src/storyboard.ts.
|
||||
|
||||
export const APP_URL = requiredEnv('APP_URL');
|
||||
|
|
@ -20,5 +20,5 @@ export const OUTPUT_DIR = 'output';
|
|||
|
||||
// Frames of head-room kept in front of sceneStart when trimming. Shared by
|
||||
// the video trim and the narration manifest so cue offsets line up with the
|
||||
// trimmed timeline. Not tuned per storyboard — same lead-in for any cut.
|
||||
// trimmed timeline. Not tuned per storyboard: same lead-in for any cut.
|
||||
export const LEAD_IN_S = 0.12;
|
||||
|
|
|
|||
|
|
@ -102,7 +102,7 @@ export class DashboardRecorder {
|
|||
/**
|
||||
* Best-effort wait for tracked API traffic to go quiet (250ms of no
|
||||
* pending requests and no loading indicator). Unlike waitForStable this
|
||||
* never throws — it simply returns at the deadline. Used before computing
|
||||
* never throws: it simply returns at the deadline. Used before computing
|
||||
* hexagon click targets so the projection runs against the response for
|
||||
* the CURRENT viewport rather than one captured mid-animation.
|
||||
*/
|
||||
|
|
@ -133,7 +133,7 @@ export class DashboardRecorder {
|
|||
// Empty responses don't replace the snapshot (parse* skips them), so a
|
||||
// snapshot whose bounds differ from the latest REQUEST means the current
|
||||
// view has zero matching features and we'd be projecting stale data.
|
||||
// Surface that loudly — it almost always means the storyboard's filters
|
||||
// Surface that loudly: it almost always means the storyboard's filters
|
||||
// emptied the area it zoomed into.
|
||||
const snapshotBounds = this.lastPostcodes?.bounds ?? this.lastHexagons?.bounds;
|
||||
if (snapshotBounds && this.lastRequestedMapBounds) {
|
||||
|
|
@ -141,7 +141,7 @@ export class DashboardRecorder {
|
|||
if (snapKey !== this.lastRequestedMapBounds) {
|
||||
console.log(
|
||||
`[dashboard] WARNING: map snapshot is stale (snapshot bounds ${snapKey} ` +
|
||||
`vs latest request ${this.lastRequestedMapBounds}) — the current view ` +
|
||||
`vs latest request ${this.lastRequestedMapBounds}). The current view ` +
|
||||
`likely has no matching features; clicks may land on empty map.`
|
||||
);
|
||||
}
|
||||
|
|
@ -221,7 +221,7 @@ export class DashboardRecorder {
|
|||
}
|
||||
|
||||
/**
|
||||
* The pixel rect inside `mapBox` that's safe to click — i.e. not under
|
||||
* The pixel rect inside `mapBox` that's safe to click, i.e. not under
|
||||
* the dashboard's left filters pane, right details pane, or (on mobile)
|
||||
* the floating MobileBottomSheet. We detect the sheet via the only
|
||||
* `section.rounded-t-2xl` in the DOM and treat its top as a hard
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@ import type { AdScene, AdScenePanel } from './script.js';
|
|||
* native cursor is hidden so what the viewer sees is entirely our element.
|
||||
*
|
||||
* Design choice: the cursor listens to mousemove rather than being driven from
|
||||
* the Node side. That keeps a single source of truth — Playwright's real mouse
|
||||
* — and the visual is pure CSS, animated by the browser's compositor.
|
||||
* the Node side. That keeps a single source of truth (Playwright's real mouse),
|
||||
* and the visual is pure CSS, animated by the browser's compositor.
|
||||
*/
|
||||
export async function installCursor(
|
||||
page: Page,
|
||||
|
|
@ -235,7 +235,7 @@ export async function installCursor(
|
|||
letter-spacing: -0.01em;
|
||||
color: #5eead4;
|
||||
}
|
||||
/* Tighter outro for vertical 9:16 — the brand/url stack must fit
|
||||
/* Tighter outro for vertical 9:16. The brand/url stack must fit
|
||||
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; }
|
||||
|
|
@ -270,7 +270,7 @@ export async function installCursor(
|
|||
-webkit-backdrop-filter: blur(3px) saturate(0.92);
|
||||
}
|
||||
/*
|
||||
* Transparent mode: no scrim, no blur — the product stays fully
|
||||
* Transparent mode: no scrim, no blur, so the product stays fully
|
||||
* visible behind a floating kicker + title. Used by mid-cue hook
|
||||
* stings ("Postcode polygraph") that should not occlude the demo.
|
||||
*/
|
||||
|
|
@ -475,7 +475,7 @@ export async function installCursor(
|
|||
/*
|
||||
* Mobile (9x16) renders the dashboard at CSS 540x960 with captureScale 2.
|
||||
* That means the AdScene CSS is sizing against a 540px-wide viewport, so
|
||||
* a 64px title is ~12% of viewport width per line — still big and bold,
|
||||
* a 64px title is ~12% of viewport width per line, still big and bold,
|
||||
* but no longer overflows the available space the way 82px did on the
|
||||
* old 1080-wide ad config. Captions also re-anchor to the upper third
|
||||
* via body.__demo-aspect-vertical.
|
||||
|
|
@ -601,7 +601,7 @@ export async function clearVignette(page: Page): Promise<void> {
|
|||
* Tag the document body with the aspect class the caption / overlay CSS keys
|
||||
* off. Run once during recorder setup so every cue inherits the right
|
||||
* positioning without per-call overrides. The body class is the cheapest
|
||||
* stable signal — the storyboard's `video.aspect` knows the truth and we
|
||||
* stable signal. The storyboard's `video.aspect` knows the truth and we
|
||||
* surface it once into the DOM.
|
||||
*/
|
||||
export async function setAspectClass(
|
||||
|
|
@ -741,7 +741,7 @@ export async function showAdScene(page: Page, scene: AdScene): Promise<void> {
|
|||
img.className = '__ad-image';
|
||||
// Skip crossOrigin so the request goes through as a vanilla
|
||||
// image fetch and CORS headers on the Unsplash CDN are not
|
||||
// required. We never read pixels back out — display only.
|
||||
// required. We never read pixels back out: display only.
|
||||
img.referrerPolicy = 'no-referrer';
|
||||
img.onerror = () => img.remove();
|
||||
img.src = src;
|
||||
|
|
@ -872,7 +872,7 @@ export async function showOutro(
|
|||
* without dragging the cursor/caption/outro overlays along with it.
|
||||
*
|
||||
* Why a wrapper and not <body>: a transformed ancestor establishes a new
|
||||
* containing block for `position: fixed` descendants — meaning fixed
|
||||
* containing block for `position: fixed` descendants, meaning fixed
|
||||
* overlays inside the transform get scaled too. By wrapping ONLY #root
|
||||
* and leaving the overlays as siblings of the wrapper, the cursor stays
|
||||
* at native size while the dashboard zooms behind it.
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { storyboards } from './storyboard.js';
|
|||
* Emit per-storyboard narration scripts for the synth step.
|
||||
*
|
||||
* Synth (tts/synth.py) runs BEFORE recording, so it needs the full ordered
|
||||
* narration list — text + per-cue gaps + voice config — without depending
|
||||
* narration list (text + per-cue gaps + voice config) without depending
|
||||
* on Playwright, the dashboard, or auth. Walk each storyboard's cues, write
|
||||
* a flat manifest under `output/<name>/narration-script.json`, then write
|
||||
* an index manifest at `output/storyboards.json` so render.sh knows which
|
||||
|
|
@ -18,11 +18,11 @@ import { storyboards } from './storyboard.js';
|
|||
* matches storyboard cues to measured durations by index.
|
||||
*/
|
||||
// Em/en-dashes and ellipses make Qwen3-TTS produce dramatic pauses, sighs,
|
||||
// or audible breaths — the captions still render the original (unicode-rich)
|
||||
// or audible breaths. The captions still render the original (unicode-rich)
|
||||
// text from the storyboard; only the synth input is sanitised.
|
||||
function normalizeForTts(text: string): string {
|
||||
return text
|
||||
.replace(/\s*[—–]\s*/g, ', ')
|
||||
.replace(/\s*[\u2014\u2013]\s*/g, ', ')
|
||||
.replace(/…/g, '.')
|
||||
.replace(/\.{3,}/g, '.')
|
||||
.replace(/\s{2,}/g, ' ')
|
||||
|
|
@ -39,7 +39,7 @@ function emitScript(storyboard: Storyboard): string {
|
|||
gapBeforeMs: cue.gapBeforeMs,
|
||||
}));
|
||||
|
||||
// The voice block is consumed by tts/synth.py — see _resolve_reference and
|
||||
// The voice block is consumed by tts/synth.py. See _resolve_reference and
|
||||
// the cache check there for which fields invalidate cached audio.
|
||||
const manifest = {
|
||||
storyboard: storyboard.name,
|
||||
|
|
@ -61,7 +61,7 @@ function emitScript(storyboard: Storyboard): string {
|
|||
|
||||
/**
|
||||
* Validate every stubbed/initial filter name and travel-destination slug
|
||||
* against the LIVE API. Wrong names don't error in the app — they silently
|
||||
* against the LIVE API. Wrong names don't error in the app: they silently
|
||||
* no-op, the map never changes, and you only find out after a full render.
|
||||
* Fails hard on a mismatch; soft-warns if the API is unreachable (render.sh
|
||||
* has already health-checked it by the time preflight runs).
|
||||
|
|
@ -69,7 +69,7 @@ function emitScript(storyboard: Storyboard): string {
|
|||
async function validateAgainstLiveApi(): Promise<void> {
|
||||
const apiBase = process.env.API_URL ?? process.env.APP_URL;
|
||||
if (!apiBase) {
|
||||
console.warn('[preflight] no API_URL/APP_URL set — skipping live filter validation');
|
||||
console.warn('[preflight] no API_URL/APP_URL set, skipping live filter validation');
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -82,7 +82,7 @@ async function validateAgainstLiveApi(): Promise<void> {
|
|||
};
|
||||
featureNames = new Set(body.groups.flatMap((g) => g.features.map((f) => f.name)));
|
||||
} catch (err) {
|
||||
console.warn(`[preflight] could not fetch ${apiBase}/api/features (${err}) — skipping validation`);
|
||||
console.warn(`[preflight] could not fetch ${apiBase}/api/features (${err}), skipping validation`);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -134,7 +134,7 @@ async function main(): Promise<void> {
|
|||
|
||||
for (const sb of storyboards) emitScript(sb);
|
||||
|
||||
// Index for shell loops — each entry has every field render.sh needs to
|
||||
// Index for shell loops: each entry has every field render.sh needs to
|
||||
// address per-storyboard outputs without re-parsing the TS source.
|
||||
const index = {
|
||||
storyboards: storyboards.map((sb) => ({
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { viewportFor } from './script.js';
|
|||
import { storyboards } from './storyboard.js';
|
||||
|
||||
async function main() {
|
||||
// probe is a debug utility — pin it to the first storyboard's viewport.
|
||||
// probe is a debug utility: pin it to the first storyboard's viewport.
|
||||
const viewport = viewportFor(storyboards[0].video);
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({
|
||||
|
|
|
|||
|
|
@ -115,7 +115,7 @@ async function runCue(
|
|||
`Trim a during step, lengthen the cue text, or move work into tail.`
|
||||
);
|
||||
}
|
||||
// Time the during block as a whole — individual steps may overrun their
|
||||
// Time the during block as a whole: individual steps may overrun their
|
||||
// budgets, but what matters at the cue boundary is total wall-clock.
|
||||
const duringStart = Date.now();
|
||||
for (const step of during) {
|
||||
|
|
@ -393,7 +393,7 @@ async function runActivity(ctx: ScriptCtx, step: Activity): Promise<void> {
|
|||
case 'dragSheet': {
|
||||
const sheet = ctx.page.locator('section[class*="rounded-t-2xl"]').first();
|
||||
const sheetBox = await sheet.boundingBox().catch(() => null);
|
||||
if (!sheetBox) return; // desktop layout — nothing to drag
|
||||
if (!sheetBox) return; // desktop layout: nothing to drag
|
||||
const handle = ctx.page
|
||||
.locator('section[class*="rounded-t-2xl"] [class*="touch-none"]')
|
||||
.first();
|
||||
|
|
@ -427,7 +427,7 @@ async function runActivity(ctx: ScriptCtx, step: Activity): Promise<void> {
|
|||
}
|
||||
case 'openFilterGroup':
|
||||
// Click is idempotent: if the group is already expanded, the click
|
||||
// would collapse it — which we don't want. Detect via aria-expanded
|
||||
// would collapse it, which we don't want. Detect via aria-expanded
|
||||
// (Radix Accordion sets it on the trigger) and skip the click when
|
||||
// the group is already open.
|
||||
await ctx.page.evaluate((selector) => {
|
||||
|
|
@ -493,7 +493,7 @@ async function tryResolveTarget(
|
|||
|
||||
/**
|
||||
* Load synth's measured cue durations. Falls back to a worst-case estimate
|
||||
* if the manifest is missing — that path is only used for ``--no-audio``
|
||||
* if the manifest is missing: that path is only used for ``--no-audio``
|
||||
* runs, where the visual flow needs to play even without speech to time
|
||||
* against.
|
||||
*/
|
||||
|
|
@ -516,7 +516,7 @@ function loadSynthIndex(storyboard: Storyboard): SynthCue[] {
|
|||
});
|
||||
}
|
||||
console.log(
|
||||
`[runner] no ${path} found — using worst-case fallback durations (${FALLBACK_MS_PER_WORD}ms/word + ${FALLBACK_TAIL_BUFFER_MS}ms buffer). Audio will be missing.`
|
||||
`[runner] no ${path} found. Using worst-case fallback durations (${FALLBACK_MS_PER_WORD}ms/word + ${FALLBACK_TAIL_BUFFER_MS}ms buffer). Audio will be missing.`
|
||||
);
|
||||
return storyboard.cues.map((cue, cueIndex) => ({
|
||||
cueIndex,
|
||||
|
|
|
|||
|
|
@ -4,14 +4,14 @@ import type { DashboardRecorder } from './dashboard.js';
|
|||
/**
|
||||
* Public scripting API for the demo video.
|
||||
*
|
||||
* The storyboard is a `Storyboard` — an ordered list of narration cues, each
|
||||
* The storyboard is a `Storyboard`: an ordered list of narration cues, each
|
||||
* carrying the activities that play alongside it. Audio is generated FIRST
|
||||
* (one batched Qwen call so the voice stays consistent across cues); the
|
||||
* runner then reads the measured per-cue durations and slots `during`
|
||||
* activities inside each cue's audio window.
|
||||
*
|
||||
* Why cue-anchored: the audio drives pacing. Re-running synth produces a new
|
||||
* set of measured durations and the storyboard self-aligns — you don't have
|
||||
* set of measured durations and the storyboard self-aligns: you don't have
|
||||
* to retune activity numbers. Author intent stays declarative ("zoom + type
|
||||
* happen during this cue, dwell 4s after, then next cue starts").
|
||||
*/
|
||||
|
|
@ -62,7 +62,7 @@ export interface AdScene {
|
|||
items?: AdSceneItem[];
|
||||
/** Optional single hero photo (URL) shown above the title. */
|
||||
image?: string;
|
||||
/** Optional [left, right] photos for split mode — used by "two streets apart" style ads. */
|
||||
/** Optional [left, right] photos for split mode, used by "two streets apart" style ads. */
|
||||
images?: [string, string];
|
||||
/** Optional caption shown under the image for attribution / context. */
|
||||
imageCaption?: string;
|
||||
|
|
@ -82,7 +82,7 @@ export type Target =
|
|||
/**
|
||||
* Resolved at runtime to the centre of a visible hexagon/postcode polygon,
|
||||
* picked from the dashboard's most recent map response. Robust to any zoom
|
||||
* level — use this when the click MUST land on a polygon and a fixed pixel
|
||||
* level. Use this when the click MUST land on a polygon and a fixed pixel
|
||||
* coordinate would risk landing on a road or river at deep zoom.
|
||||
*/
|
||||
| { kind: 'hexagon' }
|
||||
|
|
@ -195,7 +195,7 @@ export type Activity =
|
|||
| { kind: 'dragSheet'; toHeightFrac: number; durationMs: number }
|
||||
/**
|
||||
* Click the header of a collapsible filter group (e.g. "Transport",
|
||||
* "Schools") so the cards beneath it become visible. Idempotent —
|
||||
* "Schools") so the cards beneath it become visible. Idempotent:
|
||||
* if the group is already open this is a no-op click.
|
||||
*/
|
||||
| { kind: 'openFilterGroup'; selector: string; durationMs: number }
|
||||
|
|
@ -206,7 +206,7 @@ export type Activity =
|
|||
* A narration cue + the activities that play alongside it.
|
||||
*
|
||||
* text : the SPOKEN narration line (TTS input). Never rendered on
|
||||
* screen — what's said must not also be shown.
|
||||
* screen. What's said must not also be shown.
|
||||
* caption : optional SHORT on-screen chip (≤6 words) complementing the
|
||||
* narration. Distinct from `text` by design: visual hooks
|
||||
* ("You can't hear a photo") and stats live here, the story
|
||||
|
|
@ -240,7 +240,7 @@ export interface VideoConfig {
|
|||
captureScale: number;
|
||||
/**
|
||||
* Optional CSS viewport override (in pixels). Lets a single storyboard
|
||||
* record at a narrower CSS viewport than the aspect's default — e.g.
|
||||
* record at a narrower CSS viewport than the aspect's default, e.g.
|
||||
* the recording-*-mobile cuts use 540x960 so Tailwind's `md:`
|
||||
* breakpoint (≥768px) doesn't match and every component picks its
|
||||
* mobile typography. Pair with `captureScale: 2` to keep text sharp.
|
||||
|
|
@ -249,7 +249,7 @@ export interface VideoConfig {
|
|||
viewport?: { width: number; height: number };
|
||||
/**
|
||||
* Visual style of the injected cursor. 'arrow' (default) renders the
|
||||
* classic pointer — right for desktop demos. 'touch' renders a soft
|
||||
* classic pointer, right for desktop demos. 'touch' renders a soft
|
||||
* fingertip dot, which reads as a phone gesture on 9:16 mobile cuts
|
||||
* (an arrow cursor on a phone-shaped video instantly breaks the
|
||||
* illusion that you're watching the mobile product).
|
||||
|
|
@ -340,7 +340,7 @@ export interface TravelTimeFilter {
|
|||
* `post` runs once after the last cue's tail finishes. The cue list is what
|
||||
* gets handed to the synth step.
|
||||
*
|
||||
* `name` doubles as the on-disk slug — outputs go to `output/<name>/` and
|
||||
* `name` doubles as the on-disk slug: outputs go to `output/<name>/` and
|
||||
* publish as `<name>.mp4` + `<name>.jpg`. Keep names URL/path-safe.
|
||||
*/
|
||||
export interface Storyboard {
|
||||
|
|
@ -358,7 +358,7 @@ export interface Storyboard {
|
|||
/**
|
||||
* Frontend viewport in CSS pixels. Defaults to the aspect's native size
|
||||
* (1920x1080 for 16x9, 1080x1920 for 9x16). A storyboard can opt into a
|
||||
* narrower CSS viewport via `video.viewport` — e.g. recording-*-mobile
|
||||
* narrower CSS viewport via `video.viewport`, e.g. recording-*-mobile
|
||||
* uses 540x960 so the frontend's Tailwind `md:` breakpoint doesn't match
|
||||
* and every component picks mobile typography/spacing. Pair the override
|
||||
* with `captureScale: 2` to keep text sharp at the smaller resolution.
|
||||
|
|
@ -375,7 +375,7 @@ export function viewportFor(video: VideoConfig): { width: number; height: number
|
|||
*
|
||||
* Playwright's recordVideo captures the page at its CSS-pixel surface, so
|
||||
* passing a size larger than the viewport just letterboxes the content
|
||||
* into the top-left of an empty frame — not a true high-DPR raster.
|
||||
* into the top-left of an empty frame, not a true high-DPR raster.
|
||||
* Final-resolution upscale (e.g. mobile 540x960 → 1080x1920) is done in
|
||||
* render.sh's ffmpeg pass with `scale=...:flags=lanczos`, which gives a
|
||||
* sharp upscale because Chromium rasterises internally at DPR=captureScale.
|
||||
|
|
@ -387,7 +387,7 @@ export function recordedSizeFor(video: VideoConfig): { width: number; height: nu
|
|||
/**
|
||||
* The final mp4's resolution (after the lanczos upscale pass in render.sh).
|
||||
* Storyboards drive their on-screen typography from CSS viewport sizes, but
|
||||
* social platforms care about the file resolution — so we expose a
|
||||
* social platforms care about the file resolution, so we expose a
|
||||
* separate getter for the published dimensions.
|
||||
*/
|
||||
export function publishedSizeFor(video: VideoConfig): { width: number; height: number } {
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ type FormFactor = 'desktop' | 'mobile';
|
|||
* whenever the map is the story.
|
||||
* 4. Filter names MUST match live /api/features exactly (e.g.
|
||||
* "Serious crime (/yr, 7y)", "Distance to nearest amenity (Waitrose)
|
||||
* (km)") — wrong names silently no-op and the map never changes.
|
||||
* (km)"). Wrong names silently no-op and the map never changes.
|
||||
* preflight.ts validates every stubbed name against the live API.
|
||||
*
|
||||
* `name` doubles as the on-disk slug. The pipeline writes per-storyboard
|
||||
|
|
@ -47,14 +47,14 @@ type FormFactor = 'desktop' | 'mobile';
|
|||
// to modelled catchment counts ("Good+ primary school catchments"), which the
|
||||
// local stack already serves; prod still serves the older "…within 2km" names
|
||||
// until that deploy lands. Preflight fails loudly if these drift from
|
||||
// whichever API render.sh is pointed at — flip them back if you render against
|
||||
// whichever API render.sh is pointed at. Flip them back if you render against
|
||||
// prod before the catchment model deploys there.
|
||||
const SCHOOL_GOOD_PRIMARY = 'Good+ primary school catchments';
|
||||
const SCHOOL_OUTSTANDING_PRIMARY = 'Outstanding primary school catchments';
|
||||
|
||||
// Cold-open lean-in on the AI card. Desktop only; kept moderate so the
|
||||
// map remains visible on the right (zoomTo clamps the pan so the app
|
||||
// always covers the full frame — no backdrop voids).
|
||||
// always covers the full frame: no backdrop voids).
|
||||
const AI_ZOOM_SCALE_DESKTOP = 1.45;
|
||||
|
||||
const TT_CARD_SELECTOR = '[data-filter-name="tt_0"]';
|
||||
|
|
@ -62,7 +62,7 @@ const TT_SLIDER_MAX = 120;
|
|||
const TT_DRAG_FROM_MIN = 35;
|
||||
// 25 (not 20): tight enough that the drag visibly prunes the map, loose
|
||||
// enough that street-level central London keeps plenty of matching
|
||||
// postcodes — at 20 the brief emptied the centre and the postcode tap had
|
||||
// postcodes. At 20 the brief emptied the centre and the postcode tap had
|
||||
// nothing fresh to land on (the drawer then opened in its "filtered stats
|
||||
// are empty" fallback).
|
||||
const TT_DRAG_TO_MIN = 25;
|
||||
|
|
@ -356,8 +356,8 @@ function createCues(locale: RecordingLocale, formFactor: FormFactor): Storyboard
|
|||
{ kind: 'cursorScale', scale: isMobile ? 1 : 1.4, durationMs: 200 },
|
||||
{
|
||||
// Zoom AROUND the strongest visible match (hex()) so the
|
||||
// destination area is guaranteed to contain matching postcodes —
|
||||
// a fixed pixel target can dive into a part of town the filters
|
||||
// destination area is guaranteed to contain matching postcodes.
|
||||
// A fixed pixel target can dive into a part of town the filters
|
||||
// just emptied. Settled so the next cue's hex() click projects
|
||||
// against the postcode response for the FINAL viewport.
|
||||
kind: 'mapZoom',
|
||||
|
|
@ -424,7 +424,7 @@ function createCues(locale: RecordingLocale, formFactor: FormFactor): Storyboard
|
|||
// The drawer's close-button aria-label is localized
|
||||
// (mobileDrawer.closeDrawer), so use the per-locale string.
|
||||
// clickIfVisible keeps a label mismatch from crashing the
|
||||
// take — worst case the drawer lingers behind the zoom-out.
|
||||
// take. Worst case, the drawer lingers behind the zoom-out.
|
||||
kind: 'clickIfVisible',
|
||||
target: el(`button[aria-label="${copy.closeDrawerLabel}"]`),
|
||||
durationMs: 650,
|
||||
|
|
@ -442,7 +442,7 @@ function createCues(locale: RecordingLocale, formFactor: FormFactor): Storyboard
|
|||
{ kind: 'zoomReset', durationMs: 800 },
|
||||
{
|
||||
// IfVisible: the export button only renders on ≥1024px-wide
|
||||
// dashboards with a licensed user — a missing button must not
|
||||
// dashboards with a licensed user. A missing button must not
|
||||
// crash the take, just skip the ripple.
|
||||
kind: 'clickIfVisible',
|
||||
target: el(`button[title="${copy.exportButtonTitle}"]`),
|
||||
|
|
@ -510,13 +510,13 @@ function buildVideoConfig(formFactor: FormFactor): VideoConfig {
|
|||
outputFps: 50,
|
||||
minDurationS: 10,
|
||||
maxDurationS: 75,
|
||||
// Street-level zoom with the sheet collapsed — the map is the frame.
|
||||
// Street-level zoom with the sheet collapsed: the map is the frame.
|
||||
posterTimeS: 25,
|
||||
};
|
||||
}
|
||||
return {
|
||||
aspect: '16x9',
|
||||
// Native 1920x1080. NOTE: don't be tempted by a narrower CSS viewport —
|
||||
// Native 1920x1080. NOTE: don't be tempted by a narrower CSS viewport:
|
||||
// the dashboard header switches to tablet sidebar nav between 768 and
|
||||
// 1023px and the Export button (cue 7) disappears.
|
||||
captureScale: 1,
|
||||
|
|
@ -528,7 +528,7 @@ function buildVideoConfig(formFactor: FormFactor): VideoConfig {
|
|||
// Right-pane inspection: London map, filters applied, data pane
|
||||
// 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.
|
||||
// the "open" cue once the evidence has rendered: the strongest preview.
|
||||
posterTimeS: 35,
|
||||
};
|
||||
}
|
||||
|
|
@ -545,7 +545,7 @@ function createRecordingStoryboard(
|
|||
// 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
|
||||
// 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;
|
||||
|
|
@ -586,10 +586,10 @@ function createRecordingStoryboard(
|
|||
// £600k (not £315k like the old Manchester cut): central London is
|
||||
// pricier, and at £315k the price+crime combo emptied the inner
|
||||
// boroughs. At £600k ~51k postcodes pass in-frame, dropping to ~10k
|
||||
// once the commute is dragged to 25 min — a visible prune that still
|
||||
// once the commute is dragged to 25 min, a visible prune that still
|
||||
// leaves the zoom something to land on (verified via /api/filter-counts).
|
||||
'Estimated current price': [0, 600000],
|
||||
// Loose enough to keep the central-London map richly populated — a
|
||||
// Loose enough to keep the central-London map richly populated. A
|
||||
// cap of 20 emptied the city centre and left the zoom with nothing
|
||||
// to land on.
|
||||
'Serious crime (/yr, 7y)': [0, 40],
|
||||
|
|
@ -646,7 +646,7 @@ const DEMO_STORYBOARDS: Storyboard[] = RECORDING_LOCALES.flatMap((locale) =>
|
|||
// the story, and a fingertip-style cursor sells the gestures as touch.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type CityKey = 'manchester' | 'birmingham' | 'bristol' | 'london' | 'leeds';
|
||||
type CityKey = 'manchester' | 'birmingham' | 'bristol' | 'london' | 'leeds' | 'liverpool';
|
||||
|
||||
interface DemoAdCueConfig {
|
||||
text: string;
|
||||
|
|
@ -675,9 +675,11 @@ interface DemoAdStoryboardConfig {
|
|||
travelTimeFilters?: TravelTimeFilter[];
|
||||
posterTimeS?: number;
|
||||
initialZoom?: number;
|
||||
/** Override the city centre (e.g. to frame a specific cheaper-twin pair). */
|
||||
center?: { lat: number; lon: 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). */
|
||||
/** 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. */
|
||||
outroLine: string;
|
||||
|
|
@ -692,7 +694,7 @@ const AD_VIDEO: VideoConfig = {
|
|||
webmBitrate: '4M',
|
||||
outputFps: 50,
|
||||
minDurationS: 8,
|
||||
// Generous upper bound — ads with a deep mapZoom drift a second or two
|
||||
// Generous upper bound: ads with a deep mapZoom drift a second or two
|
||||
// past their declared budgets while tiles load.
|
||||
maxDurationS: 35,
|
||||
posterTimeS: 5,
|
||||
|
|
@ -734,6 +736,7 @@ const CITY_VIEWS: Record<CityKey, { lat: number; lon: number; zoom: number }> =
|
|||
bristol: { lat: 51.3245, lon: -2.5879, zoom: 11.3 },
|
||||
london: { lat: 51.4272, lon: -0.1276, zoom: 10.4 },
|
||||
leeds: { lat: 53.7308, lon: -1.5491, zoom: 11.0 },
|
||||
liverpool: { lat: 53.4084, lon: -2.9916, zoom: 11.2 },
|
||||
};
|
||||
|
||||
// -- small helpers used by the per-ad cue lists -------------------------------
|
||||
|
|
@ -782,7 +785,7 @@ const mapZoomIn = (durationMs = 1500, steps = 5): Activity => ({
|
|||
});
|
||||
/**
|
||||
* 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
|
||||
* zoom can land on a road or river and the drawer never opens).
|
||||
*/
|
||||
const tapHex = (durationMs = 1000): Activity => ({
|
||||
|
|
@ -814,9 +817,12 @@ function createDemoAdStoryboard(ad: DemoAdStoryboardConfig): Storyboard {
|
|||
ad.promptText ??
|
||||
'Flat under £350k, good commute, good schools, lower crime, quieter streets';
|
||||
|
||||
const cityView = CITY_VIEWS[ad.city];
|
||||
const initialMapView = {
|
||||
...CITY_VIEWS[ad.city],
|
||||
zoom: ad.initialZoom ?? CITY_VIEWS[ad.city].zoom,
|
||||
...cityView,
|
||||
lat: ad.center?.lat ?? cityView.lat,
|
||||
lon: ad.center?.lon ?? cityView.lon,
|
||||
zoom: ad.initialZoom ?? cityView.zoom,
|
||||
};
|
||||
|
||||
return {
|
||||
|
|
@ -877,7 +883,7 @@ function createDemoAdStoryboard(ad: DemoAdStoryboardConfig): Storyboard {
|
|||
|
||||
const AD_CONFIGS: DemoAdStoryboardConfig[] = [
|
||||
// -------------------------------------------------------------------
|
||||
// 01 — The hero feature: the whole brief in one sentence.
|
||||
// 01. The hero feature: the whole brief in one sentence.
|
||||
// Cold open ON the typing; map reveal as the payoff.
|
||||
// -------------------------------------------------------------------
|
||||
{
|
||||
|
|
@ -922,7 +928,7 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
|
|||
},
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// 02 — The commute slider. Cold open on a travel-time-coloured map
|
||||
// 02. The commute slider. Cold open on a travel-time-coloured map
|
||||
// (filter + colour applied in pre), then the 60→20 drag is the story.
|
||||
// -------------------------------------------------------------------
|
||||
{
|
||||
|
|
@ -973,8 +979,8 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
|
|||
},
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// 03 — Tap a postcode, read its file. The drawer (sold prices, Street
|
||||
// View, schools, crime) is the wow — most viewers don't know this exists.
|
||||
// 03. Tap a postcode, read its file. The drawer (sold prices, Street
|
||||
// View, schools, crime) is the wow. Most viewers don't know this exists.
|
||||
// -------------------------------------------------------------------
|
||||
{
|
||||
name: 'ad-03-postcode-files',
|
||||
|
|
@ -1029,7 +1035,7 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
|
|||
},
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// 04 — Noise. The strongest single line in the set; the product proves it.
|
||||
// 04. Noise. The strongest single line in the set; the product proves it.
|
||||
// -------------------------------------------------------------------
|
||||
{
|
||||
name: 'ad-04-quiet-streets',
|
||||
|
|
@ -1066,7 +1072,7 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
|
|||
},
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// 05 — Families / the school run. Leeds for non-London variety.
|
||||
// 05. Families / the school run. Leeds for non-London variety.
|
||||
// -------------------------------------------------------------------
|
||||
{
|
||||
name: 'ad-05-school-run',
|
||||
|
|
@ -1106,7 +1112,7 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
|
|||
},
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// 06 — Lifestyle amenities. The Waitrose line is the hook; tube + park
|
||||
// 06. Lifestyle amenities. The Waitrose line is the hook; tube + park
|
||||
// make it practical. Names match the live amenity-distance features.
|
||||
// -------------------------------------------------------------------
|
||||
{
|
||||
|
|
@ -1145,7 +1151,7 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
|
|||
},
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// 07 — Renters. Every tool is for buyers; rent is a live feature here.
|
||||
// 07. Renters. Every tool is for buyers; rent is a live feature here.
|
||||
// -------------------------------------------------------------------
|
||||
{
|
||||
name: 'ad-07-renters-map',
|
||||
|
|
@ -1185,7 +1191,7 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
|
|||
},
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// 08 — Value. The cost of overpaying for a name vs the one-off fee.
|
||||
// 08. Value. The cost of overpaying for a name vs the one-off fee.
|
||||
// -------------------------------------------------------------------
|
||||
{
|
||||
name: 'ad-08-value',
|
||||
|
|
@ -1228,6 +1234,187 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
|
|||
|
||||
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.
|
||||
// Render with: VIDEO_STORYBOARD_SET=twins ./render.sh --prod
|
||||
// ---------------------------------------------------------------------------
|
||||
const TWIN_AD_CONFIGS: DemoAdStoryboardConfig[] = [
|
||||
{
|
||||
name: 'twin-beckenham-croydon',
|
||||
matchCount: 1840,
|
||||
city: 'london',
|
||||
center: { lat: 51.38969, 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: 7,
|
||||
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, 3)],
|
||||
tail: [wait(400)],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'twin-ha7-2-vs-ha3-0',
|
||||
matchCount: 1620,
|
||||
city: 'london',
|
||||
center: { lat: 51.59199, 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: 7,
|
||||
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, 3)],
|
||||
tail: [wait(400)],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'twin-m40-5-vs-m9-4',
|
||||
matchCount: 2480,
|
||||
city: 'manchester',
|
||||
center: { lat: 53.51293, lon: -2.19574 },
|
||||
initialZoom: 12.0,
|
||||
promptText: 'Good schools, best value per square metre near Newton Heath and Harpurhey',
|
||||
filters: {
|
||||
'Est. price per sqm': [0, 1700],
|
||||
'Good+ secondary school catchments': [1, 11],
|
||||
},
|
||||
posterTimeS: 7,
|
||||
outroLine: "Newton Heath's cheaper twin is on this map. Find yours, free.",
|
||||
cues: [
|
||||
{
|
||||
text: 'Newton Heath and Harpurhey 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 Newton Heath and Harpurhey', 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 over a third less.',
|
||||
caption: 'The cheaper twin',
|
||||
during: [mapZoomIn(1400, 3)],
|
||||
tail: [wait(400)],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'twin-l16-7-vs-l14-6',
|
||||
matchCount: 1740,
|
||||
city: 'liverpool',
|
||||
center: { lat: 53.40344, 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: 7,
|
||||
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, 3)],
|
||||
tail: [wait(400)],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'twin-rm14-2-vs-rm12-5',
|
||||
matchCount: 1980,
|
||||
city: 'london',
|
||||
center: { lat: 51.54892, lon: 0.22193 },
|
||||
initialZoom: 12.0,
|
||||
promptText: 'Good schools, best value per square metre near Upminster and Hornchurch',
|
||||
filters: {
|
||||
'Est. price per sqm': [0, 5300],
|
||||
'Good+ secondary school catchments': [1, 11],
|
||||
},
|
||||
posterTimeS: 7,
|
||||
outroLine: "Upminster's cheaper twin is on this map. Find yours, free.",
|
||||
cues: [
|
||||
{
|
||||
text: 'Upminster and Hornchurch 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 Upminster and Hornchurch', 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 fifth less.',
|
||||
caption: 'The cheaper twin',
|
||||
during: [mapZoomIn(1400, 3)],
|
||||
tail: [wait(400)],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
const TWIN_AD_STORYBOARDS = TWIN_AD_CONFIGS.map(createDemoAdStoryboard);
|
||||
|
||||
const STORYBOARD_SET = process.env.VIDEO_STORYBOARD_SET ?? 'homepage-en';
|
||||
|
||||
export const storyboards: Storyboard[] = (() => {
|
||||
|
|
@ -1238,6 +1425,8 @@ export const storyboards: Storyboard[] = (() => {
|
|||
return DEMO_STORYBOARDS;
|
||||
case 'all':
|
||||
return [...AD_STORYBOARDS, ...DEMO_STORYBOARDS];
|
||||
case 'twins':
|
||||
return TWIN_AD_STORYBOARDS;
|
||||
case 'ads':
|
||||
default:
|
||||
return AD_STORYBOARDS;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue