vizcore 0.1.0 → 1.1.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 (137) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +70 -117
  3. data/docs/.nojekyll +0 -0
  4. data/docs/assets/playground-worker.js +373 -0
  5. data/docs/assets/playground.css +440 -0
  6. data/docs/assets/playground.js +652 -0
  7. data/docs/assets/site.css +744 -0
  8. data/docs/assets/vizcore-demo.gif +0 -0
  9. data/docs/assets/vizcore-poster.png +0 -0
  10. data/docs/assets/vj-tunnel.js +159 -0
  11. data/docs/index.html +225 -0
  12. data/docs/playground.html +81 -0
  13. data/docs/shape_dsl.md +269 -0
  14. data/examples/README.md +59 -0
  15. data/examples/assets/README.md +19 -0
  16. data/examples/audio_inspector.rb +34 -0
  17. data/examples/club_intro_drop.rb +78 -0
  18. data/examples/kansai_rubykaigi_visual.rb +70 -0
  19. data/examples/live_coding_minimal.rb +22 -0
  20. data/examples/midi_controller_show.rb +78 -0
  21. data/examples/midi_scene_switch.rb +3 -1
  22. data/examples/parser_visualizer.rb +48 -0
  23. data/examples/readme_demo.rb +17 -0
  24. data/examples/rhythm_geometry.rb +34 -0
  25. data/examples/ruby_crystal_show.rb +35 -0
  26. data/examples/shader_playground.rb +18 -0
  27. data/examples/unyo_liquid.rb +59 -0
  28. data/examples/vj_ambient_chill_room.rb +124 -0
  29. data/examples/vj_dnb_jungle.rb +170 -0
  30. data/examples/vj_festival_mainstage.rb +245 -0
  31. data/examples/vj_festival_mainstage.yml +17 -0
  32. data/examples/vj_glitch_industrial.rb +164 -0
  33. data/examples/vj_hiphop_cipher.rb +167 -0
  34. data/examples/vj_jpop_idol_live.rb +210 -0
  35. data/examples/vj_synthwave_retro.rb +173 -0
  36. data/examples/vj_techno_warehouse.rb +195 -0
  37. data/frontend/index.html +494 -2
  38. data/frontend/src/audio-inspector.js +40 -0
  39. data/frontend/src/custom-shape-param-controls.js +106 -0
  40. data/frontend/src/live-controls.js +131 -0
  41. data/frontend/src/main.js +1060 -16
  42. data/frontend/src/mapping-target-selector.js +109 -0
  43. data/frontend/src/midi-learn.js +194 -0
  44. data/frontend/src/performance-monitor.js +183 -0
  45. data/frontend/src/plugin-runtime.js +130 -0
  46. data/frontend/src/projector-mode.js +56 -0
  47. data/frontend/src/renderer/engine.js +157 -3
  48. data/frontend/src/renderer/layer-manager.js +442 -30
  49. data/frontend/src/renderer/shader-manager.js +26 -0
  50. data/frontend/src/runtime-control-preset.js +11 -0
  51. data/frontend/src/shader-error-overlay.js +29 -0
  52. data/frontend/src/shader-param-controls.js +93 -0
  53. data/frontend/src/shaders/builtins.js +380 -2
  54. data/frontend/src/shaders/post-effects.js +52 -0
  55. data/frontend/src/shape-editor-controls.js +157 -0
  56. data/frontend/src/visual-regression.js +67 -0
  57. data/frontend/src/visual-settings-preset.js +103 -0
  58. data/frontend/src/visuals/geometry.js +666 -0
  59. data/frontend/src/visuals/image-renderer.js +291 -0
  60. data/frontend/src/visuals/particle-system.js +56 -10
  61. data/frontend/src/visuals/shape-renderer.js +475 -0
  62. data/frontend/src/visuals/spectrogram-renderer.js +226 -0
  63. data/frontend/src/visuals/svg-arc.js +104 -0
  64. data/frontend/src/visuals/text-renderer.js +112 -11
  65. data/frontend/src/websocket-client.js +12 -1
  66. data/lib/vizcore/analysis/adaptive_normalizer.rb +70 -0
  67. data/lib/vizcore/analysis/beat_detector.rb +4 -2
  68. data/lib/vizcore/analysis/bpm_estimator.rb +8 -0
  69. data/lib/vizcore/analysis/feature_recorder.rb +159 -0
  70. data/lib/vizcore/analysis/feature_replay.rb +84 -0
  71. data/lib/vizcore/analysis/pipeline.rb +235 -11
  72. data/lib/vizcore/analysis/tap_tempo.rb +74 -0
  73. data/lib/vizcore/analysis.rb +4 -0
  74. data/lib/vizcore/audio/dummy_sine_input.rb +1 -1
  75. data/lib/vizcore/audio/fixture_input.rb +65 -0
  76. data/lib/vizcore/audio/input_manager.rb +4 -2
  77. data/lib/vizcore/audio/mic_input.rb +24 -8
  78. data/lib/vizcore/audio/portaudio_ffi.rb +106 -1
  79. data/lib/vizcore/audio.rb +1 -0
  80. data/lib/vizcore/cli/doctor.rb +159 -0
  81. data/lib/vizcore/cli/dsl_reference.rb +99 -0
  82. data/lib/vizcore/cli/layer_docs.rb +46 -0
  83. data/lib/vizcore/cli/scene_diagnostics.rb +23 -0
  84. data/lib/vizcore/cli/scene_inspector.rb +136 -0
  85. data/lib/vizcore/cli/scene_validator.rb +337 -0
  86. data/lib/vizcore/cli/shader_template.rb +68 -0
  87. data/lib/vizcore/cli/shader_uniform_docs.rb +54 -0
  88. data/lib/vizcore/cli.rb +689 -18
  89. data/lib/vizcore/config.rb +103 -2
  90. data/lib/vizcore/control_preset.rb +68 -0
  91. data/lib/vizcore/dsl/engine.rb +277 -5
  92. data/lib/vizcore/dsl/layer_builder.rb +1280 -23
  93. data/lib/vizcore/dsl/layer_group_builder.rb +112 -0
  94. data/lib/vizcore/dsl/mapping_resolver.rb +290 -7
  95. data/lib/vizcore/dsl/mapping_transform_builder.rb +71 -0
  96. data/lib/vizcore/dsl/reaction_builder.rb +44 -0
  97. data/lib/vizcore/dsl/scene_builder.rb +61 -5
  98. data/lib/vizcore/dsl/shader_source_resolver.rb +67 -6
  99. data/lib/vizcore/dsl/style_builder.rb +68 -0
  100. data/lib/vizcore/dsl/timeline_builder.rb +138 -0
  101. data/lib/vizcore/dsl/transition_controller.rb +77 -0
  102. data/lib/vizcore/dsl.rb +5 -1
  103. data/lib/vizcore/layer_catalog.rb +275 -0
  104. data/lib/vizcore/project_manifest.rb +152 -0
  105. data/lib/vizcore/renderer/png_writer.rb +57 -0
  106. data/lib/vizcore/renderer/render_sequence.rb +153 -0
  107. data/lib/vizcore/renderer/scene_frame_source.rb +132 -0
  108. data/lib/vizcore/renderer/scene_serializer.rb +36 -3
  109. data/lib/vizcore/renderer/snapshot.rb +38 -0
  110. data/lib/vizcore/renderer/snapshot_renderer.rb +938 -0
  111. data/lib/vizcore/renderer.rb +5 -0
  112. data/lib/vizcore/server/frame_broadcaster.rb +143 -8
  113. data/lib/vizcore/server/gallery_app.rb +155 -0
  114. data/lib/vizcore/server/gallery_page.rb +100 -0
  115. data/lib/vizcore/server/gallery_runner.rb +48 -0
  116. data/lib/vizcore/server/rack_app.rb +203 -4
  117. data/lib/vizcore/server/runner.rb +391 -22
  118. data/lib/vizcore/server/scene_dependency_watcher.rb +79 -0
  119. data/lib/vizcore/server/websocket_handler.rb +60 -10
  120. data/lib/vizcore/server.rb +4 -0
  121. data/lib/vizcore/shape.rb +719 -0
  122. data/lib/vizcore/sync/osc_message.rb +103 -0
  123. data/lib/vizcore/sync/osc_receiver.rb +68 -0
  124. data/lib/vizcore/sync.rb +4 -0
  125. data/lib/vizcore/templates/midi_control_scene.rb +3 -1
  126. data/lib/vizcore/templates/plugin_layer.rb +20 -0
  127. data/lib/vizcore/templates/plugin_readme.md +23 -0
  128. data/lib/vizcore/templates/plugin_renderer.js +43 -0
  129. data/lib/vizcore/templates/plugin_scene.rb +14 -0
  130. data/lib/vizcore/templates/project_readme.md +7 -23
  131. data/lib/vizcore/templates/rubykaigi_scene.rb +30 -0
  132. data/lib/vizcore/version.rb +1 -1
  133. data/lib/vizcore.rb +28 -0
  134. data/scripts/browser_capture.mjs +75 -0
  135. data/sig/vizcore.rbs +461 -0
  136. metadata +94 -3
  137. data/docs/GETTING_STARTED.md +0 -105
@@ -7,4 +7,9 @@ module Vizcore
7
7
  end
8
8
 
9
9
  require_relative "renderer/frame_scheduler"
10
+ require_relative "renderer/png_writer"
11
+ require_relative "renderer/scene_frame_source"
12
+ require_relative "renderer/render_sequence"
10
13
  require_relative "renderer/scene_serializer"
14
+ require_relative "renderer/snapshot"
15
+ require_relative "renderer/snapshot_renderer"
@@ -23,6 +23,10 @@ module Vizcore
23
23
  # @param scene_catalog [Array<Hash>, nil]
24
24
  # @param transitions [Array<Hash>, nil]
25
25
  # @param transition_controller [Vizcore::DSL::TransitionController, nil]
26
+ # @param noise_gate [Numeric]
27
+ # @param audio_normalize [Hash, nil]
28
+ # @param bpm [Numeric, nil]
29
+ # @param bpm_lock [Boolean]
26
30
  # @param error_reporter [#call, nil]
27
31
  def initialize(
28
32
  scene_name: "basic",
@@ -35,6 +39,10 @@ module Vizcore
35
39
  scene_catalog: nil,
36
40
  transitions: nil,
37
41
  transition_controller: nil,
42
+ noise_gate: Vizcore::Analysis::Pipeline::DEFAULT_NOISE_GATE,
43
+ audio_normalize: nil,
44
+ bpm: nil,
45
+ bpm_lock: false,
38
46
  error_reporter: nil
39
47
  )
40
48
  @scene_name = scene_name
@@ -44,7 +52,11 @@ module Vizcore
44
52
  fft_size = supported_fft_size(@input_manager.frame_size)
45
53
  @analysis_pipeline = analysis_pipeline || Vizcore::Analysis::Pipeline.new(
46
54
  sample_rate: @input_manager.sample_rate,
47
- fft_size: fft_size
55
+ fft_size: fft_size,
56
+ noise_gate: noise_gate,
57
+ audio_normalize: audio_normalize,
58
+ bpm: bpm,
59
+ bpm_lock: bpm_lock
48
60
  )
49
61
  @mapping_resolver = mapping_resolver || Vizcore::DSL::MappingResolver.new
50
62
  @scene_serializer = scene_serializer || Vizcore::Renderer::SceneSerializer.new
@@ -55,8 +67,11 @@ module Vizcore
55
67
  @error_reporter = error_reporter || ->(_message) {}
56
68
  @last_error = nil
57
69
  @frame_count = 0
70
+ @custom_shape_param_overrides = {}
71
+ @custom_shape_param_mutex = Mutex.new
58
72
  @transport_playing = initial_transport_playing_state
59
73
  reset_transition_trigger_counters!
74
+ @tap_tempo = Vizcore::Analysis::TapTempo.new
60
75
  @frame_scheduler = frame_scheduler || Vizcore::Renderer::FrameScheduler.new(frame_rate: FRAME_RATE) do |elapsed|
61
76
  tick(elapsed)
62
77
  end
@@ -148,24 +163,98 @@ module Vizcore
148
163
  end
149
164
  end
150
165
 
166
+ # Replace audio analysis settings after scene hot reload.
167
+ #
168
+ # @param audio_normalize [Hash, nil]
169
+ # @param bpm [Numeric, nil]
170
+ # @param bpm_lock [Boolean]
171
+ # @return [void]
172
+ def update_analysis_settings(audio_normalize:, bpm: nil, bpm_lock: false)
173
+ return unless @analysis_pipeline.respond_to?(:audio_normalize=)
174
+
175
+ @analysis_pipeline.audio_normalize = audio_normalize
176
+ @analysis_pipeline.bpm_lock = { bpm: bpm, locked: bpm_lock } if @analysis_pipeline.respond_to?(:bpm_lock=)
177
+ end
178
+
179
+ # Apply a manual tap tempo event and lock analysis BPM when enough taps exist.
180
+ #
181
+ # @param timestamp_ms [Numeric]
182
+ # @return [Float, nil]
183
+ def tap_tempo(timestamp_ms:)
184
+ bpm = @tap_tempo.tap(timestamp_ms: timestamp_ms)
185
+ return nil unless bpm
186
+ return bpm unless @analysis_pipeline.respond_to?(:bpm_lock=)
187
+
188
+ @analysis_pipeline.bpm_lock = { bpm: bpm, locked: true }
189
+ bpm
190
+ end
191
+
192
+ # Lock analysis BPM from an external sync source.
193
+ #
194
+ # @param bpm [Numeric]
195
+ # @return [Float, nil]
196
+ def lock_bpm(bpm)
197
+ numeric = Float(bpm)
198
+ return nil unless numeric.finite? && numeric.positive?
199
+ return numeric unless @analysis_pipeline.respond_to?(:bpm_lock=)
200
+
201
+ @analysis_pipeline.bpm_lock = { bpm: numeric, locked: true }
202
+ numeric
203
+ rescue ArgumentError, TypeError
204
+ nil
205
+ end
206
+
207
+ # Unlock analysis BPM after an external sync lock.
208
+ #
209
+ # @return [Boolean]
210
+ def unlock_bpm
211
+ @analysis_pipeline.bpm_lock = { bpm: nil, locked: false } if @analysis_pipeline.respond_to?(:bpm_lock=)
212
+ true
213
+ end
214
+
215
+ def set_custom_shape_param(layer_name:, custom_shape_index:, param:, value:)
216
+ layer_key = layer_name.to_s
217
+ param_key = param.to_s.strip
218
+ index = Integer(custom_shape_index)
219
+ numeric = finite_float(value)
220
+ return custom_shape_param_overrides_snapshot if layer_key.empty? || param_key.empty? || index.negative? || numeric.nil?
221
+
222
+ @custom_shape_param_mutex.synchronize do
223
+ @custom_shape_param_overrides[layer_key] ||= {}
224
+ @custom_shape_param_overrides[layer_key][index] ||= {}
225
+ @custom_shape_param_overrides[layer_key][index][param_key] = numeric
226
+ deep_dup(@custom_shape_param_overrides)
227
+ end
228
+ rescue ArgumentError, TypeError
229
+ custom_shape_param_overrides_snapshot
230
+ end
231
+
151
232
  # Build one frame payload for transport to frontend.
152
233
  #
153
234
  # @param _elapsed_seconds [Float]
154
235
  # @param samples [Array<Float>, nil]
155
236
  # @raise [Vizcore::FrameBuildError] when frame construction fails
156
237
  # @return [Hash]
157
- def build_frame(_elapsed_seconds, samples = nil)
158
- audio_samples = samples || capture_samples
159
- analyzed = @analysis_pipeline.call(audio_samples)
238
+ def build_frame(elapsed_seconds, samples = nil)
239
+ started_at_ms = monotonic_ms
240
+ audio_samples, audio_capture_ms = capture_or_use_samples(samples)
241
+ analyzed, audio_analysis_ms = measure_ms { @analysis_pipeline.call(audio_samples) }
160
242
  scene = current_scene
161
- layers = build_scene_layers(scene[:layers], analyzed)
243
+ layers, scene_build_ms = measure_ms { build_scene_layers(scene[:layers], analyzed, time: elapsed_seconds, frame: @frame_count) }
162
244
 
163
245
  @scene_serializer.audio_frame(
164
246
  timestamp: Time.now.to_f,
165
247
  audio: analyzed,
166
248
  scene_name: scene[:name],
167
249
  scene_layers: layers,
168
- transition: nil
250
+ transition: nil,
251
+ metrics: {
252
+ frame_id: @frame_count,
253
+ audio_capture_ms: audio_capture_ms,
254
+ audio_analysis_ms: audio_analysis_ms,
255
+ scene_build_ms: scene_build_ms,
256
+ server_frame_ms: monotonic_ms - started_at_ms
257
+ }
169
258
  )
170
259
  rescue StandardError => e
171
260
  report_error(e, context: "frame build failed")
@@ -174,6 +263,22 @@ module Vizcore
174
263
 
175
264
  private
176
265
 
266
+ def capture_or_use_samples(samples)
267
+ return [samples, 0.0] if samples
268
+
269
+ measure_ms { capture_samples }
270
+ end
271
+
272
+ def measure_ms
273
+ started_at = monotonic_ms
274
+ result = yield
275
+ [result, monotonic_ms - started_at]
276
+ end
277
+
278
+ def monotonic_ms
279
+ Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond)
280
+ end
281
+
177
282
  def capture_samples
178
283
  ingest_count =
179
284
  if @input_manager.respond_to?(:realtime_capture_size)
@@ -207,10 +312,40 @@ module Vizcore
207
312
  value.positive? && (value & (value - 1)).zero?
208
313
  end
209
314
 
210
- def build_scene_layers(scene_layers, analyzed)
315
+ def build_scene_layers(scene_layers, analyzed, time: 0.0, frame: 0)
211
316
  return default_scene_layers(analyzed) if scene_layers.empty?
212
317
 
213
- @mapping_resolver.resolve_layers(scene_layers: scene_layers, audio: analyzed)
318
+ @mapping_resolver.resolve_layers(
319
+ scene_layers: scene_layers,
320
+ audio: analyzed,
321
+ time: time,
322
+ frame: frame,
323
+ custom_shape_overrides: custom_shape_param_overrides_snapshot
324
+ )
325
+ end
326
+
327
+ def custom_shape_param_overrides_snapshot
328
+ @custom_shape_param_mutex.synchronize { deep_dup(@custom_shape_param_overrides) }
329
+ end
330
+
331
+ def finite_float(value)
332
+ numeric = Float(value)
333
+ return nil unless numeric.finite?
334
+
335
+ numeric
336
+ rescue ArgumentError, TypeError
337
+ nil
338
+ end
339
+
340
+ def deep_dup(value)
341
+ case value
342
+ when Hash
343
+ value.each_with_object({}) { |(key, entry), output| output[key] = deep_dup(entry) }
344
+ when Array
345
+ value.map { |entry| deep_dup(entry) }
346
+ else
347
+ value
348
+ end
214
349
  end
215
350
 
216
351
  def default_scene_layers(analyzed)
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "pathname"
5
+ require "rack"
6
+ require_relative "../dsl"
7
+ require_relative "gallery_page"
8
+
9
+ module Vizcore
10
+ module Server
11
+ # Rack app that lists bundled example scenes and their launch commands.
12
+ class GalleryApp
13
+ POSTER_PATH = "/assets/vizcore-poster.png"
14
+ DESCRIPTIONS = {
15
+ "basic.rb" => "Single wireframe cube starter.",
16
+ "intro_drop.rb" => "Beat-triggered intro to drop transition.",
17
+ "file_audio_demo.rb" => "File-audio walkthrough with layered visuals.",
18
+ "complex_audio_showcase.rb" => "Dense multi-scene showcase for audio-reactive layers.",
19
+ "rhythm_geometry.rb" => "Morphing geometric pattern driven by rhythm and bands.",
20
+ "ruby_crystal_show.rb" => "Ruby-themed crystal, particles, and text showcase.",
21
+ "parser_visualizer.rb" => "Parser-themed token, AST, and reduce visual sketch.",
22
+ "live_coding_minimal.rb" => "Tiny live-coding scene with a pulsing blob.",
23
+ "club_intro_drop.rb" => "Intro, build, and drop flow for rhythmic file input.",
24
+ "shader_playground.rb" => "Focused liquid shader scene with mapped params.",
25
+ "audio_inspector.rb" => "Audio feature visualization scene with bars and blob.",
26
+ "readme_demo.rb" => "Minimal beat pulse to ring radius demo.",
27
+ "midi_scene_switch.rb" => "MIDI note and CC driven scene switching.",
28
+ "midi_controller_show.rb" => "MIDI pads switch scenes and knobs drive global shader uniforms.",
29
+ "kansai_rubykaigi_visual.rb" => "Event showcase with ruby crystal, water ripple, and Kyoto-inspired pattern.",
30
+ "custom_shader.rb" => "Custom GLSL fragment shader example.",
31
+ "unyo_liquid.rb" => "Organic liquid wobble scene with FFT blobs and particles."
32
+ }.freeze
33
+ FILE_AUDIO_EXAMPLES = %w[
34
+ file_audio_demo.rb
35
+ complex_audio_showcase.rb
36
+ rhythm_geometry.rb
37
+ ruby_crystal_show.rb
38
+ parser_visualizer.rb
39
+ club_intro_drop.rb
40
+ audio_inspector.rb
41
+ readme_demo.rb
42
+ kansai_rubykaigi_visual.rb
43
+ ].freeze
44
+ ORDER = DESCRIPTIONS.keys.freeze
45
+
46
+ # @param examples_root [Pathname, String]
47
+ # @param docs_assets_root [Pathname, String]
48
+ def initialize(
49
+ examples_root: Vizcore.root.join("examples"),
50
+ docs_assets_root: Vizcore.root.join("docs", "assets")
51
+ )
52
+ @examples_root = Pathname.new(examples_root.to_s).expand_path
53
+ @docs_assets_root = Pathname.new(docs_assets_root.to_s).expand_path
54
+ end
55
+
56
+ # @param env [Hash]
57
+ # @return [Array(Integer, Hash, Array<String>)]
58
+ def call(env)
59
+ request = Rack::Request.new(env)
60
+
61
+ return html_response if request.path_info == "/"
62
+ return json_response if request.path_info == "/examples.json"
63
+ return poster_response if request.path_info == POSTER_PATH
64
+ return health_response if request.path_info == "/health"
65
+
66
+ not_found_response
67
+ end
68
+
69
+ private
70
+
71
+ def html_response
72
+ body = GalleryPage.new(entries: examples, poster_path: POSTER_PATH).render
73
+ response(body, content_type: "text/html; charset=utf-8")
74
+ end
75
+
76
+ def json_response
77
+ body = JSON.generate(examples: examples)
78
+ response(body, content_type: "application/json; charset=utf-8")
79
+ end
80
+
81
+ def health_response
82
+ body = JSON.generate(status: "ok", examples: examples.length)
83
+ response(body, content_type: "application/json; charset=utf-8")
84
+ end
85
+
86
+ def poster_response
87
+ path = @docs_assets_root.join("vizcore-poster.png")
88
+ return not_found_response unless path.file?
89
+
90
+ response(File.binread(path), content_type: "image/png")
91
+ end
92
+
93
+ def examples
94
+ example_paths.map { |path| example_payload(path) }
95
+ end
96
+
97
+ def example_paths
98
+ paths = @examples_root.children.select { |path| path.file? && path.extname == ".rb" }
99
+ paths.sort_by { |path| [ORDER.index(path.basename.to_s) || ORDER.length, path.basename.to_s] }
100
+ rescue Errno::ENOENT
101
+ []
102
+ end
103
+
104
+ def example_payload(path)
105
+ definition = Vizcore::DSL::Engine.load_file(path.to_s)
106
+ scenes = Array(definition[:scenes])
107
+ {
108
+ file: display_path(path),
109
+ title: path.basename(".rb").to_s.tr("_", " "),
110
+ description: DESCRIPTIONS.fetch(path.basename.to_s, "Vizcore example scene."),
111
+ scene_names: scenes.map { |scene| scene[:name].to_s },
112
+ layer_count: scenes.sum { |scene| Array(scene[:layers]).length },
113
+ command: launch_command(path),
114
+ audio_source: audio_source_for(path)
115
+ }
116
+ rescue StandardError => e
117
+ {
118
+ file: display_path(path),
119
+ title: path.basename(".rb").to_s.tr("_", " "),
120
+ description: "This example could not be inspected: #{e.message}",
121
+ scene_names: [],
122
+ layer_count: 0,
123
+ command: launch_command(path),
124
+ audio_source: audio_source_for(path)
125
+ }
126
+ end
127
+
128
+ def launch_command(path)
129
+ command = "vizcore start #{display_path(path)} --audio-source #{audio_source_for(path)}"
130
+ return command unless audio_source_for(path) == "file"
131
+
132
+ "#{command} --audio-file #{display_path(@examples_root.join('assets', 'complex_demo_loop.wav'))}"
133
+ end
134
+
135
+ def audio_source_for(path)
136
+ FILE_AUDIO_EXAMPLES.include?(path.basename.to_s) ? "file" : "dummy"
137
+ end
138
+
139
+ def display_path(path)
140
+ path = Pathname.new(path.to_s).expand_path
141
+ path.relative_path_from(Vizcore.root).to_s
142
+ rescue ArgumentError
143
+ path.to_s
144
+ end
145
+
146
+ def response(body, content_type:)
147
+ [200, { "content-type" => content_type, "content-length" => body.bytesize.to_s, "cache-control" => "no-store" }, [body]]
148
+ end
149
+
150
+ def not_found_response
151
+ [404, { "content-type" => "text/plain; charset=utf-8", "content-length" => "9" }, ["Not Found"]]
152
+ end
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+
5
+ module Vizcore
6
+ module Server
7
+ # Renders the browser HTML for the bundled example gallery.
8
+ class GalleryPage
9
+ # @param entries [Array<Hash>]
10
+ # @param poster_path [String]
11
+ def initialize(entries:, poster_path:)
12
+ @entries = entries
13
+ @poster_path = poster_path
14
+ end
15
+
16
+ # @return [String]
17
+ def render
18
+ cards = @entries.map { |entry| render_card(entry) }.join
19
+ <<~HTML
20
+ <!doctype html>
21
+ <html lang="en">
22
+ <head>
23
+ <meta charset="utf-8">
24
+ <meta name="viewport" content="width=device-width, initial-scale=1">
25
+ <title>Vizcore Example Gallery</title>
26
+ <style>#{css}</style>
27
+ </head>
28
+ <body>
29
+ <main>
30
+ <header class="header">
31
+ <img src="#{@poster_path}" alt="" class="poster">
32
+ <div>
33
+ <p class="eyebrow">Vizcore Examples</p>
34
+ <h1>Example Gallery</h1>
35
+ <p class="lede">Bundled scenes with scene counts, layer counts, audio-source hints, and launch commands.</p>
36
+ </div>
37
+ </header>
38
+ <section class="grid" aria-label="Example scenes">
39
+ #{cards}
40
+ </section>
41
+ </main>
42
+ </body>
43
+ </html>
44
+ HTML
45
+ end
46
+
47
+ private
48
+
49
+ def render_card(entry)
50
+ scenes = entry.fetch(:scene_names).empty? ? "none" : entry.fetch(:scene_names).join(", ")
51
+ <<~HTML
52
+ <article class="card">
53
+ <div class="thumb"></div>
54
+ <div class="card-body">
55
+ <h2>#{escape(entry.fetch(:title))}</h2>
56
+ <p>#{escape(entry.fetch(:description))}</p>
57
+ <dl>
58
+ <div><dt>File</dt><dd>#{escape(entry.fetch(:file))}</dd></div>
59
+ <div><dt>Scenes</dt><dd>#{escape(scenes)}</dd></div>
60
+ <div><dt>Layers</dt><dd>#{entry.fetch(:layer_count)}</dd></div>
61
+ <div><dt>Audio</dt><dd>#{escape(entry.fetch(:audio_source))}</dd></div>
62
+ </dl>
63
+ <code>#{escape(entry.fetch(:command))}</code>
64
+ </div>
65
+ </article>
66
+ HTML
67
+ end
68
+
69
+ def css
70
+ <<~CSS
71
+ :root { color-scheme: dark; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #090b10; color: #ecf4ff; }
72
+ * { box-sizing: border-box; }
73
+ body { margin: 0; min-height: 100vh; background: #090b10; }
74
+ main { width: min(1180px, calc(100% - 32px)); margin: 0 auto; padding: 32px 0 48px; }
75
+ .header { display: grid; grid-template-columns: minmax(220px, 360px) 1fr; gap: 28px; align-items: end; margin-bottom: 28px; }
76
+ .poster { width: 100%; aspect-ratio: 16 / 9; object-fit: cover; border-radius: 8px; border: 1px solid rgba(255, 255, 255, 0.14); }
77
+ .eyebrow { margin: 0 0 8px; color: #7dd3fc; font-size: 13px; text-transform: uppercase; letter-spacing: 0; }
78
+ h1 { margin: 0; font-size: 42px; line-height: 1.05; letter-spacing: 0; }
79
+ .lede { max-width: 680px; margin: 14px 0 0; color: #b8c7d9; font-size: 17px; line-height: 1.55; }
80
+ .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 16px; }
81
+ .card { overflow: hidden; border: 1px solid rgba(255, 255, 255, 0.12); border-radius: 8px; background: #111722; }
82
+ .thumb { height: 96px; background: url("#{@poster_path}") center / cover; border-bottom: 1px solid rgba(255, 255, 255, 0.1); }
83
+ .card-body { padding: 16px; }
84
+ h2 { margin: 0 0 8px; font-size: 20px; line-height: 1.2; letter-spacing: 0; }
85
+ p { margin: 0 0 14px; color: #bdcadb; line-height: 1.5; }
86
+ dl { display: grid; gap: 8px; margin: 0 0 14px; }
87
+ dl div { display: grid; grid-template-columns: 68px 1fr; gap: 10px; }
88
+ dt { color: #7dd3fc; font-size: 12px; text-transform: uppercase; }
89
+ dd { margin: 0; color: #dfe9f6; overflow-wrap: anywhere; }
90
+ code { display: block; min-height: 52px; padding: 10px; border-radius: 6px; background: #05070b; color: #b8f7d4; overflow-wrap: anywhere; line-height: 1.45; }
91
+ @media (max-width: 720px) { .header { grid-template-columns: 1fr; } h1 { font-size: 34px; } }
92
+ CSS
93
+ end
94
+
95
+ def escape(value)
96
+ CGI.escapeHTML(value.to_s)
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "puma"
4
+ require_relative "../config"
5
+ require_relative "gallery_app"
6
+
7
+ module Vizcore
8
+ module Server
9
+ # Starts a small Rack/Puma server for the example gallery.
10
+ class GalleryRunner
11
+ DEFAULT_PORT = Config::DEFAULT_PORT + 1
12
+
13
+ # @param host [String]
14
+ # @param port [Integer]
15
+ # @param output [#puts]
16
+ def initialize(host: Config::DEFAULT_HOST, port: DEFAULT_PORT, output: $stdout)
17
+ @host = host
18
+ @port = Integer(port)
19
+ @output = output
20
+ end
21
+
22
+ # @return [void]
23
+ def run
24
+ server = Puma::Server.new(GalleryApp.new, nil, min_threads: 0, max_threads: 4)
25
+ server.add_tcp_listener(@host, @port)
26
+ server.run
27
+
28
+ @output.puts("Vizcore gallery: http://#{@host}:#{@port}")
29
+ @output.puts("Press Ctrl+C to stop.")
30
+ wait_for_interrupt
31
+ ensure
32
+ server&.stop(true)
33
+ end
34
+
35
+ private
36
+
37
+ def wait_for_interrupt
38
+ stop_requested = false
39
+ %w[INT TERM].each do |signal_name|
40
+ Signal.trap(signal_name) { stop_requested = true }
41
+ rescue ArgumentError
42
+ nil
43
+ end
44
+ sleep(0.1) until stop_requested
45
+ end
46
+ end
47
+ end
48
+ end