From 201ad51f76e3bf1b7912ac561205f3203dbc8d34 Mon Sep 17 00:00:00 2001 From: bajankristof Date: Thu, 28 Nov 2024 10:52:39 +0100 Subject: [PATCH] feat: add preset to create simple thumbnails --- lib/ffmpeg.rb | 1 + lib/ffmpeg/filters/scale.rb | 45 +++++++++++++++++++++ lib/ffmpeg/presets/dash/h264.rb | 1 - lib/ffmpeg/presets/h264.rb | 30 +++++--------- lib/ffmpeg/presets/thumbnail.rb | 65 +++++++++++++++++++++++++++++++ spec/ffmpeg/filters/scale_spec.rb | 44 +++++++++++++++++++++ spec/ffmpeg/media_spec.rb | 11 ------ spec/ffmpeg/presets_spec.rb | 10 +++++ spec/spec_helper.rb | 14 ++++++- 9 files changed, 187 insertions(+), 34 deletions(-) create mode 100644 lib/ffmpeg/presets/thumbnail.rb diff --git a/lib/ffmpeg.rb b/lib/ffmpeg.rb index dba7d92..eb4d8db 100644 --- a/lib/ffmpeg.rb +++ b/lib/ffmpeg.rb @@ -21,6 +21,7 @@ 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' diff --git a/lib/ffmpeg/filters/scale.rb b/lib/ffmpeg/filters/scale.rb index 93d00d2..b61daa0 100644 --- a/lib/ffmpeg/filters/scale.rb +++ b/lib/ffmpeg/filters/scale.rb @@ -16,6 +16,51 @@ def scale(width: nil, height: nil, force_original_aspect_ratio: nil, flags: nil) # The Scale class uses the scale filter # to resize a multimedia stream. class Scale < Filter + 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 || -2 + height = max_width || -2 + else + width = max_width || -2 + height = max_height || -2 + 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) diff --git a/lib/ffmpeg/presets/dash/h264.rb b/lib/ffmpeg/presets/dash/h264.rb index d3ac8eb..c2fea67 100644 --- a/lib/ffmpeg/presets/dash/h264.rb +++ b/lib/ffmpeg/presets/dash/h264.rb @@ -2,7 +2,6 @@ require_relative '../../filter' require_relative '../../filters/fps' -require_relative '../../filters/scale' require_relative '../../filters/split' require_relative '../dash' require_relative '../h264' diff --git a/lib/ffmpeg/presets/h264.rb b/lib/ffmpeg/presets/h264.rb index 19f9468..1642080 100644 --- a/lib/ffmpeg/presets/h264.rb +++ b/lib/ffmpeg/presets/h264.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +require_relative '../filters/scale' require_relative '../preset' module FFMPEG @@ -172,6 +173,14 @@ def initialize( 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 @@ -225,28 +234,9 @@ def fits?(media) end def scale_filter(media) - unless media.is_a?(FFMPEG::Media) - raise ArgumentError, - "Unknown media format #{media.class}, expected #{FFMPEG::Media}" - end - return unless @max_width || @max_height - if media.rotated? - width = @max_height || -2 - height = @max_width || -2 - else - width = @max_width || -2 - height = @max_height || -2 - 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 + Filters::Scale.contained(media, max_width: @max_width, max_height: @max_height) 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/spec/ffmpeg/filters/scale_spec.rb b/spec/ffmpeg/filters/scale_spec.rb index 8007b42..2a06dc8 100644 --- a/spec/ffmpeg/filters/scale_spec.rb +++ b/spec/ffmpeg/filters/scale_spec.rb @@ -13,6 +13,50 @@ module FFMPEG 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 diff --git a/spec/ffmpeg/media_spec.rb b/spec/ffmpeg/media_spec.rb index 24a1f70..741fd52 100644 --- a/spec/ffmpeg/media_spec.rb +++ b/spec/ffmpeg/media_spec.rb @@ -3,17 +3,6 @@ require_relative '../spec_helper' module FFMPEG - # Cache the ffprobe output for testing. - 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 - describe Media do let(:load) { true } let(:autoload) { true } diff --git a/spec/ffmpeg/presets_spec.rb b/spec/ffmpeg/presets_spec.rb index d0a8f98..f7a7543 100644 --- a/spec/ffmpeg/presets_spec.rb +++ b/spec/ffmpeg/presets_spec.rb @@ -69,6 +69,16 @@ def initialize(name:, preset:, assert:) 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 diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 6172d26..0c6efb0 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -12,7 +12,17 @@ require 'webmock/rspec' require 'webrick' -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 @@ -94,4 +104,4 @@ def stop_web_server end FileUtils.rm_rf(tmp_dir) -FileUtils.mkdir_p tmp_dir +FileUtils.mkdir_p(tmp_dir)