Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions packages/cli/src/commands/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1662,6 +1662,22 @@ function trackRenderMetrics(
staticDedupSkipReason: perf?.staticDedup?.skipReason,
staticDedupPredictedFrames: perf?.staticDedup?.predictedFrames,
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,
compositionWidth: perf?.resolution.width,
compositionHeight: perf?.resolution.height,
Expand Down
34 changes: 34 additions & 0 deletions packages/cli/src/telemetry/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,24 @@ export function trackRenderComplete(
staticDedupSkipReason?: string;
staticDedupPredictedFrames?: 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
// triggered by a studio preview-server render (POST /api/projects/:id/render).
source?: "cli" | "studio";
Expand Down Expand Up @@ -170,6 +188,22 @@ export function trackRenderComplete(
static_dedup_skip_reason: props.staticDedupSkipReason,
static_dedup_predicted_frames: props.staticDedupPredictedFrames,
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",
composition_duration_ms: props.compositionDurationMs,
composition_width: props.compositionWidth,
Expand Down
27 changes: 26 additions & 1 deletion packages/engine/src/services/frameCapture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,12 @@ export interface CaptureSession {
* DrawElementVerificationError and the orchestrator re-renders via the
* screenshot path. */
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;
}

/**
Expand Down Expand Up @@ -470,12 +476,14 @@ async function initDrawElementOrTransparentBackground(
!supersampling &&
(!forceScreenshot || forceDE);
if ((session.config?.useDrawElement ?? false) && supersampling) {
session.deGateReason = "supersampling";
console.log(
"[engine] --experimental-fast-capture disabled for this render: drawElementImage " +
"ignores deviceScaleFactor, so supersampled (DPR > 1) output uses screenshot capture.",
);
}
if ((session.config?.useDrawElement ?? false) && !supersampling && forceScreenshot) {
session.deGateReason = "render_mode_hint";
console.log(
"[engine] fast capture: falling back to screenshot — render-mode compatibility " +
"hint forced screenshot capture (e.g. raw requestAnimationFrame composition).",
Expand Down Expand Up @@ -532,6 +540,7 @@ async function initDrawElementOrTransparentBackground(
// PSNR=inf; efb59c5b 24.5→47.4 dB, 0 damaged frames). 151 is the pinned floor.
const mode = resolveDrawElementCaptureMode(session.isSwiftShader, transparent);
if (mode === "screenshot") {
session.deGateReason = "swiftshader";
// Fall back to the browser's LAUNCH mode, not unconditionally to
// "screenshot": on a BeginFrame-launched browser (Linux fast capture)
// Page.captureScreenshot hangs for the full protocol timeout, while
Expand All @@ -551,6 +560,7 @@ async function initDrawElementOrTransparentBackground(
if (!forceDE && process.env.HF_FAST_CAPTURE_CSSFX !== "true") {
const cssFx = await detectCssEffectRisk(page);
if (cssFx) {
session.deGateReason = `css_effect:${(cssFx.split(":")[0] ?? "").replace(/[^a-z-]/gi, "")}`;
console.log(
`[engine] fast capture: falling back to ${session.launchCaptureMode} capture — ` +
`${cssFx} detected (drawElementImage cannot reproduce it; see fast-capture-limitations.md)`,
Expand Down Expand Up @@ -584,6 +594,7 @@ async function initDrawElementOrTransparentBackground(
`timeline at-risk predictor: ${atRisk.size}/${totalFrames} frames (${Math.round(atRiskFraction * 100)}%)`,
);
if (atRisk.size > 0 && atRiskFraction > fractionFloor) {
session.deGateReason = "at_risk_timeline";
console.log(
`[engine] fast capture: falling back to ${session.launchCaptureMode} capture — ` +
`${atRisk.size}/${totalFrames} frames animate a compositor-incompatible prop ` +
Expand All @@ -600,6 +611,7 @@ async function initDrawElementOrTransparentBackground(
// threeDProjection.ts. No-op for compositions without 3D content.
const threeD = await initThreeDProjection(page);
if (!forceDE && !threeD.ok) {
session.deGateReason = "3d_init_failed";
console.log(
`[engine] fast capture: falling back to ${session.launchCaptureMode} capture — ` +
`3D projection init failed (${threeD.reason ?? "unknown"})`,
Expand All @@ -620,7 +632,11 @@ async function initDrawElementOrTransparentBackground(
// Self-verification ground truth: must ALSO run pre-injection — after the
// 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).
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);
if (transparent) {
await initTransparentBackground(session.page);
Expand Down Expand Up @@ -2349,6 +2365,7 @@ async function captureFrameCore(
// 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.
if (isNoCachedPaintRecordError(err)) {
session.deNcprFallbacks = (session.deNcprFallbacks ?? 0) + 1;
console.log(
`[engine] fast capture: frame ${frameIndex} — No cached paint record; ` +
`screenshot fallback for this frame (see fast-capture-limitations.md)`,
Expand Down Expand Up @@ -2530,6 +2547,7 @@ export async function captureFrameToBufferPipelined(
// 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.
if (isNoCachedPaintRecordError(captureError)) {
session.deNcprFallbacks = (session.deNcprFallbacks ?? 0) + 1;
console.log(
`[engine] fast capture: frame ${frameIndex} — No cached paint record; ` +
`screenshot fallback for this frame (see fast-capture-limitations.md)`,
Expand Down Expand Up @@ -2934,6 +2952,13 @@ export function getCapturePerfSummary(session: CaptureSession): CapturePerfSumma
staticDedupArmed: (session.staticFrames?.size ?? 0) > 0,
staticDedupPredicted: session.staticFrames?.size ?? 0,
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,
};
}

Expand Down
20 changes: 20 additions & 0 deletions packages/engine/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,26 @@ export interface CapturePerfSummary {
* `|`-join distinct reasons when parallel workers diverge.)
*/
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 ────────────────────────────────────────────────────────
Expand Down
69 changes: 69 additions & 0 deletions packages/producer/src/services/render/perfSummary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,58 @@ export function pushWorkerDedupPerfs(
* same composition); predicted/reused = SUM (each worker dedups its own frame
* 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"] {
if (perfs.length === 0) return undefined;
const armed = perfs.some((p) => p.staticDedupArmed);
Expand Down Expand Up @@ -83,6 +135,19 @@ export function buildRenderPerfSummary(input: {
peakHeapUsedBytes: number;
/** Per-session/per-worker static-dedup perf; aggregated into `staticDedup`. */
dedupPerfs: CapturePerfSummary[];
drawElement?: {
compileGate?: string;
clampReason?: string;
selfVerifyFallback: boolean;
fallbackReason?: string;
drainStats?: {
verifyChecked: number;
verifyMinDb?: number;
blankSuspects: number;
blankDeterministicAccepts: number;
blankRecaptures: number;
};
};
}): RenderPerfSummary {
return {
renderId: input.job.id,
Expand Down Expand Up @@ -130,5 +195,9 @@ export function buildRenderPerfSummary(input: {
peakRssMb: Math.round(input.peakRssBytes / (1024 * 1024)),
peakHeapUsedMb: Math.round(input.peakHeapUsedBytes / (1024 * 1024)),
staticDedup: aggregateDedup(input.dedupPerfs),
drawElement: aggregateDrawElement(
input.dedupPerfs,
input.drawElement ?? { selfVerifyFallback: false },
),
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,15 @@ export interface CaptureStreamingStageInput {
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 =
| {
/** Streaming path ran successfully — sequencer should skip the disk path AND Stage 5 encode. */
Expand All @@ -139,6 +148,8 @@ export type CaptureStreamingStageResult =
workerCount: number;
/** Engine-resolved screenshot flag from the consumed sequential/probe session, when observed. */
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. */
Expand Down Expand Up @@ -176,6 +187,7 @@ async function runWorkerEncodePipelineLoop(
assertNotAborted: () => void,
onProgress: CaptureStreamingStageInput["onProgress"],
log: CaptureStreamingStageInput["log"],
stats: DeDrainStats,
): Promise<void> {
let prev: { idx: number; encodeResult: Promise<Buffer> } | null = null;
const frameTime = (i: number) => (i * job.config.fps.den) / job.config.fps.num;
Expand Down Expand Up @@ -228,9 +240,13 @@ async function runWorkerEncodePipelineLoop(
if (process.env.HF_FORCE_DRAWELEMENT !== "1") {
const floor = blankFloor();
if (floor > 0 && buf.length < floor && acceptedSmall?.equals(buf)) {
stats.blankSuspects += 1;
stats.blankDeterministicAccepts += 1;
// Identical to a small frame already proven deterministic (dark
// clip-gap runs repeat the same bytes) — skip the recapture.
} else if (floor > 0 && buf.length < floor) {
stats.blankSuspects += 1;
stats.blankRecaptures += 1;
log.warn("[Render] drawElement blank-frame suspect; re-capturing", {
frame: idx,
bytes: buf.length,
Expand All @@ -255,6 +271,7 @@ async function runWorkerEncodePipelineLoop(
// transient blank drop (those are intermittent by nature) — the
// frame is legitimately small (dark / low-detail). Accept it;
// deterministic damage classes are the PSNR self-verify's job.
stats.blankDeterministicAccepts += 1;
log.info("[Render] drawElement small frame is deterministic; accepted", {
frame: idx,
bytes: buf.length,
Expand Down Expand Up @@ -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})` : ""}`,
);
}
stats.verifyChecked += 1;
stats.verifyMinDb = stats.verifyMinDb === undefined ? db : Math.min(stats.verifyMinDb, db);
log.info("[Render] drawElement self-verify passed", {
frame: idx,
psnrDb: db === Infinity ? "inf" : Number(db.toFixed(1)),
Expand Down Expand Up @@ -426,6 +445,7 @@ export async function runCaptureStreamingStage(
} = input;
let { workerCount, probeSession } = input;
let lastBrowserConsole: string[] = [];
let deDrainStats: DeDrainStats | undefined;
let captureBeyondViewport: boolean | undefined = probeSession?.options.captureBeyondViewport;

// Derive a local cfg view rather than reading `forceScreenshot` from the
Expand Down Expand Up @@ -546,6 +566,12 @@ export async function runCaptureStreamingStage(
if (session.workerEncodeEnabled) {
// Worker-encode pipeline: depth-2. Frame N's in-page Worker encodes
// while frame N+1's main thread does seek+paint+drawElement+kick.
deDrainStats = {
verifyChecked: 0,
blankSuspects: 0,
blankDeterministicAccepts: 0,
blankRecaptures: 0,
};
await runWorkerEncodePipelineLoop(
session,
totalFrames,
Expand All @@ -555,6 +581,7 @@ export async function runCaptureStreamingStage(
assertNotAborted,
onProgress,
log,
deDrainStats,
);
} else {
for (let i = 0; i < totalFrames; i++) {
Expand Down Expand Up @@ -613,6 +640,7 @@ export async function runCaptureStreamingStage(
probeSession,
lastBrowserConsole,
workerCount,
deDrainStats,
captureBeyondViewport,
};
} finally {
Expand Down
Loading
Loading