@tensordoc/prism 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +175 -0
- package/dist/backends/milkdrop.d.ts +28 -0
- package/dist/backends/shadertoy.d.ts +22 -0
- package/dist/image-feed.d.ts +32 -0
- package/dist/image-overlay.d.ts +84 -0
- package/dist/index.d.ts +9 -0
- package/dist/player.d.ts +150 -0
- package/dist/prism.cjs +37 -0
- package/dist/prism.cjs.map +1 -0
- package/dist/prism.mjs +1271 -0
- package/dist/prism.mjs.map +1 -0
- package/dist/registry.d.ts +18 -0
- package/dist/runtime.d.ts +25 -0
- package/dist/synth.d.ts +47 -0
- package/dist/types.d.ts +28 -0
- package/package.json +58 -0
- package/src/backends/milkdrop.ts +190 -0
- package/src/backends/shadertoy.ts +290 -0
- package/src/image-feed.ts +185 -0
- package/src/image-overlay.ts +302 -0
- package/src/index.ts +27 -0
- package/src/peer-types.d.ts +18 -0
- package/src/player.ts +395 -0
- package/src/registry.generated.json +2022 -0
- package/src/registry.ts +70 -0
- package/src/runtime.ts +100 -0
- package/src/synth.ts +251 -0
- package/src/types.ts +98 -0
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
// shadertoy.ts — render a Shadertoy-style GLSL fragment shader to a
|
|
2
|
+
// caller-supplied canvas. Mirrors backends/milkdrop.ts's role: takes audio
|
|
3
|
+
// in, produces a live visual. GraphRuntime swaps between the two based on
|
|
4
|
+
// whether the graph has a lf.milkdrop or lf.shadertoy node.
|
|
5
|
+
//
|
|
6
|
+
// Shadertoy convention: the user shader defines
|
|
7
|
+
// void mainImage(out vec4 fragColor, in vec2 fragCoord)
|
|
8
|
+
// We wrap that with our standard uniforms:
|
|
9
|
+
// iTime, iResolution, iMouse, iChannel0 (audio FFT 256x1)
|
|
10
|
+
|
|
11
|
+
const VERTEX_SHADER = `#version 300 es
|
|
12
|
+
precision highp float;
|
|
13
|
+
out vec2 v_uv;
|
|
14
|
+
void main() {
|
|
15
|
+
// Fullscreen triangle (no buffer needed; gl_VertexID drives positions).
|
|
16
|
+
vec2 p = vec2((gl_VertexID & 1) * 4 - 1, (gl_VertexID & 2) * 2 - 1);
|
|
17
|
+
v_uv = p * 0.5 + 0.5;
|
|
18
|
+
gl_Position = vec4(p, 0.0, 1.0);
|
|
19
|
+
}
|
|
20
|
+
`;
|
|
21
|
+
|
|
22
|
+
const FRAGMENT_PREAMBLE = `#version 300 es
|
|
23
|
+
precision highp float;
|
|
24
|
+
precision highp int;
|
|
25
|
+
|
|
26
|
+
uniform float iTime;
|
|
27
|
+
uniform float iTimeDelta;
|
|
28
|
+
uniform int iFrame;
|
|
29
|
+
uniform vec3 iResolution;
|
|
30
|
+
uniform vec4 iMouse;
|
|
31
|
+
uniform sampler2D iChannel0; // audio FFT (256x1, R8)
|
|
32
|
+
uniform sampler2D iChannel1; // image input (default_image or bound source)
|
|
33
|
+
in vec2 v_uv;
|
|
34
|
+
out vec4 outColor;
|
|
35
|
+
|
|
36
|
+
`;
|
|
37
|
+
|
|
38
|
+
const FRAGMENT_EPILOGUE = `
|
|
39
|
+
|
|
40
|
+
void main() {
|
|
41
|
+
vec4 c;
|
|
42
|
+
mainImage(c, gl_FragCoord.xy);
|
|
43
|
+
outColor = vec4(c.rgb, 1.0);
|
|
44
|
+
}
|
|
45
|
+
`;
|
|
46
|
+
|
|
47
|
+
export interface ShadertoyBg {
|
|
48
|
+
/** Pretty name from the source URL, useful for SKILL readout. */
|
|
49
|
+
readonly presetName: string;
|
|
50
|
+
/** URL of the currently-loaded shader. */
|
|
51
|
+
readonly currentUrl: string | null;
|
|
52
|
+
/** Connect a Web Audio source — Shadertoy's iChannel0 sees its FFT. */
|
|
53
|
+
connectAudio: (node: AudioNode) => void;
|
|
54
|
+
/** Fetch + compile + render a new shader. Resolves once first frame
|
|
55
|
+
* paints. Throws on compile error. */
|
|
56
|
+
loadFromUrl: (url: string) => Promise<string>;
|
|
57
|
+
/** Bind an image URL as iChannel1. Resolves once decoded + uploaded.
|
|
58
|
+
* Pass null to clear (resets to the 1x1 placeholder). */
|
|
59
|
+
bindImage: (url: string | null) => Promise<void>;
|
|
60
|
+
/** Pipe a live source (e.g. a slideshow's canvas) into iChannel1 — its
|
|
61
|
+
* contents are uploaded to the GPU every frame, so when the slideshow
|
|
62
|
+
* advances to the next image, the shader sees it immediately. Pass
|
|
63
|
+
* null to disable + revert to whatever was last bound via bindImage. */
|
|
64
|
+
setLiveSource: (source: HTMLCanvasElement | HTMLVideoElement | null) => void;
|
|
65
|
+
/** Pause the render loop + free GL resources. */
|
|
66
|
+
destroy: () => void;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function createShadertoyBackground(
|
|
70
|
+
audioCtx: AudioContext,
|
|
71
|
+
canvas: HTMLCanvasElement,
|
|
72
|
+
silentSource: AudioNode,
|
|
73
|
+
): ShadertoyBg {
|
|
74
|
+
const glOrNull = canvas.getContext("webgl2", {
|
|
75
|
+
alpha: false,
|
|
76
|
+
antialias: false,
|
|
77
|
+
preserveDrawingBuffer: false,
|
|
78
|
+
powerPreference: "high-performance",
|
|
79
|
+
});
|
|
80
|
+
if (!glOrNull) throw new Error("WebGL2 not available");
|
|
81
|
+
const gl: WebGL2RenderingContext = glOrNull;
|
|
82
|
+
|
|
83
|
+
const sizeTo = (w: number, h: number): void => {
|
|
84
|
+
canvas.width = w;
|
|
85
|
+
canvas.height = h;
|
|
86
|
+
gl.viewport(0, 0, w, h);
|
|
87
|
+
};
|
|
88
|
+
sizeTo(window.innerWidth, window.innerHeight);
|
|
89
|
+
|
|
90
|
+
// Audio analyser → 256-bin FFT texture for iChannel0.
|
|
91
|
+
let audioSource: AudioNode = silentSource;
|
|
92
|
+
const analyser = audioCtx.createAnalyser();
|
|
93
|
+
analyser.fftSize = 512; // 256 frequency bins
|
|
94
|
+
audioSource.connect(analyser);
|
|
95
|
+
const fftBytes = new Uint8Array(256);
|
|
96
|
+
const audioTex = gl.createTexture()!;
|
|
97
|
+
gl.bindTexture(gl.TEXTURE_2D, audioTex);
|
|
98
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
|
99
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
|
|
100
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
|
101
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
|
102
|
+
// WebGL2 single-channel texture: R8 internal + RED format. LUMINANCE
|
|
103
|
+
// is deprecated and unreliable in WebGL2 / GLSL 300 es shaders.
|
|
104
|
+
gl.texImage2D(gl.TEXTURE_2D, 0, gl.R8, 256, 1, 0, gl.RED, gl.UNSIGNED_BYTE, null);
|
|
105
|
+
|
|
106
|
+
// iChannel1: image texture. Starts as 1x1 dim-gray placeholder so the
|
|
107
|
+
// shader has something to sample before bindImage resolves.
|
|
108
|
+
const imageTex = gl.createTexture()!;
|
|
109
|
+
gl.bindTexture(gl.TEXTURE_2D, imageTex);
|
|
110
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
|
111
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
|
|
112
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT);
|
|
113
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT);
|
|
114
|
+
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE,
|
|
115
|
+
new Uint8Array([32, 32, 38, 255]));
|
|
116
|
+
|
|
117
|
+
const dummyVao = gl.createVertexArray()!; // needed for vertexAttrib-less rendering
|
|
118
|
+
|
|
119
|
+
let currentProgram: WebGLProgram | null = null;
|
|
120
|
+
let currentUrl: string | null = null;
|
|
121
|
+
let currentName = "—";
|
|
122
|
+
let liveSource: HTMLCanvasElement | HTMLVideoElement | null = null;
|
|
123
|
+
const startTime = performance.now();
|
|
124
|
+
let lastFrameTime = startTime;
|
|
125
|
+
let frame = 0;
|
|
126
|
+
let mouseX = 0;
|
|
127
|
+
let mouseY = 0;
|
|
128
|
+
let mouseDown = false;
|
|
129
|
+
let running = true;
|
|
130
|
+
|
|
131
|
+
function compile(type: GLenum, src: string): WebGLShader {
|
|
132
|
+
const sh = gl.createShader(type)!;
|
|
133
|
+
gl.shaderSource(sh, src);
|
|
134
|
+
gl.compileShader(sh);
|
|
135
|
+
if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) {
|
|
136
|
+
const log = gl.getShaderInfoLog(sh) ?? "(no log)";
|
|
137
|
+
gl.deleteShader(sh);
|
|
138
|
+
throw new Error(`GLSL compile error:\n${log}\n--- source ---\n${numberLines(src)}`);
|
|
139
|
+
}
|
|
140
|
+
return sh;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function numberLines(src: string): string {
|
|
144
|
+
return src.split("\n").map((l, i) => `${String(i + 1).padStart(3)}: ${l}`).join("\n");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function link(userSource: string): WebGLProgram {
|
|
148
|
+
const vs = compile(gl.VERTEX_SHADER, VERTEX_SHADER);
|
|
149
|
+
const fs = compile(gl.FRAGMENT_SHADER, FRAGMENT_PREAMBLE + userSource + FRAGMENT_EPILOGUE);
|
|
150
|
+
const prog = gl.createProgram()!;
|
|
151
|
+
gl.attachShader(prog, vs);
|
|
152
|
+
gl.attachShader(prog, fs);
|
|
153
|
+
gl.linkProgram(prog);
|
|
154
|
+
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
|
|
155
|
+
const log = gl.getProgramInfoLog(prog) ?? "(no log)";
|
|
156
|
+
gl.deleteShader(vs); gl.deleteShader(fs); gl.deleteProgram(prog);
|
|
157
|
+
throw new Error(`Shader link error: ${log}`);
|
|
158
|
+
}
|
|
159
|
+
gl.deleteShader(vs); gl.deleteShader(fs);
|
|
160
|
+
return prog;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function frameLoop(): void {
|
|
164
|
+
if (!running) return;
|
|
165
|
+
const now = performance.now();
|
|
166
|
+
const iTime = (now - startTime) * 0.001;
|
|
167
|
+
const iTimeDelta = (now - lastFrameTime) * 0.001;
|
|
168
|
+
lastFrameTime = now;
|
|
169
|
+
frame++;
|
|
170
|
+
|
|
171
|
+
analyser.getByteFrequencyData(fftBytes);
|
|
172
|
+
gl.bindTexture(gl.TEXTURE_2D, audioTex);
|
|
173
|
+
gl.texImage2D(gl.TEXTURE_2D, 0, gl.R8, 256, 1, 0, gl.RED, gl.UNSIGNED_BYTE, fftBytes);
|
|
174
|
+
|
|
175
|
+
// If a live source (slideshow canvas, video element, etc.) is bound,
|
|
176
|
+
// upload its current contents to iChannel1 every frame. When the
|
|
177
|
+
// upstream advances to a new image, the shader sees it next frame.
|
|
178
|
+
if (liveSource &&
|
|
179
|
+
(liveSource instanceof HTMLCanvasElement
|
|
180
|
+
? liveSource.width > 0 && liveSource.height > 0
|
|
181
|
+
: liveSource.readyState >= 2)) {
|
|
182
|
+
gl.bindTexture(gl.TEXTURE_2D, imageTex);
|
|
183
|
+
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
|
|
184
|
+
try {
|
|
185
|
+
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, liveSource);
|
|
186
|
+
} catch {
|
|
187
|
+
// tainted canvas or zero-size — skip this frame
|
|
188
|
+
}
|
|
189
|
+
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (currentProgram) {
|
|
193
|
+
gl.useProgram(currentProgram);
|
|
194
|
+
const loc = (name: string): WebGLUniformLocation | null =>
|
|
195
|
+
gl.getUniformLocation(currentProgram!, name);
|
|
196
|
+
gl.uniform1f(loc("iTime"), iTime);
|
|
197
|
+
gl.uniform1f(loc("iTimeDelta"), iTimeDelta);
|
|
198
|
+
gl.uniform1i(loc("iFrame"), frame);
|
|
199
|
+
gl.uniform3f(loc("iResolution"), canvas.width, canvas.height, 1.0);
|
|
200
|
+
gl.uniform4f(loc("iMouse"), mouseX, canvas.height - mouseY, mouseDown ? 1 : 0, 0);
|
|
201
|
+
gl.activeTexture(gl.TEXTURE0);
|
|
202
|
+
gl.bindTexture(gl.TEXTURE_2D, audioTex);
|
|
203
|
+
gl.uniform1i(loc("iChannel0"), 0);
|
|
204
|
+
gl.activeTexture(gl.TEXTURE1);
|
|
205
|
+
gl.bindTexture(gl.TEXTURE_2D, imageTex);
|
|
206
|
+
gl.uniform1i(loc("iChannel1"), 1);
|
|
207
|
+
gl.bindVertexArray(dummyVao);
|
|
208
|
+
gl.drawArrays(gl.TRIANGLES, 0, 3);
|
|
209
|
+
} else {
|
|
210
|
+
gl.clearColor(0, 0, 0, 1);
|
|
211
|
+
gl.clear(gl.COLOR_BUFFER_BIT);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
requestAnimationFrame(frameLoop);
|
|
215
|
+
}
|
|
216
|
+
requestAnimationFrame(frameLoop);
|
|
217
|
+
|
|
218
|
+
const onResize = (): void => sizeTo(window.innerWidth, window.innerHeight);
|
|
219
|
+
const onPointerMove = (e: PointerEvent): void => { mouseX = e.clientX; mouseY = e.clientY; };
|
|
220
|
+
const onPointerDown = (): void => { mouseDown = true; };
|
|
221
|
+
const onPointerUp = (): void => { mouseDown = false; };
|
|
222
|
+
window.addEventListener("resize", onResize, { passive: true });
|
|
223
|
+
window.addEventListener("pointermove", onPointerMove, { passive: true });
|
|
224
|
+
window.addEventListener("pointerdown", onPointerDown, { passive: true });
|
|
225
|
+
window.addEventListener("pointerup", onPointerUp, { passive: true });
|
|
226
|
+
|
|
227
|
+
return {
|
|
228
|
+
get presetName(): string { return currentName; },
|
|
229
|
+
get currentUrl(): string | null { return currentUrl; },
|
|
230
|
+
connectAudio: (node) => {
|
|
231
|
+
audioSource.disconnect();
|
|
232
|
+
audioSource = node;
|
|
233
|
+
audioSource.connect(analyser);
|
|
234
|
+
},
|
|
235
|
+
loadFromUrl: async (url) => {
|
|
236
|
+
const res = await fetch(url);
|
|
237
|
+
if (!res.ok) throw new Error(`fetch ${url} → ${res.status}`);
|
|
238
|
+
const src = await res.text();
|
|
239
|
+
const prog = link(src);
|
|
240
|
+
if (currentProgram) gl.deleteProgram(currentProgram);
|
|
241
|
+
currentProgram = prog;
|
|
242
|
+
currentUrl = url;
|
|
243
|
+
currentName = url.split("/").pop()?.replace(/\.glsl$/, "") ?? url;
|
|
244
|
+
return currentName;
|
|
245
|
+
},
|
|
246
|
+
setLiveSource: (source) => {
|
|
247
|
+
liveSource = source;
|
|
248
|
+
},
|
|
249
|
+
bindImage: async (url) => {
|
|
250
|
+
if (!url) {
|
|
251
|
+
// Reset to placeholder
|
|
252
|
+
gl.bindTexture(gl.TEXTURE_2D, imageTex);
|
|
253
|
+
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE,
|
|
254
|
+
new Uint8Array([32, 32, 38, 255]));
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
const img = new Image();
|
|
258
|
+
img.crossOrigin = "anonymous";
|
|
259
|
+
await new Promise<void>((resolve, reject) => {
|
|
260
|
+
img.onload = () => resolve();
|
|
261
|
+
img.onerror = () => reject(new Error(`image load failed: ${url}`));
|
|
262
|
+
img.src = url;
|
|
263
|
+
});
|
|
264
|
+
gl.bindTexture(gl.TEXTURE_2D, imageTex);
|
|
265
|
+
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
|
|
266
|
+
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img);
|
|
267
|
+
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
|
|
268
|
+
// Use mipmaps if power-of-2; otherwise just LINEAR.
|
|
269
|
+
const pow2 = (img.naturalWidth & (img.naturalWidth - 1)) === 0
|
|
270
|
+
&& (img.naturalHeight & (img.naturalHeight - 1)) === 0;
|
|
271
|
+
if (pow2) {
|
|
272
|
+
gl.generateMipmap(gl.TEXTURE_2D);
|
|
273
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR);
|
|
274
|
+
} else {
|
|
275
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
|
276
|
+
}
|
|
277
|
+
},
|
|
278
|
+
destroy: () => {
|
|
279
|
+
running = false;
|
|
280
|
+
window.removeEventListener("resize", onResize);
|
|
281
|
+
window.removeEventListener("pointermove", onPointerMove);
|
|
282
|
+
window.removeEventListener("pointerdown", onPointerDown);
|
|
283
|
+
window.removeEventListener("pointerup", onPointerUp);
|
|
284
|
+
if (currentProgram) gl.deleteProgram(currentProgram);
|
|
285
|
+
gl.deleteTexture(audioTex);
|
|
286
|
+
gl.deleteTexture(imageTex);
|
|
287
|
+
gl.deleteVertexArray(dummyVao);
|
|
288
|
+
},
|
|
289
|
+
};
|
|
290
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// image-feed.ts — headless slideshow.
|
|
2
|
+
//
|
|
3
|
+
// Owns a single offscreen canvas, preloads a list of image URLs, and
|
|
4
|
+
// crossfades between them on a timer. Shaders read the canvas via the
|
|
5
|
+
// player's setLiveSource() — same plumbing as a live video element,
|
|
6
|
+
// just sourced from a slideshow instead of a camera.
|
|
7
|
+
//
|
|
8
|
+
// The site's src/landing/slideshow.ts is a richer surface (drag-resize
|
|
9
|
+
// card, progress bar, melt transitions); this is the minimum the npm
|
|
10
|
+
// package needs so an OSS consumer can pass image: ["a.jpg", "b.jpg"]
|
|
11
|
+
// and get a feed for the shader without writing canvas plumbing.
|
|
12
|
+
|
|
13
|
+
const DEFAULT_HOLD_SECONDS = 6;
|
|
14
|
+
const CROSSFADE_SECONDS = 1.0;
|
|
15
|
+
const CANVAS_W = 1280;
|
|
16
|
+
const CANVAS_H = 720;
|
|
17
|
+
|
|
18
|
+
export interface SlideshowOptions {
|
|
19
|
+
/** How long (seconds) each image is held on-screen before the
|
|
20
|
+
* crossfade to the next begins. Defaults to 6. */
|
|
21
|
+
holdSeconds?: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class HeadlessSlideshow {
|
|
25
|
+
/** The output canvas — bind this to the shader via setLiveSource. */
|
|
26
|
+
readonly canvas: HTMLCanvasElement;
|
|
27
|
+
private readonly ctx: CanvasRenderingContext2D;
|
|
28
|
+
private readonly urls: string[];
|
|
29
|
+
private readonly holdMs: number;
|
|
30
|
+
private readonly images: Array<HTMLImageElement | null>;
|
|
31
|
+
private currentIndex = 0;
|
|
32
|
+
private nextIndex = 1;
|
|
33
|
+
/** Wall-clock time the current crossfade started, or null if we're
|
|
34
|
+
* in the steady-hold portion of the cycle. */
|
|
35
|
+
private crossfadeStartMs: number | null = null;
|
|
36
|
+
private holdTimer: number | null = null;
|
|
37
|
+
private rafHandle: number | null = null;
|
|
38
|
+
private destroyed = false;
|
|
39
|
+
|
|
40
|
+
constructor(urls: string[], opts: SlideshowOptions = {}) {
|
|
41
|
+
if (urls.length === 0) {
|
|
42
|
+
throw new Error("HeadlessSlideshow: urls list cannot be empty");
|
|
43
|
+
}
|
|
44
|
+
this.urls = urls;
|
|
45
|
+
this.holdMs = Math.max(0.5, opts.holdSeconds ?? DEFAULT_HOLD_SECONDS) * 1000;
|
|
46
|
+
this.canvas = document.createElement("canvas");
|
|
47
|
+
this.canvas.width = CANVAS_W;
|
|
48
|
+
this.canvas.height = CANVAS_H;
|
|
49
|
+
const ctx = this.canvas.getContext("2d");
|
|
50
|
+
if (!ctx) throw new Error("HeadlessSlideshow: 2D canvas context unavailable");
|
|
51
|
+
this.ctx = ctx;
|
|
52
|
+
this.images = urls.map(() => null);
|
|
53
|
+
void this.preload();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
destroy(): void {
|
|
57
|
+
this.destroyed = true;
|
|
58
|
+
if (this.holdTimer != null) clearTimeout(this.holdTimer);
|
|
59
|
+
if (this.rafHandle != null) cancelAnimationFrame(this.rafHandle);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
private async preload(): Promise<void> {
|
|
63
|
+
// Load all images in parallel, but start rendering + cycling as
|
|
64
|
+
// soon as the first one's ready so the shader isn't stuck on a
|
|
65
|
+
// black frame waiting on slow tails.
|
|
66
|
+
const promises = this.urls.map((url, i) =>
|
|
67
|
+
loadImage(url).then(
|
|
68
|
+
(img) => {
|
|
69
|
+
if (this.destroyed) return;
|
|
70
|
+
this.images[i] = img;
|
|
71
|
+
if (i === 0) this.start();
|
|
72
|
+
},
|
|
73
|
+
(err: Error) => {
|
|
74
|
+
// Failure to load one image shouldn't kill the whole feed —
|
|
75
|
+
// leave that slot as null; the render loop skips it.
|
|
76
|
+
console.warn(`[/prism] image-feed: ${url} → ${err.message}`);
|
|
77
|
+
},
|
|
78
|
+
),
|
|
79
|
+
);
|
|
80
|
+
await Promise.allSettled(promises);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private start(): void {
|
|
84
|
+
if (this.destroyed) return;
|
|
85
|
+
this.drawFrame();
|
|
86
|
+
this.scheduleNext();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private scheduleNext(): void {
|
|
90
|
+
if (this.destroyed) return;
|
|
91
|
+
this.holdTimer = window.setTimeout(() => this.advance(), this.holdMs);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private advance(): void {
|
|
95
|
+
if (this.destroyed) return;
|
|
96
|
+
// Pick the next loaded image, skipping any that failed to load.
|
|
97
|
+
let candidate = (this.currentIndex + 1) % this.urls.length;
|
|
98
|
+
for (let i = 0; i < this.urls.length; i++) {
|
|
99
|
+
if (this.images[candidate]) break;
|
|
100
|
+
candidate = (candidate + 1) % this.urls.length;
|
|
101
|
+
}
|
|
102
|
+
if (candidate === this.currentIndex) {
|
|
103
|
+
// Only one loaded image — nothing to cross to, just re-arm.
|
|
104
|
+
this.scheduleNext();
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
this.nextIndex = candidate;
|
|
108
|
+
this.crossfadeStartMs = performance.now();
|
|
109
|
+
this.rafHandle = requestAnimationFrame(() => this.tickCrossfade());
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private tickCrossfade(): void {
|
|
113
|
+
if (this.destroyed) return;
|
|
114
|
+
if (this.crossfadeStartMs == null) return;
|
|
115
|
+
const t = (performance.now() - this.crossfadeStartMs) / 1000 / CROSSFADE_SECONDS;
|
|
116
|
+
if (t >= 1) {
|
|
117
|
+
// Crossfade finished — commit and queue the next hold.
|
|
118
|
+
this.currentIndex = this.nextIndex;
|
|
119
|
+
this.crossfadeStartMs = null;
|
|
120
|
+
this.drawFrame();
|
|
121
|
+
this.scheduleNext();
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
this.drawCrossfade(t);
|
|
125
|
+
this.rafHandle = requestAnimationFrame(() => this.tickCrossfade());
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Draw the current image fully opaque. */
|
|
129
|
+
private drawFrame(): void {
|
|
130
|
+
const img = this.images[this.currentIndex];
|
|
131
|
+
if (!img) return;
|
|
132
|
+
this.ctx.fillStyle = "#000";
|
|
133
|
+
this.ctx.fillRect(0, 0, CANVAS_W, CANVAS_H);
|
|
134
|
+
drawCovered(this.ctx, img, CANVAS_W, CANVAS_H);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Draw the current image, then the next one at alpha=t on top. */
|
|
138
|
+
private drawCrossfade(t: number): void {
|
|
139
|
+
const cur = this.images[this.currentIndex];
|
|
140
|
+
const nxt = this.images[this.nextIndex];
|
|
141
|
+
this.ctx.fillStyle = "#000";
|
|
142
|
+
this.ctx.fillRect(0, 0, CANVAS_W, CANVAS_H);
|
|
143
|
+
if (cur) drawCovered(this.ctx, cur, CANVAS_W, CANVAS_H);
|
|
144
|
+
if (nxt) {
|
|
145
|
+
this.ctx.save();
|
|
146
|
+
this.ctx.globalAlpha = t;
|
|
147
|
+
drawCovered(this.ctx, nxt, CANVAS_W, CANVAS_H);
|
|
148
|
+
this.ctx.restore();
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function loadImage(url: string): Promise<HTMLImageElement> {
|
|
154
|
+
return new Promise((resolve, reject) => {
|
|
155
|
+
const img = new Image();
|
|
156
|
+
img.crossOrigin = "anonymous";
|
|
157
|
+
img.onload = () => resolve(img);
|
|
158
|
+
img.onerror = () => reject(new Error(`image load failed: ${url}`));
|
|
159
|
+
img.src = url;
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Draw `img` covering the (w,h) box — like CSS object-fit: cover.
|
|
164
|
+
* Cropping rather than letterboxing keeps the slideshow visually full
|
|
165
|
+
* in the shader's iChannel1, which is usually being sampled by uv
|
|
166
|
+
* coordinates that assume the whole texture is content. */
|
|
167
|
+
function drawCovered(
|
|
168
|
+
ctx: CanvasRenderingContext2D,
|
|
169
|
+
img: HTMLImageElement,
|
|
170
|
+
w: number,
|
|
171
|
+
h: number,
|
|
172
|
+
): void {
|
|
173
|
+
const srcAR = img.naturalWidth / img.naturalHeight;
|
|
174
|
+
const dstAR = w / h;
|
|
175
|
+
let sx = 0, sy = 0, sw = img.naturalWidth, sh = img.naturalHeight;
|
|
176
|
+
if (srcAR > dstAR) {
|
|
177
|
+
// image is wider — crop horizontally
|
|
178
|
+
sw = img.naturalHeight * dstAR;
|
|
179
|
+
sx = (img.naturalWidth - sw) / 2;
|
|
180
|
+
} else {
|
|
181
|
+
sh = img.naturalWidth / dstAR;
|
|
182
|
+
sy = (img.naturalHeight - sh) / 2;
|
|
183
|
+
}
|
|
184
|
+
ctx.drawImage(img, sx, sy, sw, sh, 0, 0, w, h);
|
|
185
|
+
}
|