diff --git a/marketing/product-film/scripts/capture-video.mjs b/marketing/product-film/scripts/capture-video.mjs index fce0571..64c183d 100644 --- a/marketing/product-film/scripts/capture-video.mjs +++ b/marketing/product-film/scripts/capture-video.mjs @@ -8,7 +8,7 @@ import { newestWebm, webmToMp4, probeSeconds, secondsToFrames } from "./lib/reco const __dirname = dirname(fileURLToPath(import.meta.url)); 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 VIEWPORT = { width: 1920, height: 1080 }; @@ -48,6 +48,7 @@ async function main() { deviceScaleFactor: 1, // recordVideo records CSS px; keep 1 for exact 1080p 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(CURSOR_INIT_SCRIPT); const page = await context.newPage(); @@ -61,7 +62,8 @@ async function main() { } 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, { baseUrl: BASE_URL, spaNavigate, @@ -73,7 +75,7 @@ async function main() { await context.close(); // flush the .webm const webm = await newestWebm(tmpVideoDir); 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 }); const seconds = await probeSeconds(mp4); diff --git a/marketing/product-film/scripts/lib/cursor.js b/marketing/product-film/scripts/lib/cursor.js index 6575dfd..0b376ab 100644 --- a/marketing/product-film/scripts/lib/cursor.js +++ b/marketing/product-film/scripts/lib/cursor.js @@ -16,42 +16,44 @@ export function easeSteps(from, to, steps) { // Page-side script: appends a pointer element and exposes window.__cursor*. // 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 = ` (() => { if (window.__cursorEl) return; - const el = document.createElement('div'); - el.id = '__film_cursor'; - Object.assign(el.style, { - position: 'fixed', left: '0', top: '0', width: '22px', height: '22px', - marginLeft: '-4px', marginTop: '-4px', zIndex: '2147483647', - pointerEvents: 'none', transition: 'transform 0.04s linear', - background: 'transparent', + const dot = document.createElement('div'); + dot.id = '__film_cursor'; + Object.assign(dot.style, { + position: 'fixed', left: '0', top: '0', width: '30px', height: '30px', + marginLeft: '-15px', marginTop: '-15px', borderRadius: '50%', + background: 'rgba(11,95,255,0.92)', border: '3px solid #ffffff', + 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(el); + document.documentElement.appendChild(dot); const ring = document.createElement('div'); Object.assign(ring.style, { - position: 'fixed', left: '0', top: '0', width: '34px', height: '34px', - marginLeft: '-17px', marginTop: '-17px', borderRadius: '50%', - border: '2px solid rgba(11,95,255,0.9)', zIndex: '2147483646', - pointerEvents: 'none', opacity: '0', transform: 'scale(0.4)', - transition: 'opacity 0.3s, transform 0.3s', + position: 'fixed', left: '0', top: '0', width: '30px', height: '30px', + marginLeft: '-15px', marginTop: '-15px', borderRadius: '50%', + border: '4px solid rgba(255,209,0,0.95)', boxShadow: '0 0 10px 2px rgba(255,209,0,0.6)', + zIndex: '2147483646', pointerEvents: 'none', opacity: '0', + transform: 'translate(960px,540px) scale(0.5)', }); document.documentElement.appendChild(ring); - window.__cursorEl = el; window.__cursorRing = ring; + window.__cursorEl = dot; window.__cursorRing = ring; window.__cursorMove = (x, y) => { - el.style.transform = 'translate(' + x + 'px,' + y + 'px)'; - ring.style.transform = 'translate(' + x + 'px,' + y + 'px) scale(0.4)'; + dot.style.transform = 'translate(' + x + 'px,' + y + 'px)'; }; window.__cursorPulse = (x, y) => { ring.style.transition = 'none'; - ring.style.opacity = '0.9'; - ring.style.transform = 'translate(' + x + 'px,' + y + 'px) scale(0.4)'; + ring.style.opacity = '0.95'; + ring.style.transform = 'translate(' + x + 'px,' + y + 'px) scale(0.5)'; 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.transform = 'translate(' + x + 'px,' + y + 'px) scale(1.4)'; + ring.style.transform = 'translate(' + x + 'px,' + y + 'px) scale(2.6)'; }); }; })(); diff --git a/marketing/product-film/scripts/lib/record.js b/marketing/product-film/scripts/lib/record.js index 0f80a22..8a2bbef 100644 --- a/marketing/product-film/scripts/lib/record.js +++ b/marketing/product-film/scripts/lib/record.js @@ -36,10 +36,12 @@ export async function probeSeconds(file) { } // 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 }); + const seek = startSec > 0 ? ["-ss", startSec.toFixed(3)] : []; await run(ffmpegPath, [ - "-y", "-i", webm, + "-y", ...seek, "-i", webm, "-r", String(fps), "-vsync", "cfr", "-c:v", "libx264", "-pix_fmt", "yuv420p", "-crf", "18", "-preset", "medium", "-an", mp4, diff --git a/marketing/product-film/scripts/scenes/storm-intel.mjs b/marketing/product-film/scripts/scenes/storm-intel.mjs index c1e8776..83c5363 100644 --- a/marketing/product-film/scripts/scenes/storm-intel.mjs +++ b/marketing/product-film/scripts/scenes/storm-intel.mjs @@ -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) => { - // Map tiles stream in — let them settle before interacting. - await page.waitForTimeout(2500); + const dwell = (ms) => page.waitForTimeout(ms); - // Enable pin-drop mode if there's a toggle (button text "Pin" / aria), else skip. - const pinToggle = page.getByRole("button", { name: /pin/i }).first(); + // 1) Wait for the map AND the storm data to actually finish loading (not a fixed sleep). + 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()) { 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 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. - const waitFetch = page - .waitForResponse((r) => /\/api\/storm-history/.test(r.url()), { timeout: 15000 }) - .catch(() => null); // tolerate cached/no-fetch; the pin still renders - await cursor.click(target.x, target.y, 800); - await waitFetch; - await page.waitForTimeout(2500); // hold on the populated panel + // 4) Widen the range to 12 Mo FIRST (more history) — do filters before selecting an + // event so we never filter the selected event back off the map. + const range12 = page.getByRole("button", { name: /^\s*12\s*mo\s*$/i }).first(); + if (await range12.count()) { + await cursor.clickLocator(range12, 700); + await dwell(2500); + } - // Pan the cursor to the populated history panel for emphasis. - const panel = page.getByText(/storm history/i).first(); - if (await panel.count()) await cursor.clickLocator(panel, 700); - await page.waitForTimeout(1500); + // 5) Click the storm EVENT card (e.g. "S. Plano — Flash Flood") so it draws on the map, + // then HOLD on it — this visualised polygon is the payoff of the scene. + // Event titles use a "Place — Type" format (em-dash); filter chips are bare words — + // 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); };