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.
Files changed (66) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +56 -2
  3. data/README.md +258 -97
  4. data/lib/shellfie/animation_frame_builder.rb +31 -7
  5. data/lib/shellfie/animation_timeline.rb +2 -1
  6. data/lib/shellfie/ansi_line_buffer.rb +20 -4
  7. data/lib/shellfie/ansi_normalizer.rb +18 -8
  8. data/lib/shellfie/ansi_parser.rb +120 -7
  9. data/lib/shellfie/cassette.rb +76 -0
  10. data/lib/shellfie/cli.rb +65 -2
  11. data/lib/shellfie/cli_authoring.rb +233 -0
  12. data/lib/shellfie/cli_generate.rb +244 -40
  13. data/lib/shellfie/cli_info.rb +106 -6
  14. data/lib/shellfie/cli_run.rb +167 -0
  15. data/lib/shellfie/config.rb +5 -1
  16. data/lib/shellfie/config_defaults.rb +21 -2
  17. data/lib/shellfie/config_validation.rb +93 -4
  18. data/lib/shellfie/dependency_checker.rb +74 -3
  19. data/lib/shellfie/errors.rb +1 -0
  20. data/lib/shellfie/ffmpeg_encoder.rb +46 -0
  21. data/lib/shellfie/font_resolver.rb +11 -1
  22. data/lib/shellfie/gif_generator.rb +157 -11
  23. data/lib/shellfie/gif_palette.rb +8 -4
  24. data/lib/shellfie/html_renderer.rb +54 -0
  25. data/lib/shellfie/line_layout.rb +19 -10
  26. data/lib/shellfie/output_writer.rb +7 -2
  27. data/lib/shellfie/parser.rb +82 -19
  28. data/lib/shellfie/parser_validation.rb +48 -15
  29. data/lib/shellfie/render_geometry.rb +2 -1
  30. data/lib/shellfie/render_segment.rb +17 -5
  31. data/lib/shellfie/renderer.rb +28 -11
  32. data/lib/shellfie/rendering/text_painter.rb +23 -15
  33. data/lib/shellfie/rendering/window_chrome.rb +2 -2
  34. data/lib/shellfie/reproducibility_manifest.rb +41 -0
  35. data/lib/shellfie/session.rb +111 -0
  36. data/lib/shellfie/session_config.rb +562 -0
  37. data/lib/shellfie/session_runner.rb +689 -0
  38. data/lib/shellfie/svg_renderer.rb +222 -0
  39. data/lib/shellfie/terminal_screen.rb +389 -0
  40. data/lib/shellfie/text_metrics.rb +74 -15
  41. data/lib/shellfie/transcript_renderer.rb +92 -0
  42. data/lib/shellfie/version.rb +1 -1
  43. data/lib/shellfie/yaml_safety.rb +147 -0
  44. data/lib/shellfie.rb +8 -1
  45. data/schema/shellfie-v1.schema.json +153 -0
  46. data/schema/shellfie-v2.schema.json +262 -0
  47. metadata +27 -24
  48. data/.rspec +0 -3
  49. data/Rakefile +0 -8
  50. data/docs/.nojekyll +0 -0
  51. data/docs/index.html +0 -205
  52. data/docs/scripts.js +0 -85
  53. data/docs/styles.css +0 -507
  54. data/examples/animation.yml +0 -33
  55. data/examples/colored.yml +0 -20
  56. data/examples/demo.gif +0 -0
  57. data/examples/demo.png +0 -0
  58. data/examples/demo_animation.yml +0 -31
  59. data/examples/headless.png +0 -0
  60. data/examples/headless.yml +0 -16
  61. data/examples/scrolling.yml +0 -48
  62. data/examples/simple.yml +0 -21
  63. data/examples/theme_macos.png +0 -0
  64. data/examples/theme_ubuntu.png +0 -0
  65. data/examples/theme_windows.png +0 -0
  66. 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
- SUPPORTED_FORMATS = %w[png gif svg webp apng].freeze
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 file is required (use -o option)" unless @options[: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
- input_files.each do |input_file|
22
- config = apply_overrides(Parser.parse(input_file))
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
- ensure_output_writable!(output_path)
28
- write_rendered_output(config, output_path, animate: animate, format: format)
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 file path (required)") { |path| @options[:output] = path }
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", "Generate animated GIF") { @options[:animate] = true }
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("--fps FPS", Integer, "Typing speed override for animations") { |fps| @options[:fps] = parse_fps(fps) }
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 write_rendered_output(config, output_path, animate:, format:)
56
- result = animate ? generate_animation(config, output_path, format) : generate_static_image(config, output_path, format)
57
- if output_path == "-"
58
- $stdout.binmode
59
- $stdout.write(result)
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
- puts "Generated: #{result}" unless @options[:quiet]
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[:fps]).round if @options[:fps]
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 parse_fps(value)
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, 60)
129
- raise ValidationError, "fps must be between 1 and 60"
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 animate && ANIMATED_FORMATS.include?(format)
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
- directory = File.dirname(path)
153
- FileUtils.mkdir_p(directory) unless directory == "." || Dir.exist?(directory)
154
- return if @options[:force] || !File.exist?(path)
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 file already exists: #{path} (use --force to overwrite)"
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
- return @options[:output] unless multiple || batch_directory?(@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)
185
379
 
186
- File.join(@options[:output], "#{File.basename(input_file, File.extname(input_file))}.#{format}")
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