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.
- checksums.yaml +5 -5
- data/.circleci/config.yml +80 -0
- data/.rubocop.yml +9 -70
- data/.rubocop_todo.yml +68 -0
- data/CODE_OF_CONDUCT.md +36 -0
- data/CONTRIBUTING.md +23 -21
- data/Gemfile +5 -4
- data/LICENSE +11 -199
- data/README.md +135 -24
- data/SUPPORT.md +5 -0
- data/active_encode.gemspec +13 -3
- data/app/controllers/active_encode/encode_record_controller.rb +13 -0
- data/app/jobs/active_encode/polling_job.rb +1 -1
- data/app/models/active_encode/encode_record.rb +1 -0
- data/config/routes.rb +4 -0
- data/db/migrate/20180822021048_create_active_encode_encode_records.rb +1 -0
- data/db/migrate/20190702153755_add_create_options_to_active_encode_encode_records.rb +6 -0
- data/db/migrate/20190712174821_add_progress_to_active_encode_encode_records.rb +6 -0
- data/lib/active_encode.rb +1 -0
- data/lib/active_encode/base.rb +2 -2
- data/lib/active_encode/callbacks.rb +1 -0
- data/lib/active_encode/core.rb +4 -3
- data/lib/active_encode/engine.rb +1 -0
- data/lib/active_encode/engine_adapter.rb +1 -0
- data/lib/active_encode/engine_adapters.rb +4 -1
- data/lib/active_encode/engine_adapters/elastic_transcoder_adapter.rb +116 -38
- data/lib/active_encode/engine_adapters/ffmpeg_adapter.rb +141 -87
- data/lib/active_encode/engine_adapters/matterhorn_adapter.rb +5 -4
- data/lib/active_encode/engine_adapters/media_convert_adapter.rb +372 -0
- data/lib/active_encode/engine_adapters/media_convert_output.rb +104 -0
- data/lib/active_encode/engine_adapters/pass_through_adapter.rb +239 -0
- data/lib/active_encode/engine_adapters/test_adapter.rb +5 -4
- data/lib/active_encode/engine_adapters/zencoder_adapter.rb +3 -2
- data/lib/active_encode/errors.rb +6 -0
- data/lib/active_encode/global_id.rb +2 -1
- data/lib/active_encode/input.rb +3 -2
- data/lib/active_encode/output.rb +3 -2
- data/lib/active_encode/persistence.rb +11 -5
- data/lib/active_encode/polling.rb +3 -2
- data/lib/active_encode/spec/shared_specs.rb +2 -0
- data/{spec/shared_specs/engine_adapter_specs.rb → lib/active_encode/spec/shared_specs/engine_adapter.rb} +37 -38
- data/lib/active_encode/status.rb +1 -0
- data/lib/active_encode/technical_metadata.rb +3 -2
- data/lib/active_encode/version.rb +2 -1
- data/lib/file_locator.rb +93 -0
- data/spec/controllers/encode_record_controller_spec.rb +53 -0
- data/spec/fixtures/ffmpeg/cancelled-id/cancelled +0 -0
- data/spec/fixtures/file with space.low.mp4 +0 -0
- data/spec/fixtures/file with space.mp4 +0 -0
- data/spec/fixtures/fireworks.low.mp4 +0 -0
- data/spec/fixtures/media_convert/endpoints.json +1 -0
- data/spec/fixtures/media_convert/job_canceled.json +412 -0
- data/spec/fixtures/media_convert/job_canceling.json +1 -0
- data/spec/fixtures/media_convert/job_completed.json +359 -0
- data/spec/fixtures/media_convert/job_completed_detail.json +1 -0
- data/spec/fixtures/media_convert/job_completed_detail_query.json +1 -0
- data/spec/fixtures/media_convert/job_created.json +408 -0
- data/spec/fixtures/media_convert/job_failed.json +406 -0
- data/spec/fixtures/media_convert/job_progressing.json +414 -0
- data/spec/fixtures/pass_through/cancelled-id/cancelled +0 -0
- data/spec/fixtures/pass_through/cancelled-id/input_metadata +90 -0
- data/spec/fixtures/pass_through/completed-id/completed +0 -0
- data/spec/fixtures/pass_through/completed-id/input_metadata +102 -0
- data/spec/fixtures/pass_through/completed-id/output_metadata-high +90 -0
- data/spec/fixtures/pass_through/completed-id/output_metadata-low +90 -0
- data/spec/fixtures/pass_through/completed-id/video-high.mp4 +0 -0
- data/spec/fixtures/pass_through/completed-id/video-low.mp4 +0 -0
- data/spec/fixtures/pass_through/failed-id/error.log +1 -0
- data/spec/fixtures/pass_through/failed-id/input_metadata +90 -0
- data/spec/fixtures/pass_through/running-id/input_metadata +90 -0
- data/spec/integration/elastic_transcoder_adapter_spec.rb +63 -29
- data/spec/integration/ffmpeg_adapter_spec.rb +96 -24
- data/spec/integration/matterhorn_adapter_spec.rb +45 -44
- data/spec/integration/media_convert_adapter_spec.rb +126 -0
- data/spec/integration/pass_through_adapter_spec.rb +151 -0
- data/spec/integration/zencoder_adapter_spec.rb +210 -209
- data/spec/rails_helper.rb +1 -0
- data/spec/routing/encode_record_controller_routing_spec.rb +10 -0
- data/spec/spec_helper.rb +2 -2
- data/spec/test_app_templates/lib/generators/test_app_generator.rb +13 -12
- data/spec/units/callbacks_spec.rb +3 -2
- data/spec/units/core_spec.rb +26 -25
- data/spec/units/engine_adapter_spec.rb +1 -0
- data/spec/units/file_locator_spec.rb +129 -0
- data/spec/units/global_id_spec.rb +12 -11
- data/spec/units/input_spec.rb +8 -5
- data/spec/units/output_spec.rb +8 -5
- data/spec/units/persistence_spec.rb +15 -11
- data/spec/units/polling_job_spec.rb +7 -6
- data/spec/units/polling_spec.rb +1 -0
- data/spec/units/status_spec.rb +3 -3
- metadata +184 -18
- 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.
|
13
|
-
new_encode.updated_at = Time.
|
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
|
-
|
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
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
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
|
-
|
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
|
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
|
-
|
105
|
+
encode.state = :completed
|
106
|
+
elsif cancelled? encode.id
|
107
|
+
encode.state = :cancelled
|
88
108
|
elsif encode.percent_complete < 100
|
89
|
-
|
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
|
-
|
100
|
-
|
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
|
-
|
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
|
148
|
+
def get_times(id)
|
108
149
|
updated_at = if File.file? working_path("progress", id)
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
|
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
|
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
|
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
|
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
|
-
|
142
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
169
|
-
if
|
170
|
-
File.
|
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
|
-
|
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
|
-
|
182
|
-
|
183
|
-
|
184
|
-
|
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
|
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
|
-
|
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
|
199
|
-
|
200
|
-
|
201
|
-
|
202
|
-
|
203
|
-
|
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?
|
265
|
+
def progress_ended?(id)
|
207
266
|
"end" == progress_value("progress=", read_progress(id))
|
208
267
|
end
|
209
268
|
|
210
|
-
def progress_value
|
211
|
-
|
212
|
-
|
213
|
-
|
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
|
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:
|
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
|
235
|
-
|
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,
|
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
|