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.
Files changed (126) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +544 -9
  3. data/docs/.nojekyll +0 -0
  4. data/docs/assets/site.css +744 -0
  5. data/docs/assets/vizcore-demo.gif +0 -0
  6. data/docs/assets/vizcore-poster.png +0 -0
  7. data/docs/assets/vj-tunnel.js +159 -0
  8. data/docs/index.html +224 -0
  9. data/examples/README.md +59 -0
  10. data/examples/assets/README.md +19 -0
  11. data/examples/audio_inspector.rb +34 -0
  12. data/examples/club_intro_drop.rb +78 -0
  13. data/examples/kansai_rubykaigi_visual.rb +70 -0
  14. data/examples/live_coding_minimal.rb +22 -0
  15. data/examples/midi_controller_show.rb +78 -0
  16. data/examples/midi_scene_switch.rb +3 -1
  17. data/examples/parser_visualizer.rb +48 -0
  18. data/examples/readme_demo.rb +17 -0
  19. data/examples/rhythm_geometry.rb +34 -0
  20. data/examples/ruby_crystal_show.rb +35 -0
  21. data/examples/shader_playground.rb +18 -0
  22. data/examples/unyo_liquid.rb +59 -0
  23. data/examples/vj_ambient_chill_room.rb +124 -0
  24. data/examples/vj_dnb_jungle.rb +170 -0
  25. data/examples/vj_festival_mainstage.rb +245 -0
  26. data/examples/vj_festival_mainstage.yml +17 -0
  27. data/examples/vj_glitch_industrial.rb +164 -0
  28. data/examples/vj_hiphop_cipher.rb +167 -0
  29. data/examples/vj_jpop_idol_live.rb +210 -0
  30. data/examples/vj_synthwave_retro.rb +173 -0
  31. data/examples/vj_techno_warehouse.rb +195 -0
  32. data/frontend/index.html +468 -2
  33. data/frontend/src/audio-inspector.js +40 -0
  34. data/frontend/src/live-controls.js +131 -0
  35. data/frontend/src/main.js +792 -16
  36. data/frontend/src/midi-learn.js +194 -0
  37. data/frontend/src/performance-monitor.js +183 -0
  38. data/frontend/src/plugin-runtime.js +130 -0
  39. data/frontend/src/projector-mode.js +56 -0
  40. data/frontend/src/renderer/engine.js +148 -3
  41. data/frontend/src/renderer/layer-manager.js +428 -30
  42. data/frontend/src/renderer/shader-manager.js +26 -0
  43. data/frontend/src/runtime-control-preset.js +11 -0
  44. data/frontend/src/shader-error-overlay.js +29 -0
  45. data/frontend/src/shader-param-controls.js +93 -0
  46. data/frontend/src/shaders/builtins.js +380 -2
  47. data/frontend/src/shaders/post-effects.js +52 -0
  48. data/frontend/src/visual-regression.js +67 -0
  49. data/frontend/src/visual-settings-preset.js +103 -0
  50. data/frontend/src/visuals/geometry.js +268 -0
  51. data/frontend/src/visuals/image-renderer.js +291 -0
  52. data/frontend/src/visuals/particle-system.js +56 -10
  53. data/frontend/src/visuals/spectrogram-renderer.js +226 -0
  54. data/frontend/src/visuals/text-renderer.js +112 -11
  55. data/frontend/src/websocket-client.js +12 -1
  56. data/lib/vizcore/analysis/adaptive_normalizer.rb +70 -0
  57. data/lib/vizcore/analysis/beat_detector.rb +4 -2
  58. data/lib/vizcore/analysis/bpm_estimator.rb +8 -0
  59. data/lib/vizcore/analysis/feature_recorder.rb +159 -0
  60. data/lib/vizcore/analysis/feature_replay.rb +84 -0
  61. data/lib/vizcore/analysis/pipeline.rb +235 -11
  62. data/lib/vizcore/analysis/tap_tempo.rb +74 -0
  63. data/lib/vizcore/analysis.rb +4 -0
  64. data/lib/vizcore/audio/dummy_sine_input.rb +1 -1
  65. data/lib/vizcore/audio/fixture_input.rb +65 -0
  66. data/lib/vizcore/audio/input_manager.rb +4 -2
  67. data/lib/vizcore/audio/mic_input.rb +24 -8
  68. data/lib/vizcore/audio/portaudio_ffi.rb +106 -1
  69. data/lib/vizcore/audio.rb +1 -0
  70. data/lib/vizcore/cli/doctor.rb +159 -0
  71. data/lib/vizcore/cli/dsl_reference.rb +99 -0
  72. data/lib/vizcore/cli/layer_docs.rb +46 -0
  73. data/lib/vizcore/cli/scene_diagnostics.rb +23 -0
  74. data/lib/vizcore/cli/scene_inspector.rb +136 -0
  75. data/lib/vizcore/cli/scene_validator.rb +245 -0
  76. data/lib/vizcore/cli/shader_template.rb +68 -0
  77. data/lib/vizcore/cli/shader_uniform_docs.rb +54 -0
  78. data/lib/vizcore/cli.rb +689 -18
  79. data/lib/vizcore/config.rb +103 -2
  80. data/lib/vizcore/control_preset.rb +68 -0
  81. data/lib/vizcore/dsl/engine.rb +277 -5
  82. data/lib/vizcore/dsl/layer_builder.rb +491 -22
  83. data/lib/vizcore/dsl/layer_group_builder.rb +112 -0
  84. data/lib/vizcore/dsl/mapping_resolver.rb +132 -3
  85. data/lib/vizcore/dsl/mapping_transform_builder.rb +71 -0
  86. data/lib/vizcore/dsl/reaction_builder.rb +44 -0
  87. data/lib/vizcore/dsl/scene_builder.rb +61 -5
  88. data/lib/vizcore/dsl/shader_source_resolver.rb +67 -6
  89. data/lib/vizcore/dsl/style_builder.rb +68 -0
  90. data/lib/vizcore/dsl/timeline_builder.rb +138 -0
  91. data/lib/vizcore/dsl/transition_controller.rb +77 -0
  92. data/lib/vizcore/dsl.rb +5 -1
  93. data/lib/vizcore/layer_catalog.rb +273 -0
  94. data/lib/vizcore/project_manifest.rb +152 -0
  95. data/lib/vizcore/renderer/png_writer.rb +57 -0
  96. data/lib/vizcore/renderer/render_sequence.rb +153 -0
  97. data/lib/vizcore/renderer/scene_frame_source.rb +119 -0
  98. data/lib/vizcore/renderer/scene_serializer.rb +36 -3
  99. data/lib/vizcore/renderer/snapshot.rb +38 -0
  100. data/lib/vizcore/renderer/snapshot_renderer.rb +446 -0
  101. data/lib/vizcore/renderer.rb +5 -0
  102. data/lib/vizcore/server/frame_broadcaster.rb +91 -5
  103. data/lib/vizcore/server/gallery_app.rb +155 -0
  104. data/lib/vizcore/server/gallery_page.rb +100 -0
  105. data/lib/vizcore/server/gallery_runner.rb +48 -0
  106. data/lib/vizcore/server/rack_app.rb +203 -4
  107. data/lib/vizcore/server/runner.rb +370 -22
  108. data/lib/vizcore/server/scene_dependency_watcher.rb +79 -0
  109. data/lib/vizcore/server/websocket_handler.rb +60 -10
  110. data/lib/vizcore/server.rb +4 -0
  111. data/lib/vizcore/sync/osc_message.rb +103 -0
  112. data/lib/vizcore/sync/osc_receiver.rb +68 -0
  113. data/lib/vizcore/sync.rb +4 -0
  114. data/lib/vizcore/templates/midi_control_scene.rb +3 -1
  115. data/lib/vizcore/templates/plugin_layer.rb +20 -0
  116. data/lib/vizcore/templates/plugin_readme.md +23 -0
  117. data/lib/vizcore/templates/plugin_renderer.js +43 -0
  118. data/lib/vizcore/templates/plugin_scene.rb +14 -0
  119. data/lib/vizcore/templates/project_readme.md +7 -23
  120. data/lib/vizcore/templates/rubykaigi_scene.rb +30 -0
  121. data/lib/vizcore/version.rb +1 -1
  122. data/lib/vizcore.rb +27 -0
  123. data/scripts/browser_capture.mjs +75 -0
  124. data/sig/vizcore.rbs +362 -0
  125. metadata +83 -3
  126. data/docs/GETTING_STARTED.md +0 -105
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vizcore
4
+ module CLISupport
5
+ # Formats loaded scene definitions for CLI inspection.
6
+ class SceneInspector
7
+ def initialize(definition:)
8
+ @definition = definition
9
+ end
10
+
11
+ def lines
12
+ output = []
13
+ append_inputs(output, "Audio", Array(@definition[:audio]))
14
+ append_inputs(output, "MIDI", Array(@definition[:midi]))
15
+ append_scenes(output, Array(@definition[:scenes]))
16
+ append_timelines(output, Array(@definition[:timelines]))
17
+ append_transitions(output, Array(@definition[:transitions]))
18
+ append_key_mappings(output, Array(@definition[:key_mappings]))
19
+ output
20
+ end
21
+
22
+ private
23
+
24
+ def append_inputs(output, label, values)
25
+ return if values.empty?
26
+
27
+ output << "#{label}:"
28
+ values.each do |input|
29
+ output << " - #{input[:name]}#{format_options(input[:options])}"
30
+ end
31
+ end
32
+
33
+ def append_scenes(output, scenes)
34
+ output << "Scenes:"
35
+ if scenes.empty?
36
+ output << " (none)"
37
+ return
38
+ end
39
+
40
+ scenes.each do |scene|
41
+ output << " #{scene[:name]}"
42
+ append_layers(output, Array(scene[:layers]))
43
+ end
44
+ end
45
+
46
+ def append_layers(output, layers)
47
+ layers.each do |layer|
48
+ output << " layer #{layer[:name]} (#{format_layer(layer)})"
49
+ Array(layer[:mappings]).each do |mapping|
50
+ output << " #{format_source(mapping[:source])} -> #{mapping[:target]}#{format_transform(mapping[:transform])}"
51
+ end
52
+ end
53
+ end
54
+
55
+ def append_transitions(output, transitions)
56
+ return if transitions.empty?
57
+
58
+ output << "Transitions:"
59
+ transitions.each do |transition|
60
+ trigger = transition[:trigger].respond_to?(:call) ? "triggered" : "no trigger"
61
+ output << " #{transition[:from]} -> #{transition[:to]} (#{trigger})"
62
+ end
63
+ end
64
+
65
+ def append_timelines(output, timelines)
66
+ timelines.each_with_index do |timeline, index|
67
+ next if Array(timeline).empty?
68
+
69
+ output << "Timeline #{index + 1}:"
70
+ Array(timeline).each do |entry|
71
+ output << " #{format_timeline_position(entry)} -> #{entry[:scene]}"
72
+ end
73
+ end
74
+ end
75
+
76
+ def append_key_mappings(output, mappings)
77
+ return if mappings.empty?
78
+
79
+ output << "Keyboard:"
80
+ mappings.each do |mapping|
81
+ output << " #{mapping[:key]} -> #{format_key_action(mapping[:action])}"
82
+ end
83
+ end
84
+
85
+ def format_layer(layer)
86
+ type = layer[:type] || :geometry
87
+ return "#{type}, shader=#{layer[:shader]}" if layer[:shader]
88
+ return "#{type}, glsl=#{layer[:glsl]}" if layer[:glsl]
89
+
90
+ type.to_s
91
+ end
92
+
93
+ def format_source(source)
94
+ values = Hash(source || {})
95
+ return "unknown" unless values[:kind]
96
+ return "frequency_band(#{values[:band]})" if values[:kind].to_sym == :frequency_band
97
+ return "onset(#{values[:band]})" if values[:kind].to_sym == :onset && values[:band]
98
+
99
+ values[:kind].to_s
100
+ end
101
+
102
+ def format_transform(transform)
103
+ values = Hash(transform || {})
104
+ return "" if values.empty?
105
+
106
+ formatted = values.map { |key, value| "#{key}=#{value.inspect}" }.join(", ")
107
+ " [#{formatted}]"
108
+ end
109
+
110
+ def format_options(options)
111
+ values = Hash(options || {})
112
+ return "" if values.empty?
113
+
114
+ formatted = values.map { |key, value| "#{key}=#{value.inspect}" }.join(", ")
115
+ " (#{formatted})"
116
+ end
117
+
118
+ def format_key_action(action)
119
+ values = Hash(action || {})
120
+ case values[:type]&.to_sym
121
+ when :switch_scene
122
+ "switch_scene #{values[:scene]}"
123
+ when :live_control
124
+ values[:control].to_s
125
+ else
126
+ "unknown"
127
+ end
128
+ end
129
+
130
+ def format_timeline_position(entry)
131
+ unit = entry[:unit] == :seconds ? "s" : " beats"
132
+ "#{entry[:at]}#{unit}"
133
+ end
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,245 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+ require_relative "../../vizcore"
5
+ require_relative "../dsl"
6
+ require_relative "../layer_catalog"
7
+
8
+ module Vizcore
9
+ module CLISupport
10
+ # Validates scene DSL files without starting the realtime server.
11
+ class SceneValidator
12
+ BUILTIN_SHADERS = Vizcore::LayerCatalog::BUILTIN_SHADERS
13
+
14
+ MAPPING_SOURCE_KINDS = %i[
15
+ amplitude frequency_band fft_spectrum onset kick snare hihat beat beat_confidence beat_pulse beat_count bpm
16
+ ].freeze
17
+
18
+ FREQUENCY_BANDS = %i[sub low mid high].freeze
19
+ SUPPORTED_BLEND_MODES = Vizcore::LayerCatalog::BLEND_MODES
20
+ SUPPORTED_POST_EFFECTS = Vizcore::LayerCatalog::POST_EFFECTS
21
+ SUPPORTED_VJ_EFFECTS = Vizcore::LayerCatalog::VJ_EFFECTS
22
+
23
+ Issue = Struct.new(:severity, :message, keyword_init: true) do
24
+ def error?
25
+ severity == :error
26
+ end
27
+ end
28
+
29
+ Result = Struct.new(:definition, :issues, keyword_init: true) do
30
+ def valid?
31
+ issues.none?(&:error?)
32
+ end
33
+
34
+ def errors
35
+ issues.select(&:error?)
36
+ end
37
+
38
+ def warnings
39
+ issues.reject(&:error?)
40
+ end
41
+ end
42
+
43
+ def initialize(scene_file:, loader: Vizcore::DSL::Engine.method(:load_file), shader_resolver: Vizcore::DSL::ShaderSourceResolver.new)
44
+ @scene_file = scene_file
45
+ @loader = loader
46
+ @shader_resolver = shader_resolver
47
+ end
48
+
49
+ def call
50
+ definition = load_definition
51
+ Result.new(definition: definition, issues: validate_definition(definition))
52
+ rescue StandardError => e
53
+ Result.new(
54
+ definition: nil,
55
+ issues: [Issue.new(severity: :error, message: "failed to load scene: #{e.message}")]
56
+ )
57
+ end
58
+
59
+ private
60
+
61
+ def load_definition
62
+ definition = @loader.call(@scene_file)
63
+ @shader_resolver.resolve(definition: definition, scene_file: @scene_file)
64
+ end
65
+
66
+ def validate_definition(definition)
67
+ issues = []
68
+ scenes = Array(definition[:scenes])
69
+ validate_scenes(scenes, issues)
70
+ names = scene_names(scenes)
71
+ validate_transitions(Array(definition[:transitions]), names, issues)
72
+ validate_key_mappings(Array(definition[:key_mappings]), names, issues)
73
+ issues
74
+ end
75
+
76
+ def validate_scenes(scenes, issues)
77
+ issues << error("no scenes defined") if scenes.empty?
78
+ duplicate_values(scenes.filter_map { |scene| scene[:name]&.to_sym }).each do |name|
79
+ issues << error("duplicate scene name: #{name}")
80
+ end
81
+
82
+ scenes.each do |scene|
83
+ scene_name = scene[:name] || "(unnamed)"
84
+ layers = Array(scene[:layers])
85
+ issues << warn("scene #{scene_name} has no layers; frontend will render the default geometry") if layers.empty?
86
+ validate_layers(layers, scene_name, issues)
87
+ end
88
+ end
89
+
90
+ def validate_layers(layers, scene_name, issues)
91
+ duplicate_values(layers.filter_map { |layer| layer[:name]&.to_sym }).each do |name|
92
+ issues << warn("scene #{scene_name} has duplicate layer name: #{name}")
93
+ end
94
+
95
+ layers.each do |layer|
96
+ validate_layer(layer, scene_name, issues)
97
+ end
98
+ end
99
+
100
+ def validate_layer(layer, scene_name, issues)
101
+ layer_name = layer[:name] || "(unnamed)"
102
+ type = layer[:type]&.to_sym || :geometry
103
+ unless supported_layer_types.include?(type)
104
+ issues << error("scene #{scene_name} layer #{layer_name} has unsupported type: #{type}")
105
+ end
106
+
107
+ shader = layer[:shader]&.to_sym
108
+ if shader && !BUILTIN_SHADERS.include?(shader)
109
+ issues << error("scene #{scene_name} layer #{layer_name} uses unknown shader: #{shader}")
110
+ end
111
+
112
+ glsl_source = layer[:glsl_source]
113
+ issues << warn("scene #{scene_name} layer #{layer_name} has an empty GLSL file") if layer[:glsl] && glsl_source.to_s.empty?
114
+ validate_blend_mode(layer, scene_name, layer_name, issues)
115
+ validate_layer_effects(layer, scene_name, layer_name, issues)
116
+ validate_mappings(Array(layer[:mappings]), scene_name, layer_name, issues)
117
+ end
118
+
119
+ def validate_blend_mode(layer, scene_name, layer_name, issues)
120
+ blend = layer.dig(:params, :blend)
121
+ return unless blend
122
+ return if SUPPORTED_BLEND_MODES.include?(blend.to_sym)
123
+
124
+ issues << error("scene #{scene_name} layer #{layer_name} uses unsupported blend mode: #{blend}")
125
+ end
126
+
127
+ def validate_layer_effects(layer, scene_name, layer_name, issues)
128
+ params = layer[:params] || {}
129
+ validate_effect_name(params[:effect], SUPPORTED_POST_EFFECTS, "effect", scene_name, layer_name, issues)
130
+ validate_effect_name(params[:vj_effect], SUPPORTED_VJ_EFFECTS, "vj_effect", scene_name, layer_name, issues)
131
+ end
132
+
133
+ def supported_layer_types
134
+ Vizcore::LayerCatalog.supported_types
135
+ end
136
+
137
+ def validate_effect_name(value, supported, field, scene_name, layer_name, issues)
138
+ return unless value
139
+ return if supported.include?(value.to_sym)
140
+
141
+ issues << error("scene #{scene_name} layer #{layer_name} uses unsupported #{field}: #{value}")
142
+ end
143
+
144
+ def validate_mappings(mappings, scene_name, layer_name, issues)
145
+ mappings.each do |mapping|
146
+ source = Hash(mapping[:source] || {})
147
+ kind = source[:kind]&.to_sym
148
+ issues << error("scene #{scene_name} layer #{layer_name} has mapping without source kind") unless kind
149
+ next unless kind
150
+
151
+ validate_mapping_source(kind, source, scene_name, layer_name, issues)
152
+ issues << error("scene #{scene_name} layer #{layer_name} has mapping without target") unless mapping[:target]
153
+ validate_transform(Hash(mapping[:transform] || {}), scene_name, layer_name, mapping[:target], issues)
154
+ end
155
+ end
156
+
157
+ def validate_mapping_source(kind, source, scene_name, layer_name, issues)
158
+ unless MAPPING_SOURCE_KINDS.include?(kind)
159
+ issues << error("scene #{scene_name} layer #{layer_name} uses unsupported mapping source: #{kind}")
160
+ end
161
+ validate_frequency_band(source, scene_name, layer_name, issues) if kind == :frequency_band
162
+ validate_onset_band(source, scene_name, layer_name, issues) if kind == :onset
163
+ end
164
+
165
+ def validate_frequency_band(source, scene_name, layer_name, issues)
166
+ band = source[:band]&.to_sym
167
+ return if FREQUENCY_BANDS.include?(band)
168
+
169
+ issues << error("scene #{scene_name} layer #{layer_name} uses unsupported frequency band: #{band.inspect}")
170
+ end
171
+
172
+ def validate_onset_band(source, scene_name, layer_name, issues)
173
+ return unless source.key?(:band)
174
+
175
+ band = source[:band]&.to_sym
176
+ return if FREQUENCY_BANDS.include?(band)
177
+
178
+ issues << error("scene #{scene_name} layer #{layer_name} uses unsupported onset band: #{band.inspect}")
179
+ end
180
+
181
+ def validate_transform(transform, scene_name, layer_name, target, issues)
182
+ return unless transform.key?(:min) && transform.key?(:max)
183
+ return unless Float(transform[:min]) > Float(transform[:max])
184
+
185
+ issues << error("scene #{scene_name} layer #{layer_name} mapping #{target} has min greater than max")
186
+ rescue ArgumentError, TypeError
187
+ issues << error("scene #{scene_name} layer #{layer_name} mapping #{target} has non-numeric min/max")
188
+ end
189
+
190
+ def validate_transitions(transitions, names, issues)
191
+ transitions.each do |transition|
192
+ from = transition[:from]&.to_sym
193
+ to = transition[:to]&.to_sym
194
+ issues << error("transition has unknown source scene: #{from}") if from && !names.include?(from)
195
+ issues << error("transition has unknown target scene: #{to}") if to && !names.include?(to)
196
+ unless transition[:trigger].respond_to?(:call)
197
+ issues << warn("transition #{from || '?'} -> #{to || '?'} has no trigger block")
198
+ end
199
+ end
200
+ end
201
+
202
+ def validate_key_mappings(mappings, names, issues)
203
+ mappings.each do |mapping|
204
+ key = mapping[:key] || mapping["key"]
205
+ action = mapping[:action] || mapping["action"]
206
+ issues << error("key mapping has empty key") if key.to_s.strip.empty?
207
+ validate_key_action(action.is_a?(Hash) ? action : {}, names, key, issues)
208
+ end
209
+ end
210
+
211
+ def validate_key_action(action, names, key, issues)
212
+ type = (action[:type] || action["type"]).to_s.to_sym
213
+ case type
214
+ when :switch_scene
215
+ scene = action[:scene] || action["scene"]
216
+ scene_name = scene&.to_sym
217
+ issues << error("key #{key} switches to unknown scene: #{scene}") unless scene_name && names.include?(scene_name)
218
+ when :live_control
219
+ control = (action[:control] || action["control"]).to_s.to_sym
220
+ issues << error("key #{key} uses unsupported live control: #{control}") unless %i[blackout freeze].include?(control)
221
+ else
222
+ issues << error("key #{key} has unsupported action: #{type}")
223
+ end
224
+ end
225
+
226
+ def scene_names(scenes)
227
+ scenes.filter_map { |scene| scene[:name]&.to_sym }.to_set
228
+ end
229
+
230
+ def duplicate_values(values)
231
+ counts = Hash.new(0)
232
+ values.each { |value| counts[value] += 1 }
233
+ counts.select { |_value, count| count > 1 }.keys
234
+ end
235
+
236
+ def error(message)
237
+ Issue.new(severity: :error, message: message)
238
+ end
239
+
240
+ def warn(message)
241
+ Issue.new(severity: :warn, message: message)
242
+ end
243
+ end
244
+ end
245
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "pathname"
5
+
6
+ module Vizcore
7
+ module CLISupport
8
+ # Writes starter GLSL ES fragment shaders for custom shader layers.
9
+ class ShaderTemplate
10
+ TEMPLATE = <<~GLSL
11
+ #version 300 es
12
+ precision highp float;
13
+
14
+ uniform vec2 u_resolution;
15
+ uniform float u_time;
16
+ uniform float u_amplitude;
17
+ uniform float u_bass;
18
+ uniform float u_mid;
19
+ uniform float u_high;
20
+ uniform float u_beat;
21
+ uniform float u_beat_pulse;
22
+ uniform float u_onset;
23
+ uniform float u_kick;
24
+ uniform float u_bpm;
25
+ uniform float u_fft[32];
26
+ uniform float u_fft_size;
27
+
28
+ out vec4 outColor;
29
+
30
+ void main() {
31
+ vec2 uv = gl_FragCoord.xy / u_resolution.xy;
32
+ float wave = 0.5 + 0.5 * sin((uv.x + u_time * 0.12) * 12.0 + u_bass * 4.0);
33
+ vec3 color = mix(vec3(0.02, 0.06, 0.12), vec3(0.1, 0.75, 0.95), wave);
34
+ color += vec3(0.95, 0.16, 0.32) * (u_beat_pulse * 0.35 + u_onset * 0.2 + u_kick * 0.25 + u_high * 0.2);
35
+ color *= 0.35 + u_amplitude * 1.8;
36
+ outColor = vec4(color, 1.0);
37
+ }
38
+ GLSL
39
+
40
+ # @param name [String]
41
+ # @return [String]
42
+ def self.default_path(name)
43
+ "shaders/#{safe_name(name)}.frag"
44
+ end
45
+
46
+ # @param name [String]
47
+ # @return [String]
48
+ def self.safe_name(name)
49
+ raw_name = name.to_s.strip.sub(/\.frag\z/i, "")
50
+ safe = raw_name.gsub(/[^a-zA-Z0-9_-]+/, "_").gsub(/\A_+|_+\z/, "")
51
+ raise ArgumentError, "shader name is required" if safe.empty?
52
+
53
+ safe
54
+ end
55
+
56
+ # @param path [String, Pathname]
57
+ # @return [Pathname]
58
+ def write(path)
59
+ destination = Pathname(path)
60
+ raise ArgumentError, "Shader file already exists: #{destination}" if destination.exist?
61
+
62
+ FileUtils.mkdir_p(destination.dirname)
63
+ destination.write(TEMPLATE)
64
+ destination
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vizcore
4
+ module CLISupport
5
+ # Produces the custom GLSL uniform reference used by CLI and docs.
6
+ class ShaderUniformDocs
7
+ Uniform = Struct.new(:name, :type, :description, keyword_init: true)
8
+
9
+ UNIFORMS = [
10
+ Uniform.new(name: "u_resolution", type: "vec2", description: "Canvas size in pixels as width and height."),
11
+ Uniform.new(name: "u_time", type: "float", description: "Renderer time in seconds."),
12
+ Uniform.new(name: "u_amplitude", type: "float", description: "Normalized RMS amplitude, usually 0.0..1.0."),
13
+ Uniform.new(name: "u_bass", type: "float", description: "Low-frequency band level."),
14
+ Uniform.new(name: "u_mid", type: "float", description: "Mid-frequency band level."),
15
+ Uniform.new(name: "u_high", type: "float", description: "High-frequency band level."),
16
+ Uniform.new(name: "u_beat", type: "float", description: "1.0 on detected beat frames, otherwise 0.0."),
17
+ Uniform.new(name: "u_beat_pulse", type: "float", description: "Decaying beat pulse after detection."),
18
+ Uniform.new(name: "u_onset", type: "float", description: "Positive amplitude rise since the previous active frame."),
19
+ Uniform.new(name: "u_sub_onset", type: "float", description: "Positive sub-band rise since the previous active frame."),
20
+ Uniform.new(name: "u_low_onset", type: "float", description: "Positive low-band rise since the previous active frame."),
21
+ Uniform.new(name: "u_mid_onset", type: "float", description: "Positive mid-band rise since the previous active frame."),
22
+ Uniform.new(name: "u_high_onset", type: "float", description: "Positive high-band rise since the previous active frame."),
23
+ Uniform.new(name: "u_kick", type: "float", description: "Low-band percussive confidence derived from band level and onset."),
24
+ Uniform.new(name: "u_snare", type: "float", description: "Mid-band percussive confidence derived from band level and onset."),
25
+ Uniform.new(name: "u_hihat", type: "float", description: "High-band percussive confidence derived from band level and onset."),
26
+ Uniform.new(name: "u_bpm", type: "float", description: "Current BPM estimate, or 0.0 when unavailable."),
27
+ Uniform.new(name: "u_fft[32]", type: "float[]", description: "Normalized FFT preview bins."),
28
+ Uniform.new(name: "u_fft_size", type: "float", description: "Number of populated FFT preview bins."),
29
+ Uniform.new(name: "u_visual_gain", type: "float", description: "Browser visual gain control value."),
30
+ Uniform.new(name: "u_bass_boost", type: "float", description: "Browser bass boost control value."),
31
+ Uniform.new(name: "u_wobble_amount", type: "float", description: "Browser wobble amount control value."),
32
+ Uniform.new(name: "u_param_<name>", type: "float", description: "Numeric layer param or mapped DSL target."),
33
+ Uniform.new(name: "u_global_<name>", type: "float", description: "Numeric runtime global from DSL or MIDI set.")
34
+ ].freeze
35
+
36
+ # @return [Array<String>]
37
+ def lines
38
+ [
39
+ "# Vizcore Shader Uniforms",
40
+ "",
41
+ "Custom fragment shaders use GLSL ES 3.00 and receive these uniforms:",
42
+ "",
43
+ "| Uniform | Type | Description |",
44
+ "| --- | --- | --- |",
45
+ *UNIFORMS.map { |uniform| "| `#{uniform.name}` | `#{uniform.type}` | #{uniform.description} |" },
46
+ "",
47
+ "Layer params are exposed as `u_param_<name>` after non-word characters are converted to underscores.",
48
+ "For compatibility, a mapped target like `:param_intensity` is also exposed as `u_param_intensity`.",
49
+ "Runtime globals are exposed as `u_global_<name>`; `:global_intensity` becomes `u_global_intensity`."
50
+ ]
51
+ end
52
+ end
53
+ end
54
+ end