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.
- checksums.yaml +4 -4
- data/README.md +66 -648
- data/docs/assets/playground-worker.js +373 -0
- data/docs/assets/playground.css +440 -0
- data/docs/assets/playground.js +652 -0
- data/docs/index.html +2 -1
- data/docs/playground.html +81 -0
- data/docs/shape_dsl.md +269 -0
- data/frontend/index.html +50 -2
- data/frontend/src/audio-inspector.js +9 -0
- data/frontend/src/custom-shape-param-controls.js +106 -0
- data/frontend/src/live-controls.js +219 -7
- data/frontend/src/main.js +703 -45
- data/frontend/src/mapping-target-selector.js +109 -0
- data/frontend/src/midi-learn.js +22 -2
- data/frontend/src/performance-monitor.js +137 -1
- data/frontend/src/renderer/engine.js +401 -11
- data/frontend/src/renderer/layer-manager.js +490 -75
- 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/shape-editor-controls.js +157 -0
- data/frontend/src/visuals/geometry.js +425 -27
- 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 +488 -0
- data/frontend/src/visuals/spectrogram-renderer.js +14 -0
- data/frontend/src/visuals/svg-arc.js +104 -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 +65 -9
- 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 +573 -33
- 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 +1072 -21
- 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 +549 -13
- 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 +5 -2
- 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 +190 -12
- data/lib/vizcore/renderer/scene_serializer.rb +38 -0
- data/lib/vizcore/renderer/snapshot.rb +4 -3
- data/lib/vizcore/renderer/snapshot_renderer.rb +641 -23
- data/lib/vizcore/scene_trust.rb +31 -0
- data/lib/vizcore/server/frame_broadcaster.rb +513 -18
- data/lib/vizcore/server/rack_app.rb +151 -4
- data/lib/vizcore/server/runner.rb +697 -82
- data/lib/vizcore/server/websocket_handler.rb +236 -14
- data/lib/vizcore/server.rb +21 -0
- data/lib/vizcore/shape.rb +742 -0
- data/lib/vizcore/sync/osc_message.rb +66 -9
- data/lib/vizcore/version.rb +1 -1
- data/lib/vizcore.rb +34 -0
- data/scripts/browser_capture.mjs +31 -2
- data/sig/vizcore.rbs +154 -4
- 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: [
|
|
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 <<
|
|
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
|
-
|
|
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
|
-
|
|
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
|