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,276 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module AsciinemaWin
6
+ # Output organization utilities
7
+ #
8
+ # Provides structured directory organization for recordings and exports
9
+ # with timestamp-based naming and format-specific subdirectories.
10
+ module OutputOrganizer
11
+ # Default base directory for outputs
12
+ DEFAULT_BASE_DIR = "asciinema_output"
13
+
14
+ # Format subdirectory mapping
15
+ FORMAT_DIRS = {
16
+ cast: "recordings",
17
+ html: "html",
18
+ svg: "svg",
19
+ txt: "text",
20
+ text: "text",
21
+ json: "json",
22
+ gif: "video",
23
+ mp4: "video",
24
+ webm: "video",
25
+ thumbnail: "thumbnails"
26
+ }.freeze
27
+
28
+ class << self
29
+ # Get organized output path
30
+ #
31
+ # @param base_name [String] Base name for the file
32
+ # @param format [Symbol] Output format
33
+ # @param base_dir [String] Base output directory
34
+ # @param timestamp [Boolean] Include timestamp in filename
35
+ # @param session_id [String, nil] Session ID for grouping related files
36
+ # @return [String] Full output path
37
+ def output_path(base_name, format:, base_dir: DEFAULT_BASE_DIR, timestamp: true, session_id: nil)
38
+ # Ensure base directory exists
39
+ ensure_directory(base_dir)
40
+
41
+ # Get format-specific subdirectory
42
+ format_dir = FORMAT_DIRS[format.to_sym] || "other"
43
+ full_dir = File.join(base_dir, format_dir)
44
+
45
+ # Add session subdirectory if provided
46
+ if session_id
47
+ full_dir = File.join(full_dir, session_id)
48
+ end
49
+
50
+ ensure_directory(full_dir)
51
+
52
+ # Build filename
53
+ filename = if timestamp
54
+ "#{base_name}_#{timestamp_string}.#{format}"
55
+ else
56
+ "#{base_name}.#{format}"
57
+ end
58
+
59
+ File.join(full_dir, filename)
60
+ end
61
+
62
+ # Create a session directory for related outputs
63
+ #
64
+ # @param name [String] Session name
65
+ # @param base_dir [String] Base output directory
66
+ # @return [Session] Session object for organizing related files
67
+ def create_session(name, base_dir: DEFAULT_BASE_DIR)
68
+ session_id = "#{sanitize_name(name)}_#{timestamp_string}"
69
+ Session.new(session_id, base_dir)
70
+ end
71
+
72
+ # Get organized path for a recording
73
+ #
74
+ # @param name [String] Recording name
75
+ # @param base_dir [String] Base output directory
76
+ # @param timestamp [Boolean] Include timestamp
77
+ # @return [String] Full path to recording file
78
+ def recording_path(name, base_dir: DEFAULT_BASE_DIR, timestamp: true)
79
+ output_path(sanitize_name(name), format: :cast, base_dir: base_dir, timestamp: timestamp)
80
+ end
81
+
82
+ # List all sessions in output directory
83
+ #
84
+ # @param base_dir [String] Base output directory
85
+ # @return [Array<String>] Session directory names
86
+ def list_sessions(base_dir: DEFAULT_BASE_DIR)
87
+ recordings_dir = File.join(base_dir, "recordings")
88
+ return [] unless Dir.exist?(recordings_dir)
89
+
90
+ Dir.children(recordings_dir)
91
+ .select { |f| File.directory?(File.join(recordings_dir, f)) }
92
+ .sort
93
+ .reverse
94
+ end
95
+
96
+ # Get summary of output directory contents
97
+ #
98
+ # @param base_dir [String] Base output directory
99
+ # @return [Hash] Summary of files by category
100
+ def summary(base_dir: DEFAULT_BASE_DIR)
101
+ result = {}
102
+
103
+ FORMAT_DIRS.values.uniq.each do |subdir|
104
+ path = File.join(base_dir, subdir)
105
+ next unless Dir.exist?(path)
106
+
107
+ files = Dir.glob(File.join(path, "**", "*"))
108
+ .select { |f| File.file?(f) }
109
+
110
+ result[subdir] = {
111
+ count: files.length,
112
+ total_size: files.sum { |f| File.size(f) },
113
+ files: files.map { |f| File.basename(f) }
114
+ }
115
+ end
116
+
117
+ result
118
+ end
119
+
120
+ # Clean old files from output directory
121
+ #
122
+ # @param base_dir [String] Base output directory
123
+ # @param keep_days [Integer] Keep files newer than this many days
124
+ # @return [Integer] Number of files deleted
125
+ def cleanup(base_dir: DEFAULT_BASE_DIR, keep_days: 30)
126
+ cutoff = Time.now - (keep_days * 24 * 60 * 60)
127
+ deleted = 0
128
+
129
+ Dir.glob(File.join(base_dir, "**", "*")).each do |file|
130
+ next unless File.file?(file)
131
+ next if File.mtime(file) > cutoff
132
+
133
+ File.delete(file)
134
+ deleted += 1
135
+ end
136
+
137
+ # Remove empty directories
138
+ Dir.glob(File.join(base_dir, "**", "*"))
139
+ .select { |d| File.directory?(d) }
140
+ .sort_by { |d| -d.length } # Deepest first
141
+ .each do |dir|
142
+ Dir.rmdir(dir) if Dir.empty?(dir)
143
+ rescue Errno::ENOTEMPTY
144
+ # Skip non-empty directories
145
+ end
146
+
147
+ deleted
148
+ end
149
+
150
+ private
151
+
152
+ # Generate timestamp string for filenames
153
+ #
154
+ # @return [String] Timestamp in YYYYMMDD_HHMMSS format
155
+ def timestamp_string
156
+ Time.now.strftime("%Y%m%d_%H%M%S")
157
+ end
158
+
159
+ # Sanitize a name for use in filenames
160
+ #
161
+ # @param name [String] Original name
162
+ # @return [String] Sanitized name
163
+ def sanitize_name(name)
164
+ name.to_s
165
+ .gsub(/[^a-zA-Z0-9_\-]/, "_")
166
+ .gsub(/_+/, "_")
167
+ .gsub(/^_|_$/, "")
168
+ .downcase
169
+ end
170
+
171
+ # Ensure directory exists
172
+ #
173
+ # @param path [String] Directory path
174
+ # @return [void]
175
+ def ensure_directory(path)
176
+ FileUtils.mkdir_p(path) unless Dir.exist?(path)
177
+ end
178
+ end
179
+
180
+ # Session for grouping related output files
181
+ class Session
182
+ # @return [String] Session ID
183
+ attr_reader :id
184
+
185
+ # @return [String] Base output directory
186
+ attr_reader :base_dir
187
+
188
+ # @return [Time] Session creation time
189
+ attr_reader :created_at
190
+
191
+ # @return [Hash] Paths generated in this session
192
+ attr_reader :outputs
193
+
194
+ def initialize(id, base_dir)
195
+ @id = id
196
+ @base_dir = base_dir
197
+ @created_at = Time.now
198
+ @outputs = {}
199
+ end
200
+
201
+ # Get path for a recording in this session
202
+ #
203
+ # @param name [String] Recording name
204
+ # @return [String] Full path
205
+ def recording_path(name = "recording")
206
+ path = OutputOrganizer.output_path(
207
+ OutputOrganizer.send(:sanitize_name, name),
208
+ format: :cast,
209
+ base_dir: @base_dir,
210
+ session_id: @id,
211
+ timestamp: false
212
+ )
213
+ @outputs[:recording] = path
214
+ path
215
+ end
216
+
217
+ # Get path for an export in this session
218
+ #
219
+ # @param name [String] Export name
220
+ # @param format [Symbol] Output format
221
+ # @return [String] Full path
222
+ def export_path(name, format:)
223
+ path = OutputOrganizer.output_path(
224
+ OutputOrganizer.send(:sanitize_name, name),
225
+ format: format,
226
+ base_dir: @base_dir,
227
+ session_id: @id,
228
+ timestamp: false
229
+ )
230
+ @outputs[format] ||= []
231
+ @outputs[format] << path
232
+ path
233
+ end
234
+
235
+ # Get path for a thumbnail in this session
236
+ #
237
+ # @param name [String] Thumbnail name
238
+ # @param frame [Symbol] Frame type (:first, :middle, :last)
239
+ # @return [String] Full path
240
+ def thumbnail_path(name, frame: :last)
241
+ path = OutputOrganizer.output_path(
242
+ "#{OutputOrganizer.send(:sanitize_name, name)}_#{frame}",
243
+ format: :svg,
244
+ base_dir: File.join(@base_dir, "thumbnails"),
245
+ session_id: @id,
246
+ timestamp: false
247
+ )
248
+ @outputs[:thumbnails] ||= []
249
+ @outputs[:thumbnails] << path
250
+ path
251
+ end
252
+
253
+ # Get session directory path
254
+ #
255
+ # @return [String] Session directory
256
+ def directory
257
+ File.join(@base_dir, "recordings", @id)
258
+ end
259
+
260
+ # Print summary of session outputs
261
+ #
262
+ # @return [String] Summary text
263
+ def summary
264
+ lines = ["Session: #{@id}", "Created: #{@created_at}", "Outputs:"]
265
+ @outputs.each do |type, paths|
266
+ paths = [paths] unless paths.is_a?(Array)
267
+ paths.each do |path|
268
+ size = File.exist?(path) ? File.size(path) : 0
269
+ lines << " #{type}: #{File.basename(path)} (#{size} bytes)"
270
+ end
271
+ end
272
+ lines.join("\n")
273
+ end
274
+ end
275
+ end
276
+ end
@@ -0,0 +1,348 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AsciinemaWin
4
+ # Terminal session player for Windows
5
+ #
6
+ # Plays back asciicast v2 recordings with accurate timing,
7
+ # using Rich-Ruby for terminal output rendering. Supports
8
+ # speed control, idle time limiting, and interactive controls.
9
+ #
10
+ # @example Basic playback
11
+ # player = AsciinemaWin::Player.new
12
+ # player.play("session.cast")
13
+ #
14
+ # @example Fast playback
15
+ # player = AsciinemaWin::Player.new(speed: 2.0)
16
+ # player.play("session.cast")
17
+ #
18
+ # @example With idle time limit
19
+ # player = AsciinemaWin::Player.new(idle_time_limit: 1.0)
20
+ # player.play("session.cast")
21
+ class Player
22
+ # @return [Float] Playback speed multiplier
23
+ attr_reader :speed
24
+
25
+ # @return [Float, nil] Maximum idle time between frames
26
+ attr_reader :idle_time_limit
27
+
28
+ # @return [Float, nil] Alternative max idle time setting
29
+ attr_reader :max_idle_time
30
+
31
+ # @return [Boolean] Whether to pause at markers
32
+ attr_reader :pause_on_markers
33
+
34
+ # @return [Symbol] Current playback state (:idle, :playing, :paused, :stopped)
35
+ attr_reader :state
36
+
37
+ # @return [Float] Current playback position in seconds
38
+ attr_reader :position
39
+
40
+ # Create a new player
41
+ #
42
+ # @param speed [Float] Playback speed multiplier (1.0 = normal)
43
+ # @param idle_time_limit [Float, nil] Cap idle time from recording header
44
+ # @param max_idle_time [Float, nil] Override max idle time between frames
45
+ # @param pause_on_markers [Boolean] Pause playback at marker events
46
+ def initialize(
47
+ speed: 1.0,
48
+ idle_time_limit: nil,
49
+ max_idle_time: nil,
50
+ pause_on_markers: false
51
+ )
52
+ @speed = speed.to_f
53
+ @speed = 1.0 if @speed <= 0
54
+
55
+ @idle_time_limit = idle_time_limit&.to_f
56
+ @max_idle_time = max_idle_time&.to_f
57
+ @pause_on_markers = pause_on_markers
58
+
59
+ @state = :idle
60
+ @position = 0.0
61
+ @reader = nil
62
+ @header = nil
63
+ end
64
+
65
+ # Play a recording from a file
66
+ #
67
+ # @param input_path [String] Path to the recording file
68
+ # @return [void]
69
+ # @raise [FormatError] If file format is invalid
70
+ # @raise [PlaybackError] If playback fails
71
+ def play(input_path)
72
+ raise PlaybackError, "Already playing" if @state == :playing
73
+
74
+ File.open(input_path, "r", encoding: "UTF-8") do |file|
75
+ @reader = Asciicast::Reader.new(file)
76
+ @header = @reader.header
77
+ @state = :playing
78
+ @position = 0.0
79
+
80
+ begin
81
+ setup_terminal
82
+ play_events
83
+ ensure
84
+ restore_terminal
85
+ @state = :stopped
86
+ end
87
+ end
88
+ end
89
+
90
+ # Play from an IO stream
91
+ #
92
+ # @param io [IO] Input stream
93
+ # @return [void]
94
+ def play_stream(io)
95
+ raise PlaybackError, "Already playing" if @state == :playing
96
+
97
+ @reader = Asciicast::Reader.new(io)
98
+ @header = @reader.header
99
+ @state = :playing
100
+ @position = 0.0
101
+
102
+ begin
103
+ setup_terminal
104
+ play_events
105
+ ensure
106
+ restore_terminal
107
+ @state = :stopped
108
+ end
109
+ end
110
+
111
+ # Pause playback
112
+ #
113
+ # @return [void]
114
+ def pause
115
+ return unless @state == :playing
116
+
117
+ @state = :paused
118
+ end
119
+
120
+ # Resume playback after pause
121
+ #
122
+ # @return [void]
123
+ def resume
124
+ return unless @state == :paused
125
+
126
+ @state = :playing
127
+ end
128
+
129
+ # Stop playback
130
+ #
131
+ # @return [void]
132
+ def stop
133
+ @state = :stopped
134
+ end
135
+
136
+ # Set playback speed
137
+ #
138
+ # @param multiplier [Float] New speed multiplier
139
+ # @return [void]
140
+ def set_speed(multiplier)
141
+ @speed = [multiplier.to_f, 0.1].max
142
+ end
143
+
144
+ # Get recording info without playing
145
+ #
146
+ # @param input_path [String] Path to recording file
147
+ # @return [Hash] Recording metadata
148
+ def info(input_path)
149
+ Asciicast::Reader.info(input_path)
150
+ end
151
+
152
+ private
153
+
154
+ # Set up terminal for playback
155
+ #
156
+ # @return [void]
157
+ def setup_terminal
158
+ return unless @header
159
+
160
+ # Enable ANSI on Windows
161
+ if Gem.win_platform? && defined?(Rich::Win32Console)
162
+ Rich::Win32Console.enable_ansi!
163
+ end
164
+
165
+ # Clear screen and move cursor to home
166
+ print "\e[2J" # Clear screen
167
+ print "\e[H" # Move to home
168
+
169
+ # Hide cursor during playback
170
+ print "\e[?25l"
171
+
172
+ # Print playback info if verbose
173
+ if ENV["DEBUG"]
174
+ $stderr.puts "Playing: #{@header.title || "Untitled"}"
175
+ $stderr.puts "Size: #{@header.width}x#{@header.height}"
176
+ $stderr.puts "Speed: #{@speed}x"
177
+ end
178
+ end
179
+
180
+ # Restore terminal to normal state
181
+ #
182
+ # @return [void]
183
+ def restore_terminal
184
+ # Reset colors and attributes
185
+ print "\e[0m"
186
+
187
+ # Show cursor
188
+ print "\e[?25h"
189
+
190
+ # Move to new line
191
+ puts
192
+
193
+ # Flush output
194
+ $stdout.flush
195
+ end
196
+
197
+ # Play all events from the recording
198
+ #
199
+ # @return [void]
200
+ def play_events
201
+ last_time = 0.0
202
+ effective_idle_limit = calculate_idle_limit
203
+
204
+ @reader.each_event do |event|
205
+ break if @state == :stopped
206
+
207
+ # Handle pause state
208
+ while @state == :paused
209
+ sleep(0.1)
210
+ break if @state == :stopped
211
+ end
212
+
213
+ break if @state == :stopped
214
+
215
+ # Calculate delay
216
+ delay = event.time - last_time
217
+
218
+ # Apply idle time limit
219
+ if effective_idle_limit && delay > effective_idle_limit
220
+ delay = effective_idle_limit
221
+ end
222
+
223
+ # Apply speed multiplier
224
+ delay /= @speed unless @speed == Float::INFINITY
225
+
226
+ # Wait for appropriate time
227
+ wait_for_event(delay) if delay > 0
228
+
229
+ # Update position
230
+ @position = event.time
231
+ last_time = event.time
232
+
233
+ # Process event
234
+ process_event(event)
235
+ end
236
+ end
237
+
238
+ # Calculate effective idle time limit
239
+ #
240
+ # @return [Float, nil] Effective idle limit
241
+ def calculate_idle_limit
242
+ # Priority: explicit max_idle_time > idle_time_limit > header value
243
+ @max_idle_time ||
244
+ @idle_time_limit ||
245
+ @header&.idle_time_limit
246
+ end
247
+
248
+ # Wait for the specified duration (interruptible)
249
+ #
250
+ # @param seconds [Float] Time to wait
251
+ # @return [void]
252
+ def wait_for_event(seconds)
253
+ return if seconds <= 0 || @speed == Float::INFINITY
254
+
255
+ # Use small sleep intervals to allow interruption
256
+ remaining = seconds
257
+
258
+ while remaining > 0 && @state == :playing
259
+ sleep_time = [remaining, 0.05].min
260
+ sleep(sleep_time)
261
+ remaining -= sleep_time
262
+ end
263
+ end
264
+
265
+ # Process a single event
266
+ #
267
+ # @param event [Asciicast::Event] Event to process
268
+ # @return [void]
269
+ def process_event(event)
270
+ case event.type
271
+ when Asciicast::EventType::OUTPUT
272
+ render_output(event.data)
273
+ when Asciicast::EventType::RESIZE
274
+ handle_resize(event)
275
+ when Asciicast::EventType::MARKER
276
+ handle_marker(event)
277
+ when Asciicast::EventType::INPUT
278
+ # Input events are typically not played back
279
+ nil
280
+ end
281
+ end
282
+
283
+ # Render output data to terminal
284
+ #
285
+ # @param data [String] Output data (may contain ANSI sequences)
286
+ # @return [void]
287
+ def render_output(data)
288
+ # Write directly to stdout, preserving ANSI sequences
289
+ print data
290
+ $stdout.flush
291
+ end
292
+
293
+ # Handle resize event
294
+ #
295
+ # @param event [Asciicast::Event] Resize event
296
+ # @return [void]
297
+ def handle_resize(event)
298
+ dimensions = event.resize_dimensions
299
+ return unless dimensions
300
+
301
+ width, height = dimensions
302
+
303
+ # Note: We can't actually resize the terminal, but we could
304
+ # adjust our rendering or warn the user if sizes don't match
305
+ if ENV["DEBUG"]
306
+ $stderr.puts "Resize: #{width}x#{height}"
307
+ end
308
+ end
309
+
310
+ # Handle marker event
311
+ #
312
+ # @param event [Asciicast::Event] Marker event
313
+ # @return [void]
314
+ def handle_marker(event)
315
+ return unless @pause_on_markers
316
+
317
+ label = event.data.empty? ? "marker" : event.data
318
+ $stderr.puts "\n[Marker: #{label}] Press Enter to continue..."
319
+
320
+ # Pause and wait for input
321
+ @state = :paused
322
+ $stdin.gets
323
+ @state = :playing
324
+ end
325
+ end
326
+
327
+ # Simple output mode - outputs recording to stdout without timing
328
+ # Used by the `cat` command for piping to other tools
329
+ class RawPlayer < Player
330
+ def initialize
331
+ super(speed: Float::INFINITY)
332
+ end
333
+
334
+ private
335
+
336
+ def setup_terminal
337
+ # No setup needed for raw output
338
+ end
339
+
340
+ def restore_terminal
341
+ # No restore needed
342
+ end
343
+
344
+ def wait_for_event(_seconds)
345
+ # No waiting in raw mode
346
+ end
347
+ end
348
+ end