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,289 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shellfie
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
+
16
+ def validate!
17
+ validate_version!
18
+ validate_theme!
19
+ validate_window!
20
+ validate_appearance!
21
+ validate_font!
22
+ validate_animation!
23
+ validate_cursor!
24
+ validate_lines!
25
+ validate_limits!
26
+ validate_headless!
27
+ validate_resource_limits!
28
+ end
29
+
30
+ private
31
+
32
+ def validate_version!
33
+ return if @version == 1
34
+
35
+ raise ValidationError, "Unsupported config version '#{@version}'"
36
+ end
37
+
38
+ def validate_theme!
39
+ return if ThemeRegistry.valid_theme?(@theme)
40
+
41
+ raise ValidationError, "Invalid theme '#{@theme}'\n → Available themes: #{ThemeRegistry.available_themes.join(", ")}"
42
+ end
43
+
44
+ def validate_window_theme!
45
+ return if @window_theme.nil? || ThemeRegistry.valid_window_theme?(@window_theme)
46
+
47
+ raise ValidationError, "Invalid window_theme '#{@window_theme}'"
48
+ end
49
+
50
+ def validate_color_scheme!
51
+ return if ThemeRegistry.valid_color_scheme?(@color_scheme)
52
+
53
+ raise ValidationError, "Invalid color_scheme '#{@color_scheme}'"
54
+ end
55
+
56
+ def validate_window!
57
+ validate_window_theme!
58
+ validate_color_scheme!
59
+ validate_positive_integer!(@window[:width], "window.width")
60
+ validate_optional_positive_integer!(@window[:height], "window.height")
61
+ validate_non_negative_integer!(@window[:padding], "window.padding")
62
+ raise ValidationError, "window.padding must be at most 40" if @window[:padding] > 40
63
+ %i[opacity scroll_offset].each { |key| validate_number_range!(@window[key], "window.#{key}", 0.0, 1.0) }
64
+ validate_optional_positive_integer!(@window[:visible_lines], "window.visible_lines")
65
+ validate_optional_positive_integer!(@window[:max_lines], "window.max_lines")
66
+ validate_optional_positive_integer!(@window[:max_height], "window.max_height")
67
+ validate_optional_non_negative_integer!(@window[:margin], "window.margin")
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])
72
+ validate_boolean!(@window[:wrap], "window.wrap")
73
+ validate_boolean!(@window[:exact_size], "window.exact_size")
74
+ validate_boolean!(@window[:trim], "window.trim")
75
+ validate_overflow!
76
+ validate_ansi_state!
77
+ validate_background_gradient!
78
+ validate_minimum_width!
79
+ end
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
+
104
+ def validate_font!
105
+ validate_optional_string!(@font[:family], "font.family")
106
+ validate_optional_string!(@font[:fallback_family], "font.fallback_family")
107
+ validate_optional_string!(@font[:italic_family], "font.italic_family")
108
+ validate_optional_string!(@font[:emoji_family], "font.emoji_family")
109
+ validate_positive_number!(@font[:size], "font.size")
110
+ validate_positive_number!(@font[:line_height], "font.line_height")
111
+ end
112
+
113
+ def validate_animation!
114
+ validate_non_negative_integer!(@animation[:typing_speed], "animation.typing_speed")
115
+ validate_non_negative_integer!(@animation[:command_delay], "animation.command_delay")
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
119
+ validate_positive_integer!(@animation[:typing_chunk_size], "animation.typing_chunk_size")
120
+ validate_non_negative_integer!(@animation[:output_delay], "animation.output_delay")
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
127
+ validate_optional_positive_integer!(@animation[:max_frames], "animation.max_frames")
128
+ validate_boolean!(@animation[:cursor_blink], "animation.cursor_blink")
129
+ validate_boolean!(@animation[:loop], "animation.loop")
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")
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
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
154
+ end
155
+
156
+ def validate_cursor!
157
+ return if self.class::VALID_CURSOR_STYLES.include?(@cursor[:style])
158
+
159
+ raise ValidationError, "cursor.style must be one of: #{self.class::VALID_CURSOR_STYLES.join(", ")}"
160
+ end
161
+
162
+ def validate_lines!
163
+ raise ValidationError, "lines must be an Array" unless @lines.is_a?(Array)
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
170
+ end
171
+
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
+
176
+ @limits.each_key do |key|
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
180
+ end
181
+ end
182
+
183
+ def validate_headless!
184
+ validate_boolean!(@headless, "headless")
185
+ end
186
+
187
+ def validate_resource_limits!
188
+ raise ResourceLimitError, "Too many lines (max #{@limits[:max_lines]})" if @lines.size > @limits[:max_lines]
189
+ raise ResourceLimitError, "Too many frames (max #{@limits[:max_frames]})" if @frames.size > @limits[:max_frames]
190
+
191
+ total_characters = (@lines.sum { |line| line.to_s.length } + @frames.sum { |frame| frame.to_s.length })
192
+ return if total_characters <= @limits[:max_characters]
193
+
194
+ raise ResourceLimitError, "Configuration text is too large (max #{@limits[:max_characters]} characters)"
195
+ end
196
+
197
+ def validate_overflow!
198
+ return if self.class::VALID_OVERFLOW_MODES.include?(@window[:overflow])
199
+
200
+ raise ValidationError, "window.overflow must be one of: #{self.class::VALID_OVERFLOW_MODES.join(", ")}"
201
+ end
202
+
203
+ def validate_ansi_state!
204
+ return if %w[persistent line].include?(@window[:ansi_state])
205
+
206
+ raise ValidationError, "window.ansi_state must be persistent or line"
207
+ end
208
+
209
+ def validate_background_gradient!
210
+ gradient = @window[:background_gradient]
211
+ return if gradient.nil?
212
+ return if gradient.is_a?(Array) && gradient.size == 2 && gradient.all? { |color| color.is_a?(String) }
213
+
214
+ raise ValidationError, "window.background_gradient must be an array of two colors"
215
+ end
216
+
217
+ def validate_minimum_width!
218
+ return if @window[:width] >= 120
219
+
220
+ raise ValidationError, "window.width must be at least 120px"
221
+ end
222
+
223
+ def validate_optional_positive_integer!(value, name)
224
+ return if value.nil?
225
+
226
+ validate_positive_integer!(value, name)
227
+ end
228
+
229
+ def validate_optional_non_negative_integer!(value, name)
230
+ return if value.nil?
231
+
232
+ validate_non_negative_integer!(value, name)
233
+ end
234
+
235
+ def validate_optional_string!(value, name)
236
+ return if value.nil? || value.is_a?(String)
237
+
238
+ raise ValidationError, "#{name} must be a string"
239
+ end
240
+
241
+ def validate_positive_integer!(value, name)
242
+ return if value.is_a?(Integer) && value.positive?
243
+
244
+ raise ValidationError, "#{name} must be a positive integer"
245
+ end
246
+
247
+ def validate_non_negative_integer!(value, name)
248
+ return if value.is_a?(Integer) && value >= 0
249
+
250
+ raise ValidationError, "#{name} must be a non-negative integer"
251
+ end
252
+
253
+ def validate_positive_number!(value, name)
254
+ return if value.is_a?(Numeric) && value.finite? && value.positive?
255
+
256
+ raise ValidationError, "#{name} must be a positive number"
257
+ end
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
+
271
+ def validate_number_range!(value, name, min, max)
272
+ return if value.is_a?(Numeric) && value >= min && value <= max
273
+
274
+ raise ValidationError, "#{name} must be between #{min} and #{max}"
275
+ end
276
+
277
+ def validate_inclusion!(value, name, allowed)
278
+ return if allowed.include?(value)
279
+
280
+ raise ValidationError, "#{name} must be one of: #{allowed.join(", ")}"
281
+ end
282
+
283
+ def validate_boolean!(value, name)
284
+ return if value == true || value == false
285
+
286
+ raise ValidationError, "#{name} must be true or false"
287
+ end
288
+ end
289
+ end
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rbconfig"
4
+ require "open3"
5
+ require "tempfile"
6
+ require_relative "font_resolver"
7
+
8
+ module Shellfie
9
+ class DependencyChecker
10
+ IMAGE_MAGICK_COMMANDS = %w[magick convert].freeze
11
+
12
+ class << self
13
+ def imagemagick_path
14
+ @imagemagick_path ||= find_executable(IMAGE_MAGICK_COMMANDS)
15
+ end
16
+
17
+ def imagemagick_available?
18
+ !imagemagick_path.to_s.empty?
19
+ end
20
+
21
+ def ffmpeg_path
22
+ @ffmpeg_path ||= find_executable(%w[ffmpeg], verify_imagemagick: false)
23
+ end
24
+
25
+ def ensure_ffmpeg!
26
+ return if ffmpeg_path
27
+
28
+ raise DependencyError, "ffmpeg not found; install ffmpeg to generate APNG, MP4, or WebM"
29
+ end
30
+
31
+ def ffmpeg_version
32
+ return unless ffmpeg_path
33
+
34
+ output, = Open3.capture3(ffmpeg_path, "-version")
35
+ output.lines.first&.strip
36
+ rescue SystemCallError
37
+ nil
38
+ end
39
+
40
+ def ensure_imagemagick!
41
+ return if imagemagick_available?
42
+
43
+ raise DependencyError, <<~MSG
44
+ ImageMagick not found
45
+ → Please install ImageMagick: brew install imagemagick
46
+ → Or visit: https://imagemagick.org/script/download.php
47
+ MSG
48
+ end
49
+
50
+ def configure_mini_magick!(timeout: 30)
51
+ return unless defined?(MiniMagick) && MiniMagick.respond_to?(:timeout=)
52
+
53
+ MiniMagick.timeout = timeout
54
+ end
55
+
56
+ def doctor(output_dir: Dir.pwd)
57
+ details = imagemagick_details
58
+ video_version = ffmpeg_version
59
+ [
60
+ check("Ruby", RUBY_VERSION, Gem::Version.new(RUBY_VERSION) >= Gem::Version.new("3.0.0")),
61
+ check("ImageMagick", details[:version] || "not found", imagemagick_available?),
62
+ check("Image formats", details[:formats].empty? ? "unavailable" : details[:formats].join(", "),
63
+ (required_formats - details[:formats]).empty?),
64
+ check("Image render", details[:render_ok] ? "1x1 PNG generated" : "failed", details[:render_ok]),
65
+ check("Fonts", details[:font_count].to_s, details[:font_count].positive?),
66
+ check("Security policy", details[:policy] || "unavailable", !details[:policy].nil?),
67
+ check("ffmpeg (optional)", video_version || "not found; required only for APNG/MP4/WebM", true),
68
+ check("Writable output", output_dir, File.writable?(output_dir)),
69
+ check("Encoding", Encoding.default_external.name, true)
70
+ ]
71
+ end
72
+
73
+ def imagemagick_details
74
+ return { version: nil, formats: [], font_count: 0, render_ok: false, policy: nil } unless imagemagick_available?
75
+
76
+ version_output, _error, version_status = Open3.capture3(imagemagick_path, "-version")
77
+ formats_output, _error, formats_status = Open3.capture3(imagemagick_path, "-list", "format")
78
+ fonts_output, _error, fonts_status = Open3.capture3(imagemagick_path, "-list", "font")
79
+ policy_output, _error, policy_status = Open3.capture3(imagemagick_path, "-list", "policy")
80
+ {
81
+ version: version_status.success? ? version_output.lines.first&.strip : nil,
82
+ formats: formats_status.success? ? required_formats.select { |format| formats_output.match?(/^\s*#{format}\*?\s/im) } : [],
83
+ font_count: (fonts_status.success? ? fonts_output.scan(/^\s*Font:/).size : 0) +
84
+ FontResolver::FONT_FILES.values.uniq.count { |path| File.file?(path) },
85
+ render_ok: render_smoke_test,
86
+ policy: policy_status.success? ? "#{policy_output.scan(/^\s*Policy:/).size} rules loaded" : nil
87
+ }
88
+ rescue SystemCallError
89
+ { version: nil, formats: [], font_count: 0, render_ok: false, policy: nil }
90
+ end
91
+
92
+ private
93
+
94
+ def check(name, detail, ok)
95
+ { name: name, detail: detail, ok: ok }
96
+ end
97
+
98
+ def find_executable(names, verify_imagemagick: true)
99
+ paths = ENV.fetch("PATH", "").split(File::PATH_SEPARATOR)
100
+ extensions = executable_extensions
101
+
102
+ names.each do |name|
103
+ paths.each do |path|
104
+ extensions.each do |extension|
105
+ candidate = File.join(path, "#{name}#{extension}")
106
+ next unless File.file?(candidate) && File.executable?(candidate)
107
+ return candidate unless verify_imagemagick
108
+ return candidate if imagemagick_executable?(candidate)
109
+ end
110
+ end
111
+ end
112
+
113
+ nil
114
+ end
115
+
116
+ def imagemagick_executable?(candidate)
117
+ output, = Open3.capture3(candidate, "-version")
118
+ output.include?("ImageMagick")
119
+ rescue SystemCallError
120
+ false
121
+ end
122
+
123
+ def required_formats
124
+ %w[PNG GIF WEBP SVG]
125
+ end
126
+
127
+ def render_smoke_test
128
+ Tempfile.create(["shellfie-doctor", ".png"]) do |file|
129
+ _output, _error, status = Open3.capture3(imagemagick_path, "-size", "1x1", "xc:none", file.path)
130
+ return status.success? && File.binread(file.path, 8) == "\x89PNG\r\n\x1a\n".b
131
+ end
132
+ rescue SystemCallError, EOFError
133
+ false
134
+ end
135
+
136
+ def executable_extensions
137
+ return [""] unless windows?
138
+
139
+ ENV.fetch("PATHEXT", ".COM;.EXE;.BAT;.CMD").split(";")
140
+ end
141
+
142
+ def windows?
143
+ RbConfig::CONFIG["host_os"].match?(/mswin|mingw|cygwin/i)
144
+ end
145
+ end
146
+ end
147
+ end
@@ -1,15 +1,26 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Shellfie
4
- class Error < StandardError; end
4
+ class Error < StandardError
5
+ attr_reader :category, :context
6
+
7
+ def initialize(message = nil, category: nil, context: {})
8
+ @category = category
9
+ @context = context.freeze
10
+ super(message)
11
+ end
12
+ end
5
13
 
6
14
  class ConfigError < Error; end
7
15
  class ParseError < ConfigError; end
8
16
  class ValidationError < ConfigError; end
17
+ class ResourceLimitError < ConfigError; end
9
18
 
10
19
  class RenderError < Error; end
11
20
  class FontError < RenderError; end
12
21
  class ImageError < RenderError; end
13
22
 
14
23
  class DependencyError < Error; end
24
+ class FileSystemError < Error; end
25
+ class ExecutionError < Error; end
15
26
  end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "tempfile"
5
+ require_relative "errors"
6
+
7
+ module Shellfie
8
+ class FfmpegEncoder
9
+ def self.encode(images, output_path, format:, command:, framerate:, playback_speed:, loop:, loop_count: nil,
10
+ apng_prediction: nil)
11
+ list = Tempfile.new(["shellfie-frames", ".txt"])
12
+ minimum_delay = 1_000.0 / framerate
13
+ images.each do |image|
14
+ list.puts "file '#{image[:path].gsub("'", "'\\''")}'"
15
+ list.puts "duration #{[image[:delay].to_f, minimum_delay].max / 1_000.0 / playback_speed}"
16
+ end
17
+ list.puts "file '#{images.last[:path].gsub("'", "'\\''")}'"
18
+ list.close
19
+
20
+ total_duration = images.sum { |image| [image[:delay].to_f, minimum_delay].max } / 1_000.0 / playback_speed
21
+ filters = ["fps=#{framerate}"]
22
+ codec = case format
23
+ when "mp4"
24
+ filters << "pad=ceil(iw/2)*2:ceil(ih/2)*2"
25
+ %w[-c:v libx264 -pix_fmt yuv420p -movflags +faststart]
26
+ when "webm"
27
+ filters << "pad=ceil(iw/2)*2:ceil(ih/2)*2"
28
+ %w[-c:v libvpx-vp9 -pix_fmt yuva420p]
29
+ when "apng"
30
+ filters << "format=rgba"
31
+ options = ["-plays", (loop_count || (loop ? 0 : 1)).to_s]
32
+ options.concat(["-pred", apng_prediction]) if apng_prediction
33
+ options.concat(["-f", "apng"])
34
+ else raise RenderError, "Unsupported ffmpeg format: #{format}"
35
+ end
36
+ timing = ["-vf", filters.join(","), "-fps_mode", "cfr", "-t", total_duration.to_s]
37
+ _stdout, stderr, status = Open3.capture3(command, "-y", "-f", "concat", "-safe", "0", "-i", list.path,
38
+ *timing, *codec, output_path)
39
+ raise RenderError, "ffmpeg encode failed: #{stderr}" unless status.success?
40
+
41
+ output_path
42
+ ensure
43
+ list&.close!
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "digest"
5
+
6
+ module Shellfie
7
+ class FontResolver
8
+ FONT_FILES = {
9
+ "Monaco" => "/System/Library/Fonts/Monaco.ttf",
10
+ "SF Mono" => "/System/Library/Fonts/SFNSMono.ttf",
11
+ "SF Mono Italic" => "/System/Library/Fonts/SFNSMonoItalic.ttf",
12
+ "Menlo" => "/System/Library/Fonts/Menlo.ttc",
13
+ "Courier" => "/System/Library/Fonts/Courier.ttc",
14
+ "Courier New" => "/System/Library/Fonts/Supplemental/Courier New.ttf",
15
+ "DejaVu-Sans-Mono" => "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf",
16
+ "DejaVu Sans Mono" => "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf"
17
+ }.freeze
18
+
19
+ def initialize(command_provider)
20
+ @command_provider = command_provider
21
+ end
22
+
23
+ def resolve(font_config, italic:)
24
+ candidates = []
25
+ candidates << font_config[:italic_family] if italic
26
+ candidates << font_config[:family]
27
+ candidates << font_config[:fallback_family]
28
+ candidates << font_config[:emoji_family]
29
+ candidates << "Menlo"
30
+ candidates << "DejaVu-Sans-Mono"
31
+ candidates << "Courier"
32
+
33
+ candidates.compact.flat_map { |candidate| font_candidates(candidate.to_s) }
34
+ .find { |candidate| font_available?(candidate) }
35
+ end
36
+
37
+ def details(font_config)
38
+ { regular: resolve(font_config, italic: false), italic: resolve(font_config, italic: true) }.transform_values do |font|
39
+ { name: font, sha256: File.file?(font.to_s) ? Digest::SHA256.file(font).hexdigest : nil }
40
+ end
41
+ end
42
+
43
+ private
44
+
45
+ def font_available?(font)
46
+ return true if File.exist?(font)
47
+ return true if available_fonts.include?(font)
48
+
49
+ false
50
+ end
51
+
52
+ def font_candidates(font)
53
+ [font, FONT_FILES[font]].compact
54
+ end
55
+
56
+ def available_fonts
57
+ @available_fonts ||= begin
58
+ command = @command_provider.call
59
+ if command.empty?
60
+ []
61
+ else
62
+ stdout, = Open3.capture3(command, "-list", "font")
63
+ stdout.scan(/^\s*Font:\s+(.+)$/).flatten
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shellfie
4
+ class FormatResolver
5
+ class << self
6
+ def resolve(output_path, explicit:, default:)
7
+ return explicit if explicit
8
+ return default if output_path == "-"
9
+
10
+ extension = File.extname(output_path).delete_prefix(".")
11
+ extension.empty? ? default : extension
12
+ end
13
+ end
14
+ end
15
+ end