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.
150 lines
4.5 KiB
Ruby
150 lines
4.5 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
# A MediaFile for a JPEG, PNG, or GIF file. Uses libvips for resizing images.
|
|
#
|
|
# @see https://github.com/libvips/ruby-vips
|
|
# @see https://libvips.github.io/libvips/API/current
|
|
class MediaFile::Image < MediaFile
|
|
def dimensions
|
|
image.size
|
|
rescue Vips::Error
|
|
[0, 0]
|
|
end
|
|
|
|
def is_supported?
|
|
case file_ext
|
|
when :avif
|
|
# XXX Mirrored AVIFs should be unsupported too, but we currently can't detect the mirrored flag using exiftool or ffprobe.
|
|
!metadata.is_rotated? && !metadata.is_cropped? && !metadata.is_grid_image? && !metadata.is_animated_avif?
|
|
else
|
|
true
|
|
end
|
|
end
|
|
|
|
def is_corrupt?
|
|
image.stats
|
|
false
|
|
rescue Vips::Error
|
|
true
|
|
end
|
|
|
|
def duration
|
|
return nil if !is_animated?
|
|
video.duration
|
|
end
|
|
|
|
def frame_count
|
|
if file_ext == :gif
|
|
image.get("n-pages")
|
|
elsif file_ext == :png
|
|
metadata.fetch("PNG:AnimationFrames", 1)
|
|
elsif file_ext == :avif
|
|
video.frame_count
|
|
else
|
|
nil
|
|
end
|
|
end
|
|
|
|
def frame_rate
|
|
return nil if !is_animated? || frame_count.nil? || duration.nil? || duration == 0
|
|
frame_count / duration
|
|
end
|
|
|
|
def channels
|
|
image.bands
|
|
end
|
|
|
|
def colorspace
|
|
image.interpretation
|
|
end
|
|
|
|
def resize(max_width, max_height, format: :jpeg, quality: 85, **options)
|
|
# @see https://www.libvips.org/API/current/Using-vipsthumbnail.md.html
|
|
# @see https://www.libvips.org/API/current/libvips-resample.html#vips-thumbnail
|
|
if colorspace.in?(%i[srgb rgb16])
|
|
resized_image = preview_frame.image.thumbnail_image(max_width, height: max_height, import_profile: "srgb", export_profile: "srgb", **options)
|
|
elsif colorspace == :cmyk
|
|
# Leave CMYK as CMYK for better color accuracy than sRGB.
|
|
resized_image = preview_frame.image.thumbnail_image(max_width, height: max_height, import_profile: "cmyk", export_profile: "cmyk", intent: :relative, **options)
|
|
elsif colorspace.in?(%i[b-w grey16]) && has_embedded_profile?
|
|
# Convert greyscale to sRGB so that the color profile is properly applied before we strip it.
|
|
resized_image = preview_frame.image.thumbnail_image(max_width, height: max_height, export_profile: "srgb", **options)
|
|
elsif colorspace.in?(%i[b-w grey16])
|
|
# Otherwise, leave greyscale without a profile as greyscale because
|
|
# converting it to sRGB would change it from 1 channel to 3 channels.
|
|
resized_image = preview_frame.image.thumbnail_image(max_width, height: max_height, **options)
|
|
else
|
|
raise NotImplementedError
|
|
end
|
|
|
|
if resized_image.has_alpha?
|
|
resized_image = resized_image.flatten(background: 255)
|
|
end
|
|
|
|
output_file = Tempfile.new(["image-preview-#{md5}", ".#{format.to_s}"])
|
|
case format.to_sym
|
|
when :jpeg
|
|
# https://www.libvips.org/API/current/VipsForeignSave.html#vips-jpegsave
|
|
resized_image.jpegsave(output_file.path, Q: quality, strip: true, interlace: true, optimize_coding: true, optimize_scans: true, trellis_quant: true, overshoot_deringing: true, quant_table: 3)
|
|
when :webp
|
|
# https://www.libvips.org/API/current/VipsForeignSave.html#vips-webpsave
|
|
resized_image.webpsave(output_file.path, Q: quality, preset: :drawing, smart_subsample: false, effort: 4, strip: true)
|
|
when :avif
|
|
# https://www.libvips.org/API/current/VipsForeignSave.html#vips-heifsave
|
|
resized_image.heifsave(output_file.path, Q: quality, compression: :av1, effort: 4, strip: true)
|
|
else
|
|
raise NotImplementedError
|
|
end
|
|
|
|
MediaFile::Image.new(output_file)
|
|
end
|
|
|
|
def preview(max_width, max_height, **options)
|
|
w, h = MediaFile.scale_dimensions(width, height, max_width, max_height)
|
|
resize(w, h, size: :force, **options)
|
|
end
|
|
|
|
def preview_frame
|
|
if is_animated?
|
|
FFmpeg.new(file).smart_video_preview
|
|
else
|
|
self
|
|
end
|
|
end
|
|
|
|
def is_animated?
|
|
frame_count.to_i > 1
|
|
end
|
|
|
|
def is_animated_gif?
|
|
file_ext == :gif && is_animated?
|
|
end
|
|
|
|
def is_animated_png?
|
|
file_ext == :png && is_animated?
|
|
end
|
|
|
|
def is_animated_avif?
|
|
file_ext == :avif && is_animated?
|
|
end
|
|
|
|
# Return true if the image has an embedded ICC color profile.
|
|
def has_embedded_profile?
|
|
image.icc_import(embedded: true)
|
|
true
|
|
rescue Vips::Error
|
|
false
|
|
end
|
|
|
|
# @return [Vips::Image] the Vips image object for the file
|
|
def image
|
|
Vips::Image.new_from_file(file.path, fail: strict).autorot
|
|
end
|
|
|
|
def video
|
|
FFmpeg.new(file)
|
|
end
|
|
|
|
memoize :image, :video, :dimensions, :is_corrupt?, :is_animated_gif?, :is_animated_png?
|
|
end
|