diff --git a/app/build.gradle b/app/build.gradle index 2c5be431..56e4f149 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -143,7 +143,7 @@ dependencies { implementation 'org.apache.commons:commons-math3:3.6.1' // ffmpeg for encoding video (HH export) - implementation 'com.arthenica:ffmpeg-kit-min-gpl:4.4.LTS' +// implementation 'com.arthenica:ffmpeg-kit-min-gpl:4.4.LTS' // Camera def camerax_version = "1.1.0" diff --git a/app/src/main/java/net/pokeranalytics/android/ui/modules/handhistory/replayer/FrameManager.kt b/app/src/main/java/net/pokeranalytics/android/ui/modules/handhistory/replayer/FrameManager.kt index fe10f39f..b9972e6c 100644 --- a/app/src/main/java/net/pokeranalytics/android/ui/modules/handhistory/replayer/FrameManager.kt +++ b/app/src/main/java/net/pokeranalytics/android/ui/modules/handhistory/replayer/FrameManager.kt @@ -3,9 +3,9 @@ package net.pokeranalytics.android.ui.modules.handhistory.replayer import net.pokeranalytics.android.exceptions.PAIllegalStateException enum class FrameType(val visualOccurences: Int) { - STATE(150), - GATHER_ANIMATION(2), - DISTRIBUTION_ANIMATION(2) + STATE(50), + GATHER_ANIMATION(1), + DISTRIBUTION_ANIMATION(1) } class FrameManager { diff --git a/app/src/main/java/net/pokeranalytics/android/ui/modules/handhistory/replayer/ReplayExportService.kt b/app/src/main/java/net/pokeranalytics/android/ui/modules/handhistory/replayer/ReplayExportService.kt index b7625bc3..e14e71cc 100644 --- a/app/src/main/java/net/pokeranalytics/android/ui/modules/handhistory/replayer/ReplayExportService.kt +++ b/app/src/main/java/net/pokeranalytics/android/ui/modules/handhistory/replayer/ReplayExportService.kt @@ -3,6 +3,7 @@ package net.pokeranalytics.android.ui.modules.handhistory.replayer import android.app.PendingIntent import android.app.Service import android.content.ContentValues +import android.content.Context import android.content.Intent import android.net.Uri import android.os.Binder @@ -11,13 +12,17 @@ import android.os.Environment import android.os.IBinder import android.provider.MediaStore import androidx.core.content.FileProvider -import com.arthenica.ffmpegkit.FFmpegKit +import android.graphics.Bitmap +import android.media.MediaCodec +import android.media.MediaCodecInfo +import android.media.MediaFormat +import android.media.MediaMuxer +import java.nio.ByteBuffer import io.realm.Realm import kotlinx.coroutines.* import net.pokeranalytics.android.R import net.pokeranalytics.android.exceptions.PAIllegalStateException import net.pokeranalytics.android.model.realm.handhistory.HandHistory -import net.pokeranalytics.android.util.FFMPEG_DESCRIPTOR_FILE import net.pokeranalytics.android.util.TriggerNotification import net.pokeranalytics.android.util.extensions.dateTimeFileFormatted import net.pokeranalytics.android.util.extensions.findById @@ -52,11 +57,7 @@ class ReplayExportService : Service() { fun videoExport(handHistoryId: String) { this@ReplayExportService.handHistoryId = handHistoryId - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - startFFMPEGVideoExport() - } else { - startFFMPEGVideoExportPreQ() - } + startFFMPEGVideoExport() } fun gifExport(handHistoryId: String) { @@ -159,7 +160,6 @@ class ReplayExportService : Service() { val animator = ReplayerAnimator(handHistory, true) val square = 1024 - val width = square val height = square @@ -167,60 +167,35 @@ class ReplayExportService : Service() { val drawer = TableDrawer() drawer.configurePaints(context, animator) - // generates all images and file descriptor - Timber.d("Generating images for video...") - val tmpDir = animator.generateVideoContent(this@ReplayExportService) - val dpath = "${tmpDir.path}/$FFMPEG_DESCRIPTOR_FILE" - val formattedDate = Date().dateTimeFileFormatted val fileName = "hand_${formattedDate}.mp4" val outputDirectory = context.getExternalFilesDir(Environment.DIRECTORY_MOVIES) ?: throw PAIllegalStateException("File is invalid") - val output = "${outputDirectory.path}/$fileName" - - Timber.d("Assembling images for video...") - - val command = "-f concat -safe 0 -i $dpath -vb 20M -vsync vfr -s ${width}x${height} -vf fps=20 -pix_fmt yuv420p $output" - FFmpegKit.executeAsync(command) { + val outputFile = File(outputDirectory, fileName) - when { - it.returnCode.isSuccess -> { - Timber.d("FFMPEG command execution completed successfully") - } - it.returnCode.isCancel -> { - Timber.d("Command execution cancelled by user.") - } - else -> { - Timber.d(String.format("Command execution failed with rc=%d and the output below.", it.returnCode.value)) - } - } + Timber.d("Creating video with MediaMuxer...") - File(dpath).delete() - tmpDir.delete() - - val file = File(output) + try { + createVideoWithMediaMuxer(animator, context, outputFile, width, height) val resolver = applicationContext.contentResolver - - // Q version tested before calling the function val videoCollection = MediaStore.Video.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) + Timber.d("getContentUri = $videoCollection...") val fileDetails = ContentValues().apply { Timber.d("set file details = $fileName") put(MediaStore.Video.Media.DISPLAY_NAME, fileName) - put(MediaStore.Images.Media.MIME_TYPE, FileType.VIDEO_MP4.value) + put(MediaStore.Video.Media.MIME_TYPE, FileType.VIDEO_MP4.value) } - // copy video to nice path resolver.insert(videoCollection, fileDetails)?.let { uri -> - Timber.d("copy file at uri = $uri") val os = resolver.openOutputStream(uri) - os?.write(file.readBytes()) + os?.write(outputFile.readBytes()) os?.close() - file.delete() // delete temp file + outputFile.delete() // delete temp file notifyUser(uri, FileType.VIDEO_MP4) @@ -231,59 +206,173 @@ class ReplayExportService : Service() { Timber.w("Resolver insert ended without uri...") } + } catch (e: Exception) { + Timber.e(e, "Error creating video with MediaMuxer") + if (outputFile.exists()) { + outputFile.delete() + } } + realm.close() } async.await() } + } + + private fun createVideoWithMediaMuxer(animator: ReplayerAnimator, context: Context, outputFile: File, width: Int, height: Int) { + val mimeType = MediaFormat.MIMETYPE_VIDEO_AVC + val frameRate = 20 + val bitRate = 2000000 // 2Mbps + + // Create MediaFormat with YUV420 flexible format + val format = MediaFormat.createVideoFormat(mimeType, width, height).apply { + setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420Flexible) + setInteger(MediaFormat.KEY_BIT_RATE, bitRate) + setInteger(MediaFormat.KEY_FRAME_RATE, frameRate) + setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 1) + } + + // Create encoder + val encoder = MediaCodec.createEncoderByType(mimeType) + encoder.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE) + Timber.d("Starting encoder...") + encoder.start() + + // Create MediaMuxer + val muxer = MediaMuxer(outputFile.path, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4) + var trackIndex = -1 + var muxerStarted = false + + val bufferInfo = MediaCodec.BufferInfo() + var frameIndex = 0 + val presentationTimeUs = 1000000L / frameRate // Time per frame in microseconds + + try { + // Generate frames using animator + Timber.d("Generate frames...") + + animator.frames(context) { bitmap, visualOccurrences -> + Timber.d(">>> Generated frame, visualOccurrences = $visualOccurrences") + + val yuvData = convertBitmapToYUV420(bitmap, width, height) + repeat(visualOccurrences) { + // Convert bitmap to YUV420 and feed to encoder + val inputBufferIndex = encoder.dequeueInputBuffer(10000) + if (inputBufferIndex >= 0) { + val inputBuffer = encoder.getInputBuffer(inputBufferIndex) + if (inputBuffer != null) { + inputBuffer.clear() + inputBuffer.put(yuvData) + encoder.queueInputBuffer(inputBufferIndex, 0, yuvData.size, frameIndex * presentationTimeUs, 0) + } + } + + // Process output buffers +// Timber.d("drainEncoder...") + drainEncoder(encoder, muxer, bufferInfo, trackIndex) { newTrackIndex -> + trackIndex = newTrackIndex + muxerStarted = true + } + + frameIndex++ + } + } + Timber.d("end of frames generation...") + + // Signal end of input + val inputBufferIndex = encoder.dequeueInputBuffer(10000) + if (inputBufferIndex >= 0) { + encoder.queueInputBuffer(inputBufferIndex, 0, 0, frameIndex * presentationTimeUs, MediaCodec.BUFFER_FLAG_END_OF_STREAM) + } + + Timber.d("drainEncoder again...") + + // Drain remaining output + drainEncoder(encoder, muxer, bufferInfo, trackIndex, true) { newTrackIndex -> + if (!muxerStarted) { + trackIndex = newTrackIndex + muxerStarted = true + } + } + + } finally { + Timber.d("stop and release...") + + encoder.stop() + encoder.release() + if (muxerStarted) { + muxer.stop() + } + muxer.release() + } } -// private fun startVideoExport() { -// -// GlobalScope.launch(coroutineContext) { -// val c = GlobalScope.async { -// -// val realm = Realm.getDefaultInstance() -// val handHistory = realm.findById(handHistoryId) ?: throw PAIllegalStateException("HandHistory not found, id: $handHistoryId") -// -// val context = this@ReplayExportService -// -// val animator = ReplayerAnimator(handHistory, true) -// -// val square = 1024 -// -// val width = square -// val height = square -// -// animator.setDimension(width.toFloat(), height.toFloat()) -// TableDrawer.configurePaints(context, animator) -// -// val muxer = MMediaMuxer() -// muxer.init(null, width, height, "hhVideo", "YES!") -// -// animator.frames(context) { bitmap, count -> -// -// try { -// val byteArray = bitmap.toByteArray() -// muxer.addFrame(byteArray, count, false) -// } catch (e: Exception) { -// Timber.e("error = ${e.message}") -// } -// -// } -// -// realm.close() -// -// muxer.createVideo { path -> -// notifyUser(path) -// } -// -// } -// c.await() -// } -// -// } + private fun drainEncoder(encoder: MediaCodec, muxer: MediaMuxer, bufferInfo: MediaCodec.BufferInfo, + trackIndex: Int, endOfStream: Boolean = false, onTrackAdded: (Int) -> Unit) { + var localTrackIndex = trackIndex + + while (true) { + val outputBufferIndex = encoder.dequeueOutputBuffer(bufferInfo, if (endOfStream) 10000 else 0) + when { + outputBufferIndex == MediaCodec.INFO_TRY_AGAIN_LATER -> { + if (!endOfStream) break else continue + } + outputBufferIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> { + if (localTrackIndex >= 0) { + throw RuntimeException("Format changed twice") + } + localTrackIndex = muxer.addTrack(encoder.outputFormat) + muxer.start() + onTrackAdded(localTrackIndex) + } + outputBufferIndex >= 0 -> { + val outputBuffer = encoder.getOutputBuffer(outputBufferIndex) + if (outputBuffer != null && bufferInfo.size > 0 && localTrackIndex >= 0) { + muxer.writeSampleData(localTrackIndex, outputBuffer, bufferInfo) + } + encoder.releaseOutputBuffer(outputBufferIndex, false) + + if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) { + break + } + } + } + } + } + + private fun convertBitmapToYUV420(bitmap: Bitmap, width: Int, height: Int): ByteArray { + val pixels = IntArray(width * height) + bitmap.getPixels(pixels, 0, width, 0, 0, width, height) + + val yuvSize = width * height * 3 / 2 + val yuv = ByteArray(yuvSize) + + var yIndex = 0 + var uvIndex = width * height + + for (y in 0 until height) { + for (x in 0 until width) { + val pixel = pixels[y * width + x] + val r = (pixel shr 16) and 0xff + val g = (pixel shr 8) and 0xff + val b = pixel and 0xff + + // Convert RGB to YUV + val yValue = ((66 * r + 129 * g + 25 * b + 128) shr 8) + 16 + yuv[yIndex++] = yValue.coerceIn(0, 255).toByte() + + if (y % 2 == 0 && x % 2 == 0) { + val uValue = ((-38 * r - 74 * g + 112 * b + 128) shr 8) + 128 + val vValue = ((112 * r - 94 * g - 18 * b + 128) shr 8) + 128 + yuv[uvIndex++] = uValue.coerceIn(0, 255).toByte() + yuv[uvIndex++] = vValue.coerceIn(0, 255).toByte() + } + } + } + + return yuv + } private fun startGIFExportPreQ() { @@ -347,80 +436,6 @@ class ReplayExportService : Service() { } - private fun startFFMPEGVideoExportPreQ() { - - GlobalScope.launch(coroutineContext) { - val async = GlobalScope.async { - - val realm = Realm.getDefaultInstance() - val handHistory = realm.findById(handHistoryId) ?: throw PAIllegalStateException("HandHistory not found, id: $handHistoryId") - - val context = this@ReplayExportService - - val animator = ReplayerAnimator(handHistory, true) - - val square = 1024 - - val width = square - val height = square - - animator.configure(width.toFloat(), height.toFloat(), this@ReplayExportService) - val drawer = TableDrawer() - drawer.configurePaints(context, animator) - - // generates all images and file descriptor - Timber.d("Generating images for video...") - val tmpDir = animator.generateVideoContent(this@ReplayExportService) - val dpath = "${tmpDir.path}/$FFMPEG_DESCRIPTOR_FILE" - - val formattedDate = Date().dateTimeFileFormatted - val output = File( - Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MOVIES), - "hand_${formattedDate}.mp4" - ).path - - Environment.getExternalStorageState(tmpDir) - - Timber.d("Assembling images for video...") - - - val command = "-f concat -safe 0 -i $dpath -vb 20M -vsync vfr -s ${width}x${height} -vf fps=20 -pix_fmt yuv420p $output" - FFmpegKit.executeAsync(command) { - - when { - it.returnCode.isSuccess -> { - Timber.d("FFMPEG command execution completed successfully") - } - it.returnCode.isCancel -> { - Timber.d("Command execution cancelled by user.") - } - else -> { - Timber.d(String.format("Command execution failed with rc=%d and the output below.", it.returnCode.value)) - } - } - -// FFmpeg.executeAsync("-f concat -safe 0 -i $dpath -vb 20M -vsync vfr -s ${width}x${height} -vf fps=20 -pix_fmt yuv420p $output") { id, rc -> -// -// if (rc == RETURN_CODE_SUCCESS) { -// Timber.d("FFMPEG command execution completed successfully") -// } else if (rc == RETURN_CODE_CANCEL) { -// Timber.d("Command execution cancelled by user.") -// } else { -// Timber.d(String.format("Command execution failed with rc=%d and the output below.", rc)) -// } - // Delete descriptor and image files -// tmpDir.delete() -// File(dpath).delete() - - notifyUser(output) - } - - } - async.await() - } - - } - private fun notifyUser(uri: Uri, type: FileType) { val title = getString(R.string.video_available) diff --git a/app/src/main/java/net/pokeranalytics/android/ui/modules/handhistory/replayer/ReplayerAnimator.kt b/app/src/main/java/net/pokeranalytics/android/ui/modules/handhistory/replayer/ReplayerAnimator.kt index 3d7e9a41..15b39fb5 100644 --- a/app/src/main/java/net/pokeranalytics/android/ui/modules/handhistory/replayer/ReplayerAnimator.kt +++ b/app/src/main/java/net/pokeranalytics/android/ui/modules/handhistory/replayer/ReplayerAnimator.kt @@ -592,6 +592,7 @@ class ReplayerAnimator(var handHistory: HandHistory, var export: Boolean) { this.drawer.drawTable(canvas, context) frameHandler(bitmap, vo) + bitmap.recycle() }