vizcore 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. checksums.yaml +4 -4
  2. data/frontend/index.html +24 -2
  3. data/frontend/src/audio-inspector.js +9 -0
  4. data/frontend/src/live-controls.js +219 -7
  5. data/frontend/src/main.js +447 -57
  6. data/frontend/src/midi-learn.js +22 -2
  7. data/frontend/src/performance-monitor.js +137 -1
  8. data/frontend/src/renderer/engine.js +391 -10
  9. data/frontend/src/renderer/layer-manager.js +472 -71
  10. data/frontend/src/runtime-control-preset.js +44 -0
  11. data/frontend/src/scene-patches.js +159 -0
  12. data/frontend/src/shader-error-overlay.js +1 -0
  13. data/frontend/src/visuals/image-renderer.js +19 -0
  14. data/frontend/src/visuals/particle-system.js +10 -0
  15. data/frontend/src/visuals/shape-renderer.js +13 -0
  16. data/frontend/src/visuals/spectrogram-renderer.js +14 -0
  17. data/frontend/src/visuals/text-renderer.js +13 -0
  18. data/frontend/src/websocket-client.js +6 -0
  19. data/lib/vizcore/analysis/adaptive_normalizer.rb +20 -2
  20. data/lib/vizcore/analysis/bpm_estimator.rb +18 -8
  21. data/lib/vizcore/analysis/feature_recorder.rb +117 -7
  22. data/lib/vizcore/analysis/feature_replay.rb +48 -9
  23. data/lib/vizcore/analysis/pipeline.rb +258 -9
  24. data/lib/vizcore/analysis/tap_tempo.rb +17 -2
  25. data/lib/vizcore/audio/calibration.rb +156 -0
  26. data/lib/vizcore/audio/file_input.rb +28 -0
  27. data/lib/vizcore/audio/input_manager.rb +36 -1
  28. data/lib/vizcore/audio/midi_input.rb +5 -0
  29. data/lib/vizcore/audio/ring_buffer.rb +22 -0
  30. data/lib/vizcore/audio.rb +1 -0
  31. data/lib/vizcore/cli/dsl_reference.rb +64 -8
  32. data/lib/vizcore/cli/plugin_checker.rb +93 -0
  33. data/lib/vizcore/cli/scene_diagnostics.rb +2 -2
  34. data/lib/vizcore/cli/scene_inspector.rb +35 -1
  35. data/lib/vizcore/cli/scene_validator.rb +487 -39
  36. data/lib/vizcore/cli/shader_template.rb +7 -2
  37. data/lib/vizcore/cli/shader_uniform_docs.rb +11 -0
  38. data/lib/vizcore/cli.rb +268 -15
  39. data/lib/vizcore/config.rb +40 -3
  40. data/lib/vizcore/control_preset.rb +29 -0
  41. data/lib/vizcore/deep_copy.rb +21 -0
  42. data/lib/vizcore/dsl/color_helpers.rb +155 -0
  43. data/lib/vizcore/dsl/engine.rb +219 -23
  44. data/lib/vizcore/dsl/layer_builder.rb +278 -15
  45. data/lib/vizcore/dsl/layer_group_builder.rb +10 -12
  46. data/lib/vizcore/dsl/layout_helpers.rb +290 -0
  47. data/lib/vizcore/dsl/mapping_preset_builder.rb +41 -0
  48. data/lib/vizcore/dsl/mapping_resolver.rb +404 -22
  49. data/lib/vizcore/dsl/mapping_transform_builder.rb +50 -0
  50. data/lib/vizcore/dsl/midi_map_executor.rb +219 -23
  51. data/lib/vizcore/dsl/reaction_builder.rb +1 -0
  52. data/lib/vizcore/dsl/scene_builder.rb +83 -13
  53. data/lib/vizcore/dsl/shader_source_resolver.rb +1 -10
  54. data/lib/vizcore/dsl/style_builder.rb +3 -0
  55. data/lib/vizcore/dsl/timeline_builder.rb +91 -8
  56. data/lib/vizcore/dsl/transition_controller.rb +157 -18
  57. data/lib/vizcore/dsl.rb +2 -0
  58. data/lib/vizcore/layer_catalog.rb +1 -0
  59. data/lib/vizcore/plugin_asset_policy.rb +55 -0
  60. data/lib/vizcore/project_manifest.rb +12 -2
  61. data/lib/vizcore/renderer/render_sequence.rb +104 -13
  62. data/lib/vizcore/renderer/scene_frame_source.rb +179 -14
  63. data/lib/vizcore/renderer/scene_serializer.rb +38 -0
  64. data/lib/vizcore/renderer/snapshot.rb +4 -3
  65. data/lib/vizcore/renderer/snapshot_renderer.rb +134 -8
  66. data/lib/vizcore/scene_trust.rb +31 -0
  67. data/lib/vizcore/server/frame_broadcaster.rb +469 -23
  68. data/lib/vizcore/server/rack_app.rb +151 -4
  69. data/lib/vizcore/server/runner.rb +676 -82
  70. data/lib/vizcore/server/websocket_handler.rb +236 -14
  71. data/lib/vizcore/server.rb +21 -0
  72. data/lib/vizcore/shape.rb +39 -16
  73. data/lib/vizcore/sync/osc_message.rb +66 -9
  74. data/lib/vizcore/version.rb +1 -1
  75. data/lib/vizcore.rb +33 -0
  76. data/scripts/browser_capture.mjs +31 -2
  77. data/sig/vizcore.rbs +55 -4
  78. metadata +18 -3
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,7 +34,7 @@ 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";
34
39
  import {
35
40
  customShapeParamControlEntries,
@@ -58,6 +63,7 @@ import {
58
63
  saveVisualSettingsPreset,
59
64
  visualSettingFromUnit
60
65
  } from "./visual-settings-preset.js";
66
+ import { applyScenePayload, resolveScenePayload } from "./scene-patches.js";
61
67
  import { WebSocketClient } from "./websocket-client.js";
62
68
 
63
69
  window.__vizcoreMainStarted = true;
@@ -67,6 +73,7 @@ const wsStatusElement = document.querySelector("#ws-status");
67
73
  const sceneStatusElement = document.querySelector("#scene-status");
68
74
  const transitionStatusElement = document.querySelector("#transition-status");
69
75
  const frameStatusElement = document.querySelector("#frame-status");
76
+ const runtimeErrorStatusElement = document.querySelector("#runtime-error-status");
70
77
  const bpmStatusElement = document.querySelector("#bpm-status");
71
78
  const beatStatusElement = document.querySelector("#beat-status");
72
79
  const blackoutButton = document.querySelector("#blackout-toggle");
@@ -87,6 +94,7 @@ const inspectorBandElements = Object.fromEntries(
87
94
  );
88
95
  const fftPreviewElement = document.querySelector("#fft-preview");
89
96
  const audioSourceStatusElement = document.querySelector("#audio-source-status");
97
+ const audioHealthStatusElement = document.querySelector("#audio-health-status");
90
98
  const audioTrackStatusElement = document.querySelector("#audio-track-status");
91
99
  const audioPlaybackStatusElement = document.querySelector("#audio-playback-status");
92
100
  const sceneSwitcherElement = document.querySelector("#scene-switcher");
@@ -133,8 +141,13 @@ let pendingSceneRequestedAt = 0;
133
141
  let tapTempoKey = null;
134
142
  let runtimeGlobalsReceived = false;
135
143
  let runtimeControlPresetApplied = false;
144
+ let runtimeControlPresetSceneApplied = null;
145
+ let runtimeControlPresetSceneOverrides = {};
146
+ let runtimeControlPresetVisualBase = null;
147
+ let runtimeControlPresetMidiBase = null;
136
148
  let controlPresetSaveUrl = null;
137
149
  let shaderParamOverrides = {};
150
+ let scenePayload = null;
138
151
  let shaderParamControlsSignature = "";
139
152
  let shapeEditorOverrides = {};
140
153
  let shapeEditorControlsSignature = "";
@@ -148,6 +161,7 @@ applyProjectorMode(document.body, projectorMode);
148
161
  const engine = new Engine(canvas);
149
162
  let rendererReady = false;
150
163
  bindShaderCompileMetrics();
164
+ bindRendererMetrics();
151
165
  try {
152
166
  engine.init();
153
167
  rendererReady = true;
@@ -183,10 +197,26 @@ startPerformanceMonitorLoop();
183
197
  const websocketUrl = buildWebSocketUrl();
184
198
  const client = new WebSocketClient(websocketUrl, {
185
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
+
186
213
  updatePerformanceMonitor(recordSocketFrame(performanceMonitor, frame, Date.now()));
187
- engine.setAudioFrame(frame);
214
+ engine.setAudioFrame(normalizedFrame);
188
215
  frameCount += 1;
189
- 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;
190
220
  const now = performance.now();
191
221
  if (
192
222
  pendingSceneName &&
@@ -199,18 +229,8 @@ const client = new WebSocketClient(websocketUrl, {
199
229
  pendingSceneName = null;
200
230
  pendingSceneRequestedAt = 0;
201
231
  }
202
- const sceneChanged = sceneName !== currentSceneName;
203
- currentSceneName = sceneName;
204
- if (sceneChanged) {
205
- shaderParamControlsSignature = "";
206
- shapeEditorControlsSignature = "";
207
- customShapeParamControlsSignature = "";
208
- mappingTargetSelectorSignature = "";
209
- }
210
- updateShaderParamControls(frame?.scene?.layers);
211
- updateShapeEditorControls(frame?.scene?.layers);
212
- updateCustomShapeParamControls(frame?.scene?.layers);
213
- updateMappingTargetSelector(frame?.scene?.layers);
232
+ applyRuntimeControlPresetForScene(sceneName);
233
+ updateSceneControls(scene);
214
234
  const amplitude = Number(frame?.audio?.amplitude || 0).toFixed(4);
215
235
  const bpm = Number(frame?.audio?.bpm || 0);
216
236
  const beat = !!frame?.audio?.beat;
@@ -220,9 +240,6 @@ const client = new WebSocketClient(websocketUrl, {
220
240
  }
221
241
  const beatVisible = performance.now() < beatFlashUntil;
222
242
  sceneStatusElement.textContent = `Scene: ${sceneName}`;
223
- if (sceneChanged) {
224
- renderSceneButtons();
225
- }
226
243
  frameStatusElement.textContent = `Amplitude: ${amplitude} | Frames: ${frameCount}`;
227
244
  bpmStatusElement.textContent = `BPM: ${bpm > 0 ? bpm.toFixed(1) : "--"}`;
228
245
  beatStatusElement.textContent = `Beat: ${beatVisible ? "ON" : "off"} | Count: ${beatCount}`;
@@ -236,6 +253,7 @@ const client = new WebSocketClient(websocketUrl, {
236
253
  pendingSceneRequestedAt = 0;
237
254
  currentSceneName = to;
238
255
  sceneStatusElement.textContent = `Scene: ${to}`;
256
+ applyRuntimeControlPresetForScene(to, { force: true });
239
257
  transitionStatusElement.textContent = `Transition: ${from} -> ${to}`;
240
258
  renderSceneButtons();
241
259
  },
@@ -243,17 +261,9 @@ const client = new WebSocketClient(websocketUrl, {
243
261
  updateAvailableScenes(payload?.scenes);
244
262
  const sceneName = payload?.scene?.name;
245
263
  if (sceneName) {
246
- currentSceneName = String(sceneName);
247
- sceneStatusElement.textContent = `Scene: ${currentSceneName}`;
248
- renderSceneButtons();
249
- shaderParamControlsSignature = "";
250
- shapeEditorControlsSignature = "";
251
- customShapeParamControlsSignature = "";
252
- mappingTargetSelectorSignature = "";
253
- updateShaderParamControls(payload?.scene?.layers);
254
- updateShapeEditorControls(payload?.scene?.layers);
255
- updateCustomShapeParamControls(payload?.scene?.layers);
256
- updateMappingTargetSelector(payload?.scene?.layers);
264
+ scenePayload = applyScenePayload(payload.scene);
265
+ applyRuntimeControlPresetForScene(sceneName, { force: true });
266
+ updateSceneControls(scenePayload, { forceReset: true });
257
267
  }
258
268
  if (Object.prototype.hasOwnProperty.call(payload || {}, "tap_tempo_key")) {
259
269
  updateTapTempoKey(payload?.tap_tempo_key);
@@ -267,7 +277,6 @@ const client = new WebSocketClient(websocketUrl, {
267
277
  }
268
278
  if (Object.prototype.hasOwnProperty.call(payload || {}, "live_controls")) {
269
279
  applyLiveControls({
270
- ...liveControls,
271
280
  ...normalizeLiveControls(payload?.live_controls),
272
281
  });
273
282
  }
@@ -275,12 +284,18 @@ const client = new WebSocketClient(websocketUrl, {
275
284
  onLatencyProbe: (payload) => {
276
285
  updatePerformanceMonitor(recordLatencyProbe(performanceMonitor, payload, Date.now()));
277
286
  },
287
+ onRuntimeError: (payload) => {
288
+ updateRuntimeErrorStatus(payload);
289
+ },
278
290
  onStatus: (status) => {
279
291
  updatePerformanceMonitor(recordConnectionStatus(performanceMonitor, status));
280
292
  if (status === "connected") {
281
293
  lastConnectedAt = new Date();
282
294
  startLatencyProbeLoop();
283
295
  syncAudioTransportToServer({ force: true });
296
+ if (runtimeErrorStatusElement) {
297
+ runtimeErrorStatusElement.textContent = "Runtime: ok";
298
+ }
284
299
  } else {
285
300
  stopLatencyProbeLoop();
286
301
  pendingSceneName = null;
@@ -288,9 +303,12 @@ const client = new WebSocketClient(websocketUrl, {
288
303
  currentSceneName = "unknown";
289
304
  sceneStatusElement.textContent = "Scene: unknown";
290
305
  renderSceneButtons();
306
+ if (runtimeErrorStatusElement) {
307
+ runtimeErrorStatusElement.textContent = "Runtime: disconnected";
308
+ }
291
309
  }
292
- const connectedAt = lastConnectedAt ? ` | Last connected: ${formatClock(lastConnectedAt)}` : "";
293
- wsStatusElement.textContent = `WebSocket: ${status} (${websocketUrl})${connectedAt}`;
310
+ const connectedAt = lastConnectedAt ? ` | Last connected: ${formatClock(lastConnectedAt)}` : "";
311
+ wsStatusElement.textContent = `WebSocket: ${status} (${websocketUrl})${connectedAt}`;
294
312
  }
295
313
  });
296
314
 
@@ -333,9 +351,11 @@ function applyRuntime(runtime) {
333
351
  applyRuntimeGlobals(runtime?.globals);
334
352
  }
335
353
  applyRuntimeControlPreset(runtime?.control_preset);
354
+ updatePerformanceMonitor(recordWebSocketBackpressure(performanceMonitor, runtime?.websocket_backpressure));
336
355
 
337
356
  const fileName = runtime?.audio_file_name;
338
357
  const fileUrl = runtime?.audio_file_url;
358
+ applyRuntimeAudioInputHealth(runtime?.input);
339
359
  if (!fileUrl) {
340
360
  engine.setMediaElement(null);
341
361
  audioTrackStatusElement.textContent = "Track: none";
@@ -348,6 +368,33 @@ function applyRuntime(runtime) {
348
368
  setupAudioPlayback(fileUrl);
349
369
  }
350
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
+
351
398
  function applyRuntimeGlobals(globals) {
352
399
  engine.setRuntimeGlobals(globals);
353
400
  }
@@ -361,28 +408,205 @@ function updateControlPresetPersistence(runtime) {
361
408
  }
362
409
 
363
410
  function applyRuntimeControlPreset(value) {
364
- if (runtimeControlPresetApplied) {
411
+ if (!value || runtimeControlPresetApplied) {
365
412
  return;
366
413
  }
367
414
 
368
415
  const preset = normalizeRuntimeControlPreset(value);
369
- let applied = false;
370
- if (preset.visualSettings) {
371
- const imported = importVisualSettingsPreset({ visual_settings: preset.visualSettings }, { fallback: visualSettings });
372
- 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);
373
431
  syncVisualControls();
374
432
  engine.setVisualSettings(visualSettings);
375
433
  renderReactivityStatus("Project preset");
376
- applied = true;
434
+ } else {
435
+ runtimeControlPresetVisualBase = cloneRuntimeValue(visualSettings);
377
436
  }
378
437
 
379
- if (preset.midiLearnBindings) {
380
- midiLearnBindings = saveMidiLearnBindings(browserStorage(), preset.midiLearnBindings);
438
+ if (hasMidi) {
439
+ midiLearnBindings = cloneRuntimeValue(preset.midiLearnBindings);
440
+ runtimeControlPresetMidiBase = cloneRuntimeValue(midiLearnBindings);
441
+ saveMidiLearnBindings(browserStorage(), midiLearnBindings);
381
442
  renderMidiLearnStatus();
382
- 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;
383
532
  }
384
533
 
385
- runtimeControlPresetApplied = applied;
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;
553
+ }
554
+
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));
386
610
  }
387
611
 
388
612
  function updateAvailableScenes(sceneValues) {
@@ -404,6 +628,35 @@ function updateKeyboardMappings(mappings) {
404
628
  renderSceneButtons();
405
629
  }
406
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
+
407
660
  function normalizeSceneNames(sceneValues) {
408
661
  const seen = new Set();
409
662
  const names = [];
@@ -762,7 +1015,7 @@ function renderMappingTargetSelector(options) {
762
1015
  mappingTargetSelectorElement.hidden = false;
763
1016
  }
764
1017
 
765
- function requestSceneSwitch(sceneName) {
1018
+ function requestSceneSwitch(sceneName, effect = null) {
766
1019
  if (!sceneName || sceneName === currentSceneName) {
767
1020
  return;
768
1021
  }
@@ -772,17 +1025,29 @@ function requestSceneSwitch(sceneName) {
772
1025
  currentSceneName = sceneName;
773
1026
  sceneStatusElement.textContent = `Scene: ${sceneName}`;
774
1027
  renderSceneButtons();
775
- 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);
776
1034
  }
777
1035
 
778
1036
  function applyKeyboardAction(action) {
779
1037
  if (action?.type === "switch_scene") {
780
- requestSceneSwitch(action.scene);
1038
+ requestSceneSwitch(action.scene, action.effect);
781
1039
  return;
782
1040
  }
783
1041
 
784
1042
  if (action?.type === "live_control") {
785
- 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));
786
1051
  }
787
1052
  }
788
1053
 
@@ -814,6 +1079,20 @@ function sendLatencyProbe() {
814
1079
  });
815
1080
  }
816
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
+
817
1096
  function updatePerformanceMonitor(nextState) {
818
1097
  Object.assign(performanceMonitor, nextState);
819
1098
  renderPerformanceMonitor();
@@ -833,6 +1112,15 @@ function bindShaderCompileMetrics() {
833
1112
  });
834
1113
  }
835
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
+
836
1124
  function bindLiveControls() {
837
1125
  bindLiveControlButton(blackoutButton, "blackout");
838
1126
  bindLiveControlButton(freezeButton, "freeze");
@@ -847,7 +1135,7 @@ function bindLiveControls() {
847
1135
  const action = shortcutActionForKey(event);
848
1136
  if (action) {
849
1137
  event.preventDefault();
850
- applyLiveControls(toggleLiveControl(liveControls, action));
1138
+ applyKeyboardAction({ type: "live_control", control: action });
851
1139
  return;
852
1140
  }
853
1141
 
@@ -881,16 +1169,54 @@ function bindLiveControlButton(button, control) {
881
1169
  }
882
1170
 
883
1171
  function applyLiveControls(nextState) {
884
- 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
+ });
885
1188
  engine.setLiveControls(liveControls);
886
1189
  renderLiveControlStatus();
887
1190
  }
888
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
+
889
1215
  function normalizeLiveControls(value) {
890
1216
  const input = value && typeof value === "object" ? value : {};
891
1217
  return {
892
- blackout: !!input.blackout,
893
- freeze: !!input.freeze,
1218
+ blackout: normalizeLiveControlPayload(input.blackout),
1219
+ freeze: normalizeLiveControlPayload(input.freeze),
894
1220
  };
895
1221
  }
896
1222
 
@@ -900,13 +1226,15 @@ function renderLiveControlStatus() {
900
1226
  }
901
1227
 
902
1228
  if (blackoutButton) {
903
- blackoutButton.classList.toggle("is-active", liveControls.blackout);
904
- 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));
905
1232
  }
906
1233
 
907
1234
  if (freezeButton) {
908
- freezeButton.classList.toggle("is-active", liveControls.freeze);
909
- 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));
910
1238
  }
911
1239
  }
912
1240
 
@@ -1015,6 +1343,7 @@ function bindVisualControl(control, key, parser = Number) {
1015
1343
  visualSettings[key] = parser(control.value);
1016
1344
  engine.setVisualSettings(visualSettings);
1017
1345
  renderReactivityStatus();
1346
+ syncRuntimeControlPresetSceneVisualSetting(key, visualSettings[key]);
1018
1347
  });
1019
1348
  }
1020
1349
 
@@ -1022,6 +1351,7 @@ function bindVisualPresetControls() {
1022
1351
  if (reactivitySaveButton) {
1023
1352
  reactivitySaveButton.addEventListener("click", () => {
1024
1353
  Object.assign(visualSettings, saveVisualSettingsPreset(browserStorage(), visualSettings));
1354
+ syncRuntimeControlPresetBaseWithRuntime(visualSettings);
1025
1355
  renderReactivityStatus("Saved");
1026
1356
  });
1027
1357
  }
@@ -1031,6 +1361,7 @@ function bindVisualPresetControls() {
1031
1361
  Object.assign(visualSettings, loadVisualSettingsPreset(browserStorage(), { fallback: visualSettings }));
1032
1362
  syncVisualControls();
1033
1363
  engine.setVisualSettings(visualSettings);
1364
+ syncRuntimeControlPresetBaseWithRuntime(visualSettings);
1034
1365
  renderReactivityStatus("Loaded");
1035
1366
  });
1036
1367
  }
@@ -1075,6 +1406,7 @@ function bindVisualPresetControls() {
1075
1406
  Object.assign(visualSettings, saveVisualSettingsPreset(browserStorage(), visualSettings));
1076
1407
  syncVisualControls();
1077
1408
  engine.setVisualSettings(visualSettings);
1409
+ syncRuntimeControlPresetBaseWithRuntime(visualSettings);
1078
1410
  renderReactivityStatus("Imported");
1079
1411
  });
1080
1412
  }
@@ -1088,6 +1420,7 @@ async function saveProjectControlPreset() {
1088
1420
  body: JSON.stringify({
1089
1421
  visual_settings: visualSettings,
1090
1422
  midi_learn_bindings: midiLearnBindings,
1423
+ scene_overrides: runtimeControlPresetSceneOverrides,
1091
1424
  }),
1092
1425
  });
1093
1426
  return response.ok;
@@ -1172,6 +1505,7 @@ function handleMidiMessage(event) {
1172
1505
  if (pendingMidiLearnAction && midiMessageActive(event?.data)) {
1173
1506
  midiLearnBindings = upsertMidiLearnBinding(midiLearnBindings, signature, pendingMidiLearnAction);
1174
1507
  midiLearnBindings = saveMidiLearnBindings(browserStorage(), midiLearnBindings);
1508
+ syncRuntimeControlPresetMidiBindings(midiLearnBindings);
1175
1509
  renderMidiLearnStatus(`Learned ${midiSignatureLabel(signature)} -> ${midiLearnActionLabel(pendingMidiLearnAction)}`);
1176
1510
  pendingMidiLearnAction = null;
1177
1511
  return;
@@ -1190,6 +1524,7 @@ function applyMidiLearnAction(action, unitValue, active) {
1190
1524
  visualSettings[action.key] = visualSettingFromUnit(action.key, unitValue, visualSettings[action.key]);
1191
1525
  syncVisualControls();
1192
1526
  engine.setVisualSettings(visualSettings);
1527
+ syncRuntimeControlPresetSceneVisualSetting(action.key, visualSettings[action.key]);
1193
1528
  renderReactivityStatus("MIDI");
1194
1529
  return;
1195
1530
  }
@@ -1199,13 +1534,19 @@ function applyMidiLearnAction(action, unitValue, active) {
1199
1534
  }
1200
1535
 
1201
1536
  if (action.type === "switch_scene") {
1202
- requestSceneSwitch(action.scene);
1537
+ requestSceneSwitch(action.scene, action.effect);
1203
1538
  renderMidiLearnStatus(`MIDI: ${midiLearnActionLabel(action)}`);
1204
1539
  return;
1205
1540
  }
1206
1541
 
1207
1542
  if (action.type === "live_control") {
1208
- 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
+ }
1209
1550
  renderMidiLearnStatus(`MIDI: ${midiLearnActionLabel(action)}`);
1210
1551
  }
1211
1552
  }
@@ -1274,6 +1615,7 @@ function renderMidiLearnStatus(prefix = null) {
1274
1615
  function bindShaderErrorOverlay() {
1275
1616
  window.addEventListener(SHADER_ERROR_EVENT, (event) => {
1276
1617
  renderShaderError(event.detail);
1618
+ reportShaderErrorToServer(event.detail);
1277
1619
  });
1278
1620
  if (shaderErrorCloseButton) {
1279
1621
  shaderErrorCloseButton.addEventListener("click", () => {
@@ -1294,6 +1636,31 @@ function renderShaderError(detail) {
1294
1636
  shaderErrorOverlay.hidden = false;
1295
1637
  }
1296
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
+
1297
1664
  function initializeFftPreview(container) {
1298
1665
  if (!container) {
1299
1666
  return [];
@@ -1333,6 +1700,26 @@ function renderAudioInspector(audio) {
1333
1700
  }
1334
1701
  }
1335
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
+
1336
1723
  function setMeter(fill, valueElement, value, digits) {
1337
1724
  if (fill) {
1338
1725
  fill.style.setProperty("--meter-value", value.toFixed(4));
@@ -1344,5 +1731,8 @@ function setMeter(fill, valueElement, value, digits) {
1344
1731
 
1345
1732
  function buildWebSocketUrl() {
1346
1733
  const protocol = window.location.protocol === "https:" ? "wss" : "ws";
1347
- 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)}`;
1348
1738
  }