Files
danbooru/app/logical/exif_tool.rb
evazion 2f2c73eebb media assets: fix dimensions of corrupt GIFs.
Fix certain corrupt GIFs returning dimensions of 0x0. This happened
when the GIF was too corrupt for libvips to read. Fixed by using
ExifTool to read the dimensions instead.

Also add validations to ensure that it's not possible to have media
assets with a width or height of 0.
2022-10-31 15:18:02 -05:00

182 lines
6.0 KiB
Ruby

# frozen_string_literal: true
require "shellwords"
# A wrapper for the exiftool command.
class ExifTool
extend Memoist
# @see https://exiftool.org/exiftool_pod.html#OPTIONS
DEFAULT_OPTIONS = %q(
-G1 -duplicates -unknown -struct --binary
-x 'System:*' -x ExifToolVersion -x FileTypeExtension
-x MIMEType -x ImageSize -x MegaPixels
).squish
attr_reader :file
# Open a file with ExifTool.
#
# @param file [File, String] an image or video file
def initialize(file)
@file = file.is_a?(String) ? File.open(file) : file
end
# Get the file's metadata.
#
# @param options [String] the options to pass to exiftool
# @return [ExifTool::Metadata] the file's metadata
def metadata(options: DEFAULT_OPTIONS)
output = %x(exiftool #{options} -json #{file.path.shellescape})
json = JSON.parse(output).first
json = json.except("SourceFile")
ExifTool::Metadata.new(json.with_indifferent_access)
end
memoize :metadata
# A class representing the set of metadata returned by ExifTool for a file.
# Behaves like a Hash, but with extra helper methods for interpreting the metadata.
#
# @see https://exiftool.org/TagNames/index.html
class Metadata
attr_reader :metadata
delegate_missing_to :metadata
# @param [Hash] a hash of metadata as returned by ExifTool
def initialize(metadata)
@metadata = metadata
end
def is_animated?
frame_count.to_i > 1 || is_animated_webp? || is_animated_avif?
end
def is_animated_gif?
file_ext == :gif && is_animated?
end
def is_animated_png?
file_ext == :png && is_animated?
end
def is_animated_webp?
file_ext == :webp && metadata["RIFF:Duration"].present?
end
def is_animated_avif?
file_ext == :avif && metadata["QuickTime:CompatibleBrands"].to_a.include?("avis")
end
# @see https://exiftool.org/TagNames/JPEG.html
# @see https://exiftool.org/TagNames/PNG.html
# @see https://danbooru.donmai.us/posts?tags=exif:File:ColorComponents=1
# @see https://danbooru.donmai.us/posts?tags=exif:PNG:ColorType=Grayscale
def is_greyscale?
metadata["File:ColorComponents"] == 1 ||
metadata["PNG:ColorType"] == "Grayscale" ||
metadata["PNG:ColorType"] == "Grayscale with Alpha" ||
metadata["QuickTime:ChromaFormat"].to_s.match?(/Monochrome/) # "Monochrome 4:0:0"
end
# https://exiftool.org/TagNames/EXIF.html
def is_rotated?
case file_ext
when :jpg
metadata["IFD0:Orientation"].in?(["Rotate 90 CW", "Rotate 270 CW", "Rotate 180"])
when :avif
metadata["QuickTime:Rotation"].present?
else
false
end
end
# AVIF files can be cropped with the "CleanAperture" (aka "clap") tag.
def is_cropped?
file_ext == :avif && metadata["QuickTime:CleanAperture"].present?
end
# AVIF files can be a collection of smaller images combined in a grid to
# form a larger image. This is done to reduce memory usage during encoding.
#
# https://0xc0000054.github.io/pdn-avif/using-image-grids.html
def is_grid_image?
file_ext == :avif && metadata["Meta:MetaImageSize"].present?
end
# Some animations technically have a finite loop count, but loop for hundreds
# or thousands of times. Only count animations with a low loop count as non-repeating.
def is_non_repeating_animation?
loop_count.in?(0..10)
end
# https://danbooru.donmai.us/posts?tags=exif:PNG:Software=NovelAI
# https://danbooru.donmai.us/posts?tags=exif:PNG:Parameters
# https://danbooru.donmai.us/posts?tags=exif:PNG:Sd-metadata
# https://danbooru.donmai.us/posts?tags=exif:PNG:Dream
def is_ai_generated?
metadata["PNG:Software"] == "NovelAI" ||
metadata.has_key?("PNG:Parameters") ||
metadata.has_key?("PNG:Sd-metadata") ||
metadata.has_key?("PNG:Dream")
end
def width
metadata.find { |name, value| name.match?(/\A(File|PNG|GIF|RIFF|Flash|Track\d+):ImageWidth\z/) }&.second
end
def height
metadata.find { |name, value| name.match?(/\A(File|PNG|GIF|RIFF|Flash|Track\d+):ImageHeight\z/) }&.second
end
# @see http://www.vurdalakov.net/misc/gif/netscape-looping-application-extension
# @see https://wiki.mozilla.org/APNG_Specification#.60acTL.60:_The_Animation_Control_Chunk
# @see https://danbooru.donmai.us/posts?tags=-exif:GIF:AnimationIterations=Infinite+animated_gif
# @see https://danbooru.donmai.us/posts?tags=-exif:PNG:AnimationPlays=inf+animated_png
def loop_count
return Float::INFINITY if metadata["GIF:AnimationIterations"] == "Infinite"
return Float::INFINITY if metadata["PNG:AnimationPlays"] == "inf"
return Float::INFINITY if metadata["RIFF:AnimationLoopCount"] == "inf"
return metadata["GIF:AnimationIterations"] if has_key?("GIF:AnimationIterations")
return metadata["PNG:AnimationPlays"] if has_key?("PNG:AnimationPlays")
return metadata["RIFF:AnimationLoopCount"] if has_key?("RIFF:AnimationLoopCount")
# If the AnimationIterations tag isn't present, then it's counted as a loop count of 0.
return 0 if is_animated_gif? && !has_key?("GIF:AnimationIterations")
nil
end
def frame_count
if file_ext == :gif
fetch("GIF:FrameCount", 1)
elsif file_ext == :png
fetch("PNG:AnimationFrames", 1)
else
nil
end
end
def file_ext
if has_key?("File:ColorComponents")
:jpg
elsif has_key?("PNG:ColorType")
:png
elsif has_key?("GIF:GIFVersion")
:gif
elsif metadata["QuickTime:CompatibleBrands"].to_a.include?("avif") || metadata["QuickTime:CompatibleBrands"].to_a.include?("avis")
:avif
elsif has_key?("QuickTime:MovieHeaderVersion")
:mp4
elsif keys.grep(/\ARIFF:/).any?
:webp
elsif has_key?("Matroska:DocType")
:webm
elsif has_key?("Flash:FlashVersion")
:swf
elsif has_key?("ZIP:ZipCompression")
:ugoira
end
end
end
end