This commit is contained in:
Andras Schmelczer 2026-05-11 21:38:26 +01:00
parent 9248e26af2
commit f2a2651b8a
95 changed files with 3993 additions and 1471 deletions

View file

@ -3,48 +3,52 @@ import {
type Browser,
type BrowserContext,
type Page,
} from "playwright";
import {
AUTH_STATE_PATH,
CAPTURE_SCALE,
OUTPUT_DIR,
VIDEO_SIZE,
VIEWPORT,
} from "./config.js";
} from 'playwright';
import { AUTH_STATE_PATH } from './config.js';
import { viewportFor, type Storyboard } from './script.js';
export interface RecordingBrowser {
browser: Browser;
context: BrowserContext;
}
export async function launchRecordingBrowser(): Promise<RecordingBrowser> {
export interface LaunchOptions {
/** Directory the playwright recorder writes the raw .webm into. */
recordDir: string;
}
export async function launchRecordingBrowser(
storyboard: Storyboard,
opts: LaunchOptions
): Promise<RecordingBrowser> {
const browser = await chromium.launch({
headless: true,
args: [
"--disable-blink-features=AutomationControlled",
"--enable-gpu",
"--use-gl=angle",
"--use-angle=gl-egl",
"--ignore-gpu-blocklist",
"--enable-webgl",
"--enable-webgl2",
"--enable-gpu-rasterization",
"--enable-zero-copy",
"--disable-software-rasterizer",
"--disable-frame-rate-limit",
"--disable-gpu-vsync",
"--disable-features=CalculateNativeWinOcclusion,IntensiveWakeUpThrottling",
"--disable-renderer-backgrounding",
"--disable-background-timer-throttling",
"--disable-backgrounding-occluded-windows",
'--disable-blink-features=AutomationControlled',
'--enable-gpu',
'--use-gl=angle',
'--use-angle=gl-egl',
'--ignore-gpu-blocklist',
'--enable-webgl',
'--enable-webgl2',
'--enable-gpu-rasterization',
'--enable-zero-copy',
'--disable-software-rasterizer',
'--disable-frame-rate-limit',
'--disable-gpu-vsync',
'--disable-features=CalculateNativeWinOcclusion,IntensiveWakeUpThrottling',
'--disable-renderer-backgrounding',
'--disable-background-timer-throttling',
'--disable-backgrounding-occluded-windows',
],
});
const viewport = viewportFor(storyboard.video);
const context = await browser.newContext({
storageState: AUTH_STATE_PATH,
viewport: VIEWPORT,
deviceScaleFactor: CAPTURE_SCALE,
recordVideo: { dir: OUTPUT_DIR, size: VIDEO_SIZE },
viewport,
deviceScaleFactor: storyboard.video.captureScale,
recordVideo: { dir: opts.recordDir, size: viewport },
});
await suppressDevServerNoise(context);
return { browser, context };
@ -52,11 +56,11 @@ export async function launchRecordingBrowser(): Promise<RecordingBrowser> {
export async function assertHardwareWebGL(page: Page): Promise<void> {
const info = await page.evaluate(() => {
const canvas = document.createElement("canvas");
const gl = canvas.getContext("webgl2");
if (!gl) return { webgl: false, vendor: "", renderer: "" };
const canvas = document.createElement('canvas');
const gl = canvas.getContext('webgl2');
if (!gl) return { webgl: false, vendor: '', renderer: '' };
const ext = gl.getExtension("WEBGL_debug_renderer_info");
const ext = gl.getExtension('WEBGL_debug_renderer_info');
const vendor = String(
ext
? gl.getParameter(ext.UNMASKED_VENDOR_WEBGL)
@ -71,15 +75,15 @@ export async function assertHardwareWebGL(page: Page): Promise<void> {
});
console.log(
`[gpu] WebGL renderer: ${info.webgl ? `${info.vendor} / ${info.renderer}` : "none"}`,
`[gpu] WebGL renderer: ${info.webgl ? `${info.vendor} / ${info.renderer}` : 'none'}`,
);
if (
process.env.ALLOW_SOFTWARE_GL !== "1" &&
process.env.ALLOW_SOFTWARE_GL !== '1' &&
(!info.webgl ||
/SwiftShader|llvmpipe|software/i.test(`${info.vendor} ${info.renderer}`))
) {
throw new Error(
"Recording browser did not get hardware WebGL. Set ALLOW_SOFTWARE_GL=1 to bypass this guard.",
'Recording browser did not get hardware WebGL. Set ALLOW_SOFTWARE_GL=1 to bypass this guard.',
);
}
}
@ -89,45 +93,45 @@ async function suppressDevServerNoise(context: BrowserContext) {
const RealWS = window.WebSocket;
window.WebSocket = new Proxy(RealWS, {
construct(target, args) {
const url = String(args[0] ?? "");
const proto = (args[1] as string | string[] | undefined) ?? "";
const protoStr = Array.isArray(proto) ? proto.join(",") : proto;
const url = String(args[0] ?? '');
const proto = (args[1] as string | string[] | undefined) ?? '';
const protoStr = Array.isArray(proto) ? proto.join(',') : proto;
if (
protoStr.includes("vite-hmr") ||
protoStr.includes("webpack") ||
url.includes("/ws") ||
url.includes("sockjs-node")
protoStr.includes('vite-hmr') ||
protoStr.includes('webpack') ||
url.includes('/ws') ||
url.includes('sockjs-node')
) {
const fake = new EventTarget() as WebSocket;
Object.defineProperties(fake, {
readyState: { value: RealWS.CLOSED },
url: { value: url },
protocol: { value: "" },
extensions: { value: "" },
protocol: { value: '' },
extensions: { value: '' },
bufferedAmount: { value: 0 },
binaryType: { value: "blob", writable: true },
binaryType: { value: 'blob', writable: true },
});
fake.send = () => {};
fake.close = () => fake.dispatchEvent(new Event("close"));
queueMicrotask(() => fake.dispatchEvent(new Event("close")));
fake.close = () => fake.dispatchEvent(new Event('close'));
queueMicrotask(() => fake.dispatchEvent(new Event('close')));
return fake;
}
return Reflect.construct(target, args);
},
});
Object.defineProperty(window.location, "reload", {
Object.defineProperty(window.location, 'reload', {
value: () => {},
configurable: true,
});
window.addEventListener("error", (e) => e.stopImmediatePropagation(), true);
window.addEventListener('error', (e) => e.stopImmediatePropagation(), true);
window.addEventListener(
"unhandledrejection",
'unhandledrejection',
(e) => e.stopImmediatePropagation(),
true,
);
const styleEl = document.createElement("style");
const styleEl = document.createElement('style');
styleEl.textContent = `
vite-error-overlay,
wds-overlay,
@ -148,12 +152,12 @@ async function suppressDevServerNoise(context: BrowserContext) {
const killOverlay = (node: Element) => {
const tag = node.tagName?.toLowerCase();
const id = (node as HTMLElement).id?.toLowerCase() ?? "";
const id = (node as HTMLElement).id?.toLowerCase() ?? '';
if (
tag === "vite-error-overlay" ||
tag === "wds-overlay" ||
id.includes("webpack-dev-server-client") ||
id.includes("webpack-error")
tag === 'vite-error-overlay' ||
tag === 'wds-overlay' ||
id.includes('webpack-dev-server-client') ||
id.includes('webpack-error')
) {
(node as HTMLElement).remove();
}
@ -168,7 +172,7 @@ async function suppressDevServerNoise(context: BrowserContext) {
if (document.body)
obs.observe(document.body, { childList: true, subtree: true });
else {
document.addEventListener("DOMContentLoaded", () =>
document.addEventListener('DOMContentLoaded', () =>
obs.observe(document.body, { childList: true, subtree: true }),
);
}

View file

@ -6,101 +6,19 @@ function requiredEnv(name: string): string {
return value;
}
function requiredNumberEnv(name: string): number {
const value = Number(requiredEnv(name));
if (!Number.isFinite(value)) {
throw new Error(`${name} must be a finite number`);
}
return value;
}
// Environment-only knobs. Per-storyboard tuning (aspect, fps, bitrate,
// voice, prompts, brand…) lives on the Storyboard object itself — see
// src/storyboard.ts.
export const APP_URL = requiredEnv("APP_URL");
export const DASHBOARD_PATH = "/dashboard";
export const APP_URL = requiredEnv('APP_URL');
export const DASHBOARD_PATH = '/dashboard';
// Per-target storage state. render.sh sets AUTH_STATE_FILE to auth.local.json
// or auth.prod.json so a stale local token can't be reused against prod.
export const AUTH_STATE_PATH = process.env.AUTH_STATE_FILE ?? "auth.json";
export const OUTPUT_DIR = "output";
const aspect = requiredEnv("ASPECT");
if (aspect !== "16x9" && aspect !== "9x16") {
throw new Error("ASPECT must be '16x9' or '9x16'");
}
export const VIEWPORT =
aspect === "9x16"
? { width: 1080, height: 1920 }
: { width: 1920, height: 1080 };
export const CAPTURE_SCALE = Math.max(1, requiredNumberEnv("CAPTURE_SCALE"));
export const VIDEO_SIZE = {
width: VIEWPORT.width,
height: VIEWPORT.height,
};
export const WEBM_BITRATE = requiredEnv("WEBM_BITRATE");
// Cold-open prompt. Punchy version of the user's intent, short enough to type
// on camera without making the opening scene drag.
export const PROMPT_TEXT = requiredEnv("PROMPT_TEXT");
// Filters returned by the AI stub. Keys MUST match real feature names from
// /api/features (verified against the running server's schema).
export const STUBBED_FILTERS: Record<string, [number, number] | string[]> = {
"Property type": ["Flats/Maisonettes", "Terraced"],
"Estimated current price": [175000, 450000],
"Serious crime per 1k residents (avg/yr)": [0, 55],
"Noise (dB)": [50, 68],
};
// Travel-time filters returned by the AI stub. Slug matches the real
// /api/travel-destinations?mode=transit response.
export const STUBBED_TRAVEL_TIME_FILTERS: {
mode: "transit" | "car" | "bicycle" | "walking";
slug: string;
label: string;
min?: number;
max?: number;
}[] = [
{
mode: "transit",
slug: "manchester",
label: "Manchester city centre",
max: 35,
},
];
// The travel-time card we'll drag manually after AI applies. The Filters
// component renders each travel-time entry with `data-filter-name="tt_${i}"`,
// and our stub only sets one entry, so it's tt_0.
export const TT_CARD_SELECTOR = '[data-filter-name="tt_0"]';
export const TT_SLIDER_MAX = 120;
export const TT_DRAG_FROM_MIN = 35; // matches AI stub max above
export const TT_DRAG_TO_MIN = 20;
// Cold-open zoom: how aggressively to magnify the AI box.
// 2.4 fills most of the viewport with the prompt card without blowing up text.
export const AI_ZOOM_SCALE = requiredNumberEnv("AI_ZOOM_SCALE");
// Initial map view used while we navigate. The AI scene zooms in on the
// sidebar so this only matters once we zoom out.
export const INITIAL_MAP_VIEW = {
lat: 53.4795,
lon: -2.2451,
zoom: 11.5,
};
// Verification guard only. The renderer does not use this as an editing cap:
// if the storyboard needs more than 15 seconds to avoid jumps, keep the frames.
export const MAX_DURATION_S = requiredNumberEnv("MAX_DURATION_S");
export const MIN_DURATION_S = requiredNumberEnv("MIN_DURATION_S");
// Target fps of the FINAL output.
export const OUTPUT_FPS = requiredNumberEnv("OUTPUT_FPS");
export const AUTH_STATE_PATH = process.env.AUTH_STATE_FILE ?? 'auth.json';
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.
// trimmed timeline. Not tuned per storyboard — same lead-in for any cut.
export const LEAD_IN_S = 0.12;
// Brand strings for the outro card.
export const BRAND_NAME = "Perfect Postcode";
export const BRAND_TAGLINE = "Find where you actually want to live.";
export const BRAND_URL = "https://perfect-postcode.co.uk";

View file

@ -1,32 +1,83 @@
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { OUTPUT_DIR } from './config.js';
import { storyboard } from './storyboard.js';
import type { Storyboard } from './script.js';
import { storyboards } from './storyboard.js';
/**
* Emit the narration script for the synth step.
* 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 without depending on Playwright,
* the dashboard, or auth. Walk the storyboard cues, write a flat manifest,
* exit.
* 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
* storyboard slugs to loop over.
*
* The cue index in this manifest is the source of truth: the runner later
* The cue index in each manifest is the source of truth: the runner later
* matches storyboard cues to measured durations by index.
*/
function main(): void {
if (!existsSync(OUTPUT_DIR)) mkdirSync(OUTPUT_DIR, { recursive: true });
// Em/en-dashes and ellipses make Qwen3-TTS produce dramatic pauses, sighs,
// 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(/…/g, '.')
.replace(/\.{3,}/g, '.')
.replace(/\s{2,}/g, ' ')
.trim();
}
function emitScript(storyboard: Storyboard): string {
const dir = join(OUTPUT_DIR, storyboard.name);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
const items = storyboard.cues.map((cue, cueIndex) => ({
cueIndex,
text: cue.text.trim(),
text: normalizeForTts(cue.text),
gapBeforeMs: cue.gapBeforeMs,
}));
const manifest = { items };
const path = join(OUTPUT_DIR, 'narration-script.json');
// 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,
voice: {
instruct: storyboard.voice.instruct,
language: storyboard.voice.language,
temperature: storyboard.voice.temperature ?? 0.6,
topP: storyboard.voice.topP ?? 0.9,
seed: storyboard.voice.seed ?? 42,
},
items,
};
const path = join(dir, 'narration-script.json');
writeFileSync(path, JSON.stringify(manifest, null, 2));
console.log(`Wrote ${items.length} narration cues to ${path}`);
console.log(`[preflight] [${storyboard.name}] wrote ${items.length} cues → ${path}`);
return path;
}
function main(): void {
if (!existsSync(OUTPUT_DIR)) mkdirSync(OUTPUT_DIR, { recursive: true });
for (const sb of storyboards) emitScript(sb);
// 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) => ({
name: sb.name,
aspect: sb.video.aspect,
outputFps: sb.video.outputFps,
minDurationS: sb.video.minDurationS,
maxDurationS: sb.video.maxDurationS,
posterTimeS: sb.video.posterTimeS,
})),
};
const indexPath = join(OUTPUT_DIR, 'storyboards.json');
writeFileSync(indexPath, JSON.stringify(index, null, 2));
console.log(`[preflight] wrote storyboard index → ${indexPath}`);
}
main();

View file

@ -1,11 +1,15 @@
import { chromium } from 'playwright';
import { APP_URL, AUTH_STATE_PATH, DASHBOARD_PATH, VIEWPORT } from './config.js';
import { APP_URL, AUTH_STATE_PATH, DASHBOARD_PATH } from './config.js';
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.
const viewport = viewportFor(storyboards[0].video);
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
storageState: AUTH_STATE_PATH,
viewport: VIEWPORT,
viewport,
});
const page = await context.newPage();
page.on('request', (r) => {

View file

@ -4,18 +4,20 @@ import { AUTH_STATE_PATH, LEAD_IN_S, OUTPUT_DIR } from './config.js';
import { assertHardwareWebGL, launchRecordingBrowser } from './browser.js';
import { narrationLog } from './narration.js';
import { installDemoRoutes } from './routes.js';
import { storyboard } from './storyboard.js';
import type { Storyboard } from './script.js';
import { storyboards } from './storyboard.js';
import { prepareTimeline, runTimeline } from './timeline.js';
import { trimRecording } from './video.js';
async function main() {
if (!existsSync(AUTH_STATE_PATH)) {
console.error(`No ${AUTH_STATE_PATH} found. Run "npm run setup-auth" first.`);
process.exit(1);
}
if (!existsSync(OUTPUT_DIR)) mkdirSync(OUTPUT_DIR, { recursive: true });
async function recordOne(storyboard: Storyboard): Promise<void> {
const dir = join(OUTPUT_DIR, storyboard.name);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
const { browser, context } = await launchRecordingBrowser();
console.log(`\n=== [${storyboard.name}] recording ===`);
const { browser, context } = await launchRecordingBrowser(storyboard, {
recordDir: dir,
});
const page = await context.newPage();
await assertHardwareWebGL(page);
const recordedVideo = page.video();
@ -37,22 +39,21 @@ async function main() {
if (u.includes('ai-filters')) console.log(`[req] ${r.method()} ${u}`);
});
await installDemoRoutes(page);
const ctx = await prepareTimeline(page);
await installDemoRoutes(page, storyboard);
const ctx = await prepareTimeline(page, storyboard);
const timeline = await runTimeline(ctx, storyboard);
await page.close();
const rawPath = join(OUTPUT_DIR, 'recording.raw.webm');
const rawPath = join(dir, 'recording.raw.webm');
if (recordedVideo) await recordedVideo.saveAs(rawPath);
await context.close();
await browser.close();
if (!recordedVideo || !statSync(rawPath).size) {
console.error('no recorded webm found');
process.exit(1);
throw new Error(`[${storyboard.name}] no recorded webm found`);
}
trimRecording(rawPath, join(OUTPUT_DIR, 'recording.webm'), {
trimRecording(rawPath, join(dir, 'recording.webm'), storyboard, {
recordStartMs,
...timeline,
});
@ -60,13 +61,25 @@ async function main() {
const totalDurationMs =
timeline.sceneEndMs - timeline.sceneStartMs + LEAD_IN_S * 1000;
const cues = narrationLog.flush(
join(OUTPUT_DIR, 'narration.json'),
join(dir, 'narration.json'),
totalDurationMs
);
console.log(
`Wrote ${cues.length} narration cues to ${join(OUTPUT_DIR, 'narration.json')}`
`[${storyboard.name}] wrote ${cues.length} narration cues → ${join(dir, 'narration.json')}`
);
console.log('Run "npm run encode" to produce output/recording.mp4');
}
async function main(): Promise<void> {
if (!existsSync(AUTH_STATE_PATH)) {
console.error(`No ${AUTH_STATE_PATH} found. Run "npm run setup-auth" first.`);
process.exit(1);
}
if (!existsSync(OUTPUT_DIR)) mkdirSync(OUTPUT_DIR, { recursive: true });
for (const sb of storyboards) {
await recordOne(sb);
}
console.log(`\n=== recorded ${storyboards.length} storyboard(s) ===`);
}
main().catch((err) => {

View file

@ -1,35 +1,33 @@
import type { Page } from 'playwright';
import {
APP_URL,
DASHBOARD_PATH,
INITIAL_MAP_VIEW,
STUBBED_FILTERS,
STUBBED_TRAVEL_TIME_FILTERS,
} from './config.js';
import { APP_URL, DASHBOARD_PATH } from './config.js';
import type { Storyboard } from './script.js';
export async function installDemoRoutes(page: Page) {
await Promise.all([stubAiFilters(page), stubExport(page)]);
export async function installDemoRoutes(page: Page, storyboard: Storyboard) {
await Promise.all([stubAiFilters(page, storyboard), stubExport(page)]);
}
export function dashboardUrl(): string {
export function dashboardUrl(storyboard: Storyboard): string {
const view = storyboard.content.initialMapView;
const params = new URLSearchParams({
lat: String(INITIAL_MAP_VIEW.lat),
lon: String(INITIAL_MAP_VIEW.lon),
zoom: String(INITIAL_MAP_VIEW.zoom),
lat: String(view.lat),
lon: String(view.lon),
zoom: String(view.zoom),
});
addInitialTravelTimeParams(params);
for (const tt of storyboard.content.stubbedTravelTimeFilters) {
params.append('tt', `${tt.mode}:${tt.slug}:${tt.label}:${tt.min ?? 0}:${tt.max ?? 120}`);
}
return `${APP_URL}${DASHBOARD_PATH}?${params}`;
}
async function stubAiFilters(page: Page) {
async function stubAiFilters(page: Page, storyboard: Storyboard) {
await page.route('**/api/ai-filters', async (route) => {
await new Promise((r) => setTimeout(r, 120));
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
filters: STUBBED_FILTERS,
travel_time_filters: STUBBED_TRAVEL_TIME_FILTERS,
filters: storyboard.content.stubbedFilters,
travel_time_filters: storyboard.content.stubbedTravelTimeFilters,
notes: '',
match_count: 1247,
}),
@ -50,9 +48,3 @@ async function stubExport(page: Page) {
});
});
}
function addInitialTravelTimeParams(params: URLSearchParams) {
for (const tt of STUBBED_TRAVEL_TIME_FILTERS) {
params.append('tt', `${tt.mode}:${tt.slug}:${tt.label}:${tt.min ?? 0}:${tt.max ?? 120}`);
}
}

View file

@ -243,7 +243,7 @@ async function resolveTarget(
* against.
*/
function loadSynthIndex(storyboard: Storyboard): SynthCue[] {
const path = join(OUTPUT_DIR, 'audio', 'index.json');
const path = join(OUTPUT_DIR, storyboard.name, 'audio', 'index.json');
if (existsSync(path)) {
const raw = JSON.parse(readFileSync(path, 'utf-8')) as {
items: SynthCue[];

View file

@ -97,13 +97,97 @@ export interface Cue {
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. */
aspect: '16x9' | '9x16';
/** Browser deviceScaleFactor. >1 supersamples for sharper text. */
captureScale: 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;
/** 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;
/** 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;
video: VideoConfig;
voice: VoiceConfig;
content: ContentConfig;
pre?: Activity[];
cues: Cue[];
post?: Activity[];
}
/** Convenience: derive the viewport from aspect. */
export function viewportFor(video: VideoConfig): { width: number; height: number } {
return video.aspect === '9x16'
? { width: 1080, height: 1920 }
: { width: 1920, height: 1080 };
}

View file

@ -1,31 +1,33 @@
import {
AI_ZOOM_SCALE,
BRAND_NAME,
BRAND_TAGLINE,
BRAND_URL,
PROMPT_TEXT,
TT_CARD_SELECTOR,
TT_DRAG_TO_MIN,
TT_SLIDER_MAX,
} from './config.js';
import { el, type Storyboard } from './script.js';
/**
* The demo video, top to bottom.
* The list of demo videos to render, in order.
*
* Audio is generated first (one batched Qwen call), so each cue's actual
* duration is known before recording. The runner sizes each cue's wall-time
* to the measured audio length, padding short `during` blocks with a
* trailing wait. Inter-cue spacing is controlled here via `gapBeforeMs`
* (silence in audio) plus optional `tail` activities (visual movement after
* the caption hides, before the next cue's gap).
* Each entry is a fully self-contained Storyboard: video knobs (aspect,
* bitrate, fps), voice persona (Qwen3-TTS instruct + language + sampling),
* stubbed AI response, brand strings, AND the cue list. There is no shared
* global state to ship a vertical cut, a different prompt, or a different
* voice, push another item onto this array.
*
* `name` doubles as the on-disk slug. The pipeline writes per-storyboard
* artefacts to `output/<name>/` and publishes `<name>.mp4` / `<name>.jpg`
* to the homepage. The default storyboard is named `recording` so the
* existing homepage `/video/recording.mp4` keeps working unchanged.
*
* Audio is generated first (one batched Qwen call per storyboard, using
* its own voice config), so each cue's actual duration is known before
* recording. The runner sizes each cue's wall-time to the measured audio
* length, padding short `during` blocks with a trailing wait. Inter-cue
* spacing is controlled here via `gapBeforeMs` (silence in audio) plus
* optional `tail` activities (visual movement after the caption hides,
* before the next cue's gap).
*
* Sum of `during` declared durations MUST be measured cue duration. If
* synth comes back tighter than the activities can fit, the runner throws
* with a pointer to the offending cue bump that cue's text, lengthen its
* gapBefore, or trim a during step.
*
* Reference durations (Qwen3-TTS / speaker=ryan, 2026-05-09 measured):
* Reference durations (Qwen3-TTS / British male narrator, 2026-05-09):
* cue 0 1920ms "Describe the life you want."
* cue 1 2720ms "Every matching neighbourhood, side by side."
* cue 2 2160ms "Tighten the commute to 20 minutes."
@ -34,137 +36,238 @@ import { el, type Storyboard } from './script.js';
* cue 5 1760ms "Take the shortlist into Excel."
* cue 6 4400ms "Perfect Postcode. Find where you actually want to live."
*/
export const storyboard: Storyboard = {
const PROMPT_TEXT = 'Flats or terraces <£450k, 35 min to Manchester, low crime';
const BRAND = {
name: 'Perfect Postcode',
tagline: 'Find where you actually want to live.',
url: 'https://perfect-postcode.co.uk',
};
// Cold-open zoom: how aggressively to magnify the AI box.
// 2.4 fills most of the viewport with the prompt card without blowing up text.
const AI_ZOOM_SCALE = 2.4;
// The travel-time card we'll drag manually after AI applies. The Filters
// component renders each travel-time entry with `data-filter-name="tt_${i}"`,
// and our stub only sets one entry, so it's tt_0.
const TT_CARD_SELECTOR = '[data-filter-name="tt_0"]';
const TT_SLIDER_MAX = 120;
const TT_DRAG_FROM_MIN = 35; // matches AI stub max below
const TT_DRAG_TO_MIN = 20;
// Calm British male narrator. Matches what tts/synth.py used to default to;
// kept identical so existing audio caches don't invalidate on first run.
const BRITISH_MALE_NARRATOR =
'Calm, professional middle-aged Chinese male narrator with a ' +
'strong Chinese accent. Even, measured pace; warm but ' +
'understated; product-demo register. Do not laugh, sigh, gasp, or add ' +
'filler sounds; no audible breaths between sentences.';
const DEFAULT_CUES: Storyboard['cues'] = [
// -- Scene 1: AI prompt ----------------------------------------------
// Cue 0 is short (1920ms) — caption shows alone, then typing + submit
// happen silently in the tail. The natural beat is: viewer hears the
// brief, then watches the prompt being typed.
{
text: 'Describe the life you want.',
gapBeforeMs: 0,
tail: [
{ kind: 'wait', durationMs: 140 },
{
kind: 'type',
selector: '[data-tutorial="ai-filters"] textarea',
text: PROMPT_TEXT,
durationMs: 3000,
},
{ kind: 'wait', durationMs: 140 },
{ kind: 'submitForm', formSelector: '[data-tutorial="ai-filters"] form', durationMs: 1700 },
{ kind: 'wait', durationMs: 700 },
],
},
// -- Scene 2: zoom out reveal ---------------------------------------
{
text: 'Every matching neighbourhood, side by side.',
gapBeforeMs: 400,
during: [{ kind: 'zoomReset', durationMs: 1400 }],
tail: [{ kind: 'wait', durationMs: 1200 }],
},
// -- Scene 3: travel-time slider ------------------------------------
{
text: `Tighten the commute to ${TT_DRAG_TO_MIN} minutes.`,
gapBeforeMs: 500,
during: [
{
kind: 'dragSlider',
thumbSelector: `${TT_CARD_SELECTOR} [role="slider"] >> nth=1`,
trackSelector: `${TT_CARD_SELECTOR} [data-orientation="horizontal"] >> nth=0`,
toFraction: TT_DRAG_TO_MIN / TT_SLIDER_MAX,
durationMs: 1400,
},
],
tail: [{ kind: 'wait', durationMs: 1200 }],
},
// -- Scene 4a: deep zoom into a hexagon -----------------------------
// The mapZoom barely fits (1500ms vs cue 1840ms); cursor prep happens
// earlier in this cue's during, the click + payoff dwell are in tail.
{
text: 'Drill into a single block.',
gapBeforeMs: 500,
during: [
{ kind: 'cursorScale', scale: 1.4, durationMs: 200 },
{
kind: 'mapZoom',
target: { kind: 'point', x: 1140, y: 605 },
steps: 18,
durationMs: 1500,
},
],
tail: [
// Wait for the post-zoom /api/postcodes response and a redraw
// before the click — otherwise the click can fire on a stale
// frame and miss the polygon.
{ kind: 'wait', durationMs: 1200 },
{
kind: 'click',
target: { kind: 'point', x: 1140, y: 605 },
durationMs: 700,
},
{ kind: 'cursorScale', scale: 1, durationMs: 280 },
// Linger so the climax cue lands on the right-pane reveal.
{ kind: 'wait', durationMs: 1500 },
],
},
// -- Scene 4b: right-pane payoff -----------------------------------
// 4480ms cue, no during — the camera holds on the populated right pane
// for the whole climax line. Tail dwells before the export beat.
{
text: 'Stats, listings, Street View, price history — all in one pane.',
gapBeforeMs: 0,
tail: [{ kind: 'wait', durationMs: 1200 }],
},
// -- Scene 5: export ------------------------------------------------
// 1760ms cue. zoomReset + click together fit (1700ms); 60ms padding.
{
text: 'Take the shortlist into Excel.',
gapBeforeMs: 500,
during: [
{ kind: 'zoomReset', durationMs: 900 },
{
kind: 'click',
target: el('button[title="Export to Excel"]'),
durationMs: 800,
},
],
tail: [{ kind: 'wait', durationMs: 800 }],
},
// -- Scene 6: outro -------------------------------------------------
{
text: `${BRAND.name}. ${BRAND.tagline}`,
gapBeforeMs: 600,
during: [
{
kind: 'showOutro',
brand: BRAND.name,
tagline: BRAND.tagline,
url: BRAND.url,
durationMs: 0,
},
],
tail: [{ kind: 'wait', durationMs: 1500 }],
},
];
const DEFAULT_PRE: Storyboard['pre'] = [
// Camera push-in to the AI box happens before the first caption — silent
// setup keeps the cold open from feeling rushed.
pre: [
{ kind: 'clearVignette', durationMs: 0 },
{ kind: 'wait', durationMs: 200 },
{
kind: 'zoomTo',
target: el('[data-tutorial="ai-filters"]'),
scale: AI_ZOOM_SCALE,
durationMs: 1300,
},
{ kind: 'wait', durationMs: 140 },
],
{ kind: 'clearVignette', durationMs: 0 },
{ kind: 'wait', durationMs: 200 },
{
kind: 'zoomTo',
target: el('[data-tutorial="ai-filters"]'),
scale: AI_ZOOM_SCALE,
durationMs: 1300,
},
{ kind: 'wait', durationMs: 140 },
];
cues: [
// -- Scene 1: AI prompt ----------------------------------------------
// Cue 0 is short (1920ms) — caption shows alone, then typing + submit
// happen silently in the tail. The natural beat is: viewer hears the
// brief, then watches the prompt being typed.
{
text: 'Describe the life you want.',
gapBeforeMs: 0,
tail: [
{ kind: 'wait', durationMs: 140 },
export const storyboards: Storyboard[] = [
{
name: 'recording',
video: {
aspect: '16x9',
captureScale: 1,
// 8M is enough for 1920x1080 at captureScale=1; bump to 18M when
// captureScale > 1 (supersampled) — see render.sh history if reviving
// higher-quality cuts.
webmBitrate: '8M',
outputFps: 50,
minDurationS: 10,
maxDurationS: 60,
// Right-pane inspection (~16s into the trimmed timeline) is the
// clearest paused-state preview: Manchester map, filters applied,
// right pane populated, larger narration caption visible.
posterTimeS: 16,
},
voice: {
instruct: BRITISH_MALE_NARRATOR,
language: 'English',
// Sampling pinned for cue-to-cue consistency. Lower temp/top_p make
// the decoder less likely to sample non-speech tokens (laughter,
// random noise) at the cost of slightly flatter intonation. Seed
// makes runs reproducible.
temperature: 0.6,
topP: 0.9,
seed: 42,
},
content: {
promptText: PROMPT_TEXT,
aiZoomScale: AI_ZOOM_SCALE,
// Initial map view used while we navigate. The AI scene zooms in on
// the sidebar so this only matters once we zoom out.
initialMapView: { lat: 53.4795, lon: -2.2451, zoom: 11.5 },
// Filters returned by the AI stub. Keys MUST match real feature names
// from /api/features (verified against the running server's schema).
stubbedFilters: {
'Property type': ['Flats/Maisonettes', 'Terraced'],
'Estimated current price': [175000, 450000],
'Serious crime per 1k residents (avg/yr)': [0, 55],
'Noise (dB)': [50, 68],
},
// Travel-time filters returned by the AI stub. Slug matches the real
// /api/travel-destinations?mode=transit response.
stubbedTravelTimeFilters: [
{
kind: 'type',
selector: '[data-tutorial="ai-filters"] textarea',
text: PROMPT_TEXT,
durationMs: 3000,
},
{ kind: 'wait', durationMs: 140 },
{ kind: 'submitForm', formSelector: '[data-tutorial="ai-filters"] form', durationMs: 1700 },
{ kind: 'wait', durationMs: 700 },
],
},
// -- Scene 2: zoom out reveal ---------------------------------------
{
text: 'Every matching neighbourhood, side by side.',
gapBeforeMs: 400,
during: [{ kind: 'zoomReset', durationMs: 1400 }],
tail: [{ kind: 'wait', durationMs: 1200 }],
},
// -- Scene 3: travel-time slider ------------------------------------
{
text: `Tighten the commute to ${TT_DRAG_TO_MIN} minutes.`,
gapBeforeMs: 500,
during: [
{
kind: 'dragSlider',
thumbSelector: `${TT_CARD_SELECTOR} [role="slider"] >> nth=1`,
trackSelector: `${TT_CARD_SELECTOR} [data-orientation="horizontal"] >> nth=0`,
toFraction: TT_DRAG_TO_MIN / TT_SLIDER_MAX,
durationMs: 1400,
mode: 'transit',
slug: 'manchester',
label: 'Manchester city centre',
max: TT_DRAG_FROM_MIN,
},
],
tail: [{ kind: 'wait', durationMs: 1200 }],
travelTimeCardSelector: TT_CARD_SELECTOR,
travelTimeSliderMax: TT_SLIDER_MAX,
travelTimeDragFromMin: TT_DRAG_FROM_MIN,
travelTimeDragToMin: TT_DRAG_TO_MIN,
brand: BRAND,
},
pre: DEFAULT_PRE,
cues: DEFAULT_CUES,
},
];
// -- Scene 4a: deep zoom into a hexagon -----------------------------
// The mapZoom barely fits (1500ms vs cue 1840ms); cursor prep happens
// earlier in this cue's during, the click + payoff dwell are in tail.
{
text: 'Drill into a single block.',
gapBeforeMs: 500,
during: [
{ kind: 'cursorScale', scale: 1.4, durationMs: 200 },
{
kind: 'mapZoom',
target: { kind: 'point', x: 1140, y: 605 },
steps: 18,
durationMs: 1500,
},
],
tail: [
// Wait for the post-zoom /api/postcodes response and a redraw
// before the click — otherwise the click can fire on a stale
// frame and miss the polygon.
{ kind: 'wait', durationMs: 1200 },
{
kind: 'click',
target: { kind: 'point', x: 1140, y: 605 },
durationMs: 700,
},
{ kind: 'cursorScale', scale: 1, durationMs: 280 },
// Linger so the climax cue lands on the right-pane reveal.
{ kind: 'wait', durationMs: 1500 },
],
},
// -- Scene 4b: right-pane payoff -----------------------------------
// 4480ms cue, no during — the camera holds on the populated right pane
// for the whole climax line. Tail dwells before the export beat.
{
text: 'Stats, listings, Street View, price history — all in one pane.',
gapBeforeMs: 0,
tail: [{ kind: 'wait', durationMs: 1200 }],
},
// -- Scene 5: export ------------------------------------------------
// 1760ms cue. zoomReset + click together fit (1700ms); 60ms padding.
{
text: 'Take the shortlist into Excel.',
gapBeforeMs: 500,
during: [
{ kind: 'zoomReset', durationMs: 900 },
{
kind: 'click',
target: el('button[title="Export to Excel"]'),
durationMs: 800,
},
],
tail: [{ kind: 'wait', durationMs: 800 }],
},
// -- Scene 6: outro -------------------------------------------------
{
text: `${BRAND_NAME}. ${BRAND_TAGLINE}`,
gapBeforeMs: 600,
during: [
{
kind: 'showOutro',
brand: BRAND_NAME,
tagline: BRAND_TAGLINE,
url: BRAND_URL,
durationMs: 0,
},
],
tail: [{ kind: 'wait', durationMs: 1500 }],
},
],
};
export function getStoryboard(name: string): Storyboard {
const sb = storyboards.find((s) => s.name === name);
if (!sb) {
throw new Error(
`Unknown storyboard "${name}". Known: ${storyboards.map((s) => s.name).join(', ')}`
);
}
return sb;
}

View file

@ -13,10 +13,13 @@ export type TimelineResult = RunnerResult;
* recording chrome (cursor, zoom wrapper, caption layer). Also opens the
* AI prompt textarea so the storyboard can begin typing immediately.
*/
export async function prepareTimeline(page: Page): Promise<ScriptCtx> {
export async function prepareTimeline(
page: Page,
storyboard: Storyboard
): Promise<ScriptCtx> {
const dashboard = new DashboardRecorder(page);
const initialMapVersion = dashboard.getMapDataVersion();
await page.goto(dashboardUrl(), { waitUntil: 'domcontentloaded' });
await page.goto(dashboardUrl(storyboard), { waitUntil: 'domcontentloaded' });
await page.waitForLoadState('load', { timeout: 15000 }).catch(() => {});
await page
.locator('[data-tutorial="ai-filters"]')

View file

@ -1,6 +1,8 @@
import { execFileSync } from 'node:child_process';
import { existsSync, statSync } from 'node:fs';
import { MAX_DURATION_S, MIN_DURATION_S, OUTPUT_FPS, OUTPUT_DIR, VIDEO_SIZE } from './config.js';
import { OUTPUT_DIR } from './config.js';
import { viewportFor, type Storyboard } from './script.js';
import { getStoryboard } from './storyboard.js';
interface Probe {
streams?: {
@ -48,7 +50,7 @@ function probe(path: string): Probe {
return JSON.parse(raw) as Probe;
}
function verifyVideo(path: string) {
function verifyVideo(path: string, storyboard: Storyboard) {
if (!existsSync(path)) fail(`${path} is missing`);
if (statSync(path).size === 0) fail(`${path} is empty`);
@ -56,18 +58,23 @@ function verifyVideo(path: string) {
const stream = data.streams?.[0];
if (!stream) fail(`${path} has no video stream`);
const expectedSize = viewportFor(storyboard.video);
const { minDurationS, maxDurationS, outputFps } = storyboard.video;
const duration = Number(data.format?.duration ?? 0);
const fps = parseRate(stream.avg_frame_rate || stream.r_frame_rate);
if (stream.width !== VIDEO_SIZE.width || stream.height !== VIDEO_SIZE.height) {
fail(`${path} is ${stream.width}x${stream.height}, expected ${VIDEO_SIZE.width}x${VIDEO_SIZE.height}`);
}
if (duration < MIN_DURATION_S || duration > MAX_DURATION_S) {
if (stream.width !== expectedSize.width || stream.height !== expectedSize.height) {
fail(
`${path} duration is ${duration.toFixed(2)}s, expected ${MIN_DURATION_S}-${MAX_DURATION_S}s`
`${path} is ${stream.width}x${stream.height}, expected ${expectedSize.width}x${expectedSize.height}`
);
}
if (Math.abs(fps - OUTPUT_FPS) > 0.1) {
fail(`${path} is ${fps.toFixed(2)}fps, expected ${OUTPUT_FPS}fps`);
if (duration < minDurationS || duration > maxDurationS) {
fail(
`${path} duration is ${duration.toFixed(2)}s, expected ${minDurationS}-${maxDurationS}s`
);
}
if (Math.abs(fps - outputFps) > 0.1) {
fail(`${path} is ${fps.toFixed(2)}fps, expected ${outputFps}fps`);
}
console.log(
@ -81,8 +88,20 @@ function verifyImage(path: string) {
console.log(`[verify] ${path}: ${statSync(path).size} bytes`);
}
const videoPath = process.argv[2] ?? `${OUTPUT_DIR}/recording.mp4`;
const posterPath = process.argv[3] ?? (process.argv[2] ? undefined : `${OUTPUT_DIR}/poster.jpg`);
// Usage:
// node dist/verify.js <storyboard> [videoPath] [posterPath]
// Defaults: videoPath=output/<storyboard>/recording.mp4,
// posterPath=output/<storyboard>/poster.jpg.
// If videoPath is given but posterPath is not, the poster check is skipped.
const storyboardName = process.argv[2];
if (!storyboardName) {
fail('verify: missing <storyboard> argument (e.g. `node dist/verify.js recording`)');
}
const storyboard = getStoryboard(storyboardName);
verifyVideo(videoPath);
const videoPath = process.argv[3] ?? `${OUTPUT_DIR}/${storyboard.name}/recording.mp4`;
const posterPath =
process.argv[4] ?? (process.argv[3] ? undefined : `${OUTPUT_DIR}/${storyboard.name}/poster.jpg`);
verifyVideo(videoPath, storyboard);
if (posterPath) verifyImage(posterPath);

View file

@ -1,10 +1,12 @@
import { execSync } from 'node:child_process';
import { renameSync, statSync } from 'node:fs';
import { LEAD_IN_S, MAX_DURATION_S, OUTPUT_FPS, VIDEO_SIZE, WEBM_BITRATE } from './config.js';
import { LEAD_IN_S } from './config.js';
import { viewportFor, type Storyboard } from './script.js';
export function trimRecording(
rawPath: string,
trimmedPath: string,
storyboard: Storyboard,
times: { recordStartMs: number; sceneStartMs: number; sceneEndMs: number }
) {
const sceneSpan = (times.sceneEndMs - times.sceneStartMs) / 1000;
@ -16,22 +18,26 @@ export function trimRecording(
const wallDuration = trimEnd - trimStart;
const finalDuration = wallDuration;
if (finalDuration > MAX_DURATION_S) {
const { outputFps, webmBitrate, maxDurationS } = storyboard.video;
const viewport = viewportFor(storyboard.video);
if (finalDuration > maxDurationS) {
console.log(
`Scene output duration is ${finalDuration.toFixed(2)}s (guard ${MAX_DURATION_S.toFixed(2)}s); keeping the full take.`
`[${storyboard.name}] Scene output duration is ${finalDuration.toFixed(2)}s ` +
`(guard ${maxDurationS.toFixed(2)}s); keeping the full take.`
);
}
const filter =
`trim=start=${trimStart.toFixed(3)}:duration=${wallDuration.toFixed(3)},` +
`setpts=PTS-STARTPTS,fps=${OUTPUT_FPS},` +
`setpts=PTS-STARTPTS,fps=${outputFps},` +
`trim=duration=${finalDuration.toFixed(3)},setpts=PTS-STARTPTS`;
// Keep trimming inside the filter graph: it is frame-accurate for WebM
// without the keyframe leakage of input seeking.
execSync(
`ffmpeg -y -i "${rawPath}" -vf "${filter}" ` +
`-fps_mode cfr -r ${OUTPUT_FPS} -c:v libvpx -b:v ${WEBM_BITRATE} -deadline good -cpu-used 5 ` +
`-fps_mode cfr -r ${outputFps} -c:v libvpx -b:v ${webmBitrate} -deadline good -cpu-used 5 ` +
`"${trimmedPath}"`,
{ stdio: 'inherit' }
);
@ -44,6 +50,6 @@ export function trimRecording(
}
console.log(
`Wrote ${trimmedPath} (${finalDuration.toFixed(2)}s, scene=${sceneSpan.toFixed(2)}s, capture=${VIDEO_SIZE.width}x${VIDEO_SIZE.height})`
`[${storyboard.name}] Wrote ${trimmedPath} (${finalDuration.toFixed(2)}s, scene=${sceneSpan.toFixed(2)}s, capture=${viewport.width}x${viewport.height})`
);
}