Skip to content

fix(producer): fall back to screenshot capture on drawElement canvas-not-initialized - #3480

Open
miga-heygen wants to merge 2 commits into
mainfrom
fix/draw-element-canvas-fallback
Open

fix(producer): fall back to screenshot capture on drawElement canvas-not-initialized#3480
miga-heygen wants to merge 2 commits into
mainfrom
fix/draw-element-canvas-fallback

Conversation

@miga-heygen

Copy link
Copy Markdown
Contributor

Summary

  • Extend the existing drawElement fallback path to catch canvas not initialized errors alongside the existing no cached paint record case
  • Instead of hard-failing the render, gracefully falls back to screenshot capture with a diagnostic
  • Matches the documented behavior of --experimental-fast-capture

Closes #3423

Author: Miguel Ángel miguel.sierra@heygen.com
Co-Authored-By: Miga noreply@anthropic.com

…not-initialized

The fast-capture drawElement path only special-cased the "No cached
paint record" error to trigger a per-frame screenshot fallback; every
other error (including "drawElement canvas not initialized", seen at
frame 0 on some macOS/Chrome combinations) was rethrown, hard-failing
the whole render even though the docs promise automatic fallback on
incompatible compositions.

Extend the existing fallback branch (in both captureFrameCore and
captureFrameToBufferPipelined) to also catch canvas-not-initialized
errors via a shared isRecoverableDrawElementError predicate, with a
diagnostic message identifying which case triggered the fallback.

Closes #3423

Co-Authored-By: Miga <noreply@anthropic.com>

@miguel-heygen miguel-heygen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: REQUEST_CHANGES

Reasoning: The new classifier broadens a failure string that does not uniquely mean “capture canvas missing.” In drawElementService.ts, both draw paths throw drawElement canvas not initialized when !canvas || !root (lines 334 and 775). isCanvasNotInitializedError() therefore converts a missing [data-composition-id] root into a successful screenshot fallback too. That page has no composition root to capture, so a broken, navigation, or initialization state can now produce a blank or unrelated screenshot and continue the render instead of failing loudly.

Please split the in-page errors (missing canvas versus missing composition root), recover only the former, and add regressions for both the serial and pipelined paths proving canvas-missing falls back while root-missing still rejects. No merge action.

— Magi

@terencecho terencecho left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Concur with @magi-bot CHANGES_REQUESTED at e4e97ed7. The !canvas || !root conflation is real and unshipped-with. Adding orthogonal concerns for the fix scope:

1. Third emit site is unaudited. @magi-bot named drawElementService.ts:334 (serial) and :775 (pipelined). There's a third: drawElementService.ts:1002 (produceDrawElementFrameBatch's inner returning { failedAt: 0, error: "drawElement canvas not initialized" }). The batch caller wraps that at drawElementService.ts:1166 as "drawElement batch produce failed at frame N: drawElement canvas not initialized", and isCanvasNotInitializedError's msg.includes("canvas not initialized") (frameCapture.ts:3358) matches the wrapped form too. Batch-path root-missing silently sweeps into the fallback the same way. Any fix scoped to @magi-bot's two sites leaves this one exposed.

2. .includes("canvas not initialized") is a substring-match footgun. Any future error emitting that phrase ("webgl canvas not initialized", "OffscreenCanvas not initialized", a re-wrapped upstream error) gets swallowed. Prefer an error-code discriminant attached at throw time in drawElementService.ts:334/775/1002 (e.g. err.code === "HF_DRAWELEMENT_CANVAS_NOT_INIT" distinct from HF_DRAWELEMENT_ROOT_MISSING). This composes with @magi-bot's ask — the fix isn't just splitting message strings but splitting them AS coded discriminants.

3. Cross-PR seam with #3429 is the operationally-scariest failure mode. #3429 asserts artifact nb_frames matches expected before commit. #3480's fallback increments session.capturePerf.frames (frameCapture.ts:3529) AND produces a screenshotBuffer for every fallback frame — so per-frame count is preserved. If !root is deterministic on the host (broken composition, navigation error, injection ran before root mounted), every frame falls through to pageScreenshotCapture (screenshotService.ts:223 — a viewport-clip capture with NO [data-composition-id] validation) — #3429's frame-count assert passes, the render "commits successfully," and users receive N wrong-pixel screenshots as a completed render. #3429 catches range/count errors, #3480 opens a wider silent-content-corruption path that #3429 is structurally blind to. Recommend: either #3480 adds a fallback-ratio circuit breaker (e.g. hard-fail if deNcprFallbacks / frames > 0.2) OR #3429 also asserts on deNcprFallbacks / frames.

4. Stale comment. frameCapture.ts:3757-3766 documents that "No cached paint record" fallback captures the LAST drawn frame, not this one, and that's why recaptureDrawElementFrameForVerify bypasses fallback. The same argument applies verbatim to canvas-not-initialized (which is worse — no LAST frame exists at frame 0). Comment should name canvas-not-init as a second fallback source that must NOT be trusted for the verify path — otherwise a future refactor may re-introduce it and mask what the doc warned about.

5. Zero unit tests. No test for isCanvasNotInitializedError (positive at both 334-shape and 1166-wrapped-shape; negative on unrelated errors), none for isRecoverableDrawElementError composing both branches, none that root-missing HARD-FAILS instead of falling back — the regression this whole change needs.

— Review by tai (pr-review)

@vanceingalls vanceingalls left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

R2 delta @ 4ca135d (vs prior R1 e4e97ed) — verifying my three orthogonal items + the cross-PR seam I flagged in-thread, and reading @magi-bot's block for resolution.

Substantively addressed:

  • Third emit site (my #1): resolved. drawElementService.ts:1032-1035 (produceDrawElementFrameBatch's inner return) now emits the code, and captureFramesBatchPipelined (frameCapture.ts:3874-3894) branches on isRecoverableDrawElementError explicitly before falling back — batch-path recoverables no longer rely on incidental retry-then-catch, and session.deNcprFallbacks is incremented per fallback frame on this path too, so the counter is now accurate across serial + pipelined + batch.
  • Substring-match footgun (my #2): resolved with a well-documented compromise. DE_CANVAS_NOT_INITIALIZED_CODE = "HF_DE_CANVAS_NOT_INITIALIZED" is baked into the message at all three throw/return sites; isCanvasNotInitializedError now matches on that code, not the free-text tail. The module-header comment (drawElementService.ts:19-41) explains the constraint: page.evaluate boundary reconstructs errors as plain Error on the Node side (no subclass, no .code, generic .name), so a code-in-message is the closest available substitute. isNoCachedPaintRecordError also narrowed from "No cached paint record" to "No cached paint record for element" — the native phrase is fully anchored. Composes cleanly with the batch-wrapped form ("batch produce failed at frame N: : ...") because the code survives wrapping.

Partially addressed:

  • Cross-PR seam with #3429 (my #3): the delta chose observability over a circuit breaker — DE_FALLBACK_RATIO_WARN_THRESHOLD = 0.5 triggers a console.warn in getCapturePerfSummary (frameCapture.ts:4258-4283) when deNcprFallbacks / frames > 0.5, and the comment explicitly justifies not hard-gating ("aborting a render that reliably succeeds via the well-tested screenshot path is a worse outcome than a slow-but-correct render") and points to follow-up #3482 for the hard-gate decision. This is a defensible design tradeoff, but it doesn't close the silent-content-corruption path I flagged: sub-50% fallback ratio still ships those frames without validation, and >50% only logs — nothing prevents the render from committing. If #3482 is genuinely on-deck, the residual exposure is bounded; if it slips, the seam stays open. Flag not blocker on my side, but noting the follow-up carries the weight of the fix.

Not addressed:

  • My #4 (stale comment at recaptureDrawElementFrameForVerify, now at frameCapture.ts:3773-3784): still names only "No cached paint record" as the fallback source that must NOT be trusted for the verify path. Canvas-not-init is a second (and structurally worse — at frame 0 there IS no last frame to capture) fallback source that the same argument applies to verbatim, and the doc should say so — otherwise a future refactor may re-plumb canvas-not-init into recaptureDrawElementFrameForVerify unaware.
  • My #5 (zero unit tests): delta touches only drawElementService.ts and frameCapture.ts; no test files. The classifier semantics that just landed are the exact thing that a substring-match footgun fix should be armored against future regression — a positive test at both the throw-site shape and the batch-wrapped shape, a negative test on unrelated prose containing "canvas not initialized", and a root-missing HARD-FAIL regression (per @magi-bot's ask) would together lock the intent in. Without them, the ratio-warn compromise, the code-discriminant, and @magi-bot's canvas/root split are all invisible to CI.
  • @magi-bot's original CR (canvas/root split): both !canvas || !root sites (drawElementService.ts:360, 803, 1032) still throw the SAME HF_DE_CANVAS_NOT_INITIALIZED code — no split between "capture canvas missing" and "composition root missing." Root-missing still recovers to pageScreenshotCapture (which has no [data-composition-id] validation), the exact silent-corruption path @magi-bot's CR is scoped to. The commit message says "tighten error matching, audit batch path, add fallback-ratio guard" — three real things, but "split canvas vs root and hard-fail root-missing" isn't among them. The ratio-warn is a compensating observability signal, not an equivalent — 100% root-missing on a broken page state will log loudly, but sub-100% still ships wrong-content frames. As owning R1 requester on this axis, @magi-bot's block is not lifted by this delta.

Adversarial delta lenses:

  • (a) Discrimination direction: narrowed, cleanly. Code-prefix substring beats free-text substring; "for element" full phrase beats "No cached paint record" prefix. Batch path's recoverable branch also narrows to a deterministic screenshot loop instead of drop-through-then-catch.
  • (b) New hard-failure paths that previously fell through silently: none — the non-recoverable branch in captureFramesBatchPipelined (frameCapture.ts:3895-3907) preserves the prior retry-per-frame behavior for unrecognized errors, so genuinely transient non-drawElement failures still get a second chance.
  • (c) Fallback-ratio observability: present but log-only. deNcprFallbacks is in CapturePerfSummary (frameCapture.ts:4320), batch increments it, and the >50% console.warn fires in getCapturePerfSummary. Adequate for a human tailing logs / a scheduled monitor; not adequate to prevent shipping a fully-fallback render.

Net: the two of my items that had a mechanical fix (#1, #2) are cleanly addressed with thoughtful commentary. The seam-with-#3429 (#3) is thoughtfully softened but not closed and now depends on #3482 landing. The comment (#4) and tests (#5) are still open. And @magi-bot's canvas/root split — the R1 block on this PR — is not implemented. My R2 posture is COMMENT: I'm not adding a second block on top of @magi-bot's, but I don't consider tai's R1 residuals resolved either. Ship decision belongs to @magi-bot.

— Via

@terencecho terencecho left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

R2 at head 4ca135d5 after Miga's fix-up. Items 1/2/3 + @magi-bot's canvas/root item are addressed well (coded discriminant via HF_DE_CANVAS_NOT_INITIALIZED, batch site at drawElementService.ts:1029-1034 now returns the discriminant + wraps it, captureFramesBatchPipelined branches on isRecoverableDrawElementError for in-loop screenshot fallback, ratio-warn at getCapturePerfSummary frameCapture.ts:4270-4283 with follow-up #3482 filed). Bonus tightening of isNoCachedPaintRecordError to the full native phrase.

But there's a new correctness bug introduced by the fix-up commit — STILL_BLOCKED.

captureFramesBatchPipelined's new recoverable-error branch at frameCapture.ts:3877-3893:

for (let i = failedAt; i < frameIndices.length; i++) {
  const frameIndex = frameIndices[i];
  if (frameIndex === undefined) break;
  session.deNcprFallbacks = ...;
  const buffer = await pageScreenshotCapture(page, options);
  ...
}

The loop iterates frameIndices but never calls prepareFrameForCapture(session, frameIndex, times[i]) between screenshots, and it discards times[] entirely (time is only extracted in the non-recoverable else-branch that routes through captureFrameToBufferPipelined). Result: N remaining fallback frames all capture the same page state (whatever was up when the batch produce returned failedAt), producing N identical duplicate pixels rather than N properly-time-advanced screenshots.

Contrast with the per-frame recoverable path — captureFrameCore at :3520 and captureFrameToBufferPipelined at :3742 — both are entered after prepareFrameForCapture has already seeked to their time, so their single pageScreenshotCapture is correct.

For a canvas-not-init at failedAt=0 this ships an entire render's worth of duplicated frame-0 pixels. Frame-count and duration checks still pass (that's #3429's structural gap that #3482 tracks), so the render "succeeds" — but this is strictly worse than the pre-fix behavior (which hard-failed rather than silently shipping duplicates), and it's exactly the seam this PR was meant to fix by falling back correctly, not by falling back to identical pixels.

Fix options (pick one):

  • Call prepareFrameForCapture(session, frameIndex, times[i]) before each pageScreenshotCapture in the recoverable-branch loop.
  • Route each remaining frame through captureFrameToBufferPipelined (accepting one wasted drawElement attempt per frame — arguably cleaner since the non-recoverable branch already takes that path).

Also unaddressed:

  • Item 5 (unit tests) — still zero. PR files list is still exactly drawElementService.ts + frameCapture.ts. Repo-wide code search confirms isRecoverableDrawElementError, isCanvasNotInitializedError, and HF_DE_CANVAS_NOT_INITIALIZED appear only in those two source files — zero test references. Producer: unit tests CI is green but that just means pre-existing tests survived; there is no coverage for the new predicate, the batch-path recoverable branch (the one with the correctness bug above), the ratio warn, or a root-missing regression. Given a real correctness bug just slipped past all existing tests, the "we have integration coverage" defense is weakened. A single unit test around captureFramesBatchPipelined with failedAt=0 and frameIndices=[0,1,2] would have caught the duplicate-frames bug.

  • Magi's item, strictly: all three sites still if (!canvas || !root) throw/return "HF_DE_CANVAS_NOT_INITIALIZED: drawElement canvas not initialized". The two conditions still share one error message. Since both conditions now correctly route to screenshot fallback via the coded discriminant, this is a diagnostics nit — not a correctness blocker. Low value at this point.

  • Docstring nit: recaptureDrawElementFrameForVerify's docstring (frameCapture.ts:3766-3776) still only names the "No cached paint record" fallback as a wrong-frame risk — no mention of the canvas-not-init flavor. Low severity: verify path re-throws all errors (no fallback there), so behavior is unchanged; only the doc's enumeration of risks-verify-avoids is incomplete.

— Review by tai (pr-review)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Render hard-fails with drawElement canvas not initialized instead of falling back to screenshot capture (0.7.109 through 0.8.8)

4 participants