Add new APNG renderer, just for internal users for now.
1
lib/apng/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/build
|
||||
13
lib/apng/build.gradle.kts
Normal 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)
|
||||
}
|
||||
0
lib/apng/consumer-rules.pro
Normal file
21
lib/apng/proguard-rules.pro
vendored
Normal 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
|
||||
9
lib/apng/src/main/AndroidManifest.xml
Normal 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>
|
||||
459
lib/apng/src/main/java/org/signal/apng/ApngDecoder.kt
Normal 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
|
||||
}
|
||||
}
|
||||
225
lib/apng/src/main/java/org/signal/apng/ApngDrawable.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
409
lib/apng/src/test/java/org/signal/apng/ApngDecoderTest.kt
Normal 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>
|
||||
)
|
||||
}
|
||||
BIN
lib/apng/src/test/resources/apng/ball.png
Normal file
|
After Width: | Height: | Size: 60 KiB |
BIN
lib/apng/src/test/resources/apng/broken01.png
Normal file
|
After Width: | Height: | Size: 91 KiB |
BIN
lib/apng/src/test/resources/apng/broken02.png
Normal file
|
After Width: | Height: | Size: 126 KiB |
BIN
lib/apng/src/test/resources/apng/broken03.png
Normal file
|
After Width: | Height: | Size: 221 KiB |
BIN
lib/apng/src/test/resources/apng/broken05.png
Normal file
|
After Width: | Height: | Size: 296 KiB |
BIN
lib/apng/src/test/resources/apng/clock.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
lib/apng/src/test/resources/apng/elephant.png
Normal file
|
After Width: | Height: | Size: 370 KiB |
BIN
lib/apng/src/test/resources/apng/test00.png
Normal file
|
After Width: | Height: | Size: 249 B |
BIN
lib/apng/src/test/resources/apng/test01.png
Normal file
|
After Width: | Height: | Size: 307 B |
BIN
lib/apng/src/test/resources/apng/test02.png
Normal file
|
After Width: | Height: | Size: 470 B |
BIN
lib/apng/src/test/resources/apng/test03.png
Normal file
|
After Width: | Height: | Size: 261 B |
BIN
lib/apng/src/test/resources/apng/test04.png
Normal file
|
After Width: | Height: | Size: 273 B |
BIN
lib/apng/src/test/resources/apng/test05.png
Normal file
|
After Width: | Height: | Size: 486 B |
BIN
lib/apng/src/test/resources/apng/test06.png
Normal file
|
After Width: | Height: | Size: 502 B |
BIN
lib/apng/src/test/resources/apng/test07.png
Normal file
|
After Width: | Height: | Size: 617 B |
BIN
lib/apng/src/test/resources/apng/test08.png
Normal file
|
After Width: | Height: | Size: 572 B |
BIN
lib/apng/src/test/resources/apng/test09.png
Normal file
|
After Width: | Height: | Size: 508 B |
BIN
lib/apng/src/test/resources/apng/test10.png
Normal file
|
After Width: | Height: | Size: 780 B |
BIN
lib/apng/src/test/resources/apng/test11.png
Normal file
|
After Width: | Height: | Size: 508 B |
BIN
lib/apng/src/test/resources/apng/test12.png
Normal file
|
After Width: | Height: | Size: 371 B |
BIN
lib/apng/src/test/resources/apng/test13.png
Normal file
|
After Width: | Height: | Size: 613 B |
BIN
lib/apng/src/test/resources/apng/test14.png
Normal file
|
After Width: | Height: | Size: 327 B |
BIN
lib/apng/src/test/resources/apng/test15.png
Normal file
|
After Width: | Height: | Size: 492 B |
BIN
lib/apng/src/test/resources/apng/test16.png
Normal file
|
After Width: | Height: | Size: 677 B |
BIN
lib/apng/src/test/resources/apng/test17.png
Normal file
|
After Width: | Height: | Size: 671 B |
BIN
lib/apng/src/test/resources/apng/test18.png
Normal file
|
After Width: | Height: | Size: 534 B |
BIN
lib/apng/src/test/resources/apng/test19.png
Normal file
|
After Width: | Height: | Size: 614 B |
BIN
lib/apng/src/test/resources/apng/test20.png
Normal file
|
After Width: | Height: | Size: 579 B |
BIN
lib/apng/src/test/resources/apng/test21.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
lib/apng/src/test/resources/apng/test22.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
lib/apng/src/test/resources/apng/test23.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
lib/apng/src/test/resources/apng/test24.png
Normal file
|
After Width: | Height: | Size: 508 B |
BIN
lib/apng/src/test/resources/apng/test25.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
lib/apng/src/test/resources/apng/test26.png
Normal file
|
After Width: | Height: | Size: 646 B |
BIN
lib/apng/src/test/resources/apng/test27.png
Normal file
|
After Width: | Height: | Size: 646 B |
BIN
lib/apng/src/test/resources/apng/test28.png
Normal file
|
After Width: | Height: | Size: 646 B |
BIN
lib/apng/src/test/resources/apng/test29.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
lib/apng/src/test/resources/apng/test30.png
Normal file
|
After Width: | Height: | Size: 646 B |
BIN
lib/apng/src/test/resources/apng/test31.png
Normal file
|
After Width: | Height: | Size: 646 B |
BIN
lib/apng/src/test/resources/apng/test32.png
Normal file
|
After Width: | Height: | Size: 646 B |
BIN
lib/apng/src/test/resources/apng/test33.png
Normal file
|
After Width: | Height: | Size: 915 B |
BIN
lib/apng/src/test/resources/apng/test34.png
Normal file
|
After Width: | Height: | Size: 331 B |
BIN
lib/apng/src/test/resources/apng/test35.png
Normal file
|
After Width: | Height: | Size: 668 B |
BIN
lib/apng/src/test/resources/apng/test36.png
Normal file
|
After Width: | Height: | Size: 262 B |
BIN
lib/apng/src/test/resources/apng/test37.png
Normal file
|
After Width: | Height: | Size: 308 B |
BIN
lib/apng/src/test/resources/apng/test38.png
Normal file
|
After Width: | Height: | Size: 276 B |