hls 0.1.0 → 0.2.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 +4 -4
- data/CHANGELOG.md +145 -0
- data/CLAUDE.md +448 -0
- data/README.md +283 -4
- data/examples/directory.rb +16 -42
- data/lib/generators/hls/install/install_generator.rb +42 -0
- data/lib/generators/hls/install/templates/application_video.rb +28 -0
- data/lib/generators/hls/install/templates/initializer.rb +14 -0
- data/lib/generators/hls/video/templates/video.rb +44 -0
- data/lib/generators/hls/video/video_generator.rb +37 -0
- data/lib/hls/application_video.rb +636 -0
- data/lib/hls/cache.rb +31 -0
- data/lib/hls/codecs.rb +94 -0
- data/lib/hls/directory.rb +38 -0
- data/lib/hls/encode_job.rb +44 -0
- data/lib/hls/input.rb +93 -0
- data/lib/hls/instrumentation.rb +40 -0
- data/lib/hls/lock.rb +50 -0
- data/lib/hls/manifest.rb +193 -0
- data/lib/hls/railtie.rb +40 -0
- data/lib/hls/state.rb +106 -0
- data/lib/hls/storage.rb +132 -0
- data/lib/hls/testing.rb +263 -0
- data/lib/hls/uploader.rb +211 -0
- data/lib/hls/version.rb +1 -1
- data/lib/hls.rb +29 -201
- data/plans/00-goal.md +112 -0
- data/plans/01-profile-dsl.md +105 -0
- data/plans/02-upload-pipeline.md +140 -0
- data/plans/03-reader-and-manifest.md +128 -0
- data/plans/04-codec-portability.md +90 -0
- data/plans/05-server-migration.md +126 -0
- data/plans/06-activestorage-adapter.md +255 -0
- metadata +36 -8
|
@@ -0,0 +1,636 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
require "open3"
|
|
6
|
+
require "pathname"
|
|
7
|
+
require "set"
|
|
8
|
+
require "shellwords"
|
|
9
|
+
|
|
10
|
+
require "m3u8"
|
|
11
|
+
|
|
12
|
+
require_relative "codecs"
|
|
13
|
+
require_relative "instrumentation"
|
|
14
|
+
require_relative "lock"
|
|
15
|
+
require_relative "manifest"
|
|
16
|
+
require_relative "state"
|
|
17
|
+
require_relative "uploader"
|
|
18
|
+
|
|
19
|
+
module HLS
|
|
20
|
+
# Base class for declarative HLS video profiles.
|
|
21
|
+
#
|
|
22
|
+
# Subclasses use the class-level DSL to describe their renditions, codec,
|
|
23
|
+
# and storage. An instance binds the profile to a single input file and
|
|
24
|
+
# drives the encode → upload → manifest pipeline.
|
|
25
|
+
#
|
|
26
|
+
# Example:
|
|
27
|
+
#
|
|
28
|
+
# class CourseVideo < HLS::ApplicationVideo
|
|
29
|
+
# bucket "videos"
|
|
30
|
+
# signing_ttl 3600
|
|
31
|
+
# segment_duration 4
|
|
32
|
+
#
|
|
33
|
+
# rendition :full, scale: 1.0
|
|
34
|
+
# rendition :medium, scale: 0.5
|
|
35
|
+
# rendition :small, scale: 0.25
|
|
36
|
+
# end
|
|
37
|
+
#
|
|
38
|
+
# CourseVideo.new(input: HLS::Input.new("lecture.mp4"),
|
|
39
|
+
# output: Pathname.new("tmp/out")).command
|
|
40
|
+
class ApplicationVideo
|
|
41
|
+
PLAYLIST = "index.m3u8"
|
|
42
|
+
|
|
43
|
+
Rendition = Data.define(:width, :height, :bitrate)
|
|
44
|
+
|
|
45
|
+
# A class-level rendition declaration. Resolved against an input at
|
|
46
|
+
# instance time to a concrete Rendition.
|
|
47
|
+
Declaration = Data.define(:name, :scale, :width, :height, :bitrate) do
|
|
48
|
+
def scaled? = !scale.nil?
|
|
49
|
+
|
|
50
|
+
def resolve(input:, bits_per_pixel:, max_bitrate_kbps:)
|
|
51
|
+
if scaled?
|
|
52
|
+
w = (input.width * scale).floor
|
|
53
|
+
h = (input.height * scale).floor
|
|
54
|
+
Rendition.new(width: w, height: h, bitrate: estimate_bitrate(w, h, bits_per_pixel, max_bitrate_kbps))
|
|
55
|
+
else
|
|
56
|
+
Rendition.new(width: width, height: height, bitrate: bitrate)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def estimate_bitrate(width, height, bits_per_pixel, max_kbps)
|
|
63
|
+
raw_kbps = (width * height * bits_per_pixel) / 1000.0
|
|
64
|
+
# Round up to the nearest 100kbps, then cap.
|
|
65
|
+
rounded = (raw_kbps / 100.0).ceil * 100
|
|
66
|
+
[rounded, max_kbps].min
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# A poster declaration. Resolved against an input at instance time
|
|
71
|
+
# to a concrete dimension pair. Filename is `<name>.jpg`.
|
|
72
|
+
PosterDeclaration = Data.define(:name, :scale, :width, :height) do
|
|
73
|
+
def filename
|
|
74
|
+
"#{name}.jpg"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def resolve(input:)
|
|
78
|
+
if scale
|
|
79
|
+
w = (input.width * scale).floor
|
|
80
|
+
h = (input.height * scale).floor
|
|
81
|
+
[w, h]
|
|
82
|
+
else
|
|
83
|
+
[width, height]
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Friendly names for the bits-per-pixel ratios. Subclasses may also
|
|
89
|
+
# pass an integer directly.
|
|
90
|
+
BITS_PER_PIXEL = {
|
|
91
|
+
screencast: 3, # Static content: presentations, tutorials, minimal motion
|
|
92
|
+
mixed: 4, # Moderate motion: typical web videos, interviews
|
|
93
|
+
motion: 6 # High motion: action videos, sports, fast-paced content
|
|
94
|
+
}.freeze
|
|
95
|
+
|
|
96
|
+
UNSET = Object.new.freeze
|
|
97
|
+
private_constant :UNSET
|
|
98
|
+
|
|
99
|
+
class << self
|
|
100
|
+
# Defines a class-level inheritable attribute. Subclasses fall back to
|
|
101
|
+
# the parent's value until they set their own. Reads with no args,
|
|
102
|
+
# writes with one.
|
|
103
|
+
def class_setting(name, default: nil, coerce: nil)
|
|
104
|
+
ivar = :"@#{name}"
|
|
105
|
+
|
|
106
|
+
define_singleton_method(name) do |value = UNSET|
|
|
107
|
+
if UNSET.equal?(value)
|
|
108
|
+
if instance_variable_defined?(ivar)
|
|
109
|
+
instance_variable_get(ivar)
|
|
110
|
+
elsif superclass.respond_to?(name)
|
|
111
|
+
superclass.public_send(name)
|
|
112
|
+
else
|
|
113
|
+
default
|
|
114
|
+
end
|
|
115
|
+
else
|
|
116
|
+
instance_variable_set(ivar, coerce ? coerce.call(value) : value)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Renditions declared on this class. On first access in a subclass,
|
|
122
|
+
# we initialize from the parent's list so subclasses inherit
|
|
123
|
+
# naturally without an `inherited` hook reaching into them.
|
|
124
|
+
def renditions
|
|
125
|
+
@renditions ||= superclass.respond_to?(:renditions) ? superclass.renditions.dup : []
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Declare a rendition. Two forms:
|
|
129
|
+
#
|
|
130
|
+
# rendition :name, scale: 0.5
|
|
131
|
+
# rendition width: 1280, height: 720, bitrate: 1500
|
|
132
|
+
def rendition(name = nil, scale: nil, width: nil, height: nil, bitrate: nil)
|
|
133
|
+
if scale.nil? && (width.nil? || height.nil? || bitrate.nil?)
|
|
134
|
+
raise ArgumentError,
|
|
135
|
+
"rendition requires either `scale:` or all of `width:`, `height:`, `bitrate:`"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
renditions << Declaration.new(
|
|
139
|
+
name: name, scale: scale,
|
|
140
|
+
width: width, height: height, bitrate: bitrate
|
|
141
|
+
)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Replace any inherited rendition declarations with a fresh list.
|
|
145
|
+
# Useful when a subclass needs to start over rather than extend the
|
|
146
|
+
# parent's renditions.
|
|
147
|
+
def reset_renditions!
|
|
148
|
+
@renditions = []
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Posters declared on this class. Same lazy-inherit pattern as
|
|
152
|
+
# `renditions` — see the comment there.
|
|
153
|
+
def posters
|
|
154
|
+
@posters ||= superclass.respond_to?(:posters) ? superclass.posters.dup : []
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Declare a poster image. Two forms:
|
|
158
|
+
#
|
|
159
|
+
# poster :hero, scale: 1.0
|
|
160
|
+
# poster :thumbnail, width: 320, height: 180
|
|
161
|
+
#
|
|
162
|
+
# Each declaration produces `<name>.jpg` in the output directory.
|
|
163
|
+
# If no posters are declared, the encode step produces no posters.
|
|
164
|
+
def poster(name, scale: nil, width: nil, height: nil)
|
|
165
|
+
if scale.nil? && (width.nil? || height.nil?)
|
|
166
|
+
raise ArgumentError,
|
|
167
|
+
"poster requires either `scale:` or both `width:` and `height:`"
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
posters << PosterDeclaration.new(
|
|
171
|
+
name: name, scale: scale, width: width, height: height
|
|
172
|
+
)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def reset_posters!
|
|
176
|
+
@posters = []
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Returns a read-side Manifest bound to this profile's storage.
|
|
180
|
+
# The host app's controller uses this to serve signed playlists.
|
|
181
|
+
#
|
|
182
|
+
# CourseVideo.manifest("phlex/forms/overview").master_playlist
|
|
183
|
+
def manifest(path, cache: self.cache)
|
|
184
|
+
Manifest.new(
|
|
185
|
+
storage: storage_or_raise,
|
|
186
|
+
path: path,
|
|
187
|
+
segment_duration: segment_duration,
|
|
188
|
+
variant_uri: method(:variant_uri),
|
|
189
|
+
cache: cache
|
|
190
|
+
)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Maps a manifest's S3 path + ffmpeg variant index to the URI that
|
|
194
|
+
# appears in the master playlist for that variant. Override on a
|
|
195
|
+
# subclass to fit a non-default URL scheme:
|
|
196
|
+
#
|
|
197
|
+
# class CustomVideo < HLS::ApplicationVideo
|
|
198
|
+
# def self.variant_uri(path:, variant_index:)
|
|
199
|
+
# "/streams/#{path}/v/#{variant_index}.m3u8"
|
|
200
|
+
# end
|
|
201
|
+
# end
|
|
202
|
+
#
|
|
203
|
+
# The default returns `<basename(path)>/<variant_index>.m3u8`,
|
|
204
|
+
# which is relative to the master playlist's URL and matches a
|
|
205
|
+
# `/videos/*path/:id/:variant.m3u8` Rails route.
|
|
206
|
+
def variant_uri(path:, variant_index:)
|
|
207
|
+
"#{::File.basename(path)}/#{variant_index}.m3u8"
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Returns the configured storage, or raises a helpful error.
|
|
211
|
+
def storage_or_raise
|
|
212
|
+
configured = storage
|
|
213
|
+
return configured unless configured.nil?
|
|
214
|
+
|
|
215
|
+
raise ArgumentError,
|
|
216
|
+
"#{name || self} has no storage configured. Override `def self.storage` " \
|
|
217
|
+
"in the profile class with an HLS::Storage::S3 (or any object responding " \
|
|
218
|
+
"to #object and #signing_ttl)."
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# The storage backend (HLS::Storage::S3 or any conforming adapter).
|
|
223
|
+
# Override per-profile with:
|
|
224
|
+
#
|
|
225
|
+
# class ApplicationVideo < HLS::ApplicationVideo
|
|
226
|
+
# def self.storage = HLS::Storage::S3.new(
|
|
227
|
+
# bucket_name: ENV.fetch("VIDEO_S3_BUCKET_NAME"),
|
|
228
|
+
# signing_ttl: 1.hour
|
|
229
|
+
# )
|
|
230
|
+
# end
|
|
231
|
+
class_setting :storage
|
|
232
|
+
class_setting :segment_duration, default: 4
|
|
233
|
+
class_setting :audio_codec, default: "aac"
|
|
234
|
+
class_setting :audio_bitrate, default: 128
|
|
235
|
+
class_setting :video_codec, default: :h264
|
|
236
|
+
class_setting :max_bitrate_kbps, default: 15_000
|
|
237
|
+
# Hard cap (in seconds) on a single ffmpeg invocation. nil disables.
|
|
238
|
+
# On timeout the process gets SIGTERM, then SIGKILL after a grace
|
|
239
|
+
# period, and HLS::Error is raised. Tune this to roughly 2-3× the
|
|
240
|
+
# longest video you intend to encode.
|
|
241
|
+
class_setting :ffmpeg_timeout, default: nil
|
|
242
|
+
|
|
243
|
+
# Optional read-side playlist cache. An HLS::Cache groups a backend
|
|
244
|
+
# (e.g. Rails.cache) with a TTL. Anything responding to
|
|
245
|
+
# `fetch(key, &block)` also works directly — the wrapper is just
|
|
246
|
+
# the convenience for backends that need a TTL hint.
|
|
247
|
+
#
|
|
248
|
+
# def self.cache = HLS::Cache.new(backend: Rails.cache, ttl: 5.minutes)
|
|
249
|
+
class_setting :cache, default: nil
|
|
250
|
+
class_setting :bits_per_pixel,
|
|
251
|
+
default: BITS_PER_PIXEL.fetch(:mixed),
|
|
252
|
+
coerce: ->(v) { v.is_a?(Symbol) ? BITS_PER_PIXEL.fetch(v) : Integer(v) }
|
|
253
|
+
|
|
254
|
+
attr_reader :input, :output, :key_prefix
|
|
255
|
+
|
|
256
|
+
# input:: An HLS::Input (or anything that responds to width/height/path)
|
|
257
|
+
# output:: Pathname for the local working directory ffmpeg writes to
|
|
258
|
+
# key_prefix:: Where the bundle lives in the bucket. Defaults to the
|
|
259
|
+
# output directory's basename.
|
|
260
|
+
def initialize(input:, output:, key_prefix: nil)
|
|
261
|
+
@input = input
|
|
262
|
+
@output = Pathname.new(output)
|
|
263
|
+
@key_prefix = key_prefix || @output.basename.to_s
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# Run the full pipeline: encode + posters (if input changed or
|
|
267
|
+
# output is missing) then upload to the bucket. Idempotent —
|
|
268
|
+
# re-running with an unchanged input + intact output is a no-op.
|
|
269
|
+
#
|
|
270
|
+
# Returns the uploader's result hash: `{ uploaded: N, skipped: N }`.
|
|
271
|
+
def process
|
|
272
|
+
output.mkpath
|
|
273
|
+
result = nil
|
|
274
|
+
HLS::Instrumentation.instrument(:process,
|
|
275
|
+
profile: self.class.name, output: output.to_s, key_prefix: key_prefix
|
|
276
|
+
) do |payload|
|
|
277
|
+
HLS::Lock.acquire(output) do
|
|
278
|
+
state = HLS::State.load(output)
|
|
279
|
+
|
|
280
|
+
unless encoded?(state)
|
|
281
|
+
encode!
|
|
282
|
+
poster! if self.class.posters.any?
|
|
283
|
+
verify_encode!
|
|
284
|
+
state.record_encode(
|
|
285
|
+
input_digest: input_digest,
|
|
286
|
+
config_digest: config_digest,
|
|
287
|
+
profile: self.class.name,
|
|
288
|
+
renditions: renditions.map(&:to_h)
|
|
289
|
+
)
|
|
290
|
+
state.save
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
result = HLS::Uploader.new(
|
|
294
|
+
storage: self.class.storage_or_raise,
|
|
295
|
+
output: output,
|
|
296
|
+
key_prefix: key_prefix,
|
|
297
|
+
state: state
|
|
298
|
+
).perform
|
|
299
|
+
|
|
300
|
+
payload&.merge!(result) if payload
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
result
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
# Walks the just-encoded output directory and asserts the bundle is
|
|
307
|
+
# well-formed: master + variants + segments + declared posters all
|
|
308
|
+
# exist and are non-empty. Raises HLS::Error with a list of problems
|
|
309
|
+
# if anything is missing — better to fail before recording state and
|
|
310
|
+
# uploading than to leave a half-written bundle on the bucket.
|
|
311
|
+
def verify_encode!
|
|
312
|
+
HLS::Instrumentation.instrument(:verify, profile: self.class.name, output: output.to_s) do
|
|
313
|
+
problems = []
|
|
314
|
+
|
|
315
|
+
master = output.join(PLAYLIST)
|
|
316
|
+
unless master.exist?
|
|
317
|
+
raise HLS::Error, "encode produced no master playlist at #{master}"
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
master_list = M3u8::Reader.new.read(master.read)
|
|
321
|
+
if master_list.items.empty?
|
|
322
|
+
problems << "master playlist has no variant streams"
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
master_list.items.each do |variant_item|
|
|
326
|
+
variant_path = output.join(variant_item.uri)
|
|
327
|
+
unless variant_path.exist?
|
|
328
|
+
problems << "variant playlist missing: #{variant_item.uri}"
|
|
329
|
+
next
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
variant_list = M3u8::Reader.new.read(variant_path.read)
|
|
333
|
+
if variant_list.items.empty?
|
|
334
|
+
problems << "variant #{variant_item.uri} has no segments"
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
variant_list.items.each do |segment_item|
|
|
338
|
+
segment_path = variant_path.dirname.join(segment_item.segment)
|
|
339
|
+
unless segment_path.exist? && segment_path.size > 0
|
|
340
|
+
problems << "segment missing or empty: #{variant_item.uri} → #{segment_item.segment}"
|
|
341
|
+
end
|
|
342
|
+
end
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
self.class.posters.each do |declaration|
|
|
346
|
+
poster_path = output.join(declaration.filename)
|
|
347
|
+
unless poster_path.exist? && poster_path.size > 0
|
|
348
|
+
problems << "declared poster missing or empty: #{declaration.filename}"
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
next if problems.empty?
|
|
353
|
+
|
|
354
|
+
raise HLS::Error,
|
|
355
|
+
"encode produced an invalid bundle at #{output}:\n - " + problems.join("\n - ")
|
|
356
|
+
end
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
# Runs ffmpeg to produce the HLS multiplex. Raises on non-zero exit.
|
|
360
|
+
def encode!
|
|
361
|
+
input.validate! if input.respond_to?(:validate!)
|
|
362
|
+
HLS::Instrumentation.instrument(:encode,
|
|
363
|
+
profile: self.class.name,
|
|
364
|
+
output: output.to_s,
|
|
365
|
+
renditions: renditions.map(&:to_h)
|
|
366
|
+
) do
|
|
367
|
+
run_ffmpeg(command)
|
|
368
|
+
end
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
# Runs ffmpeg to produce all declared posters in one decode pass.
|
|
372
|
+
# No-op when no posters are declared.
|
|
373
|
+
def poster!
|
|
374
|
+
return if self.class.posters.empty?
|
|
375
|
+
input.validate! if input.respond_to?(:validate!)
|
|
376
|
+
HLS::Instrumentation.instrument(:poster,
|
|
377
|
+
profile: self.class.name,
|
|
378
|
+
output: output.to_s,
|
|
379
|
+
count: self.class.posters.size
|
|
380
|
+
) do
|
|
381
|
+
run_ffmpeg(poster_command)
|
|
382
|
+
end
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
# ffmpeg command that produces all declared posters in one decode pass.
|
|
386
|
+
def poster_command
|
|
387
|
+
cmd = ["ffmpeg", "-y", "-i", input.path.to_s]
|
|
388
|
+
self.class.posters.each do |declaration|
|
|
389
|
+
w, h = declaration.resolve(input: input)
|
|
390
|
+
cmd += [
|
|
391
|
+
"-vf", "scale=w=#{w}:h=#{h}:force_original_aspect_ratio=decrease",
|
|
392
|
+
"-frames:v", "1",
|
|
393
|
+
output.join(declaration.filename).to_s
|
|
394
|
+
]
|
|
395
|
+
end
|
|
396
|
+
cmd
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
# SHA256 digest of the input file. Used by the state sidecar to detect
|
|
400
|
+
# when the source has changed.
|
|
401
|
+
def input_digest
|
|
402
|
+
@input_digest ||= "sha256:#{Digest::SHA256.file(input.path.to_s).hexdigest}"
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
# SHA256 digest of the encode-affecting profile config — settings
|
|
406
|
+
# and DSL declarations that change the output bytes ffmpeg writes.
|
|
407
|
+
# Combined with `input_digest` to decide whether `process` can skip
|
|
408
|
+
# the encode step. Bumping `audio_bitrate`, swapping a codec, or
|
|
409
|
+
# adding a rendition changes this digest and forces a re-encode.
|
|
410
|
+
#
|
|
411
|
+
# Excluded: settings that don't change the encoded bytes
|
|
412
|
+
# (`storage`, `cache`, `ffmpeg_timeout`, `variant_uri`).
|
|
413
|
+
def config_digest
|
|
414
|
+
@config_digest ||= begin
|
|
415
|
+
# Sort keys at every level so the serialized form is stable
|
|
416
|
+
# regardless of insertion order or JSON.generate's
|
|
417
|
+
# implementation. Without this, a future Ruby tweak to hash
|
|
418
|
+
# ordering would invalidate every previously-recorded digest
|
|
419
|
+
# and trigger a spurious re-encode of every video at once.
|
|
420
|
+
payload = {
|
|
421
|
+
audio_bitrate: self.class.audio_bitrate,
|
|
422
|
+
audio_codec: self.class.audio_codec,
|
|
423
|
+
bits_per_pixel: self.class.bits_per_pixel,
|
|
424
|
+
max_bitrate_kbps: self.class.max_bitrate_kbps,
|
|
425
|
+
posters: self.class.posters.map { |p| p.to_h.sort.to_h },
|
|
426
|
+
renditions: self.class.renditions.map { |r| r.to_h.sort.to_h },
|
|
427
|
+
segment_duration: self.class.segment_duration,
|
|
428
|
+
video_codec: self.class.video_codec.to_s
|
|
429
|
+
}
|
|
430
|
+
"sha256:#{Digest::SHA256.hexdigest(JSON.generate(payload))}"
|
|
431
|
+
end
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
# Renditions resolved against this instance's input.
|
|
435
|
+
def renditions
|
|
436
|
+
@renditions ||= self.class.renditions.map do |declaration|
|
|
437
|
+
declaration.resolve(
|
|
438
|
+
input: input,
|
|
439
|
+
bits_per_pixel: self.class.bits_per_pixel,
|
|
440
|
+
max_bitrate_kbps: self.class.max_bitrate_kbps
|
|
441
|
+
)
|
|
442
|
+
end
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
# Renditions whose width fits within the input. We never upscale.
|
|
446
|
+
def downscaleable_renditions
|
|
447
|
+
renditions.select { |r| r.width <= input.width }
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
def exist?
|
|
451
|
+
output.join(PLAYLIST).exist?
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
def command
|
|
455
|
+
[
|
|
456
|
+
"ffmpeg",
|
|
457
|
+
"-y",
|
|
458
|
+
"-i", input.path.to_s,
|
|
459
|
+
"-filter_complex", filter_complex
|
|
460
|
+
] + video_maps + audio_maps + [
|
|
461
|
+
"-f", "hls",
|
|
462
|
+
"-var_stream_map", stream_map,
|
|
463
|
+
"-master_pl_name", PLAYLIST,
|
|
464
|
+
"-hls_time", self.class.segment_duration.to_s,
|
|
465
|
+
"-hls_playlist_type", "vod",
|
|
466
|
+
"-hls_segment_filename", segment_pattern,
|
|
467
|
+
playlist_pattern
|
|
468
|
+
]
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
private
|
|
472
|
+
|
|
473
|
+
# An "encoded" state requires the sidecar saying so AND the
|
|
474
|
+
# master playlist actually being on disk. Without the file check
|
|
475
|
+
# we'd happily skip encode and try to upload nothing if someone
|
|
476
|
+
# wiped the output dir but left state.json behind. The sidecar
|
|
477
|
+
# check covers both input and profile config — bumping a setting
|
|
478
|
+
# like `audio_bitrate` invalidates the encode even if the input
|
|
479
|
+
# is byte-identical.
|
|
480
|
+
def encoded?(state)
|
|
481
|
+
state.encoded?(input_digest: input_digest, config_digest: config_digest) &&
|
|
482
|
+
output.join(PLAYLIST).exist?
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
# Maximum number of stderr characters preserved in the error message.
|
|
486
|
+
# ffmpeg's stderr can be hundreds of KB on a real failure; the tail is
|
|
487
|
+
# where the actual error lives.
|
|
488
|
+
FFMPEG_STDERR_TAIL = 2_000
|
|
489
|
+
private_constant :FFMPEG_STDERR_TAIL
|
|
490
|
+
|
|
491
|
+
# Seconds between SIGTERM and SIGKILL when timing out a stuck ffmpeg.
|
|
492
|
+
FFMPEG_KILL_GRACE = 3
|
|
493
|
+
private_constant :FFMPEG_KILL_GRACE
|
|
494
|
+
|
|
495
|
+
def run_ffmpeg(args)
|
|
496
|
+
output.mkpath
|
|
497
|
+
cmd = args.map(&:to_s)
|
|
498
|
+
timeout = self.class.ffmpeg_timeout
|
|
499
|
+
|
|
500
|
+
stderr_text, status, timed_out = capture_with_timeout(cmd, timeout: timeout)
|
|
501
|
+
return if status&.success?
|
|
502
|
+
|
|
503
|
+
tail = stderr_text.to_s.strip
|
|
504
|
+
tail = "...#{tail[-FFMPEG_STDERR_TAIL..]}" if tail.length > FFMPEG_STDERR_TAIL
|
|
505
|
+
|
|
506
|
+
if timed_out
|
|
507
|
+
raise HLS::Error,
|
|
508
|
+
"ffmpeg timed out after #{timeout}s: #{Shellwords.join(cmd)}\n" \
|
|
509
|
+
"stderr:\n#{tail}"
|
|
510
|
+
end
|
|
511
|
+
|
|
512
|
+
raise HLS::Error,
|
|
513
|
+
"ffmpeg failed (exit #{status&.exitstatus}): #{Shellwords.join(cmd)}\n" \
|
|
514
|
+
"stderr:\n#{tail}"
|
|
515
|
+
end
|
|
516
|
+
|
|
517
|
+
# Runs cmd, capturing stderr. With a non-nil timeout, kills the
|
|
518
|
+
# process if it overruns. Returns [stderr, status, timed_out].
|
|
519
|
+
def capture_with_timeout(cmd, timeout:)
|
|
520
|
+
Open3.popen3(*cmd) do |stdin, stdout, stderr, wait_thr|
|
|
521
|
+
stdin.close
|
|
522
|
+
# Drain stdout in a background thread so a chatty ffmpeg can't
|
|
523
|
+
# block on a full pipe buffer.
|
|
524
|
+
stdout_thread = Thread.new { stdout.read }
|
|
525
|
+
stderr_thread = Thread.new { stderr.read }
|
|
526
|
+
|
|
527
|
+
if timeout && !wait_thr.join(timeout)
|
|
528
|
+
terminate_pid(wait_thr.pid)
|
|
529
|
+
stdout_thread.kill
|
|
530
|
+
stderr_thread.kill
|
|
531
|
+
return [stderr_thread.value.to_s, wait_thr.value, true]
|
|
532
|
+
end
|
|
533
|
+
|
|
534
|
+
[stderr_thread.value, wait_thr.value, false]
|
|
535
|
+
end
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
def terminate_pid(pid)
|
|
539
|
+
Process.kill("TERM", pid)
|
|
540
|
+
deadline = Time.now + FFMPEG_KILL_GRACE
|
|
541
|
+
until Time.now > deadline
|
|
542
|
+
return if Process.waitpid(pid, Process::WNOHANG)
|
|
543
|
+
sleep 0.1
|
|
544
|
+
end
|
|
545
|
+
Process.kill("KILL", pid)
|
|
546
|
+
Process.waitpid(pid)
|
|
547
|
+
rescue Errno::ESRCH, Errno::ECHILD
|
|
548
|
+
# Process already gone — nothing to do.
|
|
549
|
+
end
|
|
550
|
+
|
|
551
|
+
def filter_complex
|
|
552
|
+
n = downscaleable_renditions.size
|
|
553
|
+
split = "[0:v]split=#{n}#{(1..n).map { |i| "[v#{i}]" }.join}"
|
|
554
|
+
scaled = downscaleable_renditions.each_with_index.map do |rendition, i|
|
|
555
|
+
"[v#{i + 1}]scale='if(gt(iw,#{rendition.width}),#{rendition.width},iw)':'if(gt(iw,#{rendition.width}),-2,ih)'[v#{i + 1}out]"
|
|
556
|
+
end
|
|
557
|
+
([split] + scaled).join("; ")
|
|
558
|
+
end
|
|
559
|
+
|
|
560
|
+
def resolved_video_codec
|
|
561
|
+
@resolved_video_codec ||= HLS::Codecs.resolve(self.class.video_codec)
|
|
562
|
+
end
|
|
563
|
+
|
|
564
|
+
def video_maps
|
|
565
|
+
codec = resolved_video_codec
|
|
566
|
+
gop = gop_size
|
|
567
|
+
downscaleable_renditions.each_with_index.flat_map do |rendition, i|
|
|
568
|
+
[
|
|
569
|
+
"-map", "[v#{i + 1}out]",
|
|
570
|
+
"-c:v:#{i}", codec,
|
|
571
|
+
"-b:v:#{i}", "#{rendition.bitrate}k",
|
|
572
|
+
"-maxrate:v:#{i}", "#{(rendition.bitrate * 1.1).to_i}k",
|
|
573
|
+
"-bufsize:v:#{i}", "#{(rendition.bitrate * 2).to_i}k",
|
|
574
|
+
"-g", gop.to_s,
|
|
575
|
+
"-keyint_min", gop.to_s,
|
|
576
|
+
"-sc_threshold", "0"
|
|
577
|
+
] + video_codec_options(codec, i)
|
|
578
|
+
end
|
|
579
|
+
end
|
|
580
|
+
|
|
581
|
+
# GOP size = framerate × segment_duration. This forces a keyframe
|
|
582
|
+
# exactly at every segment boundary, which is required for HLS
|
|
583
|
+
# players to seek to a segment without buffering a stray P/B-frame
|
|
584
|
+
# chain. Without this scaling, a custom segment_duration (e.g., 2s
|
|
585
|
+
# or 6s) would produce segments that don't start with a keyframe and
|
|
586
|
+
# players would stall on seeks.
|
|
587
|
+
def gop_size
|
|
588
|
+
fps = input.respond_to?(:framerate) ? input.framerate : Input::DEFAULT_FRAMERATE
|
|
589
|
+
fps * self.class.segment_duration
|
|
590
|
+
end
|
|
591
|
+
|
|
592
|
+
def video_codec_options(codec, index)
|
|
593
|
+
case codec.to_s
|
|
594
|
+
when "h264_videotoolbox"
|
|
595
|
+
[]
|
|
596
|
+
when "libx264"
|
|
597
|
+
[
|
|
598
|
+
"-profile:v:#{index}", "high",
|
|
599
|
+
"-level:v:#{index}", "4.1",
|
|
600
|
+
"-preset:v:#{index}", "slow"
|
|
601
|
+
]
|
|
602
|
+
else
|
|
603
|
+
[]
|
|
604
|
+
end
|
|
605
|
+
end
|
|
606
|
+
|
|
607
|
+
def audio_maps
|
|
608
|
+
codec = self.class.audio_codec
|
|
609
|
+
bitrate = self.class.audio_bitrate
|
|
610
|
+
downscaleable_renditions.each_with_index.flat_map do |_, i|
|
|
611
|
+
[
|
|
612
|
+
"-map", "a:0",
|
|
613
|
+
"-c:a:#{i}", codec,
|
|
614
|
+
"-b:a:#{i}", "#{bitrate}k",
|
|
615
|
+
"-ac", "2"
|
|
616
|
+
]
|
|
617
|
+
end
|
|
618
|
+
end
|
|
619
|
+
|
|
620
|
+
def stream_map
|
|
621
|
+
downscaleable_renditions.each_index.map { |i| "v:#{i},a:#{i}" }.join(" ")
|
|
622
|
+
end
|
|
623
|
+
|
|
624
|
+
def variant_dir
|
|
625
|
+
output.join("%v")
|
|
626
|
+
end
|
|
627
|
+
|
|
628
|
+
def segment_pattern
|
|
629
|
+
variant_dir.join("%d.ts").to_s
|
|
630
|
+
end
|
|
631
|
+
|
|
632
|
+
def playlist_pattern
|
|
633
|
+
variant_dir.join(PLAYLIST).to_s
|
|
634
|
+
end
|
|
635
|
+
end
|
|
636
|
+
end
|
data/lib/hls/cache.rb
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module HLS
|
|
4
|
+
# Wraps a Rails.cache-shaped backend with a TTL so the read-side
|
|
5
|
+
# Manifest can call `cache.fetch(key) { ... }` without plumbing the
|
|
6
|
+
# TTL alongside the backend at every call site.
|
|
7
|
+
#
|
|
8
|
+
# Profile classes attach an instance via `cache`:
|
|
9
|
+
#
|
|
10
|
+
# class CourseVideo < ApplicationVideo
|
|
11
|
+
# def self.cache = HLS::Cache.new(backend: Rails.cache, ttl: 5.minutes)
|
|
12
|
+
# end
|
|
13
|
+
#
|
|
14
|
+
# The Manifest also accepts any object responding to
|
|
15
|
+
# `fetch(key, &block)` directly — wrapping is just the convenience
|
|
16
|
+
# path for backends like Rails.cache that need a TTL hint.
|
|
17
|
+
class Cache
|
|
18
|
+
DEFAULT_TTL = 300
|
|
19
|
+
|
|
20
|
+
attr_reader :backend, :ttl
|
|
21
|
+
|
|
22
|
+
def initialize(backend:, ttl: DEFAULT_TTL)
|
|
23
|
+
@backend = backend
|
|
24
|
+
@ttl = Integer(ttl)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def fetch(key, &block)
|
|
28
|
+
backend.fetch(key, expires_in: ttl, &block)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|