fix(engine): stop clobbering native <video> opacity in HDR pipeline#368
fix(engine): stop clobbering native <video> opacity in HDR pipeline#368vanceingalls merged 1 commit intomainfrom
Conversation
8b73dcf to
f442500
Compare
ea0e56a to
9cc8938
Compare
7d75173 to
d9bc49d
Compare
9cc8938 to
c4de50b
Compare
jrusso1020
left a comment
There was a problem hiding this comment.
Fix is at the right layer and the root-cause analysis in the PR body is what I'd expect from a staff-eng writeup. Three things I want to call out explicitly:
Fix is structural, not a workaround. Hiding <video> via visibility: hidden instead of opacity: 0 !important is the correct primitive — visibility removes the element from the render tree without touching its computed opacity, so GSAP-controlled opacity stays readable on every frame. The previous code was compensating for its own stomp with parseFloat(...) || 1, which silently coerced legitimate opacity 0 into 1 and masked the bug for anyone not specifically testing direct-on-<video> fades. Replacing that compensator with Number.isNaN-guarded defaults (1B) is the right tightening — it makes the code tell the truth about its inputs instead of papering over them.
1C is a real bug too. resolveRadius casting el as HTMLElement would have runtime-crashed on <svg> or any non-HTML element in the element stack. instanceof HTMLElement is the right shape because it doesn't just narrow the type, it checks the actual prototype chain. Good defensive fix.
1D is correctly downstream of 1A. The walk-from-parent in queryVideoElementBounds existed only to route around the engine's own opacity stomp. Now that the stomp is gone, walking from the element itself is what you'd naively expect from the start. Leaving the isHdrEl lookup intact is the right call — the transform/border-radius branches below still need it for unrelated reasons.
Non-blocking things worth eyeballing:
-
CI shows 4 failures including
regression-shards (fast)andstyles-b. Worth checking whether any of those are caused by this change — a test that was passing only becauseopacity: 0happened to hide some element would now fail becausevisibility: hiddenpreserves layout-box but removes from paint. Specifically: any composition where a hidden<video>'s zero-opacity was previously masking pointer-events, stacking ambiguity, or sibling visibility expectations could flip. If those failures are in compositions without native HDR video, the suspicion deserves one more trace. -
Equivalence caveat:
visibility: hiddenandopacity: 0differ on one thing that matters for us here —visibilitydoesn't create a new stacking context,opacity: 0does (when opacity < 1 and the element is positioned). For a native<video>that's opacity-1 → 0 tweened by GSAP, you're now relying on the DOM's natural stacking order without the opacity-induced stacking context. Worth mentally verifying that nothing inqueryElementStackingwas implicitly depending on the video element being its own stacking-context parent. -
Tests: the PR body notes 308/308 engine tests pass, but the producer-side
hdr-regressionWindow C golden is regenerated in the next PR (#369). I'd have liked to see at least one inline test here forqueryVideoElementBoundsthat pins the new opacity-walk-from-self behavior directly — the regression suite is great at catching pixel-level regressions but a unit test is faster for bisecting future breakage. Not blocking.
Approved.
— Rames Jusso
d9bc49d to
13b2c4c
Compare
c4de50b to
0eed2ab
Compare
9ca6e82 to
b1745dd
Compare
8f5f477 to
e56820c
Compare
c988cbe to
12786d9
Compare
e56820c to
35d6cb8
Compare
12786d9 to
57d5ff9
Compare
35d6cb8 to
03227b5
Compare
57d5ff9 to
a9f1325
Compare
24ba6ee to
7ccb234
Compare
a9f1325 to
142b39d
Compare
7ccb234 to
0a77088
Compare
142b39d to
03cc34d
Compare
0a77088 to
f25df83
Compare
c3a8e99 to
7aa30e6
Compare
680c322 to
d095760
Compare
680c322 to
d095760
Compare
7aa30e6 to
70b666a
Compare
70b666a to
fc9d37b
Compare
d095760 to
9512744
Compare
fc9d37b to
8398204
Compare
Four related bugs in the opacity pipeline that interact with HDR video
compositing and GSAP-controlled fades:
1A. screenshotService injectVideoFramesBatch and syncVideoFrameVisibility
were applying `opacity: 0 !important` to native <video> elements to
hide them under the injected <img>. That stomp clobbered any
GSAP-controlled inline opacity, so the next seek read 0 from
computed style and the comp went black. We now use
`visibility: hidden !important` only — visibility hides the element
from rendering without changing its opacity, so subsequent reads
(and queryElementStacking) see the real GSAP value on every frame.
The `parseFloat(...) || 1` recovery hack at injectVideoFramesBatch
was specifically there to compensate for this stomp; it’s now
replaced with a `Number.isNaN` guard that defaults to 1 only when
parsing actually fails.
1B. queryVideoElementBounds parsed `style.opacity` and `style.zIndex`
with `parseFloat(...) || N`, which silently coerces a real opacity
of 0 into 1 (and zIndex 0 into 0 only by coincidence). Switched to
explicit `Number.isNaN` checks so opacity 0 stays 0.
1C. resolveRadius cast `el as HTMLElement` to read offsetWidth/Height.
SVG and other non-HTML elements would have crashed at runtime.
Replaced the cast with an `instanceof HTMLElement` guard, and made
the numeric fallback `Number.isNaN`-safe.
1D. The opacity walk in queryVideoElementBounds started from
`el.parentElement` for HDR videos to skip past the engine’s forced
`opacity: 0` on the element itself. Now that the engine never sets
opacity, the special case is unnecessary — always walk from `el`.
Kept the `isHdrEl` lookup because transform/border-radius logic
further down still branches on it.
Verified:
- bun run --filter @hyperframes/engine typecheck (clean)
- bun run --filter @hyperframes/engine test (308/308 passing)
- bun run --filter @hyperframes/producer typecheck (clean)
- oxlint + oxfmt --check on both touched files
Next: regenerate hdr-regression window C (the opacity-fade window) and
tighten its maxFrameFailures budget — done in a follow-up commit so the
golden churn is reviewable separately from the code fix.
8398204 to
b3cc92d
Compare
Merge activity
|

Summary
Fix four interrelated bugs in the opacity pipeline. The headline fix: the HDR compositor was effectively ignoring direct-on-
<video>opacity animation because the engine itself was clobbering inline opacity withopacity: 0 !important— switching tovisibility: hiddenresolves the bug at the root.Why
Chunk 1ofplans/hdr-followups.md. This was the most user-visible bug in the entire follow-ups list: a GSAP-controlled opacity tween directly on a<video>element under HDR rendered at full brightness instead of fading.What changed
1A — Stop clobbering native
<video>opacity.screenshotService.injectVideoFramesBatchandsyncVideoFrameVisibilitywere applyingopacity: 0 !importantto native<video>elements to hide them under the injected<img>. That stomp clobbered any GSAP-controlled inline opacity, so the next seek read 0 from computed style and the comp went black. Switched tovisibility: hidden !importantonly. Visibility hides the element from rendering without changing its opacity, so subsequent reads (andqueryElementStacking) see the real GSAP value on every frame. TheparseFloat(...) || 1recovery hack atinjectVideoFramesBatchwas specifically there to compensate for this stomp; it's now replaced with aNumber.isNaNguard that defaults to 1 only when parsing actually fails.1B —
Number.isNaNguards inqueryVideoElementBounds.parseFloat(style.opacity) || 1silently coerced a real opacity of 0 into 1. Switched to explicitNumber.isNaNchecks so opacity 0 stays 0. Same fix forparseFloat(style.zIndex).1C —
instanceof HTMLElementinstead of cast.resolveRadiuscastel as HTMLElementto readoffsetWidth/Height. SVG and other non-HTML elements would have crashed at runtime. Replaced the cast with aninstanceof HTMLElementguard, and made the numeric fallbackNumber.isNaN-safe.1D — Opacity walk starts from the element itself. The walk in
queryVideoElementBoundsstarted fromel.parentElementfor HDR videos to skip past the engine's forcedopacity: 0on the element itself. Now that the engine never sets opacity, the special case is unnecessary — always walk fromel. Kept theisHdrEllookup because transform/border-radius logic further down still branches on it.Test plan
bun run --filter @hyperframes/engine typecheckclean.bun run --filter @hyperframes/engine test— 308/308 passing.bun run --filter @hyperframes/producer typecheckclean.oxlint+oxfmt --checkon both touched files.hdr-regressionWindow C (the direct-opacity window) now passes against the regenerated golden — see follow-up PR in this stack which tightens the budget.Stack
Chunk 1 of
plans/hdr-followups.md. Window C of the regression suite documents the bug; the next PR in the stack regenerates the golden and tightens itsmaxFrameFailuresbudget.