From 9f65fef4d55a7b46363a077e9a474f17d41e45b1 Mon Sep 17 00:00:00 2001
From: David <dmgf2008@hotmail.com>
Date: Tue, 6 Sep 2022 10:17:50 -0700
Subject: [PATCH] Detect if WEBP is animated

---
 src/components/still-image/still-image.js | 84 +++++++++++++++++++++--
 1 file changed, 78 insertions(+), 6 deletions(-)

diff --git a/src/components/still-image/still-image.js b/src/components/still-image/still-image.js
index d7abbcb5..3111e5bd 100644
--- a/src/components/still-image/still-image.js
+++ b/src/components/still-image/still-image.js
@@ -11,12 +11,13 @@ const StillImage = {
   ],
   data () {
     return {
-      stopGifs: this.$store.getters.mergedConfig.stopGifs
+      stopGifs: this.$store.getters.mergedConfig.stopGifs,
+      isAnimated: false
     }
   },
   computed: {
     animated () {
-      return this.stopGifs && (this.mimetype === 'image/gif' || this.src.endsWith('.gif'))
+      return this.stopGifs && this.isAnimated
     },
     style () {
       const appendPx = (str) => /\d$/.test(str) ? str + 'px' : str
@@ -31,17 +32,88 @@ const StillImage = {
       const image = this.$refs.src
       if (!image) return
       this.imageLoadHandler && this.imageLoadHandler(image)
+      this.detectAnimation(image)
+      this.drawThumbnail()
+    },
+    onError () {
+      this.imageLoadError && this.imageLoadError()
+    },
+    detectAnimation (image) {
+      if (this.mimetype === 'image/gif' || this.src.endsWith('.gif')) {
+        this.isAnimated = true
+        return
+      }
+      // Animated JPEGs?
+      if (!(this.src.endsWith('.webp') || this.src.endsWith('.png'))) return
+      // Browser Cache should ensure image doesn't get loaded twice if cache exists
+      fetch(image.src, {
+        mode: 'cors',
+        referrerPolicy: 'same-origin'
+      })
+        .then(data => {
+          // We don't need to read the whole file so only call it once
+          data.body.getReader().read()
+            .then(reader => {
+              if (this.src.endsWith('.webp') && this.isAnimatedWEBP(reader.value)) {
+                this.isAnimated = true
+                return
+              }
+              if (this.src.endsWith('.png') && this.isAnimatedPNG(reader.value)) {
+                this.isAnimated = true
+              }
+            })
+        })
+        .catch(() => {
+          // this.imageLoadError && this.imageLoadError()
+        })
+    },
+    isAnimatedWEBP (data) {
+      /**
+       * WEBP HEADER CHUNK
+       * === START HEADER ===
+       *  82 73 70 70 ("RIFF")
+       *  xx xx xx xx (SIZE)
+       *  87 69 66 80 ("WEBP")
+       * === END OF HEADER ===
+       *  86 80 56 88 ("VP8X") ← Extended VP8X
+       *  xx xx xx xx (VP8X)
+       *  [++] ← RSVILEX(A)R (1 byte)
+       *  A → Animated bit
+       */
+      // Relevant bytes
+      const segment = data.slice(4 * 3, (4 * 5) + 1)
+      // Check for VP8X string
+      if (segment.join('').includes(['86805688'])) {
+        // Check for Animation bit
+        return !!((segment[8] >> 1) & 1)
+      }
+      // No VP8X = Not Animated (X is for Extended)
+      return false
+    },
+    isAnimatedPNG (data) {
+      // Find acTL before IDAT in PNG; if found it is animated
+      const segment = []
+      for (let i = 0; i < data.length; i++) {
+        segment.push(String.fromCharCode(data[i]))
+      }
+      const str = segment.join('')
+      const idatPos = str.indexOf('IDAT')
+      return (str.substring(0, idatPos > 0 ? idatPos : 0).indexOf('acTL') > 0)
+    },
+    drawThumbnail () {
       const canvas = this.$refs.canvas
-      if (!canvas) return
+      if (!this.$refs.canvas) return
+      const image = this.$refs.src
       const width = image.naturalWidth
       const height = image.naturalHeight
       canvas.width = width
       canvas.height = height
       canvas.getContext('2d').drawImage(image, 0, 0, width, height)
-    },
-    onError () {
-      this.imageLoadError && this.imageLoadError()
     }
+  },
+  updated () {
+    // On computed animated change
+    this.drawThumbnail()
   }
 }