vizcore 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/frontend/index.html +24 -2
- data/frontend/src/audio-inspector.js +9 -0
- data/frontend/src/live-controls.js +219 -7
- data/frontend/src/main.js +447 -57
- data/frontend/src/midi-learn.js +22 -2
- data/frontend/src/performance-monitor.js +137 -1
- data/frontend/src/renderer/engine.js +391 -10
- data/frontend/src/renderer/layer-manager.js +472 -71
- data/frontend/src/runtime-control-preset.js +44 -0
- data/frontend/src/scene-patches.js +159 -0
- data/frontend/src/shader-error-overlay.js +1 -0
- data/frontend/src/visuals/image-renderer.js +19 -0
- data/frontend/src/visuals/particle-system.js +10 -0
- data/frontend/src/visuals/shape-renderer.js +13 -0
- data/frontend/src/visuals/spectrogram-renderer.js +14 -0
- data/frontend/src/visuals/text-renderer.js +13 -0
- data/frontend/src/websocket-client.js +6 -0
- data/lib/vizcore/analysis/adaptive_normalizer.rb +20 -2
- data/lib/vizcore/analysis/bpm_estimator.rb +18 -8
- data/lib/vizcore/analysis/feature_recorder.rb +117 -7
- data/lib/vizcore/analysis/feature_replay.rb +48 -9
- data/lib/vizcore/analysis/pipeline.rb +258 -9
- data/lib/vizcore/analysis/tap_tempo.rb +17 -2
- data/lib/vizcore/audio/calibration.rb +156 -0
- data/lib/vizcore/audio/file_input.rb +28 -0
- data/lib/vizcore/audio/input_manager.rb +36 -1
- data/lib/vizcore/audio/midi_input.rb +5 -0
- data/lib/vizcore/audio/ring_buffer.rb +22 -0
- data/lib/vizcore/audio.rb +1 -0
- data/lib/vizcore/cli/dsl_reference.rb +64 -8
- data/lib/vizcore/cli/plugin_checker.rb +93 -0
- data/lib/vizcore/cli/scene_diagnostics.rb +2 -2
- data/lib/vizcore/cli/scene_inspector.rb +35 -1
- data/lib/vizcore/cli/scene_validator.rb +487 -39
- data/lib/vizcore/cli/shader_template.rb +7 -2
- data/lib/vizcore/cli/shader_uniform_docs.rb +11 -0
- data/lib/vizcore/cli.rb +268 -15
- data/lib/vizcore/config.rb +40 -3
- data/lib/vizcore/control_preset.rb +29 -0
- data/lib/vizcore/deep_copy.rb +21 -0
- data/lib/vizcore/dsl/color_helpers.rb +155 -0
- data/lib/vizcore/dsl/engine.rb +219 -23
- data/lib/vizcore/dsl/layer_builder.rb +278 -15
- data/lib/vizcore/dsl/layer_group_builder.rb +10 -12
- data/lib/vizcore/dsl/layout_helpers.rb +290 -0
- data/lib/vizcore/dsl/mapping_preset_builder.rb +41 -0
- data/lib/vizcore/dsl/mapping_resolver.rb +404 -22
- data/lib/vizcore/dsl/mapping_transform_builder.rb +50 -0
- data/lib/vizcore/dsl/midi_map_executor.rb +219 -23
- data/lib/vizcore/dsl/reaction_builder.rb +1 -0
- data/lib/vizcore/dsl/scene_builder.rb +83 -13
- data/lib/vizcore/dsl/shader_source_resolver.rb +1 -10
- data/lib/vizcore/dsl/style_builder.rb +3 -0
- data/lib/vizcore/dsl/timeline_builder.rb +91 -8
- data/lib/vizcore/dsl/transition_controller.rb +157 -18
- data/lib/vizcore/dsl.rb +2 -0
- data/lib/vizcore/layer_catalog.rb +1 -0
- data/lib/vizcore/plugin_asset_policy.rb +55 -0
- data/lib/vizcore/project_manifest.rb +12 -2
- data/lib/vizcore/renderer/render_sequence.rb +104 -13
- data/lib/vizcore/renderer/scene_frame_source.rb +179 -14
- data/lib/vizcore/renderer/scene_serializer.rb +38 -0
- data/lib/vizcore/renderer/snapshot.rb +4 -3
- data/lib/vizcore/renderer/snapshot_renderer.rb +134 -8
- data/lib/vizcore/scene_trust.rb +31 -0
- data/lib/vizcore/server/frame_broadcaster.rb +469 -23
- data/lib/vizcore/server/rack_app.rb +151 -4
- data/lib/vizcore/server/runner.rb +676 -82
- data/lib/vizcore/server/websocket_handler.rb +236 -14
- data/lib/vizcore/server.rb +21 -0
- data/lib/vizcore/shape.rb +39 -16
- data/lib/vizcore/sync/osc_message.rb +66 -9
- data/lib/vizcore/version.rb +1 -1
- data/lib/vizcore.rb +33 -0
- data/scripts/browser_capture.mjs +31 -2
- data/sig/vizcore.rbs +55 -4
- metadata +18 -3
|
@@ -8,7 +8,9 @@ module Vizcore
|
|
|
8
8
|
|
|
9
9
|
# @param scenes [Array<Hash>]
|
|
10
10
|
# @param transitions [Array<Hash>]
|
|
11
|
-
|
|
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
|
|
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?(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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"
|
|
@@ -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
|
-
|
|
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
|
|
@@ -20,12 +20,39 @@ module Vizcore
|
|
|
20
20
|
fps: DEFAULT_FRAME_RATE,
|
|
21
21
|
width: SnapshotRenderer::DEFAULT_WIDTH,
|
|
22
22
|
height: SnapshotRenderer::DEFAULT_HEIGHT,
|
|
23
|
+
duration: nil,
|
|
24
|
+
from_frame: 1,
|
|
25
|
+
to_frame: nil,
|
|
26
|
+
resume: false,
|
|
27
|
+
seed: nil,
|
|
28
|
+
transparent: false,
|
|
29
|
+
video_codec: nil,
|
|
30
|
+
video_bitrate: nil,
|
|
31
|
+
video_crf: nil,
|
|
32
|
+
pixel_format: "yuv420p",
|
|
33
|
+
progress_reporter: nil,
|
|
23
34
|
command_runner: Open3,
|
|
24
35
|
ffmpeg_checker: nil
|
|
25
36
|
)
|
|
26
37
|
@config = config
|
|
27
|
-
@frames = normalize_frame_count(frames)
|
|
28
38
|
@fps = normalize_frame_rate(fps)
|
|
39
|
+
@frames = normalize_frame_count(duration ? (Float(duration) * @fps).ceil : frames)
|
|
40
|
+
@from_frame = normalize_frame_index(from_frame, "from-frame")
|
|
41
|
+
@to_frame = normalize_optional_frame_index(to_frame, "to-frame")
|
|
42
|
+
@to_frame = @frames if @to_frame.nil?
|
|
43
|
+
raise ArgumentError, "to-frame must be greater than or equal to from-frame" if @to_frame < @from_frame
|
|
44
|
+
raise ArgumentError, "from-frame must be within rendered frame count" if @from_frame > @frames
|
|
45
|
+
|
|
46
|
+
@to_frame = [@to_frame, @frames].min
|
|
47
|
+
@output_frames = @to_frame - @from_frame + 1
|
|
48
|
+
@resume = !!resume
|
|
49
|
+
@seed = normalize_seed(seed)
|
|
50
|
+
@transparent = !!transparent
|
|
51
|
+
@video_codec = optional_string(video_codec, "video codec")
|
|
52
|
+
@video_bitrate = optional_string(video_bitrate, "video bitrate")
|
|
53
|
+
@video_crf = optional_string(video_crf, "video crf")
|
|
54
|
+
@pixel_format = optional_string(pixel_format, "pixel format") || "yuv420p"
|
|
55
|
+
@progress_reporter = progress_reporter
|
|
29
56
|
@width = width
|
|
30
57
|
@height = height
|
|
31
58
|
@command_runner = command_runner
|
|
@@ -56,32 +83,44 @@ module Vizcore
|
|
|
56
83
|
metadata = nil
|
|
57
84
|
Dir.mktmpdir("vizcore-render-frames") do |dir|
|
|
58
85
|
frame_dir = Pathname.new(dir)
|
|
59
|
-
metadata = render_frames(frame_dir)
|
|
86
|
+
metadata = render_frames(frame_dir, preserve_frame_numbers: false)
|
|
60
87
|
encode_mp4(frame_dir: frame_dir, output_file: output_file)
|
|
61
88
|
end
|
|
62
89
|
metadata.merge(path: output_file, format: :mp4)
|
|
63
90
|
end
|
|
64
91
|
|
|
65
|
-
def render_frames(output_dir)
|
|
66
|
-
source = SceneFrameSource.new(config: @config, frame_rate: @fps)
|
|
92
|
+
def render_frames(output_dir, preserve_frame_numbers: true)
|
|
93
|
+
source = SceneFrameSource.new(config: @config, frame_rate: @fps, seed: @seed)
|
|
67
94
|
source.start
|
|
68
|
-
renderer = SnapshotRenderer.new(width: @width, height: @height)
|
|
95
|
+
renderer = SnapshotRenderer.new(width: @width, height: @height, transparent: @transparent)
|
|
69
96
|
scene_name = nil
|
|
70
97
|
|
|
71
98
|
@frames.times do |index|
|
|
99
|
+
frame_number = index + 1
|
|
100
|
+
break if frame_number > @to_frame
|
|
101
|
+
|
|
72
102
|
frame = source.capture
|
|
73
103
|
scene_name ||= frame.fetch(:scene_name)
|
|
104
|
+
next if frame_number < @from_frame || frame_number > @to_frame
|
|
105
|
+
output_frame_number = preserve_frame_numbers ? frame_number : frame_number - @from_frame + 1
|
|
106
|
+
next if @resume && frame_path(output_dir, output_frame_number).file?
|
|
107
|
+
|
|
74
108
|
File.binwrite(
|
|
75
|
-
frame_path(output_dir,
|
|
109
|
+
frame_path(output_dir, output_frame_number),
|
|
76
110
|
renderer.render(scene: frame.fetch(:scene), audio: frame.fetch(:audio))
|
|
77
111
|
)
|
|
112
|
+
emit_progress(frame_number: frame_number, output_frame_number: output_frame_number)
|
|
78
113
|
end
|
|
79
114
|
|
|
80
115
|
{
|
|
81
|
-
frames: @
|
|
116
|
+
frames: @output_frames,
|
|
117
|
+
total_frames: @frames,
|
|
118
|
+
from_frame: @from_frame,
|
|
119
|
+
to_frame: @to_frame,
|
|
82
120
|
fps: @fps,
|
|
83
121
|
width: renderer.width,
|
|
84
122
|
height: renderer.height,
|
|
123
|
+
transparent: @transparent,
|
|
85
124
|
scene: scene_name
|
|
86
125
|
}
|
|
87
126
|
ensure
|
|
@@ -92,8 +131,8 @@ module Vizcore
|
|
|
92
131
|
%w[.mp4 .mov .webm].include?(path.extname.downcase)
|
|
93
132
|
end
|
|
94
133
|
|
|
95
|
-
def frame_path(output_dir,
|
|
96
|
-
output_dir.join(format("frame_%05d.png",
|
|
134
|
+
def frame_path(output_dir, frame_number)
|
|
135
|
+
output_dir.join(format("frame_%05d.png", frame_number))
|
|
97
136
|
end
|
|
98
137
|
|
|
99
138
|
def encode_mp4(frame_dir:, output_file:)
|
|
@@ -106,7 +145,7 @@ module Vizcore
|
|
|
106
145
|
end
|
|
107
146
|
|
|
108
147
|
def ffmpeg_command(frame_dir:, output_file:)
|
|
109
|
-
[
|
|
148
|
+
command = [
|
|
110
149
|
"ffmpeg",
|
|
111
150
|
"-y",
|
|
112
151
|
"-framerate",
|
|
@@ -114,17 +153,37 @@ module Vizcore
|
|
|
114
153
|
"-i",
|
|
115
154
|
frame_dir.join("frame_%05d.png").to_s,
|
|
116
155
|
"-vf",
|
|
117
|
-
"format
|
|
156
|
+
"format=#{@pixel_format}",
|
|
118
157
|
"-pix_fmt",
|
|
119
|
-
|
|
120
|
-
output_file.to_s
|
|
158
|
+
@pixel_format
|
|
121
159
|
]
|
|
160
|
+
command.concat(["-c:v", @video_codec]) if @video_codec
|
|
161
|
+
command.concat(["-b:v", @video_bitrate]) if @video_bitrate
|
|
162
|
+
command.concat(["-crf", @video_crf]) if @video_crf
|
|
163
|
+
command << output_file.to_s
|
|
164
|
+
command
|
|
122
165
|
end
|
|
123
166
|
|
|
124
167
|
def ffmpeg_available?
|
|
125
168
|
system("ffmpeg", "-version", out: File::NULL, err: File::NULL)
|
|
126
169
|
end
|
|
127
170
|
|
|
171
|
+
def emit_progress(frame_number:, output_frame_number:)
|
|
172
|
+
return unless @progress_reporter.respond_to?(:call)
|
|
173
|
+
|
|
174
|
+
@progress_reporter.call(
|
|
175
|
+
frame: frame_number,
|
|
176
|
+
output_frame: output_frame_number,
|
|
177
|
+
from_frame: @from_frame,
|
|
178
|
+
to_frame: @to_frame,
|
|
179
|
+
total_frames: @frames,
|
|
180
|
+
output_frames: @output_frames,
|
|
181
|
+
percent: ((frame_number - @from_frame + 1).to_f / @output_frames * 100).clamp(0.0, 100.0)
|
|
182
|
+
)
|
|
183
|
+
rescue StandardError
|
|
184
|
+
nil
|
|
185
|
+
end
|
|
186
|
+
|
|
128
187
|
def format_frame_rate
|
|
129
188
|
return @fps.to_i.to_s if @fps == @fps.to_i
|
|
130
189
|
|
|
@@ -140,6 +199,21 @@ module Vizcore
|
|
|
140
199
|
raise ArgumentError, "frames must be a positive integer"
|
|
141
200
|
end
|
|
142
201
|
|
|
202
|
+
def normalize_frame_index(value, name)
|
|
203
|
+
index = Integer(value)
|
|
204
|
+
raise ArgumentError, "#{name} must be positive" unless index.positive?
|
|
205
|
+
|
|
206
|
+
index
|
|
207
|
+
rescue ArgumentError, TypeError
|
|
208
|
+
raise ArgumentError, "#{name} must be a positive integer"
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def normalize_optional_frame_index(value, name)
|
|
212
|
+
return nil if value.nil?
|
|
213
|
+
|
|
214
|
+
normalize_frame_index(value, name)
|
|
215
|
+
end
|
|
216
|
+
|
|
143
217
|
def normalize_frame_rate(value)
|
|
144
218
|
rate = Float(value)
|
|
145
219
|
raise ArgumentError, "fps must be positive" unless rate.positive?
|
|
@@ -148,6 +222,23 @@ module Vizcore
|
|
|
148
222
|
rescue ArgumentError, TypeError
|
|
149
223
|
raise ArgumentError, "fps must be a positive number"
|
|
150
224
|
end
|
|
225
|
+
|
|
226
|
+
def normalize_seed(value)
|
|
227
|
+
return nil if value.nil?
|
|
228
|
+
|
|
229
|
+
Integer(value)
|
|
230
|
+
rescue ArgumentError, TypeError
|
|
231
|
+
raise ArgumentError, "seed must be an integer"
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def optional_string(value, name)
|
|
235
|
+
return nil if value.nil?
|
|
236
|
+
|
|
237
|
+
normalized = value.to_s.strip
|
|
238
|
+
raise ArgumentError, "#{name} must not be empty" if normalized.empty?
|
|
239
|
+
|
|
240
|
+
normalized
|
|
241
|
+
end
|
|
151
242
|
end
|
|
152
243
|
end
|
|
153
244
|
end
|