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,15 @@
|
|
|
1
|
+
#ifndef GEMBA_EXT_H
|
|
2
|
+
#define GEMBA_EXT_H
|
|
3
|
+
|
|
4
|
+
#include <ruby.h>
|
|
5
|
+
#include <mgba/core/core.h>
|
|
6
|
+
#include <mgba/core/config.h>
|
|
7
|
+
#include <mgba/core/directories.h>
|
|
8
|
+
#include <mgba/core/log.h>
|
|
9
|
+
#include <mgba-util/vfs.h>
|
|
10
|
+
|
|
11
|
+
extern VALUE mGemba;
|
|
12
|
+
|
|
13
|
+
void Init_gemba_ext(void);
|
|
14
|
+
|
|
15
|
+
#endif /* GEMBA_EXT_H */
|
data/gemba.gemspec
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
require_relative "lib/gemba/version"
|
|
2
|
+
|
|
3
|
+
Gem::Specification.new do |spec|
|
|
4
|
+
spec.name = "gemba"
|
|
5
|
+
spec.version = Gemba::VERSION
|
|
6
|
+
spec.authors = ["James Cook"]
|
|
7
|
+
spec.email = ["jcook.rubyist@gmail.com"]
|
|
8
|
+
|
|
9
|
+
spec.summary = "GBA emulator frontend powered by teek and libmgba"
|
|
10
|
+
spec.description = "Wraps libmgba's mCore C API and provides a full-featured GBA player with SDL2 rendering, input, save states, and a Tk-based settings UI"
|
|
11
|
+
spec.homepage = "https://github.com/jamescook/gemba"
|
|
12
|
+
spec.licenses = ["MIT"]
|
|
13
|
+
|
|
14
|
+
spec.files = Dir.glob("{lib,ext,test,assets,bin}/**/*").select { |f|
|
|
15
|
+
File.file?(f) && f !~ /\.(bundle|so|o|log)$/ &&
|
|
16
|
+
!f.include?('.dSYM/') && File.basename(f) != 'Makefile'
|
|
17
|
+
} + %w[gemba.gemspec THIRD_PARTY_NOTICES]
|
|
18
|
+
spec.bindir = "bin"
|
|
19
|
+
spec.executables = ["gemba"]
|
|
20
|
+
spec.require_paths = ["lib"]
|
|
21
|
+
spec.extensions = ["ext/gemba/extconf.rb"]
|
|
22
|
+
spec.required_ruby_version = ">= 3.2"
|
|
23
|
+
|
|
24
|
+
spec.add_dependency "teek", ">= 0.1.2"
|
|
25
|
+
spec.add_dependency "teek-sdl2", ">= 0.1.3"
|
|
26
|
+
|
|
27
|
+
spec.add_development_dependency "rake", "~> 13.0"
|
|
28
|
+
spec.add_development_dependency "rake-compiler", "~> 1.0"
|
|
29
|
+
spec.add_development_dependency "minitest", "~> 6.0"
|
|
30
|
+
spec.add_development_dependency "method_source", "~> 1.0"
|
|
31
|
+
spec.add_development_dependency "simplecov", "~> 0.22"
|
|
32
|
+
spec.add_development_dependency "listen", "~> 3.0"
|
|
33
|
+
|
|
34
|
+
spec.requirements << "libmgba development headers"
|
|
35
|
+
spec.add_development_dependency "rubyzip", ">= 2.4"
|
|
36
|
+
|
|
37
|
+
spec.requirements << "rubyzip gem >= 2.4 (optional, for loading ROMs from .zip files)"
|
|
38
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gemba
|
|
4
|
+
# Shared helpers for child windows (Settings, Save State Picker, ROM Info).
|
|
5
|
+
#
|
|
6
|
+
# Including classes must define a TOP constant and an @app instance variable.
|
|
7
|
+
# Modal windows (grab focus) use show_window/hide_window. Non-modal windows
|
|
8
|
+
# (like ROM Info) use show_window(modal: false)/hide_window(modal: false).
|
|
9
|
+
module ChildWindow
|
|
10
|
+
# Create and configure a toplevel window with standard boilerplate.
|
|
11
|
+
# Hides the window at the end — call show_window to reveal it.
|
|
12
|
+
#
|
|
13
|
+
# @param title [String]
|
|
14
|
+
# @param geometry [String, nil] e.g. '700x390'
|
|
15
|
+
def build_toplevel(title, geometry: nil)
|
|
16
|
+
top = self.class::TOP
|
|
17
|
+
@app.command(:toplevel, top)
|
|
18
|
+
@app.command(:wm, 'title', top, title)
|
|
19
|
+
@app.command(:wm, 'geometry', top, geometry) if geometry
|
|
20
|
+
@app.command(:wm, 'resizable', top, 0, 0)
|
|
21
|
+
@app.command(:wm, 'transient', top, '.')
|
|
22
|
+
# macOS has a single app-wide menu bar — share the parent's menubar
|
|
23
|
+
# so it doesn't revert to Tk's default "wish" menu when this window
|
|
24
|
+
# has focus. On other platforms each window has its own menu bar,
|
|
25
|
+
# so sharing creates a visible duplicate.
|
|
26
|
+
if Teek.platform.darwin?
|
|
27
|
+
parent_menu = @app.command('.', :cget, '-menu') rescue nil
|
|
28
|
+
@app.command(top, :configure, menu: parent_menu) if parent_menu && !parent_menu.empty?
|
|
29
|
+
end
|
|
30
|
+
@app.command(:wm, 'protocol', top, 'WM_DELETE_WINDOW', proc { hide })
|
|
31
|
+
yield if block_given?
|
|
32
|
+
@app.command(:wm, 'withdraw', top)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Position this window to the right of the main application window.
|
|
36
|
+
def position_near_parent
|
|
37
|
+
top = self.class::TOP
|
|
38
|
+
x, y, w, _h = @app.interp.window_geometry('.')
|
|
39
|
+
@app.command(:wm, 'geometry', top, "+#{x + w + 12}+#{y}")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Reveal the window, optionally grabbing focus (modal).
|
|
43
|
+
def show_window(modal: true)
|
|
44
|
+
top = self.class::TOP
|
|
45
|
+
position_near_parent
|
|
46
|
+
@app.command(:wm, 'deiconify', top)
|
|
47
|
+
@app.command(:raise, top)
|
|
48
|
+
if modal
|
|
49
|
+
@app.command(:grab, :set, top)
|
|
50
|
+
@app.command(:focus, top)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Withdraw the window, release grab if modal, fire on_close callback.
|
|
55
|
+
def hide_window(modal: true)
|
|
56
|
+
top = self.class::TOP
|
|
57
|
+
@app.command(:grab, :release, top) if modal
|
|
58
|
+
@app.command(:wm, 'withdraw', top)
|
|
59
|
+
@callbacks[:on_close]&.call if defined?(@callbacks)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
data/lib/gemba/cli.rb
ADDED
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'optparse'
|
|
4
|
+
require_relative 'version'
|
|
5
|
+
|
|
6
|
+
module Gemba
|
|
7
|
+
class CLI
|
|
8
|
+
SUBCOMMANDS = %w[record decode info].freeze
|
|
9
|
+
|
|
10
|
+
# Entry point: dispatch to subcommand or player.
|
|
11
|
+
# @param argv [Array<String>]
|
|
12
|
+
def self.run(argv = ARGV)
|
|
13
|
+
args = argv.dup
|
|
14
|
+
|
|
15
|
+
case args.first
|
|
16
|
+
when 'record'
|
|
17
|
+
args.shift
|
|
18
|
+
run_record(args)
|
|
19
|
+
when 'decode'
|
|
20
|
+
args.shift
|
|
21
|
+
run_decode(args)
|
|
22
|
+
when 'info'
|
|
23
|
+
args.shift
|
|
24
|
+
run_info(args)
|
|
25
|
+
else
|
|
26
|
+
run_player(args)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Parse player (default) command options.
|
|
31
|
+
# @param argv [Array<String>]
|
|
32
|
+
# @return [Hash]
|
|
33
|
+
def self.parse(argv)
|
|
34
|
+
options = {}
|
|
35
|
+
|
|
36
|
+
parser = OptionParser.new do |o|
|
|
37
|
+
o.banner = "Usage: gemba [options] [ROM_FILE]"
|
|
38
|
+
o.separator ""
|
|
39
|
+
o.separator "GBA emulator powered by teek + libmgba"
|
|
40
|
+
o.separator ""
|
|
41
|
+
o.separator "Commands:"
|
|
42
|
+
o.separator " record Record video+audio to .grec (headless)"
|
|
43
|
+
o.separator " decode Encode .grec to video via ffmpeg"
|
|
44
|
+
o.separator " info Show .grec recording stats"
|
|
45
|
+
o.separator ""
|
|
46
|
+
o.separator "Player options:"
|
|
47
|
+
|
|
48
|
+
o.on("-s", "--scale N", Integer, "Window scale (1-4)") do |v|
|
|
49
|
+
options[:scale] = v.clamp(1, 4)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
o.on("-v", "--volume N", Integer, "Volume (0-100)") do |v|
|
|
53
|
+
options[:volume] = v.clamp(0, 100)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
o.on("-m", "--mute", "Start muted") do
|
|
57
|
+
options[:mute] = true
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
o.on("--no-sound", "Disable audio entirely") do
|
|
61
|
+
options[:sound] = false
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
o.on("-f", "--fullscreen", "Start in fullscreen") do
|
|
65
|
+
options[:fullscreen] = true
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
o.on("--show-fps", "Show FPS counter") do
|
|
69
|
+
options[:show_fps] = true
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
o.on("--turbo-speed N", Integer, "Fast-forward speed (0=uncapped, 2-4)") do |v|
|
|
73
|
+
options[:turbo_speed] = v.clamp(0, 4)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
o.on("--locale LANG", "Language (en, ja, auto)") do |v|
|
|
77
|
+
options[:locale] = v
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
o.on("--headless", "Run without GUI (requires --frames and ROM)") do
|
|
81
|
+
options[:headless] = true
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
o.on("--frames N", Integer, "Run N frames then exit (requires ROM)") do |v|
|
|
85
|
+
options[:frames] = v
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
o.on("--reset-config", "Delete settings file and exit (keeps saves)") do
|
|
89
|
+
options[:reset_config] = true
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
o.on("-y", "--yes", "Skip confirmation prompts") do
|
|
93
|
+
options[:yes] = true
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
o.on("--version", "Show version") do
|
|
97
|
+
options[:version] = true
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
o.on("-h", "--help", "Show this help") do
|
|
101
|
+
options[:help] = true
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
parser.parse!(argv)
|
|
106
|
+
options[:rom] = File.expand_path(argv.first) if argv.first
|
|
107
|
+
options[:parser] = parser
|
|
108
|
+
options
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Apply parsed CLI options to the user config (session-only overrides).
|
|
112
|
+
# @param config [Gemba::Config]
|
|
113
|
+
# @param options [Hash]
|
|
114
|
+
def self.apply(config, options)
|
|
115
|
+
config.scale = options[:scale] if options[:scale]
|
|
116
|
+
config.volume = options[:volume] if options[:volume]
|
|
117
|
+
config.muted = true if options[:mute]
|
|
118
|
+
config.show_fps = true if options[:show_fps]
|
|
119
|
+
config.turbo_speed = options[:turbo_speed] if options[:turbo_speed]
|
|
120
|
+
config.locale = options[:locale] if options[:locale]
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# --- Player (default command) ---
|
|
124
|
+
|
|
125
|
+
def self.run_player(argv)
|
|
126
|
+
options = parse(argv)
|
|
127
|
+
|
|
128
|
+
if options[:help]
|
|
129
|
+
puts options[:parser]
|
|
130
|
+
return
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
if options[:version]
|
|
134
|
+
puts "gemba #{Gemba::VERSION}"
|
|
135
|
+
return
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
require "gemba"
|
|
139
|
+
|
|
140
|
+
if options[:reset_config]
|
|
141
|
+
path = Config.default_path
|
|
142
|
+
unless File.exist?(path)
|
|
143
|
+
puts "No config file found at #{path}"
|
|
144
|
+
return
|
|
145
|
+
end
|
|
146
|
+
unless options[:yes]
|
|
147
|
+
print "Delete #{path}? [y/N] "
|
|
148
|
+
return unless $stdin.gets&.strip&.downcase == 'y'
|
|
149
|
+
end
|
|
150
|
+
Config.reset!(path: path)
|
|
151
|
+
puts "Deleted #{path}"
|
|
152
|
+
return
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
if options[:headless]
|
|
156
|
+
unless options[:frames] && options[:rom]
|
|
157
|
+
$stderr.puts "Error: --headless requires --frames N and a ROM file"
|
|
158
|
+
exit 1
|
|
159
|
+
end
|
|
160
|
+
require "gemba/headless"
|
|
161
|
+
HeadlessPlayer.open(options[:rom]) { |p| p.step(options[:frames]) }
|
|
162
|
+
return
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
if options[:frames] && !options[:rom]
|
|
166
|
+
$stderr.puts "Error: --frames requires a ROM file"
|
|
167
|
+
exit 1
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
apply(Gemba.user_config, options)
|
|
171
|
+
Gemba.load_locale if options[:locale]
|
|
172
|
+
|
|
173
|
+
sound = options.fetch(:sound, true)
|
|
174
|
+
Player.new(options[:rom], sound: sound, fullscreen: options[:fullscreen],
|
|
175
|
+
frames: options[:frames]).run
|
|
176
|
+
end
|
|
177
|
+
private_class_method :run_player
|
|
178
|
+
|
|
179
|
+
# --- record subcommand ---
|
|
180
|
+
|
|
181
|
+
def self.parse_record(argv)
|
|
182
|
+
options = {}
|
|
183
|
+
|
|
184
|
+
parser = OptionParser.new do |o|
|
|
185
|
+
o.banner = "Usage: gemba record [options] ROM_FILE"
|
|
186
|
+
o.separator ""
|
|
187
|
+
o.separator "Record video+audio to a .grec file (headless, no GUI)"
|
|
188
|
+
o.separator ""
|
|
189
|
+
|
|
190
|
+
o.on("--frames N", Integer, "Number of frames to record (required)") do |v|
|
|
191
|
+
options[:frames] = v
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
o.on("-o", "--output PATH", "Output .grec path (default: ROM_ID.grec)") do |v|
|
|
195
|
+
options[:output] = v
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
o.on("-c", "--compression N", Integer, "Zlib level 1-9 (default: 1, 6+ has diminishing returns)") do |v|
|
|
199
|
+
options[:compression] = v.clamp(1, 9)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
o.on("--progress", "Show recording progress") do
|
|
203
|
+
options[:progress] = true
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
o.on("-h", "--help", "Show this help") do
|
|
207
|
+
options[:help] = true
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
parser.parse!(argv)
|
|
212
|
+
options[:rom] = File.expand_path(argv.first) if argv.first
|
|
213
|
+
options[:parser] = parser
|
|
214
|
+
options
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def self.run_record(argv)
|
|
218
|
+
options = parse_record(argv)
|
|
219
|
+
|
|
220
|
+
if options[:help]
|
|
221
|
+
puts options[:parser]
|
|
222
|
+
return
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
unless options[:frames] && options[:rom]
|
|
226
|
+
$stderr.puts "Error: record requires --frames N and a ROM file"
|
|
227
|
+
$stderr.puts "Run 'gemba record --help' for usage"
|
|
228
|
+
exit 1
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
require "gemba/headless"
|
|
232
|
+
|
|
233
|
+
total = options[:frames]
|
|
234
|
+
|
|
235
|
+
HeadlessPlayer.open(options[:rom]) do |player|
|
|
236
|
+
rec_path = options[:output] ||
|
|
237
|
+
"#{Config.rom_id(player.game_code, player.checksum)}.grec"
|
|
238
|
+
|
|
239
|
+
rec_opts = {}
|
|
240
|
+
rec_opts[:compression] = options[:compression] if options[:compression]
|
|
241
|
+
player.start_recording(rec_path, **rec_opts)
|
|
242
|
+
|
|
243
|
+
if options[:progress]
|
|
244
|
+
last_print = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
245
|
+
player.step(total) do |frame|
|
|
246
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
247
|
+
if frame == total || now - last_print >= 0.5
|
|
248
|
+
pct = frame * 100.0 / total
|
|
249
|
+
$stderr.print "\rRecording: #{frame}/#{total} (#{'%.1f' % pct}%)\e[K"
|
|
250
|
+
last_print = now
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
$stderr.print "\r\e[K"
|
|
254
|
+
else
|
|
255
|
+
player.step(total)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
player.stop_recording
|
|
259
|
+
|
|
260
|
+
info = RecorderDecoder.stats(rec_path)
|
|
261
|
+
puts "Recorded #{info[:frame_count]} frames to #{rec_path}"
|
|
262
|
+
puts " Duration: #{'%.1f' % info[:duration]}s"
|
|
263
|
+
puts " Avg change: #{'%.1f' % info[:avg_change_pct]}%/frame"
|
|
264
|
+
puts " Uncompressed: #{format_size(info[:raw_video_size])} (encode input)"
|
|
265
|
+
puts " .grec size: #{format_size(File.size(rec_path))}"
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
private_class_method :run_record
|
|
269
|
+
|
|
270
|
+
# --- decode subcommand ---
|
|
271
|
+
|
|
272
|
+
def self.parse_decode(argv)
|
|
273
|
+
options = {}
|
|
274
|
+
|
|
275
|
+
parser = OptionParser.new do |o|
|
|
276
|
+
o.banner = "Usage: gemba decode [options] TREC_FILE [-- FFMPEG_ARGS...]"
|
|
277
|
+
o.separator ""
|
|
278
|
+
o.separator "Encode a .grec recording to a playable video via ffmpeg."
|
|
279
|
+
o.separator "Args after -- replace the default codec flags (-c:v, -c:a, etc)."
|
|
280
|
+
o.separator ""
|
|
281
|
+
|
|
282
|
+
o.on("-o", "--output PATH", "Output path (default: INPUT.mp4)") do |v|
|
|
283
|
+
options[:output] = v
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
o.on("--video-codec CODEC", "Video codec (default: libx264)") do |v|
|
|
287
|
+
options[:video_codec] = v
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
o.on("--audio-codec CODEC", "Audio codec (default: aac)") do |v|
|
|
291
|
+
options[:audio_codec] = v
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
o.on("-s", "--scale N", Integer, "Scale factor (default: native)") do |v|
|
|
295
|
+
options[:scale] = v.clamp(1, 10)
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
o.on("--no-progress", "Disable progress indicator") do
|
|
299
|
+
options[:progress] = false
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
o.on("-h", "--help", "Show this help") do
|
|
303
|
+
options[:help] = true
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
parser.parse!(argv)
|
|
308
|
+
options[:trec] = argv.shift
|
|
309
|
+
options[:ffmpeg_args] = argv unless argv.empty?
|
|
310
|
+
options[:parser] = parser
|
|
311
|
+
options
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def self.run_decode(argv)
|
|
315
|
+
options = parse_decode(argv)
|
|
316
|
+
|
|
317
|
+
if options[:help]
|
|
318
|
+
puts options[:parser]
|
|
319
|
+
return
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
unless options[:trec]
|
|
323
|
+
$stderr.puts "Error: decode requires a .grec file"
|
|
324
|
+
$stderr.puts "Run 'gemba decode --help' for usage"
|
|
325
|
+
exit 1
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
require "gemba/headless"
|
|
329
|
+
|
|
330
|
+
trec_path = options[:trec]
|
|
331
|
+
output_path = options[:output] || trec_path.sub(/\.grec\z/, '') + '.mp4'
|
|
332
|
+
codec_opts = {}
|
|
333
|
+
codec_opts[:video_codec] = options[:video_codec] if options[:video_codec]
|
|
334
|
+
codec_opts[:audio_codec] = options[:audio_codec] if options[:audio_codec]
|
|
335
|
+
codec_opts[:scale] = options[:scale] if options[:scale]
|
|
336
|
+
codec_opts[:ffmpeg_args] = options[:ffmpeg_args] if options[:ffmpeg_args]
|
|
337
|
+
codec_opts[:progress] = options.fetch(:progress, true)
|
|
338
|
+
|
|
339
|
+
info = RecorderDecoder.decode(trec_path, output_path, **codec_opts)
|
|
340
|
+
puts "Encoded #{info[:frame_count]} frames " \
|
|
341
|
+
"(#{info[:width]}x#{info[:height]} @ #{'%.2f' % info[:fps]} fps, " \
|
|
342
|
+
"avg #{'%.1f' % info[:avg_change_pct]}% change/frame)"
|
|
343
|
+
puts "Output: #{info[:output_path]}"
|
|
344
|
+
end
|
|
345
|
+
private_class_method :run_decode
|
|
346
|
+
|
|
347
|
+
# --- info subcommand ---
|
|
348
|
+
|
|
349
|
+
def self.run_info(argv)
|
|
350
|
+
if argv.include?('--help') || argv.include?('-h') || argv.empty?
|
|
351
|
+
puts "Usage: gemba info TREC_FILE"
|
|
352
|
+
puts ""
|
|
353
|
+
puts "Show recording stats (no ffmpeg needed)"
|
|
354
|
+
return
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
require "gemba/headless"
|
|
358
|
+
|
|
359
|
+
trec_path = argv.first
|
|
360
|
+
info = RecorderDecoder.stats(trec_path)
|
|
361
|
+
|
|
362
|
+
puts "Recording: #{trec_path}"
|
|
363
|
+
puts " Frames: #{info[:frame_count]}"
|
|
364
|
+
puts " Resolution: #{info[:width]}x#{info[:height]}"
|
|
365
|
+
puts " FPS: #{'%.2f' % info[:fps]}"
|
|
366
|
+
puts " Duration: #{'%.1f' % info[:duration]}s"
|
|
367
|
+
puts " Avg change: #{'%.1f' % info[:avg_change_pct]}%/frame"
|
|
368
|
+
puts " Uncompressed: #{format_size(info[:raw_video_size])} (encode input)"
|
|
369
|
+
puts " Audio: #{info[:audio_rate]} Hz, #{info[:audio_channels]}ch"
|
|
370
|
+
end
|
|
371
|
+
private_class_method :run_info
|
|
372
|
+
|
|
373
|
+
def self.format_size(bytes)
|
|
374
|
+
if bytes >= 1_073_741_824
|
|
375
|
+
"#{'%.1f' % (bytes / 1_073_741_824.0)} GB"
|
|
376
|
+
elsif bytes >= 1_048_576
|
|
377
|
+
"#{'%.1f' % (bytes / 1_048_576.0)} MB"
|
|
378
|
+
else
|
|
379
|
+
"#{'%.1f' % (bytes / 1024.0)} KB"
|
|
380
|
+
end
|
|
381
|
+
end
|
|
382
|
+
private_class_method :format_size
|
|
383
|
+
end
|
|
384
|
+
end
|