From 910f07c6d312ae7fc1b59708262a6a19e4a49592 Mon Sep 17 00:00:00 2001 From: bajankristof Date: Wed, 9 Jul 2025 13:13:21 +0200 Subject: [PATCH 1/4] feat(mpeg-dash): add classes to work with MPEG-DASH manifests Refs: ARC-10877 --- ffmpeg.gemspec | 1 + lib/ffmpeg.rb | 1 + lib/ffmpeg/dash.rb | 3 + lib/ffmpeg/dash/adaptation_set.rb | 89 +++++++++++++++ lib/ffmpeg/dash/manifest.rb | 74 +++++++++++++ lib/ffmpeg/dash/representation.rb | 127 ++++++++++++++++++++++ lib/ffmpeg/dash/segment_template.rb | 77 +++++++++++++ lib/ffmpeg/dash/segment_timeline.rb | 22 ++++ spec/ffmpeg/dash/adaptation_set_spec.rb | 74 +++++++++++++ spec/ffmpeg/dash/manifest_spec.rb | 49 +++++++++ spec/ffmpeg/dash/representation_spec.rb | 95 ++++++++++++++++ spec/ffmpeg/dash/segment_template_spec.rb | 56 ++++++++++ spec/fixtures/media/dash.mpd | 34 ++++++ 13 files changed, 702 insertions(+) create mode 100644 lib/ffmpeg/dash.rb create mode 100644 lib/ffmpeg/dash/adaptation_set.rb create mode 100644 lib/ffmpeg/dash/manifest.rb create mode 100644 lib/ffmpeg/dash/representation.rb create mode 100644 lib/ffmpeg/dash/segment_template.rb create mode 100644 lib/ffmpeg/dash/segment_timeline.rb create mode 100644 spec/ffmpeg/dash/adaptation_set_spec.rb create mode 100644 spec/ffmpeg/dash/manifest_spec.rb create mode 100644 spec/ffmpeg/dash/representation_spec.rb create mode 100644 spec/ffmpeg/dash/segment_template_spec.rb create mode 100644 spec/fixtures/media/dash.mpd 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..c275a61 --- /dev/null +++ b/lib/ffmpeg/dash/adaptation_set.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +require_relative 'representation' + +module FFMPEG + module DASH + # Represents an Adaptation Set in a DASH manifest. + class AdaptationSet + def initialize(node) + @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 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, nil] An array of Representation objects. + def representations + @representations ||= @node.xpath('./xmlns:Representation')&.map(&Representation.method(:new)) + 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 + + 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/manifest.rb b/lib/ffmpeg/dash/manifest.rb new file mode 100644 index 0000000..68c7671 --- /dev/null +++ b/lib/ffmpeg/dash/manifest.rb @@ -0,0 +1,74 @@ +# 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 the adaptation sets in the MPD. + # + # @return [Array, nil] An array of AdaptationSet objects. + def adaptation_sets + @adaptation_sets ||= @mpd&.xpath('./xmlns:Period[1]/xmlns:AdaptationSet')&.map(&AdaptationSet.method(:new)) + 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_s + @document.to_xml(indent: 2, encoding: 'UTF-8') + 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..00ab43e --- /dev/null +++ b/lib/ffmpeg/dash/representation.rb @@ -0,0 +1,127 @@ +# frozen_string_literal: true + +require_relative 'segment_template' + +module FFMPEG + module DASH + # Represents a Representation in a DASH manifest. + class Representation + def initialize(node) + @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.method(:new)) + 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 + + # Returns the segment timeline associated with the representation. + # + # @return [SegmentTimeline, nil] The SegmentTimeline object. + def segment_timeline + @segment_timeline ||= @node&.at_xpath('./xmlns:SegmentTimeline').then(&SegmentTimeline.method(:new)) + 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 + + 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/segment_template.rb b/lib/ffmpeg/dash/segment_template.rb new file mode 100644 index 0000000..9cc827e --- /dev/null +++ b/lib/ffmpeg/dash/segment_template.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +require 'uri' +require_relative 'segment_timeline' + +module FFMPEG + module DASH + # Represents a Segment Template in a DASH manifest. + class SegmentTemplate + def initialize(node) + @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 media segment format of the segment template. + # + # @return [String, nil] The media segment format. + def media + @media ||= @node['media'] + end + + # Returns the start number of the segment template. + # + # @return [Integer, nil] The start number as an integer. + def start_number + @start_number ||= @node['startNumber']&.to_i + 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.method(:new)) + 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.attributes[attribute] + + @node.attributes[attribute].value = + URI.parse(@node.attributes[attribute].value) + .tap { _1.query = value } + .to_s + 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/dash/segment_timeline.rb b/lib/ffmpeg/dash/segment_timeline.rb new file mode 100644 index 0000000..17dbf22 --- /dev/null +++ b/lib/ffmpeg/dash/segment_timeline.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module FFMPEG + module DASH + # Represents a Segment Template in a DASH manifest. + class SegmentTimeline + def initialize(node) + @node = node + 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/spec/ffmpeg/dash/adaptation_set_spec.rb b/spec/ffmpeg/dash/adaptation_set_spec.rb new file mode 100644 index 0000000..6343af8 --- /dev/null +++ b/spec/ffmpeg/dash/adaptation_set_spec.rb @@ -0,0 +1,74 @@ +# 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 + it 'returns the par' do + expect(video_adaptation_set.par).to eq('16:9') + end + end + + describe '#content_type' do + it 'returns the content type' do + expect(video_adaptation_set.content_type).to eq('video') + expect(audio_adaptation_set.content_type).to eq('audio') + end + end + + describe '#max_width' do + it 'returns the max width' do + expect(video_adaptation_set.max_width).to eq(1920) + end + end + + describe '#max_height' do + it 'returns the max height' do + expect(video_adaptation_set.max_height).to eq(1080) + end + end + + describe '#frame_rate' do + it 'returns the frame rate' do + expect(video_adaptation_set.frame_rate).to eq(30.to_r) + 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 +end diff --git a/spec/ffmpeg/dash/manifest_spec.rb b/spec/ffmpeg/dash/manifest_spec.rb new file mode 100644 index 0000000..ed47b1f --- /dev/null +++ b/spec/ffmpeg/dash/manifest_spec.rb @@ -0,0 +1,49 @@ +# 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 + it 'returns the type' do + expect(manifest.type).to eq('static') + end + end + + describe '#adaptation_sets' do + it 'returns the adaptation sets' do + expect(manifest.adaptation_sets.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_s' do + it 'returns the MPD as a formatted XML string' do + expect(manifest.to_s.strip).to eq(File.read(path).strip) + 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..da71d32 --- /dev/null +++ b/spec/ffmpeg/dash/representation_spec.rb @@ -0,0 +1,95 @@ +# 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 + it 'returns the sar' do + expect(video_representation.sar).to eq('1:1') + end + end + + describe '#width' do + it 'returns the width' do + expect(video_representation.width).to eq(1920) + end + end + + describe '#height' do + it 'returns the height' do + expect(video_representation.height).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 + it 'returns the segment template' do + expect(video_representation.segment_template).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 +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..3053ea4 --- /dev/null +++ b/spec/ffmpeg/dash/segment_template_spec.rb @@ -0,0 +1,56 @@ +# 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(:video_segment_template) do + manifest.adaptation_sets.find { _1.content_type == 'video' }.representations.first.segment_template + end + let(:audio_segment_template) do + manifest.adaptation_sets.find { _1.content_type == 'audio' }.representations.first.segment_template + end + + describe '#timescale' do + it 'returns the timescale' do + expect(video_segment_template.timescale).to eq(90_000) + expect(audio_segment_template.timescale).to eq(48_000) + end + end + + describe '#initialization' do + it 'returns the initialization' do + expect(video_segment_template.initialization).to eq('init-stream$RepresentationID$.m4s') + expect(audio_segment_template.initialization).to eq('init-stream$RepresentationID$.m4s') + end + end + + describe '#media' do + it 'returns the media' do + expect(video_segment_template.media).to eq('chunk-stream$RepresentationID$-$Number$.m4s') + expect(audio_segment_template.media).to eq('chunk-stream$RepresentationID$-$Number$.m4s') + end + end + + describe '#start_number' do + it 'returns the start number' do + expect(video_segment_template.start_number).to eq(1) + expect(audio_segment_template.start_number).to eq(1) + end + end + + describe '#segment_timeline' do + it 'returns the segment timeline' do + expect(video_segment_template.segment_timeline).to be_a(FFMPEG::DASH::SegmentTimeline) + end + end + + describe '#segment_query=' do + it 'sets the segment query' do + video_segment_template.segment_query = 'foo=bar' + expect(video_segment_template.initialization).to include('foo=bar') + expect(video_segment_template.media).to include('foo=bar') + end + end +end diff --git a/spec/fixtures/media/dash.mpd b/spec/fixtures/media/dash.mpd new file mode 100644 index 0000000..ef8d9ab --- /dev/null +++ b/spec/fixtures/media/dash.mpd @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 84fbfec2c1b3f4e736dbe83f332530a245c1a509 Mon Sep 17 00:00:00 2001 From: bajankristof Date: Wed, 9 Jul 2025 17:07:18 +0200 Subject: [PATCH 2/4] feat(mpeg-dash): add methods to fetch MPEG-DASH segments as ranges Refs: ARC-10877 --- lib/ffmpeg/dash/representation.rb | 14 +++---- lib/ffmpeg/dash/segment_template.rb | 11 +++++ lib/ffmpeg/dash/segment_timeline.rb | 25 +++++++++++ spec/ffmpeg/dash/representation_spec.rb | 11 +++++ spec/ffmpeg/dash/segment_template_spec.rb | 12 ++++++ spec/ffmpeg/dash/segment_timeline_spec.rb | 51 +++++++++++++++++++++++ 6 files changed, 117 insertions(+), 7 deletions(-) create mode 100644 spec/ffmpeg/dash/segment_timeline_spec.rb diff --git a/lib/ffmpeg/dash/representation.rb b/lib/ffmpeg/dash/representation.rb index 00ab43e..a9758bd 100644 --- a/lib/ffmpeg/dash/representation.rb +++ b/lib/ffmpeg/dash/representation.rb @@ -80,13 +80,6 @@ def base_url @base_url ||= @node.at_xpath('./xmlns:BaseURL')&.content end - # Returns the segment timeline associated with the representation. - # - # @return [SegmentTimeline, nil] The SegmentTimeline object. - def segment_timeline - @segment_timeline ||= @node&.at_xpath('./xmlns:SegmentTimeline').then(&SegmentTimeline.method(:new)) - end - # Sets the base URL for the representation. # # @param value [String] The base URL to set. @@ -113,6 +106,13 @@ 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 + private def respond_to_missing?(name, include_private = false) diff --git a/lib/ffmpeg/dash/segment_template.rb b/lib/ffmpeg/dash/segment_template.rb index 9cc827e..641198f 100644 --- a/lib/ffmpeg/dash/segment_template.rb +++ b/lib/ffmpeg/dash/segment_template.rb @@ -63,6 +63,17 @@ def segment_query=(value) 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 + + segment_timeline&.to_ranges&.map do |range| + (range.first / timescale).round(5)..(range.last / timescale).round(5) + end + end + private def respond_to_missing?(name, include_private = false) diff --git a/lib/ffmpeg/dash/segment_timeline.rb b/lib/ffmpeg/dash/segment_timeline.rb index 17dbf22..d872b2a 100644 --- a/lib/ffmpeg/dash/segment_timeline.rb +++ b/lib/ffmpeg/dash/segment_timeline.rb @@ -8,6 +8,31 @@ def initialize(node) @node = node end + # Returns the timescale of the segment timeline. + # + # @return [Integer, nil] The timescale as an integer. + def timescale + @timescale ||= @node['timescale']&.to_i || 1 + 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 = nil + @node.xpath('./xmlns:S').lazy.flat_map do |segment| + time = segment['t']&.to_f || time + duration = segment['d'].to_f + repeat = segment['r']&.to_i || 1 + + repeat.times.map do + ((time / timescale).round(5)..((time + duration) / timescale).round(5)).tap do + time += duration + end + end + end + end + private def respond_to_missing?(name, include_private = false) diff --git a/spec/ffmpeg/dash/representation_spec.rb b/spec/ffmpeg/dash/representation_spec.rb index da71d32..b38c391 100644 --- a/spec/ffmpeg/dash/representation_spec.rb +++ b/spec/ffmpeg/dash/representation_spec.rb @@ -92,4 +92,15 @@ 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 end diff --git a/spec/ffmpeg/dash/segment_template_spec.rb b/spec/ffmpeg/dash/segment_template_spec.rb index 3053ea4..34b2227 100644 --- a/spec/ffmpeg/dash/segment_template_spec.rb +++ b/spec/ffmpeg/dash/segment_template_spec.rb @@ -53,4 +53,16 @@ expect(video_segment_template.media).to include('foo=bar') end end + + describe '#to_ranges' do + it 'returns the segment ranges' do + expect(video_segment_template.to_ranges.to_a).to eq( + [0.0..3.0, 3.0..6.0, 9.0..10.1] + ) + + expect(audio_segment_template.to_ranges.to_a).to eq( + [0.0..2.98958, 2.98958..5.98958, 5.98958..8.98958, 8.98958..10.1] + ) + 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..b889c5d --- /dev/null +++ b/spec/ffmpeg/dash/segment_timeline_spec.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require_relative '../../spec_helper' + +describe FFMPEG::DASH::SegmentTimeline do + let(:path) { 'spec/fixtures/media/dash.mpd' } + let(:manifest) { FFMPEG::DASH::Manifest.parse(File.read(path)) } + + let(:video_segment_timeline) do + manifest + .adaptation_sets + .find { _1.content_type == 'video' } + .representations + .first + .segment_template + .segment_timeline + end + + let(:audio_segment_timeline) do + manifest + .adaptation_sets + .find { _1.content_type == 'audio' } + .representations + .first + .segment_template + .segment_timeline + end + + before do + video_segment_timeline['timescale'] = 10_000 + end + + describe '#timescale' do + it 'returns the timescale' do + expect(video_segment_timeline.timescale).to eq(10_000) + expect(audio_segment_timeline.timescale).to eq(1) + end + end + + describe '#to_ranges' do + it 'returns the segment ranges' do + expect(video_segment_timeline.to_ranges.to_a).to eq( + [0.0..27.0, 27.0..54.0, 81.0..90.9] + ) + + expect(audio_segment_timeline.to_ranges.to_a).to eq( + [0.0..143_500.0, 143_500.0..287_500.0, 287_500.0..431_500.0, 431_500.0..484_800.0] + ) + end + end +end From 0aff2b499381ee4abaaa2cd2b4b5d2ff6a91ae4f Mon Sep 17 00:00:00 2001 From: bajankristof Date: Thu, 10 Jul 2025 17:16:23 +0200 Subject: [PATCH 3/4] feat(mpeg-dash): add support for converting MPEG-DASH to HLS Refs: ARC-10877 --- lib/ffmpeg/dash/adaptation_set.rb | 51 ++++- lib/ffmpeg/dash/hls_class_methods.rb | 26 +++ lib/ffmpeg/dash/manifest.rb | 56 +++++- lib/ffmpeg/dash/representation.rb | 80 +++++++- lib/ffmpeg/dash/segment_template.rb | 60 +++++- lib/ffmpeg/dash/segment_timeline.rb | 25 ++- spec/ffmpeg/dash/adaptation_set_spec.rb | 178 ++++++++++++++++- spec/ffmpeg/dash/hls_class_methods_spec.rb | 70 +++++++ spec/ffmpeg/dash/manifest_spec.rb | 143 +++++++++++++- spec/ffmpeg/dash/representation_spec.rb | 218 ++++++++++++++++++++- spec/ffmpeg/dash/segment_template_spec.rb | 69 ++++--- spec/ffmpeg/dash/segment_timeline_spec.rb | 35 +--- spec/fixtures/media/dash.mpd | 14 +- 13 files changed, 908 insertions(+), 117 deletions(-) create mode 100644 lib/ffmpeg/dash/hls_class_methods.rb create mode 100644 spec/ffmpeg/dash/hls_class_methods_spec.rb diff --git a/lib/ffmpeg/dash/adaptation_set.rb b/lib/ffmpeg/dash/adaptation_set.rb index c275a61..1cb768b 100644 --- a/lib/ffmpeg/dash/adaptation_set.rb +++ b/lib/ffmpeg/dash/adaptation_set.rb @@ -1,12 +1,18 @@ # 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 - def initialize(node) + include HLSClassMethods + + attr_reader :manifest + + def initialize(manifest, node) + @manifest = manifest @node = node end @@ -24,6 +30,13 @@ 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. @@ -54,9 +67,12 @@ def frame_rate # Returns the representations in the adaptation set. # - # @return [Array, nil] An array of Representation objects. + # @return [Array] An array of Representation objects. def representations - @representations ||= @node.xpath('./xmlns:Representation')&.map(&Representation.method(:new)) + @representations ||= + @node + .xpath('./xmlns:Representation') + .map { Representation.new(self, _1) } end # Sets the base URL for all representations in the adaptation set. @@ -64,7 +80,7 @@ def representations # @param value [String] The base URL to set. # @return [void] def base_url=(value) - representations&.each { _1.base_url = value } + representations.each { _1.base_url = value } end # Sets the segment query for all representations in the adaptation set. @@ -72,7 +88,32 @@ def base_url=(value) # @param value [String] The segment query to set. # @return [void] def segment_query=(value) - representations&.each { _1.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 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 index 68c7671..d93abf5 100644 --- a/lib/ffmpeg/dash/manifest.rb +++ b/lib/ffmpeg/dash/manifest.rb @@ -30,11 +30,22 @@ 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, nil] An array of AdaptationSet objects. + # @return [Array] An array of AdaptationSet objects. def adaptation_sets - @adaptation_sets ||= @mpd&.xpath('./xmlns:Period[1]/xmlns:AdaptationSet')&.map(&AdaptationSet.method(:new)) + @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. @@ -42,7 +53,7 @@ def adaptation_sets # @param value [String] The base URL to set. # @return [void] def base_url=(value) - adaptation_sets&.each { _1.base_url = value } + adaptation_sets.each { _1.base_url = value } end # Sets the segment query for all adaptation sets. @@ -50,16 +61,51 @@ def base_url=(value) # @param value [String] The segment query to set. # @return [void] def segment_query=(value) - adaptation_sets&.each { _1.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_s + 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) diff --git a/lib/ffmpeg/dash/representation.rb b/lib/ffmpeg/dash/representation.rb index a9758bd..fcd64da 100644 --- a/lib/ffmpeg/dash/representation.rb +++ b/lib/ffmpeg/dash/representation.rb @@ -1,12 +1,20 @@ # 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 - def initialize(node) + include HLSClassMethods + + attr_reader :manifest, :adaptation_set + + def initialize(adaptation_set, node) + @manifest = adaptation_set.manifest + @adaptation_set = adaptation_set @node = node end @@ -70,7 +78,10 @@ def resolution # # @return [SegmentTemplate, nil] The SegmentTemplate object. def segment_template - @segment_template ||= @node.at_xpath('./xmlns:SegmentTemplate')&.then(&SegmentTemplate.method(:new)) + @segment_template ||= + @node + .at_xpath('./xmlns:SegmentTemplate') + &.then { SegmentTemplate.new(self, _1) } end # Returns the base URL of the representation. @@ -113,8 +124,73 @@ 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 diff --git a/lib/ffmpeg/dash/segment_template.rb b/lib/ffmpeg/dash/segment_template.rb index 641198f..4ed40b8 100644 --- a/lib/ffmpeg/dash/segment_template.rb +++ b/lib/ffmpeg/dash/segment_template.rb @@ -7,7 +7,11 @@ module FFMPEG module DASH # Represents a Segment Template in a DASH manifest. class SegmentTemplate - def initialize(node) + attr_reader :manifest, :representation + + def initialize(representation, node) + @manifest = representation.manifest + @representation = representation @node = node end @@ -25,6 +29,15 @@ 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. @@ -32,18 +45,32 @@ 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, nil] The start number as an integer. + # @return [Integer] The start number as an integer. def start_number - @start_number ||= @node['startNumber']&.to_i + @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.method(:new)) + @segment_timeline ||= + @node + .at_xpath('./xmlns:SegmentTimeline') + &.then { SegmentTimeline.new(self, _1) } end # Sets an arbitrary query for the initialization and media segments. @@ -54,10 +81,10 @@ def segment_query=(value) return unless value %w[initialization media].each do |attribute| - next unless @node.attributes[attribute] + next unless @node[attribute] - @node.attributes[attribute].value = - URI.parse(@node.attributes[attribute].value) + @node[attribute] = + URI.parse(@node[attribute]) .tap { _1.query = value } .to_s end @@ -69,13 +96,30 @@ def segment_query=(value) def to_ranges return unless segment_timeline + timescale = self.timescale.to_f segment_timeline&.to_ranges&.map do |range| - (range.first / timescale).round(5)..(range.last / timescale).round(5) + (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 diff --git a/lib/ffmpeg/dash/segment_timeline.rb b/lib/ffmpeg/dash/segment_timeline.rb index d872b2a..d5b355b 100644 --- a/lib/ffmpeg/dash/segment_timeline.rb +++ b/lib/ffmpeg/dash/segment_timeline.rb @@ -4,29 +4,26 @@ module FFMPEG module DASH # Represents a Segment Template in a DASH manifest. class SegmentTimeline - def initialize(node) - @node = node - end + attr_reader :manifest, :segment_template - # Returns the timescale of the segment timeline. - # - # @return [Integer, nil] The timescale as an integer. - def timescale - @timescale ||= @node['timescale']&.to_i || 1 + 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 = nil + time = 0 @node.xpath('./xmlns:S').lazy.flat_map do |segment| - time = segment['t']&.to_f || time - duration = segment['d'].to_f - repeat = segment['r']&.to_i || 1 + time = segment['t']&.to_i || time + duration = segment['d'].to_i + repeat = segment['r'].to_i - repeat.times.map do - ((time / timescale).round(5)..((time + duration) / timescale).round(5)).tap do + (repeat + 1).times.map do + (time..(time + duration)).tap do time += duration end end diff --git a/spec/ffmpeg/dash/adaptation_set_spec.rb b/spec/ffmpeg/dash/adaptation_set_spec.rb index 6343af8..818255c 100644 --- a/spec/ffmpeg/dash/adaptation_set_spec.rb +++ b/spec/ffmpeg/dash/adaptation_set_spec.rb @@ -16,33 +16,81 @@ end describe '#par' do - it 'returns the par' do - expect(video_adaptation_set.par).to eq('16:9') + 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 - expect(video_adaptation_set.content_type).to eq('video') - expect(audio_adaptation_set.content_type).to eq('audio') + is_expected.to eq('video') end end describe '#max_width' do - it 'returns the max width' do - expect(video_adaptation_set.max_width).to eq(1920) + subject { video_adaptation_set.max_width } + + it 'returns the max_width' do + is_expected.to eq(1920) end end describe '#max_height' do - it 'returns the max height' do - expect(video_adaptation_set.max_height).to eq(1080) + subject { video_adaptation_set.max_height } + + it 'returns the max_height' do + is_expected.to eq(1080) end end describe '#frame_rate' do - it 'returns the frame rate' do - expect(video_adaptation_set.frame_rate).to eq(30.to_r) + 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 @@ -71,4 +119,114 @@ 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 index ed47b1f..5c8a610 100644 --- a/spec/ffmpeg/dash/manifest_spec.rb +++ b/spec/ffmpeg/dash/manifest_spec.rb @@ -7,14 +7,43 @@ let(:manifest) { described_class.parse(File.read(path)) } describe '#type' do - it 'returns the type' do - expect(manifest.type).to eq('static') + 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(manifest.adaptation_sets.count).to eq(2) + expect(subject.count).to eq(2) end end @@ -41,9 +70,111 @@ end end - describe '#to_s' do - it 'returns the MPD as a formatted XML string' do - expect(manifest.to_s.strip).to eq(File.read(path).strip) + 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 index b38c391..a76ca06 100644 --- a/spec/ffmpeg/dash/representation_spec.rb +++ b/spec/ffmpeg/dash/representation_spec.rb @@ -37,20 +37,26 @@ end describe '#sar' do + subject { video_representation.sar } + it 'returns the sar' do - expect(video_representation.sar).to eq('1:1') + is_expected.to eq('1:1') end end describe '#width' do + subject { video_representation.width } + it 'returns the width' do - expect(video_representation.width).to eq(1920) + is_expected.to eq(1920) end end describe '#height' do + subject { video_representation.height } + it 'returns the height' do - expect(video_representation.height).to eq(1080) + is_expected.to eq(1080) end end @@ -62,8 +68,10 @@ end describe '#segment_template' do + subject { video_representation.segment_template } + it 'returns the segment template' do - expect(video_representation.segment_template).to be_a(FFMPEG::DASH::SegmentTemplate) + is_expected.to be_a(FFMPEG::DASH::SegmentTemplate) end end @@ -103,4 +111,206 @@ ) 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 index 34b2227..87a4795 100644 --- a/spec/ffmpeg/dash/segment_template_spec.rb +++ b/spec/ffmpeg/dash/segment_template_spec.rb @@ -5,64 +5,81 @@ RSpec.describe FFMPEG::DASH::SegmentTemplate do let(:path) { 'spec/fixtures/media/dash.mpd' } let(:manifest) { FFMPEG::DASH::Manifest.parse(File.read(path)) } - let(:video_segment_template) do - manifest.adaptation_sets.find { _1.content_type == 'video' }.representations.first.segment_template - end - let(:audio_segment_template) do - manifest.adaptation_sets.find { _1.content_type == 'audio' }.representations.first.segment_template + 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 - expect(video_segment_template.timescale).to eq(90_000) - expect(audio_segment_template.timescale).to eq(48_000) + is_expected.to eq(90_000) end end describe '#initialization' do - it 'returns the initialization' do - expect(video_segment_template.initialization).to eq('init-stream$RepresentationID$.m4s') - expect(audio_segment_template.initialization).to eq('init-stream$RepresentationID$.m4s') + subject { segment_template.initialization } + + it 'returns the initialization template' do + is_expected.to eq('init-stream$RepresentationID$.m4s') end end describe '#media' do - it 'returns the media' do - expect(video_segment_template.media).to eq('chunk-stream$RepresentationID$-$Number$.m4s') - expect(audio_segment_template.media).to eq('chunk-stream$RepresentationID$-$Number$.m4s') + 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 - expect(video_segment_template.start_number).to eq(1) - expect(audio_segment_template.start_number).to eq(1) + is_expected.to eq(1) end end describe '#segment_timeline' do + subject { segment_template.segment_timeline } + it 'returns the segment timeline' do - expect(video_segment_template.segment_timeline).to be_a(FFMPEG::DASH::SegmentTimeline) + is_expected.to be_a(FFMPEG::DASH::SegmentTimeline) end end describe '#segment_query=' do - it 'sets the segment query' do - video_segment_template.segment_query = 'foo=bar' - expect(video_segment_template.initialization).to include('foo=bar') - expect(video_segment_template.media).to include('foo=bar') + 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 - expect(video_segment_template.to_ranges.to_a).to eq( - [0.0..3.0, 3.0..6.0, 9.0..10.1] - ) + 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 - expect(audio_segment_template.to_ranges.to_a).to eq( - [0.0..2.98958, 2.98958..5.98958, 5.98958..8.98958, 8.98958..10.1] - ) + 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 index b889c5d..06b3157 100644 --- a/spec/ffmpeg/dash/segment_timeline_spec.rb +++ b/spec/ffmpeg/dash/segment_timeline_spec.rb @@ -2,50 +2,25 @@ require_relative '../../spec_helper' -describe FFMPEG::DASH::SegmentTimeline do +RSpec.describe FFMPEG::DASH::SegmentTimeline do let(:path) { 'spec/fixtures/media/dash.mpd' } let(:manifest) { FFMPEG::DASH::Manifest.parse(File.read(path)) } - let(:video_segment_timeline) do + let(:segment_timeline) do manifest .adaptation_sets - .find { _1.content_type == 'video' } - .representations .first - .segment_template - .segment_timeline - end - - let(:audio_segment_timeline) do - manifest - .adaptation_sets - .find { _1.content_type == 'audio' } .representations .first .segment_template .segment_timeline end - before do - video_segment_timeline['timescale'] = 10_000 - end - - describe '#timescale' do - it 'returns the timescale' do - expect(video_segment_timeline.timescale).to eq(10_000) - expect(audio_segment_timeline.timescale).to eq(1) - end - end - describe '#to_ranges' do - it 'returns the segment ranges' do - expect(video_segment_timeline.to_ranges.to_a).to eq( - [0.0..27.0, 27.0..54.0, 81.0..90.9] - ) + subject { segment_timeline.to_ranges.to_a } - expect(audio_segment_timeline.to_ranges.to_a).to eq( - [0.0..143_500.0, 143_500.0..287_500.0, 287_500.0..431_500.0, 431_500.0..484_800.0] - ) + 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 index ef8d9ab..e8fdb6b 100644 --- a/spec/fixtures/media/dash.mpd +++ b/spec/fixtures/media/dash.mpd @@ -3,17 +3,17 @@ - + - + - + - + @@ -21,10 +21,10 @@ - + - - + + From b1d20e6383bebda3e7b205abd6834d04b9efc49c Mon Sep 17 00:00:00 2001 From: bajankristof Date: Thu, 10 Jul 2025 17:24:03 +0200 Subject: [PATCH 4/4] chore: update version to 8.1.0-beta and document changes --- CHANGELOG | 7 +++++++ lib/ffmpeg/version.rb | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) 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/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