Skip to content

Commit a20909d

Browse files
committed
feat: improve remux format detection
1 parent 600996b commit a20909d

7 files changed

Lines changed: 146 additions & 1 deletion

File tree

lib/ffmpeg.rb

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ def ffmpeg_binary=(path)
101101

102102
@ffmpeg_binary = path&.to_s
103103
@ffmpeg_version = nil
104+
@muxers = nil
104105
end
105106

106107
# Get the path to the ffmpeg binary.
@@ -132,6 +133,16 @@ def ffmpeg_version?(pattern)
132133
ffmpeg_version.start_with?(pattern.to_s)
133134
end
134135

136+
# Get the set of available muxer names for the ffmpeg binary.
137+
#
138+
# @return [Set<String>]
139+
def muxers
140+
@muxers ||= begin
141+
stdout, = ffmpeg_capture3('-muxers', '-v', 'quiet')
142+
stdout.scan(/^\s*E\S*\s+(\S+)/).flatten.to_set
143+
end
144+
end
145+
135146
# Safely captures the standard output and the standard error of the ffmpeg command.
136147
#
137148
# @param args [Array<String>] The arguments to pass to ffmpeg.

lib/ffmpeg/media.rb

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ module FFMPEG
3434
# media.load!
3535
# media.video? # => true
3636
class Media
37+
WEBM_CODEC_NAMES = Set.new(%w[vp8 vp9 av1 opus vorbis]).freeze
38+
private_constant :WEBM_CODEC_NAMES
39+
3740
# Raised if media metadata cannot be loaded.
3841
class LoadError < Error
3942
attr_reader :output
@@ -67,6 +70,41 @@ def initialize(message, output)
6770
:format_name, :format_long_name,
6871
:start_time, :bit_rate, :duration
6972

73+
# Returns the file extension that best represents this media's container format.
74+
#
75+
# @return [String] e.g. ".mp4", ".mkv", ".webm", ".ts", ".m3u8"
76+
autoload def extname
77+
case format_name
78+
when /\Adash\b/ then '.mpd'
79+
when /\bhls\b/ then '.m3u8'
80+
when /\bmpegts\b/ then '.ts'
81+
when /\b(mov|mp4)\b/
82+
case major_brand
83+
when /\Aqt\b/i then '.mov'
84+
when /\Am4a\b/i then '.m4a'
85+
when /\Am4v\b/i then '.m4v'
86+
when /\Am4s\b/i then '.m4s'
87+
else '.mp4'
88+
end
89+
when /\bmatroska\b/
90+
if streams
91+
.select { _1.video? || _1.audio? }
92+
.reject(&:attached_pic?)
93+
.all? { WEBM_CODEC_NAMES.include?(_1.codec_name) }
94+
'.webm'
95+
else
96+
'.mkv'
97+
end
98+
else
99+
muxer =
100+
format_name
101+
.split(',')
102+
.find { FFMPEG.muxers.include?(_1) }
103+
.then { _1 || format_name.split(',').first }
104+
".#{muxer}"
105+
end
106+
end
107+
70108
# @param path [String, Pathname, URI] The local path or remote URL to a multimedia file.
71109
# @param ffprobe_args [Array<String>] Additional arguments to pass to ffprobe.
72110
# @param load [Boolean] Whether to load the metadata immediately.
@@ -95,7 +133,7 @@ def remux(output_path = nil, timeout: nil, &block)
95133
raise ArgumentError if remote?
96134

97135
Dir.mktmpdir do |tmpdir|
98-
output_path = File.join(tmpdir, File.basename(@path))
136+
output_path = File.join(tmpdir, "#{File.basename(@path, '.*')}#{extname}")
99137

100138
status = Remuxer.new(timeout:).process(self, output_path, &block)
101139

spec/ffmpeg/media_spec.rb

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -594,6 +594,62 @@ module FFMPEG
594594
end
595595
end
596596

597+
describe '#extname' do
598+
context 'when the media is an MP4' do
599+
it 'returns .mp4' do
600+
expect(subject.extname).to eq('.mp4')
601+
end
602+
end
603+
604+
context 'when the media is a MOV (QuickTime)' do
605+
let(:path) { fixture_media_file('rotated@0.mov') }
606+
607+
it 'returns .mov' do
608+
expect(subject.extname).to eq('.mov')
609+
end
610+
end
611+
612+
context 'when the media is a WAV' do
613+
let(:path) { fixture_media_file('hello.wav') }
614+
615+
it 'returns .wav' do
616+
expect(subject.extname).to eq('.wav')
617+
end
618+
end
619+
620+
context 'when the media is an MP3' do
621+
let(:path) { fixture_media_file('napoleon.mp3') }
622+
623+
it 'returns .mp3' do
624+
expect(subject.extname).to eq('.mp3')
625+
end
626+
end
627+
628+
context 'when the media is a Matroska container with H.264 video named .webm' do
629+
let(:path) { fixture_media_file('mkvh264.webm') }
630+
631+
it 'returns .mkv' do
632+
expect(subject.extname).to eq('.mkv')
633+
end
634+
end
635+
636+
context 'when the media is a WebM container with VP9 video and Opus audio' do
637+
let(:path) { fixture_media_file('landscape@smol.webm') }
638+
639+
it 'returns .webm' do
640+
expect(subject.extname).to eq('.webm')
641+
end
642+
end
643+
644+
context 'when the media is a Matroska container with VP9 video and AAC audio named .webm' do
645+
let(:path) { fixture_media_file('mkvaac.webm') }
646+
647+
it 'returns .mkv' do
648+
expect(subject.extname).to eq('.mkv')
649+
end
650+
end
651+
end
652+
597653
describe '#remux' do
598654
context 'with an output_path' do
599655
let(:output_path) { tmp_file(ext: 'mp4') }
@@ -664,6 +720,23 @@ module FFMPEG
664720
end
665721
end
666722
end
723+
724+
context 'when the media is incorrectly named' do
725+
let(:path) do
726+
path = tmp_file(ext: 'webm')
727+
FileUtils.cp(fixture_media_file('mkvh264.webm'), path)
728+
path
729+
end
730+
731+
it 'remuxes the file in place' do
732+
status = subject.remux
733+
734+
expect(status).to be_a(Transcoder::Status)
735+
expect(status.success?).to be(true)
736+
expect(File.exist?(path)).to be(true)
737+
expect(File.size(path)).to be > 0
738+
end
739+
end
667740
end
668741
end
669742

spec/ffmpeg_spec.rb

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,13 @@
5555
expect(described_class.instance_variable_get(:@ffmpeg_version)).to be_nil
5656
end
5757

58+
it 'clears the cached muxers' do
59+
expect(File).to receive(:executable?).with('/path/to/ffmpeg').and_return(true)
60+
described_class.instance_variable_set(:@muxers, Set.new(['mp4']))
61+
described_class.ffmpeg_binary = '/path/to/ffmpeg'
62+
expect(described_class.instance_variable_get(:@muxers)).to be_nil
63+
end
64+
5865
context 'when the assigned value is nil' do
5966
it 'clears the ffmpeg binary and version' do
6067
described_class.instance_variable_set(:@ffmpeg_binary, '/path/to/ffmpeg')
@@ -125,6 +132,22 @@
125132
end
126133
end
127134

135+
describe '.muxers' do
136+
before { described_class.instance_variable_set(:@muxers, nil) }
137+
after { described_class.instance_variable_set(:@muxers, nil) }
138+
139+
it 'returns a set of available muxer names' do
140+
expect(described_class.muxers).to be_a(Set)
141+
expect(described_class.muxers).to include('mp4', 'matroska', 'webm')
142+
end
143+
144+
it 'caches the result' do
145+
expect(described_class).to receive(:ffmpeg_capture3).once.and_call_original
146+
described_class.muxers
147+
described_class.muxers
148+
end
149+
end
150+
128151
describe '.ffmpeg_execute' do
129152
it 'returns the process status' do
130153
args = ['-i', fixture_media_file('hello.wav'), '-f', 'null', '-']
25.7 KB
Binary file not shown.

spec/fixtures/media/mkvaac.webm

29 KB
Binary file not shown.

spec/fixtures/media/mkvh264.webm

5.9 MB
Binary file not shown.

0 commit comments

Comments
 (0)