From 8b3ab04724d9d9a359c0cd29a9a0da457e5cb21f Mon Sep 17 00:00:00 2001 From: evazion Date: Sun, 17 Oct 2021 20:15:51 -0500 Subject: [PATCH] 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. --- app/logical/ffmpeg.rb | 45 +++++++++++++++++++++++---------- app/logical/media_file/image.rb | 38 ++++++---------------------- test/unit/media_file_test.rb | 16 ++++++------ 3 files changed, 47 insertions(+), 52 deletions(-) diff --git a/app/logical/ffmpeg.rb b/app/logical/ffmpeg.rb index 2628f3e6d..2d962331e 100644 --- a/app/logical/ffmpeg.rb +++ b/app/logical/ffmpeg.rb @@ -47,30 +47,34 @@ class FFmpeg video_streams.first[:height] 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 - 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 + # @return [Integer, nil] The number of frames in the video or animation, or nil if unknown. def frame_count if video_streams.first.has_key?(:nb_frames) video_streams.first[:nb_frames].to_i + elsif playback_info.has_key?(:frame) + playback_info[:frame].to_i else - (duration * frame_rate).to_i + nil end end - # @return [Float, nil] The frame rate of the video or animation, or nil if - # unknown. The frame rate can be unknown for animated PNGs that have zero - # delay between frames. + # @return [Float, nil] The average frame rate of the video or animation, or nil if unknown. def frame_rate - rate = video_streams.first[:avg_frame_rate] # "100/57" - numerator, denominator = rate.split("/") - - if numerator.to_f == 0 || denominator.to_f == 0 - nil - else - (numerator.to_f / denominator.to_f) - end + return nil if frame_count.nil? || duration.nil? || duration == 0 + frame_count / duration end def video_streams @@ -85,6 +89,19 @@ class FFmpeg audio_streams.present? 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) program = command.shellsplit.first output, status = Open3.capture2e(command) @@ -92,5 +109,5 @@ class FFmpeg output end - memoize :metadata + memoize :metadata, :playback_info, :frame_count, :duration end diff --git a/app/logical/media_file/image.rb b/app/logical/media_file/image.rb index 70a275b95..eb90a2e1a 100644 --- a/app/logical/media_file/image.rb +++ b/app/logical/media_file/image.rb @@ -24,27 +24,8 @@ class MediaFile::Image < MediaFile end def duration - if is_animated_gif? - if metadata.has_key?("GIF: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 + return nil if !is_animated? + video.duration end def frame_count @@ -58,15 +39,8 @@ class MediaFile::Image < MediaFile end def frame_rate - if is_animated_gif? - 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 + return nil if !is_animated? || frame_count.nil? || duration.nil? || duration == 0 + frame_count / duration end def channels @@ -103,6 +77,10 @@ class MediaFile::Image < MediaFile end end + def is_animated? + frame_count.to_i > 1 + end + def is_animated_gif? file_ext == :gif && is_animated? end diff --git a/test/unit/media_file_test.rb b/test/unit/media_file_test.rb index 866fd6756..cd0f427c2 100644 --- a/test/unit/media_file_test.rb +++ b/test/unit/media_file_test.rb @@ -188,7 +188,7 @@ class MediaFileTest < ActiveSupport::TestCase should "determine the duration of the video" do file = MediaFile.open("test/files/test-audio.mp4") 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) file = MediaFile.open("test/files/test-300x300.mp4") @@ -202,8 +202,8 @@ class MediaFileTest < ActiveSupport::TestCase should "determine the duration of the video" do file = MediaFile.open("test/files/test-512x512.webm") assert_equal(0.48, file.duration) - assert_equal(50, file.frame_rate) - assert_equal(24, file.frame_count) + assert_equal(10/0.48, file.frame_rate) + assert_equal(10, file.frame_count) end end @@ -245,8 +245,8 @@ class MediaFileTest < ActiveSupport::TestCase assert_equal(false, file.is_corrupt?) assert_equal(true, file.is_animated?) - assert_equal(9, file.duration) - assert_equal(0.33, file.frame_rate.round(2)) + assert_equal(3.0, file.duration) + assert_equal(1.0, file.frame_rate) assert_equal(3, file.frame_count) end end @@ -264,14 +264,14 @@ class MediaFileTest < ActiveSupport::TestCase end 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") assert_equal(false, file.is_corrupt?) 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(10, file.frame_rate) + assert_equal(2/0.3, file.frame_rate) end end