media assets: fix .webm files not including video/audio bit rates in metadata.

Fix .webm files not including the `FFmpeg:VideoBitRate` and `FFmpeg:AudioBitRate`
fields in the media_metadata table. This was because the .webm format
doesn't include the video or audio bit rates in the metadata, and
ffprobe doesn't calculate them either, so we have to calculate them
ourselves by hand.

Fixup for 523d7afdd.
This commit is contained in:
evazion
2022-11-03 21:03:03 -05:00
parent c21146f94d
commit 5f8fefccaa
5 changed files with 115 additions and 18 deletions

View File

@@ -11,9 +11,10 @@ class FFmpeg
attr_reader :file attr_reader :file
# Operate on a file with FFmpeg. # 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) def initialize(file)
@file = file.is_a?(String) ? File.open(file) : file @file = file.is_a?(String) ? MediaFile.open(file) : file
end end
# Generate a .png preview image for a video or animation. Generates # 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. # @return [Hash] A hash of the file's metadata. Will be empty if reading the file failed for any reason.
def metadata 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 = JSON.parse(output)
json.with_indifferent_access json.with_indifferent_access
rescue Error => e rescue Error => e
@@ -95,6 +96,18 @@ class FFmpeg
video_stream[:codec_name] video_stream[:codec_name]
end 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 def video_stream
video_streams.first || {} video_streams.first || {}
end end
@@ -107,6 +120,18 @@ class FFmpeg
audio_stream[:codec_name] audio_stream[:codec_name]
end 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 def audio_stream
audio_streams.first || {} audio_streams.first || {}
end end
@@ -119,6 +144,28 @@ class FFmpeg
audio_streams.present? audio_streams.present?
end 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. # @return [Boolean] True if the video is unplayable.
def is_corrupt? def is_corrupt?
error.present? error.present?
@@ -129,15 +176,25 @@ class FFmpeg
metadata[:error] || playback_info[:error] metadata[:error] || playback_info[:error]
end 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 def playback_info
output = shell!("ffmpeg -i #{file.path.shellescape} -f null /dev/null") # XXX `-c copy` is faster, but it doesn't decompress the stream so it can't detect corrupt videos.
status_line = output.lines.grep(/\Aframe=/).first.chomp 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" # time_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"} # time_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 time_line = output.lines.grep(/\Aframe=/).first.chomp
info.with_indifferent_access 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 rescue Error => e
{ error: e.message.strip }.with_indifferent_access { error: e.message.strip }.with_indifferent_access
end end
@@ -149,5 +206,5 @@ class FFmpeg
output output
end end
memoize :metadata, :playback_info, :frame_count, :duration, :error memoize :metadata, :playback_info, :frame_count, :duration, :error, :video_size, :audio_size
end end

View File

@@ -132,7 +132,7 @@ class MediaFile::Image < MediaFile
def preview_frame def preview_frame
if is_animated? if is_animated?
FFmpeg.new(file).smart_video_preview FFmpeg.new(self).smart_video_preview
else else
self self
end end
@@ -172,7 +172,7 @@ class MediaFile::Image < MediaFile
end end
def video def video
FFmpeg.new(file) FFmpeg.new(self)
end end
memoize :image, :video, :preview_frame, :dimensions, :error, :metadata, :is_corrupt?, :is_animated_gif?, :is_animated_png? memoize :image, :video, :preview_frame, :dimensions, :error, :metadata, :is_corrupt?, :is_animated_gif?, :is_animated_png?

View File

@@ -5,7 +5,7 @@
# #
# @see https://github.com/streamio/streamio-ffmpeg # @see https://github.com/streamio/streamio-ffmpeg
class MediaFile::Video < MediaFile 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 def dimensions
[video.width, video.height] [video.width, video.height]
@@ -23,10 +23,11 @@ class MediaFile::Video < MediaFile
"FFmpeg:FrameCount" => frame_count, "FFmpeg:FrameCount" => frame_count,
"FFmpeg:VideoCodec" => video_codec, "FFmpeg:VideoCodec" => video_codec,
"FFmpeg:VideoProfile" => video_stream[:profile], "FFmpeg:VideoProfile" => video_stream[:profile],
"FFmpeg:VideoBitRate" => video_bit_rate,
"FFmpeg:AudioCodec" => audio_codec, "FFmpeg:AudioCodec" => audio_codec,
"FFmpeg:AudioProfile" => audio_stream[:profile], "FFmpeg:AudioProfile" => audio_stream[:profile],
"FFmpeg:AudioLayout" => audio_stream[:channel_layout], "FFmpeg:AudioLayout" => audio_stream[:channel_layout],
"FFmpeg:AudioBitRate" => audio_stream[:bit_rate], "FFmpeg:AudioBitRate" => audio_bit_rate,
}.compact_blank) }.compact_blank)
end end
@@ -53,7 +54,7 @@ class MediaFile::Video < MediaFile
private private
def video def video
FFmpeg.new(file) FFmpeg.new(self)
end end
def preview_frame def preview_frame

Binary file not shown.

View File

@@ -200,18 +200,36 @@ class MediaFileTest < ActiveSupport::TestCase
assert_equal(false, MediaFile.open("test/files/mp4/test-300x300.mp4").has_audio?) assert_equal(false, MediaFile.open("test/files/mp4/test-300x300.mp4").has_audio?)
end 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") file = MediaFile.open("test/files/mp4/test-audio.mp4")
assert_equal(false, file.is_corrupt?) assert_equal(false, file.is_corrupt?)
assert_equal(1.002667, file.duration) assert_equal(1.002667, file.duration)
assert_equal(10/1.002667, file.frame_rate) assert_equal(10/1.002667, file.frame_rate)
assert_equal(10, file.frame_count) 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") file = MediaFile.open("test/files/mp4/test-300x300.mp4")
assert_equal(false, file.is_corrupt?) assert_equal(false, file.is_corrupt?)
assert_equal(5.7, file.duration) assert_equal(5.7, file.duration)
assert_equal(1.75, file.frame_rate.round(2)) assert_equal(1.75, file.frame_rate.round(2))
assert_equal(10, file.frame_count) 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 end
should "determine the pixel format of the video" do should "determine the pixel format of the video" do
@@ -251,11 +269,32 @@ class MediaFileTest < ActiveSupport::TestCase
end end
context "for a webm file" do 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") file = MediaFile.open("test/files/webm/test-512x512.webm")
assert_equal(0.48, file.duration) assert_equal(0.48, file.duration)
assert_equal(10/0.48, file.frame_rate) assert_equal(10/0.48, file.frame_rate)
assert_equal(10, file.frame_count) 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 end
should "detect supported files" do should "detect supported files" do