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
@@ -18,6 +18,7 @@ import {
18
18
  resolveShaderRenderer
19
19
  } from "../plugin-runtime.js";
20
20
  import { SpectrogramRenderer } from "../visuals/spectrogram-renderer.js";
21
+ import { ShapeRenderer } from "../visuals/shape-renderer.js";
21
22
  import { TextRenderer } from "../visuals/text-renderer.js";
22
23
  import { getVJEffectShader } from "../visuals/vj-effects.js";
23
24
  import { FULLSCREEN_VERTEX_SHADER } from "./shader-manager.js";
@@ -57,6 +58,8 @@ const FULLSCREEN_VERTICES = new Float32Array([
57
58
  1.0, 1.0
58
59
  ]);
59
60
  const MAX_LAYER_TARGET_PIXELS = 4_194_304;
61
+ const MIN_LAYER_RESOLUTION_SCALE = 0.1;
62
+ const SHADER_CACHE_VERSION = "v2";
60
63
 
61
64
  export const coerceUniformNumber = (value) => {
62
65
  if (typeof value === "boolean") {
@@ -129,6 +132,36 @@ export const normalizeBlendMode = (mode) => {
129
132
  return "alpha";
130
133
  };
131
134
 
135
+ export const resolveLayerResolutionScale = (params = {}, visualSettings = {}) => {
136
+ const rawValue = params?.resolution_scale
137
+ ?? params?.resolutionScale
138
+ ?? params?.target_resolution_scale
139
+ ?? params?.targetResolutionScale
140
+ ?? 1;
141
+ const layerScale = clamp(Number(rawValue), MIN_LAYER_RESOLUTION_SCALE, 1);
142
+ const safeScale = visualSettings?.safeModeActive
143
+ ? clamp(Number(visualSettings?.safeModeScale || 0.75), MIN_LAYER_RESOLUTION_SCALE, 1)
144
+ : 1;
145
+ const scale = layerScale * safeScale;
146
+ return Number.isFinite(scale) ? clamp(scale, MIN_LAYER_RESOLUTION_SCALE, 1) : 1;
147
+ };
148
+
149
+ export const shaderCacheKeyForLayer = (layer, shaderName, fragmentShader) => {
150
+ const customSource = typeof layer?.glsl_source === "string" ? layer.glsl_source : null;
151
+ const schemaSignature = stableHash(layer?.param_schema || layer?.params?.param_schema || []);
152
+ if (customSource) {
153
+ return [
154
+ "custom",
155
+ SHADER_CACHE_VERSION,
156
+ String(layer?.glsl || shaderName),
157
+ stableHash(fragmentShader),
158
+ schemaSignature,
159
+ ].join(":");
160
+ }
161
+
162
+ return ["builtin", SHADER_CACHE_VERSION, String(shaderName), schemaSignature].join(":");
163
+ };
164
+
132
165
  export const normalizePaletteColors = (value) => {
133
166
  const input = Array.isArray(value) ? value : [];
134
167
  return input
@@ -150,10 +183,119 @@ export const parseHexColor = (value) => {
150
183
  return [0, 2, 4].map((offset) => parseInt(hex.slice(offset, offset + 2), 16) / 255);
151
184
  };
152
185
 
186
+ const normalizePalettePosition = (value, paletteLength) => {
187
+ const numeric = Number(value);
188
+ if (!Number.isFinite(numeric) || !paletteLength) {
189
+ return 0;
190
+ }
191
+
192
+ const remainder = numeric % paletteLength;
193
+ return remainder >= 0 ? remainder : remainder + paletteLength;
194
+ };
195
+
196
+ const interpolatePaletteColor = (left, right, blend) => {
197
+ const ratio = clamp(blend, 0, 1);
198
+ const rgb = left.map((value, index) => {
199
+ const next = right[index];
200
+ return Math.round(clamp(value + (next - value) * ratio, 0, 1) * 255);
201
+ });
202
+ return `#${rgb.map((value) => value.toString(16).padStart(2, "0")).join("")}`;
203
+ };
204
+
205
+ const normalizeGradientStops = (stops, count) => {
206
+ if (!Array.isArray(stops)) {
207
+ return null;
208
+ }
209
+
210
+ const values = stops.map((value) => Number(value)).filter((value) => Number.isFinite(value));
211
+ if (values.length !== count) {
212
+ return null;
213
+ }
214
+
215
+ return values.map((value) => clamp(value, 0, 1)).sort((left, right) => left - right);
216
+ };
217
+
218
+ const resolveGradientColor = (value, paletteIndex = 0) => {
219
+ const gradient = value?.gradient || value;
220
+ if (!gradient || typeof gradient !== "object") {
221
+ return null;
222
+ }
223
+
224
+ const colors = normalizePaletteColors(gradient?.colors || gradient?.palette);
225
+ if (colors.length === 0) {
226
+ return null;
227
+ }
228
+
229
+ if (colors.length === 1) {
230
+ return colors[0];
231
+ }
232
+
233
+ const stops = normalizeGradientStops(gradient?.stops || gradient?.stopsPercent, colors.length);
234
+ const basePosition = Number.isFinite(Number(gradient?.position)) ? Number(gradient.position) : Number(paletteIndex);
235
+
236
+ let position;
237
+ if (stops) {
238
+ position = Number.isFinite(basePosition) ? clamp(basePosition, 0, 1) : 0;
239
+ } else {
240
+ const fractional = Number.isFinite(basePosition) ? basePosition - Math.floor(basePosition) : 0;
241
+ position = clamp(fractional, 0, 1);
242
+ }
243
+
244
+ if (position <= 0) {
245
+ return colors[0];
246
+ }
247
+ if (position >= 1) {
248
+ return colors[colors.length - 1];
249
+ }
250
+
251
+ if (!stops) {
252
+ const segmentLength = 1 / (colors.length - 1);
253
+ const segment = Math.min(Math.floor(position / segmentLength), colors.length - 2);
254
+ const blendBase = segment * segmentLength;
255
+ const blend = (position - blendBase) / segmentLength;
256
+ const left = parseHexColor(colors[segment]);
257
+ const right = parseHexColor(colors[segment + 1]);
258
+ if (!left || !right) {
259
+ return colors[segment];
260
+ }
261
+
262
+ return interpolatePaletteColor(left, right, blend);
263
+ }
264
+
265
+ const segment = Array.from({ length: colors.length - 1 }, (_, index) => index)
266
+ .find((index) => position <= stops[index + 1]);
267
+
268
+ if (segment === undefined) {
269
+ return colors[colors.length - 1];
270
+ }
271
+
272
+ if (segment <= 0 && position <= stops[0]) {
273
+ return colors[0];
274
+ }
275
+
276
+ const start = stops[segment];
277
+ const stop = stops[segment + 1];
278
+ const width = Math.max(stop - start, Number.EPSILON);
279
+ const blend = clamp((position - start) / width, 0, 1);
280
+ const left = parseHexColor(colors[segment]);
281
+ const right = parseHexColor(colors[segment + 1]);
282
+ if (!left || !right) {
283
+ return colors[segment];
284
+ }
285
+
286
+ return interpolatePaletteColor(left, right, blend);
287
+ };
288
+
153
289
  export const resolveLayerCssColor = (params = {}, fallback = "#e5f3ff", paletteIndex = 0) => {
154
- const explicitColor = String(params?.color || "").trim();
155
- if (explicitColor) {
156
- return explicitColor;
290
+ const explicitColor = params?.color;
291
+ const explicitHex = typeof explicitColor === "string" ? String(explicitColor || "").trim() : "";
292
+ if (explicitHex) {
293
+ return explicitHex;
294
+ }
295
+
296
+ const gradientColor = resolveGradientColor(explicitColor, paletteIndex);
297
+ if (gradientColor) {
298
+ return gradientColor;
157
299
  }
158
300
 
159
301
  const palette = normalizePaletteColors(params?.palette);
@@ -161,7 +303,24 @@ export const resolveLayerCssColor = (params = {}, fallback = "#e5f3ff", paletteI
161
303
  return fallback;
162
304
  }
163
305
 
164
- return palette[Math.abs(Number(paletteIndex) || 0) % palette.length];
306
+ if (palette.length === 1) {
307
+ return palette[0];
308
+ }
309
+
310
+ const position = normalizePalettePosition(paletteIndex, palette.length);
311
+ const lowerIndex = Math.floor(position);
312
+ const blend = position - lowerIndex;
313
+ if (blend <= 0 || !Number.isFinite(blend)) {
314
+ return palette[lowerIndex];
315
+ }
316
+
317
+ const leftColor = parseHexColor(palette[lowerIndex]);
318
+ const rightColor = parseHexColor(palette[(lowerIndex + 1) % palette.length]);
319
+ if (!leftColor || !rightColor) {
320
+ return palette[lowerIndex];
321
+ }
322
+
323
+ return interpolatePaletteColor(leftColor, rightColor, blend);
165
324
  };
166
325
 
167
326
  export const resolveLayerRgbColor = (params = {}, fallback = null, paletteIndex = 0) => {
@@ -196,15 +355,19 @@ export class LayerManager {
196
355
 
197
356
  this.layerFramebuffer = null;
198
357
  this.layerTexture = null;
358
+ this.layerTextureSecondary = null;
199
359
  this.layerDepthRenderbuffer = null;
200
360
  this.layerTargetWidth = 0;
201
361
  this.layerTargetHeight = 0;
202
362
  this.layerTargetAvailable = true;
203
363
  this.layerErrorKeys = new Set();
364
+ this.lastGoodShaderPrograms = new Map();
365
+ this.activeShaderLayerKeys = null;
204
366
 
205
367
  this.particleSystem = new ParticleSystem(this.gl, this.shaderManager);
206
368
  this.textRenderer = new TextRenderer(this.gl, this.shaderManager);
207
369
  this.imageRenderer = new ImageRenderer(this.gl, this.shaderManager);
370
+ this.shapeRenderer = new ShapeRenderer(this.gl, this.shaderManager);
208
371
  this.spectrogramRenderer = new SpectrogramRenderer(this.gl, this.shaderManager);
209
372
 
210
373
  this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.fullscreenBuffer);
@@ -215,30 +378,35 @@ export class LayerManager {
215
378
  const layerList = Array.isArray(layers) && layers.length > 0 ? layers : [defaultLayer(audio)];
216
379
  const width = Math.max(1, Math.floor(Number(resolution?.[0] || 1)));
217
380
  const height = Math.max(1, Math.floor(Number(resolution?.[1] || 1)));
218
- this.ensureLayerTarget(width, height);
381
+ const shaderLayerKeys = new Set();
382
+ this.activeShaderLayerKeys = shaderLayerKeys;
219
383
 
220
- if (!this.layerTargetAvailable || !this.layerFramebuffer || !this.layerTexture) {
221
- layerList.forEach((layer, index) => {
222
- try {
384
+ layerList.forEach((layer, index) => {
385
+ try {
386
+ this.ensureLayerTarget(width, height, layer, visualSettings);
387
+ if (!this.layerTargetAvailable || !this.layerFramebuffer || !this.layerTexture) {
223
388
  const blend = String(layer?.params?.blend || "alpha").toLowerCase();
224
389
  this.setBlendMode(blend);
225
390
  this.renderLayer(layer, audio, time, rotation, [width, height], globals, visualSettings, index);
226
- } catch (error) {
227
- this.reportLayerError(layer, error, "direct-render");
391
+ return;
228
392
  }
229
- });
230
- this.setBlendMode("alpha");
231
- return;
232
- }
233
393
 
234
- layerList.forEach((layer, index) => {
235
- try {
236
394
  this.gl.bindFramebuffer(this.gl.FRAMEBUFFER, this.layerFramebuffer);
237
395
  this.gl.viewport(0, 0, this.layerTargetWidth, this.layerTargetHeight);
238
396
  this.gl.clearColor(0.0, 0.0, 0.0, 0.0);
239
397
  this.gl.clear(this.gl.COLOR_BUFFER_BIT | this.gl.DEPTH_BUFFER_BIT);
240
398
 
241
- this.renderLayer(layer, audio, time, rotation, [this.layerTargetWidth, this.layerTargetHeight], globals, visualSettings, index);
399
+ this.renderLayer(
400
+ layer,
401
+ audio,
402
+ time,
403
+ rotation,
404
+ [this.layerTargetWidth, this.layerTargetHeight],
405
+ globals,
406
+ visualSettings,
407
+ index,
408
+ index
409
+ );
242
410
 
243
411
  this.gl.bindFramebuffer(this.gl.FRAMEBUFFER, null);
244
412
  this.gl.viewport(0, 0, width, height);
@@ -249,10 +417,22 @@ export class LayerManager {
249
417
  this.reportLayerError(layer, error, "layer-pass");
250
418
  }
251
419
  });
420
+ this.pruneLastGoodShaderPrograms(shaderLayerKeys);
421
+ this.activeShaderLayerKeys = null;
252
422
  this.setBlendMode("alpha");
253
423
  }
254
424
 
255
- renderLayer(layer, audio, time, rotation, resolution, globals, visualSettings, paletteIndex = 0) {
425
+ renderLayer(
426
+ layer,
427
+ audio,
428
+ time,
429
+ rotation,
430
+ resolution,
431
+ globals,
432
+ visualSettings,
433
+ paletteIndex = 0,
434
+ layerIndex = null
435
+ ) {
256
436
  if (isParticleLayer(layer)) {
257
437
  this.renderParticleLayer(layer, audio, time, paletteIndex);
258
438
  return;
@@ -274,20 +454,40 @@ export class LayerManager {
274
454
  return;
275
455
  }
276
456
  if (isShapeLayer(layer)) {
277
- this.renderShapeLayer(layer, audio, paletteIndex);
457
+ this.renderShapeLayer(layer, audio, time, resolution, paletteIndex);
278
458
  return;
279
459
  }
280
460
  if (isShaderLayer(layer)) {
281
- this.renderShaderLayer(layer, audio, time, resolution, globals, visualSettings);
461
+ this.renderShaderLayer(layer, audio, time, resolution, globals, visualSettings, paletteIndex, layerIndex);
282
462
  return;
283
463
  }
284
- if (this.renderPluginLayer(layer, audio, time, rotation, resolution, globals, visualSettings, paletteIndex)) {
464
+ if (this.renderPluginLayer(
465
+ layer,
466
+ audio,
467
+ time,
468
+ rotation,
469
+ resolution,
470
+ globals,
471
+ visualSettings,
472
+ paletteIndex,
473
+ layerIndex
474
+ )) {
285
475
  return;
286
476
  }
287
477
  this.renderGeometryLayer(layer, audio, rotation, time, paletteIndex);
288
478
  }
289
479
 
290
- renderPluginLayer(layer, audio, time, rotation, resolution, globals, visualSettings, paletteIndex = 0) {
480
+ renderPluginLayer(
481
+ layer,
482
+ audio,
483
+ time,
484
+ rotation,
485
+ resolution,
486
+ globals,
487
+ visualSettings,
488
+ paletteIndex = 0,
489
+ layerIndex = null
490
+ ) {
291
491
  const context = {
292
492
  layer,
293
493
  audio,
@@ -300,19 +500,29 @@ export class LayerManager {
300
500
  };
301
501
 
302
502
  const renderer = resolveLayerRenderer(layer?.type);
303
- if (renderer && this.renderPluginOutput(layer, renderer(context), audio, time, resolution, globals, visualSettings, paletteIndex)) {
503
+ if (renderer && this.renderPluginOutput(layer, renderer(context), audio, time, resolution, globals, visualSettings, paletteIndex, layerIndex)) {
304
504
  return true;
305
505
  }
306
506
 
307
507
  const shaderRenderer = resolveShaderRenderer(layer?.type);
308
- if (shaderRenderer && this.renderPluginOutput(layer, shaderRenderer(context), audio, time, resolution, globals, visualSettings, paletteIndex)) {
508
+ if (shaderRenderer && this.renderPluginOutput(layer, shaderRenderer(context), audio, time, resolution, globals, visualSettings, paletteIndex, layerIndex)) {
309
509
  return true;
310
510
  }
311
511
 
312
512
  return false;
313
513
  }
314
514
 
315
- renderPluginOutput(layer, output, audio, time, resolution, globals, visualSettings, paletteIndex = 0) {
515
+ renderPluginOutput(
516
+ layer,
517
+ output,
518
+ audio,
519
+ time,
520
+ resolution,
521
+ globals,
522
+ visualSettings,
523
+ paletteIndex = 0,
524
+ layerIndex = null
525
+ ) {
316
526
  const lines = normalizePluginLineOutput(output);
317
527
  if (lines) {
318
528
  const fallbackColor = resolveLayerRgbColor(layer?.params || {}, [0.82, 0.92, 1.0], paletteIndex);
@@ -330,42 +540,74 @@ export class LayerManager {
330
540
  shader: layer?.shader || "default",
331
541
  glsl: `plugin:${String(layer?.type || "layer")}:${shader.cacheKey}`,
332
542
  glsl_source: shader.fragmentShader
333
- }, audio, time, resolution, globals, visualSettings);
543
+ }, audio, time, resolution, globals, visualSettings, paletteIndex, layerIndex);
334
544
  return true;
335
545
  }
336
546
 
337
- renderShaderLayer(layer, audio, time, resolution, globals, visualSettings) {
547
+ renderShaderLayer(
548
+ layer,
549
+ audio,
550
+ time,
551
+ resolution,
552
+ globals,
553
+ visualSettings,
554
+ paletteIndex = 0,
555
+ layerIndex = null
556
+ ) {
338
557
  const shaderName = String(layer?.shader || "gradient_pulse");
339
558
  const customSource = typeof layer?.glsl_source === "string" ? layer.glsl_source : null;
340
559
  const fragmentShader = customSource || getBuiltinShader(shaderName);
341
- const cacheKey = customSource
342
- ? `custom:${String(layer?.glsl || shaderName)}:${hashString(customSource)}`
343
- : `builtin:${shaderName}`;
560
+ const cacheKey = shaderCacheKeyForLayer(layer, shaderName, fragmentShader);
561
+ const layerCacheKey = this.shaderLayerCacheKey(layer, layerIndex, customSource);
562
+ if (layerCacheKey) {
563
+ this.activeShaderLayerKeys?.add(layerCacheKey);
564
+ }
565
+
344
566
  let program = null;
345
567
  try {
346
568
  program = this.shaderManager.getProgram(cacheKey, FULLSCREEN_VERTEX_SHADER, fragmentShader);
569
+ if (layerCacheKey) {
570
+ this.lastGoodShaderPrograms.set(layerCacheKey, {
571
+ program,
572
+ cacheKey,
573
+ createdAt: Date.now()
574
+ });
575
+ }
347
576
  } catch (error) {
348
577
  if (customSource) {
349
578
  this.reportShaderError(layer, error, "custom-shader");
350
579
  console.warn("Failed to compile custom GLSL, falling back to builtin shader", error);
580
+ const cached = layerCacheKey ? this.lastGoodShaderPrograms.get(layerCacheKey) : null;
581
+ if (cached && cached.program) {
582
+ program = cached.program;
583
+ }
584
+
351
585
  try {
352
- program = this.shaderManager.getProgram(
353
- `builtin:${shaderName}`,
354
- FULLSCREEN_VERTEX_SHADER,
355
- getBuiltinShader(shaderName)
356
- );
586
+ if (!program) {
587
+ program = this.shaderManager.getProgram(
588
+ shaderCacheKeyForLayer(
589
+ { ...layer, glsl_source: null },
590
+ shaderName,
591
+ getBuiltinShader(shaderName)
592
+ ),
593
+ FULLSCREEN_VERTEX_SHADER,
594
+ getBuiltinShader(shaderName)
595
+ );
596
+ }
357
597
  } catch (builtinError) {
358
598
  this.reportShaderError(layer, builtinError, "builtin-shader-fallback");
359
599
  this.reportLayerError(layer, builtinError, "builtin-shader-fallback");
600
+ program = null;
360
601
  }
361
602
  } else {
362
603
  this.reportShaderError(layer, error, "builtin-shader");
363
604
  this.reportLayerError(layer, error, "builtin-shader");
605
+ program = null;
364
606
  }
365
607
 
366
608
  if (!program) {
367
609
  program = this.shaderManager.getProgram(
368
- "builtin:default",
610
+ shaderCacheKeyForLayer({ shader: "default" }, "default", getBuiltinShader("default")),
369
611
  FULLSCREEN_VERTEX_SHADER,
370
612
  getBuiltinShader("default")
371
613
  );
@@ -381,6 +623,7 @@ export class LayerManager {
381
623
  gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0);
382
624
 
383
625
  const bands = audio?.bands || {};
626
+ const bandPeaks = audio?.band_peaks || {};
384
627
  const onsets = audio?.onsets || {};
385
628
  const drums = audio?.drums || {};
386
629
  this.setUniform1f(program, "u_time", time);
@@ -389,8 +632,19 @@ export class LayerManager {
389
632
  this.setUniform1f(program, "u_bass", bands.low || 0);
390
633
  this.setUniform1f(program, "u_mid", bands.mid || 0);
391
634
  this.setUniform1f(program, "u_high", bands.high || 0);
635
+ this.setUniform1f(program, "u_bass_peak", bandPeaks.low || 0);
636
+ this.setUniform1f(program, "u_mid_peak", bandPeaks.mid || 0);
637
+ this.setUniform1f(program, "u_high_peak", bandPeaks.high || 0);
392
638
  this.setUniform1f(program, "u_beat", audio?.beat ? 1 : 0);
393
639
  this.setUniform1f(program, "u_beat_pulse", audio?.beat_pulse || (audio?.beat ? 1 : 0));
640
+ this.setUniform1f(program, "u_beat_phase", audio?.beat_phase || 0);
641
+ this.setUniform1f(program, "u_bar_phase", audio?.bar_phase || 0);
642
+ this.setUniform1f(program, "u_bar_count", audio?.bar_count || 0);
643
+ this.setUniform1f(program, "u_phrase_count", audio?.phrase_count || 0);
644
+ this.setUniform1f(program, "u_beat_2", audio?.beat_2 ? 1 : 0);
645
+ this.setUniform1f(program, "u_beat_4", audio?.beat_4 ? 1 : 0);
646
+ this.setUniform1f(program, "u_beat_8", audio?.beat_8 ? 1 : 0);
647
+ this.setUniform1f(program, "u_beat_triplet", audio?.beat_triplet ? 1 : 0);
394
648
  this.setUniform1f(program, "u_onset", audio?.onset || 0);
395
649
  this.setUniform1f(program, "u_sub_onset", onsets.sub || 0);
396
650
  this.setUniform1f(program, "u_low_onset", onsets.low || 0);
@@ -556,16 +810,28 @@ export class LayerManager {
556
810
  });
557
811
  }
558
812
 
559
- renderShapeLayer(layer, audio, paletteIndex = 0) {
813
+ renderShapeLayer(layer, audio, time, resolution, paletteIndex = 0) {
560
814
  const params = layer?.params || {};
815
+ const amplitude = clamp(Number(audio?.amplitude || 0), 0, 1);
816
+ const fallbackColor = [0.85, 0.50 + amplitude * 0.24, 0.95];
817
+ const cssColor = resolveLayerCssColor(params, "#d98cff", paletteIndex);
818
+ const rendered = this.shapeRenderer.render({
819
+ params,
820
+ color: cssColor,
821
+ resolution,
822
+ audio,
823
+ time
824
+ });
825
+ if (rendered) {
826
+ return;
827
+ }
828
+
561
829
  const points = buildShapeLines({ params });
562
830
 
563
831
  if (points.length === 0) {
564
832
  return;
565
833
  }
566
834
 
567
- const amplitude = clamp(Number(audio?.amplitude || 0), 0, 1);
568
- const fallbackColor = [0.85, 0.50 + amplitude * 0.24, 0.95];
569
835
  const color = resolveLayerRgbColor(params, fallbackColor, paletteIndex);
570
836
  this.renderLinePoints(points, color);
571
837
  }
@@ -584,30 +850,96 @@ export class LayerManager {
584
850
  compositeLayer(layer, { audio, time, resolution }) {
585
851
  const gl = this.gl;
586
852
  const params = layer?.params || {};
587
- const opacity = clamp(Number(params.opacity || 1), 0, 1);
853
+ const effects = this.resolvePostEffects(params);
854
+ if (effects.length === 0) {
855
+ this.drawLayerTexture(this.layerTexture, { layer, audio, time, resolution, opacity: params.opacity });
856
+ return;
857
+ }
858
+
588
859
  const blend = String(params.blend || "alpha").toLowerCase();
589
- const effectName = String(params.effect || "");
590
- const vjEffectName = String(params.vj_effect || "");
591
860
  const effectIntensity = clamp(Number(params.effect_intensity || audio?.amplitude || 0.35), 0, 1);
592
- const effectShader = getPostEffectShader(effectName);
593
- const vjShader = getVJEffectShader(vjEffectName);
594
- const selectedShader = vjShader || effectShader;
595
- const selectedEffectName = vjShader ? `vj:${vjEffectName}` : `post:${effectName}`;
596
- let program = this.compositeProgram;
597
- if (selectedShader) {
598
- try {
599
- program = this.shaderManager.getProgram(
600
- selectedEffectName,
601
- FULLSCREEN_VERTEX_SHADER,
602
- selectedShader
603
- );
604
- } catch (error) {
605
- this.reportLayerError(layer, error, selectedEffectName);
606
- program = this.compositeProgram;
861
+ let sourceTexture = this.layerTexture;
862
+ let targetTexture = this.layerTextureSecondary;
863
+
864
+ this.setBlendMode(blend);
865
+ effects.forEach((effectName) => {
866
+ const resolvedName = String(effectName);
867
+ const vjShader = getVJEffectShader(resolvedName);
868
+ const effectShader = getPostEffectShader(resolvedName);
869
+ const selectedShader = vjShader || effectShader;
870
+ const selectedEffectName = vjShader ? `vj:${resolvedName}` : `post:${resolvedName}`;
871
+ let program = this.compositeProgram;
872
+
873
+ if (selectedShader) {
874
+ try {
875
+ program = this.shaderManager.getProgram(
876
+ selectedEffectName,
877
+ FULLSCREEN_VERTEX_SHADER,
878
+ selectedShader
879
+ );
880
+ } catch (error) {
881
+ this.reportLayerError(layer, error, selectedEffectName);
882
+ program = this.compositeProgram;
883
+ }
607
884
  }
885
+
886
+ this.applyEffectPass({
887
+ sourceTexture,
888
+ destinationTexture: targetTexture,
889
+ program,
890
+ time,
891
+ resolution: [this.layerTargetWidth, this.layerTargetHeight],
892
+ effectIntensity
893
+ });
894
+
895
+ [sourceTexture, targetTexture] = [targetTexture, sourceTexture];
896
+ });
897
+
898
+ gl.bindFramebuffer(gl.FRAMEBUFFER, null);
899
+ gl.viewport(0, 0, resolution[0], resolution[1]);
900
+ this.drawLayerTexture(sourceTexture, {
901
+ layer,
902
+ audio,
903
+ time,
904
+ resolution,
905
+ opacity: params.opacity
906
+ });
907
+ }
908
+
909
+ resolvePostEffects(params) {
910
+ const postEffects = Array.isArray(params?.post_effects)
911
+ ? params.post_effects
912
+ .map((value) => String(value || "").trim().toLowerCase())
913
+ .filter((value) => value.length > 0)
914
+ : [];
915
+ if (postEffects.length > 0) {
916
+ return postEffects;
608
917
  }
609
918
 
610
- this.setBlendMode(blend);
919
+ const vjEffectName = String(params?.vj_effect || "").trim().toLowerCase();
920
+ if (vjEffectName) {
921
+ return [vjEffectName];
922
+ }
923
+
924
+ const effectName = String(params?.effect || "").trim().toLowerCase();
925
+ if (effectName) {
926
+ return [effectName];
927
+ }
928
+
929
+ return [];
930
+ }
931
+
932
+ applyEffectPass({ sourceTexture, destinationTexture, program, time, resolution, effectIntensity }) {
933
+ const gl = this.gl;
934
+ if (!sourceTexture || !destinationTexture) {
935
+ return;
936
+ }
937
+
938
+ gl.bindFramebuffer(gl.FRAMEBUFFER, this.layerFramebuffer);
939
+ gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, destinationTexture, 0);
940
+ gl.viewport(0, 0, this.layerTargetWidth, this.layerTargetHeight);
941
+ gl.clearColor(0.0, 0.0, 0.0, 0.0);
942
+ gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
611
943
 
612
944
  gl.useProgram(program);
613
945
  gl.bindBuffer(gl.ARRAY_BUFFER, this.fullscreenBuffer);
@@ -616,22 +948,41 @@ export class LayerManager {
616
948
  gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0);
617
949
 
618
950
  gl.activeTexture(gl.TEXTURE0);
619
- gl.bindTexture(gl.TEXTURE_2D, this.layerTexture);
951
+ gl.bindTexture(gl.TEXTURE_2D, sourceTexture);
620
952
  this.setUniform1i(program, "u_texture", 0);
621
- this.setUniform1f(program, "u_opacity", opacity);
622
953
  this.setUniform1f(program, "u_time", time);
623
954
  this.setUniform1f(program, "u_intensity", effectIntensity);
624
955
  this.setUniform2f(program, "u_resolution", resolution[0], resolution[1]);
956
+ this.setUniform1f(program, "u_opacity", 1);
625
957
 
626
958
  gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
627
959
  }
628
960
 
629
- ensureLayerTarget(width, height) {
961
+ drawLayerTexture(texture, { layer, audio, time, resolution, opacity }) {
962
+ const gl = this.gl;
963
+ const params = layer?.params || {};
964
+ gl.useProgram(this.compositeProgram);
965
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.fullscreenBuffer);
966
+ gl.enableVertexAttribArray(this.compositePositionLocation);
967
+ gl.vertexAttribPointer(this.compositePositionLocation, 2, gl.FLOAT, false, 0, 0);
968
+
969
+ gl.activeTexture(gl.TEXTURE0);
970
+ gl.bindTexture(gl.TEXTURE_2D, texture);
971
+ this.setUniform1i(this.compositeProgram, "u_texture", 0);
972
+ this.setUniform1f(this.compositeProgram, "u_opacity", clamp(Number(opacity || 1), 0, 1));
973
+ this.setUniform1f(this.compositeProgram, "u_time", time);
974
+ this.setUniform1f(this.compositeProgram, "u_intensity", params.effect_intensity || audio?.amplitude || 0.35);
975
+ this.setUniform2f(this.compositeProgram, "u_resolution", resolution[0], resolution[1]);
976
+
977
+ gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
978
+ }
979
+
980
+ ensureLayerTarget(width, height, layer = null, visualSettings = null) {
630
981
  if (!this.layerTargetAvailable) {
631
982
  return;
632
983
  }
633
984
 
634
- const [targetWidth, targetHeight] = this.resolveLayerTargetSize(width, height);
985
+ const [targetWidth, targetHeight] = this.resolveLayerTargetSize(width, height, layer, visualSettings);
635
986
  if (this.layerFramebuffer && this.layerTargetWidth === targetWidth && this.layerTargetHeight === targetHeight) {
636
987
  return;
637
988
  }
@@ -642,16 +993,10 @@ export class LayerManager {
642
993
 
643
994
  const gl = this.gl;
644
995
  this.layerFramebuffer = gl.createFramebuffer();
645
- this.layerTexture = gl.createTexture();
996
+ this.layerTexture = this.createLayerTexture(targetWidth, targetHeight);
997
+ this.layerTextureSecondary = this.createLayerTexture(targetWidth, targetHeight);
646
998
  this.layerDepthRenderbuffer = gl.createRenderbuffer();
647
999
 
648
- gl.bindTexture(gl.TEXTURE_2D, this.layerTexture);
649
- gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, targetWidth, targetHeight, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
650
- gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
651
- gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
652
- gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
653
- gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
654
-
655
1000
  gl.bindFramebuffer(gl.FRAMEBUFFER, this.layerFramebuffer);
656
1001
  gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, this.layerTexture, 0);
657
1002
 
@@ -673,11 +1018,27 @@ export class LayerManager {
673
1018
  gl.bindFramebuffer(gl.FRAMEBUFFER, null);
674
1019
  }
675
1020
 
1021
+ createLayerTexture(width, height) {
1022
+ const gl = this.gl;
1023
+ const texture = gl.createTexture();
1024
+ gl.bindTexture(gl.TEXTURE_2D, texture);
1025
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, width, height, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
1026
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
1027
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
1028
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
1029
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
1030
+ return texture;
1031
+ }
1032
+
676
1033
  disposeLayerTarget() {
677
1034
  if (this.layerTexture) {
678
1035
  this.gl.deleteTexture(this.layerTexture);
679
1036
  this.layerTexture = null;
680
1037
  }
1038
+ if (this.layerTextureSecondary) {
1039
+ this.gl.deleteTexture(this.layerTextureSecondary);
1040
+ this.layerTextureSecondary = null;
1041
+ }
681
1042
  if (this.layerDepthRenderbuffer) {
682
1043
  this.gl.deleteRenderbuffer(this.layerDepthRenderbuffer);
683
1044
  this.layerDepthRenderbuffer = null;
@@ -688,11 +1049,12 @@ export class LayerManager {
688
1049
  }
689
1050
  }
690
1051
 
691
- resolveLayerTargetSize(width, height) {
1052
+ resolveLayerTargetSize(width, height, layer = null, visualSettings = null) {
692
1053
  const gl = this.gl;
693
1054
  const maxTextureSize = Number(gl.getParameter(gl.MAX_TEXTURE_SIZE) || 4096);
694
- let targetWidth = clamp(Math.floor(width), 1, maxTextureSize);
695
- let targetHeight = clamp(Math.floor(height), 1, maxTextureSize);
1055
+ const scale = resolveLayerResolutionScale(layer?.params || {}, visualSettings || {});
1056
+ let targetWidth = clamp(Math.floor(width * scale), 1, maxTextureSize);
1057
+ let targetHeight = clamp(Math.floor(height * scale), 1, maxTextureSize);
696
1058
 
697
1059
  const pixels = targetWidth * targetHeight;
698
1060
  if (pixels > MAX_LAYER_TARGET_PIXELS) {
@@ -704,6 +1066,23 @@ export class LayerManager {
704
1066
  return [targetWidth, targetHeight];
705
1067
  }
706
1068
 
1069
+ dispose() {
1070
+ this.disposeLayerTarget();
1071
+ this.particleSystem?.dispose?.();
1072
+ this.textRenderer?.dispose?.();
1073
+ this.imageRenderer?.dispose?.();
1074
+ this.shapeRenderer?.dispose?.();
1075
+ this.spectrogramRenderer?.dispose?.();
1076
+ if (this.fullscreenBuffer) {
1077
+ this.gl.deleteBuffer(this.fullscreenBuffer);
1078
+ this.fullscreenBuffer = null;
1079
+ }
1080
+ if (this.geometryBuffer) {
1081
+ this.gl.deleteBuffer(this.geometryBuffer);
1082
+ this.geometryBuffer = null;
1083
+ }
1084
+ }
1085
+
707
1086
  setBlendMode(mode) {
708
1087
  const blendMode = normalizeBlendMode(mode);
709
1088
  this.gl.blendEquation(this.gl.FUNC_ADD);
@@ -780,6 +1159,27 @@ export class LayerManager {
780
1159
  window.dispatchEvent(new CustomEvent(SHADER_ERROR_EVENT, { detail }));
781
1160
  }
782
1161
  }
1162
+
1163
+ pruneLastGoodShaderPrograms(activeKeys) {
1164
+ const active = activeKeys instanceof Set ? activeKeys : null;
1165
+ if (!active) {
1166
+ return;
1167
+ }
1168
+ for (const key of this.lastGoodShaderPrograms.keys()) {
1169
+ if (!active.has(key)) {
1170
+ this.lastGoodShaderPrograms.delete(key);
1171
+ }
1172
+ }
1173
+ }
1174
+
1175
+ shaderLayerCacheKey(layer, layerIndex, hasCustomSource) {
1176
+ const name = String(layer?.name || "unnamed");
1177
+ const type = String(layer?.type || "layer");
1178
+ const sourceKind = hasCustomSource ? "custom" : "builtin";
1179
+ const sourceId = String(layer?.glsl || layer?.shader || layer?.type || "default");
1180
+ const index = Number.isFinite(layerIndex) ? layerIndex : "static";
1181
+ return `shader-layer|${sourceKind}|${index}|${type}|${name}|${sourceId}`;
1182
+ }
783
1183
  }
784
1184
 
785
1185
  const isShaderLayer = (layer) => {
@@ -845,10 +1245,25 @@ const defaultLayer = (audio) => ({
845
1245
 
846
1246
  const clamp = (value, min, max) => Math.min(Math.max(value, min), max);
847
1247
 
1248
+ const stableHash = (value) => hashString(stableStringify(value));
1249
+
1250
+ const stableStringify = (value) => {
1251
+ if (value === null || typeof value !== "object") {
1252
+ return String(value);
1253
+ }
1254
+
1255
+ if (Array.isArray(value)) {
1256
+ return `[${value.map((entry) => stableStringify(entry)).join(",")}]`;
1257
+ }
1258
+
1259
+ return `{${Object.keys(value).sort().map((key) => `${key}:${stableStringify(value[key])}`).join(",")}}`;
1260
+ };
1261
+
848
1262
  const hashString = (value) => {
1263
+ const text = String(value || "");
849
1264
  let hash = 0;
850
- for (let index = 0; index < value.length; index += 1) {
851
- hash = (hash * 31 + value.charCodeAt(index)) >>> 0;
1265
+ for (let index = 0; index < text.length; index += 1) {
1266
+ hash = (hash * 31 + text.charCodeAt(index)) >>> 0;
852
1267
  }
853
1268
  return hash.toString(16);
854
1269
  };