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.
Files changed (78) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +56 -2
  3. data/README.md +248 -228
  4. data/lib/shellfie/animation_frame_builder.rb +202 -0
  5. data/lib/shellfie/animation_scroll_easing.rb +77 -0
  6. data/lib/shellfie/animation_timeline.rb +28 -0
  7. data/lib/shellfie/ansi_colors.rb +94 -0
  8. data/lib/shellfie/ansi_line_buffer.rb +103 -0
  9. data/lib/shellfie/ansi_normalizer.rb +61 -0
  10. data/lib/shellfie/ansi_parser.rb +162 -83
  11. data/lib/shellfie/cassette.rb +76 -0
  12. data/lib/shellfie/cli.rb +75 -163
  13. data/lib/shellfie/cli_authoring.rb +233 -0
  14. data/lib/shellfie/cli_generate.rb +401 -0
  15. data/lib/shellfie/cli_info.rb +239 -0
  16. data/lib/shellfie/cli_run.rb +167 -0
  17. data/lib/shellfie/config.rb +112 -25
  18. data/lib/shellfie/config_defaults.rb +83 -0
  19. data/lib/shellfie/config_validation.rb +289 -0
  20. data/lib/shellfie/dependency_checker.rb +147 -0
  21. data/lib/shellfie/errors.rb +12 -1
  22. data/lib/shellfie/ffmpeg_encoder.rb +46 -0
  23. data/lib/shellfie/font_resolver.rb +68 -0
  24. data/lib/shellfie/format_resolver.rb +15 -0
  25. data/lib/shellfie/gif_generator.rb +231 -89
  26. data/lib/shellfie/gif_palette.rb +105 -0
  27. data/lib/shellfie/headless_theme_registry.rb +42 -0
  28. data/lib/shellfie/html_renderer.rb +54 -0
  29. data/lib/shellfie/image_magick_command_builder.rb +75 -0
  30. data/lib/shellfie/line_layout.rb +146 -0
  31. data/lib/shellfie/output_writer.rb +46 -0
  32. data/lib/shellfie/parser.rb +183 -30
  33. data/lib/shellfie/parser_validation.rb +178 -0
  34. data/lib/shellfie/raster_painter.rb +157 -0
  35. data/lib/shellfie/render_chrome_cache.rb +40 -0
  36. data/lib/shellfie/render_geometry.rb +115 -0
  37. data/lib/shellfie/render_segment.rb +71 -0
  38. data/lib/shellfie/renderer.rb +96 -149
  39. data/lib/shellfie/rendering/shape_helpers.rb +42 -0
  40. data/lib/shellfie/rendering/text_painter.rb +195 -0
  41. data/lib/shellfie/rendering/window_chrome.rb +196 -0
  42. data/lib/shellfie/reproducibility_manifest.rb +41 -0
  43. data/lib/shellfie/session.rb +111 -0
  44. data/lib/shellfie/session_config.rb +562 -0
  45. data/lib/shellfie/session_runner.rb +689 -0
  46. data/lib/shellfie/svg_raster_wrapper.rb +35 -0
  47. data/lib/shellfie/svg_renderer.rb +222 -0
  48. data/lib/shellfie/terminal_screen.rb +389 -0
  49. data/lib/shellfie/text_metrics.rb +155 -0
  50. data/lib/shellfie/theme_data.rb +80 -0
  51. data/lib/shellfie/theme_registry.rb +131 -0
  52. data/lib/shellfie/themes/base.rb +10 -1
  53. data/lib/shellfie/themes/configured.rb +61 -0
  54. data/lib/shellfie/themes/macos.rb +3 -1
  55. data/lib/shellfie/themes/ubuntu.rb +2 -1
  56. data/lib/shellfie/themes/windows_terminal.rb +7 -1
  57. data/lib/shellfie/transcript_renderer.rb +92 -0
  58. data/lib/shellfie/version.rb +1 -1
  59. data/lib/shellfie/yaml_safety.rb +147 -0
  60. data/lib/shellfie.rb +44 -3
  61. data/schema/shellfie-v1.schema.json +153 -0
  62. data/schema/shellfie-v2.schema.json +262 -0
  63. metadata +58 -20
  64. data/.rspec +0 -3
  65. data/Rakefile +0 -8
  66. data/examples/animation.yml +0 -33
  67. data/examples/colored.yml +0 -20
  68. data/examples/demo.gif +0 -0
  69. data/examples/demo.png +0 -0
  70. data/examples/demo_animation.yml +0 -31
  71. data/examples/headless.png +0 -0
  72. data/examples/headless.yml +0 -16
  73. data/examples/scrolling.yml +0 -48
  74. data/examples/simple.yml +0 -21
  75. data/examples/theme_macos.png +0 -0
  76. data/examples/theme_ubuntu.png +0 -0
  77. data/examples/theme_windows.png +0 -0
  78. 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