Files
danbooru/app/logical/ffmpeg.rb
evazion c96d60a840 uploads: add support for uploading .avif files.
Features of AVIF include:

* Lossless and lossy compression.
* High dynamic range (HDR) images
* Wide color gamut images (i.e. 10- and 12-bit color depths)
* Transparency (through alpha planes).
* Animations (with an optional cover image).
* Auxiliary image sequences, where the file contains a single primary
  image and a short secondary video, like Apple's Live Photos.
* Metadata rotation, mirroring, and cropping.

The AVIF format is still relatively new and some of these features aren't well
supported by browsers or other software:

* Animated AVIFs aren't supported by Firefox or by libvips.
* HDR images aren't supported by Firefox.
* Rotated, mirrored, and cropped AVIFs aren't supported by Firefox or Chrome.
* Image grids, where the file contains multiple images that are tiled
  together into one big image, aren't supported by Firefox.
* AVIF as a whole has only been supported for a year or two by Chrome
  and Firefox, and less than a year by Safari.

For these reasons, only basic AVIFs that don't use animation, rotation,
cropping, or image grids can be uploaded.
2022-10-25 03:29:58 -05:00

120 lines
3.5 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_streams.first[:width]
end
def height
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
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
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_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