オーディオのトランスコード処理を追加。しかしサーバ側問題でうまくない…

This commit is contained in:
tateisu 2023-04-30 18:08:52 +09:00
parent 58b14554a8
commit c5f262112e
4 changed files with 208 additions and 61 deletions

View File

@ -3,6 +3,8 @@ package jp.juggler.subwaytooter.util
import android.content.Context import android.content.Context
import android.graphics.Bitmap import android.graphics.Bitmap
import android.net.Uri import android.net.Uri
import jp.juggler.media.generateTempFile
import jp.juggler.media.transcodeAudio
import jp.juggler.subwaytooter.R import jp.juggler.subwaytooter.R
import jp.juggler.subwaytooter.api.entity.InstanceType import jp.juggler.subwaytooter.api.entity.InstanceType
import jp.juggler.subwaytooter.api.entity.TootInstance import jp.juggler.subwaytooter.api.entity.TootInstance
@ -32,14 +34,26 @@ class AttachmentRequest(
) { ) {
companion object { companion object {
private val log = LogCategory("AttachmentRequest") private val log = LogCategory("AttachmentRequest")
}
fun hasServerSupport(mimeType: String) = private val goodAudioType = setOf(
mediaConfig?.jsonArray("supported_mime_types")?.contains(mimeType) "audio/flac",
?: when (instance.instanceType) { "audio/mp3",
InstanceType.Pixelfed -> AttachmentUploader.acceptableMimeTypesPixelfed "audio/ogg",
else -> AttachmentUploader.acceptableMimeTypes "audio/vnd.wave",
}.contains(mimeType) "audio/vorbis",
"audio/wav",
"audio/wave",
"audio/webm",
"audio/x-pn-wave",
"audio/x-wav",
"audio/3gpp",
)
// val badAudioType = setOf(
// "audio/mpeg","audio/aac",
// "audio/m4a","audio/x-m4a","audio/mp4",
// "video/x-ms-asf",
// )
}
suspend fun createOpener(): InputStreamOpener { suspend fun createOpener(): InputStreamOpener {
@ -122,39 +136,50 @@ class AttachmentRequest(
) )
} }
private fun hasServerSupport(mimeType: String) =
mediaConfig?.jsonArray("supported_mime_types")?.contains(mimeType)
?: when (instance.instanceType) {
InstanceType.Pixelfed -> AttachmentUploader.acceptableMimeTypesPixelfed
else -> AttachmentUploader.acceptableMimeTypes
}.contains(mimeType)
private fun createResizedImageOpener( private fun createResizedImageOpener(
forcePng: Boolean = false, forcePng: Boolean = false,
): InputStreamOpener { ): InputStreamOpener {
val cacheDir = context.externalCacheDir val tempFile = context.generateTempFile("createResizedImageOpener")
?.apply { mkdirs() }
?: error("getExternalCacheDir returns null.")
val outputMimeType = if (forcePng || mimeType == AttachmentUploader.MIME_TYPE_PNG) {
AttachmentUploader.MIME_TYPE_PNG
} else {
AttachmentUploader.MIME_TYPE_JPEG
}
val tempFile = File(cacheDir, "tmp." + Thread.currentThread().id)
val bitmap = createResizedBitmap(
context,
uri,
imageResizeConfig,
skipIfNoNeedToResizeAndRotate = !forcePng,
serverMaxSqPixel = serverMaxSqPixel
) ?: error("createResizedBitmap returns null.")
pa.progress = context.getString(R.string.attachment_handling_compress)
try { try {
FileOutputStream(tempFile).use { os -> pa.progress = context.getString(R.string.attachment_handling_compress)
if (outputMimeType == AttachmentUploader.MIME_TYPE_PNG) {
bitmap.compress(Bitmap.CompressFormat.PNG, 100, os) val bitmap = createResizedBitmap(
} else { context,
bitmap.compress(Bitmap.CompressFormat.JPEG, 95, os) uri,
imageResizeConfig,
skipIfNoNeedToResizeAndRotate = !forcePng,
serverMaxSqPixel = serverMaxSqPixel
) ?: error("createResizedBitmap returns null.")
try {
val outputMimeType = when {
forcePng || mimeType == AttachmentUploader.MIME_TYPE_PNG ->
AttachmentUploader.MIME_TYPE_PNG
else -> AttachmentUploader.MIME_TYPE_JPEG
} }
FileOutputStream(tempFile).use { outStream ->
when (outputMimeType) {
AttachmentUploader.MIME_TYPE_PNG ->
bitmap.compress(Bitmap.CompressFormat.PNG, 100, outStream)
else ->
bitmap.compress(Bitmap.CompressFormat.JPEG, 95, outStream)
}
}
return tempFileOpener(tempFile, outputMimeType, isImage = true)
} finally {
bitmap.recycle()
} }
return tempFileOpener(tempFile, outputMimeType, isImage = true) } catch (ex: Throwable) {
} finally { tempFile.delete()
bitmap.recycle() throw ex
} }
} }
@ -168,13 +193,13 @@ class AttachmentRequest(
val outFile = File(cacheDir, "movie." + Thread.currentThread().id + ".mp4") val outFile = File(cacheDir, "movie." + Thread.currentThread().id + ".mp4")
var resultFile: File? = null var resultFile: File? = null
// 入力ファイルをコピーする
(context.contentResolver.openInputStream(uri)
?: error("openInputStream returns null.")).use { inStream ->
FileOutputStream(tempFile).use { inStream.copyTo(it) }
}
try { try {
// 入力ファイルをコピーする
(context.contentResolver.openInputStream(uri)
?: error("openInputStream returns null.")).use { inStream ->
FileOutputStream(tempFile).use { inStream.copyTo(it) }
}
// 動画のメタデータを調べる // 動画のメタデータを調べる
val info = tempFile.videoInfo val info = tempFile.videoInfo
@ -224,27 +249,34 @@ class AttachmentRequest(
} }
} }
private val aacMimeTypes = listOf( private suspend fun createResizedAudioOpener(srcBytes: Long): InputStreamOpener =
"audio/aac", "audio/aacp", "audio/3gpp", "audio/3gpp2", when {
"audio/mp4", "audio/MP4A-LATM", "audio/mpeg4-generic" hasServerSupport(mimeType) &&
).map { it.lowercase() }.toSet() goodAudioType.contains(mimeType) &&
srcBytes <= maxBytesVideo -> contentUriOpener(
private fun createResizedAudioOpener(
originalBytes: Long,
): InputStreamOpener {
if (hasServerSupport("audio/aac") && aacMimeTypes.contains(mimeType.lowercase())) {
mimeType = "audio/aac"
}
// サーバ側がサポートしてる形式でサイズ以内なら
if (hasServerSupport(mimeType) && originalBytes <= maxBytesVideo) {
return contentUriOpener(
context.contentResolver, context.contentResolver,
uri, uri,
mimeType, mimeType,
isImage = false, isImage = false,
) )
else -> {
pa.progress = context.getString(R.string.attachment_handling_compress)
val (tempFile, outMimeType) = transcodeAudio(
context,
uri,
mimeType
)
// このワークアラウンドはうまくいかなかった
// outMimeType = when (outMimeType) {
// "audio/mp4" -> "audio/x-m4a"
// else -> outMimeType
// }
tempFileOpener(
tempFile,
outMimeType,
isImage = false,
)
}
} }
error("audio conversion is not yet supported.")
}
} }

View File

@ -6,6 +6,7 @@ import android.os.Handler
import android.os.SystemClock import android.os.SystemClock
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.media3.common.MimeTypes
import jp.juggler.subwaytooter.R import jp.juggler.subwaytooter.R
import jp.juggler.subwaytooter.api.TootApiCallback import jp.juggler.subwaytooter.api.TootApiCallback
import jp.juggler.subwaytooter.api.TootApiClient import jp.juggler.subwaytooter.api.TootApiClient
@ -295,15 +296,15 @@ class AttachmentUploader(
val isAccepteble = instance.configuration val isAccepteble = instance.configuration
?.jsonObject("media_attachments") ?.jsonObject("media_attachments")
?.jsonArray("supported_mime_types") ?.jsonArray("supported_mime_types")
?.contains(mimeType) ?.contains(opener.mimeType)
?: when (instance.instanceType) { ?: when (instance.instanceType) {
InstanceType.Pixelfed -> acceptableMimeTypesPixelfed InstanceType.Pixelfed -> acceptableMimeTypesPixelfed
else -> acceptableMimeTypes else -> acceptableMimeTypes
}.contains(mimeType) }.contains(opener.mimeType)
if (!isAccepteble) { if (!isAccepteble) {
return TootApiResult( return TootApiResult(
safeContext.getString(R.string.mime_type_not_acceptable, mimeType) safeContext.getString(R.string.mime_type_not_acceptable, opener.mimeType)
) )
} }

View File

@ -98,7 +98,9 @@ dependencies {
api "org.jetbrains.kotlinx:kotlinx-datetime:0.4.0" api "org.jetbrains.kotlinx:kotlinx-datetime:0.4.0"
api "org.jetbrains.kotlinx:kotlinx-serialization-json:1.4.1" api "org.jetbrains.kotlinx:kotlinx-serialization-json:1.4.1"
api "ru.gildor.coroutines:kotlin-coroutines-okhttp:1.0" api "ru.gildor.coroutines:kotlin-coroutines-okhttp:1.0"
api "androidx.media3:media3-transformer:1.0.1"
api "androidx.media3:media3-effect:1.0.1"
api "androidx.media3:media3-common:1.0.1"
// commons-codecをapiにするとjarが使われてしまう // commons-codecをapiにするとjarが使われてしまう
// declaration of 'org.apache.commons.codec.binary.Base64' appears in /system/framework/org.apache.http.legacy.jar) // declaration of 'org.apache.commons.codec.binary.Base64' appears in /system/framework/org.apache.http.legacy.jar)
androidTestImplementation "commons-codec:commons-codec:1.15" androidTestImplementation "commons-codec:commons-codec:1.15"

View File

@ -0,0 +1,112 @@
package jp.juggler.media
import android.content.Context
import android.net.Uri
import android.os.Looper
import androidx.media3.common.MediaItem
import androidx.media3.common.MimeTypes
import androidx.media3.transformer.TransformationException
import androidx.media3.transformer.TransformationRequest
import androidx.media3.transformer.TransformationResult
import androidx.media3.transformer.Transformer
import jp.juggler.util.log.LogCategory
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
import java.nio.file.Files
import java.nio.file.Paths
import java.util.UUID
import kotlin.coroutines.resumeWithException
private val log = LogCategory("transcodeAudio")
val generateTempFileLock = Any()
fun Context.generateTempFile(prefix: String) =
synchronized(generateTempFileLock) {
val cacheDir = externalCacheDir ?: cacheDir ?: error("missing cacheDir")
cacheDir.mkdirs()
val path = Paths.get(cacheDir.canonicalPath)
if (!Files.isDirectory(path)) error("cacheDir is not directory. $cacheDir")
if (!Files.isWritable(path)) error("cacheDir is not writable. $cacheDir")
// 重複しない一時ファイル名を探す
var tempFile: File
do {
tempFile = File(cacheDir, "$prefix-${UUID.randomUUID()}")
} while (tempFile.exists())
// すぐにファイルを作成する
FileOutputStream(tempFile).use {}
tempFile
}
@OptIn(ExperimentalCoroutinesApi::class)
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
suspend fun transcodeAudio(
context: Context,
inUri: Uri,
inMimeType: String,
): Pair<File, String> {
val inputMediaItem = MediaItem.fromUri(inUri)
// トランスコードに指定するmimeType
val encodeMimeType = MimeTypes.AUDIO_AAC
// MediaStore登録に使うmimeType。 audio/mp4a-latm だと失敗するのホント困る
val storeMimeType = "audio/mp4"
val tmpFile = context.generateTempFile("transcodeAudio")
// Transformerは単一スレッドで処理する要件
val result: TransformationResult = withContext(Dispatchers.Main.immediate) {
val looper = Looper.getMainLooper()
suspendCancellableCoroutine { cont ->
val transformerListener = object : Transformer.Listener {
override fun onTransformationCompleted(
inputMediaItem: MediaItem,
transformationResult: TransformationResult,
) {
log.i("onTransformationCompleted inputMediaItem=$inputMediaItem transformationResult=$transformationResult")
if (cont.isActive) cont.resume(transformationResult) {}
}
override fun onTransformationError(
inputMediaItem: MediaItem,
exception: TransformationException,
) {
log.e(
exception,
"onTransformationError inputMediaItem=$inputMediaItem"
)
if (cont.isActive) cont.resumeWithException(exception)
}
override fun onFallbackApplied(
inputMediaItem: MediaItem,
originalTransformationRequest: TransformationRequest,
fallbackTransformationRequest: TransformationRequest,
) {
log.i("onFallbackApplied inputMediaItem=$inputMediaItem original=$originalTransformationRequest fallback=$fallbackTransformationRequest")
}
}
val transformer = Transformer.Builder(context)
.setLooper(looper)
.setTransformationRequest(
TransformationRequest.Builder()
.setAudioMimeType(encodeMimeType)
.build()
)
.setRemoveVideo(true)
.setRemoveAudio(false)
.addListener(transformerListener)
.build()
transformer.startTransformation(inputMediaItem, tmpFile.canonicalPath)
cont.invokeOnCancellation {
transformer.cancel()
}
}
}
result.run {
log.i("result: durationMs=$durationMs, fileSizeBytes=$fileSizeBytes, averageAudioBitrate=$averageAudioBitrate, averageVideoBitrate=$averageVideoBitrate, videoFrameCount=$videoFrameCount")
}
return Pair(tmpFile, storeMimeType)
}