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
@@ -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
- def initialize(frontend_root:, websocket_path: "/ws", audio_source: nil, audio_file: nil, scene_names: nil)
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 == "/" ? "index.html" : path_info.delete_prefix("/")
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" => Rack::Mime.mime_type(File.extname(full_path), "text/plain"),
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?