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.
Files changed (78) hide show
  1. checksums.yaml +4 -4
  2. data/frontend/index.html +24 -2
  3. data/frontend/src/audio-inspector.js +9 -0
  4. data/frontend/src/live-controls.js +219 -7
  5. data/frontend/src/main.js +447 -57
  6. data/frontend/src/midi-learn.js +22 -2
  7. data/frontend/src/performance-monitor.js +137 -1
  8. data/frontend/src/renderer/engine.js +391 -10
  9. data/frontend/src/renderer/layer-manager.js +472 -71
  10. data/frontend/src/runtime-control-preset.js +44 -0
  11. data/frontend/src/scene-patches.js +159 -0
  12. data/frontend/src/shader-error-overlay.js +1 -0
  13. data/frontend/src/visuals/image-renderer.js +19 -0
  14. data/frontend/src/visuals/particle-system.js +10 -0
  15. data/frontend/src/visuals/shape-renderer.js +13 -0
  16. data/frontend/src/visuals/spectrogram-renderer.js +14 -0
  17. data/frontend/src/visuals/text-renderer.js +13 -0
  18. data/frontend/src/websocket-client.js +6 -0
  19. data/lib/vizcore/analysis/adaptive_normalizer.rb +20 -2
  20. data/lib/vizcore/analysis/bpm_estimator.rb +18 -8
  21. data/lib/vizcore/analysis/feature_recorder.rb +117 -7
  22. data/lib/vizcore/analysis/feature_replay.rb +48 -9
  23. data/lib/vizcore/analysis/pipeline.rb +258 -9
  24. data/lib/vizcore/analysis/tap_tempo.rb +17 -2
  25. data/lib/vizcore/audio/calibration.rb +156 -0
  26. data/lib/vizcore/audio/file_input.rb +28 -0
  27. data/lib/vizcore/audio/input_manager.rb +36 -1
  28. data/lib/vizcore/audio/midi_input.rb +5 -0
  29. data/lib/vizcore/audio/ring_buffer.rb +22 -0
  30. data/lib/vizcore/audio.rb +1 -0
  31. data/lib/vizcore/cli/dsl_reference.rb +64 -8
  32. data/lib/vizcore/cli/plugin_checker.rb +93 -0
  33. data/lib/vizcore/cli/scene_diagnostics.rb +2 -2
  34. data/lib/vizcore/cli/scene_inspector.rb +35 -1
  35. data/lib/vizcore/cli/scene_validator.rb +487 -39
  36. data/lib/vizcore/cli/shader_template.rb +7 -2
  37. data/lib/vizcore/cli/shader_uniform_docs.rb +11 -0
  38. data/lib/vizcore/cli.rb +268 -15
  39. data/lib/vizcore/config.rb +40 -3
  40. data/lib/vizcore/control_preset.rb +29 -0
  41. data/lib/vizcore/deep_copy.rb +21 -0
  42. data/lib/vizcore/dsl/color_helpers.rb +155 -0
  43. data/lib/vizcore/dsl/engine.rb +219 -23
  44. data/lib/vizcore/dsl/layer_builder.rb +278 -15
  45. data/lib/vizcore/dsl/layer_group_builder.rb +10 -12
  46. data/lib/vizcore/dsl/layout_helpers.rb +290 -0
  47. data/lib/vizcore/dsl/mapping_preset_builder.rb +41 -0
  48. data/lib/vizcore/dsl/mapping_resolver.rb +404 -22
  49. data/lib/vizcore/dsl/mapping_transform_builder.rb +50 -0
  50. data/lib/vizcore/dsl/midi_map_executor.rb +219 -23
  51. data/lib/vizcore/dsl/reaction_builder.rb +1 -0
  52. data/lib/vizcore/dsl/scene_builder.rb +83 -13
  53. data/lib/vizcore/dsl/shader_source_resolver.rb +1 -10
  54. data/lib/vizcore/dsl/style_builder.rb +3 -0
  55. data/lib/vizcore/dsl/timeline_builder.rb +91 -8
  56. data/lib/vizcore/dsl/transition_controller.rb +157 -18
  57. data/lib/vizcore/dsl.rb +2 -0
  58. data/lib/vizcore/layer_catalog.rb +1 -0
  59. data/lib/vizcore/plugin_asset_policy.rb +55 -0
  60. data/lib/vizcore/project_manifest.rb +12 -2
  61. data/lib/vizcore/renderer/render_sequence.rb +104 -13
  62. data/lib/vizcore/renderer/scene_frame_source.rb +179 -14
  63. data/lib/vizcore/renderer/scene_serializer.rb +38 -0
  64. data/lib/vizcore/renderer/snapshot.rb +4 -3
  65. data/lib/vizcore/renderer/snapshot_renderer.rb +134 -8
  66. data/lib/vizcore/scene_trust.rb +31 -0
  67. data/lib/vizcore/server/frame_broadcaster.rb +469 -23
  68. data/lib/vizcore/server/rack_app.rb +151 -4
  69. data/lib/vizcore/server/runner.rb +676 -82
  70. data/lib/vizcore/server/websocket_handler.rb +236 -14
  71. data/lib/vizcore/server.rb +21 -0
  72. data/lib/vizcore/shape.rb +39 -16
  73. data/lib/vizcore/sync/osc_message.rb +66 -9
  74. data/lib/vizcore/version.rb +1 -1
  75. data/lib/vizcore.rb +33 -0
  76. data/scripts/browser_capture.mjs +31 -2
  77. data/sig/vizcore.rbs +55 -4
  78. metadata +18 -3
@@ -8,21 +8,40 @@ module Vizcore
8
8
  module Renderer
9
9
  # Produces analyzed scene frames for offline renderers.
10
10
  class SceneFrameSource
11
- def initialize(config:, frame_rate: nil)
11
+ def initialize(config:, frame_rate: nil, seed: nil)
12
12
  @config = config
13
13
  @frame_rate = frame_rate
14
+ @seed = seed
14
15
  @shader_source_resolver = Vizcore::DSL::ShaderSourceResolver.new
15
16
  end
16
17
 
17
18
  # @return [Vizcore::Renderer::SceneFrameSource]
18
19
  def start
19
20
  @definition = resolve_shader_sources(Vizcore::DSL::Engine.load_file(@config.scene_file.to_s))
20
- @scene = first_scene(@definition)
21
- @input_manager = build_input_manager
22
- @input_manager.start
23
- @capture_size = capture_size
24
- @pipeline = build_pipeline
21
+ apply_seed!
22
+ @source_started_at = monotonic_seconds
23
+ scenes = Array(@definition[:scenes])
24
+ initial_timeline_entry = initial_timeline_entry(@definition)
25
+ @scene = resolve_initial_scene(scenes, initial_timeline_entry)
26
+ @transition_controller = Vizcore::DSL::TransitionController.new(
27
+ scenes: scenes,
28
+ transitions: Array(@definition[:transitions]),
29
+ error_reporter: lambda do |message|
30
+ report_transition_error(message)
31
+ end
32
+ )
33
+ @mapping_resolver = Vizcore::DSL::MappingResolver.new
34
+ unless feature_replay?
35
+ @input_manager = build_input_manager
36
+ @input_manager.start
37
+ @capture_size = capture_size
38
+ end
39
+ @pipeline = replay_pipeline || build_pipeline
25
40
  @frame_count = 0
41
+ @scene_frame_base = 0
42
+ @scene_elapsed_base = 0.0
43
+ @scene_beat_base = 0
44
+ align_timeline_start(entry: initial_timeline_entry)
26
45
  self
27
46
  end
28
47
 
@@ -30,19 +49,29 @@ module Vizcore
30
49
  def capture
31
50
  ensure_started!
32
51
 
33
- audio = @pipeline.call(@input_manager.capture_frame(@capture_size))
52
+ audio = if feature_replay?
53
+ @pipeline.call
54
+ else
55
+ @pipeline.call(@input_manager.capture_frame(@capture_size))
56
+ end
34
57
  @frame_count += 1
35
- layers = Vizcore::DSL::MappingResolver.new.resolve_layers(
36
- scene_layers: @scene[:layers],
58
+ scene = @scene
59
+ layers = @mapping_resolver.resolve_layers(
60
+ scene_layers: scene[:layers],
37
61
  audio: audio,
38
62
  time: frame_time,
39
63
  frame: @frame_count
40
64
  )
65
+ evaluate_transition(audio)
41
66
 
42
67
  {
43
- scene: { name: @scene[:name], layers: layers },
68
+ scene: {
69
+ schema_version: Vizcore::Renderer::SceneSerializer::SCENE_SCHEMA_VERSION,
70
+ name: scene[:name],
71
+ layers: layers
72
+ },
44
73
  audio: audio,
45
- scene_name: @scene[:name].to_s
74
+ scene_name: scene[:name].to_s
46
75
  }
47
76
  end
48
77
 
@@ -57,6 +86,15 @@ module Vizcore
57
86
  @shader_source_resolver.resolve(definition: definition, scene_file: @config.scene_file.to_s)
58
87
  end
59
88
 
89
+ def apply_seed!
90
+ seed = @seed || @definition[:seed]
91
+ return if seed.nil?
92
+
93
+ Kernel.srand(Integer(seed))
94
+ rescue ArgumentError, TypeError
95
+ raise ArgumentError, "render seed must be an integer"
96
+ end
97
+
60
98
  def first_scene(definition)
61
99
  scene = Array(definition[:scenes]).first
62
100
  return scene if scene
@@ -64,6 +102,95 @@ module Vizcore
64
102
  { name: @config.scene_file.basename(".rb").to_sym, layers: [] }
65
103
  end
66
104
 
105
+ def initial_timeline_entry(definition)
106
+ timelines = Array(definition[:timelines])
107
+ timelines.each do |timeline|
108
+ first_entry = Array(timeline).first
109
+ return first_entry if first_entry
110
+ end
111
+
112
+ nil
113
+ end
114
+
115
+ def resolve_initial_scene(scenes, initial_timeline_entry)
116
+ scene_name = initial_timeline_entry&.dig(:scene)
117
+ scene = scenes.find { |entry| entry[:name].to_s == scene_name.to_s } if scene_name
118
+ scene || first_scene({ scenes: scenes })
119
+ end
120
+
121
+ def align_timeline_start(entry:)
122
+ return unless entry
123
+
124
+ unit = entry[:unit].to_s
125
+ start_position = Float(entry[:at] || 0.0)
126
+ return unless start_position.positive?
127
+
128
+ @scene_elapsed_base = start_position if unit == "seconds"
129
+ @scene_beat_base = Integer(start_position) if unit == "beats"
130
+ rescue StandardError
131
+ nil
132
+ end
133
+
134
+ def evaluate_transition(audio)
135
+ transition = @transition_controller.next_transition(
136
+ scene_name: @scene[:name],
137
+ audio: transition_audio(audio),
138
+ frame_count: scene_frame_count,
139
+ elapsed_seconds: scene_elapsed_seconds
140
+ )
141
+ return unless transition
142
+
143
+ @scene = transition.fetch(:scene)
144
+ reset_scene_counters(audio)
145
+ end
146
+
147
+ def transition_audio(audio)
148
+ audio_hash = Hash(audio)
149
+ scene_count = scene_beat_count(audio_hash)
150
+ audio_hash.merge(scene_musical_counts(audio_hash, beat_count: scene_count))
151
+ rescue StandardError
152
+ { beat_count: 0 }
153
+ end
154
+
155
+ def scene_frame_count
156
+ [@frame_count - @scene_frame_base, 0].max
157
+ end
158
+
159
+ def scene_elapsed_seconds
160
+ [frame_time - @scene_elapsed_base, 0.0].max
161
+ end
162
+
163
+ def scene_beat_count(audio)
164
+ global_beat_count = Integer(Hash(audio)[:beat_count] || 0)
165
+ [global_beat_count - @scene_beat_base, 0].max
166
+ rescue StandardError
167
+ 0
168
+ end
169
+
170
+ def reset_scene_counters(audio)
171
+ audio_hash = Hash(audio)
172
+ @scene_frame_base = @frame_count
173
+ @scene_elapsed_base = frame_time
174
+ @scene_beat_base = Integer(audio_hash[:beat_count] || 0) - (audio_hash[:beat] ? 1 : 0)
175
+ rescue StandardError
176
+ @scene_frame_base = @frame_count
177
+ @scene_elapsed_base = frame_time
178
+ @scene_beat_base = 0
179
+ end
180
+
181
+ def scene_musical_counts(audio, beat_count:)
182
+ beat_index = beat_count.positive? ? beat_count - 1 : 0
183
+ beat_phase = Float(audio[:beat_phase] || audio["beat_phase"] || 0.0).clamp(0.0, 1.0)
184
+ {
185
+ beat_count: beat_count,
186
+ bar_phase: (((beat_index % 4) + beat_phase) / 4.0).clamp(0.0, 1.0),
187
+ bar_count: beat_index / 4,
188
+ phrase_count: beat_index / 32
189
+ }
190
+ rescue StandardError
191
+ { beat_count: beat_count, bar_phase: 0.0, bar_count: 0, phrase_count: 0 }
192
+ end
193
+
67
194
  def build_input_manager
68
195
  Vizcore::Audio::InputManager.new(
69
196
  source: @config.audio_source,
@@ -72,12 +199,26 @@ module Vizcore
72
199
  )
73
200
  end
74
201
 
202
+ def replay_pipeline
203
+ return unless feature_replay?
204
+
205
+ Vizcore::Analysis::FeatureReplay.new(path: @config.feature_file)
206
+ end
207
+
208
+ def feature_replay?
209
+ !!@config.feature_file
210
+ end
211
+
75
212
  def capture_size
76
213
  return @input_manager.frame_size unless @frame_rate
77
214
 
78
215
  @input_manager.realtime_capture_size(@frame_rate)
79
216
  end
80
217
 
218
+ def report_transition_error(message)
219
+ warn(message)
220
+ end
221
+
81
222
  def build_pipeline
82
223
  Vizcore::Analysis::Pipeline.new(
83
224
  sample_rate: @input_manager.sample_rate,
@@ -85,22 +226,46 @@ module Vizcore
85
226
  noise_gate: @config.noise_gate,
86
227
  audio_normalize: audio_normalize_settings,
87
228
  bpm: bpm_setting,
88
- bpm_lock: bpm_lock_setting
229
+ bpm_lock: bpm_lock_setting,
230
+ onset_sensitivity: analysis_setting(:onset_sensitivity, 1.0),
231
+ fft_preview_bins: analysis_setting(:fft_bins, Vizcore::Analysis::Pipeline::DEFAULT_FFT_PREVIEW_BINS),
232
+ peak_hold_frames: analysis_setting(:peak_hold_frames, 0),
233
+ silence_reset_frames: analysis_setting(:silence_reset_frames, Vizcore::Analysis::Pipeline::SILENCE_RESET_FRAMES)
89
234
  )
90
235
  end
91
236
 
92
237
  def frame_time
93
- return 0.0 unless @frame_rate
238
+ return monotonic_elapsed unless @frame_rate
94
239
 
95
240
  (@frame_count - 1).fdiv(@frame_rate)
96
241
  end
97
242
 
243
+ def monotonic_elapsed
244
+ now = monotonic_seconds
245
+ return 0.0 unless @source_started_at
246
+
247
+ elapsed = now - @source_started_at
248
+ elapsed.positive? ? elapsed : 0.0
249
+ rescue StandardError
250
+ 0.0
251
+ end
252
+
253
+ def monotonic_seconds
254
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
255
+ end
256
+
98
257
  def audio_normalize_settings
99
258
  Hash(@definition[:analysis] || {})[:audio_normalize]
100
259
  rescue StandardError
101
260
  nil
102
261
  end
103
262
 
263
+ def analysis_setting(key, fallback)
264
+ Hash(@definition[:analysis] || {}).fetch(key, fallback)
265
+ rescue StandardError
266
+ fallback
267
+ end
268
+
104
269
  def bpm_setting
105
270
  @config.bpm || Hash(@definition[:analysis] || {})[:bpm]
106
271
  rescue StandardError
@@ -123,7 +288,7 @@ module Vizcore
123
288
  end
124
289
 
125
290
  def ensure_started!
126
- return if @input_manager && @pipeline && @scene
291
+ return if @pipeline && @scene && (!feature_replay? || @input_manager.nil? || @input_manager.running?)
127
292
 
128
293
  raise RuntimeError, "scene frame source has not been started"
129
294
  end
@@ -4,6 +4,11 @@ module Vizcore
4
4
  module Renderer
5
5
  # Serializes analysis and scene state into transport payloads.
6
6
  class SceneSerializer
7
+ SCENE_SCHEMA_VERSION = "vizcore.scene.v1"
8
+ FRAME_SCHEMA_VERSION = "vizcore.frame.v1"
9
+ LAYER_SCHEMA_VERSION = "vizcore.layer.v1"
10
+ MAPPING_SCHEMA_VERSION = "vizcore.mapping.v1"
11
+
7
12
  # @param timestamp [Numeric]
8
13
  # @param audio [Hash]
9
14
  # @param scene_name [String, Symbol]
@@ -13,6 +18,7 @@ module Vizcore
13
18
  # @return [Hash]
14
19
  def audio_frame(timestamp:, audio:, scene_name:, scene_layers:, transition: nil, metrics: nil)
15
20
  frame = {
21
+ schema_version: FRAME_SCHEMA_VERSION,
16
22
  timestamp: Float(timestamp),
17
23
  audio: serialize_audio(audio),
18
24
  scene: serialize_scene(scene_name, scene_layers),
@@ -26,12 +32,15 @@ module Vizcore
26
32
 
27
33
  def serialize_audio(audio)
28
34
  bands = symbolize_hash(audio[:bands])
35
+ band_peaks = { sub: 0.0, low: 0.0, mid: 0.0, high: 0.0 }.merge(symbolize_hash(audio[:band_peaks]))
29
36
  onsets = { sub: 0.0, low: 0.0, mid: 0.0, high: 0.0 }.merge(symbolize_hash(audio[:onsets]))
30
37
  drums = { kick: 0.0, snare: 0.0, hihat: 0.0 }.merge(symbolize_hash(audio[:drums]))
31
38
 
32
39
  {
33
40
  amplitude: round_float(audio[:amplitude]),
41
+ peak: round_float(audio[:peak]),
34
42
  bands: bands.transform_values { |value| round_float(value) },
43
+ band_peaks: band_peaks.transform_values { |value| round_float(value) },
35
44
  fft: Array(audio[:fft]).map { |value| round_float(value) },
36
45
  onset: round_float(audio[:onset]),
37
46
  onsets: onsets.transform_values { |value| round_float(value) },
@@ -40,13 +49,28 @@ module Vizcore
40
49
  beat_confidence: round_float(audio[:beat_confidence]),
41
50
  beat_pulse: round_float(audio[:beat_pulse]),
42
51
  beat_count: Integer(audio[:beat_count] || 0),
52
+ beat_phase: round_float(audio[:beat_phase]),
53
+ beat_2: !!audio[:beat_2],
54
+ beat_4: !!audio[:beat_4],
55
+ beat_8: !!audio[:beat_8],
56
+ beat_triplet: !!audio[:beat_triplet],
57
+ bar_phase: round_float(audio[:bar_phase]),
58
+ bar_count: Integer(audio[:bar_count] || 0),
59
+ phrase_count: Integer(audio[:phrase_count] || 0),
43
60
  bpm: audio[:bpm],
61
+ bpm_confidence: round_float(audio[:bpm_confidence]),
62
+ spectral_centroid: round_float(audio[:spectral_centroid]),
63
+ spectral_rolloff: round_float(audio[:spectral_rolloff]),
64
+ spectral_flatness: round_float(audio[:spectral_flatness]),
65
+ spectral_flux: round_float(audio[:spectral_flux]),
66
+ zero_crossing_rate: round_float(audio[:zero_crossing_rate]),
44
67
  peak_frequency: round_float(audio[:peak_frequency])
45
68
  }
46
69
  end
47
70
 
48
71
  def serialize_scene(scene_name, scene_layers)
49
72
  {
73
+ schema_version: SCENE_SCHEMA_VERSION,
50
74
  name: scene_name.to_s,
51
75
  layers: Array(scene_layers).map { |layer| serialize_layer(layer) }
52
76
  }
@@ -58,12 +82,14 @@ module Vizcore
58
82
  output = {
59
83
  name: values.fetch(:name).to_s,
60
84
  type: (values[:type] || :geometry).to_s,
85
+ schema_version: LAYER_SCHEMA_VERSION,
61
86
  params: symbolize_hash(values[:params])
62
87
  }
63
88
  output[:shader] = values[:shader].to_s if values[:shader]
64
89
  output[:glsl] = values[:glsl].to_s if values[:glsl]
65
90
  output[:glsl_source] = values[:glsl_source].to_s if values[:glsl_source]
66
91
  output[:param_schema] = serialize_param_schema(values[:param_schema]) if values[:param_schema]
92
+ output[:mappings] = serialize_mappings(values[:mappings]) unless Array(values[:mappings]).empty?
67
93
  output
68
94
  end
69
95
 
@@ -80,6 +106,18 @@ module Vizcore
80
106
  end
81
107
  end
82
108
 
109
+ def serialize_mappings(mappings)
110
+ Array(mappings).map do |mapping|
111
+ values = symbolize_hash(mapping)
112
+ {
113
+ schema_version: MAPPING_SCHEMA_VERSION,
114
+ source: values[:source],
115
+ target: values[:target].to_s,
116
+ transform: symbolize_hash(values[:transform])
117
+ }
118
+ end
119
+ end
120
+
83
121
  def serialize_metrics(metrics)
84
122
  symbolize_hash(metrics).each_with_object({}) do |(key, value), output|
85
123
  output[key] = key == :frame_id ? Integer(value) : round_float(value)
@@ -9,10 +9,11 @@ module Vizcore
9
9
  module Renderer
10
10
  # Builds one analyzed scene frame and writes a PNG preview.
11
11
  class Snapshot
12
- def initialize(config:, width: SnapshotRenderer::DEFAULT_WIDTH, height: SnapshotRenderer::DEFAULT_HEIGHT)
12
+ def initialize(config:, width: SnapshotRenderer::DEFAULT_WIDTH, height: SnapshotRenderer::DEFAULT_HEIGHT, transparent: false)
13
13
  @config = config
14
14
  @width = width
15
15
  @height = height
16
+ @transparent = !!transparent
16
17
  end
17
18
 
18
19
  # @param out [String, Pathname]
@@ -22,14 +23,14 @@ module Vizcore
22
23
  frame_source = SceneFrameSource.new(config: @config)
23
24
  frame_source.start
24
25
  frame = frame_source.capture
25
- png = SnapshotRenderer.new(width: @width, height: @height).render(
26
+ png = SnapshotRenderer.new(width: @width, height: @height, transparent: @transparent).render(
26
27
  scene: frame.fetch(:scene),
27
28
  audio: frame.fetch(:audio)
28
29
  )
29
30
 
30
31
  FileUtils.mkdir_p(output_path.dirname)
31
32
  File.binwrite(output_path, png)
32
- { path: output_path, scene: frame.fetch(:scene_name), width: @width, height: @height }
33
+ { path: output_path, scene: frame.fetch(:scene_name), width: @width, height: @height, transparent: @transparent }
33
34
  ensure
34
35
  frame_source&.stop
35
36
  end
@@ -19,9 +19,10 @@ module Vizcore
19
19
  [250, 204, 21]
20
20
  ].freeze
21
21
 
22
- def initialize(width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT)
22
+ def initialize(width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT, transparent: false)
23
23
  @width = normalize_dimension(width)
24
24
  @height = normalize_dimension(height)
25
+ @transparent = !!transparent
25
26
  end
26
27
 
27
28
  attr_reader :width, :height
@@ -30,8 +31,8 @@ module Vizcore
30
31
  # @param audio [Hash]
31
32
  # @return [String] PNG bytes
32
33
  def render(scene:, audio:)
33
- canvas = Canvas.new(width: width, height: height)
34
- canvas.fill_gradient(background_top(audio), background_bottom(audio))
34
+ canvas = Canvas.new(width: width, height: height, transparent: @transparent)
35
+ canvas.fill_gradient(background_top(audio), background_bottom(audio)) unless @transparent
35
36
  layers = Array(scene[:layers] || scene["layers"])
36
37
  layers = [default_layer] if layers.empty?
37
38
  layers.each_with_index { |layer, index| render_layer(canvas, layer, audio, index) }
@@ -759,14 +760,115 @@ module Vizcore
759
760
  end
760
761
 
761
762
  def configured_color(params)
762
- [params[:color], params["color"]].map { |value| value.to_s.strip }.find { |value| !value.empty? }
763
+ value = params[:color]
764
+ value = params["color"] unless value
765
+ resolved = resolve_color_value(value)
766
+ return resolved.to_s.strip unless resolved.to_s.strip.empty?
767
+
768
+ nil
769
+ end
770
+
771
+ def resolve_color_value(value)
772
+ return value if value.is_a?(String)
773
+ return resolve_gradient_color(value) if value.is_a?(Hash)
774
+
775
+ value
776
+ end
777
+
778
+ def resolve_gradient_color(value)
779
+ gradient = value[:gradient] || value["gradient"]
780
+ return nil unless gradient.is_a?(Hash)
781
+
782
+ colors = normalize_colors(gradient[:colors] || gradient["colors"])
783
+ return nil if colors.empty?
784
+
785
+ return colors[0] if colors.length == 1
786
+
787
+ position = normalize_position(gradient[:position] || gradient["position"])
788
+ stops = normalize_gradient_stops(gradient[:stops] || gradient["stops"], colors.length)
789
+
790
+ if stops
791
+ resolve_gradient_color_with_stops(colors, position, stops)
792
+ else
793
+ resolve_gradient_color_with_position(colors, position)
794
+ end
795
+ end
796
+
797
+ def normalize_gradient_stops(stops, color_count)
798
+ return nil unless stops
799
+
800
+ values = Array(stops).filter_map { |entry| Float(entry, exception: false) }
801
+ return nil if values.length != color_count
802
+
803
+ values.sort.map { |value| value.to_f.clamp(0.0, 1.0) }
804
+ end
805
+
806
+ def resolve_gradient_color_with_position(colors, position)
807
+ segment_length = 1.0 / (colors.length - 1)
808
+ segment = [(position / segment_length).floor, colors.length - 2].min
809
+ blend = (position % segment_length) / segment_length
810
+
811
+ left_color = parse_hex_color(colors[segment])
812
+ right_color = parse_hex_color(colors[segment + 1])
813
+ return colors[segment] if left_color.nil? || right_color.nil?
814
+
815
+ interpolate_hex_color(left_color, right_color, blend)
816
+ end
817
+
818
+ def resolve_gradient_color_with_stops(colors, position, stops)
819
+ index = Array.new(colors.length - 1) { |offset| offset }.index do |offset|
820
+ position <= stops[offset + 1]
821
+ end
822
+
823
+ return colors.last if index.nil?
824
+
825
+ return colors[0] if index == 0 && position <= stops[0]
826
+
827
+ left_index = [index, colors.length - 2].min
828
+ right_index = left_index + 1
829
+ start = stops[left_index]
830
+ stop = stops[right_index]
831
+ blend = ((position - start) / (stop - start)).clamp(0.0, 1.0)
832
+
833
+ left_color = parse_hex_color(colors[left_index])
834
+ right_color = parse_hex_color(colors[right_index])
835
+ return colors[left_index] if left_color.nil? || right_color.nil?
836
+
837
+ interpolate_hex_color(left_color, right_color, blend)
838
+ end
839
+
840
+ def normalize_colors(value)
841
+ Array(value).map { |entry| entry.to_s.strip }.reject(&:empty?)
842
+ end
843
+
844
+ def normalize_position(value)
845
+ position = Float(value)
846
+ position = 0.0 unless position.finite?
847
+
848
+ position % 1.0
849
+ rescue ArgumentError, TypeError
850
+ 0.0
763
851
  end
764
852
 
765
853
  def palette_color(params, index)
766
854
  palette = Array(params[:palette] || params["palette"]).map { |color| color.to_s.strip }.reject(&:empty?)
767
855
  return nil if palette.empty?
768
856
 
769
- palette[index % palette.length]
857
+ position = normalize_palette_position(index, palette.length)
858
+ return palette[0] if position.nil?
859
+
860
+ lower_index = position.floor
861
+ upper_index = (lower_index + 1) % palette.length
862
+ blend = position - lower_index
863
+
864
+ base_color = palette[lower_index]
865
+ return base_color unless blend.positive? && blend < 1.0
866
+
867
+ lower_rgb = parse_hex_color(base_color)
868
+ upper_rgb = parse_hex_color(palette[upper_index])
869
+ return base_color if lower_rgb.nil? || upper_rgb.nil?
870
+
871
+ interpolate_hex_color(lower_rgb, upper_rgb, blend)
770
872
  end
771
873
 
772
874
  def parse_hex_color(value)
@@ -778,6 +880,28 @@ module Vizcore
778
880
  [hex[0, 2], hex[2, 2], hex[4, 2]].map { |component| component.to_i(16) }
779
881
  end
780
882
 
883
+ def normalize_palette_position(value, palette_length)
884
+ return nil unless palette_length.positive?
885
+
886
+ numeric = Float(value)
887
+ return nil unless numeric.finite?
888
+
889
+ position = numeric % palette_length
890
+ return nil if position.nan?
891
+
892
+ position
893
+ rescue StandardError
894
+ nil
895
+ end
896
+
897
+ def interpolate_hex_color(left_rgb, right_rgb, blend)
898
+ blend = blend.to_f.clamp(0.0, 1.0)
899
+ rgb = left_rgb.zip(right_rgb).map do |left, right|
900
+ (left + (right - left) * blend).round.clamp(0, 255)
901
+ end
902
+ format("##{rgb.map { |value| format('%02x', value) }.join}")
903
+ end
904
+
781
905
  def default_layer
782
906
  { type: "geometry", name: "snapshot" }
783
907
  end
@@ -808,11 +932,12 @@ module Vizcore
808
932
 
809
933
  # Tiny RGBA canvas with alpha blending and a few primitive drawing helpers.
810
934
  class Canvas
811
- def initialize(width:, height:)
935
+ def initialize(width:, height:, transparent: false)
812
936
  @width = width
813
937
  @height = height
814
938
  @bytes = String.new(capacity: width * height * 4, encoding: Encoding::BINARY)
815
- @bytes << ([0, 0, 0, 255].pack("C4") * (width * height))
939
+ alpha = transparent ? 0 : 255
940
+ @bytes << ([0, 0, 0, alpha].pack("C4") * (width * height))
816
941
  end
817
942
 
818
943
  attr_reader :width, :height, :bytes
@@ -918,7 +1043,8 @@ module Vizcore
918
1043
  current = bytes.getbyte(offset + index)
919
1044
  bytes.setbyte(offset + index, interpolate(current, color[index], amount).round)
920
1045
  end
921
- bytes.setbyte(offset + 3, 255)
1046
+ alpha = bytes.getbyte(offset + 3)
1047
+ bytes.setbyte(offset + 3, [alpha + (255 - alpha) * amount, 255].min.round)
922
1048
  end
923
1049
 
924
1050
  def set_pixel(x, y, color, alpha)
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ module Vizcore
6
+ # Builds safety warnings for Ruby scene files, which execute as normal Ruby.
7
+ module SceneTrust
8
+ module_function
9
+
10
+ # @param scene_file [String, Pathname, nil]
11
+ # @param project_root [String, Pathname]
12
+ # @return [String, nil]
13
+ def warning_for(scene_file, project_root: Dir.pwd)
14
+ return nil if scene_file.to_s.strip.empty?
15
+
16
+ path = Pathname.new(scene_file).expand_path
17
+ root = Pathname.new(project_root).expand_path
18
+ return nil if under?(path, root)
19
+ return nil if under?(path, Vizcore.root)
20
+
21
+ "Scene files execute Ruby code. Review #{path} before running it, or pass --trust to suppress this warning."
22
+ end
23
+
24
+ def under?(path, root)
25
+ relative = path.relative_path_from(root)
26
+ !relative.each_filename.first&.start_with?("..")
27
+ rescue ArgumentError
28
+ false
29
+ end
30
+ end
31
+ end