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
@@ -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
@@ -8,20 +8,40 @@ module Vizcore
8
8
  module Renderer
9
9
  # Produces analyzed scene frames for offline renderers.
10
10
  class SceneFrameSource
11
- def initialize(config:, frame_rate: nil)
11
+ def initialize(config:, frame_rate: nil, seed: nil)
12
12
  @config = config
13
13
  @frame_rate = frame_rate
14
+ @seed = seed
14
15
  @shader_source_resolver = Vizcore::DSL::ShaderSourceResolver.new
15
16
  end
16
17
 
17
18
  # @return [Vizcore::Renderer::SceneFrameSource]
18
19
  def start
19
20
  @definition = resolve_shader_sources(Vizcore::DSL::Engine.load_file(@config.scene_file.to_s))
20
- @scene = first_scene(@definition)
21
- @input_manager = build_input_manager
22
- @input_manager.start
23
- @capture_size = capture_size
24
- @pipeline = build_pipeline
21
+ apply_seed!
22
+ @source_started_at = monotonic_seconds
23
+ scenes = Array(@definition[:scenes])
24
+ initial_timeline_entry = initial_timeline_entry(@definition)
25
+ @scene = resolve_initial_scene(scenes, initial_timeline_entry)
26
+ @transition_controller = Vizcore::DSL::TransitionController.new(
27
+ scenes: scenes,
28
+ transitions: Array(@definition[:transitions]),
29
+ error_reporter: lambda do |message|
30
+ report_transition_error(message)
31
+ end
32
+ )
33
+ @mapping_resolver = Vizcore::DSL::MappingResolver.new
34
+ unless feature_replay?
35
+ @input_manager = build_input_manager
36
+ @input_manager.start
37
+ @capture_size = capture_size
38
+ end
39
+ @pipeline = replay_pipeline || build_pipeline
40
+ @frame_count = 0
41
+ @scene_frame_base = 0
42
+ @scene_elapsed_base = 0.0
43
+ @scene_beat_base = 0
44
+ align_timeline_start(entry: initial_timeline_entry)
25
45
  self
26
46
  end
27
47
 
@@ -29,13 +49,29 @@ module Vizcore
29
49
  def capture
30
50
  ensure_started!
31
51
 
32
- audio = @pipeline.call(@input_manager.capture_frame(@capture_size))
33
- layers = Vizcore::DSL::MappingResolver.new.resolve_layers(scene_layers: @scene[:layers], audio: audio)
52
+ audio = if feature_replay?
53
+ @pipeline.call
54
+ else
55
+ @pipeline.call(@input_manager.capture_frame(@capture_size))
56
+ end
57
+ @frame_count += 1
58
+ scene = @scene
59
+ layers = @mapping_resolver.resolve_layers(
60
+ scene_layers: scene[:layers],
61
+ audio: audio,
62
+ time: frame_time,
63
+ frame: @frame_count
64
+ )
65
+ evaluate_transition(audio)
34
66
 
35
67
  {
36
- scene: { name: @scene[:name], layers: layers },
68
+ scene: {
69
+ schema_version: Vizcore::Renderer::SceneSerializer::SCENE_SCHEMA_VERSION,
70
+ name: scene[:name],
71
+ layers: layers
72
+ },
37
73
  audio: audio,
38
- scene_name: @scene[:name].to_s
74
+ scene_name: scene[:name].to_s
39
75
  }
40
76
  end
41
77
 
@@ -50,6 +86,15 @@ module Vizcore
50
86
  @shader_source_resolver.resolve(definition: definition, scene_file: @config.scene_file.to_s)
51
87
  end
52
88
 
89
+ def apply_seed!
90
+ seed = @seed || @definition[:seed]
91
+ return if seed.nil?
92
+
93
+ Kernel.srand(Integer(seed))
94
+ rescue ArgumentError, TypeError
95
+ raise ArgumentError, "render seed must be an integer"
96
+ end
97
+
53
98
  def first_scene(definition)
54
99
  scene = Array(definition[:scenes]).first
55
100
  return scene if scene
@@ -57,6 +102,95 @@ module Vizcore
57
102
  { name: @config.scene_file.basename(".rb").to_sym, layers: [] }
58
103
  end
59
104
 
105
+ def initial_timeline_entry(definition)
106
+ timelines = Array(definition[:timelines])
107
+ timelines.each do |timeline|
108
+ first_entry = Array(timeline).first
109
+ return first_entry if first_entry
110
+ end
111
+
112
+ nil
113
+ end
114
+
115
+ def resolve_initial_scene(scenes, initial_timeline_entry)
116
+ scene_name = initial_timeline_entry&.dig(:scene)
117
+ scene = scenes.find { |entry| entry[:name].to_s == scene_name.to_s } if scene_name
118
+ scene || first_scene({ scenes: scenes })
119
+ end
120
+
121
+ def align_timeline_start(entry:)
122
+ return unless entry
123
+
124
+ unit = entry[:unit].to_s
125
+ start_position = Float(entry[:at] || 0.0)
126
+ return unless start_position.positive?
127
+
128
+ @scene_elapsed_base = start_position if unit == "seconds"
129
+ @scene_beat_base = Integer(start_position) if unit == "beats"
130
+ rescue StandardError
131
+ nil
132
+ end
133
+
134
+ def evaluate_transition(audio)
135
+ transition = @transition_controller.next_transition(
136
+ scene_name: @scene[:name],
137
+ audio: transition_audio(audio),
138
+ frame_count: scene_frame_count,
139
+ elapsed_seconds: scene_elapsed_seconds
140
+ )
141
+ return unless transition
142
+
143
+ @scene = transition.fetch(:scene)
144
+ reset_scene_counters(audio)
145
+ end
146
+
147
+ def transition_audio(audio)
148
+ audio_hash = Hash(audio)
149
+ scene_count = scene_beat_count(audio_hash)
150
+ audio_hash.merge(scene_musical_counts(audio_hash, beat_count: scene_count))
151
+ rescue StandardError
152
+ { beat_count: 0 }
153
+ end
154
+
155
+ def scene_frame_count
156
+ [@frame_count - @scene_frame_base, 0].max
157
+ end
158
+
159
+ def scene_elapsed_seconds
160
+ [frame_time - @scene_elapsed_base, 0.0].max
161
+ end
162
+
163
+ def scene_beat_count(audio)
164
+ global_beat_count = Integer(Hash(audio)[:beat_count] || 0)
165
+ [global_beat_count - @scene_beat_base, 0].max
166
+ rescue StandardError
167
+ 0
168
+ end
169
+
170
+ def reset_scene_counters(audio)
171
+ audio_hash = Hash(audio)
172
+ @scene_frame_base = @frame_count
173
+ @scene_elapsed_base = frame_time
174
+ @scene_beat_base = Integer(audio_hash[:beat_count] || 0) - (audio_hash[:beat] ? 1 : 0)
175
+ rescue StandardError
176
+ @scene_frame_base = @frame_count
177
+ @scene_elapsed_base = frame_time
178
+ @scene_beat_base = 0
179
+ end
180
+
181
+ def scene_musical_counts(audio, beat_count:)
182
+ beat_index = beat_count.positive? ? beat_count - 1 : 0
183
+ beat_phase = Float(audio[:beat_phase] || audio["beat_phase"] || 0.0).clamp(0.0, 1.0)
184
+ {
185
+ beat_count: beat_count,
186
+ bar_phase: (((beat_index % 4) + beat_phase) / 4.0).clamp(0.0, 1.0),
187
+ bar_count: beat_index / 4,
188
+ phrase_count: beat_index / 32
189
+ }
190
+ rescue StandardError
191
+ { beat_count: beat_count, bar_phase: 0.0, bar_count: 0, phrase_count: 0 }
192
+ end
193
+
60
194
  def build_input_manager
61
195
  Vizcore::Audio::InputManager.new(
62
196
  source: @config.audio_source,
@@ -65,12 +199,26 @@ module Vizcore
65
199
  )
66
200
  end
67
201
 
202
+ def replay_pipeline
203
+ return unless feature_replay?
204
+
205
+ Vizcore::Analysis::FeatureReplay.new(path: @config.feature_file)
206
+ end
207
+
208
+ def feature_replay?
209
+ !!@config.feature_file
210
+ end
211
+
68
212
  def capture_size
69
213
  return @input_manager.frame_size unless @frame_rate
70
214
 
71
215
  @input_manager.realtime_capture_size(@frame_rate)
72
216
  end
73
217
 
218
+ def report_transition_error(message)
219
+ warn(message)
220
+ end
221
+
74
222
  def build_pipeline
75
223
  Vizcore::Analysis::Pipeline.new(
76
224
  sample_rate: @input_manager.sample_rate,
@@ -78,16 +226,46 @@ module Vizcore
78
226
  noise_gate: @config.noise_gate,
79
227
  audio_normalize: audio_normalize_settings,
80
228
  bpm: bpm_setting,
81
- bpm_lock: bpm_lock_setting
229
+ bpm_lock: bpm_lock_setting,
230
+ onset_sensitivity: analysis_setting(:onset_sensitivity, 1.0),
231
+ fft_preview_bins: analysis_setting(:fft_bins, Vizcore::Analysis::Pipeline::DEFAULT_FFT_PREVIEW_BINS),
232
+ peak_hold_frames: analysis_setting(:peak_hold_frames, 0),
233
+ silence_reset_frames: analysis_setting(:silence_reset_frames, Vizcore::Analysis::Pipeline::SILENCE_RESET_FRAMES)
82
234
  )
83
235
  end
84
236
 
237
+ def frame_time
238
+ return monotonic_elapsed unless @frame_rate
239
+
240
+ (@frame_count - 1).fdiv(@frame_rate)
241
+ end
242
+
243
+ def monotonic_elapsed
244
+ now = monotonic_seconds
245
+ return 0.0 unless @source_started_at
246
+
247
+ elapsed = now - @source_started_at
248
+ elapsed.positive? ? elapsed : 0.0
249
+ rescue StandardError
250
+ 0.0
251
+ end
252
+
253
+ def monotonic_seconds
254
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
255
+ end
256
+
85
257
  def audio_normalize_settings
86
258
  Hash(@definition[:analysis] || {})[:audio_normalize]
87
259
  rescue StandardError
88
260
  nil
89
261
  end
90
262
 
263
+ def analysis_setting(key, fallback)
264
+ Hash(@definition[:analysis] || {}).fetch(key, fallback)
265
+ rescue StandardError
266
+ fallback
267
+ end
268
+
91
269
  def bpm_setting
92
270
  @config.bpm || Hash(@definition[:analysis] || {})[:bpm]
93
271
  rescue StandardError
@@ -110,7 +288,7 @@ module Vizcore
110
288
  end
111
289
 
112
290
  def ensure_started!
113
- return if @input_manager && @pipeline && @scene
291
+ return if @pipeline && @scene && (!feature_replay? || @input_manager.nil? || @input_manager.running?)
114
292
 
115
293
  raise RuntimeError, "scene frame source has not been started"
116
294
  end
@@ -4,6 +4,11 @@ module Vizcore
4
4
  module Renderer
5
5
  # Serializes analysis and scene state into transport payloads.
6
6
  class SceneSerializer
7
+ SCENE_SCHEMA_VERSION = "vizcore.scene.v1"
8
+ FRAME_SCHEMA_VERSION = "vizcore.frame.v1"
9
+ LAYER_SCHEMA_VERSION = "vizcore.layer.v1"
10
+ MAPPING_SCHEMA_VERSION = "vizcore.mapping.v1"
11
+
7
12
  # @param timestamp [Numeric]
8
13
  # @param audio [Hash]
9
14
  # @param scene_name [String, Symbol]
@@ -13,6 +18,7 @@ module Vizcore
13
18
  # @return [Hash]
14
19
  def audio_frame(timestamp:, audio:, scene_name:, scene_layers:, transition: nil, metrics: nil)
15
20
  frame = {
21
+ schema_version: FRAME_SCHEMA_VERSION,
16
22
  timestamp: Float(timestamp),
17
23
  audio: serialize_audio(audio),
18
24
  scene: serialize_scene(scene_name, scene_layers),
@@ -26,12 +32,15 @@ module Vizcore
26
32
 
27
33
  def serialize_audio(audio)
28
34
  bands = symbolize_hash(audio[:bands])
35
+ band_peaks = { sub: 0.0, low: 0.0, mid: 0.0, high: 0.0 }.merge(symbolize_hash(audio[:band_peaks]))
29
36
  onsets = { sub: 0.0, low: 0.0, mid: 0.0, high: 0.0 }.merge(symbolize_hash(audio[:onsets]))
30
37
  drums = { kick: 0.0, snare: 0.0, hihat: 0.0 }.merge(symbolize_hash(audio[:drums]))
31
38
 
32
39
  {
33
40
  amplitude: round_float(audio[:amplitude]),
41
+ peak: round_float(audio[:peak]),
34
42
  bands: bands.transform_values { |value| round_float(value) },
43
+ band_peaks: band_peaks.transform_values { |value| round_float(value) },
35
44
  fft: Array(audio[:fft]).map { |value| round_float(value) },
36
45
  onset: round_float(audio[:onset]),
37
46
  onsets: onsets.transform_values { |value| round_float(value) },
@@ -40,13 +49,28 @@ module Vizcore
40
49
  beat_confidence: round_float(audio[:beat_confidence]),
41
50
  beat_pulse: round_float(audio[:beat_pulse]),
42
51
  beat_count: Integer(audio[:beat_count] || 0),
52
+ beat_phase: round_float(audio[:beat_phase]),
53
+ beat_2: !!audio[:beat_2],
54
+ beat_4: !!audio[:beat_4],
55
+ beat_8: !!audio[:beat_8],
56
+ beat_triplet: !!audio[:beat_triplet],
57
+ bar_phase: round_float(audio[:bar_phase]),
58
+ bar_count: Integer(audio[:bar_count] || 0),
59
+ phrase_count: Integer(audio[:phrase_count] || 0),
43
60
  bpm: audio[:bpm],
61
+ bpm_confidence: round_float(audio[:bpm_confidence]),
62
+ spectral_centroid: round_float(audio[:spectral_centroid]),
63
+ spectral_rolloff: round_float(audio[:spectral_rolloff]),
64
+ spectral_flatness: round_float(audio[:spectral_flatness]),
65
+ spectral_flux: round_float(audio[:spectral_flux]),
66
+ zero_crossing_rate: round_float(audio[:zero_crossing_rate]),
44
67
  peak_frequency: round_float(audio[:peak_frequency])
45
68
  }
46
69
  end
47
70
 
48
71
  def serialize_scene(scene_name, scene_layers)
49
72
  {
73
+ schema_version: SCENE_SCHEMA_VERSION,
50
74
  name: scene_name.to_s,
51
75
  layers: Array(scene_layers).map { |layer| serialize_layer(layer) }
52
76
  }
@@ -58,12 +82,14 @@ module Vizcore
58
82
  output = {
59
83
  name: values.fetch(:name).to_s,
60
84
  type: (values[:type] || :geometry).to_s,
85
+ schema_version: LAYER_SCHEMA_VERSION,
61
86
  params: symbolize_hash(values[:params])
62
87
  }
63
88
  output[:shader] = values[:shader].to_s if values[:shader]
64
89
  output[:glsl] = values[:glsl].to_s if values[:glsl]
65
90
  output[:glsl_source] = values[:glsl_source].to_s if values[:glsl_source]
66
91
  output[:param_schema] = serialize_param_schema(values[:param_schema]) if values[:param_schema]
92
+ output[:mappings] = serialize_mappings(values[:mappings]) unless Array(values[:mappings]).empty?
67
93
  output
68
94
  end
69
95
 
@@ -80,6 +106,18 @@ module Vizcore
80
106
  end
81
107
  end
82
108
 
109
+ def serialize_mappings(mappings)
110
+ Array(mappings).map do |mapping|
111
+ values = symbolize_hash(mapping)
112
+ {
113
+ schema_version: MAPPING_SCHEMA_VERSION,
114
+ source: values[:source],
115
+ target: values[:target].to_s,
116
+ transform: symbolize_hash(values[:transform])
117
+ }
118
+ end
119
+ end
120
+
83
121
  def serialize_metrics(metrics)
84
122
  symbolize_hash(metrics).each_with_object({}) do |(key, value), output|
85
123
  output[key] = key == :frame_id ? Integer(value) : round_float(value)
@@ -9,10 +9,11 @@ module Vizcore
9
9
  module Renderer
10
10
  # Builds one analyzed scene frame and writes a PNG preview.
11
11
  class Snapshot
12
- def initialize(config:, width: SnapshotRenderer::DEFAULT_WIDTH, height: SnapshotRenderer::DEFAULT_HEIGHT)
12
+ def initialize(config:, width: SnapshotRenderer::DEFAULT_WIDTH, height: SnapshotRenderer::DEFAULT_HEIGHT, transparent: false)
13
13
  @config = config
14
14
  @width = width
15
15
  @height = height
16
+ @transparent = !!transparent
16
17
  end
17
18
 
18
19
  # @param out [String, Pathname]
@@ -22,14 +23,14 @@ module Vizcore
22
23
  frame_source = SceneFrameSource.new(config: @config)
23
24
  frame_source.start
24
25
  frame = frame_source.capture
25
- png = SnapshotRenderer.new(width: @width, height: @height).render(
26
+ png = SnapshotRenderer.new(width: @width, height: @height, transparent: @transparent).render(
26
27
  scene: frame.fetch(:scene),
27
28
  audio: frame.fetch(:audio)
28
29
  )
29
30
 
30
31
  FileUtils.mkdir_p(output_path.dirname)
31
32
  File.binwrite(output_path, png)
32
- { path: output_path, scene: frame.fetch(:scene_name), width: @width, height: @height }
33
+ { path: output_path, scene: frame.fetch(:scene_name), width: @width, height: @height, transparent: @transparent }
33
34
  ensure
34
35
  frame_source&.stop
35
36
  end