fix(core): stop a graded plate painting through an inactive clip (#3196)

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.
This commit is contained in:
Miguel Ángel
2026-08-10 23:37:52 -04:00
committed by GitHub
parent c9dd8413c3
commit 91f14958cc
2 changed files with 39 additions and 3 deletions
@@ -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 <img> 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<HTMLCanvasElement>("[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";
+10 -3
View File
@@ -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;