90 lines
3.6 KiB
TypeScript
90 lines
3.6 KiB
TypeScript
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<ScriptCtx> {
|
|
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<TimelineResult> {
|
|
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<void> {
|
|
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<HTMLElement>('[data-tutorial="ai-filters"] button')?.click();
|
|
});
|
|
}
|
|
await textarea.waitFor({ state: 'visible', timeout: 15000 });
|
|
await sleep(100);
|
|
}
|