fix(core): a scene paints no SVG filter grain before its start or after its end (#5828)

* fix(core): a scene paints no SVG filter grain before its start or after its end

* fix(core): a scene's filter tween starts from its stylesheet filter when frames step in

The hidden-clip rule's filter:none was still on when a tween first rendered on
the frame-sized step into its scene, so it cached none as its start value. A
seek that first renders a time now shows the hidden clips due at that time
(all of them on a jump) around the GSAP render.

* fix(core): hidden scenes block only SVG filter output, leaving HTML filters to tween

Only SVG reference filters paint without input, so the filter:none rule now
covers SVG content inside a hidden clip and nothing else. A stylesheet filter
on an HTML element stays readable whenever GSAP first renders it, including a
tween that starts a frame before its scene, and the per-step show of due clips
is no longer needed.
This commit is contained in:
Miguel Ángel
2026-10-18 14:57:42 +00:00
committed by GitHub
parent dc0e80e73a
commit bb55640f5c
2 changed files with 91 additions and 2 deletions
+8 -2
View File
@@ -17,7 +17,9 @@ const SKIP_HIDDEN_IMAGES = `${SKIPPED_CLIP} img { display: none !important; }`;
const STUDIO_PREVIEW_MARK = `meta[name="${STUDIO_PREVIEW_MARK_META}"]`;
export const CLIP_HIDDEN_ATTR = "data-hf-clip-hidden";
const CONTENTS_ATTR = "data-hf-hidden-clip-contents";
const HIDE_HIDDEN_CLIP_CONTENTS = `[${CLIP_HIDDEN_ATTR}][style*="visibility: hidden"] * { visibility: hidden !important; }`;
const HIDDEN_CLIP = `[${CLIP_HIDDEN_ATTR}][style*="visibility: hidden"]`;
const HIDE_HIDDEN_CLIP_CONTENTS = `${HIDDEN_CLIP} * { visibility: hidden !important; }`;
const HIDE_SVG_GENERATED_FILTER_OUTPUT = `svg${HIDDEN_CLIP}, svg${HIDDEN_CLIP} *, ${HIDDEN_CLIP} svg, ${HIDDEN_CLIP} svg * { filter: none !important; }`;
export function showHiddenClips(): () => void {
const hidden = Array.from(document.querySelectorAll<HTMLElement>(`[${CLIP_HIDDEN_ATTR}]`));
@@ -51,7 +53,11 @@ export function hideTimedClipsUntilFirstPass(): void {
const preview = document.querySelector(STUDIO_PREVIEW_MARK) !== null;
if (preview && !skipsHiddenImages()) appendStyle(parent, SKIP_ATTR, SKIP_HIDDEN_IMAGES);
if (!document.querySelector(`style[${CONTENTS_ATTR}]`))
appendStyle(parent, CONTENTS_ATTR, HIDE_HIDDEN_CLIP_CONTENTS);
appendStyle(
parent,
CONTENTS_ATTR,
`${HIDE_HIDDEN_CLIP_CONTENTS} ${HIDE_SVG_GENERATED_FILTER_OUTPUT}`,
);
const win = window as FirstPassWindow;
// A runtime that already initialised may never run another pass to lift a new rule.
if (win.__hfFirstPassHidden || win.__hyperframeRuntimeBootstrapped) return;
@@ -320,6 +320,89 @@ describe("core runtime browser contract", () => {
}
}, 30_000);
it("paints no SVG filter output from a scene before its start or after its end", async () => {
// feTurbulence draws from nothing, so visibility:hidden alone left grain over earlier scenes.
const grain = (id: string, start: number) => `
<div class="clip" data-start="${start}" data-duration="1" style="position:absolute;inset:0">
<svg width="320" height="180" style="position:absolute;inset:0;mix-blend-mode:overlay">
<filter id="${id}"><feTurbulence type="fractalNoise" baseFrequency="0.9" numOctaves="3" /></filter>
<rect width="100%" height="100%" filter="url(#${id})" />
</svg>
</div>`;
const grainPage = await browser.newPage();
try {
await grainPage.setViewport({ width: 320, height: 180 });
await grainPage.setContent(`<!doctype html>
<style>html, body { margin: 0; background: rgb(128, 128, 128); }</style>
<div data-composition-id="root" data-start="0" data-duration="3" data-width="320" data-height="180"
style="position:relative;width:320px;height:180px">
${grain("early", 0)}${grain("late", 2)}
</div>`);
await grainPage.addScriptTag({ content: readFileSync(RUNTIME_PATH, "utf8") });
await grainPage.waitForFunction(() => window.__playerReady && window.__renderReady);
const redRangeAt = async (time: number) => {
await grainPage.evaluate((t) => window.__player!.renderSeek!(t), time);
const shot = await grainPage.screenshot({ encoding: "base64" });
return grainPage.evaluate(async (png) => {
const image = new Image();
image.src = `data:image/png;base64,${png}`;
await image.decode();
const context = new OffscreenCanvas(image.width, image.height).getContext("2d")!;
context.drawImage(image, 0, 0);
const { data } = context.getImageData(0, 0, image.width, image.height);
let min = 255;
let max = 0;
for (let i = 0; i < data.length; i += 4) {
min = Math.min(min, data[i]!);
max = Math.max(max, data[i]!);
}
return [min, max];
}, shot);
};
expect(await redRangeAt(1.5)).toEqual([128, 128]);
const [min, max] = await redRangeAt(2.5);
expect(max - min).toBeGreaterThan(20);
} finally {
await grainPage.close();
}
}, 30_000);
it.each([
[2, "blur(5px)"],
[1.9, "blur(4px)"],
])(
"starts a filter tween at %s s from its stylesheet filter when frames step into a scene at 2 s",
async (start, expected) => {
const tweenPage = await browser.newPage();
try {
await tweenPage.setContent(`<!doctype html>
<style>#probe { filter: blur(10px); }</style>
<div data-composition-id="root" data-start="0" data-duration="3" data-width="320" data-height="180">
<div class="clip" data-start="2" data-duration="1" style="position:absolute;inset:0"><div id="probe"></div></div>
</div>`);
await tweenPage.addScriptTag({
path: resolve(import.meta.dirname, "../../../core/node_modules/gsap/dist/gsap.min.js"),
});
await tweenPage.addScriptTag({
content: `window.__timelines = {root:gsap.timeline({paused:true}).to("#probe",{filter:"blur(0px)",duration:1,ease:"none",lazy:false},${start})};`,
});
await tweenPage.addScriptTag({ content: readFileSync(RUNTIME_PATH, "utf8") });
await tweenPage.waitForFunction(() => window.__playerReady && window.__renderReady);
const filter = await tweenPage.evaluate(() => {
for (let frame = 0; frame <= 75; frame++) window.__player!.renderSeek!(frame / 30);
return getComputedStyle(document.getElementById("probe")!).filter;
});
expect(filter).toBe(expected);
} finally {
await tweenPage.close();
}
},
30_000,
);
it("renders a later clip's authored lazy image as it is: laid out at setup, fetched before any seek", async () => {
// A chunked render starts a worker straight at a later clip, so its image must already be loaded.
const assets = "https://assets.test/";