diff --git a/app/logical/ffmpeg.rb b/app/logical/ffmpeg.rb index 9ce231a8b..d5dbbc38f 100644 --- a/app/logical/ffmpeg.rb +++ b/app/logical/ffmpeg.rb @@ -11,9 +11,10 @@ class FFmpeg attr_reader :file # Operate on a file with FFmpeg. - # @param file [File, String] a webm, mp4, gif, or apng file + # + # @param file [MediaFile, String] A webm, mp4, gif, or apng file. def initialize(file) - @file = file.is_a?(String) ? File.open(file) : file + @file = file.is_a?(String) ? MediaFile.open(file) : file end # Generate a .png preview image for a video or animation. Generates @@ -38,7 +39,7 @@ class FFmpeg # # @return [Hash] A hash of the file's metadata. Will be empty if reading the file failed for any reason. def metadata - output = shell!("ffprobe -v quiet -print_format json -show_format -show_streams #{file.path.shellescape}") + output = shell!("ffprobe -v quiet -print_format json -show_format -show_streams -show_packets #{file.path.shellescape}") json = JSON.parse(output) json.with_indifferent_access rescue Error => e @@ -95,6 +96,18 @@ class FFmpeg video_stream[:codec_name] end + # @return [Integer, nil] The bit rate of the video stream, in bits per second, or nil if it can't be calculated. + def video_bit_rate + if video_stream.has_key?(:bit_rate) + video_stream[:bit_rate].to_i + # .webm doesn't have the bit rate in the metadata, so we have to calculate it from the video stream size and duration. + elsif video_size > 0 && duration > 0 + ((8.0 * video_size) / duration).to_i + else + nil + end + end + def video_stream video_streams.first || {} end @@ -107,6 +120,18 @@ class FFmpeg audio_stream[:codec_name] end + # @return [Integer, nil] The bit rate of the audio stream, in bits per second, or nil if it can't be calculated. + def audio_bit_rate + if audio_stream.has_key?(:bit_rate) + audio_stream[:bit_rate].to_i + # .webm doesn't have the bit rate in the metadata, so we have to calculate it from the audio stream size and duration. + elsif audio_size > 0 && duration > 0 + ((8.0 * audio_size) / duration).to_i + else + nil + end + end + def audio_stream audio_streams.first || {} end @@ -119,6 +144,28 @@ class FFmpeg audio_streams.present? end + def packets + metadata[:packets].to_a + end + + def video_packets + packets.select { |stream| stream[:codec_type] == "video" } + end + + def audio_packets + packets.select { |stream| stream[:codec_type] == "audio" } + end + + # @return [Integer] The size of the compressed video stream in bytes. + def video_size + video_packets.pluck("size").map(&:to_i).sum + end + + # @return [Integer] The size of the compressed audio stream in bytes. + def audio_size + audio_packets.pluck("size").map(&:to_i).sum + end + # @return [Boolean] True if the video is unplayable. def is_corrupt? error.present? @@ -129,15 +176,25 @@ class FFmpeg metadata[:error] || playback_info[:error] end - # Decode the full video and return a hash containing the frame count, fps, and runtime. + # Decode the full video and return a hash containing the frame count, fps, runtime, and the sizes of the decompressed video and audio streams. def playback_info - output = shell!("ffmpeg -i #{file.path.shellescape} -f null /dev/null") - status_line = output.lines.grep(/\Aframe=/).first.chomp + # XXX `-c copy` is faster, but it doesn't decompress the stream so it can't detect corrupt videos. + output = shell!("ffmpeg -hide_banner -i #{file.path.shellescape} -f null /dev/null") - # 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 + # time_line = "frame= 10 fps=0.0 q=-0.0 Lsize=N/A time=00:00:00.48 bitrate=N/A speed= 179x" + # time_info = { "frame"=>"10", "fps"=>"0.0", "q"=>"-0.0", "Lsize"=>"N/A", "time"=>"00:00:00.48", "bitrate"=>"N/A", "speed"=>"188x" } + time_line = output.lines.grep(/\Aframe=/).first.chomp + time_info = time_line.scan(/\S+=\s*\S+/).map { |pair| pair.split(/=\s*/) }.to_h + + # size_line = "video:36kBkB audio:16kB subtitle:0kB other streams:0kB global headers:0kB muxing overhead: unknown" + # size_info = { "video" => 36000, "audio" => 16000, "subtitle" => 0, "other streams" => 0, "global headers" => 0, "muxing overhead" => 0 } + size_line = output.lines.grep(/\Avideo:/).first.chomp + size_info = size_line.scan(/[a-z ]+: *[a-z0-9]+/i).map do |pair| + key, value = pair.split(/: */) + [key.strip, value.to_i * 1000] # [" audio", "16kB"] => ["audio", 16000] + end.to_h + + { **time_info, **size_info }.with_indifferent_access rescue Error => e { error: e.message.strip }.with_indifferent_access end @@ -149,5 +206,5 @@ class FFmpeg output end - memoize :metadata, :playback_info, :frame_count, :duration, :error + memoize :metadata, :playback_info, :frame_count, :duration, :error, :video_size, :audio_size end diff --git a/app/logical/media_file/image.rb b/app/logical/media_file/image.rb index 9566c68d2..1b41b0f90 100644 --- a/app/logical/media_file/image.rb +++ b/app/logical/media_file/image.rb @@ -132,7 +132,7 @@ class MediaFile::Image < MediaFile def preview_frame if is_animated? - FFmpeg.new(file).smart_video_preview + FFmpeg.new(self).smart_video_preview else self end @@ -172,7 +172,7 @@ class MediaFile::Image < MediaFile end def video - FFmpeg.new(file) + FFmpeg.new(self) end memoize :image, :video, :preview_frame, :dimensions, :error, :metadata, :is_corrupt?, :is_animated_gif?, :is_animated_png? diff --git a/app/logical/media_file/video.rb b/app/logical/media_file/video.rb index b92a00c2f..4f08c8acd 100644 --- a/app/logical/media_file/video.rb +++ b/app/logical/media_file/video.rb @@ -5,7 +5,7 @@ # # @see https://github.com/streamio/streamio-ffmpeg class MediaFile::Video < MediaFile - delegate :duration, :frame_count, :frame_rate, :has_audio?, :is_corrupt?, :major_brand, :pix_fmt, :video_codec, :video_stream, :video_streams, :audio_codec, :audio_stream, :audio_streams, :error, to: :video + delegate :duration, :frame_count, :frame_rate, :has_audio?, :is_corrupt?, :major_brand, :pix_fmt, :video_codec, :video_bit_rate, :video_stream, :video_streams, :audio_codec, :audio_bit_rate, :audio_stream, :audio_streams, :error, to: :video def dimensions [video.width, video.height] @@ -23,10 +23,11 @@ class MediaFile::Video < MediaFile "FFmpeg:FrameCount" => frame_count, "FFmpeg:VideoCodec" => video_codec, "FFmpeg:VideoProfile" => video_stream[:profile], + "FFmpeg:VideoBitRate" => video_bit_rate, "FFmpeg:AudioCodec" => audio_codec, "FFmpeg:AudioProfile" => audio_stream[:profile], "FFmpeg:AudioLayout" => audio_stream[:channel_layout], - "FFmpeg:AudioBitRate" => audio_stream[:bit_rate], + "FFmpeg:AudioBitRate" => audio_bit_rate, }.compact_blank) end @@ -53,7 +54,7 @@ class MediaFile::Video < MediaFile private def video - FFmpeg.new(file) + FFmpeg.new(self) end def preview_frame diff --git a/test/files/webm/test-audio.webm b/test/files/webm/test-audio.webm new file mode 100644 index 000000000..75873b637 Binary files /dev/null and b/test/files/webm/test-audio.webm differ diff --git a/test/unit/media_file_test.rb b/test/unit/media_file_test.rb index b54636340..4974fefe0 100644 --- a/test/unit/media_file_test.rb +++ b/test/unit/media_file_test.rb @@ -200,18 +200,36 @@ class MediaFileTest < ActiveSupport::TestCase assert_equal(false, MediaFile.open("test/files/mp4/test-300x300.mp4").has_audio?) end - should "determine the duration of the video" do + should "determine the metadata for a video with audio" do file = MediaFile.open("test/files/mp4/test-audio.mp4") assert_equal(false, file.is_corrupt?) assert_equal(1.002667, file.duration) assert_equal(10/1.002667, file.frame_rate) assert_equal(10, file.frame_count) + assert_equal(10, file.metadata["FFmpeg:FrameCount"]) + assert_equal("mp42", file.metadata["FFmpeg:MajorBrand"]) + assert_equal("yuv420p", file.metadata["FFmpeg:PixFmt"]) + assert_equal("h264", file.metadata["FFmpeg:VideoCodec"]) + assert_equal("High", file.metadata["FFmpeg:VideoProfile"]) + assert_equal(291624, file.metadata["FFmpeg:VideoBitRate"]) + assert_equal("aac", file.metadata["FFmpeg:AudioCodec"]) + assert_equal("LC", file.metadata["FFmpeg:AudioProfile"]) + assert_equal("stereo", file.metadata["FFmpeg:AudioLayout"]) + assert_equal(128002, file.metadata["FFmpeg:AudioBitRate"]) + end + should "determine the metadata for a video without audio" do file = MediaFile.open("test/files/mp4/test-300x300.mp4") assert_equal(false, file.is_corrupt?) assert_equal(5.7, file.duration) assert_equal(1.75, file.frame_rate.round(2)) assert_equal(10, file.frame_count) + assert_equal(10, file.metadata["FFmpeg:FrameCount"]) + assert_equal("mp42", file.metadata["FFmpeg:MajorBrand"]) + assert_equal("yuv420p", file.metadata["FFmpeg:PixFmt"]) + assert_equal("h264", file.metadata["FFmpeg:VideoCodec"]) + assert_equal("Constrained Baseline", file.metadata["FFmpeg:VideoProfile"]) + assert_equal(25003, file.metadata["FFmpeg:VideoBitRate"]) end should "determine the pixel format of the video" do @@ -251,11 +269,32 @@ class MediaFileTest < ActiveSupport::TestCase end context "for a webm file" do - should "determine the duration of the video" do + should "determine the metadata for a video with audio" do + file = MediaFile.open("test/files/webm/test-audio.webm") + + assert_equal(1.01, file.duration) # 1.01 + assert_equal(10/1.01, file.frame_rate) + assert_equal(10, file.frame_count) + assert_equal(10, file.metadata["FFmpeg:FrameCount"]) + assert_equal("yuv420p", file.metadata["FFmpeg:PixFmt"]) + assert_equal("vp9", file.metadata["FFmpeg:VideoCodec"]) + assert_equal("Profile 0", file.metadata["FFmpeg:VideoProfile"]) + assert_equal(432546, file.metadata["FFmpeg:VideoBitRate"]) + assert_equal("opus", file.metadata["FFmpeg:AudioCodec"]) + assert_equal("stereo", file.metadata["FFmpeg:AudioLayout"]) + assert_equal(50661, file.metadata["FFmpeg:AudioBitRate"]) + end + + should "determine the metadata for a video without audio" do file = MediaFile.open("test/files/webm/test-512x512.webm") assert_equal(0.48, file.duration) assert_equal(10/0.48, file.frame_rate) assert_equal(10, file.frame_count) + assert_equal(10, file.metadata["FFmpeg:FrameCount"]) + assert_equal("yuv420p", file.metadata["FFmpeg:PixFmt"]) + assert_equal("vp8", file.metadata["FFmpeg:VideoCodec"]) + assert_equal("0", file.metadata["FFmpeg:VideoProfile"]) + assert_equal(196650, file.metadata["FFmpeg:VideoBitRate"]) end should "detect supported files" do