fix(engine): redraw a small drawElement frame before failing the serial capture (#5748)

The serial guard in captureFrameSurface, which segmented capture reaches, threw on any frame under the
size floor. It now draws the frame once more and keeps a byte-identical redraw, as the streaming drain
guard does, so a flat frame no longer fails a segmented render.

Fixes #5685
This commit is contained in:
kevin
2026-10-24 00:28:06 +00:00
committed by GitHub
parent fb8aadad17
commit c478110be7
2 changed files with 131 additions and 15 deletions
@@ -146,13 +146,67 @@ describe("fresh screenshot fallback", () => {
);
it("rejects a suspect tiny frame without substituting a stale screenshot", async () => {
vi.mocked(captureDrawElementFrame).mockResolvedValueOnce(Buffer.alloc(100));
vi.mocked(captureDrawElementFrame)
.mockResolvedValueOnce(Buffer.alloc(100))
.mockResolvedValueOnce(Buffer.alloc(100, 1));
await expect(captureFrameToBuffer(await session(), 7, 7 / 30)).rejects.toThrow(
"suspect small frame",
);
expect(captureDrawElementFrame).toHaveBeenCalledTimes(2);
expect(pageScreenshotCapture).not.toHaveBeenCalled();
});
// GH #5685: segmented capture reaches only this guard. A flat frame is a small JPEG that redraws
// to the same bytes; the streaming drain guard keeps it, and this guard must keep it too.
describe("a small frame that redraws byte-identically", () => {
const flat = () => Buffer.alloc(13_000, 7);
afterEach(() => vi.mocked(captureDrawElementFrame).mockReset());
it("is kept, and the next identical frame is kept without a redraw", async () => {
vi.mocked(captureDrawElementFrame).mockImplementation(async () => flat());
const s = await session();
expect((await captureFrameToBuffer(s, 465, 465 / 30)).buffer.equals(flat())).toBe(true);
expect(captureDrawElementFrame).toHaveBeenCalledTimes(2);
expect((await captureFrameToBuffer(s, 466, 466 / 30)).buffer.equals(flat())).toBe(true);
expect(captureDrawElementFrame).toHaveBeenCalledTimes(3);
expect(pageScreenshotCapture).not.toHaveBeenCalled();
});
it("lowers the floor to half its size, so a similar flat frame is not redrawn", async () => {
const s = await session();
vi.mocked(captureDrawElementFrame).mockImplementation(async () => flat());
await captureFrameToBuffer(s, 465, 465 / 30);
expect(s.deAbsFloor).toBe(6_500);
vi.mocked(captureDrawElementFrame).mockClear();
vi.mocked(captureDrawElementFrame).mockResolvedValueOnce(Buffer.alloc(12_000, 3));
await captureFrameToBuffer(s, 466, 466 / 30);
expect(captureDrawElementFrame).toHaveBeenCalledTimes(1);
});
it("keeps a full-size redraw in place of a dropped small frame", async () => {
const full = Buffer.alloc(40_000, 9);
vi.mocked(captureDrawElementFrame)
.mockResolvedValueOnce(Buffer.alloc(100))
.mockResolvedValueOnce(full);
const s = await session();
expect((await captureFrameToBuffer(s, 7, 7 / 30)).buffer).toBe(full);
expect(s.deAcceptedSmall).toBeUndefined();
expect(s.deFrameSizes).toEqual([40_000]);
});
it("still throws under BeginFrame control, where a redraw reads the same snapshot", async () => {
vi.mocked(captureDrawElementFrame).mockImplementation(async () => flat());
const s = await session();
s.beginFrameTimeTicks = 1;
const send = vi.fn(async () => ({}));
Object.assign(s.page, {
createCDPSession: async () => ({ send, on: () => {}, off: () => {} }),
});
await expect(captureFrameToBuffer(s, 465, 465 / 30)).rejects.toThrow("suspect small frame");
expect(captureDrawElementFrame).toHaveBeenCalledTimes(1);
});
});
it("returns a normal drawElement frame unchanged", async () => {
const buffer = Buffer.alloc(30_000);
vi.mocked(captureDrawElementFrame).mockResolvedValueOnce(buffer);
+76 -14
View File
@@ -277,6 +277,12 @@ export interface CaptureSession {
* drawElement intermittently returns an anomalously small (blank) frame with no
* throw; a frame far below the running median is re-captured via screenshot. */
deFrameSizes?: number[];
/** Absolute floor of the serial drawElement size guard. Starts at 20000 bytes and drops to
* half of a small frame that redraws byte-identically. */
deAbsFloor?: number;
/** The last small drawElement frame that redrew byte-identically; an equal frame is kept
* without a redraw. */
deAcceptedSmall?: Buffer;
/** Last non-deduped encode result, reused for a static frame on the drawElement
* worker-encode path (mirrors `lastFrameBuffer` on the screenshot path). Only set
* when static-frame dedup is armed on the drawElement path. */
@@ -4064,6 +4070,70 @@ export function resolveSessionMotionBlur(session: CaptureSession): MotionBlurPla
return plan;
}
/**
* Size guard for a serial drawElement frame. A frame far below the rolling median may be a
* dropped paint record, which is intermittent, so the frame is drawn once more: a byte-identical
* redraw is real content (a flat or dark frame) and is kept, as the streaming drain guard in the
* producer keeps it. Segmented capture reaches only this guard, so it must judge a small frame
* the same way, or a render that passes in one pass fails when segmented (GH #5685). A redraw
* that differs and is still small throws, and the caller restarts on a fresh screenshot page.
*/
async function judgeSmallDrawElementFrame(
session: CaptureSession,
frameIndex: number,
buffer: Buffer,
redraw: () => Promise<Buffer>,
): Promise<Buffer> {
const sizes = (session.deFrameSizes ??= []);
const sorted = sizes.length >= 12 ? [...sizes].sort((a, b) => a - b) : null;
const median = sorted ? (sorted[sorted.length >> 1] ?? 0) : 0;
const floor = Math.max(session.deAbsFloor ?? 20000, median * 0.12);
let kept = buffer;
if (buffer.length < floor && !session.deAcceptedSmall?.equals(buffer)) {
const suspect = () =>
new DrawElementCaptureError(
frameIndex,
`suspect small frame (${buffer.length}B < ${Math.round(floor)}B)`,
);
// Under BeginFrame control the page paints only on the frame already sent for this index,
// so a second draw reads the same snapshot and cannot tell a drop from real content.
if (session.beginFrameTimeTicks > 0) throw suspect();
const retry = await redraw();
if (retry.equals(buffer)) {
// Halving the floor keeps a run of similar flat frames from redrawing every frame.
session.deAbsFloor = Math.min(session.deAbsFloor ?? 20000, Math.floor(buffer.length / 2));
session.deAcceptedSmall = buffer;
} else if (retry.length < floor) {
throw suspect();
} else {
kept = retry;
}
}
if (sizes.length >= 60) sizes.shift();
sizes.push(kept.length);
return kept;
}
/** Seek to `time` again and draw the frame once more, for {@link judgeSmallDrawElementFrame}. */
async function redrawDrawElementFrame(
session: CaptureSession,
frameIndex: number,
time: number,
seekOptions?: HfSeekOptions,
heldVideoTime?: number,
): Promise<Buffer> {
const { page, options } = session;
await prepareFrameForCapture(session, frameIndex, time, seekOptions, heldVideoTime);
return captureDrawElementFrame(
page,
options.width,
options.height,
options.format ?? "jpeg",
options.quality ?? 80,
true,
);
}
/** Frame timings shared by the single-capture and accumulation paths. */
interface CapturedSurface {
buffer: Buffer;
@@ -4150,21 +4220,13 @@ async function captureFrameSurface(
);
// A tiny JPEG may be a dropped paint record. Never screenshot this page:
// its injected canvas can still hold the preceding frame's bitmap.
// Restart on a fresh screenshot page even if this was a simple valid frame.
if ((options.format ?? "jpeg") !== "png" && process.env.HF_FORCE_DRAWELEMENT !== "1") {
const sizes = (session.deFrameSizes ??= []);
const sorted = sizes.length >= 12 ? [...sizes].sort((a, b) => a - b) : null;
const median = sorted ? (sorted[sorted.length >> 1] ?? 0) : 0;
const floor = Math.max(20000, median * 0.12);
if (screenshotBuffer.length < floor) {
throw new DrawElementCaptureError(
frameIndex,
`suspect small frame (${screenshotBuffer.length}B < ${Math.round(floor)}B)`,
);
} else {
if (sizes.length >= 60) sizes.shift();
sizes.push(screenshotBuffer.length);
}
screenshotBuffer = await judgeSmallDrawElementFrame(
session,
frameIndex,
screenshotBuffer,
() => redrawDrawElementFrame(session, frameIndex, time, seekOptions, heldVideoTime),
);
}
} catch (err) {
// Missing paint records/canvas state require a new screenshot page.