From d3ae41fd9a409fa9499d68ddffd82ba298369800 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 26 Aug 2026 20:36:27 +0000 Subject: [PATCH] fix(core): read CSS animation opacity through color grading's own hide (#3329) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../core/src/runtime/colorGrading.test.ts | 30 +++++++++++++++++ packages/core/src/runtime/colorGrading.ts | 32 +++++++++++++------ 2 files changed, 53 insertions(+), 9 deletions(-) diff --git a/packages/core/src/runtime/colorGrading.test.ts b/packages/core/src/runtime/colorGrading.test.ts index 02b686e3f4..ec3be0b6de 100644 --- a/packages/core/src/runtime/colorGrading.test.ts +++ b/packages/core/src/runtime/colorGrading.test.ts @@ -799,6 +799,36 @@ describe("createColorGradingRuntime", () => { expect(canvas.style.opacity).toBe("1"); }); + it("updates canvas opacity through grading's own hide when CSS animation progresses (#3329)", () => { + const video = makeDrawableVideo(); + // Simulate a CSS entrance animation: the authored opacity is empty (no + // inline opacity), but at parse time the element has opacity 0 from the + // animation's initial keyframe. + video.setAttribute("data-hf-authored-opacity", ""); + document.body.appendChild(video); + + runtime = createColorGradingRuntime(); + const canvas = document.querySelector("[data-hf-color-grading-canvas]"); + if (!canvas) throw new Error("Expected color grading canvas"); + + // Source is hidden by color grading. + expect(video.style.getPropertyValue("opacity")).toBe("0"); + expect(video.style.getPropertyPriority("opacity")).toBe("important"); + + // The canvas opacity should NOT be frozen at "0" — after the hide is + // temporarily lifted, jsdom's getComputedStyle returns "" (no inline + // opacity set), which the code normalizes to "1". + expect(canvas.style.opacity).toBe("1"); + + // Simulate the CSS animation progressing by re-reading after a redraw. + runtime.redraw(); + expect(canvas.style.opacity).toBe("1"); + + // Source remains hidden throughout. + expect(video.style.getPropertyValue("opacity")).toBe("0"); + expect(video.style.getPropertyPriority("opacity")).toBe("important"); + }); + it("allows a drawable producer render frame to initialize hidden source grading", () => { const video = makeDrawableVideo(); video.style.display = "none"; diff --git a/packages/core/src/runtime/colorGrading.ts b/packages/core/src/runtime/colorGrading.ts index b21b2a3e1f..89b354d5e2 100644 --- a/packages/core/src/runtime/colorGrading.ts +++ b/packages/core/src/runtime/colorGrading.ts @@ -3065,16 +3065,30 @@ function drawEntry(entry: ColorGradingEntry): boolean { const sourceVisibility = entry.element.style.getPropertyValue("visibility"); const injectedFrameSource = isRenderFrameImage(source); if (injectedFrameSource) keepCanvasAboveSource(entry, source); + // When color grading hid the source (opacity:0 !important), temporarily + // restore the authored inline opacity so getComputedStyle reflects the CSS + // animation's current value instead of our own hide. Without this, a CSS + // entrance animation (opacity: 0→1) freezes at its first-frame value for + // the entire render because the !important blocks all animation effects on + // the property (#3329). The restore–read–rehide is synchronous, so no + // repaint occurs between the style writes. + if (hiddenByColorGrading && !injectedFrameSource) { + if (entry.sourceInlineOpacity !== null) { + entry.element.style.setProperty( + "opacity", + entry.sourceInlineOpacity, + entry.sourceInlineOpacityPriority || "", + ); + } else { + entry.element.style.removeProperty("opacity"); + } + } const computed = window.getComputedStyle(injectedFrameSource ? source : entry.element); - // `hideSourceElement` owns the source's inline opacity while grading is active - // (opacity:0 !important), so reading it back would mirror grading's own hide - // onto the canvas and blank it. That gate is about opacity ONLY: grading never - // writes `visibility`, so the source's computed visibility always tracks the - // clip window and has to be re-read every frame. Leaving it stale let the - // canvas keep an explicit `visibility: visible` and paint straight through an - // inactive ancestor clip's inherited `visibility: hidden`. - if (injectedFrameSource || !hiddenByColorGrading) { - entry.sourceOpacityForCanvas = computed.opacity || "1"; + // Grading never writes `visibility`, so the source's computed visibility + // always tracks the clip window and has to be re-read every frame. + entry.sourceOpacityForCanvas = computed.opacity || "1"; + if (hiddenByColorGrading && !injectedFrameSource) { + entry.element.style.setProperty("opacity", "0", "important"); } entry.sourceVisibleForCanvas = (injectedFrameSource || sourceVisibility !== "hidden") && computed.visibility !== "hidden";