perfect-postcode/video/src/script.ts
2026-05-14 08:09:19 +01:00

332 lines
13 KiB
TypeScript

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<string, [number, number] | string[]>;
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/<name>/` and
* publish as `<name>.mp4` + `<name>.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 };
}