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";