vizcore 0.1.0 → 1.0.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 +544 -9
- data/docs/.nojekyll +0 -0
- data/docs/assets/site.css +744 -0
- data/docs/assets/vizcore-demo.gif +0 -0
- data/docs/assets/vizcore-poster.png +0 -0
- data/docs/assets/vj-tunnel.js +159 -0
- data/docs/index.html +224 -0
- data/examples/README.md +59 -0
- data/examples/assets/README.md +19 -0
- data/examples/audio_inspector.rb +34 -0
- data/examples/club_intro_drop.rb +78 -0
- data/examples/kansai_rubykaigi_visual.rb +70 -0
- data/examples/live_coding_minimal.rb +22 -0
- data/examples/midi_controller_show.rb +78 -0
- data/examples/midi_scene_switch.rb +3 -1
- data/examples/parser_visualizer.rb +48 -0
- data/examples/readme_demo.rb +17 -0
- data/examples/rhythm_geometry.rb +34 -0
- data/examples/ruby_crystal_show.rb +35 -0
- data/examples/shader_playground.rb +18 -0
- data/examples/unyo_liquid.rb +59 -0
- data/examples/vj_ambient_chill_room.rb +124 -0
- data/examples/vj_dnb_jungle.rb +170 -0
- data/examples/vj_festival_mainstage.rb +245 -0
- data/examples/vj_festival_mainstage.yml +17 -0
- data/examples/vj_glitch_industrial.rb +164 -0
- data/examples/vj_hiphop_cipher.rb +167 -0
- data/examples/vj_jpop_idol_live.rb +210 -0
- data/examples/vj_synthwave_retro.rb +173 -0
- data/examples/vj_techno_warehouse.rb +195 -0
- data/frontend/index.html +468 -2
- data/frontend/src/audio-inspector.js +40 -0
- data/frontend/src/live-controls.js +131 -0
- data/frontend/src/main.js +792 -16
- data/frontend/src/midi-learn.js +194 -0
- data/frontend/src/performance-monitor.js +183 -0
- data/frontend/src/plugin-runtime.js +130 -0
- data/frontend/src/projector-mode.js +56 -0
- data/frontend/src/renderer/engine.js +148 -3
- data/frontend/src/renderer/layer-manager.js +428 -30
- data/frontend/src/renderer/shader-manager.js +26 -0
- data/frontend/src/runtime-control-preset.js +11 -0
- data/frontend/src/shader-error-overlay.js +29 -0
- data/frontend/src/shader-param-controls.js +93 -0
- data/frontend/src/shaders/builtins.js +380 -2
- data/frontend/src/shaders/post-effects.js +52 -0
- data/frontend/src/visual-regression.js +67 -0
- data/frontend/src/visual-settings-preset.js +103 -0
- data/frontend/src/visuals/geometry.js +268 -0
- data/frontend/src/visuals/image-renderer.js +291 -0
- data/frontend/src/visuals/particle-system.js +56 -10
- data/frontend/src/visuals/spectrogram-renderer.js +226 -0
- data/frontend/src/visuals/text-renderer.js +112 -11
- data/frontend/src/websocket-client.js +12 -1
- data/lib/vizcore/analysis/adaptive_normalizer.rb +70 -0
- data/lib/vizcore/analysis/beat_detector.rb +4 -2
- data/lib/vizcore/analysis/bpm_estimator.rb +8 -0
- data/lib/vizcore/analysis/feature_recorder.rb +159 -0
- data/lib/vizcore/analysis/feature_replay.rb +84 -0
- data/lib/vizcore/analysis/pipeline.rb +235 -11
- data/lib/vizcore/analysis/tap_tempo.rb +74 -0
- data/lib/vizcore/analysis.rb +4 -0
- data/lib/vizcore/audio/dummy_sine_input.rb +1 -1
- data/lib/vizcore/audio/fixture_input.rb +65 -0
- data/lib/vizcore/audio/input_manager.rb +4 -2
- data/lib/vizcore/audio/mic_input.rb +24 -8
- data/lib/vizcore/audio/portaudio_ffi.rb +106 -1
- data/lib/vizcore/audio.rb +1 -0
- data/lib/vizcore/cli/doctor.rb +159 -0
- data/lib/vizcore/cli/dsl_reference.rb +99 -0
- data/lib/vizcore/cli/layer_docs.rb +46 -0
- data/lib/vizcore/cli/scene_diagnostics.rb +23 -0
- data/lib/vizcore/cli/scene_inspector.rb +136 -0
- data/lib/vizcore/cli/scene_validator.rb +245 -0
- data/lib/vizcore/cli/shader_template.rb +68 -0
- data/lib/vizcore/cli/shader_uniform_docs.rb +54 -0
- data/lib/vizcore/cli.rb +689 -18
- data/lib/vizcore/config.rb +103 -2
- data/lib/vizcore/control_preset.rb +68 -0
- data/lib/vizcore/dsl/engine.rb +277 -5
- data/lib/vizcore/dsl/layer_builder.rb +491 -22
- data/lib/vizcore/dsl/layer_group_builder.rb +112 -0
- data/lib/vizcore/dsl/mapping_resolver.rb +132 -3
- data/lib/vizcore/dsl/mapping_transform_builder.rb +71 -0
- data/lib/vizcore/dsl/reaction_builder.rb +44 -0
- data/lib/vizcore/dsl/scene_builder.rb +61 -5
- data/lib/vizcore/dsl/shader_source_resolver.rb +67 -6
- data/lib/vizcore/dsl/style_builder.rb +68 -0
- data/lib/vizcore/dsl/timeline_builder.rb +138 -0
- data/lib/vizcore/dsl/transition_controller.rb +77 -0
- data/lib/vizcore/dsl.rb +5 -1
- data/lib/vizcore/layer_catalog.rb +273 -0
- data/lib/vizcore/project_manifest.rb +152 -0
- data/lib/vizcore/renderer/png_writer.rb +57 -0
- data/lib/vizcore/renderer/render_sequence.rb +153 -0
- data/lib/vizcore/renderer/scene_frame_source.rb +119 -0
- data/lib/vizcore/renderer/scene_serializer.rb +36 -3
- data/lib/vizcore/renderer/snapshot.rb +38 -0
- data/lib/vizcore/renderer/snapshot_renderer.rb +446 -0
- data/lib/vizcore/renderer.rb +5 -0
- data/lib/vizcore/server/frame_broadcaster.rb +91 -5
- data/lib/vizcore/server/gallery_app.rb +155 -0
- data/lib/vizcore/server/gallery_page.rb +100 -0
- data/lib/vizcore/server/gallery_runner.rb +48 -0
- data/lib/vizcore/server/rack_app.rb +203 -4
- data/lib/vizcore/server/runner.rb +370 -22
- data/lib/vizcore/server/scene_dependency_watcher.rb +79 -0
- data/lib/vizcore/server/websocket_handler.rb +60 -10
- data/lib/vizcore/server.rb +4 -0
- data/lib/vizcore/sync/osc_message.rb +103 -0
- data/lib/vizcore/sync/osc_receiver.rb +68 -0
- data/lib/vizcore/sync.rb +4 -0
- data/lib/vizcore/templates/midi_control_scene.rb +3 -1
- data/lib/vizcore/templates/plugin_layer.rb +20 -0
- data/lib/vizcore/templates/plugin_readme.md +23 -0
- data/lib/vizcore/templates/plugin_renderer.js +43 -0
- data/lib/vizcore/templates/plugin_scene.rb +14 -0
- data/lib/vizcore/templates/project_readme.md +7 -23
- data/lib/vizcore/templates/rubykaigi_scene.rb +30 -0
- data/lib/vizcore/version.rb +1 -1
- data/lib/vizcore.rb +27 -0
- data/scripts/browser_capture.mjs +75 -0
- data/sig/vizcore.rbs +362 -0
- metadata +83 -3
- data/docs/GETTING_STARTED.md +0 -105
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Vizcore
|
|
4
|
+
module DSL
|
|
5
|
+
# Collects ordered timeline scene markers and converts them to transitions.
|
|
6
|
+
class TimelineBuilder
|
|
7
|
+
DEFAULT_BEATS_PER_BAR = 4
|
|
8
|
+
|
|
9
|
+
Point = Struct.new(:value, :unit, keyword_init: true)
|
|
10
|
+
|
|
11
|
+
def initialize(beats_per_bar: DEFAULT_BEATS_PER_BAR)
|
|
12
|
+
@beats_per_bar = positive_integer(beats_per_bar, "beats_per_bar")
|
|
13
|
+
@entries = []
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Evaluate a timeline block.
|
|
17
|
+
#
|
|
18
|
+
# @yield Timeline marker definitions
|
|
19
|
+
# @return [Vizcore::DSL::TimelineBuilder]
|
|
20
|
+
def evaluate(&block)
|
|
21
|
+
instance_eval(&block) if block
|
|
22
|
+
validate_entries!
|
|
23
|
+
self
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Add a scene marker at a timeline position.
|
|
27
|
+
#
|
|
28
|
+
# @param position [Numeric, Point] seconds by default, or a value from `seconds`, `beats`, or `bars`
|
|
29
|
+
# @param scene [Symbol, String] scene to activate at the position
|
|
30
|
+
# @return [Hash]
|
|
31
|
+
def at(position, scene:)
|
|
32
|
+
point = normalize_position(position)
|
|
33
|
+
entry = {
|
|
34
|
+
at: point.value,
|
|
35
|
+
unit: point.unit,
|
|
36
|
+
scene: scene.to_sym
|
|
37
|
+
}
|
|
38
|
+
@entries << entry
|
|
39
|
+
entry
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @param value [Numeric] seconds from the timeline start
|
|
43
|
+
# @return [Point]
|
|
44
|
+
def seconds(value)
|
|
45
|
+
Point.new(value: non_negative_float(value, "timeline seconds"), unit: :seconds)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# @param value [Numeric] beats from the timeline start
|
|
49
|
+
# @return [Point]
|
|
50
|
+
def beats(value)
|
|
51
|
+
Point.new(value: non_negative_float(value, "timeline beats"), unit: :beats)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# @param value [Numeric] bars from the timeline start
|
|
55
|
+
# @param beats_per_bar [Integer, nil] meter override
|
|
56
|
+
# @return [Point]
|
|
57
|
+
def bars(value, beats_per_bar: nil)
|
|
58
|
+
beats_per_measure = beats_per_bar.nil? ? @beats_per_bar : positive_integer(beats_per_bar, "beats_per_bar")
|
|
59
|
+
beats(non_negative_float(value, "timeline bars") * beats_per_measure)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# @return [Array<Hash>] serialized marker definitions
|
|
63
|
+
def to_h
|
|
64
|
+
@entries.map(&:dup)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# @return [Array<Hash>] generated scene transitions
|
|
68
|
+
def transitions
|
|
69
|
+
@entries.each_cons(2).map do |from_entry, to_entry|
|
|
70
|
+
delta = to_entry.fetch(:at) - from_entry.fetch(:at)
|
|
71
|
+
{
|
|
72
|
+
from: from_entry.fetch(:scene),
|
|
73
|
+
to: to_entry.fetch(:scene),
|
|
74
|
+
trigger: trigger_for(delta, from_entry.fetch(:unit))
|
|
75
|
+
}
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def normalize_position(position)
|
|
82
|
+
return position if position.is_a?(Point)
|
|
83
|
+
|
|
84
|
+
seconds(position)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def trigger_for(delta, unit)
|
|
88
|
+
case unit
|
|
89
|
+
when :seconds
|
|
90
|
+
proc { seconds >= delta }
|
|
91
|
+
when :beats
|
|
92
|
+
proc { beat_count >= delta }
|
|
93
|
+
else
|
|
94
|
+
proc { false }
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def validate_entries!
|
|
99
|
+
return if @entries.length < 2
|
|
100
|
+
|
|
101
|
+
unit = @entries.first.fetch(:unit)
|
|
102
|
+
@entries.each_cons(2) do |from_entry, to_entry|
|
|
103
|
+
raise ArgumentError, "timeline entries must use the same unit" unless to_entry.fetch(:unit) == unit
|
|
104
|
+
|
|
105
|
+
from_position = from_entry.fetch(:at)
|
|
106
|
+
to_position = to_entry.fetch(:at)
|
|
107
|
+
raise ArgumentError, "timeline positions must increase" unless to_position > from_position
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def non_negative_float(value, name)
|
|
112
|
+
numeric = parse_float(value, name)
|
|
113
|
+
raise ArgumentError, "#{name} must be non-negative" if numeric.negative?
|
|
114
|
+
|
|
115
|
+
numeric
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def positive_integer(value, name)
|
|
119
|
+
numeric = parse_integer(value, name)
|
|
120
|
+
raise ArgumentError, "#{name} must be positive" unless numeric.positive?
|
|
121
|
+
|
|
122
|
+
numeric
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def parse_float(value, name)
|
|
126
|
+
Float(value)
|
|
127
|
+
rescue ArgumentError, TypeError
|
|
128
|
+
raise ArgumentError, "#{name} must be numeric"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def parse_integer(value, name)
|
|
132
|
+
Integer(value)
|
|
133
|
+
rescue ArgumentError, TypeError
|
|
134
|
+
raise ArgumentError, "#{name} must be an integer"
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
@@ -4,6 +4,8 @@ module Vizcore
|
|
|
4
4
|
module DSL
|
|
5
5
|
# Evaluates transition rules and returns scene-change payloads.
|
|
6
6
|
class TransitionController
|
|
7
|
+
DEFAULT_FRAME_RATE = 60.0
|
|
8
|
+
|
|
7
9
|
# @param scenes [Array<Hash>]
|
|
8
10
|
# @param transitions [Array<Hash>]
|
|
9
11
|
def initialize(scenes:, transitions:)
|
|
@@ -108,6 +110,8 @@ module Vizcore
|
|
|
108
110
|
def initialize(audio, frame_count:)
|
|
109
111
|
@audio = symbolize_hash(audio)
|
|
110
112
|
@bands = symbolize_hash(@audio[:bands])
|
|
113
|
+
@onsets = symbolize_hash(@audio[:onsets])
|
|
114
|
+
@drums = symbolize_hash(@audio[:drums])
|
|
111
115
|
@frame_count = Integer(frame_count)
|
|
112
116
|
rescue StandardError
|
|
113
117
|
@frame_count = 0
|
|
@@ -124,16 +128,84 @@ module Vizcore
|
|
|
124
128
|
@bands[name.to_sym].to_f
|
|
125
129
|
end
|
|
126
130
|
|
|
131
|
+
# @return [Float]
|
|
132
|
+
def sub
|
|
133
|
+
frequency_band(:sub)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# @return [Float]
|
|
137
|
+
def low
|
|
138
|
+
frequency_band(:low)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# @return [Float]
|
|
142
|
+
def bass
|
|
143
|
+
frequency_band(:low)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# @return [Float]
|
|
147
|
+
def mid
|
|
148
|
+
frequency_band(:mid)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# @return [Float]
|
|
152
|
+
def high
|
|
153
|
+
frequency_band(:high)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# @return [Float]
|
|
157
|
+
def treble
|
|
158
|
+
frequency_band(:high)
|
|
159
|
+
end
|
|
160
|
+
|
|
127
161
|
# @return [Array<Float>]
|
|
128
162
|
def fft_spectrum
|
|
129
163
|
Array(@audio[:fft])
|
|
130
164
|
end
|
|
131
165
|
|
|
166
|
+
# @param name [Symbol, String, nil]
|
|
167
|
+
# @return [Float]
|
|
168
|
+
def onset(name = nil)
|
|
169
|
+
return @audio[:onset].to_f if name.nil?
|
|
170
|
+
|
|
171
|
+
@onsets[name.to_sym].to_f
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# @return [Float]
|
|
175
|
+
def kick
|
|
176
|
+
@drums[:kick].to_f
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# @return [Float]
|
|
180
|
+
def snare
|
|
181
|
+
@drums[:snare].to_f
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# @return [Float]
|
|
185
|
+
def hihat
|
|
186
|
+
@drums[:hihat].to_f
|
|
187
|
+
end
|
|
188
|
+
|
|
132
189
|
# @return [Boolean]
|
|
133
190
|
def beat?
|
|
134
191
|
!!@audio[:beat]
|
|
135
192
|
end
|
|
136
193
|
|
|
194
|
+
# @return [Boolean]
|
|
195
|
+
def beat
|
|
196
|
+
beat?
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# @return [Float]
|
|
200
|
+
def beat_confidence
|
|
201
|
+
@audio[:beat_confidence].to_f
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# @return [Float]
|
|
205
|
+
def beat_pulse
|
|
206
|
+
@audio[:beat_pulse].to_f
|
|
207
|
+
end
|
|
208
|
+
|
|
137
209
|
# @return [Integer]
|
|
138
210
|
def beat_count
|
|
139
211
|
Integer(@audio[:beat_count] || 0)
|
|
@@ -151,6 +223,11 @@ module Vizcore
|
|
|
151
223
|
@frame_count
|
|
152
224
|
end
|
|
153
225
|
|
|
226
|
+
# @return [Float] scene-local elapsed seconds at the default runtime frame rate
|
|
227
|
+
def seconds
|
|
228
|
+
@frame_count / DEFAULT_FRAME_RATE
|
|
229
|
+
end
|
|
230
|
+
|
|
154
231
|
private
|
|
155
232
|
|
|
156
233
|
def symbolize_hash(value)
|
data/lib/vizcore/dsl.rb
CHANGED
|
@@ -6,11 +6,15 @@ module Vizcore
|
|
|
6
6
|
end
|
|
7
7
|
end
|
|
8
8
|
|
|
9
|
-
require_relative "dsl/layer_builder"
|
|
10
9
|
require_relative "dsl/file_watcher"
|
|
11
10
|
require_relative "dsl/mapping_resolver"
|
|
11
|
+
require_relative "dsl/mapping_transform_builder"
|
|
12
12
|
require_relative "dsl/midi_map_executor"
|
|
13
|
+
require_relative "dsl/reaction_builder"
|
|
14
|
+
require_relative "dsl/style_builder"
|
|
15
|
+
require_relative "dsl/layer_builder"
|
|
13
16
|
require_relative "dsl/scene_builder"
|
|
14
17
|
require_relative "dsl/shader_source_resolver"
|
|
18
|
+
require_relative "dsl/timeline_builder"
|
|
15
19
|
require_relative "dsl/transition_controller"
|
|
16
20
|
require_relative "dsl/engine"
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Vizcore
|
|
4
|
+
# Metadata for built-in layer types, params, shaders, and browser effects.
|
|
5
|
+
module LayerCatalog
|
|
6
|
+
Capability = Struct.new(:type, :aliases, :params, :mappable_params, :description, keyword_init: true) do
|
|
7
|
+
def types
|
|
8
|
+
[type, *aliases].map(&:to_sym)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def supports?(value)
|
|
12
|
+
types.include?(value.to_sym)
|
|
13
|
+
rescue StandardError
|
|
14
|
+
false
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
COMMON_PARAMS = {
|
|
19
|
+
opacity: "Float",
|
|
20
|
+
blend: "Symbol",
|
|
21
|
+
effect: "Symbol",
|
|
22
|
+
effect_intensity: "Float",
|
|
23
|
+
vj_effect: "Symbol",
|
|
24
|
+
palette: "Array<String>",
|
|
25
|
+
color: "String",
|
|
26
|
+
group: "Symbol"
|
|
27
|
+
}.freeze
|
|
28
|
+
|
|
29
|
+
CAPABILITIES = [
|
|
30
|
+
Capability.new(
|
|
31
|
+
type: :geometry,
|
|
32
|
+
aliases: %i[wireframe_cube radial_blob],
|
|
33
|
+
params: COMMON_PARAMS.merge(
|
|
34
|
+
rotation_speed: "Float",
|
|
35
|
+
color_shift: "Float",
|
|
36
|
+
deform: "Array<Float>"
|
|
37
|
+
),
|
|
38
|
+
mappable_params: %i[rotation_speed color_shift deform],
|
|
39
|
+
description: "Wireframe and radial geometry rendered by the browser."
|
|
40
|
+
),
|
|
41
|
+
Capability.new(
|
|
42
|
+
type: :shader,
|
|
43
|
+
aliases: [],
|
|
44
|
+
params: COMMON_PARAMS.merge(
|
|
45
|
+
shader_reload: "Boolean",
|
|
46
|
+
param_schema: "Array<Hash>"
|
|
47
|
+
),
|
|
48
|
+
mappable_params: %i[effect_intensity],
|
|
49
|
+
description: "GLSL ES fragment shader layer with built-in audio uniforms."
|
|
50
|
+
),
|
|
51
|
+
Capability.new(
|
|
52
|
+
type: :particle_field,
|
|
53
|
+
aliases: %i[particles particle],
|
|
54
|
+
params: COMMON_PARAMS.merge(
|
|
55
|
+
count: "Integer",
|
|
56
|
+
speed: "Float",
|
|
57
|
+
size: "Float",
|
|
58
|
+
force_field: "Symbol",
|
|
59
|
+
turbulence: "Float",
|
|
60
|
+
bass_explosion: "Float",
|
|
61
|
+
sparkle: "Float"
|
|
62
|
+
),
|
|
63
|
+
mappable_params: %i[speed size turbulence bass_explosion sparkle],
|
|
64
|
+
description: "Audio-reactive point particles with simple force fields."
|
|
65
|
+
),
|
|
66
|
+
Capability.new(
|
|
67
|
+
type: :text,
|
|
68
|
+
aliases: %i[text_layer],
|
|
69
|
+
params: COMMON_PARAMS.merge(
|
|
70
|
+
content: "String",
|
|
71
|
+
font_size: "Integer",
|
|
72
|
+
letter_spacing: "Float",
|
|
73
|
+
font: "String",
|
|
74
|
+
align: "Symbol",
|
|
75
|
+
stroke_width: "Float",
|
|
76
|
+
stroke_color: "String",
|
|
77
|
+
shadow_color: "String",
|
|
78
|
+
shadow_blur: "Float",
|
|
79
|
+
glow_strength: "Float"
|
|
80
|
+
),
|
|
81
|
+
mappable_params: %i[font_size letter_spacing glow_strength],
|
|
82
|
+
description: "Canvas text rendered into the WebGL scene."
|
|
83
|
+
),
|
|
84
|
+
Capability.new(
|
|
85
|
+
type: :svg,
|
|
86
|
+
aliases: %i[svg_layer],
|
|
87
|
+
params: COMMON_PARAMS.merge(
|
|
88
|
+
file: "String",
|
|
89
|
+
src: "String",
|
|
90
|
+
scale: "Float",
|
|
91
|
+
rotation: "Float",
|
|
92
|
+
fit: "Symbol"
|
|
93
|
+
),
|
|
94
|
+
mappable_params: %i[scale rotation opacity],
|
|
95
|
+
description: "Inline SVG asset rendered as a textured visual layer."
|
|
96
|
+
),
|
|
97
|
+
Capability.new(
|
|
98
|
+
type: :image,
|
|
99
|
+
aliases: %i[image_layer photo],
|
|
100
|
+
params: COMMON_PARAMS.merge(
|
|
101
|
+
file: "String",
|
|
102
|
+
src: "String",
|
|
103
|
+
scale: "Float",
|
|
104
|
+
rotation: "Float",
|
|
105
|
+
fit: "Symbol"
|
|
106
|
+
),
|
|
107
|
+
mappable_params: %i[scale rotation opacity],
|
|
108
|
+
description: "Inline PNG/JPEG/GIF/WebP image asset rendered as a textured visual layer."
|
|
109
|
+
),
|
|
110
|
+
Capability.new(
|
|
111
|
+
type: :video,
|
|
112
|
+
aliases: %i[video_layer footage],
|
|
113
|
+
params: COMMON_PARAMS.merge(
|
|
114
|
+
file: "String",
|
|
115
|
+
src: "String",
|
|
116
|
+
fit: "Symbol",
|
|
117
|
+
scale: "Float",
|
|
118
|
+
rotation: "Float",
|
|
119
|
+
playback_rate: "Float",
|
|
120
|
+
invert: "Float"
|
|
121
|
+
),
|
|
122
|
+
mappable_params: %i[scale rotation opacity playback_rate invert],
|
|
123
|
+
description: "Inline MP4/WebM/OGV video texture rendered as a looping visual layer."
|
|
124
|
+
),
|
|
125
|
+
Capability.new(
|
|
126
|
+
type: :waveform,
|
|
127
|
+
aliases: %i[waveform_layer],
|
|
128
|
+
params: COMMON_PARAMS.merge(
|
|
129
|
+
source: "Symbol",
|
|
130
|
+
style: "Symbol",
|
|
131
|
+
height: "Float",
|
|
132
|
+
detail: "Integer"
|
|
133
|
+
),
|
|
134
|
+
mappable_params: %i[height opacity color_shift],
|
|
135
|
+
description: "Audio feature waveform rendered as line, mirror, or ribbon geometry."
|
|
136
|
+
),
|
|
137
|
+
Capability.new(
|
|
138
|
+
type: :spectrogram,
|
|
139
|
+
aliases: %i[spectrogram_layer],
|
|
140
|
+
params: COMMON_PARAMS.merge(
|
|
141
|
+
scroll: "Symbol",
|
|
142
|
+
bins: "Integer",
|
|
143
|
+
history: "Integer",
|
|
144
|
+
gain: "Float"
|
|
145
|
+
),
|
|
146
|
+
mappable_params: %i[gain opacity],
|
|
147
|
+
description: "Scrolling FFT heatmap rendered by the browser."
|
|
148
|
+
),
|
|
149
|
+
Capability.new(
|
|
150
|
+
type: :shape,
|
|
151
|
+
aliases: %i[shapes shape_layer],
|
|
152
|
+
params: COMMON_PARAMS.merge(
|
|
153
|
+
shapes: "Array<Hash>",
|
|
154
|
+
color_shift: "Float"
|
|
155
|
+
),
|
|
156
|
+
mappable_params: %i[color_shift opacity],
|
|
157
|
+
description: "Declarative 2D circle and line primitives rendered by the browser."
|
|
158
|
+
),
|
|
159
|
+
Capability.new(
|
|
160
|
+
type: :mesh,
|
|
161
|
+
aliases: %i[mesh_layer preset_mesh],
|
|
162
|
+
params: COMMON_PARAMS.merge(
|
|
163
|
+
geometry: "Symbol",
|
|
164
|
+
material: "Symbol",
|
|
165
|
+
scale: "Float",
|
|
166
|
+
deform: "Float",
|
|
167
|
+
color_shift: "Float"
|
|
168
|
+
),
|
|
169
|
+
mappable_params: %i[scale deform opacity color_shift],
|
|
170
|
+
description: "Preset 3D wireframe meshes: cube, tetrahedron, octahedron, and icosahedron."
|
|
171
|
+
)
|
|
172
|
+
].freeze
|
|
173
|
+
|
|
174
|
+
BUILTIN_SHADERS = %i[
|
|
175
|
+
default gradient_pulse bass_tunnel neon_grid kaleidoscope spectrum_rings
|
|
176
|
+
liquid_wobble audio_bars ruby_crystal starfield waveform_ribbon
|
|
177
|
+
unyo_geometry glitch_flash
|
|
178
|
+
].freeze
|
|
179
|
+
|
|
180
|
+
BLEND_MODES = %i[
|
|
181
|
+
alpha normal add additive multiply screen difference
|
|
182
|
+
].freeze
|
|
183
|
+
|
|
184
|
+
POST_EFFECTS = %i[
|
|
185
|
+
bloom glitch chromatic feedback motion_blur crt
|
|
186
|
+
].freeze
|
|
187
|
+
|
|
188
|
+
VJ_EFFECTS = %i[
|
|
189
|
+
mirror color_shift pixelate
|
|
190
|
+
].freeze
|
|
191
|
+
|
|
192
|
+
module_function
|
|
193
|
+
|
|
194
|
+
def capabilities
|
|
195
|
+
(CAPABILITIES + plugin_capabilities).freeze
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def capability_for(type)
|
|
199
|
+
capabilities.find { |capability| capability.supports?(type) }
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def supported_type?(type)
|
|
203
|
+
!!capability_for(type)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def supported_types
|
|
207
|
+
capabilities.flat_map(&:types).uniq.freeze
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def params_for(type)
|
|
211
|
+
capability_for(type)&.params || {}
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def mappable_params_for(type)
|
|
215
|
+
capability_for(type)&.mappable_params || []
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def register_layer_capability(type:, aliases: [], params: {}, mappable_params: [], description: nil)
|
|
219
|
+
capability = Capability.new(
|
|
220
|
+
type: normalize_type!(type),
|
|
221
|
+
aliases: normalize_symbols(aliases),
|
|
222
|
+
params: COMMON_PARAMS.merge(normalize_param_types(params)),
|
|
223
|
+
mappable_params: normalize_symbols(mappable_params),
|
|
224
|
+
description: normalize_description(description)
|
|
225
|
+
)
|
|
226
|
+
validate_plugin_capability!(capability)
|
|
227
|
+
plugin_capabilities.reject! { |entry| entry.type == capability.type }
|
|
228
|
+
plugin_capabilities << capability
|
|
229
|
+
capability
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def reset_plugin_capabilities!
|
|
233
|
+
plugin_capabilities.clear
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def plugin_capabilities
|
|
237
|
+
@plugin_capabilities ||= []
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def normalize_type!(type)
|
|
241
|
+
value = type.to_s.strip
|
|
242
|
+
raise ArgumentError, "layer capability type must not be empty" if value.empty?
|
|
243
|
+
|
|
244
|
+
value.to_sym
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def normalize_symbols(values)
|
|
248
|
+
Array(values).filter_map do |value|
|
|
249
|
+
symbol = value.to_s.strip
|
|
250
|
+
symbol.empty? ? nil : symbol.to_sym
|
|
251
|
+
end.uniq
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def normalize_param_types(params)
|
|
255
|
+
Hash(params).to_h do |name, type|
|
|
256
|
+
[normalize_type!(name), type.to_s]
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def normalize_description(description)
|
|
261
|
+
value = description.to_s.strip
|
|
262
|
+
value.empty? ? "Plugin layer capability." : value
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def validate_plugin_capability!(capability)
|
|
266
|
+
reserved_types = CAPABILITIES.flat_map(&:types)
|
|
267
|
+
conflicts = capability.types & reserved_types
|
|
268
|
+
return if conflicts.empty?
|
|
269
|
+
|
|
270
|
+
raise ArgumentError, "layer capability conflicts with built-in type: #{conflicts.first}"
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
end
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
require "yaml"
|
|
5
|
+
|
|
6
|
+
module Vizcore
|
|
7
|
+
# Reads a project-level manifest such as vizcore.yml.
|
|
8
|
+
class ProjectManifest
|
|
9
|
+
# @param path [String, Pathname]
|
|
10
|
+
# @return [Vizcore::ProjectManifest]
|
|
11
|
+
def self.load(path)
|
|
12
|
+
new(path)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
attr_reader :path, :root
|
|
16
|
+
|
|
17
|
+
# @param path [String, Pathname]
|
|
18
|
+
def initialize(path)
|
|
19
|
+
@path = Pathname.new(path).expand_path
|
|
20
|
+
raise ArgumentError, "Project manifest not found: #{@path}" unless @path.file?
|
|
21
|
+
|
|
22
|
+
@root = @path.dirname
|
|
23
|
+
@data = normalize_hash(YAML.safe_load_file(@path, aliases: false) || {})
|
|
24
|
+
rescue Psych::Exception => e
|
|
25
|
+
raise ArgumentError, "Invalid project manifest #{@path}: #{e.message}"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @return [Hash] config defaults accepted by Vizcore::Config
|
|
29
|
+
def config_defaults(profile: nil)
|
|
30
|
+
data = data_for(profile)
|
|
31
|
+
{
|
|
32
|
+
scene_file: expand_path(value_at(data, "scene") || value_at(data, "scene_file")),
|
|
33
|
+
audio_source: value_at(data, "audio_source") || value_at(data, "audio", "source"),
|
|
34
|
+
audio_file: expand_path(value_at(data, "audio_file") || value_at(data, "audio", "file")),
|
|
35
|
+
audio_device: value_at(data, "audio_device") || value_at(data, "audio", "device"),
|
|
36
|
+
feature_file: expand_path(value_at(data, "feature_file") || value_at(data, "features")),
|
|
37
|
+
control_preset: expand_path(value_at(data, "control_preset") || value_at(data, "controlPreset")),
|
|
38
|
+
osc_port: value_at(data, "osc_port") || value_at(data, "sync", "osc_port") || value_at(data, "sync", "osc", "port"),
|
|
39
|
+
plugin_assets: plugin_assets(profile: profile)
|
|
40
|
+
}.compact
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# @return [Array<String>] require paths loaded before scene evaluation
|
|
44
|
+
def plugins(profile: nil)
|
|
45
|
+
plugin_entries(profile: profile).filter_map { |entry| plugin_require_path(entry) }.uniq
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# @return [Array<Pathname>] frontend plugin assets served and loaded by RackApp
|
|
49
|
+
def plugin_assets(profile: nil)
|
|
50
|
+
entries = plugin_entries(profile: profile)
|
|
51
|
+
assets = base_values("plugin_assets", "frontend_plugins") + profile_values(profile, "plugin_assets", "frontend_plugins")
|
|
52
|
+
(entries.filter_map { |entry| plugin_asset_path(entry) } + assets.filter_map { |entry| expand_path(entry) }).uniq
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# @return [Array<String>] configured profile names
|
|
56
|
+
def profile_names
|
|
57
|
+
Hash(@data["profiles"] || {}).keys
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def value_at(data, *keys)
|
|
63
|
+
current = data
|
|
64
|
+
keys.each do |key|
|
|
65
|
+
return nil unless current.is_a?(Hash)
|
|
66
|
+
|
|
67
|
+
current = current[key.to_s]
|
|
68
|
+
end
|
|
69
|
+
current
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def data_for(profile)
|
|
73
|
+
profile_name = profile.to_s.strip
|
|
74
|
+
return @data if profile_name.empty?
|
|
75
|
+
|
|
76
|
+
deep_merge(@data.reject { |key, _value| key == "profiles" }, profile_overlay(profile_name))
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def plugin_entries(profile:)
|
|
80
|
+
base_entries = base_values("plugins", ["package", "plugins"])
|
|
81
|
+
profile_entries = profile_values(profile, "plugins", ["package", "plugins"])
|
|
82
|
+
base_entries + profile_entries
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def base_values(*paths)
|
|
86
|
+
paths.each do |path|
|
|
87
|
+
value = path.is_a?(Array) ? value_at(@data, *path) : value_at(@data, path)
|
|
88
|
+
return Array(value) if value
|
|
89
|
+
end
|
|
90
|
+
[]
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def profile_values(profile, *paths)
|
|
94
|
+
overlay = profile_overlay(profile)
|
|
95
|
+
return [] if overlay.empty?
|
|
96
|
+
|
|
97
|
+
paths.each do |path|
|
|
98
|
+
value = path.is_a?(Array) ? value_at(overlay, *path) : value_at(overlay, path)
|
|
99
|
+
return Array(value) if value
|
|
100
|
+
end
|
|
101
|
+
[]
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def profile_overlay(profile)
|
|
105
|
+
profile_name = profile.to_s.strip
|
|
106
|
+
return {} if profile_name.empty?
|
|
107
|
+
|
|
108
|
+
profiles = Hash(@data["profiles"] || {})
|
|
109
|
+
normalize_hash(profiles.fetch(profile_name) do
|
|
110
|
+
raise ArgumentError, "Unknown project profile: #{profile_name}. Use one of: #{profile_names.join(', ')}"
|
|
111
|
+
end)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def plugin_require_path(entry)
|
|
115
|
+
value = if entry.is_a?(Hash)
|
|
116
|
+
entry["require"] || entry[:require] || entry["name"] || entry[:name]
|
|
117
|
+
else
|
|
118
|
+
entry
|
|
119
|
+
end
|
|
120
|
+
plugin = value.to_s.strip
|
|
121
|
+
plugin unless plugin.empty?
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def plugin_asset_path(entry)
|
|
125
|
+
return nil unless entry.is_a?(Hash)
|
|
126
|
+
|
|
127
|
+
expand_path(entry["asset"] || entry[:asset] || entry["frontend"] || entry[:frontend])
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def expand_path(value)
|
|
131
|
+
raw_value = value.to_s.strip
|
|
132
|
+
return nil if raw_value.empty?
|
|
133
|
+
|
|
134
|
+
path_value = Pathname.new(raw_value)
|
|
135
|
+
path_value.absolute? ? path_value : @root.join(path_value).expand_path
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def deep_merge(base, overlay)
|
|
139
|
+
base.merge(overlay) do |_key, left, right|
|
|
140
|
+
left.is_a?(Hash) && right.is_a?(Hash) ? deep_merge(left, right) : right
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def normalize_hash(value)
|
|
145
|
+
return {} unless value.is_a?(Hash)
|
|
146
|
+
|
|
147
|
+
value.each_with_object({}) do |(key, entry), output|
|
|
148
|
+
output[key.to_s] = entry.is_a?(Hash) ? normalize_hash(entry) : entry
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|