@@ -49,6 +49,7 @@ import {
4949 cleanupDrawElementWorkerEncode ,
5050 produceDrawElementFrame ,
5151 produceDrawElementFrameBatch ,
52+ DE_CANVAS_NOT_INITIALIZED_CODE ,
5253} from "./drawElementService.js" ;
5354import { initThreeDProjection , detectCssEffectRisk } from "./threeDProjection.js" ;
5455import { isPsnrFilterAvailable } from "../utils/psnrFilterAvailability.js" ;
@@ -3341,23 +3342,40 @@ async function computeTimelineAtRiskFrames(
33413342 * thrown when a subtree element has no paint record for the current frame (display
33423343 * toggled / detached / freshly-shown at a clip-cut boundary). Per-frame, not
33433344 * whole-comp — callers fall back to screenshot for the single frame.
3345+ *
3346+ * This is a NATIVE Chrome DOMException (`drawElementImage`'s own error), so we
3347+ * can't bake a discriminant into it the way we can for our own thrown errors
3348+ * (see {@link isCanvasNotInitializedError}) — Puppeteer's `page.evaluate`
3349+ * error reconstruction also doesn't preserve a usable `.name` for it (comes
3350+ * back generic). Match on the FULL native phrase ("...for element"), not just
3351+ * the generic "No cached paint record" prefix, to cut the odds of an
3352+ * unrelated message coincidentally matching (review: substring-match footgun).
33443353 */
33453354function isNoCachedPaintRecordError ( err : unknown ) : boolean {
33463355 const msg = err instanceof Error ? err . message : String ( err ) ;
3347- return msg . includes ( "No cached paint record" ) ;
3356+ return msg . includes ( "No cached paint record for element " ) ;
33483357}
33493358
33503359/**
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.
3360+ * True for the drawElement "capture canvas isn't set up yet" error — thrown
3361+ * (or, on the batch path, returned as a string) by drawElementService when
3362+ * the injected capture canvas isn't set up yet (observed at frame 0 on some
3363+ * macOS/Chrome combinations, see #3423). Like the no-cached-paint-record
3364+ * case, this is recoverable per-frame: callers fall back to screenshot
3365+ * capture for the affected frame(s) instead of hard-failing the whole render.
3366+ *
3367+ * Unlike the native paint-record error, THIS error is constructed by our own
3368+ * code (three sites in drawElementService.ts), so it carries the
3369+ * {@link DE_CANVAS_NOT_INITIALIZED_CODE} discriminant baked into the message
3370+ * — matching on that stable code (rather than the free-text phrase
3371+ * "canvas not initialized") avoids false-positiving on unrelated prose, and
3372+ * keeps matching correctly even after `produceDrawElementFrameBatch`'s
3373+ * "batch produce failed at frame N: <code>: ..." wrapping (review: prefer an
3374+ * error-code discriminant over a substring-match footgun).
33573375 */
33583376function isCanvasNotInitializedError ( err : unknown ) : boolean {
33593377 const msg = err instanceof Error ? err . message : String ( err ) ;
3360- return msg . includes ( "canvas not initialized" ) ;
3378+ return msg . includes ( DE_CANVAS_NOT_INITIALIZED_CODE ) ;
33613379}
33623380
33633381/**
@@ -3788,10 +3806,21 @@ export async function recaptureDrawElementFrameForVerify(
37883806 * P6 prototype (HF_DE_BATCH): capture N consecutive frames in one CDP
37893807 * round-trip via {@link produceDrawElementFrameBatch}. The caller pre-plans the
37903808 * batch (consecutive frame indices, none static-dedup'd, none opt-in
3791- * boundary-screenshot). On a mid-batch in-page failure the remaining frames are
3792- * re-captured through {@link captureFrameToBufferPipelined}, which owns the
3793- * per-frame screenshot-fallback semantics — so failure behavior is identical to
3794- * the unbatched path, just discovered at batch granularity.
3809+ * boundary-screenshot). On a mid-batch in-page failure the remaining frames'
3810+ * handling depends on whether the failure is one of the recoverable
3811+ * per-frame drawElement conditions (canvas-not-initialized / no-cached-paint-
3812+ * record, #3423):
3813+ * - Recoverable: capture the remaining frames directly via screenshot,
3814+ * same as the per-frame paths' own fallback (avoids re-attempting a
3815+ * drawElement produce that the batch call just told us will fail again —
3816+ * review finding: audit this path explicitly rather than relying on the
3817+ * incidental retry-then-catch behavior below).
3818+ * - Anything else (unrecognized error): fall through to
3819+ * {@link captureFrameToBufferPipelined}, which re-attempts drawElement (so
3820+ * a genuinely transient, non-drawElement-specific failure still gets a
3821+ * second chance) and owns the same recoverable-error/fatal-error split for
3822+ * whatever it encounters — so failure behavior for a truly fatal error is
3823+ * identical to the unbatched path, just discovered at batch granularity.
37953824 */
37963825export async function captureFramesBatchPipelined (
37973826 session : CaptureSession ,
@@ -3835,17 +3864,48 @@ export async function captureFramesBatchPipelined(
38353864 }
38363865
38373866 if ( failedAt !== null ) {
3838- console . log (
3839- `[engine] fast capture: batch produce failed at frame ` +
3840- `${ frameIndices [ failedAt ] ?? "?" } (${ error ?? "?" } ); ` +
3841- `re-capturing ${ frameIndices . length - failedAt } frame(s) per-frame` ,
3842- ) ;
3843- for ( let i = failedAt ; i < frameIndices . length ; i ++ ) {
3844- const frameIndex = frameIndices [ i ] ;
3845- const time = times [ i ] ;
3846- if ( frameIndex === undefined || time === undefined ) break ;
3847- const { encodeResult } = await captureFrameToBufferPipelined ( session , frameIndex , time ) ;
3848- results . push ( { frameIndex, encodeResult } ) ;
3867+ // `error` is a plain string here (produceDrawElementFrameBatch returns it
3868+ // out of an in-page evaluate rather than throwing an Error instance) —
3869+ // isRecoverableDrawElementError accepts `unknown` and stringifies non-Error
3870+ // input, so passing the string straight through classifies it correctly,
3871+ // including through produceDrawElementFrameBatch's own error text (which
3872+ // embeds the same DE_CANVAS_NOT_INITIALIZED_CODE / native paint-record
3873+ // phrase the per-frame paths match on).
3874+ if ( isRecoverableDrawElementError ( error ) ) {
3875+ const reason = isCanvasNotInitializedError ( error )
3876+ ? "drawElement canvas not initialized"
3877+ : "No cached paint record" ;
3878+ console . log (
3879+ `[engine] fast capture: batch produce failed at frame ` +
3880+ `${ frameIndices [ failedAt ] ?? "?" } (${ reason } ); ` +
3881+ `screenshot fallback for ${ frameIndices . length - failedAt } frame(s) ` +
3882+ `(see fast-capture-limitations.md)` ,
3883+ ) ;
3884+ for ( let i = failedAt ; i < frameIndices . length ; i ++ ) {
3885+ const frameIndex = frameIndices [ i ] ;
3886+ if ( frameIndex === undefined ) break ;
3887+ session . deNcprFallbacks = ( session . deNcprFallbacks ?? 0 ) + 1 ;
3888+ const buffer = await pageScreenshotCapture ( page , options ) ;
3889+ const encodeResult = Promise . resolve ( buffer ) ;
3890+ if ( session . staticFrames ) {
3891+ session . lastEncodeResult = encodeResult ;
3892+ session . lastEncodeResultFrame = frameIndex ;
3893+ }
3894+ results . push ( { frameIndex, encodeResult } ) ;
3895+ }
3896+ } else {
3897+ console . log (
3898+ `[engine] fast capture: batch produce failed at frame ` +
3899+ `${ frameIndices [ failedAt ] ?? "?" } (${ error ?? "?" } ); ` +
3900+ `re-capturing ${ frameIndices . length - failedAt } frame(s) per-frame` ,
3901+ ) ;
3902+ for ( let i = failedAt ; i < frameIndices . length ; i ++ ) {
3903+ const frameIndex = frameIndices [ i ] ;
3904+ const time = times [ i ] ;
3905+ if ( frameIndex === undefined || time === undefined ) break ;
3906+ const { encodeResult } = await captureFrameToBufferPipelined ( session , frameIndex , time ) ;
3907+ results . push ( { frameIndex, encodeResult } ) ;
3908+ }
38493909 }
38503910 }
38513911
@@ -4176,8 +4236,49 @@ export function percentileOf(samples: number[], p: number): number {
41764236 return Math . round ( sorted [ idx ] ?? 0 ) ;
41774237}
41784238
4239+ /**
4240+ * Fraction of captured frames above which a fast-capture render is treated as
4241+ * "drawElement effectively didn't engage" rather than "recovered a handful of
4242+ * edge-case frames" (see the cross-PR-seam warning in
4243+ * {@link getCapturePerfSummary}). Not currently a hard gate — see that
4244+ * function's comment for why — just the threshold for the loud diagnostic.
4245+ */
4246+ const DE_FALLBACK_RATIO_WARN_THRESHOLD = 0.5 ;
4247+
41794248export function getCapturePerfSummary ( session : CaptureSession ) : CapturePerfSummary {
41804249 const frames = Math . max ( 1 , session . capturePerf . frames ) ;
4250+ const ncprFallbacks = session . deNcprFallbacks ?? 0 ;
4251+ // Cross-PR seam (#3423 per-frame screenshot fallback vs #3429 artifact
4252+ // validation): #3429's artifact validation only checks that the render
4253+ // produced the right frame COUNT and duration — it has no visibility into
4254+ // HOW each frame was captured. If a composition is so incompatible with
4255+ // drawElement that most/all frames take the per-frame screenshot fallback
4256+ // added here, the render still reports "complete" with a correct frame
4257+ // count, even though drawElement effectively never engaged for it. That's
4258+ // not itself a correctness bug — screenshot capture is the platform's
4259+ // normal, well-tested baseline, so the SHIPPED PIXELS are fine — but a
4260+ // near-100% fallback ratio is a strong signal that fast-capture silently
4261+ // failed to engage for the whole render (e.g. a persistent canvas-injection
4262+ // problem) rather than recovering a handful of expected edge-case frames,
4263+ // and today nothing surfaces that distinction to telemetry or to a human.
4264+ //
4265+ // Deliberately NOT a circuit breaker: aborting/failing the render here
4266+ // would make a render that reliably succeeds via the well-tested screenshot
4267+ // path fail instead, which is a worse outcome than a slow-but-correct
4268+ // render. Whether artifact validation (or this session) should eventually
4269+ // gate on the ratio — and where that decision belongs — is tracked as an
4270+ // explicit follow-up: https://github.com/heygen-com/hyperframes/issues/3482
4271+ // ("Fast-capture: fallback-ratio guard for #3423 x #3429 seam"), rather
4272+ // than decided unilaterally in this review-response commit.
4273+ if ( frames > 0 && ncprFallbacks / frames > DE_FALLBACK_RATIO_WARN_THRESHOLD ) {
4274+ const pct = Math . round ( ( ncprFallbacks / frames ) * 100 ) ;
4275+ console . warn (
4276+ `[engine] fast capture: ${ ncprFallbacks } /${ frames } frame(s) (${ pct } %) fell back to ` +
4277+ `screenshot capture (canvas-not-initialized / no-cached-paint-record) — ` +
4278+ `drawElement likely failed to engage for this render rather than recovering a few ` +
4279+ `edge-case frames; see fast-capture-limitations.md.` ,
4280+ ) ;
4281+ }
41814282 return {
41824283 frames : session . capturePerf . frames ,
41834284 avgTotalMs : Math . round ( session . capturePerf . totalMs / frames ) ,
@@ -4216,6 +4317,6 @@ export function getCapturePerfSummary(session: CaptureSession): CapturePerfSumma
42164317 deVerifyArmed : session . deVerifyFrames ?. size ?? 0 ,
42174318 deVerifyInitMs : session . deVerifyInitMs ?? 0 ,
42184319 deBoundaryFrames : session . clipBoundaryFrames ?. size ?? 0 ,
4219- deNcprFallbacks : session . deNcprFallbacks ?? 0 ,
4320+ deNcprFallbacks : ncprFallbacks ,
42204321 } ;
42214322}
0 commit comments