diff --git a/CHANGELOG b/CHANGELOG index 01bb4b9..13283bb 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,7 @@ +== 8.5.1 2026-11-12 +* Fix re-loading the media after a successful remux +* Pick decodable audio stream over a non-decodable during remux + == 8.5.0 2026-07-16 Improvements: diff --git a/lib/ffmpeg/media.rb b/lib/ffmpeg/media.rb index c347456..e16ec7d 100644 --- a/lib/ffmpeg/media.rb +++ b/lib/ffmpeg/media.rb @@ -228,6 +228,13 @@ def load! @size = @metadata[:format][:size].to_i @streams = @metadata[:streams].map { |metadata| Stream.new(metadata, stderr) } + %i[ + @video_streams + @default_video_stream + @audio_streams + @default_audio_stream + @codec_names + ].each { remove_instance_variable(_1) if instance_variable_defined? _1 } @tags = @metadata[:format][:tags] @format_name = @metadata[:format][:format_name] diff --git a/lib/ffmpeg/remuxer.rb b/lib/ffmpeg/remuxer.rb index 56ab892..d29aead 100644 --- a/lib/ffmpeg/remuxer.rb +++ b/lib/ffmpeg/remuxer.rb @@ -52,7 +52,10 @@ def annexb?(media) def process(media, output_path, &) media = Media.new(media, load: false) unless media.is_a?(Media) - status = ffmpeg_copy(media, output_path, &) + audio_map = decodable_audio_map_patch(media) + copy_map = audio_map.nil? ? [] : ['-map', '0:v:0?', *audio_map] + + status = ffmpeg_copy(media, *copy_map, output_path, &) return status if status.success? return status unless self.class.annexb?(media) @@ -64,7 +67,7 @@ def process(media, output_path, &) return annexb_status unless annexb_status.success? mka_path = File.join(tmpdir, 'remux.mka') - mka_status = ffmpeg_copy(media, '-vn', mka_path, &) + mka_status = ffmpeg_copy(media, *(audio_map.nil? ? ['-vn'] : audio_map), mka_path, &) return mka_status unless mka_status.success? video = annexb_status.media.first @@ -125,6 +128,22 @@ def ffmpeg_copy(media, *args, inargs: [], &) ) end + # Maps the first audio stream that ffmpeg can decode. Without an explicit + # map, ffmpeg picks the audio stream with the most channels, which can be an + # undecodable one (e.g. iPhone spatial audio, codec_name "unknown") and + # drops the usable stream. Returns nil if there is no decodable audio or all + # audio streams are decodable + def decodable_audio_map_patch(media) + decodable, undecodable = media.audio_streams.partition { decodable_audio?(_1) } + return nil if undecodable.empty? + + decodable.first&.then { ['-map', "0:#{_1.index}"] } + end + + def decodable_audio?(stream) + stream.supported? && !['', 'unknown', 'none'].include?(stream.codec_name.to_s) + end + def annexb_filter(media) ['-bsf:v', "#{media.video_codec_name}_mp4toannexb"] end diff --git a/lib/ffmpeg/version.rb b/lib/ffmpeg/version.rb index d6dcbc1..1fe8e97 100644 --- a/lib/ffmpeg/version.rb +++ b/lib/ffmpeg/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module FFMPEG - VERSION = '8.5.0' + VERSION = '8.5.1' end diff --git a/spec/ffmpeg/media_spec.rb b/spec/ffmpeg/media_spec.rb index 1b84d9e..25f7d0b 100644 --- a/spec/ffmpeg/media_spec.rb +++ b/spec/ffmpeg/media_spec.rb @@ -48,6 +48,36 @@ module FFMPEG subject.load! end + context 'when the media is loaded again after its streams changed' do + let(:old_fixture) { fixture_media_file('widescreen-multi-audio.mp4') } + let(:new_fixture) { fixture_media_file('widescreen-no-audio.mp4') } + let(:tmpdir) { Dir.mktmpdir } + let(:path) { File.join(tmpdir, 'media.mp4') } + + before { FileUtils.cp(old_fixture, path) } + after { FileUtils.remove_entry(tmpdir) } + + it 'resets the memoized values calculated from the streams' do + subject.load! + old_streams = subject.streams + + expect(subject.audio_streams).not_to be_empty + expect(subject.default_audio_stream).not_to be_nil + expect(subject.audio_streams?).to be(true) + + # The spec helper caches ffprobe output by arguments, so drop it to see the new file. + FFMPEG.instance_variable_set(:@ffprobe_cache, nil) + FileUtils.cp(new_fixture, path) + subject.instance_variable_set(:@loaded, false) + subject.load! + + expect(subject.streams).not_to eq(old_streams) + expect(subject.audio_streams).to be_empty + expect(subject.default_audio_stream).to be_nil + expect(subject.audio_streams?).to be(false) + end + end + context 'when the file does not exist' do let(:path) { fixture_media_file('missing.mp4') } @@ -451,10 +481,9 @@ module FFMPEG video_profile: 'High', video_codec_name: 'h264', video_bit_rate: 41_401_600, - # rubocop:disable Layout/LineLength + # rubocop:disable-next Layout/LineLength video_overview: %r{h264 \(High\) \(avc1 / 0x31637661\), yuvj420p\(pc, bt709/bt709/bt709, (progressive|unknown)\), 3840x2160 \[SAR 1:1 DAR 16:9\]}, - # rubocop:enable Layout/LineLength video_tags: { encoder: 'Lavc61.19.100 libx264', handler_name: 'VideoHandle',