diff --git a/CHANGELOG b/CHANGELOG index 4c916c2..01bb4b9 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,8 @@ +== 8.5.0 2026-07-16 + +Improvements: +* Add `base_frame_rate` (r_frame_rate), `cfr?` and `vfr?` methods to both `FFMPEG::Media` and `FFMPEG::Stream` to detect variable or constant frame rate. + == 8.4.5 2026-07-02 Improvements: diff --git a/lib/ffmpeg/media.rb b/lib/ffmpeg/media.rb index 9dbd00d..c347456 100644 --- a/lib/ffmpeg/media.rb +++ b/lib/ffmpeg/media.rb @@ -276,7 +276,7 @@ def local? # # @return [Boolean] autoload def include_codec_name?(*codec_names) - self.codec_names.any? { codec_names.include?(_1) } + self.codec_names.intersect?(codec_names) end # Returns the set of all codec names used in the media's streams. @@ -461,11 +461,18 @@ def local? # Returns the frame rate (avg_frame_rate) of the default video stream (if any). # - # @return [Float, nil] + # @return [Rational, nil] autoload def frame_rate default_video_stream&.frame_rate end + # Returns the base frame rate (r_frame_rate) of the default video stream (if any). + # + # @return [Rational, nil] + autoload def base_frame_rate + default_video_stream&.base_frame_rate + end + # Returns the number of frames of the default video stream (if any). # # @return [Integer, nil] @@ -473,6 +480,20 @@ def local? default_video_stream&.frames end + # Returns whether the default video stream is constant frame rate (CFR). + # + # @return [Boolean, nil] + autoload def cfr? + default_video_stream&.cfr? + end + + # Returns whether the default video stream is variable frame rate (VFR). + # + # @return [Boolean, nil] + autoload def vfr? + default_video_stream&.vfr? + end + # Returns the index of the default video stream (if any). # # @return [Integer, nil] diff --git a/lib/ffmpeg/stream.rb b/lib/ffmpeg/stream.rb index 5f41d33..4a49e10 100644 --- a/lib/ffmpeg/stream.rb +++ b/lib/ffmpeg/stream.rb @@ -3,12 +3,15 @@ module FFMPEG # The Stream class represents a multimedia stream in a file. class Stream + VFR_TOLERANCE = 0.01 + attr_reader :metadata, :id, :index, :profile, :tags, :codec_name, :codec_long_name, :codec_tag, :codec_tag_string, :codec_type, :raw_width, :raw_height, :coded_width, :coded_height, :raw_sample_aspect_ratio, :raw_display_aspect_ratio, :rotation, - :pixel_format, :color_range, :color_space, :color_primaries, :color_transfer, :field_order, :frame_rate, + :pixel_format, :color_range, :color_space, :color_primaries, :color_transfer, :field_order, + :frame_rate, :base_frame_rate, :sample_rate, :sample_fmt, :channels, :channel_layout, :start_time, :bit_rate, :duration, :frames, :overview @@ -51,6 +54,9 @@ def initialize(metadata, stderr = '') @color_primaries = metadata[:color_primaries] @color_transfer = metadata[:color_transfer] @field_order = metadata[:field_order] + unless metadata[:r_frame_rate].nil? || metadata[:r_frame_rate] == '0/0' + @base_frame_rate = Rational(metadata[:r_frame_rate]) + end unless metadata[:avg_frame_rate].nil? || metadata[:avg_frame_rate] == '0/0' @frame_rate = Rational(metadata[:avg_frame_rate]) end @@ -216,6 +222,21 @@ def sample_aspect_ratio @sample_aspect_ratio ||= Rational(1) end + # Whether the stream is constant frame rate (CFR). + # + # @return [Boolean] + def cfr? = video? && !vfr? + + # Whether the stream is variable frame rate (VFR). + # + # @return [Boolean] + def vfr? + return false unless video? + return false if base_frame_rate.nil? || frame_rate.nil? || frame_rate.zero? + + ((base_frame_rate - frame_rate) / frame_rate).abs > VFR_TOLERANCE + end + protected def calculate_aspect_ratio(source) diff --git a/lib/ffmpeg/version.rb b/lib/ffmpeg/version.rb index 68b2494..d6dcbc1 100644 --- a/lib/ffmpeg/version.rb +++ b/lib/ffmpeg/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module FFMPEG - VERSION = '8.4.5' + VERSION = '8.5.0' end diff --git a/spec/ffmpeg/media_spec.rb b/spec/ffmpeg/media_spec.rb index 72dab29..1b84d9e 100644 --- a/spec/ffmpeg/media_spec.rb +++ b/spec/ffmpeg/media_spec.rb @@ -441,6 +441,9 @@ module FFMPEG color_primaries: 'bt709', color_transfer: 'bt709', frame_rate: Rational(60 / 1), + base_frame_rate: Rational(60 / 1), + cfr?: true, + vfr?: false, frames: 213, video_index: 0, video_mapping_index: 0, @@ -470,6 +473,18 @@ module FFMPEG end end + %i[base_frame_rate cfr? vfr?].each do |method| + describe "##{method}" do + context 'when the media has no video streams' do + let(:path) { fixture_media_file('hello.wav') } + + it 'returns nil' do + expect(subject.public_send(method)).to be(nil) + end + end + end + end + describe '#audio_streams' do context 'when the media has audio streams' do let(:path) { fixture_media_file('widescreen-multi-audio.mp4') } diff --git a/spec/ffmpeg/stream_spec.rb b/spec/ffmpeg/stream_spec.rb index aa9f686..5f02cc3 100644 --- a/spec/ffmpeg/stream_spec.rb +++ b/spec/ffmpeg/stream_spec.rb @@ -237,5 +237,105 @@ module FFMPEG end end end + + describe '#cfr?' do + context 'when the codec type is not video' do + let(:metadata) { { codec_type: 'audio', r_frame_rate: '1011/1000', avg_frame_rate: '1/1' } } + + it 'returns false' do + expect(subject.cfr?).to be(false) + end + end + + context 'when the base_frame_rate is nil' do + let(:metadata) { { codec_type: 'video', r_frame_rate: '0/0', avg_frame_rate: '30/1' } } + + it 'returns true' do + expect(subject.cfr?).to be(true) + end + end + + context 'when the avg_frame_rate is nil' do + let(:metadata) { { codec_type: 'video', r_frame_rate: '30/1', avg_frame_rate: '0/0' } } + + it 'returns true' do + expect(subject.cfr?).to be(true) + end + end + + context 'when the base_frame_rate and avg_frame_rate are equal' do + let(:metadata) { { codec_type: 'video', r_frame_rate: '30/1', avg_frame_rate: '30/1' } } + + it 'returns true' do + expect(subject.cfr?).to be(true) + end + end + + context 'when the base_frame_rate and avg_frame_rate are within the tolerance' do + let(:metadata) { { codec_type: 'video', r_frame_rate: '101/100', avg_frame_rate: '1/1' } } + + it 'returns true' do + expect(subject.cfr?).to be(true) + end + end + + context 'when the base_frame_rate and avg_frame_rate are beyond the tolerance' do + let(:metadata) { { codec_type: 'video', r_frame_rate: '1011/1000', avg_frame_rate: '1/1' } } + + it 'returns false' do + expect(subject.cfr?).to be(false) + end + end + end + + describe '#vfr?' do + context 'when the codec type is not video' do + let(:metadata) { { codec_type: 'audio', r_frame_rate: '1011/1000', avg_frame_rate: '1/1' } } + + it 'returns false' do + expect(subject.vfr?).to be(false) + end + end + + context 'when the base_frame_rate is nil' do + let(:metadata) { { codec_type: 'video', r_frame_rate: '0/0', avg_frame_rate: '30/1' } } + + it 'returns false' do + expect(subject.vfr?).to be(false) + end + end + + context 'when the avg_frame_rate is nil' do + let(:metadata) { { codec_type: 'video', r_frame_rate: '30/1', avg_frame_rate: '0/0' } } + + it 'returns false' do + expect(subject.vfr?).to be(false) + end + end + + context 'when the base_frame_rate and avg_frame_rate are equal' do + let(:metadata) { { codec_type: 'video', r_frame_rate: '30/1', avg_frame_rate: '30/1' } } + + it 'returns false' do + expect(subject.vfr?).to be(false) + end + end + + context 'when the base_frame_rate and avg_frame_rate are within the tolerance' do + let(:metadata) { { codec_type: 'video', r_frame_rate: '101/100', avg_frame_rate: '1/1' } } + + it 'returns false' do + expect(subject.vfr?).to be(false) + end + end + + context 'when the base_frame_rate and avg_frame_rate are beyond the tolerance' do + let(:metadata) { { codec_type: 'video', r_frame_rate: '1011/1000', avg_frame_rate: '1/1' } } + + it 'returns true' do + expect(subject.vfr?).to be(true) + end + end + end end end