feat(film): high-visibility cursor, lead-in trim, and rich storm-intel template

Replace the tiny SVG pointer with a solid brand-blue disc (white border + glow + bold click pulse) visible on any background. Orchestrator measures and trims the login/nav lead-in via ffmpeg -ss. Configurable FILM_BASE_URL. storm-intel becomes the rich hero template: waits for each load, drops a pin, waits for its storm history, clicks a storm event to visualise its polygon on the map, widens the range, and holds on the payoff.
This commit is contained in:
Satyam Rastogi
2026-05-30 21:57:00 +05:30
parent 8602c70eea
commit e58428126f
4 changed files with 91 additions and 46 deletions
@@ -8,7 +8,7 @@ import { newestWebm, webmToMp4, probeSeconds, secondsToFrames } from "./lib/reco
const __dirname = dirname(fileURLToPath(import.meta.url)); const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = join(__dirname, ".."); const ROOT = join(__dirname, "..");
const BASE_URL = "http://localhost:5173"; const BASE_URL = process.env.FILM_BASE_URL || "http://localhost:5173";
const FPS = 30; const FPS = 30;
const VIEWPORT = { width: 1920, height: 1080 }; const VIEWPORT = { width: 1920, height: 1080 };
@@ -48,6 +48,7 @@ async function main() {
deviceScaleFactor: 1, // recordVideo records CSS px; keep 1 for exact 1080p deviceScaleFactor: 1, // recordVideo records CSS px; keep 1 for exact 1080p
recordVideo: { dir: tmpVideoDir, size: VIEWPORT }, recordVideo: { dir: tmpVideoDir, size: VIEWPORT },
}); });
const tCtx = Date.now(); // recording starts at context creation; measure the lead-in
await context.addInitScript("try{localStorage.setItem('theme','dark')}catch(e){}"); await context.addInitScript("try{localStorage.setItem('theme','dark')}catch(e){}");
await context.addInitScript(CURSOR_INIT_SCRIPT); await context.addInitScript(CURSOR_INIT_SCRIPT);
const page = await context.newPage(); const page = await context.newPage();
@@ -61,7 +62,8 @@ async function main() {
} }
await page.waitForTimeout(800); await page.waitForTimeout(800);
console.log(`▶ recording ${clip.id} (${clip.role}) ...`); const leadInSec = Math.max(0, (Date.now() - tCtx) / 1000 - 0.3); // trim login+nav, keep 0.3s pad
console.log(`▶ recording ${clip.id} (${clip.role}) ... (trimming ${leadInSec.toFixed(1)}s lead-in)`);
await scene(page, cursor, { await scene(page, cursor, {
baseUrl: BASE_URL, baseUrl: BASE_URL,
spaNavigate, spaNavigate,
@@ -73,7 +75,7 @@ async function main() {
await context.close(); // flush the .webm await context.close(); // flush the .webm
const webm = await newestWebm(tmpVideoDir); const webm = await newestWebm(tmpVideoDir);
const mp4 = join(outDir, `${clip.id}.mp4`); const mp4 = join(outDir, `${clip.id}.mp4`);
await webmToMp4(webm, mp4, FPS); await webmToMp4(webm, mp4, FPS, leadInSec);
await rm(tmpVideoDir, { recursive: true, force: true }); await rm(tmpVideoDir, { recursive: true, force: true });
const seconds = await probeSeconds(mp4); const seconds = await probeSeconds(mp4);
+24 -22
View File
@@ -16,42 +16,44 @@ export function easeSteps(from, to, steps) {
// Page-side script: appends a pointer element and exposes window.__cursor*. // Page-side script: appends a pointer element and exposes window.__cursor*.
// pointer-events:none so it never blocks real clicks. Injected via addInitScript. // pointer-events:none so it never blocks real clicks. Injected via addInitScript.
// High-visibility CSS cursor (no SVG/innerHTML): a brand-blue disc with a white border,
// a dark contrast ring, and a glow — visible on dark, light, and busy map backgrounds.
// A bold yellow ring expands on every click. Starts centered so it's never stuck at 0,0.
export const CURSOR_INIT_SCRIPT = ` export const CURSOR_INIT_SCRIPT = `
(() => { (() => {
if (window.__cursorEl) return; if (window.__cursorEl) return;
const el = document.createElement('div'); const dot = document.createElement('div');
el.id = '__film_cursor'; dot.id = '__film_cursor';
Object.assign(el.style, { Object.assign(dot.style, {
position: 'fixed', left: '0', top: '0', width: '22px', height: '22px', position: 'fixed', left: '0', top: '0', width: '30px', height: '30px',
marginLeft: '-4px', marginTop: '-4px', zIndex: '2147483647', marginLeft: '-15px', marginTop: '-15px', borderRadius: '50%',
pointerEvents: 'none', transition: 'transform 0.04s linear', background: 'rgba(11,95,255,0.92)', border: '3px solid #ffffff',
background: 'transparent', boxShadow: '0 0 0 2px rgba(0,0,0,0.6), 0 0 18px 5px rgba(11,95,255,0.9)',
zIndex: '2147483647', pointerEvents: 'none', willChange: 'transform',
transition: 'transform 0.03s linear', transform: 'translate(960px,540px)',
}); });
el.innerHTML = document.documentElement.appendChild(dot);
"<svg width='22' height='22' viewBox='0 0 22 22'><path d='M2 2 L2 17 L6 13 L9 20 L12 19 L9 12 L15 12 Z' fill='white' stroke='#0B5FFF' stroke-width='1.5'/></svg>";
document.documentElement.appendChild(el);
const ring = document.createElement('div'); const ring = document.createElement('div');
Object.assign(ring.style, { Object.assign(ring.style, {
position: 'fixed', left: '0', top: '0', width: '34px', height: '34px', position: 'fixed', left: '0', top: '0', width: '30px', height: '30px',
marginLeft: '-17px', marginTop: '-17px', borderRadius: '50%', marginLeft: '-15px', marginTop: '-15px', borderRadius: '50%',
border: '2px solid rgba(11,95,255,0.9)', zIndex: '2147483646', border: '4px solid rgba(255,209,0,0.95)', boxShadow: '0 0 10px 2px rgba(255,209,0,0.6)',
pointerEvents: 'none', opacity: '0', transform: 'scale(0.4)', zIndex: '2147483646', pointerEvents: 'none', opacity: '0',
transition: 'opacity 0.3s, transform 0.3s', transform: 'translate(960px,540px) scale(0.5)',
}); });
document.documentElement.appendChild(ring); document.documentElement.appendChild(ring);
window.__cursorEl = el; window.__cursorRing = ring; window.__cursorEl = dot; window.__cursorRing = ring;
window.__cursorMove = (x, y) => { window.__cursorMove = (x, y) => {
el.style.transform = 'translate(' + x + 'px,' + y + 'px)'; dot.style.transform = 'translate(' + x + 'px,' + y + 'px)';
ring.style.transform = 'translate(' + x + 'px,' + y + 'px) scale(0.4)';
}; };
window.__cursorPulse = (x, y) => { window.__cursorPulse = (x, y) => {
ring.style.transition = 'none'; ring.style.transition = 'none';
ring.style.opacity = '0.9'; ring.style.opacity = '0.95';
ring.style.transform = 'translate(' + x + 'px,' + y + 'px) scale(0.4)'; ring.style.transform = 'translate(' + x + 'px,' + y + 'px) scale(0.5)';
requestAnimationFrame(() => { requestAnimationFrame(() => {
ring.style.transition = 'opacity 0.4s, transform 0.4s'; ring.style.transition = 'opacity 0.55s ease-out, transform 0.55s ease-out';
ring.style.opacity = '0'; ring.style.opacity = '0';
ring.style.transform = 'translate(' + x + 'px,' + y + 'px) scale(1.4)'; ring.style.transform = 'translate(' + x + 'px,' + y + 'px) scale(2.6)';
}); });
}; };
})(); })();
+4 -2
View File
@@ -36,10 +36,12 @@ export async function probeSeconds(file) {
} }
// Convert a recorded .webm to a constant-30fps .mp4 (h.264, yuv420p for broad support). // Convert a recorded .webm to a constant-30fps .mp4 (h.264, yuv420p for broad support).
export async function webmToMp4(webm, mp4, fps = 30) { // startSec trims the lead-in (login + navigation) recorded before the scene began.
export async function webmToMp4(webm, mp4, fps = 30, startSec = 0) {
await mkdir(dirname(mp4), { recursive: true }); await mkdir(dirname(mp4), { recursive: true });
const seek = startSec > 0 ? ["-ss", startSec.toFixed(3)] : [];
await run(ffmpegPath, [ await run(ffmpegPath, [
"-y", "-i", webm, "-y", ...seek, "-i", webm,
"-r", String(fps), "-vsync", "cfr", "-r", String(fps), "-vsync", "cfr",
"-c:v", "libx264", "-pix_fmt", "yuv420p", "-crf", "18", "-preset", "medium", "-c:v", "libx264", "-pix_fmt", "yuv420p", "-crf", "18", "-preset", "medium",
"-an", mp4, "-an", mp4,
@@ -1,30 +1,69 @@
// Storm Intel: drop a pin, wait for the REAL storm-history fetch, reveal the panel. // Storm Intel — HERO scene (~75s). Full walkthrough that WAITS for each load to finish:
// load map+data → drop a pin → wait for the pin's storm history → click a storm EVENT
// so it's visualised (polygon) on the map → filter by Hail → widen the date range → hold.
// This is the rich template the other scenes follow.
export default async (page, cursor, ctx) => { export default async (page, cursor, ctx) => {
// Map tiles stream in — let them settle before interacting. const dwell = (ms) => page.waitForTimeout(ms);
await page.waitForTimeout(2500);
// Enable pin-drop mode if there's a toggle (button text "Pin" / aria), else skip. // 1) Wait for the map AND the storm data to actually finish loading (not a fixed sleep).
const pinToggle = page.getByRole("button", { name: /pin/i }).first(); await page.locator(".leaflet-container").first().waitFor({ state: "visible", timeout: 20000 });
// The page shows "Loading storm data..." until events resolve — wait for it to clear.
await page
.getByText(/loading storm data/i)
.first()
.waitFor({ state: "hidden", timeout: 20000 })
.catch(() => {});
await page.waitForLoadState("networkidle").catch(() => {});
await dwell(1800); // open on the fully-loaded map
// 2) Enter pin-drop mode (crosshair / "drop a pin" toggle).
const pinToggle = page
.getByRole("button", { name: /pin|crosshair|drop a pin/i })
.first();
if (await pinToggle.count()) { if (await pinToggle.count()) {
await cursor.clickLocator(pinToggle); await cursor.clickLocator(pinToggle);
await page.waitForTimeout(400); await dwell(600);
} }
// Click a point on the map (center-ish). The map container fills the main area. // 3) Drop a pin over central Plano and WAIT for the per-pin storm history to load.
const map = page.locator(".leaflet-container").first(); const map = page.locator(".leaflet-container").first();
const box = await map.boundingBox(); const box = await map.boundingBox();
const target = { x: box.x + box.width * 0.5, y: box.y + box.height * 0.5 }; const target = { x: box.x + box.width * 0.52, y: box.y + box.height * 0.62 };
const waitHistory = page
.waitForResponse((r) => /\/api\/storm-history/.test(r.url()), { timeout: 20000 })
.catch(() => null);
await cursor.click(target.x, target.y, 900);
await waitHistory;
// Wait for the "Loading..." in the Storm History panel to resolve into real content.
await page
.getByText(/loading/i)
.first()
.waitFor({ state: "hidden", timeout: 12000 })
.catch(() => {});
await dwell(2500); // hold on the populated history panel
// Drive the real storm-history fetch and wait for it, so the panel fill is on camera. // 4) Widen the range to 12 Mo FIRST (more history) — do filters before selecting an
const waitFetch = page // event so we never filter the selected event back off the map.
.waitForResponse((r) => /\/api\/storm-history/.test(r.url()), { timeout: 15000 }) const range12 = page.getByRole("button", { name: /^\s*12\s*mo\s*$/i }).first();
.catch(() => null); // tolerate cached/no-fetch; the pin still renders if (await range12.count()) {
await cursor.click(target.x, target.y, 800); await cursor.clickLocator(range12, 700);
await waitFetch; await dwell(2500);
await page.waitForTimeout(2500); // hold on the populated panel }
// Pan the cursor to the populated history panel for emphasis. // 5) Click the storm EVENT card (e.g. "S. Plano — Flash Flood") so it draws on the map,
const panel = page.getByText(/storm history/i).first(); // then HOLD on it — this visualised polygon is the payoff of the scene.
if (await panel.count()) await cursor.clickLocator(panel, 700); // Event titles use a "Place — Type" format (em-dash); filter chips are bare words —
await page.waitForTimeout(1500); // so match the em-dash title (fallback: the unique "homes affected" line) to avoid
// accidentally clicking a top-of-page filter chip.
let card = page
.getByText(/[—–-]\s*(flash\s*flood|flood|hail|tornado|wind|snow)/i)
.first();
if (!(await card.count())) card = page.getByText(/homes affected/i).first();
if (await card.count()) {
await cursor.clickLocator(card, 900);
await dwell(4000); // let the polygon render + the map fly/zoom to it, then hold
}
// 6) Final hold on the populated, visualised view (event drawn on the map).
await dwell(3000);
}; };