package jp.juggler.apng import android.graphics.* import android.util.Log import jp.juggler.util.data.encodeUTF8 import java.io.InputStream import kotlin.math.max import kotlin.math.min class ApngFrames private constructor( private val pixelSizeMax: Int = 0, private val debug: Boolean = false, ) : ApngDecoderCallback, MyGifDecoderCallback { companion object { private const val TAG = "ApngFrames" // ループしない画像の場合は3秒でまたループさせる private const val DELAY_AFTER_END = 3000L // アニメーションフレームの描画に使う private val sPaintDontBlend = Paint().apply { xfermode = PorterDuffXfermode(PorterDuff.Mode.SRC) isFilterBitmap = true } private val sPaintClear = Paint().apply { xfermode = PorterDuffXfermode(PorterDuff.Mode.CLEAR) color = 0 } private fun createBlankBitmap(w: Int, h: Int) = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888) private fun scale(max: Int, num: Int, den: Int) = (max.toFloat() * num.toFloat() / den.toFloat() + 0.5f).toInt() private fun scaleBitmap( sizeMax: Int, src: Bitmap, recycleSrc: Boolean = true, // true: ownership of "src" will be moved or recycled. ): Bitmap { val wSrc = src.width val hSrc = src.height if (sizeMax <= 0 || (wSrc <= sizeMax && hSrc <= sizeMax)) { return if (recycleSrc) { src } else { src.copy(Bitmap.Config.ARGB_8888, false) } } val wDst: Int val hDst: Int if (wSrc >= hSrc) { wDst = sizeMax hDst = max(1, scale(sizeMax, hSrc, wSrc)) } else { hDst = sizeMax wDst = max(1, scale(sizeMax, wSrc, hSrc)) } //Log.v(TAG,"scaleBitmap: $wSrc,$hSrc => $wDst,$hDst") val b2 = createBlankBitmap(wDst, hDst) val canvas = Canvas(b2) val rectSrc = Rect(0, 0, wSrc, hSrc) val rectDst = Rect(0, 0, wDst, hDst) canvas.drawBitmap(src, rectSrc, rectDst, sPaintDontBlend) if (recycleSrc) src.recycle() return b2 } private fun toAndroidBitmap(src: ApngBitmap) = Bitmap.createBitmap( src.colors, // int[] 配列 0, // offset src.width, //stride src.width, // width src.height, //height Bitmap.Config.ARGB_8888 ) private fun toAndroidBitmap(src: ApngBitmap, sizeMax: Int) = scaleBitmap(sizeMax, toAndroidBitmap(src)) private fun parseApng( inStream: InputStream, pixelSizeMax: Int, debug: Boolean = false, ): ApngFrames { val result = ApngFrames(pixelSizeMax, debug) try { ApngDecoder.parseStream(inStream, result) result.onParseComplete() return result.takeIf { result.defaultImage != null || result.frames?.isNotEmpty() == true } ?: error("APNG has no image") } catch (ex: Throwable) { result.dispose() throw ex } } private fun parseWebP( inStream: InputStream, pixelSizeMax: Int, debug: Boolean = false, ): ApngFrames { val result = ApngFrames(pixelSizeMax, debug) try { MyWebPDecoder(result).parse(inStream) result.onParseComplete() return result.takeIf { result.defaultImage != null || result.frames?.isNotEmpty() == true } ?: error("WebP has no image") } catch (ex: Throwable) { result.dispose() throw ex } } private fun parseGif( inStream: InputStream, pixelSizeMax: Int, debug: Boolean = false, ): ApngFrames { val result = ApngFrames(pixelSizeMax, debug) try { MyGifDecoder(result).parse(inStream) result.onParseComplete() return result.takeIf { result.defaultImage != null || result.frames?.isNotEmpty() == true } ?: error("GIF has no image") } catch (ex: Throwable) { result.dispose() throw ex } } private val apngHeadKey = byteArrayOf(0x89.toByte(), 0x50) private val webpHeadKey1 = "RIFF".encodeUTF8() private val webpHeadKey2 = "WEBP".encodeUTF8() private val gifHeadKey = "GIF".toByteArray(Charsets.UTF_8) private fun matchBytes( ba1: ByteArray, ba2: ByteArray, length: Int = min(ba1.size, ba2.size), ): Boolean { for (i in 0 until length) { if (ba1[i] != ba2[i]) return false } return true } private fun matchBytesOffset( ba1: ByteArray, ba1Offset: Int, ba2: ByteArray, length: Int = ba2.size, ): Boolean { for (i in 0 until length) { if (ba1[i + ba1Offset] != ba2[i]) return false } return true } fun parse( pixelSizeMax: Int, debug: Boolean = false, opener: () -> InputStream?, ): ApngFrames? { val buf = ByteArray(12) { 0.toByte() } opener()?.use { it.read(buf, 0, buf.size) } if (buf.size >= 8 && matchBytes(buf, apngHeadKey)) { return opener()?.use { parseApng(it, pixelSizeMax, debug) } } if (buf.size >= 12 && matchBytesOffset(buf, 0, webpHeadKey1) && matchBytesOffset(buf, 8, webpHeadKey2) ) { return opener()?.use { parseWebP(it, pixelSizeMax, debug) } } if (buf.size >= 6 && matchBytes(buf, gifHeadKey)) { return opener()?.use { parseGif(it, pixelSizeMax, debug) } } return null } } private var header: ApngImageHeader? = null private var animationControl: ApngAnimationControl? = null // width,height (after resized) var width: Int = 1 private set var height: Int = 1 private set class Frame( val bitmap: Bitmap, val timeStart: Long, val timeWidth: Long, ) var frames: ArrayList? = null @Suppress("MemberVisibilityCanBePrivate") val numFrames: Int get() = frames?.size ?: 1 @Suppress("unused") val hasMultipleFrame: Boolean get() = numFrames > 1 private var timeTotal = 0L private lateinit var canvas: Canvas private var canvasBitmap: Bitmap? = null // 再生速度の調整 private var durationScale = 1f // APNGじゃなかった場合に使われる private var defaultImage: Bitmap? = null set(value) { field = value if (value != null) { width = value.width height = value.height } } val aspect:Float? get() = if( width<=0 || height<=0) null else width.toFloat().div(height) constructor(bitmap: Bitmap) : this() { defaultImage = bitmap } private fun onParseComplete() { canvasBitmap?.recycle() canvasBitmap = null val frames = this.frames if (frames != null) { if (frames.size > 1) { defaultImage?.recycle() defaultImage = null } else if (frames.size == 1) { defaultImage?.recycle() defaultImage = frames.first().bitmap frames.clear() } } } fun dispose() { canvasBitmap?.recycle() defaultImage?.recycle() frames?.forEach { it.bitmap.recycle() } } class FindFrameResult { var bitmap: Bitmap? = null // may null var delay: Long = 0 // 再描画が必要ない場合は Long.MAX_VALUE } // シーク位置に応じたコマ画像と次のコマまでの残り時間をresultに格納する @Suppress("unused") fun findFrame(result: FindFrameResult, t: Long) { if (defaultImage != null) { result.bitmap = defaultImage result.delay = Long.MAX_VALUE return } val animationControl = this.animationControl val frames = this.frames if (animationControl == null || frames == null || frames.isEmpty()) { // ここは通らないはず… result.bitmap = null result.delay = Long.MAX_VALUE return } val frameCount = frames.size val isFinite = animationControl.isFinite val repeatSequenceCount = if (isFinite) animationControl.numPlays else 1 val endWait = if (isFinite) DELAY_AFTER_END else 0L val timeTotalLoop = max(1, timeTotal * repeatSequenceCount + endWait) val tf = (max(0, t) / durationScale).toLong() // 全体の繰り返し時刻で余りを計算 val tl = tf % timeTotalLoop if (tl >= timeTotalLoop - endWait) { // 終端で待機状態 result.bitmap = frames[frameCount - 1].bitmap result.delay = (0.5f + (timeTotalLoop - tl) * durationScale).toLong() return } // 1ループの繰り返し時刻で余りを計算 val tt = tl % timeTotal // フレームリストを時刻で二分探索 var s = 0 var e = frameCount while (e - s > 1) { val mid = s + e shr 1 val frame = frames[mid] // log.d("s=%d,m=%d,e=%d tt=%d,fs=%s,fe=%d",s,mid,e,tt,frame.timeStart,frame.timeStart+frame.timeWidth ); if (tt < frame.timeStart) { e = mid } else if (tt >= frame.timeStart + frame.timeWidth) { s = mid + 1 } else { s = mid break } } s = if (s < 0) 0 else if (s >= frameCount - 1) frameCount - 1 else s val frame = frames[s] val delay = frame.timeStart + frame.timeWidth - tt result.bitmap = frames[s].bitmap result.delay = (0.5f + durationScale * max(0f, delay.toFloat())).toLong() // log.d("findFrame tf=%d,tl=%d/%d,tt=%d/%d,s=%d,w=%d,delay=%d",tf,tl,loop_total,tt,timeTotal,s,frame.timeWidth,result.delay); } ///////////////////////////////////////////////////// // implements ApngDecoderCallback override fun onApngWarning(message: String) { Log.w(TAG, message) } override fun onApngDebug(message: String) { Log.d(TAG, message) } override fun canApngDebug(): Boolean = debug override fun onHeader(apng: Apng, header: ApngImageHeader) { this.header = header } override fun onAnimationInfo( apng: Apng, header: ApngImageHeader, animationControl: ApngAnimationControl, ) { if (debug) { Log.d(TAG, "onAnimationInfo") } this.animationControl = animationControl this.frames = ArrayList(animationControl.numFrames) val canvasBitmap = createBlankBitmap(header.width, header.height) this.canvasBitmap = canvasBitmap this.canvas = Canvas(canvasBitmap) } override fun onDefaultImage(apng: Apng, bitmap: ApngBitmap) { if (debug) { Log.d(TAG, "onDefaultImage") } defaultImage?.recycle() defaultImage = toAndroidBitmap(bitmap, pixelSizeMax) } override fun onAnimationFrame( apng: Apng, frameControl: ApngFrameControl, frameBitmap: ApngBitmap, ) { if (debug) { Log.d( TAG, "onAnimationFrame seq=${frameControl.sequenceNumber}, xywh=${frameControl.xOffset},${frameControl.yOffset},${frameControl.width},${frameControl.height} blendOp=${frameControl.blendOp}, disposeOp=${frameControl.disposeOp},delay=${frameControl.delayMilliseconds}" ) } val frames = this.frames ?: return val canvasBitmap = this.canvasBitmap ?: return val disposeOp = when { // If the first `fcTL` chunk uses a `dispose_op` of APNG_DISPOSE_OP_PREVIOUS it should be treated as APNG_DISPOSE_OP_BACKGROUND. frameControl.disposeOp == DisposeOp.Previous && frames.isEmpty() -> DisposeOp.Background else -> frameControl.disposeOp } val previous: Bitmap? = when (disposeOp) { DisposeOp.Previous -> Bitmap.createBitmap( canvasBitmap, frameControl.xOffset, frameControl.yOffset, frameControl.width, frameControl.height ) else -> null } try { val frameBitmapAndroid = toAndroidBitmap(frameBitmap) try { canvas.drawBitmap( frameBitmapAndroid, frameControl.xOffset.toFloat(), frameControl.yOffset.toFloat(), when (frameControl.blendOp) { // all color components of the frame, including alpha, // overwrite the current contents of the frame's output buffer region. BlendOp.Source -> sPaintDontBlend // the frame should be composited onto the output buffer based on its alpha, // using a simple OVER operation as described in the "Alpha Channel Processing" section of the PNG specification [PNG-1.2]. BlendOp.Over -> null } ) val frame = Frame( bitmap = scaleBitmap(pixelSizeMax, canvasBitmap, recycleSrc = false), timeStart = timeTotal, timeWidth = max(1L, frameControl.delayMilliseconds) ) frames.add(frame) timeTotal += frame.timeWidth when (disposeOp) { // no disposal is done on this frame before rendering the next; // the contents of the output buffer are left as is. DisposeOp.None -> { } // the frame's region of the output buffer is // to be cleared to fully transparent black // before rendering the next frame. DisposeOp.Background -> { val rect = Rect() rect.left = frameControl.xOffset rect.top = frameControl.yOffset rect.right = frameControl.xOffset + frameControl.width rect.bottom = frameControl.yOffset + frameControl.height canvas.drawRect(rect, sPaintClear) // canvas.drawColor(0, PorterDuff.Mode.CLEAR) } // the frame's region of the output buffer is // to be reverted to the previous contents // before rendering the next frame. DisposeOp.Previous -> if (previous != null) { canvas.drawBitmap( previous, frameControl.xOffset.toFloat(), frameControl.yOffset.toFloat(), sPaintDontBlend ) } } } finally { frameBitmapAndroid.recycle() } } finally { previous?.recycle() } } /////////////////////////////////////////////////////////////////////// // Gif support override fun onGifWarning(message: String) { Log.w(TAG, message) } override fun onGifDebug(message: String) { Log.d(TAG, message) } override fun canGifDebug(): Boolean = debug override fun onGifHeader(header: ApngImageHeader) { this.header = header } override fun onGifAnimationInfo( header: ApngImageHeader, animationControl: ApngAnimationControl, ) { if (debug) { Log.d(TAG, "onAnimationInfo") } this.animationControl = animationControl this.frames = ArrayList(animationControl.numFrames) val canvasBitmap = createBlankBitmap(header.width, header.height) this.canvasBitmap = canvasBitmap this.canvas = Canvas(canvasBitmap) } override fun onGifAnimationFrame( frameControl: ApngFrameControl, frameBitmap: ApngBitmap, ) { if (debug) { Log.d( TAG, "onAnimationFrame seq=${frameControl.sequenceNumber}, xywh=${frameControl.xOffset},${frameControl.yOffset},${frameControl.width},${frameControl.height} blendOp=${frameControl.blendOp}, disposeOp=${frameControl.disposeOp},delay=${frameControl.delayMilliseconds}" ) } val frames = this.frames ?: return if (frames.isEmpty()) { defaultImage?.recycle() defaultImage = toAndroidBitmap(frameBitmap, pixelSizeMax) // ここでwidth,heightがセットされる } val frameBitmapAndroid = toAndroidBitmap(frameBitmap) try { val frame = Frame( bitmap = scaleBitmap(pixelSizeMax, frameBitmapAndroid, recycleSrc = false), timeStart = timeTotal, timeWidth = max(1L, frameControl.delayMilliseconds) ) frames.add(frame) timeTotal += frame.timeWidth } finally { frameBitmapAndroid.recycle() } } }