Class: HLS::ApplicationVideo

Inherits:
Object
  • Object
show all
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

ApplicationVideo

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

Class Method Summary collapse

Instance Method Summary collapse

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

#inputObject (readonly)

Returns the value of attribute input.



254
255
256
# File 'lib/hls/application_video.rb', line 254

def input
  @input
end

#key_prefixObject (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

#outputObject (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

.postersObject

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

.renditionsObject

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_raiseObject

Returns the configured storage, or raises a helpful error.

Raises:

  • (ArgumentError)


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

#commandObject



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_digestObject

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_renditionsObject

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

Returns:

  • (Boolean)


450
451
452
# File 'lib/hls/application_video.rb', line 450

def exist?
  output.join(PLAYLIST).exist?
end

#input_digestObject

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_commandObject

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

#processObject

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

#renditionsObject

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