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.
- checksums.yaml +4 -4
- data/README.md +544 -9
- data/docs/.nojekyll +0 -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 +224 -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 +468 -2
- data/frontend/src/audio-inspector.js +40 -0
- data/frontend/src/live-controls.js +131 -0
- data/frontend/src/main.js +792 -16
- 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 +148 -3
- data/frontend/src/renderer/layer-manager.js +428 -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/visual-regression.js +67 -0
- data/frontend/src/visual-settings-preset.js +103 -0
- data/frontend/src/visuals/geometry.js +268 -0
- data/frontend/src/visuals/image-renderer.js +291 -0
- data/frontend/src/visuals/particle-system.js +56 -10
- data/frontend/src/visuals/spectrogram-renderer.js +226 -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 +245 -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 +491 -22
- data/lib/vizcore/dsl/layer_group_builder.rb +112 -0
- data/lib/vizcore/dsl/mapping_resolver.rb +132 -3
- 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 +273 -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 +119 -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 +446 -0
- data/lib/vizcore/renderer.rb +5 -0
- data/lib/vizcore/server/frame_broadcaster.rb +91 -5
- 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 +370 -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/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 +27 -0
- data/scripts/browser_capture.mjs +75 -0
- data/sig/vizcore.rbs +362 -0
- metadata +83 -3
- data/docs/GETTING_STARTED.md +0 -105
|
@@ -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
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "json"
|
|
4
4
|
require "pathname"
|
|
5
5
|
require "rack"
|
|
6
|
+
require_relative "../control_preset"
|
|
6
7
|
require_relative "websocket_handler"
|
|
7
8
|
|
|
8
9
|
module Vizcore
|
|
@@ -10,6 +11,10 @@ module Vizcore
|
|
|
10
11
|
# Rack app serving frontend assets, health endpoint, and WebSocket upgrade.
|
|
11
12
|
class RackApp
|
|
12
13
|
AUDIO_FILE_PATH = "/audio-file"
|
|
14
|
+
CONTROL_PATH = "/control"
|
|
15
|
+
CONTROL_PRESET_PATH = "/control-preset"
|
|
16
|
+
PLUGIN_ASSET_PREFIX = "/plugins/"
|
|
17
|
+
PROJECTOR_PATH = "/projector"
|
|
13
18
|
RUNTIME_PATH = "/runtime"
|
|
14
19
|
|
|
15
20
|
# @param frontend_root [Pathname]
|
|
@@ -17,12 +22,39 @@ module Vizcore
|
|
|
17
22
|
# @param audio_source [Symbol, String, nil]
|
|
18
23
|
# @param audio_file [String, Pathname, nil]
|
|
19
24
|
# @param scene_names [Array<String, Symbol>, nil]
|
|
20
|
-
|
|
25
|
+
# @param tap_tempo_key [String, Symbol, nil]
|
|
26
|
+
# @param key_mappings [Array<Hash>, nil]
|
|
27
|
+
# @param globals [Hash, nil]
|
|
28
|
+
# @param control_preset [Hash, nil]
|
|
29
|
+
# @param control_preset_path [String, Pathname, nil]
|
|
30
|
+
# @param plugin_assets [Array<String, Pathname>, nil]
|
|
31
|
+
# @param projector_mode [Boolean]
|
|
32
|
+
def initialize(
|
|
33
|
+
frontend_root:,
|
|
34
|
+
websocket_path: "/ws",
|
|
35
|
+
audio_source: nil,
|
|
36
|
+
audio_file: nil,
|
|
37
|
+
scene_names: nil,
|
|
38
|
+
tap_tempo_key: nil,
|
|
39
|
+
key_mappings: nil,
|
|
40
|
+
globals: nil,
|
|
41
|
+
control_preset: nil,
|
|
42
|
+
control_preset_path: nil,
|
|
43
|
+
plugin_assets: nil,
|
|
44
|
+
projector_mode: false
|
|
45
|
+
)
|
|
21
46
|
@frontend_root = frontend_root.expand_path
|
|
22
47
|
@websocket_path = websocket_path
|
|
23
48
|
@audio_source = audio_source&.to_sym
|
|
24
49
|
@audio_file = audio_file ? Pathname.new(audio_file).expand_path : nil
|
|
25
50
|
@scene_names = normalize_scene_names(scene_names)
|
|
51
|
+
@tap_tempo_key = normalize_tap_tempo_key(tap_tempo_key)
|
|
52
|
+
@key_mappings = normalize_key_mappings(key_mappings)
|
|
53
|
+
@globals = normalize_globals(globals)
|
|
54
|
+
@control_preset = normalize_control_preset(control_preset)
|
|
55
|
+
@control_preset_path = control_preset_path ? Pathname.new(control_preset_path).expand_path : nil
|
|
56
|
+
@plugin_assets = normalize_plugin_assets(plugin_assets)
|
|
57
|
+
@projector_mode = !!projector_mode
|
|
26
58
|
end
|
|
27
59
|
|
|
28
60
|
# @param env [Hash]
|
|
@@ -33,7 +65,12 @@ module Vizcore
|
|
|
33
65
|
return WebSocketHandler.call(env) if request.path_info == @websocket_path
|
|
34
66
|
return health_response if request.path_info == "/health"
|
|
35
67
|
return runtime_response if request.path_info == RUNTIME_PATH
|
|
68
|
+
return control_preset_response(request) if request.path_info == CONTROL_PRESET_PATH
|
|
36
69
|
return audio_file_response(request) if request.path_info == AUDIO_FILE_PATH
|
|
70
|
+
return plugin_asset_response(request.path_info) if request.path_info.start_with?(PLUGIN_ASSET_PREFIX)
|
|
71
|
+
return serve_index(display_mode: root_display_mode) if request.path_info == "/"
|
|
72
|
+
return serve_index(display_mode: "control") if request.path_info == CONTROL_PATH
|
|
73
|
+
return serve_index(display_mode: "projector") if request.path_info == PROJECTOR_PATH
|
|
37
74
|
|
|
38
75
|
serve_static(request.path_info)
|
|
39
76
|
end
|
|
@@ -51,7 +88,15 @@ module Vizcore
|
|
|
51
88
|
audio_source: (@audio_source || :unknown).to_s,
|
|
52
89
|
audio_file_name: nil,
|
|
53
90
|
audio_file_url: nil,
|
|
54
|
-
scene_names: @scene_names
|
|
91
|
+
scene_names: @scene_names,
|
|
92
|
+
tap_tempo_key: @tap_tempo_key,
|
|
93
|
+
key_mappings: @key_mappings,
|
|
94
|
+
globals: @globals,
|
|
95
|
+
control_preset: @control_preset,
|
|
96
|
+
control_preset_writable: !!@control_preset_path,
|
|
97
|
+
control_preset_url: @control_preset_path ? CONTROL_PRESET_PATH : nil,
|
|
98
|
+
plugin_assets: @plugin_assets.map { |asset| asset.fetch(:url) },
|
|
99
|
+
projector_mode: @projector_mode
|
|
55
100
|
}
|
|
56
101
|
|
|
57
102
|
if audio_file_available?
|
|
@@ -85,16 +130,66 @@ module Vizcore
|
|
|
85
130
|
[200, audio_headers(content_length: body.bytesize), [body]]
|
|
86
131
|
end
|
|
87
132
|
|
|
133
|
+
def control_preset_response(request)
|
|
134
|
+
return not_found_response unless @control_preset_path
|
|
135
|
+
return method_not_allowed_response unless request.put? || request.post?
|
|
136
|
+
|
|
137
|
+
payload = JSON.parse(request.body.read)
|
|
138
|
+
@control_preset = Vizcore::ControlPreset.write(@control_preset_path, payload)
|
|
139
|
+
body = JSON.generate(status: "ok", control_preset: @control_preset)
|
|
140
|
+
[200, json_headers.merge("content-length" => body.bytesize.to_s), [body]]
|
|
141
|
+
rescue JSON::ParserError => e
|
|
142
|
+
body = JSON.generate(status: "error", error: "Invalid control preset JSON: #{e.message}")
|
|
143
|
+
[400, json_headers.merge("content-length" => body.bytesize.to_s), [body]]
|
|
144
|
+
rescue ArgumentError => e
|
|
145
|
+
body = JSON.generate(status: "error", error: e.message)
|
|
146
|
+
[400, json_headers.merge("content-length" => body.bytesize.to_s), [body]]
|
|
147
|
+
end
|
|
148
|
+
|
|
88
149
|
def serve_static(path_info)
|
|
89
|
-
path = path_info
|
|
150
|
+
path = path_info.delete_prefix("/")
|
|
90
151
|
full_path = File.expand_path(path, @frontend_root.to_s)
|
|
91
152
|
|
|
92
153
|
return not_found_response unless full_path.start_with?(@frontend_root.to_s)
|
|
93
154
|
return not_found_response unless File.file?(full_path)
|
|
94
155
|
|
|
95
156
|
body = File.binread(full_path)
|
|
157
|
+
static_response(body, content_type: Rack::Mime.mime_type(File.extname(full_path), "text/plain"))
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def serve_index(display_mode:)
|
|
161
|
+
full_path = @frontend_root.join("index.html")
|
|
162
|
+
return not_found_response unless full_path.file?
|
|
163
|
+
|
|
164
|
+
body = File.binread(full_path)
|
|
165
|
+
body = body.gsub(
|
|
166
|
+
'data-projector-mode="false"',
|
|
167
|
+
"data-projector-mode=\"#{display_mode == 'projector' ? 'true' : 'false'}\""
|
|
168
|
+
)
|
|
169
|
+
body = body.gsub(
|
|
170
|
+
'data-display-mode="auto"',
|
|
171
|
+
"data-display-mode=\"#{display_mode}\""
|
|
172
|
+
)
|
|
173
|
+
body = inject_plugin_asset_scripts(body)
|
|
174
|
+
static_response(body, content_type: "text/html")
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def plugin_asset_response(path_info)
|
|
178
|
+
asset = @plugin_assets.find { |entry| entry.fetch(:url) == path_info }
|
|
179
|
+
return not_found_response unless asset
|
|
180
|
+
return not_found_response unless asset.fetch(:path).file?
|
|
181
|
+
|
|
182
|
+
body = File.binread(asset.fetch(:path))
|
|
183
|
+
static_response(body, content_type: Rack::Mime.mime_type(asset.fetch(:path).extname, "text/javascript"))
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def root_display_mode
|
|
187
|
+
@projector_mode ? "projector" : "auto"
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def static_response(body, content_type:)
|
|
96
191
|
headers = {
|
|
97
|
-
"content-type" =>
|
|
192
|
+
"content-type" => content_type,
|
|
98
193
|
"content-length" => body.bytesize.to_s,
|
|
99
194
|
"cache-control" => "no-store, max-age=0, must-revalidate"
|
|
100
195
|
}
|
|
@@ -105,6 +200,10 @@ module Vizcore
|
|
|
105
200
|
[404, text_headers.merge("content-length" => "9"), ["Not Found"]]
|
|
106
201
|
end
|
|
107
202
|
|
|
203
|
+
def method_not_allowed_response
|
|
204
|
+
[405, text_headers.merge("allow" => "PUT, POST", "content-length" => "18"), ["Method Not Allowed"]]
|
|
205
|
+
end
|
|
206
|
+
|
|
108
207
|
def text_headers
|
|
109
208
|
{ "content-type" => "text/plain; charset=utf-8" }
|
|
110
209
|
end
|
|
@@ -137,6 +236,106 @@ module Vizcore
|
|
|
137
236
|
[]
|
|
138
237
|
end
|
|
139
238
|
|
|
239
|
+
def normalize_tap_tempo_key(value)
|
|
240
|
+
key = value.to_s.strip.downcase
|
|
241
|
+
return nil if key.empty?
|
|
242
|
+
|
|
243
|
+
key
|
|
244
|
+
rescue StandardError
|
|
245
|
+
nil
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def normalize_key_mappings(values)
|
|
249
|
+
Array(values).filter_map do |entry|
|
|
250
|
+
key = normalize_shortcut_key(entry[:key] || entry["key"])
|
|
251
|
+
action = entry[:action] || entry["action"]
|
|
252
|
+
next if key.empty? || !action.is_a?(Hash)
|
|
253
|
+
|
|
254
|
+
normalized_action = normalize_key_action(action)
|
|
255
|
+
next unless normalized_action
|
|
256
|
+
|
|
257
|
+
{ key: key, action: normalized_action }
|
|
258
|
+
end
|
|
259
|
+
rescue StandardError
|
|
260
|
+
[]
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def normalize_shortcut_key(value)
|
|
264
|
+
raw = value.to_s
|
|
265
|
+
return "space" if raw == " "
|
|
266
|
+
|
|
267
|
+
key = raw.strip.downcase
|
|
268
|
+
key == "spacebar" ? "space" : key
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def normalize_key_action(action)
|
|
272
|
+
type = (action[:type] || action["type"]).to_s.strip
|
|
273
|
+
case type
|
|
274
|
+
when "switch_scene"
|
|
275
|
+
scene = (action[:scene] || action["scene"]).to_s.strip
|
|
276
|
+
return nil if scene.empty?
|
|
277
|
+
|
|
278
|
+
{ type: "switch_scene", scene: scene }
|
|
279
|
+
when "live_control"
|
|
280
|
+
control = (action[:control] || action["control"]).to_s.strip
|
|
281
|
+
return nil unless %w[blackout freeze].include?(control)
|
|
282
|
+
|
|
283
|
+
{ type: "live_control", control: control }
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def normalize_globals(values)
|
|
288
|
+
Hash(values || {}).each_with_object({}) do |(key, value), output|
|
|
289
|
+
name = key.to_s.strip
|
|
290
|
+
next if name.empty?
|
|
291
|
+
|
|
292
|
+
output[name] = value
|
|
293
|
+
end
|
|
294
|
+
rescue StandardError
|
|
295
|
+
{}
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def normalize_control_preset(values)
|
|
299
|
+
preset = values.is_a?(Hash) ? values : {}
|
|
300
|
+
visual_settings = preset[:visual_settings] || preset["visual_settings"] || preset[:visualSettings] || preset["visualSettings"]
|
|
301
|
+
midi_learn_bindings = preset[:midi_learn_bindings] || preset["midi_learn_bindings"] || preset[:midiLearnBindings] || preset["midiLearnBindings"]
|
|
302
|
+
|
|
303
|
+
{}.tap do |payload|
|
|
304
|
+
payload["visual_settings"] = visual_settings if visual_settings.is_a?(Hash)
|
|
305
|
+
payload["midi_learn_bindings"] = midi_learn_bindings if midi_learn_bindings.is_a?(Hash)
|
|
306
|
+
end
|
|
307
|
+
rescue StandardError
|
|
308
|
+
{}
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def normalize_plugin_assets(values)
|
|
312
|
+
Array(values).each_with_index.filter_map do |value, index|
|
|
313
|
+
raw_value = value.to_s.strip
|
|
314
|
+
next if raw_value.empty?
|
|
315
|
+
|
|
316
|
+
path = value.is_a?(Pathname) ? value.expand_path : Pathname.new(raw_value).expand_path
|
|
317
|
+
{
|
|
318
|
+
path: path,
|
|
319
|
+
url: "#{PLUGIN_ASSET_PREFIX}#{index}/#{rack_escape_path(path.basename.to_s)}"
|
|
320
|
+
}
|
|
321
|
+
end
|
|
322
|
+
rescue StandardError
|
|
323
|
+
[]
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def inject_plugin_asset_scripts(body)
|
|
327
|
+
return body if @plugin_assets.empty?
|
|
328
|
+
|
|
329
|
+
scripts = @plugin_assets.map do |asset|
|
|
330
|
+
%(<script type="module" src="#{asset.fetch(:url)}"></script>)
|
|
331
|
+
end.join("\n ")
|
|
332
|
+
body.sub(%(<script type="module" src="/src/main.js?v=20260516d"></script>), "#{scripts}\n \\0")
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
def rack_escape_path(value)
|
|
336
|
+
Rack::Utils.escape_path(value)
|
|
337
|
+
end
|
|
338
|
+
|
|
140
339
|
def parse_byte_range(raw_range, file_size)
|
|
141
340
|
range_value = raw_range.to_s.strip
|
|
142
341
|
return nil if range_value.empty?
|