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