Skip to content

Commit

Permalink
feat: add new filters and cut / concat API
Browse files Browse the repository at this point in the history
  • Loading branch information
bajankristof committed May 2, 2024
1 parent e64bc1c commit a27becd
Show file tree
Hide file tree
Showing 13 changed files with 353 additions and 32 deletions.
15 changes: 10 additions & 5 deletions lib/ffmpeg.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
16 changes: 16 additions & 0 deletions lib/ffmpeg/filters/filter.rb
Original file line number Diff line number Diff line change
@@ -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
19 changes: 19 additions & 0 deletions lib/ffmpeg/filters/grayscale.rb
Original file line number Diff line number Diff line change
@@ -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
55 changes: 55 additions & 0 deletions lib/ffmpeg/filters/silence_detect.rb
Original file line number Diff line number Diff line change
@@ -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
52 changes: 45 additions & 7 deletions lib/ffmpeg/media.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<Media>' 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'
)

Expand Down Expand Up @@ -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
26 changes: 15 additions & 11 deletions lib/ffmpeg/transcoder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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 == ''
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down
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.1.0'
VERSION = '4.2.0'
end
23 changes: 23 additions & 0 deletions spec/ffmpeg/filters/grayscale_spec.rb
Original file line number Diff line number Diff line change
@@ -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
Loading

0 comments on commit a27becd

Please sign in to comment.