vizcore 0.1.0 → 1.0.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 (126) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +544 -9
  3. data/docs/.nojekyll +0 -0
  4. data/docs/assets/site.css +744 -0
  5. data/docs/assets/vizcore-demo.gif +0 -0
  6. data/docs/assets/vizcore-poster.png +0 -0
  7. data/docs/assets/vj-tunnel.js +159 -0
  8. data/docs/index.html +224 -0
  9. data/examples/README.md +59 -0
  10. data/examples/assets/README.md +19 -0
  11. data/examples/audio_inspector.rb +34 -0
  12. data/examples/club_intro_drop.rb +78 -0
  13. data/examples/kansai_rubykaigi_visual.rb +70 -0
  14. data/examples/live_coding_minimal.rb +22 -0
  15. data/examples/midi_controller_show.rb +78 -0
  16. data/examples/midi_scene_switch.rb +3 -1
  17. data/examples/parser_visualizer.rb +48 -0
  18. data/examples/readme_demo.rb +17 -0
  19. data/examples/rhythm_geometry.rb +34 -0
  20. data/examples/ruby_crystal_show.rb +35 -0
  21. data/examples/shader_playground.rb +18 -0
  22. data/examples/unyo_liquid.rb +59 -0
  23. data/examples/vj_ambient_chill_room.rb +124 -0
  24. data/examples/vj_dnb_jungle.rb +170 -0
  25. data/examples/vj_festival_mainstage.rb +245 -0
  26. data/examples/vj_festival_mainstage.yml +17 -0
  27. data/examples/vj_glitch_industrial.rb +164 -0
  28. data/examples/vj_hiphop_cipher.rb +167 -0
  29. data/examples/vj_jpop_idol_live.rb +210 -0
  30. data/examples/vj_synthwave_retro.rb +173 -0
  31. data/examples/vj_techno_warehouse.rb +195 -0
  32. data/frontend/index.html +468 -2
  33. data/frontend/src/audio-inspector.js +40 -0
  34. data/frontend/src/live-controls.js +131 -0
  35. data/frontend/src/main.js +792 -16
  36. data/frontend/src/midi-learn.js +194 -0
  37. data/frontend/src/performance-monitor.js +183 -0
  38. data/frontend/src/plugin-runtime.js +130 -0
  39. data/frontend/src/projector-mode.js +56 -0
  40. data/frontend/src/renderer/engine.js +148 -3
  41. data/frontend/src/renderer/layer-manager.js +428 -30
  42. data/frontend/src/renderer/shader-manager.js +26 -0
  43. data/frontend/src/runtime-control-preset.js +11 -0
  44. data/frontend/src/shader-error-overlay.js +29 -0
  45. data/frontend/src/shader-param-controls.js +93 -0
  46. data/frontend/src/shaders/builtins.js +380 -2
  47. data/frontend/src/shaders/post-effects.js +52 -0
  48. data/frontend/src/visual-regression.js +67 -0
  49. data/frontend/src/visual-settings-preset.js +103 -0
  50. data/frontend/src/visuals/geometry.js +268 -0
  51. data/frontend/src/visuals/image-renderer.js +291 -0
  52. data/frontend/src/visuals/particle-system.js +56 -10
  53. data/frontend/src/visuals/spectrogram-renderer.js +226 -0
  54. data/frontend/src/visuals/text-renderer.js +112 -11
  55. data/frontend/src/websocket-client.js +12 -1
  56. data/lib/vizcore/analysis/adaptive_normalizer.rb +70 -0
  57. data/lib/vizcore/analysis/beat_detector.rb +4 -2
  58. data/lib/vizcore/analysis/bpm_estimator.rb +8 -0
  59. data/lib/vizcore/analysis/feature_recorder.rb +159 -0
  60. data/lib/vizcore/analysis/feature_replay.rb +84 -0
  61. data/lib/vizcore/analysis/pipeline.rb +235 -11
  62. data/lib/vizcore/analysis/tap_tempo.rb +74 -0
  63. data/lib/vizcore/analysis.rb +4 -0
  64. data/lib/vizcore/audio/dummy_sine_input.rb +1 -1
  65. data/lib/vizcore/audio/fixture_input.rb +65 -0
  66. data/lib/vizcore/audio/input_manager.rb +4 -2
  67. data/lib/vizcore/audio/mic_input.rb +24 -8
  68. data/lib/vizcore/audio/portaudio_ffi.rb +106 -1
  69. data/lib/vizcore/audio.rb +1 -0
  70. data/lib/vizcore/cli/doctor.rb +159 -0
  71. data/lib/vizcore/cli/dsl_reference.rb +99 -0
  72. data/lib/vizcore/cli/layer_docs.rb +46 -0
  73. data/lib/vizcore/cli/scene_diagnostics.rb +23 -0
  74. data/lib/vizcore/cli/scene_inspector.rb +136 -0
  75. data/lib/vizcore/cli/scene_validator.rb +245 -0
  76. data/lib/vizcore/cli/shader_template.rb +68 -0
  77. data/lib/vizcore/cli/shader_uniform_docs.rb +54 -0
  78. data/lib/vizcore/cli.rb +689 -18
  79. data/lib/vizcore/config.rb +103 -2
  80. data/lib/vizcore/control_preset.rb +68 -0
  81. data/lib/vizcore/dsl/engine.rb +277 -5
  82. data/lib/vizcore/dsl/layer_builder.rb +491 -22
  83. data/lib/vizcore/dsl/layer_group_builder.rb +112 -0
  84. data/lib/vizcore/dsl/mapping_resolver.rb +132 -3
  85. data/lib/vizcore/dsl/mapping_transform_builder.rb +71 -0
  86. data/lib/vizcore/dsl/reaction_builder.rb +44 -0
  87. data/lib/vizcore/dsl/scene_builder.rb +61 -5
  88. data/lib/vizcore/dsl/shader_source_resolver.rb +67 -6
  89. data/lib/vizcore/dsl/style_builder.rb +68 -0
  90. data/lib/vizcore/dsl/timeline_builder.rb +138 -0
  91. data/lib/vizcore/dsl/transition_controller.rb +77 -0
  92. data/lib/vizcore/dsl.rb +5 -1
  93. data/lib/vizcore/layer_catalog.rb +273 -0
  94. data/lib/vizcore/project_manifest.rb +152 -0
  95. data/lib/vizcore/renderer/png_writer.rb +57 -0
  96. data/lib/vizcore/renderer/render_sequence.rb +153 -0
  97. data/lib/vizcore/renderer/scene_frame_source.rb +119 -0
  98. data/lib/vizcore/renderer/scene_serializer.rb +36 -3
  99. data/lib/vizcore/renderer/snapshot.rb +38 -0
  100. data/lib/vizcore/renderer/snapshot_renderer.rb +446 -0
  101. data/lib/vizcore/renderer.rb +5 -0
  102. data/lib/vizcore/server/frame_broadcaster.rb +91 -5
  103. data/lib/vizcore/server/gallery_app.rb +155 -0
  104. data/lib/vizcore/server/gallery_page.rb +100 -0
  105. data/lib/vizcore/server/gallery_runner.rb +48 -0
  106. data/lib/vizcore/server/rack_app.rb +203 -4
  107. data/lib/vizcore/server/runner.rb +370 -22
  108. data/lib/vizcore/server/scene_dependency_watcher.rb +79 -0
  109. data/lib/vizcore/server/websocket_handler.rb +60 -10
  110. data/lib/vizcore/server.rb +4 -0
  111. data/lib/vizcore/sync/osc_message.rb +103 -0
  112. data/lib/vizcore/sync/osc_receiver.rb +68 -0
  113. data/lib/vizcore/sync.rb +4 -0
  114. data/lib/vizcore/templates/midi_control_scene.rb +3 -1
  115. data/lib/vizcore/templates/plugin_layer.rb +20 -0
  116. data/lib/vizcore/templates/plugin_readme.md +23 -0
  117. data/lib/vizcore/templates/plugin_renderer.js +43 -0
  118. data/lib/vizcore/templates/plugin_scene.rb +14 -0
  119. data/lib/vizcore/templates/project_readme.md +7 -23
  120. data/lib/vizcore/templates/rubykaigi_scene.rb +30 -0
  121. data/lib/vizcore/version.rb +1 -1
  122. data/lib/vizcore.rb +27 -0
  123. data/scripts/browser_capture.mjs +75 -0
  124. data/sig/vizcore.rbs +362 -0
  125. metadata +83 -3
  126. 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
@@ -4,6 +4,10 @@ module Vizcore
4
4
  module DSL
5
5
  # Resolves `map` definitions into concrete per-layer parameter values.
6
6
  class MappingResolver
7
+ def initialize
8
+ @mapping_state = {}
9
+ end
10
+
7
11
  # @param scene_layers [Array<Hash>]
8
12
  # @param audio [Hash]
9
13
  # @return [Array<Hash>] normalized layer payloads with resolved params
@@ -17,7 +21,7 @@ module Vizcore
17
21
 
18
22
  def resolve_layer(layer, audio)
19
23
  params = (layer[:params] || {}).dup
20
- params.merge!(resolve_mappings(layer[:mappings], audio))
24
+ merge_resolved_mappings!(params, resolve_mappings(layer[:mappings], audio, layer_name: layer[:name]))
21
25
 
22
26
  output = {
23
27
  name: layer.fetch(:name).to_s,
@@ -27,20 +31,64 @@ module Vizcore
27
31
  output[:shader] = layer[:shader].to_s if layer[:shader]
28
32
  output[:glsl] = layer[:glsl].to_s if layer[:glsl]
29
33
  output[:glsl_source] = layer[:glsl_source].to_s if layer[:glsl_source]
34
+ output[:param_schema] = Array(layer[:param_schema]).map(&:dup) if layer[:param_schema]
30
35
  output
31
36
  end
32
37
 
33
- def resolve_mappings(mappings, audio)
38
+ def resolve_mappings(mappings, audio, layer_name:)
34
39
  Array(mappings).each_with_object({}) do |mapping, resolved|
35
40
  source = mapping[:source]
36
41
  target = mapping[:target]
37
42
  next unless source && target
38
43
 
39
44
  value = resolve_source_value(source, audio)
40
- resolved[target.to_sym] = value unless value.nil?
45
+ value = apply_transform(value, mapping[:transform], state_key: [layer_name, target, source])
46
+ resolved[target.to_s] = value unless value.nil?
47
+ end
48
+ end
49
+
50
+ def merge_resolved_mappings!(params, mappings)
51
+ mappings.each do |target, value|
52
+ if target.include?(".")
53
+ assign_nested_param(params, target.split("."), value)
54
+ else
55
+ params[target.to_sym] = value
56
+ end
57
+ end
58
+ end
59
+
60
+ def assign_nested_param(container, path, value)
61
+ key = path.shift
62
+ if path.empty?
63
+ assign_nested_value(container, key, value)
64
+ return
65
+ end
66
+
67
+ next_container = nested_value(container, key)
68
+ return unless next_container
69
+
70
+ assign_nested_param(next_container, path, value)
71
+ end
72
+
73
+ def nested_value(container, key)
74
+ return container[key.to_i] if container.is_a?(Array) && integer_key?(key)
75
+ return container[key.to_sym] if container.is_a?(Hash)
76
+
77
+ nil
78
+ end
79
+
80
+ def assign_nested_value(container, key, value)
81
+ if container.is_a?(Array) && integer_key?(key)
82
+ container[key.to_i] = value
83
+ elsif container.is_a?(Hash)
84
+ container[key.to_sym] = value
41
85
  end
42
86
  end
43
87
 
88
+ def integer_key?(value)
89
+ value.match?(/\A\d+\z/)
90
+ end
91
+
44
92
  def resolve_source_value(source, audio)
45
93
  case source[:kind]&.to_sym
46
94
  when :amplitude
@@ -49,8 +97,16 @@ module Vizcore
49
97
  audio.dig(:bands, source[:band]&.to_sym)
50
98
  when :fft_spectrum
51
99
  audio[:fft]
100
+ when :onset
101
+ resolve_onset(source, audio)
102
+ when :kick, :snare, :hihat
103
+ audio.dig(:drums, source[:kind].to_sym)
52
104
  when :beat
53
105
  audio[:beat]
106
+ when :beat_confidence
107
+ audio[:beat_confidence]
108
+ when :beat_pulse
109
+ audio[:beat_pulse]
54
110
  when :beat_count
55
111
  audio[:beat_count]
56
112
  when :bpm
@@ -60,6 +116,79 @@ module Vizcore
60
116
  end
61
117
  end
62
118
 
119
+ def resolve_onset(source, audio)
120
+ band = source[:band]&.to_sym
121
+ return audio[:onset] unless band
122
+
123
+ audio.dig(:onsets, band)
124
+ end
125
+
126
+ def apply_transform(value, transform, state_key:)
127
+ return value if transform.nil? || transform.empty?
128
+ return transform_array(value, transform) if value.is_a?(Array)
129
+ return nil if value.is_a?(Hash) || value.nil?
130
+
131
+ transformed = transform_scalar(value, transform)
132
+ return nil if transformed.nil?
133
+
134
+ apply_smoothing(transformed, transform, state_key)
135
+ end
136
+
137
+ def transform_array(value, transform)
138
+ value.map do |entry|
139
+ transform_scalar(entry, transform, fallback: 0.0) || 0.0
140
+ end
141
+ end
142
+
143
+ def transform_scalar(value, transform, fallback: nil)
144
+ numeric = numeric_value(value, fallback: fallback)
145
+ return nil if numeric.nil?
146
+
147
+ numeric = 0.0 if transform.key?(:deadzone) && numeric.abs < Float(transform[:deadzone])
148
+ numeric *= Float(transform[:gain]) if transform.key?(:gain)
149
+ numeric = apply_curve(numeric, transform[:curve]) if transform[:curve]
150
+ numeric = [numeric, Float(transform[:min])].max if transform.key?(:min)
151
+ numeric = [numeric, Float(transform[:max])].min if transform.key?(:max)
152
+ numeric
153
+ end
154
+
155
+ def numeric_value(value, fallback:)
156
+ return value ? 1.0 : 0.0 if value == true || value == false
157
+
158
+ Float(value)
159
+ rescue ArgumentError, TypeError
160
+ fallback
161
+ end
162
+
163
+ def apply_curve(value, curve)
164
+ case curve.to_sym
165
+ when :linear
166
+ value
167
+ when :sqrt
168
+ Math.sqrt([value, 0.0].max)
169
+ when :square
170
+ value * value
171
+ when :ease_out
172
+ clamped = [[value, 0.0].max, 1.0].min
173
+ 1.0 - ((1.0 - clamped) * (1.0 - clamped))
174
+ end
175
+ end
176
+
177
+ def apply_smoothing(value, transform, state_key)
178
+ return value unless transform.key?(:attack) || transform.key?(:release)
179
+
180
+ previous = @mapping_state[state_key]
181
+ if previous.nil?
182
+ @mapping_state[state_key] = value
183
+ return value
184
+ end
185
+
186
+ alpha = value >= previous ? transform.fetch(:attack, 1.0) : transform.fetch(:release, 1.0)
187
+ smoothed = previous + (value - previous) * alpha
188
+ @mapping_state[state_key] = smoothed
189
+ smoothed
190
+ end
191
+
63
192
  def normalize_scene_layers(scene_layers)
64
193
  Array(scene_layers).map { |layer| deep_symbolize(layer) }
65
194
  end
@@ -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
@@ -1,15 +1,28 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "base64"
3
4
  require "pathname"
4
5
 
5
6
  module Vizcore
6
7
  module DSL
7
- # Replaces layer `glsl` paths with inlined shader source text.
8
+ # Replaces external layer source paths with browser-ready inline payloads.
8
9
  class ShaderSourceResolver
10
+ MEDIA_MIME_TYPES = {
11
+ ".gif" => "image/gif",
12
+ ".jpg" => "image/jpeg",
13
+ ".jpeg" => "image/jpeg",
14
+ ".png" => "image/png",
15
+ ".svg" => "image/svg+xml",
16
+ ".webp" => "image/webp",
17
+ ".mp4" => "video/mp4",
18
+ ".ogv" => "video/ogg",
19
+ ".webm" => "video/webm"
20
+ }.freeze
21
+
9
22
  # @param definition [Hash] DSL definition payload
10
23
  # @param scene_file [String, Pathname] source scene file
11
- # @raise [ArgumentError] when a referenced GLSL file is missing
12
- # @return [Hash] deep-copied definition with `:glsl_source` entries
24
+ # @raise [ArgumentError] when a referenced source file is missing
25
+ # @return [Hash] deep-copied definition with resolved layer source entries
13
26
  def resolve(definition:, scene_file:)
14
27
  scene_path = Pathname.new(scene_file.to_s).expand_path
15
28
  base_dir = scene_path.dirname
@@ -29,8 +42,13 @@ module Vizcore
29
42
  def resolve_layer(layer, base_dir:)
30
43
  layer_hash = symbolize_hash(layer)
31
44
  shader_path = layer_hash[:glsl]
32
- return layer_hash unless shader_path
45
+ layer_hash = resolve_shader_layer(layer_hash, base_dir: base_dir) if shader_path
46
+ layer_hash = resolve_media_layer(layer_hash, base_dir: base_dir) if media_layer?(layer_hash)
47
+ layer_hash
48
+ end
33
49
 
50
+ def resolve_shader_layer(layer_hash, base_dir:)
51
+ shader_path = layer_hash.fetch(:glsl)
34
52
  full_path = resolve_path(base_dir: base_dir, shader_path: shader_path)
35
53
  raise ArgumentError, "GLSL file not found: #{shader_path}" unless full_path.file?
36
54
 
@@ -39,8 +57,51 @@ module Vizcore
39
57
  layer_hash
40
58
  end
41
59
 
42
- def resolve_path(base_dir:, shader_path:)
43
- path = Pathname.new(shader_path.to_s)
60
+ def resolve_media_layer(layer_hash, base_dir:)
61
+ params = symbolize_hash(layer_hash[:params] || {})
62
+ media_path = params[:file]
63
+ return layer_hash unless media_path
64
+
65
+ full_path = resolve_path(base_dir: base_dir, relative_path: media_path)
66
+ raise ArgumentError, "#{media_layer_label(layer_hash)} file not found: #{media_path}" unless full_path.file?
67
+
68
+ mime_type = MEDIA_MIME_TYPES[full_path.extname.downcase]
69
+ raise ArgumentError, "Unsupported #{media_layer_label(layer_hash)} file extension: #{media_path}" unless mime_type
70
+ raise ArgumentError, "Unsupported SVG file extension: #{media_path}" if svg_layer?(layer_hash) && mime_type != "image/svg+xml"
71
+ raise ArgumentError, "Unsupported Image file extension: #{media_path}" if image_layer?(layer_hash) && !mime_type.start_with?("image/")
72
+ raise ArgumentError, "Unsupported Video file extension: #{media_path}" if video_layer?(layer_hash) && !mime_type.start_with?("video/")
73
+
74
+ params[:file] = media_path.to_s
75
+ params[:src] = "data:#{mime_type};base64,#{Base64.strict_encode64(full_path.binread)}"
76
+ layer_hash[:params] = params
77
+ layer_hash
78
+ end
79
+
80
+ def svg_layer?(layer_hash)
81
+ %i[svg svg_layer].include?(layer_hash[:type]&.to_sym)
82
+ end
83
+
84
+ def image_layer?(layer_hash)
85
+ %i[image image_layer photo].include?(layer_hash[:type]&.to_sym)
86
+ end
87
+
88
+ def video_layer?(layer_hash)
89
+ %i[video video_layer footage].include?(layer_hash[:type]&.to_sym)
90
+ end
91
+
92
+ def media_layer?(layer_hash)
93
+ svg_layer?(layer_hash) || image_layer?(layer_hash) || video_layer?(layer_hash)
94
+ end
95
+
96
+ def media_layer_label(layer_hash)
97
+ return "SVG" if svg_layer?(layer_hash)
98
+ return "Video" if video_layer?(layer_hash)
99
+
100
+ "Image"
101
+ end
102
+
103
+ def resolve_path(base_dir:, relative_path: nil, shader_path: nil)
104
+ path = Pathname.new((relative_path || shader_path).to_s)
44
105
  return path.expand_path if path.absolute?
45
106
 
46
107
  base_dir.join(path).expand_path
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vizcore
4
+ module DSL
5
+ # Collects reusable layer parameter presets for the `style` DSL.
6
+ class StyleBuilder
7
+ # @param name [Symbol, String] style identifier
8
+ # @param kind [String] user-facing DSL kind for error messages
9
+ def initialize(name:, kind: "style")
10
+ @name = name.to_sym
11
+ @kind = kind
12
+ @params = {}
13
+ end
14
+
15
+ # Evaluate a style block.
16
+ #
17
+ # @yield Style parameter declarations
18
+ # @return [Vizcore::DSL::StyleBuilder]
19
+ def evaluate(&block)
20
+ instance_eval(&block) if block
21
+ raise ArgumentError, "#{@kind} #{@name} requires at least one parameter" if @params.empty?
22
+
23
+ self
24
+ end
25
+
26
+ # @return [Hash] serialized style payload
27
+ def to_h
28
+ {
29
+ name: @name,
30
+ params: @params.dup
31
+ }
32
+ end
33
+
34
+ # Store an ordered color palette for styles and themes.
35
+ #
36
+ # @param colors [Array<String, Array<String>>] color values such as "#00ffff"
37
+ # @raise [ArgumentError] when no non-blank colors are supplied
38
+ # @return [Array<String>]
39
+ def palette(*colors)
40
+ @params[:palette] = normalize_palette(colors)
41
+ end
42
+
43
+ # Stores one-argument style setters into `params`.
44
+ # @api private
45
+ def method_missing(method_name, *args, &block)
46
+ if block.nil? && args.length == 1
47
+ @params[method_name.to_sym] = args.first
48
+ return args.first
49
+ end
50
+
51
+ super
52
+ end
53
+
54
+ def respond_to_missing?(_method_name, _include_private = false)
55
+ true
56
+ end
57
+
58
+ private
59
+
60
+ def normalize_palette(colors)
61
+ values = colors.flatten.map { |color| color.to_s.strip }.reject(&:empty?)
62
+ raise ArgumentError, "#{@kind} #{@name} palette requires at least one color" if values.empty?
63
+
64
+ values
65
+ end
66
+ end
67
+ end
68
+ end