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.
- checksums.yaml +4 -4
- data/README.md +70 -117
- data/docs/.nojekyll +0 -0
- data/docs/assets/playground-worker.js +373 -0
- data/docs/assets/playground.css +440 -0
- data/docs/assets/playground.js +652 -0
- data/docs/assets/site.css +744 -0
- data/docs/assets/vizcore-demo.gif +0 -0
- data/docs/assets/vizcore-poster.png +0 -0
- data/docs/assets/vj-tunnel.js +159 -0
- data/docs/index.html +225 -0
- data/docs/playground.html +81 -0
- data/docs/shape_dsl.md +269 -0
- data/examples/README.md +59 -0
- data/examples/assets/README.md +19 -0
- data/examples/audio_inspector.rb +34 -0
- data/examples/club_intro_drop.rb +78 -0
- data/examples/kansai_rubykaigi_visual.rb +70 -0
- data/examples/live_coding_minimal.rb +22 -0
- data/examples/midi_controller_show.rb +78 -0
- data/examples/midi_scene_switch.rb +3 -1
- data/examples/parser_visualizer.rb +48 -0
- data/examples/readme_demo.rb +17 -0
- data/examples/rhythm_geometry.rb +34 -0
- data/examples/ruby_crystal_show.rb +35 -0
- data/examples/shader_playground.rb +18 -0
- data/examples/unyo_liquid.rb +59 -0
- data/examples/vj_ambient_chill_room.rb +124 -0
- data/examples/vj_dnb_jungle.rb +170 -0
- data/examples/vj_festival_mainstage.rb +245 -0
- data/examples/vj_festival_mainstage.yml +17 -0
- data/examples/vj_glitch_industrial.rb +164 -0
- data/examples/vj_hiphop_cipher.rb +167 -0
- data/examples/vj_jpop_idol_live.rb +210 -0
- data/examples/vj_synthwave_retro.rb +173 -0
- data/examples/vj_techno_warehouse.rb +195 -0
- data/frontend/index.html +494 -2
- data/frontend/src/audio-inspector.js +40 -0
- data/frontend/src/custom-shape-param-controls.js +106 -0
- data/frontend/src/live-controls.js +131 -0
- data/frontend/src/main.js +1060 -16
- data/frontend/src/mapping-target-selector.js +109 -0
- data/frontend/src/midi-learn.js +194 -0
- data/frontend/src/performance-monitor.js +183 -0
- data/frontend/src/plugin-runtime.js +130 -0
- data/frontend/src/projector-mode.js +56 -0
- data/frontend/src/renderer/engine.js +157 -3
- data/frontend/src/renderer/layer-manager.js +442 -30
- data/frontend/src/renderer/shader-manager.js +26 -0
- data/frontend/src/runtime-control-preset.js +11 -0
- data/frontend/src/shader-error-overlay.js +29 -0
- data/frontend/src/shader-param-controls.js +93 -0
- data/frontend/src/shaders/builtins.js +380 -2
- data/frontend/src/shaders/post-effects.js +52 -0
- data/frontend/src/shape-editor-controls.js +157 -0
- data/frontend/src/visual-regression.js +67 -0
- data/frontend/src/visual-settings-preset.js +103 -0
- data/frontend/src/visuals/geometry.js +666 -0
- data/frontend/src/visuals/image-renderer.js +291 -0
- data/frontend/src/visuals/particle-system.js +56 -10
- data/frontend/src/visuals/shape-renderer.js +475 -0
- data/frontend/src/visuals/spectrogram-renderer.js +226 -0
- data/frontend/src/visuals/svg-arc.js +104 -0
- data/frontend/src/visuals/text-renderer.js +112 -11
- data/frontend/src/websocket-client.js +12 -1
- data/lib/vizcore/analysis/adaptive_normalizer.rb +70 -0
- data/lib/vizcore/analysis/beat_detector.rb +4 -2
- data/lib/vizcore/analysis/bpm_estimator.rb +8 -0
- data/lib/vizcore/analysis/feature_recorder.rb +159 -0
- data/lib/vizcore/analysis/feature_replay.rb +84 -0
- data/lib/vizcore/analysis/pipeline.rb +235 -11
- data/lib/vizcore/analysis/tap_tempo.rb +74 -0
- data/lib/vizcore/analysis.rb +4 -0
- data/lib/vizcore/audio/dummy_sine_input.rb +1 -1
- data/lib/vizcore/audio/fixture_input.rb +65 -0
- data/lib/vizcore/audio/input_manager.rb +4 -2
- data/lib/vizcore/audio/mic_input.rb +24 -8
- data/lib/vizcore/audio/portaudio_ffi.rb +106 -1
- data/lib/vizcore/audio.rb +1 -0
- data/lib/vizcore/cli/doctor.rb +159 -0
- data/lib/vizcore/cli/dsl_reference.rb +99 -0
- data/lib/vizcore/cli/layer_docs.rb +46 -0
- data/lib/vizcore/cli/scene_diagnostics.rb +23 -0
- data/lib/vizcore/cli/scene_inspector.rb +136 -0
- data/lib/vizcore/cli/scene_validator.rb +337 -0
- data/lib/vizcore/cli/shader_template.rb +68 -0
- data/lib/vizcore/cli/shader_uniform_docs.rb +54 -0
- data/lib/vizcore/cli.rb +689 -18
- data/lib/vizcore/config.rb +103 -2
- data/lib/vizcore/control_preset.rb +68 -0
- data/lib/vizcore/dsl/engine.rb +277 -5
- data/lib/vizcore/dsl/layer_builder.rb +1280 -23
- data/lib/vizcore/dsl/layer_group_builder.rb +112 -0
- data/lib/vizcore/dsl/mapping_resolver.rb +290 -7
- data/lib/vizcore/dsl/mapping_transform_builder.rb +71 -0
- data/lib/vizcore/dsl/reaction_builder.rb +44 -0
- data/lib/vizcore/dsl/scene_builder.rb +61 -5
- data/lib/vizcore/dsl/shader_source_resolver.rb +67 -6
- data/lib/vizcore/dsl/style_builder.rb +68 -0
- data/lib/vizcore/dsl/timeline_builder.rb +138 -0
- data/lib/vizcore/dsl/transition_controller.rb +77 -0
- data/lib/vizcore/dsl.rb +5 -1
- data/lib/vizcore/layer_catalog.rb +275 -0
- data/lib/vizcore/project_manifest.rb +152 -0
- data/lib/vizcore/renderer/png_writer.rb +57 -0
- data/lib/vizcore/renderer/render_sequence.rb +153 -0
- data/lib/vizcore/renderer/scene_frame_source.rb +132 -0
- data/lib/vizcore/renderer/scene_serializer.rb +36 -3
- data/lib/vizcore/renderer/snapshot.rb +38 -0
- data/lib/vizcore/renderer/snapshot_renderer.rb +938 -0
- data/lib/vizcore/renderer.rb +5 -0
- data/lib/vizcore/server/frame_broadcaster.rb +143 -8
- data/lib/vizcore/server/gallery_app.rb +155 -0
- data/lib/vizcore/server/gallery_page.rb +100 -0
- data/lib/vizcore/server/gallery_runner.rb +48 -0
- data/lib/vizcore/server/rack_app.rb +203 -4
- data/lib/vizcore/server/runner.rb +391 -22
- data/lib/vizcore/server/scene_dependency_watcher.rb +79 -0
- data/lib/vizcore/server/websocket_handler.rb +60 -10
- data/lib/vizcore/server.rb +4 -0
- data/lib/vizcore/shape.rb +719 -0
- data/lib/vizcore/sync/osc_message.rb +103 -0
- data/lib/vizcore/sync/osc_receiver.rb +68 -0
- data/lib/vizcore/sync.rb +4 -0
- data/lib/vizcore/templates/midi_control_scene.rb +3 -1
- data/lib/vizcore/templates/plugin_layer.rb +20 -0
- data/lib/vizcore/templates/plugin_readme.md +23 -0
- data/lib/vizcore/templates/plugin_renderer.js +43 -0
- data/lib/vizcore/templates/plugin_scene.rb +14 -0
- data/lib/vizcore/templates/project_readme.md +7 -23
- data/lib/vizcore/templates/rubykaigi_scene.rb +30 -0
- data/lib/vizcore/version.rb +1 -1
- data/lib/vizcore.rb +28 -0
- data/scripts/browser_capture.mjs +75 -0
- data/sig/vizcore.rbs +461 -0
- metadata +94 -3
- 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
|
+
});
|