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.
- checksums.yaml +4 -4
- checksums.yaml.gz.sig +0 -0
- data/CHANGELOG.md +74 -47
- data/CONTRIBUTING.md +1 -1
- data/lib/aspera/api/aoc.rb +118 -78
- data/lib/aspera/api/node.rb +101 -49
- data/lib/aspera/ascp/installation.rb +94 -30
- data/lib/aspera/cli/extended_value.rb +1 -0
- data/lib/aspera/cli/formatter.rb +47 -40
- data/lib/aspera/cli/manager.rb +30 -4
- data/lib/aspera/cli/plugins/aoc.rb +214 -136
- data/lib/aspera/cli/plugins/ats.rb +3 -3
- data/lib/aspera/cli/plugins/base.rb +17 -42
- data/lib/aspera/cli/plugins/config.rb +5 -3
- data/lib/aspera/cli/plugins/console.rb +3 -3
- data/lib/aspera/cli/plugins/faspex.rb +5 -5
- data/lib/aspera/cli/plugins/faspex5.rb +20 -18
- data/lib/aspera/cli/plugins/node.rb +66 -70
- data/lib/aspera/cli/plugins/oauth.rb +5 -12
- data/lib/aspera/cli/plugins/orchestrator.rb +13 -13
- data/lib/aspera/cli/plugins/preview.rb +116 -80
- data/lib/aspera/cli/plugins/server.rb +2 -10
- data/lib/aspera/cli/plugins/shares.rb +7 -7
- data/lib/aspera/cli/version.rb +1 -1
- data/lib/aspera/dot_container.rb +7 -3
- data/lib/aspera/environment.rb +3 -2
- data/lib/aspera/log.rb +1 -1
- data/lib/aspera/preview/file_types.rb +1 -1
- data/lib/aspera/preview/generator.rb +146 -91
- data/lib/aspera/preview/options.rb +4 -1
- data/lib/aspera/preview/terminal.rb +50 -20
- data/lib/aspera/preview/utils.rb +76 -34
- data/lib/aspera/products/transferd.rb +1 -1
- data/lib/aspera/rest.rb +1 -0
- data/lib/aspera/rest_list.rb +23 -16
- data/lib/aspera/secret_hider.rb +3 -1
- data/lib/aspera/uri_reader.rb +17 -2
- data.tar.gz.sig +0 -0
- metadata +5 -5
- 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
|
-
#
|
|
15
|
+
# Generates one preview file for one format for one file at a time.
|
|
16
16
|
class Generator
|
|
17
|
-
#
|
|
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
|
-
#
|
|
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
|
|
34
|
-
# @param dst
|
|
35
|
-
# @param options
|
|
36
|
-
# @param main_temp_dir [String]
|
|
37
|
-
# @param
|
|
38
|
-
def initialize(src, dst, options, main_temp_dir,
|
|
39
|
-
|
|
40
|
-
@
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
#
|
|
67
|
+
# Creates preview as specified in constructor.
|
|
61
68
|
def generate
|
|
62
|
-
Log.log.debug{"#{@
|
|
69
|
+
Log.log.debug{"#{@source}->#{@destination} (#{@processing_method})"}
|
|
63
70
|
begin
|
|
64
71
|
send(@processing_method)
|
|
65
|
-
#
|
|
66
|
-
result_size = File.size(@
|
|
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
|
-
#
|
|
86
|
-
# @param
|
|
87
|
-
# @param
|
|
88
|
-
# @param
|
|
89
|
-
# @
|
|
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(@
|
|
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
|
-
|
|
103
|
-
|
|
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
|
-
#
|
|
124
|
+
# Go to last dupe frame.
|
|
107
125
|
last_keyframe = current_index + @options.blend_pauseframes
|
|
108
|
-
#
|
|
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: @
|
|
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',
|
|
136
|
+
'-r', frame_rate_hz,
|
|
119
137
|
'-pix_fmt', 'yuv420p'
|
|
120
138
|
]
|
|
121
139
|
)
|
|
122
140
|
end
|
|
123
141
|
|
|
124
|
-
#
|
|
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(@
|
|
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: @
|
|
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
|
-
#
|
|
165
|
+
# Concat clips.
|
|
147
166
|
Utils.ffmpeg(
|
|
148
167
|
in_f: file_list_file,
|
|
149
168
|
in_p: ['-f', 'concat'],
|
|
150
|
-
out_f: @
|
|
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
|
-
#
|
|
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: @
|
|
185
|
+
in_f: @source,
|
|
166
186
|
in_p: options['in'] || ['-ss', @options.video_start_sec.to_i * 0.9],
|
|
167
|
-
out_f: @
|
|
187
|
+
out_f: @destination,
|
|
168
188
|
out_p: options['out'] || [
|
|
169
|
-
'-t',
|
|
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
|
-
@
|
|
190
|
-
Utils.video_get_duration(@
|
|
211
|
+
@source,
|
|
212
|
+
Utils.video_get_duration(@source) * @options.thumb_vid_fraction,
|
|
191
213
|
@options.thumb_vid_scale,
|
|
192
|
-
@
|
|
214
|
+
@destination
|
|
193
215
|
)
|
|
194
216
|
end
|
|
195
217
|
|
|
196
|
-
#
|
|
197
|
-
#
|
|
198
|
-
#
|
|
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: @
|
|
231
|
+
in_f: @source,
|
|
203
232
|
in_p: [
|
|
204
|
-
'-ss',
|
|
205
|
-
'-t',
|
|
233
|
+
'-ss', p_start_offset,
|
|
234
|
+
'-t', p_max_duration
|
|
206
235
|
],
|
|
207
|
-
out_f: @
|
|
236
|
+
out_f: @destination,
|
|
208
237
|
out_p: [
|
|
209
|
-
'-vf', 'fps=5,scale=120:-1:flags=lanczos
|
|
210
|
-
'-
|
|
211
|
-
'-f', '
|
|
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(@
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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 ||= @
|
|
228
|
-
Utils.
|
|
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
|
-
@
|
|
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.
|
|
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
|
-
"#{@
|
|
247
|
-
@
|
|
248
|
-
|
|
249
|
-
Utils.
|
|
299
|
+
"#{@source}[0]",
|
|
300
|
+
@destination
|
|
301
|
+
)
|
|
302
|
+
Utils.silent_execute(:optipng, @destination)
|
|
250
303
|
end
|
|
251
304
|
|
|
252
|
-
# text to
|
|
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
|
-
#
|
|
255
|
-
first_lines = File.foreach(@
|
|
256
|
-
Utils.
|
|
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', #
|
|
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', #
|
|
317
|
+
'-fill', 'black', # Font color.
|
|
263
318
|
'-annotate', '+0+0', first_lines,
|
|
264
|
-
'-trim', #
|
|
319
|
+
'-trim', # Avoid large blank regions.
|
|
265
320
|
'-bordercolor', 'white',
|
|
266
321
|
'-border', 8,
|
|
267
322
|
'+repage',
|
|
268
|
-
@
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
88
|
-
#
|
|
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
|
|
91
|
-
#
|
|
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
|
-
#
|
|
115
|
+
# Terminal identifiers known to support the iTerm2 inline-image protocol.
|
|
94
116
|
ITERM_NAMES = %w[iTerm WezTerm mintty].freeze
|
|
95
|
-
#
|
|
96
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
156
|
-
#
|
|
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
|
-
#
|
|
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)}
|