LGTM
This commit is contained in:
parent
701c17a703
commit
f114ada255
44 changed files with 5264 additions and 1674 deletions
26
video/package-lock.json
generated
26
video/package-lock.json
generated
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 0–120, 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
29
video/tts/pyproject.toml
Normal 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
1278
video/tts/uv.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue