fix(producer): fall back to screenshot capture on drawElement canvas-not-initialized - #3480
fix(producer): fall back to screenshot capture on drawElement canvas-not-initialized#3480miga-heygen wants to merge 2 commits into
Conversation
…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
left a comment
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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)
…ath, add fallback-ratio guard
vanceingalls
left a comment
There was a problem hiding this comment.
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, andcaptureFramesBatchPipelined(frameCapture.ts:3874-3894) branches onisRecoverableDrawElementErrorexplicitly before falling back — batch-path recoverables no longer rely on incidental retry-then-catch, andsession.deNcprFallbacksis 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;isCanvasNotInitializedErrornow matches on that code, not the free-text tail. The module-header comment (drawElementService.ts:19-41) explains the constraint:page.evaluateboundary reconstructs errors as plainErroron the Node side (no subclass, no.code, generic.name), so a code-in-message is the closest available substitute.isNoCachedPaintRecordErroralso 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
left a comment
There was a problem hiding this comment.
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 eachpageScreenshotCapturein 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 confirmsisRecoverableDrawElementError,isCanvasNotInitializedError, andHF_DE_CANVAS_NOT_INITIALIZEDappear only in those two source files — zero test references.Producer: unit testsCI 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 aroundcaptureFramesBatchPipelinedwithfailedAt=0andframeIndices=[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)
Summary
canvas not initializederrors alongside the existingno cached paint recordcase--experimental-fast-captureCloses #3423
Author: Miguel Ángel miguel.sierra@heygen.com
Co-Authored-By: Miga noreply@anthropic.com