Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/cli/src/commands/render.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
);
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/commands/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1854,7 +1854,7 @@ const KNOWN_STAGE_CODES: Readonly<Record<string, string>> = {
};

// 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();
Expand Down
2 changes: 1 addition & 1 deletion packages/engine/src/services/frameCapture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
22 changes: 22 additions & 0 deletions packages/engine/src/services/riskFrames.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]> = [];

Expand Down
17 changes: 16 additions & 1 deletion packages/engine/src/services/riskFrames.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ interface ScanOptions {
budgetMs?: number;
now?: () => number;
onProgress?: (scannedFrames: number, totalFrames: number) => void;
onEnd?: () => void;
}

/**
Expand All @@ -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<Set<number> | 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<Set<number> | null> {
const live = new Set<number>();
const started = now();
Expand Down
1 change: 1 addition & 0 deletions packages/engine/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,7 @@ export interface CaptureOptions {
deVerifySamples?: number;
deFrameFallback?: boolean;
onEffectScanProgress?: (scannedFrames: number, totalFrames: number) => void;
onEffectScanEnd?: () => void;
}

export interface CaptureVideoMetadataHint {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) => ({
Expand Down Expand Up @@ -90,8 +90,16 @@ vi.mock("./render/captureCost.js", async (importOriginal) => ({
}));
vi.mock("./render/stages/captureStreamingStage.js", async (importOriginal) => ({
...(await importOriginal<typeof import("./render/stages/captureStreamingStage.js")>()),
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: [] });
},
}));
Expand All @@ -102,27 +110,51 @@ const dirs: string[] = [];
beforeEach(() => {
state.calibrationEvaluateTimedOut = false;
state.framesBeforeFailure = 0;
state.effectScanRan = false;
vi.stubEnv("HF_SEGMENTED_CAPTURE", "false");
});
afterEach(() => {
vi.unstubAllEnvs();
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
});

function render(): Promise<void> {
function render(config: { lowMemoryMode?: boolean } = {}): Promise<void> {
const root = mkdtempSync(join(tmpdir(), "hf-page-never-ready-"));
dirs.push(root);
writeFileSync(join(root, "index.html"), "<div></div>");
const job = createRenderJob({
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<typeof createRenderJob>;

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(
Expand Down
16 changes: 12 additions & 4 deletions packages/producer/src/services/renderOrchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -4237,6 +4244,7 @@ async function executeRenderPipeline(input: {
} finally {
calibrationFrames?.release();
}
startingFrameCapture();
captureCalibration = outcome.calibration;
calibrationEvaluateTimedOut = outcome.calibrationEvaluateTimedOut;
captureForceScreenshot = outcome.forceScreenshot;
Expand Down
Loading