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
@@ -1,5 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
4
+ require "optparse"
5
+ require "yaml"
6
+ require "cgi/escape"
7
+
3
8
  module Shellfie
4
9
  module CLIInfo
5
10
  private
@@ -7,6 +12,7 @@ module Shellfie
7
12
  def run_init
8
13
  puts <<~YAML
9
14
  # Shellfie configuration file
15
+ version: 1
10
16
  theme: macos
11
17
  title: "Terminal — zsh"
12
18
 
@@ -42,10 +48,32 @@ module Shellfie
42
48
  end
43
49
 
44
50
  def run_validate
51
+ format = "text"
52
+ OptionParser.new { |opts| opts.on("--format FORMAT", "text, json, sarif, or junit") { |value| format = value } }.parse!(@args)
53
+ raise ValidationError, "validation format must be text, json, sarif, or junit" unless %w[text json sarif junit].include?(format)
54
+ @options[:validation_format] = format
45
55
  input_file = @args.shift
46
56
  raise ConfigError, "Input file is required" unless input_file
57
+ @options[:validation_path] = input_file
58
+
59
+ if configuration_version(input_file) == 2
60
+ session = SessionConfig.parse(input_file)
61
+ return emit_validation_report(valid: true, path: input_file, details: { version: 2, steps: session.steps.size, outputs: session.outputs.size }) if format != "text"
62
+
63
+ puts "✓ Session configuration is valid"
64
+ puts " Steps: #{session.steps.size}"
65
+ puts " Outputs: #{session.outputs.size}"
66
+ return
67
+ end
47
68
 
48
69
  config = Parser.parse(input_file)
70
+ if format != "text"
71
+ return emit_validation_report(
72
+ valid: true, path: input_file,
73
+ details: { version: 1, theme: config.theme, mode: config.animated? ? "animated" : "static" }
74
+ )
75
+ end
76
+
49
77
  puts "✓ Configuration is valid"
50
78
  puts " Theme: #{config.theme}"
51
79
  puts " Title: #{config.title}"
@@ -59,10 +87,38 @@ module Shellfie
59
87
  end
60
88
 
61
89
  def run_inspect
90
+ json = false
91
+ OptionParser.new { |opts| opts.on("--json", "Print machine-readable JSON") { json = true } }.parse!(@args)
62
92
  input_file = @args.shift
63
93
  raise ConfigError, "Input file is required" unless input_file
64
94
 
95
+ if configuration_version(input_file) == 2
96
+ session = SessionConfig.parse(input_file)
97
+ info = {
98
+ config: session.to_h,
99
+ mode: session.mode,
100
+ terminal: session.terminal,
101
+ steps: session.steps.size,
102
+ outputs: session.outputs
103
+ }
104
+ return puts(JSON.pretty_generate(info)) if json
105
+
106
+ puts "Session:"
107
+ puts " Version: 2"
108
+ puts " Mode: #{session.mode}"
109
+ puts " Terminal: #{session.terminal[:columns]}x#{session.terminal[:rows]} (#{session.terminal[:shell]})"
110
+ puts " Steps: #{session.steps.size}"
111
+ puts " Outputs: #{session.outputs.size}"
112
+ return
113
+ end
114
+
65
115
  info = Shellfie.inspect_config(input_file)
116
+ info[:unicode] = {
117
+ version: TextMetrics::UNICODE_VERSION,
118
+ width_table: TextMetrics::WIDTH_TABLE_VERSION,
119
+ ambiguous_width: info.dig(:config, :window, :ambiguous_width) || 1
120
+ }
121
+ return puts(JSON.pretty_generate(info)) if json
66
122
  puts "Config:"
67
123
  puts " Version: #{info[:config][:version]}"
68
124
  puts " Theme: #{info[:theme]}"
@@ -72,6 +128,11 @@ module Shellfie
72
128
  puts " Frames: #{info[:config][:frames].size}"
73
129
  puts " Estimated size: #{info[:geometry][:canvas_width]}x#{info[:geometry][:canvas_height]}"
74
130
  puts " Logical size: #{info[:geometry][:logical_width]}x#{info[:geometry][:logical_height]} @#{info[:geometry][:scale]}x"
131
+ puts " Unicode: #{info[:unicode][:version]} (width table #{info[:unicode][:width_table]}, ambiguous=#{info[:unicode][:ambiguous_width]})"
132
+ info.fetch(:fonts, {}).each do |style, font|
133
+ fingerprint = font[:sha256] ? " (sha256: #{font[:sha256]})" : ""
134
+ puts " Font #{style}: #{font[:name] || "not found"}#{fingerprint}"
135
+ end
75
136
  end
76
137
 
77
138
  def run_doctor
@@ -88,15 +149,49 @@ module Shellfie
88
149
  puts "shellfie #{VERSION}"
89
150
  end
90
151
 
152
+ def emit_validation_report(valid:, path: nil, details: nil, error: nil)
153
+ format = @options[:validation_format]
154
+ path ||= @options[:validation_path]
155
+ message = error&.message
156
+ case format
157
+ when "json"
158
+ puts JSON.pretty_generate(version: 1, valid: valid, path: path, details: details, errors: message ? [{ message: message }] : [])
159
+ when "sarif"
160
+ result = if message
161
+ [{ level: "error", message: { text: message }, locations: path ? [{ physicalLocation: { artifactLocation: { uri: path } } }] : [] }]
162
+ else
163
+ []
164
+ end
165
+ puts JSON.pretty_generate(
166
+ version: "2.1.0", "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
167
+ runs: [{ tool: { driver: { name: "shellfie", version: VERSION } }, results: result }]
168
+ )
169
+ when "junit"
170
+ failure = %(<failure message="#{CGI.escapeHTML(message)}">#{CGI.escapeHTML(message)}</failure>) if message
171
+ puts %(<testsuite name="shellfie validate" tests="1" failures="#{valid ? 0 : 1}"><testcase name="#{CGI.escapeHTML(path || "configuration")}">#{failure}</testcase></testsuite>)
172
+ else
173
+ raise ValidationError, "validation format must be text, json, sarif, or junit"
174
+ end
175
+ end
176
+
91
177
  def show_help
92
178
  puts <<~HELP
93
- Shellfie - Terminal screenshot-style image generator
179
+ Shellfie - Deterministic terminal visual compiler
94
180
 
95
181
  Usage: shellfie <command> [options]
96
182
  shf <command> [options]
97
183
 
98
184
  Commands:
99
- generate Generate image from configuration file
185
+ generate Render outputs from a configuration file
186
+ run Execute and render a version 2 terminal session
187
+ record Run a session and save a cassette or editable YAML
188
+ replay Render an existing cassette without executing commands
189
+ new Create a config from a template
190
+ format Normalize YAML formatting
191
+ compile Print the resolved config or session IR
192
+ schema Print the version 1 or 2 JSON Schema
193
+ completion Print bash, zsh, fish, or PowerShell completion
194
+ watch Regenerate when a config or included file changes
100
195
  init Output sample configuration
101
196
  themes List available themes
102
197
  validate Validate configuration file
@@ -106,22 +201,27 @@ module Shellfie
106
201
  help Show this help
107
202
 
108
203
  Generate Options:
109
- -o, --output PATH Output file path (required)
204
+ -o, --output PATH Output path/template (defaults beside input)
110
205
  -t, --theme NAME Override theme (macos, ubuntu, windows)
111
- -a, --animate Generate animated GIF
206
+ -a, --animate Render animated output
112
207
  -s, --scale FACTOR Output scale (1, 2, 3)
113
208
  -w, --width PIXELS Override width
114
209
  --no-shadow Disable shadow effect
115
210
  --no-header Disable window header (headless mode)
116
211
  --transparent Transparent background
117
- --fps FPS Override animation typing FPS
212
+ --typing-rate CPS Typing rate in characters per second
213
+ --framerate FPS Output timing precision
214
+ --seed N Deterministic animation jitter seed
215
+ --playback-speed N Playback speed multiplier
216
+ --fps FPS Deprecated alias for --framerate
118
217
  --overflow MODE Line overflow mode: clip, wrap, scroll
119
218
  --wrap, --no-wrap Enable or disable long-line wrapping
120
219
  --exact-size Match canvas to configured window size
121
- --format FORMAT Output format: png, gif, svg, webp, apng
220
+ --format FORMAT Also: mp4, webm, png-sequence, html, txt, json
122
221
  --force Overwrite existing output files
123
222
  --quiet Suppress non-error output
124
223
  --verbose Print progress details
224
+ --manifest PATH Write environment and output fingerprints
125
225
 
126
226
  Examples:
127
227
  shellfie generate config.yml -o terminal.png
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "optparse"
5
+ require "yaml"
6
+ require_relative "cassette"
7
+ require_relative "output_writer"
8
+ require_relative "session_config"
9
+
10
+ module Shellfie
11
+ module CLIRun
12
+ private
13
+
14
+ def run_session(record: false)
15
+ parser = build_run_parser(record: record)
16
+ parser.parse!(@args)
17
+ input = @args.shift
18
+ raise ConfigError, "Session configuration is required" unless input
19
+
20
+ config = SessionConfig.parse(input)
21
+ raise ConfigError, "mode: replay is not executable; use shellfie replay CASSETTE.json" if config.mode == "replay"
22
+ cassette_path = @options[:cassette]
23
+ yaml_path = @options[:yaml]
24
+ raise ConfigError, "record requires --cassette PATH or --yaml PATH" if record && !cassette_path && !yaml_path
25
+ resolved_outputs = resolve_session_outputs(config.outputs, base_dir: config.base_dir, allow_empty: record && (cassette_path || yaml_path))
26
+ preflight_session_artifacts!(resolved_outputs, cassette_path, yaml_path, input_path: config.path)
27
+ preflight_render_dependencies!(resolved_outputs.map { |_path, format, _output| format })
28
+ raise DependencyError, "Live sessions are not supported on native Windows" if Gem.win_platform?
29
+
30
+ require_relative "session_runner"
31
+ session = SessionRunner.new(config).run
32
+ write_cassette(cassette_path, session) if cassette_path
33
+ write_recording(yaml_path, session) if yaml_path
34
+ render_session_outputs(session, config.outputs, base_dir: config.base_dir, theme: config.theme, render: config.render,
35
+ resolved: resolved_outputs)
36
+ end
37
+
38
+ def replay_session
39
+ build_replay_parser.parse!(@args)
40
+ input = @args.shift
41
+ raise ConfigError, "Cassette file is required" unless input
42
+
43
+ session = Cassette.read(input)
44
+ render_session_outputs(session, [], base_dir: Dir.pwd, theme: @options[:theme] || "macos", render: {})
45
+ end
46
+
47
+ def build_run_parser(record:)
48
+ OptionParser.new do |opts|
49
+ opts.banner = "Usage: shellfie #{record ? "record" : "run"} SESSION.yml [options]"
50
+ session_output_options(opts)
51
+ opts.on("--cassette PATH", "Write an offline replay cassette") { |path| @options[:cassette] = path }
52
+ opts.on("--yaml PATH", "Write an editable compose recording") { |path| @options[:yaml] = path } if record
53
+ end
54
+ end
55
+
56
+ def build_replay_parser
57
+ OptionParser.new do |opts|
58
+ opts.banner = "Usage: shellfie replay SESSION.json [options]"
59
+ session_output_options(opts)
60
+ opts.on("-t", "--theme NAME", "Render theme") { |theme| @options[:theme] = theme }
61
+ end
62
+ end
63
+
64
+ def session_output_options(opts)
65
+ opts.on("-o", "--output PATH", "Output path (overrides config outputs)") { |path| @options[:output] = path }
66
+ opts.on("--format FORMAT", "Output format") { |format| @options[:format] = parse_format(format) }
67
+ opts.on("-a", "--animate", "Render the captured timeline") { @options[:animate] = true }
68
+ opts.on("--force", "Overwrite existing outputs") { @options[:force] = true }
69
+ opts.on("--quiet", "Suppress generated paths") { @options[:quiet] = true }
70
+ end
71
+
72
+ def render_session_outputs(session, configured_outputs, base_dir:, theme:, render:, resolved: nil)
73
+ resolved ||= resolve_session_outputs(configured_outputs, base_dir: base_dir)
74
+ resolved.each do |path, format, output|
75
+ ensure_output_writable!(path)
76
+ animate = output.fetch(:animate, @options[:animate] || CLIGenerate::ANIMATED_FORMATS.include?(format))
77
+ capture = output[:capture]
78
+ captured_lines = capture && (session.captures[capture] || session.captures[capture.to_sym])
79
+ raise ConfigError, "Unknown capture: #{capture}" if capture && !captured_lines
80
+
81
+ config = session.render_config(theme: theme, options: render, animated: animate, lines: captured_lines)
82
+ original_options = @options
83
+ @options = @options.merge(output.slice(:scale, :shadow, :transparent))
84
+ write_rendered_output(config, path, animate: animate, format: format)
85
+ ensure
86
+ @options = original_options
87
+ end
88
+ end
89
+
90
+ def resolve_session_outputs(configured_outputs, base_dir:, allow_empty: false)
91
+ outputs = if @options[:output]
92
+ [{ path: @options[:output], format: @options[:format], animate: @options[:animate] }]
93
+ else
94
+ configured_outputs
95
+ end
96
+ raise ConfigError, "Output is required with -o or outputs in the session config" if outputs.empty? && !allow_empty
97
+
98
+ resolved = outputs.map do |output|
99
+ path = output[:path] == "-" ? "-" : File.expand_path(output[:path], base_dir)
100
+ format = (output[:format] || @options[:format] || File.extname(path).delete_prefix(".")).to_s.downcase
101
+ unless CLIGenerate::SUPPORTED_FORMATS.include?(format)
102
+ raise ValidationError, "format must be one of: #{CLIGenerate::SUPPORTED_FORMATS.join(", ")}"
103
+ end
104
+ animate = output[:animate].nil? ? (@options[:animate] || CLIGenerate::ANIMATED_FORMATS.include?(format)) : output[:animate]
105
+ validate_output_mode!(format, animate)
106
+ raise ConfigError, "Captured screens cannot be rendered as animations" if output[:capture] && animate
107
+
108
+ [path, format, output.merge(animate: animate)]
109
+ end
110
+ duplicate = resolved.group_by(&:first).find { |_path, items| items.size > 1 }&.first
111
+ raise ConfigError, "Multiple outputs resolve to the same path: #{duplicate}" if duplicate
112
+
113
+ resolved
114
+ end
115
+
116
+ def preflight_session_artifacts!(resolved_outputs, cassette_path, yaml_path, input_path: nil)
117
+ metadata = [cassette_path, yaml_path].compact
118
+ raise ConfigError, "Cassette and YAML outputs cannot be stdout" if metadata.include?("-")
119
+
120
+ paths = resolved_outputs.filter_map { |path, _format, _output| path unless path == "-" } +
121
+ metadata.map { |path| File.expand_path(path) }
122
+ collision = paths.group_by { |path| canonical_output_path(path) }.find { |_path, items| items.size > 1 }&.first
123
+ raise ConfigError, "Session artifacts resolve to the same path: #{collision}" if collision
124
+ canonical_paths = paths.map { |path| canonical_output_path(path) }
125
+ sequence_dirs = resolved_outputs.filter_map do |path, format, _output|
126
+ canonical_output_path(path) if format == "png-sequence"
127
+ end
128
+ nested = sequence_dirs.find do |directory|
129
+ canonical_paths.any? { |path| path != directory && (path_within?(path, directory) || path_within?(directory, path)) }
130
+ end
131
+ raise ConfigError, "Session artifact conflicts with a PNG sequence directory: #{nested}" if nested
132
+ if input_path && paths.any? { |path| canonical_output_path(path) == canonical_output_path(input_path) }
133
+ raise ConfigError, "Session artifact conflicts with the session configuration: #{input_path}"
134
+ end
135
+ resolved_outputs.each do |path, format, _output|
136
+ if format == "png-sequence" && Dir.exist?(path) && !replaceable_png_sequence_directory?(path)
137
+ raise FileSystemError, "Refusing to replace a non-Shellfie directory: #{path}"
138
+ end
139
+ end
140
+
141
+ paths.each { |path| ensure_output_writable!(path) }
142
+ if resolved_outputs.any? { |_path, format, output| format == "mp4" && (@options[:transparent] || output[:transparent]) }
143
+ raise ConfigError, "MP4 output does not support transparency"
144
+ end
145
+ end
146
+
147
+ def write_cassette(path, session)
148
+ FileUtils.mkdir_p(File.dirname(path)) unless File.dirname(path) == "."
149
+ if File.exist?(path) && !@options[:force]
150
+ raise FileSystemError, "Cassette already exists: #{path} (use --force to overwrite)"
151
+ end
152
+
153
+ Cassette.write(path, session)
154
+ $stderr.puts "Recorded: #{path}" unless @options[:quiet]
155
+ end
156
+
157
+ def write_recording(path, session)
158
+ FileUtils.mkdir_p(File.dirname(path)) unless File.dirname(path) == "."
159
+ if File.exist?(path) && !@options[:force]
160
+ raise FileSystemError, "Recording already exists: #{path} (use --force to overwrite)"
161
+ end
162
+
163
+ OutputWriter.write(path, extension: "yml") { |temporary_path| File.write(temporary_path, YAML.dump(session.compose_hash)) }
164
+ $stderr.puts "Recorded: #{path}" unless @options[:quiet]
165
+ end
166
+ end
167
+ end
@@ -14,6 +14,8 @@ module Shellfie
14
14
  VALID_CURSOR_STYLES = %w[block bar underline].freeze
15
15
  VALID_PALETTES = %w[global adaptive theme].freeze
16
16
  VALID_SCROLL_EASINGS = %w[linear ease_in ease_out ease_in_out].freeze
17
+ VALID_APNG_PREDICTIONS = %w[none sub up avg paeth mixed].freeze
18
+ VALID_ANIMATION_DIRECTIONS = %w[forward reverse ping_pong].freeze
17
19
 
18
20
  class << self
19
21
  def deep_dup(value)
@@ -55,7 +57,7 @@ module Shellfie
55
57
  DEFAULTS = deep_freeze(deep_dup(ConfigDefaults::VALUES))
56
58
 
57
59
  attr_reader :version, :theme, :window_theme, :color_scheme, :colors, :window_decoration, :title, :window, :font,
58
- :lines, :animation, :frames, :headless, :cursor, :limits
60
+ :lines, :animation, :frames, :headless, :cursor, :limits, :source_paths
59
61
 
60
62
  def initialize(options = {})
61
63
  options = self.class.normalize_keys(options)
@@ -75,6 +77,7 @@ module Shellfie
75
77
  @cursor = merged[:cursor]
76
78
  @limits = merged[:limits]
77
79
  @headless = merged[:headless] || false
80
+ @source_paths = Array(options[:source_paths])
78
81
 
79
82
  validate!
80
83
  freeze_state!
@@ -139,6 +142,7 @@ module Shellfie
139
142
  @lines = self.class.deep_freeze(@lines)
140
143
  @frames = self.class.deep_freeze(@frames)
141
144
  @title.freeze
145
+ @source_paths = self.class.deep_freeze(@source_paths)
142
146
  @theme.freeze
143
147
  @window_theme.freeze if @window_theme
144
148
  @color_scheme.freeze if @color_scheme
@@ -11,6 +11,7 @@ module Shellfie
11
11
  window_decoration: {},
12
12
  window: {
13
13
  width: 600,
14
+ height: nil,
14
15
  padding: 20,
15
16
  opacity: 1.0,
16
17
  visible_lines: nil,
@@ -22,6 +23,9 @@ module Shellfie
22
23
  exact_size: false,
23
24
  trim: false,
24
25
  tab_width: 8,
26
+ ambiguous_width: 1,
27
+ osc_policy: "ignore",
28
+ graphics_policy: "ignore",
25
29
  ansi_state: "persistent",
26
30
  background_gradient: nil,
27
31
  scroll_offset: 0.0
@@ -40,13 +44,26 @@ module Shellfie
40
44
  cursor_blink: true,
41
45
  loop: false,
42
46
  typing_jitter: 0.0,
47
+ seed: 0,
43
48
  typing_chunk_size: 1,
44
49
  output_delay: 0,
45
50
  final_delay: 1_000,
46
51
  max_frames: nil,
47
52
  dither: true,
48
53
  palette: "global",
49
- scroll_easing: "linear"
54
+ gif_colors: 256,
55
+ gif_optimize: true,
56
+ webp_lossless: true,
57
+ webp_quality: 100,
58
+ webp_method: 4,
59
+ webp_near_lossless: 100,
60
+ apng_prediction: "paeth",
61
+ loop_count: nil,
62
+ direction: "forward",
63
+ loop_offset: 0,
64
+ scroll_easing: "linear",
65
+ framerate: 30,
66
+ playback_speed: 1.0
50
67
  },
51
68
  cursor: {
52
69
  style: "block",
@@ -57,7 +74,9 @@ module Shellfie
57
74
  max_frames: 500,
58
75
  max_render_frames: 2_000,
59
76
  max_characters: 200_000,
60
- max_pixels: 50_000_000
77
+ max_pixels: 50_000_000,
78
+ max_total_pixels: 2_000_000_000,
79
+ max_temp_bytes: 8_000_000_000
61
80
  }
62
81
  }.freeze
63
82
  end
@@ -2,10 +2,22 @@
2
2
 
3
3
  module Shellfie
4
4
  module ConfigValidation
5
+ RESOURCE_LIMIT_CEILINGS = {
6
+ max_lines: 10_000,
7
+ max_frames: 500,
8
+ max_render_frames: 2_000,
9
+ max_characters: 200_000,
10
+ max_pixels: 50_000_000,
11
+ max_total_pixels: 2_000_000_000,
12
+ max_temp_bytes: 8_000_000_000
13
+ }.freeze
14
+ MAX_FRAME_DELAY_MS = 86_400_000
15
+
5
16
  def validate!
6
17
  validate_version!
7
18
  validate_theme!
8
19
  validate_window!
20
+ validate_appearance!
9
21
  validate_font!
10
22
  validate_animation!
11
23
  validate_cursor!
@@ -45,13 +57,18 @@ module Shellfie
45
57
  validate_window_theme!
46
58
  validate_color_scheme!
47
59
  validate_positive_integer!(@window[:width], "window.width")
60
+ validate_optional_positive_integer!(@window[:height], "window.height")
48
61
  validate_non_negative_integer!(@window[:padding], "window.padding")
62
+ raise ValidationError, "window.padding must be at most 40" if @window[:padding] > 40
49
63
  %i[opacity scroll_offset].each { |key| validate_number_range!(@window[key], "window.#{key}", 0.0, 1.0) }
50
64
  validate_optional_positive_integer!(@window[:visible_lines], "window.visible_lines")
51
65
  validate_optional_positive_integer!(@window[:max_lines], "window.max_lines")
52
66
  validate_optional_positive_integer!(@window[:max_height], "window.max_height")
53
67
  validate_optional_non_negative_integer!(@window[:margin], "window.margin")
54
68
  validate_positive_integer!(@window[:tab_width], "window.tab_width")
69
+ validate_inclusion!(@window[:ambiguous_width], "window.ambiguous_width", [1, 2])
70
+ validate_inclusion!(@window[:osc_policy], "window.osc_policy", %w[ignore preserve apply])
71
+ validate_inclusion!(@window[:graphics_policy], "window.graphics_policy", %w[ignore error])
55
72
  validate_boolean!(@window[:wrap], "window.wrap")
56
73
  validate_boolean!(@window[:exact_size], "window.exact_size")
57
74
  validate_boolean!(@window[:trim], "window.trim")
@@ -61,6 +78,29 @@ module Shellfie
61
78
  validate_minimum_width!
62
79
  end
63
80
 
81
+ def validate_appearance!
82
+ raise ValidationError, "title must be a string" unless @title.is_a?(String)
83
+ unless @colors.values.all?(String)
84
+ raise ValidationError, "colors values must be strings"
85
+ end
86
+ %i[title_bar_height button_size button_spacing button_width corner_radius].each do |key|
87
+ next unless @window_decoration.key?(key)
88
+
89
+ validate_non_negative_number!(@window_decoration[key], "window_decoration.#{key}")
90
+ end
91
+ return unless @window_decoration.key?(:shadow)
92
+
93
+ shadow = @window_decoration[:shadow]
94
+ raise ValidationError, "window_decoration.shadow must be a mapping" unless shadow.is_a?(Hash)
95
+ validate_non_negative_number!(shadow[:blur], "window_decoration.shadow.blur") if shadow.key?(:blur)
96
+ %i[offset_x offset_y].each do |key|
97
+ validate_finite_number!(shadow[key], "window_decoration.shadow.#{key}") if shadow.key?(key)
98
+ end
99
+ if shadow.key?(:color) && !shadow[:color].is_a?(String)
100
+ raise ValidationError, "window_decoration.shadow.color must be a string"
101
+ end
102
+ end
103
+
64
104
  def validate_font!
65
105
  validate_optional_string!(@font[:family], "font.family")
66
106
  validate_optional_string!(@font[:fallback_family], "font.fallback_family")
@@ -74,15 +114,43 @@ module Shellfie
74
114
  validate_non_negative_integer!(@animation[:typing_speed], "animation.typing_speed")
75
115
  validate_non_negative_integer!(@animation[:command_delay], "animation.command_delay")
76
116
  validate_number_range!(@animation[:typing_jitter], "animation.typing_jitter", 0.0, 1.0)
117
+ validate_non_negative_integer!(@animation[:seed], "animation.seed")
118
+ raise ValidationError, "animation.seed must be at most 2147483647" if @animation[:seed] > 2_147_483_647
77
119
  validate_positive_integer!(@animation[:typing_chunk_size], "animation.typing_chunk_size")
78
120
  validate_non_negative_integer!(@animation[:output_delay], "animation.output_delay")
79
121
  validate_non_negative_integer!(@animation[:final_delay], "animation.final_delay")
122
+ %i[typing_speed command_delay output_delay final_delay].each do |key|
123
+ if @animation[key] > MAX_FRAME_DELAY_MS
124
+ raise ValidationError, "animation.#{key} must be at most #{MAX_FRAME_DELAY_MS}"
125
+ end
126
+ end
80
127
  validate_optional_positive_integer!(@animation[:max_frames], "animation.max_frames")
81
128
  validate_boolean!(@animation[:cursor_blink], "animation.cursor_blink")
82
129
  validate_boolean!(@animation[:loop], "animation.loop")
83
130
  validate_boolean!(@animation[:dither], "animation.dither")
131
+ validate_boolean!(@animation[:gif_optimize], "animation.gif_optimize")
132
+ validate_boolean!(@animation[:webp_lossless], "animation.webp_lossless")
84
133
  validate_inclusion!(@animation[:palette], "animation.palette", self.class::VALID_PALETTES)
134
+ validate_inclusion!(@animation[:apng_prediction], "animation.apng_prediction", self.class::VALID_APNG_PREDICTIONS)
135
+ validate_positive_integer!(@animation[:gif_colors], "animation.gif_colors")
136
+ raise ValidationError, "animation.gif_colors must be between 2 and 256" unless @animation[:gif_colors].between?(2, 256)
137
+ %i[webp_quality webp_near_lossless].each do |key|
138
+ validate_non_negative_integer!(@animation[key], "animation.#{key}")
139
+ raise ValidationError, "animation.#{key} must be at most 100" if @animation[key] > 100
140
+ end
141
+ validate_non_negative_integer!(@animation[:webp_method], "animation.webp_method")
142
+ raise ValidationError, "animation.webp_method must be at most 6" if @animation[:webp_method] > 6
143
+ unless @animation[:loop_count].nil?
144
+ validate_non_negative_integer!(@animation[:loop_count], "animation.loop_count")
145
+ raise ValidationError, "animation.loop_count must be at most 65535" if @animation[:loop_count] > 65_535
146
+ end
85
147
  validate_inclusion!(@animation[:scroll_easing], "animation.scroll_easing", self.class::VALID_SCROLL_EASINGS)
148
+ validate_inclusion!(@animation[:direction], "animation.direction", self.class::VALID_ANIMATION_DIRECTIONS)
149
+ validate_non_negative_integer!(@animation[:loop_offset], "animation.loop_offset")
150
+ validate_positive_integer!(@animation[:framerate], "animation.framerate")
151
+ validate_positive_number!(@animation[:playback_speed], "animation.playback_speed")
152
+ raise ValidationError, "animation.framerate must be at most 120" if @animation[:framerate] > 120
153
+ raise ValidationError, "animation.playback_speed must be at most 100" if @animation[:playback_speed] > 100
86
154
  end
87
155
 
88
156
  def validate_cursor!
@@ -94,11 +162,21 @@ module Shellfie
94
162
  def validate_lines!
95
163
  raise ValidationError, "lines must be an Array" unless @lines.is_a?(Array)
96
164
  raise ValidationError, "frames must be an Array" unless @frames.is_a?(Array)
165
+ @frames.each_with_index do |frame, index|
166
+ unless frame.respond_to?(:delay) && frame.delay.is_a?(Integer) && frame.delay.between?(0, MAX_FRAME_DELAY_MS)
167
+ raise ValidationError, "frames[#{index}].delay must be between 0 and #{MAX_FRAME_DELAY_MS}"
168
+ end
169
+ end
97
170
  end
98
171
 
99
172
  def validate_limits!
173
+ unknown = @limits.keys - RESOURCE_LIMIT_CEILINGS.keys
174
+ raise ValidationError, "Unknown limits key(s): #{unknown.join(", ")}" unless unknown.empty?
175
+
100
176
  @limits.each_key do |key|
101
177
  validate_positive_integer!(@limits[key], "limits.#{key}")
178
+ ceiling = RESOURCE_LIMIT_CEILINGS.fetch(key)
179
+ raise ValidationError, "limits.#{key} must be at most #{ceiling}" if @limits[key] > ceiling
102
180
  end
103
181
  end
104
182
 
@@ -137,10 +215,9 @@ module Shellfie
137
215
  end
138
216
 
139
217
  def validate_minimum_width!
140
- min_width = [120, (@window[:padding] * 2) + 40].max
141
- return if @window[:width] >= min_width
218
+ return if @window[:width] >= 120
142
219
 
143
- raise ValidationError, "window.width must be at least #{min_width}px for the configured padding"
220
+ raise ValidationError, "window.width must be at least 120px"
144
221
  end
145
222
 
146
223
  def validate_optional_positive_integer!(value, name)
@@ -174,11 +251,23 @@ module Shellfie
174
251
  end
175
252
 
176
253
  def validate_positive_number!(value, name)
177
- return if value.is_a?(Numeric) && value.positive?
254
+ return if value.is_a?(Numeric) && value.finite? && value.positive?
178
255
 
179
256
  raise ValidationError, "#{name} must be a positive number"
180
257
  end
181
258
 
259
+ def validate_non_negative_number!(value, name)
260
+ return if value.is_a?(Numeric) && value.finite? && value >= 0
261
+
262
+ raise ValidationError, "#{name} must be a non-negative number"
263
+ end
264
+
265
+ def validate_finite_number!(value, name)
266
+ return if value.is_a?(Numeric) && value.finite?
267
+
268
+ raise ValidationError, "#{name} must be a finite number"
269
+ end
270
+
182
271
  def validate_number_range!(value, name, min, max)
183
272
  return if value.is_a?(Numeric) && value >= min && value <= max
184
273