From 91f14958cc449f95a412993373077a6f53fabc1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 10 Aug 2026 23:37:52 -0400 Subject: [PATCH] fix(core): stop a graded plate painting through an inactive clip (#3196) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A color-graded image or video inside a timed sub-composition kept painting after its clip window closed. The runtime hid the sub-composition wrapper with `visibility: hidden`, but the grading canvas carried an explicit inline `visibility: visible`, and an explicit value on a descendant escapes an ancestor's inherited `hidden`. The treated plate composited over whichever scene was actually on screen, in preview and in the encoded render alike. `drawEntry` only refreshed its cached view of the source's visibility inside `if (injectedFrameSource || !hiddenByColorGrading)`. That gate exists for opacity: `hideSourceElement` sets `opacity: 0 !important` on the source while grading is active, so mirroring the source's computed opacity onto the canvas would blank it. Visibility was swept into the same gate by accident. Grading never writes `visibility`, so the source's computed visibility always tracks the clip window — and because every graded source is hidden-by-grading, the mirror could never self-heal once it went stale. Split the two mirrors: opacity stays gated, visibility is re-read from computed style every frame. Deriving it from the source rather than from a notification means any way of hiding a clip works, including ones that do not exist yet. --- .../core/src/runtime/colorGrading.test.ts | 29 +++++++++++++++++++ packages/core/src/runtime/colorGrading.ts | 13 +++++++-- 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/packages/core/src/runtime/colorGrading.test.ts b/packages/core/src/runtime/colorGrading.test.ts index 3042564b2..02b686e3f 100644 --- a/packages/core/src/runtime/colorGrading.test.ts +++ b/packages/core/src/runtime/colorGrading.test.ts @@ -770,6 +770,35 @@ describe("createColorGradingRuntime", () => { expect(canvas.style.opacity).toBe("0.75"); }); + it("hides the canvas when an ancestor clip goes out of its visibility window", () => { + // A graded inside a timed sub-composition carries no data-start of its + // own, so nothing tells grading the clip left the screen — the canvas has to + // notice from the source's inherited visibility. Before the fix, grading's own + // opacity hide short-circuited that read and the canvas kept an explicit + // `visibility: visible`, painting the treated plate over the active scene. + const scene = document.createElement("div"); + const image = makeDrawableImage(); + scene.appendChild(image); + document.body.appendChild(scene); + + runtime = createColorGradingRuntime(); + const canvas = document.querySelector("[data-hf-color-grading-canvas]"); + if (!canvas) throw new Error("Expected color grading canvas"); + expect(canvas.style.visibility).toBe("visible"); + + scene.style.visibility = "hidden"; + runtime.redraw(); + + expect(canvas.style.visibility).toBe("hidden"); + + scene.style.visibility = "visible"; + runtime.redraw(); + + expect(canvas.style.visibility).toBe("visible"); + // Grading still owns the source's opacity hide, so the canvas keeps full opacity. + expect(canvas.style.opacity).toBe("1"); + }); + 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 a5050a722..1074e1318 100644 --- a/packages/core/src/runtime/colorGrading.ts +++ b/packages/core/src/runtime/colorGrading.ts @@ -3065,12 +3065,19 @@ function drawEntry(entry: ColorGradingEntry): boolean { const sourceVisibility = entry.element.style.getPropertyValue("visibility"); const injectedFrameSource = isRenderFrameImage(source); if (injectedFrameSource) keepCanvasAboveSource(entry, source); + 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) { - const computed = window.getComputedStyle(injectedFrameSource ? source : entry.element); entry.sourceOpacityForCanvas = computed.opacity || "1"; - entry.sourceVisibleForCanvas = - (injectedFrameSource || sourceVisibility !== "hidden") && computed.visibility !== "hidden"; } + entry.sourceVisibleForCanvas = + (injectedFrameSource || sourceVisibility !== "hidden") && computed.visibility !== "hidden"; const layout = updateCanvasLayout(entry, styleSource); if (!layout) return false;