Skip to content
Merged
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
7 changes: 7 additions & 0 deletions CHANGELOG
Original file line number Diff line number Diff line change
@@ -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:
Expand Down
11 changes: 11 additions & 0 deletions lib/ffmpeg.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<String>]
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<String>] The arguments to pass to ffmpeg.
Expand Down
40 changes: 39 additions & 1 deletion lib/ffmpeg/media.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<String>] Additional arguments to pass to ffprobe.
# @param load [Boolean] Whether to load the metadata immediately.
Expand Down Expand Up @@ -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)

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.3.1'
VERSION = '8.4.0'
end
73 changes: 73 additions & 0 deletions spec/ffmpeg/media_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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') }
Expand Down Expand Up @@ -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

Expand Down
23 changes: 23 additions & 0 deletions spec/ffmpeg_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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', '-']
Expand Down
Binary file added spec/fixtures/media/landscape@smol.webm
Binary file not shown.
Binary file added spec/fixtures/media/mkvaac.webm
Binary file not shown.
Binary file added spec/fixtures/media/mkvh264.webm
Binary file not shown.
Loading