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,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
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../analysis"
|
|
4
|
+
require_relative "../audio"
|
|
5
|
+
require_relative "../dsl"
|
|
6
|
+
|
|
7
|
+
module Vizcore
|
|
8
|
+
module Renderer
|
|
9
|
+
# Produces analyzed scene frames for offline renderers.
|
|
10
|
+
class SceneFrameSource
|
|
11
|
+
def initialize(config:, frame_rate: nil)
|
|
12
|
+
@config = config
|
|
13
|
+
@frame_rate = frame_rate
|
|
14
|
+
@shader_source_resolver = Vizcore::DSL::ShaderSourceResolver.new
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# @return [Vizcore::Renderer::SceneFrameSource]
|
|
18
|
+
def start
|
|
19
|
+
@definition = resolve_shader_sources(Vizcore::DSL::Engine.load_file(@config.scene_file.to_s))
|
|
20
|
+
@scene = first_scene(@definition)
|
|
21
|
+
@input_manager = build_input_manager
|
|
22
|
+
@input_manager.start
|
|
23
|
+
@capture_size = capture_size
|
|
24
|
+
@pipeline = build_pipeline
|
|
25
|
+
self
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @return [Hash] frame data consumed by offline renderers
|
|
29
|
+
def capture
|
|
30
|
+
ensure_started!
|
|
31
|
+
|
|
32
|
+
audio = @pipeline.call(@input_manager.capture_frame(@capture_size))
|
|
33
|
+
layers = Vizcore::DSL::MappingResolver.new.resolve_layers(scene_layers: @scene[:layers], audio: audio)
|
|
34
|
+
|
|
35
|
+
{
|
|
36
|
+
scene: { name: @scene[:name], layers: layers },
|
|
37
|
+
audio: audio,
|
|
38
|
+
scene_name: @scene[:name].to_s
|
|
39
|
+
}
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @return [void]
|
|
43
|
+
def stop
|
|
44
|
+
@input_manager&.stop
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def resolve_shader_sources(definition)
|
|
50
|
+
@shader_source_resolver.resolve(definition: definition, scene_file: @config.scene_file.to_s)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def first_scene(definition)
|
|
54
|
+
scene = Array(definition[:scenes]).first
|
|
55
|
+
return scene if scene
|
|
56
|
+
|
|
57
|
+
{ name: @config.scene_file.basename(".rb").to_sym, layers: [] }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def build_input_manager
|
|
61
|
+
Vizcore::Audio::InputManager.new(
|
|
62
|
+
source: @config.audio_source,
|
|
63
|
+
file_path: @config.audio_file&.to_s,
|
|
64
|
+
audio_device: @config.audio_device
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def capture_size
|
|
69
|
+
return @input_manager.frame_size unless @frame_rate
|
|
70
|
+
|
|
71
|
+
@input_manager.realtime_capture_size(@frame_rate)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def build_pipeline
|
|
75
|
+
Vizcore::Analysis::Pipeline.new(
|
|
76
|
+
sample_rate: @input_manager.sample_rate,
|
|
77
|
+
fft_size: supported_fft_size(@input_manager.frame_size),
|
|
78
|
+
noise_gate: @config.noise_gate,
|
|
79
|
+
audio_normalize: audio_normalize_settings,
|
|
80
|
+
bpm: bpm_setting,
|
|
81
|
+
bpm_lock: bpm_lock_setting
|
|
82
|
+
)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def audio_normalize_settings
|
|
86
|
+
Hash(@definition[:analysis] || {})[:audio_normalize]
|
|
87
|
+
rescue StandardError
|
|
88
|
+
nil
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def bpm_setting
|
|
92
|
+
@config.bpm || Hash(@definition[:analysis] || {})[:bpm]
|
|
93
|
+
rescue StandardError
|
|
94
|
+
@config.bpm
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def bpm_lock_setting
|
|
98
|
+
@config.bpm_lock? || !!Hash(@definition[:analysis] || {})[:bpm_lock]
|
|
99
|
+
rescue StandardError
|
|
100
|
+
@config.bpm_lock?
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def supported_fft_size(size)
|
|
104
|
+
value = Integer(size)
|
|
105
|
+
return value if value.positive? && (value & (value - 1)).zero?
|
|
106
|
+
|
|
107
|
+
1024
|
|
108
|
+
rescue StandardError
|
|
109
|
+
1024
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def ensure_started!
|
|
113
|
+
return if @input_manager && @pipeline && @scene
|
|
114
|
+
|
|
115
|
+
raise RuntimeError, "scene frame source has not been started"
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -9,28 +9,39 @@ module Vizcore
|
|
|
9
9
|
# @param scene_name [String, Symbol]
|
|
10
10
|
# @param scene_layers [Array<Hash>]
|
|
11
11
|
# @param transition [Hash, nil]
|
|
12
|
+
# @param metrics [Hash, nil]
|
|
12
13
|
# @return [Hash]
|
|
13
|
-
def audio_frame(timestamp:, audio:, scene_name:, scene_layers:, transition: nil)
|
|
14
|
-
{
|
|
14
|
+
def audio_frame(timestamp:, audio:, scene_name:, scene_layers:, transition: nil, metrics: nil)
|
|
15
|
+
frame = {
|
|
15
16
|
timestamp: Float(timestamp),
|
|
16
17
|
audio: serialize_audio(audio),
|
|
17
18
|
scene: serialize_scene(scene_name, scene_layers),
|
|
18
19
|
transition: transition
|
|
19
20
|
}
|
|
21
|
+
frame[:metrics] = serialize_metrics(metrics) if metrics
|
|
22
|
+
frame
|
|
20
23
|
end
|
|
21
24
|
|
|
22
25
|
private
|
|
23
26
|
|
|
24
27
|
def serialize_audio(audio)
|
|
25
28
|
bands = symbolize_hash(audio[:bands])
|
|
29
|
+
onsets = { sub: 0.0, low: 0.0, mid: 0.0, high: 0.0 }.merge(symbolize_hash(audio[:onsets]))
|
|
30
|
+
drums = { kick: 0.0, snare: 0.0, hihat: 0.0 }.merge(symbolize_hash(audio[:drums]))
|
|
26
31
|
|
|
27
32
|
{
|
|
28
33
|
amplitude: round_float(audio[:amplitude]),
|
|
29
34
|
bands: bands.transform_values { |value| round_float(value) },
|
|
30
35
|
fft: Array(audio[:fft]).map { |value| round_float(value) },
|
|
36
|
+
onset: round_float(audio[:onset]),
|
|
37
|
+
onsets: onsets.transform_values { |value| round_float(value) },
|
|
38
|
+
drums: drums.transform_values { |value| round_float(value) },
|
|
31
39
|
beat: !!audio[:beat],
|
|
40
|
+
beat_confidence: round_float(audio[:beat_confidence]),
|
|
41
|
+
beat_pulse: round_float(audio[:beat_pulse]),
|
|
32
42
|
beat_count: Integer(audio[:beat_count] || 0),
|
|
33
|
-
bpm: audio[:bpm]
|
|
43
|
+
bpm: audio[:bpm],
|
|
44
|
+
peak_frequency: round_float(audio[:peak_frequency])
|
|
34
45
|
}
|
|
35
46
|
end
|
|
36
47
|
|
|
@@ -52,9 +63,31 @@ module Vizcore
|
|
|
52
63
|
output[:shader] = values[:shader].to_s if values[:shader]
|
|
53
64
|
output[:glsl] = values[:glsl].to_s if values[:glsl]
|
|
54
65
|
output[:glsl_source] = values[:glsl_source].to_s if values[:glsl_source]
|
|
66
|
+
output[:param_schema] = serialize_param_schema(values[:param_schema]) if values[:param_schema]
|
|
55
67
|
output
|
|
56
68
|
end
|
|
57
69
|
|
|
70
|
+
def serialize_param_schema(schema)
|
|
71
|
+
Array(schema).map do |entry|
|
|
72
|
+
values = symbolize_hash(entry)
|
|
73
|
+
{
|
|
74
|
+
name: values.fetch(:name).to_s,
|
|
75
|
+
default: round_float(values[:default]),
|
|
76
|
+
min: round_float(values[:min]),
|
|
77
|
+
max: round_float(values[:max]),
|
|
78
|
+
step: round_float(values[:step])
|
|
79
|
+
}.compact
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def serialize_metrics(metrics)
|
|
84
|
+
symbolize_hash(metrics).each_with_object({}) do |(key, value), output|
|
|
85
|
+
output[key] = key == :frame_id ? Integer(value) : round_float(value)
|
|
86
|
+
rescue StandardError
|
|
87
|
+
output[key] = 0
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
58
91
|
def symbolize_hash(value)
|
|
59
92
|
Hash(value).each_with_object({}) do |(key, entry), output|
|
|
60
93
|
output[key.to_sym] = entry
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "pathname"
|
|
5
|
+
require_relative "scene_frame_source"
|
|
6
|
+
require_relative "snapshot_renderer"
|
|
7
|
+
|
|
8
|
+
module Vizcore
|
|
9
|
+
module Renderer
|
|
10
|
+
# Builds one analyzed scene frame and writes a PNG preview.
|
|
11
|
+
class Snapshot
|
|
12
|
+
def initialize(config:, width: SnapshotRenderer::DEFAULT_WIDTH, height: SnapshotRenderer::DEFAULT_HEIGHT)
|
|
13
|
+
@config = config
|
|
14
|
+
@width = width
|
|
15
|
+
@height = height
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# @param out [String, Pathname]
|
|
19
|
+
# @return [Hash] snapshot metadata
|
|
20
|
+
def write(out:)
|
|
21
|
+
output_path = Pathname.new(out.to_s).expand_path
|
|
22
|
+
frame_source = SceneFrameSource.new(config: @config)
|
|
23
|
+
frame_source.start
|
|
24
|
+
frame = frame_source.capture
|
|
25
|
+
png = SnapshotRenderer.new(width: @width, height: @height).render(
|
|
26
|
+
scene: frame.fetch(:scene),
|
|
27
|
+
audio: frame.fetch(:audio)
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
FileUtils.mkdir_p(output_path.dirname)
|
|
31
|
+
File.binwrite(output_path, png)
|
|
32
|
+
{ path: output_path, scene: frame.fetch(:scene_name), width: @width, height: @height }
|
|
33
|
+
ensure
|
|
34
|
+
frame_source&.stop
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|