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
@@ -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,18 +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
26
+ SUPPORTED_SHAPE_KINDS = %i[circle line rect polygon polyline path star].freeze
27
+ STRICT_PARAM_ALLOWLIST = Vizcore::DSL::LayerBuilder::STRICT_PARAM_ALLOWLIST
22
28
 
23
- Issue = Struct.new(:severity, :message, keyword_init: true) do
29
+ Issue = Struct.new(:severity, :code, :message, keyword_init: true) do
24
30
  def error?
25
31
  severity == :error
26
32
  end
33
+
34
+ def to_h
35
+ { severity: severity, code: code, message: message }
36
+ end
27
37
  end
28
38
 
29
39
  Result = Struct.new(:definition, :issues, keyword_init: true) do
@@ -40,10 +50,11 @@ module Vizcore
40
50
  end
41
51
  end
42
52
 
43
- 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)
44
54
  @scene_file = scene_file
45
55
  @loader = loader
46
56
  @shader_resolver = shader_resolver
57
+ @strict = !!strict
47
58
  end
48
59
 
49
60
  def call
@@ -52,7 +63,7 @@ module Vizcore
52
63
  rescue StandardError => e
53
64
  Result.new(
54
65
  definition: nil,
55
- 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")]
56
67
  )
57
68
  end
58
69
 
@@ -69,27 +80,29 @@ module Vizcore
69
80
  validate_scenes(scenes, issues)
70
81
  names = scene_names(scenes)
71
82
  validate_transitions(Array(definition[:transitions]), names, issues)
83
+ validate_timelines(Array(definition[:timelines]), names, issues)
72
84
  validate_key_mappings(Array(definition[:key_mappings]), names, issues)
85
+ validate_midi_maps(Array(definition[:midi_maps]), scenes, issues)
73
86
  issues
74
87
  end
75
88
 
76
89
  def validate_scenes(scenes, issues)
77
- issues << error("no scenes defined") if scenes.empty?
90
+ issues << error("no scenes defined", code: "E_NO_SCENES") if scenes.empty?
78
91
  duplicate_values(scenes.filter_map { |scene| scene[:name]&.to_sym }).each do |name|
79
- issues << error("duplicate scene name: #{name}")
92
+ issues << error("duplicate scene name: #{name}", code: "E_DUPLICATE_SCENE")
80
93
  end
81
94
 
82
95
  scenes.each do |scene|
83
96
  scene_name = scene[:name] || "(unnamed)"
84
97
  layers = Array(scene[:layers])
85
- 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?
86
99
  validate_layers(layers, scene_name, issues)
87
100
  end
88
101
  end
89
102
 
90
103
  def validate_layers(layers, scene_name, issues)
91
104
  duplicate_values(layers.filter_map { |layer| layer[:name]&.to_sym }).each do |name|
92
- 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")
93
106
  end
94
107
 
95
108
  layers.each do |layer|
@@ -101,19 +114,21 @@ module Vizcore
101
114
  layer_name = layer[:name] || "(unnamed)"
102
115
  type = layer[:type]&.to_sym || :geometry
103
116
  unless supported_layer_types.include?(type)
104
- 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")
105
118
  end
106
119
 
107
120
  shader = layer[:shader]&.to_sym
108
121
  if shader && !BUILTIN_SHADERS.include?(shader)
109
- 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")
110
123
  end
111
124
 
112
125
  glsl_source = layer[:glsl_source]
113
- 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]
114
128
  validate_blend_mode(layer, scene_name, layer_name, issues)
115
129
  validate_layer_effects(layer, scene_name, layer_name, issues)
116
- validate_mappings(Array(layer[:mappings]), scene_name, layer_name, issues)
130
+ validate_shape_layer(layer, scene_name, layer_name, issues)
131
+ validate_mappings(Array(layer[:mappings]), layer, scene_name, layer_name, issues)
117
132
  end
118
133
 
119
134
  def validate_blend_mode(layer, scene_name, layer_name, issues)
@@ -121,52 +136,266 @@ module Vizcore
121
136
  return unless blend
122
137
  return if SUPPORTED_BLEND_MODES.include?(blend.to_sym)
123
138
 
124
- 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")
125
140
  end
126
141
 
127
142
  def validate_layer_effects(layer, scene_name, layer_name, issues)
128
143
  params = layer[:params] || {}
129
144
  validate_effect_name(params[:effect], SUPPORTED_POST_EFFECTS, "effect", scene_name, layer_name, issues)
130
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
+ )
154
+ end
155
+
156
+ def validate_shape_layer(layer, scene_name, layer_name, issues)
157
+ params = layer[:params] || {}
158
+ return unless shape_layer?(layer) || shape_value(params, :shapes)
159
+
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|
166
+ values = shape_hash(shape)
167
+ label = shape_label(values, index)
168
+ validate_shape_kind(values, label, scene_name, layer_name, issues)
169
+ validate_shape_fallback_fill(values, label, scene_name, layer_name, issues)
170
+ validate_shape_opacity(values, label, scene_name, layer_name, issues)
171
+ validate_shape_scale(values, label, scene_name, layer_name, issues)
172
+ end
173
+ end
174
+
175
+ def validate_shape_kind(shape, label, scene_name, layer_name, issues)
176
+ kind = shape_value(shape, :kind)&.to_sym
177
+ return if SUPPORTED_SHAPE_KINDS.include?(kind)
178
+
179
+ issues << warn("scene #{scene_name} layer #{layer_name} shape #{label} uses unsupported kind: #{kind || "missing"}", code: "W_UNSUPPORTED_SHAPE_KIND")
180
+ end
181
+
182
+ def validate_shape_fallback_fill(shape, label, scene_name, layer_name, issues)
183
+ fill = shape_value(shape, :fill)
184
+ return if fill.nil? || fill.to_s.empty?
185
+
186
+ issues << warn("scene #{scene_name} layer #{layer_name} shape #{label} fill may be ignored by line fallback", code: "W_SHAPE_FILL_FALLBACK")
187
+ end
188
+
189
+ def validate_shape_opacity(shape, label, scene_name, layer_name, issues)
190
+ opacity = numeric_shape_value(shape_value(shape, :opacity))
191
+ return unless opacity && (opacity.negative? || opacity > 1)
192
+
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")
194
+ end
195
+
196
+ def validate_shape_scale(shape, label, scene_name, layer_name, issues)
197
+ scale_values(shape).each do |scale|
198
+ next unless scale
199
+
200
+ if scale.zero?
201
+ issues << warn("scene #{scene_name} layer #{layer_name} shape #{label} scale includes 0; shape may collapse", code: "W_SHAPE_ZERO_SCALE")
202
+ elsif scale.abs > 8
203
+ issues << warn("scene #{scene_name} layer #{layer_name} shape #{label} scale #{scale} is extreme; renderer will clamp", code: "W_SHAPE_EXTREME_SCALE")
204
+ end
205
+ end
131
206
  end
132
207
 
133
208
  def supported_layer_types
134
209
  Vizcore::LayerCatalog.supported_types
135
210
  end
136
211
 
212
+ def shape_layer?(layer)
213
+ %w[shape shapes shape_layer].include?((layer[:type] || layer["type"]).to_s)
214
+ end
215
+
216
+ def shape_label(shape, index)
217
+ id = shape_value(shape, :id)
218
+ id ? "`#{id}`" : "##{index + 1}"
219
+ end
220
+
221
+ def scale_values(shape)
222
+ transform = Hash(shape_value(shape, :transform) || {})
223
+ scale = shape_value(transform, :scale) || shape_value(shape, :scale)
224
+ case scale
225
+ when Hash
226
+ [numeric_shape_value(shape_value(scale, :x)), numeric_shape_value(shape_value(scale, :y))]
227
+ when Array
228
+ [numeric_shape_value(scale[0]), numeric_shape_value(scale[1])]
229
+ else
230
+ [numeric_shape_value(scale)]
231
+ end
232
+ rescue TypeError
233
+ []
234
+ end
235
+
236
+ def numeric_shape_value(value)
237
+ return nil if value.nil?
238
+
239
+ numeric = Float(value)
240
+ numeric if numeric.finite?
241
+ rescue ArgumentError, TypeError
242
+ nil
243
+ end
244
+
245
+ def shape_hash(value)
246
+ Hash(value)
247
+ rescue TypeError
248
+ {}
249
+ end
250
+
251
+ def shape_value(hash, key)
252
+ hash[key] || hash[key.to_s]
253
+ end
254
+
137
255
  def validate_effect_name(value, supported, field, scene_name, layer_name, issues)
138
256
  return unless value
139
257
  return if supported.include?(value.to_sym)
140
258
 
141
- 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
276
+ end
277
+
278
+ def symbol_or_nil(value)
279
+ value.to_sym
280
+ rescue StandardError
281
+ nil
142
282
  end
143
283
 
144
- def validate_mappings(mappings, scene_name, layer_name, issues)
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
+
145
289
  mappings.each do |mapping|
146
290
  source = Hash(mapping[:source] || {})
147
291
  kind = source[:kind]&.to_sym
148
- 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
149
293
  next unless kind
150
294
 
151
295
  validate_mapping_source(kind, source, scene_name, layer_name, issues)
152
- 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)
153
298
  validate_transform(Hash(mapping[:transform] || {}), scene_name, layer_name, mapping[:target], issues)
154
299
  end
155
300
  end
156
301
 
157
302
  def validate_mapping_source(kind, source, scene_name, layer_name, issues)
158
303
  unless MAPPING_SOURCE_KINDS.include?(kind)
159
- 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")
160
305
  end
161
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
162
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
+ {}
163
392
  end
164
393
 
165
394
  def validate_frequency_band(source, scene_name, layer_name, issues)
166
395
  band = source[:band]&.to_sym
167
396
  return if FREQUENCY_BANDS.include?(band)
168
397
 
169
- 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")
170
399
  end
171
400
 
172
401
  def validate_onset_band(source, scene_name, layer_name, issues)
@@ -175,35 +404,82 @@ module Vizcore
175
404
  band = source[:band]&.to_sym
176
405
  return if FREQUENCY_BANDS.include?(band)
177
406
 
178
- 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
179
432
  end
180
433
 
181
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
+
182
443
  return unless transform.key?(:min) && transform.key?(:max)
183
444
  return unless Float(transform[:min]) > Float(transform[:max])
184
445
 
185
- 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")
186
447
  rescue ArgumentError, TypeError
187
- 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")
188
449
  end
189
450
 
190
451
  def validate_transitions(transitions, names, issues)
191
452
  transitions.each do |transition|
192
453
  from = transition[:from]&.to_sym
193
454
  to = transition[:to]&.to_sym
194
- issues << error("transition has unknown source scene: #{from}") if from && !names.include?(from)
195
- 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)
196
457
  unless transition[:trigger].respond_to?(:call)
197
- 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")
198
470
  end
199
471
  end
200
472
  end
201
473
 
202
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
+
203
479
  mappings.each do |mapping|
204
480
  key = mapping[:key] || mapping["key"]
205
481
  action = mapping[:action] || mapping["action"]
206
- 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?
207
483
  validate_key_action(action.is_a?(Hash) ? action : {}, names, key, issues)
208
484
  end
209
485
  end
@@ -214,13 +490,277 @@ module Vizcore
214
490
  when :switch_scene
215
491
  scene = action[:scene] || action["scene"]
216
492
  scene_name = scene&.to_sym
217
- 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)
218
494
  when :live_control
219
495
  control = (action[:control] || action["control"]).to_s.to_sym
220
- 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)
221
527
  else
222
- 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)]
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
223
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})
224
764
  end
225
765
 
226
766
  def scene_names(scenes)
@@ -233,12 +773,12 @@ module Vizcore
233
773
  counts.select { |_value, count| count > 1 }.keys
234
774
  end
235
775
 
236
- def error(message)
237
- Issue.new(severity: :error, message: message)
776
+ def error(message, code: "E_VALIDATION")
777
+ Issue.new(severity: :error, code: code, message: message)
238
778
  end
239
779
 
240
- def warn(message)
241
- Issue.new(severity: :warn, message: message)
780
+ def warn(message, code: "W_VALIDATION")
781
+ Issue.new(severity: :warn, code: code, message: message)
242
782
  end
243
783
  end
244
784
  end