vizcore 1.0.0 → 1.2.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.
Files changed (90) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +66 -648
  3. data/docs/assets/playground-worker.js +373 -0
  4. data/docs/assets/playground.css +440 -0
  5. data/docs/assets/playground.js +652 -0
  6. data/docs/index.html +2 -1
  7. data/docs/playground.html +81 -0
  8. data/docs/shape_dsl.md +269 -0
  9. data/frontend/index.html +50 -2
  10. data/frontend/src/audio-inspector.js +9 -0
  11. data/frontend/src/custom-shape-param-controls.js +106 -0
  12. data/frontend/src/live-controls.js +219 -7
  13. data/frontend/src/main.js +703 -45
  14. data/frontend/src/mapping-target-selector.js +109 -0
  15. data/frontend/src/midi-learn.js +22 -2
  16. data/frontend/src/performance-monitor.js +137 -1
  17. data/frontend/src/renderer/engine.js +401 -11
  18. data/frontend/src/renderer/layer-manager.js +490 -75
  19. data/frontend/src/runtime-control-preset.js +44 -0
  20. data/frontend/src/scene-patches.js +159 -0
  21. data/frontend/src/shader-error-overlay.js +1 -0
  22. data/frontend/src/shape-editor-controls.js +157 -0
  23. data/frontend/src/visuals/geometry.js +425 -27
  24. data/frontend/src/visuals/image-renderer.js +19 -0
  25. data/frontend/src/visuals/particle-system.js +10 -0
  26. data/frontend/src/visuals/shape-renderer.js +488 -0
  27. data/frontend/src/visuals/spectrogram-renderer.js +14 -0
  28. data/frontend/src/visuals/svg-arc.js +104 -0
  29. data/frontend/src/visuals/text-renderer.js +13 -0
  30. data/frontend/src/websocket-client.js +6 -0
  31. data/lib/vizcore/analysis/adaptive_normalizer.rb +20 -2
  32. data/lib/vizcore/analysis/bpm_estimator.rb +18 -8
  33. data/lib/vizcore/analysis/feature_recorder.rb +117 -7
  34. data/lib/vizcore/analysis/feature_replay.rb +48 -9
  35. data/lib/vizcore/analysis/pipeline.rb +258 -9
  36. data/lib/vizcore/analysis/tap_tempo.rb +17 -2
  37. data/lib/vizcore/audio/calibration.rb +156 -0
  38. data/lib/vizcore/audio/file_input.rb +28 -0
  39. data/lib/vizcore/audio/input_manager.rb +36 -1
  40. data/lib/vizcore/audio/midi_input.rb +5 -0
  41. data/lib/vizcore/audio/ring_buffer.rb +22 -0
  42. data/lib/vizcore/audio.rb +1 -0
  43. data/lib/vizcore/cli/dsl_reference.rb +65 -9
  44. data/lib/vizcore/cli/plugin_checker.rb +93 -0
  45. data/lib/vizcore/cli/scene_diagnostics.rb +2 -2
  46. data/lib/vizcore/cli/scene_inspector.rb +35 -1
  47. data/lib/vizcore/cli/scene_validator.rb +573 -33
  48. data/lib/vizcore/cli/shader_template.rb +7 -2
  49. data/lib/vizcore/cli/shader_uniform_docs.rb +11 -0
  50. data/lib/vizcore/cli.rb +268 -15
  51. data/lib/vizcore/config.rb +40 -3
  52. data/lib/vizcore/control_preset.rb +29 -0
  53. data/lib/vizcore/deep_copy.rb +21 -0
  54. data/lib/vizcore/dsl/color_helpers.rb +155 -0
  55. data/lib/vizcore/dsl/engine.rb +219 -23
  56. data/lib/vizcore/dsl/layer_builder.rb +1072 -21
  57. data/lib/vizcore/dsl/layer_group_builder.rb +10 -12
  58. data/lib/vizcore/dsl/layout_helpers.rb +290 -0
  59. data/lib/vizcore/dsl/mapping_preset_builder.rb +41 -0
  60. data/lib/vizcore/dsl/mapping_resolver.rb +549 -13
  61. data/lib/vizcore/dsl/mapping_transform_builder.rb +50 -0
  62. data/lib/vizcore/dsl/midi_map_executor.rb +219 -23
  63. data/lib/vizcore/dsl/reaction_builder.rb +1 -0
  64. data/lib/vizcore/dsl/scene_builder.rb +83 -13
  65. data/lib/vizcore/dsl/shader_source_resolver.rb +1 -10
  66. data/lib/vizcore/dsl/style_builder.rb +3 -0
  67. data/lib/vizcore/dsl/timeline_builder.rb +91 -8
  68. data/lib/vizcore/dsl/transition_controller.rb +157 -18
  69. data/lib/vizcore/dsl.rb +2 -0
  70. data/lib/vizcore/layer_catalog.rb +5 -2
  71. data/lib/vizcore/plugin_asset_policy.rb +55 -0
  72. data/lib/vizcore/project_manifest.rb +12 -2
  73. data/lib/vizcore/renderer/render_sequence.rb +104 -13
  74. data/lib/vizcore/renderer/scene_frame_source.rb +190 -12
  75. data/lib/vizcore/renderer/scene_serializer.rb +38 -0
  76. data/lib/vizcore/renderer/snapshot.rb +4 -3
  77. data/lib/vizcore/renderer/snapshot_renderer.rb +641 -23
  78. data/lib/vizcore/scene_trust.rb +31 -0
  79. data/lib/vizcore/server/frame_broadcaster.rb +513 -18
  80. data/lib/vizcore/server/rack_app.rb +151 -4
  81. data/lib/vizcore/server/runner.rb +697 -82
  82. data/lib/vizcore/server/websocket_handler.rb +236 -14
  83. data/lib/vizcore/server.rb +21 -0
  84. data/lib/vizcore/shape.rb +742 -0
  85. data/lib/vizcore/sync/osc_message.rb +66 -9
  86. data/lib/vizcore/version.rb +1 -1
  87. data/lib/vizcore.rb +34 -0
  88. data/scripts/browser_capture.mjs +31 -2
  89. data/sig/vizcore.rbs +154 -4
  90. metadata +29 -3
@@ -1,6 +1,10 @@
1
1
  import { LayerManager } from "./layer-manager.js";
2
2
  import { ShaderManager } from "./shader-manager.js";
3
3
  import { applyShaderParamOverrides } from "../shader-param-controls.js";
4
+ import { applyShapeEditorOverrides } from "../shape-editor-controls.js";
5
+
6
+ export const RENDERER_CAPABILITIES_EVENT = "vizcore:renderer-capabilities";
7
+ export const RENDERER_SAFE_MODE_EVENT = "vizcore:renderer-safe-mode";
4
8
 
5
9
  export class Engine {
6
10
  constructor(canvas) {
@@ -13,25 +17,54 @@ export class Engine {
13
17
  this.currentRotationSpeed = 0.5;
14
18
  this.mediaElement = null;
15
19
  this.lastMediaTime = null;
20
+ this.resizeHandler = null;
21
+ this.rendererCapabilities = null;
22
+ this.safeModeState = {
23
+ active: false,
24
+ slowFrames: 0,
25
+ fastFrames: 0,
26
+ };
16
27
  this.visualSettings = {
17
28
  visualGain: 1,
18
29
  bassBoost: 1,
19
30
  smoothing: 0,
20
31
  beatHoldMs: 180,
21
32
  wobbleAmount: 1,
33
+ maxDevicePixelRatio: 2,
34
+ safeMode: true,
35
+ safeModeFrameMs: 34,
36
+ safeModeScale: 0.75,
22
37
  };
23
38
  this.visualAudioState = null;
24
39
  this.liveControls = {
25
- blackout: false,
26
- freeze: false,
40
+ blackout: { enabled: false, color: [0, 0, 0, 1] },
41
+ freeze: { enabled: false },
42
+ };
43
+ this.liveControlRuntime = {
44
+ blackout: {
45
+ opacity: 0,
46
+ targetOpacity: 0,
47
+ transitionFrom: 0,
48
+ transitionStartedAt: 0,
49
+ transitionDuration: 0,
50
+ color: [0, 0, 0, 1],
51
+ },
52
+ freeze: {
53
+ enabled: false,
54
+ targetEnabled: false,
55
+ transitionStartedAt: 0,
56
+ transitionDuration: 0,
57
+ },
27
58
  };
28
59
  this.runtimeGlobals = {};
29
60
  this.shaderParamOverrides = {};
61
+ this.shapeEditorOverrides = {};
30
62
  this.beatHoldUntil = 0;
31
63
  this.frame = {
32
64
  audio: {
33
65
  amplitude: 0,
34
66
  bands: { sub: 0, low: 0, mid: 0, high: 0 },
67
+ band_peaks: { sub: 0, low: 0, mid: 0, high: 0 },
35
68
  fft: [],
36
69
  onset: 0,
37
70
  onsets: { sub: 0, low: 0, mid: 0, high: 0 },
@@ -39,6 +72,14 @@ export class Engine {
39
72
  beat: false,
40
73
  beat_pulse: 0,
41
74
  beat_count: 0,
75
+ beat_phase: 0,
76
+ beat_2: false,
77
+ beat_4: false,
78
+ beat_8: false,
79
+ beat_triplet: false,
80
+ bar_phase: 0,
81
+ bar_count: 0,
82
+ phrase_count: 0,
42
83
  bpm: 0
43
84
  },
44
85
  scene: {
@@ -56,12 +97,18 @@ export class Engine {
56
97
 
57
98
  this.shaderManager = new ShaderManager(this.gl);
58
99
  this.layerManager = new LayerManager(this.gl, this.shaderManager);
100
+ this.rendererCapabilities = collectRendererCapabilities(this.gl, {
101
+ devicePixelRatio: currentDevicePixelRatio(),
102
+ effectiveDevicePixelRatio: this.effectiveDevicePixelRatio(),
103
+ });
104
+ dispatchRendererEvent(RENDERER_CAPABILITIES_EVENT, this.rendererCapabilities);
59
105
 
60
106
  this.gl.enable(this.gl.DEPTH_TEST);
61
107
  this.gl.enable(this.gl.BLEND);
62
108
  this.gl.blendFunc(this.gl.SRC_ALPHA, this.gl.ONE_MINUS_SRC_ALPHA);
63
109
  this.resize();
64
- window.addEventListener("resize", () => this.resize());
110
+ this.resizeHandler = () => this.resize();
111
+ window.addEventListener("resize", this.resizeHandler);
65
112
  }
66
113
 
67
114
  setAudioFrame(frame) {
@@ -81,13 +128,108 @@ export class Engine {
81
128
  ...this.visualSettings,
82
129
  ...settings,
83
130
  };
131
+ if (this.gl) {
132
+ this.resize();
133
+ }
84
134
  }
85
135
 
86
136
  setLiveControls(controls = {}) {
137
+ const now = performance.now();
87
138
  this.liveControls = {
88
- blackout: !!controls?.blackout,
89
- freeze: !!controls?.freeze,
139
+ blackout: normalizeLiveControlState(controls?.blackout),
140
+ freeze: normalizeLiveControlState(controls?.freeze),
90
141
  };
142
+ this.applyBlackoutRuntime(this.liveControls.blackout, now);
143
+ this.applyFreezeRuntime(this.liveControls.freeze, now);
144
+ }
145
+
146
+ applyBlackoutRuntime(control, now) {
147
+ const targetOpacity = control.enabled ? 1 : 0;
148
+ const color = normalizeLiveControlColor(control.color);
149
+ if (color) {
150
+ this.liveControlRuntime.blackout.color = color;
151
+ }
152
+
153
+ if (targetOpacity === this.liveControlRuntime.blackout.targetOpacity) {
154
+ this.liveControlRuntime.blackout.transitionDuration = 0;
155
+ this.liveControlRuntime.blackout.opacity = targetOpacity;
156
+ return;
157
+ }
158
+
159
+ const transitionDuration = control.enabled
160
+ ? finiteFloat(control.fade)
161
+ : finiteFloat(control.release);
162
+ if (!transitionDuration) {
163
+ this.liveControlRuntime.blackout.opacity = targetOpacity;
164
+ this.liveControlRuntime.blackout.targetOpacity = targetOpacity;
165
+ this.liveControlRuntime.blackout.transitionDuration = 0;
166
+ return;
167
+ }
168
+
169
+ this.liveControlRuntime.blackout.targetOpacity = targetOpacity;
170
+ this.liveControlRuntime.blackout.transitionFrom = this.liveControlRuntime.blackout.opacity;
171
+ this.liveControlRuntime.blackout.transitionStartedAt = now;
172
+ this.liveControlRuntime.blackout.transitionDuration = transitionDuration;
173
+ }
174
+
175
+ applyFreezeRuntime(control, now) {
176
+ const targetEnabled = !!control.enabled;
177
+ if (targetEnabled === this.liveControlRuntime.freeze.targetEnabled) {
178
+ return;
179
+ }
180
+
181
+ const transitionDuration = targetEnabled
182
+ ? finiteFloat(control.fade)
183
+ : finiteFloat(control.release);
184
+ if (!transitionDuration) {
185
+ this.liveControlRuntime.freeze.enabled = targetEnabled;
186
+ this.liveControlRuntime.freeze.targetEnabled = targetEnabled;
187
+ this.liveControlRuntime.freeze.transitionDuration = 0;
188
+ return;
189
+ }
190
+
191
+ this.liveControlRuntime.freeze.targetEnabled = targetEnabled;
192
+ this.liveControlRuntime.freeze.transitionStartedAt = now;
193
+ this.liveControlRuntime.freeze.transitionDuration = transitionDuration;
194
+ }
195
+
196
+ updateLiveControlRuntime(time) {
197
+ this.updateBlackoutRuntime(time);
198
+ this.updateFreezeRuntime(time);
199
+ }
200
+
201
+ updateBlackoutRuntime(time) {
202
+ const blackout = this.liveControlRuntime.blackout;
203
+ const duration = blackout.transitionDuration;
204
+ if (!duration || duration <= 0) {
205
+ return;
206
+ }
207
+
208
+ const elapsed = time - blackout.transitionStartedAt;
209
+ if (elapsed >= duration) {
210
+ blackout.opacity = blackout.targetOpacity;
211
+ blackout.transitionDuration = 0;
212
+ return;
213
+ }
214
+
215
+ const progress = clamp(elapsed / duration, 0, 1);
216
+ blackout.opacity = blackout.transitionFrom + (blackout.targetOpacity - blackout.transitionFrom) * progress;
217
+ }
218
+
219
+ updateFreezeRuntime(time) {
220
+ const freeze = this.liveControlRuntime.freeze;
221
+ const duration = freeze.transitionDuration;
222
+ if (!duration || duration <= 0) {
223
+ return;
224
+ }
225
+
226
+ const elapsed = time - freeze.transitionStartedAt;
227
+ if (elapsed < duration) {
228
+ return;
229
+ }
230
+
231
+ freeze.enabled = freeze.targetEnabled;
232
+ freeze.transitionDuration = 0;
91
233
  }
92
234
 
93
235
  setRuntimeGlobals(globals = {}) {
@@ -98,14 +240,19 @@ export class Engine {
98
240
  this.shaderParamOverrides = overrides && typeof overrides === "object" ? overrides : {};
99
241
  }
100
242
 
243
+ setShapeEditorOverrides(overrides = {}) {
244
+ this.shapeEditorOverrides = overrides && typeof overrides === "object" ? overrides : {};
245
+ }
246
+
101
247
  start() {
102
248
  this.lastTime = performance.now();
103
249
  requestAnimationFrame((time) => this.render(time));
104
250
  }
105
251
 
106
252
  resize() {
107
- const width = Math.floor(this.canvas.clientWidth * window.devicePixelRatio);
108
- const height = Math.floor(this.canvas.clientHeight * window.devicePixelRatio);
253
+ const dpr = this.effectiveDevicePixelRatio();
254
+ const width = Math.max(1, Math.floor(this.canvas.clientWidth * dpr));
255
+ const height = Math.max(1, Math.floor(this.canvas.clientHeight * dpr));
109
256
  if (this.canvas.width === width && this.canvas.height === height) {
110
257
  return;
111
258
  }
@@ -114,6 +261,15 @@ export class Engine {
114
261
  this.gl.viewport(0, 0, width, height);
115
262
  }
116
263
 
264
+ effectiveDevicePixelRatio() {
265
+ return resolveEffectiveDevicePixelRatio({
266
+ devicePixelRatio: currentDevicePixelRatio(),
267
+ maxDevicePixelRatio: this.visualSettings.maxDevicePixelRatio,
268
+ safeModeActive: this.safeModeState.active,
269
+ safeModeScale: this.visualSettings.safeModeScale,
270
+ });
271
+ }
272
+
117
273
  render(time) {
118
274
  let deltaSeconds = (time - this.lastTime) / 1000;
119
275
  this.lastTime = time;
@@ -136,14 +292,21 @@ export class Engine {
136
292
  this.lastMediaTime = null;
137
293
  }
138
294
 
139
- if (this.liveControls.blackout) {
140
- this.gl.clearColor(0, 0, 0, 1);
295
+ this.updateSafeMode(deltaSeconds * 1000);
296
+ this.updateLiveControlRuntime(time);
297
+
298
+ const blackout = this.liveControlRuntime.blackout;
299
+ const blackoutColor = blackout.color || [0, 0, 0, 1];
300
+ const [blackoutRed, blackoutGreen, blackoutBlue, blackoutAlpha = 1] = blackoutColor;
301
+ const effectiveBlackoutAlpha = clamp(blackout.opacity * blackoutAlpha, 0, 1);
302
+ if (effectiveBlackoutAlpha >= 1) {
303
+ this.gl.clearColor(blackoutRed, blackoutGreen, blackoutBlue, 1);
141
304
  this.gl.clear(this.gl.COLOR_BUFFER_BIT | this.gl.DEPTH_BUFFER_BIT);
142
305
  requestAnimationFrame((nextTime) => this.render(nextTime));
143
306
  return;
144
307
  }
145
308
 
146
- if (this.liveControls.freeze) {
309
+ if (this.liveControlRuntime.freeze.enabled) {
147
310
  requestAnimationFrame((nextTime) => this.render(nextTime));
148
311
  return;
149
312
  }
@@ -156,7 +319,10 @@ export class Engine {
156
319
  });
157
320
  this.visualAudioState = audio;
158
321
  const rawLayers = Array.isArray(this.frame?.scene?.layers) ? this.frame.scene.layers : [];
159
- const layers = applyShaderParamOverrides(rawLayers, this.shaderParamOverrides);
322
+ const layers = applyShapeEditorOverrides(
323
+ applyShaderParamOverrides(rawLayers, this.shaderParamOverrides),
324
+ this.shapeEditorOverrides
325
+ );
160
326
  const amplitude = clamp(Number(audio.amplitude || 0), 0, 1);
161
327
  const rotationSpeed = resolveRotationSpeed(layers, amplitude);
162
328
  this.currentRotationSpeed += (rotationSpeed - this.currentRotationSpeed) * 0.1;
@@ -170,6 +336,16 @@ export class Engine {
170
336
  );
171
337
  this.gl.clear(this.gl.COLOR_BUFFER_BIT | this.gl.DEPTH_BUFFER_BIT);
172
338
 
339
+ if (effectiveBlackoutAlpha > 0) {
340
+ this.gl.clearColor(
341
+ blackoutRed,
342
+ blackoutGreen,
343
+ blackoutBlue,
344
+ effectiveBlackoutAlpha
345
+ );
346
+ this.gl.clear(this.gl.COLOR_BUFFER_BIT | this.gl.DEPTH_BUFFER_BIT);
347
+ }
348
+
173
349
  this.layerManager.renderScene({
174
350
  layers,
175
351
  audio,
@@ -182,8 +358,115 @@ export class Engine {
182
358
 
183
359
  requestAnimationFrame((nextTime) => this.render(nextTime));
184
360
  }
361
+
362
+ updateSafeMode(frameMs) {
363
+ const nextState = nextSafeModeState({
364
+ state: this.safeModeState,
365
+ frameMs,
366
+ enabled: this.visualSettings.safeMode,
367
+ thresholdMs: this.visualSettings.safeModeFrameMs,
368
+ });
369
+ if (nextState.active === this.safeModeState.active) {
370
+ this.safeModeState = nextState;
371
+ return;
372
+ }
373
+
374
+ this.safeModeState = nextState;
375
+ this.resize();
376
+ dispatchRendererEvent(RENDERER_SAFE_MODE_EVENT, {
377
+ active: nextState.active,
378
+ effectiveDevicePixelRatio: this.effectiveDevicePixelRatio(),
379
+ });
380
+ }
381
+
382
+ dispose() {
383
+ if (this.resizeHandler && typeof window !== "undefined") {
384
+ window.removeEventListener("resize", this.resizeHandler);
385
+ this.resizeHandler = null;
386
+ }
387
+ this.layerManager?.dispose?.();
388
+ this.shaderManager?.dispose?.();
389
+ }
185
390
  }
186
391
 
392
+ const normalizeLiveControlState = (state) => {
393
+ if (state === null || state === undefined || typeof state === "boolean") {
394
+ return { enabled: !!state };
395
+ }
396
+
397
+ return {
398
+ enabled: state.value !== undefined ? !!state.value : !!state.enabled,
399
+ fade: finiteFloat(state.fade),
400
+ release: finiteFloat(state.release),
401
+ color: normalizeLiveControlColor(state.color),
402
+ };
403
+ };
404
+
405
+ const finiteFloat = (value) => {
406
+ const number = Number(value);
407
+ return Number.isFinite(number) && number >= 0 ? number : null;
408
+ };
409
+
410
+ const normalizeLiveControlColor = (value) => {
411
+ if (value == null) {
412
+ return null;
413
+ }
414
+
415
+ if (Array.isArray(value)) {
416
+ const channels = Array.from(value)
417
+ .slice(0, 4)
418
+ .map((channel) => Number(channel));
419
+ if (channels.length < 3 || channels.length > 4 || channels.some((channel) => !Number.isFinite(channel))) {
420
+ return null;
421
+ }
422
+
423
+ const rgbValues = channels.slice(0, 3);
424
+ const alpha = channels[3];
425
+ const rgb = rgbValues.every((channel) => channel >= 0 && channel <= 1)
426
+ ? rgbValues.map((channel) => clamp(channel, 0, 1))
427
+ : rgbValues.map((channel) => clamp(channel / 255, 0, 1));
428
+ const normalizedAlpha = alpha == null ? null : (alpha > 1 ? clamp(alpha / 255, 0, 1) : clamp(alpha, 0, 1));
429
+
430
+ return channels.length === 3 ? rgb : [...rgb, normalizedAlpha];
431
+ }
432
+
433
+ const color = parseHexColor(String(value || ""));
434
+ if (!Array.isArray(color)) {
435
+ return null;
436
+ }
437
+
438
+ if (color.length === 4) {
439
+ return color.map((channel) => clamp(channel, 0, 1));
440
+ }
441
+
442
+ if (color.length === 3) {
443
+ return color.map((channel) => clamp(channel, 0, 1));
444
+ }
445
+
446
+ return null;
447
+ };
448
+
449
+ const parseHexColor = (value) => {
450
+ const raw = String(value || "").trim();
451
+ const match = raw.match(/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/);
452
+ if (!match) {
453
+ return null;
454
+ }
455
+
456
+ const rawHex = match[1];
457
+ const hex = rawHex.length === 3 || rawHex.length === 4
458
+ ? rawHex.split("").map((entry) => `${entry}${entry}`).join("")
459
+ : rawHex;
460
+
461
+ const channels = [];
462
+ for (let index = 0; index < hex.length; index += 2) {
463
+ const value = Number.parseInt(hex.slice(index, index + 2), 16);
464
+ channels.push(value / 255);
465
+ }
466
+
467
+ return channels;
468
+ };
469
+
187
470
  const resolveRotationSpeed = (layers, amplitude) => {
188
471
  const layerWithSpeed = Array.isArray(layers)
189
472
  ? layers.find((layer) => Number.isFinite(Number(layer?.params?.rotation_speed)))
@@ -277,4 +560,111 @@ const isSilentAudio = (audio) => {
277
560
  && Number(drums.hihat || 0) <= 0;
278
561
  };
279
562
 
563
+ export const resolveEffectiveDevicePixelRatio = ({
564
+ devicePixelRatio,
565
+ maxDevicePixelRatio = 2,
566
+ safeModeActive = false,
567
+ safeModeScale = 0.75,
568
+ } = {}) => {
569
+ const rawDpr = Number(devicePixelRatio);
570
+ const maxDpr = clamp(Number(maxDevicePixelRatio || 2), 0.5, 4);
571
+ const base = clamp(Number.isFinite(rawDpr) ? rawDpr : 1, 0.5, maxDpr);
572
+ if (!safeModeActive) {
573
+ return roundDpr(base);
574
+ }
575
+
576
+ const scale = clamp(Number(safeModeScale || 0.75), 0.25, 1);
577
+ return roundDpr(clamp(base * scale, 0.5, maxDpr));
578
+ };
579
+
580
+ export const nextSafeModeState = ({
581
+ state,
582
+ frameMs,
583
+ enabled = true,
584
+ thresholdMs = 34,
585
+ } = {}) => {
586
+ const current = state || {};
587
+ if (!enabled) {
588
+ return { active: false, slowFrames: 0, fastFrames: 0 };
589
+ }
590
+
591
+ const value = Number(frameMs);
592
+ const threshold = clamp(Number(thresholdMs || 34), 16, 250);
593
+ if (!Number.isFinite(value) || value <= 0) {
594
+ return { ...current };
595
+ }
596
+
597
+ const slowFrames = value > threshold ? Number(current.slowFrames || 0) + 1 : 0;
598
+ const fastFrames = value < threshold * 0.75 ? Number(current.fastFrames || 0) + 1 : 0;
599
+ const active = current.active ? fastFrames < 120 : slowFrames >= 12;
600
+ return {
601
+ active,
602
+ slowFrames: active ? 0 : slowFrames,
603
+ fastFrames: active ? fastFrames : 0,
604
+ };
605
+ };
606
+
607
+ export const collectRendererCapabilities = (gl, {
608
+ devicePixelRatio = currentDevicePixelRatio(),
609
+ effectiveDevicePixelRatio = devicePixelRatio,
610
+ } = {}) => {
611
+ const maxViewportDims = safeGetParameter(gl, gl?.MAX_VIEWPORT_DIMS) || [];
612
+ return {
613
+ webgl2: true,
614
+ devicePixelRatio: roundDpr(devicePixelRatio),
615
+ effectiveDevicePixelRatio: roundDpr(effectiveDevicePixelRatio),
616
+ maxTextureSize: Number(safeGetParameter(gl, gl?.MAX_TEXTURE_SIZE) || 0),
617
+ maxDrawBuffers: Number(safeGetParameter(gl, gl?.MAX_DRAW_BUFFERS) || 0),
618
+ maxRenderbufferSize: Number(safeGetParameter(gl, gl?.MAX_RENDERBUFFER_SIZE) || 0),
619
+ maxViewportDims: Array.from(maxViewportDims).map((value) => Number(value || 0)),
620
+ floatColorBuffer: !!safeGetExtension(gl, "EXT_color_buffer_float"),
621
+ textureFloat: !!safeGetExtension(gl, "OES_texture_float"),
622
+ };
623
+ };
624
+
625
+ const currentDevicePixelRatio = () => {
626
+ if (typeof window === "undefined") {
627
+ return 1;
628
+ }
629
+ return Number(window.devicePixelRatio || 1);
630
+ };
631
+
632
+ const safeGetParameter = (gl, parameter) => {
633
+ if (!gl || parameter === undefined || typeof gl.getParameter !== "function") {
634
+ return null;
635
+ }
636
+
637
+ try {
638
+ return gl.getParameter(parameter);
639
+ } catch {
640
+ return null;
641
+ }
642
+ };
643
+
644
+ const safeGetExtension = (gl, name) => {
645
+ if (!gl || typeof gl.getExtension !== "function") {
646
+ return null;
647
+ }
648
+
649
+ try {
650
+ return gl.getExtension(name);
651
+ } catch {
652
+ return null;
653
+ }
654
+ };
655
+
656
+ const dispatchRendererEvent = (type, detail) => {
657
+ if (typeof window === "undefined" || typeof window.dispatchEvent !== "function") {
658
+ return;
659
+ }
660
+
661
+ if (typeof CustomEvent !== "function") {
662
+ return;
663
+ }
664
+
665
+ window.dispatchEvent(new CustomEvent(type, { detail }));
666
+ };
667
+
668
+ const roundDpr = (value) => Math.round(Number(value || 1) * 100) / 100;
669
+
280
670
  const clamp = (value, min, max) => Math.min(Math.max(value, min), max);