coil-kt / coil

Image loading for Android and Compose Multiplatform.
https://coil-kt.github.io/coil/
Apache License 2.0
10.84k stars 665 forks source link

Add constant to parse Gif Loop Count and apply to repeatCount #2654

Open tgyuuAn opened 2 weeks ago

tgyuuAn commented 2 weeks ago

Key Changes


You can use this feature in your code as follows:

@Preview
@Composable
fun Test() {
    val customImageLoader = ImageLoader.Builder(LocalContext.current)
        .components {
            if (Build.VERSION.SDK_INT >= 28) {
                add(AnimatedImageDecoder.Factory())
            } else {
                add(GifDecoder.Factory())
            }
        }
        .build()

    val imageRequest = ImageRequest.Builder(LocalContext.current)
        .data(sample.common.R.drawable.ic_change_loop_count)
        .repeatCount(GIF_LOOP_COUNT)  // <---- This Constant
        .build()

    AsyncImage(
        imageLoader = customImageLoader,
        model = imageRequest,
        contentDescription = null,
        modifier = Modifier.size(100.dp),
    )
}



Parsing Logic for GIF

During the GIF parsing process, we utilized source.peek() to read the data again. This was necessary because the loop count needs to be accessed without consuming the original BufferedSource, ensuring that we can still read the data needed for further processing. Unfortunately, this reliance on peek() is a limitation and feels like a workaround in the parsing logic.

Handling Loop Count in AnimatedImageDecoder

In the AnimatedImageDecoder, the logic that applies the loop count is separate from the logic that consumes the ImageSource. Because of this separation, the wrapDrawable function now includes an additional parameter, loopCount. This design choice was unavoidable, as adding a setter to Options.repeatCount would not have sufficed for properly applying the loop count without compromising the integrity of the original image data.

If you have a better opinion, please tell me, and I will reflect it.


GIF Format Overview

The Graphics Interchange Format (GIF) consists of a structured file format that is primarily used for graphics and animations. Here’s a breakdown of its structure and the significance of the Application Extension block, particularly regarding the parsing of the loop count.

GIF Structure in Bytes

  1. Header (6 bytes):

    • The GIF file begins with a header that indicates the format version:
      • GIF87a (6 bytes)
      • GIF89a (6 bytes) <--- Loop Count exists only in this format.
  2. Logical Screen Descriptor (7 bytes):

    • This section provides information about the logical screen, including dimensions and color table flags.
  3. Global Color Table (if present):

    • This table can contain up to 256 colors and is 3 bytes per color, resulting in a maximum size of 765 bytes (256 colors).
  4. Image Descriptor (10 bytes):

    • Each image within the GIF is described by an Image Descriptor that includes its position and size on the logical screen.
  5. Image Data:

    • This section contains the actual pixel data for the image, which may also include local color tables if specified.
  6. Extension Blocks:

    • GIF files can include several extension blocks, which provide additional functionality.

Application Extension Block

The Application Extension Block is crucial for animated GIFs as it defines the animation properties, including the loop count. The structure of the Application Extension Block is as follows:

In our parsing logic, we focus on the Application Extension to extract the loop count, which is critical for controlling the animation behavior of the GIF.

internal fun extractLoopCountFromGif(source: BufferedSource): Int? {
    return try {
        val byteArray = source.use { it.readByteArray() } // Copy data
        val bufferedSource = byteArray.inputStream().source().buffer() // Create BufferedSource from the copy
        val loopCount = parseLoopCount(bufferedSource)
        loopCount
    } catch (e: Exception) {
        null
    }
}

private fun parseLoopCount(source: BufferedSource): Int? {
    // Read GIF header
    val headerBytes = ByteArray(6)
    if (source.read(headerBytes) != 6) return null

    // Read Logical Screen Descriptor
    val screenDescriptorBytes = ByteArray(7)
    if (source.read(screenDescriptorBytes) != 7) return null

    // Skip Global Color Table
    if ((screenDescriptorBytes[4] and 0b10000000.toByte()) != 0.toByte()) {
        val colorTableSize = 3 * (1 shl ((screenDescriptorBytes[4].toInt() and 0b00000111) + 1)) // Calculate color table size
        source.skip(colorTableSize.toLong()) // Skip Global Color Table
    }

    // Handle Application Extension Block
    while (!source.exhausted()) {
        val blockType = source.readByte().toInt() and 0xFF
        if (blockType == 0x21) { // Extension Introducer
            val label = source.readByte().toInt() and 0xFF
            if (label == 0xFF) { // Application Extension
                val blockSize = source.readByte().toInt() and 0xFF
                val appIdentifier = source.readUtf8(8)
                val appAuthCode = source.readUtf8(3)

                // Check for NETSCAPE block and parse loop count
                if (appIdentifier == "NETSCAPE" && appAuthCode == "2.0") {

                    // Read data block size
                    val dataBlockSize = source.readByte().toInt() and 0xFF
                    // Read loop count indicator byte
                    val loopCountIndicator = source.readByte().toInt() and 0xFF
                    // Read low and high bytes for loop count
                    val loopCountLow = source.readByte().toInt() and 0xFF // Read low byte
                    val loopCountHigh = source.readByte().toInt() and 0xFF // Read high byte
                    val loopCount = (loopCountHigh shl 8) or loopCountLow // Combine high and low bytes

                    // Read block terminator byte
                    val blockTerminator = source.readByte().toInt() and 0xFF
                    return if (loopCount == 0) Int.MAX_VALUE else loopCount // 0 means infinite loop
                } else {
                    skipExtensionBlock(source) // Skip if not NETSCAPE
                }
            } else {
                skipExtensionBlock(source) // Skip other extension blocks
            }
        }
    }
    return null
}

// Function to skip Extension Blocks
// Extension Blocks always terminate with a zero
private fun skipExtensionBlock(source: BufferedSource) {
    while (true) {
        val size = source.readByte().toInt() and 0xFF
        if (size == 0) break
        source.skip(size.toLong())
    }
}

For further reading, check out:


If I need to write test code, please let me know where I can write it.

@colinrtwhite

colinrtwhite commented 2 weeks ago

@tgyuuAn Thank you for taking the time to write this! That said, I think this can be implemented without a custom GIF parser. The AnimatedImageDrawable docs mention that by default the repeat count in the encoded data is respected. As such I think all we need to do to support using the encoded repeat count is to not call animatedDrawable.repeatCount = if options.repeatCount == GIF_LOOP_COUNT. Also I would rename GIF_LOOP_COUNT to ENCODED_REPEAT_COUNT to indicate it's encoded in the data.

For GifDecoder I don't think we can support repeatCount without reading the data, but we can just mark ENCODED_REPEAT_COUNT as API >= 28. I'd prefer to avoid adding custom GIF parsing code since it would only be used on older devices and it could be tough to maintain long term.

tgyuuAn commented 2 weeks ago

@tgyuuAn Thank you for taking the time to write this! That said, I think this can be implemented without a custom GIF parser. The AnimatedImageDrawable docs mention that by default the repeat count in the encoded data is respected. As such I think all we need to do to support using the encoded repeat count is to not call animatedDrawable.repeatCount = if options.repeatCount == GIF_LOOP_COUNT. Also I would rename GIF_LOOP_COUNT to ENCODED_REPEAT_COUNT to indicate it's encoded in the data.

For GifDecoder I don't think we can support repeatCount without reading the data, but we can just mark ENCODED_REPEAT_COUNT as API >= 28. I'd prefer to avoid adding custom GIF parsing code since it would only be used on older devices and it could be tough to maintain long term.

I feel a weight lifted off my shoulders !!!!!!!

tgyuuAn commented 2 weeks ago

@colinrtwhite

If it is over 28, there is an exception that causes infinite repetition if you use GifDecoder.

colinrtwhite commented 2 weeks ago

@tgyuuAn You need to run ./gradlew spotlessApply to fix the style check.

colinrtwhite commented 2 weeks ago

@tgyuuAn Thanks for implementing this - no more changes needed! I'll merge this once we're done with 3.0.x bug fix releases and include it in the 3.1.0 release.