diff --git a/CHANGELOG b/CHANGELOG index 81ed863..3125145 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,10 @@ +== 8.4.0 2026-03-20 + +Improvements: +* Added `FFMPEG::Media#extname` to detect the best file extension for a media's container format. +* Added `FFMPEG.muxers` to retrieve the set of available muxer names. +* Remux now uses `extname` to produce correct media even for incorrectly named input. + == 8.3.1 2026-03-20 Fixes: diff --git a/lib/ffmpeg.rb b/lib/ffmpeg.rb index 169d5a5..ab538db 100644 --- a/lib/ffmpeg.rb +++ b/lib/ffmpeg.rb @@ -101,6 +101,7 @@ def ffmpeg_binary=(path) @ffmpeg_binary = path&.to_s @ffmpeg_version = nil + @muxers = nil end # Get the path to the ffmpeg binary. @@ -132,6 +133,16 @@ def ffmpeg_version?(pattern) ffmpeg_version.start_with?(pattern.to_s) end + # Get the set of available muxer names for the ffmpeg binary. + # + # @return [Set] + def muxers + @muxers ||= begin + stdout, = ffmpeg_capture3('-muxers', '-v', 'quiet') + stdout.scan(/^\s*E\S*\s+(\S+)/).flatten.to_set + end + end + # Safely captures the standard output and the standard error of the ffmpeg command. # # @param args [Array] The arguments to pass to ffmpeg. diff --git a/lib/ffmpeg/media.rb b/lib/ffmpeg/media.rb index 4ff933c..6e03db0 100644 --- a/lib/ffmpeg/media.rb +++ b/lib/ffmpeg/media.rb @@ -34,6 +34,9 @@ module FFMPEG # media.load! # media.video? # => true class Media + WEBM_CODEC_NAMES = Set.new(%w[vp8 vp9 av1 opus vorbis]).freeze + private_constant :WEBM_CODEC_NAMES + # Raised if media metadata cannot be loaded. class LoadError < Error attr_reader :output @@ -67,6 +70,41 @@ def initialize(message, output) :format_name, :format_long_name, :start_time, :bit_rate, :duration + # Returns the file extension that best represents this media's container format. + # + # @return [String] e.g. ".mp4", ".mkv", ".webm", ".ts", ".m3u8" + autoload def extname + case format_name + when /\Adash\b/ then '.mpd' + when /\bhls\b/ then '.m3u8' + when /\bmpegts\b/ then '.ts' + when /\b(mov|mp4)\b/ + case major_brand + when /\Aqt\b/i then '.mov' + when /\Am4a\b/i then '.m4a' + when /\Am4v\b/i then '.m4v' + when /\Am4s\b/i then '.m4s' + else '.mp4' + end + when /\bmatroska\b/ + if streams + .select { _1.video? || _1.audio? } + .reject(&:attached_pic?) + .all? { WEBM_CODEC_NAMES.include?(_1.codec_name) } + '.webm' + else + '.mkv' + end + else + muxer = + format_name + .split(',') + .find { FFMPEG.muxers.include?(_1) } + .then { _1 || format_name.split(',').first } + ".#{muxer}" + end + end + # @param path [String, Pathname, URI] The local path or remote URL to a multimedia file. # @param ffprobe_args [Array] Additional arguments to pass to ffprobe. # @param load [Boolean] Whether to load the metadata immediately. @@ -95,7 +133,7 @@ def remux(output_path = nil, timeout: nil, &block) raise ArgumentError if remote? Dir.mktmpdir do |tmpdir| - output_path = File.join(tmpdir, File.basename(@path)) + output_path = File.join(tmpdir, "#{File.basename(@path, '.*')}#{extname}") status = Remuxer.new(timeout:).process(self, output_path, &block) diff --git a/lib/ffmpeg/version.rb b/lib/ffmpeg/version.rb index 3c8fd6b..282764d 100644 --- a/lib/ffmpeg/version.rb +++ b/lib/ffmpeg/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module FFMPEG - VERSION = '8.3.1' + VERSION = '8.4.0' end diff --git a/spec/ffmpeg/media_spec.rb b/spec/ffmpeg/media_spec.rb index 968c264..dc97229 100644 --- a/spec/ffmpeg/media_spec.rb +++ b/spec/ffmpeg/media_spec.rb @@ -594,6 +594,62 @@ module FFMPEG end end + describe '#extname' do + context 'when the media is an MP4' do + it 'returns .mp4' do + expect(subject.extname).to eq('.mp4') + end + end + + context 'when the media is a MOV (QuickTime)' do + let(:path) { fixture_media_file('rotated@0.mov') } + + it 'returns .mov' do + expect(subject.extname).to eq('.mov') + end + end + + context 'when the media is a WAV' do + let(:path) { fixture_media_file('hello.wav') } + + it 'returns .wav' do + expect(subject.extname).to eq('.wav') + end + end + + context 'when the media is an MP3' do + let(:path) { fixture_media_file('napoleon.mp3') } + + it 'returns .mp3' do + expect(subject.extname).to eq('.mp3') + end + end + + context 'when the media is a Matroska container with H.264 video named .webm' do + let(:path) { fixture_media_file('mkvh264.webm') } + + it 'returns .mkv' do + expect(subject.extname).to eq('.mkv') + end + end + + context 'when the media is a WebM container with VP9 video and Opus audio' do + let(:path) { fixture_media_file('landscape@smol.webm') } + + it 'returns .webm' do + expect(subject.extname).to eq('.webm') + end + end + + context 'when the media is a Matroska container with VP9 video and AAC audio named .webm' do + let(:path) { fixture_media_file('mkvaac.webm') } + + it 'returns .mkv' do + expect(subject.extname).to eq('.mkv') + end + end + end + describe '#remux' do context 'with an output_path' do let(:output_path) { tmp_file(ext: 'mp4') } @@ -664,6 +720,23 @@ module FFMPEG end end end + + context 'when the media is incorrectly named' do + let(:path) do + path = tmp_file(ext: 'webm') + FileUtils.cp(fixture_media_file('mkvh264.webm'), path) + path + end + + it 'remuxes the file in place' do + status = subject.remux + + expect(status).to be_a(Transcoder::Status) + expect(status.success?).to be(true) + expect(File.exist?(path)).to be(true) + expect(File.size(path)).to be > 0 + end + end end end diff --git a/spec/ffmpeg_spec.rb b/spec/ffmpeg_spec.rb index aa0d1ae..9311f23 100644 --- a/spec/ffmpeg_spec.rb +++ b/spec/ffmpeg_spec.rb @@ -55,6 +55,13 @@ expect(described_class.instance_variable_get(:@ffmpeg_version)).to be_nil end + it 'clears the cached muxers' do + expect(File).to receive(:executable?).with('/path/to/ffmpeg').and_return(true) + described_class.instance_variable_set(:@muxers, Set.new(['mp4'])) + described_class.ffmpeg_binary = '/path/to/ffmpeg' + expect(described_class.instance_variable_get(:@muxers)).to be_nil + end + context 'when the assigned value is nil' do it 'clears the ffmpeg binary and version' do described_class.instance_variable_set(:@ffmpeg_binary, '/path/to/ffmpeg') @@ -125,6 +132,22 @@ end end + describe '.muxers' do + before { described_class.instance_variable_set(:@muxers, nil) } + after { described_class.instance_variable_set(:@muxers, nil) } + + it 'returns a set of available muxer names' do + expect(described_class.muxers).to be_a(Set) + expect(described_class.muxers).to include('mp4', 'matroska', 'webm') + end + + it 'caches the result' do + expect(described_class).to receive(:ffmpeg_capture3).once.and_call_original + described_class.muxers + described_class.muxers + end + end + describe '.ffmpeg_execute' do it 'returns the process status' do args = ['-i', fixture_media_file('hello.wav'), '-f', 'null', '-'] diff --git a/spec/fixtures/media/landscape@smol.webm b/spec/fixtures/media/landscape@smol.webm new file mode 100644 index 0000000..2ce401f Binary files /dev/null and b/spec/fixtures/media/landscape@smol.webm differ diff --git a/spec/fixtures/media/mkvaac.webm b/spec/fixtures/media/mkvaac.webm new file mode 100644 index 0000000..268703f Binary files /dev/null and b/spec/fixtures/media/mkvaac.webm differ diff --git a/spec/fixtures/media/mkvh264.webm b/spec/fixtures/media/mkvh264.webm new file mode 100644 index 0000000..945c338 Binary files /dev/null and b/spec/fixtures/media/mkvh264.webm differ