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/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
|
-
|
|
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
|
-
|
|
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://beautifulruby.com/phlex
|
|
34
|
+
[](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
|
-
|
|
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
|
|
data/examples/directory.rb
CHANGED
|
@@ -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
|
-
|
|
15
|
+
class CourseVideo < HLS::ApplicationVideo
|
|
16
|
+
bits_per_pixel :screencast
|
|
21
17
|
|
|
22
|
-
|
|
23
|
-
|
|
18
|
+
rendition :full, scale: 1.0
|
|
19
|
+
rendition :medium, scale: 0.5
|
|
20
|
+
rendition :small, scale: 0.25
|
|
24
21
|
|
|
25
|
-
|
|
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
|
-
|
|
25
|
+
directory = HLS::Directory.new(source).glob("**/*.mp4").to_a
|
|
26
|
+
puts "Processing #{directory.size} files from #{source}"
|
|
54
27
|
|
|
55
|
-
|
|
28
|
+
directory.each do |input, path|
|
|
56
29
|
output = destination.join(path)
|
|
57
30
|
FileUtils.mkdir_p(output)
|
|
58
31
|
|
|
59
|
-
|
|
60
|
-
puts "Processing renditions for: #{input}"
|
|
32
|
+
puts "Processing #{input.path} to #{output}"
|
|
61
33
|
|
|
62
|
-
|
|
63
|
-
|
|
34
|
+
profile = CourseVideo.new(input:, output:)
|
|
35
|
+
profile.encode!
|
|
36
|
+
profile.poster!
|
|
64
37
|
|
|
65
|
-
|
|
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
|