diff --git a/CHANGELOG b/CHANGELOG index 58dec35..0d40ed4 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -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: diff --git a/ffmpeg.gemspec b/ffmpeg.gemspec index 5b7a32a..191e9d8 100644 --- a/ffmpeg.gemspec +++ b/ffmpeg.gemspec @@ -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') diff --git a/lib/ffmpeg.rb b/lib/ffmpeg.rb index c8e276e..f5d8850 100644 --- a/lib/ffmpeg.rb +++ b/lib/ffmpeg.rb @@ -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' diff --git a/lib/ffmpeg/dash.rb b/lib/ffmpeg/dash.rb new file mode 100644 index 0000000..7ac8917 --- /dev/null +++ b/lib/ffmpeg/dash.rb @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +require_relative 'dash/manifest' diff --git a/lib/ffmpeg/dash/adaptation_set.rb b/lib/ffmpeg/dash/adaptation_set.rb new file mode 100644 index 0000000..1cb768b --- /dev/null +++ b/lib/ffmpeg/dash/adaptation_set.rb @@ -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] 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 diff --git a/lib/ffmpeg/dash/hls_class_methods.rb b/lib/ffmpeg/dash/hls_class_methods.rb new file mode 100644 index 0000000..ba6c403 --- /dev/null +++ b/lib/ffmpeg/dash/hls_class_methods.rb @@ -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 diff --git a/lib/ffmpeg/dash/manifest.rb b/lib/ffmpeg/dash/manifest.rb new file mode 100644 index 0000000..d93abf5 --- /dev/null +++ b/lib/ffmpeg/dash/manifest.rb @@ -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] 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 diff --git a/lib/ffmpeg/dash/representation.rb b/lib/ffmpeg/dash/representation.rb new file mode 100644 index 0000000..fcd64da --- /dev/null +++ b/lib/ffmpeg/dash/representation.rb @@ -0,0 +1,203 @@ +# frozen_string_literal: true + +require 'uri' +require_relative 'hls_class_methods' +require_relative 'segment_template' + +module FFMPEG + module DASH + # Represents a Representation in a DASH manifest. + class Representation + include HLSClassMethods + + attr_reader :manifest, :adaptation_set + + def initialize(adaptation_set, node) + @manifest = adaptation_set.manifest + @adaptation_set = adaptation_set + @node = node + end + + # Returns the ID of the representation. + # + # @return [Integer, nil] The ID of the representation. + def id + @id ||= @node['id']&.to_i + end + + # Returns the MIME type of the representation. + # + # @return [String, nil] The MIME type. + def mime_type + @mime_type ||= @node['mimeType'] + end + + # Returns the codecs used in the representation. + # + # @return [String, nil] The codecs string. + def codecs + @codecs ||= @node['codecs'] + end + + # Returns the bandwidth of the representation in bits per second. + # + # @return [Integer, nil] The bandwidth in bits per second. + def bandwidth + @bandwidth ||= @node['bandwidth']&.to_i + end + + # Returns the pixel aspect ratio of the representation. + # + # @return [String, nil] The pixel aspect ratio. + def sar + @sar ||= @node['sar'] + end + + # Returns the width of the representation. + # + # @return [Integer, nil] The width in pixels. + def width + @width ||= @node['width']&.to_i + end + + # Returns the height of the representation. + # + # @return [Integer, nil] The height in pixels. + def height + @height ||= @node['height']&.to_i + end + + # Returns the resolution of the representation in the format "width x height". + # + # @return [String, nil] The resolution string. + def resolution + @resolution ||= "#{width}x#{height}" if width && height + end + + # Returns the segment template associated with the representation. + # + # @return [SegmentTemplate, nil] The SegmentTemplate object. + def segment_template + @segment_template ||= + @node + .at_xpath('./xmlns:SegmentTemplate') + &.then { SegmentTemplate.new(self, _1) } + end + + # Returns the base URL of the representation. + # + # @return [String, nil] The base URL. + def base_url + @base_url ||= @node.at_xpath('./xmlns:BaseURL')&.content + end + + # Sets the base URL for the representation. + # + # @param value [String] The base URL to set. + # @return [void] + def base_url=(value) + @node.xpath('./xmlns:BaseURL').each(&:remove) + return unless (@base_url = value) + + node = @node.document.create_element('BaseURL', value) + node_to_prepend = @node.element_children.find { _1.name.casecmp('BaseURL').positive? } + + if node_to_prepend + node_to_prepend.add_previous_sibling(node) + else + @node.add_child(node) + end + end + + # Sets the segment query for the segment template of the representation. + # + # @param value [String] The segment query to set. + # @return [void] + def segment_query=(value) + segment_template&.segment_query = value + end + + # Returns the segment ranges of the representation as an enumerable of ranges. + # + # @return [Enumerable::Lazy, nil] An enumerable of ranges representing the segments. + def to_ranges + segment_template&.to_ranges + end + + # Returns the representation as a string in M3U8 (HLS playlist) format. + # NOTE: Currently we only support audio and video representations. + # + # See https://datatracker.ietf.org/doc/html/rfc8216 + # + # @return [String, nil] The M3U8 formatted string for the representation. + def to_m3u8 + return unless %w[audio video].include?(@adaptation_set.content_type) + return unless segment_template + + m3u8 = %w[#EXTM3U #EXT-X-VERSION:6] + m3u8 << m3u8t('EXT-X-PLAYLIST-TYPE', %w[VOD]) if @manifest.vod? + m3u8 << m3u8t( + 'EXT-X-MAP', + 'URI' => quote(url(segment_template.initialization_filename)) + ) + + target_duration = 0 + to_ranges.each_with_index do |range, index| + filename = segment_template.media_filename(index) + duration = (range.end - range.begin).round(5) + target_duration = [duration, target_duration].max + + m3u8 << m3u8t('EXTINF', [duration, '']) + m3u8 << url(filename) + end + + [ + m3u8[0..1], + m3u8t('EXT-X-TARGETDURATION', [target_duration.ceil]), + m3u8[2..], + @manifest.vod? ? '#EXT-X-ENDLIST' : nil + ].compact.join("\n") + end + + # Returns the representation as a string in M3U8 (HLS playlist) stream info format. + # NOTE: Currently we only support audio and video representations. + # + # See https://datatracker.ietf.org/doc/html/rfc8216 + # + # @param audio_group_id [String, nil] The audio group ID to include in the stream info. + # @return [String, nil] The M3U8 formatted string for the representation as a stream. + def to_m3u8si(audio_group_id: nil, video_group_id: nil) + return unless %w[audio video].include?(@adaptation_set.content_type) + + "#{m3u8t( + 'EXT-X-STREAM-INF', + 'BANDWIDTH' => bandwidth, + 'CODECS' => quote(codecs), + 'RESOLUTION' => resolution, + 'AUDIO' => quote(audio_group_id), + 'VIDEO' => quote(video_group_id) + )}\n#{"stream#{id}.m3u8"}" + end + + private + + def vod? + @adaptation_set.manifest.vod? + end + + def url(filename) + return filename unless base_url + + URI.join(base_url, filename).to_s + end + + 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 diff --git a/lib/ffmpeg/dash/segment_template.rb b/lib/ffmpeg/dash/segment_template.rb new file mode 100644 index 0000000..4ed40b8 --- /dev/null +++ b/lib/ffmpeg/dash/segment_template.rb @@ -0,0 +1,132 @@ +# frozen_string_literal: true + +require 'uri' +require_relative 'segment_timeline' + +module FFMPEG + module DASH + # Represents a Segment Template in a DASH manifest. + class SegmentTemplate + attr_reader :manifest, :representation + + def initialize(representation, node) + @manifest = representation.manifest + @representation = representation + @node = node + end + + # Returns the timescale of the segment template. + # + # @return [Integer, nil] The timescale as an integer. + def timescale + @timescale ||= @node['timescale']&.to_i + end + + # Returns the initialization segment format of the segment template. + # + # @return [String, nil] The initialization segment format. + def initialization + @initialization ||= @node['initialization'] + end + + # Returns the initialization segment filename. + # + # @return [String, nil] The formatted initialization segment filename. + def initialization_filename + return unless initialization + + format_filename(initialization, start_number) + end + + # Returns the media segment format of the segment template. + # + # @return [String, nil] The media segment format. + def media + @media ||= @node['media'] + end + + # Returns the media segment filename for a given index. + # Note that the index in the argument is zero-based, while the segment numbering + # starts from the `startNumber`. This method adjusts the index accordingly. + # + # @return [String, nil] The formatted media segment filename. + def media_filename(index) + return unless media + + format_filename(media, index + start_number) + end + + # Returns the start number of the segment template. + # + # @return [Integer] The start number as an integer. + def start_number + @start_number ||= @node['startNumber']&.to_i || 1 + end + + # Returns the segment timeline associated with the segment template. + # + # @return [SegmentTimeline, nil] The SegmentTimeline object. + def segment_timeline + @segment_timeline ||= + @node + .at_xpath('./xmlns:SegmentTimeline') + &.then { SegmentTimeline.new(self, _1) } + end + + # Sets an arbitrary query for the initialization and media segments. + # + # @param value [String] The query string to set. + # @return [void] + def segment_query=(value) + return unless value + + %w[initialization media].each do |attribute| + next unless @node[attribute] + + @node[attribute] = + URI.parse(@node[attribute]) + .tap { _1.query = value } + .to_s + end + end + + # Returns the segment ranges of the segment timeline. + # + # @return [Enumerator::Lazy, nil] An enumerable of ranges representing the segments. + def to_ranges + return unless segment_timeline + + timescale = self.timescale.to_f + segment_timeline&.to_ranges&.map do |range| + (range.begin / timescale).round(5)..(range.end / timescale).round(5) + end + end + + private + + def format_filename(template, number) + template.gsub(/\$(RepresentationID|Number)(%\w+)?\$/) do + key = Regexp.last_match(1) + format = Regexp.last_match(2) + value = + case key + when 'RepresentationID' + @representation.id + else + number + end + + format ? format % value : value + end + end + + 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 diff --git a/lib/ffmpeg/dash/segment_timeline.rb b/lib/ffmpeg/dash/segment_timeline.rb new file mode 100644 index 0000000..d5b355b --- /dev/null +++ b/lib/ffmpeg/dash/segment_timeline.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module FFMPEG + module DASH + # Represents a Segment Template in a DASH manifest. + class SegmentTimeline + attr_reader :manifest, :segment_template + + def initialize(segment_template, node) + @manifest = segment_template.manifest + @segment_template = segment_template + @node = node + end + + # Returns the segment ranges of the timeline as an enumerable of ranges. + # + # @return [Enumerable::Lazy] An enumerable of ranges representing the segments. + def to_ranges + time = 0 + @node.xpath('./xmlns:S').lazy.flat_map do |segment| + time = segment['t']&.to_i || time + duration = segment['d'].to_i + repeat = segment['r'].to_i + + (repeat + 1).times.map do + (time..(time + duration)).tap do + time += duration + end + end + end + 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 diff --git a/lib/ffmpeg/version.rb b/lib/ffmpeg/version.rb index 0934169..12c542a 100644 --- a/lib/ffmpeg/version.rb +++ b/lib/ffmpeg/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module FFMPEG - VERSION = '8.0.0' + VERSION = '8.1.0-beta' end diff --git a/spec/ffmpeg/dash/adaptation_set_spec.rb b/spec/ffmpeg/dash/adaptation_set_spec.rb new file mode 100644 index 0000000..818255c --- /dev/null +++ b/spec/ffmpeg/dash/adaptation_set_spec.rb @@ -0,0 +1,232 @@ +# frozen_string_literal: true + +require_relative '../../spec_helper' + +RSpec.describe FFMPEG::DASH::AdaptationSet do + let(:path) { 'spec/fixtures/media/dash.mpd' } + let(:manifest) { FFMPEG::DASH::Manifest.parse(File.read(path)) } + let(:video_adaptation_set) { manifest.adaptation_sets.find { |s| s.content_type == 'video' } } + let(:audio_adaptation_set) { manifest.adaptation_sets.find { |s| s.content_type == 'audio' } } + + describe '#id' do + it 'returns the id' do + expect(video_adaptation_set.id).to eq(0) + expect(audio_adaptation_set.id).to eq(1) + end + end + + describe '#par' do + subject { video_adaptation_set.par } + + it 'returns tha par' do + is_expected.to eq('16:9') + end + end + + describe '#content_type' do + subject { video_adaptation_set.content_type } + + it 'returns the content type' do + is_expected.to eq('video') + end + end + + describe '#max_width' do + subject { video_adaptation_set.max_width } + + it 'returns the max_width' do + is_expected.to eq(1920) + end + end + + describe '#max_height' do + subject { video_adaptation_set.max_height } + + it 'returns the max_height' do + is_expected.to eq(1080) + end + end + + describe '#frame_rate' do + subject { video_adaptation_set.frame_rate } + + it 'returns the frame_rate' do + is_expected.to eq(30.to_r) + end + end + + describe '#lang' do + subject { adaptation_set.lang } + + context 'when language is specified' do + let(:mpd) do + <<~XML + + + + + + + + + + + + + + + XML + end + let(:manifest) { FFMPEG::DASH::Manifest.parse(mpd) } + let(:adaptation_set) { manifest.adaptation_sets.first } + + it 'returns the language' do + is_expected.to eq('en') + end + end + + context 'when language is not specified' do + let(:adaptation_set) { video_adaptation_set } + + it 'returns nil' do + is_expected.to be_nil + end + end + end + + describe '#representations' do + it 'returns the representations' do + expect(video_adaptation_set.representations.count).to eq(2) + expect(audio_adaptation_set.representations.count).to eq(1) + end + end + + describe '#base_url=' do + it 'sets the base url on all representations' do + video_adaptation_set.base_url = 'http://example.com/' + video_adaptation_set.representations.each do |representation| + expect(representation.at_xpath('.//xmlns:BaseURL').content).to eq('http://example.com/') + end + end + end + + describe '#segment_query=' do + it 'sets the segment query on all segment templates' do + video_adaptation_set.segment_query = 'foo=bar' + video_adaptation_set.representations.each do |representation| + expect(representation.segment_template.initialization).to match(/\?foo=bar$/) + expect(representation.segment_template.media).to match(/\?foo=bar$/) + end + end + end + + describe '#to_m3u8mt' do + subject { adaptation_set.to_m3u8mt(group_id: group_id, default: default, autoselect: autoselect) } + + let(:group_id) { 'audio' } + let(:default) { true } + let(:autoselect) { true } + + context 'with an audio adaptation set' do + let(:adaptation_set) { audio_adaptation_set } + + it 'returns an EXT-X-MEDIA tag' do + is_expected.to eq( + '#EXT-X-MEDIA:TYPE=AUDIO,' \ + 'GROUP-ID=audio,NAME="und",LANGUAGE="und",DEFAULT=YES,AUTOSELECT=YES,URI="stream2.m3u8"' + ) + end + end + + context 'with a video adaptation set' do + let(:adaptation_set) { video_adaptation_set } + let(:group_id) { 'video' } + let(:default) { false } + let(:autoselect) { false } + + it 'returns an EXT-X-MEDIA tag' do + is_expected.to eq( + '#EXT-X-MEDIA:TYPE=VIDEO,' \ + 'GROUP-ID=video,NAME="und",LANGUAGE="und",DEFAULT=NO,AUTOSELECT=NO,URI="stream0.m3u8"' + ) + end + end + + context 'with language specified' do + let(:mpd) do + <<~XML + + + + + + + + + + + + + + + XML + end + let(:manifest) { FFMPEG::DASH::Manifest.parse(mpd) } + let(:adaptation_set) { manifest.adaptation_sets.first } + + it 'uses the specified language' do + is_expected.to eq( + '#EXT-X-MEDIA:TYPE=AUDIO,' \ + 'GROUP-ID=audio,NAME="en",LANGUAGE="en",DEFAULT=YES,AUTOSELECT=YES,URI="stream0.m3u8"' + ) + end + end + + context 'with unsupported content types' do + let(:mpd) do + <<~XML + + + + + + + + + + + + + + + XML + end + let(:manifest) { FFMPEG::DASH::Manifest.parse(mpd) } + let(:adaptation_set) { manifest.adaptation_sets.first } + + it 'returns nil' do + is_expected.to be_nil + end + end + + context 'when no representations are present' do + let(:mpd) do + <<~XML + + + + + + + + XML + end + let(:manifest) { FFMPEG::DASH::Manifest.parse(mpd) } + let(:adaptation_set) { manifest.adaptation_sets.first } + + it 'returns nil' do + is_expected.to be_nil + end + end + end +end diff --git a/spec/ffmpeg/dash/hls_class_methods_spec.rb b/spec/ffmpeg/dash/hls_class_methods_spec.rb new file mode 100644 index 0000000..6735ef8 --- /dev/null +++ b/spec/ffmpeg/dash/hls_class_methods_spec.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +require_relative '../../spec_helper' + +RSpec.describe FFMPEG::DASH::HLSClassMethods do + # Create a dummy class to test the module methods + let(:test_class) do + Class.new do + include FFMPEG::DASH::HLSClassMethods + end + end + + subject { test_class.new } + + describe '#quote' do + context 'with string values' do + it 'returns quoted JSON string' do + expect(subject.send(:quote, 'foo"bar')).to eq('"foo\\"bar"') + expect(subject.send(:quote, "foo\nbar")).to eq('"foo\\nbar"') + end + end + + context 'with symbol values' do + it 'returns quoted JSON string' do + expect(subject.send(:quote, :foo)).to eq('"foo"') + end + end + + context 'with numeric values' do + it 'returns quoted JSON string' do + expect(subject.send(:quote, 123)).to eq('"123"') + expect(subject.send(:quote, 45.67)).to eq('"45.67"') + end + end + + context 'with boolean values' do + it 'returns quoted JSON string' do + expect(subject.send(:quote, false)).to eq('"false"') + end + end + + context 'with nil values' do + it 'returns nil' do + expect(subject.send(:quote, nil)).to be_nil + end + end + end + + describe '#m3u8t' do + context 'with hash attributes' do + it 'returns an HLS tag with hash attributes' do + attributes = { 'TYPE' => 'AUDIO', 'GROUP-ID' => 'audio', 'NAME' => '"English"', 'CHANNELS' => nil } + + expect( + subject.send(:m3u8t, 'EXT-X-MEDIA', attributes) + ).to eq('#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID=audio,NAME="English"') + end + end + + context 'with array attributes' do + it 'returns an HLS tag with array attributes' do + attributes = [3.0, ''] + + expect( + subject.send(:m3u8t, 'EXTINF', attributes) + ).to eq('#EXTINF:3.0,') + end + end + end +end diff --git a/spec/ffmpeg/dash/manifest_spec.rb b/spec/ffmpeg/dash/manifest_spec.rb new file mode 100644 index 0000000..5c8a610 --- /dev/null +++ b/spec/ffmpeg/dash/manifest_spec.rb @@ -0,0 +1,180 @@ +# frozen_string_literal: true + +require_relative '../../spec_helper' + +RSpec.describe FFMPEG::DASH::Manifest do + let(:path) { 'spec/fixtures/media/dash.mpd' } + let(:manifest) { described_class.parse(File.read(path)) } + + describe '#type' do + subject { manifest.type } + + it 'returns static' do + is_expected.to eq('static') + end + end + + describe '#vod?' do + subject { manifest.vod? } + + context 'for static manifests' do + it 'returns true' do + is_expected.to be true + end + end + + context 'for dynamic manifests' do + let(:mpd) do + <<~XML + + + + XML + end + let(:manifest) { described_class.parse(mpd) } + + it 'returns false' do + is_expected.to be false + end + end + end + + describe '#adaptation_sets' do + subject { manifest.adaptation_sets } + + it 'returns the adaptation sets' do + expect(subject.count).to eq(2) + end + end + + describe '#base_url=' do + it 'sets the base url on all representations' do + manifest.base_url = 'http://example.com/' + manifest.adaptation_sets.each do |adaptation_set| + adaptation_set.representations.each do |representation| + expect(representation.at_xpath('.//xmlns:BaseURL').content).to eq('http://example.com/') + end + end + end + end + + describe '#segment_query=' do + it 'sets the segment query on all segment templates' do + manifest.segment_query = 'foo=bar' + manifest.adaptation_sets.each do |adaptation_set| + adaptation_set.representations.each do |representation| + expect(representation.segment_template.initialization).to match(/\?foo=bar$/) + expect(representation.segment_template.media).to match(/\?foo=bar$/) + end + end + end + end + + describe '#to_xml' do + subject { manifest.to_xml.strip } + + it 'returns the original XML content' do + is_expected.to eq(File.read(path).strip) + end + end + + describe '#to_m3u8' do + subject { manifest.to_m3u8 } + + context 'with both audio and video content' do + it 'returns the MPD as an HLS playlist' do + is_expected.to eq(<<~M3U8.strip) + #EXTM3U + #EXT-X-VERSION:6 + #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID=audio,NAME="und",LANGUAGE="und",DEFAULT=YES,AUTOSELECT=YES,URI="stream2.m3u8" + #EXT-X-STREAM-INF:BANDWIDTH=2500000,CODECS="avc1.640028",RESOLUTION=1920x1080,AUDIO="audio" + stream0.m3u8 + #EXT-X-STREAM-INF:BANDWIDTH=1250000,CODECS="avc1.640028",RESOLUTION=1280x720,AUDIO="audio" + stream1.m3u8 + M3U8 + end + end + + context 'with only video content' do + let(:mpd) do + <<~XML + + + + + + + + + + + + + + + XML + end + let(:manifest) { described_class.parse(mpd) } + + it 'returns an HLS playlist without EXT-X-MEDIA tag' do + is_expected.to eq(<<~M3U8.strip) + #EXTM3U + #EXT-X-VERSION:6 + #EXT-X-STREAM-INF:BANDWIDTH=2500000,CODECS="avc1.640028",RESOLUTION=1920x1080 + stream0.m3u8 + M3U8 + end + end + + context 'with only audio content' do + let(:mpd) do + <<~XML + + + + + + + + + + + + + + + XML + end + let(:manifest) { described_class.parse(mpd) } + + it 'returns an HLS playlist without EXT-X-MEDIA tag' do + is_expected.to eq(<<~M3U8.strip) + #EXTM3U + #EXT-X-VERSION:6 + #EXT-X-STREAM-INF:BANDWIDTH=128000,CODECS="mp4a.40.2" + stream0.m3u8 + M3U8 + end + end + + context 'with no supported content' do + let(:mpd) do + <<~XML + + + + + + XML + end + let(:manifest) { described_class.parse(mpd) } + + it 'returns a minimal HLS playlist' do + is_expected.to eq(<<~M3U8.strip) + #EXTM3U + #EXT-X-VERSION:6 + M3U8 + end + end + end +end diff --git a/spec/ffmpeg/dash/representation_spec.rb b/spec/ffmpeg/dash/representation_spec.rb new file mode 100644 index 0000000..a76ca06 --- /dev/null +++ b/spec/ffmpeg/dash/representation_spec.rb @@ -0,0 +1,316 @@ +# frozen_string_literal: true + +require_relative '../../spec_helper' + +RSpec.describe FFMPEG::DASH::Representation do + let(:path) { 'spec/fixtures/media/dash.mpd' } + let(:manifest) { FFMPEG::DASH::Manifest.parse(File.read(path)) } + let(:video_representation) { manifest.adaptation_sets.find { |s| s.content_type == 'video' }.representations.first } + let(:audio_representation) { manifest.adaptation_sets.find { |s| s.content_type == 'audio' }.representations.first } + + describe '#id' do + it 'returns the id' do + expect(video_representation.id).to eq(0) + expect(audio_representation.id).to eq(2) + end + end + + describe '#mime_type' do + it 'returns the mime type' do + expect(video_representation.mime_type).to eq('video/mp4') + expect(audio_representation.mime_type).to eq('audio/mp4') + end + end + + describe '#codecs' do + it 'returns the codecs' do + expect(video_representation.codecs).to eq('avc1.640028') + expect(audio_representation.codecs).to eq('mp4a.40.2') + end + end + + describe '#bandwidth' do + it 'returns the bandwidth' do + expect(video_representation.bandwidth).to eq(2_500_000) + expect(audio_representation.bandwidth).to eq(128_000) + end + end + + describe '#sar' do + subject { video_representation.sar } + + it 'returns the sar' do + is_expected.to eq('1:1') + end + end + + describe '#width' do + subject { video_representation.width } + + it 'returns the width' do + is_expected.to eq(1920) + end + end + + describe '#height' do + subject { video_representation.height } + + it 'returns the height' do + is_expected.to eq(1080) + end + end + + describe '#resolution' do + it 'returns the resolution based on width and height' do + expect(video_representation.resolution).to eq('1920x1080') + expect(audio_representation.resolution).to be_nil + end + end + + describe '#segment_template' do + subject { video_representation.segment_template } + + it 'returns the segment template' do + is_expected.to be_a(FFMPEG::DASH::SegmentTemplate) + end + end + + describe '#base_url' do + before do + video_representation.add_child('http://example.com/') + end + + it 'returns the base url' do + expect(video_representation.base_url).to eq('http://example.com/') + expect(audio_representation.base_url).to be_nil + end + end + + describe '#base_url=' do + it 'sets the base url' do + video_representation.base_url = 'http://example.com/' + expect(video_representation.at_xpath('.//xmlns:BaseURL').content).to eq('http://example.com/') + end + end + + describe '#segment_query=' do + it 'sets the segment query' do + video_representation.segment_query = 'foo=bar' + expect(video_representation.segment_template.initialization).to match(/\?foo=bar$/) + expect(video_representation.segment_template.media).to match(/\?foo=bar$/) + end + end + + describe '#to_ranges' do + it 'returns the segment ranges' do + expect(video_representation.to_ranges.to_a).to eq( + [0.0..3.0, 3.0..6.0, 9.0..10.1] + ) + expect(audio_representation.to_ranges.to_a).to eq( + [0.0..2.98958, 2.98958..5.98958, 5.98958..8.98958, 8.98958..10.1] + ) + end + end + + describe '#to_m3u8' do + subject { representation.to_m3u8 } + + context 'with video representation' do + let(:representation) { video_representation } + + it 'returns M3U8 playlist for video representation' do + is_expected.to eq(<<~M3U8.strip) + #EXTM3U + #EXT-X-VERSION:6 + #EXT-X-TARGETDURATION:3 + #EXT-X-PLAYLIST-TYPE:VOD + #EXT-X-MAP:URI="init-stream0.m4s" + #EXTINF:3.0, + chunk-stream0-00001.m4s + #EXTINF:3.0, + chunk-stream0-00002.m4s + #EXTINF:1.1, + chunk-stream0-00003.m4s + #EXT-X-ENDLIST + M3U8 + end + end + + context 'with audio representation' do + let(:representation) { audio_representation } + + it 'returns M3U8 playlist for audio representation' do + is_expected.to eq(<<~M3U8.strip) + #EXTM3U + #EXT-X-VERSION:6 + #EXT-X-TARGETDURATION:3 + #EXT-X-PLAYLIST-TYPE:VOD + #EXT-X-MAP:URI="init-stream2.m4s" + #EXTINF:2.98958, + chunk-stream2-00001.m4s + #EXTINF:3.0, + chunk-stream2-00002.m4s + #EXTINF:3.0, + chunk-stream2-00003.m4s + #EXTINF:1.11042, + chunk-stream2-00004.m4s + #EXT-X-ENDLIST + M3U8 + end + end + + context 'with base URL set' do + let(:representation) { video_representation } + + before { representation.base_url = 'http://example.com/dash/' } + + it 'includes base URL in segment URLs' do + expect(subject).to include('URI="http://example.com/dash/init-stream0.m4s"') + expect(subject).to include('http://example.com/dash/chunk-stream0-00001.m4s') + end + end + + context 'with dynamic manifest' do + let(:mpd) do + <<~XML + + + + + + + + + + + + + + + XML + end + let(:manifest) { FFMPEG::DASH::Manifest.parse(mpd) } + let(:representation) { manifest.adaptation_sets.first.representations.first } + + it 'excludes VOD-specific tags' do + expect(subject).not_to include('#EXT-X-PLAYLIST-TYPE:VOD') + expect(subject).not_to include('#EXT-X-ENDLIST') + end + end + + context 'with unsupported content types' do + let(:mpd) do + <<~XML + + + + + + + + + + + + + + + XML + end + let(:manifest) { FFMPEG::DASH::Manifest.parse(mpd) } + let(:representation) { manifest.adaptation_sets.first.representations.first } + + it 'returns nil' do + is_expected.to be_nil + end + end + + context 'when no segment template is present' do + let(:mpd) do + <<~XML + + + + + + + + + + XML + end + let(:manifest) { FFMPEG::DASH::Manifest.parse(mpd) } + let(:representation) { manifest.adaptation_sets.first.representations.first } + + it 'returns nil' do + is_expected.to be_nil + end + end + end + + describe '#to_m3u8si' do + subject { representation.to_m3u8si(audio_group_id: audio_group_id, video_group_id: video_group_id) } + + let(:audio_group_id) { nil } + let(:video_group_id) { nil } + + context 'with video representation' do + let(:representation) { video_representation } + let(:audio_group_id) { 'audio' } + + it 'returns an HLS EXT-X-STREAM-INF tag' do + is_expected.to eq(<<~M3U8.strip) + #EXT-X-STREAM-INF:BANDWIDTH=2500000,CODECS="avc1.640028",RESOLUTION=1920x1080,AUDIO="audio" + stream0.m3u8 + M3U8 + end + end + + context 'with audio representation' do + let(:representation) { audio_representation } + + it 'returns an HLS EXT-X-STREAM-INF tag without resolution' do + is_expected.to eq(<<~M3U8.strip) + #EXT-X-STREAM-INF:BANDWIDTH=128000,CODECS="mp4a.40.2" + stream2.m3u8 + M3U8 + end + end + + context 'with video group ID parameter' do + let(:representation) { video_representation } + let(:video_group_id) { 'video' } + + it 'includes video group ID' do + expect(subject).to include('VIDEO="video"') + end + end + + context 'with unsupported content types' do + let(:mpd) do + <<~XML + + + + + + + + + + + + + + + XML + end + let(:manifest) { FFMPEG::DASH::Manifest.parse(mpd) } + let(:representation) { manifest.adaptation_sets.first.representations.first } + + it 'returns nil' do + is_expected.to be_nil + end + end + end +end diff --git a/spec/ffmpeg/dash/segment_template_spec.rb b/spec/ffmpeg/dash/segment_template_spec.rb new file mode 100644 index 0000000..87a4795 --- /dev/null +++ b/spec/ffmpeg/dash/segment_template_spec.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +require_relative '../../spec_helper' + +RSpec.describe FFMPEG::DASH::SegmentTemplate do + let(:path) { 'spec/fixtures/media/dash.mpd' } + let(:manifest) { FFMPEG::DASH::Manifest.parse(File.read(path)) } + let(:segment_template) do + manifest.adaptation_sets.first.representations.first.segment_template + end + + describe '#timescale' do + subject { segment_template.timescale } + + it 'returns the timescale' do + is_expected.to eq(90_000) + end + end + + describe '#initialization' do + subject { segment_template.initialization } + + it 'returns the initialization template' do + is_expected.to eq('init-stream$RepresentationID$.m4s') + end + end + + describe '#media' do + subject { segment_template.media } + + it 'returns the media template' do + is_expected.to eq('chunk-stream$RepresentationID$-$Number%05d$.m4s') + end + end + + describe '#start_number' do + subject { segment_template.start_number } + + it 'returns the start number' do + is_expected.to eq(1) + end + end + + describe '#segment_timeline' do + subject { segment_template.segment_timeline } + + it 'returns the segment timeline' do + is_expected.to be_a(FFMPEG::DASH::SegmentTimeline) + end + end + + describe '#segment_query=' do + before { segment_template.segment_query = 'foo=bar' } + + it 'includes query in the initialization template' do + expect(segment_template.initialization).to include('foo=bar') + end + + it 'includes query in the media template' do + expect(segment_template.media).to include('foo=bar') + end + end + + describe '#to_ranges' do + subject { segment_template.to_ranges.to_a } + + it 'returns the segment ranges' do + is_expected.to eq([0.0..3.0, 3.0..6.0, 9.0..10.1]) + end + end + + describe '#initialization_filename' do + it 'returns the formatted initialization filename' do + expect(segment_template.initialization_filename).to eq('init-stream0.m4s') + end + end + + describe '#media_filename' do + it 'returns the formatted media filename for segments' do + expect(segment_template.media_filename(0)).to eq('chunk-stream0-00001.m4s') + expect(segment_template.media_filename(1)).to eq('chunk-stream0-00002.m4s') + expect(segment_template.media_filename(5)).to eq('chunk-stream0-00006.m4s') + end + end +end diff --git a/spec/ffmpeg/dash/segment_timeline_spec.rb b/spec/ffmpeg/dash/segment_timeline_spec.rb new file mode 100644 index 0000000..06b3157 --- /dev/null +++ b/spec/ffmpeg/dash/segment_timeline_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require_relative '../../spec_helper' + +RSpec.describe FFMPEG::DASH::SegmentTimeline do + let(:path) { 'spec/fixtures/media/dash.mpd' } + let(:manifest) { FFMPEG::DASH::Manifest.parse(File.read(path)) } + + let(:segment_timeline) do + manifest + .adaptation_sets + .first + .representations + .first + .segment_template + .segment_timeline + end + + describe '#to_ranges' do + subject { segment_timeline.to_ranges.to_a } + + it 'returns the segment timeline ranges' do + is_expected.to eq([0..270_000, 270_000..540_000, 810_000..909_000]) + end + end +end diff --git a/spec/fixtures/media/dash.mpd b/spec/fixtures/media/dash.mpd new file mode 100644 index 0000000..e8fdb6b --- /dev/null +++ b/spec/fixtures/media/dash.mpd @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +