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.
Files changed (126) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +544 -9
  3. data/docs/.nojekyll +0 -0
  4. data/docs/assets/site.css +744 -0
  5. data/docs/assets/vizcore-demo.gif +0 -0
  6. data/docs/assets/vizcore-poster.png +0 -0
  7. data/docs/assets/vj-tunnel.js +159 -0
  8. data/docs/index.html +224 -0
  9. data/examples/README.md +59 -0
  10. data/examples/assets/README.md +19 -0
  11. data/examples/audio_inspector.rb +34 -0
  12. data/examples/club_intro_drop.rb +78 -0
  13. data/examples/kansai_rubykaigi_visual.rb +70 -0
  14. data/examples/live_coding_minimal.rb +22 -0
  15. data/examples/midi_controller_show.rb +78 -0
  16. data/examples/midi_scene_switch.rb +3 -1
  17. data/examples/parser_visualizer.rb +48 -0
  18. data/examples/readme_demo.rb +17 -0
  19. data/examples/rhythm_geometry.rb +34 -0
  20. data/examples/ruby_crystal_show.rb +35 -0
  21. data/examples/shader_playground.rb +18 -0
  22. data/examples/unyo_liquid.rb +59 -0
  23. data/examples/vj_ambient_chill_room.rb +124 -0
  24. data/examples/vj_dnb_jungle.rb +170 -0
  25. data/examples/vj_festival_mainstage.rb +245 -0
  26. data/examples/vj_festival_mainstage.yml +17 -0
  27. data/examples/vj_glitch_industrial.rb +164 -0
  28. data/examples/vj_hiphop_cipher.rb +167 -0
  29. data/examples/vj_jpop_idol_live.rb +210 -0
  30. data/examples/vj_synthwave_retro.rb +173 -0
  31. data/examples/vj_techno_warehouse.rb +195 -0
  32. data/frontend/index.html +468 -2
  33. data/frontend/src/audio-inspector.js +40 -0
  34. data/frontend/src/live-controls.js +131 -0
  35. data/frontend/src/main.js +792 -16
  36. data/frontend/src/midi-learn.js +194 -0
  37. data/frontend/src/performance-monitor.js +183 -0
  38. data/frontend/src/plugin-runtime.js +130 -0
  39. data/frontend/src/projector-mode.js +56 -0
  40. data/frontend/src/renderer/engine.js +148 -3
  41. data/frontend/src/renderer/layer-manager.js +428 -30
  42. data/frontend/src/renderer/shader-manager.js +26 -0
  43. data/frontend/src/runtime-control-preset.js +11 -0
  44. data/frontend/src/shader-error-overlay.js +29 -0
  45. data/frontend/src/shader-param-controls.js +93 -0
  46. data/frontend/src/shaders/builtins.js +380 -2
  47. data/frontend/src/shaders/post-effects.js +52 -0
  48. data/frontend/src/visual-regression.js +67 -0
  49. data/frontend/src/visual-settings-preset.js +103 -0
  50. data/frontend/src/visuals/geometry.js +268 -0
  51. data/frontend/src/visuals/image-renderer.js +291 -0
  52. data/frontend/src/visuals/particle-system.js +56 -10
  53. data/frontend/src/visuals/spectrogram-renderer.js +226 -0
  54. data/frontend/src/visuals/text-renderer.js +112 -11
  55. data/frontend/src/websocket-client.js +12 -1
  56. data/lib/vizcore/analysis/adaptive_normalizer.rb +70 -0
  57. data/lib/vizcore/analysis/beat_detector.rb +4 -2
  58. data/lib/vizcore/analysis/bpm_estimator.rb +8 -0
  59. data/lib/vizcore/analysis/feature_recorder.rb +159 -0
  60. data/lib/vizcore/analysis/feature_replay.rb +84 -0
  61. data/lib/vizcore/analysis/pipeline.rb +235 -11
  62. data/lib/vizcore/analysis/tap_tempo.rb +74 -0
  63. data/lib/vizcore/analysis.rb +4 -0
  64. data/lib/vizcore/audio/dummy_sine_input.rb +1 -1
  65. data/lib/vizcore/audio/fixture_input.rb +65 -0
  66. data/lib/vizcore/audio/input_manager.rb +4 -2
  67. data/lib/vizcore/audio/mic_input.rb +24 -8
  68. data/lib/vizcore/audio/portaudio_ffi.rb +106 -1
  69. data/lib/vizcore/audio.rb +1 -0
  70. data/lib/vizcore/cli/doctor.rb +159 -0
  71. data/lib/vizcore/cli/dsl_reference.rb +99 -0
  72. data/lib/vizcore/cli/layer_docs.rb +46 -0
  73. data/lib/vizcore/cli/scene_diagnostics.rb +23 -0
  74. data/lib/vizcore/cli/scene_inspector.rb +136 -0
  75. data/lib/vizcore/cli/scene_validator.rb +245 -0
  76. data/lib/vizcore/cli/shader_template.rb +68 -0
  77. data/lib/vizcore/cli/shader_uniform_docs.rb +54 -0
  78. data/lib/vizcore/cli.rb +689 -18
  79. data/lib/vizcore/config.rb +103 -2
  80. data/lib/vizcore/control_preset.rb +68 -0
  81. data/lib/vizcore/dsl/engine.rb +277 -5
  82. data/lib/vizcore/dsl/layer_builder.rb +491 -22
  83. data/lib/vizcore/dsl/layer_group_builder.rb +112 -0
  84. data/lib/vizcore/dsl/mapping_resolver.rb +132 -3
  85. data/lib/vizcore/dsl/mapping_transform_builder.rb +71 -0
  86. data/lib/vizcore/dsl/reaction_builder.rb +44 -0
  87. data/lib/vizcore/dsl/scene_builder.rb +61 -5
  88. data/lib/vizcore/dsl/shader_source_resolver.rb +67 -6
  89. data/lib/vizcore/dsl/style_builder.rb +68 -0
  90. data/lib/vizcore/dsl/timeline_builder.rb +138 -0
  91. data/lib/vizcore/dsl/transition_controller.rb +77 -0
  92. data/lib/vizcore/dsl.rb +5 -1
  93. data/lib/vizcore/layer_catalog.rb +273 -0
  94. data/lib/vizcore/project_manifest.rb +152 -0
  95. data/lib/vizcore/renderer/png_writer.rb +57 -0
  96. data/lib/vizcore/renderer/render_sequence.rb +153 -0
  97. data/lib/vizcore/renderer/scene_frame_source.rb +119 -0
  98. data/lib/vizcore/renderer/scene_serializer.rb +36 -3
  99. data/lib/vizcore/renderer/snapshot.rb +38 -0
  100. data/lib/vizcore/renderer/snapshot_renderer.rb +446 -0
  101. data/lib/vizcore/renderer.rb +5 -0
  102. data/lib/vizcore/server/frame_broadcaster.rb +91 -5
  103. data/lib/vizcore/server/gallery_app.rb +155 -0
  104. data/lib/vizcore/server/gallery_page.rb +100 -0
  105. data/lib/vizcore/server/gallery_runner.rb +48 -0
  106. data/lib/vizcore/server/rack_app.rb +203 -4
  107. data/lib/vizcore/server/runner.rb +370 -22
  108. data/lib/vizcore/server/scene_dependency_watcher.rb +79 -0
  109. data/lib/vizcore/server/websocket_handler.rb +60 -10
  110. data/lib/vizcore/server.rb +4 -0
  111. data/lib/vizcore/sync/osc_message.rb +103 -0
  112. data/lib/vizcore/sync/osc_receiver.rb +68 -0
  113. data/lib/vizcore/sync.rb +4 -0
  114. data/lib/vizcore/templates/midi_control_scene.rb +3 -1
  115. data/lib/vizcore/templates/plugin_layer.rb +20 -0
  116. data/lib/vizcore/templates/plugin_readme.md +23 -0
  117. data/lib/vizcore/templates/plugin_renderer.js +43 -0
  118. data/lib/vizcore/templates/plugin_scene.rb +14 -0
  119. data/lib/vizcore/templates/project_readme.md +7 -23
  120. data/lib/vizcore/templates/rubykaigi_scene.rb +30 -0
  121. data/lib/vizcore/version.rb +1 -1
  122. data/lib/vizcore.rb +27 -0
  123. data/scripts/browser_capture.mjs +75 -0
  124. data/sig/vizcore.rbs +362 -0
  125. metadata +83 -3
  126. data/docs/GETTING_STARTED.md +0 -105
data/lib/vizcore/cli.rb CHANGED
@@ -1,11 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "fileutils"
4
+ require "net/http"
4
5
  require "pathname"
5
6
  require "thor"
6
7
  require_relative "../vizcore"
8
+ require_relative "analysis"
7
9
  require_relative "audio"
10
+ require_relative "cli/doctor"
11
+ require_relative "cli/dsl_reference"
12
+ require_relative "cli/layer_docs"
13
+ require_relative "cli/scene_diagnostics"
14
+ require_relative "cli/shader_template"
15
+ require_relative "cli/shader_uniform_docs"
8
16
  require_relative "config"
17
+ require_relative "project_manifest"
9
18
  require_relative "server"
10
19
 
11
20
  module Vizcore
@@ -22,48 +31,196 @@ module Vizcore
22
31
 
23
32
  default_command :help
24
33
 
25
- desc "start SCENE_FILE", "Start vizcore HTTP/WebSocket server"
34
+ SCAFFOLD_TEMPLATES = {
35
+ "standard" => {
36
+ label: "standard",
37
+ start_scene: "scenes/basic.rb",
38
+ files: [
39
+ ["basic_scene.rb", "scenes/basic.rb", "Minimal wireframe starter"],
40
+ ["intro_drop_scene.rb", "scenes/intro_drop.rb", "Transition flow with beat trigger"],
41
+ ["midi_control_scene.rb", "scenes/midi_control.rb", "MIDI note/CC mapping example"],
42
+ ["custom_shader_scene.rb", "scenes/custom_shader.rb", "Custom GLSL + post/VJ effect example"],
43
+ ["custom_wave.frag", "shaders/custom_wave.frag", "Custom GLSL fragment shader"]
44
+ ],
45
+ notes: [
46
+ "`scenes/custom_shader.rb` references `shaders/custom_wave.frag`.",
47
+ "Use `vizcore devices midi` before running `scenes/midi_control.rb`."
48
+ ]
49
+ },
50
+ "minimal" => {
51
+ label: "minimal",
52
+ start_scene: "scenes/basic.rb",
53
+ files: [
54
+ ["basic_scene.rb", "scenes/basic.rb", "Minimal wireframe starter"]
55
+ ],
56
+ notes: []
57
+ },
58
+ "shader" => {
59
+ label: "shader",
60
+ start_scene: "scenes/custom_shader.rb",
61
+ files: [
62
+ ["custom_shader_scene.rb", "scenes/custom_shader.rb", "Custom GLSL + post/VJ effect example"],
63
+ ["custom_wave.frag", "shaders/custom_wave.frag", "Custom GLSL fragment shader"]
64
+ ],
65
+ notes: [
66
+ "`scenes/custom_shader.rb` references `shaders/custom_wave.frag`."
67
+ ]
68
+ },
69
+ "midi" => {
70
+ label: "midi",
71
+ start_scene: "scenes/midi_control.rb",
72
+ files: [
73
+ ["midi_control_scene.rb", "scenes/midi_control.rb", "MIDI note/CC mapping example"]
74
+ ],
75
+ notes: [
76
+ "Run `vizcore devices midi` before starting the MIDI scene."
77
+ ]
78
+ },
79
+ "live-set" => {
80
+ label: "live-set",
81
+ start_scene: "scenes/live_set.rb",
82
+ files: [
83
+ ["intro_drop_scene.rb", "scenes/live_set.rb", "Two-scene transition flow with beat trigger"]
84
+ ],
85
+ notes: [
86
+ "Use file audio or a microphone input with clear beats for transition triggers."
87
+ ]
88
+ },
89
+ "rubykaigi" => {
90
+ label: "rubykaigi",
91
+ start_scene: "scenes/rubykaigi.rb",
92
+ files: [
93
+ ["rubykaigi_scene.rb", "scenes/rubykaigi.rb", "Ruby conference visual starter"]
94
+ ],
95
+ notes: [
96
+ "This scene uses Ruby-red text and audio-reactive geometry for talk or event visuals."
97
+ ]
98
+ }
99
+ }.freeze
100
+
101
+ PLUGIN_SCAFFOLD_FILES = [
102
+ ["plugin_readme.md", "README.md"],
103
+ ["plugin_layer.rb", "lib/{{plugin_name}}.rb"],
104
+ ["plugin_renderer.js", "frontend/{{plugin_name}}-renderer.js"],
105
+ ["plugin_scene.rb", "examples/{{plugin_name}}_scene.rb"]
106
+ ].freeze
107
+ DEFAULT_CAPTURE_PORT = 4579
108
+
109
+ desc "start [SCENE_FILE]", "Start vizcore HTTP/WebSocket server"
110
+ option :manifest, type: :string, desc: "Project manifest YAML path"
111
+ option :profile, type: :string, desc: "Project manifest profile name"
26
112
  option :host, type: :string, default: Config::DEFAULT_HOST, desc: "Bind host"
27
113
  option :port, type: :numeric, default: Config::DEFAULT_PORT, desc: "Bind port"
28
- option :audio_source, type: :string, default: Config::DEFAULT_AUDIO_SOURCE.to_s, desc: "Audio source: mic, file, dummy"
114
+ option :audio_source, type: :string, desc: "Audio source: mic, file, dummy"
29
115
  option :audio_file, type: :string, desc: "Path to audio file used when --audio-source file (wav/mp3/flac)"
116
+ option :audio_device, type: :string, desc: "Audio input device index or name used when --audio-source mic"
117
+ option :feature_file, type: :string, desc: "Replay recorded feature JSON instead of live audio analysis"
118
+ option :control_preset, type: :string, desc: "Control preset JSON for browser HUD and MIDI learn"
119
+ option :noise_gate, type: :numeric, default: Config::DEFAULT_NOISE_GATE, desc: "RMS level below which audio is treated as silence"
120
+ option :bpm, type: :numeric, desc: "Fixed BPM value used with --bpm-lock"
121
+ option :bpm_lock, type: :boolean, default: false, desc: "Lock analysis BPM output to --bpm"
122
+ option :osc_port, type: :numeric, desc: "UDP port for OSC sync (/vizcore/scene, /vizcore/tap)"
123
+ option :reload, type: :boolean, default: Config::DEFAULT_RELOAD, desc: "Reload the scene file when it changes"
124
+ option :projector, type: :boolean, default: false, desc: "Hide browser operator UI for projection output"
30
125
  # Start the Vizcore server with the given scene file.
31
126
  #
32
127
  # @param scene_file [String] path to a Ruby scene DSL file
33
128
  # @raise [Thor::Error] when CLI arguments are invalid
34
129
  # @return [void]
35
- def start(scene_file)
130
+ def start(scene_file = nil)
131
+ manifest = load_project_manifest(options[:manifest])
132
+ profile = options[:profile]
133
+ load_manifest_plugins(manifest, profile: profile)
134
+ defaults = manifest&.config_defaults(profile: profile) || {}
36
135
  config = Config.new(
37
- scene_file: scene_file,
136
+ scene_file: scene_file || defaults[:scene_file],
38
137
  host: options.fetch(:host),
39
138
  port: options.fetch(:port),
40
- audio_source: options.fetch(:audio_source),
41
- audio_file: options[:audio_file]
139
+ audio_source: options[:audio_source] || defaults[:audio_source] || Config::DEFAULT_AUDIO_SOURCE,
140
+ audio_file: options[:audio_file] || defaults[:audio_file],
141
+ audio_device: options[:audio_device] || defaults[:audio_device],
142
+ feature_file: options[:feature_file] || defaults[:feature_file],
143
+ control_preset: options[:control_preset] || defaults[:control_preset],
144
+ plugin_assets: defaults[:plugin_assets],
145
+ noise_gate: options.fetch(:noise_gate),
146
+ bpm: options[:bpm],
147
+ bpm_lock: options.fetch(:bpm_lock),
148
+ osc_port: options[:osc_port] || defaults[:osc_port],
149
+ reload: options.fetch(:reload),
150
+ projector_mode: options.fetch(:projector)
42
151
  )
43
152
  Server::Runner.new(config).run
44
153
  rescue ArgumentError => e
45
154
  raise Thor::Error, e.message
46
155
  end
47
156
 
157
+ desc "demo", "Start the bundled audio-reactive demo"
158
+ option :host, type: :string, default: Config::DEFAULT_HOST, desc: "Bind host"
159
+ option :port, type: :numeric, default: Config::DEFAULT_PORT, desc: "Bind port"
160
+ option :noise_gate, type: :numeric, default: Config::DEFAULT_NOISE_GATE, desc: "RMS level below which audio is treated as silence"
161
+ option :bpm, type: :numeric, desc: "Fixed BPM value used with --bpm-lock"
162
+ option :bpm_lock, type: :boolean, default: false, desc: "Lock analysis BPM output to --bpm"
163
+ option :control_preset, type: :string, desc: "Control preset JSON for browser HUD and MIDI learn"
164
+ option :osc_port, type: :numeric, desc: "UDP port for OSC sync (/vizcore/scene, /vizcore/tap)"
165
+ option :projector, type: :boolean, default: false, desc: "Hide browser operator UI for projection output"
166
+ # Start a bundled scene with bundled audio for first-run verification.
167
+ #
168
+ # @return [void]
169
+ def demo
170
+ config = Config.new(
171
+ scene_file: Vizcore.root.join("examples", "rhythm_geometry.rb"),
172
+ host: options.fetch(:host),
173
+ port: options.fetch(:port),
174
+ audio_source: :file,
175
+ audio_file: Vizcore.root.join("examples", "assets", "complex_demo_loop.wav"),
176
+ noise_gate: options.fetch(:noise_gate),
177
+ bpm: options[:bpm],
178
+ bpm_lock: options.fetch(:bpm_lock),
179
+ control_preset: options[:control_preset],
180
+ osc_port: options[:osc_port],
181
+ projector_mode: options.fetch(:projector)
182
+ )
183
+ Server::Runner.new(config).run
184
+ rescue ArgumentError => e
185
+ raise Thor::Error, e.message
186
+ end
187
+
188
+ desc "gallery", "Start the bundled example gallery"
189
+ option :host, type: :string, default: Config::DEFAULT_HOST, desc: "Bind host"
190
+ option :port, type: :numeric, default: Vizcore::Server::GalleryRunner::DEFAULT_PORT, desc: "Bind port"
191
+ # Start a browser gallery for bundled example scenes.
192
+ #
193
+ # @return [void]
194
+ def gallery
195
+ Vizcore::Server::GalleryRunner.new(
196
+ host: options.fetch(:host),
197
+ port: options.fetch(:port)
198
+ ).run
199
+ end
200
+
48
201
  desc "new NAME", "Create a starter project scaffold"
202
+ option :template,
203
+ type: :string,
204
+ default: "standard",
205
+ desc: "Scaffold template: standard, minimal, shader, midi, live-set, rubykaigi"
49
206
  # Generate a new Vizcore project scaffold.
50
207
  #
51
208
  # @param name [String] directory name for the new project
52
209
  # @return [void]
53
210
  def new(name)
211
+ scaffold = scaffold_template(options.fetch(:template))
54
212
  root = Pathname.new(name).expand_path
55
- FileUtils.mkdir_p(root.join("scenes"))
56
- FileUtils.mkdir_p(root.join("shaders"))
213
+ FileUtils.mkdir_p(root)
57
214
 
58
- write_template("project_readme.md", root.join("README.md"), project_name: name)
59
- write_template("basic_scene.rb", root.join("scenes", "basic.rb"), project_name: name)
60
- write_template("intro_drop_scene.rb", root.join("scenes", "intro_drop.rb"), project_name: name)
61
- write_template("midi_control_scene.rb", root.join("scenes", "midi_control.rb"), project_name: name)
62
- write_template("custom_shader_scene.rb", root.join("scenes", "custom_shader.rb"), project_name: name)
63
- write_template("custom_wave.frag", root.join("shaders", "custom_wave.frag"), project_name: name)
215
+ write_project_readme(root.join("README.md"), project_name: name, scaffold: scaffold)
216
+ scaffold.fetch(:files).each do |template_name, destination, _description|
217
+ write_template(template_name, root.join(destination), project_name: name)
218
+ end
64
219
 
65
- say("Created project scaffold: #{root}")
66
- say("Next: cd #{name} && vizcore start scenes/basic.rb")
220
+ say("Created project scaffold (#{scaffold.fetch(:label)}): #{root}")
221
+ say("Next: cd #{name} && vizcore start #{scaffold.fetch(:start_scene)}")
222
+ rescue ArgumentError => e
223
+ raise Thor::Error, e.message
67
224
  end
68
225
 
69
226
  desc "devices [TYPE]", "Show available devices (audio or midi)"
@@ -86,14 +243,528 @@ module Vizcore
86
243
  end
87
244
  end
88
245
 
246
+ desc "doctor", "Check local dependencies and device availability"
247
+ # Print local environment checks for Vizcore runtime dependencies.
248
+ #
249
+ # @raise [Thor::Error] when a required check fails
250
+ # @return [void]
251
+ def doctor
252
+ report = Vizcore::CLISupport::Doctor.new.call
253
+ report.checks.each do |check|
254
+ say("#{status_label(check.status)} #{check.name}: #{check.message}")
255
+ end
256
+ raise Thor::Error, "vizcore doctor found required failures" if report.failure?
257
+ end
258
+
259
+ map "inspect" => :inspect_scene
260
+ desc "inspect SCENE_FILE", "Print scenes, layers, mappings, and transitions"
261
+ # Load a scene DSL file and print its runtime structure.
262
+ #
263
+ # @param scene_file [String] path to a Ruby scene DSL file
264
+ # @raise [Thor::Error] when scene loading fails
265
+ # @return [void]
266
+ def inspect_scene(scene_file)
267
+ diagnostics = Vizcore::CLISupport::SceneDiagnostics.new(scene_file: scene_file)
268
+ result = diagnostics.validate
269
+ print_issues(result.issues)
270
+ raise Thor::Error, "scene inspection failed" unless result.definition
271
+
272
+ diagnostics.inspect_lines(result.definition).each { |line| say(line) }
273
+ end
274
+
275
+ desc "validate SCENE_FILE", "Validate a scene DSL file"
276
+ # Load and validate a scene DSL file without starting the server.
277
+ #
278
+ # @param scene_file [String] path to a Ruby scene DSL file
279
+ # @raise [Thor::Error] when validation fails
280
+ # @return [void]
281
+ def validate(scene_file)
282
+ result = Vizcore::CLISupport::SceneDiagnostics.new(scene_file: scene_file).validate
283
+ print_issues(result.issues)
284
+ raise Thor::Error, "scene validation failed" unless result.valid?
285
+
286
+ say("Scene valid: #{scene_file}")
287
+ end
288
+
289
+ desc "layers", "Print built-in layer capability metadata"
290
+ # Print supported layer types, params, and browser-side capabilities.
291
+ #
292
+ # @return [void]
293
+ def layers
294
+ Vizcore::CLISupport::LayerDocs.new.lines.each { |line| say(line) }
295
+ end
296
+
297
+ map "dsl-docs" => :dsl_docs
298
+ desc "dsl-docs", "Print generated Ruby DSL reference"
299
+ # Print generated documentation for the Ruby scene DSL.
300
+ #
301
+ # @return [void]
302
+ def dsl_docs
303
+ Vizcore::CLISupport::DslReference.new.lines.each { |line| say(line) }
304
+ end
305
+
306
+ map "shader-docs" => :shader_docs
307
+ desc "shader-docs", "Print custom GLSL shader uniform reference"
308
+ # Print generated documentation for custom GLSL uniforms.
309
+ #
310
+ # @return [void]
311
+ def shader_docs
312
+ Vizcore::CLISupport::ShaderUniformDocs.new.lines.each { |line| say(line) }
313
+ end
314
+
315
+ desc "shader COMMAND [NAME]", "Manage custom GLSL shader helpers"
316
+ option :out, type: :string, desc: "Output path for `shader new`"
317
+ # Run custom shader helper commands.
318
+ #
319
+ # @param command [String]
320
+ # @param name [String, nil]
321
+ # @raise [Thor::Error] when the subcommand or arguments are invalid
322
+ # @return [void]
323
+ def shader(command = nil, name = nil)
324
+ case command.to_s
325
+ when "new"
326
+ create_shader_template(name)
327
+ else
328
+ raise Thor::Error, "Unknown shader command: #{command || '(nil)'}. Use `vizcore shader new NAME`."
329
+ end
330
+ rescue ArgumentError => e
331
+ raise Thor::Error, e.message
332
+ end
333
+
334
+ desc "plugin COMMAND [NAME]", "Manage Vizcore plugin helpers"
335
+ option :out, type: :string, desc: "Output directory for `plugin new`"
336
+ # Run plugin helper commands.
337
+ #
338
+ # @param command [String, nil]
339
+ # @param name [String, nil]
340
+ # @raise [Thor::Error] when the subcommand or arguments are invalid
341
+ # @return [void]
342
+ def plugin(command = nil, name = nil)
343
+ case command.to_s
344
+ when "new"
345
+ create_plugin_scaffold(name)
346
+ else
347
+ raise Thor::Error, "Unknown plugin command: #{command || '(nil)'}. Use `vizcore plugin new NAME`."
348
+ end
349
+ rescue ArgumentError => e
350
+ raise Thor::Error, e.message
351
+ end
352
+
353
+ map "browser-capture" => :browser_capture
354
+ desc "browser-capture URL", "Capture a browser-rendered Vizcore canvas to PNG"
355
+ option :out, type: :string, default: "browser-capture.png", desc: "Output PNG path"
356
+ option :selector, type: :string, default: "#vizcore-canvas", desc: "Element selector to capture"
357
+ option :wait, type: :numeric, default: 1000, desc: "Milliseconds to wait after page load"
358
+ option :width, type: :numeric, default: 1280, desc: "Browser viewport width"
359
+ option :height, type: :numeric, default: 720, desc: "Browser viewport height"
360
+ # Capture browser-rendered output from a running Vizcore server.
361
+ #
362
+ # @param url [String]
363
+ # @raise [Thor::Error] when Playwright capture fails
364
+ # @return [void]
365
+ def browser_capture(url)
366
+ run_browser_capture(
367
+ url,
368
+ out: options.fetch(:out),
369
+ selector: options.fetch(:selector),
370
+ wait: options.fetch(:wait),
371
+ width: options.fetch(:width),
372
+ height: options.fetch(:height)
373
+ )
374
+ end
375
+
376
+ desc "capture SCENE_FILE", "Start a temporary server and capture the browser-rendered canvas"
377
+ option :host, type: :string, default: Config::DEFAULT_HOST, desc: "Temporary server host"
378
+ option :port, type: :numeric, default: DEFAULT_CAPTURE_PORT, desc: "Temporary server port"
379
+ option :audio_source, type: :string, default: "dummy", desc: "Audio source: dummy, file, mic"
380
+ option :audio_file, type: :string, desc: "Path to audio file used when --audio-source file"
381
+ option :feature_file, type: :string, desc: "Replay recorded feature JSON instead of live audio analysis"
382
+ option :control_preset, type: :string, desc: "Control preset JSON for browser HUD and MIDI learn"
383
+ option :out, type: :string, default: "browser-capture.png", desc: "Output PNG path"
384
+ option :selector, type: :string, default: "#vizcore-canvas", desc: "Element selector to capture"
385
+ option :wait, type: :numeric, default: 1000, desc: "Milliseconds to wait after page load"
386
+ option :timeout, type: :numeric, default: 10, desc: "Seconds to wait for the temporary server"
387
+ option :width, type: :numeric, default: 1280, desc: "Browser viewport width"
388
+ option :height, type: :numeric, default: 720, desc: "Browser viewport height"
389
+ # Start Vizcore and capture a browser-rendered canvas from the projector route.
390
+ #
391
+ # @param scene_file [String]
392
+ # @raise [Thor::Error] when server startup or capture fails
393
+ # @return [void]
394
+ def capture(scene_file)
395
+ config = Config.new(
396
+ scene_file: scene_file,
397
+ host: options.fetch(:host),
398
+ port: options.fetch(:port),
399
+ audio_source: options.fetch(:audio_source),
400
+ audio_file: options[:audio_file],
401
+ feature_file: options[:feature_file],
402
+ control_preset: options[:control_preset],
403
+ reload: false,
404
+ projector_mode: true
405
+ )
406
+ validate_snapshot_config!(config)
407
+
408
+ pid = Kernel.spawn(*temporary_server_command(config), out: File::NULL, err: File::NULL)
409
+ begin
410
+ wait_for_http("http://#{config.host}:#{config.port}/health", timeout: options.fetch(:timeout))
411
+ run_browser_capture(
412
+ "http://#{config.host}:#{config.port}/projector",
413
+ out: options.fetch(:out),
414
+ selector: options.fetch(:selector),
415
+ wait: options.fetch(:wait),
416
+ width: options.fetch(:width),
417
+ height: options.fetch(:height)
418
+ )
419
+ ensure
420
+ stop_temporary_server(pid)
421
+ end
422
+ rescue StandardError => e
423
+ raise Thor::Error, e.message
424
+ end
425
+
426
+ desc "snapshot SCENE_FILE", "Render one scene frame to a PNG snapshot"
427
+ option :audio_source, type: :string, default: "dummy", desc: "Audio source: dummy, file, mic"
428
+ option :audio_file, type: :string, desc: "Path to audio file used when --audio-source file"
429
+ option :audio_device, type: :string, desc: "Audio input device index or name used when --audio-source mic"
430
+ option :noise_gate, type: :numeric, default: Config::DEFAULT_NOISE_GATE, desc: "RMS level below which audio is treated as silence"
431
+ option :bpm, type: :numeric, desc: "Fixed BPM value used with --bpm-lock"
432
+ option :bpm_lock, type: :boolean, default: false, desc: "Lock analysis BPM output to --bpm"
433
+ option :out, type: :string, default: "snapshot.png", desc: "Output PNG path"
434
+ option :width, type: :numeric, default: Vizcore::Renderer::SnapshotRenderer::DEFAULT_WIDTH, desc: "Snapshot width"
435
+ option :height, type: :numeric, default: Vizcore::Renderer::SnapshotRenderer::DEFAULT_HEIGHT, desc: "Snapshot height"
436
+ # Load a scene DSL file and write a software-rendered PNG preview.
437
+ #
438
+ # @param scene_file [String] path to a Ruby scene DSL file
439
+ # @raise [Thor::Error] when scene loading or snapshot writing fails
440
+ # @return [void]
441
+ def snapshot(scene_file)
442
+ config = Config.new(
443
+ scene_file: scene_file,
444
+ audio_source: options.fetch(:audio_source),
445
+ audio_file: options[:audio_file],
446
+ audio_device: options[:audio_device],
447
+ noise_gate: options.fetch(:noise_gate),
448
+ bpm: options[:bpm],
449
+ bpm_lock: options.fetch(:bpm_lock)
450
+ )
451
+ validate_snapshot_config!(config)
452
+
453
+ result = Vizcore::Renderer::Snapshot.new(
454
+ config: config,
455
+ width: options.fetch(:width),
456
+ height: options.fetch(:height)
457
+ ).write(out: options.fetch(:out))
458
+ say("Snapshot written: #{result[:path]} (scene=#{result[:scene]}, #{result[:width]}x#{result[:height]})")
459
+ rescue StandardError => e
460
+ raise Thor::Error, e.message
461
+ end
462
+
463
+ desc "render SCENE_FILE", "Render a PNG image sequence or MP4 video for a scene"
464
+ option :audio_source, type: :string, default: "dummy", desc: "Audio source: dummy, file, mic"
465
+ option :audio_file, type: :string, desc: "Path to audio file used when --audio-source file"
466
+ option :audio_device, type: :string, desc: "Audio input device index or name used when --audio-source mic"
467
+ option :noise_gate, type: :numeric, default: Config::DEFAULT_NOISE_GATE, desc: "RMS level below which audio is treated as silence"
468
+ option :bpm, type: :numeric, desc: "Fixed BPM value used with --bpm-lock"
469
+ option :bpm_lock, type: :boolean, default: false, desc: "Lock analysis BPM output to --bpm"
470
+ option :out, type: :string, default: "frames", desc: "Output directory for PNG frames, or .mp4 video path"
471
+ option :frames, type: :numeric, default: Vizcore::Renderer::RenderSequence::DEFAULT_FRAME_COUNT, desc: "Number of frames to write"
472
+ option :fps, type: :numeric, default: Vizcore::Renderer::RenderSequence::DEFAULT_FRAME_RATE, desc: "Render frame rate"
473
+ option :width, type: :numeric, default: Vizcore::Renderer::SnapshotRenderer::DEFAULT_WIDTH, desc: "Frame width"
474
+ option :height, type: :numeric, default: Vizcore::Renderer::SnapshotRenderer::DEFAULT_HEIGHT, desc: "Frame height"
475
+ # Load a scene DSL file and write a software-rendered PNG image sequence or MP4.
476
+ #
477
+ # @param scene_file [String] path to a Ruby scene DSL file
478
+ # @raise [Thor::Error] when scene loading or frame writing fails
479
+ # @return [void]
480
+ def render(scene_file)
481
+ config = Config.new(
482
+ scene_file: scene_file,
483
+ audio_source: options.fetch(:audio_source),
484
+ audio_file: options[:audio_file],
485
+ audio_device: options[:audio_device],
486
+ noise_gate: options.fetch(:noise_gate),
487
+ bpm: options[:bpm],
488
+ bpm_lock: options.fetch(:bpm_lock)
489
+ )
490
+ validate_snapshot_config!(config)
491
+
492
+ result = Vizcore::Renderer::RenderSequence.new(
493
+ config: config,
494
+ frames: options.fetch(:frames),
495
+ fps: options.fetch(:fps),
496
+ width: options.fetch(:width),
497
+ height: options.fetch(:height)
498
+ ).write(out: options.fetch(:out))
499
+ return say(render_video_message(result)) if result[:format] == :mp4
500
+
501
+ say(
502
+ "Frames written: #{result[:path]} " \
503
+ "(scene=#{result[:scene]}, frames=#{result[:frames]}, fps=#{result[:fps]}, #{result[:width]}x#{result[:height]})"
504
+ )
505
+ rescue StandardError => e
506
+ raise Thor::Error, e.message
507
+ end
508
+
509
+ map "record-features" => :record_features
510
+ desc "record-features AUDIO_FILE", "Record audio analysis features to JSON"
511
+ option :out, type: :string, default: "features.json", desc: "Output JSON path"
512
+ option :frames, type: :numeric, default: Vizcore::Analysis::FeatureRecorder::DEFAULT_FRAME_COUNT, desc: "Number of analysis frames to record"
513
+ option :fps, type: :numeric, default: Vizcore::Analysis::FeatureRecorder::DEFAULT_FRAME_RATE, desc: "Analysis frame rate"
514
+ option :noise_gate, type: :numeric, default: Config::DEFAULT_NOISE_GATE, desc: "RMS level below which audio is treated as silence"
515
+ option :audio_normalize, type: :boolean, default: false, desc: "Apply adaptive feature normalization"
516
+ option :bpm, type: :numeric, desc: "Fixed BPM value used with --bpm-lock"
517
+ option :bpm_lock, type: :boolean, default: false, desc: "Lock analysis BPM output to --bpm"
518
+ # Analyze an audio file and persist feature frames as JSON.
519
+ #
520
+ # @param audio_file [String] path to WAV/MP3/FLAC audio file
521
+ # @raise [Thor::Error] when audio loading or JSON writing fails
522
+ # @return [void]
523
+ def record_features(audio_file)
524
+ result = Vizcore::Analysis::FeatureRecorder.new(
525
+ audio_file: audio_file,
526
+ frames: options.fetch(:frames),
527
+ fps: options.fetch(:fps),
528
+ noise_gate: options.fetch(:noise_gate),
529
+ audio_normalize: feature_audio_normalize_setting,
530
+ bpm: options[:bpm],
531
+ bpm_lock: options.fetch(:bpm_lock)
532
+ ).write(out: options.fetch(:out))
533
+ say(
534
+ "Features written: #{result[:path]} " \
535
+ "(frames=#{result[:frames]}, fps=#{result[:fps]}, sample_rate=#{result[:sample_rate]})"
536
+ )
537
+ rescue StandardError => e
538
+ raise Thor::Error, e.message
539
+ end
540
+
89
541
  private
90
542
 
91
- def write_template(template_name, destination, project_name:)
543
+ def status_label(status)
544
+ case status
545
+ when :ok
546
+ "[ok]"
547
+ when :warn
548
+ "[warn]"
549
+ else
550
+ "[fail]"
551
+ end
552
+ end
553
+
554
+ def print_issues(issues)
555
+ issues.each do |issue|
556
+ label = issue.error? ? "[error]" : "[warn]"
557
+ say("#{label} #{issue.message}")
558
+ end
559
+ end
560
+
561
+ def validate_snapshot_config!(config)
562
+ raise ArgumentError, "Scene file not found: #{config.scene_file || '(nil)'}" unless config.scene_exists?
563
+ return unless config.audio_source == :file
564
+ return if config.audio_file&.file?
565
+
566
+ raise ArgumentError, "Audio file not found: #{config.audio_file || '(nil)'}"
567
+ end
568
+
569
+ def create_shader_template(name)
570
+ raise ArgumentError, "shader name is required" if name.to_s.strip.empty?
571
+
572
+ destination = options[:out] || Vizcore::CLISupport::ShaderTemplate.default_path(name)
573
+ path = Vizcore::CLISupport::ShaderTemplate.new.write(destination)
574
+ say("Shader template written: #{path}")
575
+ end
576
+
577
+ def write_template(template_name, destination, project_name: nil, replacements: {})
92
578
  template_path = Vizcore.templates_root.join(template_name)
93
- body = template_path.read.gsub("{{project_name}}", project_name)
579
+ values = replacements.transform_keys(&:to_s)
580
+ values["{{project_name}}"] = project_name if project_name
581
+ body = template_path.read
582
+ values.each do |placeholder, value|
583
+ body = body.gsub(placeholder, value.to_s)
584
+ end
585
+ FileUtils.mkdir_p(destination.dirname)
586
+ destination.write(body)
587
+ end
588
+
589
+ def write_project_readme(destination, project_name:, scaffold:)
590
+ template_path = Vizcore.templates_root.join("project_readme.md")
591
+ body = template_path.read
592
+ .gsub("{{project_name}}", project_name)
593
+ .gsub("{{template_name}}", scaffold.fetch(:label))
594
+ .gsub("{{start_scene}}", scaffold.fetch(:start_scene))
595
+ .gsub("{{included_files}}", scaffold_files(scaffold))
596
+ .gsub("{{template_notes}}", scaffold_notes(scaffold))
597
+ FileUtils.mkdir_p(destination.dirname)
94
598
  destination.write(body)
95
599
  end
96
600
 
601
+ def scaffold_template(name)
602
+ key = name.to_s.strip.downcase
603
+ key = "standard" if key.empty? || key == "default"
604
+ scaffold = SCAFFOLD_TEMPLATES[key]
605
+ return scaffold if scaffold
606
+
607
+ raise ArgumentError, "Unknown template: #{name}. Use one of: #{SCAFFOLD_TEMPLATES.keys.join(', ')}"
608
+ end
609
+
610
+ def scaffold_files(scaffold)
611
+ scaffold.fetch(:files).map do |_template_name, destination, description|
612
+ "- `#{destination}`: #{description}"
613
+ end.join("\n")
614
+ end
615
+
616
+ def scaffold_notes(scaffold)
617
+ notes = Array(scaffold[:notes])
618
+ return "No extra setup is required." if notes.empty?
619
+
620
+ notes.map { |note| "- #{note}" }.join("\n")
621
+ end
622
+
623
+ def render_video_message(result)
624
+ "Video written: #{result[:path]} " \
625
+ "(scene=#{result[:scene]}, frames=#{result[:frames]}, fps=#{result[:fps]}, #{result[:width]}x#{result[:height]})"
626
+ end
627
+
628
+ def run_browser_capture(url, out:, selector:, wait:, width:, height:)
629
+ script = Vizcore.root.join("scripts", "browser_capture.mjs")
630
+ command = [
631
+ "node",
632
+ script.to_s,
633
+ url.to_s,
634
+ "--out",
635
+ out.to_s,
636
+ "--selector",
637
+ selector.to_s,
638
+ "--wait",
639
+ wait.to_s,
640
+ "--width",
641
+ width.to_s,
642
+ "--height",
643
+ height.to_s
644
+ ]
645
+ success = Kernel.system(*command)
646
+ raise Thor::Error, "browser capture failed" unless success
647
+ end
648
+
649
+ def temporary_server_command(config)
650
+ command = [
651
+ Gem.ruby,
652
+ "-I#{Vizcore.root.join('lib')}",
653
+ Vizcore.root.join("exe", "vizcore").to_s,
654
+ "start",
655
+ config.scene_file.to_s,
656
+ "--host",
657
+ config.host,
658
+ "--port",
659
+ config.port.to_s,
660
+ "--audio-source",
661
+ config.audio_source.to_s,
662
+ "--no-reload",
663
+ "--projector"
664
+ ]
665
+ command.concat(["--audio-file", config.audio_file.to_s]) if config.audio_file
666
+ command.concat(["--feature-file", config.feature_file.to_s]) if config.feature_file
667
+ command.concat(["--control-preset", config.control_preset.to_s]) if config.control_preset
668
+ command
669
+ end
670
+
671
+ def wait_for_http(url, timeout:)
672
+ return true if Float(timeout) <= 0
673
+
674
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + Float(timeout)
675
+ uri = URI(url)
676
+ loop do
677
+ response = Net::HTTP.get_response(uri)
678
+ return true if response.is_a?(Net::HTTPSuccess)
679
+ raise "Timed out waiting for #{url}" if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
680
+
681
+ sleep(0.1)
682
+ rescue StandardError
683
+ raise "Timed out waiting for #{url}" if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
684
+
685
+ sleep(0.1)
686
+ end
687
+ end
688
+
689
+ def stop_temporary_server(pid)
690
+ Process.kill("TERM", pid)
691
+ Process.wait(pid)
692
+ rescue Errno::ECHILD, Errno::ESRCH
693
+ nil
694
+ end
695
+
696
+ def create_plugin_scaffold(name)
697
+ metadata = plugin_scaffold_metadata(name)
698
+ root = Pathname.new(options[:out] || metadata.fetch(:plugin_name)).expand_path
699
+ replacements = metadata.transform_keys { |key| "{{#{key}}}" }
700
+
701
+ FileUtils.mkdir_p(root)
702
+ PLUGIN_SCAFFOLD_FILES.each do |template_name, destination|
703
+ rendered_destination = destination.dup
704
+ replacements.each do |placeholder, value|
705
+ rendered_destination = rendered_destination.gsub(placeholder, value.to_s)
706
+ end
707
+ write_template(template_name, root.join(rendered_destination), replacements: replacements)
708
+ end
709
+
710
+ say("Created plugin scaffold: #{root}")
711
+ say("Next: require_relative \"#{root.basename}/lib/#{metadata.fetch(:plugin_name)}\" in your scene")
712
+ end
713
+
714
+ def plugin_scaffold_metadata(name)
715
+ raw_name = name.to_s.strip
716
+ raise ArgumentError, "plugin name is required" if raw_name.empty?
717
+
718
+ plugin_name = normalize_plugin_name(raw_name)
719
+ raise ArgumentError, "plugin name must contain letters or numbers" if plugin_name.empty?
720
+
721
+ module_name = plugin_module_name(plugin_name)
722
+ {
723
+ plugin_name: plugin_name,
724
+ plugin_module: module_name,
725
+ plugin_renderer: "render#{module_name}",
726
+ plugin_type: "#{plugin_name}_layer",
727
+ plugin_title: plugin_name.split("_").map(&:capitalize).join(" ")
728
+ }
729
+ end
730
+
731
+ def normalize_plugin_name(name)
732
+ name.gsub(/([a-z\d])([A-Z])/, "\\1_\\2")
733
+ .tr("- ", "__")
734
+ .gsub(/[^a-zA-Z0-9_]/, "_")
735
+ .gsub(/_+/, "_")
736
+ .downcase
737
+ .sub(/\A_+/, "")
738
+ .sub(/_+\z/, "")
739
+ end
740
+
741
+ def plugin_module_name(plugin_name)
742
+ module_name = plugin_name.split("_").map(&:capitalize).join
743
+ module_name.match?(/\A[A-Z]/) ? module_name : "Plugin#{module_name}"
744
+ end
745
+
746
+ def load_project_manifest(path)
747
+ return nil if path.to_s.strip.empty?
748
+
749
+ Vizcore::ProjectManifest.load(path)
750
+ end
751
+
752
+ def load_manifest_plugins(manifest, profile: nil)
753
+ return unless manifest
754
+
755
+ manifest.plugins(profile: profile).each do |plugin|
756
+ Vizcore.plugin(plugin)
757
+ rescue LoadError => e
758
+ raise Thor::Error, "Failed to load manifest plugin #{plugin}: #{e.message}"
759
+ end
760
+ end
761
+
762
+ def feature_audio_normalize_setting
763
+ return nil unless options.fetch(:audio_normalize)
764
+
765
+ { mode: :adaptive }
766
+ end
767
+
97
768
  def print_audio_devices
98
769
  say("Audio devices:")
99
770
  Vizcore::Audio::InputManager.available_audio_devices.each do |device|