From d610c9727d48690c5638d7df654598b0c317254d Mon Sep 17 00:00:00 2001 From: tarsin Date: Mon, 13 Feb 2023 18:03:31 +0800 Subject: [PATCH] Rewrite source when downloading image --- app/src/main/java/coil/decode/DecodeUtils.kt | 66 ++++++++++++++ .../coil/decode/FrameDelayRewritingSource.kt | 88 +++++++++++++++++++ .../com/hippo/ehviewer/spider/SpiderDen.kt | 21 +++-- 3 files changed, 168 insertions(+), 7 deletions(-) create mode 100644 app/src/main/java/coil/decode/DecodeUtils.kt create mode 100644 app/src/main/java/coil/decode/FrameDelayRewritingSource.kt diff --git a/app/src/main/java/coil/decode/DecodeUtils.kt b/app/src/main/java/coil/decode/DecodeUtils.kt new file mode 100644 index 000000000..630931f2e --- /dev/null +++ b/app/src/main/java/coil/decode/DecodeUtils.kt @@ -0,0 +1,66 @@ +@file:JvmName("GifDecodeUtils") + +package coil.decode + +import kotlin.experimental.and +import okio.BufferedSource +import okio.ByteString.Companion.encodeUtf8 + +// https://www.matthewflickinger.com/lab/whatsinagif/bits_and_bytes.asp +private val GIF_HEADER_87A = "GIF87a".encodeUtf8() +private val GIF_HEADER_89A = "GIF89a".encodeUtf8() + +// https://developers.google.com/speed/webp/docs/riff_container +private val WEBP_HEADER_RIFF = "RIFF".encodeUtf8() +private val WEBP_HEADER_WEBP = "WEBP".encodeUtf8() +private val WEBP_HEADER_VPX8 = "VP8X".encodeUtf8() + +// https://nokiatech.github.io/heif/technical.html +private val HEIF_HEADER_FTYP = "ftyp".encodeUtf8() +private val HEIF_HEADER_MSF1 = "msf1".encodeUtf8() +private val HEIF_HEADER_HEVC = "hevc".encodeUtf8() +private val HEIF_HEADER_HEVX = "hevx".encodeUtf8() + +/** + * Return 'true' if the [source] contains a GIF image. The [source] is not consumed. + */ +fun DecodeUtils.isGif(source: BufferedSource): Boolean { + return source.rangeEquals(0, GIF_HEADER_89A) || + source.rangeEquals(0, GIF_HEADER_87A) +} + +/** + * Return 'true' if the [source] contains a WebP image. The [source] is not consumed. + */ +fun DecodeUtils.isWebP(source: BufferedSource): Boolean { + return source.rangeEquals(0, WEBP_HEADER_RIFF) && + source.rangeEquals(8, WEBP_HEADER_WEBP) +} + +/** + * Return 'true' if the [source] contains an animated WebP image. The [source] is not consumed. + */ +fun DecodeUtils.isAnimatedWebP(source: BufferedSource): Boolean { + return isWebP(source) && + source.rangeEquals(12, WEBP_HEADER_VPX8) && + source.request(17) && + (source.buffer[16] and 0b00000010) > 0 +} + +/** + * Return 'true' if the [source] contains an HEIF image. The [source] is not consumed. + */ +fun DecodeUtils.isHeif(source: BufferedSource): Boolean { + return source.rangeEquals(4, HEIF_HEADER_FTYP) +} + +/** + * Return 'true' if the [source] contains an animated HEIF image sequence. The [source] is not + * consumed. + */ +fun DecodeUtils.isAnimatedHeif(source: BufferedSource): Boolean { + return isHeif(source) && + (source.rangeEquals(8, HEIF_HEADER_MSF1) || + source.rangeEquals(8, HEIF_HEADER_HEVC) || + source.rangeEquals(8, HEIF_HEADER_HEVX)) +} diff --git a/app/src/main/java/coil/decode/FrameDelayRewritingSource.kt b/app/src/main/java/coil/decode/FrameDelayRewritingSource.kt new file mode 100644 index 000000000..e2d75cd80 --- /dev/null +++ b/app/src/main/java/coil/decode/FrameDelayRewritingSource.kt @@ -0,0 +1,88 @@ +@file:Suppress("SameParameterValue") + +package coil.decode + +import okio.Buffer +import okio.ByteString +import okio.ByteString.Companion.decodeHex +import okio.ForwardingSource +import okio.Source + +/** + * A [ForwardingSource] that rewrites the GIF frame delay in every graphics control block if it's + * below a threshold. + */ +internal class FrameDelayRewritingSource(delegate: Source) : ForwardingSource(delegate) { + + // An intermediary buffer so we can read and alter the data before it's written to the destination. + private val buffer = Buffer() + + override fun read(sink: Buffer, byteCount: Long): Long { + // Ensure our buffer has enough bytes to satisfy this read. + request(byteCount) + + // Short circuit if there are no bytes in the buffer. + if (buffer.size == 0L) { + return if (byteCount == 0L) 0L else -1L + } + + // Search through the buffer and rewrite any frame delays below the threshold. + var bytesWritten = 0L + while (true) { + val index = indexOf(FRAME_DELAY_START_MARKER) + if (index == -1L) break + + // Write up until the end of the frame delay start marker. + bytesWritten += write(sink, index + FRAME_DELAY_START_MARKER_SIZE_BYTES) + + // Check for the end of the graphics control extension block. + if (!request(5) || buffer[4] != 0.toByte()) continue + + // Rewrite the frame delay if it is below the threshold. + // The frame delay is stored as two unsigned bytes in reverse order + // (i.e. the most significant bits are in the byte that's read second). + val frameDelay = (buffer[2].toUByte().toInt() shl 8) or buffer[1].toUByte().toInt() + if (frameDelay < MINIMUM_FRAME_DELAY) { + sink.writeByte(buffer[0].toInt()) + sink.writeByte(DEFAULT_FRAME_DELAY) + sink.writeByte(0) + buffer.skip(3) + } + } + + // Write anything left in the source. + if (bytesWritten < byteCount) { + bytesWritten += write(sink, byteCount - bytesWritten) + } + return if (bytesWritten == 0L) -1 else bytesWritten + } + + private fun indexOf(bytes: ByteString): Long { + var index = -1L + while (true) { + index = buffer.indexOf(bytes[0], index + 1) + if (index == -1L) break + if (request(bytes.size.toLong()) && buffer.rangeEquals(index, bytes)) break + } + return index + } + + private fun write(sink: Buffer, byteCount: Long): Long { + return buffer.read(sink, byteCount).coerceAtLeast(0) + } + + private fun request(byteCount: Long): Boolean { + if (buffer.size >= byteCount) return true + val toRead = byteCount - buffer.size + return super.read(buffer, toRead) == toRead + } + + private companion object { + // https://www.matthewflickinger.com/lab/whatsinagif/bits_and_bytes.asp + // See: "Graphics Control Extension" + private val FRAME_DELAY_START_MARKER = "0021F904".decodeHex() + private const val FRAME_DELAY_START_MARKER_SIZE_BYTES = 4 + private const val MINIMUM_FRAME_DELAY = 2 + private const val DEFAULT_FRAME_DELAY = 10 + } +} diff --git a/app/src/main/java/com/hippo/ehviewer/spider/SpiderDen.kt b/app/src/main/java/com/hippo/ehviewer/spider/SpiderDen.kt index 63e71ab91..18b66be12 100644 --- a/app/src/main/java/com/hippo/ehviewer/spider/SpiderDen.kt +++ b/app/src/main/java/com/hippo/ehviewer/spider/SpiderDen.kt @@ -15,6 +15,9 @@ */ package com.hippo.ehviewer.spider +import coil.decode.DecodeUtils +import coil.decode.FrameDelayRewritingSource +import coil.decode.isGif import coil.disk.DiskCache import com.hippo.Native.mapFd import com.hippo.Native.unmapDirectByteBuffer @@ -35,6 +38,7 @@ import com.hippo.yorozuya.MathUtils import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.ensureActive import okhttp3.executeAsync +import okio.buffer import java.io.File import java.io.FileInputStream import java.io.FileOutputStream @@ -175,13 +179,16 @@ class SpiderDen(private val mGalleryInfo: GalleryInfo) { suspend fun doSave(chan: FileChannel): Long { var receivedSize: Long = 0 - body.source().use { source -> - while (true) { - currentCoroutineContext().ensureActive() - val bytesRead = chan.transferFrom(source, receivedSize, TRANSFER_BLOCK) - receivedSize += bytesRead - notifyProgress(body.contentLength(), receivedSize, bytesRead.toInt()) - if (bytesRead.toInt() == 0) break + body.source().use { responseSource -> + val source = if (DecodeUtils.isGif(responseSource)) FrameDelayRewritingSource(responseSource).buffer() else responseSource + source.use { + while (true) { + currentCoroutineContext().ensureActive() + val bytesRead = chan.transferFrom(it, receivedSize, TRANSFER_BLOCK) + receivedSize += bytesRead + notifyProgress(body.contentLength(), receivedSize, bytesRead.toInt()) + if (bytesRead.toInt() == 0) break + } } } return receivedSize