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