diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 25ff13dab6..db63b3973c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -216,6 +216,14 @@ android:exported="false" android:foregroundServiceType="dataSync" /> + + + diff --git a/app/src/main/aidl/eu/kanade/tachiyomi/ui/player/scene/ISceneCommandCallback.aidl b/app/src/main/aidl/eu/kanade/tachiyomi/ui/player/scene/ISceneCommandCallback.aidl new file mode 100644 index 0000000000..f55d26311a --- /dev/null +++ b/app/src/main/aidl/eu/kanade/tachiyomi/ui/player/scene/ISceneCommandCallback.aidl @@ -0,0 +1,5 @@ +package eu.kanade.tachiyomi.ui.player.scene; + +oneway interface ISceneCommandCallback { + void onCompleted(long requestId, boolean success, String output); +} diff --git a/app/src/main/aidl/eu/kanade/tachiyomi/ui/player/scene/ISceneCommandService.aidl b/app/src/main/aidl/eu/kanade/tachiyomi/ui/player/scene/ISceneCommandService.aidl new file mode 100644 index 0000000000..e15c82b88f --- /dev/null +++ b/app/src/main/aidl/eu/kanade/tachiyomi/ui/player/scene/ISceneCommandService.aidl @@ -0,0 +1,14 @@ +package eu.kanade.tachiyomi.ui.player.scene; + +import eu.kanade.tachiyomi.ui.player.scene.ISceneCommandCallback; + +interface ISceneCommandService { + void execute( + long requestId, + int commandType, + in String[] arguments, + ISceneCommandCallback callback + ); + + void cancel(long requestId); +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/App.kt b/app/src/main/java/eu/kanade/tachiyomi/App.kt index 1fa2960cfe..128ae8c4fb 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/App.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/App.kt @@ -65,6 +65,7 @@ import eu.kanade.tachiyomi.di.PreferenceModule import eu.kanade.tachiyomi.di.SYPreferenceModule import eu.kanade.tachiyomi.network.NetworkHelper import eu.kanade.tachiyomi.ui.base.delegate.SecureActivityDelegate +import eu.kanade.tachiyomi.ui.player.scene.SceneCommandProcess import eu.kanade.tachiyomi.util.CrashLogUtil import eu.kanade.tachiyomi.util.system.DeviceUtil import eu.kanade.tachiyomi.util.system.GLUtil @@ -114,6 +115,15 @@ class App : Application(), DefaultLifecycleObserver, SingletonImageLoader.Factor @SuppressLint("LaunchActivityFromNotification") override fun onCreate() { super.onCreate() + if (SceneCommandProcess.isCurrent()) { + if (!LogcatLogger.isInstalled) { + LogcatLogger.install() + } + if (LogcatLogger.loggers.none { it is AndroidLogcatLogger }) { + LogcatLogger.loggers += AndroidLogcatLogger(LogPriority.INFO) + } + return + } patchInjekt() // KMK --> diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/player/AniyomiMPVView.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/player/AniyomiMPVView.kt index 663fa6f833..e743f8dac3 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/player/AniyomiMPVView.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/player/AniyomiMPVView.kt @@ -20,6 +20,7 @@ package eu.kanade.tachiyomi.ui.player import android.content.Context import android.os.Build import android.os.Environment +import android.os.Looper import android.util.AttributeSet import android.view.KeyCharacterMap import android.view.KeyEvent @@ -51,17 +52,44 @@ class AniyomiMPVView(context: Context, attributes: AttributeSet) : BaseMPVView(c var isExiting = false var surfaceReady = false private set + private val playbackLoadGate = SurfacePlaybackLoadGate { url -> + if (isExiting) { + false + } else { + MPVLib.command(arrayOf("loadfile", url, "replace")) + true + } + } override fun surfaceCreated(holder: SurfaceHolder) { super.surfaceCreated(holder) surfaceReady = true + playbackLoadGate.onSurfaceCreated() } override fun surfaceDestroyed(holder: SurfaceHolder) { + playbackLoadGate.onSurfaceDestroyed() surfaceReady = false super.surfaceDestroyed(holder) } + fun loadFileWhenSurfaceReady(url: String) { + if (Looper.myLooper() == Looper.getMainLooper()) { + playbackLoadGate.load(url) + } else { + post { playbackLoadGate.load(url) } + } + } + + fun retryPendingLoad() { + playbackLoadGate.retryPending() + } + + fun destroyPlayer() { + playbackLoadGate.close() + destroy() + } + private fun getPropertyInt(property: String): Int? { return MPVLib.getPropertyInt(property) as Int? } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/player/MpvConfigDirectoryResolver.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/player/MpvConfigDirectoryResolver.kt new file mode 100644 index 0000000000..aec8c7bee6 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/player/MpvConfigDirectoryResolver.kt @@ -0,0 +1,17 @@ +package eu.kanade.tachiyomi.ui.player + +internal fun resolveMpvConfigDirectory( + internalConfigDirectory: String, + useExternalConfigDirectory: Boolean, + externalConfigDirectory: () -> String?, + onExternalFailure: (Exception) -> Unit = {}, +): String { + if (!useExternalConfigDirectory) return internalConfigDirectory + + return try { + externalConfigDirectory()?.takeIf { it.isNotBlank() } ?: internalConfigDirectory + } catch (error: Exception) { + onExternalFailure(error) + internalConfigDirectory + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/player/PictureInPictureGuard.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/player/PictureInPictureGuard.kt new file mode 100644 index 0000000000..53e28f981a --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/player/PictureInPictureGuard.kt @@ -0,0 +1,21 @@ +package eu.kanade.tachiyomi.ui.player + +internal class PictureInPictureGuard( + initiallyAvailable: Boolean, + private val onRejected: (IllegalStateException) -> Unit = {}, +) { + var isAvailable = initiallyAvailable + private set + + fun runIfAvailable(operation: () -> Boolean): Boolean { + if (!isAvailable) return false + + return try { + operation() + } catch (error: IllegalStateException) { + isAvailable = false + onRejected(error) + false + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/player/PlayerActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/player/PlayerActivity.kt index 6fafe056e1..b5d7077bd2 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/player/PlayerActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/player/PlayerActivity.kt @@ -43,6 +43,7 @@ import android.net.Uri import android.os.Build import android.os.Bundle import android.os.Environment +import android.os.Looper import android.util.Rational import android.view.KeyEvent import android.view.View @@ -142,10 +143,19 @@ class PlayerActivity : BaseActivity() { private var restoreAudioFocus: () -> Unit = {} private var pipRect: Rect? = null - val isPipSupportedAndEnabled by lazy { - packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE) && - playerPreferences.enablePip().get() + private val pipGuard by lazy { + PictureInPictureGuard( + initiallyAvailable = packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE) && + playerPreferences.enablePip().get(), + onRejected = { error -> + logcat(LogPriority.WARN, error) { + "Picture-in-picture disabled after framework rejection" + } + }, + ) } + val isPipSupportedAndEnabled: Boolean + get() = pipGuard.isAvailable private var pipReceiver: BroadcastReceiver? = null @@ -396,7 +406,9 @@ class PlayerActivity : BaseActivity() { castManager = castManager, // Pass the castManager instance onBackPress = { if (isPipSupportedAndEnabled && player.paused == false && playerPreferences.pipOnExit().get()) { - enterPictureInPictureMode(createPipParams()) + if (!enterPictureInPictureIfAvailable()) { + finish() + } } else { finish() } @@ -526,7 +538,7 @@ class PlayerActivity : BaseActivity() { MPVLib.removeLogObserver(playerObserver) MPVLib.removeObserver(playerObserver) - player.destroy() + player.destroyPlayer() castManager.cleanup() @@ -575,7 +587,7 @@ class PlayerActivity : BaseActivity() { @SuppressLint("MissingSuperCall") override fun onUserLeaveHint() { if (isPipSupportedAndEnabled && player.paused == false && playerPreferences.pipOnExit().get()) { - enterPictureInPictureMode() + enterPictureInPictureIfAvailable() } super.onUserLeaveHint() } @@ -587,7 +599,9 @@ class PlayerActivity : BaseActivity() { viewModel.panelShown.value == Panels.None && viewModel.dialogShown.value == Dialogs.None ) { - enterPictureInPictureMode() + if (!enterPictureInPictureIfAvailable()) { + super.onBackPressed() + } } } else { super.onBackPressed() @@ -596,7 +610,7 @@ class PlayerActivity : BaseActivity() { override fun onStart() { super.onStart() - setPictureInPictureParams(createPipParams()) + updatePictureInPictureParamsIfAvailable() WindowCompat.setDecorFitsSystemWindows(window, false) window.setFlags( WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS, @@ -643,18 +657,24 @@ class PlayerActivity : BaseActivity() { } private fun loadPlayableUrl(url: String) { - MPVLib.command(arrayOf("loadfile", url, "replace")) + player.loadFileWhenSurfaceReady(url) } private fun setupPlayerMPV() { val logLevel = if (networkPreferences.verboseLogging().get()) "info" else "warn" val internalConfigDir = applicationContext.filesDir.path - val configDir = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && Environment.isExternalStorageManager()) { - storageManager.getMPVConfigDirectory()!!.filePath!! - } else { - internalConfigDir - } + val configDir = resolveMpvConfigDirectory( + internalConfigDirectory = internalConfigDir, + useExternalConfigDirectory = Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && + Environment.isExternalStorageManager(), + externalConfigDirectory = { storageManager.getMPVConfigDirectory()?.filePath }, + onExternalFailure = { error -> + logcat(LogPriority.WARN, error) { + "Failed to resolve external MPV config directory; using internal storage" + } + }, + ) val mpvConfFile = File("$configDir/mpv.conf") advancedPlayerPreferences.mpvConf().get().let { mpvConfFile.writeText(it) } @@ -891,6 +911,7 @@ class PlayerActivity : BaseActivity() { } player.isExiting = false + player.retryPendingLoad() super.onResume() viewModel.currentVolume.update { @@ -963,7 +984,7 @@ class PlayerActivity : BaseActivity() { } runCatching { - setPictureInPictureParams(createPipParams()) + updatePictureInPictureParamsIfAvailable() } } @@ -1024,6 +1045,19 @@ class PlayerActivity : BaseActivity() { } } + internal fun updatePictureInPictureParamsIfAvailable(): Boolean { + return pipGuard.runIfAvailable { + setPictureInPictureParams(createPipParams()) + true + } + } + + internal fun enterPictureInPictureIfAvailable(): Boolean { + return pipGuard.runIfAvailable { + enterPictureInPictureMode(createPipParams()) + } + } + fun createPipParams(): PictureInPictureParams { val builder = PictureInPictureParams.Builder() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { @@ -1066,7 +1100,7 @@ class PlayerActivity : BaseActivity() { pipReceiver = null } } else { - setPictureInPictureParams(createPipParams()) + updatePictureInPictureParamsIfAvailable() viewModel.hideControls() viewModel.hideSeekBar() viewModel.isBrightnessSliderShown.update { false } @@ -1082,7 +1116,7 @@ class PlayerActivity : BaseActivity() { PIP_PREVIOUS -> viewModel.changeEpisode(true) PIP_SKIP -> viewModel.seekBy(10) } - setPictureInPictureParams(createPipParams()) + updatePictureInPictureParamsIfAvailable() } } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { @@ -1342,6 +1376,10 @@ class PlayerActivity : BaseActivity() { } fun setVideo(video: Video?, position: Long? = null) { + if (Looper.myLooper() != Looper.getMainLooper()) { + runOnUiThread { setVideo(video, position) } + return + } if (player.isExiting) return if (video == null) return diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/player/PlayerViewModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/player/PlayerViewModel.kt index 830de880c6..f9d3300a3c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/player/PlayerViewModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/player/PlayerViewModel.kt @@ -1491,7 +1491,7 @@ class PlayerViewModel @JvmOverloads internal constructor( activity.player.paused = true _paused.update { true } runCatching { - activity.setPictureInPictureParams(activity.createPipParams()) + activity.updatePictureInPictureParamsIfAvailable() } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/player/SurfacePlaybackLoadGate.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/player/SurfacePlaybackLoadGate.kt new file mode 100644 index 0000000000..65dcf57796 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/player/SurfacePlaybackLoadGate.kt @@ -0,0 +1,45 @@ +package eu.kanade.tachiyomi.ui.player + +internal class SurfacePlaybackLoadGate( + private val loadNow: (String) -> Boolean, +) { + private var isSurfaceReady = false + private var isClosed = false + private var pendingUrl: String? = null + + fun load(url: String) { + if (isClosed) return + + if (isSurfaceReady && loadNow(url)) { + pendingUrl = null + } else { + pendingUrl = url + } + } + + fun onSurfaceCreated() { + if (isClosed) return + + isSurfaceReady = true + retryPending() + } + + fun retryPending() { + if (isClosed || !isSurfaceReady) return + + val url = pendingUrl ?: return + if (loadNow(url)) { + pendingUrl = null + } + } + + fun onSurfaceDestroyed() { + isSurfaceReady = false + } + + fun close() { + isClosed = true + isSurfaceReady = false + pendingUrl = null + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/PlayerControls.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/PlayerControls.kt index f04d72cb25..017f29c9b1 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/PlayerControls.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/PlayerControls.kt @@ -685,7 +685,7 @@ fun PlayerControls( isPipAvailable = activity.isPipSupportedAndEnabled, onPipClick = { if (!viewModel.isLoadingEpisode.value) { - activity.enterPictureInPictureMode(activity.createPipParams()) + activity.enterPictureInPictureIfAvailable() } }, onAspectClick = { @@ -868,7 +868,7 @@ fun PlayerControls( onDismiss = dismissVideoOcr, ) - PlayerSceneMiningProgressDialog( + PlayerSceneMiningProgressOverlay( progress = sceneMiningProgress, onCancel = viewModel::cancelSceneMiningPreCommit, ) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/PlayerSceneMiningUi.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/PlayerSceneMiningUi.kt index 6ce11160af..906059479f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/PlayerSceneMiningUi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/PlayerSceneMiningUi.kt @@ -1,11 +1,35 @@ package eu.kanade.tachiyomi.ui.player.controls import android.content.Context -import androidx.compose.material3.AlertDialog +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable -import androidx.compose.ui.window.DialogProperties +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp import chimahon.anki.AnkiMediaWarning import eu.kanade.tachiyomi.ui.player.scene.PlayerSceneMiningProgress import eu.kanade.tachiyomi.util.system.toast @@ -13,7 +37,7 @@ import tachiyomi.i18n.kmk.KMR import tachiyomi.presentation.core.i18n.stringResource @Composable -internal fun PlayerSceneMiningProgressDialog( +internal fun PlayerSceneMiningProgressOverlay( progress: PlayerSceneMiningProgress, onCancel: () -> Unit, ) { @@ -23,21 +47,57 @@ internal fun PlayerSceneMiningProgressDialog( PlayerSceneMiningProgress.Preparing -> stringResource(KMR.strings.anki_scene_preparing) PlayerSceneMiningProgress.Committing -> stringResource(KMR.strings.anki_scene_committing) } - AlertDialog( - onDismissRequest = {}, - confirmButton = { + + Box( + modifier = Modifier + .fillMaxSize() + .windowInsetsPadding(WindowInsets.safeDrawing) + .padding(horizontal = 16.dp, vertical = 8.dp), + contentAlignment = Alignment.TopCenter, + ) { + Row( + modifier = Modifier + .widthIn(max = 360.dp) + .background( + color = Color.Black.copy(alpha = 0.82f), + shape = RoundedCornerShape(8.dp), + ) + .pointerInput(Unit) { + detectTapGestures(onTap = {}) + } + .padding(start = 12.dp, end = if (progress.canCancel) 0.dp else 12.dp) + .semantics { liveRegion = LiveRegionMode.Polite }, + verticalAlignment = Alignment.CenterVertically, + ) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + color = Color.White, + strokeWidth = 2.dp, + ) + Text( + text = status, + modifier = Modifier + .weight(1f, fill = false) + .padding(horizontal = 10.dp, vertical = 10.dp), + color = Color.White, + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) if (progress.canCancel) { - TextButton(onClick = onCancel) { - Text(stringResource(KMR.strings.anki_scene_cancel)) + IconButton( + onClick = onCancel, + modifier = Modifier.size(48.dp), + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(KMR.strings.anki_scene_cancel), + tint = Color.White, + ) } } - }, - text = { Text(status) }, - properties = DialogProperties( - dismissOnBackPress = false, - dismissOnClickOutside = false, - ), - ) + } + } } internal fun Context.showPlayerAnkiMediaWarnings(warnings: List) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/player/scene/AndroidSceneCaptureService.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/player/scene/AndroidSceneCaptureService.kt index 899b811ff7..4c986c4a57 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/player/scene/AndroidSceneCaptureService.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/player/scene/AndroidSceneCaptureService.kt @@ -11,6 +11,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.io.File import java.util.UUID +import java.util.concurrent.atomic.AtomicReference internal fun interface SceneCaptureService { suspend fun prepare(request: SceneCaptureRequest): AnkiScreenshotPreparation @@ -21,90 +22,209 @@ internal class AndroidSceneCaptureService private constructor( private val inputAcquirer: SceneInputAcquirer, private val commandExecutor: SceneCommandExecutor, private val validate: (File) -> AnimatedAvifInfo?, - private val av1EncoderName: () -> String?, + private val av1Encoder: (SceneVideoDimensions) -> Av1EncoderSelection?, ) : SceneCaptureService { constructor(context: Context) : this( sceneDirectory = File(context.cacheDir, SCENE_CACHE_DIRECTORY), inputAcquirer = AndroidSceneInputAcquirer(context), - commandExecutor = FfmpegKitSceneCommandExecutor(), + commandExecutor = IsolatedSceneCommandExecutor(context), validate = AnimatedAvifValidator::validate, - av1EncoderName = ::platformAv1EncoderName, + av1Encoder = ::platformAv1Encoder, ) override suspend fun prepare(request: SceneCaptureRequest): AnkiScreenshotPreparation { - val input = request.videoInput ?: return AnkiScreenshotPreparation.Failed(stillFallback = null) - val range = request.resolvedTiming?.animationRange - ?: return AnkiScreenshotPreparation.Failed(stillFallback = null) - val encoderName = av1EncoderName() - if (encoderName.isNullOrBlank()) { + val input = request.videoInput ?: run { + sceneLog { "prepare: videoInput was null" } return AnkiScreenshotPreparation.Failed(stillFallback = null) } - - return withContext(Dispatchers.IO) { - if (!isSafe(input)) { - return@withContext AnkiScreenshotPreparation.Failed(stillFallback = null) + val range = request.resolvedTiming?.animationRange + ?: run { + sceneLog { "prepare: resolvedTiming.animationRange was null" } + return AnkiScreenshotPreparation.Failed(stillFallback = null) } - val lease = inputAcquirer.acquire(input) - ?: return@withContext AnkiScreenshotPreparation.Failed(stillFallback = null) - sceneDirectory.mkdirs() - val output = File(sceneDirectory, "${UUID.randomUUID()}.avif") - val inputCleanup = SceneNativeCleanup(lease::close) - val outputCleanup = SceneNativeCleanup(output::delete) - var transferred = false - try { - val result = commandExecutor.executeFfmpeg( - SceneFfmpegArguments.animatedAvif( + + val undeliveredOutput = AtomicReference() + return try { + val result = withContext(Dispatchers.IO) { + try { + val sourceDimensions = inspectSafeVideo(input) + if (sourceDimensions == null) { + sceneLog { "prepare: input rejected by ffprobe safety check" } + return@withContext AnkiScreenshotPreparation.Failed(stillFallback = null) + } + val encoder = av1Encoder(sourceDimensions) + ?: run { + sceneLog { + "prepare: no usable av1 MediaCodec encoder found for " + + "${sourceDimensions.width}x${sourceDimensions.height}" + } + return@withContext AnkiScreenshotPreparation.Failed(stillFallback = null) + } + sceneLog { + "prepare: starting, encoder=${encoder.name} source=${sourceDimensions.width}x" + + "${sourceDimensions.height} content=${encoder.contentSize.width}x" + + "${encoder.contentSize.height} output=${encoder.outputSize.width}x" + + "${encoder.outputSize.height} range=${range.startSeconds}..${range.endSeconds} " + + "(${range.durationSeconds}s) input=${input.describe()}" + } + prepareOnIo( input = input, - acquiredInputValue = lease.ffmpegValue, range = range, - outputFile = output.absolutePath, - encoderName = encoderName, - tlsCaFile = lease.tlsCaFile, - ), - ) { + encoder = encoder, + undeliveredOutput = undeliveredOutput, + ) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + sceneLog(throwable = e) { "prepare: threw before scene generation" } + AnkiScreenshotPreparation.Failed(stillFallback = null) + } + } + undeliveredOutput.set(null) + result + } finally { + undeliveredOutput.getAndSet(null)?.release() + } + } + + private suspend fun prepareOnIo( + input: SceneVideoInputSpec, + range: SceneTimeRange, + encoder: Av1EncoderSelection, + undeliveredOutput: AtomicReference, + ): AnkiScreenshotPreparation { + sceneDirectory.mkdirs() + val outputBaseName = UUID.randomUUID().toString() + val output = File(sceneDirectory, "$outputBaseName.avif") + val lease = inputAcquirer.acquire(input) + ?: run { + sceneLog { "prepare: could not acquire input lease" } + return AnkiScreenshotPreparation.Failed(stillFallback = null) + } + val encodeArguments = try { + SceneFfmpegArguments.animatedAvifMediaCodec( + input = input, + acquiredInputValue = lease.ffmpegValue, + range = range, + outputFile = output.absolutePath, + encoderName = encoder.name, + contentSize = encoder.contentSize, + outputSize = encoder.outputSize, + tlsCaFile = lease.tlsCaFile, + ) + } catch (e: Exception) { + lease.close() + sceneLog(throwable = e) { "prepare: could not build AV1 encode arguments" } + return AnkiScreenshotPreparation.Failed(stillFallback = null) + } + val inputCleanup = SceneNativeCleanup(lease::close) + val outputCleanup = SceneNativeCleanup(output::delete) + var transferred = false + return try { + val encodeResult = try { + commandExecutor.executeFfmpeg(encodeArguments) { inputCleanup.nativeFinished() outputCleanup.nativeFinished() } - when (result) { - SceneCommandResult.Failed -> { - return@withContext AnkiScreenshotPreparation.Failed(stillFallback = null) - } - is SceneCommandResult.Success -> Unit - } - val info = validate(output) - ?.takeIf { - it.width in 1..MAX_OUTPUT_DIMENSION && - it.height in 1..MAX_OUTPUT_DIMENSION && - it.frameCount in 2..SceneFfmpegArguments.MAX_FRAME_COUNT && - it.totalDurationMillis > 0L - } - ?: return@withContext AnkiScreenshotPreparation.Failed(stillFallback = null) - val animation = AnkiMediaNaming.sceneFileSource(output) - transferred = true - AnkiScreenshotPreparation.Animated( - animation = animation, - stillFallback = null, - ) } catch (e: CancellationException) { throw e - } catch (_: Exception) { - AnkiScreenshotPreparation.Failed(stillFallback = null) - } finally { - inputCleanup.release() - if (!transferred) outputCleanup.release() + } catch (e: Exception) { + inputCleanup.nativeFinished() + outputCleanup.nativeFinished() + throw e + } + inputCleanup.release() + when (encodeResult) { + SceneCommandResult.Failed -> { + sceneLog { "prepare: direct av1_mediacodec AVIF encode failed" } + return AnkiScreenshotPreparation.Failed(stillFallback = null) + } + is SceneCommandResult.Success -> Unit + } + val validated = validate(output) + if (validated == null) { + sceneLog { "prepare: AVIF structure validation failed, ${output.length()} bytes" } + return AnkiScreenshotPreparation.Failed(stillFallback = null) + } + val info = validated + .takeIf { + it.width == encoder.outputSize.width && + it.height == encoder.outputSize.height && + it.frameCount in 2..SceneFfmpegArguments.MAX_FRAME_COUNT && + it.totalDurationMillis > 0L + } + ?: run { + sceneLog { + "prepare: AVIF outside selection, width=${validated.width} " + + "height=${validated.height} expected=${encoder.outputSize.width}x" + + "${encoder.outputSize.height} frameCount=${validated.frameCount} " + + "(need 2..${SceneFfmpegArguments.MAX_FRAME_COUNT}) " + + "durationMs=${validated.totalDurationMillis}" + } + return AnkiScreenshotPreparation.Failed(stillFallback = null) + } + val animation = AnkiMediaNaming.sceneFileSource(output) + val prepared = AnkiScreenshotPreparation.Animated( + animation = animation, + stillFallback = null, + ) + undeliveredOutput.set(outputCleanup) + transferred = true + sceneLog { + "prepare: success, ${info.frameCount} frames ${info.width}x${info.height} " + + "${info.totalDurationMillis}ms ${output.length()} bytes" + } + prepared + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + sceneLog(throwable = e) { "prepare: threw during scene generation" } + return AnkiScreenshotPreparation.Failed(stillFallback = null) + } finally { + inputCleanup.release() + if (!transferred) { + outputCleanup.release() } } } - private suspend fun isSafe(input: SceneVideoInputSpec): Boolean { - val lease = inputAcquirer.acquire(input) ?: return false + private suspend fun inspectSafeVideo(input: SceneVideoInputSpec): SceneVideoDimensions? { + val lease = inputAcquirer.acquire(input) ?: run { + sceneLog { "isSafe: could not acquire input lease for probe" } + return null + } + val arguments = try { + SceneFfmpegArguments.videoProbe(input, lease.ffmpegValue, lease.tlsCaFile) + } catch (e: Exception) { + lease.close() + sceneLog(throwable = e) { "isSafe: could not build ffprobe arguments" } + return null + } val cleanup = SceneNativeCleanup(lease::close) return try { - val result = commandExecutor.executeFfprobe( - SceneFfmpegArguments.videoProbe(input, lease.ffmpegValue, lease.tlsCaFile), - cleanup::nativeFinished, - ) - result is SceneCommandResult.Success && SceneMediaProbe.inspect(result.output) + val result = try { + commandExecutor.executeFfprobe(arguments, cleanup::nativeFinished) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + cleanup.nativeFinished() + throw e + } + when (result) { + SceneCommandResult.Failed -> { + sceneLog { "isSafe: ffprobe failed to run" } + null + } + is SceneCommandResult.Success -> { + // An absent pix_fmt and an HDR rejection both return false, so print the output. + SceneMediaProbe.inspectVideo(result.output).also { inspected -> + if (inspected == null) { + val output = redactSceneLogLine(result.output) + sceneLog { "isSafe: probe rejected input, ffprobe output=<<<$output>>>" } + } + } + } + } } finally { cleanup.release() } @@ -112,25 +232,24 @@ internal class AndroidSceneCaptureService private constructor( internal companion object { private const val SCENE_CACHE_DIRECTORY = "chimahon_scene_capture" - private const val MAX_OUTPUT_DIMENSION = 640 fun forTests( sceneDirectory: File, inputAcquirer: SceneInputAcquirer, commandExecutor: SceneCommandExecutor, validate: (File) -> AnimatedAvifInfo?, - av1EncoderName: () -> String? = { TEST_AV1_ENCODER_NAME }, + av1Encoder: (SceneVideoDimensions) -> Av1EncoderSelection? = ::testAv1Encoder, ): AndroidSceneCaptureService { return AndroidSceneCaptureService( sceneDirectory = sceneDirectory, inputAcquirer = inputAcquirer, commandExecutor = commandExecutor, validate = validate, - av1EncoderName = av1EncoderName, + av1Encoder = av1Encoder, ) } - private fun platformAv1EncoderName(): String? { + private fun platformAv1Encoder(source: SceneVideoDimensions): Av1EncoderSelection? { val mimeTypes = MimeTypeMap.getSingleton() val hasMimeMapping = mimeTypes.getMimeTypeFromExtension("avif") ?.equals("image/avif", ignoreCase = true) == true && @@ -139,36 +258,70 @@ internal class AndroidSceneCaptureService private constructor( if (!hasMimeMapping) return null return runCatching { - MediaCodecList(MediaCodecList.REGULAR_CODECS).codecInfos + val candidates = MediaCodecList(MediaCodecList.REGULAR_CODECS).codecInfos .asSequence() .filter(MediaCodecInfo::isEncoder) .filter { info -> info.supportedTypes.any { it.equals(AV1_MIME_TYPE, ignoreCase = true) } } - .firstOrNull { info -> + .mapNotNull { info -> runCatching { val capabilities = info.getCapabilitiesForType(AV1_MIME_TYPE) - val encoder = capabilities.encoderCapabilities ?: return@runCatching false - val video = capabilities.videoCapabilities ?: return@runCatching false - val supportsYuv420Planar = capabilities.colorFormats.contains( - MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420Planar, - ) - supportsYuv420Planar && - encoder.isBitrateModeSupported( + val encoder = capabilities.encoderCapabilities ?: return@runCatching null + val video = capabilities.videoCapabilities ?: return@runCatching null + Av1EncoderCandidate( + name = info.name, + supportsPlanarYuv420 = capabilities.colorFormats.contains( + MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420Planar, + ), + supportsConstantQuality = encoder.isBitrateModeSupported( MediaCodecInfo.EncoderCapabilities.BITRATE_MODE_CQ, - ) && - encoder.qualityRange.contains(MEDIACODEC_QUALITY) && - video.areSizeAndRateSupported( - MAX_OUTPUT_DIMENSION, - MAX_OUTPUT_DIMENSION, - SceneFfmpegArguments.FRAME_RATE, - ) - }.getOrDefault(false) + ), + supportsTargetQuality = encoder.qualityRange.contains(MEDIACODEC_QUALITY), + widthAlignment = video.widthAlignment, + heightAlignment = video.heightAlignment, + minimumWidth = video.supportedWidths.lower, + minimumHeight = video.supportedHeights.lower, + maximumWidth = video.supportedWidths.upper, + maximumHeight = video.supportedHeights.upper, + supportedWidthsForHeight = { height -> + runCatching { + video.getSupportedWidthsFor(height) + }.getOrNull()?.let { range -> + range.lower..range.upper + } + }, + supportsSizeAndRate = { size, rate -> + video.areSizeAndRateSupported(size.width, size.height, rate) + }, + ) + }.getOrNull() } - ?.name + selectAv1Encoder( + source = source, + candidates = candidates, + frameRate = SceneFfmpegArguments.FRAME_RATE, + ) }.getOrNull() } + private fun testAv1Encoder(source: SceneVideoDimensions): Av1EncoderSelection? { + return selectAv1Encoder( + source = source, + candidates = sequenceOf( + Av1EncoderCandidate( + name = TEST_AV1_ENCODER_NAME, + supportsPlanarYuv420 = true, + supportsConstantQuality = true, + supportsTargetQuality = true, + widthAlignment = SCENE_PIXEL_ALIGNMENT, + heightAlignment = SCENE_PIXEL_ALIGNMENT, + supportsSizeAndRate = { _, _ -> true }, + ), + ), + ) + } + internal const val TEST_AV1_ENCODER_NAME = "test.av1.encoder" private const val AV1_MIME_TYPE = "video/av01" private const val MEDIACODEC_QUALITY = 35 diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/player/scene/AndroidSceneInputAcquirer.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/player/scene/AndroidSceneInputAcquirer.kt index 949fd2391b..cb941a1ef3 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/player/scene/AndroidSceneInputAcquirer.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/player/scene/AndroidSceneInputAcquirer.kt @@ -40,16 +40,23 @@ internal class AndroidSceneInputAcquirer( }.getOrNull() } + /** + * FFmpeg must not be handed a `/proc/self/fd/N` path for a SAF document. Although FFmpegKit + * runs in this process and so shares the descriptor table, opening that symlink by path + * re-resolves to the real file and re-checks permissions against it. Shared storage is + * FUSE-backed and `media_rw`-owned, and the SAF grant attaches to the descriptor rather than + * to the path, so the reopen fails with `EACCES` and the probe rejects a perfectly good file. + * + * FFmpegKit's `saf:` protocol exists for this: it retains the [Uri] and opens the descriptor + * from inside the native handler, so the grant still applies. The URI is encoded here and + * registered only inside the dedicated FFmpegKit process. + */ private fun acquireContentUri(value: String): SceneInputLease? { - val descriptor = runCatching { - applicationContext.contentResolver.openFileDescriptor(Uri.parse(value), "r") - }.getOrNull() ?: return null - return object : SceneInputLease { - override val ffmpegValue = "/proc/self/fd/${descriptor.fd}" - override val tlsCaFile: String? = null - - override fun close() = descriptor.close() + val uri = runCatching { Uri.parse(value) }.getOrNull() ?: run { + sceneLog { "acquire: could not parse content uri" } + return null } + return acquired(SceneSafInput.encodeForRead(uri)) } private fun acquired( diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/player/scene/AnimatedAvifValidator.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/player/scene/AnimatedAvifValidator.kt index 6fba2971f0..7ce4fe65bd 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/player/scene/AnimatedAvifValidator.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/player/scene/AnimatedAvifValidator.kt @@ -74,6 +74,12 @@ internal object AnimatedAvifValidator { val (width, height) = parseSampleDescription(sampleBoxes.only("stsd") ?: return null) ?: return null val timing = parseTiming(sampleBoxes.only("stts") ?: return null) ?: return null val sizes = parseSizes(sampleBoxes.only("stsz") ?: return null) ?: return null + val syncTables = sampleBoxes.filter { it.type == "stss" } + if (syncTables.size > 1 || + syncTables.singleOrNull()?.let { !hasFirstSyncSample(it, timing.frames) } == true + ) { + return null + } return Track( width = width, height = height, @@ -155,6 +161,23 @@ internal object AnimatedAvifValidator { return Sizes(frames.toInt(), total) } + private fun hasFirstSyncSample(box: Box, frames: Int): Boolean { + if (box.dataSize < 12 || u32(box.start) != 0L) return false + val entries = u32(box.start + 4) + if (entries !in 1..frames.toLong() || box.dataSize.toLong() != 8L + entries * 4L) { + return false + } + var previous = 0L + var offset = box.start + 8 + repeat(entries.toInt()) { + val sample = u32(offset) + if (sample <= previous || sample > frames) return false + previous = sample + offset += 4 + } + return u32(box.start + 8) == 1L + } + private fun boxes(start: Int, end: Int): List? { if (start !in 0..end || end > bytes.size) return null val result = mutableListOf() diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/player/scene/FfmpegKitSceneCommandExecutor.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/player/scene/FfmpegKitSceneCommandExecutor.kt index 3bc5f60bcd..e82ac9b450 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/player/scene/FfmpegKitSceneCommandExecutor.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/player/scene/FfmpegKitSceneCommandExecutor.kt @@ -3,10 +3,13 @@ package eu.kanade.tachiyomi.ui.player.scene import com.arthenica.ffmpegkit.FFmpegKitConfig import com.arthenica.ffmpegkit.FFmpegSession import com.arthenica.ffmpegkit.FFprobeSession +import com.arthenica.ffmpegkit.Level import com.arthenica.ffmpegkit.LogCallback import com.arthenica.ffmpegkit.LogRedirectionStrategy import com.arthenica.ffmpegkit.ReturnCode +import com.arthenica.ffmpegkit.Session import com.arthenica.ffmpegkit.StatisticsCallback +import eu.kanade.tachiyomi.data.animedownload.buildFFmpegFailureMessage import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.suspendCancellableCoroutine import java.util.concurrent.atomic.AtomicBoolean @@ -37,25 +40,53 @@ internal interface SceneCommandExecutor { internal class SceneNativeCleanup( private val cleanup: () -> Unit, ) { - private val nativeFinished = AtomicBoolean(false) - private val released = AtomicBoolean(false) - private val cleaned = AtomicBoolean(false) + private val lock = Any() + private val initialNativeFinished = AtomicBoolean(false) + private var activeNativeUses = 1 + private var released = false + private var cleaned = false fun nativeFinished() { - nativeFinished.set(true) - cleanIfReady() + finishNativeUse(initialNativeFinished) + } + + fun retainNativeUse(): () -> Unit { + synchronized(lock) { + check(!released) { "Cannot retain a released native resource" } + activeNativeUses++ + } + val finished = AtomicBoolean(false) + return { + finishNativeUse(finished) + } } fun release() { - released.set(true) - cleanIfReady() + val shouldClean = synchronized(lock) { + released = true + markCleanIfReady() + } + if (shouldClean) runCatching(cleanup) } - private fun cleanIfReady() { - if (nativeFinished.get() && released.get() && cleaned.compareAndSet(false, true)) { - runCatching(cleanup) + private fun finishNativeUse(finished: AtomicBoolean) { + if (finished.compareAndSet(false, true)) { + val shouldClean = synchronized(lock) { + check(activeNativeUses > 0) + activeNativeUses-- + markCleanIfReady() + } + if (shouldClean) runCatching(cleanup) } } + + private fun markCleanIfReady(): Boolean { + if (activeNativeUses == 0 && released && !cleaned) { + cleaned = true + return true + } + return false + } } internal class FfmpegKitSceneCommandExecutor : SceneCommandExecutor { @@ -68,7 +99,7 @@ internal class FfmpegKitSceneCommandExecutor : SceneCommandExecutor { FFmpegSession.create( arguments, {}, - DISCARD_LOG_CALLBACK, + SCENE_LOG_CALLBACK, DISCARD_STATISTICS_CALLBACK, LogRedirectionStrategy.NEVER_PRINT_LOGS, ) @@ -79,6 +110,7 @@ internal class FfmpegKitSceneCommandExecutor : SceneCommandExecutor { if (ReturnCode.isSuccess(session.returnCode)) { SceneCommandResult.Success() } else { + sceneLog { "ffmpeg: ${session.describeFailure()}" } SceneCommandResult.Failed } }, @@ -95,7 +127,7 @@ internal class FfmpegKitSceneCommandExecutor : SceneCommandExecutor { FFprobeSession.create( arguments, {}, - DISCARD_LOG_CALLBACK, + SCENE_LOG_CALLBACK, LogRedirectionStrategy.NEVER_PRINT_LOGS, ) }, @@ -105,6 +137,7 @@ internal class FfmpegKitSceneCommandExecutor : SceneCommandExecutor { if (ReturnCode.isSuccess(session.returnCode)) { SceneCommandResult.Success(session.output.orEmpty()) } else { + sceneLog { "ffprobe: ${session.describeFailure()}" } SceneCommandResult.Failed } }, @@ -201,7 +234,28 @@ internal class FfmpegKitSceneCommandExecutor : SceneCommandExecutor { } private companion object { - val DISCARD_LOG_CALLBACK = LogCallback {} val DISCARD_STATISTICS_CALLBACK = StatisticsCallback {} + + /** + * FFmpegKit hands every line to the session callback before consulting the redirection + * strategy, so [LogRedirectionStrategy.NEVER_PRINT_LOGS] still yields the full output here + * while suppressing FFmpegKit's own unredacted logcat writes. + */ + val SCENE_LOG_CALLBACK = LogCallback { log -> + if (log.level.value <= Level.AV_LOG_WARNING.value) { + log.message?.takeIf(String::isNotBlank)?.let { message -> + sceneLog { "ffmpeg output: ${redactSceneLogLine(message)}" } + } + } + } + + fun Session.describeFailure(): String { + val message = buildFFmpegFailureMessage( + exitCode = returnCode?.toString() ?: "", + failStackTrace = failStackTrace, + logs = allLogsAsString, + ) + return redactSceneLogLine(message) + } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/player/scene/IsolatedSceneCommandExecutor.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/player/scene/IsolatedSceneCommandExecutor.kt new file mode 100644 index 0000000000..499a2f7861 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/player/scene/IsolatedSceneCommandExecutor.kt @@ -0,0 +1,226 @@ +package eu.kanade.tachiyomi.ui.player.scene + +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.ServiceConnection +import android.os.IBinder +import kotlinx.coroutines.suspendCancellableCoroutine +import java.util.concurrent.atomic.AtomicLong +import kotlin.coroutines.resume + +/** + * Runs FFmpegKit in a dedicated process (`:scene_processing`). + * + * NOTE: an earlier version of this comment claimed the process split was required to avoid a + * duplicate-SONAME linker conflict between `aniyomi-mpv-lib` and `ffmpeg-kit`. That is not correct. + * `aniyomi-mpv-lib`'s AAR ships NO `libav*.so`; its `libmpv.so` DT_NEEDEDs `libavcodec.so`, + * `libavformat.so`, `libavutil.so`, etc., and `ffmpeg-kit` is the sole provider of those SONAMEs. + * Both consumers therefore share the one FFmpeg build already present in the process -- there is no + * competing second implementation and no ABI-level collision. Consistently, this app also invokes + * FFmpegKit in the main process from [eu.kanade.tachiyomi.data.animedownload.AnimeDownloader] and + * [eu.kanade.tachiyomi.util.storage.FFmpegUtils] without any such conflict. + * + * The remaining defensible reasons for a separate process are unproven here: isolating FFmpegKit's + * process-global state (log/statistics callbacks, session registry) from a live libmpv, and + * containing native crashes in the media path by allowing Android to terminate only the worker + * process. Neither is backed by a reproduction, so retain the process boundary pending explicit + * crash and process-global-state testing. A future change may fold scene mining back into the main + * process without a linker conflict, but a native crash there would terminate the whole app. + */ +internal class IsolatedSceneCommandExecutor( + context: Context, +) : SceneCommandExecutor { + private val applicationContext = context.applicationContext + + override suspend fun executeFfmpeg( + arguments: Array, + onNativeFinished: () -> Unit, + ): SceneCommandResult { + return execute(COMMAND_FFMPEG, arguments, onNativeFinished) + } + + override suspend fun executeFfprobe( + arguments: Array, + onNativeFinished: () -> Unit, + ): SceneCommandResult { + return execute(COMMAND_FFPROBE, arguments, onNativeFinished) + } + + private suspend fun execute( + commandType: Int, + arguments: Array, + onNativeFinished: () -> Unit, + ): SceneCommandResult { + return suspendCancellableCoroutine { continuation -> + val call = CommandCall( + context = applicationContext, + requestId = NEXT_REQUEST_ID.getAndIncrement(), + commandType = commandType, + arguments = arguments.copyOf(), + onNativeFinished = onNativeFinished, + deliver = { result -> + if (continuation.isActive) { + continuation.resume(result) + } + }, + ) + continuation.invokeOnCancellation { call.cancel() } + call.bind() + } + } + + private class CommandCall( + private val context: Context, + private val requestId: Long, + private val commandType: Int, + private val arguments: Array, + private val onNativeFinished: () -> Unit, + private val deliver: (SceneCommandResult) -> Unit, + ) { + private val lock = Any() + private var remote: ISceneCommandService? = null + private var bound = false + private var submitted = false + private var finished = false + + private val callback = object : ISceneCommandCallback.Stub() { + override fun onCompleted( + completedRequestId: Long, + success: Boolean, + output: String?, + ) { + if (completedRequestId != requestId) return + complete( + if (success) { + SceneCommandResult.Success(output.orEmpty()) + } else { + SceneCommandResult.Failed + }, + ) + } + } + + private val connection = object : ServiceConnection { + override fun onServiceConnected(name: ComponentName?, service: IBinder?) { + val commandService = ISceneCommandService.Stub.asInterface(service) + ?: run { + complete(SceneCommandResult.Failed) + return + } + val submittedSuccessfully = synchronized(lock) { + if (finished) { + false + } else { + remote = commandService + try { + commandService.execute(requestId, commandType, arguments, callback) + submitted = true + true + } catch (_: Exception) { + false + } + } + } + if (!submittedSuccessfully) { + complete(SceneCommandResult.Failed) + } + } + + override fun onServiceDisconnected(name: ComponentName?) { + complete(SceneCommandResult.Failed) + } + + override fun onBindingDied(name: ComponentName?) { + complete(SceneCommandResult.Failed) + } + + override fun onNullBinding(name: ComponentName?) { + complete(SceneCommandResult.Failed) + } + } + + fun bind() { + val didBind = runCatching { + context.bindService( + Intent(context, IsolatedSceneCommandService::class.java), + connection, + Context.BIND_AUTO_CREATE or Context.BIND_IMPORTANT, + ) + }.getOrDefault(false) + val shouldUnbind = synchronized(lock) { + bound = didBind + didBind && finished + } + if (shouldUnbind) { + synchronized(lock) { + bound = false + } + runCatching { context.unbindService(connection) } + } else if (!didBind) { + complete(SceneCommandResult.Failed) + } + } + + fun cancel() { + val action = synchronized(lock) { + when { + finished -> CancelAction.None + submitted -> CancelAction.Remote(remote) + else -> { + finished = true + CancelAction.Local + } + } + } + when (action) { + CancelAction.None -> Unit + CancelAction.Local -> finish(SceneCommandResult.Failed) + is CancelAction.Remote -> { + try { + action.service?.cancel(requestId) + } catch (_: Exception) { + complete(SceneCommandResult.Failed) + } + } + } + } + + private fun complete(result: SceneCommandResult) { + val shouldFinish = synchronized(lock) { + if (finished) { + false + } else { + finished = true + true + } + } + if (shouldFinish) finish(result) + } + + private fun finish(result: SceneCommandResult) { + runCatching(onNativeFinished) + deliver(result) + val shouldUnbind = synchronized(lock) { + remote = null + bound.also { bound = false } + } + if (shouldUnbind) { + runCatching { context.unbindService(connection) } + } + } + + private sealed interface CancelAction { + data object None : CancelAction + data object Local : CancelAction + data class Remote(val service: ISceneCommandService?) : CancelAction + } + } + + internal companion object { + const val COMMAND_FFMPEG = 1 + const val COMMAND_FFPROBE = 2 + + private val NEXT_REQUEST_ID = AtomicLong(1L) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/player/scene/IsolatedSceneCommandService.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/player/scene/IsolatedSceneCommandService.kt new file mode 100644 index 0000000000..1ded28de4f --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/player/scene/IsolatedSceneCommandService.kt @@ -0,0 +1,161 @@ +package eu.kanade.tachiyomi.ui.player.scene + +import android.app.Service +import android.content.Intent +import android.os.IBinder +import android.os.Process +import com.arthenica.ffmpegkit.FFmpegKitConfig +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.util.concurrent.ConcurrentHashMap + +class IsolatedSceneCommandService : Service() { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val jobs = ConcurrentHashMap() + private val executor by lazy(LazyThreadSafetyMode.NONE) { + FfmpegKitSceneCommandExecutor() + } + + private val binder = object : ISceneCommandService.Stub() { + override fun execute( + requestId: Long, + commandType: Int, + arguments: Array?, + callback: ISceneCommandCallback?, + ) { + if (arguments == null || callback == null) return + val copiedArguments = Array(arguments.size) { arguments[it] } + sceneLog { + "execute: accepted request=$requestId type=$commandType pid=${Process.myPid()}" + } + val job = scope.launch(start = CoroutineStart.LAZY) { + try { + val result = try { + executeAndAwaitNative(commandType, copiedArguments) + } catch (_: CancellationException) { + SceneCommandResult.Failed + } catch (_: Exception) { + SceneCommandResult.Failed + } + withContext(NonCancellable) { + sceneLog { + "execute: completed request=$requestId success=" + + "${result is SceneCommandResult.Success} pid=${Process.myPid()}" + } + deliverResult(requestId, result, callback) + } + } finally { + jobs.remove(requestId) + } + } + // Request IDs come from a single AtomicLong in the one main-process executor, so they + // are unique for this service process's lifetime and cannot collide here. + jobs[requestId] = job + job.start() + } + + override fun cancel(requestId: Long) { + jobs[requestId]?.cancel() + } + } + + /** + * Delivers the result over the (oneway) callback, capping the payload well under Binder's ~1 MB + * transaction limit. An oversized successful output is reported as a failure with a distinct log + * line so it is not silently mistaken for a genuine ffmpeg/ffprobe failure. + */ + private fun deliverResult( + requestId: Long, + result: SceneCommandResult, + callback: ISceneCommandCallback, + ) { + val output = (result as? SceneCommandResult.Success)?.output + val (success, payload) = when { + output == null -> false to "" + output.length <= MAX_SCENE_CALLBACK_OUTPUT_CHARS -> true to output + else -> { + sceneLog { "execute: dropping oversized output request=$requestId chars=${output.length}" } + false to "" + } + } + runCatching { callback.onCompleted(requestId, success, payload) } + } + + override fun onCreate() { + super.onCreate() + sceneLog { "onCreate: isolated FFmpeg process pid=${Process.myPid()}" } + } + + override fun onBind(intent: Intent?): IBinder = binder + + override fun onDestroy() { + scope.cancel() + super.onDestroy() + } + + private suspend fun executeAndAwaitNative( + commandType: Int, + arguments: Array, + ): SceneCommandResult { + val nativeFinished = CompletableDeferred() + val resolvedArguments = runCatching { resolveSafArguments(arguments) }.getOrNull() ?: run { + nativeFinished.complete(Unit) + return SceneCommandResult.Failed + } + var result: SceneCommandResult = SceneCommandResult.Failed + try { + result = when (commandType) { + IsolatedSceneCommandExecutor.COMMAND_FFMPEG -> { + executor.executeFfmpeg(resolvedArguments) { + nativeFinished.complete(Unit) + } + } + IsolatedSceneCommandExecutor.COMMAND_FFPROBE -> { + executor.executeFfprobe(resolvedArguments) { + nativeFinished.complete(Unit) + } + } + else -> { + nativeFinished.complete(Unit) + SceneCommandResult.Failed + } + } + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + result = SceneCommandResult.Failed + } finally { + withContext(NonCancellable) { + nativeFinished.await() + } + } + return result + } + + private fun resolveSafArguments(arguments: Array): Array? { + return Array(arguments.size) { index -> + val value = arguments[index] + if (!SceneSafInput.isReadToken(value)) { + value + } else { + val uri = SceneSafInput.decodeForRead(value) ?: return null + FFmpegKitConfig.getSafParameterForRead(applicationContext, uri) + ?.takeIf(String::isNotBlank) + ?: return null + } + } + } + + private companion object { + const val MAX_SCENE_CALLBACK_OUTPUT_CHARS = 128 * 1024 + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/player/scene/PlayerSceneMiningCoordinator.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/player/scene/PlayerSceneMiningCoordinator.kt index 5cad5e729f..0c9536d7d1 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/player/scene/PlayerSceneMiningCoordinator.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/player/scene/PlayerSceneMiningCoordinator.kt @@ -26,7 +26,10 @@ internal fun interface SceneStillFallbackEncoder { internal object AndroidSceneStillFallbackEncoder : SceneStillFallbackEncoder { override suspend fun encode(request: SceneCaptureRequest): AnkiMediaSource.Bytes? { - val bitmap = request.fallbackBitmapOrNull() ?: return null + val bitmap = request.fallbackBitmapOrNull() ?: run { + sceneLog { "stillFallback: no bitmap available" } + return null + } return withContext(Dispatchers.Default) { try { val bytes = ImageEncoder.encode(bitmap).bytes.takeIf(ByteArray::isNotEmpty) ?: return@withContext null @@ -40,7 +43,8 @@ internal object AndroidSceneStillFallbackEncoder : SceneStillFallbackEncoder { ) } catch (e: CancellationException) { throw e - } catch (_: Exception) { + } catch (e: Exception) { + sceneLog(throwable = e) { "stillFallback: encode threw" } null } } @@ -120,6 +124,7 @@ internal class PlayerSceneMiningCoordinator( request: SceneCaptureRequest, mode: AnkiScreenshotMode, ): AnkiScreenshotPreparation { + sceneLog { "prepareScreenshot: resolved screenshot mode=${mode.storageValue}" } return when (mode) { AnkiScreenshotMode.NONE -> AnkiScreenshotPreparation.Still(null) AnkiScreenshotMode.FULL, @@ -177,6 +182,10 @@ internal class PlayerSceneMiningCoordinator( request.videoInput == null || request.resolvedTiming == null ) { + sceneLog { + "prepareAnimated: bailed before capture, videoInput null=${request.videoInput == null} " + + "resolvedTiming null=${request.resolvedTiming == null}" + } return AnkiScreenshotPreparation.Failed(stillEncoder.encode(request)) } val prepared = try { @@ -184,13 +193,16 @@ internal class PlayerSceneMiningCoordinator( sceneCaptureService().prepare(request) } } catch (_: TimeoutCancellationException) { + sceneLog { "prepareAnimated: timed out after ${sceneTimeoutMillis}ms" } return AnkiScreenshotPreparation.Failed(stillEncoder.encode(request)) } catch (e: CancellationException) { throw e - } catch (_: Exception) { + } catch (e: Exception) { + sceneLog(throwable = e) { "prepareAnimated: capture threw" } return AnkiScreenshotPreparation.Failed(stillEncoder.encode(request)) } if (prepared !is AnkiScreenshotPreparation.Animated) { + sceneLog { "prepareAnimated: capture returned ${prepared::class.simpleName}, not Animated" } return prepared.withStillFallback(request) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/player/scene/SceneAv1EncoderSelector.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/player/scene/SceneAv1EncoderSelector.kt new file mode 100644 index 0000000000..aaaa996c98 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/player/scene/SceneAv1EncoderSelector.kt @@ -0,0 +1,299 @@ +package eu.kanade.tachiyomi.ui.player.scene + +import kotlin.math.abs +import kotlin.math.min + +internal data class SceneVideoDimensions( + val width: Int, + val height: Int, +) + +internal data class Av1EncoderCandidate( + val name: String, + val supportsPlanarYuv420: Boolean, + val supportsConstantQuality: Boolean, + val supportsTargetQuality: Boolean, + val widthAlignment: Int, + val heightAlignment: Int, + val minimumWidth: Int = 1, + val minimumHeight: Int = 1, + val maximumWidth: Int = Int.MAX_VALUE, + val maximumHeight: Int = Int.MAX_VALUE, + val supportedWidthsForHeight: (Int) -> IntRange? = { + minimumWidth..maximumWidth + }, + val supportsSizeAndRate: (SceneVideoDimensions, Double) -> Boolean, +) + +internal data class Av1EncoderSelection( + val name: String, + val contentSize: SceneVideoDimensions, + val outputSize: SceneVideoDimensions, +) + +internal fun selectAv1Encoder( + source: SceneVideoDimensions, + candidates: Sequence, + frameRate: Double = SCENE_FRAME_RATE, +): Av1EncoderSelection? { + if (source.width <= 0 || source.height <= 0) return null + if (!frameRate.isFinite() || frameRate <= 0.0) return null + val boundedOutputDimension = SCENE_MAX_OUTPUT_DIMENSION + + var bestSelection: Av1EncoderSelection? = null + candidates.forEach { candidate -> + if (candidate.name.isBlank() || + !candidate.supportsPlanarYuv420 || + !candidate.supportsConstantQuality || + !candidate.supportsTargetQuality || + candidate.widthAlignment <= 0 || + candidate.heightAlignment <= 0 || + candidate.minimumWidth <= 0 || + candidate.minimumHeight <= 0 || + candidate.maximumWidth < candidate.minimumWidth || + candidate.maximumHeight < candidate.minimumHeight + ) { + return@forEach + } + + val widthAlignment = combinedAlignment(candidate.widthAlignment, SCENE_MEDIACODEC_CANVAS_ALIGNMENT) + ?: return@forEach + val heightAlignment = combinedAlignment(candidate.heightAlignment, SCENE_MEDIACODEC_CANVAS_ALIGNMENT) + ?: return@forEach + if (widthAlignment > boundedOutputDimension || heightAlignment > boundedOutputDimension) { + return@forEach + } + val checkedSizes = mutableSetOf() + val queriedHeights = mutableSetOf() + val widthRanges = mutableMapOf() + val selection = (boundedOutputDimension downTo SCENE_PIXEL_ALIGNMENT) + .firstNotNullOfOrNull { contentCap -> + val contentSize = scaledSceneSize( + source = source, + maxOutputDimension = contentCap, + widthAlignment = SCENE_PIXEL_ALIGNMENT, + heightAlignment = SCENE_PIXEL_ALIGNMENT, + ) ?: return@firstNotNullOfOrNull null + val outputSize = supportedCanvasSize( + contentSize = contentSize, + candidate = candidate, + widthAlignment = widthAlignment, + heightAlignment = heightAlignment, + frameRate = frameRate, + maxOutputDimension = boundedOutputDimension, + checkedSizes = checkedSizes, + queriedHeights = queriedHeights, + widthRanges = widthRanges, + ) ?: return@firstNotNullOfOrNull null + Av1EncoderSelection( + name = candidate.name, + contentSize = contentSize, + outputSize = outputSize, + ) + } + ?: return@forEach + if (selection.isBetterThan(bestSelection, source)) { + bestSelection = selection + } + } + return bestSelection +} + +private fun supportedCanvasSize( + contentSize: SceneVideoDimensions, + candidate: Av1EncoderCandidate, + widthAlignment: Int, + heightAlignment: Int, + frameRate: Double, + maxOutputDimension: Int, + checkedSizes: MutableSet, + queriedHeights: MutableSet, + widthRanges: MutableMap, +): SceneVideoDimensions? { + val minimumWidth = maxOf(contentSize.width, candidate.minimumWidth) + .alignUp(widthAlignment, maxOutputDimension) + ?: return null + val minimumHeight = maxOf(contentSize.height, candidate.minimumHeight) + .alignUp(heightAlignment, maxOutputDimension) + ?: return null + val maximumWidth = min(candidate.maximumWidth, maxOutputDimension) + .alignDown(widthAlignment) + val maximumHeight = min(candidate.maximumHeight, maxOutputDimension) + .alignDown(heightAlignment) + if (minimumWidth > maximumWidth || minimumHeight > maximumHeight) return null + + var best: SceneVideoDimensions? = null + var height = minimumHeight + while (height <= maximumHeight) { + val bestArea = best?.let { it.width.toLong() * it.height } + if (bestArea != null && height.toLong() * minimumWidth >= bestArea) break + + val widthRange = if (queriedHeights.add(height)) { + runCatching { + candidate.supportedWidthsForHeight(height) + }.getOrNull().also { widthRanges[height] = it } + } else { + widthRanges[height] + } + if (widthRange != null && !widthRange.isEmpty()) { + val rangeMaximum = min(widthRange.last, maximumWidth) + var width = maxOf(minimumWidth, widthRange.first) + .alignUp(widthAlignment, rangeMaximum) + while (width != null && width <= rangeMaximum) { + if (bestArea != null && height.toLong() * width >= bestArea) break + val outputSize = SceneVideoDimensions(width = width, height = height) + val supported = checkedSizes.add(outputSize) && + runCatching { + candidate.supportsSizeAndRate(outputSize, frameRate) + }.getOrDefault(false) + if (supported) { + best = outputSize + if (outputSize == contentSize) return outputSize + break + } + width = (width + widthAlignment) + .takeIf { it <= rangeMaximum } + } + } + height += heightAlignment + } + return best +} + +private fun Int.alignUp(alignment: Int, maximum: Int): Int? { + val aligned = ((toLong() + alignment - 1L) / alignment) * alignment + return aligned + .takeIf { it in alignment.toLong()..maximum.toLong() } + ?.toInt() +} + +private fun scaledSceneSize( + source: SceneVideoDimensions, + maxOutputDimension: Int, + widthAlignment: Int, + heightAlignment: Int, +): SceneVideoDimensions? { + val (fittedWidth, fittedHeight) = when { + source.width <= maxOutputDimension && source.height <= maxOutputDimension -> { + source.width to source.height + } + source.width >= source.height -> { + maxOutputDimension to + (maxOutputDimension.toLong() * source.height / source.width).toInt() + } + else -> { + (maxOutputDimension.toLong() * source.width / source.height).toInt() to + maxOutputDimension + } + } + val maximumWidth = fittedWidth.alignDown(widthAlignment) + val maximumHeight = fittedHeight.alignDown(heightAlignment) + if (maximumWidth < widthAlignment || maximumHeight < heightAlignment) return null + + var best: SceneVideoDimensions? = null + fun consider(width: Int, height: Int) { + if (width !in widthAlignment..maximumWidth || + height !in heightAlignment..maximumHeight || + width % widthAlignment != 0 || + height % heightAlignment != 0 + ) { + return + } + val candidate = SceneVideoDimensions(width = width, height = height) + if (candidate.aspectErrorFrom(source) > MAX_CONTENT_ASPECT_ERROR) return + if (candidate.isBetterContentThan(best, source)) { + best = candidate + } + } + + alignedValueClosest( + numerator = maximumWidth.toLong() * source.height, + denominator = source.width.toLong(), + alignment = heightAlignment, + maximum = maximumHeight, + )?.let { height -> consider(maximumWidth, height) } + alignedValueClosest( + numerator = maximumHeight.toLong() * source.width, + denominator = source.height.toLong(), + alignment = widthAlignment, + maximum = maximumWidth, + )?.let { width -> consider(width, maximumHeight) } + return best +} + +private fun alignedValueClosest( + numerator: Long, + denominator: Long, + alignment: Int, + maximum: Int, +): Int? { + val alignedUnitDenominator = denominator * alignment + val floorUnits = numerator / alignedUnitDenominator + return sequenceOf(floorUnits, floorUnits + 1L) + .filter { units -> units in 1..(maximum / alignment).toLong() } + .map { units -> (units * alignment).toInt() } + .distinct() + .minWithOrNull( + compareBy { value -> + abs(numerator - value.toLong() * denominator) + }.thenByDescending { it }, + ) +} + +private fun Int.alignDown(alignment: Int): Int { + return this - (this % alignment) +} + +private fun SceneVideoDimensions.isBetterContentThan( + other: SceneVideoDimensions?, + source: SceneVideoDimensions, +): Boolean { + other ?: return true + val area = width.toLong() * height + val otherArea = other.width.toLong() * other.height + if (area != otherArea) return area > otherArea + val aspectError = aspectErrorFrom(source) + val otherAspectError = other.aspectErrorFrom(source) + if (aspectError != otherAspectError) return aspectError < otherAspectError + if (width != other.width) return width > other.width + return height > other.height +} + +private fun Av1EncoderSelection.isBetterThan( + other: Av1EncoderSelection?, + source: SceneVideoDimensions, +): Boolean { + other ?: return true + if (contentSize != other.contentSize) { + val thisIsBetter = contentSize.isBetterContentThan(other.contentSize, source) + val otherIsBetter = other.contentSize.isBetterContentThan(contentSize, source) + if (thisIsBetter != otherIsBetter) return thisIsBetter + } + val outputArea = outputSize.width.toLong() * outputSize.height + val otherOutputArea = other.outputSize.width.toLong() * other.outputSize.height + return outputArea < otherOutputArea +} + +private fun SceneVideoDimensions.aspectErrorFrom(source: SceneVideoDimensions): Double { + val scaledSourceWidth = width.toLong() * source.height + val scaledOutputWidth = height.toLong() * source.width + return abs(scaledSourceWidth - scaledOutputWidth).toDouble() / scaledOutputWidth +} + +private fun combinedAlignment(first: Int, second: Int): Int? { + var a = first + var b = second + while (b != 0) { + val remainder = a % b + a = b + b = remainder + } + val combined = first.toLong() / a * second + return combined.takeIf { it in 1..Int.MAX_VALUE }?.toInt() +} + +internal const val SCENE_MAX_OUTPUT_DIMENSION = 640 +internal const val SCENE_PIXEL_ALIGNMENT = 2 +internal const val SCENE_MEDIACODEC_CANVAS_ALIGNMENT = 16 +internal const val SCENE_FRAME_RATE = 8.0 +private const val MAX_CONTENT_ASPECT_ERROR = 0.002 diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/player/scene/SceneCaptureRequest.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/player/scene/SceneCaptureRequest.kt index 9e322e58ea..4a6bcbc595 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/player/scene/SceneCaptureRequest.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/player/scene/SceneCaptureRequest.kt @@ -157,16 +157,28 @@ internal class SceneMpvSnapshotReader( fun read(): SceneMpvSnapshot? { val anchor = properties.double("time-pos") ?.takeIf { it.isFinite() && it >= 0.0 } - ?: return null + ?: run { + sceneLog { "mpvSnapshot: unusable time-pos" } + return null + } val duration = properties.double("duration") ?.takeIf { it.isFinite() && it >= 0.0 } val speed = properties.double("sub-speed") ?.takeIf { it.isFinite() && it > 0.0 } - ?: return null + ?: run { + sceneLog { "mpvSnapshot: unusable sub-speed" } + return null + } val delay = properties.double("sub-delay") ?.takeIf(Double::isFinite) - ?: return null - val selectedVideo = selectedVideo() ?: return null + ?: run { + sceneLog { "mpvSnapshot: unusable sub-delay" } + return null + } + val selectedVideo = selectedVideo() ?: run { + sceneLog { "mpvSnapshot: no selected internal video track with a valid ff-index" } + return null + } val selectedAudio = selectedAudio() return SceneMpvSnapshot( @@ -293,14 +305,36 @@ internal class SceneCaptureRequestFactory( captureFallback: suspend () -> Bitmap?, resolveTiming: (SceneMpvSnapshot) -> SceneResolvedTiming?, ): SceneCaptureRequest? { - val beforeMpv = mpvSnapshotReader.read() ?: return null - val beforeVideo = videoSnapshot(beforeMpv) ?: return null - val fallback = captureFallback() ?: return null + val beforeMpv = mpvSnapshotReader.read() ?: run { + sceneLog { "capture: could not read mpv snapshot before still capture" } + return null + } + val beforeVideo = videoSnapshot(beforeMpv) ?: run { + sceneLog { "capture: no video snapshot before still capture (currentVideo null?)" } + return null + } + val fallback = captureFallback() ?: run { + sceneLog { "capture: still-frame capture returned no bitmap" } + return null + } var transferred = false try { - val afterMpv = mpvSnapshotReader.read() ?: return null - val afterVideo = videoSnapshot(afterMpv) ?: return null - if (!sameCaptureState(beforeMpv, afterMpv) || beforeVideo != afterVideo) return null + val afterMpv = mpvSnapshotReader.read() ?: run { + sceneLog { "capture: could not read mpv snapshot after still capture" } + return null + } + val afterVideo = videoSnapshot(afterMpv) ?: run { + sceneLog { "capture: no video snapshot after still capture" } + return null + } + if (!sameCaptureState(beforeMpv, afterMpv) || beforeVideo != afterVideo) { + sceneLog { + "capture: player state changed during capture, " + + "divergence=${describeDivergence(beforeMpv, afterMpv)} " + + "videoSnapshotChanged=${beforeVideo != afterVideo}" + } + return null + } val videoInput = SceneVideoInputResolver.resolve(beforeVideo.video) val sentenceAudioInput = when { @@ -311,10 +345,17 @@ internal class SceneCaptureRequestFactory( beforeMpv.selectedAudioFfmpegIndex == null -> null else -> videoInput } + val resolvedTiming = resolveTiming(beforeMpv) + sceneLog { + "capture: resolved videoInput=${videoInput?.describe() ?: "null"} " + + "resolvedTiming=${resolvedTiming?.animationRange?.let { + "${it.startSeconds}..${it.endSeconds}" + } ?: "null"}" + } val request = SceneCaptureRequest( videoInput = videoInput, sentenceAudioInput = sentenceAudioInput, - resolvedTiming = resolveTiming(beforeMpv), + resolvedTiming = resolvedTiming, stillFallback = OwnedBitmap(fallback), ) transferred = true @@ -324,6 +365,34 @@ internal class SceneCaptureRequestFactory( } } + /** Names the fields that moved, so a spurious rejection can be told from a real seek. */ + private fun describeDivergence(before: SceneMpvSnapshot, after: SceneMpvSnapshot): String { + val changed = buildList { + if (!closeEnough( + before.anchorMediaSeconds, + after.anchorMediaSeconds, + SCENE_ANCHOR_TOLERANCE_SECONDS, + ) + ) { + add("anchor(${before.anchorMediaSeconds}->${after.anchorMediaSeconds})") + } + if (!nullableDoubleEquals(before.mediaDurationSeconds, after.mediaDurationSeconds)) add("duration") + if (!nullableDoubleEquals(before.subtitleStartSeconds, after.subtitleStartSeconds)) add("subStart") + if (!nullableDoubleEquals(before.subtitleEndSeconds, after.subtitleEndSeconds)) add("subEnd") + if (!nullableDoubleEquals(before.subtitleSpeed, after.subtitleSpeed)) add("subSpeed") + if (!nullableDoubleEquals(before.subtitleDelaySeconds, after.subtitleDelaySeconds)) add("subDelay") + if (before.playableValue != after.playableValue) add("playableValue") + if (before.selectedVideoId != after.selectedVideoId) add("videoId") + if (before.selectedVideoFfmpegIndex != after.selectedVideoFfmpegIndex) add("videoFfIndex") + if (before.selectedAudioId != after.selectedAudioId) add("audioId") + if (before.selectedExternalAudioValue != after.selectedExternalAudioValue) add("externalAudio") + if (before.selectedAudioIsExternal != after.selectedAudioIsExternal) add("audioIsExternal") + if (before.seekable != after.seekable) add("seekable") + if (before.selectedAudioFfmpegIndex != after.selectedAudioFfmpegIndex) add("audioFfIndex") + } + return if (changed.isEmpty()) "none" else changed.joinToString(",") + } + private fun sameCaptureState(before: SceneMpvSnapshot, after: SceneMpvSnapshot): Boolean { return closeEnough(before.anchorMediaSeconds, after.anchorMediaSeconds, SCENE_ANCHOR_TOLERANCE_SECONDS) && nullableDoubleEquals(before.mediaDurationSeconds, after.mediaDurationSeconds) && diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/player/scene/SceneCommandProcess.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/player/scene/SceneCommandProcess.kt new file mode 100644 index 0000000000..9695ba660b --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/player/scene/SceneCommandProcess.kt @@ -0,0 +1,21 @@ +package eu.kanade.tachiyomi.ui.player.scene + +import android.app.Application +import android.os.Build +import java.io.File + +internal object SceneCommandProcess { + // Must match android:process on IsolatedSceneCommandService in AndroidManifest.xml. + const val SUFFIX = ":scene_processing" + + fun isCurrent(): Boolean = currentProcessName().endsWith(SUFFIX) + + private fun currentProcessName(): String { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + return Application.getProcessName() + } + return runCatching { + File("/proc/self/cmdline").readText().substringBefore('\u0000') + }.getOrDefault("") + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/player/scene/SceneMediaProbe.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/player/scene/SceneMediaProbe.kt index 1466b296f1..09f63db6c1 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/player/scene/SceneMediaProbe.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/player/scene/SceneMediaProbe.kt @@ -1,53 +1,101 @@ package eu.kanade.tachiyomi.ui.player.scene import java.util.Locale +import kotlin.math.abs +import kotlin.math.roundToInt internal object SceneMediaProbe { fun inspect(output: String): Boolean { + return isSafeVideo(output, parseValues(output)) + } + + fun inspectVideo(output: String): SceneVideoDimensions? { + val values = parseValues(output) + if (!isSafeVideo(output, values)) return null + + val width = values.firstOrNull { it.first == "width" }?.second?.toIntOrNull() + ?.takeIf { it > 0 } + ?: return null + val height = values.firstOrNull { it.first == "height" }?.second?.toIntOrNull() + ?.takeIf { it > 0 } + ?: return null + val sampleAspectRatio = values + .firstOrNull { it.first == "sample_aspect_ratio" } + ?.second + .toSampleAspectRatio() + val displayWidthValue = width.toDouble() * sampleAspectRatio + if (!displayWidthValue.isFinite() || displayWidthValue !in 1.0..Int.MAX_VALUE.toDouble()) { + return null + } + val displayWidth = displayWidthValue.roundToInt() + val rotationValue = values.firstOrNull { it.first == "rotation" }?.second + ?.toDoubleOrNull() + ?: 0.0 + if (!rotationValue.isFinite()) return null + val normalizedRotationValue = ((rotationValue % 360.0) + 360.0) % 360.0 + val rotation = normalizedRotationValue.roundToInt() + if (abs(normalizedRotationValue - rotation) > ROTATION_EPSILON || rotation % 90 != 0) { + return null + } + val normalizedRotation = ((rotation % 360) + 360) % 360 + return if (normalizedRotation == 90 || normalizedRotation == 270) { + SceneVideoDimensions(width = height, height = displayWidth) + } else { + SceneVideoDimensions(width = displayWidth, height = height) + } + } + + fun inspectAudio(output: String): Boolean { + val normalized = output.lowercase(Locale.ROOT) + return PROTECTION_MARKERS.none(normalized::contains) && "codec_type=audio" in normalized + } + + private fun isSafeVideo( + output: String, + values: List>, + ): Boolean { val normalized = output.lowercase(Locale.ROOT) if (PROTECTION_MARKERS.any(normalized::contains)) { return false } - val values = output.lineSequence() - .mapNotNull { line -> - val separator = line.indexOf('=') - if (separator <= 0) { - null - } else { - line.substring(0, separator).trim().lowercase(Locale.ROOT) to - line.substring(separator + 1).trim().lowercase(Locale.ROOT) - } - } - .toList() val pixelFormat = values.firstOrNull { it.first == "pix_fmt" }?.second ?: return false if (pixelFormat in setOf("none", "unknown")) { return false } - val rawBits = values.firstOrNull { it.first == "bits_per_raw_sample" } - ?.second - ?.toIntOrNull() val transfer = values.firstOrNull { it.first == "color_transfer" }?.second.orEmpty() val primaries = values.firstOrNull { it.first == "color_primaries" }?.second.orEmpty() - val profile = values.firstOrNull { it.first == "profile" }?.second.orEmpty() - if ( - rawBits?.let { it > 8 } == true || - TEN_BIT_PIXEL_FORMAT.containsMatchIn(pixelFormat) || - transfer in HDR_TRANSFERS || - primaries == "bt2020" || - profile.contains("main 10") - ) { + if (transfer in HDR_TRANSFERS || primaries == "bt2020") { return false } return true } - fun inspectAudio(output: String): Boolean { - val normalized = output.lowercase(Locale.ROOT) - return PROTECTION_MARKERS.none(normalized::contains) && "codec_type=audio" in normalized + private fun parseValues(output: String): List> { + return output.lineSequence() + .mapNotNull { line -> + val separator = line.indexOf('=') + if (separator <= 0) { + null + } else { + line.substring(0, separator).trim().lowercase(Locale.ROOT) to + line.substring(separator + 1).trim().lowercase(Locale.ROOT) + } + } + .toList() + } + + private fun String?.toSampleAspectRatio(): Double { + val parts = this?.split(':', limit = 2) + val numerator = parts?.getOrNull(0)?.toLongOrNull() + val denominator = parts?.getOrNull(1)?.toLongOrNull() + if (numerator == null || denominator == null || numerator <= 0L || denominator <= 0L) { + return 1.0 + } + return numerator.toDouble() / denominator.toDouble() } private val HDR_TRANSFERS = setOf("smpte2084", "arib-std-b67") - private val TEN_BIT_PIXEL_FORMAT = Regex("(p0(?:10|12|16)|p(?:9|10|12|14|16)(?:le|be)?)(?:$|[^0-9])") + private const val ROTATION_EPSILON = 0.001 private val PROTECTION_MARKERS = setOf("cenc", "cbcs", "crypto", "encrypted", "encryption", "drm") } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/player/scene/SceneMiningLog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/player/scene/SceneMiningLog.kt new file mode 100644 index 0000000000..917e8e8898 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/player/scene/SceneMiningLog.kt @@ -0,0 +1,56 @@ +package eu.kanade.tachiyomi.ui.player.scene + +import logcat.LogPriority +import logcat.asLog +import logcat.logcat +import java.net.URI +import java.util.Locale + +internal const val SCENE_LOG_TAG = "SceneMining" + +/** + * Scene mining downgrades to a still image on any failure, so every early return needs to say why. + * + * Fixed at [LogPriority.INFO] because release-derived builds drop anything lower. Emits + * [SCENE_LOG_TAG] as the real logcat tag rather than as a message prefix, so that + * `adb logcat -s SceneMining` selects the whole trace; the house `logcat` helper in + * `tachiyomi.core.common` keeps the calling class as the tag and would leave the filter empty. + * The calling class is named in each message instead, since that is what the tag gave up. + */ +internal inline fun Any.sceneLog( + throwable: Throwable? = null, + message: () -> String, + // Positional, so the String first parameter picks the top-level tag-first overload rather than + // the `Any.logcat` extension that is also in scope here. +) = logcat(SCENE_LOG_TAG, LogPriority.INFO) { + val caller = this::class.java.simpleName.takeIf(String::isNotBlank) ?: "Scene" + buildString { + append(caller).append(": ").append(message()) + if (throwable != null) append('\n').append(throwable.asLog()) + } +} + +/** + * Remote scene inputs are rejected outright when they carry credentials, so logging one verbatim + * would defeat that check. Keeps only the scheme and host; paths can be signed too. + */ +internal fun redactSceneValue(value: String?): String { + if (value.isNullOrBlank()) return "" + val lowered = value.lowercase(Locale.ROOT) + if (!lowered.startsWith("http://") && !lowered.startsWith("https://")) return value + val uri = runCatching { URI(value) }.getOrNull() ?: return "" + return "${uri.scheme}://${uri.host ?: ""}/" +} + +/** + * FFmpeg echoes the input URL into its own diagnostics, so its output is redacted line by line + * rather than as a single value: a URL can appear anywhere inside otherwise useful error text. + */ +internal fun redactSceneLogLine(line: String): String = + EMBEDDED_HTTP_URL.replace(line) { match -> redactSceneValue(match.value) } + +private val EMBEDDED_HTTP_URL = Regex("""https?://\S+""", RegexOption.IGNORE_CASE) + +internal fun SceneVideoInputSpec.describe(): String = + "kind=$kind value=${redactSceneValue(value)} videoStreamIndex=$videoStreamIndex " + + "audioStreamIndex=$audioStreamIndex headerCount=${headers.size}" diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/player/scene/SceneSafInput.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/player/scene/SceneSafInput.kt new file mode 100644 index 0000000000..12c20ecdd3 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/player/scene/SceneSafInput.kt @@ -0,0 +1,32 @@ +package eu.kanade.tachiyomi.ui.player.scene + +import android.net.Uri +import java.nio.charset.StandardCharsets +import java.util.Base64 + +/** + * Keeps a content URI out of FFmpeg arguments until they reach the process that owns FFmpegKit. + */ +internal object SceneSafInput { + private const val READ_PREFIX = "chimahon-saf-read:" + + fun encodeForRead(uri: Uri): String { + require(uri.scheme.equals("content", ignoreCase = true)) + val encoded = Base64.getUrlEncoder() + .withoutPadding() + .encodeToString(uri.toString().toByteArray(StandardCharsets.UTF_8)) + return READ_PREFIX + encoded + } + + fun decodeForRead(value: String): Uri? { + if (!value.startsWith(READ_PREFIX)) return null + val encoded = value.removePrefix(READ_PREFIX) + val decoded = runCatching { + String(Base64.getUrlDecoder().decode(encoded), StandardCharsets.UTF_8) + }.getOrNull() ?: return null + return Uri.parse(decoded) + .takeIf { it.scheme.equals("content", ignoreCase = true) } + } + + fun isReadToken(value: String): Boolean = value.startsWith(READ_PREFIX) +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/player/scene/SceneSentenceAudioService.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/player/scene/SceneSentenceAudioService.kt index 7a9a972ce9..d0f7f028ba 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/player/scene/SceneSentenceAudioService.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/player/scene/SceneSentenceAudioService.kt @@ -22,7 +22,7 @@ internal class FrozenSceneSentenceAudioService private constructor( constructor(context: Context) : this( cacheDirectory = context.cacheDir, inputAcquirer = AndroidSceneInputAcquirer(context), - commandExecutor = FfmpegKitSceneCommandExecutor(), + commandExecutor = IsolatedSceneCommandExecutor(context), ) override suspend fun prepare(request: SceneCaptureRequest): AnkiMediaSource? { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/player/scene/SceneVideoInput.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/player/scene/SceneVideoInput.kt index b6f19d112a..22fdf6ec7d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/player/scene/SceneVideoInput.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/player/scene/SceneVideoInput.kt @@ -33,26 +33,53 @@ internal data class SceneVideoInputSnapshot( internal object SceneVideoInputResolver { fun resolve(snapshot: SceneVideoInputSnapshot): SceneVideoInputSpec? { if (snapshot.originalVideoValue.isBlank() && snapshot.playableValue.isNullOrBlank()) { + sceneLog { "resolve: rejected, both originalVideoValue and playableValue blank" } + return null + } + if (isDash(snapshot.originalVideoValue) || isDash(snapshot.playableValue)) { + sceneLog { "resolve: rejected, DASH input is unsupported" } return null } - if (isDash(snapshot.originalVideoValue) || isDash(snapshot.playableValue)) return null if (snapshot.ffmpegStreamArgs.isNotEmpty() || snapshot.ffmpegVideoArgs.isNotEmpty()) { + sceneLog { + "resolve: rejected, extension supplied ffmpeg args " + + "(stream=${snapshot.ffmpegStreamArgs.size} video=${snapshot.ffmpegVideoArgs.size})" + } + return null + } + if (snapshot.seekable != true) { + sceneLog { "resolve: rejected, input not seekable (seekable=${snapshot.seekable})" } return null } - if (snapshot.seekable != true) return null val original = snapshot.originalVideoValue.takeIf(String::isNotBlank) - if (original != null && isTransient(original)) return null + if (original != null && isTransient(original)) { + sceneLog { "resolve: rejected, originalVideoValue has a transient scheme" } + return null + } val normalized = original?.let(::normalizeInput) ?: snapshot.playableValue?.takeIf(String::isNotBlank)?.let { playable -> - if (isTransient(playable)) return null + if (isTransient(playable)) { + sceneLog { "resolve: rejected, playableValue has a transient scheme" } + return null + } normalizeInput(playable) } - ?: return null + ?: run { + sceneLog { + "resolve: rejected, unrecognized input scheme " + + "original=${redactSceneValue(snapshot.originalVideoValue)} " + + "playable=${redactSceneValue(snapshot.playableValue)}" + } + return null + } val headers = when (normalized.second) { SceneVideoInputKind.REMOTE_HTTP -> validateRemoteInput(normalized.first, snapshot.headers) - ?: return null + ?: run { + sceneLog { "resolve: rejected, remote input failed validation (credentials or headers)" } + return null + } SceneVideoInputKind.LOCAL_FILE, SceneVideoInputKind.CONTENT_URI, -> emptyList() @@ -163,12 +190,14 @@ internal object SceneVideoInputResolver { } internal object SceneFfmpegArguments { - fun animatedAvif( + fun animatedAvifMediaCodec( input: SceneVideoInputSpec, acquiredInputValue: String, range: SceneTimeRange, outputFile: String, encoderName: String, + contentSize: SceneVideoDimensions, + outputSize: SceneVideoDimensions, tlsCaFile: String? = null, ): Array { require(encoderName.isNotBlank()) { "AV1 encoder name must not be blank" } @@ -186,7 +215,7 @@ internal object SceneFfmpegArguments { add("-t") add(range.durationSeconds.toFfmpegSeconds()) add("-vf") - add(FRAME_FILTER) + add(frameFilter(contentSize, outputSize)) add("-frames:v") add(MAX_FRAME_COUNT.toString()) add("-c:v") @@ -222,7 +251,10 @@ internal object SceneFfmpegArguments { add("-select_streams") add(input.videoProbeSelector()) add("-show_entries") - add("stream=pix_fmt,color_transfer,color_primaries,bits_per_raw_sample,profile:stream_side_data") + add( + "stream=width,height,sample_aspect_ratio,pix_fmt,color_transfer,color_primaries," + + "bits_per_raw_sample,profile:stream_side_data", + ) add("-of") add("default=noprint_wrappers=1") add(acquiredInputValue) @@ -320,14 +352,54 @@ internal object SceneFfmpegArguments { return String.format(Locale.ROOT, "%.6f", this).trimEnd('0').trimEnd('.') } - internal const val FRAME_FILTER = - "fps=8,scale=w='min(640,iw)':h='min(640,ih)':force_original_aspect_ratio=decrease:force_divisible_by=16,setsar=1" - internal const val FRAME_RATE = 8.0 + internal fun frameFilter( + contentSize: SceneVideoDimensions, + outputSize: SceneVideoDimensions, + ): String { + require( + outputSize.width in SCENE_PIXEL_ALIGNMENT..SCENE_MAX_OUTPUT_DIMENSION && + outputSize.height in SCENE_PIXEL_ALIGNMENT..SCENE_MAX_OUTPUT_DIMENSION && + outputSize.width % SCENE_PIXEL_ALIGNMENT == 0 && + outputSize.height % SCENE_PIXEL_ALIGNMENT == 0 && + outputSize.width % SCENE_MEDIACODEC_CANVAS_ALIGNMENT == 0 && + outputSize.height % SCENE_MEDIACODEC_CANVAS_ALIGNMENT == 0, + ) { + "Scene output size must be 16-pixel aligned and no larger than $SCENE_MAX_OUTPUT_DIMENSION" + } + require( + contentSize.width in SCENE_PIXEL_ALIGNMENT..outputSize.width && + contentSize.height in SCENE_PIXEL_ALIGNMENT..outputSize.height && + contentSize.width % SCENE_PIXEL_ALIGNMENT == 0 && + contentSize.height % SCENE_PIXEL_ALIGNMENT == 0, + ) { + "Scene content size must be even and fit inside the output" + } + return buildList { + add("fps=8") + add("scale=w=${contentSize.width}:h=${contentSize.height}") + add("setsar=1") + if (contentSize != outputSize) { + val horizontalGap = outputSize.width - contentSize.width + val verticalGap = outputSize.height - contentSize.height + add( + "pad=w=${outputSize.width}:h=${outputSize.height}:" + + "x=${horizontalGap.centeredChromaOffset()}:" + + "y=${verticalGap.centeredChromaOffset()}:color=black", + ) + } + }.joinToString(separator = ",") + } + + private fun Int.centeredChromaOffset(): Int { + return (this / 2).let { center -> center - (center % SCENE_PIXEL_ALIGNMENT) } + } + + internal const val FRAME_RATE = SCENE_FRAME_RATE internal const val MAX_FRAME_COUNT = 80 private const val REMOTE_PROTOCOLS = "http,https,tls,tcp,crypto" private const val REMOTE_IO_TIMEOUT_MICROSECONDS = "15000000" internal const val ALLOWED_INPUT_DECODERS = - "aac,ac3,alac,av1,dca,eac3,ffv1,flac,h263,h264,hevc,libdav1d,mjpeg,mp3,mp3float,mpeg1video," + - "mpeg2video,mpeg4,opus,pcm_f32le,pcm_s16le,pcm_s24le,pcm_s32le,png,prores,theora,truehd," + - "vorbis,vp8,vp9" + "aac,ac3,alac,av1,dca,eac3,ffv1,flac,h263,h264,hevc,libdav1d,mjpeg,mov_text,mp3,mp3float," + + "mpeg1video,mpeg2video,mpeg4,opus,pcm_f32le,pcm_s16le,pcm_s24le,pcm_s32le,png,prores," + + "theora,truehd,vorbis,vp8,vp9" } diff --git a/app/src/test/kotlin/eu/kanade/tachiyomi/ui/player/MpvConfigDirectoryResolverTest.kt b/app/src/test/kotlin/eu/kanade/tachiyomi/ui/player/MpvConfigDirectoryResolverTest.kt new file mode 100644 index 0000000000..f1d784506e --- /dev/null +++ b/app/src/test/kotlin/eu/kanade/tachiyomi/ui/player/MpvConfigDirectoryResolverTest.kt @@ -0,0 +1,79 @@ +package eu.kanade.tachiyomi.ui.player + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Test + +class MpvConfigDirectoryResolverTest { + + @Test + fun `external config directory is used when it is available`() { + assertEquals( + "/storage/emulated/0/chimaFork/mpv", + resolveMpvConfigDirectory( + internalConfigDirectory = "/data/user/0/app.chimahon.dev/files", + useExternalConfigDirectory = true, + externalConfigDirectory = { "/storage/emulated/0/chimaFork/mpv" }, + ), + ) + } + + @Test + fun `missing external directory falls back to internal storage`() { + assertEquals( + "/data/user/0/app.chimahon.dev/files", + resolveMpvConfigDirectory( + internalConfigDirectory = "/data/user/0/app.chimahon.dev/files", + useExternalConfigDirectory = true, + externalConfigDirectory = { null }, + ), + ) + } + + @Test + fun `blank external path falls back to internal storage`() { + assertEquals( + "/data/user/0/app.chimahon.dev/files", + resolveMpvConfigDirectory( + internalConfigDirectory = "/data/user/0/app.chimahon.dev/files", + useExternalConfigDirectory = true, + externalConfigDirectory = { " " }, + ), + ) + } + + @Test + fun `external lookup failure falls back and reports the cause`() { + val failure = SecurityException("Persisted URI grant is missing") + var reportedFailure: Exception? = null + + assertEquals( + "/data/user/0/app.chimahon.dev/files", + resolveMpvConfigDirectory( + internalConfigDirectory = "/data/user/0/app.chimahon.dev/files", + useExternalConfigDirectory = true, + externalConfigDirectory = { throw failure }, + onExternalFailure = { reportedFailure = it }, + ), + ) + assertSame(failure, reportedFailure) + } + + @Test + fun `external directory is not queried without all files access`() { + var lookupCount = 0 + + assertEquals( + "/data/user/0/app.chimahon.dev/files", + resolveMpvConfigDirectory( + internalConfigDirectory = "/data/user/0/app.chimahon.dev/files", + useExternalConfigDirectory = false, + externalConfigDirectory = { + lookupCount++ + "/storage/emulated/0/chimaFork/mpv" + }, + ), + ) + assertEquals(0, lookupCount) + } +} diff --git a/app/src/test/kotlin/eu/kanade/tachiyomi/ui/player/PictureInPictureGuardTest.kt b/app/src/test/kotlin/eu/kanade/tachiyomi/ui/player/PictureInPictureGuardTest.kt new file mode 100644 index 0000000000..d494339c55 --- /dev/null +++ b/app/src/test/kotlin/eu/kanade/tachiyomi/ui/player/PictureInPictureGuardTest.kt @@ -0,0 +1,89 @@ +package eu.kanade.tachiyomi.ui.player + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class PictureInPictureGuardTest { + + @Test + fun `unavailable picture in picture skips framework calls`() { + var calls = 0 + val guard = PictureInPictureGuard(initiallyAvailable = false) + + val completed = guard.runIfAvailable { + calls++ + true + } + + assertFalse(completed) + assertEquals(0, calls) + } + + @Test + fun `available picture in picture runs framework calls`() { + var calls = 0 + val guard = PictureInPictureGuard(initiallyAvailable = true) + + val completed = guard.runIfAvailable { + calls++ + true + } + + assertTrue(completed) + assertTrue(guard.isAvailable) + assertEquals(1, calls) + } + + @Test + fun `framework rejection disables later picture in picture calls`() { + val rejection = IllegalStateException("Device doesn't support picture-in-picture mode") + var reportedFailure: IllegalStateException? = null + var calls = 0 + val guard = PictureInPictureGuard( + initiallyAvailable = true, + onRejected = { reportedFailure = it }, + ) + + val firstCompleted = guard.runIfAvailable { + calls++ + throw rejection + } + val secondCompleted = guard.runIfAvailable { + calls++ + true + } + + assertFalse(firstCompleted) + assertFalse(secondCompleted) + assertFalse(guard.isAvailable) + assertSame(rejection, reportedFailure) + assertEquals(1, calls) + } + + @Test + fun `framework false result is preserved without disabling later calls`() { + val guard = PictureInPictureGuard(initiallyAvailable = true) + + val completed = guard.runIfAvailable { false } + + assertFalse(completed) + assertTrue(guard.isAvailable) + } + + @Test + fun `unexpected failures are not hidden`() { + val failure = IllegalArgumentException("Invalid picture-in-picture parameters") + val guard = PictureInPictureGuard(initiallyAvailable = true) + + val thrown = assertThrows(IllegalArgumentException::class.java) { + guard.runIfAvailable { throw failure } + } + + assertSame(failure, thrown) + assertTrue(guard.isAvailable) + } +} diff --git a/app/src/test/kotlin/eu/kanade/tachiyomi/ui/player/SurfacePlaybackLoadGateTest.kt b/app/src/test/kotlin/eu/kanade/tachiyomi/ui/player/SurfacePlaybackLoadGateTest.kt new file mode 100644 index 0000000000..ba8bb467df --- /dev/null +++ b/app/src/test/kotlin/eu/kanade/tachiyomi/ui/player/SurfacePlaybackLoadGateTest.kt @@ -0,0 +1,114 @@ +package eu.kanade.tachiyomi.ui.player + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class SurfacePlaybackLoadGateTest { + + @Test + fun `cold start defers playback until the surface exists`() { + val loaded = mutableListOf() + val gate = SurfacePlaybackLoadGate { + loaded += it + true + } + + gate.load("content://episode") + + assertTrue(loaded.isEmpty()) + + gate.onSurfaceCreated() + + assertEquals(listOf("content://episode"), loaded) + } + + @Test + fun `latest pending load replaces an older request`() { + val loaded = mutableListOf() + val gate = SurfacePlaybackLoadGate { + loaded += it + true + } + + gate.load("content://old") + gate.load("content://new") + gate.onSurfaceCreated() + + assertEquals(listOf("content://new"), loaded) + } + + @Test + fun `surface recreation defers new playback until reattached`() { + val loaded = mutableListOf() + val gate = SurfacePlaybackLoadGate { + loaded += it + true + } + + gate.onSurfaceCreated() + gate.load("content://first") + gate.onSurfaceDestroyed() + gate.load("content://second") + + assertEquals(listOf("content://first"), loaded) + + gate.onSurfaceCreated() + + assertEquals(listOf("content://first", "content://second"), loaded) + } + + @Test + fun `surface recreation without a pending request does not reload`() { + val loaded = mutableListOf() + val gate = SurfacePlaybackLoadGate { + loaded += it + true + } + + gate.onSurfaceCreated() + gate.load("content://episode") + gate.onSurfaceDestroyed() + gate.onSurfaceCreated() + + assertEquals(listOf("content://episode"), loaded) + } + + @Test + fun `closing the gate drops pending and future loads`() { + val loaded = mutableListOf() + val gate = SurfacePlaybackLoadGate { + loaded += it + true + } + + gate.load("content://pending") + gate.close() + gate.onSurfaceCreated() + gate.load("content://late") + + assertTrue(loaded.isEmpty()) + } + + @Test + fun `rejected surface load remains pending until playback resumes`() { + val loaded = mutableListOf() + var canLoad = false + val gate = SurfacePlaybackLoadGate { + if (canLoad) { + loaded += it + } + canLoad + } + + gate.onSurfaceCreated() + gate.load("content://episode") + + assertTrue(loaded.isEmpty()) + + canLoad = true + gate.retryPending() + + assertEquals(listOf("content://episode"), loaded) + } +} diff --git a/app/src/test/kotlin/eu/kanade/tachiyomi/ui/player/scene/AndroidSceneCaptureServiceTest.kt b/app/src/test/kotlin/eu/kanade/tachiyomi/ui/player/scene/AndroidSceneCaptureServiceTest.kt index 8e0ec461ec..9ac3120e44 100644 --- a/app/src/test/kotlin/eu/kanade/tachiyomi/ui/player/scene/AndroidSceneCaptureServiceTest.kt +++ b/app/src/test/kotlin/eu/kanade/tachiyomi/ui/player/scene/AndroidSceneCaptureServiceTest.kt @@ -4,7 +4,17 @@ import android.graphics.Bitmap import chimahon.anki.AnkiScreenshotPreparation import io.mockk.every import io.mockk.mockk +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout import org.junit.jupiter.api.Assertions.assertArrayEquals import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse @@ -18,11 +28,11 @@ class AndroidSceneCaptureServiceTest { lateinit var tempDirectory: File @Test - fun `successful capture uses exact bounded AVIF command`() = runTest { + fun `successful capture encodes directly to animated AVIF in one command`() = runTest { val executor = RecordingExecutor(writeOutput = true) val service = service( executor = executor, - validate = { AnimatedAvifInfo(320, 180, 24, 3_000) }, + validate = { AnimatedAvifInfo(320, 192, 24, 3_000) }, ) val result = service.prepare(request()) @@ -30,29 +40,158 @@ class AndroidSceneCaptureServiceTest { val animated = result as AnkiScreenshotPreparation.Animated assertEquals("avif", animated.animation.extension) assertTrue(animated.animation.preferredBaseName.startsWith("chimahon_scene_")) + assertEquals(1, executor.ffmpegArguments.size) assertArrayEquals( - expectedAvifArguments(animated.animation.file.absolutePath), - executor.ffmpegArguments, + expectedAv1Arguments(animated.animation.file.absolutePath), + executor.ffmpegArguments[0], ) animated.animation.file.delete() } @Test - fun `missing compatible AV1 encoder falls back before native work`() = runTest { + fun `missing compatible AV1 encoder falls back before encode`() = runTest { val executor = RecordingExecutor(writeOutput = true) val service = service( executor = executor, - av1EncoderName = { null }, + av1Encoder = { null }, ) val result = service.prepare(request()) assertTrue(result is AnkiScreenshotPreparation.Failed) - assertEquals(0, executor.probeCalls) + assertEquals(1, executor.probeCalls) assertEquals(0, executor.ffmpegCalls) assertFalse(tempDirectory.resolve("scene").exists()) } + @Test + fun `capture selects an encoder for probed dimensions and applies its exact output`() = runTest { + val executor = RecordingExecutor(writeOutput = true) + var selectedFor: SceneVideoDimensions? = null + val service = service( + executor = executor, + validate = { AnimatedAvifInfo(320, 192, 24, 3_000) }, + av1Encoder = { source -> + selectedFor = source + Av1EncoderSelection( + name = TEST_AV1_ENCODER_NAME, + contentSize = SceneVideoDimensions(width = 320, height = 180), + outputSize = SceneVideoDimensions(width = 320, height = 192), + ) + }, + ) + + val result = service.prepare(request()) + + assertTrue(result is AnkiScreenshotPreparation.Animated) + assertEquals(SceneVideoDimensions(width = 320, height = 180), selectedFor) + val encodeArguments = executor.ffmpegArguments.first().toList() + assertEquals( + SceneFfmpegArguments.frameFilter( + contentSize = SceneVideoDimensions(width = 320, height = 180), + outputSize = SceneVideoDimensions(width = 320, height = 192), + ), + encodeArguments[encodeArguments.indexOf("-vf") + 1], + ) + (result as AnkiScreenshotPreparation.Animated).animation.file.delete() + } + + @Test + fun `capture rejects output dimensions that differ from the codec selection`() = runTest { + val executor = RecordingExecutor(writeOutput = true) + val service = service( + executor = executor, + validate = { AnimatedAvifInfo(320, 180, 24, 3_000) }, + av1Encoder = { + Av1EncoderSelection( + name = TEST_AV1_ENCODER_NAME, + contentSize = SceneVideoDimensions(width = 320, height = 180), + outputSize = SceneVideoDimensions(width = 320, height = 192), + ) + }, + ) + + val result = service.prepare(request()) + + assertTrue(result is AnkiScreenshotPreparation.Failed) + assertTrue(tempDirectory.resolve("scene").listFiles().isNullOrEmpty()) + } + + @Test + fun `cancellation while returning a completed capture deletes the undelivered output`() = runTest { + val executor = RecordingExecutor(writeOutput = true) + val callerJob = Job(currentCoroutineContext()[Job]) + val service = service( + executor = executor, + validate = { + callerJob.cancel() + AnimatedAvifInfo(320, 192, 24, 3_000) + }, + ) + + var cancelled = false + try { + withContext(callerJob) { + service.prepare(request()) + } + } catch (_: CancellationException) { + cancelled = true + } + + assertTrue(cancelled) + assertTrue(tempDirectory.resolve("scene").listFiles().isNullOrEmpty()) + } + + @Test + fun `probe argument failure closes its input lease and fails closed`() = runTest { + var closeCalls = 0 + val service = service( + executor = RecordingExecutor(writeOutput = true), + inputAcquirer = SceneInputAcquirer { input -> + object : SceneInputLease { + override val ffmpegValue = input.value + override val tlsCaFile: String? = null + + override fun close() { + closeCalls++ + } + } + }, + ) + + val result = runCatching { service.prepare(request()) } + + assertEquals(1, closeCalls) + assertTrue(result.getOrNull() is AnkiScreenshotPreparation.Failed) + } + + @Test + fun `encode argument failure closes both acquired input leases`() = runTest { + var acquisitions = 0 + var closeCalls = 0 + val service = service( + executor = RecordingExecutor(writeOutput = true), + inputAcquirer = SceneInputAcquirer { input -> + acquisitions++ + object : SceneInputLease { + override val ffmpegValue = input.value + override val tlsCaFile = if (acquisitions == 1) "/files/cacert.pem" else null + + override fun close() { + closeCalls++ + } + } + }, + ) + + val result = service.prepare(request()) + + assertTrue(result is AnkiScreenshotPreparation.Failed) + assertEquals(2, acquisitions) + assertEquals(2, closeCalls) + assertTrue(tempDirectory.resolve("scene").listFiles().isNullOrEmpty()) + } + @Test fun `failed validation deletes partial output`() = runTest { val executor = RecordingExecutor(writeOutput = true) @@ -68,26 +207,64 @@ class AndroidSceneCaptureServiceTest { assertTrue(sceneDirectory.listFiles().isNullOrEmpty()) } + @Test + fun `cancellation reaches native encode and defers file cleanup until native return`() = runTest { + val executor = RecordingExecutor(writeOutput = true, suspendEncode = true) + val service = service(executor = executor) + val preparation = launch { service.prepare(request()) } + withContext(Dispatchers.Default) { + withTimeout(5_000) { executor.encodeStarted.await() } + } + val output = File(executor.ffmpegArguments.last().last()) + + preparation.cancelAndJoin() + withContext(Dispatchers.Default) { + withTimeout(5_000) { executor.cancellationObserved.await() } + } + + assertTrue(output.isFile) + + executor.finishNative() + + assertFalse(output.exists()) + } + private fun service( executor: RecordingExecutor, + inputAcquirer: SceneInputAcquirer = SceneInputAcquirer { input -> + object : SceneInputLease { + override val ffmpegValue = input.value + override val tlsCaFile = "/files/cacert.pem" + + override fun close() = Unit + } + }, validate: (File) -> AnimatedAvifInfo? = { - AnimatedAvifInfo(320, 180, 24, 3_000) + AnimatedAvifInfo(320, 192, 24, 3_000) + }, + av1Encoder: (SceneVideoDimensions) -> Av1EncoderSelection? = { source -> + selectAv1Encoder( + source = source, + candidates = sequenceOf( + Av1EncoderCandidate( + name = TEST_AV1_ENCODER_NAME, + supportsPlanarYuv420 = true, + supportsConstantQuality = true, + supportsTargetQuality = true, + widthAlignment = 2, + heightAlignment = 2, + supportsSizeAndRate = { _, _ -> true }, + ), + ), + ) }, - av1EncoderName: () -> String? = { TEST_AV1_ENCODER_NAME }, ): AndroidSceneCaptureService { return AndroidSceneCaptureService.forTests( sceneDirectory = tempDirectory.resolve("scene"), - inputAcquirer = SceneInputAcquirer { input -> - object : SceneInputLease { - override val ffmpegValue = input.value - override val tlsCaFile = "/files/cacert.pem" - - override fun close() = Unit - } - }, + inputAcquirer = inputAcquirer, commandExecutor = executor, validate = validate, - av1EncoderName = av1EncoderName, + av1Encoder = av1Encoder, ) } @@ -110,7 +287,7 @@ class AndroidSceneCaptureServiceTest { ) } - private fun expectedAvifArguments(output: String): Array { + private fun expectedAv1Arguments(output: String): Array { return arrayOf( "-codec_whitelist", SceneFfmpegArguments.ALLOWED_INPUT_DECODERS, @@ -136,7 +313,10 @@ class AndroidSceneCaptureServiceTest { "-t", "3", "-vf", - SceneFfmpegArguments.FRAME_FILTER, + SceneFfmpegArguments.frameFilter( + contentSize = SceneVideoDimensions(width = 320, height = 180), + outputSize = SceneVideoDimensions(width = 320, height = 192), + ), "-frames:v", "80", "-c:v", @@ -162,21 +342,35 @@ class AndroidSceneCaptureServiceTest { private class RecordingExecutor( private val writeOutput: Boolean, + private val suspendEncode: Boolean = false, ) : SceneCommandExecutor { var probeCalls = 0 var ffmpegCalls = 0 - var ffmpegArguments: Array = emptyArray() + val ffmpegArguments = mutableListOf>() + val encodeStarted = CompletableDeferred() + val cancellationObserved = CompletableDeferred() + private lateinit var onEncodeFinished: () -> Unit override suspend fun executeFfmpeg( arguments: Array, onNativeFinished: () -> Unit, ): SceneCommandResult { - return try { - ffmpegCalls++ - ffmpegArguments = arguments - if (writeOutput) { - File(arguments.last()).writeBytes(byteArrayOf(1, 2, 3)) + ffmpegCalls++ + ffmpegArguments += arguments + val output = File(arguments.last()) + if (writeOutput) { + output.writeBytes(byteArrayOf(1, 2, 3)) + } + if (suspendEncode && output.extension == "avif") { + onEncodeFinished = onNativeFinished + encodeStarted.complete(Unit) + return suspendCancellableCoroutine { continuation -> + continuation.invokeOnCancellation { + cancellationObserved.complete(Unit) + } } + } + return try { SceneCommandResult.Success() } finally { onNativeFinished() @@ -190,12 +384,17 @@ class AndroidSceneCaptureServiceTest { return try { probeCalls++ SceneCommandResult.Success( - "pix_fmt=yuv420p\ncolor_transfer=bt709\ncolor_primaries=bt709\nbits_per_raw_sample=8", + "width=320\nheight=180\npix_fmt=yuv420p\ncolor_transfer=bt709\n" + + "color_primaries=bt709\nbits_per_raw_sample=8", ) } finally { onNativeFinished() } } + + fun finishNative() { + onEncodeFinished() + } } private companion object { diff --git a/app/src/test/kotlin/eu/kanade/tachiyomi/ui/player/scene/AnimatedAvifValidatorTest.kt b/app/src/test/kotlin/eu/kanade/tachiyomi/ui/player/scene/AnimatedAvifValidatorTest.kt index cd3d81b391..36a93abec7 100644 --- a/app/src/test/kotlin/eu/kanade/tachiyomi/ui/player/scene/AnimatedAvifValidatorTest.kt +++ b/app/src/test/kotlin/eu/kanade/tachiyomi/ui/player/scene/AnimatedAvifValidatorTest.kt @@ -55,6 +55,15 @@ class AnimatedAvifValidatorTest { assertNull(validate(avif(mediaBytes = 3))) } + @Test + fun `requires the first AV1 sample to be a sync sample`() { + assertNull(validate(avif(syncSamples = listOf(2)))) + assertEquals( + AnimatedAvifInfo(width = 64, height = 48, frameCount = 4, totalDurationMillis = 500), + validate(avif(syncSamples = listOf(1, 3))), + ) + } + private fun avif( majorBrand: String = "avis", brands: List = listOf("avif", "MA1B"), @@ -65,6 +74,7 @@ class AnimatedAvifValidatorTest { frameDuration: Int = 1_000, sampleSizes: List = List(frames) { 1 }, mediaBytes: Int = sampleSizes.sum(), + syncSamples: List? = null, ): ByteArray { val fileType = majorBrand.ascii() + ByteArray(4) + brands.fold(byteArrayOf()) { bytes, brand -> bytes + brand.ascii() } val sampleEntry = box( @@ -84,6 +94,12 @@ class AnimatedAvifValidatorTest { writeUInt32(8, sampleSizes.size) sampleSizes.forEachIndexed { index, size -> writeUInt32(12 + index * 4, size) } } + val syncSampleTable = syncSamples?.let { samples -> + ByteArray(8 + samples.size * 4).apply { + writeUInt32(4, samples.size) + samples.forEachIndexed { index, sample -> writeUInt32(8 + index * 4, sample) } + } + } val mediaHeader = ByteArray(24).apply { writeUInt32(12, 8_000) writeUInt32(16, frames * frameDuration) @@ -91,7 +107,8 @@ class AnimatedAvifValidatorTest { val sampleTable = box("stsd", sampleDescription) + box("stts", sampleTiming) + - box("stsz", sampleSizeTable) + box("stsz", sampleSizeTable) + + (syncSampleTable?.let { box("stss", it) } ?: byteArrayOf()) val movie = box( "moov", box( diff --git a/app/src/test/kotlin/eu/kanade/tachiyomi/ui/player/scene/SceneAv1EncoderSelectorTest.kt b/app/src/test/kotlin/eu/kanade/tachiyomi/ui/player/scene/SceneAv1EncoderSelectorTest.kt new file mode 100644 index 0000000000..b700635fa1 --- /dev/null +++ b/app/src/test/kotlin/eu/kanade/tachiyomi/ui/player/scene/SceneAv1EncoderSelectorTest.kt @@ -0,0 +1,303 @@ +package eu.kanade.tachiyomi.ui.player.scene + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test + +class SceneAv1EncoderSelectorTest { + @Test + fun `landscape input pads the MediaCodec canvas to sixteen pixels`() { + val checkedSizes = mutableListOf() + val selection = selectAv1Encoder( + source = SceneVideoDimensions(width = 320, height = 180), + candidates = sequenceOf( + candidate { size, _ -> + checkedSizes += size + size == SceneVideoDimensions(width = 320, height = 192) + }, + ), + ) + + assertEquals( + Av1EncoderSelection( + name = ENCODER_NAME, + contentSize = SceneVideoDimensions(width = 320, height = 180), + outputSize = SceneVideoDimensions(width = 320, height = 192), + ), + selection, + ) + assertEquals(listOf(SceneVideoDimensions(width = 320, height = 192)), checkedSizes) + } + + @Test + fun `square input lowers its cap until it fits the codec block budget`() { + val selection = selectAv1Encoder( + source = SceneVideoDimensions(width = 640, height = 640), + candidates = sequenceOf( + candidate { size, _ -> + size.width.ceilDiv(16) * size.height.ceilDiv(16) <= 1_350 + }, + ), + ) + + assertEquals( + Av1EncoderSelection( + name = ENCODER_NAME, + contentSize = SceneVideoDimensions(width = 576, height = 576), + outputSize = SceneVideoDimensions(width = 576, height = 576), + ), + selection, + ) + } + + @Test + fun `common sixteen by nine sources select the exact same output geometry`() { + listOf( + SceneVideoDimensions(width = 640, height = 360), + SceneVideoDimensions(width = 1248, height = 702), + ).forEach { source -> + assertEquals( + SceneVideoDimensions(width = 640, height = 368), + selectAv1Encoder( + source = source, + candidates = sequenceOf(candidate()), + )?.outputSize, + ) + } + } + + @Test + fun `portrait input lowers both dimensions until the codec block budget fits`() { + val selection = selectAv1Encoder( + source = SceneVideoDimensions(width = 536, height = 640), + candidates = sequenceOf( + candidate { size, _ -> + size.width.ceilDiv(16) * size.height.ceilDiv(16) <= 1_350 + }, + ), + ) + + assertEquals(SceneVideoDimensions(width = 528, height = 640), selection?.outputSize) + } + + @Test + fun `codec alignment expands the canvas instead of squashing the source`() { + val checkedSizes = mutableListOf() + val selection = selectAv1Encoder( + source = SceneVideoDimensions(width = 320, height = 180), + candidates = sequenceOf( + candidate( + widthAlignment = 16, + heightAlignment = 16, + supportsSizeAndRate = { size, _ -> + checkedSizes += size + true + }, + ), + ), + ) + + assertEquals(SceneVideoDimensions(width = 320, height = 180), selection?.contentSize) + assertEquals(SceneVideoDimensions(width = 320, height = 192), selection?.outputSize) + assertEquals(listOf(SceneVideoDimensions(width = 320, height = 192)), checkedSizes) + } + + @Test + fun `codec minimum dimensions expand only the canvas`() { + val selection = selectAv1Encoder( + source = SceneVideoDimensions(width = 32, height = 18), + candidates = sequenceOf( + candidate( + minimumWidth = 64, + minimumHeight = 64, + supportsSizeAndRate = { size, _ -> + size.width >= 64 && size.height >= 64 + }, + ), + ), + ) + + assertEquals(SceneVideoDimensions(width = 32, height = 18), selection?.contentSize) + assertEquals(SceneVideoDimensions(width = 64, height = 64), selection?.outputSize) + } + + @Test + fun `conditional codec dimensions add padding instead of reducing content`() { + val checkedSizes = mutableListOf() + val selection = selectAv1Encoder( + source = SceneVideoDimensions(width = 64, height = 64), + candidates = sequenceOf( + candidate( + supportedWidthsForHeight = { height -> + if (height == 64) 128..640 else null + }, + supportsSizeAndRate = { size, _ -> + checkedSizes += size + size == SceneVideoDimensions(width = 128, height = 64) + }, + ), + ), + ) + + assertEquals(SceneVideoDimensions(width = 64, height = 64), selection?.contentSize) + assertEquals(SceneVideoDimensions(width = 128, height = 64), selection?.outputSize) + assertEquals(listOf(SceneVideoDimensions(width = 128, height = 64)), checkedSizes) + } + + @Test + fun `conditional codec range checks wider canvases until the frame rate is supported`() { + val checkedSizes = mutableListOf() + val selection = selectAv1Encoder( + source = SceneVideoDimensions(width = 64, height = 64), + candidates = sequenceOf( + candidate( + widthAlignment = 64, + heightAlignment = 64, + supportedWidthsForHeight = { height -> + if (height == 64) 64..128 else null + }, + supportsSizeAndRate = { size, _ -> + checkedSizes += size + size == SceneVideoDimensions(width = 128, height = 64) + }, + ), + ), + ) + + assertEquals(SceneVideoDimensions(width = 64, height = 64), selection?.contentSize) + assertEquals(SceneVideoDimensions(width = 128, height = 64), selection?.outputSize) + assertEquals( + listOf( + SceneVideoDimensions(width = 64, height = 64), + SceneVideoDimensions(width = 128, height = 64), + ), + checkedSizes, + ) + } + + @Test + fun `invalid conditional height query does not reject a later padded canvas`() { + val selection = selectAv1Encoder( + source = SceneVideoDimensions(width = 64, height = 64), + candidates = sequenceOf( + candidate( + widthAlignment = 64, + heightAlignment = 64, + supportedWidthsForHeight = { height -> + if (height == 64) { + throw IllegalArgumentException("unsupported height") + } + if (height == 128) 64..64 else null + }, + supportsSizeAndRate = { size, _ -> + size == SceneVideoDimensions(width = 64, height = 128) + }, + ), + ), + ) + + assertEquals(SceneVideoDimensions(width = 64, height = 64), selection?.contentSize) + assertEquals(SceneVideoDimensions(width = 64, height = 128), selection?.outputSize) + } + + @Test + fun `highest resolution wins across compatible encoders`() { + val selection = selectAv1Encoder( + source = SceneVideoDimensions(width = 640, height = 360), + candidates = sequenceOf( + candidate( + name = "limited.encoder", + supportsSizeAndRate = { size, _ -> size.width <= 320 }, + ), + candidate(name = "full.encoder"), + ), + ) + + assertEquals("full.encoder", selection?.name) + assertEquals(SceneVideoDimensions(width = 640, height = 360), selection?.contentSize) + } + + @Test + fun `selection never exceeds the production output bound`() { + val selection = selectAv1Encoder( + source = SceneVideoDimensions(width = 1_600, height = 900), + candidates = sequenceOf(candidate()), + ) + + assertEquals(SceneVideoDimensions(width = 640, height = 368), selection?.outputSize) + } + + @Test + fun `narrow inputs reduce the long edge instead of visibly changing aspect`() { + val selection = selectAv1Encoder( + source = SceneVideoDimensions(width = 3, height = 640), + candidates = sequenceOf(candidate()), + ) + + assertEquals(SceneVideoDimensions(width = 2, height = 426), selection?.contentSize) + assertNull( + selectAv1Encoder( + source = SceneVideoDimensions(width = 1, height = 640), + candidates = sequenceOf(candidate()), + ), + ) + assertNull( + selectAv1Encoder( + source = SceneVideoDimensions(width = Int.MAX_VALUE, height = 1), + candidates = sequenceOf(candidate()), + ), + ) + } + + @Test + fun `required MediaCodec format and quality capabilities remain enforced`() { + val unsupported = sequenceOf( + candidate(supportsPlanarYuv420 = false), + candidate(supportsConstantQuality = false), + candidate(supportsTargetQuality = false), + ) + + assertNull( + selectAv1Encoder( + source = SceneVideoDimensions(width = 1920, height = 1080), + candidates = unsupported, + ), + ) + } + + private fun candidate( + name: String = ENCODER_NAME, + supportsPlanarYuv420: Boolean = true, + supportsConstantQuality: Boolean = true, + supportsTargetQuality: Boolean = true, + widthAlignment: Int = 2, + heightAlignment: Int = 2, + minimumWidth: Int = 2, + minimumHeight: Int = 2, + maximumWidth: Int = Int.MAX_VALUE, + maximumHeight: Int = Int.MAX_VALUE, + supportedWidthsForHeight: (Int) -> IntRange? = { + minimumWidth..maximumWidth + }, + supportsSizeAndRate: (SceneVideoDimensions, Double) -> Boolean = { _, _ -> true }, + ) = Av1EncoderCandidate( + name = name, + supportsPlanarYuv420 = supportsPlanarYuv420, + supportsConstantQuality = supportsConstantQuality, + supportsTargetQuality = supportsTargetQuality, + widthAlignment = widthAlignment, + heightAlignment = heightAlignment, + minimumWidth = minimumWidth, + minimumHeight = minimumHeight, + maximumWidth = maximumWidth, + maximumHeight = maximumHeight, + supportedWidthsForHeight = supportedWidthsForHeight, + supportsSizeAndRate = supportsSizeAndRate, + ) + + private fun Int.ceilDiv(divisor: Int): Int = (this + divisor - 1) / divisor + + private companion object { + const val ENCODER_NAME = "c2.android.av1.encoder" + } +} diff --git a/app/src/test/kotlin/eu/kanade/tachiyomi/ui/player/scene/SceneMediaProbeTest.kt b/app/src/test/kotlin/eu/kanade/tachiyomi/ui/player/scene/SceneMediaProbeTest.kt index 4f7eea75b5..fc2a7517d1 100644 --- a/app/src/test/kotlin/eu/kanade/tachiyomi/ui/player/scene/SceneMediaProbeTest.kt +++ b/app/src/test/kotlin/eu/kanade/tachiyomi/ui/player/scene/SceneMediaProbeTest.kt @@ -1,6 +1,8 @@ package eu.kanade.tachiyomi.ui.player.scene +import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test @@ -21,12 +23,21 @@ class SceneMediaProbeTest { } @Test - fun `ten bit and HDR video are rejected`() { + fun `ten bit SDR video is safe`() { listOf( "pix_fmt=yuv420p10le\ncolor_transfer=bt709", + "pix_fmt=yuv420p\nbits_per_raw_sample=10", + "pix_fmt=yuv420p10le\nprofile=Main 10", + ).forEach { output -> + assertTrue(SceneMediaProbe.inspect(output)) + } + } + + @Test + fun `HDR video is rejected`() { + listOf( "pix_fmt=yuv420p\ncolor_transfer=smpte2084", "pix_fmt=yuv420p\ncolor_primaries=bt2020", - "pix_fmt=yuv420p\nbits_per_raw_sample=10", ).forEach { output -> assertFalse(SceneMediaProbe.inspect(output)) } @@ -43,6 +54,74 @@ class SceneMediaProbeTest { } } + @Test + fun `video inspection returns dimensions after display rotation`() { + assertEquals( + SceneVideoDimensions(width = 320, height = 180), + SceneMediaProbe.inspectVideo( + "width=320\nheight=180\npix_fmt=yuv420p\ncolor_transfer=bt709", + ), + ) + assertEquals( + SceneVideoDimensions(width = 1080, height = 1920), + SceneMediaProbe.inspectVideo( + "width=1920\nheight=1080\npix_fmt=yuv420p\ncolor_transfer=bt709\nrotation=90", + ), + ) + } + + @Test + fun `video inspection accepts only orthogonal display rotation`() { + listOf(-90, 90, 270, 450).forEach { rotation -> + assertEquals( + SceneVideoDimensions(width = 180, height = 320), + SceneMediaProbe.inspectVideo( + "width=320\nheight=180\npix_fmt=yuv420p\nrotation=$rotation", + ), + ) + } + assertEquals( + SceneVideoDimensions(width = 320, height = 180), + SceneMediaProbe.inspectVideo( + "width=320\nheight=180\npix_fmt=yuv420p\nrotation=180", + ), + ) + assertNull( + SceneMediaProbe.inspectVideo( + "width=320\nheight=180\npix_fmt=yuv420p\nrotation=45", + ), + ) + } + + @Test + fun `video inspection applies sample aspect ratio before display rotation`() { + assertEquals( + SceneVideoDimensions(width = 768, height = 576), + SceneMediaProbe.inspectVideo( + "width=720\nheight=576\nsample_aspect_ratio=16:15\n" + + "pix_fmt=yuv420p\ncolor_transfer=bt709", + ), + ) + assertEquals( + SceneVideoDimensions(width = 576, height = 768), + SceneMediaProbe.inspectVideo( + "width=720\nheight=576\nsample_aspect_ratio=16:15\n" + + "pix_fmt=yuv420p\ncolor_transfer=bt709\nrotation=90", + ), + ) + } + + @Test + fun `video inspection requires safe positive dimensions`() { + assertNull(SceneMediaProbe.inspectVideo("pix_fmt=yuv420p")) + assertNull(SceneMediaProbe.inspectVideo("width=0\nheight=180\npix_fmt=yuv420p")) + assertNull( + SceneMediaProbe.inspectVideo( + "width=320\nheight=180\npix_fmt=yuv420p\ncolor_transfer=smpte2084", + ), + ) + } + @Test fun `audio probe requires a clear audio stream`() { assertTrue(SceneMediaProbe.inspectAudio("codec_type=audio\ncodec_name=aac")) diff --git a/app/src/test/kotlin/eu/kanade/tachiyomi/ui/player/scene/SceneMiningLogTest.kt b/app/src/test/kotlin/eu/kanade/tachiyomi/ui/player/scene/SceneMiningLogTest.kt new file mode 100644 index 0000000000..02ae967cc1 --- /dev/null +++ b/app/src/test/kotlin/eu/kanade/tachiyomi/ui/player/scene/SceneMiningLogTest.kt @@ -0,0 +1,110 @@ +package eu.kanade.tachiyomi.ui.player.scene + +import logcat.LogPriority +import logcat.LogcatLogger +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class SceneMiningLogTest { + private class RecordingLogger : LogcatLogger { + val entries = mutableListOf>() + + override fun isLoggable(priority: LogPriority) = true + + override fun log(priority: LogPriority, tag: String, message: String) { + entries += Triple(priority, tag, message) + } + } + + private val logger = RecordingLogger() + + @AfterEach + fun tearDown() { + if (LogcatLogger.isInstalled) LogcatLogger.uninstall() + } + + /** + * A previous build emitted the calling class as the tag and `[SceneMining]` as a message + * prefix, which made `adb logcat -s SceneMining` return nothing at all and read as though the + * instrumentation had never fired. + */ + @Test + fun `scene logs are tagged so a tag-only logcat filter finds them`() { + LogcatLogger.install(logger) + + sceneLog { "prepare: starting" } + + val (priority, tag, message) = logger.entries.single() + assertEquals(SCENE_LOG_TAG, tag) + // Release-derived builds install a logger with an INFO floor and would drop DEBUG. + assertEquals(LogPriority.INFO, priority) + assertTrue(message.endsWith("prepare: starting"), message) + // The caller is still identifiable now that it no longer occupies the tag. + assertTrue(message.startsWith("SceneMiningLogTest: "), message) + } + + @Test + fun `scene logs append the throwable so a swallowed cause survives`() { + LogcatLogger.install(logger) + + sceneLog(throwable = IllegalStateException("boom")) { "prepare: threw" } + + val message = logger.entries.single().third + assertTrue(message.contains("prepare: threw"), message) + assertTrue(message.contains("IllegalStateException"), message) + assertTrue(message.contains("boom"), message) + } + + @Test + fun `remote values keep only scheme and host`() { + assertEquals( + "https://media.example/", + redactSceneValue("https://media.example/a/b.mkv?token=super-secret&x-amz-signature=abc"), + ) + // The scheme is echoed as written, so an uppercase input stays uppercase. + assertEquals( + "HTTP://media.example/", + redactSceneValue("HTTP://media.example/a/b.mkv"), + ) + assertEquals("", redactSceneValue(null)) + assertEquals("", redactSceneValue(" ")) + } + + @Test + fun `local paths and content uris stay readable`() { + assertEquals("/video/episode.mkv", redactSceneValue("/video/episode.mkv")) + assertEquals( + "content://media/external/video/1", + redactSceneValue("content://media/external/video/1"), + ) + } + + @Test + fun `ffmpeg output keeps its diagnostics but loses embedded credentials`() { + val redacted = redactSceneLogLine( + "https://media.example/ep.mkv?token=secret: Server returned 403 Forbidden", + ) + + assertEquals( + "https://media.example/ Server returned 403 Forbidden", + redacted, + ) + assertFalse(redacted.contains("secret")) + } + + @Test + fun `every url on a multi line report is redacted`() { + val redacted = redactSceneLogLine( + """ + [tls @ 0x1] error opening https://a.example/x?sig=one + [http @ 0x2] retry https://b.example/y?sig=two failed + """.trimIndent(), + ) + + assertFalse(redacted.contains("sig=")) + assertEquals(2, Regex("").findAll(redacted).count()) + } +} diff --git a/app/src/test/kotlin/eu/kanade/tachiyomi/ui/player/scene/SceneVideoInputTest.kt b/app/src/test/kotlin/eu/kanade/tachiyomi/ui/player/scene/SceneVideoInputTest.kt index 243daccfde..66309392bf 100644 --- a/app/src/test/kotlin/eu/kanade/tachiyomi/ui/player/scene/SceneVideoInputTest.kt +++ b/app/src/test/kotlin/eu/kanade/tachiyomi/ui/player/scene/SceneVideoInputTest.kt @@ -4,6 +4,7 @@ import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertNotNull import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertThrows import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test @@ -62,14 +63,16 @@ class SceneVideoInputTest { } @Test - fun `AVIF command has the single bounded native recipe`() { + fun `AV1 encode muxes MediaCodec output directly into animated AVIF`() { val input = supportedInput() - val arguments = SceneFfmpegArguments.animatedAvif( + val arguments = SceneFfmpegArguments.animatedAvifMediaCodec( input = input, acquiredInputValue = "https://media.example/video.mp4", range = SceneTimeRange(1.25, 11.25), outputFile = "/cache/output.avif", encoderName = TEST_AV1_ENCODER_NAME, + contentSize = SceneVideoDimensions(width = 640, height = 360), + outputSize = SceneVideoDimensions(width = 640, height = 368), tlsCaFile = "/files/cacert.pem", ).toList() @@ -104,9 +107,51 @@ class SceneVideoInputTest { ), ) assertEquals(1, arguments.count { it == "-c:v" }) - assertEquals(SceneFfmpegArguments.FRAME_FILTER, arguments[arguments.indexOf("-vf") + 1]) - assertTrue(SceneFfmpegArguments.FRAME_FILTER.contains("force_divisible_by=16")) - assertFalse(arguments.any { it.contains("webp", ignoreCase = true) }) + assertEquals( + "fps=8,scale=w=640:h=360,setsar=1,pad=w=640:h=368:x=0:y=4:color=black", + arguments[arguments.indexOf("-vf") + 1], + ) + assertEquals("/cache/output.avif", arguments.last()) + } + + @Test + fun `AV1 encode pads aspect preserving content into the codec canvas`() { + val arguments = SceneFfmpegArguments.animatedAvifMediaCodec( + input = supportedInput(), + acquiredInputValue = "https://media.example/video.mp4", + range = SceneTimeRange(1.25, 11.25), + outputFile = "/cache/output.avif", + encoderName = TEST_AV1_ENCODER_NAME, + contentSize = SceneVideoDimensions(width = 320, height = 180), + outputSize = SceneVideoDimensions(width = 320, height = 192), + tlsCaFile = "/files/cacert.pem", + ).toList() + + assertEquals( + "fps=8,scale=w=320:h=180,setsar=1,pad=w=320:h=192:x=0:y=6:color=black", + arguments[arguments.indexOf("-vf") + 1], + ) + } + + @Test + fun `AV1 padding uses explicit chroma aligned offsets`() { + assertEquals( + "fps=8,scale=w=318:h=178,setsar=1,pad=w=320:h=192:x=0:y=6:color=black", + SceneFfmpegArguments.frameFilter( + contentSize = SceneVideoDimensions(width = 318, height = 178), + outputSize = SceneVideoDimensions(width = 320, height = 192), + ), + ) + } + + @Test + fun `AV1 filter rejects a canvas that is not sixteen pixel aligned`() { + assertThrows(IllegalArgumentException::class.java) { + SceneFfmpegArguments.frameFilter( + contentSize = SceneVideoDimensions(width = 320, height = 180), + outputSize = SceneVideoDimensions(width = 320, height = 180), + ) + } } @Test @@ -115,12 +160,14 @@ class SceneVideoInputTest { val range = SceneTimeRange(1.25, 2.25) val caFile = "/files/cacert.pem" val commands = listOf( - SceneFfmpegArguments.animatedAvif( + SceneFfmpegArguments.animatedAvifMediaCodec( input = input, acquiredInputValue = input.value, range = range, outputFile = "/cache/scene.avif", encoderName = TEST_AV1_ENCODER_NAME, + contentSize = SceneVideoDimensions(width = 640, height = 360), + outputSize = SceneVideoDimensions(width = 640, height = 368), tlsCaFile = caFile, ), SceneFfmpegArguments.videoProbe(input, input.value, caFile), @@ -138,6 +185,67 @@ class SceneVideoInputTest { } } + @Test + fun `embedded MP4 subtitles do not block scene probe or encode`() { + val input = supportedInput() + val commands = listOf( + SceneFfmpegArguments.videoProbe(input, input.value, "/files/cacert.pem"), + SceneFfmpegArguments.animatedAvifMediaCodec( + input = input, + acquiredInputValue = input.value, + range = SceneTimeRange(1.25, 2.25), + outputFile = "/cache/scene.avif", + encoderName = TEST_AV1_ENCODER_NAME, + contentSize = SceneVideoDimensions(width = 640, height = 360), + outputSize = SceneVideoDimensions(width = 640, height = 368), + tlsCaFile = "/files/cacert.pem", + ), + ) + + commands.forEach { command -> + val whitelist = command[command.indexOf("-codec_whitelist") + 1].split(',') + assertTrue("mov_text" in whitelist, "mov_text missing from $whitelist") + } + } + + /** + * SAF documents reach FFmpeg as FFmpegKit's `saf:.` pseudo-URL, because reopening a + * `/proc/self/fd/N` path re-checks permissions against the real file and loses the SAF grant. + * `-protocol_whitelist` would filter that scheme out, so it must stay confined to remote input. + */ + @Test + fun `content uri commands pass a saf value through without restricting protocols`() { + val input = SceneVideoInputSpec( + value = "content://com.android.externalstorage.documents/tree/primary%3AAnime", + kind = SceneVideoInputKind.CONTENT_URI, + headers = emptyList(), + ) + val safValue = "saf:37.mp4" + val range = SceneTimeRange(1.25, 2.25) + val commands = listOf( + SceneFfmpegArguments.animatedAvifMediaCodec( + input = input, + acquiredInputValue = safValue, + range = range, + outputFile = "/cache/scene.avif", + encoderName = TEST_AV1_ENCODER_NAME, + contentSize = SceneVideoDimensions(width = 640, height = 360), + outputSize = SceneVideoDimensions(width = 640, height = 368), + ), + SceneFfmpegArguments.videoProbe(input, safValue), + SceneFfmpegArguments.audioProbe(input, safValue), + SceneFfmpegArguments.sentenceAudio(input, safValue, range, "/cache/audio.m4a"), + ) + + commands.forEach { command -> + val arguments = command.toList() + assertTrue(safValue in arguments, "saf value missing from $arguments") + assertFalse("-protocol_whitelist" in arguments, "saf scheme would be filtered out") + // The content uri itself must never reach ffmpeg -- it is not an openable path. + assertFalse(arguments.any { it.startsWith("content://") }, arguments.toString()) + } + } + @Test fun `sentence audio maps the frozen selected stream`() { val input = supportedInput().copy(videoStreamIndex = 2, audioStreamIndex = 3) @@ -147,12 +255,14 @@ class SceneVideoInputTest { .sentenceAudio(input, input.value, range, "/cache/audio.m4a", caFile) .toList() val video = SceneFfmpegArguments - .animatedAvif( + .animatedAvifMediaCodec( input = input, acquiredInputValue = input.value, range = range, outputFile = "/cache/scene.avif", encoderName = TEST_AV1_ENCODER_NAME, + contentSize = SceneVideoDimensions(width = 640, height = 360), + outputSize = SceneVideoDimensions(width = 640, height = 368), tlsCaFile = caFile, ) .toList() diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c0ff5e74d6..9459d0db04 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -22,7 +22,7 @@ aniyomi-mpv-lib = "1.17.n" seeker = "1.2.2" media = "1.7.1" truetypeparser = "2.1.4" -ffmpeg-kit = "1.17" +ffmpeg-kit = "1.17.8.1" [libraries] desugar = "com.android.tools:desugar_jdk_libs:2.1.5" @@ -94,7 +94,7 @@ truetypeparser = { module = "io.github.yubyf:truetypeparser-light", version.ref torrentserver = "com.github.Diegopyl1209:torrentserver-aniyomi:c18f58e51b" media-router = "androidx.mediarouter:mediarouter:1.8.1" cast-play-services = "com.google.android.gms:play-services-cast-framework:22.1.0" -ffmpeg-kit = { module = "com.github.jmir1:ffmpeg-kit", version.ref = "ffmpeg-kit" } +ffmpeg-kit = { module = "com.github.bee-san:ffmpeg-kit", version.ref = "ffmpeg-kit" } smart-exception-java = "com.arthenica:smart-exception-java:0.2.1" nanohttpd = { module = "org.nanohttpd:nanohttpd", version = "2.3.1" }