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
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
# 06 — ActiveStorage adapter
|
|
2
|
+
|
|
3
|
+
## Goal
|
|
4
|
+
|
|
5
|
+
A user attaches an mp4 to a model via ActiveStorage. The HLS pipeline
|
|
6
|
+
runs automatically, lands a bundle in a dedicated bucket, and the model
|
|
7
|
+
exposes signed-URL playlists ready to hand to a video player. Reads
|
|
8
|
+
feel like ActiveStorage; writes are background-driven by the gem.
|
|
9
|
+
|
|
10
|
+
```ruby
|
|
11
|
+
class Course < ApplicationRecord
|
|
12
|
+
has_one_attached :source_video # ActiveStorage
|
|
13
|
+
has_hls_video :web, profile: WebVideo # Our gem
|
|
14
|
+
has_hls_video :mobile, profile: MobileVideo # Same source, different profile
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
course = Course.create!
|
|
18
|
+
course.source_video.attach(uploaded_file)
|
|
19
|
+
# A WebVideo encode job + a MobileVideo encode job get enqueued.
|
|
20
|
+
|
|
21
|
+
# Read side, no waiting necessary if the bundle is ready:
|
|
22
|
+
course.web.ready? # bool
|
|
23
|
+
course.web.master_playlist_url # signed URL the player consumes
|
|
24
|
+
course.web.poster_url(:hero) # signed URL for the named poster
|
|
25
|
+
course.web.manifest # raw HLS::Manifest if you need it
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Why this shape
|
|
29
|
+
|
|
30
|
+
- **Source stays in ActiveStorage** because that's what people already
|
|
31
|
+
know and AS handles uploads, validations, file types. We don't want to
|
|
32
|
+
reinvent the upload story.
|
|
33
|
+
- **Bundles live in a dedicated bucket** (separate from the AS bucket)
|
|
34
|
+
because HLS bundles are 10-100× the size of the source, expire on
|
|
35
|
+
different schedules, and benefit from CDN-friendly caching headers
|
|
36
|
+
that don't fit AS conventions.
|
|
37
|
+
- **Multiple HLS profiles per source** because the same upload often
|
|
38
|
+
needs different bundles (web, mobile, AppleTV) and re-encoding from
|
|
39
|
+
the original is cheap relative to other options.
|
|
40
|
+
- **Profile classes are app/videos/*.rb** matching what step 01 already
|
|
41
|
+
built — no new top-level concept.
|
|
42
|
+
|
|
43
|
+
## Preconditions
|
|
44
|
+
|
|
45
|
+
- [01](01-profile-dsl.md) — Profile DSL exists.
|
|
46
|
+
- [02](02-upload-pipeline.md) — Upload pipeline + EncodeJob exist.
|
|
47
|
+
- [03](03-reader-and-manifest.md) — Manifest exists.
|
|
48
|
+
|
|
49
|
+
## Work
|
|
50
|
+
|
|
51
|
+
### 1. Database schema
|
|
52
|
+
|
|
53
|
+
Add a migration:
|
|
54
|
+
|
|
55
|
+
```ruby
|
|
56
|
+
create_table :hls_bundles do |t|
|
|
57
|
+
t.references :owner, polymorphic: true, null: false
|
|
58
|
+
t.string :name, null: false # the has_hls_video name (e.g. "web")
|
|
59
|
+
t.string :profile, null: false # profile class name (e.g. "WebVideo")
|
|
60
|
+
t.string :key_prefix, null: false # bucket key prefix
|
|
61
|
+
t.string :status, null: false, default: "pending"
|
|
62
|
+
# pending | encoding | ready | failed
|
|
63
|
+
t.string :input_digest # sha256 of the source blob
|
|
64
|
+
t.json :renditions # array of resolved renditions
|
|
65
|
+
t.string :error_message
|
|
66
|
+
t.datetime :encoded_at
|
|
67
|
+
t.timestamps
|
|
68
|
+
|
|
69
|
+
t.index [:owner_type, :owner_id, :name], unique: true
|
|
70
|
+
end
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
`HLS::Bundle` is the AR model. Provides:
|
|
74
|
+
- `#ready?` / `#failed?` / `#pending?` / `#encoding?`
|
|
75
|
+
- `#manifest` — returns an `HLS::Manifest` for the configured profile + key_prefix
|
|
76
|
+
- `#master_playlist_url`, `#variant_playlist_url(name)`, `#poster_url(name)`
|
|
77
|
+
— convenience that delegate to manifest
|
|
78
|
+
|
|
79
|
+
### 2. The `has_hls_video` macro
|
|
80
|
+
|
|
81
|
+
In `lib/hls/active_storage/macros.rb`:
|
|
82
|
+
|
|
83
|
+
```ruby
|
|
84
|
+
module HLS::ActiveStorage::Macros
|
|
85
|
+
def has_hls_video(name, profile:, source: nil)
|
|
86
|
+
# Define an AR association: has_one :<name>_hls_bundle, ->{ where(name: name) }, class_name: "HLS::Bundle"
|
|
87
|
+
# Define a reader: def <name> = <name>_hls_bundle || build_<name>_hls_bundle
|
|
88
|
+
# Register an after_attach hook on `source` (default: source_video)
|
|
89
|
+
# that enqueues HLS::EncodeJob with profile + bundle id
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
ActiveRecord::Base.extend HLS::ActiveStorage::Macros
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Loaded by the Railtie via `ActiveSupport.on_load(:active_record)`.
|
|
97
|
+
|
|
98
|
+
### 3. Encode trigger
|
|
99
|
+
|
|
100
|
+
When `source_video.attach(...)` happens:
|
|
101
|
+
|
|
102
|
+
1. AS fires `after_commit` on the attachment.
|
|
103
|
+
2. We compute the source blob's digest.
|
|
104
|
+
3. For each `has_hls_video` declaration on the model:
|
|
105
|
+
- Find or create the matching `HLS::Bundle` row
|
|
106
|
+
- Set `status: "pending"`, store `input_digest`
|
|
107
|
+
- Enqueue `HLS::EncodeJob.perform_later(bundle_id: bundle.id)`
|
|
108
|
+
|
|
109
|
+
The EncodeJob:
|
|
110
|
+
|
|
111
|
+
1. Loads the bundle, sets `status: "encoding"`
|
|
112
|
+
2. Downloads the source blob to a tmpdir
|
|
113
|
+
3. Resolves the profile class
|
|
114
|
+
4. Runs `profile.new(input:, output:, key_prefix: bundle.key_prefix).process`
|
|
115
|
+
5. On success: `bundle.update!(status: "ready", encoded_at: Time.now, renditions: ...)`
|
|
116
|
+
6. On failure: `bundle.update!(status: "failed", error_message: e.message)` + re-raise
|
|
117
|
+
|
|
118
|
+
### 4. Key prefix derivation
|
|
119
|
+
|
|
120
|
+
Default: `"#{model.class.name.underscore}/#{model.id}/#{bundle.name}"`
|
|
121
|
+
e.g. `course/42/web`. Configurable via the macro:
|
|
122
|
+
|
|
123
|
+
```ruby
|
|
124
|
+
has_hls_video :web, profile: WebVideo, key_prefix: ->(model) { "videos/#{model.public_id}/web" }
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### 5. Re-encoding when source changes
|
|
128
|
+
|
|
129
|
+
The `input_digest` lives on the bundle row. When `source_video.attach`
|
|
130
|
+
fires with a *new* blob:
|
|
131
|
+
|
|
132
|
+
- New digest != stored digest → enqueue encode (replacing the bundle)
|
|
133
|
+
- Same digest → no-op
|
|
134
|
+
|
|
135
|
+
This makes attaching the same file twice a no-op while still allowing
|
|
136
|
+
re-uploads to trigger re-encodes.
|
|
137
|
+
|
|
138
|
+
### 6. Cleanup
|
|
139
|
+
|
|
140
|
+
When the model is destroyed:
|
|
141
|
+
|
|
142
|
+
- `HLS::Bundle` rows go via `dependent: :destroy`
|
|
143
|
+
- A separate cleanup job deletes the bucket prefix (we don't block model
|
|
144
|
+
destroy on S3 calls)
|
|
145
|
+
|
|
146
|
+
### 7. Generators
|
|
147
|
+
|
|
148
|
+
`bin/rails generate hls:install` — creates the migration, an
|
|
149
|
+
`app/videos/application_video.rb`, and a config initializer skeleton.
|
|
150
|
+
|
|
151
|
+
`bin/rails generate hls:profile WebVideo` — creates
|
|
152
|
+
`app/videos/web_video.rb` with a sensible rendition + poster default.
|
|
153
|
+
|
|
154
|
+
### 8. Tests
|
|
155
|
+
|
|
156
|
+
In `spec/integration/active_storage_spec.rb`:
|
|
157
|
+
|
|
158
|
+
- Boot dummy app with AS configured (already supported in spec/dummy?)
|
|
159
|
+
- Define a `Course` model with `has_one_attached :source_video` and
|
|
160
|
+
`has_hls_video :web, profile: TestProfile`
|
|
161
|
+
- Attach the test fixture from `HLS::Testing.generate_test_video`
|
|
162
|
+
- Assert: `Course#web` returns a Bundle, status transitions
|
|
163
|
+
`pending → encoding → ready` (with inline ActiveJob), bundle key
|
|
164
|
+
prefix lands in the stub bucket, `manifest.master_playlist` works
|
|
165
|
+
- Assert: re-attaching the same file is a no-op
|
|
166
|
+
- Assert: attaching a different file re-enqueues
|
|
167
|
+
- Assert: destroying the course destroys the bundle row
|
|
168
|
+
|
|
169
|
+
## Acceptance
|
|
170
|
+
|
|
171
|
+
- [ ] `has_hls_video` macro is callable on `ActiveRecord::Base`
|
|
172
|
+
subclasses inside a Rails app.
|
|
173
|
+
- [ ] Attaching to the configured source attribute enqueues an encode
|
|
174
|
+
job.
|
|
175
|
+
- [ ] After the job runs, `model.<name>.ready?` is true and
|
|
176
|
+
`model.<name>.master_playlist_url` returns a signed URL.
|
|
177
|
+
- [ ] The bucket key prefix follows the configured shape.
|
|
178
|
+
- [ ] Re-attaching the same source is a no-op.
|
|
179
|
+
- [ ] Re-attaching a different source triggers re-encoding.
|
|
180
|
+
- [ ] Multiple `has_hls_video` declarations on the same model produce
|
|
181
|
+
independent bundles in independent profiles.
|
|
182
|
+
- [ ] Failed encodes mark the bundle `failed` with `error_message`
|
|
183
|
+
populated.
|
|
184
|
+
- [ ] Generator commands work end-to-end against `spec/dummy`.
|
|
185
|
+
- [ ] Integration spec exercises the full attach → encode → manifest
|
|
186
|
+
→ signed URL path.
|
|
187
|
+
|
|
188
|
+
## Open questions
|
|
189
|
+
|
|
190
|
+
### Should bundles live in their own bucket or in the AS bucket?
|
|
191
|
+
|
|
192
|
+
**Probably their own.** Reasoning:
|
|
193
|
+
- HLS bundles are 10–100× the size of source uploads. Bucket-level
|
|
194
|
+
budgeting matters.
|
|
195
|
+
- They have different cache-control / lifecycle rules
|
|
196
|
+
(segments are immutable + long-cached, AS blobs aren't).
|
|
197
|
+
- They use different credentials in production (often different IAM
|
|
198
|
+
policies for who can read/write).
|
|
199
|
+
|
|
200
|
+
The macro could accept `bucket: ...` to override. Default comes from
|
|
201
|
+
`Rails.application.config.hls.bucket`.
|
|
202
|
+
|
|
203
|
+
### Are HLS bundles ActiveStorage Variants in disguise?
|
|
204
|
+
|
|
205
|
+
**No, but related.** AS Variants:
|
|
206
|
+
- Are derivatives of *one* blob
|
|
207
|
+
- Are serialized into the URL itself (transformations encoded as
|
|
208
|
+
signed params)
|
|
209
|
+
- Produce *one* output blob
|
|
210
|
+
|
|
211
|
+
HLS bundles:
|
|
212
|
+
- Are derivatives of one blob, BUT produce a directory tree, not a
|
|
213
|
+
single file
|
|
214
|
+
- Need a database row to track encoding state (variants don't have state)
|
|
215
|
+
- Have multiple output objects per "variant" (the profile)
|
|
216
|
+
|
|
217
|
+
So the *interface* is variant-shaped (`course.web` feels like
|
|
218
|
+
`course.source_video.variant(:web)`) but the storage and lifecycle are
|
|
219
|
+
fundamentally different. A pure-Variant implementation would lose the
|
|
220
|
+
state tracking we need for async encodes.
|
|
221
|
+
|
|
222
|
+
### What about polymorphic bundles?
|
|
223
|
+
|
|
224
|
+
`HLS::Bundle` is `belongs_to :owner, polymorphic: true`, so any model
|
|
225
|
+
can `has_hls_video`. This is the standard AS pattern.
|
|
226
|
+
|
|
227
|
+
### Sidecar state vs DB
|
|
228
|
+
|
|
229
|
+
Step 02 has a `.hls-state.json` sidecar in the bucket. Once we have a DB
|
|
230
|
+
row, do we need both?
|
|
231
|
+
|
|
232
|
+
**Keep both.** The sidecar is the source of truth for the *bundle
|
|
233
|
+
contents* (which segments uploaded, etag/digest pairs); the DB row is
|
|
234
|
+
the source of truth for the *workflow* (status, input digest, encoded_at).
|
|
235
|
+
They serve different purposes:
|
|
236
|
+
|
|
237
|
+
- Sidecar survives a database wipe — bundle is reconstructable.
|
|
238
|
+
- DB row survives a bucket wipe — workflow state isn't lost.
|
|
239
|
+
|
|
240
|
+
The EncodeJob writes to both: the uploader maintains the sidecar
|
|
241
|
+
incrementally, the job updates the DB row at coarse milestones
|
|
242
|
+
(start/finish/fail).
|
|
243
|
+
|
|
244
|
+
### Migration path for existing course videos
|
|
245
|
+
|
|
246
|
+
The server already has Tigris-stored bundles encoded by the legacy
|
|
247
|
+
script. Two paths:
|
|
248
|
+
|
|
249
|
+
1. **Backfill**: a rake task creates `HLS::Bundle` rows pointing at
|
|
250
|
+
existing key prefixes, status=`ready`, no source blob attached.
|
|
251
|
+
Existing videos work; new ones go through AS.
|
|
252
|
+
2. **Re-encode**: re-attach existing sources through AS, let the
|
|
253
|
+
pipeline produce new bundles. Higher cost, cleaner long-term.
|
|
254
|
+
|
|
255
|
+
Option 1 first; option 2 only if needed.
|
metadata
CHANGED
|
@@ -1,42 +1,42 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: hls
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Brad Gessler
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date:
|
|
10
|
+
date: 2026-05-06 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
|
-
name:
|
|
13
|
+
name: m3u8
|
|
14
14
|
requirement: !ruby/object:Gem::Requirement
|
|
15
15
|
requirements:
|
|
16
16
|
- - "~>"
|
|
17
17
|
- !ruby/object:Gem::Version
|
|
18
|
-
version:
|
|
18
|
+
version: 0.8.0
|
|
19
19
|
type: :runtime
|
|
20
20
|
prerelease: false
|
|
21
21
|
version_requirements: !ruby/object:Gem::Requirement
|
|
22
22
|
requirements:
|
|
23
23
|
- - "~>"
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
|
-
version:
|
|
25
|
+
version: 0.8.0
|
|
26
26
|
- !ruby/object:Gem::Dependency
|
|
27
|
-
name:
|
|
27
|
+
name: aws-sdk-s3
|
|
28
28
|
requirement: !ruby/object:Gem::Requirement
|
|
29
29
|
requirements:
|
|
30
30
|
- - "~>"
|
|
31
31
|
- !ruby/object:Gem::Version
|
|
32
|
-
version:
|
|
32
|
+
version: '1.0'
|
|
33
33
|
type: :runtime
|
|
34
34
|
prerelease: false
|
|
35
35
|
version_requirements: !ruby/object:Gem::Requirement
|
|
36
36
|
requirements:
|
|
37
37
|
- - "~>"
|
|
38
38
|
- !ruby/object:Gem::Version
|
|
39
|
-
version:
|
|
39
|
+
version: '1.0'
|
|
40
40
|
description: Create sane HLS video with a few commands and serve it up from an S3
|
|
41
41
|
object store.
|
|
42
42
|
email:
|
|
@@ -46,11 +46,39 @@ extensions: []
|
|
|
46
46
|
extra_rdoc_files: []
|
|
47
47
|
files:
|
|
48
48
|
- ".rspec"
|
|
49
|
+
- CHANGELOG.md
|
|
50
|
+
- CLAUDE.md
|
|
49
51
|
- README.md
|
|
50
52
|
- Rakefile
|
|
51
53
|
- examples/directory.rb
|
|
54
|
+
- lib/generators/hls/install/install_generator.rb
|
|
55
|
+
- lib/generators/hls/install/templates/application_video.rb
|
|
56
|
+
- lib/generators/hls/install/templates/initializer.rb
|
|
57
|
+
- lib/generators/hls/video/templates/video.rb
|
|
58
|
+
- lib/generators/hls/video/video_generator.rb
|
|
52
59
|
- lib/hls.rb
|
|
60
|
+
- lib/hls/application_video.rb
|
|
61
|
+
- lib/hls/cache.rb
|
|
62
|
+
- lib/hls/codecs.rb
|
|
63
|
+
- lib/hls/directory.rb
|
|
64
|
+
- lib/hls/encode_job.rb
|
|
65
|
+
- lib/hls/input.rb
|
|
66
|
+
- lib/hls/instrumentation.rb
|
|
67
|
+
- lib/hls/lock.rb
|
|
68
|
+
- lib/hls/manifest.rb
|
|
69
|
+
- lib/hls/railtie.rb
|
|
70
|
+
- lib/hls/state.rb
|
|
71
|
+
- lib/hls/storage.rb
|
|
72
|
+
- lib/hls/testing.rb
|
|
73
|
+
- lib/hls/uploader.rb
|
|
53
74
|
- lib/hls/version.rb
|
|
75
|
+
- plans/00-goal.md
|
|
76
|
+
- plans/01-profile-dsl.md
|
|
77
|
+
- plans/02-upload-pipeline.md
|
|
78
|
+
- plans/03-reader-and-manifest.md
|
|
79
|
+
- plans/04-codec-portability.md
|
|
80
|
+
- plans/05-server-migration.md
|
|
81
|
+
- plans/06-activestorage-adapter.md
|
|
54
82
|
- sig/hls.rbs
|
|
55
83
|
homepage: https://github.com/beautifulruby/hls
|
|
56
84
|
licenses: []
|