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
14 changes: 14 additions & 0 deletions CHANGELOG
Original file line number Diff line number Diff line change
@@ -1,3 +1,17 @@
== 8.0.0 2025-06-27

Improvements:
* Added support for retries in the `FFMPEG::Transcoder` class. This allows for more robust command
argument composing and thus more stable outputs.

Breaking Changes:
* The `FFMPEG::Transcoder#process!` method will now fail if the expected output files do not exist after
successful processing. This behaviour can be controled by passing `checks: []` to the transcoder
initializer.
* The `FFMPEG::Status::ExitError` class has been renamed to `FFMPEG::ExitError`.
* The `FFMPEG::ExitError` class now holds a reference to the `StringIO` output of the FFmpeg command
(before it contained the `String` representation).

== 7.1.4 2025-06-23

Fixes:
Expand Down
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,12 @@ transcoder = FFMPEG::Transcoder.new(
# to optimize the transcoding process.
presets: [preset],
# The reporters are used to generate reports during the transcoding process.
reporters: [FFMPEG::Reporters::Progress, FFMPEG::Reporters::Silence]
reporters: [FFMPEG::Reporters::Progress, FFMPEG::Reporters::Silence],
# The checks are used to validate the output files after the transcoding process.
# They can be symbols, in which case they refer to methods on the `FFMPEG::Transcoder::Status` class,
# or objects that respond to `call` (such as lambdas or procs), in which case they will be called with
# the `FFMPEG::Transcoder::Status` object as an argument.
checks: %i[exist?]
) do
# This block sets up the input arguments of the ffmpeg command.
# It uses the same DSL to define the arguments as the preset does for the output arguments.
Expand All @@ -131,7 +136,7 @@ status = transcoder.process(media, '/path/to/output') do |report|
end
end

status.success? # true (would be false if ffmpeg fails to transcode the media)
status.success? # true (returns true if the exit status is zero and all checks passed)
status.exitstatus # 0 (the exit status of the ffmpeg command)
status.paths # ['/path/to/output.mp4'] (the paths of the output files)
status.media # [FFMPEG::Media] (the media objects of the output files)
Expand Down
12 changes: 7 additions & 5 deletions lib/ffmpeg/command_args.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,10 @@ class << self
# The block is evaluated in the context of the new instance.
#
# @param media [FFMPEG::Media] The media to transcode.
# @return [FFMPEG::CommandArgs] The new FFMPEG::CommandArgs object.
def compose(media, &block)
new(media).tap do |args|
# @param context [Hash, nil] Additional context for composing the arguments.
# # @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?
end
end
Expand All @@ -33,9 +34,10 @@ def compose(media, &block)
attr_reader :media

# @param media [FFMPEG::Media] The media to transcode.
def initialize(media)
# @param context [Hash, nil] Additional context for composing the arguments.
def initialize(media, context: nil)
@media = media
super()
super(context:)
end

# Sets the frame rate to the minimum of the current frame rate and the target value.
Expand Down
11 changes: 11 additions & 0 deletions lib/ffmpeg/errors.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,15 @@

module FFMPEG
class Error < StandardError; end

# Raised by FFMPEG::Status#assert! if the underlying
# process status has a non-zero exit code.
class ExitError < Error
attr_reader :output

def initialize(message, output)
@output = output
super(message)
end
end
end
7 changes: 5 additions & 2 deletions lib/ffmpeg/preset.rb
Original file line number Diff line number Diff line change
Expand Up @@ -35,15 +35,17 @@ def filename(**kwargs)
# Returns the command arguments for the given media.
#
# @param media [Media] The media to encode.
# @param context [Hash, nil] Additional context for composing the arguments.
# @return [Array<String>] The command arguments.
def args(media)
@command_args_klass.compose(media, &@compose_args).to_a
def args(media, context: nil)
@command_args_klass.compose(media, context:, &@compose_args).to_a
end

# Transcode the media to the output path.
#
# @param media [Media] The media to transcode.
# @param output_path [String, Pathname] The path to the output file.
# @param timeout [Integer, nil] The timeout for the transcoding process.
# @yield The block to execute when progress is made.
# @return [FFMPEG::Transcoder::Status] The status of the transcoding process.
def transcode(media, output_path, timeout: nil, &)
Expand All @@ -55,6 +57,7 @@ def transcode(media, output_path, timeout: nil, &)
#
# @param media [Media] The media to transcode.
# @param output_path [String, Pathname] The path to the output file.
# @param timeout [Integer, nil] The timeout for the transcoding process.
# @yield The block to execute when progress is made.
# @return [FFMPEG::Transcoder::Status] The status of the transcoding process.
def transcode!(media, output_path, timeout: nil, &)
Expand Down
28 changes: 25 additions & 3 deletions lib/ffmpeg/raw_command_args.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ class << self
# the method is treated as a new argument to add to the command arguments.
#
# @param block_args [Array] The arguments to pass to the block.
# @param context [Hash, nil] Additional context for composing the command arguments.
# @yield The block to execute to compose the command arguments.
# @return [FFMPEG::RawCommandArgs] The new set of raw command arguments.
#
Expand All @@ -29,8 +30,8 @@ class << self
# audio_codec_name 'aac'
# end
# args.to_s # => "-c:v libx264 -c:a aac"
def compose(*block_args, &)
new.tap do |args|
def compose(*block_args, context: nil, &)
new(context:).tap do |args|
args.instance_exec(*block_args, &) if block_given?
end
end
Expand Down Expand Up @@ -87,8 +88,9 @@ def escape_graph_component(value)
end
end

def initialize
def initialize(context: nil)
@args = []
@context = context
end

# Returns the array representation of the command arguments.
Expand Down Expand Up @@ -134,6 +136,26 @@ def use(composable, only: nil, except: nil)
self
end

# Executes the block if the specified matcher matches the context.
# The block is executed in the context of the command arguments.
#
# @param matcher [String, Symbol, Hash] The matcher to check against the context.
# @param & [Proc] The block to execute if the matcher matches.
# @return [self]
def context(matcher, &)
return if @context.nil?

if matcher.is_a?(Hash)
return unless @context >= matcher
else
return unless @context.key?(matcher)
end

instance_exec(&) if block_given?

self
end

# ==================== #
# === COMMON UTILS === #
# ==================== #
Expand Down
12 changes: 1 addition & 11 deletions lib/ffmpeg/status.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,6 @@ module FFMPEG
# It also provides a method to raise an error if the subprocess
# did not finish successfully.
class Status
# Raised by #assert! if the status has a non-zero exit code.
class ExitError < Error
attr_reader :output

def initialize(message, output)
@output = output
super(message)
end
end

attr_reader :duration, :output, :upstream

def initialize
Expand All @@ -30,7 +20,7 @@ def assert!
message = @output.string.match(/\b(?:error|invalid|failed|could not)\b.+$/i)
message ||= 'FFmpeg exited with non-zero exit status'

raise ExitError.new("#{message} (code: #{exitstatus})", @output.string)
raise ExitError.new("#{message} (code: #{exitstatus})", @output)
end

# Binds the status to an upstream Process::Status object.
Expand Down
108 changes: 79 additions & 29 deletions lib/ffmpeg/transcoder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,31 @@ class Transcoder
class Status < FFMPEG::Status
attr_reader :paths

def initialize(paths)
def initialize(paths, checks: %i[exist?])
@paths = paths
@checks = checks
super()
end

# Returns true if the transcoding process was successful.
# It returns true if the process exited with a zero exit status
# and all checks passed.
#
# @return [Boolean] True if the transcoding process was successful, false otherwise.
def success?
return false unless super

@checks.all? do |check|
if check.is_a?(Symbol) && respond_to?(check)
send(check)
elsif check.respond_to?(:call)
check.call(self)
else
raise ArgumentError, "Unknown check format #{check.class}, expected #{Symbol} or #{Proc}"
end
end
end

# Returns the media files associated with the transcoding process.
#
# @param ffprobe_args [Array<String>] The arguments to pass to ffprobe.
Expand All @@ -43,15 +63,33 @@ def media(*ffprobe_args, load: true, autoload: true)
Media.new(path, *ffprobe_args, load: load, autoload: autoload)
end
end

# Returns true if all output paths exist.
#
# @return [Boolean] True if all output paths exist, false otherwise.
def exist?
@paths.all? { |path| File.exist?(path) }
end
end

attr_reader :name, :metadata, :presets, :reporters, :timeout
attr_reader :name, :metadata, :presets, :reporters, :checks, :retries, :timeout

def initialize(name: nil, metadata: nil, presets: [], reporters: nil, timeout: nil, &compose_inargs)
def initialize(
name: nil,
metadata: nil,
presets: [],
reporters: nil,
checks: %i[exist?],
retries: nil,
timeout: nil,
&compose_inargs
)
@name = name
@metadata = metadata
@presets = presets
@reporters = reporters
@checks = checks
@retries = retries&.abs || 0
@timeout = timeout
@compose_inargs = compose_inargs
end
Expand All @@ -63,34 +101,46 @@ def initialize(name: nil, metadata: nil, presets: [], reporters: nil, timeout: n
# @yield The block to execute to report the transcoding process.
# @return [FFMPEG::Transcoder::Status] The status of the transcoding process.
def process(media, output_path, &)
media = Media.new(media, load: false) unless media.is_a?(Media)

output_paths = []
output_path = Pathname.new(output_path)
output_dir = output_path.dirname
output_filename_kwargs = {
basename: output_path.basename(output_path.extname),
extname: output_path.extname
}

args = []
@presets.each do |preset|
filename = preset.filename(**output_filename_kwargs)
args += preset.args(media)
args << (filename.nil? ? output_path.to_s : output_dir.join(filename).to_s)
output_paths << args.last
end
status = nil

attempts = 0
while attempts <= @retries
media = Media.new(media, load: false) unless media.is_a?(Media)
context = { attempts: }
context[:retry] = true if attempts.positive?
Comment thread
bajankristof marked this conversation as resolved.

output_paths = []
output_path = Pathname.new(output_path)
output_dir = output_path.dirname
output_filename_kwargs = {
basename: output_path.basename(output_path.extname),
extname: output_path.extname
}

args = []
@presets.each do |preset|
filename = preset.filename(**output_filename_kwargs)
args += preset.args(media, context:)
args << (filename.nil? ? output_path.to_s : output_dir.join(filename).to_s)
output_paths << args.last
end

inargs = CommandArgs.compose(media, context:, &@compose_inargs).to_a
status = media.ffmpeg_execute(
*args,
inargs:,
reporters:,
timeout:,
status: Status.new(output_paths, checks:),
&
)

inargs = CommandArgs.compose(media, &@compose_inargs).to_a
return status if status.success?

attempts += 1
end

media.ffmpeg_execute(
*args,
inargs:,
reporters:,
timeout:,
status: Status.new(output_paths),
&
)
status
end

# Transcodes the media file using the preset configurations
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 = '7.1.4'
VERSION = '8.0.0'
end
Loading