mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 17:27:09 +00:00
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:
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user