fix(shader-transitions): the render leaves scene visibility to the runtime (#4509)

* fix(shader-transitions): a render no longer shows the last shader scene past its end

The page-side render un-hid the scene settled after the last shader
transition on every later frame, so a scene that followed it rendered
underneath the old one. It now un-hides that scene only while its own
data-start/data-duration window is open.

* fix(shader-transitions): the render leaves scene visibility to the runtime

The page-side compositor un-hid the destination of the last finished
shader transition on every non-transition frame, a workaround for the
runtime hiding a film's final scene before the end. The runtime has
kept the last frame since #4158, so the workaround only showed a shader
scene outside its own window: before it starts and after it ends.
Remove it, with the narrower check this branch added first.
This commit is contained in:
Miguel Ángel
2026-09-26 01:18:44 +00:00
committed by GitHub
parent 4ef2fc3f1e
commit 5145a136a0
2 changed files with 93 additions and 18 deletions
@@ -1,6 +1,7 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
clonePinStyleFor,
installPageSideCompositor,
isPageSideCompositingSupported,
PAGE_COMPOSITOR_BUILD_CANARY,
PAGE_COMPOSITOR_CANVAS_ID,
@@ -90,6 +91,95 @@ describe("clonePinStyleFor", () => {
});
});
describe("page-side compositor seek", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
class FakeScene {
style = { visibility: "hidden" };
constructor(private readonly attrs: Record<string, string>) {}
getAttribute(name: string) {
return this.attrs[name] ?? null;
}
}
// A browser with drawElementImage and a WebGL context whose every call succeeds.
function installWithHiddenScenes(
timing: Record<string, readonly [start: string, duration: string]>,
) {
const gl = new Proxy(
{},
{
get: (_target, key) => {
if (key === "getShaderParameter" || key === "getProgramParameter") return () => true;
if (key === "getExtension") return () => ({ loseContext: () => undefined });
return () => ({});
},
},
);
const canvas = () => ({
style: {},
width: 0,
height: 0,
layoutSubtree: true,
firstChild: null,
setAttribute: () => undefined,
remove: () => undefined,
getContext: (type: string) => (type === "2d" ? { drawElementImage: () => undefined } : gl),
});
const scenes = new Map(
Object.entries(timing).map(([id, [start, duration]]) => [
id,
new FakeScene({ "data-start": start, "data-duration": duration }),
]),
);
let startPolling: (() => void) | undefined;
const hf = { seek: vi.fn() };
vi.stubGlobal("window", {
__hf: hf,
setInterval: (poll: () => void) => {
startPolling = poll;
return 1;
},
clearInterval: () => undefined,
});
vi.stubGlobal("HTMLElement", FakeScene);
vi.stubGlobal("document", {
createElement: canvas,
getElementById: (id: string) => scenes.get(id) ?? null,
body: { appendChild: () => undefined },
});
const installed = installPageSideCompositor({
scenes: ["s4", "s5"],
transitions: [{ time: 4.4, duration: 0.8, shader: "domain-warp" }],
bgColor: "#000",
accentColors: { accent: [1, 1, 1], dark: [0, 0, 0], bright: [1, 1, 1] },
width: 1920,
height: 1080,
defaultDuration: 0.8,
});
startPolling?.();
return { installed, hf, scenes };
}
// s4 runs 2.8 to 4.8 s, s5 4.4 to 8.0 s; a plain scene plays before and after them.
const film = { s4: ["2.8", "2"], s5: ["4.4", "3.6"] } as const;
it("leaves the runtime's hide on a shader scene before its window", () => {
const { installed, hf, scenes } = installWithHiddenScenes(film);
expect(installed).toBe(true);
hf.seek(2.4);
expect(scenes.get("s4")?.style.visibility).toBe("hidden");
});
it("leaves the runtime's hide on the last shader scene after its window", () => {
const { hf, scenes } = installWithHiddenScenes(film);
hf.seek(8.8);
expect(scenes.get("s5")?.style.visibility).toBe("hidden");
});
});
describe("page-side compositor exported constants", () => {
it("exports a stable canary string used by the bundled-CLI smoke", () => {
expect(PAGE_COMPOSITOR_BUILD_CANARY).toBe("__hf_page_compositor_v1__");
@@ -116,6 +116,7 @@ export function isPageSideCompositingSupported(): boolean {
return true;
}
// fallow-ignore-next-line complexity
export function installPageSideCompositor(options: PageCompositorInstallOptions): boolean {
if (typeof window === "undefined") return false;
(window as unknown as { __HF_PAGE_COMPOSITOR_CANARY__?: string }).__HF_PAGE_COMPOSITOR_CANARY__ =
@@ -226,16 +227,6 @@ export function installPageSideCompositor(options: PageCompositorInstallOptions)
return null;
}
// Scene on screen at a non-transition time: after the last transition whose
// window has passed. Full transitions list so the index matches scene order.
function settledSceneIdAt(time: number): string | undefined {
let idx = 0;
for (const t of transitions) {
if (time >= t.time + (t.duration ?? defaultDuration)) idx += 1;
}
return scenes[Math.min(idx, scenes.length - 1)];
}
let currentActive: ResolvedTransition | null = null;
let currentProgress = 0;
@@ -249,6 +240,7 @@ export function installPageSideCompositor(options: PageCompositorInstallOptions)
// injection so cloneNode picks up <img> replacements for <video> elements.
// Awaits decode on cloned data-URI images so drawElementImage reads
// the current frame, not a stale paint cache entry.
// fallow-ignore-next-line complexity
async function prepareComposite(): Promise<boolean> {
const active = currentActive;
if (!active) {
@@ -319,6 +311,7 @@ export function installPageSideCompositor(options: PageCompositorInstallOptions)
// Phase 2b: drawElementImage from painted clones + shader composite.
// Called after micro-screenshot forces the browser to paint the clones.
// fallow-ignore-next-line complexity
function resolveComposite(): boolean {
const active = currentActive;
if (!active) {
@@ -401,14 +394,6 @@ export function installPageSideCompositor(options: PageCompositorInstallOptions)
pWin.__hf_page_composite_pending = false;
while (fromStaging.firstChild) fromStaging.removeChild(fromStaging.firstChild);
while (toStaging.firstChild) toStaging.removeChild(toStaging.firstChild);
// Live-page screenshot parity with the layered path's forceVisible: the
// core clip runtime hides the final scene a beat before the comp ends, so
// un-hide the settled scene (others stay at opacity 0).
const settledId = settledSceneIdAt(time);
const settled = settledId ? document.getElementById(settledId) : null;
if (settled instanceof HTMLElement && settled.style.visibility === "hidden") {
settled.style.visibility = "visible";
}
return result;
}
currentActive = active;