vizcore 1.1.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 (78) hide show
  1. checksums.yaml +4 -4
  2. data/frontend/index.html +24 -2
  3. data/frontend/src/audio-inspector.js +9 -0
  4. data/frontend/src/live-controls.js +219 -7
  5. data/frontend/src/main.js +447 -57
  6. data/frontend/src/midi-learn.js +22 -2
  7. data/frontend/src/performance-monitor.js +137 -1
  8. data/frontend/src/renderer/engine.js +391 -10
  9. data/frontend/src/renderer/layer-manager.js +472 -71
  10. data/frontend/src/runtime-control-preset.js +44 -0
  11. data/frontend/src/scene-patches.js +159 -0
  12. data/frontend/src/shader-error-overlay.js +1 -0
  13. data/frontend/src/visuals/image-renderer.js +19 -0
  14. data/frontend/src/visuals/particle-system.js +10 -0
  15. data/frontend/src/visuals/shape-renderer.js +13 -0
  16. data/frontend/src/visuals/spectrogram-renderer.js +14 -0
  17. data/frontend/src/visuals/text-renderer.js +13 -0
  18. data/frontend/src/websocket-client.js +6 -0
  19. data/lib/vizcore/analysis/adaptive_normalizer.rb +20 -2
  20. data/lib/vizcore/analysis/bpm_estimator.rb +18 -8
  21. data/lib/vizcore/analysis/feature_recorder.rb +117 -7
  22. data/lib/vizcore/analysis/feature_replay.rb +48 -9
  23. data/lib/vizcore/analysis/pipeline.rb +258 -9
  24. data/lib/vizcore/analysis/tap_tempo.rb +17 -2
  25. data/lib/vizcore/audio/calibration.rb +156 -0
  26. data/lib/vizcore/audio/file_input.rb +28 -0
  27. data/lib/vizcore/audio/input_manager.rb +36 -1
  28. data/lib/vizcore/audio/midi_input.rb +5 -0
  29. data/lib/vizcore/audio/ring_buffer.rb +22 -0
  30. data/lib/vizcore/audio.rb +1 -0
  31. data/lib/vizcore/cli/dsl_reference.rb +64 -8
  32. data/lib/vizcore/cli/plugin_checker.rb +93 -0
  33. data/lib/vizcore/cli/scene_diagnostics.rb +2 -2
  34. data/lib/vizcore/cli/scene_inspector.rb +35 -1
  35. data/lib/vizcore/cli/scene_validator.rb +487 -39
  36. data/lib/vizcore/cli/shader_template.rb +7 -2
  37. data/lib/vizcore/cli/shader_uniform_docs.rb +11 -0
  38. data/lib/vizcore/cli.rb +268 -15
  39. data/lib/vizcore/config.rb +40 -3
  40. data/lib/vizcore/control_preset.rb +29 -0
  41. data/lib/vizcore/deep_copy.rb +21 -0
  42. data/lib/vizcore/dsl/color_helpers.rb +155 -0
  43. data/lib/vizcore/dsl/engine.rb +219 -23
  44. data/lib/vizcore/dsl/layer_builder.rb +278 -15
  45. data/lib/vizcore/dsl/layer_group_builder.rb +10 -12
  46. data/lib/vizcore/dsl/layout_helpers.rb +290 -0
  47. data/lib/vizcore/dsl/mapping_preset_builder.rb +41 -0
  48. data/lib/vizcore/dsl/mapping_resolver.rb +404 -22
  49. data/lib/vizcore/dsl/mapping_transform_builder.rb +50 -0
  50. data/lib/vizcore/dsl/midi_map_executor.rb +219 -23
  51. data/lib/vizcore/dsl/reaction_builder.rb +1 -0
  52. data/lib/vizcore/dsl/scene_builder.rb +83 -13
  53. data/lib/vizcore/dsl/shader_source_resolver.rb +1 -10
  54. data/lib/vizcore/dsl/style_builder.rb +3 -0
  55. data/lib/vizcore/dsl/timeline_builder.rb +91 -8
  56. data/lib/vizcore/dsl/transition_controller.rb +157 -18
  57. data/lib/vizcore/dsl.rb +2 -0
  58. data/lib/vizcore/layer_catalog.rb +1 -0
  59. data/lib/vizcore/plugin_asset_policy.rb +55 -0
  60. data/lib/vizcore/project_manifest.rb +12 -2
  61. data/lib/vizcore/renderer/render_sequence.rb +104 -13
  62. data/lib/vizcore/renderer/scene_frame_source.rb +179 -14
  63. data/lib/vizcore/renderer/scene_serializer.rb +38 -0
  64. data/lib/vizcore/renderer/snapshot.rb +4 -3
  65. data/lib/vizcore/renderer/snapshot_renderer.rb +134 -8
  66. data/lib/vizcore/scene_trust.rb +31 -0
  67. data/lib/vizcore/server/frame_broadcaster.rb +469 -23
  68. data/lib/vizcore/server/rack_app.rb +151 -4
  69. data/lib/vizcore/server/runner.rb +676 -82
  70. data/lib/vizcore/server/websocket_handler.rb +236 -14
  71. data/lib/vizcore/server.rb +21 -0
  72. data/lib/vizcore/shape.rb +39 -16
  73. data/lib/vizcore/sync/osc_message.rb +66 -9
  74. data/lib/vizcore/version.rb +1 -1
  75. data/lib/vizcore.rb +33 -0
  76. data/scripts/browser_capture.mjs +31 -2
  77. data/sig/vizcore.rbs +55 -4
  78. metadata +18 -3
@@ -58,6 +58,8 @@ const FULLSCREEN_VERTICES = new Float32Array([
58
58
  1.0, 1.0
59
59
  ]);
60
60
  const MAX_LAYER_TARGET_PIXELS = 4_194_304;
61
+ const MIN_LAYER_RESOLUTION_SCALE = 0.1;
62
+ const SHADER_CACHE_VERSION = "v2";
61
63
 
62
64
  export const coerceUniformNumber = (value) => {
63
65
  if (typeof value === "boolean") {
@@ -130,6 +132,36 @@ export const normalizeBlendMode = (mode) => {
130
132
  return "alpha";
131
133
  };
132
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
+
133
165
  export const normalizePaletteColors = (value) => {
134
166
  const input = Array.isArray(value) ? value : [];
135
167
  return input
@@ -151,10 +183,119 @@ export const parseHexColor = (value) => {
151
183
  return [0, 2, 4].map((offset) => parseInt(hex.slice(offset, offset + 2), 16) / 255);
152
184
  };
153
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
+
154
289
  export const resolveLayerCssColor = (params = {}, fallback = "#e5f3ff", paletteIndex = 0) => {
155
- const explicitColor = String(params?.color || "").trim();
156
- if (explicitColor) {
157
- 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;
158
299
  }
159
300
 
160
301
  const palette = normalizePaletteColors(params?.palette);
@@ -162,7 +303,24 @@ export const resolveLayerCssColor = (params = {}, fallback = "#e5f3ff", paletteI
162
303
  return fallback;
163
304
  }
164
305
 
165
- 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);
166
324
  };
167
325
 
168
326
  export const resolveLayerRgbColor = (params = {}, fallback = null, paletteIndex = 0) => {
@@ -197,11 +355,14 @@ export class LayerManager {
197
355
 
198
356
  this.layerFramebuffer = null;
199
357
  this.layerTexture = null;
358
+ this.layerTextureSecondary = null;
200
359
  this.layerDepthRenderbuffer = null;
201
360
  this.layerTargetWidth = 0;
202
361
  this.layerTargetHeight = 0;
203
362
  this.layerTargetAvailable = true;
204
363
  this.layerErrorKeys = new Set();
364
+ this.lastGoodShaderPrograms = new Map();
365
+ this.activeShaderLayerKeys = null;
205
366
 
206
367
  this.particleSystem = new ParticleSystem(this.gl, this.shaderManager);
207
368
  this.textRenderer = new TextRenderer(this.gl, this.shaderManager);
@@ -217,30 +378,35 @@ export class LayerManager {
217
378
  const layerList = Array.isArray(layers) && layers.length > 0 ? layers : [defaultLayer(audio)];
218
379
  const width = Math.max(1, Math.floor(Number(resolution?.[0] || 1)));
219
380
  const height = Math.max(1, Math.floor(Number(resolution?.[1] || 1)));
220
- this.ensureLayerTarget(width, height);
381
+ const shaderLayerKeys = new Set();
382
+ this.activeShaderLayerKeys = shaderLayerKeys;
221
383
 
222
- if (!this.layerTargetAvailable || !this.layerFramebuffer || !this.layerTexture) {
223
- layerList.forEach((layer, index) => {
224
- try {
384
+ layerList.forEach((layer, index) => {
385
+ try {
386
+ this.ensureLayerTarget(width, height, layer, visualSettings);
387
+ if (!this.layerTargetAvailable || !this.layerFramebuffer || !this.layerTexture) {
225
388
  const blend = String(layer?.params?.blend || "alpha").toLowerCase();
226
389
  this.setBlendMode(blend);
227
390
  this.renderLayer(layer, audio, time, rotation, [width, height], globals, visualSettings, index);
228
- } catch (error) {
229
- this.reportLayerError(layer, error, "direct-render");
391
+ return;
230
392
  }
231
- });
232
- this.setBlendMode("alpha");
233
- return;
234
- }
235
393
 
236
- layerList.forEach((layer, index) => {
237
- try {
238
394
  this.gl.bindFramebuffer(this.gl.FRAMEBUFFER, this.layerFramebuffer);
239
395
  this.gl.viewport(0, 0, this.layerTargetWidth, this.layerTargetHeight);
240
396
  this.gl.clearColor(0.0, 0.0, 0.0, 0.0);
241
397
  this.gl.clear(this.gl.COLOR_BUFFER_BIT | this.gl.DEPTH_BUFFER_BIT);
242
398
 
243
- 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
+ );
244
410
 
245
411
  this.gl.bindFramebuffer(this.gl.FRAMEBUFFER, null);
246
412
  this.gl.viewport(0, 0, width, height);
@@ -251,10 +417,22 @@ export class LayerManager {
251
417
  this.reportLayerError(layer, error, "layer-pass");
252
418
  }
253
419
  });
420
+ this.pruneLastGoodShaderPrograms(shaderLayerKeys);
421
+ this.activeShaderLayerKeys = null;
254
422
  this.setBlendMode("alpha");
255
423
  }
256
424
 
257
- 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
+ ) {
258
436
  if (isParticleLayer(layer)) {
259
437
  this.renderParticleLayer(layer, audio, time, paletteIndex);
260
438
  return;
@@ -280,16 +458,36 @@ export class LayerManager {
280
458
  return;
281
459
  }
282
460
  if (isShaderLayer(layer)) {
283
- this.renderShaderLayer(layer, audio, time, resolution, globals, visualSettings);
461
+ this.renderShaderLayer(layer, audio, time, resolution, globals, visualSettings, paletteIndex, layerIndex);
284
462
  return;
285
463
  }
286
- 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
+ )) {
287
475
  return;
288
476
  }
289
477
  this.renderGeometryLayer(layer, audio, rotation, time, paletteIndex);
290
478
  }
291
479
 
292
- 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
+ ) {
293
491
  const context = {
294
492
  layer,
295
493
  audio,
@@ -302,19 +500,29 @@ export class LayerManager {
302
500
  };
303
501
 
304
502
  const renderer = resolveLayerRenderer(layer?.type);
305
- 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)) {
306
504
  return true;
307
505
  }
308
506
 
309
507
  const shaderRenderer = resolveShaderRenderer(layer?.type);
310
- 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)) {
311
509
  return true;
312
510
  }
313
511
 
314
512
  return false;
315
513
  }
316
514
 
317
- 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
+ ) {
318
526
  const lines = normalizePluginLineOutput(output);
319
527
  if (lines) {
320
528
  const fallbackColor = resolveLayerRgbColor(layer?.params || {}, [0.82, 0.92, 1.0], paletteIndex);
@@ -332,42 +540,74 @@ export class LayerManager {
332
540
  shader: layer?.shader || "default",
333
541
  glsl: `plugin:${String(layer?.type || "layer")}:${shader.cacheKey}`,
334
542
  glsl_source: shader.fragmentShader
335
- }, audio, time, resolution, globals, visualSettings);
543
+ }, audio, time, resolution, globals, visualSettings, paletteIndex, layerIndex);
336
544
  return true;
337
545
  }
338
546
 
339
- 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
+ ) {
340
557
  const shaderName = String(layer?.shader || "gradient_pulse");
341
558
  const customSource = typeof layer?.glsl_source === "string" ? layer.glsl_source : null;
342
559
  const fragmentShader = customSource || getBuiltinShader(shaderName);
343
- const cacheKey = customSource
344
- ? `custom:${String(layer?.glsl || shaderName)}:${hashString(customSource)}`
345
- : `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
+
346
566
  let program = null;
347
567
  try {
348
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
+ }
349
576
  } catch (error) {
350
577
  if (customSource) {
351
578
  this.reportShaderError(layer, error, "custom-shader");
352
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
+
353
585
  try {
354
- program = this.shaderManager.getProgram(
355
- `builtin:${shaderName}`,
356
- FULLSCREEN_VERTEX_SHADER,
357
- getBuiltinShader(shaderName)
358
- );
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
+ }
359
597
  } catch (builtinError) {
360
598
  this.reportShaderError(layer, builtinError, "builtin-shader-fallback");
361
599
  this.reportLayerError(layer, builtinError, "builtin-shader-fallback");
600
+ program = null;
362
601
  }
363
602
  } else {
364
603
  this.reportShaderError(layer, error, "builtin-shader");
365
604
  this.reportLayerError(layer, error, "builtin-shader");
605
+ program = null;
366
606
  }
367
607
 
368
608
  if (!program) {
369
609
  program = this.shaderManager.getProgram(
370
- "builtin:default",
610
+ shaderCacheKeyForLayer({ shader: "default" }, "default", getBuiltinShader("default")),
371
611
  FULLSCREEN_VERTEX_SHADER,
372
612
  getBuiltinShader("default")
373
613
  );
@@ -383,6 +623,7 @@ export class LayerManager {
383
623
  gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0);
384
624
 
385
625
  const bands = audio?.bands || {};
626
+ const bandPeaks = audio?.band_peaks || {};
386
627
  const onsets = audio?.onsets || {};
387
628
  const drums = audio?.drums || {};
388
629
  this.setUniform1f(program, "u_time", time);
@@ -391,8 +632,19 @@ export class LayerManager {
391
632
  this.setUniform1f(program, "u_bass", bands.low || 0);
392
633
  this.setUniform1f(program, "u_mid", bands.mid || 0);
393
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);
394
638
  this.setUniform1f(program, "u_beat", audio?.beat ? 1 : 0);
395
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);
396
648
  this.setUniform1f(program, "u_onset", audio?.onset || 0);
397
649
  this.setUniform1f(program, "u_sub_onset", onsets.sub || 0);
398
650
  this.setUniform1f(program, "u_low_onset", onsets.low || 0);
@@ -598,30 +850,96 @@ export class LayerManager {
598
850
  compositeLayer(layer, { audio, time, resolution }) {
599
851
  const gl = this.gl;
600
852
  const params = layer?.params || {};
601
- 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
+
602
859
  const blend = String(params.blend || "alpha").toLowerCase();
603
- const effectName = String(params.effect || "");
604
- const vjEffectName = String(params.vj_effect || "");
605
860
  const effectIntensity = clamp(Number(params.effect_intensity || audio?.amplitude || 0.35), 0, 1);
606
- const effectShader = getPostEffectShader(effectName);
607
- const vjShader = getVJEffectShader(vjEffectName);
608
- const selectedShader = vjShader || effectShader;
609
- const selectedEffectName = vjShader ? `vj:${vjEffectName}` : `post:${effectName}`;
610
- let program = this.compositeProgram;
611
- if (selectedShader) {
612
- try {
613
- program = this.shaderManager.getProgram(
614
- selectedEffectName,
615
- FULLSCREEN_VERTEX_SHADER,
616
- selectedShader
617
- );
618
- } catch (error) {
619
- this.reportLayerError(layer, error, selectedEffectName);
620
- 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
+ }
621
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;
622
917
  }
623
918
 
624
- 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);
625
943
 
626
944
  gl.useProgram(program);
627
945
  gl.bindBuffer(gl.ARRAY_BUFFER, this.fullscreenBuffer);
@@ -630,22 +948,41 @@ export class LayerManager {
630
948
  gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0);
631
949
 
632
950
  gl.activeTexture(gl.TEXTURE0);
633
- gl.bindTexture(gl.TEXTURE_2D, this.layerTexture);
951
+ gl.bindTexture(gl.TEXTURE_2D, sourceTexture);
634
952
  this.setUniform1i(program, "u_texture", 0);
635
- this.setUniform1f(program, "u_opacity", opacity);
636
953
  this.setUniform1f(program, "u_time", time);
637
954
  this.setUniform1f(program, "u_intensity", effectIntensity);
638
955
  this.setUniform2f(program, "u_resolution", resolution[0], resolution[1]);
956
+ this.setUniform1f(program, "u_opacity", 1);
639
957
 
640
958
  gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
641
959
  }
642
960
 
643
- 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) {
644
981
  if (!this.layerTargetAvailable) {
645
982
  return;
646
983
  }
647
984
 
648
- const [targetWidth, targetHeight] = this.resolveLayerTargetSize(width, height);
985
+ const [targetWidth, targetHeight] = this.resolveLayerTargetSize(width, height, layer, visualSettings);
649
986
  if (this.layerFramebuffer && this.layerTargetWidth === targetWidth && this.layerTargetHeight === targetHeight) {
650
987
  return;
651
988
  }
@@ -656,16 +993,10 @@ export class LayerManager {
656
993
 
657
994
  const gl = this.gl;
658
995
  this.layerFramebuffer = gl.createFramebuffer();
659
- this.layerTexture = gl.createTexture();
996
+ this.layerTexture = this.createLayerTexture(targetWidth, targetHeight);
997
+ this.layerTextureSecondary = this.createLayerTexture(targetWidth, targetHeight);
660
998
  this.layerDepthRenderbuffer = gl.createRenderbuffer();
661
999
 
662
- gl.bindTexture(gl.TEXTURE_2D, this.layerTexture);
663
- gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, targetWidth, targetHeight, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
664
- gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
665
- gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
666
- gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
667
- gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
668
-
669
1000
  gl.bindFramebuffer(gl.FRAMEBUFFER, this.layerFramebuffer);
670
1001
  gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, this.layerTexture, 0);
671
1002
 
@@ -687,11 +1018,27 @@ export class LayerManager {
687
1018
  gl.bindFramebuffer(gl.FRAMEBUFFER, null);
688
1019
  }
689
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
+
690
1033
  disposeLayerTarget() {
691
1034
  if (this.layerTexture) {
692
1035
  this.gl.deleteTexture(this.layerTexture);
693
1036
  this.layerTexture = null;
694
1037
  }
1038
+ if (this.layerTextureSecondary) {
1039
+ this.gl.deleteTexture(this.layerTextureSecondary);
1040
+ this.layerTextureSecondary = null;
1041
+ }
695
1042
  if (this.layerDepthRenderbuffer) {
696
1043
  this.gl.deleteRenderbuffer(this.layerDepthRenderbuffer);
697
1044
  this.layerDepthRenderbuffer = null;
@@ -702,11 +1049,12 @@ export class LayerManager {
702
1049
  }
703
1050
  }
704
1051
 
705
- resolveLayerTargetSize(width, height) {
1052
+ resolveLayerTargetSize(width, height, layer = null, visualSettings = null) {
706
1053
  const gl = this.gl;
707
1054
  const maxTextureSize = Number(gl.getParameter(gl.MAX_TEXTURE_SIZE) || 4096);
708
- let targetWidth = clamp(Math.floor(width), 1, maxTextureSize);
709
- 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);
710
1058
 
711
1059
  const pixels = targetWidth * targetHeight;
712
1060
  if (pixels > MAX_LAYER_TARGET_PIXELS) {
@@ -718,6 +1066,23 @@ export class LayerManager {
718
1066
  return [targetWidth, targetHeight];
719
1067
  }
720
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
+
721
1086
  setBlendMode(mode) {
722
1087
  const blendMode = normalizeBlendMode(mode);
723
1088
  this.gl.blendEquation(this.gl.FUNC_ADD);
@@ -794,6 +1159,27 @@ export class LayerManager {
794
1159
  window.dispatchEvent(new CustomEvent(SHADER_ERROR_EVENT, { detail }));
795
1160
  }
796
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
+ }
797
1183
  }
798
1184
 
799
1185
  const isShaderLayer = (layer) => {
@@ -859,10 +1245,25 @@ const defaultLayer = (audio) => ({
859
1245
 
860
1246
  const clamp = (value, min, max) => Math.min(Math.max(value, min), max);
861
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
+
862
1262
  const hashString = (value) => {
1263
+ const text = String(value || "");
863
1264
  let hash = 0;
864
- for (let index = 0; index < value.length; index += 1) {
865
- 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;
866
1267
  }
867
1268
  return hash.toString(16);
868
1269
  };