From fc5269e770e22add05b0824442d765970bb71626 Mon Sep 17 00:00:00 2001 From: bajankristof Date: Wed, 29 Jan 2025 09:35:03 +0100 Subject: [PATCH] feat: improve presets to be more customizable --- .ruby-version | 2 +- .tool-versions | 1 + CHANGELOG | 9 ++ lib/ffmpeg/presets/aac.rb | 18 ++- lib/ffmpeg/presets/dash.rb | 26 +++- lib/ffmpeg/presets/dash/aac.rb | 27 ++-- lib/ffmpeg/presets/dash/h264.rb | 210 ++++++++++++++++++++++++-------- lib/ffmpeg/presets/h264.rb | 134 ++++++++++++++++---- lib/ffmpeg/presets/thumbnail.rb | 8 +- lib/ffmpeg/version.rb | 2 +- spec/ffmpeg/presets_spec.rb | 2 +- 11 files changed, 346 insertions(+), 93 deletions(-) create mode 100644 .tool-versions diff --git a/.ruby-version b/.ruby-version index 9c25013..47b322c 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.3.6 +3.4.1 diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000..041df9a --- /dev/null +++ b/.tool-versions @@ -0,0 +1 @@ +ruby 3.4.1 diff --git a/CHANGELOG b/CHANGELOG index 1394500..b29eeb2 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,12 @@ +== 7.0.0-beta.2 2025-01-29 + +Fixes: +* Make sure the DASH H.264 preset includes at least one video stream. + +Improvements: +* Added LD H.264 presets for the DASH H.264 preset that are only included when no other preset fits the specific media. +* Added more options to customize most presets. + == 7.0.0-beta.1 2025-01-22 Fixes: diff --git a/lib/ffmpeg/presets/aac.rb b/lib/ffmpeg/presets/aac.rb index d55e440..d031f11 100644 --- a/lib/ffmpeg/presets/aac.rb +++ b/lib/ffmpeg/presets/aac.rb @@ -10,39 +10,45 @@ class << self def aac_128k( name: 'AAC 128k', filename: '%s.aac', - metadata: nil + metadata: nil, + & ) AAC.new( name:, filename:, metadata:, - audio_bit_rate: '128k' + audio_bit_rate: '128k', + & ) end def aac_192k( name: 'AAC 192k', filename: '%s.aac', - metadata: nil + metadata: nil, + & ) AAC.new( name:, filename:, metadata:, - audio_bit_rate: '192k' + audio_bit_rate: '192k', + & ) end def aac_320k( name: 'AAC 320k', filename: '%s.aac', - metadata: nil + metadata: nil, + & ) AAC.new( name:, filename:, metadata:, - audio_bit_rate: '320k' + audio_bit_rate: '320k', + & ) end end diff --git a/lib/ffmpeg/presets/dash.rb b/lib/ffmpeg/presets/dash.rb index d272a19..58ec10e 100644 --- a/lib/ffmpeg/presets/dash.rb +++ b/lib/ffmpeg/presets/dash.rb @@ -8,23 +8,39 @@ module FFMPEG module Presets # Preset to encode DASH media files. class DASH < Preset + attr_reader :segment_duration, :min_keyframe_interval, :max_keyframe_interval, :scene_change_threshold + # @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 segment_duration [Integer] The duration of each segment in seconds. + # @param min_keyframe_interval [Integer] The minimum keyframe interval in frames. + # @param max_keyframe_interval [Integer] The maximum keyframe interval in frames. + # @param scene_change_threshold [Integer] The scene change threshold. # @yield The block to execute to compose the command arguments. def initialize( name: nil, filename: nil, metadata: nil, + segment_duration: 2, + min_keyframe_interval: 48, + max_keyframe_interval: 48, + scene_change_threshold: 0, & ) - super do + @segment_duration = segment_duration + @min_keyframe_interval = min_keyframe_interval + @max_keyframe_interval = max_keyframe_interval + @scene_change_threshold = scene_change_threshold + preset = self + + 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 + segment_duration preset.segment_duration + min_keyframe_interval preset.min_keyframe_interval + max_keyframe_interval preset.max_keyframe_interval + scene_change_threshold preset.scene_change_threshold muxing_flags '+faststart' map_chapters '-1' diff --git a/lib/ffmpeg/presets/dash/aac.rb b/lib/ffmpeg/presets/dash/aac.rb index 690d988..313a8ba 100644 --- a/lib/ffmpeg/presets/dash/aac.rb +++ b/lib/ffmpeg/presets/dash/aac.rb @@ -11,39 +11,51 @@ class << self def aac_128k( name: 'DASH AAC 128k', filename: '%s.mpd', - metadata: nil + metadata: nil, + segment_duration: 2, + & ) AAC.new( name:, filename:, metadata:, - audio_bit_rate: '128k' + segment_duration:, + audio_bit_rate: '128k', + & ) end def aac_192k( name: 'DASH AAC 192k', filename: '%s.mpd', - metadata: nil + metadata: nil, + segment_duration: 2, + & ) AAC.new( name:, filename:, metadata:, - audio_bit_rate: '192k' + segment_duration:, + audio_bit_rate: '192k', + & ) end def aac_320k( name: 'DASH AAC 320k', filename: '%s.mpd', - metadata: nil + metadata: nil, + segment_duration: 2, + & ) AAC.new( name:, filename:, metadata:, - audio_bit_rate: '320k' + segment_duration:, + audio_bit_rate: '320k', + & ) end end @@ -61,13 +73,14 @@ def initialize( name: nil, filename: nil, metadata: nil, + segment_duration: 2, audio_bit_rate: '128k', & ) @audio_bit_rate = audio_bit_rate preset = self - super(name:, filename:, metadata:) do + super(name:, filename:, metadata:, segment_duration:) do audio_codec_name 'aac' instance_exec(&) if block_given? diff --git a/lib/ffmpeg/presets/dash/h264.rb b/lib/ffmpeg/presets/dash/h264.rb index c2fea67..4e5cb5b 100644 --- a/lib/ffmpeg/presets/dash/h264.rb +++ b/lib/ffmpeg/presets/dash/h264.rb @@ -16,15 +16,31 @@ def h264_360p( name: 'DASH H.264 360p', filename: '%s.mpd', metadata: nil, + segment_duration: 2, + min_keyframe_interval: 48, + max_keyframe_interval: 48, + scene_change_threshold: 0, audio_bit_rate: '128k', - frame_rate: 30 + frame_rate: 30, + ld_frame_rate: 24, + & ) H264.new( name:, filename:, metadata:, - audio_bit_rate:, - h264_presets: [Presets.h264_360p(frame_rate:)] + segment_duration:, + min_keyframe_interval:, + max_keyframe_interval:, + scene_change_threshold:, + h264_presets: [ + Presets.h264_360p(audio_bit_rate:, frame_rate:) + ], + ld_h264_presets: [ + Presets.h264_240p(audio_bit_rate:, frame_rate: ld_frame_rate), + Presets.h264_144p(audio_bit_rate:, frame_rate: ld_frame_rate) + ], + & ) end @@ -32,18 +48,32 @@ def h264_480p( name: 'DASH H.264 480p', filename: '%s.mpd', metadata: nil, + segment_duration: 2, + min_keyframe_interval: 48, + max_keyframe_interval: 48, + scene_change_threshold: 0, audio_bit_rate: '128k', - frame_rate: 30 + frame_rate: 30, + ld_frame_rate: 24, + & ) H264.new( name:, filename:, metadata:, - audio_bit_rate:, + segment_duration:, + min_keyframe_interval:, + max_keyframe_interval:, + scene_change_threshold:, h264_presets: [ - Presets.h264_480p(frame_rate:), - Presets.h264_360p(frame_rate:) - ] + Presets.h264_480p(audio_bit_rate:, frame_rate:), + Presets.h264_360p(audio_bit_rate:, frame_rate:) + ], + ld_h264_presets: [ + Presets.h264_240p(audio_bit_rate:, frame_rate: ld_frame_rate), + Presets.h264_144p(audio_bit_rate:, frame_rate: ld_frame_rate) + ], + & ) end @@ -51,20 +81,34 @@ def h264_720p( name: 'DASH H.264 720p', filename: '%s.mpd', metadata: nil, + segment_duration: 2, + min_keyframe_interval: 48, + max_keyframe_interval: 48, + scene_change_threshold: 0, audio_bit_rate: '128k', + ld_frame_rate: 24, sd_frame_rate: 30, - hd_frame_rate: 30 + hd_frame_rate: 30, + & ) H264.new( name:, filename:, metadata:, - audio_bit_rate:, + segment_duration:, + min_keyframe_interval:, + max_keyframe_interval:, + scene_change_threshold:, 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) - ] + Presets.h264_720p(audio_bit_rate:, frame_rate: hd_frame_rate), + Presets.h264_480p(audio_bit_rate:, frame_rate: sd_frame_rate), + Presets.h264_360p(audio_bit_rate:, frame_rate: sd_frame_rate) + ], + ld_h264_presets: [ + Presets.h264_240p(audio_bit_rate:, frame_rate: ld_frame_rate), + Presets.h264_144p(audio_bit_rate:, frame_rate: ld_frame_rate) + ], + & ) end @@ -72,21 +116,35 @@ def h264_1080p( name: 'DASH H.264 1080p', filename: '%s.mpd', metadata: nil, + segment_duration: 2, + min_keyframe_interval: 48, + max_keyframe_interval: 48, + scene_change_threshold: 0, audio_bit_rate: '128k', + ld_frame_rate: 24, sd_frame_rate: 30, - hd_frame_rate: 30 + hd_frame_rate: 30, + & ) H264.new( name:, filename:, metadata:, - audio_bit_rate:, + segment_duration:, + min_keyframe_interval:, + max_keyframe_interval:, + scene_change_threshold:, 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) - ] + Presets.h264_1080p(audio_bit_rate:, frame_rate: hd_frame_rate), + Presets.h264_720p(audio_bit_rate:, frame_rate: hd_frame_rate), + Presets.h264_480p(audio_bit_rate:, frame_rate: sd_frame_rate), + Presets.h264_360p(audio_bit_rate:, frame_rate: sd_frame_rate) + ], + ld_h264_presets: [ + Presets.h264_240p(audio_bit_rate:, frame_rate: ld_frame_rate), + Presets.h264_144p(audio_bit_rate:, frame_rate: ld_frame_rate) + ], + & ) end @@ -94,22 +152,36 @@ def h264_1440p( name: 'DASH H.264 1440p', filename: '%s.mpd', metadata: nil, + segment_duration: 2, + min_keyframe_interval: 48, + max_keyframe_interval: 48, + scene_change_threshold: 0, audio_bit_rate: '128k', + ld_frame_rate: 24, sd_frame_rate: 30, - hd_frame_rate: 30 + hd_frame_rate: 30, + & ) H264.new( name:, filename:, metadata:, - audio_bit_rate:, + segment_duration:, + min_keyframe_interval:, + max_keyframe_interval:, + scene_change_threshold:, 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) - ] + Presets.h264_1440p(audio_bit_rate:, frame_rate: hd_frame_rate), + Presets.h264_1080p(audio_bit_rate:, frame_rate: hd_frame_rate), + Presets.h264_720p(audio_bit_rate:, frame_rate: hd_frame_rate), + Presets.h264_480p(audio_bit_rate:, frame_rate: sd_frame_rate), + Presets.h264_360p(audio_bit_rate:, frame_rate: sd_frame_rate) + ], + ld_h264_presets: [ + Presets.h264_240p(audio_bit_rate:, frame_rate: ld_frame_rate), + Presets.h264_144p(audio_bit_rate:, frame_rate: ld_frame_rate) + ], + & ) end @@ -117,44 +189,66 @@ def h264_4k( name: 'DASH H.264 4K', filename: '%s.mpd', metadata: nil, + segment_duration: 2, + min_keyframe_interval: 48, + max_keyframe_interval: 48, + scene_change_threshold: 0, audio_bit_rate: '128k', + ld_frame_rate: 24, sd_frame_rate: 30, - hd_frame_rate: 30, - uhd_frame_rate: 30 + hd_frame_rate: 60, + uhd_frame_rate: 60, + & ) H264.new( name:, filename:, metadata:, - audio_bit_rate:, + segment_duration:, + min_keyframe_interval:, + max_keyframe_interval:, + scene_change_threshold:, 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) - ] + Presets.h264_4k(audio_bit_rate:, frame_rate: uhd_frame_rate), + Presets.h264_1440p(audio_bit_rate:, frame_rate: hd_frame_rate), + Presets.h264_1080p(audio_bit_rate:, frame_rate: hd_frame_rate), + Presets.h264_720p(audio_bit_rate:, frame_rate: hd_frame_rate), + Presets.h264_480p(audio_bit_rate:, frame_rate: sd_frame_rate), + Presets.h264_360p(audio_bit_rate:, frame_rate: sd_frame_rate) + ], + ld_h264_presets: [ + Presets.h264_240p(audio_bit_rate:, frame_rate: ld_frame_rate), + Presets.h264_144p(audio_bit_rate:, frame_rate: ld_frame_rate) + ], + & ) end end # Preset to encode DASH H.264 video files. class H264 < DASH - attr_reader :audio_bit_rate, :h264_presets + attr_reader :h264_presets, :ld_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. + # @param segment_duration [Integer] The duration of each segment in seconds. + # @param min_keyframe_interval [Integer] The minimum keyframe interval in frames. + # @param max_keyframe_interval [Integer] The maximum keyframe interval in frames. + # @param scene_change_threshold [Integer] The scene change threshold. + # @param h264_presets [Array] The H.264 presets to use for video streams and the audio stream. + # @param ld_h264_presets [Array] The H.264 presets to use for low-definition video streams. # @yield The block to execute to compose the command arguments. def initialize( name: nil, filename: nil, metadata: nil, - audio_bit_rate: '128k', + segment_duration: 2, + min_keyframe_interval: 48, + max_keyframe_interval: 48, + scene_change_threshold: 0, h264_presets: [Presets.h264_1080p, Presets.h264_720p, Presets.h264_480p, Presets.h264_360p], + ld_h264_presets: [Presets.h264_240p, Presets.h264_144p], & ) unless h264_presets.is_a?(Array) @@ -168,20 +262,28 @@ def initialize( end end - @audio_bit_rate = audio_bit_rate @h264_presets = h264_presets + @ld_h264_presets = ld_h264_presets preset = self - super(name:, filename:, metadata:) do + super( + name:, + filename:, + metadata:, + segment_duration:, + min_keyframe_interval:, + max_keyframe_interval:, + scene_change_threshold: + ) 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) } + # Only include usable H.264 presets. + h264_presets = preset.usable_h264_presets(media) + if media.video_streams? # Split the default video stream into multiple streams, # one for each H.264 preset (e.g.: [v:0]split=2[v0][v1]). split_filter = @@ -217,10 +319,20 @@ def initialize( end map media.audio_mapping_id do - audio_bit_rate preset.audio_bit_rate + audio_bit_rate h264_presets.first.audio_bit_rate end end end + + def usable_h264_presets(media) + result = h264_presets.filter { |h264_preset| h264_preset.fits?(media) } + return result unless result.empty? + + result = ld_h264_presets.filter { |h264_preset| h264_preset.fits?(media) } + return result unless result.empty? + + [ld_h264_presets.last || h264_presets.last] + end end end end diff --git a/lib/ffmpeg/presets/h264.rb b/lib/ffmpeg/presets/h264.rb index 1642080..2bfc50b 100644 --- a/lib/ffmpeg/presets/h264.rb +++ b/lib/ffmpeg/presets/h264.rb @@ -8,135 +8,227 @@ module FFMPEG module Presets # rubocop:enable Style/Documentation class << self + def h264_144p( + name: 'H.264 144p', + filename: '%s.144p.mp4', + metadata: nil, + audio_bit_rate: '128k', + video_preset: 'ultrafast', + video_profile: 'baseline', + frame_rate: 30, + constant_rate_factor: 28, + pixel_format: 'yuv420p', + & + ) + H264.new( + name:, + filename:, + metadata:, + audio_bit_rate:, + video_preset:, + video_profile:, + frame_rate:, + constant_rate_factor:, + pixel_format:, + max_width: 256, + max_height: 144, + & + ) + end + + def h264_240p( + name: 'H.264 240p', + filename: '%s.240p.mp4', + metadata: nil, + audio_bit_rate: '128k', + video_preset: 'ultrafast', + video_profile: 'baseline', + frame_rate: 30, + constant_rate_factor: 28, + pixel_format: 'yuv420p', + & + ) + H264.new( + name:, + filename:, + metadata:, + audio_bit_rate:, + video_preset:, + video_profile:, + frame_rate:, + constant_rate_factor:, + pixel_format:, + max_width: 426, + max_height: 240, + & + ) + end + def h264_360p( name: 'H.264 360p', filename: '%s.360p.mp4', + metadata: nil, + audio_bit_rate: '128k', video_preset: 'ultrafast', video_profile: 'baseline', frame_rate: 30, constant_rate_factor: 28, - pixel_format: 'yuv420p' + pixel_format: 'yuv420p', + & ) H264.new( name:, filename:, + metadata:, + audio_bit_rate:, video_preset:, video_profile:, frame_rate:, constant_rate_factor:, pixel_format:, max_width: 640, - max_height: 360 + max_height: 360, + & ) end def h264_480p( name: 'H.264 480p', filename: '%s.480p.mp4', + metadata: nil, + audio_bit_rate: '128k', video_preset: 'fast', video_profile: 'main', frame_rate: 30, - constant_rate_factor: 26, - pixel_format: 'yuv420p' + constant_rate_factor: 27, + pixel_format: 'yuv420p', + & ) H264.new( name:, filename:, + metadata:, + audio_bit_rate:, video_preset:, video_profile:, frame_rate:, constant_rate_factor:, pixel_format:, max_width: 854, - max_height: 480 + max_height: 480, + & ) end def h264_720p( name: 'H.264 720p', filename: '%s.720p.mp4', + metadata: nil, + audio_bit_rate: '128k', video_preset: 'fast', video_profile: 'high', - frame_rate: 30, - constant_rate_factor: 24, - pixel_format: 'yuv420p' + frame_rate: 60, + constant_rate_factor: 27, + pixel_format: 'yuv420p', + & ) H264.new( name:, filename:, + metadata:, + audio_bit_rate:, video_preset:, video_profile:, frame_rate:, constant_rate_factor:, pixel_format:, max_width: 1280, - max_height: 720 + max_height: 720, + & ) end def h264_1080p( name: 'H.264 1080p', filename: '%s.1080p.mp4', + metadata: nil, + audio_bit_rate: '128k', video_preset: 'fast', video_profile: 'high', - frame_rate: 30, - constant_rate_factor: 23, - pixel_format: 'yuv420p' + frame_rate: 60, + constant_rate_factor: 27, + pixel_format: 'yuv420p', + & ) H264.new( name:, filename:, + metadata:, + audio_bit_rate:, video_preset:, video_profile:, frame_rate:, constant_rate_factor:, pixel_format:, max_width: 1920, - max_height: 1080 + max_height: 1080, + & ) end def h264_1440p( name: 'H.264 2K', filename: '%s.2k.mp4', + metadata: nil, + audio_bit_rate: '128k', video_preset: 'fast', video_profile: 'high', - frame_rate: 30, - constant_rate_factor: 23, - pixel_format: 'yuv420p' + frame_rate: 60, + constant_rate_factor: 26, + pixel_format: 'yuv420p', + & ) H264.new( name:, filename:, + metadata:, + audio_bit_rate:, video_preset:, video_profile:, frame_rate:, constant_rate_factor:, pixel_format:, max_width: 2560, - max_height: 1440 + max_height: 1440, + & ) end def h264_4k( name: 'H.264 4K', filename: '%s.4k.mp4', + metadata: nil, + audio_bit_rate: '128k', video_preset: 'fast', video_profile: 'high', - frame_rate: 30, - constant_rate_factor: 23, - pixel_format: 'yuv420p' + frame_rate: 60, + constant_rate_factor: 26, + pixel_format: 'yuv420p', + & ) H264.new( name:, filename:, + metadata:, + audio_bit_rate:, video_preset:, video_profile:, frame_rate:, constant_rate_factor:, pixel_format:, max_width: 3840, - max_height: 2160 + max_height: 2160, + & ) end end diff --git a/lib/ffmpeg/presets/thumbnail.rb b/lib/ffmpeg/presets/thumbnail.rb index 14e795b..2c69fd5 100644 --- a/lib/ffmpeg/presets/thumbnail.rb +++ b/lib/ffmpeg/presets/thumbnail.rb @@ -13,14 +13,16 @@ def thumbnail( filename: '%s.thumb.jpg', metadata: nil, max_width: nil, - max_height: nil + max_height: nil, + & ) Thumbnail.new( name:, filename:, metadata:, max_width: max_width, - max_height: max_height + max_height: max_height, + & ) end end @@ -49,6 +51,8 @@ def initialize(name: nil, filename: nil, metadata: nil, max_width: nil, max_heig preset = self super(name:, filename:, metadata:) do + instance_exec(&) if block_given? + arg 'ss', (media.duration / 2).floor if media.duration.is_a?(Numeric) arg 'frames:v', 1 filter preset.scale_filter(media) diff --git a/lib/ffmpeg/version.rb b/lib/ffmpeg/version.rb index 2dd6fb0..dd3c1b2 100644 --- a/lib/ffmpeg/version.rb +++ b/lib/ffmpeg/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module FFMPEG - VERSION = '7.0.0-beta.1' + VERSION = '7.0.0-beta.2' end diff --git a/spec/ffmpeg/presets_spec.rb b/spec/ffmpeg/presets_spec.rb index f7a7543..4831f20 100644 --- a/spec/ffmpeg/presets_spec.rb +++ b/spec/ffmpeg/presets_spec.rb @@ -45,7 +45,7 @@ def initialize(name:, preset:, assert:) ), 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), + preset: Presets::DASH.h264_4k, assert: lambda do |media| expect(media.path).to match(/\.mpd\z/) expect(media.streams.length).to be(5)