diff --git a/apng_library/src/main/java/oupson/apng/encoder/ExperimentalApngEncoder.kt b/apng_library/src/main/java/oupson/apng/encoder/ExperimentalApngEncoder.kt index 751e52c..08c886c 100644 --- a/apng_library/src/main/java/oupson/apng/encoder/ExperimentalApngEncoder.kt +++ b/apng_library/src/main/java/oupson/apng/encoder/ExperimentalApngEncoder.kt @@ -2,13 +2,15 @@ package oupson.apng.encoder +import android.content.Context 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.imageUtils.FrameEncoder import oupson.apng.utils.Utils import java.io.ByteArrayOutputStream +import java.io.File import java.io.InputStream import java.io.OutputStream import java.util.zip.CRC32 @@ -18,6 +20,7 @@ import java.util.zip.CRC32 // TODO BUFFER AND BUFFER DEACTIVATION WHEN BITMAP CONFIG DOES NOT CONTAIN AN ALPHA CHANNEL // TODO JAVA OVERLOADS class ExperimentalApngEncoder( + private var context : Context, // TODO REMOVE private val outputStream: OutputStream, private val width : Int, private val height : Int, @@ -92,7 +95,16 @@ class ExperimentalApngEncoder( val idat = IDAT().apply { val byteArray = if (usePngEncoder) { - PngEncoder().encode(btm, true) + val m = ByteArrayOutputStream() + val e = FrameEncoder(btm.width, btm.height, true, outputStream = m) + e.encode(btm) + //PngEncoder().encode(btm, true) + m.close() + val b = m.toByteArray() + File(context.filesDir, "image$frameIndex.png").apply { + println("Path is ${this.path}") + }.writeBytes(b) + b } else { val outputStream = ByteArrayOutputStream() btm.compress(Bitmap.CompressFormat.PNG, 100, outputStream) diff --git a/apng_library/src/main/java/oupson/apng/imageUtils/FrameEncoder.kt b/apng_library/src/main/java/oupson/apng/imageUtils/FrameEncoder.kt new file mode 100644 index 0000000..14c1083 --- /dev/null +++ b/apng_library/src/main/java/oupson/apng/imageUtils/FrameEncoder.kt @@ -0,0 +1,415 @@ +package oupson.apng.imageUtils + +import android.graphics.Bitmap +import oupson.apng.utils.Utils +import java.io.ByteArrayOutputStream +import java.io.IOException +import java.io.OutputStream +import java.util.zip.CRC32 +import java.util.zip.Deflater +import java.util.zip.DeflaterOutputStream +import kotlin.math.max +import kotlin.math.min + +// TODO MERGE INTO EXPERIMENTALAPNGENCODER ? +/** + * Taken from http://catcode.com/pngencoder/com/keypoint/PngEncoder.java + */ +class FrameEncoder(width: Int, height : Int, encodeAlpha: Boolean = false, filter: Int = 0, compressionLevel: Int = 0, private var outputStream: OutputStream) { + companion object { + /** Constants for filter (NONE) */ + private const val FILTER_NONE = 0 + + /** Constants for filter (SUB) */ + private const val FILTER_SUB = 1 + + /** Constants for filter (UP) */ + private const val FILTER_UP = 2 + + /** Constants for filter (LAST) */ + private const val FILTER_LAST = 2 + } + + /** Encode alpha ? */ + private var encodeAlpha = true + + /** IHDR tag. */ + private val ihdr = byteArrayOf(73, 72, 68, 82) + + /** IDAT tag. */ + private val idat = byteArrayOf(73, 68, 65, 84) + + /** IEND tag. */ + private val iend = byteArrayOf(73, 69, 78, 68) + + /** The png bytes. */ + //private var pngBytes: ByteArray? = null + + /** The prior row. */ + private var priorRow: ByteArray? = null + + /** The left bytes. */ + private var leftBytes: ByteArray? = null + + /** The width. */ + private var width: Int = 0 + private var height: Int = 0 + + /** The byte position. */ + private var bytePos: Int = 0 + private var maxPos: Int = 0 + + /** CRC. */ + private var crc = CRC32() + + /** The CRC value. */ + private var crcValue: Long = 0 + + /** The filter type. */ + private var filter: Int = 0 + + /** The bytes-per-pixel. */ + private var bytesPerPixel: Int = 0 + + /** The compression level. */ + private var compressionLevel: Int = 0 + + private var pos: Int = 0 + + init { + this.filter = FILTER_NONE + if (filter <= FILTER_LAST) { + this.filter = filter + } + + if (compressionLevel in 0..9) { + this.compressionLevel = compressionLevel + } + + this.encodeAlpha = encodeAlpha + + this.width = width + this.height = height + } + + /** + * Encode a [Bitmap] into a png + * + * @param image Bitmap to encode + * @param encodeAlpha Specify if the alpha should be encoded or not + * @param filter 0=none, 1=sub, 2=up + * @param compressionLevel ! Don't use it : It's buggy + */ + fun encode(image: Bitmap) { + /* + * start with an array that is big enough to hold all the pixels + * (plus filter bytes), and an extra 200 bytes for header info + */ + //pngBytes = ByteArray((width + 1) * height * 3 + 200) + /* + * keep track of largest byte written to the array + */ + maxPos = 0 + val pngIdBytes = byteArrayOf(-119, 80, 78, 71, 13, 10, 26, 10) + outputStream.write(pngIdBytes) + //hdrPos = bytePos; + writeHeader() + //dataPos = bytePos; + if (writeImageData(image)) { + writeEnd() + //pngBytes = resizeByteArray(pngBytes!!, maxPos) + } else { + throw Exception() + } + } + + /** + * Increase or decrease the length of a byte array. + * + * @param array ByteArray to resize + * @param newLength The length you wish the new array to have. + * @return Array of newly desired length. If shorter than the + * original, the trailing elements are truncated. + */ + private fun resizeByteArray(array: ByteArray, newLength: Int): ByteArray { + val newArray = ByteArray(newLength) + val oldLength = array.size + System.arraycopy(array, 0, newArray, 0, min(oldLength, newLength)) + return newArray + } + + /** + * Write an array of bytes into the pngBytes array. + * Note: This routine has the side effect of updating + * maxPos, the largest element written in the array. + * The array is resized by 1000 bytes or the length + * of the data to be written, whichever is larger. + * + * @param data The data to be written into pngBytes. + * @param offset The starting point to write to. + * @return The next place to be written to in the pngBytes array. + */ + /*private fun writeBytes(data: ByteArray, offset: Int): Int { + maxPos = max(maxPos, offset + data.size) + if (data.size + offset > pngBytes!!.size) { + pngBytes = resizeByteArray(pngBytes!!, pngBytes!!.size + max(1000, data.size)) + } + System.arraycopy(data, 0, pngBytes!!, offset, data.size) + return offset + data.size + }*/ + + /** + * Write an array of bytes into the pngBytes array, specifying number of bytes to write. + * Note: This routine has the side effect of updating + * maxPos, the largest element written in the array. + * The array is resized by 1000 bytes or the length + * of the data to be written, whichever is larger. + * + * @param data The data to be written into pngBytes. + * @param nBytes The number of bytes to be written. + * @param offset The starting point to write to. + * @return The next place to be written to in the pngBytes array. + */ + /* private fun writeBytes(data: ByteArray, nBytes: Int, offset: Int): Int { + maxPos = max(maxPos, offset + nBytes) + if (nBytes + offset > pngBytes!!.size) { + pngBytes = resizeByteArray(pngBytes!!, pngBytes!!.size + max(1000, nBytes)) + } + System.arraycopy(data, 0, pngBytes!!, offset, nBytes) + return offset + nBytes + }*/ + + /** + * Write a two-byte integer into the pngBytes array at a given position. + * + * @param n The integer to be written into pngBytes. + * @param offset The starting point to write to. + * @return The next place to be written to in the pngBytes array. + */ + @Suppress("unused") + private fun writeInt2(n: Int) { + val temp = byteArrayOf((n shr 8 and 0xff).toByte(), (n and 0xff).toByte()) + outputStream.write(temp) + } + + /** + * Write a four-byte integer into the pngBytes array at a given position. + * + * @param n The integer to be written into pngBytes. + * @param offset The starting point to write to. + * @return The next place to be written to in the pngBytes array. + */ + private fun writeInt4(n: Int) { + val temp = byteArrayOf( + (n shr 24 and 0xff).toByte(), + (n shr 16 and 0xff).toByte(), + (n shr 8 and 0xff).toByte(), + (n and 0xff).toByte() + ) + outputStream.write(temp) + } + + /** + * Write a single byte into the pngBytes array at a given position. + * + * @param b The integer to be written into pngBytes. + * @param offset The starting point to write to. + * @return The next place to be written to in the pngBytes array. + */ + private fun writeByte(b: Int) { + outputStream.write(b) + } + + /** + * Write a PNG "IHDR" chunk into the pngBytes array. + */ + private fun writeHeader() { + writeInt4(13) + val startPos: Int = bytePos + val arrayList = arrayListOf() + arrayList.addAll(ihdr.asList()) + arrayList.addAll(Utils.to4Bytes(width)) + arrayList.addAll(Utils.to4Bytes(height)) + arrayList.add(8) // bit depth + arrayList.add(if (encodeAlpha) 6 else 2) // direct model + arrayList.add(0) // compression method + arrayList.add(0) // filter method + arrayList.add(0) // no interlace + outputStream.write( + arrayList.toByteArray() + ) + crc.reset() + crc.update(arrayList.toByteArray(), startPos, bytePos - startPos) + crcValue = crc.value + writeInt4(crcValue.toInt()) + } + + /** + * Perform "sub" filtering on the given row. + * Uses temporary array leftBytes to store the original values + * of the previous pixels. The array is 16 bytes long, which + * will easily hold two-byte samples plus two-byte alpha. + * + * @param pixels The array holding the scan lines being built + * @param startPos Starting position within pixels of bytes to be filtered. + * @param width Width of a scanline in pixels. + */ + private fun filterSub(pixels: ByteArray, startPos: Int, width: Int) { + val offset = bytesPerPixel + val actualStart = startPos + offset + val nBytes = width * bytesPerPixel + var leftInsert = offset + var leftExtract = 0 + var i: Int = actualStart + while (i < startPos + nBytes) { + leftBytes!![leftInsert] = pixels[i] + pixels[i] = ((pixels[i] - leftBytes!![leftExtract]) % 256).toByte() + leftInsert = (leftInsert + 1) % 0x0f + leftExtract = (leftExtract + 1) % 0x0f + i++ + } + } + + /** + * Perform "up" filtering on the given row. + * Side effect: refills the prior row with current row + * + * @param pixels The array holding the scan lines being built + * @param startPos Starting position within pixels of bytes to be filtered. + * @param width Width of a scanline in pixels. + */ + private fun filterUp(pixels: ByteArray, startPos: Int, width: Int) { + var i = 0 + val nBytes: Int = width * bytesPerPixel + var currentByte: Byte + while (i < nBytes) { + currentByte = pixels[startPos + i] + pixels[startPos + i] = ((pixels[startPos + i] - priorRow!![i]) % 256).toByte() + priorRow!![i] = currentByte + i++ + } + } + + /** + * Write the image data into the pngBytes array. + * This will write one or more PNG "IDAT" chunks. In order + * to conserve memory, this method grabs as many rows as will + * fit into 32K bytes, or the whole image; whichever is less. + * + * + * @return true if no errors; false if error grabbing pixels + */ + private fun writeImageData(image: Bitmap): Boolean { + var rowsLeft = height // number of rows remaining to write + var startRow = 0 // starting row to process this time through + var nRows: Int // how many rows to grab at a time + + var scanLines: ByteArray // the scan lines to be compressed + var scanPos: Int // where we are in the scan lines + var startPos: Int // where this line's actual pixels start (used for filtering) + + val compressedLines: ByteArray // the resultant compressed lines + val nCompressed: Int // how big is the compressed area? + + //int depth; // color depth ( handle only 8 or 32 ) + + bytesPerPixel = if (encodeAlpha) 4 else 3 + + val scrunch = Deflater(compressionLevel) + val outBytes = ByteArrayOutputStream(1024) + + val compBytes = DeflaterOutputStream(outBytes, scrunch) + try { + while (rowsLeft > 0) { + nRows = min(32767 / (width * (bytesPerPixel + 1)), rowsLeft) + nRows = max(nRows, 1) + + val pixels = IntArray(width * nRows) + + //pg = new PixelGrabber(image, 0, startRow, width, nRows, pixels, 0, width); + image.getPixels(pixels, 0, width, 0, startRow, width, nRows) + + /* + * Create a data chunk. scanLines adds "nRows" for + * the filter bytes. + */ + scanLines = ByteArray(width * nRows * bytesPerPixel + nRows) + + if (filter == FILTER_SUB) { + leftBytes = ByteArray(16) + } + if (filter == FILTER_UP) { + priorRow = ByteArray(width * bytesPerPixel) + } + + scanPos = 0 + startPos = 1 + for (i in 0 until width * nRows) { + if (i % width == 0) { + scanLines[scanPos++] = filter.toByte() + startPos = scanPos + } + scanLines[scanPos++] = (pixels[i] shr 16 and 0xff).toByte() + scanLines[scanPos++] = (pixels[i] shr 8 and 0xff).toByte() + scanLines[scanPos++] = (pixels[i] and 0xff).toByte() + if (encodeAlpha) { + scanLines[scanPos++] = (pixels[i] shr 24 and 0xff).toByte() + } + if (i % width == width - 1 && filter != FILTER_NONE) { + if (filter == FILTER_SUB) { + filterSub(scanLines, startPos, width) + } + if (filter == FILTER_UP) { + filterUp(scanLines, startPos, width) + } + } + } + + /* + * Write these lines to the output area + */ + compBytes.write(scanLines, 0, scanPos) + + startRow += nRows + rowsLeft -= nRows + } + compBytes.close() + + /* + * Write the compressed bytes + */ + compressedLines = outBytes.toByteArray() + nCompressed = compressedLines.size + + crc.reset() + writeInt4(nCompressed) + //bytePos = writeBytes(idat, bytePos) + outputStream.write(idat) + crc.update(idat) + //bytePos = writeBytes(compressedLines, nCompressed, bytePos) + outputStream.write(compressedLines) + crc.update(compressedLines, 0, nCompressed) + + crcValue = crc.value + writeInt4(crcValue.toInt()) + scrunch.finish() + scrunch.end() + return true + } catch (e: IOException) { + System.err.println(e.toString()) + return false + } + } + + /** + * Write a PNG "IEND" chunk into the pngBytes array. + */ + private fun writeEnd() { + writeInt4(0) + outputStream.write(iend) + crc.reset() + crc.update(iend) + crcValue = crc.value + writeInt4(crcValue.toInt()) + } +} \ No newline at end of file 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 1cfda4d..7409731 100644 --- a/app-test/src/main/java/oupson/apngcreator/activities/CreatorActivity.kt +++ b/app-test/src/main/java/oupson/apngcreator/activities/CreatorActivity.kt @@ -121,7 +121,7 @@ class CreatorActivity : AppCompatActivity() { if (BuildConfig.DEBUG) Log.i(TAG, "MaxWidth : $maxWidth; MaxHeight : $maxHeight") - val encoder = ExperimentalApngEncoder(out, maxWidth, maxHeight, items.size) + val encoder = ExperimentalApngEncoder(this@CreatorActivity, out, maxWidth, maxHeight, items.size) items.forEachIndexed { i, uri -> if (BuildConfig.DEBUG) Log.v(TAG, "Encoding frame $i") @@ -199,7 +199,7 @@ class CreatorActivity : AppCompatActivity() { str?.close() } - val encoder = ExperimentalApngEncoder(out, maxWidth, maxHeight, items.size) + val encoder = ExperimentalApngEncoder(this@CreatorActivity, out, maxWidth, maxHeight, items.size) items.forEach { uri -> println("delay : ${uri.second.toFloat()}ms") val str = contentResolver.openInputStream(uri.first) ?: return@forEach @@ -314,7 +314,7 @@ class CreatorActivity : AppCompatActivity() { if (BuildConfig.DEBUG) Log.i(TAG, "MaxWidth : $maxWidth; MaxHeight : $maxHeight") - val encoder = ExperimentalApngEncoder(out, maxWidth, maxHeight, items.size, Bitmap.Config.ARGB_8888) + val encoder = ExperimentalApngEncoder(this@CreatorActivity, out, maxWidth, maxHeight, items.size, Bitmap.Config.ARGB_8888) items.forEach { uri -> // println("delay : ${adapter?.delay?.get(i)?.toFloat() ?: 1000f}ms") val str =