Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG
Original file line number Diff line number Diff line change
@@ -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:
Expand Down
25 changes: 23 additions & 2 deletions lib/ffmpeg/media.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -461,18 +461,39 @@ 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]
autoload def frames
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]
Expand Down
23 changes: 22 additions & 1 deletion lib/ffmpeg/stream.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion lib/ffmpeg/version.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# frozen_string_literal: true

module FFMPEG
VERSION = '8.4.5'
VERSION = '8.5.0'
end
15 changes: 15 additions & 0 deletions spec/ffmpeg/media_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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') }
Expand Down
100 changes: 100 additions & 0 deletions spec/ffmpeg/stream_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading