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.
@@ -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("&", "&amp;")
516
+ .gsub("<", "&lt;")
517
+ .gsub(">", "&gt;")
518
+ .gsub("'", "&apos;")
519
+ .gsub('"', "&quot;")
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("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;")
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("&", "&amp;")
719
+ .gsub("<", "&lt;")
720
+ .gsub(">", "&gt;")
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
+