vizcore 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. checksums.yaml +4 -4
  2. data/frontend/index.html +24 -2
  3. data/frontend/src/audio-inspector.js +9 -0
  4. data/frontend/src/live-controls.js +219 -7
  5. data/frontend/src/main.js +447 -57
  6. data/frontend/src/midi-learn.js +22 -2
  7. data/frontend/src/performance-monitor.js +137 -1
  8. data/frontend/src/renderer/engine.js +391 -10
  9. data/frontend/src/renderer/layer-manager.js +472 -71
  10. data/frontend/src/runtime-control-preset.js +44 -0
  11. data/frontend/src/scene-patches.js +159 -0
  12. data/frontend/src/shader-error-overlay.js +1 -0
  13. data/frontend/src/visuals/image-renderer.js +19 -0
  14. data/frontend/src/visuals/particle-system.js +10 -0
  15. data/frontend/src/visuals/shape-renderer.js +13 -0
  16. data/frontend/src/visuals/spectrogram-renderer.js +14 -0
  17. data/frontend/src/visuals/text-renderer.js +13 -0
  18. data/frontend/src/websocket-client.js +6 -0
  19. data/lib/vizcore/analysis/adaptive_normalizer.rb +20 -2
  20. data/lib/vizcore/analysis/bpm_estimator.rb +18 -8
  21. data/lib/vizcore/analysis/feature_recorder.rb +117 -7
  22. data/lib/vizcore/analysis/feature_replay.rb +48 -9
  23. data/lib/vizcore/analysis/pipeline.rb +258 -9
  24. data/lib/vizcore/analysis/tap_tempo.rb +17 -2
  25. data/lib/vizcore/audio/calibration.rb +156 -0
  26. data/lib/vizcore/audio/file_input.rb +28 -0
  27. data/lib/vizcore/audio/input_manager.rb +36 -1
  28. data/lib/vizcore/audio/midi_input.rb +5 -0
  29. data/lib/vizcore/audio/ring_buffer.rb +22 -0
  30. data/lib/vizcore/audio.rb +1 -0
  31. data/lib/vizcore/cli/dsl_reference.rb +64 -8
  32. data/lib/vizcore/cli/plugin_checker.rb +93 -0
  33. data/lib/vizcore/cli/scene_diagnostics.rb +2 -2
  34. data/lib/vizcore/cli/scene_inspector.rb +35 -1
  35. data/lib/vizcore/cli/scene_validator.rb +487 -39
  36. data/lib/vizcore/cli/shader_template.rb +7 -2
  37. data/lib/vizcore/cli/shader_uniform_docs.rb +11 -0
  38. data/lib/vizcore/cli.rb +268 -15
  39. data/lib/vizcore/config.rb +40 -3
  40. data/lib/vizcore/control_preset.rb +29 -0
  41. data/lib/vizcore/deep_copy.rb +21 -0
  42. data/lib/vizcore/dsl/color_helpers.rb +155 -0
  43. data/lib/vizcore/dsl/engine.rb +219 -23
  44. data/lib/vizcore/dsl/layer_builder.rb +278 -15
  45. data/lib/vizcore/dsl/layer_group_builder.rb +10 -12
  46. data/lib/vizcore/dsl/layout_helpers.rb +290 -0
  47. data/lib/vizcore/dsl/mapping_preset_builder.rb +41 -0
  48. data/lib/vizcore/dsl/mapping_resolver.rb +404 -22
  49. data/lib/vizcore/dsl/mapping_transform_builder.rb +50 -0
  50. data/lib/vizcore/dsl/midi_map_executor.rb +219 -23
  51. data/lib/vizcore/dsl/reaction_builder.rb +1 -0
  52. data/lib/vizcore/dsl/scene_builder.rb +83 -13
  53. data/lib/vizcore/dsl/shader_source_resolver.rb +1 -10
  54. data/lib/vizcore/dsl/style_builder.rb +3 -0
  55. data/lib/vizcore/dsl/timeline_builder.rb +91 -8
  56. data/lib/vizcore/dsl/transition_controller.rb +157 -18
  57. data/lib/vizcore/dsl.rb +2 -0
  58. data/lib/vizcore/layer_catalog.rb +1 -0
  59. data/lib/vizcore/plugin_asset_policy.rb +55 -0
  60. data/lib/vizcore/project_manifest.rb +12 -2
  61. data/lib/vizcore/renderer/render_sequence.rb +104 -13
  62. data/lib/vizcore/renderer/scene_frame_source.rb +179 -14
  63. data/lib/vizcore/renderer/scene_serializer.rb +38 -0
  64. data/lib/vizcore/renderer/snapshot.rb +4 -3
  65. data/lib/vizcore/renderer/snapshot_renderer.rb +134 -8
  66. data/lib/vizcore/scene_trust.rb +31 -0
  67. data/lib/vizcore/server/frame_broadcaster.rb +469 -23
  68. data/lib/vizcore/server/rack_app.rb +151 -4
  69. data/lib/vizcore/server/runner.rb +676 -82
  70. data/lib/vizcore/server/websocket_handler.rb +236 -14
  71. data/lib/vizcore/server.rb +21 -0
  72. data/lib/vizcore/shape.rb +39 -16
  73. data/lib/vizcore/sync/osc_message.rb +66 -9
  74. data/lib/vizcore/version.rb +1 -1
  75. data/lib/vizcore.rb +33 -0
  76. data/scripts/browser_capture.mjs +31 -2
  77. data/sig/vizcore.rbs +55 -4
  78. metadata +18 -3
@@ -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 + u_high * 0.2);
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
- Server::Runner.new(config).run
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
- projector_mode: options.fetch(:projector)
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
- say("#{label} #{issue.message}")
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 run_browser_capture(url, out:, selector:, wait:, width:, height:)
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
- success = Kernel.system(*command)
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?
@@ -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, :noise_gate, :bpm, :osc_port, :projector_mode
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