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/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..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,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 @@ -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? + + 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 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 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