Skip to content

Commit

Permalink
feat: add public methods to allow custom external ffmpeg and ffprobe …
Browse files Browse the repository at this point in the history
…calls
  • Loading branch information
bajankristof committed Apr 29, 2024
1 parent 31f57b6 commit e64bc1c
Show file tree
Hide file tree
Showing 9 changed files with 180 additions and 109 deletions.
90 changes: 89 additions & 1 deletion lib/ffmpeg.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
$LOAD_PATH.unshift File.dirname(__FILE__)

require 'logger'
require 'net/http'
require 'open3'
require 'uri'

require_relative 'ffmpeg/version'
require_relative 'ffmpeg/encoding_options'
Expand All @@ -11,7 +14,15 @@
require_relative 'ffmpeg/media'
require_relative 'ffmpeg/stream'
require_relative 'ffmpeg/transcoder'
require_relative 'ffmpeg/utils'

if RUBY_PLATFORM =~ /(win|w)(32|64)$/
begin
require 'win32/process'
rescue LoadError
'Warning: ffmpeg is missing the win32-process gem to properly handle hung transcodings. ' \
'Install the gem (in Gemfile if using bundler) to avoid errors.'
end
end

# The FFMPEG module allows you to customise the behaviour of the FFMPEG library.
#
Expand All @@ -20,6 +31,8 @@
# FFMPEG.ffprobe_binary = '/usr/local/bin/ffprobe'
# FFMPEG.logger = Logger.new(STDOUT)
module FFMPEG
SIGKILL = RUBY_PLATFORM =~ /(win|w)(32|64)$/ ? 1 : 'SIGKILL'

# FFMPEG logs information about its progress when it's transcoding.
# Jack in your own logger through this method if you wish to.
#
Expand Down Expand Up @@ -60,6 +73,30 @@ def self.ffmpeg_binary
@ffmpeg_binary || which('ffmpeg')
end

# Safely captures the standard output and the standard error of the ffmpeg command.
#
# @return [[String, String, Process::Status]] the standard output, the standard error, and the process status
# @raise [Errno::ENOENT] if the ffmpeg binary cannot be found
def self.ffmpeg_capture3(*args)
stdout, stderr, status = Open3.capture3(ffmpeg_binary, *args)
FFMPEG::IO.force_encoding(stdout)
FFMPEG::IO.force_encoding(stderr)
[stdout, stderr, status]
end

# Starts a new ffmpeg process with the given arguments.
# Yields the the standard input (#<FFMPEG::IO>), the standard output (#<FFMPEG::IO>)
# and the standard error (#<FFMPEG::IO>) streams, as well as the child process Thread
# to the specified block.
#
# @return [void]
# @raise [Errno::ENOENT] if the ffmpeg binary cannot be found
def self.ffmpeg_popen3(*args, &block)
Open3.popen3(ffmpeg_binary, *args) do |stdin, stdout, stderr, wait_thr|
block.call(stdin, FFMPEG::IO.new(stdout), FFMPEG::IO.new(stderr), wait_thr)
end
end

# Get the path to the ffprobe binary, defaulting to what is on ENV['PATH']
#
# @return [String] the path to the ffprobe binary
Expand All @@ -82,6 +119,30 @@ def self.ffprobe_binary=(bin)
@ffprobe_binary = bin
end

# Safely captures the standard output and the standard error of the ffmpeg command.
#
# @return [[String, String, Process::Status]] the standard output, the standard error, and the process status
# @raise [Errno::ENOENT] if the ffprobe binary cannot be found
def self.ffprobe_capture3(*args)
stdout, stderr, status = Open3.capture3(ffprobe_binary, *args)
FFMPEG::IO.force_encoding(stdout)
FFMPEG::IO.force_encoding(stderr)
[stdout, stderr, status]
end

# Starts a new ffprobe process with the given arguments.
# Yields the the standard input (#<FFMPEG::IO>), the standard output (#<FFMPEG::IO>)
# and the standard error (#<FFMPEG::IO>) streams, as well as the child process Thread
# to the specified block.
#
# @return [void]
# @raise [Errno::ENOENT] if the ffprobe binary cannot be found
def self.ffprobe_popen3(*args, &block)
Open3.popen3(ffprobe_binary, *args) do |stdin, stdout, stderr, wait_thr|
block.call(stdin, FFMPEG::IO.new(stdout), FFMPEG::IO.new(stderr), wait_thr)
end
end

# Get the maximum number of http redirect attempts
#
# @return [Integer] the maximum number of retries
Expand All @@ -101,6 +162,33 @@ def self.max_http_redirect_attempts=(value)
@max_http_redirect_attempts = value
end

# Sends a HEAD request to a remote URL.
# Follows redirects up to the maximum number of attempts.
#
# @return [Net::HTTPResponse, nil] the response object
# @raise [FFMPEG::HTTPTooManyRedirects] if the maximum number of redirects is exceeded
def self.fetch_http_head(url, max_redirect_attempts = max_http_redirect_attempts)
uri = URI(url)
return unless uri.path

conn = Net::HTTP.new(uri.host, uri.port)
conn.use_ssl = uri.port == 443
response = conn.request_head(uri.request_uri)

case response
when Net::HTTPRedirection
raise HTTPTooManyRedirects if max_redirect_attempts.zero?

redirect_uri = uri + URI(response.header['Location'])

fetch_http_head(redirect_uri, max_redirect_attempts - 1)
else
response
end
rescue SocketError, Errno::ECONNREFUSED
nil
end

# Cross-platform way of finding an executable in the $PATH.
#
# which('ruby') #=> /usr/bin/ruby
Expand Down
82 changes: 54 additions & 28 deletions lib/ffmpeg/io.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,39 +3,65 @@
require 'English'
require 'timeout'

if RUBY_PLATFORM =~ /(win|w)(32|64)$/
begin
require 'win32/process'
rescue LoadError
'Warning: ffmpeg is missing the win32-process gem to properly handle hung transcodings. ' \
'Install the gem (in Gemfile if using bundler) to avoid errors.'
end
end
module FFMPEG
# The IO class is a simple wrapper around IO objects that adds a timeout
# to all read operations and fixes encoding issues.
class IO
attr_accessor :timeout

def self.force_encoding(chunk)
chunk[/test/]
rescue ArgumentError
chunk.force_encoding('ISO-8859-1')
end

# Monkey Patch timeout support into the IO class...
class IO
def each_with_timeout(pid, seconds, separator = $INPUT_RECORD_SEPARATOR)
last_tick = Time.now
current_thr = Thread.current
timeout_thr = Thread.new do
loop do
sleep 0.1
current_thr.raise Timeout::Error.new('output wait time expired') if last_tick - Time.now < -seconds
def initialize(target)
@target = target
end

%i[
getc
gets
readchar
readline
].each do |symbol|
define_method(symbol) do |*args|
Timeout.timeout(timeout) do
output = @target.send(symbol, *args)
self.class.force_encoding(output)
output
end
end
end

each(separator) do |buffer|
last_tick = Time.now
yield buffer
%i[
each
each_char
each_line
].each do |symbol|
read = symbol == :each_char ? :getc : :gets
define_method(symbol) do |*args, &block|
until eof?
output = send(read, *args)
block.call(output)
end
end
end
rescue Timeout::Error
if RUBY_PLATFORM =~ /(win|w)(32|64)$/
Process.kill(1, pid)
else
Process.kill('SIGKILL', pid)

def readlines(*args)
lines = []
lines << gets(*args) until eof?
lines
end

private

def respond_to_missing?(symbol, include_private = false)
@target.respond_to?(symbol, include_private)
end

def method_missing(symbol, *args)
@target.send(symbol, *args)
end
raise
ensure
timeout_thr.kill
end
end
12 changes: 6 additions & 6 deletions lib/ffmpeg/media.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

require 'multi_json'
require 'net/http'
require 'uri'

module FFMPEG
# The Media class represents a multimedia file and provides methods
Expand All @@ -18,7 +19,7 @@ def initialize(path)

# Check if the file exists and get its size
if remote?
response = Utils.fetch_http_head(path)
response = FFMPEG.fetch_http_head(path)

unless response.is_a?(Net::HTTPSuccess)
raise Errno::ENOENT, "the URL '#{path}' does not exist or is not available (response code: #{response.code})"
Expand All @@ -32,11 +33,10 @@ def initialize(path)
end

# Run ffprobe to get the streams and format
command = [FFMPEG.ffprobe_binary, '-i', path,
'-print_format', 'json', '-show_format', '-show_streams', '-show_error']
stdout, stderr, _status = Open3.capture3(*command)
Utils.force_iso8859(stdout)
Utils.force_iso8859(stderr)
stdout, stderr, _status = FFMPEG.ffprobe_capture3(
'-i', path, '-print_format', 'json',
'-show_format', '-show_streams', '-show_error'
)

# Parse ffprobe metadata
begin
Expand Down
50 changes: 23 additions & 27 deletions lib/ffmpeg/transcoder.rb
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
# frozen_string_literal: true

require 'open3'
require 'timeout'

module FFMPEG
# The Transcoder class is responsible for transcoding multimedia files.
# It accepts a Media object or a path to a multimedia file as input.
class Transcoder
attr_reader :command, :output, :errors, :input_path, :output_path
attr_reader :args, :output, :errors, :input_path, :output_path

@timeout = 30

Expand Down Expand Up @@ -53,7 +53,11 @@ def initialize(
prepare_resolution
prepare_screenshot

@command = [FFMPEG.ffmpeg_binary, '-y', *@input_options, '-i', @input_path, *@options.to_a, @output_path]
@args = ['-y', *@input_options, '-i', @input_path, *@options.to_a, @output_path]
end

def command
[FFMPEG.ffmpeg_binary, *@args]
end

def run(&block)
Expand Down Expand Up @@ -139,44 +143,36 @@ def validate_output_file
end
end

# frame= 4855 fps= 46 q=31.0 size= 45306kB time=00:02:42.28 bitrate=2287.0kbits/
def execute
FFMPEG.logger.info("Running transcoding...\n#{@command}\n")

@output = String.new

Open3.popen3(*@command) do |_stdin, _stdout, stderr, wait_thr|
FFMPEG.ffmpeg_popen3(*@args) do |_stdin, stdout, stderr, wait_thr|
yield(0.0) if block_given?

handler = proc do |line|
Utils.force_iso8859(line)
if timeout
stdout.timeout = timeout
stderr.timeout = timeout
end

stderr.each do |line|
@output << line

if line.include?('time=')
time = if line =~ /time=(\d+):(\d+):(\d+.\d+)/ # ffmpeg 0.8 and above style
(Regexp.last_match(1).to_i * 3600) +
(Regexp.last_match(2).to_i * 60) +
Regexp.last_match(3).to_f
else # better make sure it wont blow up in case of unexpected output
0.0
end

if @media
progress = time / @media.duration
yield(progress) if block_given?
end
end
end
next unless @media
next unless block_given?
next unless line =~ /time=(\d+):(\d+):(\d+.\d+)/ # time=00:02:42.28

if timeout
stderr.each_with_timeout(wait_thr.pid, timeout, 'size=', &handler)
else
stderr.each('size=', &handler)
time = (::Regexp.last_match(1).to_i * 3600) +
(::Regexp.last_match(2).to_i * 60) +
::Regexp.last_match(3).to_f
yield(time / @media.duration)
end

@errors << 'ffmpeg returned non-zero exit code' unless wait_thr.value.success?
rescue Timeout::Error
FFMPEG.logger.error "Process hung...\n@command\n#{@command}\nOutput\n#{@output}\n"
Process.kill(FFMPEG::SIGKILL, wait_thr.pid)
FFMPEG.logger.error "Process hung...\n#{@command}\nOutput\n#{@output}\n"
raise Error, "Process hung. Full output: #{@output}"
end
end
Expand Down
37 changes: 0 additions & 37 deletions lib/ffmpeg/utils.rb

This file was deleted.

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 = '4.0.0'
VERSION = '4.1.0'
end
Loading

0 comments on commit e64bc1c

Please sign in to comment.