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(
arrayOf<CharSequence>(
getString(R.string.text),
getString(R.string.video)
// "GIF"
getString(R.string.video),
"GIF"
)
) { _, index ->
// The 'which' argument contains the index position
@ -152,13 +152,24 @@ class HandHistoryActivity : BaseActivity() {
when (index) {
0 -> this.textExport()
1 -> this.videoExportAskForPermission()
2 -> this.gifExport()
2 -> this.gifExportAskForPermission()
}
}
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() {
Toast.makeText(this, R.string.video_export_started, Toast.LENGTH_LONG).show()
@ -171,16 +182,17 @@ class HandHistoryActivity : BaseActivity() {
}
private fun videoExport() {
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()
}
}
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() {

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

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

@ -803,6 +803,6 @@
<string name="video_available">Video available!</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="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>

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