mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 17:27:09 +00:00
fix(engine): a render stall names the step Chrome hung in and says what to do (#5450)
* fix(engine): a capture stall names the step Chrome stopped answering in * fix(engine): a stall on a motion-blurred frame names the seek or injection it hung in
This commit is contained in:
@@ -591,3 +591,47 @@ describe("computeStaticFrameSet's real page-side property matching (not a canned
|
||||
expect(result.nonSpatialOnlyFrameSet.has(0)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a stall on a motion-blurred frame names the step in progress", () => {
|
||||
it("names the frame seek, the injection and the restore seek, not the previous frame's step", async () => {
|
||||
const steps: string[] = [];
|
||||
const session = makeSession({
|
||||
captureStep: "capturing the frame",
|
||||
onBeforeCapture: async () => {
|
||||
steps.push(`inject:${session.captureStep}`);
|
||||
},
|
||||
});
|
||||
const page = globalThis as unknown as {
|
||||
window: { __hf: { seek: (time: number, options?: { subFrameDivisions?: number }) => void } };
|
||||
};
|
||||
page.window.__hf.seek = (_time, options) => {
|
||||
if (options?.subFrameDivisions === undefined) steps.push(`seek:${session.captureStep}`);
|
||||
};
|
||||
|
||||
await captureFrameToBuffer(session, 10, 10 / 30);
|
||||
|
||||
expect(steps[0]).toBe("seek:seeking the timeline");
|
||||
expect(steps[1]).toBe("inject:injecting video frames");
|
||||
expect(steps[steps.length - 1]).toBe("seek:seeking the timeline");
|
||||
});
|
||||
|
||||
it("names the seek and the injection on a frame without motion blur too", async () => {
|
||||
const steps: string[] = [];
|
||||
const session = makeSession({
|
||||
motionBlur: undefined,
|
||||
captureStep: "capturing the frame",
|
||||
onBeforeCapture: async () => {
|
||||
steps.push(`inject:${session.captureStep}`);
|
||||
},
|
||||
});
|
||||
const page = globalThis as unknown as { window: { __hf: { seek: () => void } } };
|
||||
page.window.__hf.seek = () => {
|
||||
steps.push(`seek:${session.captureStep}`);
|
||||
};
|
||||
|
||||
await captureFrameToBuffer(session, 10, 10 / 30);
|
||||
|
||||
expect(steps).toEqual(["seek:seeking the timeline", "inject:injecting video frames"]);
|
||||
expect(session.captureStep).toBe("capturing the frame");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -108,6 +108,16 @@ export type { CaptureOptions, CaptureResult, CaptureBufferResult, CapturePerfSum
|
||||
* both times shows its frame for `heldVideoTime`, so footage does not smear. */
|
||||
export type BeforeCaptureHook = (page: Page, time: number, heldVideoTime?: number) => Promise<void>;
|
||||
|
||||
/** The per-frame wait a capture is in, worded for the stall error a user reads. */
|
||||
type CaptureStep =
|
||||
| "seeking the timeline"
|
||||
| "decoding CSS background images"
|
||||
| "injecting video frames"
|
||||
| "waiting for page seek handlers"
|
||||
| "loading color-grading LUTs"
|
||||
| "compositing the page"
|
||||
| "capturing the frame";
|
||||
|
||||
export interface CaptureSession {
|
||||
browser: Browser;
|
||||
/** Exact ownership token for this browser acquisition. */
|
||||
@@ -298,6 +308,7 @@ export interface CaptureSession {
|
||||
* retries via screenshot.
|
||||
*/
|
||||
deFrameTimeouts?: number;
|
||||
captureStep?: CaptureStep;
|
||||
/**
|
||||
* drawElement init passed every gate but stopped before verification +
|
||||
* canvas injection: the session has no video-frame injector yet (probe
|
||||
@@ -2856,8 +2867,10 @@ async function prepareFrameForCapture(
|
||||
);
|
||||
|
||||
const seekStart = Date.now();
|
||||
session.captureStep = "seeking the timeline";
|
||||
const hasPendingComposite = await seekPageTimeline(page, quantizedTime, seekOptions);
|
||||
|
||||
session.captureStep = "decoding CSS background images";
|
||||
await decodeDynamicCssBackgroundImages(page);
|
||||
|
||||
const seekMs = Date.now() - seekStart;
|
||||
@@ -2867,9 +2880,12 @@ async function prepareFrameForCapture(
|
||||
// replacements for <video> elements.
|
||||
const beforeCaptureStart = Date.now();
|
||||
if (session.onBeforeCapture) {
|
||||
session.captureStep = "injecting video frames";
|
||||
await session.onBeforeCapture(page, quantizedTime, heldVideoTime);
|
||||
}
|
||||
session.captureStep = "waiting for page seek handlers";
|
||||
await waitForPendingSeekCompletion(page);
|
||||
session.captureStep = "loading color-grading LUTs";
|
||||
await page.evaluate(async () => {
|
||||
const runtime = (
|
||||
window as Window & {
|
||||
@@ -2889,6 +2905,7 @@ async function prepareFrameForCapture(
|
||||
session.captureMode !== "beginframe" &&
|
||||
session.captureMode !== "drawelement"
|
||||
) {
|
||||
session.captureStep = "compositing the page";
|
||||
await page.evaluate(async () => {
|
||||
const w = window as unknown as { __hf_page_composite_prepare?: () => Promise<boolean> };
|
||||
if (typeof w.__hf_page_composite_prepare === "function") {
|
||||
@@ -2909,6 +2926,7 @@ async function prepareFrameForCapture(
|
||||
});
|
||||
}
|
||||
|
||||
session.captureStep = "capturing the frame";
|
||||
return { quantizedTime, seekMs, beforeCaptureMs };
|
||||
}
|
||||
|
||||
@@ -4189,12 +4207,14 @@ async function captureAccumulatedFrame(
|
||||
const frameTime = quantizeSeekTime(absFrameIndex / fps, fps);
|
||||
|
||||
const eventfulSeekStart = Date.now();
|
||||
session.captureStep = "seeking the timeline";
|
||||
await seekPageTimeline(session.page, frameTime, undefined);
|
||||
const totals = { seekMs: Date.now() - eventfulSeekStart, beforeCaptureMs: 0, screenshotMs: 0 };
|
||||
if (session.onBeforeCapture) {
|
||||
// The injector copies a video's own style only when its frame changes: inject at frameTime
|
||||
// first so the held frame carries frameTime's style, not the first sample's.
|
||||
const injectStart = Date.now();
|
||||
session.captureStep = "injecting video frames";
|
||||
await session.onBeforeCapture(session.page, frameTime, frameTime);
|
||||
totals.beforeCaptureMs += Date.now() - injectStart;
|
||||
}
|
||||
@@ -4237,6 +4257,7 @@ async function captureAccumulatedFrame(
|
||||
}
|
||||
|
||||
const restoreSeekStart = Date.now();
|
||||
session.captureStep = "seeking the timeline";
|
||||
await seekPageTimeline(session.page, frameTime, { suppressEvents: true });
|
||||
totals.seekMs += Date.now() - restoreSeekStart;
|
||||
|
||||
@@ -4603,6 +4624,7 @@ export async function captureFramesBatchPipelined(
|
||||
const fps = fpsToNumber(options.fps);
|
||||
const quantized = times.map((t) => quantizeTimeToFrame(t, fps));
|
||||
|
||||
session.captureStep = "capturing the frame";
|
||||
const { encodeResults, failedAt, error } = await produceDrawElementFrameBatch(
|
||||
page,
|
||||
quantized,
|
||||
|
||||
@@ -203,7 +203,7 @@ async function captureSegmentFrames(
|
||||
ctx.captureFrame(ctx.session, i, time),
|
||||
ctx.stallTimeoutMs - (Date.now() - lastProgressAt),
|
||||
{
|
||||
captureMode: ctx.session.captureMode,
|
||||
session: ctx.session,
|
||||
frameIndex: i,
|
||||
totalFrames: ctx.totalFrames,
|
||||
stallTimeoutMs: ctx.stallTimeoutMs,
|
||||
|
||||
@@ -61,11 +61,12 @@ mock.module("@hyperframes/engine", () => ({
|
||||
}
|
||||
return [];
|
||||
},
|
||||
captureFrameToBuffer: async () => {
|
||||
captureFrameToBuffer: async (session: { captureStep?: string }) => {
|
||||
if (failCaptureFrameToBuffer) {
|
||||
throw new Error("captureFrameToBuffer failed");
|
||||
}
|
||||
if (hangSequentialUntilStall) {
|
||||
session.captureStep = "capturing the frame";
|
||||
return new Promise(() => {});
|
||||
}
|
||||
return { buffer: Buffer.from("frame"), captureTimeMs: 1 };
|
||||
@@ -600,6 +601,7 @@ describe("runCaptureStreamingStage", () => {
|
||||
expect(caught).toBeInstanceOf(Error);
|
||||
expect((caught as Error & { cause?: Error }).cause?.name).toBe("SequentialCaptureStallError");
|
||||
expect((caught as Error).message).toContain("Sequential screenshot capture stalled");
|
||||
expect((caught as Error).message).toContain("(stuck at frame 0/10 while capturing the frame)");
|
||||
expect((caught as Error).message).not.toContain("drawElement");
|
||||
expect(closeCaptureSession).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -93,6 +93,9 @@ import type { SdrStreamingCapturePlan } from "../capturePlan.js";
|
||||
*/
|
||||
const DEFAULT_CAPTURE_STALL_MS = 60_000;
|
||||
const DE_STALL_POLL_MS = 5_000;
|
||||
const STALL_ADVICE =
|
||||
"Chrome stopped answering. If another app is keeping the GPU or CPU busy " +
|
||||
"(a 3D render, a game, another export), let it finish or render one job at a time, then retry.";
|
||||
|
||||
/**
|
||||
* The parallel no-progress watchdog tripped. Typed like the sequential stall
|
||||
@@ -116,12 +119,12 @@ class ParallelCaptureStallError extends Error {
|
||||
}) {
|
||||
const phases = args.phaseSummary ? ` Last worker phases: ${args.phaseSummary}.` : "";
|
||||
super(
|
||||
args.variant === "drain"
|
||||
(args.variant === "drain"
|
||||
? `[Render] Parallel ${args.captureLabel} capture stalled: no frame progress for ` +
|
||||
`${args.stallTimeoutMs}ms (stuck at ${args.lastCapturedFrames}/${args.totalFrames}).${phases}`
|
||||
`${args.stallTimeoutMs}ms (stuck at ${args.lastCapturedFrames}/${args.totalFrames}).${phases}`
|
||||
: `[Render] Parallel ${args.captureLabel} capture stalled after ${args.stallTimeoutMs}ms ` +
|
||||
`with no frame progress (last frame ${args.lastCapturedFrames}/${args.totalFrames}); ` +
|
||||
`retrying on a fresh screenshot session.${phases}`,
|
||||
`with no frame progress (last frame ${args.lastCapturedFrames}/${args.totalFrames}); ` +
|
||||
`retrying on a fresh screenshot session.${phases}`) + ` ${STALL_ADVICE}`,
|
||||
);
|
||||
this.name = "ParallelCaptureStallError";
|
||||
this.variant = args.variant;
|
||||
@@ -160,21 +163,25 @@ function captureModeLabel(mode: CaptureSession["captureMode"]): string {
|
||||
return "screenshot";
|
||||
}
|
||||
|
||||
type StalledSession = Pick<CaptureSession, "captureMode" | "captureStep">;
|
||||
|
||||
class SequentialCaptureStallError extends Error {
|
||||
readonly captureMode: CaptureSession["captureMode"];
|
||||
|
||||
constructor(
|
||||
captureMode: CaptureSession["captureMode"],
|
||||
session: StalledSession,
|
||||
stallTimeoutMs: number,
|
||||
frameIndex: number,
|
||||
totalFrames: number,
|
||||
) {
|
||||
const step = session.captureStep ? ` while ${session.captureStep}` : "";
|
||||
super(
|
||||
`[Render] Sequential ${captureModeLabel(captureMode)} capture stalled: ` +
|
||||
`no frame progress for ${stallTimeoutMs}ms (stuck at frame ${frameIndex}/${totalFrames}).`,
|
||||
`[Render] Sequential ${captureModeLabel(session.captureMode)} capture stalled: ` +
|
||||
`no frame progress for ${stallTimeoutMs}ms (stuck at frame ${frameIndex}/${totalFrames}${step}). ` +
|
||||
STALL_ADVICE,
|
||||
);
|
||||
this.name = "SequentialCaptureStallError";
|
||||
this.captureMode = captureMode;
|
||||
this.captureMode = session.captureMode;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,7 +189,7 @@ export function raceAgainstStall<T>(
|
||||
promise: Promise<T>,
|
||||
deadlineMs: number,
|
||||
input: {
|
||||
captureMode: CaptureSession["captureMode"];
|
||||
session: StalledSession;
|
||||
frameIndex: number;
|
||||
totalFrames: number;
|
||||
stallTimeoutMs: number;
|
||||
@@ -195,11 +202,11 @@ export function raceAgainstStall<T>(
|
||||
reject(
|
||||
signal?.aborted
|
||||
? new Error(
|
||||
`[Render] Sequential ${captureModeLabel(input.captureMode)} capture aborted ` +
|
||||
`[Render] Sequential ${captureModeLabel(input.session.captureMode)} capture aborted ` +
|
||||
`while a capture call was in flight (frame ${input.frameIndex}/${input.totalFrames}).`,
|
||||
)
|
||||
: new SequentialCaptureStallError(
|
||||
input.captureMode,
|
||||
input.session,
|
||||
input.stallTimeoutMs,
|
||||
input.frameIndex,
|
||||
input.totalFrames,
|
||||
@@ -473,7 +480,7 @@ async function runWorkerEncodePipelineLoop(
|
||||
raceAgainstStall(
|
||||
promise,
|
||||
stallTimeoutMs - (Date.now() - lastProgressAt),
|
||||
{ captureMode: session.captureMode, frameIndex: idx, totalFrames, stallTimeoutMs },
|
||||
{ session, frameIndex: idx, totalFrames, stallTimeoutMs },
|
||||
abortSignal,
|
||||
);
|
||||
|
||||
@@ -953,7 +960,7 @@ export async function runCaptureStreamingStage(
|
||||
const { buffer } = await raceAgainstStall(
|
||||
captureFrameToBuffer(session, i, time),
|
||||
stallTimeoutMs - (Date.now() - lastProgressAt),
|
||||
{ captureMode: session.captureMode, frameIndex: i, totalFrames, stallTimeoutMs },
|
||||
{ session, frameIndex: i, totalFrames, stallTimeoutMs },
|
||||
abortSignal,
|
||||
);
|
||||
await reorderBuffer.waitForFrame(i);
|
||||
|
||||
Reference in New Issue
Block a user