vizcore 0.1.0 → 1.0.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/README.md +544 -9
- data/docs/.nojekyll +0 -0
- data/docs/assets/site.css +744 -0
- data/docs/assets/vizcore-demo.gif +0 -0
- data/docs/assets/vizcore-poster.png +0 -0
- data/docs/assets/vj-tunnel.js +159 -0
- data/docs/index.html +224 -0
- data/examples/README.md +59 -0
- data/examples/assets/README.md +19 -0
- data/examples/audio_inspector.rb +34 -0
- data/examples/club_intro_drop.rb +78 -0
- data/examples/kansai_rubykaigi_visual.rb +70 -0
- data/examples/live_coding_minimal.rb +22 -0
- data/examples/midi_controller_show.rb +78 -0
- data/examples/midi_scene_switch.rb +3 -1
- data/examples/parser_visualizer.rb +48 -0
- data/examples/readme_demo.rb +17 -0
- data/examples/rhythm_geometry.rb +34 -0
- data/examples/ruby_crystal_show.rb +35 -0
- data/examples/shader_playground.rb +18 -0
- data/examples/unyo_liquid.rb +59 -0
- data/examples/vj_ambient_chill_room.rb +124 -0
- data/examples/vj_dnb_jungle.rb +170 -0
- data/examples/vj_festival_mainstage.rb +245 -0
- data/examples/vj_festival_mainstage.yml +17 -0
- data/examples/vj_glitch_industrial.rb +164 -0
- data/examples/vj_hiphop_cipher.rb +167 -0
- data/examples/vj_jpop_idol_live.rb +210 -0
- data/examples/vj_synthwave_retro.rb +173 -0
- data/examples/vj_techno_warehouse.rb +195 -0
- data/frontend/index.html +468 -2
- data/frontend/src/audio-inspector.js +40 -0
- data/frontend/src/live-controls.js +131 -0
- data/frontend/src/main.js +792 -16
- data/frontend/src/midi-learn.js +194 -0
- data/frontend/src/performance-monitor.js +183 -0
- data/frontend/src/plugin-runtime.js +130 -0
- data/frontend/src/projector-mode.js +56 -0
- data/frontend/src/renderer/engine.js +148 -3
- data/frontend/src/renderer/layer-manager.js +428 -30
- data/frontend/src/renderer/shader-manager.js +26 -0
- data/frontend/src/runtime-control-preset.js +11 -0
- data/frontend/src/shader-error-overlay.js +29 -0
- data/frontend/src/shader-param-controls.js +93 -0
- data/frontend/src/shaders/builtins.js +380 -2
- data/frontend/src/shaders/post-effects.js +52 -0
- data/frontend/src/visual-regression.js +67 -0
- data/frontend/src/visual-settings-preset.js +103 -0
- data/frontend/src/visuals/geometry.js +268 -0
- data/frontend/src/visuals/image-renderer.js +291 -0
- data/frontend/src/visuals/particle-system.js +56 -10
- data/frontend/src/visuals/spectrogram-renderer.js +226 -0
- data/frontend/src/visuals/text-renderer.js +112 -11
- data/frontend/src/websocket-client.js +12 -1
- data/lib/vizcore/analysis/adaptive_normalizer.rb +70 -0
- data/lib/vizcore/analysis/beat_detector.rb +4 -2
- data/lib/vizcore/analysis/bpm_estimator.rb +8 -0
- data/lib/vizcore/analysis/feature_recorder.rb +159 -0
- data/lib/vizcore/analysis/feature_replay.rb +84 -0
- data/lib/vizcore/analysis/pipeline.rb +235 -11
- data/lib/vizcore/analysis/tap_tempo.rb +74 -0
- data/lib/vizcore/analysis.rb +4 -0
- data/lib/vizcore/audio/dummy_sine_input.rb +1 -1
- data/lib/vizcore/audio/fixture_input.rb +65 -0
- data/lib/vizcore/audio/input_manager.rb +4 -2
- data/lib/vizcore/audio/mic_input.rb +24 -8
- data/lib/vizcore/audio/portaudio_ffi.rb +106 -1
- data/lib/vizcore/audio.rb +1 -0
- data/lib/vizcore/cli/doctor.rb +159 -0
- data/lib/vizcore/cli/dsl_reference.rb +99 -0
- data/lib/vizcore/cli/layer_docs.rb +46 -0
- data/lib/vizcore/cli/scene_diagnostics.rb +23 -0
- data/lib/vizcore/cli/scene_inspector.rb +136 -0
- data/lib/vizcore/cli/scene_validator.rb +245 -0
- data/lib/vizcore/cli/shader_template.rb +68 -0
- data/lib/vizcore/cli/shader_uniform_docs.rb +54 -0
- data/lib/vizcore/cli.rb +689 -18
- data/lib/vizcore/config.rb +103 -2
- data/lib/vizcore/control_preset.rb +68 -0
- data/lib/vizcore/dsl/engine.rb +277 -5
- data/lib/vizcore/dsl/layer_builder.rb +491 -22
- data/lib/vizcore/dsl/layer_group_builder.rb +112 -0
- data/lib/vizcore/dsl/mapping_resolver.rb +132 -3
- data/lib/vizcore/dsl/mapping_transform_builder.rb +71 -0
- data/lib/vizcore/dsl/reaction_builder.rb +44 -0
- data/lib/vizcore/dsl/scene_builder.rb +61 -5
- data/lib/vizcore/dsl/shader_source_resolver.rb +67 -6
- data/lib/vizcore/dsl/style_builder.rb +68 -0
- data/lib/vizcore/dsl/timeline_builder.rb +138 -0
- data/lib/vizcore/dsl/transition_controller.rb +77 -0
- data/lib/vizcore/dsl.rb +5 -1
- data/lib/vizcore/layer_catalog.rb +273 -0
- data/lib/vizcore/project_manifest.rb +152 -0
- data/lib/vizcore/renderer/png_writer.rb +57 -0
- data/lib/vizcore/renderer/render_sequence.rb +153 -0
- data/lib/vizcore/renderer/scene_frame_source.rb +119 -0
- data/lib/vizcore/renderer/scene_serializer.rb +36 -3
- data/lib/vizcore/renderer/snapshot.rb +38 -0
- data/lib/vizcore/renderer/snapshot_renderer.rb +446 -0
- data/lib/vizcore/renderer.rb +5 -0
- data/lib/vizcore/server/frame_broadcaster.rb +91 -5
- data/lib/vizcore/server/gallery_app.rb +155 -0
- data/lib/vizcore/server/gallery_page.rb +100 -0
- data/lib/vizcore/server/gallery_runner.rb +48 -0
- data/lib/vizcore/server/rack_app.rb +203 -4
- data/lib/vizcore/server/runner.rb +370 -22
- data/lib/vizcore/server/scene_dependency_watcher.rb +79 -0
- data/lib/vizcore/server/websocket_handler.rb +60 -10
- data/lib/vizcore/server.rb +4 -0
- data/lib/vizcore/sync/osc_message.rb +103 -0
- data/lib/vizcore/sync/osc_receiver.rb +68 -0
- data/lib/vizcore/sync.rb +4 -0
- data/lib/vizcore/templates/midi_control_scene.rb +3 -1
- data/lib/vizcore/templates/plugin_layer.rb +20 -0
- data/lib/vizcore/templates/plugin_readme.md +23 -0
- data/lib/vizcore/templates/plugin_renderer.js +43 -0
- data/lib/vizcore/templates/plugin_scene.rb +14 -0
- data/lib/vizcore/templates/project_readme.md +7 -23
- data/lib/vizcore/templates/rubykaigi_scene.rb +30 -0
- data/lib/vizcore/version.rb +1 -1
- data/lib/vizcore.rb +27 -0
- data/scripts/browser_capture.mjs +75 -0
- data/sig/vizcore.rbs +362 -0
- metadata +83 -3
- data/docs/GETTING_STARTED.md +0 -105
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "png_writer"
|
|
4
|
+
|
|
5
|
+
module Vizcore
|
|
6
|
+
module Renderer
|
|
7
|
+
# Renders a deterministic software preview PNG from a scene frame.
|
|
8
|
+
class SnapshotRenderer
|
|
9
|
+
DEFAULT_WIDTH = 1280
|
|
10
|
+
DEFAULT_HEIGHT = 720
|
|
11
|
+
PALETTE = [
|
|
12
|
+
[56, 189, 248],
|
|
13
|
+
[225, 29, 72],
|
|
14
|
+
[101, 255, 176],
|
|
15
|
+
[244, 114, 182],
|
|
16
|
+
[250, 204, 21]
|
|
17
|
+
].freeze
|
|
18
|
+
|
|
19
|
+
def initialize(width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT)
|
|
20
|
+
@width = normalize_dimension(width)
|
|
21
|
+
@height = normalize_dimension(height)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
attr_reader :width, :height
|
|
25
|
+
|
|
26
|
+
# @param scene [Hash]
|
|
27
|
+
# @param audio [Hash]
|
|
28
|
+
# @return [String] PNG bytes
|
|
29
|
+
def render(scene:, audio:)
|
|
30
|
+
canvas = Canvas.new(width: width, height: height)
|
|
31
|
+
canvas.fill_gradient(background_top(audio), background_bottom(audio))
|
|
32
|
+
layers = Array(scene[:layers] || scene["layers"])
|
|
33
|
+
layers = [default_layer] if layers.empty?
|
|
34
|
+
layers.each_with_index { |layer, index| render_layer(canvas, layer, audio, index) }
|
|
35
|
+
PngWriter.encode(width: width, height: height, rgba: canvas.bytes)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def render_layer(canvas, layer, audio, index)
|
|
41
|
+
type = (layer[:type] || layer["type"] || "geometry").to_s
|
|
42
|
+
color = layer_color(layer, audio, index)
|
|
43
|
+
|
|
44
|
+
case type
|
|
45
|
+
when "shader"
|
|
46
|
+
render_shader_layer(canvas, audio, color, index)
|
|
47
|
+
when "particle_field"
|
|
48
|
+
render_particle_layer(canvas, layer, audio, color)
|
|
49
|
+
when "text"
|
|
50
|
+
render_text_layer(canvas, layer, audio, color)
|
|
51
|
+
when "svg", "svg_layer", "image", "image_layer", "photo", "video", "video_layer", "footage"
|
|
52
|
+
render_image_layer(canvas, layer, audio, color)
|
|
53
|
+
when "waveform", "waveform_layer"
|
|
54
|
+
render_waveform_layer(canvas, layer, audio, color)
|
|
55
|
+
when "spectrogram", "spectrogram_layer"
|
|
56
|
+
render_spectrogram_layer(canvas, layer, audio, color)
|
|
57
|
+
when "shape", "shapes", "shape_layer"
|
|
58
|
+
render_shape_layer(canvas, layer, audio, color)
|
|
59
|
+
when "mesh", "mesh_layer", "preset_mesh"
|
|
60
|
+
render_mesh_layer(canvas, layer, audio, color, index)
|
|
61
|
+
else
|
|
62
|
+
render_geometry_layer(canvas, audio, color, index)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def render_shader_layer(canvas, audio, color, index)
|
|
67
|
+
amplitude = clamp(audio[:amplitude])
|
|
68
|
+
y_base = height * (0.35 + index * 0.12)
|
|
69
|
+
5.times do |wave_index|
|
|
70
|
+
alpha = 0.16 + amplitude * 0.18
|
|
71
|
+
offset = wave_index * height * 0.055
|
|
72
|
+
canvas.draw_wave(y_base + offset, amplitude: amplitude, color: color, alpha: alpha)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def render_particle_layer(canvas, layer, audio, color)
|
|
77
|
+
amplitude = clamp(audio[:amplitude])
|
|
78
|
+
count = [[Integer(layer.dig(:params, :count) || layer.dig("params", "count") || 420), 80].max, 900].min
|
|
79
|
+
count.times do |index|
|
|
80
|
+
x = (Math.sin(index * 12.9898) * 43_758.5453).abs % width
|
|
81
|
+
y = (Math.sin(index * 78.233) * 12_345.6789).abs % height
|
|
82
|
+
radius = 1 + (index % 3) + (amplitude * 2).round
|
|
83
|
+
canvas.fill_circle(x, y, radius, color, alpha: 0.35 + amplitude * 0.45)
|
|
84
|
+
end
|
|
85
|
+
rescue ArgumentError, TypeError
|
|
86
|
+
nil
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def render_text_layer(canvas, layer, audio, color)
|
|
90
|
+
params = Hash(layer[:params] || layer["params"] || {})
|
|
91
|
+
content = params[:content] || params["content"] || layer[:name] || layer["name"] || "Vizcore"
|
|
92
|
+
canvas.draw_label(
|
|
93
|
+
content.to_s,
|
|
94
|
+
x: width * 0.5,
|
|
95
|
+
y: height * 0.72,
|
|
96
|
+
color: color,
|
|
97
|
+
alpha: 0.62 + clamp(audio[:beat_pulse]) * 0.28,
|
|
98
|
+
letter_spacing: normalize_letter_spacing(params)
|
|
99
|
+
)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def render_image_layer(canvas, layer, audio, color)
|
|
103
|
+
params = Hash(layer[:params] || layer["params"] || {})
|
|
104
|
+
label = params[:file] || params["file"] || layer[:name] || layer["name"] || "image"
|
|
105
|
+
scale = Float(params[:scale] || params["scale"] || 1).clamp(0.1, 4.0)
|
|
106
|
+
pulse = clamp(audio[:beat_pulse])
|
|
107
|
+
size = [width, height].min * (0.18 + pulse * 0.06) * scale
|
|
108
|
+
x = width * 0.5
|
|
109
|
+
y = height * 0.5
|
|
110
|
+
canvas.draw_rect_outline(x - size / 2, y - size / 2, size, size, color, alpha: 0.72)
|
|
111
|
+
canvas.draw_label(File.basename(label.to_s), x: x, y: y + size * 0.62, color: color, alpha: 0.66)
|
|
112
|
+
rescue ArgumentError, TypeError
|
|
113
|
+
nil
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def render_waveform_layer(canvas, layer, audio, color)
|
|
117
|
+
params = Hash(layer[:params] || layer["params"] || {})
|
|
118
|
+
amplitude = clamp(audio[:amplitude])
|
|
119
|
+
style = (params[:style] || params["style"] || "line").to_s
|
|
120
|
+
height_scale = normalize_waveform_height(params)
|
|
121
|
+
alpha = 0.45 + amplitude * 0.35
|
|
122
|
+
y_base = height * 0.5
|
|
123
|
+
|
|
124
|
+
canvas.draw_wave(y_base, amplitude: amplitude, color: color, alpha: alpha, height_scale: height_scale)
|
|
125
|
+
return unless %w[mirror ribbon].include?(style)
|
|
126
|
+
|
|
127
|
+
canvas.draw_wave(y_base, amplitude: amplitude, color: color, alpha: alpha * 0.68, height_scale: -height_scale)
|
|
128
|
+
rescue ArgumentError, TypeError
|
|
129
|
+
nil
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def render_spectrogram_layer(canvas, layer, audio, color)
|
|
133
|
+
params = Hash(layer[:params] || layer["params"] || {})
|
|
134
|
+
fft = Array(audio[:fft] || audio["fft"])
|
|
135
|
+
bins = [[Integer(params[:bins] || params["bins"] || 32), 8].max, 96].min
|
|
136
|
+
gain = Float(params[:gain] || params["gain"] || 1).clamp(0.1, 8.0)
|
|
137
|
+
band_width = width.to_f / bins
|
|
138
|
+
rows = 18
|
|
139
|
+
row_height = height * 0.5 / rows
|
|
140
|
+
top = height * 0.22
|
|
141
|
+
|
|
142
|
+
rows.times do |row|
|
|
143
|
+
age = row.to_f / [rows - 1, 1].max
|
|
144
|
+
bins.times do |bin|
|
|
145
|
+
value = clamp(Float(fft[bin % [fft.length, 1].max] || 0) * gain)
|
|
146
|
+
alpha = (0.08 + value * 0.52) * (1.0 - age * 0.62)
|
|
147
|
+
x = bin * band_width
|
|
148
|
+
y = top + row * row_height
|
|
149
|
+
canvas.fill_rect(x, y, band_width.ceil + 1, row_height.ceil + 1, color, alpha: alpha)
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
rescue ArgumentError, TypeError
|
|
153
|
+
nil
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def render_shape_layer(canvas, layer, audio, color)
|
|
157
|
+
params = Hash(layer[:params] || layer["params"] || {})
|
|
158
|
+
shapes = Array(params[:shapes] || params["shapes"])
|
|
159
|
+
pulse = clamp(audio[:beat_pulse])
|
|
160
|
+
alpha = 0.58 + pulse * 0.24
|
|
161
|
+
|
|
162
|
+
shapes.each do |shape|
|
|
163
|
+
shape_hash = Hash(shape)
|
|
164
|
+
case (shape_hash[:kind] || shape_hash["kind"]).to_s
|
|
165
|
+
when "circle"
|
|
166
|
+
render_circle_shape(canvas, shape_hash, color, alpha)
|
|
167
|
+
when "line"
|
|
168
|
+
render_line_shape(canvas, shape_hash, color, alpha)
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
rescue ArgumentError, TypeError
|
|
172
|
+
nil
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def render_circle_shape(canvas, shape, color, alpha)
|
|
176
|
+
count = [[Integer(shape[:count] || shape["count"] || 1), 1].max, 32].min
|
|
177
|
+
radius = Float(shape[:radius] || shape["radius"] || 100).abs
|
|
178
|
+
x = Float(shape[:x] || shape["x"] || width * 0.5)
|
|
179
|
+
y = Float(shape[:y] || shape["y"] || height * 0.5)
|
|
180
|
+
x = width * 0.5 if x.abs <= 1.5
|
|
181
|
+
y = height * 0.5 if y.abs <= 1.5
|
|
182
|
+
|
|
183
|
+
count.times do |index|
|
|
184
|
+
canvas.draw_circle_outline(x, y, radius * ((index + 1).to_f / count), color, alpha: alpha)
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def render_line_shape(canvas, shape, color, alpha)
|
|
189
|
+
x1 = Float(shape[:x1] || shape["x1"] || width * 0.2)
|
|
190
|
+
y1 = Float(shape[:y1] || shape["y1"] || height * 0.5)
|
|
191
|
+
x2 = Float(shape[:x2] || shape["x2"] || width * 0.8)
|
|
192
|
+
y2 = Float(shape[:y2] || shape["y2"] || height * 0.5)
|
|
193
|
+
canvas.draw_line(x1, y1, x2, y2, color, alpha: alpha)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def render_mesh_layer(canvas, layer, audio, color, index)
|
|
197
|
+
params = Hash(layer[:params] || layer["params"] || {})
|
|
198
|
+
amplitude = clamp(audio[:amplitude])
|
|
199
|
+
high = audio.dig(:bands, :high) || audio.dig("bands", "high")
|
|
200
|
+
deform = clamp(params[:deform] || params["deform"] || high || amplitude)
|
|
201
|
+
scale = Float(params[:scale] || params["scale"] || 1).clamp(0.1, 3.0)
|
|
202
|
+
radius = [width, height].min * (0.20 + amplitude * 0.08 + deform * 0.08) * scale
|
|
203
|
+
cx = width * (0.5 + (index - 1) * 0.06)
|
|
204
|
+
cy = height * 0.48
|
|
205
|
+
top = [cx, cy - radius * 0.62]
|
|
206
|
+
bottom = [cx, cy + radius * 0.62]
|
|
207
|
+
ring = 6.times.map do |point_index|
|
|
208
|
+
angle = (point_index.to_f / 6) * Math::PI * 2 + Math::PI / 6
|
|
209
|
+
[cx + Math.cos(angle) * radius * 0.72, cy + Math.sin(angle) * radius * 0.36]
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
ring.each_with_index do |point, point_index|
|
|
213
|
+
next_point = ring[(point_index + 1) % ring.length]
|
|
214
|
+
canvas.draw_line(point[0], point[1], next_point[0], next_point[1], color, alpha: 0.62)
|
|
215
|
+
canvas.draw_line(top[0], top[1], point[0], point[1], color, alpha: 0.54)
|
|
216
|
+
canvas.draw_line(bottom[0], bottom[1], next_point[0], next_point[1], color, alpha: 0.42)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
3.times do |point_index|
|
|
220
|
+
from = ring[point_index]
|
|
221
|
+
to = ring[point_index + 3]
|
|
222
|
+
canvas.draw_line(from[0], from[1], to[0], to[1], color, alpha: 0.32 + deform * 0.2)
|
|
223
|
+
end
|
|
224
|
+
rescue ArgumentError, TypeError
|
|
225
|
+
nil
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def render_geometry_layer(canvas, audio, color, index)
|
|
229
|
+
amplitude = clamp(audio[:amplitude])
|
|
230
|
+
size = [width, height].min * (0.22 + amplitude * 0.18)
|
|
231
|
+
cx = width * (0.5 + (index - 1) * 0.08)
|
|
232
|
+
cy = height * 0.48
|
|
233
|
+
offset = size * 0.24
|
|
234
|
+
canvas.draw_rect_outline(cx - size / 2, cy - size / 2, size, size, color, alpha: 0.78)
|
|
235
|
+
canvas.draw_rect_outline(cx - size / 2 + offset, cy - size / 2 - offset, size, size, color, alpha: 0.46)
|
|
236
|
+
4.times do |corner|
|
|
237
|
+
x1 = cx - size / 2 + (corner.even? ? 0 : size)
|
|
238
|
+
y1 = cy - size / 2 + (corner < 2 ? 0 : size)
|
|
239
|
+
canvas.draw_line(x1, y1, x1 + offset, y1 - offset, color, alpha: 0.55)
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def background_top(audio)
|
|
244
|
+
amplitude = clamp(audio[:amplitude])
|
|
245
|
+
high = clamp(audio.dig(:bands, :high))
|
|
246
|
+
[4 + (amplitude * 22).round, 10 + (high * 38).round, 24 + (amplitude * 34).round]
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def background_bottom(audio)
|
|
250
|
+
low = clamp(audio.dig(:bands, :low))
|
|
251
|
+
[1 + (low * 30).round, 4 + (low * 18).round, 12 + (low * 44).round]
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def layer_color(layer, audio, index)
|
|
255
|
+
base = configured_layer_color(layer, index) || PALETTE[index % PALETTE.length]
|
|
256
|
+
beat = clamp(audio[:beat_pulse])
|
|
257
|
+
name_factor = (layer[:shader] || layer["shader"] || layer[:name] || layer["name"]).to_s.bytes.sum % 38
|
|
258
|
+
base.map { |value| [[value + name_factor + (beat * 30).round, 255].min, 0].max }
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def configured_layer_color(layer, index)
|
|
262
|
+
params = Hash(layer[:params] || layer["params"] || {})
|
|
263
|
+
color = configured_color(params) || palette_color(params, index)
|
|
264
|
+
parse_hex_color(color)
|
|
265
|
+
rescue StandardError
|
|
266
|
+
nil
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def configured_color(params)
|
|
270
|
+
[params[:color], params["color"]].map { |value| value.to_s.strip }.find { |value| !value.empty? }
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def palette_color(params, index)
|
|
274
|
+
palette = Array(params[:palette] || params["palette"]).map { |color| color.to_s.strip }.reject(&:empty?)
|
|
275
|
+
return nil if palette.empty?
|
|
276
|
+
|
|
277
|
+
palette[index % palette.length]
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def parse_hex_color(value)
|
|
281
|
+
match = value.to_s.strip.match(/\A#(?<hex>[0-9a-fA-F]{3}|[0-9a-fA-F]{6})\z/)
|
|
282
|
+
return nil unless match
|
|
283
|
+
|
|
284
|
+
hex = match[:hex]
|
|
285
|
+
hex = hex.chars.map { |char| "#{char}#{char}" }.join if hex.length == 3
|
|
286
|
+
[hex[0, 2], hex[2, 2], hex[4, 2]].map { |component| component.to_i(16) }
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def default_layer
|
|
290
|
+
{ type: "geometry", name: "snapshot" }
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def normalize_dimension(value)
|
|
294
|
+
Integer(value).clamp(64, 4096)
|
|
295
|
+
rescue ArgumentError, TypeError
|
|
296
|
+
DEFAULT_WIDTH
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def clamp(value)
|
|
300
|
+
Float(value || 0).clamp(0.0, 1.0)
|
|
301
|
+
rescue ArgumentError, TypeError
|
|
302
|
+
0.0
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def normalize_letter_spacing(params)
|
|
306
|
+
Float(params[:letter_spacing] || params["letter_spacing"] || 0).clamp(0.0, 96.0)
|
|
307
|
+
rescue ArgumentError, TypeError
|
|
308
|
+
0.0
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def normalize_waveform_height(params)
|
|
312
|
+
Float(params[:height] || params["height"] || 0.46).clamp(0.05, 1.1)
|
|
313
|
+
rescue ArgumentError, TypeError
|
|
314
|
+
0.46
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# Tiny RGBA canvas with alpha blending and a few primitive drawing helpers.
|
|
318
|
+
class Canvas
|
|
319
|
+
def initialize(width:, height:)
|
|
320
|
+
@width = width
|
|
321
|
+
@height = height
|
|
322
|
+
@bytes = String.new(capacity: width * height * 4, encoding: Encoding::BINARY)
|
|
323
|
+
@bytes << ([0, 0, 0, 255].pack("C4") * (width * height))
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
attr_reader :width, :height, :bytes
|
|
327
|
+
|
|
328
|
+
def fill_gradient(top, bottom)
|
|
329
|
+
height.times do |y|
|
|
330
|
+
t = y.to_f / [height - 1, 1].max
|
|
331
|
+
color = 3.times.map { |index| interpolate(top[index], bottom[index], t).round }
|
|
332
|
+
width.times { |x| set_pixel(x, y, color, 255) }
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def draw_wave(y_base, amplitude:, color:, alpha:, height_scale: 1.0)
|
|
337
|
+
previous = nil
|
|
338
|
+
width.times do |x|
|
|
339
|
+
phase = (x.to_f / width) * Math::PI * 4.0
|
|
340
|
+
y = y_base + Math.sin(phase) * height * (0.06 + amplitude * 0.08) * height_scale
|
|
341
|
+
draw_line(previous[0], previous[1], x, y, color, alpha: alpha) if previous
|
|
342
|
+
previous = [x, y]
|
|
343
|
+
end
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def draw_rect_outline(x, y, rect_width, rect_height, color, alpha:)
|
|
347
|
+
draw_line(x, y, x + rect_width, y, color, alpha: alpha)
|
|
348
|
+
draw_line(x + rect_width, y, x + rect_width, y + rect_height, color, alpha: alpha)
|
|
349
|
+
draw_line(x + rect_width, y + rect_height, x, y + rect_height, color, alpha: alpha)
|
|
350
|
+
draw_line(x, y + rect_height, x, y, color, alpha: alpha)
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def draw_circle_outline(cx, cy, radius, color, alpha:)
|
|
354
|
+
segments = 96
|
|
355
|
+
previous = nil
|
|
356
|
+
(0..segments).each do |index|
|
|
357
|
+
angle = (index.to_f / segments) * Math::PI * 2
|
|
358
|
+
point = [cx + Math.cos(angle) * radius, cy + Math.sin(angle) * radius]
|
|
359
|
+
draw_line(previous[0], previous[1], point[0], point[1], color, alpha: alpha) if previous
|
|
360
|
+
previous = point
|
|
361
|
+
end
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
def fill_rect(x, y, rect_width, rect_height, color, alpha:)
|
|
365
|
+
start_x = x.round
|
|
366
|
+
end_x = (x + rect_width).round
|
|
367
|
+
start_y = y.round
|
|
368
|
+
end_y = (y + rect_height).round
|
|
369
|
+
start_y.upto(end_y) do |py|
|
|
370
|
+
start_x.upto(end_x) { |px| blend_pixel(px, py, color, alpha) }
|
|
371
|
+
end
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
def draw_line(x1, y1, x2, y2, color, alpha:)
|
|
375
|
+
x1 = x1.round
|
|
376
|
+
y1 = y1.round
|
|
377
|
+
x2 = x2.round
|
|
378
|
+
y2 = y2.round
|
|
379
|
+
steps = [(x2 - x1).abs, (y2 - y1).abs].max
|
|
380
|
+
return blend_pixel(x1, y1, color, alpha) if steps.zero?
|
|
381
|
+
|
|
382
|
+
steps.times do |step|
|
|
383
|
+
t = step.to_f / steps
|
|
384
|
+
blend_pixel(interpolate(x1, x2, t).round, interpolate(y1, y2, t).round, color, alpha)
|
|
385
|
+
end
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
def fill_circle(cx, cy, radius, color, alpha:)
|
|
389
|
+
cx = cx.round
|
|
390
|
+
cy = cy.round
|
|
391
|
+
radius = radius.round
|
|
392
|
+
(cy - radius).upto(cy + radius) do |y|
|
|
393
|
+
(cx - radius).upto(cx + radius) do |x|
|
|
394
|
+
next if ((x - cx)**2) + ((y - cy)**2) > radius**2
|
|
395
|
+
|
|
396
|
+
blend_pixel(x, y, color, alpha)
|
|
397
|
+
end
|
|
398
|
+
end
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
def draw_label(text, x:, y:, color:, alpha:, letter_spacing: 0.0)
|
|
402
|
+
chars = text.each_byte.first(24)
|
|
403
|
+
char_width = 14 + Float(letter_spacing).clamp(0.0, 96.0).round
|
|
404
|
+
total_width = chars.length * char_width
|
|
405
|
+
start_x = x.round - total_width / 2
|
|
406
|
+
chars.each_with_index do |byte, index|
|
|
407
|
+
height_factor = 0.35 + (byte % 9) * 0.07
|
|
408
|
+
fill_bar(start_x + index * char_width, y.round, 9, (42 * height_factor).round, color, alpha)
|
|
409
|
+
end
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
private
|
|
413
|
+
|
|
414
|
+
def fill_bar(x, baseline, bar_width, bar_height, color, alpha)
|
|
415
|
+
(baseline - bar_height).upto(baseline) do |y|
|
|
416
|
+
x.upto(x + bar_width) { |px| blend_pixel(px, y, color, alpha) }
|
|
417
|
+
end
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
def blend_pixel(x, y, color, alpha)
|
|
421
|
+
return if x.negative? || y.negative? || x >= width || y >= height
|
|
422
|
+
|
|
423
|
+
offset = ((y * width) + x) * 4
|
|
424
|
+
amount = Float(alpha).clamp(0.0, 1.0)
|
|
425
|
+
3.times do |index|
|
|
426
|
+
current = bytes.getbyte(offset + index)
|
|
427
|
+
bytes.setbyte(offset + index, interpolate(current, color[index], amount).round)
|
|
428
|
+
end
|
|
429
|
+
bytes.setbyte(offset + 3, 255)
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
def set_pixel(x, y, color, alpha)
|
|
433
|
+
offset = ((y * width) + x) * 4
|
|
434
|
+
bytes.setbyte(offset, color[0])
|
|
435
|
+
bytes.setbyte(offset + 1, color[1])
|
|
436
|
+
bytes.setbyte(offset + 2, color[2])
|
|
437
|
+
bytes.setbyte(offset + 3, alpha)
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
def interpolate(from, to, amount)
|
|
441
|
+
from + (to - from) * amount
|
|
442
|
+
end
|
|
443
|
+
end
|
|
444
|
+
end
|
|
445
|
+
end
|
|
446
|
+
end
|
data/lib/vizcore/renderer.rb
CHANGED
|
@@ -7,4 +7,9 @@ module Vizcore
|
|
|
7
7
|
end
|
|
8
8
|
|
|
9
9
|
require_relative "renderer/frame_scheduler"
|
|
10
|
+
require_relative "renderer/png_writer"
|
|
11
|
+
require_relative "renderer/scene_frame_source"
|
|
12
|
+
require_relative "renderer/render_sequence"
|
|
10
13
|
require_relative "renderer/scene_serializer"
|
|
14
|
+
require_relative "renderer/snapshot"
|
|
15
|
+
require_relative "renderer/snapshot_renderer"
|
|
@@ -23,6 +23,10 @@ module Vizcore
|
|
|
23
23
|
# @param scene_catalog [Array<Hash>, nil]
|
|
24
24
|
# @param transitions [Array<Hash>, nil]
|
|
25
25
|
# @param transition_controller [Vizcore::DSL::TransitionController, nil]
|
|
26
|
+
# @param noise_gate [Numeric]
|
|
27
|
+
# @param audio_normalize [Hash, nil]
|
|
28
|
+
# @param bpm [Numeric, nil]
|
|
29
|
+
# @param bpm_lock [Boolean]
|
|
26
30
|
# @param error_reporter [#call, nil]
|
|
27
31
|
def initialize(
|
|
28
32
|
scene_name: "basic",
|
|
@@ -35,6 +39,10 @@ module Vizcore
|
|
|
35
39
|
scene_catalog: nil,
|
|
36
40
|
transitions: nil,
|
|
37
41
|
transition_controller: nil,
|
|
42
|
+
noise_gate: Vizcore::Analysis::Pipeline::DEFAULT_NOISE_GATE,
|
|
43
|
+
audio_normalize: nil,
|
|
44
|
+
bpm: nil,
|
|
45
|
+
bpm_lock: false,
|
|
38
46
|
error_reporter: nil
|
|
39
47
|
)
|
|
40
48
|
@scene_name = scene_name
|
|
@@ -44,7 +52,11 @@ module Vizcore
|
|
|
44
52
|
fft_size = supported_fft_size(@input_manager.frame_size)
|
|
45
53
|
@analysis_pipeline = analysis_pipeline || Vizcore::Analysis::Pipeline.new(
|
|
46
54
|
sample_rate: @input_manager.sample_rate,
|
|
47
|
-
fft_size: fft_size
|
|
55
|
+
fft_size: fft_size,
|
|
56
|
+
noise_gate: noise_gate,
|
|
57
|
+
audio_normalize: audio_normalize,
|
|
58
|
+
bpm: bpm,
|
|
59
|
+
bpm_lock: bpm_lock
|
|
48
60
|
)
|
|
49
61
|
@mapping_resolver = mapping_resolver || Vizcore::DSL::MappingResolver.new
|
|
50
62
|
@scene_serializer = scene_serializer || Vizcore::Renderer::SceneSerializer.new
|
|
@@ -57,6 +69,7 @@ module Vizcore
|
|
|
57
69
|
@frame_count = 0
|
|
58
70
|
@transport_playing = initial_transport_playing_state
|
|
59
71
|
reset_transition_trigger_counters!
|
|
72
|
+
@tap_tempo = Vizcore::Analysis::TapTempo.new
|
|
60
73
|
@frame_scheduler = frame_scheduler || Vizcore::Renderer::FrameScheduler.new(frame_rate: FRAME_RATE) do |elapsed|
|
|
61
74
|
tick(elapsed)
|
|
62
75
|
end
|
|
@@ -148,6 +161,55 @@ module Vizcore
|
|
|
148
161
|
end
|
|
149
162
|
end
|
|
150
163
|
|
|
164
|
+
# Replace audio analysis settings after scene hot reload.
|
|
165
|
+
#
|
|
166
|
+
# @param audio_normalize [Hash, nil]
|
|
167
|
+
# @param bpm [Numeric, nil]
|
|
168
|
+
# @param bpm_lock [Boolean]
|
|
169
|
+
# @return [void]
|
|
170
|
+
def update_analysis_settings(audio_normalize:, bpm: nil, bpm_lock: false)
|
|
171
|
+
return unless @analysis_pipeline.respond_to?(:audio_normalize=)
|
|
172
|
+
|
|
173
|
+
@analysis_pipeline.audio_normalize = audio_normalize
|
|
174
|
+
@analysis_pipeline.bpm_lock = { bpm: bpm, locked: bpm_lock } if @analysis_pipeline.respond_to?(:bpm_lock=)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Apply a manual tap tempo event and lock analysis BPM when enough taps exist.
|
|
178
|
+
#
|
|
179
|
+
# @param timestamp_ms [Numeric]
|
|
180
|
+
# @return [Float, nil]
|
|
181
|
+
def tap_tempo(timestamp_ms:)
|
|
182
|
+
bpm = @tap_tempo.tap(timestamp_ms: timestamp_ms)
|
|
183
|
+
return nil unless bpm
|
|
184
|
+
return bpm unless @analysis_pipeline.respond_to?(:bpm_lock=)
|
|
185
|
+
|
|
186
|
+
@analysis_pipeline.bpm_lock = { bpm: bpm, locked: true }
|
|
187
|
+
bpm
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Lock analysis BPM from an external sync source.
|
|
191
|
+
#
|
|
192
|
+
# @param bpm [Numeric]
|
|
193
|
+
# @return [Float, nil]
|
|
194
|
+
def lock_bpm(bpm)
|
|
195
|
+
numeric = Float(bpm)
|
|
196
|
+
return nil unless numeric.finite? && numeric.positive?
|
|
197
|
+
return numeric unless @analysis_pipeline.respond_to?(:bpm_lock=)
|
|
198
|
+
|
|
199
|
+
@analysis_pipeline.bpm_lock = { bpm: numeric, locked: true }
|
|
200
|
+
numeric
|
|
201
|
+
rescue ArgumentError, TypeError
|
|
202
|
+
nil
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Unlock analysis BPM after an external sync lock.
|
|
206
|
+
#
|
|
207
|
+
# @return [Boolean]
|
|
208
|
+
def unlock_bpm
|
|
209
|
+
@analysis_pipeline.bpm_lock = { bpm: nil, locked: false } if @analysis_pipeline.respond_to?(:bpm_lock=)
|
|
210
|
+
true
|
|
211
|
+
end
|
|
212
|
+
|
|
151
213
|
# Build one frame payload for transport to frontend.
|
|
152
214
|
#
|
|
153
215
|
# @param _elapsed_seconds [Float]
|
|
@@ -155,17 +217,25 @@ module Vizcore
|
|
|
155
217
|
# @raise [Vizcore::FrameBuildError] when frame construction fails
|
|
156
218
|
# @return [Hash]
|
|
157
219
|
def build_frame(_elapsed_seconds, samples = nil)
|
|
158
|
-
|
|
159
|
-
|
|
220
|
+
started_at_ms = monotonic_ms
|
|
221
|
+
audio_samples, audio_capture_ms = capture_or_use_samples(samples)
|
|
222
|
+
analyzed, audio_analysis_ms = measure_ms { @analysis_pipeline.call(audio_samples) }
|
|
160
223
|
scene = current_scene
|
|
161
|
-
layers = build_scene_layers(scene[:layers], analyzed)
|
|
224
|
+
layers, scene_build_ms = measure_ms { build_scene_layers(scene[:layers], analyzed) }
|
|
162
225
|
|
|
163
226
|
@scene_serializer.audio_frame(
|
|
164
227
|
timestamp: Time.now.to_f,
|
|
165
228
|
audio: analyzed,
|
|
166
229
|
scene_name: scene[:name],
|
|
167
230
|
scene_layers: layers,
|
|
168
|
-
transition: nil
|
|
231
|
+
transition: nil,
|
|
232
|
+
metrics: {
|
|
233
|
+
frame_id: @frame_count,
|
|
234
|
+
audio_capture_ms: audio_capture_ms,
|
|
235
|
+
audio_analysis_ms: audio_analysis_ms,
|
|
236
|
+
scene_build_ms: scene_build_ms,
|
|
237
|
+
server_frame_ms: monotonic_ms - started_at_ms
|
|
238
|
+
}
|
|
169
239
|
)
|
|
170
240
|
rescue StandardError => e
|
|
171
241
|
report_error(e, context: "frame build failed")
|
|
@@ -174,6 +244,22 @@ module Vizcore
|
|
|
174
244
|
|
|
175
245
|
private
|
|
176
246
|
|
|
247
|
+
def capture_or_use_samples(samples)
|
|
248
|
+
return [samples, 0.0] if samples
|
|
249
|
+
|
|
250
|
+
measure_ms { capture_samples }
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def measure_ms
|
|
254
|
+
started_at = monotonic_ms
|
|
255
|
+
result = yield
|
|
256
|
+
[result, monotonic_ms - started_at]
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def monotonic_ms
|
|
260
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond)
|
|
261
|
+
end
|
|
262
|
+
|
|
177
263
|
def capture_samples
|
|
178
264
|
ingest_count =
|
|
179
265
|
if @input_manager.respond_to?(:realtime_capture_size)
|