gemba 0.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 +7 -0
- data/THIRD_PARTY_NOTICES +113 -0
- data/assets/JetBrainsMonoNL-Regular.ttf +0 -0
- data/assets/ark-pixel-12px-monospaced-ja.ttf +0 -0
- data/bin/gemba +14 -0
- data/ext/gemba/extconf.rb +185 -0
- data/ext/gemba/gemba_ext.c +1051 -0
- data/ext/gemba/gemba_ext.h +15 -0
- data/gemba.gemspec +38 -0
- data/lib/gemba/child_window.rb +62 -0
- data/lib/gemba/cli.rb +384 -0
- data/lib/gemba/config.rb +621 -0
- data/lib/gemba/core.rb +121 -0
- data/lib/gemba/headless.rb +12 -0
- data/lib/gemba/headless_player.rb +206 -0
- data/lib/gemba/hotkey_map.rb +202 -0
- data/lib/gemba/input_mappings.rb +214 -0
- data/lib/gemba/locale.rb +92 -0
- data/lib/gemba/locales/en.yml +157 -0
- data/lib/gemba/locales/ja.yml +157 -0
- data/lib/gemba/method_coverage_service.rb +265 -0
- data/lib/gemba/overlay_renderer.rb +109 -0
- data/lib/gemba/player.rb +1515 -0
- data/lib/gemba/recorder.rb +156 -0
- data/lib/gemba/recorder_decoder.rb +325 -0
- data/lib/gemba/rom_info_window.rb +346 -0
- data/lib/gemba/rom_loader.rb +100 -0
- data/lib/gemba/runtime.rb +39 -0
- data/lib/gemba/save_state_manager.rb +155 -0
- data/lib/gemba/save_state_picker.rb +199 -0
- data/lib/gemba/settings_window.rb +1173 -0
- data/lib/gemba/tip_service.rb +133 -0
- data/lib/gemba/toast_overlay.rb +128 -0
- data/lib/gemba/version.rb +5 -0
- data/lib/gemba.rb +17 -0
- data/test/fixtures/test.gba +0 -0
- data/test/fixtures/test.sav +0 -0
- data/test/shared/screenshot_helper.rb +113 -0
- data/test/shared/simplecov_config.rb +59 -0
- data/test/shared/teek_test_worker.rb +388 -0
- data/test/shared/tk_test_helper.rb +354 -0
- data/test/support/input_mocks.rb +61 -0
- data/test/support/player_helpers.rb +77 -0
- data/test/test_cli.rb +281 -0
- data/test/test_config.rb +897 -0
- data/test/test_core.rb +401 -0
- data/test/test_gamepad_map.rb +116 -0
- data/test/test_headless_player.rb +205 -0
- data/test/test_helper.rb +19 -0
- data/test/test_hotkey_map.rb +396 -0
- data/test/test_keyboard_map.rb +108 -0
- data/test/test_locale.rb +159 -0
- data/test/test_mgba.rb +26 -0
- data/test/test_overlay_renderer.rb +199 -0
- data/test/test_player.rb +903 -0
- data/test/test_recorder.rb +180 -0
- data/test/test_rom_loader.rb +149 -0
- data/test/test_save_state_manager.rb +289 -0
- data/test/test_settings_hotkeys.rb +434 -0
- data/test/test_settings_window.rb +1039 -0
- data/test/test_tip_service.rb +138 -0
- data/test/test_toast_overlay.rb +216 -0
- data/test/test_virtual_keyboard.rb +39 -0
- data/test/test_xor_delta.rb +61 -0
- metadata +234 -0
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'zlib'
|
|
4
|
+
require 'thread'
|
|
5
|
+
|
|
6
|
+
module Gemba
|
|
7
|
+
# Records emulator video + audio to a .grec file.
|
|
8
|
+
#
|
|
9
|
+
# Video is delta-compressed (XOR with previous frame) then zlib level 1.
|
|
10
|
+
# Audio is stored as raw PCM. A background thread handles disk I/O so
|
|
11
|
+
# the frame loop stays fast.
|
|
12
|
+
#
|
|
13
|
+
# @example
|
|
14
|
+
# recorder = Recorder.new("output.grec", width: 240, height: 160)
|
|
15
|
+
# recorder.start
|
|
16
|
+
# loop do
|
|
17
|
+
# core.run_frame
|
|
18
|
+
# recorder.capture(core.video_buffer_argb, core.audio_buffer)
|
|
19
|
+
# end
|
|
20
|
+
# recorder.stop
|
|
21
|
+
class Recorder
|
|
22
|
+
MAGIC = "GEMBAREC"
|
|
23
|
+
FOOTER_MAGIC = "GEND"
|
|
24
|
+
VERSION = 1
|
|
25
|
+
FLUSH_INTERVAL = 60 # frames between queue flushes (~1s)
|
|
26
|
+
|
|
27
|
+
# @param path [String] output .grec file path
|
|
28
|
+
# @param width [Integer] video width (240 for GBA)
|
|
29
|
+
# @param height [Integer] video height (160 for GBA)
|
|
30
|
+
# @param audio_rate [Integer] audio sample rate (default 44100)
|
|
31
|
+
# @param audio_channels [Integer] audio channels (default 2)
|
|
32
|
+
# @param compression [Integer] zlib compression level 1-9 (default 1 = fastest)
|
|
33
|
+
def initialize(path, width:, height:, audio_rate: 44100, audio_channels: 2,
|
|
34
|
+
compression: Zlib::BEST_SPEED)
|
|
35
|
+
@path = path
|
|
36
|
+
@width = width
|
|
37
|
+
@height = height
|
|
38
|
+
@audio_rate = audio_rate
|
|
39
|
+
@audio_channels = audio_channels
|
|
40
|
+
@compression = compression
|
|
41
|
+
@frame_size = width * height * 4
|
|
42
|
+
@recording = false
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Start recording. Writes header and spawns writer thread.
|
|
46
|
+
def start
|
|
47
|
+
raise "Already recording" if @recording
|
|
48
|
+
@recording = true
|
|
49
|
+
@frame_count = 0
|
|
50
|
+
@prev_frame = ("\0" * @frame_size).b
|
|
51
|
+
@batch = []
|
|
52
|
+
@queue = Thread::Queue.new
|
|
53
|
+
@writer = Thread.new { writer_loop }
|
|
54
|
+
@queue.push(build_header)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Capture one frame of video + audio.
|
|
58
|
+
# @param video_argb [String] raw ARGB8888 pixel data
|
|
59
|
+
# @param audio_pcm [String] raw s16le stereo PCM data
|
|
60
|
+
def capture(video_argb, audio_pcm)
|
|
61
|
+
return unless @recording
|
|
62
|
+
|
|
63
|
+
delta = Gemba.xor_delta(video_argb, @prev_frame)
|
|
64
|
+
@prev_frame = video_argb.dup
|
|
65
|
+
|
|
66
|
+
changed = Gemba.count_changed_pixels(delta)
|
|
67
|
+
total = @width * @height
|
|
68
|
+
change_pct = total > 0 ? (changed * 100 / total).clamp(0, 100) : 0
|
|
69
|
+
|
|
70
|
+
compressed = Zlib::Deflate.deflate(delta, @compression)
|
|
71
|
+
|
|
72
|
+
@batch << [change_pct, compressed, audio_pcm || "".b]
|
|
73
|
+
@frame_count += 1
|
|
74
|
+
|
|
75
|
+
if @batch.length >= FLUSH_INTERVAL
|
|
76
|
+
flush_batch
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Stop recording. Flushes remaining data, writes footer, closes file.
|
|
81
|
+
def stop
|
|
82
|
+
return unless @recording
|
|
83
|
+
@recording = false
|
|
84
|
+
flush_batch unless @batch.empty?
|
|
85
|
+
@queue.push(build_footer)
|
|
86
|
+
@queue.push(:done)
|
|
87
|
+
@writer.join
|
|
88
|
+
@writer = nil
|
|
89
|
+
@queue = nil
|
|
90
|
+
@batch = nil
|
|
91
|
+
@prev_frame = nil
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# @return [Boolean] true if currently recording
|
|
95
|
+
def recording?
|
|
96
|
+
@recording
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# @return [Integer] number of frames captured so far
|
|
100
|
+
def frame_count
|
|
101
|
+
@frame_count || 0
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
def flush_batch
|
|
107
|
+
data = encode_batch(@batch)
|
|
108
|
+
@queue.push(data)
|
|
109
|
+
@batch = []
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def encode_batch(frames)
|
|
113
|
+
buf = String.new(encoding: Encoding::BINARY, capacity: frames.length * 1024)
|
|
114
|
+
frames.each do |change_pct, compressed_video, audio_pcm|
|
|
115
|
+
buf << [change_pct].pack('C')
|
|
116
|
+
buf << [compressed_video.bytesize].pack('V')
|
|
117
|
+
buf << compressed_video
|
|
118
|
+
buf << [audio_pcm.bytesize].pack('V')
|
|
119
|
+
buf << audio_pcm
|
|
120
|
+
end
|
|
121
|
+
buf
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def build_header
|
|
125
|
+
# 32-byte header
|
|
126
|
+
h = String.new(encoding: Encoding::BINARY, capacity: 32)
|
|
127
|
+
h << MAGIC # 8 bytes
|
|
128
|
+
h << [VERSION].pack('C') # 1 byte
|
|
129
|
+
h << [@width, @height].pack('v2') # 4 bytes
|
|
130
|
+
h << [262_144, 4389].pack('V2') # 8 bytes (fps = 262144/4389 ≈ 59.7272)
|
|
131
|
+
h << [@audio_rate].pack('V') # 4 bytes
|
|
132
|
+
h << [@audio_channels, 16].pack('C2') # 2 bytes
|
|
133
|
+
h << ("\0" * 5) # 5 bytes reserved
|
|
134
|
+
h
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def build_footer
|
|
138
|
+
footer = String.new(encoding: Encoding::BINARY, capacity: 8)
|
|
139
|
+
footer << [@frame_count].pack('V') # 4 bytes
|
|
140
|
+
footer << FOOTER_MAGIC # 4 bytes
|
|
141
|
+
footer
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def writer_loop
|
|
145
|
+
File.open(@path, 'wb') do |f|
|
|
146
|
+
loop do
|
|
147
|
+
chunk = @queue.pop
|
|
148
|
+
break if chunk == :done
|
|
149
|
+
f.write(chunk)
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
rescue => e
|
|
153
|
+
warn "gemba: recorder write error: #{e.message}"
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'zlib'
|
|
4
|
+
require 'open3'
|
|
5
|
+
require 'tempfile'
|
|
6
|
+
|
|
7
|
+
module Gemba
|
|
8
|
+
# Decodes a .grec file and encodes it to a playable video via ffmpeg.
|
|
9
|
+
#
|
|
10
|
+
# Two-pass approach to avoid writing massive intermediate files:
|
|
11
|
+
# Pass 1: Extract audio to a small tempfile (~10MB/min), count frames,
|
|
12
|
+
# collect per-frame change percentages.
|
|
13
|
+
# Pass 2: Decode video frames one at a time and pipe to ffmpeg's stdin.
|
|
14
|
+
#
|
|
15
|
+
# Only one decoded video frame is in memory at a time, so RAM usage stays
|
|
16
|
+
# constant regardless of recording length.
|
|
17
|
+
#
|
|
18
|
+
# @example
|
|
19
|
+
# info = RecorderDecoder.decode("recording.grec", "output.mp4")
|
|
20
|
+
# puts "Encoded #{info[:frame_count]} frames to #{info[:output_path]}"
|
|
21
|
+
#
|
|
22
|
+
# @example Quick stats without encoding
|
|
23
|
+
# stats = RecorderDecoder.stats("recording.grec")
|
|
24
|
+
# puts "#{stats[:frame_count]} frames, avg #{stats[:avg_change_pct].round(1)}% change"
|
|
25
|
+
class RecorderDecoder
|
|
26
|
+
class FormatError < StandardError; end
|
|
27
|
+
class FfmpegNotFound < StandardError; end
|
|
28
|
+
|
|
29
|
+
DEFAULT_VIDEO_CODEC = 'libx264'
|
|
30
|
+
DEFAULT_AUDIO_CODEC = 'aac'
|
|
31
|
+
|
|
32
|
+
# Quick scan of a .grec file — no ffmpeg needed.
|
|
33
|
+
# Reads header + per-frame change bytes, skips video/audio data.
|
|
34
|
+
# @param trec_path [String]
|
|
35
|
+
# @return [Hash] :frame_count, :width, :height, :fps, :duration,
|
|
36
|
+
# :avg_change_pct, :raw_video_size, :audio_rate, :audio_channels
|
|
37
|
+
def self.stats(trec_path)
|
|
38
|
+
new(trec_path, nil).stats
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Decode a .grec file and encode to a playable video file.
|
|
42
|
+
# @param trec_path [String] path to .grec file
|
|
43
|
+
# @param output_path [String] output video path (e.g. "out.mp4", "out.mkv")
|
|
44
|
+
# @param video_codec [String] ffmpeg video codec (default: libx264)
|
|
45
|
+
# @param audio_codec [String] ffmpeg audio codec (default: aac)
|
|
46
|
+
# @param scale [Integer, nil] output scale factor (nil = native)
|
|
47
|
+
# @param ffmpeg_args [Array<String>, nil] raw ffmpeg output args (overrides codecs)
|
|
48
|
+
# @param progress [Boolean] show encoding progress (default: true)
|
|
49
|
+
# @return [Hash] :output_path, :frame_count, :width, :height, :fps,
|
|
50
|
+
# :avg_change_pct, :raw_video_size, :audio_rate, :audio_channels
|
|
51
|
+
def self.decode(trec_path, output_path, video_codec: DEFAULT_VIDEO_CODEC,
|
|
52
|
+
audio_codec: DEFAULT_AUDIO_CODEC, scale: nil,
|
|
53
|
+
ffmpeg_args: nil, progress: true)
|
|
54
|
+
new(trec_path, output_path,
|
|
55
|
+
video_codec: video_codec, audio_codec: audio_codec,
|
|
56
|
+
scale: scale, ffmpeg_args: ffmpeg_args, progress: progress).decode
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def initialize(trec_path, output_path, video_codec: DEFAULT_VIDEO_CODEC,
|
|
60
|
+
audio_codec: DEFAULT_AUDIO_CODEC, scale: nil,
|
|
61
|
+
ffmpeg_args: nil, progress: true)
|
|
62
|
+
@trec_path = trec_path
|
|
63
|
+
@output_path = output_path
|
|
64
|
+
@video_codec = video_codec
|
|
65
|
+
@audio_codec = audio_codec
|
|
66
|
+
@scale = scale
|
|
67
|
+
@ffmpeg_args = ffmpeg_args
|
|
68
|
+
@progress = progress
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Quick stats scan — reads only header + 1 byte per frame.
|
|
72
|
+
def stats
|
|
73
|
+
header = nil
|
|
74
|
+
frame_count = 0
|
|
75
|
+
total_change = 0
|
|
76
|
+
|
|
77
|
+
File.open(@trec_path, 'rb') do |f|
|
|
78
|
+
header = read_header(f)
|
|
79
|
+
|
|
80
|
+
until f.eof?
|
|
81
|
+
break if at_footer?(f)
|
|
82
|
+
|
|
83
|
+
change_pct = f.read(1)&.unpack1('C') or break
|
|
84
|
+
total_change += change_pct
|
|
85
|
+
|
|
86
|
+
video_len = read_u32(f) or break
|
|
87
|
+
f.seek(video_len, IO::SEEK_CUR)
|
|
88
|
+
|
|
89
|
+
audio_len = read_u32(f) or break
|
|
90
|
+
f.seek(audio_len, IO::SEEK_CUR)
|
|
91
|
+
|
|
92
|
+
frame_count += 1
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
fps = header[:fps_num].to_f / header[:fps_den]
|
|
97
|
+
frame_size = header[:width] * header[:height] * 4
|
|
98
|
+
|
|
99
|
+
{
|
|
100
|
+
frame_count: frame_count,
|
|
101
|
+
width: header[:width],
|
|
102
|
+
height: header[:height],
|
|
103
|
+
fps: fps,
|
|
104
|
+
duration: frame_count / fps,
|
|
105
|
+
avg_change_pct: frame_count > 0 ? total_change.to_f / frame_count : 0,
|
|
106
|
+
raw_video_size: frame_count * frame_size,
|
|
107
|
+
audio_rate: header[:audio_rate],
|
|
108
|
+
audio_channels: header[:audio_channels],
|
|
109
|
+
}
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def decode
|
|
113
|
+
check_ffmpeg!
|
|
114
|
+
|
|
115
|
+
header = nil
|
|
116
|
+
frame_count = 0
|
|
117
|
+
total_change = 0
|
|
118
|
+
|
|
119
|
+
# Pass 1: parse header, extract audio to tempfile, count frames.
|
|
120
|
+
# Video chunks are skipped (seek, not read) to keep this fast.
|
|
121
|
+
audio_tmp = Tempfile.new(['trec_audio', '.raw'])
|
|
122
|
+
audio_tmp.binmode
|
|
123
|
+
|
|
124
|
+
File.open(@trec_path, 'rb') do |f|
|
|
125
|
+
header = read_header(f)
|
|
126
|
+
|
|
127
|
+
until f.eof?
|
|
128
|
+
break if at_footer?(f)
|
|
129
|
+
|
|
130
|
+
change_pct = f.read(1)&.unpack1('C') or break
|
|
131
|
+
total_change += change_pct
|
|
132
|
+
|
|
133
|
+
video_len = read_u32(f) or break
|
|
134
|
+
f.seek(video_len, IO::SEEK_CUR)
|
|
135
|
+
|
|
136
|
+
audio_len = read_u32(f) or break
|
|
137
|
+
audio_tmp.write(f.read(audio_len)) if audio_len > 0
|
|
138
|
+
|
|
139
|
+
frame_count += 1
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
audio_tmp.flush
|
|
144
|
+
fps = header[:fps_num].to_f / header[:fps_den]
|
|
145
|
+
frame_size = header[:width] * header[:height] * 4
|
|
146
|
+
|
|
147
|
+
# Pass 2: decode video frames and pipe to ffmpeg.
|
|
148
|
+
encode(header, fps, audio_tmp.path, frame_count)
|
|
149
|
+
|
|
150
|
+
{
|
|
151
|
+
output_path: @output_path,
|
|
152
|
+
frame_count: frame_count,
|
|
153
|
+
width: header[:width],
|
|
154
|
+
height: header[:height],
|
|
155
|
+
fps: fps,
|
|
156
|
+
avg_change_pct: frame_count > 0 ? total_change.to_f / frame_count : 0,
|
|
157
|
+
raw_video_size: frame_count * frame_size,
|
|
158
|
+
audio_rate: header[:audio_rate],
|
|
159
|
+
audio_channels: header[:audio_channels],
|
|
160
|
+
}
|
|
161
|
+
ensure
|
|
162
|
+
audio_tmp&.close!
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
private
|
|
166
|
+
|
|
167
|
+
def check_ffmpeg!
|
|
168
|
+
Open3.capture2e('ffmpeg', '-version')
|
|
169
|
+
rescue Errno::ENOENT
|
|
170
|
+
raise FfmpegNotFound, "ffmpeg not found in PATH"
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Pipe decoded video frames to ffmpeg stdin while ffmpeg reads audio
|
|
174
|
+
# from the tempfile. One frame in memory at a time.
|
|
175
|
+
def encode(header, fps, audio_path, total_frames)
|
|
176
|
+
frame_size = header[:width] * header[:height] * 4
|
|
177
|
+
prev_frame = ("\0" * frame_size).b
|
|
178
|
+
cmd = build_ffmpeg_cmd(header, fps, audio_path)
|
|
179
|
+
|
|
180
|
+
Open3.popen3(*cmd) do |stdin, stdout, stderr, wait_thr|
|
|
181
|
+
err_reader = Thread.new { stderr.read }
|
|
182
|
+
progress_reader = @progress ? start_progress_reader(stdout, total_frames) : nil
|
|
183
|
+
|
|
184
|
+
File.open(@trec_path, 'rb') do |f|
|
|
185
|
+
f.seek(32) # skip header
|
|
186
|
+
|
|
187
|
+
until f.eof?
|
|
188
|
+
break if at_footer?(f)
|
|
189
|
+
|
|
190
|
+
f.seek(1, IO::SEEK_CUR) # skip change_pct byte
|
|
191
|
+
|
|
192
|
+
# Decode one video frame
|
|
193
|
+
video_len = read_u32(f) or break
|
|
194
|
+
compressed = f.read(video_len)
|
|
195
|
+
delta = Zlib::Inflate.inflate(compressed)
|
|
196
|
+
frame = Gemba.xor_delta(delta, prev_frame)
|
|
197
|
+
prev_frame = frame
|
|
198
|
+
stdin.write(frame)
|
|
199
|
+
|
|
200
|
+
# Skip audio (already extracted in pass 1)
|
|
201
|
+
audio_len = read_u32(f) or break
|
|
202
|
+
f.seek(audio_len, IO::SEEK_CUR) if audio_len > 0
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
stdin.close
|
|
207
|
+
progress_reader&.join
|
|
208
|
+
$stderr.print "\r\e[K" if @progress
|
|
209
|
+
status = wait_thr.value
|
|
210
|
+
unless status.success?
|
|
211
|
+
raise "ffmpeg failed (exit #{status.exitstatus}): #{err_reader.value}"
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Read ffmpeg's -progress output on stdout and print a progress line.
|
|
217
|
+
def start_progress_reader(stdout, total_frames)
|
|
218
|
+
Thread.new do
|
|
219
|
+
current_frame = 0
|
|
220
|
+
encode_fps = 0.0
|
|
221
|
+
last_print = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
222
|
+
|
|
223
|
+
stdout.each_line do |line|
|
|
224
|
+
case line
|
|
225
|
+
when /\Aframe=(\d+)/
|
|
226
|
+
current_frame = $1.to_i
|
|
227
|
+
when /\Afps=([\d.]+)/
|
|
228
|
+
encode_fps = $1.to_f
|
|
229
|
+
when /\Aprogress=/
|
|
230
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
231
|
+
is_done = line.start_with?('progress=end')
|
|
232
|
+
if is_done || now - last_print >= 0.5
|
|
233
|
+
pct = total_frames > 0 ? (current_frame * 100.0 / total_frames) : 0
|
|
234
|
+
fps_str = encode_fps > 0 ? " @ #{'%.1f' % encode_fps} fps" : ""
|
|
235
|
+
$stderr.print "\rEncoding: #{current_frame}/#{total_frames} " \
|
|
236
|
+
"(#{'%.1f' % pct}%)#{fps_str}\e[K"
|
|
237
|
+
last_print = now
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Build the ffmpeg argument list.
|
|
245
|
+
#
|
|
246
|
+
# Video input (pipe:0): raw frames in BGRA pixel order.
|
|
247
|
+
# mGBA's video_buffer_argb stores each pixel as a uint32 0xAARRGGBB;
|
|
248
|
+
# on little-endian systems the byte layout is B-G-R-A, so ffmpeg
|
|
249
|
+
# must be told -pix_fmt bgra (not argb).
|
|
250
|
+
#
|
|
251
|
+
# Audio input: raw PCM, signed 16-bit little-endian, stereo interleaved
|
|
252
|
+
# (LRLRLR...) at the GBA's native sample rate (44100 Hz).
|
|
253
|
+
#
|
|
254
|
+
# -pix_fmt yuv420p on output ensures broad player/browser compatibility.
|
|
255
|
+
def build_ffmpeg_cmd(header, fps, audio_path)
|
|
256
|
+
cmd = %W[
|
|
257
|
+
ffmpeg -y -loglevel error
|
|
258
|
+
-f rawvideo -pix_fmt bgra
|
|
259
|
+
-s #{header[:width]}x#{header[:height]}
|
|
260
|
+
-r #{format('%.4f', fps)}
|
|
261
|
+
-i pipe:0
|
|
262
|
+
-f s16le -ar #{header[:audio_rate]}
|
|
263
|
+
-ac #{header[:audio_channels]}
|
|
264
|
+
-i #{audio_path}
|
|
265
|
+
]
|
|
266
|
+
if @scale && @scale > 1
|
|
267
|
+
w = header[:width] * @scale
|
|
268
|
+
h = header[:height] * @scale
|
|
269
|
+
# Use -sws_flags instead of scale=W:H:flags=neighbor — the inline
|
|
270
|
+
# flags= syntax produced vertically-oriented output on ffmpeg 7.1.1.
|
|
271
|
+
cmd.push('-vf', "scale=#{w}:#{h}",
|
|
272
|
+
'-sws_flags', 'neighbor')
|
|
273
|
+
end
|
|
274
|
+
if @ffmpeg_args
|
|
275
|
+
cmd.concat(@ffmpeg_args)
|
|
276
|
+
else
|
|
277
|
+
cmd.push('-c:v', @video_codec, '-pix_fmt', 'yuv420p',
|
|
278
|
+
'-c:a', @audio_codec)
|
|
279
|
+
end
|
|
280
|
+
cmd.push('-progress', 'pipe:1') if @progress
|
|
281
|
+
cmd.push(@output_path)
|
|
282
|
+
cmd
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def read_header(f)
|
|
286
|
+
raw = f.read(32)
|
|
287
|
+
raise FormatError, "File too small for header" unless raw && raw.bytesize == 32
|
|
288
|
+
|
|
289
|
+
magic = raw[0, 8]
|
|
290
|
+
raise FormatError, "Invalid magic: #{magic.inspect}" unless magic == Recorder::MAGIC
|
|
291
|
+
|
|
292
|
+
version = raw[8].unpack1('C')
|
|
293
|
+
raise FormatError, "Unsupported version: #{version}" unless version == Recorder::VERSION
|
|
294
|
+
|
|
295
|
+
width, height = raw[9, 4].unpack('v2')
|
|
296
|
+
fps_num, fps_den = raw[13, 8].unpack('V2')
|
|
297
|
+
audio_rate = raw[21, 4].unpack1('V')
|
|
298
|
+
audio_channels, audio_bits = raw[25, 2].unpack('C2')
|
|
299
|
+
|
|
300
|
+
{ width: width, height: height,
|
|
301
|
+
fps_num: fps_num, fps_den: fps_den,
|
|
302
|
+
audio_rate: audio_rate,
|
|
303
|
+
audio_channels: audio_channels,
|
|
304
|
+
audio_bits: audio_bits }
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
# Check if we're at the 8-byte footer. If not, rewind to where we were.
|
|
308
|
+
def at_footer?(f)
|
|
309
|
+
pos = f.pos
|
|
310
|
+
marker = f.read(8)
|
|
311
|
+
if marker && marker.bytesize == 8
|
|
312
|
+
_, magic = marker.unpack('Va4')
|
|
313
|
+
return true if magic == Recorder::FOOTER_MAGIC
|
|
314
|
+
end
|
|
315
|
+
f.seek(pos)
|
|
316
|
+
false
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def read_u32(f)
|
|
320
|
+
raw = f.read(4)
|
|
321
|
+
return nil unless raw && raw.bytesize == 4
|
|
322
|
+
raw.unpack1('V')
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
end
|