diff --git a/app/logical/exif_tool.rb b/app/logical/exif_tool.rb index ee0f8ad0e..8a9b5c574 100644 --- a/app/logical/exif_tool.rb +++ b/app/logical/exif_tool.rb @@ -48,7 +48,7 @@ class ExifTool end def is_animated? - frame_count.to_i > 1 || is_animated_avif? + frame_count.to_i > 1 || is_animated_webp? || is_animated_avif? end def is_animated_gif? @@ -59,6 +59,10 @@ class ExifTool 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 @@ -123,8 +127,10 @@ class ExifTool 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") @@ -153,6 +159,8 @@ class ExifTool :avif elsif has_key?("QuickTime:MovieHeaderVersion") :mp4 + elsif keys.grep(/\ARIFF:/).any? + :webp elsif has_key?("Matroska:DocType") :webm elsif has_key?("Flash:FlashVersion") diff --git a/app/logical/media_file.rb b/app/logical/media_file.rb index fad6da362..3206e1b33 100644 --- a/app/logical/media_file.rb +++ b/app/logical/media_file.rb @@ -26,7 +26,7 @@ class MediaFile file = Kernel.open(file, "r", binmode: true) unless file.respond_to?(:read) case file_ext(file) - when :jpg, :gif, :png, :avif + when :jpg, :gif, :png, :webp, :avif MediaFile::Image.new(file, **options) when :swf MediaFile::Flash.new(file, **options) @@ -62,6 +62,10 @@ class MediaFile when /\A\x1a\x45\xdf\xa3/n :webm + # https://developers.google.com/speed/webp/docs/riff_container + when /\ARIFF....WEBP/ + :webp + # https://www.ftyps.com # isom (common) - MP4 Base Media v1 [IS0 14496-12:2003] # mp42 (common) - MP4 v2 [ISO 14496-14] @@ -144,7 +148,7 @@ class MediaFile # @return [Boolean] true if the file is an image def is_image? - file_ext.in?([:jpg, :png, :gif, :avif]) + file_ext.in?(%i[jpg png gif webp avif]) end # @return [Boolean] true if the file is a video diff --git a/app/logical/media_file/image.rb b/app/logical/media_file/image.rb index c84f2dde2..0b83c8473 100644 --- a/app/logical/media_file/image.rb +++ b/app/logical/media_file/image.rb @@ -16,6 +16,8 @@ class MediaFile::Image < MediaFile 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? + when :webp + !is_animated? else true end @@ -34,11 +36,12 @@ class MediaFile::Image < MediaFile end def frame_count - if file_ext == :gif - image.get("n-pages") - elsif file_ext == :png + case file_ext + when :gif, :webp + image.get("n-pages") if image.get_fields.include?("n-pages") + when :png metadata.fetch("PNG:AnimationFrames", 1) - elsif file_ext == :avif + when :avif video.frame_count else nil @@ -124,6 +127,10 @@ class MediaFile::Image < MediaFile file_ext == :png && is_animated? end + def is_animated_webp? + file_ext == :webp && is_animated? + end + def is_animated_avif? file_ext == :avif && is_animated? end diff --git a/app/models/media_asset.rb b/app/models/media_asset.rb index b5d101ef7..38a29c7ba 100644 --- a/app/models/media_asset.rb +++ b/app/models/media_asset.rb @@ -3,7 +3,7 @@ class MediaAsset < ApplicationRecord class Error < StandardError; end - FILE_TYPES = %w[jpg png gif avif mp4 webm swf zip] + FILE_TYPES = %w[jpg png gif webp avif mp4 webm swf zip] FILE_KEY_LENGTH = 9 VARIANTS = %i[preview 180x180 360x360 720x720 sample original] MAX_FILE_SIZE = Danbooru.config.max_file_size.to_i @@ -372,7 +372,7 @@ class MediaAsset < ApplicationRecord concerning :FileTypeMethods do def is_image? - file_ext.in?(%w[jpg png gif avif]) + file_ext.in?(%w[jpg png gif webp avif]) end def is_static_image? diff --git a/app/models/post.rb b/app/models/post.rb index 413914e29..bacfe73aa 100644 --- a/app/models/post.rb +++ b/app/models/post.rb @@ -188,7 +188,7 @@ class Post < ApplicationRecord end def is_image? - file_ext =~ /jpg|gif|png|avif/i + file_ext =~ /jpg|gif|png|webp|avif/i end def is_flash? diff --git a/test/files/webp/2_webp_a.webp b/test/files/webp/2_webp_a.webp new file mode 100644 index 000000000..0a8560a77 Binary files /dev/null and b/test/files/webp/2_webp_a.webp differ diff --git a/test/files/webp/2_webp_ll.webp b/test/files/webp/2_webp_ll.webp new file mode 100644 index 000000000..7c5de8033 Binary files /dev/null and b/test/files/webp/2_webp_ll.webp differ diff --git a/test/files/webp/Exif2.webp b/test/files/webp/Exif2.webp new file mode 100644 index 000000000..d50b46a27 Binary files /dev/null and b/test/files/webp/Exif2.webp differ diff --git a/test/files/webp/README.md b/test/files/webp/README.md new file mode 100644 index 000000000..e4675d4f1 --- /dev/null +++ b/test/files/webp/README.md @@ -0,0 +1,6 @@ +Test file sources: + +* https://github.com/webmproject/libwebp-test-data +* https://developers.google.com/speed/webp/gallery1 +* https://zpl.fi/exif-orientation-in-different-formats/ +* https://git.zpl.fi/exif-orientation/about/ diff --git a/test/files/webp/fjord.webp b/test/files/webp/fjord.webp new file mode 100644 index 000000000..122741b60 Binary files /dev/null and b/test/files/webp/fjord.webp differ diff --git a/test/files/webp/lossless1.webp b/test/files/webp/lossless1.webp new file mode 100644 index 000000000..c0cdebf52 Binary files /dev/null and b/test/files/webp/lossless1.webp differ diff --git a/test/files/webp/lossy_alpha1.webp b/test/files/webp/lossy_alpha1.webp new file mode 100644 index 000000000..14fe79dcf Binary files /dev/null and b/test/files/webp/lossy_alpha1.webp differ diff --git a/test/files/webp/nyancat.webp b/test/files/webp/nyancat.webp new file mode 100644 index 000000000..7a1d3fe14 Binary files /dev/null and b/test/files/webp/nyancat.webp differ diff --git a/test/files/webp/test.webp b/test/files/webp/test.webp new file mode 100644 index 000000000..c4a7b16c3 Binary files /dev/null and b/test/files/webp/test.webp differ diff --git a/test/functional/uploads_controller_test.rb b/test/functional/uploads_controller_test.rb index 55c7b8cd0..4c1148cc9 100644 --- a/test/functional/uploads_controller_test.rb +++ b/test/functional/uploads_controller_test.rb @@ -197,6 +197,13 @@ class UploadsControllerTest < ActionDispatch::IntegrationTest end end + context "for an unsupported WebP file" do + should "fail for an animated WebP" do + create_upload!("test/files/webp/nyancat.webp", user: @user) + assert_match("File type is not supported", Upload.last.error) + end + end + context "for an unsupported AVIF file" do should "fail for a grid image" do create_upload!("test/files/avif/Image grid example.avif", user: @user) @@ -309,6 +316,9 @@ class UploadsControllerTest < ActionDispatch::IntegrationTest should_upload_successfully("test/files/avif/hdr_cosmos01000_cicp9-16-9_yuv444_full_qp40.avif") should_upload_successfully("test/files/avif/paris_icc_exif_xmp.avif") should_upload_successfully("test/files/avif/tiger_3layer_1res.avif") + + should_upload_successfully("test/files/webp/test.webp") + should_upload_successfully("test/files/webp/fjord.webp") end context "uploading multiple files from your computer" do diff --git a/test/unit/autocomplete_service_test.rb b/test/unit/autocomplete_service_test.rb index e222e6e2e..f1e379491 100644 --- a/test/unit/autocomplete_service_test.rb +++ b/test/unit/autocomplete_service_test.rb @@ -170,7 +170,7 @@ class AutocompleteServiceTest < ActiveSupport::TestCase assert_autocomplete_equals(["filetype:gif"], "filetype:g", :tag_query) assert_autocomplete_equals(["filetype:swf"], "filetype:s", :tag_query) assert_autocomplete_equals(["filetype:zip"], "filetype:z", :tag_query) - assert_autocomplete_equals(["filetype:webm"], "filetype:w", :tag_query) + assert_autocomplete_equals(["filetype:webm", "filetype:webp"], "filetype:w", :tag_query) assert_autocomplete_equals(["filetype:mp4"], "filetype:m", :tag_query) assert_autocomplete_equals(["commentary:true"], "commentary:tru", :tag_query) diff --git a/test/unit/media_file_test.rb b/test/unit/media_file_test.rb index 07bc3c9a6..f68c2b3de 100644 --- a/test/unit/media_file_test.rb +++ b/test/unit/media_file_test.rb @@ -21,6 +21,10 @@ class MediaFileTest < ActiveSupport::TestCase assert_equal([32, 32], MediaFile.open("test/files/test-static-32x32.gif").dimensions) end + should "determine the correct dimensions for a WebP file" do + assert_equal([550, 368], MediaFile.open("test/files/webp/fjord.webp").dimensions) + end + should "determine the correct dimensions for an AVIF file" do assert_equal([2048, 858], MediaFile.open("test/files/avif/hdr_cosmos01000_cicp9-16-9_yuv420_limited_qp40.avif").dimensions) end @@ -89,6 +93,12 @@ class MediaFileTest < ActiveSupport::TestCase assert_equal(:gif, MediaFile.open("test/files/test-static-32x32.gif").file_ext) end + should "determine the correct extension for a WebP file" do + Dir["test/files/webp/*.webp"].each do |file| + assert_equal(:webp, MediaFile.open(file).file_ext) + end + end + should "determine the correct extension for an AVIF file" do Dir["test/files/avif/*.avif"].each do |file| assert_equal(:avif, MediaFile.open(file).file_ext) @@ -137,6 +147,7 @@ class MediaFileTest < ActiveSupport::TestCase assert_equal([150, 101], MediaFile.open("test/files/test.jpg").preview(150, 150).dimensions) assert_equal([113, 150], MediaFile.open("test/files/test.png").preview(150, 150).dimensions) assert_equal([150, 150], MediaFile.open("test/files/test.gif").preview(150, 150).dimensions) + assert_equal([150, 100], MediaFile.open("test/files/webp/fjord.webp").preview(150, 150).dimensions) assert_equal([150, 63], MediaFile.open("test/files/avif/hdr_cosmos01000_cicp9-16-9_yuv420_limited_qp40.avif").preview(150, 150).dimensions) end @@ -311,6 +322,40 @@ class MediaFileTest < ActiveSupport::TestCase end end + context "a WebP file" do + should "be able to read WebP files" do + Dir["test/files/webp/*.webp"].each do |file| + assert_nothing_raised { MediaFile.open(file).attributes } + end + end + + should "detect animated files" do + assert_equal(true, MediaFile.open("test/files/webp/nyancat.webp").is_animated?) + assert_equal(true, MediaFile.open("test/files/webp/nyancat.webp").is_animated_webp?) + assert_equal(true, MediaFile.open("test/files/webp/nyancat.webp").metadata.is_animated?) + assert_equal(false, MediaFile.open("test/files/webp/nyancat.webp").is_supported?) + assert_equal(12, MediaFile.open("test/files/webp/nyancat.webp").frame_count) + assert_equal(Float::INFINITY, MediaFile.open("test/files/webp/nyancat.webp").metadata.loop_count) + + # assert_equal(0.84, MediaFile.open("test/files/webp/nyancat.webp").duration) + end + + should "be able to generate a preview" do + assert_equal([128, 128], MediaFile.open("test/files/webp/test.webp").preview(180, 180).dimensions) + assert_equal([176, 180], MediaFile.open("test/files/webp/2_webp_a.webp").preview(180, 180).dimensions) + assert_equal([176, 180], MediaFile.open("test/files/webp/2_webp_ll.webp").preview(180, 180).dimensions) + assert_equal([180, 120], MediaFile.open("test/files/webp/Exif2.webp").preview(180, 180).dimensions) + assert_equal([180, 120], MediaFile.open("test/files/webp/fjord.webp").preview(180, 180).dimensions) + assert_equal([180, 55], MediaFile.open("test/files/webp/lossless1.webp").preview(180, 180).dimensions) + assert_equal([180, 55], MediaFile.open("test/files/webp/lossy_alpha1.webp").preview(180, 180).dimensions) + end + + should "ignore EXIF orientation tags" do + # XXX It's possible for .webp files to contain the IFD0:Orientation tag, but browsers currently ignore it, so we do too. + assert_equal(false, MediaFile.open("test/files/webp/Exif2.webp").metadata.is_rotated?) + end + end + context "an AVIF file" do should "be able to read AVIF files" do Dir["test/files/avif/*.avif"].each do |file|