vizcore 0.1.0 → 1.0.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 (126) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +544 -9
  3. data/docs/.nojekyll +0 -0
  4. data/docs/assets/site.css +744 -0
  5. data/docs/assets/vizcore-demo.gif +0 -0
  6. data/docs/assets/vizcore-poster.png +0 -0
  7. data/docs/assets/vj-tunnel.js +159 -0
  8. data/docs/index.html +224 -0
  9. data/examples/README.md +59 -0
  10. data/examples/assets/README.md +19 -0
  11. data/examples/audio_inspector.rb +34 -0
  12. data/examples/club_intro_drop.rb +78 -0
  13. data/examples/kansai_rubykaigi_visual.rb +70 -0
  14. data/examples/live_coding_minimal.rb +22 -0
  15. data/examples/midi_controller_show.rb +78 -0
  16. data/examples/midi_scene_switch.rb +3 -1
  17. data/examples/parser_visualizer.rb +48 -0
  18. data/examples/readme_demo.rb +17 -0
  19. data/examples/rhythm_geometry.rb +34 -0
  20. data/examples/ruby_crystal_show.rb +35 -0
  21. data/examples/shader_playground.rb +18 -0
  22. data/examples/unyo_liquid.rb +59 -0
  23. data/examples/vj_ambient_chill_room.rb +124 -0
  24. data/examples/vj_dnb_jungle.rb +170 -0
  25. data/examples/vj_festival_mainstage.rb +245 -0
  26. data/examples/vj_festival_mainstage.yml +17 -0
  27. data/examples/vj_glitch_industrial.rb +164 -0
  28. data/examples/vj_hiphop_cipher.rb +167 -0
  29. data/examples/vj_jpop_idol_live.rb +210 -0
  30. data/examples/vj_synthwave_retro.rb +173 -0
  31. data/examples/vj_techno_warehouse.rb +195 -0
  32. data/frontend/index.html +468 -2
  33. data/frontend/src/audio-inspector.js +40 -0
  34. data/frontend/src/live-controls.js +131 -0
  35. data/frontend/src/main.js +792 -16
  36. data/frontend/src/midi-learn.js +194 -0
  37. data/frontend/src/performance-monitor.js +183 -0
  38. data/frontend/src/plugin-runtime.js +130 -0
  39. data/frontend/src/projector-mode.js +56 -0
  40. data/frontend/src/renderer/engine.js +148 -3
  41. data/frontend/src/renderer/layer-manager.js +428 -30
  42. data/frontend/src/renderer/shader-manager.js +26 -0
  43. data/frontend/src/runtime-control-preset.js +11 -0
  44. data/frontend/src/shader-error-overlay.js +29 -0
  45. data/frontend/src/shader-param-controls.js +93 -0
  46. data/frontend/src/shaders/builtins.js +380 -2
  47. data/frontend/src/shaders/post-effects.js +52 -0
  48. data/frontend/src/visual-regression.js +67 -0
  49. data/frontend/src/visual-settings-preset.js +103 -0
  50. data/frontend/src/visuals/geometry.js +268 -0
  51. data/frontend/src/visuals/image-renderer.js +291 -0
  52. data/frontend/src/visuals/particle-system.js +56 -10
  53. data/frontend/src/visuals/spectrogram-renderer.js +226 -0
  54. data/frontend/src/visuals/text-renderer.js +112 -11
  55. data/frontend/src/websocket-client.js +12 -1
  56. data/lib/vizcore/analysis/adaptive_normalizer.rb +70 -0
  57. data/lib/vizcore/analysis/beat_detector.rb +4 -2
  58. data/lib/vizcore/analysis/bpm_estimator.rb +8 -0
  59. data/lib/vizcore/analysis/feature_recorder.rb +159 -0
  60. data/lib/vizcore/analysis/feature_replay.rb +84 -0
  61. data/lib/vizcore/analysis/pipeline.rb +235 -11
  62. data/lib/vizcore/analysis/tap_tempo.rb +74 -0
  63. data/lib/vizcore/analysis.rb +4 -0
  64. data/lib/vizcore/audio/dummy_sine_input.rb +1 -1
  65. data/lib/vizcore/audio/fixture_input.rb +65 -0
  66. data/lib/vizcore/audio/input_manager.rb +4 -2
  67. data/lib/vizcore/audio/mic_input.rb +24 -8
  68. data/lib/vizcore/audio/portaudio_ffi.rb +106 -1
  69. data/lib/vizcore/audio.rb +1 -0
  70. data/lib/vizcore/cli/doctor.rb +159 -0
  71. data/lib/vizcore/cli/dsl_reference.rb +99 -0
  72. data/lib/vizcore/cli/layer_docs.rb +46 -0
  73. data/lib/vizcore/cli/scene_diagnostics.rb +23 -0
  74. data/lib/vizcore/cli/scene_inspector.rb +136 -0
  75. data/lib/vizcore/cli/scene_validator.rb +245 -0
  76. data/lib/vizcore/cli/shader_template.rb +68 -0
  77. data/lib/vizcore/cli/shader_uniform_docs.rb +54 -0
  78. data/lib/vizcore/cli.rb +689 -18
  79. data/lib/vizcore/config.rb +103 -2
  80. data/lib/vizcore/control_preset.rb +68 -0
  81. data/lib/vizcore/dsl/engine.rb +277 -5
  82. data/lib/vizcore/dsl/layer_builder.rb +491 -22
  83. data/lib/vizcore/dsl/layer_group_builder.rb +112 -0
  84. data/lib/vizcore/dsl/mapping_resolver.rb +132 -3
  85. data/lib/vizcore/dsl/mapping_transform_builder.rb +71 -0
  86. data/lib/vizcore/dsl/reaction_builder.rb +44 -0
  87. data/lib/vizcore/dsl/scene_builder.rb +61 -5
  88. data/lib/vizcore/dsl/shader_source_resolver.rb +67 -6
  89. data/lib/vizcore/dsl/style_builder.rb +68 -0
  90. data/lib/vizcore/dsl/timeline_builder.rb +138 -0
  91. data/lib/vizcore/dsl/transition_controller.rb +77 -0
  92. data/lib/vizcore/dsl.rb +5 -1
  93. data/lib/vizcore/layer_catalog.rb +273 -0
  94. data/lib/vizcore/project_manifest.rb +152 -0
  95. data/lib/vizcore/renderer/png_writer.rb +57 -0
  96. data/lib/vizcore/renderer/render_sequence.rb +153 -0
  97. data/lib/vizcore/renderer/scene_frame_source.rb +119 -0
  98. data/lib/vizcore/renderer/scene_serializer.rb +36 -3
  99. data/lib/vizcore/renderer/snapshot.rb +38 -0
  100. data/lib/vizcore/renderer/snapshot_renderer.rb +446 -0
  101. data/lib/vizcore/renderer.rb +5 -0
  102. data/lib/vizcore/server/frame_broadcaster.rb +91 -5
  103. data/lib/vizcore/server/gallery_app.rb +155 -0
  104. data/lib/vizcore/server/gallery_page.rb +100 -0
  105. data/lib/vizcore/server/gallery_runner.rb +48 -0
  106. data/lib/vizcore/server/rack_app.rb +203 -4
  107. data/lib/vizcore/server/runner.rb +370 -22
  108. data/lib/vizcore/server/scene_dependency_watcher.rb +79 -0
  109. data/lib/vizcore/server/websocket_handler.rb +60 -10
  110. data/lib/vizcore/server.rb +4 -0
  111. data/lib/vizcore/sync/osc_message.rb +103 -0
  112. data/lib/vizcore/sync/osc_receiver.rb +68 -0
  113. data/lib/vizcore/sync.rb +4 -0
  114. data/lib/vizcore/templates/midi_control_scene.rb +3 -1
  115. data/lib/vizcore/templates/plugin_layer.rb +20 -0
  116. data/lib/vizcore/templates/plugin_readme.md +23 -0
  117. data/lib/vizcore/templates/plugin_renderer.js +43 -0
  118. data/lib/vizcore/templates/plugin_scene.rb +14 -0
  119. data/lib/vizcore/templates/project_readme.md +7 -23
  120. data/lib/vizcore/templates/rubykaigi_scene.rb +30 -0
  121. data/lib/vizcore/version.rb +1 -1
  122. data/lib/vizcore.rb +27 -0
  123. data/scripts/browser_capture.mjs +75 -0
  124. data/sig/vizcore.rbs +362 -0
  125. metadata +83 -3
  126. data/docs/GETTING_STARTED.md +0 -105
@@ -1,7 +1,23 @@
1
1
  import { getBuiltinShader } from "../shaders/builtins.js";
2
2
  import { getPostEffectShader } from "../shaders/post-effects.js";
3
- import { buildWireframeLines, estimateDeformFromSpectrum } from "../visuals/geometry.js";
3
+ import { SHADER_ERROR_EVENT, buildShaderErrorDetail } from "../shader-error-overlay.js";
4
+ import {
5
+ buildPresetMeshLines,
6
+ buildRadialBlobLines,
7
+ buildShapeLines,
8
+ buildWaveformLines,
9
+ buildWireframeLines,
10
+ estimateDeformFromSpectrum
11
+ } from "../visuals/geometry.js";
12
+ import { ImageRenderer } from "../visuals/image-renderer.js";
4
13
  import { ParticleSystem } from "../visuals/particle-system.js";
14
+ import {
15
+ normalizePluginLineOutput,
16
+ normalizePluginShaderOutput,
17
+ resolveLayerRenderer,
18
+ resolveShaderRenderer
19
+ } from "../plugin-runtime.js";
20
+ import { SpectrogramRenderer } from "../visuals/spectrogram-renderer.js";
5
21
  import { TextRenderer } from "../visuals/text-renderer.js";
6
22
  import { getVJEffectShader } from "../visuals/vj-effects.js";
7
23
  import { FULLSCREEN_VERTEX_SHADER } from "./shader-manager.js";
@@ -42,6 +58,117 @@ const FULLSCREEN_VERTICES = new Float32Array([
42
58
  ]);
43
59
  const MAX_LAYER_TARGET_PIXELS = 4_194_304;
44
60
 
61
+ export const coerceUniformNumber = (value) => {
62
+ if (typeof value === "boolean") {
63
+ return value ? 1 : 0;
64
+ }
65
+
66
+ if (typeof value !== "number" && typeof value !== "string") {
67
+ return null;
68
+ }
69
+
70
+ if (typeof value === "string" && value.trim() === "") {
71
+ return null;
72
+ }
73
+
74
+ const numeric = Number(value);
75
+ if (!Number.isFinite(numeric)) {
76
+ return null;
77
+ }
78
+
79
+ return numeric;
80
+ };
81
+
82
+ export const shaderParamUniformNames = (rawKey) => {
83
+ const safeKey = String(rawKey || "").replace(/[^a-zA-Z0-9_]/g, "_");
84
+ if (!safeKey) {
85
+ return [];
86
+ }
87
+
88
+ const names = [`u_param_${safeKey}`];
89
+
90
+ if (safeKey.startsWith("param_")) {
91
+ names.push(`u_${safeKey}`);
92
+ }
93
+
94
+ return [...new Set(names)];
95
+ };
96
+
97
+ export const shaderGlobalUniformNames = (rawKey) => {
98
+ const safeKey = String(rawKey || "").replace(/[^a-zA-Z0-9_]/g, "_");
99
+ if (!safeKey) {
100
+ return [];
101
+ }
102
+
103
+ const names = safeKey.startsWith("global_")
104
+ ? [`u_${safeKey}`, `u_global_${safeKey.slice(7)}`]
105
+ : [`u_global_${safeKey}`];
106
+
107
+ return [...new Set(names)];
108
+ };
109
+
110
+ export const normalizeSpectrum = (value, size = 32) => {
111
+ const input = Array.isArray(value) || ArrayBuffer.isView(value) ? Array.from(value) : [];
112
+ const output = new Float32Array(size);
113
+
114
+ for (let index = 0; index < size; index += 1) {
115
+ const numeric = Number(input[index] || 0);
116
+ output[index] = Number.isFinite(numeric) ? Math.min(Math.max(numeric, 0), 1) : 0;
117
+ }
118
+
119
+ return output;
120
+ };
121
+
122
+ export const normalizeBlendMode = (mode) => {
123
+ const value = String(mode || "alpha").toLowerCase();
124
+ if (value === "normal" || value === "alpha") return "alpha";
125
+ if (value === "add" || value === "additive") return "add";
126
+ if (value === "multiply") return "multiply";
127
+ if (value === "screen") return "screen";
128
+ if (value === "difference") return "difference";
129
+ return "alpha";
130
+ };
131
+
132
+ export const normalizePaletteColors = (value) => {
133
+ const input = Array.isArray(value) ? value : [];
134
+ return input
135
+ .map((entry) => String(entry || "").trim())
136
+ .filter((entry) => entry.length > 0);
137
+ };
138
+
139
+ export const parseHexColor = (value) => {
140
+ const raw = String(value || "").trim();
141
+ const match = raw.match(/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/);
142
+ if (!match) {
143
+ return null;
144
+ }
145
+
146
+ const hex = match[1].length === 3
147
+ ? match[1].split("").map((char) => `${char}${char}`).join("")
148
+ : match[1];
149
+
150
+ return [0, 2, 4].map((offset) => parseInt(hex.slice(offset, offset + 2), 16) / 255);
151
+ };
152
+
153
+ export const resolveLayerCssColor = (params = {}, fallback = "#e5f3ff", paletteIndex = 0) => {
154
+ const explicitColor = String(params?.color || "").trim();
155
+ if (explicitColor) {
156
+ return explicitColor;
157
+ }
158
+
159
+ const palette = normalizePaletteColors(params?.palette);
160
+ if (palette.length === 0) {
161
+ return fallback;
162
+ }
163
+
164
+ return palette[Math.abs(Number(paletteIndex) || 0) % palette.length];
165
+ };
166
+
167
+ export const resolveLayerRgbColor = (params = {}, fallback = null, paletteIndex = 0) => {
168
+ const parsed = parseHexColor(resolveLayerCssColor(params, "", paletteIndex));
169
+ return parsed || fallback;
170
+ };
171
+
45
172
  export class LayerManager {
46
173
  constructor(gl, shaderManager) {
47
174
  this.gl = gl;
@@ -77,39 +204,41 @@ export class LayerManager {
77
204
 
78
205
  this.particleSystem = new ParticleSystem(this.gl, this.shaderManager);
79
206
  this.textRenderer = new TextRenderer(this.gl, this.shaderManager);
207
+ this.imageRenderer = new ImageRenderer(this.gl, this.shaderManager);
208
+ this.spectrogramRenderer = new SpectrogramRenderer(this.gl, this.shaderManager);
80
209
 
81
210
  this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.fullscreenBuffer);
82
211
  this.gl.bufferData(this.gl.ARRAY_BUFFER, FULLSCREEN_VERTICES, this.gl.STATIC_DRAW);
83
212
  }
84
213
 
85
- renderScene({ layers, audio, time, rotation, resolution }) {
214
+ renderScene({ layers, audio, time, rotation, resolution, globals, visualSettings }) {
86
215
  const layerList = Array.isArray(layers) && layers.length > 0 ? layers : [defaultLayer(audio)];
87
216
  const width = Math.max(1, Math.floor(Number(resolution?.[0] || 1)));
88
217
  const height = Math.max(1, Math.floor(Number(resolution?.[1] || 1)));
89
218
  this.ensureLayerTarget(width, height);
90
219
 
91
220
  if (!this.layerTargetAvailable || !this.layerFramebuffer || !this.layerTexture) {
92
- for (const layer of layerList) {
221
+ layerList.forEach((layer, index) => {
93
222
  try {
94
223
  const blend = String(layer?.params?.blend || "alpha").toLowerCase();
95
224
  this.setBlendMode(blend);
96
- this.renderLayer(layer, audio, time, rotation, [width, height]);
225
+ this.renderLayer(layer, audio, time, rotation, [width, height], globals, visualSettings, index);
97
226
  } catch (error) {
98
227
  this.reportLayerError(layer, error, "direct-render");
99
228
  }
100
- }
229
+ });
101
230
  this.setBlendMode("alpha");
102
231
  return;
103
232
  }
104
233
 
105
- for (const layer of layerList) {
234
+ layerList.forEach((layer, index) => {
106
235
  try {
107
236
  this.gl.bindFramebuffer(this.gl.FRAMEBUFFER, this.layerFramebuffer);
108
237
  this.gl.viewport(0, 0, this.layerTargetWidth, this.layerTargetHeight);
109
238
  this.gl.clearColor(0.0, 0.0, 0.0, 0.0);
110
239
  this.gl.clear(this.gl.COLOR_BUFFER_BIT | this.gl.DEPTH_BUFFER_BIT);
111
240
 
112
- this.renderLayer(layer, audio, time, rotation, [this.layerTargetWidth, this.layerTargetHeight]);
241
+ this.renderLayer(layer, audio, time, rotation, [this.layerTargetWidth, this.layerTargetHeight], globals, visualSettings, index);
113
242
 
114
243
  this.gl.bindFramebuffer(this.gl.FRAMEBUFFER, null);
115
244
  this.gl.viewport(0, 0, width, height);
@@ -119,27 +248,93 @@ export class LayerManager {
119
248
  this.gl.viewport(0, 0, width, height);
120
249
  this.reportLayerError(layer, error, "layer-pass");
121
250
  }
122
- }
251
+ });
123
252
  this.setBlendMode("alpha");
124
253
  }
125
254
 
126
- renderLayer(layer, audio, time, rotation, resolution) {
255
+ renderLayer(layer, audio, time, rotation, resolution, globals, visualSettings, paletteIndex = 0) {
127
256
  if (isParticleLayer(layer)) {
128
- this.renderParticleLayer(layer, audio, time);
257
+ this.renderParticleLayer(layer, audio, time, paletteIndex);
129
258
  return;
130
259
  }
131
260
  if (isTextLayer(layer)) {
132
- this.renderTextLayer(layer, audio, time);
261
+ this.renderTextLayer(layer, audio, time, paletteIndex);
262
+ return;
263
+ }
264
+ if (isImageLayer(layer) || isVideoLayer(layer)) {
265
+ this.renderImageLayer(layer, audio);
266
+ return;
267
+ }
268
+ if (isWaveformLayer(layer)) {
269
+ this.renderWaveformLayer(layer, audio, time, paletteIndex);
270
+ return;
271
+ }
272
+ if (isSpectrogramLayer(layer)) {
273
+ this.renderSpectrogramLayer(layer, audio);
274
+ return;
275
+ }
276
+ if (isShapeLayer(layer)) {
277
+ this.renderShapeLayer(layer, audio, paletteIndex);
133
278
  return;
134
279
  }
135
280
  if (isShaderLayer(layer)) {
136
- this.renderShaderLayer(layer, audio, time, resolution);
281
+ this.renderShaderLayer(layer, audio, time, resolution, globals, visualSettings);
282
+ return;
283
+ }
284
+ if (this.renderPluginLayer(layer, audio, time, rotation, resolution, globals, visualSettings, paletteIndex)) {
137
285
  return;
138
286
  }
139
- this.renderGeometryLayer(layer, audio, rotation);
287
+ this.renderGeometryLayer(layer, audio, rotation, time, paletteIndex);
140
288
  }
141
289
 
142
- renderShaderLayer(layer, audio, time, resolution) {
290
+ renderPluginLayer(layer, audio, time, rotation, resolution, globals, visualSettings, paletteIndex = 0) {
291
+ const context = {
292
+ layer,
293
+ audio,
294
+ time,
295
+ rotation,
296
+ resolution,
297
+ globals,
298
+ visualSettings,
299
+ paletteIndex
300
+ };
301
+
302
+ const renderer = resolveLayerRenderer(layer?.type);
303
+ if (renderer && this.renderPluginOutput(layer, renderer(context), audio, time, resolution, globals, visualSettings, paletteIndex)) {
304
+ return true;
305
+ }
306
+
307
+ const shaderRenderer = resolveShaderRenderer(layer?.type);
308
+ if (shaderRenderer && this.renderPluginOutput(layer, shaderRenderer(context), audio, time, resolution, globals, visualSettings, paletteIndex)) {
309
+ return true;
310
+ }
311
+
312
+ return false;
313
+ }
314
+
315
+ renderPluginOutput(layer, output, audio, time, resolution, globals, visualSettings, paletteIndex = 0) {
316
+ const lines = normalizePluginLineOutput(output);
317
+ if (lines) {
318
+ const fallbackColor = resolveLayerRgbColor(layer?.params || {}, [0.82, 0.92, 1.0], paletteIndex);
319
+ this.renderLinePoints(lines.points, lines.color || fallbackColor);
320
+ return true;
321
+ }
322
+
323
+ const shader = normalizePluginShaderOutput(output);
324
+ if (!shader) {
325
+ return false;
326
+ }
327
+
328
+ this.renderShaderLayer({
329
+ ...layer,
330
+ shader: layer?.shader || "default",
331
+ glsl: `plugin:${String(layer?.type || "layer")}:${shader.cacheKey}`,
332
+ glsl_source: shader.fragmentShader
333
+ }, audio, time, resolution, globals, visualSettings);
334
+ return true;
335
+ }
336
+
337
+ renderShaderLayer(layer, audio, time, resolution, globals, visualSettings) {
143
338
  const shaderName = String(layer?.shader || "gradient_pulse");
144
339
  const customSource = typeof layer?.glsl_source === "string" ? layer.glsl_source : null;
145
340
  const fragmentShader = customSource || getBuiltinShader(shaderName);
@@ -151,6 +346,7 @@ export class LayerManager {
151
346
  program = this.shaderManager.getProgram(cacheKey, FULLSCREEN_VERTEX_SHADER, fragmentShader);
152
347
  } catch (error) {
153
348
  if (customSource) {
349
+ this.reportShaderError(layer, error, "custom-shader");
154
350
  console.warn("Failed to compile custom GLSL, falling back to builtin shader", error);
155
351
  try {
156
352
  program = this.shaderManager.getProgram(
@@ -159,9 +355,11 @@ export class LayerManager {
159
355
  getBuiltinShader(shaderName)
160
356
  );
161
357
  } catch (builtinError) {
358
+ this.reportShaderError(layer, builtinError, "builtin-shader-fallback");
162
359
  this.reportLayerError(layer, builtinError, "builtin-shader-fallback");
163
360
  }
164
361
  } else {
362
+ this.reportShaderError(layer, error, "builtin-shader");
165
363
  this.reportLayerError(layer, error, "builtin-shader");
166
364
  }
167
365
 
@@ -183,6 +381,8 @@ export class LayerManager {
183
381
  gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0);
184
382
 
185
383
  const bands = audio?.bands || {};
384
+ const onsets = audio?.onsets || {};
385
+ const drums = audio?.drums || {};
186
386
  this.setUniform1f(program, "u_time", time);
187
387
  this.setUniform2f(program, "u_resolution", resolution[0], resolution[1]);
188
388
  this.setUniform1f(program, "u_amplitude", audio?.amplitude || 0);
@@ -190,31 +390,71 @@ export class LayerManager {
190
390
  this.setUniform1f(program, "u_mid", bands.mid || 0);
191
391
  this.setUniform1f(program, "u_high", bands.high || 0);
192
392
  this.setUniform1f(program, "u_beat", audio?.beat ? 1 : 0);
393
+ this.setUniform1f(program, "u_beat_pulse", audio?.beat_pulse || (audio?.beat ? 1 : 0));
394
+ this.setUniform1f(program, "u_onset", audio?.onset || 0);
395
+ this.setUniform1f(program, "u_sub_onset", onsets.sub || 0);
396
+ this.setUniform1f(program, "u_low_onset", onsets.low || 0);
397
+ this.setUniform1f(program, "u_mid_onset", onsets.mid || 0);
398
+ this.setUniform1f(program, "u_high_onset", onsets.high || 0);
399
+ this.setUniform1f(program, "u_kick", drums.kick || 0);
400
+ this.setUniform1f(program, "u_snare", drums.snare || 0);
401
+ this.setUniform1f(program, "u_hihat", drums.hihat || 0);
193
402
  this.setUniform1f(program, "u_bpm", audio?.bpm || 0);
403
+ const spectrum = normalizeSpectrum(audio?.fft, 32);
404
+ this.setUniform1fv(program, "u_fft[0]", spectrum);
405
+ this.setUniform1f(program, "u_fft_size", spectrum.length);
406
+ this.setUniform1f(program, "u_visual_gain", audio?.visual_gain || visualSettings?.visualGain || 1);
407
+ this.setUniform1f(program, "u_bass_boost", audio?.bass_boost || visualSettings?.bassBoost || 1);
408
+ this.setUniform1f(program, "u_wobble_amount", audio?.wobble_amount || visualSettings?.wobbleAmount || 1);
409
+
410
+ const runtimeGlobals = globals && typeof globals === "object" ? globals : {};
411
+ for (const [key, value] of Object.entries(runtimeGlobals)) {
412
+ const numeric = coerceUniformNumber(value);
413
+ if (numeric === null) {
414
+ continue;
415
+ }
416
+ for (const uniformName of shaderGlobalUniformNames(key)) {
417
+ this.setUniform1f(program, uniformName, numeric);
418
+ }
419
+ }
194
420
 
195
421
  const params = layer?.params || {};
196
422
  for (const [key, value] of Object.entries(params)) {
197
- if (typeof value !== "number") {
423
+ const numeric = coerceUniformNumber(value);
424
+ if (numeric === null) {
198
425
  continue;
199
426
  }
200
- const safeKey = key.replace(/[^a-zA-Z0-9_]/g, "_");
201
- this.setUniform1f(program, `u_param_${safeKey}`, value);
427
+ for (const uniformName of shaderParamUniformNames(key)) {
428
+ this.setUniform1f(program, uniformName, numeric);
429
+ }
202
430
  }
203
431
 
204
432
  gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
205
433
  }
206
434
 
207
- renderGeometryLayer(layer, audio, rotation) {
435
+ renderGeometryLayer(layer, audio, rotation, time, paletteIndex = 0) {
208
436
  const gl = this.gl;
209
437
  const params = layer?.params || {};
210
438
  const colorShift = clamp(Number(params.color_shift || 0), 0, 1);
211
439
  const deform = estimateDeformFromSpectrum(params.deform ?? audio?.fft);
212
- const points = buildWireframeLines({
440
+ const type = String(layer?.type || "").toLowerCase();
441
+ let points = buildWireframeLines({
213
442
  rotationY: rotation,
214
443
  rotationX: rotation * 0.8,
215
444
  deform
216
445
  });
217
446
 
447
+ if (type === "radial_blob") {
448
+ points = buildRadialBlobLines({ time, params, audio });
449
+ } else if (isMeshLayer(layer)) {
450
+ points = buildPresetMeshLines({
451
+ rotationY: rotation,
452
+ rotationX: rotation * 0.8,
453
+ deform,
454
+ params
455
+ });
456
+ }
457
+
218
458
  gl.useProgram(this.geometryProgram);
219
459
  gl.bindBuffer(gl.ARRAY_BUFFER, this.geometryBuffer);
220
460
  gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(points), gl.DYNAMIC_DRAW);
@@ -222,38 +462,125 @@ export class LayerManager {
222
462
  gl.vertexAttribPointer(this.geometryPositionLocation, 2, gl.FLOAT, false, 0, 0);
223
463
 
224
464
  const amplitude = clamp(Number(audio?.amplitude || 0), 0, 1);
225
- gl.uniform3f(
226
- this.geometryColorLocation,
227
- 0.45 + amplitude * 0.45,
465
+ const pulse = clamp(Number(audio?.beat_pulse || 0), 0, 1);
466
+ const fallbackColor = [
467
+ 0.45 + amplitude * 0.45 + pulse * 0.15,
228
468
  0.75 + colorShift * 0.2,
229
469
  0.96
230
- );
470
+ ];
471
+ const color = resolveLayerRgbColor(params, fallbackColor, paletteIndex);
472
+ gl.uniform3f(this.geometryColorLocation, color[0], color[1], color[2]);
231
473
  gl.drawArrays(gl.LINES, 0, points.length / 2);
232
474
  }
233
475
 
234
- renderParticleLayer(layer, audio, time) {
476
+ renderWaveformLayer(layer, audio, time, paletteIndex = 0) {
477
+ const gl = this.gl;
478
+ const params = layer?.params || {};
479
+ const points = buildWaveformLines({ time, params, audio });
480
+
481
+ if (points.length === 0) {
482
+ return;
483
+ }
484
+
485
+ gl.useProgram(this.geometryProgram);
486
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.geometryBuffer);
487
+ gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(points), gl.DYNAMIC_DRAW);
488
+ gl.enableVertexAttribArray(this.geometryPositionLocation);
489
+ gl.vertexAttribPointer(this.geometryPositionLocation, 2, gl.FLOAT, false, 0, 0);
490
+
491
+ const amplitude = clamp(Number(audio?.amplitude || 0), 0, 1);
492
+ const high = clamp(Number(audio?.bands?.high || 0), 0, 1);
493
+ const fallbackColor = [
494
+ 0.28 + high * 0.32,
495
+ 0.86 + amplitude * 0.14,
496
+ 0.72 + high * 0.22
497
+ ];
498
+ const color = resolveLayerRgbColor(params, fallbackColor, paletteIndex);
499
+ gl.uniform3f(this.geometryColorLocation, color[0], color[1], color[2]);
500
+ gl.drawArrays(gl.LINES, 0, points.length / 2);
501
+ }
502
+
503
+ renderParticleLayer(layer, audio, time, paletteIndex = 0) {
235
504
  const params = layer?.params || {};
236
505
  this.particleSystem.render({
237
506
  count: Number(params.count || 2400),
238
507
  speed: Number(params.speed || audio?.amplitude || 0),
239
508
  size: Number(params.size || 2.0),
509
+ forceField: String(params.force_field || "drift"),
510
+ turbulence: Number(params.turbulence || 0),
511
+ bassExplosion: Number(params.bass_explosion || 0),
512
+ sparkle: Number(params.sparkle || 0),
513
+ color: resolveLayerRgbColor(params, null, paletteIndex),
240
514
  audio,
241
515
  time
242
516
  });
243
517
  }
244
518
 
245
- renderTextLayer(layer, audio, time) {
519
+ renderTextLayer(layer, audio, time, paletteIndex = 0) {
246
520
  const params = layer?.params || {};
247
521
  this.textRenderer.render({
248
522
  content: params.content || "VIZCORE",
249
523
  fontSize: Number(params.font_size || 120),
250
- color: params.color || "#e5f3ff",
524
+ color: resolveLayerCssColor(params, "#e5f3ff", paletteIndex),
525
+ fontFamily: params.font || params.font_family,
526
+ align: params.align,
527
+ letterSpacing: params.letter_spacing,
528
+ strokeWidth: params.stroke_width,
529
+ strokeColor: params.stroke_color,
530
+ shadowColor: params.shadow_color,
531
+ shadowBlur: params.shadow_blur,
251
532
  glowStrength: Number(params.glow_strength ?? 0.15),
252
533
  audio,
253
534
  time
254
535
  });
255
536
  }
256
537
 
538
+ renderImageLayer(layer, audio) {
539
+ const params = layer?.params || {};
540
+ this.imageRenderer.render({
541
+ src: params.src || params.file,
542
+ fit: params.fit,
543
+ scale: params.scale,
544
+ rotation: params.rotation,
545
+ playbackRate: params.playback_rate,
546
+ invert: params.invert,
547
+ audio
548
+ });
549
+ }
550
+
551
+ renderSpectrogramLayer(layer, audio) {
552
+ this.spectrogramRenderer.render({
553
+ key: layer?.name || "spectrogram",
554
+ audio,
555
+ params: layer?.params || {}
556
+ });
557
+ }
558
+
559
+ renderShapeLayer(layer, audio, paletteIndex = 0) {
560
+ const params = layer?.params || {};
561
+ const points = buildShapeLines({ params });
562
+
563
+ if (points.length === 0) {
564
+ return;
565
+ }
566
+
567
+ const amplitude = clamp(Number(audio?.amplitude || 0), 0, 1);
568
+ const fallbackColor = [0.85, 0.50 + amplitude * 0.24, 0.95];
569
+ const color = resolveLayerRgbColor(params, fallbackColor, paletteIndex);
570
+ this.renderLinePoints(points, color);
571
+ }
572
+
573
+ renderLinePoints(points, color) {
574
+ const gl = this.gl;
575
+ gl.useProgram(this.geometryProgram);
576
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.geometryBuffer);
577
+ gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(points), gl.DYNAMIC_DRAW);
578
+ gl.enableVertexAttribArray(this.geometryPositionLocation);
579
+ gl.vertexAttribPointer(this.geometryPositionLocation, 2, gl.FLOAT, false, 0, 0);
580
+ gl.uniform3f(this.geometryColorLocation, color[0], color[1], color[2]);
581
+ gl.drawArrays(gl.LINES, 0, points.length / 2);
582
+ }
583
+
257
584
  compositeLayer(layer, { audio, time, resolution }) {
258
585
  const gl = this.gl;
259
586
  const params = layer?.params || {};
@@ -378,11 +705,25 @@ export class LayerManager {
378
705
  }
379
706
 
380
707
  setBlendMode(mode) {
381
- if (mode === "add" || mode === "additive") {
382
- this.gl.blendFunc(this.gl.SRC_ALPHA, this.gl.ONE);
383
- return;
708
+ const blendMode = normalizeBlendMode(mode);
709
+ this.gl.blendEquation(this.gl.FUNC_ADD);
710
+
711
+ switch (blendMode) {
712
+ case "add":
713
+ this.gl.blendFunc(this.gl.SRC_ALPHA, this.gl.ONE);
714
+ return;
715
+ case "multiply":
716
+ this.gl.blendFunc(this.gl.DST_COLOR, this.gl.ONE_MINUS_SRC_ALPHA);
717
+ return;
718
+ case "screen":
719
+ this.gl.blendFunc(this.gl.ONE, this.gl.ONE_MINUS_SRC_COLOR);
720
+ return;
721
+ case "difference":
722
+ this.gl.blendFunc(this.gl.ONE_MINUS_DST_COLOR, this.gl.ONE_MINUS_SRC_COLOR);
723
+ return;
724
+ default:
725
+ this.gl.blendFunc(this.gl.SRC_ALPHA, this.gl.ONE_MINUS_SRC_ALPHA);
384
726
  }
385
- this.gl.blendFunc(this.gl.SRC_ALPHA, this.gl.ONE_MINUS_SRC_ALPHA);
386
727
  }
387
728
 
388
729
  setUniform1f(program, uniformName, value) {
@@ -393,6 +734,14 @@ export class LayerManager {
393
734
  this.gl.uniform1f(location, Number(value || 0));
394
735
  }
395
736
 
737
+ setUniform1fv(program, uniformName, values) {
738
+ const location = this.gl.getUniformLocation(program, uniformName);
739
+ if (location === null) {
740
+ return;
741
+ }
742
+ this.gl.uniform1fv(location, values);
743
+ }
744
+
396
745
  setUniform2f(program, uniformName, x, y) {
397
746
  const location = this.gl.getUniformLocation(program, uniformName);
398
747
  if (location === null) {
@@ -419,6 +768,18 @@ export class LayerManager {
419
768
  this.layerErrorKeys.add(key);
420
769
  console.warn(`Layer render failed (${phase}) [${name}]`, error);
421
770
  }
771
+
772
+ reportShaderError(layer, error, phase) {
773
+ const detail = buildShaderErrorDetail({ layer, error, phase });
774
+ const key = `shader:${detail.phase}:${detail.name}:${detail.shader}:${detail.message}`;
775
+ if (this.layerErrorKeys.has(key)) {
776
+ return;
777
+ }
778
+ this.layerErrorKeys.add(key);
779
+ if (typeof window !== "undefined" && typeof window.dispatchEvent === "function") {
780
+ window.dispatchEvent(new CustomEvent(SHADER_ERROR_EVENT, { detail }));
781
+ }
782
+ }
422
783
  }
423
784
 
424
785
  const isShaderLayer = (layer) => {
@@ -436,6 +797,43 @@ const isTextLayer = (layer) => {
436
797
  return type === "text" || type === "text_layer";
437
798
  };
438
799
 
800
+ const isSvgLayer = (layer) => {
801
+ const type = String(layer?.type || "").toLowerCase();
802
+ return type === "svg" || type === "svg_layer";
803
+ };
804
+
805
+ const isRasterImageLayer = (layer) => {
806
+ const type = String(layer?.type || "").toLowerCase();
807
+ return type === "image" || type === "image_layer" || type === "photo";
808
+ };
809
+
810
+ const isImageLayer = (layer) => isSvgLayer(layer) || isRasterImageLayer(layer);
811
+
812
+ const isVideoLayer = (layer) => {
813
+ const type = String(layer?.type || "").toLowerCase();
814
+ return type === "video" || type === "video_layer" || type === "footage";
815
+ };
816
+
817
+ const isWaveformLayer = (layer) => {
818
+ const type = String(layer?.type || "").toLowerCase();
819
+ return type === "waveform" || type === "waveform_layer";
820
+ };
821
+
822
+ const isSpectrogramLayer = (layer) => {
823
+ const type = String(layer?.type || "").toLowerCase();
824
+ return type === "spectrogram" || type === "spectrogram_layer";
825
+ };
826
+
827
+ const isShapeLayer = (layer) => {
828
+ const type = String(layer?.type || "").toLowerCase();
829
+ return type === "shape" || type === "shapes" || type === "shape_layer";
830
+ };
831
+
832
+ const isMeshLayer = (layer) => {
833
+ const type = String(layer?.type || "").toLowerCase();
834
+ return type === "mesh" || type === "mesh_layer" || type === "preset_mesh";
835
+ };
836
+
439
837
  const defaultLayer = (audio) => ({
440
838
  name: "wireframe_cube",
441
839
  type: "geometry",
@@ -6,6 +6,7 @@ void main() {
6
6
  gl_Position = vec4(a_position, 0.0, 1.0);
7
7
  }
8
8
  `;
9
+ export const SHADER_COMPILE_EVENT = "vizcore:shader-compile";
9
10
 
10
11
  export class ShaderManager {
11
12
  constructor(gl) {
@@ -20,8 +21,13 @@ export class ShaderManager {
20
21
  return cached;
21
22
  }
22
23
 
24
+ const startedAtMs = nowMs();
23
25
  const program = this.createProgram(vertexSource, fragmentSource);
24
26
  this.programCache.set(key, program);
27
+ dispatchShaderCompileEvent({
28
+ cacheKey: key,
29
+ compileMs: nowMs() - startedAtMs,
30
+ });
25
31
  return program;
26
32
  }
27
33
 
@@ -67,3 +73,23 @@ const compileShader = (gl, type, source) => {
67
73
  }
68
74
  return shader;
69
75
  };
76
+
77
+ const nowMs = () => {
78
+ if (typeof performance !== "undefined" && typeof performance.now === "function") {
79
+ return performance.now();
80
+ }
81
+
82
+ return Date.now();
83
+ };
84
+
85
+ const dispatchShaderCompileEvent = (detail) => {
86
+ if (typeof window === "undefined" || typeof window.dispatchEvent !== "function") {
87
+ return;
88
+ }
89
+
90
+ if (typeof CustomEvent !== "function") {
91
+ return;
92
+ }
93
+
94
+ window.dispatchEvent(new CustomEvent(SHADER_COMPILE_EVENT, { detail }));
95
+ };
@@ -0,0 +1,11 @@
1
+ export const normalizeRuntimeControlPreset = (value) => {
2
+ const input = value && typeof value === "object" ? value : {};
3
+ return {
4
+ visualSettings: objectValue(input.visual_settings) || objectValue(input.visualSettings) || null,
5
+ midiLearnBindings: objectValue(input.midi_learn_bindings) || objectValue(input.midiLearnBindings) || null,
6
+ };
7
+ };
8
+
9
+ const objectValue = (value) => {
10
+ return value && typeof value === "object" && !Array.isArray(value) ? value : null;
11
+ };