shellfie 0.1.1 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +56 -2
- data/README.md +248 -228
- data/lib/shellfie/animation_frame_builder.rb +202 -0
- data/lib/shellfie/animation_scroll_easing.rb +77 -0
- data/lib/shellfie/animation_timeline.rb +28 -0
- data/lib/shellfie/ansi_colors.rb +94 -0
- data/lib/shellfie/ansi_line_buffer.rb +103 -0
- data/lib/shellfie/ansi_normalizer.rb +61 -0
- data/lib/shellfie/ansi_parser.rb +162 -83
- data/lib/shellfie/cassette.rb +76 -0
- data/lib/shellfie/cli.rb +75 -163
- data/lib/shellfie/cli_authoring.rb +233 -0
- data/lib/shellfie/cli_generate.rb +401 -0
- data/lib/shellfie/cli_info.rb +239 -0
- data/lib/shellfie/cli_run.rb +167 -0
- data/lib/shellfie/config.rb +112 -25
- data/lib/shellfie/config_defaults.rb +83 -0
- data/lib/shellfie/config_validation.rb +289 -0
- data/lib/shellfie/dependency_checker.rb +147 -0
- data/lib/shellfie/errors.rb +12 -1
- data/lib/shellfie/ffmpeg_encoder.rb +46 -0
- data/lib/shellfie/font_resolver.rb +68 -0
- data/lib/shellfie/format_resolver.rb +15 -0
- data/lib/shellfie/gif_generator.rb +231 -89
- data/lib/shellfie/gif_palette.rb +105 -0
- data/lib/shellfie/headless_theme_registry.rb +42 -0
- data/lib/shellfie/html_renderer.rb +54 -0
- data/lib/shellfie/image_magick_command_builder.rb +75 -0
- data/lib/shellfie/line_layout.rb +146 -0
- data/lib/shellfie/output_writer.rb +46 -0
- data/lib/shellfie/parser.rb +183 -30
- data/lib/shellfie/parser_validation.rb +178 -0
- data/lib/shellfie/raster_painter.rb +157 -0
- data/lib/shellfie/render_chrome_cache.rb +40 -0
- data/lib/shellfie/render_geometry.rb +115 -0
- data/lib/shellfie/render_segment.rb +71 -0
- data/lib/shellfie/renderer.rb +96 -149
- data/lib/shellfie/rendering/shape_helpers.rb +42 -0
- data/lib/shellfie/rendering/text_painter.rb +195 -0
- data/lib/shellfie/rendering/window_chrome.rb +196 -0
- data/lib/shellfie/reproducibility_manifest.rb +41 -0
- data/lib/shellfie/session.rb +111 -0
- data/lib/shellfie/session_config.rb +562 -0
- data/lib/shellfie/session_runner.rb +689 -0
- data/lib/shellfie/svg_raster_wrapper.rb +35 -0
- data/lib/shellfie/svg_renderer.rb +222 -0
- data/lib/shellfie/terminal_screen.rb +389 -0
- data/lib/shellfie/text_metrics.rb +155 -0
- data/lib/shellfie/theme_data.rb +80 -0
- data/lib/shellfie/theme_registry.rb +131 -0
- data/lib/shellfie/themes/base.rb +10 -1
- data/lib/shellfie/themes/configured.rb +61 -0
- data/lib/shellfie/themes/macos.rb +3 -1
- data/lib/shellfie/themes/ubuntu.rb +2 -1
- data/lib/shellfie/themes/windows_terminal.rb +7 -1
- data/lib/shellfie/transcript_renderer.rb +92 -0
- data/lib/shellfie/version.rb +1 -1
- data/lib/shellfie/yaml_safety.rb +147 -0
- data/lib/shellfie.rb +44 -3
- data/schema/shellfie-v1.schema.json +153 -0
- data/schema/shellfie-v2.schema.json +262 -0
- metadata +58 -20
- data/.rspec +0 -3
- data/Rakefile +0 -8
- data/examples/animation.yml +0 -33
- data/examples/colored.yml +0 -20
- data/examples/demo.gif +0 -0
- data/examples/demo.png +0 -0
- data/examples/demo_animation.yml +0 -31
- data/examples/headless.png +0 -0
- data/examples/headless.yml +0 -16
- data/examples/scrolling.yml +0 -48
- data/examples/simple.yml +0 -21
- data/examples/theme_macos.png +0 -0
- data/examples/theme_ubuntu.png +0 -0
- data/examples/theme_windows.png +0 -0
- data/shellfie.gemspec +0 -32
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "optparse"
|
|
5
|
+
require "yaml"
|
|
6
|
+
require "cgi/escape"
|
|
7
|
+
|
|
8
|
+
module Shellfie
|
|
9
|
+
module CLIInfo
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
def run_init
|
|
13
|
+
puts <<~YAML
|
|
14
|
+
# Shellfie configuration file
|
|
15
|
+
version: 1
|
|
16
|
+
theme: macos
|
|
17
|
+
title: "Terminal — zsh"
|
|
18
|
+
|
|
19
|
+
window:
|
|
20
|
+
width: 600
|
|
21
|
+
padding: 20
|
|
22
|
+
|
|
23
|
+
lines:
|
|
24
|
+
- prompt: "$ "
|
|
25
|
+
command: "gem install shellfie"
|
|
26
|
+
|
|
27
|
+
- output: |
|
|
28
|
+
Fetching shellfie-#{VERSION}.gem
|
|
29
|
+
Successfully installed shellfie-#{VERSION}
|
|
30
|
+
1 gem installed
|
|
31
|
+
|
|
32
|
+
- prompt: "$ "
|
|
33
|
+
command: "shellfie --version"
|
|
34
|
+
|
|
35
|
+
- output: "shellfie #{VERSION}"
|
|
36
|
+
YAML
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def run_themes
|
|
40
|
+
puts "Available themes:"
|
|
41
|
+
puts
|
|
42
|
+
ThemeRegistry.available_themes.each { |theme| puts " #{theme}" }
|
|
43
|
+
puts
|
|
44
|
+
puts "Available color schemes:"
|
|
45
|
+
ThemeRegistry.available_color_schemes.each { |scheme| puts " #{scheme}" }
|
|
46
|
+
puts
|
|
47
|
+
puts "Use: shellfie generate config.yml -o output.png -t THEME_NAME"
|
|
48
|
+
end
|
|
49
|
+
|
|
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
|
|
55
|
+
input_file = @args.shift
|
|
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
|
|
68
|
+
|
|
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
|
+
|
|
77
|
+
puts "✓ Configuration is valid"
|
|
78
|
+
puts " Theme: #{config.theme}"
|
|
79
|
+
puts " Title: #{config.title}"
|
|
80
|
+
puts " Mode: #{config.animated? ? "animated" : "static"}"
|
|
81
|
+
puts " Lines: #{config.lines.size}" if config.static?
|
|
82
|
+
puts " Source frames: #{config.frames.size}" if config.animated?
|
|
83
|
+
puts " Estimated render frames: #{AnimationFrameBuilder.new(config).build.size}" if config.animated?
|
|
84
|
+
geometry = Renderer.new(config).estimate
|
|
85
|
+
puts " Estimated size: #{geometry[:canvas_width]}x#{geometry[:canvas_height]}"
|
|
86
|
+
puts " Logical size: #{geometry[:logical_width]}x#{geometry[:logical_height]} @#{geometry[:scale]}x"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def run_inspect
|
|
90
|
+
json = false
|
|
91
|
+
OptionParser.new { |opts| opts.on("--json", "Print machine-readable JSON") { json = true } }.parse!(@args)
|
|
92
|
+
input_file = @args.shift
|
|
93
|
+
raise ConfigError, "Input file is required" unless input_file
|
|
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
|
+
|
|
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
|
|
122
|
+
puts "Config:"
|
|
123
|
+
puts " Version: #{info[:config][:version]}"
|
|
124
|
+
puts " Theme: #{info[:theme]}"
|
|
125
|
+
puts " Title: #{info[:config][:title]}"
|
|
126
|
+
puts " Mode: #{info[:config][:frames].empty? ? "static" : "animated"}"
|
|
127
|
+
puts " Lines: #{info[:config][:lines].size}"
|
|
128
|
+
puts " Frames: #{info[:config][:frames].size}"
|
|
129
|
+
puts " Estimated size: #{info[:geometry][:canvas_width]}x#{info[:geometry][:canvas_height]}"
|
|
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
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def run_doctor
|
|
139
|
+
failed = false
|
|
140
|
+
DependencyChecker.doctor.each do |check|
|
|
141
|
+
status = check[:ok] ? "ok" : "fail"
|
|
142
|
+
failed ||= !check[:ok]
|
|
143
|
+
puts "#{status.ljust(4)} #{check[:name]}: #{check[:detail]}"
|
|
144
|
+
end
|
|
145
|
+
exit 4 if failed
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def run_version
|
|
149
|
+
puts "shellfie #{VERSION}"
|
|
150
|
+
end
|
|
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
|
+
|
|
177
|
+
def show_help
|
|
178
|
+
puts <<~HELP
|
|
179
|
+
Shellfie - Deterministic terminal visual compiler
|
|
180
|
+
|
|
181
|
+
Usage: shellfie <command> [options]
|
|
182
|
+
shf <command> [options]
|
|
183
|
+
|
|
184
|
+
Commands:
|
|
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
|
|
195
|
+
init Output sample configuration
|
|
196
|
+
themes List available themes
|
|
197
|
+
validate Validate configuration file
|
|
198
|
+
inspect Print resolved config and estimated image size
|
|
199
|
+
doctor Check dependencies and local environment
|
|
200
|
+
version Show version
|
|
201
|
+
help Show this help
|
|
202
|
+
|
|
203
|
+
Generate Options:
|
|
204
|
+
-o, --output PATH Output path/template (defaults beside input)
|
|
205
|
+
-t, --theme NAME Override theme (macos, ubuntu, windows)
|
|
206
|
+
-a, --animate Render animated output
|
|
207
|
+
-s, --scale FACTOR Output scale (1, 2, 3)
|
|
208
|
+
-w, --width PIXELS Override width
|
|
209
|
+
--no-shadow Disable shadow effect
|
|
210
|
+
--no-header Disable window header (headless mode)
|
|
211
|
+
--transparent Transparent background
|
|
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
|
|
217
|
+
--overflow MODE Line overflow mode: clip, wrap, scroll
|
|
218
|
+
--wrap, --no-wrap Enable or disable long-line wrapping
|
|
219
|
+
--exact-size Match canvas to configured window size
|
|
220
|
+
--format FORMAT Also: mp4, webm, png-sequence, html, txt, json
|
|
221
|
+
--force Overwrite existing output files
|
|
222
|
+
--quiet Suppress non-error output
|
|
223
|
+
--verbose Print progress details
|
|
224
|
+
--manifest PATH Write environment and output fingerprints
|
|
225
|
+
|
|
226
|
+
Examples:
|
|
227
|
+
shellfie generate config.yml -o terminal.png
|
|
228
|
+
shellfie generate config.yml -o demo.gif --animate
|
|
229
|
+
shellfie generate config.yml -o retina.png --scale 2
|
|
230
|
+
shellfie init > my-config.yml
|
|
231
|
+
shellfie themes
|
|
232
|
+
|
|
233
|
+
# Short form
|
|
234
|
+
shf generate config.yml -o terminal.png
|
|
235
|
+
shf init > config.yml
|
|
236
|
+
HELP
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
end
|
|
@@ -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
|
@@ -1,40 +1,86 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "config_validation"
|
|
4
|
+
require_relative "config_defaults"
|
|
5
|
+
require_relative "errors"
|
|
6
|
+
require_relative "theme_registry"
|
|
7
|
+
|
|
3
8
|
module Shellfie
|
|
4
9
|
class Config
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
10
|
+
include ConfigValidation
|
|
11
|
+
|
|
12
|
+
VALID_THEMES = ThemeRegistry.available_themes.freeze
|
|
13
|
+
VALID_OVERFLOW_MODES = %w[clip wrap scroll].freeze
|
|
14
|
+
VALID_CURSOR_STYLES = %w[block bar underline].freeze
|
|
15
|
+
VALID_PALETTES = %w[global adaptive theme].freeze
|
|
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
|
|
19
|
+
|
|
20
|
+
class << self
|
|
21
|
+
def deep_dup(value)
|
|
22
|
+
case value
|
|
23
|
+
when Hash
|
|
24
|
+
value.each_with_object({}) { |(key, nested_value), result| result[key] = deep_dup(nested_value) }
|
|
25
|
+
when Array
|
|
26
|
+
value.map { |nested_value| deep_dup(nested_value) }
|
|
27
|
+
else
|
|
28
|
+
value
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def deep_freeze(value)
|
|
33
|
+
case value
|
|
34
|
+
when Hash
|
|
35
|
+
value.each_value { |nested_value| deep_freeze(nested_value) }
|
|
36
|
+
when Array
|
|
37
|
+
value.each { |nested_value| deep_freeze(nested_value) }
|
|
38
|
+
end
|
|
39
|
+
value.freeze
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def normalize_keys(value)
|
|
43
|
+
case value
|
|
44
|
+
when Hash
|
|
45
|
+
value.each_with_object({}) do |(key, nested_value), result|
|
|
46
|
+
normalized_key = key.is_a?(String) ? key.to_sym : key
|
|
47
|
+
result[normalized_key] = normalize_keys(nested_value)
|
|
48
|
+
end
|
|
49
|
+
when Array
|
|
50
|
+
value.map { |nested_value| normalize_keys(nested_value) }
|
|
51
|
+
else
|
|
52
|
+
value
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
DEFAULTS = deep_freeze(deep_dup(ConfigDefaults::VALUES))
|
|
25
58
|
|
|
26
|
-
attr_reader :theme, :
|
|
59
|
+
attr_reader :version, :theme, :window_theme, :color_scheme, :colors, :window_decoration, :title, :window, :font,
|
|
60
|
+
:lines, :animation, :frames, :headless, :cursor, :limits, :source_paths
|
|
27
61
|
|
|
28
62
|
def initialize(options = {})
|
|
63
|
+
options = self.class.normalize_keys(options)
|
|
29
64
|
merged = merge_defaults(options)
|
|
65
|
+
@version = merged[:version]
|
|
30
66
|
@theme = merged[:theme]
|
|
67
|
+
@window_theme = merged[:window_theme]
|
|
68
|
+
@color_scheme = merged[:color_scheme]
|
|
69
|
+
@colors = merged[:colors]
|
|
70
|
+
@window_decoration = merged[:window_decoration]
|
|
31
71
|
@title = merged[:title] || "Terminal"
|
|
32
72
|
@window = merged[:window]
|
|
33
73
|
@font = merged[:font]
|
|
34
74
|
@lines = merged[:lines] || []
|
|
35
75
|
@animation = merged[:animation]
|
|
36
76
|
@frames = merged[:frames] || []
|
|
37
|
-
@
|
|
77
|
+
@cursor = merged[:cursor]
|
|
78
|
+
@limits = merged[:limits]
|
|
79
|
+
@headless = merged[:headless] || false
|
|
80
|
+
@source_paths = Array(options[:source_paths])
|
|
81
|
+
|
|
82
|
+
validate!
|
|
83
|
+
freeze_state!
|
|
38
84
|
end
|
|
39
85
|
|
|
40
86
|
def static?
|
|
@@ -45,21 +91,62 @@ module Shellfie
|
|
|
45
91
|
!static?
|
|
46
92
|
end
|
|
47
93
|
|
|
94
|
+
def to_h
|
|
95
|
+
{
|
|
96
|
+
version: version,
|
|
97
|
+
theme: theme,
|
|
98
|
+
window_theme: window_theme,
|
|
99
|
+
color_scheme: color_scheme,
|
|
100
|
+
colors: colors,
|
|
101
|
+
window_decoration: window_decoration,
|
|
102
|
+
title: title,
|
|
103
|
+
window: window,
|
|
104
|
+
font: font,
|
|
105
|
+
animation: animation,
|
|
106
|
+
cursor: cursor,
|
|
107
|
+
limits: limits,
|
|
108
|
+
headless: headless,
|
|
109
|
+
lines: lines.map(&:to_h),
|
|
110
|
+
frames: frames.map(&:to_h)
|
|
111
|
+
}
|
|
112
|
+
end
|
|
113
|
+
|
|
48
114
|
private
|
|
49
115
|
|
|
50
116
|
def merge_defaults(options)
|
|
51
|
-
result =
|
|
117
|
+
result = self.class.deep_dup(DEFAULTS)
|
|
52
118
|
DEFAULTS.each do |key, value|
|
|
119
|
+
next unless options.key?(key)
|
|
120
|
+
|
|
53
121
|
result[key] = if value.is_a?(Hash) && options[key].is_a?(Hash)
|
|
54
|
-
|
|
122
|
+
result[key].merge(options[key])
|
|
55
123
|
else
|
|
56
|
-
|
|
124
|
+
self.class.deep_dup(options[key])
|
|
57
125
|
end
|
|
58
126
|
end
|
|
59
127
|
result[:title] = options[:title]
|
|
60
128
|
result[:lines] = options[:lines]
|
|
61
129
|
result[:frames] = options[:frames]
|
|
130
|
+
result[:headless] = options[:headless] if options.key?(:headless)
|
|
62
131
|
result
|
|
63
132
|
end
|
|
133
|
+
|
|
134
|
+
def freeze_state!
|
|
135
|
+
@colors = self.class.deep_freeze(@colors)
|
|
136
|
+
@window_decoration = self.class.deep_freeze(@window_decoration)
|
|
137
|
+
@window = self.class.deep_freeze(@window)
|
|
138
|
+
@font = self.class.deep_freeze(@font)
|
|
139
|
+
@animation = self.class.deep_freeze(@animation)
|
|
140
|
+
@cursor = self.class.deep_freeze(@cursor)
|
|
141
|
+
@limits = self.class.deep_freeze(@limits)
|
|
142
|
+
@lines = self.class.deep_freeze(@lines)
|
|
143
|
+
@frames = self.class.deep_freeze(@frames)
|
|
144
|
+
@title.freeze
|
|
145
|
+
@source_paths = self.class.deep_freeze(@source_paths)
|
|
146
|
+
@theme.freeze
|
|
147
|
+
@window_theme.freeze if @window_theme
|
|
148
|
+
@color_scheme.freeze if @color_scheme
|
|
149
|
+
freeze
|
|
150
|
+
end
|
|
64
151
|
end
|
|
65
152
|
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Shellfie
|
|
4
|
+
module ConfigDefaults
|
|
5
|
+
VALUES = {
|
|
6
|
+
version: 1,
|
|
7
|
+
theme: "macos",
|
|
8
|
+
window_theme: nil,
|
|
9
|
+
color_scheme: nil,
|
|
10
|
+
colors: {},
|
|
11
|
+
window_decoration: {},
|
|
12
|
+
window: {
|
|
13
|
+
width: 600,
|
|
14
|
+
height: nil,
|
|
15
|
+
padding: 20,
|
|
16
|
+
opacity: 1.0,
|
|
17
|
+
visible_lines: nil,
|
|
18
|
+
max_lines: nil,
|
|
19
|
+
max_height: nil,
|
|
20
|
+
wrap: false,
|
|
21
|
+
overflow: "clip",
|
|
22
|
+
margin: nil,
|
|
23
|
+
exact_size: false,
|
|
24
|
+
trim: false,
|
|
25
|
+
tab_width: 8,
|
|
26
|
+
ambiguous_width: 1,
|
|
27
|
+
osc_policy: "ignore",
|
|
28
|
+
graphics_policy: "ignore",
|
|
29
|
+
ansi_state: "persistent",
|
|
30
|
+
background_gradient: nil,
|
|
31
|
+
scroll_offset: 0.0
|
|
32
|
+
},
|
|
33
|
+
font: {
|
|
34
|
+
family: "Monaco",
|
|
35
|
+
size: 14,
|
|
36
|
+
line_height: 1.4,
|
|
37
|
+
fallback_family: nil,
|
|
38
|
+
italic_family: nil,
|
|
39
|
+
emoji_family: nil
|
|
40
|
+
},
|
|
41
|
+
animation: {
|
|
42
|
+
typing_speed: 80,
|
|
43
|
+
command_delay: 500,
|
|
44
|
+
cursor_blink: true,
|
|
45
|
+
loop: false,
|
|
46
|
+
typing_jitter: 0.0,
|
|
47
|
+
seed: 0,
|
|
48
|
+
typing_chunk_size: 1,
|
|
49
|
+
output_delay: 0,
|
|
50
|
+
final_delay: 1_000,
|
|
51
|
+
max_frames: nil,
|
|
52
|
+
dither: true,
|
|
53
|
+
palette: "global",
|
|
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
|
|
67
|
+
},
|
|
68
|
+
cursor: {
|
|
69
|
+
style: "block",
|
|
70
|
+
color: nil
|
|
71
|
+
},
|
|
72
|
+
limits: {
|
|
73
|
+
max_lines: 10_000,
|
|
74
|
+
max_frames: 500,
|
|
75
|
+
max_render_frames: 2_000,
|
|
76
|
+
max_characters: 200_000,
|
|
77
|
+
max_pixels: 50_000_000,
|
|
78
|
+
max_total_pixels: 2_000_000_000,
|
|
79
|
+
max_temp_bytes: 8_000_000_000
|
|
80
|
+
}
|
|
81
|
+
}.freeze
|
|
82
|
+
end
|
|
83
|
+
end
|