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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG
Original file line number Diff line number Diff line change
@@ -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:
Expand Down
7 changes: 7 additions & 0 deletions lib/ffmpeg/media.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
23 changes: 21 additions & 2 deletions lib/ffmpeg/remuxer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion lib/ffmpeg/version.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# frozen_string_literal: true

module FFMPEG
VERSION = '8.5.0'
VERSION = '8.5.1'
end
33 changes: 31 additions & 2 deletions spec/ffmpeg/media_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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') }

Expand Down Expand Up @@ -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',
Expand Down
Loading