fix(core): read CSS animation opacity through color grading's own hide - #3507
fix(core): read CSS animation opacity through color grading's own hide#3507miga-heygen wants to merge 1 commit into
Conversation
#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
There was a problem hiding this comment.
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.
-
packages/core/src/runtime/colorGrading.test.ts:802-830— the new #3329 test only covers thesourceInlineOpacity === nullbranch of the lift (removePropertyat:3084). The other branch —setProperty(..., sourceInlineOpacityPriority)atcolorGrading.ts:3078-3082— has no per-frame coverage. The destroy-time restore of"0.75"attest.ts:255-271predates this PR and doesn't exercisedrawEntry's new lift/rehide dance. Suggest adding a case that stampsdata-hf-authored-opacity="0.5"and asserts canvas opacity reflects the restored value acrossruntime.redraw(), with source rehidden at0 !importantafter each frame. -
colorGrading.test.ts:825-826— the secondruntime.redraw()doesn't simulate an animation progressing; jsdom has no CSS-animation engine, so bothgetComputedStylereads return""and hit the|| "1"normalization atcolorGrading.ts:3090. The test proves "the code path no longer freezes at0", not "a real browser reads the interpolated animation value." That verification necessarily lives in the visual/regression shards — thepreview-regressionandregression-shardsshards 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.) -
colorGrading.ts:3076-3092— notry/finallywrapping the lift-read-rehide. The only realistic throw surface between the lift and the rehide iswindow.getComputedStyleon: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 andsourceHiddenremainstrue, so the DOM/flag state diverges until the nextdrawEntryre-hides (orrestoreSourceElementat:1948-1958skips the branch becauseopacity !== "0 !important"and leaves it as-is). Wrapping the lift and rehide intry/finallywould harden this at zero real cost. Nit-level. -
colorGrading.ts:3087-3092— the lift/rehide now runs every frame for every hidden-by-grading entry, adding twosetPropertymutations 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-3427bails on thegeometrySignaturecheck 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().opacitysemantics while a@keyframesanimation is playing — the fix's premise is that the browser returns the current interpolated value once the inline0 !importantis out of the cascade. I trust that against the spec but did not open a browser to confirm. - The
preview-regressionandregression-shardsshards 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-internalis 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
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 initialopacity: 0is copied tosourceOpacityForCanvas. ThenhideSourceElement()setsopacity: 0 !importanton the source. On subsequent frames, thehiddenByColorGradingguard correctly prevents reading back grading's own hide — but also prevents updating the canvas opacity as the animation progresses. Both source and canvas stay atopacity: 0for 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