aspera-cli 4.25.6 → 4.26.1

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 (64) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +89 -48
  4. data/CONTRIBUTING.md +1 -1
  5. data/lib/aspera/api/aoc.rb +120 -79
  6. data/lib/aspera/api/node.rb +103 -51
  7. data/lib/aspera/ascp/installation.rb +99 -32
  8. data/lib/aspera/assert.rb +17 -13
  9. data/lib/aspera/cli/extended_value.rb +7 -2
  10. data/lib/aspera/cli/formatter.rb +107 -95
  11. data/lib/aspera/cli/main.rb +69 -10
  12. data/lib/aspera/cli/manager.rb +158 -78
  13. data/lib/aspera/cli/options.schema.yaml +82 -0
  14. data/lib/aspera/cli/plugins/aoc.rb +247 -144
  15. data/lib/aspera/cli/plugins/ats.rb +3 -3
  16. data/lib/aspera/cli/plugins/base.rb +60 -76
  17. data/lib/aspera/cli/plugins/config.rb +14 -12
  18. data/lib/aspera/cli/plugins/console.rb +3 -3
  19. data/lib/aspera/cli/plugins/faspex.rb +6 -6
  20. data/lib/aspera/cli/plugins/faspex5.rb +24 -23
  21. data/lib/aspera/cli/plugins/node.rb +67 -71
  22. data/lib/aspera/cli/plugins/oauth.rb +5 -12
  23. data/lib/aspera/cli/plugins/orchestrator.rb +13 -13
  24. data/lib/aspera/cli/plugins/preview.rb +116 -80
  25. data/lib/aspera/cli/plugins/server.rb +2 -10
  26. data/lib/aspera/cli/plugins/shares.rb +7 -7
  27. data/lib/aspera/cli/sync_actions.rb +1 -1
  28. data/lib/aspera/cli/transfer_agent.rb +17 -15
  29. data/lib/aspera/cli/version.rb +1 -1
  30. data/lib/aspera/command_line_builder.rb +22 -18
  31. data/lib/aspera/dot_container.rb +7 -3
  32. data/lib/aspera/environment.rb +6 -5
  33. data/lib/aspera/formatter_interface.rb +14 -0
  34. data/lib/aspera/hash_ext.rb +6 -0
  35. data/lib/aspera/log.rb +5 -4
  36. data/lib/aspera/markdown.rb +4 -1
  37. data/lib/aspera/oauth/factory.rb +1 -1
  38. data/lib/aspera/preview/file_types.rb +1 -1
  39. data/lib/aspera/preview/generator.rb +146 -91
  40. data/lib/aspera/preview/options.rb +4 -1
  41. data/lib/aspera/preview/terminal.rb +50 -20
  42. data/lib/aspera/preview/utils.rb +76 -34
  43. data/lib/aspera/products/transferd.rb +1 -1
  44. data/lib/aspera/proxy_auto_config.rb +3 -0
  45. data/lib/aspera/rest.rb +2 -1
  46. data/lib/aspera/rest_list.rb +23 -16
  47. data/lib/aspera/schema/IBM Aspera Faspex API-5.0-enhanced.yaml +62801 -0
  48. data/lib/aspera/schema/IBM Aspera on Cloud API-0.2.6-enhanced.yaml +8898 -0
  49. data/lib/aspera/schema/documentation.rb +107 -0
  50. data/lib/aspera/schema/reader.rb +75 -0
  51. data/lib/aspera/schema/registry.rb +63 -0
  52. data/lib/aspera/secret_hider.rb +3 -1
  53. data/lib/aspera/sync/conf.schema.yaml +0 -26
  54. data/lib/aspera/sync/operations.rb +9 -5
  55. data/lib/aspera/transfer/faux_file.rb +1 -1
  56. data/lib/aspera/transfer/resumer.rb +1 -1
  57. data/lib/aspera/transfer/spec.rb +3 -3
  58. data/lib/aspera/transfer/spec.schema.yaml +1 -1
  59. data/lib/aspera/uri_reader.rb +17 -2
  60. data/lib/aspera/yaml.rb +4 -2
  61. data.tar.gz.sig +0 -0
  62. metadata +13 -7
  63. metadata.gz.sig +0 -0
  64. data/lib/aspera/transfer/spec_doc.rb +0 -76
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'aspera/log'
4
4
  require 'aspera/assert'
5
+ require 'aspera/schema/registry'
5
6
  require 'yaml'
6
7
  module Aspera
7
8
  # Helper class to build command line from a parameter list (key-value hash)
@@ -35,9 +36,9 @@ module Aspera
35
36
 
36
37
  class << self
37
38
  # Called by provider of definition before constructor of this class so that schema has all mandatory fields
38
- def read_schema(folder, name, ascp: false)
39
- schema = YAML.load_file(File.join(folder, "#{name}.schema.yaml"))
40
- validate_schema(schema, ascp: ascp)
39
+ # @return [Aspera::Schema::Reader]
40
+ def read_schema(name_sym, ascp: false)
41
+ validate_schema(Schema::Registry.instance.reader(name_sym), ascp: ascp)
41
42
  end
42
43
 
43
44
  # @param agent [Symbol] Transfer agent name
@@ -49,29 +50,32 @@ module Aspera
49
50
 
50
51
  private
51
52
 
53
+ DIRECT_PROPERTIES = %w[x-cli-option x-cli-envvar x-cli-special].freeze
54
+
52
55
  # Fill default values for some fields in the schema
53
- # @param schema [Hash] The JSON schema
54
- # @param ascp [Boolean] `true` if ascp
56
+ # @param schema [Schema::Reader] The JSON schema
57
+ # @param ascp [Boolean] `true` if `ascp`
58
+ # @return [Schema::Reader] The JSON schema
55
59
  def validate_schema(schema, ascp: false)
56
- direct_props = %w[x-cli-option x-cli-envvar x-cli-special].freeze
57
- schema['properties'].each do |name, info|
58
- Aspera.assert_type(info, Hash){"#{info.class} for #{name}"}
59
- unsupported_keys = info.keys - PROPERTY_KEYS
60
+ Aspera.assert_type(schema, Schema::Reader){'schema'}
61
+ Aspera.assert(schema.current.key?('properties')){"Schema must have 'properties': #{schema}"}
62
+ schema.each_property do |property_schema, name, _full_name|
63
+ node = property_schema.current
64
+ unsupported_keys = node.keys - PROPERTY_KEYS
60
65
  Aspera.assert(unsupported_keys.empty?){"Unsupported definition keys: #{unsupported_keys}"}
61
- Aspera.assert(info.key?('type') || info.key?('enum')){"Missing type for #{name} in #{schema['description']}"}
62
- Aspera.assert(info['type'].eql?('boolean')){"switch must be bool: #{name}"} if info['x-cli-switch'] && !info['x-cli-special']
63
- info['x-cli-option'] = "--#{name.to_s.tr('_', '-')}" if info['x-cli-option'].eql?(true) || (info['x-cli-switch'].eql?(true) && !info.key?('x-cli-option'))
64
- Aspera.assert(direct_props.any?{ |i| info.key?(i)}, type: :warn){name} if ascp && supported_by_agent(:direct, info)
65
- info.freeze
66
- validate_schema(info, ascp: ascp) if info['type'].eql?('object') && info['properties']
67
- validate_schema(info['items'], ascp: ascp) if info['type'].eql?('array') && info['items'] && info['items']['properties']
66
+ Aspera.assert(node.key?('type') || node.key?('enum')){"Missing type for #{name} in #{schema.current.dig('description').current}"}
67
+ Aspera.assert(node['type'].eql?('boolean')){"switch must be bool: #{name}"} if node['x-cli-switch'] && !node['x-cli-special']
68
+ node['x-cli-option'] = "--#{name.to_s.tr('_', '-')}" if node['x-cli-option'].eql?(true) || (node['x-cli-switch'].eql?(true) && !node.key?('x-cli-option'))
69
+ Aspera.assert(DIRECT_PROPERTIES.any?{ |i| node.key?(i)}, type: :warn){name} if ascp && supported_by_agent(:direct, node)
70
+ node.freeze
68
71
  end
69
72
  schema
70
73
  end
71
74
  end
72
75
 
73
- # @param [Hash] object with parameters
74
- # @param [Hash] schema JSON schema
76
+ # @param object [Hash] object with parameters
77
+ # @param schema [Schema::Reader] JSON schema
78
+ # @param convert [Object] function to convert value
75
79
  def initialize(object, schema, convert)
76
80
  @object = object # keep reference so that it can be modified by caller before calling `process_params`
77
81
  @schema = schema
@@ -61,6 +61,7 @@ module Aspera
61
61
  def to_dotted
62
62
  result = {}
63
63
  until @stack.empty?
64
+ # path: Array, current: Array or Hash or other
64
65
  path, current = @stack.pop
65
66
  to_insert = nil
66
67
  # empty things are left intact
@@ -78,8 +79,11 @@ module Aspera
78
79
  elsif current.all?{ |i| i.is_a?(Hash) && i.keys == ['name']}
79
80
  to_insert = current.map{ |i| i['name']}
80
81
  # Array of Hashes with only 'name' and 'value' keys -> Hash of key/values
81
- elsif current.all?{ |i| i.is_a?(Hash) && i.keys.sort == %w[name value]}
82
- add_elements(path, current.to_h{ |i| [i['name'], i['value']]})
82
+ elsif current.all?{ |i| i.is_a?(Hash) && i.key?('name') && i.key?('value') && i.length <= 3}
83
+ # if there is an extra key, other than 'name' and 'value', insert that key as is
84
+ add_elements(path, current.flat_map{ |h| h.except('name', 'value').to_a})
85
+ # Insert name/value pairs as Hash
86
+ add_elements(path, current.to_h{ |h| h.values_at('name', 'value')})
83
87
  else
84
88
  add_elements(path, current.each_with_index.map{ |v, i| [i, v]})
85
89
  end
@@ -97,7 +101,7 @@ module Aspera
97
101
  # Add elements of enumerator to the @stack, in reverse order
98
102
  def add_elements(path, enum)
99
103
  enum.reverse_each do |key, value|
100
- @stack.push([path + [key], value])
104
+ @stack.push([path + [key.to_s], value])
101
105
  end
102
106
  nil
103
107
  end
@@ -91,9 +91,9 @@ module Aspera
91
91
  #
92
92
  # @param cmd [Array<#to_s>] The executable and its arguments.
93
93
  # @param mode [:execute, :background, :capture] The execution strategy:
94
- # - `:execute` Uses {Kernel.system}. Returns `true`, `false`, or `nil`.
94
+ # - `:execute` Uses {Kernel.system}. Returns `true`, `false`, or `nil`. (Default)
95
95
  # - `:background` Uses {Process.spawn}. Returns the spawned process PID.
96
- # - `:capture` Uses {Open3.capture3}. Returns captured output and status.
96
+ # - `:capture` Uses {Open3.capture3}. Returns captured out, err, and status.
97
97
  #
98
98
  # @param kwargs [Hash] Additional options forwarded to the underlying call.
99
99
  #
@@ -151,7 +151,7 @@ module Aspera
151
151
  # @param path [String] the file path
152
152
  # @param force [Boolean] if true, overwrite the file
153
153
  # @param mode [Integer] the file mode (permissions)
154
- # @block [Proc] return the content to write to the file
154
+ # @yieldreturn [String] The content to write to the file
155
155
  def write_file_restricted(path, force: false, mode: nil)
156
156
  Aspera.assert(block_given?, type: Aspera::InternalError)
157
157
  if force || !File.exist?(path)
@@ -280,7 +280,7 @@ module Aspera
280
280
  when Environment::OS_MACOS then return self.class.secure_execute('open', uri.to_s)
281
281
  when Environment::OS_WINDOWS then return self.class.secure_execute('start', 'explorer', %Q{"#{uri}"})
282
282
  when Environment::OS_LINUX then return self.class.secure_execute('xdg-open', uri.to_s)
283
- else Assert.error_unexpected_value(os){'no graphical open method'}
283
+ else Aspera.error_unexpected_value(os){'no graphical open method'}
284
284
  end
285
285
  end
286
286
 
@@ -316,6 +316,7 @@ module Aspera
316
316
 
317
317
  # Replacement character for illegal filename characters
318
318
  # Can also be used as safe "join" character
319
+ # @return [String] One character
319
320
  def safe_filename_character
320
321
  return REPLACE_CHARACTER if @file_illegal_characters.nil? || @file_illegal_characters.empty?
321
322
  @file_illegal_characters[0]
@@ -333,7 +334,7 @@ module Aspera
333
334
  filename = filename.chop while filename.end_with?(' ', '.')
334
335
  if @file_illegal_characters&.size.to_i >= 2
335
336
  # replace all illegal characters with safe_char
336
- filename = filename.tr(@file_illegal_characters[1..-1], safe_char)
337
+ filename = filename.tr(@file_illegal_characters[1..], safe_char)
337
338
  end
338
339
  # ensure only one safe_char is used at a time
339
340
  return filename.gsub(/#{Regexp.escape(safe_char)}+/, safe_char).chomp(safe_char)
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'aspera/assert'
4
+
5
+ module Aspera
6
+ # Common interface for documentation formatters
7
+ # Used by Schema::Documentation to format tables
8
+ module FormatterInterface
9
+ Aspera.require_method!(:tick)
10
+ Aspera.require_method!(:special_format)
11
+ Aspera.require_method!(:check_row)
12
+ Aspera.require_method!(:markdown_text)
13
+ end
14
+ end
@@ -9,6 +9,12 @@ class ::Hash
9
9
  merge!(second){ |_key, v1, v2| v1.is_a?(Hash) && v2.is_a?(Hash) ? v1.deep_merge!(v2) : v2}
10
10
  end
11
11
 
12
+ # Recursively iterate through hash and execute block on leaf values
13
+ # @param memory [Object, nil] Optional memory object passed to block
14
+ # @yieldparam hash [Hash] The current hash
15
+ # @yieldparam key [Object] The current key
16
+ # @yieldparam value [Object] The current value (non-Hash)
17
+ # @yieldparam memory [Object, nil] The memory object
12
18
  def deep_do(memory = nil, &block)
13
19
  each do |key, value|
14
20
  if value.is_a?(Hash)
data/lib/aspera/log.rb CHANGED
@@ -77,7 +77,7 @@ module Aspera
77
77
  # @param name [String, Symbol] Name of object dumped
78
78
  # @param object [Hash, nil] Data to dump
79
79
  # @param level [Symbol] Debug level
80
- # @param block [Proc, nil] Give computed object
80
+ # @yieldreturn [Object] Computed object to dump (alternative to object parameter)
81
81
  def dump(name, object = nil, level: :debug, &block)
82
82
  return unless instance.logger.send(:"#{level}?")
83
83
  Aspera.assert(object.nil? || block.nil?){'Use either object, or block, not both'}
@@ -93,12 +93,13 @@ module Aspera
93
93
  JSON.pretty_generate(object) rescue PP.pp(object, +'')
94
94
  when :ruby
95
95
  PP.pp(object, +'')
96
- else error_unexpected_value(instance.dump_format){'dump format'}
96
+ else Aspera.error_unexpected_value(instance.dump_format){'dump format'}
97
97
  end
98
- "#{name.to_s.green} (#{instance.dump_format})=\n#{dump_text}"
98
+ "#{name.to_s.green}(#{instance.dump_format})#{object.class}=\n#{dump_text}"
99
99
  end
100
100
 
101
101
  # Capture the output of $stderr and log it at debug level
102
+ # @yieldreturn [void] Code block whose stderr output will be captured
102
103
  def capture_stderr
103
104
  real_stderr = $stderr
104
105
  $stderr = StringIO.new
@@ -192,7 +193,7 @@ module Aspera
192
193
  end
193
194
  # Use `local2` facility, like other Aspera components
194
195
  @logger = Syslog::Logger.new(@program_name, Syslog::LOG_LOCAL2)
195
- else error_unexpected_value(new_log_type){"log type (#{LOG_TYPES.join(', ')})"}
196
+ else Aspera.error_unexpected_value(new_log_type){"log type (#{LOG_TYPES.join(', ')})"}
196
197
  end
197
198
  @logger.level = current_severity_integer
198
199
  @logger_type = new_log_type
@@ -8,11 +8,14 @@ module Aspera
8
8
  HTML_BREAK = '<br/>'
9
9
 
10
10
  class << self
11
+ COL_WIDTH = 80
11
12
  # Generate markdown from the provided 2D table
13
+ # @param table [Array<Array<String>>] 2D array of strings
14
+ # @return [String] markdown table
12
15
  def table(table)
13
16
  # get max width of each columns
14
17
  col_widths = table.transpose.map do |col|
15
- [col.flat_map{ |c| c.to_s.delete('`').split(HTML_BREAK).map(&:size)}.max, 80].min
18
+ [col.flat_map{ |c| c.to_s.delete('`').split(HTML_BREAK).map(&:size)}.max, COL_WIDTH].min
16
19
  end
17
20
  headings = table.shift
18
21
  table.unshift(col_widths.map{ |col_width| '-' * col_width})
@@ -33,7 +33,7 @@ module Aspera
33
33
  # Extract only token from Authorization (remove scheme)
34
34
  def bearer_token(authorization)
35
35
  Aspera.assert(bearer_auth?(authorization)){'not a bearer token, wrong prefix scheme'}
36
- return authorization[SPACE_BEARER_AUTH_SCHEME.length..-1]
36
+ return authorization.delete_prefix(SPACE_BEARER_AUTH_SCHEME)
37
37
  end
38
38
 
39
39
  # Generate a unique cache id for a token creator
@@ -14,7 +14,7 @@ module Aspera
14
14
  # values for conversion_type : input format
15
15
  CONVERSION_TYPES = %i[image office pdf plaintext video].freeze
16
16
 
17
- # special cases for mime types
17
+ # Special cases for MIME types
18
18
  # spellchecker:disable
19
19
  SUPPORTED_MIME_TYPES = {
20
20
  'application/json' => :plaintext,
@@ -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