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.
- checksums.yaml +4 -4
- data/README.md +70 -117
- data/docs/.nojekyll +0 -0
- data/docs/assets/playground-worker.js +373 -0
- data/docs/assets/playground.css +440 -0
- data/docs/assets/playground.js +652 -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 +225 -0
- data/docs/playground.html +81 -0
- data/docs/shape_dsl.md +269 -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 +494 -2
- data/frontend/src/audio-inspector.js +40 -0
- data/frontend/src/custom-shape-param-controls.js +106 -0
- data/frontend/src/live-controls.js +131 -0
- data/frontend/src/main.js +1060 -16
- data/frontend/src/mapping-target-selector.js +109 -0
- 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 +157 -3
- data/frontend/src/renderer/layer-manager.js +442 -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/shape-editor-controls.js +157 -0
- data/frontend/src/visual-regression.js +67 -0
- data/frontend/src/visual-settings-preset.js +103 -0
- data/frontend/src/visuals/geometry.js +666 -0
- data/frontend/src/visuals/image-renderer.js +291 -0
- data/frontend/src/visuals/particle-system.js +56 -10
- data/frontend/src/visuals/shape-renderer.js +475 -0
- data/frontend/src/visuals/spectrogram-renderer.js +226 -0
- data/frontend/src/visuals/svg-arc.js +104 -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 +337 -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 +1280 -23
- data/lib/vizcore/dsl/layer_group_builder.rb +112 -0
- data/lib/vizcore/dsl/mapping_resolver.rb +290 -7
- 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 +275 -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 +132 -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 +938 -0
- data/lib/vizcore/renderer.rb +5 -0
- data/lib/vizcore/server/frame_broadcaster.rb +143 -8
- 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 +391 -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/shape.rb +719 -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 +28 -0
- data/scripts/browser_capture.mjs +75 -0
- data/sig/vizcore.rbs +461 -0
- metadata +94 -3
- 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
|