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
data/lib/hls/uploader.rb
ADDED
|
@@ -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
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
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
|
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.
|