Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add new class for improved subprocess status handling #11

Merged
merged 6 commits into from
Feb 13, 2025
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
8 changes: 8 additions & 0 deletions CHANGELOG
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
== 7.0.0-beta.3 2025-02-10

Improvements:
* Added new `FFMPEG::Status` class to handle the status of ffmpeg and ffprobe processes.

Breaking Changes:
* Removed the borderline useless thumbnail preset.

== 7.0.0-beta.2 2025-01-29

Fixes:
Expand Down
1 change: 1 addition & 0 deletions ffmpeg.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ Gem::Specification.new do |s|

s.required_ruby_version = '>= 3.2'

s.add_dependency('logger', '~> 1.6')
s.add_dependency('multi_json', '~> 1.8')

s.add_development_dependency('debug')
Expand Down
48 changes: 35 additions & 13 deletions lib/ffmpeg.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,11 @@
require_relative 'ffmpeg/presets/dash/aac'
require_relative 'ffmpeg/presets/dash/h264'
require_relative 'ffmpeg/presets/h264'
require_relative 'ffmpeg/presets/thumbnail'
require_relative 'ffmpeg/raw_command_args'
require_relative 'ffmpeg/reporters/output'
require_relative 'ffmpeg/reporters/progress'
require_relative 'ffmpeg/reporters/silence'
require_relative 'ffmpeg/status'
require_relative 'ffmpeg/transcoder'
require_relative 'ffmpeg/version'

Expand All @@ -50,7 +50,7 @@ module FFMPEG
SIGKILL = RUBY_PLATFORM =~ /(win|w)(32|64)$/ ? 1 : 'SIGKILL'

class << self
attr_writer :logger
attr_writer :logger, :reporters

# Get the FFMPEG logger.
#
Expand Down Expand Up @@ -85,6 +85,11 @@ def io_encoding=(encoding)
FFMPEG::IO.encoding = encoding
end

# Get the reporters that are used by default to parse the output of the ffmpeg command.
def reporters
@reporters ||= [FFMPEG::Reporters::Progress]
end

# Set the path to the ffmpeg binary.
#
# @param path [String]
Expand Down Expand Up @@ -137,21 +142,38 @@ def ffmpeg_popen3(*args, &)
# @param args [Array<String>] The arguments to pass to ffmpeg.
# @param reporters [Array<FFMPEG::Reporters::Output>] The reporters to use to parse the output.
# @yield [report] Reports from the ffmpeg command (see FFMPEG::Reporters).
# @return [Process::Status]
def ffmpeg_execute(*args, reporters: [Reporters::Progress])
ffmpeg_popen3(*args) do |_stdin, _stdout, stderr, wait_thr|
stderr.each(chomp: true) do |line|
next unless block_given?
# @return [FFMPEG::Status]
def ffmpeg_execute(*args, status: nil, reporters: nil)
status ||= FFMPEG::Status.new
reporters ||= self.reporters

status.bind!(
ffmpeg_popen3(*args) do |_stdin, stdout, stderr, wait_thr|
stderr.each(chomp: true) do |line|
reporter = reporters.find { |r| r.match?(line) }
status.puts(line) if reporter.nil? || reporter.log?

reporter = reporters.find { |r| r.match?(line) }
reporter ||= Reporters::Output
report = reporter.new(line)
next unless reporter && block_given?

yield report
yield reporter.new(line)
end

::IO.copy_stream(stdout, status.output) if status.empty?

wait_thr.value
end
)
end

wait_thr.value
end
# Execute a ffmpeg command and raise an error
# if the subprocess did not finish successfully.
#
# @param args [Array<String>] The arguments to pass to ffmpeg.
# @param reporters [Array<FFMPEG::Reporters::Output>] The reporters to use to parse the output.
# @yield [report] Reports from the ffmpeg command (see FFMPEG::Reporters).
# @return [FFMPEG::Status]
def ffmpeg_execute!(*args, status: nil, reporters: nil)
ffmpeg_execute(*args, status:, reporters:).assert!
end

# Get the path to the ffprobe binary.
Expand Down
36 changes: 27 additions & 9 deletions lib/ffmpeg/media.rb
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,15 @@ module FFMPEG
# media.load!
# media.video? # => true
class Media
class LoadError < FFMPEG::Error; end
# Raised if media metadata cannot be loaded.
class LoadError < Error
attr_reader :output

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

private_class_method def self.autoload(*method_names)
method_names.flatten!
Expand Down Expand Up @@ -89,11 +97,14 @@ def load!
begin
@metadata = MultiJson.load(stdout, symbolize_keys: true)
rescue MultiJson::ParseError => e
raise LoadError, e.message.capitalize
raise LoadError.new(e.message.capitalize, stdout)
end

if @metadata.key?(:error)
raise LoadError, "#{@metadata[:error][:string].capitalize} (code #{@metadata[:error][:code]})"
raise LoadError.new(
"#{@metadata[:error][:string].capitalize} (code #{@metadata[:error][:code]})",
stdout
)
end

@size = @metadata[:format][:size].to_i
Expand Down Expand Up @@ -484,12 +495,19 @@ def local?
# @param inargs [Array<String>] The arguments to pass before the input.
# @yield [report] Reports from the ffmpeg command (see FFMPEG::Reporters).
# @return [Process::Status]
def ffmpeg_execute(*args, inargs: [], reporters: nil, &block)
if reporters.is_a?(Array)
FFMPEG.ffmpeg_execute(*inargs, '-i', path, *args, reporters: reporters, &block)
else
FFMPEG.ffmpeg_execute(*inargs, '-i', path, *args, &block)
end
def ffmpeg_execute(*args, inargs: [], status: nil, reporters: nil, &block)
FFMPEG.ffmpeg_execute(*inargs, '-i', path, *args, status:, reporters:, &block)
end

# Execute a ffmpeg command with the media as input
# and raise an error if the subprocess did not finish successfully.
#
# @param args [Array<String>] The arguments to pass to ffmpeg.
# @param inargs [Array<String>] The arguments to pass before the input.
# @yield [report] Reports from the ffmpeg command (see FFMPEG::Reporters).
# @return [Process::Status]
def ffmpeg_execute!(*args, inargs: [], status: nil, reporters: nil, &block)
ffmpeg_execute(*args, inargs:, status:, reporters:, &block).assert!
end
end
end
69 changes: 0 additions & 69 deletions lib/ffmpeg/presets/thumbnail.rb

This file was deleted.

1 change: 1 addition & 0 deletions lib/ffmpeg/reporters/output.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ module FFMPEG
module Reporters
# Represents a raw output line from ffmpeg.
class Output
def self.log? = true
def self.match?(_line) = true

attr_reader :output
Expand Down
2 changes: 2 additions & 0 deletions lib/ffmpeg/reporters/progress.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ module FFMPEG
module Reporters
# Represents the progress of an encoding operation.
class Progress < Output
def self.log? = false

def self.match?(line)
line.match?(/^\s*frame=/)
end
Expand Down
2 changes: 2 additions & 0 deletions lib/ffmpeg/reporters/silence.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ module FFMPEG
module Reporters
# Represents a silence report from ffmpeg.
class Silence < Output
def self.log? = false

def self.match?(line)
line.match?(/^\[silencedetect @ \w+\]/)
end
Expand Down
61 changes: 61 additions & 0 deletions lib/ffmpeg/status.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# frozen_string_literal: true

module FFMPEG
# The Status class represents the status of a ffmpeg process.
# It inherits all methods from the Process::Status class.
# 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 :output, :upstream

def initialize
@output = StringIO.new
end

def puts(*args)
output.puts(*args)
end

# Returns true if the output is empty.
def empty?
output.string.empty?
end

# Raises an error if the subprocess did not finish successfully.
def assert!
return self if success?

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)
end

# Binds the status to an upstream Process::Status object.
def bind!(upstream)
@upstream = upstream

freeze
end

private

def respond_to_missing?(symbol, include_private)
@upstream.respond_to?(symbol, include_private)
end

def method_missing(symbol, *args)
@upstream.send(symbol, *args)
end
end
end
40 changes: 22 additions & 18 deletions lib/ffmpeg/transcoder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,15 @@ module FFMPEG
# status.exitstatus # 0
class Transcoder
# The Status class represents the status of a transcoding process.
# It inherits all methods from the Process::Status class.
# It inherits all methods from the FFMPEG::Status class.
# It also provides a method to retrieve the media files associated with
# the transcoding process.
class Status
class Status < FFMPEG::Status
attr_reader :paths

def initialize(process_status, paths)
@process_status = process_status
def initialize(paths)
@paths = paths
super()
end

# Returns the media files associated with the transcoding process.
Expand All @@ -43,21 +43,11 @@ def media(*ffprobe_args, load: true, autoload: true)
Media.new(path, *ffprobe_args, load: load, autoload: autoload)
end
end

private

def respond_to_missing?(symbol, include_private)
@process_status.respond_to?(symbol, include_private)
end

def method_missing(symbol, *args)
@process_status.send(symbol, *args)
end
end

attr_reader :name, :metadata, :presets, :reporters

def initialize(name: nil, metadata: nil, presets: [], reporters: [Reporters::Progress], &compose_inargs)
def initialize(name: nil, metadata: nil, presets: [], reporters: nil, &compose_inargs)
@name = name
@metadata = metadata
@presets = presets
Expand Down Expand Up @@ -92,10 +82,24 @@ def process(media, output_path, &)

inargs = CommandArgs.compose(media, &@compose_inargs).to_a

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

# Transcodes the media file using the preset configurations
# and raise an error if the subprocess did not finish successfully.
#
# @param media [String, Pathname, URI, FFMPEG::Media] The media file to transcode.
# @param output_path [String, Pathname] The output path to save the transcoded files.
# @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, &)
process(media, output_path, &).assert!
end
end
end
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.0.0-beta.2'
VERSION = '7.0.0-beta.3'
end
Loading