From 0ebb55551cc352374cd43258ab8729592f97e3a1 Mon Sep 17 00:00:00 2001 From: FooIbar <118464521+FooIbar@users.noreply.github.com> Date: Sat, 14 Jan 2023 17:19:48 +0800 Subject: [PATCH] Rewrite the GIF frame delay if it's below a threshold (coil-kt/coil#783) (#672) * Rewrite the GIF frame delay if it's below a threshold (coil-kt/coil#783) Resolve #641 * fixup! Rewrite the GIF frame delay if it's below a threshold (coil-kt/coil#783) --- app/src/main/java/coil/decode/DecodeUtils.kt | 66 ++++++++++++++ .../coil/decode/FrameDelayRewritingSource.kt | 88 +++++++++++++++++++ app/src/main/java/com/hippo/image/Image.kt | 28 +++++- 3 files changed, 179 insertions(+), 3 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..0d530af66 --- /dev/null +++ b/app/src/main/java/coil/decode/DecodeUtils.kt @@ -0,0 +1,66 @@ +@file:JvmName("GifDecodeUtils") + +package coil.decode + +import okio.BufferedSource +import okio.ByteString.Companion.encodeUtf8 +import kotlin.experimental.and + +// 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/image/Image.kt b/app/src/main/java/com/hippo/image/Image.kt index 42247dd79..f2fb26eff 100644 --- a/app/src/main/java/com/hippo/image/Image.kt +++ b/app/src/main/java/com/hippo/image/Image.kt @@ -25,8 +25,15 @@ import android.graphics.ImageDecoder.Source import android.graphics.drawable.Animatable import android.graphics.drawable.BitmapDrawable import android.graphics.drawable.Drawable +import coil.decode.DecodeUtils +import coil.decode.FrameDelayRewritingSource +import coil.decode.isGif import com.hippo.UriArchiveAccessor import com.hippo.ehviewer.EhApplication +import okio.Buffer +import okio.BufferedSource +import okio.buffer +import okio.source import java.io.FileInputStream import java.nio.ByteBuffer import java.nio.channels.FileChannel @@ -80,20 +87,35 @@ class Image private constructor(source: Source, private val byteBuffer: ByteBuff @Throws(DecodeException::class) @JvmStatic fun decode(stream: FileInputStream): Image { - val src = ImageDecoder.createSource( + val source = stream.source().buffer() + val buffer = if (DecodeUtils.isGif(source)) { + rewriteSource(source) + } else { stream.channel.map( FileChannel.MapMode.READ_ONLY, 0, stream.available().toLong() ) - ) + } + val src = ImageDecoder.createSource(buffer) return Image(src) } @Throws(DecodeException::class) @JvmStatic fun decode(buffer: ByteBuffer): Image { - val src = ImageDecoder.createSource(buffer) + val source = Buffer().apply { write(buffer) } + val byteBuffer = if (DecodeUtils.isGif(source)) { + rewriteSource(source) + } else { + buffer.position(0) as ByteBuffer + } + val src = ImageDecoder.createSource(byteBuffer) return Image(src, buffer) } + + private fun rewriteSource(source: BufferedSource): ByteBuffer { + val bufferedSource = FrameDelayRewritingSource(source).buffer() + return ByteBuffer.wrap(bufferedSource.use { it.readByteArray() }) + } } } \ No newline at end of file