import type { Page } from 'playwright'; 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 * 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 * to retune activity numbers. Author intent stays declarative ("zoom + type * happen during this cue, dwell 4s after, then next cue starts"). */ export interface ScriptCtx { page: Page; dashboard: DashboardRecorder; cursor: { x: number; y: number }; } export type AdSceneAccent = 'teal' | 'sky' | 'amber' | 'rose' | 'lime' | 'violet'; export type AdSceneMode = | 'title' | 'stack' | 'split' | 'rank' | 'comment' | 'scanner' | 'tabs' | 'receipt' | 'polygraph' | 'match'; export interface AdSceneItem { label: string; value?: string; tone?: 'good' | 'bad' | 'warn' | 'neutral'; } export interface AdScenePanel { title: string; subtitle?: string; meta?: string; tone?: 'good' | 'bad' | 'warn' | 'neutral'; } export interface AdScene { mode?: AdSceneMode; accent?: AdSceneAccent; kicker?: string; title: string; body?: string; comment?: string; footer?: string; progress?: number; left?: AdScenePanel; right?: AdScenePanel; 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. */ images?: [string, string]; /** Optional caption shown under the image for attribution / context. */ imageCaption?: string; /** * If true, render the scene with a transparent background so the dashboard * stays visible behind. Useful for hooks where you want a floating kicker * + title without occluding the live product. Defaults to false (full * scrim, used by the closing "title" cards). */ transparent?: boolean; } /** A point on screen, resolved at runtime to viewport pixels. */ export type Target = | { kind: 'point'; x: number; y: number } | { kind: 'element'; selector: string } /** * 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 * coordinate would risk landing on a road or river at deep zoom. */ | { kind: 'hexagon' } /** * Resolved at runtime to (xFrac·viewport.width, yFrac·viewport.height). Use * this when an activity needs a stable visual location that scales with the * aspect (e.g. "centre of the map area" on both 16x9 desktop and 9x16 * mobile, where the visible map is only the top portion above the bottom * sheet). Both fractions are clamped to [0, 1]. */ | { kind: 'viewportFraction'; xFrac: number; yFrac: number }; export const at = (x: number, y: number): Target => ({ kind: 'point', x, y }); export const el = (selector: string): Target => ({ kind: 'element', selector }); export const hex = (): Target => ({ kind: 'hexagon' }); export const vfrac = (xFrac: number, yFrac: number): Target => ({ kind: 'viewportFraction', xFrac, yFrac, }); /** * Activities are the runner's atomic operations. Each one has a fixed * `durationMs` budget; the runner pads short overruns and warns on long ones. */ export type Activity = /** Pure pause. Useful for spacing. */ | { kind: 'wait'; durationMs: number } /** Smoothly zoom the dashboard wrapper so `target` lands at viewport centre. */ | { kind: 'zoomTo'; target: Target; scale: number; durationMs: number } /** Animate the wrapper back to identity. */ | { kind: 'zoomReset'; durationMs: number } /** Slide the cursor from its current position to `target`. */ | { kind: 'moveCursor'; target: Target; durationMs: number } /** Move + click + ripple. `durationMs` is the whole gesture, including settle. */ | { kind: 'click'; target: Target; durationMs: number } /** Type into a textarea/input over exactly `durationMs`. */ | { kind: 'type'; selector: string; text: string; durationMs: number } /** Grow or shrink the visible cursor (CSS scale). */ | { kind: 'cursorScale'; scale: number; durationMs: number } /** * Wheel-zoom the underlying map at `target`. `steps` controls intensity * (each step is one ~120px wheel notch). `direction` picks zoom in (the * default) or zoom out, so the same activity can both dive into a * postcode and later pull back to the filtered overview. */ | { kind: 'mapZoom'; target: Target; steps: number; durationMs: number; direction?: 'in' | 'out'; } /** Drag the right thumb of a Radix slider to a fraction in [0,1]. */ | { kind: 'dragSlider'; thumbSelector: string; trackSelector: string; toFraction: number; durationMs: number; } /** Submit a form found by selector and wait `durationMs`. */ | { kind: 'submitForm'; formSelector: string; durationMs: number } /** Reveal the closing brand card. */ | { kind: 'showOutro'; brand: string; tagline: string; url: 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. */ | { kind: 'hideAdScene'; durationMs: number } /** Fade away the opening vignette. */ | { kind: 'clearVignette'; durationMs: number } /** * Smoothly scroll the closest scrollable ancestor of `selector` to * absolute pixel `top`. Used to surface a specific filter card or to * scroll through the property-stats drawer after a postcode click. */ | { kind: 'scrollPane'; selector: string; top: number; durationMs: number } /** * Click the header of a collapsible filter group (e.g. "Transport", * "Education") 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 }; /** * A narration cue + the activities that play alongside it. * * gapBeforeMs : silent wall-time before the caption appears (= silence in * audio between the previous cue ending and this one). * during : activities that play WHILE the caption is on screen. The * sum of declared durations must be ≤ the measured audio * duration; the runner pads short blocks so the caption stays * on for the full cue. Sum > measured is a hard error. * tail : activities that run AFTER the caption hides, before the * next cue's gapBefore starts. Use it for dwells/transitions * that aren't tied to spoken words. */ export interface Cue { text: string; gapBeforeMs: number; during?: Activity[]; tail?: Activity[]; } /** Recorder + encoder knobs. Set per storyboard so vertical/horizontal cuts * can coexist without env-var juggling. */ export interface VideoConfig { /** "16x9" → 1920x1080, "9x16" → 1080x1920 by default. */ aspect: '16x9' | '9x16'; /** Browser deviceScaleFactor. >1 supersamples for sharper text. */ 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. * 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. * If unset, the viewport comes from `viewportFor(aspect)`. */ viewport?: { width: number; height: number }; /** WebM bitrate passed to libvpx, e.g. "8M" or "18M". */ webmBitrate: string; /** Final fps after the trim/resample pass. */ outputFps: number; /** verify.ts duration window. */ minDurationS: number; maxDurationS: number; /** Timestamp (seconds, in the trimmed mp4) used to extract the homepage * poster JPEG. Pick a frame that previews well on a paused player. */ posterTimeS: number; } /** Qwen3-TTS voice + language settings, sent to synth.py via the narration * script. Per storyboard so we can ship a British male narrator on one cut * and a different persona on another. */ export interface VoiceConfig { /** VoiceDesign persona prompt (accent, register, anti-filler directives). */ instruct: string; /** Qwen3-TTS language string, e.g. "English". */ language: string; /** Reference utterance used when minting a generated voice for this language. */ referenceText?: string; /** Sampling temperature (default 0.6). */ temperature?: number; /** Top-p nucleus sampling (default 0.9). */ topP?: number; /** Reproducibility seed (default 42). */ seed?: number; } /** Brand strings rendered by the outro card. */ export interface BrandConfig { name: string; tagline: string; url: string; } /** Story-specific content: the AI prompt typed on camera, the stubbed AI * response, the initial map view, and the travel-time slider tuning. The * storyboard cues reference these via the active Storyboard rather than * through globals so multiple storyboards can declare different prompts / * filters / drag targets without colliding. */ export interface ContentConfig { /** Prompt text typed into the AI box during the cold open. */ promptText: string; /** Frontend i18n language code to set before loading the dashboard. */ appLanguage?: string; /** Cold-open zoom multiplier on the AI card. */ aiZoomScale: number; initialMapView: { lat: number; lon: number; zoom: number }; stubbedFilters: Record; stubbedTravelTimeFilters: TravelTimeFilter[]; travelTimeCardSelector: string; travelTimeSliderMax: number; travelTimeDragFromMin: number; travelTimeDragToMin: number; brand: BrandConfig; } export interface TravelTimeFilter { mode: 'transit' | 'car' | 'bicycle' | 'walking'; slug: string; label: string; min?: number; max?: number; } /** * Top-level storyboard. `pre` runs once before the first cue's gapBefore; * `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//` and * publish as `.mp4` + `.jpg`. Keep names URL/path-safe. */ export interface Storyboard { name: string; /** Optional language/variant code, used for manifests and logging. */ locale?: string; video: VideoConfig; voice: VoiceConfig; content: ContentConfig; pre?: Activity[]; cues: Cue[]; post?: Activity[]; } /** * 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 * 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. */ export function viewportFor(video: VideoConfig): { width: number; height: number } { if (video.viewport) return video.viewport; return video.aspect === '9x16' ? { width: 1080, height: 1920 } : { width: 1920, height: 1080 }; } /** * Recorded video resolution. Equal to the CSS viewport. * * 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. * 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. */ export function recordedSizeFor(video: VideoConfig): { width: number; height: number } { return viewportFor(video); } /** * 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 * separate getter for the published dimensions. */ export function publishedSizeFor(video: VideoConfig): { width: number; height: number } { const viewport = viewportFor(video); const scale = Math.max(1, Math.round(video.captureScale)); return { width: viewport.width * scale, height: viewport.height * scale }; }