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.
120 lines
3.5 KiB
Ruby
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
|