feat(engine,producer,cli): drawElement release telemetry on render_complete (#2002)

Default-on drawElement ships with a runtime self-verification net (#1998);
this makes its in-the-wild behavior observable. Every render_complete event
now answers: which capture mode actually ran, why drawElement disengaged
when it did (compile gate / producer clamp / engine init gate), whether the
self-verify net fired and why, and how much margin verification had.

Follows the static-dedup telemetry pattern: engine session fields →
CapturePerfSummary → RenderPerfSummary.drawElement → snake_case props on
render_complete.

New event props: de_capture_mode, de_compile_gate, de_clamp_reason,
de_gate_reason, de_worker_encode, de_verify_armed, de_verify_checked,
de_verify_min_db (margin above the 32dB threshold — drift here is the
early-warning signal before fallbacks spike), de_verify_init_ms,
de_self_verify_fallback, de_fallback_reason, de_blank_suspects,
de_blank_deterministic_accepts, de_blank_recaptures, de_boundary_frames,
de_ncpr_fallbacks.

Validated end-to-end on live renders: drawelement path reports mode/verify
counters/minDb/init cost; a blur-gated comp reports mode=screenshot +
gate_reason=css_effect:filter; a forced verification failure reports
self_verify_fallback=true + fallback_reason=psnr.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-07-06 17:39:46 -07:00
committed by GitHub
co-authored by Claude Fable 5
parent 906c8d04f8
commit 1005703441
8 changed files with 257 additions and 1 deletions
+16
View File
@@ -1662,6 +1662,22 @@ function trackRenderMetrics(
staticDedupSkipReason: perf?.staticDedup?.skipReason, staticDedupSkipReason: perf?.staticDedup?.skipReason,
staticDedupPredictedFrames: perf?.staticDedup?.predictedFrames, staticDedupPredictedFrames: perf?.staticDedup?.predictedFrames,
staticDedupReusedFrames: perf?.staticDedup?.reusedFrames, staticDedupReusedFrames: perf?.staticDedup?.reusedFrames,
deCaptureMode: perf?.drawElement?.mode,
deCompileGate: perf?.drawElement?.compileGate,
deClampReason: perf?.drawElement?.clampReason,
deGateReason: perf?.drawElement?.gateReason,
deWorkerEncode: perf?.drawElement?.workerEncode,
deVerifyArmed: perf?.drawElement?.verifyArmed,
deVerifyChecked: perf?.drawElement?.verifyChecked,
deVerifyMinDb: perf?.drawElement?.verifyMinDb,
deVerifyInitMs: perf?.drawElement?.verifyInitMs,
deSelfVerifyFallback: perf?.drawElement?.selfVerifyFallback,
deFallbackReason: perf?.drawElement?.fallbackReason,
deBlankSuspects: perf?.drawElement?.blankSuspects,
deBlankDeterministicAccepts: perf?.drawElement?.blankDeterministicAccepts,
deBlankRecaptures: perf?.drawElement?.blankRecaptures,
deBoundaryFrames: perf?.drawElement?.boundaryFrames,
deNcprFallbacks: perf?.drawElement?.ncprFallbacks,
compositionDurationMs, compositionDurationMs,
compositionWidth: perf?.resolution.width, compositionWidth: perf?.resolution.width,
compositionHeight: perf?.resolution.height, compositionHeight: perf?.resolution.height,
+34
View File
@@ -114,6 +114,24 @@ export function trackRenderComplete(
staticDedupSkipReason?: string; staticDedupSkipReason?: string;
staticDedupPredictedFrames?: number; staticDedupPredictedFrames?: number;
staticDedupReusedFrames?: number; staticDedupReusedFrames?: number;
// drawElement fast-capture outcome (default-on release visibility).
// Undefined on render paths with no capture session.
deCaptureMode?: string;
deCompileGate?: string;
deClampReason?: string;
deGateReason?: string;
deWorkerEncode?: boolean;
deVerifyArmed?: number;
deVerifyChecked?: number;
deVerifyMinDb?: number;
deVerifyInitMs?: number;
deSelfVerifyFallback?: boolean;
deFallbackReason?: string;
deBlankSuspects?: number;
deBlankDeterministicAccepts?: number;
deBlankRecaptures?: number;
deBoundaryFrames?: number;
deNcprFallbacks?: number;
// "cli" when triggered by `hyperframes render` (default), "studio" when // "cli" when triggered by `hyperframes render` (default), "studio" when
// triggered by a studio preview-server render (POST /api/projects/:id/render). // triggered by a studio preview-server render (POST /api/projects/:id/render).
source?: "cli" | "studio"; source?: "cli" | "studio";
@@ -170,6 +188,22 @@ export function trackRenderComplete(
static_dedup_skip_reason: props.staticDedupSkipReason, static_dedup_skip_reason: props.staticDedupSkipReason,
static_dedup_predicted_frames: props.staticDedupPredictedFrames, static_dedup_predicted_frames: props.staticDedupPredictedFrames,
static_dedup_reused_frames: props.staticDedupReusedFrames, static_dedup_reused_frames: props.staticDedupReusedFrames,
de_capture_mode: props.deCaptureMode,
de_compile_gate: props.deCompileGate,
de_clamp_reason: props.deClampReason,
de_gate_reason: props.deGateReason,
de_worker_encode: props.deWorkerEncode,
de_verify_armed: props.deVerifyArmed,
de_verify_checked: props.deVerifyChecked,
de_verify_min_db: props.deVerifyMinDb,
de_verify_init_ms: props.deVerifyInitMs,
de_self_verify_fallback: props.deSelfVerifyFallback,
de_fallback_reason: props.deFallbackReason,
de_blank_suspects: props.deBlankSuspects,
de_blank_deterministic_accepts: props.deBlankDeterministicAccepts,
de_blank_recaptures: props.deBlankRecaptures,
de_boundary_frames: props.deBoundaryFrames,
de_ncpr_fallbacks: props.deNcprFallbacks,
source: props.source ?? "cli", source: props.source ?? "cli",
composition_duration_ms: props.compositionDurationMs, composition_duration_ms: props.compositionDurationMs,
composition_width: props.compositionWidth, composition_width: props.compositionWidth,
+26 -1
View File
@@ -154,6 +154,12 @@ export interface CaptureSession {
* DrawElementVerificationError and the orchestrator re-renders via the * DrawElementVerificationError and the orchestrator re-renders via the
* screenshot path. */ * screenshot path. */
deVerifyFrames?: Map<number, Buffer>; deVerifyFrames?: Map<number, Buffer>;
/** Low-cardinality init-gate reason when drawElement routed to baseline (telemetry). */
deGateReason?: string;
/** Wall-clock ms spent capturing self-verification ground truth at init (telemetry). */
deVerifyInitMs?: number;
/** Count of per-frame "No cached paint record" screenshot fallbacks (telemetry). */
deNcprFallbacks?: number;
} }
/** /**
@@ -470,12 +476,14 @@ async function initDrawElementOrTransparentBackground(
!supersampling && !supersampling &&
(!forceScreenshot || forceDE); (!forceScreenshot || forceDE);
if ((session.config?.useDrawElement ?? false) && supersampling) { if ((session.config?.useDrawElement ?? false) && supersampling) {
session.deGateReason = "supersampling";
console.log( console.log(
"[engine] --experimental-fast-capture disabled for this render: drawElementImage " + "[engine] --experimental-fast-capture disabled for this render: drawElementImage " +
"ignores deviceScaleFactor, so supersampled (DPR > 1) output uses screenshot capture.", "ignores deviceScaleFactor, so supersampled (DPR > 1) output uses screenshot capture.",
); );
} }
if ((session.config?.useDrawElement ?? false) && !supersampling && forceScreenshot) { if ((session.config?.useDrawElement ?? false) && !supersampling && forceScreenshot) {
session.deGateReason = "render_mode_hint";
console.log( console.log(
"[engine] fast capture: falling back to screenshot — render-mode compatibility " + "[engine] fast capture: falling back to screenshot — render-mode compatibility " +
"hint forced screenshot capture (e.g. raw requestAnimationFrame composition).", "hint forced screenshot capture (e.g. raw requestAnimationFrame composition).",
@@ -532,6 +540,7 @@ async function initDrawElementOrTransparentBackground(
// PSNR=inf; efb59c5b 24.5→47.4 dB, 0 damaged frames). 151 is the pinned floor. // PSNR=inf; efb59c5b 24.5→47.4 dB, 0 damaged frames). 151 is the pinned floor.
const mode = resolveDrawElementCaptureMode(session.isSwiftShader, transparent); const mode = resolveDrawElementCaptureMode(session.isSwiftShader, transparent);
if (mode === "screenshot") { if (mode === "screenshot") {
session.deGateReason = "swiftshader";
// Fall back to the browser's LAUNCH mode, not unconditionally to // Fall back to the browser's LAUNCH mode, not unconditionally to
// "screenshot": on a BeginFrame-launched browser (Linux fast capture) // "screenshot": on a BeginFrame-launched browser (Linux fast capture)
// Page.captureScreenshot hangs for the full protocol timeout, while // Page.captureScreenshot hangs for the full protocol timeout, while
@@ -551,6 +560,7 @@ async function initDrawElementOrTransparentBackground(
if (!forceDE && process.env.HF_FAST_CAPTURE_CSSFX !== "true") { if (!forceDE && process.env.HF_FAST_CAPTURE_CSSFX !== "true") {
const cssFx = await detectCssEffectRisk(page); const cssFx = await detectCssEffectRisk(page);
if (cssFx) { if (cssFx) {
session.deGateReason = `css_effect:${(cssFx.split(":")[0] ?? "").replace(/[^a-z-]/gi, "")}`;
console.log( console.log(
`[engine] fast capture: falling back to ${session.launchCaptureMode} capture — ` + `[engine] fast capture: falling back to ${session.launchCaptureMode} capture — ` +
`${cssFx} detected (drawElementImage cannot reproduce it; see fast-capture-limitations.md)`, `${cssFx} detected (drawElementImage cannot reproduce it; see fast-capture-limitations.md)`,
@@ -584,6 +594,7 @@ async function initDrawElementOrTransparentBackground(
`timeline at-risk predictor: ${atRisk.size}/${totalFrames} frames (${Math.round(atRiskFraction * 100)}%)`, `timeline at-risk predictor: ${atRisk.size}/${totalFrames} frames (${Math.round(atRiskFraction * 100)}%)`,
); );
if (atRisk.size > 0 && atRiskFraction > fractionFloor) { if (atRisk.size > 0 && atRiskFraction > fractionFloor) {
session.deGateReason = "at_risk_timeline";
console.log( console.log(
`[engine] fast capture: falling back to ${session.launchCaptureMode} capture — ` + `[engine] fast capture: falling back to ${session.launchCaptureMode} capture — ` +
`${atRisk.size}/${totalFrames} frames animate a compositor-incompatible prop ` + `${atRisk.size}/${totalFrames} frames animate a compositor-incompatible prop ` +
@@ -600,6 +611,7 @@ async function initDrawElementOrTransparentBackground(
// threeDProjection.ts. No-op for compositions without 3D content. // threeDProjection.ts. No-op for compositions without 3D content.
const threeD = await initThreeDProjection(page); const threeD = await initThreeDProjection(page);
if (!forceDE && !threeD.ok) { if (!forceDE && !threeD.ok) {
session.deGateReason = "3d_init_failed";
console.log( console.log(
`[engine] fast capture: falling back to ${session.launchCaptureMode} capture — ` + `[engine] fast capture: falling back to ${session.launchCaptureMode} capture — ` +
`3D projection init failed (${threeD.reason ?? "unknown"})`, `3D projection init failed (${threeD.reason ?? "unknown"})`,
@@ -620,7 +632,11 @@ async function initDrawElementOrTransparentBackground(
// Self-verification ground truth: must ALSO run pre-injection — after the // Self-verification ground truth: must ALSO run pre-injection — after the
// canvas wraps the root, a page screenshot shows the canvas's last-drawn // canvas wraps the root, a page screenshot shows the canvas's last-drawn
// bitmap, not the live DOM (see the Lim 6 boundary-screenshot note). // bitmap, not the live DOM (see the Lim 6 boundary-screenshot note).
await captureDeVerificationFrames(session, page, logInitPhase); {
const verifyStart = Date.now();
await captureDeVerificationFrames(session, page, logInitPhase);
session.deVerifyInitMs = Date.now() - verifyStart;
}
await injectDrawElementCanvas(page, session.options.width, session.options.height); await injectDrawElementCanvas(page, session.options.width, session.options.height);
if (transparent) { if (transparent) {
await initTransparentBackground(session.page); await initTransparentBackground(session.page);
@@ -2349,6 +2365,7 @@ async function captureFrameCore(
// is a per-frame condition, not a whole-comp one — fall back to screenshot // is a per-frame condition, not a whole-comp one — fall back to screenshot
// for THIS frame instead of aborting the render. See fast-capture-limitations.md. // for THIS frame instead of aborting the render. See fast-capture-limitations.md.
if (isNoCachedPaintRecordError(err)) { if (isNoCachedPaintRecordError(err)) {
session.deNcprFallbacks = (session.deNcprFallbacks ?? 0) + 1;
console.log( console.log(
`[engine] fast capture: frame ${frameIndex} — No cached paint record; ` + `[engine] fast capture: frame ${frameIndex} — No cached paint record; ` +
`screenshot fallback for this frame (see fast-capture-limitations.md)`, `screenshot fallback for this frame (see fast-capture-limitations.md)`,
@@ -2530,6 +2547,7 @@ export async function captureFrameToBufferPipelined(
// The worker isn't involved for this frame; return a resolved encodeResult so // The worker isn't involved for this frame; return a resolved encodeResult so
// the pipeline loop writes it like any other. See fast-capture-limitations.md. // the pipeline loop writes it like any other. See fast-capture-limitations.md.
if (isNoCachedPaintRecordError(captureError)) { if (isNoCachedPaintRecordError(captureError)) {
session.deNcprFallbacks = (session.deNcprFallbacks ?? 0) + 1;
console.log( console.log(
`[engine] fast capture: frame ${frameIndex} — No cached paint record; ` + `[engine] fast capture: frame ${frameIndex} — No cached paint record; ` +
`screenshot fallback for this frame (see fast-capture-limitations.md)`, `screenshot fallback for this frame (see fast-capture-limitations.md)`,
@@ -2934,6 +2952,13 @@ export function getCapturePerfSummary(session: CaptureSession): CapturePerfSumma
staticDedupArmed: (session.staticFrames?.size ?? 0) > 0, staticDedupArmed: (session.staticFrames?.size ?? 0) > 0,
staticDedupPredicted: session.staticFrames?.size ?? 0, staticDedupPredicted: session.staticFrames?.size ?? 0,
staticDedupSkipReason: session.staticDedupSkipReason, staticDedupSkipReason: session.staticDedupSkipReason,
captureMode: session.captureMode,
deGateReason: session.deGateReason,
deWorkerEncode: session.workerEncodeEnabled ?? false,
deVerifyArmed: session.deVerifyFrames?.size ?? 0,
deVerifyInitMs: session.deVerifyInitMs ?? 0,
deBoundaryFrames: session.clipBoundaryFrames?.size ?? 0,
deNcprFallbacks: session.deNcprFallbacks ?? 0,
}; };
} }
+20
View File
@@ -197,6 +197,26 @@ export interface CapturePerfSummary {
* `|`-join distinct reasons when parallel workers diverge.) * `|`-join distinct reasons when parallel workers diverge.)
*/ */
staticDedupSkipReason?: string; staticDedupSkipReason?: string;
// ── drawElement fast-capture outcome (default-on release visibility) ──
/** Final capture mode this session used: "drawelement" | "screenshot" | "beginframe". */
captureMode: string;
/**
* Low-cardinality init-time gate that routed a drawElement-eligible session
* to the baseline: `swiftshader` | `css_effect:<fx>` | `at_risk_timeline` |
* `3d_init_failed` | `supersampling` | `render_mode_hint`. Undefined when
* drawElement ran or was never attempted.
*/
deGateReason?: string;
/** Worker-encode pipeline active (the drain that runs self-verification). */
deWorkerEncode: boolean;
/** Self-verification ground-truth samples armed at init (0 = verification off/skipped). */
deVerifyArmed: number;
/** Wall-clock cost of capturing the ground-truth samples at init. */
deVerifyInitMs: number;
/** Clip-cut boundary frames routed to per-frame screenshot (Lim 6). */
deBoundaryFrames: number;
/** Per-frame "No cached paint record" screenshot fallbacks during capture. */
deNcprFallbacks: number;
} }
// ── Global Augmentation ──────────────────────────────────────────────────────── // ── Global Augmentation ────────────────────────────────────────────────────────
@@ -35,6 +35,58 @@ export function pushWorkerDedupPerfs(
* same composition); predicted/reused = SUM (each worker dedups its own frame * same composition); predicted/reused = SUM (each worker dedups its own frame
* range); skipReason = the distinct reasons (sorted, `|`-joined) when not armed. * range); skipReason = the distinct reasons (sorted, `|`-joined) when not armed.
*/ */
/**
* Collapse per-session capture perf + producer-side decisions into the
* render-level drawElement outcome. mode/gateReason |-join distinct values
* across workers (bounded cardinality); counters SUM.
*/
// Flat field mapping — branches are ?? fallbacks, not logic.
// fallow-ignore-next-line complexity
function aggregateDrawElement(
perfs: CapturePerfSummary[],
de: {
compileGate?: string;
clampReason?: string;
selfVerifyFallback: boolean;
fallbackReason?: string;
drainStats?: {
verifyChecked: number;
verifyMinDb?: number;
blankSuspects: number;
blankDeterministicAccepts: number;
blankRecaptures: number;
};
},
): RenderPerfSummary["drawElement"] {
if (perfs.length === 0) return undefined;
const modes = [...new Set(perfs.map((p) => p.captureMode).filter(Boolean))].sort();
const gateReasons = [
...new Set(perfs.map((p) => p.deGateReason).filter((r): r is string => !!r)),
].sort();
const drain = de.drainStats;
return {
mode: modes.join("|") || "unknown",
compileGate: de.compileGate,
clampReason: de.clampReason,
gateReason: gateReasons.length > 0 ? gateReasons.join("|") : undefined,
workerEncode: perfs.some((p) => p.deWorkerEncode),
verifyArmed: perfs.reduce((sum, p) => sum + (p.deVerifyArmed ?? 0), 0),
verifyChecked: drain?.verifyChecked ?? 0,
verifyMinDb:
drain?.verifyMinDb === undefined
? undefined
: Math.round(Math.min(drain.verifyMinDb, 999) * 10) / 10,
verifyInitMs: perfs.reduce((sum, p) => sum + (p.deVerifyInitMs ?? 0), 0),
selfVerifyFallback: de.selfVerifyFallback,
fallbackReason: de.fallbackReason,
blankSuspects: drain?.blankSuspects ?? 0,
blankDeterministicAccepts: drain?.blankDeterministicAccepts ?? 0,
blankRecaptures: drain?.blankRecaptures ?? 0,
boundaryFrames: perfs.reduce((sum, p) => sum + (p.deBoundaryFrames ?? 0), 0),
ncprFallbacks: perfs.reduce((sum, p) => sum + (p.deNcprFallbacks ?? 0), 0),
};
}
function aggregateDedup(perfs: CapturePerfSummary[]): RenderPerfSummary["staticDedup"] { function aggregateDedup(perfs: CapturePerfSummary[]): RenderPerfSummary["staticDedup"] {
if (perfs.length === 0) return undefined; if (perfs.length === 0) return undefined;
const armed = perfs.some((p) => p.staticDedupArmed); const armed = perfs.some((p) => p.staticDedupArmed);
@@ -83,6 +135,19 @@ export function buildRenderPerfSummary(input: {
peakHeapUsedBytes: number; peakHeapUsedBytes: number;
/** Per-session/per-worker static-dedup perf; aggregated into `staticDedup`. */ /** Per-session/per-worker static-dedup perf; aggregated into `staticDedup`. */
dedupPerfs: CapturePerfSummary[]; dedupPerfs: CapturePerfSummary[];
drawElement?: {
compileGate?: string;
clampReason?: string;
selfVerifyFallback: boolean;
fallbackReason?: string;
drainStats?: {
verifyChecked: number;
verifyMinDb?: number;
blankSuspects: number;
blankDeterministicAccepts: number;
blankRecaptures: number;
};
};
}): RenderPerfSummary { }): RenderPerfSummary {
return { return {
renderId: input.job.id, renderId: input.job.id,
@@ -130,5 +195,9 @@ export function buildRenderPerfSummary(input: {
peakRssMb: Math.round(input.peakRssBytes / (1024 * 1024)), peakRssMb: Math.round(input.peakRssBytes / (1024 * 1024)),
peakHeapUsedMb: Math.round(input.peakHeapUsedBytes / (1024 * 1024)), peakHeapUsedMb: Math.round(input.peakHeapUsedBytes / (1024 * 1024)),
staticDedup: aggregateDedup(input.dedupPerfs), staticDedup: aggregateDedup(input.dedupPerfs),
drawElement: aggregateDrawElement(
input.dedupPerfs,
input.drawElement ?? { selfVerifyFallback: false },
),
}; };
} }
@@ -128,6 +128,15 @@ export interface CaptureStreamingStageInput {
dedupPerfs: CapturePerfSummary[]; dedupPerfs: CapturePerfSummary[];
} }
/** Drain-side safety-net counters for the worker-encode loop (telemetry). */
export interface DeDrainStats {
verifyChecked: number;
verifyMinDb?: number;
blankSuspects: number;
blankDeterministicAccepts: number;
blankRecaptures: number;
}
export type CaptureStreamingStageResult = export type CaptureStreamingStageResult =
| { | {
/** Streaming path ran successfully — sequencer should skip the disk path AND Stage 5 encode. */ /** Streaming path ran successfully — sequencer should skip the disk path AND Stage 5 encode. */
@@ -139,6 +148,8 @@ export type CaptureStreamingStageResult =
workerCount: number; workerCount: number;
/** Engine-resolved screenshot flag from the consumed sequential/probe session, when observed. */ /** Engine-resolved screenshot flag from the consumed sequential/probe session, when observed. */
captureBeyondViewport?: boolean; captureBeyondViewport?: boolean;
/** Safety-net drain counters (worker-encode loop only; undefined elsewhere). */
deDrainStats?: DeDrainStats;
} }
| { | {
/** Spawn failed (non-abort) — sequencer should fall back to the disk path. */ /** Spawn failed (non-abort) — sequencer should fall back to the disk path. */
@@ -176,6 +187,7 @@ async function runWorkerEncodePipelineLoop(
assertNotAborted: () => void, assertNotAborted: () => void,
onProgress: CaptureStreamingStageInput["onProgress"], onProgress: CaptureStreamingStageInput["onProgress"],
log: CaptureStreamingStageInput["log"], log: CaptureStreamingStageInput["log"],
stats: DeDrainStats,
): Promise<void> { ): Promise<void> {
let prev: { idx: number; encodeResult: Promise<Buffer> } | null = null; let prev: { idx: number; encodeResult: Promise<Buffer> } | null = null;
const frameTime = (i: number) => (i * job.config.fps.den) / job.config.fps.num; const frameTime = (i: number) => (i * job.config.fps.den) / job.config.fps.num;
@@ -228,9 +240,13 @@ async function runWorkerEncodePipelineLoop(
if (process.env.HF_FORCE_DRAWELEMENT !== "1") { if (process.env.HF_FORCE_DRAWELEMENT !== "1") {
const floor = blankFloor(); const floor = blankFloor();
if (floor > 0 && buf.length < floor && acceptedSmall?.equals(buf)) { if (floor > 0 && buf.length < floor && acceptedSmall?.equals(buf)) {
stats.blankSuspects += 1;
stats.blankDeterministicAccepts += 1;
// Identical to a small frame already proven deterministic (dark // Identical to a small frame already proven deterministic (dark
// clip-gap runs repeat the same bytes) — skip the recapture. // clip-gap runs repeat the same bytes) — skip the recapture.
} else if (floor > 0 && buf.length < floor) { } else if (floor > 0 && buf.length < floor) {
stats.blankSuspects += 1;
stats.blankRecaptures += 1;
log.warn("[Render] drawElement blank-frame suspect; re-capturing", { log.warn("[Render] drawElement blank-frame suspect; re-capturing", {
frame: idx, frame: idx,
bytes: buf.length, bytes: buf.length,
@@ -255,6 +271,7 @@ async function runWorkerEncodePipelineLoop(
// transient blank drop (those are intermittent by nature) — the // transient blank drop (those are intermittent by nature) — the
// frame is legitimately small (dark / low-detail). Accept it; // frame is legitimately small (dark / low-detail). Accept it;
// deterministic damage classes are the PSNR self-verify's job. // deterministic damage classes are the PSNR self-verify's job.
stats.blankDeterministicAccepts += 1;
log.info("[Render] drawElement small frame is deterministic; accepted", { log.info("[Render] drawElement small frame is deterministic; accepted", {
frame: idx, frame: idx,
bytes: buf.length, bytes: buf.length,
@@ -299,6 +316,8 @@ async function runWorkerEncodePipelineLoop(
`drawElement self-verify failed at frame ${idx}: ${db.toFixed(1)}dB < ${verifyMinDb}dB vs pre-injection screenshot${dumpDir ? ` (pair: ${dumpDir})` : ""}`, `drawElement self-verify failed at frame ${idx}: ${db.toFixed(1)}dB < ${verifyMinDb}dB vs pre-injection screenshot${dumpDir ? ` (pair: ${dumpDir})` : ""}`,
); );
} }
stats.verifyChecked += 1;
stats.verifyMinDb = stats.verifyMinDb === undefined ? db : Math.min(stats.verifyMinDb, db);
log.info("[Render] drawElement self-verify passed", { log.info("[Render] drawElement self-verify passed", {
frame: idx, frame: idx,
psnrDb: db === Infinity ? "inf" : Number(db.toFixed(1)), psnrDb: db === Infinity ? "inf" : Number(db.toFixed(1)),
@@ -426,6 +445,7 @@ export async function runCaptureStreamingStage(
} = input; } = input;
let { workerCount, probeSession } = input; let { workerCount, probeSession } = input;
let lastBrowserConsole: string[] = []; let lastBrowserConsole: string[] = [];
let deDrainStats: DeDrainStats | undefined;
let captureBeyondViewport: boolean | undefined = probeSession?.options.captureBeyondViewport; let captureBeyondViewport: boolean | undefined = probeSession?.options.captureBeyondViewport;
// Derive a local cfg view rather than reading `forceScreenshot` from the // Derive a local cfg view rather than reading `forceScreenshot` from the
@@ -546,6 +566,12 @@ export async function runCaptureStreamingStage(
if (session.workerEncodeEnabled) { if (session.workerEncodeEnabled) {
// Worker-encode pipeline: depth-2. Frame N's in-page Worker encodes // Worker-encode pipeline: depth-2. Frame N's in-page Worker encodes
// while frame N+1's main thread does seek+paint+drawElement+kick. // while frame N+1's main thread does seek+paint+drawElement+kick.
deDrainStats = {
verifyChecked: 0,
blankSuspects: 0,
blankDeterministicAccepts: 0,
blankRecaptures: 0,
};
await runWorkerEncodePipelineLoop( await runWorkerEncodePipelineLoop(
session, session,
totalFrames, totalFrames,
@@ -555,6 +581,7 @@ export async function runCaptureStreamingStage(
assertNotAborted, assertNotAborted,
onProgress, onProgress,
log, log,
deDrainStats,
); );
} else { } else {
for (let i = 0; i < totalFrames; i++) { for (let i = 0; i < totalFrames; i++) {
@@ -613,6 +640,7 @@ export async function runCaptureStreamingStage(
probeSession, probeSession,
lastBrowserConsole, lastBrowserConsole,
workerCount, workerCount,
deDrainStats,
captureBeyondViewport, captureBeyondViewport,
}; };
} finally { } finally {
@@ -102,6 +102,9 @@ export interface CompileStageResult {
* instead of relying on `cfg.forceScreenshot` mutations. * instead of relying on `cfg.forceScreenshot` mutations.
*/ */
forceScreenshot: boolean; forceScreenshot: boolean;
/** Low-cardinality compile-time gate that disabled default drawElement:
* `3d` | `mix_blend_mode` | `shader_transitions`. Undefined when none fired. */
deCompileGate?: string;
} }
export async function runCompileStage(input: CompileStageInput): Promise<CompileStageResult> { export async function runCompileStage(input: CompileStageInput): Promise<CompileStageResult> {
@@ -162,12 +165,14 @@ export async function runCompileStage(input: CompileStageInput): Promise<Compile
// Detection runs inside the compiler on PRE-CDN-inline HTML — GSAP's own // Detection runs inside the compiler on PRE-CDN-inline HTML — GSAP's own
// source contains `transformPerspective`, so scanning compiled.html here // source contains `transformPerspective`, so scanning compiled.html here
// would flag every composition that loads GSAP. // would flag every composition that loads GSAP.
let deCompileGate: string | undefined;
if ( if (
cfg.useDrawElement && cfg.useDrawElement &&
process.env.HF_FAST_CAPTURE_3D !== "true" && process.env.HF_FAST_CAPTURE_3D !== "true" &&
compiled.usesThreeDTransforms compiled.usesThreeDTransforms
) { ) {
cfg.useDrawElement = false; cfg.useDrawElement = false;
deCompileGate = "3d";
log.info( log.info(
"[Render] Fast capture: composition uses a CSS 3D rendering context " + "[Render] Fast capture: composition uses a CSS 3D rendering context " +
"(perspective / preserve-3d / backface-visibility) — disabling drawElementImage " + "(perspective / preserve-3d / backface-visibility) — disabling drawElementImage " +
@@ -186,6 +191,7 @@ export async function runCompileStage(input: CompileStageInput): Promise<Compile
compiled.usesMixBlendMode compiled.usesMixBlendMode
) { ) {
cfg.useDrawElement = false; cfg.useDrawElement = false;
deCompileGate = "mix_blend_mode";
log.info( log.info(
"[Render] Fast capture: composition uses mix-blend-mode — disabling drawElementImage " + "[Render] Fast capture: composition uses mix-blend-mode — disabling drawElementImage " +
"for this render. Capture uses the platform's baseline route.", "for this render. Capture uses the platform's baseline route.",
@@ -202,6 +208,7 @@ export async function runCompileStage(input: CompileStageInput): Promise<Compile
compiled.hasShaderTransitions compiled.hasShaderTransitions
) { ) {
cfg.useDrawElement = false; cfg.useDrawElement = false;
deCompileGate = "shader_transitions";
log.info( log.info(
"[Render] Fast capture: composition uses shader transitions — disabling drawElementImage " + "[Render] Fast capture: composition uses shader transitions — disabling drawElementImage " +
"so page-side compositing stays available.", "so page-side compositing stays available.",
@@ -269,5 +276,6 @@ export async function runCompileStage(input: CompileStageInput): Promise<Compile
outputHeight, outputHeight,
compileOnlyMs, compileOnlyMs,
forceScreenshot, forceScreenshot,
deCompileGate,
}; };
} }
@@ -352,6 +352,42 @@ export interface RenderPerfSummary {
reusedFrames: number; reusedFrames: number;
skipReason?: string; skipReason?: string;
}; };
/**
* drawElement fast-capture outcome for this render (default-on release
* visibility). Undefined when no capture session ran.
*/
drawElement?: {
/** Final capture mode: "drawelement" | "screenshot" | "beginframe" (|-joined if workers diverge). */
mode: string;
/** Compile-time gate that disabled default DE: 3d | mix_blend_mode | shader_transitions. */
compileGate?: string;
/** Producer clamp that disabled default DE: parallel | disk_path. */
clampReason?: string;
/** Engine init-time gate: swiftshader | css_effect:* | at_risk_timeline | 3d_init_failed | supersampling | render_mode_hint. */
gateReason?: string;
/** Worker-encode drain (the verified path) was active. */
workerEncode: boolean;
/** Self-verification ground-truth samples armed at init. */
verifyArmed: number;
/** Samples actually compared at drain time. */
verifyChecked: number;
/** Minimum PSNR across checked samples (dB; margin above the 32dB threshold). */
verifyMinDb?: number;
/** Init cost of capturing ground truth (ms). */
verifyInitMs: number;
/** Self-verification tripped and the render re-ran via screenshot. */
selfVerifyFallback: boolean;
/** What tripped it: psnr | blank. */
fallbackReason?: string;
/** Blank-guard counters. */
blankSuspects: number;
blankDeterministicAccepts: number;
blankRecaptures: number;
/** Clip-cut boundary frames captured via per-frame screenshot. */
boundaryFrames: number;
/** Per-frame "No cached paint record" screenshot fallbacks. */
ncprFallbacks: number;
};
} }
export interface HdrDiagnostics { export interface HdrDiagnostics {
@@ -1167,6 +1203,13 @@ export async function executeRenderJob(
// via the explicit `forceScreenshot` parameter rather than reading // via the explicit `forceScreenshot` parameter rather than reading
// `cfg.forceScreenshot` directly. // `cfg.forceScreenshot` directly.
let captureForceScreenshot = compileResult.forceScreenshot; let captureForceScreenshot = compileResult.forceScreenshot;
// drawElement release telemetry: why default DE disengaged (if it did),
// whether self-verify fell back, and the drain-side counters.
const deCompileGate = compileResult.deCompileGate;
let deClampReason: string | undefined;
let deSelfVerifyFallback = false;
let deFallbackReason: string | undefined;
let deDrainStats: import("./render/stages/captureStreamingStage.js").DeDrainStats | undefined;
updateCaptureObservability({ forceScreenshot: captureForceScreenshot }); updateCaptureObservability({ forceScreenshot: captureForceScreenshot });
observability.checkpoint("compile", "composition metadata resolved", { observability.checkpoint("compile", "composition metadata resolved", {
width, width,
@@ -1584,6 +1627,7 @@ export async function executeRenderJob(
(!useStreamingEncode || workerCount > 1) (!useStreamingEncode || workerCount > 1)
) { ) {
cfg.useDrawElement = false; cfg.useDrawElement = false;
deClampReason = workerCount > 1 ? "parallel" : "disk_path";
log.info( log.info(
"[Render] Fast capture: default-on drawElement disabled for this render — " + "[Render] Fast capture: default-on drawElement disabled for this render — " +
(workerCount > 1 ? "parallel capture" : "the disk capture path") + (workerCount > 1 ? "parallel capture" : "the disk capture path") +
@@ -1837,6 +1881,10 @@ export async function executeRenderJob(
// session was closed by the stage's finally; probeSession (if any) // session was closed by the stage's finally; probeSession (if any)
// was consumed by it, so a fresh session spawns on retry. // was consumed by it, so a fresh session spawns on retry.
if (!isDrawElementVerificationError(err)) throw err; if (!isDrawElementVerificationError(err)) throw err;
deSelfVerifyFallback = true;
deFallbackReason = /blank/i.test(err instanceof Error ? err.message : "")
? "blank"
: "psnr";
log.warn("[Render] drawElement self-verification failed; re-rendering via screenshot", { log.warn("[Render] drawElement self-verification failed; re-rendering via screenshot", {
error: err instanceof Error ? err.message : String(err), error: err instanceof Error ? err.message : String(err),
}); });
@@ -1858,6 +1906,7 @@ export async function executeRenderJob(
const captureFrameMs = Date.now() - captureFrameStart; const captureFrameMs = Date.now() - captureFrameStart;
if (streamingRes.success) { if (streamingRes.success) {
streamingHandled = true; streamingHandled = true;
deDrainStats = streamingRes.deDrainStats;
workerCount = streamingRes.workerCount; workerCount = streamingRes.workerCount;
updateCaptureObservability({ workerCount }); updateCaptureObservability({ workerCount });
if (streamingRes.captureBeyondViewport !== undefined) { if (streamingRes.captureBeyondViewport !== undefined) {
@@ -2026,6 +2075,13 @@ export async function executeRenderJob(
captureCalibration, captureCalibration,
captureAttempts, captureAttempts,
dedupPerfs, dedupPerfs,
drawElement: {
compileGate: deCompileGate,
clampReason: deClampReason,
selfVerifyFallback: deSelfVerifyFallback,
fallbackReason: deFallbackReason,
drainStats: deDrainStats,
},
hdrDiagnostics, hdrDiagnostics,
hdrPerf, hdrPerf,
observability: observabilitySummary, observability: observabilitySummary,