Don't allow uploading videos with unsupported video codecs. The only video codecs we allow for MP4 files are H.264 and VP9. Other codecs, including H.265 (aka HEVC), MPEG-4 part 2, and AV1, are disallowed because they're not universally supported by browsers. Firefox doesn't support H.265 or MPEG-4 part 2, and Safari doesn't support AV1. Additionally, don't allow videos with multiple video tracks, multiple audio tracks, or no video tracks. Multiple video and audio tracks are disallowed because they're rare and for moderation purposes, we don't want people hiding content in extra tracks. These restrictions really only apply to MP4 videos, since WebM files don't support multiple video or audio tracks and only support a limited number of codecs (VP8 and VP9 for videos, Vorbis and Opus for audio). There are currently 22 posts with unsupported video codecs: * https://danbooru.donmai.us/posts?tags=video+is:mp4+-exif:Track1:CompressorID=avc1+-exif:Track2:CompressorID=avc1+-exif:Track1:CompressorID=vp09+-exif:Track2:CompressorID=vp09 # AVC1 is H.264 There is one post that has multiple audio tracks: * https://danbooru.donmai.us/posts/2382057
128 lines
3.6 KiB
Ruby
128 lines
3.6 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 [File, String] a webm, mp4, gif, or apng file
|
|
def initialize(file)
|
|
@file = file.is_a?(String) ? File.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 #{file.path.shellescape}")
|
|
json = JSON.parse(output)
|
|
json.with_indifferent_access
|
|
rescue Error
|
|
{}
|
|
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 video_codec
|
|
video_stream[:codec_name]
|
|
end
|
|
|
|
def video_stream
|
|
video_streams.first || {}
|
|
end
|
|
|
|
def video_streams
|
|
metadata[:streams].to_a.select { |stream| stream[:codec_type] == "video" }
|
|
end
|
|
|
|
def audio_streams
|
|
metadata[:streams].to_a.select { |stream| stream[:codec_type] == "audio" }
|
|
end
|
|
|
|
def has_audio?
|
|
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)
|
|
raise Error, "#{program} failed: #{output}" if !status.success?
|
|
output
|
|
end
|
|
|
|
memoize :metadata, :playback_info, :frame_count, :duration
|
|
end
|