Skip to content

Segmented capture (HF_SEGMENTED_CAPTURE=true) fails the render on a flat drawElement frame that the one-pass render recaptures and accepts #5685

Closed

Description

@dorrogeray

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):

  1. 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 with recaptureDrawElementFrameForVerify. A byte-identical retry is accepted, and the floor drops to half that frame (lines 386-408).
  2. The segmented capture's captureSegmentFrames captures each frame with captureFrameToBuffer (captureSegmentedStage.ts, lines 192-215 and 276). That reaches the serial guard in captureFrameSurface (packages/engine/src/services/frameCapture.ts, lines 4038-4055). Its floor is Math.max(20000, median * 0.12) from the first frame on. It throws DrawElementCaptureError on any frame under the floor, with no second capture. Its comment expects the caller to "restart on a fresh screenshot page".
  3. No caller in the segmented route does that. runOneSegment retries only a lost browser target (retryAfterTargetLoss, captureSegmentedStage.ts, lines 472-515). The orchestrator awaits runCaptureSegmentedStage with no try/catch (renderOrchestrator.ts, lines 4477-4529). The streaming route has one around invokeStreaming(), where shouldRetryViaPinnedFallback sends a DrawElementCaptureError to a screenshot retry (line 4634). replanSegmentedAfterFailure handles exactly this failure: draw_element_capture keeps 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

  1. Set up the reproduction above.
  2. In repro, run the segmented render:
    HF_SEGMENTED_CAPTURE=true HF_SEGMENT_FRAMES=225 npx [email protected] render --quality draft -o out.mp4
  3. 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 11

Additional 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

  1. added
    bugSomething isn't working
    triage/needs-triageNeeds current-behavior, related-work, and scope evaluation.
    on Oct 16, 2026
  2. jekyll541 commented on Oct 16, 2026

    @jekyll541

    DocuAngel still not following

  3. added a commit that references this issue on Oct 17, 2026
    07d4b9c
  4. added
    triage/readyAccepted scope, current evidence, acceptance criteria, and validation path are recorded.
    and removed
    triage/needs-triageNeeds current-behavior, related-work, and scope evaluation.
    on Oct 23, 2026
  5. added a commit that references this issue on Oct 24, 2026
    c478110
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingtriage/readyAccepted scope, current evidence, acceptance criteria, and validation path are recorded.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions