aspera-cli 4.25.6 → 4.26.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.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +74 -47
  4. data/CONTRIBUTING.md +1 -1
  5. data/lib/aspera/api/aoc.rb +118 -78
  6. data/lib/aspera/api/node.rb +101 -49
  7. data/lib/aspera/ascp/installation.rb +94 -30
  8. data/lib/aspera/cli/extended_value.rb +1 -0
  9. data/lib/aspera/cli/formatter.rb +47 -40
  10. data/lib/aspera/cli/manager.rb +30 -4
  11. data/lib/aspera/cli/plugins/aoc.rb +214 -136
  12. data/lib/aspera/cli/plugins/ats.rb +3 -3
  13. data/lib/aspera/cli/plugins/base.rb +17 -42
  14. data/lib/aspera/cli/plugins/config.rb +5 -3
  15. data/lib/aspera/cli/plugins/console.rb +3 -3
  16. data/lib/aspera/cli/plugins/faspex.rb +5 -5
  17. data/lib/aspera/cli/plugins/faspex5.rb +20 -18
  18. data/lib/aspera/cli/plugins/node.rb +66 -70
  19. data/lib/aspera/cli/plugins/oauth.rb +5 -12
  20. data/lib/aspera/cli/plugins/orchestrator.rb +13 -13
  21. data/lib/aspera/cli/plugins/preview.rb +116 -80
  22. data/lib/aspera/cli/plugins/server.rb +2 -10
  23. data/lib/aspera/cli/plugins/shares.rb +7 -7
  24. data/lib/aspera/cli/version.rb +1 -1
  25. data/lib/aspera/dot_container.rb +7 -3
  26. data/lib/aspera/environment.rb +3 -2
  27. data/lib/aspera/log.rb +1 -1
  28. data/lib/aspera/preview/file_types.rb +1 -1
  29. data/lib/aspera/preview/generator.rb +146 -91
  30. data/lib/aspera/preview/options.rb +4 -1
  31. data/lib/aspera/preview/terminal.rb +50 -20
  32. data/lib/aspera/preview/utils.rb +76 -34
  33. data/lib/aspera/products/transferd.rb +1 -1
  34. data/lib/aspera/rest.rb +1 -0
  35. data/lib/aspera/rest_list.rb +23 -16
  36. data/lib/aspera/secret_hider.rb +3 -1
  37. data/lib/aspera/uri_reader.rb +17 -2
  38. data.tar.gz.sig +0 -0
  39. metadata +5 -5
  40. metadata.gz.sig +0 -0
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # ffmpeg options:
4
- # spellchecker:ignore pauseframes libx264 trunc bufsize muxer apng libmp3lame maxrate posterize movflags faststart
4
+ # spellchecker:ignore soffice pauseframes libx264 trunc bufsize muxer apng libmp3lame maxrate posterize movflags faststart
5
5
  # spellchecker:ignore palettegen paletteuse pointsize bordercolor repage lanczos unoconv optipng reencode conv transframes
6
6
 
7
7
  require 'aspera/preview/options'
@@ -12,37 +12,41 @@ require 'aspera/assert'
12
12
 
13
13
  module Aspera
14
14
  module Preview
15
- # generate one preview file for one format for one file at a time
15
+ # Generates one preview file for one format for one file at a time.
16
16
  class Generator
17
- # values for preview_format : output format
17
+ # Values for preview_format: output format.
18
18
  PREVIEW_FORMATS = %i[png mp4].freeze
19
19
 
20
+ # List of valid ffmpeg option keys for reencode configuration.
20
21
  FFMPEG_OPTIONS_LIST = %w[in out].freeze
21
22
 
22
- # CLI needs to know conversion type to know if need skip it
23
- # one of CONVERSION_TYPES
24
- attr_reader :conversion_type
23
+ # CLI needs to know conversion type to know if need skip it.
24
+ # One of CONVERSION_TYPES.
25
+ attr_reader :conversion_type, :destination
25
26
 
26
- # Node API MIME types are from: http://svn.apache.org/repos/asf/httpd/httpd/trunk/docs/conf/mime.types
27
+ # Node API MIME types are from: http://svn.apache.org/repos/asf/httpd/httpd/trunk/docs/conf/mime.types.
27
28
  # The resulting preview file type is taken from destination file extension.
28
- # Conversion methods are provided by private methods: convert_<conversion_type>_to_<preview_format>
29
- # -> conversion_type is one of FileTypes::CONVERSION_TYPES
30
- # -> preview_format is one of Generator::PREVIEW_FORMATS
31
- # The conversion video->mp4 is implemented in methods: convert_video_to_mp4_using_<video_conversion>
32
- # -> conversion method is one of Generator::VIDEO_CONVERSION_METHODS
33
- # @param src [String] source file path
34
- # @param dst [String] destination file path
35
- # @param options [Options] All conversion options
36
- # @param main_temp_dir [String] Main temp folder, sub folder will be created for generation
37
- # @param api_mime_type [String,nil] Optional MIME type as provided by node api (or nil)
38
- def initialize(src, dst, options, main_temp_dir, api_mime_type)
39
- @source_file_path = src
40
- @destination_file_path = dst
29
+ # Conversion methods are provided by private methods: convert_<conversion_type>_to_<preview_format>.
30
+ # -> conversion_type is one of FileTypes::CONVERSION_TYPES.
31
+ # -> preview_format is one of Generator::PREVIEW_FORMATS.
32
+ # The conversion video->mp4 is implemented in methods: convert_video_to_mp4_using_<video_conversion>.
33
+ # -> conversion method is one of Generator::VIDEO_CONVERSION_METHODS.
34
+ # @param src [String] Source file path.
35
+ # @param dst [String] Destination file path.
36
+ # @param options [Options] All conversion options.
37
+ # @param main_temp_dir [String] Main temp folder, sub folder will be created for generation.
38
+ # @param mime [String, nil] Optional MIME type as provided by node api (or nil).
39
+ def initialize(src, dst, options, main_temp_dir, mime: nil)
40
+ # Source file path
41
+ @source = src
42
+ # Destination file path
43
+ @destination = dst
41
44
  @options = options
42
- @temp_folder = File.join(main_temp_dir, @source_file_path.split('/').last.gsub(/\s/, '_').gsub(/\W/, ''))
43
- # extract preview format from extension of target file
44
- @preview_format_sym = File.extname(@destination_file_path).gsub(/^\./, '').to_sym
45
- conversion_type = FileTypes.instance.conversion_type(@source_file_path, api_mime_type)
45
+ # temp folder name based on source file
46
+ @temp_folder = File.join(main_temp_dir, @source.split('/').last.gsub(/\s/, '_').gsub(/\W/, ''))
47
+ # Extract preview format from extension of target file.
48
+ @preview_format_sym = File.extname(@destination).gsub(/^\./, '').to_sym
49
+ conversion_type = FileTypes.instance.conversion_type(@source, mime)
46
50
  @processing_method = "convert_#{conversion_type}_to_#{@preview_format_sym}"
47
51
  if conversion_type.eql?(:video)
48
52
  case @preview_format_sym
@@ -55,82 +59,97 @@ module Aspera
55
59
  @processing_method = @processing_method.to_sym
56
60
  Log.log.debug{"method: #{@processing_method}"}
57
61
  Aspera.assert(respond_to?(@processing_method, true)){"no processing known for #{conversion_type} -> #{@preview_format_sym}"}
62
+ command = [:magick] + %w[identify -list font]
63
+ magick_fonts = Utils.parse_magick_fonts(Utils.execute(*command, mode: :capture).first)
64
+ Aspera.assert(magick_fonts[:fonts].any?{ |f| f[:name].eql?(@options.thumb_text_font)}){"Missing font #{@options.thumb_text_font} in #{command}"}
58
65
  end
59
66
 
60
- # Create preview as specified in constructor.
67
+ # Creates preview as specified in constructor.
61
68
  def generate
62
- Log.log.debug{"#{@source_file_path}->#{@destination_file_path} (#{@processing_method})"}
69
+ Log.log.debug{"#{@source}->#{@destination} (#{@processing_method})"}
63
70
  begin
64
71
  send(@processing_method)
65
- # check that generated size does not exceed maximum
66
- result_size = File.size(@destination_file_path)
72
+ # Check that generated size does not exceed maximum.
73
+ result_size = File.size(@destination)
67
74
  Log.log.warn{"preview size exceeds maximum allowed #{result_size} > #{@options.max_size}"} if result_size > @options.max_size
68
- rescue StandardError => e
69
- Log.log.error{"Ignoring: #{e.class} #{e.message}"}
70
- Log.log.debug(e.backtrace.join("\n").red)
71
- FileUtils.cp(File.expand_path(@preview_format_sym.eql?(:mp4) ? 'video_error.png' : 'image_error.png', File.dirname(__FILE__)), @destination_file_path)
72
75
  ensure
73
76
  FileUtils.rm_rf(@temp_folder)
74
77
  end
75
78
  end
76
79
 
80
+ # Path to error image corresponding to preview type.
81
+ # @return [String] The path to the error image.
82
+ def error_asset
83
+ File.expand_path(@preview_format_sym.eql?(:mp4) ? 'video_error.png' : 'image_error.png', File.dirname(__FILE__))
84
+ end
85
+
77
86
  private
78
87
 
79
88
  # Creates a unique temp folder for file.
89
+ # @return [String] The temporary folder path.
80
90
  def this_tmpdir
81
91
  FileUtils.mkdir_p(@temp_folder)
82
92
  return @temp_folder
83
93
  end
84
94
 
85
- # @param duration of video
86
- # @param start_offset of parts
87
- # @param total_count of parts
88
- # @param index of part (start at 1)
89
- # @return [Integer] offset in seconds suitable for ffmpeg -ss option
95
+ # Calculates offset in seconds for video frame extraction.
96
+ # @param duration [Float] Duration of video in seconds.
97
+ # @param start_offset [Numeric] Start offset of parts in seconds.
98
+ # @param total_count [Integer] Total count of parts.
99
+ # @param index [Integer] Index of part (starts at 1).
100
+ # @return [Float] Offset in seconds suitable for ffmpeg -ss option.
90
101
  def get_offset(duration, start_offset, total_count, index)
91
102
  Aspera.assert_type(duration, Float){'duration'}
92
103
  return start_offset + ((index - 1) * (duration - start_offset) / total_count)
93
104
  end
94
105
 
106
+ # Converts video to MP4 using blend method.
107
+ # Extracts key frames and blends them with transitions.
95
108
  def convert_video_to_mp4_using_blend
96
- p_duration = Utils.video_get_duration(@source_file_path)
109
+ p_duration = Utils.video_get_duration(@source)
97
110
  p_start_offset = @options.video_start_sec.to_i
98
111
  p_key_frame_count = @options.blend_keyframes.to_i
99
112
  last_keyframe = nil
100
113
  current_index = 1
114
+ frame_rate_hz = 30
101
115
  1.upto(p_key_frame_count) do |i|
102
- offset_seconds = get_offset(p_duration, p_start_offset, p_key_frame_count, i)
103
- Utils.video_dump_frame(@source_file_path, offset_seconds, @options.video_scale, this_tmpdir, current_index)
116
+ Utils.video_dump_frame(
117
+ @source,
118
+ get_offset(p_duration, p_start_offset, p_key_frame_count, i),
119
+ @options.video_scale,
120
+ Utils.get_tmp_num_filepath(this_tmpdir, current_index)
121
+ )
104
122
  Utils.video_dupe_frame(this_tmpdir, current_index, @options.blend_pauseframes)
105
123
  Utils.video_blend_frames(this_tmpdir, last_keyframe, current_index) unless last_keyframe.nil?
106
- # go to last dupe frame
124
+ # Go to last dupe frame.
107
125
  last_keyframe = current_index + @options.blend_pauseframes
108
- # go after last dupe frame and keep space to blend
126
+ # Go after last dupe frame and keep space to blend.
109
127
  current_index = last_keyframe + 1 + @options.blend_transframes
110
128
  end
111
129
  Utils.ffmpeg(
112
130
  in_f: Utils.ffmpeg_fmt(this_tmpdir),
113
131
  in_p: ['-framerate', @options.blend_fps],
114
- out_f: @destination_file_path,
132
+ out_f: @destination,
115
133
  out_p: [
116
134
  '-filter:v', "scale='trunc(iw/2)*2:trunc(ih/2)*2'",
117
135
  '-codec:v', 'libx264',
118
- '-r', 30,
136
+ '-r', frame_rate_hz,
119
137
  '-pix_fmt', 'yuv420p'
120
138
  ]
121
139
  )
122
140
  end
123
141
 
124
- # generate n clips starting at offset
142
+ # Converts video to MP4 using clips method.
143
+ # Generates n clips starting at offset and concatenates them.
125
144
  def convert_video_to_mp4_using_clips
126
- p_duration = Utils.video_get_duration(@source_file_path)
145
+ p_duration = Utils.video_get_duration(@source)
127
146
  file_list_file = File.join(this_tmpdir, 'clip_files.txt')
128
147
  File.open(file_list_file, 'w+') do |f|
129
148
  1.upto(@options.clips_count.to_i) do |i|
130
149
  offset_seconds = get_offset(p_duration, @options.video_start_sec.to_i, @options.clips_count.to_i, i)
131
150
  tmp_file_name = format('clip%04d.mp4', i)
132
151
  Utils.ffmpeg(
133
- in_f: @source_file_path,
152
+ in_f: @source,
134
153
  in_p: ['-ss', offset_seconds * 0.9],
135
154
  out_f: File.join(this_tmpdir, tmp_file_name),
136
155
  out_p: [
@@ -143,17 +162,18 @@ module Aspera
143
162
  f.puts("file '#{tmp_file_name}'")
144
163
  end
145
164
  end
146
- # concat clips
165
+ # Concat clips.
147
166
  Utils.ffmpeg(
148
167
  in_f: file_list_file,
149
168
  in_p: ['-f', 'concat'],
150
- out_f: @destination_file_path,
169
+ out_f: @destination,
151
170
  out_p: ['-codec', 'copy']
152
171
  )
153
172
  File.delete(file_list_file)
154
173
  end
155
174
 
156
- # do a simple re-encoding
175
+ # Converts video to MP4 using re-encoding method.
176
+ # Performs a simple re-encoding with configurable ffmpeg options.
157
177
  def convert_video_to_mp4_using_reencode
158
178
  options = @options.reencode_ffmpeg
159
179
  Aspera.assert_type(options, Hash){'reencode_ffmpeg'}
@@ -162,11 +182,11 @@ module Aspera
162
182
  Aspera.assert_type(v, Array){k}
163
183
  end
164
184
  Utils.ffmpeg(
165
- in_f: @source_file_path,
185
+ in_f: @source,
166
186
  in_p: options['in'] || ['-ss', @options.video_start_sec.to_i * 0.9],
167
- out_f: @destination_file_path,
187
+ out_f: @destination,
168
188
  out_p: options['out'] || [
169
- '-t', '60',
189
+ '-t', 60,
170
190
  '-codec:v', 'libx264',
171
191
  '-profile:v', 'high',
172
192
  '-pix_fmt', 'yuv420p',
@@ -184,89 +204,124 @@ module Aspera
184
204
  )
185
205
  end
186
206
 
207
+ # Converts video to PNG using fixed frame method.
208
+ # Generates a static thumbnail at a specific time offset.
187
209
  def convert_video_to_png_using_fixed
188
210
  Utils.video_dump_frame(
189
- @source_file_path,
190
- Utils.video_get_duration(@source_file_path) * @options.thumb_vid_fraction,
211
+ @source,
212
+ Utils.video_get_duration(@source) * @options.thumb_vid_fraction,
191
213
  @options.thumb_vid_scale,
192
- @destination_file_path
214
+ @destination
193
215
  )
194
216
  end
195
217
 
196
- # https://trac.ffmpeg.org/wiki/SponsoringPrograms/GSoC/2015#AnimatedPortableNetworkGraphicsAPNG
197
- # ffmpeg -h muxer=apng
198
- # thumb is 32x32
199
- # ffmpeg output.png
218
+ # Converts video to animated PNG (APNG).
219
+ # Creates an animated thumbnail with looping.
220
+ # @see https://trac.ffmpeg.org/wiki/SponsoringPrograms/GSoC/2015#AnimatedPortableNetworkGraphicsAPNG
200
221
  def convert_video_to_png_using_animated
222
+ p_duration = Utils.video_get_duration(@source)
223
+ p_start_offset = @options.video_start_sec.to_i
224
+ p_max_duration = @options.clips_length.to_i
225
+ # If video is shorter than start offset + duration, adjust to capture from start.
226
+ if p_duration <= (p_start_offset + p_max_duration)
227
+ p_start_offset = 0
228
+ p_max_duration = p_duration
229
+ end
201
230
  Utils.ffmpeg(
202
- in_f: @source_file_path,
231
+ in_f: @source,
203
232
  in_p: [
204
- '-ss', 10, # seek to input position
205
- '-t', 20 # max seconds
233
+ '-ss', p_start_offset,
234
+ '-t', p_max_duration
206
235
  ],
207
- out_f: @destination_file_path,
236
+ out_f: @destination,
208
237
  out_p: [
209
- '-vf', 'fps=5,scale=120:-1:flags=lanczos,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse',
210
- '-loop', 0,
211
- '-f', 'gif'
238
+ '-vf', 'fps=5,scale=120:-1:flags=lanczos',
239
+ '-plays', 0, # Loop forever (0 = infinite loop for APNG).
240
+ '-f', 'apng'
212
241
  ]
213
242
  )
214
243
  end
215
244
 
245
+ # Converts office document to PNG.
246
+ # First converts to PDF, then to PNG image.
216
247
  def convert_office_to_png
217
- tmp_pdf_file = File.join(this_tmpdir, "#{File.basename(@source_file_path, File.extname(@source_file_path))}.pdf")
218
- Utils.external_command(:unoconv, [
219
- '-f', 'pdf',
220
- '-o', tmp_pdf_file,
221
- @source_file_path
222
- ])
248
+ tmp_pdf_file = File.join(this_tmpdir, "#{File.basename(@source, File.extname(@source))}.pdf")
249
+ case @options.office_conversion
250
+ when :unoconv
251
+ Utils.silent_execute(
252
+ :unoconv,
253
+ '-f', 'pdf',
254
+ '-o', tmp_pdf_file,
255
+ @source
256
+ )
257
+ when :soffice
258
+ Utils.silent_execute(
259
+ :soffice,
260
+ '--headless',
261
+ '--convert-to', 'pdf',
262
+ '--outdir', File.dirname(tmp_pdf_file),
263
+ @source
264
+ )
265
+ # soffice creates the file with the source name, so we need to rename it if needed.
266
+ generated_pdf = File.join(File.dirname(tmp_pdf_file), "#{File.basename(@source, File.extname(@source))}.pdf")
267
+ FileUtils.mv(generated_pdf, tmp_pdf_file) if generated_pdf != tmp_pdf_file
268
+ else Aspera.error_unexpected_value(@options.office_conversion){'office_conversion'}
269
+ end
223
270
  convert_pdf_to_png(tmp_pdf_file)
224
271
  end
225
272
 
273
+ # Converts PDF to PNG image.
274
+ # @param source_file_path [String, nil] Optional source file path, defaults to @source.
226
275
  def convert_pdf_to_png(source_file_path = nil)
227
- source_file_path ||= @source_file_path
228
- Utils.external_command(:magick, [
276
+ source_file_path ||= @source
277
+ Utils.silent_execute(
278
+ :magick,
229
279
  'convert',
230
280
  '-size', "x#{@options.thumb_img_size}",
231
281
  '-background', 'white',
232
282
  '-flatten',
233
283
  "#{source_file_path}[0]",
234
- @destination_file_path
235
- ])
284
+ @destination
285
+ )
236
286
  end
237
287
 
288
+ # Converts image to PNG thumbnail.
289
+ # Applies auto-orientation, resizing, and optimization.
238
290
  def convert_image_to_png
239
- Utils.external_command(:magick, [
291
+ Utils.silent_execute(
292
+ :magick,
240
293
  'convert',
241
294
  '-auto-orient',
242
295
  '-thumbnail', "#{@options.thumb_img_size}x#{@options.thumb_img_size}>",
243
296
  '-quality', 95,
244
297
  '+dither',
245
298
  '-posterize', 40,
246
- "#{@source_file_path}[0]",
247
- @destination_file_path
248
- ])
249
- Utils.external_command(:optipng, [@destination_file_path])
299
+ "#{@source}[0]",
300
+ @destination
301
+ )
302
+ Utils.silent_execute(:optipng, @destination)
250
303
  end
251
304
 
252
- # text to png
305
+ # Converts plain text to PNG image.
306
+ # Renders first 100 lines of text file as an image.
253
307
  def convert_plaintext_to_png
254
- # get 100 first lines of text file
255
- first_lines = File.foreach(@source_file_path).first(100).join
256
- Utils.external_command(:magick, [
308
+ # Get 100 first lines of text file.
309
+ first_lines = File.foreach(@source).first(100).join
310
+ Utils.silent_execute(
311
+ :magick,
257
312
  'convert',
258
313
  '-size', "#{@options.thumb_img_size}x#{@options.thumb_img_size}",
259
- 'xc:white', # define canvas with background color (xc, or canvas) of preceding size
314
+ 'xc:white', # Define canvas with background color (xc, or canvas) of preceding size.
260
315
  '-font', @options.thumb_text_font,
261
316
  '-pointsize', 12,
262
- '-fill', 'black', # font color
317
+ '-fill', 'black', # Font color.
263
318
  '-annotate', '+0+0', first_lines,
264
- '-trim', # avoid large blank regions
319
+ '-trim', # Avoid large blank regions.
265
320
  '-bordercolor', 'white',
266
321
  '-border', 8,
267
322
  '+repage',
268
- @destination_file_path
269
- ])
323
+ @destination
324
+ )
270
325
  end
271
326
  end
272
327
  end
@@ -10,6 +10,8 @@ module Aspera
10
10
  # types of generation for video files
11
11
  VIDEO_CONVERSION_METHODS = %i[reencode blend clips].freeze
12
12
  VIDEO_THUMBNAIL_METHODS = %i[fixed animated].freeze
13
+ # methods for office document conversion
14
+ OFFICE_CONVERSION_METHODS = %i[soffice unoconv].freeze
13
15
  # options used in generator
14
16
  # for scaling see: https://trac.ffmpeg.org/wiki/Scaling
15
17
  # iw/ih : input width or height
@@ -20,11 +22,12 @@ module Aspera
20
22
  {name: :thumb_vid_fraction, default: 0.1, description: 'png: video: time percent position of snapshot'},
21
23
  {name: :thumb_img_size, default: 800, description: 'png: non-video: height (and width)'},
22
24
  {name: :thumb_text_font, default: 'Courier', description: 'png: plaintext: font for text rendering: `magick identify -list font`'},
25
+ {name: :office_conversion, default: :soffice, description: 'office: method for office document conversion', values: OFFICE_CONVERSION_METHODS},
23
26
  {name: :video_conversion, default: :reencode, description: 'mp4: method for preview generation', values: VIDEO_CONVERSION_METHODS},
24
27
  {name: :video_png_conv, default: :fixed, description: 'mp4: method for thumbnail generation', values: VIDEO_THUMBNAIL_METHODS},
25
28
  {name: :video_scale, default: "'min(iw,360)':-2", description: 'mp4: all: video scale (ffmpeg scale argument)'},
26
29
  {name: :video_start_sec, default: 10, description: 'mp4: all: start offset (seconds) of video preview'},
27
- {name: :reencode_ffmpeg, default: {}, description: 'mp4: reencode: options to ffmpeg'},
30
+ {name: :reencode_ffmpeg, default: {}, description: 'mp4: reencode: options to ffmpeg, keys: `in`, `out`'},
28
31
  {name: :blend_keyframes, default: 30, description: 'mp4: blend: # key frames'},
29
32
  {name: :blend_pauseframes, default: 3, description: 'mp4: blend: # pause frames'},
30
33
  {name: :blend_transframes, default: 5, description: 'mp4: blend: # transition blend frames'},
@@ -11,15 +11,22 @@ require 'aspera/environment'
11
11
  module Aspera
12
12
  module Preview
13
13
  module Backend
14
- # provides image pixels scaled to terminal
14
+ # Base decoder that rescales image data to the current terminal geometry.
15
15
  class Base
16
+ # @param reserve [Integer] number of terminal rows reserved for non-image output
17
+ # @param double [Boolean] when `true`, render two image rows in one terminal row
18
+ # @param font_ratio [Float] terminal font aspect ratio: height divided by width
16
19
  def initialize(reserve:, double:, font_ratio:)
17
20
  @reserve = reserve
18
21
  @height_ratio = double ? 2.0 : 1.0
19
22
  @font_ratio = font_ratio
20
23
  end
21
24
  Aspera.require_method!(:terminal_pixels)
22
- # compute scaling to fit terminal
25
+ # Compute output dimensions that fit inside the terminal while preserving aspect ratio.
26
+ #
27
+ # @param rows [Integer] source image height in pixels
28
+ # @param columns [Integer] source image width in pixels
29
+ # @return [Array<Integer>] scaled width and height for terminal rendering
23
30
  def terminal_scaling(rows, columns)
24
31
  (term_rows, term_columns) = IO.console.winsize || [24, 80]
25
32
  term_rows = [term_rows - @reserve, 2].max
@@ -29,22 +36,30 @@ module Aspera
29
36
  end
30
37
 
31
38
  class RMagick < Base
39
+ # Initialize the RMagick-backed decoder for a binary image payload.
40
+ #
41
+ # @param blob [String] encoded image binary content
42
+ # @param kwargs [Hash] forwarding options accepted by [`initialize`](lib/aspera/preview/terminal.rb:16)
32
43
  def initialize(blob, **kwargs)
33
44
  super(**kwargs)
34
- # do not require statically, as the package is optional
45
+ # Load lazily because this dependency is optional.
35
46
  require 'rmagick' # https://rmagick.github.io/index.html
36
47
  @image = Magick::ImageList.new.from_blob(blob)
37
48
  end
38
49
 
50
+ # Decode the image and return RGB pixels scaled for terminal rendering.
51
+ #
52
+ # @return [Array<Array<Array<Integer>>>] rows of `[red, green, blue]` pixel triplets
39
53
  def terminal_pixels
40
- # quantum depth is 8 or 16, see: `magick xc: -format "%q" info:`
54
+ # ImageMagick channel depth is typically 8 or 16 bits.
55
+ # See: `magick xc: -format "%q" info:`
41
56
  shift_for_8_bit = Magick::MAGICKCORE_QUANTUM_DEPTH - 8
42
- # get all pixel colors, adjusted for Rainbow
57
+ # Extract RGB values and normalize them to 8-bit channels for Rainbow.
43
58
  pixel_colors = []
44
59
  @image.scale(*terminal_scaling(@image.rows, @image.columns)).each_pixel do |pixel, col, row|
45
60
  pixel_rgb = [pixel.red, pixel.green, pixel.blue]
46
61
  pixel_rgb = pixel_rgb.map{ |color| color >> shift_for_8_bit} unless shift_for_8_bit.eql?(0)
47
- # init 2-dim array
62
+ # Initialize the destination 2D pixel matrix row by row.
48
63
  pixel_colors[row] ||= []
49
64
  pixel_colors[row][col] = pixel_rgb
50
65
  end
@@ -53,12 +68,19 @@ module Aspera
53
68
  end
54
69
 
55
70
  class ChunkyPNG < Base
71
+ # Initialize the ChunkyPNG-backed decoder for a PNG payload.
72
+ #
73
+ # @param blob [String] PNG binary content
74
+ # @param kwargs [Hash] forwarding options accepted by [`initialize`](lib/aspera/preview/terminal.rb:16)
56
75
  def initialize(blob, **kwargs)
57
76
  super(**kwargs)
58
77
  require 'chunky_png'
59
78
  @png = ::ChunkyPNG::Image.from_blob(blob)
60
79
  end
61
80
 
81
+ # Resize the PNG using nearest-neighbor sampling and return RGB pixel rows.
82
+ #
83
+ # @return [Array<Array<Array<Integer>>>] rows of `[red, green, blue]` pixel triplets
62
84
  def terminal_pixels
63
85
  src_w = @png.width
64
86
  src_h = @png.height
@@ -75,7 +97,7 @@ module Aspera
75
97
  sx = (dx * x_ratio).floor
76
98
  sx = src_w - 1 if sx >= src_w
77
99
  rgba = @png.get_pixel(sx, sy)
78
- # ChunkyPNG stores as 0xRRGGBBAA; extract 8-bit channels
100
+ # ChunkyPNG stores pixels as 0xRRGGBBAA; extract 8-bit RGB channels.
79
101
  pixel_colors[dy][dx] = %i[r g b].map{ |i| ::ChunkyPNG::Color.send(i, rgba)}
80
102
  end
81
103
  end
@@ -84,19 +106,21 @@ module Aspera
84
106
  end
85
107
  end
86
108
 
87
- # Display a picture in the terminal.
88
- # Either use coloured characters or iTerm2 protocol.
109
+ # Render an image for terminal output.
110
+ # Uses either colored text blocks or the iTerm2 inline-image protocol when available.
89
111
  class Terminal
90
- # Rainbow only supports 8-bit colors
91
- # env vars to detect terminal type
112
+ # Rainbow only supports 8-bit color values.
113
+ # Environment variables inspected to detect compatible terminal implementations.
92
114
  TERM_ENV_VARS = %w[TERM_PROGRAM LC_TERMINAL].freeze
93
- # terminal names that support iTerm2 image display
115
+ # Terminal identifiers known to support the iTerm2 inline-image protocol.
94
116
  ITERM_NAMES = %w[iTerm WezTerm mintty].freeze
95
- # TODO: retrieve terminal font ratio using some termcap ?
96
- # ratio = font height / font width
117
+ # Fallback font aspect ratio used to estimate how many image pixels fit in a character cell.
118
+ # Ratio = font height / font width.
97
119
  DEFAULT_FONT_RATIO = 32.0 / 14.0
98
120
  private_constant :TERM_ENV_VARS, :ITERM_NAMES, :DEFAULT_FONT_RATIO
99
121
  class << self
122
+ # Render an image blob for display in the current terminal.
123
+ #
100
124
  # @param blob [String] The image as a binary string
101
125
  # @param text [Boolean] `true` to display the image as text, `false` to use iTerm2 if supported
102
126
  # @param reserve [Integer] Number of lines to reserve for other text than the image
@@ -124,7 +148,7 @@ module Aspera
124
148
  return iterm_display_image(blob) if iterm_supported?
125
149
  raise 'Cannot decode picture.'
126
150
  end
127
- # now generate text
151
+ # Convert decoded pixels into terminal glyphs.
128
152
  text_pixels = []
129
153
  pixel_colors.each_with_index do |row_data, row|
130
154
  next if double && (row.odd? || row.eql?(pixel_colors.length - 1))
@@ -140,11 +164,14 @@ module Aspera
140
164
  return text_pixels.join
141
165
  end
142
166
 
143
- # display image in iTerm2
167
+ # Build the iTerm2 inline-image escape sequence.
144
168
  # https://iterm2.com/documentation-images.html
169
+ #
170
+ # @param blob [String] image binary content
171
+ # @return [String] escape sequence that displays the image inline
145
172
  def iterm_display_image(blob)
146
173
  # image = Magick::ImageList.new.from_blob(blob)
147
- # parameters for iTerm2 image display
174
+ # Parameters accepted by the iTerm2 inline-image protocol.
148
175
  arguments = {
149
176
  inline: 1,
150
177
  preserveAspectRatio: 1,
@@ -152,12 +179,15 @@ module Aspera
152
179
  # width: image.columns,
153
180
  # height: image.rows
154
181
  }.map{ |k, v| "#{k}=#{v}"}.join(';')
155
- # \a is BEL, \e is ESC : https://github.com/ruby/ruby/blob/master/doc/syntax/literals.rdoc#label-Strings
156
- # escape sequence for iTerm2 image display
182
+ # `\a` is BEL and `\e` is ESC.
183
+ # See: https://github.com/ruby/ruby/blob/master/doc/syntax/literals.rdoc#label-Strings
184
+ # Return the full escape sequence expected by iTerm2-compatible terminals.
157
185
  return "\e]1337;File=#{arguments}:#{Base64.strict_encode64(blob)}\a"
158
186
  end
159
187
 
160
- # @return [Boolean] true if the terminal supports iTerm2 image display
188
+ # Detect whether the current terminal supports iTerm2 inline images.
189
+ #
190
+ # @return [Boolean] `true` when the current terminal advertises iTerm2 image support
161
191
  def iterm_supported?
162
192
  TERM_ENV_VARS.each do |env_var|
163
193
  return true if ITERM_NAMES.any?{ |term| ENV[env_var]&.include?(term)}