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,211 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "aws-sdk-s3"
4
+ require "digest"
5
+ require "pathname"
6
+
7
+ require_relative "instrumentation"
8
+ require_relative "lock"
9
+
10
+ module HLS
11
+ # Walks an encoded HLS bundle and pushes each file to the configured
12
+ # bucket. Idempotent and resumable: a state sidecar tracks per-file
13
+ # MD5 digests, and files whose remote upload matches the local digest
14
+ # are skipped.
15
+ class Uploader
16
+ CONTENT_TYPES = {
17
+ ".m3u8" => "application/vnd.apple.mpegurl",
18
+ ".ts" => "video/MP2T",
19
+ ".jpg" => "image/jpeg",
20
+ ".jpeg" => "image/jpeg",
21
+ ".png" => "image/png",
22
+ ".vtt" => "text/vtt"
23
+ }.freeze
24
+
25
+ CACHE_CONTROL_IMMUTABLE = "public, max-age=31536000, immutable"
26
+
27
+ # VOD playlists are also immutable once written — segments don't
28
+ # get rewritten, the playlist itself doesn't change. Use a shorter
29
+ # max-age than the segments themselves so deploys can publish a
30
+ # superseding bundle, but allow CDN caching at the playlist edge.
31
+ CACHE_CONTROL_PLAYLIST = "public, max-age=300"
32
+
33
+ # Default retries on top of the AWS SDK's own retry behavior. Bumped
34
+ # to absorb transient network errors during long, multi-object
35
+ # uploads where the SDK's retry budget per call has been exhausted.
36
+ DEFAULT_MAX_RETRIES = 3
37
+ DEFAULT_INITIAL_BACKOFF = 0.5
38
+
39
+ # Parallel upload workers. The SDK is thread-safe, S3 is throughput-
40
+ # bound on typical connections, and a 3-rendition bundle is dozens
41
+ # of small segments — overlapping their PUTs cuts wall-clock time
42
+ # significantly. 4 workers is a good default for home/office links;
43
+ # bump higher on a beefy server with a fat pipe.
44
+ DEFAULT_CONCURRENCY = 4
45
+
46
+ # Errors classed as transient and worth retrying. We deliberately
47
+ # don't include the broad Aws::Errors::ServiceError parent — a 403
48
+ # or NoSuchBucket should fail fast, not retry.
49
+ TRANSIENT_ERRORS = [
50
+ Seahorse::Client::NetworkingError,
51
+ Aws::S3::Errors::RequestTimeout,
52
+ Aws::S3::Errors::ServiceUnavailable,
53
+ Aws::S3::Errors::SlowDown,
54
+ Aws::S3::Errors::InternalError
55
+ ].freeze
56
+
57
+ attr_reader :storage, :output, :key_prefix, :state,
58
+ :max_retries, :initial_backoff, :concurrency
59
+
60
+ def initialize(storage:, output:, key_prefix:, state:,
61
+ max_retries: DEFAULT_MAX_RETRIES,
62
+ initial_backoff: DEFAULT_INITIAL_BACKOFF,
63
+ concurrency: DEFAULT_CONCURRENCY)
64
+ @storage = storage
65
+ @output = Pathname.new(output)
66
+ @key_prefix = key_prefix.to_s.sub(%r{\A/}, "").sub(%r{/\z}, "")
67
+ @state = state
68
+ @max_retries = max_retries
69
+ @initial_backoff = initial_backoff
70
+ @concurrency = [concurrency.to_i, 1].max
71
+ @state_mutex = Mutex.new
72
+ end
73
+
74
+ # Upload everything under the output directory that hasn't been
75
+ # uploaded yet. Returns a hash with :uploaded and :skipped counts.
76
+ def perform
77
+ pending = uploadable_files.filter_map do |file|
78
+ relative_key = relative_key_for(file)
79
+ digest = md5_of(file)
80
+ next nil if @state_mutex.synchronize {
81
+ state.uploaded?(relative_key: relative_key, digest: digest)
82
+ }
83
+ [file, relative_key, digest]
84
+ end
85
+
86
+ skipped = uploadable_files.size - pending.size
87
+ uploaded = upload_in_parallel(pending)
88
+
89
+ { uploaded: uploaded, skipped: skipped }
90
+ end
91
+
92
+ private
93
+
94
+ def upload_in_parallel(pending)
95
+ return 0 if pending.empty?
96
+
97
+ queue = Queue.new
98
+ pending.each { |item| queue << item }
99
+ concurrency.times { queue << :stop }
100
+
101
+ uploaded = 0
102
+ uploaded_mutex = Mutex.new
103
+ first_error = nil
104
+ error_mutex = Mutex.new
105
+
106
+ workers = Array.new([concurrency, pending.size].min) do
107
+ Thread.new do
108
+ loop do
109
+ item = queue.pop
110
+ break if item == :stop
111
+ break if error_mutex.synchronize { first_error }
112
+ file, relative_key, digest = item
113
+
114
+ begin
115
+ response = upload(file, relative_key: relative_key)
116
+ @state_mutex.synchronize do
117
+ state.record_upload(relative_key: relative_key, digest: digest, etag: response.etag)
118
+ state.save
119
+ end
120
+ uploaded_mutex.synchronize { uploaded += 1 }
121
+ rescue => e
122
+ error_mutex.synchronize { first_error ||= e }
123
+ break
124
+ end
125
+ end
126
+ end
127
+ end
128
+
129
+ workers.each(&:join)
130
+ raise first_error if first_error
131
+ uploaded
132
+ end
133
+
134
+ def upload(file, relative_key:)
135
+ key = key_for(relative_key)
136
+ ct = content_type_for(file)
137
+ response = nil
138
+ HLS::Instrumentation.instrument(:upload_object,
139
+ key: key, bytes: file.size, content_type: ct
140
+ ) do
141
+ with_retries(key: key) do
142
+ object = storage.object(key)
143
+ response = object.put(
144
+ body: file.open("rb"),
145
+ content_type: ct,
146
+ cache_control: cache_control_for(file)
147
+ )
148
+ end
149
+ end
150
+ response
151
+ end
152
+
153
+ # Retries a transient error a bounded number of times with
154
+ # exponential backoff. Per-attempt instrumentation lets the host
155
+ # app see retries happening (and decide whether the budget is too
156
+ # generous).
157
+ def with_retries(key:)
158
+ attempt = 0
159
+ backoff = initial_backoff
160
+ begin
161
+ yield
162
+ rescue *TRANSIENT_ERRORS => e
163
+ attempt += 1
164
+ raise if attempt > max_retries
165
+ HLS::Instrumentation.instrument(:upload_retry,
166
+ key: key, attempt: attempt, error: e.class.name, message: e.message
167
+ ) {}
168
+ sleep backoff
169
+ backoff *= 2
170
+ retry
171
+ end
172
+ end
173
+
174
+ def uploadable_files
175
+ output.glob("**/*")
176
+ .select { |p| p.file? }
177
+ .reject { |p| skip?(p) }
178
+ .sort
179
+ end
180
+
181
+ # Skip the state sidecar and lock file (we never upload them) plus
182
+ # any junk dotfiles macOS / editors leave behind (.DS_Store, ._*).
183
+ def skip?(path)
184
+ basename = path.basename.to_s
185
+ basename == State::FILENAME ||
186
+ basename == Lock::FILENAME ||
187
+ basename == ".DS_Store" ||
188
+ basename.start_with?("._")
189
+ end
190
+
191
+ def relative_key_for(file)
192
+ file.relative_path_from(output).to_s
193
+ end
194
+
195
+ def key_for(relative_key)
196
+ key_prefix.empty? ? relative_key : "#{key_prefix}/#{relative_key}"
197
+ end
198
+
199
+ def md5_of(file)
200
+ Digest::MD5.file(file).hexdigest
201
+ end
202
+
203
+ def content_type_for(file)
204
+ CONTENT_TYPES.fetch(file.extname.downcase, "application/octet-stream")
205
+ end
206
+
207
+ def cache_control_for(file)
208
+ file.extname.downcase == ".m3u8" ? CACHE_CONTROL_PLAYLIST : CACHE_CONTROL_IMMUTABLE
209
+ end
210
+ end
211
+ end
data/lib/hls/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module HLS
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/hls.rb CHANGED
@@ -1,212 +1,40 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "aws-sdk-s3"
4
+
3
5
  require_relative "hls/version"
4
- require "shellwords"
5
- require "pathname"
6
- require "json"
7
- require "m3u8"
8
6
 
9
7
  module HLS
10
8
  class Error < StandardError; end
11
9
 
12
- class Poster
13
- attr_reader :input, :output, :width, :height
14
-
15
- def initialize(input:, output:, width:, height:)
16
- @input = input
17
- @output = output.join("poster.jpg")
18
- @width = width
19
- @height = height
20
- end
10
+ class << self
11
+ # The Aws::S3::Resource the gem uses when an HLS::Storage::S3
12
+ # adapter is configured by `bucket_name:` (and resolves the bucket
13
+ # lazily through this resource). Set in `config/initializers/hls.rb`:
14
+ #
15
+ # HLS.s3_resource = Aws::S3::Resource.new(...)
16
+ #
17
+ # Storage::S3 instances may also pass `s3_resource:` directly to
18
+ # bypass this default.
19
+ attr_writer :s3_resource
21
20
 
22
- def command
23
- [
24
- # invoke ffmpeg
25
- "ffmpeg",
26
- # overwrite output files without confirmation
27
- "-y",
28
- # input video file
29
- "-i", input,
30
- # scale video to target resolution
31
- "-vf", "scale=w=#{width}:h=#{height}:force_original_aspect_ratio=decrease",
32
- # extract only one frame
33
- "-frames:v", "1",
34
- # output file path
35
- output
36
- ]
37
- end
38
- end
39
-
40
- module Video
41
- class Base
42
- attr_accessor :input, :output, :renditions
43
-
44
- PLAYLIST = "index.m3u8".freeze
45
-
46
- Rendition = Data.define(:width, :height, :bitrate)
47
-
48
- def initialize(input:, output:)
49
- @input = Input.new(input)
50
- @output = output
51
- @renditions = []
52
- end
53
-
54
- def downscaleable_renditions
55
- @renditions.select { |r| r.width <= input.width }
56
- end
57
-
58
- def rendition(...)
59
- @renditions << Rendition.new(...)
60
- end
61
-
62
- def command
63
- [
64
- "ffmpeg",
65
- "-y",
66
- "-i", @input.path,
67
- "-filter_complex", filter_complex
68
- ] + \
69
- video_maps + \
70
- audio_maps + \
71
- [
72
- "-f", "hls",
73
- "-var_stream_map", stream_map,
74
- "-master_pl_name", PLAYLIST,
75
- "-hls_time", "4",
76
- "-hls_playlist_type", "vod",
77
- "-hls_segment_filename", segment,
78
- playlist
79
- ]
80
- end
81
-
82
- private
83
-
84
- def filter_complex
85
- n = downscaleable_renditions.size
86
- split = "[0:v]split=#{n}#{(1..n).map { |i| "[v#{i}]" }.join}"
87
- scaled = downscaleable_renditions.each_with_index.map do |rendition, i|
88
- "[v#{i + 1}]scale='if(gt(iw,#{rendition.width}),#{rendition.width},iw)':'if(gt(iw,#{rendition.width}),-2,ih)'[v#{i + 1}out]"
89
- end
90
- ([split] + scaled).join("; ")
91
- end
92
-
93
- def video_maps(codec: "h264_videotoolbox")
94
- downscaleable_renditions.each_with_index.flat_map do |rendition, i|
95
- [
96
- "-map", "[v#{i + 1}out]",
97
- "-c:v:#{i}", codec,
98
- "-b:v:#{i}", "#{rendition.bitrate}k"
99
- ]
100
- end
101
- end
102
-
103
- def audio_maps(codec: "aac", bitrate: 128)
104
- downscaleable_renditions.each_with_index.flat_map do |_, i|
105
- [
106
- "-map", "a:0",
107
- "-c:a:#{i}", codec,
108
- "-b:a:#{i}", "#{bitrate}k",
109
- "-ac", "2"
110
- ]
111
- end
112
- end
113
-
114
- def stream_map
115
- downscaleable_renditions.each_index.map { |i| "v:#{i},a:#{i}" }.join(" ")
116
- end
117
-
118
- def variant
119
- @output.join("%v")
120
- end
121
-
122
- def segment
123
- variant.join("%d.ts").to_s
124
- end
125
-
126
- def playlist
127
- variant.join(PLAYLIST).to_s
128
- end
129
- end
130
-
131
- class Web < Base
132
- def initialize(...)
133
- super(...)
134
- # 360p - Low quality for mobile/slow connections
135
- rendition width: 640, height: 360, bitrate: 500
136
- # 480p - Standard definition for basic streaming
137
- rendition width: 854, height: 480, bitrate: 1000
138
- # 720p - High definition for most desktop viewing
139
- rendition width: 1280, height: 720, bitrate: 3000
140
- # 1080p - Full HD for high-quality streaming
141
- rendition width: 1920, height: 1080, bitrate: 6000
142
- # 4K - Ultra HD for premium viewing experience
143
- rendition width: 3840, height: 2160, bitrate: 12000
144
- end
145
- end
146
-
147
- # Do very little work on vidoes so I can get more dev cycles in.
148
- class Dev < Base
149
- def initialize(...)
150
- super(...)
151
- # 360p - Low quality for mobile/slow connections
152
- rendition width: 640, height: 360, bitrate: 500
153
- end
154
- end
155
- end
156
-
157
- class Input
158
- attr_reader :path, :json
159
-
160
- def initialize(path)
161
- @path = Pathname(path)
162
- end
163
-
164
- def json
165
- @json ||= probe
166
- end
167
-
168
- def width = stream.dig(:width)
169
- def height = stream.dig(:height)
170
- def codec = stream.dig(:codec_name)
171
- def duration = json.dig(:format, :duration)&.to_f
172
- def framerate = parse_rate(stream.dig(:r_frame_rate))
173
-
174
- private
175
-
176
- def stream
177
- json.dig(:streams, 0)
178
- end
179
-
180
- def probe
181
- raw = `ffprobe -v error -select_streams v:0 \
182
- -show_entries stream=width,height,r_frame_rate,codec_name \
183
- -show_entries format=duration \
184
- -of json "#{@path}"`
185
-
186
- JSON.parse(raw, symbolize_names: true)
187
- end
188
-
189
- def parse_rate(rate)
190
- return unless rate
191
- num, den = rate.split("/").map(&:to_f)
192
- return if den.zero?
193
- (num / den).round(3)
194
- end
195
- end
196
-
197
- class Directory
198
- def initialize(source)
199
- @source = Pathname.new(source)
200
- end
201
-
202
- def glob(glob)
203
- Enumerator.new do |y|
204
- @source.glob(glob).each do |input|
205
- relative = input.relative_path_from(@source)
206
- output = relative.dirname.join(relative.basename(input.extname))
207
- y << [ input, output ]
208
- end
209
- end
21
+ def s3_resource
22
+ @s3_resource ||= Aws::S3::Resource.new
210
23
  end
211
24
  end
212
25
  end
26
+
27
+ require_relative "hls/codecs"
28
+ require_relative "hls/instrumentation"
29
+ require_relative "hls/lock"
30
+ require_relative "hls/state"
31
+ require_relative "hls/storage"
32
+ require_relative "hls/cache"
33
+ require_relative "hls/uploader"
34
+ require_relative "hls/manifest"
35
+ require_relative "hls/input"
36
+ require_relative "hls/directory"
37
+ require_relative "hls/application_video"
38
+
39
+ # Optional Railtie — only loaded when running under Rails.
40
+ require_relative "hls/railtie" if defined?(Rails::Railtie)
data/plans/00-goal.md ADDED
@@ -0,0 +1,112 @@
1
+ # Goal
2
+
3
+ A Rails app declares a video profile class and gets web-playable HLS streaming
4
+ out of a private object store with a single command.
5
+
6
+ ```ruby
7
+ # app/videos/application_video.rb
8
+ class ApplicationVideo < HLS::ApplicationVideo
9
+ bucket ENV.fetch("VIDEO_S3_BUCKET_NAME")
10
+ signing_ttl 1.hour
11
+ segment_duration 4
12
+ end
13
+
14
+ # app/videos/course_video.rb
15
+ class CourseVideo < ApplicationVideo
16
+ rendition :full, scale: 1.0
17
+ rendition :medium, scale: 0.5
18
+ rendition :small, scale: 0.25
19
+ end
20
+ ```
21
+
22
+ ```ruby
23
+ # Author drops a file in, kicks off the pipeline:
24
+ CourseVideo.process(input: "lecture.mp4")
25
+ # → probes input
26
+ # → encodes HLS multiplex (multiple renditions + segments + poster)
27
+ # → uploads to Tigris
28
+ # → records sidecar state so re-runs are no-ops
29
+
30
+ # Browser hits the existing /videos/:id route:
31
+ # → controller asks CourseVideo.manifest(path) for a signed master playlist
32
+ # → m3u8 player streams the variants over pre-signed segment URLs
33
+ ```
34
+
35
+ ## What "done" looks like
36
+
37
+ - [ ] A new mp4 dropped into the input source ends up playable in a browser
38
+ via the existing `VideosController` with no manual steps.
39
+ *(Pipeline exists end-to-end; needs a live walk-through against
40
+ Tigris with a real source file.)*
41
+ - [x] The pipeline is **idempotent**: re-running on an already-processed video
42
+ uploads nothing.
43
+ - [x] The pipeline is **resumable**: a crashed run resumes without re-encoding.
44
+ - [x] The encode runs on **Linux workers** (Fly.io / CI), not just macOS dev
45
+ laptops. *(Codec auto-detect is in; CI Linux job not yet wired.)*
46
+ - [x] Preview-window slicing (`VideoPlan#full_video.enabled?` →
47
+ `variant[0...PREVIEW_DURATION]`) still works for locked content.
48
+ - [x] Twitter-bot poster path (`format.jpeg` → `stream_object`) still works.
49
+ - [ ] The existing live course at `../server` cuts over without breaking
50
+ currently-playing videos. *(Integrated; needs deploy + smoke test.)*
51
+
52
+ ## The plan
53
+
54
+ Six steps, each independently verifiable. Cranked in order — earlier steps
55
+ unblock later ones.
56
+
57
+ - [x] **[01 — Profile DSL](01-profile-dsl.md)**
58
+ `ApplicationVideo` base + `app/videos/*.rb` autoload via Railtie.
59
+ Class-level DSL replaces today's imperative `Video::Scalable` /
60
+ `Video::VTechWatch` constructors.
61
+ - [x] **[02 — Upload pipeline](02-upload-pipeline.md)**
62
+ `HLS::Uploader` step pushes the bundle to Tigris. Sidecar state file
63
+ makes it idempotent + resumable. ActiveJob wrapper for queue runners.
64
+ - [x] **[03 — Reader & Manifest](03-reader-and-manifest.md)**
65
+ Hoist `server/app/models/video.rb` into the gem as `HLS::Manifest`.
66
+ Profile class becomes the entry point: `CourseVideo.manifest(path)`.
67
+ - [x] **[04 — Codec portability](04-codec-portability.md)**
68
+ Detect platform and pick `h264_videotoolbox` / `libx264` / `h264_nvenc`.
69
+ Linux CI proves it.
70
+ - [x] **[05 — Server migration](05-server-migration.md)**
71
+ Land it all in `../server` behind feature parity with the current
72
+ `Video` model. Smoke test, cut over, delete the old code.
73
+ - [ ] **[06 — ActiveStorage adapter](06-activestorage-adapter.md)**
74
+ `has_hls_video :web, profile: WebVideo` on AR models. Attach a
75
+ source blob → encode job → bundle in dedicated bucket → signed
76
+ playlists ready to serve. Optional follow-up; the gem works
77
+ without it.
78
+
79
+ ## Cranking on this
80
+
81
+ Each plan doc has the same shape:
82
+
83
+ ```
84
+ Goal — one paragraph
85
+ Preconditions — what must already be done
86
+ Work — concrete tasks
87
+ Acceptance — checklist the loop verifies before advancing
88
+ ```
89
+
90
+ Loop driver:
91
+
92
+ 1. Read `00-goal.md` (this file). Find the first unchecked step.
93
+ 2. Read that step's plan. Confirm preconditions hold.
94
+ 3. Do the work. Run the acceptance checks.
95
+ 4. When acceptance passes, tick the step here and move to the next.
96
+ 5. Stop when every box at the top is ticked.
97
+
98
+ Run with:
99
+
100
+ ```
101
+ /loop /implement-next-plan
102
+ ```
103
+
104
+ …or any equivalent driver that re-enters this file each iteration.
105
+
106
+ ## Non-goals (for now)
107
+
108
+ - Live streaming. VOD only.
109
+ - Per-user DRM beyond pre-signed URLs.
110
+ - Multi-profile fan-out (one source → both `WebVideo` and `MobileVideo`
111
+ bundles). One profile per attachment; revisit if needed.
112
+ - ActiveStorage integration. The gem owns its own storage layout.
@@ -0,0 +1,105 @@
1
+ # 01 — Profile DSL
2
+
3
+ ## Goal
4
+
5
+ A Rails app declares video profiles as classes in `app/videos/`, and each
6
+ class fully describes its renditions, codec, and storage. The profile class is
7
+ the unit — encode, upload, and (later) read all hang off it.
8
+
9
+ ```ruby
10
+ # app/videos/application_video.rb
11
+ class ApplicationVideo < HLS::ApplicationVideo
12
+ bucket ENV.fetch("VIDEO_S3_BUCKET_NAME")
13
+ signing_ttl 1.hour
14
+ segment_duration 4
15
+ end
16
+
17
+ # app/videos/course_video.rb
18
+ class CourseVideo < ApplicationVideo
19
+ rendition :full, scale: 1.0
20
+ rendition :medium, scale: 0.5
21
+ rendition :small, scale: 0.25
22
+ end
23
+
24
+ # app/videos/screencast_video.rb
25
+ class ScreencastVideo < ApplicationVideo
26
+ bits_per_pixel :screencast
27
+ rendition width: 1920, height: 1080, bitrate: 2_500
28
+ rendition width: 1280, height: 720, bitrate: 1_500
29
+ end
30
+ ```
31
+
32
+ ## Preconditions
33
+
34
+ - Existing `HLS::Video::Base`, `HLS::Video::Scalable`, `HLS::Video::VTechWatch`
35
+ classes in `lib/hls.rb` (already there).
36
+ - Existing `HLS::Poster` (already there).
37
+
38
+ ## Work
39
+
40
+ ### 1. Extract `HLS::ApplicationVideo` base class
41
+
42
+ In `lib/hls/application_video.rb`:
43
+
44
+ - Class-level DSL: `bucket`, `signing_ttl`, `segment_duration`,
45
+ `audio_codec`, `audio_bitrate`, `video_codec`, `bits_per_pixel`.
46
+ - Class-level `rendition(...)` accumulator. Two forms:
47
+ - Explicit: `rendition width:, height:, bitrate:`
48
+ - Scaled: `rendition :name, scale: 1.0` (computed from input dimensions
49
+ using the existing `Scalable#estimated_bitrate` math).
50
+ - DSL values are inherited and overridable (subclasses see parent renditions
51
+ unless they redeclare).
52
+ - `.new(input:, output:)` constructs an instance bound to one input.
53
+ - `#process` runs the full pipeline (encode + poster + upload, wired in
54
+ step 02).
55
+
56
+ ### 2. Migrate existing classes
57
+
58
+ - `HLS::Video::Scalable` → keep as a strategy helper (`Scalable.renditions_for(input)`)
59
+ used by profile classes that want auto-scaling. Or fold it into a
60
+ `scaled_renditions n: 3` DSL macro on `ApplicationVideo`.
61
+ - `HLS::Video::VTechWatch` → delete (it's a dev-loop hack; replace with a
62
+ `FastVideo` example profile in tests/fixtures).
63
+
64
+ ### 3. Railtie
65
+
66
+ In `lib/hls/railtie.rb`:
67
+
68
+ - Register `app/videos` as an autoload + eager-load path.
69
+ - Expose `Rails.application.config.hls` for app-wide defaults
70
+ (default bucket, default signing TTL, default codec).
71
+ - Wire `Rails.application.config.hls.s3_client` so the gem uses the host
72
+ app's `Aws::S3::Resource` configuration (matches `Video::Tigris` setup
73
+ in `server/app/models/video.rb:4-9`).
74
+
75
+ ### 4. Backwards-compatible shim
76
+
77
+ Keep `HLS::Video::Base#command` working unchanged so step 02 doesn't have to
78
+ touch ffmpeg arg construction. The DSL classes should compose down to the
79
+ same command array Base produces today.
80
+
81
+ ## Acceptance
82
+
83
+ - [x] `HLS::ApplicationVideo` exists; `bucket`, `signing_ttl`,
84
+ `segment_duration`, `rendition` work as documented above.
85
+ - [x] A subclass with three `rendition :name, scale: ...` declarations
86
+ produces a `command` array byte-identical to today's
87
+ `HLS::Video::Scalable.new(input:, output:)` for a fixture input.
88
+ - [x] A subclass with explicit `rendition width:, height:, bitrate:`
89
+ produces the expected ffmpeg command for a fixed-size encode.
90
+ - [x] Subclass inheritance works: a child class inherits parent's `bucket`
91
+ and `signing_ttl` unless it overrides.
92
+ - [x] Railtie autoloads `app/videos/*.rb` in a Rails dummy app under
93
+ `spec/dummy` (or equivalent); `CourseVideo` resolves via constantize.
94
+ - [x] `HLS::Video::VTechWatch` is gone; nothing in `examples/` or tests
95
+ references it.
96
+ - [x] Unit tests cover the DSL accumulator, inheritance, and the
97
+ Scalable-equivalence golden test.
98
+
99
+ ## Open questions
100
+
101
+ - Do we want a `scaled_renditions count: 3` macro, or stay explicit per
102
+ rendition? Probably explicit — it's three lines and more readable.
103
+ - Should `bucket` accept an `Aws::S3::Bucket` directly, a name string, or
104
+ both? Probably both, with a string being looked up via the configured
105
+ S3 client.