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
@@ -1,16 +1,31 @@
1
+ # frozen_string_literal: true
1
2
  require 'fileutils'
2
3
  require 'nokogiri'
4
+ require 'shellwords'
3
5
 
4
6
  module ActiveEncode
5
7
  module EngineAdapters
6
8
  class FfmpegAdapter
7
9
  WORK_DIR = ENV["ENCODE_WORK_DIR"] || "encodes" # Should read from config
10
+ MEDIAINFO_PATH = ENV["MEDIAINFO_PATH"] || "mediainfo"
11
+ FFMPEG_PATH = ENV["FFMPEG_PATH"] || "ffmpeg"
8
12
 
9
13
  def create(input_url, options = {})
14
+ # Decode file uris for ffmpeg (mediainfo works either way)
15
+ case input_url
16
+ when /^file\:\/\/\//
17
+ input_url = URI.decode(input_url)
18
+ when /^s3\:\/\//
19
+ require 'file_locator'
20
+
21
+ s3_object = FileLocator::S3File.new(input_url).object
22
+ input_url = URI.parse(s3_object.presigned_url(:get))
23
+ end
24
+
10
25
  new_encode = ActiveEncode::Base.new(input_url, options)
11
26
  new_encode.id = SecureRandom.uuid
12
- new_encode.created_at = Time.new
13
- new_encode.updated_at = Time.new
27
+ new_encode.created_at = Time.now.utc
28
+ new_encode.updated_at = Time.now.utc
14
29
  new_encode.current_operations = []
15
30
  new_encode.output = []
16
31
 
@@ -19,18 +34,24 @@ module ActiveEncode
19
34
  FileUtils.mkdir_p working_path("outputs", new_encode.id)
20
35
 
21
36
  # Extract technical metadata from input file
22
- `mediainfo --Output=XML --LogFile=#{working_path("input_metadata", new_encode.id)} #{input_url}`
37
+ curl_option = if options && options[:headers]
38
+ headers = options[:headers].map { |k, v| "#{k}: #{v}" }
39
+ (["--File_curl=HttpHeader"] + headers).join(",").yield_self { |s| "'#{s}'" }
40
+ else
41
+ ""
42
+ end
43
+ `#{MEDIAINFO_PATH} #{curl_option} --Output=XML --LogFile=#{working_path("input_metadata", new_encode.id)} "#{input_url}"`
23
44
  new_encode.input = build_input new_encode
24
45
 
25
46
  if new_encode.input.duration.blank?
26
47
  new_encode.state = :failed
27
48
  new_encode.percent_complete = 1
28
49
 
29
- if new_encode.input.file_size.blank?
30
- new_encode.errors = ["#{input_url} does not exist or is not accessible"]
31
- else
32
- new_encode.errors = ["Error inspecting input: #{input_url}"]
33
- end
50
+ new_encode.errors = if new_encode.input.file_size.blank?
51
+ ["#{input_url} does not exist or is not accessible"]
52
+ else
53
+ ["Error inspecting input: #{input_url}"]
54
+ end
34
55
 
35
56
  write_errors new_encode
36
57
  return new_encode
@@ -42,20 +63,27 @@ module ActiveEncode
42
63
 
43
64
  # Run the ffmpeg command and save its pid
44
65
  command = ffmpeg_command(input_url, new_encode.id, options)
45
- pid = Process.spawn(command)
66
+ pid = Process.spawn(command, err: working_path('error.log', new_encode.id))
46
67
  File.open(working_path("pid", new_encode.id), 'w') { |file| file.write pid }
47
68
  new_encode.input.id = pid
48
69
 
49
- # Prevent zombie process
50
- Process.detach(pid)
51
-
52
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
+ ensure
78
+ # Prevent zombie process
79
+ Process.detach(pid) if pid.present?
53
80
  end
54
81
 
55
82
  # Return encode object from file system
56
- def find(id, opts={})
83
+ def find(id, opts = {})
57
84
  encode_class = opts[:cast]
58
- encode = ActiveEncode::Base.new(nil, opts)
85
+ encode_class ||= ActiveEncode::Base
86
+ encode = encode_class.new(nil, opts)
59
87
  encode.id = id
60
88
  encode.output = []
61
89
  encode.created_at, encode.updated_at = get_times encode.id
@@ -65,28 +93,21 @@ module ActiveEncode
65
93
  pid = get_pid(id)
66
94
  encode.input.id = pid if pid.present?
67
95
 
68
- if File.file? working_path("error.log", id)
69
- error = File.read working_path("error.log", id)
70
- if error.present?
71
- encode.state = :failed
72
- encode.errors = [error]
73
-
74
- return encode
75
- end
76
- end
77
-
78
- encode.errors = []
79
-
80
96
  encode.current_operations = []
81
97
  encode.created_at, encode.updated_at = get_times encode.id
82
-
83
- if running? pid
98
+ encode.errors = read_errors(id)
99
+ if encode.errors.present?
100
+ encode.state = :failed
101
+ elsif running? pid
84
102
  encode.state = :running
85
103
  encode.current_operations = ["transcoding"]
86
104
  elsif progress_ended?(encode.id) && encode.percent_complete == 100
87
- encode.state = :completed
105
+ encode.state = :completed
106
+ elsif cancelled? encode.id
107
+ encode.state = :cancelled
88
108
  elsif encode.percent_complete < 100
89
- encode.state = :cancelled
109
+ encode.errors << "Encoding has completed but the output duration is shorter than the input"
110
+ encode.state = :failed
90
111
  end
91
112
 
92
113
  encode.output = build_outputs encode if encode.completed?
@@ -96,31 +117,61 @@ module ActiveEncode
96
117
 
97
118
  # Cancel ongoing encode using pid file
98
119
  def cancel(id)
99
- pid = get_pid(id)
100
- Process.kill 'SIGTERM', pid.to_i
120
+ encode = find id
121
+ if encode.running?
122
+ pid = get_pid(id)
123
+
124
+ IO.popen("ps -ef | grep #{pid}") do |pipe|
125
+ child_pids = pipe.readlines.map do |line|
126
+ parts = line.split(/\s+/)
127
+ parts[1] if parts[2] == pid.to_s && parts[1] != pipe.pid.to_s
128
+ end.compact
129
+
130
+ child_pids.each do |cpid|
131
+ Process.kill 'SIGTERM', cpid.to_i
132
+ end
133
+ end
101
134
 
102
- find id
135
+ Process.kill 'SIGTERM', pid.to_i
136
+ File.write(working_path("cancelled", id), "")
137
+ encode = find id
138
+ end
139
+ encode
140
+ rescue Errno::ESRCH
141
+ raise NotRunningError
142
+ rescue StandardError
143
+ raise CancelError
103
144
  end
104
145
 
105
- private
146
+ private
106
147
 
107
- def get_times id
148
+ def get_times(id)
108
149
  updated_at = if File.file? working_path("progress", id)
109
- File.mtime(working_path("progress", id))
110
- elsif File.file? working_path("error.log", id)
111
- File.mtime(working_path("error.log", id))
112
- else
113
- File.mtime(working_path("input_metadata", id))
114
- end
115
-
116
- return File.mtime(working_path("input_metadata", id)), updated_at
150
+ File.mtime(working_path("progress", id))
151
+ elsif File.file? working_path("error.log", id)
152
+ File.mtime(working_path("error.log", id))
153
+ else
154
+ File.mtime(working_path("input_metadata", id))
155
+ end
156
+
157
+ [File.mtime(working_path("input_metadata", id)), updated_at]
117
158
  end
118
159
 
119
- def write_errors encode
160
+ def write_errors(encode)
120
161
  File.write(working_path("error.log", encode.id), encode.errors.join("\n"))
121
162
  end
122
163
 
123
- def build_input encode
164
+ def read_errors(id)
165
+ err_path = working_path("error.log", id)
166
+ error = File.read(err_path) if File.file? err_path
167
+ if error.present?
168
+ [error]
169
+ else
170
+ []
171
+ end
172
+ end
173
+
174
+ def build_input(encode)
124
175
  input = ActiveEncode::Input.new
125
176
  metadata = get_tech_metadata(working_path("input_metadata", encode.id))
126
177
  input.url = metadata[:url]
@@ -132,22 +183,21 @@ private
132
183
  input
133
184
  end
134
185
 
135
- def build_outputs encode
186
+ def build_outputs(encode)
136
187
  id = encode.id
137
188
  outputs = []
138
189
  Dir["#{File.absolute_path(working_path('outputs', id))}/*"].each do |file_path|
139
190
  output = ActiveEncode::Output.new
140
191
  output.url = "file://#{file_path}"
141
- original_extension = File.extname(encode.input.url)
142
- original_filename = File.basename(encode.input.url).chomp(original_extension)
143
- output.label = file_path[/#{Regexp.quote(original_filename)}\-(.*?)#{Regexp.quote(File.extname(file_path))}$/, 1]
192
+ sanitized_filename = sanitize_base encode.input.url
193
+ output.label = file_path[/#{Regexp.quote(sanitized_filename)}\-(.*?)#{Regexp.quote(File.extname(file_path))}$/, 1]
144
194
  output.id = "#{encode.input.id}-#{output.label}"
145
195
  output.created_at = encode.created_at
146
196
  output.updated_at = File.mtime file_path
147
197
 
148
198
  # Extract technical metadata from output file
149
199
  metadata_path = working_path("output_metadata-#{output.label}", id)
150
- `mediainfo --Output=XML --LogFile=#{metadata_path} #{output.url}` unless File.file? metadata_path
200
+ `#{MEDIAINFO_PATH} --Output=XML --LogFile=#{metadata_path} #{output.url}` unless File.file? metadata_path
151
201
  output.assign_tech_metadata(get_tech_metadata(metadata_path))
152
202
 
153
203
  outputs << output
@@ -158,72 +208,80 @@ private
158
208
 
159
209
  def ffmpeg_command(input_url, id, opts)
160
210
  output_opt = opts[:outputs].collect do |output|
161
- file_name = "outputs/#{File.basename(input_url, File.extname(input_url))}-#{output[:label]}.#{output[:extension]}"
211
+ sanitized_filename = sanitize_base input_url
212
+ file_name = "outputs/#{sanitized_filename}-#{output[:label]}.#{output[:extension]}"
162
213
  " #{output[:ffmpeg_opt]} #{working_path(file_name, id)}"
163
214
  end.join(" ")
164
-
165
- "ffmpeg -y -loglevel error -progress #{working_path("progress", id)} -i #{input_url} #{output_opt} > #{working_path("error.log", id)} 2>&1"
215
+ header_opt = Array(opts[:headers]).map do |k, v|
216
+ "#{k}: #{v}\r\n"
217
+ end.join
218
+ header_opt = "-headers '#{header_opt}'" if header_opt.present?
219
+ "#{FFMPEG_PATH} #{header_opt} -y -loglevel error -progress #{working_path('progress', id)} -i \"#{input_url}\" #{output_opt}"
166
220
  end
167
221
 
168
- def get_pid(id)
169
- if File.file? working_path("pid", id)
170
- File.read(working_path("pid", id)).remove("\n")
222
+ def sanitize_base(input_url)
223
+ if input_url.is_a? URI::HTTP
224
+ File.basename(input_url.path, File.extname(input_url.path))
171
225
  else
172
- nil
226
+ File.basename(input_url, File.extname(input_url)).gsub(/[^0-9A-Za-z.\-]/, '_')
173
227
  end
174
228
  end
175
229
 
230
+ def get_pid(id)
231
+ File.read(working_path("pid", id)).remove("\n") if File.file? working_path("pid", id)
232
+ end
233
+
176
234
  def working_path(path, id)
177
235
  File.join(WORK_DIR, id, path)
178
236
  end
179
237
 
180
238
  def running?(pid)
181
- begin
182
- Process.getpgid pid.to_i
183
- true
184
- rescue Errno::ESRCH
185
- false
186
- end
239
+ Process.getpgid pid.to_i
240
+ true
241
+ rescue Errno::ESRCH
242
+ false
187
243
  end
188
244
 
189
- def calculate_percent_complete encode
245
+ def calculate_percent_complete(encode)
190
246
  data = read_progress encode.id
191
247
  if data.blank?
192
248
  1
193
249
  else
194
- (progress_value("out_time_ms=", data).to_i * 0.0001 / encode.input.duration).round
250
+ progress_in_milliseconds = progress_value("out_time_ms=", data).to_i / 1000.0
251
+ output = (progress_in_milliseconds / encode.input.duration * 100).ceil
252
+ return 100 if output > 100
253
+ output
195
254
  end
196
255
  end
197
256
 
198
- def read_progress id
199
- if File.file? working_path("progress", id)
200
- File.read working_path("progress", id)
201
- else
202
- nil
203
- end
257
+ def cancelled?(id)
258
+ File.exist? working_path("cancelled", id)
259
+ end
260
+
261
+ def read_progress(id)
262
+ File.read working_path("progress", id) if File.file? working_path("progress", id)
204
263
  end
205
264
 
206
- def progress_ended? id
265
+ def progress_ended?(id)
207
266
  "end" == progress_value("progress=", read_progress(id))
208
267
  end
209
268
 
210
- def progress_value key, data
211
- if data.present? && key.present?
212
- ri = data.rindex(key) + key.length
213
- data[ri..data.index("\n", ri)-1]
214
- else
215
- nil
216
- end
269
+ def progress_value(key, data)
270
+ return nil unless data.present? && key.present?
271
+ ri = data.rindex(key) + key.length
272
+ data[ri..data.index("\n", ri) - 1]
217
273
  end
218
274
 
219
- def get_tech_metadata file_path
275
+ def get_tech_metadata(file_path)
220
276
  doc = Nokogiri::XML File.read(file_path)
221
277
  doc.remove_namespaces!
278
+ duration = get_xpath_text(doc, '//Duration/text()', :to_f)
279
+ duration *= 1000 unless duration.nil? # Convert to milliseconds
222
280
  { url: get_xpath_text(doc, '//media/@ref', :to_s),
223
281
  width: get_xpath_text(doc, '//Width/text()', :to_f),
224
282
  height: get_xpath_text(doc, '//Height/text()', :to_f),
225
283
  frame_rate: get_xpath_text(doc, '//FrameRate/text()', :to_f),
226
- duration: get_xpath_text(doc, '//Duration/text()', :to_f),
284
+ duration: duration,
227
285
  file_size: get_xpath_text(doc, '//FileSize/text()', :to_i),
228
286
  audio_codec: get_xpath_text(doc, '//track[@type="Audio"]/CodecID/text()', :to_s),
229
287
  audio_bitrate: get_xpath_text(doc, '//track[@type="Audio"]/BitRate/text()', :to_i),
@@ -231,12 +289,8 @@ private
231
289
  video_bitrate: get_xpath_text(doc, '//track[@type="Video"]/BitRate/text()', :to_i) }
232
290
  end
233
291
 
234
- def get_xpath_text doc, xpath, cast_method
235
- if doc.xpath(xpath).first
236
- doc.xpath(xpath).first.text.send(cast_method)
237
- else
238
- nil
239
- end
292
+ def get_xpath_text(doc, xpath, cast_method)
293
+ doc.xpath(xpath).first&.text&.send(cast_method)
240
294
  end
241
295
  end
242
296
  end
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  require 'rubyhorn'
2
3
 
3
4
  module ActiveEncode
@@ -11,7 +12,7 @@ module ActiveEncode
11
12
  build_encode(get_workflow(workflow_om))
12
13
  end
13
14
 
14
- def find(id, opts = {})
15
+ def find(id, _opts = {})
15
16
  build_encode(fetch_workflow(id))
16
17
  end
17
18
 
@@ -145,7 +146,7 @@ module ActiveEncode
145
146
 
146
147
  def convert_created_at(workflow)
147
148
  created_at = workflow.xpath('mediapackage/@start').last.to_s
148
- created_at.present? ? Time.parse(created_at) : nil
149
+ created_at.present? ? Time.parse(created_at).utc : nil
149
150
  end
150
151
 
151
152
  def convert_updated_at(workflow)
@@ -156,13 +157,13 @@ module ActiveEncode
156
157
  def convert_output_created_at(track, workflow)
157
158
  quality = track.xpath('tags/tag[starts-with(text(),"quality")]/text()').to_s
158
159
  created_at = workflow.xpath("//operation[@id=\"compose\"][configurations/configuration[@key=\"target-tags\" and contains(text(), \"#{quality}\")]]/started/text()").to_s
159
- created_at.present? ? Time.at(created_at.to_i / 1000.0) : nil
160
+ created_at.present? ? Time.at(created_at.to_i / 1000.0).utc : nil
160
161
  end
161
162
 
162
163
  def convert_output_updated_at(track, workflow)
163
164
  quality = track.xpath('tags/tag[starts-with(text(),"quality")]/text()').to_s
164
165
  updated_at = workflow.xpath("//operation[@id=\"compose\"][configurations/configuration[@key=\"target-tags\" and contains(text(), \"#{quality}\")]]/completed/text()").to_s
165
- updated_at.present? ? Time.at(updated_at.to_i / 1000.0) : nil
166
+ updated_at.present? ? Time.at(updated_at.to_i / 1000.0).utc : nil
166
167
  end
167
168
 
168
169
  def convert_options(workflow)
@@ -0,0 +1,372 @@
1
+ # frozen_string_literal: true
2
+ require 'active_encode/engine_adapters/media_convert_output.rb'
3
+ require 'active_support/core_ext/integer/time'
4
+ require 'addressable/uri'
5
+ require 'aws-sdk-cloudwatchevents'
6
+ require 'aws-sdk-cloudwatchlogs'
7
+ require 'aws-sdk-mediaconvert'
8
+ require 'file_locator'
9
+
10
+ require 'active_support/json'
11
+ require 'active_support/time'
12
+
13
+ module ActiveEncode
14
+ module EngineAdapters
15
+ class MediaConvertAdapter
16
+ # [AWS Elemental MediaConvert](https://aws.amazon.com/mediaconvert/) doesn't provide detailed
17
+ # output information in the job description that can be pulled directly from the service.
18
+ # Instead, it provides that information along with the job status notification when the job
19
+ # status changes to `COMPLETE`. The only way to capture that notification is through an [Amazon
20
+ # Eventbridge](https://aws.amazon.com/eventbridge/) rule that forwards the status change
21
+ # notification to another service for capture and/or handling.
22
+ #
23
+ # `ActiveEncode::EngineAdapters::MediaConvert` does this by creating a [CloudWatch Logs]
24
+ # (https://aws.amazon.com/cloudwatch/) log group and an EventBridge rule to forward status
25
+ # change notifications to the log group. It can then find the log entry containing the output
26
+ # details later when the job is complete. This is accomplished by calling the idempotent
27
+ # `#setup!` method.
28
+ #
29
+ # The AWS user/role calling the `#setup!` method will require permissions to create the
30
+ # necessary CloudWatch and EventBridge resources, and the role passed to the engine adapter
31
+ # will need access to any S3 buckets where files will be read from or written to during
32
+ # transcoding.
33
+ #
34
+ # Configuration example:
35
+ #
36
+ # ActiveEncode::Base.engine_adapter = :media_convert
37
+ # ActiveEncode::Base.engine_adapter.role = 'arn:aws:iam::123456789012:role/service-role/MediaConvert_Default_Role'
38
+ # ActiveEncode::Base.engine_adapter.output_bucket = 'output-bucket'
39
+ # ActiveEncode::Base.engine_adapter.setup!
40
+
41
+ JOB_STATES = {
42
+ "SUBMITTED" => :running, "PROGRESSING" => :running, "CANCELED" => :cancelled,
43
+ "ERROR" => :failed, "COMPLETE" => :completed
44
+ }.freeze
45
+
46
+ OUTPUT_GROUP_TEMPLATES = {
47
+ hls: { min_segment_length: 0, segment_control: "SEGMENTED_FILES", segment_length: 10 },
48
+ dash_iso: { fragment_length: 2, segment_control: "SEGMENTED_FILES", segment_length: 30 },
49
+ file: {},
50
+ ms_smooth: { fragment_length: 2 },
51
+ cmaf: { fragment_length: 2, segment_control: "SEGMENTED_FILES", segment_length: 10 }
52
+ }.freeze
53
+
54
+ attr_accessor :role, :output_bucket
55
+ attr_writer :log_group, :queue
56
+
57
+ def setup!
58
+ rule_name = "active-encode-mediaconvert-#{queue}"
59
+ return true if event_rule_exists?(rule_name)
60
+
61
+ queue_arn = mediaconvert.get_queue(name: queue).queue.arn
62
+
63
+ event_pattern = {
64
+ source: ["aws.mediaconvert"],
65
+ "detail-type": ["MediaConvert Job State Change"],
66
+ detail: {
67
+ queue: [queue_arn]
68
+ }
69
+ }
70
+
71
+ log_group_arn = create_log_group(log_group).arn
72
+
73
+ cloudwatch_events.put_rule(
74
+ name: rule_name,
75
+ event_pattern: event_pattern.to_json,
76
+ state: "ENABLED",
77
+ description: "Forward MediaConvert job state changes from queue #{queue} to #{log_group}"
78
+ )
79
+
80
+ cloudwatch_events.put_targets(
81
+ rule: rule_name,
82
+ targets: [
83
+ {
84
+ id: "Id#{SecureRandom.uuid}",
85
+ arn: log_group_arn
86
+ }
87
+ ]
88
+ )
89
+ true
90
+ end
91
+
92
+ # Required options:
93
+ #
94
+ # * `output_prefix`: The S3 key prefix to use as the base for all outputs.
95
+ #
96
+ # * `outputs`: An array of `{preset, modifier}` options defining how to transcode and name the outputs.
97
+ #
98
+ # Optional options:
99
+ #
100
+ # * `masterfile_bucket`: The bucket to which file-based inputs will be copied before
101
+ # being passed to MediaConvert. Also used for S3-based inputs
102
+ # unless `use_original_url` is specified.
103
+ #
104
+ # * `use_original_url`: If `true`, any S3 URL passed in as input will be passed directly to
105
+ # MediaConvert as the file input instead of copying the source to
106
+ # the `masterfile_bucket`.
107
+ #
108
+ # Example:
109
+ # {
110
+ # output_prefix: "path/to/output/files",
111
+ # outputs: [
112
+ # {preset: "System-Avc_16x9_1080p_29_97fps_8500kbps", modifier: "-1080"},
113
+ # {preset: "System-Avc_16x9_720p_29_97fps_5000kbps", modifier: "-720"},
114
+ # {preset: "System-Avc_16x9_540p_29_97fps_3500kbps", modifier: "-540"}
115
+ # ]
116
+ # }
117
+ # }
118
+ def create(input_url, options = {})
119
+ input_url = s3_uri(input_url, options)
120
+
121
+ input = options[:media_type] == :audio ? make_audio_input(input_url) : make_video_input(input_url)
122
+
123
+ create_job_params = {
124
+ role: role,
125
+ settings: {
126
+ inputs: [input],
127
+ output_groups: make_output_groups(options)
128
+ }
129
+ }
130
+
131
+ response = mediaconvert.create_job(create_job_params)
132
+ job = response.job
133
+ build_encode(job)
134
+ end
135
+
136
+ def find(id, _opts = {})
137
+ response = mediaconvert.get_job(id: id)
138
+ job = response.job
139
+ build_encode(job)
140
+ rescue Aws::MediaConvert::Errors::NotFound
141
+ raise ActiveEncode::NotFound, "Job #{id} not found"
142
+ end
143
+
144
+ def cancel(id)
145
+ mediaconvert.cancel_job(id: id)
146
+ find(id)
147
+ end
148
+
149
+ def log_group
150
+ @log_group ||= "/aws/events/active-encode/mediaconvert/#{queue}"
151
+ end
152
+
153
+ def queue
154
+ @queue ||= "Default"
155
+ end
156
+
157
+ private
158
+
159
+ def build_encode(job)
160
+ return nil if job.nil?
161
+ encode = ActiveEncode::Base.new(job.settings.inputs.first.file_input, {})
162
+ encode.id = job.id
163
+ encode.input.id = job.id
164
+ encode.state = JOB_STATES[job.status]
165
+ encode.current_operations = [job.current_phase].compact
166
+ encode.created_at = job.timing.submit_time
167
+ encode.updated_at = job.timing.finish_time || job.timing.start_time || encode.created_at
168
+ encode.percent_complete = convert_percent_complete(job)
169
+ encode.errors = [job.error_message].compact
170
+
171
+ encode.input.created_at = encode.created_at
172
+ encode.input.updated_at = encode.updated_at
173
+
174
+ encode.output = encode.state == :completed ? convert_output(job) : []
175
+ encode
176
+ end
177
+
178
+ def convert_percent_complete(job)
179
+ case job.status
180
+ when "SUBMITTED"
181
+ 5
182
+ when "PROGRESSING"
183
+ job.job_percent_complete
184
+ when "CANCELED", "ERROR"
185
+ 50
186
+ when "COMPLETE"
187
+ 100
188
+ else
189
+ 0
190
+ end
191
+ end
192
+
193
+ def convert_output(job)
194
+ results = get_encode_results(job)
195
+ settings = job.settings.output_groups.first.outputs
196
+
197
+ outputs = results.dig('detail', 'outputGroupDetails', 0, 'outputDetails').map.with_index do |detail, index|
198
+ tech_md = MediaConvertOutput.tech_metadata(settings[index], detail)
199
+ output = ActiveEncode::Output.new
200
+
201
+ output.created_at = job.timing.submit_time
202
+ output.updated_at = job.timing.finish_time || job.timing.start_time || output.created_at
203
+
204
+ [:width, :height, :frame_rate, :duration, :checksum, :audio_codec, :video_codec,
205
+ :audio_bitrate, :video_bitrate, :file_size, :label, :url, :id].each do |field|
206
+ output.send("#{field}=", tech_md[field])
207
+ end
208
+ output.id ||= "#{job.id}-output#{tech_md[:suffix]}"
209
+ output
210
+ end
211
+
212
+ adaptive_playlist = results.dig('detail', 'outputGroupDetails', 0, 'playlistFilePaths', 0)
213
+ unless adaptive_playlist.nil?
214
+ output = ActiveEncode::Output.new
215
+ output.created_at = job.timing.submit_time
216
+ output.updated_at = job.timing.finish_time || job.timing.start_time || output.created_at
217
+ output.id = "#{job.id}-output-auto"
218
+
219
+ [:duration, :audio_codec, :video_codec].each do |field|
220
+ output.send("#{field}=", outputs.first.send(field))
221
+ end
222
+ output.label = File.basename(adaptive_playlist)
223
+ output.url = adaptive_playlist
224
+ outputs << output
225
+ end
226
+ outputs
227
+ end
228
+
229
+ def get_encode_results(job)
230
+ start_time = job.timing.submit_time
231
+ end_time = job.timing.finish_time || Time.now.utc
232
+
233
+ response = cloudwatch_logs.start_query(
234
+ log_group_name: log_group,
235
+ start_time: start_time.to_i,
236
+ end_time: end_time.to_i,
237
+ limit: 1,
238
+ query_string: "fields @message | filter detail.jobId = '#{job.id}' | filter detail.status = 'COMPLETE' | sort @ingestionTime desc"
239
+ )
240
+ query_id = response.query_id
241
+ response = cloudwatch_logs.get_query_results(query_id: query_id)
242
+ until response.status == "Complete"
243
+ sleep(0.5)
244
+ response = cloudwatch_logs.get_query_results(query_id: query_id)
245
+ end
246
+ raise ActiveEncode::NotFound, "Unable to load progress for job #{job.id}" if response.results.empty?
247
+
248
+ JSON.parse(response.results.first.first.value)
249
+ end
250
+
251
+ def cloudwatch_events
252
+ @cloudwatch_events ||= Aws::CloudWatchEvents::Client.new
253
+ end
254
+
255
+ def cloudwatch_logs
256
+ @cloudwatch_logs ||= Aws::CloudWatchLogs::Client.new
257
+ end
258
+
259
+ def mediaconvert
260
+ endpoint = Aws::MediaConvert::Client.new.describe_endpoints.endpoints.first.url
261
+ @mediaconvert ||= Aws::MediaConvert::Client.new(endpoint: endpoint)
262
+ end
263
+
264
+ def s3_uri(url, options = {})
265
+ bucket = options[:masterfile_bucket]
266
+
267
+ case Addressable::URI.parse(url).scheme
268
+ when nil, 'file'
269
+ upload_to_s3 url, bucket
270
+ when 's3'
271
+ return url if options[:use_original_url]
272
+ check_s3_bucket url, bucket
273
+ else
274
+ raise ArgumentError, "Cannot handle source URL: #{url}"
275
+ end
276
+ end
277
+
278
+ def check_s3_bucket(input_url, source_bucket)
279
+ # logger.info("Checking `#{input_url}'")
280
+ s3_object = FileLocator::S3File.new(input_url).object
281
+ if s3_object.bucket_name == source_bucket
282
+ # logger.info("Already in bucket `#{source_bucket}'")
283
+ s3_object.key
284
+ else
285
+ s3_key = File.join(SecureRandom.uuid, s3_object.key)
286
+ # logger.info("Copying to `#{source_bucket}/#{input_url}'")
287
+ target = Aws::S3::Object.new(bucket_name: source_bucket, key: input_url)
288
+ target.copy_from(s3_object, multipart_copy: s3_object.size > 15_728_640) # 15.megabytes
289
+ s3_key
290
+ end
291
+ end
292
+
293
+ def upload_to_s3(input_url, source_bucket)
294
+ # original_input = input_url
295
+ bucket = Aws::S3::Resource.new(client: s3client).bucket(source_bucket)
296
+ filename = FileLocator.new(input_url).location
297
+ s3_key = File.join(SecureRandom.uuid, File.basename(filename))
298
+ # logger.info("Copying `#{original_input}' to `#{source_bucket}/#{input_url}'")
299
+ obj = bucket.object(s3_key)
300
+ obj.upload_file filename
301
+
302
+ s3_key
303
+ end
304
+
305
+ def event_rule_exists?(rule_name)
306
+ rule = cloudwatch_events.list_rules(name_prefix: rule_name).rules.find do |existing_rule|
307
+ existing_rule.name == rule_name
308
+ end
309
+ !rule.nil?
310
+ end
311
+
312
+ def find_log_group(name)
313
+ cloudwatch_logs.describe_log_groups(log_group_name_prefix: name).log_groups.find do |group|
314
+ group.log_group_name == name
315
+ end
316
+ end
317
+
318
+ def create_log_group(name)
319
+ result = find_log_group(name)
320
+
321
+ return result unless result.nil?
322
+
323
+ cloudwatch_logs.create_log_group(log_group_name: name)
324
+ find_log_group(name)
325
+ end
326
+
327
+ def make_audio_input(input_url)
328
+ {
329
+ audio_selectors: { "Audio Selector 1" => { default_selection: "DEFAULT" } },
330
+ audio_selector_groups: {
331
+ "Audio Selector Group 1" => {
332
+ audio_selector_names: ["Audio Selector 1"]
333
+ }
334
+ },
335
+ file_input: input_url,
336
+ timecode_source: "ZEROBASED"
337
+ }
338
+ end
339
+
340
+ def make_video_input(input_url)
341
+ {
342
+ audio_selectors: { "Audio Selector 1" => { default_selection: "DEFAULT" } },
343
+ file_input: input_url,
344
+ timecode_source: "ZEROBASED",
345
+ video_selector: {}
346
+ }
347
+ end
348
+
349
+ def make_output_groups(options)
350
+ output_type = options[:output_type] || :hls
351
+ raise ArgumentError, "Unknown output type: #{output_type.inspect}" unless OUTPUT_GROUP_TEMPLATES.keys.include?(output_type)
352
+ output_group_settings_key = "#{output_type}_group_settings".to_sym
353
+ output_group_settings = OUTPUT_GROUP_TEMPLATES[output_type].merge(destination: "s3://#{output_bucket}/#{options[:output_prefix]}")
354
+
355
+ outputs = options[:outputs].map do |output|
356
+ {
357
+ preset: output[:preset],
358
+ name_modifier: output[:modifier]
359
+ }
360
+ end
361
+
362
+ [{
363
+ output_group_settings: {
364
+ type: output_group_settings_key.upcase,
365
+ output_group_settings_key => output_group_settings
366
+ },
367
+ outputs: outputs
368
+ }]
369
+ end
370
+ end
371
+ end
372
+ end