From 468eb12573325111fe8405cfbc43407ac9fe2686 Mon Sep 17 00:00:00 2001 From: Mergan Date: Fri, 23 Sep 2022 10:27:14 +0000 Subject: [PATCH] Detect whether an WEBP or APNG is animated (#161) Co-authored-by: David Reviewed-on: https://akkoma.dev/AkkomaGang/pleroma-fe/pulls/161 Co-authored-by: Mergan Co-committed-by: Mergan --- src/components/still-image/still-image.js | 85 +++++++++++++++++++++-- 1 file changed, 79 insertions(+), 6 deletions(-) diff --git a/src/components/still-image/still-image.js b/src/components/still-image/still-image.js index d7abbcb5..480de9fa 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,89 @@ 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 + } + // harmless CORS errors without-- clean console with + if (!this.$store.state.instance.mediaProxyAvailable) 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, { + 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() } }