Skip to content

fix(engine): stop clobbering native <video> opacity in HDR pipeline#368

Open
vanceingalls wants to merge 1 commit intovance/transition-defaultsfrom
vance/opacity-pipeline
Open

fix(engine): stop clobbering native <video> opacity in HDR pipeline#368
vanceingalls wants to merge 1 commit intovance/transition-defaultsfrom
vance/opacity-pipeline

Conversation

@vanceingalls
Copy link
Copy Markdown
Collaborator

@vanceingalls vanceingalls commented Apr 21, 2026

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 with opacity: 0 !important — switching to visibility: hidden resolves the bug at the root.

Why

Chunk 1 of plans/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.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. Switched to 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 — Number.isNaN guards in queryVideoElementBounds. parseFloat(style.opacity) || 1 silently coerced a real opacity of 0 into 1. Switched to explicit Number.isNaN checks so opacity 0 stays 0. Same fix for parseFloat(style.zIndex).

1C — instanceof HTMLElement instead of cast. 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 — Opacity walk starts from the element itself. The 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.

Test plan

  • 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.
  • hdr-regression Window 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 its maxFrameFailures budget.

Copy link
Copy Markdown
Collaborator Author

vanceingalls commented Apr 21, 2026

Warning

This pull request is not mergeable via GitHub because a downstack PR is open. Once all requirements are satisfied, merge this PR as a stack on Graphite.
Learn more

This stack of pull requests is managed by Graphite. Learn more about stacking.

This was referenced Apr 21, 2026
@vanceingalls vanceingalls force-pushed the vance/opacity-pipeline branch from 8b73dcf to f442500 Compare April 21, 2026 20:54
@vanceingalls vanceingalls marked this pull request as ready for review April 21, 2026 20:57
@vanceingalls vanceingalls force-pushed the vance/transition-defaults branch from ea0e56a to 9cc8938 Compare April 22, 2026 01:16
@vanceingalls vanceingalls force-pushed the vance/opacity-pipeline branch 2 times, most recently from 7d75173 to d9bc49d Compare April 22, 2026 02:03
@vanceingalls vanceingalls force-pushed the vance/transition-defaults branch from 9cc8938 to c4de50b Compare April 22, 2026 02:03
Copy link
Copy Markdown
Collaborator

@jrusso1020 jrusso1020 left a comment

Choose a reason for hiding this comment

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

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:

  1. CI shows 4 failures including regression-shards (fast) and styles-b. Worth checking whether any of those are caused by this change — a test that was passing only because opacity: 0 happened to hide some element would now fail because visibility: hidden preserves 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.

  2. Equivalence caveat: visibility: hidden and opacity: 0 differ on one thing that matters for us here — visibility doesn't create a new stacking context, opacity: 0 does (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 in queryElementStacking was implicitly depending on the video element being its own stacking-context parent.

  3. Tests: the PR body notes 308/308 engine tests pass, but the producer-side hdr-regression Window C golden is regenerated in the next PR (#369). I'd have liked to see at least one inline test here for queryVideoElementBounds that 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

@vanceingalls vanceingalls force-pushed the vance/opacity-pipeline branch from d9bc49d to 13b2c4c Compare April 22, 2026 04:43
@vanceingalls vanceingalls force-pushed the vance/transition-defaults branch from c4de50b to 0eed2ab Compare April 22, 2026 04:43
@vanceingalls vanceingalls force-pushed the vance/transition-defaults branch from 0eed2ab to d0aa979 Compare April 22, 2026 05:09
@vanceingalls vanceingalls force-pushed the vance/opacity-pipeline branch from 13b2c4c to 724cd72 Compare April 22, 2026 05:09
@vanceingalls
Copy link
Copy Markdown
Collaborator Author

Thanks. Three notes addressed:

1. CI failures. fast and styles-b finished after you reviewed and both passed; only regression-shards (hdr) is red, which is exactly the Window C re-baseline this PR documents and #369 lands. So the failure cascade is bounded to the HDR golden, not a side-effect of visibility: hidden flipping a non-HDR composition's stacking/pointer-events expectations.

2. Stacking context implications of visibility: hidden. Verified — queryElementStacking does not depend on the video element being its own stacking-context parent. The getEffectiveZIndex walk only considers explicit z-index on positioned (non-static) ancestors and explicitly does NOT detect implicit stacking contexts from opacity < 1, transform, filter, will-change, isolation, or mix-blend-mode (see the long comment block at videoFrameInjector.ts:283-298). The compositor was never relying on the opacity: 0-induced stacking context to anchor the video — it was working in spite of it. Switching to visibility: hidden is strictly cleaner: same hide-from-paint, no stacking-context side effect, and the GSAP-controlled opacity now reads back through getEffectiveOpacity (which walks from el itself, multiplying ancestor opacities) on every frame.

3. Inline unit test for queryVideoElementBounds. Fair call. The function runs inside page.evaluate against a live Puppeteer DOM, so a true unit test needs JSDOM-with-DOMMatrix or a thin Puppeteer harness — non-trivial setup that's why the existing engine suite leans on the regression goldens for this layer. Tracking it in plans/hdr-followups.md as a follow-up rather than blocking on it here. The behavioral pin you wanted (opacity-walk-from-self preserving GSAP-controlled <video> opacity) is now caught end-to-end by the regenerated Window C in #369 — slower to bisect than a unit test, but correct.

Approving observations are non-blocking, so landing as-is.

@vanceingalls vanceingalls force-pushed the vance/opacity-pipeline branch from 724cd72 to e55f683 Compare April 22, 2026 06:21
@vanceingalls vanceingalls force-pushed the vance/transition-defaults branch 2 times, most recently from 6ab72a6 to aaa7abe Compare April 22, 2026 06:23
@vanceingalls vanceingalls force-pushed the vance/opacity-pipeline branch from e55f683 to bc3088f Compare April 22, 2026 06:23
@vanceingalls vanceingalls force-pushed the vance/transition-defaults branch from aaa7abe to e039969 Compare April 22, 2026 16:29
@vanceingalls vanceingalls force-pushed the vance/opacity-pipeline branch from bc3088f to 193e174 Compare April 22, 2026 16:30
@vanceingalls vanceingalls force-pushed the vance/transition-defaults branch from e039969 to acc87c8 Compare April 22, 2026 17:19
@vanceingalls vanceingalls force-pushed the vance/opacity-pipeline branch 2 times, most recently from 730156b to 9ca6e82 Compare April 22, 2026 17:30
@vanceingalls vanceingalls force-pushed the vance/transition-defaults branch from acc87c8 to 8f5f477 Compare April 22, 2026 17:30
@vanceingalls vanceingalls force-pushed the vance/opacity-pipeline branch from 9ca6e82 to b1745dd Compare April 22, 2026 18:35
@vanceingalls vanceingalls force-pushed the vance/transition-defaults branch from 8f5f477 to e56820c Compare April 22, 2026 18:50
@vanceingalls vanceingalls force-pushed the vance/opacity-pipeline branch 2 times, most recently from c988cbe to 12786d9 Compare April 22, 2026 18:59
@vanceingalls vanceingalls force-pushed the vance/transition-defaults branch from e56820c to 35d6cb8 Compare April 22, 2026 18:59
@vanceingalls vanceingalls force-pushed the vance/opacity-pipeline branch from 12786d9 to 57d5ff9 Compare April 22, 2026 19:34
@vanceingalls vanceingalls force-pushed the vance/transition-defaults branch from 35d6cb8 to 03227b5 Compare April 22, 2026 19:34
@vanceingalls vanceingalls force-pushed the vance/opacity-pipeline branch from 57d5ff9 to a9f1325 Compare April 22, 2026 20:45
@vanceingalls vanceingalls force-pushed the vance/transition-defaults branch from 24ba6ee to 7ccb234 Compare April 22, 2026 20:48
@vanceingalls vanceingalls force-pushed the vance/opacity-pipeline branch from a9f1325 to 142b39d Compare April 22, 2026 20:48
@vanceingalls vanceingalls force-pushed the vance/transition-defaults branch from 7ccb234 to 0a77088 Compare April 22, 2026 22:13
@vanceingalls vanceingalls force-pushed the vance/opacity-pipeline branch from 142b39d to 03cc34d Compare April 22, 2026 22:13
@vanceingalls vanceingalls force-pushed the vance/transition-defaults branch from 0a77088 to f25df83 Compare April 22, 2026 22:43
@vanceingalls vanceingalls force-pushed the vance/opacity-pipeline branch from 03cc34d to c3a8e99 Compare April 22, 2026 22:44
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.
@vanceingalls vanceingalls force-pushed the vance/opacity-pipeline branch from c3a8e99 to 7aa30e6 Compare April 22, 2026 23:26
@vanceingalls vanceingalls force-pushed the vance/transition-defaults branch from f25df83 to 680c322 Compare April 22, 2026 23:26
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.

2 participants