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.
@@ -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.1.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: 2025-07-25 00:00:00.000000000 Z
10
+ date: 2026-05-06 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
- name: bigdecimal
13
+ name: m3u8
14
14
  requirement: !ruby/object:Gem::Requirement
15
15
  requirements:
16
16
  - - "~>"
17
17
  - !ruby/object:Gem::Version
18
- version: '3.0'
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: '3.0'
25
+ version: 0.8.0
26
26
  - !ruby/object:Gem::Dependency
27
- name: m3u8
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: 0.8.0
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: 0.8.0
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: []