diff --git a/src/components/still-image/still-image.js b/src/components/still-image/still-image.js index 9c335d6c..963bb1ac 100644 --- a/src/components/still-image/still-image.js +++ b/src/components/still-image/still-image.js @@ -39,27 +39,18 @@ const StillImage = { this.imageLoadError && this.imageLoadError() }, detectAnimation (image) { - // If there are no file extensions, the mimetype isn't set, and no mediaproxy is available, we can't figure out - // the mimetype of the image. - const hasFileExtension = this.src.split('/').pop().includes('.') // TODO: Better check? - const mediaProxyAvailable = this.$store.state.instance.mediaProxyAvailable - if (!hasFileExtension && this.mimetype === undefined && !mediaProxyAvailable) { - // It's a bit aggressive to assume all images we can't find the mimetype of is animated, but necessary for - // people in need of reduced motion accessibility. As such, we'll consider those images animated if the user - // agent is set to prefer reduced motion. Otherwise, it'll just be used as an early exit. - if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) - this.isAnimated = true - return + // It's a bit aggressive to assume all images we can't find the mimetype of is animated, but necessary for + // people in need of reduced motion accessibility. As such, we'll consider those images animated if the user + // agent is set to prefer reduced motion. Otherwise, it'll just be used as an early exit. + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { + this.isAnimated = true } - if (this.mimetype === 'image/gif' || this.src.endsWith('.gif')) { - this.isAnimated = true - return - } + const mediaProxyAvailable = this.$store.state.instance.mediaProxyAvailable + // harmless CORS errors without-- clean console with if (!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' @@ -68,11 +59,16 @@ const StillImage = { // 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)) { + // Ordered from least to most intensive + if (this.isGIF(reader.value)) { this.isAnimated = true return } - if (this.src.endsWith('.png') && this.isAnimatedPNG(reader.value)) { + if (this.isAnimatedWEBP(reader.value)) { + this.isAnimated = true + return + } + if (this.isAnimatedPNG(reader.value)) { this.isAnimated = true } }) @@ -81,6 +77,20 @@ const StillImage = { // this.imageLoadError && this.imageLoadError() }) }, + isGIF (data) { + // I am a perfectly sane individual + // + // GIF HEADER CHUNK + // === START HEADER === + // 47 49 46 38 ("GIF8") + const gifHeader = [0x47, 0x49, 0x46]; + for (let i = 0; i < 3; i++) { + if (data[i] !== gifHeader[i]) { + return false; + } + } + return true + }, isAnimatedWEBP (data) { /** * WEBP HEADER CHUNK @@ -114,16 +124,51 @@ const StillImage = { const idatPos = str.indexOf('IDAT') return (str.substring(0, idatPos > 0 ? idatPos : 0).indexOf('acTL') > 0) }, - drawThumbnail () { - const canvas = this.$refs.canvas - 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) - } + drawThumbnail() { + const canvas = this.$refs.canvas; + if (!canvas) return; + + const context = canvas.getContext('2d'); + const image = this.$refs.src; + const parentElement = canvas.parentElement; + + // Draw the quick, unscaled version first + context.drawImage(image, 0, 0, parentElement.clientWidth, parentElement.clientHeight); + + // Use requestAnimationFrame to schedule the scaling to the next frame + requestAnimationFrame(() => { + // Adjust for high-DPI displays + const ratio = window.devicePixelRatio || 1; + canvas.width = parentElement.clientWidth * ratio; + canvas.height = parentElement.clientHeight * ratio; + canvas.style.width = `${parentElement.clientWidth}px`; + canvas.style.height = `${parentElement.clientHeight}px`; + context.scale(ratio, ratio); + + // Maintain the aspect ratio of the image + const imgAspectRatio = image.naturalWidth / image.naturalHeight; + const canvasAspectRatio = parentElement.clientWidth / parentElement.clientHeight; + + let drawWidth, drawHeight; + + if (imgAspectRatio > canvasAspectRatio) { + drawWidth = parentElement.clientWidth; + drawHeight = parentElement.clientWidth / imgAspectRatio; + } else { + drawHeight = parentElement.clientHeight; + drawWidth = parentElement.clientHeight * imgAspectRatio; + } + + context.clearRect(0, 0, canvas.width, canvas.height); // Clear the previous unscaled image + context.imageSmoothingEnabled = true; + context.imageSmoothingQuality = 'high'; + + // Draw the good one for realsies + const dx = (parentElement.clientWidth - drawWidth) / 2; + const dy = (parentElement.clientHeight - drawHeight) / 2; + context.drawImage(image, dx, dy, drawWidth, drawHeight); + }); + } }, updated () { // On computed animated change diff --git a/src/components/still-image/still-image.vue b/src/components/still-image/still-image.vue index fa3edacf..68e4ca49 100644 --- a/src/components/still-image/still-image.vue +++ b/src/components/still-image/still-image.vue @@ -60,7 +60,7 @@ &.animated { &::before { zoom: var(--_still_image-label-scale, 1); - content: 'gif'; + content: var(--image-type-label, 'A?'); position: absolute; line-height: 1; font-size: 0.7em;