vizcore 0.1.0 → 1.1.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 (137) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +70 -117
  3. data/docs/.nojekyll +0 -0
  4. data/docs/assets/playground-worker.js +373 -0
  5. data/docs/assets/playground.css +440 -0
  6. data/docs/assets/playground.js +652 -0
  7. data/docs/assets/site.css +744 -0
  8. data/docs/assets/vizcore-demo.gif +0 -0
  9. data/docs/assets/vizcore-poster.png +0 -0
  10. data/docs/assets/vj-tunnel.js +159 -0
  11. data/docs/index.html +225 -0
  12. data/docs/playground.html +81 -0
  13. data/docs/shape_dsl.md +269 -0
  14. data/examples/README.md +59 -0
  15. data/examples/assets/README.md +19 -0
  16. data/examples/audio_inspector.rb +34 -0
  17. data/examples/club_intro_drop.rb +78 -0
  18. data/examples/kansai_rubykaigi_visual.rb +70 -0
  19. data/examples/live_coding_minimal.rb +22 -0
  20. data/examples/midi_controller_show.rb +78 -0
  21. data/examples/midi_scene_switch.rb +3 -1
  22. data/examples/parser_visualizer.rb +48 -0
  23. data/examples/readme_demo.rb +17 -0
  24. data/examples/rhythm_geometry.rb +34 -0
  25. data/examples/ruby_crystal_show.rb +35 -0
  26. data/examples/shader_playground.rb +18 -0
  27. data/examples/unyo_liquid.rb +59 -0
  28. data/examples/vj_ambient_chill_room.rb +124 -0
  29. data/examples/vj_dnb_jungle.rb +170 -0
  30. data/examples/vj_festival_mainstage.rb +245 -0
  31. data/examples/vj_festival_mainstage.yml +17 -0
  32. data/examples/vj_glitch_industrial.rb +164 -0
  33. data/examples/vj_hiphop_cipher.rb +167 -0
  34. data/examples/vj_jpop_idol_live.rb +210 -0
  35. data/examples/vj_synthwave_retro.rb +173 -0
  36. data/examples/vj_techno_warehouse.rb +195 -0
  37. data/frontend/index.html +494 -2
  38. data/frontend/src/audio-inspector.js +40 -0
  39. data/frontend/src/custom-shape-param-controls.js +106 -0
  40. data/frontend/src/live-controls.js +131 -0
  41. data/frontend/src/main.js +1060 -16
  42. data/frontend/src/mapping-target-selector.js +109 -0
  43. data/frontend/src/midi-learn.js +194 -0
  44. data/frontend/src/performance-monitor.js +183 -0
  45. data/frontend/src/plugin-runtime.js +130 -0
  46. data/frontend/src/projector-mode.js +56 -0
  47. data/frontend/src/renderer/engine.js +157 -3
  48. data/frontend/src/renderer/layer-manager.js +442 -30
  49. data/frontend/src/renderer/shader-manager.js +26 -0
  50. data/frontend/src/runtime-control-preset.js +11 -0
  51. data/frontend/src/shader-error-overlay.js +29 -0
  52. data/frontend/src/shader-param-controls.js +93 -0
  53. data/frontend/src/shaders/builtins.js +380 -2
  54. data/frontend/src/shaders/post-effects.js +52 -0
  55. data/frontend/src/shape-editor-controls.js +157 -0
  56. data/frontend/src/visual-regression.js +67 -0
  57. data/frontend/src/visual-settings-preset.js +103 -0
  58. data/frontend/src/visuals/geometry.js +666 -0
  59. data/frontend/src/visuals/image-renderer.js +291 -0
  60. data/frontend/src/visuals/particle-system.js +56 -10
  61. data/frontend/src/visuals/shape-renderer.js +475 -0
  62. data/frontend/src/visuals/spectrogram-renderer.js +226 -0
  63. data/frontend/src/visuals/svg-arc.js +104 -0
  64. data/frontend/src/visuals/text-renderer.js +112 -11
  65. data/frontend/src/websocket-client.js +12 -1
  66. data/lib/vizcore/analysis/adaptive_normalizer.rb +70 -0
  67. data/lib/vizcore/analysis/beat_detector.rb +4 -2
  68. data/lib/vizcore/analysis/bpm_estimator.rb +8 -0
  69. data/lib/vizcore/analysis/feature_recorder.rb +159 -0
  70. data/lib/vizcore/analysis/feature_replay.rb +84 -0
  71. data/lib/vizcore/analysis/pipeline.rb +235 -11
  72. data/lib/vizcore/analysis/tap_tempo.rb +74 -0
  73. data/lib/vizcore/analysis.rb +4 -0
  74. data/lib/vizcore/audio/dummy_sine_input.rb +1 -1
  75. data/lib/vizcore/audio/fixture_input.rb +65 -0
  76. data/lib/vizcore/audio/input_manager.rb +4 -2
  77. data/lib/vizcore/audio/mic_input.rb +24 -8
  78. data/lib/vizcore/audio/portaudio_ffi.rb +106 -1
  79. data/lib/vizcore/audio.rb +1 -0
  80. data/lib/vizcore/cli/doctor.rb +159 -0
  81. data/lib/vizcore/cli/dsl_reference.rb +99 -0
  82. data/lib/vizcore/cli/layer_docs.rb +46 -0
  83. data/lib/vizcore/cli/scene_diagnostics.rb +23 -0
  84. data/lib/vizcore/cli/scene_inspector.rb +136 -0
  85. data/lib/vizcore/cli/scene_validator.rb +337 -0
  86. data/lib/vizcore/cli/shader_template.rb +68 -0
  87. data/lib/vizcore/cli/shader_uniform_docs.rb +54 -0
  88. data/lib/vizcore/cli.rb +689 -18
  89. data/lib/vizcore/config.rb +103 -2
  90. data/lib/vizcore/control_preset.rb +68 -0
  91. data/lib/vizcore/dsl/engine.rb +277 -5
  92. data/lib/vizcore/dsl/layer_builder.rb +1280 -23
  93. data/lib/vizcore/dsl/layer_group_builder.rb +112 -0
  94. data/lib/vizcore/dsl/mapping_resolver.rb +290 -7
  95. data/lib/vizcore/dsl/mapping_transform_builder.rb +71 -0
  96. data/lib/vizcore/dsl/reaction_builder.rb +44 -0
  97. data/lib/vizcore/dsl/scene_builder.rb +61 -5
  98. data/lib/vizcore/dsl/shader_source_resolver.rb +67 -6
  99. data/lib/vizcore/dsl/style_builder.rb +68 -0
  100. data/lib/vizcore/dsl/timeline_builder.rb +138 -0
  101. data/lib/vizcore/dsl/transition_controller.rb +77 -0
  102. data/lib/vizcore/dsl.rb +5 -1
  103. data/lib/vizcore/layer_catalog.rb +275 -0
  104. data/lib/vizcore/project_manifest.rb +152 -0
  105. data/lib/vizcore/renderer/png_writer.rb +57 -0
  106. data/lib/vizcore/renderer/render_sequence.rb +153 -0
  107. data/lib/vizcore/renderer/scene_frame_source.rb +132 -0
  108. data/lib/vizcore/renderer/scene_serializer.rb +36 -3
  109. data/lib/vizcore/renderer/snapshot.rb +38 -0
  110. data/lib/vizcore/renderer/snapshot_renderer.rb +938 -0
  111. data/lib/vizcore/renderer.rb +5 -0
  112. data/lib/vizcore/server/frame_broadcaster.rb +143 -8
  113. data/lib/vizcore/server/gallery_app.rb +155 -0
  114. data/lib/vizcore/server/gallery_page.rb +100 -0
  115. data/lib/vizcore/server/gallery_runner.rb +48 -0
  116. data/lib/vizcore/server/rack_app.rb +203 -4
  117. data/lib/vizcore/server/runner.rb +391 -22
  118. data/lib/vizcore/server/scene_dependency_watcher.rb +79 -0
  119. data/lib/vizcore/server/websocket_handler.rb +60 -10
  120. data/lib/vizcore/server.rb +4 -0
  121. data/lib/vizcore/shape.rb +719 -0
  122. data/lib/vizcore/sync/osc_message.rb +103 -0
  123. data/lib/vizcore/sync/osc_receiver.rb +68 -0
  124. data/lib/vizcore/sync.rb +4 -0
  125. data/lib/vizcore/templates/midi_control_scene.rb +3 -1
  126. data/lib/vizcore/templates/plugin_layer.rb +20 -0
  127. data/lib/vizcore/templates/plugin_readme.md +23 -0
  128. data/lib/vizcore/templates/plugin_renderer.js +43 -0
  129. data/lib/vizcore/templates/plugin_scene.rb +14 -0
  130. data/lib/vizcore/templates/project_readme.md +7 -23
  131. data/lib/vizcore/templates/rubykaigi_scene.rb +30 -0
  132. data/lib/vizcore/version.rb +1 -1
  133. data/lib/vizcore.rb +28 -0
  134. data/scripts/browser_capture.mjs +75 -0
  135. data/sig/vizcore.rbs +461 -0
  136. metadata +94 -3
  137. data/docs/GETTING_STARTED.md +0 -105
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "layer_builder"
4
+
5
+ module Vizcore
6
+ module DSL
7
+ # Collects related layers and applies shared layer parameters.
8
+ class LayerGroupBuilder
9
+ # @param name [Symbol, String] group identifier stored on nested layer params
10
+ # @param styles [Hash] reusable layer parameter styles
11
+ # @param defaults [Hash] scene defaults already applied before group params
12
+ def initialize(name:, styles: {}, defaults: {})
13
+ @name = name.to_sym
14
+ @styles = styles
15
+ @params = deep_dup(defaults)
16
+ @layers = []
17
+ end
18
+
19
+ # Evaluate a group block.
20
+ #
21
+ # @yield Layer group DSL methods
22
+ # @return [Vizcore::DSL::LayerGroupBuilder]
23
+ def evaluate(&block)
24
+ instance_eval(&block) if block
25
+ self
26
+ end
27
+
28
+ # Define one layer in this group.
29
+ #
30
+ # @param name [Symbol, String] layer identifier
31
+ # @yield Layer definition block
32
+ # @return [void]
33
+ def layer(name, &block)
34
+ builder = LayerBuilder.new(name: name, styles: @styles, defaults: layer_defaults)
35
+ builder.evaluate(&block)
36
+ @layers << builder.to_h
37
+ end
38
+
39
+ # @param value [Symbol, String] layer compositing mode shared by nested layers
40
+ # @return [Symbol]
41
+ def blend(value)
42
+ @params[:blend] = value.to_sym
43
+ end
44
+
45
+ # Store an ordered color palette shared by nested layers.
46
+ #
47
+ # @param colors [Array<String, Array<String>>] color values such as "#00ffff"
48
+ # @raise [ArgumentError] when no non-blank colors are supplied
49
+ # @return [Array<String>]
50
+ def palette(*colors)
51
+ @params[:palette] = normalize_palette(colors)
52
+ end
53
+
54
+ # Merge a named style into this group's shared params.
55
+ #
56
+ # @param name [Symbol, String] style identifier
57
+ # @raise [ArgumentError] when the style is unknown
58
+ # @return [Hash] applied style params
59
+ def use_style(name)
60
+ style_name = name.to_sym
61
+ style_params = @styles.fetch(style_name) { raise ArgumentError, "unknown style: #{style_name}" }
62
+ @params.merge!(deep_dup(style_params))
63
+ end
64
+
65
+ # @return [Array<Hash>] serialized nested layers
66
+ def to_a
67
+ @layers.map { |layer| deep_dup(layer) }
68
+ end
69
+
70
+ # Stores dynamic one-argument setters into shared group params.
71
+ # @api private
72
+ def method_missing(method_name, *args, &block)
73
+ if block.nil? && args.length == 1
74
+ @params[method_name.to_sym] = args.first
75
+ return args.first
76
+ end
77
+
78
+ super
79
+ end
80
+
81
+ def respond_to_missing?(method_name, include_private = false)
82
+ @params.key?(method_name.to_sym) || super
83
+ end
84
+
85
+ private
86
+
87
+ def layer_defaults
88
+ deep_dup(@params).merge(group: @name)
89
+ end
90
+
91
+ def normalize_palette(colors)
92
+ values = colors.flatten.map { |color| color.to_s.strip }.reject(&:empty?)
93
+ raise ArgumentError, "group #{@name} palette requires at least one color" if values.empty?
94
+
95
+ values
96
+ end
97
+
98
+ def deep_dup(value)
99
+ case value
100
+ when Hash
101
+ value.each_with_object({}) do |(key, entry), output|
102
+ output[key] = deep_dup(entry)
103
+ end
104
+ when Array
105
+ value.map { |entry| deep_dup(entry) }
106
+ else
107
+ value
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
@@ -1,23 +1,31 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../shape"
4
+
3
5
  module Vizcore
4
6
  module DSL
5
7
  # Resolves `map` definitions into concrete per-layer parameter values.
6
8
  class MappingResolver
9
+ def initialize
10
+ @mapping_state = {}
11
+ end
12
+
7
13
  # @param scene_layers [Array<Hash>]
8
14
  # @param audio [Hash]
9
15
  # @return [Array<Hash>] normalized layer payloads with resolved params
10
- def resolve_layers(scene_layers:, audio:)
16
+ def resolve_layers(scene_layers:, audio:, time: 0.0, frame: 0, resolution: [1280, 720], globals: {}, custom_shape_overrides: {})
11
17
  normalize_scene_layers(scene_layers).map do |layer|
12
- resolve_layer(layer, audio)
18
+ resolve_layer(layer, audio, time: time, frame: frame, resolution: resolution, globals: globals, custom_shape_overrides: custom_shape_overrides)
13
19
  end
14
20
  end
15
21
 
16
22
  private
17
23
 
18
- def resolve_layer(layer, audio)
19
- params = (layer[:params] || {}).dup
20
- params.merge!(resolve_mappings(layer[:mappings], audio))
24
+ def resolve_layer(layer, audio, time:, frame:, resolution:, globals:, custom_shape_overrides:)
25
+ params = deep_dup(layer[:params] || {})
26
+ apply_custom_shape_overrides!(params, layer_name: layer[:name], custom_shape_overrides: custom_shape_overrides)
27
+ merge_resolved_mappings!(params, resolve_mappings(layer[:mappings], audio, layer_name: layer[:name]))
28
+ expand_dynamic_custom_shapes!(params, layer: layer, audio: audio, time: time, frame: frame, resolution: resolution, globals: globals)
21
29
 
22
30
  output = {
23
31
  name: layer.fetch(:name).to_s,
@@ -27,20 +35,201 @@ module Vizcore
27
35
  output[:shader] = layer[:shader].to_s if layer[:shader]
28
36
  output[:glsl] = layer[:glsl].to_s if layer[:glsl]
29
37
  output[:glsl_source] = layer[:glsl_source].to_s if layer[:glsl_source]
38
+ output[:param_schema] = Array(layer[:param_schema]).map(&:dup) if layer[:param_schema]
30
39
  output
31
40
  end
32
41
 
33
- def resolve_mappings(mappings, audio)
42
+ def resolve_mappings(mappings, audio, layer_name:)
34
43
  Array(mappings).each_with_object({}) do |mapping, resolved|
35
44
  source = mapping[:source]
36
45
  target = mapping[:target]
37
46
  next unless source && target
38
47
 
39
48
  value = resolve_source_value(source, audio)
40
- resolved[target.to_sym] = value unless value.nil?
49
+ value = apply_transform(value, mapping[:transform], state_key: [layer_name, target, source])
50
+ resolved[target.to_s] = value unless value.nil?
51
+ end
52
+ end
53
+
54
+ def merge_resolved_mappings!(params, mappings)
55
+ mappings.each do |target, value|
56
+ if target.include?(".")
57
+ assign_nested_param(params, target.split("."), value)
58
+ else
59
+ params[target.to_sym] = value
60
+ end
61
+ end
62
+ end
63
+
64
+ def expand_dynamic_custom_shapes!(params, layer:, audio:, time:, frame:, resolution:, globals:)
65
+ descriptors = Array(params.delete(:custom_shapes) || params.delete("custom_shapes"))
66
+ return if descriptors.empty?
67
+
68
+ params[:shapes] = Array(params[:shapes])
69
+ controls = []
70
+ descriptors.each_with_index do |descriptor, index|
71
+ start_index = params[:shapes].length
72
+ expanded = expand_dynamic_custom_shape(descriptor, layer: layer, palette: params[:palette], audio: audio, time: time, frame: frame, resolution: resolution, globals: globals)
73
+ params[:shapes].concat(expanded)
74
+ controls << custom_shape_control_descriptor(descriptor, index: index, start_index: start_index, count: expanded.length)
75
+ end
76
+ params[:custom_shape_controls] = controls unless controls.empty?
77
+ end
78
+
79
+ def expand_dynamic_custom_shape(descriptor, layer:, palette:, audio:, time:, frame:, resolution:, globals:)
80
+ values = Hash(descriptor)
81
+ renderer = values.fetch(:renderer)
82
+ shape_name = values[:name] || renderer
83
+ primitives = Vizcore::Shape.expand_custom_shape(
84
+ renderer,
85
+ params: Hash(values[:params] || {}),
86
+ shape_id: values[:shape_id],
87
+ layer_name: layer[:name],
88
+ palette: Array(palette),
89
+ audio: audio,
90
+ time: time,
91
+ frame: frame,
92
+ resolution: resolution,
93
+ globals: globals,
94
+ shape_name: shape_name
95
+ )
96
+ primitives.each { |primitive| apply_custom_shape_attributes!(primitive, values) }
97
+ end
98
+
99
+ def custom_shape_control_descriptor(descriptor, index:, start_index:, count:)
100
+ values = Hash(descriptor)
101
+ {
102
+ index: index,
103
+ name: (values[:name] || values["name"] || "custom_shape").to_s,
104
+ params: deep_dup(Hash(values[:params] || values["params"] || {})),
105
+ param_schema: Array(values[:param_schema] || values["param_schema"]).map { |entry| deep_dup(entry) },
106
+ shape_indices: (start_index...(start_index + count)).to_a
107
+ }
108
+ end
109
+
110
+ def apply_custom_shape_overrides!(params, layer_name:, custom_shape_overrides:)
111
+ layer_overrides = custom_shape_layer_overrides(custom_shape_overrides, layer_name)
112
+ return if layer_overrides.empty?
113
+
114
+ descriptors = Array(params[:custom_shapes] || params["custom_shapes"])
115
+ layer_overrides.each do |index, values|
116
+ descriptor = descriptors[Integer(index)]
117
+ next unless descriptor && values.is_a?(Hash)
118
+
119
+ descriptor[:params] ||= {}
120
+ values.each do |param_name, value|
121
+ key = param_name.to_sym
122
+ descriptor[:params][key] = value
123
+ end
124
+ rescue ArgumentError, TypeError
125
+ next
41
126
  end
42
127
  end
43
128
 
129
+ def custom_shape_layer_overrides(overrides, layer_name)
130
+ values = Hash(overrides)
131
+ name = layer_name.to_s
132
+ Hash(values[name] || values[layer_name.to_sym] || {})
133
+ rescue TypeError
134
+ {}
135
+ end
136
+
137
+ def apply_custom_shape_attributes!(primitive, descriptor)
138
+ style = Hash(descriptor[:style] || {})
139
+ style.each do |key, value|
140
+ symbol_key = key.to_sym
141
+ if symbol_key == :opacity && primitive.key?(:opacity)
142
+ primitive[:opacity] = numeric(style[:opacity] || style["opacity"], :opacity) * numeric(primitive[:opacity], :opacity)
143
+ else
144
+ primitive[symbol_key] = deep_dup(value) unless primitive.key?(symbol_key)
145
+ end
146
+ end
147
+
148
+ transform = Hash(descriptor[:transform] || {})
149
+ primitive[:transform] = compose_shape_transform(transform, primitive[:transform]) unless transform.empty?
150
+ primitive
151
+ end
152
+
153
+ def assign_nested_param(container, path, value)
154
+ key = path.shift
155
+ if path.empty?
156
+ assign_nested_value(container, key, value)
157
+ return
158
+ end
159
+
160
+ next_container = nested_value(container, key)
161
+ next_container = create_nested_container(container, key, path.first) if next_container.nil?
162
+ return unless next_container
163
+
164
+ assign_nested_param(next_container, path, value)
165
+ end
166
+
167
+ def nested_value(container, key)
168
+ return container[key.to_i] if container.is_a?(Array) && integer_key?(key)
169
+ return container[key.to_sym] if container.is_a?(Hash)
170
+
171
+ nil
172
+ end
173
+
174
+ def create_nested_container(container, key, next_key)
175
+ return unless container.is_a?(Hash)
176
+
177
+ value = integer_key?(next_key) ? [] : {}
178
+ container[key.to_sym] = value
179
+ end
180
+
181
+ def assign_nested_value(container, key, value)
182
+ if container.is_a?(Array) && integer_key?(key)
183
+ container[key.to_i] = value
184
+ elsif container.is_a?(Hash)
185
+ container[key.to_sym] = value
186
+ end
187
+ end
188
+
189
+ def integer_key?(value)
190
+ value.match?(/\A\d+\z/)
191
+ end
192
+
193
+ def compose_shape_transform(parent, child)
194
+ return deep_dup(child || {}) unless parent
195
+
196
+ child ||= {}
197
+ output = deep_dup(parent)
198
+ output[:translate] = add_shape_xy(parent[:translate], child[:translate]) if child.key?(:translate)
199
+ output[:origin] = child[:origin] if child.key?(:origin)
200
+ output[:rotate] = numeric(parent[:rotate] || 0, :rotate) + numeric(child[:rotate] || 0, :rotate) if child.key?(:rotate)
201
+ output[:scale] = multiply_shape_scale(parent[:scale], child[:scale]) if child.key?(:scale)
202
+ output
203
+ end
204
+
205
+ def add_shape_xy(parent, child)
206
+ parent ||= {}
207
+ child ||= {}
208
+ {
209
+ x: numeric(parent[:x] || parent["x"] || 0, :"translate.x") + numeric(child[:x] || child["x"] || 0, :"translate.x"),
210
+ y: numeric(parent[:y] || parent["y"] || 0, :"translate.y") + numeric(child[:y] || child["y"] || 0, :"translate.y")
211
+ }
212
+ end
213
+
214
+ def multiply_shape_scale(parent, child)
215
+ parent = shape_scale_pair(parent)
216
+ child = shape_scale_pair(child)
217
+ { x: parent[:x] * child[:x], y: parent[:y] * child[:y] }
218
+ end
219
+
220
+ def shape_scale_pair(value)
221
+ return { x: numeric(value[:x] || value["x"] || 1, :"scale.x"), y: numeric(value[:y] || value["y"] || 1, :"scale.y") } if value.is_a?(Hash)
222
+
223
+ scale = numeric(value || 1, :scale)
224
+ { x: scale, y: scale }
225
+ end
226
+
227
+ def numeric(value, name)
228
+ Float(value)
229
+ rescue ArgumentError, TypeError
230
+ raise ArgumentError, "param #{name} must be numeric"
231
+ end
232
+
44
233
  def resolve_source_value(source, audio)
45
234
  case source[:kind]&.to_sym
46
235
  when :amplitude
@@ -49,8 +238,16 @@ module Vizcore
49
238
  audio.dig(:bands, source[:band]&.to_sym)
50
239
  when :fft_spectrum
51
240
  audio[:fft]
241
+ when :onset
242
+ resolve_onset(source, audio)
243
+ when :kick, :snare, :hihat
244
+ audio.dig(:drums, source[:kind].to_sym)
52
245
  when :beat
53
246
  audio[:beat]
247
+ when :beat_confidence
248
+ audio[:beat_confidence]
249
+ when :beat_pulse
250
+ audio[:beat_pulse]
54
251
  when :beat_count
55
252
  audio[:beat_count]
56
253
  when :bpm
@@ -60,10 +257,96 @@ module Vizcore
60
257
  end
61
258
  end
62
259
 
260
+ def resolve_onset(source, audio)
261
+ band = source[:band]&.to_sym
262
+ return audio[:onset] unless band
263
+
264
+ audio.dig(:onsets, band)
265
+ end
266
+
267
+ def apply_transform(value, transform, state_key:)
268
+ return value if transform.nil? || transform.empty?
269
+ return transform_array(value, transform) if value.is_a?(Array)
270
+ return nil if value.is_a?(Hash) || value.nil?
271
+
272
+ transformed = transform_scalar(value, transform)
273
+ return nil if transformed.nil?
274
+
275
+ apply_smoothing(transformed, transform, state_key)
276
+ end
277
+
278
+ def transform_array(value, transform)
279
+ value.map do |entry|
280
+ transform_scalar(entry, transform, fallback: 0.0) || 0.0
281
+ end
282
+ end
283
+
284
+ def transform_scalar(value, transform, fallback: nil)
285
+ numeric = numeric_value(value, fallback: fallback)
286
+ return nil if numeric.nil?
287
+
288
+ numeric = 0.0 if transform.key?(:deadzone) && numeric.abs < Float(transform[:deadzone])
289
+ numeric *= Float(transform[:gain]) if transform.key?(:gain)
290
+ numeric = apply_curve(numeric, transform[:curve]) if transform[:curve]
291
+ numeric = [numeric, Float(transform[:min])].max if transform.key?(:min)
292
+ numeric = [numeric, Float(transform[:max])].min if transform.key?(:max)
293
+ numeric
294
+ end
295
+
296
+ def numeric_value(value, fallback:)
297
+ return value ? 1.0 : 0.0 if value == true || value == false
298
+
299
+ Float(value)
300
+ rescue ArgumentError, TypeError
301
+ fallback
302
+ end
303
+
304
+ def apply_curve(value, curve)
305
+ case curve.to_sym
306
+ when :linear
307
+ value
308
+ when :sqrt
309
+ Math.sqrt([value, 0.0].max)
310
+ when :square
311
+ value * value
312
+ when :ease_out
313
+ clamped = [[value, 0.0].max, 1.0].min
314
+ 1.0 - ((1.0 - clamped) * (1.0 - clamped))
315
+ end
316
+ end
317
+
318
+ def apply_smoothing(value, transform, state_key)
319
+ return value unless transform.key?(:attack) || transform.key?(:release)
320
+
321
+ previous = @mapping_state[state_key]
322
+ if previous.nil?
323
+ @mapping_state[state_key] = value
324
+ return value
325
+ end
326
+
327
+ alpha = value >= previous ? transform.fetch(:attack, 1.0) : transform.fetch(:release, 1.0)
328
+ smoothed = previous + (value - previous) * alpha
329
+ @mapping_state[state_key] = smoothed
330
+ smoothed
331
+ end
332
+
63
333
  def normalize_scene_layers(scene_layers)
64
334
  Array(scene_layers).map { |layer| deep_symbolize(layer) }
65
335
  end
66
336
 
337
+ def deep_dup(value)
338
+ case value
339
+ when Hash
340
+ value.each_with_object({}) do |(key, entry), output|
341
+ output[key] = deep_dup(entry)
342
+ end
343
+ when Array
344
+ value.map { |entry| deep_dup(entry) }
345
+ else
346
+ value
347
+ end
348
+ end
349
+
67
350
  def deep_symbolize(value)
68
351
  case value
69
352
  when Hash
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vizcore
4
+ module DSL
5
+ # Collects block-style mapping transform options.
6
+ class MappingTransformBuilder
7
+ # @param initial [Hash]
8
+ def initialize(initial = {})
9
+ @values = initial.each_with_object({}) do |(key, value), output|
10
+ output[key.to_sym] = value
11
+ end
12
+ end
13
+
14
+ # @return [Vizcore::DSL::MappingTransformBuilder]
15
+ def evaluate(&block)
16
+ instance_eval(&block) if block
17
+ self
18
+ end
19
+
20
+ # @param value [Numeric]
21
+ # @return [Numeric]
22
+ def gain(value)
23
+ @values[:gain] = value
24
+ end
25
+
26
+ # @param value [Range, Array]
27
+ # @return [Range, Array]
28
+ def range(value)
29
+ @values[:range] = value
30
+ end
31
+
32
+ # @param value [Numeric]
33
+ # @return [Numeric]
34
+ def min(value)
35
+ @values[:min] = value
36
+ end
37
+
38
+ # @param value [Numeric]
39
+ # @return [Numeric]
40
+ def max(value)
41
+ @values[:max] = value
42
+ end
43
+
44
+ # @param value [Symbol, String]
45
+ # @return [Symbol, String]
46
+ def curve(value)
47
+ @values[:curve] = value
48
+ end
49
+
50
+ # @param value [Numeric]
51
+ # @return [Numeric]
52
+ def deadzone(value)
53
+ @values[:deadzone] = value
54
+ end
55
+
56
+ # @param attack [Numeric, nil]
57
+ # @param release [Numeric, nil]
58
+ # @return [Hash]
59
+ def smooth(attack: nil, release: nil)
60
+ @values[:attack] = attack unless attack.nil?
61
+ @values[:release] = release unless release.nil?
62
+ @values
63
+ end
64
+
65
+ # @return [Hash]
66
+ def to_h
67
+ @values.dup
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vizcore
4
+ module DSL
5
+ # Collects high-level `react_to` DSL entries and converts them to mappings.
6
+ class ReactionBuilder
7
+ # @param mapping_factory [#call] builds one normalized mapping hash
8
+ def initialize(mapping_factory:)
9
+ @mapping_factory = mapping_factory
10
+ @mappings = []
11
+ end
12
+
13
+ # Evaluate a `react_to` block.
14
+ #
15
+ # @yield Reaction DSL methods
16
+ # @raise [ArgumentError] when the block does not define any reaction
17
+ # @return [Array<Hash>] normalized mapping payloads
18
+ def evaluate(&block)
19
+ instance_eval(&block) if block
20
+ raise ArgumentError, "react_to requires at least one change or trigger" if @mappings.empty?
21
+
22
+ @mappings.map(&:dup)
23
+ end
24
+
25
+ # Continuously map the reaction source to a target parameter.
26
+ #
27
+ # @param target [Symbol, String] layer parameter name
28
+ # @param options [Hash] mapping transform options
29
+ # @return [void]
30
+ def change(target, **options)
31
+ @mappings << @mapping_factory.call(target, options)
32
+ end
33
+
34
+ # Map the reaction source to an event-like target parameter.
35
+ #
36
+ # @param target [Symbol, String] layer parameter name
37
+ # @param options [Hash] mapping transform options
38
+ # @return [void]
39
+ def trigger(target, **options)
40
+ change(target, **options)
41
+ end
42
+ end
43
+ end
44
+ end
@@ -1,15 +1,23 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "layer_builder"
4
+ require_relative "layer_group_builder"
4
5
 
5
6
  module Vizcore
6
7
  module DSL
7
8
  # Collects layer definitions inside a single scene block.
8
9
  class SceneBuilder
9
10
  # @param name [Symbol, String] scene identifier
10
- def initialize(name:)
11
+ # @param styles [Hash] reusable layer parameter styles
12
+ # @param themes [Hash] reusable scene-wide layer parameter themes
13
+ # @param layers [Array<Hash>] initial layer definitions
14
+ def initialize(name:, styles: {}, themes: {}, layers: [])
11
15
  @name = name.to_sym
12
- @layers = []
16
+ @styles = styles
17
+ @themes = themes
18
+ @theme_name = nil
19
+ @theme_params = {}
20
+ @layers = layers.map { |layer| deep_dup(layer) }
13
21
  end
14
22
 
15
23
  # Evaluate a scene block.
@@ -27,17 +35,65 @@ module Vizcore
27
35
  # @yield Layer definition block
28
36
  # @return [void]
29
37
  def layer(name, &block)
30
- builder = LayerBuilder.new(name: name)
38
+ builder = LayerBuilder.new(name: name, styles: @styles, defaults: @theme_params)
31
39
  builder.evaluate(&block)
32
40
  @layers << builder.to_h
33
41
  end
34
42
 
43
+ # Define a related group of layers with shared params.
44
+ #
45
+ # @param name [Symbol, String] group identifier
46
+ # @yield Layer group definition block
47
+ # @return [void]
48
+ def group(name, &block)
49
+ builder = LayerGroupBuilder.new(name: name, styles: @styles, defaults: @theme_params)
50
+ builder.evaluate(&block)
51
+ @layers.concat(builder.to_a)
52
+ end
53
+
54
+ # Apply a named theme as default params for all layers in this scene.
55
+ #
56
+ # @param name [Symbol, String] theme identifier
57
+ # @raise [ArgumentError] when the theme is unknown
58
+ # @return [Hash] applied theme params
59
+ def use_theme(name)
60
+ theme_name = name.to_sym
61
+ theme_params = @themes.fetch(theme_name) { raise ArgumentError, "unknown theme: #{theme_name}" }
62
+ @theme_name = theme_name
63
+ @theme_params = deep_dup(theme_params)
64
+ @layers = @layers.map { |layer| apply_theme_defaults(layer, @theme_params) }
65
+ deep_dup(@theme_params)
66
+ end
67
+
35
68
  # @return [Hash] serialized scene payload
36
69
  def to_h
37
- {
70
+ scene = {
38
71
  name: @name,
39
- layers: @layers.map { |layer| layer.dup }
72
+ layers: @layers.map { |layer| deep_dup(layer) }
40
73
  }
74
+ scene[:theme] = @theme_name if @theme_name
75
+ scene
76
+ end
77
+
78
+ private
79
+
80
+ def apply_theme_defaults(layer, theme_params)
81
+ themed_layer = deep_dup(layer)
82
+ themed_layer[:params] = deep_dup(theme_params).merge(Hash(themed_layer[:params] || {}))
83
+ themed_layer
84
+ end
85
+
86
+ 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
41
97
  end
42
98
  end
43
99
  end