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
@@ -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