Integrate FFMPEG library to manage the video export

bs
Laurent 5 years ago
parent 9aac503fc9
commit 2d1589babb
  1. 3
      app/build.gradle
  2. 57
      app/src/main/java/net/pokeranalytics/android/ui/modules/handhistory/replayer/ReplayExportService.kt
  3. 62
      app/src/main/java/net/pokeranalytics/android/ui/modules/handhistory/replayer/ReplayerAnimator.kt
  4. 33
      app/src/main/java/net/pokeranalytics/android/util/FileUtils.kt
  5. 1
      app/src/main/java/net/pokeranalytics/android/util/Global.kt

@ -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'

@ -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<HandHistory>(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) {

@ -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

@ -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()
}
}
}
}

@ -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"
Loading…
Cancel
Save