Skip to content

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

Merged
vanceingalls merged 1 commit intomainfrom
vance/opacity-pipeline
Apr 23, 2026
Merged

fix(engine): stop clobbering native <video> opacity in HDR pipeline#368
vanceingalls merged 1 commit intomainfrom
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

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/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 2 times, most recently from c3a8e99 to 7aa30e6 Compare April 22, 2026 23:26
@vanceingalls vanceingalls force-pushed the vance/transition-defaults branch 2 times, most recently from 680c322 to d095760 Compare April 22, 2026 23:57
@graphite-app graphite-app Bot changed the base branch from vance/transition-defaults to graphite-base/368 April 22, 2026 23:57
@vanceingalls vanceingalls force-pushed the vance/opacity-pipeline branch from 7aa30e6 to 70b666a Compare April 22, 2026 23:58
@graphite-app graphite-app Bot changed the base branch from graphite-base/368 to vance/transition-defaults April 22, 2026 23:58
@vanceingalls vanceingalls changed the base branch from vance/transition-defaults to graphite-base/368 April 23, 2026 00:02
@vanceingalls vanceingalls force-pushed the vance/opacity-pipeline branch from 70b666a to fc9d37b Compare April 23, 2026 00:02
@vanceingalls vanceingalls changed the base branch from graphite-base/368 to main April 23, 2026 00:03
@vanceingalls vanceingalls force-pushed the vance/opacity-pipeline branch from fc9d37b to 8398204 Compare April 23, 2026 00:45
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 8398204 to b3cc92d Compare April 23, 2026 01:58
@vanceingalls vanceingalls merged commit 2d57918 into main Apr 23, 2026
41 of 44 checks passed
Copy link
Copy Markdown
Collaborator Author

Merge activity

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