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.
211 lines
6.3 KiB
Ruby
211 lines
6.3 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require "shellwords"
|
|
|
|
# A wrapper for the ffmpeg command.
|
|
class FFmpeg
|
|
extend Memoist
|
|
|
|
class Error < StandardError; end
|
|
|
|
attr_reader :file
|
|
|
|
# Operate on a file with FFmpeg.
|
|
#
|
|
# @param file [MediaFile, String] A webm, mp4, gif, or apng file.
|
|
def initialize(file)
|
|
@file = file.is_a?(String) ? MediaFile.open(file) : file
|
|
end
|
|
|
|
# Generate a .png preview image for a video or animation. Generates
|
|
# thumbnails intelligently by avoiding blank frames.
|
|
#
|
|
# @return [MediaFile] the preview image
|
|
def smart_video_preview
|
|
vp = Tempfile.new(["video-preview", ".png"], binmode: true)
|
|
|
|
# https://ffmpeg.org/ffmpeg.html#Main-options
|
|
# https://ffmpeg.org/ffmpeg-filters.html#thumbnail
|
|
output = shell!("ffmpeg -i #{file.path.shellescape} -vf thumbnail=300 -frames:v 1 -y #{vp.path.shellescape}")
|
|
Rails.logger.debug(output)
|
|
|
|
MediaFile.open(vp)
|
|
end
|
|
|
|
# Get file metadata using ffprobe.
|
|
#
|
|
# @see https://ffmpeg.org/ffprobe.html
|
|
# @see https://gist.github.com/nrk/2286511
|
|
#
|
|
# @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 -show_packets #{file.path.shellescape}")
|
|
json = JSON.parse(output)
|
|
json.with_indifferent_access
|
|
rescue Error => e
|
|
{ error: e.message.strip }.with_indifferent_access
|
|
end
|
|
|
|
def width
|
|
video_stream[:width]
|
|
end
|
|
|
|
def height
|
|
video_stream[: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
|
|
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_stream.has_key?(:nb_frames)
|
|
video_stream[:nb_frames].to_i
|
|
elsif playback_info.has_key?(:frame)
|
|
playback_info[:frame].to_i
|
|
else
|
|
nil
|
|
end
|
|
end
|
|
|
|
# @return [Float, nil] The average frame rate of the video or animation, or nil if unknown.
|
|
def frame_rate
|
|
return nil if frame_count.nil? || duration.nil? || duration == 0
|
|
frame_count / duration
|
|
end
|
|
|
|
def major_brand
|
|
metadata.dig(:format, :tags, :major_brand)
|
|
end
|
|
|
|
def pix_fmt
|
|
video_stream[:pix_fmt]
|
|
end
|
|
|
|
def video_codec
|
|
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
|
|
|
|
def video_streams
|
|
metadata[:streams].to_a.select { |stream| stream[:codec_type] == "video" }
|
|
end
|
|
|
|
def audio_codec
|
|
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
|
|
|
|
def audio_streams
|
|
metadata[:streams].to_a.select { |stream| stream[:codec_type] == "audio" }
|
|
end
|
|
|
|
def has_audio?
|
|
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?
|
|
end
|
|
|
|
# @return [String, nil] The error message if the video is unplayable, or nil if no error.
|
|
def error
|
|
metadata[:error] || playback_info[:error]
|
|
end
|
|
|
|
# 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
|
|
# 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")
|
|
|
|
# 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
|
|
|
|
def shell!(command)
|
|
program = command.shellsplit.first
|
|
output, status = Open3.capture2e(command)
|
|
raise Error, "#{program} failed: #{output}" if !status.success?
|
|
output
|
|
end
|
|
|
|
memoize :metadata, :playback_info, :frame_count, :duration, :error, :video_size, :audio_size
|
|
end
|