active_encode 0.4.1 → 0.8.0

Sign up to get free protection for your applications and to get access to all the features.
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