Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
== 8.1.0-beta 2025-07-15

Improvements:
* Added comprehensive MPEG-DASH manifest parsing capabilities with the new `FFMPEG::DASH` module.
* Added support for converting MPEG-DASH manifests to HLS (M3U8) playlists.
* Added support for configurable base URLs and segment queries in DASH manifests.

== 8.0.0 2025-06-27

Improvements:
Expand Down
1 change: 1 addition & 0 deletions ffmpeg.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ Gem::Specification.new do |s|

s.add_dependency('logger', '~> 1.6')
s.add_dependency('multi_json', '~> 1.8')
s.add_dependency('nokogiri', '~> 1.18')
s.add_dependency('shellwords', '~> 0.2')

s.add_development_dependency('debug')
Expand Down
1 change: 1 addition & 0 deletions lib/ffmpeg.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
require_relative 'ffmpeg/command_args/color_space_injection'
require_relative 'ffmpeg/command_args/composable'
require_relative 'ffmpeg/command_args/network_streaming'
require_relative 'ffmpeg/dash'
require_relative 'ffmpeg/errors'
require_relative 'ffmpeg/filter'
require_relative 'ffmpeg/filters/format'
Expand Down
3 changes: 3 additions & 0 deletions lib/ffmpeg/dash.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# frozen_string_literal: true

require_relative 'dash/manifest'
130 changes: 130 additions & 0 deletions lib/ffmpeg/dash/adaptation_set.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
# frozen_string_literal: true

require_relative 'hls_class_methods'
require_relative 'representation'

module FFMPEG
module DASH
# Represents an Adaptation Set in a DASH manifest.
class AdaptationSet
include HLSClassMethods

attr_reader :manifest

def initialize(manifest, node)
@manifest = manifest
@node = node
end

# Returns the ID of the adaptation set.
#
# @return [Integer, nil] The ID of the adaptation set.
def id
@id ||= @node['id']&.to_i
end

# Returns the aspect ratio of the adaptation set.
#
# @return [String, nil] The pixel aspect ratio.
def par
@par ||= @node['par']
end

# Returns the language of the adaptation set.
#
# @return [String, nil] The language code (e.g., 'und', 'en', 'fr').
def lang
@lang ||= @node['lang']
end

# Returns the content type of the adaptation set.
#
# @return [String, nil] The content type.
def content_type
@content_type ||= @node['contentType']
end

# Returns the max width of the adaptation set.
#
# @return [Integer, nil] The maximum width in pixels.
def max_width
@max_width ||= @node['maxWidth']&.to_i
end

# Returns the max height of the adaptation set.
#
# @return [Integer, nil] The maximum height in pixels.
def max_height
@max_height ||= @node['maxHeight']&.to_i
end

# Returns the frame rate of the adaptation set.
#
# @return [Rational, nil] The frame rate as a Rational number.
def frame_rate
@frame_rate ||= @node['frameRate']&.to_r
end

# Returns the representations in the adaptation set.
#
# @return [Array<Representation>] An array of Representation objects.
def representations
@representations ||=
@node
.xpath('./xmlns:Representation')
.map { Representation.new(self, _1) }
end

# Sets the base URL for all representations in the adaptation set.
#
# @param value [String] The base URL to set.
# @return [void]
def base_url=(value)
representations.each { _1.base_url = value }
end

# Sets the segment query for all representations in the adaptation set.
#
# @param value [String] The segment query to set.
# @return [void]
def segment_query=(value)
representations.each { _1.segment_query = value }
end

# Returns the representation as a string in M3U8 (HLS playlist) media track format.
# NOTE: Currently we only support audio and video representations.
#
# See https://datatracker.ietf.org/doc/html/rfc8216
#
# @param default [Boolean] Whether to mark media track as default or not.
# @param autoselect [Boolean] Whether to mark media track as automatically selected or not.
# @param group_id [String, nil] The group ID for media track.
# @return [String, nil] The M3U8 EXT-X-MEDIA formatted string for the representation.
def to_m3u8mt(group_id: content_type, default: true, autoselect: true)
return unless %w[audio video].include?(content_type)
return unless representations.any?

m3u8t(
'EXT-X-MEDIA',
'TYPE' => content_type.upcase,
'GROUP-ID' => group_id,
'NAME' => quote(lang || 'und'),
'LANGUAGE' => quote(lang || 'und'),
'DEFAULT' => default ? 'YES' : 'NO',
'AUTOSELECT' => autoselect ? 'YES' : 'NO',
'URI' => quote("stream#{representations.first.id}.m3u8")
)
end

private

def respond_to_missing?(name, include_private = false)
@node.respond_to?(name, include_private) || super
end

def method_missing(name, *args, &)
@node.send(name, *args, &)
end
end
end
end
26 changes: 26 additions & 0 deletions lib/ffmpeg/dash/hls_class_methods.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# frozen_string_literal: true

require 'json'

module FFMPEG
module DASH
# Provides class methods for HLS-related functionality.
module HLSClassMethods
private

def quote(value)
return if value.nil?

JSON.generate(value.to_s)
end

def m3u8t(tag, attributes)
if attributes.is_a?(Hash)
"##{tag}:#{attributes.filter_map { |k, v| "#{k}=#{v}" unless v.nil? }.join(',')}"
else
"##{tag}:#{attributes.join(',')}"
end
end
end
end
end
120 changes: 120 additions & 0 deletions lib/ffmpeg/dash/manifest.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
# frozen_string_literal: true

require 'nokogiri'

require_relative 'adaptation_set'

module FFMPEG
module DASH
# Represents a DASH manifest document.
class Manifest
class << self
# Parses a DASH manifest document and returns a Manifest object.
#
# @param document [String] The XML document as a string.
# @return [Manifest] A new Manifest object containing the parsed document.
def parse(document)
new(Nokogiri::XML(document, &:noblanks))
end
end

def initialize(document)
@document = document
@mpd = @document.at_xpath('/xmlns:MPD')
end

# Returns the type of the MPD (e.g., 'static', 'dynamic').
#
# @return [String, nil] The type of the MPD.
def type
@type ||= @mpd&.[]('type')
end

# Returns true if the MPD is a VOD (Video on Demand) manifest.
#
# @return [Boolean] True if the MPD is a VOD manifest, false otherwise.
def vod?
type != 'dynamic'
end

# Returns the adaptation sets in the MPD.
#
# @return [Array<AdaptationSet>] An array of AdaptationSet objects.
def adaptation_sets
@adaptation_sets ||=
@mpd
&.xpath('./xmlns:Period[1]/xmlns:AdaptationSet')
&.map { AdaptationSet.new(self, _1) }
.then { _1 || [] }
end

# Sets the base URL for all adaptation sets.
#
# @param value [String] The base URL to set.
# @return [void]
def base_url=(value)
adaptation_sets.each { _1.base_url = value }
end

# Sets the segment query for all adaptation sets.
#
# @param value [String] The segment query to set.
# @return [void]
def segment_query=(value)
adaptation_sets.each { _1.segment_query = value }
end

# Returns the MPD as a string in XML format.
#
# @return [String] The MPD document as a formatted XML string.
def to_xml
@document.to_xml(indent: 2, encoding: 'UTF-8')
end

# Returns the MPD as a string in M3U8 (HLS playlist) format.
# NOTE: Currently only audio and video representations are supported.
# Additionally only the first adaptation set of each type is included.
#
# See https://datatracker.ietf.org/doc/html/rfc8216
#
# @return [String] The MPD document as a formatted M3U8 string.
def to_m3u8
m3u8 = %w[#EXTM3U #EXT-X-VERSION:6]

adaptation_sets =
self
.adaptation_sets
.select(&:representations)
.select { %w[audio video].include?(_1.content_type) }
.uniq(&:content_type)
.sort_by(&:content_type)

# Add the EXT-X-MEDIA tag for the audio adaptation set only if there
# are both audio and video adaptation sets present.
if adaptation_sets.size.to_i > 1
m3u8 <<
adaptation_sets
.first
.to_m3u8mt(group_id: 'audio')
end

# Add the EXT-X-STREAM-INF tag for each audio or video representation.
adaptation_sets.last&.representations&.each do |representation|
m3u8 << representation.to_m3u8si(audio_group_id: adaptation_sets.size > 1 ? 'audio' : nil)
end

m3u8.compact.join("\n")
end

private

def respond_to_missing?(name, include_private = false)
@document.respond_to?(name, include_private) || super
end

def method_missing(name, *args, &)
@document.send(name, *args, &)
end
end
end
end
Loading