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,537 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+
6
+ module AsciinemaWin
7
+ # Asciicast v2 file format handling
8
+ #
9
+ # This module implements the asciicast v2 specification for terminal recordings.
10
+ # Format: newline-delimited JSON (NDJSON)
11
+ # - Line 1: Header object with metadata
12
+ # - Lines 2+: Event arrays [time, type, data]
13
+ #
14
+ # @see https://docs.asciinema.org/manual/asciicast/v2/
15
+ module Asciicast
16
+ # Asciicast format version
17
+ VERSION = 2
18
+
19
+ # File extension
20
+ EXTENSION = ".cast"
21
+
22
+ # MIME type
23
+ MIME_TYPE = "application/x-asciicast"
24
+
25
+ # Event type constants
26
+ module EventType
27
+ # Output data written to terminal
28
+ OUTPUT = "o"
29
+ # Input data read from terminal
30
+ INPUT = "i"
31
+ # Terminal resize event
32
+ RESIZE = "r"
33
+ # Marker/bookmark
34
+ MARKER = "m"
35
+ end
36
+
37
+ # Recording header with metadata
38
+ #
39
+ # @example Create a header
40
+ # header = Header.new(
41
+ # width: 120,
42
+ # height: 30,
43
+ # title: "Demo Recording"
44
+ # )
45
+ class Header
46
+ # @return [Integer] Format version (always 2)
47
+ attr_accessor :version
48
+
49
+ # @return [Integer] Terminal width in columns
50
+ attr_accessor :width
51
+
52
+ # @return [Integer] Terminal height in rows
53
+ attr_accessor :height
54
+
55
+ # @return [Integer, nil] Unix timestamp of recording start
56
+ attr_accessor :timestamp
57
+
58
+ # @return [Float, nil] Total duration in seconds
59
+ attr_accessor :duration
60
+
61
+ # @return [Float, nil] Maximum idle time between frames
62
+ attr_accessor :idle_time_limit
63
+
64
+ # @return [String, nil] Command that was recorded
65
+ attr_accessor :command
66
+
67
+ # @return [String, nil] Recording title
68
+ attr_accessor :title
69
+
70
+ # @return [Hash<String, String>] Captured environment variables
71
+ attr_accessor :env
72
+
73
+ # @return [Hash, nil] Terminal color theme
74
+ attr_accessor :theme
75
+
76
+ # Create a new header
77
+ #
78
+ # @param width [Integer] Terminal width
79
+ # @param height [Integer] Terminal height
80
+ # @param timestamp [Integer, nil] Unix timestamp (defaults to now)
81
+ # @param duration [Float, nil] Recording duration
82
+ # @param idle_time_limit [Float, nil] Max idle time
83
+ # @param command [String, nil] Recorded command
84
+ # @param title [String, nil] Recording title
85
+ # @param env [Hash, nil] Environment variables
86
+ # @param theme [Hash, nil] Color theme
87
+ def initialize(
88
+ width:,
89
+ height:,
90
+ timestamp: nil,
91
+ duration: nil,
92
+ idle_time_limit: nil,
93
+ command: nil,
94
+ title: nil,
95
+ env: nil,
96
+ theme: nil
97
+ )
98
+ @version = VERSION
99
+ @width = width
100
+ @height = height
101
+ @timestamp = timestamp || Time.now.to_i
102
+ @duration = duration
103
+ @idle_time_limit = idle_time_limit
104
+ @command = command
105
+ @title = title
106
+ @env = env || {}
107
+ @theme = theme
108
+ end
109
+
110
+ # Serialize header to JSON string
111
+ #
112
+ # @return [String] JSON representation
113
+ def to_json(*_args)
114
+ data = {
115
+ "version" => @version,
116
+ "width" => @width,
117
+ "height" => @height
118
+ }
119
+
120
+ # Add optional fields only if present
121
+ data["timestamp"] = @timestamp if @timestamp
122
+ data["duration"] = @duration if @duration
123
+ data["idle_time_limit"] = @idle_time_limit if @idle_time_limit
124
+ data["command"] = @command if @command
125
+ data["title"] = @title if @title
126
+ data["env"] = @env unless @env.empty?
127
+ data["theme"] = @theme if @theme
128
+
129
+ JSON.generate(data)
130
+ end
131
+
132
+ # Parse header from JSON string
133
+ #
134
+ # @param json_str [String] JSON string
135
+ # @return [Header] Parsed header
136
+ # @raise [FormatError] If JSON is invalid or required fields are missing
137
+ def self.from_json(json_str)
138
+ data = JSON.parse(json_str)
139
+
140
+ unless data.is_a?(Hash)
141
+ raise FormatError, "Header must be a JSON object"
142
+ end
143
+
144
+ version = data["version"]
145
+ unless version == VERSION
146
+ raise FormatError, "Unsupported asciicast version: #{version}. Expected: #{VERSION}"
147
+ end
148
+
149
+ width = data["width"]
150
+ height = data["height"]
151
+
152
+ unless width.is_a?(Integer) && width > 0
153
+ raise FormatError, "Invalid width: #{width}"
154
+ end
155
+
156
+ unless height.is_a?(Integer) && height > 0
157
+ raise FormatError, "Invalid height: #{height}"
158
+ end
159
+
160
+ new(
161
+ width: width,
162
+ height: height,
163
+ timestamp: data["timestamp"],
164
+ duration: data["duration"]&.to_f,
165
+ idle_time_limit: data["idle_time_limit"]&.to_f,
166
+ command: data["command"],
167
+ title: data["title"],
168
+ env: data["env"] || {},
169
+ theme: data["theme"]
170
+ )
171
+ rescue JSON::ParserError => e
172
+ raise FormatError, "Invalid header JSON: #{e.message}"
173
+ end
174
+
175
+ # @return [Hash] Header as a hash
176
+ def to_h
177
+ {
178
+ version: @version,
179
+ width: @width,
180
+ height: @height,
181
+ timestamp: @timestamp,
182
+ duration: @duration,
183
+ idle_time_limit: @idle_time_limit,
184
+ command: @command,
185
+ title: @title,
186
+ env: @env,
187
+ theme: @theme
188
+ }
189
+ end
190
+ end
191
+
192
+ # Single recording event
193
+ #
194
+ # @example Output event
195
+ # event = Event.new(0.5, EventType::OUTPUT, "Hello, World!")
196
+ #
197
+ # @example Resize event
198
+ # event = Event.new(1.0, EventType::RESIZE, "120x40")
199
+ class Event
200
+ # @return [Float] Time offset in seconds from recording start
201
+ attr_reader :time
202
+
203
+ # @return [String] Event type (o, i, r, m)
204
+ attr_reader :type
205
+
206
+ # @return [String] Event data
207
+ attr_reader :data
208
+
209
+ # Create a new event
210
+ #
211
+ # @param time [Float] Time offset in seconds
212
+ # @param type [String] Event type
213
+ # @param data [String] Event data
214
+ def initialize(time, type, data)
215
+ @time = time.to_f
216
+ @type = type.to_s
217
+ @data = data.to_s
218
+ end
219
+
220
+ # Serialize event to JSON array string
221
+ #
222
+ # @return [String] JSON array representation
223
+ def to_json(*_args)
224
+ JSON.generate([@time, @type, @data])
225
+ end
226
+
227
+ # Parse event from JSON string
228
+ #
229
+ # @param json_str [String] JSON array string
230
+ # @return [Event] Parsed event
231
+ # @raise [FormatError] If format is invalid
232
+ def self.from_json(json_str)
233
+ data = JSON.parse(json_str)
234
+
235
+ unless data.is_a?(Array) && data.length >= 3
236
+ raise FormatError, "Event must be a JSON array with 3 elements"
237
+ end
238
+
239
+ new(data[0], data[1], data[2])
240
+ rescue JSON::ParserError => e
241
+ raise FormatError, "Invalid event JSON: #{e.message}"
242
+ end
243
+
244
+ # Check if this is an output event
245
+ # @return [Boolean]
246
+ def output?
247
+ @type == EventType::OUTPUT
248
+ end
249
+
250
+ # Check if this is an input event
251
+ # @return [Boolean]
252
+ def input?
253
+ @type == EventType::INPUT
254
+ end
255
+
256
+ # Check if this is a resize event
257
+ # @return [Boolean]
258
+ def resize?
259
+ @type == EventType::RESIZE
260
+ end
261
+
262
+ # Check if this is a marker event
263
+ # @return [Boolean]
264
+ def marker?
265
+ @type == EventType::MARKER
266
+ end
267
+
268
+ # Get resize dimensions (for resize events)
269
+ # @return [Array<Integer>, nil] [width, height] or nil if not a resize event
270
+ def resize_dimensions
271
+ return nil unless resize?
272
+
273
+ parts = @data.split("x")
274
+ return nil unless parts.length == 2
275
+
276
+ [parts[0].to_i, parts[1].to_i]
277
+ end
278
+
279
+ # @return [Hash] Event as a hash
280
+ def to_h
281
+ { time: @time, type: @type, data: @data }
282
+ end
283
+ end
284
+
285
+ # Writer for creating asciicast recordings
286
+ #
287
+ # @example Write a recording
288
+ # header = Asciicast::Header.new(width: 80, height: 24)
289
+ # File.open("recording.cast", "w") do |file|
290
+ # writer = Asciicast::Writer.new(file, header)
291
+ # writer.write_output(0.0, "Hello\r\n")
292
+ # writer.write_output(0.5, "World\r\n")
293
+ # writer.close
294
+ # end
295
+ class Writer
296
+ # @return [Header] Recording header
297
+ attr_reader :header
298
+
299
+ # @return [Float] Last event time (for duration calculation)
300
+ attr_reader :last_event_time
301
+
302
+ # Create a new writer
303
+ #
304
+ # @param io [IO] Output stream
305
+ # @param header [Header] Recording header
306
+ def initialize(io, header)
307
+ @io = io
308
+ @header = header
309
+ @last_event_time = 0.0
310
+ @closed = false
311
+ @event_count = 0
312
+
313
+ # Write header immediately
314
+ write_header
315
+ end
316
+
317
+ # Write an output event
318
+ #
319
+ # @param time [Float] Time offset in seconds
320
+ # @param data [String] Output data
321
+ # @return [void]
322
+ def write_output(time, data)
323
+ write_event(Event.new(time, EventType::OUTPUT, data))
324
+ end
325
+
326
+ # Write an input event
327
+ #
328
+ # @param time [Float] Time offset in seconds
329
+ # @param data [String] Input data
330
+ # @return [void]
331
+ def write_input(time, data)
332
+ write_event(Event.new(time, EventType::INPUT, data))
333
+ end
334
+
335
+ # Write a resize event
336
+ #
337
+ # @param time [Float] Time offset in seconds
338
+ # @param width [Integer] New width
339
+ # @param height [Integer] New height
340
+ # @return [void]
341
+ def write_resize(time, width, height)
342
+ write_event(Event.new(time, EventType::RESIZE, "#{width}x#{height}"))
343
+ end
344
+
345
+ # Write a marker event
346
+ #
347
+ # @param time [Float] Time offset in seconds
348
+ # @param label [String] Marker label
349
+ # @return [void]
350
+ def write_marker(time, label = "")
351
+ write_event(Event.new(time, EventType::MARKER, label))
352
+ end
353
+
354
+ # Write a generic event
355
+ #
356
+ # @param event [Event] Event to write
357
+ # @return [void]
358
+ def write_event(event)
359
+ raise RecordingError, "Writer is closed" if @closed
360
+
361
+ @io.puts(event.to_json)
362
+ @last_event_time = event.time
363
+ @event_count += 1
364
+ end
365
+
366
+ # Close the writer and finalize the recording
367
+ #
368
+ # @return [void]
369
+ def close
370
+ return if @closed
371
+
372
+ @closed = true
373
+ @io.flush if @io.respond_to?(:flush)
374
+ end
375
+
376
+ # @return [Boolean] Whether the writer is closed
377
+ def closed?
378
+ @closed
379
+ end
380
+
381
+ # @return [Integer] Number of events written
382
+ def event_count
383
+ @event_count
384
+ end
385
+
386
+ private
387
+
388
+ # Write the header line
389
+ def write_header
390
+ @io.puts(@header.to_json)
391
+ end
392
+ end
393
+
394
+ # Reader for playing back asciicast recordings
395
+ #
396
+ # @example Read a recording
397
+ # File.open("recording.cast", "r") do |file|
398
+ # reader = Asciicast::Reader.new(file)
399
+ # puts "Recording: #{reader.header.title}"
400
+ # reader.each_event do |event|
401
+ # puts "#{event.time}: #{event.type}"
402
+ # end
403
+ # end
404
+ class Reader
405
+ # @return [Header] Recording header
406
+ attr_reader :header
407
+
408
+ # Create a new reader
409
+ #
410
+ # @param io [IO] Input stream
411
+ # @raise [FormatError] If header is invalid
412
+ def initialize(io)
413
+ @io = io
414
+ @header = read_header
415
+ @events_started = false
416
+ end
417
+
418
+ # Get recording info from a file path
419
+ #
420
+ # @param path [String] Path to recording file
421
+ # @return [Hash] Recording metadata
422
+ # @raise [FormatError] If file is invalid
423
+ def self.info(path)
424
+ File.open(path, "r", encoding: "UTF-8") do |file|
425
+ reader = new(file)
426
+ header = reader.header
427
+
428
+ # Count events and calculate duration
429
+ event_count = 0
430
+ last_time = 0.0
431
+
432
+ reader.each_event do |event|
433
+ event_count += 1
434
+ last_time = event.time
435
+ end
436
+
437
+ {
438
+ version: header.version,
439
+ width: header.width,
440
+ height: header.height,
441
+ timestamp: header.timestamp,
442
+ duration: header.duration || last_time,
443
+ idle_time_limit: header.idle_time_limit,
444
+ command: header.command,
445
+ title: header.title,
446
+ env: header.env,
447
+ theme: header.theme,
448
+ event_count: event_count
449
+ }
450
+ end
451
+ end
452
+
453
+ # Iterate over all events in the recording
454
+ #
455
+ # @yield [Event] Each event in order
456
+ # @return [Enumerator, void] If no block given, returns an Enumerator
457
+ def each_event
458
+ return enum_for(:each_event) unless block_given?
459
+
460
+ @events_started = true
461
+
462
+ @io.each_line do |line|
463
+ line = line.strip
464
+ next if line.empty?
465
+
466
+ begin
467
+ event = Event.from_json(line)
468
+ yield event
469
+ rescue FormatError
470
+ # Skip invalid lines (could be comments or garbage)
471
+ next
472
+ end
473
+ end
474
+ end
475
+
476
+ # Read all events into an array
477
+ #
478
+ # @return [Array<Event>] All events
479
+ def events
480
+ each_event.to_a
481
+ end
482
+
483
+ # Get total duration of the recording
484
+ #
485
+ # @return [Float] Duration in seconds
486
+ def duration
487
+ return @header.duration if @header.duration
488
+
489
+ # Calculate from events
490
+ last_time = 0.0
491
+ each_event { |e| last_time = e.time }
492
+ last_time
493
+ end
494
+
495
+ private
496
+
497
+ # Read and parse the header line
498
+ #
499
+ # @return [Header] Parsed header
500
+ # @raise [FormatError] If header is invalid
501
+ def read_header
502
+ line = @io.readline
503
+ Header.from_json(line.strip)
504
+ rescue EOFError
505
+ raise FormatError, "Empty file: no header found"
506
+ end
507
+ end
508
+
509
+ # Create a recording from a file path with a block
510
+ #
511
+ # @param path [String] Output file path
512
+ # @param width [Integer] Terminal width
513
+ # @param height [Integer] Terminal height
514
+ # @param kwargs [Hash] Additional header options
515
+ # @yield [Writer] Writer for adding events
516
+ # @return [void]
517
+ def self.create(path, width:, height:, **kwargs)
518
+ header = Header.new(width: width, height: height, **kwargs)
519
+
520
+ File.open(path, "w", encoding: "UTF-8") do |file|
521
+ writer = Writer.new(file, header)
522
+ yield writer if block_given?
523
+ writer.close
524
+ end
525
+ end
526
+
527
+ # Load a recording from a file path
528
+ #
529
+ # @param path [String] Input file path
530
+ # @return [Reader] Reader for the recording
531
+ # @raise [FormatError] If file is invalid
532
+ def self.load(path)
533
+ file = File.open(path, "r", encoding: "UTF-8")
534
+ Reader.new(file)
535
+ end
536
+ end
537
+ end