This commit is contained in:
Andras Schmelczer 2026-05-09 09:26:40 +01:00
parent 701c17a703
commit f114ada255
44 changed files with 5264 additions and 1674 deletions

View file

@ -8,21 +8,21 @@
"name": "video",
"version": "1.0.0",
"dependencies": {
"playwright": "^1.49.0"
"playwright": "^1.59.1"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.7.0"
"@types/node": "^25.6.1",
"typescript": "^6.0.3"
}
},
"node_modules/@types/node": {
"version": "22.19.17",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz",
"integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==",
"version": "25.6.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.1.tgz",
"integrity": "sha512-coJCN8O1q4AGyyqCAUSP06P+SrMTu18BkEj3NVAK07q6QUneD2wzj3CLv9+yP+BMeZQlMvneXqqvDe3w+xcq7g==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
"undici-types": "~7.19.0"
}
},
"node_modules/fsevents": {
@ -70,9 +70,9 @@
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@ -84,9 +84,9 @@
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"version": "7.19.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
"integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==",
"dev": true,
"license": "MIT"
}

View file

@ -15,10 +15,10 @@
"render": "./render.sh"
},
"dependencies": {
"playwright": "^1.49.0"
"playwright": "^1.59.1"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.7.0"
"@types/node": "^25.6.1",
"typescript": "^6.0.3"
}
}

View file

@ -1,326 +0,0 @@
import type { Page } from 'playwright';
import type { DashboardRecorder, HexagonClickTarget } from './dashboard.js';
import {
AI_ZOOM_SCALE,
BRAND_NAME,
BRAND_TAGLINE,
BRAND_URL,
PROMPT_TEXT,
TT_CARD_SELECTOR,
TT_DRAG_FROM_MIN,
TT_DRAG_TO_MIN,
TT_SLIDER_MAX,
} from './config.js';
import {
clearVignette,
flashRect,
hideCaption,
showCaption,
showOutro,
zoomReset,
zoomTo,
} from './dom.js';
import { fakeType, sleep, smoothMove, smoothDragSliderThumb } from './motion.js';
export interface SceneCtx {
page: Page;
dashboard: DashboardRecorder;
cursor: { x: number; y: number };
}
const AI_CLOSEUP_ZOOM_MS = 1400;
const RESULTS_ZOOM_OUT_MS = 1500;
const EXPORT_ZOOM_OUT_MS = 1100;
const PROMPT_TYPING_DELAY_MS = 64;
const MAP_ZOOM_WHEEL_STEPS = 18;
const MAP_ZOOM_WHEEL_DELTA = -120;
const MAP_ZOOM_WHEEL_PAUSE_MS = 70;
/**
* Scene 1: start wide, then zoom into the AI prompt. The AI response is
* stubbed, while the map filters and right pane are loaded from the real app.
*/
export async function sceneAiCloseUp(ctx: SceneCtx): Promise<void> {
const { page, dashboard } = ctx;
await clearVignette(page);
await showCaption(page, 'Brief: flats or terraces under £450k near central Manchester.');
await sleep(180);
await zoomToAiBox(page, AI_CLOSEUP_ZOOM_MS);
await sleep(AI_CLOSEUP_ZOOM_MS + 160);
await fakeType(
page,
'[data-tutorial="ai-filters"] textarea',
PROMPT_TEXT,
PROMPT_TYPING_DELAY_MS
);
await sleep(160);
const aiResponse = page
.waitForResponse(
(response) => response.url().includes('/api/ai-filters') && response.status() === 200,
{ timeout: 1800 }
)
.catch(() => null);
const mapVersion = dashboard.getMapDataVersion();
await page.evaluate(() => {
document.querySelector<HTMLFormElement>('[data-tutorial="ai-filters"] form')?.requestSubmit();
});
await aiResponse;
await sleep(160);
await dashboard.waitForMapSettled(mapVersion, 15000);
await showCaption(page, 'The filters are already live on the map.');
await sleep(560);
await hideCaption(page);
}
/**
* Scene 2: animate the wrapper back to scale 1 so the full dashboard is
* revealed. The map has already pan-flown to Manchester (MapPage's
* own flyTo fires when AI travel-time filters are applied), so the zoom-out
* lands on a useful, filtered view.
*/
export async function sceneZoomOutResults(ctx: SceneCtx): Promise<void> {
const { page } = ctx;
await showCaption(page, 'Now the view lands on central Manchester with the filters already set.');
await zoomReset(page, RESULTS_ZOOM_OUT_MS);
await sleep(RESULTS_ZOOM_OUT_MS + 160);
await hideCaption(page);
await sleep(180);
}
/**
* Scene 3: drag the right thumb of the AI-applied travel-time slider from
* 35 to 20 minutes. The slider has step=1 over 0120, so the drag is paced
* with real pointer updates instead of jumping the value directly.
*
* The card we drag (`tt_0`) only exists because the AI filter step inserted
* exactly one travel-time entry; if you change the AI stub's count, update
* the selector or this scene will time out.
*/
export async function sceneTravelTimeSlider(ctx: SceneCtx): Promise<void> {
const { page, dashboard } = ctx;
await showCaption(
page,
`Then tighten the commute: ${TT_DRAG_FROM_MIN} minutes down to ${TT_DRAG_TO_MIN}.`
);
const card = page.locator(TT_CARD_SELECTOR);
await card.waitFor({ state: 'visible', timeout: 4000 });
await card.scrollIntoViewIfNeeded();
await sleep(60);
// Two thumbs in a Radix range slider; the second one is the max.
const thumbSelector = `${TT_CARD_SELECTOR} [role="slider"] >> nth=1`;
// Track is the first horizontal-orientation element inside the card.
const trackSelector = `${TT_CARD_SELECTOR} [data-orientation="horizontal"] >> nth=0`;
// Slider goes 0..120, target = 20 → fraction 0.166...
const toFraction = TT_DRAG_TO_MIN / TT_SLIDER_MAX;
const mapVersion = dashboard.getMapDataVersion();
ctx.cursor = await smoothDragSliderThumb(
page,
thumbSelector,
trackSelector,
ctx.cursor,
toFraction,
1180
);
await sleep(220);
await dashboard.waitForMapSettled(mapVersion, 16000);
await showCaption(page, 'The map redraws around the areas that still work.');
await sleep(720);
await hideCaption(page);
await sleep(180);
}
/**
* Scene 4: after the filtered result map is visible, zoom into Manchester,
* click a hexagon, then let the right pane open from that selection.
*/
export async function sceneClusterClick(ctx: SceneCtx): Promise<void> {
const { page } = ctx;
const viewport = page.viewportSize() ?? { width: 1920, height: 1080 };
await showCaption(page, 'Zoom into the result clusters, then click a promising hexagon.');
const defaultCluster = {
x: 360 + (viewport.width - 360) * 0.35,
y: viewport.height * 0.52,
};
const cluster = await pickMapZoomTarget(ctx, defaultCluster);
await smoothMove(page, ctx.cursor, cluster, { durationMs: 520 });
ctx.cursor = cluster;
await sleep(220);
await zoomMapWithWheel(ctx, cluster);
ctx.cursor = await clickVisibleHexagon(ctx, cluster);
await sleep(360);
await showCaption(
page,
'This is the useful pause: local stats, matching homes, and street context together.'
);
await sleep(1000);
await hideCaption(page);
}
async function pickMapZoomTarget(
ctx: SceneCtx,
fallback: { x: number; y: number }
): Promise<{ x: number; y: number }> {
const [target] = await ctx.dashboard.visibleHexagonTargets(1).catch(() => []);
return target ? { x: target.x, y: target.y } : fallback;
}
async function zoomMapWithWheel(ctx: SceneCtx, target: { x: number; y: number }): Promise<void> {
const { page, dashboard } = ctx;
const mapVersion = dashboard.getMapDataVersion();
await page.mouse.move(target.x, target.y);
for (let i = 0; i < MAP_ZOOM_WHEEL_STEPS; i++) {
await page.mouse.wheel(0, MAP_ZOOM_WHEEL_DELTA);
await sleep(MAP_ZOOM_WHEEL_PAUSE_MS);
}
await dashboard.waitForMapSettled(mapVersion, 16000);
await sleep(260);
}
async function clickVisibleHexagon(
ctx: SceneCtx,
fallbackTarget: { x: number; y: number }
): Promise<{ x: number; y: number }> {
const candidates = await ctx.dashboard.visibleHexagonTargets(8).catch((error) => {
console.log(
`[scene] Falling back to direct map click targets: ${
error instanceof Error ? error.message : String(error)
}`
);
return [];
});
const clickTargets = await addFallbackClickTargets(ctx, candidates, fallbackTarget);
const startedAt = ctx.dashboard.getSelectionStatsVersion();
let lastError: Error | null = null;
for (const target of clickTargets) {
await moveAndClickHexagon(ctx, target);
try {
await ctx.dashboard.waitForSelectionReady(startedAt, 7000);
return { x: target.x, y: target.y };
} catch (error) {
if (ctx.dashboard.getSelectionStatsVersion() > startedAt) {
return { x: target.x, y: target.y };
}
lastError = error instanceof Error ? error : new Error(String(error));
}
}
throw new Error(
`Could not open a map selection from the visible hexagons${
lastError ? `: ${lastError.message}` : ''
}`
);
}
async function addFallbackClickTargets(
ctx: SceneCtx,
candidates: HexagonClickTarget[],
fallbackTarget: { x: number; y: number }
): Promise<HexagonClickTarget[]> {
const mapBox = await ctx.page.locator('[data-tutorial="map"]').boundingBox();
const fallbacks: HexagonClickTarget[] = [
{
h3: 'direct-target',
x: fallbackTarget.x,
y: fallbackTarget.y,
count: 1,
},
];
if (mapBox) {
fallbacks.push({
h3: 'map-center',
x: mapBox.x + mapBox.width / 2,
y: mapBox.y + mapBox.height / 2,
count: 1,
});
}
const seen = new Set<string>();
return [...candidates, ...fallbacks].filter((target) => {
const key = `${Math.round(target.x / 12)},${Math.round(target.y / 12)}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
async function moveAndClickHexagon(ctx: SceneCtx, target: HexagonClickTarget): Promise<void> {
await smoothMove(ctx.page, ctx.cursor, { x: target.x, y: target.y }, { durationMs: 420 });
ctx.cursor = { x: target.x, y: target.y };
await ctx.page.mouse.click(target.x, target.y);
await sleep(140);
}
/** Export the current shortlist, then reveal the URL. */
export async function sceneExportAndOutro(ctx: SceneCtx): Promise<void> {
const { page } = ctx;
await showCaption(page, 'When the shortlist feels right, export the exact filtered view.');
await zoomReset(page, EXPORT_ZOOM_OUT_MS);
await sleep(EXPORT_ZOOM_OUT_MS + 120);
const exportButton = page.locator('button[title="Export to Excel"]').first();
await exportButton.waitFor({ state: 'visible', timeout: 4000 });
const box = await exportButton.boundingBox();
if (!box) throw new Error('Export button has no bounding box');
const target = { x: box.x + box.width / 2, y: box.y + box.height / 2 };
await smoothMove(page, ctx.cursor, target, { durationMs: 620 });
ctx.cursor = target;
await sleep(160);
const download = page.waitForEvent('download', { timeout: 4000 }).catch(() => null);
await page.mouse.click(target.x, target.y);
await flashRect(page, box);
await sleep(680);
await hideCaption(page);
await showOutro(ctx.page, BRAND_NAME, BRAND_TAGLINE, BRAND_URL);
void download;
await sleep(2200);
}
/** Open the AI prompt before the timed scene starts. */
export async function prepareAiBox(ctx: SceneCtx): Promise<void> {
const { page } = ctx;
const aiRoot = page.locator('[data-tutorial="ai-filters"]').first();
await aiRoot.waitFor({ state: 'visible', timeout: 15000 });
const textarea = page.locator('[data-tutorial="ai-filters"] textarea');
if (!(await textarea.isVisible().catch(() => false))) {
const aiButton = aiRoot.locator('button').first();
await aiButton.waitFor({ state: 'visible', timeout: 8000 });
const btnBox = await aiButton.boundingBox();
if (btnBox) await page.mouse.click(btnBox.x + btnBox.width / 2, btnBox.y + btnBox.height / 2);
}
if (!(await textarea.isVisible().catch(() => false))) {
await page.evaluate(() => {
document.querySelector<HTMLElement>('[data-tutorial="ai-filters"] button')?.click();
});
}
await textarea.waitFor({ state: 'visible', timeout: 15000 });
await sleep(100);
}
async function zoomToAiBox(page: Page, durationMs: number): Promise<void> {
const aiCard = page.locator('[data-tutorial="ai-filters"]');
const cardBox = await aiCard.boundingBox();
if (!cardBox) throw new Error('AI card has no bounding box');
const focusX = cardBox.x + cardBox.width / 2;
const focusY = cardBox.y + cardBox.height / 2;
await zoomTo(page, { scale: AI_ZOOM_SCALE, focusX, focusY, durationMs });
}

29
video/tts/pyproject.toml Normal file
View file

@ -0,0 +1,29 @@
[project]
name = "property-map-video-tts"
version = "0.1.0"
description = "Qwen3-TTS narration generator for the homepage demo video."
requires-python = ">=3.12,<3.13"
dependencies = [
"qwen-tts>=0.1.1",
# Host driver is CUDA 12.4 (see `nvidia-smi`). torch 2.7+ dropped cu124
# wheels, so we cap below that and pull the cu124 build from PyTorch's
# own index (configured below). torchaudio must match torch's CUDA build
# — the PyPI default ships a CUDA 13 binary that fails to load
# libcudart.so.13 on this host.
"torch>=2.5,<2.7",
"torchaudio>=2.5,<2.7",
"soundfile>=0.12",
"numpy>=1.26",
]
[tool.uv]
environments = ["sys_platform == 'linux' and python_version < '3.13'"]
[tool.uv.sources]
torch = [{ index = "pytorch-cu124" }]
torchaudio = [{ index = "pytorch-cu124" }]
[[tool.uv.index]]
name = "pytorch-cu124"
url = "https://download.pytorch.org/whl/cu124"
explicit = true

1278
video/tts/uv.lock generated Normal file

File diff suppressed because it is too large Load diff