@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/src/player.ts ADDED
@@ -0,0 +1,395 @@
1
+ // player.ts — public PrismPlayer class.
2
+ //
3
+ // Owns a small DOM tree (two stacked <canvas> elements inside `container`),
4
+ // an AudioContext, a SyntheticSignal (the default audio driver), the two
5
+ // rendering backends, and a GraphRuntime that swaps between them.
6
+ //
7
+ // Minimal usage:
8
+ //
9
+ // const player = new PrismPlayer({ container: el, graph });
10
+ //
11
+ // The caller can override the audio source any time via connectAudio(),
12
+ // swap graphs via load(), or pipe a live texture into iChannel1 via
13
+ // setLiveSource(). Backends + runtime + synth + audioCtx are exposed
14
+ // readonly so embedders can drive them directly (read presetName, fire
15
+ // pulseBeat from an external trigger, etc.) without re-creating the API.
16
+
17
+ import { createMilkdropBackground, type MilkdropBg } from "./backends/milkdrop";
18
+ import { createShadertoyBackground, type ShadertoyBg } from "./backends/shadertoy";
19
+ import { HeadlessSlideshow } from "./image-feed";
20
+ import { shortIdToGraph } from "./registry";
21
+ import { GraphRuntime, type ApplyResult } from "./runtime";
22
+ import { SyntheticSignal } from "./synth";
23
+ import type { PrismGraph } from "./types";
24
+
25
+ /** Anything `load()` and the `graph` option accept. A string is treated
26
+ * as a catalog short_id (6-char base62) and resolved against the
27
+ * bundled registry. */
28
+ export type GraphInput = PrismGraph | string;
29
+
30
+ /** Anything the `image` option / `connectImage()` accepts. Strings
31
+ * beyond the "webcam" / "tab" sentinels are treated as static URLs. */
32
+ export type ImageSource =
33
+ | "webcam"
34
+ | "tab"
35
+ | string
36
+ | string[]
37
+ | HTMLCanvasElement
38
+ | HTMLVideoElement
39
+ | MediaStream;
40
+
41
+ export interface PrismPlayerOptions {
42
+ /** Where to mount the visualization canvases. Pass either an element
43
+ * (preferred from frameworks — React refs, Vue template refs, etc.)
44
+ * or a DOM id string for hand-written HTML embeds. The player
45
+ * creates its own canvases inside; it does not touch any children
46
+ * that already exist there. */
47
+ container: HTMLElement | string;
48
+ /** Optional initial graph. Pass a PrismGraph object directly, or a
49
+ * 6-char short_id string (e.g. "7Hq3pK") to look up an entry from
50
+ * the bundled catalog. Without it, cold-opens on a curated milkdrop
51
+ * preset; you can call load() later. */
52
+ graph?: GraphInput;
53
+ /** Audio source. An AudioNode is connected directly; "mic" and "tab"
54
+ * request the matching media stream via getUserMedia /
55
+ * getDisplayMedia and connect that. Undefined → the built-in
56
+ * SyntheticSignal drives both backends. */
57
+ audio?: "mic" | "tab" | MediaStream | AudioNode;
58
+ /** Bring your own AudioContext (useful when the embedder already has
59
+ * one; web pages can only have a small number of them). */
60
+ audioCtx?: AudioContext;
61
+ /** iChannel1 fallback used by shader backends until something more
62
+ * specific is bound by the graph or setLiveSource(). */
63
+ defaultImage?: string;
64
+ /** Image feed for shader backends (bound to iChannel1). Accepts:
65
+ * - A single URL → static image
66
+ * - An array of URLs → built-in crossfading slideshow
67
+ * - "webcam" / "tab" → getUserMedia / getDisplayMedia
68
+ * - a MediaStream / HTMLVideoElement → live video
69
+ * - an HTMLCanvasElement → live canvas (e.g. your own
70
+ * renderer; the player just
71
+ * re-uploads its contents every
72
+ * frame)
73
+ * Use this for "I want pictures showing through the shader" without
74
+ * thinking about iChannel1 or setLiveSource. */
75
+ image?: ImageSource;
76
+ /** Seconds each image is held on-screen before the crossfade to the
77
+ * next begins. Only used when `image` is a URL array. Defaults to 6. */
78
+ holdSeconds?: number;
79
+ /** Milkdrop preset to load on cold-open. Falls back to a random preset
80
+ * from the bundled library if the name isn't found. */
81
+ initialPresetName?: string;
82
+ /** Called once the first frame has painted. */
83
+ onReady?: () => void;
84
+ /** Async failures (audio capture rejection, graph load errors that
85
+ * cannot be surfaced from a synchronous call) land here. */
86
+ onError?: (err: Error) => void;
87
+ }
88
+
89
+ export class PrismPlayer {
90
+ /** AudioContext driving both backends + the default synthetic signal.
91
+ * Created on construction unless one was passed in `opts.audioCtx`. */
92
+ readonly audioCtx: AudioContext;
93
+ /** Default audio driver: a silent "pink-noise-ish" pad that keeps the
94
+ * visualization animated when no real audio is connected. Kept alive
95
+ * for the lifetime of the player — call `player.synth.stop()` if you
96
+ * want to free it after switching to a real audio source. */
97
+ readonly synth: SyntheticSignal;
98
+ /** Milkdrop (butterchurn) backend handle. Cold-opens on a random
99
+ * preset (or `initialPresetName` if you passed one). */
100
+ readonly milkdrop: MilkdropBg;
101
+ /** Shadertoy backend handle. Idle until a graph with `lf.shadertoy` is
102
+ * loaded (or you call `shadertoy.loadFromUrl` directly). */
103
+ readonly shadertoy: ShadertoyBg;
104
+ /** Graph executor — dispatches to the right backend based on the
105
+ * graph's light-field generator node. */
106
+ readonly runtime: GraphRuntime;
107
+ /** Tracks which backend's canvas is currently visible. */
108
+ activeBackend: "milkdrop" | "shadertoy" = "milkdrop";
109
+
110
+ private readonly milkdropCanvas: HTMLCanvasElement;
111
+ private readonly shadertoyCanvas: HTMLCanvasElement;
112
+ private readonly ownsAudioCtx: boolean;
113
+ /** Default hold per image when `image` is a URL list; set in ctor. */
114
+ private readonly defaultHoldSeconds: number;
115
+ /** Owned media resources (MediaStreams we started, <video> elements
116
+ * we created internally, headless slideshow). Cleaned up by
117
+ * connectImage() before re-binding and by destroy() on teardown. */
118
+ private currentSlideshow: HeadlessSlideshow | null = null;
119
+ private currentVideo: HTMLVideoElement | null = null;
120
+ private currentStream: MediaStream | null = null;
121
+ /** Audio MediaStream the player started itself ("mic" / "tab"). Held
122
+ * so disconnectAudio() can stop the tracks. Null if the caller
123
+ * passed in their own stream/node (we never stop tracks we don't own). */
124
+ private currentAudioOwnedStream: MediaStream | null = null;
125
+
126
+ constructor(opts: PrismPlayerOptions) {
127
+ const container = resolveContainer(opts.container);
128
+
129
+ this.audioCtx = opts.audioCtx ?? new AudioContext();
130
+ this.ownsAudioCtx = opts.audioCtx === undefined;
131
+ this.defaultHoldSeconds = opts.holdSeconds ?? 6;
132
+ this.synth = new SyntheticSignal(this.audioCtx);
133
+
134
+ // Two stacked canvases — opacity-crossfaded by setActiveBackend.
135
+ // Match the legacy id/class pair so existing CSS (cold-open animation,
136
+ // bg-canvas--hidden/--active modifiers) still applies on prism.scott.ai.
137
+ this.milkdropCanvas = createCanvas("milkdrop");
138
+ this.shadertoyCanvas = createCanvas("shadertoy");
139
+ this.shadertoyCanvas.classList.add("bg-canvas--hidden");
140
+ container.appendChild(this.milkdropCanvas);
141
+ container.appendChild(this.shadertoyCanvas);
142
+
143
+ this.milkdrop = createMilkdropBackground(
144
+ this.audioCtx,
145
+ this.milkdropCanvas,
146
+ this.synth.getOutput(),
147
+ opts.onReady,
148
+ { initialPresetName: opts.initialPresetName },
149
+ );
150
+ this.shadertoy = createShadertoyBackground(
151
+ this.audioCtx,
152
+ this.shadertoyCanvas,
153
+ this.synth.getOutput(),
154
+ );
155
+
156
+ if (opts.defaultImage) {
157
+ void this.shadertoy.bindImage(opts.defaultImage).catch((err: Error) => {
158
+ opts.onError?.(err);
159
+ });
160
+ }
161
+
162
+ this.runtime = new GraphRuntime({
163
+ milkdrop: this.milkdrop,
164
+ shadertoy: this.shadertoy,
165
+ setActiveBackend: (which) => this.setActiveBackend(which),
166
+ });
167
+
168
+ if (opts.graph !== undefined) {
169
+ const result = this.load(opts.graph);
170
+ if (!result.ok && opts.onError) {
171
+ opts.onError(new Error(result.error ?? "graph apply failed"));
172
+ }
173
+ }
174
+
175
+ if (opts.audio !== undefined) {
176
+ void this.connectAudio(opts.audio).catch((err: Error) => opts.onError?.(err));
177
+ }
178
+
179
+ if (opts.image !== undefined) {
180
+ void this.connectImage(opts.image).catch((err: Error) => opts.onError?.(err));
181
+ }
182
+ }
183
+
184
+ /** Swap to a new graph. Accepts either a full PrismGraph object or a
185
+ * 6-char short_id string from the bundled registry. Returns the
186
+ * ApplyResult the runtime emits so the caller can react to backend
187
+ * switches / errors. An unknown short_id returns
188
+ * `{ ok: false, error: "unknown short_id ..." }` without touching
189
+ * the running visualization. */
190
+ load(graph: GraphInput, blendSeconds?: number): ApplyResult {
191
+ if (typeof graph === "string") {
192
+ const resolved = shortIdToGraph(graph);
193
+ if (!resolved) {
194
+ return { ok: false, error: `unknown short_id: ${graph}` };
195
+ }
196
+ return this.runtime.apply(resolved, blendSeconds);
197
+ }
198
+ return this.runtime.apply(graph, blendSeconds);
199
+ }
200
+
201
+ /** Connect a new audio source. Accepts:
202
+ * - "mic" — getUserMedia({ audio: true })
203
+ * - "tab" — getDisplayMedia({ audio: true })
204
+ * - MediaStream — wrapped in a MediaStreamAudioSourceNode
205
+ * - AudioNode — connected directly
206
+ * Routes the source to both backends so whichever one is active sees
207
+ * reactivity. Tracks any MediaStream the player started itself so
208
+ * disconnectAudio() can stop the tracks (mic indicator off, etc.). */
209
+ async connectAudio(source: "mic" | "tab" | MediaStream | AudioNode): Promise<AudioNode> {
210
+ const { node, owned } = await this.resolveAudioNode(source);
211
+ this.milkdrop.connectAudio(node);
212
+ this.shadertoy.connectAudio(node);
213
+ this.currentAudioOwnedStream = owned;
214
+ return node;
215
+ }
216
+
217
+ /** Disconnect the current audio source and revert both backends to
218
+ * the built-in SyntheticSignal. Stops any MediaStream tracks the
219
+ * player started itself (mic / tab capture indicator goes off).
220
+ * Streams or AudioNodes the caller passed in are left untouched —
221
+ * the caller owns their lifetime. Idempotent. */
222
+ disconnectAudio(): void {
223
+ if (this.currentAudioOwnedStream) {
224
+ for (const track of this.currentAudioOwnedStream.getTracks()) track.stop();
225
+ this.currentAudioOwnedStream = null;
226
+ }
227
+ const fallback = this.synth.getOutput();
228
+ this.milkdrop.connectAudio(fallback);
229
+ this.shadertoy.connectAudio(fallback);
230
+ }
231
+
232
+ /** Pipe a live source (e.g. a slideshow's canvas) into iChannel1 — its
233
+ * contents are re-uploaded each frame. Pass null to disable.
234
+ * Most embedders should prefer connectImage(), which handles
235
+ * webcam/tab/slideshow plumbing for you. */
236
+ setLiveSource(source: HTMLCanvasElement | HTMLVideoElement | null): void {
237
+ this.shadertoy.setLiveSource(source);
238
+ }
239
+
240
+ /** Connect an image source for the shader's iChannel1. Symmetric to
241
+ * connectAudio. Replaces any prior image feed; old MediaStream
242
+ * tracks are stopped and internal slideshow timers are cleared.
243
+ * See ImageSource for the accepted shapes. */
244
+ async connectImage(source: ImageSource): Promise<void> {
245
+ this.teardownImageFeed();
246
+ if (typeof source === "string") {
247
+ if (source === "webcam") {
248
+ const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: false });
249
+ this.bindStream(stream);
250
+ return;
251
+ }
252
+ if (source === "tab") {
253
+ const stream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: false });
254
+ this.bindStream(stream);
255
+ return;
256
+ }
257
+ // Treat any other string as a static image URL.
258
+ this.setLiveSource(null);
259
+ await this.shadertoy.bindImage(source);
260
+ return;
261
+ }
262
+ if (Array.isArray(source)) {
263
+ const slideshow = new HeadlessSlideshow(source, { holdSeconds: this.defaultHoldSeconds });
264
+ this.currentSlideshow = slideshow;
265
+ this.setLiveSource(slideshow.canvas);
266
+ return;
267
+ }
268
+ if (source instanceof MediaStream) {
269
+ this.bindStream(source);
270
+ return;
271
+ }
272
+ // Canvas or video element passed in directly — wire it as the
273
+ // live source without owning it (caller manages its lifetime).
274
+ this.setLiveSource(source);
275
+ }
276
+
277
+ /** Stop the current image feed and unbind from the shader. Releases
278
+ * resources the player owned: slideshow timer cleared, MediaStream
279
+ * tracks stopped (webcam light off), internal <video> detached.
280
+ * Canvas/video elements the caller passed in are left untouched.
281
+ * After this, the shader reverts to whatever was last bound via
282
+ * `defaultImage` or `shadertoy.bindImage()` (or the 1×1 placeholder).
283
+ * Idempotent. */
284
+ disconnectImage(): void {
285
+ this.teardownImageFeed();
286
+ }
287
+
288
+ /** Toggle which backend's canvas is visible. Called by GraphRuntime;
289
+ * callers can flip it manually too (rare). */
290
+ setActiveBackend(which: "milkdrop" | "shadertoy"): void {
291
+ this.activeBackend = which;
292
+ if (which === "milkdrop") {
293
+ this.milkdropCanvas.classList.remove("bg-canvas--hidden");
294
+ this.milkdropCanvas.classList.add("bg-canvas--active");
295
+ this.shadertoyCanvas.classList.add("bg-canvas--hidden");
296
+ this.shadertoyCanvas.classList.remove("bg-canvas--active");
297
+ } else {
298
+ this.shadertoyCanvas.classList.remove("bg-canvas--hidden");
299
+ this.shadertoyCanvas.classList.add("bg-canvas--active");
300
+ this.milkdropCanvas.classList.add("bg-canvas--hidden");
301
+ this.milkdropCanvas.classList.remove("bg-canvas--active");
302
+ }
303
+ }
304
+
305
+ /** Stop animation, free GL resources, detach canvases, close the
306
+ * AudioContext (only if the player created it itself). */
307
+ destroy(): void {
308
+ this.teardownImageFeed();
309
+ if (this.currentAudioOwnedStream) {
310
+ for (const track of this.currentAudioOwnedStream.getTracks()) track.stop();
311
+ this.currentAudioOwnedStream = null;
312
+ }
313
+ this.milkdrop.destroy();
314
+ this.shadertoy.destroy();
315
+ this.synth.stop();
316
+ this.milkdropCanvas.remove();
317
+ this.shadertoyCanvas.remove();
318
+ if (this.ownsAudioCtx) {
319
+ void this.audioCtx.close();
320
+ }
321
+ }
322
+
323
+ /** Wrap a MediaStream in an autoplaying <video> and bind that as the
324
+ * live source. The video element is held internally; teardown is
325
+ * handled by teardownImageFeed(). */
326
+ private bindStream(stream: MediaStream): void {
327
+ const video = document.createElement("video");
328
+ video.srcObject = stream;
329
+ video.autoplay = true;
330
+ video.muted = true;
331
+ video.playsInline = true;
332
+ // Auto-play may reject (Safari, autoplay policy); we ignore — the
333
+ // user gesture that opened the stream usually satisfies the policy.
334
+ void video.play().catch(() => undefined);
335
+ this.currentVideo = video;
336
+ this.currentStream = stream;
337
+ this.setLiveSource(video);
338
+ }
339
+
340
+ /** Stop the current image feed (slideshow timer, MediaStream tracks,
341
+ * internal <video>) and unbind from the shader. Idempotent. */
342
+ private teardownImageFeed(): void {
343
+ if (this.currentSlideshow) {
344
+ this.currentSlideshow.destroy();
345
+ this.currentSlideshow = null;
346
+ }
347
+ if (this.currentStream) {
348
+ for (const track of this.currentStream.getTracks()) track.stop();
349
+ this.currentStream = null;
350
+ }
351
+ if (this.currentVideo) {
352
+ this.currentVideo.srcObject = null;
353
+ this.currentVideo = null;
354
+ }
355
+ this.setLiveSource(null);
356
+ }
357
+
358
+ private async resolveAudioNode(
359
+ source: "mic" | "tab" | MediaStream | AudioNode,
360
+ ): Promise<{ node: AudioNode; owned: MediaStream | null }> {
361
+ if (source instanceof AudioNode) return { node: source, owned: null };
362
+ let stream: MediaStream;
363
+ let owned = false;
364
+ if (source instanceof MediaStream) {
365
+ stream = source;
366
+ } else if (source === "mic") {
367
+ stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });
368
+ owned = true;
369
+ } else {
370
+ stream = await navigator.mediaDevices.getDisplayMedia({ audio: true, video: false });
371
+ owned = true;
372
+ }
373
+ return {
374
+ node: this.audioCtx.createMediaStreamSource(stream),
375
+ owned: owned ? stream : null,
376
+ };
377
+ }
378
+ }
379
+
380
+ function resolveContainer(container: HTMLElement | string): HTMLElement {
381
+ if (typeof container !== "string") return container;
382
+ const el = document.getElementById(container);
383
+ if (!el) {
384
+ throw new Error(`PrismPlayer: no element found with id "${container}"`);
385
+ }
386
+ return el;
387
+ }
388
+
389
+ function createCanvas(id: string): HTMLCanvasElement {
390
+ const canvas = document.createElement("canvas");
391
+ canvas.id = id;
392
+ canvas.className = "bg-canvas";
393
+ canvas.setAttribute("aria-hidden", "true");
394
+ return canvas;
395
+ }