Added GIF export

bs
Laurent 5 years ago
parent cddec1379c
commit b2c11c266d
  1. 26
      app/src/main/java/net/pokeranalytics/android/ui/modules/handhistory/HandHistoryActivity.kt
  2. 93
      app/src/main/java/net/pokeranalytics/android/ui/modules/handhistory/replayer/ReplayExportService.kt
  3. 1608
      app/src/main/java/net/pokeranalytics/android/util/video/AnimatedGIFWriter.java
  4. 150
      app/src/main/java/net/pokeranalytics/android/util/video/MMediaMuxer.kt
  5. 2
      app/src/main/res/values/strings.xml
  6. 2
      app/standard/release/output.json

@ -143,8 +143,8 @@ class HandHistoryActivity : BaseActivity() {
builder.setItems( builder.setItems(
arrayOf<CharSequence>( arrayOf<CharSequence>(
getString(R.string.text), getString(R.string.text),
getString(R.string.video) getString(R.string.video),
// "GIF" "GIF"
) )
) { _, index -> ) { _, index ->
// The 'which' argument contains the index position // The 'which' argument contains the index position
@ -152,13 +152,24 @@ class HandHistoryActivity : BaseActivity() {
when (index) { when (index) {
0 -> this.textExport() 0 -> this.textExport()
1 -> this.videoExportAskForPermission() 1 -> this.videoExportAskForPermission()
2 -> this.gifExport() 2 -> this.gifExportAskForPermission()
} }
} }
builder.create().show() builder.create().show()
} }
private fun gifExportAskForPermission() {
Toast.makeText(this, R.string.video_export_started, Toast.LENGTH_LONG).show()
askForPermission(arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE), RequestCode.PERMISSION_WRITE_EXTERNAL_STORAGE.value) { granted ->
if (granted) {
gifExport()
}
}
}
private fun videoExportAskForPermission() { private fun videoExportAskForPermission() {
Toast.makeText(this, R.string.video_export_started, Toast.LENGTH_LONG).show() Toast.makeText(this, R.string.video_export_started, Toast.LENGTH_LONG).show()
@ -171,16 +182,17 @@ class HandHistoryActivity : BaseActivity() {
} }
private fun videoExport() { private fun videoExport() {
val handHistoryId = this.handHistory.id val handHistoryId = this.handHistory.id
this.replayExportService?.export(handHistoryId) ?: run { this.replayExportService?.videoExport(handHistoryId) ?: run {
Toast.makeText(this, "Export service not available. Please contact support", Toast.LENGTH_LONG).show() Toast.makeText(this, "Export service not available. Please contact support", Toast.LENGTH_LONG).show()
} }
} }
private fun gifExport() { private fun gifExport() {
val handHistoryId = this.handHistory.id
this.replayExportService?.gifExport(handHistoryId) ?: run {
Toast.makeText(this, "Export service not available. Please contact support", Toast.LENGTH_LONG).show()
}
} }
private fun textExport() { private fun textExport() {

@ -4,6 +4,7 @@ import android.app.PendingIntent
import android.app.Service import android.app.Service
import android.content.Intent import android.content.Intent
import android.os.Binder import android.os.Binder
import android.os.Environment
import android.os.IBinder import android.os.IBinder
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import io.realm.Realm import io.realm.Realm
@ -16,10 +17,14 @@ 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.ui.extensions.toByteArray import net.pokeranalytics.android.ui.extensions.toByteArray
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.findById import net.pokeranalytics.android.util.extensions.findById
import net.pokeranalytics.android.util.video.AnimatedGIFWriter
import net.pokeranalytics.android.util.video.MMediaMuxer import net.pokeranalytics.android.util.video.MMediaMuxer
import timber.log.Timber import timber.log.Timber
import java.io.File import java.io.File
import java.io.FileOutputStream
import java.util.*
import kotlin.coroutines.CoroutineContext import kotlin.coroutines.CoroutineContext
@ -42,12 +47,17 @@ class ReplayExportService : Service() {
} }
fun export(handHistoryId: String) { fun videoExport(handHistoryId: String) {
this@ReplayExportService.handHistoryId = handHistoryId this@ReplayExportService.handHistoryId = handHistoryId
startExport() startVideoExport()
} }
private fun startExport() { fun gifExport(handHistoryId: String) {
this@ReplayExportService.handHistoryId = handHistoryId
startGIFExport()
}
private fun startGIFExport() {
GlobalScope.launch(coroutineContext) { GlobalScope.launch(coroutineContext) {
val c = GlobalScope.async { val c = GlobalScope.async {
@ -59,22 +69,80 @@ class ReplayExportService : Service() {
val animator = ReplayerAnimator(handHistory, true) val animator = ReplayerAnimator(handHistory, true)
val square = 1024f val square = 1024
val width = square val width = square
val height = square val height = square
animator.setDimension(width, height) animator.setDimension(width.toFloat(), height.toFloat())
TableDrawer.configurePaints(context, animator)
val formattedDate = Date().dateTimeFileFormatted
val path = File(
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MOVIES),
"gif_${formattedDate}.gif"
).toString()
val writer = AnimatedGIFWriter(false)
val os = FileOutputStream(path)
writer.prepareForWrite(os, width, height)
var animationCount = 0
animator.frames(context) { bitmap, count ->
when {
count > 10 -> {
writer.writeFrame(os, bitmap, count * 8)
animationCount = 0
}
else -> {
if (animationCount % 2 == 0) {
writer.writeFrame(os, bitmap)
}
animationCount++
}
}
}
writer.finishWrite(os)
realm.close()
notifyUser(path)
}
c.await()
}
}
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) TableDrawer.configurePaints(context, animator)
val muxer = MMediaMuxer() val muxer = MMediaMuxer()
muxer.Init(null, width.toInt(), height.toInt(), "hhVideo", "YES!") muxer.init(null, width, height, "hhVideo", "YES!")
animator.frames(context) { bitmap, count -> animator.frames(context) { bitmap, count ->
try { try {
val byteArray = bitmap.toByteArray() val byteArray = bitmap.toByteArray()
muxer.AddFrame(byteArray, count, false) muxer.addFrame(byteArray, count, false)
} catch (e: Exception) { } catch (e: Exception) {
Timber.e("error = ${e.message}") Timber.e("error = ${e.message}")
} }
@ -83,7 +151,7 @@ class ReplayExportService : Service() {
realm.close() realm.close()
muxer.CreateVideo { path -> muxer.createVideo { path ->
notifyUser(path) notifyUser(path)
} }
@ -95,6 +163,8 @@ class ReplayExportService : Service() {
private fun notifyUser(path: String) { private fun notifyUser(path: String) {
Timber.d("Show local notification")
val title = getString(R.string.video_available) val title = getString(R.string.video_available)
val body = getString(R.string.video_retrieval_message) + ": " + path val body = getString(R.string.video_retrieval_message) + ": " + path
@ -104,8 +174,13 @@ class ReplayExportService : Service() {
File(path) File(path)
) )
val type = when {
path.contains("gif") -> "image/gif"
else -> "video/*"
}
val intent = Intent(Intent.ACTION_VIEW) val intent = Intent(Intent.ACTION_VIEW)
intent.setDataAndType(uri, "video/*") intent.setDataAndType(uri, type)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
val chooser = Intent.createChooser(intent, getString(R.string.open_file_with)) val chooser = Intent.createChooser(intent, getString(R.string.open_file_with))

@ -34,7 +34,7 @@ class MMediaMuxer {
private var _mess: String? = null private var _mess: String? = null
private var completion: ((String) -> (Unit))? = null private var completion: ((String) -> (Unit))? = null
fun Init( fun init(
activity: Activity?, activity: Activity?,
width: Int, width: Int,
height: Int, height: Int,
@ -50,26 +50,26 @@ class MMediaMuxer {
ShowProgressBar() ShowProgressBar()
} }
fun AddFrame(byteFrame: ByteArray) { fun addFrame(byteFrame: ByteArray) {
CheckDataListState() checkDataListState()
Thread(Runnable { Thread(Runnable {
Logd("Android get Frame") Logd("Android get Frame")
val bit = BitmapFactory.decodeByteArray(byteFrame, 0, byteFrame.size) val bitmap = BitmapFactory.decodeByteArray(byteFrame, 0, byteFrame.size)
Logd("Android convert Bitmap") Logd("Android convert Bitmap")
val byteConvertFrame = val byteConvertFrame =
getNV21(bit.width, bit.height, bit) getNV21(bitmap.width, bitmap.height, bitmap)
Logd("Android convert getNV21") Logd("Android convert getNV21")
bitList!!.add(byteConvertFrame) bitList!!.add(byteConvertFrame)
}).start() }).start()
} }
fun AddFrame(byteFrame: ByteArray, count: Int, isLast: Boolean) { fun addFrame(byteFrame: ByteArray, count: Int, isLast: Boolean) {
var bf = byteFrame var bf = byteFrame
CheckDataListState() checkDataListState()
Logd("Android get Frames = $count") Logd("Android get Frames = $count")
val bit = BitmapFactory.decodeByteArray(bf, 0, bf.size) val bitmap = BitmapFactory.decodeByteArray(bf, 0, bf.size)
Logd("Android convert Bitmap") Logd("Android convert Bitmap")
bf = getNV21(bit.width, bit.height, bit) bf = getNV21(bitmap.width, bitmap.height, bitmap)
Logd("Android convert getNV21") Logd("Android convert getNV21")
for (i in 0 until count) { for (i in 0 until count) {
if (isLast) { if (isLast) {
@ -80,7 +80,7 @@ class MMediaMuxer {
} }
} }
fun CreateVideo(handler: (String) -> (Unit)) { fun createVideo(handler: (String) -> (Unit)) {
this.completion = handler this.completion = handler
currentIndexFrame = 0 currentIndexFrame = 0
Logd("Prepare Frames Data") Logd("Prepare Frames Data")
@ -122,26 +122,18 @@ class MMediaMuxer {
private fun bufferEncoder() { private fun bufferEncoder() {
val runnable = Runnable { val runnable = Runnable {
try { try {
Logd( Logd("PrepareEncoder start")
"PrepareEncoder start" prepareEncoder()
) Logd("PrepareEncoder end")
PrepareEncoder()
Logd(
"PrepareEncoder end"
)
} catch (e: IOException) { } catch (e: IOException) {
Loge( Loge(e.message)
e.message
)
} }
try { try {
while (mRunning) { while (mRunning) {
Encode() encode()
} }
} finally { } finally {
Logd( Logd("release")
"release"
)
Release() Release()
HideProgressBar() HideProgressBar()
bitFirst = null bitFirst = null
@ -159,45 +151,41 @@ class MMediaMuxer {
} }
@Throws(IOException::class) @Throws(IOException::class)
private fun PrepareEncoder() { private fun prepareEncoder() {
val codecInfo = // val codecInfo = selectCodec(MIME_TYPE)
selectCodec( // if (codecInfo == null) {
MIME_TYPE // Loge("Unable to find an appropriate codec for $MIME_TYPE")
) // }
if (codecInfo == null) {
Loge("Unable to find an appropriate codec for $MIME_TYPE") val codec = MediaCodec.createEncoderByType(MIME_TYPE)
}
Logd("found codec: " + codecInfo!!.name)
val colorFormat: Int val colorFormat: Int
colorFormat = try { colorFormat = try {
selectColorFormat( selectColorFormat(codec.codecInfo, MIME_TYPE)
codecInfo,
MIME_TYPE
)
} catch (e: Exception) { } catch (e: Exception) {
Timber.d(">>> color format exception: $e")
CodecCapabilities.COLOR_FormatYUV420SemiPlanar CodecCapabilities.COLOR_FormatYUV420SemiPlanar
} }
mediaCodec = MediaCodec.createByCodecName(codecInfo.name)
val mediaFormat = MediaFormat.createVideoFormat( Logd("Selected codec: " + codec.name)
MIME_TYPE, Logd("Selected color format: $colorFormat")
_width,
_height // mediaCodec = MediaCodec.createByCodecName(codecInfo.name)
)
mediaFormat.setInteger(MediaFormat.KEY_BIT_RATE, mediaCodec = codec
BIT_RATE
) val mediaFormat = MediaFormat.createVideoFormat(MIME_TYPE, _width, _height)
mediaFormat.setInteger(MediaFormat.KEY_FRAME_RATE, mediaFormat.setInteger(MediaFormat.KEY_BIT_RATE, BIT_RATE)
FRAME_RATE mediaFormat.setInteger(MediaFormat.KEY_FRAME_RATE, FRAME_RATE)
)
mediaFormat.setInteger(MediaFormat.KEY_COLOR_FORMAT, colorFormat) mediaFormat.setInteger(MediaFormat.KEY_COLOR_FORMAT, colorFormat)
mediaFormat.setInteger( mediaFormat.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, IFRAME_INTERVAL)
MediaFormat.KEY_I_FRAME_INTERVAL,
INFLAME_INTERVAL
)
mediaCodec?.let { mediaCodec?.let {
it.configure(mediaFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE) it.configure(mediaFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
it.start() it.start()
Timber.d("format2: ${it.outputFormat}")
} }
try { try {
val formattedDate = Date().dateTimeFileFormatted val formattedDate = Date().dateTimeFileFormatted
outputPath = File( outputPath = File(
@ -210,7 +198,7 @@ class MMediaMuxer {
} }
} }
private fun Encode() { private fun encode() {
while (true) { while (true) {
if (!mRunning) { if (!mRunning) {
break break
@ -297,12 +285,8 @@ class MMediaMuxer {
return yuv return yuv
} }
private fun encodeYUV420SP( private fun encodeYUV420SP(yuv420sp: ByteArray, argb: IntArray, width: Int, height: Int) {
yuv420sp: ByteArray,
argb: IntArray,
width: Int,
height: Int
) {
val frameSize = width * height val frameSize = width * height
var yIndex = 0 var yIndex = 0
var uvIndex = frameSize var uvIndex = frameSize
@ -337,7 +321,7 @@ class MMediaMuxer {
} }
} }
private fun CheckDataListState() { private fun checkDataListState() {
if (bitList == null) { if (bitList == null) {
bitList = ArrayList() bitList = ArrayList()
} }
@ -354,12 +338,12 @@ class MMediaMuxer {
} }
companion object { companion object {
private const val MIME_TYPE = "video/avc" // H.264 Advanced Video Coding private const val MIME_TYPE = MediaFormat.MIMETYPE_VIDEO_AVC // H.264 Advanced Video Coding
private var _width = 512 private var _width = 512
private var _height = 512 private var _height = 512
private const val BIT_RATE = 800000 private const val BIT_RATE = 2000000 // 800000
private const val INFLAME_INTERVAL = 1 private const val IFRAME_INTERVAL = 1
private const val FRAME_RATE = 50 private const val FRAME_RATE = 30 // 50
// private const val DEBUG = false // private const val DEBUG = false
private const val TAG = "CODEC" private const val TAG = "CODEC"
/** /**
@ -368,19 +352,32 @@ class MMediaMuxer {
*/ */
private fun selectCodec(mimeType: String): MediaCodecInfo? { private fun selectCodec(mimeType: String): MediaCodecInfo? {
val numCodecs = MediaCodecList.getCodecCount() val numCodecs = MediaCodecList.getCodecCount()
val validCodecs = mutableListOf<MediaCodecInfo>()
for (i in 0 until numCodecs) { for (i in 0 until numCodecs) {
val codecInfo = MediaCodecList.getCodecInfoAt(i) val codecInfo = MediaCodecList.getCodecInfoAt(i)
if (!codecInfo.isEncoder) { if (!codecInfo.isEncoder) {
continue continue
} }
val types = codecInfo.supportedTypes val types = codecInfo.supportedTypes
for (j in types.indices) { for (j in types.indices) {
if (types[j].equals(mimeType, ignoreCase = true)) { if (types[j].equals(mimeType, ignoreCase = true)) {
return codecInfo validCodecs.add(codecInfo)
// return codecInfo
} }
} }
} }
return null /**
* OMX.qcom.video.encoder.avc
* OMX.google.h264.encoder
*/
validCodecs.forEach {
Timber.d("VALID CODEC name = ${it.name}")
}
return validCodecs.firstOrNull()
} }
/** /**
@ -388,18 +385,17 @@ class MMediaMuxer {
* code. If no match is found, this throws a test failure -- the set of * code. If no match is found, this throws a test failure -- the set of
* formats known to the test should be expanded for new platforms. * formats known to the test should be expanded for new platforms.
*/ */
private fun selectColorFormat( private fun selectColorFormat(codecInfo: MediaCodecInfo, mimeType: String): Int {
codecInfo: MediaCodecInfo,
mimeType: String val capabilities = codecInfo.getCapabilitiesForType(mimeType)
): Int {
val capabilities = codecInfo capabilities.colorFormats.forEach {
.getCapabilitiesForType(mimeType) Timber.d(">>> Color Format = $it")
}
for (i in capabilities.colorFormats.indices) { for (i in capabilities.colorFormats.indices) {
val colorFormat = capabilities.colorFormats[i] val colorFormat = capabilities.colorFormats[i]
if (isRecognizedFormat( if (isRecognizedFormat(colorFormat)) {
colorFormat
)
) {
return colorFormat return colorFormat
} }
} }

@ -803,6 +803,6 @@
<string name="video_available">Video available!</string> <string name="video_available">Video available!</string>
<string name="video_retrieval_message">Your video has been generated at the following path</string> <string name="video_retrieval_message">Your video has been generated at the following path</string>
<string name="open_file_with">Open file with</string> <string name="open_file_with">Open file with</string>
<string name="video_export_started">We\'ll send you a notification when your video is available. Expect approximately one minute!</string> <string name="video_export_started">We\'ll send you a notification when your file is available. Expect approximately one minute!</string>
</resources> </resources>

@ -1 +1 @@
[{"outputType":{"type":"APK"},"apkData":{"type":"MAIN","splits":[],"versionCode":95,"versionName":"5.0.1","enabled":true,"outputFile":"PokerAnalytics_5.0.1(95)_200805_0932_release.apk","fullName":"standardRelease","baseName":"standard-release","dirName":""},"path":"PokerAnalytics_5.0.1(95)_200805_0932_release.apk","properties":{}}] [{"outputType":{"type":"APK"},"apkData":{"type":"MAIN","splits":[],"versionCode":96,"versionName":"5.0.2","enabled":true,"outputFile":"PokerAnalytics_5.0.2(96)_200813_1136_release.apk","fullName":"standardRelease","baseName":"standard-release","dirName":""},"path":"PokerAnalytics_5.0.2(96)_200813_1136_release.apk","properties":{}}]
Loading…
Cancel
Save