diff --git a/packages/cli/src/commands/render.test.ts b/packages/cli/src/commands/render.test.ts index 526d1c8ea2e..dc1e606a444 100644 --- a/packages/cli/src/commands/render.test.ts +++ b/packages/cli/src/commands/render.test.ts @@ -2370,6 +2370,9 @@ describe("normalizeStageCode", () => { expect(normalizeStageCode("Encoding frame 600/600")).toBe("encoding_video"); expect(normalizeStageCode("Encoding frame 12/90")).toBe("encoding_video"); expect(normalizeStageCode("Capturing frame 120/600 (6 workers)")).toBe("capturing_frame"); + expect(normalizeStageCode("Checking effects, 240 of 240 frames")).toBe( + normalizeStageCode("Checking effects, 12 of 90 frames"), + ); expect(normalizeStageCode("Streaming frame 3/40 (segment 1/2, 2 workers)")).toBe( "streaming_frame", ); diff --git a/packages/cli/src/commands/render.ts b/packages/cli/src/commands/render.ts index e21c8514f32..9e7381afaee 100644 --- a/packages/cli/src/commands/render.ts +++ b/packages/cli/src/commands/render.ts @@ -1854,7 +1854,7 @@ const KNOWN_STAGE_CODES: Readonly> = { }; // Live counts in a progress label ("Capturing frame 120/600 (6 workers)") would make a code per render. -const STAGE_COUNTS = /\([^)]*\)|\d+\/\d+/g; +const STAGE_COUNTS = /\([^)]*\)|\d+\/\d+|\d+ of \d+/g; export function normalizeStageCode(stage: string): string { const base = stage.replace(STAGE_COUNTS, "").trim(); diff --git a/packages/engine/src/services/frameCapture.ts b/packages/engine/src/services/frameCapture.ts index a876cd93acc..4011d7052fb 100644 --- a/packages/engine/src/services/frameCapture.ts +++ b/packages/engine/src/services/frameCapture.ts @@ -1361,7 +1361,7 @@ async function cssEffectRiskFrames( fps, totalFrames, totalFrames * MAX_RISK_FRAME_FRACTION, - { onProgress: session.options.onEffectScanProgress }, + { onProgress: session.options.onEffectScanProgress, onEnd: session.options.onEffectScanEnd }, ); 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 b569bf2987a..d86c8aa05a1 100644 --- a/packages/engine/src/services/riskFrames.test.ts +++ b/packages/engine/src/services/riskFrames.test.ts @@ -140,6 +140,28 @@ describe("scanCssEffectLiveFrames", () => { } }); + it("says the scan ended when it finishes, stops early over budget, or the page fails", async () => { + let ended = 0; + const onEnd = () => ended++; + await scanCssEffectLiveFrames(makePage(between(0, 0)), 10, 500, 500, { onEnd }); + expect(ended).toBe(1); + + let clock = 0; + const stoppedEarly = await scanCssEffectLiveFrames(makePage(between(0, 0)), 10, 5_000, 5_000, { + budgetMs: 1_000, + now: () => (clock += 500), + onEnd, + }); + expect(stoppedEarly).toBeNull(); + expect(ended).toBe(2); + + const failing = { evaluate: () => Promise.reject(new Error("page gone")) } as unknown as Page; + await expect(scanCssEffectLiveFrames(failing, 10, 500, 500, { onEnd })).rejects.toThrow( + "page gone", + ); + expect(ended).toBe(3); + }); + it("reports the frames scanned after each chunk", async () => { const seen: Array<[number, number]> = []; diff --git a/packages/engine/src/services/riskFrames.ts b/packages/engine/src/services/riskFrames.ts index 4c2fc39814a..6815a09d3d7 100644 --- a/packages/engine/src/services/riskFrames.ts +++ b/packages/engine/src/services/riskFrames.ts @@ -82,6 +82,7 @@ interface ScanOptions { budgetMs?: number; now?: () => number; onProgress?: (scannedFrames: number, totalFrames: number) => void; + onEnd?: () => void; } /** @@ -93,7 +94,21 @@ export async function scanCssEffectLiveFrames( fps: number, totalFrames: number, maxFrames: number, - { budgetMs = SCAN_BUDGET_MS, now = () => performance.now(), onProgress }: ScanOptions = {}, + options: ScanOptions = {}, +): Promise | null> { + try { + return await scanLiveFrames(page, fps, totalFrames, maxFrames, options); + } finally { + options.onEnd?.(); + } +} + +async function scanLiveFrames( + page: Page, + fps: number, + totalFrames: number, + maxFrames: number, + { budgetMs = SCAN_BUDGET_MS, now = () => performance.now(), onProgress }: ScanOptions, ): Promise | null> { const live = new Set(); const started = now(); diff --git a/packages/engine/src/types.ts b/packages/engine/src/types.ts index e8113395610..c2fb8aedbd0 100644 --- a/packages/engine/src/types.ts +++ b/packages/engine/src/types.ts @@ -262,6 +262,7 @@ export interface CaptureOptions { deVerifySamples?: number; deFrameFallback?: boolean; onEffectScanProgress?: (scannedFrames: number, totalFrames: number) => void; + onEffectScanEnd?: () => void; } export interface CaptureVideoMetadataHint { diff --git a/packages/producer/src/services/renderOrchestrator.pageNeverReady.test.ts b/packages/producer/src/services/renderOrchestrator.pageNeverReady.test.ts index e3c5343a452..0877f7ba38b 100644 --- a/packages/producer/src/services/renderOrchestrator.pageNeverReady.test.ts +++ b/packages/producer/src/services/renderOrchestrator.pageNeverReady.test.ts @@ -2,14 +2,14 @@ import { afterEach, beforeEach, expect, it, vi } from "vitest"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { resolveConfig } from "@hyperframes/engine"; +import { type CaptureOptions, resolveConfig } from "@hyperframes/engine"; import { CaptureStageError } from "./render/captureStageError.js"; import { extractSafeRenderErrorMetadata } from "../server.js"; import { createRenderJob, executeRenderJob } from "./renderOrchestrator.js"; // Entry: executeRenderJob on the streaming route; calibration and the streaming stage are scripted. const { state } = vi.hoisted(() => ({ - state: { calibrationEvaluateTimedOut: false, framesBeforeFailure: 0 }, + state: { calibrationEvaluateTimedOut: false, framesBeforeFailure: 0, effectScanRan: false }, })); vi.mock("@hyperframes/engine", async (importOriginal) => ({ @@ -90,8 +90,16 @@ vi.mock("./render/captureCost.js", async (importOriginal) => ({ })); vi.mock("./render/stages/captureStreamingStage.js", async (importOriginal) => ({ ...(await importOriginal()), - runCaptureStreamingStage: async (input: { job: { framesRendered?: number } }) => { + runCaptureStreamingStage: async (input: { + job: { framesRendered?: number }; + buildCaptureOptions: () => CaptureOptions; + }) => { input.job.framesRendered = state.framesBeforeFailure; + if (state.effectScanRan) { + const options = input.buildCaptureOptions(); + options.onEffectScanProgress?.(240, 240); + options.onEffectScanEnd?.(); + } throw new CaptureStageError({ cause: new Error(EVALUATE_TIMEOUT), browserConsole: [] }); }, })); @@ -102,6 +110,7 @@ const dirs: string[] = []; beforeEach(() => { state.calibrationEvaluateTimedOut = false; state.framesBeforeFailure = 0; + state.effectScanRan = false; vi.stubEnv("HF_SEGMENTED_CAPTURE", "false"); }); afterEach(() => { @@ -109,7 +118,7 @@ afterEach(() => { for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); }); -function render(): Promise { +function render(config: { lowMemoryMode?: boolean } = {}): Promise { const root = mkdtempSync(join(tmpdir(), "hf-page-never-ready-")); dirs.push(root); writeFileSync(join(root, "index.html"), "
"); @@ -117,12 +126,35 @@ function render(): Promise { fps: 30, quality: "standard", hdrMode: "force-sdr", - producerConfig: resolveConfig({ forceScreenshot: true, browserGpuMode: "software" }), + producerConfig: { + ...resolveConfig({ forceScreenshot: true, browserGpuMode: "software" }), + ...config, + }, logger: { info() {}, warn() {}, error() {}, debug() {} }, }); + lastJob = job; return executeRenderJob(job, root, join(root, "output.mp4")); } +let lastJob: ReturnType; + +it("names frame capture as the failed stage for a failure after the effect scan, before frame one", async () => { + state.effectScanRan = true; + await render().catch(() => undefined); + expect(lastJob.failedStage).toBe("Starting frame capture"); +}); + +it.each([ + ["after calibration", {}], + ["with no calibration, straight after the GPU check", { lowMemoryMode: true }], +])( + "names frame capture as the failed stage for a failure before frame one, %s", + async (_, config) => { + await render(config).catch(() => undefined); + expect(lastJob.failedStage).toBe("Starting frame capture"); + }, +); + it("fails once as non-retryable when calibration and capture both timed out with no frame", async () => { state.calibrationEvaluateTimedOut = true; const error = await render().then( diff --git a/packages/producer/src/services/renderOrchestrator.ts b/packages/producer/src/services/renderOrchestrator.ts index b382f190d7c..9dfc80dc88f 100644 --- a/packages/producer/src/services/renderOrchestrator.ts +++ b/packages/producer/src/services/renderOrchestrator.ts @@ -3689,9 +3689,12 @@ async function executeRenderPipeline(input: { // ── Stage 4: Frame capture ────────────────────────────────────────── const stage4Start = Date.now(); - updateJobStatus(job, "rendering", "Starting frame capture", 25, onProgress, { - code: "start_browsers", - }); + const EFFECT_SCAN_STAGE = "Checking effects"; + const startingFrameCapture = () => + updateJobStatus(job, "rendering", "Starting frame capture", 25, onProgress, { + code: "start_browsers", + }); + startingFrameCapture(); // Start file server (may already be running from duration discovery). // The page-side compositing stub is injected later (after hasHdrContent @@ -3730,6 +3733,7 @@ async function executeRenderPipeline(input: { chromePath: resolveHeadlessShellPath(cfg), browserTimeout: cfg.browserTimeout, }); + startingFrameCapture(); // Apply the software-GPU→screenshot clamp to the AUTHORITATIVE local // `captureForceScreenshot` (not just the observability copy) so all // downstream strategy + telemetry code reads the corrected value. @@ -3807,10 +3811,13 @@ async function executeRenderPipeline(input: { updateJobStatus( job, "rendering", - `Checking effects, ${scanned} of ${total} frames`, + `${EFFECT_SCAN_STAGE}, ${scanned} of ${total} frames`, 25, onProgress, ), + onEffectScanEnd: () => { + if (job.currentStage.startsWith(EFFECT_SCAN_STAGE)) startingFrameCapture(); + }, 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 @@ -4237,6 +4244,7 @@ async function executeRenderPipeline(input: { } finally { calibrationFrames?.release(); } + startingFrameCapture(); captureCalibration = outcome.calibration; calibrationEvaluateTimedOut = outcome.calibrationEvaluateTimedOut; captureForceScreenshot = outcome.forceScreenshot;