Module: AsciinemaWin::Export

Defined in:
lib/asciinema_win/export.rb

Overview

Note:

Video export requires FFmpeg installed and in PATH. This is an OPTIONAL feature - core functionality works without it.

Export module for converting recordings to different formats

Supports export to:

  • Cast (asciicast v2 - copy/convert)

  • HTML (embedded player)

  • SVG (static snapshot)

  • Text (plain text dump)

  • JSON (normalized format)

  • GIF/MP4/WebM (requires FFmpeg - optional)

Constant Summary collapse

NATIVE_FORMATS =

Export formats supported natively (no external dependencies)

%i[cast html svg txt text json].freeze
EXTERNAL_FORMATS =

Export formats requiring external tools

%i[gif mp4 webm].freeze
ALL_FORMATS =

All supported formats

(NATIVE_FORMATS + EXTERNAL_FORMATS).freeze

Class Method Summary collapse

Class Method Details

.adjust_speed(input_path, output_path, speed: 1.0, max_idle: nil) ⇒ Boolean

Adjust playback speed of a recording

Parameters:

  • input_path (String)

    Input .cast file

  • output_path (String)

    Output .cast file

  • speed (Float) (defaults to: 1.0)

    Speed multiplier (2.0 = 2x faster)

  • max_idle (Float, nil) (defaults to: nil)

    Compress idle time to this maximum

Returns:

  • (Boolean)

    Success



226
227
228
# File 'lib/asciinema_win/export.rb', line 226

def adjust_speed(input_path, output_path, speed: 1.0, max_idle: nil)
  export_cast(input_path, output_path, speed: speed, max_idle: max_idle)
end

.concatenate(input_paths, output_path, title: nil, gap: 1.0) ⇒ Boolean

Concatenate multiple recordings into one

Parameters:

  • input_paths (Array<String>)

    Paths to .cast files to concatenate

  • output_path (String)

    Output .cast file path

  • title (String, nil) (defaults to: nil)

    Title for combined recording

  • gap (Float) (defaults to: 1.0)

    Gap in seconds between recordings

Returns:

  • (Boolean)

    Success

Raises:



124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
# File 'lib/asciinema_win/export.rb', line 124

def concatenate(input_paths, output_path, title: nil, gap: 1.0)
  raise ExportError, "No input files specified" if input_paths.empty?

  # Load first file to get dimensions
  first_reader = Asciicast.load(input_paths.first)
  first_header = first_reader.header

  # Determine max dimensions across all files
  max_width = first_header.width
  max_height = first_header.height

  input_paths[1..].each do |path|
    reader = Asciicast.load(path)
    max_width = [max_width, reader.header.width].max
    max_height = [max_height, reader.header.height].max
  end

  # Create output header
  combined_title = title || input_paths.map { |p| File.basename(p, ".cast") }.join(" + ")
  new_header = Asciicast::Header.new(
    width: max_width,
    height: max_height,
    timestamp: first_header.timestamp,
    title: combined_title
  )

  File.open(output_path, "w", encoding: "UTF-8") do |file|
    writer = Asciicast::Writer.new(file, new_header)
    current_time = 0.0

    input_paths.each_with_index do |path, index|
      reader = Asciicast.load(path)
      last_event_time = 0.0

      reader.each_event do |event|
        writer.write_event(Asciicast::Event.new(current_time + event.time, event.type, event.data))
        last_event_time = event.time
      end

      # Add gap before next recording (except after last)
      current_time += last_event_time + gap if index < input_paths.length - 1

      # Add marker at join point
      if index < input_paths.length - 1
        writer.write_marker(current_time - gap / 2, "joined: #{File.basename(path)}")
      end
    end

    writer.close
  end

  true
end

.export(input_path, output_path, format:, **options) ⇒ Boolean

Export a recording to the specified format

Parameters:

  • input_path (String)

    Path to the .cast file

  • output_path (String)

    Path for the output file

  • format (Symbol)

    Output format (:cast, :html, :svg, :txt, :json, :gif, :mp4, :webm)

  • options (Hash)

    Format-specific options

Returns:

  • (Boolean)

    True if export succeeded

Raises:



38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
# File 'lib/asciinema_win/export.rb', line 38

def export(input_path, output_path, format:, **options)
  format = format.to_sym

  unless ALL_FORMATS.include?(format)
    raise ExportError, "Unsupported format: #{format}. Supported: #{ALL_FORMATS.join(", ")}"
  end

  case format
  when :cast
    export_cast(input_path, output_path, **options)
  when :html
    export_html(input_path, output_path, **options)
  when :svg
    export_svg(input_path, output_path, **options)
  when :txt, :text
    export_text(input_path, output_path, **options)
  when :json
    export_json(input_path, output_path, **options)
  when :gif, :mp4, :webm
    export_video(input_path, output_path, format: format, **options)
  end
end

.export_cast(input_path, output_path, title: nil, trim_start: nil, trim_end: nil, speed: 1.0, max_idle: nil, **_options) ⇒ Boolean

Export to asciicast v2 format (copy or transform)

Parameters:

  • input_path (String)

    Input .cast file

  • output_path (String)

    Output .cast file

  • title (String, nil) (defaults to: nil)

    New title (optional)

  • trim_start (Float, nil) (defaults to: nil)

    Trim seconds from start

  • trim_end (Float, nil) (defaults to: nil)

    Trim seconds from end

  • speed (Float) (defaults to: 1.0)

    Speed multiplier (1.0 = normal, 2.0 = 2x faster)

  • max_idle (Float, nil) (defaults to: nil)

    Maximum idle time between events

Returns:

  • (Boolean)

    Success



71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
# File 'lib/asciinema_win/export.rb', line 71

def export_cast(input_path, output_path, title: nil, trim_start: nil, trim_end: nil, speed: 1.0, max_idle: nil, **_options)
  reader = Asciicast.load(input_path)
  original_header = reader.header

  # Create new header with potential modifications
  new_header = Asciicast::Header.new(
    width: original_header.width,
    height: original_header.height,
    timestamp: original_header.timestamp,
    idle_time_limit: max_idle || original_header.idle_time_limit,
    command: original_header.command,
    title: title || original_header.title,
    env: original_header.env,
    theme: original_header.theme
  )

  File.open(output_path, "w", encoding: "UTF-8") do |file|
    writer = Asciicast::Writer.new(file, new_header)
    last_time = 0.0

    reader.each_event do |event|
      # Apply trimming if specified
      next if trim_start && event.time < trim_start
      next if trim_end && event.time > trim_end

      # Adjust time for trimming
      adjusted_time = trim_start ? event.time - trim_start : event.time

      # Apply speed adjustment
      adjusted_time /= speed

      # Apply max idle limit
      if max_idle && (adjusted_time - last_time) > max_idle
        adjusted_time = last_time + max_idle
      end

      writer.write_event(Asciicast::Event.new(adjusted_time, event.type, event.data))
      last_time = adjusted_time
    end

    writer.close
  end

  true
end

.export_html(input_path, output_path, title: nil, theme: "asciinema", autoplay: false, **_options) ⇒ Boolean

Export to HTML with embedded asciinema-player

Parameters:

  • input_path (String)

    Input .cast file

  • output_path (String)

    Output .html file

  • title (String) (defaults to: nil)

    Page title

  • theme (String) (defaults to: "asciinema")

    Player theme (asciinema, tango, solarized-dark, etc.)

  • autoplay (Boolean) (defaults to: false)

    Auto-start playback

Returns:

  • (Boolean)

    Success



238
239
240
241
242
243
244
245
246
247
# File 'lib/asciinema_win/export.rb', line 238

def export_html(input_path, output_path, title: nil, theme: "asciinema", autoplay: false, **_options)
  info = Asciicast::Reader.info(input_path)
  cast_content = File.read(input_path, encoding: "UTF-8")
  title ||= info[:title] || "Terminal Recording"

  html = generate_html(cast_content, info, title: title, theme: theme, autoplay: autoplay)

  File.write(output_path, html, encoding: "UTF-8")
  true
end

.export_json(input_path, output_path, **_options) ⇒ Boolean

Export to JSON (normalized format)

Parameters:

  • input_path (String)

    Input .cast file

  • output_path (String)

    Output .json file

Returns:

  • (Boolean)

    Success



300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
# File 'lib/asciinema_win/export.rb', line 300

def export_json(input_path, output_path, **_options)
  require "json"

  info = Asciicast::Reader.info(input_path)
  reader = Asciicast.load(input_path)

  events = reader.each_event.map do |event|
    { time: event.time, type: event.type, data: event.data }
  end

  data = {
    header: info,
    events: events
  }

  File.write(output_path, JSON.pretty_generate(data), encoding: "UTF-8")
  true
end

.export_svg(input_path, output_path, theme: "asciinema", frame: :last, **_options) ⇒ Boolean

Export to SVG with full color support

Parameters:

  • input_path (String)

    Input .cast file

  • output_path (String)

    Output .svg file

  • theme (String) (defaults to: "asciinema")

    Color theme (asciinema, dracula, monokai, etc.)

  • frame (Symbol) (defaults to: :last)

    Which frame to capture (:first, :last, :all)

Returns:

  • (Boolean)

    Success



256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
# File 'lib/asciinema_win/export.rb', line 256

def export_svg(input_path, output_path, theme: "asciinema", frame: :last, **_options)
  info = Asciicast::Reader.info(input_path)
  reader = Asciicast.load(input_path)

  # Collect all output
  output = StringIO.new
  reader.each_event do |event|
    output << event.data if event.output?
  end

  # Parse ANSI codes and render colored SVG
  color_theme = Themes.get(theme)
  svg = generate_colored_svg(output.string, info[:width], info[:height], color_theme)

  File.write(output_path, svg, encoding: "UTF-8")
  true
end

.export_text(input_path, output_path, strip_ansi: true, **_options) ⇒ Boolean

Export to plain text

Parameters:

  • input_path (String)

    Input .cast file

  • output_path (String)

    Output .txt file

  • strip_ansi (Boolean) (defaults to: true)

    Remove ANSI escape sequences

Returns:

  • (Boolean)

    Success



280
281
282
283
284
285
286
287
288
289
290
291
292
293
# File 'lib/asciinema_win/export.rb', line 280

def export_text(input_path, output_path, strip_ansi: true, **_options)
  reader = Asciicast.load(input_path)

  output = StringIO.new
  reader.each_event do |event|
    output << event.data if event.output?
  end

  text = output.string
  text = strip_ansi_codes(text) if strip_ansi

  File.write(output_path, text, encoding: "UTF-8")
  true
end

.export_video(input_path, output_path, format:, fps: 10, font_size: 14, theme: "asciinema", scale: 1.0, loop_count: -1,, **_options) ⇒ Boolean

Note:

Requires FFmpeg to be installed and in PATH (or FFMPEG_PATH set)

Export to video format (GIF, MP4, WebM)

Uses FFmpeg with 2-pass palette generation for high-quality GIF output. Renders each frame as SVG and pipes directly to FFmpeg for optimal efficiency.

Parameters:

  • input_path (String)

    Input .cast file

  • output_path (String)

    Output video file

  • format (Symbol)

    Video format (:gif, :mp4, :webm)

  • fps (Integer) (defaults to: 10)

    Frames per second (default: 10)

  • font_size (Integer) (defaults to: 14)

    Font size in pixels (default: 14)

  • theme (String) (defaults to: "asciinema")

    Color theme name (default: “asciinema”)

  • scale (Float) (defaults to: 1.0)

    Output scale factor (default: 1.0)

  • loop_count (Integer) (defaults to: -1,)

    GIF loop count (-1=infinite, 0=none, default: -1)

Returns:

  • (Boolean)

    Success

Raises:

  • (ExportError)

    If FFmpeg is not available or export fails



336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
# File 'lib/asciinema_win/export.rb', line 336

def export_video(input_path, output_path, format:, fps: 10, font_size: 14, theme: "asciinema", scale: 1.0, loop_count: -1, **_options)
  unless ffmpeg_available?
    raise ExportError, <<~MSG
      FFmpeg is required for #{format.upcase} export but was not found.

      To install FFmpeg:
      1. Download from https://ffmpeg.org/download.html
      2. Add to PATH or set FFMPEG_PATH environment variable

      Alternatively, use native export formats: #{NATIVE_FORMATS.join(", ")}
    MSG
  end

  # Create temporary directory for frames
  temp_dir = File.join(Dir.tmpdir, "asciinema_win_#{Process.pid}_#{Time.now.to_i}")
  FileUtils.mkdir_p(temp_dir)

  begin
    # Generate SVG frame files
    $stderr.puts "Generating frames at #{fps} FPS..."
    frame_count = generate_video_frames(
      input_path, temp_dir,
      fps: fps,
      font_size: font_size,
      theme: theme,
      scale: scale
    )

    if frame_count == 0
      raise ExportError, "No frames generated from recording"
    end

    $stderr.puts "Generated #{frame_count} frames"

    # Use FFmpeg to create video
    $stderr.puts "Encoding #{format.upcase}..."
    ffmpeg_create_video(temp_dir, output_path, format: format, fps: fps, loop_count: loop_count)

    $stderr.puts "Video saved to #{output_path}"
    true
  ensure
    # Cleanup temp files
    FileUtils.rm_rf(temp_dir) if Dir.exist?(temp_dir)
  end
end

.ffmpeg_available?Boolean

Check if FFmpeg is available

Returns:

  • (Boolean)

    True if FFmpeg is in PATH



385
386
387
388
389
390
391
392
# File 'lib/asciinema_win/export.rb', line 385

def ffmpeg_available?
  ffmpeg_path = ENV["FFMPEG_PATH"] || "ffmpeg"
  # Array form: no shell, so a path with spaces works and FFMPEG_PATH
  # cannot be used for shell injection.
  system(ffmpeg_path, "-version", out: File::NULL, err: File::NULL)
rescue StandardError
  false
end

.thumbnail(input_path, output_path, frame: :last, theme: "asciinema", width: nil, height: nil, **_options) ⇒ Boolean

Generate a thumbnail image from a recording

Parameters:

  • input_path (String)

    Input .cast file

  • output_path (String)

    Output image path (.svg or .png)

  • frame (Symbol) (defaults to: :last)

    Which frame (:first, :last, :middle)

  • theme (String) (defaults to: "asciinema")

    Color theme

  • width (Integer, nil) (defaults to: nil)

    Override width in pixels

  • height (Integer, nil) (defaults to: nil)

    Override height in pixels

Returns:

  • (Boolean)

    Success



187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
# File 'lib/asciinema_win/export.rb', line 187

def thumbnail(input_path, output_path, frame: :last, theme: "asciinema", width: nil, height: nil, **_options)
  info = Asciicast::Reader.info(input_path)
  reader = Asciicast.load(input_path)

  # Determine which frame to capture
  target_time = case frame
                when :first then 0.0
                when :last then info[:duration]
                when :middle then info[:duration] / 2
                when Numeric then frame.to_f
                else info[:duration]
                end

  # Collect output up to target time
  output = StringIO.new
  reader.each_event do |event|
    break if event.time > target_time

    output << event.data if event.output?
  end

  # Parse and render
  color_theme = Themes.get(theme)
  parser = AnsiParser.new(width: info[:width], height: info[:height])
  lines = parser.parse(output.string)

  svg = generate_thumbnail_svg(lines, info[:width], info[:height], color_theme, width: width, height: height)

  File.write(output_path, svg, encoding: "UTF-8")
  true
end