vizcore 0.1.0 → 1.1.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 (137) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +70 -117
  3. data/docs/.nojekyll +0 -0
  4. data/docs/assets/playground-worker.js +373 -0
  5. data/docs/assets/playground.css +440 -0
  6. data/docs/assets/playground.js +652 -0
  7. data/docs/assets/site.css +744 -0
  8. data/docs/assets/vizcore-demo.gif +0 -0
  9. data/docs/assets/vizcore-poster.png +0 -0
  10. data/docs/assets/vj-tunnel.js +159 -0
  11. data/docs/index.html +225 -0
  12. data/docs/playground.html +81 -0
  13. data/docs/shape_dsl.md +269 -0
  14. data/examples/README.md +59 -0
  15. data/examples/assets/README.md +19 -0
  16. data/examples/audio_inspector.rb +34 -0
  17. data/examples/club_intro_drop.rb +78 -0
  18. data/examples/kansai_rubykaigi_visual.rb +70 -0
  19. data/examples/live_coding_minimal.rb +22 -0
  20. data/examples/midi_controller_show.rb +78 -0
  21. data/examples/midi_scene_switch.rb +3 -1
  22. data/examples/parser_visualizer.rb +48 -0
  23. data/examples/readme_demo.rb +17 -0
  24. data/examples/rhythm_geometry.rb +34 -0
  25. data/examples/ruby_crystal_show.rb +35 -0
  26. data/examples/shader_playground.rb +18 -0
  27. data/examples/unyo_liquid.rb +59 -0
  28. data/examples/vj_ambient_chill_room.rb +124 -0
  29. data/examples/vj_dnb_jungle.rb +170 -0
  30. data/examples/vj_festival_mainstage.rb +245 -0
  31. data/examples/vj_festival_mainstage.yml +17 -0
  32. data/examples/vj_glitch_industrial.rb +164 -0
  33. data/examples/vj_hiphop_cipher.rb +167 -0
  34. data/examples/vj_jpop_idol_live.rb +210 -0
  35. data/examples/vj_synthwave_retro.rb +173 -0
  36. data/examples/vj_techno_warehouse.rb +195 -0
  37. data/frontend/index.html +494 -2
  38. data/frontend/src/audio-inspector.js +40 -0
  39. data/frontend/src/custom-shape-param-controls.js +106 -0
  40. data/frontend/src/live-controls.js +131 -0
  41. data/frontend/src/main.js +1060 -16
  42. data/frontend/src/mapping-target-selector.js +109 -0
  43. data/frontend/src/midi-learn.js +194 -0
  44. data/frontend/src/performance-monitor.js +183 -0
  45. data/frontend/src/plugin-runtime.js +130 -0
  46. data/frontend/src/projector-mode.js +56 -0
  47. data/frontend/src/renderer/engine.js +157 -3
  48. data/frontend/src/renderer/layer-manager.js +442 -30
  49. data/frontend/src/renderer/shader-manager.js +26 -0
  50. data/frontend/src/runtime-control-preset.js +11 -0
  51. data/frontend/src/shader-error-overlay.js +29 -0
  52. data/frontend/src/shader-param-controls.js +93 -0
  53. data/frontend/src/shaders/builtins.js +380 -2
  54. data/frontend/src/shaders/post-effects.js +52 -0
  55. data/frontend/src/shape-editor-controls.js +157 -0
  56. data/frontend/src/visual-regression.js +67 -0
  57. data/frontend/src/visual-settings-preset.js +103 -0
  58. data/frontend/src/visuals/geometry.js +666 -0
  59. data/frontend/src/visuals/image-renderer.js +291 -0
  60. data/frontend/src/visuals/particle-system.js +56 -10
  61. data/frontend/src/visuals/shape-renderer.js +475 -0
  62. data/frontend/src/visuals/spectrogram-renderer.js +226 -0
  63. data/frontend/src/visuals/svg-arc.js +104 -0
  64. data/frontend/src/visuals/text-renderer.js +112 -11
  65. data/frontend/src/websocket-client.js +12 -1
  66. data/lib/vizcore/analysis/adaptive_normalizer.rb +70 -0
  67. data/lib/vizcore/analysis/beat_detector.rb +4 -2
  68. data/lib/vizcore/analysis/bpm_estimator.rb +8 -0
  69. data/lib/vizcore/analysis/feature_recorder.rb +159 -0
  70. data/lib/vizcore/analysis/feature_replay.rb +84 -0
  71. data/lib/vizcore/analysis/pipeline.rb +235 -11
  72. data/lib/vizcore/analysis/tap_tempo.rb +74 -0
  73. data/lib/vizcore/analysis.rb +4 -0
  74. data/lib/vizcore/audio/dummy_sine_input.rb +1 -1
  75. data/lib/vizcore/audio/fixture_input.rb +65 -0
  76. data/lib/vizcore/audio/input_manager.rb +4 -2
  77. data/lib/vizcore/audio/mic_input.rb +24 -8
  78. data/lib/vizcore/audio/portaudio_ffi.rb +106 -1
  79. data/lib/vizcore/audio.rb +1 -0
  80. data/lib/vizcore/cli/doctor.rb +159 -0
  81. data/lib/vizcore/cli/dsl_reference.rb +99 -0
  82. data/lib/vizcore/cli/layer_docs.rb +46 -0
  83. data/lib/vizcore/cli/scene_diagnostics.rb +23 -0
  84. data/lib/vizcore/cli/scene_inspector.rb +136 -0
  85. data/lib/vizcore/cli/scene_validator.rb +337 -0
  86. data/lib/vizcore/cli/shader_template.rb +68 -0
  87. data/lib/vizcore/cli/shader_uniform_docs.rb +54 -0
  88. data/lib/vizcore/cli.rb +689 -18
  89. data/lib/vizcore/config.rb +103 -2
  90. data/lib/vizcore/control_preset.rb +68 -0
  91. data/lib/vizcore/dsl/engine.rb +277 -5
  92. data/lib/vizcore/dsl/layer_builder.rb +1280 -23
  93. data/lib/vizcore/dsl/layer_group_builder.rb +112 -0
  94. data/lib/vizcore/dsl/mapping_resolver.rb +290 -7
  95. data/lib/vizcore/dsl/mapping_transform_builder.rb +71 -0
  96. data/lib/vizcore/dsl/reaction_builder.rb +44 -0
  97. data/lib/vizcore/dsl/scene_builder.rb +61 -5
  98. data/lib/vizcore/dsl/shader_source_resolver.rb +67 -6
  99. data/lib/vizcore/dsl/style_builder.rb +68 -0
  100. data/lib/vizcore/dsl/timeline_builder.rb +138 -0
  101. data/lib/vizcore/dsl/transition_controller.rb +77 -0
  102. data/lib/vizcore/dsl.rb +5 -1
  103. data/lib/vizcore/layer_catalog.rb +275 -0
  104. data/lib/vizcore/project_manifest.rb +152 -0
  105. data/lib/vizcore/renderer/png_writer.rb +57 -0
  106. data/lib/vizcore/renderer/render_sequence.rb +153 -0
  107. data/lib/vizcore/renderer/scene_frame_source.rb +132 -0
  108. data/lib/vizcore/renderer/scene_serializer.rb +36 -3
  109. data/lib/vizcore/renderer/snapshot.rb +38 -0
  110. data/lib/vizcore/renderer/snapshot_renderer.rb +938 -0
  111. data/lib/vizcore/renderer.rb +5 -0
  112. data/lib/vizcore/server/frame_broadcaster.rb +143 -8
  113. data/lib/vizcore/server/gallery_app.rb +155 -0
  114. data/lib/vizcore/server/gallery_page.rb +100 -0
  115. data/lib/vizcore/server/gallery_runner.rb +48 -0
  116. data/lib/vizcore/server/rack_app.rb +203 -4
  117. data/lib/vizcore/server/runner.rb +391 -22
  118. data/lib/vizcore/server/scene_dependency_watcher.rb +79 -0
  119. data/lib/vizcore/server/websocket_handler.rb +60 -10
  120. data/lib/vizcore/server.rb +4 -0
  121. data/lib/vizcore/shape.rb +719 -0
  122. data/lib/vizcore/sync/osc_message.rb +103 -0
  123. data/lib/vizcore/sync/osc_receiver.rb +68 -0
  124. data/lib/vizcore/sync.rb +4 -0
  125. data/lib/vizcore/templates/midi_control_scene.rb +3 -1
  126. data/lib/vizcore/templates/plugin_layer.rb +20 -0
  127. data/lib/vizcore/templates/plugin_readme.md +23 -0
  128. data/lib/vizcore/templates/plugin_renderer.js +43 -0
  129. data/lib/vizcore/templates/plugin_scene.rb +14 -0
  130. data/lib/vizcore/templates/project_readme.md +7 -23
  131. data/lib/vizcore/templates/rubykaigi_scene.rb +30 -0
  132. data/lib/vizcore/version.rb +1 -1
  133. data/lib/vizcore.rb +28 -0
  134. data/scripts/browser_capture.mjs +75 -0
  135. data/sig/vizcore.rbs +461 -0
  136. metadata +94 -3
  137. data/docs/GETTING_STARTED.md +0 -105
@@ -0,0 +1,275 @@
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
+ shape_schema_version: "Integer",
155
+ units: "Symbol",
156
+ color_shift: "Float"
157
+ ),
158
+ mappable_params: %i[color_shift opacity shapes],
159
+ description: "Declarative and Ruby-generated 2D circle, line, rect, polygon, polyline, path, and star primitives rendered by the browser."
160
+ ),
161
+ Capability.new(
162
+ type: :mesh,
163
+ aliases: %i[mesh_layer preset_mesh],
164
+ params: COMMON_PARAMS.merge(
165
+ geometry: "Symbol",
166
+ material: "Symbol",
167
+ scale: "Float",
168
+ deform: "Float",
169
+ color_shift: "Float"
170
+ ),
171
+ mappable_params: %i[scale deform opacity color_shift],
172
+ description: "Preset 3D wireframe meshes: cube, tetrahedron, octahedron, and icosahedron."
173
+ )
174
+ ].freeze
175
+
176
+ BUILTIN_SHADERS = %i[
177
+ default gradient_pulse bass_tunnel neon_grid kaleidoscope spectrum_rings
178
+ liquid_wobble audio_bars ruby_crystal starfield waveform_ribbon
179
+ unyo_geometry glitch_flash
180
+ ].freeze
181
+
182
+ BLEND_MODES = %i[
183
+ alpha normal add additive multiply screen difference
184
+ ].freeze
185
+
186
+ POST_EFFECTS = %i[
187
+ bloom glitch chromatic feedback motion_blur crt
188
+ ].freeze
189
+
190
+ VJ_EFFECTS = %i[
191
+ mirror color_shift pixelate
192
+ ].freeze
193
+
194
+ module_function
195
+
196
+ def capabilities
197
+ (CAPABILITIES + plugin_capabilities).freeze
198
+ end
199
+
200
+ def capability_for(type)
201
+ capabilities.find { |capability| capability.supports?(type) }
202
+ end
203
+
204
+ def supported_type?(type)
205
+ !!capability_for(type)
206
+ end
207
+
208
+ def supported_types
209
+ capabilities.flat_map(&:types).uniq.freeze
210
+ end
211
+
212
+ def params_for(type)
213
+ capability_for(type)&.params || {}
214
+ end
215
+
216
+ def mappable_params_for(type)
217
+ capability_for(type)&.mappable_params || []
218
+ end
219
+
220
+ def register_layer_capability(type:, aliases: [], params: {}, mappable_params: [], description: nil)
221
+ capability = Capability.new(
222
+ type: normalize_type!(type),
223
+ aliases: normalize_symbols(aliases),
224
+ params: COMMON_PARAMS.merge(normalize_param_types(params)),
225
+ mappable_params: normalize_symbols(mappable_params),
226
+ description: normalize_description(description)
227
+ )
228
+ validate_plugin_capability!(capability)
229
+ plugin_capabilities.reject! { |entry| entry.type == capability.type }
230
+ plugin_capabilities << capability
231
+ capability
232
+ end
233
+
234
+ def reset_plugin_capabilities!
235
+ plugin_capabilities.clear
236
+ end
237
+
238
+ def plugin_capabilities
239
+ @plugin_capabilities ||= []
240
+ end
241
+
242
+ def normalize_type!(type)
243
+ value = type.to_s.strip
244
+ raise ArgumentError, "layer capability type must not be empty" if value.empty?
245
+
246
+ value.to_sym
247
+ end
248
+
249
+ def normalize_symbols(values)
250
+ Array(values).filter_map do |value|
251
+ symbol = value.to_s.strip
252
+ symbol.empty? ? nil : symbol.to_sym
253
+ end.uniq
254
+ end
255
+
256
+ def normalize_param_types(params)
257
+ Hash(params).to_h do |name, type|
258
+ [normalize_type!(name), type.to_s]
259
+ end
260
+ end
261
+
262
+ def normalize_description(description)
263
+ value = description.to_s.strip
264
+ value.empty? ? "Plugin layer capability." : value
265
+ end
266
+
267
+ def validate_plugin_capability!(capability)
268
+ reserved_types = CAPABILITIES.flat_map(&:types)
269
+ conflicts = capability.types & reserved_types
270
+ return if conflicts.empty?
271
+
272
+ raise ArgumentError, "layer capability conflicts with built-in type: #{conflicts.first}"
273
+ end
274
+ end
275
+ 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
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zlib"
4
+
5
+ module Vizcore
6
+ module Renderer
7
+ # Minimal PNG encoder for RGBA pixel buffers.
8
+ class PngWriter
9
+ SIGNATURE = "\x89PNG\r\n\x1A\n".b
10
+ COLOR_TYPE_RGBA = 6
11
+ BIT_DEPTH = 8
12
+
13
+ class << self
14
+ # @param width [Integer]
15
+ # @param height [Integer]
16
+ # @param rgba [String] RGBA bytes, row-major
17
+ # @return [String] PNG bytes
18
+ def encode(width:, height:, rgba:)
19
+ width = Integer(width)
20
+ height = Integer(height)
21
+ pixels = rgba.to_s.b
22
+ expected_size = width * height * 4
23
+ raise ArgumentError, "RGBA buffer must be #{expected_size} bytes" unless pixels.bytesize == expected_size
24
+
25
+ SIGNATURE + chunk("IHDR", ihdr(width, height)) + chunk("IDAT", Zlib::Deflate.deflate(scanlines(width, height, pixels))) + chunk("IEND", "".b)
26
+ end
27
+
28
+ # @return [void]
29
+ def write(path:, width:, height:, rgba:)
30
+ File.binwrite(path, encode(width: width, height: height, rgba: rgba))
31
+ end
32
+
33
+ private
34
+
35
+ def ihdr(width, height)
36
+ [width, height, BIT_DEPTH, COLOR_TYPE_RGBA, 0, 0, 0].pack("NNCCCCC")
37
+ end
38
+
39
+ def scanlines(width, height, pixels)
40
+ row_size = width * 4
41
+ output = +""
42
+ output.force_encoding(Encoding::BINARY)
43
+ height.times do |row|
44
+ output << "\x00".b
45
+ output << pixels.byteslice(row * row_size, row_size)
46
+ end
47
+ output
48
+ end
49
+
50
+ def chunk(type, data)
51
+ typed_data = type.b + data.b
52
+ [data.bytesize].pack("N") + typed_data + [Zlib.crc32(typed_data)].pack("N")
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "open3"
5
+ require "pathname"
6
+ require "tmpdir"
7
+ require_relative "scene_frame_source"
8
+ require_relative "snapshot_renderer"
9
+
10
+ module Vizcore
11
+ module Renderer
12
+ # Writes a deterministic PNG image sequence from a scene.
13
+ class RenderSequence
14
+ DEFAULT_FRAME_COUNT = 60
15
+ DEFAULT_FRAME_RATE = 30.0
16
+
17
+ def initialize(
18
+ config:,
19
+ frames: DEFAULT_FRAME_COUNT,
20
+ fps: DEFAULT_FRAME_RATE,
21
+ width: SnapshotRenderer::DEFAULT_WIDTH,
22
+ height: SnapshotRenderer::DEFAULT_HEIGHT,
23
+ command_runner: Open3,
24
+ ffmpeg_checker: nil
25
+ )
26
+ @config = config
27
+ @frames = normalize_frame_count(frames)
28
+ @fps = normalize_frame_rate(fps)
29
+ @width = width
30
+ @height = height
31
+ @command_runner = command_runner
32
+ @ffmpeg_checker = ffmpeg_checker || method(:ffmpeg_available?)
33
+ end
34
+
35
+ # @param out [String, Pathname] output directory for PNG frames, or `.mp4`
36
+ # @return [Hash] render metadata
37
+ def write(out:)
38
+ output_path = Pathname.new(out.to_s).expand_path
39
+ return write_video(output_path) if video_output?(output_path)
40
+
41
+ write_frames(output_path)
42
+ end
43
+
44
+ private
45
+
46
+ def write_frames(output_dir)
47
+ FileUtils.mkdir_p(output_dir)
48
+ render_frames(output_dir).merge(path: output_dir, format: :png_sequence)
49
+ end
50
+
51
+ def write_video(output_file)
52
+ raise ArgumentError, "Only .mp4 video output is supported" unless output_file.extname.downcase == ".mp4"
53
+ raise ArgumentError, "ffmpeg is required for MP4 output" unless @ffmpeg_checker.call
54
+
55
+ FileUtils.mkdir_p(output_file.dirname)
56
+ metadata = nil
57
+ Dir.mktmpdir("vizcore-render-frames") do |dir|
58
+ frame_dir = Pathname.new(dir)
59
+ metadata = render_frames(frame_dir)
60
+ encode_mp4(frame_dir: frame_dir, output_file: output_file)
61
+ end
62
+ metadata.merge(path: output_file, format: :mp4)
63
+ end
64
+
65
+ def render_frames(output_dir)
66
+ source = SceneFrameSource.new(config: @config, frame_rate: @fps)
67
+ source.start
68
+ renderer = SnapshotRenderer.new(width: @width, height: @height)
69
+ scene_name = nil
70
+
71
+ @frames.times do |index|
72
+ frame = source.capture
73
+ scene_name ||= frame.fetch(:scene_name)
74
+ File.binwrite(
75
+ frame_path(output_dir, index),
76
+ renderer.render(scene: frame.fetch(:scene), audio: frame.fetch(:audio))
77
+ )
78
+ end
79
+
80
+ {
81
+ frames: @frames,
82
+ fps: @fps,
83
+ width: renderer.width,
84
+ height: renderer.height,
85
+ scene: scene_name
86
+ }
87
+ ensure
88
+ source&.stop
89
+ end
90
+
91
+ def video_output?(path)
92
+ %w[.mp4 .mov .webm].include?(path.extname.downcase)
93
+ end
94
+
95
+ def frame_path(output_dir, index)
96
+ output_dir.join(format("frame_%05d.png", index + 1))
97
+ end
98
+
99
+ def encode_mp4(frame_dir:, output_file:)
100
+ stdout, stderr, status = @command_runner.capture3(*ffmpeg_command(frame_dir: frame_dir, output_file: output_file))
101
+ return if status.success?
102
+
103
+ detail = stderr.to_s.strip.empty? ? stdout.to_s.strip : stderr.to_s.strip
104
+ message = detail.empty? ? "ffmpeg failed with non-zero status" : "ffmpeg failed: #{detail}"
105
+ raise ArgumentError, message
106
+ end
107
+
108
+ def ffmpeg_command(frame_dir:, output_file:)
109
+ [
110
+ "ffmpeg",
111
+ "-y",
112
+ "-framerate",
113
+ format_frame_rate,
114
+ "-i",
115
+ frame_dir.join("frame_%05d.png").to_s,
116
+ "-vf",
117
+ "format=yuv420p",
118
+ "-pix_fmt",
119
+ "yuv420p",
120
+ output_file.to_s
121
+ ]
122
+ end
123
+
124
+ def ffmpeg_available?
125
+ system("ffmpeg", "-version", out: File::NULL, err: File::NULL)
126
+ end
127
+
128
+ def format_frame_rate
129
+ return @fps.to_i.to_s if @fps == @fps.to_i
130
+
131
+ @fps.to_s
132
+ end
133
+
134
+ def normalize_frame_count(value)
135
+ count = Integer(value)
136
+ raise ArgumentError, "frames must be positive" unless count.positive?
137
+
138
+ count
139
+ rescue ArgumentError, TypeError
140
+ raise ArgumentError, "frames must be a positive integer"
141
+ end
142
+
143
+ def normalize_frame_rate(value)
144
+ rate = Float(value)
145
+ raise ArgumentError, "fps must be positive" unless rate.positive?
146
+
147
+ rate
148
+ rescue ArgumentError, TypeError
149
+ raise ArgumentError, "fps must be a positive number"
150
+ end
151
+ end
152
+ end
153
+ end