Skip to content

fix(engine): catch low-contrast drawElement self-verify misses - #3941

Open
santhiprakash wants to merge 4 commits into
heygen-com:mainfrom
santhiprakash:fix/de-verify-region-shift
Open

santhiprakash wants to merge 4 commits into
heygen-com:mainfrom
santhiprakash:fix/de-verify-region-shift

Conversation

@santhiprakash

@santhiprakash santhiprakash commented Sep 14, 2026 •

Copy link
Copy Markdown
Contributor

What

Adds a complementary region-presence check to the drawElement self-verify path. The existing whole-frame PSNR check stays as the magnitude baseline; a new regionShift() metric reports the worst ~96px region's mean signed channel shift (1/255 units) via one extra ffmpeg pass (grainextract + area downscale). The shift is measured and logged on every verified sample, and the max lands in de_verify_max_shift_observed telemetry.

The shift gate ships observe-only by default. Fixture calibration on the producer regression suite (see thread below) found no threshold that separates the #3345 damage class (~3-5/255) from benign divergence — compositor-animated CSS content measured 18-27/255, and a truth-vs-truth screenshot pair of the same seek differed by 22/255. Setting HF_DE_VERIFY_MAX_SHIFT to a value in [1, 64] arms the fail-closed fallback: a sample then fails if db < HF_DE_VERIFY_MIN_DB or shift > HF_DE_VERIFY_MAX_SHIFT, and either breach throws DrawElementVerificationError which the orchestrator converts into the screenshot fallback, matching the videoFrameCoverage.ts precedent called out in the issue thread.

Closes #3345.

Why

The magnitude-weighted PSNR gate cannot detect a fully-missing low-contrast element. Reproduced from the issue: a ~4/255 luma band covering ~15% of the frame scores 44.1 dB — 12 dB clear of the 32 dB floor — so a wrong MP4 ships with exit code 0. PSNR is the right answer to "how far off are the pixels on average"; the self-verify question is "did any content go missing", which needs a localized signal that a correct background cannot dilute.

How

  • packages/engine/src/utils/psnr.ts — new regionShift() (signed diff centered on mid-gray by grainextract, downscaled to coarse cells by area average; the max |cell−128| is the worst region's mean shift) and resolveDeVerifyMaxShift() (env HF_DE_VERIFY_MAX_SHIFT in [1, 64] arms the gate; unset = observe-only).
  • packages/producer/src/services/render/stages/captureStreamingStage.ts — streaming drain guard computes both metrics, records verifyMaxShiftObserved, and throws kind: "shift" only when the gate is armed and trips.
  • packages/engine/src/services/parallelCoordinator.ts — same two-gate check on the parallel disk-verify path (assertDiskSampleVerified, exported for tests).
  • packages/engine/src/services/frameCapture.ts — DrawElementVerificationDetails.kind gains "shift" with failedShift/verifyMaxShift structured fields (no message parsing).
  • renderOrchestrator.ts / observability.ts / perfSummary.ts — telemetry widened on both paths: a shift trip reports de_fallback_reason = "shift" with de_fallback_failed_shift + de_fallback_max_shift, and clean renders surface de_verify_max_shift_observed for calibration.
  • packages/cli/src/telemetry — both fields flow into render_complete/render_error properties.

Measured on the repro pair (1080p variant): missing band PSNR 44.1 dB / shift 5, identical pair shift 0, independent encoder noise ≤2, saturated damage 127 (clamped). Chroma-heavy damage is caught too (rgb24 diff, not luma-only).

Test plan

  • Unit tests added/updated
  • Manual testing performed
  • Documentation updated (if applicable)

New regression coverage, all passing locally:

  • packages/engine/src/utils/psnr.test.ts (new): real-ffmpeg repro of drawElement self-verify PSNR gate cannot detect missing low-contrast content #3345 — the missing low-contrast element passes the old 32 dB gate (44 dB) but trips the region gate; identical/noisy pairs stay under the ceiling; saturated damage reads far above it. Plus mocked-execFile unit tests for the rawvideo scan and both env resolvers.
  • frameCapture.test.ts: kind: "shift" details survive getDrawElementVerificationDetails round-trip without colliding with the dB fields.
  • parallelCoordinator.test.ts: assertDiskSampleVerified throws kind: "shift" at db=44/shift=13, still throws kind: "psnr" below the floor, and passes a clean sample.
  • captureStreamingStage.test.ts: end-to-end drain-guard tests — a verified frame with PSNR 44 / shift 13 is rejected when the gate is armed (HF_DE_VERIFY_MAX_SHIFT=3), the same sample is accepted with verifyMaxShiftObserved = 13 when unarmed, and PSNR 48 / shift 1 is accepted either way.
packages/engine:   3 test files, 92 tests passed (vitest)
packages/producer: captureStreamingStage.test.ts — 14 pass / 0 fail (bun test)
typecheck:         tsc --noEmit clean in both packages
lint/format:       oxlint 0 warnings, oxfmt clean

Out of scope: the issue's second hole — verification only samples truth frames, so unsampled frames stay unchecked — is unchanged and worth a follow-up.

AI-assisted. I wrote and verified this change.

@santhiprakash
santhiprakash force-pushed the fix/de-verify-region-shift branch from 3f38e4d to 346777e Compare September 17, 2026 01:22
@santhiprakash
santhiprakash force-pushed the fix/de-verify-region-shift branch 2 times, most recently from fc8199f to 39eef86 Compare September 28, 2026 20:37
Whole-frame magnitude-weighted PSNR cannot see a fully-missing
low-contrast element: the issue-3345 repro (a ~4/255 band over ~15%
of the frame) scores 44dB, 12dB clear of the 32dB floor, and ships
a wrong MP4 with exit code 0.

Add regionShift(): one ffmpeg pass (grainextract + area downscale)
reporting the worst ~96px region's mean signed channel shift in
1/255 units. Encoder noise cancels inside a region while an absent
element leaves its whole footprint shifted by its own delta, so a
missing region can no longer be diluted by a correct background.

Both the streaming drain guard and the parallel disk-path verify now
require PSNR >= HF_DE_VERIFY_MIN_DB AND region shift <=
HF_DE_VERIFY_MAX_SHIFT (default 3/255). Either breach throws
DrawElementVerificationError (new kind "shift"), which the
orchestrator already converts into the never-wrong screenshot
fallback — matching the videoFrameCoverage fail-closed precedent.

Closes heygen-com#3345

@jrusso1020 jrusso1020 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm commenting rather than approving. The mechanism is sound, but the default threshold is a policy call, and nothing yet shows it won't trip on clean renders.

What works

  • packages/engine/src/utils/psnr.ts:57: computing regionShift with grainextract plus an area downscale is a good way to get a signed per-region mean. It answers the question the issue raises ("did content go missing") instead of the averaged-magnitude question PSNR answers.
  • Both gates are wired the same way in the streaming drain (captureStreamingStage.ts:431-441) and on the disk path (parallelCoordinator.ts:747). The new kind: "shift" survives getDrawElementVerificationDetails.

Concern: the default of 3/255 isn't calibrated on real drawElement-vs-screenshot pairs
Every measurement in the PR is synthetic. I ran the exact filter chain from psnr.ts:77 on a few image pairs:

Pair PSNR regionShift
testsrc2 JPEG q2 vs q10 n/a 1
testsrc2, 1px blur or 1px x-offset n/a 1
testsrc2, gamma 1.02 / 1.04 n/a 2 / 4
Same line of 28px text, +1px stroke (a font-weight/hinting difference) 33.1 dB (passes) 10 (trips)

Encoder noise and subpixel offsets stay well under the ceiling, as claimed. But a small, systematic rasterization difference trips it while PSNR passes. That includes text weight and a slight gamma or color-management drift between the canvas path and the screenshot. If drawElement and the screenshot differ that way on real content, many renders fall back to the slower screenshot path. The output is still correct, but the speed cost is large.

Before this ships as a default-on gate, I'd like to see:

  1. regionShift on clean renders of the producer regression fixtures, with drawElement on, so the distribution behind "3" is real data.
  2. The failing shift added to telemetry. deVerifyFallbackTelemetry (renderOrchestrator.ts:2814) records reason: "shift" and the frame, but not failedShift/verifyMaxShift. Without them, a production false-positive rate can't be tuned.

A maintainer also needs to decide whether a new default-on gate and a new HF_DE_VERIFY_MAX_SHIFT env var are wanted, versus shipping it in observe-only mode first.

What I ran

  • vitest run src/utils/psnr.test.ts (real ffmpeg): 9/9 pass.
  • I couldn't run frameCapture.test.ts or parallelCoordinator.test.ts locally, because they need a built core runtime. CI runs them and they're green.

— Rames

…elemetry

Wire failedShift/verifyMaxShift from DrawElementVerificationError through
deVerifyFallbackTelemetry into RenderCaptureObservability, the perfSummary
drawElement aggregate, CLI render_complete/render_error props, and the
crash-survival observability payload as de_fallback_failed_shift and
de_fallback_max_shift. A production false-positive rate for the new gate
can now be measured from telemetry instead of inferred from fallback
counts alone.
Calibration on the producer regression fixtures found no threshold that
separates a missing low-contrast region (needs ~3/255) from benign
divergence: compositor-animated CSS content measured 18-27/255, and a
truth-vs-truth screenshot pair of the same seek differed by 22/255.
With the gate default-on at 3, clean renders would eat screenshot
fallbacks.

HF_DE_VERIFY_MAX_SHIFT now arms the fail-closed shift fallback only
when set in [1, 64]; unset or invalid leaves the check observe-only.
Every verified sample still logs its measured shift, and the streaming
drain aggregates the observed max into verifyMaxShiftObserved on the
perf summary and the de_verify_max_shift_observed telemetry property,
so a real distribution can be collected before a ceiling is chosen.

Also replaces the two em-dashes the studio copy-dashes check flags in
the new verify error strings and trims the added field comments back
under the comment-ratchet share.
@santhiprakash

Copy link
Copy Markdown
Contributor Author

Calibration data + gate-policy change pushed in b1087b6f.

Real-fixture calibration

Method: drove the production regression fixtures through the same capture functions the verify path uses — compiled each fixture, rendered truth via Page.captureScreenshot (jpeg q=80), captured the same frame indices via the page-side drawElementImage API (--enable-features=CanvasDrawElement, Chromium 155), then ran the real psnrDb/regionShift on each pair. 56 pairs across 15 fixtures; d3-adapter timed out at 60s and is excluded.

Fixture Samples PSNR (dB) regionShift
fast-capture-gsap 8 55.2–61.5 0
parallel-capture-regression 8 51.0–51.4 1
gsap-letters-render-compat 8 43.6–84.1 0–1
animejs-adapter 8 52.7–52.9 1
gsap-call-render-seek 4 50.7 1
wysiwyg-subcomp-css 4 49.7 1
font-variant-numeric / variables-prod / css-import-scoping / many-cuts / sub-comp-t0 / nested-subcomp-depth-3 / missing-host-comp-id / subcomp-bare-fragment 1–8 each 49.2–inf 0
css-spinner-render-compat 4 32.0–35.4 18–27

The finding that decides the policy

css-spinner-render-compat is a data-no-timeline comp with a pure CSS animation: spin — DE-eligible, no gate routes it to screenshot. First suspicion was a DE rasterization defect, but the control runs show it is a truth-floor problem, not a DE problem:

  • two back-to-back screenshots of the same seek: PSNR 34.3 dB, shift 23
  • a second screenshot-vs-screenshot pair at the same frame: PSNR 34.8 dB, shift 22
  • same comparison after 5s idle: inf / 0

The CSS animation phase is nondeterministic under the capture sequence, so even truth-vs-truth disagrees far above the proposed ceiling. Combined with your synthetic results (gamma 1.04 → 4, +1px stroke → 10) and the #3345 repro needing ≥3–5, no default threshold separates the damage class from this benign class — default-on would cost screenshot fallbacks on legitimate renders.

What changed

  • HF_DE_VERIFY_MAX_SHIFT is now unset-by-default observe-only: the shift is still computed and logged on every verified sample, and the observed max feeds a new verifyMaxShiftObserved drain stat → drawElement.verifyMaxShiftObserved on the perf summary → de_verify_max_shift_observed on render_complete telemetry. Setting it in [1, 64] arms the fail-closed gate exactly as before (both streaming and parallel-disk paths, same kind: "shift" error → screenshot fallback).
  • Your telemetry ask landed in 8784b51d: failedShift/verifyMaxShift propagate through deVerifyFallbackTelemetry → de_fallback_failed_shift + de_fallback_max_shift on render_error, plus the crash-survival observability mirror.
  • This gives us the clean-render production distribution needed to pick a real ceiling — or to scope the gate to non-nondeterministic comp classes (e.g. skip when the comp has wall-clock CSS animation) before arming it.

Recommendation: keep it observe-only until telemetry shows where production clusters land; the env escape hatch arms it immediately for any install that wants fail-closed now.

Also fixed the two CI items this commit: the em-dashes check:studio-copy-dashes flagged in the new error strings, and the comment-ratchet share bump in observability/perfSummary.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

drawElement self-verify PSNR gate cannot detect missing low-contrast content

2 participants