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,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vizcore
4
+ module DSL
5
+ # Collects ordered timeline scene markers and converts them to transitions.
6
+ class TimelineBuilder
7
+ DEFAULT_BEATS_PER_BAR = 4
8
+
9
+ Point = Struct.new(:value, :unit, keyword_init: true)
10
+
11
+ def initialize(beats_per_bar: DEFAULT_BEATS_PER_BAR)
12
+ @beats_per_bar = positive_integer(beats_per_bar, "beats_per_bar")
13
+ @entries = []
14
+ end
15
+
16
+ # Evaluate a timeline block.
17
+ #
18
+ # @yield Timeline marker definitions
19
+ # @return [Vizcore::DSL::TimelineBuilder]
20
+ def evaluate(&block)
21
+ instance_eval(&block) if block
22
+ validate_entries!
23
+ self
24
+ end
25
+
26
+ # Add a scene marker at a timeline position.
27
+ #
28
+ # @param position [Numeric, Point] seconds by default, or a value from `seconds`, `beats`, or `bars`
29
+ # @param scene [Symbol, String] scene to activate at the position
30
+ # @return [Hash]
31
+ def at(position, scene:)
32
+ point = normalize_position(position)
33
+ entry = {
34
+ at: point.value,
35
+ unit: point.unit,
36
+ scene: scene.to_sym
37
+ }
38
+ @entries << entry
39
+ entry
40
+ end
41
+
42
+ # @param value [Numeric] seconds from the timeline start
43
+ # @return [Point]
44
+ def seconds(value)
45
+ Point.new(value: non_negative_float(value, "timeline seconds"), unit: :seconds)
46
+ end
47
+
48
+ # @param value [Numeric] beats from the timeline start
49
+ # @return [Point]
50
+ def beats(value)
51
+ Point.new(value: non_negative_float(value, "timeline beats"), unit: :beats)
52
+ end
53
+
54
+ # @param value [Numeric] bars from the timeline start
55
+ # @param beats_per_bar [Integer, nil] meter override
56
+ # @return [Point]
57
+ def bars(value, beats_per_bar: nil)
58
+ beats_per_measure = beats_per_bar.nil? ? @beats_per_bar : positive_integer(beats_per_bar, "beats_per_bar")
59
+ beats(non_negative_float(value, "timeline bars") * beats_per_measure)
60
+ end
61
+
62
+ # @return [Array<Hash>] serialized marker definitions
63
+ def to_h
64
+ @entries.map(&:dup)
65
+ end
66
+
67
+ # @return [Array<Hash>] generated scene transitions
68
+ def transitions
69
+ @entries.each_cons(2).map do |from_entry, to_entry|
70
+ delta = to_entry.fetch(:at) - from_entry.fetch(:at)
71
+ {
72
+ from: from_entry.fetch(:scene),
73
+ to: to_entry.fetch(:scene),
74
+ trigger: trigger_for(delta, from_entry.fetch(:unit))
75
+ }
76
+ end
77
+ end
78
+
79
+ private
80
+
81
+ def normalize_position(position)
82
+ return position if position.is_a?(Point)
83
+
84
+ seconds(position)
85
+ end
86
+
87
+ def trigger_for(delta, unit)
88
+ case unit
89
+ when :seconds
90
+ proc { seconds >= delta }
91
+ when :beats
92
+ proc { beat_count >= delta }
93
+ else
94
+ proc { false }
95
+ end
96
+ end
97
+
98
+ def validate_entries!
99
+ return if @entries.length < 2
100
+
101
+ unit = @entries.first.fetch(:unit)
102
+ @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
104
+
105
+ from_position = from_entry.fetch(:at)
106
+ to_position = to_entry.fetch(:at)
107
+ raise ArgumentError, "timeline positions must increase" unless to_position > from_position
108
+ end
109
+ end
110
+
111
+ def non_negative_float(value, name)
112
+ numeric = parse_float(value, name)
113
+ raise ArgumentError, "#{name} must be non-negative" if numeric.negative?
114
+
115
+ numeric
116
+ end
117
+
118
+ def positive_integer(value, name)
119
+ numeric = parse_integer(value, name)
120
+ raise ArgumentError, "#{name} must be positive" unless numeric.positive?
121
+
122
+ numeric
123
+ end
124
+
125
+ def parse_float(value, name)
126
+ Float(value)
127
+ rescue ArgumentError, TypeError
128
+ raise ArgumentError, "#{name} must be numeric"
129
+ end
130
+
131
+ def parse_integer(value, name)
132
+ Integer(value)
133
+ rescue ArgumentError, TypeError
134
+ raise ArgumentError, "#{name} must be an integer"
135
+ end
136
+ end
137
+ end
138
+ end
@@ -4,6 +4,8 @@ module Vizcore
4
4
  module DSL
5
5
  # Evaluates transition rules and returns scene-change payloads.
6
6
  class TransitionController
7
+ DEFAULT_FRAME_RATE = 60.0
8
+
7
9
  # @param scenes [Array<Hash>]
8
10
  # @param transitions [Array<Hash>]
9
11
  def initialize(scenes:, transitions:)
@@ -108,6 +110,8 @@ module Vizcore
108
110
  def initialize(audio, frame_count:)
109
111
  @audio = symbolize_hash(audio)
110
112
  @bands = symbolize_hash(@audio[:bands])
113
+ @onsets = symbolize_hash(@audio[:onsets])
114
+ @drums = symbolize_hash(@audio[:drums])
111
115
  @frame_count = Integer(frame_count)
112
116
  rescue StandardError
113
117
  @frame_count = 0
@@ -124,16 +128,84 @@ module Vizcore
124
128
  @bands[name.to_sym].to_f
125
129
  end
126
130
 
131
+ # @return [Float]
132
+ def sub
133
+ frequency_band(:sub)
134
+ end
135
+
136
+ # @return [Float]
137
+ def low
138
+ frequency_band(:low)
139
+ end
140
+
141
+ # @return [Float]
142
+ def bass
143
+ frequency_band(:low)
144
+ end
145
+
146
+ # @return [Float]
147
+ def mid
148
+ frequency_band(:mid)
149
+ end
150
+
151
+ # @return [Float]
152
+ def high
153
+ frequency_band(:high)
154
+ end
155
+
156
+ # @return [Float]
157
+ def treble
158
+ frequency_band(:high)
159
+ end
160
+
127
161
  # @return [Array<Float>]
128
162
  def fft_spectrum
129
163
  Array(@audio[:fft])
130
164
  end
131
165
 
166
+ # @param name [Symbol, String, nil]
167
+ # @return [Float]
168
+ def onset(name = nil)
169
+ return @audio[:onset].to_f if name.nil?
170
+
171
+ @onsets[name.to_sym].to_f
172
+ end
173
+
174
+ # @return [Float]
175
+ def kick
176
+ @drums[:kick].to_f
177
+ end
178
+
179
+ # @return [Float]
180
+ def snare
181
+ @drums[:snare].to_f
182
+ end
183
+
184
+ # @return [Float]
185
+ def hihat
186
+ @drums[:hihat].to_f
187
+ end
188
+
132
189
  # @return [Boolean]
133
190
  def beat?
134
191
  !!@audio[:beat]
135
192
  end
136
193
 
194
+ # @return [Boolean]
195
+ def beat
196
+ beat?
197
+ end
198
+
199
+ # @return [Float]
200
+ def beat_confidence
201
+ @audio[:beat_confidence].to_f
202
+ end
203
+
204
+ # @return [Float]
205
+ def beat_pulse
206
+ @audio[:beat_pulse].to_f
207
+ end
208
+
137
209
  # @return [Integer]
138
210
  def beat_count
139
211
  Integer(@audio[:beat_count] || 0)
@@ -151,6 +223,11 @@ module Vizcore
151
223
  @frame_count
152
224
  end
153
225
 
226
+ # @return [Float] scene-local elapsed seconds at the default runtime frame rate
227
+ def seconds
228
+ @frame_count / DEFAULT_FRAME_RATE
229
+ end
230
+
154
231
  private
155
232
 
156
233
  def symbolize_hash(value)
data/lib/vizcore/dsl.rb CHANGED
@@ -6,11 +6,15 @@ module Vizcore
6
6
  end
7
7
  end
8
8
 
9
- require_relative "dsl/layer_builder"
10
9
  require_relative "dsl/file_watcher"
11
10
  require_relative "dsl/mapping_resolver"
11
+ require_relative "dsl/mapping_transform_builder"
12
12
  require_relative "dsl/midi_map_executor"
13
+ require_relative "dsl/reaction_builder"
14
+ require_relative "dsl/style_builder"
15
+ require_relative "dsl/layer_builder"
13
16
  require_relative "dsl/scene_builder"
14
17
  require_relative "dsl/shader_source_resolver"
18
+ require_relative "dsl/timeline_builder"
15
19
  require_relative "dsl/transition_controller"
16
20
  require_relative "dsl/engine"
@@ -0,0 +1,273 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vizcore
4
+ # Metadata for built-in layer types, params, shaders, and browser effects.
5
+ module LayerCatalog
6
+ Capability = Struct.new(:type, :aliases, :params, :mappable_params, :description, keyword_init: true) do
7
+ def types
8
+ [type, *aliases].map(&:to_sym)
9
+ end
10
+
11
+ def supports?(value)
12
+ types.include?(value.to_sym)
13
+ rescue StandardError
14
+ false
15
+ end
16
+ end
17
+
18
+ COMMON_PARAMS = {
19
+ opacity: "Float",
20
+ blend: "Symbol",
21
+ effect: "Symbol",
22
+ effect_intensity: "Float",
23
+ vj_effect: "Symbol",
24
+ palette: "Array<String>",
25
+ color: "String",
26
+ group: "Symbol"
27
+ }.freeze
28
+
29
+ CAPABILITIES = [
30
+ Capability.new(
31
+ type: :geometry,
32
+ aliases: %i[wireframe_cube radial_blob],
33
+ params: COMMON_PARAMS.merge(
34
+ rotation_speed: "Float",
35
+ color_shift: "Float",
36
+ deform: "Array<Float>"
37
+ ),
38
+ mappable_params: %i[rotation_speed color_shift deform],
39
+ description: "Wireframe and radial geometry rendered by the browser."
40
+ ),
41
+ Capability.new(
42
+ type: :shader,
43
+ aliases: [],
44
+ params: COMMON_PARAMS.merge(
45
+ shader_reload: "Boolean",
46
+ param_schema: "Array<Hash>"
47
+ ),
48
+ mappable_params: %i[effect_intensity],
49
+ description: "GLSL ES fragment shader layer with built-in audio uniforms."
50
+ ),
51
+ Capability.new(
52
+ type: :particle_field,
53
+ aliases: %i[particles particle],
54
+ params: COMMON_PARAMS.merge(
55
+ count: "Integer",
56
+ speed: "Float",
57
+ size: "Float",
58
+ force_field: "Symbol",
59
+ turbulence: "Float",
60
+ bass_explosion: "Float",
61
+ sparkle: "Float"
62
+ ),
63
+ mappable_params: %i[speed size turbulence bass_explosion sparkle],
64
+ description: "Audio-reactive point particles with simple force fields."
65
+ ),
66
+ Capability.new(
67
+ type: :text,
68
+ aliases: %i[text_layer],
69
+ params: COMMON_PARAMS.merge(
70
+ content: "String",
71
+ font_size: "Integer",
72
+ letter_spacing: "Float",
73
+ font: "String",
74
+ align: "Symbol",
75
+ stroke_width: "Float",
76
+ stroke_color: "String",
77
+ shadow_color: "String",
78
+ shadow_blur: "Float",
79
+ glow_strength: "Float"
80
+ ),
81
+ mappable_params: %i[font_size letter_spacing glow_strength],
82
+ description: "Canvas text rendered into the WebGL scene."
83
+ ),
84
+ Capability.new(
85
+ type: :svg,
86
+ aliases: %i[svg_layer],
87
+ params: COMMON_PARAMS.merge(
88
+ file: "String",
89
+ src: "String",
90
+ scale: "Float",
91
+ rotation: "Float",
92
+ fit: "Symbol"
93
+ ),
94
+ mappable_params: %i[scale rotation opacity],
95
+ description: "Inline SVG asset rendered as a textured visual layer."
96
+ ),
97
+ Capability.new(
98
+ type: :image,
99
+ aliases: %i[image_layer photo],
100
+ params: COMMON_PARAMS.merge(
101
+ file: "String",
102
+ src: "String",
103
+ scale: "Float",
104
+ rotation: "Float",
105
+ fit: "Symbol"
106
+ ),
107
+ mappable_params: %i[scale rotation opacity],
108
+ description: "Inline PNG/JPEG/GIF/WebP image asset rendered as a textured visual layer."
109
+ ),
110
+ Capability.new(
111
+ type: :video,
112
+ aliases: %i[video_layer footage],
113
+ params: COMMON_PARAMS.merge(
114
+ file: "String",
115
+ src: "String",
116
+ fit: "Symbol",
117
+ scale: "Float",
118
+ rotation: "Float",
119
+ playback_rate: "Float",
120
+ invert: "Float"
121
+ ),
122
+ mappable_params: %i[scale rotation opacity playback_rate invert],
123
+ description: "Inline MP4/WebM/OGV video texture rendered as a looping visual layer."
124
+ ),
125
+ Capability.new(
126
+ type: :waveform,
127
+ aliases: %i[waveform_layer],
128
+ params: COMMON_PARAMS.merge(
129
+ source: "Symbol",
130
+ style: "Symbol",
131
+ height: "Float",
132
+ detail: "Integer"
133
+ ),
134
+ mappable_params: %i[height opacity color_shift],
135
+ description: "Audio feature waveform rendered as line, mirror, or ribbon geometry."
136
+ ),
137
+ Capability.new(
138
+ type: :spectrogram,
139
+ aliases: %i[spectrogram_layer],
140
+ params: COMMON_PARAMS.merge(
141
+ scroll: "Symbol",
142
+ bins: "Integer",
143
+ history: "Integer",
144
+ gain: "Float"
145
+ ),
146
+ mappable_params: %i[gain opacity],
147
+ description: "Scrolling FFT heatmap rendered by the browser."
148
+ ),
149
+ Capability.new(
150
+ type: :shape,
151
+ aliases: %i[shapes shape_layer],
152
+ params: COMMON_PARAMS.merge(
153
+ shapes: "Array<Hash>",
154
+ color_shift: "Float"
155
+ ),
156
+ mappable_params: %i[color_shift opacity],
157
+ description: "Declarative 2D circle and line primitives rendered by the browser."
158
+ ),
159
+ Capability.new(
160
+ type: :mesh,
161
+ aliases: %i[mesh_layer preset_mesh],
162
+ params: COMMON_PARAMS.merge(
163
+ geometry: "Symbol",
164
+ material: "Symbol",
165
+ scale: "Float",
166
+ deform: "Float",
167
+ color_shift: "Float"
168
+ ),
169
+ mappable_params: %i[scale deform opacity color_shift],
170
+ description: "Preset 3D wireframe meshes: cube, tetrahedron, octahedron, and icosahedron."
171
+ )
172
+ ].freeze
173
+
174
+ BUILTIN_SHADERS = %i[
175
+ default gradient_pulse bass_tunnel neon_grid kaleidoscope spectrum_rings
176
+ liquid_wobble audio_bars ruby_crystal starfield waveform_ribbon
177
+ unyo_geometry glitch_flash
178
+ ].freeze
179
+
180
+ BLEND_MODES = %i[
181
+ alpha normal add additive multiply screen difference
182
+ ].freeze
183
+
184
+ POST_EFFECTS = %i[
185
+ bloom glitch chromatic feedback motion_blur crt
186
+ ].freeze
187
+
188
+ VJ_EFFECTS = %i[
189
+ mirror color_shift pixelate
190
+ ].freeze
191
+
192
+ module_function
193
+
194
+ def capabilities
195
+ (CAPABILITIES + plugin_capabilities).freeze
196
+ end
197
+
198
+ def capability_for(type)
199
+ capabilities.find { |capability| capability.supports?(type) }
200
+ end
201
+
202
+ def supported_type?(type)
203
+ !!capability_for(type)
204
+ end
205
+
206
+ def supported_types
207
+ capabilities.flat_map(&:types).uniq.freeze
208
+ end
209
+
210
+ def params_for(type)
211
+ capability_for(type)&.params || {}
212
+ end
213
+
214
+ def mappable_params_for(type)
215
+ capability_for(type)&.mappable_params || []
216
+ end
217
+
218
+ def register_layer_capability(type:, aliases: [], params: {}, mappable_params: [], description: nil)
219
+ capability = Capability.new(
220
+ type: normalize_type!(type),
221
+ aliases: normalize_symbols(aliases),
222
+ params: COMMON_PARAMS.merge(normalize_param_types(params)),
223
+ mappable_params: normalize_symbols(mappable_params),
224
+ description: normalize_description(description)
225
+ )
226
+ validate_plugin_capability!(capability)
227
+ plugin_capabilities.reject! { |entry| entry.type == capability.type }
228
+ plugin_capabilities << capability
229
+ capability
230
+ end
231
+
232
+ def reset_plugin_capabilities!
233
+ plugin_capabilities.clear
234
+ end
235
+
236
+ def plugin_capabilities
237
+ @plugin_capabilities ||= []
238
+ end
239
+
240
+ def normalize_type!(type)
241
+ value = type.to_s.strip
242
+ raise ArgumentError, "layer capability type must not be empty" if value.empty?
243
+
244
+ value.to_sym
245
+ end
246
+
247
+ def normalize_symbols(values)
248
+ Array(values).filter_map do |value|
249
+ symbol = value.to_s.strip
250
+ symbol.empty? ? nil : symbol.to_sym
251
+ end.uniq
252
+ end
253
+
254
+ def normalize_param_types(params)
255
+ Hash(params).to_h do |name, type|
256
+ [normalize_type!(name), type.to_s]
257
+ end
258
+ end
259
+
260
+ def normalize_description(description)
261
+ value = description.to_s.strip
262
+ value.empty? ? "Plugin layer capability." : value
263
+ end
264
+
265
+ def validate_plugin_capability!(capability)
266
+ reserved_types = CAPABILITIES.flat_map(&:types)
267
+ conflicts = capability.types & reserved_types
268
+ return if conflicts.empty?
269
+
270
+ raise ArgumentError, "layer capability conflicts with built-in type: #{conflicts.first}"
271
+ end
272
+ end
273
+ end
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "yaml"
5
+
6
+ module Vizcore
7
+ # Reads a project-level manifest such as vizcore.yml.
8
+ class ProjectManifest
9
+ # @param path [String, Pathname]
10
+ # @return [Vizcore::ProjectManifest]
11
+ def self.load(path)
12
+ new(path)
13
+ end
14
+
15
+ attr_reader :path, :root
16
+
17
+ # @param path [String, Pathname]
18
+ def initialize(path)
19
+ @path = Pathname.new(path).expand_path
20
+ raise ArgumentError, "Project manifest not found: #{@path}" unless @path.file?
21
+
22
+ @root = @path.dirname
23
+ @data = normalize_hash(YAML.safe_load_file(@path, aliases: false) || {})
24
+ rescue Psych::Exception => e
25
+ raise ArgumentError, "Invalid project manifest #{@path}: #{e.message}"
26
+ end
27
+
28
+ # @return [Hash] config defaults accepted by Vizcore::Config
29
+ def config_defaults(profile: nil)
30
+ data = data_for(profile)
31
+ {
32
+ scene_file: expand_path(value_at(data, "scene") || value_at(data, "scene_file")),
33
+ audio_source: value_at(data, "audio_source") || value_at(data, "audio", "source"),
34
+ audio_file: expand_path(value_at(data, "audio_file") || value_at(data, "audio", "file")),
35
+ audio_device: value_at(data, "audio_device") || value_at(data, "audio", "device"),
36
+ feature_file: expand_path(value_at(data, "feature_file") || value_at(data, "features")),
37
+ control_preset: expand_path(value_at(data, "control_preset") || value_at(data, "controlPreset")),
38
+ osc_port: value_at(data, "osc_port") || value_at(data, "sync", "osc_port") || value_at(data, "sync", "osc", "port"),
39
+ plugin_assets: plugin_assets(profile: profile)
40
+ }.compact
41
+ end
42
+
43
+ # @return [Array<String>] require paths loaded before scene evaluation
44
+ def plugins(profile: nil)
45
+ plugin_entries(profile: profile).filter_map { |entry| plugin_require_path(entry) }.uniq
46
+ end
47
+
48
+ # @return [Array<Pathname>] frontend plugin assets served and loaded by RackApp
49
+ def plugin_assets(profile: nil)
50
+ entries = plugin_entries(profile: profile)
51
+ assets = base_values("plugin_assets", "frontend_plugins") + profile_values(profile, "plugin_assets", "frontend_plugins")
52
+ (entries.filter_map { |entry| plugin_asset_path(entry) } + assets.filter_map { |entry| expand_path(entry) }).uniq
53
+ end
54
+
55
+ # @return [Array<String>] configured profile names
56
+ def profile_names
57
+ Hash(@data["profiles"] || {}).keys
58
+ end
59
+
60
+ private
61
+
62
+ def value_at(data, *keys)
63
+ current = data
64
+ keys.each do |key|
65
+ return nil unless current.is_a?(Hash)
66
+
67
+ current = current[key.to_s]
68
+ end
69
+ current
70
+ end
71
+
72
+ def data_for(profile)
73
+ profile_name = profile.to_s.strip
74
+ return @data if profile_name.empty?
75
+
76
+ deep_merge(@data.reject { |key, _value| key == "profiles" }, profile_overlay(profile_name))
77
+ end
78
+
79
+ def plugin_entries(profile:)
80
+ base_entries = base_values("plugins", ["package", "plugins"])
81
+ profile_entries = profile_values(profile, "plugins", ["package", "plugins"])
82
+ base_entries + profile_entries
83
+ end
84
+
85
+ def base_values(*paths)
86
+ paths.each do |path|
87
+ value = path.is_a?(Array) ? value_at(@data, *path) : value_at(@data, path)
88
+ return Array(value) if value
89
+ end
90
+ []
91
+ end
92
+
93
+ def profile_values(profile, *paths)
94
+ overlay = profile_overlay(profile)
95
+ return [] if overlay.empty?
96
+
97
+ paths.each do |path|
98
+ value = path.is_a?(Array) ? value_at(overlay, *path) : value_at(overlay, path)
99
+ return Array(value) if value
100
+ end
101
+ []
102
+ end
103
+
104
+ def profile_overlay(profile)
105
+ profile_name = profile.to_s.strip
106
+ return {} if profile_name.empty?
107
+
108
+ profiles = Hash(@data["profiles"] || {})
109
+ normalize_hash(profiles.fetch(profile_name) do
110
+ raise ArgumentError, "Unknown project profile: #{profile_name}. Use one of: #{profile_names.join(', ')}"
111
+ end)
112
+ end
113
+
114
+ def plugin_require_path(entry)
115
+ value = if entry.is_a?(Hash)
116
+ entry["require"] || entry[:require] || entry["name"] || entry[:name]
117
+ else
118
+ entry
119
+ end
120
+ plugin = value.to_s.strip
121
+ plugin unless plugin.empty?
122
+ end
123
+
124
+ def plugin_asset_path(entry)
125
+ return nil unless entry.is_a?(Hash)
126
+
127
+ expand_path(entry["asset"] || entry[:asset] || entry["frontend"] || entry[:frontend])
128
+ end
129
+
130
+ def expand_path(value)
131
+ raw_value = value.to_s.strip
132
+ return nil if raw_value.empty?
133
+
134
+ path_value = Pathname.new(raw_value)
135
+ path_value.absolute? ? path_value : @root.join(path_value).expand_path
136
+ end
137
+
138
+ def deep_merge(base, overlay)
139
+ base.merge(overlay) do |_key, left, right|
140
+ left.is_a?(Hash) && right.is_a?(Hash) ? deep_merge(left, right) : right
141
+ end
142
+ end
143
+
144
+ def normalize_hash(value)
145
+ return {} unless value.is_a?(Hash)
146
+
147
+ value.each_with_object({}) do |(key, entry), output|
148
+ output[key.to_s] = entry.is_a?(Hash) ? normalize_hash(entry) : entry
149
+ end
150
+ end
151
+ end
152
+ end