vizcore 1.0.0 → 1.2.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 (90) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +66 -648
  3. data/docs/assets/playground-worker.js +373 -0
  4. data/docs/assets/playground.css +440 -0
  5. data/docs/assets/playground.js +652 -0
  6. data/docs/index.html +2 -1
  7. data/docs/playground.html +81 -0
  8. data/docs/shape_dsl.md +269 -0
  9. data/frontend/index.html +50 -2
  10. data/frontend/src/audio-inspector.js +9 -0
  11. data/frontend/src/custom-shape-param-controls.js +106 -0
  12. data/frontend/src/live-controls.js +219 -7
  13. data/frontend/src/main.js +703 -45
  14. data/frontend/src/mapping-target-selector.js +109 -0
  15. data/frontend/src/midi-learn.js +22 -2
  16. data/frontend/src/performance-monitor.js +137 -1
  17. data/frontend/src/renderer/engine.js +401 -11
  18. data/frontend/src/renderer/layer-manager.js +490 -75
  19. data/frontend/src/runtime-control-preset.js +44 -0
  20. data/frontend/src/scene-patches.js +159 -0
  21. data/frontend/src/shader-error-overlay.js +1 -0
  22. data/frontend/src/shape-editor-controls.js +157 -0
  23. data/frontend/src/visuals/geometry.js +425 -27
  24. data/frontend/src/visuals/image-renderer.js +19 -0
  25. data/frontend/src/visuals/particle-system.js +10 -0
  26. data/frontend/src/visuals/shape-renderer.js +488 -0
  27. data/frontend/src/visuals/spectrogram-renderer.js +14 -0
  28. data/frontend/src/visuals/svg-arc.js +104 -0
  29. data/frontend/src/visuals/text-renderer.js +13 -0
  30. data/frontend/src/websocket-client.js +6 -0
  31. data/lib/vizcore/analysis/adaptive_normalizer.rb +20 -2
  32. data/lib/vizcore/analysis/bpm_estimator.rb +18 -8
  33. data/lib/vizcore/analysis/feature_recorder.rb +117 -7
  34. data/lib/vizcore/analysis/feature_replay.rb +48 -9
  35. data/lib/vizcore/analysis/pipeline.rb +258 -9
  36. data/lib/vizcore/analysis/tap_tempo.rb +17 -2
  37. data/lib/vizcore/audio/calibration.rb +156 -0
  38. data/lib/vizcore/audio/file_input.rb +28 -0
  39. data/lib/vizcore/audio/input_manager.rb +36 -1
  40. data/lib/vizcore/audio/midi_input.rb +5 -0
  41. data/lib/vizcore/audio/ring_buffer.rb +22 -0
  42. data/lib/vizcore/audio.rb +1 -0
  43. data/lib/vizcore/cli/dsl_reference.rb +65 -9
  44. data/lib/vizcore/cli/plugin_checker.rb +93 -0
  45. data/lib/vizcore/cli/scene_diagnostics.rb +2 -2
  46. data/lib/vizcore/cli/scene_inspector.rb +35 -1
  47. data/lib/vizcore/cli/scene_validator.rb +573 -33
  48. data/lib/vizcore/cli/shader_template.rb +7 -2
  49. data/lib/vizcore/cli/shader_uniform_docs.rb +11 -0
  50. data/lib/vizcore/cli.rb +268 -15
  51. data/lib/vizcore/config.rb +40 -3
  52. data/lib/vizcore/control_preset.rb +29 -0
  53. data/lib/vizcore/deep_copy.rb +21 -0
  54. data/lib/vizcore/dsl/color_helpers.rb +155 -0
  55. data/lib/vizcore/dsl/engine.rb +219 -23
  56. data/lib/vizcore/dsl/layer_builder.rb +1072 -21
  57. data/lib/vizcore/dsl/layer_group_builder.rb +10 -12
  58. data/lib/vizcore/dsl/layout_helpers.rb +290 -0
  59. data/lib/vizcore/dsl/mapping_preset_builder.rb +41 -0
  60. data/lib/vizcore/dsl/mapping_resolver.rb +549 -13
  61. data/lib/vizcore/dsl/mapping_transform_builder.rb +50 -0
  62. data/lib/vizcore/dsl/midi_map_executor.rb +219 -23
  63. data/lib/vizcore/dsl/reaction_builder.rb +1 -0
  64. data/lib/vizcore/dsl/scene_builder.rb +83 -13
  65. data/lib/vizcore/dsl/shader_source_resolver.rb +1 -10
  66. data/lib/vizcore/dsl/style_builder.rb +3 -0
  67. data/lib/vizcore/dsl/timeline_builder.rb +91 -8
  68. data/lib/vizcore/dsl/transition_controller.rb +157 -18
  69. data/lib/vizcore/dsl.rb +2 -0
  70. data/lib/vizcore/layer_catalog.rb +5 -2
  71. data/lib/vizcore/plugin_asset_policy.rb +55 -0
  72. data/lib/vizcore/project_manifest.rb +12 -2
  73. data/lib/vizcore/renderer/render_sequence.rb +104 -13
  74. data/lib/vizcore/renderer/scene_frame_source.rb +190 -12
  75. data/lib/vizcore/renderer/scene_serializer.rb +38 -0
  76. data/lib/vizcore/renderer/snapshot.rb +4 -3
  77. data/lib/vizcore/renderer/snapshot_renderer.rb +641 -23
  78. data/lib/vizcore/scene_trust.rb +31 -0
  79. data/lib/vizcore/server/frame_broadcaster.rb +513 -18
  80. data/lib/vizcore/server/rack_app.rb +151 -4
  81. data/lib/vizcore/server/runner.rb +697 -82
  82. data/lib/vizcore/server/websocket_handler.rb +236 -14
  83. data/lib/vizcore/server.rb +21 -0
  84. data/lib/vizcore/shape.rb +742 -0
  85. data/lib/vizcore/sync/osc_message.rb +66 -9
  86. data/lib/vizcore/version.rb +1 -1
  87. data/lib/vizcore.rb +34 -0
  88. data/scripts/browser_capture.mjs +31 -2
  89. data/sig/vizcore.rbs +154 -4
  90. metadata +29 -3
@@ -3,6 +3,7 @@
3
3
  require "json"
4
4
  require "set"
5
5
  require "thread"
6
+ require "uri"
6
7
  require_relative "../errors"
7
8
 
8
9
  module Vizcore
@@ -12,6 +13,14 @@ module Vizcore
12
13
  PROTOCOL_VERSION = "vizcore.frame.v1"
13
14
  MAX_BUFFERED_FRAME_BYTES = 1_000_000
14
15
  DROPPABLE_MESSAGE_TYPES = Set["audio_frame"].freeze
16
+ VALID_CLIENT_ROLES = Set["projector", "control", "monitor"].freeze
17
+ CONTROL_ROLE = "control".freeze
18
+ PROJECTOR_ROLE = "projector".freeze
19
+ MONITOR_ROLE = "monitor".freeze
20
+ READ_ONLY_ROLES = Set[PROJECTOR_ROLE, MONITOR_ROLE].freeze
21
+ READ_ONLY_ALLOWED_MESSAGE_TYPES = Set["latency_probe", "client_runtime_error"].freeze
22
+ LOW_BANDWIDTH_ROLES = Set[CONTROL_ROLE, MONITOR_ROLE].freeze
23
+ CONTROL_AUDIO_FRAME_INTERVAL = 4
15
24
 
16
25
  class << self
17
26
  # Rack endpoint for WebSocket upgrade handling.
@@ -25,7 +34,7 @@ module Vizcore
25
34
 
26
35
  socket = websocket_klass.new(env, nil, ping: 15)
27
36
 
28
- socket.on(:open) { register(socket) }
37
+ socket.on(:open) { register(socket, role: websocket_role_for_env(env)) }
29
38
  socket.on(:close) { unregister(socket) }
30
39
  socket.on(:message) { |event| handle_message(socket, event.data) }
31
40
 
@@ -78,6 +87,28 @@ module Vizcore
78
87
  mutex.synchronize { @dropped_frame_count || 0 }
79
88
  end
80
89
 
90
+ # @return [Hash] current websocket backpressure metrics for control/status surfaces.
91
+ def backpressure_status
92
+ mutex.synchronize do
93
+ clients = sockets.map { |socket| backpressure_client_status(socket) }
94
+ {
95
+ threshold_bytes: MAX_BUFFERED_FRAME_BYTES,
96
+ active_clients: sockets.size,
97
+ total: {
98
+ dropped_frames: socket_backpressure_totals[:dropped_frames],
99
+ dropped_payload_bytes: socket_backpressure_totals[:dropped_payload_bytes],
100
+ sent_frames: socket_backpressure_totals[:sent_frames],
101
+ sent_payload_bytes: socket_backpressure_totals[:sent_payload_bytes],
102
+ avg_payload_bytes: average(
103
+ socket_backpressure_totals[:sent_payload_bytes],
104
+ socket_backpressure_totals[:sent_frames]
105
+ )
106
+ },
107
+ clients: clients
108
+ }
109
+ end
110
+ end
111
+
81
112
  # Register one inbound message handler for client -> server control messages.
82
113
  #
83
114
  # @yieldparam message [Hash]
@@ -103,32 +134,52 @@ module Vizcore
103
134
  end
104
135
 
105
136
  def send_message(socket, message, type:)
106
- return if drop_for_backpressure?(socket, type)
137
+ return unless should_send_to_socket?(socket, type: type)
138
+
139
+ message_bytes = message.bytesize
140
+ return if drop_for_backpressure?(socket, type, payload_bytes: message_bytes)
107
141
 
108
142
  if event_machine_reactor_running?
109
- EventMachine.schedule { safe_send(socket, message, type: type) }
143
+ EventMachine.schedule { safe_send(socket, message, type: type, payload_bytes: message_bytes) }
110
144
  else
111
- safe_send(socket, message, type: type)
145
+ safe_send(socket, message, type: type, payload_bytes: message_bytes)
112
146
  end
113
147
  end
114
148
 
115
- def safe_send(socket, message, type:)
116
- return if drop_for_backpressure?(socket, type)
149
+ def safe_send(socket, message, type:, payload_bytes:)
150
+ return if drop_for_backpressure?(socket, type, payload_bytes: payload_bytes)
117
151
 
118
152
  socket.send(message)
153
+ record_message_sent(socket, payload_bytes)
119
154
  rescue StandardError => e
120
155
  set_last_error(e)
121
156
  unregister(socket)
122
157
  end
123
158
 
124
- def drop_for_backpressure?(socket, type)
159
+ def drop_for_backpressure?(socket, type, payload_bytes: nil)
125
160
  return false unless DROPPABLE_MESSAGE_TYPES.include?(type.to_s)
126
161
 
127
162
  buffered_amount = socket_buffered_amount(socket)
128
- return false unless buffered_amount && buffered_amount > MAX_BUFFERED_FRAME_BYTES
163
+ begin
164
+ buffered_amount = Integer(buffered_amount) if buffered_amount
165
+ rescue ArgumentError, TypeError
166
+ buffered_amount = nil
167
+ end
129
168
 
130
- increment_dropped_frame_count
131
- true
169
+ if buffered_amount && buffered_amount > MAX_BUFFERED_FRAME_BYTES
170
+ mutex.synchronize do
171
+ refresh_socket_backpressure_metrics(socket, buffered_amount: buffered_amount)
172
+ increment_dropped_frame_count
173
+ increment_client_drop(socket, payload_bytes: payload_bytes)
174
+ end
175
+ return true
176
+ end
177
+
178
+ if buffered_amount
179
+ mutex.synchronize { refresh_socket_backpressure_metrics(socket, buffered_amount: buffered_amount) }
180
+ end
181
+
182
+ false
132
183
  end
133
184
 
134
185
  def socket_buffered_amount(socket)
@@ -160,12 +211,21 @@ module Vizcore
160
211
  nil
161
212
  end
162
213
 
163
- def register(socket)
164
- mutex.synchronize { sockets << socket }
214
+ def register(socket, role: PROJECTOR_ROLE)
215
+ mutex.synchronize do
216
+ sockets << socket
217
+ socket_backpressure_metrics[socket_id(socket)] = default_backpressure_metrics
218
+ client_backpressure_metrics(socket)[:role] = normalize_client_role(role)
219
+ client_backpressure_metrics(socket)[:control_audio_frame_index] = 0
220
+ socket_backpressure_metrics[socket_id(socket)] = client_backpressure_metrics(socket)
221
+ end
165
222
  end
166
223
 
167
224
  def unregister(socket)
168
- mutex.synchronize { sockets.delete(socket) }
225
+ mutex.synchronize do
226
+ sockets.delete(socket)
227
+ socket_backpressure_metrics.delete(socket_id(socket))
228
+ end
169
229
  end
170
230
 
171
231
  def each_socket(&block)
@@ -177,6 +237,158 @@ module Vizcore
177
237
  @sockets ||= Set.new
178
238
  end
179
239
 
240
+ def socket_backpressure_metrics
241
+ @socket_backpressure_metrics ||= {}
242
+ end
243
+
244
+ def socket_backpressure_totals
245
+ @socket_backpressure_totals ||= {
246
+ dropped_frames: 0,
247
+ dropped_payload_bytes: 0,
248
+ sent_frames: 0,
249
+ sent_payload_bytes: 0
250
+ }
251
+ end
252
+
253
+ def backpressure_client_status(socket)
254
+ metrics = client_backpressure_metrics(socket)
255
+ {
256
+ id: socket_id(socket).to_s,
257
+ role: metrics[:role],
258
+ buffered_amount: metrics[:buffered_amount],
259
+ peak_buffered_amount: metrics[:peak_buffered_amount],
260
+ dropped_frames: metrics[:dropped_frames],
261
+ dropped_payload_bytes: metrics[:dropped_payload_bytes],
262
+ sent_frames: metrics[:sent_frames],
263
+ sent_payload_bytes: metrics[:sent_payload_bytes],
264
+ avg_payload_bytes: average(metrics[:sent_payload_bytes], metrics[:sent_frames]),
265
+ estimated_lag_frames: estimated_lag_frames(
266
+ metrics[:buffered_amount],
267
+ metrics[:sent_payload_bytes],
268
+ metrics[:sent_frames]
269
+ ),
270
+ last_payload_bytes: metrics[:last_payload_bytes]
271
+ }
272
+ end
273
+
274
+ def socket_id(socket)
275
+ socket.object_id
276
+ end
277
+
278
+ def client_backpressure_metrics(socket)
279
+ socket_backpressure_metrics.fetch(socket_id(socket)) do
280
+ socket_backpressure_metrics[socket_id(socket)] = default_backpressure_metrics
281
+ end
282
+ end
283
+
284
+ def socket_role(socket)
285
+ client_backpressure_metrics(socket)[:role]
286
+ end
287
+
288
+ def should_send_to_socket?(socket, type:)
289
+ return true unless LOW_BANDWIDTH_ROLES.include?(socket_role(socket))
290
+ return true unless type.to_s == "audio_frame"
291
+
292
+ control_audio_frame_due?(socket)
293
+ end
294
+
295
+ def control_audio_frame_due?(socket)
296
+ metrics = client_backpressure_metrics(socket)
297
+ metrics[:control_audio_frame_index] = (metrics[:control_audio_frame_index] || 0) + 1
298
+ count = metrics[:control_audio_frame_index]
299
+
300
+ return true if count == 1
301
+ return true if (count % CONTROL_AUDIO_FRAME_INTERVAL).zero?
302
+
303
+ false
304
+ end
305
+
306
+ def refresh_socket_backpressure_metrics(socket, buffered_amount: nil)
307
+ metrics = client_backpressure_metrics(socket)
308
+ amount = buffered_amount.nil? ? socket_buffered_amount(socket) : buffered_amount
309
+ return metrics unless amount
310
+
311
+ integer_amount = amount.to_i
312
+ metrics[:buffered_amount] = integer_amount
313
+ metrics[:peak_buffered_amount] = [metrics[:peak_buffered_amount], integer_amount].max
314
+ metrics
315
+ end
316
+
317
+ def increment_client_drop(socket, payload_bytes: nil)
318
+ payload_bytes = Integer(payload_bytes || 0)
319
+ metrics = client_backpressure_metrics(socket)
320
+ metrics[:dropped_frames] += 1
321
+ metrics[:dropped_payload_bytes] += payload_bytes
322
+ socket_backpressure_totals[:dropped_frames] += 1
323
+ socket_backpressure_totals[:dropped_payload_bytes] += payload_bytes
324
+ end
325
+
326
+ def record_message_sent(socket, payload_bytes)
327
+ payload_bytes = Integer(payload_bytes || 0)
328
+ buffered_amount = socket_buffered_amount(socket)
329
+ buffered_amount = Integer(buffered_amount) if buffered_amount
330
+
331
+ mutex.synchronize do
332
+ metrics = client_backpressure_metrics(socket)
333
+ metrics[:sent_frames] += 1
334
+ metrics[:sent_payload_bytes] += payload_bytes
335
+ metrics[:last_payload_bytes] = payload_bytes
336
+ socket_backpressure_totals[:sent_frames] += 1
337
+ socket_backpressure_totals[:sent_payload_bytes] += payload_bytes
338
+ refresh_socket_backpressure_metrics(socket, buffered_amount: buffered_amount) if buffered_amount
339
+ end
340
+ end
341
+
342
+ def average(numerator, denominator)
343
+ return 0.0 if denominator.to_i <= 0
344
+
345
+ numerator.to_f / denominator.to_f
346
+ end
347
+
348
+ def estimated_lag_frames(buffered_amount, payload_bytes, sent_frames)
349
+ avg_payload_bytes = average(payload_bytes, sent_frames)
350
+ return 0.0 if avg_payload_bytes <= 0.0
351
+
352
+ buffered_amount.to_f / avg_payload_bytes
353
+ end
354
+
355
+ def default_backpressure_metrics
356
+ {
357
+ role: PROJECTOR_ROLE,
358
+ control_audio_frame_index: 0,
359
+ buffered_amount: 0,
360
+ peak_buffered_amount: 0,
361
+ dropped_frames: 0,
362
+ dropped_payload_bytes: 0,
363
+ sent_frames: 0,
364
+ sent_payload_bytes: 0,
365
+ last_payload_bytes: 0
366
+ }
367
+ end
368
+
369
+ def websocket_role_for_env(env)
370
+ return PROJECTOR_ROLE unless env.is_a?(Hash)
371
+
372
+ role = query_param(String(env["QUERY_STRING"] || ""), "role")
373
+ normalize_client_role(role)
374
+ end
375
+
376
+ def query_param(query_string, key)
377
+ URI.decode_www_form(query_string).each do |entry_key, value|
378
+ return value if entry_key == key
379
+ end
380
+
381
+ nil
382
+ rescue StandardError
383
+ nil
384
+ end
385
+
386
+ def normalize_client_role(role)
387
+ return PROJECTOR_ROLE unless VALID_CLIENT_ROLES.include?(role.to_s)
388
+
389
+ role.to_s
390
+ end
391
+
180
392
  def mutex
181
393
  @mutex ||= Mutex.new
182
394
  end
@@ -186,7 +398,7 @@ module Vizcore
186
398
  end
187
399
 
188
400
  def increment_dropped_frame_count
189
- mutex.synchronize { @dropped_frame_count = (@dropped_frame_count || 0) + 1 }
401
+ @dropped_frame_count = (@dropped_frame_count || 0) + 1
190
402
  end
191
403
 
192
404
  def dispatch_message(message, socket)
@@ -194,12 +406,22 @@ module Vizcore
194
406
  return unless handler
195
407
  return unless message.is_a?(Hash)
196
408
 
409
+ type = message["type"] || message[:type]
410
+ role = socket_role(socket)
411
+ return unless client_message_allowed?(role: role, type: type)
412
+
197
413
  handler.call(message, socket)
198
414
  rescue StandardError => e
199
415
  set_last_error(e)
200
416
  nil
201
417
  end
202
418
 
419
+ def client_message_allowed?(role:, type:)
420
+ return true unless READ_ONLY_ROLES.include?(role.to_s)
421
+
422
+ READ_ONLY_ALLOWED_MESSAGE_TYPES.include?(type.to_s)
423
+ end
424
+
203
425
  def text_headers
204
426
  { "content-type" => "text/plain; charset=utf-8" }
205
427
  end
@@ -1,8 +1,29 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "config"
4
+
3
5
  module Vizcore
4
6
  # Rack/WebSocket server runtime namespace.
5
7
  module Server
8
+ # Start a Vizcore server from Ruby code.
9
+ #
10
+ # @param config [Vizcore::Config, Hash, nil] runtime config or Config keyword options
11
+ # @param output [#puts] stream used by the runner
12
+ # @param options [Hash] Config keyword options when `config` is nil
13
+ # @return [void]
14
+ def self.start(config = nil, output: $stdout, **options)
15
+ runtime_config = start_config(config, options)
16
+ Runner.new(runtime_config, output: output).run
17
+ end
18
+
19
+ def self.start_config(config, options)
20
+ return Config.new(**options) if config.nil?
21
+ return config if config.is_a?(Config) && options.empty?
22
+ return Config.new(**config.merge(options)) if config.is_a?(Hash)
23
+
24
+ raise ArgumentError, "server start requires a Vizcore::Config or config options"
25
+ end
26
+ private_class_method :start_config
6
27
  end
7
28
  end
8
29