@ -3,7 +3,13 @@ package net.pokeranalytics.android.ui.modules.handhistory.replayer
import android.app.PendingIntent
import android.app.PendingIntent
import android.app.Service
import android.app.Service
import android.content.ContentValues
import android.content.ContentValues
import android.content.Context
import android.content.Intent
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.net.Uri
import android.os.Binder
import android.os.Binder
import android.os.Build
import android.os.Build
@ -11,13 +17,14 @@ import android.os.Environment
import android.os.IBinder
import android.os.IBinder
import android.provider.MediaStore
import android.provider.MediaStore
import androidx.core.content.FileProvider
import androidx.core.content.FileProvider
import com.arthenica.ffmpegkit.FFmpegKit
import io.realm.Realm
import io.realm.Realm
import kotlinx.coroutines.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import net.pokeranalytics.android.R
import net.pokeranalytics.android.R
import net.pokeranalytics.android.exceptions.PAIllegalStateException
import net.pokeranalytics.android.exceptions.PAIllegalStateException
import net.pokeranalytics.android.model.realm.handhistory.HandHistory
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.TriggerNotification
import net.pokeranalytics.android.util.extensions.dateTimeFileFormatted
import net.pokeranalytics.android.util.extensions.dateTimeFileFormatted
import net.pokeranalytics.android.util.extensions.findById
import net.pokeranalytics.android.util.extensions.findById
@ -25,7 +32,7 @@ import net.pokeranalytics.android.util.video.AnimatedGIFWriter
import timber.log.Timber
import timber.log.Timber
import java.io.File
import java.io.File
import java.io.FileOutputStream
import java.io.FileOutputStream
import java.util.*
import java.util.Date
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.CoroutineContext
enum class FileType ( var value : String ) {
enum class FileType ( var value : String ) {
@ -52,11 +59,7 @@ class ReplayExportService : Service() {
fun videoExport ( handHistoryId : String ) {
fun videoExport ( handHistoryId : String ) {
this @ReplayExportService . handHistoryId = handHistoryId
this @ReplayExportService . handHistoryId = handHistoryId
if ( Build . VERSION . SDK _INT >= Build . VERSION _CODES . Q ) {
startFFMPEGVideoExport ( )
startFFMPEGVideoExport ( )
} else {
startFFMPEGVideoExportPreQ ( )
}
}
}
fun gifExport ( handHistoryId : String ) {
fun gifExport ( handHistoryId : String ) {
@ -159,7 +162,6 @@ class ReplayExportService : Service() {
val animator = ReplayerAnimator ( handHistory , true )
val animator = ReplayerAnimator ( handHistory , true )
val square = 1024
val square = 1024
val width = square
val width = square
val height = square
val height = square
@ -167,60 +169,35 @@ class ReplayExportService : Service() {
val drawer = TableDrawer ( )
val drawer = TableDrawer ( )
drawer . configurePaints ( context , animator )
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 formattedDate = Date ( ) . dateTimeFileFormatted
val fileName = " hand_ ${formattedDate} .mp4 "
val fileName = " hand_ ${formattedDate} .mp4 "
val outputDirectory = context . getExternalFilesDir ( Environment . DIRECTORY _MOVIES ) ?: throw PAIllegalStateException ( " File is invalid " )
val outputDirectory = context . getExternalFilesDir ( Environment . DIRECTORY _MOVIES ) ?: throw PAIllegalStateException ( " File is invalid " )
val output = " ${outputDirectory.path} / $fileName "
val outputFile = File ( outputDirectory , fileName )
Timber . d ( " Assembling images for video ..." )
Timber . d ( " Creating video with MediaMuxer ..." )
val command = " -f concat -safe 0 -i $dpath -vb 20M -vsync vfr -s ${width} x ${height} -vf fps=20 -pix_fmt yuv420p $output "
try {
FFmpegKit . executeAsync ( command ) {
createVideoWithMediaMuxer ( animator , context , outputFile , width , height )
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 ) )
}
}
File ( dpath ) . delete ( )
tmpDir . delete ( )
val file = File ( output )
val resolver = applicationContext . contentResolver
val resolver = applicationContext . contentResolver
// Q version tested before calling the function
val videoCollection = MediaStore . Video . Media . getContentUri ( MediaStore . VOLUME _EXTERNAL _PRIMARY )
val videoCollection = MediaStore . Video . Media . getContentUri ( MediaStore . VOLUME _EXTERNAL _PRIMARY )
Timber . d ( " getContentUri = $videoCollection ... " )
val fileDetails = ContentValues ( ) . apply {
val fileDetails = ContentValues ( ) . apply {
Timber . d ( " set file details = $fileName " )
Timber . d ( " set file details = $fileName " )
put ( MediaStore . Video . Media . DISPLAY _NAME , 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 ->
resolver . insert ( videoCollection , fileDetails ) ?. let { uri ->
Timber . d ( " copy file at uri = $uri " )
Timber . d ( " copy file at uri = $uri " )
val os = resolver . openOutputStream ( uri )
val os = resolver . openOutputStream ( uri )
os ?. write ( f ile. readBytes ( ) )
os ?. write ( outputFile . readBytes ( ) )
os ?. close ( )
os ?. close ( )
f ile. delete ( ) // delete temp file
outputF ile. delete ( ) // delete temp file
notifyUser ( uri , FileType . VIDEO _MP4 )
notifyUser ( uri , FileType . VIDEO _MP4 )
@ -231,128 +208,182 @@ class ReplayExportService : Service() {
Timber . w ( " Resolver insert ended without uri... " )
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 ( )
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 )
}
}
// private fun startVideoExport() {
// Create encoder
//
val encoder = MediaCodec . createEncoderByType ( mimeType )
// GlobalScope.launch(coroutineContext) {
encoder . configure ( format , null , null , MediaCodec . CONFIGURE _FLAG _ENCODE )
// val c = GlobalScope.async {
Timber . d ( " Starting encoder... " )
//
encoder . start ( )
// 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 ( ) {
// Create MediaMuxer
val muxer = MediaMuxer ( outputFile . path , MediaMuxer . OutputFormat . MUXER _OUTPUT _MPEG _4 )
var trackIndex = - 1
var muxerStarted = false
GlobalScope . launch ( coroutineContext ) {
val bufferInfo = MediaCodec . BufferInfo ( )
val c = GlobalScope . async {
var frameIndex = 0
val presentationTimeUs = 1000000L / frameRate // Time per frame in microseconds
val realm = Realm . getDefaultInstance ( )
try {
realm . refresh ( )
// Generate frames using animator
Timber . d ( " Generate frames... " )
val handHistory = realm . findById < HandHistory > ( handHistoryId ) ?: throw PAIllegalStateException ( " HandHistory not found, id: $handHistoryId " )
animator . frames ( context ) { bitmap , visualOccurrences ->
// Timber.d(">>> Generated frame, visualOccurrences = $visualOccurrences")
val context = this @ReplayExportService
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 )
}
}
val animator = ReplayerAnimator ( handHistory , true )
// Process output buffers
// Timber.d("drainEncoder...")
val square = 1024
drainEncoder ( encoder , muxer , bufferInfo , trackIndex ) { newTrackIndex ->
trackIndex = newTrackIndex
muxerStarted = true
}
val width = square
frameIndex ++
val height = square
}
}
Timber . d ( " end of frames generation... " )
animator . configure ( width . toFloat ( ) , height . toFloat ( ) , context )
// 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 )
}
val formattedDate = Date ( ) . dateTimeFileFormatted
Timber . d ( " drainEncoder again... " )
val path = File (
Environment . getExternalStoragePublicDirectory ( Environment . DIRECTORY _MOVIES ) ,
" hand_ ${formattedDate} .gif "
) . toString ( )
val writer = AnimatedGIFWriter ( false )
// Drain remaining output
val os = FileOutputStream ( path )
drainEncoder ( encoder , muxer , bufferInfo , trackIndex , true ) { newTrackIndex ->
writer . prepareForWrite ( os , width , height )
if ( ! muxerStarted ) {
trackIndex = newTrackIndex
muxerStarted = true
}
}
val drawer = TableDrawer ( )
} finally {
drawer . configurePaints ( context , animator )
Timber . d ( " stop and release... " )
var animationCount = 0
encoder . stop ( )
animator . frames ( context ) { bitmap , count ->
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 {
when {
count > 10 -> {
outputBufferIndex == MediaCodec . INFO _TRY _AGAIN _LATER -> {
writer . writeFrame ( os , bitmap , count * 8 )
if ( ! endOfStream ) break else continue
animationCount = 0
}
}
else -> {
outputBufferIndex == MediaCodec . INFO _OUTPUT _FORMAT _CHANGED -> {
if ( animationCount % 2 == 0 ) {
if ( localTrackIndex > = 0 ) {
writer . writeFrame ( os , bitmap )
throw RuntimeException ( " Format changed twice " )
}
}
animationCount ++
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
}
}
}
}
}
}
writer . finishWrite ( os )
realm . close ( )
private fun convertBitmapToYUV420 ( bitmap : Bitmap , width : Int , height : Int ) : ByteArray {
notifyUser ( path )
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 ( )
}
}
}
c . await ( )
}
}
return yuv
}
}
private fun startFFMPEGVideoExportPreQ ( ) {
private fun startGIF ExportPreQ ( ) {
GlobalScope . launch ( coroutineContext ) {
GlobalScope . launch ( coroutineContext ) {
val async = GlobalScope . async {
val c = GlobalScope . async {
val realm = Realm . getDefaultInstance ( )
val realm = Realm . getDefaultInstance ( )
realm . refresh ( )
val handHistory = realm . findById < HandHistory > ( handHistoryId ) ?: throw PAIllegalStateException ( " HandHistory not found, id: $handHistoryId " )
val handHistory = realm . findById < HandHistory > ( handHistoryId ) ?: throw PAIllegalStateException ( " HandHistory not found, id: $handHistoryId " )
val context = this @ReplayExportService
val context = this @ReplayExportService
@ -364,59 +395,45 @@ class ReplayExportService : Service() {
val width = square
val width = square
val height = square
val height = square
animator . configure ( width . toFloat ( ) , height . toFloat ( ) , this @ReplayExportService )
animator . configure ( width . toFloat ( ) , height . toFloat ( ) , context )
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 formattedDate = Date ( ) . dateTimeFileFormatted
val output = File (
val path = File (
Environment . getExternalStoragePublicDirectory ( Environment . DIRECTORY _MOVIES ) ,
Environment . getExternalStoragePublicDirectory ( Environment . DIRECTORY _MOVIES ) ,
" hand_ ${formattedDate} .mp4 "
" hand_ ${formattedDate} .gif "
) . path
) . toString ( )
Environment . getExternalStorageState ( tmpDir )
Timber . d ( " Assembling images for video... " )
val writer = AnimatedGIFWriter ( false )
val os = FileOutputStream ( path )
writer . prepareForWrite ( os , width , height )
val drawer = TableDrawer ( )
drawer . configurePaints ( context , animator )
val command = " -f concat -safe 0 -i $dpath -vb 20M -vsync vfr -s ${width} x ${height} -vf fps=20 -pix_fmt yuv420p $output "
var animationCount = 0
FFmpegKit . executeAsync ( command ) {
animator . frames ( context ) { bitmap , count ->
when {
when {
it . returnCode . isSuccess -> {
count > 10 -> {
Timber . d ( " FFMPEG command execution completed successfully " )
writer . writeFrame ( os , bitmap , count * 8 )
}
animationCount = 0
it . returnCode . isCancel -> {
Timber . d ( " Command execution cancelled by user. " )
}
}
else -> {
else -> {
Timber . d ( String . format ( " Command execution failed with rc=%d and the output below. " , it . returnCode . value ) )
if ( animationCount % 2 == 0 ) {
writer . writeFrame ( os , bitmap )
}
animationCount ++
}
}
}
}
// 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 )
}
}
writer . finishWrite ( os )
realm . close ( )
notifyUser ( path )
}
}
async . await ( )
c . await ( )
}
}
}
}