332 lines
13 KiB
TypeScript
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 };
|
|
}
|