mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-08-31 02:41:44 +00:00
Merge 3c424fc34e into 3337cc8990
This commit is contained in:
@@ -0,0 +1,308 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// html2canvas needs a real browser. The snapshot pipeline only cares that it
|
||||
// gets back a canvas it can encode, so stand in a stub and keep the test on the
|
||||
// context-loss behaviour.
|
||||
vi.mock("./capture.js", () => ({
|
||||
initCapture: () => undefined,
|
||||
isHtmlInCanvasCaptureSupported: () => false,
|
||||
captureScene: () =>
|
||||
Promise.resolve({
|
||||
width: 8,
|
||||
height: 8,
|
||||
toBlob: (cb: (blob: Blob) => void) => cb(new Blob(["x"])),
|
||||
} as unknown as HTMLCanvasElement),
|
||||
}));
|
||||
|
||||
import { init, type TransitionConfig } from "./hyper-shader.js";
|
||||
|
||||
let drawArraysCalls = 0;
|
||||
let createProgramCalls = 0;
|
||||
let loseContextCalls = 0;
|
||||
let webglContextCount = 0;
|
||||
|
||||
function createMockWebGl(): WebGLRenderingContext {
|
||||
return {
|
||||
VERTEX_SHADER: 0x8b31,
|
||||
FRAGMENT_SHADER: 0x8b30,
|
||||
COMPILE_STATUS: 0x8b81,
|
||||
LINK_STATUS: 0x8b82,
|
||||
TEXTURE_2D: 0x0de1,
|
||||
TEXTURE_WRAP_S: 0x2802,
|
||||
TEXTURE_WRAP_T: 0x2803,
|
||||
TEXTURE_MIN_FILTER: 0x2801,
|
||||
TEXTURE_MAG_FILTER: 0x2800,
|
||||
CLAMP_TO_EDGE: 0x812f,
|
||||
LINEAR: 0x2601,
|
||||
RGBA: 0x1908,
|
||||
UNSIGNED_BYTE: 0x1401,
|
||||
ARRAY_BUFFER: 0x8892,
|
||||
STATIC_DRAW: 0x88e4,
|
||||
TEXTURE0: 0x84c0,
|
||||
TEXTURE1: 0x84c1,
|
||||
FLOAT: 0x1406,
|
||||
TRIANGLE_STRIP: 0x0005,
|
||||
UNPACK_FLIP_Y_WEBGL: 0x9240,
|
||||
FRAMEBUFFER: 0x8d40,
|
||||
COLOR_ATTACHMENT0: 0x8ce0,
|
||||
createShader: () => ({}),
|
||||
shaderSource: () => undefined,
|
||||
compileShader: () => undefined,
|
||||
getShaderParameter: () => true,
|
||||
getShaderInfoLog: () => "",
|
||||
createProgram: () => {
|
||||
createProgramCalls += 1;
|
||||
return {};
|
||||
},
|
||||
attachShader: () => undefined,
|
||||
linkProgram: () => undefined,
|
||||
getProgramParameter: () => true,
|
||||
getProgramInfoLog: () => "",
|
||||
createTexture: () => ({}),
|
||||
bindTexture: () => undefined,
|
||||
texParameteri: () => undefined,
|
||||
texImage2D: () => undefined,
|
||||
createFramebuffer: () => ({}),
|
||||
bindFramebuffer: () => undefined,
|
||||
framebufferTexture2D: () => undefined,
|
||||
createBuffer: () => ({}),
|
||||
bindBuffer: () => undefined,
|
||||
bufferData: () => undefined,
|
||||
getAttribLocation: () => 0,
|
||||
getUniformLocation: (_p: WebGLProgram, name: string) => name,
|
||||
viewport: () => undefined,
|
||||
useProgram: () => undefined,
|
||||
activeTexture: () => undefined,
|
||||
pixelStorei: () => undefined,
|
||||
uniform1i: () => undefined,
|
||||
uniform1f: () => undefined,
|
||||
uniform2f: () => undefined,
|
||||
uniform3f: () => undefined,
|
||||
enableVertexAttribArray: () => undefined,
|
||||
vertexAttribPointer: () => undefined,
|
||||
drawArrays: () => {
|
||||
drawArraysCalls += 1;
|
||||
},
|
||||
deleteTexture: () => undefined,
|
||||
getExtension: (name: string) =>
|
||||
name === "WEBGL_lose_context"
|
||||
? {
|
||||
loseContext: () => {
|
||||
loseContextCalls += 1;
|
||||
},
|
||||
}
|
||||
: null,
|
||||
} as unknown as WebGLRenderingContext;
|
||||
}
|
||||
|
||||
interface FakeTimeline {
|
||||
paused: () => boolean;
|
||||
play: (from?: number) => FakeTimeline;
|
||||
pause: (at?: number) => FakeTimeline;
|
||||
time: (value?: number) => FakeTimeline | number;
|
||||
call: () => FakeTimeline;
|
||||
to: () => FakeTimeline;
|
||||
set: () => FakeTimeline;
|
||||
from: () => FakeTimeline;
|
||||
fromTo: () => FakeTimeline;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
function makeTimeline(): FakeTimeline {
|
||||
let position = 0;
|
||||
let paused = true;
|
||||
const tl: FakeTimeline = {
|
||||
paused: () => paused,
|
||||
play: (from?: number) => {
|
||||
if (typeof from === "number") position = from;
|
||||
paused = false;
|
||||
return tl;
|
||||
},
|
||||
pause: (at?: number) => {
|
||||
if (typeof at === "number") position = at;
|
||||
paused = true;
|
||||
return tl;
|
||||
},
|
||||
time: (value?: number) => {
|
||||
if (value === undefined) return position;
|
||||
position = value;
|
||||
return tl;
|
||||
},
|
||||
call: () => tl,
|
||||
to: () => tl,
|
||||
set: () => tl,
|
||||
from: () => tl,
|
||||
fromTo: () => tl,
|
||||
};
|
||||
return tl;
|
||||
}
|
||||
|
||||
function setupDom(): void {
|
||||
document.body.innerHTML = `
|
||||
<div data-composition-id="main" data-width="320" data-height="180" data-duration="4">
|
||||
<div id="s1" class="scene"></div>
|
||||
<div id="s2" class="scene"></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function startShader(transitions: TransitionConfig[]): {
|
||||
timeline: { time: (value: number) => void };
|
||||
glCanvas: HTMLCanvasElement;
|
||||
} {
|
||||
const timeline = makeTimeline();
|
||||
init({
|
||||
bgColor: "#000",
|
||||
scenes: ["s1", "s2"],
|
||||
transitions,
|
||||
timeline: timeline as never,
|
||||
compositionId: "main",
|
||||
previewCaptureFps: 1,
|
||||
});
|
||||
const glCanvas = document.getElementById("gl-canvas");
|
||||
if (!(glCanvas instanceof HTMLCanvasElement)) throw new Error("gl canvas missing");
|
||||
return {
|
||||
timeline: {
|
||||
time: (value: number) => {
|
||||
(timeline.time as (v: number) => unknown)(value);
|
||||
},
|
||||
},
|
||||
glCanvas,
|
||||
};
|
||||
}
|
||||
|
||||
/** Wait for the prewarm + texture-upload promise chains to settle. */
|
||||
async function settle(): Promise<void> {
|
||||
for (let i = 0; i < 40; i += 1) {
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
}
|
||||
|
||||
function sceneOpacity(id: string): string {
|
||||
return document.getElementById(id)?.style.opacity ?? "";
|
||||
}
|
||||
|
||||
const SHADER_TRANSITION: TransitionConfig = { time: 1, duration: 1, shader: "glitch" };
|
||||
const CSS_TRANSITION: TransitionConfig = { time: 1, duration: 1 };
|
||||
|
||||
describe("HyperShader WebGL context loss", () => {
|
||||
let getContextSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
drawArraysCalls = 0;
|
||||
createProgramCalls = 0;
|
||||
loseContextCalls = 0;
|
||||
webglContextCount = 0;
|
||||
setupDom();
|
||||
// No IndexedDB: the snapshot cache degrades to in-memory blobs, which is
|
||||
// all this test needs.
|
||||
vi.stubGlobal("indexedDB", undefined);
|
||||
vi.stubGlobal("createImageBitmap", () => Promise.resolve({}));
|
||||
getContextSpy = vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation(((
|
||||
type: string,
|
||||
) => {
|
||||
if (type !== "webgl") return null;
|
||||
webglContextCount += 1;
|
||||
return createMockWebGl();
|
||||
}) as never) as ReturnType<typeof vi.spyOn>;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Drain this test's beforeunload teardown (registered `{ once: true }`) so
|
||||
// it cannot fire during the next test on the shared window.
|
||||
window.dispatchEvent(new Event("beforeunload"));
|
||||
getContextSpy.mockRestore();
|
||||
vi.unstubAllGlobals();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
it("stops shader work without throwing when the context is lost", async () => {
|
||||
const { timeline, glCanvas } = startShader([SHADER_TRANSITION]);
|
||||
await settle();
|
||||
timeline.time(1.5);
|
||||
await settle();
|
||||
timeline.time(1.5);
|
||||
expect(drawArraysCalls).toBeGreaterThan(0);
|
||||
|
||||
const lost = new Event("webglcontextlost", { cancelable: true });
|
||||
glCanvas.dispatchEvent(lost);
|
||||
|
||||
expect(lost.defaultPrevented).toBe(true);
|
||||
expect(glCanvas.style.display).toBe("none");
|
||||
|
||||
drawArraysCalls = 0;
|
||||
expect(() => timeline.time(1.5)).not.toThrow();
|
||||
await settle();
|
||||
expect(drawArraysCalls).toBe(0);
|
||||
expect(glCanvas.style.display).toBe("none");
|
||||
// The transition still plays — as a DOM crossfade.
|
||||
expect(Number(sceneOpacity("s1"))).toBeCloseTo(0.5, 5);
|
||||
expect(Number(sceneOpacity("s2"))).toBeCloseTo(0.5, 5);
|
||||
});
|
||||
|
||||
it("rebuilds and resumes rendering when the context is restored", async () => {
|
||||
const { timeline, glCanvas } = startShader([SHADER_TRANSITION]);
|
||||
await settle();
|
||||
timeline.time(1.5);
|
||||
await settle();
|
||||
|
||||
glCanvas.dispatchEvent(new Event("webglcontextlost", { cancelable: true }));
|
||||
const programsBeforeRestore = createProgramCalls;
|
||||
drawArraysCalls = 0;
|
||||
|
||||
glCanvas.dispatchEvent(new Event("webglcontextrestored"));
|
||||
|
||||
// Programs, buffers and render targets are rebuilt, not reused.
|
||||
expect(createProgramCalls).toBeGreaterThan(programsBeforeRestore);
|
||||
|
||||
await settle();
|
||||
timeline.time(1.5);
|
||||
expect(drawArraysCalls).toBeGreaterThan(0);
|
||||
expect(glCanvas.style.display).toBe("block");
|
||||
});
|
||||
|
||||
it("releases the context on teardown instead of leaving it to GC", async () => {
|
||||
startShader([SHADER_TRANSITION]);
|
||||
await settle();
|
||||
expect(webglContextCount).toBe(1);
|
||||
expect(loseContextCalls).toBe(0);
|
||||
|
||||
window.dispatchEvent(new Event("beforeunload"));
|
||||
|
||||
expect(loseContextCalls).toBe(1);
|
||||
});
|
||||
|
||||
// Browsers cap concurrent WebGL contexts (~16) and silently drop the oldest,
|
||||
// so init() must not take one on spec — only a shader transition that is
|
||||
// actually going to draw may.
|
||||
it("defers context creation until a shader transition needs it", async () => {
|
||||
const { timeline } = startShader([SHADER_TRANSITION]);
|
||||
expect(webglContextCount).toBe(0);
|
||||
|
||||
await settle();
|
||||
timeline.time(1.5);
|
||||
await settle();
|
||||
timeline.time(1.5);
|
||||
|
||||
expect(webglContextCount).toBe(1);
|
||||
expect(drawArraysCalls).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("leaves a composition without shader transitions unaffected, and takes no context", async () => {
|
||||
const { timeline, glCanvas } = startShader([CSS_TRANSITION]);
|
||||
await settle();
|
||||
timeline.time(1.5);
|
||||
await settle();
|
||||
expect(webglContextCount).toBe(0);
|
||||
expect(drawArraysCalls).toBe(0);
|
||||
expect(Number(sceneOpacity("s1"))).toBeCloseTo(0.5, 5);
|
||||
|
||||
glCanvas.dispatchEvent(new Event("webglcontextlost", { cancelable: true }));
|
||||
|
||||
expect(() => timeline.time(1.5)).not.toThrow();
|
||||
expect(Number(sceneOpacity("s1"))).toBeCloseTo(0.5, 5);
|
||||
expect(Number(sceneOpacity("s2"))).toBeCloseTo(0.5, 5);
|
||||
expect(drawArraysCalls).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
createTexture,
|
||||
uploadTextureSource,
|
||||
renderShader,
|
||||
manageContextLoss,
|
||||
DEFAULT_WIDTH,
|
||||
DEFAULT_HEIGHT,
|
||||
type AccentColors,
|
||||
@@ -101,7 +102,9 @@ interface CachedTransition {
|
||||
duration: number;
|
||||
fromId: string;
|
||||
toId: string;
|
||||
prog: WebGLProgram | null; // null for CSS-fallback transitions
|
||||
/** The authored shader name; undefined for a CSS crossfade. */
|
||||
shader: string | undefined;
|
||||
prog: WebGLProgram | null; // null until the shader is compiled, and for CSS-fallback transitions
|
||||
frames: CachedTransitionFrame[];
|
||||
cacheKey: string;
|
||||
dirty: boolean;
|
||||
@@ -139,6 +142,23 @@ interface SnapshotCacheEntry {
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
/** Every GL object owned by one init() call — recreated as a unit on restore. */
|
||||
interface GlResources {
|
||||
quadBuf: WebGLBuffer;
|
||||
blendProg: WebGLProgram;
|
||||
blendLoc: {
|
||||
a: WebGLUniformLocation | null;
|
||||
b: WebGLUniformLocation | null;
|
||||
mix: WebGLUniformLocation | null;
|
||||
pos: number;
|
||||
};
|
||||
/** Render targets holding the two motion-interpolated transition frames. */
|
||||
fromTex: WebGLTexture;
|
||||
toTex: WebGLTexture;
|
||||
fromFbo: WebGLFramebuffer;
|
||||
toFbo: WebGLFramebuffer;
|
||||
}
|
||||
|
||||
interface SceneStyleState {
|
||||
scene: HTMLElement | null;
|
||||
opacity: string;
|
||||
@@ -910,32 +930,6 @@ export function init(config: HyperShaderConfig): GsapTimeline {
|
||||
glCanvas.style.width = `${compWidth}px`;
|
||||
glCanvas.style.height = `${compHeight}px`;
|
||||
|
||||
const gl = createContext(glCanvas, compWidth, compHeight);
|
||||
if (!gl) {
|
||||
console.warn("[HyperShader] WebGL unavailable — shader transitions disabled.");
|
||||
const fallback = config.timeline || gsap.timeline({ paused: true });
|
||||
registerTimeline(compId, fallback, config.timeline);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const quadBuf = setupQuad(gl);
|
||||
|
||||
const programs = new Map<string, WebGLProgram>();
|
||||
for (const t of transitions) {
|
||||
// Strict undefined check — an explicit empty string from a vanilla-JS
|
||||
// caller (the IIFE bundle is hand-loaded via <script> tags) should NOT
|
||||
// be silently coerced into a CSS crossfade. The shader registry will
|
||||
// throw a clear "unknown shader" error for it.
|
||||
if (t.shader === undefined) continue;
|
||||
if (!programs.has(t.shader)) {
|
||||
try {
|
||||
programs.set(t.shader, createProgram(gl, getFragSource(t.shader)));
|
||||
} catch (e) {
|
||||
console.error(`[HyperShader] Failed to compile "${t.shader}":`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const canvasEl = glCanvas;
|
||||
const previewCaptureFps = clampNumber(resolvePositiveNumber(config.previewCaptureFps, 30), 1, 60);
|
||||
const previewCaptureScale = resolvePlayerCaptureScale();
|
||||
@@ -943,30 +937,149 @@ export function init(config: HyperShaderConfig): GsapTimeline {
|
||||
const previewTextureWidth = Math.max(1, Math.round(compWidth * previewCaptureScale));
|
||||
const previewTextureHeight = Math.max(1, Math.round(compHeight * previewCaptureScale));
|
||||
const cachedTransitions: CachedTransition[] = [];
|
||||
const blendProg = createProgramWithVertex(
|
||||
gl,
|
||||
NO_FLIP_VERT_SRC,
|
||||
[
|
||||
"precision mediump float;",
|
||||
"varying vec2 v_uv;",
|
||||
"uniform sampler2D u_a;",
|
||||
"uniform sampler2D u_b;",
|
||||
"uniform float u_mix;",
|
||||
"void main(){",
|
||||
"gl_FragColor=mix(texture2D(u_a,v_uv),texture2D(u_b,v_uv),u_mix);",
|
||||
"}",
|
||||
].join(""),
|
||||
);
|
||||
const blendLoc = {
|
||||
a: gl.getUniformLocation(blendProg, "u_a"),
|
||||
b: gl.getUniformLocation(blendProg, "u_b"),
|
||||
mix: gl.getUniformLocation(blendProg, "u_mix"),
|
||||
pos: gl.getAttribLocation(blendProg, "a_pos"),
|
||||
|
||||
// The context and everything in it are created on demand — see ensureGl().
|
||||
let gl: WebGLRenderingContext | null = null;
|
||||
let glRes: GlResources | null = null;
|
||||
let releaseGlContext: (() => void) | null = null;
|
||||
let glUnavailable = false;
|
||||
let contextLost = false;
|
||||
|
||||
// Every GL object below dies with the context, so they are created and
|
||||
// recreated as one unit — see ensureGl() and the context-restore handler it
|
||||
// installs. `programs` is a stable Map identity refilled in place because
|
||||
// syncCacheProgram() reads it after every (re)build.
|
||||
const programs = new Map<string, WebGLProgram>();
|
||||
const createGlResources = (gl: WebGLRenderingContext): GlResources => {
|
||||
programs.clear();
|
||||
for (const t of transitions) {
|
||||
// Strict undefined check — an explicit empty string from a vanilla-JS
|
||||
// caller (the IIFE bundle is hand-loaded via <script> tags) should NOT
|
||||
// be silently coerced into a CSS crossfade. The shader registry will
|
||||
// throw a clear "unknown shader" error for it.
|
||||
if (t.shader === undefined) continue;
|
||||
if (programs.has(t.shader)) continue;
|
||||
try {
|
||||
programs.set(t.shader, createProgram(gl, getFragSource(t.shader)));
|
||||
} catch (e) {
|
||||
console.error(`[HyperShader] Failed to compile "${t.shader}":`, e);
|
||||
}
|
||||
}
|
||||
const blendProg = createProgramWithVertex(
|
||||
gl,
|
||||
NO_FLIP_VERT_SRC,
|
||||
[
|
||||
"precision mediump float;",
|
||||
"varying vec2 v_uv;",
|
||||
"uniform sampler2D u_a;",
|
||||
"uniform sampler2D u_b;",
|
||||
"uniform float u_mix;",
|
||||
"void main(){",
|
||||
"gl_FragColor=mix(texture2D(u_a,v_uv),texture2D(u_b,v_uv),u_mix);",
|
||||
"}",
|
||||
].join(""),
|
||||
);
|
||||
const fromTex = createRenderTexture(gl, previewTextureWidth, previewTextureHeight);
|
||||
const toTex = createRenderTexture(gl, previewTextureWidth, previewTextureHeight);
|
||||
return {
|
||||
quadBuf: setupQuad(gl),
|
||||
blendProg,
|
||||
blendLoc: {
|
||||
a: gl.getUniformLocation(blendProg, "u_a"),
|
||||
b: gl.getUniformLocation(blendProg, "u_b"),
|
||||
mix: gl.getUniformLocation(blendProg, "u_mix"),
|
||||
pos: gl.getAttribLocation(blendProg, "a_pos"),
|
||||
},
|
||||
fromTex,
|
||||
toTex,
|
||||
fromFbo: createFramebuffer(gl, fromTex),
|
||||
toFbo: createFramebuffer(gl, toTex),
|
||||
};
|
||||
};
|
||||
const interpolatedFromTex = createRenderTexture(gl, previewTextureWidth, previewTextureHeight);
|
||||
const interpolatedToTex = createRenderTexture(gl, previewTextureWidth, previewTextureHeight);
|
||||
const interpolatedFromFbo = createFramebuffer(gl, interpolatedFromTex);
|
||||
const interpolatedToFbo = createFramebuffer(gl, interpolatedToTex);
|
||||
/**
|
||||
* A cache learns its program when the context comes up, and again after a
|
||||
* context restore — programs die with the context. A shader that fails to
|
||||
* compile degrades to the CSS crossfade path so scene progression still runs.
|
||||
*/
|
||||
const syncCacheProgram = (cache: CachedTransition): void => {
|
||||
if (cache.shader === undefined) return;
|
||||
cache.prog = programs.get(cache.shader) ?? null;
|
||||
if (cache.prog) return;
|
||||
console.warn(
|
||||
`[HyperShader] Shader "${cache.shader}" failed to compile — falling back to CSS crossfade.`,
|
||||
);
|
||||
cache.fallback = true;
|
||||
cache.ready = true;
|
||||
cache.dirty = false;
|
||||
cache.persisted = true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create the context, and everything that lives in it, the first time a
|
||||
* shader transition actually needs to draw.
|
||||
*
|
||||
* Browsers cap concurrent WebGL contexts (~16) and silently drop the oldest,
|
||||
* so a composition whose transitions are all CSS crossfades — it never
|
||||
* compiles a program, uploads a texture or draws — must not hold one. Both
|
||||
* callers are on a shader path: the texture upload choke point and the
|
||||
* shader draw in tickShader().
|
||||
*/
|
||||
const ensureGl = (): WebGLRenderingContext | null => {
|
||||
if (gl || glUnavailable) return gl;
|
||||
const created = createContext(canvasEl, compWidth, compHeight);
|
||||
if (!created) {
|
||||
glUnavailable = true;
|
||||
console.warn("[HyperShader] WebGL unavailable — shader transitions play as CSS crossfades.");
|
||||
return null;
|
||||
}
|
||||
gl = created;
|
||||
glRes = createGlResources(created);
|
||||
releaseGlContext = manageContextLoss(canvasEl, created, {
|
||||
onLost: () => {
|
||||
contextLost = true;
|
||||
canvasEl.style.display = "none";
|
||||
// Repaint at the current playhead so the DOM crossfade takes over
|
||||
// immediately instead of leaving the last shader frame frozen on screen.
|
||||
tickShader();
|
||||
},
|
||||
onRestored: () => {
|
||||
contextLost = false;
|
||||
try {
|
||||
rebuildGlResources();
|
||||
} catch (e) {
|
||||
contextLost = true;
|
||||
console.warn("[HyperShader] WebGL context restore failed:", e);
|
||||
return;
|
||||
}
|
||||
// tickShader re-requests textures for the active transition, so this both
|
||||
// repaints and kicks the re-upload.
|
||||
tickShader();
|
||||
},
|
||||
});
|
||||
for (const cache of cachedTransitions) syncCacheProgram(cache);
|
||||
return created;
|
||||
};
|
||||
|
||||
const rebuildGlResources = (): void => {
|
||||
const restored = gl;
|
||||
if (!restored) return;
|
||||
glRes = createGlResources(restored);
|
||||
for (const cache of cachedTransitions) {
|
||||
syncCacheProgram(cache);
|
||||
// Deleting the old textures would be a no-op on a lost context, so the
|
||||
// handles are just dropped; ensureTransitionTextures re-uploads them from
|
||||
// the cached blobs (in memory, or re-read from IndexedDB). Bumping the
|
||||
// generation makes any in-flight upload job abandon its dead textures.
|
||||
cache.textureGeneration += 1;
|
||||
cache.texturePromise = null;
|
||||
cache.textureReady = false;
|
||||
for (const frame of cache.frames) {
|
||||
frame.fromTex = null;
|
||||
frame.toTex = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let loadingOverlay: SnapshotLoadingOverlay | null = null;
|
||||
const getLoadingOverlay = (): SnapshotLoadingOverlay | null => {
|
||||
if (loadingMode !== "internal") return null;
|
||||
@@ -1057,6 +1170,8 @@ export function init(config: HyperShaderConfig): GsapTimeline {
|
||||
};
|
||||
|
||||
const renderTextureBlend = (
|
||||
gl: WebGLRenderingContext,
|
||||
glRes: GlResources,
|
||||
target: WebGLFramebuffer,
|
||||
texA: WebGLTexture,
|
||||
texB: WebGLTexture,
|
||||
@@ -1064,17 +1179,17 @@ export function init(config: HyperShaderConfig): GsapTimeline {
|
||||
): void => {
|
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, target);
|
||||
gl.viewport(0, 0, previewTextureWidth, previewTextureHeight);
|
||||
gl.useProgram(blendProg);
|
||||
gl.useProgram(glRes.blendProg);
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
gl.bindTexture(gl.TEXTURE_2D, texA);
|
||||
gl.uniform1i(blendLoc.a, 0);
|
||||
gl.uniform1i(glRes.blendLoc.a, 0);
|
||||
gl.activeTexture(gl.TEXTURE1);
|
||||
gl.bindTexture(gl.TEXTURE_2D, texB);
|
||||
gl.uniform1i(blendLoc.b, 1);
|
||||
gl.uniform1f(blendLoc.mix, mix);
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, quadBuf);
|
||||
gl.enableVertexAttribArray(blendLoc.pos);
|
||||
gl.vertexAttribPointer(blendLoc.pos, 2, gl.FLOAT, false, 0, 0);
|
||||
gl.uniform1i(glRes.blendLoc.b, 1);
|
||||
gl.uniform1f(glRes.blendLoc.mix, mix);
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, glRes.quadBuf);
|
||||
gl.enableVertexAttribArray(glRes.blendLoc.pos);
|
||||
gl.vertexAttribPointer(glRes.blendLoc.pos, 2, gl.FLOAT, false, 0, 0);
|
||||
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
|
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
|
||||
gl.viewport(0, 0, compWidth, compHeight);
|
||||
@@ -1136,7 +1251,7 @@ export function init(config: HyperShaderConfig): GsapTimeline {
|
||||
currentTime < cache.time + cache.duration
|
||||
);
|
||||
});
|
||||
if (upcoming) {
|
||||
if (upcoming && !contextLost) {
|
||||
preloadTransitionTextures(upcoming);
|
||||
}
|
||||
|
||||
@@ -1156,11 +1271,22 @@ export function init(config: HyperShaderConfig): GsapTimeline {
|
||||
canvasEl.style.display = "none";
|
||||
return;
|
||||
}
|
||||
// First shader draw of the composition: this is where the context (and
|
||||
// with it every program) comes into existence. Guarded so an all-CSS
|
||||
// composition never creates one.
|
||||
if (!cache.fallback && !contextLost) ensureGl();
|
||||
|
||||
// CSS-only transitions (prog === null) MUST take the fallback path. The
|
||||
// fallback flag is the normal signal, but we also guard on prog to keep
|
||||
// the invariant even if some path momentarily resets fallback while prog
|
||||
// stays null (it can't be re-created — there is no shader to compile).
|
||||
if (cache.fallback || cache.prog === null) {
|
||||
// stays null (a CSS transition has no shader to compile, and ensureGl
|
||||
// above found no context to compile one in).
|
||||
// A lost context joins them: every program, buffer and texture below is
|
||||
// dead until `webglcontextrestored`, so the DOM crossfade carries the
|
||||
// transition instead of the shader.
|
||||
const glCtx = gl;
|
||||
const res = glRes;
|
||||
if (cache.fallback || cache.prog === null || contextLost || !glCtx || !res) {
|
||||
state.active = true;
|
||||
state.transitionIndex = activeIndex;
|
||||
state.prog = null;
|
||||
@@ -1189,16 +1315,16 @@ export function init(config: HyperShaderConfig): GsapTimeline {
|
||||
return;
|
||||
}
|
||||
paintScenePairState(cache.fromId, cache.toId, "1", "1");
|
||||
renderTextureBlend(interpolatedFromFbo, frame.a.fromTex, frame.b.fromTex, frame.mix);
|
||||
renderTextureBlend(interpolatedToFbo, frame.a.toTex, frame.b.toTex, frame.mix);
|
||||
renderTextureBlend(glCtx, res, res.fromFbo, frame.a.fromTex, frame.b.fromTex, frame.mix);
|
||||
renderTextureBlend(glCtx, res, res.toFbo, frame.a.toTex, frame.b.toTex, frame.mix);
|
||||
|
||||
canvasEl.style.display = "block";
|
||||
renderShader(
|
||||
gl,
|
||||
quadBuf,
|
||||
glCtx,
|
||||
res.quadBuf,
|
||||
prog,
|
||||
interpolatedFromTex,
|
||||
interpolatedToTex,
|
||||
res.fromTex,
|
||||
res.toTex,
|
||||
state.progress,
|
||||
accentColors,
|
||||
compWidth,
|
||||
@@ -1325,19 +1451,12 @@ export function init(config: HyperShaderConfig): GsapTimeline {
|
||||
const toId = scenes[i + 1];
|
||||
if (!fromId || !toId) continue;
|
||||
|
||||
// shader omitted → CSS crossfade. shader present but program failed to
|
||||
// compile (logged above) → degrade gracefully to CSS crossfade so the
|
||||
// opacity timeline still runs and scene progression isn't broken. Both
|
||||
// paths land in the always-ready prog=null cache.
|
||||
const requestedShader = t.shader !== undefined;
|
||||
const compiledProg = requestedShader ? (programs.get(t.shader!) ?? null) : null;
|
||||
const isCssFallback = !requestedShader || compiledProg === null;
|
||||
if (requestedShader && compiledProg === null) {
|
||||
console.warn(
|
||||
`[HyperShader] Shader "${t.shader}" failed to compile — falling back to CSS crossfade.`,
|
||||
);
|
||||
}
|
||||
const prog = isCssFallback ? null : compiledProg;
|
||||
// shader omitted → CSS crossfade, an always-ready prog=null cache. A cache
|
||||
// that DID ask for a shader also starts at prog=null: programs are compiled
|
||||
// with the context, which does not exist yet. syncCacheProgram() fills prog
|
||||
// in when ensureGl() brings the context up, and degrades the cache to this
|
||||
// same always-ready CSS state if the shader fails to compile.
|
||||
const isCssFallback = t.shader === undefined;
|
||||
|
||||
const dur = t.duration ?? DEFAULT_DURATION;
|
||||
const ease = t.ease ?? DEFAULT_EASE;
|
||||
@@ -1349,7 +1468,8 @@ export function init(config: HyperShaderConfig): GsapTimeline {
|
||||
duration: dur,
|
||||
fromId,
|
||||
toId,
|
||||
prog,
|
||||
shader: t.shader,
|
||||
prog: null,
|
||||
frames: [],
|
||||
cacheKey: "",
|
||||
dirty: !isCssFallback,
|
||||
@@ -1369,11 +1489,11 @@ export function init(config: HyperShaderConfig): GsapTimeline {
|
||||
const toScene = document.getElementById(toId);
|
||||
if (!fromScene || !toScene) return;
|
||||
|
||||
state.prog = prog;
|
||||
const cache = cachedTransitions[cacheIndex];
|
||||
state.prog = cache?.prog ?? null;
|
||||
state.transitionIndex = cacheIndex;
|
||||
state.progress = 0;
|
||||
state.active = true;
|
||||
const cache = cachedTransitions[cacheIndex];
|
||||
if (cache?.fallback) {
|
||||
applyFallbackTransition(cache, 0);
|
||||
return;
|
||||
@@ -1483,22 +1603,27 @@ export function init(config: HyperShaderConfig): GsapTimeline {
|
||||
cache.textureGeneration += 1;
|
||||
for (const frame of cache.frames) {
|
||||
if (frame.fromTex) {
|
||||
gl.deleteTexture(frame.fromTex);
|
||||
gl?.deleteTexture(frame.fromTex);
|
||||
frame.fromTex = null;
|
||||
}
|
||||
if (frame.toTex) {
|
||||
gl.deleteTexture(frame.toTex);
|
||||
gl?.deleteTexture(frame.toTex);
|
||||
frame.toTex = null;
|
||||
}
|
||||
}
|
||||
cache.textureReady = false;
|
||||
};
|
||||
|
||||
// Caches with prog === null are CSS crossfade transitions and must stay in
|
||||
// the always-ready fallback state. Without this guard, disposeCachedTransition
|
||||
// Transitions that play as a DOM crossfade and must stay in the always-ready
|
||||
// fallback state: the author asked for no shader, or the context is up and
|
||||
// the shader failed to compile. Without this guard, disposeCachedTransition
|
||||
// + markScenesDirty would route them through the WebGL prewarm path and
|
||||
// tickShader would eventually call renderShader(state.prog!) with a null prog.
|
||||
const isCssOnlyTransition = (cache: CachedTransition): boolean => cache.prog === null;
|
||||
// `prog === null` alone cannot be the test — before ensureGl() every cache
|
||||
// has a null prog, which would strand real shader transitions here and skip
|
||||
// the prewarm that captures their frames.
|
||||
const isCssOnlyTransition = (cache: CachedTransition): boolean =>
|
||||
cache.shader === undefined || (gl !== null && cache.prog === null);
|
||||
|
||||
const disposeCachedTransition = (cache: CachedTransition): void => {
|
||||
disposeTransitionTextures(cache);
|
||||
@@ -1543,7 +1668,7 @@ export function init(config: HyperShaderConfig): GsapTimeline {
|
||||
getSceneSignature(cache.fromId),
|
||||
cache.toId,
|
||||
getSceneSignature(cache.toId),
|
||||
transitions[cache.index]?.shader || "unknown",
|
||||
cache.shader || "unknown",
|
||||
cache.time,
|
||||
cache.duration,
|
||||
sampleCount,
|
||||
@@ -1707,8 +1832,8 @@ export function init(config: HyperShaderConfig): GsapTimeline {
|
||||
]);
|
||||
if (!fromEntry || !toEntry) {
|
||||
for (const frame of hydratedFrames) {
|
||||
gl.deleteTexture(frame.fromTex);
|
||||
gl.deleteTexture(frame.toTex);
|
||||
gl?.deleteTexture(frame.fromTex);
|
||||
gl?.deleteTexture(frame.toTex);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -1732,12 +1857,20 @@ export function init(config: HyperShaderConfig): GsapTimeline {
|
||||
};
|
||||
|
||||
const ensureTransitionTextures = (cache: CachedTransition): Promise<boolean> => {
|
||||
// Single choke point for texture GL work — everything that would upload to
|
||||
// a dead context routes through here.
|
||||
if (contextLost) return Promise.resolve(false);
|
||||
if (cache.fallback || cache.dirty || !cache.ready) return Promise.resolve(false);
|
||||
if (cache.textureReady) {
|
||||
markTextureAccess(cache);
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
if (cache.texturePromise) return cache.texturePromise;
|
||||
// Being that choke point, this is also where a composition first NEEDS a
|
||||
// context: the guards above have already returned for every cache that
|
||||
// plays as a CSS crossfade, so nothing that reaches here is context-free.
|
||||
const gl = ensureGl();
|
||||
if (!gl) return Promise.resolve(false);
|
||||
|
||||
const generation = cache.textureGeneration;
|
||||
const frames = cache.frames;
|
||||
@@ -2202,6 +2335,8 @@ export function init(config: HyperShaderConfig): GsapTimeline {
|
||||
for (const observer of sceneEditObservers) {
|
||||
observer.disconnect();
|
||||
}
|
||||
// Null when no shader transition ever drew — there is no context to release.
|
||||
releaseGlContext?.();
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createProgram, manageContextLoss } from "./webgl.js";
|
||||
|
||||
function createMockWebGl(): WebGLRenderingContext {
|
||||
const loseContext = vi.fn();
|
||||
return {
|
||||
VERTEX_SHADER: 0x8b31,
|
||||
FRAGMENT_SHADER: 0x8b30,
|
||||
COMPILE_STATUS: 0x8b81,
|
||||
LINK_STATUS: 0x8b82,
|
||||
createShader: vi.fn(() => ({})),
|
||||
shaderSource: vi.fn(),
|
||||
compileShader: vi.fn(),
|
||||
getShaderParameter: vi.fn(() => true),
|
||||
getShaderInfoLog: vi.fn(() => ""),
|
||||
createProgram: vi.fn(() => ({})),
|
||||
attachShader: vi.fn(),
|
||||
linkProgram: vi.fn(),
|
||||
getProgramParameter: vi.fn(() => true),
|
||||
getProgramInfoLog: vi.fn(() => ""),
|
||||
getExtension: vi.fn((name: string) => (name === "WEBGL_lose_context" ? { loseContext } : null)),
|
||||
} as unknown as WebGLRenderingContext;
|
||||
}
|
||||
|
||||
describe("manageContextLoss", () => {
|
||||
it("cancels the default loss action so the context can be restored", () => {
|
||||
const canvas = document.createElement("canvas");
|
||||
const gl = createMockWebGl();
|
||||
const onLost = vi.fn();
|
||||
manageContextLoss(canvas, gl, { onLost, onRestored: vi.fn() });
|
||||
|
||||
const lost = new Event("webglcontextlost", { cancelable: true });
|
||||
canvas.dispatchEvent(lost);
|
||||
|
||||
expect(lost.defaultPrevented).toBe(true);
|
||||
expect(onLost).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("reports restoration", () => {
|
||||
const canvas = document.createElement("canvas");
|
||||
const onRestored = vi.fn();
|
||||
manageContextLoss(canvas, createMockWebGl(), { onLost: vi.fn(), onRestored });
|
||||
|
||||
canvas.dispatchEvent(new Event("webglcontextrestored"));
|
||||
|
||||
expect(onRestored).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("releases the context explicitly on teardown and stops listening", () => {
|
||||
const canvas = document.createElement("canvas");
|
||||
const gl = createMockWebGl();
|
||||
const onLost = vi.fn();
|
||||
const release = manageContextLoss(canvas, gl, { onLost, onRestored: vi.fn() });
|
||||
|
||||
release();
|
||||
|
||||
const extension = vi.mocked(gl.getExtension).mock.results[0]?.value as {
|
||||
loseContext: () => void;
|
||||
};
|
||||
expect(extension.loseContext).toHaveBeenCalledTimes(1);
|
||||
|
||||
canvas.dispatchEvent(new Event("webglcontextlost", { cancelable: true }));
|
||||
expect(onLost).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("createProgram", () => {
|
||||
// The vertex shader used to be cached in a module-level singleton, which
|
||||
// outlived the context it was compiled in — after a restore every program
|
||||
// linked against a dead shader.
|
||||
it("compiles a fresh vertex shader per program", () => {
|
||||
const gl = createMockWebGl();
|
||||
|
||||
createProgram(gl, "void main(){}");
|
||||
createProgram(gl, "void main(){}");
|
||||
|
||||
const vertexCompiles = vi
|
||||
.mocked(gl.createShader)
|
||||
.mock.calls.filter(([type]) => type === gl.VERTEX_SHADER);
|
||||
expect(vertexCompiles).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,34 @@ export function createContext(
|
||||
return gl as WebGLRenderingContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep `gl` recoverable, and hand it back explicitly when the caller is done.
|
||||
*
|
||||
* Browsers cap concurrent WebGL contexts (~16) and silently drop the oldest.
|
||||
* The default action of `webglcontextlost` makes that drop permanent, so
|
||||
* without `preventDefault()` a transition just stops rendering with no
|
||||
* diagnostic. The returned teardown releases the context instead of waiting
|
||||
* for the canvas to become collectable.
|
||||
*/
|
||||
export function manageContextLoss(
|
||||
canvas: HTMLCanvasElement,
|
||||
gl: WebGLRenderingContext,
|
||||
handlers: { onLost: () => void; onRestored: () => void },
|
||||
): () => void {
|
||||
const handleLost = (event: Event): void => {
|
||||
event.preventDefault();
|
||||
handlers.onLost();
|
||||
};
|
||||
const handleRestored = (): void => handlers.onRestored();
|
||||
canvas.addEventListener("webglcontextlost", handleLost);
|
||||
canvas.addEventListener("webglcontextrestored", handleRestored);
|
||||
return () => {
|
||||
canvas.removeEventListener("webglcontextlost", handleLost);
|
||||
canvas.removeEventListener("webglcontextrestored", handleRestored);
|
||||
gl.getExtension("WEBGL_lose_context")?.loseContext();
|
||||
};
|
||||
}
|
||||
|
||||
export function setupQuad(gl: WebGLRenderingContext): WebGLBuffer {
|
||||
const buf = gl.createBuffer();
|
||||
if (!buf) throw new Error("[HyperShader] Failed to create quad buffer");
|
||||
@@ -23,8 +51,6 @@ export function setupQuad(gl: WebGLRenderingContext): WebGLBuffer {
|
||||
return buf;
|
||||
}
|
||||
|
||||
let cachedVertexShader: WebGLShader | null = null;
|
||||
|
||||
function compileShader(gl: WebGLRenderingContext, src: string, type: number): WebGLShader {
|
||||
const s = gl.createShader(type);
|
||||
if (!s) throw new Error("[HyperShader] Failed to create shader");
|
||||
@@ -52,11 +78,13 @@ function linkProgram(
|
||||
return p;
|
||||
}
|
||||
|
||||
// The shared vertex shader used to be cached in a module-level singleton. That
|
||||
// made it outlive both the context it was compiled in and any second context on
|
||||
// the page, so every program linked after a context loss would link against a
|
||||
// dead shader. It is five lines of GLSL compiled a handful of times per init —
|
||||
// cheaper to recompile than to invalidate.
|
||||
export function createProgram(gl: WebGLRenderingContext, fragSrc: string): WebGLProgram {
|
||||
if (!cachedVertexShader) {
|
||||
cachedVertexShader = compileShader(gl, vertSrc, gl.VERTEX_SHADER);
|
||||
}
|
||||
return linkProgram(gl, cachedVertexShader, fragSrc);
|
||||
return createProgramWithVertex(gl, vertSrc, fragSrc);
|
||||
}
|
||||
|
||||
export function createProgramWithVertex(
|
||||
|
||||
Reference in New Issue
Block a user