Improve FAQ & video rendering, tighten homepage and CSS
This commit is contained in:
parent
05a1f316e1
commit
c69bb0d614
48 changed files with 4689 additions and 1077 deletions
4
video/.gitignore
vendored
Normal file
4
video/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
node_modules
|
||||
dist
|
||||
output
|
||||
auth.json
|
||||
94
video/package-lock.json
generated
Normal file
94
video/package-lock.json
generated
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
{
|
||||
"name": "video",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "video",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"playwright": "^1.49.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.7.0"
|
||||
}
|
||||
},
|
||||
"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==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.59.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
|
||||
"integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.59.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.59.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
|
||||
"integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"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==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
22
video/package.json
Normal file
22
video/package.json
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"name": "video",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "Scripted Playwright recording of the dashboard for the homepage hero and social ads.",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"setup-auth": "tsc && node dist/auth.js",
|
||||
"record": "tsc && node dist/record.js",
|
||||
"record:vertical": "tsc && ASPECT=9x16 node dist/record.js",
|
||||
"encode": "ffmpeg -y -i output/recording.webm -c:v libx264 -pix_fmt yuv420p -crf 16 -preset slow -movflags +faststart output/recording.mp4",
|
||||
"render": "./render.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"playwright": "^1.49.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.7.0"
|
||||
}
|
||||
}
|
||||
150
video/render.sh
Executable file
150
video/render.sh
Executable file
|
|
@ -0,0 +1,150 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# End-to-end re-render of the dashboard demo video.
|
||||
#
|
||||
# Defaults assume you run from inside this repo's vscode-server container
|
||||
# (where host.docker.internal reaches the docker-compose stack). Override
|
||||
# any URL/credential via env vars at the top.
|
||||
#
|
||||
# Usage:
|
||||
# ./render.sh # full pipeline (uses cached auth.json if fresh)
|
||||
# ./render.sh --fresh-auth # force re-auth even if auth.json exists
|
||||
# ./render.sh --no-encode # stop at WebM, skip MP4 encode
|
||||
# FORCE_AUTH=1 ./render.sh # same as --fresh-auth
|
||||
# APP_URL=http://localhost:3001 ./render.sh # override frontend URL
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# -- config (override via env) -------------------------------------------------
|
||||
APP_URL="${APP_URL:-http://host.docker.internal:3001}"
|
||||
PB_URL="${PB_URL:-http://host.docker.internal:8090}"
|
||||
API_URL="${API_URL:-http://host.docker.internal:8001}"
|
||||
PB_EMAIL="${PB_EMAIL:-demo-video@local.test}"
|
||||
PB_PASSWORD="${PB_PASSWORD:-DemoVideoPass123!}"
|
||||
MAX_DURATION_S="${MAX_DURATION_S:-15}"
|
||||
AUTH_TTL_HOURS="${AUTH_TTL_HOURS:-24}" # re-auth if auth.json older than this
|
||||
|
||||
FRESH_AUTH="${FORCE_AUTH:-0}"
|
||||
DO_ENCODE=1
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--fresh-auth) FRESH_AUTH=1 ;;
|
||||
--no-encode) DO_ENCODE=0 ;;
|
||||
-h|--help)
|
||||
sed -n '3,18p' "$0"
|
||||
exit 0 ;;
|
||||
*) echo "Unknown arg: $arg" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
# -- helpers ------------------------------------------------------------------
|
||||
say() { printf '\n[render] %s\n' "$*"; }
|
||||
fail() { printf '\n[render] FAIL: %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
http_code() {
|
||||
curl -s -o /dev/null -w "%{http_code}" --connect-timeout 3 --max-time 5 "$1" || echo "000"
|
||||
}
|
||||
|
||||
wait_for() {
|
||||
local url="$1" desc="$2" timeout="${3:-90}"
|
||||
say "Waiting for $desc ($url)"
|
||||
for i in $(seq 1 "$timeout"); do
|
||||
if [ "$(http_code "$url")" = "200" ]; then
|
||||
say " ready after ${i}s"
|
||||
return 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
fail "$desc not reachable after ${timeout}s"
|
||||
}
|
||||
|
||||
# -- stack health -------------------------------------------------------------
|
||||
say "Checking stack health"
|
||||
fe_code="$(http_code "$APP_URL/")"
|
||||
api_code="$(http_code "$API_URL/api/features")"
|
||||
pb_code="$(http_code "$PB_URL/api/health")"
|
||||
echo " frontend=$fe_code api=$api_code pocketbase=$pb_code"
|
||||
|
||||
if [ "$fe_code" != "200" ] || [ "$pb_code" != "200" ]; then
|
||||
fail "Stack down. From the repo root run: docker compose up -d"
|
||||
fi
|
||||
if [ "$api_code" != "200" ]; then
|
||||
wait_for "$API_URL/api/features" "Rust API" 120
|
||||
fi
|
||||
|
||||
# -- node deps ----------------------------------------------------------------
|
||||
if [ ! -d node_modules ]; then
|
||||
say "Installing npm deps"
|
||||
npm install --no-audit --no-fund
|
||||
fi
|
||||
|
||||
# Chromium binary lives in Playwright's cache; install if missing.
|
||||
if ! npx --no-install playwright --version >/dev/null 2>&1 \
|
||||
|| [ ! -d "$HOME/.cache/ms-playwright" ] \
|
||||
|| ! find "$HOME/.cache/ms-playwright" -maxdepth 1 -name "chromium-*" -print -quit | grep -q .; then
|
||||
say "Installing Playwright Chromium"
|
||||
npx playwright install chromium
|
||||
fi
|
||||
|
||||
# -- build --------------------------------------------------------------------
|
||||
say "Compiling TypeScript"
|
||||
./node_modules/.bin/tsc
|
||||
|
||||
# -- auth ---------------------------------------------------------------------
|
||||
need_auth=0
|
||||
if [ "$FRESH_AUTH" = "1" ] || [ ! -f auth.json ]; then
|
||||
need_auth=1
|
||||
else
|
||||
# File mtime check, portable: if older than TTL, refresh.
|
||||
if [ "$(find auth.json -mmin "+$((AUTH_TTL_HOURS * 60))" -print 2>/dev/null)" ]; then
|
||||
say "auth.json is older than ${AUTH_TTL_HOURS}h, will refresh"
|
||||
need_auth=1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$need_auth" = "1" ]; then
|
||||
say "Minting fresh auth.json (user: $PB_EMAIL)"
|
||||
PB_URL="$PB_URL" PB_EMAIL="$PB_EMAIL" PB_PASSWORD="$PB_PASSWORD" \
|
||||
APP_URL="$APP_URL" \
|
||||
node dist/auth.js
|
||||
else
|
||||
say "Reusing existing auth.json"
|
||||
fi
|
||||
|
||||
# -- record -------------------------------------------------------------------
|
||||
say "Recording"
|
||||
mkdir -p output
|
||||
# Wipe last run's leaking artifacts so the rename step picks up *this* run.
|
||||
rm -f output/recording.webm output/recording.mp4 output/page@*.webm output/page@*.webm.untrimmed
|
||||
|
||||
APP_URL="$APP_URL" MAX_DURATION_S="$MAX_DURATION_S" node dist/record.js
|
||||
|
||||
if [ ! -s output/recording.webm ]; then
|
||||
fail "recording.webm missing or empty"
|
||||
fi
|
||||
|
||||
# -- encode -------------------------------------------------------------------
|
||||
if [ "$DO_ENCODE" = "1" ]; then
|
||||
if ! command -v ffmpeg >/dev/null 2>&1; then
|
||||
fail "ffmpeg not on PATH; rerun with --no-encode if you only need the WebM"
|
||||
fi
|
||||
say "Encoding to MP4"
|
||||
ffmpeg -y -loglevel warning -i output/recording.webm \
|
||||
-c:v libx264 -pix_fmt yuv420p -crf 18 -movflags +faststart \
|
||||
output/recording.mp4
|
||||
fi
|
||||
|
||||
# -- report -------------------------------------------------------------------
|
||||
say "Done"
|
||||
if command -v ffprobe >/dev/null 2>&1; then
|
||||
for f in output/recording.webm output/recording.mp4; do
|
||||
[ -f "$f" ] || continue
|
||||
dur=$(ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "$f")
|
||||
size=$(stat -c '%s' "$f" 2>/dev/null || stat -f '%z' "$f")
|
||||
printf ' %s %ss %s bytes\n' "$f" "$(printf '%.2f' "$dur")" "$size"
|
||||
done
|
||||
else
|
||||
ls -la output/recording.* 2>/dev/null || true
|
||||
fi
|
||||
97
video/src/auth.ts
Normal file
97
video/src/auth.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import { chromium } from 'playwright';
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import { APP_URL, AUTH_STATE_PATH } from './config.js';
|
||||
|
||||
/**
|
||||
* Auth setup. Two modes:
|
||||
*
|
||||
* 1. Programmatic (preferred for CI / non-interactive runs): set
|
||||
* PB_URL, PB_EMAIL, PB_PASSWORD env vars. We hit the PocketBase REST
|
||||
* auth-with-password endpoint, then hand-write a Playwright storageState
|
||||
* file with the resulting token in localStorage["pb_auth"]. The PocketBase
|
||||
* JS SDK reads that key on boot and treats us as logged in — bit-equivalent
|
||||
* to a real UI login.
|
||||
*
|
||||
* 2. Interactive: no env vars, we open a headed browser, you log in by hand,
|
||||
* press Enter, and we serialize the resulting cookies + localStorage.
|
||||
* Works on a developer laptop; doesn't work in headless environments.
|
||||
*/
|
||||
|
||||
interface PbAuthResponse {
|
||||
token: string;
|
||||
record: Record<string, unknown>;
|
||||
}
|
||||
|
||||
async function programmatic() {
|
||||
const email = process.env.PB_EMAIL!;
|
||||
const password = process.env.PB_PASSWORD!;
|
||||
|
||||
// Driving the login through the app itself ensures the PocketBase SDK's
|
||||
// LocalAuthStore sees the token via its own write path. Hand-writing
|
||||
// localStorage["pb_auth"] sometimes races with the SDK's module-time read.
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({ viewport: { width: 1280, height: 800 } });
|
||||
const page = await context.newPage();
|
||||
await page.goto(APP_URL);
|
||||
|
||||
await page.evaluate(
|
||||
async ({ email, password }) => {
|
||||
const res = await fetch('/pb/api/collections/users/auth-with-password', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ identity: email, password }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`login ${res.status} ${await res.text()}`);
|
||||
const data = await res.json();
|
||||
// The SDK's LocalAuthStore default storageKey is "pocketbase_auth",
|
||||
// not "pb_auth" (which is just the cookie name in BaseAuthStore).
|
||||
localStorage.setItem(
|
||||
'pocketbase_auth',
|
||||
JSON.stringify({ token: data.token, record: data.record })
|
||||
);
|
||||
// Skip the react-joyride product tour — its spotlight overlay
|
||||
// intercepts pointer events and breaks the recording.
|
||||
localStorage.setItem('tutorial_completed', '1');
|
||||
},
|
||||
{ email, password }
|
||||
);
|
||||
|
||||
await context.storageState({ path: AUTH_STATE_PATH });
|
||||
await browser.close();
|
||||
console.log(`Saved ${AUTH_STATE_PATH} via in-app PocketBase login (user: ${email}).`);
|
||||
}
|
||||
|
||||
async function interactive() {
|
||||
const browser = await chromium.launch({ headless: false });
|
||||
const context = await browser.newContext({ viewport: { width: 1280, height: 800 } });
|
||||
const page = await context.newPage();
|
||||
await page.goto(APP_URL);
|
||||
|
||||
console.log('');
|
||||
console.log(' → Log in via the UI in the opened browser window.');
|
||||
console.log(' → Once you see the dashboard, press Enter in this terminal.');
|
||||
console.log('');
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
process.stdin.resume();
|
||||
process.stdin.once('data', () => resolve());
|
||||
});
|
||||
|
||||
await context.storageState({ path: AUTH_STATE_PATH });
|
||||
console.log(`Saved storage state to ${AUTH_STATE_PATH}`);
|
||||
await browser.close();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (process.env.PB_URL && process.env.PB_EMAIL && process.env.PB_PASSWORD) {
|
||||
await programmatic();
|
||||
process.exit(0);
|
||||
}
|
||||
await interactive();
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
50
video/src/config.ts
Normal file
50
video/src/config.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
export const APP_URL = process.env.APP_URL ?? 'http://host.docker.internal:3001';
|
||||
export const DASHBOARD_PATH = '/dashboard';
|
||||
|
||||
export const AUTH_STATE_PATH = 'auth.json';
|
||||
export const OUTPUT_DIR = 'output';
|
||||
|
||||
const aspect = process.env.ASPECT ?? '16x9';
|
||||
export const VIEWPORT =
|
||||
aspect === '9x16' ? { width: 1080, height: 1920 } : { width: 1920, height: 1080 };
|
||||
|
||||
export const PROMPT_TEXT =
|
||||
process.env.PROMPT_TEXT ?? 'Near Kings Cross, EPC C+, under £600k';
|
||||
|
||||
// Filter the AI stub will "return". Keys must match real feature names from
|
||||
// /api/features. Pulled from the running server's schema.
|
||||
export const STUBBED_FILTERS: Record<string, [number, number] | string[]> = {
|
||||
'Estimated current price': [0, 600000],
|
||||
'Number of bedrooms & living rooms': [4, 6],
|
||||
'Property type': ['Detached', 'Semi-Detached', 'Terraced'],
|
||||
'Distance to nearest train or tube station (km)': [0, 1.0],
|
||||
};
|
||||
|
||||
// Slider we'll drag in scene 3. Must be a numeric (range) feature, and must
|
||||
// already be in STUBBED_FILTERS so the card is mounted by the time we drag.
|
||||
export const DRAG_FILTER_NAME =
|
||||
process.env.DRAG_FILTER_NAME ?? 'Estimated current price';
|
||||
// Fraction of the track to drag the right thumb to (0..1 from the left).
|
||||
export const DRAG_TO_FRACTION = 0.55;
|
||||
|
||||
// London-ish view used for the cold open.
|
||||
export const COLD_OPEN_VIEW = '#lat=51.535&lon=-0.105&zoom=11';
|
||||
|
||||
// Hard cap on the trimmed output. Scene-time overhead (CDP roundtrips,
|
||||
// boundingBox calls, layout settling) varies run-to-run, so we trim to a
|
||||
// deterministic length even if total scene wall time exceeds it.
|
||||
export const MAX_DURATION_S = Number(process.env.MAX_DURATION_S ?? 15);
|
||||
|
||||
// Slow down all interactions and animations by this factor while recording,
|
||||
// then speed the output back up by the same factor in ffmpeg. The visible
|
||||
// animation speed in the final video is unchanged, but each visual frame had
|
||||
// N× more wall time to render → fewer dropped frames, smoother motion.
|
||||
//
|
||||
// 1 = no slow-down (choppy on software GL)
|
||||
// 2 = double recording length, ~2× more unique frames in output (recommended)
|
||||
// 3-4 = even smoother, slower to produce; diminishing returns past 4
|
||||
export const RECORD_SCALE = Math.max(1, Number(process.env.RECORD_SCALE ?? 3));
|
||||
|
||||
// Target fps of the FINAL output. We force ffmpeg to interpolate up to this
|
||||
// rate so the speed-up doesn't leave gaps.
|
||||
export const OUTPUT_FPS = Number(process.env.OUTPUT_FPS ?? 60);
|
||||
201
video/src/dom.ts
Normal file
201
video/src/dom.ts
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
import type { Page } from 'playwright';
|
||||
|
||||
/**
|
||||
* Inject a visible cursor that mirrors the real mouse position. The browser's
|
||||
* native cursor is hidden so what the viewer sees is entirely our element.
|
||||
*
|
||||
* Design choice: the cursor listens to mousemove rather than being driven from
|
||||
* the Node side. That keeps a single source of truth — Playwright's real mouse
|
||||
* — and the visual is pure CSS, animated by the browser's compositor.
|
||||
*/
|
||||
export async function installCursor(page: Page): Promise<void> {
|
||||
await page.addStyleTag({
|
||||
content: `
|
||||
*, *::before, *::after { cursor: none !important; }
|
||||
|
||||
#__demo-cursor {
|
||||
position: fixed;
|
||||
top: 0; left: 0;
|
||||
width: 22px; height: 22px;
|
||||
pointer-events: none;
|
||||
z-index: 2147483646;
|
||||
transform: translate(-2px, -2px);
|
||||
transition: transform 60ms linear, scale 120ms ease-out;
|
||||
will-change: transform;
|
||||
}
|
||||
#__demo-cursor svg {
|
||||
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.35));
|
||||
}
|
||||
#__demo-cursor.click { scale: 0.85; }
|
||||
|
||||
.__demo-ripple {
|
||||
position: fixed;
|
||||
pointer-events: none;
|
||||
z-index: 2147483645;
|
||||
width: 0; height: 0;
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(20, 184, 166, 0.9);
|
||||
background: rgba(20, 184, 166, 0.18);
|
||||
transform: translate(-50%, -50%);
|
||||
animation: __demo-ripple 600ms ease-out forwards;
|
||||
}
|
||||
@keyframes __demo-ripple {
|
||||
0% { width: 0; height: 0; opacity: 1; }
|
||||
100% { width: 64px; height: 64px; opacity: 0; }
|
||||
}
|
||||
|
||||
#__demo-vignette {
|
||||
position: fixed; inset: 0;
|
||||
pointer-events: none;
|
||||
background: radial-gradient(circle at center, transparent 40%, rgba(0,0,0,0.55) 100%);
|
||||
z-index: 2147483640;
|
||||
opacity: 1;
|
||||
transition: opacity 1000ms ease-out;
|
||||
}
|
||||
#__demo-vignette.gone { opacity: 0; }
|
||||
|
||||
#__demo-caption {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: 7%;
|
||||
transform: translate(-50%, 24px);
|
||||
padding: 14px 22px;
|
||||
border-radius: 999px;
|
||||
background: rgba(15, 23, 42, 0.78);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
color: #f0fdfa;
|
||||
font: 500 22px/1.2 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
letter-spacing: 0.01em;
|
||||
box-shadow: 0 14px 40px rgba(0,0,0,0.35), inset 0 0 0 1px rgba(255,255,255,0.08);
|
||||
z-index: 2147483641;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 320ms ease-out, transform 320ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
white-space: nowrap;
|
||||
}
|
||||
#__demo-caption.visible { opacity: 1; transform: translate(-50%, 0); }
|
||||
|
||||
#__demo-outro {
|
||||
position: fixed; inset: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: rgba(2, 6, 23, 0);
|
||||
z-index: 2147483642;
|
||||
pointer-events: none;
|
||||
transition: background 700ms ease-out;
|
||||
}
|
||||
#__demo-outro.visible { background: rgba(2, 6, 23, 0.78); backdrop-filter: blur(8px); }
|
||||
#__demo-outro .card {
|
||||
text-align: center;
|
||||
color: white;
|
||||
opacity: 0;
|
||||
transform: translateY(12px) scale(0.98);
|
||||
transition: opacity 700ms ease-out 120ms, transform 700ms cubic-bezier(0.22,1,0.36,1) 120ms;
|
||||
}
|
||||
#__demo-outro.visible .card { opacity: 1; transform: translateY(0) scale(1); }
|
||||
#__demo-outro h1 {
|
||||
font: 700 64px/1.05 ui-sans-serif, system-ui, sans-serif;
|
||||
margin: 0 0 12px;
|
||||
background: linear-gradient(90deg, #5eead4, #14b8a6);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
#__demo-outro p { font: 400 24px/1.4 ui-sans-serif, system-ui, sans-serif; color: #cbd5e1; margin: 0 0 18px; }
|
||||
#__demo-outro .url { font: 600 22px/1 ui-sans-serif, system-ui, sans-serif; color: #5eead4; }
|
||||
`,
|
||||
});
|
||||
|
||||
await page.evaluate(() => {
|
||||
const cursor = document.createElement('div');
|
||||
cursor.id = '__demo-cursor';
|
||||
cursor.innerHTML = `
|
||||
<svg viewBox="0 0 22 22" width="22" height="22" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M2 2 L2 18 L7 13 L10 20 L13 19 L10 12 L17 12 Z"
|
||||
fill="white" stroke="#0f172a" stroke-width="1.4" stroke-linejoin="round"/>
|
||||
</svg>`;
|
||||
document.body.appendChild(cursor);
|
||||
|
||||
const vignette = document.createElement('div');
|
||||
vignette.id = '__demo-vignette';
|
||||
document.body.appendChild(vignette);
|
||||
|
||||
const caption = document.createElement('div');
|
||||
caption.id = '__demo-caption';
|
||||
document.body.appendChild(caption);
|
||||
|
||||
window.addEventListener(
|
||||
'mousemove',
|
||||
(e) => {
|
||||
cursor.style.transform = `translate(${e.clientX - 2}px, ${e.clientY - 2}px)`;
|
||||
},
|
||||
{ passive: true, capture: true }
|
||||
);
|
||||
|
||||
window.addEventListener(
|
||||
'mousedown',
|
||||
(e) => {
|
||||
cursor.classList.add('click');
|
||||
const r = document.createElement('div');
|
||||
r.className = '__demo-ripple';
|
||||
r.style.left = `${e.clientX}px`;
|
||||
r.style.top = `${e.clientY}px`;
|
||||
document.body.appendChild(r);
|
||||
setTimeout(() => r.remove(), 650);
|
||||
},
|
||||
{ passive: true, capture: true }
|
||||
);
|
||||
window.addEventListener(
|
||||
'mouseup',
|
||||
() => cursor.classList.remove('click'),
|
||||
{ passive: true, capture: true }
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export async function clearVignette(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
document.getElementById('__demo-vignette')?.classList.add('gone');
|
||||
});
|
||||
}
|
||||
|
||||
export async function showCaption(page: Page, text: string): Promise<void> {
|
||||
await page.evaluate((t) => {
|
||||
const el = document.getElementById('__demo-caption');
|
||||
if (!el) return;
|
||||
el.textContent = t;
|
||||
el.classList.add('visible');
|
||||
}, text);
|
||||
}
|
||||
|
||||
export async function hideCaption(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
document.getElementById('__demo-caption')?.classList.remove('visible');
|
||||
});
|
||||
}
|
||||
|
||||
export async function showOutro(
|
||||
page: Page,
|
||||
brand: string,
|
||||
tagline: string,
|
||||
url: string
|
||||
): Promise<void> {
|
||||
await page.evaluate(
|
||||
({ brand, tagline, url }) => {
|
||||
const el = document.createElement('div');
|
||||
el.id = '__demo-outro';
|
||||
el.innerHTML = `
|
||||
<div class="card">
|
||||
<h1>${brand}</h1>
|
||||
<p>${tagline}</p>
|
||||
<div class="url">${url}</div>
|
||||
</div>`;
|
||||
document.body.appendChild(el);
|
||||
// Force reflow so the transition fires.
|
||||
void el.offsetHeight;
|
||||
el.classList.add('visible');
|
||||
},
|
||||
{ brand, tagline, url }
|
||||
);
|
||||
}
|
||||
128
video/src/motion.ts
Normal file
128
video/src/motion.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import type { Page } from 'playwright';
|
||||
import { RECORD_SCALE } from './config.js';
|
||||
|
||||
// All timing primitives multiply by RECORD_SCALE. Scenes call them with
|
||||
// "human-time" durations; the actual wall-clock pause is N× longer so the
|
||||
// renderer has more time per visual frame. ffmpeg later speeds the output
|
||||
// back up, so the *visible* animation speed in the final video is unchanged.
|
||||
export const sleep = (ms: number) =>
|
||||
new Promise<void>((r) => setTimeout(r, ms * RECORD_SCALE));
|
||||
|
||||
// Cubic ease-in-out: slow start and end, fast middle. Reads as "natural" motion.
|
||||
export const easeInOut = (t: number): number =>
|
||||
t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
|
||||
|
||||
// Slight overshoot then settle — gives clicks a tactile feel when paired with ripple.
|
||||
export const easeOutBack = (t: number): number => {
|
||||
const c1 = 1.70158;
|
||||
const c3 = c1 + 1;
|
||||
return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);
|
||||
};
|
||||
|
||||
interface MoveOptions {
|
||||
durationMs?: number;
|
||||
ease?: (t: number) => number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the real mouse from its current position to (x, y) along an eased path.
|
||||
* The injected cursor follows via its mousemove listener — no explicit visual sync needed.
|
||||
*/
|
||||
export async function smoothMove(
|
||||
page: Page,
|
||||
from: { x: number; y: number },
|
||||
to: { x: number; y: number },
|
||||
{ durationMs = 600, ease = easeInOut }: MoveOptions = {}
|
||||
): Promise<void> {
|
||||
// Step count scales with RECORD_SCALE so we get more cursor positions per
|
||||
// unit of visible animation — each one is a chance for the renderer to
|
||||
// sample. CDP roundtrips cap us at ~60 commands/s, so 60fps × RECORD_SCALE
|
||||
// is the practical ceiling.
|
||||
const fps = 60;
|
||||
const wallDuration = durationMs * RECORD_SCALE;
|
||||
const steps = Math.max(2, Math.round((wallDuration / 1000) * fps));
|
||||
const stepWaitMs = wallDuration / steps;
|
||||
for (let i = 1; i <= steps; i++) {
|
||||
const t = ease(i / steps);
|
||||
const x = from.x + (to.x - from.x) * t;
|
||||
const y = from.y + (to.y - from.y) * t;
|
||||
await page.mouse.move(x, y);
|
||||
// Use a non-scaling sleep here — we already factored RECORD_SCALE in.
|
||||
await new Promise((r) => setTimeout(r, stepWaitMs));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* "Fake" type: progressively set the textarea value from inside the browser,
|
||||
* dispatching React-compatible input events. Looks identical to keyboard.type
|
||||
* but runs in one CDP roundtrip instead of N (where N = char count). On a
|
||||
* 37-char prompt this is ~1s instead of ~3s.
|
||||
*/
|
||||
export async function fakeType(
|
||||
page: Page,
|
||||
selector: string,
|
||||
text: string,
|
||||
delayMs: number
|
||||
): Promise<void> {
|
||||
// Scale browser-side typing by RECORD_SCALE too, so the typing animation
|
||||
// has more wall time per character to render.
|
||||
const scaledDelay = delayMs * RECORD_SCALE;
|
||||
await page.evaluate(
|
||||
({ selector, text, delayMs }) => {
|
||||
const ta = document.querySelector(selector) as HTMLTextAreaElement | null;
|
||||
if (!ta) throw new Error('textarea not found: ' + selector);
|
||||
ta.focus();
|
||||
// React tracks the textarea value by hooking the descriptor; we have to
|
||||
// call the prototype setter directly so React sees the change.
|
||||
const proto = Object.getPrototypeOf(ta);
|
||||
const setValue = Object.getOwnPropertyDescriptor(proto, 'value')?.set;
|
||||
if (!setValue) throw new Error('no value setter on textarea');
|
||||
return new Promise<void>((resolve) => {
|
||||
let i = 0;
|
||||
const id = window.setInterval(() => {
|
||||
i += 1;
|
||||
setValue.call(ta, text.slice(0, i));
|
||||
ta.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
if (i >= text.length) {
|
||||
window.clearInterval(id);
|
||||
resolve();
|
||||
}
|
||||
}, delayMs);
|
||||
});
|
||||
},
|
||||
{ selector, text, delayMs: scaledDelay }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drag the right-hand thumb of a Radix slider to a target track fraction.
|
||||
* Returns the final cursor position so callers can chain a smoothMove afterwards.
|
||||
*/
|
||||
export async function smoothDragSliderThumb(
|
||||
page: Page,
|
||||
thumbSelector: string,
|
||||
trackSelector: string,
|
||||
fromCursor: { x: number; y: number },
|
||||
toFraction: number,
|
||||
durationMs = 1100
|
||||
): Promise<{ x: number; y: number }> {
|
||||
const thumbBox = await page.locator(thumbSelector).boundingBox();
|
||||
const trackBox = await page.locator(trackSelector).boundingBox();
|
||||
if (!thumbBox || !trackBox) throw new Error('slider not found');
|
||||
|
||||
const thumbCx = thumbBox.x + thumbBox.width / 2;
|
||||
const thumbCy = thumbBox.y + thumbBox.height / 2;
|
||||
const targetX = trackBox.x + trackBox.width * toFraction;
|
||||
|
||||
// smoothMove already applies RECORD_SCALE internally; pass human-time durations.
|
||||
await smoothMove(page, fromCursor, { x: thumbCx, y: thumbCy }, { durationMs: 500 });
|
||||
await page.mouse.down();
|
||||
await smoothMove(
|
||||
page,
|
||||
{ x: thumbCx, y: thumbCy },
|
||||
{ x: targetX, y: thumbCy },
|
||||
{ durationMs }
|
||||
);
|
||||
await page.mouse.up();
|
||||
return { x: targetX, y: thumbCy };
|
||||
}
|
||||
93
video/src/probe.ts
Normal file
93
video/src/probe.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import { chromium } from 'playwright';
|
||||
import { APP_URL, AUTH_STATE_PATH, DASHBOARD_PATH, VIEWPORT } from './config.js';
|
||||
|
||||
async function main() {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({
|
||||
storageState: AUTH_STATE_PATH,
|
||||
viewport: VIEWPORT,
|
||||
});
|
||||
const page = await context.newPage();
|
||||
page.on('request', (r) => {
|
||||
if (r.url().includes('auth-refresh')) {
|
||||
console.log('REQ', r.method(), r.url(), 'headers:', JSON.stringify(r.headers()));
|
||||
}
|
||||
});
|
||||
page.on('response', async (r) => {
|
||||
if (r.url().includes('auth-refresh')) {
|
||||
const body = await r.text().catch(() => '');
|
||||
console.log('RES', r.status(), r.url(), 'body:', body.slice(0, 200));
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto(`${APP_URL}${DASHBOARD_PATH}`, { waitUntil: 'networkidle' });
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
|
||||
await page.screenshot({ path: 'output/probe-1-initial.png', fullPage: false });
|
||||
|
||||
const ls = await page.evaluate(() => {
|
||||
const v = localStorage.getItem('pocketbase_auth');
|
||||
let parsed: unknown = null;
|
||||
try { parsed = v ? JSON.parse(v) : null; } catch {}
|
||||
return {
|
||||
raw: v?.slice(0, 80),
|
||||
hasToken: !!(parsed as { token?: string })?.token,
|
||||
hasRecord: !!(parsed as { record?: unknown })?.record,
|
||||
hasModel: !!(parsed as { model?: unknown })?.model,
|
||||
};
|
||||
});
|
||||
console.log('pb_auth localStorage:', ls);
|
||||
|
||||
const refreshTest = await page.evaluate(async () => {
|
||||
const stored = JSON.parse(localStorage.getItem('pb_auth') ?? '{}');
|
||||
const r = await fetch('/pb/api/collections/users/auth-refresh', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: stored.token },
|
||||
});
|
||||
return { status: r.status, body: (await r.text()).slice(0, 200) };
|
||||
});
|
||||
console.log('refresh test:', refreshTest);
|
||||
|
||||
// Try the SDK path directly. Reach into the Vite-served module graph.
|
||||
const sdkRefresh = await page.evaluate(async () => {
|
||||
type W = Window & { pb?: { collection: (n: string) => { authRefresh: () => Promise<unknown> }, authStore: { isValid: boolean, token: string, record: unknown } } };
|
||||
const w = window as W;
|
||||
if (!w.pb) return { error: 'window.pb not exposed' };
|
||||
const before = { isValid: w.pb.authStore.isValid, hasToken: !!w.pb.authStore.token };
|
||||
try {
|
||||
const out = await w.pb.collection('users').authRefresh();
|
||||
return { before, ok: true, out };
|
||||
} catch (e) {
|
||||
return { before, ok: false, error: String(e) };
|
||||
}
|
||||
});
|
||||
console.log('SDK refresh:', sdkRefresh);
|
||||
|
||||
const aiCount = await page.locator('[data-tutorial="ai-filters"]').count();
|
||||
const aiVisible = await page.locator('[data-tutorial="ai-filters"]').first().isVisible().catch(() => false);
|
||||
const aiBtnCount = await page.locator('[data-tutorial="ai-filters"] button').count();
|
||||
const filterCount = await page.locator('[data-filter-name]').count();
|
||||
const filterNames = await page.locator('[data-filter-name]').evaluateAll((els) =>
|
||||
els.map((e) => e.getAttribute('data-filter-name'))
|
||||
);
|
||||
const tutorialOverlay = await page.locator('[role="dialog"], [data-tutorial-step], .tutorial-overlay').count();
|
||||
|
||||
console.log({ aiCount, aiVisible, aiBtnCount, filterCount, filterNames, tutorialOverlay });
|
||||
|
||||
// Try clicking the AI button and check whether textarea appears
|
||||
const aiBtn = page.locator('[data-tutorial="ai-filters"] button').first();
|
||||
if (await aiBtn.isVisible()) {
|
||||
await aiBtn.click();
|
||||
await new Promise((r) => setTimeout(r, 600));
|
||||
const taCount = await page.locator('[data-tutorial="ai-filters"] textarea').count();
|
||||
console.log({ afterClick_taCount: taCount });
|
||||
await page.screenshot({ path: 'output/probe-2-after-click.png' });
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
220
video/src/record.ts
Normal file
220
video/src/record.ts
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
import { chromium } from 'playwright';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { existsSync, mkdirSync, renameSync, readdirSync, statSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
APP_URL,
|
||||
AUTH_STATE_PATH,
|
||||
COLD_OPEN_VIEW,
|
||||
DASHBOARD_PATH,
|
||||
MAX_DURATION_S,
|
||||
OUTPUT_DIR,
|
||||
OUTPUT_FPS,
|
||||
RECORD_SCALE,
|
||||
STUBBED_FILTERS,
|
||||
VIEWPORT,
|
||||
} from './config.js';
|
||||
import { installCursor } from './dom.js';
|
||||
import {
|
||||
sceneAiPrompt,
|
||||
sceneColdOpen,
|
||||
sceneOutro,
|
||||
scenePropertyReveal,
|
||||
sceneSliderControl,
|
||||
type SceneCtx,
|
||||
} from './scenes.js';
|
||||
import { sleep } from './motion.js';
|
||||
|
||||
/**
|
||||
* Stub the AI endpoint. The real backend calls Gemini and takes 2–5s; for a
|
||||
* 15-second video we want sub-second response so the map reacts crisply with
|
||||
* the typed prompt still on screen. Returning canned filters also makes every
|
||||
* recording bit-identical.
|
||||
*/
|
||||
async function stubAiFilters(page: import('playwright').Page) {
|
||||
await page.route('**/api/ai-filters', async (route) => {
|
||||
// Small delay so the loading indicator is visible (looks like real AI work).
|
||||
await new Promise((r) => setTimeout(r, 400));
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
filters: STUBBED_FILTERS,
|
||||
travel_time_filters: [],
|
||||
notes: '',
|
||||
match_count: 1247,
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (!existsSync(AUTH_STATE_PATH)) {
|
||||
console.error(
|
||||
`No ${AUTH_STATE_PATH} found. Run "npm run setup-auth" first to log in once.`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
if (!existsSync(OUTPUT_DIR)) mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
args: [
|
||||
'--disable-blink-features=AutomationControlled',
|
||||
// Headless Chromium otherwise loses the WebGL context mid-render when
|
||||
// deck.gl pushes large buffers; SwiftShader is software GL but stable.
|
||||
'--use-gl=angle',
|
||||
'--use-angle=swiftshader',
|
||||
'--enable-unsafe-swiftshader',
|
||||
'--ignore-gpu-blocklist',
|
||||
// Lift Chromium's animation/raster rate caps so RECORD_SCALE actually
|
||||
// gets us extra frames per second of wall time. Without these, Chromium
|
||||
// throttles offscreen rendering and the slow-down is wasted.
|
||||
'--disable-frame-rate-limit',
|
||||
'--disable-gpu-vsync',
|
||||
'--disable-features=CalculateNativeWinOcclusion,IntensiveWakeUpThrottling',
|
||||
'--disable-renderer-backgrounding',
|
||||
'--disable-background-timer-throttling',
|
||||
'--disable-backgrounding-occluded-windows',
|
||||
],
|
||||
});
|
||||
|
||||
const context = await browser.newContext({
|
||||
storageState: AUTH_STATE_PATH,
|
||||
viewport: VIEWPORT,
|
||||
deviceScaleFactor: 2,
|
||||
recordVideo: { dir: OUTPUT_DIR, size: VIEWPORT },
|
||||
});
|
||||
|
||||
// Vite's dev server pushes HMR updates over a "vite-hmr" WebSocket. If a
|
||||
// module isn't accept-marked the client triggers a FULL page reload — which
|
||||
// mid-recording resets the React tree and re-shows "Connecting to server…".
|
||||
// Disable the client-side HMR socket entirely.
|
||||
await context.addInitScript(() => {
|
||||
// Block the vite-hmr WebSocket so HMR push messages never arrive.
|
||||
const RealWS = window.WebSocket;
|
||||
window.WebSocket = new Proxy(RealWS, {
|
||||
construct(target, args) {
|
||||
const proto = (args[1] as string | string[] | undefined) ?? '';
|
||||
const protoStr = Array.isArray(proto) ? proto.join(',') : proto;
|
||||
if (protoStr.includes('vite-hmr')) {
|
||||
return Object.assign(Object.create(RealWS.prototype), {
|
||||
readyState: RealWS.CONNECTING,
|
||||
send() {}, close() {},
|
||||
addEventListener() {}, removeEventListener() {},
|
||||
dispatchEvent: () => true,
|
||||
});
|
||||
}
|
||||
return Reflect.construct(target, args);
|
||||
},
|
||||
});
|
||||
|
||||
// Belt-and-braces: even if an HMR push slips through (e.g. via a different
|
||||
// transport in a later Vite version), neutralize the full-reload fallback.
|
||||
const noop = () => {};
|
||||
Object.defineProperty(window.location, 'reload', { value: noop, configurable: true });
|
||||
});
|
||||
|
||||
const page = await context.newPage();
|
||||
// recordVideo starts the moment the page is created. We want the final clip
|
||||
// to begin at the cold-open scene, not include the navigation/settle phase.
|
||||
// Track when the recording started and when the scenes start, so we can
|
||||
// ffmpeg-trim post-hoc.
|
||||
const recordStartMs = Date.now();
|
||||
page.on('console', (m) => {
|
||||
if (m.type() === 'error' || m.type() === 'warning') {
|
||||
console.log(`[browser ${m.type()}] ${m.text()}`);
|
||||
}
|
||||
});
|
||||
page.on('response', (r) => {
|
||||
const u = r.url();
|
||||
if (r.status() === 401 || u.includes('ai-filters')) {
|
||||
console.log(`[net] ${r.status()} ${r.request().method()} ${u}`);
|
||||
}
|
||||
});
|
||||
page.on('request', (r) => {
|
||||
const u = r.url();
|
||||
if (u.includes('ai-filters')) console.log(`[req] ${r.method()} ${u}`);
|
||||
});
|
||||
await stubAiFilters(page);
|
||||
|
||||
const url = `${APP_URL}${DASHBOARD_PATH}${COLD_OPEN_VIEW}`;
|
||||
await page.goto(url, { waitUntil: 'networkidle' });
|
||||
|
||||
// Settle: deck.gl tiles, postcode aggregations, sidebar mount.
|
||||
await sleep(800);
|
||||
|
||||
await installCursor(page);
|
||||
|
||||
// Park cursor near top-left so its first move (to the AI box) is visible.
|
||||
const ctx: SceneCtx = { page, cursor: { x: 80, y: 90 } };
|
||||
await page.mouse.move(ctx.cursor.x, ctx.cursor.y);
|
||||
|
||||
const sceneStartMs = Date.now();
|
||||
await sceneColdOpen(ctx);
|
||||
await sceneAiPrompt(ctx);
|
||||
await sceneSliderControl(ctx);
|
||||
await scenePropertyReveal(ctx);
|
||||
await sceneOutro(ctx);
|
||||
const sceneEndMs = Date.now();
|
||||
|
||||
await page.close();
|
||||
await context.close();
|
||||
await browser.close();
|
||||
|
||||
// Playwright names recordings by guid; rename the most recent one.
|
||||
const files = readdirSync(OUTPUT_DIR)
|
||||
.filter((f) => f.endsWith('.webm') && f.startsWith('page@'))
|
||||
.map((f) => ({ f, t: statSync(join(OUTPUT_DIR, f)).mtimeMs }))
|
||||
.sort((a, b) => b.t - a.t);
|
||||
if (!files[0]) {
|
||||
console.error('no recorded webm found');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const rawPath = join(OUTPUT_DIR, files[0].f);
|
||||
const trimmedPath = join(OUTPUT_DIR, 'recording.webm');
|
||||
const sceneSpan = (sceneEndMs - sceneStartMs) / 1000;
|
||||
// The trim window is in *recording wall time*, which is RECORD_SCALE× the
|
||||
// visible duration. After ffmpeg setpts speeds it back up, the final clip
|
||||
// will be exactly MAX_DURATION_S seconds.
|
||||
const wallCap = MAX_DURATION_S * RECORD_SCALE;
|
||||
const trimEnd = (sceneEndMs - recordStartMs) / 1000;
|
||||
const wallDuration = Math.min(sceneSpan, wallCap);
|
||||
const trimStart = trimEnd - wallDuration;
|
||||
const finalDuration = wallDuration / RECORD_SCALE;
|
||||
if (sceneSpan > wallCap) {
|
||||
console.log(
|
||||
`Scene wall time was ${sceneSpan.toFixed(2)}s (cap ${wallCap.toFixed(2)}s at scale ${RECORD_SCALE}); trimming to last ${MAX_DURATION_S}s (anchored to outro).`
|
||||
);
|
||||
}
|
||||
|
||||
// Trim AND speed up AND interpolate to OUTPUT_FPS in one pass.
|
||||
// - -ss + -t: trim window in raw recording's wall time.
|
||||
// - setpts=PTS/RECORD_SCALE: speed up the clip back to "human time".
|
||||
// - minterpolate at fps=OUTPUT_FPS: synthesize intermediate frames so the
|
||||
// sped-up output runs smoothly at 60fps even if raw was 25fps.
|
||||
// - libvpx-vp9 with -deadline good gives a tight WebM that the encode step
|
||||
// can re-mux to MP4 quickly.
|
||||
execSync(
|
||||
`ffmpeg -y -ss ${trimStart.toFixed(3)} -i "${rawPath}" -t ${wallDuration.toFixed(3)} ` +
|
||||
`-vf "setpts=PTS/${RECORD_SCALE},minterpolate=fps=${OUTPUT_FPS}:mi_mode=mci:mc_mode=aobmc:me_mode=bidir:vsbmc=1" ` +
|
||||
`-r ${OUTPUT_FPS} ` +
|
||||
`-c:v libvpx-vp9 -b:v 6M -deadline good -cpu-used 2 ` +
|
||||
`"${trimmedPath}"`,
|
||||
{ stdio: 'inherit' }
|
||||
);
|
||||
// Drop the untrimmed file once we've extracted the scenes.
|
||||
try {
|
||||
statSync(rawPath) && renameSync(rawPath, rawPath + '.untrimmed');
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
console.log(`Wrote ${trimmedPath} (${finalDuration.toFixed(2)}s @ ${OUTPUT_FPS}fps, scale=${RECORD_SCALE})`);
|
||||
console.log('Run "npm run encode" to produce output/recording.mp4');
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
125
video/src/scenes.ts
Normal file
125
video/src/scenes.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import type { Page } from 'playwright';
|
||||
import {
|
||||
PROMPT_TEXT,
|
||||
DRAG_FILTER_NAME,
|
||||
DRAG_TO_FRACTION,
|
||||
} from './config.js';
|
||||
import {
|
||||
clearVignette,
|
||||
hideCaption,
|
||||
showCaption,
|
||||
showOutro,
|
||||
} from './dom.js';
|
||||
import { fakeType, sleep, smoothMove, smoothDragSliderThumb } from './motion.js';
|
||||
|
||||
export interface SceneCtx {
|
||||
page: Page;
|
||||
cursor: { x: number; y: number };
|
||||
}
|
||||
|
||||
/** Cold open. Vignette fades; cursor parks at a "natural" rest position. */
|
||||
export async function sceneColdOpen(ctx: SceneCtx): Promise<void> {
|
||||
await clearVignette(ctx.page);
|
||||
await ctx.page.mouse.move(ctx.cursor.x, ctx.cursor.y);
|
||||
await sleep(1100);
|
||||
}
|
||||
|
||||
/**
|
||||
* AI prompt scene: click the collapsed AI box, type the prompt, submit,
|
||||
* watch the (stubbed) response apply.
|
||||
*/
|
||||
export async function sceneAiPrompt(ctx: SceneCtx): Promise<void> {
|
||||
const { page } = ctx;
|
||||
|
||||
await showCaption(page, 'Describe the area you want.');
|
||||
|
||||
const aiButton = page.locator('[data-tutorial="ai-filters"] button').first();
|
||||
const btnBox = await aiButton.boundingBox();
|
||||
if (!btnBox) throw new Error('AI button not found');
|
||||
|
||||
const target = { x: btnBox.x + btnBox.width / 2, y: btnBox.y + btnBox.height / 2 };
|
||||
await smoothMove(page, ctx.cursor, target, { durationMs: 400 });
|
||||
ctx.cursor = target;
|
||||
|
||||
await page.mouse.click(target.x, target.y);
|
||||
|
||||
const textarea = page.locator('[data-tutorial="ai-filters"] textarea');
|
||||
await textarea.waitFor({ state: 'visible', timeout: 3000 });
|
||||
await sleep(120);
|
||||
|
||||
const taBox = await textarea.boundingBox();
|
||||
if (taBox) {
|
||||
const into = { x: taBox.x + 30, y: taBox.y + taBox.height / 2 };
|
||||
await smoothMove(page, ctx.cursor, into, { durationMs: 220 });
|
||||
ctx.cursor = into;
|
||||
}
|
||||
|
||||
// fakeType runs the typing animation inside the browser to avoid CDP
|
||||
// round-trip overhead per keystroke (which can quadruple total typing time).
|
||||
await fakeType(page, '[data-tutorial="ai-filters"] textarea', PROMPT_TEXT, 35);
|
||||
await sleep(180);
|
||||
|
||||
await page.keyboard.press('Enter');
|
||||
await sleep(700);
|
||||
await hideCaption(page);
|
||||
await sleep(150);
|
||||
}
|
||||
|
||||
/**
|
||||
* Slider scene: pan to a numeric filter's right thumb and drag it inward.
|
||||
* The whole point: the user sees the map react in real time to a human action,
|
||||
* driving home that AI sets a starting point but you stay in control.
|
||||
*/
|
||||
export async function sceneSliderControl(ctx: SceneCtx): Promise<void> {
|
||||
const { page } = ctx;
|
||||
await showCaption(page, 'You stay in control.');
|
||||
|
||||
const card = page.locator(`[data-filter-name="${DRAG_FILTER_NAME}"]`);
|
||||
await card.waitFor({ state: 'visible', timeout: 3000 });
|
||||
await card.scrollIntoViewIfNeeded();
|
||||
await sleep(120);
|
||||
|
||||
const thumbSelector = `[data-filter-name="${DRAG_FILTER_NAME}"] [role="slider"] >> nth=1`;
|
||||
const trackSelector = `[data-filter-name="${DRAG_FILTER_NAME}"] [data-orientation="horizontal"] >> nth=0`;
|
||||
|
||||
ctx.cursor = await smoothDragSliderThumb(
|
||||
page,
|
||||
thumbSelector,
|
||||
trackSelector,
|
||||
ctx.cursor,
|
||||
DRAG_TO_FRACTION,
|
||||
1100
|
||||
);
|
||||
|
||||
await sleep(550);
|
||||
await hideCaption(page);
|
||||
await sleep(150);
|
||||
}
|
||||
|
||||
/** Property reveal: click a postcode on the map to open the side pane with charts. */
|
||||
export async function scenePropertyReveal(ctx: SceneCtx): Promise<void> {
|
||||
const { page } = ctx;
|
||||
const viewport = page.viewportSize() ?? { width: 1920, height: 1080 };
|
||||
|
||||
const target = {
|
||||
x: 360 + (viewport.width - 360) * 0.55,
|
||||
y: viewport.height * 0.5,
|
||||
};
|
||||
|
||||
await smoothMove(page, ctx.cursor, target, { durationMs: 500 });
|
||||
ctx.cursor = target;
|
||||
|
||||
await page.mouse.click(target.x, target.y);
|
||||
await sleep(1300);
|
||||
}
|
||||
|
||||
/** Outro: full-screen logo card with brand + URL. */
|
||||
export async function sceneOutro(ctx: SceneCtx): Promise<void> {
|
||||
await showOutro(
|
||||
ctx.page,
|
||||
'Perfect Postcodes',
|
||||
'Find where you actually want to live.',
|
||||
'perfectpostcodes.com'
|
||||
);
|
||||
await sleep(1800);
|
||||
}
|
||||
17
video/tsconfig.json
Normal file
17
video/tsconfig.json
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "bundler",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": false,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue