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,21 +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
|
-
|
|
21
|
-
@
|
|
22
|
-
@
|
|
23
|
-
|
|
24
|
-
@
|
|
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
|
|
25
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)
|
|
26
45
|
self
|
|
27
46
|
end
|
|
28
47
|
|
|
@@ -30,19 +49,29 @@ module Vizcore
|
|
|
30
49
|
def capture
|
|
31
50
|
ensure_started!
|
|
32
51
|
|
|
33
|
-
audio =
|
|
52
|
+
audio = if feature_replay?
|
|
53
|
+
@pipeline.call
|
|
54
|
+
else
|
|
55
|
+
@pipeline.call(@input_manager.capture_frame(@capture_size))
|
|
56
|
+
end
|
|
34
57
|
@frame_count += 1
|
|
35
|
-
|
|
36
|
-
|
|
58
|
+
scene = @scene
|
|
59
|
+
layers = @mapping_resolver.resolve_layers(
|
|
60
|
+
scene_layers: scene[:layers],
|
|
37
61
|
audio: audio,
|
|
38
62
|
time: frame_time,
|
|
39
63
|
frame: @frame_count
|
|
40
64
|
)
|
|
65
|
+
evaluate_transition(audio)
|
|
41
66
|
|
|
42
67
|
{
|
|
43
|
-
scene: {
|
|
68
|
+
scene: {
|
|
69
|
+
schema_version: Vizcore::Renderer::SceneSerializer::SCENE_SCHEMA_VERSION,
|
|
70
|
+
name: scene[:name],
|
|
71
|
+
layers: layers
|
|
72
|
+
},
|
|
44
73
|
audio: audio,
|
|
45
|
-
scene_name:
|
|
74
|
+
scene_name: scene[:name].to_s
|
|
46
75
|
}
|
|
47
76
|
end
|
|
48
77
|
|
|
@@ -57,6 +86,15 @@ module Vizcore
|
|
|
57
86
|
@shader_source_resolver.resolve(definition: definition, scene_file: @config.scene_file.to_s)
|
|
58
87
|
end
|
|
59
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
|
+
|
|
60
98
|
def first_scene(definition)
|
|
61
99
|
scene = Array(definition[:scenes]).first
|
|
62
100
|
return scene if scene
|
|
@@ -64,6 +102,95 @@ module Vizcore
|
|
|
64
102
|
{ name: @config.scene_file.basename(".rb").to_sym, layers: [] }
|
|
65
103
|
end
|
|
66
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
|
+
|
|
67
194
|
def build_input_manager
|
|
68
195
|
Vizcore::Audio::InputManager.new(
|
|
69
196
|
source: @config.audio_source,
|
|
@@ -72,12 +199,26 @@ module Vizcore
|
|
|
72
199
|
)
|
|
73
200
|
end
|
|
74
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
|
+
|
|
75
212
|
def capture_size
|
|
76
213
|
return @input_manager.frame_size unless @frame_rate
|
|
77
214
|
|
|
78
215
|
@input_manager.realtime_capture_size(@frame_rate)
|
|
79
216
|
end
|
|
80
217
|
|
|
218
|
+
def report_transition_error(message)
|
|
219
|
+
warn(message)
|
|
220
|
+
end
|
|
221
|
+
|
|
81
222
|
def build_pipeline
|
|
82
223
|
Vizcore::Analysis::Pipeline.new(
|
|
83
224
|
sample_rate: @input_manager.sample_rate,
|
|
@@ -85,22 +226,46 @@ module Vizcore
|
|
|
85
226
|
noise_gate: @config.noise_gate,
|
|
86
227
|
audio_normalize: audio_normalize_settings,
|
|
87
228
|
bpm: bpm_setting,
|
|
88
|
-
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)
|
|
89
234
|
)
|
|
90
235
|
end
|
|
91
236
|
|
|
92
237
|
def frame_time
|
|
93
|
-
return
|
|
238
|
+
return monotonic_elapsed unless @frame_rate
|
|
94
239
|
|
|
95
240
|
(@frame_count - 1).fdiv(@frame_rate)
|
|
96
241
|
end
|
|
97
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
|
+
|
|
98
257
|
def audio_normalize_settings
|
|
99
258
|
Hash(@definition[:analysis] || {})[:audio_normalize]
|
|
100
259
|
rescue StandardError
|
|
101
260
|
nil
|
|
102
261
|
end
|
|
103
262
|
|
|
263
|
+
def analysis_setting(key, fallback)
|
|
264
|
+
Hash(@definition[:analysis] || {}).fetch(key, fallback)
|
|
265
|
+
rescue StandardError
|
|
266
|
+
fallback
|
|
267
|
+
end
|
|
268
|
+
|
|
104
269
|
def bpm_setting
|
|
105
270
|
@config.bpm || Hash(@definition[:analysis] || {})[:bpm]
|
|
106
271
|
rescue StandardError
|
|
@@ -123,7 +288,7 @@ module Vizcore
|
|
|
123
288
|
end
|
|
124
289
|
|
|
125
290
|
def ensure_started!
|
|
126
|
-
return if @
|
|
291
|
+
return if @pipeline && @scene && (!feature_replay? || @input_manager.nil? || @input_manager.running?)
|
|
127
292
|
|
|
128
293
|
raise RuntimeError, "scene frame source has not been started"
|
|
129
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
|
|
@@ -19,9 +19,10 @@ module Vizcore
|
|
|
19
19
|
[250, 204, 21]
|
|
20
20
|
].freeze
|
|
21
21
|
|
|
22
|
-
def initialize(width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT)
|
|
22
|
+
def initialize(width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT, transparent: false)
|
|
23
23
|
@width = normalize_dimension(width)
|
|
24
24
|
@height = normalize_dimension(height)
|
|
25
|
+
@transparent = !!transparent
|
|
25
26
|
end
|
|
26
27
|
|
|
27
28
|
attr_reader :width, :height
|
|
@@ -30,8 +31,8 @@ module Vizcore
|
|
|
30
31
|
# @param audio [Hash]
|
|
31
32
|
# @return [String] PNG bytes
|
|
32
33
|
def render(scene:, audio:)
|
|
33
|
-
canvas = Canvas.new(width: width, height: height)
|
|
34
|
-
canvas.fill_gradient(background_top(audio), background_bottom(audio))
|
|
34
|
+
canvas = Canvas.new(width: width, height: height, transparent: @transparent)
|
|
35
|
+
canvas.fill_gradient(background_top(audio), background_bottom(audio)) unless @transparent
|
|
35
36
|
layers = Array(scene[:layers] || scene["layers"])
|
|
36
37
|
layers = [default_layer] if layers.empty?
|
|
37
38
|
layers.each_with_index { |layer, index| render_layer(canvas, layer, audio, index) }
|
|
@@ -759,14 +760,115 @@ module Vizcore
|
|
|
759
760
|
end
|
|
760
761
|
|
|
761
762
|
def configured_color(params)
|
|
762
|
-
|
|
763
|
+
value = params[:color]
|
|
764
|
+
value = params["color"] unless value
|
|
765
|
+
resolved = resolve_color_value(value)
|
|
766
|
+
return resolved.to_s.strip unless resolved.to_s.strip.empty?
|
|
767
|
+
|
|
768
|
+
nil
|
|
769
|
+
end
|
|
770
|
+
|
|
771
|
+
def resolve_color_value(value)
|
|
772
|
+
return value if value.is_a?(String)
|
|
773
|
+
return resolve_gradient_color(value) if value.is_a?(Hash)
|
|
774
|
+
|
|
775
|
+
value
|
|
776
|
+
end
|
|
777
|
+
|
|
778
|
+
def resolve_gradient_color(value)
|
|
779
|
+
gradient = value[:gradient] || value["gradient"]
|
|
780
|
+
return nil unless gradient.is_a?(Hash)
|
|
781
|
+
|
|
782
|
+
colors = normalize_colors(gradient[:colors] || gradient["colors"])
|
|
783
|
+
return nil if colors.empty?
|
|
784
|
+
|
|
785
|
+
return colors[0] if colors.length == 1
|
|
786
|
+
|
|
787
|
+
position = normalize_position(gradient[:position] || gradient["position"])
|
|
788
|
+
stops = normalize_gradient_stops(gradient[:stops] || gradient["stops"], colors.length)
|
|
789
|
+
|
|
790
|
+
if stops
|
|
791
|
+
resolve_gradient_color_with_stops(colors, position, stops)
|
|
792
|
+
else
|
|
793
|
+
resolve_gradient_color_with_position(colors, position)
|
|
794
|
+
end
|
|
795
|
+
end
|
|
796
|
+
|
|
797
|
+
def normalize_gradient_stops(stops, color_count)
|
|
798
|
+
return nil unless stops
|
|
799
|
+
|
|
800
|
+
values = Array(stops).filter_map { |entry| Float(entry, exception: false) }
|
|
801
|
+
return nil if values.length != color_count
|
|
802
|
+
|
|
803
|
+
values.sort.map { |value| value.to_f.clamp(0.0, 1.0) }
|
|
804
|
+
end
|
|
805
|
+
|
|
806
|
+
def resolve_gradient_color_with_position(colors, position)
|
|
807
|
+
segment_length = 1.0 / (colors.length - 1)
|
|
808
|
+
segment = [(position / segment_length).floor, colors.length - 2].min
|
|
809
|
+
blend = (position % segment_length) / segment_length
|
|
810
|
+
|
|
811
|
+
left_color = parse_hex_color(colors[segment])
|
|
812
|
+
right_color = parse_hex_color(colors[segment + 1])
|
|
813
|
+
return colors[segment] if left_color.nil? || right_color.nil?
|
|
814
|
+
|
|
815
|
+
interpolate_hex_color(left_color, right_color, blend)
|
|
816
|
+
end
|
|
817
|
+
|
|
818
|
+
def resolve_gradient_color_with_stops(colors, position, stops)
|
|
819
|
+
index = Array.new(colors.length - 1) { |offset| offset }.index do |offset|
|
|
820
|
+
position <= stops[offset + 1]
|
|
821
|
+
end
|
|
822
|
+
|
|
823
|
+
return colors.last if index.nil?
|
|
824
|
+
|
|
825
|
+
return colors[0] if index == 0 && position <= stops[0]
|
|
826
|
+
|
|
827
|
+
left_index = [index, colors.length - 2].min
|
|
828
|
+
right_index = left_index + 1
|
|
829
|
+
start = stops[left_index]
|
|
830
|
+
stop = stops[right_index]
|
|
831
|
+
blend = ((position - start) / (stop - start)).clamp(0.0, 1.0)
|
|
832
|
+
|
|
833
|
+
left_color = parse_hex_color(colors[left_index])
|
|
834
|
+
right_color = parse_hex_color(colors[right_index])
|
|
835
|
+
return colors[left_index] if left_color.nil? || right_color.nil?
|
|
836
|
+
|
|
837
|
+
interpolate_hex_color(left_color, right_color, blend)
|
|
838
|
+
end
|
|
839
|
+
|
|
840
|
+
def normalize_colors(value)
|
|
841
|
+
Array(value).map { |entry| entry.to_s.strip }.reject(&:empty?)
|
|
842
|
+
end
|
|
843
|
+
|
|
844
|
+
def normalize_position(value)
|
|
845
|
+
position = Float(value)
|
|
846
|
+
position = 0.0 unless position.finite?
|
|
847
|
+
|
|
848
|
+
position % 1.0
|
|
849
|
+
rescue ArgumentError, TypeError
|
|
850
|
+
0.0
|
|
763
851
|
end
|
|
764
852
|
|
|
765
853
|
def palette_color(params, index)
|
|
766
854
|
palette = Array(params[:palette] || params["palette"]).map { |color| color.to_s.strip }.reject(&:empty?)
|
|
767
855
|
return nil if palette.empty?
|
|
768
856
|
|
|
769
|
-
|
|
857
|
+
position = normalize_palette_position(index, palette.length)
|
|
858
|
+
return palette[0] if position.nil?
|
|
859
|
+
|
|
860
|
+
lower_index = position.floor
|
|
861
|
+
upper_index = (lower_index + 1) % palette.length
|
|
862
|
+
blend = position - lower_index
|
|
863
|
+
|
|
864
|
+
base_color = palette[lower_index]
|
|
865
|
+
return base_color unless blend.positive? && blend < 1.0
|
|
866
|
+
|
|
867
|
+
lower_rgb = parse_hex_color(base_color)
|
|
868
|
+
upper_rgb = parse_hex_color(palette[upper_index])
|
|
869
|
+
return base_color if lower_rgb.nil? || upper_rgb.nil?
|
|
870
|
+
|
|
871
|
+
interpolate_hex_color(lower_rgb, upper_rgb, blend)
|
|
770
872
|
end
|
|
771
873
|
|
|
772
874
|
def parse_hex_color(value)
|
|
@@ -778,6 +880,28 @@ module Vizcore
|
|
|
778
880
|
[hex[0, 2], hex[2, 2], hex[4, 2]].map { |component| component.to_i(16) }
|
|
779
881
|
end
|
|
780
882
|
|
|
883
|
+
def normalize_palette_position(value, palette_length)
|
|
884
|
+
return nil unless palette_length.positive?
|
|
885
|
+
|
|
886
|
+
numeric = Float(value)
|
|
887
|
+
return nil unless numeric.finite?
|
|
888
|
+
|
|
889
|
+
position = numeric % palette_length
|
|
890
|
+
return nil if position.nan?
|
|
891
|
+
|
|
892
|
+
position
|
|
893
|
+
rescue StandardError
|
|
894
|
+
nil
|
|
895
|
+
end
|
|
896
|
+
|
|
897
|
+
def interpolate_hex_color(left_rgb, right_rgb, blend)
|
|
898
|
+
blend = blend.to_f.clamp(0.0, 1.0)
|
|
899
|
+
rgb = left_rgb.zip(right_rgb).map do |left, right|
|
|
900
|
+
(left + (right - left) * blend).round.clamp(0, 255)
|
|
901
|
+
end
|
|
902
|
+
format("##{rgb.map { |value| format('%02x', value) }.join}")
|
|
903
|
+
end
|
|
904
|
+
|
|
781
905
|
def default_layer
|
|
782
906
|
{ type: "geometry", name: "snapshot" }
|
|
783
907
|
end
|
|
@@ -808,11 +932,12 @@ module Vizcore
|
|
|
808
932
|
|
|
809
933
|
# Tiny RGBA canvas with alpha blending and a few primitive drawing helpers.
|
|
810
934
|
class Canvas
|
|
811
|
-
def initialize(width:, height:)
|
|
935
|
+
def initialize(width:, height:, transparent: false)
|
|
812
936
|
@width = width
|
|
813
937
|
@height = height
|
|
814
938
|
@bytes = String.new(capacity: width * height * 4, encoding: Encoding::BINARY)
|
|
815
|
-
|
|
939
|
+
alpha = transparent ? 0 : 255
|
|
940
|
+
@bytes << ([0, 0, 0, alpha].pack("C4") * (width * height))
|
|
816
941
|
end
|
|
817
942
|
|
|
818
943
|
attr_reader :width, :height, :bytes
|
|
@@ -918,7 +1043,8 @@ module Vizcore
|
|
|
918
1043
|
current = bytes.getbyte(offset + index)
|
|
919
1044
|
bytes.setbyte(offset + index, interpolate(current, color[index], amount).round)
|
|
920
1045
|
end
|
|
921
|
-
bytes.
|
|
1046
|
+
alpha = bytes.getbyte(offset + 3)
|
|
1047
|
+
bytes.setbyte(offset + 3, [alpha + (255 - alpha) * amount, 255].min.round)
|
|
922
1048
|
end
|
|
923
1049
|
|
|
924
1050
|
def set_pixel(x, y, color, alpha)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
|
|
5
|
+
module Vizcore
|
|
6
|
+
# Builds safety warnings for Ruby scene files, which execute as normal Ruby.
|
|
7
|
+
module SceneTrust
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
# @param scene_file [String, Pathname, nil]
|
|
11
|
+
# @param project_root [String, Pathname]
|
|
12
|
+
# @return [String, nil]
|
|
13
|
+
def warning_for(scene_file, project_root: Dir.pwd)
|
|
14
|
+
return nil if scene_file.to_s.strip.empty?
|
|
15
|
+
|
|
16
|
+
path = Pathname.new(scene_file).expand_path
|
|
17
|
+
root = Pathname.new(project_root).expand_path
|
|
18
|
+
return nil if under?(path, root)
|
|
19
|
+
return nil if under?(path, Vizcore.root)
|
|
20
|
+
|
|
21
|
+
"Scene files execute Ruby code. Review #{path} before running it, or pass --trust to suppress this warning."
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def under?(path, root)
|
|
25
|
+
relative = path.relative_path_from(root)
|
|
26
|
+
!relative.each_filename.first&.start_with?("..")
|
|
27
|
+
rescue ArgumentError
|
|
28
|
+
false
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|