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.
- checksums.yaml +4 -4
- data/README.md +66 -648
- data/docs/assets/playground-worker.js +373 -0
- data/docs/assets/playground.css +440 -0
- data/docs/assets/playground.js +652 -0
- data/docs/index.html +2 -1
- data/docs/playground.html +81 -0
- data/docs/shape_dsl.md +269 -0
- data/frontend/index.html +50 -2
- data/frontend/src/audio-inspector.js +9 -0
- data/frontend/src/custom-shape-param-controls.js +106 -0
- data/frontend/src/live-controls.js +219 -7
- data/frontend/src/main.js +703 -45
- data/frontend/src/mapping-target-selector.js +109 -0
- data/frontend/src/midi-learn.js +22 -2
- data/frontend/src/performance-monitor.js +137 -1
- data/frontend/src/renderer/engine.js +401 -11
- data/frontend/src/renderer/layer-manager.js +490 -75
- data/frontend/src/runtime-control-preset.js +44 -0
- data/frontend/src/scene-patches.js +159 -0
- data/frontend/src/shader-error-overlay.js +1 -0
- data/frontend/src/shape-editor-controls.js +157 -0
- data/frontend/src/visuals/geometry.js +425 -27
- data/frontend/src/visuals/image-renderer.js +19 -0
- data/frontend/src/visuals/particle-system.js +10 -0
- data/frontend/src/visuals/shape-renderer.js +488 -0
- data/frontend/src/visuals/spectrogram-renderer.js +14 -0
- data/frontend/src/visuals/svg-arc.js +104 -0
- data/frontend/src/visuals/text-renderer.js +13 -0
- data/frontend/src/websocket-client.js +6 -0
- data/lib/vizcore/analysis/adaptive_normalizer.rb +20 -2
- data/lib/vizcore/analysis/bpm_estimator.rb +18 -8
- data/lib/vizcore/analysis/feature_recorder.rb +117 -7
- data/lib/vizcore/analysis/feature_replay.rb +48 -9
- data/lib/vizcore/analysis/pipeline.rb +258 -9
- data/lib/vizcore/analysis/tap_tempo.rb +17 -2
- data/lib/vizcore/audio/calibration.rb +156 -0
- data/lib/vizcore/audio/file_input.rb +28 -0
- data/lib/vizcore/audio/input_manager.rb +36 -1
- data/lib/vizcore/audio/midi_input.rb +5 -0
- data/lib/vizcore/audio/ring_buffer.rb +22 -0
- data/lib/vizcore/audio.rb +1 -0
- data/lib/vizcore/cli/dsl_reference.rb +65 -9
- data/lib/vizcore/cli/plugin_checker.rb +93 -0
- data/lib/vizcore/cli/scene_diagnostics.rb +2 -2
- data/lib/vizcore/cli/scene_inspector.rb +35 -1
- data/lib/vizcore/cli/scene_validator.rb +573 -33
- data/lib/vizcore/cli/shader_template.rb +7 -2
- data/lib/vizcore/cli/shader_uniform_docs.rb +11 -0
- data/lib/vizcore/cli.rb +268 -15
- data/lib/vizcore/config.rb +40 -3
- data/lib/vizcore/control_preset.rb +29 -0
- data/lib/vizcore/deep_copy.rb +21 -0
- data/lib/vizcore/dsl/color_helpers.rb +155 -0
- data/lib/vizcore/dsl/engine.rb +219 -23
- data/lib/vizcore/dsl/layer_builder.rb +1072 -21
- data/lib/vizcore/dsl/layer_group_builder.rb +10 -12
- data/lib/vizcore/dsl/layout_helpers.rb +290 -0
- data/lib/vizcore/dsl/mapping_preset_builder.rb +41 -0
- data/lib/vizcore/dsl/mapping_resolver.rb +549 -13
- data/lib/vizcore/dsl/mapping_transform_builder.rb +50 -0
- data/lib/vizcore/dsl/midi_map_executor.rb +219 -23
- data/lib/vizcore/dsl/reaction_builder.rb +1 -0
- data/lib/vizcore/dsl/scene_builder.rb +83 -13
- data/lib/vizcore/dsl/shader_source_resolver.rb +1 -10
- data/lib/vizcore/dsl/style_builder.rb +3 -0
- data/lib/vizcore/dsl/timeline_builder.rb +91 -8
- data/lib/vizcore/dsl/transition_controller.rb +157 -18
- data/lib/vizcore/dsl.rb +2 -0
- data/lib/vizcore/layer_catalog.rb +5 -2
- data/lib/vizcore/plugin_asset_policy.rb +55 -0
- data/lib/vizcore/project_manifest.rb +12 -2
- data/lib/vizcore/renderer/render_sequence.rb +104 -13
- data/lib/vizcore/renderer/scene_frame_source.rb +190 -12
- data/lib/vizcore/renderer/scene_serializer.rb +38 -0
- data/lib/vizcore/renderer/snapshot.rb +4 -3
- data/lib/vizcore/renderer/snapshot_renderer.rb +641 -23
- data/lib/vizcore/scene_trust.rb +31 -0
- data/lib/vizcore/server/frame_broadcaster.rb +513 -18
- data/lib/vizcore/server/rack_app.rb +151 -4
- data/lib/vizcore/server/runner.rb +697 -82
- data/lib/vizcore/server/websocket_handler.rb +236 -14
- data/lib/vizcore/server.rb +21 -0
- data/lib/vizcore/shape.rb +742 -0
- data/lib/vizcore/sync/osc_message.rb +66 -9
- data/lib/vizcore/version.rb +1 -1
- data/lib/vizcore.rb +34 -0
- data/scripts/browser_capture.mjs +31 -2
- data/sig/vizcore.rbs +154 -4
- metadata +29 -3
|
@@ -15,17 +15,35 @@ module Vizcore
|
|
|
15
15
|
module Server
|
|
16
16
|
# Bootstraps Rack/Puma, audio pipeline, scene reload, and MIDI runtime.
|
|
17
17
|
class Runner
|
|
18
|
+
DEFAULT_PROFILE_NAME = "default".freeze
|
|
19
|
+
|
|
18
20
|
# @param config [Vizcore::Config]
|
|
21
|
+
# @param manifest [Vizcore::ProjectManifest, nil]
|
|
22
|
+
# @param initial_profile [String, nil]
|
|
19
23
|
# @param output [#puts]
|
|
20
|
-
def initialize(config, output: $stdout)
|
|
24
|
+
def initialize(config, manifest: nil, initial_profile: nil, output: $stdout)
|
|
21
25
|
@config = config
|
|
26
|
+
@manifest = manifest
|
|
27
|
+
@available_profiles = derive_available_profiles
|
|
28
|
+
@active_profile = normalize_profile_name(initial_profile)
|
|
29
|
+
@active_scene_file = active_scene_file_for_profile(@active_profile)
|
|
22
30
|
@output = output
|
|
23
31
|
@shader_source_resolver = Vizcore::DSL::ShaderSourceResolver.new
|
|
24
32
|
@scene_catalog_mutex = Mutex.new
|
|
25
33
|
@scene_catalog = []
|
|
34
|
+
@scene_watcher = nil
|
|
35
|
+
@osc_schedule_mutex = Mutex.new
|
|
36
|
+
@osc_schedule_threads = []
|
|
37
|
+
@osc_runtime_active = false
|
|
38
|
+
@midi_runtime = nil
|
|
39
|
+
@osc_runtime = nil
|
|
40
|
+
@broadcaster = nil
|
|
26
41
|
@runtime_globals_mutex = Mutex.new
|
|
27
42
|
@runtime_globals = {}
|
|
28
|
-
@live_controls = {
|
|
43
|
+
@live_controls = {
|
|
44
|
+
"blackout" => default_live_control_state,
|
|
45
|
+
"freeze" => default_live_control_state
|
|
46
|
+
}
|
|
29
47
|
end
|
|
30
48
|
|
|
31
49
|
# Run server lifecycle until interrupted.
|
|
@@ -35,16 +53,16 @@ module Vizcore
|
|
|
35
53
|
# @return [void]
|
|
36
54
|
def run
|
|
37
55
|
validate_scene_file!
|
|
56
|
+
validate_public_bind_settings!
|
|
38
57
|
validate_feature_settings!
|
|
39
58
|
validate_control_preset_settings!
|
|
40
59
|
validate_plugin_asset_settings!
|
|
41
60
|
validate_audio_settings!
|
|
42
|
-
definition =
|
|
61
|
+
definition = load_definition_for_profile(@active_profile)
|
|
43
62
|
control_preset = load_control_preset
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
63
|
+
timeline_entry = initial_timeline_entry(definition)
|
|
64
|
+
scene = initial_scene(definition) || fallback_scene
|
|
65
|
+
broadcaster = nil
|
|
48
66
|
app = RackApp.new(
|
|
49
67
|
frontend_root: Vizcore.frontend_root,
|
|
50
68
|
audio_source: runtime_audio_source,
|
|
@@ -56,39 +74,50 @@ module Vizcore
|
|
|
56
74
|
control_preset: control_preset,
|
|
57
75
|
control_preset_path: @config.control_preset,
|
|
58
76
|
plugin_assets: @config.plugin_assets,
|
|
59
|
-
projector_mode: @config.projector_mode
|
|
77
|
+
projector_mode: @config.projector_mode,
|
|
78
|
+
runtime_status_provider: -> { runtime_status_payload }
|
|
60
79
|
)
|
|
61
80
|
server = Puma::Server.new(app, nil, min_threads: 0, max_threads: 4)
|
|
62
81
|
server.add_tcp_listener(@config.host, @config.port)
|
|
63
82
|
server.run
|
|
64
83
|
|
|
65
84
|
input_manager = build_input_manager
|
|
85
|
+
warn_if_sample_rate_mismatch(input_manager)
|
|
66
86
|
broadcaster = FrameBroadcaster.new(
|
|
67
87
|
scene_name: scene[:name].to_s,
|
|
68
88
|
scene_layers: scene[:layers],
|
|
69
89
|
scene_catalog: definition[:scenes],
|
|
70
90
|
transitions: definition[:transitions],
|
|
91
|
+
initial_timeline_entry: timeline_entry,
|
|
71
92
|
input_manager: input_manager,
|
|
72
93
|
analysis_pipeline: replay_pipeline,
|
|
73
94
|
noise_gate: @config.noise_gate,
|
|
74
95
|
audio_normalize: audio_normalize_settings(definition),
|
|
75
96
|
bpm: bpm_setting(definition),
|
|
76
97
|
bpm_lock: bpm_lock_setting(definition),
|
|
98
|
+
onset_sensitivity: analysis_setting(definition, :onset_sensitivity, 1.0),
|
|
99
|
+
fft_preview_bins: analysis_setting(definition, :fft_bins, Vizcore::Analysis::Pipeline::DEFAULT_FFT_PREVIEW_BINS),
|
|
100
|
+
peak_hold_frames: analysis_setting(definition, :peak_hold_frames, 0),
|
|
101
|
+
silence_reset_frames: analysis_setting(definition, :silence_reset_frames, Vizcore::Analysis::Pipeline::SILENCE_RESET_FRAMES),
|
|
77
102
|
error_reporter: ->(message) { @output.puts(message) }
|
|
78
103
|
)
|
|
104
|
+
@broadcaster = broadcaster
|
|
105
|
+
configure_runtime_for_definition(definition: definition, broadcaster: broadcaster)
|
|
79
106
|
replace_scene_catalog(definition[:scenes])
|
|
80
107
|
if file_transport_enabled?
|
|
81
108
|
broadcaster.sync_transport(playing: false, position_seconds: 0.0)
|
|
82
109
|
end
|
|
83
110
|
broadcaster.start
|
|
84
111
|
register_client_message_handler(broadcaster)
|
|
85
|
-
midi_runtime = start_midi_runtime(definition, broadcaster)
|
|
86
|
-
osc_runtime = start_osc_runtime(broadcaster)
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
112
|
+
@midi_runtime = start_midi_runtime(definition, broadcaster)
|
|
113
|
+
@osc_runtime = start_osc_runtime(broadcaster)
|
|
114
|
+
@scene_watcher = start_scene_watcher(
|
|
115
|
+
broadcaster,
|
|
116
|
+
definition: definition,
|
|
117
|
+
scene_file: active_scene_file
|
|
118
|
+
) do |reloaded_definition|
|
|
119
|
+
@midi_runtime = refresh_midi_runtime(@midi_runtime, reloaded_definition, broadcaster)
|
|
120
|
+
end if @config.reload?
|
|
92
121
|
|
|
93
122
|
@output.puts("Vizcore server listening at http://#{@config.host}:#{@config.port}")
|
|
94
123
|
@output.puts("Projector output: http://#{@config.host}:#{@config.port}/projector")
|
|
@@ -97,26 +126,84 @@ module Vizcore
|
|
|
97
126
|
@output.puts("Hot reload: #{@config.reload? ? 'enabled' : 'disabled'}")
|
|
98
127
|
@output.puts("Audio playback: http://#{@config.host}:#{@config.port}/audio-file") if file_transport_enabled?
|
|
99
128
|
@output.puts("Feature replay: #{@config.feature_file}") if feature_replay?
|
|
100
|
-
@output.puts("OSC sync: udp://#{@config.host}:#{@config.osc_port}") if osc_runtime
|
|
129
|
+
@output.puts("OSC sync: udp://#{@config.host}:#{@config.osc_port}") if @osc_runtime
|
|
101
130
|
@output.puts("Press Ctrl+C to stop.")
|
|
102
131
|
|
|
103
132
|
wait_for_interrupt
|
|
104
133
|
ensure
|
|
105
134
|
Vizcore::Server::WebSocketHandler.clear_message_handler
|
|
106
|
-
stop_osc_runtime(osc_runtime)
|
|
107
|
-
stop_midi_runtime(midi_runtime)
|
|
108
|
-
|
|
135
|
+
stop_osc_runtime(@osc_runtime)
|
|
136
|
+
stop_midi_runtime(@midi_runtime)
|
|
137
|
+
@scene_watcher&.stop
|
|
109
138
|
broadcaster&.stop
|
|
110
139
|
server&.stop(true)
|
|
111
140
|
end
|
|
112
141
|
|
|
142
|
+
def warn_if_sample_rate_mismatch(input_manager)
|
|
143
|
+
return unless input_manager.respond_to?(:status)
|
|
144
|
+
|
|
145
|
+
status = input_manager.status
|
|
146
|
+
return unless status[:sample_rate_mismatch]
|
|
147
|
+
|
|
148
|
+
requested = status[:requested_sample_rate]
|
|
149
|
+
actual = status[:sample_rate]
|
|
150
|
+
return unless requested && actual
|
|
151
|
+
|
|
152
|
+
@output.puts(
|
|
153
|
+
"Warning: requested audio sample rate #{requested} does not match device sample rate #{actual}; " \
|
|
154
|
+
"analysis will use #{actual}."
|
|
155
|
+
)
|
|
156
|
+
end
|
|
157
|
+
|
|
113
158
|
private
|
|
114
159
|
|
|
160
|
+
def derive_available_profiles
|
|
161
|
+
base_profiles = [DEFAULT_PROFILE_NAME]
|
|
162
|
+
manifest_profiles = @manifest ? Array(@manifest.profile_names).map(&:to_s) : []
|
|
163
|
+
all_profiles = (base_profiles + manifest_profiles).map { |profile| normalize_profile_name(profile) }
|
|
164
|
+
all_profiles.uniq
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def normalize_profile_name(value)
|
|
168
|
+
raw = value.to_s.strip
|
|
169
|
+
raw.empty? ? DEFAULT_PROFILE_NAME : raw
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def active_profile_for_api
|
|
173
|
+
@active_profile
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def available_profiles_for_api
|
|
177
|
+
Array(@available_profiles)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def active_scene_file_for_profile(profile)
|
|
181
|
+
normalized_profile = normalize_profile_name(profile)
|
|
182
|
+
defaults = manifest_config_defaults_for(normalized_profile)
|
|
183
|
+
defaults.fetch(:scene_file, @config.scene_file)
|
|
184
|
+
rescue StandardError
|
|
185
|
+
@config.scene_file
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def manifest_config_defaults_for(profile)
|
|
189
|
+
return {} unless @manifest
|
|
190
|
+
|
|
191
|
+
@manifest.config_defaults(profile: profile)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def active_scene_file
|
|
195
|
+
@active_scene_file
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def active_profile? (candidate)
|
|
199
|
+
active_profile_for_api == normalize_profile_name(candidate)
|
|
200
|
+
end
|
|
201
|
+
|
|
115
202
|
def validate_scene_file!
|
|
116
|
-
return if
|
|
203
|
+
return if active_scene_file&.file?
|
|
117
204
|
|
|
118
|
-
message = if
|
|
119
|
-
"Scene file not found: #{
|
|
205
|
+
message = if active_scene_file
|
|
206
|
+
"Scene file not found: #{active_scene_file}"
|
|
120
207
|
else
|
|
121
208
|
"Scene file is required"
|
|
122
209
|
end
|
|
@@ -124,16 +211,21 @@ module Vizcore
|
|
|
124
211
|
raise Vizcore::ConfigurationError, message
|
|
125
212
|
end
|
|
126
213
|
|
|
127
|
-
def
|
|
128
|
-
|
|
129
|
-
|
|
214
|
+
def load_definition_for_profile(profile)
|
|
215
|
+
scene_file = active_scene_file_for_profile(profile)
|
|
216
|
+
raw_definition = Vizcore::DSL::Engine.load_file(scene_file.to_s)
|
|
217
|
+
resolve_shader_sources(raw_definition, scene_file: scene_file)
|
|
130
218
|
rescue StandardError => e
|
|
131
219
|
raise Vizcore::SceneLoadError, Vizcore::ErrorFormatting.summarize(
|
|
132
220
|
e,
|
|
133
|
-
context: "Failed to load scene file #{
|
|
221
|
+
context: "Failed to load scene file #{scene_file}"
|
|
134
222
|
)
|
|
135
223
|
end
|
|
136
224
|
|
|
225
|
+
def load_definition!
|
|
226
|
+
load_definition_for_profile(active_profile_for_api)
|
|
227
|
+
end
|
|
228
|
+
|
|
137
229
|
def validate_audio_settings!
|
|
138
230
|
return if feature_replay?
|
|
139
231
|
return unless @config.audio_source == :file
|
|
@@ -142,6 +234,19 @@ module Vizcore
|
|
|
142
234
|
raise Vizcore::ConfigurationError, "Audio file not found: #{@config.audio_file || '(nil)'}"
|
|
143
235
|
end
|
|
144
236
|
|
|
237
|
+
def validate_public_bind_settings!
|
|
238
|
+
return if @config.allow_public_control?
|
|
239
|
+
return unless public_bind_host?(@config.host)
|
|
240
|
+
|
|
241
|
+
raise Vizcore::ConfigurationError,
|
|
242
|
+
"Refusing to expose Vizcore control routes on #{@config.host}; pass --allow-public-control when this is intentional"
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def public_bind_host?(host)
|
|
246
|
+
value = host.to_s.strip
|
|
247
|
+
value.empty? || value == "0.0.0.0" || value == "::" || value == "[::]"
|
|
248
|
+
end
|
|
249
|
+
|
|
145
250
|
def validate_feature_settings!
|
|
146
251
|
return unless feature_replay?
|
|
147
252
|
return if @config.feature_file.file?
|
|
@@ -201,6 +306,59 @@ module Vizcore
|
|
|
201
306
|
feature_replay? ? nil : @config.audio_file
|
|
202
307
|
end
|
|
203
308
|
|
|
309
|
+
def runtime_status_payload
|
|
310
|
+
payload = @broadcaster ? @broadcaster.runtime_status : {}
|
|
311
|
+
payload.merge(
|
|
312
|
+
active_profile: active_profile_for_api,
|
|
313
|
+
available_profiles: available_profiles_for_api
|
|
314
|
+
)
|
|
315
|
+
rescue StandardError
|
|
316
|
+
{
|
|
317
|
+
active_profile: active_profile_for_api,
|
|
318
|
+
available_profiles: available_profiles_for_api
|
|
319
|
+
}
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def configure_runtime_for_definition(definition:, broadcaster: @broadcaster)
|
|
323
|
+
replace_runtime_globals(globals_for(definition))
|
|
324
|
+
@tap_tempo_key = tap_tempo_key(definition)
|
|
325
|
+
broadcaster.update_transition_definition(
|
|
326
|
+
scenes: Array(definition[:scenes]),
|
|
327
|
+
transitions: Array(definition[:transitions])
|
|
328
|
+
)
|
|
329
|
+
broadcaster.update_analysis_settings(
|
|
330
|
+
audio_normalize: audio_normalize_settings(definition),
|
|
331
|
+
bpm: bpm_setting(definition),
|
|
332
|
+
bpm_lock: bpm_lock_setting(definition),
|
|
333
|
+
onset_sensitivity: analysis_setting(definition, :onset_sensitivity, 1.0),
|
|
334
|
+
fft_preview_bins: analysis_setting(definition, :fft_bins, Vizcore::Analysis::Pipeline::DEFAULT_FFT_PREVIEW_BINS),
|
|
335
|
+
peak_hold_frames: analysis_setting(definition, :peak_hold_frames, 0),
|
|
336
|
+
silence_reset_frames: analysis_setting(definition, :silence_reset_frames, Vizcore::Analysis::Pipeline::SILENCE_RESET_FRAMES)
|
|
337
|
+
)
|
|
338
|
+
scene = initial_scene(definition) || fallback_scene
|
|
339
|
+
broadcaster.update_scene(scene_name: scene[:name], scene_layers: scene[:layers])
|
|
340
|
+
scene
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def runtime_config_update_payload(scene:, definition:)
|
|
344
|
+
{
|
|
345
|
+
scene: scene,
|
|
346
|
+
scenes: scene_names_for(definition),
|
|
347
|
+
tap_tempo_key: @tap_tempo_key,
|
|
348
|
+
key_mappings: key_mappings_for(definition),
|
|
349
|
+
globals: runtime_globals_snapshot,
|
|
350
|
+
active_profile: active_profile_for_api,
|
|
351
|
+
available_profiles: available_profiles_for_api
|
|
352
|
+
}
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
def broadcast_config_update(scene:, definition:)
|
|
356
|
+
WebSocketHandler.broadcast(
|
|
357
|
+
type: "config_update",
|
|
358
|
+
payload: runtime_config_update_payload(scene: scene, definition: definition)
|
|
359
|
+
)
|
|
360
|
+
end
|
|
361
|
+
|
|
204
362
|
def wait_for_interrupt
|
|
205
363
|
stop_requested = false
|
|
206
364
|
%w[INT TERM].each do |signal_name|
|
|
@@ -211,37 +369,29 @@ module Vizcore
|
|
|
211
369
|
sleep(0.1) until stop_requested
|
|
212
370
|
end
|
|
213
371
|
|
|
214
|
-
def start_scene_watcher(broadcaster, definition:, &on_reload)
|
|
215
|
-
watcher = Vizcore::Server::SceneDependencyWatcher.new(
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
scene =
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
bpm: bpm_setting(definition),
|
|
228
|
-
bpm_lock: bpm_lock_setting(definition)
|
|
229
|
-
)
|
|
230
|
-
broadcaster.update_scene(scene_name: scene[:name], scene_layers: scene[:layers])
|
|
231
|
-
on_reload&.call(definition)
|
|
372
|
+
def start_scene_watcher(broadcaster, definition:, scene_file: nil, &on_reload)
|
|
373
|
+
watcher = Vizcore::Server::SceneDependencyWatcher.new(
|
|
374
|
+
scene_file: (scene_file || active_scene_file).to_s,
|
|
375
|
+
definition: definition
|
|
376
|
+
) do |reloaded_definition, _changed_path|
|
|
377
|
+
reloaded_definition = resolve_shader_sources(reloaded_definition, scene_file: (scene_file || active_scene_file))
|
|
378
|
+
scene = configure_runtime_for_definition(definition: reloaded_definition, broadcaster: broadcaster)
|
|
379
|
+
on_reload&.call(reloaded_definition)
|
|
380
|
+
broadcast_config_update(scene: scene, definition: reloaded_definition)
|
|
381
|
+
@output.puts("Scene reloaded: #{scene[:name]}")
|
|
382
|
+
rescue StandardError => e
|
|
383
|
+
message = Vizcore::ErrorFormatting.summarize(e, context: "Scene reload failed")
|
|
384
|
+
@output.puts(message)
|
|
232
385
|
WebSocketHandler.broadcast(
|
|
233
|
-
type: "
|
|
386
|
+
type: "runtime_error",
|
|
234
387
|
payload: {
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
388
|
+
source: "scene_reload",
|
|
389
|
+
event: "scene_reload_failed",
|
|
390
|
+
context: "Scene reload failed",
|
|
391
|
+
message: message,
|
|
392
|
+
keeping_last_good_scene: true
|
|
240
393
|
}
|
|
241
394
|
)
|
|
242
|
-
@output.puts("Scene reloaded: #{scene[:name]}")
|
|
243
|
-
rescue StandardError => e
|
|
244
|
-
@output.puts(Vizcore::ErrorFormatting.summarize(e, context: "Scene reload failed"))
|
|
245
395
|
end
|
|
246
396
|
watcher.start
|
|
247
397
|
watcher
|
|
@@ -254,9 +404,31 @@ module Vizcore
|
|
|
254
404
|
definition.fetch(:scenes, []).first
|
|
255
405
|
end
|
|
256
406
|
|
|
407
|
+
def initial_timeline_entry(definition)
|
|
408
|
+
Array(definition[:timelines]).each do |timeline|
|
|
409
|
+
entry = Array(timeline).first
|
|
410
|
+
return entry if entry
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
nil
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
def initial_scene(definition)
|
|
417
|
+
entry = initial_timeline_entry(definition)
|
|
418
|
+
scene_name = entry&.dig(:scene)
|
|
419
|
+
return first_scene(definition) unless scene_name
|
|
420
|
+
|
|
421
|
+
Array(definition[:scenes]).find do |candidate|
|
|
422
|
+
candidate[:name].to_s == scene_name.to_s
|
|
423
|
+
end
|
|
424
|
+
end
|
|
425
|
+
|
|
257
426
|
def fallback_scene
|
|
427
|
+
scene_file = active_scene_file
|
|
428
|
+
scene_name = scene_file ? scene_file.basename(".rb").to_s : ""
|
|
429
|
+
|
|
258
430
|
{
|
|
259
|
-
name:
|
|
431
|
+
name: scene_name.empty? ? DEFAULT_PROFILE_NAME.to_sym : scene_name.to_sym,
|
|
260
432
|
layers: []
|
|
261
433
|
}
|
|
262
434
|
end
|
|
@@ -319,20 +491,24 @@ module Vizcore
|
|
|
319
491
|
def start_osc_runtime(broadcaster)
|
|
320
492
|
return nil unless @config.osc_port
|
|
321
493
|
|
|
494
|
+
@osc_runtime_active = true
|
|
322
495
|
receiver = Vizcore::Sync::OscReceiver.new(
|
|
323
496
|
host: @config.host,
|
|
324
497
|
port: @config.osc_port,
|
|
325
|
-
handler: ->(message) {
|
|
498
|
+
handler: ->(message) { handle_osc_messages(message, broadcaster) },
|
|
326
499
|
error_reporter: ->(message) { @output.puts(message) }
|
|
327
500
|
)
|
|
328
501
|
receiver.start
|
|
329
502
|
rescue StandardError => e
|
|
330
503
|
@output.puts(Vizcore::ErrorFormatting.summarize(e, context: "OSC runtime disabled"))
|
|
331
504
|
receiver&.stop
|
|
505
|
+
@osc_runtime_active = false
|
|
332
506
|
nil
|
|
333
507
|
end
|
|
334
508
|
|
|
335
509
|
def stop_osc_runtime(runtime)
|
|
510
|
+
@osc_runtime_active = false
|
|
511
|
+
clear_scheduled_osc_messages
|
|
336
512
|
runtime&.stop
|
|
337
513
|
nil
|
|
338
514
|
rescue StandardError => e
|
|
@@ -340,10 +516,60 @@ module Vizcore
|
|
|
340
516
|
nil
|
|
341
517
|
end
|
|
342
518
|
|
|
519
|
+
def handle_osc_messages(messages, broadcaster)
|
|
520
|
+
Array(messages).each do |message|
|
|
521
|
+
next unless message
|
|
522
|
+
handle_osc_message(message, broadcaster)
|
|
523
|
+
end
|
|
524
|
+
end
|
|
525
|
+
|
|
343
526
|
def handle_osc_message(message, broadcaster)
|
|
527
|
+
return unless @osc_runtime_active
|
|
528
|
+
|
|
529
|
+
target_time = finite_float(message.timetag)
|
|
530
|
+
return process_osc_message(message, broadcaster) if target_time.nil?
|
|
531
|
+
|
|
532
|
+
delay = target_time - wall_clock_seconds
|
|
533
|
+
return process_osc_message(message, broadcaster) if delay <= 0
|
|
534
|
+
|
|
535
|
+
schedule_osc_message(message, broadcaster, delay)
|
|
536
|
+
rescue StandardError => e
|
|
537
|
+
@output.puts(Vizcore::ErrorFormatting.summarize(e, context: "OSC control message failed"))
|
|
538
|
+
end
|
|
539
|
+
|
|
540
|
+
def schedule_osc_message(message, broadcaster, delay)
|
|
541
|
+
thread = Thread.new do
|
|
542
|
+
sleep(delay)
|
|
543
|
+
return unless @osc_runtime_active
|
|
544
|
+
|
|
545
|
+
process_osc_message(message, broadcaster)
|
|
546
|
+
ensure
|
|
547
|
+
@osc_schedule_mutex.synchronize { @osc_schedule_threads.delete(Thread.current) }
|
|
548
|
+
end
|
|
549
|
+
@osc_schedule_mutex.synchronize { @osc_schedule_threads << thread }
|
|
550
|
+
end
|
|
551
|
+
|
|
552
|
+
def clear_scheduled_osc_messages
|
|
553
|
+
threads = @osc_schedule_mutex.synchronize do
|
|
554
|
+
threads = Array(@osc_schedule_threads)
|
|
555
|
+
@osc_schedule_threads.clear
|
|
556
|
+
threads
|
|
557
|
+
end
|
|
558
|
+
threads.each do |thread|
|
|
559
|
+
thread.kill
|
|
560
|
+
thread.join(0.05)
|
|
561
|
+
rescue StandardError
|
|
562
|
+
nil
|
|
563
|
+
end
|
|
564
|
+
end
|
|
565
|
+
|
|
566
|
+
def process_osc_message(message, broadcaster)
|
|
344
567
|
case message.address
|
|
345
568
|
when "/vizcore/scene"
|
|
346
|
-
|
|
569
|
+
arguments = Array(message.arguments)
|
|
570
|
+
target_name = arguments.first
|
|
571
|
+
effect = parse_osc_scene_effect(arguments.drop(1))
|
|
572
|
+
switch_scene_from_client(target_name, broadcaster, source: "osc", effect: effect)
|
|
347
573
|
when "/vizcore/tap"
|
|
348
574
|
apply_tap_tempo({ "client_tapped_at_ms" => wall_clock_ms }, broadcaster)
|
|
349
575
|
when "/vizcore/bpm"
|
|
@@ -351,16 +577,16 @@ module Vizcore
|
|
|
351
577
|
when "/vizcore/bpm_unlock"
|
|
352
578
|
apply_osc_bpm_unlock(broadcaster)
|
|
353
579
|
when %r{\A/vizcore/global/([^/]+)\z}
|
|
354
|
-
apply_osc_global(Regexp.last_match(1), message.arguments
|
|
580
|
+
apply_osc_global(Regexp.last_match(1), message.arguments)
|
|
581
|
+
when %r{\A/vizcore/layer/([^/]+)/(.+)\z}
|
|
582
|
+
apply_osc_layer_param(broadcaster, Regexp.last_match(1), Regexp.last_match(2), message.arguments)
|
|
355
583
|
when %r{\A/vizcore/live/(blackout|freeze)\z}
|
|
356
|
-
apply_osc_live_control(Regexp.last_match(1), message.arguments
|
|
584
|
+
apply_osc_live_control(Regexp.last_match(1), message.arguments)
|
|
357
585
|
when "/vizcore/transport/play", "/vizcore/transport/position"
|
|
358
586
|
apply_osc_transport(broadcaster, playing: true, position_seconds: message.arguments.first)
|
|
359
587
|
when "/vizcore/transport/stop"
|
|
360
588
|
apply_osc_transport(broadcaster, playing: false, position_seconds: message.arguments.first)
|
|
361
589
|
end
|
|
362
|
-
rescue StandardError => e
|
|
363
|
-
@output.puts(Vizcore::ErrorFormatting.summarize(e, context: "OSC control message failed"))
|
|
364
590
|
end
|
|
365
591
|
|
|
366
592
|
def handle_midi_event(executor, event, broadcaster)
|
|
@@ -378,6 +604,73 @@ module Vizcore
|
|
|
378
604
|
end
|
|
379
605
|
end
|
|
380
606
|
|
|
607
|
+
def switch_profile(raw_profile, broadcaster)
|
|
608
|
+
profile = normalize_profile_name(raw_profile)
|
|
609
|
+
return if profile == active_profile_for_api
|
|
610
|
+
|
|
611
|
+
unless available_profiles_for_api.include?(profile)
|
|
612
|
+
WebSocketHandler.broadcast(
|
|
613
|
+
type: "runtime_error",
|
|
614
|
+
payload: {
|
|
615
|
+
source: "profile",
|
|
616
|
+
context: "Unknown profile: #{profile}",
|
|
617
|
+
event: "unknown_profile",
|
|
618
|
+
message: "Profile not found: #{profile}"
|
|
619
|
+
}
|
|
620
|
+
)
|
|
621
|
+
return
|
|
622
|
+
end
|
|
623
|
+
|
|
624
|
+
scene_file = active_scene_file_for_profile(profile)
|
|
625
|
+
if scene_file.nil? || !scene_file.file?
|
|
626
|
+
WebSocketHandler.broadcast(
|
|
627
|
+
type: "runtime_error",
|
|
628
|
+
payload: {
|
|
629
|
+
source: "profile",
|
|
630
|
+
context: "Profile scene file missing",
|
|
631
|
+
event: "profile_scene_missing",
|
|
632
|
+
message: "Missing scene file for profile: #{profile}"
|
|
633
|
+
}
|
|
634
|
+
)
|
|
635
|
+
return
|
|
636
|
+
end
|
|
637
|
+
|
|
638
|
+
definition = load_definition_for_profile(profile)
|
|
639
|
+
scene = configure_runtime_for_definition(definition: definition, broadcaster: broadcaster)
|
|
640
|
+
@midi_runtime = refresh_midi_runtime(@midi_runtime, definition, broadcaster)
|
|
641
|
+
restart_scene_watcher_for_profile(profile_scene_file: scene_file, definition: definition, broadcaster: broadcaster)
|
|
642
|
+
@active_profile = profile
|
|
643
|
+
@active_scene_file = scene_file
|
|
644
|
+
broadcast_config_update(scene: scene, definition: definition)
|
|
645
|
+
rescue StandardError => e
|
|
646
|
+
message = Vizcore::ErrorFormatting.summarize(e, context: "Profile switch failed")
|
|
647
|
+
@output.puts(message)
|
|
648
|
+
WebSocketHandler.broadcast(
|
|
649
|
+
type: "runtime_error",
|
|
650
|
+
payload: {
|
|
651
|
+
source: "profile",
|
|
652
|
+
context: "Profile switch failed",
|
|
653
|
+
event: "profile_switch_failed",
|
|
654
|
+
message: message
|
|
655
|
+
}
|
|
656
|
+
)
|
|
657
|
+
end
|
|
658
|
+
|
|
659
|
+
def restart_scene_watcher_for_profile(profile_scene_file:, definition:, broadcaster:)
|
|
660
|
+
return unless @config.reload?
|
|
661
|
+
|
|
662
|
+
new_watcher = start_scene_watcher(
|
|
663
|
+
broadcaster,
|
|
664
|
+
definition: definition,
|
|
665
|
+
scene_file: profile_scene_file
|
|
666
|
+
)
|
|
667
|
+
return unless new_watcher
|
|
668
|
+
|
|
669
|
+
old_watcher = @scene_watcher
|
|
670
|
+
@scene_watcher = new_watcher
|
|
671
|
+
old_watcher&.stop
|
|
672
|
+
end
|
|
673
|
+
|
|
381
674
|
def handle_client_message(message, broadcaster, socket = nil)
|
|
382
675
|
type = message["type"] || message[:type]
|
|
383
676
|
payload = message["payload"] || message[:payload]
|
|
@@ -395,9 +688,16 @@ module Vizcore
|
|
|
395
688
|
when "switch_scene"
|
|
396
689
|
values = Hash(payload)
|
|
397
690
|
target_name = values.fetch("scene", values.fetch(:scene, values.fetch("scene_name", values.fetch(:scene_name, nil))))
|
|
398
|
-
|
|
691
|
+
effect = normalize_transition_effect(values["effect"] || values[:effect])
|
|
692
|
+
switch_scene_from_client(target_name, broadcaster, effect: effect)
|
|
693
|
+
when "switch_profile"
|
|
694
|
+
switch_profile((payload || {})["profile"] || (payload || {})[:profile], broadcaster)
|
|
399
695
|
when "tap_tempo"
|
|
400
696
|
apply_tap_tempo(payload, broadcaster)
|
|
697
|
+
when "custom_shape_param"
|
|
698
|
+
apply_custom_shape_param(payload, broadcaster)
|
|
699
|
+
when "client_runtime_error"
|
|
700
|
+
report_client_runtime_error(payload)
|
|
401
701
|
end
|
|
402
702
|
rescue StandardError => e
|
|
403
703
|
@output.puts(Vizcore::ErrorFormatting.summarize(e, context: "Client control message failed"))
|
|
@@ -418,24 +718,52 @@ module Vizcore
|
|
|
418
718
|
WebSocketHandler.send_to(socket, type: "latency_probe", payload: response)
|
|
419
719
|
end
|
|
420
720
|
|
|
721
|
+
def report_client_runtime_error(payload)
|
|
722
|
+
values = Hash(payload)
|
|
723
|
+
message = values["message"] || values[:message]
|
|
724
|
+
return unless message
|
|
725
|
+
|
|
726
|
+
context = values["context"] || values[:context] || "Client runtime error"
|
|
727
|
+
source = normalize_client_error_source(values["source"] || values[:source])
|
|
728
|
+
event = values["event"] || values[:event]
|
|
729
|
+
WebSocketHandler.broadcast(
|
|
730
|
+
type: "runtime_error",
|
|
731
|
+
payload: {
|
|
732
|
+
source: source,
|
|
733
|
+
context: String(context),
|
|
734
|
+
message: String(message),
|
|
735
|
+
frame_id: current_frame_id(values),
|
|
736
|
+
event: event
|
|
737
|
+
}
|
|
738
|
+
)
|
|
739
|
+
end
|
|
740
|
+
|
|
741
|
+
def current_frame_id(values)
|
|
742
|
+
frame_id = values["frame_id"] || values[:frame_id]
|
|
743
|
+
parsed = finite_float(frame_id)
|
|
744
|
+
parsed if parsed
|
|
745
|
+
rescue StandardError
|
|
746
|
+
nil
|
|
747
|
+
end
|
|
748
|
+
|
|
749
|
+
def normalize_client_error_source(value)
|
|
750
|
+
source = String(value || "runtime").strip
|
|
751
|
+
source.empty? ? "runtime" : source
|
|
752
|
+
end
|
|
753
|
+
|
|
421
754
|
def apply_midi_action(action, executor, broadcaster)
|
|
422
755
|
case action[:type]
|
|
423
756
|
when :switch_scene
|
|
424
757
|
target_scene = action[:scene]
|
|
425
758
|
return unless target_scene
|
|
426
759
|
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
broadcaster.
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
to: target_scene[:name].to_s,
|
|
435
|
-
effect: action[:effect],
|
|
436
|
-
source: "midi"
|
|
437
|
-
}
|
|
438
|
-
)
|
|
760
|
+
apply_midi_scene_change(target_scene, action[:effect], broadcaster)
|
|
761
|
+
when :next_scene
|
|
762
|
+
target_scene = adjacent_scene_for(broadcaster.current_scene_snapshot[:name], offset: 1)
|
|
763
|
+
apply_midi_scene_change(target_scene, action[:effect], broadcaster) if target_scene
|
|
764
|
+
when :previous_scene
|
|
765
|
+
target_scene = adjacent_scene_for(broadcaster.current_scene_snapshot[:name], offset: -1)
|
|
766
|
+
apply_midi_scene_change(target_scene, action[:effect], broadcaster) if target_scene
|
|
439
767
|
when :set_global
|
|
440
768
|
WebSocketHandler.broadcast(
|
|
441
769
|
type: "config_update",
|
|
@@ -443,9 +771,40 @@ module Vizcore
|
|
|
443
771
|
globals: executor.globals
|
|
444
772
|
}
|
|
445
773
|
)
|
|
774
|
+
when :live_control
|
|
775
|
+
apply_midi_live_control(action[:control], action)
|
|
446
776
|
end
|
|
447
777
|
end
|
|
448
778
|
|
|
779
|
+
def apply_midi_live_control(control, action)
|
|
780
|
+
control_name = control.to_s
|
|
781
|
+
return unless @live_controls.key?(control_name)
|
|
782
|
+
|
|
783
|
+
@live_controls[control_name] = normalize_live_control_state(action)
|
|
784
|
+
WebSocketHandler.broadcast(
|
|
785
|
+
type: "config_update",
|
|
786
|
+
payload: {
|
|
787
|
+
live_controls: @live_controls.dup,
|
|
788
|
+
source: "midi"
|
|
789
|
+
}
|
|
790
|
+
)
|
|
791
|
+
end
|
|
792
|
+
|
|
793
|
+
def apply_midi_scene_change(target_scene, effect, broadcaster)
|
|
794
|
+
current = broadcaster.current_scene_snapshot
|
|
795
|
+
from_scene = current[:name]
|
|
796
|
+
broadcaster.update_scene(scene_name: target_scene[:name], scene_layers: target_scene[:layers])
|
|
797
|
+
WebSocketHandler.broadcast(
|
|
798
|
+
type: "scene_change",
|
|
799
|
+
payload: {
|
|
800
|
+
from: from_scene.to_s,
|
|
801
|
+
to: target_scene[:name].to_s,
|
|
802
|
+
effect: effect,
|
|
803
|
+
source: "midi"
|
|
804
|
+
}
|
|
805
|
+
)
|
|
806
|
+
end
|
|
807
|
+
|
|
449
808
|
def midi_runtime_settings(definition)
|
|
450
809
|
midi_inputs = Array(definition[:midi])
|
|
451
810
|
|
|
@@ -506,8 +865,11 @@ module Vizcore
|
|
|
506
865
|
[]
|
|
507
866
|
end
|
|
508
867
|
|
|
509
|
-
def resolve_shader_sources(definition)
|
|
510
|
-
@shader_source_resolver.resolve(
|
|
868
|
+
def resolve_shader_sources(definition, scene_file: nil)
|
|
869
|
+
@shader_source_resolver.resolve(
|
|
870
|
+
definition: definition,
|
|
871
|
+
scene_file: (scene_file || active_scene_file).to_s
|
|
872
|
+
)
|
|
511
873
|
end
|
|
512
874
|
|
|
513
875
|
def replace_scene_catalog(scenes)
|
|
@@ -536,6 +898,12 @@ module Vizcore
|
|
|
536
898
|
nil
|
|
537
899
|
end
|
|
538
900
|
|
|
901
|
+
def analysis_setting(definition, key, fallback)
|
|
902
|
+
Hash(definition[:analysis] || {}).fetch(key, fallback)
|
|
903
|
+
rescue StandardError
|
|
904
|
+
fallback
|
|
905
|
+
end
|
|
906
|
+
|
|
539
907
|
def bpm_setting(definition)
|
|
540
908
|
@config.bpm || Hash(definition[:analysis] || {})[:bpm]
|
|
541
909
|
rescue StandardError
|
|
@@ -605,8 +973,8 @@ module Vizcore
|
|
|
605
973
|
)
|
|
606
974
|
end
|
|
607
975
|
|
|
608
|
-
def apply_osc_global(name,
|
|
609
|
-
globals = set_runtime_global(name,
|
|
976
|
+
def apply_osc_global(name, arguments)
|
|
977
|
+
globals = set_runtime_global(name, normalize_osc_argument(arguments))
|
|
610
978
|
WebSocketHandler.broadcast(
|
|
611
979
|
type: "config_update",
|
|
612
980
|
payload: {
|
|
@@ -616,8 +984,50 @@ module Vizcore
|
|
|
616
984
|
)
|
|
617
985
|
end
|
|
618
986
|
|
|
987
|
+
def apply_osc_layer_param(broadcaster, layer_name, param, arguments)
|
|
988
|
+
return unless broadcaster.respond_to?(:set_layer_param)
|
|
989
|
+
|
|
990
|
+
overrides = broadcaster.set_layer_param(
|
|
991
|
+
layer_name: layer_name,
|
|
992
|
+
param: param.to_s.tr("/", "."),
|
|
993
|
+
value: normalize_osc_argument(arguments)
|
|
994
|
+
)
|
|
995
|
+
WebSocketHandler.broadcast(
|
|
996
|
+
type: "config_update",
|
|
997
|
+
payload: {
|
|
998
|
+
layer_params: overrides,
|
|
999
|
+
source: "osc"
|
|
1000
|
+
}
|
|
1001
|
+
)
|
|
1002
|
+
end
|
|
1003
|
+
|
|
1004
|
+
def apply_custom_shape_param(payload, broadcaster)
|
|
1005
|
+
return unless broadcaster.respond_to?(:set_custom_shape_param)
|
|
1006
|
+
|
|
1007
|
+
values = Hash(payload)
|
|
1008
|
+
overrides = broadcaster.set_custom_shape_param(
|
|
1009
|
+
layer_name: values["layer"] || values[:layer] || values["layer_name"] || values[:layer_name],
|
|
1010
|
+
custom_shape_index: values["custom_shape_index"] || values[:custom_shape_index] || values["index"] || values[:index],
|
|
1011
|
+
param: values["param"] || values[:param],
|
|
1012
|
+
value: values["value"] || values[:value]
|
|
1013
|
+
)
|
|
1014
|
+
WebSocketHandler.broadcast(
|
|
1015
|
+
type: "config_update",
|
|
1016
|
+
payload: {
|
|
1017
|
+
custom_shape_params: overrides,
|
|
1018
|
+
source: "ui"
|
|
1019
|
+
}
|
|
1020
|
+
)
|
|
1021
|
+
end
|
|
1022
|
+
|
|
619
1023
|
def apply_osc_live_control(control, value)
|
|
620
|
-
|
|
1024
|
+
values = Array(value)
|
|
1025
|
+
@live_controls[control] = default_live_control_state(
|
|
1026
|
+
enabled: osc_truthy?(values.first),
|
|
1027
|
+
fade: values[1],
|
|
1028
|
+
release: values[2],
|
|
1029
|
+
color: values[3]
|
|
1030
|
+
)
|
|
621
1031
|
WebSocketHandler.broadcast(
|
|
622
1032
|
type: "config_update",
|
|
623
1033
|
payload: {
|
|
@@ -636,11 +1046,73 @@ module Vizcore
|
|
|
636
1046
|
)
|
|
637
1047
|
end
|
|
638
1048
|
|
|
639
|
-
def
|
|
1049
|
+
def normalize_osc_argument(arguments)
|
|
1050
|
+
values = Array(arguments)
|
|
1051
|
+
normalize_osc_value(values.first, input_min: values[1], input_max: values[2])
|
|
1052
|
+
end
|
|
1053
|
+
|
|
1054
|
+
def normalize_osc_value(value, input_min: nil, input_max: nil)
|
|
640
1055
|
numeric = finite_float(value)
|
|
1056
|
+
normalized_range = parse_osc_range(input_min, input_max)
|
|
1057
|
+
normalized = normalize_osc_range(numeric, input_min: normalized_range[:min], input_max: normalized_range[:max]) unless numeric.nil? || normalized_range.nil?
|
|
1058
|
+
return normalized unless normalized.nil?
|
|
1059
|
+
|
|
641
1060
|
numeric.nil? ? value : numeric
|
|
642
1061
|
end
|
|
643
1062
|
|
|
1063
|
+
def parse_osc_range(input_min, input_max)
|
|
1064
|
+
if input_max.nil?
|
|
1065
|
+
return parse_osc_range_preset(input_min) if input_min
|
|
1066
|
+
return nil
|
|
1067
|
+
end
|
|
1068
|
+
|
|
1069
|
+
{
|
|
1070
|
+
min: input_min,
|
|
1071
|
+
max: input_max
|
|
1072
|
+
}
|
|
1073
|
+
end
|
|
1074
|
+
|
|
1075
|
+
def parse_osc_range_preset(value)
|
|
1076
|
+
return nil unless value
|
|
1077
|
+
|
|
1078
|
+
symbol = value.to_s.strip.downcase
|
|
1079
|
+
case symbol
|
|
1080
|
+
when "0..1", "01", "unit", "unit01", "unit_01", "unit_0_1", "normalized"
|
|
1081
|
+
{ min: 0.0, max: 1.0 }
|
|
1082
|
+
when "-1..1", "bipolar", "bip", "minus1..1", "minus1_1", "-1_1", "-1,1", "-1 to 1"
|
|
1083
|
+
{ min: -1.0, max: 1.0 }
|
|
1084
|
+
when "midi", "midicc", "cc", "midi_cc", "0..127", "0..128", "127"
|
|
1085
|
+
{ min: 0.0, max: 127.0 }
|
|
1086
|
+
else
|
|
1087
|
+
parse_range_expression(value)
|
|
1088
|
+
end
|
|
1089
|
+
end
|
|
1090
|
+
|
|
1091
|
+
def parse_range_expression(value)
|
|
1092
|
+
text = value.to_s.strip
|
|
1093
|
+
from, to = text.split("..", 2)
|
|
1094
|
+
return nil if to.nil?
|
|
1095
|
+
|
|
1096
|
+
min = finite_float(from)
|
|
1097
|
+
max = finite_float(to)
|
|
1098
|
+
return nil if min.nil? || max.nil?
|
|
1099
|
+
|
|
1100
|
+
{
|
|
1101
|
+
min: min,
|
|
1102
|
+
max: max
|
|
1103
|
+
}
|
|
1104
|
+
end
|
|
1105
|
+
|
|
1106
|
+
def normalize_osc_range(value, input_min:, input_max:)
|
|
1107
|
+
return nil if input_min.nil? || input_max.nil?
|
|
1108
|
+
|
|
1109
|
+
min = finite_float(input_min)
|
|
1110
|
+
max = finite_float(input_max)
|
|
1111
|
+
return nil if min.nil? || max.nil? || min == max
|
|
1112
|
+
|
|
1113
|
+
((value - min) / (max - min)).clamp(0.0, 1.0)
|
|
1114
|
+
end
|
|
1115
|
+
|
|
644
1116
|
def osc_truthy?(value)
|
|
645
1117
|
return true if value.nil?
|
|
646
1118
|
return value if value == true || value == false
|
|
@@ -651,7 +1123,30 @@ module Vizcore
|
|
|
651
1123
|
%w[true on yes 1].include?(value.to_s.strip.downcase)
|
|
652
1124
|
end
|
|
653
1125
|
|
|
654
|
-
def
|
|
1126
|
+
def default_live_control_state(enabled: false, fade: nil, release: nil, color: nil)
|
|
1127
|
+
{
|
|
1128
|
+
"enabled" => !!enabled,
|
|
1129
|
+
"fade" => finite_float(fade),
|
|
1130
|
+
"release" => finite_float(release),
|
|
1131
|
+
"color" => normalize_control_color(color)
|
|
1132
|
+
}.compact
|
|
1133
|
+
end
|
|
1134
|
+
|
|
1135
|
+
def normalize_live_control_state(value)
|
|
1136
|
+
return default_live_control_state(enabled: !!value) unless value.is_a?(Hash)
|
|
1137
|
+
|
|
1138
|
+
values = value.each_with_object({}) do |(entry_key, entry_value), output|
|
|
1139
|
+
output[entry_key.to_s] = entry_value
|
|
1140
|
+
end
|
|
1141
|
+
default_live_control_state(
|
|
1142
|
+
enabled: values.fetch("value", values.fetch("enabled", false)),
|
|
1143
|
+
fade: values["fade"],
|
|
1144
|
+
release: values["release"],
|
|
1145
|
+
color: values["color"]
|
|
1146
|
+
)
|
|
1147
|
+
end
|
|
1148
|
+
|
|
1149
|
+
def switch_scene_from_client(target_name, broadcaster, source: "ui", effect: nil)
|
|
655
1150
|
requested = target_name.to_s.strip
|
|
656
1151
|
return if requested.empty?
|
|
657
1152
|
|
|
@@ -661,17 +1156,66 @@ module Vizcore
|
|
|
661
1156
|
current = broadcaster.current_scene_snapshot
|
|
662
1157
|
from_scene = current[:name]
|
|
663
1158
|
broadcaster.update_scene(scene_name: target_scene[:name], scene_layers: target_scene[:layers])
|
|
1159
|
+
resolved_effect = resolve_manual_scene_effect(effect)
|
|
664
1160
|
WebSocketHandler.broadcast(
|
|
665
1161
|
type: "scene_change",
|
|
666
1162
|
payload: {
|
|
667
1163
|
from: from_scene.to_s,
|
|
668
1164
|
to: target_scene[:name].to_s,
|
|
669
|
-
effect:
|
|
1165
|
+
effect: resolved_effect,
|
|
670
1166
|
source: source
|
|
671
1167
|
}
|
|
672
1168
|
)
|
|
673
1169
|
end
|
|
674
1170
|
|
|
1171
|
+
def resolve_manual_scene_effect(effect)
|
|
1172
|
+
normalized = normalize_transition_effect(effect)
|
|
1173
|
+
return normalized unless normalized.nil?
|
|
1174
|
+
return nil unless @config.respond_to?(:scene_switch_effect)
|
|
1175
|
+
|
|
1176
|
+
deep_dup(@config.scene_switch_effect)
|
|
1177
|
+
rescue StandardError
|
|
1178
|
+
nil
|
|
1179
|
+
end
|
|
1180
|
+
|
|
1181
|
+
def normalize_transition_effect(value)
|
|
1182
|
+
return nil unless value
|
|
1183
|
+
return nil if value.is_a?(Array)
|
|
1184
|
+
return value unless value.is_a?(Hash)
|
|
1185
|
+
|
|
1186
|
+
if value[:name] || value["name"]
|
|
1187
|
+
{
|
|
1188
|
+
name: value[:name] || value["name"],
|
|
1189
|
+
options: value[:options] || value["options"] || {}
|
|
1190
|
+
}
|
|
1191
|
+
else
|
|
1192
|
+
value
|
|
1193
|
+
end
|
|
1194
|
+
rescue StandardError
|
|
1195
|
+
nil
|
|
1196
|
+
end
|
|
1197
|
+
|
|
1198
|
+
def parse_osc_scene_effect(arguments)
|
|
1199
|
+
return nil if arguments.empty?
|
|
1200
|
+
|
|
1201
|
+
name = arguments[0]
|
|
1202
|
+
return nil unless name
|
|
1203
|
+
|
|
1204
|
+
effect_name = name.to_s.strip
|
|
1205
|
+
return nil if effect_name.empty?
|
|
1206
|
+
|
|
1207
|
+
duration = finite_float(arguments[1])
|
|
1208
|
+
return { name: effect_name.to_sym } if duration.nil?
|
|
1209
|
+
|
|
1210
|
+
{ name: effect_name.to_sym, options: { duration: duration } }
|
|
1211
|
+
rescue StandardError
|
|
1212
|
+
nil
|
|
1213
|
+
end
|
|
1214
|
+
|
|
1215
|
+
def deep_dup(value)
|
|
1216
|
+
Vizcore::DeepCopy.copy(value)
|
|
1217
|
+
end
|
|
1218
|
+
|
|
675
1219
|
def find_scene_catalog_scene(name)
|
|
676
1220
|
@scene_catalog_mutex.synchronize do
|
|
677
1221
|
Array(@scene_catalog).each do |scene|
|
|
@@ -688,8 +1232,32 @@ module Vizcore
|
|
|
688
1232
|
nil
|
|
689
1233
|
end
|
|
690
1234
|
|
|
1235
|
+
def adjacent_scene_for(current_name, offset:)
|
|
1236
|
+
@scene_catalog_mutex.synchronize do
|
|
1237
|
+
scenes = Array(@scene_catalog)
|
|
1238
|
+
return nil if scenes.empty?
|
|
1239
|
+
|
|
1240
|
+
current_index = scenes.index do |scene|
|
|
1241
|
+
raw_name = scene.dig(:name) || scene["name"]
|
|
1242
|
+
raw_name.to_s == current_name.to_s
|
|
1243
|
+
end
|
|
1244
|
+
return nil unless current_index
|
|
1245
|
+
|
|
1246
|
+
target = scenes[(current_index + Integer(offset)) % scenes.length]
|
|
1247
|
+
raw_name = target.dig(:name) || target["name"]
|
|
1248
|
+
layers = target.dig(:layers) || target["layers"]
|
|
1249
|
+
{ name: raw_name.to_sym, layers: Array(layers) }
|
|
1250
|
+
end
|
|
1251
|
+
rescue StandardError
|
|
1252
|
+
nil
|
|
1253
|
+
end
|
|
1254
|
+
|
|
691
1255
|
def wall_clock_ms
|
|
692
|
-
|
|
1256
|
+
wall_clock_seconds * 1000.0
|
|
1257
|
+
end
|
|
1258
|
+
|
|
1259
|
+
def wall_clock_seconds
|
|
1260
|
+
Time.now.to_f
|
|
693
1261
|
end
|
|
694
1262
|
|
|
695
1263
|
def finite_float(value)
|
|
@@ -700,6 +1268,53 @@ module Vizcore
|
|
|
700
1268
|
rescue StandardError
|
|
701
1269
|
nil
|
|
702
1270
|
end
|
|
1271
|
+
|
|
1272
|
+
def normalize_control_color(value)
|
|
1273
|
+
return nil if value.nil?
|
|
1274
|
+
|
|
1275
|
+
if value.is_a?(Array)
|
|
1276
|
+
return nil unless (3..4).cover?(value.length)
|
|
1277
|
+
|
|
1278
|
+
channels = Array(value).map { |entry| Float(entry, exception: false) }
|
|
1279
|
+
return nil if channels.include?(nil)
|
|
1280
|
+
|
|
1281
|
+
rgb = channels.take(3)
|
|
1282
|
+
alpha = channels[3]
|
|
1283
|
+
normalized_rgb = if rgb.all? { |channel| channel.between?(0.0, 1.0) }
|
|
1284
|
+
rgb
|
|
1285
|
+
else
|
|
1286
|
+
rgb.map { |channel| channel / 255.0 }
|
|
1287
|
+
end
|
|
1288
|
+
normalized = normalized_rgb.map { |channel| [0.0, [1.0, channel].min].max }
|
|
1289
|
+
return alpha.nil? ? normalized : normalized + [normalize_control_alpha(alpha)]
|
|
1290
|
+
end
|
|
1291
|
+
|
|
1292
|
+
raw = value.to_s.strip
|
|
1293
|
+
match = raw.match(/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/)
|
|
1294
|
+
return nil unless match
|
|
1295
|
+
|
|
1296
|
+
raw_hex = match[1]
|
|
1297
|
+
hex = raw_hex.length == 3 || raw_hex.length == 4 ? raw_hex.chars.map { |entry| "#{entry}#{entry}" }.join("") : raw_hex
|
|
1298
|
+
|
|
1299
|
+
[
|
|
1300
|
+
Integer("0x#{hex[0, 2]}", 16),
|
|
1301
|
+
Integer("0x#{hex[2, 2]}", 16),
|
|
1302
|
+
Integer("0x#{hex[4, 2]}", 16),
|
|
1303
|
+
Integer("0x#{hex[6, 2]}", 16)
|
|
1304
|
+
].take(raw_hex.length > 4 ? 4 : 3).map { |channel| [0.0, [1.0, channel / 255.0].min].max }
|
|
1305
|
+
rescue StandardError
|
|
1306
|
+
nil
|
|
1307
|
+
end
|
|
1308
|
+
|
|
1309
|
+
def normalize_control_alpha(value)
|
|
1310
|
+
return nil if value.nil?
|
|
1311
|
+
|
|
1312
|
+
alpha = Float(value, exception: false)
|
|
1313
|
+
return nil if alpha.nil?
|
|
1314
|
+
return [0.0, [1.0, alpha].min].max if alpha.between?(0.0, 1.0)
|
|
1315
|
+
|
|
1316
|
+
[0.0, [1.0, alpha / 255.0].min].max
|
|
1317
|
+
end
|
|
703
1318
|
end
|
|
704
1319
|
end
|
|
705
1320
|
end
|