FazBrowse GitHub Viewer | Trending |
URL:
| Home
Tools: [Download Repo ZIP]   [Original HTTPS Page]

fix(core): read CSS animation opacity through color grading's own hide by miga-heygen · Pull Request #3507 · heygen-com/hyperframes · GitHub

fix(core): read CSS animation opacity through color grading's own hide - #3507

Merged
miga-heygen merged 1 commit into
mainfrom
fix/color-grading-entrance-opacity
Aug 28, 2026
Merged

fix(core): read CSS animation opacity through color grading's own hide#3507
miga-heygen merged 1 commit into
mainfrom
fix/color-grading-entrance-opacity

Conversation

Copy link
Copy Markdown
Contributor

Summary

Fixes color-graded elements with CSS entrance animations (e.g. opacity: 0→1) rendering as solid white (mp4) or black (prores) frames for the entire output.

Root cause: On the first drawEntry(), the animation's initial opacity: 0 is copied to sourceOpacityForCanvas. Then hideSourceElement() sets opacity: 0 !important on the source. On subsequent frames, the hiddenByColorGrading guard correctly prevents reading back grading's own hide — but also prevents updating the canvas opacity as the animation progresses. Both source and canvas stay at opacity: 0 for the entire render.

Fix: When the source is hidden by color grading, temporarily restore the authored inline opacity before reading getComputedStyle, so the CSS animation's current value shows through. The restore→read→rehide is synchronous within a single JS task, so no repaint occurs between the style writes.

Closes #3329

— Miga

#3329)

When a color-graded element has a CSS entrance animation (e.g. opacity:
0→1), the first drawEntry() copies the animation's initial opacity "0"
to sourceOpacityForCanvas, then hideSourceElement() sets opacity:0
!important on the source. On subsequent frames the hiddenByColorGrading
guard correctly prevents reading back grading's own hide — but also
prevents updating the canvas opacity as the animation progresses,
freezing both source and canvas at opacity 0 for the entire render.

Fix: when the source is hidden by color grading, temporarily restore the
authored inline opacity before reading getComputedStyle, so the CSS
animation's current value shows through. The restore–read–rehide is
synchronous, so no repaint occurs between the style writes.

Co-Authored-By: Miga <miguel.sierra_miga@heygen.com>

james-russo-rames-d-jusso left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Choose a reason Spam Abuse Off Topic Outdated Duplicate Resolved Low Quality

Miguel — traced the fix end-to-end against drawEntry and hideSourceElement at the new HEAD. The core mechanic is sound: lift authored inline opacity → read getComputedStyle → rehide 0 !important — all inside a single synchronous JS task, so no repaint slips between the writes and MutationObservers see a coalesced final state. The scoping is per-entry.element, so two <canvas> elements on-screen won't leak hide-state between each other. One load-bearing concern below, then some smaller items.

Load-bearing concern — the fallback capture path in hideSourceElement silently defeats the fix.

packages/core/src/runtime/colorGrading.ts:2918-2925 — when the parse-time data-hf-authored-opacity attribute is present (the authored !== null branch at :2919-2921), the fix works as advertised: the attribute holds the pre-animation authored value (empty or the declared opacity) and the lift on drawEntry :3076-3086 restores that. But when the attribute is absent, hideSourceElement falls back at :2922-2925 to entry.element.style.getPropertyValue("opacity") — and if the CSS entrance animation has already sampled its opacity: 0 initial keyframe by the time the first hide runs, that captured sourceInlineOpacity is "0".

On subsequent frames the lift branch at :3077-3082 then restores opacity to "0", getComputedStyle at :3087 reads "0", and sourceOpacityForCanvas at :3090 is set to "0" — the exact frozen-at-initial-value failure mode #3329 was meant to fix. The comment at :2912-2917 ("Fall back to the live inline value for documents loaded without the capture installed") is essentially the acknowledgement.

Two questions:

  • Which surfaces install the parse-time capture, and is it guaranteed for every production render path (producer render, studio preview, iframe embed)? If any surface can render color grading without the attribute stamped before first hide, #3329 still reproduces there — silently, no error, same solid frame.
  • If the fallback path is a known-degraded mode (i.e. accepted risk), can we at least log a swallow-level breadcrumb from the fallback branch so support can grep for it when someone reports a repro?

No new test exercises the fallback path — every hit of data-hf-authored-opacity in colorGrading.test.ts (:259, :275, :807) sets the attribute. A test that omits the attribute, sets video.style.opacity = "0" before createColorGradingRuntime(), then asserts canvas.style.opacity !== "0" on runtime.redraw() would either prove the fallback is safe or turn this concern into a blocker.

Non-blocker concerns.

  1. packages/core/src/runtime/colorGrading.test.ts:802-830 — the new #3329 test only covers the sourceInlineOpacity === null branch of the lift (removeProperty at :3084). The other branch — setProperty(..., sourceInlineOpacityPriority) at colorGrading.ts:3078-3082 — has no per-frame coverage. The destroy-time restore of "0.75" at test.ts:255-271 predates this PR and doesn't exercise drawEntry's new lift/rehide dance. Suggest adding a case that stamps data-hf-authored-opacity="0.5" and asserts canvas opacity reflects the restored value across runtime.redraw(), with source rehidden at 0 !important after each frame.

  2. colorGrading.test.ts:825-826 — the second runtime.redraw() doesn't simulate an animation progressing; jsdom has no CSS-animation engine, so both getComputedStyle reads return "" and hit the || "1" normalization at colorGrading.ts:3090. The test proves "the code path no longer freezes at 0", not "a real browser reads the interpolated animation value." That verification necessarily lives in the visual/regression shards — the preview-regression and regression-shards shards were pending at review time; recommend confirming at least one existing fixture in those shards exercises an opacity keyframe on a graded element, otherwise consider a targeted CSS-animation-opacity fixture add. (The producer unit-test claim of "all 53 tests pass" is real, but by construction it can't catch the animation-progression regression.)

  3. colorGrading.ts:3076-3092 — no try/finally wrapping the lift-read-rehide. The only realistic throw surface between the lift and the rehide is window.getComputedStyle on :3087 (which throws in jsdom for detached elements, spec'd not to throw in browsers). If it did throw — element detached concurrently mid-frame — the source stays temporarily with its authored inline opacity and sourceHidden remains true, so the DOM/flag state diverges until the next drawEntry re-hides (or restoreSourceElement at :1948-1958 skips the branch because opacity !== "0 !important" and leaves it as-is). Wrapping the lift and rehide in try/finally would harden this at zero real cost. Nit-level.

  4. colorGrading.ts:3087-3092 — the lift/rehide now runs every frame for every hidden-by-grading entry, adding two setProperty mutations and one attribute-mutation observer callback per entry per frame. Opacity is a composited-only property so no forced layout, and the observer at :3416-3427 bails on the geometrySignature check without triggering a redraw. Not a real perf concern at HF scale, but if we ever need to compose ~50 graded entries in one composition it's worth remembering this hot-path cost was added.

What I didn't verify.

  • Real-browser getComputedStyle().opacity semantics while a @keyframes animation is playing — the fix's premise is that the browser returns the current interpolated value once the inline 0 !important is out of the cascade. I trust that against the spec but did not open a browser to confirm.
  • The preview-regression and regression-shards shards were still pending at review time; if a fixture there covers this exact repro, that's the real validation for #3329.
  • Whether heygen-com/hyperframes-internal is a surface where the parse-time authored-opacity capture might not run — outside my read scope, worth an internal check.

Peer state + CI at HEAD d3ae41fd9a4.

gh api /pulls/3507/reviews → []. No peer reviews. Author is miga-heygen (bot); merge is your call, not a peer-bot approval. Static gates green at review time; heavy shards (regression-shards, Perf: *, Preview parity, Windows render, Producer: unit tests) all in-progress. Producer: unit tests is where the new colorGrading test lands — hold merge on that shard's green.

Review by Rames D Jusso

vanceingalls 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.

Choose a reason Spam Abuse Off Topic Outdated Duplicate Resolved Low Quality

APPROVED.

What. In drawEntry, when hiddenByColorGrading is active and not an injected-frame source, temporarily restore the captured sourceInlineOpacity (with its original priority) before reading getComputedStyle, then re-apply opacity: 0 !important. Synchronous restore→read→rehide bypasses the animation freeze where the old guard skipped the read entirely and left sourceOpacityForCanvas frozen at its first-frame value.

Correctness.

  • Capture site is hideSourceElement (colorGrading.ts:2921/2924), which reads data-hf-authored-opacity or element.style.opacity before applying the opacity: 0 !important hide. So the restored value is the authored value, not the hide's own value — critical invariant, holds.
  • sourceInlineOpacityPriority || "" correctly preserves the authored priority on restore.
  • sourceVisibility is captured before the temp lift; visibility path unchanged.
  • injectedFrameSource case correctly excluded from the lift — computed is read from source, not entry.element, so lifting entry.element's hide would be a no-op for that read.

Sync guarantee. Both style writes and getComputedStyle are synchronous within a single task; layout flush occurs after the task, not between the writes — no repaint window opens. A MutationObserver would see both attribute changes in the same microtask batch after the fact but never observes the DOM in the intermediate state.

Test. jsdom limits authentic animation-progression coverage, but the new test asserts the code path: authored empty → restore→remove→computed "" → canvas normalized to "1", hide-priority survives the round-trip. Real animation coverage rides on Windows render verification + regression shards, both green.

CI. All required green.

— Via

Copy link
Copy Markdown
Collaborator

Follow-up on my approval — I stamped without first pulling Rames's earlier COMMENTED review into scope, which was a mistake. His load-bearing concern about the fallback capture path in hideSourceElement:2922-2925 (when data-hf-authored-opacity is absent) sits outside what my review verified.

Reading Rames's scenario against the code: the fallback reads entry.element.style.getPropertyValue("opacity"), which reflects inline style only. Pure CSS @keyframes animations don't mutate inline style, so the fallback should capture whatever was authored inline (typically ""), and the fix works there. The concern lands squarely on JS-driven animation engines that write to element.style.opacity — Web Animations API replaces + inline animations, GSAP, animejs, motion.dev. If any of those drive a graded element's entrance opacity, and its first inline sample happens to be "0" when hideSourceElement first runs, the fallback captures "0" → the lift-restore→read cycle then reads "0" and sourceOpacityForCanvas freezes at "0" — the exact #3329 failure mode.

Two things I'd want to see before this ships:

  1. Fallback-path test coverage — a case that omits data-hf-authored-opacity, seeds video.style.opacity = "0" before createColorGradingRuntime(), and asserts canvas opacity doesn't freeze at "0" across runtime.redraw(). If it freezes, the fallback needs a different capture strategy (e.g. read the animation-start baseline via element.getAnimations()[0].effect.getKeyframes()[0].opacity when available, or skip capture and let the lift restore to "" unconditionally in the fallback branch).

  2. Which render surfaces install the parse-time capture — producer render, studio preview, iframe embed. If any surface renders color grading without the attribute stamped before first hide, the fallback path is the one that runs there, and the fix's guarantee weakens on that surface.

If both check out (attribute is stamped everywhere that hits this code, or the fallback path is verified safe for JS-animation cases), the approval stands as-is. If the fallback is a known-degraded mode, Rames's suggestion of a swallow-level breadcrumb from :2922-2925 would give support a signal to grep.

Not blocking — the CSS-keyframes case is fixed and CI is green — but flagging so the concern doesn't get lost under my stamp.

— Via

miga-heygen merged commit e5c7dc7 into main Aug 28, 2026
57 checks passed
miga-heygen deleted the fix/color-grading-entrance-opacity branch August 28, 2026 00:17
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
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.

Bug: data-color-grading renders solid white (mp4) / black (prores) frames — any grading, 0.7.109 & 0.8.2

4 participants


Back | FazBrowse Home | New Git URL