From 2d1589babb3eeb8ea754adb0a18441cf3eb3ba68 Mon Sep 17 00:00:00 2001 From: Laurent Date: Wed, 26 Aug 2020 15:45:37 +0200 Subject: [PATCH] Integrate FFMPEG library to manage the video export --- app/build.gradle | 3 + .../replayer/ReplayExportService.kt | 57 ++++++++++++++++- .../handhistory/replayer/ReplayerAnimator.kt | 62 +++++++++++++++++++ .../pokeranalytics/android/util/FileUtils.kt | 33 ++++++++-- .../net/pokeranalytics/android/util/Global.kt | 1 + 5 files changed, 151 insertions(+), 5 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index f0fd2821..36edec79 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -129,6 +129,9 @@ dependencies { // Polynomial Regression implementation 'org.apache.commons:commons-math3:3.6.1' + // ffmpeg for encoding video (HH export) + implementation 'com.arthenica:mobile-ffmpeg-full:4.4.LTS' + // Instrumented Tests androidTestImplementation 'androidx.test:core:1.2.0' androidTestImplementation 'androidx.test:runner:1.2.0' 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 a03a6975..1d58bd9f 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 @@ -7,6 +7,9 @@ import android.os.Binder import android.os.Environment import android.os.IBinder import androidx.core.content.FileProvider +import com.arthenica.mobileffmpeg.Config.RETURN_CODE_CANCEL +import com.arthenica.mobileffmpeg.Config.RETURN_CODE_SUCCESS +import com.arthenica.mobileffmpeg.FFmpeg import io.realm.Realm import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope @@ -16,6 +19,7 @@ import net.pokeranalytics.android.R import net.pokeranalytics.android.exceptions.PAIllegalStateException import net.pokeranalytics.android.model.realm.handhistory.HandHistory import net.pokeranalytics.android.ui.extensions.toByteArray +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 @@ -49,7 +53,7 @@ class ReplayExportService : Service() { fun videoExport(handHistoryId: String) { this@ReplayExportService.handHistoryId = handHistoryId - startVideoExport() + startFFMPEGVideoExport() } fun gifExport(handHistoryId: String) { @@ -115,6 +119,57 @@ class ReplayExportService : Service() { } + private fun startFFMPEGVideoExport() { + + 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.setDimension(width.toFloat(), height.toFloat()) + TableDrawer.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 directory = context.getExternalFilesDir(null) ?: throw PAIllegalStateException("File is invalid") + val output = "${directory.path}/video_${Date().dateTimeFileFormatted}.mp4" + + Environment.getExternalStorageState(tmpDir) + + Timber.d("Assembling images for video...") + FFmpeg.executeAsync("-f concat -safe 0 -i $dpath -vsync vfr -s ${width}x${height} -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)) + } + tmpDir.delete() + + notifyUser(output) + } + + } + async.await() + } + + } + private fun startVideoExport() { GlobalScope.launch(coroutineContext) { 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 b275f9c6..719b0edf 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 @@ -4,14 +4,20 @@ import android.content.Context import android.graphics.Bitmap import android.graphics.Canvas import android.graphics.RectF +import kotlinx.coroutines.delay import net.pokeranalytics.android.exceptions.PAIllegalStateException import net.pokeranalytics.android.model.handhistory.Street import net.pokeranalytics.android.model.realm.handhistory.Action import net.pokeranalytics.android.model.realm.handhistory.HandHistory import net.pokeranalytics.android.ui.modules.handhistory.model.ActionList import net.pokeranalytics.android.ui.modules.handhistory.model.ComputedAction +import net.pokeranalytics.android.util.FFMPEG_DESCRIPTOR_FILE +import net.pokeranalytics.android.util.FileUtils import net.pokeranalytics.android.util.MathUtils +import net.pokeranalytics.android.util.extensions.dateTimeFileFormatted import timber.log.Timber +import java.io.File +import java.util.* class ReplayerAnimator(var handHistory: HandHistory, var export: Boolean) { @@ -469,6 +475,62 @@ class ReplayerAnimator(var handHistory: HandHistory, var export: Boolean) { return false } + /*** + * Generates images and image descriptor to build the video using ffmpeg + * Command line: https://trac.ffmpeg.org/wiki/Slideshow + */ + suspend fun generateVideoContent(context: Context): File { + + var ffmpegImageDescriptor = "" + var count = 0 + var imagePath = "" + + val dirName = Date().dateTimeFileFormatted + val directory = context.getExternalFilesDir("tmp/$dirName") ?: throw PAIllegalStateException("File is invalid") + if (!directory.exists()) { + val mkdirs = directory.mkdirs() + Timber.d("Directories creation = $mkdirs") + } + + (this.currentStepIndex until this.steps.size).forEach { + + (0 until this.frameManager.totalFrames).forEach { _ -> + + val bitmap = Bitmap.createBitmap(this.width.toInt(), this.height.toInt(), Bitmap.Config.ARGB_8888) + val canvas = Canvas(bitmap) + val vo = this.visualOccurences / 90.0 // this is needed before the call to drawTable which pass to the next frame + + TableDrawer.drawTable(this, canvas, context) + + imagePath = File(directory, "img_$count.png").path + + FileUtils.writeBitmapToPNG(bitmap, imagePath) + bitmap.recycle() + + ffmpegImageDescriptor = ffmpegImageDescriptor.plus("file '$imagePath'\n") + ffmpegImageDescriptor = ffmpegImageDescriptor.plus("duration $vo\n") + + count++ + + Thread.sleep(100L) + +// delay(100L) + } + + nextStep() + + } + +// ffmpegImageDescriptor = ffmpegImageDescriptor.plus(imagePath) // adds path one more time due to "quirk", see doc above + + FileUtils.writeToFile(ffmpegImageDescriptor, FFMPEG_DESCRIPTOR_FILE, directory) + + Timber.d("desc = $ffmpegImageDescriptor") + + return directory + + } + /*** * This method creates a bitmap for each step and frame * in order to create a playable video for the whole hand diff --git a/app/src/main/java/net/pokeranalytics/android/util/FileUtils.kt b/app/src/main/java/net/pokeranalytics/android/util/FileUtils.kt index 666bf06b..3c0f1bf5 100644 --- a/app/src/main/java/net/pokeranalytics/android/util/FileUtils.kt +++ b/app/src/main/java/net/pokeranalytics/android/util/FileUtils.kt @@ -1,18 +1,31 @@ package net.pokeranalytics.android.util import android.content.Context +import android.graphics.Bitmap import timber.log.Timber -import java.io.BufferedWriter import java.io.File import java.io.FileOutputStream -import java.io.OutputStreamWriter +import java.io.IOException class FileUtils { - companion object{ + companion object { /*** - * Writes a [string] into a file named [fileName], using a [context] + * Writes some [content] into a file named [fileName] inside [directory] + */ + fun writeToFile(content: String, fileName: String, directory: File) { + + val file = File(directory, fileName) + + val fileOutputStream = FileOutputStream(file) + fileOutputStream.write(content.toByteArray()) + fileOutputStream.close() + } + + /*** + * Writes a [string] into a file named [fileName], inside the Files directory, + * using a [context] * Should be surrounded by a try/catch IOException */ fun writeFileToFilesDir(string: String, fileName: String, context: Context) { @@ -27,6 +40,18 @@ class FileUtils { } + fun writeBitmapToPNG(bitmap: Bitmap, fileName: String) { + + try { + FileOutputStream(fileName).use { out -> + bitmap.compress(Bitmap.CompressFormat.PNG, 100, out) + } + } catch (e: IOException) { + e.printStackTrace() + } + + } + } } \ No newline at end of file diff --git a/app/src/main/java/net/pokeranalytics/android/util/Global.kt b/app/src/main/java/net/pokeranalytics/android/util/Global.kt index e3a56c20..19d486bb 100644 --- a/app/src/main/java/net/pokeranalytics/android/util/Global.kt +++ b/app/src/main/java/net/pokeranalytics/android/util/Global.kt @@ -2,3 +2,4 @@ package net.pokeranalytics.android.util const val NULL_TEXT: String = "--" const val RANDOM_PLAYER: String = "☺︎" +const val FFMPEG_DESCRIPTOR_FILE = "descriptor.txt" \ No newline at end of file