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
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "mini_magick"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "json"
|
|
6
|
+
require "tmpdir"
|
|
7
|
+
require_relative "animation_frame_builder"
|
|
8
|
+
require_relative "dependency_checker"
|
|
9
|
+
require_relative "format_resolver"
|
|
10
|
+
require_relative "ffmpeg_encoder"
|
|
11
|
+
require_relative "gif_palette"
|
|
12
|
+
require_relative "image_magick_command_builder"
|
|
13
|
+
require_relative "output_writer"
|
|
14
|
+
require_relative "render_chrome_cache"
|
|
4
15
|
require_relative "renderer"
|
|
5
16
|
|
|
6
17
|
module Shellfie
|
|
@@ -11,141 +22,272 @@ module Shellfie
|
|
|
11
22
|
@config = config
|
|
12
23
|
@renderer = Renderer.new(config)
|
|
13
24
|
@theme = @renderer.theme
|
|
25
|
+
@frame_builder = AnimationFrameBuilder.new(config)
|
|
14
26
|
end
|
|
15
27
|
|
|
16
|
-
def generate(output_path, scale: 1, shadow: true)
|
|
28
|
+
def generate(output_path, scale: 1, shadow: true, transparent: false, format: nil, io: nil)
|
|
17
29
|
check_dependencies!
|
|
18
30
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
31
|
+
images = []
|
|
32
|
+
chrome_cache = RenderChromeCache.new
|
|
33
|
+
begin
|
|
34
|
+
frames = playback_frames(coalesce_frames(build_animation_frames))
|
|
35
|
+
validate_frame_limit!(frames)
|
|
36
|
+
validate_workload!(frames, scale: scale, shadow: shadow)
|
|
37
|
+
warn_frame_count(frames)
|
|
38
|
+
images = render_frames(frames, scale: scale, shadow: shadow, transparent: transparent, chrome_cache: chrome_cache)
|
|
39
|
+
extension = FormatResolver.resolve(output_path, explicit: format, default: "gif")
|
|
40
|
+
return write_png_sequence(images, output_path) if extension == "png-sequence"
|
|
41
|
+
|
|
42
|
+
OutputWriter.write(output_path, extension: extension, io: io) do |temporary_path|
|
|
43
|
+
combine_to_animation(images, temporary_path, format: extension)
|
|
44
|
+
end
|
|
45
|
+
ensure
|
|
46
|
+
cleanup_temp_files(images)
|
|
47
|
+
chrome_cache.cleanup
|
|
48
|
+
end
|
|
49
|
+
rescue MiniMagick::Error => e
|
|
50
|
+
raise RenderError.new("ImageMagick animation render failed: #{e.message}", category: :render)
|
|
24
51
|
end
|
|
25
52
|
|
|
26
53
|
private
|
|
27
54
|
|
|
28
55
|
def check_dependencies!
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
raise DependencyError, <<~MSG
|
|
32
|
-
ImageMagick not found
|
|
33
|
-
→ Please install ImageMagick: brew install imagemagick
|
|
34
|
-
→ Or visit: https://imagemagick.org/script/download.php
|
|
35
|
-
MSG
|
|
36
|
-
end
|
|
56
|
+
DependencyChecker.configure_mini_magick!
|
|
57
|
+
DependencyChecker.ensure_imagemagick!
|
|
37
58
|
end
|
|
38
59
|
|
|
39
60
|
def build_animation_frames
|
|
40
|
-
|
|
41
|
-
current_lines = []
|
|
42
|
-
animation_settings = config.animation
|
|
43
|
-
|
|
44
|
-
config.frames.each do |frame|
|
|
45
|
-
if frame.type
|
|
46
|
-
typing_frames = build_typing_frames(
|
|
47
|
-
current_lines.dup,
|
|
48
|
-
frame.prompt || "",
|
|
49
|
-
frame.type,
|
|
50
|
-
animation_settings[:typing_speed] || 80
|
|
51
|
-
)
|
|
52
|
-
frames.concat(typing_frames)
|
|
53
|
-
current_lines << { prompt: frame.prompt, command: frame.type }
|
|
54
|
-
end
|
|
55
|
-
|
|
56
|
-
if frame.output
|
|
57
|
-
frame.output.split("\n").each do |line|
|
|
58
|
-
current_lines << { output: line }
|
|
59
|
-
end
|
|
60
|
-
frames << { lines: build_display_lines(current_lines), delay: frame.delay || 100 }
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
if frame.delay && frame.delay > 0 && !frame.output
|
|
64
|
-
frames << { lines: build_display_lines(current_lines), delay: frame.delay }
|
|
65
|
-
end
|
|
66
|
-
end
|
|
67
|
-
|
|
68
|
-
frames
|
|
69
|
-
end
|
|
70
|
-
|
|
71
|
-
def build_typing_frames(base_lines, prompt, command, typing_speed)
|
|
72
|
-
frames = []
|
|
73
|
-
chars = command.chars
|
|
74
|
-
|
|
75
|
-
chars.each_with_index do |_char, i|
|
|
76
|
-
typed = command[0..i]
|
|
77
|
-
lines = base_lines.dup
|
|
78
|
-
lines << { prompt: prompt, command: typed, cursor: true }
|
|
79
|
-
frames << { lines: build_display_lines(lines), delay: typing_speed }
|
|
80
|
-
end
|
|
81
|
-
|
|
82
|
-
final_lines = base_lines.dup
|
|
83
|
-
final_lines << { prompt: prompt, command: command }
|
|
84
|
-
frames << { lines: build_display_lines(final_lines), delay: typing_speed }
|
|
85
|
-
|
|
86
|
-
frames
|
|
61
|
+
@frame_builder.build
|
|
87
62
|
end
|
|
88
63
|
|
|
89
|
-
def
|
|
90
|
-
|
|
91
|
-
if line_data[:prompt]
|
|
92
|
-
text = "#{line_data[:prompt]}#{line_data[:command]}"
|
|
93
|
-
text += "█" if line_data[:cursor]
|
|
94
|
-
Line.new(prompt: text, command: nil, output: nil)
|
|
95
|
-
else
|
|
96
|
-
Line.new(prompt: nil, command: nil, output: line_data[:output])
|
|
97
|
-
end
|
|
98
|
-
end
|
|
64
|
+
def cursor_text
|
|
65
|
+
@frame_builder.cursor_text
|
|
99
66
|
end
|
|
100
67
|
|
|
101
|
-
def render_frames(frames, scale:, shadow:)
|
|
68
|
+
def render_frames(frames, scale:, shadow:, transparent:, chrome_cache:)
|
|
102
69
|
temp_dir = Dir.mktmpdir("shellfie")
|
|
103
70
|
images = []
|
|
71
|
+
complete = false
|
|
72
|
+
visible_lines = fixed_visible_lines(frames)
|
|
104
73
|
|
|
105
74
|
frames.each_with_index do |frame, idx|
|
|
106
|
-
frame_config = create_frame_config(
|
|
107
|
-
|
|
75
|
+
frame_config = create_frame_config(
|
|
76
|
+
frame[:lines],
|
|
77
|
+
window_overrides: { visible_lines: visible_lines }.merge(frame[:window] || {})
|
|
78
|
+
)
|
|
79
|
+
renderer = Renderer.new(frame_config, chrome_cache: chrome_cache)
|
|
108
80
|
output_path = File.join(temp_dir, "frame_#{format("%04d", idx)}.png")
|
|
109
|
-
renderer.render(output_path, scale: scale, shadow: shadow)
|
|
81
|
+
renderer.render(output_path, scale: scale, shadow: shadow, transparent: transparent)
|
|
110
82
|
images << { path: output_path, delay: frame[:delay] }
|
|
111
83
|
end
|
|
112
84
|
|
|
85
|
+
complete = true
|
|
113
86
|
images
|
|
87
|
+
ensure
|
|
88
|
+
FileUtils.rm_rf(temp_dir) if temp_dir && !complete
|
|
114
89
|
end
|
|
115
90
|
|
|
116
|
-
def create_frame_config(lines)
|
|
91
|
+
def create_frame_config(lines, window_overrides: {})
|
|
117
92
|
Config.new(
|
|
118
93
|
theme: config.theme,
|
|
94
|
+
window_theme: config.window_theme,
|
|
95
|
+
color_scheme: config.color_scheme,
|
|
96
|
+
colors: config.colors,
|
|
97
|
+
window_decoration: config.window_decoration,
|
|
119
98
|
title: config.title,
|
|
120
|
-
window: config.window,
|
|
99
|
+
window: config.window.merge(window_overrides),
|
|
121
100
|
font: config.font,
|
|
122
101
|
lines: lines,
|
|
102
|
+
animation: config.animation,
|
|
103
|
+
cursor: config.cursor,
|
|
104
|
+
limits: config.limits,
|
|
123
105
|
headless: config.headless
|
|
124
106
|
)
|
|
125
107
|
end
|
|
126
108
|
|
|
127
|
-
def
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
109
|
+
def combine_to_animation(images, output_path, format:)
|
|
110
|
+
if %w[mp4 webm apng].include?(format)
|
|
111
|
+
DependencyChecker.ensure_ffmpeg!
|
|
112
|
+
return FfmpegEncoder.encode(
|
|
113
|
+
images,
|
|
114
|
+
output_path,
|
|
115
|
+
format: format,
|
|
116
|
+
command: DependencyChecker.ffmpeg_path,
|
|
117
|
+
framerate: config.animation[:framerate],
|
|
118
|
+
playback_speed: config.animation[:playback_speed],
|
|
119
|
+
loop: config.animation[:loop],
|
|
120
|
+
loop_count: config.animation[:loop_count],
|
|
121
|
+
apng_prediction: config.animation[:apng_prediction]
|
|
122
|
+
)
|
|
123
|
+
end
|
|
131
124
|
|
|
132
|
-
|
|
133
|
-
|
|
125
|
+
palette = GifPalette.new(config: config, theme: theme) if format == "gif"
|
|
126
|
+
ImageMagickCommandBuilder.convert do |convert|
|
|
127
|
+
convert.dispose "none" if format == "gif"
|
|
128
|
+
convert.loop animation_loop_count
|
|
129
|
+
|
|
130
|
+
animation_entries(images).each do |delay, img|
|
|
134
131
|
convert.delay delay
|
|
135
132
|
convert << img[:path]
|
|
136
133
|
end
|
|
137
134
|
|
|
138
|
-
convert
|
|
139
|
-
convert
|
|
140
|
-
|
|
141
|
-
|
|
135
|
+
configure_animation_format(convert, format, images: images, palette: palette)
|
|
136
|
+
ImageMagickCommandBuilder.output(convert, output_path, format: format)
|
|
137
|
+
end
|
|
138
|
+
ensure
|
|
139
|
+
palette&.cleanup
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def configure_animation_format(convert, format, images:, palette:)
|
|
143
|
+
case format
|
|
144
|
+
when "gif"
|
|
145
|
+
palette.apply(convert, images: images)
|
|
146
|
+
convert.layers "optimize" if config.animation[:gif_optimize]
|
|
147
|
+
when "webp"
|
|
148
|
+
convert.define "webp:lossless=#{config.animation[:webp_lossless]}"
|
|
149
|
+
convert.define "webp:method=#{config.animation[:webp_method]}"
|
|
150
|
+
convert.define "webp:near-lossless=#{config.animation[:webp_near_lossless]}"
|
|
151
|
+
convert.quality config.animation[:webp_quality]
|
|
142
152
|
end
|
|
143
153
|
end
|
|
144
154
|
|
|
155
|
+
def animation_loop_count
|
|
156
|
+
config.animation[:loop_count] || (config.animation[:loop] ? 0 : 1)
|
|
157
|
+
end
|
|
158
|
+
|
|
145
159
|
def cleanup_temp_files(images)
|
|
146
|
-
images.each { |img| File.delete(img[:path]) if File.exist?(img[:path]) }
|
|
147
160
|
temp_dir = File.dirname(images.first[:path]) if images.any?
|
|
148
|
-
|
|
161
|
+
FileUtils.rm_rf(temp_dir) if temp_dir && Dir.exist?(temp_dir)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def write_png_sequence(images, output_path)
|
|
165
|
+
if Dir.exist?(output_path) && !replaceable_sequence_directory?(output_path)
|
|
166
|
+
raise FileSystemError, "Refusing to replace a non-Shellfie directory: #{output_path}"
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
parent = File.dirname(File.expand_path(output_path))
|
|
170
|
+
FileUtils.mkdir_p(parent)
|
|
171
|
+
temporary = Dir.mktmpdir(".shellfie-sequence-", parent)
|
|
172
|
+
frames = images.each_with_index.map do |image, index|
|
|
173
|
+
filename = "frame_#{format("%04d", index)}.png"
|
|
174
|
+
FileUtils.cp(image[:path], File.join(temporary, filename))
|
|
175
|
+
{ file: filename, delay_ms: image[:delay] / config.animation[:playback_speed] }
|
|
176
|
+
end
|
|
177
|
+
File.write(File.join(temporary, "timeline.json"), JSON.pretty_generate(version: 1, frames: frames))
|
|
178
|
+
backup = "#{temporary}-previous"
|
|
179
|
+
File.rename(output_path, backup) if File.exist?(output_path)
|
|
180
|
+
File.rename(temporary, output_path)
|
|
181
|
+
FileUtils.rm_rf(backup)
|
|
182
|
+
output_path
|
|
183
|
+
rescue StandardError
|
|
184
|
+
File.rename(backup, output_path) if backup && File.exist?(backup) && !File.exist?(output_path)
|
|
185
|
+
raise
|
|
186
|
+
ensure
|
|
187
|
+
FileUtils.rm_rf(temporary) if temporary && Dir.exist?(temporary)
|
|
188
|
+
FileUtils.rm_rf(backup) if backup && File.exist?(backup)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def replaceable_sequence_directory?(path)
|
|
192
|
+
entries = Dir.children(path)
|
|
193
|
+
return true if entries.empty?
|
|
194
|
+
return false unless entries.include?("timeline.json")
|
|
195
|
+
|
|
196
|
+
entries.all? { |entry| entry == "timeline.json" || entry.match?(/\Aframe_\d{4}\.png\z/) }
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def animation_delays(images)
|
|
200
|
+
elapsed = 0.0
|
|
201
|
+
emitted = 0
|
|
202
|
+
images.map do |image|
|
|
203
|
+
elapsed += image[:delay] / config.animation[:playback_speed]
|
|
204
|
+
target = [(elapsed / 10.0).round, emitted + 1].max
|
|
205
|
+
(target - emitted).tap { emitted = target }
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def animation_entries(images)
|
|
210
|
+
target_ticks = [(images.sum { |image| image[:delay] } / config.animation[:playback_speed] / 10.0).round, 1].max
|
|
211
|
+
return animation_delays(images).zip(images) if target_ticks >= images.size
|
|
212
|
+
|
|
213
|
+
elapsed = 0.0
|
|
214
|
+
ends = images.map { |image| elapsed += image[:delay] / config.animation[:playback_speed] }
|
|
215
|
+
indexes = (1..target_ticks).map do |tick|
|
|
216
|
+
ends.index { |finish| finish >= tick * 10 } || images.size - 1
|
|
217
|
+
end
|
|
218
|
+
indexes[-1] = images.size - 1
|
|
219
|
+
indexes.chunk(&:itself).map { |index, ticks| [ticks.size, images[index]] }
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def warn_frame_count(frames)
|
|
223
|
+
max_frames = config.animation[:max_frames]
|
|
224
|
+
return unless max_frames && frames.size > max_frames
|
|
225
|
+
|
|
226
|
+
$stderr.puts "Warning: animation will generate #{frames.size} frames (max_frames is #{max_frames})"
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def validate_frame_limit!(frames)
|
|
230
|
+
return if frames.size <= config.limits[:max_render_frames]
|
|
231
|
+
|
|
232
|
+
raise ResourceLimitError, "Animation would generate #{frames.size} frames (max #{config.limits[:max_render_frames]})"
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def coalesce_frames(frames)
|
|
236
|
+
frames.each_with_object([]) do |frame, result|
|
|
237
|
+
if result.last && frame_key(result.last) == frame_key(frame)
|
|
238
|
+
result.last[:delay] += frame[:delay]
|
|
239
|
+
else
|
|
240
|
+
result << frame.merge(lines: frame[:lines].dup)
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def frame_key(frame)
|
|
246
|
+
[frame[:lines].map(&:to_h), frame[:window]]
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def playback_frames(frames)
|
|
250
|
+
frames = frames.reverse if config.animation[:direction] == "reverse"
|
|
251
|
+
offset = config.animation[:loop_offset]
|
|
252
|
+
if offset >= frames.size
|
|
253
|
+
raise ValidationError, "animation.loop_offset must be less than the #{frames.size} rendered frames"
|
|
254
|
+
end
|
|
255
|
+
frames = frames.rotate(offset)
|
|
256
|
+
return frames unless config.animation[:direction] == "ping_pong" && frames.size > 2
|
|
257
|
+
|
|
258
|
+
frames + frames[1...-1].reverse
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def validate_workload!(frames, scale:, shadow:)
|
|
262
|
+
speed = Rational(config.animation[:playback_speed].to_s)
|
|
263
|
+
encoded_frames = (frames.sum { |frame| frame[:delay] } * config.animation[:framerate] / (1_000 * speed)).ceil
|
|
264
|
+
if encoded_frames > config.limits[:max_render_frames]
|
|
265
|
+
raise ResourceLimitError,
|
|
266
|
+
"Animation timeline would encode #{encoded_frames} frames (max #{config.limits[:max_render_frames]})"
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
visible_lines = fixed_visible_lines(frames)
|
|
270
|
+
max_pixels = frames.map do |frame|
|
|
271
|
+
frame_config = create_frame_config(frame[:lines], window_overrides: { visible_lines: visible_lines }.merge(frame[:window] || {}))
|
|
272
|
+
geometry = Renderer.new(frame_config).estimate(scale: scale, shadow: shadow)
|
|
273
|
+
geometry[:canvas_width] * geometry[:canvas_height]
|
|
274
|
+
end.max || 0
|
|
275
|
+
total_pixels = max_pixels * frames.size
|
|
276
|
+
if total_pixels > config.limits[:max_total_pixels]
|
|
277
|
+
raise ResourceLimitError,
|
|
278
|
+
"Animation workload is too large (#{total_pixels} pixels, max #{config.limits[:max_total_pixels]})"
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
estimated_bytes = total_pixels * 4
|
|
282
|
+
return if estimated_bytes <= config.limits[:max_temp_bytes]
|
|
283
|
+
|
|
284
|
+
raise ResourceLimitError,
|
|
285
|
+
"Animation temporary data is too large (#{estimated_bytes} bytes, max #{config.limits[:max_temp_bytes]})"
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def fixed_visible_lines(frames)
|
|
289
|
+
config.window[:visible_lines] || [frames.map { |frame| frame[:lines].size }.max.to_i, 1].max
|
|
149
290
|
end
|
|
291
|
+
|
|
150
292
|
end
|
|
151
293
|
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tempfile"
|
|
4
|
+
require_relative "image_magick_command_builder"
|
|
5
|
+
|
|
6
|
+
module Shellfie
|
|
7
|
+
class GifPalette
|
|
8
|
+
def initialize(config:, theme:, command_builder: ImageMagickCommandBuilder)
|
|
9
|
+
@config = config
|
|
10
|
+
@theme = theme
|
|
11
|
+
@command_builder = command_builder
|
|
12
|
+
@temporary_files = []
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def apply(convert, images: [])
|
|
16
|
+
convert.dither(dither_mode)
|
|
17
|
+
|
|
18
|
+
case @config.animation[:palette]
|
|
19
|
+
when "global"
|
|
20
|
+
apply_global_palette(convert, images)
|
|
21
|
+
when "theme"
|
|
22
|
+
apply_theme_palette(convert)
|
|
23
|
+
else
|
|
24
|
+
convert.colors color_count
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def cleanup
|
|
29
|
+
@temporary_files.each { |file| file.close! if file.respond_to?(:close!) }
|
|
30
|
+
@temporary_files.clear
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def apply_global_palette(convert, images)
|
|
36
|
+
palette_path = build_global_palette(images)
|
|
37
|
+
convert.remap palette_path if palette_path
|
|
38
|
+
convert.colors color_count
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def apply_theme_palette(convert)
|
|
42
|
+
palette_path = build_theme_palette
|
|
43
|
+
convert.remap palette_path if palette_path
|
|
44
|
+
convert.colors(theme_color_count)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def build_global_palette(images)
|
|
48
|
+
return nil if images.empty?
|
|
49
|
+
|
|
50
|
+
path = palette_path
|
|
51
|
+
@command_builder.convert do |convert|
|
|
52
|
+
images.each { |image| convert << image[:path] }
|
|
53
|
+
convert.append
|
|
54
|
+
convert.colors 256
|
|
55
|
+
convert.unique_colors
|
|
56
|
+
@command_builder.output(convert, path, format: "png")
|
|
57
|
+
end
|
|
58
|
+
path
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def build_theme_palette
|
|
62
|
+
colors = theme_colors.first(color_count)
|
|
63
|
+
return nil if colors.empty?
|
|
64
|
+
|
|
65
|
+
path = palette_path
|
|
66
|
+
@command_builder.convert do |convert|
|
|
67
|
+
@command_builder.canvas(convert, width: colors.size, height: 1, background: "xc:transparent")
|
|
68
|
+
colors.each_with_index do |color, index|
|
|
69
|
+
convert.fill color
|
|
70
|
+
@command_builder.point(convert, index, 0)
|
|
71
|
+
end
|
|
72
|
+
@command_builder.output(convert, path, format: "png")
|
|
73
|
+
end
|
|
74
|
+
path
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def palette_path
|
|
78
|
+
file = Tempfile.new(["shellfie-palette", ".png"])
|
|
79
|
+
@temporary_files << file
|
|
80
|
+
file.close
|
|
81
|
+
file.path
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def dither_mode
|
|
85
|
+
@config.animation[:dither] ? "FloydSteinberg" : "None"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def theme_color_count
|
|
89
|
+
[[theme_colors.size, [16, color_count].min].max, color_count].min
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def theme_colors
|
|
93
|
+
@theme_colors ||= [
|
|
94
|
+
@theme.colors.values,
|
|
95
|
+
@theme.button_colors,
|
|
96
|
+
@theme.window_decoration.dig(:shadow, :color),
|
|
97
|
+
@theme.window_decoration[:border]
|
|
98
|
+
].flatten.compact.uniq
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def color_count
|
|
102
|
+
@config.animation[:gif_colors]
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "theme_data"
|
|
4
|
+
|
|
5
|
+
module Shellfie
|
|
6
|
+
class HeadlessThemeRegistry
|
|
7
|
+
VARIANTS = {
|
|
8
|
+
"plain" => {
|
|
9
|
+
window_decoration: {
|
|
10
|
+
title_bar_height: 0,
|
|
11
|
+
corner_radius: 0,
|
|
12
|
+
button_size: 0,
|
|
13
|
+
button_spacing: 0
|
|
14
|
+
},
|
|
15
|
+
button_colors: [],
|
|
16
|
+
button_style: :none,
|
|
17
|
+
buttons_position: :left,
|
|
18
|
+
title_alignment: :left
|
|
19
|
+
}
|
|
20
|
+
}.freeze
|
|
21
|
+
|
|
22
|
+
class << self
|
|
23
|
+
def build(theme, variant: "plain")
|
|
24
|
+
settings = VARIANTS.fetch(variant)
|
|
25
|
+
ThemeData.new(
|
|
26
|
+
name: theme.name,
|
|
27
|
+
colors: theme.colors,
|
|
28
|
+
window_decoration: ThemeData.deep_merge(theme.window_decoration, settings[:window_decoration]),
|
|
29
|
+
button_colors: settings[:button_colors],
|
|
30
|
+
buttons_position: settings[:buttons_position],
|
|
31
|
+
button_style: settings[:button_style],
|
|
32
|
+
font: theme.font,
|
|
33
|
+
title_alignment: settings[:title_alignment]
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def available_variants
|
|
38
|
+
VARIANTS.keys
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "cgi/escape"
|
|
4
|
+
require_relative "svg_renderer"
|
|
5
|
+
|
|
6
|
+
module Shellfie
|
|
7
|
+
class HtmlRenderer
|
|
8
|
+
def initialize(config:, theme:)
|
|
9
|
+
@config = config
|
|
10
|
+
@svg_renderer = SvgRenderer.new(config: config, theme: theme)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def render(geometry, output_path, transparent: false)
|
|
14
|
+
svg = @svg_renderer.to_svg(geometry, transparent: transparent).sub(/\A<\?xml[^>]+>\s*/, "")
|
|
15
|
+
transcript = geometry[:lines].map { |line| line[:segments].map(&:text).join }.join("\n")
|
|
16
|
+
File.write(output_path, <<~HTML)
|
|
17
|
+
<!doctype html>
|
|
18
|
+
<html lang="en">
|
|
19
|
+
<head>
|
|
20
|
+
<meta charset="utf-8">
|
|
21
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
22
|
+
<title>#{escape(@config.title)}</title>
|
|
23
|
+
<style>
|
|
24
|
+
:root { color-scheme: light dark; }
|
|
25
|
+
:root[data-theme="light"] { color-scheme: light; }
|
|
26
|
+
:root[data-theme="dark"] { color-scheme: dark; }
|
|
27
|
+
body { margin: 0; display: grid; min-height: 100vh; place-items: center; background: Canvas; }
|
|
28
|
+
main { max-width: 100%; overflow: auto; }
|
|
29
|
+
button { position: fixed; inset: 1rem 1rem auto auto; font: inherit; }
|
|
30
|
+
svg { display: block; max-width: 100%; height: auto; }
|
|
31
|
+
.transcript { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: pre; }
|
|
32
|
+
</style>
|
|
33
|
+
</head>
|
|
34
|
+
<body>
|
|
35
|
+
<button type="button" aria-label="Toggle color theme">Theme</button>
|
|
36
|
+
<main aria-label="#{escape(@config.title)}">#{svg}<pre class="transcript">#{escape(transcript)}</pre></main>
|
|
37
|
+
<script>
|
|
38
|
+
document.querySelector('button').addEventListener('click', () => {
|
|
39
|
+
const root = document.documentElement;
|
|
40
|
+
root.dataset.theme = root.dataset.theme === 'dark' ? 'light' : 'dark';
|
|
41
|
+
});
|
|
42
|
+
</script>
|
|
43
|
+
</body>
|
|
44
|
+
</html>
|
|
45
|
+
HTML
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def escape(value)
|
|
51
|
+
CGI.escapeHTML(value.to_s)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "mini_magick"
|
|
4
|
+
|
|
5
|
+
module Shellfie
|
|
6
|
+
class ImageMagickCommandBuilder
|
|
7
|
+
class << self
|
|
8
|
+
def convert(&block)
|
|
9
|
+
MiniMagick.convert(&block)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def canvas(convert, width:, height:, background:)
|
|
13
|
+
convert.size "#{width}x#{height}"
|
|
14
|
+
convert << background
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def output(convert, path, format: nil)
|
|
18
|
+
convert << output_path(path, format: format || File.extname(path).delete_prefix("."))
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def output_path(path, format:)
|
|
22
|
+
format == "apng" ? "apng:#{path}" : path
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def draw(convert, command)
|
|
26
|
+
convert.draw command
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def rectangle(convert, x1, y1, x2, y2)
|
|
30
|
+
draw(convert, "rectangle #{x1},#{y1} #{x2},#{y2}")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def rectangles(convert, rectangles)
|
|
34
|
+
return if rectangles.empty?
|
|
35
|
+
|
|
36
|
+
draw(convert, rectangles.map { |rect| "rectangle #{rect[:x1]},#{rect[:y1]} #{rect[:x2]},#{rect[:y2]}" }.join(" "))
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def round_rectangle(convert, x1, y1, x2, y2, radius)
|
|
40
|
+
draw(convert, "roundrectangle #{x1},#{y1} #{x2},#{y2} #{radius},#{radius}")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def line(convert, x1, y1, x2, y2)
|
|
44
|
+
draw(convert, "line #{x1},#{y1} #{x2},#{y2}")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def lines(convert, lines)
|
|
48
|
+
return if lines.empty?
|
|
49
|
+
|
|
50
|
+
draw(convert, lines.map { |line| "line #{line[:x1]},#{line[:y1]} #{line[:x2]},#{line[:y2]}" }.join(" "))
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def circle(convert, center_x, center_y, radius)
|
|
54
|
+
draw(convert, "circle #{center_x},#{center_y} #{center_x + radius},#{center_y}")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def point(convert, x, y)
|
|
58
|
+
draw(convert, "point #{x},#{y}")
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def region(convert, x:, y:, width:, height:)
|
|
62
|
+
convert.region "#{width}x#{height}+#{x}+#{y}"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def clear_region(convert)
|
|
66
|
+
convert << "+region"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def composite_over(convert)
|
|
70
|
+
convert.compose "over"
|
|
71
|
+
convert.composite
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|