@@ -3347,6 +3347,30 @@ function isNoCachedPaintRecordError(err: unknown): boolean {
33473347 return msg . includes ( "No cached paint record" ) ;
33483348}
33493349
3350+ /**
3351+ * True for the drawElement `canvas not initialized` error (thrown by
3352+ * drawElementService when the injected capture canvas isn't set up yet —
3353+ * observed at frame 0 on some macOS/Chrome combinations, see #3423). Like the
3354+ * no-cached-paint-record case, this is recoverable per-frame: callers fall
3355+ * back to screenshot capture for the affected frame instead of hard-failing
3356+ * the whole render.
3357+ */
3358+ function isCanvasNotInitializedError ( err : unknown ) : boolean {
3359+ const msg = err instanceof Error ? err . message : String ( err ) ;
3360+ return msg . includes ( "canvas not initialized" ) ;
3361+ }
3362+
3363+ /**
3364+ * Single gate for drawElement failures the fast-capture pipeline knows how to
3365+ * recover from by falling back to screenshot capture instead of aborting the
3366+ * render. Both {@link captureFrameCore} and {@link captureFrameToBufferPipelined}
3367+ * consult this so a newly-recognized recoverable error only needs to be taught
3368+ * here once.
3369+ */
3370+ function isRecoverableDrawElementError ( err : unknown ) : boolean {
3371+ return isNoCachedPaintRecordError ( err ) || isCanvasNotInitializedError ( err ) ;
3372+ }
3373+
33503374async function captureFrameCore (
33513375 session : CaptureSession ,
33523376 frameIndex : number ,
@@ -3418,7 +3442,7 @@ async function captureFrameCore(
34183442 // stale), so the "fallback" REPLACES good frames with damaged ones (validated:
34193443 // 35e8fa9f 462→0 damaged frames, 4001da8e 11→0, when this is off). The two real
34203444 // boundary failure modes are now caught reactively below — the throw case by
3421- // isNoCachedPaintRecordError , the silent-solid-black case by the small-frame
3445+ // isRecoverableDrawElementError , the silent-solid-black case by the small-frame
34223446 // blank-guard (a solid frame is a tiny JPEG) — without touching frames drawElement
34233447 // handles. Force the old behavior with HF_FAST_CAPTURE_BOUNDARY_SS=true. The worker
34243448 // path keeps proactive boundary-SS (it has no blank-guard); see
@@ -3476,13 +3500,18 @@ async function captureFrameCore(
34763500 } catch ( err ) {
34773501 // drawElementImage throws `InvalidStateError: No cached paint record for
34783502 // element` when an element in the subtree has no paint record this frame
3479- // (display toggled / detached / freshly-shown at a clip-cut boundary). This
3480- // is a per-frame condition, not a whole-comp one — fall back to screenshot
3481- // for THIS frame instead of aborting the render. See fast-capture-limitations.md.
3482- if ( isNoCachedPaintRecordError ( err ) ) {
3503+ // (display toggled / detached / freshly-shown at a clip-cut boundary), and
3504+ // `canvas not initialized` when the injected capture canvas isn't set up yet
3505+ // (observed at frame 0 on some macOS/Chrome combinations, see #3423). Both
3506+ // are per-frame conditions, not whole-comp ones — fall back to screenshot for
3507+ // THIS frame instead of aborting the render. See fast-capture-limitations.md.
3508+ if ( isRecoverableDrawElementError ( err ) ) {
34833509 session . deNcprFallbacks = ( session . deNcprFallbacks ?? 0 ) + 1 ;
3510+ const reason = isCanvasNotInitializedError ( err )
3511+ ? "drawElement canvas not initialized"
3512+ : "No cached paint record" ;
34843513 console . log (
3485- `[engine] fast capture: frame ${ frameIndex } — No cached paint record ; ` +
3514+ `[engine] fast capture: frame ${ frameIndex } — ${ reason } ; ` +
34863515 `screenshot fallback for this frame (see fast-capture-limitations.md)` ,
34873516 ) ;
34883517 screenshotBuffer = await pageScreenshotCapture ( page , options ) ;
@@ -3691,14 +3720,18 @@ export async function captureFrameToBufferPipelined(
36913720
36923721 return { encodeResult, captureTimeMs } ;
36933722 } catch ( captureError ) {
3694- // Per-frame `No cached paint record`: fall back to screenshot for THIS frame
3695- // instead of aborting the render (clip-cut boundary / freshly-shown element).
3696- // The worker isn't involved for this frame; return a resolved encodeResult so
3697- // the pipeline loop writes it like any other. See fast-capture-limitations.md.
3698- if ( isNoCachedPaintRecordError ( captureError ) ) {
3723+ // Per-frame `No cached paint record` or `canvas not initialized` (#3423): fall
3724+ // back to screenshot for THIS frame instead of aborting the render (clip-cut
3725+ // boundary / freshly-shown element / capture canvas not yet set up). The worker
3726+ // isn't involved for this frame; return a resolved encodeResult so the pipeline
3727+ // loop writes it like any other. See fast-capture-limitations.md.
3728+ if ( isRecoverableDrawElementError ( captureError ) ) {
36993729 session . deNcprFallbacks = ( session . deNcprFallbacks ?? 0 ) + 1 ;
3730+ const reason = isCanvasNotInitializedError ( captureError )
3731+ ? "drawElement canvas not initialized"
3732+ : "No cached paint record" ;
37003733 console . log (
3701- `[engine] fast capture: frame ${ frameIndex } — No cached paint record ; ` +
3734+ `[engine] fast capture: frame ${ frameIndex } — ${ reason } ; ` +
37023735 `screenshot fallback for this frame (see fast-capture-limitations.md)` ,
37033736 ) ;
37043737 const buffer = await pageScreenshotCapture ( page , options ) ;
0 commit comments