This commit is contained in:
Andras Schmelczer 2026-05-12 22:00:56 +01:00
parent 8708bf000d
commit 11711c57e6
38 changed files with 5361 additions and 265 deletions

View file

@ -6,8 +6,9 @@ import { el, type Storyboard } from './script.js';
* 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.
* global state. The exported array can contain generated variants, so a
* shared visual storyboard can render once per language without repeating
* its activity sequence.
*
* `name` doubles as the on-disk slug. The pipeline writes per-storyboard
* artefacts to `output/<name>/` and publishes `<name>.mp4` / `<name>.jpg`
@ -23,15 +24,6 @@ import { el, type Storyboard } from './script.js';
* before the next cue's gap).
*/
const PROMPT_TEXT = 'Flats <£300k, 35 min to commute Manchester close to an outstanding school in a quite low crime area';
const BRAND = {
name: 'Perfect Postcode',
tagline: 'Your best chance to find your next perfect home.',
url: 'https://perfect-postcode.co.uk',
};
const AI_ZOOM_SCALE = 2.4;
const TT_CARD_SELECTOR = '[data-filter-name="tt_0"]';
@ -39,103 +31,238 @@ const TT_SLIDER_MAX = 120;
const TT_DRAG_FROM_MIN = 35;
const TT_DRAG_TO_MIN = 20;
const BRITISH_MALE_NARRATOR =
'Calm and cheerful young British male narrator from the North of England with a ' +
'strong Manchester accent.';
type RecordingLocale = 'en' | 'de' | 'zh' | 'hi';
const DEFAULT_CUES: Storyboard['cues'] = [
{
text: 'Start by describing the type of place you\'re looking for',
gapBeforeMs: 0,
tail: [
{
kind: 'type',
selector: '[data-tutorial="ai-filters"] textarea',
text: PROMPT_TEXT,
durationMs: 3000,
},
{ kind: 'submitForm', formSelector: '[data-tutorial="ai-filters"] form', durationMs: 1700 },
],
},
{
text: 'The dashboard will show you the likeliest places that will meet your expectations',
gapBeforeMs: 400,
during: [{ kind: 'zoomReset', durationMs: 1400 }],
tail: [{ kind: 'wait', durationMs: 500 }],
},
interface RecordingLocalization {
name: string;
appLanguage: string;
ttsLanguage: string;
voiceInstruct: string;
voiceReferenceText: string;
promptText: string;
travelTimeLabel: string;
exportButtonTitle: string;
brand: {
name: string;
tagline: string;
url: string;
};
cues: {
describe: string;
dashboard: string;
filters: string;
details: string;
shortlist: string;
};
}
{
text: `Adjust the filters to narrow down to the best candidates`,
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: 1000,
},
],
tail: [{ kind: 'wait', durationMs: 400 }],
},
const BRAND_URL = 'https://perfect-postcode.co.uk';
{
text: 'And now it\'s time to dig into the details. Looks good to me!',
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: 500 },
{
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 },
],
const RECORDING_LOCALIZATIONS: Record<RecordingLocale, RecordingLocalization> = {
en: {
name: 'recording',
appLanguage: 'en',
ttsLanguage: 'English',
voiceInstruct:
'Calm and cheerful young British male narrator from the North of England with a ' +
'strong Manchester accent.',
voiceReferenceText:
"Welcome to the demonstration. This is the narrator voice you'll hear throughout the video.",
promptText:
'Flats <£300k, 35 min to commute Manchester close to an outstanding school in a quite low crime area',
travelTimeLabel: 'Manchester city centre',
exportButtonTitle: 'Export to Excel',
brand: {
name: 'Perfect Postcode',
tagline: 'Your best chance to find your next perfect home.',
url: BRAND_URL,
},
cues: {
describe: "Start by describing the type of place you're looking for",
dashboard: 'The dashboard will show you the likeliest places that will meet your expectations',
filters: 'Adjust the filters to narrow down to the best candidates',
details: "And now it's time to dig into the details. Looks good to me!",
shortlist:
'Now you can take your shortlist and start looking for your next home in your perfect postcode.',
},
},
de: {
name: 'recording-de',
appLanguage: 'de',
ttsLanguage: 'German',
voiceInstruct:
'Calm and cheerful German male narrator with clear standard German pronunciation ' +
'and a friendly, practical delivery.',
voiceReferenceText:
'Willkommen zur Demonstration. Diese Sprecherstimme hören Sie im gesamten Video.',
promptText:
'Wohnungen unter £300k, 35 Min. Pendelzeit nach Manchester, nahe einer herausragenden Schule in einer sehr kriminalitätsarmen Gegend',
travelTimeLabel: 'Stadtzentrum Manchester',
exportButtonTitle: 'Als Excel exportieren',
brand: {
name: 'Perfect Postcode',
tagline: 'Ihre beste Chance, Ihr nächstes perfektes Zuhause zu finden.',
url: BRAND_URL,
},
cues: {
describe: 'Beschreiben Sie zuerst, wonach Sie suchen.',
dashboard: 'Das Dashboard zeigt die Orte, die Ihre Erwartungen am ehesten erfüllen.',
filters: 'Passen Sie die Filter an, um die besten Kandidaten einzugrenzen.',
details: 'Jetzt geht es in die Details. Sieht gut aus!',
shortlist:
'Jetzt können Sie Ihre Auswahl nehmen und Ihr nächstes Zuhause in Ihrem perfekten Postcode suchen.',
},
},
zh: {
name: 'recording-zh',
appLanguage: 'zh',
ttsLanguage: 'Chinese',
voiceInstruct:
'Calm and cheerful Mandarin Chinese male narrator with clear standard Mandarin ' +
'pronunciation and a friendly, practical delivery.',
voiceReferenceText: '欢迎观看演示。整段视频都会使用这位旁白的声音。',
promptText: '30万英镑以内的公寓35分钟通勤到曼彻斯特靠近优秀学校犯罪率很低的区域',
travelTimeLabel: '曼彻斯特市中心',
exportButtonTitle: '导出为 Excel',
brand: {
name: 'Perfect Postcode',
tagline: '帮你更有把握找到下一个理想家。',
url: BRAND_URL,
},
cues: {
describe: '先描述你想找什么样的地方',
dashboard: '仪表板会显示最符合你期望的地点',
filters: '调整筛选条件,缩小到最合适的候选区域',
details: '现在深入查看细节。看起来不错!',
shortlist: '现在你可以带着候选清单,开始寻找理想邮编里的下一个家。',
},
},
hi: {
name: 'recording-hi',
appLanguage: 'hi',
ttsLanguage: 'English',
voiceInstruct:
'Calm and cheerful Indian male narrator speaking English with a strong Indian accent ' +
'and a friendly, practical delivery.',
voiceReferenceText:
"Welcome to the demonstration. This is the narrator voice you'll hear throughout the video.",
promptText:
'Flats <£300k, 35 min to commute Manchester close to an outstanding school in a quite low crime area',
travelTimeLabel: 'Manchester city centre',
exportButtonTitle: 'Excel में निर्यात करें',
brand: {
name: 'Perfect Postcode',
tagline: 'Your best chance to find your next perfect home.',
url: BRAND_URL,
},
cues: {
describe: "Start by describing the type of place you're looking for",
dashboard: 'The dashboard will show you the likeliest places that will meet your expectations',
filters: 'Adjust the filters to narrow down to the best candidates',
details: "And now it's time to dig into the details. Looks good to me!",
shortlist:
'Now you can take your shortlist and start looking for your next home in your perfect postcode.',
},
},
};
{
text: 'Now you can take your shortlist and start looking for your next home in your perfect postcode.',
gapBeforeMs: 500,
during: [
{ kind: 'zoomReset', durationMs: 900 },
{
kind: 'click',
target: el('button[title="Export to Excel"]'),
durationMs: 800,
},
],
tail: [{ kind: 'wait', durationMs: 800 }],
},
function createCues(locale: RecordingLocale): Storyboard['cues'] {
const copy = RECORDING_LOCALIZATIONS[locale];
{
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 }],
},
];
return [
{
text: copy.cues.describe,
gapBeforeMs: 0,
tail: [
{
kind: 'type',
selector: '[data-tutorial="ai-filters"] textarea',
text: copy.promptText,
durationMs: 3000,
},
{ kind: 'submitForm', formSelector: '[data-tutorial="ai-filters"] form', durationMs: 1700 },
],
},
{
text: copy.cues.dashboard,
gapBeforeMs: 400,
during: [{ kind: 'zoomReset', durationMs: 1400 }],
tail: [{ kind: 'wait', durationMs: 500 }],
},
{
text: copy.cues.filters,
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: 1000,
},
],
tail: [{ kind: 'wait', durationMs: 400 }],
},
{
text: copy.cues.details,
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: 500 },
{
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 },
],
},
{
text: copy.cues.shortlist,
gapBeforeMs: 500,
during: [
{ kind: 'zoomReset', durationMs: 900 },
{
kind: 'click',
target: el(`button[title="${copy.exportButtonTitle}"]`),
durationMs: 800,
},
],
tail: [{ kind: 'wait', durationMs: 800 }],
},
{
text: `${copy.brand.name}. ${copy.brand.tagline}`,
gapBeforeMs: 600,
during: [
{
kind: 'showOutro',
brand: copy.brand.name,
tagline: copy.brand.tagline,
url: copy.brand.url,
durationMs: 0,
},
],
tail: [{ kind: 'wait', durationMs: 1500 }],
},
];
}
const DEFAULT_PRE: Storyboard['pre'] = [
{ kind: 'clearVignette', durationMs: 0 },
@ -149,9 +276,12 @@ const DEFAULT_PRE: Storyboard['pre'] = [
{ kind: 'wait', durationMs: 140 },
];
export const storyboards: Storyboard[] = [
{
name: 'recording',
function createRecordingStoryboard(locale: RecordingLocale): Storyboard {
const copy = RECORDING_LOCALIZATIONS[locale];
return {
name: copy.name,
locale,
video: {
aspect: '16x9',
captureScale: 1,
@ -168,23 +298,25 @@ export const storyboards: Storyboard[] = [
posterTimeS: 16,
},
voice: {
instruct: BRITISH_MALE_NARRATOR,
language: 'English',
instruct: copy.voiceInstruct,
language: copy.ttsLanguage,
referenceText: copy.voiceReferenceText,
temperature: 0.6,
topP: 0.9,
seed: 42,
},
content: {
promptText: PROMPT_TEXT,
promptText: copy.promptText,
appLanguage: copy.appLanguage,
aiZoomScale: AI_ZOOM_SCALE,
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],
'Property type': ['Flats/Maisonettes'],
'Estimated current price': [0, 300000],
'Serious crime per 1k residents (avg/yr)': [0, 55],
'Noise (dB)': [50, 68],
'Outstanding primary schools within 2km': [1, 10],
},
// Travel-time filters returned by the AI stub. Slug matches the real
// /api/travel-destinations?mode=transit response.
@ -192,7 +324,7 @@ export const storyboards: Storyboard[] = [
{
mode: 'transit',
slug: 'manchester',
label: 'Manchester city centre',
label: copy.travelTimeLabel,
max: TT_DRAG_FROM_MIN,
},
],
@ -200,12 +332,16 @@ export const storyboards: Storyboard[] = [
travelTimeSliderMax: TT_SLIDER_MAX,
travelTimeDragFromMin: TT_DRAG_FROM_MIN,
travelTimeDragToMin: TT_DRAG_TO_MIN,
brand: BRAND,
brand: copy.brand,
},
pre: DEFAULT_PRE,
cues: DEFAULT_CUES,
},
];
cues: createCues(locale),
};
}
export const storyboards: Storyboard[] = (['en', 'de', 'zh', 'hi'] as const).map((locale) =>
createRecordingStoryboard(locale)
);
export function getStoryboard(name: string): Storyboard {
const sb = storyboards.find((s) => s.name === name);