diff --git a/app/logical/exif_tool.rb b/app/logical/exif_tool.rb index f48368c76..94f0e6592 100644 --- a/app/logical/exif_tool.rb +++ b/app/logical/exif_tool.rb @@ -14,21 +14,112 @@ class ExifTool 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. - # @see https://exiftool.org/TagNames/index.html + # # @param options [String] the options to pass to exiftool - # @return [Hash] the file's metadata + # @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") - json.with_indifferent_access + 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 + end + + def is_animated_gif? + file_ext == :gif && is_animated? + end + + def is_animated_png? + file_ext == :png && is_animated? + 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" + end + + # https://exiftool.org/TagNames/EXIF.html + def is_rotated? + file_ext == :jpg && metadata["IFD0:Orientation"].in?(["Rotate 90 CW", "Rotate 270 CW", "Rotate 180"]) + 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 + + # @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 metadata["GIF:AnimationIterations"] if has_key?("GIF:AnimationIterations") + return metadata["PNG:AnimationPlays"] if has_key?("PNG:AnimationPlays") + + # 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 has_key?("QuickTime:MovieHeaderVersion") + :mp4 + elsif has_key?("Matroska:DocType") + :webm + elsif has_key?("Flash:FlashVersion") + :swf + elsif has_key?("ZIP:ZipCompression") + :ugoira + end + end + end end diff --git a/app/models/media_asset.rb b/app/models/media_asset.rb index f432a7095..be6b7db14 100644 --- a/app/models/media_asset.rb +++ b/app/models/media_asset.rb @@ -1,6 +1,7 @@ class MediaAsset < ApplicationRecord has_one :media_metadata, dependent: :destroy delegate :metadata, to: :media_metadata + delegate :is_animated?, :is_animated_gif?, :is_animated_png?, :is_non_repeating_animation?, :is_greyscale?, :is_rotated?, to: :metadata def self.search(params) q = search_attributes(params, :id, :created_at, :updated_at, :md5, :file_ext, :file_size, :image_width, :image_height) @@ -18,59 +19,4 @@ class MediaAsset < ApplicationRecord self.image_height = media_file.height self.media_metadata = MediaMetadata.new(file: media_file) end - - def is_animated? - is_animated_gif? || is_animated_png? - end - - def is_animated_gif? - file_ext == "gif" && metadata.fetch("GIF:FrameCount", 1) > 1 - end - - def is_animated_png? - file_ext == "png" && metadata.fetch("PNG:AnimationFrames", 1) > 1 - 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" - - # Not always accurate: - # metadata["ICC-header:ColorSpaceData"] == "GRAY" || - # metadata["XMP-photoshop:ColorMode"] == "Grayscale" || - # metadata["XMP-photoshop:ICCProfileName"] == "EPSON Gray - Gamma 2.2" || - # metadata["XMP-photoshop:ICCProfileName"] == "Gray Gamma 2.2" - end - - # https://exiftool.org/TagNames/EXIF.html - def is_rotated? - file_ext == "jpg" && metadata["IFD0:Orientation"].in?(["Rotate 90 CW", "Rotate 270 CW", "Rotate 180"]) - 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 - - # @see https://exiftool.org/TagNames/GIF.html - # @see https://exiftool.org/TagNames/PNG.html - # @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 metadata["GIF:AnimationIterations"] if metadata["GIF:AnimationIterations"].present? - return metadata["PNG:AnimationPlays"] if metadata["PNG:AnimationPlays"].present? - - # If the AnimationIterations tag isn't present, then it's counted as a loop count of 0. - return 0 if is_animated_gif? && metadata["GIF:AnimationIterations"].nil? - - nil - end end diff --git a/app/models/media_metadata.rb b/app/models/media_metadata.rb index ce7e394a7..5f1895394 100644 --- a/app/models/media_metadata.rb +++ b/app/models/media_metadata.rb @@ -23,4 +23,8 @@ class MediaMetadata < ApplicationRecord def file=(file_or_path) self.metadata = MediaFile.open(file_or_path).metadata end + + def metadata + ExifTool::Metadata.new(self[:metadata]) + end end