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