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
|
@@ -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?
|