vizcore 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. checksums.yaml +4 -4
  2. data/frontend/index.html +24 -2
  3. data/frontend/src/audio-inspector.js +9 -0
  4. data/frontend/src/live-controls.js +219 -7
  5. data/frontend/src/main.js +447 -57
  6. data/frontend/src/midi-learn.js +22 -2
  7. data/frontend/src/performance-monitor.js +137 -1
  8. data/frontend/src/renderer/engine.js +391 -10
  9. data/frontend/src/renderer/layer-manager.js +472 -71
  10. data/frontend/src/runtime-control-preset.js +44 -0
  11. data/frontend/src/scene-patches.js +159 -0
  12. data/frontend/src/shader-error-overlay.js +1 -0
  13. data/frontend/src/visuals/image-renderer.js +19 -0
  14. data/frontend/src/visuals/particle-system.js +10 -0
  15. data/frontend/src/visuals/shape-renderer.js +13 -0
  16. data/frontend/src/visuals/spectrogram-renderer.js +14 -0
  17. data/frontend/src/visuals/text-renderer.js +13 -0
  18. data/frontend/src/websocket-client.js +6 -0
  19. data/lib/vizcore/analysis/adaptive_normalizer.rb +20 -2
  20. data/lib/vizcore/analysis/bpm_estimator.rb +18 -8
  21. data/lib/vizcore/analysis/feature_recorder.rb +117 -7
  22. data/lib/vizcore/analysis/feature_replay.rb +48 -9
  23. data/lib/vizcore/analysis/pipeline.rb +258 -9
  24. data/lib/vizcore/analysis/tap_tempo.rb +17 -2
  25. data/lib/vizcore/audio/calibration.rb +156 -0
  26. data/lib/vizcore/audio/file_input.rb +28 -0
  27. data/lib/vizcore/audio/input_manager.rb +36 -1
  28. data/lib/vizcore/audio/midi_input.rb +5 -0
  29. data/lib/vizcore/audio/ring_buffer.rb +22 -0
  30. data/lib/vizcore/audio.rb +1 -0
  31. data/lib/vizcore/cli/dsl_reference.rb +64 -8
  32. data/lib/vizcore/cli/plugin_checker.rb +93 -0
  33. data/lib/vizcore/cli/scene_diagnostics.rb +2 -2
  34. data/lib/vizcore/cli/scene_inspector.rb +35 -1
  35. data/lib/vizcore/cli/scene_validator.rb +487 -39
  36. data/lib/vizcore/cli/shader_template.rb +7 -2
  37. data/lib/vizcore/cli/shader_uniform_docs.rb +11 -0
  38. data/lib/vizcore/cli.rb +268 -15
  39. data/lib/vizcore/config.rb +40 -3
  40. data/lib/vizcore/control_preset.rb +29 -0
  41. data/lib/vizcore/deep_copy.rb +21 -0
  42. data/lib/vizcore/dsl/color_helpers.rb +155 -0
  43. data/lib/vizcore/dsl/engine.rb +219 -23
  44. data/lib/vizcore/dsl/layer_builder.rb +278 -15
  45. data/lib/vizcore/dsl/layer_group_builder.rb +10 -12
  46. data/lib/vizcore/dsl/layout_helpers.rb +290 -0
  47. data/lib/vizcore/dsl/mapping_preset_builder.rb +41 -0
  48. data/lib/vizcore/dsl/mapping_resolver.rb +404 -22
  49. data/lib/vizcore/dsl/mapping_transform_builder.rb +50 -0
  50. data/lib/vizcore/dsl/midi_map_executor.rb +219 -23
  51. data/lib/vizcore/dsl/reaction_builder.rb +1 -0
  52. data/lib/vizcore/dsl/scene_builder.rb +83 -13
  53. data/lib/vizcore/dsl/shader_source_resolver.rb +1 -10
  54. data/lib/vizcore/dsl/style_builder.rb +3 -0
  55. data/lib/vizcore/dsl/timeline_builder.rb +91 -8
  56. data/lib/vizcore/dsl/transition_controller.rb +157 -18
  57. data/lib/vizcore/dsl.rb +2 -0
  58. data/lib/vizcore/layer_catalog.rb +1 -0
  59. data/lib/vizcore/plugin_asset_policy.rb +55 -0
  60. data/lib/vizcore/project_manifest.rb +12 -2
  61. data/lib/vizcore/renderer/render_sequence.rb +104 -13
  62. data/lib/vizcore/renderer/scene_frame_source.rb +179 -14
  63. data/lib/vizcore/renderer/scene_serializer.rb +38 -0
  64. data/lib/vizcore/renderer/snapshot.rb +4 -3
  65. data/lib/vizcore/renderer/snapshot_renderer.rb +134 -8
  66. data/lib/vizcore/scene_trust.rb +31 -0
  67. data/lib/vizcore/server/frame_broadcaster.rb +469 -23
  68. data/lib/vizcore/server/rack_app.rb +151 -4
  69. data/lib/vizcore/server/runner.rb +676 -82
  70. data/lib/vizcore/server/websocket_handler.rb +236 -14
  71. data/lib/vizcore/server.rb +21 -0
  72. data/lib/vizcore/shape.rb +39 -16
  73. data/lib/vizcore/sync/osc_message.rb +66 -9
  74. data/lib/vizcore/version.rb +1 -1
  75. data/lib/vizcore.rb +33 -0
  76. data/scripts/browser_capture.mjs +31 -2
  77. data/sig/vizcore.rbs +55 -4
  78. metadata +18 -3
@@ -50,13 +50,42 @@ module Vizcore
50
50
  input = value.is_a?(Hash) ? value : {}
51
51
  visual_settings = hash_value(input, "visual_settings", "visualSettings", "settings")
52
52
  midi_learn_bindings = hash_value(input, "midi_learn_bindings", "midiLearnBindings", "midi")
53
+ scene_overrides = hash_value(input, "scene_overrides", "sceneOverrides")
53
54
 
54
55
  {}.tap do |payload|
55
56
  payload["visual_settings"] = visual_settings if visual_settings
56
57
  payload["midi_learn_bindings"] = midi_learn_bindings if midi_learn_bindings
58
+ normalized_scene_overrides = normalize_scene_overrides(scene_overrides)
59
+ payload["scene_overrides"] = normalized_scene_overrides if normalized_scene_overrides
57
60
  end
58
61
  end
59
62
 
63
+ def normalize_scene_overrides(value)
64
+ return nil unless value
65
+ raw_overrides = value.is_a?(Hash) ? value : {}
66
+ normalized = {}
67
+
68
+ raw_overrides.each do |raw_scene, raw_override|
69
+ scene_name = raw_scene.to_s.strip
70
+ next if scene_name.empty?
71
+
72
+ scene_override = normalize_scene_override(raw_override)
73
+ next if scene_override.empty?
74
+
75
+ normalized[scene_name] = scene_override
76
+ end
77
+
78
+ normalized.empty? ? nil : normalized
79
+ end
80
+
81
+ def normalize_scene_override(value)
82
+ input = value.is_a?(Hash) ? value : {}
83
+ {
84
+ "visual_settings" => hash_value(input, "visual_settings", "visualSettings"),
85
+ "midi_learn_bindings" => hash_value(input, "midi_learn_bindings", "midiLearnBindings", "midi")
86
+ }.select { |_, entry| entry }
87
+ end
88
+
60
89
  def hash_value(input, *keys)
61
90
  keys.each do |key|
62
91
  value = input[key] || input[key.to_sym]
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vizcore
4
+ # Small recursive copier for plain payload hashes/arrays passed between DSL, server, and renderer.
5
+ module DeepCopy
6
+ module_function
7
+
8
+ # @param value [Object]
9
+ # @return [Object]
10
+ def copy(value)
11
+ case value
12
+ when Hash
13
+ value.each_with_object({}) { |(key, entry), output| output[key] = copy(entry) }
14
+ when Array
15
+ value.map { |entry| copy(entry) }
16
+ else
17
+ value
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vizcore
4
+ module DSL
5
+ # Shared color helper methods for Ruby DSL builders.
6
+ module ColorHelpers
7
+ def hsl(hue, saturation, lightness)
8
+ rgb = hsl_to_rgb(Float(hue), Float(saturation), Float(lightness))
9
+ format("#%02x%02x%02x", *rgb)
10
+ rescue ArgumentError, TypeError
11
+ raise ArgumentError, "hsl requires numeric hue, saturation, and lightness"
12
+ end
13
+
14
+ def hsv(hue, saturation, value)
15
+ rgb = hsv_to_rgb(Float(hue), Float(saturation), Float(value))
16
+ format("#%02x%02x%02x", *rgb)
17
+ rescue ArgumentError, TypeError
18
+ raise ArgumentError, "hsv requires numeric hue, saturation, and value"
19
+ end
20
+
21
+ # Build a reusable gradient descriptor for layer color fields.
22
+ #
23
+ # @param type [Symbol, String] :linear, :radial, or a supported gradient kind
24
+ # @param colors [Array<String>] at least two hex-like color values
25
+ # @param stops [Array<Numeric>, nil] optional stop points in 0.0..1.0
26
+ # @param position [Numeric, nil] optional fixed position in 0.0..1.0
27
+ # @return [Hash]
28
+ def gradient(type: :linear, colors:, stops: nil, position: nil)
29
+ gradient_type = normalize_gradient_type(type)
30
+ normalized_colors = normalize_colors_for_gradient(colors)
31
+ raise ArgumentError, "gradient requires at least two colors" if normalized_colors.length < 2
32
+
33
+ stops = normalize_gradient_stops(stops, normalized_colors.length)
34
+
35
+ descriptor = {
36
+ type: gradient_type,
37
+ colors: normalized_colors
38
+ }
39
+ descriptor[:stops] = stops if stops
40
+ descriptor[:position] = normalize_gradient_position(position) unless position.nil?
41
+ { gradient: descriptor }
42
+ rescue ArgumentError, TypeError
43
+ raise
44
+ rescue StandardError
45
+ raise ArgumentError, "gradient requires numeric or parseable color values"
46
+ end
47
+
48
+ private
49
+
50
+ def hsl_to_rgb(hue, saturation, lightness)
51
+ hue = hue % 360.0
52
+ saturation = normalize_percent_value(saturation)
53
+ lightness = normalize_percent_value(lightness)
54
+
55
+ return [0, 0, 0] if saturation.zero?
56
+
57
+ q = lightness < 0.5 ? lightness * (1.0 + saturation) : lightness + saturation - (lightness * saturation)
58
+ p = 2.0 * lightness - q
59
+ h = hue / 360.0
60
+
61
+ [
62
+ hue_channel_to_rgb(h + 1.0 / 3.0, p, q),
63
+ hue_channel_to_rgb(h, p, q),
64
+ hue_channel_to_rgb(h - 1.0 / 3.0, p, q)
65
+ ]
66
+ end
67
+
68
+ def hue_channel_to_rgb(value, p, q)
69
+ value += 1.0 while value < 0.0
70
+ value -= 1.0 while value > 1.0
71
+
72
+ channel =
73
+ if value < 1.0 / 6.0
74
+ p + (q - p) * 6.0 * value
75
+ elsif value < 1.0 / 2.0
76
+ q
77
+ elsif value < 2.0 / 3.0
78
+ p + (q - p) * (2.0 / 3.0 - value) * 6.0
79
+ else
80
+ p
81
+ end
82
+
83
+ (channel * 255.0).round.clamp(0, 255)
84
+ end
85
+
86
+ def hsv_to_rgb(hue, saturation, value)
87
+ hue = hue % 360.0
88
+ saturation = normalize_percent_value(saturation)
89
+ value = normalize_percent_value(value)
90
+ return [0, 0, 0] if saturation.zero?
91
+
92
+ sector = hue / 60.0
93
+ i = sector.floor.to_i
94
+ f = sector - i
95
+ p = value * (1.0 - saturation)
96
+ q = value * (1.0 - f * saturation)
97
+ t = value * (1.0 - (1.0 - f) * saturation)
98
+
99
+ [
100
+ [value, t, p],
101
+ [q, value, p],
102
+ [p, value, t],
103
+ [p, q, value],
104
+ [t, p, value],
105
+ [value, p, q]
106
+ ][i % 6].map { |channel| (channel * 255.0).round.clamp(0, 255) }
107
+ end
108
+
109
+ def normalize_percent_value(value)
110
+ normalized = Float(value)
111
+ return normalized / 100.0 if normalized > 1.0
112
+
113
+ normalized.clamp(0.0, 1.0)
114
+ end
115
+
116
+ def normalize_gradient_type(value)
117
+ symbol = value.to_s.strip.downcase
118
+ raise ArgumentError, "gradient type must be linear or radial" if symbol.empty?
119
+
120
+ case symbol
121
+ when "linear", "radial"
122
+ symbol
123
+ else
124
+ raise ArgumentError, "unsupported gradient type: #{value}"
125
+ end
126
+ end
127
+
128
+ def normalize_colors_for_gradient(colors)
129
+ values = Array(colors).flatten.map { |color| color.to_s.strip }.reject(&:empty?)
130
+ raise ArgumentError, "gradient requires at least two colors" if values.empty?
131
+
132
+ values.each do |value|
133
+ next if value.match?(/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/)
134
+ raise ArgumentError, "gradient colors must be hex strings"
135
+ end
136
+
137
+ values
138
+ end
139
+
140
+ def normalize_gradient_stops(stops, color_count)
141
+ return nil if stops.nil?
142
+
143
+ values = Array(stops).map { |value| Float(value) }
144
+ raise ArgumentError, "gradient stops must match color count" if values.length != color_count
145
+
146
+ values
147
+ end
148
+
149
+ def normalize_gradient_position(value)
150
+ value = Float(value)
151
+ value.clamp(0.0, 1.0)
152
+ end
153
+ end
154
+ end
155
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "pathname"
4
+ require_relative "../deep_copy"
4
5
  require_relative "file_watcher"
5
6
  require_relative "scene_builder"
6
7
  require_relative "style_builder"
@@ -76,12 +77,15 @@ module Vizcore
76
77
  @midi_mappings = []
77
78
  @key_mappings = []
78
79
  @global_params = {}
80
+ @mapping_presets = {}
79
81
  @analysis_settings = {}
80
82
  @section_tail = nil
81
83
  @timelines = []
82
84
  @styles = {}
83
85
  @themes = {}
84
86
  @scene_registry = {}
87
+ @strict = false
88
+ @seed = nil
85
89
  end
86
90
 
87
91
  # Evaluate DSL methods on this engine instance.
@@ -124,6 +128,17 @@ module Vizcore
124
128
  @themes[theme_definition[:name]] = deep_dup(theme_definition[:params])
125
129
  end
126
130
 
131
+ # Register reusable mapping behavior for layer-level targets.
132
+ #
133
+ # @param name [Symbol, String] mapping preset identifier
134
+ # @yield Mapping preset block
135
+ # @return [void]
136
+ def mapping(name, &block)
137
+ builder = MappingPresetBuilder.new(name: name, strict: @strict)
138
+ preset_definition = builder.evaluate(&block).to_h
139
+ @mapping_presets[preset_definition[:name]] = deep_dup(preset_definition[:mappings])
140
+ end
141
+
127
142
  # Register a MIDI input definition.
128
143
  #
129
144
  # @param name [Symbol, String] input name
@@ -133,6 +148,23 @@ module Vizcore
133
148
  @midi_inputs << { name: name.to_sym, options: symbolize_keys(options) }
134
149
  end
135
150
 
151
+ # Enable strict DSL validation while the file is evaluated.
152
+ #
153
+ # @return [Boolean]
154
+ def strict!
155
+ @strict = true
156
+ end
157
+
158
+ # Set a deterministic Ruby random seed for offline rendering.
159
+ #
160
+ # @param value [Integer]
161
+ # @return [Integer]
162
+ def seed(value)
163
+ @seed = Integer(value)
164
+ rescue ArgumentError, TypeError
165
+ raise ArgumentError, "seed must be an integer"
166
+ end
167
+
136
168
  # Configure analysis-level audio feature normalization.
137
169
  #
138
170
  # @param mode [Symbol, String] `:off` or `:adaptive`
@@ -143,6 +175,15 @@ module Vizcore
143
175
  @analysis_settings[:audio_normalize] = settings
144
176
  end
145
177
 
178
+ # Configure analysis feature extraction behavior.
179
+ #
180
+ # @param options [Hash] optional onset/FFT/silence/peak-hold settings
181
+ # @return [Hash] normalized analysis settings
182
+ def audio_analysis(**options)
183
+ settings = normalize_audio_analysis(options)
184
+ @analysis_settings.merge!(settings)
185
+ end
186
+
146
187
  # Set a fixed BPM value for analysis output.
147
188
  #
148
189
  # @param value [Numeric]
@@ -177,7 +218,7 @@ module Vizcore
177
218
  # @yield Scene definition block
178
219
  # @return [void]
179
220
  def scene(name, extends: nil, &block)
180
- builder = SceneBuilder.new(name: name, styles: @styles, themes: @themes, layers: inherited_layers(extends))
221
+ builder = SceneBuilder.new(name: name, styles: @styles, themes: @themes, mapping_presets: @mapping_presets, layers: inherited_layers(extends), strict: @strict)
181
222
  builder.evaluate(&block)
182
223
  scene_definition = builder.to_h
183
224
  @scenes << scene_definition
@@ -190,15 +231,30 @@ module Vizcore
190
231
  # @param name [Symbol, String] scene/section identifier
191
232
  # @param bars [Integer] section duration in bars
192
233
  # @param beats_per_bar [Integer] meter used to convert bars into beats
234
+ # @param loop [Boolean] whether the section should loop to itself
235
+ # @param hold [Numeric] optional additional beats to wait before transitioning
236
+ # @param outro [Boolean] whether to skip auto-transitioning to the next section
193
237
  # @yield Scene definition block
194
238
  # @return [void]
195
- def section(name, bars:, beats_per_bar: 4, &block)
239
+ def section(name, bars:, beats_per_bar: 4, loop: false, hold: 0, outro: false, &block)
196
240
  section_name = name.to_sym
197
241
  section_beats = positive_integer(bars, "section bars") * positive_integer(beats_per_bar, "beats_per_bar")
242
+ normalized_hold = non_negative_float(hold, "section hold")
243
+ is_loop = !!loop
244
+ is_outro = !!outro
245
+ if is_loop && is_outro
246
+ raise ArgumentError, "section cannot be both loop and outro"
247
+ end
198
248
 
199
249
  scene(section_name, &block)
200
250
  add_section_transition(to: section_name) if @section_tail
201
- @section_tail = { name: section_name, beats: section_beats }
251
+ @section_tail = {
252
+ name: section_name,
253
+ beats: section_beats,
254
+ hold: normalized_hold,
255
+ loop: is_loop,
256
+ outro: is_outro
257
+ }
202
258
  end
203
259
 
204
260
  # Define ordered scene markers and derive transitions between them.
@@ -209,7 +265,7 @@ module Vizcore
209
265
  def timeline(beats_per_bar: TimelineBuilder::DEFAULT_BEATS_PER_BAR, &block)
210
266
  raise ArgumentError, "timeline requires a block" unless block
211
267
 
212
- builder = TimelineBuilder.new(beats_per_bar: beats_per_bar).evaluate(&block)
268
+ builder = TimelineBuilder.new(beats_per_bar: beats_per_bar, bpm: @analysis_settings[:bpm]).evaluate(&block)
213
269
  entries = builder.to_h
214
270
  @timelines << entries unless entries.empty?
215
271
  @transitions.concat(builder.transitions)
@@ -236,15 +292,26 @@ module Vizcore
236
292
  # @param note [Integer, nil] note number trigger
237
293
  # @param cc [Integer, nil] control-change trigger
238
294
  # @param pc [Integer, nil] program-change trigger
295
+ # @param channel [Integer, nil] optional MIDI channel condition (1..16; 0 aliases channel 1)
296
+ # @param relative [Boolean] true when CC values should be treated as relative encoder deltas
297
+ # @param deadband [Numeric, nil] minimum CC value change required to emit an action
298
+ # @param smooth [Numeric, Boolean, nil] optional CC smoothing alpha
299
+ # @param pickup [Boolean, nil] when true, waits for CC to reach local pickup point before emitting updates
239
300
  # @yield Action block executed by midi runtime
240
301
  # @raise [ArgumentError] when no trigger is supplied
241
302
  # @return [void]
242
- def midi_map(note: nil, cc: nil, pc: nil, &block)
303
+ def midi_map(note: nil, cc: nil, pc: nil, channel: nil, relative: false, deadband: nil, smooth: nil, pickup: nil, allow_multiple: false, &block)
243
304
  trigger = {}
244
305
  trigger[:note] = Integer(note) unless note.nil?
245
306
  trigger[:cc] = Integer(cc) unless cc.nil?
246
307
  trigger[:pc] = Integer(pc) unless pc.nil?
247
308
  raise ArgumentError, "midi_map requires note, cc or pc" if trigger.empty?
309
+ trigger[:channel] = normalize_midi_channel(channel) unless channel.nil?
310
+ trigger[:relative] = true if relative && trigger.key?(:cc)
311
+ trigger[:deadband] = non_negative_float(deadband, "midi deadband") unless deadband.nil?
312
+ trigger[:smooth] = normalize_midi_smooth(smooth) unless smooth.nil? || smooth == false
313
+ trigger[:pickup] = pickup if trigger.key?(:cc) && trigger[:cc].between?(0, 127) && !!pickup
314
+ trigger[:allow_multiple] = !!allow_multiple
248
315
 
249
316
  @midi_mappings << {
250
317
  trigger: trigger,
@@ -282,6 +349,7 @@ module Vizcore
282
349
 
283
350
  # @return [Hash] deep-copied definition payload for renderer/runtime.
284
351
  def result
352
+ append_pending_section_transition
285
353
  definition = {
286
354
  audio: @audio_inputs.map { |item| deep_dup(item) },
287
355
  midi: @midi_inputs.map { |item| deep_dup(item) },
@@ -289,11 +357,14 @@ module Vizcore
289
357
  transitions: @transitions.map { |transition| deep_dup(transition) },
290
358
  midi_maps: @midi_mappings.map { |mapping| deep_dup(mapping) },
291
359
  key_mappings: @key_mappings.map { |mapping| deep_dup(mapping) },
360
+ mapping_presets: @mapping_presets.map { |name, mappings| { name: name, mappings: deep_dup(mappings) } },
292
361
  globals: deep_dup(@global_params),
293
362
  analysis: deep_dup(@analysis_settings),
294
363
  styles: @styles.map { |name, params| { name: name, params: deep_dup(params) } },
295
364
  themes: @themes.map { |name, params| { name: name, params: deep_dup(params) } }
296
365
  }
366
+ definition[:strict] = true if @strict
367
+ definition[:seed] = @seed unless @seed.nil?
297
368
  definition[:timelines] = @timelines.map { |timeline| deep_dup(timeline) } unless @timelines.empty?
298
369
  definition
299
370
  end
@@ -314,6 +385,17 @@ module Vizcore
314
385
  settings[:window] = positive_float(options[:window], "audio_normalize window") if options.key?(:window)
315
386
  settings[:target] = unit_float(options[:target], "audio_normalize target") if options.key?(:target)
316
387
  settings[:floor] = unit_float(options[:floor], "audio_normalize floor") if options.key?(:floor)
388
+ settings[:per_band] = !!options[:per_band] if options.key?(:per_band)
389
+ settings
390
+ end
391
+
392
+ def normalize_audio_analysis(options)
393
+ settings = {}
394
+ settings[:onset_sensitivity] = positive_float(options[:onset_sensitivity], "onset_sensitivity") if options.key?(:onset_sensitivity)
395
+ settings[:fft_bins] = ranged_integer(options[:fft_bins], "fft_bins", 8, 128) if options.key?(:fft_bins)
396
+ peak_hold = options.key?(:peak_hold) ? options[:peak_hold] : options[:peak_hold_frames]
397
+ settings[:peak_hold_frames] = ranged_integer(peak_hold, "peak_hold", 0, 10_000) unless peak_hold.nil?
398
+ settings[:silence_reset_frames] = ranged_integer(options[:silence_reset_frames], "silence_reset_frames", 1, 10_000) if options.key?(:silence_reset_frames)
317
399
  settings
318
400
  end
319
401
 
@@ -324,6 +406,39 @@ module Vizcore
324
406
  numeric
325
407
  end
326
408
 
409
+ def ranged_integer(value, name, min, max)
410
+ numeric = Integer(value)
411
+ raise ArgumentError, "#{name} must be between #{min} and #{max}" unless numeric.between?(min, max)
412
+
413
+ numeric
414
+ end
415
+
416
+ def normalize_midi_channel(value)
417
+ channel = Integer(value)
418
+ return 0 if channel.zero?
419
+ return channel - 1 if channel.between?(1, 16)
420
+
421
+ raise ArgumentError, "midi channel must be between 1 and 16"
422
+ end
423
+
424
+ def non_negative_float(value, name)
425
+ numeric = Float(value)
426
+ raise ArgumentError, "#{name} must be non-negative" if numeric.negative?
427
+
428
+ numeric
429
+ end
430
+
431
+ def normalize_midi_smooth(value)
432
+ return 0.25 if value == true
433
+
434
+ numeric = Float(value)
435
+ raise ArgumentError, "midi smooth must be between 0.0 and 1.0" unless numeric.between?(0.0, 1.0)
436
+
437
+ numeric
438
+ rescue ArgumentError, TypeError
439
+ raise ArgumentError, "midi smooth must be true or between 0.0 and 1.0"
440
+ end
441
+
327
442
  def positive_float(value, name)
328
443
  numeric = Float(value)
329
444
  raise ArgumentError, "#{name} must be positive" unless numeric.positive?
@@ -352,11 +467,29 @@ module Vizcore
352
467
  def add_section_transition(to:)
353
468
  from = @section_tail.fetch(:name)
354
469
  beats = @section_tail.fetch(:beats)
470
+ hold = @section_tail.fetch(:hold, 0.0)
471
+ return if @section_tail.fetch(:loop, false)
472
+ return if @section_tail.fetch(:outro, false)
473
+
355
474
  @transitions << {
356
475
  from: from,
357
476
  to: to,
358
- trigger: proc { beat_count >= beats }
477
+ trigger: proc { beat_count >= (beats + hold) }
478
+ }
479
+ end
480
+
481
+ def append_pending_section_transition
482
+ return unless @section_tail && @section_tail.fetch(:loop, false)
483
+
484
+ from = @section_tail.fetch(:name)
485
+ beats = @section_tail.fetch(:beats, 0)
486
+ hold = @section_tail.fetch(:hold, 0.0)
487
+ @transitions << {
488
+ from: from,
489
+ to: from,
490
+ trigger: proc { beat_count >= (beats + hold) }
359
491
  }
492
+ @section_tail = @section_tail.merge(loop: false)
360
493
  end
361
494
 
362
495
  def inherited_layers(scene_name)
@@ -370,16 +503,7 @@ module Vizcore
370
503
  end
371
504
 
372
505
  def deep_dup(value)
373
- case value
374
- when Hash
375
- value.each_with_object({}) do |(key, entry), output|
376
- output[key] = deep_dup(entry)
377
- end
378
- when Array
379
- value.map { |entry| deep_dup(entry) }
380
- else
381
- value
382
- end
506
+ Vizcore::DeepCopy.copy(value)
383
507
  end
384
508
 
385
509
  # Builder object for `transition` block internals.
@@ -451,36 +575,50 @@ module Vizcore
451
575
  #
452
576
  # @param name [Symbol, String]
453
577
  # @return [void]
454
- def switch_scene(name)
578
+ def switch_scene(name, effect: nil)
455
579
  scene_name = name.to_s.strip
456
580
  raise ArgumentError, "switch_scene scene must not be empty" if scene_name.empty?
457
581
 
458
- assign_action(type: :switch_scene, scene: scene_name)
582
+ action = { type: :switch_scene, scene: scene_name }
583
+ action[:effect] = effect unless effect.nil?
584
+ assign_action(action)
459
585
  end
460
586
 
461
587
  # Toggle browser blackout output.
462
588
  #
463
589
  # @return [void]
464
- def blackout
465
- live_control(:blackout)
590
+ def blackout(value = nil, fade: nil, release: nil, color: nil)
591
+ live_control(:blackout, value, fade: fade, release: release, color: color)
466
592
  end
467
593
 
468
594
  # Toggle browser freeze output.
469
595
  #
470
596
  # @return [void]
471
- def freeze
472
- live_control(:freeze)
597
+ def freeze(value = nil, fade: nil, release: nil)
598
+ live_control(:freeze, value, fade: fade, release: release)
473
599
  end
474
600
 
475
601
  # Toggle a browser live control.
476
602
  #
477
603
  # @param control [Symbol, String]
604
+ # @param value [Object, nil] target value; nil means UI-side toggle for keyboard/live mapping
478
605
  # @return [void]
479
- def live_control(control)
606
+ def live_control(control, value = nil, fade: nil, release: nil, color: nil)
480
607
  normalized = control.to_s.strip.downcase.to_sym
481
608
  raise ArgumentError, "unsupported live control: #{control}" unless %i[blackout freeze].include?(normalized)
482
609
 
483
610
  assign_action(type: :live_control, control: normalized)
611
+
612
+ # Preserve explicit value and transition timings when provided by DSL authors.
613
+ # `nil` is kept for UI-side toggles to avoid changing existing keyboard ergonomics.
614
+ action = @action
615
+ action[:value] = value unless value.nil?
616
+ action[:fade] = normalize_control_transition(fade)
617
+ action[:release] = normalize_control_transition(release)
618
+ action[:color] = normalize_control_color(color) unless color.nil?
619
+ action.delete(:fade) if action[:fade].nil?
620
+ action.delete(:release) if action[:release].nil?
621
+ action.delete(:color) if action[:color].nil?
484
622
  end
485
623
 
486
624
  # @return [Hash] serialized key action
@@ -495,6 +633,64 @@ module Vizcore
495
633
 
496
634
  @action = action
497
635
  end
636
+
637
+ def normalize_control_transition(value)
638
+ return nil if value.nil?
639
+
640
+ numeric = Float(value)
641
+ return nil if numeric.negative? || !numeric.finite?
642
+
643
+ numeric
644
+ rescue ArgumentError, TypeError
645
+ nil
646
+ end
647
+
648
+ def normalize_control_color(value)
649
+ return nil if value.nil?
650
+
651
+ if value.is_a?(Array)
652
+ return nil unless (3..4).cover?(value.length)
653
+
654
+ channels = Array(value).map { |entry| Float(entry, exception: false) }
655
+ return nil if channels.include?(nil)
656
+
657
+ rgb = channels.take(3)
658
+ alpha = channels[3]
659
+ normalized_rgb = if rgb.all? { |channel| channel.between?(0.0, 1.0) }
660
+ rgb
661
+ else
662
+ rgb.map { |channel| channel / 255.0 }
663
+ end
664
+ normalized = normalized_rgb.map { |channel| [0.0, [1.0, channel].min].max }
665
+ return alpha.nil? ? normalized : normalized + [normalize_control_alpha(alpha)]
666
+ end
667
+
668
+ raw = value.to_s.strip
669
+ match = raw.match(/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/)
670
+ return nil unless match
671
+
672
+ raw_hex = match[1]
673
+ hex = raw_hex.length == 3 || raw_hex.length == 4 ? raw_hex.chars.map { |char| "#{char}#{char}" }.join("") : raw_hex
674
+
675
+ [
676
+ Integer("0x#{hex[0, 2]}", 16),
677
+ Integer("0x#{hex[2, 2]}", 16),
678
+ Integer("0x#{hex[4, 2]}", 16),
679
+ Integer("0x#{hex[6, 2]}", 16)
680
+ ].take(raw_hex.length > 4 ? 4 : 3).map { |channel| [0.0, [1.0, channel / 255.0].min].max }
681
+ rescue ArgumentError, TypeError
682
+ nil
683
+ end
684
+
685
+ def normalize_control_alpha(value)
686
+ return nil if value.nil?
687
+
688
+ alpha = Float(value, exception: false)
689
+ return nil if alpha.nil?
690
+ return [0.0, [1.0, alpha].min].max if alpha.between?(0.0, 1.0)
691
+
692
+ [0.0, [1.0, alpha / 255.0].min].max
693
+ end
498
694
  end
499
695
  end
500
696
  end