Skip to content

Commit

Permalink
feat: add preset to create simple thumbnails
Browse files Browse the repository at this point in the history
  • Loading branch information
bajankristof committed Nov 28, 2024
1 parent 80b78aa commit 201ad51
Show file tree
Hide file tree
Showing 9 changed files with 187 additions and 34 deletions.
1 change: 1 addition & 0 deletions lib/ffmpeg.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
45 changes: 45 additions & 0 deletions lib/ffmpeg/filters/scale.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 0 additions & 1 deletion lib/ffmpeg/presets/dash/h264.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
30 changes: 10 additions & 20 deletions lib/ffmpeg/presets/h264.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# frozen_string_literal: true

require_relative '../filters/scale'
require_relative '../preset'

module FFMPEG
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
65 changes: 65 additions & 0 deletions lib/ffmpeg/presets/thumbnail.rb
Original file line number Diff line number Diff line change
@@ -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: '%<basename>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
44 changes: 44 additions & 0 deletions spec/ffmpeg/filters/scale_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,50 @@ module FFMPEG

module Filters
describe Scale do
describe '.contained' do
let(:media) { Media.new(fixture_media_file('[email protected]')) }

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('[email protected]')) }

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
Expand Down
11 changes: 0 additions & 11 deletions spec/ffmpeg/media_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
10 changes: 10 additions & 0 deletions spec/ffmpeg/presets_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 12 additions & 2 deletions spec/spec_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -94,4 +104,4 @@ def stop_web_server
end

FileUtils.rm_rf(tmp_dir)
FileUtils.mkdir_p tmp_dir
FileUtils.mkdir_p(tmp_dir)

0 comments on commit 201ad51

Please sign in to comment.