active_encode 0.4.1 → 0.8.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 (93) hide show
  1. checksums.yaml +5 -5
  2. data/.circleci/config.yml +80 -0
  3. data/.rubocop.yml +9 -70
  4. data/.rubocop_todo.yml +68 -0
  5. data/CODE_OF_CONDUCT.md +36 -0
  6. data/CONTRIBUTING.md +23 -21
  7. data/Gemfile +5 -4
  8. data/LICENSE +11 -199
  9. data/README.md +135 -24
  10. data/SUPPORT.md +5 -0
  11. data/active_encode.gemspec +13 -3
  12. data/app/controllers/active_encode/encode_record_controller.rb +13 -0
  13. data/app/jobs/active_encode/polling_job.rb +1 -1
  14. data/app/models/active_encode/encode_record.rb +1 -0
  15. data/config/routes.rb +4 -0
  16. data/db/migrate/20180822021048_create_active_encode_encode_records.rb +1 -0
  17. data/db/migrate/20190702153755_add_create_options_to_active_encode_encode_records.rb +6 -0
  18. data/db/migrate/20190712174821_add_progress_to_active_encode_encode_records.rb +6 -0
  19. data/lib/active_encode.rb +1 -0
  20. data/lib/active_encode/base.rb +2 -2
  21. data/lib/active_encode/callbacks.rb +1 -0
  22. data/lib/active_encode/core.rb +4 -3
  23. data/lib/active_encode/engine.rb +1 -0
  24. data/lib/active_encode/engine_adapter.rb +1 -0
  25. data/lib/active_encode/engine_adapters.rb +4 -1
  26. data/lib/active_encode/engine_adapters/elastic_transcoder_adapter.rb +116 -38
  27. data/lib/active_encode/engine_adapters/ffmpeg_adapter.rb +141 -87
  28. data/lib/active_encode/engine_adapters/matterhorn_adapter.rb +5 -4
  29. data/lib/active_encode/engine_adapters/media_convert_adapter.rb +372 -0
  30. data/lib/active_encode/engine_adapters/media_convert_output.rb +104 -0
  31. data/lib/active_encode/engine_adapters/pass_through_adapter.rb +239 -0
  32. data/lib/active_encode/engine_adapters/test_adapter.rb +5 -4
  33. data/lib/active_encode/engine_adapters/zencoder_adapter.rb +3 -2
  34. data/lib/active_encode/errors.rb +6 -0
  35. data/lib/active_encode/global_id.rb +2 -1
  36. data/lib/active_encode/input.rb +3 -2
  37. data/lib/active_encode/output.rb +3 -2
  38. data/lib/active_encode/persistence.rb +11 -5
  39. data/lib/active_encode/polling.rb +3 -2
  40. data/lib/active_encode/spec/shared_specs.rb +2 -0
  41. data/{spec/shared_specs/engine_adapter_specs.rb → lib/active_encode/spec/shared_specs/engine_adapter.rb} +37 -38
  42. data/lib/active_encode/status.rb +1 -0
  43. data/lib/active_encode/technical_metadata.rb +3 -2
  44. data/lib/active_encode/version.rb +2 -1
  45. data/lib/file_locator.rb +93 -0
  46. data/spec/controllers/encode_record_controller_spec.rb +53 -0
  47. data/spec/fixtures/ffmpeg/cancelled-id/cancelled +0 -0
  48. data/spec/fixtures/file with space.low.mp4 +0 -0
  49. data/spec/fixtures/file with space.mp4 +0 -0
  50. data/spec/fixtures/fireworks.low.mp4 +0 -0
  51. data/spec/fixtures/media_convert/endpoints.json +1 -0
  52. data/spec/fixtures/media_convert/job_canceled.json +412 -0
  53. data/spec/fixtures/media_convert/job_canceling.json +1 -0
  54. data/spec/fixtures/media_convert/job_completed.json +359 -0
  55. data/spec/fixtures/media_convert/job_completed_detail.json +1 -0
  56. data/spec/fixtures/media_convert/job_completed_detail_query.json +1 -0
  57. data/spec/fixtures/media_convert/job_created.json +408 -0
  58. data/spec/fixtures/media_convert/job_failed.json +406 -0
  59. data/spec/fixtures/media_convert/job_progressing.json +414 -0
  60. data/spec/fixtures/pass_through/cancelled-id/cancelled +0 -0
  61. data/spec/fixtures/pass_through/cancelled-id/input_metadata +90 -0
  62. data/spec/fixtures/pass_through/completed-id/completed +0 -0
  63. data/spec/fixtures/pass_through/completed-id/input_metadata +102 -0
  64. data/spec/fixtures/pass_through/completed-id/output_metadata-high +90 -0
  65. data/spec/fixtures/pass_through/completed-id/output_metadata-low +90 -0
  66. data/spec/fixtures/pass_through/completed-id/video-high.mp4 +0 -0
  67. data/spec/fixtures/pass_through/completed-id/video-low.mp4 +0 -0
  68. data/spec/fixtures/pass_through/failed-id/error.log +1 -0
  69. data/spec/fixtures/pass_through/failed-id/input_metadata +90 -0
  70. data/spec/fixtures/pass_through/running-id/input_metadata +90 -0
  71. data/spec/integration/elastic_transcoder_adapter_spec.rb +63 -29
  72. data/spec/integration/ffmpeg_adapter_spec.rb +96 -24
  73. data/spec/integration/matterhorn_adapter_spec.rb +45 -44
  74. data/spec/integration/media_convert_adapter_spec.rb +126 -0
  75. data/spec/integration/pass_through_adapter_spec.rb +151 -0
  76. data/spec/integration/zencoder_adapter_spec.rb +210 -209
  77. data/spec/rails_helper.rb +1 -0
  78. data/spec/routing/encode_record_controller_routing_spec.rb +10 -0
  79. data/spec/spec_helper.rb +2 -2
  80. data/spec/test_app_templates/lib/generators/test_app_generator.rb +13 -12
  81. data/spec/units/callbacks_spec.rb +3 -2
  82. data/spec/units/core_spec.rb +26 -25
  83. data/spec/units/engine_adapter_spec.rb +1 -0
  84. data/spec/units/file_locator_spec.rb +129 -0
  85. data/spec/units/global_id_spec.rb +12 -11
  86. data/spec/units/input_spec.rb +8 -5
  87. data/spec/units/output_spec.rb +8 -5
  88. data/spec/units/persistence_spec.rb +15 -11
  89. data/spec/units/polling_job_spec.rb +7 -6
  90. data/spec/units/polling_spec.rb +1 -0
  91. data/spec/units/status_spec.rb +3 -3
  92. metadata +184 -18
  93. data/.travis.yml +0 -19
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+ module ActiveEncode
3
+ module EngineAdapters
4
+ module MediaConvertOutput
5
+ class << self
6
+ AUDIO_SETTINGS = {
7
+ "AAC" => :aac_settings,
8
+ "AC3" => :ac3_settings,
9
+ "AIFF" => :aiff_settings,
10
+ "EAC3_ATMOS" => :eac_3_atmos_settings,
11
+ "EAC3" => :eac_3_settings,
12
+ "MP2" => :mp_2_settings,
13
+ "MP3" => :mp_3_settings,
14
+ "OPUS" => :opus_settings,
15
+ "VORBIS" => :vorbis_settings,
16
+ "WAV" => :wav_settings
17
+ }.freeze
18
+
19
+ VIDEO_SETTINGS = {
20
+ "AV1" => :av_1_settings,
21
+ "AVC_INTRA" => :avc_intra_settings,
22
+ "FRAME_CAPTURE" => :frame_capture_settings,
23
+ "H_264" => :h264_settings,
24
+ "H_265" => :h265_settings,
25
+ "MPEG2" => :mpeg_2_settings,
26
+ "PRORES" => :prores_settings,
27
+ "VC3" => :vc_3_settings,
28
+ "VP8" => :vp_8_settings,
29
+ "VP9" => :vp_9_settings,
30
+ "XAVC" => :xavc_settings
31
+ }.freeze
32
+
33
+ def tech_metadata(settings, output)
34
+ url = output.dig('outputFilePaths', 0)
35
+ {
36
+ width: output.dig('videoDetails', 'widthInPx'),
37
+ height: output.dig('videoDetails', 'heightInPx'),
38
+ frame_rate: extract_video_frame_rate(settings),
39
+ duration: output['durationInMs'],
40
+ audio_codec: extract_audio_codec(settings),
41
+ video_codec: extract_video_codec(settings),
42
+ audio_bitrate: extract_audio_bitrate(settings),
43
+ video_bitrate: extract_video_bitrate(settings),
44
+ url: url,
45
+ label: File.basename(url),
46
+ suffix: settings.name_modifier
47
+ }
48
+ end
49
+
50
+ def extract_audio_codec(settings)
51
+ settings.audio_descriptions.first.codec_settings.codec
52
+ rescue
53
+ nil
54
+ end
55
+
56
+ def extract_audio_codec_settings(settings)
57
+ codec_key = AUDIO_SETTINGS[extract_audio_codec(settings)]
58
+ settings.audio_descriptions.first.codec_settings[codec_key]
59
+ end
60
+
61
+ def extract_video_codec(settings)
62
+ settings.video_description.codec_settings.codec
63
+ rescue
64
+ nil
65
+ end
66
+
67
+ def extract_video_codec_settings(settings)
68
+ codec_key = VIDEO_SETTINGS[extract_video_codec(settings)]
69
+ settings.video_description.codec_settings[codec_key]
70
+ rescue
71
+ nil
72
+ end
73
+
74
+ def extract_audio_bitrate(settings)
75
+ codec_settings = extract_audio_codec_settings(settings)
76
+ return nil if codec_settings.nil?
77
+ try(codec_settings, :bitrate)
78
+ end
79
+
80
+ def extract_video_bitrate(settings)
81
+ codec_settings = extract_video_codec_settings(settings)
82
+ return nil if codec_settings.nil?
83
+ try(codec_settings, :bitrate) || try(codec_settings, :max_bitrate)
84
+ end
85
+
86
+ def extract_video_frame_rate(settings)
87
+ codec_settings = extract_video_codec_settings(settings)
88
+ return nil if codec_settings.nil?
89
+ (codec_settings.framerate_numerator.to_f / codec_settings.framerate_denominator.to_f).round(2)
90
+ rescue
91
+ nil
92
+ end
93
+
94
+ private
95
+
96
+ def try(struct, key)
97
+ struct[key]
98
+ rescue
99
+ nil
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,239 @@
1
+ # frozen_string_literal: true
2
+ require 'fileutils'
3
+ require 'nokogiri'
4
+ require 'shellwords'
5
+ require 'file_locator'
6
+
7
+ # PassThroughAdapter accepts an input file url and a number of derivative urls in the options
8
+ # E.g. `create(input, outputs: [{ label: 'low', url: 'file:///derivatives/low.mp4' }, { label: 'high', url: 'file:///derivatives/high.mp4' }])`
9
+ # This adapter mirrors the ffmpeg adapter but differs in a few ways:
10
+ # 1. It starts by copying the derivative files to the work directory
11
+ # 2. It runs Mediainfo on the input and output files and skips ffmpeg
12
+ # 3. All work is done in the create method so it's status is always completed or failed
13
+ module ActiveEncode
14
+ module EngineAdapters
15
+ class PassThroughAdapter
16
+ WORK_DIR = ENV["ENCODE_WORK_DIR"] || "encodes" # Should read from config
17
+ MEDIAINFO_PATH = ENV["MEDIAINFO_PATH"] || "mediainfo"
18
+
19
+ def create(input_url, options = {})
20
+ # Decode file uris for ffmpeg (mediainfo works either way)
21
+ input_url = URI.decode(input_url) if input_url.starts_with? "file:///"
22
+
23
+ new_encode = ActiveEncode::Base.new(input_url, options)
24
+ new_encode.id = SecureRandom.uuid
25
+ new_encode.current_operations = []
26
+ new_encode.output = []
27
+
28
+ # Create a working directory that holds all output files related to the encode
29
+ FileUtils.mkdir_p working_path("", new_encode.id)
30
+ FileUtils.mkdir_p working_path("outputs", new_encode.id)
31
+
32
+ # Extract technical metadata from input file
33
+ `#{MEDIAINFO_PATH} --Output=XML --LogFile=#{working_path("input_metadata", new_encode.id)} #{input_url.shellescape}`
34
+ new_encode.input = build_input new_encode
35
+ new_encode.input.id = new_encode.id
36
+ new_encode.created_at, new_encode.updated_at = get_times new_encode.id
37
+
38
+ if new_encode.input.duration.blank?
39
+ new_encode.state = :failed
40
+ new_encode.percent_complete = 1
41
+
42
+ new_encode.errors = if new_encode.input.file_size.blank?
43
+ ["#{input_url} does not exist or is not accessible"]
44
+ else
45
+ ["Error inspecting input: #{input_url}"]
46
+ end
47
+
48
+ write_errors new_encode
49
+ return new_encode
50
+ end
51
+
52
+ # For saving filename to label map used to find the label when building outputs
53
+ filename_label_hash = {}
54
+
55
+ # Copy derivatives to work directory
56
+ options[:outputs].each do |opt|
57
+ url = opt[:url]
58
+ output_path = working_path("outputs/#{sanitize_base opt[:url]}#{File.extname opt[:url]}", new_encode.id)
59
+ FileUtils.cp FileLocator.new(url).location, output_path
60
+ filename_label_hash[output_path] = opt[:label]
61
+ end
62
+
63
+ # Write filename-to-label map so we can retrieve them on build_output
64
+ File.write working_path("filename_label.yml", new_encode.id), filename_label_hash.to_yaml
65
+
66
+ new_encode.percent_complete = 1
67
+ new_encode.state = :running
68
+ new_encode.errors = []
69
+
70
+ new_encode
71
+ rescue StandardError => e
72
+ new_encode.state = :failed
73
+ new_encode.percent_complete = 1
74
+ new_encode.errors = [e.full_message]
75
+ write_errors new_encode
76
+ return new_encode
77
+ end
78
+
79
+ # Return encode object from file system
80
+ def find(id, opts = {})
81
+ encode_class = opts[:cast]
82
+ encode_class ||= ActiveEncode::Base
83
+ encode = encode_class.new(nil, opts)
84
+ encode.id = id
85
+ encode.created_at, encode.updated_at = get_times encode.id
86
+ encode.input = build_input encode
87
+ encode.input.id = encode.id
88
+ encode.output = []
89
+ encode.current_operations = []
90
+
91
+ encode.errors = read_errors(id)
92
+ if encode.errors.present?
93
+ encode.state = :failed
94
+ encode.percent_complete = 1
95
+ elsif cancelled?(id)
96
+ encode.state = :cancelled
97
+ encode.percent_complete = 1
98
+ elsif completed?(id)
99
+ encode.state = :completed
100
+ encode.percent_complete = 100
101
+ else
102
+ encode.output = build_outputs encode
103
+ encode.state = :completed
104
+ encode.percent_complete = 100
105
+ end
106
+
107
+ encode
108
+ rescue StandardError => e
109
+ encode.state = :failed
110
+ encode.percent_complete = 1
111
+ encode.errors = [e.full_message]
112
+ write_errors encode
113
+ return encode
114
+ end
115
+
116
+ # Cancel ongoing encode using pid file
117
+ def cancel(id)
118
+ # Check for errors and if not then create cancelled file else raise CancelError?
119
+ if running?(id)
120
+ File.write(working_path("cancelled", id), "")
121
+ find id
122
+ else
123
+ raise CancelError
124
+ end
125
+ end
126
+
127
+ private
128
+
129
+ def running?(id)
130
+ !cancelled?(id) || !failed?(id) || !completed?(id)
131
+ end
132
+
133
+ def cancelled?(id)
134
+ File.exist? working_path("cancelled", id)
135
+ end
136
+
137
+ def failed?(id)
138
+ read_errors(id).present?
139
+ end
140
+
141
+ def completed?(id)
142
+ File.exist? working_path("completed", id)
143
+ end
144
+
145
+ def get_times(id)
146
+ updated_at = if File.file? working_path("completed", id)
147
+ File.mtime(working_path("completed", id))
148
+ elsif File.file? working_path("cancelled", id)
149
+ File.mtime(working_path("cancelled", id))
150
+ elsif File.file? working_path("error.log", id)
151
+ File.mtime(working_path("error.log", id))
152
+ else
153
+ File.mtime(working_path("input_metadata", id))
154
+ end
155
+
156
+ [File.mtime(working_path("input_metadata", id)), updated_at]
157
+ end
158
+
159
+ def write_errors(encode)
160
+ File.write(working_path("error.log", encode.id), encode.errors.join("\n"))
161
+ end
162
+
163
+ def read_errors(id)
164
+ err_path = working_path("error.log", id)
165
+ error = File.read(err_path) if File.file? err_path
166
+ if error.present?
167
+ [error]
168
+ else
169
+ []
170
+ end
171
+ end
172
+
173
+ def build_input(encode)
174
+ input = ActiveEncode::Input.new
175
+ metadata = get_tech_metadata(working_path("input_metadata", encode.id))
176
+ input.url = metadata[:url]
177
+ input.assign_tech_metadata(metadata)
178
+ created_at = File.mtime(working_path("input_metadata", encode.id))
179
+ input.created_at = created_at
180
+ input.updated_at = created_at
181
+
182
+ input
183
+ end
184
+
185
+ def build_outputs(encode)
186
+ id = encode.id
187
+ outputs = []
188
+ filename_label_hash = YAML.safe_load(File.read(working_path("filename_label.yml", id))) if File.exist?(working_path("filename_label.yml", id))
189
+ Dir["#{File.absolute_path(working_path('outputs', id))}/*"].each do |file_path|
190
+ output = ActiveEncode::Output.new
191
+ output.url = "file://#{file_path}"
192
+ output.label = filename_label_hash[file_path] if filename_label_hash
193
+ output.id = "#{encode.input.id}-#{output.label}"
194
+ output.created_at = encode.created_at
195
+ output.updated_at = File.mtime file_path
196
+
197
+ # Extract technical metadata from output file
198
+ metadata_path = working_path("output_metadata-#{output.label}", id)
199
+ `#{MEDIAINFO_PATH} --Output=XML --LogFile=#{metadata_path} #{output.url}` unless File.file? metadata_path
200
+ output.assign_tech_metadata(get_tech_metadata(metadata_path))
201
+
202
+ outputs << output
203
+ end
204
+ File.write(working_path("completed", id), "")
205
+
206
+ outputs
207
+ end
208
+
209
+ def sanitize_base(input_url)
210
+ File.basename(input_url, File.extname(input_url)).gsub(/[^0-9A-Za-z.\-]/, '_')
211
+ end
212
+
213
+ def working_path(path, id)
214
+ File.join(WORK_DIR, id, path)
215
+ end
216
+
217
+ def get_tech_metadata(file_path)
218
+ doc = Nokogiri::XML File.read(file_path)
219
+ doc.remove_namespaces!
220
+ duration = get_xpath_text(doc, '//Duration/text()', :to_f)
221
+ duration *= 1000 unless duration.nil? # Convert to milliseconds
222
+ { url: get_xpath_text(doc, '//media/@ref', :to_s),
223
+ width: get_xpath_text(doc, '//Width/text()', :to_f),
224
+ height: get_xpath_text(doc, '//Height/text()', :to_f),
225
+ frame_rate: get_xpath_text(doc, '//FrameRate/text()', :to_f),
226
+ duration: duration,
227
+ file_size: get_xpath_text(doc, '//FileSize/text()', :to_i),
228
+ audio_codec: get_xpath_text(doc, '//track[@type="Audio"]/CodecID/text()', :to_s),
229
+ audio_bitrate: get_xpath_text(doc, '//track[@type="Audio"]/BitRate/text()', :to_i),
230
+ video_codec: get_xpath_text(doc, '//track[@type="Video"]/CodecID/text()', :to_s),
231
+ video_bitrate: get_xpath_text(doc, '//track[@type="Video"]/BitRate/text()', :to_i) }
232
+ end
233
+
234
+ def get_xpath_text(doc, xpath, cast_method)
235
+ doc.xpath(xpath).first&.text&.send(cast_method)
236
+ end
237
+ end
238
+ end
239
+ end
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module ActiveEncode
2
3
  module EngineAdapters
3
4
  class TestAdapter
@@ -9,8 +10,8 @@ module ActiveEncode
9
10
  new_encode = ActiveEncode::Base.new(input_url, options)
10
11
  new_encode.id = SecureRandom.uuid
11
12
  new_encode.state = :running
12
- new_encode.created_at = Time.now
13
- new_encode.updated_at = Time.now
13
+ new_encode.created_at = Time.now.utc
14
+ new_encode.updated_at = Time.now.utc
14
15
  @encodes[new_encode.id] = new_encode
15
16
  new_encode
16
17
  end
@@ -18,14 +19,14 @@ module ActiveEncode
18
19
  def find(id, _opts = {})
19
20
  new_encode = @encodes[id]
20
21
  # Update the updated_at time to simulate changes
21
- new_encode.updated_at = Time.now
22
+ new_encode.updated_at = Time.now.utc
22
23
  new_encode
23
24
  end
24
25
 
25
26
  def cancel(id)
26
27
  new_encode = @encodes[id]
27
28
  new_encode.state = :cancelled
28
- new_encode.updated_at = Time.now
29
+ new_encode.updated_at = Time.now.utc
29
30
  new_encode
30
31
  end
31
32
  end
@@ -1,13 +1,14 @@
1
+ # frozen_string_literal: true
1
2
  module ActiveEncode
2
3
  module EngineAdapters
3
4
  class ZencoderAdapter
4
5
  # TODO: add a stub for an input helper (supplied by an initializer) that transforms encode.input.url into a zencoder accepted url
5
- def create(input_url, options = {})
6
+ def create(input_url, _options = {})
6
7
  response = Zencoder::Job.create(input: input_url.to_s)
7
8
  build_encode(get_job_details(response.body["id"]))
8
9
  end
9
10
 
10
- def find(id, opts = {})
11
+ def find(id, _opts = {})
11
12
  build_encode(get_job_details(id))
12
13
  end
13
14
 
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+ module ActiveEncode #:nodoc:
3
+ class NotFound < RuntimeError; end
4
+ class NotRunningError < RuntimeError; end
5
+ class CancelError < RuntimeError; end
6
+ end
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  require 'globalid'
2
3
 
3
4
  module ActiveEncode
@@ -9,7 +10,7 @@ module ActiveEncode
9
10
  other.is_a?(ActiveEncode::Base) && to_global_id == other.to_global_id
10
11
  end
11
12
 
12
- def to_global_id(options = {})
13
+ def to_global_id(_options = {})
13
14
  super(app: 'ActiveEncode')
14
15
  end
15
16
  end
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module ActiveEncode
2
3
  class Input
3
4
  include Status
@@ -8,8 +9,8 @@ module ActiveEncode
8
9
 
9
10
  def valid?
10
11
  id.present? && url.present? &&
11
- created_at.is_a?(Time) && updated_at.is_a?(Time) &&
12
- updated_at >= created_at
12
+ created_at.is_a?(Time) && updated_at.is_a?(Time) &&
13
+ updated_at >= created_at
13
14
  end
14
15
  end
15
16
  end
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module ActiveEncode
2
3
  class Output
3
4
  include Status
@@ -9,8 +10,8 @@ module ActiveEncode
9
10
 
10
11
  def valid?
11
12
  id.present? && url.present? && label.present? &&
12
- created_at.is_a?(Time) && updated_at.is_a?(Time) &&
13
- updated_at >= created_at
13
+ created_at.is_a?(Time) && updated_at.is_a?(Time) &&
14
+ updated_at >= created_at
14
15
  end
15
16
  end
16
17
  end