shellfie 0.1.1 → 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/CHANGELOG.md +56 -2
- data/README.md +248 -228
- data/lib/shellfie/animation_frame_builder.rb +202 -0
- data/lib/shellfie/animation_scroll_easing.rb +77 -0
- data/lib/shellfie/animation_timeline.rb +28 -0
- data/lib/shellfie/ansi_colors.rb +94 -0
- data/lib/shellfie/ansi_line_buffer.rb +103 -0
- data/lib/shellfie/ansi_normalizer.rb +61 -0
- data/lib/shellfie/ansi_parser.rb +162 -83
- data/lib/shellfie/cassette.rb +76 -0
- data/lib/shellfie/cli.rb +75 -163
- data/lib/shellfie/cli_authoring.rb +233 -0
- data/lib/shellfie/cli_generate.rb +401 -0
- data/lib/shellfie/cli_info.rb +239 -0
- data/lib/shellfie/cli_run.rb +167 -0
- data/lib/shellfie/config.rb +112 -25
- data/lib/shellfie/config_defaults.rb +83 -0
- data/lib/shellfie/config_validation.rb +289 -0
- data/lib/shellfie/dependency_checker.rb +147 -0
- data/lib/shellfie/errors.rb +12 -1
- data/lib/shellfie/ffmpeg_encoder.rb +46 -0
- data/lib/shellfie/font_resolver.rb +68 -0
- data/lib/shellfie/format_resolver.rb +15 -0
- data/lib/shellfie/gif_generator.rb +231 -89
- data/lib/shellfie/gif_palette.rb +105 -0
- data/lib/shellfie/headless_theme_registry.rb +42 -0
- data/lib/shellfie/html_renderer.rb +54 -0
- data/lib/shellfie/image_magick_command_builder.rb +75 -0
- data/lib/shellfie/line_layout.rb +146 -0
- data/lib/shellfie/output_writer.rb +46 -0
- data/lib/shellfie/parser.rb +183 -30
- data/lib/shellfie/parser_validation.rb +178 -0
- data/lib/shellfie/raster_painter.rb +157 -0
- data/lib/shellfie/render_chrome_cache.rb +40 -0
- data/lib/shellfie/render_geometry.rb +115 -0
- data/lib/shellfie/render_segment.rb +71 -0
- data/lib/shellfie/renderer.rb +96 -149
- data/lib/shellfie/rendering/shape_helpers.rb +42 -0
- data/lib/shellfie/rendering/text_painter.rb +195 -0
- data/lib/shellfie/rendering/window_chrome.rb +196 -0
- data/lib/shellfie/reproducibility_manifest.rb +41 -0
- data/lib/shellfie/session.rb +111 -0
- data/lib/shellfie/session_config.rb +562 -0
- data/lib/shellfie/session_runner.rb +689 -0
- data/lib/shellfie/svg_raster_wrapper.rb +35 -0
- data/lib/shellfie/svg_renderer.rb +222 -0
- data/lib/shellfie/terminal_screen.rb +389 -0
- data/lib/shellfie/text_metrics.rb +155 -0
- data/lib/shellfie/theme_data.rb +80 -0
- data/lib/shellfie/theme_registry.rb +131 -0
- data/lib/shellfie/themes/base.rb +10 -1
- data/lib/shellfie/themes/configured.rb +61 -0
- data/lib/shellfie/themes/macos.rb +3 -1
- data/lib/shellfie/themes/ubuntu.rb +2 -1
- data/lib/shellfie/themes/windows_terminal.rb +7 -1
- data/lib/shellfie/transcript_renderer.rb +92 -0
- data/lib/shellfie/version.rb +1 -1
- data/lib/shellfie/yaml_safety.rb +147 -0
- data/lib/shellfie.rb +44 -3
- data/schema/shellfie-v1.schema.json +153 -0
- data/schema/shellfie-v2.schema.json +262 -0
- metadata +58 -20
- data/.rspec +0 -3
- data/Rakefile +0 -8
- data/examples/animation.yml +0 -33
- data/examples/colored.yml +0 -20
- data/examples/demo.gif +0 -0
- data/examples/demo.png +0 -0
- data/examples/demo_animation.yml +0 -31
- data/examples/headless.png +0 -0
- data/examples/headless.yml +0 -16
- data/examples/scrolling.yml +0 -48
- data/examples/simple.yml +0 -21
- data/examples/theme_macos.png +0 -0
- data/examples/theme_ubuntu.png +0 -0
- data/examples/theme_windows.png +0 -0
- data/shellfie.gemspec +0 -32
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
require "optparse"
|
|
6
|
+
require "tempfile"
|
|
7
|
+
require "yaml"
|
|
8
|
+
|
|
9
|
+
module Shellfie
|
|
10
|
+
module CLIAuthoring
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
def run_new
|
|
14
|
+
options = { template: "static" }
|
|
15
|
+
OptionParser.new do |opts|
|
|
16
|
+
opts.on("--template NAME", "static, animation, run, tui, ci, or theme-gallery") { |name| options[:template] = name }
|
|
17
|
+
opts.on("--force", "Overwrite an existing file") { options[:force] = true }
|
|
18
|
+
end.parse!(@args)
|
|
19
|
+
path = @args.shift
|
|
20
|
+
raise ConfigError, "Output path is required" unless path
|
|
21
|
+
raise ValidationError, "unknown template: #{options[:template]}" unless templates.key?(options[:template])
|
|
22
|
+
raise FileSystemError, "File already exists: #{path} (use --force to overwrite)" if File.exist?(path) && !options[:force]
|
|
23
|
+
|
|
24
|
+
FileUtils.mkdir_p(File.dirname(path)) unless File.dirname(path) == "."
|
|
25
|
+
OutputWriter.write(path, extension: "yml") do |temporary_path|
|
|
26
|
+
File.write(temporary_path, templates.fetch(options[:template]))
|
|
27
|
+
end
|
|
28
|
+
puts "Created: #{path}"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def run_format
|
|
32
|
+
check = false
|
|
33
|
+
OptionParser.new { |opts| opts.on("--check", "Exit unsuccessfully if formatting differs") { check = true } }.parse!(@args)
|
|
34
|
+
path = @args.shift
|
|
35
|
+
raise ConfigError, "Configuration file is required" unless path
|
|
36
|
+
|
|
37
|
+
original = YamlSafety.read_file(path, max_bytes: Parser::MAX_INCLUDE_BYTES)
|
|
38
|
+
normalized = YAML.dump(
|
|
39
|
+
YamlSafety.load_file(path, max_bytes: Parser::MAX_INCLUDE_BYTES, symbolize_names: false)
|
|
40
|
+
)
|
|
41
|
+
if check
|
|
42
|
+
raise ValidationError, "Configuration is not formatted: #{path}" unless original == normalized
|
|
43
|
+
puts "Formatted: #{path}"
|
|
44
|
+
return
|
|
45
|
+
end
|
|
46
|
+
return puts("Unchanged: #{path}") if original == normalized
|
|
47
|
+
|
|
48
|
+
mode = File.stat(path).mode
|
|
49
|
+
temp = Tempfile.new([File.basename(path), ".tmp"], File.dirname(path))
|
|
50
|
+
temp.write(normalized)
|
|
51
|
+
temp.close
|
|
52
|
+
File.chmod(mode, temp.path)
|
|
53
|
+
FileUtils.mv(temp.path, path)
|
|
54
|
+
puts "Formatted: #{path}"
|
|
55
|
+
ensure
|
|
56
|
+
temp&.close!
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def run_compile
|
|
60
|
+
output_format = "json"
|
|
61
|
+
OptionParser.new do |opts|
|
|
62
|
+
opts.on("--format FORMAT", "json or yaml") { |format| output_format = format }
|
|
63
|
+
end.parse!(@args)
|
|
64
|
+
path = @args.shift
|
|
65
|
+
raise ConfigError, "Configuration file is required" unless path
|
|
66
|
+
raise ValidationError, "compile format must be json or yaml" unless %w[json yaml].include?(output_format)
|
|
67
|
+
|
|
68
|
+
version = configuration_version(path)
|
|
69
|
+
value = version == 2 ? SessionConfig.parse(path).to_h : Parser.parse(path).to_h
|
|
70
|
+
puts(output_format == "json" ? JSON.pretty_generate(value) : YAML.dump(value))
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def run_schema
|
|
74
|
+
version = Integer(@args.shift || 1, exception: false)
|
|
75
|
+
raise ValidationError, "schema version must be 1 or 2" unless [1, 2].include?(version)
|
|
76
|
+
|
|
77
|
+
puts File.read(File.expand_path("../../schema/shellfie-v#{version}.schema.json", __dir__))
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def run_completion
|
|
81
|
+
shell = @args.shift || "bash"
|
|
82
|
+
commands = CLI::COMMANDS.join(" ")
|
|
83
|
+
script = case shell
|
|
84
|
+
when "bash" then "complete -W '#{commands}' shellfie shf"
|
|
85
|
+
when "zsh" then "compdef '_arguments \"1:command:(#{commands})\"' shellfie shf"
|
|
86
|
+
when "fish" then commands.split.map { |command| "complete -c shellfie -f -a #{command}" }.join("\n")
|
|
87
|
+
when "powershell", "pwsh"
|
|
88
|
+
<<~POWERSHELL.chomp
|
|
89
|
+
Register-ArgumentCompleter -Native -CommandName shellfie,shf -ScriptBlock {
|
|
90
|
+
param($wordToComplete)
|
|
91
|
+
'#{commands}'.Split(' ') | Where-Object { $_ -like "$wordToComplete*" }
|
|
92
|
+
}
|
|
93
|
+
POWERSHELL
|
|
94
|
+
else raise ValidationError, "completion shell must be bash, zsh, fish, or powershell"
|
|
95
|
+
end
|
|
96
|
+
puts script
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def run_watch
|
|
100
|
+
options = { interval: 0.5 }
|
|
101
|
+
OptionParser.new do |opts|
|
|
102
|
+
opts.on("-o", "--output PATH", "Output path") { |path| options[:output] = path }
|
|
103
|
+
opts.on("--interval SECONDS", Float, "Polling interval") { |value| options[:interval] = value }
|
|
104
|
+
end.parse!(@args)
|
|
105
|
+
input = @args.shift
|
|
106
|
+
raise ConfigError, "Input and -o output are required" unless input && options[:output]
|
|
107
|
+
raise ValidationError, "interval must be positive" unless options[:interval].positive?
|
|
108
|
+
|
|
109
|
+
watched = [File.realpath(input)]
|
|
110
|
+
previous = nil
|
|
111
|
+
loop do
|
|
112
|
+
current = watch_snapshot(watched)
|
|
113
|
+
if current != previous
|
|
114
|
+
begin
|
|
115
|
+
version = configuration_version(input)
|
|
116
|
+
config = version == 2 ? SessionConfig.parse(input) : Parser.parse(input)
|
|
117
|
+
watched = config.source_paths
|
|
118
|
+
command = version == 2 ? "run" : "generate"
|
|
119
|
+
CLI.new([command, input, "-o", options[:output], "--force"]).run
|
|
120
|
+
rescue SystemExit
|
|
121
|
+
nil
|
|
122
|
+
rescue Shellfie::Error => e
|
|
123
|
+
warn_error "Error: #{e.message}"
|
|
124
|
+
end
|
|
125
|
+
previous = watch_snapshot(watched)
|
|
126
|
+
end
|
|
127
|
+
sleep options[:interval]
|
|
128
|
+
end
|
|
129
|
+
rescue Interrupt
|
|
130
|
+
puts "Stopped"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def watch_snapshot(paths)
|
|
134
|
+
paths.to_h do |path|
|
|
135
|
+
modified = File.mtime(path)
|
|
136
|
+
[path, modified]
|
|
137
|
+
rescue SystemCallError
|
|
138
|
+
[path, nil]
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def templates
|
|
143
|
+
@templates ||= {
|
|
144
|
+
"static" => <<~YAML,
|
|
145
|
+
version: 1
|
|
146
|
+
theme: macos
|
|
147
|
+
title: Terminal
|
|
148
|
+
lines:
|
|
149
|
+
- prompt: "$ "
|
|
150
|
+
command: echo hello
|
|
151
|
+
- output: hello
|
|
152
|
+
YAML
|
|
153
|
+
"animation" => <<~YAML,
|
|
154
|
+
version: 1
|
|
155
|
+
theme: macos
|
|
156
|
+
title: Demo
|
|
157
|
+
frames:
|
|
158
|
+
- prompt: "$ "
|
|
159
|
+
type: echo hello
|
|
160
|
+
delay: 500
|
|
161
|
+
- output: hello
|
|
162
|
+
delay: 1000
|
|
163
|
+
YAML
|
|
164
|
+
"run" => <<~YAML,
|
|
165
|
+
version: 2
|
|
166
|
+
mode: run
|
|
167
|
+
title: Recorded shell
|
|
168
|
+
terminal:
|
|
169
|
+
shell: /bin/sh
|
|
170
|
+
columns: 80
|
|
171
|
+
rows: 24
|
|
172
|
+
steps:
|
|
173
|
+
- type: echo hello
|
|
174
|
+
- key: enter
|
|
175
|
+
- expect:
|
|
176
|
+
screen_contains: hello
|
|
177
|
+
exit_status: 0
|
|
178
|
+
outputs:
|
|
179
|
+
- path: session.svg
|
|
180
|
+
format: svg
|
|
181
|
+
YAML
|
|
182
|
+
"tui" => <<~YAML,
|
|
183
|
+
version: 2
|
|
184
|
+
mode: run
|
|
185
|
+
title: TUI capture
|
|
186
|
+
terminal:
|
|
187
|
+
shell: /bin/sh
|
|
188
|
+
columns: 100
|
|
189
|
+
rows: 30
|
|
190
|
+
steps:
|
|
191
|
+
- run: your-tui-command
|
|
192
|
+
async: true
|
|
193
|
+
- wait:
|
|
194
|
+
stable: 500ms
|
|
195
|
+
timeout: 10s
|
|
196
|
+
- capture: ready
|
|
197
|
+
outputs:
|
|
198
|
+
- path: tui.svg
|
|
199
|
+
format: svg
|
|
200
|
+
capture: ready
|
|
201
|
+
YAML
|
|
202
|
+
"ci" => <<~YAML,
|
|
203
|
+
version: 2
|
|
204
|
+
mode: run
|
|
205
|
+
title: CI verification
|
|
206
|
+
terminal:
|
|
207
|
+
shell: /bin/sh
|
|
208
|
+
requires: [ruby]
|
|
209
|
+
steps:
|
|
210
|
+
- run: ruby --version
|
|
211
|
+
visibility: visible
|
|
212
|
+
- expect:
|
|
213
|
+
exit_status: 0
|
|
214
|
+
outputs:
|
|
215
|
+
- path: ci.svg
|
|
216
|
+
format: svg
|
|
217
|
+
YAML
|
|
218
|
+
"theme-gallery" => <<~YAML
|
|
219
|
+
version: 1
|
|
220
|
+
theme: macos
|
|
221
|
+
title: Theme gallery
|
|
222
|
+
lines:
|
|
223
|
+
- prompt: "$ "
|
|
224
|
+
command: shellfie themes
|
|
225
|
+
- output: |-
|
|
226
|
+
macos
|
|
227
|
+
ubuntu
|
|
228
|
+
windows
|
|
229
|
+
YAML
|
|
230
|
+
}
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
end
|
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "optparse"
|
|
5
|
+
require "json"
|
|
6
|
+
require "tmpdir"
|
|
7
|
+
require_relative "output_writer"
|
|
8
|
+
require_relative "reproducibility_manifest"
|
|
9
|
+
|
|
10
|
+
module Shellfie
|
|
11
|
+
module CLIGenerate
|
|
12
|
+
ANIMATED_FORMATS = %w[gif webp apng mp4 webm png-sequence].freeze
|
|
13
|
+
STATIC_FORMATS = %w[png svg svg-raster webp html].freeze
|
|
14
|
+
SEMANTIC_FORMATS = %w[txt ansi json asciicast cast].freeze
|
|
15
|
+
SUPPORTED_FORMATS = (STATIC_FORMATS + ANIMATED_FORMATS + SEMANTIC_FORMATS).uniq.freeze
|
|
16
|
+
ASPECT_PRESETS = {
|
|
17
|
+
"readme" => { width: 800, height: 450 },
|
|
18
|
+
"ogp" => { width: 1200, height: 630 },
|
|
19
|
+
"widescreen" => { width: 1280, height: 720 },
|
|
20
|
+
"standard" => { width: 960, height: 720 },
|
|
21
|
+
"vertical" => { width: 720, height: 1280 }
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def run_generate
|
|
27
|
+
build_generate_parser.parse!(@args)
|
|
28
|
+
input_files = expand_input_paths(@args)
|
|
29
|
+
raise ConfigError, "Input file is required" if input_files.empty?
|
|
30
|
+
raise ConfigError, "Output is required when reading stdin" if !@options[:output] && input_files.include?("-")
|
|
31
|
+
@options[:default_output] = true unless @options[:output]
|
|
32
|
+
raise ConfigError, "stdout output supports only one input file" if @options[:output] == "-" && input_files.size > 1
|
|
33
|
+
raise ConfigError, "--format is required when writing to stdout" if @options[:output] == "-" && !@options[:format]
|
|
34
|
+
raise ConfigError, "--manifest cannot be used when writing output to stdout" if @options[:output] == "-" && @options[:manifest]
|
|
35
|
+
raise ConfigError, "--check cannot write to stdout" if @options[:output] == "-" && @options[:check]
|
|
36
|
+
raise ConfigError, "--check cannot be combined with --force or --manifest" if @options[:check] && (@options[:force] || @options[:manifest])
|
|
37
|
+
configs = input_files.to_h { |input_file| [input_file, apply_overrides(Parser.parse(input_file))] }
|
|
38
|
+
jobs = input_files.map do |input_file|
|
|
39
|
+
config = configs.fetch(input_file)
|
|
40
|
+
animate = animation_output?(config)
|
|
41
|
+
format = @options[:default_output] ? (@options[:format] || (animate ? "gif" : "png")) : output_format_for(@options[:output], animate)
|
|
42
|
+
output_path = output_path_for(input_file, format, multiple: input_files.size > 1, config: config)
|
|
43
|
+
raise ConfigError, "PNG sequence output cannot be written to stdout" if output_path == "-" && format == "png-sequence"
|
|
44
|
+
validate_output_mode!(format, animate)
|
|
45
|
+
[config, animate, format, output_path]
|
|
46
|
+
end
|
|
47
|
+
duplicate = jobs.group_by(&:last).find { |_path, grouped| grouped.size > 1 }&.first
|
|
48
|
+
raise ConfigError, "Multiple inputs resolve to the same output: #{duplicate}" if duplicate
|
|
49
|
+
input_paths = jobs.flat_map { |config, _animate, _format, _output| config.source_paths }
|
|
50
|
+
.concat(input_files.reject { |path| path == "-" })
|
|
51
|
+
.map { |path| canonical_output_path(path) }.uniq
|
|
52
|
+
output_collision = jobs.find do |_config, _animate, _format, output_path|
|
|
53
|
+
output_path != "-" && input_paths.include?(canonical_output_path(output_path))
|
|
54
|
+
end
|
|
55
|
+
raise ConfigError, "Generated output conflicts with an input file: #{output_collision.last}" if output_collision
|
|
56
|
+
directory_collision = jobs.find do |_config, _animate, format, output_path|
|
|
57
|
+
next false unless format == "png-sequence"
|
|
58
|
+
|
|
59
|
+
directory = canonical_output_path(output_path)
|
|
60
|
+
input_paths.any? { |path| path_within?(path, directory) }
|
|
61
|
+
end
|
|
62
|
+
if directory_collision
|
|
63
|
+
raise ConfigError, "PNG sequence output contains an input file: #{directory_collision.last}"
|
|
64
|
+
end
|
|
65
|
+
if @options[:manifest]
|
|
66
|
+
raise ConfigError, "Manifest output cannot be stdout" if @options[:manifest] == "-"
|
|
67
|
+
|
|
68
|
+
manifest_path = canonical_output_path(@options[:manifest])
|
|
69
|
+
collision = jobs.any? do |_config, _animate, _format, output_path|
|
|
70
|
+
output_path != "-" && canonical_output_path(output_path) == manifest_path
|
|
71
|
+
end
|
|
72
|
+
raise ConfigError, "Manifest path conflicts with a generated output: #{@options[:manifest]}" if collision
|
|
73
|
+
raise ConfigError, "Manifest path conflicts with an input file: #{@options[:manifest]}" if input_paths.include?(manifest_path)
|
|
74
|
+
sequence_dirs = jobs.filter_map do |_config, _animate, format, output_path|
|
|
75
|
+
canonical_output_path(output_path) if format == "png-sequence"
|
|
76
|
+
end
|
|
77
|
+
nested = sequence_dirs.any? do |directory|
|
|
78
|
+
path_within?(manifest_path, directory) || path_within?(directory, manifest_path)
|
|
79
|
+
end
|
|
80
|
+
raise ConfigError, "Manifest path conflicts with a PNG sequence directory: #{@options[:manifest]}" if nested
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
preflight_render_dependencies!(jobs.map { |_config, _animate, format, _output_path| format })
|
|
84
|
+
jobs.each do |_config, _animate, format, output_path|
|
|
85
|
+
if format == "png-sequence" && Dir.exist?(output_path) && !replaceable_png_sequence_directory?(output_path)
|
|
86
|
+
raise FileSystemError, "Refusing to replace a non-Shellfie directory: #{output_path}"
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
jobs.each do |_config, _animate, _format, output_path|
|
|
90
|
+
if @options[:check]
|
|
91
|
+
raise FileSystemError, "Output is missing: #{output_path}" unless File.exist?(output_path)
|
|
92
|
+
else
|
|
93
|
+
ensure_output_writable!(output_path)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
ensure_output_writable!(@options[:manifest]) if @options[:manifest]
|
|
97
|
+
manifests = render_jobs(jobs)
|
|
98
|
+
write_manifest(manifests) if @options[:manifest]
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def build_generate_parser
|
|
102
|
+
OptionParser.new do |opts|
|
|
103
|
+
opts.banner = "Usage: shellfie generate INPUT_FILE [options]"
|
|
104
|
+
opts.on("-o", "--output PATH", "Output path or {name}-{theme}-{scale}.{format} template") { |path| @options[:output] = path }
|
|
105
|
+
opts.on("-t", "--theme NAME", "Override theme (macos, ubuntu, windows)") { |theme| @options[:theme] = theme }
|
|
106
|
+
opts.on("-a", "--animate", "Render animated output") { @options[:animate] = true }
|
|
107
|
+
opts.on("-s", "--scale FACTOR", "Output scale (1, 2, 3)") { |scale| @options[:scale] = parse_scale(scale) }
|
|
108
|
+
opts.on("-w", "--width PIXELS", Integer, "Override width") { |width| @options[:width] = width }
|
|
109
|
+
opts.on("--preset NAME", "readme, ogp, widescreen, standard, or vertical") do |name|
|
|
110
|
+
raise ValidationError, "preset must be one of: #{ASPECT_PRESETS.keys.join(', ')}" unless ASPECT_PRESETS.key?(name)
|
|
111
|
+
|
|
112
|
+
@options[:preset] = name
|
|
113
|
+
end
|
|
114
|
+
opts.on("--typing-rate CPS", Integer, "Typing rate in characters per second") do |rate|
|
|
115
|
+
@options[:typing_rate] = parse_rate(rate)
|
|
116
|
+
end
|
|
117
|
+
opts.on("--framerate FPS", Integer, "Output timing precision in frames per second") do |fps|
|
|
118
|
+
@options[:framerate] = parse_framerate(fps)
|
|
119
|
+
end
|
|
120
|
+
opts.on("--fps FPS", Integer, "Deprecated alias for --framerate") do |fps|
|
|
121
|
+
warn_error "Warning: --fps is deprecated; use --framerate"
|
|
122
|
+
@options[:framerate] = parse_framerate(fps)
|
|
123
|
+
end
|
|
124
|
+
opts.on("--seed N", Integer, "Deterministic animation seed") { |seed| @options[:seed] = parse_seed(seed) }
|
|
125
|
+
opts.on("--playback-speed FACTOR", Float, "Playback speed multiplier") do |speed|
|
|
126
|
+
@options[:playback_speed] = parse_playback_speed(speed)
|
|
127
|
+
end
|
|
128
|
+
opts.on("--overflow MODE", "Line overflow mode (clip, wrap, scroll)") { |mode| @options[:overflow] = mode }
|
|
129
|
+
opts.on("--wrap", "Wrap long lines") { @options[:wrap] = true }
|
|
130
|
+
opts.on("--no-wrap", "Clip long lines") { @options[:wrap] = false }
|
|
131
|
+
opts.on("--exact-size", "Make output canvas match the configured window size") { @options[:exact_size] = true }
|
|
132
|
+
opts.on("--no-shadow", "Disable shadow effect") { @options[:shadow] = false }
|
|
133
|
+
opts.on("--transparent", "Transparent background") { @options[:transparent] = true }
|
|
134
|
+
opts.on("--no-header", "Disable window header (headless mode)") { @options[:headless] = true }
|
|
135
|
+
opts.on("--format FORMAT", "Output format (png, gif, svg, svg-raster, webp, apng, mp4, webm, png-sequence, html, txt, ansi, json, asciicast)") { |format| @options[:format] = parse_format(format) }
|
|
136
|
+
opts.on("--force", "Overwrite existing output files") { @options[:force] = true }
|
|
137
|
+
opts.on("--check", "Fail if the existing output differs without replacing it") { @options[:check] = true }
|
|
138
|
+
opts.on("--jobs N", Integer, "Render up to N inputs in parallel (1-32)") { |value| @options[:jobs] = parse_jobs(value) }
|
|
139
|
+
opts.on("--quiet", "Suppress non-error output") { @options[:quiet] = true }
|
|
140
|
+
opts.on("--verbose", "Print extra progress information") { @options[:verbose] = true }
|
|
141
|
+
opts.on("--manifest PATH", "Write a reproducibility manifest") { |path| @options[:manifest] = path }
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def render_jobs(jobs)
|
|
146
|
+
workers = [@options[:jobs] || 1, jobs.size].min
|
|
147
|
+
return jobs.filter_map { |job| render_job(job) } if workers <= 1
|
|
148
|
+
|
|
149
|
+
queue = Queue.new
|
|
150
|
+
jobs.each_with_index { |job, index| queue << [index, job] }
|
|
151
|
+
results = Array.new(jobs.size)
|
|
152
|
+
errors = Queue.new
|
|
153
|
+
Array.new(workers) do
|
|
154
|
+
Thread.new do
|
|
155
|
+
loop do
|
|
156
|
+
index, job = queue.pop(true)
|
|
157
|
+
results[index] = render_job(job)
|
|
158
|
+
rescue ThreadError
|
|
159
|
+
break
|
|
160
|
+
rescue StandardError => e
|
|
161
|
+
errors << e
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end.each(&:join)
|
|
165
|
+
raise errors.pop unless errors.empty?
|
|
166
|
+
|
|
167
|
+
results.compact
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def render_job(job)
|
|
171
|
+
config, animate, format, output_path = job
|
|
172
|
+
if @options[:check]
|
|
173
|
+
check_rendered_output(config, output_path, animate: animate, format: format)
|
|
174
|
+
else
|
|
175
|
+
write_rendered_output(config, output_path, animate: animate, format: format)
|
|
176
|
+
end
|
|
177
|
+
ReproducibilityManifest.build(config, output_path: output_path, format: format) if @options[:manifest]
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def write_rendered_output(config, output_path, animate:, format:, announce: true)
|
|
181
|
+
$stdout.binmode if output_path == "-"
|
|
182
|
+
result = if SEMANTIC_FORMATS.include?(format)
|
|
183
|
+
TranscriptRenderer.new(config).render(output_path, format: format, io: output_path == "-" ? $stdout : nil)
|
|
184
|
+
elsif animate
|
|
185
|
+
generate_animation(config, output_path, format)
|
|
186
|
+
else
|
|
187
|
+
generate_static_image(config, output_path, format)
|
|
188
|
+
end
|
|
189
|
+
$stderr.puts "Generated: #{result}" if announce && output_path != "-" && !@options[:quiet]
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def check_rendered_output(config, output_path, animate:, format:)
|
|
193
|
+
Dir.mktmpdir("shellfie-check") do |dir|
|
|
194
|
+
candidate = format == "png-sequence" ? File.join(dir, "sequence") : File.join(dir, "output.#{format}")
|
|
195
|
+
write_rendered_output(config, candidate, animate: animate, format: format, announce: false)
|
|
196
|
+
expected = ReproducibilityManifest.output_digest(output_path)
|
|
197
|
+
actual = ReproducibilityManifest.output_digest(candidate)
|
|
198
|
+
raise ValidationError, "Generated output is stale: #{output_path}" unless expected == actual
|
|
199
|
+
end
|
|
200
|
+
$stderr.puts "Current: #{output_path}" unless @options[:quiet]
|
|
201
|
+
output_path
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def generate_animation(config, output_path, format)
|
|
205
|
+
warn_verbose "Rendering animation to #{output_path}"
|
|
206
|
+
GifGenerator.new(config).generate(
|
|
207
|
+
output_path,
|
|
208
|
+
scale: @options[:scale] || 1,
|
|
209
|
+
shadow: @options[:shadow] != false,
|
|
210
|
+
transparent: @options[:transparent] || false,
|
|
211
|
+
format: format,
|
|
212
|
+
io: output_path == "-" ? $stdout : nil
|
|
213
|
+
)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def generate_static_image(config, output_path, format)
|
|
217
|
+
warn_verbose "Rendering image to #{output_path}"
|
|
218
|
+
Renderer.new(config).render(
|
|
219
|
+
output_path,
|
|
220
|
+
scale: @options[:scale] || 1,
|
|
221
|
+
shadow: @options[:shadow] != false,
|
|
222
|
+
transparent: @options[:transparent] || false,
|
|
223
|
+
format: format,
|
|
224
|
+
io: output_path == "-" ? $stdout : nil
|
|
225
|
+
)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def apply_overrides(config)
|
|
229
|
+
window_overrides = build_window_overrides
|
|
230
|
+
animation_overrides = build_animation_overrides
|
|
231
|
+
return config if @options.values_at(:theme, :headless).all?(&:nil?) &&
|
|
232
|
+
window_overrides.empty? &&
|
|
233
|
+
animation_overrides.empty?
|
|
234
|
+
|
|
235
|
+
options = config.to_h.merge(
|
|
236
|
+
theme: @options[:theme] || config.theme,
|
|
237
|
+
window: config.window.merge(window_overrides),
|
|
238
|
+
animation: config.animation.merge(animation_overrides),
|
|
239
|
+
lines: config.lines,
|
|
240
|
+
frames: config.frames,
|
|
241
|
+
headless: @options[:headless] || config.headless,
|
|
242
|
+
source_paths: config.source_paths
|
|
243
|
+
)
|
|
244
|
+
Config.new(options)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def build_window_overrides
|
|
248
|
+
(@options[:preset] ? ASPECT_PRESETS.fetch(@options[:preset]).merge(exact_size: true) : {}).tap do |overrides|
|
|
249
|
+
overrides[:width] = @options[:width] if @options[:width]
|
|
250
|
+
overrides[:overflow] = @options[:overflow] if @options[:overflow]
|
|
251
|
+
overrides[:wrap] = @options[:wrap] unless @options[:wrap].nil?
|
|
252
|
+
overrides[:exact_size] = true if @options[:exact_size]
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def build_animation_overrides
|
|
257
|
+
{}.tap do |overrides|
|
|
258
|
+
overrides[:typing_speed] = (1_000.0 / @options[:typing_rate]).round if @options[:typing_rate]
|
|
259
|
+
overrides[:framerate] = @options[:framerate] if @options[:framerate]
|
|
260
|
+
overrides[:playback_speed] = @options[:playback_speed] if @options[:playback_speed]
|
|
261
|
+
overrides[:seed] = @options[:seed] if @options.key?(:seed)
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def parse_scale(value)
|
|
266
|
+
scale = Integer(value, exception: false)
|
|
267
|
+
return scale if [1, 2, 3].include?(scale)
|
|
268
|
+
raise ValidationError, "scale must be 1, 2, or 3"
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def parse_rate(value)
|
|
272
|
+
rate = Integer(value, exception: false)
|
|
273
|
+
return rate if rate && rate.between?(1, 1_000)
|
|
274
|
+
raise ValidationError, "typing rate must be between 1 and 1000"
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def parse_framerate(value)
|
|
278
|
+
fps = Integer(value, exception: false)
|
|
279
|
+
return fps if fps && fps.between?(1, 120)
|
|
280
|
+
raise ValidationError, "framerate must be between 1 and 120"
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def parse_playback_speed(value)
|
|
284
|
+
speed = Float(value, exception: false)
|
|
285
|
+
return speed if speed&.positive? && speed <= 100
|
|
286
|
+
raise ValidationError, "playback speed must be greater than 0 and at most 100"
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def parse_seed(value)
|
|
290
|
+
seed = Integer(value, exception: false)
|
|
291
|
+
return seed if seed&.between?(0, 2_147_483_647)
|
|
292
|
+
|
|
293
|
+
raise ValidationError, "seed must be between 0 and 2147483647"
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def parse_jobs(value)
|
|
297
|
+
jobs = Integer(value, exception: false)
|
|
298
|
+
return jobs if jobs&.between?(1, 32)
|
|
299
|
+
|
|
300
|
+
raise ValidationError, "jobs must be between 1 and 32"
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def parse_format(value)
|
|
304
|
+
format = value.to_s.downcase
|
|
305
|
+
return format if SUPPORTED_FORMATS.include?(format)
|
|
306
|
+
raise ValidationError, "format must be one of: #{SUPPORTED_FORMATS.join(", ")}"
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def validate_output_mode!(format, animate)
|
|
310
|
+
raise ConfigError, "MP4 output does not support transparency" if format == "mp4" && @options[:transparent]
|
|
311
|
+
|
|
312
|
+
if SEMANTIC_FORMATS.include?(format)
|
|
313
|
+
return
|
|
314
|
+
elsif animate && ANIMATED_FORMATS.include?(format)
|
|
315
|
+
return
|
|
316
|
+
elsif !animate && STATIC_FORMATS.include?(format)
|
|
317
|
+
return
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
mode = animate ? "animated" : "static"
|
|
321
|
+
raise ConfigError, "#{mode} output does not support .#{format}"
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def ensure_output_writable!(path)
|
|
325
|
+
return if path == "-"
|
|
326
|
+
|
|
327
|
+
if File.exist?(path) && !@options[:force]
|
|
328
|
+
raise FileSystemError, "Output file already exists: #{path} (use --force to overwrite)"
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
directory = File.dirname(File.expand_path(path))
|
|
332
|
+
directory = File.dirname(directory) until File.exist?(directory)
|
|
333
|
+
return if File.directory?(directory) && File.writable?(directory)
|
|
334
|
+
|
|
335
|
+
raise FileSystemError, "Output directory is not writable: #{directory}"
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def preflight_render_dependencies!(formats)
|
|
339
|
+
DependencyChecker.ensure_imagemagick! if (formats - %w[svg html txt json]).any?
|
|
340
|
+
DependencyChecker.ensure_ffmpeg! if (formats & %w[apng mp4 webm]).any?
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def expand_input_paths(args)
|
|
344
|
+
args.flat_map do |path|
|
|
345
|
+
next path if path == "-"
|
|
346
|
+
|
|
347
|
+
matches = path.match?(/[*?\[]/) ? Dir.glob(path) : [path]
|
|
348
|
+
matches.sort
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
def animation_output?(config)
|
|
353
|
+
@options[:animate] || config.animated?
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
def output_format_for(path, animate)
|
|
357
|
+
return @options[:format] if @options[:format]
|
|
358
|
+
return animate ? "gif" : "png" if path == "-" || batch_directory?(path)
|
|
359
|
+
|
|
360
|
+
extension = File.extname(path).delete_prefix(".").downcase
|
|
361
|
+
extension.empty? ? (animate ? "gif" : "png") : extension
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
def output_path_for(input_file, format, multiple:, config:)
|
|
365
|
+
name = File.basename(input_file, File.extname(input_file))
|
|
366
|
+
if @options[:default_output]
|
|
367
|
+
return File.join(File.dirname(input_file), "#{name}.#{format}")
|
|
368
|
+
end
|
|
369
|
+
return @options[:output] if @options[:output] == "-"
|
|
370
|
+
if @options[:output].include?("{")
|
|
371
|
+
path = @options[:output].gsub("{name}", name)
|
|
372
|
+
.gsub("{theme}", config.theme)
|
|
373
|
+
.gsub("{scale}", (@options[:scale] || 1).to_s)
|
|
374
|
+
.gsub("{format}", format)
|
|
375
|
+
raise ValidationError, "Unknown output template placeholder: #{path[/\{[^}]+\}/]}" if path.match?(/\{[^}]+\}/)
|
|
376
|
+
return path
|
|
377
|
+
end
|
|
378
|
+
return @options[:output] unless multiple || batch_directory?(@options[:output], format)
|
|
379
|
+
|
|
380
|
+
File.join(@options[:output], "#{name}.#{format}")
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
def batch_directory?(path, format = nil)
|
|
384
|
+
path.end_with?(File::SEPARATOR) || (Dir.exist?(path) && format != "png-sequence")
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
def warn_verbose(message)
|
|
388
|
+
$stderr.puts message if @options[:verbose] && !@options[:quiet]
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
def write_manifest(manifests)
|
|
392
|
+
path = @options[:manifest]
|
|
393
|
+
raise FileSystemError, "Manifest already exists: #{path} (use --force to overwrite)" if File.exist?(path) && !@options[:force]
|
|
394
|
+
|
|
395
|
+
FileUtils.mkdir_p(File.dirname(path)) unless File.dirname(path) == "."
|
|
396
|
+
value = manifests.size == 1 ? manifests.first : manifests
|
|
397
|
+
OutputWriter.write(path, extension: "json") { |temporary_path| File.write(temporary_path, JSON.pretty_generate(value)) }
|
|
398
|
+
$stderr.puts "Manifest: #{path}" unless @options[:quiet]
|
|
399
|
+
end
|
|
400
|
+
end
|
|
401
|
+
end
|