vizcore 1.0.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 (90) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +66 -648
  3. data/docs/assets/playground-worker.js +373 -0
  4. data/docs/assets/playground.css +440 -0
  5. data/docs/assets/playground.js +652 -0
  6. data/docs/index.html +2 -1
  7. data/docs/playground.html +81 -0
  8. data/docs/shape_dsl.md +269 -0
  9. data/frontend/index.html +50 -2
  10. data/frontend/src/audio-inspector.js +9 -0
  11. data/frontend/src/custom-shape-param-controls.js +106 -0
  12. data/frontend/src/live-controls.js +219 -7
  13. data/frontend/src/main.js +703 -45
  14. data/frontend/src/mapping-target-selector.js +109 -0
  15. data/frontend/src/midi-learn.js +22 -2
  16. data/frontend/src/performance-monitor.js +137 -1
  17. data/frontend/src/renderer/engine.js +401 -11
  18. data/frontend/src/renderer/layer-manager.js +490 -75
  19. data/frontend/src/runtime-control-preset.js +44 -0
  20. data/frontend/src/scene-patches.js +159 -0
  21. data/frontend/src/shader-error-overlay.js +1 -0
  22. data/frontend/src/shape-editor-controls.js +157 -0
  23. data/frontend/src/visuals/geometry.js +425 -27
  24. data/frontend/src/visuals/image-renderer.js +19 -0
  25. data/frontend/src/visuals/particle-system.js +10 -0
  26. data/frontend/src/visuals/shape-renderer.js +488 -0
  27. data/frontend/src/visuals/spectrogram-renderer.js +14 -0
  28. data/frontend/src/visuals/svg-arc.js +104 -0
  29. data/frontend/src/visuals/text-renderer.js +13 -0
  30. data/frontend/src/websocket-client.js +6 -0
  31. data/lib/vizcore/analysis/adaptive_normalizer.rb +20 -2
  32. data/lib/vizcore/analysis/bpm_estimator.rb +18 -8
  33. data/lib/vizcore/analysis/feature_recorder.rb +117 -7
  34. data/lib/vizcore/analysis/feature_replay.rb +48 -9
  35. data/lib/vizcore/analysis/pipeline.rb +258 -9
  36. data/lib/vizcore/analysis/tap_tempo.rb +17 -2
  37. data/lib/vizcore/audio/calibration.rb +156 -0
  38. data/lib/vizcore/audio/file_input.rb +28 -0
  39. data/lib/vizcore/audio/input_manager.rb +36 -1
  40. data/lib/vizcore/audio/midi_input.rb +5 -0
  41. data/lib/vizcore/audio/ring_buffer.rb +22 -0
  42. data/lib/vizcore/audio.rb +1 -0
  43. data/lib/vizcore/cli/dsl_reference.rb +65 -9
  44. data/lib/vizcore/cli/plugin_checker.rb +93 -0
  45. data/lib/vizcore/cli/scene_diagnostics.rb +2 -2
  46. data/lib/vizcore/cli/scene_inspector.rb +35 -1
  47. data/lib/vizcore/cli/scene_validator.rb +573 -33
  48. data/lib/vizcore/cli/shader_template.rb +7 -2
  49. data/lib/vizcore/cli/shader_uniform_docs.rb +11 -0
  50. data/lib/vizcore/cli.rb +268 -15
  51. data/lib/vizcore/config.rb +40 -3
  52. data/lib/vizcore/control_preset.rb +29 -0
  53. data/lib/vizcore/deep_copy.rb +21 -0
  54. data/lib/vizcore/dsl/color_helpers.rb +155 -0
  55. data/lib/vizcore/dsl/engine.rb +219 -23
  56. data/lib/vizcore/dsl/layer_builder.rb +1072 -21
  57. data/lib/vizcore/dsl/layer_group_builder.rb +10 -12
  58. data/lib/vizcore/dsl/layout_helpers.rb +290 -0
  59. data/lib/vizcore/dsl/mapping_preset_builder.rb +41 -0
  60. data/lib/vizcore/dsl/mapping_resolver.rb +549 -13
  61. data/lib/vizcore/dsl/mapping_transform_builder.rb +50 -0
  62. data/lib/vizcore/dsl/midi_map_executor.rb +219 -23
  63. data/lib/vizcore/dsl/reaction_builder.rb +1 -0
  64. data/lib/vizcore/dsl/scene_builder.rb +83 -13
  65. data/lib/vizcore/dsl/shader_source_resolver.rb +1 -10
  66. data/lib/vizcore/dsl/style_builder.rb +3 -0
  67. data/lib/vizcore/dsl/timeline_builder.rb +91 -8
  68. data/lib/vizcore/dsl/transition_controller.rb +157 -18
  69. data/lib/vizcore/dsl.rb +2 -0
  70. data/lib/vizcore/layer_catalog.rb +5 -2
  71. data/lib/vizcore/plugin_asset_policy.rb +55 -0
  72. data/lib/vizcore/project_manifest.rb +12 -2
  73. data/lib/vizcore/renderer/render_sequence.rb +104 -13
  74. data/lib/vizcore/renderer/scene_frame_source.rb +190 -12
  75. data/lib/vizcore/renderer/scene_serializer.rb +38 -0
  76. data/lib/vizcore/renderer/snapshot.rb +4 -3
  77. data/lib/vizcore/renderer/snapshot_renderer.rb +641 -23
  78. data/lib/vizcore/scene_trust.rb +31 -0
  79. data/lib/vizcore/server/frame_broadcaster.rb +513 -18
  80. data/lib/vizcore/server/rack_app.rb +151 -4
  81. data/lib/vizcore/server/runner.rb +697 -82
  82. data/lib/vizcore/server/websocket_handler.rb +236 -14
  83. data/lib/vizcore/server.rb +21 -0
  84. data/lib/vizcore/shape.rb +742 -0
  85. data/lib/vizcore/sync/osc_message.rb +66 -9
  86. data/lib/vizcore/version.rb +1 -1
  87. data/lib/vizcore.rb +34 -0
  88. data/scripts/browser_capture.mjs +31 -2
  89. data/sig/vizcore.rbs +154 -4
  90. metadata +29 -3
@@ -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
@@ -8,7 +8,9 @@ module Vizcore
8
8
 
9
9
  # @param scenes [Array<Hash>]
10
10
  # @param transitions [Array<Hash>]
11
- def initialize(scenes:, transitions:)
11
+ # @param error_reporter [#call, nil]
12
+ def initialize(scenes:, transitions:, error_reporter: nil)
13
+ @error_reporter = error_reporter || ->(_message) {}
12
14
  update(scenes: scenes, transitions: transitions)
13
15
  end
14
16
 
@@ -23,11 +25,12 @@ module Vizcore
23
25
  # @param scene_name [String, Symbol]
24
26
  # @param audio [Hash]
25
27
  # @param frame_count [Integer]
28
+ # @param elapsed_seconds [Numeric, nil]
26
29
  # @return [Hash, nil] transition payload when condition matches
27
- def next_transition(scene_name:, audio:, frame_count: 0)
30
+ def next_transition(scene_name:, audio:, frame_count: 0, elapsed_seconds: nil)
28
31
  current = scene_name.to_sym
29
32
  transition = @transitions.find do |entry|
30
- entry[:from] == current && trigger_match?(entry[:trigger], audio, frame_count)
33
+ entry[:from] == current && trigger_match?(entry, audio, frame_count, elapsed_seconds)
31
34
  end
32
35
  return nil unless transition
33
36
 
@@ -73,14 +76,24 @@ module Vizcore
73
76
  end
74
77
  end
75
78
 
76
- def trigger_match?(trigger, audio, frame_count)
79
+ def trigger_match?(transition, audio, frame_count, elapsed_seconds)
80
+ trigger = transition[:trigger]
77
81
  return false unless trigger.respond_to?(:call)
78
82
 
79
- TriggerContext.new(audio, frame_count: frame_count).instance_exec(&trigger)
80
- rescue StandardError
83
+ TriggerContext.new(audio, frame_count: frame_count, elapsed_seconds: elapsed_seconds).instance_exec(&trigger)
84
+ rescue StandardError => e
85
+ report_trigger_error(transition, e)
81
86
  false
82
87
  end
83
88
 
89
+ def report_trigger_error(transition, error)
90
+ @error_reporter.call(
91
+ "transition trigger failed: #{transition[:from]} -> #{transition[:to]} (#{error.class}: #{error.message})"
92
+ )
93
+ rescue StandardError
94
+ nil
95
+ end
96
+
84
97
  def symbolize_hash(value)
85
98
  Hash(value).each_with_object({}) do |(key, entry), output|
86
99
  output[key.to_sym] = entry
@@ -90,16 +103,7 @@ module Vizcore
90
103
  end
91
104
 
92
105
  def deep_dup(value)
93
- case value
94
- when Hash
95
- value.each_with_object({}) do |(key, entry), output|
96
- output[key] = deep_dup(entry)
97
- end
98
- when Array
99
- value.map { |entry| deep_dup(entry) }
100
- else
101
- value
102
- end
106
+ Vizcore::DeepCopy.copy(value)
103
107
  end
104
108
 
105
109
  # Runtime DSL context exposed to transition trigger blocks.
@@ -107,14 +111,18 @@ module Vizcore
107
111
  class TriggerContext
108
112
  # @param audio [Hash]
109
113
  # @param frame_count [Integer]
110
- def initialize(audio, frame_count:)
114
+ # @param elapsed_seconds [Numeric, nil]
115
+ def initialize(audio, frame_count:, elapsed_seconds: nil)
111
116
  @audio = symbolize_hash(audio)
112
117
  @bands = symbolize_hash(@audio[:bands])
118
+ @band_peaks = symbolize_hash(@audio[:band_peaks])
113
119
  @onsets = symbolize_hash(@audio[:onsets])
114
120
  @drums = symbolize_hash(@audio[:drums])
115
121
  @frame_count = Integer(frame_count)
122
+ @elapsed_seconds = normalize_elapsed_seconds(elapsed_seconds)
116
123
  rescue StandardError
117
124
  @frame_count = 0
125
+ @elapsed_seconds = nil
118
126
  end
119
127
 
120
128
  # @return [Float]
@@ -122,42 +130,83 @@ module Vizcore
122
130
  @audio[:amplitude].to_f
123
131
  end
124
132
 
133
+ # @return [Float]
134
+ def peak
135
+ @audio[:peak].to_f
136
+ end
137
+
125
138
  # @param name [Symbol, String]
126
139
  # @return [Float]
127
140
  def frequency_band(name)
128
141
  @bands[name.to_sym].to_f
129
142
  end
130
143
 
144
+ # @param name [Symbol, String]
145
+ # @return [Float]
146
+ def frequency_band_peak(name)
147
+ @band_peaks[name.to_sym].to_f
148
+ end
149
+
131
150
  # @return [Float]
132
151
  def sub
133
152
  frequency_band(:sub)
134
153
  end
135
154
 
155
+ # @return [Float]
156
+ def sub_peak
157
+ frequency_band_peak(:sub)
158
+ end
159
+
136
160
  # @return [Float]
137
161
  def low
138
162
  frequency_band(:low)
139
163
  end
140
164
 
165
+ # @return [Float]
166
+ def low_peak
167
+ frequency_band_peak(:low)
168
+ end
169
+
141
170
  # @return [Float]
142
171
  def bass
143
172
  frequency_band(:low)
144
173
  end
145
174
 
175
+ # @return [Float]
176
+ def bass_peak
177
+ frequency_band_peak(:low)
178
+ end
179
+
146
180
  # @return [Float]
147
181
  def mid
148
182
  frequency_band(:mid)
149
183
  end
150
184
 
185
+ # @return [Float]
186
+ def mid_peak
187
+ frequency_band_peak(:mid)
188
+ end
189
+
151
190
  # @return [Float]
152
191
  def high
153
192
  frequency_band(:high)
154
193
  end
155
194
 
195
+ # @return [Float]
196
+ def high_peak
197
+ frequency_band_peak(:high)
198
+ end
199
+
156
200
  # @return [Float]
157
201
  def treble
158
202
  frequency_band(:high)
159
203
  end
160
204
 
205
+ # @return [Float]
206
+ def treble_peak
207
+ frequency_band_peak(:high)
208
+ end
209
+
161
210
  # @return [Array<Float>]
162
211
  def fft_spectrum
163
212
  Array(@audio[:fft])
@@ -213,23 +262,113 @@ module Vizcore
213
262
  0
214
263
  end
215
264
 
265
+ # @return [Float]
266
+ def beat_phase
267
+ @audio[:beat_phase].to_f
268
+ end
269
+
270
+ # @return [Boolean]
271
+ def beat_2
272
+ !!@audio[:beat_2]
273
+ end
274
+
275
+ # @return [Boolean]
276
+ def beat_4
277
+ !!@audio[:beat_4]
278
+ end
279
+
280
+ # @return [Boolean]
281
+ def beat_8
282
+ !!@audio[:beat_8]
283
+ end
284
+
285
+ # @return [Boolean]
286
+ def beat_triplet
287
+ !!@audio[:beat_triplet]
288
+ end
289
+
290
+ # @return [Boolean]
291
+ def triplet
292
+ beat_triplet
293
+ end
294
+
295
+ # @return [Float]
296
+ def bar_phase
297
+ @audio[:bar_phase].to_f
298
+ end
299
+
300
+ # @return [Integer]
301
+ def bar_count
302
+ Integer(@audio[:bar_count] || 0)
303
+ rescue StandardError
304
+ 0
305
+ end
306
+
307
+ # @return [Integer]
308
+ def phrase_count
309
+ Integer(@audio[:phrase_count] || 0)
310
+ rescue StandardError
311
+ 0
312
+ end
313
+
216
314
  # @return [Float]
217
315
  def bpm
218
316
  @audio[:bpm].to_f
219
317
  end
220
318
 
319
+ # @return [Float]
320
+ def bpm_confidence
321
+ @audio[:bpm_confidence].to_f
322
+ end
323
+
324
+ # @return [Float]
325
+ def spectral_centroid
326
+ @audio[:spectral_centroid].to_f
327
+ end
328
+
329
+ # @return [Float]
330
+ def spectral_rolloff
331
+ @audio[:spectral_rolloff].to_f
332
+ end
333
+
334
+ # @return [Float]
335
+ def spectral_flatness
336
+ @audio[:spectral_flatness].to_f
337
+ end
338
+
339
+ # @return [Float]
340
+ def spectral_flux
341
+ @audio[:spectral_flux].to_f
342
+ end
343
+
344
+ # @return [Float]
345
+ def zero_crossing_rate
346
+ @audio[:zero_crossing_rate].to_f
347
+ end
348
+
221
349
  # @return [Integer]
222
350
  def frame_count
223
351
  @frame_count
224
352
  end
225
353
 
226
- # @return [Float] scene-local elapsed seconds at the default runtime frame rate
354
+ # @return [Float] scene-local elapsed seconds
227
355
  def seconds
356
+ return @elapsed_seconds if @elapsed_seconds
357
+
228
358
  @frame_count / DEFAULT_FRAME_RATE
229
359
  end
230
360
 
231
361
  private
232
362
 
363
+ def normalize_elapsed_seconds(value)
364
+ return nil if value.nil?
365
+
366
+ numeric = Float(value)
367
+ numeric.finite? ? numeric : nil
368
+ rescue StandardError
369
+ nil
370
+ end
371
+
233
372
  def symbolize_hash(value)
234
373
  Hash(value).each_with_object({}) do |(key, entry), output|
235
374
  output[key.to_sym] = entry
data/lib/vizcore/dsl.rb CHANGED
@@ -6,11 +6,13 @@ module Vizcore
6
6
  end
7
7
  end
8
8
 
9
+ require_relative "deep_copy"
9
10
  require_relative "dsl/file_watcher"
10
11
  require_relative "dsl/mapping_resolver"
11
12
  require_relative "dsl/mapping_transform_builder"
12
13
  require_relative "dsl/midi_map_executor"
13
14
  require_relative "dsl/reaction_builder"
15
+ require_relative "dsl/mapping_preset_builder"
14
16
  require_relative "dsl/style_builder"
15
17
  require_relative "dsl/layer_builder"
16
18
  require_relative "dsl/scene_builder"
@@ -19,6 +19,7 @@ module Vizcore
19
19
  opacity: "Float",
20
20
  blend: "Symbol",
21
21
  effect: "Symbol",
22
+ post_effects: "Array<Symbol>",
22
23
  effect_intensity: "Float",
23
24
  vj_effect: "Symbol",
24
25
  palette: "Array<String>",
@@ -151,10 +152,12 @@ module Vizcore
151
152
  aliases: %i[shapes shape_layer],
152
153
  params: COMMON_PARAMS.merge(
153
154
  shapes: "Array<Hash>",
155
+ shape_schema_version: "Integer",
156
+ units: "Symbol",
154
157
  color_shift: "Float"
155
158
  ),
156
- mappable_params: %i[color_shift opacity],
157
- description: "Declarative 2D circle and line primitives rendered by the browser."
159
+ mappable_params: %i[color_shift opacity shapes],
160
+ description: "Declarative and Ruby-generated 2D circle, line, rect, polygon, polyline, path, and star primitives rendered by the browser."
158
161
  ),
159
162
  Capability.new(
160
163
  type: :mesh,
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "rack"
5
+
6
+ module Vizcore
7
+ # Validates browser-side plugin assets before they are served by the runtime.
8
+ class PluginAssetPolicy
9
+ ALLOWED_EXTENSIONS = %w[.js .mjs].freeze
10
+ ALLOWED_MIME_TYPES = %w[text/javascript application/javascript].freeze
11
+
12
+ # @param path [String, Pathname]
13
+ # @param root [String, Pathname, nil] optional sandbox root
14
+ # @return [Pathname]
15
+ def self.validate!(path, root: nil)
16
+ asset_path = Pathname.new(path).expand_path
17
+ root_path = root ? Pathname.new(root).expand_path : nil
18
+
19
+ if root_path && !inside_root?(asset_path, root_path)
20
+ raise ArgumentError, "Plugin asset must stay inside #{root_path}: #{asset_path}"
21
+ end
22
+
23
+ unless ALLOWED_EXTENSIONS.include?(asset_path.extname.downcase)
24
+ raise ArgumentError, "Unsupported plugin asset extension: #{asset_path.extname}. Use one of: #{ALLOWED_EXTENSIONS.join(', ')}"
25
+ end
26
+
27
+ extname = asset_path.extname.downcase
28
+ mime_type = Rack::Mime.mime_type(extname, "application/octet-stream")
29
+ mime_type = case extname
30
+ when ".mjs"
31
+ mime_type == "application/octet-stream" ? "application/javascript" : mime_type
32
+ when ".js"
33
+ mime_type == "application/octet-stream" ? "text/javascript" : mime_type
34
+ else
35
+ mime_type
36
+ end
37
+ unless ALLOWED_MIME_TYPES.include?(mime_type)
38
+ raise ArgumentError, "Unsupported plugin asset MIME type: #{mime_type}. Use one of: #{ALLOWED_MIME_TYPES.join(", ")}"
39
+ end
40
+
41
+ asset_path
42
+ end
43
+
44
+ # @param path [Pathname]
45
+ # @param root [Pathname]
46
+ # @return [Boolean]
47
+ def self.inside_root?(path, root)
48
+ relative = path.relative_path_from(root)
49
+ !relative.each_filename.first&.start_with?("..")
50
+ rescue ArgumentError
51
+ false
52
+ end
53
+ private_class_method :inside_root?
54
+ end
55
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "pathname"
4
4
  require "yaml"
5
+ require_relative "plugin_asset_policy"
5
6
 
6
7
  module Vizcore
7
8
  # Reads a project-level manifest such as vizcore.yml.
@@ -36,6 +37,8 @@ module Vizcore
36
37
  feature_file: expand_path(value_at(data, "feature_file") || value_at(data, "features")),
37
38
  control_preset: expand_path(value_at(data, "control_preset") || value_at(data, "controlPreset")),
38
39
  osc_port: value_at(data, "osc_port") || value_at(data, "sync", "osc_port") || value_at(data, "sync", "osc", "port"),
40
+ scene_switch_effect: value_at(data, "scene_switch_effect"),
41
+ scene_switch_effect_duration: value_at(data, "scene_switch_effect_duration"),
39
42
  plugin_assets: plugin_assets(profile: profile)
40
43
  }.compact
41
44
  end
@@ -49,7 +52,8 @@ module Vizcore
49
52
  def plugin_assets(profile: nil)
50
53
  entries = plugin_entries(profile: profile)
51
54
  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
55
+ manifest_assets = assets.filter_map { |entry| validate_plugin_asset_path(expand_path(entry)) }
56
+ (entries.filter_map { |entry| plugin_asset_path(entry) } + manifest_assets).uniq
53
57
  end
54
58
 
55
59
  # @return [Array<String>] configured profile names
@@ -124,7 +128,7 @@ module Vizcore
124
128
  def plugin_asset_path(entry)
125
129
  return nil unless entry.is_a?(Hash)
126
130
 
127
- expand_path(entry["asset"] || entry[:asset] || entry["frontend"] || entry[:frontend])
131
+ validate_plugin_asset_path(expand_path(entry["asset"] || entry[:asset] || entry["frontend"] || entry[:frontend]))
128
132
  end
129
133
 
130
134
  def expand_path(value)
@@ -135,6 +139,12 @@ module Vizcore
135
139
  path_value.absolute? ? path_value : @root.join(path_value).expand_path
136
140
  end
137
141
 
142
+ def validate_plugin_asset_path(path)
143
+ return nil unless path
144
+
145
+ Vizcore::PluginAssetPolicy.validate!(path, root: @root)
146
+ end
147
+
138
148
  def deep_merge(base, overlay)
139
149
  base.merge(overlay) do |_key, left, right|
140
150
  left.is_a?(Hash) && right.is_a?(Hash) ? deep_merge(left, right) : right