media file: fix calculation of video/animation duration.

Fix how the duration of videos and animated GIFs / PNGs is calculated.
If we can't determine the duration from the file metadata, then play the
entire video or animation back using FFmpeg and scrape the duration and
frame count.

This is necessary for things like WebM files where the duration metadata
is optional, or animated GIFs and PNGs that don't have a duration field
in the metadata, only a frame count and a sequence of frame delays.
This commit is contained in:
evazion
2021-10-17 20:15:51 -05:00
parent d8de58d991
commit 8b3ab04724
3 changed files with 47 additions and 52 deletions

View File

@@ -47,30 +47,34 @@ class FFmpeg
video_streams.first[:height] video_streams.first[:height]
end end
# @see https://trac.ffmpeg.org/wiki/FFprobeTips#Duration
# @return [Float, nil] The duration of the video or animation in seconds, or nil if unknown.
def duration def duration
metadata.dig(:format, :duration).to_f if metadata.dig(:format, :duration).present?
metadata.dig(:format, :duration).to_f
elsif playback_info.has_key?(:time)
hours, minutes, seconds = playback_info[:time].split(/:/)
hours.to_f*60*60 + minutes.to_f*60 + seconds.to_f
else
nil
end
end end
# @return [Integer, nil] The number of frames in the video or animation, or nil if unknown.
def frame_count def frame_count
if video_streams.first.has_key?(:nb_frames) if video_streams.first.has_key?(:nb_frames)
video_streams.first[:nb_frames].to_i video_streams.first[:nb_frames].to_i
elsif playback_info.has_key?(:frame)
playback_info[:frame].to_i
else else
(duration * frame_rate).to_i nil
end end
end end
# @return [Float, nil] The frame rate of the video or animation, or nil if # @return [Float, nil] The average frame rate of the video or animation, or nil if unknown.
# unknown. The frame rate can be unknown for animated PNGs that have zero
# delay between frames.
def frame_rate def frame_rate
rate = video_streams.first[:avg_frame_rate] # "100/57" return nil if frame_count.nil? || duration.nil? || duration == 0
numerator, denominator = rate.split("/") frame_count / duration
if numerator.to_f == 0 || denominator.to_f == 0
nil
else
(numerator.to_f / denominator.to_f)
end
end end
def video_streams def video_streams
@@ -85,6 +89,19 @@ class FFmpeg
audio_streams.present? audio_streams.present?
end end
# Decode the full video and return a hash containing the frame count, fps, and runtime.
def playback_info
output = shell!("ffmpeg -i #{file.path.shellescape} -f null /dev/null")
status_line = output.lines.grep(/\Aframe=/).first.chomp
# status_line = "frame= 10 fps=0.0 q=-0.0 Lsize=N/A time=00:00:00.48 bitrate=N/A speed= 179x"
# info = {"frame"=>"10", "fps"=>"0.0", "q"=>"-0.0", "Lsize"=>"N/A", "time"=>"00:00:00.48", "bitrate"=>"N/A", "speed"=>"188x"}
info = status_line.scan(/\S+=\s*\S+/).map { |pair| pair.split(/=\s*/) }.to_h
info.with_indifferent_access
rescue Error => e
{}
end
def shell!(command) def shell!(command)
program = command.shellsplit.first program = command.shellsplit.first
output, status = Open3.capture2e(command) output, status = Open3.capture2e(command)
@@ -92,5 +109,5 @@ class FFmpeg
output output
end end
memoize :metadata memoize :metadata, :playback_info, :frame_count, :duration
end end

View File

@@ -24,27 +24,8 @@ class MediaFile::Image < MediaFile
end end
def duration def duration
if is_animated_gif? return nil if !is_animated?
if metadata.has_key?("GIF:Duration") video.duration
# GIF:Duration => "9.03 s"
metadata["GIF:Duration"].to_f
else
# If GIF:Duration is absent then it means the GIF has an unlimited
# framerate. In this situation we assume the browser will play the GIF
# at 10 FPS; this is browser dependent.
#
# A GIF consists of a series of frames, each frame having a separate frame
# delay. The duration of the GIF is the sum of each frame delay. If the frame
# delay of each frame is zero, then it means the GIF has an unlimited framerate
# and should be played as fast as possible. In reality, browsers cap the
# framerate to around 10FPS.
(frame_count * (1.0/10.0)).round(2)
end
elsif is_animated_png?
frame_count.to_f / frame_rate
else
nil
end
end end
def frame_count def frame_count
@@ -58,15 +39,8 @@ class MediaFile::Image < MediaFile
end end
def frame_rate def frame_rate
if is_animated_gif? return nil if !is_animated? || frame_count.nil? || duration.nil? || duration == 0
frame_count / duration frame_count / duration
elsif is_animated_png?
# XXX As with GIFs, animated PNGs can have an unspecified frame rate.
# Assume 10FPS if the frame rate is unspecified.
video.frame_rate.presence || 10.0
else
nil
end
end end
def channels def channels
@@ -103,6 +77,10 @@ class MediaFile::Image < MediaFile
end end
end end
def is_animated?
frame_count.to_i > 1
end
def is_animated_gif? def is_animated_gif?
file_ext == :gif && is_animated? file_ext == :gif && is_animated?
end end

View File

@@ -188,7 +188,7 @@ class MediaFileTest < ActiveSupport::TestCase
should "determine the duration of the video" do should "determine the duration of the video" do
file = MediaFile.open("test/files/test-audio.mp4") file = MediaFile.open("test/files/test-audio.mp4")
assert_equal(1.002667, file.duration) assert_equal(1.002667, file.duration)
assert_equal(10, file.frame_rate) assert_equal(10/1.002667, file.frame_rate)
assert_equal(10, file.frame_count) assert_equal(10, file.frame_count)
file = MediaFile.open("test/files/test-300x300.mp4") file = MediaFile.open("test/files/test-300x300.mp4")
@@ -202,8 +202,8 @@ class MediaFileTest < ActiveSupport::TestCase
should "determine the duration of the video" do should "determine the duration of the video" do
file = MediaFile.open("test/files/test-512x512.webm") file = MediaFile.open("test/files/test-512x512.webm")
assert_equal(0.48, file.duration) assert_equal(0.48, file.duration)
assert_equal(50, file.frame_rate) assert_equal(10/0.48, file.frame_rate)
assert_equal(24, file.frame_count) assert_equal(10, file.frame_count)
end end
end end
@@ -245,8 +245,8 @@ class MediaFileTest < ActiveSupport::TestCase
assert_equal(false, file.is_corrupt?) assert_equal(false, file.is_corrupt?)
assert_equal(true, file.is_animated?) assert_equal(true, file.is_animated?)
assert_equal(9, file.duration) assert_equal(3.0, file.duration)
assert_equal(0.33, file.frame_rate.round(2)) assert_equal(1.0, file.frame_rate)
assert_equal(3, file.frame_count) assert_equal(3, file.frame_count)
end end
end end
@@ -264,14 +264,14 @@ class MediaFileTest < ActiveSupport::TestCase
end end
context "that is animated but with an unspecified frame rate" do context "that is animated but with an unspecified frame rate" do
should "have an assumed frame rate of 10FPS" do should "have an assumed frame rate of ~6.66 FPS" do
file = MediaFile.open("test/files/test-animated-inf-fps.png") file = MediaFile.open("test/files/test-animated-inf-fps.png")
assert_equal(false, file.is_corrupt?) assert_equal(false, file.is_corrupt?)
assert_equal(true, file.is_animated?) assert_equal(true, file.is_animated?)
assert_equal(0.2, file.duration) assert_equal(0.3, file.duration)
assert_equal(2, file.frame_count) assert_equal(2, file.frame_count)
assert_equal(10, file.frame_rate) assert_equal(2/0.3, file.frame_rate)
end end
end end