asciinema_win 0.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 +7 -0
- data/README.md +575 -0
- data/exe/asciinema_win +17 -0
- data/lib/asciinema_win/ansi_parser.rb +437 -0
- data/lib/asciinema_win/asciicast.rb +537 -0
- data/lib/asciinema_win/cli.rb +591 -0
- data/lib/asciinema_win/export.rb +780 -0
- data/lib/asciinema_win/output_organizer.rb +276 -0
- data/lib/asciinema_win/player.rb +348 -0
- data/lib/asciinema_win/recorder.rb +480 -0
- data/lib/asciinema_win/screen_buffer.rb +375 -0
- data/lib/asciinema_win/themes.rb +334 -0
- data/lib/asciinema_win/version.rb +6 -0
- data/lib/asciinema_win.rb +153 -0
- data/lib/rich/_palettes.rb +148 -0
- data/lib/rich/box.rb +342 -0
- data/lib/rich/cells.rb +512 -0
- data/lib/rich/color.rb +628 -0
- data/lib/rich/color_triplet.rb +220 -0
- data/lib/rich/console.rb +549 -0
- data/lib/rich/control.rb +332 -0
- data/lib/rich/json.rb +254 -0
- data/lib/rich/layout.rb +314 -0
- data/lib/rich/markdown.rb +509 -0
- data/lib/rich/markup.rb +175 -0
- data/lib/rich/panel.rb +311 -0
- data/lib/rich/progress.rb +430 -0
- data/lib/rich/segment.rb +387 -0
- data/lib/rich/style.rb +433 -0
- data/lib/rich/syntax.rb +1145 -0
- data/lib/rich/table.rb +525 -0
- data/lib/rich/terminal_theme.rb +126 -0
- data/lib/rich/text.rb +433 -0
- data/lib/rich/tree.rb +220 -0
- data/lib/rich/version.rb +5 -0
- data/lib/rich/win32_console.rb +859 -0
- data/lib/rich.rb +108 -0
- metadata +123 -0
|
@@ -0,0 +1,780 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AsciinemaWin
|
|
4
|
+
# Export module for converting recordings to different formats
|
|
5
|
+
#
|
|
6
|
+
# Supports export to:
|
|
7
|
+
# - Cast (asciicast v2 - copy/convert)
|
|
8
|
+
# - HTML (embedded player)
|
|
9
|
+
# - SVG (static snapshot)
|
|
10
|
+
# - Text (plain text dump)
|
|
11
|
+
# - JSON (normalized format)
|
|
12
|
+
# - GIF/MP4/WebM (requires FFmpeg - optional)
|
|
13
|
+
#
|
|
14
|
+
# @note Video export requires FFmpeg installed and in PATH.
|
|
15
|
+
# This is an OPTIONAL feature - core functionality works without it.
|
|
16
|
+
module Export
|
|
17
|
+
# Export formats supported natively (no external dependencies)
|
|
18
|
+
NATIVE_FORMATS = %i[cast html svg txt text json].freeze
|
|
19
|
+
|
|
20
|
+
# Export formats requiring external tools
|
|
21
|
+
EXTERNAL_FORMATS = %i[gif mp4 webm].freeze
|
|
22
|
+
|
|
23
|
+
# All supported formats
|
|
24
|
+
ALL_FORMATS = (NATIVE_FORMATS + EXTERNAL_FORMATS).freeze
|
|
25
|
+
|
|
26
|
+
class << self
|
|
27
|
+
# Export a recording to the specified format
|
|
28
|
+
#
|
|
29
|
+
# @param input_path [String] Path to the .cast file
|
|
30
|
+
# @param output_path [String] Path for the output file
|
|
31
|
+
# @param format [Symbol] Output format (:cast, :html, :svg, :txt, :json, :gif, :mp4, :webm)
|
|
32
|
+
# @param options [Hash] Format-specific options
|
|
33
|
+
# @return [Boolean] True if export succeeded
|
|
34
|
+
# @raise [ExportError] If export fails
|
|
35
|
+
def export(input_path, output_path, format:, **options)
|
|
36
|
+
format = format.to_sym
|
|
37
|
+
|
|
38
|
+
unless ALL_FORMATS.include?(format)
|
|
39
|
+
raise ExportError, "Unsupported format: #{format}. Supported: #{ALL_FORMATS.join(", ")}"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
case format
|
|
43
|
+
when :cast
|
|
44
|
+
export_cast(input_path, output_path, **options)
|
|
45
|
+
when :html
|
|
46
|
+
export_html(input_path, output_path, **options)
|
|
47
|
+
when :svg
|
|
48
|
+
export_svg(input_path, output_path, **options)
|
|
49
|
+
when :txt, :text
|
|
50
|
+
export_text(input_path, output_path, **options)
|
|
51
|
+
when :json
|
|
52
|
+
export_json(input_path, output_path, **options)
|
|
53
|
+
when :gif, :mp4, :webm
|
|
54
|
+
export_video(input_path, output_path, format: format, **options)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Export to asciicast v2 format (copy or transform)
|
|
59
|
+
#
|
|
60
|
+
# @param input_path [String] Input .cast file
|
|
61
|
+
# @param output_path [String] Output .cast file
|
|
62
|
+
# @param title [String, nil] New title (optional)
|
|
63
|
+
# @param trim_start [Float, nil] Trim seconds from start
|
|
64
|
+
# @param trim_end [Float, nil] Trim seconds from end
|
|
65
|
+
# @param speed [Float] Speed multiplier (1.0 = normal, 2.0 = 2x faster)
|
|
66
|
+
# @param max_idle [Float, nil] Maximum idle time between events
|
|
67
|
+
# @return [Boolean] Success
|
|
68
|
+
def export_cast(input_path, output_path, title: nil, trim_start: nil, trim_end: nil, speed: 1.0, max_idle: nil, **_options)
|
|
69
|
+
reader = Asciicast.load(input_path)
|
|
70
|
+
original_header = reader.header
|
|
71
|
+
|
|
72
|
+
# Create new header with potential modifications
|
|
73
|
+
new_header = Asciicast::Header.new(
|
|
74
|
+
width: original_header.width,
|
|
75
|
+
height: original_header.height,
|
|
76
|
+
timestamp: original_header.timestamp,
|
|
77
|
+
idle_time_limit: max_idle || original_header.idle_time_limit,
|
|
78
|
+
command: original_header.command,
|
|
79
|
+
title: title || original_header.title,
|
|
80
|
+
env: original_header.env,
|
|
81
|
+
theme: original_header.theme
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
File.open(output_path, "w", encoding: "UTF-8") do |file|
|
|
85
|
+
writer = Asciicast::Writer.new(file, new_header)
|
|
86
|
+
last_time = 0.0
|
|
87
|
+
|
|
88
|
+
reader.each_event do |event|
|
|
89
|
+
# Apply trimming if specified
|
|
90
|
+
next if trim_start && event.time < trim_start
|
|
91
|
+
next if trim_end && event.time > trim_end
|
|
92
|
+
|
|
93
|
+
# Adjust time for trimming
|
|
94
|
+
adjusted_time = trim_start ? event.time - trim_start : event.time
|
|
95
|
+
|
|
96
|
+
# Apply speed adjustment
|
|
97
|
+
adjusted_time /= speed
|
|
98
|
+
|
|
99
|
+
# Apply max idle limit
|
|
100
|
+
if max_idle && (adjusted_time - last_time) > max_idle
|
|
101
|
+
adjusted_time = last_time + max_idle
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
writer.write_event(Asciicast::Event.new(adjusted_time, event.type, event.data))
|
|
105
|
+
last_time = adjusted_time
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
writer.close
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
true
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Concatenate multiple recordings into one
|
|
115
|
+
#
|
|
116
|
+
# @param input_paths [Array<String>] Paths to .cast files to concatenate
|
|
117
|
+
# @param output_path [String] Output .cast file path
|
|
118
|
+
# @param title [String, nil] Title for combined recording
|
|
119
|
+
# @param gap [Float] Gap in seconds between recordings
|
|
120
|
+
# @return [Boolean] Success
|
|
121
|
+
def concatenate(input_paths, output_path, title: nil, gap: 1.0)
|
|
122
|
+
raise ExportError, "No input files specified" if input_paths.empty?
|
|
123
|
+
|
|
124
|
+
# Load first file to get dimensions
|
|
125
|
+
first_reader = Asciicast.load(input_paths.first)
|
|
126
|
+
first_header = first_reader.header
|
|
127
|
+
|
|
128
|
+
# Determine max dimensions across all files
|
|
129
|
+
max_width = first_header.width
|
|
130
|
+
max_height = first_header.height
|
|
131
|
+
|
|
132
|
+
input_paths[1..].each do |path|
|
|
133
|
+
reader = Asciicast.load(path)
|
|
134
|
+
max_width = [max_width, reader.header.width].max
|
|
135
|
+
max_height = [max_height, reader.header.height].max
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Create output header
|
|
139
|
+
combined_title = title || input_paths.map { |p| File.basename(p, ".cast") }.join(" + ")
|
|
140
|
+
new_header = Asciicast::Header.new(
|
|
141
|
+
width: max_width,
|
|
142
|
+
height: max_height,
|
|
143
|
+
timestamp: first_header.timestamp,
|
|
144
|
+
title: combined_title
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
File.open(output_path, "w", encoding: "UTF-8") do |file|
|
|
148
|
+
writer = Asciicast::Writer.new(file, new_header)
|
|
149
|
+
current_time = 0.0
|
|
150
|
+
|
|
151
|
+
input_paths.each_with_index do |path, index|
|
|
152
|
+
reader = Asciicast.load(path)
|
|
153
|
+
last_event_time = 0.0
|
|
154
|
+
|
|
155
|
+
reader.each_event do |event|
|
|
156
|
+
writer.write_event(Asciicast::Event.new(current_time + event.time, event.type, event.data))
|
|
157
|
+
last_event_time = event.time
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Add gap before next recording (except after last)
|
|
161
|
+
current_time += last_event_time + gap if index < input_paths.length - 1
|
|
162
|
+
|
|
163
|
+
# Add marker at join point
|
|
164
|
+
if index < input_paths.length - 1
|
|
165
|
+
writer.write_marker(current_time - gap / 2, "joined: #{File.basename(path)}")
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
writer.close
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
true
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Generate a thumbnail image from a recording
|
|
176
|
+
#
|
|
177
|
+
# @param input_path [String] Input .cast file
|
|
178
|
+
# @param output_path [String] Output image path (.svg or .png)
|
|
179
|
+
# @param frame [Symbol] Which frame (:first, :last, :middle)
|
|
180
|
+
# @param theme [String] Color theme
|
|
181
|
+
# @param width [Integer, nil] Override width in pixels
|
|
182
|
+
# @param height [Integer, nil] Override height in pixels
|
|
183
|
+
# @return [Boolean] Success
|
|
184
|
+
def thumbnail(input_path, output_path, frame: :last, theme: "asciinema", width: nil, height: nil, **_options)
|
|
185
|
+
info = Asciicast::Reader.info(input_path)
|
|
186
|
+
reader = Asciicast.load(input_path)
|
|
187
|
+
|
|
188
|
+
# Determine which frame to capture
|
|
189
|
+
target_time = case frame
|
|
190
|
+
when :first then 0.0
|
|
191
|
+
when :last then info[:duration]
|
|
192
|
+
when :middle then info[:duration] / 2
|
|
193
|
+
when Numeric then frame.to_f
|
|
194
|
+
else info[:duration]
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Collect output up to target time
|
|
198
|
+
output = StringIO.new
|
|
199
|
+
reader.each_event do |event|
|
|
200
|
+
break if event.time > target_time
|
|
201
|
+
|
|
202
|
+
output << event.data if event.output?
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Parse and render
|
|
206
|
+
color_theme = Themes.get(theme)
|
|
207
|
+
parser = AnsiParser.new(width: info[:width], height: info[:height])
|
|
208
|
+
lines = parser.parse(output.string)
|
|
209
|
+
|
|
210
|
+
svg = generate_thumbnail_svg(lines, info[:width], info[:height], color_theme, width: width, height: height)
|
|
211
|
+
|
|
212
|
+
File.write(output_path, svg, encoding: "UTF-8")
|
|
213
|
+
true
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Adjust playback speed of a recording
|
|
217
|
+
#
|
|
218
|
+
# @param input_path [String] Input .cast file
|
|
219
|
+
# @param output_path [String] Output .cast file
|
|
220
|
+
# @param speed [Float] Speed multiplier (2.0 = 2x faster)
|
|
221
|
+
# @param max_idle [Float, nil] Compress idle time to this maximum
|
|
222
|
+
# @return [Boolean] Success
|
|
223
|
+
def adjust_speed(input_path, output_path, speed: 1.0, max_idle: nil)
|
|
224
|
+
export_cast(input_path, output_path, speed: speed, max_idle: max_idle)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Export to HTML with embedded asciinema-player
|
|
228
|
+
#
|
|
229
|
+
# @param input_path [String] Input .cast file
|
|
230
|
+
# @param output_path [String] Output .html file
|
|
231
|
+
# @param title [String] Page title
|
|
232
|
+
# @param theme [String] Player theme (asciinema, tango, solarized-dark, etc.)
|
|
233
|
+
# @param autoplay [Boolean] Auto-start playback
|
|
234
|
+
# @return [Boolean] Success
|
|
235
|
+
def export_html(input_path, output_path, title: nil, theme: "asciinema", autoplay: false, **_options)
|
|
236
|
+
info = Asciicast::Reader.info(input_path)
|
|
237
|
+
cast_content = File.read(input_path, encoding: "UTF-8")
|
|
238
|
+
title ||= info[:title] || "Terminal Recording"
|
|
239
|
+
|
|
240
|
+
html = generate_html(cast_content, info, title: title, theme: theme, autoplay: autoplay)
|
|
241
|
+
|
|
242
|
+
File.write(output_path, html, encoding: "UTF-8")
|
|
243
|
+
true
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Export to SVG with full color support
|
|
247
|
+
#
|
|
248
|
+
# @param input_path [String] Input .cast file
|
|
249
|
+
# @param output_path [String] Output .svg file
|
|
250
|
+
# @param theme [String] Color theme (asciinema, dracula, monokai, etc.)
|
|
251
|
+
# @param frame [Symbol] Which frame to capture (:first, :last, :all)
|
|
252
|
+
# @return [Boolean] Success
|
|
253
|
+
def export_svg(input_path, output_path, theme: "asciinema", frame: :last, **_options)
|
|
254
|
+
info = Asciicast::Reader.info(input_path)
|
|
255
|
+
reader = Asciicast.load(input_path)
|
|
256
|
+
|
|
257
|
+
# Collect all output
|
|
258
|
+
output = StringIO.new
|
|
259
|
+
reader.each_event do |event|
|
|
260
|
+
output << event.data if event.output?
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# Parse ANSI codes and render colored SVG
|
|
264
|
+
color_theme = Themes.get(theme)
|
|
265
|
+
svg = generate_colored_svg(output.string, info[:width], info[:height], color_theme)
|
|
266
|
+
|
|
267
|
+
File.write(output_path, svg, encoding: "UTF-8")
|
|
268
|
+
true
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# Export to plain text
|
|
272
|
+
#
|
|
273
|
+
# @param input_path [String] Input .cast file
|
|
274
|
+
# @param output_path [String] Output .txt file
|
|
275
|
+
# @param strip_ansi [Boolean] Remove ANSI escape sequences
|
|
276
|
+
# @return [Boolean] Success
|
|
277
|
+
def export_text(input_path, output_path, strip_ansi: true, **_options)
|
|
278
|
+
reader = Asciicast.load(input_path)
|
|
279
|
+
|
|
280
|
+
output = StringIO.new
|
|
281
|
+
reader.each_event do |event|
|
|
282
|
+
output << event.data if event.output?
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
text = output.string
|
|
286
|
+
text = strip_ansi_codes(text) if strip_ansi
|
|
287
|
+
|
|
288
|
+
File.write(output_path, text, encoding: "UTF-8")
|
|
289
|
+
true
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# Export to JSON (normalized format)
|
|
293
|
+
#
|
|
294
|
+
# @param input_path [String] Input .cast file
|
|
295
|
+
# @param output_path [String] Output .json file
|
|
296
|
+
# @return [Boolean] Success
|
|
297
|
+
def export_json(input_path, output_path, **_options)
|
|
298
|
+
require "json"
|
|
299
|
+
|
|
300
|
+
info = Asciicast::Reader.info(input_path)
|
|
301
|
+
reader = Asciicast.load(input_path)
|
|
302
|
+
|
|
303
|
+
events = reader.each_event.map do |event|
|
|
304
|
+
{ time: event.time, type: event.type, data: event.data }
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
data = {
|
|
308
|
+
header: info,
|
|
309
|
+
events: events
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
File.write(output_path, JSON.pretty_generate(data), encoding: "UTF-8")
|
|
313
|
+
true
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
# Export to video format (GIF, MP4, WebM)
|
|
317
|
+
#
|
|
318
|
+
# @note Requires FFmpeg to be installed
|
|
319
|
+
#
|
|
320
|
+
# @param input_path [String] Input .cast file
|
|
321
|
+
# @param output_path [String] Output video file
|
|
322
|
+
# @param format [Symbol] Video format (:gif, :mp4, :webm)
|
|
323
|
+
# @param fps [Integer] Frames per second
|
|
324
|
+
# @param font_size [Integer] Font size in pixels
|
|
325
|
+
# @return [Boolean] Success
|
|
326
|
+
# @raise [ExportError] If FFmpeg is not available
|
|
327
|
+
def export_video(input_path, output_path, format:, fps: 10, font_size: 14, **_options)
|
|
328
|
+
unless ffmpeg_available?
|
|
329
|
+
raise ExportError, <<~MSG
|
|
330
|
+
FFmpeg is required for #{format.upcase} export but was not found.
|
|
331
|
+
|
|
332
|
+
To install FFmpeg:
|
|
333
|
+
1. Download from https://ffmpeg.org/download.html
|
|
334
|
+
2. Add to PATH or set FFMPEG_PATH environment variable
|
|
335
|
+
|
|
336
|
+
Alternatively, use native export formats: #{NATIVE_FORMATS.join(", ")}
|
|
337
|
+
MSG
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
# For video export, we need to:
|
|
341
|
+
# 1. Render each frame to an image
|
|
342
|
+
# 2. Use FFmpeg to combine into video
|
|
343
|
+
#
|
|
344
|
+
# This is a simplified implementation that creates a basic video
|
|
345
|
+
# For production use, consider using agg (asciinema/agg) or similar
|
|
346
|
+
|
|
347
|
+
warn "Video export is experimental. For best results, use https://github.com/asciinema/agg"
|
|
348
|
+
|
|
349
|
+
# Create temporary directory for frames
|
|
350
|
+
temp_dir = File.join(Dir.tmpdir, "asciinema_win_#{Process.pid}")
|
|
351
|
+
Dir.mkdir(temp_dir) unless Dir.exist?(temp_dir)
|
|
352
|
+
|
|
353
|
+
begin
|
|
354
|
+
# Generate frame images (simplified - just text rendering)
|
|
355
|
+
generate_video_frames(input_path, temp_dir, fps: fps, font_size: font_size)
|
|
356
|
+
|
|
357
|
+
# Use FFmpeg to create video
|
|
358
|
+
ffmpeg_create_video(temp_dir, output_path, format: format, fps: fps)
|
|
359
|
+
|
|
360
|
+
true
|
|
361
|
+
ensure
|
|
362
|
+
# Cleanup temp files
|
|
363
|
+
FileUtils.rm_rf(temp_dir) if Dir.exist?(temp_dir)
|
|
364
|
+
end
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
# Check if FFmpeg is available
|
|
368
|
+
#
|
|
369
|
+
# @return [Boolean] True if FFmpeg is in PATH
|
|
370
|
+
def ffmpeg_available?
|
|
371
|
+
ffmpeg_path = ENV["FFMPEG_PATH"] || "ffmpeg"
|
|
372
|
+
system("#{ffmpeg_path} -version", out: File::NULL, err: File::NULL)
|
|
373
|
+
rescue StandardError
|
|
374
|
+
false
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
private
|
|
378
|
+
|
|
379
|
+
# Generate HTML with embedded player
|
|
380
|
+
def generate_html(cast_content, info, title:, theme:, autoplay:)
|
|
381
|
+
# Escape the cast content for embedding in JavaScript
|
|
382
|
+
escaped_cast = cast_content.gsub("\\", "\\\\\\\\")
|
|
383
|
+
.gsub("'", "\\\\'")
|
|
384
|
+
.gsub("\n", "\\n")
|
|
385
|
+
.gsub("\r", "\\r")
|
|
386
|
+
|
|
387
|
+
autoplay_attr = autoplay ? 'autoplay="true"' : ""
|
|
388
|
+
|
|
389
|
+
<<~HTML
|
|
390
|
+
<!DOCTYPE html>
|
|
391
|
+
<html lang="en">
|
|
392
|
+
<head>
|
|
393
|
+
<meta charset="UTF-8">
|
|
394
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
395
|
+
<title>#{title}</title>
|
|
396
|
+
<link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/npm/asciinema-player@3.6.3/dist/bundle/asciinema-player.css" />
|
|
397
|
+
<style>
|
|
398
|
+
body {
|
|
399
|
+
margin: 0;
|
|
400
|
+
padding: 20px;
|
|
401
|
+
background: #1a1a2e;
|
|
402
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
403
|
+
}
|
|
404
|
+
.container {
|
|
405
|
+
max-width: 1000px;
|
|
406
|
+
margin: 0 auto;
|
|
407
|
+
}
|
|
408
|
+
h1 {
|
|
409
|
+
color: #eee;
|
|
410
|
+
margin-bottom: 20px;
|
|
411
|
+
}
|
|
412
|
+
.info {
|
|
413
|
+
color: #888;
|
|
414
|
+
margin-bottom: 20px;
|
|
415
|
+
}
|
|
416
|
+
#player {
|
|
417
|
+
border-radius: 8px;
|
|
418
|
+
overflow: hidden;
|
|
419
|
+
}
|
|
420
|
+
</style>
|
|
421
|
+
</head>
|
|
422
|
+
<body>
|
|
423
|
+
<div class="container">
|
|
424
|
+
<h1>#{title}</h1>
|
|
425
|
+
<div class="info">
|
|
426
|
+
Size: #{info[:width]}x#{info[:height]} |
|
|
427
|
+
Duration: #{format("%.1f", info[:duration])}s |
|
|
428
|
+
Events: #{info[:event_count]}
|
|
429
|
+
</div>
|
|
430
|
+
<div id="player"></div>
|
|
431
|
+
</div>
|
|
432
|
+
|
|
433
|
+
<script src="https://cdn.jsdelivr.net/npm/asciinema-player@3.6.3/dist/bundle/asciinema-player.min.js"></script>
|
|
434
|
+
<script>
|
|
435
|
+
const castContent = '#{escaped_cast}';
|
|
436
|
+
const blob = new Blob([castContent], { type: 'text/plain' });
|
|
437
|
+
const url = URL.createObjectURL(blob);
|
|
438
|
+
|
|
439
|
+
AsciinemaPlayer.create(url, document.getElementById('player'), {
|
|
440
|
+
theme: '#{theme}',
|
|
441
|
+
#{autoplay_attr}
|
|
442
|
+
fit: 'width',
|
|
443
|
+
fontSize: 'medium'
|
|
444
|
+
});
|
|
445
|
+
</script>
|
|
446
|
+
</body>
|
|
447
|
+
</html>
|
|
448
|
+
HTML
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
# Generate colored SVG representation using ANSI parser
|
|
452
|
+
#
|
|
453
|
+
# @param content [String] Raw ANSI content
|
|
454
|
+
# @param width [Integer] Terminal width
|
|
455
|
+
# @param height [Integer] Terminal height
|
|
456
|
+
# @param theme [Themes::Theme] Color theme
|
|
457
|
+
# @return [String] SVG content
|
|
458
|
+
def generate_colored_svg(content, width, height, theme)
|
|
459
|
+
# Parse ANSI content
|
|
460
|
+
parser = AnsiParser.new(width: width, height: height)
|
|
461
|
+
lines = parser.parse(content)
|
|
462
|
+
|
|
463
|
+
char_width = 8.4
|
|
464
|
+
char_height = 18
|
|
465
|
+
padding = 16
|
|
466
|
+
border_radius = 8
|
|
467
|
+
|
|
468
|
+
svg_width = (width * char_width + padding * 2).ceil
|
|
469
|
+
svg_height = (height * char_height + padding * 2 + 30).ceil # +30 for title bar
|
|
470
|
+
|
|
471
|
+
# Build SVG content
|
|
472
|
+
svg_content = StringIO.new
|
|
473
|
+
|
|
474
|
+
# Render each line
|
|
475
|
+
lines.each_with_index do |line, y|
|
|
476
|
+
y_pos = padding + 30 + (y + 1) * char_height # +30 for title bar offset
|
|
477
|
+
|
|
478
|
+
# Group characters with same style for efficiency
|
|
479
|
+
x = 0
|
|
480
|
+
while x < line.chars.length
|
|
481
|
+
char_data = line.chars[x]
|
|
482
|
+
|
|
483
|
+
# Find run of characters with same style
|
|
484
|
+
run_start = x
|
|
485
|
+
while x < line.chars.length && line.chars[x].same_style?(char_data)
|
|
486
|
+
x += 1
|
|
487
|
+
end
|
|
488
|
+
|
|
489
|
+
# Skip if just spaces with default style
|
|
490
|
+
text = line.chars[run_start...x].map(&:char).join
|
|
491
|
+
next if text.match?(/^\s*$/) && char_data.default_style?
|
|
492
|
+
|
|
493
|
+
# Calculate position
|
|
494
|
+
x_pos = padding + run_start * char_width
|
|
495
|
+
|
|
496
|
+
# Build style attributes
|
|
497
|
+
styles = []
|
|
498
|
+
fill = resolve_fg_color(char_data.fg, theme)
|
|
499
|
+
styles << "fill:#{fill}" if fill != theme.foreground
|
|
500
|
+
|
|
501
|
+
bg = resolve_bg_color(char_data.bg, theme)
|
|
502
|
+
if bg && bg != theme.background
|
|
503
|
+
# Add background rectangle
|
|
504
|
+
bg_width = (x - run_start) * char_width
|
|
505
|
+
svg_content << %(<rect x="#{x_pos}" y="#{y_pos - char_height + 4}" width="#{bg_width}" height="#{char_height}" fill="#{bg}"/>)
|
|
506
|
+
svg_content << "\n"
|
|
507
|
+
end
|
|
508
|
+
|
|
509
|
+
styles << "font-weight:bold" if char_data.bold
|
|
510
|
+
styles << "font-style:italic" if char_data.italic
|
|
511
|
+
|
|
512
|
+
style_attr = styles.empty? ? "" : %( style="#{styles.join(";")}")
|
|
513
|
+
|
|
514
|
+
# Escape text for XML
|
|
515
|
+
escaped = text.gsub("&", "&")
|
|
516
|
+
.gsub("<", "<")
|
|
517
|
+
.gsub(">", ">")
|
|
518
|
+
.gsub("'", "'")
|
|
519
|
+
.gsub('"', """)
|
|
520
|
+
|
|
521
|
+
# Add decorations
|
|
522
|
+
decorations = []
|
|
523
|
+
decorations << "underline" if char_data.underline
|
|
524
|
+
decorations << "line-through" if char_data.strikethrough
|
|
525
|
+
dec_attr = decorations.empty? ? "" : %( text-decoration="#{decorations.join(" ")}")
|
|
526
|
+
|
|
527
|
+
svg_content << %(<text x="#{x_pos}" y="#{y_pos}" class="t"#{style_attr}#{dec_attr}>#{escaped}</text>)
|
|
528
|
+
svg_content << "\n"
|
|
529
|
+
end
|
|
530
|
+
end
|
|
531
|
+
|
|
532
|
+
# Window button colors
|
|
533
|
+
close_color = "#ff5f56"
|
|
534
|
+
minimize_color = "#ffbd2e"
|
|
535
|
+
maximize_color = "#27c93f"
|
|
536
|
+
|
|
537
|
+
<<~SVG
|
|
538
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
539
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="#{svg_width}" height="#{svg_height}" viewBox="0 0 #{svg_width} #{svg_height}">
|
|
540
|
+
<defs>
|
|
541
|
+
<style>
|
|
542
|
+
.bg { fill: #{theme.background}; }
|
|
543
|
+
.title-bar { fill: #{darken_color(theme.background, 0.15)}; }
|
|
544
|
+
.t {
|
|
545
|
+
font-family: "Cascadia Code", "Fira Code", "Consolas", "Monaco", "Courier New", monospace;
|
|
546
|
+
font-size: 14px;
|
|
547
|
+
fill: #{theme.foreground};
|
|
548
|
+
white-space: pre;
|
|
549
|
+
}
|
|
550
|
+
</style>
|
|
551
|
+
</defs>
|
|
552
|
+
|
|
553
|
+
<!-- Window frame -->
|
|
554
|
+
<rect class="bg" width="100%" height="100%" rx="#{border_radius}"/>
|
|
555
|
+
|
|
556
|
+
<!-- Title bar -->
|
|
557
|
+
<rect class="title-bar" width="100%" height="30" rx="#{border_radius}" ry="#{border_radius}"/>
|
|
558
|
+
<rect class="title-bar" y="#{border_radius}" width="100%" height="#{30 - border_radius}"/>
|
|
559
|
+
|
|
560
|
+
<!-- Window buttons -->
|
|
561
|
+
<circle cx="20" cy="15" r="6" fill="#{close_color}"/>
|
|
562
|
+
<circle cx="40" cy="15" r="6" fill="#{minimize_color}"/>
|
|
563
|
+
<circle cx="60" cy="15" r="6" fill="#{maximize_color}"/>
|
|
564
|
+
|
|
565
|
+
<!-- Terminal content -->
|
|
566
|
+
<g class="terminal">
|
|
567
|
+
#{svg_content.string} </g>
|
|
568
|
+
</svg>
|
|
569
|
+
SVG
|
|
570
|
+
end
|
|
571
|
+
|
|
572
|
+
# Generate thumbnail SVG (smaller, simplified)
|
|
573
|
+
#
|
|
574
|
+
# @param lines [Array<ParsedLine>] Parsed lines
|
|
575
|
+
# @param term_width [Integer] Terminal width
|
|
576
|
+
# @param term_height [Integer] Terminal height
|
|
577
|
+
# @param theme [Themes::Theme] Color theme
|
|
578
|
+
# @param width [Integer, nil] Override width
|
|
579
|
+
# @param height [Integer, nil] Override height
|
|
580
|
+
# @return [String] SVG content
|
|
581
|
+
def generate_thumbnail_svg(lines, term_width, term_height, theme, width: nil, height: nil)
|
|
582
|
+
# Calculate dimensions
|
|
583
|
+
char_width = 6.0
|
|
584
|
+
char_height = 12.0
|
|
585
|
+
padding = 8
|
|
586
|
+
border_radius = 6
|
|
587
|
+
title_bar_height = 20
|
|
588
|
+
|
|
589
|
+
svg_width = width || (term_width * char_width + padding * 2).ceil
|
|
590
|
+
svg_height = height || (term_height * char_height + padding * 2 + title_bar_height).ceil
|
|
591
|
+
|
|
592
|
+
# Scale factor if custom dimensions provided
|
|
593
|
+
scale_x = width ? width.to_f / (term_width * char_width + padding * 2) : 1.0
|
|
594
|
+
scale_y = height ? height.to_f / (term_height * char_height + padding * 2 + title_bar_height) : 1.0
|
|
595
|
+
|
|
596
|
+
# Build SVG content
|
|
597
|
+
svg_content = StringIO.new
|
|
598
|
+
|
|
599
|
+
# Render each line (simplified)
|
|
600
|
+
lines.each_with_index do |line, y|
|
|
601
|
+
y_pos = (padding + title_bar_height + (y + 1) * char_height) * scale_y
|
|
602
|
+
|
|
603
|
+
x = 0
|
|
604
|
+
while x < line.chars.length
|
|
605
|
+
char_data = line.chars[x]
|
|
606
|
+
|
|
607
|
+
run_start = x
|
|
608
|
+
while x < line.chars.length && line.chars[x].same_style?(char_data)
|
|
609
|
+
x += 1
|
|
610
|
+
end
|
|
611
|
+
|
|
612
|
+
text = line.chars[run_start...x].map(&:char).join
|
|
613
|
+
next if text.match?(/^\s*$/) && char_data.default_style?
|
|
614
|
+
|
|
615
|
+
x_pos = (padding + run_start * char_width) * scale_x
|
|
616
|
+
fill = resolve_fg_color(char_data.fg, theme)
|
|
617
|
+
style = fill != theme.foreground ? %( style="fill:#{fill}") : ""
|
|
618
|
+
|
|
619
|
+
escaped = text.gsub("&", "&").gsub("<", "<").gsub(">", ">")
|
|
620
|
+
svg_content << %(<text x="#{x_pos}" y="#{y_pos}" class="t"#{style}>#{escaped}</text>\n)
|
|
621
|
+
end
|
|
622
|
+
end
|
|
623
|
+
|
|
624
|
+
<<~SVG
|
|
625
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
626
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="#{svg_width}" height="#{svg_height}">
|
|
627
|
+
<defs>
|
|
628
|
+
<style>
|
|
629
|
+
.bg { fill: #{theme.background}; }
|
|
630
|
+
.bar { fill: #{darken_color(theme.background, 0.15)}; }
|
|
631
|
+
.t {
|
|
632
|
+
font-family: monospace;
|
|
633
|
+
font-size: #{(10 * scale_y).round}px;
|
|
634
|
+
fill: #{theme.foreground};
|
|
635
|
+
}
|
|
636
|
+
</style>
|
|
637
|
+
</defs>
|
|
638
|
+
<rect class="bg" width="100%" height="100%" rx="#{border_radius}"/>
|
|
639
|
+
<rect class="bar" width="100%" height="#{title_bar_height}" rx="#{border_radius}"/>
|
|
640
|
+
<circle cx="12" cy="10" r="4" fill="#ff5f56"/>
|
|
641
|
+
<circle cx="26" cy="10" r="4" fill="#ffbd2e"/>
|
|
642
|
+
<circle cx="40" cy="10" r="4" fill="#27c93f"/>
|
|
643
|
+
<g class="content">
|
|
644
|
+
#{svg_content.string} </g>
|
|
645
|
+
</svg>
|
|
646
|
+
SVG
|
|
647
|
+
end
|
|
648
|
+
|
|
649
|
+
# Resolve foreground color from ANSI code to hex
|
|
650
|
+
#
|
|
651
|
+
# @param fg [Integer, String, nil] Foreground color
|
|
652
|
+
# @param theme [Themes::Theme] Color theme
|
|
653
|
+
# @return [String] Hex color
|
|
654
|
+
def resolve_fg_color(fg, theme)
|
|
655
|
+
return theme.foreground if fg.nil?
|
|
656
|
+
return fg if fg.is_a?(String) && fg.start_with?("#")
|
|
657
|
+
|
|
658
|
+
case fg
|
|
659
|
+
when 30..37
|
|
660
|
+
theme.fg_color(fg)
|
|
661
|
+
when 90..97
|
|
662
|
+
theme.fg_color(fg)
|
|
663
|
+
when Integer
|
|
664
|
+
theme.color(fg)
|
|
665
|
+
else
|
|
666
|
+
theme.foreground
|
|
667
|
+
end
|
|
668
|
+
end
|
|
669
|
+
|
|
670
|
+
# Resolve background color from ANSI code to hex
|
|
671
|
+
#
|
|
672
|
+
# @param bg [Integer, String, nil] Background color
|
|
673
|
+
# @param theme [Themes::Theme] Color theme
|
|
674
|
+
# @return [String, nil] Hex color or nil
|
|
675
|
+
def resolve_bg_color(bg, theme)
|
|
676
|
+
return nil if bg.nil?
|
|
677
|
+
return bg if bg.is_a?(String) && bg.start_with?("#")
|
|
678
|
+
|
|
679
|
+
case bg
|
|
680
|
+
when 40..47
|
|
681
|
+
theme.bg_color(bg)
|
|
682
|
+
when 100..107
|
|
683
|
+
theme.bg_color(bg)
|
|
684
|
+
when Integer
|
|
685
|
+
theme.color(bg)
|
|
686
|
+
else
|
|
687
|
+
nil
|
|
688
|
+
end
|
|
689
|
+
end
|
|
690
|
+
|
|
691
|
+
# Darken a hex color
|
|
692
|
+
#
|
|
693
|
+
# @param hex [String] Hex color (#rrggbb)
|
|
694
|
+
# @param factor [Float] Darken factor (0-1)
|
|
695
|
+
# @return [String] Darkened hex color
|
|
696
|
+
def darken_color(hex, factor)
|
|
697
|
+
hex = hex.delete("#")
|
|
698
|
+
r = [(hex[0..1].to_i(16) * (1 - factor)).round, 0].max
|
|
699
|
+
g = [(hex[2..3].to_i(16) * (1 - factor)).round, 0].max
|
|
700
|
+
b = [(hex[4..5].to_i(16) * (1 - factor)).round, 0].max
|
|
701
|
+
format("#%02x%02x%02x", r, g, b)
|
|
702
|
+
end
|
|
703
|
+
|
|
704
|
+
# Generate SVG representation (legacy, no colors)
|
|
705
|
+
def generate_svg(content, width, height)
|
|
706
|
+
# Strip ANSI codes for SVG (simplified)
|
|
707
|
+
text = strip_ansi_codes(content)
|
|
708
|
+
lines = text.split("\n")
|
|
709
|
+
|
|
710
|
+
char_width = 8
|
|
711
|
+
char_height = 16
|
|
712
|
+
padding = 20
|
|
713
|
+
|
|
714
|
+
svg_width = width * char_width + padding * 2
|
|
715
|
+
svg_height = height * char_height + padding * 2
|
|
716
|
+
|
|
717
|
+
svg_lines = lines.first(height).map.with_index do |line, y|
|
|
718
|
+
escaped = line.gsub("&", "&")
|
|
719
|
+
.gsub("<", "<")
|
|
720
|
+
.gsub(">", ">")
|
|
721
|
+
y_pos = padding + (y + 1) * char_height
|
|
722
|
+
%(<text x="#{padding}" y="#{y_pos}" class="line">#{escaped}</text>)
|
|
723
|
+
end.join("\n")
|
|
724
|
+
|
|
725
|
+
<<~SVG
|
|
726
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
727
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="#{svg_width}" height="#{svg_height}">
|
|
728
|
+
<style>
|
|
729
|
+
.bg { fill: #1a1a2e; }
|
|
730
|
+
.line {
|
|
731
|
+
font-family: "Consolas", "Monaco", "Courier New", monospace;
|
|
732
|
+
font-size: 14px;
|
|
733
|
+
fill: #eee;
|
|
734
|
+
}
|
|
735
|
+
</style>
|
|
736
|
+
<rect class="bg" width="100%" height="100%" rx="8"/>
|
|
737
|
+
#{svg_lines}
|
|
738
|
+
</svg>
|
|
739
|
+
SVG
|
|
740
|
+
end
|
|
741
|
+
|
|
742
|
+
# Strip ANSI escape codes from text
|
|
743
|
+
def strip_ansi_codes(text)
|
|
744
|
+
# Remove all ANSI escape sequences
|
|
745
|
+
text.gsub(/\e\[[0-9;]*[a-zA-Z]/, "")
|
|
746
|
+
.gsub(/\e\][^\a]*\a/, "") # OSC sequences
|
|
747
|
+
.gsub(/\r/, "") # Carriage returns
|
|
748
|
+
end
|
|
749
|
+
|
|
750
|
+
# Generate video frames (placeholder - needs proper implementation)
|
|
751
|
+
def generate_video_frames(input_path, temp_dir, fps:, font_size:)
|
|
752
|
+
# This is a simplified placeholder
|
|
753
|
+
# Full implementation would render each frame to PNG
|
|
754
|
+
warn "Frame generation not fully implemented - using placeholder"
|
|
755
|
+
end
|
|
756
|
+
|
|
757
|
+
# Use FFmpeg to create video from frames
|
|
758
|
+
def ffmpeg_create_video(temp_dir, output_path, format:, fps:)
|
|
759
|
+
ffmpeg = ENV["FFMPEG_PATH"] || "ffmpeg"
|
|
760
|
+
|
|
761
|
+
case format
|
|
762
|
+
when :gif
|
|
763
|
+
# GIF creation
|
|
764
|
+
cmd = "#{ffmpeg} -y -framerate #{fps} -i #{temp_dir}/frame_%04d.png -vf \"palettegen\" #{temp_dir}/palette.png"
|
|
765
|
+
system(cmd, out: File::NULL, err: File::NULL)
|
|
766
|
+
|
|
767
|
+
cmd = "#{ffmpeg} -y -framerate #{fps} -i #{temp_dir}/frame_%04d.png -i #{temp_dir}/palette.png -lavfi \"paletteuse\" #{output_path}"
|
|
768
|
+
system(cmd, out: File::NULL, err: File::NULL)
|
|
769
|
+
when :mp4
|
|
770
|
+
cmd = "#{ffmpeg} -y -framerate #{fps} -i #{temp_dir}/frame_%04d.png -c:v libx264 -pix_fmt yuv420p #{output_path}"
|
|
771
|
+
system(cmd, out: File::NULL, err: File::NULL)
|
|
772
|
+
when :webm
|
|
773
|
+
cmd = "#{ffmpeg} -y -framerate #{fps} -i #{temp_dir}/frame_%04d.png -c:v libvpx-vp9 #{output_path}"
|
|
774
|
+
system(cmd, out: File::NULL, err: File::NULL)
|
|
775
|
+
end
|
|
776
|
+
end
|
|
777
|
+
end
|
|
778
|
+
end
|
|
779
|
+
end
|
|
780
|
+
|