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