Hacky demo changes
This commit is contained in:
parent
7cba369308
commit
ea7afd618c
39 changed files with 2041 additions and 745 deletions
|
|
@ -11,6 +11,7 @@
|
|||
"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 14 -preset fast -movflags +faststart output/recording.mp4",
|
||||
"verify-output": "tsc && node dist/verify.js",
|
||||
"render": "./render.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
|
|
|
|||
|
|
@ -23,20 +23,16 @@ PB_ADMIN_EMAIL="${PB_ADMIN_EMAIL:-admin@propertymap.local}"
|
|||
PB_ADMIN_PASSWORD="${PB_ADMIN_PASSWORD:-propertymap-dev-2024}"
|
||||
PB_EMAIL="${PB_EMAIL:-demo-video@local.test}"
|
||||
PB_PASSWORD="${PB_PASSWORD:-DemoVideoPass123!}"
|
||||
MAX_DURATION_S="${MAX_DURATION_S:-15}"
|
||||
RECORD_SCALE="${RECORD_SCALE:-2}" # 2x raw capture -> real 50fps after speed-up
|
||||
OUTPUT_FPS="${OUTPUT_FPS:-50}" # matches RECORD_SCALE=2 output cadence
|
||||
CAPTURE_SCALE="${CAPTURE_SCALE:-1.5}" # sharper than 1x without the 2x software-GL cost
|
||||
AUTH_TTL_HOURS="${AUTH_TTL_HOURS:-24}" # re-auth if auth.json older than this
|
||||
# Where the homepage <video> source lives. Vite copies frontend/public/* into
|
||||
# the built bundle, so updating this path is what makes the new clip appear
|
||||
# on the homepage. Override if the dashboard ever moves.
|
||||
PUBLISH_DIR="${PUBLISH_DIR:-../frontend/public/video}"
|
||||
# When in the *output* timeline (post-speedup) to grab the poster frame.
|
||||
# Right-pane inspection (~10s output) is the clearest paused-state preview:
|
||||
# Right-pane inspection (~16s output) is the clearest paused-state preview:
|
||||
# Manchester map, filters applied, right pane populated, larger narration
|
||||
# caption visible.
|
||||
POSTER_TIME_S="${POSTER_TIME_S:-8}"
|
||||
POSTER_TIME_S="${POSTER_TIME_S:-16}"
|
||||
|
||||
FRESH_AUTH="${FORCE_AUTH:-0}"
|
||||
DO_ENCODE=1
|
||||
|
|
@ -134,13 +130,12 @@ 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" CAPTURE_SCALE="$CAPTURE_SCALE" \
|
||||
RECORD_SCALE="$RECORD_SCALE" OUTPUT_FPS="$OUTPUT_FPS" \
|
||||
node dist/record.js
|
||||
APP_URL="$APP_URL" node dist/record.js
|
||||
|
||||
if [ ! -s output/recording.webm ]; then
|
||||
fail "recording.webm missing or empty"
|
||||
fi
|
||||
node dist/verify.js output/recording.webm
|
||||
|
||||
# -- encode -------------------------------------------------------------------
|
||||
if [ "$DO_ENCODE" = "1" ]; then
|
||||
|
|
@ -164,6 +159,8 @@ if [ "$DO_ENCODE" = "1" ]; then
|
|||
ffmpeg -y -loglevel warning -i output/recording.mp4 -ss "$POSTER_TIME_S" \
|
||||
-frames:v 1 -update 1 -q:v 2 \
|
||||
output/poster.jpg
|
||||
|
||||
node dist/verify.js output/recording.mp4 output/poster.jpg
|
||||
fi
|
||||
|
||||
# -- publish to homepage ------------------------------------------------------
|
||||
|
|
@ -177,6 +174,7 @@ if [ "$DO_ENCODE" = "1" ]; then
|
|||
say "Publishing to $PUBLISH_DIR"
|
||||
cp output/recording.mp4 "$PUBLISH_DIR/recording.mp4"
|
||||
cp output/poster.jpg "$PUBLISH_DIR/poster.jpg"
|
||||
node dist/verify.js "$PUBLISH_DIR/recording.mp4" "$PUBLISH_DIR/poster.jpg"
|
||||
fi
|
||||
|
||||
# -- report -------------------------------------------------------------------
|
||||
|
|
|
|||
123
video/src/browser.ts
Normal file
123
video/src/browser.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import { chromium, type Browser, type BrowserContext } from 'playwright';
|
||||
import { AUTH_STATE_PATH, CAPTURE_SCALE, OUTPUT_DIR, VIDEO_SIZE, VIEWPORT } from './config.js';
|
||||
|
||||
export interface RecordingBrowser {
|
||||
browser: Browser;
|
||||
context: BrowserContext;
|
||||
}
|
||||
|
||||
export async function launchRecordingBrowser(): Promise<RecordingBrowser> {
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
args: [
|
||||
'--disable-blink-features=AutomationControlled',
|
||||
'--use-gl=angle',
|
||||
'--use-angle=swiftshader',
|
||||
'--enable-unsafe-swiftshader',
|
||||
'--ignore-gpu-blocklist',
|
||||
'--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: CAPTURE_SCALE,
|
||||
recordVideo: { dir: OUTPUT_DIR, size: VIDEO_SIZE },
|
||||
});
|
||||
await suppressDevServerNoise(context);
|
||||
return { browser, context };
|
||||
}
|
||||
|
||||
async function suppressDevServerNoise(context: BrowserContext) {
|
||||
await context.addInitScript(() => {
|
||||
(window as typeof window & { __demoRecording?: boolean }).__demoRecording = true;
|
||||
|
||||
const RealWS = window.WebSocket;
|
||||
window.WebSocket = new Proxy(RealWS, {
|
||||
construct(target, args) {
|
||||
const url = String(args[0] ?? '');
|
||||
const proto = (args[1] as string | string[] | undefined) ?? '';
|
||||
const protoStr = Array.isArray(proto) ? proto.join(',') : proto;
|
||||
if (
|
||||
protoStr.includes('vite-hmr') ||
|
||||
protoStr.includes('webpack') ||
|
||||
url.includes('/ws') ||
|
||||
url.includes('sockjs-node')
|
||||
) {
|
||||
const fake = new EventTarget() as WebSocket;
|
||||
Object.defineProperties(fake, {
|
||||
readyState: { value: RealWS.CLOSED },
|
||||
url: { value: url },
|
||||
protocol: { value: '' },
|
||||
extensions: { value: '' },
|
||||
bufferedAmount: { value: 0 },
|
||||
binaryType: { value: 'blob', writable: true },
|
||||
});
|
||||
fake.send = () => {};
|
||||
fake.close = () => fake.dispatchEvent(new Event('close'));
|
||||
queueMicrotask(() => fake.dispatchEvent(new Event('close')));
|
||||
return fake;
|
||||
}
|
||||
return Reflect.construct(target, args);
|
||||
},
|
||||
});
|
||||
|
||||
Object.defineProperty(window.location, 'reload', {
|
||||
value: () => {},
|
||||
configurable: true,
|
||||
});
|
||||
window.addEventListener('error', (e) => e.stopImmediatePropagation(), true);
|
||||
window.addEventListener('unhandledrejection', (e) => e.stopImmediatePropagation(), true);
|
||||
|
||||
const styleEl = document.createElement('style');
|
||||
styleEl.textContent = `
|
||||
vite-error-overlay,
|
||||
wds-overlay,
|
||||
#webpack-dev-server-client-overlay,
|
||||
#webpack-dev-server-client-overlay-div,
|
||||
iframe[src*="webpack-dev-server"],
|
||||
iframe[id*="webpack"],
|
||||
[id*="webpack-dev-server-client"],
|
||||
[class*="error-overlay"],
|
||||
[class*="webpack-error"] {
|
||||
display: none !important;
|
||||
visibility: hidden !important;
|
||||
opacity: 0 !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
`;
|
||||
(document.head ?? document.documentElement).appendChild(styleEl);
|
||||
|
||||
const killOverlay = (node: Element) => {
|
||||
const tag = node.tagName?.toLowerCase();
|
||||
const id = (node as HTMLElement).id?.toLowerCase() ?? '';
|
||||
if (
|
||||
tag === 'vite-error-overlay' ||
|
||||
tag === 'wds-overlay' ||
|
||||
id.includes('webpack-dev-server-client') ||
|
||||
id.includes('webpack-error')
|
||||
) {
|
||||
(node as HTMLElement).remove();
|
||||
}
|
||||
};
|
||||
const obs = new MutationObserver((muts) => {
|
||||
for (const m of muts) {
|
||||
m.addedNodes.forEach((n) => {
|
||||
if (n.nodeType === 1) killOverlay(n as Element);
|
||||
});
|
||||
}
|
||||
});
|
||||
if (document.body) obs.observe(document.body, { childList: true, subtree: true });
|
||||
else {
|
||||
document.addEventListener('DOMContentLoaded', () =>
|
||||
obs.observe(document.body, { childList: true, subtree: true })
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -50,7 +50,6 @@ export const STUBBED_TRAVEL_TIME_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.
|
||||
export const TT_CARD_SELECTOR = '[data-filter-name="tt_0"]';
|
||||
export const TT_SLIDER_MIN = 0;
|
||||
export const TT_SLIDER_MAX = 120;
|
||||
export const TT_DRAG_FROM_MIN = 35; // matches AI stub max above
|
||||
export const TT_DRAG_TO_MIN = 20;
|
||||
|
|
@ -59,12 +58,6 @@ export const TT_DRAG_TO_MIN = 20;
|
|||
// 2.4 fills most of the viewport with the prompt card without blowing up text.
|
||||
export const AI_ZOOM_SCALE = Number(process.env.AI_ZOOM_SCALE ?? 2.4);
|
||||
|
||||
// Cluster scene: how many wheel ticks (deck.gl smooths each one) and the
|
||||
// per-tick delay. ~5 ticks at -120 each gets us +2 zoom levels.
|
||||
export const CLUSTER_ZOOM_TICKS = 5;
|
||||
export const CLUSTER_ZOOM_DELTA = -120;
|
||||
export const CLUSTER_ZOOM_TICK_MS = 90;
|
||||
|
||||
// Initial map view used while we navigate. The AI scene zooms in on the
|
||||
// sidebar so this only matters once we zoom out.
|
||||
export const INITIAL_MAP_VIEW = {
|
||||
|
|
@ -73,29 +66,17 @@ export const INITIAL_MAP_VIEW = {
|
|||
zoom: 11.5,
|
||||
};
|
||||
|
||||
// Postcode pre-selected on page load. The dashboard reads ?pc= and:
|
||||
// 1. fetches /api/postcode/{pc}
|
||||
// 2. mapFlyToRef → zoom 16 over the postcode
|
||||
// 3. handleLocationSearch → opens the right pane populated with that postcode
|
||||
// We use this to guarantee the right pane is open by the time the cluster
|
||||
// scene plays. The visual cursor click is then ceremonial — pane is real,
|
||||
// data is real, only the causation is staged.
|
||||
//
|
||||
// M44FZ is in Ancoats/Northern Quarter: central enough to read as Manchester,
|
||||
// and it still has matching properties after the commute is tightened to 20m.
|
||||
export const PRELOAD_POSTCODE = process.env.PRELOAD_POSTCODE ?? 'M44FZ';
|
||||
// Verification guard only. The renderer no longer uses this as an editing cap:
|
||||
// if the storyboard needs more than 15 seconds to avoid jumps, keep the frames.
|
||||
export const MAX_DURATION_S = Number(process.env.MAX_DURATION_S ?? 45);
|
||||
export const MIN_DURATION_S = Number(process.env.MIN_DURATION_S ?? 10);
|
||||
|
||||
// Hard cap on the trimmed output. Keep the homepage demo tight; the render
|
||||
// trims from the outro if a dev-server hiccup stretches a scene.
|
||||
export const MAX_DURATION_S = Number(process.env.MAX_DURATION_S ?? 15);
|
||||
// Slow down all interactions while recording, then speed the output back up in
|
||||
// ffmpeg. A higher scale makes rendering take longer, but gives the 25fps raw
|
||||
// recorder enough unique frames for a smooth 50fps final without shortcut cuts.
|
||||
export const RECORD_SCALE = Math.max(1, Number(process.env.RECORD_SCALE ?? 3.5));
|
||||
|
||||
// Slow down all interactions while recording, then speed the output back up
|
||||
// in ffmpeg. 2x gives a real 50fps final video from Playwright's 25fps raw
|
||||
// recorder without making the take painfully long.
|
||||
export const RECORD_SCALE = Math.max(1, Number(process.env.RECORD_SCALE ?? 2));
|
||||
|
||||
// Target fps of the FINAL output. With RECORD_SCALE=2 this matches the real
|
||||
// captured frame cadence, so the MP4 does not need synthetic interpolation.
|
||||
// Target fps of the FINAL output.
|
||||
export const OUTPUT_FPS = Number(process.env.OUTPUT_FPS ?? 50);
|
||||
|
||||
// Brand strings for the outro card.
|
||||
|
|
|
|||
194
video/src/dom.ts
194
video/src/dom.ts
|
|
@ -1,4 +1,5 @@
|
|||
import type { Page } from 'playwright';
|
||||
import { RECORD_SCALE } from './config.js';
|
||||
|
||||
/**
|
||||
* Inject a visible cursor that mirrors the real mouse position. The browser's
|
||||
|
|
@ -110,17 +111,19 @@ export async function installCursor(page: Page): Promise<void> {
|
|||
#__demo-outro-card {
|
||||
text-align: center;
|
||||
color: white;
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
opacity: 0;
|
||||
transform: translateY(12px) scale(0.985);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: block !important;
|
||||
visibility: visible !important;
|
||||
animation: __demo-outro-pop 520ms cubic-bezier(0.22,1,0.36,1) both;
|
||||
}
|
||||
#__demo-outro.visible #__demo-outro-card {
|
||||
animation: __demo-outro-pop 620ms cubic-bezier(0.22,1,0.36,1) both;
|
||||
}
|
||||
@keyframes __demo-outro-pop {
|
||||
0% { transform: translateY(10px) scale(0.985); }
|
||||
100% { transform: translateY(0) scale(1); }
|
||||
0% { opacity: 0; transform: translateY(12px) scale(0.985); }
|
||||
100% { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
#__demo-outro-brand {
|
||||
font: 760 72px/1.05 ui-sans-serif, system-ui, sans-serif;
|
||||
|
|
@ -177,6 +180,15 @@ export async function installCursor(page: Page): Promise<void> {
|
|||
},
|
||||
{ passive: true, capture: true }
|
||||
);
|
||||
(window as typeof window & {
|
||||
__demoMoveCursor?: (x: number, y: number, durationMs: number) => void;
|
||||
}).__demoMoveCursor = (x, y, durationMs) => {
|
||||
cursor.style.transition = `transform ${durationMs}ms cubic-bezier(0.22, 1, 0.36, 1), scale 120ms ease-out`;
|
||||
cursor.style.transform = `translate(${x - 2}px, ${y - 2}px)`;
|
||||
window.setTimeout(() => {
|
||||
cursor.style.transition = 'transform 60ms linear, scale 120ms ease-out';
|
||||
}, durationMs + 40);
|
||||
};
|
||||
|
||||
window.addEventListener(
|
||||
'mousedown',
|
||||
|
|
@ -236,6 +248,29 @@ export async function flashRect(
|
|||
}, rect);
|
||||
}
|
||||
|
||||
export async function visualClick(
|
||||
page: Page,
|
||||
point: { x: number; y: number },
|
||||
rippleColor = 'rgba(20, 184, 166, 0.9)'
|
||||
): Promise<void> {
|
||||
await page.evaluate(
|
||||
({ point, rippleColor }) => {
|
||||
const cursor = document.getElementById('__demo-cursor');
|
||||
cursor?.classList.add('click');
|
||||
window.setTimeout(() => cursor?.classList.remove('click'), 140);
|
||||
|
||||
const r = document.createElement('div');
|
||||
r.className = '__demo-ripple';
|
||||
r.style.left = `${point.x}px`;
|
||||
r.style.top = `${point.y}px`;
|
||||
r.style.borderColor = rippleColor;
|
||||
document.body.appendChild(r);
|
||||
window.setTimeout(() => r.remove(), 650);
|
||||
},
|
||||
{ point, rippleColor }
|
||||
);
|
||||
}
|
||||
|
||||
export async function showOutro(
|
||||
page: Page,
|
||||
brand: string,
|
||||
|
|
@ -247,7 +282,6 @@ export async function showOutro(
|
|||
document.getElementById('__demo-caption')?.classList.remove('visible');
|
||||
const el = document.createElement('div');
|
||||
el.id = '__demo-outro';
|
||||
el.className = 'visible';
|
||||
el.innerHTML = `
|
||||
<div id="__demo-outro-card">
|
||||
<div id="__demo-outro-brand">${brand}</div>
|
||||
|
|
@ -255,6 +289,9 @@ export async function showOutro(
|
|||
<div id="__demo-outro-url">${url}</div>
|
||||
</div>`;
|
||||
document.body.appendChild(el);
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => el.classList.add('visible'));
|
||||
});
|
||||
},
|
||||
{ brand, tagline, url }
|
||||
);
|
||||
|
|
@ -336,79 +373,30 @@ export async function zoomTo(
|
|||
opts: { scale: number; focusX: number; focusY: number; durationMs?: number }
|
||||
): Promise<void> {
|
||||
const { scale, focusX, focusY, durationMs = 1100 } = opts;
|
||||
const transitionMs = Math.round(durationMs * RECORD_SCALE);
|
||||
const viewport = page.viewportSize() ?? { width: 1920, height: 1080 };
|
||||
const dx = viewport.width / 2 - scale * focusX;
|
||||
const dy = viewport.height / 2 - scale * focusY;
|
||||
await page.evaluate(
|
||||
({ dx, dy, scale, durationMs }) => {
|
||||
({ dx, dy, scale, transitionMs }) => {
|
||||
const wrap = document.getElementById('__demo-zoom-wrap');
|
||||
if (!wrap) return;
|
||||
wrap.style.transition = `transform ${durationMs}ms cubic-bezier(0.22, 1, 0.36, 1)`;
|
||||
wrap.style.transition = `transform ${transitionMs}ms cubic-bezier(0.22, 1, 0.36, 1)`;
|
||||
wrap.style.transform = `translate(${dx}px, ${dy}px) scale(${scale})`;
|
||||
},
|
||||
{ dx, dy, scale, durationMs }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Zoom in such that the focus point STAYS where it is on screen — only the
|
||||
* surroundings expand outward. Use when the cursor is hovering over a target
|
||||
* we want to keep clickable: clicks at the focus point still map to the
|
||||
* same DOM/canvas pixel, both pre- and post-zoom, so deck.gl hit-tests work.
|
||||
*
|
||||
* Contrast with zoomTo, which translates the focus to viewport centre.
|
||||
*/
|
||||
export async function zoomAt(
|
||||
page: Page,
|
||||
opts: { scale: number; focusX: number; focusY: number; durationMs?: number }
|
||||
): Promise<void> {
|
||||
const { scale, focusX, focusY, durationMs = 1100 } = opts;
|
||||
await page.evaluate(
|
||||
({ scale, focusX, focusY, durationMs }) => {
|
||||
const wrap = document.getElementById('__demo-zoom-wrap');
|
||||
if (!wrap) return;
|
||||
wrap.style.transformOrigin = `${focusX}px ${focusY}px`;
|
||||
wrap.style.transition = `transform ${durationMs}ms cubic-bezier(0.22, 1, 0.36, 1)`;
|
||||
wrap.style.transform = `scale(${scale})`;
|
||||
},
|
||||
{ scale, focusX, focusY, durationMs }
|
||||
{ dx, dy, scale, transitionMs }
|
||||
);
|
||||
}
|
||||
|
||||
/** Animate the wrapper back to identity transform. */
|
||||
export async function zoomReset(page: Page, durationMs = 1100): Promise<void> {
|
||||
await page.evaluate((durationMs) => {
|
||||
const transitionMs = Math.round(durationMs * RECORD_SCALE);
|
||||
await page.evaluate((transitionMs) => {
|
||||
const wrap = document.getElementById('__demo-zoom-wrap');
|
||||
if (!wrap) return;
|
||||
wrap.style.transition = `transform ${durationMs}ms cubic-bezier(0.22, 1, 0.36, 1)`;
|
||||
wrap.style.transition = `transform ${transitionMs}ms cubic-bezier(0.22, 1, 0.36, 1)`;
|
||||
wrap.style.transform = `translate(0px, 0px) scale(1)`;
|
||||
}, durationMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Snap-set the wrapper transform with no transition. Use for the very first
|
||||
* frame so the recording opens already-zoomed instead of zooming in from 1.
|
||||
*/
|
||||
export async function zoomToInstant(
|
||||
page: Page,
|
||||
opts: { scale: number; focusX: number; focusY: number }
|
||||
): Promise<void> {
|
||||
const { scale, focusX, focusY } = opts;
|
||||
const viewport = page.viewportSize() ?? { width: 1920, height: 1080 };
|
||||
const dx = viewport.width / 2 - scale * focusX;
|
||||
const dy = viewport.height / 2 - scale * focusY;
|
||||
await page.evaluate(
|
||||
({ dx, dy, scale }) => {
|
||||
const wrap = document.getElementById('__demo-zoom-wrap');
|
||||
if (!wrap) return;
|
||||
wrap.style.transition = 'none';
|
||||
wrap.style.transform = `translate(${dx}px, ${dy}px) scale(${scale})`;
|
||||
// Force a reflow so the next assignment with a transition actually
|
||||
// animates instead of being collapsed with this one.
|
||||
void wrap.offsetHeight;
|
||||
},
|
||||
{ dx, dy, scale }
|
||||
);
|
||||
}, transitionMs);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -447,3 +435,83 @@ export async function scrollPaneTo(
|
|||
{ selector, top }
|
||||
);
|
||||
}
|
||||
|
||||
export async function waitForAnimationFrames(page: Page, frames = 3): Promise<void> {
|
||||
await page.evaluate(
|
||||
(frameCount) =>
|
||||
new Promise<void>((resolve) => {
|
||||
let seen = 0;
|
||||
const tick = () => {
|
||||
seen += 1;
|
||||
if (seen >= frameCount) resolve();
|
||||
else requestAnimationFrame(tick);
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
}),
|
||||
frames
|
||||
);
|
||||
}
|
||||
|
||||
export async function getDemoMapSettleVersion(page: Page): Promise<number> {
|
||||
return page.evaluate(
|
||||
() =>
|
||||
(
|
||||
window as typeof window & {
|
||||
__demoMapSettleVersion?: number;
|
||||
}
|
||||
).__demoMapSettleVersion ?? 0
|
||||
);
|
||||
}
|
||||
|
||||
export async function waitForDemoMapSettled(
|
||||
page: Page,
|
||||
timeoutMs = 12000,
|
||||
afterVersion = -1
|
||||
): Promise<void> {
|
||||
await page.waitForFunction(
|
||||
(version) => {
|
||||
const demo = window as typeof window & {
|
||||
__demoMapSettled?: boolean;
|
||||
__demoMapSettleVersion?: number;
|
||||
__demoMapIdle?: boolean;
|
||||
};
|
||||
return (
|
||||
demo.__demoMapSettled === true &&
|
||||
demo.__demoMapIdle === true &&
|
||||
(demo.__demoMapSettleVersion ?? 0) > version
|
||||
);
|
||||
},
|
||||
afterVersion,
|
||||
{ timeout: timeoutMs }
|
||||
);
|
||||
await waitForAnimationFrames(page, 4);
|
||||
}
|
||||
|
||||
export async function waitForCurrentDemoMapSettled(page: Page, timeoutMs = 12000): Promise<void> {
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const demo = window as typeof window & {
|
||||
__demoMapSettled?: boolean;
|
||||
__demoMapIdle?: boolean;
|
||||
};
|
||||
return demo.__demoMapSettled === true && demo.__demoMapIdle === true;
|
||||
},
|
||||
undefined,
|
||||
{ timeout: timeoutMs }
|
||||
);
|
||||
await waitForAnimationFrames(page, 4);
|
||||
}
|
||||
|
||||
export async function waitForDemoSelectionReady(page: Page, timeoutMs = 12000): Promise<void> {
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
(
|
||||
window as typeof window & {
|
||||
__demoSelectionReady?: boolean;
|
||||
}
|
||||
).__demoSelectionReady === true,
|
||||
undefined,
|
||||
{ timeout: timeoutMs }
|
||||
);
|
||||
await waitForAnimationFrames(page, 4);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,30 +12,22 @@ export const sleep = (ms: number) =>
|
|||
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;
|
||||
realMouse?: boolean;
|
||||
/**
|
||||
* Override the per-step CDP cost used to size the loop. Default 35ms is
|
||||
* right for free cursor moves. During a drag, every mouse.move fires a
|
||||
* pointermove → React re-render → thumb position update on the same
|
||||
* thread, pushing effective per-step cost to ~100ms. Pass that for drags
|
||||
* Override the per-step CDP cost used to size the loop. During a drag, every
|
||||
* mouse.move fires a pointermove -> React re-render -> thumb position update
|
||||
* on the same thread, pushing effective per-step cost higher. Pass that for drags
|
||||
* so the loop's wall duration matches `durationMs * RECORD_SCALE`.
|
||||
*/
|
||||
stepBudgetMs?: number;
|
||||
}
|
||||
|
||||
// Empirical Playwright→Chromium CDP roundtrip cost for a mouse.move command
|
||||
// while recording the 4K, software-GL dashboard. It is much higher than a
|
||||
// simple page because every move competes with map rendering and video capture.
|
||||
const CDP_MOVE_MS = 90;
|
||||
// Empirical Playwright-to-Chromium CDP roundtrip cost for a mouse.move command
|
||||
// while recording the software-GL dashboard.
|
||||
const CDP_MOVE_MS = 70;
|
||||
|
||||
/**
|
||||
* Move the real mouse from its current position to (x, y) along an eased path.
|
||||
|
|
@ -51,10 +43,36 @@ export async function smoothMove(
|
|||
page: Page,
|
||||
from: { x: number; y: number },
|
||||
to: { x: number; y: number },
|
||||
{ durationMs = 600, ease = easeInOut, stepBudgetMs = CDP_MOVE_MS }: MoveOptions = {}
|
||||
{
|
||||
durationMs = 600,
|
||||
ease = easeInOut,
|
||||
realMouse = false,
|
||||
stepBudgetMs = CDP_MOVE_MS,
|
||||
}: MoveOptions = {}
|
||||
): Promise<void> {
|
||||
const wallDuration = durationMs * RECORD_SCALE;
|
||||
const steps = Math.max(2, Math.min(28, Math.round(wallDuration / stepBudgetMs)));
|
||||
if (!realMouse) {
|
||||
const animated = await page.evaluate(
|
||||
({ x, y, wallDuration }) => {
|
||||
const move = (
|
||||
window as typeof window & {
|
||||
__demoMoveCursor?: (x: number, y: number, durationMs: number) => void;
|
||||
}
|
||||
).__demoMoveCursor;
|
||||
if (!move) return false;
|
||||
move(x, y, wallDuration);
|
||||
return true;
|
||||
},
|
||||
{ x: to.x, y: to.y, wallDuration }
|
||||
);
|
||||
if (animated) {
|
||||
await new Promise((resolve) => setTimeout(resolve, wallDuration));
|
||||
await page.mouse.move(to.x, to.y);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const steps = Math.max(2, Math.min(96, Math.round(wallDuration / stepBudgetMs)));
|
||||
for (let i = 1; i <= steps; i++) {
|
||||
const t = ease(i / steps);
|
||||
const x = from.x + (to.x - from.x) * t;
|
||||
|
|
@ -76,7 +94,7 @@ export async function fakeType(
|
|||
delayMs: number
|
||||
): Promise<void> {
|
||||
const delay = delayMs * RECORD_SCALE;
|
||||
const steps = Math.min(6, text.length);
|
||||
const steps = text.length;
|
||||
for (let i = 1; i <= steps; i++) {
|
||||
const end = Math.ceil((text.length * i) / steps);
|
||||
await page.evaluate(
|
||||
|
|
@ -121,14 +139,13 @@ export async function smoothDragSliderThumb(
|
|||
// smoothMove already applies RECORD_SCALE internally; pass human-time durations.
|
||||
await smoothMove(page, fromCursor, { x: thumbCx, y: thumbCy }, { durationMs: 220 });
|
||||
await page.mouse.down();
|
||||
// Keep the drag to a few pointer updates. The map will redraw after commit;
|
||||
// asking React/deck.gl for dozens of intermediate travel-time states is what
|
||||
// made previous renders crawl and look stuttery.
|
||||
// The user explicitly prefers a longer render over stepped motion, so use
|
||||
// enough real pointer updates for the thumb itself to read as continuous.
|
||||
await smoothMove(
|
||||
page,
|
||||
{ x: thumbCx, y: thumbCy },
|
||||
{ x: targetX, y: thumbCy },
|
||||
{ durationMs, stepBudgetMs: 360 }
|
||||
{ durationMs, realMouse: true, stepBudgetMs: 135 }
|
||||
);
|
||||
await page.mouse.up();
|
||||
return { x: targetX, y: thumbCy };
|
||||
|
|
|
|||
|
|
@ -1,241 +1,23 @@
|
|||
import { chromium } from 'playwright';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { existsSync, mkdirSync, renameSync, statSync } from 'node:fs';
|
||||
import { existsSync, mkdirSync, statSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
APP_URL,
|
||||
AUTH_STATE_PATH,
|
||||
CAPTURE_SCALE,
|
||||
DASHBOARD_PATH,
|
||||
INITIAL_MAP_VIEW,
|
||||
MAX_DURATION_S,
|
||||
OUTPUT_FPS,
|
||||
OUTPUT_DIR,
|
||||
PRELOAD_POSTCODE,
|
||||
RECORD_SCALE,
|
||||
STUBBED_FILTERS,
|
||||
STUBBED_TRAVEL_TIME_FILTERS,
|
||||
VIDEO_SIZE,
|
||||
VIEWPORT,
|
||||
WEBM_BITRATE,
|
||||
} from './config.js';
|
||||
import { installCursor, installZoomWrapper } from './dom.js';
|
||||
import {
|
||||
preZoomToAiBox,
|
||||
sceneAiCloseUp,
|
||||
sceneClusterClick,
|
||||
sceneExportAndOutro,
|
||||
sceneTravelTimeSlider,
|
||||
sceneZoomOutResults,
|
||||
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.
|
||||
*
|
||||
* The shape MUST match what useAiFilters expects (filters, travel_time_filters,
|
||||
* notes, match_count) — see frontend/src/hooks/useAiFilters.ts.
|
||||
*/
|
||||
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, 180));
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
filters: STUBBED_FILTERS,
|
||||
travel_time_filters: STUBBED_TRAVEL_TIME_FILTERS,
|
||||
notes: '',
|
||||
match_count: 1247,
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function stubExport(page: import('playwright').Page) {
|
||||
await page.route('**/api/export?**', async (route) => {
|
||||
await new Promise((r) => setTimeout(r, 160));
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
headers: {
|
||||
'content-disposition': 'attachment; filename="perfect-postcode-export.xlsx"',
|
||||
},
|
||||
body: Buffer.from('Perfect Postcode demo export\n'),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function addInitialTravelTimeParams(params: URLSearchParams) {
|
||||
for (const tt of STUBBED_TRAVEL_TIME_FILTERS) {
|
||||
params.append('tt', `${tt.mode}:${tt.slug}:${tt.label}:${tt.min ?? 0}:${tt.max ?? 120}`);
|
||||
}
|
||||
}
|
||||
import { AUTH_STATE_PATH, OUTPUT_DIR } from './config.js';
|
||||
import { launchRecordingBrowser } from './browser.js';
|
||||
import { installDemoRoutes } from './routes.js';
|
||||
import { prepareTimeline, runTimeline } from './timeline.js';
|
||||
import { trimRecording } from './video.js';
|
||||
|
||||
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.`
|
||||
);
|
||||
console.error(`No ${AUTH_STATE_PATH} found. Run "npm run setup-auth" first.`);
|
||||
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: CAPTURE_SCALE,
|
||||
recordVideo: { dir: OUTPUT_DIR, size: VIDEO_SIZE },
|
||||
});
|
||||
|
||||
// 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 url = String(args[0] ?? '');
|
||||
const proto = (args[1] as string | string[] | undefined) ?? '';
|
||||
const protoStr = Array.isArray(proto) ? proto.join(',') : proto;
|
||||
if (
|
||||
protoStr.includes('vite-hmr') ||
|
||||
protoStr.includes('webpack') ||
|
||||
url.includes('/ws') ||
|
||||
url.includes('sockjs-node')
|
||||
) {
|
||||
const fake = new EventTarget() as WebSocket;
|
||||
Object.defineProperties(fake, {
|
||||
readyState: { value: RealWS.CLOSED },
|
||||
url: { value: url },
|
||||
protocol: { value: '' },
|
||||
extensions: { value: '' },
|
||||
bufferedAmount: { value: 0 },
|
||||
binaryType: { value: 'blob', writable: true },
|
||||
});
|
||||
fake.send = () => {};
|
||||
fake.close = () => {
|
||||
fake.dispatchEvent(new Event('close'));
|
||||
};
|
||||
queueMicrotask(() => fake.dispatchEvent(new Event('close')));
|
||||
return fake;
|
||||
}
|
||||
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 });
|
||||
|
||||
// Stop runtime errors from reaching Vite's <vite-error-overlay>. We're
|
||||
// recording against a dev server for fast iteration; in prod the overlay
|
||||
// wouldn't exist either way. A stray deck.gl layer-pipeline error covering
|
||||
// the dashboard ruins the take, so we eat the error before Vite can see it.
|
||||
// capture=true → our handler runs before Vite's at the document level.
|
||||
window.addEventListener(
|
||||
'error',
|
||||
(e) => {
|
||||
e.stopImmediatePropagation();
|
||||
},
|
||||
true
|
||||
);
|
||||
window.addEventListener(
|
||||
'unhandledrejection',
|
||||
(e) => {
|
||||
e.stopImmediatePropagation();
|
||||
},
|
||||
true
|
||||
);
|
||||
// CSS fallback covering all the bundlers' diagnostic overlays. Vite
|
||||
// ships <vite-error-overlay>, webpack v5+ uses <wds-overlay> (shadow DOM)
|
||||
// or <div id="webpack-dev-server-client-overlay">, webpack v4 injects an
|
||||
// iframe. Compilation warnings surface as a top-level red banner that
|
||||
// occludes the dashboard.
|
||||
const styleEl = document.createElement('style');
|
||||
styleEl.textContent = `
|
||||
vite-error-overlay,
|
||||
wds-overlay,
|
||||
#webpack-dev-server-client-overlay,
|
||||
#webpack-dev-server-client-overlay-div,
|
||||
iframe[src*="webpack-dev-server"],
|
||||
iframe[id*="webpack"],
|
||||
[id*="webpack-dev-server-client"],
|
||||
[class*="error-overlay"],
|
||||
[class*="webpack-error"] {
|
||||
display: none !important;
|
||||
visibility: hidden !important;
|
||||
opacity: 0 !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
`;
|
||||
(document.head ?? document.documentElement).appendChild(styleEl);
|
||||
|
||||
// Belt-and-braces: a MutationObserver kills any newly-injected overlay
|
||||
// root by name. Webpack v5 reinjects on each warning batch, so a static
|
||||
// CSS rule alone occasionally races a brief flash of the banner.
|
||||
const killOverlay = (node: Element) => {
|
||||
const tag = node.tagName?.toLowerCase();
|
||||
const id = (node as HTMLElement).id?.toLowerCase() ?? '';
|
||||
if (
|
||||
tag === 'vite-error-overlay' ||
|
||||
tag === 'wds-overlay' ||
|
||||
id.includes('webpack-dev-server-client') ||
|
||||
id.includes('webpack-error')
|
||||
) {
|
||||
(node as HTMLElement).remove();
|
||||
}
|
||||
};
|
||||
const obs = new MutationObserver((muts) => {
|
||||
for (const m of muts)
|
||||
m.addedNodes.forEach((n) => {
|
||||
if (n.nodeType === 1) killOverlay(n as Element);
|
||||
});
|
||||
});
|
||||
if (document.body) obs.observe(document.body, { childList: true, subtree: true });
|
||||
else
|
||||
document.addEventListener('DOMContentLoaded', () =>
|
||||
obs.observe(document.body, { childList: true, subtree: true })
|
||||
);
|
||||
});
|
||||
|
||||
const { browser, context } = await launchRecordingBrowser();
|
||||
const page = await context.newPage();
|
||||
const recordedVideo = page.video();
|
||||
// recordVideo starts the moment the page is created. We want the final clip
|
||||
// to begin once we're zoomed on the AI prompt and ready to type — NOT
|
||||
// include navigation, sidebar mount, or the AI button click. We track scene
|
||||
// start vs record start and 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()}`);
|
||||
|
|
@ -251,87 +33,14 @@ async function main() {
|
|||
const u = r.url();
|
||||
if (u.includes('ai-filters')) console.log(`[req] ${r.method()} ${u}`);
|
||||
});
|
||||
await stubAiFilters(page);
|
||||
await stubExport(page);
|
||||
|
||||
// Pre-load with ?pc= so the dashboard auto-opens the right pane and
|
||||
// flies to that postcode at zoom 16 (where postcode polygons render
|
||||
// individually). The cluster click later in the scene becomes purely
|
||||
// visual — the pane is already there, data already loaded.
|
||||
const params = new URLSearchParams({
|
||||
pc: PRELOAD_POSTCODE,
|
||||
lat: String(INITIAL_MAP_VIEW.lat),
|
||||
lon: String(INITIAL_MAP_VIEW.lon),
|
||||
zoom: String(INITIAL_MAP_VIEW.zoom),
|
||||
});
|
||||
addInitialTravelTimeParams(params);
|
||||
const url = `${APP_URL}${DASHBOARD_PATH}?${params}`;
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForLoadState('load', { timeout: 15000 }).catch(() => {});
|
||||
await page
|
||||
.locator('[data-tutorial="ai-filters"]')
|
||||
.waitFor({ state: 'visible', timeout: 15000 });
|
||||
|
||||
// Wait for the right pane to actually mount AND its content to render.
|
||||
// Without this the AI scene's browser-side setInterval (fakeType) gets
|
||||
// throttled by Chromium's scheduler under boot load (deck.gl uploads,
|
||||
// Street View iframe, postcode geometry fetch) and typing stretches 3×.
|
||||
try {
|
||||
await page
|
||||
.locator('[data-tutorial="right-pane"]')
|
||||
.waitFor({ state: 'visible', timeout: 15000 });
|
||||
// Either the area stats or the Street View embed indicates the pane
|
||||
// has finished its first render pass.
|
||||
await page
|
||||
.locator('[data-tutorial="right-pane"] iframe, [data-tutorial="right-pane"] canvas')
|
||||
.first()
|
||||
.waitFor({ state: 'attached', timeout: 2000 })
|
||||
.catch(() => {});
|
||||
} catch {
|
||||
// Pane didn't appear (postcode may not exist on this stack); proceed
|
||||
// anyway — scenes still work, just with no pane content.
|
||||
console.log('[render] right-pane preload did not mount; continuing');
|
||||
}
|
||||
// Final settle. The dashboard's flyTo to the preloaded postcode runs a
|
||||
// ~1.5s maplibre animation; deck.gl's hexagon/postcode buffers upload
|
||||
// through the same window. We wait long enough that all of this completes
|
||||
// before scenes start, otherwise the AI scene's browser-side setInterval
|
||||
// (fakeType) gets throttled to 200ms+ ticks.
|
||||
await new Promise((r) => setTimeout(r, 1200));
|
||||
|
||||
// Wrapper must be installed BEFORE the cursor — the cursor is appended to
|
||||
// <body> and must remain a sibling of the wrapper, not a descendant.
|
||||
await installZoomWrapper(page);
|
||||
await installCursor(page);
|
||||
|
||||
// Park cursor near the AI box (sidebar) at low-y so the first move is short.
|
||||
const ctx: SceneCtx = { page, cursor: { x: 200, y: 240 } };
|
||||
await page.mouse.move(ctx.cursor.x, ctx.cursor.y);
|
||||
|
||||
// Pre-flight (NOT counted in scene wall time): expand the AI prompt and
|
||||
// snap the wrapper to its zoomed-on-AI starting state.
|
||||
await preZoomToAiBox(ctx);
|
||||
await sleep(80);
|
||||
|
||||
const sceneStartMs = Date.now();
|
||||
const t = (label: string, prev: number) => {
|
||||
const now = Date.now();
|
||||
console.log(`[scene] ${label}: ${((now - prev) / 1000).toFixed(2)}s wall`);
|
||||
return now;
|
||||
};
|
||||
let mark = sceneStartMs;
|
||||
await sceneAiCloseUp(ctx); mark = t('AI close-up', mark);
|
||||
await sceneZoomOutResults(ctx); mark = t('Zoom out', mark);
|
||||
await sceneTravelTimeSlider(ctx); mark = t('TT slider', mark);
|
||||
await sceneClusterClick(ctx); mark = t('Cluster click', mark);
|
||||
await sceneExportAndOutro(ctx); mark = t('Export + outro', mark);
|
||||
const sceneEndMs = Date.now();
|
||||
await installDemoRoutes(page);
|
||||
const ctx = await prepareTimeline(page);
|
||||
const timeline = await runTimeline(ctx);
|
||||
|
||||
await page.close();
|
||||
const rawPath = join(OUTPUT_DIR, 'recording.raw.webm');
|
||||
if (recordedVideo) {
|
||||
await recordedVideo.saveAs(rawPath);
|
||||
}
|
||||
if (recordedVideo) await recordedVideo.saveAs(rawPath);
|
||||
await context.close();
|
||||
await browser.close();
|
||||
|
||||
|
|
@ -340,46 +49,10 @@ async function main() {
|
|||
process.exit(1);
|
||||
}
|
||||
|
||||
const trimmedPath = join(OUTPUT_DIR, 'recording.webm');
|
||||
const sceneSpan = (sceneEndMs - sceneStartMs) / 1000;
|
||||
const maxFinalDuration = Math.max(0.1, MAX_DURATION_S - 0.2);
|
||||
// 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 (or sceneSpan/RECORD_SCALE if shorter).
|
||||
const wallCap = maxFinalDuration * 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 ${maxFinalDuration.toFixed(2)}s (anchored to outro).`
|
||||
);
|
||||
}
|
||||
|
||||
// Trim + speed-up 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". Each
|
||||
// raw frame plays for 1/N as long → we get N× the effective fps for free,
|
||||
// no synthetic interpolation needed.
|
||||
// - fps=OUTPUT_FPS gives the MP4 encoder a stable cadence; with the default
|
||||
// RECORD_SCALE=2 this matches the real captured cadence (25fps × 2).
|
||||
execSync(
|
||||
`ffmpeg -y -ss ${trimStart.toFixed(3)} -i "${rawPath}" -t ${wallDuration.toFixed(3)} ` +
|
||||
`-vf "setpts=PTS/${RECORD_SCALE},fps=${OUTPUT_FPS},trim=duration=${finalDuration.toFixed(3)},setpts=PTS-STARTPTS" -fps_mode cfr ` +
|
||||
`-r ${OUTPUT_FPS} -c:v libvpx -b:v ${WEBM_BITRATE} -deadline good -cpu-used 5 ` +
|
||||
`"${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, scale=${RECORD_SCALE}, capture=${VIDEO_SIZE.width}x${VIDEO_SIZE.height})`
|
||||
);
|
||||
trimRecording(rawPath, join(OUTPUT_DIR, 'recording.webm'), {
|
||||
recordStartMs,
|
||||
...timeline,
|
||||
});
|
||||
console.log('Run "npm run encode" to produce output/recording.mp4');
|
||||
}
|
||||
|
||||
|
|
|
|||
58
video/src/routes.ts
Normal file
58
video/src/routes.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import type { Page } from 'playwright';
|
||||
import {
|
||||
APP_URL,
|
||||
DASHBOARD_PATH,
|
||||
INITIAL_MAP_VIEW,
|
||||
STUBBED_FILTERS,
|
||||
STUBBED_TRAVEL_TIME_FILTERS,
|
||||
} from './config.js';
|
||||
|
||||
export async function installDemoRoutes(page: Page) {
|
||||
await Promise.all([stubAiFilters(page), stubExport(page)]);
|
||||
}
|
||||
|
||||
export function dashboardUrl(): string {
|
||||
const params = new URLSearchParams({
|
||||
lat: String(INITIAL_MAP_VIEW.lat),
|
||||
lon: String(INITIAL_MAP_VIEW.lon),
|
||||
zoom: String(INITIAL_MAP_VIEW.zoom),
|
||||
});
|
||||
addInitialTravelTimeParams(params);
|
||||
return `${APP_URL}${DASHBOARD_PATH}?${params}`;
|
||||
}
|
||||
|
||||
async function stubAiFilters(page: Page) {
|
||||
await page.route('**/api/ai-filters', async (route) => {
|
||||
await new Promise((r) => setTimeout(r, 120));
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
filters: STUBBED_FILTERS,
|
||||
travel_time_filters: STUBBED_TRAVEL_TIME_FILTERS,
|
||||
notes: '',
|
||||
match_count: 1247,
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function stubExport(page: Page) {
|
||||
await page.route('**/api/export?**', async (route) => {
|
||||
await new Promise((r) => setTimeout(r, 120));
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
headers: {
|
||||
'content-disposition': 'attachment; filename="perfect-postcode-export.xlsx"',
|
||||
},
|
||||
body: Buffer.from('Perfect Postcode demo export\n'),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function addInitialTravelTimeParams(params: URLSearchParams) {
|
||||
for (const tt of STUBBED_TRAVEL_TIME_FILTERS) {
|
||||
params.append('tt', `${tt.mode}:${tt.slug}:${tt.label}:${tt.min ?? 0}:${tt.max ?? 120}`);
|
||||
}
|
||||
}
|
||||
|
|
@ -13,13 +13,16 @@ import {
|
|||
import {
|
||||
clearVignette,
|
||||
flashRect,
|
||||
getDemoMapSettleVersion,
|
||||
hideCaption,
|
||||
scrollPaneTo,
|
||||
showCaption,
|
||||
showOutro,
|
||||
visualClick,
|
||||
waitForDemoMapSettled,
|
||||
waitForCurrentDemoMapSettled,
|
||||
waitForDemoSelectionReady,
|
||||
zoomReset,
|
||||
zoomTo,
|
||||
zoomToInstant,
|
||||
} from './dom.js';
|
||||
import { fakeType, sleep, smoothMove, smoothDragSliderThumb } from './motion.js';
|
||||
|
||||
|
|
@ -29,37 +32,36 @@ export interface SceneCtx {
|
|||
}
|
||||
|
||||
/**
|
||||
* Scene 1: open already zoomed in on the AI prompt card. Caption fades in,
|
||||
* the user types their request, and the already-preloaded filters are revealed
|
||||
* behind the zoomed wrapper. Keeping this beat visual avoids slow dev-server
|
||||
* data refreshes eating the 15-second timeline.
|
||||
*
|
||||
* Pre-conditions (set up by record.ts before scene timer starts):
|
||||
* - The AI box is already expanded (textarea visible, ready to focus).
|
||||
* - The wrapper is already zoomed at AI_ZOOM_SCALE on the AI box centre.
|
||||
* - The vignette is up.
|
||||
* 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 } = ctx;
|
||||
|
||||
await clearVignette(page);
|
||||
await showCaption(page, 'Brief: flats or terraces under £450k near central Manchester.');
|
||||
await sleep(160);
|
||||
await sleep(180);
|
||||
|
||||
await fakeType(page, '[data-tutorial="ai-filters"] textarea', PROMPT_TEXT, 14);
|
||||
await sleep(120);
|
||||
await zoomToAiBox(page, 720);
|
||||
await sleep(760);
|
||||
|
||||
await fakeType(page, '[data-tutorial="ai-filters"] textarea', PROMPT_TEXT, 18);
|
||||
await sleep(160);
|
||||
const aiResponse = page
|
||||
.waitForResponse(
|
||||
(response) => response.url().includes('/api/ai-filters') && response.status() === 200,
|
||||
{ timeout: 1800 }
|
||||
)
|
||||
.catch(() => null);
|
||||
const mapVersion = await getDemoMapSettleVersion(page);
|
||||
await page.evaluate(() => {
|
||||
document.querySelector<HTMLFormElement>('[data-tutorial="ai-filters"] form')?.requestSubmit();
|
||||
});
|
||||
await aiResponse;
|
||||
await sleep(160);
|
||||
await waitForDemoMapSettled(page, 15000, mapVersion);
|
||||
await showCaption(page, 'The filters are already live on the map.');
|
||||
await sleep(360);
|
||||
await sleep(560);
|
||||
await hideCaption(page);
|
||||
}
|
||||
|
||||
|
|
@ -72,10 +74,10 @@ export async function sceneAiCloseUp(ctx: SceneCtx): Promise<void> {
|
|||
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, 560);
|
||||
await sleep(420);
|
||||
await zoomReset(page, 860);
|
||||
await sleep(980);
|
||||
await hideCaption(page);
|
||||
await sleep(80);
|
||||
await sleep(180);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -107,6 +109,7 @@ export async function sceneTravelTimeSlider(ctx: SceneCtx): Promise<void> {
|
|||
|
||||
// Slider goes 0..120, target = 20 → fraction 0.166...
|
||||
const toFraction = TT_DRAG_TO_MIN / TT_SLIDER_MAX;
|
||||
const mapVersion = await getDemoMapSettleVersion(page);
|
||||
|
||||
ctx.cursor = await smoothDragSliderThumb(
|
||||
page,
|
||||
|
|
@ -114,87 +117,90 @@ export async function sceneTravelTimeSlider(ctx: SceneCtx): Promise<void> {
|
|||
trackSelector,
|
||||
ctx.cursor,
|
||||
toFraction,
|
||||
520
|
||||
1180
|
||||
);
|
||||
|
||||
await sleep(120);
|
||||
await sleep(220);
|
||||
await waitForDemoMapSettled(page, 16000, mapVersion);
|
||||
await showCaption(page, 'The map redraws around the areas that still work.');
|
||||
await sleep(440);
|
||||
await sleep(720);
|
||||
await hideCaption(page);
|
||||
await sleep(60);
|
||||
await sleep(180);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scene 4: zoom into a cluster of filtered postcodes (using deck.gl's own
|
||||
* camera, via wheel events), click one, and as the right pane fills, pan
|
||||
* the framing rightward while scrolling the pane content.
|
||||
*
|
||||
* Why two zoom mechanisms across this scene:
|
||||
* - Pre-click: native deck.gl wheel-zoom. CSS-transforming the wrapper
|
||||
* changes `canvas.getBoundingClientRect()` (scaled rect) without changing
|
||||
* `canvas.width`. deck.gl's hit-test uses the rect for screen→buffer
|
||||
* mapping, returns a partial picked object, and React re-renders mid-paint
|
||||
* leaving a null layer reference that crashes `MapboxLayer.render`.
|
||||
* Native wheel-zoom recomputes deck.gl's camera in-place; layers stay coherent.
|
||||
* - Post-click: CSS transform to pan the framing rightward. By this point
|
||||
* the postcode is selected and layers are stable, so the transform is safe.
|
||||
* 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, 'Open one promising area and check the detail before shortlisting.');
|
||||
await showCaption(page, 'Zoom into the result clusters, then click a promising hexagon.');
|
||||
|
||||
// Click point: roughly map centre. After AI flew the camera to Manchester, this
|
||||
// sits in the densely-filtered city core where hexagons reliably cover any
|
||||
// pixel. Earlier iterations wheel-zoomed first to "feel cinematic", but
|
||||
// that crossed the hexagon→postcode layer-swap threshold mid-flight and
|
||||
// clicks landed in a layer gap (no pane opened).
|
||||
const cluster = {
|
||||
x: 360 + (viewport.width - 360) * 0.5,
|
||||
y: viewport.height * 0.45,
|
||||
x: 360 + (viewport.width - 360) * 0.35,
|
||||
y: viewport.height * 0.52,
|
||||
};
|
||||
|
||||
await smoothMove(page, ctx.cursor, cluster, { durationMs: 260 });
|
||||
await smoothMove(page, ctx.cursor, cluster, { durationMs: 520 });
|
||||
ctx.cursor = cluster;
|
||||
await sleep(70);
|
||||
await sleep(220);
|
||||
|
||||
// The right pane was opened at page load via ?pc= — no need to drive a
|
||||
// real selection through deck.gl's hit-test, which is flaky in headless
|
||||
// Chromium. The mouse.click here is purely for the visible cursor ripple
|
||||
// animation; the pane is already populated with real postcode data.
|
||||
await page.mouse.click(cluster.x, cluster.y);
|
||||
await sleep(130);
|
||||
await zoomMapWithWheel(page, cluster);
|
||||
|
||||
// NOW zoom in toward the cluster, pan rightward to centre the right pane,
|
||||
// and scroll the pane content — all in parallel. Layers are stable so the
|
||||
// CSS transform is safe.
|
||||
const rightShift = 240;
|
||||
await Promise.all([
|
||||
zoomTo(page, {
|
||||
scale: 1.35,
|
||||
focusX: cluster.x + rightShift,
|
||||
focusY: cluster.y,
|
||||
durationMs: 520,
|
||||
}),
|
||||
scrollPaneTo(page, '[data-tutorial="right-pane"]', 480),
|
||||
]);
|
||||
await sleep(320);
|
||||
const clicked = await clickHexagon(page, cluster);
|
||||
ctx.cursor = clicked;
|
||||
await openDemoHexagon(page);
|
||||
await page.locator('[data-tutorial="right-pane"]').waitFor({ state: 'visible', timeout: 5000 });
|
||||
await waitForDemoSelectionReady(page, 16000);
|
||||
await sleep(360);
|
||||
await showCaption(
|
||||
page,
|
||||
'This is the useful pause: local stats, matching homes, and street context together.'
|
||||
);
|
||||
await sleep(520);
|
||||
await sleep(1000);
|
||||
await hideCaption(page);
|
||||
}
|
||||
|
||||
async function clickHexagon(
|
||||
page: Page,
|
||||
target: { x: number; y: number }
|
||||
): Promise<{ x: number; y: number }> {
|
||||
await visualClick(page, target);
|
||||
await sleep(140);
|
||||
return target;
|
||||
}
|
||||
|
||||
async function zoomMapWithWheel(page: Page, target: { x: number; y: number }): Promise<void> {
|
||||
await page.mouse.move(target.x, target.y);
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await page.mouse.wheel(0, -120);
|
||||
await sleep(95);
|
||||
}
|
||||
await waitForCurrentDemoMapSettled(page, 16000);
|
||||
await sleep(260);
|
||||
}
|
||||
|
||||
async function openDemoHexagon(page: Page): Promise<void> {
|
||||
const selected = await page.evaluate(
|
||||
() =>
|
||||
(
|
||||
window as typeof window & {
|
||||
__demoOpenBestHexagon?: () => string | null;
|
||||
}
|
||||
).__demoOpenBestHexagon?.() ?? null
|
||||
);
|
||||
if (!selected) throw new Error('Could not open a demo hexagon selection');
|
||||
}
|
||||
|
||||
/** 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, 460);
|
||||
await sleep(240);
|
||||
await zoomReset(page, 680);
|
||||
await sleep(520);
|
||||
|
||||
const exportButton = page.locator('button[title="Export to Excel"]').first();
|
||||
await exportButton.waitFor({ state: 'visible', timeout: 4000 });
|
||||
|
|
@ -202,34 +208,25 @@ export async function sceneExportAndOutro(ctx: SceneCtx): Promise<void> {
|
|||
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: 360 });
|
||||
await smoothMove(page, ctx.cursor, target, { durationMs: 620 });
|
||||
ctx.cursor = target;
|
||||
await sleep(70);
|
||||
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(280);
|
||||
await sleep(680);
|
||||
await hideCaption(page);
|
||||
await showOutro(ctx.page, BRAND_NAME, BRAND_TAGLINE, BRAND_URL);
|
||||
void download;
|
||||
await sleep(2400);
|
||||
await sleep(2200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper used by record.ts: after navigation but BEFORE the scene timer
|
||||
* starts, click the AI-prompt button so its textarea is mounted, then snap
|
||||
* the wrapper to its zoomed-on-AI starting state.
|
||||
*
|
||||
* Splitting this out keeps the scene timer honest: the textarea's mount
|
||||
* animation and the zoom snap don't eat into the 15s budget.
|
||||
*/
|
||||
export async function preZoomToAiBox(ctx: SceneCtx): Promise<void> {
|
||||
/** Open the AI prompt before the timed scene starts. */
|
||||
export async function prepareAiBox(ctx: SceneCtx): Promise<void> {
|
||||
const { page } = ctx;
|
||||
|
||||
// Open the AI prompt. The collapsed state shows a single button; clicking
|
||||
// it expands the form and reveals the textarea.
|
||||
const aiRoot = page.locator('[data-tutorial="ai-filters"]').first();
|
||||
await aiRoot.waitFor({ state: 'visible', timeout: 15000 });
|
||||
|
||||
|
|
@ -247,13 +244,13 @@ export async function preZoomToAiBox(ctx: SceneCtx): Promise<void> {
|
|||
}
|
||||
await textarea.waitFor({ state: 'visible', timeout: 15000 });
|
||||
await sleep(100);
|
||||
}
|
||||
|
||||
// Snap-zoom to the AI card centre. The recording opens already zoomed in
|
||||
// — there's no awkward "from 1× to 2.4×" intro animation.
|
||||
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 zoomToInstant(page, { scale: AI_ZOOM_SCALE, focusX, focusY });
|
||||
await zoomTo(page, { scale: AI_ZOOM_SCALE, focusX, focusY, durationMs });
|
||||
}
|
||||
|
|
|
|||
58
video/src/timeline.ts
Normal file
58
video/src/timeline.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import type { Page } from 'playwright';
|
||||
import { installCursor, installZoomWrapper, waitForCurrentDemoMapSettled } from './dom.js';
|
||||
import { sleep } from './motion.js';
|
||||
import { dashboardUrl } from './routes.js';
|
||||
import {
|
||||
prepareAiBox,
|
||||
sceneAiCloseUp,
|
||||
sceneClusterClick,
|
||||
sceneExportAndOutro,
|
||||
sceneTravelTimeSlider,
|
||||
sceneZoomOutResults,
|
||||
type SceneCtx,
|
||||
} from './scenes.js';
|
||||
|
||||
export interface TimelineResult {
|
||||
sceneStartMs: number;
|
||||
sceneEndMs: number;
|
||||
}
|
||||
|
||||
export async function prepareTimeline(page: Page): Promise<SceneCtx> {
|
||||
await page.goto(dashboardUrl(), { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForLoadState('load', { timeout: 15000 }).catch(() => {});
|
||||
await page
|
||||
.locator('[data-tutorial="ai-filters"]')
|
||||
.waitFor({ state: 'visible', timeout: 15000 });
|
||||
await page.locator('canvas').first().waitFor({ state: 'attached', timeout: 15000 });
|
||||
await waitForCurrentDemoMapSettled(page, 15000);
|
||||
|
||||
await new Promise((r) => setTimeout(r, 400));
|
||||
await installZoomWrapper(page);
|
||||
await installCursor(page);
|
||||
|
||||
const ctx: SceneCtx = { page, cursor: { x: 200, y: 240 } };
|
||||
await page.mouse.move(ctx.cursor.x, ctx.cursor.y);
|
||||
await prepareAiBox(ctx);
|
||||
await sleep(80);
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export async function runTimeline(ctx: SceneCtx): Promise<TimelineResult> {
|
||||
const sceneStartMs = Date.now();
|
||||
let mark = sceneStartMs;
|
||||
|
||||
mark = await runScene('AI close-up', mark, () => sceneAiCloseUp(ctx));
|
||||
mark = await runScene('Zoom out', mark, () => sceneZoomOutResults(ctx));
|
||||
mark = await runScene('TT slider', mark, () => sceneTravelTimeSlider(ctx));
|
||||
mark = await runScene('Cluster click', mark, () => sceneClusterClick(ctx));
|
||||
mark = await runScene('Export + outro', mark, () => sceneExportAndOutro(ctx));
|
||||
|
||||
return { sceneStartMs, sceneEndMs: mark };
|
||||
}
|
||||
|
||||
async function runScene(label: string, prev: number, scene: () => Promise<void>): Promise<number> {
|
||||
await scene();
|
||||
const now = Date.now();
|
||||
console.log(`[scene] ${label}: ${((now - prev) / 1000).toFixed(2)}s wall`);
|
||||
return now;
|
||||
}
|
||||
88
video/src/verify.ts
Normal file
88
video/src/verify.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import { execFileSync } from 'node:child_process';
|
||||
import { existsSync, statSync } from 'node:fs';
|
||||
import { MAX_DURATION_S, MIN_DURATION_S, OUTPUT_FPS, OUTPUT_DIR, VIDEO_SIZE } from './config.js';
|
||||
|
||||
interface Probe {
|
||||
streams?: {
|
||||
width?: number;
|
||||
height?: number;
|
||||
avg_frame_rate?: string;
|
||||
r_frame_rate?: string;
|
||||
}[];
|
||||
format?: {
|
||||
duration?: string;
|
||||
size?: string;
|
||||
};
|
||||
}
|
||||
|
||||
function fail(message: string): never {
|
||||
console.error(`[verify] FAIL: ${message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function parseRate(rate: string | undefined): number {
|
||||
if (!rate) return 0;
|
||||
const [num, den] = rate.split('/').map(Number);
|
||||
if (!num || !den) return Number(rate) || 0;
|
||||
return num / den;
|
||||
}
|
||||
|
||||
function probe(path: string): Probe {
|
||||
const raw = execFileSync(
|
||||
'ffprobe',
|
||||
[
|
||||
'-v',
|
||||
'error',
|
||||
'-select_streams',
|
||||
'v:0',
|
||||
'-show_entries',
|
||||
'stream=width,height,r_frame_rate,avg_frame_rate',
|
||||
'-show_entries',
|
||||
'format=duration,size',
|
||||
'-of',
|
||||
'json',
|
||||
path,
|
||||
],
|
||||
{ encoding: 'utf8' }
|
||||
);
|
||||
return JSON.parse(raw) as Probe;
|
||||
}
|
||||
|
||||
function verifyVideo(path: string) {
|
||||
if (!existsSync(path)) fail(`${path} is missing`);
|
||||
if (statSync(path).size === 0) fail(`${path} is empty`);
|
||||
|
||||
const data = probe(path);
|
||||
const stream = data.streams?.[0];
|
||||
if (!stream) fail(`${path} has no video stream`);
|
||||
|
||||
const duration = Number(data.format?.duration ?? 0);
|
||||
const fps = parseRate(stream.avg_frame_rate || stream.r_frame_rate);
|
||||
if (stream.width !== VIDEO_SIZE.width || stream.height !== VIDEO_SIZE.height) {
|
||||
fail(`${path} is ${stream.width}x${stream.height}, expected ${VIDEO_SIZE.width}x${VIDEO_SIZE.height}`);
|
||||
}
|
||||
if (duration < MIN_DURATION_S || duration > MAX_DURATION_S) {
|
||||
fail(
|
||||
`${path} duration is ${duration.toFixed(2)}s, expected ${MIN_DURATION_S}-${MAX_DURATION_S}s`
|
||||
);
|
||||
}
|
||||
if (Math.abs(fps - OUTPUT_FPS) > 0.1) {
|
||||
fail(`${path} is ${fps.toFixed(2)}fps, expected ${OUTPUT_FPS}fps`);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[verify] ${path}: ${stream.width}x${stream.height}, ${duration.toFixed(2)}s, ${fps.toFixed(2)}fps`
|
||||
);
|
||||
}
|
||||
|
||||
function verifyImage(path: string) {
|
||||
if (!existsSync(path)) fail(`${path} is missing`);
|
||||
if (statSync(path).size === 0) fail(`${path} is empty`);
|
||||
console.log(`[verify] ${path}: ${statSync(path).size} bytes`);
|
||||
}
|
||||
|
||||
const videoPath = process.argv[2] ?? `${OUTPUT_DIR}/recording.mp4`;
|
||||
const posterPath = process.argv[3] ?? (process.argv[2] ? undefined : `${OUTPUT_DIR}/poster.jpg`);
|
||||
|
||||
verifyVideo(videoPath);
|
||||
if (posterPath) verifyImage(posterPath);
|
||||
58
video/src/video.ts
Normal file
58
video/src/video.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { execSync } from 'node:child_process';
|
||||
import { renameSync, statSync } from 'node:fs';
|
||||
import {
|
||||
MAX_DURATION_S,
|
||||
OUTPUT_FPS,
|
||||
RECORD_SCALE,
|
||||
VIDEO_SIZE,
|
||||
WEBM_BITRATE,
|
||||
} from './config.js';
|
||||
|
||||
const LEAD_IN_S = 0.12;
|
||||
|
||||
export function trimRecording(
|
||||
rawPath: string,
|
||||
trimmedPath: string,
|
||||
times: { recordStartMs: number; sceneStartMs: number; sceneEndMs: number }
|
||||
) {
|
||||
const sceneSpan = (times.sceneEndMs - times.sceneStartMs) / 1000;
|
||||
const trimStart = Math.max(
|
||||
0,
|
||||
(times.sceneStartMs - times.recordStartMs) / 1000 - LEAD_IN_S * RECORD_SCALE
|
||||
);
|
||||
const trimEnd = (times.sceneEndMs - times.recordStartMs) / 1000;
|
||||
const wallDuration = trimEnd - trimStart;
|
||||
const finalDuration = wallDuration / RECORD_SCALE;
|
||||
|
||||
if (finalDuration > MAX_DURATION_S) {
|
||||
console.log(
|
||||
`Scene output duration is ${finalDuration.toFixed(2)}s (guard ${MAX_DURATION_S.toFixed(2)}s); keeping the full take.`
|
||||
);
|
||||
}
|
||||
|
||||
const filter =
|
||||
`trim=start=${trimStart.toFixed(3)}:duration=${wallDuration.toFixed(3)},` +
|
||||
`setpts=(PTS-STARTPTS)/${RECORD_SCALE},fps=${OUTPUT_FPS},` +
|
||||
`trim=duration=${finalDuration.toFixed(3)},setpts=PTS-STARTPTS`;
|
||||
|
||||
// Keep trimming inside the filter graph: it is frame-accurate for WebM
|
||||
// without the keyframe leakage of input seeking or the post-speedup timing
|
||||
// ambiguity of output seeking.
|
||||
execSync(
|
||||
`ffmpeg -y -i "${rawPath}" -vf "${filter}" ` +
|
||||
`-fps_mode cfr -r ${OUTPUT_FPS} -c:v libvpx -b:v ${WEBM_BITRATE} -deadline good -cpu-used 5 ` +
|
||||
`"${trimmedPath}"`,
|
||||
{ stdio: 'inherit' }
|
||||
);
|
||||
|
||||
try {
|
||||
statSync(rawPath);
|
||||
renameSync(rawPath, rawPath + '.untrimmed');
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Wrote ${trimmedPath} (${finalDuration.toFixed(2)}s, scene=${(sceneSpan / RECORD_SCALE).toFixed(2)}s, scale=${RECORD_SCALE}, capture=${VIDEO_SIZE.width}x${VIDEO_SIZE.height})`
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue