Skip to content

Commit 6b0310d

Browse files
committed
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
1 parent 004710a commit 6b0310d

8 files changed

Lines changed: 189 additions & 51 deletions

File tree

lib/ffmpeg/command_args.rb

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,10 @@ class << self
2222
# The block is evaluated in the context of the new instance.
2323
#
2424
# @param media [FFMPEG::Media] The media to transcode.
25-
# @return [FFMPEG::CommandArgs] The new FFMPEG::CommandArgs object.
26-
def compose(media, &block)
27-
new(media).tap do |args|
25+
# @param context [Hash, nil] Additional context for composing the arguments.
26+
# # @return [FFMPEG::CommandArgs] The new FFMPEG::CommandArgs object.
27+
def compose(media, context: nil, &block)
28+
new(media, context:).tap do |args|
2829
args.instance_exec(&block) if block_given?
2930
end
3031
end
@@ -33,9 +34,10 @@ def compose(media, &block)
3334
attr_reader :media
3435

3536
# @param media [FFMPEG::Media] The media to transcode.
36-
def initialize(media)
37+
# @param context [Hash, nil] Additional context for composing the arguments.
38+
def initialize(media, context: nil)
3739
@media = media
38-
super()
40+
super(context:)
3941
end
4042

4143
# Sets the frame rate to the minimum of the current frame rate and the target value.

lib/ffmpeg/errors.rb

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,15 @@
22

33
module FFMPEG
44
class Error < StandardError; end
5+
6+
# Raised by FFMPEG::Status#assert! if the underlying
7+
# process status has a non-zero exit code.
8+
class ExitError < Error
9+
attr_reader :output
10+
11+
def initialize(message, output)
12+
@output = output
13+
super(message)
14+
end
15+
end
516
end

lib/ffmpeg/preset.rb

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,15 +35,17 @@ def filename(**kwargs)
3535
# Returns the command arguments for the given media.
3636
#
3737
# @param media [Media] The media to encode.
38+
# @param context [Hash, nil] Additional context for composing the arguments.
3839
# @return [Array<String>] The command arguments.
39-
def args(media)
40-
@command_args_klass.compose(media, &@compose_args).to_a
40+
def args(media, context: nil)
41+
@command_args_klass.compose(media, context:, &@compose_args).to_a
4142
end
4243

4344
# Transcode the media to the output path.
4445
#
4546
# @param media [Media] The media to transcode.
4647
# @param output_path [String, Pathname] The path to the output file.
48+
# @param timeout [Integer, nil] The timeout for the transcoding process.
4749
# @yield The block to execute when progress is made.
4850
# @return [FFMPEG::Transcoder::Status] The status of the transcoding process.
4951
def transcode(media, output_path, timeout: nil, &)
@@ -55,6 +57,7 @@ def transcode(media, output_path, timeout: nil, &)
5557
#
5658
# @param media [Media] The media to transcode.
5759
# @param output_path [String, Pathname] The path to the output file.
60+
# @param timeout [Integer, nil] The timeout for the transcoding process.
5861
# @yield The block to execute when progress is made.
5962
# @return [FFMPEG::Transcoder::Status] The status of the transcoding process.
6063
def transcode!(media, output_path, timeout: nil, &)

lib/ffmpeg/raw_command_args.rb

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ class << self
2020
# the method is treated as a new argument to add to the command arguments.
2121
#
2222
# @param block_args [Array] The arguments to pass to the block.
23+
# @param context [Hash, nil] Additional context for composing the command arguments.
2324
# @yield The block to execute to compose the command arguments.
2425
# @return [FFMPEG::RawCommandArgs] The new set of raw command arguments.
2526
#
@@ -29,8 +30,8 @@ class << self
2930
# audio_codec_name 'aac'
3031
# end
3132
# args.to_s # => "-c:v libx264 -c:a aac"
32-
def compose(*block_args, &)
33-
new.tap do |args|
33+
def compose(*block_args, context: nil, &)
34+
new(context:).tap do |args|
3435
args.instance_exec(*block_args, &) if block_given?
3536
end
3637
end
@@ -87,8 +88,9 @@ def escape_graph_component(value)
8788
end
8889
end
8990

90-
def initialize
91+
def initialize(context: nil)
9192
@args = []
93+
@context = context
9294
end
9395

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

139+
# Executes the block if the specified matcher matches the context.
140+
# The block is executed in the context of the command arguments.
141+
#
142+
# @param matcher [String, Symbol, Hash] The matcher to check against the context.
143+
# @param & [Proc] The block to execute if the matcher matches.
144+
# @return [self]
145+
def context(matcher, &)
146+
return if @context.nil?
147+
148+
if matcher.is_a?(Hash)
149+
return unless @context >= matcher
150+
else
151+
return unless @context.key?(matcher)
152+
end
153+
154+
instance_exec(&) if block_given?
155+
156+
self
157+
end
158+
137159
# ==================== #
138160
# === COMMON UTILS === #
139161
# ==================== #

lib/ffmpeg/status.rb

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,6 @@ module FFMPEG
66
# It also provides a method to raise an error if the subprocess
77
# did not finish successfully.
88
class Status
9-
# Raised by #assert! if the status has a non-zero exit code.
10-
class ExitError < Error
11-
attr_reader :output
12-
13-
def initialize(message, output)
14-
@output = output
15-
super(message)
16-
end
17-
end
18-
199
attr_reader :duration, :output, :upstream
2010

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

33-
raise ExitError.new("#{message} (code: #{exitstatus})", @output.string)
23+
raise ExitError.new("#{message} (code: #{exitstatus})", @output)
3424
end
3525

3626
# Binds the status to an upstream Process::Status object.

lib/ffmpeg/transcoder.rb

Lines changed: 48 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -47,12 +47,21 @@ def media(*ffprobe_args, load: true, autoload: true)
4747

4848
attr_reader :name, :metadata, :presets, :reporters, :timeout
4949

50-
def initialize(name: nil, metadata: nil, presets: [], reporters: nil, timeout: nil, &compose_inargs)
50+
def initialize(
51+
name: nil,
52+
metadata: nil,
53+
presets: [],
54+
reporters: nil,
55+
retries: nil,
56+
timeout: nil,
57+
&compose_inargs
58+
)
5159
@name = name
5260
@metadata = metadata
5361
@presets = presets
5462
@reporters = reporters
5563
@timeout = timeout
64+
@retries = retries&.abs || 0
5665
@compose_inargs = compose_inargs
5766
end
5867

@@ -63,34 +72,46 @@ def initialize(name: nil, metadata: nil, presets: [], reporters: nil, timeout: n
6372
# @yield The block to execute to report the transcoding process.
6473
# @return [FFMPEG::Transcoder::Status] The status of the transcoding process.
6574
def process(media, output_path, &)
66-
media = Media.new(media, load: false) unless media.is_a?(Media)
67-
68-
output_paths = []
69-
output_path = Pathname.new(output_path)
70-
output_dir = output_path.dirname
71-
output_filename_kwargs = {
72-
basename: output_path.basename(output_path.extname),
73-
extname: output_path.extname
74-
}
75-
76-
args = []
77-
@presets.each do |preset|
78-
filename = preset.filename(**output_filename_kwargs)
79-
args += preset.args(media)
80-
args << (filename.nil? ? output_path.to_s : output_dir.join(filename).to_s)
81-
output_paths << args.last
82-
end
75+
status = nil
76+
77+
attempts = 0
78+
while attempts <= @retries
79+
media = Media.new(media, load: false) unless media.is_a?(Media)
80+
context = { attempts: }
81+
context[:retry] = true if attempts.positive?
82+
83+
output_paths = []
84+
output_path = Pathname.new(output_path)
85+
output_dir = output_path.dirname
86+
output_filename_kwargs = {
87+
basename: output_path.basename(output_path.extname),
88+
extname: output_path.extname
89+
}
90+
91+
args = []
92+
@presets.each do |preset|
93+
filename = preset.filename(**output_filename_kwargs)
94+
args += preset.args(media, context:)
95+
args << (filename.nil? ? output_path.to_s : output_dir.join(filename).to_s)
96+
output_paths << args.last
97+
end
98+
99+
inargs = CommandArgs.compose(media, context:, &@compose_inargs).to_a
100+
status = media.ffmpeg_execute(
101+
*args,
102+
inargs:,
103+
reporters:,
104+
timeout:,
105+
status: Status.new(output_paths),
106+
&
107+
)
83108

84-
inargs = CommandArgs.compose(media, &@compose_inargs).to_a
109+
return status if status.success?
110+
111+
attempts += 1
112+
end
85113

86-
media.ffmpeg_execute(
87-
*args,
88-
inargs:,
89-
reporters:,
90-
timeout:,
91-
status: Status.new(output_paths),
92-
&
93-
)
114+
status
94115
end
95116

96117
# Transcodes the media file using the preset configurations

spec/ffmpeg/raw_command_args_spec.rb

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44

55
module FFMPEG
66
describe RawCommandArgs do
7-
subject { RawCommandArgs.new }
7+
let(:context) { nil }
8+
subject { RawCommandArgs.new(context:) }
89

910
describe '#use' do
1011
let(:composable) do
@@ -55,6 +56,61 @@ module FFMPEG
5556
end
5657
end
5758

59+
describe '#context' do
60+
context 'when the instance context is nil' do
61+
it 'does not execute the block' do
62+
subject.context(nil) do
63+
subject.arg('foo', 'bar')
64+
end
65+
expect(subject.to_a).to eq([])
66+
end
67+
end
68+
69+
context 'when the instance context is not nil' do
70+
let(:context) { { foo: true, bar: false } }
71+
72+
context 'and the matcher is a symbol' do
73+
context 'that is a key in the context' do
74+
it 'executes the block' do
75+
subject.context(:foo) do
76+
arg('foo', 'bar')
77+
end
78+
expect(subject.to_a).to eq(%w[-foo bar])
79+
end
80+
end
81+
82+
context 'that is not a key in the context' do
83+
it 'does not execute the block' do
84+
subject.context(:baz) do
85+
arg('foo', 'bar')
86+
end
87+
expect(subject.to_a).to eq([])
88+
end
89+
end
90+
end
91+
92+
context 'and the matcher is a hash' do
93+
context 'that is a subset of the context' do
94+
it 'executes the block' do
95+
subject.context(foo: true) do
96+
arg('foo', 'bar')
97+
end
98+
expect(subject.to_a).to eq(%w[-foo bar])
99+
end
100+
end
101+
102+
context 'that is not a subset of the context' do
103+
it 'does not execute the block' do
104+
subject.context(foo: false) do
105+
arg('foo', 'bar')
106+
end
107+
expect(subject.to_a).to eq([])
108+
end
109+
end
110+
end
111+
end
112+
end
113+
58114
describe '#arg' do
59115
it 'adds the argument' do
60116
subject.arg('foo', 'bar')

spec/ffmpeg/transcoder_spec.rb

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
module FFMPEG
66
describe Transcoder do
77
describe '#process' do
8+
let(:media) { Media.new(fixture_media_file('landscape@4k60.mp4')) }
9+
810
let(:preset1) do
911
Preset.new(filename: '%<basename>s.mp4') do
1012
video_codec_name 'libx264'
@@ -31,14 +33,19 @@ module FFMPEG
3133
end
3234
end
3335

36+
let(:retries) { 0 }
37+
3438
subject do
35-
described_class.new(presets: [preset1, preset2]) do
39+
described_class.new(presets: [preset1, preset2], retries:) do
3640
raw_arg '-noautorotate'
41+
42+
context :retry do
43+
raw_arg '-xerror'
44+
end
3745
end
3846
end
3947

4048
it 'transcodes a multimedia file using the specified presets' do
41-
media = Media.new(fixture_media_file('landscape@4k60.mp4'))
4249
output_path = File.join(tmp_dir, SecureRandom.hex(4))
4350

4451
expect(media).to receive(:ffmpeg_execute).and_wrap_original do |method, *args, **kwargs, &block|
@@ -84,6 +91,32 @@ module FFMPEG
8491
expect(reports.length).to be >= 1
8592
expect(reports).to all(be_a(Reporters::Output))
8693
end
94+
95+
context 'when the transcoding process finishes with non-zero exit status' do
96+
let(:retries) { 1 }
97+
98+
it 'retries up to the set number of times' do
99+
output_path = File.join(tmp_dir, SecureRandom.hex(4))
100+
101+
attempts = 0
102+
status = double(Transcoder::Status, success?: false)
103+
expect(media).to receive(:ffmpeg_execute).twice do |*_args, inargs:, **_kwargs|
104+
attempts += 1
105+
106+
if attempts == 2
107+
allow(status).to receive(:success?).and_return(true)
108+
expect(inargs).to include('-xerror')
109+
else
110+
expect(inargs).not_to include('-xerror')
111+
end
112+
113+
status
114+
end
115+
116+
expect(subject.process(media, output_path)).to be(status)
117+
expect(attempts).to eq(2)
118+
end
119+
end
87120
end
88121

89122
describe '#process!' do

0 commit comments

Comments
 (0)