LGTM
This commit is contained in:
parent
9248e26af2
commit
f2a2651b8a
95 changed files with 3993 additions and 1471 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue