From 9e9c8e0c52cfb101ce0252eb0c3ab20d9f15e7c0 Mon Sep 17 00:00:00 2001 From: bajankristof Date: Mon, 23 Jun 2025 16:55:56 +0200 Subject: [PATCH 1/3] feat!: add support for transcoder retries With this change it is now possible to define command arguments in presets, transcoders and composables that are only passed on to FFmpeg if we're currently retrying the command due to it having exited with a non-zero exit status. BREAKING CHANGES: - FFMPEG::Status::ExitError has been renamed to FFMPEG::ExitError - FFMPEG::ExitError now holds the StringIO output representation --- lib/ffmpeg/command_args.rb | 12 +++-- lib/ffmpeg/errors.rb | 11 ++++ lib/ffmpeg/preset.rb | 7 ++- lib/ffmpeg/raw_command_args.rb | 28 +++++++++-- lib/ffmpeg/status.rb | 12 +---- lib/ffmpeg/transcoder.rb | 75 ++++++++++++++++++---------- spec/ffmpeg/raw_command_args_spec.rb | 58 ++++++++++++++++++++- spec/ffmpeg/transcoder_spec.rb | 37 +++++++++++++- 8 files changed, 189 insertions(+), 51 deletions(-) diff --git a/lib/ffmpeg/command_args.rb b/lib/ffmpeg/command_args.rb index 2a9dd24..1704444 100644 --- a/lib/ffmpeg/command_args.rb +++ b/lib/ffmpeg/command_args.rb @@ -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 @@ -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. diff --git a/lib/ffmpeg/errors.rb b/lib/ffmpeg/errors.rb index 7b0e66a..b2a14ad 100644 --- a/lib/ffmpeg/errors.rb +++ b/lib/ffmpeg/errors.rb @@ -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 diff --git a/lib/ffmpeg/preset.rb b/lib/ffmpeg/preset.rb index 0af0943..ebce0ef 100644 --- a/lib/ffmpeg/preset.rb +++ b/lib/ffmpeg/preset.rb @@ -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] 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, &) @@ -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, &) diff --git a/lib/ffmpeg/raw_command_args.rb b/lib/ffmpeg/raw_command_args.rb index 1b49792..1b277fc 100644 --- a/lib/ffmpeg/raw_command_args.rb +++ b/lib/ffmpeg/raw_command_args.rb @@ -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. # @@ -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 @@ -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. @@ -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 === # # ==================== # diff --git a/lib/ffmpeg/status.rb b/lib/ffmpeg/status.rb index 29eba02..cda7896 100644 --- a/lib/ffmpeg/status.rb +++ b/lib/ffmpeg/status.rb @@ -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 @@ -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. diff --git a/lib/ffmpeg/transcoder.rb b/lib/ffmpeg/transcoder.rb index 464e6bb..0947da5 100644 --- a/lib/ffmpeg/transcoder.rb +++ b/lib/ffmpeg/transcoder.rb @@ -47,12 +47,21 @@ def media(*ffprobe_args, load: true, autoload: true) attr_reader :name, :metadata, :presets, :reporters, :timeout - def initialize(name: nil, metadata: nil, presets: [], reporters: nil, timeout: nil, &compose_inargs) + def initialize( + name: nil, + metadata: nil, + presets: [], + reporters: nil, + retries: nil, + timeout: nil, + &compose_inargs + ) @name = name @metadata = metadata @presets = presets @reporters = reporters @timeout = timeout + @retries = retries&.abs || 0 @compose_inargs = compose_inargs end @@ -63,34 +72,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? + + 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), + & + ) - 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 diff --git a/spec/ffmpeg/raw_command_args_spec.rb b/spec/ffmpeg/raw_command_args_spec.rb index 50151e9..773f996 100644 --- a/spec/ffmpeg/raw_command_args_spec.rb +++ b/spec/ffmpeg/raw_command_args_spec.rb @@ -4,7 +4,8 @@ module FFMPEG describe RawCommandArgs do - subject { RawCommandArgs.new } + let(:context) { nil } + subject { RawCommandArgs.new(context:) } describe '#use' do let(:composable) do @@ -55,6 +56,61 @@ module FFMPEG end end + describe '#context' do + context 'when the instance context is nil' do + it 'does not execute the block' do + subject.context(nil) do + subject.arg('foo', 'bar') + end + expect(subject.to_a).to eq([]) + end + end + + context 'when the instance context is not nil' do + let(:context) { { foo: true, bar: false } } + + context 'and the matcher is a symbol' do + context 'that is a key in the context' do + it 'executes the block' do + subject.context(:foo) do + arg('foo', 'bar') + end + expect(subject.to_a).to eq(%w[-foo bar]) + end + end + + context 'that is not a key in the context' do + it 'does not execute the block' do + subject.context(:baz) do + arg('foo', 'bar') + end + expect(subject.to_a).to eq([]) + end + end + end + + context 'and the matcher is a hash' do + context 'that is a subset of the context' do + it 'executes the block' do + subject.context(foo: true) do + arg('foo', 'bar') + end + expect(subject.to_a).to eq(%w[-foo bar]) + end + end + + context 'that is not a subset of the context' do + it 'does not execute the block' do + subject.context(foo: false) do + arg('foo', 'bar') + end + expect(subject.to_a).to eq([]) + end + end + end + end + end + describe '#arg' do it 'adds the argument' do subject.arg('foo', 'bar') diff --git a/spec/ffmpeg/transcoder_spec.rb b/spec/ffmpeg/transcoder_spec.rb index 26f5192..77ec74f 100644 --- a/spec/ffmpeg/transcoder_spec.rb +++ b/spec/ffmpeg/transcoder_spec.rb @@ -5,6 +5,8 @@ module FFMPEG describe Transcoder do describe '#process' do + let(:media) { Media.new(fixture_media_file('landscape@4k60.mp4')) } + let(:preset1) do Preset.new(filename: '%s.mp4') do video_codec_name 'libx264' @@ -31,14 +33,19 @@ module FFMPEG end end + let(:retries) { 0 } + subject do - described_class.new(presets: [preset1, preset2]) do + described_class.new(presets: [preset1, preset2], retries:) do raw_arg '-noautorotate' + + context :retry do + raw_arg '-xerror' + end end end it 'transcodes a multimedia file using the specified presets' do - media = Media.new(fixture_media_file('landscape@4k60.mp4')) output_path = File.join(tmp_dir, SecureRandom.hex(4)) expect(media).to receive(:ffmpeg_execute).and_wrap_original do |method, *args, **kwargs, &block| @@ -84,6 +91,32 @@ module FFMPEG expect(reports.length).to be >= 1 expect(reports).to all(be_a(Reporters::Output)) end + + context 'when the transcoding process finishes with non-zero exit status' do + let(:retries) { 1 } + + it 'retries up to the set number of times' do + output_path = File.join(tmp_dir, SecureRandom.hex(4)) + + attempts = 0 + status = double(Transcoder::Status, success?: false) + expect(media).to receive(:ffmpeg_execute).twice do |*_args, inargs:, **_kwargs| + attempts += 1 + + if attempts == 2 + allow(status).to receive(:success?).and_return(true) + expect(inargs).to include('-xerror') + else + expect(inargs).not_to include('-xerror') + end + + status + end + + expect(subject.process(media, output_path)).to be(status) + expect(attempts).to eq(2) + end + end end describe '#process!' do From c2e4c96e520c773e0ccdd4f8ec4f0f479770f028 Mon Sep 17 00:00:00 2001 From: bajankristof Date: Tue, 24 Jun 2025 11:27:54 +0200 Subject: [PATCH 2/3] feat!: add support for transcoding status checks BREAKING CHANGE: - The `FFMPEG::Transcoding::Status#success?` method now checks whether all output files have been created or not. This behaviour can be disabled by setting the `checks` keyword argument on the transcoder to an empty array (or whatever checks you wish to perform). - Transcoding retries now consider the transcoding status checks when deciding whether to retry the process or not. --- lib/ffmpeg/transcoder.rb | 37 +++++++++++++++++++++++++++++++++---- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/lib/ffmpeg/transcoder.rb b/lib/ffmpeg/transcoder.rb index 0947da5..18eb880 100644 --- a/lib/ffmpeg/transcoder.rb +++ b/lib/ffmpeg/transcoder.rb @@ -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] The arguments to pass to ffprobe. @@ -43,15 +63,23 @@ 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, + checks: %i[exist?], retries: nil, timeout: nil, &compose_inargs @@ -60,8 +88,9 @@ def initialize( @metadata = metadata @presets = presets @reporters = reporters - @timeout = timeout + @checks = checks @retries = retries&.abs || 0 + @timeout = timeout @compose_inargs = compose_inargs end @@ -102,7 +131,7 @@ def process(media, output_path, &) inargs:, reporters:, timeout:, - status: Status.new(output_paths), + status: Status.new(output_paths, checks:), & ) From 6c7e7288a1dd6fe86b5abfdcf8a90f6de4cf3fe0 Mon Sep 17 00:00:00 2001 From: bajankristof Date: Mon, 23 Jun 2025 17:03:13 +0200 Subject: [PATCH 3/3] chore: update version to 8.0.0 and document changes --- CHANGELOG | 14 ++++++++++++++ README.md | 9 +++++++-- lib/ffmpeg/version.rb | 2 +- 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index e1baf3b..58dec35 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -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: diff --git a/README.md b/README.md index 67f9b70..53c4730 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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) diff --git a/lib/ffmpeg/version.rb b/lib/ffmpeg/version.rb index 2308db7..0934169 100644 --- a/lib/ffmpeg/version.rb +++ b/lib/ffmpeg/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module FFMPEG - VERSION = '7.1.4' + VERSION = '8.0.0' end