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
data/lib/hls/codecs.rb
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require "set"
|
|
5
|
+
|
|
6
|
+
module HLS
|
|
7
|
+
# Codec resolution for ffmpeg.
|
|
8
|
+
#
|
|
9
|
+
# A profile declares its codec in one of three forms:
|
|
10
|
+
#
|
|
11
|
+
# video_codec :h264 # logical, auto-resolved per host
|
|
12
|
+
# video_codec "libx264" # explicit ffmpeg encoder name
|
|
13
|
+
# video_codec "h264_nvenc" # explicit (cloud GPU)
|
|
14
|
+
#
|
|
15
|
+
# When the profile asks for a logical codec, this module picks the best
|
|
16
|
+
# available encoder for the current host by consulting the ffmpeg
|
|
17
|
+
# encoder list. The list is queried once and cached.
|
|
18
|
+
module Codecs
|
|
19
|
+
H264 = {
|
|
20
|
+
videotoolbox: "h264_videotoolbox", # macOS hardware
|
|
21
|
+
nvenc: "h264_nvenc", # NVIDIA GPU
|
|
22
|
+
qsv: "h264_qsv", # Intel QuickSync
|
|
23
|
+
libx264: "libx264" # software fallback
|
|
24
|
+
}.freeze
|
|
25
|
+
|
|
26
|
+
# Per-platform priority order for the :h264 logical codec. The first
|
|
27
|
+
# encoder available on the host wins.
|
|
28
|
+
H264_PRIORITY = {
|
|
29
|
+
darwin: [:videotoolbox, :libx264],
|
|
30
|
+
linux: [:nvenc, :qsv, :libx264]
|
|
31
|
+
}.freeze
|
|
32
|
+
|
|
33
|
+
class UnknownEncoder < StandardError; end
|
|
34
|
+
|
|
35
|
+
module_function
|
|
36
|
+
|
|
37
|
+
# Resolves a `video_codec` value to an explicit ffmpeg encoder name.
|
|
38
|
+
def resolve(value)
|
|
39
|
+
case value
|
|
40
|
+
when :h264
|
|
41
|
+
resolve_h264
|
|
42
|
+
when Symbol
|
|
43
|
+
# Already a specific variant: :libx264, :nvenc, etc.
|
|
44
|
+
H264.fetch(value) { raise UnknownEncoder, "Unknown codec symbol: #{value}" }
|
|
45
|
+
when String
|
|
46
|
+
value
|
|
47
|
+
else
|
|
48
|
+
raise ArgumentError, "Unsupported video_codec value: #{value.inspect}"
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# The set of encoders ffmpeg reports as available on this host. Cached
|
|
53
|
+
# for the life of the process.
|
|
54
|
+
def available_encoders
|
|
55
|
+
@available_encoders ||= probe_encoders
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Reset the encoder cache. Useful in tests.
|
|
59
|
+
def reset!
|
|
60
|
+
@available_encoders = nil
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def resolve_h264
|
|
64
|
+
priority = H264_PRIORITY[platform] || [:libx264]
|
|
65
|
+
encoder_name = priority
|
|
66
|
+
.map { |sym| H264.fetch(sym) }
|
|
67
|
+
.find { |name| available_encoders.include?(name) }
|
|
68
|
+
|
|
69
|
+
encoder_name || "libx264"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def platform
|
|
73
|
+
case RbConfig::CONFIG["host_os"]
|
|
74
|
+
when /darwin/ then :darwin
|
|
75
|
+
when /linux/ then :linux
|
|
76
|
+
when /mswin|mingw|cygwin/ then :windows
|
|
77
|
+
else :unknown
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def probe_encoders
|
|
82
|
+
stdout, _stderr, status = Open3.capture3("ffmpeg", "-hide_banner", "-encoders")
|
|
83
|
+
return Set.new unless status.success?
|
|
84
|
+
|
|
85
|
+
stdout.lines.filter_map do |line|
|
|
86
|
+
# Encoder lines look like: V..... libx264 ...
|
|
87
|
+
# Skip the header and metadata lines.
|
|
88
|
+
next unless line =~ /\A [\sVAS\.][\sFSXBD\.]{5}\s+(\S+)/
|
|
89
|
+
|
|
90
|
+
$1
|
|
91
|
+
end.to_set
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
|
|
5
|
+
module HLS
|
|
6
|
+
# Walks a source directory and yields `(input, relative_output_dir)`
|
|
7
|
+
# pairs for each matching file. The relative output dir drops the
|
|
8
|
+
# source's extension so callers can join it onto a destination root.
|
|
9
|
+
#
|
|
10
|
+
# Example:
|
|
11
|
+
#
|
|
12
|
+
# HLS::Directory.new("uploads").glob("**/*.mp4").each do |input, output|
|
|
13
|
+
# # input is an HLS::Input
|
|
14
|
+
# # output is a Pathname like "course/lecture-01" (no extension)
|
|
15
|
+
# end
|
|
16
|
+
class Directory
|
|
17
|
+
include Enumerable
|
|
18
|
+
|
|
19
|
+
def initialize(source)
|
|
20
|
+
@source = Pathname.new(source)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def glob(pattern)
|
|
24
|
+
@pattern = pattern
|
|
25
|
+
self
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def each(&block)
|
|
29
|
+
raise ArgumentError, "call .glob(pattern) before iterating" unless @pattern
|
|
30
|
+
|
|
31
|
+
@source.glob(@pattern).each do |path|
|
|
32
|
+
relative = path.relative_path_from(@source)
|
|
33
|
+
output = relative.dirname.join(relative.basename(path.extname))
|
|
34
|
+
yield Input.new(path), output
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Loaded by the Railtie when ActiveJob is available. Plain-Ruby usage of
|
|
4
|
+
# the gem doesn't pull this in.
|
|
5
|
+
return unless defined?(ActiveJob)
|
|
6
|
+
|
|
7
|
+
module HLS
|
|
8
|
+
# ActiveJob wrapper that runs a profile's full pipeline (encode + upload).
|
|
9
|
+
#
|
|
10
|
+
# Enqueue with:
|
|
11
|
+
#
|
|
12
|
+
# HLS::EncodeJob.perform_later(
|
|
13
|
+
# profile: "CourseVideo",
|
|
14
|
+
# input: "/path/to/source.mp4",
|
|
15
|
+
# output: "/path/to/working/dir",
|
|
16
|
+
# key_prefix: "phlex/forms/overview"
|
|
17
|
+
# )
|
|
18
|
+
class EncodeJob < ActiveJob::Base
|
|
19
|
+
queue_as { ENV.fetch("HLS_QUEUE", "default") }
|
|
20
|
+
|
|
21
|
+
# Another worker is encoding the same output dir. Retrying just
|
|
22
|
+
# means we'll bump heads with them again — the lock is released
|
|
23
|
+
# exactly when their work finishes, and at that point the state
|
|
24
|
+
# sidecar will say `encoded?`, so the original caller can simply
|
|
25
|
+
# re-enqueue if they care to verify.
|
|
26
|
+
discard_on HLS::Lock::Busy
|
|
27
|
+
|
|
28
|
+
# Malformed state.json is operator-intervention territory: retrying
|
|
29
|
+
# silently re-runs the encode and re-corrupts. Surface it to the
|
|
30
|
+
# dead-letter queue so someone notices.
|
|
31
|
+
discard_on HLS::State::CorruptError
|
|
32
|
+
|
|
33
|
+
def perform(profile:, input:, output:, key_prefix: nil)
|
|
34
|
+
profile_class = profile.is_a?(Class) ? profile : profile.constantize
|
|
35
|
+
input_obj = input.is_a?(HLS::Input) ? input : HLS::Input.new(input)
|
|
36
|
+
|
|
37
|
+
profile_class.new(
|
|
38
|
+
input: input_obj,
|
|
39
|
+
output: Pathname.new(output),
|
|
40
|
+
key_prefix: key_prefix
|
|
41
|
+
).process
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
data/lib/hls/input.rb
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "open3"
|
|
5
|
+
require "pathname"
|
|
6
|
+
|
|
7
|
+
module HLS
|
|
8
|
+
# An input video file. Probes its metadata via ffprobe lazily (the first
|
|
9
|
+
# time you ask for `width`, `height`, etc.) and caches the result.
|
|
10
|
+
class Input
|
|
11
|
+
PROBE_ARGS = %w[
|
|
12
|
+
-v error
|
|
13
|
+
-select_streams v:0
|
|
14
|
+
-show_entries stream
|
|
15
|
+
-show_entries format
|
|
16
|
+
-of json
|
|
17
|
+
].freeze
|
|
18
|
+
|
|
19
|
+
# Fallback framerate when ffprobe doesn't report one (rare, but
|
|
20
|
+
# possible for some containers / streams). 30fps is the safe choice
|
|
21
|
+
# for web video — common for screencasts and matches most uploads.
|
|
22
|
+
DEFAULT_FRAMERATE = 30
|
|
23
|
+
|
|
24
|
+
attr_reader :path
|
|
25
|
+
|
|
26
|
+
def initialize(path)
|
|
27
|
+
@path = Pathname.new(path)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def width = video_stream[:width]
|
|
31
|
+
def height = video_stream[:height]
|
|
32
|
+
def bitrate = video_stream[:bit_rate]
|
|
33
|
+
def codec = video_stream[:codec_name]
|
|
34
|
+
def duration = json.dig(:format, :duration)&.to_f
|
|
35
|
+
|
|
36
|
+
# Frames per second as a Float. ffprobe reports framerate as a
|
|
37
|
+
# rational ("30000/1001"); we resolve it to a float and round to the
|
|
38
|
+
# nearest int. Returns DEFAULT_FRAMERATE if the input doesn't
|
|
39
|
+
# advertise a usable framerate.
|
|
40
|
+
def framerate
|
|
41
|
+
raw = video_stream[:avg_frame_rate] || video_stream[:r_frame_rate]
|
|
42
|
+
return DEFAULT_FRAMERATE if raw.nil? || raw.empty? || raw == "0/0"
|
|
43
|
+
|
|
44
|
+
num, den = raw.split("/").map(&:to_f)
|
|
45
|
+
return DEFAULT_FRAMERATE if den.nil? || den.zero?
|
|
46
|
+
(num / den).round
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def json
|
|
50
|
+
@json ||= probe
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Returns true if ffprobe found a usable video stream. False for
|
|
54
|
+
# audio-only files, malformed media, or anything missing pixel
|
|
55
|
+
# dimensions.
|
|
56
|
+
def video?
|
|
57
|
+
stream = json.dig(:streams, 0)
|
|
58
|
+
!!(stream && stream[:width] && stream[:height])
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Raises HLS::Error if the input is not a video. Call this before
|
|
62
|
+
# handing an Input to the encode pipeline if you want to fail fast
|
|
63
|
+
# with a clear message rather than waiting for ffmpeg to choke.
|
|
64
|
+
def validate!
|
|
65
|
+
return self if video?
|
|
66
|
+
raise HLS::Error,
|
|
67
|
+
"#{@path} has no video stream — ffprobe found " \
|
|
68
|
+
"#{json[:streams]&.size || 0} stream(s) but none with width/height. " \
|
|
69
|
+
"Audio-only files and non-media files are not supported."
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
# Returns the first video stream's metadata, or raises HLS::Error if
|
|
75
|
+
# there is none. We don't return an empty hash silently — `width`
|
|
76
|
+
# returning nil leads to obscure crashes deeper in the pipeline.
|
|
77
|
+
def video_stream
|
|
78
|
+
json.dig(:streams, 0) or raise HLS::Error,
|
|
79
|
+
"#{@path} has no video stream (ffprobe returned no streams matching v:0)"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def probe
|
|
83
|
+
raise HLS::Error, "input file not found: #{@path}" unless @path.exist?
|
|
84
|
+
|
|
85
|
+
stdout, stderr, status = Open3.capture3("ffprobe", *PROBE_ARGS, @path.to_s)
|
|
86
|
+
unless status.success?
|
|
87
|
+
raise HLS::Error, "ffprobe failed for #{@path}: #{stderr.strip}"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
JSON.parse(stdout, symbolize_names: true)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module HLS
|
|
4
|
+
# Lightweight instrumentation wrapper around ActiveSupport::Notifications.
|
|
5
|
+
#
|
|
6
|
+
# Subscribe to the events from your host app to wire up logging,
|
|
7
|
+
# metrics, or tracing. Event names follow the Rails convention
|
|
8
|
+
# `<action>.<library>` so they show up nicely in LogSubscriber output.
|
|
9
|
+
#
|
|
10
|
+
# ActiveSupport::Notifications.subscribe("encode.hls") do |name, start, finish, _, payload|
|
|
11
|
+
# Rails.logger.info "[hls] encoded #{payload[:profile]} in #{((finish - start) * 1000).round}ms"
|
|
12
|
+
# end
|
|
13
|
+
#
|
|
14
|
+
# Events emitted:
|
|
15
|
+
#
|
|
16
|
+
# encode.hls — encode! finished. payload: profile, output, renditions
|
|
17
|
+
# poster.hls — poster! finished. payload: profile, output, count
|
|
18
|
+
# verify.hls — verify_encode! passed. payload: profile, output
|
|
19
|
+
# upload_object.hls — single object PUT to the bucket. payload: key, bytes, content_type
|
|
20
|
+
# process.hls — full process pipeline. payload: profile, key_prefix, uploaded, skipped
|
|
21
|
+
#
|
|
22
|
+
# When ActiveSupport::Notifications isn't loaded (plain-Ruby usage,
|
|
23
|
+
# specs without Rails), the helper is a no-op that still yields.
|
|
24
|
+
module Instrumentation
|
|
25
|
+
module_function
|
|
26
|
+
|
|
27
|
+
# Wraps a block in an ActiveSupport::Notifications event. Yields the
|
|
28
|
+
# mutable payload hash to the block so callers can attach result
|
|
29
|
+
# data (e.g., uploaded counts) before the event is published. When
|
|
30
|
+
# ActiveSupport::Notifications isn't loaded, yields the same payload
|
|
31
|
+
# hash with no surrounding instrumentation.
|
|
32
|
+
def instrument(event, payload = {})
|
|
33
|
+
if defined?(ActiveSupport::Notifications)
|
|
34
|
+
ActiveSupport::Notifications.instrument("#{event}.hls", payload) { yield payload if block_given? }
|
|
35
|
+
else
|
|
36
|
+
yield payload if block_given?
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
data/lib/hls/lock.rb
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
|
|
5
|
+
module HLS
|
|
6
|
+
# Advisory file lock for an encode run. Two workers asked to process
|
|
7
|
+
# the same output directory at the same time would corrupt each
|
|
8
|
+
# other's state.json and leave a half-uploaded bundle. The lock
|
|
9
|
+
# makes that case fail loudly instead of silently.
|
|
10
|
+
#
|
|
11
|
+
# Uses `flock` with `LOCK_EX | LOCK_NB` — the second process gets
|
|
12
|
+
# `HLS::Lock::Busy` immediately rather than blocking. The lock file
|
|
13
|
+
# itself (`<output>/.hls-lock`) is left on disk; only the kernel-level
|
|
14
|
+
# advisory lock is released. This keeps the file's inode stable so
|
|
15
|
+
# `flock` works reliably across re-runs.
|
|
16
|
+
class Lock
|
|
17
|
+
FILENAME = ".hls-lock"
|
|
18
|
+
|
|
19
|
+
class Busy < HLS::Error; end
|
|
20
|
+
|
|
21
|
+
def self.acquire(output_dir, &block)
|
|
22
|
+
new(output_dir).acquire(&block)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
attr_reader :path
|
|
26
|
+
|
|
27
|
+
def initialize(output_dir)
|
|
28
|
+
@path = Pathname.new(output_dir).join(FILENAME)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Acquires the lock, yields, releases. If another process holds it,
|
|
32
|
+
# raises `HLS::Lock::Busy` immediately — we don't wait.
|
|
33
|
+
def acquire
|
|
34
|
+
@path.parent.mkpath
|
|
35
|
+
File.open(@path, File::CREAT | File::RDWR, 0o644) do |f|
|
|
36
|
+
unless f.flock(File::LOCK_EX | File::LOCK_NB)
|
|
37
|
+
raise Busy, "another process is already encoding #{@path.parent}"
|
|
38
|
+
end
|
|
39
|
+
f.truncate(0)
|
|
40
|
+
f.write("#{Process.pid}\n")
|
|
41
|
+
f.flush
|
|
42
|
+
begin
|
|
43
|
+
yield
|
|
44
|
+
ensure
|
|
45
|
+
f.flock(File::LOCK_UN)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
data/lib/hls/manifest.rb
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "m3u8"
|
|
4
|
+
|
|
5
|
+
module HLS
|
|
6
|
+
# Reads an HLS bundle out of an S3-compatible bucket and returns
|
|
7
|
+
# signed-URL playlists ready to hand to a video player.
|
|
8
|
+
#
|
|
9
|
+
# The Manifest is the read-side counterpart to ApplicationVideo. A
|
|
10
|
+
# profile class hands one to its caller via `profile.manifest(path)`,
|
|
11
|
+
# but Manifest can also be used directly with any bucket.
|
|
12
|
+
#
|
|
13
|
+
# Example:
|
|
14
|
+
#
|
|
15
|
+
# manifest = CourseVideo.manifest("phlex/forms/overview")
|
|
16
|
+
# manifest.master_playlist # => M3u8::Playlist with rewritten URIs
|
|
17
|
+
# manifest.poster_url # => pre-signed URL string
|
|
18
|
+
#
|
|
19
|
+
# `master_playlist` rewrites variant URIs from ffmpeg's flat
|
|
20
|
+
# `0/index.m3u8` form to a controller-routable `<path>/0.m3u8` shape
|
|
21
|
+
# so the host app's controller can serve each variant by name.
|
|
22
|
+
class Manifest
|
|
23
|
+
DEFAULT_POSTER = "poster"
|
|
24
|
+
MASTER_PLAYLIST = "index.m3u8"
|
|
25
|
+
VARIANT_PLAYLIST = "index.m3u8"
|
|
26
|
+
|
|
27
|
+
attr_reader :storage, :path, :segment_duration, :cache
|
|
28
|
+
|
|
29
|
+
# storage:: An HLS::Storage adapter (S3, Memory, ...). Owns
|
|
30
|
+
# the bucket and the default signing TTL.
|
|
31
|
+
# path:: key prefix where the bundle lives
|
|
32
|
+
# segment_duration: HLS segment length, drives Variant#duration math
|
|
33
|
+
# variant_uri:: callable taking (path:, variant_index:) and
|
|
34
|
+
# returning the URI string to put in the master
|
|
35
|
+
# playlist for that variant. Default produces
|
|
36
|
+
# `<basename(path)>/<index>.m3u8`, which matches a
|
|
37
|
+
# `/videos/*path/:id/:variant` Rails route shape.
|
|
38
|
+
# cache:: an HLS::Cache (or any object responding to
|
|
39
|
+
# `fetch(key, &block)`). When set, raw playlists
|
|
40
|
+
# are read through it instead of being fetched on
|
|
41
|
+
# every request. nil disables caching.
|
|
42
|
+
def initialize(storage:, path:,
|
|
43
|
+
segment_duration: 4, variant_uri: nil,
|
|
44
|
+
cache: nil)
|
|
45
|
+
@storage = storage
|
|
46
|
+
@path = path
|
|
47
|
+
@segment_duration = Integer(segment_duration)
|
|
48
|
+
@variant_uri = variant_uri || DEFAULT_VARIANT_URI
|
|
49
|
+
@cache = cache
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Default presigned-URL TTL for this manifest. Reads from the
|
|
53
|
+
# storage adapter so signing config lives in one place.
|
|
54
|
+
def expires_in
|
|
55
|
+
storage.signing_ttl
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
DEFAULT_VARIANT_URI = ->(path:, variant_index:) {
|
|
59
|
+
"#{::File.basename(path)}/#{variant_index}.m3u8"
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
# Pre-signed URL for a poster image. With no argument, returns
|
|
63
|
+
# `<path>/poster.jpg` (back-compat with the legacy `HLS::Poster`
|
|
64
|
+
# filename). With a name, returns `<path>/<name>.jpg`.
|
|
65
|
+
def poster_url(name = DEFAULT_POSTER)
|
|
66
|
+
presigned_url("#{name}.jpg")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Master playlist with variant URIs rewritten from ffmpeg's
|
|
70
|
+
# `<index>/index.m3u8` form to whatever the configured `variant_uri`
|
|
71
|
+
# callable returns. The default produces `<id>/<index>.m3u8` where
|
|
72
|
+
# `<id>` is the last segment of the manifest's path — relative to
|
|
73
|
+
# the master playlist's URL, so a player fetching
|
|
74
|
+
# `/videos/<path>/<id>.m3u8` resolves the variant URI to
|
|
75
|
+
# `/videos/<path>/<id>/<index>.m3u8`.
|
|
76
|
+
#
|
|
77
|
+
# Returns a fresh M3u8::Playlist on each call (does not mutate the
|
|
78
|
+
# cached raw playlist).
|
|
79
|
+
def master_playlist
|
|
80
|
+
list = M3u8::Playlist.new
|
|
81
|
+
list.items = raw_master_playlist.items.map do |item|
|
|
82
|
+
rewritten = item.clone
|
|
83
|
+
rewritten.uri = @variant_uri.call(
|
|
84
|
+
path: path,
|
|
85
|
+
variant_index: ::File.dirname(item.uri)
|
|
86
|
+
)
|
|
87
|
+
rewritten
|
|
88
|
+
end
|
|
89
|
+
list
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# All variants discovered in the master playlist, indexed by
|
|
93
|
+
# ffmpeg's variant path (`"0"`, `"1"`, ...).
|
|
94
|
+
def variants
|
|
95
|
+
@variants ||= raw_master_playlist.items.map do |item|
|
|
96
|
+
variant_path = ::File.dirname(item.uri)
|
|
97
|
+
variant_playlist = read_playlist(variant_path, VARIANT_PLAYLIST)
|
|
98
|
+
Variant.new(manifest: self, variant_path: variant_path, items: variant_playlist.items)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Look up a variant by ffmpeg's path identifier ("0", "1", ...).
|
|
103
|
+
def variant(variant_path)
|
|
104
|
+
variants.find { |v| v.variant_path == variant_path }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Pre-signs an arbitrary key under this manifest's path.
|
|
108
|
+
def presigned_url(*parts, expires_in: self.expires_in)
|
|
109
|
+
object(*parts).presigned_url(:get, expires_in: Integer(expires_in))
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
private
|
|
113
|
+
|
|
114
|
+
def object(*parts)
|
|
115
|
+
storage.object(::File.join(path, *parts))
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def read_object(*parts)
|
|
119
|
+
object(*parts).get.body.read
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def read_playlist(*parts)
|
|
123
|
+
M3u8::Reader.new.read(cached_object_body(*parts))
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Reads a key's bytes through the cache when one is configured;
|
|
127
|
+
# otherwise hits the bucket directly. Cache keys include the bucket
|
|
128
|
+
# path so two manifests on different prefixes don't collide. The
|
|
129
|
+
# TTL lives on the cache object itself (HLS::Cache wraps a backend
|
|
130
|
+
# with a TTL); raw `Rails.cache` works too but uses its default TTL.
|
|
131
|
+
def cached_object_body(*parts)
|
|
132
|
+
return read_object(*parts) if cache.nil?
|
|
133
|
+
|
|
134
|
+
key = "hls/manifest/#{::File.join(path, *parts)}"
|
|
135
|
+
cache.fetch(key) { read_object(*parts) }
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# The raw, untouched master playlist as ffmpeg wrote it. Kept private
|
|
139
|
+
# because callers should always go through `master_playlist`, which
|
|
140
|
+
# rewrites variant URIs. Memoized so repeated reads (poster_url +
|
|
141
|
+
# master_playlist + variants in one request) only pay one S3 GET.
|
|
142
|
+
def raw_master_playlist
|
|
143
|
+
@raw_master_playlist ||= read_playlist(MASTER_PLAYLIST)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# A single rendition variant — its ordered list of segments and the
|
|
147
|
+
# ability to slice a duration window for previews.
|
|
148
|
+
class Variant
|
|
149
|
+
attr_reader :manifest, :variant_path, :items
|
|
150
|
+
|
|
151
|
+
def initialize(manifest:, variant_path:, items:)
|
|
152
|
+
@manifest = manifest
|
|
153
|
+
@variant_path = variant_path
|
|
154
|
+
@items = items
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Duration in seconds. Approximate — uses segment_duration as a
|
|
158
|
+
# uniform per-segment value, which matches how ffmpeg writes VOD
|
|
159
|
+
# bundles. The last segment may be slightly shorter.
|
|
160
|
+
def duration
|
|
161
|
+
items.count * manifest.segment_duration
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Slice a duration window. The range is in seconds. Inclusive ranges
|
|
165
|
+
# round down, exclusive ranges round up. Useful for preview windows
|
|
166
|
+
# (e.g. `variant[0...30]` for a 30-second teaser).
|
|
167
|
+
def [](range)
|
|
168
|
+
raise ArgumentError, "Only Range objects are supported" unless range.is_a?(Range)
|
|
169
|
+
|
|
170
|
+
segment_count =
|
|
171
|
+
if range.exclude_end?
|
|
172
|
+
(range.end.to_f / manifest.segment_duration).ceil
|
|
173
|
+
else
|
|
174
|
+
(range.end.to_f / manifest.segment_duration).floor
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
Variant.new(manifest: manifest, variant_path: variant_path, items: items.take(segment_count))
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# The variant playlist with each segment rewritten to a pre-signed
|
|
181
|
+
# URL. Returns a fresh M3u8::Playlist; does not mutate `items`.
|
|
182
|
+
def playlist
|
|
183
|
+
list = M3u8::Playlist.new
|
|
184
|
+
list.items = items.map do |item|
|
|
185
|
+
signed = item.clone
|
|
186
|
+
signed.segment = manifest.presigned_url(variant_path, item.segment)
|
|
187
|
+
signed
|
|
188
|
+
end
|
|
189
|
+
list
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
data/lib/hls/railtie.rb
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/railtie"
|
|
4
|
+
|
|
5
|
+
module HLS
|
|
6
|
+
# Railtie that wires HLS into a host Rails app.
|
|
7
|
+
#
|
|
8
|
+
# - Registers `app/videos` as an autoload + eager-load path so that
|
|
9
|
+
# profile classes like `app/videos/course_video.rb` are picked up
|
|
10
|
+
# by Zeitwerk.
|
|
11
|
+
# - Lazily loads HLS::EncodeJob when ActiveJob is loaded.
|
|
12
|
+
#
|
|
13
|
+
# That's it. There is intentionally no Rails.application.config.hls
|
|
14
|
+
# config bag and no load hook to subscribe to. Configure profile
|
|
15
|
+
# classes directly:
|
|
16
|
+
#
|
|
17
|
+
# class ApplicationVideo < HLS::ApplicationVideo
|
|
18
|
+
# def self.storage = HLS::Storage::S3.new(
|
|
19
|
+
# bucket_name: ENV.fetch("VIDEO_S3_BUCKET_NAME"),
|
|
20
|
+
# signing_ttl: 1.hour
|
|
21
|
+
# )
|
|
22
|
+
# segment_duration 4
|
|
23
|
+
# end
|
|
24
|
+
#
|
|
25
|
+
# Zeitwerk reloads the class in dev so the settings re-apply
|
|
26
|
+
# automatically; no special hook needed.
|
|
27
|
+
class Railtie < Rails::Railtie
|
|
28
|
+
initializer "hls.autoload_paths", before: :set_autoload_paths do |app|
|
|
29
|
+
videos_path = app.root.join("app", "videos")
|
|
30
|
+
app.config.autoload_paths << videos_path.to_s
|
|
31
|
+
app.config.eager_load_paths << videos_path.to_s
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
initializer "hls.encode_job" do
|
|
35
|
+
ActiveSupport.on_load(:active_job) do
|
|
36
|
+
require "hls/encode_job"
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|