Add new APNG renderer, just for internal users for now.

This commit is contained in:
Greyson Parrelli
2024-02-02 10:08:08 -05:00
committed by Cody Henthorne
parent 34d87cf6e1
commit c3f9e5d972
151 changed files with 2425 additions and 13 deletions

1
lib/apng/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/build

13
lib/apng/build.gradle.kts Normal file
View File

@@ -0,0 +1,13 @@
plugins {
id("signal-library")
}
android {
namespace = "org.signal.apng"
}
dependencies {
implementation(project(":core:util"))
testImplementation(testLibs.junit.junit)
testImplementation(testLibs.robolectric.robolectric)
}

View File

21
lib/apng/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright 2024 Signal Messenger, LLC
~ SPDX-License-Identifier: AGPL-3.0-only
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>

View File

@@ -0,0 +1,459 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.apng
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import androidx.annotation.WorkerThread
import org.signal.core.util.readNBytesOrThrow
import org.signal.core.util.readUInt
import org.signal.core.util.stream.Crc32OutputStream
import org.signal.core.util.toUInt
import org.signal.core.util.toUShort
import org.signal.core.util.writeUInt
import java.io.ByteArrayOutputStream
import java.io.EOFException
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.util.zip.CRC32
/**
* Full spec:
* http://www.w3.org/TR/PNG/
*/
class ApngDecoder(val inputStream: InputStream) {
companion object {
private val PNG_MAGIC = byteArrayOf(0x89.toByte(), 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A)
@Throws(IOException::class)
fun isApng(inputStream: InputStream): Boolean {
val magic = inputStream.readNBytesOrThrow(8)
if (!magic.contentEquals(PNG_MAGIC)) {
return false
}
try {
while (true) {
val length: UInt = inputStream.readUInt()
val type: String = inputStream.readNBytesOrThrow(4).toString(Charsets.US_ASCII)
if (type == "acTL") {
return true
}
if (type == "IDAT") {
return false
}
// Skip over data + CRC for chunks we don't care about
inputStream.skip(length.toLong() + 4)
}
} catch (e: EOFException) {
return false
}
}
private fun OutputStream.withCrc32(block: OutputStream.() -> Unit): UInt {
return Crc32OutputStream(this)
.apply(block)
.currentCrc32
.toUInt()
}
}
var metadata: Metadata? = null
/**
* So, PNG's are composed of chunks of various types. An APNG is a valid PNG on it's own, but has some extra
* chunks that can be read to play the animation. The chunk structure of an APNG looks something like this:
*
* ---------------------
* IHDR - Mandatory first chunk, contains metadata about the image (width, height, etc).
*
* [ in any order...
* acTL - Contains metadata about the animation. The presence of this chunk is what tells us that we have an APNG.
* fcTL - (Optional) If present, it tells us that the first IDAT chunk is part of the animation itself. Contains information about how the frame is
* rendered (see below).
* xxx - There are plenty of other possible chunks that can go here. We don't care about them, but we need to remember them and give them to the
* PNG encoder as we create each frame. Could be critical data like what palette is used for rendering.
* ]
*
* IDAT - Contains the compressed image data. For an APNG, the first IDAT represents the "default" state of the image that will be shown even if the
* renderer doesn't support APNGs. If an fcTL is present before this, the IDAT also represents the first frame of the animation.
*
* ( in pairs, repeated for each frame...
* fcTL - This contains metadata about the frame, such as dimensions, delay, and positioning.
* fdAT - This contains the actual frame data that we want to render.
* )
*
* xxx - There are other possible chunks that could be placed after the animation sequence but before the end of the file. Usually things like tEXt chunks
* that contain metadata and whatnot. They're not important.
* IEND - Mandatory last chunk. Marks the end of the file.
* ---------------------
*
* We need to read and recognize a subset of these chunks that tell us how the APNG is structured. However, the actual encoding/decoding of the PNG data
* can be done by the system. We just need to parse out all of the frames and other metadata in order to render the animation.
*/
fun debugGetAllFrames(): List<Frame> {
// Read the magic bytes to verify that this is a PNG
val magic = inputStream.readNBytesOrThrow(8)
if (!magic.contentEquals(PNG_MAGIC)) {
throw IllegalArgumentException("Not a PNG!")
}
// The IHDR chunk is the first chunk in a PNG file and contains metadata about the image.
// Per spec it must appear first, so if it's missing the file is invalid.
val ihdr = inputStream.readChunk() ?: throw IOException("Missing IHDR chunk!")
if (ihdr !is Chunk.IHDR) {
throw IOException("First chunk is not IHDR!")
}
// Next, we want to read all of the chunks up to the first IDAT chunk.
// The first IDAT chunk represents the default image, and possibly the first frame the animation (depending on the presence of an fcTL chunk).
// In order for this to be a valid APNG, there _must_ be an acTL chunk before the first IDAT chunk.
val framePrefixChunks: MutableList<Chunk.ArbitraryChunk> = mutableListOf()
var earlyActl: Chunk.acTL? = null
var earlyFctl: Chunk.fcTL? = null
var chunk = inputStream.readChunk()
while (chunk != null && chunk !is Chunk.IDAT) {
when (chunk) {
is Chunk.acTL -> earlyActl = chunk
is Chunk.fcTL -> earlyFctl = chunk
is Chunk.ArbitraryChunk -> framePrefixChunks += chunk
else -> throw IOException("Unexpected chunk type before IDAT: $chunk")
}
chunk = inputStream.readChunk()
}
if (chunk == null) {
throw EOFException("Hit the end of the file before we hit an IDAT!")
}
if (earlyActl == null) {
throw IOException("Missing acTL chunk! Not an APNG!")
}
metadata = Metadata(
width = ihdr.width.toInt(),
height = ihdr.height.toInt(),
numPlays = earlyActl.numPlays.toInt().takeIf { it > 0 } ?: Int.MAX_VALUE
)
// Collect all consecutive IDAT chunks -- PNG allows splitting image data across multiple IDATs
val idatData = ByteArrayOutputStream()
idatData.write((chunk as Chunk.IDAT).data)
chunk = inputStream.readChunk()
while (chunk is Chunk.IDAT) {
idatData.write(chunk.data)
chunk = inputStream.readChunk()
}
val frames: MutableList<Frame> = mutableListOf()
if (earlyFctl != null) {
val allIdatData = idatData.toByteArray()
val pngData = encodePng(ihdr, framePrefixChunks, allIdatData.size.toUInt(), allIdatData)
frames += Frame(pngData, earlyFctl)
}
// chunk already points to the first non-IDAT chunk from the collection loop above
while (chunk != null && chunk !is Chunk.IEND) {
while (chunk != null && chunk !is Chunk.fcTL) {
chunk = inputStream.readChunk()
}
if (chunk == null) {
break
}
if (chunk !is Chunk.fcTL) {
throw IOException("Expected an fcTL chunk, got $chunk instead!")
}
val fctl: Chunk.fcTL = chunk
chunk = inputStream.readChunk()
if (chunk !is Chunk.fdAT) {
throw IOException("Expected an fdAT chunk, got $chunk instead!")
}
// Collect all consecutive fdAT chunks -- frames can span multiple fdATs per the spec
val fdatData = ByteArrayOutputStream()
while (chunk is Chunk.fdAT) {
fdatData.write(chunk.data)
chunk = inputStream.readChunk()
}
val allFdatData = fdatData.toByteArray()
val pngData = encodePng(ihdr.copy(width = fctl.width, height = fctl.height), framePrefixChunks, allFdatData.size.toUInt(), allFdatData)
frames += Frame(pngData, fctl)
}
return frames
}
private fun encodePng(ihdr: Chunk.IHDR, prefixChunks: List<Chunk.ArbitraryChunk>, dataLength: UInt, data: ByteArray): ByteArray {
val totalPrefixSize = PNG_MAGIC.size + Chunk.IHDR.LENGTH.toInt() + prefixChunks.sumOf { it.data.size }
val outputStream = ByteArrayOutputStream(totalPrefixSize)
outputStream.write(PNG_MAGIC)
outputStream.writeIHDRChunk(ihdr)
prefixChunks.forEach { chunk ->
outputStream.writeArbitraryChunk(chunk)
}
outputStream.writeIDATChunk(dataLength, data)
outputStream.write(Chunk.IEND.data)
return outputStream.toByteArray()
}
private fun OutputStream.writeIHDRChunk(ihdr: Chunk.IHDR) {
this.writeUInt(Chunk.IHDR.LENGTH)
val crc32: UInt = this.withCrc32 {
write("IHDR".toByteArray(Charsets.US_ASCII))
writeUInt(ihdr.width)
writeUInt(ihdr.height)
write(ihdr.bitDepth.toInt())
write(ihdr.colorType.toInt())
write(ihdr.compressionMethod.toInt())
write(ihdr.filterMethod.toInt())
write(ihdr.interlaceMethod.toInt())
}
this.writeUInt(crc32)
}
private fun OutputStream.writeIDATChunk(length: UInt, data: ByteArray) {
this.writeUInt(length)
val crc = this.withCrc32 {
write("IDAT".toByteArray(Charsets.US_ASCII))
write(data)
}
this.writeUInt(crc)
}
private fun OutputStream.writeArbitraryChunk(chunk: Chunk.ArbitraryChunk) {
this.writeUInt(chunk.length)
this.write(chunk.type.toByteArray(Charsets.US_ASCII))
this.write(chunk.data)
this.writeUInt(chunk.crc)
}
// TODO private
sealed class Chunk {
/**
* Contains metadata about the overall image. Must appear first.
*/
data class IHDR(
val width: UInt,
val height: UInt,
val bitDepth: Byte,
val colorType: Byte,
val compressionMethod: Byte,
val filterMethod: Byte,
val interlaceMethod: Byte
) : Chunk() {
companion object {
val LENGTH: UInt = 13.toUInt()
}
}
/**
* Contains the actual compressed PNG image data. For an APNG, the IDAT chunk represents the default image and possibly the first frame of the animation.
*/
class IDAT(val length: UInt, val data: ByteArray) : Chunk()
/**
* Marks the end of the file.
*/
object IEND : Chunk() {
/** Every IEND chunk has the same data. */
val data: ByteArray = ByteArrayOutputStream().apply {
writeUInt(0.toUInt())
val crc: UInt = this.withCrc32 {
write("IEND".toByteArray(Charsets.US_ASCII))
}
writeUInt(crc)
}.toByteArray()
}
/**
* Contains metadata about the animation. Appears before the first IDAT chunk.
*/
class acTL(
val numFrames: UInt,
val numPlays: UInt
) : Chunk()
/**
* Contains metadata about a single frame of the animation. Appears before each fdAT chunk.
*/
class fcTL(
val sequenceNumber: UInt,
val width: UInt,
val height: UInt,
val xOffset: UInt,
val yOffset: UInt,
val delayNum: UShort,
val delayDen: UShort,
val disposeOp: DisposeOp,
val blendOp: BlendOp
) : Chunk() {
/**
* Describes how you should dispose of this frame before rendering the next one. That means that in order to render the current frame, you need to know
* the [disposeOp] of the _previous_ frame.
*/
enum class DisposeOp(val value: Byte) {
/** Don't do anything. The content stays rendered. Often paired with [BlendOp.OVER] to draw "diffs" instead of whole new frames. */
NONE(0),
/** Replace the entire drawing surface with transparent black. */
BACKGROUND(1),
/** TODO still figuring this one out */
PREVIOUS(2)
}
enum class BlendOp(val value: Byte) {
/** Replace all pixels in the target region with the new pixels. */
SOURCE(0),
/**
* Composites the new pixels over top of existing pixels in the region. Analogous to layering two PNGs with transparency in photoshop.
* Often paired with [DisposeOp.NONE] to draw "diffs" instead of whole new frames.
*/
OVER(1)
}
}
/**
* Contains the actual compressed image data for a single frame of the animation. Appears after each fcTL chunk.
* The contents of [data] are actually an [IDAT] chunk, meaning that to decode the frame, we can just bolt metadata to the front of the file and hand
* it off to the system decoder.
*/
class fdAT(
val length: UInt,
val sequenceNumber: UInt,
val data: ByteArray
) : Chunk()
/**
* Represents a PNG chunk that we don't care about because it's not APNG-specific.
* We still have to remember it and give it the PNG encoder as we create each frame, but we don't need to understand it.
*/
class ArbitraryChunk(
val length: UInt,
val type: String,
val data: ByteArray,
val crc: UInt
) : Chunk() {
override fun toString(): String {
return "Type: $type, Length: $length, CRC: $crc"
}
}
}
class Frame(
val pngData: ByteArray,
val fcTL: Chunk.fcTL
) {
@WorkerThread
fun decodeBitmap(): Bitmap {
return BitmapFactory.decodeByteArray(pngData, 0, pngData.size)
?: throw IOException("Failed to decode frame bitmap")
}
}
class Metadata(
val width: Int,
val height: Int,
val numPlays: Int
)
}
private fun InputStream.readChunk(): ApngDecoder.Chunk? {
try {
val length: UInt = this.readUInt()
val type: String = this.readNBytesOrThrow(4).toString(Charsets.US_ASCII)
val data = this.readNBytesOrThrow(length.toInt())
val dataCrc = CRC32().also { it.update(type.toByteArray(Charsets.US_ASCII)) }.also { it.update(data) }.value
val targetCrc = this.readUInt().toLong()
if (dataCrc != targetCrc) {
return null
}
return when (type) {
"IHDR" -> {
ApngDecoder.Chunk.IHDR(
width = data.sliceArray(0 until 4).toUInt(),
height = data.sliceArray(4 until 8).toUInt(),
bitDepth = data[8],
colorType = data[9],
compressionMethod = data[10],
filterMethod = data[11],
interlaceMethod = data[12]
)
}
"IDAT" -> {
ApngDecoder.Chunk.IDAT(length, data)
}
"IEND" -> {
ApngDecoder.Chunk.IEND
}
"acTL" -> {
ApngDecoder.Chunk.acTL(
numFrames = data.sliceArray(0 until 4).toUInt(),
numPlays = data.sliceArray(4 until 8).toUInt()
)
}
"fcTL" -> {
ApngDecoder.Chunk.fcTL(
sequenceNumber = data.sliceArray(0 until 4).toUInt(),
width = data.sliceArray(4 until 8).toUInt(),
height = data.sliceArray(8 until 12).toUInt(),
xOffset = data.sliceArray(12 until 16).toUInt(),
yOffset = data.sliceArray(16 until 20).toUInt(),
delayNum = data.sliceArray(20 until 22).toUShort(),
delayDen = data.sliceArray(22 until 24).toUShort(),
disposeOp = when (data[24]) {
0.toByte() -> ApngDecoder.Chunk.fcTL.DisposeOp.NONE
1.toByte() -> ApngDecoder.Chunk.fcTL.DisposeOp.BACKGROUND
2.toByte() -> ApngDecoder.Chunk.fcTL.DisposeOp.PREVIOUS
else -> throw IOException("Invalid disposeOp: ${data[24]}")
},
blendOp = when (data[25]) {
0.toByte() -> ApngDecoder.Chunk.fcTL.BlendOp.SOURCE
1.toByte() -> ApngDecoder.Chunk.fcTL.BlendOp.OVER
else -> throw IOException("Invalid blendOp: ${data[25]}")
}
)
}
"fdAT" -> {
ApngDecoder.Chunk.fdAT(
length = length,
sequenceNumber = data.sliceArray(0 until 4).toUInt(),
data = data.sliceArray(4 until data.size)
)
}
else -> {
ApngDecoder.Chunk.ArbitraryChunk(length, type, data, targetCrc.toInt().toUInt())
}
}
} catch (e: EOFException) {
return null
}
}

View File

@@ -0,0 +1,225 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.apng
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.ColorFilter
import android.graphics.Paint
import android.graphics.PixelFormat
import android.graphics.PorterDuff
import android.graphics.Rect
import android.graphics.drawable.Animatable
import android.graphics.drawable.Drawable
import android.os.SystemClock
import androidx.core.graphics.BlendModeCompat
import androidx.core.graphics.setBlendMode
class ApngDrawable(val decoder: ApngDecoder) : Drawable(), Animatable {
companion object {
private val CLEAR_PAINT = Paint().apply {
color = Color.TRANSPARENT
setBlendMode(BlendModeCompat.CLEAR)
}
private val DEBUG_PAINT = Paint().apply {
color = Color.RED
style = Paint.Style.STROKE
strokeWidth = 1f
}
}
val currentFrame: ApngDecoder.Frame
get() = frames[position]
var position = 0
private set
val frameCount: Int
get() = frames.size
var debugDrawBounds = false
var loopForever = false
private val frames: List<ApngDecoder.Frame> = decoder.debugGetAllFrames()
private var playCount = 0
private val frameRect = Rect(0, 0, 0, 0)
private var timeForNextFrame = 0L
private val activeBitmap = Bitmap.createBitmap(decoder.metadata?.width ?: 0, decoder.metadata?.height ?: 0, Bitmap.Config.ARGB_8888)
private val pendingBitmap = Bitmap.createBitmap(decoder.metadata?.width ?: 0, decoder.metadata?.height ?: 0, Bitmap.Config.ARGB_8888)
private val disposeOpBitmap = Bitmap.createBitmap(decoder.metadata?.width ?: 0, decoder.metadata?.height ?: 0, Bitmap.Config.ARGB_8888)
private val pendingCanvas = Canvas(pendingBitmap)
private val activeCanvas = Canvas(activeBitmap)
private val disposeOpCanvas = Canvas(disposeOpBitmap)
private var playing = true
override fun draw(canvas: Canvas) {
if (!playing) {
canvas.drawBitmap(activeBitmap, 0f, 0f, null)
return
}
if (SystemClock.uptimeMillis() < timeForNextFrame) {
canvas.drawBitmap(activeBitmap, 0f, 0f, null)
scheduleSelf({ invalidateSelf() }, timeForNextFrame)
return
}
val totalPlays = decoder.metadata?.numPlays ?: Int.MAX_VALUE
if (playCount >= totalPlays && !loopForever) {
canvas.drawBitmap(activeBitmap, 0f, 0f, null)
return
}
val frame = frames[position]
drawFrame(frame)
canvas.drawBitmap(activeBitmap, 0f, 0f, null)
position = (position + 1) % frames.size
if (position == 0) {
playCount++
}
timeForNextFrame = SystemClock.uptimeMillis() + frame.delayMs
scheduleSelf({ invalidateSelf() }, timeForNextFrame)
}
override fun getIntrinsicWidth(): Int {
return decoder.metadata?.width ?: 0
}
override fun getIntrinsicHeight(): Int {
return decoder.metadata?.height ?: 0
}
override fun setAlpha(alpha: Int) {
// Not currently implemented
}
override fun setColorFilter(colorFilter: ColorFilter?) {
// Not currently implemented
}
override fun getOpacity(): Int {
return PixelFormat.OPAQUE
}
override fun start() {
playing = true
invalidateSelf()
}
override fun stop() {
playing = false
}
override fun isRunning(): Boolean {
return playing
}
fun nextFrame() {
position = (position + 1) % frames.size
if (position == 0) {
playCount++
}
drawFrame(frames[position])
}
fun prevFrame() {
if (position == 0) {
position = frames.size - 1
playCount--
} else {
position--
}
drawFrame(frames[position])
}
fun recycle() {
activeBitmap.recycle()
pendingBitmap.recycle()
disposeOpBitmap.recycle()
}
private fun drawFrame(frame: ApngDecoder.Frame) {
frameRect.updateBoundsFrom(frame)
// If the disposeOp is PREVIOUS, then we need to save the contents of the frame before we draw into it
if (frame.fcTL.disposeOp == ApngDecoder.Chunk.fcTL.DisposeOp.PREVIOUS) {
disposeOpCanvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR)
disposeOpCanvas.drawBitmap(pendingBitmap, 0f, 0f, null)
}
// Start with a clean slate if this is the first frame
if (position == 0) {
pendingCanvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR)
}
when (frame.fcTL.blendOp) {
ApngDecoder.Chunk.fcTL.BlendOp.SOURCE -> {
// This blendOp means that we want all of our new pixels to completely replace the old ones, including the transparent pixels.
// Normally drawing bitmaps will composite over the existing content, so to allow our new transparent pixels to overwrite old ones,
// we clear out the drawing region before drawing the new frame.
pendingCanvas.drawRect(frameRect, CLEAR_PAINT)
}
ApngDecoder.Chunk.fcTL.BlendOp.OVER -> {
// This blendOp means that we composite the new pixels over the old ones, as if layering two PNG's over top of each other in photoshop.
// We don't need to do anything special here -- the canvas naturally draws bitmaps like this by default.
}
}
val frameBitmap = frame.decodeBitmap()
pendingCanvas.drawBitmap(frameBitmap, frame.fcTL.xOffset.toFloat(), frame.fcTL.yOffset.toFloat(), null)
frameBitmap.recycle()
// Copy the contents of the pending bitmap into the active bitmap
activeCanvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR)
activeCanvas.drawBitmap(pendingBitmap, 0f, 0f, null)
if (debugDrawBounds) {
activeCanvas.drawRect(frameRect, DEBUG_PAINT)
}
// disposeOp's are how a frame is supposed to clean itself up after it's rendered.
when (frame.fcTL.disposeOp) {
ApngDecoder.Chunk.fcTL.DisposeOp.NONE -> {
// This disposeOp means we don't have to do anything
}
ApngDecoder.Chunk.fcTL.DisposeOp.BACKGROUND -> {
// This disposeOp means that we want to reset the drawing region of the frame to transparent
pendingCanvas.drawRect(frameRect, CLEAR_PAINT)
}
ApngDecoder.Chunk.fcTL.DisposeOp.PREVIOUS -> {
// This disposeOp means we want to reset the drawing region of the frame to the content that was there before it was drawn.
// Per spec, if the first frame has a disposeOp of DISPOSE_OP_PREVIOUS, we treat it as DISPOSE_OP_BACKGROUND
if (position == 0) {
pendingCanvas.drawRect(frameRect, CLEAR_PAINT)
} else {
pendingCanvas.drawRect(frameRect, CLEAR_PAINT)
pendingCanvas.drawBitmap(disposeOpBitmap, frameRect, frameRect, null)
}
}
}
}
private val ApngDecoder.Frame.delayMs: Long
get() {
val delayNumerator = fcTL.delayNum.toInt()
val delayDenominator = fcTL.delayDen.toInt().takeIf { it > 0 } ?: 100
return (delayNumerator * 1000 / delayDenominator).toLong()
}
private fun Rect.updateBoundsFrom(frame: ApngDecoder.Frame) {
left = frame.fcTL.xOffset.toInt()
right = frame.fcTL.xOffset.toInt() + frame.fcTL.width.toInt()
top = frame.fcTL.yOffset.toInt()
bottom = frame.fcTL.yOffset.toInt() + frame.fcTL.height.toInt()
}
}

View File

@@ -0,0 +1,409 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.apng
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import java.io.InputStream
@RunWith(RobolectricTestRunner::class)
class ApngDecoderTest {
// -- isApng --
@Test
fun `isApng returns false for static PNG`() {
assertFalse(ApngDecoder.isApng(open("test00.png")))
}
@Test
fun `isApng returns true for single-frame APNG using default image`() {
assertTrue(ApngDecoder.isApng(open("test01.png")))
}
@Test
fun `isApng returns true for single-frame APNG ignoring default image`() {
assertTrue(ApngDecoder.isApng(open("test02.png")))
}
@Test
fun `isApng returns true for multi-frame APNG`() {
assertTrue(ApngDecoder.isApng(open("test07.png")))
}
@Test
fun `isApng returns true for real-world image`() {
assertTrue(ApngDecoder.isApng(open("ball.png")))
}
// -- Basic parsing --
@Test
fun `test01 - single frame using default image`() {
val result = decode("test01.png")
assertNotNull(result.metadata)
assertEquals(1, result.frames.size)
result.frames.forEach { assertNotNull(it.decodeBitmap()) }
}
@Test
fun `test02 - single frame ignoring default image`() {
val result = decode("test02.png")
assertNotNull(result.metadata)
assertEquals(1, result.frames.size)
result.frames.forEach { assertNotNull(it.decodeBitmap()) }
}
// -- Split IDAT and fdAT --
@Test
fun `test03 - split IDAT is not APNG`() {
assertFalse(ApngDecoder.isApng(open("test03.png")))
}
@Test
fun `test04 - split IDAT with zero-length chunk is not APNG`() {
assertFalse(ApngDecoder.isApng(open("test04.png")))
}
@Test
fun `test05 - split fdAT parses successfully`() {
val result = decode("test05.png")
assertNotNull(result.metadata)
assertEquals(1, result.frames.size)
result.frames.forEach { assertNotNull(it.decodeBitmap()) }
}
@Test
fun `test06 - split fdAT with zero-length chunk parses successfully`() {
val result = decode("test06.png")
assertNotNull(result.metadata)
assertEquals(1, result.frames.size)
result.frames.forEach { assertNotNull(it.decodeBitmap()) }
}
// -- Dispose ops --
@Test
fun `test07 - DISPOSE_OP_NONE basic`() {
val result = decode("test07.png")
assertTrue(result.frames.size >= 2)
assertTrue(result.frames.any { it.fcTL.disposeOp == ApngDecoder.Chunk.fcTL.DisposeOp.NONE })
result.frames.forEach { assertNotNull(it.decodeBitmap()) }
}
@Test
fun `test08 - DISPOSE_OP_BACKGROUND basic`() {
val result = decode("test08.png")
assertTrue(result.frames.size >= 2)
assertTrue(result.frames.any { it.fcTL.disposeOp == ApngDecoder.Chunk.fcTL.DisposeOp.BACKGROUND })
result.frames.forEach { assertNotNull(it.decodeBitmap()) }
}
@Test
fun `test09 - DISPOSE_OP_BACKGROUND final frame`() {
val result = decode("test09.png")
assertTrue(result.frames.size >= 2)
result.frames.forEach { assertNotNull(it.decodeBitmap()) }
}
@Test
fun `test10 - DISPOSE_OP_PREVIOUS basic`() {
val result = decode("test10.png")
assertTrue(result.frames.size >= 2)
assertTrue(result.frames.any { it.fcTL.disposeOp == ApngDecoder.Chunk.fcTL.DisposeOp.PREVIOUS })
result.frames.forEach { assertNotNull(it.decodeBitmap()) }
}
@Test
fun `test11 - DISPOSE_OP_PREVIOUS final frame`() {
val result = decode("test11.png")
assertTrue(result.frames.size >= 2)
result.frames.forEach { assertNotNull(it.decodeBitmap()) }
}
@Test
fun `test12 - DISPOSE_OP_PREVIOUS first frame`() {
val result = decode("test12.png")
assertTrue(result.frames.size >= 2)
assertEquals(ApngDecoder.Chunk.fcTL.DisposeOp.PREVIOUS, result.frames[0].fcTL.disposeOp)
result.frames.forEach { assertNotNull(it.decodeBitmap()) }
}
// -- Dispose ops with regions --
@Test
fun `test13 - DISPOSE_OP_NONE in region`() {
val result = decode("test13.png")
assertTrue(result.frames.size >= 2)
val subFrame = result.frames.find { it.fcTL.width != result.metadata!!.width.toUInt() || it.fcTL.height != result.metadata!!.height.toUInt() }
assertNotNull("Expected at least one sub-region frame", subFrame)
result.frames.forEach { assertNotNull(it.decodeBitmap()) }
}
@Test
fun `test14 - DISPOSE_OP_BACKGROUND before region`() {
val result = decode("test14.png")
assertTrue(result.frames.size >= 2)
result.frames.forEach { assertNotNull(it.decodeBitmap()) }
}
@Test
fun `test15 - DISPOSE_OP_BACKGROUND in region`() {
val result = decode("test15.png")
assertTrue(result.frames.size >= 2)
result.frames.forEach { assertNotNull(it.decodeBitmap()) }
}
@Test
fun `test16 - DISPOSE_OP_PREVIOUS in region`() {
val result = decode("test16.png")
assertTrue(result.frames.size >= 2)
result.frames.forEach { assertNotNull(it.decodeBitmap()) }
}
// -- Blend ops --
@Test
fun `test17 - BLEND_OP_SOURCE on solid colour`() {
val result = decode("test17.png")
assertTrue(result.frames.size >= 2)
assertTrue(result.frames.any { it.fcTL.blendOp == ApngDecoder.Chunk.fcTL.BlendOp.SOURCE })
result.frames.forEach { assertNotNull(it.decodeBitmap()) }
}
@Test
fun `test18 - BLEND_OP_SOURCE on transparent colour`() {
val result = decode("test18.png")
assertTrue(result.frames.size >= 2)
result.frames.forEach { assertNotNull(it.decodeBitmap()) }
}
@Test
fun `test19 - BLEND_OP_SOURCE on nearly-transparent colour`() {
val result = decode("test19.png")
assertTrue(result.frames.size >= 2)
result.frames.forEach { assertNotNull(it.decodeBitmap()) }
}
@Test
fun `test20 - BLEND_OP_OVER on solid and transparent colours`() {
val result = decode("test20.png")
assertTrue(result.frames.size >= 2)
assertTrue(result.frames.any { it.fcTL.blendOp == ApngDecoder.Chunk.fcTL.BlendOp.OVER })
result.frames.forEach { assertNotNull(it.decodeBitmap()) }
}
@Test
fun `test21 - BLEND_OP_OVER repeatedly with nearly-transparent colours`() {
val result = decode("test21.png")
assertTrue(result.frames.size >= 2)
result.frames.forEach { assertNotNull(it.decodeBitmap()) }
}
// -- Blending and gamma --
@Test
fun `test22 - BLEND_OP_OVER with gamma`() {
val result = decode("test22.png")
assertTrue(result.frames.isNotEmpty())
result.frames.forEach { assertNotNull(it.decodeBitmap()) }
}
@Test
fun `test23 - BLEND_OP_OVER with gamma nearly black`() {
val result = decode("test23.png")
assertTrue(result.frames.isNotEmpty())
result.frames.forEach { assertNotNull(it.decodeBitmap()) }
}
// -- Chunk ordering --
@Test
fun `test24 - fcTL before acTL parses successfully`() {
val result = decode("test24.png")
assertNotNull(result.metadata)
assertTrue(result.frames.isNotEmpty())
result.frames.forEach { assertNotNull(it.decodeBitmap()) }
}
// -- Delays --
@Test
fun `test25 - basic delays`() {
val result = decode("test25.png")
assertTrue(result.frames.size >= 2)
val delaysMs = result.frames.map { it.delayMs }
assertTrue("Expected at least one 500ms delay", delaysMs.any { it == 500L })
assertTrue("Expected at least one 1000ms delay", delaysMs.any { it == 1000L })
}
@Test
fun `test26 - rounding of division`() {
val result = decode("test26.png")
assertTrue(result.frames.size >= 2)
val delaysMs = result.frames.map { it.delayMs }
assertTrue("Expected at least one 500ms delay", delaysMs.any { it == 500L })
assertTrue("Expected at least one 1000ms delay", delaysMs.any { it == 1000L })
}
@Test
fun `test27 - 16-bit numerator and denominator`() {
val result = decode("test27.png")
assertTrue(result.frames.size >= 2)
val delaysMs = result.frames.map { it.delayMs }
assertTrue("Expected at least one 500ms delay", delaysMs.any { it == 500L })
assertTrue("Expected at least one 1000ms delay", delaysMs.any { it == 1000L })
}
@Test
fun `test28 - zero denominator treated as 100`() {
val result = decode("test28.png")
assertTrue(result.frames.size >= 2)
val delaysMs = result.frames.map { it.delayMs }
assertTrue("Expected at least one 500ms delay", delaysMs.any { it == 500L })
assertTrue("Expected at least one 1000ms delay", delaysMs.any { it == 1000L })
result.frames.forEach {
val den = it.fcTL.delayDen.toInt()
if (den == 0) {
val expectedMs = it.fcTL.delayNum.toInt() * 1000 / 100
assertEquals(expectedMs.toLong(), it.delayMs)
}
}
}
@Test
fun `test29 - zero numerator`() {
val result = decode("test29.png")
assertTrue(result.frames.size >= 2)
assertTrue("Expected at least one zero-delay frame", result.frames.any { it.fcTL.delayNum.toInt() == 0 })
}
// -- num_plays --
@Test
fun `test30 - num_plays 0 means infinite`() {
val result = decode("test30.png")
assertEquals(Int.MAX_VALUE, result.metadata!!.numPlays)
}
@Test
fun `test31 - num_plays 1`() {
val result = decode("test31.png")
assertEquals(1, result.metadata!!.numPlays)
}
@Test
fun `test32 - num_plays 2`() {
val result = decode("test32.png")
assertEquals(2, result.metadata!!.numPlays)
}
// -- Other color depths and types --
@Test
fun `test33 - 16-bit colour`() {
val result = decode("test33.png")
assertTrue(result.frames.isNotEmpty())
result.frames.forEach { assertNotNull(it.decodeBitmap()) }
}
@Test
fun `test34 - 8-bit greyscale`() {
val result = decode("test34.png")
assertTrue(result.frames.isNotEmpty())
result.frames.forEach { assertNotNull(it.decodeBitmap()) }
}
@Test
fun `test35 - 8-bit greyscale and alpha with blending`() {
val result = decode("test35.png")
assertTrue(result.frames.isNotEmpty())
result.frames.forEach { assertNotNull(it.decodeBitmap()) }
}
@Test
fun `test36 - 2-color palette`() {
val result = decode("test36.png")
assertTrue(result.frames.isNotEmpty())
result.frames.forEach { assertNotNull(it.decodeBitmap()) }
}
@Test
fun `test37 - 2-bit palette and alpha`() {
val result = decode("test37.png")
assertTrue(result.frames.isNotEmpty())
result.frames.forEach { assertNotNull(it.decodeBitmap()) }
}
@Test
fun `test38 - 1-bit palette and alpha with blending`() {
val result = decode("test38.png")
assertTrue(result.frames.isNotEmpty())
result.frames.forEach { assertNotNull(it.decodeBitmap()) }
}
// -- Real-world samples --
@Test
fun `ball - real world bouncing ball`() {
val result = decode("ball.png")
assertNotNull(result.metadata)
assertTrue(result.frames.size > 1)
result.frames.forEach { assertNotNull(it.decodeBitmap()) }
}
@Test
fun `clock - real world clock with BLEND_OP_OVER`() {
val result = decode("clock.png")
assertNotNull(result.metadata)
assertTrue(result.frames.size > 1)
result.frames.forEach { assertNotNull(it.decodeBitmap()) }
}
@Test
fun `elephant - real world elephant`() {
val result = decode("elephant.png")
assertNotNull(result.metadata)
assertTrue(result.frames.size > 1)
result.frames.forEach { assertNotNull(it.decodeBitmap()) }
}
// -- Helpers --
private fun open(filename: String): InputStream {
return javaClass.classLoader!!.getResourceAsStream("apng/$filename")
?: throw IllegalStateException("Test resource not found: apng/$filename")
}
private fun decode(filename: String): DecodeResult {
val decoder = ApngDecoder(open(filename))
val frames = decoder.debugGetAllFrames()
return DecodeResult(decoder.metadata, frames)
}
private val ApngDecoder.Frame.delayMs: Long
get() {
val delayNumerator = fcTL.delayNum.toInt()
val delayDenominator = fcTL.delayDen.toInt().takeIf { it > 0 } ?: 100
return (delayNumerator * 1000L / delayDenominator)
}
private data class DecodeResult(
val metadata: ApngDecoder.Metadata?,
val frames: List<ApngDecoder.Frame>
)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 296 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 370 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 249 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 307 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 470 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 273 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 486 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 502 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 617 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 572 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 508 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 780 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 508 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 371 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 613 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 327 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 492 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 677 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 671 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 534 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 614 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 579 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 508 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 646 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 646 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 646 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 646 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 646 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 646 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 915 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 331 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 668 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 308 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 276 B