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.
- checksums.yaml +4 -4
- data/frontend/index.html +24 -2
- data/frontend/src/audio-inspector.js +9 -0
- data/frontend/src/live-controls.js +219 -7
- data/frontend/src/main.js +447 -57
- data/frontend/src/midi-learn.js +22 -2
- data/frontend/src/performance-monitor.js +137 -1
- data/frontend/src/renderer/engine.js +391 -10
- data/frontend/src/renderer/layer-manager.js +472 -71
- data/frontend/src/runtime-control-preset.js +44 -0
- data/frontend/src/scene-patches.js +159 -0
- data/frontend/src/shader-error-overlay.js +1 -0
- data/frontend/src/visuals/image-renderer.js +19 -0
- data/frontend/src/visuals/particle-system.js +10 -0
- data/frontend/src/visuals/shape-renderer.js +13 -0
- data/frontend/src/visuals/spectrogram-renderer.js +14 -0
- data/frontend/src/visuals/text-renderer.js +13 -0
- data/frontend/src/websocket-client.js +6 -0
- data/lib/vizcore/analysis/adaptive_normalizer.rb +20 -2
- data/lib/vizcore/analysis/bpm_estimator.rb +18 -8
- data/lib/vizcore/analysis/feature_recorder.rb +117 -7
- data/lib/vizcore/analysis/feature_replay.rb +48 -9
- data/lib/vizcore/analysis/pipeline.rb +258 -9
- data/lib/vizcore/analysis/tap_tempo.rb +17 -2
- data/lib/vizcore/audio/calibration.rb +156 -0
- data/lib/vizcore/audio/file_input.rb +28 -0
- data/lib/vizcore/audio/input_manager.rb +36 -1
- data/lib/vizcore/audio/midi_input.rb +5 -0
- data/lib/vizcore/audio/ring_buffer.rb +22 -0
- data/lib/vizcore/audio.rb +1 -0
- data/lib/vizcore/cli/dsl_reference.rb +64 -8
- data/lib/vizcore/cli/plugin_checker.rb +93 -0
- data/lib/vizcore/cli/scene_diagnostics.rb +2 -2
- data/lib/vizcore/cli/scene_inspector.rb +35 -1
- data/lib/vizcore/cli/scene_validator.rb +487 -39
- data/lib/vizcore/cli/shader_template.rb +7 -2
- data/lib/vizcore/cli/shader_uniform_docs.rb +11 -0
- data/lib/vizcore/cli.rb +268 -15
- data/lib/vizcore/config.rb +40 -3
- data/lib/vizcore/control_preset.rb +29 -0
- data/lib/vizcore/deep_copy.rb +21 -0
- data/lib/vizcore/dsl/color_helpers.rb +155 -0
- data/lib/vizcore/dsl/engine.rb +219 -23
- data/lib/vizcore/dsl/layer_builder.rb +278 -15
- data/lib/vizcore/dsl/layer_group_builder.rb +10 -12
- data/lib/vizcore/dsl/layout_helpers.rb +290 -0
- data/lib/vizcore/dsl/mapping_preset_builder.rb +41 -0
- data/lib/vizcore/dsl/mapping_resolver.rb +404 -22
- data/lib/vizcore/dsl/mapping_transform_builder.rb +50 -0
- data/lib/vizcore/dsl/midi_map_executor.rb +219 -23
- data/lib/vizcore/dsl/reaction_builder.rb +1 -0
- data/lib/vizcore/dsl/scene_builder.rb +83 -13
- data/lib/vizcore/dsl/shader_source_resolver.rb +1 -10
- data/lib/vizcore/dsl/style_builder.rb +3 -0
- data/lib/vizcore/dsl/timeline_builder.rb +91 -8
- data/lib/vizcore/dsl/transition_controller.rb +157 -18
- data/lib/vizcore/dsl.rb +2 -0
- data/lib/vizcore/layer_catalog.rb +1 -0
- data/lib/vizcore/plugin_asset_policy.rb +55 -0
- data/lib/vizcore/project_manifest.rb +12 -2
- data/lib/vizcore/renderer/render_sequence.rb +104 -13
- data/lib/vizcore/renderer/scene_frame_source.rb +179 -14
- data/lib/vizcore/renderer/scene_serializer.rb +38 -0
- data/lib/vizcore/renderer/snapshot.rb +4 -3
- data/lib/vizcore/renderer/snapshot_renderer.rb +134 -8
- data/lib/vizcore/scene_trust.rb +31 -0
- data/lib/vizcore/server/frame_broadcaster.rb +469 -23
- data/lib/vizcore/server/rack_app.rb +151 -4
- data/lib/vizcore/server/runner.rb +676 -82
- data/lib/vizcore/server/websocket_handler.rb +236 -14
- data/lib/vizcore/server.rb +21 -0
- data/lib/vizcore/shape.rb +39 -16
- data/lib/vizcore/sync/osc_message.rb +66 -9
- data/lib/vizcore/version.rb +1 -1
- data/lib/vizcore.rb +33 -0
- data/scripts/browser_capture.mjs +31 -2
- data/sig/vizcore.rbs +55 -4
- 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: [
|
|
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 <<
|
|
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))
|
|
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
|
|
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
|
-
|
|
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
|