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,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