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" }