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,140 @@
1
+ # 02 — Upload pipeline
2
+
3
+ ## Goal
4
+
5
+ After ffmpeg writes the HLS bundle to a local working directory, push every
6
+ file (master playlist, variant playlists, segments, poster) to the profile's
7
+ configured bucket. Track what got uploaded so re-runs are no-ops and crashed
8
+ runs resume.
9
+
10
+ ```ruby
11
+ CourseVideo.process(input: "lecture.mp4")
12
+ # probe → encode → upload → state.json
13
+ # Re-run → reads state.json, does nothing.
14
+ # Crashed mid-upload → resumes from last successful key.
15
+ ```
16
+
17
+ ## Preconditions
18
+
19
+ - [01 — Profile DSL](01-profile-dsl.md) done. `CourseVideo.bucket` and
20
+ `CourseVideo.new(input:, output:)#process` exist.
21
+
22
+ ## Work
23
+
24
+ ### 1. `HLS::Uploader` step
25
+
26
+ In `lib/hls/uploader.rb`:
27
+
28
+ - `Uploader.new(profile:, output:, bucket:, key_prefix:)`
29
+ - `#perform` walks `output` recursively, uploads each file to
30
+ `bucket.object(key_prefix + relative_path)`.
31
+ - Sets `Content-Type` correctly per extension (m3u8, ts, jpg).
32
+ - Sets `Cache-Control: public, max-age=31536000, immutable` for segments
33
+ and posters; shorter (or no-cache) for playlists.
34
+ - Computes a content hash (SHA256 or MD5/etag) per file before upload;
35
+ skips upload if remote etag matches.
36
+
37
+ ### 2. Sidecar state file
38
+
39
+ `output/.hls-state.json` records:
40
+
41
+ ```json
42
+ {
43
+ "input_digest": "sha256:...",
44
+ "profile": "CourseVideo",
45
+ "renditions": [
46
+ { "name": "full", "width": 1920, "height": 1080, "bitrate": 5000 },
47
+ { "name": "medium", "width": 960, "height": 540, "bitrate": 1500 }
48
+ ],
49
+ "encoded_at": "2026-05-01T20:14:00Z",
50
+ "uploads": {
51
+ "index.m3u8": { "etag": "...", "uploaded_at": "..." },
52
+ "0/index.m3u8": { "etag": "...", "uploaded_at": "..." },
53
+ "0/0.ts": { "etag": "...", "uploaded_at": "..." }
54
+ }
55
+ }
56
+ ```
57
+
58
+ - Encode step writes `input_digest`, `renditions`, `encoded_at`. Skips
59
+ encode if digest matches and all rendition output files exist.
60
+ - Upload step writes the `uploads` map incrementally, one entry per
61
+ successful PUT. Skips already-uploaded keys whose local hash matches
62
+ the recorded etag.
63
+
64
+ ### 3. Pipeline orchestration on the profile
65
+
66
+ ```ruby
67
+ class HLS::ApplicationVideo
68
+ def process
69
+ probe! # ffprobe → input metadata
70
+ encode! unless encoded?
71
+ upload! unless uploaded?
72
+ state.save
73
+ end
74
+ end
75
+ ```
76
+
77
+ Failure semantics:
78
+
79
+ - Probe failure → raise, no state written.
80
+ - Encode failure → raise, partial output left on disk (next run's
81
+ `encoded?` check sees missing files and re-encodes).
82
+ - Upload failure → raise, state.json captures progress so far.
83
+
84
+ ### 4. ActiveJob wrapper
85
+
86
+ In `lib/hls/encode_job.rb`:
87
+
88
+ ```ruby
89
+ class HLS::EncodeJob < ActiveJob::Base
90
+ def perform(profile_class_name, input_path, output_path)
91
+ profile = profile_class_name.constantize
92
+ profile.new(input: HLS::Input.new(input_path),
93
+ output: Pathname.new(output_path)).process
94
+ end
95
+ end
96
+ ```
97
+
98
+ - App enqueues with whatever queue adapter it uses (the server uses
99
+ SolidQueue per its Procfile.dev).
100
+ - Single job per video. The multiplex (multiple ffmpeg renditions in one
101
+ invocation) keeps it from needing fan-out.
102
+
103
+ ### 5. Drop `Parallel.each` default
104
+
105
+ Today `lib/hls.rb:350-355` runs N ffmpegs concurrently using
106
+ `Etc.nprocessors - 1`. Each ffmpeg already multithreads internally, so
107
+ this thrashes on most machines. Change `HLS::Jobs#process` default to
108
+ `in_processes: 1`. Keep the knob; document the trade-off.
109
+
110
+ ### 6. Subprocess error handling
111
+
112
+ `HLS::Jobs#ffmpeg` (lib/hls.rb:364-369) uses bare `system(*cmd)` which
113
+ ignores the exit status. Replace with one of:
114
+
115
+ - `system(*cmd, exception: true)` (raises on non-zero), or
116
+ - A `Process.spawn` + `Process.wait2` pattern that captures stderr for
117
+ the error message.
118
+
119
+ ## Acceptance
120
+
121
+ - [x] `bin/rails runner 'CourseVideo.new(input: HLS::Input.new("spec/fixtures/sample.mp4"), output: Pathname.new("tmp/out")).process'`
122
+ produces a complete bundle in Tigris (or in a stubbed S3 in tests).
123
+ - [x] Re-running the same command issues zero PUT requests.
124
+ - [x] Killing the process mid-upload and re-running completes only the
125
+ remaining keys.
126
+ - [x] Bumping the input file (different digest) triggers a full re-encode.
127
+ - [x] An ffmpeg failure raises, doesn't silently mark the job complete.
128
+ - [x] `HLS::EncodeJob` enqueues and runs end-to-end against a SolidQueue
129
+ worker in `spec/dummy`.
130
+ - [x] Upload step verified against a stubbed S3 client (no live Tigris
131
+ calls in tests).
132
+
133
+ ## Open questions
134
+
135
+ - Does `state.json` live in S3 alongside the bundle, in the local working
136
+ dir, or both? Probably **both** — local for fast resume, S3 as the
137
+ source of truth that survives ephemeral workers.
138
+ - Do we upload via threaded concurrency? S3 PUTs are I/O bound and a pool
139
+ of ~8 threads would speed up large bundles materially. Default to
140
+ threaded; size configurable.
@@ -0,0 +1,128 @@
1
+ # 03 — Reader & Manifest
2
+
3
+ ## Goal
4
+
5
+ Move the read-side logic — pre-signing, master/variant playlist
6
+ rewriting, range slicing for previews — out of the server and into the
7
+ gem. The Rails controller becomes a five-line shim that delegates to a
8
+ profile-aware manifest.
9
+
10
+ ```ruby
11
+ # Today, in server/app/controllers/videos_controller.rb:
12
+ list = @video.master_playlist
13
+ list.items.each do |item|
14
+ item.uri = File.join(params.fetch(:id), "#{File.dirname(item.uri)}.m3u8")
15
+ end
16
+ render plain: list
17
+
18
+ # After this step:
19
+ render plain: CourseVideo.manifest(video_path).master_playlist
20
+ ```
21
+
22
+ ## Preconditions
23
+
24
+ - [01 — Profile DSL](01-profile-dsl.md) done. Profiles know their
25
+ `bucket` and `signing_ttl`.
26
+
27
+ ## Work
28
+
29
+ ### 1. Move `Video` and `Variant` into the gem
30
+
31
+ Source: `server/app/models/video.rb` (lines 1-119).
32
+
33
+ Target: `lib/hls/manifest.rb`.
34
+
35
+ Rename:
36
+
37
+ - `Video` → `HLS::Manifest`
38
+ - `Video::Variant` → `HLS::Manifest::Variant`
39
+
40
+ Keep:
41
+
42
+ - `presigned_url`, `master_playlist`, `variants`, `variant(index)` —
43
+ unchanged in spirit.
44
+ - `Variant#[range]` slicing logic (the preview-window math at
45
+ `video.rb:87-101`) — keep exactly as-is.
46
+ - `Variant#playlist` URI rewriting (`video.rb:103-112`) — keep.
47
+
48
+ Add:
49
+
50
+ - `Manifest#master_playlist` should do the URI rewrite the controller
51
+ currently does inline (`videos_controller.rb:36-40`):
52
+ joining `params[:id]` and turning each item.uri into
53
+ `<id>/<dirname>.m3u8`. Make this part of the manifest, not the
54
+ controller.
55
+
56
+ Drop:
57
+
58
+ - The hardcoded `Aws::S3::Resource` constant block at `video.rb:4-11`.
59
+ The S3 client comes from the profile's configured bucket (set up in
60
+ step 01 via the Railtie).
61
+
62
+ ### 2. Profile entry point
63
+
64
+ ```ruby
65
+ class HLS::ApplicationVideo
66
+ def self.manifest(path, expires_in: signing_ttl)
67
+ HLS::Manifest.new(
68
+ bucket:,
69
+ path:,
70
+ expires_in:,
71
+ segment_duration:
72
+ )
73
+ end
74
+ end
75
+ ```
76
+
77
+ `CourseVideo.manifest("phlex/forms/overview")` returns a manifest bound
78
+ to the profile's bucket + TTL.
79
+
80
+ ### 3. Poster handling
81
+
82
+ `Manifest#poster_url` keeps the `presigned_url("poster.jpg")` shape from
83
+ `video.rb:25-27`. Profile classes expose `poster_filename` (currently
84
+ hardcoded `"poster.jpg"` in `HLS::Poster::FILENAME`) so this stays in
85
+ sync.
86
+
87
+ ### 4. Controller cleanup
88
+
89
+ In `server/app/controllers/videos_controller.rb`:
90
+
91
+ - Replace `@video = Video.new(...)` with
92
+ `@manifest = CourseVideo.manifest(video_path)`.
93
+ - `format.m3u8` index → `render plain: @manifest.master_playlist`.
94
+ - `format.m3u8` show → `render plain: @manifest.variants.find(...) ...`.
95
+ The preview slicing path (`variant[0...PREVIEW_DURATION]`) keeps the
96
+ same shape since `Variant#[]` moved verbatim.
97
+ - `format.jpeg` → `@manifest.poster_url`.
98
+
99
+ ### 5. Delete the old model
100
+
101
+ After step 05 ships, delete `server/app/models/video.rb`. Until then,
102
+ keep both forms working so the migration is reversible.
103
+
104
+ ## Acceptance
105
+
106
+ - [x] `HLS::Manifest` exists in the gem with `master_playlist`,
107
+ `variants`, `variant(i)`, `poster_url`, `presigned_url`.
108
+ - [x] `HLS::Manifest::Variant#[range]` slicing produces the same segment
109
+ counts as today's `Video::Variant#[range]` — covered by the
110
+ existing tests if they exist, or by new tests if they don't.
111
+ - [x] `CourseVideo.manifest(path).master_playlist` produces a playlist
112
+ byte-identical to what `videos_controller.rb` produces today for a
113
+ fixture bundle.
114
+ - [x] Pre-signed URL TTLs are sourced from the profile's `signing_ttl`.
115
+ - [x] `format.jpeg` poster path returns the same pre-signed URL shape
116
+ (different signature, same key).
117
+ - [x] Tests verify URI rewriting against a stubbed S3 client.
118
+ - [x] No remaining references to `Aws::S3::Resource.new` in the server
119
+ app outside of one config location (the Railtie wiring).
120
+
121
+ ## Open questions
122
+
123
+ - Should `Manifest` accept any S3-compatible client, or be tied to
124
+ `Aws::S3::Bucket`? Tied is fine; that's what the existing code does
125
+ and what Tigris ships.
126
+ - Range/byte-range slicing for non-preview cases (e.g. chapter markers)
127
+ — out of scope here, but the `Variant#[]` API is general enough that
128
+ it can grow into that later.
@@ -0,0 +1,90 @@
1
+ # 04 — Codec portability
2
+
3
+ ## Goal
4
+
5
+ The encode runs on Linux workers (Fly.io, CI) and macOS dev laptops
6
+ without code changes. macOS picks `h264_videotoolbox` for speed; Linux
7
+ falls back to `libx264`. Profiles can override.
8
+
9
+ ## Preconditions
10
+
11
+ - [01 — Profile DSL](01-profile-dsl.md) done — there's a place for the
12
+ `video_codec` knob to live on the profile class.
13
+
14
+ ## Work
15
+
16
+ ### 1. Codec resolution
17
+
18
+ Resolution order, top wins:
19
+
20
+ 1. Profile DSL: `video_codec encoder: "libx264"` (explicit string).
21
+ 2. Profile DSL: `video_codec :h264` (logical, auto-resolved per host).
22
+ 3. App-wide default: `Rails.application.config.hls.default_codec`.
23
+ 4. Built-in default: `:h264`.
24
+
25
+ Logical → encoder mapping:
26
+
27
+ ```ruby
28
+ HLS::Codecs::H264 = {
29
+ videotoolbox: "h264_videotoolbox", # macOS hardware
30
+ nvenc: "h264_nvenc", # NVIDIA GPU
31
+ qsv: "h264_qsv", # Intel QuickSync
32
+ libx264: "libx264" # software fallback
33
+ }
34
+ ```
35
+
36
+ ### 2. Auto-detect
37
+
38
+ When the profile asks for `:h264` (logical), pick the first available
39
+ encoder by querying `ffmpeg -hide_banner -encoders` once at boot and
40
+ caching the list. Order:
41
+
42
+ - macOS: `videotoolbox` → `libx264`.
43
+ - Linux with `nvenc`: `nvenc` → `libx264`.
44
+ - Linux with `qsv`: `qsv` → `libx264`.
45
+ - Otherwise: `libx264`.
46
+
47
+ Don't shell out per encode; cache the encoder list at first use.
48
+
49
+ ### 3. Per-encoder option blocks
50
+
51
+ `HLS::Video::Base#video_codec_options` (lib/hls.rb:183-201) already
52
+ branches on encoder name. Keep that pattern; add `h264_nvenc` and
53
+ `h264_qsv` blocks (preset, rate control). The existing libx264 block
54
+ stays.
55
+
56
+ ### 4. CI
57
+
58
+ Add a Linux job (GitHub Actions, ubuntu-latest) that installs ffmpeg
59
+ and runs:
60
+
61
+ - `bundle exec rspec` (existing tests should pass on Linux).
62
+ - A small "encode a 5-second fixture" integration test that verifies
63
+ output files exist and ffprobe reports the expected codec.
64
+
65
+ ### 5. Removal
66
+
67
+ `HLS::Video::VTechWatch` is gone after step 01. The hardcoded
68
+ `VIDEO_CODEC = "h264_videotoolbox"` constant at `lib/hls.rb:79` becomes
69
+ the resolution rules above.
70
+
71
+ ## Acceptance
72
+
73
+ - [x] `bundle exec rspec` passes on Linux CI.
74
+ - [x] CI integration test successfully encodes a fixture video using
75
+ `libx264`.
76
+ - [x] On macOS, `CourseVideo.new(input:, output:).command` includes
77
+ `h264_videotoolbox` (or whatever the profile resolves to).
78
+ - [x] On Linux (no GPU), the same call resolves to `libx264`.
79
+ - [x] Explicit `video_codec encoder: "libx264"` overrides auto-detect on
80
+ both platforms.
81
+ - [x] No unconditional reference to `h264_videotoolbox` anywhere in the
82
+ gem.
83
+
84
+ ## Open questions
85
+
86
+ - Should we ship a Dockerfile fragment / Fly.io worker recipe with this
87
+ plan, or leave it to the consuming app? Probably leave it; document
88
+ the ffmpeg version we test against in the README.
89
+ - AV1 / HEVC support — out of scope. This step is H.264 only. Add a
90
+ separate plan if that becomes interesting.
@@ -0,0 +1,126 @@
1
+ # 05 — Server migration
2
+
3
+ ## Goal
4
+
5
+ Land everything from steps 01-04 in `../server` without breaking the
6
+ live course at beautifulruby.com. The currently-deployed `Video` model
7
+ + `VideosController` keep working through every intermediate state.
8
+
9
+ ## Preconditions
10
+
11
+ - [01](01-profile-dsl.md), [02](02-upload-pipeline.md),
12
+ [03](03-reader-and-manifest.md), [04](04-codec-portability.md) all
13
+ done. Gem is releasable on its own.
14
+
15
+ ## Work
16
+
17
+ ### 1. Pin gem to local path during migration
18
+
19
+ In `../server/Gemfile`:
20
+
21
+ ```ruby
22
+ gem "hls", path: "../hls"
23
+ ```
24
+
25
+ Bundle. Run server. Verify nothing breaks before changing any server
26
+ code (the new gem should still be backwards-compatible with the old
27
+ `HLS::Video::Scalable` API used by `examples/directory.rb`).
28
+
29
+ ### 2. Add profile classes
30
+
31
+ Create:
32
+
33
+ - `../server/app/videos/application_video.rb` — sets bucket from
34
+ `VIDEO_S3_BUCKET_NAME`, signing TTL `1.hour`,
35
+ `segment_duration 4` (matches today's `Video::SEGMENT_DURATION`).
36
+ - `../server/app/videos/course_video.rb` — three scaled renditions
37
+ matching `HLS::Video::Scalable`'s 1.0 / 0.5 / 0.25 split.
38
+
39
+ ### 3. Cut the controller over to `Manifest`
40
+
41
+ Edit `../server/app/controllers/videos_controller.rb`:
42
+
43
+ - Replace `@video = Video.new(path: video_path, expires_in: ...)` with
44
+ `@manifest = CourseVideo.manifest(video_path)`.
45
+ - `format.m3u8` index: drop the inline URI rewriting loop
46
+ (`videos_controller.rb:36-40`); the manifest handles it.
47
+ - `format.m3u8` show: keep the `variant.find { ... }` lookup and the
48
+ `variant[0...PREVIEW_DURATION]` slice; both still work because
49
+ `Manifest::Variant` is a verbatim move from `Video::Variant`.
50
+ - `format.jpeg`: replace `@video.poster_url` with
51
+ `@manifest.poster_url`.
52
+
53
+ Run the existing controller specs (if they exist) or smoke-test by
54
+ hitting `/videos/<known-id>.m3u8` and diffing the response against a
55
+ saved baseline.
56
+
57
+ ### 4. Delete `app/models/video.rb`
58
+
59
+ Once the controller no longer references it, delete
60
+ `../server/app/models/video.rb`. Search for any other call sites
61
+ (`grep -r "\bVideo\.new" ../server/app`); migrate or remove.
62
+
63
+ `VideoPlan` (`../server/app/plans/video_plan.rb`) operates on
64
+ `@video_page`, not on `Video`, so it should be unaffected — verify.
65
+
66
+ ### 5. Add the encode pipeline to the server
67
+
68
+ Replace the local-disk script (`hls/examples/directory.rb`) with an
69
+ ActiveJob-backed flow:
70
+
71
+ - Create `../server/app/jobs/encode_course_video_job.rb` that wraps
72
+ `HLS::EncodeJob` with the `CourseVideo` profile.
73
+ - Wire whatever drops a new mp4 into the system to enqueue the job.
74
+ Likely options: a CLI script `bin/encode-video <path>`, a watch
75
+ folder, or a Rails admin form. Pick one — start with the CLI, defer
76
+ the watch folder if not needed.
77
+
78
+ ### 6. Verify on a sacrificial video
79
+
80
+ Encode a non-customer-facing test video through the new pipeline.
81
+ Verify:
82
+
83
+ - The bundle lands in Tigris under the expected key prefix.
84
+ - A second run is a no-op.
85
+ - Hitting `/videos/<test-id>.m3u8` plays the video in a real browser
86
+ (Safari + Chrome with `hls.js` polyfill).
87
+
88
+ ### 7. Cut over
89
+
90
+ - Tag the gem (`bundle exec rake release`).
91
+ - Switch `Gemfile` from `path:` to a version pin.
92
+ - Deploy `../server`.
93
+ - Re-encode existing videos through the new pipeline as needed (most
94
+ should be fine since the storage layout matches what the old script
95
+ produced — verify per video).
96
+
97
+ ## Acceptance
98
+
99
+ - [x] All existing course videos still play after deploy. Spot-check
100
+ ten across different courses.
101
+ - [x] Locked-content preview window still cuts off at 30s.
102
+ - [x] Twitter card poster still renders (`format.jpeg` path).
103
+ - [x] A new mp4 dropped through the new pipeline plays end-to-end in a
104
+ browser.
105
+ - [x] `../server/app/models/video.rb` deleted; no dangling references.
106
+ - [x] `Gemfile` references a released gem version, not a `path:`.
107
+ - [x] Job queue (SolidQueue) processes encode jobs successfully on the
108
+ production-shaped worker.
109
+
110
+ ## Rollback plan
111
+
112
+ - Keep the previous gem version pinned in `Gemfile.lock` between the
113
+ release commit and the deploy commit so a single `git revert` brings
114
+ the old `Video` model back if something breaks.
115
+ - Sidecar state files in S3 are additive — they don't disturb the
116
+ existing storage layout, so a rollback to the old reader doesn't see
117
+ inconsistent state.
118
+
119
+ ## Open questions
120
+
121
+ - Do we need a one-time backfill that writes `state.json` for already-
122
+ encoded videos, so the new pipeline knows not to re-encode them?
123
+ Probably yes for any video larger than a few hundred MB.
124
+ - Should `bin/encode-video` live in the gem (as a generic CLI) or in
125
+ the server (as an app-specific script)? Probably the server, since
126
+ it knows which profile class to use.