Repository navigation
Description
Describe the bug
With segmented capture on (HF_SEGMENTED_CAPTURE=true, documented in docs/packages/cli.mdx, "Segmented capture"), a render on the drawElement path fails at the first frame of one flat colour:
drawElement capture failed at frame 465: suspect small frame (12998B < 20000B); fresh screenshot capture required
A flat 1920x1080 frame is a JPEG of about 13 KB, so it is under the 20,000-byte floor. The frame is valid. The one-pass render of the same project, on the same machine and version, meets the same frame. It captures it again, finds the bytes identical, accepts it and completes:
[WARN] [Render] drawElement blank-frame suspect; re-capturing {"frame":465,"bytes":12998,"floor":56346}
[INFO] [Render] drawElement small frame is deterministic; accepted {"frame":465,"bytes":12998}
So the two capture loops judge a small frame differently. The segmented one also never makes the "fresh screenshot capture" its message asks for: nothing in the segmented route catches the error, and the render exits 1.
Reproduced on 0.8.152 and 0.8.163 (the latest). The code below is unchanged on main (one commit ahead of v0.8.163, which touches none of these files).
Why, in the code (v0.8.163):
- The one pass (single-worker drawElement with worker encode) drains every frame through
createDrainFrameGuard(packages/producer/src/services/render/stages/captureStreamingStage.ts, line 320). It has no floor before 12 frames; after that, the floor is the larger of 20,000 bytes and 12 % of the rolling median. A frame under the floor is captured again withrecaptureDrawElementFrameForVerify. A byte-identical retry is accepted, and the floor drops to half that frame (lines 386-408). - The segmented capture's
captureSegmentFramescaptures each frame withcaptureFrameToBuffer(captureSegmentedStage.ts, lines 192-215 and 276). That reaches the serial guard incaptureFrameSurface(packages/engine/src/services/frameCapture.ts, lines 4038-4055). Its floor isMath.max(20000, median * 0.12)from the first frame on. It throwsDrawElementCaptureErroron any frame under the floor, with no second capture. Its comment expects the caller to "restart on a fresh screenshot page". - No caller in the segmented route does that.
runOneSegmentretries only a lost browser target (retryAfterTargetLoss, captureSegmentedStage.ts, lines 472-515). The orchestrator awaitsrunCaptureSegmentedStagewith notry/catch(renderOrchestrator.ts, lines 4477-4529). The streaming route has one aroundinvokeStreaming(), whereshouldRetryViaPinnedFallbacksends aDrawElementCaptureErrorto a screenshot retry (line 4634).replanSegmentedAfterFailurehandles exactly this failure:draw_element_capturekeeps segmenting and forces screenshots (capturePlan.ts, lines 220-242). But no caller reaches it with this error.
The floor sits only in the drawElement branch, so this shows where drawElement is chosen: Windows or macOS with a hardware GPU. Linux takes BeginFrame. Every run on this install also printed "Parallel drawElement capture stays off for this install (a previous render had to fall back)". On an install where the parallel router is on, a render may take another route and never reach this guard; HF_DE_SINGLE_MIN_FRAMES and the frame count above keep it on single-worker drawElement here.
Minimal reproduction
A folder repro holding only the index.html below (we rendered exactly that; a project from npx hyperframes init repro --non-interactive --example blank with its index.html replaced should behave the same). It is 40 s at 30 fps (1,200 frames, so HF_DE_SINGLE_MIN_FRAMES' default of 900 keeps drawElement on one worker). It shows a grid of 400 coloured cells, hides it at 15.5 s, and fades it back in from 16.5 s. Frames 465 to 495 are the flat background, #e3e3e3, and nothing else.
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=1920, height=1080" />
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/gsap.min.js"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { margin: 0; width: 1920px; height: 1080px; overflow: hidden; background: #e3e3e3; }
#root { width: 100%; height: 100%; background: #e3e3e3; }
#grid {
position: absolute; inset: 40px; display: grid; gap: 8px;
grid-template-columns: repeat(16, 1fr);
font-family: ui-monospace, Consolas, monospace; font-size: 22px;
}
#grid span { padding: 6px; color: #f4f4f5; }
</style>
</head>
<body>
<div id="root" data-composition-id="main" data-start="0" data-duration="40" data-width="1920" data-height="1080">
<div id="grid" class="clip" data-start="0" data-duration="40" data-track-index="0"></div>
</div>
<script>
// 400 coloured cells with text, so ordinary frames are large JPEGs.
const grid = document.getElementById("grid");
for (let i = 0; i < 400; i++) {
const s = document.createElement("span");
s.textContent = "cell " + i;
s.style.background = "hsl(" + ((i * 37) % 360) + " 60% 35%)";
grid.appendChild(s);
}
const tl = gsap.timeline({ paused: true });
// 15.5 to 16.5 s: only the flat background (frames 465 to 495), then the grid fades back in.
tl.set("#grid", { opacity: 1 }, 0);
tl.set("#grid", { opacity: 0 }, 15.5);
tl.to("#grid", { opacity: 1, duration: 1 }, 16.5);
window.__timelines["main"] = tl;
tl.seek(0);
</script>
</body>
</html>Steps to reproduce
- Set up the reproduction above.
- In
repro, run the segmented render:
HF_SEGMENTED_CAPTURE=true HF_SEGMENT_FRAMES=225 npx [email protected] render --quality draft -o out.mp4 - Run the same command without the two variables:
npx [email protected] render --quality draft -o out.mp4
Expected behavior
Both renders complete, and the segmented one keeps frame 465 as the one pass does.
Actual behavior
Step 2 exits 1 after about 12 s, at the first flat frame (lines trimmed to the relevant ones; renderJobId removed):
[hyperframes] browserGpuMode probe → hardware (WebGL renderer vendor="Google Inc. (AMD)" renderer="ANGLE (AMD, AMD Radeon(TM) 610M (0x0000164E) Direct3D11 vs_5_0 ps_5_0, D3D11)")
[INFO] [Render] Fast capture: single-worker drawElement streaming preferred over 5-worker screenshot capture (1200 frames >= 900; verified path, measured faster at every worker count). Set HF_DE_SINGLE_MIN_FRAMES=0 or --workers N to override.
[INFO] [Render:trace] {"phase":"capture_segmented","status":"start","elapsedMs":639,"stagePhase":"capturing","workerCount":1,"forceScreenshot":false,"totalFrames":1200,"framesCompleted":0,"captureMode":"drawelement","captureOperation":"drawElement"}
[BrowserManager] Browser launched (HeadlessChrome/152.0.7977.30, screenshot, gl=--use-gl=angle --use-angle=d3d11, headlessShell=true, platform=win32)
[INFO] [Render:trace] {"phase":"capture_segmented","status":"error","elapsedMs":12200,"durationMs":11561,"message":"drawElement capture failed at frame 465: suspect small frame (12998B < 20000B); fresh screenshot capture required","stagePhase":"capturing","workerCount":1,"forceScreenshot":false,"totalFrames":1200,"framesCompleted":465,"captureMode":"drawelement","captureOperation":"drawElement"}
✗ Render failed
drawElement capture failed at frame 465: suspect small frame (12998B < 20000B); fresh screenshot capture required
Step 3 completes:
[INFO] [Render] Fast capture: single-worker drawElement streaming preferred over 5-worker screenshot capture (1200 frames >= 900; verified path, measured faster at every worker count). Set HF_DE_SINGLE_MIN_FRAMES=0 or --workers N to override.
[INFO] [Render] drawElement self-verify passed {"frame":300,"psnrDb":33.4}
[WARN] [Render] drawElement blank-frame suspect; re-capturing {"frame":465,"bytes":12998,"floor":56346}
[INFO] [Render] drawElement small frame is deterministic; accepted {"frame":465,"bytes":12998}
[INFO] [Render] drawElement self-verify passed {"frame":600,"psnrDb":33.4}
[INFO] [Render] drawElement self-verify passed {"frame":900,"psnrDb":33.4}
[INFO] [Render] drawElement self-verify passed {"frame":1140,"psnrDb":33.4}
3.0 MB · 40.0s video · rendered in 20.6s
drawelement capture · hardware gpu · compile 0.3s · extract 0.0s · audio 0.0s · probe 0.0s · setup 0.5s · capture 19.6s · encode (during capture) 19.6s · assemble 0.1s
In its output, frames 465 to 495 are one flat colour (signalstats YMIN = YMAX = 211 on every one); frame 464 has Y from 30 to 248.
| version | segmented (step 2) | one pass (step 3) |
|---|---|---|
| 0.8.152 | exit 1 at frame 465, 12998B < 20000B |
exit 0, frame 465 accepted as deterministic |
| 0.8.163 | exit 1 at frame 465, 12998B < 20000B |
exit 0, frame 465 accepted as deterministic |
The same happens in a real two-minute composition: a scene that opens on its bare background before its content fades in. That frame was 12,999 bytes; the segmented render failed at it, and the one pass accepted it and two later small frames.
Environment
OS: Windows 11 (build 26200)
Node.js: v24.14.0
HyperFrames: 0.8.163 and 0.8.152 (code unchanged on main as of 2026-10-16)
Chrome: Chrome Headless Shell 152.0.7977.30
GPU: AMD Radeon 610M (integrated) through ANGLE, Direct3D 11Additional context
Request: one judgement of a small drawElement frame in both loops. In captureFrameSurface's guard, do what createDrainFrameGuard does before throwing: seek again, draw again, and keep a byte-identical small frame. A sketch against v0.8.163 (frameCapture.ts, lines 4041-4055; deAbsFloor and deAcceptedSmall would be two new optional CaptureSession fields beside deFrameSizes):
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);
- }
+ const floor = Math.max(session.deAbsFloor ?? 20000, median * 0.12);
+ if (screenshotBuffer.length < floor && !session.deAcceptedSmall?.equals(screenshotBuffer)) {
+ // As createDrainFrameGuard: a dropped paint record is intermittent, a flat frame repeats.
+ await prepareFrameForCapture(session, frameIndex, time, seekOptions, heldVideoTime);
+ // (re-issue the HeadlessExperimental.beginFrame above first when beginFrameTimeTicks > 0)
+ const retry = await captureDrawElementFrame(page, options.width, options.height,
+ options.format ?? "jpeg", options.quality ?? 80, session.beginFrameTimeTicks === 0);
+ if (!retry.equals(screenshotBuffer)) {
+ throw new DrawElementCaptureError(
+ frameIndex,
+ `suspect small frame (${screenshotBuffer.length}B < ${Math.round(floor)}B)`,
+ );
+ }
+ session.deAbsFloor = Math.min(session.deAbsFloor ?? 20000, Math.floor(screenshotBuffer.length / 2));
+ session.deAcceptedSmall = screenshotBuffer;
+ }
+ if (sizes.length >= 60) sizes.shift();
+ sizes.push(screenshotBuffer.length);That fixes the segmented route and gives the serial disk route the same judgement. A frame that differs on the second capture still throws as today.
Why not only the retry. The other fix is to catch DrawElementCaptureError and DrawElementVerificationError around runCaptureSegmentedStage, and apply replanAfterFailure(capturePlan, { kind: "draw_element_capture" }), as the streaming route does. That should happen as well, for frames that really are dropped. On its own, though, it would re-render every segment in screenshots because of one valid flat frame, and the output would no longer match the one pass. drawElement and screenshot frames differ: the self-verify above measures 33.4 dB between them. Also, computeSegmentPlanHash does not include the capture mode, so a --resume after such a fallback could join drawElement segments to screenshot segments in one video.
Activity
jekyll541 commentedon Oct 16, 2026
DocuAngel still not following
fix(engine): redraw a small drawElement frame before failing the seri…
fix(engine): redraw a small drawElement frame before failing the seri…