vizcore 1.1.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/frontend/index.html +24 -2
- data/frontend/src/audio-inspector.js +9 -0
- data/frontend/src/live-controls.js +219 -7
- data/frontend/src/main.js +447 -57
- data/frontend/src/midi-learn.js +22 -2
- data/frontend/src/performance-monitor.js +137 -1
- data/frontend/src/renderer/engine.js +391 -10
- data/frontend/src/renderer/layer-manager.js +472 -71
- 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/visuals/image-renderer.js +19 -0
- data/frontend/src/visuals/particle-system.js +10 -0
- data/frontend/src/visuals/shape-renderer.js +13 -0
- data/frontend/src/visuals/spectrogram-renderer.js +14 -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 +64 -8
- 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 +487 -39
- 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 +278 -15
- 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 +404 -22
- 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 +1 -0
- 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 +179 -14
- data/lib/vizcore/renderer/scene_serializer.rb +38 -0
- data/lib/vizcore/renderer/snapshot.rb +4 -3
- data/lib/vizcore/renderer/snapshot_renderer.rb +134 -8
- data/lib/vizcore/scene_trust.rb +31 -0
- data/lib/vizcore/server/frame_broadcaster.rb +469 -23
- data/lib/vizcore/server/rack_app.rb +151 -4
- data/lib/vizcore/server/runner.rb +676 -82
- data/lib/vizcore/server/websocket_handler.rb +236 -14
- data/lib/vizcore/server.rb +21 -0
- data/lib/vizcore/shape.rb +39 -16
- data/lib/vizcore/sync/osc_message.rb +66 -9
- data/lib/vizcore/version.rb +1 -1
- data/lib/vizcore.rb +33 -0
- data/scripts/browser_capture.mjs +31 -2
- data/sig/vizcore.rbs +55 -4
- metadata +18 -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,11 +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)
|
|
401
697
|
when "custom_shape_param"
|
|
402
698
|
apply_custom_shape_param(payload, broadcaster)
|
|
699
|
+
when "client_runtime_error"
|
|
700
|
+
report_client_runtime_error(payload)
|
|
403
701
|
end
|
|
404
702
|
rescue StandardError => e
|
|
405
703
|
@output.puts(Vizcore::ErrorFormatting.summarize(e, context: "Client control message failed"))
|
|
@@ -420,24 +718,52 @@ module Vizcore
|
|
|
420
718
|
WebSocketHandler.send_to(socket, type: "latency_probe", payload: response)
|
|
421
719
|
end
|
|
422
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
|
+
|
|
423
754
|
def apply_midi_action(action, executor, broadcaster)
|
|
424
755
|
case action[:type]
|
|
425
756
|
when :switch_scene
|
|
426
757
|
target_scene = action[:scene]
|
|
427
758
|
return unless target_scene
|
|
428
759
|
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
broadcaster.
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
to: target_scene[:name].to_s,
|
|
437
|
-
effect: action[:effect],
|
|
438
|
-
source: "midi"
|
|
439
|
-
}
|
|
440
|
-
)
|
|
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
|
|
441
767
|
when :set_global
|
|
442
768
|
WebSocketHandler.broadcast(
|
|
443
769
|
type: "config_update",
|
|
@@ -445,9 +771,40 @@ module Vizcore
|
|
|
445
771
|
globals: executor.globals
|
|
446
772
|
}
|
|
447
773
|
)
|
|
774
|
+
when :live_control
|
|
775
|
+
apply_midi_live_control(action[:control], action)
|
|
448
776
|
end
|
|
449
777
|
end
|
|
450
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
|
+
|
|
451
808
|
def midi_runtime_settings(definition)
|
|
452
809
|
midi_inputs = Array(definition[:midi])
|
|
453
810
|
|
|
@@ -508,8 +865,11 @@ module Vizcore
|
|
|
508
865
|
[]
|
|
509
866
|
end
|
|
510
867
|
|
|
511
|
-
def resolve_shader_sources(definition)
|
|
512
|
-
@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
|
+
)
|
|
513
873
|
end
|
|
514
874
|
|
|
515
875
|
def replace_scene_catalog(scenes)
|
|
@@ -538,6 +898,12 @@ module Vizcore
|
|
|
538
898
|
nil
|
|
539
899
|
end
|
|
540
900
|
|
|
901
|
+
def analysis_setting(definition, key, fallback)
|
|
902
|
+
Hash(definition[:analysis] || {}).fetch(key, fallback)
|
|
903
|
+
rescue StandardError
|
|
904
|
+
fallback
|
|
905
|
+
end
|
|
906
|
+
|
|
541
907
|
def bpm_setting(definition)
|
|
542
908
|
@config.bpm || Hash(definition[:analysis] || {})[:bpm]
|
|
543
909
|
rescue StandardError
|
|
@@ -607,8 +973,8 @@ module Vizcore
|
|
|
607
973
|
)
|
|
608
974
|
end
|
|
609
975
|
|
|
610
|
-
def apply_osc_global(name,
|
|
611
|
-
globals = set_runtime_global(name,
|
|
976
|
+
def apply_osc_global(name, arguments)
|
|
977
|
+
globals = set_runtime_global(name, normalize_osc_argument(arguments))
|
|
612
978
|
WebSocketHandler.broadcast(
|
|
613
979
|
type: "config_update",
|
|
614
980
|
payload: {
|
|
@@ -618,6 +984,23 @@ module Vizcore
|
|
|
618
984
|
)
|
|
619
985
|
end
|
|
620
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
|
+
|
|
621
1004
|
def apply_custom_shape_param(payload, broadcaster)
|
|
622
1005
|
return unless broadcaster.respond_to?(:set_custom_shape_param)
|
|
623
1006
|
|
|
@@ -638,7 +1021,13 @@ module Vizcore
|
|
|
638
1021
|
end
|
|
639
1022
|
|
|
640
1023
|
def apply_osc_live_control(control, value)
|
|
641
|
-
|
|
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
|
+
)
|
|
642
1031
|
WebSocketHandler.broadcast(
|
|
643
1032
|
type: "config_update",
|
|
644
1033
|
payload: {
|
|
@@ -657,11 +1046,73 @@ module Vizcore
|
|
|
657
1046
|
)
|
|
658
1047
|
end
|
|
659
1048
|
|
|
660
|
-
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)
|
|
661
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
|
+
|
|
662
1060
|
numeric.nil? ? value : numeric
|
|
663
1061
|
end
|
|
664
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
|
+
|
|
665
1116
|
def osc_truthy?(value)
|
|
666
1117
|
return true if value.nil?
|
|
667
1118
|
return value if value == true || value == false
|
|
@@ -672,7 +1123,30 @@ module Vizcore
|
|
|
672
1123
|
%w[true on yes 1].include?(value.to_s.strip.downcase)
|
|
673
1124
|
end
|
|
674
1125
|
|
|
675
|
-
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)
|
|
676
1150
|
requested = target_name.to_s.strip
|
|
677
1151
|
return if requested.empty?
|
|
678
1152
|
|
|
@@ -682,17 +1156,66 @@ module Vizcore
|
|
|
682
1156
|
current = broadcaster.current_scene_snapshot
|
|
683
1157
|
from_scene = current[:name]
|
|
684
1158
|
broadcaster.update_scene(scene_name: target_scene[:name], scene_layers: target_scene[:layers])
|
|
1159
|
+
resolved_effect = resolve_manual_scene_effect(effect)
|
|
685
1160
|
WebSocketHandler.broadcast(
|
|
686
1161
|
type: "scene_change",
|
|
687
1162
|
payload: {
|
|
688
1163
|
from: from_scene.to_s,
|
|
689
1164
|
to: target_scene[:name].to_s,
|
|
690
|
-
effect:
|
|
1165
|
+
effect: resolved_effect,
|
|
691
1166
|
source: source
|
|
692
1167
|
}
|
|
693
1168
|
)
|
|
694
1169
|
end
|
|
695
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
|
+
|
|
696
1219
|
def find_scene_catalog_scene(name)
|
|
697
1220
|
@scene_catalog_mutex.synchronize do
|
|
698
1221
|
Array(@scene_catalog).each do |scene|
|
|
@@ -709,8 +1232,32 @@ module Vizcore
|
|
|
709
1232
|
nil
|
|
710
1233
|
end
|
|
711
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
|
+
|
|
712
1255
|
def wall_clock_ms
|
|
713
|
-
|
|
1256
|
+
wall_clock_seconds * 1000.0
|
|
1257
|
+
end
|
|
1258
|
+
|
|
1259
|
+
def wall_clock_seconds
|
|
1260
|
+
Time.now.to_f
|
|
714
1261
|
end
|
|
715
1262
|
|
|
716
1263
|
def finite_float(value)
|
|
@@ -721,6 +1268,53 @@ module Vizcore
|
|
|
721
1268
|
rescue StandardError
|
|
722
1269
|
nil
|
|
723
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
|
|
724
1318
|
end
|
|
725
1319
|
end
|
|
726
1320
|
end
|