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/README.md CHANGED
@@ -16,15 +16,22 @@ The most annoying part about serving HLS videos from private object stores is ge
16
16
 
17
17
  ### Rails integration
18
18
 
19
- When a user requests a video, you probably want a controller that determines whether they have access to the video. If they do, the Rails integration will request a manifest file stored along side your video to generate the pre-signed URLs for each chunk.
19
+ A Railtie autoloads `app/videos/*.rb` profile classes, wires app-wide
20
+ defaults from `config/initializers/hls.rb`, and ships an
21
+ `HLS::EncodeJob` ActiveJob wrapper for queue-driven encoding.
20
22
 
21
- Generators will also be included that pin an HLS polyfill for browsers that don't support HLS natively, like Chrome and Firefox.
23
+ ## Requirements
24
+
25
+ - Ruby 3.1+
26
+ - `ffmpeg` and `ffprobe` on the PATH (Homebrew, apt, or your distro
27
+ equivalent)
28
+ - An S3-compatible bucket (Tigris, Cloudflare R2, AWS S3, MinIO, etc.)
22
29
 
23
30
  ## Support
24
31
 
25
32
  Consider [buying a video course from Beautiful Ruby](https://beautifulruby.com) and learn a thing or two to keep the machine going that originally built this gem.
26
33
 
27
- [![](https://immutable.terminalwire.com/NgTt6nzO1aEnExV8j6ODuKt2iZpY74ZF8ecpUSCp4A0tXA0ErpJIS4cdMX0tQQKOWwZSl65jWnpzpgCLJThhhWtZJGr42XKt7WIi.png)](https://beautifulruby.com/phlex/forms/overview)
34
+ [![](https://immutable.terminalwire.com/NgTt6nzO1aEnExV8j6ODuKt2iZpY74ZF8ecpUSCp4A0tXA0ErpJIS4cdMX0tQQKOWwZSl65jWnpzpgCLJThhhWtZJGr42XKt7WIi.png)](https://beautifulruby.com/phlex)
28
35
 
29
36
  ## Installation
30
37
 
@@ -40,9 +47,281 @@ If bundler is not being used to manage dependencies, install the gem by executin
40
47
  gem install hls
41
48
  ```
42
49
 
50
+ In a Rails app, scaffold the initial config + base profile with:
51
+
52
+ ```bash
53
+ bin/rails g hls:install
54
+ ```
55
+
56
+ This writes `config/initializers/hls.rb` (with env-var-driven bucket /
57
+ S3 config) and `app/videos/application_video.rb` (the base class your
58
+ profiles inherit from). Then generate per-content-type profiles:
59
+
60
+ ```bash
61
+ bin/rails g hls:video Course
62
+ # => app/videos/course_video.rb
63
+ ```
64
+
65
+ The generated profile has a sensible 3-rendition ladder, a hero
66
+ poster, and inline comments for the common knobs. Edit to tune.
67
+
43
68
  ## Usage
44
69
 
45
- TODO: Write usage instructions here when the gem is finished
70
+ ### Declare a profile
71
+
72
+ In a Rails app, `app/videos/*.rb` is autoloaded and inherits app-wide
73
+ defaults from `config/initializers/hls.rb`:
74
+
75
+ ```ruby
76
+ # config/initializers/hls.rb
77
+ require "aws-sdk-s3"
78
+
79
+ HLS.s3_resource = Aws::S3::Resource.new(
80
+ access_key_id: ENV.fetch("VIDEO_AWS_ACCESS_KEY_ID"),
81
+ secret_access_key: ENV.fetch("VIDEO_AWS_SECRET_ACCESS_KEY"),
82
+ endpoint: ENV.fetch("VIDEO_S3_ENDPOINT_URL"),
83
+ region: "auto"
84
+ )
85
+
86
+ # app/videos/application_video.rb — bucket + signing TTL live on the
87
+ # storage adapter, configured here so every subclass under app/videos
88
+ # inherits them. Override `def self.storage` on a subclass to point
89
+ # at a different bucket.
90
+ class ApplicationVideo < HLS::ApplicationVideo
91
+ def self.storage = HLS::Storage::S3.new(
92
+ bucket_name: ENV.fetch("VIDEO_S3_BUCKET_NAME"),
93
+ signing_ttl: 1.hour
94
+ )
95
+
96
+ segment_duration 4
97
+ end
98
+
99
+ # app/videos/course_video.rb
100
+ class CourseVideo < ApplicationVideo
101
+ rendition :high, scale: 1.0
102
+ rendition :medium, scale: 0.5
103
+ rendition :small, scale: 0.25
104
+
105
+ poster :hero, scale: 1.0
106
+ poster :thumbnail, width: 320, height: 180
107
+ end
108
+ ```
109
+
110
+ ### Encode and upload
111
+
112
+ ```ruby
113
+ profile = CourseVideo.new(
114
+ input: HLS::Input.new("lecture.mp4"),
115
+ output: Pathname.new("tmp/encoded"),
116
+ key_prefix: "courses/phlex/intro"
117
+ )
118
+ profile.process
119
+ # Probes input → encodes HLS multiplex + posters → uploads to bucket.
120
+ # Idempotent: re-running with the same input is a no-op.
121
+ ```
122
+
123
+ Or enqueue the work asynchronously:
124
+
125
+ ```ruby
126
+ HLS::EncodeJob.perform_later(
127
+ profile: "CourseVideo",
128
+ input: "lecture.mp4",
129
+ output: "tmp/encoded",
130
+ key_prefix: "courses/phlex/intro"
131
+ )
132
+ ```
133
+
134
+ ### Serve from a controller
135
+
136
+ ```ruby
137
+ class VideosController < ApplicationController
138
+ before_action { @manifest = CourseVideo.manifest(params[:id]) }
139
+
140
+ def index
141
+ respond_to do |format|
142
+ format.m3u8 { render plain: @manifest.master_playlist }
143
+ format.jpg { redirect_to @manifest.poster_url(:hero), allow_other_host: true }
144
+ end
145
+ end
146
+
147
+ def show
148
+ variant = @manifest.variant(params[:variant])
149
+ render plain: variant.playlist
150
+ end
151
+ end
152
+ ```
153
+
154
+ `master_playlist` rewrites variant URIs to a controller-routable shape;
155
+ `variant.playlist` rewrites segment URIs to pre-signed S3 URLs that
156
+ the player can fetch directly.
157
+
158
+ ### Preview windows
159
+
160
+ `Variant#[range]` slices the variant to a duration in seconds. Useful
161
+ for locked-content previews:
162
+
163
+ ```ruby
164
+ PREVIEW_DURATION = 30.seconds
165
+ list = if subscriber?
166
+ @manifest.variant(params[:variant]).playlist
167
+ else
168
+ @manifest.variant(params[:variant])[0...PREVIEW_DURATION].playlist
169
+ end
170
+ ```
171
+
172
+ ### Testing your profile
173
+
174
+ The gem ships test helpers for verifying that your profile produces a
175
+ correct bundle end-to-end:
176
+
177
+ ```ruby
178
+ require "hls/testing"
179
+
180
+ RSpec.describe CourseVideo do
181
+ include HLS::Testing
182
+
183
+ it "produces a complete HLS bundle" do
184
+ video = generate_test_video(duration: 12)
185
+ output = Pathname.new(Dir.mktmpdir)
186
+
187
+ profile = CourseVideo.new(input: HLS::Input.new(video), output: output)
188
+ silence_ffmpeg { profile.encode!; profile.poster! }
189
+
190
+ expect(output).to be_a_valid_hls_bundle
191
+ .with_variants(3)
192
+ .with_posters(:hero, :thumbnail)
193
+ end
194
+ end
195
+ ```
196
+
197
+ ### Codec auto-detection
198
+
199
+ By default `video_codec :h264` resolves to the best available encoder
200
+ on the host (`h264_videotoolbox` on macOS, `h264_nvenc`/`h264_qsv` on
201
+ Linux GPUs, `libx264` as the universal fallback). Override per-profile
202
+ when you need an explicit one:
203
+
204
+ ```ruby
205
+ class WebVideo < ApplicationVideo
206
+ video_codec "libx264" # always software, regardless of host
207
+ end
208
+ ```
209
+
210
+ ### Configuration reference
211
+
212
+ Every class-level setting on `HLS::ApplicationVideo` is inheritable
213
+ through the class hierarchy. For static values, set with the DSL form
214
+ (`segment_duration 4`); for dynamic values that should re-read on
215
+ every Zeitwerk reload, override the reader (`def self.storage = ...`).
216
+
217
+ | Setting | Default | Notes |
218
+ |----------------------|----------------------|-------|
219
+ | `storage` | _none — required_ | `HLS::Storage::S3`, `HLS::Storage::Memory`, or any conforming adapter |
220
+ | `segment_duration` | `4` | HLS segment length, seconds |
221
+ | `video_codec` | `:h264` | Symbol (auto-resolved) or string (explicit) |
222
+ | `audio_codec` | `"aac"` | |
223
+ | `audio_bitrate` | `128` | kbps |
224
+ | `bits_per_pixel` | `:mixed` (4) | `:screencast` (3), `:mixed` (4), `:motion` (6) |
225
+ | `max_bitrate_kbps` | `15_000` | Caps scaled-rendition bitrate |
226
+ | `ffmpeg_timeout` | `nil` | Hard cap (seconds) on a single ffmpeg run; `nil` disables |
227
+ | `cache` | `nil` | `HLS::Cache` (groups a `Rails.cache`-shaped backend with a TTL) or any object responding to `fetch(key, &block)` |
228
+
229
+ `HLS::Storage::S3` itself takes `bucket_name:`, `signing_ttl:`, and an
230
+ optional `s3_resource:` (defaults to `HLS.s3_resource`). For tests or
231
+ non-AWS backends, use `HLS::Storage::Memory.new(name: ...)` or any
232
+ object responding to `signing_ttl` and `object(key)`.
233
+
234
+ `HLS.s3_resource` is process-global. If two profiles need different
235
+ SDK clients (different regions, credentials, or endpoints), pass
236
+ `s3_resource:` directly to each `HLS::Storage::S3` rather than relying
237
+ on the global default:
238
+
239
+ ```ruby
240
+ class CourseVideo < ApplicationVideo
241
+ def self.storage = HLS::Storage::S3.new(
242
+ s3_resource: Aws::S3::Resource.new(region: "us-west-2", ...),
243
+ bucket_name: "course-videos",
244
+ signing_ttl: 1.hour
245
+ )
246
+ end
247
+ ```
248
+
249
+ ### Concurrency and retries
250
+
251
+ The uploader runs PUTs in parallel with bounded concurrency and retries
252
+ transient failures with exponential backoff. Defaults are tuned for
253
+ typical home/office connections; override per-call:
254
+
255
+ ```ruby
256
+ HLS::Uploader.new(
257
+ storage: storage, output: out, key_prefix: prefix, state: state,
258
+ concurrency: 8, # default 4
259
+ max_retries: 5, # default 3
260
+ initial_backoff: 0.25 # default 0.5 seconds, doubles per attempt
261
+ ).perform
262
+ ```
263
+
264
+ Only transient errors (network timeouts, 503s, throttling) are retried;
265
+ permanent errors (NoSuchBucket, 403) fail fast.
266
+
267
+ ### Instrumentation
268
+
269
+ The gem publishes ActiveSupport::Notifications events when AS is
270
+ loaded. Subscribe to wire up logging or metrics:
271
+
272
+ ```ruby
273
+ ActiveSupport::Notifications.subscribe("encode.hls") do |name, start, finish, _, payload|
274
+ Rails.logger.info "[hls] encoded #{payload[:profile]} in #{((finish - start) * 1000).round}ms"
275
+ end
276
+ ```
277
+
278
+ Events:
279
+
280
+ | Name | Payload keys |
281
+ |---------------------|---------------------------------------------|
282
+ | `encode.hls` | `profile`, `output`, `renditions` |
283
+ | `poster.hls` | `profile`, `output`, `count` |
284
+ | `verify.hls` | `profile`, `output` |
285
+ | `upload_object.hls` | `key`, `bytes`, `content_type` |
286
+ | `upload_retry.hls` | `key`, `attempt`, `error`, `message` |
287
+ | `process.hls` | `profile`, `key_prefix`, `uploaded`, `skipped` |
288
+
289
+ ### Storage adapters
290
+
291
+ The default backend is `HLS::Storage::S3`, which wraps an
292
+ `Aws::S3::Bucket` (works with AWS S3, Tigris, Cloudflare R2, MinIO).
293
+ `HLS::Storage::Memory` ships as a no-network adapter useful for tests.
294
+ Roll your own by implementing the protocol — `signing_ttl` plus
295
+ `object(key)` returning something that responds to `get`,
296
+ `put(body:, content_type:, cache_control:)`, and
297
+ `presigned_url(:get, expires_in:)`.
298
+
299
+ #### MinIO
300
+
301
+ MinIO is API-compatible with S3 — point `HLS.s3_resource` at its
302
+ endpoint:
303
+
304
+ ```ruby
305
+ # config/initializers/hls.rb
306
+ HLS.s3_resource = Aws::S3::Resource.new(
307
+ access_key_id: ENV["MINIO_ACCESS_KEY"],
308
+ secret_access_key: ENV["MINIO_SECRET_KEY"],
309
+ endpoint: ENV["MINIO_ENDPOINT"], # e.g. http://localhost:9000
310
+ region: "us-east-1",
311
+ force_path_style: true # required for MinIO
312
+ )
313
+
314
+ # app/videos/application_video.rb
315
+ class ApplicationVideo < HLS::ApplicationVideo
316
+ def self.storage = HLS::Storage::S3.new(
317
+ bucket_name: ENV.fetch("MINIO_BUCKET"),
318
+ signing_ttl: 1.hour
319
+ )
320
+ end
321
+ ```
322
+
323
+ `force_path_style: true` is the key MinIO requirement — MinIO doesn't
324
+ do virtual-hosted-style addressing.
46
325
 
47
326
  ## Development
48
327
 
@@ -4,62 +4,36 @@ gemfile do
4
4
  source "https://rubygems.org"
5
5
 
6
6
  gem "hls", path: ".."
7
- gem "parallel"
8
7
  end
9
8
 
10
9
  require "fileutils"
11
- require "pathname"
12
- require "etc"
13
- require "shellwords"
14
- require "uri"
15
10
 
16
- storage = Pathname.new("/Users/bradgessler/Desktop")
11
+ storage = Pathname.new(ENV.fetch("SOURCE_PATH", "/Users/bradgessler/Desktop"))
17
12
  source = storage.join("Exports")
18
13
  destination = storage.join("Uploads")
19
14
 
20
- CONCURRENCY = Etc.nprocessors / 2
15
+ class CourseVideo < HLS::ApplicationVideo
16
+ bits_per_pixel :screencast
21
17
 
22
- class Jobs
23
- include Enumerable
18
+ rendition :full, scale: 1.0
19
+ rendition :medium, scale: 0.5
20
+ rendition :small, scale: 0.25
24
21
 
25
- def initialize
26
- @jobs = []
27
- end
28
-
29
- def schedule(&job)
30
- @jobs << job
31
- end
32
-
33
- def render(rendition)
34
- schedule do
35
- ffmpeg rendition
36
- end
37
- end
38
-
39
- def process(in_processes: CONCURRENCY, **options)
40
- Parallel.each(@jobs, in_processes: in_processes, &:call)
41
- end
42
-
43
- private
44
-
45
- def ffmpeg(task)
46
- cmd = task.command.map(&:to_s)
47
- puts "[#{task.class.name}] Running: #{Shellwords.join(cmd)}"
48
- system(*cmd)
49
- puts "[#{task.class.name}] Done"
50
- end
22
+ poster :poster, scale: 1.0
51
23
  end
52
24
 
53
- jobs = Jobs.new
25
+ directory = HLS::Directory.new(source).glob("**/*.mp4").to_a
26
+ puts "Processing #{directory.size} files from #{source}"
54
27
 
55
- HLS::Directory.new(source).glob("**/*.mp4").each do |input, path|
28
+ directory.each do |input, path|
56
29
  output = destination.join(path)
57
30
  FileUtils.mkdir_p(output)
58
31
 
59
- package = HLS::Video::Web.new(input:, output:)
60
- puts "Processing renditions for: #{input}"
32
+ puts "Processing #{input.path} to #{output}"
61
33
 
62
- jobs.render package
63
- end
34
+ profile = CourseVideo.new(input:, output:)
35
+ profile.encode!
36
+ profile.poster!
64
37
 
65
- jobs.process
38
+ puts "Completed #{input.path} to #{output}"
39
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/base"
4
+
5
+ module Hls
6
+ module Generators
7
+ # Bootstraps a host Rails app for the HLS gem:
8
+ # - config/initializers/hls.rb (bucket + S3 resource config)
9
+ # - app/videos/application_video.rb (base profile class)
10
+ #
11
+ # Usage:
12
+ #
13
+ # bin/rails g hls:install
14
+ class InstallGenerator < Rails::Generators::Base
15
+ source_root File.expand_path("templates", __dir__)
16
+
17
+ def create_initializer
18
+ template "initializer.rb", "config/initializers/hls.rb"
19
+ end
20
+
21
+ def create_application_video
22
+ template "application_video.rb", "app/videos/application_video.rb"
23
+ end
24
+
25
+ def post_install_message
26
+ say <<~MSG
27
+
28
+ HLS installed.
29
+
30
+ Next steps:
31
+ 1. Set the env vars referenced in config/initializers/hls.rb
32
+ (VIDEO_AWS_ACCESS_KEY_ID, VIDEO_S3_BUCKET_NAME, etc.)
33
+ 2. Generate your first profile:
34
+ bin/rails g hls:video Course
35
+ 3. Encode + upload a video:
36
+ CourseVideo.new(input: HLS::Input.new("lecture.mp4"),
37
+ output: Pathname.new("tmp/encoded")).process
38
+ MSG
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Base class for every video profile in this app. Defaults set here
4
+ # flow down to subclasses unless overridden. Per-profile classes live
5
+ # alongside this file at app/videos/<name>_video.rb — generate one
6
+ # with:
7
+ #
8
+ # bin/rails g hls:video Course
9
+ #
10
+ class ApplicationVideo < HLS::ApplicationVideo
11
+ # The storage backend. Defaulted to an S3 bucket configured from
12
+ # env vars; signing TTL controls how long pre-signed URLs stay
13
+ # valid. Override `storage` per-subclass to point at a different
14
+ # bucket, swap in a different adapter, etc.
15
+ def self.storage = HLS::Storage::S3.new(
16
+ bucket_name: ENV.fetch("VIDEO_S3_BUCKET_NAME"),
17
+ signing_ttl: 1.hour
18
+ )
19
+
20
+ # Examples of app-wide overrides. Uncomment to apply to every
21
+ # subclass.
22
+ #
23
+ # video_codec :h264 # auto-resolves the best encoder
24
+ # audio_codec "aac"
25
+ # bits_per_pixel :screencast # :screencast (3), :mixed (4), :motion (6)
26
+ # max_bitrate_kbps 15_000
27
+ # segment_duration 4
28
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ # HLS gem configuration. Sets the Aws::S3::Resource the gem talks to.
4
+ # Per-profile bucket / signing TTL / segment duration live on the
5
+ # profile classes themselves under app/videos/.
6
+
7
+ require "aws-sdk-s3"
8
+
9
+ HLS.s3_resource = Aws::S3::Resource.new(
10
+ access_key_id: ENV.fetch("VIDEO_AWS_ACCESS_KEY_ID"),
11
+ secret_access_key: ENV.fetch("VIDEO_AWS_SECRET_ACCESS_KEY"),
12
+ endpoint: ENV.fetch("VIDEO_S3_ENDPOINT_URL"),
13
+ region: ENV.fetch("VIDEO_AWS_REGION", "auto")
14
+ )
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Encoding profile for <%= class_name %>. Inherits app-wide defaults
4
+ # from ApplicationVideo and config/initializers/hls.rb.
5
+ #
6
+ # Usage:
7
+ #
8
+ # profile = <%= class_name %>.new(
9
+ # input: HLS::Input.new("path/to/source.mp4"),
10
+ # output: Pathname.new("tmp/encoded/#{id}"),
11
+ # key_prefix: "videos/#{id}"
12
+ # )
13
+ # profile.process
14
+ # # Probes input → encodes HLS multiplex + posters → uploads to bucket.
15
+ # # Idempotent: re-running with the same input is a no-op.
16
+ #
17
+ # Then in your controller:
18
+ #
19
+ # manifest = <%= class_name %>.manifest("videos/#{id}")
20
+ # render plain: manifest.master_playlist
21
+ #
22
+ class <%= class_name %> < ApplicationVideo
23
+ # Three-rendition ladder scaled off the input. Each rendition is
24
+ # encoded only if it fits within the input dimensions (no upscaling).
25
+ # `scale:` derives width/height from the source; pass explicit
26
+ # `width:`/`height:`/`bitrate:` for fixed dimensions.
27
+ rendition :high, scale: 1.0
28
+ rendition :medium, scale: 0.5
29
+ rendition :small, scale: 0.25
30
+
31
+ # One full-size poster. Add more (thumbnails, social cards, etc.)
32
+ # by uncommenting the lines below or adding your own.
33
+ poster :hero, scale: 1.0
34
+ # poster :thumbnail, width: 320, height: 180
35
+ # poster :og, width: 1200, height: 630 # social share card
36
+
37
+ # Per-profile overrides. Anything left out falls back to
38
+ # ApplicationVideo, then the gem's defaults.
39
+ #
40
+ # bits_per_pixel :screencast # :screencast (3), :mixed (4), :motion (6)
41
+ # video_codec "libx264" # pin software encoder regardless of host
42
+ # max_bitrate_kbps 8_000 # cap any single rendition's bitrate
43
+ # segment_duration 2 # tighter segments → faster seeking, more files
44
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/named_base"
4
+
5
+ module Hls
6
+ module Generators
7
+ # Scaffolds a new video profile under app/videos/.
8
+ #
9
+ # bin/rails g hls:video Course
10
+ # # => app/videos/course_video.rb
11
+ #
12
+ # The generated file inherits from `ApplicationVideo` and ships with
13
+ # a sensible default rendition ladder, a hero poster, and inline
14
+ # comments for the common knobs (alternative codecs, bitrate
15
+ # tuning, additional posters). Edit the file to tune.
16
+ class VideoGenerator < Rails::Generators::NamedBase
17
+ source_root File.expand_path("templates", __dir__)
18
+
19
+ def create_video_profile
20
+ template "video.rb", File.join("app/videos", "#{file_name}_video.rb")
21
+ end
22
+
23
+ private
24
+
25
+ # `Course` -> `course`, `BlogPost` -> `blog_post`. Strips a
26
+ # trailing `Video` so `g hls:video CourseVideo` doesn't produce
27
+ # `course_video_video.rb`.
28
+ def file_name
29
+ super.sub(/_video\z/, "")
30
+ end
31
+
32
+ def class_name
33
+ "#{file_name.camelize}Video"
34
+ end
35
+ end
36
+ end
37
+ end