diff --git a/CHANGELOG b/CHANGELOG index 9e58f49..42ae473 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,11 @@ +== 8.1.0-beta.5 2025-10-31 + +Improvements: +* Added audio channels configuration to built-in presets. + +Fixes: +* Fixed MPEG-DASH timing issues by using avoid_negative_ts 'make_zero' instead of the asetpts filter. + == 8.1.0-beta.4 2025-10-10 Improvements: diff --git a/lib/ffmpeg.rb b/lib/ffmpeg.rb index f5d8850..f54c857 100644 --- a/lib/ffmpeg.rb +++ b/lib/ffmpeg.rb @@ -100,6 +100,7 @@ def ffmpeg_binary=(path) end @ffmpeg_binary = path + @ffmpeg_version = nil end # Get the path to the ffmpeg binary. @@ -110,6 +111,27 @@ def ffmpeg_binary @ffmpeg_binary ||= which('ffmpeg') end + # Get the version of the ffmpeg binary. + # + # @return [String] The version string (e.g., "4.4.6", "8.0") + def ffmpeg_version + @ffmpeg_version ||= begin + stdout, = FFMPEG::IO.capture3(ffmpeg_binary, '-version') + stdout[/ffmpeg version (\d+\.\d+(?:\.\d+)?)/i, 1] + end + end + + # Check if the ffmpeg version matches the given pattern. + # + # @param pattern [String, Regexp] The version pattern to match. + # @return [Boolean] True if the ffmpeg version matches the pattern, false otherwise. + def ffmpeg_version?(pattern) + return false unless ffmpeg_version + return pattern.match?(ffmpeg_version) if pattern.is_a?(Regexp) + + ffmpeg_version.start_with?(pattern.to_s) + end + # Safely captures the standard output and the standard error of the ffmpeg command. # # @param args [Array] The arguments to pass to ffmpeg. @@ -198,6 +220,28 @@ def ffprobe_binary=(path) end @ffprobe_binary = path + @ffprobe_version = nil + end + + # Get the version of the ffprobe binary. + # + # @return [String] The version string (e.g., "4.4.6", "8.0") + def ffprobe_version + @ffprobe_version ||= begin + stdout, = FFMPEG::IO.capture3(ffprobe_binary, '-version') + stdout[/ffprobe version (\d+\.\d+(?:\.\d+)?)/i, 1] + end + end + + # Check if the ffprobe version matches the given pattern. + # + # @param pattern [String, Regexp] The version pattern to match. + # @return [Boolean] True if the ffprobe version matches the pattern, false otherwise. + def ffprobe_version?(pattern) + return false unless ffprobe_version + return pattern.match?(ffprobe_version) if pattern.is_a?(Regexp) + + ffprobe_version.start_with?(pattern.to_s) end # Safely captures the standard output and the standard error of the ffmpeg command. diff --git a/lib/ffmpeg/presets/aac.rb b/lib/ffmpeg/presets/aac.rb index db098d5..54b4a09 100644 --- a/lib/ffmpeg/presets/aac.rb +++ b/lib/ffmpeg/presets/aac.rb @@ -13,6 +13,7 @@ def aac_128k( metadata: nil, threads: FFMPEG.threads, audio_sample_rate: 48_000, + audio_channels: 2, & ) AAC.new( @@ -21,6 +22,7 @@ def aac_128k( metadata:, threads:, audio_sample_rate:, + audio_channels:, audio_bit_rate: '128k', & ) @@ -32,6 +34,7 @@ def aac_192k( metadata: nil, threads: FFMPEG.threads, audio_sample_rate: 48_000, + audio_channels: 2, & ) AAC.new( @@ -40,6 +43,7 @@ def aac_192k( metadata:, threads:, audio_sample_rate:, + audio_channels:, audio_bit_rate: '192k', & ) @@ -51,6 +55,7 @@ def aac_320k( metadata: nil, threads: FFMPEG.threads, audio_sample_rate: 48_000, + audio_channels: 2, & ) AAC.new( @@ -59,6 +64,7 @@ def aac_320k( metadata:, threads:, audio_sample_rate:, + audio_channels:, audio_bit_rate: '320k', & ) @@ -67,12 +73,14 @@ def aac_320k( # Preset to encode AAC audio files. class AAC < Preset - attr_reader :threads, :audio_bit_rate, :audio_sample_rate + attr_reader :threads, :audio_bit_rate, :audio_sample_rate, :audio_channels # @param name [String] The name of the preset. # @param filename [String] The filename format of the output. # @param metadata [Object] The metadata to associate with the preset. # @param audio_bit_rate [String] The audio bit rate to use. + # @param audio_sample_rate [Integer] The audio sample rate to use. + # @param audio_channels [Integer, nil] The number of audio channels to use (nil to preserve source). # @yield The block to execute to compose the command arguments. def initialize( name: nil, @@ -81,11 +89,13 @@ def initialize( threads: FFMPEG.threads, audio_bit_rate: '128k', audio_sample_rate: 48_000, + audio_channels: 2, & ) @threads = threads @audio_bit_rate = audio_bit_rate @audio_sample_rate = audio_sample_rate + @audio_channels = audio_channels preset = self super(name:, filename:, metadata:) do @@ -101,6 +111,7 @@ def initialize( map media.audio_mapping_id do audio_bit_rate preset.audio_bit_rate audio_sample_rate preset.audio_sample_rate + audio_channels preset.audio_channels if preset.audio_channels end end end diff --git a/lib/ffmpeg/presets/dash.rb b/lib/ffmpeg/presets/dash.rb index 7ac7f84..afe0abf 100644 --- a/lib/ffmpeg/presets/dash.rb +++ b/lib/ffmpeg/presets/dash.rb @@ -31,6 +31,7 @@ def initialize( use_template 1 use_timeline 1 segment_duration preset.segment_duration + avoid_negative_ts 'make_zero' muxing_flags 'frag_keyframe+empty_moov+default_base_moof' map_chapters '-1' diff --git a/lib/ffmpeg/presets/dash/aac.rb b/lib/ffmpeg/presets/dash/aac.rb index 67dfa5b..8b7f4a5 100644 --- a/lib/ffmpeg/presets/dash/aac.rb +++ b/lib/ffmpeg/presets/dash/aac.rb @@ -15,6 +15,7 @@ def aac_128k( threads: FFMPEG.threads, segment_duration: 4, audio_sample_rate: 48_000, + audio_channels: 2, & ) AAC.new( @@ -24,6 +25,7 @@ def aac_128k( threads:, segment_duration:, audio_sample_rate:, + audio_channels:, audio_bit_rate: '128k', & ) @@ -36,6 +38,7 @@ def aac_192k( threads: FFMPEG.threads, segment_duration: 4, audio_sample_rate: 48_000, + audio_channels: 2, & ) AAC.new( @@ -45,6 +48,7 @@ def aac_192k( threads:, segment_duration:, audio_sample_rate:, + audio_channels:, audio_bit_rate: '192k', & ) @@ -57,6 +61,7 @@ def aac_320k( threads: FFMPEG.threads, segment_duration: 4, audio_sample_rate: 48_000, + audio_channels: 2, & ) AAC.new( @@ -66,6 +71,7 @@ def aac_320k( threads:, segment_duration:, audio_sample_rate:, + audio_channels:, audio_bit_rate: '320k', & ) @@ -74,12 +80,14 @@ def aac_320k( # Preset to encode DASH AAC audio files. class AAC < DASH - attr_reader :audio_bit_rate, :audio_sample_rate + attr_reader :audio_bit_rate, :audio_sample_rate, :audio_channels # @param name [String] The name of the preset. # @param filename [String] The filename format of the output. # @param metadata [Object] The metadata to associate with the preset. # @param audio_bit_rate [String] The audio bit rate to use. + # @param audio_sample_rate [Integer] The audio sample rate to use. + # @param audio_channels [Integer, nil] The number of audio channels to use (nil to preserve source). # @yield The block to execute to compose the command arguments. def initialize( name: nil, @@ -89,10 +97,12 @@ def initialize( segment_duration: 4, audio_bit_rate: '128k', audio_sample_rate: 48_000, + audio_channels: 2, & ) @audio_bit_rate = audio_bit_rate @audio_sample_rate = audio_sample_rate + @audio_channels = audio_channels preset = self super( @@ -111,6 +121,7 @@ def initialize( map media.audio_mapping_id do audio_bit_rate preset.audio_bit_rate audio_sample_rate preset.audio_sample_rate + audio_channels preset.audio_channels if preset.audio_channels end end end diff --git a/lib/ffmpeg/presets/dash/h264.rb b/lib/ffmpeg/presets/dash/h264.rb index 7330d5b..79530ee 100644 --- a/lib/ffmpeg/presets/dash/h264.rb +++ b/lib/ffmpeg/presets/dash/h264.rb @@ -308,10 +308,9 @@ def initialize( end map media.audio_mapping_id do - # Reset the audio stream's timestamps to start from 0. - filter Filter.new(:audio, 'asetpts', expr: 'PTS-STARTPTS') audio_bit_rate h264_presets.first.audio_bit_rate audio_sample_rate h264_presets.first.audio_sample_rate + audio_channels h264_presets.first.audio_channels if h264_presets.first.audio_channels end end end diff --git a/lib/ffmpeg/presets/h264.rb b/lib/ffmpeg/presets/h264.rb index 1b41145..eb74eb3 100644 --- a/lib/ffmpeg/presets/h264.rb +++ b/lib/ffmpeg/presets/h264.rb @@ -15,6 +15,7 @@ def h264_144p( threads: FFMPEG.threads, audio_bit_rate: '128k', audio_sample_rate: 48_000, + audio_channels: 2, video_preset: 'ultrafast', video_profile: 'baseline', frame_rate: 30, @@ -29,6 +30,7 @@ def h264_144p( threads:, audio_bit_rate:, audio_sample_rate:, + audio_channels:, video_preset:, video_profile:, frame_rate:, @@ -47,6 +49,7 @@ def h264_240p( threads: FFMPEG.threads, audio_bit_rate: '128k', audio_sample_rate: 48_000, + audio_channels: 2, video_preset: 'ultrafast', video_profile: 'baseline', frame_rate: 30, @@ -61,6 +64,7 @@ def h264_240p( threads:, audio_bit_rate:, audio_sample_rate:, + audio_channels:, video_preset:, video_profile:, frame_rate:, @@ -79,6 +83,7 @@ def h264_360p( threads: FFMPEG.threads, audio_bit_rate: '128k', audio_sample_rate: 48_000, + audio_channels: 2, video_preset: 'ultrafast', video_profile: 'baseline', frame_rate: 30, @@ -93,6 +98,7 @@ def h264_360p( threads:, audio_bit_rate:, audio_sample_rate:, + audio_channels:, video_preset:, video_profile:, frame_rate:, @@ -111,6 +117,7 @@ def h264_480p( threads: FFMPEG.threads, audio_bit_rate: '128k', audio_sample_rate: 48_000, + audio_channels: 2, video_preset: 'fast', video_profile: 'main', frame_rate: 30, @@ -125,6 +132,7 @@ def h264_480p( threads:, audio_bit_rate:, audio_sample_rate:, + audio_channels:, video_preset:, video_profile:, frame_rate:, @@ -143,6 +151,7 @@ def h264_720p( threads: FFMPEG.threads, audio_bit_rate: '128k', audio_sample_rate: 48_000, + audio_channels: 2, video_preset: 'fast', video_profile: 'high', frame_rate: 60, @@ -157,6 +166,7 @@ def h264_720p( threads:, audio_bit_rate:, audio_sample_rate:, + audio_channels:, video_preset:, video_profile:, frame_rate:, @@ -175,6 +185,7 @@ def h264_1080p( threads: FFMPEG.threads, audio_bit_rate: '128k', audio_sample_rate: 48_000, + audio_channels: 2, video_preset: 'fast', video_profile: 'high', frame_rate: 60, @@ -189,6 +200,7 @@ def h264_1080p( threads:, audio_bit_rate:, audio_sample_rate:, + audio_channels:, video_preset:, video_profile:, frame_rate:, @@ -207,6 +219,7 @@ def h264_1440p( threads: FFMPEG.threads, audio_bit_rate: '128k', audio_sample_rate: 48_000, + audio_channels: 2, video_preset: 'fast', video_profile: 'high', frame_rate: 60, @@ -221,6 +234,7 @@ def h264_1440p( threads:, audio_bit_rate:, audio_sample_rate:, + audio_channels:, video_preset:, video_profile:, frame_rate:, @@ -239,6 +253,7 @@ def h264_4k( threads: FFMPEG.threads, audio_bit_rate: '128k', audio_sample_rate: 48_000, + audio_channels: 2, video_preset: 'fast', video_profile: 'high', frame_rate: 60, @@ -253,6 +268,7 @@ def h264_4k( threads:, audio_bit_rate:, audio_sample_rate:, + audio_channels:, video_preset:, video_profile:, frame_rate:, @@ -267,7 +283,7 @@ def h264_4k( # Preset to encode H.264 video files. class H264 < Preset - attr_reader :threads, :audio_bit_rate, :audio_sample_rate, :video_preset, :video_profile, + attr_reader :threads, :audio_bit_rate, :audio_sample_rate, :audio_channels, :video_preset, :video_profile, :frame_rate, :constant_rate_factor, :pixel_format, :max_width, :max_height @@ -275,6 +291,8 @@ class H264 < Preset # @param filename [String] The filename format of the output. # @param metadata [Object] The metadata to associate with the preset. # @param audio_bit_rate [String] The audio bit rate to use. + # @param audio_sample_rate [Integer] The audio sample rate to use. + # @param audio_channels [Integer, nil] The number of audio channels to use (nil to preserve source). # @param video_preset [String] The video preset to use. # @param video_profile [String] The video profile to use. # @param frame_rate [Integer] The frame rate to use. @@ -290,6 +308,7 @@ def initialize( threads: FFMPEG.threads, audio_bit_rate: '128k', audio_sample_rate: 48_000, + audio_channels: 2, video_preset: 'fast', video_profile: 'high', frame_rate: 30, @@ -310,6 +329,7 @@ def initialize( @threads = threads @audio_bit_rate = audio_bit_rate @audio_sample_rate = audio_sample_rate + @audio_channels = audio_channels @video_preset = video_preset @video_profile = video_profile @frame_rate = frame_rate @@ -340,6 +360,7 @@ def initialize( map media.audio_mapping_id do audio_bit_rate preset.audio_bit_rate audio_sample_rate preset.audio_sample_rate + audio_channels preset.audio_channels if preset.audio_channels end end end diff --git a/lib/ffmpeg/raw_command_args.rb b/lib/ffmpeg/raw_command_args.rb index 1b277fc..338da20 100644 --- a/lib/ffmpeg/raw_command_args.rb +++ b/lib/ffmpeg/raw_command_args.rb @@ -583,6 +583,21 @@ def video_quality(value, **kwargs) stream_arg('q', value, stream_type: 'v', **kwargs) end + # Sets the video sync in the command arguments. + # This is used to synchronize audio and video streams. + # + # @param value [String, Numeric] The video sync to set. + # @return [self] + # + # @example + # args = FFMPEG::RawCommandArgs.compose do + # video_sync 'cfr' + # end + # args.to_s # "-vsync cfr" + def video_sync(value) + arg('vsync', value) + end + # Sets a frame rate in the command arguments. # # @param value [String, Numeric] The frame rate to set. diff --git a/lib/ffmpeg/version.rb b/lib/ffmpeg/version.rb index 1fb60af..261c129 100644 --- a/lib/ffmpeg/version.rb +++ b/lib/ffmpeg/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module FFMPEG - VERSION = '8.1.0-beta.4' + VERSION = '8.1.0-beta.5' end diff --git a/spec/ffmpeg/presets_spec.rb b/spec/ffmpeg/presets_spec.rb index 49ae3cd..882a4e3 100644 --- a/spec/ffmpeg/presets_spec.rb +++ b/spec/ffmpeg/presets_spec.rb @@ -45,7 +45,7 @@ def initialize(name:, preset:, assert:) end ), PresetTest.new( - name: 'DASH H.264 4K 30 FPS', + name: 'DASH H.264 4K 60 FPS', preset: Presets::DASH.h264_4k, assert: lambda do |media| expect(media.path).to match(/\.mpd\z/) @@ -54,11 +54,17 @@ def initialize(name:, preset:, assert:) expect(media.audio_streams.length).to be(1) expect(media.width).to be(1080) expect(media.height).to be(1920) - expect(media.frame_rate).to eq(Rational(60)) + # FFmpeg 4 has a bug where avg_frame_rate is 0/0 for our DASH manifests + expect(media.frame_rate).to eq(Rational(60)) unless FFMPEG.ffmpeg_version?('4.') expect(media.audio_bit_rate).to be_within(15_000).of(128_000) expect(media.video_streams.map(&:width)).to eq([1080, 720, 480, 360]) expect(media.video_streams.map(&:height)).to eq([1920, 1280, 854, 640]) - expect(media.video_streams.map(&:frame_rate)).to eq([Rational(60), Rational(60), Rational(30), Rational(30)]) + # FFmpeg 4 has a bug where avg_frame_rate is 0/0 for our DASH manifests + unless FFMPEG.ffmpeg_version?('4.') + expect(media.video_streams.map(&:frame_rate)).to( + eq([Rational(60), Rational(60), Rational(30), Rational(30)]) + ) + end end ), PresetTest.new( diff --git a/spec/ffmpeg_spec.rb b/spec/ffmpeg_spec.rb index f0b25da..bb519a6 100644 --- a/spec/ffmpeg_spec.rb +++ b/spec/ffmpeg_spec.rb @@ -6,13 +6,17 @@ before do described_class.instance_variable_set(:@logger, nil) described_class.instance_variable_set(:@ffmpeg_binary, nil) + described_class.instance_variable_set(:@ffmpeg_version, nil) described_class.instance_variable_set(:@ffprobe_binary, nil) + described_class.instance_variable_set(:@ffprobe_version, nil) end after do described_class.instance_variable_set(:@logger, nil) described_class.instance_variable_set(:@ffmpeg_binary, nil) + described_class.instance_variable_set(:@ffmpeg_version, nil) described_class.instance_variable_set(:@ffprobe_binary, nil) + described_class.instance_variable_set(:@ffprobe_version, nil) end describe '.logger' do @@ -44,6 +48,13 @@ expect(described_class.ffmpeg_binary).to eq('/path/to/ffmpeg') end + it 'clears the cached ffmpeg version' do + expect(File).to receive(:executable?).with('/path/to/ffmpeg').and_return(true) + described_class.instance_variable_set(:@ffmpeg_version, '4.4.6') + described_class.ffmpeg_binary = '/path/to/ffmpeg' + expect(described_class.instance_variable_get(:@ffmpeg_version)).to be_nil + end + context 'when the assigned value is not executable' do it 'raises an error' do expect(File).to receive(:executable?).with('/path/to/ffmpeg').and_return(false) @@ -52,6 +63,58 @@ end end + describe '.ffmpeg_version' do + it 'returns the version string from ffmpeg binary' do + expect(FFMPEG::IO).to receive(:capture3) + .with(described_class.ffmpeg_binary, '-version') + .and_return(['ffmpeg version 4.4.6 Copyright (c) 2000-2025 the FFmpeg developers', '']) + + expect(described_class.ffmpeg_version).to eq('4.4.6') + end + + it 'caches the version' do + expect(FFMPEG::IO).to receive(:capture3) + .once + .with(described_class.ffmpeg_binary, '-version') + .and_return(['ffmpeg version 8.0 Copyright (c) 2000-2025 the FFmpeg developers', '']) + + described_class.ffmpeg_version + expect(described_class.ffmpeg_version).to eq('8.0') + end + + it 'handles versions with different formats' do + expect(FFMPEG::IO).to receive(:capture3) + .with(described_class.ffmpeg_binary, '-version') + .and_return(['ffmpeg version 5.1.2-static https://johnvansickle.com/ffmpeg/', '']) + + expect(described_class.ffmpeg_version).to eq('5.1.2') + end + end + + describe '.ffmpeg_version?' do + context 'when the version matches the pattern' do + it 'returns true' do + expect(FFMPEG::IO).to receive(:capture3) + .with(described_class.ffmpeg_binary, '-version') + .and_return(['ffmpeg version 4.4.6 Copyright (c) 2000-2025 the FFmpeg developers', '']) + + expect(described_class.ffmpeg_version?('4.4')).to be true + expect(described_class.ffmpeg_version?(/^4\.\d+/)).to be true + end + end + + context 'when the version does not match the pattern' do + it 'returns false' do + expect(FFMPEG::IO).to receive(:capture3) + .with(described_class.ffmpeg_binary, '-version') + .and_return(['ffmpeg version 4.4.6 Copyright (c) 2000-2025 the FFmpeg developers', '']) + + expect(described_class.ffmpeg_version?('5')).to be false + expect(described_class.ffmpeg_version?(/^5/)).to be false + end + end + end + describe '.ffmpeg_execute' do it 'returns the process status' do args = ['-i', fixture_media_file('hello.wav'), '-f', 'null', '-'] @@ -144,6 +207,13 @@ expect(described_class.ffprobe_binary).to eq '/path/to/ffprobe' end + it 'clears the cached ffprobe version' do + expect(File).to receive(:executable?).with('/path/to/ffprobe').and_return(true) + described_class.instance_variable_set(:@ffprobe_version, '4.4.6') + described_class.ffprobe_binary = '/path/to/ffprobe' + expect(described_class.instance_variable_get(:@ffprobe_version)).to be_nil + end + context 'when the assigned value is not executable' do it 'raises an error' do expect(File).to receive(:executable?).with('/path/to/ffprobe').and_return(false) @@ -151,4 +221,56 @@ end end end + + describe '.ffprobe_version' do + it 'returns the version string from ffprobe binary' do + expect(FFMPEG::IO).to receive(:capture3) + .with(described_class.ffprobe_binary, '-version') + .and_return(['ffprobe version 4.4.6 Copyright (c) 2007-2025 the FFmpeg developers', '']) + + expect(described_class.ffprobe_version).to eq('4.4.6') + end + + it 'caches the version' do + expect(FFMPEG::IO).to receive(:capture3) + .once + .with(described_class.ffprobe_binary, '-version') + .and_return(['ffprobe version 8.0 Copyright (c) 2007-2025 the FFmpeg developers', '']) + + described_class.ffprobe_version + expect(described_class.ffprobe_version).to eq('8.0') + end + + it 'handles versions with different formats' do + expect(FFMPEG::IO).to receive(:capture3) + .with(described_class.ffprobe_binary, '-version') + .and_return(['ffprobe version 5.1.2-static https://johnvansickle.com/ffmpeg/', '']) + + expect(described_class.ffprobe_version).to eq('5.1.2') + end + end + + describe '.ffprobe_version?' do + context 'when the version matches the pattern' do + it 'returns true' do + expect(FFMPEG::IO).to receive(:capture3) + .with(described_class.ffprobe_binary, '-version') + .and_return(['ffprobe version 4.4.6 Copyright (c) 2007-2025 the FFmpeg developers', '']) + + expect(described_class.ffprobe_version?('4.4')).to be true + expect(described_class.ffprobe_version?(/^4\.\d+/)).to be true + end + end + + context 'when the version does not match the pattern' do + it 'returns false' do + expect(FFMPEG::IO).to receive(:capture3) + .with(described_class.ffprobe_binary, '-version') + .and_return(['ffprobe version 4.4.6 Copyright (c) 2007-2025 the FFmpeg developers', '']) + + expect(described_class.ffprobe_version?('5')).to be false + expect(described_class.ffprobe_version?(/^5/)).to be false + end + end + end end