Skip to content

Commit 4ca135d

Browse files
miguel-heygenmiga-heygen
authored andcommitted
fix(producer): address review — tighten error matching, audit batch path, add fallback-ratio guard
1 parent e4e97ed commit 4ca135d

2 files changed

Lines changed: 163 additions & 27 deletions

File tree

packages/engine/src/services/drawElementService.ts

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,32 @@
1616

1717
import type { Page } from "puppeteer-core";
1818

19+
/**
20+
* Discriminant prefix embedded in every "capture canvas isn't set up yet"
21+
* error THIS module throws/returns (as opposed to the native
22+
* `InvalidStateError: No cached paint record for element` DOMException that
23+
* `drawElementImage` itself throws, which we don't control the text of).
24+
*
25+
* These errors cross a `page.evaluate` boundary: Puppeteer reconstructs them
26+
* as a plain `Error` on the Node side (see puppeteer-core's
27+
* `createEvaluationError`), so custom properties/subclasses don't survive —
28+
* only `message` (and `name`, which for a plain `Error` thrown in-page is
29+
* just `"Error"`) make the round trip. A stable, low-cardinality code baked
30+
* into the message is therefore the closest available substitute for a real
31+
* error-code discriminant. frameCapture.ts matches on this exact code
32+
* (substring) rather than on the free-text tail, so classification survives
33+
* the message being re-wrapped (e.g. `produceDrawElementFrameBatch`'s
34+
* "batch produce failed at frame N: <code>: ..." wrapping) and won't
35+
* false-positive on unrelated prose that happens to contain the words
36+
* "canvas" and "initialized".
37+
*
38+
* IMPORTANT: because `page.evaluate` serializes closures via `Function#toString`,
39+
* the three throw/return sites below CANNOT reference this constant directly
40+
* (it wouldn't be in scope inside the browser) — they inline the same literal
41+
* string. Keep all three in sync with this constant if it ever changes.
42+
*/
43+
export const DE_CANVAS_NOT_INITIALIZED_CODE = "HF_DE_CANVAS_NOT_INITIALIZED";
44+
1945
/**
2046
* Resolve which capture mode to use when `useDrawElement` is true.
2147
*
@@ -331,7 +357,9 @@ export async function captureDrawElementFrame(
331357
}) => {
332358
const canvas = document.getElementById("__hf_de_canvas") as HTMLCanvasElement | null;
333359
const root = document.querySelector("[data-composition-id]") as HTMLElement | null;
334-
if (!canvas || !root) throw new Error("drawElement canvas not initialized");
360+
if (!canvas || !root) {
361+
throw new Error("HF_DE_CANVAS_NOT_INITIALIZED: drawElement canvas not initialized");
362+
}
335363
const ctx = canvas.getContext("2d");
336364
if (!ctx) throw new Error("drawElement: 2d context unavailable");
337365
// Accelerated canvases (webgl/webgl2/webgpu) never repaint — their paint
@@ -772,7 +800,9 @@ export async function produceDrawElementFrame(
772800
({ w, h, q, sync, fid }: { w: number; h: number; q: number; sync: boolean; fid: number }) => {
773801
const canvas = document.getElementById("__hf_de_canvas") as HTMLCanvasElement | null;
774802
const root = document.querySelector("[data-composition-id]") as HTMLElement | null;
775-
if (!canvas || !root) throw new Error("drawElement canvas not initialized");
803+
if (!canvas || !root) {
804+
throw new Error("HF_DE_CANVAS_NOT_INITIALIZED: drawElement canvas not initialized");
805+
}
776806
const ctx = canvas.getContext("2d");
777807
if (!ctx) throw new Error("drawElement: 2d context unavailable");
778808

@@ -999,7 +1029,12 @@ export async function produceDrawElementFrameBatch(
9991029
}): Promise<{ failedAt: number | null; error?: string }> => {
10001030
const canvas = document.getElementById("__hf_de_canvas") as HTMLCanvasElement | null;
10011031
const root = document.querySelector("[data-composition-id]") as HTMLElement | null;
1002-
if (!canvas || !root) return { failedAt: 0, error: "drawElement canvas not initialized" };
1032+
if (!canvas || !root) {
1033+
return {
1034+
failedAt: 0,
1035+
error: "HF_DE_CANVAS_NOT_INITIALIZED: drawElement canvas not initialized",
1036+
};
1037+
}
10031038
const ctx = canvas.getContext("2d");
10041039
if (!ctx) return { failedAt: 0, error: "drawElement: 2d context unavailable" };
10051040

packages/engine/src/services/frameCapture.ts

Lines changed: 125 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ import {
4949
cleanupDrawElementWorkerEncode,
5050
produceDrawElementFrame,
5151
produceDrawElementFrameBatch,
52+
DE_CANVAS_NOT_INITIALIZED_CODE,
5253
} from "./drawElementService.js";
5354
import { initThreeDProjection, detectCssEffectRisk } from "./threeDProjection.js";
5455
import { 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
*/
33453354
function 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
*/
33583376
function 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
*/
37963825
export 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+
41794248
export 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

Comments
 (0)