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:
@@ -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);
|
||||||
|
|||||||
@@ -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)';
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -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);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user