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
@@ -3,6 +3,7 @@
3
3
  require "set"
4
4
  require_relative "../../vizcore"
5
5
  require_relative "../dsl"
6
+ require_relative "../dsl/midi_map_executor"
6
7
  require_relative "../layer_catalog"
7
8
 
8
9
  module Vizcore
@@ -12,19 +13,27 @@ module Vizcore
12
13
  BUILTIN_SHADERS = Vizcore::LayerCatalog::BUILTIN_SHADERS
13
14
 
14
15
  MAPPING_SOURCE_KINDS = %i[
15
- amplitude frequency_band fft_spectrum onset kick snare hihat beat beat_confidence beat_pulse beat_count bpm
16
+ amplitude peak frequency_band frequency_band_peak fft_spectrum onset kick snare hihat beat beat_confidence beat_pulse beat_count bpm
17
+ beat_phase beat_2 beat_4 beat_8 beat_triplet triplet bar_phase bar_count phrase_count bpm_confidence
18
+ spectral_centroid spectral_rolloff spectral_flatness spectral_flux zero_crossing_rate global lfo adsr envelope
16
19
  ].freeze
17
20
 
21
+ LFO_WAVES = %i[sine triangle saw square].freeze
18
22
  FREQUENCY_BANDS = %i[sub low mid high].freeze
19
23
  SUPPORTED_BLEND_MODES = Vizcore::LayerCatalog::BLEND_MODES
20
24
  SUPPORTED_POST_EFFECTS = Vizcore::LayerCatalog::POST_EFFECTS
21
25
  SUPPORTED_VJ_EFFECTS = Vizcore::LayerCatalog::VJ_EFFECTS
22
26
  SUPPORTED_SHAPE_KINDS = %i[circle line rect polygon polyline path star].freeze
27
+ STRICT_PARAM_ALLOWLIST = Vizcore::DSL::LayerBuilder::STRICT_PARAM_ALLOWLIST
23
28
 
24
- Issue = Struct.new(:severity, :message, keyword_init: true) do
29
+ Issue = Struct.new(:severity, :code, :message, keyword_init: true) do
25
30
  def error?
26
31
  severity == :error
27
32
  end
33
+
34
+ def to_h
35
+ { severity: severity, code: code, message: message }
36
+ end
28
37
  end
29
38
 
30
39
  Result = Struct.new(:definition, :issues, keyword_init: true) do
@@ -41,10 +50,11 @@ module Vizcore
41
50
  end
42
51
  end
43
52
 
44
- def initialize(scene_file:, loader: Vizcore::DSL::Engine.method(:load_file), shader_resolver: Vizcore::DSL::ShaderSourceResolver.new)
53
+ def initialize(scene_file:, loader: Vizcore::DSL::Engine.method(:load_file), shader_resolver: Vizcore::DSL::ShaderSourceResolver.new, strict: false)
45
54
  @scene_file = scene_file
46
55
  @loader = loader
47
56
  @shader_resolver = shader_resolver
57
+ @strict = !!strict
48
58
  end
49
59
 
50
60
  def call
@@ -53,7 +63,7 @@ module Vizcore
53
63
  rescue StandardError => e
54
64
  Result.new(
55
65
  definition: nil,
56
- issues: [Issue.new(severity: :error, message: "failed to load scene: #{e.message}")]
66
+ issues: [error("failed to load scene: #{e.message}", code: "E_SCENE_LOAD")]
57
67
  )
58
68
  end
59
69
 
@@ -70,27 +80,29 @@ module Vizcore
70
80
  validate_scenes(scenes, issues)
71
81
  names = scene_names(scenes)
72
82
  validate_transitions(Array(definition[:transitions]), names, issues)
83
+ validate_timelines(Array(definition[:timelines]), names, issues)
73
84
  validate_key_mappings(Array(definition[:key_mappings]), names, issues)
85
+ validate_midi_maps(Array(definition[:midi_maps]), scenes, issues)
74
86
  issues
75
87
  end
76
88
 
77
89
  def validate_scenes(scenes, issues)
78
- issues << error("no scenes defined") if scenes.empty?
90
+ issues << error("no scenes defined", code: "E_NO_SCENES") if scenes.empty?
79
91
  duplicate_values(scenes.filter_map { |scene| scene[:name]&.to_sym }).each do |name|
80
- issues << error("duplicate scene name: #{name}")
92
+ issues << error("duplicate scene name: #{name}", code: "E_DUPLICATE_SCENE")
81
93
  end
82
94
 
83
95
  scenes.each do |scene|
84
96
  scene_name = scene[:name] || "(unnamed)"
85
97
  layers = Array(scene[:layers])
86
- issues << warn("scene #{scene_name} has no layers; frontend will render the default geometry") if layers.empty?
98
+ issues << warn("scene #{scene_name} has no layers; frontend will render the default geometry", code: "W_EMPTY_SCENE") if layers.empty?
87
99
  validate_layers(layers, scene_name, issues)
88
100
  end
89
101
  end
90
102
 
91
103
  def validate_layers(layers, scene_name, issues)
92
104
  duplicate_values(layers.filter_map { |layer| layer[:name]&.to_sym }).each do |name|
93
- issues << warn("scene #{scene_name} has duplicate layer name: #{name}")
105
+ issues << error("scene #{scene_name} has duplicate layer name: #{name}", code: "E_DUPLICATE_LAYER")
94
106
  end
95
107
 
96
108
  layers.each do |layer|
@@ -102,20 +114,21 @@ module Vizcore
102
114
  layer_name = layer[:name] || "(unnamed)"
103
115
  type = layer[:type]&.to_sym || :geometry
104
116
  unless supported_layer_types.include?(type)
105
- issues << error("scene #{scene_name} layer #{layer_name} has unsupported type: #{type}")
117
+ issues << error("scene #{scene_name} layer #{layer_name} has unsupported type: #{type}", code: "E_UNKNOWN_LAYER_TYPE")
106
118
  end
107
119
 
108
120
  shader = layer[:shader]&.to_sym
109
121
  if shader && !BUILTIN_SHADERS.include?(shader)
110
- issues << error("scene #{scene_name} layer #{layer_name} uses unknown shader: #{shader}")
122
+ issues << error("scene #{scene_name} layer #{layer_name} uses unknown shader: #{shader}", code: "E_UNKNOWN_SHADER")
111
123
  end
112
124
 
113
125
  glsl_source = layer[:glsl_source]
114
- issues << warn("scene #{scene_name} layer #{layer_name} has an empty GLSL file") if layer[:glsl] && glsl_source.to_s.empty?
126
+ issues << warn("scene #{scene_name} layer #{layer_name} has an empty GLSL file", code: "W_EMPTY_GLSL") if layer[:glsl] && glsl_source.to_s.empty?
127
+ validate_unknown_layer_params(layer, scene_name, layer_name, type, issues) if @strict || layer[:strict]
115
128
  validate_blend_mode(layer, scene_name, layer_name, issues)
116
129
  validate_layer_effects(layer, scene_name, layer_name, issues)
117
130
  validate_shape_layer(layer, scene_name, layer_name, issues)
118
- validate_mappings(Array(layer[:mappings]), scene_name, layer_name, issues)
131
+ validate_mappings(Array(layer[:mappings]), layer, scene_name, layer_name, issues)
119
132
  end
120
133
 
121
134
  def validate_blend_mode(layer, scene_name, layer_name, issues)
@@ -123,20 +136,33 @@ module Vizcore
123
136
  return unless blend
124
137
  return if SUPPORTED_BLEND_MODES.include?(blend.to_sym)
125
138
 
126
- issues << error("scene #{scene_name} layer #{layer_name} uses unsupported blend mode: #{blend}")
139
+ issues << error("scene #{scene_name} layer #{layer_name} uses unsupported blend mode: #{blend}", code: "E_UNSUPPORTED_BLEND")
127
140
  end
128
141
 
129
142
  def validate_layer_effects(layer, scene_name, layer_name, issues)
130
143
  params = layer[:params] || {}
131
144
  validate_effect_name(params[:effect], SUPPORTED_POST_EFFECTS, "effect", scene_name, layer_name, issues)
132
145
  validate_effect_name(params[:vj_effect], SUPPORTED_VJ_EFFECTS, "vj_effect", scene_name, layer_name, issues)
146
+ validate_effect_chain(
147
+ params[:post_effects],
148
+ SUPPORTED_POST_EFFECTS + SUPPORTED_VJ_EFFECTS,
149
+ "post_effects",
150
+ scene_name,
151
+ layer_name,
152
+ issues
153
+ )
133
154
  end
134
155
 
135
156
  def validate_shape_layer(layer, scene_name, layer_name, issues)
136
157
  params = layer[:params] || {}
137
158
  return unless shape_layer?(layer) || shape_value(params, :shapes)
138
159
 
139
- Array(shape_value(params, :shapes)).each_with_index do |shape, index|
160
+ shapes = Array(shape_value(params, :shapes))
161
+ duplicate_values(shapes.filter_map { |shape| shape_value(shape_hash(shape), :id)&.to_sym }).each do |id|
162
+ issues << error("scene #{scene_name} layer #{layer_name} has duplicate shape id: #{id}", code: "E_DUPLICATE_SHAPE")
163
+ end
164
+
165
+ shapes.each_with_index do |shape, index|
140
166
  values = shape_hash(shape)
141
167
  label = shape_label(values, index)
142
168
  validate_shape_kind(values, label, scene_name, layer_name, issues)
@@ -150,21 +176,21 @@ module Vizcore
150
176
  kind = shape_value(shape, :kind)&.to_sym
151
177
  return if SUPPORTED_SHAPE_KINDS.include?(kind)
152
178
 
153
- issues << warn("scene #{scene_name} layer #{layer_name} shape #{label} uses unsupported kind: #{kind || "missing"}")
179
+ issues << warn("scene #{scene_name} layer #{layer_name} shape #{label} uses unsupported kind: #{kind || "missing"}", code: "W_UNSUPPORTED_SHAPE_KIND")
154
180
  end
155
181
 
156
182
  def validate_shape_fallback_fill(shape, label, scene_name, layer_name, issues)
157
183
  fill = shape_value(shape, :fill)
158
184
  return if fill.nil? || fill.to_s.empty?
159
185
 
160
- issues << warn("scene #{scene_name} layer #{layer_name} shape #{label} fill may be ignored by line fallback")
186
+ issues << warn("scene #{scene_name} layer #{layer_name} shape #{label} fill may be ignored by line fallback", code: "W_SHAPE_FILL_FALLBACK")
161
187
  end
162
188
 
163
189
  def validate_shape_opacity(shape, label, scene_name, layer_name, issues)
164
190
  opacity = numeric_shape_value(shape_value(shape, :opacity))
165
191
  return unless opacity && (opacity.negative? || opacity > 1)
166
192
 
167
- issues << warn("scene #{scene_name} layer #{layer_name} shape #{label} opacity #{opacity} is outside 0..1; renderer will clamp")
193
+ issues << warn("scene #{scene_name} layer #{layer_name} shape #{label} opacity #{opacity} is outside 0..1; renderer will clamp", code: "W_SHAPE_OPACITY_RANGE")
168
194
  end
169
195
 
170
196
  def validate_shape_scale(shape, label, scene_name, layer_name, issues)
@@ -172,9 +198,9 @@ module Vizcore
172
198
  next unless scale
173
199
 
174
200
  if scale.zero?
175
- issues << warn("scene #{scene_name} layer #{layer_name} shape #{label} scale includes 0; shape may collapse")
201
+ issues << warn("scene #{scene_name} layer #{layer_name} shape #{label} scale includes 0; shape may collapse", code: "W_SHAPE_ZERO_SCALE")
176
202
  elsif scale.abs > 8
177
- issues << warn("scene #{scene_name} layer #{layer_name} shape #{label} scale #{scale} is extreme; renderer will clamp")
203
+ issues << warn("scene #{scene_name} layer #{layer_name} shape #{label} scale #{scale} is extreme; renderer will clamp", code: "W_SHAPE_EXTREME_SCALE")
178
204
  end
179
205
  end
180
206
  end
@@ -230,35 +256,146 @@ module Vizcore
230
256
  return unless value
231
257
  return if supported.include?(value.to_sym)
232
258
 
233
- issues << error("scene #{scene_name} layer #{layer_name} uses unsupported #{field}: #{value}")
259
+ issues << error("scene #{scene_name} layer #{layer_name} uses unsupported #{field}: #{value}", code: "E_UNSUPPORTED_#{field.to_s.upcase}")
260
+ end
261
+
262
+ def validate_effect_chain(value, supported, field, scene_name, layer_name, issues)
263
+ return if value.nil? || Array(value).empty?
264
+ unless value.is_a?(Array)
265
+ issues << error("scene #{scene_name} layer #{layer_name} #{field} must be an array", code: "E_INVALID_#{field.upcase}_FORMAT")
266
+ return
267
+ end
268
+
269
+ value.each_with_index do |name, index|
270
+ next if name.nil?
271
+ normalized = symbol_or_nil(name)
272
+ next if normalized && supported.include?(normalized)
273
+
274
+ issues << error("scene #{scene_name} layer #{layer_name} uses unsupported #{field} at index #{index}: #{name}", code: "E_UNSUPPORTED_#{field.upcase}")
275
+ end
234
276
  end
235
277
 
236
- def validate_mappings(mappings, scene_name, layer_name, issues)
278
+ def symbol_or_nil(value)
279
+ value.to_sym
280
+ rescue StandardError
281
+ nil
282
+ end
283
+
284
+ def validate_mappings(mappings, layer, scene_name, layer_name, issues)
285
+ duplicate_values(mappings.filter_map { |mapping| mapping[:target]&.to_sym }).each do |target|
286
+ issues << warn("scene #{scene_name} layer #{layer_name} maps multiple sources to target: #{target}", code: "W_DUPLICATE_MAPPING_TARGET")
287
+ end
288
+
237
289
  mappings.each do |mapping|
238
290
  source = Hash(mapping[:source] || {})
239
291
  kind = source[:kind]&.to_sym
240
- issues << error("scene #{scene_name} layer #{layer_name} has mapping without source kind") unless kind
292
+ issues << error("scene #{scene_name} layer #{layer_name} has mapping without source kind", code: "E_MAPPING_SOURCE_MISSING") unless kind
241
293
  next unless kind
242
294
 
243
295
  validate_mapping_source(kind, source, scene_name, layer_name, issues)
244
- issues << error("scene #{scene_name} layer #{layer_name} has mapping without target") unless mapping[:target]
296
+ issues << error("scene #{scene_name} layer #{layer_name} has mapping without target", code: "E_MAPPING_TARGET_MISSING") unless mapping[:target]
297
+ validate_mapping_target(mapping[:target], layer, scene_name, layer_name, issues)
245
298
  validate_transform(Hash(mapping[:transform] || {}), scene_name, layer_name, mapping[:target], issues)
246
299
  end
247
300
  end
248
301
 
249
302
  def validate_mapping_source(kind, source, scene_name, layer_name, issues)
250
303
  unless MAPPING_SOURCE_KINDS.include?(kind)
251
- issues << error("scene #{scene_name} layer #{layer_name} uses unsupported mapping source: #{kind}")
304
+ issues << error("scene #{scene_name} layer #{layer_name} uses unsupported mapping source: #{kind}", code: "E_UNKNOWN_MAPPING_SOURCE")
252
305
  end
253
306
  validate_frequency_band(source, scene_name, layer_name, issues) if kind == :frequency_band
307
+ validate_frequency_band(source, scene_name, layer_name, issues) if kind == :frequency_band_peak
254
308
  validate_onset_band(source, scene_name, layer_name, issues) if kind == :onset
309
+ validate_global_source(source, scene_name, layer_name, issues) if kind == :global
310
+ validate_lfo_source(source, scene_name, layer_name, issues) if kind == :lfo
311
+ validate_envelope_source(source, scene_name, layer_name, issues) if kind == :adsr || kind == :envelope
312
+ return unless kind == :adsr || kind == :envelope
313
+
314
+ validate_source_option_type(source, :attack, scene_name, layer_name, issues, allow_negative: false)
315
+ validate_source_option_type(source, :decay, scene_name, layer_name, issues, allow_negative: false)
316
+ validate_source_option_type(source, :release, scene_name, layer_name, issues, allow_negative: false)
317
+ validate_source_option_type(source, :threshold, scene_name, layer_name, issues, allow_negative: true)
318
+ validate_source_option_type(source, :peak, scene_name, layer_name, issues, allow_negative: true)
319
+ validate_source_option_type(source, :sustain, scene_name, layer_name, issues, allow_negative: true, min: 0.0, max: 1.0)
320
+ end
321
+
322
+ def validate_envelope_source(source, scene_name, layer_name, issues)
323
+ raw_nested = source[:source]
324
+ raw_nested = source["source"] if raw_nested.nil?
325
+ raw_nested = :kick if raw_nested.nil?
326
+ nested = normalize_mapping_source(raw_nested)
327
+ nested_kind = nested[:kind]&.to_sym
328
+ unless MAPPING_SOURCE_KINDS.include?(nested_kind)
329
+ issues << error(
330
+ "scene #{scene_name} layer #{layer_name} uses unsupported envelope source: #{nested_kind}",
331
+ code: "E_ENVELOPE_SOURCE"
332
+ )
333
+ return
334
+ end
335
+
336
+ if nested_kind == :adsr || nested_kind == :envelope
337
+ issues << error(
338
+ "scene #{scene_name} layer #{layer_name} does not support nested envelope source: #{nested_kind}",
339
+ code: "E_ENVELOPE_SOURCE"
340
+ )
341
+ return
342
+ end
343
+
344
+ validate_mapping_source(nested_kind, nested, scene_name, layer_name, issues)
345
+ end
346
+
347
+ def validate_source_option_type(source, key, scene_name, layer_name, issues, allow_negative:, min: nil, max: nil)
348
+ value = source[key]
349
+ value = source[key.to_s] if value.nil?
350
+ return if value.nil?
351
+
352
+ numeric = Float(value)
353
+ if min || max
354
+ within_min = min.nil? ? true : numeric >= min
355
+ within_max = max.nil? ? true : numeric <= max
356
+ unless within_min && within_max
357
+ issues << error(
358
+ "scene #{scene_name} layer #{layer_name} envelope option #{key} must be between #{min} and #{max}: #{value}",
359
+ code: "E_ENVELOPE_SOURCE"
360
+ )
361
+ end
362
+ return
363
+ end
364
+
365
+ if !allow_negative && numeric.negative?
366
+ issues << error(
367
+ "scene #{scene_name} layer #{layer_name} envelope option #{key} must be non-negative: #{value}",
368
+ code: "E_ENVELOPE_SOURCE"
369
+ )
370
+ end
371
+ rescue StandardError
372
+ issues << error(
373
+ "scene #{scene_name} layer #{layer_name} envelope option #{key} must be numeric: #{value}",
374
+ code: "E_ENVELOPE_SOURCE"
375
+ )
376
+ end
377
+
378
+ def normalize_mapping_source(raw)
379
+ return {} if raw.nil?
380
+ if raw.is_a?(Hash)
381
+ normalized = {}
382
+ raw.each do |key, value|
383
+ normalized[key.to_sym] = value.respond_to?(:to_sym) ? value.to_sym : value
384
+ end
385
+ return normalized if normalized[:kind]
386
+ return {}
387
+ end
388
+
389
+ return { kind: raw.to_sym } if raw.respond_to?(:to_sym)
390
+ rescue StandardError
391
+ {}
255
392
  end
256
393
 
257
394
  def validate_frequency_band(source, scene_name, layer_name, issues)
258
395
  band = source[:band]&.to_sym
259
396
  return if FREQUENCY_BANDS.include?(band)
260
397
 
261
- issues << error("scene #{scene_name} layer #{layer_name} uses unsupported frequency band: #{band.inspect}")
398
+ issues << error("scene #{scene_name} layer #{layer_name} uses unsupported frequency band: #{band.inspect}", code: "E_UNKNOWN_FREQUENCY_BAND")
262
399
  end
263
400
 
264
401
  def validate_onset_band(source, scene_name, layer_name, issues)
@@ -267,35 +404,82 @@ module Vizcore
267
404
  band = source[:band]&.to_sym
268
405
  return if FREQUENCY_BANDS.include?(band)
269
406
 
270
- issues << error("scene #{scene_name} layer #{layer_name} uses unsupported onset band: #{band.inspect}")
407
+ issues << error("scene #{scene_name} layer #{layer_name} uses unsupported onset band: #{band.inspect}", code: "E_UNKNOWN_ONSET_BAND")
408
+ end
409
+
410
+ def validate_global_source(source, scene_name, layer_name, issues)
411
+ name = source[:name] || source["name"]
412
+ return unless name.to_s.strip.empty?
413
+
414
+ issues << error("scene #{scene_name} layer #{layer_name} uses global mapping source without name", code: "E_GLOBAL_SOURCE_NAME")
415
+ end
416
+
417
+ def validate_lfo_source(source, scene_name, layer_name, issues)
418
+ wave = source[:wave] || source["wave"] || :sine
419
+ wave_name = wave.respond_to?(:to_sym) ? wave.to_sym : nil
420
+ unless LFO_WAVES.include?(wave_name)
421
+ issues << error("scene #{scene_name} layer #{layer_name} uses unsupported LFO wave: #{wave}", code: "E_LFO_WAVE")
422
+ end
423
+
424
+ %i[rate phase].each do |key|
425
+ value = source[key] || source[key.to_s]
426
+ next if value.nil?
427
+
428
+ Float(value)
429
+ rescue ArgumentError, TypeError
430
+ issues << error("scene #{scene_name} layer #{layer_name} has non-numeric LFO #{key}: #{value}", code: "E_LFO_#{key.to_s.upcase}")
431
+ end
271
432
  end
272
433
 
273
434
  def validate_transform(transform, scene_name, layer_name, target, issues)
435
+ if transform.key?(:as)
436
+ mode = transform[:as]
437
+ mode_value = mode.respond_to?(:to_sym) ? mode.to_sym : nil
438
+ unless %i[continuous trigger].include?(mode_value)
439
+ issues << error("scene #{scene_name} layer #{layer_name} mapping #{target} has unsupported as mode: #{mode}", code: "E_MAPPING_TRANSFORM_AS")
440
+ end
441
+ end
442
+
274
443
  return unless transform.key?(:min) && transform.key?(:max)
275
444
  return unless Float(transform[:min]) > Float(transform[:max])
276
445
 
277
- issues << error("scene #{scene_name} layer #{layer_name} mapping #{target} has min greater than max")
446
+ issues << error("scene #{scene_name} layer #{layer_name} mapping #{target} has min greater than max", code: "E_MAPPING_RANGE")
278
447
  rescue ArgumentError, TypeError
279
- issues << error("scene #{scene_name} layer #{layer_name} mapping #{target} has non-numeric min/max")
448
+ issues << error("scene #{scene_name} layer #{layer_name} mapping #{target} has non-numeric min/max", code: "E_MAPPING_RANGE")
280
449
  end
281
450
 
282
451
  def validate_transitions(transitions, names, issues)
283
452
  transitions.each do |transition|
284
453
  from = transition[:from]&.to_sym
285
454
  to = transition[:to]&.to_sym
286
- issues << error("transition has unknown source scene: #{from}") if from && !names.include?(from)
287
- issues << error("transition has unknown target scene: #{to}") if to && !names.include?(to)
455
+ issues << error("transition has unknown source scene: #{from}", code: "E_UNKNOWN_TRANSITION_SOURCE") if from && !names.include?(from)
456
+ issues << error("transition has unknown target scene: #{to}", code: "E_UNKNOWN_TRANSITION_TARGET") if to && !names.include?(to)
288
457
  unless transition[:trigger].respond_to?(:call)
289
- issues << warn("transition #{from || '?'} -> #{to || '?'} has no trigger block")
458
+ issues << warn("transition #{from || '?'} -> #{to || '?'} has no trigger block", code: "W_TRANSITION_WITHOUT_TRIGGER")
459
+ end
460
+ end
461
+ end
462
+
463
+ def validate_timelines(timelines, names, issues)
464
+ timelines.each do |timeline|
465
+ Array(timeline).each do |entry|
466
+ scene = entry[:scene]&.to_sym
467
+ next if scene && names.include?(scene)
468
+
469
+ issues << error("timeline references unknown scene: #{entry[:scene]}", code: "E_UNKNOWN_TIMELINE_SCENE")
290
470
  end
291
471
  end
292
472
  end
293
473
 
294
474
  def validate_key_mappings(mappings, names, issues)
475
+ duplicate_values(mappings.map { |mapping| (mapping[:key] || mapping["key"]).to_s.strip.downcase }.reject(&:empty?)).each do |key|
476
+ issues << error("duplicate key mapping: #{key}", code: "E_DUPLICATE_KEY_MAPPING")
477
+ end
478
+
295
479
  mappings.each do |mapping|
296
480
  key = mapping[:key] || mapping["key"]
297
481
  action = mapping[:action] || mapping["action"]
298
- issues << error("key mapping has empty key") if key.to_s.strip.empty?
482
+ issues << error("key mapping has empty key", code: "E_KEY_EMPTY") if key.to_s.strip.empty?
299
483
  validate_key_action(action.is_a?(Hash) ? action : {}, names, key, issues)
300
484
  end
301
485
  end
@@ -306,13 +490,277 @@ module Vizcore
306
490
  when :switch_scene
307
491
  scene = action[:scene] || action["scene"]
308
492
  scene_name = scene&.to_sym
309
- issues << error("key #{key} switches to unknown scene: #{scene}") unless scene_name && names.include?(scene_name)
493
+ issues << error("key #{key} switches to unknown scene: #{scene}", code: "E_UNKNOWN_KEY_SCENE") unless scene_name && names.include?(scene_name)
310
494
  when :live_control
311
495
  control = (action[:control] || action["control"]).to_s.to_sym
312
- issues << error("key #{key} uses unsupported live control: #{control}") unless %i[blackout freeze].include?(control)
496
+ issues << error("key #{key} uses unsupported live control: #{control}", code: "E_UNKNOWN_KEY_CONTROL") unless %i[blackout freeze].include?(control)
497
+ else
498
+ issues << error("key #{key} has unsupported action: #{type}", code: "E_UNKNOWN_KEY_ACTION")
499
+ end
500
+ end
501
+
502
+ def validate_midi_maps(mappings, scenes, issues)
503
+ deduped = Set.new
504
+ midi_trigger_conflicts(Array(mappings)).each do |conflict|
505
+ label = midi_trigger_signature_label(conflict)
506
+ next if midi_trigger_conflicts_allow_multiple?(conflict)
507
+ next if deduped.include?(label)
508
+
509
+ deduped << label
510
+ issues << error("duplicate MIDI mapping: #{label}", code: "E_DUPLICATE_MIDI_MAPPING")
511
+ end
512
+ mappings.each do |mapping|
513
+ validate_midi_trigger(Hash(mapping[:trigger] || mapping["trigger"] || {}), issues)
514
+ validate_midi_action(mapping[:action] || mapping["action"], scenes, issues)
515
+ end
516
+ end
517
+
518
+ def validate_midi_action(action, scenes, issues)
519
+ return unless action.respond_to?(:call)
520
+
521
+ context = Vizcore::DSL::MidiMapExecutor::ActionContext.new(
522
+ scenes: scene_lookup(scenes),
523
+ globals: {}
524
+ )
525
+ if action.arity.zero?
526
+ context.instance_exec(&action)
313
527
  else
314
- issues << error("key #{key} has unsupported action: #{type}")
528
+ context.instance_exec(64, &action)
529
+ end
530
+
531
+ context.unknown_scene_names.each do |scene|
532
+ issues << error("MIDI mapping switches to unknown scene: #{scene}", code: "E_UNKNOWN_MIDI_SCENE")
533
+ end
534
+ rescue StandardError
535
+ nil
536
+ end
537
+
538
+ def scene_lookup(scenes)
539
+ Array(scenes).each_with_object({}) do |scene, output|
540
+ next unless (name = scene[:name])
541
+
542
+ key = name.to_sym
543
+ output[key] = {
544
+ name: key,
545
+ layers: Array(scene[:layers]).map { |layer| Vizcore::DeepCopy.copy(layer) }
546
+ }
547
+ end
548
+ end
549
+
550
+ def midi_trigger_conflicts(mappings)
551
+ entries = indexed_midi_triggers(Array(mappings))
552
+ grouped = entries.group_by { |entry| [entry[:kind], entry[:value]] }
553
+ conflicts = []
554
+
555
+ grouped.each_value do |group|
556
+ by_channel = group.group_by { |entry| entry[:channel] }
557
+ wildcard = by_channel.delete(nil) || []
558
+ explicit = by_channel.values.flatten
559
+
560
+ conflicts << (wildcard + explicit) if wildcard.any? && explicit.any?
561
+ conflicts << wildcard if wildcard.length > 1
562
+ by_channel.each_value do |channel_entries|
563
+ conflicts << channel_entries if channel_entries.length > 1
564
+ end
565
+ end
566
+
567
+ conflicts.select { |entries| entries.length > 1 }
568
+ end
569
+
570
+ def indexed_midi_triggers(mappings)
571
+ Array(mappings).each_with_index.filter_map do |mapping, index|
572
+ values = Hash(mapping[:trigger] || mapping["trigger"] || {})
573
+ spec = midi_trigger_spec(values)
574
+ next if spec.nil?
575
+
576
+ channel = midi_trigger_channel(values)
577
+ next if channel == :invalid
578
+
579
+ kind, value = spec
580
+ {
581
+ index: index,
582
+ kind: kind,
583
+ value: value,
584
+ channel: channel == :any ? nil : channel,
585
+ allow_multiple: !!(values[:allow_multiple] || values["allow_multiple"])
586
+ }
587
+ end
588
+ end
589
+
590
+ def midi_trigger_conflicts_allow_multiple?(entries)
591
+ entries.all? { |entry| entry[:allow_multiple] }
592
+ end
593
+
594
+ def midi_trigger_channel(values)
595
+ raw_channel = values[:channel]
596
+ raw_channel = values["channel"] if raw_channel.nil?
597
+ return :any if raw_channel.nil?
598
+
599
+ Integer(raw_channel)
600
+ rescue StandardError
601
+ :invalid
602
+ end
603
+
604
+ def midi_trigger_spec(values)
605
+ %i[note cc pc].each do |key|
606
+ raw = values[key]
607
+ raw = values[key.to_s] if raw.nil?
608
+ next if raw.nil?
609
+
610
+ return [key, Integer(raw)]
315
611
  end
612
+ nil
613
+ rescue StandardError
614
+ nil
615
+ end
616
+
617
+ def midi_trigger_signature_label(entries)
618
+ first = entries.first
619
+ return "MIDI mapping" if first.nil?
620
+
621
+ channels = entries.map { |entry| entry[:channel] }.uniq
622
+ explicit_channels = channels.compact
623
+ return midi_trigger_signature(kind: first[:kind], value: first[:value], channel: nil) if channels.include?(nil) || explicit_channels.length != 1
624
+
625
+ midi_trigger_signature(kind: first[:kind], value: first[:value], channel: explicit_channels.first)
626
+ end
627
+
628
+ def midi_trigger_signature(kind:, value:, channel:)
629
+ channel_part = channel.nil? ? "" : ":ch#{channel}"
630
+ "#{kind}:#{value}#{channel_part}"
631
+ end
632
+
633
+ def validate_midi_trigger(trigger, issues)
634
+ channel = trigger[:channel] || trigger["channel"]
635
+ return if channel.nil?
636
+
637
+ value = Integer(channel)
638
+ return if value.between?(0, 15)
639
+
640
+ issues << error("MIDI mapping has unsupported channel: #{channel}", code: "E_MIDI_CHANNEL")
641
+ rescue ArgumentError, TypeError
642
+ issues << error("MIDI mapping has non-numeric channel: #{channel}", code: "E_MIDI_CHANNEL")
643
+ end
644
+
645
+ def validate_unknown_layer_params(layer, scene_name, layer_name, type, issues)
646
+ params = Hash(layer[:params] || {})
647
+ declared_params = Array(layer[:param_schema]).filter_map do |entry|
648
+ (entry[:name] || entry["name"])&.to_sym
649
+ end
650
+ allowed = (Vizcore::LayerCatalog.params_for(type).keys + declared_params + STRICT_PARAM_ALLOWLIST).map(&:to_sym).uniq
651
+ unknown = params.keys.map(&:to_sym) - allowed
652
+ unknown.each do |param|
653
+ issues << error("scene #{scene_name} layer #{layer_name} has unknown param in strict mode: #{param}", code: "E_UNKNOWN_LAYER_PARAM")
654
+ end
655
+ rescue StandardError
656
+ nil
657
+ end
658
+
659
+ def validate_mapping_target(target, layer, scene_name, layer_name, issues)
660
+ value = target.to_s
661
+ return unless value.start_with?("shapes.")
662
+
663
+ validate_shape_mapping_target(value, layer, scene_name, layer_name, issues)
664
+ rescue StandardError
665
+ issues << error("scene #{scene_name} layer #{layer_name} mapping #{target} has invalid target", code: "E_MAPPING_TARGET")
666
+ end
667
+
668
+ def validate_shape_mapping_target(target, layer, scene_name, layer_name, issues)
669
+ parts = target.split(".")
670
+ return unless parts.length >= 2
671
+
672
+ shape_index = parse_shape_index(parts[1])
673
+ if shape_index.nil?
674
+ issues << error("scene #{scene_name} layer #{layer_name} mapping #{target} has non-numeric shape index", code: "E_MAPPING_TARGET")
675
+ return
676
+ end
677
+
678
+ shapes = Array(shape_value(Hash(layer[:params] || {}), :shapes))
679
+ unless shape_index.between?(0, shapes.length - 1)
680
+ issues << error("scene #{scene_name} layer #{layer_name} mapping #{target} references missing shape index", code: "E_MAPPING_TARGET")
681
+ return
682
+ end
683
+
684
+ validate_nested_shape_path(parts[2..], shapes[shape_index], target, scene_name, layer_name, issues)
685
+ end
686
+
687
+ def validate_nested_shape_path(parts, container, target, scene_name, layer_name, issues)
688
+ return if parts.empty?
689
+
690
+ current_part = parts.first
691
+ next_part = parts[1]
692
+ remaining = parts.drop(1)
693
+ current_is_array = container.is_a?(Array)
694
+
695
+ if current_is_array && integer_key?(current_part)
696
+ array_index = parse_shape_index(current_part)
697
+ if array_index.nil?
698
+ issues << error("scene #{scene_name} layer #{layer_name} mapping #{target} has invalid array index", code: "E_MAPPING_TARGET")
699
+ return
700
+ end
701
+
702
+ if remaining.empty?
703
+ return
704
+ end
705
+
706
+ unless validate_array_index(container, array_index, target, scene_name, layer_name, issues)
707
+ return
708
+ end
709
+
710
+ next_container = Array(container)[array_index]
711
+ if next_container.is_a?(Hash) || next_container.is_a?(Array)
712
+ validate_nested_shape_path(remaining, next_container, target, scene_name, layer_name, issues)
713
+ else
714
+ issues << error("scene #{scene_name} layer #{layer_name} mapping #{target} references non-container segment #{current_part}", code: "E_MAPPING_TARGET")
715
+ end
716
+
717
+ return
718
+ end
719
+
720
+ unless container.is_a?(Hash)
721
+ issues << error("scene #{scene_name} layer #{layer_name} mapping #{target} references non-container segment #{current_part}", code: "E_MAPPING_TARGET")
722
+ return
723
+ end
724
+
725
+ key = current_part.to_sym
726
+ if container.key?(key)
727
+ validate_nested_shape_path(remaining, container[key], target, scene_name, layer_name, issues)
728
+ return
729
+ end
730
+
731
+ if container.key?(current_part)
732
+ validate_nested_shape_path(remaining, container[current_part], target, scene_name, layer_name, issues)
733
+ return
734
+ end
735
+
736
+ return if remaining.empty?
737
+
738
+ # Missing key is valid for simple nested hashes; continue by simulating
739
+ # the runtime container creation used by MappingResolver.
740
+ simulate_container = integer_key?(next_part) ? [] : {}
741
+ validate_nested_shape_path(remaining, simulate_container, target, scene_name, layer_name, issues)
742
+ end
743
+
744
+ def validate_array_index(container, index, target, scene_name, layer_name, issues)
745
+ unless container.is_a?(Array)
746
+ issues << error("scene #{scene_name} layer #{layer_name} mapping #{target} references array index on non-array", code: "E_MAPPING_TARGET")
747
+ return false
748
+ end
749
+
750
+ return true if index.between?(0, container.length - 1)
751
+
752
+ issues << error("scene #{scene_name} layer #{layer_name} mapping #{target} references missing array index #{index}", code: "E_MAPPING_TARGET")
753
+ false
754
+ end
755
+
756
+ def parse_shape_index(value)
757
+ Integer(value)
758
+ rescue ArgumentError, TypeError
759
+ nil
760
+ end
761
+
762
+ def integer_key?(value)
763
+ value.to_s.match?(%r{\A\d+\z})
316
764
  end
317
765
 
318
766
  def scene_names(scenes)
@@ -325,12 +773,12 @@ module Vizcore
325
773
  counts.select { |_value, count| count > 1 }.keys
326
774
  end
327
775
 
328
- def error(message)
329
- Issue.new(severity: :error, message: message)
776
+ def error(message, code: "E_VALIDATION")
777
+ Issue.new(severity: :error, code: code, message: message)
330
778
  end
331
779
 
332
- def warn(message)
333
- Issue.new(severity: :warn, message: message)
780
+ def warn(message, code: "W_VALIDATION")
781
+ Issue.new(severity: :warn, code: code, message: message)
334
782
  end
335
783
  end
336
784
  end