shellfie 1.0.0 → 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 +258 -97
- data/lib/shellfie/animation_frame_builder.rb +31 -7
- data/lib/shellfie/animation_timeline.rb +2 -1
- data/lib/shellfie/ansi_line_buffer.rb +20 -4
- data/lib/shellfie/ansi_normalizer.rb +18 -8
- data/lib/shellfie/ansi_parser.rb +120 -7
- data/lib/shellfie/cassette.rb +76 -0
- data/lib/shellfie/cli.rb +65 -2
- data/lib/shellfie/cli_authoring.rb +233 -0
- data/lib/shellfie/cli_generate.rb +244 -40
- data/lib/shellfie/cli_info.rb +106 -6
- data/lib/shellfie/cli_run.rb +167 -0
- data/lib/shellfie/config.rb +5 -1
- data/lib/shellfie/config_defaults.rb +21 -2
- data/lib/shellfie/config_validation.rb +93 -4
- data/lib/shellfie/dependency_checker.rb +74 -3
- data/lib/shellfie/errors.rb +1 -0
- data/lib/shellfie/ffmpeg_encoder.rb +46 -0
- data/lib/shellfie/font_resolver.rb +11 -1
- data/lib/shellfie/gif_generator.rb +157 -11
- data/lib/shellfie/gif_palette.rb +8 -4
- data/lib/shellfie/html_renderer.rb +54 -0
- data/lib/shellfie/line_layout.rb +19 -10
- data/lib/shellfie/output_writer.rb +7 -2
- data/lib/shellfie/parser.rb +82 -19
- data/lib/shellfie/parser_validation.rb +48 -15
- data/lib/shellfie/render_geometry.rb +2 -1
- data/lib/shellfie/render_segment.rb +17 -5
- data/lib/shellfie/renderer.rb +28 -11
- data/lib/shellfie/rendering/text_painter.rb +23 -15
- data/lib/shellfie/rendering/window_chrome.rb +2 -2
- 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_renderer.rb +222 -0
- data/lib/shellfie/terminal_screen.rb +389 -0
- data/lib/shellfie/text_metrics.rb +74 -15
- 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 +8 -1
- data/schema/shellfie-v1.schema.json +153 -0
- data/schema/shellfie-v2.schema.json +262 -0
- metadata +27 -24
- data/.rspec +0 -3
- data/Rakefile +0 -8
- data/docs/.nojekyll +0 -0
- data/docs/index.html +0 -205
- data/docs/scripts.js +0 -85
- data/docs/styles.css +0 -507
- 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
|
|
@@ -2,12 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
require "fileutils"
|
|
4
4
|
require "optparse"
|
|
5
|
+
require "json"
|
|
6
|
+
require "tmpdir"
|
|
7
|
+
require_relative "output_writer"
|
|
8
|
+
require_relative "reproducibility_manifest"
|
|
5
9
|
|
|
6
10
|
module Shellfie
|
|
7
11
|
module CLIGenerate
|
|
8
|
-
ANIMATED_FORMATS = %w[gif webp apng].freeze
|
|
9
|
-
STATIC_FORMATS = %w[png svg webp].freeze
|
|
10
|
-
|
|
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
|
|
11
23
|
|
|
12
24
|
private
|
|
13
25
|
|
|
@@ -15,29 +27,104 @@ module Shellfie
|
|
|
15
27
|
build_generate_parser.parse!(@args)
|
|
16
28
|
input_files = expand_input_paths(@args)
|
|
17
29
|
raise ConfigError, "Input file is required" if input_files.empty?
|
|
18
|
-
raise ConfigError, "Output
|
|
30
|
+
raise ConfigError, "Output is required when reading stdin" if !@options[:output] && input_files.include?("-")
|
|
31
|
+
@options[:default_output] = true unless @options[:output]
|
|
19
32
|
raise ConfigError, "stdout output supports only one input file" if @options[:output] == "-" && input_files.size > 1
|
|
20
33
|
raise ConfigError, "--format is required when writing to stdout" if @options[:output] == "-" && !@options[:format]
|
|
21
|
-
|
|
22
|
-
|
|
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)
|
|
23
40
|
animate = animation_output?(config)
|
|
24
|
-
format = output_format_for(@options[:output], animate)
|
|
25
|
-
output_path = output_path_for(input_file, format, multiple: input_files.size > 1)
|
|
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"
|
|
26
44
|
validate_output_mode!(format, animate)
|
|
27
|
-
|
|
28
|
-
|
|
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}"
|
|
29
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]
|
|
30
99
|
end
|
|
31
100
|
|
|
32
101
|
def build_generate_parser
|
|
33
102
|
OptionParser.new do |opts|
|
|
34
103
|
opts.banner = "Usage: shellfie generate INPUT_FILE [options]"
|
|
35
|
-
opts.on("-o", "--output PATH", "Output
|
|
104
|
+
opts.on("-o", "--output PATH", "Output path or {name}-{theme}-{scale}.{format} template") { |path| @options[:output] = path }
|
|
36
105
|
opts.on("-t", "--theme NAME", "Override theme (macos, ubuntu, windows)") { |theme| @options[:theme] = theme }
|
|
37
|
-
opts.on("-a", "--animate", "
|
|
106
|
+
opts.on("-a", "--animate", "Render animated output") { @options[:animate] = true }
|
|
38
107
|
opts.on("-s", "--scale FACTOR", "Output scale (1, 2, 3)") { |scale| @options[:scale] = parse_scale(scale) }
|
|
39
108
|
opts.on("-w", "--width PIXELS", Integer, "Override width") { |width| @options[:width] = width }
|
|
40
|
-
opts.on("--
|
|
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
|
|
41
128
|
opts.on("--overflow MODE", "Line overflow mode (clip, wrap, scroll)") { |mode| @options[:overflow] = mode }
|
|
42
129
|
opts.on("--wrap", "Wrap long lines") { @options[:wrap] = true }
|
|
43
130
|
opts.on("--no-wrap", "Clip long lines") { @options[:wrap] = false }
|
|
@@ -45,21 +132,73 @@ module Shellfie
|
|
|
45
132
|
opts.on("--no-shadow", "Disable shadow effect") { @options[:shadow] = false }
|
|
46
133
|
opts.on("--transparent", "Transparent background") { @options[:transparent] = true }
|
|
47
134
|
opts.on("--no-header", "Disable window header (headless mode)") { @options[:headless] = true }
|
|
48
|
-
opts.on("--format FORMAT", "Output format (png, gif, svg, webp, apng)") { |format| @options[:format] = parse_format(format) }
|
|
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) }
|
|
49
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) }
|
|
50
139
|
opts.on("--quiet", "Suppress non-error output") { @options[:quiet] = true }
|
|
51
140
|
opts.on("--verbose", "Print extra progress information") { @options[:verbose] = true }
|
|
141
|
+
opts.on("--manifest PATH", "Write a reproducibility manifest") { |path| @options[:manifest] = path }
|
|
52
142
|
end
|
|
53
143
|
end
|
|
54
144
|
|
|
55
|
-
def
|
|
56
|
-
|
|
57
|
-
if
|
|
58
|
-
|
|
59
|
-
|
|
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)
|
|
60
174
|
else
|
|
61
|
-
|
|
175
|
+
write_rendered_output(config, output_path, animate: animate, format: format)
|
|
62
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
|
|
63
202
|
end
|
|
64
203
|
|
|
65
204
|
def generate_animation(config, output_path, format)
|
|
@@ -69,7 +208,8 @@ module Shellfie
|
|
|
69
208
|
scale: @options[:scale] || 1,
|
|
70
209
|
shadow: @options[:shadow] != false,
|
|
71
210
|
transparent: @options[:transparent] || false,
|
|
72
|
-
format: format
|
|
211
|
+
format: format,
|
|
212
|
+
io: output_path == "-" ? $stdout : nil
|
|
73
213
|
)
|
|
74
214
|
end
|
|
75
215
|
|
|
@@ -80,7 +220,8 @@ module Shellfie
|
|
|
80
220
|
scale: @options[:scale] || 1,
|
|
81
221
|
shadow: @options[:shadow] != false,
|
|
82
222
|
transparent: @options[:transparent] || false,
|
|
83
|
-
format: format
|
|
223
|
+
format: format,
|
|
224
|
+
io: output_path == "-" ? $stdout : nil
|
|
84
225
|
)
|
|
85
226
|
end
|
|
86
227
|
|
|
@@ -97,13 +238,14 @@ module Shellfie
|
|
|
97
238
|
animation: config.animation.merge(animation_overrides),
|
|
98
239
|
lines: config.lines,
|
|
99
240
|
frames: config.frames,
|
|
100
|
-
headless: @options[:headless] || config.headless
|
|
241
|
+
headless: @options[:headless] || config.headless,
|
|
242
|
+
source_paths: config.source_paths
|
|
101
243
|
)
|
|
102
244
|
Config.new(options)
|
|
103
245
|
end
|
|
104
246
|
|
|
105
247
|
def build_window_overrides
|
|
106
|
-
{}.tap do |overrides|
|
|
248
|
+
(@options[:preset] ? ASPECT_PRESETS.fetch(@options[:preset]).merge(exact_size: true) : {}).tap do |overrides|
|
|
107
249
|
overrides[:width] = @options[:width] if @options[:width]
|
|
108
250
|
overrides[:overflow] = @options[:overflow] if @options[:overflow]
|
|
109
251
|
overrides[:wrap] = @options[:wrap] unless @options[:wrap].nil?
|
|
@@ -113,7 +255,10 @@ module Shellfie
|
|
|
113
255
|
|
|
114
256
|
def build_animation_overrides
|
|
115
257
|
{}.tap do |overrides|
|
|
116
|
-
overrides[:typing_speed] = (1_000.0 / @options[:
|
|
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)
|
|
117
262
|
end
|
|
118
263
|
end
|
|
119
264
|
|
|
@@ -123,10 +268,36 @@ module Shellfie
|
|
|
123
268
|
raise ValidationError, "scale must be 1, 2, or 3"
|
|
124
269
|
end
|
|
125
270
|
|
|
126
|
-
def
|
|
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)
|
|
127
278
|
fps = Integer(value, exception: false)
|
|
128
|
-
return fps if fps && fps.between?(1,
|
|
129
|
-
raise ValidationError, "
|
|
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"
|
|
130
301
|
end
|
|
131
302
|
|
|
132
303
|
def parse_format(value)
|
|
@@ -136,7 +307,11 @@ module Shellfie
|
|
|
136
307
|
end
|
|
137
308
|
|
|
138
309
|
def validate_output_mode!(format, animate)
|
|
139
|
-
if
|
|
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)
|
|
140
315
|
return
|
|
141
316
|
elsif !animate && STATIC_FORMATS.include?(format)
|
|
142
317
|
return
|
|
@@ -149,11 +324,20 @@ module Shellfie
|
|
|
149
324
|
def ensure_output_writable!(path)
|
|
150
325
|
return if path == "-"
|
|
151
326
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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)
|
|
155
334
|
|
|
156
|
-
raise FileSystemError, "Output
|
|
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?
|
|
157
341
|
end
|
|
158
342
|
|
|
159
343
|
def expand_input_paths(args)
|
|
@@ -166,8 +350,6 @@ module Shellfie
|
|
|
166
350
|
end
|
|
167
351
|
|
|
168
352
|
def animation_output?(config)
|
|
169
|
-
return true if ANIMATED_FORMATS.include?(@options[:format])
|
|
170
|
-
|
|
171
353
|
@options[:animate] || config.animated?
|
|
172
354
|
end
|
|
173
355
|
|
|
@@ -179,19 +361,41 @@ module Shellfie
|
|
|
179
361
|
extension.empty? ? (animate ? "gif" : "png") : extension
|
|
180
362
|
end
|
|
181
363
|
|
|
182
|
-
def output_path_for(input_file, format, multiple:)
|
|
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
|
|
183
369
|
return @options[:output] if @options[:output] == "-"
|
|
184
|
-
|
|
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)
|
|
185
379
|
|
|
186
|
-
File.join(@options[:output], "#{
|
|
380
|
+
File.join(@options[:output], "#{name}.#{format}")
|
|
187
381
|
end
|
|
188
382
|
|
|
189
|
-
def batch_directory?(path)
|
|
190
|
-
path.end_with?(File::SEPARATOR) || Dir.exist?(path)
|
|
383
|
+
def batch_directory?(path, format = nil)
|
|
384
|
+
path.end_with?(File::SEPARATOR) || (Dir.exist?(path) && format != "png-sequence")
|
|
191
385
|
end
|
|
192
386
|
|
|
193
387
|
def warn_verbose(message)
|
|
194
388
|
$stderr.puts message if @options[:verbose] && !@options[:quiet]
|
|
195
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
|
|
196
400
|
end
|
|
197
401
|
end
|