Skip to content

Commit

Permalink
Add constant to parse Gif Loop Count and apply to repeatCount
Browse files Browse the repository at this point in the history
  • Loading branch information
tgyuuAn committed Nov 8, 2024
1 parent 39ed658 commit 9b8b120
Show file tree
Hide file tree
Showing 5 changed files with 99 additions and 11 deletions.
25 changes: 19 additions & 6 deletions coil-gif/src/main/java/coil3/gif/AnimatedImageDecoder.kt
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import coil3.decode.Decoder
import coil3.decode.ImageSource
import coil3.decode.toImageDecoderSourceOrNull
import coil3.fetch.SourceFetchResult
import coil3.gif.MovieDrawable.Companion.GIF_LOOP_COUNT
import coil3.gif.MovieDrawable.Companion.REPEAT_INFINITE
import coil3.gif.internal.animatable2CallbackOf
import coil3.gif.internal.asPostProcessor
import coil3.gif.internal.maybeWrapImageSourceToRewriteFrameDelay
Expand Down Expand Up @@ -54,10 +56,22 @@ class AnimatedImageDecoder(

override suspend fun decode(): DecodeResult {
var isSampled = false

val loopCount = if (options.repeatCount == GIF_LOOP_COUNT) {
extractLoopCountFromGif(source.source().peek()) ?: REPEAT_INFINITE
} else {
options.repeatCount
}

val drawable = runInterruptible {
maybeWrapImageSourceToRewriteFrameDelay(source, enforceMinimumFrameDelay).use { source ->
maybeWrapImageSourceToRewriteFrameDelay(
source,
enforceMinimumFrameDelay,
).use { source ->
val imageSource = source.toImageDecoderSourceOrNull(options, animated = true)
?: ImageDecoder.createSource(source.source().use { it.squashToDirectByteBuffer() })
?: ImageDecoder.createSource(
source.source().use { it.squashToDirectByteBuffer() },
)
imageSource.decodeDrawable { info, _ ->
// Configure the output image's size.
val (srcWidth, srcHeight) = info.size
Expand Down Expand Up @@ -88,14 +102,13 @@ class AnimatedImageDecoder(
setTargetSize(targetWidth, targetHeight)
}
}

// Configure any other attributes.
configureImageDecoderProperties()
}
}
}
return DecodeResult(
image = wrapDrawable(drawable).asImage(),
image = wrapDrawable(drawable, loopCount).asImage(),
isSampled = isSampled,
)
}
Expand All @@ -117,12 +130,12 @@ class AnimatedImageDecoder(
postProcessor = options.animatedTransformation?.asPostProcessor()
}

private suspend fun wrapDrawable(baseDrawable: Drawable): Drawable {
private suspend fun wrapDrawable(baseDrawable: Drawable, loopCount: Int): Drawable {
if (baseDrawable !is AnimatedImageDrawable) {
return baseDrawable
}

baseDrawable.repeatCount = options.repeatCount
baseDrawable.repeatCount = loopCount

// Set the start and end animation callbacks if any one is supplied through the request.
val onStart = options.animationStartCallback
Expand Down
10 changes: 8 additions & 2 deletions coil-gif/src/main/java/coil3/gif/GifDecoder.kt
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,13 @@ class GifDecoder(
) : Decoder {

override suspend fun decode() = runInterruptible {
val loopCount = if (options.repeatCount == MovieDrawable.GIF_LOOP_COUNT) {
source.source().peek().let { extractLoopCountFromGif(it) }
?: MovieDrawable.REPEAT_INFINITE
} else {
options.repeatCount
}

val source = maybeWrapImageSourceToRewriteFrameDelay(source, enforceMinimumFrameDelay)
val movie: Movie? = source.use { Movie.decodeStream(it.source().inputStream()) }

Expand All @@ -49,9 +56,8 @@ class GifDecoder(
scale = options.scale,
)

drawable.setRepeatCount(options.repeatCount)
drawable.setRepeatCount(loopCount)

// Set the start and end animation callbacks if any one is supplied through the request.
val onStart = options.animationStartCallback
val onEnd = options.animationEndCallback
if (onStart != null || onEnd != null) {
Expand Down
12 changes: 10 additions & 2 deletions coil-gif/src/main/java/coil3/gif/MovieDrawable.kt
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ class MovieDrawable @JvmOverloads constructor(
* Default: [REPEAT_INFINITE]
*/
fun setRepeatCount(repeatCount: Int) {
require(repeatCount >= REPEAT_INFINITE) { "Invalid repeatCount: $repeatCount" }
require(repeatCount >= GIF_LOOP_COUNT) { "Invalid repeatCount: $repeatCount" }
this.repeatCount = repeatCount
}

Expand Down Expand Up @@ -193,7 +193,8 @@ class MovieDrawable @JvmOverloads constructor(
@Suppress("DEPRECATION")
override fun getOpacity(): Int {
return if (paint.alpha == 255 &&
(pixelOpacity == OPAQUE || (pixelOpacity == UNCHANGED && movie.isOpaque))) {
(pixelOpacity == OPAQUE || (pixelOpacity == UNCHANGED && movie.isOpaque))
) {
PixelFormat.OPAQUE
} else {
PixelFormat.TRANSLUCENT
Expand Down Expand Up @@ -284,5 +285,12 @@ class MovieDrawable @JvmOverloads constructor(
companion object {
/** Pass this to [setRepeatCount] to repeat infinitely. */
const val REPEAT_INFINITE = -1

/**
* Pass this to [setRepeatCount] to repeat according to GIF's LoopCount metadata.
*
* If the metadata is not available, it will default to infinite repetition.
*/
const val GIF_LOOP_COUNT = -2
}
}
60 changes: 60 additions & 0 deletions coil-gif/src/main/java/coil3/gif/decodeUtils.kt
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,63 @@ fun DecodeUtils.isAnimatedHeif(source: BufferedSource): Boolean {
source.rangeEquals(8, HEIF_HEADER_HEVC) ||
source.rangeEquals(8, HEIF_HEADER_HEVX))
}

internal fun extractLoopCountFromGif(source: BufferedSource): Int? {
return try {
parseLoopCount(source)
} catch (e: Exception) {
null
}
}

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

val screenDescriptorBytes = ByteArray(7)
if (source.read(screenDescriptorBytes) != 7) return null

if ((screenDescriptorBytes[4] and 0b10000000.toByte()) != 0.toByte()) {
val colorTableSize = 3 * (1 shl ((screenDescriptorBytes[4].toInt() and 0b00000111) + 1))
source.skip(colorTableSize.toLong())
}

// 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)

if (appIdentifier == "NETSCAPE" && appAuthCode == "2.0") {
val dataBlockSize = source.readByte().toInt() and 0xFF
val loopCountIndicator = source.readByte().toInt() and 0xFF

// Read low and high bytes for loop count
val loopCountLow = source.readByte().toInt() and 0xFF
val loopCountHigh = source.readByte().toInt() and 0xFF
val loopCount = (loopCountHigh shl 8) or loopCountLow
val blockTerminator = source.readByte().toInt() and 0xFF
return if (loopCount == 0) null else loopCount
} else {
skipExtensionBlock(source) // Skip if not NETSCAPE
}
} else {
skipExtensionBlock(source) // Skip other extension blocks
}
}
}
return null
}

// 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())
}
}
3 changes: 2 additions & 1 deletion coil-gif/src/main/java/coil3/gif/imageRequests.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import android.graphics.drawable.Drawable
import coil3.Extras
import coil3.annotation.ExperimentalCoilApi
import coil3.getExtra
import coil3.gif.MovieDrawable.Companion.GIF_LOOP_COUNT
import coil3.gif.MovieDrawable.Companion.REPEAT_INFINITE
import coil3.request.ImageRequest
import coil3.request.Options
Expand All @@ -17,7 +18,7 @@ import coil3.request.Options
* @see AnimatedImageDrawable.setRepeatCount
*/
fun ImageRequest.Builder.repeatCount(repeatCount: Int) = apply {
require(repeatCount >= REPEAT_INFINITE) { "Invalid repeatCount: $repeatCount" }
require(repeatCount >= GIF_LOOP_COUNT) { "Invalid repeatCount: $repeatCount" }
extras[repeatCountKey] = repeatCount
}

Expand Down

0 comments on commit 9b8b120

Please sign in to comment.