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
3 changes: 3 additions & 0 deletions CHANGELOG
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
== 8.3.0 2026-03-19
* Introduce inline remux when there's no output path provided

== 8.2.0 2026-03-11

Improvements:
Expand Down
30 changes: 25 additions & 5 deletions lib/ffmpeg/media.rb
Original file line number Diff line number Diff line change
Expand Up @@ -85,22 +85,42 @@ def initialize(path, *ffprobe_args, load: true, autoload: true)
# extraction, it falls back to extracting raw streams and re-muxing with
# a corrected frame rate.
#
# @param output_path [String, Pathname] The output path for the remuxed file.
# @param output_path [String, Pathname, nil] The output path for the remuxed file.
# Tries an inline replacement for nil value.
# @param timeout [Integer, nil] Timeout in seconds for each ffmpeg command.
# @yield [report] Reports from the ffmpeg command (see FFMPEG::Reporters).
# @return [FFMPEG::Transcoder::Status]
def remux(output_path, timeout: nil, &block)
Remuxer.new(timeout:).process(self, output_path, &block)
def remux(output_path = nil, timeout: nil, &block)
return Remuxer.new(timeout:).process(self, output_path, &block) if output_path
raise ArgumentError if remote?

Dir.mktmpdir do |tmpdir|
output_path = File.join(tmpdir, File.basename(@path))

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

if status.success?
File.unlink @path
File.mv output_path, @path
if @loaded
@loaded = false
load!
end
end

status
end
end

# Remuxes the media file to the given output path via stream copy,
# raising an error if the remux fails.
#
# @param output_path [String, Pathname] The output path for the remuxed file.
# @param output_path [String, Pathname, nil] The output path for the remuxed file.
# Tries an inline replacement for nil value.
# @param timeout [Integer, nil] Timeout in seconds for each ffmpeg command.
# @yield [report] Reports from the ffmpeg command (see FFMPEG::Reporters).
# @return [FFMPEG::Transcoder::Status]
def remux!(output_path, timeout: nil, &block)
def remux!(output_path = nil, timeout: nil, &block)
remux(output_path, timeout:, &block).assert!
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.2.0'
VERSION = '8.3.0'
end
119 changes: 119 additions & 0 deletions spec/ffmpeg/media_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -594,6 +594,125 @@ module FFMPEG
end
end

describe '#remux' do
context 'with an output_path' do
let(:output_path) { tmp_file(ext: 'mp4') }
let(:remuxer) { instance_double(Remuxer) }
let(:status) { instance_double(Transcoder::Status) }

before do
allow(Remuxer).to receive(:new).and_return(remuxer)
allow(remuxer).to receive(:process).and_return(status)
end

it 'delegates to a new Remuxer with the given output path' do
expect(Remuxer).to receive(:new).with(timeout: nil).and_return(remuxer)
expect(remuxer).to receive(:process).with(subject, output_path).and_return(status)
expect(subject.remux(output_path)).to be(status)
end

it 'passes the timeout option to the Remuxer' do
timeout = rand(999)
expect(Remuxer).to receive(:new).with(timeout: timeout).and_return(remuxer)
expect(remuxer).to receive(:process).with(subject, output_path).and_return(status)
subject.remux(output_path, timeout: timeout)
end
end

context 'without an output_path' do
context 'when the media is remote' do
let(:path) { fixture_media_file('landscape@4k60.mp4', remote: true) }

it 'raises ArgumentError' do
expect { subject.remux }.to raise_error(ArgumentError)
end
end

context 'when the media is local' do
let(:path) do
dst = tmp_file(ext: 'mp4')
FileUtils.cp(fixture_media_file('widescreen-no-audio.mp4'), dst)
dst
end

let(:remuxer) { instance_double(Remuxer) }

before do
allow(Remuxer).to receive(:new).and_return(remuxer)
end

context 'when the remux succeeds' do
let(:remux_status) { instance_double(Transcoder::Status, success?: true) }

before do
allow(remuxer).to receive(:process).and_return(remux_status)
allow(File).to receive(:unlink)
allow(File).to receive(:mv)
end

it 'unlinks the original file and moves the remuxed output in its place' do
expect(File).to receive(:unlink).with(path)
expect(File).to receive(:mv).with(an_instance_of(String), path)
subject.remux
end

it 'reloads the media metadata' do
subject # force initialization before setting expectation
expect(subject).to receive(:load!).once.and_call_original
subject.remux
end

it 'returns the status' do
expect(subject.remux).to be(remux_status)
end
end

context 'when the remux fails' do
let(:remux_status) { instance_double(Transcoder::Status, success?: false) }

before do
allow(remuxer).to receive(:process).and_return(remux_status)
end

it 'does not modify the original file' do
expect(File).not_to receive(:unlink)
expect(File).not_to receive(:mv)
subject.remux
end

it 'returns the status' do
expect(subject.remux).to be(remux_status)
end
end
end
end
end

describe '#remux!' do
context 'with an output_path' do
it 'calls assert! on the result of #remux' do
output_path = tmp_file(ext: 'mp4')
status = instance_double(Transcoder::Status)

expect(subject).to receive(:remux).with(output_path, timeout: nil).and_return(status)
expect(status).to receive(:assert!).and_return(status)

subject.remux!(output_path)
end
end

context 'without an output_path' do
it 'calls assert! on the result of #remux without output_path' do
status = instance_double(Transcoder::Status)

expect(subject).to receive(:remux).with(nil, timeout: nil).and_return(status)
expect(status).to receive(:assert!).and_return(status)

subject.remux!
end
end
end

describe '#ffmpeg_execute' do
it 'executes a ffmpeg command with the media as input' do
reports = []
Expand Down
Loading