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
|
@@ -17,8 +17,13 @@ module Vizcore
|
|
|
17
17
|
uniform float u_bass;
|
|
18
18
|
uniform float u_mid;
|
|
19
19
|
uniform float u_high;
|
|
20
|
+
uniform float u_bass_peak;
|
|
21
|
+
uniform float u_mid_peak;
|
|
22
|
+
uniform float u_high_peak;
|
|
20
23
|
uniform float u_beat;
|
|
21
24
|
uniform float u_beat_pulse;
|
|
25
|
+
uniform float u_beat_phase;
|
|
26
|
+
uniform float u_bar_phase;
|
|
22
27
|
uniform float u_onset;
|
|
23
28
|
uniform float u_kick;
|
|
24
29
|
uniform float u_bpm;
|
|
@@ -29,9 +34,9 @@ module Vizcore
|
|
|
29
34
|
|
|
30
35
|
void main() {
|
|
31
36
|
vec2 uv = gl_FragCoord.xy / u_resolution.xy;
|
|
32
|
-
float wave = 0.5 + 0.5 * sin((uv.x + u_time * 0.12) * 12.0 + u_bass * 4.0);
|
|
37
|
+
float wave = 0.5 + 0.5 * sin((uv.x + u_time * 0.12 + u_bar_phase * 0.2) * 12.0 + u_bass * 4.0);
|
|
33
38
|
vec3 color = mix(vec3(0.02, 0.06, 0.12), vec3(0.1, 0.75, 0.95), wave);
|
|
34
|
-
color += vec3(0.95, 0.16, 0.32) * (u_beat_pulse * 0.35 + u_onset * 0.2 + u_kick * 0.25 +
|
|
39
|
+
color += vec3(0.95, 0.16, 0.32) * (u_beat_pulse * 0.35 + u_onset * 0.2 + u_kick * 0.25 + u_high_peak * 0.2);
|
|
35
40
|
color *= 0.35 + u_amplitude * 1.8;
|
|
36
41
|
outColor = vec4(color, 1.0);
|
|
37
42
|
}
|
|
@@ -13,8 +13,19 @@ module Vizcore
|
|
|
13
13
|
Uniform.new(name: "u_bass", type: "float", description: "Low-frequency band level."),
|
|
14
14
|
Uniform.new(name: "u_mid", type: "float", description: "Mid-frequency band level."),
|
|
15
15
|
Uniform.new(name: "u_high", type: "float", description: "High-frequency band level."),
|
|
16
|
+
Uniform.new(name: "u_bass_peak", type: "float", description: "Held low-frequency band peak."),
|
|
17
|
+
Uniform.new(name: "u_mid_peak", type: "float", description: "Held mid-frequency band peak."),
|
|
18
|
+
Uniform.new(name: "u_high_peak", type: "float", description: "Held high-frequency band peak."),
|
|
16
19
|
Uniform.new(name: "u_beat", type: "float", description: "1.0 on detected beat frames, otherwise 0.0."),
|
|
17
20
|
Uniform.new(name: "u_beat_pulse", type: "float", description: "Decaying beat pulse after detection."),
|
|
21
|
+
Uniform.new(name: "u_beat_phase", type: "float", description: "0.0..1.0 phase inside the current beat."),
|
|
22
|
+
Uniform.new(name: "u_bar_phase", type: "float", description: "0.0..1.0 phase inside the current 4-beat bar."),
|
|
23
|
+
Uniform.new(name: "u_bar_count", type: "float", description: "Completed 4-beat bars since analysis start."),
|
|
24
|
+
Uniform.new(name: "u_phrase_count", type: "float", description: "Completed 8-bar phrases since analysis start."),
|
|
25
|
+
Uniform.new(name: "u_beat_2", type: "float", description: "Half-beat subdivision pulse."),
|
|
26
|
+
Uniform.new(name: "u_beat_4", type: "float", description: "Quarter-beat subdivision pulse."),
|
|
27
|
+
Uniform.new(name: "u_beat_8", type: "float", description: "Eighth-beat subdivision pulse."),
|
|
28
|
+
Uniform.new(name: "u_beat_triplet", type: "float", description: "Triplet subdivision pulse."),
|
|
18
29
|
Uniform.new(name: "u_onset", type: "float", description: "Positive amplitude rise since the previous active frame."),
|
|
19
30
|
Uniform.new(name: "u_sub_onset", type: "float", description: "Positive sub-band rise since the previous active frame."),
|
|
20
31
|
Uniform.new(name: "u_low_onset", type: "float", description: "Positive low-band rise since the previous active frame."),
|
data/lib/vizcore/cli.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "fileutils"
|
|
4
|
+
require "json"
|
|
4
5
|
require "net/http"
|
|
5
6
|
require "pathname"
|
|
6
7
|
require "thor"
|
|
@@ -10,11 +11,13 @@ require_relative "audio"
|
|
|
10
11
|
require_relative "cli/doctor"
|
|
11
12
|
require_relative "cli/dsl_reference"
|
|
12
13
|
require_relative "cli/layer_docs"
|
|
14
|
+
require_relative "cli/plugin_checker"
|
|
13
15
|
require_relative "cli/scene_diagnostics"
|
|
14
16
|
require_relative "cli/shader_template"
|
|
15
17
|
require_relative "cli/shader_uniform_docs"
|
|
16
18
|
require_relative "config"
|
|
17
19
|
require_relative "project_manifest"
|
|
20
|
+
require_relative "scene_trust"
|
|
18
21
|
require_relative "server"
|
|
19
22
|
|
|
20
23
|
module Vizcore
|
|
@@ -120,8 +123,12 @@ module Vizcore
|
|
|
120
123
|
option :bpm, type: :numeric, desc: "Fixed BPM value used with --bpm-lock"
|
|
121
124
|
option :bpm_lock, type: :boolean, default: false, desc: "Lock analysis BPM output to --bpm"
|
|
122
125
|
option :osc_port, type: :numeric, desc: "UDP port for OSC sync (/vizcore/scene, /vizcore/tap)"
|
|
126
|
+
option :scene_switch_effect, type: :string, desc: "Transition effect name for manual scene switch actions"
|
|
127
|
+
option :scene_switch_duration, type: :numeric, desc: "Duration in seconds for manual scene switch effects"
|
|
123
128
|
option :reload, type: :boolean, default: Config::DEFAULT_RELOAD, desc: "Reload the scene file when it changes"
|
|
124
129
|
option :projector, type: :boolean, default: false, desc: "Hide browser operator UI for projection output"
|
|
130
|
+
option :allow_public_control, type: :boolean, default: false, desc: "Allow control panel/WebSocket when binding to a public host"
|
|
131
|
+
option :trust, type: :boolean, default: false, desc: "Suppress Ruby scene execution safety warning"
|
|
125
132
|
# Start the Vizcore server with the given scene file.
|
|
126
133
|
#
|
|
127
134
|
# @param scene_file [String] path to a Ruby scene DSL file
|
|
@@ -146,10 +153,14 @@ module Vizcore
|
|
|
146
153
|
bpm: options[:bpm],
|
|
147
154
|
bpm_lock: options.fetch(:bpm_lock),
|
|
148
155
|
osc_port: options[:osc_port] || defaults[:osc_port],
|
|
156
|
+
scene_switch_effect: options[:scene_switch_effect] || defaults[:scene_switch_effect],
|
|
157
|
+
scene_switch_effect_duration: options[:scene_switch_duration] || defaults[:scene_switch_effect_duration],
|
|
149
158
|
reload: options.fetch(:reload),
|
|
150
|
-
projector_mode: options.fetch(:projector)
|
|
159
|
+
projector_mode: options.fetch(:projector),
|
|
160
|
+
allow_public_control: options.fetch(:allow_public_control)
|
|
151
161
|
)
|
|
152
|
-
|
|
162
|
+
warn_untrusted_scene(config.scene_file, project_root: manifest&.root || Dir.pwd) unless options.fetch(:trust)
|
|
163
|
+
Server::Runner.new(config, manifest: manifest, initial_profile: profile).run
|
|
153
164
|
rescue ArgumentError => e
|
|
154
165
|
raise Thor::Error, e.message
|
|
155
166
|
end
|
|
@@ -162,7 +173,10 @@ module Vizcore
|
|
|
162
173
|
option :bpm_lock, type: :boolean, default: false, desc: "Lock analysis BPM output to --bpm"
|
|
163
174
|
option :control_preset, type: :string, desc: "Control preset JSON for browser HUD and MIDI learn"
|
|
164
175
|
option :osc_port, type: :numeric, desc: "UDP port for OSC sync (/vizcore/scene, /vizcore/tap)"
|
|
176
|
+
option :scene_switch_effect, type: :string, desc: "Transition effect name for manual scene switch actions"
|
|
177
|
+
option :scene_switch_duration, type: :numeric, desc: "Duration in seconds for manual scene switch effects"
|
|
165
178
|
option :projector, type: :boolean, default: false, desc: "Hide browser operator UI for projection output"
|
|
179
|
+
option :allow_public_control, type: :boolean, default: false, desc: "Allow control panel/WebSocket when binding to a public host"
|
|
166
180
|
# Start a bundled scene with bundled audio for first-run verification.
|
|
167
181
|
#
|
|
168
182
|
# @return [void]
|
|
@@ -178,7 +192,10 @@ module Vizcore
|
|
|
178
192
|
bpm_lock: options.fetch(:bpm_lock),
|
|
179
193
|
control_preset: options[:control_preset],
|
|
180
194
|
osc_port: options[:osc_port],
|
|
181
|
-
|
|
195
|
+
scene_switch_effect: options[:scene_switch_effect],
|
|
196
|
+
scene_switch_effect_duration: options[:scene_switch_duration],
|
|
197
|
+
projector_mode: options.fetch(:projector),
|
|
198
|
+
allow_public_control: options.fetch(:allow_public_control)
|
|
182
199
|
)
|
|
183
200
|
Server::Runner.new(config).run
|
|
184
201
|
rescue ArgumentError => e
|
|
@@ -256,16 +273,76 @@ module Vizcore
|
|
|
256
273
|
raise Thor::Error, "vizcore doctor found required failures" if report.failure?
|
|
257
274
|
end
|
|
258
275
|
|
|
276
|
+
desc "features", "Print optional runtime feature availability"
|
|
277
|
+
option :format, type: :string, default: "text", desc: "Output format: text or json"
|
|
278
|
+
# Print optional dependency feature flags for automation and doctor-style checks.
|
|
279
|
+
#
|
|
280
|
+
# @raise [Thor::Error] when the output format is unsupported
|
|
281
|
+
# @return [void]
|
|
282
|
+
def features
|
|
283
|
+
payload = Vizcore.features
|
|
284
|
+
case options.fetch(:format).to_s
|
|
285
|
+
when "json"
|
|
286
|
+
say(JSON.pretty_generate(payload.transform_keys(&:to_s)))
|
|
287
|
+
when "text"
|
|
288
|
+
payload.each do |name, available|
|
|
289
|
+
say("#{available ? '[ok]' : '[warn]'} #{name}: #{available ? 'available' : 'unavailable'}")
|
|
290
|
+
end
|
|
291
|
+
else
|
|
292
|
+
raise Thor::Error, "unsupported features format: #{options.fetch(:format)}"
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
desc "calibrate COMMAND", "Measure input levels for audio calibration"
|
|
297
|
+
option :audio_source, type: :string, default: "mic", desc: "Audio source: mic, file, dummy"
|
|
298
|
+
option :audio_file, type: :string, desc: "Path to audio file used when --audio-source file"
|
|
299
|
+
option :audio_device, type: :string, desc: "Audio input device index or name used when --audio-source mic"
|
|
300
|
+
option :duration, type: :numeric, default: Vizcore::Audio::Calibration::DEFAULT_DURATION, desc: "Calibration duration in seconds"
|
|
301
|
+
option :fps, type: :numeric, default: Vizcore::Audio::Calibration::DEFAULT_FPS, desc: "Calibration sampling rate"
|
|
302
|
+
option :format, type: :string, default: "text", desc: "Output format: text or json"
|
|
303
|
+
# Run calibration helpers.
|
|
304
|
+
#
|
|
305
|
+
# @param command [String, nil]
|
|
306
|
+
# @raise [Thor::Error] when arguments are invalid
|
|
307
|
+
# @return [void]
|
|
308
|
+
def calibrate(command = nil)
|
|
309
|
+
raise Thor::Error, "Unknown calibrate command: #{command || '(nil)'}. Use `vizcore calibrate audio`." unless command.to_s == "audio"
|
|
310
|
+
|
|
311
|
+
result = Vizcore::Audio::Calibration.new(
|
|
312
|
+
source: options.fetch(:audio_source),
|
|
313
|
+
file_path: options[:audio_file],
|
|
314
|
+
audio_device: options[:audio_device],
|
|
315
|
+
duration: options.fetch(:duration),
|
|
316
|
+
fps: options.fetch(:fps)
|
|
317
|
+
).call
|
|
318
|
+
print_calibration_result(result, format: options.fetch(:format))
|
|
319
|
+
rescue ArgumentError => e
|
|
320
|
+
raise Thor::Error, e.message
|
|
321
|
+
end
|
|
322
|
+
|
|
259
323
|
map "inspect" => :inspect_scene
|
|
260
324
|
desc "inspect SCENE_FILE", "Print scenes, layers, mappings, and transitions"
|
|
325
|
+
option :format, type: :string, default: "text", desc: "Output format: text or json"
|
|
261
326
|
# Load a scene DSL file and print its runtime structure.
|
|
262
327
|
#
|
|
263
328
|
# @param scene_file [String] path to a Ruby scene DSL file
|
|
264
329
|
# @raise [Thor::Error] when scene loading fails
|
|
265
330
|
# @return [void]
|
|
266
331
|
def inspect_scene(scene_file)
|
|
332
|
+
format = options.fetch(:format).to_s
|
|
267
333
|
diagnostics = Vizcore::CLISupport::SceneDiagnostics.new(scene_file: scene_file)
|
|
268
334
|
result = diagnostics.validate
|
|
335
|
+
if format == "json"
|
|
336
|
+
payload = {
|
|
337
|
+
issues: result.issues.map(&:to_h),
|
|
338
|
+
definition: result.definition ? Vizcore::CLISupport::SceneInspector.new(definition: result.definition).to_h : nil
|
|
339
|
+
}
|
|
340
|
+
say(JSON.pretty_generate(payload))
|
|
341
|
+
raise Thor::Error, "scene inspection failed" unless result.definition
|
|
342
|
+
return
|
|
343
|
+
end
|
|
344
|
+
raise Thor::Error, "unsupported inspect format: #{format}" unless format == "text"
|
|
345
|
+
|
|
269
346
|
print_issues(result.issues)
|
|
270
347
|
raise Thor::Error, "scene inspection failed" unless result.definition
|
|
271
348
|
|
|
@@ -273,13 +350,14 @@ module Vizcore
|
|
|
273
350
|
end
|
|
274
351
|
|
|
275
352
|
desc "validate SCENE_FILE", "Validate a scene DSL file"
|
|
353
|
+
option :strict, type: :boolean, default: false, desc: "Error on unknown layer params and stricter duplicate mappings"
|
|
276
354
|
# Load and validate a scene DSL file without starting the server.
|
|
277
355
|
#
|
|
278
356
|
# @param scene_file [String] path to a Ruby scene DSL file
|
|
279
357
|
# @raise [Thor::Error] when validation fails
|
|
280
358
|
# @return [void]
|
|
281
359
|
def validate(scene_file)
|
|
282
|
-
result = Vizcore::CLISupport::SceneDiagnostics.new(scene_file: scene_file).validate
|
|
360
|
+
result = Vizcore::CLISupport::SceneDiagnostics.new(scene_file: scene_file, strict: options.fetch(:strict)).validate
|
|
283
361
|
print_issues(result.issues)
|
|
284
362
|
raise Thor::Error, "scene validation failed" unless result.valid?
|
|
285
363
|
|
|
@@ -343,8 +421,10 @@ module Vizcore
|
|
|
343
421
|
case command.to_s
|
|
344
422
|
when "new"
|
|
345
423
|
create_plugin_scaffold(name)
|
|
424
|
+
when "check"
|
|
425
|
+
check_plugin_scaffold(name)
|
|
346
426
|
else
|
|
347
|
-
raise Thor::Error, "Unknown plugin command: #{command || '(nil)'}. Use `vizcore plugin new NAME`."
|
|
427
|
+
raise Thor::Error, "Unknown plugin command: #{command || '(nil)'}. Use `vizcore plugin new NAME` or `vizcore plugin check PATH`."
|
|
348
428
|
end
|
|
349
429
|
rescue ArgumentError => e
|
|
350
430
|
raise Thor::Error, e.message
|
|
@@ -357,6 +437,8 @@ module Vizcore
|
|
|
357
437
|
option :wait, type: :numeric, default: 1000, desc: "Milliseconds to wait after page load"
|
|
358
438
|
option :width, type: :numeric, default: 1280, desc: "Browser viewport width"
|
|
359
439
|
option :height, type: :numeric, default: 720, desc: "Browser viewport height"
|
|
440
|
+
option :wait_for_frame, type: :boolean, default: false, desc: "Wait until the Vizcore page receives an audio frame"
|
|
441
|
+
option :frame_timeout, type: :numeric, default: 10_000, desc: "Milliseconds to wait for the first Vizcore frame"
|
|
360
442
|
# Capture browser-rendered output from a running Vizcore server.
|
|
361
443
|
#
|
|
362
444
|
# @param url [String]
|
|
@@ -369,7 +451,9 @@ module Vizcore
|
|
|
369
451
|
selector: options.fetch(:selector),
|
|
370
452
|
wait: options.fetch(:wait),
|
|
371
453
|
width: options.fetch(:width),
|
|
372
|
-
height: options.fetch(:height)
|
|
454
|
+
height: options.fetch(:height),
|
|
455
|
+
wait_for_frame: options.fetch(:wait_for_frame),
|
|
456
|
+
frame_timeout: options.fetch(:frame_timeout)
|
|
373
457
|
)
|
|
374
458
|
end
|
|
375
459
|
|
|
@@ -386,6 +470,10 @@ module Vizcore
|
|
|
386
470
|
option :timeout, type: :numeric, default: 10, desc: "Seconds to wait for the temporary server"
|
|
387
471
|
option :width, type: :numeric, default: 1280, desc: "Browser viewport width"
|
|
388
472
|
option :height, type: :numeric, default: 720, desc: "Browser viewport height"
|
|
473
|
+
option :wait_for_frame, type: :boolean, default: true, desc: "Wait until the projector receives an audio frame"
|
|
474
|
+
option :frame_timeout, type: :numeric, default: 10_000, desc: "Milliseconds to wait for the first projector frame"
|
|
475
|
+
option :allow_public_control, type: :boolean, default: false, desc: "Allow control panel/WebSocket when binding to a public host"
|
|
476
|
+
option :trust, type: :boolean, default: false, desc: "Suppress Ruby scene execution safety warning"
|
|
389
477
|
# Start Vizcore and capture a browser-rendered canvas from the projector route.
|
|
390
478
|
#
|
|
391
479
|
# @param scene_file [String]
|
|
@@ -401,9 +489,11 @@ module Vizcore
|
|
|
401
489
|
feature_file: options[:feature_file],
|
|
402
490
|
control_preset: options[:control_preset],
|
|
403
491
|
reload: false,
|
|
404
|
-
projector_mode: true
|
|
492
|
+
projector_mode: true,
|
|
493
|
+
allow_public_control: options.fetch(:allow_public_control)
|
|
405
494
|
)
|
|
406
495
|
validate_snapshot_config!(config)
|
|
496
|
+
warn_untrusted_scene(config.scene_file) unless options.fetch(:trust)
|
|
407
497
|
|
|
408
498
|
pid = Kernel.spawn(*temporary_server_command(config), out: File::NULL, err: File::NULL)
|
|
409
499
|
begin
|
|
@@ -414,7 +504,9 @@ module Vizcore
|
|
|
414
504
|
selector: options.fetch(:selector),
|
|
415
505
|
wait: options.fetch(:wait),
|
|
416
506
|
width: options.fetch(:width),
|
|
417
|
-
height: options.fetch(:height)
|
|
507
|
+
height: options.fetch(:height),
|
|
508
|
+
wait_for_frame: options.fetch(:wait_for_frame),
|
|
509
|
+
frame_timeout: options.fetch(:frame_timeout)
|
|
418
510
|
)
|
|
419
511
|
ensure
|
|
420
512
|
stop_temporary_server(pid)
|
|
@@ -433,6 +525,8 @@ module Vizcore
|
|
|
433
525
|
option :out, type: :string, default: "snapshot.png", desc: "Output PNG path"
|
|
434
526
|
option :width, type: :numeric, default: Vizcore::Renderer::SnapshotRenderer::DEFAULT_WIDTH, desc: "Snapshot width"
|
|
435
527
|
option :height, type: :numeric, default: Vizcore::Renderer::SnapshotRenderer::DEFAULT_HEIGHT, desc: "Snapshot height"
|
|
528
|
+
option :transparent, type: :boolean, default: false, desc: "Render a transparent PNG background"
|
|
529
|
+
option :trust, type: :boolean, default: false, desc: "Suppress Ruby scene execution safety warning"
|
|
436
530
|
# Load a scene DSL file and write a software-rendered PNG preview.
|
|
437
531
|
#
|
|
438
532
|
# @param scene_file [String] path to a Ruby scene DSL file
|
|
@@ -449,11 +543,13 @@ module Vizcore
|
|
|
449
543
|
bpm_lock: options.fetch(:bpm_lock)
|
|
450
544
|
)
|
|
451
545
|
validate_snapshot_config!(config)
|
|
546
|
+
warn_untrusted_scene(config.scene_file) unless options.fetch(:trust)
|
|
452
547
|
|
|
453
548
|
result = Vizcore::Renderer::Snapshot.new(
|
|
454
549
|
config: config,
|
|
455
550
|
width: options.fetch(:width),
|
|
456
|
-
height: options.fetch(:height)
|
|
551
|
+
height: options.fetch(:height),
|
|
552
|
+
transparent: options.fetch(:transparent)
|
|
457
553
|
).write(out: options.fetch(:out))
|
|
458
554
|
say("Snapshot written: #{result[:path]} (scene=#{result[:scene]}, #{result[:width]}x#{result[:height]})")
|
|
459
555
|
rescue StandardError => e
|
|
@@ -469,15 +565,30 @@ module Vizcore
|
|
|
469
565
|
option :bpm_lock, type: :boolean, default: false, desc: "Lock analysis BPM output to --bpm"
|
|
470
566
|
option :out, type: :string, default: "frames", desc: "Output directory for PNG frames, or .mp4 video path"
|
|
471
567
|
option :frames, type: :numeric, default: Vizcore::Renderer::RenderSequence::DEFAULT_FRAME_COUNT, desc: "Number of frames to write"
|
|
568
|
+
option :duration, type: :numeric, desc: "Render duration in seconds; overrides --frames"
|
|
569
|
+
option :from_frame, type: :numeric, default: 1, desc: "First 1-based frame to write"
|
|
570
|
+
option :to_frame, type: :numeric, desc: "Last 1-based frame to write"
|
|
571
|
+
option :resume, type: :boolean, default: false, desc: "Skip PNG frames that already exist"
|
|
572
|
+
option :seed, type: :numeric, desc: "Deterministic random seed for render"
|
|
573
|
+
option :transparent, type: :boolean, default: false, desc: "Render transparent PNG frames"
|
|
574
|
+
option :progress, type: :boolean, default: false, desc: "Print render progress for long renders"
|
|
575
|
+
option :feature_cache, type: :boolean, default: true, desc: "Reuse and write cached feature analysis for file-based renders"
|
|
576
|
+
option :feature_cache_dir, type: :string, desc: "Directory used to cache recorded analysis features"
|
|
577
|
+
option :codec, type: :string, desc: "ffmpeg video codec for MP4 output"
|
|
578
|
+
option :bitrate, type: :string, desc: "ffmpeg video bitrate for MP4 output"
|
|
579
|
+
option :crf, type: :string, desc: "ffmpeg CRF value for MP4 output"
|
|
580
|
+
option :pix_fmt, type: :string, default: "yuv420p", desc: "ffmpeg pixel format for MP4 output"
|
|
472
581
|
option :fps, type: :numeric, default: Vizcore::Renderer::RenderSequence::DEFAULT_FRAME_RATE, desc: "Render frame rate"
|
|
473
582
|
option :width, type: :numeric, default: Vizcore::Renderer::SnapshotRenderer::DEFAULT_WIDTH, desc: "Frame width"
|
|
474
583
|
option :height, type: :numeric, default: Vizcore::Renderer::SnapshotRenderer::DEFAULT_HEIGHT, desc: "Frame height"
|
|
584
|
+
option :trust, type: :boolean, default: false, desc: "Suppress Ruby scene execution safety warning"
|
|
475
585
|
# Load a scene DSL file and write a software-rendered PNG image sequence or MP4.
|
|
476
586
|
#
|
|
477
587
|
# @param scene_file [String] path to a Ruby scene DSL file
|
|
478
588
|
# @raise [Thor::Error] when scene loading or frame writing fails
|
|
479
589
|
# @return [void]
|
|
480
590
|
def render(scene_file)
|
|
591
|
+
feature_file = resolve_render_feature_cache if feature_cache_enabled?(scene_file: scene_file)
|
|
481
592
|
config = Config.new(
|
|
482
593
|
scene_file: scene_file,
|
|
483
594
|
audio_source: options.fetch(:audio_source),
|
|
@@ -485,16 +596,29 @@ module Vizcore
|
|
|
485
596
|
audio_device: options[:audio_device],
|
|
486
597
|
noise_gate: options.fetch(:noise_gate),
|
|
487
598
|
bpm: options[:bpm],
|
|
488
|
-
bpm_lock: options.fetch(:bpm_lock)
|
|
599
|
+
bpm_lock: options.fetch(:bpm_lock),
|
|
600
|
+
feature_file: feature_file
|
|
489
601
|
)
|
|
490
602
|
validate_snapshot_config!(config)
|
|
603
|
+
warn_untrusted_scene(config.scene_file) unless options.fetch(:trust)
|
|
491
604
|
|
|
492
605
|
result = Vizcore::Renderer::RenderSequence.new(
|
|
493
606
|
config: config,
|
|
494
607
|
frames: options.fetch(:frames),
|
|
495
608
|
fps: options.fetch(:fps),
|
|
496
609
|
width: options.fetch(:width),
|
|
497
|
-
height: options.fetch(:height)
|
|
610
|
+
height: options.fetch(:height),
|
|
611
|
+
duration: options[:duration],
|
|
612
|
+
from_frame: options.fetch(:from_frame),
|
|
613
|
+
to_frame: options[:to_frame],
|
|
614
|
+
resume: options.fetch(:resume),
|
|
615
|
+
seed: options[:seed],
|
|
616
|
+
transparent: options.fetch(:transparent),
|
|
617
|
+
video_codec: options[:codec],
|
|
618
|
+
video_bitrate: options[:bitrate],
|
|
619
|
+
video_crf: options[:crf],
|
|
620
|
+
pixel_format: options[:pix_fmt],
|
|
621
|
+
progress_reporter: render_progress_reporter
|
|
498
622
|
).write(out: options.fetch(:out))
|
|
499
623
|
return say(render_video_message(result)) if result[:format] == :mp4
|
|
500
624
|
|
|
@@ -515,6 +639,8 @@ module Vizcore
|
|
|
515
639
|
option :audio_normalize, type: :boolean, default: false, desc: "Apply adaptive feature normalization"
|
|
516
640
|
option :bpm, type: :numeric, desc: "Fixed BPM value used with --bpm-lock"
|
|
517
641
|
option :bpm_lock, type: :boolean, default: false, desc: "Lock analysis BPM output to --bpm"
|
|
642
|
+
option :cache, type: :boolean, default: true, desc: "Store and reuse recorded feature cache"
|
|
643
|
+
option :cache_dir, type: :string, desc: "Directory for feature cache storage"
|
|
518
644
|
# Analyze an audio file and persist feature frames as JSON.
|
|
519
645
|
#
|
|
520
646
|
# @param audio_file [String] path to WAV/MP3/FLAC audio file
|
|
@@ -528,7 +654,8 @@ module Vizcore
|
|
|
528
654
|
noise_gate: options.fetch(:noise_gate),
|
|
529
655
|
audio_normalize: feature_audio_normalize_setting,
|
|
530
656
|
bpm: options[:bpm],
|
|
531
|
-
bpm_lock: options.fetch(:bpm_lock)
|
|
657
|
+
bpm_lock: options.fetch(:bpm_lock),
|
|
658
|
+
cache_root: feature_record_cache_root
|
|
532
659
|
).write(out: options.fetch(:out))
|
|
533
660
|
say(
|
|
534
661
|
"Features written: #{result[:path]} " \
|
|
@@ -540,6 +667,57 @@ module Vizcore
|
|
|
540
667
|
|
|
541
668
|
private
|
|
542
669
|
|
|
670
|
+
def feature_cache_enabled?(scene_file:)
|
|
671
|
+
return false unless options.fetch(:audio_source) == "file"
|
|
672
|
+
return false unless options[:audio_file]
|
|
673
|
+
return false unless options.fetch(:feature_cache)
|
|
674
|
+
|
|
675
|
+
Pathname.new(scene_file).expand_path.file?
|
|
676
|
+
end
|
|
677
|
+
|
|
678
|
+
def resolve_render_feature_cache
|
|
679
|
+
cache_recorder = Vizcore::Analysis::FeatureRecorder.new(
|
|
680
|
+
audio_file: options[:audio_file],
|
|
681
|
+
frames: rendered_frame_count,
|
|
682
|
+
fps: options.fetch(:fps),
|
|
683
|
+
noise_gate: options.fetch(:noise_gate),
|
|
684
|
+
audio_normalize: nil,
|
|
685
|
+
bpm: options[:bpm],
|
|
686
|
+
bpm_lock: options.fetch(:bpm_lock),
|
|
687
|
+
cache_root: render_feature_cache_root
|
|
688
|
+
)
|
|
689
|
+
cache_path = cache_recorder.cache_path
|
|
690
|
+
return nil unless cache_path
|
|
691
|
+
|
|
692
|
+
cache_recorder.write(out: cache_path.to_s)
|
|
693
|
+
cache_path
|
|
694
|
+
end
|
|
695
|
+
|
|
696
|
+
def render_feature_cache_root
|
|
697
|
+
options[:feature_cache_dir] || default_feature_cache_root
|
|
698
|
+
end
|
|
699
|
+
|
|
700
|
+
def feature_record_cache_root
|
|
701
|
+
return nil unless options.fetch(:cache)
|
|
702
|
+
|
|
703
|
+
options[:cache_dir] || default_feature_cache_root
|
|
704
|
+
end
|
|
705
|
+
|
|
706
|
+
def default_feature_cache_root
|
|
707
|
+
base_path = if (value = ENV["XDG_CACHE_HOME"])
|
|
708
|
+
Pathname.new(value)
|
|
709
|
+
else
|
|
710
|
+
Pathname.new(Dir.home).join(".cache")
|
|
711
|
+
end
|
|
712
|
+
base_path.join("vizcore", "features")
|
|
713
|
+
end
|
|
714
|
+
|
|
715
|
+
def rendered_frame_count
|
|
716
|
+
return (Float(options[:duration]) * Float(options.fetch(:fps))).ceil if options[:duration]
|
|
717
|
+
|
|
718
|
+
Integer(options.fetch(:frames))
|
|
719
|
+
end
|
|
720
|
+
|
|
543
721
|
def status_label(status)
|
|
544
722
|
case status
|
|
545
723
|
when :ok
|
|
@@ -554,7 +732,27 @@ module Vizcore
|
|
|
554
732
|
def print_issues(issues)
|
|
555
733
|
issues.each do |issue|
|
|
556
734
|
label = issue.error? ? "[error]" : "[warn]"
|
|
557
|
-
|
|
735
|
+
code = issue.respond_to?(:code) && issue.code ? " #{issue.code}" : ""
|
|
736
|
+
say("#{label}#{code} #{issue.message}")
|
|
737
|
+
end
|
|
738
|
+
end
|
|
739
|
+
|
|
740
|
+
def warn_untrusted_scene(scene_file, project_root: Dir.pwd)
|
|
741
|
+
warning = Vizcore::SceneTrust.warning_for(scene_file, project_root: project_root)
|
|
742
|
+
warn("[warn] #{warning}") if warning
|
|
743
|
+
end
|
|
744
|
+
|
|
745
|
+
def print_calibration_result(result, format:)
|
|
746
|
+
case format.to_s
|
|
747
|
+
when "json"
|
|
748
|
+
say(JSON.pretty_generate(result.to_h.transform_keys(&:to_s)))
|
|
749
|
+
when "text"
|
|
750
|
+
say("Audio calibration:")
|
|
751
|
+
result.to_h.each do |key, value|
|
|
752
|
+
say(" #{key}: #{value}")
|
|
753
|
+
end
|
|
754
|
+
else
|
|
755
|
+
raise Thor::Error, "unsupported calibration format: #{format}"
|
|
558
756
|
end
|
|
559
757
|
end
|
|
560
758
|
|
|
@@ -625,7 +823,18 @@ module Vizcore
|
|
|
625
823
|
"(scene=#{result[:scene]}, frames=#{result[:frames]}, fps=#{result[:fps]}, #{result[:width]}x#{result[:height]})"
|
|
626
824
|
end
|
|
627
825
|
|
|
628
|
-
def
|
|
826
|
+
def render_progress_reporter
|
|
827
|
+
return nil unless options.fetch(:progress)
|
|
828
|
+
|
|
829
|
+
lambda do |event|
|
|
830
|
+
say(
|
|
831
|
+
"Render progress: frame #{event.fetch(:frame)}/#{event.fetch(:to_frame)} " \
|
|
832
|
+
"(#{event.fetch(:percent).round(1)}%)"
|
|
833
|
+
)
|
|
834
|
+
end
|
|
835
|
+
end
|
|
836
|
+
|
|
837
|
+
def run_browser_capture(url, out:, selector:, wait:, width:, height:, wait_for_frame: false, frame_timeout: 10_000)
|
|
629
838
|
script = Vizcore.root.join("scripts", "browser_capture.mjs")
|
|
630
839
|
command = [
|
|
631
840
|
"node",
|
|
@@ -642,10 +851,43 @@ module Vizcore
|
|
|
642
851
|
"--height",
|
|
643
852
|
height.to_s
|
|
644
853
|
]
|
|
645
|
-
|
|
854
|
+
if wait_for_frame
|
|
855
|
+
command << "--wait-for-frame"
|
|
856
|
+
command << "--frame-timeout"
|
|
857
|
+
command << frame_timeout.to_s
|
|
858
|
+
end
|
|
859
|
+
|
|
860
|
+
success = nil
|
|
861
|
+
with_frontend_node_modules do
|
|
862
|
+
success = Kernel.system(*command)
|
|
863
|
+
end
|
|
646
864
|
raise Thor::Error, "browser capture failed" unless success
|
|
647
865
|
end
|
|
648
866
|
|
|
867
|
+
def with_frontend_node_modules
|
|
868
|
+
frontend_node_modules = Vizcore.frontend_root.join("node_modules").expand_path
|
|
869
|
+
previous_node_path = ENV["NODE_PATH"]
|
|
870
|
+
had_node_modules = frontend_node_modules.directory?
|
|
871
|
+
|
|
872
|
+
if had_node_modules
|
|
873
|
+
ENV["NODE_PATH"] = if previous_node_path.to_s.empty?
|
|
874
|
+
frontend_node_modules.to_s
|
|
875
|
+
else
|
|
876
|
+
[frontend_node_modules.to_s, previous_node_path].join(File::PATH_SEPARATOR)
|
|
877
|
+
end
|
|
878
|
+
end
|
|
879
|
+
|
|
880
|
+
yield
|
|
881
|
+
ensure
|
|
882
|
+
if had_node_modules
|
|
883
|
+
if previous_node_path
|
|
884
|
+
ENV["NODE_PATH"] = previous_node_path
|
|
885
|
+
else
|
|
886
|
+
ENV.delete("NODE_PATH")
|
|
887
|
+
end
|
|
888
|
+
end
|
|
889
|
+
end
|
|
890
|
+
|
|
649
891
|
def temporary_server_command(config)
|
|
650
892
|
command = [
|
|
651
893
|
Gem.ruby,
|
|
@@ -665,6 +907,7 @@ module Vizcore
|
|
|
665
907
|
command.concat(["--audio-file", config.audio_file.to_s]) if config.audio_file
|
|
666
908
|
command.concat(["--feature-file", config.feature_file.to_s]) if config.feature_file
|
|
667
909
|
command.concat(["--control-preset", config.control_preset.to_s]) if config.control_preset
|
|
910
|
+
command << "--allow-public-control" if config.allow_public_control?
|
|
668
911
|
command
|
|
669
912
|
end
|
|
670
913
|
|
|
@@ -711,6 +954,16 @@ module Vizcore
|
|
|
711
954
|
say("Next: require_relative \"#{root.basename}/lib/#{metadata.fetch(:plugin_name)}\" in your scene")
|
|
712
955
|
end
|
|
713
956
|
|
|
957
|
+
def check_plugin_scaffold(path)
|
|
958
|
+
raise ArgumentError, "plugin path is required" if path.to_s.strip.empty?
|
|
959
|
+
|
|
960
|
+
report = Vizcore::CLISupport::PluginChecker.new(path).call
|
|
961
|
+
report.checks.each do |check|
|
|
962
|
+
say("#{status_label(check.status)} #{check.name}: #{check.message}")
|
|
963
|
+
end
|
|
964
|
+
raise Thor::Error, "plugin check failed" if report.failure?
|
|
965
|
+
end
|
|
966
|
+
|
|
714
967
|
def plugin_scaffold_metadata(name)
|
|
715
968
|
raw_name = name.to_s.strip
|
|
716
969
|
raise ArgumentError, "plugin name is required" if raw_name.empty?
|
data/lib/vizcore/config.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "pathname"
|
|
4
|
+
require_relative "plugin_asset_policy"
|
|
4
5
|
|
|
5
6
|
module Vizcore
|
|
6
7
|
# Runtime configuration for CLI/server startup.
|
|
@@ -18,7 +19,8 @@ module Vizcore
|
|
|
18
19
|
# Supported CLI audio source values.
|
|
19
20
|
SUPPORTED_AUDIO_SOURCES = %i[mic file dummy].freeze
|
|
20
21
|
|
|
21
|
-
attr_reader :host, :port, :scene_file, :audio_source, :audio_file, :audio_device, :feature_file, :control_preset, :plugin_assets,
|
|
22
|
+
attr_reader :host, :port, :scene_file, :audio_source, :audio_file, :audio_device, :feature_file, :control_preset, :plugin_assets,
|
|
23
|
+
:noise_gate, :bpm, :osc_port, :projector_mode, :scene_switch_effect
|
|
22
24
|
|
|
23
25
|
# @param scene_file [String, Pathname] scene DSL file path
|
|
24
26
|
# @param host [String] bind host
|
|
@@ -35,6 +37,8 @@ module Vizcore
|
|
|
35
37
|
# @param osc_port [Integer, nil] UDP port for OSC control sync
|
|
36
38
|
# @param reload [Boolean] true when scene file changes should be reloaded while running
|
|
37
39
|
# @param projector_mode [Boolean] true when the browser should hide operator UI by default
|
|
40
|
+
# @param scene_switch_effect [Hash, nil] transition metadata applied to manual scene switches
|
|
41
|
+
# @param allow_public_control [Boolean] true when binding operator control routes on a public host is intentional
|
|
38
42
|
def initialize(
|
|
39
43
|
scene_file:,
|
|
40
44
|
host: DEFAULT_HOST,
|
|
@@ -50,7 +54,10 @@ module Vizcore
|
|
|
50
54
|
bpm_lock: false,
|
|
51
55
|
osc_port: nil,
|
|
52
56
|
reload: DEFAULT_RELOAD,
|
|
53
|
-
projector_mode: false
|
|
57
|
+
projector_mode: false,
|
|
58
|
+
scene_switch_effect: nil,
|
|
59
|
+
scene_switch_effect_duration: nil,
|
|
60
|
+
allow_public_control: false
|
|
54
61
|
)
|
|
55
62
|
@scene_file = Pathname.new(scene_file).expand_path if scene_file
|
|
56
63
|
@host = host
|
|
@@ -67,6 +74,8 @@ module Vizcore
|
|
|
67
74
|
@osc_port = normalize_optional_port(osc_port)
|
|
68
75
|
@reload = !!reload
|
|
69
76
|
@projector_mode = !!projector_mode
|
|
77
|
+
@scene_switch_effect = normalize_scene_switch_effect(scene_switch_effect, scene_switch_effect_duration)
|
|
78
|
+
@allow_public_control = !!allow_public_control
|
|
70
79
|
end
|
|
71
80
|
|
|
72
81
|
# @return [Boolean] true when the configured scene file exists.
|
|
@@ -89,6 +98,11 @@ module Vizcore
|
|
|
89
98
|
@bpm_lock
|
|
90
99
|
end
|
|
91
100
|
|
|
101
|
+
# @return [Boolean] true when public host binding is allowed.
|
|
102
|
+
def allow_public_control?
|
|
103
|
+
@allow_public_control
|
|
104
|
+
end
|
|
105
|
+
|
|
92
106
|
private
|
|
93
107
|
|
|
94
108
|
def normalize_audio_source(value)
|
|
@@ -140,8 +154,31 @@ module Vizcore
|
|
|
140
154
|
raw_value = value.to_s.strip
|
|
141
155
|
next if raw_value.empty?
|
|
142
156
|
|
|
143
|
-
value.is_a?(Pathname) ? value.expand_path : Pathname.new(raw_value).expand_path
|
|
157
|
+
path = value.is_a?(Pathname) ? value.expand_path : Pathname.new(raw_value).expand_path
|
|
158
|
+
Vizcore::PluginAssetPolicy.validate!(path)
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def normalize_scene_switch_effect(effect_name, duration)
|
|
163
|
+
return nil if effect_name.nil?
|
|
164
|
+
|
|
165
|
+
raw_name = effect_name.to_s.strip
|
|
166
|
+
return nil if raw_name.empty?
|
|
167
|
+
|
|
168
|
+
options = {}
|
|
169
|
+
unless duration.nil?
|
|
170
|
+
normalized_duration = Float(duration)
|
|
171
|
+
options[:duration] = normalized_duration if normalized_duration.positive? || normalized_duration.zero?
|
|
144
172
|
end
|
|
173
|
+
|
|
174
|
+
{
|
|
175
|
+
name: raw_name.to_sym,
|
|
176
|
+
options: options
|
|
177
|
+
}.tap do |effect|
|
|
178
|
+
effect.delete(:options) if options.empty?
|
|
179
|
+
end
|
|
180
|
+
rescue ArgumentError, TypeError
|
|
181
|
+
raise ArgumentError, "scene_switch_duration must be numeric"
|
|
145
182
|
end
|
|
146
183
|
end
|
|
147
184
|
end
|