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.
Files changed (78) hide show
  1. checksums.yaml +4 -4
  2. data/frontend/index.html +24 -2
  3. data/frontend/src/audio-inspector.js +9 -0
  4. data/frontend/src/live-controls.js +219 -7
  5. data/frontend/src/main.js +447 -57
  6. data/frontend/src/midi-learn.js +22 -2
  7. data/frontend/src/performance-monitor.js +137 -1
  8. data/frontend/src/renderer/engine.js +391 -10
  9. data/frontend/src/renderer/layer-manager.js +472 -71
  10. data/frontend/src/runtime-control-preset.js +44 -0
  11. data/frontend/src/scene-patches.js +159 -0
  12. data/frontend/src/shader-error-overlay.js +1 -0
  13. data/frontend/src/visuals/image-renderer.js +19 -0
  14. data/frontend/src/visuals/particle-system.js +10 -0
  15. data/frontend/src/visuals/shape-renderer.js +13 -0
  16. data/frontend/src/visuals/spectrogram-renderer.js +14 -0
  17. data/frontend/src/visuals/text-renderer.js +13 -0
  18. data/frontend/src/websocket-client.js +6 -0
  19. data/lib/vizcore/analysis/adaptive_normalizer.rb +20 -2
  20. data/lib/vizcore/analysis/bpm_estimator.rb +18 -8
  21. data/lib/vizcore/analysis/feature_recorder.rb +117 -7
  22. data/lib/vizcore/analysis/feature_replay.rb +48 -9
  23. data/lib/vizcore/analysis/pipeline.rb +258 -9
  24. data/lib/vizcore/analysis/tap_tempo.rb +17 -2
  25. data/lib/vizcore/audio/calibration.rb +156 -0
  26. data/lib/vizcore/audio/file_input.rb +28 -0
  27. data/lib/vizcore/audio/input_manager.rb +36 -1
  28. data/lib/vizcore/audio/midi_input.rb +5 -0
  29. data/lib/vizcore/audio/ring_buffer.rb +22 -0
  30. data/lib/vizcore/audio.rb +1 -0
  31. data/lib/vizcore/cli/dsl_reference.rb +64 -8
  32. data/lib/vizcore/cli/plugin_checker.rb +93 -0
  33. data/lib/vizcore/cli/scene_diagnostics.rb +2 -2
  34. data/lib/vizcore/cli/scene_inspector.rb +35 -1
  35. data/lib/vizcore/cli/scene_validator.rb +487 -39
  36. data/lib/vizcore/cli/shader_template.rb +7 -2
  37. data/lib/vizcore/cli/shader_uniform_docs.rb +11 -0
  38. data/lib/vizcore/cli.rb +268 -15
  39. data/lib/vizcore/config.rb +40 -3
  40. data/lib/vizcore/control_preset.rb +29 -0
  41. data/lib/vizcore/deep_copy.rb +21 -0
  42. data/lib/vizcore/dsl/color_helpers.rb +155 -0
  43. data/lib/vizcore/dsl/engine.rb +219 -23
  44. data/lib/vizcore/dsl/layer_builder.rb +278 -15
  45. data/lib/vizcore/dsl/layer_group_builder.rb +10 -12
  46. data/lib/vizcore/dsl/layout_helpers.rb +290 -0
  47. data/lib/vizcore/dsl/mapping_preset_builder.rb +41 -0
  48. data/lib/vizcore/dsl/mapping_resolver.rb +404 -22
  49. data/lib/vizcore/dsl/mapping_transform_builder.rb +50 -0
  50. data/lib/vizcore/dsl/midi_map_executor.rb +219 -23
  51. data/lib/vizcore/dsl/reaction_builder.rb +1 -0
  52. data/lib/vizcore/dsl/scene_builder.rb +83 -13
  53. data/lib/vizcore/dsl/shader_source_resolver.rb +1 -10
  54. data/lib/vizcore/dsl/style_builder.rb +3 -0
  55. data/lib/vizcore/dsl/timeline_builder.rb +91 -8
  56. data/lib/vizcore/dsl/transition_controller.rb +157 -18
  57. data/lib/vizcore/dsl.rb +2 -0
  58. data/lib/vizcore/layer_catalog.rb +1 -0
  59. data/lib/vizcore/plugin_asset_policy.rb +55 -0
  60. data/lib/vizcore/project_manifest.rb +12 -2
  61. data/lib/vizcore/renderer/render_sequence.rb +104 -13
  62. data/lib/vizcore/renderer/scene_frame_source.rb +179 -14
  63. data/lib/vizcore/renderer/scene_serializer.rb +38 -0
  64. data/lib/vizcore/renderer/snapshot.rb +4 -3
  65. data/lib/vizcore/renderer/snapshot_renderer.rb +134 -8
  66. data/lib/vizcore/scene_trust.rb +31 -0
  67. data/lib/vizcore/server/frame_broadcaster.rb +469 -23
  68. data/lib/vizcore/server/rack_app.rb +151 -4
  69. data/lib/vizcore/server/runner.rb +676 -82
  70. data/lib/vizcore/server/websocket_handler.rb +236 -14
  71. data/lib/vizcore/server.rb +21 -0
  72. data/lib/vizcore/shape.rb +39 -16
  73. data/lib/vizcore/sync/osc_message.rb +66 -9
  74. data/lib/vizcore/version.rb +1 -1
  75. data/lib/vizcore.rb +33 -0
  76. data/scripts/browser_capture.mjs +31 -2
  77. data/sig/vizcore.rbs +55 -4
  78. metadata +18 -3
@@ -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>",
@@ -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
@@ -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, index),
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: @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, index)
96
- output_dir.join(format("frame_%05d.png", index + 1))
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=yuv420p",
156
+ "format=#{@pixel_format}",
118
157
  "-pix_fmt",
119
- "yuv420p",
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