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,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.
|