SubwayTooter-Android-App/base/src/main/java/jp/juggler/util/media/MovieUtils.kt

362 lines
14 KiB
Kotlin

package jp.juggler.util.media
import android.content.Context
import android.net.Uri
import androidx.media3.common.MediaItem
import androidx.media3.common.MimeTypes
import androidx.media3.effect.ScaleAndRotateTransformation
import androidx.media3.transformer.Composition
import androidx.media3.transformer.DefaultEncoderFactory
import androidx.media3.transformer.EditedMediaItem
import androidx.media3.transformer.Effects
import androidx.media3.transformer.ExportException
import androidx.media3.transformer.ExportResult
import androidx.media3.transformer.ProgressHolder
import androidx.media3.transformer.Transformer
import androidx.media3.transformer.VideoEncoderSettings
import com.otaliastudios.transcoder.Transcoder
import com.otaliastudios.transcoder.TranscoderListener
import com.otaliastudios.transcoder.common.Size
import com.otaliastudios.transcoder.strategy.DefaultAudioStrategy
import com.otaliastudios.transcoder.strategy.DefaultVideoStrategy
import jp.juggler.util.coroutine.AppDispatchers
import jp.juggler.util.data.clip
import jp.juggler.util.log.LogCategory
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.ClosedReceiveChannelException
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileInputStream
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference
import kotlin.coroutines.resumeWithException
import kotlin.math.ceil
import kotlin.math.max
import kotlin.math.min
import kotlin.math.sqrt
private val log = LogCategory("MovieUtils")
enum class MovieResizeMode(val int: Int) {
Auto(0),
No(1),
Always(2),
;
companion object {
fun fromInt(i: Int) = entries.find { it.int == i } ?: Auto
}
}
data class MovieResizeConfig(
val mode: MovieResizeMode,
val limitFrameRate: Int,
val limitBitrate: Long,
val limitSquarePixels: Int,
) {
// 値を狭めた新しいオブジェクトを返す
fun restrict(
limitFrameRate: Int? = null,
limitBitrate: Long? = null,
limitSquarePixels: Int? = null,
) = MovieResizeConfig(
mode = this.mode,
limitFrameRate = min(
limitFrameRate ?: this.limitFrameRate,
this.limitFrameRate
),
limitBitrate = min(
limitBitrate ?: this.limitBitrate,
this.limitBitrate
),
limitSquarePixels = min(
limitSquarePixels ?: this.limitSquarePixels,
this.limitSquarePixels
),
)
// トランスコードをスキップする判定
fun isTranscodeRequired(info: VideoInfo) = when (mode) {
MovieResizeMode.No -> false
MovieResizeMode.Always -> true
MovieResizeMode.Auto ->
info.squarePixels > limitSquarePixels ||
(info.actualBps ?: 0).toFloat() > limitBitrate.toFloat() * 1.5f ||
(info.frameRatio == null || info.frameRatio < 1f || info.frameRatio > limitFrameRate)
}
}
/**
* レシーバが奇数なら+1した値を返す
*
* `OMX.qcom.video.encoder.avc video encoder does not support odd resolution 1018x2263`
*/
private fun Int.fixOdd() = if (and(1) == 0) this else this + 1
/**
* 動画のピクセルサイズを制限に合わせてスケーリングする
*/
private fun createScaledSize(inSize: Size, limitSquarePixels: Int): Size {
if (inSize.major <= 0 || inSize.minor <= 0) {
// 入力サイズの縦横が0以下の場合、アスペクト比を計算できないのでリサイズできない
log.w("createScaledSize: video size not valid. major=${inSize.major}, minor=${inSize.minor}")
return inSize
}
val squarePixels = inSize.major * inSize.minor
if (squarePixels <= limitSquarePixels) {
return inSize
}
val aspect = inSize.major.toFloat() / inSize.minor.toFloat()
return Size(
max(1f, sqrt(limitSquarePixels.toFloat() * aspect)).toInt().fixOdd(),
max(1f, sqrt(limitSquarePixels.toFloat() / aspect)).toInt().fixOdd(),
)
}
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
suspend fun transcodeVideoMedia3Transformer(
context: Context,
info: VideoInfo,
inFile: File,
outFile: File,
resizeConfig: MovieResizeConfig,
onProgress: (Float) -> Unit,
): File = try {
withContext(AppDispatchers.MainImmediate) {
when (resizeConfig.mode) {
MovieResizeMode.No -> return@withContext inFile
MovieResizeMode.Always -> Unit
MovieResizeMode.Auto -> {
if (!resizeConfig.isTranscodeRequired(info)) {
log.i("transcodeVideoMedia3Transformer: transcode not required.")
return@withContext inFile
}
}
}
val srcMediaItem = MediaItem.fromUri(Uri.fromFile(inFile))
val editedMediaItem = EditedMediaItem.Builder(srcMediaItem).apply {
// 入力のフレームレートが高すぎるなら制限する
if (info.frameRatio == null || info.frameRatio < 1f ||
info.frameRatio > resizeConfig.limitFrameRate
) {
// This should be set for inputs that don't have an implicit frame rate (e.g. images).
// It will be ignored for inputs that do have an implicit frame rate (e.g. video).
setFrameRate(resizeConfig.limitFrameRate)
}
// 入力のピクセルサイズが大きすぎるなら制限する
if (info.size.w > 0 && info.size.h > 0 &&
info.squarePixels > resizeConfig.limitSquarePixels
) {
// 端数やodd補正などによる問題が出なさそうなscale値を計算する
fun calcScale(
srcLongerSide: Int,
aspect: Float,
limitSqPixel: Int,
): Float {
var sqPixel = limitSqPixel
while (true) {
val newW = ceil(sqrt(sqPixel * aspect)).toInt().fixOdd()
val newH = ceil(sqrt(sqPixel / aspect)).toInt().fixOdd()
if (newW * newH <= resizeConfig.limitSquarePixels) {
return max(newW, newH).toFloat().div(srcLongerSide)
}
sqPixel -= srcLongerSide
}
}
val scale = calcScale(
srcLongerSide = max(info.size.w, info.size.h),
aspect = info.size.w.toFloat() / info.size.h.toFloat(),
limitSqPixel = resizeConfig.limitSquarePixels
)
val effects = Effects(
/* audioProcessors */ emptyList(),
/* videoEffects */ listOf(
ScaleAndRotateTransformation.Builder().apply {
setScale(scale, scale)
}.build()
)
)
setEffects(effects)
}
}.build()
// 完了検知
val completed = AtomicBoolean(false)
val error = AtomicReference<Throwable>(null)
val listener = object : Transformer.Listener {
override fun onCompleted(composition: Composition, exportResult: ExportResult) {
super.onCompleted(composition, exportResult)
completed.compareAndSet(false, true)
}
override fun onError(
composition: Composition,
exportResult: ExportResult,
exportException: ExportException,
) {
super.onError(composition, exportResult, exportException)
error.compareAndSet(null, exportException)
}
}
val videoEncoderSettings = VideoEncoderSettings.Builder().apply {
setBitrate(resizeConfig.limitBitrate.clip(100_000L, Int.MAX_VALUE.toLong()).toInt())
}.build()
val encoderFactory = DefaultEncoderFactory.Builder(context).apply {
setRequestedVideoEncoderSettings(videoEncoderSettings)
// missing setRequestedAudioEncoderSettings
}.build()
// 開始
val transformer = Transformer.Builder(context).apply {
setEncoderFactory(encoderFactory)
setAudioMimeType(MimeTypes.AUDIO_AAC)
setVideoMimeType(MimeTypes.VIDEO_H264)
addListener(listener)
}.build()
transformer.start(editedMediaItem, outFile.canonicalPath)
// 完了まで待機しつつ、定期的に進捗コールバックを呼ぶ
val progressHolder = ProgressHolder()
while (!completed.get()) {
error.get()?.let { throw it }
val progress = when (transformer.getProgress(progressHolder)) {
Transformer.PROGRESS_STATE_NOT_STARTED -> 0f
else -> progressHolder.progress.toFloat() / 100f
}
onProgress(progress)
delay(1000L)
}
outFile
}
} catch (ex: Throwable) {
log.w("delete outFile due to error.")
outFile.delete()
throw ex
}
@Suppress("BlockingMethodInNonBlockingContext")
suspend fun transcodeVideo(
info: VideoInfo,
inFile: File,
outFile: File,
resizeConfig: MovieResizeConfig,
onProgress: (Float) -> Unit,
): File = try {
withContext(AppDispatchers.IO) {
if (!resizeConfig.isTranscodeRequired(info)) {
log.i("transcodeVideo: isTranscodeRequired returns false.")
return@withContext inFile
}
when (resizeConfig.mode) {
MovieResizeMode.No -> return@withContext inFile
MovieResizeMode.Always -> Unit
MovieResizeMode.Auto -> {
if (info.squarePixels <= resizeConfig.limitSquarePixels &&
(info.actualBps ?: 0).toFloat() <= resizeConfig.limitBitrate * 1.5f &&
(info.frameRatio?.toInt() ?: 0) <= resizeConfig.limitFrameRate
) {
log.i("transcodeVideo: no need to transcode.")
return@withContext inFile
}
}
}
val resultFile = FileInputStream(inFile).use { inStream ->
// 進捗コールバックの発生頻度が多すぎるので間引く
val progressChannel = Channel<Float>(capacity = Channel.CONFLATED)
val progressSender = launch(AppDispatchers.MainImmediate) {
try {
while (true) {
onProgress(progressChannel.receive())
delay(1000L)
}
} catch (ex: Throwable) {
when (ex) {
is ClosedReceiveChannelException -> log.i("progress closed.")
is CancellationException -> log.i("progress cancelled.")
else -> log.w(ex, "progress error")
}
}
}
try {
suspendCancellableCoroutine { cont ->
// https://github.com/natario1/Transcoder/pull/160
// ワークアラウンドとしてファイルではなくfdを渡す
val future = Transcoder.into(outFile.canonicalPath)
.addDataSource(inStream.fd)
.setVideoTrackStrategy(DefaultVideoStrategy.Builder()
.addResizer { inSize ->
createScaledSize(
inSize = inSize,
limitSquarePixels = resizeConfig.limitSquarePixels
)
}
.frameRate(resizeConfig.limitFrameRate)
.keyFrameInterval(10f)
.bitRate(resizeConfig.limitBitrate)
.build())
.setAudioTrackStrategy(
DefaultAudioStrategy.Builder()
.channels(2)
.sampleRate(44100)
.bitRate(96_000L)
.build()
)
.setListener(object : TranscoderListener {
override fun onTranscodeCanceled() {
log.w("onTranscodeCanceled")
cont.resumeWithException(CancellationException("transcode cancelled."))
}
override fun onTranscodeFailed(exception: Throwable) {
log.w("onTranscodeFailed")
cont.resumeWithException(exception)
}
override fun onTranscodeCompleted(successCode: Int) {
when (successCode) {
Transcoder.SUCCESS_TRANSCODED -> outFile
/* Transcoder.SUCCESS_NOT_NEEDED */ else -> inFile
}.let { cont.resumeWith(Result.success(it)) }
}
override fun onTranscodeProgress(progress: Double) {
val result = progressChannel.trySend(progress.toFloat())
if (!result.isSuccess) {
log.w("trySend $result")
}
}
}).transcode()
cont.invokeOnCancellation { future.cancel(true) }
}
} finally {
progressChannel.close()
progressSender.cancelAndJoin()
}
}
resultFile
}
} catch (ex: Throwable) {
log.w("delete outFile due to error.")
outFile.delete()
throw ex
}