diff --git a/.github/workflows/ci.test.yml b/.github/workflows/ci.test.yml index 1c36d99..6684938 100644 --- a/.github/workflows/ci.test.yml +++ b/.github/workflows/ci.test.yml @@ -14,13 +14,12 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - ruby-version: ['3.0', '3.1', '3.2', '3.3'] - ffmpeg-version: ['7.0.2', '6.0.1', '5.1.1', '4.4.1'] + ruby-version: ['3.1', '3.2', '3.3'] + ffmpeg-version: ['release', '6.0.1', '5.1.1', '4.4.1'] include: - - ffmpeg-version: '7.0.2' - download-path: 'releases' - version-name: 'release' + - ffmpeg-version: 'release' + ffmpeg-download-path: 'releases' steps: - uses: actions/checkout@v4 @@ -32,14 +31,16 @@ jobs: rubygems: latest - name: Install FFMPEG + working-directory: /tmp run: | sudo apt-get update sudo apt-get install -y wget - wget https://johnvansickle.com/ffmpeg/${{ matrix.download-path || 'old-releases' }}/ffmpeg-${{ matrix.version-name || matrix.ffmpeg-version }}-amd64-static.tar.xz - tar -xf ffmpeg-${{ matrix.version-name || matrix.ffmpeg-version }}-amd64-static.tar.xz - sudo mv ffmpeg-${{ matrix.ffmpeg-version }}-amd64-static/ffmpeg /usr/local/bin/ffmpeg - sudo mv ffmpeg-${{ matrix.ffmpeg-version }}-amd64-static/ffprobe /usr/local/bin/ffprobe - rm -rf ffmpeg-${{ matrix.version-name || matrix.ffmpeg-version }}-amd64-static.tar.xz ffmpeg-${{ matrix.ffmpeg-version }}-amd64-static + wget -O ffmpeg.tar.xz https://johnvansickle.com/ffmpeg/${{ matrix.ffmpeg-download-path || 'old-releases' }}/ffmpeg-${{ matrix.ffmpeg-version }}-amd64-static.tar.xz + mkdir ffmpeg + tar -xf ffmpeg.tar.xz --strip=1 -C ffmpeg + sudo mv ffmpeg/ffmpeg /usr/local/bin/ffmpeg + sudo mv ffmpeg/ffprobe /usr/local/bin/ffprobe + rm -rf ffmpeg.tar.xz ffmpeg - name: Run RSpec run: bundle exec rspec diff --git a/.rubocop.yml b/.rubocop.yml index c593b11..c599c1f 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -3,7 +3,7 @@ inherit_from: .rubocop_todo.yml AllCops: NewCops: enable SuggestExtensions: false - TargetRubyVersion: 3.0 + TargetRubyVersion: 3.1 Metrics/AbcSize: Enabled: false @@ -38,5 +38,8 @@ Style/ArgumentsForwarding: Style/FloatDivision: Enabled: false +Style/RequireOrder: + Enabled: true + Style/SafeNavigationChainLength: Enabled: false diff --git a/.ruby-version b/.ruby-version new file mode 100644 index 0000000..9c25013 --- /dev/null +++ b/.ruby-version @@ -0,0 +1 @@ +3.3.6 diff --git a/README.md b/README.md index 354a8a6..26d9468 100644 --- a/README.md +++ b/README.md @@ -3,253 +3,178 @@ [![Test](https://github.com/instructure/ruby-ffmpeg/actions/workflows/ci.test.yml/badge.svg?event=push)](https://github.com/instructure/ruby-ffmpeg/actions/workflows/ci.test.yml) [![Lint](https://github.com/instructure/ruby-ffmpeg/actions/workflows/ci.lint.yml/badge.svg?event=push)](https://github.com/instructure/ruby-ffmpeg/actions/workflows/ci.lint.yml) -Simple yet powerful wrapper around the ffmpeg command for reading metadata and transcoding movies. +Simple yet powerful wrapper around the ffmpeg command for reading metadata and transcoding media. ## Compatibility ### Ruby -Only guaranteed to work with Ruby 3.0 or later. +Only guaranteed to work with Ruby 3.1 or later. ### ffmpeg -The current gem is tested against ffmpeg 4, 5 and 6. So no guarantees with earlier (or much later) +The current gem is tested against ffmpeg 4, 5, 6 and 7. So no guarantees with earlier (or much later) versions. Output and input standards have inconveniently changed rather a lot between versions of ffmpeg. Our goal is to keep this library in sync with new versions of ffmpeg as they come along. On macOS: `brew install ffmpeg`. -## Usage - -### Require the gem +## Installation ```ruby require 'ffmpeg', git: 'https://github.com/instructure/ruby-ffmpeg' ``` -### Reading Metadata - -```ruby -media = FFMPEG::Media.new('path/to/movie.mov') - -media.duration # 7.5 (duration of the media in seconds) -media.bitrate # 481 (bitrate in kb/s) -media.size # 455546 (filesize in bytes) - -media.video_overview # 'h264, yuv420p, 640x480 [PAR 1:1 DAR 4:3], 371 kb/s, 16.75 fps, 15 tbr, 600 tbn, 1200 tbc' (raw video stream info) -media.video_codec_name # 'h264' -media.color_space # 'yuv420p' -media.resolution # '640x480' -media.width # 640 (width of the video stream in pixels) -media.height # 480 (height of the video stream in pixels) -media.frame_rate # 16.72 (frames per second) - -media.audio_overview # 'aac, 44100 Hz, stereo, s16, 75 kb/s' (raw audio stream info) -media.audio_codec_name # 'aac' -media.audio_sample_rate # 44100 -media.audio_channels # 2 - -media.video # FFMPEG::Stream -# Multiple audio streams -media.audio[0] # FFMPEG::Stream - -media.valid? # true (would be false if ffmpeg fails to read the movie) -``` - -### Transcoding - -First argument is the output file path. - -```ruby -media.transcode('path/to/new_movie.mp4') # Default ffmpeg settings for mp4 format -``` - -Keep track of progress with an optional block. - -```ruby -media.transcode('path/to/new_movie.mp4') { |progress| puts progress } # 0.2 ... 0.5 ... 1.0 -``` - -Give custom command line options with an array. - -```ruby -media.transcode('path/to/new_movie.mp4', %w(-ac aac -vc libx264 -ac 2 ...)) -``` - -Use the EncodingOptions parser for humanly readable transcoding options. Below you'll find most of the supported options. -Note that the :custom key is an array so that it can be used for FFMpeg options like -`-map` that can be repeated: - -```ruby -options = { - video_codec: 'libx264', frame_rate: 10, resolution: '320x240', video_bitrate: 300, video_bitrate_tolerance: 100, - aspect: 1.333333, keyframe_interval: 90, x264_vprofile: 'high', x264_preset: 'slow', - audio_codec: 'libfaac', audio_bitrate: 32, audio_sample_rate: 22050, audio_channels: 1, - threads: 2, custom: %w(-vf crop=60:60:10:10 -map 0:0 -map 0:1) -} - -media.transcode('movie.mp4', options) -``` - -The transcode function returns a Movie object for the encoded file. - -```ruby -new_media = media.transcode('path/to/new_movie.flv') - -new_media.video_codec_name # 'flv' -new_media.audio_codec_name # 'mp3' -``` - -Aspect ratio is added to encoding options automatically if none is specified. - -```ruby -options = { resolution: '320x180' } # Will add -aspect 1.77777777777778 to ffmpeg -``` - -Preserve aspect ratio on width or height by using the preserve_aspect_ratio transcoder option. - -```ruby -media = FFMPEG::Media.new('path/to/movie.mov') - -options = { resolution: '320x240' } - -kwargs = { preserve_aspect_ratio: :width } -media.transcode('movie.mp4', options, **kwargs) # Output resolution will be 320x180 - -kwargs = { preserve_aspect_ratio: :height } -media.transcode('movie.mp4', options, **kwargs) # Output resolution will be 426x240 -``` - -For constant bitrate encoding use video_min_bitrate and video_max_bitrate with buffer_size. - -```ruby -options = {video_min_bitrate: 600, video_max_bitrate: 600, buffer_size: 2000} -media.transcode('path/to/new_movie.flv', options) -``` - -### Specifying Input Options - -To specify which options apply the input, such as changing the input framerate, use `input_options` hash -in the transcoder kwargs. - -```ruby -movie = FFMPEG::Media.new('path/to/movie.mov') - -kwargs = { input_options: { framerate: '1/5' } } -movie.transcode('path/to/new_movie.mp4', {}, **kwargs) - -# FFMPEG Command will look like this: -# ffmpeg -y -framerate 1/5 -i path/to/movie.mov movie.mp4 -``` - -### Watermarking - -Add watermark image on the video. - -For example, you want to add a watermark on the video at right top corner with 10px padding. - -```ruby -options = { - watermark: 'path/to/watermark.png', resolution: '640x360', - watermark_filter: { position: 'RT', padding_x: 10, padding_y: 10 } -} -``` - -Position can be "LT" (Left Top Corner), "RT" (Right Top Corner), "LB" (Left Bottom Corner), "RB" (Right Bottom Corner). -The watermark will not appear unless `watermark_filter` specifies the position. `padding_x` and `padding_y` default to -`10`. - -### Taking Screenshots - -You can use the screenshot method to make taking screenshots a bit simpler. - -```ruby -media.screenshot('path/to/new_screenshot.jpg') -``` - -The screenshot method has the very same API as transcode so the same options will work. +## Usage -```ruby -media.screenshot('path/to/new_screenshot.bmp', { seek_time: 5, resolution: '320x240' }) -``` +NOTE: For advanced usage, don't be afraid to dive into the source code. +The gem is designed with care and documented all over the place, so dig in. -To generate multiple screenshots in a single pass, specify `vframes` and a wildcard filename. Make -sure to disable output file validation. The following code generates up to 20 screenshots every 10 seconds: +### Metadata ```ruby -media.screenshot('path/to/new_screenshot_%d.jpg', { vframes: 20, frame_rate: '1/6' }, validate: false) -``` - -To specify the quality when generating compressed screenshots (.jpg), use `quality` which specifies -ffmpeg `-v:q` option. Quality is an integer between 1 and 31, where lower is better quality: +media = FFMPEG::Media.new('path/to/media.mp4') -```ruby -media.screenshot('path/to/new_screenshot_%d.jpg', { quality: 3 }) -``` +media.valid? # true (would be false if ffmpeg fails to read the media metadata) +media.local? # true (would be false if the file is remote) +media.remote? # false -You can preserve aspect ratio the same way as when using transcode. +media.streams # [FFMPEG::Stream, FFMPEG::Stream] +media.video_streams # [FFMPEG::Stream] +media.video_streams? # true (tells you if the media has video streams or not) +media.default_video_stream # FFMPEG::Stream +media.audio_streams # [FFMPEG::Stream] +media.audio_streams? # true (tells you if the media has audio streams or not) +media.default_audio_stream # FFMPEG::Stream +media.video? # true (tells you if the media has a movie stream – non-attached-picture) +media.audio? # false (tells you if the media is an audio file – based on the streams) -```ruby -media.screenshot('path/to/new_screenshot.png', { seek_time: 2, resolution: '200x120' }, preserve_aspect_ratio: :width) +media.rotation # 90 (rotation of the default video stream in degrees) +media.raw_width # 480 (the reported width of the default video stream in pixels) +media.raw_height # 640 (the reported height of the default video stream in pixels) +media.width # 640 (width of the default video stream in pixels) +media.height # 480 (height of the default video stream in pixels) ``` -### Create a Slideshow from Stills -Creating a slideshow from stills uses named sequences of files and stiches the result together in a slideshow -video. - -Since there is no media to transcode, the Transcoder class needs to be used. - -```ruby -slideshow_transcoder = FFMPEG::Transcoder.new( - 'img_%03d.jpeg', - 'slideshow.mp4', - { resolution: '320x240' }, - input_options: { framerate: '1/5' } -) - -slideshow = slideshow_transcoder.run -# slideshow is a Movie object -``` +For the full list of attributes available on the `FFMPEG::Media` object, +see the [source code](https://github.com/instructure/ruby-ffmpeg/blob/main/lib/ffmpeg/media.rb). -Specify the path to ffmpeg --------------------------- +### Transcoding -By default, the gem assumes that the ffmpeg binary is available in the execution path and named ffmpeg and so will run commands that look something like `ffmpeg -i /path/to/input.file ...`. Use the FFMPEG.ffmpeg_binary setter to specify the full path to the binary if necessary: +The two core components of the transcoding process are the `FFMPEG::Preset` and `FFMPEG::Transcoder` classes. + +#### The `FFMPEG::Preset` class +```ruby +preset = FFMPEG::Preset.new( + name: 'My Preset', + filename: '%s.mp4', + metadata: { + some_important_metadata_for_you: 'foo' + } +) do + # This block sets up the output arguments of the ffmpeg command. + # It uses a DSL to define the arguments in a more human-readable way. + # The methods for the DSL are defined in the FFMPEG::RawCommandArgs and + # FFMPEG::CommandArgs classes. + + video_codec_name 'libx264' # -c:v libx264 + audio_codec_name 'aac' # -c:a aac + + # media is the FFMPEG::Media object used as input for the transcoding process + map media.video_mapping_id do # -map v:0 + filter FFMPEG::Filters.scale(width: -2, height: 360) # -vf scale=w=-2:h=360 + constant_rate_factor 22 # -crf 22 + frame_rate 30 # -r 30 + end + + # media is the FFMPEG::Media object used as input for the transcoding process + map media.audio_mapping_id do # -map a:0 + audio_bit_rate 128000 # -b:a 128k + end +end +``` + +#### The `FFMPEG::Transcoder` class +```ruby +transcoder = FFMPEG::Transcoder.new( + name: 'My Transcoder', + metadata: { + some_important_metadata_for_you: 'bar' + }, + # You can pass multiple presets to the transcoder + # and they will be processed in a single ffmpeg command + # to optimize the transcoding process. + presets: [preset], + # The reporters are used to generate reports during the transcoding process. + reporters: [FFMPEG::Reporters::Progress, FFMPEG::Reporters::Silence] +) do + # This block sets up the input arguments of the ffmpeg command. + # It uses the same DSL to define the arguments as the preset does for the output arguments. + # The methods for the DSL are defined in the FFMPEG::RawCommandArgs and + # FFMPEG::CommandArgs classes. + + # media is the FFMPEG::Media object used as input for the transcoding process + if media.rotated? + raw_arg '-noautorotate' # -noautorotate + end +end + +status = transcoder.process(media, '/path/to/output') do |report| + # This block is called for each report generated by the reporters + case report.class + when FFMPEG::Reporters::Progress + puts "Progress: #{report.time}" + when FFMPEG::Reporters::Silence + puts "Silence: #{report.duration}" + else + # FFMPEG::Reporters::Output + end +end + +status.success? # true (would be false if ffmpeg fails to transcode the media) +status.exitstatus # 0 (the exit status of the ffmpeg command) +status.paths # ['/path/to/output.mp4'] (the paths of the output files) +status.media # [FFMPEG::Media] (the media objects of the output files) +``` + +### Presets + +The gem comes with a few presets out of the box. + +You can find them in the `lib/ffmpeg/presets` directory. + +### Customization + +By default, the gem assumes that the ffmpeg and ffprobe binaries are available in the execution path (named ffmpeg and ffprobe) +and so will run commands that look something like `ffmpeg -i /path/to/input.file ...`. +Use the below setters to specify the full path to the binaries if necessary: ```ruby FFMPEG.ffmpeg_binary = '/usr/local/bin/ffmpeg' +FFMPEG.ffprobe_binary = '/usr/local/bin/ffprobe' ``` -This will cause the same command to run as `/usr/local/bin/ffmpeg -i /path/to/input.file ...` instead. - +--- -Automatically kill hung processes ---------------------------------- - -By default, the gem will wait for 30 seconds between IO feedback from the FFMPEG process. After which an error is logged and the process killed. +By default, the gem will wait for 30 seconds between IO feedback from the FFMPEG process. +After which an error is raised and the process killed. It is possible to modify this behaviour by setting a new default: ```ruby -# Change the timeout -Transcoder.timeout = 10 +# Change the IO timeout +FFMPEG.io_timeout = 10 -# Disable the timeout altogether -Transcoder.timeout = false +# Disable the IO timeout altogether +FFMPEG.io_timeout = nil ``` -Disabling output file validation ------------------------------- - -By default Transcoder validates the output file, in case you use FFMPEG for HLS -format that creates multiple outputs you can disable the validation by passing -`validate: false` in the transcoder kwargs. +--- -Note that transcode will not return the encoded media object in this case since -attempting to open a (possibly) invalid output file might result in an error being raised. +The gem uses a logger to output debug information. +By default, it will log to STDOUT, but you can change this behaviour by setting a new logger: ```ruby -kwargs = { validate: false } -media.transcode('movie.mp4', options, **kwargs) # returns nil +FFMPEG.logger = Logger.new('path/to/logfile.log') ``` Copyright diff --git a/ffmpeg.gemspec b/ffmpeg.gemspec index 6d593a4..33eb0e3 100644 --- a/ffmpeg.gemspec +++ b/ffmpeg.gemspec @@ -13,7 +13,7 @@ Gem::Specification.new do |s| s.homepage = 'https://github.com/instructure/ffmpeg' s.summary = 'Wraps ffmpeg to read metadata and transcodes videos.' - s.required_ruby_version = '>= 3.0' + s.required_ruby_version = '>= 3.1' s.add_dependency('multi_json', '~> 1.8') diff --git a/lib/ffmpeg.rb b/lib/ffmpeg.rb index 983980d..e9b4ff7 100644 --- a/lib/ffmpeg.rb +++ b/lib/ffmpeg.rb @@ -3,27 +3,37 @@ $LOAD_PATH.unshift File.dirname(__FILE__) require 'logger' -require 'net/http' require 'open3' -require 'uri' -require_relative 'ffmpeg/version' -require_relative 'ffmpeg/encoding_options' +require_relative 'ffmpeg/command_args' require_relative 'ffmpeg/errors' -require_relative 'ffmpeg/timeout' +require_relative 'ffmpeg/filter' +require_relative 'ffmpeg/filters/fps' +require_relative 'ffmpeg/filters/grayscale' +require_relative 'ffmpeg/filters/scale' +require_relative 'ffmpeg/filters/silence_detect' +require_relative 'ffmpeg/filters/split' require_relative 'ffmpeg/io' require_relative 'ffmpeg/media' -require_relative 'ffmpeg/stream' +require_relative 'ffmpeg/preset' +require_relative 'ffmpeg/presets/aac' +require_relative 'ffmpeg/presets/dash' +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/transcoder' -require_relative 'ffmpeg/filters/filter' -require_relative 'ffmpeg/filters/grayscale' -require_relative 'ffmpeg/filters/silence_detect' +require_relative 'ffmpeg/version' 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. ' \ + 'Warning: ffmpeg is missing the win32-process gem to properly handle hanging transcodings. ' \ 'Install the gem (in Gemfile if using bundler) to avoid errors.' end end @@ -31,182 +41,180 @@ # The FFMPEG module allows you to customise the behaviour of the FFMPEG library. # # @example +# FFMPEG.logger = Logger.new($stdout) +# FFMPEG.io_timeout = 60 # FFMPEG.ffmpeg_binary = '/usr/local/bin/ffmpeg' # 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. - # - # @param [Logger] log your own logger - # @return [Logger] the logger you set - def self.logger=(log) - @logger = log - end - - # Get FFMPEG logger. - # - # @return [Logger] - def self.logger - return @logger if @logger - - logger = Logger.new($stdout) - logger.level = Logger::INFO - @logger = logger - end + class << self + attr_writer :logger, :io_timeout - # Set the path of the ffmpeg binary. - # Can be useful if you need to specify a path such as /usr/local/bin/ffmpeg - # - # @param [String] path to the ffmpeg binary - # @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) - - @ffmpeg_binary = bin - end - - # Get the path to the ffmpeg binary, defaulting to 'ffmpeg' - # - # @return [String] the path to the ffmpeg binary - # @raise Errno::ENOENT if the ffmpeg binary cannot be found - 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.encode!(stdout) - FFMPEG::IO.encode!(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) + # Get the FFMPEG logger. + # + # @return [Logger] + def logger + @logger ||= Logger.new($stdout, level: Logger::INFO) end - end - # Get the path to the ffprobe binary, defaulting to what is on ENV['PATH'] - # - # @return [String] the path to the ffprobe binary - # @raise Errno::ENOENT if the ffprobe binary cannot be found - def self.ffprobe_binary - @ffprobe_binary ||= which('ffprobe') - end + # Get the timeout that's used when waiting for ffmpeg output. + # This timeout is used by ffmpeg_execute calls and the Transcoder class. + # Defaults to 30 seconds. + # + # @return [Integer] + def io_timeout + return @io_timeout if defined?(@io_timeout) - # Set the path of the ffprobe binary. - # Can be useful if you need to specify a path such as /usr/local/bin/ffprobe - # - # @param [String] path to the ffprobe binary - # @return [String] the path you set - # @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" + @io_timeout = 30 end - @ffprobe_binary = bin - end + # Set the path to the ffmpeg binary. + # + # @param path [String] + # @return [String] + # @raise [Errno::ENOENT] If the ffmpeg binary is not an executable. + def ffmpeg_binary=(path) + if path.is_a?(String) && !File.executable?(path) + raise Errno::ENOENT, + "The ffmpeg binary, '#{path}', is not executable" + 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.encode!(stdout) - FFMPEG::IO.encode!(stderr) - [stdout, stderr, status] - end + @ffmpeg_binary = path + 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) + # Get the path to the ffmpeg binary. + # Defaults to the first ffmpeg binary found in the PATH. + # + # @return [String] + def ffmpeg_binary + @ffmpeg_binary ||= which('ffmpeg') end - end - # Get the maximum number of http redirect attempts - # - # @return [Integer] the maximum number of retries - def self.max_http_redirect_attempts - @max_http_redirect_attempts.nil? ? 10 : @max_http_redirect_attempts - end + # Safely captures the standard output and the standard error of the ffmpeg command. + # + # @return [Array] The standard output, the standard error, and the process status. + def ffmpeg_capture3(*args) + logger.debug(self) { "ffmpeg -y #{args.join(' ')}" } + stdout, stderr, status = Open3.capture3(ffmpeg_binary, '-y', *args) + FFMPEG::IO.encode!(stdout) + FFMPEG::IO.encode!(stderr) + [stdout, stderr, status] + end - # Set the maximum number of http redirect attempts. - # - # @param [Integer] the maximum number of retries - # @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) - if value && !value.is_a?(Integer) - raise ArgumentError, 'Unknown max_http_redirect_attempts format, must be an Integer' + # Starts a new ffmpeg process with the given arguments. + # Yields the standard input, the standard output + # and the standard error streams, as well as the child process + # to the specified block. + # + # @yieldparam stdin (+IO+) The standard input stream. + # @yieldparam stdout (+FFMPEG::IO+) The standard output stream. + # @yieldparam stderr (+FFMPEG::IO+) The standard error stream. + # @yieldparam wait_thr (+Thread+) The child process thread. + # @return [void] + def ffmpeg_popen3(*args, &block) + logger.debug(self) { "ffmpeg -y #{args.join(' ')}" } + Open3.popen3(ffmpeg_binary, '-y', *args) do |stdin, stdout, stderr, wait_thr| + block.call(stdin, FFMPEG::IO.new(stdout), FFMPEG::IO.new(stderr), wait_thr) + rescue StandardError + wait_thr.kill + wait_thr.join + raise + end end - raise ArgumentError, 'Invalid max_http_redirect_attempts format, may not be negative' if value&.negative? - @max_http_redirect_attempts = value - end + # Execute a ffmpeg command. + # + # @param args [Array] The arguments to pass to ffmpeg. + # @param reporters [Array] 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 do |line| + next unless block_given? + + reporter = reporters.find { |r| r.match?(line) } + reporter ||= Reporters::Output + report = reporter.new(line) + yield report + end + + wait_thr.value + end + 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 + # Get the path to the ffprobe binary. + # Defaults to the first ffprobe binary found in the PATH. + # + # @return [String] The path to the ffprobe binary. + # @raise [Errno::ENOENT] If the ffprobe binary cannot be found. + def ffprobe_binary + @ffprobe_binary ||= which('ffprobe') + end - conn = Net::HTTP.new(uri.host, uri.port) - conn.use_ssl = uri.port == 443 - response = conn.request_head(uri.request_uri) + # Set the path of the ffprobe binary. + # Can be useful if you need to specify a path such as /usr/local/bin/ffprobe. + # + # @param [String] path + # @return [String] + # @raise [Errno::ENOENT] If the ffprobe binary is not an executable. + def ffprobe_binary=(path) + if path.is_a?(String) && !File.executable?(path) + raise Errno::ENOENT, "The ffprobe binary, '#{path}', is not executable" + end - case response - when Net::HTTPRedirection - raise HTTPTooManyRedirects if max_redirect_attempts.zero? + @ffprobe_binary = path + end - redirect_uri = uri + URI(response.header['Location']) + # Safely captures the standard output and the standard error of the ffmpeg command. + # + # @return [Array] The standard output, the standard error, and the process status. + # @raise [Errno::ENOENT] If the ffprobe binary cannot be found. + def ffprobe_capture3(*args) + logger.debug(self) { "ffprobe -y #{args.join(' ')}" } + stdout, stderr, status = Open3.capture3(ffprobe_binary, '-y', *args) + FFMPEG::IO.encode!(stdout) + FFMPEG::IO.encode!(stderr) + [stdout, stderr, status] + end - fetch_http_head(redirect_uri, max_redirect_attempts - 1) - else - response + # Starts a new ffprobe process with the given arguments. + # Yields the standard input, the standard output + # and the standard error streams, as well as the child process + # to the specified block. + # + # @yieldparam stdin (+IO+) The standard input stream. + # @yieldparam stdout (+FFMPEG::IO+) The standard output stream. + # @yieldparam stderr (+FFMPEG::IO+) The standard error stream. + # @return [void] + # @raise [Errno::ENOENT] If the ffprobe binary cannot be found. + def ffprobe_popen3(*args, &block) + logger.debug(self) { "ffprobe -y #{args.join(' ')}" } + Open3.popen3(ffprobe_binary, '-y', *args) do |stdin, stdout, stderr, wait_thr| + block.call(stdin, FFMPEG::IO.new(stdout), FFMPEG::IO.new(stderr), wait_thr) + rescue StandardError + wait_thr.kill + wait_thr.join + raise + end end - rescue SocketError, Errno::ECONNREFUSED - nil - end - # Cross-platform way of finding an executable in the $PATH. - # - # which('ruby') #=> /usr/bin/ruby - # see: http://stackoverflow.com/questions/2108727/which-in-ruby-checking-if-program-exists-in-path-from-ruby - def self.which(cmd) - exts = ENV['PATHEXT'] ? ENV['PATHEXT'].split(';') : [''] - ENV['PATH'].split(File::PATH_SEPARATOR).each do |path| - exts.each do |ext| - exe = File.join(path, "#{cmd}#{ext}") - return exe if File.executable? exe + # Cross-platform way of finding an executable in the $PATH. + # See http://stackoverflow.com/questions/2108727/which-in-ruby-checking-if-program-exists-in-path-from-ruby + # + # @example + # which('ruby') #=> /usr/bin/ruby + def which(cmd) + exts = ENV['PATHEXT'] ? ENV['PATHEXT'].split(';') : [''] + ENV['PATH'].split(File::PATH_SEPARATOR).each do |path| + exts.each do |ext| + match = File.join(path, "#{cmd}#{ext}") + return match if File.executable?(match) + end end + + raise Errno::ENOENT, "The #{cmd} binary could not be found in the PATH" end - raise Errno::ENOENT, "The #{cmd} binary could not be found in #{ENV.fetch('PATH', nil)}" end end diff --git a/lib/ffmpeg/command_args.rb b/lib/ffmpeg/command_args.rb new file mode 100644 index 0000000..f91c957 --- /dev/null +++ b/lib/ffmpeg/command_args.rb @@ -0,0 +1,154 @@ +# frozen_string_literal: true + +require_relative 'raw_command_args' + +module FFMPEG + # A helper class for composing command arguments for FFMPEG. + # It provides a DSL for setting arguments based on media properties. + # + # @example + # args = FFMPEG::CommandArgs.compose(media) do + # map media.video_mapping_id do + # video_codec_name 'libx264' + # frame_rate 30 + # end + # end + # args.to_s # "-map 0:v:0 -c:v:0 libx264 -r 30" + class CommandArgs < RawCommandArgs + class << self + # Composes a new instance of CommandArgs with the given media. + # The block is evaluated in the context of the new instance. + # + # @param media [FFMPEG::Media] The media to transcode. + # @return [FFMPEG::CommandArgs] The new FFMPEG::CommandArgs object. + def compose(media, &block) + new(media).tap do |args| + args.instance_exec(&block) if block_given? + end + end + end + + attr_reader :media + + # @param media [FFMPEG::Media] The media to transcode. + def initialize(media) + @media = media + super() + end + + # Sets the frame rate to the minimum of the current frame rate and the target value. + # + # @param target_value [Integer, Float] The target frame rate. + # @return [self] + def frame_rate(target_value) + return self if target_value.nil? + + super(adjusted_frame_rate(target_value)) + end + + # Sets the video bit rate to the minimum of the current video bit rate and the target value. + # The target value can be an Integer or a String (e.g.: 128k or 1M). + # + # @param target_value [Integer, String] The target bit rate. + # @param kwargs [Hash] Additional options (see FFMPEG::RawCommandArgs#video_bit_rate). + # @return [self] + def video_bit_rate(target_value, **kwargs) + return self if target_value.nil? + + super(adjusted_video_bit_rate(target_value), **kwargs) + end + + # Sets the audio bit rate to the minimum of the current audio bit rate and the target value. + # The target value can be an Integer or a String (e.g.: 128k or 1M). + # + # @param target_value [Integer, String] The target bit rate. + # @return [self] + def min_video_bit_rate(target_value) + return self if target_value.nil? + + super(adjusted_video_bit_rate(target_value)) + end + + # Sets the audio bit rate to the minimum of the current audio bit rate and the target value. + # The target value can be an Integer or a String (e.g.: 128k or 1M). + # + # @param target_value [Integer, String] The target bit rate. + # @return [self] + def max_video_bit_rate(target_value) + return self if target_value.nil? + + super(adjusted_video_bit_rate(target_value)) + end + + # Sets the audio bit rate to the minimum of the current audio bit rate and the target value. + # The target value can be an Integer or a String (e.g.: 128k or 1M). + # + # @param target_value [Integer, String] The target bit rate + # @return [self] + def audio_bit_rate(target_value, **kwargs) + return self if target_value.nil? + + super(adjusted_audio_bit_rate(target_value), **kwargs) + end + + # Returns the minimum of the current frame rate and the target value. + # + # @param target_value [Integer, Float] The target frame rate. + # @return [Numeric] + def adjusted_frame_rate(target_value) + [media.frame_rate, target_value].min + end + + # Returns the minimum of the current video bit rate and the target value. + # The target value can be an Integer or a String (e.g.: 128k or 1M). + # The result is a String with the value in kilobits. + # + # @param target_value [Integer, String] The target video bit rate. + # @return [String] + def adjusted_video_bit_rate(target_value) + min_bit_rate(media.video_bit_rate, target_value) + end + + # Returns the minimum of the current audio bit rate and the target value. + # The target value can be an Integer or a String (e.g.: 128k or 1M). + # The result is a String with the value in kilobits. + # + # @param target_value [Integer, String] The target audio bit rate. + # @return [String] + def adjusted_audio_bit_rate(target_value) + min_bit_rate(media.audio_bit_rate, target_value) + end + + private + + def min_bit_rate(*values) + bit_rate = + values.map do |value| + next value if value.is_a?(Integer) + + unless value.is_a?(String) + raise ArgumentError, + "Unknown bit rate format #{value.class}, expected #{Integer} or #{String}" + end + + match = value.match(/\A([1-9]\d*)([kM])\z/) + unless match + raise ArgumentError, + "Unknown bit rate format #{value}, expected [1-9]\\d*[kM]" + end + + value = match[1].to_i + case match[2] + when 'k' + value * 1_000 + when 'M' + value * 1_000_000 + else + value + end + end.min + + "#{(bit_rate.to_f / 1000).round}k" + end + end +end diff --git a/lib/ffmpeg/encoding_options.rb b/lib/ffmpeg/encoding_options.rb deleted file mode 100644 index f08d8c3..0000000 --- a/lib/ffmpeg/encoding_options.rb +++ /dev/null @@ -1,212 +0,0 @@ -# frozen_string_literal: true - -module FFMPEG - # Encoding options for the ffmpeg command - # in the form of a hash. - class EncodingOptions < Hash - def initialize(options = {}) - super(nil) - merge!(options) - end - - def to_a - ary = [] - - # codecs should go before the presets so that the files will be matched successfully - # all other parameters go after so that we can override whatever is in the preset - keys.sort_by(&method(:params_order)).each do |key| - value = self[key] - chunk = send("convert_#{key}", value) if value && supports_option?(key) - ary += chunk unless chunk.nil? - end - - ary += convert_aspect(calculate_aspect) if calculate_aspect? - ary.map(&:to_s) - end - - def width - self[:resolution].split('x').first.to_i - rescue StandardError - nil - end - - def height - self[:resolution].split('x').last.to_i - rescue StandardError - nil - end - - private - - def params_order(key) - case key - when /watermark$/ - 0 - when /watermark/ - 1 - when /codec/ - 2 - when /preset/ - 3 - else - 4 - end - end - - def supports_option?(option) - method = RUBY_VERSION < '1.9' ? "convert_#{option}" : :"convert_#{option}" - private_methods.include?(method) - end - - def convert_aspect(value) - ['-aspect', value] - end - - def calculate_aspect - width, height = self[:resolution].split('x') - width.to_f / height.to_f - end - - def calculate_aspect? - self[:aspect].nil? && self[:resolution] - end - - def convert_video_codec(value) - ['-vcodec', value] - end - - def convert_frame_rate(value) - ['-r', value] - end - - def convert_resolution(value) - ['-s', value] - end - - def convert_video_bitrate(value) - ['-b:v', k_format(value)] - end - - def convert_audio_codec(value) - ['-acodec', value] - end - - def convert_audio_bitrate(value) - ['-b:a', k_format(value)] - end - - def convert_audio_sample_rate(value) - ['-ar', value] - end - - def convert_audio_channels(value) - ['-ac', value] - end - - def convert_video_max_bitrate(value) - ['-maxrate', k_format(value)] - end - - def convert_video_min_bitrate(value) - ['-minrate', k_format(value)] - end - - def convert_buffer_size(value) - ['-bufsize', k_format(value)] - end - - def convert_video_bitrate_tolerance(value) - ['-bt', k_format(value)] - end - - def convert_threads(value) - ['-threads', value] - end - - def convert_target(value) - ['-target', value] - end - - def convert_duration(value) - ['-t', value] - end - - def convert_video_preset(value) - ['-vpre', value] - end - - def convert_audio_preset(value) - ['-apre', value] - end - - def convert_file_preset(value) - ['-fpre', value] - end - - def convert_keyframe_interval(value) - ['-g', value] - end - - def convert_seek_time(value) - ['-ss', value] - end - - def convert_screenshot(value) - result = [] - unless self[:vframes] - result << '-vframes' - result << 1 - end - result << '-f' - result << 'image2' - value ? result : [] - end - - def convert_quality(value) - ['-q:v', value] - end - - def convert_vframes(value) - ['-vframes', value] - end - - def convert_x264_vprofile(value) - ['-vprofile', value] - end - - def convert_x264_preset(value) - ['-preset', value] - end - - def convert_watermark(value) - ['-i', value] - end - - def convert_watermark_filter(value) - position = value[:position] - padding_x = value[:padding_x] || 10 - padding_y = value[:padding_y] || 10 - case position.to_s - when 'LT' - ['-filter_complex', "scale=#{self[:resolution]},overlay=x=#{padding_x}:y=#{padding_y}"] - when 'RT' - ['-filter_complex', "scale=#{self[:resolution]},overlay=x=main_w-overlay_w-#{padding_x}:y=#{padding_y}"] - when 'LB' - ['-filter_complex', "scale=#{self[:resolution]},overlay=x=#{padding_x}:y=main_h-overlay_h-#{padding_y}"] - when 'RB' - ['-filter_complex', - "scale=#{self[:resolution]},overlay=x=main_w-overlay_w-#{padding_x}:y=main_h-overlay_h-#{padding_y}"] - end - end - - def convert_custom(value) - raise ArgumentError unless value.class <= Array - - value - end - - def k_format(value) - value.to_s.include?('k') ? value : "#{value}k" - end - end -end diff --git a/lib/ffmpeg/errors.rb b/lib/ffmpeg/errors.rb index f0b1f9e..7b0e66a 100644 --- a/lib/ffmpeg/errors.rb +++ b/lib/ffmpeg/errors.rb @@ -1,9 +1,5 @@ # frozen_string_literal: true module FFMPEG - class Error < StandardError - end - - class HTTPTooManyRedirects < StandardError - end + class Error < StandardError; end end diff --git a/lib/ffmpeg/filter.rb b/lib/ffmpeg/filter.rb new file mode 100644 index 0000000..1044d40 --- /dev/null +++ b/lib/ffmpeg/filter.rb @@ -0,0 +1,202 @@ +# frozen_string_literal: true + +require_relative 'command_args' + +module FFMPEG + # The Filter class represents a ffmpeg filter + # that can be applied to a stream. + # + # @example + # filter = FFMPEG::Filter.new(:video, 'scale', w: -2, h: 720) + # filter.to_s # => "scale=w=-2:h=720" + # filter.with_input_link('0:v').with_output_link('v0').to_s # => "[0:v]scale=w=-2:h=720[v0]" + class Filter + class << self + # Join the filters together into a filter chain + # that can be applied to a stream. + # + # @param filters [Array] The filters to join. + # @return [String] The filter chain. + def join(*filters) + filters.compact.map(&:to_s).join(',') + end + end + + attr_reader :type, :name, :kwargs, :input_links, :output_links + + # @param type [Symbol] The type of the filter (must be one of :video or :audio). + # @param name [String] The name of the filter (e.g.: 'scale', 'volume'). + # @param kwargs [Hash] The keyword arguments to use for the filter. + def initialize(type, name, **kwargs) + raise ArgumentError, "Unknown type #{type}, expected :video or :audio" unless %i[audio video].include?(type) + raise ArgumentError, "Unknown name format #{name.class}, expected #{String}" unless name.is_a?(String) + + @type = type + @name = name + + kwargs = kwargs.compact + @kwargs = kwargs unless kwargs.empty? + + @input_links = [] + @output_links = [] + end + + # Clone the filter. + def clone + super.tap do |filter| + filter.instance_variable_set(:@input_links, @input_links.clone) + filter.instance_variable_set(:@output_links, @output_links.clone) + end + end + + # Convert the filter to a string. + # + # @return [String] The filter as a string. + def to_s + [ + format_input_links, + [@name, format_kwargs].reject(&:empty?).join('='), + format_output_links + ].join + end + + # Clone the filter with the specified input links. + # + # @param stream_ids [Array] The stream IDs to use as input links. + # @return [Filter] The cloned filter. + # + # @example + # filter = FFMPEG::Filter.new(:audio, 'silencedetect') + # filter.with_input_links('0:a:0').to_s # => "[0:a:0]silencedetect" + def with_input_links(*stream_ids) + clone.with_input_links!(*stream_ids) + end + + # Set the specified input links on the filter. + # This will replace any existing input links. + # + # @param stream_ids [Array] The stream IDs to use as input links. + # @return [self] + # + # @example + # filter = FFMPEG::Filter.new(:audio, 'silencedetect') + # filter.with_input_links!('0:a:0') + # filter.to_s # => "[0:a:0]silencedetect" + def with_input_links!(*stream_ids) + @input_links = [] + stream_ids.each(&method(:with_input_link!)) + self + end + + # Clone the filter with the specified input link. + # This will add the input link to the existing input links. + # + # @param stream_id [String] The stream ID to use as input link. + # @return [Filter] The cloned filter. + # + # @example + # filter = FFMPEG::Filter.new(:audio, 'silencedetect') + # filter.with_input_link('0:a:0').to_s # => "[0:a:0]silencedetect" + def with_input_link(stream_id) + clone.with_input_link!(stream_id) + end + + # Add the specified input link to the filter. + # + # @param stream_id [String] The stream ID to use as input link. + # @return [self] + # + # @example + # filter = FFMPEG::Filter.new(:audio, 'silencedetect') + # filter.with_input_link!('0:a:0') + # filter.to_s # => "[0:a:0]silencedetect" + def with_input_link!(stream_id) + unless stream_id.is_a?(String) + raise ArgumentError, + "Unknown stream_id format #{stream_id.class}, expected #{String}" + end + + @input_links << stream_id + self + end + + # Clone the filter with the specified output links. + # This will replace any existing output links. + # + # @param stream_ids [Array] The stream IDs to use as output links. + # @return [Filter] The cloned filter. + # + # @example + # filter = FFMPEG::Filter.new(:audio, 'silencedetect') + # filter.with_output_links('a0', 'a1').to_s # => "silencedetect[a0][a1]" + def with_output_links(*stream_ids) + clone.with_output_links!(*stream_ids) + end + + # Set the specified output links on the filter. + # This will replace any existing output links. + # + # @param stream_ids [Array] The stream IDs to use as output links. + # @return [self] + # + # @example + # filter = FFMPEG::Filter.new(:audio, 'silencedetect') + # filter.with_output_links!('a0', 'a1') + # filter.to_s # => "silencedetect[a0][a1]" + def with_output_links!(*stream_ids) + @output_links = [] + stream_ids.each(&method(:with_output_link!)) + self + end + + # Clone the filter with the specified output link. + # This will add the output link to the existing output links. + # + # @param stream_id [String] The stream ID to use as output link. + # @return [Filter] The cloned filter. + # + # @example + # filter = FFMPEG::Filter.new(:audio, 'silencedetect') + # filter.with_output_link('a0').to_s # => "silencedetect[a0]" + def with_output_link(stream_id) + clone.with_output_link!(stream_id) + end + + # Add the specified output link to the filter. + # + # @param stream_id [String] The stream ID to use as output link. + # @return [self] + # + # @example + # filter = FFMPEG::Filter.new(:audio, 'silencedetect') + # filter.with_output_link!('a0') + # filter.to_s # => "silencedetect[a0]" + def with_output_link!(stream_id) + unless stream_id.is_a?(String) + raise ArgumentError, + "Unknown stream_id format #{stream_id.class}, expected #{String}" + end + + @output_links << stream_id + self + end + + protected + + def format_kwargs(kwargs = @kwargs) + CommandArgs.format_kwargs(kwargs) + end + + def format_input_links + format_links(@input_links) + end + + def format_output_links + format_links(@output_links) + end + + def format_links(links) + links.map { |link| "[#{link}]" }.join + end + end +end diff --git a/lib/ffmpeg/filters/filter.rb b/lib/ffmpeg/filters/filter.rb deleted file mode 100644 index 9144f0e..0000000 --- a/lib/ffmpeg/filters/filter.rb +++ /dev/null @@ -1,16 +0,0 @@ -# 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/fps.rb b/lib/ffmpeg/filters/fps.rb new file mode 100644 index 0000000..15be4a5 --- /dev/null +++ b/lib/ffmpeg/filters/fps.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require_relative '../filter' + +module FFMPEG + # rubocop:disable Style/Documentation + module Filters + # rubocop:enable Style/Documentation + + class << self + def fps(frame_rate) + FPS.new(frame_rate) + end + end + + # The FPS class uses the fps filter + # to set the frame rate of a multimedia stream. + class FPS < Filter + attr_reader :frame_rate + + def initialize(frame_rate) + unless frame_rate.is_a?(Numeric) + raise ArgumentError, + "Unknown frame_rate format #{frame_rate.class}, expected #{Numeric}" + end + + @frame_rate = frame_rate + + super(:video, 'fps') + end + + protected + + def format_kwargs + @frame_rate.to_s + end + end + end +end diff --git a/lib/ffmpeg/filters/grayscale.rb b/lib/ffmpeg/filters/grayscale.rb index 10fcc42..e4cf583 100644 --- a/lib/ffmpeg/filters/grayscale.rb +++ b/lib/ffmpeg/filters/grayscale.rb @@ -1,18 +1,23 @@ # frozen_string_literal: true +require_relative '../filter' + module FFMPEG + # rubocop:disable Style/Documentation module Filters - # The Grayscale class uses the format filter - # to convert a multimedia file to grayscale. - class Grayscale - include Filter + # rubocop:enable Style/Documentation - def to_s - 'format=gray' + class << self + def grayscale + Grayscale.new end + end - def to_a - ['-vf', to_s] + # The Grayscale class uses the format filter + # to convert a multimedia stream to grayscale. + class Grayscale < Filter + def initialize + super(:video, 'format', pix_fmts: ['gray']) end end end diff --git a/lib/ffmpeg/filters/scale.rb b/lib/ffmpeg/filters/scale.rb new file mode 100644 index 0000000..f6b4541 --- /dev/null +++ b/lib/ffmpeg/filters/scale.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +require_relative '../filter' + +module FFMPEG + # rubocop:disable Style/Documentation + module Filters + # rubocop:enable Style/Documentation + + class << self + def scale(width: nil, height: nil, force_original_aspect_ratio: nil, flags: nil) + Scale.new(width:, height:, force_original_aspect_ratio:, flags:) + end + end + + # The Scale class uses the scale filter + # to resize a multimedia stream. + class Scale < Filter + NEAREST_DIMENSION = -1 + NEAREST_EVEN_DIMENSION = -2 + + class << self + # Returns a scale filter that fits the specified media + # within the specified maximum width and height, + # keeping the original aspect ratio. + # + # @param media [FFMPEG::Media] The media to fit. + # @param max_width [Numeric] The maximum width to fit. + # @param max_height [Numeric] The maximum height to fit. + # @return [FFMPEG::Filters::Scale] The scale filter. + def contained(media, max_width: nil, max_height: nil) + unless media.is_a?(FFMPEG::Media) + raise ArgumentError, + "Unknown media format #{media.class}, expected #{FFMPEG::Media}" + end + + if max_width && !max_width.is_a?(Numeric) + raise ArgumentError, + "Unknown max_width format #{max_width.class}, expected #{Numeric}" + end + + if max_height && !max_height.is_a?(Numeric) + raise ArgumentError, + "Unknown max_height format #{max_height.class}, expected #{Numeric}" + end + + return unless max_width || max_height + + if media.rotated? + width = max_height || NEAREST_EVEN_DIMENSION + height = max_width || NEAREST_EVEN_DIMENSION + else + width = max_width || NEAREST_EVEN_DIMENSION + height = max_height || NEAREST_EVEN_DIMENSION + end + + if width.negative? || height.negative? + Filters.scale(width:, height:) + elsif media.calculated_aspect_ratio > Rational(width, height) + Filters.scale(width:, height: -2) + else + Filters.scale(width: -2, height:) + end + end + end + + attr_reader :width, :height, :force_original_aspect_ratio, :flags + + def initialize(width: nil, height: nil, force_original_aspect_ratio: nil, flags: nil) + if !width.nil? && !width.is_a?(Numeric) && !width.is_a?(String) + raise ArgumentError, "Unknown width format #{width.class}, expected #{Numeric} or #{String}" + end + + if !height.nil? && !height.is_a?(Numeric) && !height.is_a?(String) + raise ArgumentError, "Unknown height format #{height.class}, expected #{Numeric} or #{String}" + end + + if !force_original_aspect_ratio.nil? && !force_original_aspect_ratio.is_a?(String) + raise ArgumentError, + "Unknown force_original_aspect_ratio format #{force_original_aspect_ratio.class}, expected #{String}" + end + + if !flags.nil? && !flags.is_a?(Array) + raise ArgumentError, "Unknown flags format #{flags.class}, expected #{Array}" + end + + @width = width + @height = height + @force_original_aspect_ratio = force_original_aspect_ratio + @flags = flags + + super(:video, 'scale') + end + + protected + + def format_kwargs + super( + w: @width, + h: @height, + force_original_aspect_ratio: @force_original_aspect_ratio, + flags: @flags + ) + end + end + end +end diff --git a/lib/ffmpeg/filters/silence_detect.rb b/lib/ffmpeg/filters/silence_detect.rb index 2bbfef0..73318f8 100644 --- a/lib/ffmpeg/filters/silence_detect.rb +++ b/lib/ffmpeg/filters/silence_detect.rb @@ -1,54 +1,47 @@ # frozen_string_literal: true +require_relative '../filter' + module FFMPEG + # rubocop:disable Style/Documentation 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) + # rubocop:enable Style/Documentation - include Filter + class << self + def silence_detect(threshold: nil, duration: nil, mono: nil) + SilenceDetect.new(threshold:, duration:, mono:) + end + end + # The SilenceDetect class is uses the silencedetect filter + # to detect silent parts in a multimedia stream. + class SilenceDetect < Filter attr_reader :threshold, :duration, :mono - def initialize(threshold: nil, duration: nil, mono: false) + def initialize(threshold: nil, duration: nil, mono: nil) if !threshold.nil? && !threshold.is_a?(Numeric) && !threshold.is_a?(String) - raise ArgumentError, 'Unknown threshold format, should be either Numeric or String' + raise ArgumentError, "Unknown threshold format #{threshold.class}, expected #{Numeric} or #{String}" + end + + if !duration.nil? && !duration.is_a?(Numeric) + raise ArgumentError, "Unknown duration format #{duration.class}, expected #{Numeric}" end - raise ArgumentError, 'Unknown duration format, should be Numeric' if !duration.nil? && !duration.is_a?(Numeric) + if !mono.nil? && !mono.is_a?(TrueClass) && !mono.is_a?(FalseClass) + raise ArgumentError, "Unknown mono format #{mono.class}, expected #{TrueClass} or #{FalseClass}" + end @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 + super(:audio, 'silencedetect') 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 + protected - def scan(output) - self.class.scan(output) + def format_kwargs + super(n: @threshold, d: @duration, m: @mono) end end end diff --git a/lib/ffmpeg/filters/split.rb b/lib/ffmpeg/filters/split.rb new file mode 100644 index 0000000..0f834a2 --- /dev/null +++ b/lib/ffmpeg/filters/split.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require_relative '../filter' + +module FFMPEG + # rubocop:disable Style/Documentation + module Filters + # rubocop:enable Style/Documentation + + class << self + def split(output_count) + Split.new(output_count) + end + end + + # The Split class uses the split filter + # to split a multimedia stream into multiple outputs. + class Split < Filter + attr_reader :output_count + + def initialize(output_count = nil) + unless output_count.nil? || output_count.is_a?(Integer) + raise ArgumentError, + "Unknown output_count format #{output_count.class}, expected #{Integer}" + end + + @output_count = output_count if output_count + + super(:video, 'split') + end + + protected + + def format_kwargs + @output_count.to_s + end + end + end +end diff --git a/lib/ffmpeg/io.rb b/lib/ffmpeg/io.rb index 1b95ef3..689a0ee 100644 --- a/lib/ffmpeg/io.rb +++ b/lib/ffmpeg/io.rb @@ -3,6 +3,8 @@ require 'English' require 'timeout' +require_relative 'timeout' + module FFMPEG # The IO class is a simple wrapper around IO objects that adds a timeout # to all read operations and fixes encoding issues. @@ -17,6 +19,7 @@ def self.encode!(chunk) def initialize(target) @target = target + @timeout = FFMPEG.io_timeout end def each(&block) diff --git a/lib/ffmpeg/media.rb b/lib/ffmpeg/media.rb index 98cd638..e0f963f 100644 --- a/lib/ffmpeg/media.rb +++ b/lib/ffmpeg/media.rb @@ -1,272 +1,495 @@ # frozen_string_literal: true require 'multi_json' -require 'net/http' require 'uri' -require 'tempfile' + +require_relative 'errors' +require_relative 'stream' module FFMPEG # The Media class represents a multimedia file and provides methods - # to inspect and transcode it. + # to inspect its metadata. # It accepts a local path or remote URL to a multimedia file as input. # It uses ffprobe to get the streams and format of the multimedia file. + # + # @example + # media = FFMPEG::Media.new('/path/to/media.mp4') + # media.video? # => true + # media.video_streams? # => true + # media.audio? # => false + # media.audio_streams? # => true + # media.local? # => true + # + # @example + # media = FFMPEG::Media.new('https://example.com/media.mp4', load: false) + # media.loaded? # => false + # media.video? # => true (loaded automatically) + # media.loaded? # => true + # media.remote? # => true + # + # @example + # media = FFMPEG::Media.new('/path/to/media.mp4', load: false, autoload: false) + # media.loaded? # => false + # media.video? # => raises 'Media not loaded' + # media.load! + # media.video? # => true class Media - attr_reader :path, :size, :metadata, :streams, :tags, - :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 + class LoadError < FFMPEG::Error; end + + private_class_method def self.autoload(*method_names) + method_names.flatten! + method_names.each do |method_name| + method = instance_method(method_name) + define_method(method_name) do |*args, &block| + if loaded? + method.bind(self).call(*args, &block) + elsif @autoload + load! + method.bind(self).call(*args, &block) + else + raise 'Media not loaded' + end + end + end end - def initialize(path) - @path = path + attr_reader :path - # Check if the file exists and get its size - if remote? - response = FFMPEG.fetch_http_head(@path) + autoload attr_reader :size, :metadata, :streams, :tags, + :format_name, :format_long_name, + :start_time, :bit_rate, :duration - unless response.is_a?(Net::HTTPSuccess) - raise Errno::ENOENT, - "The file at '#{@path}' does not exist or is not available (response code: #{response.code})" - end + # @param path [String] The local path or remote URL to a multimedia file. + # @param ffprobe_args [Array] Additional arguments to pass to ffprobe. + # @param load [Boolean] Whether to load the metadata immediately. + # @param autoload [Boolean] Whether to autoload the metadata when accessing attributes. + def initialize(path, *ffprobe_args, load: true, autoload: true) + @path = path.to_s + @ffprobe_args = ffprobe_args + @autoload = autoload + @loaded = false + @mutex = Mutex.new + load! if load + end - @size = response.content_length - else - raise Errno::ENOENT, "The file at '#{@path}' does not exist" unless File.exist?(@path) + # Load the metadata of the multimedia file. + # + # @return [Boolean] + def load! + @mutex.lock - @size = File.size(@path) - end + return @loaded if @loaded - # Run ffprobe to get the streams and format - stdout, stderr, _status = FFMPEG.ffprobe_capture3( + stdout, stderr, = FFMPEG.ffprobe_capture3( '-i', @path, '-print_format', 'json', - '-show_format', '-show_streams', '-show_error' + '-show_format', '-show_streams', '-show_error', + *@ffprobe_args ) - # Parse ffprobe metadata begin @metadata = MultiJson.load(stdout, symbolize_keys: true) - rescue MultiJson::ParseError - raise "Could not parse output from FFProbe:\n#{stdout}" + rescue MultiJson::ParseError => e + raise LoadError, e.message.capitalize end - if @metadata.key?(:error) || stderr.include?('could not find codec parameters') - @invalid = true - return + if @metadata.key?(:error) + raise LoadError, "#{@metadata[:error][:string].capitalize} (code #{@metadata[:error][:code]})" end - @streams = @metadata[:streams].map { |stream| Stream.new(stream, stderr) } + @size = @metadata[:format][:size].to_i + @streams = @metadata[:streams].map { |metadata| Stream.new(metadata, stderr) } @tags = @metadata[:format][:tags] @format_name = @metadata[:format][:format_name] @format_long_name = @metadata[:format][:format_long_name] @start_time = @metadata[:format][:start_time].to_f - @bitrate = @metadata[:format][:bit_rate].to_i + @bit_rate = @metadata[:format][:bit_rate].to_i @duration = @metadata[:format][:duration].to_f - @invalid = @streams.all?(&:unsupported?) + @valid = @streams.any?(&:supported?) + + @loaded = true + ensure + @mutex.unlock end - def valid? - !@invalid + # Whether the media has been loaded. + # + # @return [Boolean] + def loaded? + @loaded end + # Whether the media is on a remote URL. + # + # @return [Boolean] def remote? @remote ||= @path =~ URI::DEFAULT_PARSER.make_regexp(%w[http https]) ? true : false end + # Whether the media is at a local path. + # + # @return [Boolean] def local? !remote? end - def width - video&.width + # Whether the media is valid (there is at least one stream that is supported). + # + # @return [Boolean] + autoload def valid? + @valid + end + + # Returns all video streams. + # + # @return [Array, nil] + autoload def video_streams + return @video_streams if instance_variable_defined?(:@video_streams) + + @video_streams = @streams.select(&:video?) + end + + # Whether the media has video streams. + # + # @return [Boolean] + autoload def video_streams? + !video_streams.empty? end - def height - video&.height + # Whether the media has a video stream (excluding attached pictures). + # + # @return [Boolean] + autoload def video? + video_streams.any? { |stream| !stream.attached_pic? } end - def rotation - video&.rotation + # Returns the default video stream (if any). + # + # @return [Stream, nil] + autoload def default_video_stream + return @default_video_stream if instance_variable_defined?(:@default_video_stream) + + @default_video_stream = video_streams.find(&:default?) || video_streams.first end - def resolution - video&.resolution + # Whether the media is rotated (based on the default video stream). + # (e.g. 90°, 180°, 270°) + # + # @return [Boolean] + autoload def rotated? + default_video_stream&.rotated? || false end - def display_aspect_ratio - video&.display_aspect_ratio + # Whether the media is portrait (based on the default video stream). + # + # @return [Boolean] + autoload def portrait? + default_video_stream&.portrait? || false end - def sample_aspect_ratio - video&.sample_aspect_ratio + # Whether the media is landscape (based on the default video stream). + # + # @return [Boolean] + autoload def landscape? + default_video_stream&.landscape? || false end - def calculated_aspect_ratio - video&.calculated_aspect_ratio + # Returns the width of the default video stream (if any). + # + # @return [Integer, nil] + autoload def width + default_video_stream&.width end - def calculated_pixel_aspect_ratio - video&.calculated_pixel_aspect_ratio + # Returns the raw (unrotated) width of the default video stream (if any). + # + # @return [Integer, nil] + autoload def raw_width + default_video_stream&.raw_width end - def color_range - video&.color_range + # Returns the height of the default video stream (if any). + # + # @return [Integer, nil] + autoload def height + default_video_stream&.height end - def color_space - video&.color_space + # Returns the raw (unrotated) height of the default video stream (if any). + # + # @return [Integer, nil] + autoload def raw_height + default_video_stream&.raw_height end - def frame_rate - video&.frame_rate + # Returns the rotation of the default video stream (if any). + # + # @return [Integer, nil] + autoload def rotation + default_video_stream&.rotation end - def frames - video&.frames + # Returns the resolution of the default video stream (if any). + # + # @return [String, nil] + autoload def resolution + default_video_stream&.resolution end - def video - @video ||= @streams&.find(&:video?) + # Returns the display aspect ratio of the default video stream (if any). + # + # @return [String, nil] + autoload def display_aspect_ratio + default_video_stream&.display_aspect_ratio end - def video? - !video.nil? + # Returns the sample aspect ratio of the default video stream (if any). + # + # @return [String, nil] + autoload def sample_aspect_ratio + default_video_stream&.sample_aspect_ratio end - def video_only? - video? && !audio? + # Returns the calculated aspect ratio of the default video stream (if any). + # + # @return [String, nil] + autoload def calculated_aspect_ratio + default_video_stream&.calculated_aspect_ratio end - def video_index - video&.index + # Returns the calculated pixel aspect ratio of the default video stream (if any). + # + # @return [String, nil] + autoload def calculated_pixel_aspect_ratio + default_video_stream&.calculated_pixel_aspect_ratio end - def video_profile - video&.profile + # Returns the color range of the default video stream (if any). + # + # @return [String, nil] + autoload def color_range + default_video_stream&.color_range end - def video_codec_name - video&.codec_name + # Returns the color space of the default video stream (if any). + # + # @return [String, nil] + autoload def color_space + default_video_stream&.color_space end - def video_codec_type - video&.codec_type + # Returns the frame rate (avg_frame_rate) of the default video stream (if any). + # + # @return [Float, nil] + autoload def frame_rate + default_video_stream&.frame_rate end - def video_bitrate - video&.bitrate + # Returns the number of frames of the default video stream (if any). + # + # @return [Integer, nil] + autoload def frames + default_video_stream&.frames end - def video_overview - video&.overview + # Returns the index of the default video stream (if any). + # + # @return [Integer, nil] + autoload def video_index + default_video_stream&.index end - def video_tags - video&.tags + # Returns the mapping index of the default video stream (if any). + # (Can be used as an output option for ffmpeg to select the video stream.) + # + # @return [Integer, nil] + autoload def video_mapping_index + video_streams.index(default_video_stream) end - def audio - @audio ||= @streams&.select(&:audio?) + # Returns the mapping ID of the default video stream (if any). + # (Can be used as an output option for ffmpeg to select the video stream.) + # (e.g. "-map v:0" to select the first video stream.) + # + # @return [String, nil] + autoload def video_mapping_id + index = video_mapping_index + return if index.nil? + + "v:#{index}" end - def audio? - audio&.length&.positive? + # Returns the profile of the default video stream (if any). + # + # @return [String, nil] + autoload def video_profile + default_video_stream&.profile end - def audio_only? - audio? && !video? + # Returns the codec name of the default video stream (if any). + # + # @return [String, nil] + autoload def video_codec_name + default_video_stream&.codec_name end - def audio_with_attached_pic? - audio? && video? && streams.select(&:video?).all?(&:attached_pic?) + # Returns the bit rate of the default video stream (if any). + # + # @return [Integer, nil] + autoload def video_bit_rate + default_video_stream&.bit_rate end - def silent? - !audio? + # Returns the overview of the default video stream (if any). + # + # @return [String, nil] + autoload def video_overview + default_video_stream&.overview end - def audio_index - audio&.first&.index + # Returns the tags of the default video stream (if any). + # + # @return [Hash, nil] + autoload def video_tags + default_video_stream&.tags end - def audio_profile - audio&.first&.profile + # Returns all audio streams. + # + # @return [Array, nil] + autoload def audio_streams + return @audio_streams if instance_variable_defined?(:@audio_streams) + + @audio_streams = @streams.select(&:audio?) end - def audio_codec_name - audio&.first&.codec_name + # Whether the media has audio streams. + # + # @return [Boolean] + autoload def audio_streams? + audio_streams && !audio_streams.empty? end - def audio_codec_type - audio&.first&.codec_type + # Whether the media only contains audio streams and optional attached pictures. + # + # @return [Boolean] + autoload def audio? + audio_streams? && video_streams.all?(&:attached_pic?) end - def audio_bitrate - audio&.first&.bitrate + # Whether the media is silent (no audio streams). + # + # @return [Boolean] + autoload def silent? + !audio_streams? end - def audio_channels - audio&.first&.channels + # Returns the default audio stream (if any). + # + # @return [Stream, nil] + autoload def default_audio_stream + return @default_audio_stream if instance_variable_defined?(:@default_audio_stream) + + @default_audio_stream = audio_streams.find(&:default?) || audio_streams.first end - def audio_channel_layout - audio&.first&.channel_layout + # Returns the index of the default audio stream (if any). + # + # @return [Integer, nil] + autoload def audio_index + default_audio_stream&.index end - def audio_sample_rate - audio&.first&.sample_rate + # Returns the mapping index of the default audio stream (if any). + # (Can be used as an output option for ffmpeg to select the audio stream.) + # + # @return [Integer, nil] + autoload def audio_mapping_index + audio_streams.index(default_audio_stream) end - def audio_overview - audio&.first&.overview + # Returns the mapping ID of the default audio stream (if any). + # (Can be used as an output option for ffmpeg to select the audio stream.) + # (e.g. "-map a:0" to select the first audio stream.) + # + # @return [String, nil] + autoload def audio_mapping_id + index = audio_mapping_index + return if index.nil? + + "a:#{index}" end - def audio_tags - audio&.first&.tags + # Returns the profile of the default audio stream (if any). + # + # @return [String, nil] + autoload def audio_profile + default_audio_stream&.profile end - def transcoder(output_path, options, **kwargs) - Transcoder.new(self, output_path, options, **kwargs) + # Returns the codec name of the default audio stream (if any). + # + # @return [String, nil] + autoload def audio_codec_name + default_audio_stream&.codec_name end - def transcode(output_path, options = EncodingOptions.new, **kwargs, &block) - transcoder(output_path, options, **kwargs).run(&block) + # Returns the bit rate of the default audio stream (if any). + # + # @return [Integer, nil] + autoload def audio_bit_rate + default_audio_stream&.bit_rate end - def screenshot(output_path, options = EncodingOptions.new, **kwargs, &block) - options = options.merge(screenshot: true) - transcode(output_path, options, **kwargs, &block) + # Returns the channels of the default audio stream (if any). + # + # @return [String, nil] + autoload def audio_channels + default_audio_stream&.channels 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 + # Returns the channel layout of the default audio stream (if any). + # + # @return [String, nil] + autoload def audio_channel_layout + default_audio_stream&.channel_layout + end - options = options.merge(seek_time: from) - transcode(output_path, options, **kwargs) + # Returns the sample rate of the default audio stream (if any). + # + # @return [Integer, nil] + autoload def audio_sample_rate + default_audio_stream&.sample_rate + end + + # Returns the overview of the default audio stream (if any). + # + # @return [String, nil] + autoload def audio_overview + default_audio_stream&.overview + end + + # Returns the tags of the default audio stream (if any). + # + # @return [Hash, nil] + autoload def audio_tags + default_audio_stream&.tags + end + + # Execute a ffmpeg command with the media as input. + # + # @param args [Array] The arguments to pass to ffmpeg. + # @param inargs [Array] 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 end end end diff --git a/lib/ffmpeg/preset.rb b/lib/ffmpeg/preset.rb new file mode 100644 index 0000000..d828f8e --- /dev/null +++ b/lib/ffmpeg/preset.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require_relative 'command_args' + +module FFMPEG + # Represents a preset for a specific encoding configuration. + # @!attribute [r] name + # @!attribute [r] metadata + class Preset + attr_reader :name, :metadata + + # @param name [String] The name of the preset. + # @param filename [String] The filename format for the output. + # @param metadata [Hash] The metadata for the preset. + # @param command_args_klass [Class] The class to use when composing command arguments. + # @yield The block to execute to compose the command arguments. + def initialize(name: nil, filename: nil, metadata: nil, command_args_klass: CommandArgs, &compose_args) + @name = name + @metadata = metadata + @filename = filename + @command_args_klass = command_args_klass + @compose_args = compose_args + end + + # Returns a rendered filename. + # + # @param kwargs [Hash] The key-value pairs to use when rendering the filename. + # @return [String, nil] The rendered filename. + def filename(**kwargs) + return nil if @filename.nil? + + @filename % kwargs + end + + # Returns the command arguments for the given media. + # + # @param media [Media] The media to encode. + # @return [Array] The command arguments. + def args(media) + @command_args_klass.compose(media, &@compose_args).to_a + end + + # Transcode the media to the output path. + # + # @param media [Media] The media to transcode. + # @param output_path [String] The path to the output file. + # @yield The block to execute when progress is made. + # @return [FFMPEG::Transcoder::Status] The status of the transcoding process. + def transcode(media, output_path, &) + FFMPEG::Transcoder.new(presets: [self]).process(media, output_path, &) + end + end +end diff --git a/lib/ffmpeg/presets/aac.rb b/lib/ffmpeg/presets/aac.rb new file mode 100644 index 0000000..d55e440 --- /dev/null +++ b/lib/ffmpeg/presets/aac.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +require_relative '../preset' + +module FFMPEG + # rubocop:disable Style/Documentation + module Presets + # rubocop:enable Style/Documentation + class << self + def aac_128k( + name: 'AAC 128k', + filename: '%s.aac', + metadata: nil + ) + AAC.new( + name:, + filename:, + metadata:, + audio_bit_rate: '128k' + ) + end + + def aac_192k( + name: 'AAC 192k', + filename: '%s.aac', + metadata: nil + ) + AAC.new( + name:, + filename:, + metadata:, + audio_bit_rate: '192k' + ) + end + + def aac_320k( + name: 'AAC 320k', + filename: '%s.aac', + metadata: nil + ) + AAC.new( + name:, + filename:, + metadata:, + audio_bit_rate: '320k' + ) + end + end + + # Preset to encode AAC audio files. + class AAC < Preset + attr_reader :audio_bit_rate + + # @param name [String] The name of the preset. + # @param filename [String] The filename format of the output. + # @param metadata [Object] The metadata to associate with the preset. + # @param audio_bit_rate [String] The audio bit rate to use. + # @yield The block to execute to compose the command arguments. + def initialize( + name: nil, + filename: nil, + metadata: nil, + audio_bit_rate: '128k', + & + ) + @audio_bit_rate = audio_bit_rate + preset = self + + super(name:, filename:, metadata:) do + muxing_flags '+faststart' + map_chapters '-1' + audio_codec_name 'aac' + + instance_exec(&) if block_given? + + map media.audio_mapping_id do + audio_bit_rate preset.audio_bit_rate + end + end + end + end + end +end diff --git a/lib/ffmpeg/presets/dash.rb b/lib/ffmpeg/presets/dash.rb new file mode 100644 index 0000000..37a92f7 --- /dev/null +++ b/lib/ffmpeg/presets/dash.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require 'etc' + +require_relative '../preset' + +module FFMPEG + module Presets + # Preset to encode DASH media files. + class DASH < Preset + # @param name [String] The name of the preset. + # @param filename [String] The filename format of the output. + # @param metadata [Object] The metadata to associate with the preset. + # @yield The block to execute to compose the command arguments. + def initialize( + name: nil, + filename: nil, + metadata: nil, + & + ) + super(name:, filename:, metadata:) do + format_name 'dash' + adaptation_sets 'id=0,streams=v id=1,streams=a' + segment_duration 2 + min_keyframe_interval 48 + max_keyframe_interval 48 + scene_change_threshold 0 + + muxing_flags '+faststart' + map_chapters '-1' + + instance_exec(&) + end + end + end + end +end diff --git a/lib/ffmpeg/presets/dash/aac.rb b/lib/ffmpeg/presets/dash/aac.rb new file mode 100644 index 0000000..690d988 --- /dev/null +++ b/lib/ffmpeg/presets/dash/aac.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +require_relative '../dash' + +module FFMPEG + # rubocop:disable Style/Documentation + module Presets + class DASH + # rubocop:enable Style/Documentation + class << self + def aac_128k( + name: 'DASH AAC 128k', + filename: '%s.mpd', + metadata: nil + ) + AAC.new( + name:, + filename:, + metadata:, + audio_bit_rate: '128k' + ) + end + + def aac_192k( + name: 'DASH AAC 192k', + filename: '%s.mpd', + metadata: nil + ) + AAC.new( + name:, + filename:, + metadata:, + audio_bit_rate: '192k' + ) + end + + def aac_320k( + name: 'DASH AAC 320k', + filename: '%s.mpd', + metadata: nil + ) + AAC.new( + name:, + filename:, + metadata:, + audio_bit_rate: '320k' + ) + end + end + + # Preset to encode DASH AAC audio files. + class AAC < DASH + attr_reader :audio_bit_rate + + # @param name [String] The name of the preset. + # @param filename [String] The filename format of the output. + # @param metadata [Object] The metadata to associate with the preset. + # @param audio_bit_rate [String] The audio bit rate to use. + # @yield The block to execute to compose the command arguments. + def initialize( + name: nil, + filename: nil, + metadata: nil, + audio_bit_rate: '128k', + & + ) + @audio_bit_rate = audio_bit_rate + preset = self + + super(name:, filename:, metadata:) do + audio_codec_name 'aac' + + instance_exec(&) if block_given? + + map media.audio_mapping_id do + audio_bit_rate preset.audio_bit_rate + end + end + end + end + end + end +end diff --git a/lib/ffmpeg/presets/dash/h264.rb b/lib/ffmpeg/presets/dash/h264.rb new file mode 100644 index 0000000..c2fea67 --- /dev/null +++ b/lib/ffmpeg/presets/dash/h264.rb @@ -0,0 +1,227 @@ +# frozen_string_literal: true + +require_relative '../../filter' +require_relative '../../filters/fps' +require_relative '../../filters/split' +require_relative '../dash' +require_relative '../h264' + +module FFMPEG + # rubocop:disable Style/Documentation + module Presets + class DASH + # rubocop:enable Style/Documentation + class << self + def h264_360p( + name: 'DASH H.264 360p', + filename: '%s.mpd', + metadata: nil, + audio_bit_rate: '128k', + frame_rate: 30 + ) + H264.new( + name:, + filename:, + metadata:, + audio_bit_rate:, + h264_presets: [Presets.h264_360p(frame_rate:)] + ) + end + + def h264_480p( + name: 'DASH H.264 480p', + filename: '%s.mpd', + metadata: nil, + audio_bit_rate: '128k', + frame_rate: 30 + ) + H264.new( + name:, + filename:, + metadata:, + audio_bit_rate:, + h264_presets: [ + Presets.h264_480p(frame_rate:), + Presets.h264_360p(frame_rate:) + ] + ) + end + + def h264_720p( + name: 'DASH H.264 720p', + filename: '%s.mpd', + metadata: nil, + audio_bit_rate: '128k', + sd_frame_rate: 30, + hd_frame_rate: 30 + ) + H264.new( + name:, + filename:, + metadata:, + audio_bit_rate:, + h264_presets: [ + Presets.h264_720p(frame_rate: hd_frame_rate), + Presets.h264_480p(frame_rate: sd_frame_rate), + Presets.h264_360p(frame_rate: sd_frame_rate) + ] + ) + end + + def h264_1080p( + name: 'DASH H.264 1080p', + filename: '%s.mpd', + metadata: nil, + audio_bit_rate: '128k', + sd_frame_rate: 30, + hd_frame_rate: 30 + ) + H264.new( + name:, + filename:, + metadata:, + audio_bit_rate:, + h264_presets: [ + Presets.h264_1080p(frame_rate: hd_frame_rate), + Presets.h264_720p(frame_rate: hd_frame_rate), + Presets.h264_480p(frame_rate: sd_frame_rate), + Presets.h264_360p(frame_rate: sd_frame_rate) + ] + ) + end + + def h264_1440p( + name: 'DASH H.264 1440p', + filename: '%s.mpd', + metadata: nil, + audio_bit_rate: '128k', + sd_frame_rate: 30, + hd_frame_rate: 30 + ) + H264.new( + name:, + filename:, + metadata:, + audio_bit_rate:, + h264_presets: [ + Presets.h264_1440p(frame_rate: hd_frame_rate), + Presets.h264_1080p(frame_rate: hd_frame_rate), + Presets.h264_720p(frame_rate: hd_frame_rate), + Presets.h264_480p(frame_rate: sd_frame_rate), + Presets.h264_360p(frame_rate: sd_frame_rate) + ] + ) + end + + def h264_4k( + name: 'DASH H.264 4K', + filename: '%s.mpd', + metadata: nil, + audio_bit_rate: '128k', + sd_frame_rate: 30, + hd_frame_rate: 30, + uhd_frame_rate: 30 + ) + H264.new( + name:, + filename:, + metadata:, + audio_bit_rate:, + h264_presets: [ + Presets.h264_4k(frame_rate: uhd_frame_rate), + Presets.h264_1440p(frame_rate: hd_frame_rate), + Presets.h264_1080p(frame_rate: hd_frame_rate), + Presets.h264_720p(frame_rate: hd_frame_rate), + Presets.h264_480p(frame_rate: sd_frame_rate), + Presets.h264_360p(frame_rate: sd_frame_rate) + ] + ) + end + end + + # Preset to encode DASH H.264 video files. + class H264 < DASH + attr_reader :audio_bit_rate, :h264_presets + + # @param name [String] The name of the preset. + # @param filename [String] The filename format of the output. + # @param metadata [Object] The metadata to associate with the preset. + # @param audio_bit_rate [String] The audio bit rate to use. + # @param h264_presets [Array] The H.264 presets to use for video streams. + # @yield The block to execute to compose the command arguments. + def initialize( + name: nil, + filename: nil, + metadata: nil, + audio_bit_rate: '128k', + h264_presets: [Presets.h264_1080p, Presets.h264_720p, Presets.h264_480p, Presets.h264_360p], + & + ) + unless h264_presets.is_a?(Array) + raise ArgumentError, "Unknown h264_presets format #{h264_presets.class}, expected #{Array}" + end + + h264_presets.each do |h264_preset| + unless h264_preset.is_a?(Presets::H264) + raise ArgumentError, + "Unknown h264_presets format #{h264_preset.class}, expected #{Array} of #{Presets::H264}" + end + end + + @audio_bit_rate = audio_bit_rate + @h264_presets = h264_presets + preset = self + + super(name:, filename:, metadata:) do + video_codec_name 'libx264' + audio_codec_name 'aac' + + instance_exec(&) if block_given? + + if media.video_streams? + # Only include H.264 presets that the media fits within. + h264_presets = preset.h264_presets.filter { |h264_preset| h264_preset.fits?(media) } + + # Split the default video stream into multiple streams, + # one for each H.264 preset (e.g.: [v:0]split=2[v0][v1]). + split_filter = + Filters.split(h264_presets.length) + .with_input_link!(media.video_mapping_id) + .with_output_links!(*h264_presets.each_with_index.map { |_, index| "v#{index}" }) + + # Scale the split video streams to the desired resolutions + # and frame rates (e.g.: [v0]scale=640:360,fps=30[v0out]). + scale_filter_graphs = + h264_presets.each_with_index.map do |h264_preset, index| + Filter.join( + h264_preset + .scale_filter(media) + .with_input_link!("v#{index}"), + Filters.fps(adjusted_frame_rate(h264_preset.frame_rate)) + .with_output_link!("v#{index}out") + ) + end + + # Apply the generated filter complex to the output. + filter_complex split_filter, *scale_filter_graphs + + # Map the scaled video streams with the desired H.264 parameters. + h264_presets.each_with_index do |h264_preset, index| + map "[v#{index}out]" do + video_preset h264_preset.video_preset, stream_index: index + video_profile h264_preset.video_profile, stream_index: index + constant_rate_factor h264_preset.constant_rate_factor, stream_id: "v:#{index}" + pixel_format h264_preset.pixel_format + end + end + end + + map media.audio_mapping_id do + audio_bit_rate preset.audio_bit_rate + end + end + end + end + end + end +end diff --git a/lib/ffmpeg/presets/h264.rb b/lib/ffmpeg/presets/h264.rb new file mode 100644 index 0000000..1642080 --- /dev/null +++ b/lib/ffmpeg/presets/h264.rb @@ -0,0 +1,243 @@ +# frozen_string_literal: true + +require_relative '../filters/scale' +require_relative '../preset' + +module FFMPEG + # rubocop:disable Style/Documentation + module Presets + # rubocop:enable Style/Documentation + class << self + def h264_360p( + name: 'H.264 360p', + filename: '%s.360p.mp4', + video_preset: 'ultrafast', + video_profile: 'baseline', + frame_rate: 30, + constant_rate_factor: 28, + pixel_format: 'yuv420p' + ) + H264.new( + name:, + filename:, + video_preset:, + video_profile:, + frame_rate:, + constant_rate_factor:, + pixel_format:, + max_width: 640, + max_height: 360 + ) + end + + def h264_480p( + name: 'H.264 480p', + filename: '%s.480p.mp4', + video_preset: 'fast', + video_profile: 'main', + frame_rate: 30, + constant_rate_factor: 26, + pixel_format: 'yuv420p' + ) + H264.new( + name:, + filename:, + video_preset:, + video_profile:, + frame_rate:, + constant_rate_factor:, + pixel_format:, + max_width: 854, + max_height: 480 + ) + end + + def h264_720p( + name: 'H.264 720p', + filename: '%s.720p.mp4', + video_preset: 'fast', + video_profile: 'high', + frame_rate: 30, + constant_rate_factor: 24, + pixel_format: 'yuv420p' + ) + H264.new( + name:, + filename:, + video_preset:, + video_profile:, + frame_rate:, + constant_rate_factor:, + pixel_format:, + max_width: 1280, + max_height: 720 + ) + end + + def h264_1080p( + name: 'H.264 1080p', + filename: '%s.1080p.mp4', + video_preset: 'fast', + video_profile: 'high', + frame_rate: 30, + constant_rate_factor: 23, + pixel_format: 'yuv420p' + ) + H264.new( + name:, + filename:, + video_preset:, + video_profile:, + frame_rate:, + constant_rate_factor:, + pixel_format:, + max_width: 1920, + max_height: 1080 + ) + end + + def h264_1440p( + name: 'H.264 2K', + filename: '%s.2k.mp4', + video_preset: 'fast', + video_profile: 'high', + frame_rate: 30, + constant_rate_factor: 23, + pixel_format: 'yuv420p' + ) + H264.new( + name:, + filename:, + video_preset:, + video_profile:, + frame_rate:, + constant_rate_factor:, + pixel_format:, + max_width: 2560, + max_height: 1440 + ) + end + + def h264_4k( + name: 'H.264 4K', + filename: '%s.4k.mp4', + video_preset: 'fast', + video_profile: 'high', + frame_rate: 30, + constant_rate_factor: 23, + pixel_format: 'yuv420p' + ) + H264.new( + name:, + filename:, + video_preset:, + video_profile:, + frame_rate:, + constant_rate_factor:, + pixel_format:, + max_width: 3840, + max_height: 2160 + ) + end + end + + # Preset to encode H.264 video files. + class H264 < Preset + attr_reader :audio_bit_rate, :video_preset, :video_profile, + :frame_rate, :constant_rate_factor, :pixel_format, + :max_width, :max_height + + # @param name [String] The name of the preset. + # @param filename [String] The filename format of the output. + # @param metadata [Object] The metadata to associate with the preset. + # @param audio_bit_rate [String] The audio bit rate to use. + # @param video_preset [String] The video preset to use. + # @param video_profile [String] The video profile to use. + # @param frame_rate [Integer] The frame rate to use. + # @param constant_rate_factor [Integer] The constant rate factor to use. + # @param pixel_format [String] The pixel format to use. + # @param max_width [Integer] The maximum width of the video. + # @param max_height [Integer] The maximum height of the video. + # @yield The block to execute to compose the command arguments. + def initialize( + name: nil, + filename: nil, + metadata: nil, + audio_bit_rate: '128k', + video_preset: 'fast', + video_profile: 'high', + frame_rate: 30, + constant_rate_factor: 23, + pixel_format: 'yuv420p', + max_width: nil, + max_height: nil, + & + ) + if max_width && !max_width.is_a?(Numeric) + raise ArgumentError, "Unknown max_width format #{max_width.class}, expected #{Numeric}" + end + + if max_height && !max_height.is_a?(Numeric) + raise ArgumentError, "Unknown max_height format #{max_height.class}, expected #{Numeric}" + end + + @audio_bit_rate = audio_bit_rate + @video_preset = video_preset + @video_profile = video_profile + @frame_rate = frame_rate + @constant_rate_factor = constant_rate_factor + @pixel_format = pixel_format + @max_width = max_width + @max_height = max_height + preset = self + + super(name:, filename:, metadata:) do + format_name 'mp4' + muxing_flags '+faststart' + map_chapters '-1' + video_codec_name 'libx264' + audio_codec_name 'aac' + + instance_exec(&) if block_given? + + map media.video_mapping_id do + video_preset preset.video_preset + video_profile preset.video_profile + frame_rate preset.frame_rate + constant_rate_factor preset.constant_rate_factor + pixel_format preset.pixel_format + filter preset.scale_filter(media) + end + + map media.audio_mapping_id do + audio_bit_rate preset.audio_bit_rate + end + end + end + + def fits?(media) + unless media.is_a?(FFMPEG::Media) + raise ArgumentError, "Unknown media format #{media.class}, expected #{FFMPEG::Media}" + end + + return false unless media.raw_width && media.raw_height + + if @max_width && @max_height + media.raw_width >= @max_width || media.raw_height >= @max_height + elsif @max_width + media.raw_width >= @max_width + elsif @max_height + media.raw_height >= @max_height + else + true + end + end + + def scale_filter(media) + return unless @max_width || @max_height + + Filters::Scale.contained(media, max_width: @max_width, max_height: @max_height) + end + end + end +end diff --git a/lib/ffmpeg/presets/thumbnail.rb b/lib/ffmpeg/presets/thumbnail.rb new file mode 100644 index 0000000..14e795b --- /dev/null +++ b/lib/ffmpeg/presets/thumbnail.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +require_relative '../filters/scale' +require_relative '../preset' + +module FFMPEG + # rubocop:disable Style/Documentation + module Presets + # rubocop:enable Style/Documentation + class << self + def thumbnail( + name: 'JPEG thumbnail', + filename: '%s.thumb.jpg', + metadata: nil, + max_width: nil, + max_height: nil + ) + Thumbnail.new( + name:, + filename:, + metadata:, + max_width: max_width, + max_height: max_height + ) + end + end + + # Preset to create a thumbnail from a video. + class Thumbnail < Preset + attr_reader :max_width, :max_height + + # @param name [String] The name of the preset. + # @param filename [String] The filename format of the output. + # @param metadata [Hash] The metadata to associate with the preset. + # @param max_width [Numeric] The maximum width of the thumbnail. + # @param max_height [Numeric] The maximum height of the thumbnail. + # @yield The block to execute to compose the command arguments. + def initialize(name: nil, filename: nil, metadata: nil, max_width: nil, max_height: nil, &) + if max_width && !max_width.is_a?(Numeric) + raise ArgumentError, "Unknown max_width format #{max_width.class}, expected #{Numeric}" + end + + if max_height && !max_height.is_a?(Numeric) + raise ArgumentError, "Unknown max_height format #{max_height.class}, expected #{Numeric}" + end + + @max_width = max_width + @max_height = max_height + preset = self + + super(name:, filename:, metadata:) do + arg 'ss', (media.duration / 2).floor if media.duration.is_a?(Numeric) + arg 'frames:v', 1 + filter preset.scale_filter(media) + end + end + + def scale_filter(media) + return unless @max_width || @max_height + + Filters::Scale.contained(media, max_width: @max_width, max_height: @max_height) + end + end + end +end diff --git a/lib/ffmpeg/raw_command_args.rb b/lib/ffmpeg/raw_command_args.rb new file mode 100644 index 0000000..d554dd6 --- /dev/null +++ b/lib/ffmpeg/raw_command_args.rb @@ -0,0 +1,775 @@ +# frozen_string_literal: true + +require_relative 'filter' + +module FFMPEG + # A helper class for composing raw command arguments for FFMPEG. + # It provides a DSL for setting arguments. + # + # @example + # args = FFMPEG::RawCommandArgs.compose do + # video_codec_name 'libx264' + # audio_codec_name 'aac' + # end + # args.to_s # "-c:v libx264 -c:a aac" + class RawCommandArgs + class << self + # Compose a new set of command arguments with the specified block. + # The block is executed in the context of the new set of command arguments. + # When calling unknown methods on the new set of command arguments, + # the method is treated as a new argument to add to the command arguments. + # + # @param block_args [Array] The arguments to pass to the block. + # @yield The block to execute to compose the command arguments. + # @return [FFMPEG::RawCommandArgs] The new set of raw command arguments. + # + # @example + # args = FFMPEG::RawCommandArgs.compose do + # video_codec_name 'libx264' + # audio_codec_name 'aac' + # end + # args.to_s # => "-c:v libx264 -c:a aac" + def compose(*block_args, &) + new.tap do |args| + args.instance_exec(*block_args, &) if block_given? + end + end + + # Format the specified flags into a string. + # + # @param flags [Array] The flags to format. + # @param separator [String] The separator to use between flags. + # @param escape [Boolean] Whether to escape the flags or not (default: true). + # + # @example + # FFMPEG::RawCommandArgs.format_flags(['fast', 'superfast']) # => "fast|superfast" + def format_flags(flags, separator: '|', escape: true) + raise ArgumentError, "Unknown flags format #{flags.class}, expected #{Array}" unless flags.is_a?(Array) + + flags = escape ? flags.map(&method(:escape_graph_component)) : flags.map(&:to_s) + flags.join(separator) + end + + # Format the specified keyword arguments into a string. + # The keyword arguments are formatted as key-value pairs + # for a ffmpeg command. + # + # @param kwargs [Hash] The keyword arguments to format. + # @param separator [String] The separator to use between key-value pairs. + # @param escape [Boolean] Whether to escape the values or not (default: true). + # + # @example + # FFMPEG::RawCommandArgs.format_kwargs(bit_rate: '128k', profile: 'high') # => "bit_rate=128k:profile=high" + def format_kwargs(kwargs, separator: ':', escape: true) + return '' if kwargs.nil? + + raise ArgumentError, "Unknown kwargs format #{kwargs.class}, expected #{Hash}" unless kwargs.is_a?(Hash) + + kwargs.each_with_object([]) do |(key, value), acc| + if value.nil? + next acc + elsif value.is_a?(Array) + acc << "#{key}=#{format_flags(value, escape:)}" + else + value = escape_graph_component(value) if escape + acc << "#{key}=#{value}" + end + end.join(separator) + end + + private + + def escape_graph_component(value) + value = value.to_s + value =~ /[\\'\[\]=:|;,]/ ? "'#{value.gsub(/([\\'])/, '\\\\\1')}'" : value + end + end + + def initialize + @args = [] + end + + # Returns the array representation of the command arguments. + def to_a + @args + end + + # ==================== # + # === COMMON UTILS === # + # ==================== # + + # Add a new argument to the command arguments. + # + # @param name [String] The name of the argument. + # @param value [Object] The value of the argument. + # @return [self] + # + # @example + # args = FFMPEG::RawCommandArgs.compose do + # arg 'c:v', 'libx264' + # end + # args.to_s # "-c:v libx264" + def arg(name, value = nil) + value = + if value.is_a?(Hash) + self.class.format_kwargs(value) + elsif value.is_a?(Array) + self.class.format_flags(value) + else + value.to_s + end + + @args << "-#{name}" + @args << value if value + + self + end + + # Adds a new stream specific argument to the command arguments. + # + # @param name [String] The name of the argument. + # @param value [Object] The value of the argument. + # @param stream_id [String] The stream ID to target (preferred over stream type and index). + # @param stream_type [String] The stream type to target. + # @param stream_index [String] The stream index to target. + # @return [self] + # + # @example + # args = FFMPEG::RawCommandArgs.compose do + # stream_arg 'c', 'libx264', stream_id: 'v:0' + # end + # args.to_s # "-c:v:0 libx264" + # + # @example + # args = FFMPEG::RawCommandArgs.compose do + # stream_arg 'c', 'libx264', stream_type: 'v', stream_index: 0 + # end + # args.to_s # "-c:v:0 libx264" + def stream_arg(name, value, stream_id: nil, stream_type: nil, stream_index: nil) + if stream_id + arg("#{name}:#{stream_id}", value) + elsif stream_type && stream_index + arg("#{name}:#{stream_type}:#{stream_index}", value) + elsif stream_type + arg("#{name}:#{stream_type}", value) + elsif stream_index + arg("#{name}:#{stream_index}", value) + else + arg(name, value) + end + end + + # Adds a new raw argument to the command arguments. + # + # @param value [Object] The argument. + # @return [self] + # + # @example + # args = FFMPEG::RawCommandArgs.compose do + # raw_arg '-vn' + # raw_arg '-an' + # end + # args.to_s # "-vn -an" + def raw_arg(value) + @args << value.to_s + + self + end + + # Maps the specified stream ID to the output. + # If a block is given, the block is executed right + # after the -map argument is added. + # This allows for adding stream specific arguments. + # + # @param stream_id [String] The stream ID to map. + # @yield The block to execute to compose the stream specific arguments. + # @return [self] + # + # @example + # args = FFMPEG::RawCommandArgs.compose do + # map 'v:0' do + # video_codec_name 'libx264' + # end + # end + # args.to_s # "-map 0:v:0 -c:v libx264" + def map(stream_id) + return if stream_id.nil? + + arg('map', stream_id.to_s) + + yield if block_given? + + self + end + + # Adds a new filter to the command arguments. + # + # @param filter [FFMPEG::Filter] The filter to add. + # @return [self] + # + # @example + # args = FFMPEG::RawCommandArgs.compose do + # filter FFMPEG::Filters.scale(width: -2, height: 1080) + # end + # args.to_s # "-vf scale=w=-2:h=1080" + def filter(filter) + filters(filter) + end + + # Adds multiple filters to the command arguments + # in a single filter chain. + # + # @param filters [Array] The filters to add. + # @return [self] + # + # @example + # args = FFMPEG::RawCommandArgs.compose do + # filters FFMPEG::Filters.scale(width: -2, height: 1080), + # FFMPEG::Filters.fps(24), + # FFMPEG::Filters.silence_detect + # end + # args.to_s # "-vf scale=w=-2:h=1080,fps=24 -af silencedetect" + def filters(*filters) + filters.compact.group_by(&:type).each do |type, group| + arg("#{type.to_s[0]}f", Filter.join(*group)) + end + + self + end + + # Adds a new bitstream filter to the command arguments. + # + # @param filter [FFMPEG::Filter] The bitstream filter to add. + # @return [self] + # + # @example + # args = FFMPEG::RawCommandArgs.compose do + # bitstream_filter FFMPEG::Filter.new(:video, 'h264_mp4toannexb') + # end + # args.to_s # "-bsf:v h264_mp4toannexb" + def bitstream_filter(filter) + bitstream_filters(filter) + end + + # Adds multiple bitstream filters to the command arguments. + # + # @param filters [Array] The bitstream filters to add. + # @return [self] + # + # @example + # args = FFMPEG::RawCommandArgs.compose do + # bitstream_filters FFMPEG::Filter.new(:video, 'h264_mp4toannexb'), + # FFMPEG::Filter.new(:audio, 'aac_adtstoasc') + # end + # args.to_s # "-bsf:v h264_mp4toannexb -bsf:a aac_adtstoasc" + def bitstream_filters(*filters) + filters.compact.group_by(&:type).each do |type, group| + arg("bsf:#{type.to_s[0]}", Filter.join(*group)) + end + + self + end + + # Adds a new filter complex to the command arguments. + # + # @param filters [Array] The filters to add. + # @return [self] + def filter_complex(*filters) + arg('filter_complex', filters.compact.map(&:to_s).join(';')) + + self + end + + # Sets the output format in the command arguments. + # + # @param value [String] The format to set. + # @return [self] + # + # @example + # args = FFMPEG::RawCommandArgs.compose do + # output_format 'dash' + # end + # args.to_s # "-f dash" + def format_name(value) + arg('f', value) + end + + # Adds new muxing flags in the command arguments. + # + # @param value [String] The flags to add. + # @return [self] + # + # @example + # args = FFMPEG::RawCommandArgs.compose do + # muxing_flags '+faststart+frag_keyframe' + # end + # args.to_s # "-movflags +faststart+frag_keyframe" + def muxing_flags(value) + arg('movflags', value) + end + + # Sets the buffer size in the command arguments. + # + # @param value [String, Numeric] The buffer size to set. + # @return [self] + # + # @example + # args = FFMPEG::RawCommandArgs.compose do + # buffer_size '64k' + # end + # args.to_s # "-bufsize 64k" + def buffer_size(value) + arg('bufsize', value) + end + + # Sets the duration in the command arguments. + # + # @param value [String, Numeric] The duration to set. + # @return [self] + # + # @example + # args = FFMPEG::RawCommandArgs.compose do + # duration 10 + # end + # args.to_s # "-t 10" + def duration(value) + arg('t', value) + end + + # Sets the segment duration in the command arguments. + # This is used for adaptive streaming. + # + # @param value [String, Numeric] The segment duration to set. + # @return [self] + # + # @example + # args = FFMPEG::RawCommandArgs.compose do + # segment_duration 2 + # end + # args.to_s # "-seg_duration 2" + def segment_duration(value) + arg('seg_duration', value) + end + + # Sets a constant rate factor in the command arguments. + # + # @param value [String, Numeric] The constant rate factor to set. + # @param kwargs [Hash] The stream specific arguments to use (see stream_arg). + # @return [self] + # + # @example + # args = FFMPEG::RawCommandArgs.compose do + # constant_rate_factor 23 + # end + # args.to_s # "-crf 23" + # + # @example + # args = FFMPEG::RawCommandArgs.compose do + # constant_rate_factor 23, stream_id: 'v:0' + # end + # args.to_s # "-crf:v:0 23" + def constant_rate_factor(value, **kwargs) + stream_arg('crf', value, **kwargs) + end + + # =================== # + # === VIDEO UTILS === # + # =================== # + + # Sets a video codec in the command arguments. + # + # @param value [String] The video codec to set. + # @param stream_index [String] The stream index to target. + # @return [self] + # + # @example + # args = FFMPEG::RawCommandArgs.compose do + # video_codec_name 'libx264' + # end + # args.to_s # "-c:v libx264" + # + # @example + # args = FFMPEG::RawCommandArgs.compose do + # video_codec_name 'libx264', stream_index: 0 + # end + # args.to_s # "-c:v:0 libx264" + def video_codec_name(value, stream_index: nil) + stream_arg('c', value, stream_type: 'v', stream_index:) + end + + # Sets a video bit rate in the command arguments. + # + # @param value [String, Numeric] The video bit rate to set. + # @param stream_index [String] The stream index to target. + # @return [self] + # + # @example + # args = FFMPEG::RawCommandArgs.compose do + # video_bit_rate '128k' + # end + # args.to_s # "-b:v 128k" + # + # @example + # args = FFMPEG::RawCommandArgs.compose do + # video_bit_rate '128k', stream_index: 0 + # end + # args.to_s # "-b:v:0 128k" + def video_bit_rate(value, stream_index: nil) + stream_arg('b', value, stream_type: 'v', stream_index:) + end + + # Sets a minimum video bit rate in the command arguments. + # + # @param value [String, Numeric] The minimum video bit rate to set. + # @return [self] + # + # @example + # args = FFMPEG::RawCommandArgs.compose do + # min_video_bit_rate '128k' + # end + # args.to_s # "-minrate 128k" + def min_video_bit_rate(value) + arg('minrate', value) + end + + # Sets a maximum video bit rate in the command arguments. + # + # @param value [String, Numeric] The maximum video bit rate to set. + # @return [self] + # + # @example + # args = FFMPEG::RawCommandArgs.compose do + # max_video_bit_rate '128k' + # end + # args.to_s # "-maxrate 128k" + def max_video_bit_rate(value) + arg('maxrate', value) + end + + # Sets a video preset in the command arguments. + # + # @param value [String] The video preset to set. + # @param stream_index [String] The stream index to target. + # @return [self] + # + # @example + # args = FFMPEG::RawCommandArgs.compose do + # video_preset 'fast' + # end + # args.to_s # "-preset:v fast" + # + # @example + # args = FFMPEG::RawCommandArgs.compose do + # video_preset 'fast', stream_index: 0 + # end + # args.to_s # "-preset:v:0 fast" + def video_preset(value, stream_index: nil) + stream_arg('preset', value, stream_type: 'v', stream_index:) + end + + # Sets a video profile in the command arguments. + # + # @param value [String] The video profile to set. + # @param stream_index [String] The stream index to target. + # @return [self] + # + # @example + # args = FFMPEG::RawCommandArgs.compose do + # video_profile 'high' + # end + # args.to_s # "-profile:v high" + # + # @example + # args = FFMPEG::RawCommandArgs.compose do + # video_profile 'high', stream_index: 0 + # end + # args.to_s # "-profile:v:0 high" + def video_profile(value, stream_index: nil) + stream_arg('profile', value, stream_type: 'v', stream_index:) + end + + # Sets a video quality in the command arguments. + # + # @param value [String] The video quality to set. + # @param stream_index [String] The stream index to target. + # @return [self] + # + # @example + # args = FFMPEG::RawCommandArgs.compose do + # video_quality '2' + # end + # args.to_s # "-q:v 2" + # + # @example + # args = FFMPEG::RawCommandArgs.compose do + # video_quality '2', stream_index: 0 + # end + # args.to_s # "-q:v:0 2" + def video_quality(value, stream_index: nil) + stream_arg('q', value, stream_type: 'v', stream_index:) + end + + # Sets a frame rate in the command arguments. + # + # @param value [String, Numeric] The frame rate to set. + # @return [self] + # + # @example + # args = FFMPEG::RawCommandArgs.compose do + # frame_rate 30 + # end + # args.to_s # "-r 30" + def frame_rate(value) + arg('r', value) + end + + # Sets a pixel format in the command arguments. + # + # @param value [String] The pixel format to set. + # @return [self] + # + # @example + # args = FFMPEG::RawCommandArgs.compose do + # pixel_format 'yuv420p' + # end + # args.to_s # "-pix_fmt yuv420p" + def pixel_format(value) + arg('pix_fmt', value) + end + + # Sets a resolution in the command arguments. + # + # @param value [String] The resolution to set. + # @return [self] + # + # @example + # args = FFMPEG::RawCommandArgs.compose do + # resolution '1920x1080' + # end + # args.to_s # "-s 1920x1080" + def resolution(value) + arg('s', value) + end + + # Sets an aspect ratio in the command arguments. + # + # @param value [String] The aspect ratio to set. + # @return [self] + # + # @example + # args = FFMPEG::RawCommandArgs.compose do + # aspect_ratio '16:9' + # end + # args.to_s # "-aspect 16:9" + def aspect_ratio(value) + arg('aspect', value) + end + + # Sets a minimum keyframe interval in the command arguments. + # This is used for adaptive streaming. + # + # @param value [String, Numeric] The minimum keyframe interval to set. + # @return [self] + # + # @example + # args = FFMPEG::RawCommandArgs.compose do + # min_keyframe_interval 48 + # end + # args.to_s # "-keyint_min 48" + def min_keyframe_interval(value) + arg('keyint_min', value) + end + + # Sets a maximum keyframe interval in the command arguments. + # + # This is used for adaptive streaming. + # @param value [String, Numeric] The maximum keyframe interval to set. + # @return [self] + # + # @example + # args = FFMPEG::RawCommandArgs.compose do + # max_keyframe_interval 48 + # end + # args.to_s # "-g 48" + def max_keyframe_interval(value) + arg('g', value) + end + + # Sets a scene change threshold in the command arguments. + # This is used for adaptive streaming. + # + # @param value [String, Numeric] The scene change threshold to set. + # @return [self] + # + # @example + # args = FFMPEG::RawCommandArgs.compose do + # scene_change_threshold 0 + # end + # args.to_s # "-sc_threshold 0" + def scene_change_threshold(value) + arg('sc_threshold', value) + end + + # =================== # + # === AUDIO UTILS === # + # =================== # + + # Sets an audio codec in the command arguments. + # + # @param value [String] The audio codec to set. + # @param stream_index [String] The stream index to target. + # @return [self] + # + # @example + # args = FFMPEG::RawCommandArgs.compose do + # audio_codec_name 'aac' + # end + # args.to_s # "-c:a aac" + # + # @example + # args = FFMPEG::RawCommandArgs.compose do + # audio_codec_name 'aac', stream_index: 0 + # end + # args.to_s # "-c:a:0 aac" + def audio_codec_name(value, stream_index: nil) + stream_arg('c', value, stream_type: 'a', stream_index:) + end + + # Sets an audio bit rate in the command arguments. + # + # @param value [String, Numeric] The audio bit rate to set. + # @param stream_index [String] The stream index to target. + # @return [self] + # + # @example + # args = FFMPEG::RawCommandArgs.compose do + # audio_bit_rate '128k' + # end + # args.to_s # "-b:a 128k" + # + # @example + # args = FFMPEG::RawCommandArgs.compose do + # audio_bit_rate '128k', stream_index: 0 + # end + # args.to_s # "-b:a:0 128k" + def audio_bit_rate(value, stream_index: nil) + stream_arg('b', value, stream_type: 'a', stream_index:) + end + + # Sets an audio sample rate in the command arguments. + # + # @param value [String, Numeric] The audio sample rate to set. + # @return [self] + # + # @example + # args = FFMPEG::RawCommandArgs.compose do + # audio_sample_rate 44100 + # end + # args.to_s # "-ar 44100" + def audio_sample_rate(value) + arg('ar', value) + end + + # Sets the number of audio channels in the command arguments. + # + # @param value [String, Numeric] The number of audio channels to set. + # @return [self] + # + # @example + # args = FFMPEG::RawCommandArgs.compose do + # audio_channels 2 + # end + # args.to_s # "-ac 2" + def audio_channels(value) + arg('ac', value) + end + + # Sets an audio preset in the command arguments. + # + # @param value [String] The audio preset to set. + # @param stream_index [String] The stream index to target. + # @return [self] + # + # @example + # args = FFMPEG::RawCommandArgs.compose do + # audio_preset 'aac_low' + # end + # args.to_s # "-profile:a aac_low" + # + # @example + # args = FFMPEG::RawCommandArgs.compose do + # audio_preset 'aac_low', stream_index: 0 + # end + # args.to_s # "-profile:a:0 aac_low" + def audio_preset(value, stream_index: nil) + stream_arg('preset', value, stream_type: 'a', stream_index:) + end + + # Sets an audio profile in the command arguments. + # + # @param value [String] The audio profile to set. + # @param stream_index [String] The stream index to target. + # @return [self] + # + # @example + # args = FFMPEG::RawCommandArgs.compose do + # audio_profile 'aac_low' + # end + # args.to_s # "-profile:a aac_low" + # + # @example + # args = FFMPEG::RawCommandArgs.compose do + # audio_profile 'aac_low', stream_index: 0 + # end + # args.to_s # "-profile:a:0 aac_low" + def audio_profile(value, stream_index: nil) + stream_arg('profile', value, stream_type: 'a', stream_index:) + end + + # Sets an audio quality in the command arguments. + # + # @param value [String] The audio quality to set. + # @param stream_index [String] The stream index to target. + # @return [self] + # + # @example + # args = FFMPEG::RawCommandArgs.compose do + # audio_quality '2' + # end + # args.to_s # "-q:a 2" + # + # @example + # args = FFMPEG::RawCommandArgs.compose do + # audio_quality '2', stream_index: 0 + # end + # args.to_s # "-q:a:0 2" + def audio_quality(value, stream_index: nil) + stream_arg('q', value, stream_type: 'a', stream_index:) + end + + # Sets the audio sync in the command arguments. + # This is used to synchronize audio and video streams. + # + # @param value [String, Numeric] The audio sync to set. + # @return [self] + # + # @example + # args = FFMPEG::RawCommandArgs.compose do + # audio_sync 1 + # end + # args.to_s # "-async 1" + def audio_sync(value) + arg('async', value) + end + + private + + def respond_to_missing? + true + end + + def method_missing(name, *args) + arg(name, args.first) + end + end +end diff --git a/lib/ffmpeg/reporters/output.rb b/lib/ffmpeg/reporters/output.rb new file mode 100644 index 0000000..6e5074c --- /dev/null +++ b/lib/ffmpeg/reporters/output.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module FFMPEG + module Reporters + # Represents a raw output line from ffmpeg. + class Output + def self.match?(_line) = true + + attr_reader :output + + def initialize(output) + @output = output + end + + def to_s + output + end + end + end +end diff --git a/lib/ffmpeg/reporters/progress.rb b/lib/ffmpeg/reporters/progress.rb new file mode 100644 index 0000000..85de38b --- /dev/null +++ b/lib/ffmpeg/reporters/progress.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +require_relative 'output' + +module FFMPEG + module Reporters + # Represents the progress of an encoding operation. + class Progress < Output + def self.match?(line) + line.match?(/^\s*frame=/) + end + + # Returns the current frame number. + # + # @return [Integer, nil] + def frame + return @frame if instance_variable_defined?(:@frame) + + @frame ||= output[/^\s*frame=\s*(\d+)/, 1]&.to_i + end + + # Returns the current frame rate (speed). + # + # @return [Float, nil] + def fps + return @fps if instance_variable_defined?(:@fps) + + @fps ||= output[/\s*fps=\s*(\d+(?!\.\d+)?)/, 1]&.to_f + end + + # Returns the current size of the output file. + # + # @return [String, nil] + def size + return @size if instance_variable_defined?(:@size) + + @size ||= output[/\s*size=\s*(\S+)/, 1] + end + + # Returns the current time within the media. + # + # @return [Float, nil] + def time + return @time if instance_variable_defined?(:@time) + + @time = if output =~ /time=(\d+):(\d+):(\d+.\d+)/ + (::Regexp.last_match(1).to_i * 3600) + + (::Regexp.last_match(2).to_i * 60) + + ::Regexp.last_match(3).to_f + end + end + + # Returns the current bit rate. + # + # @return [String, nil] + def bit_rate + return @bit_rate if instance_variable_defined?(:@bit_rate) + + @bit_rate ||= output[/\s*bitrate=\s*(\S+)/, 1] + end + + # Returns the current processing speed. + # + # @return [Float, nil] + def speed + return @speed if instance_variable_defined?(:@speed) + + @speed ||= output[/\s*speed=\s*(\d+(?:\.\d+)?)/, 1]&.to_f + end + end + end +end diff --git a/lib/ffmpeg/reporters/silence.rb b/lib/ffmpeg/reporters/silence.rb new file mode 100644 index 0000000..fb8820e --- /dev/null +++ b/lib/ffmpeg/reporters/silence.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require_relative 'output' + +module FFMPEG + module Reporters + # Represents a silence report from ffmpeg. + class Silence < Output + def self.match?(line) + line.match?(/^\[silencedetect @ \w+\]/) + end + + # Returns the ID of the filter. + # + # @return [String, nil] + def filter_id + output[/^\[silencedetect @ (\w+)\]/, 1] + end + + # Returns the start time of the silence. + # + # @return [Float, nil] + def start + return @start if instance_variable_defined?(:@start) + + @start ||= output[/silence_start: (\d+(?:\.\d+)?)/, 1]&.to_f + end + + # Returns the end time of the silence. + # + # @return [Float, nil] + def end + return @end if instance_variable_defined?(:@end) + + @end ||= output[/silence_end: (\d+(?:\.\d+)?)/, 1]&.to_f + end + + # Returns the duration of the silence. + # + # @return [Float, nil] + def duration + return @duration if instance_variable_defined?(:@duration) + + @duration ||= output[/silence_duration: (\d+(?:\.\d+)?)/, 1]&.to_f + end + end + end +end diff --git a/lib/ffmpeg/stream.rb b/lib/ffmpeg/stream.rb index 49965d9..9982362 100644 --- a/lib/ffmpeg/stream.rb +++ b/lib/ffmpeg/stream.rb @@ -3,26 +3,13 @@ module FFMPEG # The Stream class represents a multimedia stream in a file. class Stream - module CodecType - VIDEO = 'video' - AUDIO = 'audio' - end - - module ChannelLayout - MONO = 'mono' - STEREO = 'stereo' - FIVE_ONE = '5.1' - SEVEN_ONE = '7.1' - UNKNOWN = 'unknown' - end - attr_reader :metadata, :id, :index, :profile, :tags, :codec_name, :codec_long_name, :codec_tag, :codec_tag_string, :codec_type, :coded_width, :coded_height, :sample_aspect_ratio, :display_aspect_ratio, :rotation, :color_range, :color_space, :frame_rate, :sample_rate, :sample_fmt, :channels, :channel_layout, - :start_time, :bitrate, :duration, :frames, :overview + :start_time, :bit_rate, :duration, :frames, :overview def initialize(metadata, stderr = '') @metadata = metadata @@ -36,12 +23,12 @@ def initialize(metadata, stderr = '') @codec_long_name = metadata[:codec_long_name] @codec_tag = metadata[:codec_tag] @codec_tag_string = metadata[:codec_tag_string] - @codec_type = metadata[:codec_type] + @codec_type = metadata[:codec_type]&.to_sym - @width = metadata[:width] - @height = metadata[:height] - @coded_width = metadata[:coded_width] - @coded_height = metadata[:coded_height] + @width = metadata[:width]&.to_i + @height = metadata[:height]&.to_i + @coded_width = metadata[:coded_width]&.to_i + @coded_height = metadata[:coded_height]&.to_i @sample_aspect_ratio = metadata[:sample_aspect_ratio] @display_aspect_ratio = metadata[:display_aspect_ratio] @@ -69,7 +56,7 @@ def initialize(metadata, stderr = '') @channel_layout = metadata[:channel_layout] @start_time = metadata[:start_time].to_f - @bitrate = metadata[:bit_rate].to_i + @bit_rate = metadata[:bit_rate].to_i @duration = metadata[:duration].to_f @frames = metadata[:nb_frames].to_i @@ -85,65 +72,148 @@ def initialize(metadata, stderr = '') "#{sample_rate} Hz, " \ "#{channel_layout}, " \ "#{sample_fmt}, " \ - "#{bitrate} bit/s" + "#{bit_rate} bit/s" end @supported = stderr !~ /^Unsupported codec with id (\d+) for input stream #{Regexp.quote(@index.to_s)}$/ end + # Whether the stream is supported. + # + # @return [Boolean] def supported? @supported end + # Whether the stream is unsupported. + # + # @return [Boolean] def unsupported? !supported? end + # Whether the stream is a video stream. + # + # @return [Boolean] def video? - codec_type == CodecType::VIDEO + codec_type == :video end + # Whether the stream is an audio stream. + # + # @return [Boolean] def audio? - codec_type == CodecType::AUDIO + codec_type == :audio end + # Whether the stream is marked as default. + # + # @return [Boolean] def default? metadata.dig(:disposition, :default) == 1 end + # Whether the stream is marked as an attached picture. + # + # @return [Boolean] def attached_pic? metadata.dig(:disposition, :attached_pic) == 1 end + # Whether the stream is rotated. + # This is determined by the value of a rotation tag or display matrix side data. + # + # @return [Boolean] + def rotated? + !@rotation.nil? && @rotation % 180 != 0 + end + + # Whether the stream is portrait. + # + # @return [Boolean] + def portrait? + return true if width < height + + width == height && rotated? + end + + # Whether the stream is landscape. + # + # @return [Boolean] + def landscape? + return true if width > height + + width == height && !rotated? + end + + # The width of the stream. + # If the stream is rotated, the height is returned instead. + # + # @return [Integer] def width - @rotation.nil? || @rotation == 180 ? @width : @height + rotated? ? @height : @width end + # The raw width of the stream. + # This is the width of the stream without considering rotation. + # + # @return [Integer] + def raw_width + @width + end + + # The height of the stream. + # If the stream is rotated, the width is returned instead. + # + # @return [Integer] def height - @rotation.nil? || @rotation == 180 ? @height : @width + rotated? ? @width : @height + end + + # The raw height of the stream. + # This is the height of the stream without considering rotation. + # + # @return [Integer] + def raw_height + @height end + # The resolution of the stream. + # This is a string in the format "#{width}x#{height}". + # + # @return [String] def resolution return if width.nil? || height.nil? "#{width}x#{height}" end + # The calculated aspect ratio of the stream. + # This is calculated from the display aspect ratio or the width and height. + # If neither are available, nil is returned. + # If the stream is rotated, the inverted aspect ratio is returned. + # + # @return [Rational, nil] def calculated_aspect_ratio return @calculated_aspect_ratio unless @calculated_aspect_ratio.nil? @calculated_aspect_ratio = calculate_aspect_ratio(display_aspect_ratio) - @calculated_aspect_ratio ||= width.to_f / height.to_f - @calculated_aspect_ratio = nil if @calculated_aspect_ratio.nan? + @calculated_aspect_ratio ||= Rational(width, height) if width && height @calculated_aspect_ratio end + # The calculated pixel aspect ratio of the stream. + # This is calculated from the sample aspect ratio. + # If the sample aspect ratio is not available, 1 is returned. + # If the stream is rotated, the inverted aspect ratio is returned. + # + # @return [Rational] def calculated_pixel_aspect_ratio return @calculated_pixel_aspect_ratio unless @calculated_pixel_aspect_ratio.nil? @calculated_pixel_aspect_ratio = calculate_aspect_ratio(sample_aspect_ratio) - @calculated_pixel_aspect_ratio ||= 1 + @calculated_pixel_aspect_ratio ||= Rational(1) end protected @@ -151,10 +221,10 @@ def calculated_pixel_aspect_ratio def calculate_aspect_ratio(source) return nil if source.nil? - width, height = source.split(':') - return nil if width == '0' || height == '0' + width, height = source.split(':').map(&:to_i) + return nil if width.zero? || height.zero? - @rotation.nil? || (@rotation == 180) ? width.to_f / height.to_f : height.to_f / width.to_f + rotated? ? Rational(height, width) : Rational(width, height) end end end diff --git a/lib/ffmpeg/transcoder.rb b/lib/ffmpeg/transcoder.rb index 7e3d153..a29eb7d 100644 --- a/lib/ffmpeg/transcoder.rb +++ b/lib/ffmpeg/transcoder.rb @@ -2,216 +2,100 @@ require 'timeout' +require_relative 'media' + 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. + # The Transcoder class is responsible for transcoding multimedia files + # via preset configurations. + # + # @example + # transcoder = FFMPEG::Transcoder.new( + # presets: [FFMPEG::Presets.h264_360p_30, FFMPEG::Presets.aac_128k] + # ) + # status = transcoder.process('input.mp4', 'output') do |report| + # puts(report) + # end + # status.paths # ['output.360p30.mp4', 'output.128k.aac'] + # status.media # [FFMPEG::Media, FFMPEG::Media] + # status.success? # true + # status.exitstatus # 0 class Transcoder - attr_reader :args, :input_path, :output_path, - :output, :progress, :succeeded - - @timeout = 30 - - class << self - attr_accessor :timeout - end - - def initialize( - input, - output_path, - options, - validate: true, - preserve_aspect_ratio: true, - progress_digits: 2, - input_options: [], - filters: [] - ) - if input.is_a?(Media) - @media = input - @input_path = input.path - elsif input.is_a?(String) - @input_path = input + # The Status class represents the status of a transcoding process. + # It inherits all methods from the Process::Status class. + # It also provides a method to retrieve the media files associated with + # the transcoding process. + class Status + attr_reader :paths + + def initialize(process_status, paths) + @process_status = process_status + @paths = paths end - @output_path = output_path - @options = options.is_a?(Hash) ? EncodingOptions.new(options) : options - @validate = validate - @preserve_aspect_ratio = preserve_aspect_ratio - @progress_digits = progress_digits - @input_options = input_options - @filters = filters - - if @input_options.is_a?(Hash) - @input_options = @input_options.reduce([]) do |acc, (key, value)| - acc.push("-#{key}", value.to_s) + # Returns the media files associated with the transcoding process. + # + # @param ffprobe_args [Array] The arguments to pass to ffprobe. + # @param load [Boolean] Whether to load the media files. + # @param autoload [Boolean] Whether to autoload the media files. + # @return [Array] The media files. + def media(*ffprobe_args, load: true, autoload: true) + @paths.map do |path| + Media.new(path, *ffprobe_args, load: load, autoload: autoload) end end - unless @options.is_a?(Array) || @options.is_a?(EncodingOptions) - raise ArgumentError, "Unknown options format '#{@options}', should be either EncodingOptions, Hash or Array." - end - - unless @input_options.is_a?(Array) - raise ArgumentError, "Unknown input_options format '#{@input_options}', should be either Hash or Array." - end - - prepare_resolution - prepare_seek_time - - @args = ['-y', *@input_options, '-i', @input_path, - *@options.to_a, *@filters.map(&:to_a).flatten, - @output_path] - end - - def command - [FFMPEG.ffmpeg_binary, *@args] - end - - def run(&block) - execute(&block) - validate_result if @validate - end - - def finished? - !@succeeded.nil? - end - - def succeeded? - return false unless @succeeded - return true unless @validate - - result&.valid? - end - - def failed? - !succeeded? - end - - def result - return nil unless @succeeded - - @result ||= Media.new(@output_path) if File.exist?(@output_path) - end - - def timeout - self.class.timeout - end - - private - - def prepare_resolution - return unless @preserve_aspect_ratio - return if @media&.video&.calculated_aspect_ratio.nil? - - case @preserve_aspect_ratio.to_s - when 'width' - height = @options.width / @media.video.calculated_aspect_ratio - height = height.ceil.even? ? height.ceil : height.floor - height += 1 if height.odd? # needed if height ended up with no decimals in the first place - @options[:resolution] = "#{@options.width}x#{height}" - when 'height' - width = @options.height * @media.video.calculated_aspect_ratio - width = width.ceil.even? ? width.ceil : width.floor - width += 1 if width.odd? - @options[:resolution] = "#{width}x#{@options.height}" - end - end - - def prepare_seek_time - # Moves any seek_time to an 'ss' input option - - seek_time = '' + private - if @options.is_a?(Array) - index = @options.find_index('-ss') - unless index.nil? - @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 + def respond_to_missing?(symbol, include_private) + @process_status.respond_to?(symbol, include_private) end - return if seek_time.to_s == '' - - index = @input_options.find_index('-ss') - if index.nil? - @input_options.push('-ss', seek_time) - else - @input_options[index + 1] = seek_time + def method_missing(symbol, *args) + @process_status.send(symbol, *args) end end - def validate_result - return result if result&.valid? + attr_reader :name, :metadata, :presets, :reporters - message = "Transcoding #{@input_path} to #{@output_path} produced invalid media\n" \ - "Command: #{command.join(' ')}\n" \ - "Output: #{@output}" - FFMPEG.logger.error(self.class) { message } - raise Error, message + def initialize(name: nil, metadata: nil, presets: [], reporters: [Reporters::Progress], &compose_inargs) + @name = name + @metadata = metadata + @presets = presets + @reporters = reporters + @compose_inargs = compose_inargs end - def execute - FFMPEG.logger.info(self.class) do - "Transcoding #{@input_path} to #{@output_path}...\n" \ - "Command: #{command.join(' ')}" + # Transcodes the media file using the preset configurations. + # + # @param media [String, FFMPEG::Media] The media file to transcode. + # @param output_path [String] 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, &) + media = Media.new(media, load: false) unless media.is_a?(Media) + + output_paths = [] + output_path = Pathname.new(output_path) + output_dir = output_path.dirname + output_filename_kwargs = { + basename: output_path.basename(output_path.extname), + extname: output_path.extname + } + + args = [] + @presets.each do |preset| + filename = preset.filename(**output_filename_kwargs) + args += preset.args(media) + args << (filename.nil? ? output_path.to_s : output_dir.join(filename).to_s) + output_paths << args.last end - @output = String.new - @progress = 0.0 - @succeeded = nil - - FFMPEG.ffmpeg_popen3(*@args) do |_stdin, stdout, stderr, wait_thr| - yield(0.0) if block_given? - - if timeout - stdout.timeout = timeout - stderr.timeout = timeout - end - - stderr.each do |line| - @output << line - - next unless @media - next unless line =~ /time=(\d+):(\d+):(\d+.\d+)/ # time=00:02:42.28 + inargs = CommandArgs.compose(media, &@compose_inargs).to_a - time = (::Regexp.last_match(1).to_i * 3600) + - (::Regexp.last_match(2).to_i * 60) + - ::Regexp.last_match(3).to_f - progress = (time / @media.duration).round(@progress_digits) - next unless progress < 1.0 || progress == @progress - - @progress = progress - yield(@progress) if block_given? - end - - if wait_thr.value.success? - @succeeded = true - @progress = 1.0 - yield(@progress) if block_given? - - FFMPEG.logger.info(self.class) do - "Transcoding #{@input_path} to #{@output_path} succeeded\n" \ - "Command: #{command.join(' ')}\n" \ - "Output: #{@output}" - end - else - @succeeded = false - message = "Transcoding #{@input_path} to #{@output_path} failed\n" \ - "Command: #{command.join(' ')}\n" \ - "Output: #{@output}" - FFMPEG.logger.error(self.class) { message } - raise Error, message - end - rescue ::Timeout::Error - @succeeded = false - Process.kill(FFMPEG::SIGKILL, wait_thr.pid) - message = "Transcoding #{@input_path} to #{@output_path} timed out\n" \ - "Command: #{command.join(' ')}\n" \ - "Output: #{@output}" - FFMPEG.logger.error(self.class) { message } - raise Error, message - end + Status.new( + media.ffmpeg_execute(*args, inargs: inargs, reporters: @reporters, &), + output_paths + ) end end end diff --git a/lib/ffmpeg/version.rb b/lib/ffmpeg/version.rb index a91a498..70ad8e1 100644 --- a/lib/ffmpeg/version.rb +++ b/lib/ffmpeg/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module FFMPEG - VERSION = '6.1.2' + VERSION = '7.0.0-beta' end diff --git a/spec/ffmpeg/command_args_spec.rb b/spec/ffmpeg/command_args_spec.rb new file mode 100644 index 0000000..fd00444 --- /dev/null +++ b/spec/ffmpeg/command_args_spec.rb @@ -0,0 +1,137 @@ +# frozen_string_literal: true + +require_relative '../spec_helper' + +module FFMPEG + describe CommandArgs do + describe '#frame_rate' do + context 'when the media frame rate is lower than the target value' do + it 'sets the frame rate to the media frame rate' do + media = instance_double(Media, frame_rate: 30) + args = CommandArgs.compose(media) { frame_rate 60 } + expect(args.to_a).to eq(%w[-r 30]) + end + end + + context 'when the media frame rate is higher than the target value' do + it 'sets the frame rate to the target value' do + media = instance_double(Media, frame_rate: 60) + args = CommandArgs.compose(media) { frame_rate 30 } + expect(args.to_a).to eq(%w[-r 30]) + end + end + + context 'when the target value is nil' do + it 'does not set the frame rate' do + media = instance_double(Media, frame_rate: 30) + args = CommandArgs.compose(media) { frame_rate nil } + expect(args.to_a).to eq(%w[]) + end + end + end + + describe '#video_bit_rate' do + context 'when the media video bit rate is lower than the target value' do + it 'sets the video bit rate to the media video bit rate' do + media = instance_double(Media, video_bit_rate: 128_000) + args = CommandArgs.compose(media) { video_bit_rate '256k' } + expect(args.to_a).to eq(%w[-b:v 128k]) + end + end + + context 'when the media video bit rate is higher than the target value' do + it 'sets the video bit rate to the target value' do + media = instance_double(Media, video_bit_rate: 256_000) + args = CommandArgs.compose(media) { video_bit_rate '128k' } + expect(args.to_a).to eq(%w[-b:v 128k]) + end + end + + context 'when the target value is nil' do + it 'does not set the video bit rate' do + media = instance_double(Media, video_bit_rate: 128_000) + args = CommandArgs.compose(media) { video_bit_rate nil } + expect(args.to_a).to eq(%w[]) + end + end + end + + describe '#min_video_bit_rate' do + context 'when the media video bit rate is lower than the target value' do + it 'sets the video bit rate to the media video bit rate' do + media = instance_double(Media, video_bit_rate: 128_000) + args = CommandArgs.compose(media) { min_video_bit_rate '256k' } + expect(args.to_a).to eq(%w[-minrate 128k]) + end + end + + context 'when the media video bit rate is higher than the target value' do + it 'sets the video bit rate to the target value' do + media = instance_double(Media, video_bit_rate: 256_000) + args = CommandArgs.compose(media) { min_video_bit_rate '128k' } + expect(args.to_a).to eq(%w[-minrate 128k]) + end + end + + context 'when the target value is nil' do + it 'does not set the video bit rate' do + media = instance_double(Media, video_bit_rate: 128_000) + args = CommandArgs.compose(media) { min_video_bit_rate nil } + expect(args.to_a).to eq(%w[]) + end + end + end + + describe '#max_video_bit_rate' do + context 'when the media video bit rate is lower than the target value' do + it 'sets the video bit rate to the media video bit rate' do + media = instance_double(Media, video_bit_rate: 128_000) + args = CommandArgs.compose(media) { max_video_bit_rate '256k' } + expect(args.to_a).to eq(%w[-maxrate 128k]) + end + end + + context 'when the media video bit rate is higher than the target value' do + it 'sets the video bit rate to the target value' do + media = instance_double(Media, video_bit_rate: 256_000) + args = CommandArgs.compose(media) { max_video_bit_rate '128k' } + expect(args.to_a).to eq(%w[-maxrate 128k]) + end + end + + context 'when the target value is nil' do + it 'does not set the video bit rate' do + media = instance_double(Media, video_bit_rate: 128_000) + args = CommandArgs.compose(media) { max_video_bit_rate nil } + expect(args.to_a).to eq(%w[]) + end + end + end + + describe '#audio_bit_rate' do + context 'when the media audio bit rate is lower than the target value' do + it 'sets the audio bit rate to the media audio bit rate' do + media = instance_double(Media, audio_bit_rate: 128_000) + args = CommandArgs.compose(media) { audio_bit_rate '256k' } + expect(args.to_a).to eq(%w[-b:a 128k]) + end + end + + context 'when the media audio bit rate is higher than the target value' do + it 'sets the audio bit rate to the target value' do + media = instance_double(Media, audio_bit_rate: 256_000) + args = CommandArgs.compose(media) { audio_bit_rate '128k' } + expect(args.to_a).to eq(%w[-b:a 128k]) + end + end + + context 'when the target value is nil' do + it 'does not set the audio bit rate' do + media = instance_double(Media, audio_bit_rate: 128_000) + args = CommandArgs.compose(media) { audio_bit_rate nil } + expect(args.to_a).to eq(%w[]) + end + end + end + end +end diff --git a/spec/ffmpeg/encoding_options_spec.rb b/spec/ffmpeg/encoding_options_spec.rb deleted file mode 100644 index 13ff466..0000000 --- a/spec/ffmpeg/encoding_options_spec.rb +++ /dev/null @@ -1,212 +0,0 @@ -# frozen_string_literal: true - -require_relative '../spec_helper' - -module FFMPEG - describe EncodingOptions do - it 'should convert video codec' do - expect(EncodingOptions.new(video_codec: 'libx264').to_a).to eq(%w[-vcodec libx264]) - end - - it 'should know the width from the resolution or be nil' do - expect(EncodingOptions.new(resolution: '320x240').width).to eq(320) - expect(EncodingOptions.new.width).to be_nil - end - - it 'should know the height from the resolution or be nil' do - expect(EncodingOptions.new(resolution: '320x240').height).to eq(240) - expect(EncodingOptions.new.height).to be_nil - end - - it 'should convert frame rate' do - expect(EncodingOptions.new(frame_rate: 29.9).to_a).to eq(%w[-r 29.9]) - end - - it 'should convert the resolution' do - expect(EncodingOptions.new(resolution: '640x480').to_a).to include('-s', '640x480') - end - - it 'should add calculated aspect ratio' do - expect(EncodingOptions.new(resolution: '640x480').to_a).to include('-aspect', '1.3333333333333333') - expect(EncodingOptions.new(resolution: '640x360').to_a).to include('-aspect', '1.7777777777777777') - end - - it 'should use specified aspect ratio if given' do - output = EncodingOptions.new(resolution: '640x480', aspect: 1.77777777777778).to_a - expect(output).to include('-s', '640x480') - expect(output).to include('-aspect', '1.77777777777778') - end - - it 'should convert video bitrate' do - expect(EncodingOptions.new(video_bitrate: '600k').to_a).to eq(%w[-b:v 600k]) - end - - it 'should use k unit for video bitrate' do - expect(EncodingOptions.new(video_bitrate: 600).to_a).to eq(%w[-b:v 600k]) - end - - it 'should convert audio codec' do - expect(EncodingOptions.new(audio_codec: 'aac').to_a).to eq(%w[-acodec aac]) - end - - it 'should convert audio bitrate' do - expect(EncodingOptions.new(audio_bitrate: '128k').to_a).to eq(%w[-b:a 128k]) - end - - it 'should use k unit for audio bitrate' do - expect(EncodingOptions.new(audio_bitrate: '128k').to_a).to eq(%w[-b:a 128k]) - end - - it 'should convert audio sample rate' do - expect(EncodingOptions.new(audio_sample_rate: 44_100).to_a).to eq(%w[-ar 44100]) - end - - it 'should convert audio channels' do - expect(EncodingOptions.new(audio_channels: 2).to_a).to eq(%w[-ac 2]) - end - - it 'should convert maximum video bitrate' do - expect(EncodingOptions.new(video_max_bitrate: 600).to_a).to eq(%w[-maxrate 600k]) - end - - it 'should convert minimum video bitrate' do - expect(EncodingOptions.new(video_min_bitrate: 600).to_a).to eq(%w[-minrate 600k]) - end - - it 'should convert video bitrate tolerance' do - expect(EncodingOptions.new(video_bitrate_tolerance: 100).to_a).to eq(%w[-bt 100k]) - end - - it 'should convert buffer size' do - expect(EncodingOptions.new(buffer_size: 2000).to_a).to eq(%w[-bufsize 2000k]) - end - - it 'should convert threads' do - expect(EncodingOptions.new(threads: 2).to_a).to eq(%w[-threads 2]) - end - - it 'should convert duration' do - expect(EncodingOptions.new(duration: 30).to_a).to eq(%w[-t 30]) - end - - it 'should convert target' do - expect(EncodingOptions.new(target: 'ntsc-vcd').to_a).to eq(%w[-target ntsc-vcd]) - end - - it 'should convert keyframe interval' do - expect(EncodingOptions.new(keyframe_interval: 60).to_a).to eq(%w[-g 60]) - end - - it 'should convert video preset' do - expect(EncodingOptions.new(video_preset: 'max').to_a).to eq(%w[-vpre max]) - end - - it 'should convert audio preset' do - expect(EncodingOptions.new(audio_preset: 'max').to_a).to eq(%w[-apre max]) - end - - it 'should convert file preset' do - expect(EncodingOptions.new(file_preset: 'max.ffpreset').to_a).to eq(%w[-fpre max.ffpreset]) - end - - it 'should specify seek time' do - expect(EncodingOptions.new(seek_time: 1).to_a).to eq(%w[-ss 1]) - end - - it 'should specify default screenshot parameters' do - expect(EncodingOptions.new(screenshot: true).to_a).to eq(%w[-vframes 1 -f image2]) - end - - it 'should specify screenshot parameters when using -vframes' do - expect(EncodingOptions.new(screenshot: true, vframes: 123).to_a).to eq(%w[-f image2 -vframes 123]) - end - - it 'should specify screenshot parameters when using video quality -v:q' do - expect(EncodingOptions.new(screenshot: true, vframes: 123, - quality: 3).to_a).to eq(%w[-f image2 -vframes 123 - -q:v 3]) - end - - it 'should put the parameters in order of codecs, presets, others' do - opts = {} - opts[:frame_rate] = 25 - opts[:video_codec] = 'libx264' - opts[:video_preset] = 'normal' - opts[:watermark_filter] = { position: 'RT', padding_x: 10, padding_y: 10 } - opts[:watermark] = 'watermark.png' - - converted = EncodingOptions.new(opts).to_a - expect(converted).to eq(%w[-i watermark.png -filter_complex scale=,overlay=x=main_w-overlay_w-10:y=10 -vcodec - libx264 -vpre normal -r 25]) - end - - it 'should convert a lot of them simultaneously' do - converted = EncodingOptions.new(video_codec: 'libx264', audio_codec: 'aac', video_bitrate: '1000k').to_a - expect(converted).to include('-acodec', 'aac') - end - - it 'should ignore options with nil value' do - expect(EncodingOptions.new(video_codec: 'libx264', frame_rate: nil).to_a).to eq(%w[-vcodec libx264]) - end - - it 'should convert x264 vprofile' do - expect(EncodingOptions.new(x264_vprofile: 'high').to_a).to eq(%w[-vprofile high]) - end - - it 'should convert x264 preset' do - expect(EncodingOptions.new(x264_preset: 'slow').to_a).to eq(%w[-preset slow]) - end - - it 'should specify input watermark file' do - expect(EncodingOptions.new(watermark: 'watermark.png').to_a).to eq(%w[-i watermark.png]) - end - - it 'should specify watermark position at left top corner' do - opts = {} - opts[:resolution] = '640x480' - opts[:watermark_filter] = { position: 'LT', padding_x: 10, padding_y: 10 } - converted = EncodingOptions.new(opts).to_a - expect(converted).to include '-filter_complex', 'scale=640x480,overlay=x=10:y=10' - end - - it 'should specify watermark position at right top corner' do - opts = { - resolution: '640x480', - watermark_filter: { position: 'RT', padding_x: 10, padding_y: 10 } - } - converted = EncodingOptions.new(opts).to_a - expect(converted).to include '-filter_complex', 'scale=640x480,overlay=x=main_w-overlay_w-10:y=10' - end - - it 'should specify watermark position at left bottom corner' do - opts = { - resolution: '640x480', - watermark_filter: { position: 'LB', padding_x: 10, padding_y: 10 } - } - converted = EncodingOptions.new(opts).to_a - expect(converted).to include '-filter_complex', 'scale=640x480,overlay=x=10:y=main_h-overlay_h-10' - end - - it 'should specify watermark position at left bottom corner' do - opts = { - resolution: '640x480', - watermark_filter: { position: 'RB', padding_x: 10, padding_y: 10 } - } - converted = EncodingOptions.new(opts).to_a - expect(converted.find do |str| - str =~ /overlay/ - end).to include 'overlay=x=main_w-overlay_w-10:y=main_h-overlay_h-10' - end - - context 'for custom options' do - it 'should not allow custom options as string' do - expect { EncodingOptions.new({ custom: '-map 0:0 -map 0:1' }).to_a }.to raise_error(ArgumentError) - end - - it 'should correctly include custom options' do - converted = EncodingOptions.new({ custom: %w[-map 0:0 -map 0:1] }).to_a - expect(converted).to eq(['-map', '0:0', '-map', '0:1']) - end - end - end -end diff --git a/spec/ffmpeg/filter_spec.rb b/spec/ffmpeg/filter_spec.rb new file mode 100644 index 0000000..191b4de --- /dev/null +++ b/spec/ffmpeg/filter_spec.rb @@ -0,0 +1,136 @@ +# frozen_string_literal: true + +require_relative '../spec_helper' + +module FFMPEG + describe Filter do + describe '#initialize' do + context 'when the type is invalid' do + it 'raises an ArgumentError' do + expect do + described_class.new(:x, 'foo') + end.to raise_error(ArgumentError, 'Unknown type x, expected :video or :audio') + end + end + + context 'when the name is not a string' do + it 'raises an ArgumentError' do + expect do + described_class.new(:video, 1) + end.to raise_error(ArgumentError, 'Unknown name format Integer, expected String') + end + end + end + + describe '#with_input_links' do + it 'returns a clone of the filter with the input links' do + filter = described_class.new(:video, 'scale', w: -2, h: 720) + expect(filter.with_input_links('0:v').to_s).to eq('[0:v]scale=w=-2:h=720') + expect(filter.input_links).to eq([]) + end + end + + describe '#with_input_links!' do + it 'returns the filter with the input links' do + filter = described_class.new(:video, 'scale', w: -2, h: 720) + expect(filter.with_input_links!('0:v').to_s).to eq('[0:v]scale=w=-2:h=720') + expect(filter.input_links).to eq(['0:v']) + end + + it 'overwrites the input links' do + filter = described_class.new(:video, 'scale', w: -2, h: 720) + filter.with_input_links!('0:v').with_input_links!('1:v') + expect(filter.input_links).to eq(['1:v']) + end + end + + describe '#with_input_link' do + it 'returns a clone of the filter with the added input link' do + filter = described_class.new(:video, 'scale', w: -2, h: 720) + filter.with_input_link!('0:v') + expect(filter.with_input_link('1:v').to_s).to eq('[0:v][1:v]scale=w=-2:h=720') + expect(filter.input_links).to eq(['0:v']) + end + end + + describe '#with_input_link!' do + it 'returns the filter with the added input link' do + filter = described_class.new(:video, 'scale', w: -2, h: 720) + filter.with_input_link!('0:v') + expect(filter.with_input_link!('1:v').to_s).to eq('[0:v][1:v]scale=w=-2:h=720') + expect(filter.input_links).to eq(%w[0:v 1:v]) + end + end + + describe '#with_output_links' do + it 'returns a clone of the filter with the output links' do + filter = described_class.new(:video, 'scale', w: -2, h: 720) + expect(filter.with_output_links('v0').to_s).to eq('scale=w=-2:h=720[v0]') + expect(filter.output_links).to eq([]) + end + end + + describe '#with_output_links!' do + it 'returns the filter with the output links' do + filter = described_class.new(:video, 'scale', w: -2, h: 720) + expect(filter.with_output_links!('v0').to_s).to eq('scale=w=-2:h=720[v0]') + expect(filter.output_links).to eq(['v0']) + end + + it 'overwrites the output links' do + filter = described_class.new(:video, 'scale', w: -2, h: 720) + filter.with_output_links!('v0').with_output_links!('v1') + expect(filter.output_links).to eq(['v1']) + end + end + + describe '#with_output_link' do + it 'returns a clone of the filter with the added output link' do + filter = described_class.new(:video, 'scale', w: -2, h: 720) + filter.with_output_link!('v0') + expect(filter.with_output_link('v1').to_s).to eq('scale=w=-2:h=720[v0][v1]') + expect(filter.output_links).to eq(['v0']) + end + end + + describe '#with_output_link!' do + it 'returns the filter with the added output link' do + filter = described_class.new(:video, 'scale', w: -2, h: 720) + filter.with_output_link!('v0') + expect(filter.with_output_link!('v1').to_s).to eq('scale=w=-2:h=720[v0][v1]') + expect(filter.output_links).to eq(%w[v0 v1]) + end + end + + describe '#to_s' do + context 'when the kwargs are empty' do + it 'returns the name' do + filter = described_class.new(:audio, 'foo') + expect(filter.to_s).to eq('foo') + end + end + + context 'when the kwargs contain only nil values' do + it 'returns the name' do + filter = described_class.new(:audio, 'foo', bar: nil, baz: nil) + expect(filter.to_s).to eq('foo') + end + end + + context 'when the kwargs are not empty' do + it 'returns the name and options' do + filter = described_class.new( + :audio, + 'foo', + bar: "'[Vive La Liberté]'", + baz: [1, 2], + fizz: nil, + buzz: true + ) + + expect(filter.to_s).to eq("foo=bar='\\'[Vive La Liberté]\\'':baz=1|2:buzz=true") + end + end + end + end +end diff --git a/spec/ffmpeg/filters/fps_spec.rb b/spec/ffmpeg/filters/fps_spec.rb new file mode 100644 index 0000000..7046645 --- /dev/null +++ b/spec/ffmpeg/filters/fps_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require_relative '../../spec_helper' + +module FFMPEG + describe Filters do + describe '.fps' do + it 'returns a new FPS filter' do + expect(described_class.fps(30).to_s).to eq('fps=30') + end + end + end + + module Filters + describe FPS do + describe '#initialize' do + it 'raises ArgumentError if frame_rate is not numeric' do + expect { described_class.new(30) }.not_to raise_error + expect { described_class.new(0.01) }.not_to raise_error + expect { described_class.new('30') }.to raise_error(ArgumentError) + expect { described_class.new([]) }.to raise_error(ArgumentError) + end + end + end + end +end diff --git a/spec/ffmpeg/filters/grayscale_spec.rb b/spec/ffmpeg/filters/grayscale_spec.rb index e793d61..01ede28 100644 --- a/spec/ffmpeg/filters/grayscale_spec.rb +++ b/spec/ffmpeg/filters/grayscale_spec.rb @@ -3,19 +3,20 @@ require_relative '../../spec_helper' module FFMPEG + describe Filters do + describe '.grayscale' do + it 'returns a new grayscale filter' do + expect(described_class.grayscale.to_s).to eq('format=pix_fmts=gray') + end + end + end + 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]) + filter = described_class.new + expect(filter.to_s).to eq('format=pix_fmts=gray') end end end diff --git a/spec/ffmpeg/filters/scale_spec.rb b/spec/ffmpeg/filters/scale_spec.rb new file mode 100644 index 0000000..2a06dc8 --- /dev/null +++ b/spec/ffmpeg/filters/scale_spec.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +require_relative '../../spec_helper' + +module FFMPEG + describe Filters do + describe '.scale' do + it 'returns a scale filter' do + expect(described_class.scale(width: 640, height: 480).to_s).to eq('scale=w=640:h=480') + end + end + end + + module Filters + describe Scale do + describe '.contained' do + let(:media) { Media.new(fixture_media_file('landscape@4k60.mp4')) } + + it 'raises ArgumentError if media is not an FFMPEG::Media' do + expect { described_class.contained(nil) }.to raise_error(ArgumentError) + expect { described_class.contained('media') }.to raise_error(ArgumentError) + end + + it 'raises ArgumentError if max_width is not numeric' do + expect { described_class.contained(media, max_width: 'foo') }.to raise_error(ArgumentError) + end + + it 'raises ArgumentError if max_height is not numeric' do + expect { described_class.contained(media, max_height: 'foo') }.to raise_error(ArgumentError) + end + + it 'returns nil if max_width and max_height are not specified' do + expect(described_class.contained(media)).to be_nil + end + + it 'returns a contained scale filter' do + expect(described_class.contained(media, max_width: 640).to_s).to eq('scale=w=640:h=-2') + expect(described_class.contained(media, max_height: 480).to_s).to eq('scale=w=-2:h=480') + expect(described_class.contained(media, max_width: 640, max_height: 480).to_s).to eq('scale=w=640:h=-2') + end + + context 'when the media is rotated' do + let(:media) { Media.new(fixture_media_file('portrait@4k60.mp4')) } + + it 'returns a contained scale filter' do + expect(described_class.contained(media, max_width: 640).to_s).to eq('scale=w=-2:h=640') + expect(described_class.contained(media, max_height: 480).to_s).to eq('scale=w=480:h=-2') + expect(described_class.contained(media, max_width: 640, max_height: 480).to_s).to eq('scale=w=-2:h=640') + end + end + + context 'when the aspect ratio is higher than the max_width and max_height' do + it 'returns a contained scale filter that scales to width' do + expect(media).to receive(:calculated_aspect_ratio).and_return(2) + expect(described_class.contained(media, max_width: 640, max_height: 480).to_s).to eq('scale=w=640:h=-2') + end + end + end + + describe '#initialize' do + it 'raises ArgumentError if width is not numeric or string' do + expect { described_class.new(width: 1) }.not_to raise_error + expect { described_class.new(width: 0.01) }.not_to raise_error + expect { described_class.new(width: '1') }.not_to raise_error + expect { described_class.new(width: []) }.to raise_error(ArgumentError) + end + + it 'raises ArgumentError if height is not numeric or string' do + expect { described_class.new(height: 1) }.not_to raise_error + expect { described_class.new(height: 0.01) }.not_to raise_error + expect { described_class.new(height: '1') }.not_to raise_error + expect { described_class.new(height: []) }.to raise_error(ArgumentError) + end + + it 'raises ArgumentError if force_original_aspect_ratio is not string' do + expect { described_class.new(force_original_aspect_ratio: '1') }.not_to raise_error + expect { described_class.new(force_original_aspect_ratio: 1) }.to raise_error(ArgumentError) + end + + it 'raises ArgumentError if flags is not an array' do + expect { described_class.new(flags: []) }.not_to raise_error + expect { described_class.new(flags: '1') }.to raise_error(ArgumentError) + end + end + + describe '#to_s' do + it 'returns the filter as a string' do + filter = described_class.new(width: 640, height: 480, force_original_aspect_ratio: 'decrease') + expect(filter.to_s).to eq('scale=w=640:h=480:force_original_aspect_ratio=decrease') + + filter = described_class.new(width: 'iw/2', height: 'ih/2', force_original_aspect_ratio: 'increase') + expect(filter.to_s).to eq('scale=w=iw/2:h=ih/2:force_original_aspect_ratio=increase') + + filter = described_class.new(width: -2) + expect(filter.to_s).to eq('scale=w=-2') + end + end + end + end +end diff --git a/spec/ffmpeg/filters/silence_detect_spec.rb b/spec/ffmpeg/filters/silence_detect_spec.rb index b047660..a6d6074 100644 --- a/spec/ffmpeg/filters/silence_detect_spec.rb +++ b/spec/ffmpeg/filters/silence_detect_spec.rb @@ -3,26 +3,16 @@ 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 + describe Filters do + describe '.silence_detect' do + it 'returns a new SilenceDetect filter' do + expect(described_class.silence_detect.to_s).to eq('silencedetect') end + end + end + module Filters + describe SilenceDetect do describe '#initialize' do it 'raises ArgumentError if threshold is not numeric or string' do expect { described_class.new(threshold: '-30dB') }.not_to raise_error @@ -36,29 +26,15 @@ module Filters 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 + filter = described_class.new(threshold: '-30dB', duration: 1, mono: true) + expect(filter.to_s).to eq('silencedetect=n=-30dB:d=1:m=true') - describe '#to_a' do - it 'returns the filter as an array' do - expect(subject.to_a).to eq(['-af', subject.to_s]) + filter = described_class.new + expect(filter.to_s).to eq('silencedetect') end end end diff --git a/spec/ffmpeg/filters/split_spec.rb b/spec/ffmpeg/filters/split_spec.rb new file mode 100644 index 0000000..d942c0e --- /dev/null +++ b/spec/ffmpeg/filters/split_spec.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require_relative '../../spec_helper' + +module FFMPEG + describe Filters do + describe '.fps' do + it 'returns a new FPS filter' do + expect(described_class.split(30).to_s).to eq('split=30') + end + end + end + + module Filters + describe Split do + describe '#initialize' do + it 'raises ArgumentError if output_count is not an integer' do + expect { described_class.new(30) }.not_to raise_error + expect { described_class.new(0.01) }.to raise_error(ArgumentError) + expect { described_class.new('30') }.to raise_error(ArgumentError) + expect { described_class.new([]) }.to raise_error(ArgumentError) + end + end + + describe '#to_s' do + it 'returns the filter as a string' do + expect(described_class.new(30).to_s).to eq('split=30') + expect(described_class.new.to_s).to eq('split') + end + end + end + end +end diff --git a/spec/ffmpeg/media_spec.rb b/spec/ffmpeg/media_spec.rb index b16dad6..741fd52 100644 --- a/spec/ffmpeg/media_spec.rb +++ b/spec/ffmpeg/media_spec.rb @@ -4,404 +4,615 @@ module FFMPEG describe Media do - subject { described_class.new("#{fixture_path}/movies/awesome_movie.mov") } + let(:load) { true } + let(:autoload) { true } + let(:path) { fixture_media_file('landscape@4k60.mp4') } + + subject { described_class.new(path, load:, autoload:) } before(:all) { start_web_server } after(:all) { stop_web_server } describe '#initialize' do - context 'given a non-existent local file' do - subject { described_class.new('i_dont_exist') } + context 'when load is set to false' do + let(:load) { false } - it 'should throw ArgumentError' do - expect { subject }.to raise_error(Errno::ENOENT, /does not exist/) + it 'does not load the media' do + expect_any_instance_of(described_class).not_to receive(:load!) + expect(subject.path).to eq(path) end - end - context 'given an unreachable remote file' do - subject { described_class.new('http://127.0.0.1:8000/notfound/awesome_movie.mov') } + it 'autoloads the media on demand' do + expect_any_instance_of(described_class).to receive(:load!).and_call_original + expect(subject.valid?).to be(true) + expect(subject.video_streams?).to be(true) + end - it 'should throw ArgumentError' do - expect { subject }.to raise_error(Errno::ENOENT, /404/) + context 'and autoload is set to false' do + let(:autoload) { false } + + it 'does not autoload the media on demand' do + expect_any_instance_of(described_class).not_to receive(:load!) + expect { subject.valid? }.to raise_error(RuntimeError, /media not loaded/i) + end end end + end - context 'given a remote file with too many redirects' do - subject { described_class.new('http://127.0.0.1:8000/moved/awesome_movie.mov') } - before { FFMPEG.max_http_redirect_attempts = 0 } - after { FFMPEG.max_http_redirect_attempts = nil } + describe '#load!' do + let(:load) { false } - it 'should throw HTTPTooManyRedirects' do - expect { subject }.to raise_error(FFMPEG::HTTPTooManyRedirects) - end + it 'loads the media once' do + expect(FFMPEG).to receive(:ffprobe_capture3).once.and_call_original + subject.load! + subject.load! end - context 'given an empty file' do - subject { described_class.new("#{fixture_path}/movies/empty.flv") } + context 'when the file does not exist' do + let(:path) { fixture_media_file('missing.mp4') } - it 'should mark the media as invalid' do - expect(subject.valid?).to be(false) + it 'raises an error' do + expect { subject.load! }.to raise_error(described_class::LoadError, /\bno such file or directory\b/i) end end - context 'given a broken file' do - subject { described_class.new("#{fixture_path}/movies/broken.mp4") } + context 'when the remote file does not exist' do + let(:path) { fixture_media_file('missing.mp4', remote: true) } - it 'should mark the media as invalid' do - expect(subject.valid?).to be(false) + it 'raises an error' do + expect { subject.load! }.to raise_error(described_class::LoadError, /\b404 not found\b/i) end end - context 'when the ffprobe output' do - let(:stdout_fixture_file) { nil } - let(:stderr_fixture_file) { nil } - let(:stdout) { read_fixture_file("outputs/#{stdout_fixture_file}") } - let(:stderr) { stderr_fixture_file ? read_fixture_file("outputs/#{stderr_fixture_file}") : '' } + context 'when the remote file was moved' do + let(:path) { fixture_media_file('moved', 'landscape@4k60.mp4', remote: true) } - before { allow(Open3).to receive(:capture3).and_return([stdout, stderr, double(succeeded: true)]) } - subject { described_class.new(__FILE__) } + it 'does not raise an error' do + expect { subject.load! }.not_to raise_error + expect(subject.valid?).to be(true) + end + end - context 'cannot be parsed' do - let(:stdout_fixture_file) { 'ffprobe_bad_json.txt' } + context 'when the ffprobe output contains' do + context 'an error' do + let(:path) { fixture_media_file('broken.mp4') } - it 'should throw RuntimeError' do - expect { subject }.to raise_error(RuntimeError, /Could not parse output from FFProbe/) + it 'raises an error' do + expect { subject.load! }.to raise_error(described_class::LoadError, /\binvalid data found\b/i) end end - context 'contains an error' do - let(:stdout_fixture_file) { 'ffprobe_error.txt' } + context 'bad JSON' do + let(:stdout) { read_fixture_file('outputs', 'ffprobe-bad-json.txt') } - it 'should mark the media as invalid' do - expect(subject.valid?).to be(false) - expect(subject.streams).to be_nil + before { allow(FFMPEG).to receive(:ffprobe_capture3).and_return([stdout, '', nil]) } + + it 'raises an error' do + expect { subject.load! }.to raise_error(described_class::LoadError, /\bunexpected token\b/i) end end - context 'contains only unsupported streams' do - let(:stdout_fixture_file) { 'ffprobe_unsupported_audio_and_video_stdout.txt' } - let(:stderr_fixture_file) { 'ffprobe_unsupported_audio_and_video_stderr.txt' } + context 'ISO-8859-1 byte sequences' do + let(:stdout) { read_fixture_file('outputs', 'ffprobe-iso8859.txt') } - it 'should mark the media as invalid' do - expect(subject.valid?).to be(false) - expect(subject.streams).to all(be_unsupported) + before { allow(FFMPEG).to receive(:ffprobe_capture3).and_return([stdout, '', nil]) } + + it 'does not raise and error' do + expect(subject.load!).to be(true) end end + end + end + + describe '#remote?' do + context 'when the media is a remote file' do + let(:path) { fixture_media_file('landscape@4k60.mp4', remote: true) } + + it 'returns true' do + expect(subject.remote?).to be(true) + end + end + + context 'when the media is a local file' do + it 'returns false' do + expect(subject.remote?).to be(false) + end + end + end - context 'contains some unsupported streams' do - let(:stdout_fixture_file) { 'ffprobe_unsupported_audio_stdout.txt' } - let(:stderr_fixture_file) { 'ffprobe_unsupported_audio_stderr.txt' } + describe '#local?' do + context 'when the media is a remote file' do + let(:path) { fixture_media_file('landscape@4k60.mp4', remote: true) } - it 'should not mark the media as invalid' do + it 'returns false' do + expect(subject.local?).to be(false) + end + end + + context 'when the media is a local file' do + it 'returns true' do + expect(subject.local?).to be(true) + end + end + end + + describe '#valid?' do + context 'when the media contains' do + context 'supported and unsupported streams' do + let(:stdout) { read_fixture_file('outputs', 'ffprobe-unsupported-audio-stdout.txt') } + let(:stderr) { read_fixture_file('outputs', 'ffprobe-unsupported-audio-stderr.txt') } + + before { expect(FFMPEG).to receive(:ffprobe_capture3).and_return([stdout, stderr, nil]) } + + it 'returns true' do expect(subject.valid?).to be(true) - expect(subject.video.supported?).to be(true) - expect(subject.audio.first.unsupported?).to be(true) end end - context 'contains ISO-8859-1 byte sequences' do - let(:stdout_fixture_file) { 'ffprobe_iso8859.txt' } + context 'only unsupported streams' do + let(:stdout) { read_fixture_file('outputs', 'ffprobe-unsupported-audio-and-video-stdout.txt') } + let(:stderr) { read_fixture_file('outputs', 'ffprobe-unsupported-audio-and-video-stderr.txt') } + + before { expect(FFMPEG).to receive(:ffprobe_capture3).and_return([stdout, stderr, nil]) } - it 'should not raise an error' do - expect { subject }.not_to raise_error + it 'returns false' do + expect(subject.valid?).to be(false) + end + end + + context 'only supported streams' do + it 'returns true' do + expect(subject.valid?).to be(true) end end end end - describe '#remote?' do - it 'should return true if the path is a remote URL' do - subject = described_class.new('http://127.0.0.1:8000/awesome_movie.mov') - expect(subject.remote?).to be(true) - expect(subject.local?).to be(false) + describe '#video_streams' do + context 'when the media has video streams' do + it 'returns the video streams' do + expect(subject.video_streams.length).to be >= 1 + expect(subject.video_streams).to all(be_a(FFMPEG::Stream)) + expect(subject.video_streams.select(&:video?)).to eq(subject.video_streams) + end end - it 'should return false if the path is a local file' do - expect(subject.remote?).to be(false) - expect(subject.local?).to be(true) + context 'when the media does not have video streams' do + let(:path) { fixture_media_file('hello.wav') } + + it 'returns an empty array' do + expect(subject.video_streams).to eq([]) + end end end - describe '#size' do - context 'when the path is a remote URL' do - it 'should return the content-length of the remote file' do - subject = described_class.new('http://127.0.0.1:8000/moved/awesome_movie.mov') - expect(subject.size).to eq(455_546) + describe '#video_streams?' do + context 'when the media has video streams' do + it 'returns true' do + expect(subject.video_streams?).to be(true) end end - context 'when the path is a local file' do - it 'should return the size of the local file' do - expect(subject.size).to eq(455_546) + context 'when the media does not have video streams' do + let(:path) { fixture_media_file('hello.wav') } + + it 'returns false' do + expect(subject.video_streams?).to be(false) end end end - describe '#video' do - it 'should return the first video stream' do - expect(subject.video).to be_a(Stream) - expect(subject.video.codec_type).to eq('video') + describe '#video?' do + context 'when the media has a moving video stream' do + it 'returns true' do + expect(subject.video?).to be(true) + end + end + + context 'when the media has moving and still video streams' do + let(:path) { fixture_media_file('attached-pic.mov') } + + it 'returns true' do + expect(subject.video?).to be(true) + end + end + + context 'when the media only has still video streams' do + let(:path) { fixture_media_file('napoleon.mp3') } + + it 'returns false' do + expect(subject.video?).to be(false) + end + end + + context 'when the media has no video streams' do + let(:path) { fixture_media_file('hello.wav') } + + it 'returns false' do + expect(subject.video?).to be(false) + end end end - describe '#video?' do - it 'should return true if the media has a video stream' do - expect(subject.video?).to be(true) + describe '#default_video_stream' do + context 'when the media has a default video stream' do + it 'returns the default video stream' do + expect(subject.default_video_stream).to be_a(FFMPEG::Stream) + expect(subject.default_video_stream.video?).to be(true) + expect(subject.default_video_stream.default?).to be(true) + end end - it 'should return false if the media does not have a video stream' do - subject = described_class.new("#{fixture_path}/sounds/hello.wav") - expect(subject.video?).to be(false) + context 'when the media does not have video streams' do + let(:path) { fixture_media_file('hello.wav') } + + it 'returns nil' do + expect(subject.default_video_stream).to be(nil) + end end end - describe '#video_only?' do - it 'should return true if the media has only a video stream' do - subject.instance_variable_set(:@audio, []) - expect(subject.video_only?).to be(true) + describe '#rotated?' do + context 'when the default video stream is not rotated' do + it 'returns false' do + expect(subject.rotated?).to be(false) + end + end + + context 'when the default video stream is rotated' do + let(:path) { fixture_media_file('rotated@90.mov') } + + it 'returns true' do + expect(subject.rotated?).to be(true) + end end - it 'should return false if the media has audio streams' do - expect(subject.video_only?).to be(false) + context 'when the default video stream is fully rotated' do + let(:path) { fixture_media_file('rotated@180.mov') } + + it 'returns false' do + expect(subject.rotated?).to be(false) + end end - it 'should return false if the media does not have a video stream' do - subject = described_class.new("#{fixture_path}/sounds/hello.wav") - expect(subject.video_only?).to be(false) + context 'when the media has no video streams' do + let(:path) { fixture_media_file('hello.wav') } + + it 'returns false' do + expect(subject.rotated?).to be(false) + end end end - %i[ - width - height - rotation - resolution - display_aspect_ratio - sample_aspect_ratio - calculated_aspect_ratio - calculated_pixel_aspect_ratio - color_range - color_space - frame_rate - frames - ].each do |method| - describe "##{method}" do - it 'should delegate to the video stream' do - expect(subject.video).to receive(method).and_return('foo') - expect(subject.send(method)).to be('foo') + describe '#portrait?' do + context 'when the default video stream is not portrait' do + it 'returns false' do + expect(subject.portrait?).to be(false) + end + end + + context 'when the default video stream is portrait' do + let(:path) { fixture_media_file('portrait@4k60.mp4') } + + it 'returns true' do + expect(subject.portrait?).to be(true) + end + end + + context 'when the media has no video streams' do + let(:path) { fixture_media_file('hello.wav') } + + it 'returns false' do + expect(subject.portrait?).to be(false) end + end + end - next unless method == :rotation + describe '#landscape?' do + context 'when the default video stream is landscape' do + it 'returns true' do + expect(subject.landscape?).to be(true) + end + end - [0, 90, 180, 270].each do |rotation| - describe "ios_rotate#{rotation}.mov" do - subject { described_class.new("#{fixture_path}/movies/ios_rotate#{rotation}.mov") } + context 'when the default video stream is not landscape' do + let(:path) { fixture_media_file('portrait@4k60.mp4') } - it 'should return the correct rotation' do - expect(subject.rotation).to eq(rotation.zero? ? nil : rotation) - end - end + it 'returns false' do + expect(subject.landscape?).to be(false) + end + end + + context 'when the media has no video streams' do + let(:path) { fixture_media_file('hello.wav') } + + it 'returns false' do + expect(subject.landscape?).to be(false) end end end - %i[ - index - profile - codec_name - codec_type - bitrate - overview - tags - ].each do |method| - describe "#video_#{method}" do - it 'should delegate to the video stream' do - expect(subject.video).to receive(method).and_return('foo') - expect(subject.send("video_#{method}")).to be('foo') + describe '#width' do + context 'when the default video stream is not rotated' do + it 'returns its width' do + expect(subject.width).to be(3840) + end + end + + context 'when the default video stream is rotated' do + let(:path) { fixture_media_file('portrait@4k60.mp4') } + + it 'returns its height' do + expect(subject.width).to be(2160) end end end - describe '#audio' do - it 'should return the audio streams' do - expect(subject.audio).to all(be_a(Stream)) - expect(subject.audio.map(&:codec_type)).to all(eq('audio')) + describe '#raw_width' do + let(:path) { fixture_media_file('portrait@4k60.mp4') } + + it 'returns the width of the default video stream' do + expect(subject.raw_width).to be(3840) end end - describe '#audio?' do - it 'should return true if the media has audio streams' do - expect(subject.audio?).to be(true) + describe '#height' do + context 'when the default video stream is not rotated' do + it 'returns its height' do + expect(subject.height).to be(2160) + end end - it 'should return false if the media does not have audio streams' do - subject.instance_variable_set(:@audio, []) - expect(subject.audio?).to be(false) + context 'when the default video stream is rotated' do + let(:path) { fixture_media_file('portrait@4k60.mp4') } + + it 'returns its width' do + expect(subject.height).to be(3840) + end end end - describe '#audio_only?' do - it 'should return true if the media has only audio streams' do - subject = described_class.new("#{fixture_path}/sounds/hello.wav") - expect(subject.audio_only?).to be(true) + describe '#raw_height' do + let(:path) { fixture_media_file('portrait@4k60.mp4') } + + it 'returns the height of the default video stream' do + expect(subject.raw_height).to be(2160) end + end - it 'should return false if the media has video streams' do - subject = described_class.new("#{fixture_path}/sounds/napoleon.mp3") - expect(subject.audio_only?).to be(false) + describe '#rotation' do + context 'when the default video stream is not rotated' do + it 'returns nil' do + expect(subject.rotation).to be(nil) + end end - it 'should return false if the media does not have audio streams' do - subject = described_class.new("#{fixture_path}/movies/awesome_movie.mov") - expect(subject.audio_only?).to be(false) + [90, 180, 270].each do |rotation| + context "when the default video stream is rotated at #{rotation}" do + let(:path) { fixture_media_file("rotated@#{rotation}.mov") } + + it "returns #{rotation}" do + expect(subject.rotation).to be(rotation) + end + end end end - describe '#audio_with_attached_pic?' do - it 'should return true if the media has audio streams with attached pictures' do - subject = described_class.new("#{fixture_path}/sounds/napoleon.mp3") - expect(subject.audio_with_attached_pic?).to be(true) + describe '#resolution' do + context 'when the default video stream is not rotated' do + it 'returns the correct resolution' do + expect(subject.resolution).to eq('3840x2160') + end end - it 'should return false if the media does not have audio streams with attached pictures' do - expect(subject.audio_with_attached_pic?).to be(false) + context 'when the default video stream is rotated' do + let(:path) { fixture_media_file('portrait@4k60.mp4') } + + it 'returns the correct resolution' do + expect(subject.resolution).to eq('2160x3840') + end end + end + + describe '#display_aspect_ratio' do + let(:path) { fixture_media_file('portrait@4k60.mp4') } - it 'should return false if the media does not have attached pictures' do - subject = described_class.new("#{fixture_path}/sounds/hello.wav") - expect(subject.audio_with_attached_pic?).to be(false) + it 'returns the display aspect ratio of the default video stream' do + expect(subject.display_aspect_ratio).to eq('16:9') end + end - it 'should return false if the media has both attached pictures and normal video streams' do - subject = described_class.new("#{fixture_path}/movies/attached_pic.mov") - expect(subject.audio_with_attached_pic?).to be(false) + describe '#sample_aspect_ratio' do + it 'returns the sample aspect ratio of the default video stream' do + expect(subject.sample_aspect_ratio).to eq('1:1') end end - %i[ - index - profile - codec_name - codec_type - bitrate - channels - channel_layout - sample_rate - overview - tags - ].each do |method| - describe "#audio_#{method}" do - it 'should delegate to the first audio stream' do - expect(subject.audio.first).to receive(method).and_return('foo') - expect(subject.send("audio_#{method}")).to be('foo') + describe '#calculated_aspect_ratio' do + context 'when the default video stream is not rotated' do + it 'returns the aspect ratio of the default video stream' do + expect(subject.calculated_aspect_ratio).to eq(Rational(16, 9)) end end - end - describe '#transcoder' do - let(:output_path) { tmp_file(ext: 'mov') } + context 'when the default video stream is rotated' do + let(:path) { fixture_media_file('portrait@4k60.mp4') } - 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') + it 'returns the inverted aspect ratio of the default video stream' do + expect(subject.calculated_aspect_ratio).to eq(Rational(9, 16)) + end end end - describe '#transcode' do - let(:output_path) { tmp_file(ext: 'mov') } - let(:options) { { custom: %w[-vcodec libx264] } } - let(:kwargs) { { preserve_aspect_ratio: :width } } + { + calculated_pixel_aspect_ratio: Rational(1), + color_range: 'pc', + color_space: 'yuvj420p', + frame_rate: Rational(60 / 1), + frames: 213, + video_index: 0, + video_mapping_index: 0, + video_mapping_id: 'v:0', + video_profile: 'High', + video_codec_name: 'h264', + video_bit_rate: 41_401_600, + video_overview: 'h264 (High) (avc1 / 0x31637661), yuvj420p, 3840x2160 [SAR 1:1 DAR 16:9]', + video_tags: { + encoder: 'Lavc61.19.100 libx264', + handler_name: 'VideoHandle', + language: 'eng', + vendor_id: '[0][0][0][0]' + } + }.each do |method, value| + describe "##{method}" do + it "returns the #{method.to_s.gsub(/^video_/, '').gsub('_', ' ')} of the default video stream" do + expect(subject.public_send(method)).to eq(value) + end + end + end + + describe '#audio_streams' do + context 'when the media has audio streams' do + let(:path) { fixture_media_file('widescreen-multi-audio.mp4') } + + it 'returns the audio streams' do + expect(subject.audio_streams.length).to be >= 1 + expect(subject.audio_streams).to all(be_a(FFMPEG::Stream)) + expect(subject.audio_streams.select(&:audio?)).to eq(subject.audio_streams) + end + end - it 'should run the transcoder' do - transcoder_double = double(Transcoder) - expect(Transcoder).to receive(:new) - .with(subject, output_path, options, **kwargs) - .and_return(transcoder_double) - expect(transcoder_double).to receive(:run) + context 'when the media does not have audio streams' do + let(:path) { fixture_media_file('widescreen-no-audio.mp4') } - subject.transcode(output_path, options, **kwargs) + it 'returns an empty array' do + expect(subject.audio_streams).to eq([]) + end 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 } } + describe '#audio_streams?' do + context 'when the media has audio streams' do + it 'returns true' do + expect(subject.audio_streams?).to be(true) + end + end - it 'should run the transcoder with screenshot option' do - transcoder_double = double(Transcoder) - expect(Transcoder).to receive(:new) - .with(subject, output_path, options.merge(screenshot: true), **kwargs) - .and_return(transcoder_double) - expect(transcoder_double).to receive(:run) + context 'when the media does not have audio streams' do + let(:path) { fixture_media_file('widescreen-no-audio.mp4') } - subject.screenshot(output_path, options, **kwargs) + it 'returns false' do + expect(subject.audio_streams?).to be(false) + end end end - describe '#cut' do - let(:output_path) { tmp_file(ext: 'mov') } - let(:options) { { custom: %w[-vcodec libx264] } } + describe '#audio?' do + context 'when the media only has audio streams' do + let(:path) { fixture_media_file('hello.wav') } + + it 'returns true' do + expect(subject.audio?).to be(true) + end + end - 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) + context 'when the media only has audio streams and still video streams' do + let(:path) { fixture_media_file('napoleon.mp3') } - subject.cut(output_path, 2, 4, options) + it 'returns true' do + expect(subject.audio?).to be(true) end end - context 'with input options as a string array' do - let(:kwargs) { { input_options: %w[-ss 999] } } + context 'when the media has audio and moving video streams' do + it 'returns false' do + expect(subject.audio?).to be(false) + end + end - 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) + context 'when the media has no audio streams' do + let(:path) { fixture_media_file('widescreen-no-audio.mp4') } - subject.cut(output_path, 2, 4, options, **kwargs) + it 'returns false' do + expect(subject.audio?).to be(false) end end + end - context 'with input options as a hash' do - let(:kwargs) { { input_options: { ss: 999 } } } + describe '#silent?' do + context 'when the media has an audio stream' do + it 'returns false' do + expect(subject.silent?).to be(false) + end + end + + context 'when the media has no audio streams' do + let(:path) { fixture_media_file('widescreen-no-audio.mp4') } + + it 'returns true' do + expect(subject.silent?).to be(true) + end + end + end + + describe '#default_audio_stream' do + context 'when the media has a default audio stream' do + let(:path) { fixture_media_file('widescreen-multi-audio.mp4') } + + it 'returns the default audio stream' do + expect(subject.default_audio_stream).to be_a(FFMPEG::Stream) + expect(subject.default_audio_stream.audio?).to be(true) + expect(subject.default_audio_stream.default?).to be(true) + end + end - 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) + context 'when the media does not have audio streams' do + let(:path) { fixture_media_file('widescreen-no-audio.mp4') } - subject.cut(output_path, 2, 4, options, **kwargs) + it 'returns nil' do + expect(subject.default_audio_stream).to be(nil) 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.7) + { + audio_index: 1, + audio_mapping_index: 0, + audio_mapping_id: 'a:0', + audio_codec_name: 'aac', + audio_bit_rate: 192_028, + audio_overview: 'aac (mp4a / 0x6134706d), 48000 Hz, stereo, fltp, 192028 bit/s', + audio_tags: { + handler_name: 'SoundHandle', + language: 'eng', + vendor_id: '[0][0][0][0]' + } + }.each do |method, value| + describe "##{method}" do + it "returns the #{method.to_s.gsub(/^audio_/, '').gsub('_', ' ')} of the default audio stream" do + expect(subject.public_send(method)).to eq(value) + end + end + end + + describe '#ffmpeg_execute' do + it 'executes a ffmpeg command with the media as input' do + reports = [] + block = ->(report) { reports << report } + args = %w[-af silencedetect=d=0.5 -f null -] + reporters = [FFMPEG::Reporters::Silence] + + expect(FFMPEG).to receive(:ffmpeg_execute).and_call_original + + status = subject.ffmpeg_execute(*args, reporters:, &block) + expect(status).to be_a(Process::Status) + expect(status.exitstatus).to be(0) + + expect(reports.length).to be >= 1 + expect(reports).to all(be_a(FFMPEG::Reporters::Output)) + expect(reports.select do |report| + report.is_a?(FFMPEG::Reporters::Silence) + end.length).to be >= 1 end end end diff --git a/spec/ffmpeg/preset_spec.rb b/spec/ffmpeg/preset_spec.rb new file mode 100644 index 0000000..b0638cb --- /dev/null +++ b/spec/ffmpeg/preset_spec.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require_relative '../spec_helper' + +module FFMPEG + describe Preset do + describe '#filename' do + it 'returns the rendered filename' do + preset = described_class.new(filename: '%s.mpd') + expect(preset.filename(basename: 'manifest')).to eq('manifest.mpd') + end + + context 'when the preset filename is nil' do + it 'returns nil' do + preset = described_class.new(filename: nil) + expect(preset.filename).to be_nil + end + end + end + + describe '#args' do + it 'returns the command arguments for the media' do + media = instance_double(Media, frame_rate: 69) + preset = described_class.new { arg 'r', media.frame_rate } + expect(preset.args(media)).to eq(%w[-r 69]) + end + end + + describe '#transcode' do + it 'transcodes the media to the output path' do + media = Media.new(fixture_media_file('hello.wav')) + + preset = described_class.new do + audio_codec_name 'aac' + audio_bit_rate '128k' + map media.audio_mapping_id + end + + status = preset.transcode(media, File.join(tmp_dir, 'hello.aac')) + expect(status).to be_a(FFMPEG::Transcoder::Status) + expect(status.success?).to be(true) + expect(status.paths).to eq([File.join(tmp_dir, 'hello.aac')]) + end + end + end +end diff --git a/spec/ffmpeg/presets_spec.rb b/spec/ffmpeg/presets_spec.rb new file mode 100644 index 0000000..f7a7543 --- /dev/null +++ b/spec/ffmpeg/presets_spec.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +require_relative '../spec_helper' + +module FFMPEG + class PresetTest + attr_reader :name, :preset, :assert + + def initialize(name:, preset:, assert:) + @name = name + @preset = preset + @assert = assert + end + end + + describe Presets do + let(:media) { Media.new(fixture_media_file('portrait@1080p60.mp4')) } + let(:output_dir) { Dir.mktmpdir(nil, tmp_dir) } + let(:output_path) { File.join(output_dir, SecureRandom.hex(4)) } + + [ + PresetTest.new( + name: 'H.264 360p 30 FPS', + preset: Presets.h264_360p, + assert: lambda do |media| + expect(media.path).to match(/\.mp4\z/) + expect(media.streams.length).to be(2) + expect(media.video_streams.length).to be(1) + expect(media.audio_streams.length).to be(1) + expect(media.width).to be(360) + expect(media.height).to be(640) + expect(media.frame_rate).to eq(Rational(30)) + expect(media.audio_bit_rate).to be_within(15_000).of(128_000) + end + ), + PresetTest.new( + name: 'AAC 128k', + preset: Presets.aac_128k, + assert: lambda do |media| + expect(media.path).to match(/\.aac\z/) + expect(media.streams.length).to be(1) + expect(media.audio_streams.length).to be(1) + expect(media.audio_bit_rate).to be_within(15_000).of(128_000) + end + ), + PresetTest.new( + name: 'DASH H.264 4K 30 FPS', + preset: Presets::DASH.h264_4k(uhd_frame_rate: 30, hd_frame_rate: 60, sd_frame_rate: 30), + assert: lambda do |media| + expect(media.path).to match(/\.mpd\z/) + expect(media.streams.length).to be(5) + expect(media.video_streams.length).to be(4) + expect(media.audio_streams.length).to be(1) + expect(media.width).to be(1080) + expect(media.height).to be(1920) + expect(media.frame_rate).to eq(Rational(60)) + expect(media.audio_bit_rate).to be_within(15_000).of(128_000) + expect(media.video_streams.map(&:width)).to eq([1080, 720, 480, 360]) + expect(media.video_streams.map(&:height)).to eq([1920, 1280, 854, 640]) + expect(media.video_streams.map(&:frame_rate)).to eq([Rational(60), Rational(60), Rational(30), Rational(30)]) + end + ), + PresetTest.new( + name: 'DASH AAC 128k', + preset: Presets::DASH.aac_128k, + assert: lambda do |media| + expect(media.path).to match(/\.mpd\z/) + expect(media.streams.length).to be(1) + expect(media.audio_streams.length).to be(1) + expect(media.audio_bit_rate).to be_within(15_000).of(128_000) + end + ), + PresetTest.new( + name: 'JPEG thumbnail', + preset: Presets.thumbnail(max_width: 640, max_height: 360), + assert: lambda do |media| + expect(media.path).to match(/\.jpg\z/) + expect(media.streams.length).to be(1) + expect(media.width).to be(360) + expect(media.height).to be(640) + end + ) + ].each do |test| + describe test.name do + it 'transcodes the media to the correct parameters' do + status = test.preset.transcode(media, output_path) + expect(status.success?).to be(true) + instance_exec(status.media.first, &test.assert) + end + end + end + end +end diff --git a/spec/ffmpeg/raw_command_args_spec.rb b/spec/ffmpeg/raw_command_args_spec.rb new file mode 100644 index 0000000..61eae16 --- /dev/null +++ b/spec/ffmpeg/raw_command_args_spec.rb @@ -0,0 +1,216 @@ +# frozen_string_literal: true + +require_relative '../spec_helper' + +module FFMPEG + describe RawCommandArgs do + subject { RawCommandArgs.new } + + describe '#arg' do + it 'adds the argument' do + subject.arg('foo', 'bar') + expect(subject.to_a).to eq(%w[-foo bar]) + end + + context 'when the value is a hash' do + it 'adds the argument as kwargs' do + subject.arg('foo', { bar: 'baz', fizz: 'buzz' }) + expect(subject.to_a).to eq(%w[-foo bar=baz:fizz=buzz]) + end + end + + context 'when the value is an array' do + it 'adds the argument as flags' do + subject.arg('foo', %w[bar baz]) + expect(subject.to_a).to eq(%w[-foo bar|baz]) + end + end + end + + describe '#stream_arg' do + it 'adds the stream argument' do + subject.stream_arg('foo', 'bar') + expect(subject.to_a).to eq(%w[-foo bar]) + end + + it 'adds the stream argument with the stream index' do + subject.stream_arg('foo', 'bar', stream_index: 0) + expect(subject.to_a).to eq(%w[-foo:0 bar]) + end + + it 'adds the stream argument with the stream type' do + subject.stream_arg('foo', 'bar', stream_type: 'v') + expect(subject.to_a).to eq(%w[-foo:v bar]) + end + + it 'adds the stream argument with the stream index and type' do + subject.stream_arg('foo', 'bar', stream_index: 0, stream_type: 'v') + expect(subject.to_a).to eq(%w[-foo:v:0 bar]) + end + + it 'adds the stream argument with the stream ID' do + subject.stream_arg('foo', 'bar', stream_id: '0x101') + expect(subject.to_a).to eq(%w[-foo:0x101 bar]) + end + end + + describe '#raw_arg' do + it 'adds the raw argument' do + subject.raw_arg('-foo') + expect(subject.to_a).to eq(%w[-foo]) + end + end + + describe '#map' do + it 'adds the map argument' do + subject.map(0) + expect(subject.to_a).to eq(%w[-map 0]) + end + + it 'adds the map argument and executes the block' do + subject.map(0) do + subject.video_codec_name 'libx264' + end + expect(subject.to_a).to eq(%w[-map 0 -c:v libx264]) + end + + context 'when the mapped stream ID is nil' do + it 'does not execute the block' do + subject.map(nil) do + subject.video_codec_name 'libx264' + end + expect(subject.to_a).to eq([]) + end + end + end + + describe '#filter' do + it 'adds the correct filter argument' do + filter = Filters.fps(30) + subject.filter(filter) + expect(subject.to_a).to eq(['-vf', filter.to_s]) + end + end + + describe '#filters' do + it 'adds the correct filter arguments' do + video_filters = [Filters.fps(30), Filters.grayscale] + audio_filters = [Filters.silence_detect] + subject.filters(*video_filters[0..1], *audio_filters, *video_filters[2..]) + expect(subject.to_a).to eq(['-vf', Filter.join(*video_filters), '-af', Filter.join(*audio_filters)]) + end + end + + describe '#bitstream_filter' do + it 'adds the correct bitstream filter argument' do + subject.bitstream_filter(Filter.new(:video, 'h264_mp4toannexb')) + expect(subject.to_a).to eq(%w[-bsf:v h264_mp4toannexb]) + end + end + + describe '#bitstream_filters' do + it 'adds the correct bitstream filter arguments' do + video_filters = [Filter.new(:video, 'h264_mp4toannexb'), Filter.new(:video, 'h264_mp4toannexb')] + audio_filters = [Filter.new(:audio, 'aac_adtstoasc')] + subject.bitstream_filters(*video_filters[0..1], *audio_filters, *video_filters[2..]) + expect(subject.to_a).to eq(['-bsf:v', Filter.join(*video_filters), '-bsf:a', Filter.join(*audio_filters)]) + end + end + + describe '#filter_complex' do + it 'adds the filter complex argument' do + subject.filter_complex(Filters.fps(30), 'foo') + expect(subject.to_a).to eq(%w[-filter_complex fps=30;foo]) + end + end + + describe '#constant_rate_factor' do + it 'adds the constant rate factor argument' do + subject.constant_rate_factor(23) + expect(subject.to_a).to eq(%w[-crf 23]) + end + + it 'adds the constant rate factor argument with the stream index' do + subject.constant_rate_factor(23, stream_index: 0) + expect(subject.to_a).to eq(%w[-crf:0 23]) + end + + it 'adds the constant rate factor argument with the stream type' do + subject.constant_rate_factor(23, stream_type: 'v') + expect(subject.to_a).to eq(%w[-crf:v 23]) + end + + it 'adds the constant rate factor argument with the stream index and type' do + subject.constant_rate_factor(23, stream_index: 0, stream_type: 'v') + expect(subject.to_a).to eq(%w[-crf:v:0 23]) + end + + it 'adds the constant rate factor argument with the stream ID' do + subject.constant_rate_factor(23, stream_id: '0x101') + expect(subject.to_a).to eq(%w[-crf:0x101 23]) + end + end + + describe '#method_missing' do + it 'adds the argument' do + subject.foo('bar') + expect(subject.to_a).to eq(%w[-foo bar]) + end + end + + { + format_name: 'f', + muxing_flags: 'movflags', + buffer_size: 'bufsize', + duration: 't', + segment_duration: 'seg_duration', + min_video_bit_rate: 'minrate', + max_video_bit_rate: 'maxrate', + frame_rate: 'r', + pixel_format: 'pix_fmt', + resolution: 's', + aspect_ratio: 'aspect', + min_keyframe_interval: 'keyint_min', + max_keyframe_interval: 'g', + scene_change_threshold: 'sc_threshold', + audio_sample_rate: 'ar', + audio_channels: 'ac', + audio_sync: 'async' + }.each do |method, flag| + describe "##{method}" do + it "adds the #{method.to_s.gsub('_', ' ')} argument" do + value = SecureRandom.hex(4) + subject.public_send(method, value) + expect(subject.to_a).to eq(["-#{flag}", value]) + end + end + end + + { + video_codec_name: 'c:v', + video_bit_rate: 'b:v', + video_preset: 'preset:v', + video_profile: 'profile:v', + video_quality: 'q:v', + audio_codec_name: 'c:a', + audio_bit_rate: 'b:a', + audio_preset: 'preset:a', + audio_profile: 'profile:a', + audio_quality: 'q:a' + }.each do |method, flag| + describe "##{method}" do + it "adds the #{method.to_s.gsub('_', ' ')} argument" do + value = SecureRandom.hex(4) + subject.public_send(method, value) + expect(subject.to_a).to eq(["-#{flag}", value]) + end + + it "adds the #{method.to_s.gsub('_', ' ')} argument with the stream index" do + value = SecureRandom.hex(4) + subject.public_send(method, value, stream_index: 0) + expect(subject.to_a).to eq(["-#{flag}:0", value]) + end + end + end + end +end diff --git a/spec/ffmpeg/reporters/progress_spec.rb b/spec/ffmpeg/reporters/progress_spec.rb new file mode 100644 index 0000000..b32afad --- /dev/null +++ b/spec/ffmpeg/reporters/progress_spec.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +require_relative '../../spec_helper' + +module FFMPEG + module Reporters + describe Progress do + subject do + described_class.new( + 'frame= 210 fps= 90 q=-1.0 Lsize= 3366KiB time=00:00:03.46 bitrate=7953.3kbits/s dup=1 drop=0 speed=1.49x' + ) + end + + describe '.match?' do + context 'when the line starts with a frame number' do + it 'returns true' do + expect(Progress.match?('frame=1')).to be(true) + end + end + + context 'when the line does not start with a frame number' do + it 'returns false' do + expect(Progress.match?('size=1')).to be(false) + end + end + end + + describe '#frame' do + it 'returns the current frame number' do + expect(subject.frame).to eq(210) + end + end + + describe '#fps' do + it 'returns the current frame rate' do + expect(subject.fps).to eq(90.0) + end + end + + describe '#size' do + it 'returns the current size of the output file' do + expect(subject.size).to eq('3366KiB') + end + end + + describe '#time' do + it 'returns the current time within the media' do + expect(subject.time).to eq(3.46) + end + end + + describe '#bit_rate' do + it 'returns the current bit rate' do + expect(subject.bit_rate).to eq('7953.3kbits/s') + end + end + + describe '#speed' do + it 'returns the current processing speed' do + expect(subject.speed).to eq(1.49) + end + end + end + end +end diff --git a/spec/ffmpeg/reporters/silence_spec.rb b/spec/ffmpeg/reporters/silence_spec.rb new file mode 100644 index 0000000..14cd45d --- /dev/null +++ b/spec/ffmpeg/reporters/silence_spec.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require_relative '../../spec_helper' + +module FFMPEG + module Reporters + describe Silence do + subject do + described_class.new( + '[silencedetect @ 0x7f8c6c000000] silence_start: 1.69 silence_end: 2.42 silence_duration: 0.73' + ) + end + + describe '.match?' do + context 'when the line starts with a silence report' do + it 'returns true' do + expect(Silence.match?('[silencedetect @ 0x7f8c6c000000]')).to be(true) + end + end + + context 'when the line does not start with a silence report' do + it 'returns false' do + expect(Silence.match?('size=1')).to be(false) + end + end + end + + describe '#filter_id' do + it 'returns the ID of the filter' do + expect(subject.filter_id).to eq('0x7f8c6c000000') + end + end + + describe '#start' do + it 'returns the start time of the silence' do + expect(subject.start).to eq(1.69) + end + end + + describe '#end' do + it 'returns the end time of the silence' do + expect(subject.end).to eq(2.42) + end + end + + describe '#duration' do + it 'returns the duration of the silence' do + expect(subject.duration).to eq(0.73) + end + end + end + end +end diff --git a/spec/ffmpeg/stream_spec.rb b/spec/ffmpeg/stream_spec.rb index fa84a76..c4e1015 100644 --- a/spec/ffmpeg/stream_spec.rb +++ b/spec/ffmpeg/stream_spec.rb @@ -12,14 +12,14 @@ module FFMPEG context 'when the codec is not supported' do let(:stderr) { 'Unsupported codec with id 1 for input stream 1' } - it 'should return false' do + it 'returns false' do expect(subject.supported?).to be(false) expect(subject.unsupported?).to be(true) end end context 'when the codec is supported' do - it 'should return true' do + it 'returns true' do expect(subject.supported?).to be(true) expect(subject.unsupported?).to be(false) end @@ -28,17 +28,17 @@ module FFMPEG describe '#video?' do context 'when the codec type is video' do - let(:metadata) { { codec_type: Stream::CodecType::VIDEO } } + let(:metadata) { { codec_type: 'video' } } - it 'should return true' do + it 'returns true' do expect(subject.video?).to be(true) end end context 'when the codec type is not video' do - let(:metadata) { { codec_type: Stream::CodecType::AUDIO } } + let(:metadata) { { codec_type: 'audio' } } - it 'should return false' do + it 'returns false' do expect(subject.video?).to be(false) end end @@ -46,17 +46,17 @@ module FFMPEG describe '#audio?' do context 'when the codec type is audio' do - let(:metadata) { { codec_type: Stream::CodecType::AUDIO } } + let(:metadata) { { codec_type: 'audio' } } - it 'should return true' do + it 'returns true' do expect(subject.audio?).to be(true) end end context 'when the codec type is not audio' do - let(:metadata) { { codec_type: Stream::CodecType::VIDEO } } + let(:metadata) { { codec_type: 'video' } } - it 'should return false' do + it 'returns false' do expect(subject.audio?).to be(false) end end @@ -66,7 +66,7 @@ module FFMPEG context 'when marked as default' do let(:metadata) { { disposition: { default: 1 } } } - it 'should return true' do + it 'returns true' do expect(subject.default?).to be(true) end end @@ -74,7 +74,7 @@ module FFMPEG context 'when not marked as default' do let(:metadata) { { disposition: { default: 0 } } } - it 'should return false' do + it 'returns false' do expect(subject.default?).to be(false) end end @@ -84,7 +84,7 @@ module FFMPEG context 'when marked as an attached picture' do let(:metadata) { { disposition: { attached_pic: 1 } } } - it 'should return true' do + it 'returns true' do expect(subject.attached_pic?).to be(true) end end @@ -92,7 +92,7 @@ module FFMPEG context 'when not marked as an attached picture' do let(:metadata) { { disposition: { attached_pic: 0 } } } - it 'should return false' do + it 'returns false' do expect(subject.attached_pic?).to be(false) end end @@ -102,7 +102,7 @@ module FFMPEG context 'when the rotation is nil' do let(:metadata) { { width: 100, height: 200 } } - it 'should return the width' do + it 'returns the width' do expect(subject.width).to eq(100) end end @@ -110,7 +110,7 @@ module FFMPEG context 'when the rotation is 180' do let(:metadata) { { width: 100, height: 200, tags: { rotate: 180 } } } - it 'should return the width' do + it 'returns the width' do expect(subject.width).to eq(100) end end @@ -118,7 +118,7 @@ module FFMPEG context 'when the rotation is not 180' do let(:metadata) { { width: 100, height: 200, tags: { rotate: 90 } } } - it 'should return the height' do + it 'returns the height' do expect(subject.width).to eq(200) end end @@ -128,7 +128,7 @@ module FFMPEG context 'when the rotation is nil' do let(:metadata) { { width: 100, height: 200 } } - it 'should return the height' do + it 'returns the height' do expect(subject.height).to eq(200) end end @@ -136,7 +136,7 @@ module FFMPEG context 'when the rotation is 180' do let(:metadata) { { width: 100, height: 200, tags: { rotate: 180 } } } - it 'should return the height' do + it 'returns the height' do expect(subject.height).to eq(200) end end @@ -144,7 +144,7 @@ module FFMPEG context 'when the rotation is not 180' do let(:metadata) { { width: 100, height: 200, tags: { rotate: 90 } } } - it 'should return the width' do + it 'returns the width' do expect(subject.height).to eq(100) end end @@ -154,7 +154,7 @@ module FFMPEG context 'when the width and height are nil' do let(:metadata) { { width: nil, height: nil } } - it 'should return nil' do + it 'returns nil' do expect(subject.resolution).to be_nil end end @@ -162,7 +162,7 @@ module FFMPEG context 'when the width and height are not nil' do let(:metadata) { { width: 100, height: 200 } } - it 'should return the resolution' do + it 'returns the resolution' do expect(subject.resolution).to eq('100x200') end end @@ -172,23 +172,23 @@ module FFMPEG context 'when the display_aspect_ratio is nil' do let(:metadata) { { width: 100, height: 200 } } - it 'should return the aspect ratio from the width and height' do - expect(subject.calculated_aspect_ratio).to eq(0.5) + it 'returns the aspect ratio from the width and height' do + expect(subject.calculated_aspect_ratio).to eq(Rational(1, 2)) end end context 'when the display_aspect_ratio is not nil' do let(:metadata) { { width: 100, height: 200, display_aspect_ratio: '16:9' } } - it 'should return the aspect ratio from the display_aspect_ratio' do - expect(subject.calculated_aspect_ratio).to eq(16.0 / 9.0) + it 'returns the aspect ratio from the display_aspect_ratio' do + expect(subject.calculated_aspect_ratio).to eq(Rational(16, 9)) end context 'and the stream is rotated' do let(:metadata) { { width: 100, height: 200, display_aspect_ratio: '16:9', tags: { rotate: 90 } } } - it 'should return the aspect ratio from the display_aspect_ratio' do - expect(subject.calculated_aspect_ratio).to eq(9.0 / 16.0) + it 'returns the aspect ratio from the display_aspect_ratio' do + expect(subject.calculated_aspect_ratio).to eq(Rational(9, 16)) end end end @@ -198,16 +198,16 @@ module FFMPEG context 'when the sample_aspect_ratio is nil' do let(:metadata) { { sample_aspect_ratio: nil } } - it 'should return 1' do - expect(subject.calculated_pixel_aspect_ratio).to eq(1) + it 'returns 1' do + expect(subject.calculated_pixel_aspect_ratio).to eq(Rational(1)) end end context 'when the sample_aspect_ratio is not nil' do let(:metadata) { { sample_aspect_ratio: '16:9' } } - it 'should return the aspect ratio from the sample_aspect_ratio' do - expect(subject.calculated_pixel_aspect_ratio).to eq(16.0 / 9.0) + it 'returns the aspect ratio from the sample_aspect_ratio' do + expect(subject.calculated_pixel_aspect_ratio).to eq(Rational(16, 9)) end end end diff --git a/spec/ffmpeg/transcoder_spec.rb b/spec/ffmpeg/transcoder_spec.rb index e939289..2d14723 100644 --- a/spec/ffmpeg/transcoder_spec.rb +++ b/spec/ffmpeg/transcoder_spec.rb @@ -4,414 +4,82 @@ module FFMPEG describe Transcoder do - let(:media) { Media.new("#{fixture_path}/movies/awesome_movie.mov") } - - describe '#initialize' do - let(:output_path) { tmp_file(ext: 'flv') } - - it 'should accept EncodingOptions as options' do - expect do - described_class.new(media, output_path, EncodingOptions.new) - end.not_to raise_error - end - - it 'should accept Hash as options' do - expect do - described_class.new(media, output_path, { video_codec: 'libx264' }) - end.not_to raise_error - end - - it 'should accept Array as options' do - expect do - described_class.new(media, output_path, %w[-vcodec libx264]) - end.not_to raise_error - end - - it 'should not accept anything else as options' do - expect do - described_class.new(media, output_path, 'string?') - end.to raise_error(ArgumentError, /Unknown options format/) - end - end - - describe '#run' do - before do - allow(FFMPEG.logger).to receive(:info) - end - - let(:input) { media } - let(:output_ext) { 'mp4' } - let(:output_path) { tmp_file(ext: output_ext) } - let(:options) { EncodingOptions.new } - let(:kwargs) { nil } - - subject do - if kwargs.nil? - described_class.new(input, output_path, options) - else - described_class.new(input, output_path, options, **kwargs) - end - end - - context 'when ffmpeg freezes' do - before do - Transcoder.timeout = 1 - FFMPEG.ffmpeg_binary = "#{fixture_path}/bin/ffmpeg-hanging" - end - - after do - Transcoder.timeout = 30 - FFMPEG.ffmpeg_binary = nil - end - - it 'should fail when the timeout is exceeded' do - ::Timeout.timeout(5) do - expect(FFMPEG.logger).to receive(:error) - expect { subject.run }.to raise_error(FFMPEG::Error, /Transcoding .+ timed out/) - end - end - end - - context 'when ffmpeg crashes' do - let(:input) { 'http://256.256.256.256/bad-address.mp4' } - - it 'should fail with non-zero exit code error' do - expect(FFMPEG.logger).to receive(:error) - expect { subject.run }.to raise_error(FFMPEG::Error, /Transcoding .+ failed/) - end - end - - context 'with timeout disabled' do - let(:output_ext) { 'mpg' } - let(:options) { { target: 'ntsc-vcd' } } - - before { Transcoder.timeout = nil } - after { Transcoder.timeout = 30 } - - it 'should still work with NTSC target' do - result = subject.run - expect(result.resolution).to eq('352x240') - end - end - - it 'should transcode the input and report progress' do - reports = [] - subject.run { |progress| reports << progress } - - expect(subject.result).to be_valid - expect(reports).to include(0.0, 1.0) - expect(reports.length).to be >= 3 - expect(File.exist?(output_path)).to be_truthy - end - - context 'with full set of encoding options' do - let(:options) do - { video_codec: 'libx264', frame_rate: 10, resolution: '320x240', video_bitrate: 300, - audio_codec: 'libmp3lame', audio_bitrate: 32, audio_sample_rate: 22_050, audio_channels: 1 } - end - - it 'should transcode the input' do - result = subject.run - - expect(result.video_bitrate).to be_within(90_000).of(300_000) - expect(result.video_codec_name).to match(/h264/) - expect(result.resolution).to eq('320x240') - expect(result.frame_rate).to eq(10.0) - expect(result.audio_bitrate).to be_within(2000).of(32_000) - expect(result.audio_codec_name).to match(/mp3/) - expect(result.audio_sample_rate).to eq(22_050) - expect(result.audio_channels).to eq(1) - expect(File.exist?(output_path)).to be_truthy - end - end - - context 'with audio only' do - let(:media) { Media.new("#{fixture_path}/sounds/hello.wav") } - let(:output_ext) { 'mp3' } - let(:options) { { audio_codec: 'libmp3lame', input_options: %w[-qscale:a 2] } } - - it 'should transcode the input' do - result = subject.run - - expect(result.video_codec_name).to be_nil - expect(result.audio_codec_name).to match(/mp3/) - expect(result.audio_sample_rate).to eq(44_100) - expect(result.audio_channels).to eq(1) - expect(File.exist?(output_path)).to be_truthy - end - - context 'when ffmpeg freezes' do - before do - Transcoder.timeout = 1 - FFMPEG.ffmpeg_binary = "#{fixture_path}/bin/ffmpeg-audio-hanging" + describe '#process' do + let(:preset1) do + Preset.new(filename: '%s.mp4') do + video_codec_name 'libx264' + audio_codec_name 'aac' + + map media.video_mapping_id do + filter Filters.scale(width: -2, height: 360) + constant_rate_factor 28 end - after do - Transcoder.timeout = 30 - FFMPEG.ffmpeg_binary = nil - end - - it 'should fail when the timeout is exceeded' do - ::Timeout.timeout(5) do - expect { subject.run }.to raise_error(FFMPEG::Error, /Transcoding .+ timed out/) - end + map media.audio_mapping_id do + audio_bit_rate '96k' end end end - context 'with aspect ratio preservation' do - let(:media) { Media.new("#{fixture_path}/movies/widescreen_movie.mov") } - let(:options) { { resolution: '320x240' } } - let(:kwargs) { { preserve_aspect_ratio: :width } } + let(:preset2) do + Preset.new(filename: '%s.aac') do + audio_codec_name 'aac' - context 'set to width' do - it 'should transcode to the correct resolution' do - result = subject.run - expect(result.resolution).to eq('320x180') + map media.audio_mapping_id do + audio_bit_rate '96k' end end - - context 'set to height' do - let(:kwargs) { { preserve_aspect_ratio: :height } } - - it 'should transcode to the correct resolution' do - result = subject.run - expect(result.resolution).to eq('426x240') - end - end - - it 'should use the specified resolution when if the original aspect ratio is undeterminable' do - expect(media.video).to receive(:calculated_aspect_ratio).and_return(nil) - result = subject.run - expect(result.resolution).to eq('320x240') - end - - it 'should round to resolutions divisible by 2' do - expect(media.video).to receive(:calculated_aspect_ratio).at_least(:once).and_return(1.234) - result = subject.run - expect(result.resolution).to eq('320x260') # 320 / 1.234 should at first be rounded to 259 - end - end - - context 'with string array options' do - let(:options) { %w[-s 300x200 -ac 2] } - - it 'should transcode the input' do - result = subject.run - expect(result.resolution).to eq('300x200') - expect(result.audio_channels).to eq(2) - end - end - - context 'with input file that contains single quote' do - let(:media) { Media.new("#{fixture_path}/movies/awesome'movie.mov") } - - it 'should not fail' do - expect { subject.run }.not_to raise_error - end end - context 'with output file that contains single quote' do - let(:output_path) { "#{tmp_path}/output with 'quote.flv" } - - before { FileUtils.rm_f(output_path) } - - it 'should not fail' do - expect { subject.run }.not_to raise_error - end - end - - context 'with output file that contains ISO-8859-1 characters' do - let(:output_path) { "#{tmp_path}/saløndethé.flv" } - - before { FileUtils.rm_f(output_path) } - - it 'should not fail' do - expect { subject.run }.not_to raise_error - end - end - - context 'with explicitly set duration' do - let(:options) { { duration: 2 } } - - it 'should transcode correctly' do - result = subject.run - expect(result.duration).to be >= 1.8 - expect(result.duration).to be <= 2.2 - end - end - - context 'with remote URL as input' do - let(:media) { Media.new('http://127.0.0.1:8000/awesome_movie.mov') } - - before(:context) { start_web_server } - after(:context) { stop_web_server } - - it 'should transcode correctly' do - expect { subject.run }.not_to raise_error - expect(File.exist?(output_path)).to be_truthy - end - end - - context 'with screenshot' do - let(:output_ext) { 'jpg' } - let(:options) { { screenshot: true, seek_time: 3 } } - - it 'should produce the correct ffmpeg command' do - expect(subject.command.join(' ')).to include("-ss 3 -i #{subject.input_path}") - end - - it 'should transcode to the original resolution by default' do - result = subject.run - expect(result.resolution).to eq('640x480') - end - - context 'and explicitly set resolution' do - let(:options) { { screenshot: true, seek_time: 3, resolution: '400x200' } } - - it 'should transcode to the specified resolution' do - result = subject.run - expect(result.resolution).to eq('400x200') - end - end - - context 'and aspect ratio preservation' do - let(:options) { { screenshot: true, seek_time: 4, resolution: '320x500' } } - let(:kwargs) { { preserve_aspect_ratio: :width } } - - it 'should transcode to the correct resolution' do - result = subject.run - expect(result.resolution).to eq('320x240') - end - end - - describe 'for multiple screenshots' do - let(:output_path) { "#{tmp_path}/screenshots_%d.png" } - let(:options) { { screenshot: true, seek_time: 4, resolution: '320x500' } } - let(:kwargs) { { preserve_aspect_ratio: :width } } - - context 'with output file validation' do - it 'should fail' do - expect do - subject.run - end.to raise_error(FFMPEG::Error, /Transcoding .+ produced invalid media/) - end - end - - context 'without output file validation' do - let(:kwargs) { { preserve_aspect_ratio: :width, validate: false } } - - it 'should create sequential screenshots' do - subject.run - expect(Dir[File.join(tmp_path, 'screenshots_*.png')].count { |file| File.file?(file) }).to eq(1) - end - end - end - - context 'and custom input options' do - let(:kwargs) { { input_options: %w[-re] } } - - it 'should produce the correct ffmpeg command' do - expect(subject.command.join(' ')).to include("-re -ss 3 -i #{subject.input_path}") - end - - context 'that already define -ss' do - let(:kwargs) { { input_options: %w[-ss 5 -re] } } - - it 'should overwrite the -ss value' do - expect(subject.command.join(' ')).to include("-ss 3 -re -i #{subject.input_path}") - end - end - end - end - - context 'with watermarking' do - let(:options) { { watermark: "#{fixture_path}/images/watermark.png", watermark_filter: { position: 'RT' } } } - - it 'should transcode the input with the watermark' do - expect { subject.run }.not_to raise_error - end - end - - context 'without output file validation' do - let(:kwargs) { { validate: false } } - - before { allow(subject).to receive(:execute) } - - it 'should not validate the output file' do - expect(subject).to_not receive(:validate_output_path) - subject.run - end - - it 'should not return a Media object' do - expect(subject).to_not receive(:result) - expect(subject.run).to eq(nil) - end - end - - context 'with custom encoding options' do - let(:options) { { video_codec: 'libx264', custom: %w[-map 0:0 -map 0:1] } } - - it 'should add the custom encoding options to the command' do - expect(subject.command.join(' ')).to include('-map 0:0 -map 0:1') - end - end - - context 'with custom input options' do - context 'as a string array' do - let(:kwargs) { { input_options: %w[-framerate 1/5 -re] } } - - it 'should produce the correct ffmpeg command' do - expect(subject.command.join(' ')).to include("-framerate 1/5 -re -i #{subject.input_path}") - end - end - - context 'as a hash' do - let(:kwargs) { { input_options: { framerate: '1/5' } } } - - it 'should produce the correct ffmpeg command' do - expect(subject.command.join(' ')).to include("-framerate 1/5 -i #{subject.input_path}") - end + subject do + described_class.new(presets: [preset1, preset2]) do + raw_arg '-noautorotate' end end - context 'with an image sequence as input' do - let(:input) { "#{fixture_path}/images/img_%03d.jpeg" } - let(:kwargs) { { input_options: %w[-framerate 1/5] } } - - it 'should not raise an error' do - expect { subject.run }.to_not raise_error - end - - it 'should produce a slideshow' do - result = subject.run - expect(result.duration).to eq(25) - end + it 'transcodes a multimedia file using the specified presets' do + media = Media.new(fixture_media_file('landscape@4k60.mp4')) + output_path = File.join(tmp_dir, SecureRandom.hex(4)) - context 'and files where the file extension does not match the file type' do - let(:input) { "#{fixture_path}/images/wrong_type/img_%03d.tiff" } + expect(media).to receive(:ffmpeg_execute).and_wrap_original do |method, *args, **kwargs, &block| + expect(args).to eq( + %W[ + -c:v libx264 -c:a aac + -map v:0 -vf scale=w=-2:h=360 -crf 28 + -map a:0 -b:a 96k + #{output_path}.mp4 + -c:a aac + -map a:0 -b:a 96k + #{output_path}.aac + ] + ) - it 'should fail' do - expect { subject.run }.to raise_error(FFMPEG::Error, /Transcoding .+ failed/) - end - end - end + expect(kwargs).to eq( + inargs: ['-noautorotate'], + reporters: [Reporters::Progress] + ) - 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') + method.call(*args, **kwargs, &block) 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 + reports = [] + status = subject.process(media, output_path) do |report| + reports << report + end + + expect(status).to be_a(Transcoder::Status) + expect(status.paths).to eq(%W[#{output_path}.mp4 #{output_path}.aac]) + expect(status.media).to all(be_a(Media)) + expect(status.media.first.video?).to be(true) + expect(status.media.first.height).to eq(360) + expect(status.media.first.streams.length).to eq(2) + expect(status.media.first.audio_bit_rate).to be_within(15_000).of(96_000) + expect(status.media.last.audio?).to be(true) + expect(status.media.last.streams.length).to eq(1) + expect(status.media.last.audio_bit_rate).to be_within(15_000).of(96_000) + + expect(reports.length).to be >= 1 + expect(reports).to all(be_a(Reporters::Output)) end end end diff --git a/spec/ffmpeg_spec.rb b/spec/ffmpeg_spec.rb index 0d0bec6..ea2659e 100644 --- a/spec/ffmpeg_spec.rb +++ b/spec/ffmpeg_spec.rb @@ -3,109 +3,106 @@ require 'spec_helper' describe FFMPEG do - describe '.logger' do - after do - FFMPEG.logger = Logger.new(nil) - end + before do + described_class.instance_variable_set(:@logger, nil) + described_class.instance_variable_set(:@ffmpeg_binary, nil) + described_class.instance_variable_set(:@ffprobe_binary, nil) + end - it 'should be a Logger' do - expect(FFMPEG.logger).to be_instance_of(Logger) - end + after do + described_class.instance_variable_set(:@logger, nil) + described_class.instance_variable_set(:@ffmpeg_binary, nil) + described_class.instance_variable_set(:@ffprobe_binary, nil) + end - it 'should be at info level' do - FFMPEG.logger = nil # Reset the logger so that we get the default - expect(FFMPEG.logger.level).to eq(Logger::INFO) + describe '.logger' do + it 'defaults to a Logger with info level' do + expect(described_class.logger).to be_instance_of(Logger) + expect(described_class.logger.level).to eq(Logger::INFO) end + end - it 'should be assignable' do - new_logger = Logger.new($stdout) - FFMPEG.logger = new_logger - expect(FFMPEG.logger).to eq(new_logger) + describe '.logger=' do + it 'assigns the logger' do + logger = Logger.new($stdout) + described_class.logger = logger + expect(described_class.logger).to eq(logger) end end describe '.ffmpeg_binary' do - before do - FFMPEG.instance_variable_set(:@ffmpeg_binary, nil) - end - - after do - FFMPEG.instance_variable_set(:@ffmpeg_binary, nil) - end - - it 'should default to finding from path' do - allow(FFMPEG).to receive(:which) { '/usr/local/bin/ffmpeg' } - allow(File).to receive(:executable?) { true } - expect(FFMPEG.ffmpeg_binary).to eq FFMPEG.which('ffmpeg') - end - - it 'should be assignable' do - allow(File).to receive(:executable?).with('/new/path/to/ffmpeg') { true } - FFMPEG.ffmpeg_binary = '/new/path/to/ffmpeg' - expect(FFMPEG.ffmpeg_binary).to eq '/new/path/to/ffmpeg' + it 'defaults to finding from path' do + expect(described_class).to receive(:which).and_return('/path/to/ffmpeg') + expect(described_class.ffmpeg_binary).to eq('/path/to/ffmpeg') end + end - it 'should raise exception if it cannot find assigned executable' do - expect { FFMPEG.ffmpeg_binary = '/new/path/to/ffmpeg' }.to raise_error(Errno::ENOENT) + describe '.ffmpeg_binary=' do + it 'assigns the ffmpeg binary' do + expect(File).to receive(:executable?).with('/path/to/ffmpeg').and_return(true) + described_class.ffmpeg_binary = '/path/to/ffmpeg' + expect(described_class.ffmpeg_binary).to eq('/path/to/ffmpeg') end - it 'should raise exception if it cannot find executable on path' do - allow(File).to receive(:executable?) { false } - expect { FFMPEG.ffmpeg_binary }.to raise_error(Errno::ENOENT) + context 'when the assigned value is not executable' do + it 'raises an error' do + expect(File).to receive(:executable?).with('/path/to/ffmpeg').and_return(false) + expect { described_class.ffmpeg_binary = '/path/to/ffmpeg' }.to raise_error(Errno::ENOENT) + end end end - describe '.ffprobe_binary' do - before do - FFMPEG.instance_variable_set(:@ffprobe_binary, nil) - end + describe '.ffmpeg_execute' do + let(:args) { ['-i', fixture_media_file('hello.wav'), '-f', 'null', '/dev/null'] } - after do - FFMPEG.instance_variable_set(:@ffprobe_binary, nil) - end + it 'returns the process status and yields reports' do + reports = [] - it 'should default to finding from path' do - allow(FFMPEG).to receive(:which) { '/usr/local/bin/ffprobe' } - allow(File).to receive(:executable?) { true } - expect(FFMPEG.ffprobe_binary).to eq FFMPEG.which('ffprobe') - end + status = described_class.ffmpeg_execute(*args) do |report| + reports << report + end - it 'should be assignable' do - allow(File).to receive(:executable?).with('/new/path/to/ffprobe') { true } - FFMPEG.ffprobe_binary = '/new/path/to/ffprobe' - expect(FFMPEG.ffprobe_binary).to eq '/new/path/to/ffprobe' + expect(status).to be_a(Process::Status) + expect(status.exitstatus).to eq(0) + expect(reports.length).to be >= 1 end - it 'should raise exception if it cannot find assigned executable' do - expect { FFMPEG.ffprobe_binary = '/new/path/to/ffprobe' }.to raise_error(Errno::ENOENT) - end + context 'when ffmpeg hangs' do + before do + FFMPEG.io_timeout = 0.5 + FFMPEG.ffmpeg_binary = fixture_file('bin/ffmpeg-hanging') + end - it 'should raise exception if it cannot find executable on path' do - allow(File).to receive(:executable?) { false } - expect { FFMPEG.ffprobe_binary }.to raise_error(Errno::ENOENT) - end - end - - describe '.max_http_redirect_attempts' do - after do - FFMPEG.max_http_redirect_attempts = nil - end + after do + FFMPEG.remove_instance_variable(:@io_timeout) + FFMPEG.ffmpeg_binary = nil + end - it 'should default to 10' do - expect(FFMPEG.max_http_redirect_attempts).to eq 10 + it 'raises Timeout::Error' do + expect { described_class.ffmpeg_execute(*args) }.to raise_error(Timeout::Error) + end end + end - it 'should be an Integer' do - expect { FFMPEG.max_http_redirect_attempts = 1.23 }.to raise_error(ArgumentError) + describe '.ffprobe_binary' do + it 'defaults to finding from path' do + expect(described_class).to receive(:which).and_return('/path/to/ffprobe') + expect(described_class.ffprobe_binary).to eq('/path/to/ffprobe') end + end - it 'should not be negative' do - expect { FFMPEG.max_http_redirect_attempts = -1 }.to raise_error(ArgumentError) + describe '.ffprobe_binary=' do + it 'assigns the ffprobe binary' do + expect(File).to receive(:executable?).with('/path/to/ffprobe').and_return(true) + described_class.ffprobe_binary = '/path/to/ffprobe' + expect(described_class.ffprobe_binary).to eq '/path/to/ffprobe' end - it 'should be assignable' do - FFMPEG.max_http_redirect_attempts = 5 - expect(FFMPEG.max_http_redirect_attempts).to eq 5 + context 'when the assigned value is not executable' do + it 'raises an error' do + expect(File).to receive(:executable?).with('/path/to/ffprobe').and_return(false) + expect { described_class.ffprobe_binary = '/path/to/ffprobe' }.to raise_error(Errno::ENOENT) + end end end end diff --git a/spec/fixtures/bin/ffmpeg-audio-hanging b/spec/fixtures/bin/ffmpeg-audio-hanging deleted file mode 100755 index 7469fa9..0000000 --- a/spec/fixtures/bin/ffmpeg-audio-hanging +++ /dev/null @@ -1,61 +0,0 @@ -#!/usr/bin/env ruby -# frozen_string_literal: true - -warn <<~OUTPUT - ffmpeg version 1.1 Copyright (c) 2000-2013 the FFmpeg developers - built on Jan 16 2013 13:01:30 with Apple clang version 4.1 (tags/Apple/clang-421.11.66) (based on LLVM 3.1svn) - configuration: --prefix=/usr/local/Cellar/ffmpeg/1.1 --enable-shared --enable-pthreads --enable-gpl --enable-version3 --enable-nonfree --enable-hardcoded-tables --enable-avresample --cc=cc --host-cflags= --host-ldflags= --enable-libx264 --enable-libfaac --enable-libmp3lame --enable-libxvid --enable-libvorbis --enable-libvpx --enable-librtmp --enable-libvo-aacenc - libavutil 52. 13.100 / 52. 13.100 - libavcodec 54. 86.100 / 54. 86.100 - libavformat 54. 59.106 / 54. 59.106 - libavdevice 54. 3.102 / 54. 3.102 - libavfilter 3. 32.100 / 3. 32.100 - libswscale 2. 1.103 / 2. 1.103 - libswresample 0. 17.102 / 0. 17.102 - libpostproc 52. 2.100 / 52. 2.100 - Input #0, mov,mp4,m4a,3gp,3g2,mj2, from 'test.mp4': - Metadata: - major_brand : mp42 - minor_version : 0 - compatible_brands: isommp42 - creation_time : 2013-01-09 14:28:40 - Duration: 02:27:46.52, start: 0.000000, bitrate: 2206 kb/s - Stream #0:0(eng): Video: h264 (Main) (avc1 / 0x31637661), yuv420p, 1280x720 [SAR 1:1 DAR 16:9], 2041 kb/s, 25 fps, 25 tbr, 25k tbn, 50 tbc - Metadata: - creation_time : 2013-01-09 14:28:41 - handler_name : Mainconcept MP4 Video Media Handler - Stream #0:1(eng): Audio: aac (mp4a / 0x6134706D), 44100 Hz, stereo, fltp, 61 kb/s - Metadata: - creation_time : 2013-01-09 14:28:41 - handler_name : Mainconcept MP4 Sound Media Handler - Stream #0:2(und): Data: none (rtp / 0x20707472) - Metadata: - creation_time : 2013-01-08 16:14:36 - handler_name : GPAC ISO Hint Handler - Stream #0:3(und): Data: none (rtp / 0x20707472) - Metadata: - creation_time : 2013-01-08 16:15:08 - handler_name : GPAC ISO Hint Handler - Output #0, mp3, to 'audio_only.mp3': - Metadata: - major_brand : mp42 - minor_version : 0 - compatible_brands: isommp42 - TSSE : Lavf54.59.106 - Stream #0:0(eng): Audio: mp3, 48000 Hz, stereo, fltp, 48 kb/s - Metadata: - creation_time : 2013-01-09 14:28:41 - handler_name : Mainconcept MP4 Sound Media Handler -OUTPUT - -if ARGV.length > 2 # looks like we're trying to transcode - warn <<-OUTPUT - Stream mapping: - Stream #0:1 -> #0:0 (aac -> libmp3lame) - Press [q] to stop, [?] for help - OUTPUT - $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/images/img_001.jpeg b/spec/fixtures/images/img_001.jpeg deleted file mode 100644 index 96175d3..0000000 Binary files a/spec/fixtures/images/img_001.jpeg and /dev/null differ diff --git a/spec/fixtures/images/img_002.jpeg b/spec/fixtures/images/img_002.jpeg deleted file mode 100644 index cc029f7..0000000 Binary files a/spec/fixtures/images/img_002.jpeg and /dev/null differ diff --git a/spec/fixtures/images/img_003.jpeg b/spec/fixtures/images/img_003.jpeg deleted file mode 100644 index 36f2f36..0000000 Binary files a/spec/fixtures/images/img_003.jpeg and /dev/null differ diff --git a/spec/fixtures/images/img_004.jpeg b/spec/fixtures/images/img_004.jpeg deleted file mode 100644 index da617b5..0000000 Binary files a/spec/fixtures/images/img_004.jpeg and /dev/null differ diff --git a/spec/fixtures/images/img_005.jpeg b/spec/fixtures/images/img_005.jpeg deleted file mode 100644 index 47eb214..0000000 Binary files a/spec/fixtures/images/img_005.jpeg and /dev/null differ diff --git a/spec/fixtures/images/watermark.png b/spec/fixtures/images/watermark.png deleted file mode 100644 index 2b5e384..0000000 Binary files a/spec/fixtures/images/watermark.png and /dev/null differ diff --git a/spec/fixtures/images/wrong_type/img_001.tiff b/spec/fixtures/images/wrong_type/img_001.tiff deleted file mode 100644 index 96175d3..0000000 Binary files a/spec/fixtures/images/wrong_type/img_001.tiff and /dev/null differ diff --git a/spec/fixtures/images/wrong_type/img_002.tiff b/spec/fixtures/images/wrong_type/img_002.tiff deleted file mode 100644 index cc029f7..0000000 Binary files a/spec/fixtures/images/wrong_type/img_002.tiff and /dev/null differ diff --git a/spec/fixtures/movies/attached_pic.mov b/spec/fixtures/media/attached-pic.mov similarity index 100% rename from spec/fixtures/movies/attached_pic.mov rename to spec/fixtures/media/attached-pic.mov diff --git a/spec/fixtures/movies/broken.mp4 b/spec/fixtures/media/broken.mp4 similarity index 100% rename from spec/fixtures/movies/broken.mp4 rename to spec/fixtures/media/broken.mp4 diff --git a/spec/fixtures/movies/empty.flv b/spec/fixtures/media/empty.flv similarity index 100% rename from spec/fixtures/movies/empty.flv rename to spec/fixtures/media/empty.flv diff --git a/spec/fixtures/sounds/hello.wav b/spec/fixtures/media/hello.wav similarity index 100% rename from spec/fixtures/sounds/hello.wav rename to spec/fixtures/media/hello.wav diff --git a/spec/fixtures/media/landscape@4k60.mp4 b/spec/fixtures/media/landscape@4k60.mp4 new file mode 100644 index 0000000..f6aa69f Binary files /dev/null and b/spec/fixtures/media/landscape@4k60.mp4 differ diff --git a/spec/fixtures/media/landscape@odd.mp4 b/spec/fixtures/media/landscape@odd.mp4 new file mode 100644 index 0000000..04a4008 Binary files /dev/null and b/spec/fixtures/media/landscape@odd.mp4 differ diff --git a/spec/fixtures/sounds/napoleon.mp3 b/spec/fixtures/media/napoleon.mp3 similarity index 100% rename from spec/fixtures/sounds/napoleon.mp3 rename to spec/fixtures/media/napoleon.mp3 diff --git a/spec/fixtures/media/portrait@1080p60.mp4 b/spec/fixtures/media/portrait@1080p60.mp4 new file mode 100644 index 0000000..fc2f740 Binary files /dev/null and b/spec/fixtures/media/portrait@1080p60.mp4 differ diff --git a/spec/fixtures/media/portrait@4k60.mp4 b/spec/fixtures/media/portrait@4k60.mp4 new file mode 100644 index 0000000..428305c Binary files /dev/null and b/spec/fixtures/media/portrait@4k60.mp4 differ diff --git a/spec/fixtures/movies/ios_rotate0.mov b/spec/fixtures/media/rotated@0.mov similarity index 100% rename from spec/fixtures/movies/ios_rotate0.mov rename to spec/fixtures/media/rotated@0.mov diff --git a/spec/fixtures/movies/ios_rotate180.mov b/spec/fixtures/media/rotated@180.mov similarity index 100% rename from spec/fixtures/movies/ios_rotate180.mov rename to spec/fixtures/media/rotated@180.mov diff --git a/spec/fixtures/movies/ios_rotate270.mov b/spec/fixtures/media/rotated@270.mov similarity index 100% rename from spec/fixtures/movies/ios_rotate270.mov rename to spec/fixtures/media/rotated@270.mov diff --git a/spec/fixtures/movies/ios_rotate90.mov b/spec/fixtures/media/rotated@90.mov similarity index 100% rename from spec/fixtures/movies/ios_rotate90.mov rename to spec/fixtures/media/rotated@90.mov diff --git a/spec/fixtures/movies/multi_audio_movie.mp4 b/spec/fixtures/media/widescreen-multi-audio.mp4 similarity index 100% rename from spec/fixtures/movies/multi_audio_movie.mp4 rename to spec/fixtures/media/widescreen-multi-audio.mp4 diff --git a/spec/fixtures/media/widescreen-no-audio.mp4 b/spec/fixtures/media/widescreen-no-audio.mp4 new file mode 100644 index 0000000..3be484e Binary files /dev/null and b/spec/fixtures/media/widescreen-no-audio.mp4 differ diff --git a/spec/fixtures/movies/awesome'movie.mov b/spec/fixtures/movies/awesome'movie.mov deleted file mode 100644 index 9cf76b6..0000000 Binary files a/spec/fixtures/movies/awesome'movie.mov and /dev/null differ diff --git a/spec/fixtures/movies/awesome_movie.mov b/spec/fixtures/movies/awesome_movie.mov deleted file mode 100644 index 9cf76b6..0000000 Binary files a/spec/fixtures/movies/awesome_movie.mov and /dev/null differ diff --git a/spec/fixtures/movies/sideways_movie.mov b/spec/fixtures/movies/sideways_movie.mov deleted file mode 100644 index 0f9a3e3..0000000 Binary files a/spec/fixtures/movies/sideways_movie.mov and /dev/null differ diff --git a/spec/fixtures/movies/weird_aspect_movie.mpg b/spec/fixtures/movies/weird_aspect_movie.mpg deleted file mode 100644 index 7d9bb7a..0000000 Binary files a/spec/fixtures/movies/weird_aspect_movie.mpg and /dev/null differ diff --git a/spec/fixtures/movies/widescreen_movie.mov b/spec/fixtures/movies/widescreen_movie.mov deleted file mode 100644 index b0fa4c1..0000000 Binary files a/spec/fixtures/movies/widescreen_movie.mov and /dev/null differ diff --git a/spec/fixtures/outputs/ffprobe_bad_json.txt b/spec/fixtures/outputs/ffprobe-bad-json.txt similarity index 100% rename from spec/fixtures/outputs/ffprobe_bad_json.txt rename to spec/fixtures/outputs/ffprobe-bad-json.txt diff --git a/spec/fixtures/outputs/ffprobe_error.txt b/spec/fixtures/outputs/ffprobe-error.txt similarity index 100% rename from spec/fixtures/outputs/ffprobe_error.txt rename to spec/fixtures/outputs/ffprobe-error.txt diff --git a/spec/fixtures/outputs/ffprobe_iso8859.txt b/spec/fixtures/outputs/ffprobe-iso8859.txt similarity index 100% rename from spec/fixtures/outputs/ffprobe_iso8859.txt rename to spec/fixtures/outputs/ffprobe-iso8859.txt diff --git a/spec/fixtures/outputs/ffprobe_unsupported_audio_and_video_stderr.txt b/spec/fixtures/outputs/ffprobe-unsupported-audio-and-video-stderr.txt similarity index 100% rename from spec/fixtures/outputs/ffprobe_unsupported_audio_and_video_stderr.txt rename to spec/fixtures/outputs/ffprobe-unsupported-audio-and-video-stderr.txt diff --git a/spec/fixtures/outputs/ffprobe_unsupported_audio_and_video_stdout.txt b/spec/fixtures/outputs/ffprobe-unsupported-audio-and-video-stdout.txt similarity index 100% rename from spec/fixtures/outputs/ffprobe_unsupported_audio_and_video_stdout.txt rename to spec/fixtures/outputs/ffprobe-unsupported-audio-and-video-stdout.txt diff --git a/spec/fixtures/outputs/ffprobe_unsupported_audio_stderr.txt b/spec/fixtures/outputs/ffprobe-unsupported-audio-stderr.txt similarity index 100% rename from spec/fixtures/outputs/ffprobe_unsupported_audio_stderr.txt rename to spec/fixtures/outputs/ffprobe-unsupported-audio-stderr.txt diff --git a/spec/fixtures/outputs/ffprobe_unsupported_audio_stdout.txt b/spec/fixtures/outputs/ffprobe-unsupported-audio-stdout.txt similarity index 100% rename from spec/fixtures/outputs/ffprobe_unsupported_audio_stdout.txt rename to spec/fixtures/outputs/ffprobe-unsupported-audio-stdout.txt diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 7cd54f0..0c6efb0 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -8,40 +8,60 @@ require 'debug' require 'fileutils' +require 'uri' require 'webmock/rspec' require 'webrick' -WebMock.allow_net_connect! -FFMPEG.logger = Logger.new(nil) +module FFMPEG + class << self + alias ffprobe_raw_capture3 ffprobe_capture3 + + def ffprobe_capture3(*args) + cache_key = args.hash + @ffprobe_cache ||= {} + @ffprobe_cache[cache_key] ||= ffprobe_raw_capture3(*args) + end + end +end RSpec.configure do |config| config.filter_run focus: true config.run_all_when_everything_filtered = true - - config.before(:each) do - stub_request(:head, 'http://127.0.0.1:8000/moved/awesome_movie.mov') - .with(headers: { 'Accept' => '*/*', 'User-Agent' => 'Ruby' }) - .to_return(status: 302, headers: { location: '/awesome_movie.mov' }) - stub_request(:head, 'http://127.0.0.1:8000/notfound/awesome_movie.mov') - .with(headers: { 'Accept' => '*/*', 'User-Agent' => 'Ruby' }) - .to_return(status: 404, headers: {}) - end - config.after(:suite) do - FileUtils.rm_rf(tmp_path) + FileUtils.rm_rf(tmp_dir) end end -def fixture_path - @fixture_path ||= File.join(File.dirname(__FILE__), 'fixtures') +def fixture_dir + @fixture_dir ||= File.join(File.dirname(__FILE__), 'fixtures') +end + +def fixture_file(*path) + File.join(fixture_dir, *path) +end + +def fixture_media_dir + @fixture_media_dir ||= File.join(fixture_dir, 'media') end -def tmp_path - @tmp_path ||= File.join(File.dirname(__FILE__), '..', 'tmp') +def fixture_media_url + 'http://127.0.0.1:8000' end -def read_fixture_file(filename) - File.read(File.join(fixture_path, filename)) +def fixture_media_file(*path, remote: false) + if remote + URI.join(fixture_media_url, *path).to_s + else + File.join(fixture_media_dir, *path) + end +end + +def read_fixture_file(*path) + File.read(fixture_file(*path)) +end + +def tmp_dir + @tmp_dir ||= File.join(File.dirname(__FILE__), '..', 'tmp') end def tmp_file(filename: nil, basename: nil, ext: nil) @@ -52,22 +72,30 @@ def tmp_file(filename: nil, basename: nil, ext: nil) filename += ".#{ext}" if ext end - File.join(tmp_path, filename) + File.join(tmp_dir, filename) end def start_web_server @server = WEBrick::HTTPServer.new( Port: 8000, - DocumentRoot: "#{fixture_path}/movies", + DocumentRoot: "#{fixture_dir}/media", Logger: WEBrick::Log.new(File.open(File::NULL, 'w')), AccessLog: [] ) - @server.mount_proc '/unauthorized.mov' do |_, response| + @server.mount_proc '/unauthorized' do |_, response| response.body = 'Unauthorized' response.status = 403 end + @server.mount_proc '/moved' do |request, response| + filename = request.path&.split('/')&.last + raise WEBrick::HTTPStatus::ServerError unless filename + + response['Location'] = "/#{filename}" + response.status = 302 + end + Thread.new { @server.start } end @@ -75,5 +103,5 @@ def stop_web_server @server.shutdown end -FileUtils.rm_rf(tmp_path) -FileUtils.mkdir_p tmp_path +FileUtils.rm_rf(tmp_dir) +FileUtils.mkdir_p(tmp_dir)