vizcore 0.1.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +70 -117
  3. data/docs/.nojekyll +0 -0
  4. data/docs/assets/playground-worker.js +373 -0
  5. data/docs/assets/playground.css +440 -0
  6. data/docs/assets/playground.js +652 -0
  7. data/docs/assets/site.css +744 -0
  8. data/docs/assets/vizcore-demo.gif +0 -0
  9. data/docs/assets/vizcore-poster.png +0 -0
  10. data/docs/assets/vj-tunnel.js +159 -0
  11. data/docs/index.html +225 -0
  12. data/docs/playground.html +81 -0
  13. data/docs/shape_dsl.md +269 -0
  14. data/examples/README.md +59 -0
  15. data/examples/assets/README.md +19 -0
  16. data/examples/audio_inspector.rb +34 -0
  17. data/examples/club_intro_drop.rb +78 -0
  18. data/examples/kansai_rubykaigi_visual.rb +70 -0
  19. data/examples/live_coding_minimal.rb +22 -0
  20. data/examples/midi_controller_show.rb +78 -0
  21. data/examples/midi_scene_switch.rb +3 -1
  22. data/examples/parser_visualizer.rb +48 -0
  23. data/examples/readme_demo.rb +17 -0
  24. data/examples/rhythm_geometry.rb +34 -0
  25. data/examples/ruby_crystal_show.rb +35 -0
  26. data/examples/shader_playground.rb +18 -0
  27. data/examples/unyo_liquid.rb +59 -0
  28. data/examples/vj_ambient_chill_room.rb +124 -0
  29. data/examples/vj_dnb_jungle.rb +170 -0
  30. data/examples/vj_festival_mainstage.rb +245 -0
  31. data/examples/vj_festival_mainstage.yml +17 -0
  32. data/examples/vj_glitch_industrial.rb +164 -0
  33. data/examples/vj_hiphop_cipher.rb +167 -0
  34. data/examples/vj_jpop_idol_live.rb +210 -0
  35. data/examples/vj_synthwave_retro.rb +173 -0
  36. data/examples/vj_techno_warehouse.rb +195 -0
  37. data/frontend/index.html +494 -2
  38. data/frontend/src/audio-inspector.js +40 -0
  39. data/frontend/src/custom-shape-param-controls.js +106 -0
  40. data/frontend/src/live-controls.js +131 -0
  41. data/frontend/src/main.js +1060 -16
  42. data/frontend/src/mapping-target-selector.js +109 -0
  43. data/frontend/src/midi-learn.js +194 -0
  44. data/frontend/src/performance-monitor.js +183 -0
  45. data/frontend/src/plugin-runtime.js +130 -0
  46. data/frontend/src/projector-mode.js +56 -0
  47. data/frontend/src/renderer/engine.js +157 -3
  48. data/frontend/src/renderer/layer-manager.js +442 -30
  49. data/frontend/src/renderer/shader-manager.js +26 -0
  50. data/frontend/src/runtime-control-preset.js +11 -0
  51. data/frontend/src/shader-error-overlay.js +29 -0
  52. data/frontend/src/shader-param-controls.js +93 -0
  53. data/frontend/src/shaders/builtins.js +380 -2
  54. data/frontend/src/shaders/post-effects.js +52 -0
  55. data/frontend/src/shape-editor-controls.js +157 -0
  56. data/frontend/src/visual-regression.js +67 -0
  57. data/frontend/src/visual-settings-preset.js +103 -0
  58. data/frontend/src/visuals/geometry.js +666 -0
  59. data/frontend/src/visuals/image-renderer.js +291 -0
  60. data/frontend/src/visuals/particle-system.js +56 -10
  61. data/frontend/src/visuals/shape-renderer.js +475 -0
  62. data/frontend/src/visuals/spectrogram-renderer.js +226 -0
  63. data/frontend/src/visuals/svg-arc.js +104 -0
  64. data/frontend/src/visuals/text-renderer.js +112 -11
  65. data/frontend/src/websocket-client.js +12 -1
  66. data/lib/vizcore/analysis/adaptive_normalizer.rb +70 -0
  67. data/lib/vizcore/analysis/beat_detector.rb +4 -2
  68. data/lib/vizcore/analysis/bpm_estimator.rb +8 -0
  69. data/lib/vizcore/analysis/feature_recorder.rb +159 -0
  70. data/lib/vizcore/analysis/feature_replay.rb +84 -0
  71. data/lib/vizcore/analysis/pipeline.rb +235 -11
  72. data/lib/vizcore/analysis/tap_tempo.rb +74 -0
  73. data/lib/vizcore/analysis.rb +4 -0
  74. data/lib/vizcore/audio/dummy_sine_input.rb +1 -1
  75. data/lib/vizcore/audio/fixture_input.rb +65 -0
  76. data/lib/vizcore/audio/input_manager.rb +4 -2
  77. data/lib/vizcore/audio/mic_input.rb +24 -8
  78. data/lib/vizcore/audio/portaudio_ffi.rb +106 -1
  79. data/lib/vizcore/audio.rb +1 -0
  80. data/lib/vizcore/cli/doctor.rb +159 -0
  81. data/lib/vizcore/cli/dsl_reference.rb +99 -0
  82. data/lib/vizcore/cli/layer_docs.rb +46 -0
  83. data/lib/vizcore/cli/scene_diagnostics.rb +23 -0
  84. data/lib/vizcore/cli/scene_inspector.rb +136 -0
  85. data/lib/vizcore/cli/scene_validator.rb +337 -0
  86. data/lib/vizcore/cli/shader_template.rb +68 -0
  87. data/lib/vizcore/cli/shader_uniform_docs.rb +54 -0
  88. data/lib/vizcore/cli.rb +689 -18
  89. data/lib/vizcore/config.rb +103 -2
  90. data/lib/vizcore/control_preset.rb +68 -0
  91. data/lib/vizcore/dsl/engine.rb +277 -5
  92. data/lib/vizcore/dsl/layer_builder.rb +1280 -23
  93. data/lib/vizcore/dsl/layer_group_builder.rb +112 -0
  94. data/lib/vizcore/dsl/mapping_resolver.rb +290 -7
  95. data/lib/vizcore/dsl/mapping_transform_builder.rb +71 -0
  96. data/lib/vizcore/dsl/reaction_builder.rb +44 -0
  97. data/lib/vizcore/dsl/scene_builder.rb +61 -5
  98. data/lib/vizcore/dsl/shader_source_resolver.rb +67 -6
  99. data/lib/vizcore/dsl/style_builder.rb +68 -0
  100. data/lib/vizcore/dsl/timeline_builder.rb +138 -0
  101. data/lib/vizcore/dsl/transition_controller.rb +77 -0
  102. data/lib/vizcore/dsl.rb +5 -1
  103. data/lib/vizcore/layer_catalog.rb +275 -0
  104. data/lib/vizcore/project_manifest.rb +152 -0
  105. data/lib/vizcore/renderer/png_writer.rb +57 -0
  106. data/lib/vizcore/renderer/render_sequence.rb +153 -0
  107. data/lib/vizcore/renderer/scene_frame_source.rb +132 -0
  108. data/lib/vizcore/renderer/scene_serializer.rb +36 -3
  109. data/lib/vizcore/renderer/snapshot.rb +38 -0
  110. data/lib/vizcore/renderer/snapshot_renderer.rb +938 -0
  111. data/lib/vizcore/renderer.rb +5 -0
  112. data/lib/vizcore/server/frame_broadcaster.rb +143 -8
  113. data/lib/vizcore/server/gallery_app.rb +155 -0
  114. data/lib/vizcore/server/gallery_page.rb +100 -0
  115. data/lib/vizcore/server/gallery_runner.rb +48 -0
  116. data/lib/vizcore/server/rack_app.rb +203 -4
  117. data/lib/vizcore/server/runner.rb +391 -22
  118. data/lib/vizcore/server/scene_dependency_watcher.rb +79 -0
  119. data/lib/vizcore/server/websocket_handler.rb +60 -10
  120. data/lib/vizcore/server.rb +4 -0
  121. data/lib/vizcore/shape.rb +719 -0
  122. data/lib/vizcore/sync/osc_message.rb +103 -0
  123. data/lib/vizcore/sync/osc_receiver.rb +68 -0
  124. data/lib/vizcore/sync.rb +4 -0
  125. data/lib/vizcore/templates/midi_control_scene.rb +3 -1
  126. data/lib/vizcore/templates/plugin_layer.rb +20 -0
  127. data/lib/vizcore/templates/plugin_readme.md +23 -0
  128. data/lib/vizcore/templates/plugin_renderer.js +43 -0
  129. data/lib/vizcore/templates/plugin_scene.rb +14 -0
  130. data/lib/vizcore/templates/project_readme.md +7 -23
  131. data/lib/vizcore/templates/rubykaigi_scene.rb +30 -0
  132. data/lib/vizcore/version.rb +1 -1
  133. data/lib/vizcore.rb +28 -0
  134. data/scripts/browser_capture.mjs +75 -0
  135. data/sig/vizcore.rbs +461 -0
  136. metadata +94 -3
  137. data/docs/GETTING_STARTED.md +0 -105
@@ -0,0 +1,652 @@
1
+ const presets = {
2
+ rings: {
3
+ label: "Beat rings",
4
+ source: `Vizcore.define do
5
+ scene :readme_demo do
6
+ layer :beat_rings do
7
+ palette "#24f6ff", "#ff2bbd", "#caff2e"
8
+
9
+ circle count: 4 do
10
+ radius 92
11
+ stroke 3
12
+ map beat_pulse, to: :radius, gain: 160.0, min: 56, max: 164, release: 0.2
13
+ end
14
+ end
15
+ end
16
+ end`
17
+ },
18
+ drop: {
19
+ label: "Drop scene",
20
+ source: `Vizcore.define do
21
+ scene :intro do
22
+ layer :title do
23
+ type :text
24
+ content "VIZCORE"
25
+ font_size 92
26
+ fill "#ffffff"
27
+ map beat?, to: :opacity, range: 0.35..1.0
28
+ end
29
+
30
+ layer :grid do
31
+ shader :neon_grid
32
+ map mid, to: :intensity, gain: 1.8, range: 0.2..1.0
33
+ end
34
+ end
35
+
36
+ scene :drop do
37
+ layer :tunnel do
38
+ shader :bass_tunnel
39
+ map bass, to: :scale, range: 0.8..1.5, curve: :sqrt
40
+ map beat_pulse, to: :flash
41
+ end
42
+
43
+ layer :particles do
44
+ type :particle_field
45
+ count 2400
46
+ blend :screen
47
+ map bass, to: :size, range: 2.0..8.0
48
+ map treble, to: :sparkle
49
+ end
50
+ end
51
+
52
+ transition from: :intro, to: :drop do
53
+ on_bar 8
54
+ effect :crossfade, duration: 1.0
55
+ end
56
+ end`
57
+ },
58
+ scopes: {
59
+ label: "Scopes",
60
+ source: `Vizcore.define do
61
+ scene :analysis do
62
+ layer :wave do
63
+ type :waveform
64
+ source :audio
65
+ style :ribbon
66
+ map amplitude, to: :height, range: 0.15..0.7
67
+ end
68
+
69
+ layer :waterfall do
70
+ type :spectrogram
71
+ scroll :vertical
72
+ bins 64
73
+ map treble, to: :gain, range: 0.7..2.4
74
+ end
75
+
76
+ layer :mesh do
77
+ type :wireframe_cube
78
+ map bass, to: :scale, range: 0.75..1.35
79
+ map mid, to: :rotation_speed, range: 0.2..1.8
80
+ end
81
+ end
82
+ end`
83
+ }
84
+ };
85
+
86
+ const editor = document.querySelector("#editor");
87
+ const presetSelect = document.querySelector("#preset-select");
88
+ const runButton = document.querySelector("#run-button");
89
+ const resetButton = document.querySelector("#reset-button");
90
+ const rubyStatus = document.querySelector("#ruby-status");
91
+ const compileStatus = document.querySelector("#compile-status");
92
+ const jsonOutput = document.querySelector("#json-output");
93
+ const sceneTabs = document.querySelector("#scene-tabs");
94
+ const canvas = document.querySelector("#preview-canvas");
95
+ const sceneStat = document.querySelector("#scene-stat");
96
+ const audioStat = document.querySelector("#audio-stat");
97
+ const beatStat = document.querySelector("#beat-stat");
98
+ const errorOutput = document.querySelector("#error-output");
99
+ const context = canvas.getContext("2d");
100
+
101
+ const compileTimeoutMs = 9000;
102
+ let worker = null;
103
+ let requestId = 0;
104
+ let pendingCompile = null;
105
+ let latestRunId = 0;
106
+ let sceneDefinition = null;
107
+ let activeSceneName = "";
108
+ let lastFrameTime = performance.now();
109
+ let beatStartedAt = 0;
110
+ let animationFrame = 0;
111
+
112
+ const clamp = (value, min = 0, max = 1) => Math.min(Math.max(value, min), max);
113
+
114
+ const populatePresets = () => {
115
+ Object.entries(presets).forEach(([key, preset]) => {
116
+ const option = document.createElement("option");
117
+ option.value = key;
118
+ option.textContent = preset.label;
119
+ presetSelect.append(option);
120
+ });
121
+ presetSelect.value = "rings";
122
+ editor.value = presets.rings.source;
123
+ };
124
+
125
+ const setStatus = (message, detail = "") => {
126
+ rubyStatus.textContent = message;
127
+ compileStatus.textContent = detail;
128
+ };
129
+
130
+ const showError = (message, backtrace = []) => {
131
+ errorOutput.hidden = false;
132
+ errorOutput.textContent = [message, ...backtrace].filter(Boolean).join("\n");
133
+ };
134
+
135
+ const clearError = () => {
136
+ errorOutput.hidden = true;
137
+ errorOutput.textContent = "";
138
+ };
139
+
140
+ const restartWorker = () => {
141
+ if (worker) {
142
+ worker.terminate();
143
+ }
144
+ worker = new Worker(new URL("playground-worker.js", import.meta.url), { type: "module" });
145
+ worker.addEventListener("message", handleWorkerMessage);
146
+ worker.addEventListener("error", (event) => {
147
+ rejectPending(new Error(event.message || "Ruby worker failed"));
148
+ setStatus("Ruby wasm error", "Worker failed");
149
+ });
150
+ };
151
+
152
+ const rejectPending = (error) => {
153
+ if (!pendingCompile) return;
154
+
155
+ clearTimeout(pendingCompile.timer);
156
+ pendingCompile.reject(error);
157
+ pendingCompile = null;
158
+ };
159
+
160
+ const cancelPendingCompile = () => {
161
+ const error = Object.assign(new Error("Compile request was superseded"), { cancelled: true });
162
+ rejectPending(error);
163
+ };
164
+
165
+ const handleWorkerMessage = (event) => {
166
+ const message = event.data || {};
167
+ if (message.type === "ready") {
168
+ setStatus("Ruby wasm ready", "Loaded");
169
+ return;
170
+ }
171
+ if (message.type === "status") {
172
+ setStatus(message.message, "Working");
173
+ return;
174
+ }
175
+ if (!pendingCompile || message.id !== pendingCompile.id) return;
176
+
177
+ clearTimeout(pendingCompile.timer);
178
+ const pending = pendingCompile;
179
+ pendingCompile = null;
180
+
181
+ if (message.type === "compiled") {
182
+ pending.resolve(JSON.parse(message.definition_json || "{}"));
183
+ return;
184
+ }
185
+
186
+ pending.reject(Object.assign(new Error(message.message || "Ruby compile failed"), {
187
+ backtrace: message.backtrace || []
188
+ }));
189
+ };
190
+
191
+ const compileScene = (source) => {
192
+ if (!worker) {
193
+ restartWorker();
194
+ }
195
+
196
+ if (pendingCompile) {
197
+ cancelPendingCompile();
198
+ }
199
+
200
+ requestId += 1;
201
+ const id = requestId;
202
+
203
+ return new Promise((resolve, reject) => {
204
+ const timer = setTimeout(() => {
205
+ rejectPending(new Error("Ruby evaluation timed out"));
206
+ restartWorker();
207
+ }, compileTimeoutMs);
208
+
209
+ pendingCompile = { id, timer, resolve, reject };
210
+ worker.postMessage({ type: "compile", id, source });
211
+ });
212
+ };
213
+
214
+ const runEditor = async () => {
215
+ const runId = latestRunId + 1;
216
+ latestRunId = runId;
217
+ clearError();
218
+ runButton.disabled = true;
219
+ setStatus("Ruby wasm", "Compiling");
220
+
221
+ try {
222
+ const definition = await compileScene(editor.value);
223
+ if (runId !== latestRunId) return;
224
+
225
+ sceneDefinition = normalizeDefinition(definition);
226
+ activeSceneName = sceneDefinition.scenes[0]?.name || "";
227
+ jsonOutput.textContent = JSON.stringify(sceneDefinition, null, 2);
228
+ renderSceneTabs();
229
+ setStatus("Ruby wasm ready", "Compiled");
230
+ } catch (error) {
231
+ if (error.cancelled) return;
232
+
233
+ showError(error.message, error.backtrace);
234
+ setStatus("Ruby wasm error", "Compile failed");
235
+ } finally {
236
+ if (runId === latestRunId) {
237
+ runButton.disabled = false;
238
+ }
239
+ }
240
+ };
241
+
242
+ const normalizeDefinition = (definition) => {
243
+ const scenes = Array.isArray(definition?.scenes) ? definition.scenes : [];
244
+ return {
245
+ scenes: scenes.map((scene) => ({
246
+ name: String(scene?.name || "scene"),
247
+ layers: Array.isArray(scene?.layers) ? scene.layers : []
248
+ })),
249
+ transitions: Array.isArray(definition?.transitions) ? definition.transitions : [],
250
+ globals: definition?.globals && typeof definition.globals === "object" ? definition.globals : {}
251
+ };
252
+ };
253
+
254
+ const renderSceneTabs = () => {
255
+ sceneTabs.replaceChildren();
256
+ const scenes = sceneDefinition?.scenes || [];
257
+ scenes.forEach((scene) => {
258
+ const button = document.createElement("button");
259
+ button.type = "button";
260
+ button.textContent = scene.name;
261
+ button.className = scene.name === activeSceneName ? "scene-tab active" : "scene-tab";
262
+ button.addEventListener("click", () => {
263
+ activeSceneName = scene.name;
264
+ renderSceneTabs();
265
+ });
266
+ sceneTabs.append(button);
267
+ });
268
+ };
269
+
270
+ const resizeCanvas = () => {
271
+ const rect = canvas.getBoundingClientRect();
272
+ const dpr = Math.min(window.devicePixelRatio || 1, 2);
273
+ const width = Math.max(1, Math.floor(rect.width * dpr));
274
+ const height = Math.max(1, Math.floor(rect.height * dpr));
275
+ if (canvas.width === width && canvas.height === height) return;
276
+
277
+ canvas.width = width;
278
+ canvas.height = height;
279
+ };
280
+
281
+ const buildAudio = (time) => {
282
+ const beatInterval = 0.5;
283
+ const beatPhase = time % beatInterval;
284
+ const beat = beatPhase < 0.07;
285
+ if (beat && time - beatStartedAt > 0.2) {
286
+ beatStartedAt = time;
287
+ }
288
+
289
+ const beatPulse = clamp(1 - (time - beatStartedAt) * 4);
290
+ const low = clamp(0.48 + Math.sin(time * 3.2) * 0.28 + beatPulse * 0.32);
291
+ const mid = clamp(0.44 + Math.sin(time * 4.7 + 1.1) * 0.24);
292
+ const high = clamp(0.36 + Math.sin(time * 8.3 + 0.7) * 0.26 + (beat ? 0.22 : 0));
293
+ const amplitude = clamp((low + mid + high) / 3);
294
+ const fft = Array.from({ length: 32 }, (_, index) => {
295
+ const wave = Math.sin(time * (1.4 + index * 0.08) + index * 0.41);
296
+ const falloff = 1 - index / 42;
297
+ return clamp((0.45 + wave * 0.4) * falloff + beatPulse * 0.18);
298
+ });
299
+
300
+ return {
301
+ amplitude,
302
+ beat,
303
+ beat_pulse: beatPulse,
304
+ beat_confidence: clamp(beatPulse + 0.2),
305
+ bands: { sub: low * 0.78, low, mid, high },
306
+ drums: { kick: low * beatPulse, snare: mid * (beat ? 0.8 : 0.2), hihat: high },
307
+ onsets: { low: beatPulse, mid: mid * 0.35, high: high * 0.4 },
308
+ onset: Math.max(beatPulse, high * 0.3),
309
+ fft,
310
+ bpm: 120,
311
+ beat_count: Math.floor(time / beatInterval)
312
+ };
313
+ };
314
+
315
+ const sourceValue = (source, audio) => {
316
+ if (!source || typeof source !== "object") return 0;
317
+
318
+ if (source.source === "amplitude") return audio.amplitude;
319
+ if (source.source === "fft_spectrum") return average(audio.fft);
320
+ if (source.source === "beat") return audio.beat ? 1 : 0;
321
+ if (source.source === "beat_pulse") return audio.beat_pulse;
322
+ if (source.source === "beat_confidence") return audio.beat_confidence;
323
+ if (source.source === "band") return Number(audio.bands?.[source.name] || 0);
324
+ if (source.source === "drum") return Number(audio.drums?.[source.name] || 0);
325
+ if (source.source === "onset") {
326
+ return source.name ? Number(audio.onsets?.[source.name] || 0) : Number(audio.onset || 0);
327
+ }
328
+
329
+ return 0;
330
+ };
331
+
332
+ const average = (values) => {
333
+ if (!Array.isArray(values) || values.length === 0) return 0;
334
+ return values.reduce((sum, value) => sum + Number(value || 0), 0) / values.length;
335
+ };
336
+
337
+ const applyTransform = (value, transform = {}) => {
338
+ let output = Number(value || 0);
339
+ const deadzone = Number(transform.deadzone || 0);
340
+ if (Math.abs(output) < deadzone) output = 0;
341
+
342
+ output *= Number(transform.gain || 1);
343
+
344
+ if (transform.curve === "sqrt") output = Math.sqrt(Math.max(output, 0));
345
+ if (transform.curve === "square") output *= output;
346
+ if (transform.curve === "ease_out") output = 1 - Math.pow(1 - clamp(output), 2);
347
+
348
+ if (Array.isArray(transform.range) && transform.range.length >= 2) {
349
+ const min = Number(transform.range[0]);
350
+ const max = Number(transform.range[1]);
351
+ output = min + clamp(output) * (max - min);
352
+ }
353
+
354
+ if (transform.min !== undefined) output = Math.max(output, Number(transform.min));
355
+ if (transform.max !== undefined) output = Math.min(output, Number(transform.max));
356
+ return output;
357
+ };
358
+
359
+ const layerParams = (layer, audio) => {
360
+ const params = { ...(layer?.params || {}) };
361
+ const mappings = Array.isArray(layer?.mappings) ? layer.mappings : [];
362
+ mappings.forEach((mapping) => {
363
+ if (!mapping?.target) return;
364
+ params[mapping.target] = applyTransform(sourceValue(mapping.source, audio), mapping.transform);
365
+ });
366
+ return params;
367
+ };
368
+
369
+ const shapeParams = (shape, audio) => {
370
+ const params = { ...shape };
371
+ const mappings = Array.isArray(shape?.mappings) ? shape.mappings : [];
372
+ mappings.forEach((mapping) => {
373
+ if (!mapping?.target) return;
374
+ params[mapping.target] = applyTransform(sourceValue(mapping.source, audio), mapping.transform);
375
+ });
376
+ return params;
377
+ };
378
+
379
+ const render = (now) => {
380
+ resizeCanvas();
381
+ const delta = Math.min((now - lastFrameTime) / 1000, 0.05);
382
+ lastFrameTime = now;
383
+ const time = now / 1000;
384
+ const audio = buildAudio(time);
385
+
386
+ drawFrame(time, delta, audio);
387
+ updateStats(audio);
388
+ animationFrame = requestAnimationFrame(render);
389
+ };
390
+
391
+ const drawFrame = (time, _delta, audio) => {
392
+ const width = canvas.width;
393
+ const height = canvas.height;
394
+ context.clearRect(0, 0, width, height);
395
+ drawBackground(width, height, audio);
396
+
397
+ const scene = currentScene();
398
+ if (!scene) {
399
+ drawIdle(width, height, time, audio);
400
+ return;
401
+ }
402
+
403
+ scene.layers.forEach((layer, index) => {
404
+ const params = layerParams(layer, audio);
405
+ const type = String(layer?.type || "geometry");
406
+ const shader = String(layer?.shader || "");
407
+
408
+ context.save();
409
+ context.globalCompositeOperation = blendMode(params.blend);
410
+ context.globalAlpha = clamp(Number(params.opacity ?? 1), 0, 1);
411
+
412
+ if (type === "shader") drawShaderLayer(width, height, shader, params, audio, time, index);
413
+ if (type === "particle_field") drawParticles(width, height, params, audio, time);
414
+ if (type === "text") drawText(width, height, params, audio);
415
+ if (type === "waveform") drawWaveform(width, height, params, audio, time);
416
+ if (type === "spectrogram") drawSpectrogram(width, height, params, audio);
417
+ if (type === "wireframe_cube" || type === "mesh") drawWireframe(width, height, params, audio, time);
418
+ drawShapes(width, height, params, audio);
419
+
420
+ context.restore();
421
+ });
422
+ };
423
+
424
+ const currentScene = () => {
425
+ const scenes = sceneDefinition?.scenes || [];
426
+ return scenes.find((scene) => scene.name === activeSceneName) || scenes[0] || null;
427
+ };
428
+
429
+ const blendMode = (mode) => {
430
+ if (mode === "screen") return "screen";
431
+ if (mode === "add") return "lighter";
432
+ if (mode === "multiply") return "multiply";
433
+ if (mode === "difference") return "difference";
434
+ return "source-over";
435
+ };
436
+
437
+ const drawBackground = (width, height, audio) => {
438
+ const gradient = context.createLinearGradient(0, 0, width, height);
439
+ gradient.addColorStop(0, "#06110f");
440
+ gradient.addColorStop(0.5, "#0b1220");
441
+ gradient.addColorStop(1, "#130812");
442
+ context.fillStyle = gradient;
443
+ context.fillRect(0, 0, width, height);
444
+
445
+ context.strokeStyle = `rgba(56, 189, 248, ${0.06 + audio.amplitude * 0.08})`;
446
+ context.lineWidth = 1;
447
+ const grid = Math.max(44, width / 20);
448
+ for (let x = 0; x <= width; x += grid) {
449
+ context.beginPath();
450
+ context.moveTo(x, 0);
451
+ context.lineTo(x, height);
452
+ context.stroke();
453
+ }
454
+ for (let y = 0; y <= height; y += grid) {
455
+ context.beginPath();
456
+ context.moveTo(0, y);
457
+ context.lineTo(width, y);
458
+ context.stroke();
459
+ }
460
+ };
461
+
462
+ const drawIdle = (width, height, time, audio) => {
463
+ context.save();
464
+ context.translate(width / 2, height / 2);
465
+ for (let index = 0; index < 9; index += 1) {
466
+ const radius = 70 + index * 36 + Math.sin(time * 2 + index) * 12 + audio.beat_pulse * 42;
467
+ context.strokeStyle = color(index, 0.35);
468
+ context.lineWidth = 2;
469
+ context.beginPath();
470
+ context.arc(0, 0, radius, 0, Math.PI * 2);
471
+ context.stroke();
472
+ }
473
+ context.restore();
474
+ };
475
+
476
+ const drawShaderLayer = (width, height, shader, params, audio, time, index) => {
477
+ if (shader.includes("rings") || shader.includes("tunnel")) {
478
+ context.save();
479
+ context.translate(width / 2, height / 2);
480
+ const scale = Number(params.scale || 1);
481
+ for (let ring = 0; ring < 14; ring += 1) {
482
+ const radius = (ring * 42 + (time * 50) % 42) * scale + audio.beat_pulse * 48;
483
+ context.strokeStyle = color(ring + index, 0.18 + audio.amplitude * 0.35);
484
+ context.lineWidth = 2 + audio.beat_pulse * 5;
485
+ context.beginPath();
486
+ context.arc(0, 0, radius, 0, Math.PI * 2);
487
+ context.stroke();
488
+ }
489
+ context.restore();
490
+ return;
491
+ }
492
+
493
+ const stripes = 18;
494
+ for (let stripe = 0; stripe < stripes; stripe += 1) {
495
+ const y = (stripe / stripes) * height;
496
+ const offset = Math.sin(time * 2 + stripe * 0.8) * 90 * audio.bands.mid;
497
+ context.fillStyle = color(stripe + index, 0.1 + audio.amplitude * 0.18);
498
+ context.fillRect(offset, y, width, height / stripes + 2);
499
+ }
500
+ };
501
+
502
+ const drawParticles = (width, height, params, audio, time) => {
503
+ const count = Math.min(Number(params.count || 900), 1100);
504
+ const size = Number(params.size || 2.5);
505
+ const sparkle = Number(params.sparkle || audio.bands.high);
506
+ context.fillStyle = `rgba(248, 250, 252, ${0.24 + sparkle * 0.46})`;
507
+ for (let index = 0; index < count; index += 1) {
508
+ const seed = index * 12.9898;
509
+ const angle = seed + time * (0.15 + audio.bands.low);
510
+ const orbit = ((index % 97) / 97) * Math.min(width, height) * 0.55;
511
+ const x = width / 2 + Math.cos(angle) * orbit + Math.sin(seed) * width * 0.08;
512
+ const y = height / 2 + Math.sin(angle * 1.17) * orbit * 0.65;
513
+ context.fillRect(x, y, size, size);
514
+ }
515
+ };
516
+
517
+ const drawText = (width, height, params, audio) => {
518
+ const text = String(params.content || "VIZCORE");
519
+ const opacity = Number(params.opacity ?? 1);
520
+ context.globalAlpha *= clamp(opacity + audio.beat_pulse * 0.2);
521
+ context.fillStyle = String(params.fill || "#f8fafc");
522
+ context.font = `700 ${Number(params.font_size || 72)}px IBM Plex Sans, system-ui, sans-serif`;
523
+ context.textAlign = "center";
524
+ context.textBaseline = "middle";
525
+ context.shadowColor = "rgba(34, 197, 94, 0.45)";
526
+ context.shadowBlur = 24 + audio.beat_pulse * 28;
527
+ text.split("\\n").forEach((line, index, lines) => {
528
+ context.fillText(line, width / 2, height / 2 + (index - (lines.length - 1) / 2) * 92);
529
+ });
530
+ };
531
+
532
+ const drawWaveform = (width, height, params, audio, time) => {
533
+ const waveHeight = Number(params.height || 0.35) * height;
534
+ context.strokeStyle = "rgba(36, 246, 255, 0.86)";
535
+ context.lineWidth = 3;
536
+ context.beginPath();
537
+ for (let x = 0; x <= width; x += 8) {
538
+ const unit = x / width;
539
+ const y = height / 2 + Math.sin(unit * Math.PI * 8 + time * 5) * waveHeight * (0.25 + audio.amplitude);
540
+ if (x === 0) context.moveTo(x, y);
541
+ else context.lineTo(x, y);
542
+ }
543
+ context.stroke();
544
+ };
545
+
546
+ const drawSpectrogram = (width, height, params, audio) => {
547
+ const bins = Math.min(Number(params.bins || 32), 96);
548
+ const gain = Number(params.gain || 1);
549
+ const barWidth = width / bins;
550
+ for (let index = 0; index < bins; index += 1) {
551
+ const value = clamp((audio.fft[index % audio.fft.length] || 0) * gain);
552
+ context.fillStyle = color(index, 0.2 + value * 0.55);
553
+ context.fillRect(index * barWidth, height * (1 - value), Math.ceil(barWidth), height * value);
554
+ }
555
+ };
556
+
557
+ const drawWireframe = (width, height, params, audio, time) => {
558
+ const scale = Math.min(width, height) * 0.18 * Number(params.scale || 1);
559
+ const speed = Number(params.rotation_speed || 1);
560
+ const points = [
561
+ [-1, -1, -1], [1, -1, -1], [1, 1, -1], [-1, 1, -1],
562
+ [-1, -1, 1], [1, -1, 1], [1, 1, 1], [-1, 1, 1]
563
+ ].map(([x, y, z]) => projectPoint(x, y, z, scale, time * speed, width, height));
564
+ const edges = [[0,1], [1,2], [2,3], [3,0], [4,5], [5,6], [6,7], [7,4], [0,4], [1,5], [2,6], [3,7]];
565
+ context.strokeStyle = `rgba(163, 230, 53, ${0.5 + audio.amplitude * 0.35})`;
566
+ context.lineWidth = 2;
567
+ edges.forEach(([a, b]) => {
568
+ context.beginPath();
569
+ context.moveTo(points[a][0], points[a][1]);
570
+ context.lineTo(points[b][0], points[b][1]);
571
+ context.stroke();
572
+ });
573
+ };
574
+
575
+ const projectPoint = (x, y, z, scale, rotation, width, height) => {
576
+ const cos = Math.cos(rotation);
577
+ const sin = Math.sin(rotation);
578
+ const rx = x * cos - z * sin;
579
+ const rz = x * sin + z * cos + 4;
580
+ const perspective = scale / rz;
581
+ return [width / 2 + rx * perspective * 4, height / 2 + y * perspective * 4];
582
+ };
583
+
584
+ const drawShapes = (width, height, params, audio) => {
585
+ const shapes = Array.isArray(params.shapes) ? params.shapes : [];
586
+ shapes.forEach((shape, shapeIndex) => {
587
+ const resolved = shapeParams(shape, audio);
588
+ if (resolved.type === "circle") {
589
+ const count = Math.max(1, Number(resolved.count || 1));
590
+ for (let index = 0; index < count; index += 1) {
591
+ const radius = Number(resolved.radius || 90) + index * 42;
592
+ context.strokeStyle = color(index + shapeIndex, 0.5);
593
+ context.lineWidth = Number(resolved.stroke || 3);
594
+ context.beginPath();
595
+ context.arc(width / 2, height / 2, radius, 0, Math.PI * 2);
596
+ context.stroke();
597
+ }
598
+ }
599
+ if (resolved.type === "line") {
600
+ context.strokeStyle = color(shapeIndex, 0.55);
601
+ context.lineWidth = Number(resolved.stroke || 2);
602
+ context.beginPath();
603
+ context.moveTo(Number(resolved.x1 || 0), Number(resolved.y1 || height / 2));
604
+ context.lineTo(Number(resolved.x2 || width), Number(resolved.y2 || height / 2));
605
+ context.stroke();
606
+ }
607
+ });
608
+ };
609
+
610
+ const color = (index, alpha = 1) => {
611
+ const palette = [
612
+ [36, 246, 255],
613
+ [255, 43, 189],
614
+ [202, 255, 46],
615
+ [250, 204, 21],
616
+ [251, 113, 133]
617
+ ];
618
+ const entry = palette[index % palette.length];
619
+ return `rgba(${entry[0]}, ${entry[1]}, ${entry[2]}, ${alpha})`;
620
+ };
621
+
622
+ const updateStats = (audio) => {
623
+ const scene = currentScene();
624
+ sceneStat.textContent = `Scene: ${scene?.name || "--"}`;
625
+ audioStat.textContent = `Amplitude: ${audio.amplitude.toFixed(3)}`;
626
+ beatStat.textContent = `Beat: ${audio.beat ? "ON" : "off"} | BPM: ${audio.bpm}`;
627
+ beatStat.classList.toggle("active", audio.beat);
628
+ };
629
+
630
+ const bindEvents = () => {
631
+ runButton.addEventListener("click", runEditor);
632
+ resetButton.addEventListener("click", () => {
633
+ editor.value = presets[presetSelect.value].source;
634
+ runEditor();
635
+ });
636
+ presetSelect.addEventListener("change", () => {
637
+ editor.value = presets[presetSelect.value].source;
638
+ runEditor();
639
+ });
640
+ window.addEventListener("resize", resizeCanvas);
641
+ };
642
+
643
+ populatePresets();
644
+ bindEvents();
645
+ restartWorker();
646
+ runEditor();
647
+ animationFrame = requestAnimationFrame(render);
648
+
649
+ window.addEventListener("beforeunload", () => {
650
+ cancelAnimationFrame(animationFrame);
651
+ if (worker) worker.terminate();
652
+ });