diff --git a/lib/ffmpeg.rb b/lib/ffmpeg.rb index eded024..ff10f6b 100644 --- a/lib/ffmpeg.rb +++ b/lib/ffmpeg.rb @@ -14,6 +14,9 @@ require_relative 'ffmpeg/media' require_relative 'ffmpeg/stream' require_relative 'ffmpeg/transcoder' +require_relative 'ffmpeg/filters/filter' +require_relative 'ffmpeg/filters/grayscale' +require_relative 'ffmpeg/filters/silence_detect' if RUBY_PLATFORM =~ /(win|w)(32|64)$/ begin @@ -60,7 +63,7 @@ def self.logger # @return [String] the path you set # @raise Errno::ENOENT if the ffmpeg binary cannot be found def self.ffmpeg_binary=(bin) - raise Errno::ENOENT, "the ffmpeg binary, '#{bin}', is not executable" if bin.is_a?(String) && !File.executable?(bin) + raise Errno::ENOENT, "The ffmpeg binary, '#{bin}', is not executable" if bin.is_a?(String) && !File.executable?(bin) @ffmpeg_binary = bin end @@ -113,7 +116,7 @@ def self.ffprobe_binary # @raise Errno::ENOENT if the ffprobe binary cannot be found def self.ffprobe_binary=(bin) if bin.is_a?(String) && !File.executable?(bin) - raise Errno::ENOENT, "the ffprobe binary, '#{bin}', is not executable" + raise Errno::ENOENT, "The ffprobe binary, '#{bin}', is not executable" end @ffprobe_binary = bin @@ -156,8 +159,10 @@ def self.max_http_redirect_attempts # @return [Integer] the number of retries you set # @raise Errno::ENOENT if the value is negative or not an Integer def self.max_http_redirect_attempts=(value) - raise Errno::ENOENT, 'max_http_redirect_attempts must be an integer' if value && !value.is_a?(Integer) - raise Errno::ENOENT, 'max_http_redirect_attempts may not be negative' if value&.negative? + if value && !value.is_a?(Integer) + raise ArgumentError, 'Unknown max_http_redirect_attempts format, must be an Integer' + end + raise ArgumentError, 'Invalid max_http_redirect_attempts format, may not be negative' if value&.negative? @max_http_redirect_attempts = value end @@ -201,6 +206,6 @@ def self.which(cmd) return exe if File.executable? exe end end - raise Errno::ENOENT, "the #{cmd} binary could not be found in #{ENV.fetch('PATH', nil)}" + raise Errno::ENOENT, "The #{cmd} binary could not be found in #{ENV.fetch('PATH', nil)}" end end diff --git a/lib/ffmpeg/filters/filter.rb b/lib/ffmpeg/filters/filter.rb new file mode 100644 index 0000000..9144f0e --- /dev/null +++ b/lib/ffmpeg/filters/filter.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module FFMPEG + module Filters + # The Filter module is the base "interface" for all filters. + module Filter + def to_s + raise NotImplementedError + end + + def to_a + raise NotImplementedError + end + end + end +end diff --git a/lib/ffmpeg/filters/grayscale.rb b/lib/ffmpeg/filters/grayscale.rb new file mode 100644 index 0000000..10fcc42 --- /dev/null +++ b/lib/ffmpeg/filters/grayscale.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module FFMPEG + module Filters + # The Grayscale class uses the format filter + # to convert a multimedia file to grayscale. + class Grayscale + include Filter + + def to_s + 'format=gray' + end + + def to_a + ['-vf', to_s] + end + end + end +end diff --git a/lib/ffmpeg/filters/silence_detect.rb b/lib/ffmpeg/filters/silence_detect.rb new file mode 100644 index 0000000..2bbfef0 --- /dev/null +++ b/lib/ffmpeg/filters/silence_detect.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +module FFMPEG + module Filters + # The SilenceDetect class is uses the silencedetect filter + # to detect silent parts in a multimedia file. + class SilenceDetect + Range = Struct.new(:start, :end, :duration) + + include Filter + + attr_reader :threshold, :duration, :mono + + def initialize(threshold: nil, duration: nil, mono: false) + if !threshold.nil? && !threshold.is_a?(Numeric) && !threshold.is_a?(String) + raise ArgumentError, 'Unknown threshold format, should be either Numeric or String' + end + + raise ArgumentError, 'Unknown duration format, should be Numeric' if !duration.nil? && !duration.is_a?(Numeric) + + @threshold = threshold + @duration = duration + @mono = mono + end + + def self.scan(output) + result = [] + + output.scan(/silence_end: (\d+\.\d+) \| silence_duration: (\d+\.\d+)/) do + e = Regexp.last_match(1).to_f + d = Regexp.last_match(2).to_f + result << Range.new(e - d, e, d) + end + + result + end + + def to_s + args = [] + args << "n=#{@threshold}" if @threshold + args << "d=#{@duration}" if @duration + args << 'm=true' if @mono + args.empty? ? 'silencedetect' : "silencedetect=#{args.join(':')}" + end + + def to_a + ['-af', to_s] + end + + def scan(output) + self.class.scan(output) + end + end + end +end diff --git a/lib/ffmpeg/media.rb b/lib/ffmpeg/media.rb index 34d043d..ec2d640 100644 --- a/lib/ffmpeg/media.rb +++ b/lib/ffmpeg/media.rb @@ -3,6 +3,7 @@ require 'multi_json' require 'net/http' require 'uri' +require 'tempfile' module FFMPEG # The Media class represents a multimedia file and provides methods @@ -14,27 +15,46 @@ class Media :format_name, :format_long_name, :start_time, :bitrate, :duration + def self.concat(output_path, *media) + raise ArgumentError, 'Unknown *media format, must be Array' unless media.all? { |m| m.is_a?(Media) } + raise ArgumentError, 'Invalid *media format, must contain more than one Media object' if media.length < 2 + raise ArgumentError, 'Invalid *media format, has to be all valid Media objects' unless media.all?(&:valid?) + raise ArgumentError, 'Invalid *media format, has to be all local Media objects' unless media.all?(&:local?) + + tempfile = Tempfile.open(%w[ffmpeg .txt]) + tempfile.write(media.map { |m| "file '#{File.absolute_path(m.path)}'" }.join("\n")) + tempfile.close + + options = { custom: %w[-c copy] } + kwargs = { input_options: %w[-safe 0 -f concat] } + Transcoder.new(tempfile.path, output_path, options, **kwargs).run + ensure + tempfile&.close + tempfile&.unlink + end + def initialize(path) @path = path # Check if the file exists and get its size if remote? - response = FFMPEG.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})" + raise Errno::ENOENT, + "The file at '#{@path}' does not exist or is not available (response code: #{response.code})" end @size = response.content_length else - raise Errno::ENOENT, "the file '#{path}' does not exist" unless File.exist?(path) + raise Errno::ENOENT, "The file at '#{@path}' does not exist" unless File.exist?(path) - @size = File.size(path) + @size = File.size(@path) end # Run ffprobe to get the streams and format stdout, stderr, _status = FFMPEG.ffprobe_capture3( - '-i', path, '-print_format', 'json', + '-i', @path, '-print_format', 'json', '-show_format', '-show_streams', '-show_error' ) @@ -219,12 +239,30 @@ def audio_tags audio&.first&.tags end + def cut(output_path, from, to, options = EncodingOptions.new, **kwargs) + kwargs[:input_options] ||= [] + if kwargs[:input_options].is_a?(Array) + kwargs[:input_options] << '-to' + kwargs[:input_options] << to.to_s + elsif kwargs[:input_options].is_a?(Hash) + kwargs[:input_options][:to] = to + end + + options = options.merge(seek_time: from) + transcode(output_path, options, **kwargs) + end + + def transcoder(output_path, options = EncodingOptions.new, **kwargs) + Transcoder.new(self, output_path, options, **kwargs) + end + def transcode(output_path, options = EncodingOptions.new, **kwargs, &block) - Transcoder.new(self, output_path, options, **kwargs).run(&block) + transcoder(output_path, options, **kwargs).run(&block) end def screenshot(output_path, options = EncodingOptions.new, **kwargs, &block) - transcode(output_path, options.merge(screenshot: true), **kwargs, &block) + options = options.merge(screenshot: true) + transcode(output_path, options, **kwargs, &block) end end end diff --git a/lib/ffmpeg/transcoder.rb b/lib/ffmpeg/transcoder.rb index e02528c..b2d685b 100644 --- a/lib/ffmpeg/transcoder.rb +++ b/lib/ffmpeg/transcoder.rb @@ -20,7 +20,8 @@ def initialize( options, validate: true, preserve_aspect_ratio: true, - input_options: [] + input_options: [], + filters: [] ) if input.is_a?(Media) @media = input @@ -34,6 +35,7 @@ def initialize( @validate = validate @preserve_aspect_ratio = preserve_aspect_ratio @input_options = input_options + @filters = filters @errors = [] if @input_options.is_a?(Hash) @@ -51,9 +53,11 @@ def initialize( end prepare_resolution - prepare_screenshot + prepare_seek_time - @args = ['-y', *@input_options, '-i', @input_path, *@options.to_a, @output_path] + @args = ['-y', *@input_options, '-i', @input_path, + *@options.to_a, *@filters.map(&:to_a).flatten, + @output_path] end def command @@ -104,19 +108,19 @@ def prepare_resolution end end - def prepare_screenshot - # Moves any screenshot seek_time to an 'ss' custom arg + def prepare_seek_time + # Moves any seek_time to an 'ss' input option seek_time = '' if @options.is_a?(Array) - index = @options.find_index('-seek_time') unless @options.find_index('-screenshot').nil? + index = @options.find_index('-ss') unless index.nil? - @options.delete_at(index) # delete 'seek_time' + @options.delete_at(index) # delete 'ss' seek_time = @options.delete_at(index + 1).to_s # fetch the seek value end else - seek_time = @options.delete(:seek_time).to_s unless @options[:screenshot].nil? + seek_time = @options.delete(:seek_time).to_s end return if seek_time.to_s == '' @@ -138,13 +142,13 @@ def validate_output_file FFMPEG.logger.info "Transcoding of #{@input_path} to #{@output_path} succeeded\n" else errors = "Errors: #{@errors.join(', ')}. " - FFMPEG.logger.error "Failed encoding...\n#{@command}\n\n#{@output}\n#{errors}\n" + FFMPEG.logger.error "Failed encoding...\n#{command.join(' ')}\n\n#{@output}\n#{errors}\n" raise Error, "Failed encoding. #{errors}Full output: #{@output}" end end def execute - FFMPEG.logger.info("Running transcoding...\n#{@command}\n") + FFMPEG.logger.info("Running transcoding...\n#{command.join(' ')}\n") @output = String.new @@ -172,7 +176,7 @@ def execute @errors << 'ffmpeg returned non-zero exit code' unless wait_thr.value.success? rescue Timeout::Error Process.kill(FFMPEG::SIGKILL, wait_thr.pid) - FFMPEG.logger.error "Process hung...\n#{@command}\nOutput\n#{@output}\n" + FFMPEG.logger.error "Process hung...\n#{command.join(' ')}\nOutput\n#{@output}\n" raise Error, "Process hung. Full output: #{@output}" end end diff --git a/lib/ffmpeg/version.rb b/lib/ffmpeg/version.rb index 490e822..b2d9c46 100644 --- a/lib/ffmpeg/version.rb +++ b/lib/ffmpeg/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module FFMPEG - VERSION = '4.1.0' + VERSION = '4.2.0' end diff --git a/spec/ffmpeg/filters/grayscale_spec.rb b/spec/ffmpeg/filters/grayscale_spec.rb new file mode 100644 index 0000000..e793d61 --- /dev/null +++ b/spec/ffmpeg/filters/grayscale_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require_relative '../../spec_helper' + +module FFMPEG + module Filters + describe Grayscale do + subject { described_class.new } + + describe '#to_s' do + it 'returns the filter as a string' do + expect(subject.to_s).to eq('format=gray') + end + end + + describe '#to_a' do + it 'returns the filter as an array' do + expect(subject.to_a).to eq(['-vf', subject.to_s]) + end + end + end + end +end diff --git a/spec/ffmpeg/filters/silence_detect_spec.rb b/spec/ffmpeg/filters/silence_detect_spec.rb new file mode 100644 index 0000000..b047660 --- /dev/null +++ b/spec/ffmpeg/filters/silence_detect_spec.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require_relative '../../spec_helper' + +module FFMPEG + module Filters + describe SilenceDetect do + subject { described_class.new(threshold: '-30dB', duration: 1, mono: true) } + + describe '.scan' do + it 'returns an array of silence ranges' do + output = <<~OUTPUT + silence_end: 1.000000 | silence_duration: 1.000000 + silence_end: 3.000000 | silence_duration: 1.000000 + OUTPUT + + ranges = described_class.scan(output) + + expect(ranges).to eq([ + described_class::Range.new(0.0, 1.0, 1.0), + described_class::Range.new(2.0, 3.0, 1.0) + ]) + end + end + + describe '#initialize' do + it 'raises ArgumentError if threshold is not numeric or string' do + expect { described_class.new(threshold: '-30dB') }.not_to raise_error + expect { described_class.new(threshold: 1) }.not_to raise_error + expect { described_class.new(threshold: 0.01) }.not_to raise_error + expect { described_class.new(threshold: []) }.to raise_error(ArgumentError) + end + + it 'raises ArgumentError if duration is not numeric' do + expect { described_class.new(duration: 1) }.not_to raise_error + expect { described_class.new(duration: 0.01) }.not_to raise_error + expect { described_class.new(duration: '1') }.to raise_error(ArgumentError) + end + + it 'sets the threshold' do + expect(subject.threshold).to eq('-30dB') + end + + it 'sets the duration' do + expect(subject.duration).to eq(1) + end + + it 'sets mono to true' do + expect(subject.mono).to eq(true) + end + end + + describe '#to_s' do + it 'returns the filter as a string' do + expect(subject.to_s).to eq('silencedetect=n=-30dB:d=1:m=true') + end + end + + describe '#to_a' do + it 'returns the filter as an array' do + expect(subject.to_a).to eq(['-af', subject.to_s]) + end + end + end + end +end diff --git a/spec/ffmpeg/media_spec.rb b/spec/ffmpeg/media_spec.rb index a214d3a..3543a44 100644 --- a/spec/ffmpeg/media_spec.rb +++ b/spec/ffmpeg/media_spec.rb @@ -276,33 +276,110 @@ module FFMPEG end end + describe '#transcoder' do + let(:output_path) { tmp_file(ext: 'mov') } + + it 'returns a transcoder for the media' do + transcoder = subject.transcoder(output_path, { custom: %w[-vcodec libx264] }) + expect(transcoder).to be_a(Transcoder) + expect(transcoder.input_path).to eq(subject.path) + expect(transcoder.output_path).to eq(output_path) + expect(transcoder.command.join(' ')).to include('-vcodec libx264') + end + end + describe '#transcode' do - let(:options) { { custom: '-vcodec libx264' } } + let(:output_path) { tmp_file(ext: 'mov') } + let(:options) { { custom: %w[-vcodec libx264] } } let(:kwargs) { { preserve_aspect_ratio: :width } } it 'should run the transcoder' do transcoder_double = double(Transcoder) expect(Transcoder).to receive(:new) - .with(subject, "#{tmp_path}/awesome.flv", options, **kwargs) + .with(subject, output_path, options, **kwargs) .and_return(transcoder_double) expect(transcoder_double).to receive(:run) - subject.transcode("#{tmp_path}/awesome.flv", options, **kwargs) + subject.transcode(output_path, options, **kwargs) end end describe '#screenshot' do + let(:output_path) { tmp_file(ext: 'jpg') } let(:options) { { seek_time: 2, dimensions: '640x480' } } let(:kwargs) { { preserve_aspect_ratio: :width } } it 'should run the transcoder with screenshot option' do transcoder_double = double(Transcoder) expect(Transcoder).to receive(:new) - .with(subject, "#{tmp_path}/awesome.jpg", options.merge(screenshot: true), **kwargs) + .with(subject, output_path, options.merge(screenshot: true), **kwargs) .and_return(transcoder_double) expect(transcoder_double).to receive(:run) - subject.screenshot("#{tmp_path}/awesome.jpg", options, **kwargs) + subject.screenshot(output_path, options, **kwargs) + end + end + + describe '#cut' do + let(:output_path) { tmp_file(ext: 'mov') } + let(:options) { { custom: %w[-vcodec libx264] } } + + context 'with no input options' do + it 'should run the transcoder to cut the media' do + expected_kwargs = { input_options: %w[-to 4] } + transcoder_double = double(Transcoder) + expect(Transcoder).to receive(:new) + .with(subject, output_path, options.merge(seek_time: 2), **expected_kwargs) + .and_return(transcoder_double) + expect(transcoder_double).to receive(:run) + + subject.cut(output_path, 2, 4, options) + end + end + + context 'with input options as a string array' do + let(:kwargs) { { input_options: %w[-ss 999] } } + + it 'should run the transcoder to cut the media' do + expected_kwargs = kwargs.merge({ input_options: kwargs[:input_options] + %w[-to 4] }) + transcoder_double = double(Transcoder) + expect(Transcoder).to receive(:new) + .with(subject, output_path, options.merge(seek_time: 2), **expected_kwargs) + .and_return(transcoder_double) + expect(transcoder_double).to receive(:run) + + subject.cut(output_path, 2, 4, options, **kwargs) + end + end + + context 'with input options as a hash' do + let(:kwargs) { { input_options: { ss: 999 } } } + + it 'should run the transcoder to cut the media' do + expected_kwargs = kwargs.merge({ input_options: kwargs[:input_options].merge({ to: 4 }) }) + transcoder_double = double(Transcoder) + expect(Transcoder).to receive(:new) + .with(subject, output_path, options.merge(seek_time: 2), **expected_kwargs) + .and_return(transcoder_double) + expect(transcoder_double).to receive(:run) + + subject.cut(output_path, 2, 4, options, **kwargs) + end + end + end + + describe '.concat' do + let(:output_path) { tmp_file(ext: 'mov') } + let(:segment1_path) { tmp_file(basename: 'segment1', ext: 'mov') } + let(:segment2_path) { tmp_file(basename: 'segment2', ext: 'mov') } + + it 'should run the transcoder to concatenate the segments' do + segment1 = subject.cut(segment1_path, 1, 3) + segment2 = subject.cut(segment2_path, 4, subject.duration) + result = described_class.concat(output_path, segment1, segment2) + expect(result).to be_a(Media) + expect(result.path).to eq(output_path) + expect(result.duration).to be_within(0.2).of(5.5) end end end diff --git a/spec/ffmpeg/transcoder_spec.rb b/spec/ffmpeg/transcoder_spec.rb index 5be6ccf..1f7d9d1 100644 --- a/spec/ffmpeg/transcoder_spec.rb +++ b/spec/ffmpeg/transcoder_spec.rb @@ -400,6 +400,23 @@ module FFMPEG end end end + + context 'with filters' do + let(:kwargs) { { filters: [Filters::SilenceDetect.new(threshold: '-30dB', duration: 1, mono: true)] } } + + it 'should produce the correct ffmpeg command' do + expect(subject.command.join(' ')).to include('-af silencedetect=n=-30dB:d=1:m') + end + + it 'should transcode correctly' do + result = subject.run + expect(result).to be_a(FFMPEG::Media) + + ranges = Filters::SilenceDetect.scan(subject.output) + expect(ranges.length).to eq(2) + expect(ranges.all?(Filters::SilenceDetect::Range)).to be_truthy + end + end end end end diff --git a/spec/ffmpeg_spec.rb b/spec/ffmpeg_spec.rb index a9b9f72..d15eaf2 100644 --- a/spec/ffmpeg_spec.rb +++ b/spec/ffmpeg_spec.rb @@ -86,11 +86,11 @@ end it 'should be an Integer' do - expect { FFMPEG.max_http_redirect_attempts = 1.23 }.to raise_error(Errno::ENOENT) + expect { FFMPEG.max_http_redirect_attempts = 1.23 }.to raise_error(ArgumentError) end it 'should not be negative' do - expect { FFMPEG.max_http_redirect_attempts = -1 }.to raise_error(Errno::ENOENT) + expect { FFMPEG.max_http_redirect_attempts = -1 }.to raise_error(ArgumentError) end it 'should be assignable' do diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index c64e5b5..6336ba1 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -43,10 +43,11 @@ def read_fixture_file(filename) File.read(File.join(fixture_path, filename)) end -def tmp_file(filename: nil, ext: nil) +def tmp_file(filename: nil, basename: nil, ext: nil) if filename.nil? filename = RSpec.current_example.metadata[:description].downcase.gsub(/[^\w]/, '_') filename += "_#{('a'..'z').to_a.sample(8).join}" + filename += "_#{basename}" if basename filename += ".#{ext}" if ext end