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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +56 -2
- data/README.md +258 -97
- data/lib/shellfie/animation_frame_builder.rb +31 -7
- data/lib/shellfie/animation_timeline.rb +2 -1
- data/lib/shellfie/ansi_line_buffer.rb +20 -4
- data/lib/shellfie/ansi_normalizer.rb +18 -8
- data/lib/shellfie/ansi_parser.rb +120 -7
- data/lib/shellfie/cassette.rb +76 -0
- data/lib/shellfie/cli.rb +65 -2
- data/lib/shellfie/cli_authoring.rb +233 -0
- data/lib/shellfie/cli_generate.rb +244 -40
- data/lib/shellfie/cli_info.rb +106 -6
- data/lib/shellfie/cli_run.rb +167 -0
- data/lib/shellfie/config.rb +5 -1
- data/lib/shellfie/config_defaults.rb +21 -2
- data/lib/shellfie/config_validation.rb +93 -4
- data/lib/shellfie/dependency_checker.rb +74 -3
- data/lib/shellfie/errors.rb +1 -0
- data/lib/shellfie/ffmpeg_encoder.rb +46 -0
- data/lib/shellfie/font_resolver.rb +11 -1
- data/lib/shellfie/gif_generator.rb +157 -11
- data/lib/shellfie/gif_palette.rb +8 -4
- data/lib/shellfie/html_renderer.rb +54 -0
- data/lib/shellfie/line_layout.rb +19 -10
- data/lib/shellfie/output_writer.rb +7 -2
- data/lib/shellfie/parser.rb +82 -19
- data/lib/shellfie/parser_validation.rb +48 -15
- data/lib/shellfie/render_geometry.rb +2 -1
- data/lib/shellfie/render_segment.rb +17 -5
- data/lib/shellfie/renderer.rb +28 -11
- data/lib/shellfie/rendering/text_painter.rb +23 -15
- data/lib/shellfie/rendering/window_chrome.rb +2 -2
- 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_renderer.rb +222 -0
- data/lib/shellfie/terminal_screen.rb +389 -0
- data/lib/shellfie/text_metrics.rb +74 -15
- 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 +8 -1
- data/schema/shellfie-v1.schema.json +153 -0
- data/schema/shellfie-v2.schema.json +262 -0
- metadata +27 -24
- data/.rspec +0 -3
- data/Rakefile +0 -8
- data/docs/.nojekyll +0 -0
- data/docs/index.html +0 -205
- data/docs/scripts.js +0 -85
- data/docs/styles.css +0 -507
- 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,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "rbconfig"
|
|
4
|
+
require "open3"
|
|
5
|
+
require "tempfile"
|
|
6
|
+
require_relative "font_resolver"
|
|
4
7
|
|
|
5
8
|
module Shellfie
|
|
6
9
|
class DependencyChecker
|
|
@@ -15,6 +18,25 @@ module Shellfie
|
|
|
15
18
|
!imagemagick_path.to_s.empty?
|
|
16
19
|
end
|
|
17
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
|
+
|
|
18
40
|
def ensure_imagemagick!
|
|
19
41
|
return if imagemagick_available?
|
|
20
42
|
|
|
@@ -32,21 +54,48 @@ module Shellfie
|
|
|
32
54
|
end
|
|
33
55
|
|
|
34
56
|
def doctor(output_dir: Dir.pwd)
|
|
57
|
+
details = imagemagick_details
|
|
58
|
+
video_version = ffmpeg_version
|
|
35
59
|
[
|
|
36
60
|
check("Ruby", RUBY_VERSION, Gem::Version.new(RUBY_VERSION) >= Gem::Version.new("3.0.0")),
|
|
37
|
-
check("ImageMagick",
|
|
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),
|
|
38
68
|
check("Writable output", output_dir, File.writable?(output_dir)),
|
|
39
69
|
check("Encoding", Encoding.default_external.name, true)
|
|
40
70
|
]
|
|
41
71
|
end
|
|
42
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
|
+
|
|
43
92
|
private
|
|
44
93
|
|
|
45
94
|
def check(name, detail, ok)
|
|
46
95
|
{ name: name, detail: detail, ok: ok }
|
|
47
96
|
end
|
|
48
97
|
|
|
49
|
-
def find_executable(names)
|
|
98
|
+
def find_executable(names, verify_imagemagick: true)
|
|
50
99
|
paths = ENV.fetch("PATH", "").split(File::PATH_SEPARATOR)
|
|
51
100
|
extensions = executable_extensions
|
|
52
101
|
|
|
@@ -54,7 +103,9 @@ module Shellfie
|
|
|
54
103
|
paths.each do |path|
|
|
55
104
|
extensions.each do |extension|
|
|
56
105
|
candidate = File.join(path, "#{name}#{extension}")
|
|
57
|
-
|
|
106
|
+
next unless File.file?(candidate) && File.executable?(candidate)
|
|
107
|
+
return candidate unless verify_imagemagick
|
|
108
|
+
return candidate if imagemagick_executable?(candidate)
|
|
58
109
|
end
|
|
59
110
|
end
|
|
60
111
|
end
|
|
@@ -62,6 +113,26 @@ module Shellfie
|
|
|
62
113
|
nil
|
|
63
114
|
end
|
|
64
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
|
+
|
|
65
136
|
def executable_extensions
|
|
66
137
|
return [""] unless windows?
|
|
67
138
|
|
data/lib/shellfie/errors.rb
CHANGED
|
@@ -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
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "open3"
|
|
4
|
+
require "digest"
|
|
5
|
+
|
|
3
6
|
module Shellfie
|
|
4
7
|
class FontResolver
|
|
5
8
|
FONT_FILES = {
|
|
@@ -31,6 +34,12 @@ module Shellfie
|
|
|
31
34
|
.find { |candidate| font_available?(candidate) }
|
|
32
35
|
end
|
|
33
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
|
+
|
|
34
43
|
private
|
|
35
44
|
|
|
36
45
|
def font_available?(font)
|
|
@@ -50,7 +59,8 @@ module Shellfie
|
|
|
50
59
|
if command.empty?
|
|
51
60
|
[]
|
|
52
61
|
else
|
|
53
|
-
|
|
62
|
+
stdout, = Open3.capture3(command, "-list", "font")
|
|
63
|
+
stdout.scan(/^\s*Font:\s+(.+)$/).flatten
|
|
54
64
|
end
|
|
55
65
|
end
|
|
56
66
|
end
|
|
@@ -2,10 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
require "mini_magick"
|
|
4
4
|
require "fileutils"
|
|
5
|
+
require "json"
|
|
5
6
|
require "tmpdir"
|
|
6
7
|
require_relative "animation_frame_builder"
|
|
7
8
|
require_relative "dependency_checker"
|
|
8
9
|
require_relative "format_resolver"
|
|
10
|
+
require_relative "ffmpeg_encoder"
|
|
9
11
|
require_relative "gif_palette"
|
|
10
12
|
require_relative "image_magick_command_builder"
|
|
11
13
|
require_relative "output_writer"
|
|
@@ -23,18 +25,21 @@ module Shellfie
|
|
|
23
25
|
@frame_builder = AnimationFrameBuilder.new(config)
|
|
24
26
|
end
|
|
25
27
|
|
|
26
|
-
def generate(output_path, scale: 1, shadow: true, transparent: false, format: nil)
|
|
28
|
+
def generate(output_path, scale: 1, shadow: true, transparent: false, format: nil, io: nil)
|
|
27
29
|
check_dependencies!
|
|
28
30
|
|
|
29
31
|
images = []
|
|
30
32
|
chrome_cache = RenderChromeCache.new
|
|
31
33
|
begin
|
|
32
|
-
frames = build_animation_frames
|
|
34
|
+
frames = playback_frames(coalesce_frames(build_animation_frames))
|
|
33
35
|
validate_frame_limit!(frames)
|
|
36
|
+
validate_workload!(frames, scale: scale, shadow: shadow)
|
|
34
37
|
warn_frame_count(frames)
|
|
35
38
|
images = render_frames(frames, scale: scale, shadow: shadow, transparent: transparent, chrome_cache: chrome_cache)
|
|
36
39
|
extension = FormatResolver.resolve(output_path, explicit: format, default: "gif")
|
|
37
|
-
|
|
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|
|
|
38
43
|
combine_to_animation(images, temporary_path, format: extension)
|
|
39
44
|
end
|
|
40
45
|
ensure
|
|
@@ -63,16 +68,24 @@ module Shellfie
|
|
|
63
68
|
def render_frames(frames, scale:, shadow:, transparent:, chrome_cache:)
|
|
64
69
|
temp_dir = Dir.mktmpdir("shellfie")
|
|
65
70
|
images = []
|
|
71
|
+
complete = false
|
|
72
|
+
visible_lines = fixed_visible_lines(frames)
|
|
66
73
|
|
|
67
74
|
frames.each_with_index do |frame, idx|
|
|
68
|
-
frame_config = create_frame_config(
|
|
75
|
+
frame_config = create_frame_config(
|
|
76
|
+
frame[:lines],
|
|
77
|
+
window_overrides: { visible_lines: visible_lines }.merge(frame[:window] || {})
|
|
78
|
+
)
|
|
69
79
|
renderer = Renderer.new(frame_config, chrome_cache: chrome_cache)
|
|
70
80
|
output_path = File.join(temp_dir, "frame_#{format("%04d", idx)}.png")
|
|
71
81
|
renderer.render(output_path, scale: scale, shadow: shadow, transparent: transparent)
|
|
72
82
|
images << { path: output_path, delay: frame[:delay] }
|
|
73
83
|
end
|
|
74
84
|
|
|
85
|
+
complete = true
|
|
75
86
|
images
|
|
87
|
+
ensure
|
|
88
|
+
FileUtils.rm_rf(temp_dir) if temp_dir && !complete
|
|
76
89
|
end
|
|
77
90
|
|
|
78
91
|
def create_frame_config(lines, window_overrides: {})
|
|
@@ -94,13 +107,28 @@ module Shellfie
|
|
|
94
107
|
end
|
|
95
108
|
|
|
96
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
|
|
124
|
+
|
|
97
125
|
palette = GifPalette.new(config: config, theme: theme) if format == "gif"
|
|
98
126
|
ImageMagickCommandBuilder.convert do |convert|
|
|
99
127
|
convert.dispose "none" if format == "gif"
|
|
100
|
-
convert.loop
|
|
128
|
+
convert.loop animation_loop_count
|
|
101
129
|
|
|
102
|
-
images.each do |img|
|
|
103
|
-
convert.delay
|
|
130
|
+
animation_entries(images).each do |delay, img|
|
|
131
|
+
convert.delay delay
|
|
104
132
|
convert << img[:path]
|
|
105
133
|
end
|
|
106
134
|
|
|
@@ -115,19 +143,80 @@ module Shellfie
|
|
|
115
143
|
case format
|
|
116
144
|
when "gif"
|
|
117
145
|
palette.apply(convert, images: images)
|
|
118
|
-
convert.layers "optimize"
|
|
146
|
+
convert.layers "optimize" if config.animation[:gif_optimize]
|
|
119
147
|
when "webp"
|
|
120
|
-
convert.define "webp:lossless
|
|
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]
|
|
121
152
|
end
|
|
122
153
|
end
|
|
123
154
|
|
|
155
|
+
def animation_loop_count
|
|
156
|
+
config.animation[:loop_count] || (config.animation[:loop] ? 0 : 1)
|
|
157
|
+
end
|
|
158
|
+
|
|
124
159
|
def cleanup_temp_files(images)
|
|
125
160
|
temp_dir = File.dirname(images.first[:path]) if images.any?
|
|
126
161
|
FileUtils.rm_rf(temp_dir) if temp_dir && Dir.exist?(temp_dir)
|
|
127
162
|
end
|
|
128
163
|
|
|
129
|
-
def
|
|
130
|
-
|
|
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]] }
|
|
131
220
|
end
|
|
132
221
|
|
|
133
222
|
def warn_frame_count(frames)
|
|
@@ -143,5 +232,62 @@ module Shellfie
|
|
|
143
232
|
raise ResourceLimitError, "Animation would generate #{frames.size} frames (max #{config.limits[:max_render_frames]})"
|
|
144
233
|
end
|
|
145
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
|
|
290
|
+
end
|
|
291
|
+
|
|
146
292
|
end
|
|
147
293
|
end
|
data/lib/shellfie/gif_palette.rb
CHANGED
|
@@ -21,7 +21,7 @@ module Shellfie
|
|
|
21
21
|
when "theme"
|
|
22
22
|
apply_theme_palette(convert)
|
|
23
23
|
else
|
|
24
|
-
convert.colors
|
|
24
|
+
convert.colors color_count
|
|
25
25
|
end
|
|
26
26
|
end
|
|
27
27
|
|
|
@@ -35,7 +35,7 @@ module Shellfie
|
|
|
35
35
|
def apply_global_palette(convert, images)
|
|
36
36
|
palette_path = build_global_palette(images)
|
|
37
37
|
convert.remap palette_path if palette_path
|
|
38
|
-
convert.colors
|
|
38
|
+
convert.colors color_count
|
|
39
39
|
end
|
|
40
40
|
|
|
41
41
|
def apply_theme_palette(convert)
|
|
@@ -59,7 +59,7 @@ module Shellfie
|
|
|
59
59
|
end
|
|
60
60
|
|
|
61
61
|
def build_theme_palette
|
|
62
|
-
colors = theme_colors.first(
|
|
62
|
+
colors = theme_colors.first(color_count)
|
|
63
63
|
return nil if colors.empty?
|
|
64
64
|
|
|
65
65
|
path = palette_path
|
|
@@ -86,7 +86,7 @@ module Shellfie
|
|
|
86
86
|
end
|
|
87
87
|
|
|
88
88
|
def theme_color_count
|
|
89
|
-
[[theme_colors.size, 16].max,
|
|
89
|
+
[[theme_colors.size, [16, color_count].min].max, color_count].min
|
|
90
90
|
end
|
|
91
91
|
|
|
92
92
|
def theme_colors
|
|
@@ -97,5 +97,9 @@ module Shellfie
|
|
|
97
97
|
@theme.window_decoration[:border]
|
|
98
98
|
].flatten.compact.uniq
|
|
99
99
|
end
|
|
100
|
+
|
|
101
|
+
def color_count
|
|
102
|
+
@config.animation[:gif_colors]
|
|
103
|
+
end
|
|
100
104
|
end
|
|
101
105
|
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
|
data/lib/shellfie/line_layout.rb
CHANGED
|
@@ -39,8 +39,8 @@ module Shellfie
|
|
|
39
39
|
|
|
40
40
|
def vertical_line_limit(title_bar_height, padding, line_height)
|
|
41
41
|
limits = [@config.window[:visible_lines], @config.window[:max_lines]].compact
|
|
42
|
-
|
|
43
|
-
available_height =
|
|
42
|
+
[@config.window[:height], @config.window[:max_height]].compact.each do |height|
|
|
43
|
+
available_height = height - title_bar_height - padding * 2
|
|
44
44
|
limits << [(available_height / line_height).floor, 1].max
|
|
45
45
|
end
|
|
46
46
|
|
|
@@ -58,8 +58,8 @@ module Shellfie
|
|
|
58
58
|
used_cells = 0
|
|
59
59
|
|
|
60
60
|
line[:segments].each do |segment|
|
|
61
|
-
segment.text.
|
|
62
|
-
width = TextMetrics.
|
|
61
|
+
TextMetrics.graphemes(segment.text).each do |char|
|
|
62
|
+
width = TextMetrics.grapheme_width(char, ambiguous_width: ambiguous_width)
|
|
63
63
|
if used_cells.positive? && used_cells + width > max_cells
|
|
64
64
|
wrapped << { segments: [], selected: line[:selected] }
|
|
65
65
|
used_cells = 0
|
|
@@ -78,29 +78,29 @@ module Shellfie
|
|
|
78
78
|
segments.each_with_object([]) do |segment, result|
|
|
79
79
|
break result if remaining <= 0
|
|
80
80
|
|
|
81
|
-
text = TextMetrics.take_cells(segment.text, remaining)
|
|
81
|
+
text = TextMetrics.take_cells(segment.text, remaining, ambiguous_width: ambiguous_width)
|
|
82
82
|
next if text.empty?
|
|
83
83
|
|
|
84
84
|
result << copy_segment(segment, text)
|
|
85
|
-
remaining -= TextMetrics.cell_width(text)
|
|
85
|
+
remaining -= TextMetrics.cell_width(text, ambiguous_width: ambiguous_width)
|
|
86
86
|
end
|
|
87
87
|
end
|
|
88
88
|
|
|
89
89
|
def scroll_segments(segments, max_cells)
|
|
90
|
-
total_cells = segments.sum { |segment| TextMetrics.cell_width(segment.text) }
|
|
90
|
+
total_cells = segments.sum { |segment| TextMetrics.cell_width(segment.text, ambiguous_width: ambiguous_width) }
|
|
91
91
|
return clip_segments(segments, max_cells) if total_cells <= max_cells
|
|
92
92
|
|
|
93
93
|
cells_to_drop = total_cells - max_cells
|
|
94
94
|
scrolled = []
|
|
95
95
|
|
|
96
96
|
segments.each do |segment|
|
|
97
|
-
segment_cells = TextMetrics.cell_width(segment.text)
|
|
97
|
+
segment_cells = TextMetrics.cell_width(segment.text, ambiguous_width: ambiguous_width)
|
|
98
98
|
if cells_to_drop >= segment_cells
|
|
99
99
|
cells_to_drop -= segment_cells
|
|
100
100
|
next
|
|
101
101
|
end
|
|
102
102
|
|
|
103
|
-
text = cells_to_drop.positive? ? TextMetrics.drop_cells(segment.text, cells_to_drop) : segment.text
|
|
103
|
+
text = cells_to_drop.positive? ? TextMetrics.drop_cells(segment.text, cells_to_drop, ambiguous_width: ambiguous_width) : segment.text
|
|
104
104
|
cells_to_drop = 0
|
|
105
105
|
scrolled << copy_segment(segment, text) unless text.empty?
|
|
106
106
|
end
|
|
@@ -127,11 +127,20 @@ module Shellfie
|
|
|
127
127
|
segment.bold,
|
|
128
128
|
segment.italic,
|
|
129
129
|
segment.underline,
|
|
130
|
+
segment.underline_style,
|
|
131
|
+
segment.underline_color,
|
|
130
132
|
segment.dim,
|
|
131
133
|
segment.reverse,
|
|
132
134
|
segment.strikethrough,
|
|
133
|
-
segment.overline
|
|
135
|
+
segment.overline,
|
|
136
|
+
segment.blink,
|
|
137
|
+
segment.conceal,
|
|
138
|
+
segment.link
|
|
134
139
|
]
|
|
135
140
|
end
|
|
141
|
+
|
|
142
|
+
def ambiguous_width
|
|
143
|
+
@config.window[:ambiguous_width]
|
|
144
|
+
end
|
|
136
145
|
end
|
|
137
146
|
end
|
|
@@ -7,13 +7,18 @@ require "tmpdir"
|
|
|
7
7
|
module Shellfie
|
|
8
8
|
class OutputWriter
|
|
9
9
|
class << self
|
|
10
|
-
def write(path, extension:)
|
|
10
|
+
def write(path, extension:, io: nil)
|
|
11
11
|
FileUtils.mkdir_p(output_directory(path)) unless stdout?(path)
|
|
12
12
|
temp = Tempfile.new(["shellfie", ".#{extension}"], output_directory(path), binmode: true)
|
|
13
13
|
temp.close
|
|
14
14
|
yield temp.path
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
if stdout?(path)
|
|
17
|
+
return File.binread(temp.path) unless io
|
|
18
|
+
|
|
19
|
+
File.open(temp.path, "rb") { |source| IO.copy_stream(source, io) }
|
|
20
|
+
return path
|
|
21
|
+
end
|
|
17
22
|
|
|
18
23
|
FileUtils.mv(temp.path, path)
|
|
19
24
|
path
|