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
5 changes: 5 additions & 0 deletions CHANGELOG
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
== 8.2.0 2026-03-11

Improvements:
* Added remuxer to clone media with clean containers within milliseconds.

== 8.1.1 2026-02-24

Fixes:
Expand Down
54 changes: 44 additions & 10 deletions lib/ffmpeg.rb
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
require_relative 'ffmpeg/presets/dash/h264'
require_relative 'ffmpeg/presets/h264'
require_relative 'ffmpeg/raw_command_args'
require_relative 'ffmpeg/remuxer'
require_relative 'ffmpeg/reporters/output'
require_relative 'ffmpeg/reporters/progress'
require_relative 'ffmpeg/reporters/silence'
Expand Down Expand Up @@ -90,16 +91,15 @@ def io_encoding=(encoding)

# Set the path to the ffmpeg binary.
#
# @param path [String]
# @return [String]
# @param path [String, Pathname, nil] The path to the ffmpeg binary.
# @return [String, nil]
# @raise [Errno::ENOENT] If the ffmpeg binary is not an executable.
def ffmpeg_binary=(path)
if path.is_a?(String) && !File.executable?(path)
raise Errno::ENOENT,
"The ffmpeg binary, '#{path}', is not executable"
if !path.nil? && !File.executable?(path.to_s)
raise Errno::ENOENT, "The ffmpeg binary, '#{path}', is not executable"
end

@ffmpeg_binary = path
@ffmpeg_binary = path&.to_s
@ffmpeg_version = nil
end

Expand Down Expand Up @@ -211,15 +211,15 @@ def ffprobe_binary
# Set the path of the ffprobe binary.
# Can be useful if you need to specify a path such as /usr/local/bin/ffprobe.
#
# @param [String] path
# @return [String]
# @param [String, Pathname, nil] path The path to the ffprobe binary.
# @return [String, nil]
# @raise [Errno::ENOENT] If the ffprobe binary is not an executable.
def ffprobe_binary=(path)
if path.is_a?(String) && !File.executable?(path)
if !path.nil? && !File.executable?(path.to_s)
raise Errno::ENOENT, "The ffprobe binary, '#{path}', is not executable"
end

@ffprobe_binary = path
@ffprobe_binary = path&.to_s
@ffprobe_version = nil
end

Expand Down Expand Up @@ -270,6 +270,40 @@ def ffprobe_popen3(*args, &)
FFMPEG::IO.popen3(ffprobe_binary, *args, &)
end

# Get the path to the exiftool binary.
# Returns nil if exiftool is not found in the PATH.
#
# @return [String, nil]
def exiftool_binary
return @exiftool_binary if defined?(@exiftool_binary)

@exiftool_binary = which('exiftool')
rescue Errno::ENOENT
@exiftool_binary = nil
end

# Set the path to the exiftool binary.
#
# @param path [String, Pathname, nil] The path to the exiftool binary.
# @return [String, nil]
# @raise [Errno::ENOENT] If the exiftool binary is not an executable.
def exiftool_binary=(path)
if !path.nil? && !File.executable?(path.to_s)
raise Errno::ENOENT, "The exiftool binary, '#{path}', is not executable"
end

@exiftool_binary = path&.to_s
end

# Safely captures the standard output and the standard error of the exiftool command.
#
# @param args [Array<String>] The arguments to pass to exiftool.
# @return [Array<String, Process::Status>] The standard output, the standard error, and the process status.
def exiftool_capture3(*args)
logger.debug(self) { "exiftool #{Shellwords.join(args)}" }
FFMPEG::IO.capture3(exiftool_binary, *args)
end

# Cross-platform way of finding an executable in the $PATH.
# See http://stackoverflow.com/questions/2108727/which-in-ruby-checking-if-program-exists-in-path-from-ruby
#
Expand Down
6 changes: 3 additions & 3 deletions lib/ffmpeg/command_args.rb
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ class << self
#
# @param media [FFMPEG::Media] The media to transcode.
# @param context [Hash, nil] Additional context for composing the arguments.
# # @return [FFMPEG::CommandArgs] The new FFMPEG::CommandArgs object.
# @return [FFMPEG::CommandArgs] The new FFMPEG::CommandArgs object.
def compose(media, context: nil, &block)
new(media, context:).tap do |args|
args.instance_exec(&block) if block_given?
Expand Down Expand Up @@ -66,7 +66,7 @@ def video_bit_rate(target_value, **kwargs)
super(adjusted_video_bit_rate(target_value), **kwargs)
end

# Sets the audio bit rate to the minimum of the current audio bit rate and the target value.
# Sets the minimum video bit rate to the minimum of the current video bit rate and the target value.
# The target value can be an Integer or a String (e.g.: 128k or 1M).
#
# @param target_value [Integer, String] The target bit rate.
Expand All @@ -77,7 +77,7 @@ def min_video_bit_rate(target_value)
super(adjusted_video_bit_rate(target_value))
end

# Sets the audio bit rate to the minimum of the current audio bit rate and the target value.
# Sets the maximum video bit rate to the minimum of the current video bit rate and the target value.
# The target value can be an Integer or a String (e.g.: 128k or 1M).
#
# @param target_value [Integer, String] The target bit rate.
Expand Down
2 changes: 1 addition & 1 deletion lib/ffmpeg/filter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ class << self
# @param filters [Array<Filter>] The filters to join.
# @return [String] The filter chain.
def join(*filters)
filters.compact.map(&:to_s).join(',')
filters.compact.join(',')
end
end

Expand Down
34 changes: 34 additions & 0 deletions lib/ffmpeg/io.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,32 +10,62 @@ module IO
class << self
attr_writer :timeout, :encoding

# Returns the I/O timeout in seconds. Defaults to 30.
#
# @return [Integer]
def timeout
return @timeout if defined?(@timeout)

@timeout = 30
end

# Returns the I/O encoding. Defaults to UTF-8.
#
# @return [Encoding]
def encoding
@encoding ||= Encoding::UTF_8
end

# Encodes the string in-place using the configured encoding,
# replacing invalid and undefined characters.
#
# @param string [String] The string to encode.
# @return [String]
def encode!(string)
string.encode!(encoding, invalid: :replace, undef: :replace)
end

# Extends the given IO object with the configured timeout, encoding,
# and the FFMPEG::IO module.
#
# @param io [IO] The IO object to extend.
# @return [IO]
def extend!(io)
io.timeout = timeout
io.set_encoding(encoding, invalid: :replace, undef: :replace)
io.extend(FFMPEG::IO)
end

# Runs the given command and captures stdout, stderr, and the process status.
# Encodes the output using the configured encoding.
#
# @param cmd [Array<String>] The command to run.
# @return [Array<String, Process::Status>] stdout, stderr, and the process status.
def capture3(*cmd)
*io, status = Open3.capture3(*cmd)
io.each(&method(:encode!))
[*io, status]
end

# Starts the given command and yields or returns stdin, stdout, stderr, and the wait thread.
# Each IO stream is extended with the configured timeout and encoding.
#
# @param cmd [Array<String>] The command to run.
# @yieldparam stdin [IO]
# @yieldparam stdout [FFMPEG::IO]
# @yieldparam stderr [FFMPEG::IO]
# @yieldparam wait_thr [Thread]
# @return [Process::Status, Array<IO, Thread>]
def popen3(*cmd, &block)
if block_given?
Open3.popen3(*cmd) do |*io, wait_thr|
Expand All @@ -54,6 +84,10 @@ def popen3(*cmd, &block)
end
end

# Iterates over each line of the IO stream, yielding each line to the block.
#
# @param chomp [Boolean] Whether to include the line separator in each yielded line.
# @yieldparam line [String] Each line from the stream.
def each(chomp: false, &block)
# We need to run this loop in a separate thread to avoid
# errors with exit signals being sent to the main thread.
Expand Down
24 changes: 24 additions & 0 deletions lib/ffmpeg/media.rb
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,30 @@ def initialize(path, *ffprobe_args, load: true, autoload: true)
load! if load
end

# Remuxes the media file to the given output path via stream copy.
# If the initial stream copy fails and the video codec supports Annex B
# 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 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)
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 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)
remux(output_path, timeout:, &block).assert!
end

# Load the metadata of the multimedia file.
#
# @return [Boolean]
Expand Down
2 changes: 1 addition & 1 deletion lib/ffmpeg/raw_command_args.rb
Original file line number Diff line number Diff line change
Expand Up @@ -349,7 +349,7 @@ def bitstream_filters(*filters, stream_id: nil, stream_index: nil)
# @param filters [Array<FFMPEG::Filter, String>] The filters to add.
# @return [self]
def filter_complex(*filters)
arg('filter_complex', filters.compact.map(&:to_s).join(';'))
arg('filter_complex', filters.compact.join(';'))

self
end
Expand Down
Loading
Loading