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.
- checksums.yaml +4 -4
- data/README.md +70 -117
- data/docs/.nojekyll +0 -0
- data/docs/assets/playground-worker.js +373 -0
- data/docs/assets/playground.css +440 -0
- data/docs/assets/playground.js +652 -0
- data/docs/assets/site.css +744 -0
- data/docs/assets/vizcore-demo.gif +0 -0
- data/docs/assets/vizcore-poster.png +0 -0
- data/docs/assets/vj-tunnel.js +159 -0
- data/docs/index.html +225 -0
- data/docs/playground.html +81 -0
- data/docs/shape_dsl.md +269 -0
- data/examples/README.md +59 -0
- data/examples/assets/README.md +19 -0
- data/examples/audio_inspector.rb +34 -0
- data/examples/club_intro_drop.rb +78 -0
- data/examples/kansai_rubykaigi_visual.rb +70 -0
- data/examples/live_coding_minimal.rb +22 -0
- data/examples/midi_controller_show.rb +78 -0
- data/examples/midi_scene_switch.rb +3 -1
- data/examples/parser_visualizer.rb +48 -0
- data/examples/readme_demo.rb +17 -0
- data/examples/rhythm_geometry.rb +34 -0
- data/examples/ruby_crystal_show.rb +35 -0
- data/examples/shader_playground.rb +18 -0
- data/examples/unyo_liquid.rb +59 -0
- data/examples/vj_ambient_chill_room.rb +124 -0
- data/examples/vj_dnb_jungle.rb +170 -0
- data/examples/vj_festival_mainstage.rb +245 -0
- data/examples/vj_festival_mainstage.yml +17 -0
- data/examples/vj_glitch_industrial.rb +164 -0
- data/examples/vj_hiphop_cipher.rb +167 -0
- data/examples/vj_jpop_idol_live.rb +210 -0
- data/examples/vj_synthwave_retro.rb +173 -0
- data/examples/vj_techno_warehouse.rb +195 -0
- data/frontend/index.html +494 -2
- data/frontend/src/audio-inspector.js +40 -0
- data/frontend/src/custom-shape-param-controls.js +106 -0
- data/frontend/src/live-controls.js +131 -0
- data/frontend/src/main.js +1060 -16
- data/frontend/src/mapping-target-selector.js +109 -0
- data/frontend/src/midi-learn.js +194 -0
- data/frontend/src/performance-monitor.js +183 -0
- data/frontend/src/plugin-runtime.js +130 -0
- data/frontend/src/projector-mode.js +56 -0
- data/frontend/src/renderer/engine.js +157 -3
- data/frontend/src/renderer/layer-manager.js +442 -30
- data/frontend/src/renderer/shader-manager.js +26 -0
- data/frontend/src/runtime-control-preset.js +11 -0
- data/frontend/src/shader-error-overlay.js +29 -0
- data/frontend/src/shader-param-controls.js +93 -0
- data/frontend/src/shaders/builtins.js +380 -2
- data/frontend/src/shaders/post-effects.js +52 -0
- data/frontend/src/shape-editor-controls.js +157 -0
- data/frontend/src/visual-regression.js +67 -0
- data/frontend/src/visual-settings-preset.js +103 -0
- data/frontend/src/visuals/geometry.js +666 -0
- data/frontend/src/visuals/image-renderer.js +291 -0
- data/frontend/src/visuals/particle-system.js +56 -10
- data/frontend/src/visuals/shape-renderer.js +475 -0
- data/frontend/src/visuals/spectrogram-renderer.js +226 -0
- data/frontend/src/visuals/svg-arc.js +104 -0
- data/frontend/src/visuals/text-renderer.js +112 -11
- data/frontend/src/websocket-client.js +12 -1
- data/lib/vizcore/analysis/adaptive_normalizer.rb +70 -0
- data/lib/vizcore/analysis/beat_detector.rb +4 -2
- data/lib/vizcore/analysis/bpm_estimator.rb +8 -0
- data/lib/vizcore/analysis/feature_recorder.rb +159 -0
- data/lib/vizcore/analysis/feature_replay.rb +84 -0
- data/lib/vizcore/analysis/pipeline.rb +235 -11
- data/lib/vizcore/analysis/tap_tempo.rb +74 -0
- data/lib/vizcore/analysis.rb +4 -0
- data/lib/vizcore/audio/dummy_sine_input.rb +1 -1
- data/lib/vizcore/audio/fixture_input.rb +65 -0
- data/lib/vizcore/audio/input_manager.rb +4 -2
- data/lib/vizcore/audio/mic_input.rb +24 -8
- data/lib/vizcore/audio/portaudio_ffi.rb +106 -1
- data/lib/vizcore/audio.rb +1 -0
- data/lib/vizcore/cli/doctor.rb +159 -0
- data/lib/vizcore/cli/dsl_reference.rb +99 -0
- data/lib/vizcore/cli/layer_docs.rb +46 -0
- data/lib/vizcore/cli/scene_diagnostics.rb +23 -0
- data/lib/vizcore/cli/scene_inspector.rb +136 -0
- data/lib/vizcore/cli/scene_validator.rb +337 -0
- data/lib/vizcore/cli/shader_template.rb +68 -0
- data/lib/vizcore/cli/shader_uniform_docs.rb +54 -0
- data/lib/vizcore/cli.rb +689 -18
- data/lib/vizcore/config.rb +103 -2
- data/lib/vizcore/control_preset.rb +68 -0
- data/lib/vizcore/dsl/engine.rb +277 -5
- data/lib/vizcore/dsl/layer_builder.rb +1280 -23
- data/lib/vizcore/dsl/layer_group_builder.rb +112 -0
- data/lib/vizcore/dsl/mapping_resolver.rb +290 -7
- data/lib/vizcore/dsl/mapping_transform_builder.rb +71 -0
- data/lib/vizcore/dsl/reaction_builder.rb +44 -0
- data/lib/vizcore/dsl/scene_builder.rb +61 -5
- data/lib/vizcore/dsl/shader_source_resolver.rb +67 -6
- data/lib/vizcore/dsl/style_builder.rb +68 -0
- data/lib/vizcore/dsl/timeline_builder.rb +138 -0
- data/lib/vizcore/dsl/transition_controller.rb +77 -0
- data/lib/vizcore/dsl.rb +5 -1
- data/lib/vizcore/layer_catalog.rb +275 -0
- data/lib/vizcore/project_manifest.rb +152 -0
- data/lib/vizcore/renderer/png_writer.rb +57 -0
- data/lib/vizcore/renderer/render_sequence.rb +153 -0
- data/lib/vizcore/renderer/scene_frame_source.rb +132 -0
- data/lib/vizcore/renderer/scene_serializer.rb +36 -3
- data/lib/vizcore/renderer/snapshot.rb +38 -0
- data/lib/vizcore/renderer/snapshot_renderer.rb +938 -0
- data/lib/vizcore/renderer.rb +5 -0
- data/lib/vizcore/server/frame_broadcaster.rb +143 -8
- data/lib/vizcore/server/gallery_app.rb +155 -0
- data/lib/vizcore/server/gallery_page.rb +100 -0
- data/lib/vizcore/server/gallery_runner.rb +48 -0
- data/lib/vizcore/server/rack_app.rb +203 -4
- data/lib/vizcore/server/runner.rb +391 -22
- data/lib/vizcore/server/scene_dependency_watcher.rb +79 -0
- data/lib/vizcore/server/websocket_handler.rb +60 -10
- data/lib/vizcore/server.rb +4 -0
- data/lib/vizcore/shape.rb +719 -0
- data/lib/vizcore/sync/osc_message.rb +103 -0
- data/lib/vizcore/sync/osc_receiver.rb +68 -0
- data/lib/vizcore/sync.rb +4 -0
- data/lib/vizcore/templates/midi_control_scene.rb +3 -1
- data/lib/vizcore/templates/plugin_layer.rb +20 -0
- data/lib/vizcore/templates/plugin_readme.md +23 -0
- data/lib/vizcore/templates/plugin_renderer.js +43 -0
- data/lib/vizcore/templates/plugin_scene.rb +14 -0
- data/lib/vizcore/templates/project_readme.md +7 -23
- data/lib/vizcore/templates/rubykaigi_scene.rb +30 -0
- data/lib/vizcore/version.rb +1 -1
- data/lib/vizcore.rb +28 -0
- data/scripts/browser_capture.mjs +75 -0
- data/sig/vizcore.rbs +461 -0
- metadata +94 -3
- data/docs/GETTING_STARTED.md +0 -105
data/lib/vizcore/renderer.rb
CHANGED
|
@@ -7,4 +7,9 @@ module Vizcore
|
|
|
7
7
|
end
|
|
8
8
|
|
|
9
9
|
require_relative "renderer/frame_scheduler"
|
|
10
|
+
require_relative "renderer/png_writer"
|
|
11
|
+
require_relative "renderer/scene_frame_source"
|
|
12
|
+
require_relative "renderer/render_sequence"
|
|
10
13
|
require_relative "renderer/scene_serializer"
|
|
14
|
+
require_relative "renderer/snapshot"
|
|
15
|
+
require_relative "renderer/snapshot_renderer"
|
|
@@ -23,6 +23,10 @@ module Vizcore
|
|
|
23
23
|
# @param scene_catalog [Array<Hash>, nil]
|
|
24
24
|
# @param transitions [Array<Hash>, nil]
|
|
25
25
|
# @param transition_controller [Vizcore::DSL::TransitionController, nil]
|
|
26
|
+
# @param noise_gate [Numeric]
|
|
27
|
+
# @param audio_normalize [Hash, nil]
|
|
28
|
+
# @param bpm [Numeric, nil]
|
|
29
|
+
# @param bpm_lock [Boolean]
|
|
26
30
|
# @param error_reporter [#call, nil]
|
|
27
31
|
def initialize(
|
|
28
32
|
scene_name: "basic",
|
|
@@ -35,6 +39,10 @@ module Vizcore
|
|
|
35
39
|
scene_catalog: nil,
|
|
36
40
|
transitions: nil,
|
|
37
41
|
transition_controller: nil,
|
|
42
|
+
noise_gate: Vizcore::Analysis::Pipeline::DEFAULT_NOISE_GATE,
|
|
43
|
+
audio_normalize: nil,
|
|
44
|
+
bpm: nil,
|
|
45
|
+
bpm_lock: false,
|
|
38
46
|
error_reporter: nil
|
|
39
47
|
)
|
|
40
48
|
@scene_name = scene_name
|
|
@@ -44,7 +52,11 @@ module Vizcore
|
|
|
44
52
|
fft_size = supported_fft_size(@input_manager.frame_size)
|
|
45
53
|
@analysis_pipeline = analysis_pipeline || Vizcore::Analysis::Pipeline.new(
|
|
46
54
|
sample_rate: @input_manager.sample_rate,
|
|
47
|
-
fft_size: fft_size
|
|
55
|
+
fft_size: fft_size,
|
|
56
|
+
noise_gate: noise_gate,
|
|
57
|
+
audio_normalize: audio_normalize,
|
|
58
|
+
bpm: bpm,
|
|
59
|
+
bpm_lock: bpm_lock
|
|
48
60
|
)
|
|
49
61
|
@mapping_resolver = mapping_resolver || Vizcore::DSL::MappingResolver.new
|
|
50
62
|
@scene_serializer = scene_serializer || Vizcore::Renderer::SceneSerializer.new
|
|
@@ -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(
|
|
158
|
-
|
|
159
|
-
|
|
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(
|
|
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
|