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
@@ -19,6 +19,7 @@ module Vizcore
19
19
  @midi_maps = normalize_midi_maps(midi_maps)
20
20
  @scenes = normalize_scenes(scenes)
21
21
  @globals = normalize_globals(globals) unless globals.nil?
22
+ @cc_state = {}
22
23
  end
23
24
 
24
25
  # @return [Hash] mutable global parameter snapshot
@@ -32,8 +33,11 @@ module Vizcore
32
33
  @midi_maps.each_with_object([]) do |mapping, actions|
33
34
  next unless mapping_match?(mapping[:trigger], event)
34
35
 
36
+ value = event_value(event, mapping[:trigger])
37
+ next if value.nil?
38
+
35
39
  context = ActionContext.new(scenes: @scenes, globals: @globals)
36
- invoke_action_block(context, mapping[:action], event, mapping[:trigger])
40
+ invoke_action_block(context, mapping[:action], value)
37
41
  actions.concat(context.actions)
38
42
  end
39
43
  end
@@ -73,6 +77,8 @@ module Vizcore
73
77
  end
74
78
 
75
79
  def mapping_match?(trigger, event)
80
+ return false unless channel_match?(trigger, event)
81
+
76
82
  if trigger.key?(:note)
77
83
  event.type == :note_on && event.data1 == trigger[:note].to_i
78
84
  elsif trigger.key?(:cc)
@@ -84,8 +90,13 @@ module Vizcore
84
90
  end
85
91
  end
86
92
 
87
- def invoke_action_block(context, action, event, trigger)
88
- value = event_value(event, trigger)
93
+ def channel_match?(trigger, event)
94
+ return true unless trigger.key?(:channel)
95
+
96
+ event.channel.to_i == trigger[:channel].to_i
97
+ end
98
+
99
+ def invoke_action_block(context, action, value)
89
100
  if action.arity.zero?
90
101
  context.instance_exec(&action)
91
102
  else
@@ -94,8 +105,10 @@ module Vizcore
94
105
  end
95
106
 
96
107
  def event_value(event, trigger)
97
- if trigger.key?(:note) || trigger.key?(:cc)
108
+ if trigger.key?(:note)
98
109
  event.data2.to_i.clamp(0, 127)
110
+ elsif trigger.key?(:cc)
111
+ cc_event_value(event, trigger)
99
112
  elsif trigger.key?(:pc)
100
113
  event.data1.to_i.clamp(0, 127)
101
114
  else
@@ -103,6 +116,78 @@ module Vizcore
103
116
  end
104
117
  end
105
118
 
119
+ def cc_event_value(event, trigger)
120
+ raw = event.data2.to_i.clamp(0, 127)
121
+ state = (@cc_state[state_key(trigger)] ||= {})
122
+ value = trigger[:relative] ? relative_cc_delta(raw) : raw
123
+ return nil if pickup_blocked?(raw, state, trigger)
124
+ return nil if within_deadband?(value, state, trigger)
125
+
126
+ value = smooth_value(value, state, trigger)
127
+ state[:last_raw] = raw unless trigger[:relative]
128
+ state[:last_value] = value
129
+ value
130
+ end
131
+
132
+ def relative_cc_delta(raw)
133
+ return raw if raw.between?(1, 63)
134
+ return raw - 128 if raw.between?(65, 127)
135
+
136
+ 0
137
+ end
138
+
139
+ def pickup_blocked?(raw, state, trigger)
140
+ return false unless trigger[:pickup]
141
+ return false unless trigger.key?(:cc)
142
+ return false if state[:pickup_synced]
143
+
144
+ return false if trigger[:relative]
145
+
146
+ reference = state[:pickup_reference_raw]
147
+ unless reference
148
+ state[:pickup_reference_raw] = raw
149
+ return true
150
+ end
151
+
152
+ tolerance = trigger[:deadband] || 1
153
+ if (raw - reference).abs <= tolerance
154
+ state[:pickup_synced] = true
155
+ state[:pickup_reference_raw] = nil
156
+ return false
157
+ end
158
+
159
+ true
160
+ end
161
+
162
+ def within_deadband?(value, state, trigger)
163
+ return false unless trigger.key?(:deadband)
164
+
165
+ deadband = Float(trigger[:deadband])
166
+ if trigger[:relative]
167
+ value.abs <= deadband
168
+ elsif state.key?(:last_raw)
169
+ (value - state[:last_raw].to_f).abs <= deadband
170
+ else
171
+ false
172
+ end
173
+ rescue ArgumentError, TypeError
174
+ false
175
+ end
176
+
177
+ def smooth_value(value, state, trigger)
178
+ return value unless trigger.key?(:smooth)
179
+ return value unless state.key?(:last_value)
180
+
181
+ alpha = Float(trigger[:smooth]).clamp(0.0, 1.0)
182
+ state[:last_value].to_f + ((value - state[:last_value].to_f) * alpha)
183
+ rescue ArgumentError, TypeError
184
+ value
185
+ end
186
+
187
+ def state_key(trigger)
188
+ [trigger[:channel], trigger[:cc]]
189
+ end
190
+
106
191
  def symbolize_hash(value)
107
192
  Hash(value).each_with_object({}) do |(key, entry), output|
108
193
  output[key.to_sym] = entry
@@ -112,16 +197,7 @@ module Vizcore
112
197
  end
113
198
 
114
199
  def deep_dup(value)
115
- case value
116
- when Hash
117
- value.each_with_object({}) do |(key, entry), output|
118
- output[key] = deep_dup(entry)
119
- end
120
- when Array
121
- value.map { |entry| deep_dup(entry) }
122
- else
123
- value
124
- end
200
+ Vizcore::DeepCopy.copy(value)
125
201
  end
126
202
 
127
203
  # Runtime DSL context used while executing one `midi_map` action block.
@@ -129,6 +205,7 @@ module Vizcore
129
205
  class ActionContext
130
206
  # Collected runtime actions emitted by DSL calls.
131
207
  attr_reader :actions
208
+ attr_reader :unknown_scene_names
132
209
 
133
210
  # @param scenes [Hash]
134
211
  # @param globals [Hash]
@@ -136,6 +213,7 @@ module Vizcore
136
213
  @scenes = scenes
137
214
  @globals = globals
138
215
  @actions = []
216
+ @unknown_scene_names = []
139
217
  end
140
218
 
141
219
  # @param name [Symbol, String]
@@ -143,7 +221,11 @@ module Vizcore
143
221
  # @return [void]
144
222
  def switch_scene(name, effect: nil)
145
223
  scene = @scenes[name.to_sym]
146
- return unless scene
224
+ unless scene
225
+ unknown = name.to_s
226
+ @unknown_scene_names << unknown unless @unknown_scene_names.include?(unknown)
227
+ return
228
+ end
147
229
 
148
230
  @actions << {
149
231
  type: :switch_scene,
@@ -155,6 +237,22 @@ module Vizcore
155
237
  }
156
238
  end
157
239
 
240
+ # Advance to the next scene in runtime scene order.
241
+ #
242
+ # @param effect [Hash, nil]
243
+ # @return [void]
244
+ def next_scene(effect: nil)
245
+ @actions << { type: :next_scene, effect: deep_dup(effect) }
246
+ end
247
+
248
+ # Move to the previous scene in runtime scene order.
249
+ #
250
+ # @param effect [Hash, nil]
251
+ # @return [void]
252
+ def previous_scene(effect: nil)
253
+ @actions << { type: :previous_scene, effect: deep_dup(effect) }
254
+ end
255
+
158
256
  # @param key [Symbol, String]
159
257
  # @param value [Object]
160
258
  # @return [void]
@@ -168,19 +266,117 @@ module Vizcore
168
266
  }
169
267
  end
170
268
 
269
+ # @param control [Symbol, String]
270
+ # @param value [Boolean, nil] target value; nil defaults to true for direct calls
271
+ # @param fade [Numeric, nil] optional seconds for transition to `value == true`
272
+ # @param release [Numeric, nil] optional seconds for transition to `value == false`
273
+ # @return [void]
274
+ def live_control(control, value = nil, fade: nil, release: nil, color: nil)
275
+ state = normalize_live_control_state(value)
276
+ state[:fade] = normalize_control_transition(fade)
277
+ state[:release] = normalize_control_transition(release)
278
+ state[:color] = normalize_control_color(color)
279
+ state.delete(:fade) if state[:fade].nil?
280
+ state.delete(:release) if state[:release].nil?
281
+ state.delete(:color) if state[:color].nil?
282
+
283
+ @actions << {
284
+ type: :live_control,
285
+ control: control.to_s,
286
+ **state
287
+ }
288
+ end
289
+
290
+ # @param value [Boolean, nil]
291
+ # @return [void]
292
+ def blackout(value = nil, fade: nil, release: nil, color: nil)
293
+ live_control(:blackout, value, fade: fade, release: release, color: color)
294
+ end
295
+
296
+ # @param value [Boolean, nil]
297
+ # @return [void]
298
+ def freeze(value = nil, fade: nil, release: nil)
299
+ live_control(:freeze, value, fade: fade, release: release)
300
+ end
301
+
171
302
  private
172
303
 
304
+ def normalize_live_control_state(value)
305
+ if value.is_a?(Hash)
306
+ state = value.transform_keys(&:to_sym)
307
+ enabled = state.key?(:value) ? state[:value] : true
308
+ return {
309
+ value: !!enabled,
310
+ fade: normalize_control_transition(state[:fade]),
311
+ release: normalize_control_transition(state[:release]),
312
+ color: normalize_control_color(state[:color]),
313
+ }
314
+ end
315
+
316
+ { value: !!(value.nil? || value) }
317
+ end
318
+
173
319
  def deep_dup(value)
174
- case value
175
- when Hash
176
- value.each_with_object({}) do |(key, entry), output|
177
- output[key] = deep_dup(entry)
320
+ Vizcore::DeepCopy.copy(value)
321
+ end
322
+
323
+ def normalize_control_transition(value)
324
+ return nil if value.nil?
325
+
326
+ numeric = Float(value)
327
+ return nil if numeric.negative? || !numeric.finite?
328
+
329
+ numeric
330
+ rescue ArgumentError, TypeError
331
+ nil
332
+ end
333
+
334
+ def normalize_control_color(value)
335
+ return nil if value.nil?
336
+
337
+ if value.is_a?(Array)
338
+ return nil unless (3..4).cover?(value.length)
339
+
340
+ channels = Array(value).map { |entry| Float(entry, exception: false) }
341
+ return nil if channels.include?(nil)
342
+
343
+ rgb = channels.take(3)
344
+ alpha = channels[3]
345
+ normalized_rgb = if rgb.all? { |channel| channel.between?(0.0, 1.0) }
346
+ rgb
347
+ else
348
+ rgb.map { |channel| channel / 255.0 }
178
349
  end
179
- when Array
180
- value.map { |entry| deep_dup(entry) }
181
- else
182
- value
350
+ normalized = normalized_rgb.map { |channel| [0.0, [1.0, channel].min].max }
351
+ return alpha.nil? ? normalized : normalized + [normalize_control_alpha(alpha)]
183
352
  end
353
+
354
+ raw = value.to_s.strip
355
+ match = raw.match(/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/)
356
+ return nil unless match
357
+
358
+ raw_hex = match[1]
359
+ hex = raw_hex.length == 3 || raw_hex.length == 4 ? raw_hex.chars.map { |char| "#{char}#{char}" }.join("") : raw_hex
360
+
361
+ [
362
+ Integer("0x#{hex[0, 2]}", 16),
363
+ Integer("0x#{hex[2, 2]}", 16),
364
+ Integer("0x#{hex[4, 2]}", 16),
365
+ Integer("0x#{hex[6, 2]}", 16)
366
+ ].take(raw_hex.length > 4 ? 4 : 3).map { |channel| [0.0, [1.0, channel / 255.0].min].max }
367
+ rescue ArgumentError, TypeError
368
+ nil
369
+ end
370
+
371
+ def normalize_control_alpha(value)
372
+ return nil if value.nil?
373
+
374
+ alpha = Float(value, exception: false)
375
+ return nil if alpha.nil?
376
+
377
+ return [0.0, [1.0, alpha].min].max if alpha.between?(0.0, 1.0)
378
+
379
+ [0.0, [1.0, alpha / 255.0].min].max
184
380
  end
185
381
  end
186
382
  end
@@ -37,6 +37,7 @@ module Vizcore
37
37
  # @param options [Hash] mapping transform options
38
38
  # @return [void]
39
39
  def trigger(target, **options)
40
+ options = options.merge(as: :trigger) unless options.key?(:as)
40
41
  change(target, **options)
41
42
  end
42
43
  end
@@ -2,6 +2,8 @@
2
2
 
3
3
  require_relative "layer_builder"
4
4
  require_relative "layer_group_builder"
5
+ require_relative "style_builder"
6
+ require_relative "../deep_copy"
5
7
 
6
8
  module Vizcore
7
9
  module DSL
@@ -10,11 +12,15 @@ module Vizcore
10
12
  # @param name [Symbol, String] scene identifier
11
13
  # @param styles [Hash] reusable layer parameter styles
12
14
  # @param themes [Hash] reusable scene-wide layer parameter themes
15
+ # @param mapping_presets [Hash] reusable mapping presets
13
16
  # @param layers [Array<Hash>] initial layer definitions
14
- def initialize(name:, styles: {}, themes: {}, layers: [])
17
+ # @param strict [Boolean] true when unknown layer params should fail
18
+ def initialize(name:, styles: {}, themes: {}, mapping_presets: {}, layers: [], strict: false)
15
19
  @name = name.to_sym
16
20
  @styles = styles
17
21
  @themes = themes
22
+ @mapping_presets = mapping_presets
23
+ @strict = !!strict
18
24
  @theme_name = nil
19
25
  @theme_params = {}
20
26
  @layers = layers.map { |layer| deep_dup(layer) }
@@ -35,18 +41,83 @@ module Vizcore
35
41
  # @yield Layer definition block
36
42
  # @return [void]
37
43
  def layer(name, &block)
38
- builder = LayerBuilder.new(name: name, styles: @styles, defaults: @theme_params)
44
+ builder = LayerBuilder.new(name: name, styles: @styles, mapping_presets: @mapping_presets, defaults: @theme_params, strict: @strict)
39
45
  builder.evaluate(&block)
40
46
  @layers << builder.to_h
41
47
  end
42
48
 
49
+ # Set defaults applied to every layer in this scene.
50
+ #
51
+ # @param params [Hash] layer params
52
+ # @yield optional defaults block using style-like setters
53
+ # @return [Hash]
54
+ def scene_defaults(**params, &block)
55
+ defaults = params.each_with_object({}) { |(key, value), output| output[key.to_sym] = value }
56
+ if block
57
+ block_defaults = StyleBuilder.new(name: :scene_defaults, kind: "scene_defaults").evaluate(&block).to_h[:params]
58
+ defaults.merge!(block_defaults)
59
+ end
60
+ raise ArgumentError, "scene_defaults requires at least one parameter" if defaults.empty?
61
+
62
+ @theme_params = deep_dup(@theme_params).merge(defaults)
63
+ @layers = @layers.map { |layer| apply_theme_defaults(layer, defaults) }
64
+ deep_dup(@theme_params)
65
+ end
66
+
67
+ # Remove an inherited or previously declared layer by name.
68
+ #
69
+ # @param name [Symbol, String]
70
+ # @return [Hash] removed layer definition
71
+ def remove_layer(name)
72
+ index = layer_index!(name)
73
+ @layers.delete_at(index)
74
+ end
75
+
76
+ # Replace an inherited or previously declared layer while preserving order.
77
+ #
78
+ # @param name [Symbol, String]
79
+ # @yield Layer definition block
80
+ # @return [Hash] replacement layer definition
81
+ def replace_layer(name, &block)
82
+ index = layer_index!(name)
83
+ builder = LayerBuilder.new(
84
+ name: name,
85
+ styles: @styles,
86
+ mapping_presets: @mapping_presets,
87
+ defaults: @theme_params,
88
+ strict: @strict
89
+ )
90
+ builder.evaluate(&block)
91
+ @layers[index] = builder.to_h
92
+ end
93
+
94
+ # Override params on an existing layer without changing its type/shader.
95
+ #
96
+ # @param name [Symbol, String]
97
+ # @param params [Hash]
98
+ # @yield optional style-like param block
99
+ # @return [Hash] updated layer definition
100
+ def override_layer(name, **params, &block)
101
+ index = layer_index!(name)
102
+ overrides = params.each_with_object({}) { |(key, value), output| output[key.to_sym] = value }
103
+ if block
104
+ block_overrides = StyleBuilder.new(name: name, kind: "override_layer").evaluate(&block).to_h[:params]
105
+ overrides.merge!(block_overrides)
106
+ end
107
+ raise ArgumentError, "override_layer #{name} requires at least one parameter" if overrides.empty?
108
+
109
+ layer = deep_dup(@layers[index])
110
+ layer[:params] = Hash(layer[:params] || {}).merge(overrides)
111
+ @layers[index] = layer
112
+ end
113
+
43
114
  # Define a related group of layers with shared params.
44
115
  #
45
116
  # @param name [Symbol, String] group identifier
46
117
  # @yield Layer group definition block
47
118
  # @return [void]
48
119
  def group(name, &block)
49
- builder = LayerGroupBuilder.new(name: name, styles: @styles, defaults: @theme_params)
120
+ builder = LayerGroupBuilder.new(name: name, styles: @styles, mapping_presets: @mapping_presets, defaults: @theme_params, strict: @strict)
50
121
  builder.evaluate(&block)
51
122
  @layers.concat(builder.to_a)
52
123
  end
@@ -83,17 +154,16 @@ module Vizcore
83
154
  themed_layer
84
155
  end
85
156
 
157
+ def layer_index!(name)
158
+ normalized = name.to_sym
159
+ index = @layers.index { |layer| layer[:name]&.to_sym == normalized }
160
+ raise ArgumentError, "unknown layer: #{normalized}" unless index
161
+
162
+ index
163
+ end
164
+
86
165
  def deep_dup(value)
87
- case value
88
- when Hash
89
- value.each_with_object({}) do |(key, entry), output|
90
- output[key] = deep_dup(entry)
91
- end
92
- when Array
93
- value.map { |entry| deep_dup(entry) }
94
- else
95
- value
96
- end
166
+ Vizcore::DeepCopy.copy(value)
97
167
  end
98
168
  end
99
169
  end
@@ -116,16 +116,7 @@ module Vizcore
116
116
  end
117
117
 
118
118
  def deep_dup(value)
119
- case value
120
- when Hash
121
- value.each_with_object({}) do |(key, entry), output|
122
- output[key] = deep_dup(entry)
123
- end
124
- when Array
125
- value.map { |entry| deep_dup(entry) }
126
- else
127
- value
128
- end
119
+ Vizcore::DeepCopy.copy(value)
129
120
  end
130
121
  end
131
122
  end
@@ -1,9 +1,12 @@
1
1
  # frozen_string_literal: true
2
+ require_relative "color_helpers"
2
3
 
3
4
  module Vizcore
4
5
  module DSL
5
6
  # Collects reusable layer parameter presets for the `style` DSL.
6
7
  class StyleBuilder
8
+ include ColorHelpers
9
+
7
10
  # @param name [Symbol, String] style identifier
8
11
  # @param kind [String] user-facing DSL kind for error messages
9
12
  def initialize(name:, kind: "style")
@@ -8,9 +8,11 @@ module Vizcore
8
8
 
9
9
  Point = Struct.new(:value, :unit, keyword_init: true)
10
10
 
11
- def initialize(beats_per_bar: DEFAULT_BEATS_PER_BAR)
11
+ # @param bpm [Numeric, nil] fixed BPM for mixed-unit timeline conversion
12
+ def initialize(beats_per_bar: DEFAULT_BEATS_PER_BAR, bpm: nil)
12
13
  @beats_per_bar = positive_integer(beats_per_bar, "beats_per_bar")
13
14
  @entries = []
15
+ @bpm = positive_float(bpm, "timeline bpm") unless bpm.nil?
14
16
  end
15
17
 
16
18
  # Evaluate a timeline block.
@@ -27,14 +29,16 @@ module Vizcore
27
29
  #
28
30
  # @param position [Numeric, Point] seconds by default, or a value from `seconds`, `beats`, or `bars`
29
31
  # @param scene [Symbol, String] scene to activate at the position
32
+ # @param cue [Symbol, String, nil] optional cue identifier for marker metadata
30
33
  # @return [Hash]
31
- def at(position, scene:)
34
+ def at(position, scene:, cue: nil)
32
35
  point = normalize_position(position)
33
36
  entry = {
34
37
  at: point.value,
35
38
  unit: point.unit,
36
39
  scene: scene.to_sym
37
40
  }
41
+ entry[:cue] = cue.to_sym if cue
38
42
  @entries << entry
39
43
  entry
40
44
  end
@@ -66,12 +70,13 @@ module Vizcore
66
70
 
67
71
  # @return [Array<Hash>] generated scene transitions
68
72
  def transitions
73
+ return [] if @entries.length < 2
74
+
69
75
  @entries.each_cons(2).map do |from_entry, to_entry|
70
- delta = to_entry.fetch(:at) - from_entry.fetch(:at)
71
76
  {
72
77
  from: from_entry.fetch(:scene),
73
78
  to: to_entry.fetch(:scene),
74
- trigger: trigger_for(delta, from_entry.fetch(:unit))
79
+ trigger: trigger_for(from_entry, to_entry)
75
80
  }
76
81
  end
77
82
  end
@@ -84,8 +89,15 @@ module Vizcore
84
89
  seconds(position)
85
90
  end
86
91
 
87
- def trigger_for(delta, unit)
88
- case unit
92
+ def trigger_for(from_entry, to_entry)
93
+ return trigger_for_same_unit(from_entry, to_entry) unless mixed_units?(from_entry.fetch(:unit), to_entry.fetch(:unit))
94
+
95
+ trigger_for_mixed_units(from_entry, to_entry)
96
+ end
97
+
98
+ def trigger_for_same_unit(from_entry, to_entry)
99
+ delta = to_entry.fetch(:at) - from_entry.fetch(:at)
100
+ case from_entry.fetch(:unit)
89
101
  when :seconds
90
102
  proc { seconds >= delta }
91
103
  when :beats
@@ -95,12 +107,56 @@ module Vizcore
95
107
  end
96
108
  end
97
109
 
110
+ def trigger_for_mixed_units(from_entry, to_entry)
111
+ fixed_bpm = @bpm
112
+
113
+ if fixed_bpm
114
+ from_position = marker_position_seconds(from_entry, fixed_bpm: fixed_bpm)
115
+ to_position = marker_position_seconds(to_entry, fixed_bpm: fixed_bpm)
116
+ if from_position && to_position
117
+ return proc { seconds >= (to_position - from_position) }
118
+ end
119
+ end
120
+
121
+ convert_position = lambda do |entry, bpm|
122
+ value = entry.fetch(:at)
123
+ case entry.fetch(:unit)
124
+ when :seconds
125
+ value
126
+ when :beats
127
+ return nil unless bpm.to_f.positive?
128
+
129
+ value * 60.0 / Float(bpm)
130
+ else
131
+ nil
132
+ end
133
+ end
134
+
135
+ proc do
136
+ used_bpm = fixed_bpm || bpm
137
+ from_position = convert_position.call(from_entry, used_bpm)
138
+ to_position = convert_position.call(to_entry, used_bpm)
139
+ return false unless from_position && to_position
140
+
141
+ seconds >= (to_position - from_position)
142
+ end
143
+ end
144
+
98
145
  def validate_entries!
99
146
  return if @entries.length < 2
100
147
 
101
- unit = @entries.first.fetch(:unit)
102
148
  @entries.each_cons(2) do |from_entry, to_entry|
103
- raise ArgumentError, "timeline entries must use the same unit" unless to_entry.fetch(:unit) == unit
149
+ if mixed_units?(from_entry.fetch(:unit), to_entry.fetch(:unit))
150
+ if @bpm
151
+ from_position = marker_position_seconds(from_entry, fixed_bpm: @bpm)
152
+ to_position = marker_position_seconds(to_entry, fixed_bpm: @bpm)
153
+ raise ArgumentError, "timeline entries must increase when converted to seconds" if to_position.nil? || from_position.nil? || to_position <= from_position
154
+ end
155
+
156
+ next
157
+ end
158
+
159
+ raise ArgumentError, "timeline entries must use the same unit" unless to_entry.fetch(:unit) == from_entry.fetch(:unit)
104
160
 
105
161
  from_position = from_entry.fetch(:at)
106
162
  to_position = to_entry.fetch(:at)
@@ -108,6 +164,24 @@ module Vizcore
108
164
  end
109
165
  end
110
166
 
167
+ def mixed_units?(left_unit, right_unit)
168
+ left_unit != right_unit
169
+ end
170
+
171
+ def marker_position_seconds(entry, fixed_bpm:)
172
+ value = entry.fetch(:at)
173
+ case entry.fetch(:unit)
174
+ when :seconds
175
+ value
176
+ when :beats
177
+ return nil unless fixed_bpm.to_f.positive?
178
+
179
+ value * 60.0 / Float(fixed_bpm)
180
+ else
181
+ nil
182
+ end
183
+ end
184
+
111
185
  def non_negative_float(value, name)
112
186
  numeric = parse_float(value, name)
113
187
  raise ArgumentError, "#{name} must be non-negative" if numeric.negative?
@@ -133,6 +207,15 @@ module Vizcore
133
207
  rescue ArgumentError, TypeError
134
208
  raise ArgumentError, "#{name} must be an integer"
135
209
  end
210
+
211
+ def positive_float(value, name)
212
+ numeric = Float(value)
213
+ raise ArgumentError, "#{name} must be positive" unless numeric.positive?
214
+
215
+ numeric
216
+ rescue ArgumentError, TypeError
217
+ raise ArgumentError, "#{name} must be numeric"
218
+ end
136
219
  end
137
220
  end
138
221
  end