@ -3,13 +3,7 @@ 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
@ -17,14 +11,13 @@ 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.Dispatchers
import kotlinx.coroutines.*
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
@ -32,7 +25,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.Date
import java.util.*
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.CoroutineContext
enum class FileType ( var value : String ) {
enum class FileType ( var value : String ) {
@ -59,7 +52,11 @@ class ReplayExportService : Service() {
fun videoExport ( handHistoryId : String ) {
fun videoExport ( handHistoryId : String ) {
this @ReplayExportService . handHistoryId = handHistoryId
this @ReplayExportService . handHistoryId = handHistoryId
startFFMPEGVideoExport ( )
if ( Build . VERSION . SDK _INT >= Build . VERSION _CODES . Q ) {
startFFMPEGVideoExport ( )
} else {
startFFMPEGVideoExportPreQ ( )
}
}
}
fun gifExport ( handHistoryId : String ) {
fun gifExport ( handHistoryId : String ) {
@ -162,6 +159,7 @@ 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
@ -169,35 +167,60 @@ 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 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 {
File ( dpath ) . delete ( )
createVideoWithMediaMuxer ( animator , context , outputFile , width , height )
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 . 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 ->
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 ( outputF ile. readBytes ( ) )
os ?. write ( f ile. readBytes ( ) )
os ?. close ( )
os ?. close ( )
outputF ile. delete ( ) // delete temp file
f ile. delete ( ) // delete temp file
notifyUser ( uri , FileType . VIDEO _MP4 )
notifyUser ( uri , FileType . VIDEO _MP4 )
@ -208,173 +231,59 @@ 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 )
}
// 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 ,
// private fun startVideoExport() {
trackIndex : Int , endOfStream : Boolean = false , onTrackAdded : ( Int ) -> Unit ) {
//
var localTrackIndex = trackIndex
// GlobalScope.launch(coroutineContext) {
// val c = GlobalScope.async {
while ( true ) {
//
val outputBufferIndex = encoder . dequeueOutputBuffer ( bufferInfo , if ( endOfStream ) 10000 else 0 )
// val realm = Realm.getDefaultInstance()
when {
// val handHistory = realm.findById<HandHistory>(handHistoryId) ?: throw PAIllegalStateException("HandHistory not found, id: $handHistoryId")
outputBufferIndex == MediaCodec . INFO _TRY _AGAIN _LATER -> {
//
if ( ! endOfStream ) break else continue
// val context = this@ReplayExportService
}
//
outputBufferIndex == MediaCodec . INFO _OUTPUT _FORMAT _CHANGED -> {
// val animator = ReplayerAnimator(handHistory, true)
if ( localTrackIndex >= 0 ) {
//
throw RuntimeException ( " Format changed twice " )
// val square = 1024
}
//
localTrackIndex = muxer . addTrack ( encoder . outputFormat )
// val width = square
muxer . start ( )
// val height = square
onTrackAdded ( localTrackIndex )
//
}
// animator.setDimension(width.toFloat(), height.toFloat())
outputBufferIndex >= 0 -> {
// TableDrawer.configurePaints(context, animator)
val outputBuffer = encoder . getOutputBuffer ( outputBufferIndex )
//
if ( outputBuffer != null && bufferInfo . size > 0 && localTrackIndex >= 0 ) {
// val muxer = MMediaMuxer()
muxer . writeSampleData ( localTrackIndex , outputBuffer , bufferInfo )
// muxer.init(null, width, height, "hhVideo", "YES!")
}
//
encoder . releaseOutputBuffer ( outputBufferIndex , false )
// animator.frames(context) { bitmap, count ->
//
if ( bufferInfo . flags and MediaCodec . BUFFER _FLAG _END _OF _STREAM != 0 ) {
// try {
break
// val byteArray = bitmap.toByteArray()
}
// muxer.addFrame(byteArray, count, false)
}
// } catch (e: Exception) {
}
// Timber.e("error = ${e.message}")
}
// }
}
//
// }
private fun convertBitmapToYUV420 ( bitmap : Bitmap , width : Int , height : Int ) : ByteArray {
//
val pixels = IntArray ( width * height )
// realm.close()
bitmap . getPixels ( pixels , 0 , width , 0 , 0 , width , height )
//
// muxer.createVideo { path ->
val yuvSize = width * height * 3 / 2
// notifyUser(path)
val yuv = ByteArray ( yuvSize )
// }
//
var yIndex = 0
// }
var uvIndex = width * height
// c.await()
// }
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 ( ) {
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 ) {
private fun notifyUser ( uri : Uri , type : FileType ) {
val title = getString ( R . string . video _available )
val title = getString ( R . string . video _available )