@ -3,13 +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.graphics.Bitmap
import android.media.MediaCodec
import android.media.MediaCodecInfo
import android.media.MediaFormat
import android.media.MediaMuxer
import android.net.Uri
import android.os.Binder
import android.os.Build
@ -17,14 +11,13 @@ import android.os.Environment
import android.os.IBinder
import android.provider.MediaStore
import androidx.core.content.FileProvider
import com.arthenica.ffmpegkit.FFmpegKit
import io.realm.Realm
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
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
@ -32,7 +25,7 @@ import net.pokeranalytics.android.util.video.AnimatedGIFWriter
import timber.log.Timber
import java.io.File
import java.io.FileOutputStream
import java.util.Date
import java.util.*
import kotlin.coroutines.CoroutineContext
enum class FileType ( var value : String ) {
@ -59,7 +52,11 @@ class ReplayExportService : Service() {
fun videoExport ( handHistoryId : String ) {
this @ReplayExportService . handHistoryId = handHistoryId
startFFMPEGVideoExport ( )
if ( Build . VERSION . SDK _INT >= Build . VERSION _CODES . Q ) {
startFFMPEGVideoExport ( )
} else {
startFFMPEGVideoExportPreQ ( )
}
}
fun gifExport ( handHistoryId : String ) {
@ -162,6 +159,7 @@ class ReplayExportService : Service() {
val animator = ReplayerAnimator ( handHistory , true )
val square = 1024
val width = square
val height = square
@ -169,35 +167,60 @@ 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 outputFile = File ( outputDirectory , fileName )
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 ) {
Timber . d ( " Creating video with MediaMuxer... " )
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 ) )
}
}
try {
createVideoWithMediaMuxer ( animator , context , outputFile , width , height )
File ( dpath ) . delete ( )
tmpDir . delete ( )
val file = File ( output )
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 . Video . Media . MIME _TYPE , FileType . VIDEO _MP4 . value )
put ( MediaStore . Images . 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 ( outputF ile. readBytes ( ) )
os ?. write ( f ile. readBytes ( ) )
os ?. close ( )
outputF ile. delete ( ) // delete temp file
f ile. delete ( ) // delete temp file
notifyUser ( uri , FileType . VIDEO _MP4 )
@ -208,173 +231,59 @@ 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 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 startVideoExport() {
//
// GlobalScope.launch(coroutineContext) {
// val c = 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)
//
// 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 startGIFExportPreQ ( ) {
@ -438,6 +347,80 @@ class ReplayExportService : Service() {
}
private fun startFFMPEGVideoExportPreQ ( ) {
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 . 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 )