diff --git a/packages/engine/src/services/frameCapture.ts b/packages/engine/src/services/frameCapture.ts index 693565d056..9b367979f1 100644 --- a/packages/engine/src/services/frameCapture.ts +++ b/packages/engine/src/services/frameCapture.ts @@ -1309,6 +1309,7 @@ async function cssEffectRiskFrames( fps, totalFrames, totalFrames * MAX_RISK_FRAME_FRACTION, + { onProgress: session.options.onEffectScanProgress }, ); return live && withFramesAfterCallback(live, scan.callbackFrom, fps, totalFrames); } catch (error) { diff --git a/packages/engine/src/services/riskFrames.test.ts b/packages/engine/src/services/riskFrames.test.ts index d9856aef1c..5536098528 100644 --- a/packages/engine/src/services/riskFrames.test.ts +++ b/packages/engine/src/services/riskFrames.test.ts @@ -102,6 +102,52 @@ const frames = (live: Set | null) => [...(live ?? [])].sort((a, b) => a const range = (from: number, count: number) => Array.from({ length: count }, (_, i) => from + i); describe("scanCssEffectLiveFrames", () => { + it("stops after one chunk when the whole scan would hold back the first frame too long", async () => { + const seeks: number[] = []; + const page = makePage(between(0, 0)); + const evaluate = page.evaluate.bind(page); + page.evaluate = (async (...args: Parameters) => { + seeks.push((args[1] as number[]).length); + return evaluate(...args); + }) as Page["evaluate"]; + let clock = 0; + // 24 ms a frame, what a heavy page costs: 40,219 frames would take 16 minutes. + const now = () => (clock += 240 * 24); + + expect( + await scanCssEffectLiveFrames(page, 60, 40_219, 40_219, { budgetMs: 60_000, now }), + ).toBeNull(); + expect(seeks).toEqual([240]); + }); + + it("reports the frames scanned after each chunk", async () => { + const seen: Array<[number, number]> = []; + + await scanCssEffectLiveFrames(makePage(between(0, 0)), 10, 500, 500, { + onProgress: (scanned, total) => seen.push([scanned, total]), + }); + expect(seen).toEqual([ + [240, 500], + [480, 500], + [500, 500], + ]); + }); + + it("scans to the end when the whole scan fits the budget", async () => { + let clock = 0; + const now = () => (clock += 240); + + const live = await scanCssEffectLiveFrames( + makePage(between(2, 4)), + 10, + 1000, + 1000, + 60_000, + now, + ); + expect(frames(live)).toEqual(range(20, 20)); + }); + it("returns the frames where the effect element shows", async () => { const live = await scanCssEffectLiveFrames(makePage(between(2, 4)), 10, 100, 100); expect(frames(live)).toEqual(range(20, 20)); diff --git a/packages/engine/src/services/riskFrames.ts b/packages/engine/src/services/riskFrames.ts index 87d127753f..90e089b311 100644 --- a/packages/engine/src/services/riskFrames.ts +++ b/packages/engine/src/services/riskFrames.ts @@ -9,6 +9,9 @@ import { quantizeTimeToFrame } from "@hyperframes/core"; /** Frames per page round trip while scanning; a scan is one in-page loop per chunk. */ const SCAN_CHUNK_FRAMES = 240; +/** Longest the scan may hold back the first frame; above it the whole render captures by screenshot. */ +const SCAN_BUDGET_MS = 120_000; + /** * Above this share of risk frames the second browser and the switching cost outweigh the fast * frames, so the whole render captures by screenshot as it always has. @@ -26,53 +29,71 @@ const PIXELS_1080P = 1920 * 1080; /** Risk ranges closer than this many frames are joined, so capture never alternates modes. */ export const RISK_RANGE_HYSTERESIS_FRAMES = 12; +async function scanChunk(page: Page, times: number[]): Promise { + return page.evaluate(async (ts: number[]): Promise => { + type HfWindow = Window & { + __hf?: { seek?: (t: number) => void }; + __hfWaitForSeekCompletion?: () => Promise; + __hfCssFxTargets?: Element[]; + }; + const w = window as HfWindow; + const targets = w.__hfCssFxTargets ?? []; + // A hidden element still shows a descendant that sets `visibility: visible` itself. + const paints = (el: Element): boolean => + getComputedStyle(el).visibility !== "hidden" || + Array.from(el.querySelectorAll("*")).some( + (d) => getComputedStyle(d).visibility === "visible", + ); + const shows = (el: Element): boolean => { + for (let n: Element | null = el; n; n = n.parentElement) { + const cs = getComputedStyle(n); + if (cs.display === "none" || Number(cs.opacity) === 0) return false; + } + return paints(el); + }; + const out: number[] = []; + for (let i = 0; i < ts.length; i++) { + w.__hf?.seek?.(ts[i] as number); + await w.__hfWaitForSeekCompletion?.(); + if (targets.some(shows)) out.push(i); + } + return out; + }, times); +} + +interface ScanOptions { + budgetMs?: number; + now?: () => number; + onProgress?: (scannedFrames: number, totalFrames: number) => void; +} + /** * Frames where a recorded effect element shows. Seeks the page forward, so use a throwaway one. - * Null once more than `maxFrames` frames qualify. + * Null once more than `maxFrames` frames qualify, or once the whole scan would take over `budgetMs`. */ export async function scanCssEffectLiveFrames( page: Page, fps: number, totalFrames: number, maxFrames: number, + { budgetMs = SCAN_BUDGET_MS, now = () => performance.now(), onProgress }: ScanOptions = {}, ): Promise | null> { const live = new Set(); - for (let from = 0; from < totalFrames; from += SCAN_CHUNK_FRAMES) { + const started = now(); + for (let from = 0; from < totalFrames && live.size <= maxFrames; from += SCAN_CHUNK_FRAMES) { + const to = Math.min(totalFrames, from + SCAN_CHUNK_FRAMES); const times: number[] = []; - for (let f = from; f < Math.min(totalFrames, from + SCAN_CHUNK_FRAMES); f++) { - times.push(quantizeTimeToFrame(f / fps, fps)); + for (let f = from; f < to; f++) times.push(quantizeTimeToFrame(f / fps, fps)); + for (const i of await scanChunk(page, times)) live.add(from + i); + onProgress?.(to, totalFrames); + const projectedMs = ((now() - started) / to) * totalFrames; + if (to < totalFrames && projectedMs > budgetMs) { + console.error( + `[engine] fast capture: the effect scan of ${totalFrames} frames would take ` + + `${Math.round(projectedMs / 1000)} s, over its ${budgetMs / 1000} s budget`, + ); + return null; } - const hits = await page.evaluate(async (ts: number[]): Promise => { - type HfWindow = Window & { - __hf?: { seek?: (t: number) => void }; - __hfWaitForSeekCompletion?: () => Promise; - __hfCssFxTargets?: Element[]; - }; - const w = window as HfWindow; - const targets = w.__hfCssFxTargets ?? []; - // A hidden element still shows a descendant that sets `visibility: visible` itself. - const paints = (el: Element): boolean => - getComputedStyle(el).visibility !== "hidden" || - Array.from(el.querySelectorAll("*")).some( - (d) => getComputedStyle(d).visibility === "visible", - ); - const shows = (el: Element): boolean => { - for (let n: Element | null = el; n; n = n.parentElement) { - const cs = getComputedStyle(n); - if (cs.display === "none" || Number(cs.opacity) === 0) return false; - } - return paints(el); - }; - const out: number[] = []; - for (let i = 0; i < ts.length; i++) { - w.__hf?.seek?.(ts[i] as number); - await w.__hfWaitForSeekCompletion?.(); - if (targets.some(shows)) out.push(i); - } - return out; - }, times); - for (const i of hits) live.add(from + i); - if (live.size > maxFrames) break; } return live.size > maxFrames ? null : live; } diff --git a/packages/engine/src/types.ts b/packages/engine/src/types.ts index 606f2bbec9..a4b2436744 100644 --- a/packages/engine/src/types.ts +++ b/packages/engine/src/types.ts @@ -246,6 +246,7 @@ export interface CaptureOptions { */ deVerifySamples?: number; deFrameFallback?: boolean; + onEffectScanProgress?: (scannedFrames: number, totalFrames: number) => void; } export interface CaptureVideoMetadataHint { diff --git a/packages/producer/src/services/renderOrchestrator.ts b/packages/producer/src/services/renderOrchestrator.ts index 9d4abc0ba1..2b815a120a 100644 --- a/packages/producer/src/services/renderOrchestrator.ts +++ b/packages/producer/src/services/renderOrchestrator.ts @@ -3683,6 +3683,15 @@ async function executeRenderPipeline(input: { // sample frame indices from this so they land inside the drained range. compositionDurationSeconds: job.duration, deFrameFallback: true, + // A line per scanned chunk, so a long effect scan reads as preparing, not as a stall. + onEffectScanProgress: (scanned, total) => + updateJobStatus( + job, + "rendering", + `Checking effects, ${scanned} of ${total} frames`, + 25, + onProgress, + ), requiresWebGpu: compositionRequiresWebGpu(compiled.html), // Live route for Chrome memory (spec §5 Phase −1). The aggregate route // through CapturePerfSummary only exists on success; a mid-capture