diff --git a/lib/ffmpeg.rb b/lib/ffmpeg.rb index d3baeef..eded024 100644 --- a/lib/ffmpeg.rb +++ b/lib/ffmpeg.rb @@ -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' @@ -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. # @@ -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. # @@ -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 (#), the standard output (#) + # and the standard error (#) 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 @@ -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 (#), the standard output (#) + # and the standard error (#) 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 @@ -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 diff --git a/lib/ffmpeg/io.rb b/lib/ffmpeg/io.rb index 9433e4a..9fea0ff 100644 --- a/lib/ffmpeg/io.rb +++ b/lib/ffmpeg/io.rb @@ -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 diff --git a/lib/ffmpeg/media.rb b/lib/ffmpeg/media.rb index d0b32c7..34d043d 100644 --- a/lib/ffmpeg/media.rb +++ b/lib/ffmpeg/media.rb @@ -2,6 +2,7 @@ require 'multi_json' require 'net/http' +require 'uri' module FFMPEG # The Media class represents a multimedia file and provides methods @@ -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})" @@ -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 diff --git a/lib/ffmpeg/transcoder.rb b/lib/ffmpeg/transcoder.rb index d5c1422..e02528c 100644 --- a/lib/ffmpeg/transcoder.rb +++ b/lib/ffmpeg/transcoder.rb @@ -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 @@ -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) @@ -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 diff --git a/lib/ffmpeg/utils.rb b/lib/ffmpeg/utils.rb deleted file mode 100644 index b8ce361..0000000 --- a/lib/ffmpeg/utils.rb +++ /dev/null @@ -1,37 +0,0 @@ -# frozen_string_literal: true - -require 'net/http' -require 'uri' - -module FFMPEG - # Utility methods - class Utils - def self.force_iso8859(output) - output[/test/] - rescue ArgumentError - output.force_encoding('ISO-8859-1') - end - - def self.fetch_http_head(url, max_redirect_attempts = FFMPEG.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 FFMPEG::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 - end -end diff --git a/lib/ffmpeg/version.rb b/lib/ffmpeg/version.rb index 8b1d3cb..490e822 100644 --- a/lib/ffmpeg/version.rb +++ b/lib/ffmpeg/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module FFMPEG - VERSION = '4.0.0' + VERSION = '4.1.0' end diff --git a/spec/ffmpeg/transcoder_spec.rb b/spec/ffmpeg/transcoder_spec.rb index 606a095..5be6ccf 100644 --- a/spec/ffmpeg/transcoder_spec.rb +++ b/spec/ffmpeg/transcoder_spec.rb @@ -83,7 +83,7 @@ module FFMPEG let(:output_ext) { 'mpg' } let(:options) { { target: 'ntsc-vcd' } } - before { Transcoder.timeout = false } + before { Transcoder.timeout = nil } after { Transcoder.timeout = 30 } it 'should still work with NTSC target' do @@ -141,7 +141,7 @@ module FFMPEG context 'when ffmpeg freezes' do before do Transcoder.timeout = 1 - FFMPEG.ffmpeg_binary = "#{fixture_path}/bin/ffmpeg-audio-only" + FFMPEG.ffmpeg_binary = "#{fixture_path}/bin/ffmpeg-audio-hanging" end after do @@ -150,7 +150,7 @@ module FFMPEG end it 'should fail when the timeout is exceeded' do - expect { subject.run }.to raise_error(FFMPEG::Error, /Errors: no output file created/) + expect { subject.run }.to raise_error(FFMPEG::Error, /Process hung/) end end end diff --git a/spec/fixtures/bin/ffmpeg-audio-only b/spec/fixtures/bin/ffmpeg-audio-hanging similarity index 96% rename from spec/fixtures/bin/ffmpeg-audio-only rename to spec/fixtures/bin/ffmpeg-audio-hanging index 5d38e58..7469fa9 100755 --- a/spec/fixtures/bin/ffmpeg-audio-only +++ b/spec/fixtures/bin/ffmpeg-audio-hanging @@ -54,10 +54,8 @@ if ARGV.length > 2 # looks like we're trying to transcode Stream #0:1 -> #0:0 (aac -> libmp3lame) Press [q] to stop, [?] for help OUTPUT - 3.times do - $stderr.write "size= 51953kB time=02:27:46.48 bitrate= 48.0kbits/s\r" - sleep 0.5 - end + $stderr.write "size= 51953kB time=02:27:46.48 bitrate= 48.0kbits/s\r" + loop { sleep 1 } else warn 'At least one output file must be specified' end diff --git a/spec/fixtures/bin/ffmpeg-hanging b/spec/fixtures/bin/ffmpeg-hanging index 2aac1ce..8695ba7 100755 --- a/spec/fixtures/bin/ffmpeg-hanging +++ b/spec/fixtures/bin/ffmpeg-hanging @@ -38,8 +38,8 @@ if ARGV.length > 2 # looks like we're trying to transcode Stream #0:1 -> #0:1 (aac -> libfaac) Press [q] to stop, [?] for help OUTPUT - warn 'frame= 72 fps=0.0 q=32766.0 Lsize= 115kB time=00:00:07.00 bitrate= 134.6kbits/s' - loop { sleep 1 } # omg hang! + $stderr.write 'frame= 72 fps=0.0 q=32766.0 Lsize= 115kB time=00:00:07.00 bitrate= 134.6kbits/s' + loop { sleep 1 } else warn 'At least one output file must be specified' end