import type { CDPSession, Page } from "playwright"; import { CoreError, fail } from "./errors.ts"; import { runRenderPool } from "./render-pool.ts"; export const FAST_RENDER_PLAYBACK_RATE = 4; /** Parallel export is independent of the accelerated capture switch. */ export function resolveFastRenderParallel(requested?: number, environment = process.env["FAST_RENDER-PARA"]): number { const value = requested ?? (environment?.trim() ? Number(environment) : 1); if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 1) { fail("INVALID_RENDER_OPTION", "FAST_RENDER-PARA / fastRenderParallel 必须是正整数"); } return value; } export function resolveFastRenderSpeed(requested?: number, environment = process.env.FAST_RENDER_SPEED): number { const value = requested ?? (environment === undefined || environment.trim() === "" ? FAST_RENDER_PLAYBACK_RATE : Number(environment)); if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) { fail("INVALID_RENDER_OPTION", "FAST_RENDER_SPEED / fastRenderSpeed 必须是有限正数"); } return value; } /** Explicit options override the process switch, including an explicit false. */ export function resolveFastRender(requested?: boolean, environment = process.env.FAST_RENDER): boolean { if (requested !== undefined) { if (typeof requested !== "boolean") fail("INVALID_RENDER_OPTION", "fastRender 必须是 boolean"); return requested; } const value = environment?.trim().toLowerCase() ?? ""; if (["", "0", "false", "off", "no"].includes(value)) return false; if (["1", "true", "on", "yes"].includes(value)) return true; fail("INVALID_RENDER_OPTION", "FAST_RENDER 必须是 1/0、true/false、on/off 或 yes/no"); } export interface FastCaptureRequest { /** Absolute first frame; callbacks also receive absolute frame indices. */ readonly startFrame?: number; readonly frameCount: number; readonly signal?: AbortSignal; /** Ordered CFR frames; missed compositor frames hold the preceding image. */ readonly onFrame: (frame: number, png: Uint8Array) => Promise; } export interface FastCaptureResult { readonly capturedFrames: number; readonly repeatedFrames: number; } /** Fixed absolute shards; callbacks from different pages may arrive out of order. */ export async function captureParallelFrames( instances: readonly { readonly captureFrames?: (request: FastCaptureRequest) => Promise }[], parallelism: number, request: FastCaptureRequest, ): Promise { const start = request.startFrame ?? 0; if (!Number.isSafeInteger(request.frameCount) || request.frameCount < 1 || !Number.isSafeInteger(start) || start < 0 || !Number.isSafeInteger(start + request.frameCount)) { fail("INVALID_RENDER_OPTION", "连续捕获必须提供有效的整数帧范围"); } const count = Math.min(request.frameCount, resolveFastRenderParallel(parallelism, "")); if (instances.length === 0) fail("INVALID_RENDER_OPTION", "连续捕获至少需要一个 DOM page"); const size = Math.floor(request.frameCount / count); const remainder = request.frameCount % count; let capturedFrames = 0; let repeatedFrames = 0; await runRenderPool(count, instances.length, async (index, worker, signal) => { const capture = instances[worker]?.captureFrames; if (capture === undefined) fail("INVALID_RENDER_OPTION", "当前 artifact 不支持连续捕获"); const result = await capture({ startFrame: start + index * size + Math.min(index, remainder), frameCount: size + (index < remainder ? 1 : 0), signal, onFrame: request.onFrame, }); capturedFrames += result.capturedFrames; repeatedFrames += result.repeatedFrames; }, request.signal); return { capturedFrames, repeatedFrames }; } export async function captureFastViewport(cdp: CDPSession): Promise<{ data: string }> { return cdp.send("Page.captureScreenshot", { format: "png", fromSurface: true, captureBeyondViewport: false, optimizeForSpeed: true, }); } /** Continuous compositor capture. Only the endpoints require a screenshot transaction. */ export async function captureFastSequence( page: Page, cdp: CDPSession, fps: number, playbackRate: number, request: FastCaptureRequest, ): Promise { const { frameCount, startFrame = 0, signal, onFrame } = request; if (!Number.isSafeInteger(frameCount) || frameCount < 1 || !Number.isSafeInteger(startFrame) || startFrame < 0 || !Number.isSafeInteger(startFrame + frameCount)) { fail("INVALID_RENDER_OPTION", "FAST_RENDER frameCount 必须是正整数,startFrame 必须是非负整数且范围不能溢出"); } const cancelled = () => { if (signal?.aborted) fail("RENDER_CANCELLED", "FAST_RENDER 已取消"); }; cancelled(); const bounded = (label: string, operation: Promise): Promise => { let timer: ReturnType; return Promise.race([operation, new Promise((_, reject) => { timer = setTimeout(() => reject(new CoreError("DOM_RUNTIME_TIMEOUT", `FAST_RENDER ${label} 超过 15000ms`)), 15_000); })]).finally(() => clearTimeout(timer)); }; const call = (method: string, argument?: unknown) => page.evaluate( ({ method, argument }) => (globalThis as any).__fourierDomTimeline[method](argument), { method, argument }, ); await bounded("activate page", page.bringToFront()); const startMilliseconds = startFrame / fps * 1000; await bounded("initialize frame", call("setTimeFast", startMilliseconds)); let previous: Uint8Array = Buffer.from((await bounded("first screenshot", captureFastViewport(cdp))).data, "base64"); await onFrame(startFrame, previous); cancelled(); if (frameCount === 1) return { capturedFrames: 1, repeatedFrames: 0 }; let cursor = 1; let capturedFrames = 1; let repeatedFrames = 0; let startedAt: number | undefined; let pending: { frame: number; png: Buffer } | undefined; let draining: Promise | undefined; let streamError: unknown; let accepting = true; const drain = () => { if (draining !== undefined) return; draining = (async () => { while (pending !== undefined) { const next = pending; pending = undefined; if (next.frame < cursor) continue; while (cursor < next.frame) { cancelled(); await onFrame(startFrame + cursor++, previous); repeatedFrames++; } cancelled(); await onFrame(startFrame + cursor++, next.png); previous = next.png; capturedFrames++; } })().catch((error) => { streamError = error; rejectAbort(error); }).finally(() => { draining = undefined; }); }; const listener = (event: { data: string; sessionId: number; metadata: { timestamp?: number }; }) => { const acknowledge = () => cdp.send("Page.screencastFrameAck", { sessionId: event.sessionId }) .catch((error) => { if (accepting) { streamError = error; rejectAbort(error); } }); if (!accepting || startedAt === undefined || streamError !== undefined) { void acknowledge(); return; } const timestamp = event.metadata.timestamp; if (timestamp === undefined || !Number.isFinite(timestamp)) { streamError = new CoreError("FAST_RENDER_TIMESTAMP_MISSING", "连续捕获缺少帧时间戳"); rejectAbort(streamError); void acknowledge(); return; } const frame = Math.floor((timestamp * 1000 - startedAt) * playbackRate * fps / 1000); if (frame < cursor || frame >= frameCount - 1) { void acknowledge(); return; } pending = { frame, png: Buffer.from(event.data, "base64") }; drain(); // Let Chromium's in-flight limit provide backpressure. Immediately ACKing // large PNGs can saturate the CDP transport and starve clock/control calls. void (draining ?? Promise.resolve()).then(acknowledge); }; let rejectAbort: (error: unknown) => void = () => {}; const aborted = new Promise((_, reject) => { rejectAbort = reject; }); void aborted.catch(() => undefined); const abort = () => rejectAbort(new CoreError("RENDER_CANCELLED", "FAST_RENDER 已取消")); signal?.addEventListener("abort", abort, { once: true }); cdp.on("Page.screencastFrame", listener); let timer: ReturnType | undefined; try { cancelled(); await bounded("start screencast", cdp.send("Page.startScreencast", { format: "png", everyNthFrame: 1 })); const durationMilliseconds = (frameCount - 1) / fps * 1000; const started = await bounded("start clock", call("startFast", { startMilliseconds, durationMilliseconds, playbackRate, })) as { startedAt: number }; startedAt = started.startedAt; timer = setTimeout(() => { void bounded("clock status", call("fastStatus")).then((status) => rejectAbort(new CoreError( "DOM_RUNTIME_TIMEOUT", "FAST_RENDER 连续捕获超时", { status }, )), rejectAbort); }, durationMilliseconds / playbackRate + 15_000); await Promise.race([call("waitFast"), aborted]); accepting = false; await bounded("stop screencast", cdp.send("Page.stopScreencast")); await bounded("frame writer", draining ?? Promise.resolve()); if (streamError !== undefined) throw streamError; cancelled(); // Endpoints are exact even when intermediate compositor frames were dropped. await bounded("last frame", call("setTimeFast", (startFrame + frameCount - 1) / fps * 1000)); const last = Buffer.from((await bounded("last screenshot", captureFastViewport(cdp))).data, "base64"); while (cursor < frameCount - 1) { cancelled(); await onFrame(startFrame + cursor++, previous); repeatedFrames++; } cancelled(); await onFrame(startFrame + frameCount - 1, last); return { capturedFrames: capturedFrames + 1, repeatedFrames }; } finally { accepting = false; clearTimeout(timer); signal?.removeEventListener("abort", abort); cdp.off("Page.screencastFrame", listener); await bounded("stop screencast cleanup", cdp.send("Page.stopScreencast")).catch(() => undefined); await bounded("stop clock cleanup", call("stopFast")).catch(() => undefined); await bounded("writer cleanup", draining ?? Promise.resolve()).catch(() => undefined); } }