import type { Page } from 'playwright'; import { DashboardRecorder } from './dashboard.js'; import { installCursor, installZoomWrapper, setAspectClass } from './dom.js'; import { sleep } from './motion.js'; import { dashboardUrl } from './routes.js'; import { runStoryboard, type RunnerResult } from './runner.js'; import type { ScriptCtx, Storyboard } from './script.js'; export type TimelineResult = RunnerResult; /** * Boot the dashboard, wait for the first map response, and inject the * recording chrome (cursor, zoom wrapper, caption layer). Also opens the * AI prompt textarea so the storyboard can begin typing immediately. */ export async function prepareTimeline( page: Page, storyboard: Storyboard ): Promise { const dashboard = new DashboardRecorder(page); const initialMapVersion = dashboard.getMapDataVersion(); await page.goto(dashboardUrl(storyboard), { 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 dashboard.waitForMapSettled(initialMapVersion, 15000); await sleep(400); await installZoomWrapper(page); await installCursor(page, storyboard.video.cursorStyle ?? 'arrow'); await setAspectClass(page, storyboard.video.aspect); // Park the mouse over the header bar, never the map: an idle pointer over // a polygon summons the hover card, and on cold-open ads that tooltip was // baked into frame 0 (and the poster/thumbnail). const ctx: ScriptCtx = { page, dashboard, cursor: { x: 24, y: 14 } }; await page.mouse.move(ctx.cursor.x, ctx.cursor.y); // Only pre-open the AI prompt when the storyboard actually types into it. // Opening it unconditionally grows the mobile bottom sheet (keyboard // avoidance) and steals the frame from the map on ads that never type. if (storyboardTypes(storyboard)) { await prepareAiBox(ctx); } await sleep(80); return ctx; } function storyboardTypes(storyboard: Storyboard): boolean { const all = [ ...(storyboard.pre ?? []), ...storyboard.cues.flatMap((cue) => [...(cue.during ?? []), ...(cue.tail ?? [])]), ...(storyboard.post ?? []), ]; return all.some((activity) => activity.kind === 'type'); } export async function runTimeline( ctx: ScriptCtx, storyboard: Storyboard ): Promise { return runStoryboard(ctx, storyboard); } /** * Open the AI prompt before the timed scene starts. This is preparation * work, not part of the storyboard, because waiting for the textarea to * appear has indeterminate duration. */ async function prepareAiBox(ctx: ScriptCtx): Promise { const { page } = ctx; const aiRoot = page.locator('[data-tutorial="ai-filters"]').first(); await aiRoot.waitFor({ state: 'visible', timeout: 15000 }); const textarea = page.locator('[data-tutorial="ai-filters"] textarea'); if (!(await textarea.isVisible().catch(() => false))) { const aiButton = aiRoot.locator('button').first(); await aiButton.waitFor({ state: 'visible', timeout: 8000 }); const btnBox = await aiButton.boundingBox(); if (btnBox) await page.mouse.click(btnBox.x + btnBox.width / 2, btnBox.y + btnBox.height / 2); } if (!(await textarea.isVisible().catch(() => false))) { await page.evaluate(() => { document.querySelector('[data-tutorial="ai-filters"] button')?.click(); }); } await textarea.waitFor({ state: 'visible', timeout: 15000 }); await sleep(100); }