Class: HLS::ApplicationVideo
- Inherits:
-
Object
- Object
- HLS::ApplicationVideo
- Defined in:
- lib/hls/application_video.rb
Overview
Base class for declarative HLS video profiles.
Subclasses use the class-level DSL to describe their renditions, codec, and storage. An instance binds the profile to a single input file and drives the encode → upload → manifest pipeline.
Example:
class CourseVideo < HLS::ApplicationVideo
bucket "videos"
signing_ttl 3600
segment_duration 4
rendition :full, scale: 1.0
rendition :medium, scale: 0.5
rendition :small, scale: 0.25
end
CourseVideo.new(input: HLS::Input.new("lecture.mp4"),
output: Pathname.new("tmp/out")).command
Direct Known Subclasses
Defined Under Namespace
Classes: Declaration, PosterDeclaration, Rendition
Constant Summary collapse
- PLAYLIST =
"index.m3u8"- BITS_PER_PIXEL =
Friendly names for the bits-per-pixel ratios. Subclasses may also pass an integer directly.
{ screencast: 3, # Static content: presentations, tutorials, minimal motion mixed: 4, # Moderate motion: typical web videos, interviews motion: 6 # High motion: action videos, sports, fast-paced content }.freeze
Instance Attribute Summary collapse
-
#input ⇒ Object
readonly
Returns the value of attribute input.
-
#key_prefix ⇒ Object
readonly
Returns the value of attribute key_prefix.
-
#output ⇒ Object
readonly
Returns the value of attribute output.
Class Method Summary collapse
-
.class_setting(name, default: nil, coerce: nil) ⇒ Object
Defines a class-level inheritable attribute.
-
.manifest(path, cache: self.cache) ⇒ Object
Returns a read-side Manifest bound to this profile’s storage.
-
.poster(name, scale: nil, width: nil, height: nil) ⇒ Object
Declare a poster image.
-
.posters ⇒ Object
Posters declared on this class.
-
.rendition(name = nil, scale: nil, width: nil, height: nil, bitrate: nil) ⇒ Object
Declare a rendition.
-
.renditions ⇒ Object
Renditions declared on this class.
- .reset_posters! ⇒ Object
-
.reset_renditions! ⇒ Object
Replace any inherited rendition declarations with a fresh list.
-
.storage_or_raise ⇒ Object
Returns the configured storage, or raises a helpful error.
-
.variant_uri(path:, variant_index:) ⇒ Object
Maps a manifest’s S3 path + ffmpeg variant index to the URI that appears in the master playlist for that variant.
Instance Method Summary collapse
- #command ⇒ Object
-
#config_digest ⇒ Object
SHA256 digest of the encode-affecting profile config — settings and DSL declarations that change the output bytes ffmpeg writes.
-
#downscaleable_renditions ⇒ Object
Renditions whose width fits within the input.
-
#encode! ⇒ Object
Runs ffmpeg to produce the HLS multiplex.
- #exist? ⇒ Boolean
-
#initialize(input:, output:, key_prefix: nil) ⇒ ApplicationVideo
constructor
- input
- An HLS::Input (or anything that responds to width/height/path) output
- Pathname for the local working directory ffmpeg writes to key_prefix
-
Where the bundle lives in the bucket.
-
#input_digest ⇒ Object
SHA256 digest of the input file.
-
#poster! ⇒ Object
Runs ffmpeg to produce all declared posters in one decode pass.
-
#poster_command ⇒ Object
ffmpeg command that produces all declared posters in one decode pass.
-
#process ⇒ Object
Run the full pipeline: encode + posters (if input changed or output is missing) then upload to the bucket.
-
#renditions ⇒ Object
Renditions resolved against this instance’s input.
-
#verify_encode! ⇒ Object
Walks the just-encoded output directory and asserts the bundle is well-formed: master + variants + segments + declared posters all exist and are non-empty.
Constructor Details
#initialize(input:, output:, key_prefix: nil) ⇒ ApplicationVideo
- input
-
An HLS::Input (or anything that responds to width/height/path)
- output
-
Pathname for the local working directory ffmpeg writes to
- key_prefix
-
Where the bundle lives in the bucket. Defaults to the output directory’s basename.
260 261 262 263 264 |
# File 'lib/hls/application_video.rb', line 260 def initialize(input:, output:, key_prefix: nil) @input = input @output = Pathname.new(output) @key_prefix = key_prefix || @output.basename.to_s end |
Instance Attribute Details
#input ⇒ Object (readonly)
Returns the value of attribute input.
254 255 256 |
# File 'lib/hls/application_video.rb', line 254 def input @input end |
#key_prefix ⇒ Object (readonly)
Returns the value of attribute key_prefix.
254 255 256 |
# File 'lib/hls/application_video.rb', line 254 def key_prefix @key_prefix end |
#output ⇒ Object (readonly)
Returns the value of attribute output.
254 255 256 |
# File 'lib/hls/application_video.rb', line 254 def output @output end |
Class Method Details
.class_setting(name, default: nil, coerce: nil) ⇒ Object
Defines a class-level inheritable attribute. Subclasses fall back to the parent’s value until they set their own. Reads with no args, writes with one.
103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 |
# File 'lib/hls/application_video.rb', line 103 def class_setting(name, default: nil, coerce: nil) ivar = :"@#{name}" define_singleton_method(name) do |value = UNSET| if UNSET.equal?(value) if instance_variable_defined?(ivar) instance_variable_get(ivar) elsif superclass.respond_to?(name) superclass.public_send(name) else default end else instance_variable_set(ivar, coerce ? coerce.call(value) : value) end end end |
.manifest(path, cache: self.cache) ⇒ Object
Returns a read-side Manifest bound to this profile’s storage. The host app’s controller uses this to serve signed playlists.
CourseVideo.manifest("phlex/forms/overview").master_playlist
183 184 185 186 187 188 189 190 191 |
# File 'lib/hls/application_video.rb', line 183 def manifest(path, cache: self.cache) Manifest.new( storage: storage_or_raise, path: path, segment_duration: segment_duration, variant_uri: method(:variant_uri), cache: cache ) end |
.poster(name, scale: nil, width: nil, height: nil) ⇒ Object
Declare a poster image. Two forms:
poster :hero, scale: 1.0
poster :thumbnail, width: 320, height: 180
Each declaration produces ‘<name>.jpg` in the output directory. If no posters are declared, the encode step produces no posters.
164 165 166 167 168 169 170 171 172 173 |
# File 'lib/hls/application_video.rb', line 164 def poster(name, scale: nil, width: nil, height: nil) if scale.nil? && (width.nil? || height.nil?) raise ArgumentError, "poster requires either `scale:` or both `width:` and `height:`" end posters << PosterDeclaration.new( name: name, scale: scale, width: width, height: height ) end |
.posters ⇒ Object
Posters declared on this class. Same lazy-inherit pattern as ‘renditions` — see the comment there.
153 154 155 |
# File 'lib/hls/application_video.rb', line 153 def posters @posters ||= superclass.respond_to?(:posters) ? superclass.posters.dup : [] end |
.rendition(name = nil, scale: nil, width: nil, height: nil, bitrate: nil) ⇒ Object
Declare a rendition. Two forms:
rendition :name, scale: 0.5
rendition width: 1280, height: 720, bitrate: 1500
132 133 134 135 136 137 138 139 140 141 142 |
# File 'lib/hls/application_video.rb', line 132 def rendition(name = nil, scale: nil, width: nil, height: nil, bitrate: nil) if scale.nil? && (width.nil? || height.nil? || bitrate.nil?) raise ArgumentError, "rendition requires either `scale:` or all of `width:`, `height:`, `bitrate:`" end renditions << Declaration.new( name: name, scale: scale, width: width, height: height, bitrate: bitrate ) end |
.renditions ⇒ Object
Renditions declared on this class. On first access in a subclass, we initialize from the parent’s list so subclasses inherit naturally without an ‘inherited` hook reaching into them.
124 125 126 |
# File 'lib/hls/application_video.rb', line 124 def renditions @renditions ||= superclass.respond_to?(:renditions) ? superclass.renditions.dup : [] end |
.reset_posters! ⇒ Object
175 176 177 |
# File 'lib/hls/application_video.rb', line 175 def reset_posters! @posters = [] end |
.reset_renditions! ⇒ Object
Replace any inherited rendition declarations with a fresh list. Useful when a subclass needs to start over rather than extend the parent’s renditions.
147 148 149 |
# File 'lib/hls/application_video.rb', line 147 def reset_renditions! @renditions = [] end |
.storage_or_raise ⇒ Object
Returns the configured storage, or raises a helpful error.
211 212 213 214 215 216 217 218 219 |
# File 'lib/hls/application_video.rb', line 211 def storage_or_raise configured = storage return configured unless configured.nil? raise ArgumentError, "#{name || self} has no storage configured. Override `def self.storage` " \ "in the profile class with an HLS::Storage::S3 (or any object responding " \ "to #object and #signing_ttl)." end |
.variant_uri(path:, variant_index:) ⇒ Object
Maps a manifest’s S3 path + ffmpeg variant index to the URI that appears in the master playlist for that variant. Override on a subclass to fit a non-default URL scheme:
class CustomVideo < HLS::ApplicationVideo
def self.variant_uri(path:, variant_index:)
"/streams/#{path}/v/#{variant_index}.m3u8"
end
end
The default returns ‘<basename(path)>/<variant_index>.m3u8`, which is relative to the master playlist’s URL and matches a ‘/videos/*path/:id/:variant.m3u8` Rails route.
206 207 208 |
# File 'lib/hls/application_video.rb', line 206 def variant_uri(path:, variant_index:) "#{::File.basename(path)}/#{variant_index}.m3u8" end |
Instance Method Details
#command ⇒ Object
454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 |
# File 'lib/hls/application_video.rb', line 454 def command [ "ffmpeg", "-y", "-i", input.path.to_s, "-filter_complex", filter_complex ] + video_maps + audio_maps + [ "-f", "hls", "-var_stream_map", stream_map, "-master_pl_name", PLAYLIST, "-hls_time", self.class.segment_duration.to_s, "-hls_playlist_type", "vod", "-hls_segment_filename", segment_pattern, playlist_pattern ] end |
#config_digest ⇒ Object
SHA256 digest of the encode-affecting profile config — settings and DSL declarations that change the output bytes ffmpeg writes. Combined with ‘input_digest` to decide whether `process` can skip the encode step. Bumping `audio_bitrate`, swapping a codec, or adding a rendition changes this digest and forces a re-encode.
Excluded: settings that don’t change the encoded bytes (‘storage`, `cache`, `ffmpeg_timeout`, `variant_uri`).
413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 |
# File 'lib/hls/application_video.rb', line 413 def config_digest @config_digest ||= begin # Sort keys at every level so the serialized form is stable # regardless of insertion order or JSON.generate's # implementation. Without this, a future Ruby tweak to hash # ordering would invalidate every previously-recorded digest # and trigger a spurious re-encode of every video at once. payload = { audio_bitrate: self.class.audio_bitrate, audio_codec: self.class.audio_codec, bits_per_pixel: self.class.bits_per_pixel, max_bitrate_kbps: self.class.max_bitrate_kbps, posters: self.class.posters.map { |p| p.to_h.sort.to_h }, renditions: self.class.renditions.map { |r| r.to_h.sort.to_h }, segment_duration: self.class.segment_duration, video_codec: self.class.video_codec.to_s } "sha256:#{Digest::SHA256.hexdigest(JSON.generate(payload))}" end end |
#downscaleable_renditions ⇒ Object
Renditions whose width fits within the input. We never upscale.
446 447 448 |
# File 'lib/hls/application_video.rb', line 446 def downscaleable_renditions renditions.select { |r| r.width <= input.width } end |
#encode! ⇒ Object
Runs ffmpeg to produce the HLS multiplex. Raises on non-zero exit.
360 361 362 363 364 365 366 367 368 369 |
# File 'lib/hls/application_video.rb', line 360 def encode! input.validate! if input.respond_to?(:validate!) HLS::Instrumentation.instrument(:encode, profile: self.class.name, output: output.to_s, renditions: renditions.map(&:to_h) ) do run_ffmpeg(command) end end |
#exist? ⇒ Boolean
450 451 452 |
# File 'lib/hls/application_video.rb', line 450 def exist? output.join(PLAYLIST).exist? end |
#input_digest ⇒ Object
SHA256 digest of the input file. Used by the state sidecar to detect when the source has changed.
401 402 403 |
# File 'lib/hls/application_video.rb', line 401 def input_digest @input_digest ||= "sha256:#{Digest::SHA256.file(input.path.to_s).hexdigest}" end |
#poster! ⇒ Object
Runs ffmpeg to produce all declared posters in one decode pass. No-op when no posters are declared.
373 374 375 376 377 378 379 380 381 382 383 |
# File 'lib/hls/application_video.rb', line 373 def poster! return if self.class.posters.empty? input.validate! if input.respond_to?(:validate!) HLS::Instrumentation.instrument(:poster, profile: self.class.name, output: output.to_s, count: self.class.posters.size ) do run_ffmpeg(poster_command) end end |
#poster_command ⇒ Object
ffmpeg command that produces all declared posters in one decode pass.
386 387 388 389 390 391 392 393 394 395 396 397 |
# File 'lib/hls/application_video.rb', line 386 def poster_command cmd = ["ffmpeg", "-y", "-i", input.path.to_s] self.class.posters.each do |declaration| w, h = declaration.resolve(input: input) cmd += [ "-vf", "scale=w=#{w}:h=#{h}:force_original_aspect_ratio=decrease", "-frames:v", "1", output.join(declaration.filename).to_s ] end cmd end |
#process ⇒ Object
Run the full pipeline: encode + posters (if input changed or output is missing) then upload to the bucket. Idempotent —re-running with an unchanged input + intact output is a no-op.
Returns the uploader’s result hash: ‘{ uploaded: N, skipped: N }`.
271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 |
# File 'lib/hls/application_video.rb', line 271 def process output.mkpath result = nil HLS::Instrumentation.instrument(:process, profile: self.class.name, output: output.to_s, key_prefix: key_prefix ) do |payload| HLS::Lock.acquire(output) do state = HLS::State.load(output) unless encoded?(state) encode! poster! if self.class.posters.any? verify_encode! state.record_encode( input_digest: input_digest, config_digest: config_digest, profile: self.class.name, renditions: renditions.map(&:to_h) ) state.save end result = HLS::Uploader.new( storage: self.class.storage_or_raise, output: output, key_prefix: key_prefix, state: state ).perform payload&.merge!(result) if payload end end result end |
#renditions ⇒ Object
Renditions resolved against this instance’s input.
435 436 437 438 439 440 441 442 443 |
# File 'lib/hls/application_video.rb', line 435 def renditions @renditions ||= self.class.renditions.map do |declaration| declaration.resolve( input: input, bits_per_pixel: self.class.bits_per_pixel, max_bitrate_kbps: self.class.max_bitrate_kbps ) end end |
#verify_encode! ⇒ Object
Walks the just-encoded output directory and asserts the bundle is well-formed: master + variants + segments + declared posters all exist and are non-empty. Raises HLS::Error with a list of problems if anything is missing — better to fail before recording state and uploading than to leave a half-written bundle on the bucket.
311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 |
# File 'lib/hls/application_video.rb', line 311 def verify_encode! HLS::Instrumentation.instrument(:verify, profile: self.class.name, output: output.to_s) do problems = [] master = output.join(PLAYLIST) unless master.exist? raise HLS::Error, "encode produced no master playlist at #{master}" end master_list = M3u8::Reader.new.read(master.read) if master_list.items.empty? problems << "master playlist has no variant streams" end master_list.items.each do |variant_item| variant_path = output.join(variant_item.uri) unless variant_path.exist? problems << "variant playlist missing: #{variant_item.uri}" next end variant_list = M3u8::Reader.new.read(variant_path.read) if variant_list.items.empty? problems << "variant #{variant_item.uri} has no segments" end variant_list.items.each do |segment_item| segment_path = variant_path.dirname.join(segment_item.segment) unless segment_path.exist? && segment_path.size > 0 problems << "segment missing or empty: #{variant_item.uri} → #{segment_item.segment}" end end end self.class.posters.each do |declaration| poster_path = output.join(declaration.filename) unless poster_path.exist? && poster_path.size > 0 problems << "declared poster missing or empty: #{declaration.filename}" end end next if problems.empty? raise HLS::Error, "encode produced an invalid bundle at #{output}:\n - " + problems.join("\n - ") end end |