From d32f5f3ebf5b85c8b36fb50f3593b47f4f4e9839 Mon Sep 17 00:00:00 2001 From: oupson Date: Thu, 14 May 2020 08:45:34 +0200 Subject: [PATCH] Adding function release as requested in issue #7 Working on an update of ApngEncoder, for now it is called Experimental Apng Encoder but will be merged in ApngEncoder when it is stable --- .../java/oupson/apng/encoder/ApngEncoder.kt | 3 + .../apng/encoder/ExperimentalApngEncoder.kt | 268 ++++++++++++++++++ .../java/oupson/apng/imageUtils/PngEncoder.kt | 7 + .../apngcreator/activities/CreatorActivity.kt | 13 +- 4 files changed, 287 insertions(+), 4 deletions(-) create mode 100644 apng_library/src/main/java/oupson/apng/encoder/ExperimentalApngEncoder.kt diff --git a/apng_library/src/main/java/oupson/apng/encoder/ApngEncoder.kt b/apng_library/src/main/java/oupson/apng/encoder/ApngEncoder.kt index f478b62..43058e9 100644 --- a/apng_library/src/main/java/oupson/apng/encoder/ApngEncoder.kt +++ b/apng_library/src/main/java/oupson/apng/encoder/ApngEncoder.kt @@ -114,6 +114,9 @@ class ApngEncoder( } } frameIndex++ + if (usePngEncoder) { + PngEncoder.release() + } } fun writeEnd() { diff --git a/apng_library/src/main/java/oupson/apng/encoder/ExperimentalApngEncoder.kt b/apng_library/src/main/java/oupson/apng/encoder/ExperimentalApngEncoder.kt new file mode 100644 index 0000000..a28bf4e --- /dev/null +++ b/apng_library/src/main/java/oupson/apng/encoder/ExperimentalApngEncoder.kt @@ -0,0 +1,268 @@ +package oupson.apng.encoder + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.os.Build +import oupson.apng.chunks.IDAT +import oupson.apng.imageUtils.PngEncoder +import oupson.apng.utils.Utils +import java.io.ByteArrayOutputStream +import java.io.InputStream +import java.io.OutputStream +import java.util.zip.CRC32 + +// TODO DOCUMENTATION +// TODO BITMAP ENCODING +// TODO BUFFER AND BUFFER DEACTIVATION WHEN BITMAP CONFIG DOES NOT CONTAIN AN ALPHA CHANNEL +// TODO JAVA OVERLOADS +class ExperimentalApngEncoder( + private val outputStream: OutputStream, + private val width : Int, + private val height : Int, + numberOfFrames : Int, + private val config : Bitmap.Config = Bitmap.Config.ARGB_8888) { + private var frameIndex = 0 + private var seq = 0 + + private val idatName : List by lazy { + listOf(0x49.toByte(), 0x44.toByte(), 0x41.toByte(), 0x54.toByte()) + } + + init { + outputStream.write(Utils.pngSignature) + outputStream.write(generateIhdr()) + outputStream.write(generateACTL(numberOfFrames)) + } + + // TODO ADD SUPPORT FOR FIRST FRAME NOT IN ANIM + // TODO OPTIMISE APNG + @JvmOverloads + fun writeFrame( + inputStream: InputStream, + delay: Float = 1000f, + xOffsets: Int = 0, + yOffsets: Int = 0, + blendOp: Utils.Companion.BlendOp = Utils.Companion.BlendOp.APNG_BLEND_OP_SOURCE, + disposeOp: Utils.Companion.DisposeOp = Utils.Companion.DisposeOp.APNG_DISPOSE_OP_NONE, + usePngEncoder: Boolean = false + ) { + val btm = BitmapFactory.decodeStream(inputStream).let { + if (it.config != config) + it.copy(config, it.isMutable) + else + it + } + inputStream.close() + + if (frameIndex == 0) { + if (btm.width != width) + throw Exception("Width of first frame must be equal to width of APNG. (${btm.width} != $width)") + if (btm.height != height) + throw Exception("Height of first frame must be equal to height of APNG. (${btm.height} != $height)") + } + + generateFCTL(btm, delay, disposeOp, blendOp, xOffsets, yOffsets) + + val idat = IDAT().apply { + val byteArray = if (usePngEncoder) { + PngEncoder.encode(btm, true) + } else { + val outputStream = ByteArrayOutputStream() + btm.compress(Bitmap.CompressFormat.PNG, 100, outputStream) + outputStream.toByteArray() + } + var cursor = 8 + while (cursor < byteArray.size) { + val chunk = byteArray.copyOfRange(cursor, cursor + Utils.parseLength(byteArray.copyOfRange(cursor, cursor + 4)) + 12) + parse(chunk) + + cursor += Utils.parseLength(byteArray.copyOfRange(cursor, cursor + 4)) + 12 + } + } + + idat.IDATBody.forEach { idatBody -> + if (frameIndex == 0) { + val idatChunk = ArrayList().let { i -> + // Add IDAT + i.addAll(idatName) + // Add chunk body + i.addAll(idatBody.asList()) + i.toByteArray() + } + // Add the chunk body length + outputStream.write(Utils.to4Bytes(idatBody.size)) + + // Generate CRC + val crc1 = CRC32() + crc1.update(idatChunk, 0, idatChunk.size) + outputStream.write(idatChunk) + outputStream.write(Utils.to4Bytes(crc1.value.toInt())) + } else { + val fdat = ArrayList().let { fdat -> + fdat.addAll(byteArrayOf(0x66, 0x64, 0x41, 0x54).asList()) + // Add fdat + fdat.addAll(Utils.to4Bytes(seq++).asList()) + // Add chunk body + fdat.addAll(idatBody.asList()) + fdat.toByteArray() + } + // Add the chunk body length + outputStream.write(Utils.to4Bytes(idatBody.size + 4)) + + // Generate CRC + val crc1 = CRC32() + crc1.update(fdat, 0, fdat.size) + outputStream.write(fdat) + outputStream.write(Utils.to4Bytes(crc1.value.toInt())) + } + } + frameIndex++ + if (usePngEncoder) { + PngEncoder.release() + } + } + + fun writeEnd() { + // Add IEND body length : 0 + outputStream.write(Utils.to4Bytes(0)) + // Add IEND + val iend = byteArrayOf(0x49, 0x45, 0x4E, 0x44) + // Generate crc for IEND + val crC32 = CRC32() + crC32.update(iend, 0, iend.size) + outputStream.write(iend) + outputStream.write(Utils.to4Bytes(crC32.value.toInt())) + } + + /** + * Generate the IHDR chunks. + * @return [ByteArray] The byteArray generated + */ + private fun generateIhdr(): ByteArray { + val ihdr = ArrayList() + + // We need a body var to know body length and generate crc + val ihdrBody = ArrayList() + + /** + if (((maxWidth != frames[0].width) && (maxHeight != frames[0].height)) && cover == null) { + cover = generateCover(BitmapFactory.decodeByteArray(frames[0].byteArray, 0, frames[0].byteArray.size), maxWidth!!, maxHeight!!) + }*/ + + + // Add chunk body length + ihdr.addAll(arrayListOf(0x00, 0x00, 0x00, 0x0d)) + // ADD IHDR + ihdrBody.addAll(arrayListOf(0x49, 0x48, 0x44, 0x52)) + + // Add the max width and height + ihdrBody.addAll(Utils.to4Bytes(width).asList()) + ihdrBody.addAll(Utils.to4Bytes(height).asList()) + + // BIT DEPTH + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + ihdrBody.add(when (config) { + Bitmap.Config.ARGB_8888 -> 8.toByte() + Bitmap.Config.RGBA_F16 -> 8.toByte() + Bitmap.Config.RGB_565 -> 8.toByte() + else -> throw Exception("CONFIG IS NOT SUPPORTED") + }) + } else { + // SEEMS LIKE THERE IS A BUG WITH RGBA_F16 AND WHEN + ihdrBody.add( + if (config == Bitmap.Config.ARGB_8888) + 8.toByte() + else if (config == Bitmap.Config.RGB_565) + 8.toByte() + else + throw Exception("CONFIG IS NOT SUPPORTED") + ) + } + + // COLOR TYPE + ihdrBody.add( + if (config == Bitmap.Config.RGB_565) { + 2.toByte() + } else { + 6.toByte() + } + ) + + // COMPRESSION + ihdrBody.add(0.toByte()) + // FILTER + ihdrBody.add(0.toByte()) + // INTERLACE + ihdrBody.add(0.toByte()) + + // Generate CRC + val crC32 = CRC32() + crC32.update(ihdrBody.toByteArray(), 0, ihdrBody.size) + ihdr.addAll(ihdrBody) + ihdr.addAll(Utils.to4Bytes(crC32.value.toInt()).asList()) + return ihdr.toByteArray() + } + + /** + * Generate the animation control chunk + * @return [ArrayList] The byteArray generated + */ + private fun generateACTL(num: Int): ByteArray { + val res = ArrayList() + val actl = ArrayList() + + // Add length bytes + res.addAll(arrayListOf(0, 0, 0, 0x08)) + + // Add acTL + actl.addAll(byteArrayOf(0x61, 0x63, 0x54, 0x4c).asList()) + + // Add number of frames + actl.addAll(Utils.to4Bytes(num).asList()) + + // Number of repeat, 0 to infinite + actl.addAll(Utils.to4Bytes(0).asList()) + res.addAll(actl) + + // generate crc + val crc = CRC32() + crc.update(actl.toByteArray(), 0, actl.size) + res.addAll(Utils.to4Bytes(crc.value.toInt()).asList()) + return res.toByteArray() + } + + private fun generateFCTL(btm : Bitmap, delay: Float, disposeOp: Utils.Companion.DisposeOp, blendOp: Utils.Companion.BlendOp, xOffsets: Int, yOffsets: Int) { + val fcTL = ArrayList() + + // Add the length of the chunk body + outputStream.write(byteArrayOf(0x00, 0x00, 0x00, 0x1A)) + + // Add fcTL + fcTL.addAll(byteArrayOf(0x66, 0x63, 0x54, 0x4c).asList()) + + // Add the frame number + fcTL.addAll(Utils.to4Bytes(seq++).asList()) + + // Add width and height + fcTL.addAll(Utils.to4Bytes(btm.width).asList()) + fcTL.addAll(Utils.to4Bytes(btm.height).asList()) + + // Add offsets + fcTL.addAll(Utils.to4Bytes(xOffsets).asList()) + fcTL.addAll(Utils.to4Bytes(yOffsets).asList()) + + // Set frame delay + fcTL.addAll(Utils.to2Bytes(delay.toInt()).asList()) + fcTL.addAll(Utils.to2Bytes(1000).asList()) + + // Add DisposeOp and BlendOp + fcTL.add(Utils.getDisposeOp(disposeOp).toByte()) + fcTL.add(Utils.getBlendOp(blendOp).toByte()) + + // Create CRC + val crc = CRC32() + crc.update(fcTL.toByteArray(), 0, fcTL.size) + outputStream.write(fcTL.toByteArray()) + outputStream.write(Utils.to4Bytes(crc.value.toInt())) + } +} \ No newline at end of file diff --git a/apng_library/src/main/java/oupson/apng/imageUtils/PngEncoder.kt b/apng_library/src/main/java/oupson/apng/imageUtils/PngEncoder.kt index db4d002..b6ec6c1 100644 --- a/apng_library/src/main/java/oupson/apng/imageUtils/PngEncoder.kt +++ b/apng_library/src/main/java/oupson/apng/imageUtils/PngEncoder.kt @@ -10,6 +10,7 @@ import kotlin.math.max import kotlin.math.min // TODO FIND A BETTER SOLUTION +// TODO ABSOLUTELY NOT THREAD SAFE, FIX THAT /** * Taken from http://catcode.com/pngencoder/com/keypoint/PngEncoder.java */ @@ -137,6 +138,12 @@ class PngEncoder { return newArray } + fun release() { + image?.recycle() + image = null + pngBytes = null + } + /** * Write an array of bytes into the pngBytes array. * Note: This routine has the side effect of updating diff --git a/app-test/src/main/java/oupson/apngcreator/activities/CreatorActivity.kt b/app-test/src/main/java/oupson/apngcreator/activities/CreatorActivity.kt index 3891226..881a4c0 100644 --- a/app-test/src/main/java/oupson/apngcreator/activities/CreatorActivity.kt +++ b/app-test/src/main/java/oupson/apngcreator/activities/CreatorActivity.kt @@ -5,6 +5,7 @@ import android.content.Intent import android.graphics.Bitmap import android.graphics.BitmapFactory import android.net.Uri +import android.os.Build import android.os.Bundle import android.util.Log import android.view.Menu @@ -20,7 +21,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import oupson.apng.encoder.ApngEncoder +import oupson.apng.encoder.ExperimentalApngEncoder import oupson.apngcreator.BuildConfig import oupson.apngcreator.R import oupson.apngcreator.adapter.ImageAdapter @@ -123,7 +124,7 @@ class CreatorActivity : AppCompatActivity() { if (BuildConfig.DEBUG) Log.i(TAG, "MaxWidth : $maxWidth; MaxHeight : $maxHeight") - val encoder = ApngEncoder(out, maxWidth, maxHeight, items.size) + val encoder = ExperimentalApngEncoder(out, maxWidth, maxHeight, items.size) items.forEachIndexed { i, uri -> if (BuildConfig.DEBUG) Log.v(TAG, "Encoding frame $i") @@ -203,7 +204,7 @@ class CreatorActivity : AppCompatActivity() { str?.close() } - val encoder = ApngEncoder(out, maxWidth, maxHeight, items.size) + val encoder = ExperimentalApngEncoder(out, maxWidth, maxHeight, items.size) items.forEach { uri -> println("delay : ${uri.second.toFloat()}ms") val str = contentResolver.openInputStream(uri.first) ?: return@forEach @@ -318,7 +319,11 @@ class CreatorActivity : AppCompatActivity() { if (BuildConfig.DEBUG) Log.i(TAG, "MaxWidth : $maxWidth; MaxHeight : $maxHeight") - val encoder = ApngEncoder(out, maxWidth, maxHeight, items.size) + val encoder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + ExperimentalApngEncoder(out, maxWidth, maxHeight, items.size, Bitmap.Config.RGBA_F16) + } else { + TODO("VERSION.SDK_INT < O") + } items.forEach { uri -> // println("delay : ${adapter?.delay?.get(i)?.toFloat() ?: 1000f}ms") val str =