mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-04 16:42:27 +00:00
feat(engine): opt-in per-frame timing on fast-capture fallback path
Field-signal baseline: >=2 fallbacks/hr on darwin/arm64 from filter:blur and filter:drop-shadow triggers. Fallback path perf is currently untimed, so we can't know if the overhead is 10% or 10x. This PR adds opt-in per-frame timing (HF_PROFILE_FALLBACK_CAPTURE=true) that emits p50/p95/p99 + trigger reason via the observeRenderStage telemetry channel extended in #2510. Diagnostic surface only -- no perf fix, no behavior change on healthy paths. Stack: PR #9 (final) of 9 (base via/escape-hatch-fallback-reproducer). Signed-off-by: Via
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Tests for the `percentileOf` helper added alongside the fast-capture
|
||||
* fallback profiling diagnostic (PR: `feat(engine): opt-in per-frame
|
||||
* timing on fast-capture fallback path`).
|
||||
*
|
||||
* The helper feeds the `capture_fallback_profile` observability checkpoint
|
||||
* — a diagnostic-only surface that has to give reviewers of future fallback
|
||||
* perf regressions a *trustworthy* number, so the percentile math is
|
||||
* pinned by these tests rather than left as read-through-the-caller
|
||||
* behavior.
|
||||
*
|
||||
* Nearest-rank semantics were chosen to match the existing `medianOf` p50
|
||||
* helper (`sorted[Math.floor(sorted.length / 2)]`) so p50 and p95/p99 stay
|
||||
* comparable — an interpolated-percentile answer would differ from the p50
|
||||
* emission for small sample sets and make cross-percentile reads confusing.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { percentileOf } from "./frameCapture.js";
|
||||
|
||||
describe("percentileOf", () => {
|
||||
it("returns 0 for empty samples (matches medianOf's empty behavior)", () => {
|
||||
expect(percentileOf([], 0.5)).toBe(0);
|
||||
expect(percentileOf([], 0.95)).toBe(0);
|
||||
expect(percentileOf([], 0.99)).toBe(0);
|
||||
});
|
||||
|
||||
it("returns the sole sample regardless of percentile for length-1 input", () => {
|
||||
expect(percentileOf([42], 0.5)).toBe(42);
|
||||
expect(percentileOf([42], 0.95)).toBe(42);
|
||||
expect(percentileOf([42], 0.99)).toBe(42);
|
||||
});
|
||||
|
||||
it("computes nearest-rank percentiles on a fixed 100-sample ramp (1..100)", () => {
|
||||
const samples = Array.from({ length: 100 }, (_, i) => i + 1);
|
||||
// floor(0.5 * 100) = 50 → sorted[50] = 51
|
||||
expect(percentileOf(samples, 0.5)).toBe(51);
|
||||
// floor(0.95 * 100) = 95 → sorted[95] = 96
|
||||
expect(percentileOf(samples, 0.95)).toBe(96);
|
||||
// floor(0.99 * 100) = 99 → sorted[99] = 100
|
||||
expect(percentileOf(samples, 0.99)).toBe(100);
|
||||
});
|
||||
|
||||
it("clamps p=1 (would land at length) to the last sample rather than out-of-range", () => {
|
||||
const samples = Array.from({ length: 100 }, (_, i) => i + 1);
|
||||
// floor(1.0 * 100) = 100 → clamped to sorted[99] = 100
|
||||
expect(percentileOf(samples, 1.0)).toBe(100);
|
||||
});
|
||||
|
||||
it("does not require pre-sorted input (sorts a shuffled sample set)", () => {
|
||||
const samples = [10, 3, 7, 1, 5, 9, 2, 8, 4, 6];
|
||||
// sorted = [1..10]; floor(0.5*10)=5 → sorted[5]=6
|
||||
expect(percentileOf(samples, 0.5)).toBe(6);
|
||||
// floor(0.95*10)=9 → sorted[9]=10
|
||||
expect(percentileOf(samples, 0.95)).toBe(10);
|
||||
// floor(0.99*10)=9 → sorted[9]=10
|
||||
expect(percentileOf(samples, 0.99)).toBe(10);
|
||||
});
|
||||
|
||||
it("does not mutate the caller's samples array", () => {
|
||||
const samples = [5, 3, 8, 1, 9, 2, 7, 4, 6];
|
||||
const snapshot = [...samples];
|
||||
percentileOf(samples, 0.95);
|
||||
expect(samples).toEqual(snapshot);
|
||||
});
|
||||
|
||||
it("rounds fractional millisecond samples the same way medianOf does", () => {
|
||||
// 100 ramped fractional samples 0.5, 1.5, 2.5, …, 99.5.
|
||||
const samples = Array.from({ length: 100 }, (_, i) => i + 0.5);
|
||||
// floor(0.5*100)=50 → sorted[50] = 50.5 → Math.round → 51
|
||||
expect(percentileOf(samples, 0.5)).toBe(51);
|
||||
// floor(0.95*100)=95 → sorted[95] = 95.5 → Math.round → 96
|
||||
expect(percentileOf(samples, 0.95)).toBe(96);
|
||||
});
|
||||
|
||||
it("distinguishes p95 from p99 on a heavy-tailed sample set", () => {
|
||||
// 95 samples at 40ms (steady-state) + 5 samples at 200ms (paint-heavy tail)
|
||||
// — a shape the fast-capture fallback path is expected to produce
|
||||
// (see `fallbackCaptureProfile.ts` framing for why p95≠p99 matters here).
|
||||
const steady = Array.from({ length: 95 }, () => 40);
|
||||
const tail = Array.from({ length: 5 }, () => 200);
|
||||
const samples = [...steady, ...tail];
|
||||
// sorted: [40 x95, 200 x5]; floor(0.5*100)=50 → 40
|
||||
expect(percentileOf(samples, 0.5)).toBe(40);
|
||||
// floor(0.95*100)=95 → sorted[95] = 200 (first of the tail)
|
||||
expect(percentileOf(samples, 0.95)).toBe(200);
|
||||
// floor(0.99*100)=99 → sorted[99] = 200
|
||||
expect(percentileOf(samples, 0.99)).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -179,6 +179,17 @@ export interface CaptureSession {
|
||||
deVerifyFrames?: Map<number, Buffer>;
|
||||
/** Low-cardinality init-gate reason when drawElement routed to baseline (telemetry). */
|
||||
deGateReason?: string;
|
||||
/**
|
||||
* Full trigger string when drawElement gated off to the screenshot fallback
|
||||
* path — preserves the specific CSS effect (`filter:blur`,
|
||||
* `filter:drop-shadow`, `backdrop-filter`, `clip-path`) that
|
||||
* {@link deGateReason} sanitizes down to a low-cardinality bucket. Populated
|
||||
* on the same fallback-gate branches as `deGateReason`; consumed by the
|
||||
* `capture_fallback_profile` observability checkpoint gated behind
|
||||
* `HF_PROFILE_FALLBACK_CAPTURE=true`. See
|
||||
* `packages/producer/src/services/render/fallbackCaptureProfile.ts`.
|
||||
*/
|
||||
deFallbackTrigger?: 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). */
|
||||
@@ -651,6 +662,7 @@ async function initDrawElementOrTransparentBackground(
|
||||
(!forceScreenshot || forceDE);
|
||||
if ((session.config?.useDrawElement ?? false) && supersampling) {
|
||||
session.deGateReason = "supersampling";
|
||||
session.deFallbackTrigger = "supersampling";
|
||||
console.log(
|
||||
"[engine] --experimental-fast-capture disabled for this render: drawElementImage " +
|
||||
"ignores deviceScaleFactor, so supersampled (DPR > 1) output uses screenshot capture.",
|
||||
@@ -658,6 +670,7 @@ async function initDrawElementOrTransparentBackground(
|
||||
}
|
||||
if ((session.config?.useDrawElement ?? false) && !supersampling && forceScreenshot) {
|
||||
session.deGateReason = "render_mode_hint";
|
||||
session.deFallbackTrigger = "render_mode_hint";
|
||||
console.log(
|
||||
"[engine] fast capture: falling back to screenshot — render-mode compatibility " +
|
||||
"hint forced screenshot capture (e.g. raw requestAnimationFrame composition).",
|
||||
@@ -707,6 +720,7 @@ async function initDrawElementOrTransparentBackground(
|
||||
});
|
||||
if (!supportsDrawElement) {
|
||||
session.deGateReason = "unsupported_chrome";
|
||||
session.deFallbackTrigger = "unsupported_chrome";
|
||||
console.log(
|
||||
`[engine] fast capture: falling back to ${session.launchCaptureMode} capture — ` +
|
||||
"this Chrome build does not implement canvas.drawElementImage (Dev/Canary-only " +
|
||||
@@ -732,6 +746,7 @@ async function initDrawElementOrTransparentBackground(
|
||||
const mode = resolveDrawElementCaptureMode(session.isSwiftShader, transparent);
|
||||
if (mode === "screenshot") {
|
||||
session.deGateReason = "swiftshader";
|
||||
session.deFallbackTrigger = "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
|
||||
@@ -752,6 +767,11 @@ async function initDrawElementOrTransparentBackground(
|
||||
const cssFx = await detectCssEffectRisk(page);
|
||||
if (cssFx) {
|
||||
session.deGateReason = `css_effect:${(cssFx.split(":")[0] ?? "").replace(/[^a-z-]/gi, "")}`;
|
||||
// Full specific effect ("filter:blur" / "filter:drop-shadow" /
|
||||
// "backdrop-filter" / "clip-path") — `deGateReason` sanitizes
|
||||
// this to the low-cardinality prefix; `deFallbackTrigger` keeps
|
||||
// the fine-grained value for the diagnostic profile emission.
|
||||
session.deFallbackTrigger = cssFx;
|
||||
console.log(
|
||||
`[engine] fast capture: falling back to ${session.launchCaptureMode} capture — ` +
|
||||
`${cssFx} detected (drawElementImage cannot reproduce it; see fast-capture-limitations.md)`,
|
||||
@@ -786,6 +806,7 @@ async function initDrawElementOrTransparentBackground(
|
||||
);
|
||||
if (atRisk.size > 0 && atRiskFraction > fractionFloor) {
|
||||
session.deGateReason = "at_risk_timeline";
|
||||
session.deFallbackTrigger = "at_risk_timeline";
|
||||
console.log(
|
||||
`[engine] fast capture: falling back to ${session.launchCaptureMode} capture — ` +
|
||||
`${atRisk.size}/${totalFrames} frames animate a compositor-incompatible prop ` +
|
||||
@@ -803,6 +824,7 @@ async function initDrawElementOrTransparentBackground(
|
||||
const threeD = await initThreeDProjection(page);
|
||||
if (!forceDE && !threeD.ok) {
|
||||
session.deGateReason = "3d_init_failed";
|
||||
session.deFallbackTrigger = "3d_init_failed";
|
||||
console.log(
|
||||
`[engine] fast capture: falling back to ${session.launchCaptureMode} capture — ` +
|
||||
`3D projection init failed (${threeD.reason ?? "unknown"})`,
|
||||
@@ -3510,6 +3532,26 @@ function medianOf(samples: number[]): number {
|
||||
return Math.round(sorted[Math.floor(sorted.length / 2)] ?? 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Percentile of a positive-real sample set (nearest-rank; matches how the
|
||||
* existing {@link medianOf} p50 helper picks the middle index). `p` is a
|
||||
* fraction in [0, 1]; the sample at `floor(p * n)` (clamped to `[0, n-1]`)
|
||||
* is returned. Sample set is not mutated. Returns 0 for empty input, mirroring
|
||||
* the p50 helper.
|
||||
*
|
||||
* Used for the `capture_fallback_profile` observability checkpoint added in
|
||||
* the fast-capture fallback profiling PR: we already collect `capturePerf.frameMs`
|
||||
* per session, so computing p95/p99 is one sort + two lookups — cheap enough
|
||||
* to always compute alongside the existing p50, no separate opt-in path
|
||||
* needed for the math. The env gate lives at the emission site.
|
||||
*/
|
||||
export function percentileOf(samples: number[], p: number): number {
|
||||
if (samples.length === 0) return 0;
|
||||
const sorted = [...samples].sort((a, b) => a - b);
|
||||
const idx = Math.min(sorted.length - 1, Math.max(0, Math.floor(p * sorted.length)));
|
||||
return Math.round(sorted[idx] ?? 0);
|
||||
}
|
||||
|
||||
export function getCapturePerfSummary(session: CaptureSession): CapturePerfSummary {
|
||||
const frames = Math.max(1, session.capturePerf.frames);
|
||||
return {
|
||||
@@ -3519,6 +3561,8 @@ export function getCapturePerfSummary(session: CaptureSession): CapturePerfSumma
|
||||
avgBeforeCaptureMs: Math.round(session.capturePerf.beforeCaptureMs / frames),
|
||||
avgScreenshotMs: Math.round(session.capturePerf.screenshotMs / frames),
|
||||
p50TotalMs: medianOf(session.capturePerf.frameMs),
|
||||
p95TotalMs: percentileOf(session.capturePerf.frameMs, 0.95),
|
||||
p99TotalMs: percentileOf(session.capturePerf.frameMs, 0.99),
|
||||
subTimelineWaitOutcome: session.subTimelineWaitOutcome,
|
||||
warnings: session.warnings.map((warning) => ({
|
||||
...warning,
|
||||
@@ -3539,6 +3583,7 @@ export function getCapturePerfSummary(session: CaptureSession): CapturePerfSumma
|
||||
beginFrameHasDamage: session.beginFrameHasDamageCount,
|
||||
captureMode: session.captureMode,
|
||||
deGateReason: session.deGateReason,
|
||||
deFallbackTrigger: session.deFallbackTrigger,
|
||||
deWorkerEncode: session.workerEncodeEnabled ?? false,
|
||||
deVerifyArmed: session.deVerifyFrames?.size ?? 0,
|
||||
deVerifyInitMs: session.deVerifyInitMs ?? 0,
|
||||
|
||||
Reference in New Issue
Block a user