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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +56 -2
- data/README.md +248 -228
- data/lib/shellfie/animation_frame_builder.rb +202 -0
- data/lib/shellfie/animation_scroll_easing.rb +77 -0
- data/lib/shellfie/animation_timeline.rb +28 -0
- data/lib/shellfie/ansi_colors.rb +94 -0
- data/lib/shellfie/ansi_line_buffer.rb +103 -0
- data/lib/shellfie/ansi_normalizer.rb +61 -0
- data/lib/shellfie/ansi_parser.rb +162 -83
- data/lib/shellfie/cassette.rb +76 -0
- data/lib/shellfie/cli.rb +75 -163
- data/lib/shellfie/cli_authoring.rb +233 -0
- data/lib/shellfie/cli_generate.rb +401 -0
- data/lib/shellfie/cli_info.rb +239 -0
- data/lib/shellfie/cli_run.rb +167 -0
- data/lib/shellfie/config.rb +112 -25
- data/lib/shellfie/config_defaults.rb +83 -0
- data/lib/shellfie/config_validation.rb +289 -0
- data/lib/shellfie/dependency_checker.rb +147 -0
- data/lib/shellfie/errors.rb +12 -1
- data/lib/shellfie/ffmpeg_encoder.rb +46 -0
- data/lib/shellfie/font_resolver.rb +68 -0
- data/lib/shellfie/format_resolver.rb +15 -0
- data/lib/shellfie/gif_generator.rb +231 -89
- data/lib/shellfie/gif_palette.rb +105 -0
- data/lib/shellfie/headless_theme_registry.rb +42 -0
- data/lib/shellfie/html_renderer.rb +54 -0
- data/lib/shellfie/image_magick_command_builder.rb +75 -0
- data/lib/shellfie/line_layout.rb +146 -0
- data/lib/shellfie/output_writer.rb +46 -0
- data/lib/shellfie/parser.rb +183 -30
- data/lib/shellfie/parser_validation.rb +178 -0
- data/lib/shellfie/raster_painter.rb +157 -0
- data/lib/shellfie/render_chrome_cache.rb +40 -0
- data/lib/shellfie/render_geometry.rb +115 -0
- data/lib/shellfie/render_segment.rb +71 -0
- data/lib/shellfie/renderer.rb +96 -149
- data/lib/shellfie/rendering/shape_helpers.rb +42 -0
- data/lib/shellfie/rendering/text_painter.rb +195 -0
- data/lib/shellfie/rendering/window_chrome.rb +196 -0
- data/lib/shellfie/reproducibility_manifest.rb +41 -0
- data/lib/shellfie/session.rb +111 -0
- data/lib/shellfie/session_config.rb +562 -0
- data/lib/shellfie/session_runner.rb +689 -0
- data/lib/shellfie/svg_raster_wrapper.rb +35 -0
- data/lib/shellfie/svg_renderer.rb +222 -0
- data/lib/shellfie/terminal_screen.rb +389 -0
- data/lib/shellfie/text_metrics.rb +155 -0
- data/lib/shellfie/theme_data.rb +80 -0
- data/lib/shellfie/theme_registry.rb +131 -0
- data/lib/shellfie/themes/base.rb +10 -1
- data/lib/shellfie/themes/configured.rb +61 -0
- data/lib/shellfie/themes/macos.rb +3 -1
- data/lib/shellfie/themes/ubuntu.rb +2 -1
- data/lib/shellfie/themes/windows_terminal.rb +7 -1
- data/lib/shellfie/transcript_renderer.rb +92 -0
- data/lib/shellfie/version.rb +1 -1
- data/lib/shellfie/yaml_safety.rb +147 -0
- data/lib/shellfie.rb +44 -3
- data/schema/shellfie-v1.schema.json +153 -0
- data/schema/shellfie-v2.schema.json +262 -0
- metadata +58 -20
- data/.rspec +0 -3
- data/Rakefile +0 -8
- data/examples/animation.yml +0 -33
- data/examples/colored.yml +0 -20
- data/examples/demo.gif +0 -0
- data/examples/demo.png +0 -0
- data/examples/demo_animation.yml +0 -31
- data/examples/headless.png +0 -0
- data/examples/headless.yml +0 -16
- data/examples/scrolling.yml +0 -48
- data/examples/simple.yml +0 -21
- data/examples/theme_macos.png +0 -0
- data/examples/theme_ubuntu.png +0 -0
- data/examples/theme_windows.png +0 -0
- data/shellfie.gemspec +0 -32
|
@@ -0,0 +1,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
|
data/lib/shellfie/errors.rb
CHANGED
|
@@ -1,15 +1,26 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Shellfie
|
|
4
|
-
class Error < StandardError
|
|
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
|