vizcore 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +66 -648
  3. data/docs/assets/playground-worker.js +373 -0
  4. data/docs/assets/playground.css +440 -0
  5. data/docs/assets/playground.js +652 -0
  6. data/docs/index.html +2 -1
  7. data/docs/playground.html +81 -0
  8. data/docs/shape_dsl.md +269 -0
  9. data/frontend/index.html +50 -2
  10. data/frontend/src/audio-inspector.js +9 -0
  11. data/frontend/src/custom-shape-param-controls.js +106 -0
  12. data/frontend/src/live-controls.js +219 -7
  13. data/frontend/src/main.js +703 -45
  14. data/frontend/src/mapping-target-selector.js +109 -0
  15. data/frontend/src/midi-learn.js +22 -2
  16. data/frontend/src/performance-monitor.js +137 -1
  17. data/frontend/src/renderer/engine.js +401 -11
  18. data/frontend/src/renderer/layer-manager.js +490 -75
  19. data/frontend/src/runtime-control-preset.js +44 -0
  20. data/frontend/src/scene-patches.js +159 -0
  21. data/frontend/src/shader-error-overlay.js +1 -0
  22. data/frontend/src/shape-editor-controls.js +157 -0
  23. data/frontend/src/visuals/geometry.js +425 -27
  24. data/frontend/src/visuals/image-renderer.js +19 -0
  25. data/frontend/src/visuals/particle-system.js +10 -0
  26. data/frontend/src/visuals/shape-renderer.js +488 -0
  27. data/frontend/src/visuals/spectrogram-renderer.js +14 -0
  28. data/frontend/src/visuals/svg-arc.js +104 -0
  29. data/frontend/src/visuals/text-renderer.js +13 -0
  30. data/frontend/src/websocket-client.js +6 -0
  31. data/lib/vizcore/analysis/adaptive_normalizer.rb +20 -2
  32. data/lib/vizcore/analysis/bpm_estimator.rb +18 -8
  33. data/lib/vizcore/analysis/feature_recorder.rb +117 -7
  34. data/lib/vizcore/analysis/feature_replay.rb +48 -9
  35. data/lib/vizcore/analysis/pipeline.rb +258 -9
  36. data/lib/vizcore/analysis/tap_tempo.rb +17 -2
  37. data/lib/vizcore/audio/calibration.rb +156 -0
  38. data/lib/vizcore/audio/file_input.rb +28 -0
  39. data/lib/vizcore/audio/input_manager.rb +36 -1
  40. data/lib/vizcore/audio/midi_input.rb +5 -0
  41. data/lib/vizcore/audio/ring_buffer.rb +22 -0
  42. data/lib/vizcore/audio.rb +1 -0
  43. data/lib/vizcore/cli/dsl_reference.rb +65 -9
  44. data/lib/vizcore/cli/plugin_checker.rb +93 -0
  45. data/lib/vizcore/cli/scene_diagnostics.rb +2 -2
  46. data/lib/vizcore/cli/scene_inspector.rb +35 -1
  47. data/lib/vizcore/cli/scene_validator.rb +573 -33
  48. data/lib/vizcore/cli/shader_template.rb +7 -2
  49. data/lib/vizcore/cli/shader_uniform_docs.rb +11 -0
  50. data/lib/vizcore/cli.rb +268 -15
  51. data/lib/vizcore/config.rb +40 -3
  52. data/lib/vizcore/control_preset.rb +29 -0
  53. data/lib/vizcore/deep_copy.rb +21 -0
  54. data/lib/vizcore/dsl/color_helpers.rb +155 -0
  55. data/lib/vizcore/dsl/engine.rb +219 -23
  56. data/lib/vizcore/dsl/layer_builder.rb +1072 -21
  57. data/lib/vizcore/dsl/layer_group_builder.rb +10 -12
  58. data/lib/vizcore/dsl/layout_helpers.rb +290 -0
  59. data/lib/vizcore/dsl/mapping_preset_builder.rb +41 -0
  60. data/lib/vizcore/dsl/mapping_resolver.rb +549 -13
  61. data/lib/vizcore/dsl/mapping_transform_builder.rb +50 -0
  62. data/lib/vizcore/dsl/midi_map_executor.rb +219 -23
  63. data/lib/vizcore/dsl/reaction_builder.rb +1 -0
  64. data/lib/vizcore/dsl/scene_builder.rb +83 -13
  65. data/lib/vizcore/dsl/shader_source_resolver.rb +1 -10
  66. data/lib/vizcore/dsl/style_builder.rb +3 -0
  67. data/lib/vizcore/dsl/timeline_builder.rb +91 -8
  68. data/lib/vizcore/dsl/transition_controller.rb +157 -18
  69. data/lib/vizcore/dsl.rb +2 -0
  70. data/lib/vizcore/layer_catalog.rb +5 -2
  71. data/lib/vizcore/plugin_asset_policy.rb +55 -0
  72. data/lib/vizcore/project_manifest.rb +12 -2
  73. data/lib/vizcore/renderer/render_sequence.rb +104 -13
  74. data/lib/vizcore/renderer/scene_frame_source.rb +190 -12
  75. data/lib/vizcore/renderer/scene_serializer.rb +38 -0
  76. data/lib/vizcore/renderer/snapshot.rb +4 -3
  77. data/lib/vizcore/renderer/snapshot_renderer.rb +641 -23
  78. data/lib/vizcore/scene_trust.rb +31 -0
  79. data/lib/vizcore/server/frame_broadcaster.rb +513 -18
  80. data/lib/vizcore/server/rack_app.rb +151 -4
  81. data/lib/vizcore/server/runner.rb +697 -82
  82. data/lib/vizcore/server/websocket_handler.rb +236 -14
  83. data/lib/vizcore/server.rb +21 -0
  84. data/lib/vizcore/shape.rb +742 -0
  85. data/lib/vizcore/sync/osc_message.rb +66 -9
  86. data/lib/vizcore/version.rb +1 -1
  87. data/lib/vizcore.rb +34 -0
  88. data/scripts/browser_capture.mjs +31 -2
  89. data/sig/vizcore.rbs +154 -4
  90. metadata +29 -3
data/frontend/src/main.js CHANGED
@@ -1,10 +1,12 @@
1
1
  import { BAND_KEYS, DEFAULT_FFT_BINS, buildAudioInspectorState, formatMeterValue } from "./audio-inspector.js";
2
2
  import {
3
3
  createLiveControlState,
4
+ isLiveControlEnabled,
4
5
  isTapTempoShortcut,
5
6
  keyboardActionForKey,
6
7
  liveControlStatusText,
7
8
  normalizeKeyboardMappings,
9
+ normalizeLiveControlPayload,
8
10
  shortcutActionForKey,
9
11
  shortcutSceneIndexForKey,
10
12
  toggleLiveControl,
@@ -15,7 +17,10 @@ import {
15
17
  recordConnectionStatus,
16
18
  recordLatencyProbe,
17
19
  recordRenderFrame,
20
+ recordRendererCapabilities,
21
+ recordRendererSafeMode,
18
22
  recordShaderCompile,
23
+ recordWebSocketBackpressure,
19
24
  recordSocketFrame,
20
25
  } from "./performance-monitor.js";
21
26
  import {
@@ -29,12 +34,26 @@ import {
29
34
  upsertMidiLearnBinding,
30
35
  } from "./midi-learn.js";
31
36
  import { applyProjectorMode, resolveProjectorMode } from "./projector-mode.js";
32
- import { Engine } from "./renderer/engine.js";
37
+ import { Engine, RENDERER_CAPABILITIES_EVENT, RENDERER_SAFE_MODE_EVENT } from "./renderer/engine.js";
33
38
  import { SHADER_COMPILE_EVENT } from "./renderer/shader-manager.js";
39
+ import {
40
+ customShapeParamControlEntries,
41
+ customShapeParamMessage,
42
+ pruneCustomShapeParamOverrides
43
+ } from "./custom-shape-param-controls.js";
44
+ import {
45
+ mappingTargetOptions,
46
+ mappingTargetSignature
47
+ } from "./mapping-target-selector.js";
34
48
  import {
35
49
  pruneShaderParamOverrides,
36
50
  shaderParamControlEntries
37
51
  } from "./shader-param-controls.js";
52
+ import {
53
+ normalizeShapeEditorPatch,
54
+ pruneShapeEditorOverrides,
55
+ shapeEditorEntries
56
+ } from "./shape-editor-controls.js";
38
57
  import { normalizeRuntimeControlPreset } from "./runtime-control-preset.js";
39
58
  import { SHADER_ERROR_EVENT, formatShaderErrorMessage, formatShaderErrorTitle } from "./shader-error-overlay.js";
40
59
  import {
@@ -44,6 +63,7 @@ import {
44
63
  saveVisualSettingsPreset,
45
64
  visualSettingFromUnit
46
65
  } from "./visual-settings-preset.js";
66
+ import { applyScenePayload, resolveScenePayload } from "./scene-patches.js";
47
67
  import { WebSocketClient } from "./websocket-client.js";
48
68
 
49
69
  window.__vizcoreMainStarted = true;
@@ -53,6 +73,7 @@ const wsStatusElement = document.querySelector("#ws-status");
53
73
  const sceneStatusElement = document.querySelector("#scene-status");
54
74
  const transitionStatusElement = document.querySelector("#transition-status");
55
75
  const frameStatusElement = document.querySelector("#frame-status");
76
+ const runtimeErrorStatusElement = document.querySelector("#runtime-error-status");
56
77
  const bpmStatusElement = document.querySelector("#bpm-status");
57
78
  const beatStatusElement = document.querySelector("#beat-status");
58
79
  const blackoutButton = document.querySelector("#blackout-toggle");
@@ -73,6 +94,7 @@ const inspectorBandElements = Object.fromEntries(
73
94
  );
74
95
  const fftPreviewElement = document.querySelector("#fft-preview");
75
96
  const audioSourceStatusElement = document.querySelector("#audio-source-status");
97
+ const audioHealthStatusElement = document.querySelector("#audio-health-status");
76
98
  const audioTrackStatusElement = document.querySelector("#audio-track-status");
77
99
  const audioPlaybackStatusElement = document.querySelector("#audio-playback-status");
78
100
  const sceneSwitcherElement = document.querySelector("#scene-switcher");
@@ -91,6 +113,9 @@ const reactivityStatusElement = document.querySelector("#reactivity-status");
91
113
  const midiLearnStatusElement = document.querySelector("#midi-learn-status");
92
114
  const midiLearnButtons = Array.from(document.querySelectorAll("[data-midi-learn-action]"));
93
115
  const shaderParamControlsElement = document.querySelector("#shader-param-controls");
116
+ const shapeEditorControlsElement = document.querySelector("#shape-editor-controls");
117
+ const customShapeParamControlsElement = document.querySelector("#custom-shape-param-controls");
118
+ const mappingTargetSelectorElement = document.querySelector("#mapping-target-selector");
94
119
  const shaderErrorOverlay = document.querySelector("#shader-error-overlay");
95
120
  const shaderErrorTitleElement = document.querySelector("#shader-error-title");
96
121
  const shaderErrorMessageElement = document.querySelector("#shader-error-message");
@@ -116,15 +141,27 @@ let pendingSceneRequestedAt = 0;
116
141
  let tapTempoKey = null;
117
142
  let runtimeGlobalsReceived = false;
118
143
  let runtimeControlPresetApplied = false;
144
+ let runtimeControlPresetSceneApplied = null;
145
+ let runtimeControlPresetSceneOverrides = {};
146
+ let runtimeControlPresetVisualBase = null;
147
+ let runtimeControlPresetMidiBase = null;
119
148
  let controlPresetSaveUrl = null;
120
149
  let shaderParamOverrides = {};
150
+ let scenePayload = null;
121
151
  let shaderParamControlsSignature = "";
152
+ let shapeEditorOverrides = {};
153
+ let shapeEditorControlsSignature = "";
154
+ let customShapeParamOverrides = {};
155
+ let customShapeParamControlsSignature = "";
156
+ let mappingTargetSelectorSignature = "";
157
+ let selectedMappingTarget = "";
122
158
  let midiAccess = null;
123
159
  let pendingMidiLearnAction = null;
124
160
  applyProjectorMode(document.body, projectorMode);
125
161
  const engine = new Engine(canvas);
126
162
  let rendererReady = false;
127
163
  bindShaderCompileMetrics();
164
+ bindRendererMetrics();
128
165
  try {
129
166
  engine.init();
130
167
  rendererReady = true;
@@ -160,10 +197,26 @@ startPerformanceMonitorLoop();
160
197
  const websocketUrl = buildWebSocketUrl();
161
198
  const client = new WebSocketClient(websocketUrl, {
162
199
  onFrame: (frame) => {
200
+ const resolvedScene = resolveScenePayload({
201
+ incomingScene: frame?.scene,
202
+ currentScene: scenePayload,
203
+ frameVersion: frame?.scene_version,
204
+ });
205
+ if (resolvedScene) {
206
+ scenePayload = resolvedScene;
207
+ }
208
+ const normalizedFrame = {
209
+ ...frame,
210
+ scene: scenePayload
211
+ };
212
+
163
213
  updatePerformanceMonitor(recordSocketFrame(performanceMonitor, frame, Date.now()));
164
- engine.setAudioFrame(frame);
214
+ engine.setAudioFrame(normalizedFrame);
165
215
  frameCount += 1;
166
- let sceneName = String(frame?.scene?.name || currentSceneName);
216
+ const scene = scenePayload;
217
+ let sceneName = String(scene?.name || currentSceneName);
218
+ document.body.dataset.vizcoreFrameCount = String(frameCount);
219
+ document.body.dataset.vizcoreScene = sceneName;
167
220
  const now = performance.now();
168
221
  if (
169
222
  pendingSceneName &&
@@ -176,12 +229,8 @@ const client = new WebSocketClient(websocketUrl, {
176
229
  pendingSceneName = null;
177
230
  pendingSceneRequestedAt = 0;
178
231
  }
179
- const sceneChanged = sceneName !== currentSceneName;
180
- currentSceneName = sceneName;
181
- if (sceneChanged) {
182
- shaderParamControlsSignature = "";
183
- }
184
- updateShaderParamControls(frame?.scene?.layers);
232
+ applyRuntimeControlPresetForScene(sceneName);
233
+ updateSceneControls(scene);
185
234
  const amplitude = Number(frame?.audio?.amplitude || 0).toFixed(4);
186
235
  const bpm = Number(frame?.audio?.bpm || 0);
187
236
  const beat = !!frame?.audio?.beat;
@@ -191,9 +240,6 @@ const client = new WebSocketClient(websocketUrl, {
191
240
  }
192
241
  const beatVisible = performance.now() < beatFlashUntil;
193
242
  sceneStatusElement.textContent = `Scene: ${sceneName}`;
194
- if (sceneChanged) {
195
- renderSceneButtons();
196
- }
197
243
  frameStatusElement.textContent = `Amplitude: ${amplitude} | Frames: ${frameCount}`;
198
244
  bpmStatusElement.textContent = `BPM: ${bpm > 0 ? bpm.toFixed(1) : "--"}`;
199
245
  beatStatusElement.textContent = `Beat: ${beatVisible ? "ON" : "off"} | Count: ${beatCount}`;
@@ -207,6 +253,7 @@ const client = new WebSocketClient(websocketUrl, {
207
253
  pendingSceneRequestedAt = 0;
208
254
  currentSceneName = to;
209
255
  sceneStatusElement.textContent = `Scene: ${to}`;
256
+ applyRuntimeControlPresetForScene(to, { force: true });
210
257
  transitionStatusElement.textContent = `Transition: ${from} -> ${to}`;
211
258
  renderSceneButtons();
212
259
  },
@@ -214,11 +261,9 @@ const client = new WebSocketClient(websocketUrl, {
214
261
  updateAvailableScenes(payload?.scenes);
215
262
  const sceneName = payload?.scene?.name;
216
263
  if (sceneName) {
217
- currentSceneName = String(sceneName);
218
- sceneStatusElement.textContent = `Scene: ${currentSceneName}`;
219
- renderSceneButtons();
220
- shaderParamControlsSignature = "";
221
- updateShaderParamControls(payload?.scene?.layers);
264
+ scenePayload = applyScenePayload(payload.scene);
265
+ applyRuntimeControlPresetForScene(sceneName, { force: true });
266
+ updateSceneControls(scenePayload, { forceReset: true });
222
267
  }
223
268
  if (Object.prototype.hasOwnProperty.call(payload || {}, "tap_tempo_key")) {
224
269
  updateTapTempoKey(payload?.tap_tempo_key);
@@ -232,7 +277,6 @@ const client = new WebSocketClient(websocketUrl, {
232
277
  }
233
278
  if (Object.prototype.hasOwnProperty.call(payload || {}, "live_controls")) {
234
279
  applyLiveControls({
235
- ...liveControls,
236
280
  ...normalizeLiveControls(payload?.live_controls),
237
281
  });
238
282
  }
@@ -240,12 +284,18 @@ const client = new WebSocketClient(websocketUrl, {
240
284
  onLatencyProbe: (payload) => {
241
285
  updatePerformanceMonitor(recordLatencyProbe(performanceMonitor, payload, Date.now()));
242
286
  },
287
+ onRuntimeError: (payload) => {
288
+ updateRuntimeErrorStatus(payload);
289
+ },
243
290
  onStatus: (status) => {
244
291
  updatePerformanceMonitor(recordConnectionStatus(performanceMonitor, status));
245
292
  if (status === "connected") {
246
293
  lastConnectedAt = new Date();
247
294
  startLatencyProbeLoop();
248
295
  syncAudioTransportToServer({ force: true });
296
+ if (runtimeErrorStatusElement) {
297
+ runtimeErrorStatusElement.textContent = "Runtime: ok";
298
+ }
249
299
  } else {
250
300
  stopLatencyProbeLoop();
251
301
  pendingSceneName = null;
@@ -253,9 +303,12 @@ const client = new WebSocketClient(websocketUrl, {
253
303
  currentSceneName = "unknown";
254
304
  sceneStatusElement.textContent = "Scene: unknown";
255
305
  renderSceneButtons();
306
+ if (runtimeErrorStatusElement) {
307
+ runtimeErrorStatusElement.textContent = "Runtime: disconnected";
308
+ }
256
309
  }
257
- const connectedAt = lastConnectedAt ? ` | Last connected: ${formatClock(lastConnectedAt)}` : "";
258
- wsStatusElement.textContent = `WebSocket: ${status} (${websocketUrl})${connectedAt}`;
310
+ const connectedAt = lastConnectedAt ? ` | Last connected: ${formatClock(lastConnectedAt)}` : "";
311
+ wsStatusElement.textContent = `WebSocket: ${status} (${websocketUrl})${connectedAt}`;
259
312
  }
260
313
  });
261
314
 
@@ -298,9 +351,11 @@ function applyRuntime(runtime) {
298
351
  applyRuntimeGlobals(runtime?.globals);
299
352
  }
300
353
  applyRuntimeControlPreset(runtime?.control_preset);
354
+ updatePerformanceMonitor(recordWebSocketBackpressure(performanceMonitor, runtime?.websocket_backpressure));
301
355
 
302
356
  const fileName = runtime?.audio_file_name;
303
357
  const fileUrl = runtime?.audio_file_url;
358
+ applyRuntimeAudioInputHealth(runtime?.input);
304
359
  if (!fileUrl) {
305
360
  engine.setMediaElement(null);
306
361
  audioTrackStatusElement.textContent = "Track: none";
@@ -313,6 +368,33 @@ function applyRuntime(runtime) {
313
368
  setupAudioPlayback(fileUrl);
314
369
  }
315
370
 
371
+ function applyRuntimeAudioInputHealth(input) {
372
+ if (!audioHealthStatusElement) {
373
+ return;
374
+ }
375
+
376
+ const source = String(input?.source || "unknown");
377
+ const sampleRate = Number(input?.sample_rate);
378
+ const requestedSampleRate = Number(input?.requested_sample_rate);
379
+ const frameSize = Number(input?.frame_size);
380
+ const ringBuffer = input?.ring_buffer || {};
381
+ const overrun = Number(ringBuffer?.overrun_count || 0);
382
+ const underrun = Number(ringBuffer?.underrun_count || 0);
383
+ const sampleRateText = Number.isFinite(sampleRate) && sampleRate > 0
384
+ ? `${Math.round(sampleRate)}Hz`
385
+ : "--";
386
+ const requestedSampleRateText = Number.isFinite(requestedSampleRate) &&
387
+ requestedSampleRate > 0 &&
388
+ requestedSampleRate !== sampleRate
389
+ ? ` (${Math.round(requestedSampleRate)}Hz requested)`
390
+ : "";
391
+ const frameText = Number.isFinite(frameSize) && frameSize > 0
392
+ ? ` | Frame ${Math.round(frameSize)}`
393
+ : "";
394
+
395
+ audioHealthStatusElement.textContent = `Input: ${source} | Sample ${sampleRateText}${requestedSampleRateText} | ${frameText} | Overrun ${Math.max(0, overrun)} | Underrun ${Math.max(0, underrun)}`;
396
+ }
397
+
316
398
  function applyRuntimeGlobals(globals) {
317
399
  engine.setRuntimeGlobals(globals);
318
400
  }
@@ -326,28 +408,205 @@ function updateControlPresetPersistence(runtime) {
326
408
  }
327
409
 
328
410
  function applyRuntimeControlPreset(value) {
329
- if (runtimeControlPresetApplied) {
411
+ if (!value || runtimeControlPresetApplied) {
330
412
  return;
331
413
  }
332
414
 
333
415
  const preset = normalizeRuntimeControlPreset(value);
334
- let applied = false;
335
- if (preset.visualSettings) {
336
- const imported = importVisualSettingsPreset({ visual_settings: preset.visualSettings }, { fallback: visualSettings });
337
- Object.assign(visualSettings, saveVisualSettingsPreset(browserStorage(), imported));
416
+ const hasVisual = Boolean(preset.visualSettings);
417
+ const hasMidi = Boolean(preset.midiLearnBindings);
418
+ const hasSceneOverrides = preset.sceneOverrides && Object.keys(preset.sceneOverrides).length > 0;
419
+ if (!hasVisual && !hasMidi && !hasSceneOverrides) {
420
+ return;
421
+ }
422
+
423
+ if (hasVisual) {
424
+ const imported = importVisualSettingsPreset(
425
+ { visual_settings: preset.visualSettings },
426
+ { fallback: visualSettings }
427
+ );
428
+ Object.assign(visualSettings, imported);
429
+ runtimeControlPresetVisualBase = cloneRuntimeValue(visualSettings);
430
+ saveVisualSettingsPreset(browserStorage(), visualSettings);
338
431
  syncVisualControls();
339
432
  engine.setVisualSettings(visualSettings);
340
433
  renderReactivityStatus("Project preset");
341
- applied = true;
434
+ } else {
435
+ runtimeControlPresetVisualBase = cloneRuntimeValue(visualSettings);
342
436
  }
343
437
 
344
- if (preset.midiLearnBindings) {
345
- midiLearnBindings = saveMidiLearnBindings(browserStorage(), preset.midiLearnBindings);
438
+ if (hasMidi) {
439
+ midiLearnBindings = cloneRuntimeValue(preset.midiLearnBindings);
440
+ runtimeControlPresetMidiBase = cloneRuntimeValue(midiLearnBindings);
441
+ saveMidiLearnBindings(browserStorage(), midiLearnBindings);
346
442
  renderMidiLearnStatus();
347
- applied = true;
443
+ } else {
444
+ runtimeControlPresetMidiBase = cloneRuntimeValue(midiLearnBindings);
445
+ }
446
+
447
+ runtimeControlPresetSceneOverrides = cloneRuntimeValue(preset.sceneOverrides || {});
448
+ runtimeControlPresetApplied = true;
449
+ runtimeControlPresetSceneApplied = null;
450
+ applyRuntimeControlPresetForScene(currentSceneName, { force: true });
451
+ }
452
+
453
+ function applyRuntimeControlPresetForScene(sceneName, { force = false } = {}) {
454
+ if (!runtimeControlPresetApplied) {
455
+ return;
456
+ }
457
+
458
+ const normalizedScene = normalizeSceneName(sceneName || currentSceneName);
459
+ if (!force && runtimeControlPresetSceneApplied === normalizedScene) {
460
+ return;
461
+ }
462
+ runtimeControlPresetSceneApplied = normalizedScene;
463
+
464
+ const override = normalizeRuntimeSceneOverride(runtimeControlPresetSceneOverrides[normalizedScene]) || {};
465
+ const nextVisual = deriveSceneVisualSettings(runtimeControlPresetVisualBase, override.visualSettings);
466
+ if (nextVisual) {
467
+ const hasVisualChanges = hasVisualSettingsChanges(visualSettings, nextVisual);
468
+ if (hasVisualChanges) {
469
+ Object.assign(visualSettings, nextVisual);
470
+ syncVisualControls();
471
+ engine.setVisualSettings(visualSettings);
472
+ renderReactivityStatus("Project preset");
473
+ }
474
+ }
475
+
476
+ const nextBindings = deriveSceneMidiBindings(runtimeControlPresetMidiBase, override.midiLearnBindings);
477
+ if (nextBindings && hasMidiLearnBindingChanges(midiLearnBindings, nextBindings)) {
478
+ midiLearnBindings = nextBindings;
479
+ renderMidiLearnStatus();
480
+ }
481
+ }
482
+
483
+ function deriveSceneVisualSettings(baseSettings, sceneVisualSettings) {
484
+ const base = baseSettings && typeof baseSettings === "object" ? cloneRuntimeValue(baseSettings) : {};
485
+ if (sceneVisualSettings) {
486
+ return Object.assign(base, cloneRuntimeValue(sceneVisualSettings));
487
+ }
488
+ return Object.keys(base).length ? base : null;
489
+ }
490
+
491
+ function deriveSceneMidiBindings(baseBindings, sceneMidiBindings) {
492
+ const base = baseBindings && typeof baseBindings === "object" ? cloneRuntimeValue(baseBindings) : {};
493
+ if (sceneMidiBindings) {
494
+ return Object.assign(base, cloneRuntimeValue(sceneMidiBindings));
495
+ }
496
+ return Object.keys(base).length ? base : null;
497
+ }
498
+
499
+ function normalizeRuntimeSceneOverride(value) {
500
+ if (!value || typeof value !== "object") {
501
+ return null;
502
+ }
503
+
504
+ const input = value && typeof value === "object" ? value : {};
505
+ const output = {};
506
+ const visualSettings = objectValue(input.visualSettings) || objectValue(input.visual_settings);
507
+ const midiLearnBindings = objectValue(input.midiLearnBindings) || objectValue(input.midi_learn_bindings);
508
+ if (visualSettings) {
509
+ output.visualSettings = visualSettings;
510
+ }
511
+ if (midiLearnBindings) {
512
+ output.midiLearnBindings = midiLearnBindings;
513
+ }
514
+ return Object.keys(output).length ? output : null;
515
+ }
516
+
517
+ function syncRuntimeControlPresetSceneVisualSetting(key, value) {
518
+ if (!runtimeControlPresetApplied) {
519
+ return;
520
+ }
521
+
522
+ const sceneName = normalizeSceneName(currentSceneName);
523
+ const override = runtimeControlPresetSceneOverrides[sceneName];
524
+ if (override && typeof override === "object") {
525
+ const normalizedSceneOverride = normalizeRuntimeSceneOverride(override) || {};
526
+ const currentOverride = cloneRuntimeValue(normalizedSceneOverride);
527
+ currentOverride.visualSettings ||= {};
528
+ currentOverride.visualSettings[key] = value;
529
+ runtimeControlPresetSceneOverrides[sceneName] = currentOverride;
530
+ applyRuntimeControlPresetForScene(sceneName, { force: true });
531
+ return;
532
+ }
533
+
534
+ runtimeControlPresetVisualBase = runtimeControlPresetVisualBase || cloneRuntimeValue(visualSettings);
535
+ runtimeControlPresetVisualBase[key] = value;
536
+ }
537
+
538
+ function syncRuntimeControlPresetMidiBindings(nextBindings) {
539
+ if (!runtimeControlPresetApplied) {
540
+ return;
541
+ }
542
+
543
+ const sceneName = normalizeSceneName(currentSceneName);
544
+ const normalizedBindings = objectValue(nextBindings) ? cloneRuntimeValue(nextBindings) : {};
545
+ const override = runtimeControlPresetSceneOverrides[sceneName];
546
+ if (override && typeof override === "object") {
547
+ const normalizedSceneOverride = normalizeRuntimeSceneOverride(override) || {};
548
+ const currentOverride = cloneRuntimeValue(normalizedSceneOverride);
549
+ currentOverride.midiLearnBindings = normalizedBindings;
550
+ runtimeControlPresetSceneOverrides[sceneName] = currentOverride;
551
+ applyRuntimeControlPresetForScene(sceneName, { force: true });
552
+ return;
348
553
  }
349
554
 
350
- runtimeControlPresetApplied = applied;
555
+ runtimeControlPresetMidiBase = normalizedBindings;
556
+ }
557
+
558
+ function syncRuntimeControlPresetBaseWithRuntime(nextVisualSettings = visualSettings) {
559
+ if (!runtimeControlPresetApplied) {
560
+ return;
561
+ }
562
+
563
+ runtimeControlPresetVisualBase = cloneRuntimeValue(nextVisualSettings);
564
+ }
565
+
566
+ function hasVisualSettingsChanges(currentValue, nextValue) {
567
+ return (
568
+ currentValue.visualGain !== nextValue.visualGain ||
569
+ currentValue.bassBoost !== nextValue.bassBoost ||
570
+ currentValue.smoothing !== nextValue.smoothing ||
571
+ currentValue.beatHoldMs !== nextValue.beatHoldMs ||
572
+ currentValue.wobbleAmount !== nextValue.wobbleAmount
573
+ );
574
+ }
575
+
576
+ function hasMidiLearnBindingChanges(currentBindings, nextBindings) {
577
+ if (!nextBindings) {
578
+ return false;
579
+ }
580
+ const currentKeys = Object.keys(currentBindings || {});
581
+ const nextKeys = Object.keys(nextBindings);
582
+ if (currentKeys.length !== nextKeys.length) {
583
+ return true;
584
+ }
585
+ for (const signature of nextKeys) {
586
+ if (!Object.prototype.hasOwnProperty.call(currentBindings, signature)) {
587
+ return true;
588
+ }
589
+ if (JSON.stringify(currentBindings[signature]) !== JSON.stringify(nextBindings[signature])) {
590
+ return true;
591
+ }
592
+ }
593
+ return false;
594
+ }
595
+
596
+ function normalizeSceneName(sceneName) {
597
+ return String(sceneName || "").trim() || "unknown";
598
+ }
599
+
600
+ function objectValue(value) {
601
+ return value && typeof value === "object" && !Array.isArray(value) ? value : null;
602
+ }
603
+
604
+ function cloneRuntimeValue(value) {
605
+ if (!value || typeof value !== "object") {
606
+ return {};
607
+ }
608
+
609
+ return JSON.parse(JSON.stringify(value));
351
610
  }
352
611
 
353
612
  function updateAvailableScenes(sceneValues) {
@@ -369,6 +628,35 @@ function updateKeyboardMappings(mappings) {
369
628
  renderSceneButtons();
370
629
  }
371
630
 
631
+ function updateSceneControls(scene, { forceReset = false } = {}) {
632
+ const sceneLayers = scene?.layers;
633
+ const hasLayers = Array.isArray(sceneLayers);
634
+ const nextSceneName = String(scene?.name || currentSceneName);
635
+
636
+ if (currentSceneName !== nextSceneName) {
637
+ currentSceneName = nextSceneName;
638
+ sceneStatusElement.textContent = `Scene: ${currentSceneName}`;
639
+ renderSceneButtons();
640
+ forceReset = true;
641
+ }
642
+
643
+ if (!scene) {
644
+ return;
645
+ }
646
+
647
+ if (forceReset) {
648
+ shaderParamControlsSignature = "";
649
+ shapeEditorControlsSignature = "";
650
+ customShapeParamControlsSignature = "";
651
+ mappingTargetSelectorSignature = "";
652
+ }
653
+
654
+ updateShaderParamControls(hasLayers ? sceneLayers : null);
655
+ updateShapeEditorControls(hasLayers ? sceneLayers : null);
656
+ updateCustomShapeParamControls(hasLayers ? sceneLayers : null);
657
+ updateMappingTargetSelector(hasLayers ? sceneLayers : null);
658
+ }
659
+
372
660
  function normalizeSceneNames(sceneValues) {
373
661
  const seen = new Set();
374
662
  const names = [];
@@ -494,7 +782,240 @@ function formatShaderParamValue(value) {
494
782
  return Math.abs(numeric) >= 10 ? numeric.toFixed(1) : numeric.toFixed(2);
495
783
  }
496
784
 
497
- function requestSceneSwitch(sceneName) {
785
+ function updateShapeEditorControls(layers) {
786
+ const entries = shapeEditorEntries(layers, shapeEditorOverrides);
787
+ const signature = shapeEditorControlsSignatureFor(entries);
788
+ if (signature === shapeEditorControlsSignature) {
789
+ return;
790
+ }
791
+
792
+ shapeEditorControlsSignature = signature;
793
+ shapeEditorOverrides = pruneShapeEditorOverrides(shapeEditorOverrides, entries);
794
+ engine.setShapeEditorOverrides(shapeEditorOverrides);
795
+ renderShapeEditorControls(entries);
796
+ }
797
+
798
+ function shapeEditorControlsSignatureFor(entries) {
799
+ return entries.map((entry) => (
800
+ `${entry.key}:${entry.kind}:${JSON.stringify(entry.values)}`
801
+ )).join("|");
802
+ }
803
+
804
+ function renderShapeEditorControls(entries) {
805
+ if (!shapeEditorControlsElement) {
806
+ return;
807
+ }
808
+
809
+ if (!entries.length) {
810
+ shapeEditorControlsElement.hidden = true;
811
+ shapeEditorControlsElement.replaceChildren();
812
+ return;
813
+ }
814
+
815
+ const title = document.createElement("p");
816
+ title.className = "shader-param-controls__title";
817
+ title.textContent = "Shape Editor";
818
+ const controls = entries.map((entry) => createShapeEditorControl(entry));
819
+ shapeEditorControlsElement.replaceChildren(title, ...controls);
820
+ shapeEditorControlsElement.hidden = false;
821
+ }
822
+
823
+ function createShapeEditorControl(entry) {
824
+ const section = document.createElement("details");
825
+ const summary = document.createElement("summary");
826
+ summary.textContent = entry.label;
827
+ section.append(summary);
828
+ section.append(
829
+ createShapeKindControl(entry),
830
+ createShapeNumberControl(entry, "translateX", "Move X", -640, 640, 1),
831
+ createShapeNumberControl(entry, "translateY", "Move Y", -360, 360, 1),
832
+ createShapeNumberControl(entry, "rotate", "Rotate", -180, 180, 1),
833
+ createShapeNumberControl(entry, "scaleX", "Scale X", -4, 4, 0.05),
834
+ createShapeNumberControl(entry, "scaleY", "Scale Y", -4, 4, 0.05),
835
+ createShapeNumberControl(entry, "opacity", "Opacity", 0, 1, 0.05),
836
+ createShapeColorControl(entry, "fill", "Fill"),
837
+ createShapeColorControl(entry, "strokeColor", "Stroke"),
838
+ createShapeNumberControl(entry, "strokeWidth", "Stroke W", 0, 24, 0.5)
839
+ );
840
+ return section;
841
+ }
842
+
843
+ function createShapeKindControl(entry) {
844
+ const label = document.createElement("label");
845
+ const name = document.createElement("span");
846
+ const select = document.createElement("select");
847
+ name.textContent = "Kind";
848
+ ["circle", "line", "rect", "polygon", "polyline", "path", "star"].forEach((kind) => {
849
+ const option = document.createElement("option");
850
+ option.value = kind;
851
+ option.textContent = kind;
852
+ select.append(option);
853
+ });
854
+ select.value = entry.kind;
855
+ select.addEventListener("change", () => {
856
+ writeShapeEditorOverride(entry, { ...entry.values, kind: select.value });
857
+ });
858
+ label.append(name, select);
859
+ return label;
860
+ }
861
+
862
+ function createShapeNumberControl(entry, key, labelText, min, max, step) {
863
+ const label = document.createElement("label");
864
+ const name = document.createElement("span");
865
+ const input = document.createElement("input");
866
+ const value = document.createElement("output");
867
+ name.textContent = labelText;
868
+ input.type = "range";
869
+ input.min = String(min);
870
+ input.max = String(max);
871
+ input.step = String(step);
872
+ input.value = String(entry.values[key]);
873
+ value.value = formatShaderParamValue(entry.values[key]);
874
+ input.addEventListener("input", () => {
875
+ const numeric = Number(input.value);
876
+ writeShapeEditorOverride(entry, { ...entry.values, [key]: numeric });
877
+ value.value = formatShaderParamValue(numeric);
878
+ });
879
+ label.append(name, input, value);
880
+ return label;
881
+ }
882
+
883
+ function createShapeColorControl(entry, key, labelText) {
884
+ const label = document.createElement("label");
885
+ const name = document.createElement("span");
886
+ const input = document.createElement("input");
887
+ const value = document.createElement("output");
888
+ name.textContent = labelText;
889
+ input.type = "color";
890
+ input.value = entry.values[key];
891
+ value.value = entry.values[key];
892
+ input.addEventListener("input", () => {
893
+ writeShapeEditorOverride(entry, { ...entry.values, [key]: input.value, [`${key}Enabled`]: true });
894
+ value.value = input.value;
895
+ });
896
+ label.append(name, input, value);
897
+ return label;
898
+ }
899
+
900
+ function writeShapeEditorOverride(entry, values) {
901
+ shapeEditorOverrides[entry.layerKey] ||= {};
902
+ shapeEditorOverrides[entry.layerKey][entry.shapeIndex] = normalizeShapeEditorPatch(values);
903
+ engine.setShapeEditorOverrides(shapeEditorOverrides);
904
+ }
905
+
906
+ function updateCustomShapeParamControls(layers) {
907
+ const entries = customShapeParamControlEntries(layers, customShapeParamOverrides);
908
+ const signature = customShapeParamControlsSignatureFor(entries);
909
+ if (signature === customShapeParamControlsSignature) {
910
+ return;
911
+ }
912
+
913
+ customShapeParamControlsSignature = signature;
914
+ customShapeParamOverrides = pruneCustomShapeParamOverrides(customShapeParamOverrides, entries);
915
+ renderCustomShapeParamControls(entries);
916
+ }
917
+
918
+ function customShapeParamControlsSignatureFor(entries) {
919
+ return entries.map((entry) => (
920
+ `${entry.key}:${entry.min}:${entry.max}:${entry.step}:${entry.value}`
921
+ )).join("|");
922
+ }
923
+
924
+ function renderCustomShapeParamControls(entries) {
925
+ if (!customShapeParamControlsElement) {
926
+ return;
927
+ }
928
+
929
+ if (!entries.length) {
930
+ customShapeParamControlsElement.hidden = true;
931
+ customShapeParamControlsElement.replaceChildren();
932
+ return;
933
+ }
934
+
935
+ const title = document.createElement("p");
936
+ title.className = "shader-param-controls__title";
937
+ title.textContent = "Custom Shape Params";
938
+ const controls = entries.map((entry) => createCustomShapeParamControl(entry));
939
+ customShapeParamControlsElement.replaceChildren(title, ...controls);
940
+ customShapeParamControlsElement.hidden = false;
941
+ }
942
+
943
+ function createCustomShapeParamControl(entry) {
944
+ const label = document.createElement("label");
945
+ const name = document.createElement("span");
946
+ const input = document.createElement("input");
947
+ const value = document.createElement("output");
948
+ name.textContent = entry.label;
949
+ input.type = "range";
950
+ input.min = String(entry.min);
951
+ input.max = String(entry.max);
952
+ input.step = String(entry.step);
953
+ input.value = String(entry.value);
954
+ value.value = formatShaderParamValue(entry.value);
955
+ input.addEventListener("input", () => {
956
+ const numeric = Number(input.value);
957
+ customShapeParamOverrides[entry.layerKey] ||= {};
958
+ customShapeParamOverrides[entry.layerKey][entry.customShapeIndex] ||= {};
959
+ customShapeParamOverrides[entry.layerKey][entry.customShapeIndex][entry.paramName] = numeric;
960
+ client.send("custom_shape_param", customShapeParamMessage(entry, numeric));
961
+ value.value = formatShaderParamValue(numeric);
962
+ });
963
+ label.append(name, input, value);
964
+ return label;
965
+ }
966
+
967
+ function updateMappingTargetSelector(layers) {
968
+ const options = mappingTargetOptions(layers);
969
+ const signature = mappingTargetSignature(options);
970
+ if (signature === mappingTargetSelectorSignature) {
971
+ return;
972
+ }
973
+
974
+ mappingTargetSelectorSignature = signature;
975
+ renderMappingTargetSelector(options);
976
+ }
977
+
978
+ function renderMappingTargetSelector(options) {
979
+ if (!mappingTargetSelectorElement) {
980
+ return;
981
+ }
982
+
983
+ if (!options.length) {
984
+ selectedMappingTarget = "";
985
+ mappingTargetSelectorElement.hidden = true;
986
+ mappingTargetSelectorElement.replaceChildren();
987
+ return;
988
+ }
989
+
990
+ const title = document.createElement("p");
991
+ title.className = "shader-param-controls__title";
992
+ title.textContent = "Mapping Targets";
993
+ const label = document.createElement("label");
994
+ const name = document.createElement("span");
995
+ const select = document.createElement("select");
996
+ const output = document.createElement("output");
997
+ name.textContent = "Target";
998
+ options.forEach((option) => {
999
+ const item = document.createElement("option");
1000
+ item.value = option.target;
1001
+ item.textContent = option.label;
1002
+ select.append(item);
1003
+ });
1004
+ if (!options.some((option) => option.target === selectedMappingTarget)) {
1005
+ selectedMappingTarget = options[0].target;
1006
+ }
1007
+ select.value = selectedMappingTarget;
1008
+ output.value = selectedMappingTarget;
1009
+ select.addEventListener("change", () => {
1010
+ selectedMappingTarget = select.value;
1011
+ output.value = select.value;
1012
+ });
1013
+ label.append(name, select, output);
1014
+ mappingTargetSelectorElement.replaceChildren(title, label);
1015
+ mappingTargetSelectorElement.hidden = false;
1016
+ }
1017
+
1018
+ function requestSceneSwitch(sceneName, effect = null) {
498
1019
  if (!sceneName || sceneName === currentSceneName) {
499
1020
  return;
500
1021
  }
@@ -504,17 +1025,29 @@ function requestSceneSwitch(sceneName) {
504
1025
  currentSceneName = sceneName;
505
1026
  sceneStatusElement.textContent = `Scene: ${sceneName}`;
506
1027
  renderSceneButtons();
507
- client.send("switch_scene", { scene: sceneName });
1028
+ const payload = { scene: sceneName };
1029
+ const normalizedEffect = normalizeTransitionEffect(effect);
1030
+ if (normalizedEffect) {
1031
+ payload.effect = normalizedEffect;
1032
+ }
1033
+ client.send("switch_scene", payload);
508
1034
  }
509
1035
 
510
1036
  function applyKeyboardAction(action) {
511
1037
  if (action?.type === "switch_scene") {
512
- requestSceneSwitch(action.scene);
1038
+ requestSceneSwitch(action.scene, action.effect);
513
1039
  return;
514
1040
  }
515
1041
 
516
1042
  if (action?.type === "live_control") {
517
- applyLiveControls(toggleLiveControl(liveControls, action.control));
1043
+ if (Object.prototype.hasOwnProperty.call(action, "value")) {
1044
+ applyLiveControls({
1045
+ [action?.control]: normalizeLiveControlPayload(action),
1046
+ });
1047
+ return;
1048
+ }
1049
+
1050
+ applyLiveControls(toggleLiveControl(liveControls, action?.control));
518
1051
  }
519
1052
  }
520
1053
 
@@ -546,6 +1079,20 @@ function sendLatencyProbe() {
546
1079
  });
547
1080
  }
548
1081
 
1082
+ function normalizeTransitionEffect(effect) {
1083
+ if (!effect) {
1084
+ return null;
1085
+ }
1086
+ if (typeof effect === "string" || typeof effect === "number" || typeof effect === "symbol") {
1087
+ return String(effect);
1088
+ }
1089
+ if (typeof effect !== "object" || Array.isArray(effect)) {
1090
+ return null;
1091
+ }
1092
+
1093
+ return effect;
1094
+ }
1095
+
549
1096
  function updatePerformanceMonitor(nextState) {
550
1097
  Object.assign(performanceMonitor, nextState);
551
1098
  renderPerformanceMonitor();
@@ -565,6 +1112,15 @@ function bindShaderCompileMetrics() {
565
1112
  });
566
1113
  }
567
1114
 
1115
+ function bindRendererMetrics() {
1116
+ window.addEventListener(RENDERER_CAPABILITIES_EVENT, (event) => {
1117
+ updatePerformanceMonitor(recordRendererCapabilities(performanceMonitor, event.detail));
1118
+ });
1119
+ window.addEventListener(RENDERER_SAFE_MODE_EVENT, (event) => {
1120
+ updatePerformanceMonitor(recordRendererSafeMode(performanceMonitor, event.detail));
1121
+ });
1122
+ }
1123
+
568
1124
  function bindLiveControls() {
569
1125
  bindLiveControlButton(blackoutButton, "blackout");
570
1126
  bindLiveControlButton(freezeButton, "freeze");
@@ -579,7 +1135,7 @@ function bindLiveControls() {
579
1135
  const action = shortcutActionForKey(event);
580
1136
  if (action) {
581
1137
  event.preventDefault();
582
- applyLiveControls(toggleLiveControl(liveControls, action));
1138
+ applyKeyboardAction({ type: "live_control", control: action });
583
1139
  return;
584
1140
  }
585
1141
 
@@ -613,16 +1169,54 @@ function bindLiveControlButton(button, control) {
613
1169
  }
614
1170
 
615
1171
  function applyLiveControls(nextState) {
616
- Object.assign(liveControls, nextState);
1172
+ if (!nextState || typeof nextState !== "object") {
1173
+ return;
1174
+ }
1175
+
1176
+ const nextBlackout = mergeLiveControlState(
1177
+ liveControls.blackout,
1178
+ nextState.blackout
1179
+ );
1180
+ const nextFreeze = mergeLiveControlState(
1181
+ liveControls.freeze,
1182
+ nextState.freeze
1183
+ );
1184
+ Object.assign(liveControls, {
1185
+ blackout: nextBlackout,
1186
+ freeze: nextFreeze,
1187
+ });
617
1188
  engine.setLiveControls(liveControls);
618
1189
  renderLiveControlStatus();
619
1190
  }
620
1191
 
1192
+ function mergeLiveControlState(currentState, nextState) {
1193
+ const current = normalizeLiveControlPayload(currentState);
1194
+ if (nextState === undefined) {
1195
+ return current;
1196
+ }
1197
+ if (!nextState || typeof nextState !== "object" || Array.isArray(nextState)) {
1198
+ return normalizeLiveControlPayload(nextState);
1199
+ }
1200
+
1201
+ const normalized = normalizeLiveControlPayload(nextState);
1202
+ const nextHasFade = Object.prototype.hasOwnProperty.call(nextState, "fade");
1203
+ const nextHasRelease = Object.prototype.hasOwnProperty.call(nextState, "release");
1204
+ const nextHasColor = Object.prototype.hasOwnProperty.call(nextState, "color");
1205
+
1206
+ return {
1207
+ ...current,
1208
+ ...normalized,
1209
+ ...(nextHasFade ? { fade: normalized.fade } : {}),
1210
+ ...(nextHasRelease ? { release: normalized.release } : {}),
1211
+ ...(nextHasColor ? { color: normalized.color } : {}),
1212
+ };
1213
+ }
1214
+
621
1215
  function normalizeLiveControls(value) {
622
1216
  const input = value && typeof value === "object" ? value : {};
623
1217
  return {
624
- blackout: !!input.blackout,
625
- freeze: !!input.freeze,
1218
+ blackout: normalizeLiveControlPayload(input.blackout),
1219
+ freeze: normalizeLiveControlPayload(input.freeze),
626
1220
  };
627
1221
  }
628
1222
 
@@ -632,13 +1226,15 @@ function renderLiveControlStatus() {
632
1226
  }
633
1227
 
634
1228
  if (blackoutButton) {
635
- blackoutButton.classList.toggle("is-active", liveControls.blackout);
636
- blackoutButton.setAttribute("aria-pressed", String(liveControls.blackout));
1229
+ const isActive = isLiveControlEnabled(liveControls.blackout);
1230
+ blackoutButton.classList.toggle("is-active", isActive);
1231
+ blackoutButton.setAttribute("aria-pressed", String(isActive));
637
1232
  }
638
1233
 
639
1234
  if (freezeButton) {
640
- freezeButton.classList.toggle("is-active", liveControls.freeze);
641
- freezeButton.setAttribute("aria-pressed", String(liveControls.freeze));
1235
+ const isActive = isLiveControlEnabled(liveControls.freeze);
1236
+ freezeButton.classList.toggle("is-active", isActive);
1237
+ freezeButton.setAttribute("aria-pressed", String(isActive));
642
1238
  }
643
1239
  }
644
1240
 
@@ -747,6 +1343,7 @@ function bindVisualControl(control, key, parser = Number) {
747
1343
  visualSettings[key] = parser(control.value);
748
1344
  engine.setVisualSettings(visualSettings);
749
1345
  renderReactivityStatus();
1346
+ syncRuntimeControlPresetSceneVisualSetting(key, visualSettings[key]);
750
1347
  });
751
1348
  }
752
1349
 
@@ -754,6 +1351,7 @@ function bindVisualPresetControls() {
754
1351
  if (reactivitySaveButton) {
755
1352
  reactivitySaveButton.addEventListener("click", () => {
756
1353
  Object.assign(visualSettings, saveVisualSettingsPreset(browserStorage(), visualSettings));
1354
+ syncRuntimeControlPresetBaseWithRuntime(visualSettings);
757
1355
  renderReactivityStatus("Saved");
758
1356
  });
759
1357
  }
@@ -763,6 +1361,7 @@ function bindVisualPresetControls() {
763
1361
  Object.assign(visualSettings, loadVisualSettingsPreset(browserStorage(), { fallback: visualSettings }));
764
1362
  syncVisualControls();
765
1363
  engine.setVisualSettings(visualSettings);
1364
+ syncRuntimeControlPresetBaseWithRuntime(visualSettings);
766
1365
  renderReactivityStatus("Loaded");
767
1366
  });
768
1367
  }
@@ -807,6 +1406,7 @@ function bindVisualPresetControls() {
807
1406
  Object.assign(visualSettings, saveVisualSettingsPreset(browserStorage(), visualSettings));
808
1407
  syncVisualControls();
809
1408
  engine.setVisualSettings(visualSettings);
1409
+ syncRuntimeControlPresetBaseWithRuntime(visualSettings);
810
1410
  renderReactivityStatus("Imported");
811
1411
  });
812
1412
  }
@@ -820,6 +1420,7 @@ async function saveProjectControlPreset() {
820
1420
  body: JSON.stringify({
821
1421
  visual_settings: visualSettings,
822
1422
  midi_learn_bindings: midiLearnBindings,
1423
+ scene_overrides: runtimeControlPresetSceneOverrides,
823
1424
  }),
824
1425
  });
825
1426
  return response.ok;
@@ -904,6 +1505,7 @@ function handleMidiMessage(event) {
904
1505
  if (pendingMidiLearnAction && midiMessageActive(event?.data)) {
905
1506
  midiLearnBindings = upsertMidiLearnBinding(midiLearnBindings, signature, pendingMidiLearnAction);
906
1507
  midiLearnBindings = saveMidiLearnBindings(browserStorage(), midiLearnBindings);
1508
+ syncRuntimeControlPresetMidiBindings(midiLearnBindings);
907
1509
  renderMidiLearnStatus(`Learned ${midiSignatureLabel(signature)} -> ${midiLearnActionLabel(pendingMidiLearnAction)}`);
908
1510
  pendingMidiLearnAction = null;
909
1511
  return;
@@ -922,6 +1524,7 @@ function applyMidiLearnAction(action, unitValue, active) {
922
1524
  visualSettings[action.key] = visualSettingFromUnit(action.key, unitValue, visualSettings[action.key]);
923
1525
  syncVisualControls();
924
1526
  engine.setVisualSettings(visualSettings);
1527
+ syncRuntimeControlPresetSceneVisualSetting(action.key, visualSettings[action.key]);
925
1528
  renderReactivityStatus("MIDI");
926
1529
  return;
927
1530
  }
@@ -931,13 +1534,19 @@ function applyMidiLearnAction(action, unitValue, active) {
931
1534
  }
932
1535
 
933
1536
  if (action.type === "switch_scene") {
934
- requestSceneSwitch(action.scene);
1537
+ requestSceneSwitch(action.scene, action.effect);
935
1538
  renderMidiLearnStatus(`MIDI: ${midiLearnActionLabel(action)}`);
936
1539
  return;
937
1540
  }
938
1541
 
939
1542
  if (action.type === "live_control") {
940
- applyLiveControls(toggleLiveControl(liveControls, action.control));
1543
+ if (Object.prototype.hasOwnProperty.call(action, "value")) {
1544
+ applyLiveControls({
1545
+ [action.control]: normalizeLiveControlPayload(action),
1546
+ });
1547
+ } else {
1548
+ applyLiveControls(toggleLiveControl(liveControls, action.control));
1549
+ }
941
1550
  renderMidiLearnStatus(`MIDI: ${midiLearnActionLabel(action)}`);
942
1551
  }
943
1552
  }
@@ -1006,6 +1615,7 @@ function renderMidiLearnStatus(prefix = null) {
1006
1615
  function bindShaderErrorOverlay() {
1007
1616
  window.addEventListener(SHADER_ERROR_EVENT, (event) => {
1008
1617
  renderShaderError(event.detail);
1618
+ reportShaderErrorToServer(event.detail);
1009
1619
  });
1010
1620
  if (shaderErrorCloseButton) {
1011
1621
  shaderErrorCloseButton.addEventListener("click", () => {
@@ -1026,6 +1636,31 @@ function renderShaderError(detail) {
1026
1636
  shaderErrorOverlay.hidden = false;
1027
1637
  }
1028
1638
 
1639
+ function reportShaderErrorToServer(detail = {}) {
1640
+ const source = String(detail?.source || "shader").trim() || "shader";
1641
+ const layer = String(detail?.name || "layer").trim();
1642
+ const shader = String(detail?.shader || "unknown").trim();
1643
+ const event = String(detail?.event || "shader_failed").trim() || "shader_failed";
1644
+ const phase = String(detail?.phase || "").trim();
1645
+ const message = String(detail?.message || "").trim();
1646
+ const fullMessage = `${layer} (${shader}) ${phase ? `[${phase}] ` : ""}${message}`;
1647
+ client.send("client_runtime_error", {
1648
+ source,
1649
+ event,
1650
+ context: "shader compile failed",
1651
+ message: fullMessage,
1652
+ layer,
1653
+ shader,
1654
+ phase
1655
+ });
1656
+ updateRuntimeErrorStatus({
1657
+ source,
1658
+ event,
1659
+ context: "shader compile failed",
1660
+ message: fullMessage
1661
+ });
1662
+ }
1663
+
1029
1664
  function initializeFftPreview(container) {
1030
1665
  if (!container) {
1031
1666
  return [];
@@ -1065,6 +1700,26 @@ function renderAudioInspector(audio) {
1065
1700
  }
1066
1701
  }
1067
1702
 
1703
+ function updateRuntimeErrorStatus(payload = {}) {
1704
+ if (!runtimeErrorStatusElement) {
1705
+ return;
1706
+ }
1707
+
1708
+ const source = String(payload?.source || "runtime").trim();
1709
+ const event = String(payload?.event || "").trim();
1710
+ const context = String(payload?.context || "runtime error").trim();
1711
+ const message = String(payload?.message || "").trim();
1712
+ const frameId = payload?.frame_id;
1713
+
1714
+ const detail = [context, event].filter(Boolean).join(" / ");
1715
+ const frameText = Number.isFinite(frameId) ? ` (frame ${frameId})` : "";
1716
+ const text = message
1717
+ ? `Runtime (${source}): ${detail}${frameText} | ${message}`
1718
+ : `Runtime (${source}): ${detail}${frameText}`;
1719
+
1720
+ runtimeErrorStatusElement.textContent = text;
1721
+ }
1722
+
1068
1723
  function setMeter(fill, valueElement, value, digits) {
1069
1724
  if (fill) {
1070
1725
  fill.style.setProperty("--meter-value", value.toFixed(4));
@@ -1076,5 +1731,8 @@ function setMeter(fill, valueElement, value, digits) {
1076
1731
 
1077
1732
  function buildWebSocketUrl() {
1078
1733
  const protocol = window.location.protocol === "https:" ? "wss" : "ws";
1079
- return `${protocol}://${window.location.host}/ws`;
1734
+ const mode = new URLSearchParams(window.location.search || "").get("mode");
1735
+ const normalizedMode = String(mode || "").toLowerCase();
1736
+ const role = projectorMode || normalizedMode === "projector" ? "projector" : normalizedMode === "monitor" ? "monitor" : "control";
1737
+ return `${protocol}://${window.location.host}/ws?role=${encodeURIComponent(role)}`;
1080
1738
  }