From b09e2e03cce2dadd1a3f2c67d3b3429c70f616c8 Mon Sep 17 00:00:00 2001 From: Autumn Skerritt Date: Fri, 31 Jul 2026 06:53:51 +0000 Subject: [PATCH 1/3] fix(player): create animated AVIF scenes from MediaCodec output Accept ten-bit SDR input, normalize MediaCodec AV1 packets, remux them into animated AVIF, preserve native cancellation and cleanup, report fallback causes, and use FFmpegKit's SAF protocol for local video input. This squashes the complete fix/animated-avif-mediacodec-full series so AVIF work remains separate from the statistics feature. --- .../scene/AndroidSceneCaptureService.kt | 130 +++++++++++++++--- .../player/scene/AndroidSceneInputAcquirer.kt | 33 +++-- .../scene/FfmpegKitSceneCommandExecutor.kt | 80 +++++++++-- .../scene/MediaCodecAv1StreamNormalizer.kt | 103 ++++++++++++++ .../scene/PlayerSceneMiningCoordinator.kt | 18 ++- .../ui/player/scene/SceneCaptureRequest.kt | 91 ++++++++++-- .../ui/player/scene/SceneMediaProbe.kt | 13 +- .../ui/player/scene/SceneMiningLog.kt | 56 ++++++++ .../ui/player/scene/SceneVideoInput.kt | 69 ++++++++-- .../scene/AndroidSceneCaptureServiceTest.kt | 107 ++++++++++++-- .../MediaCodecAv1StreamNormalizerTest.kt | 57 ++++++++ .../ui/player/scene/SceneMediaProbeTest.kt | 13 +- .../ui/player/scene/SceneMiningLogTest.kt | 110 +++++++++++++++ .../ui/player/scene/SceneVideoInputTest.kt | 83 +++++++++-- 14 files changed, 868 insertions(+), 95 deletions(-) create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/player/scene/MediaCodecAv1StreamNormalizer.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/player/scene/SceneMiningLog.kt create mode 100644 app/src/test/kotlin/eu/kanade/tachiyomi/ui/player/scene/MediaCodecAv1StreamNormalizerTest.kt create mode 100644 app/src/test/kotlin/eu/kanade/tachiyomi/ui/player/scene/SceneMiningLogTest.kt 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..f0cc6599bc 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 @@ -32,79 +32,174 @@ internal class AndroidSceneCaptureService private constructor( ) override suspend fun prepare(request: SceneCaptureRequest): AnkiScreenshotPreparation { - val input = request.videoInput ?: return AnkiScreenshotPreparation.Failed(stillFallback = null) + val input = request.videoInput ?: run { + sceneLog { "prepare: videoInput was null" } + return AnkiScreenshotPreparation.Failed(stillFallback = null) + } val range = request.resolvedTiming?.animationRange - ?: return AnkiScreenshotPreparation.Failed(stillFallback = null) + ?: run { + sceneLog { "prepare: resolvedTiming.animationRange was null" } + return AnkiScreenshotPreparation.Failed(stillFallback = null) + } val encoderName = av1EncoderName() if (encoderName.isNullOrBlank()) { + sceneLog { "prepare: no usable av1 MediaCodec encoder found" } return AnkiScreenshotPreparation.Failed(stillFallback = null) } + sceneLog { + "prepare: starting, encoder=$encoderName range=${range.startSeconds}..${range.endSeconds} " + + "(${range.durationSeconds}s) input=${input.describe()}" + } return withContext(Dispatchers.IO) { if (!isSafe(input)) { + sceneLog { "prepare: input rejected by ffprobe safety check" } return@withContext AnkiScreenshotPreparation.Failed(stillFallback = null) } val lease = inputAcquirer.acquire(input) - ?: return@withContext AnkiScreenshotPreparation.Failed(stillFallback = null) + ?: run { + sceneLog { "prepare: could not acquire input lease" } + return@withContext AnkiScreenshotPreparation.Failed(stillFallback = null) + } sceneDirectory.mkdirs() - val output = File(sceneDirectory, "${UUID.randomUUID()}.avif") + val outputBaseName = UUID.randomUUID().toString() + val intermediate = File(sceneDirectory, "$outputBaseName.obu") + val output = File(sceneDirectory, "$outputBaseName.avif") val inputCleanup = SceneNativeCleanup(lease::close) - val outputCleanup = SceneNativeCleanup(output::delete) + val intermediateCleanup = SceneNativeCleanup(intermediate::delete) + var outputCleanup: SceneNativeCleanup? = null var transferred = false try { - val result = commandExecutor.executeFfmpeg( - SceneFfmpegArguments.animatedAvif( + val encodeResult = commandExecutor.executeFfmpeg( + SceneFfmpegArguments.av1MediaCodecPackets( input = input, acquiredInputValue = lease.ffmpegValue, range = range, - outputFile = output.absolutePath, + outputFile = intermediate.absolutePath, encoderName = encoderName, tlsCaFile = lease.tlsCaFile, ), ) { inputCleanup.nativeFinished() - outputCleanup.nativeFinished() + intermediateCleanup.nativeFinished() + } + inputCleanup.release() + when (encodeResult) { + SceneCommandResult.Failed -> { + sceneLog { "prepare: pass 1 (av1_mediacodec encode) failed" } + return@withContext AnkiScreenshotPreparation.Failed(stillFallback = null) + } + is SceneCommandResult.Success -> Unit + } + val rawPackets = intermediate + .takeIf { it.isFile && it.length() in 1..MAX_INTERMEDIATE_BYTES } + ?.readBytes() + if (rawPackets == null) { + sceneLog { + "prepare: intermediate unusable, isFile=${intermediate.isFile} " + + "length=${intermediate.length()} max=$MAX_INTERMEDIATE_BYTES" + } + return@withContext AnkiScreenshotPreparation.Failed(stillFallback = null) + } + val normalized = MediaCodecAv1StreamNormalizer.normalize(rawPackets) + if (normalized == null) { + sceneLog { "prepare: AV1 packet normalization rejected ${rawPackets.size} bytes" } + return@withContext AnkiScreenshotPreparation.Failed(stillFallback = null) } - when (result) { + sceneLog { "prepare: normalized ${rawPackets.size} -> ${normalized.size} bytes" } + intermediate.writeBytes(normalized) + + val currentOutputCleanup = SceneNativeCleanup(output::delete) + outputCleanup = currentOutputCleanup + val finishIntermediateRemuxUse = intermediateCleanup.retainNativeUse() + val remuxResult = commandExecutor.executeFfmpeg( + SceneFfmpegArguments.animatedAvifFromObu( + inputFile = intermediate.absolutePath, + outputFile = output.absolutePath, + ), + ) { + finishIntermediateRemuxUse() + currentOutputCleanup.nativeFinished() + } + when (remuxResult) { SceneCommandResult.Failed -> { + sceneLog { "prepare: pass 2 (AVIF remux) failed" } return@withContext AnkiScreenshotPreparation.Failed(stillFallback = null) } is SceneCommandResult.Success -> Unit } - val info = validate(output) - ?.takeIf { + val validated = validate(output) + if (validated == null) { + sceneLog { "prepare: AVIF structure validation failed, ${output.length()} bytes" } + return@withContext AnkiScreenshotPreparation.Failed(stillFallback = null) + } + val info = validated + .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) + ?: run { + sceneLog { + "prepare: AVIF outside bounds, width=${validated.width} height=${validated.height} " + + "(max $MAX_OUTPUT_DIMENSION) frameCount=${validated.frameCount} " + + "(need 2..${SceneFfmpegArguments.MAX_FRAME_COUNT}) " + + "durationMs=${validated.totalDurationMillis}" + } + return@withContext AnkiScreenshotPreparation.Failed(stillFallback = null) + } val animation = AnkiMediaNaming.sceneFileSource(output) transferred = true + sceneLog { + "prepare: success, ${info.frameCount} frames ${info.width}x${info.height} " + + "${info.totalDurationMillis}ms ${output.length()} bytes" + } AnkiScreenshotPreparation.Animated( animation = animation, stillFallback = null, ) } catch (e: CancellationException) { throw e - } catch (_: Exception) { + } catch (e: Exception) { + sceneLog(throwable = e) { "prepare: threw during scene generation" } AnkiScreenshotPreparation.Failed(stillFallback = null) } finally { inputCleanup.release() - if (!transferred) outputCleanup.release() + intermediateCleanup.release() + if (!transferred) { + outputCleanup?.release() ?: output.delete() + } } } } private suspend fun isSafe(input: SceneVideoInputSpec): Boolean { - val lease = inputAcquirer.acquire(input) ?: return false + val lease = inputAcquirer.acquire(input) ?: run { + sceneLog { "isSafe: could not acquire input lease for probe" } + return false + } 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) + when (result) { + SceneCommandResult.Failed -> { + sceneLog { "isSafe: ffprobe failed to run" } + false + } + is SceneCommandResult.Success -> { + // An absent pix_fmt and an HDR rejection both return false, so print the output. + SceneMediaProbe.inspect(result.output).also { accepted -> + if (!accepted) { + val output = redactSceneLogLine(result.output) + sceneLog { "isSafe: probe rejected input, ffprobe output=<<<$output>>>" } + } + } + } + } } finally { cleanup.release() } @@ -113,6 +208,7 @@ internal class AndroidSceneCaptureService private constructor( internal companion object { private const val SCENE_CACHE_DIRECTORY = "chimahon_scene_capture" private const val MAX_OUTPUT_DIMENSION = 640 + private const val MAX_INTERMEDIATE_BYTES = 12L * 1024L * 1024L fun forTests( sceneDirectory: File, 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..a72f82c86a 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 @@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.ui.player.scene import android.content.Context import android.net.Uri +import com.arthenica.ffmpegkit.FFmpegKitConfig import java.io.Closeable import java.io.File @@ -40,16 +41,32 @@ 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. + */ 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 + } + // Registers the uri and returns "saf:."; the descriptor is opened lazily, by + // FFmpegKit's native handler, and closed by it once FFmpeg closes the stream. The + // registration is consumed by that first open, so a lease must not be reused across + // invocations -- every call site here acquires a fresh one per FFmpeg command. + val safValue = runCatching { + FFmpegKitConfig.getSafParameterForRead(applicationContext, uri) + }.getOrNull()?.takeIf(String::isNotBlank) ?: run { + sceneLog { "acquire: FFmpegKit refused a saf parameter for the content uri" } + return null } + return acquired(safValue) } private fun acquired( 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/MediaCodecAv1StreamNormalizer.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/player/scene/MediaCodecAv1StreamNormalizer.kt new file mode 100644 index 0000000000..c7fd9f227e --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/player/scene/MediaCodecAv1StreamNormalizer.kt @@ -0,0 +1,103 @@ +package eu.kanade.tachiyomi.ui.player.scene + +import java.io.ByteArrayOutputStream + +/** + * Removes Android's AV1CodecConfigurationRecord and restores temporal-unit boundaries that raw + * packet output loses. FFmpeg's MediaCodec wrapper incorrectly prepends the record to frame data. + */ +internal object MediaCodecAv1StreamNormalizer { + fun normalize(input: ByteArray): ByteArray? { + if (input.size < AV1C_HEADER_SIZE + 1) return null + val start = if (isAv1CodecConfigurationRecord(input)) AV1C_HEADER_SIZE else 0 + val obus = parseObus(input, start) ?: return null + if (obus.none { it.type == OBU_SEQUENCE_HEADER } || + obus.count { it.type == OBU_FRAME || it.type == OBU_FRAME_HEADER } < 2 + ) { + return null + } + + val output = ByteArrayOutputStream(input.size + obus.size * TEMPORAL_DELIMITER.size) + var frameStarted = false + if (obus.first().type != OBU_TEMPORAL_DELIMITER) { + output.write(TEMPORAL_DELIMITER) + } + obus.forEach { obu -> + when (obu.type) { + OBU_TEMPORAL_DELIMITER -> { + if (!output.endsWithTemporalDelimiter()) { + output.write(TEMPORAL_DELIMITER) + } + frameStarted = false + } + OBU_FRAME, + OBU_FRAME_HEADER, + -> { + if (frameStarted) output.write(TEMPORAL_DELIMITER) + output.write(input, obu.offset, obu.length) + frameStarted = true + } + else -> output.write(input, obu.offset, obu.length) + } + } + return output.toByteArray() + } + + private fun isAv1CodecConfigurationRecord(input: ByteArray): Boolean { + val first = input[0].toInt() and 0xff + return first and 0x80 != 0 && first and 0x7f == 1 + } + + private fun parseObus(input: ByteArray, start: Int): List? { + val result = mutableListOf() + var offset = start + while (offset < input.size) { + val header = input[offset].toInt() and 0xff + if (header and 0x80 != 0 || header and 0x01 != 0 || header and 0x02 == 0) return null + val extensionBytes = if (header and 0x04 != 0) 1 else 0 + val sizeOffset = offset + 1 + extensionBytes + if (sizeOffset >= input.size) return null + val size = readLeb128(input, sizeOffset) ?: return null + val payloadOffset = sizeOffset + size.bytes + val end = payloadOffset.toLong() + size.value + if (end > input.size || end > Int.MAX_VALUE) return null + result += Obu( + type = header shr 3 and 0x0f, + offset = offset, + length = end.toInt() - offset, + ) + offset = end.toInt() + } + return result.takeIf { it.isNotEmpty() } + } + + private fun readLeb128(input: ByteArray, offset: Int): Leb128? { + var value = 0L + for (index in 0 until MAX_LEB128_BYTES) { + val position = offset + index + if (position >= input.size) return null + val byte = input[position].toInt() and 0xff + value = value or ((byte and 0x7f).toLong() shl (index * 7)) + if (byte and 0x80 == 0) return Leb128(value, index + 1) + } + return null + } + + private fun ByteArrayOutputStream.endsWithTemporalDelimiter(): Boolean { + val bytes = toByteArray() + return bytes.size >= TEMPORAL_DELIMITER.size && + bytes[bytes.lastIndex - 1] == TEMPORAL_DELIMITER[0] && + bytes[bytes.lastIndex] == TEMPORAL_DELIMITER[1] + } + + private data class Obu(val type: Int, val offset: Int, val length: Int) + private data class Leb128(val value: Long, val bytes: Int) + + private val TEMPORAL_DELIMITER = byteArrayOf(0x12, 0x00) + private const val AV1C_HEADER_SIZE = 4 + private const val MAX_LEB128_BYTES = 8 + private const val OBU_SEQUENCE_HEADER = 1 + private const val OBU_TEMPORAL_DELIMITER = 2 + private const val OBU_FRAME_HEADER = 3 + private const val OBU_FRAME = 6 +} 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/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/SceneMediaProbe.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/player/scene/SceneMediaProbe.kt index 1466b296f1..b111c0cdcc 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 @@ -24,19 +24,9 @@ internal object SceneMediaProbe { 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 @@ -48,6 +38,5 @@ internal object SceneMediaProbe { } 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 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/SceneVideoInput.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/player/scene/SceneVideoInput.kt index b6f19d112a..3aff14ceb5 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,7 +190,7 @@ internal object SceneVideoInputResolver { } internal object SceneFfmpegArguments { - fun animatedAvif( + fun av1MediaCodecPackets( input: SceneVideoInputSpec, acquiredInputValue: String, range: SceneTimeRange, @@ -201,15 +228,37 @@ internal object SceneFfmpegArguments { add("1") add("-pix_fmt") add("yuv420p") - add("-loop") - add("0") add("-f") - add("avif") + add("data") add("-y") add(outputFile) }.toTypedArray() } + fun animatedAvifFromObu( + inputFile: String, + outputFile: String, + ): Array { + return arrayOf( + "-f", + "obu", + "-framerate", + FRAME_RATE.toInt().toString(), + "-i", + inputFile, + "-map", + "0:v:0", + "-c:v", + "copy", + "-loop", + "0", + "-f", + "avif", + "-y", + outputFile, + ) + } + fun videoProbe( input: SceneVideoInputSpec, acquiredInputValue: String, 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..d5e8bb05aa 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,14 @@ import android.graphics.Bitmap import chimahon.anki.AnkiScreenshotPreparation import io.mockk.every import io.mockk.mockk +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancelAndJoin +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,7 +25,7 @@ class AndroidSceneCaptureServiceTest { lateinit var tempDirectory: File @Test - fun `successful capture uses exact bounded AVIF command`() = runTest { + fun `successful capture normalizes AV1 packets then remuxes them to animated AVIF`() = runTest { val executor = RecordingExecutor(writeOutput = true) val service = service( executor = executor, @@ -30,10 +37,23 @@ class AndroidSceneCaptureServiceTest { val animated = result as AnkiScreenshotPreparation.Animated assertEquals("avif", animated.animation.extension) assertTrue(animated.animation.preferredBaseName.startsWith("chimahon_scene_")) + assertEquals(2, executor.ffmpegArguments.size) assertArrayEquals( - expectedAvifArguments(animated.animation.file.absolutePath), - executor.ffmpegArguments, + expectedAv1Arguments(animated.animation.file.absolutePath.replaceAfterLast('.', "obu")), + executor.ffmpegArguments[0], ) + assertArrayEquals( + expectedAvifRemuxArguments( + animated.animation.file.absolutePath.replaceAfterLast('.', "obu"), + animated.animation.file.absolutePath, + ), + executor.ffmpegArguments[1], + ) + val intermediate = File( + animated.animation.file.parentFile, + "${animated.animation.file.nameWithoutExtension}.obu", + ) + assertFalse(intermediate.exists()) animated.animation.file.delete() } @@ -68,6 +88,32 @@ class AndroidSceneCaptureServiceTest { assertTrue(sceneDirectory.listFiles().isNullOrEmpty()) } + @Test + fun `cancellation reaches native remux and defers file cleanup until native return`() = runTest { + val executor = RecordingExecutor(writeOutput = true, suspendRemux = true) + val service = service(executor = executor) + val preparation = launch { service.prepare(request()) } + withContext(Dispatchers.Default) { + withTimeout(5_000) { executor.remuxStarted.await() } + } + val remuxArguments = executor.ffmpegArguments.last() + val intermediate = File(remuxArguments[remuxArguments.indexOf("-i") + 1]) + val output = File(remuxArguments.last()) + + preparation.cancelAndJoin() + withContext(Dispatchers.Default) { + withTimeout(5_000) { executor.cancellationObserved.await() } + } + + assertTrue(intermediate.isFile) + assertTrue(output.isFile) + + executor.finishNative() + + assertFalse(intermediate.exists()) + assertFalse(output.exists()) + } + private fun service( executor: RecordingExecutor, validate: (File) -> AnimatedAvifInfo? = { @@ -110,7 +156,7 @@ class AndroidSceneCaptureServiceTest { ) } - private fun expectedAvifArguments(output: String): Array { + private fun expectedAv1Arguments(output: String): Array { return arrayOf( "-codec_whitelist", SceneFfmpegArguments.ALLOWED_INPUT_DECODERS, @@ -151,6 +197,25 @@ class AndroidSceneCaptureServiceTest { "1", "-pix_fmt", "yuv420p", + "-f", + "data", + "-y", + output, + ) + } + + private fun expectedAvifRemuxArguments(input: String, output: String): Array { + return arrayOf( + "-f", + "obu", + "-framerate", + "8", + "-i", + input, + "-map", + "0:v:0", + "-c:v", + "copy", "-loop", "0", "-f", @@ -162,21 +227,39 @@ class AndroidSceneCaptureServiceTest { private class RecordingExecutor( private val writeOutput: Boolean, + private val suspendRemux: Boolean = false, ) : SceneCommandExecutor { var probeCalls = 0 var ffmpegCalls = 0 - var ffmpegArguments: Array = emptyArray() + val ffmpegArguments = mutableListOf>() + val remuxStarted = CompletableDeferred() + val cancellationObserved = CompletableDeferred() + private lateinit var onRemuxFinished: () -> 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) { + val bytes = when (output.extension) { + "obu" -> mediaCodecAv1PacketStream() + else -> byteArrayOf(1, 2, 3) + } + output.writeBytes(bytes) + } + if (suspendRemux && output.extension == "avif") { + onRemuxFinished = onNativeFinished + remuxStarted.complete(Unit) + return suspendCancellableCoroutine { continuation -> + continuation.invokeOnCancellation { + cancellationObserved.complete(Unit) + } } + } + return try { SceneCommandResult.Success() } finally { onNativeFinished() @@ -196,6 +279,10 @@ class AndroidSceneCaptureServiceTest { onNativeFinished() } } + + fun finishNative() { + onRemuxFinished() + } } private companion object { diff --git a/app/src/test/kotlin/eu/kanade/tachiyomi/ui/player/scene/MediaCodecAv1StreamNormalizerTest.kt b/app/src/test/kotlin/eu/kanade/tachiyomi/ui/player/scene/MediaCodecAv1StreamNormalizerTest.kt new file mode 100644 index 0000000000..f16e9caa19 --- /dev/null +++ b/app/src/test/kotlin/eu/kanade/tachiyomi/ui/player/scene/MediaCodecAv1StreamNormalizerTest.kt @@ -0,0 +1,57 @@ +package eu.kanade.tachiyomi.ui.player.scene + +import org.junit.jupiter.api.Assertions.assertArrayEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test + +class MediaCodecAv1StreamNormalizerTest { + @Test + fun `strips av1C and restores temporal boundaries`() { + assertArrayEquals( + byteArrayOf( + 0x12, + 0x00, + 0x0a, + 0x01, + 0x00, + 0x32, + 0x01, + 0x11, + 0x12, + 0x00, + 0x32, + 0x01, + 0x22, + ), + MediaCodecAv1StreamNormalizer.normalize(mediaCodecAv1PacketStream()), + ) + } + + @Test + fun `rejects malformed and single-frame streams`() { + assertNull(MediaCodecAv1StreamNormalizer.normalize(byteArrayOf(0x81.toByte(), 0x00))) + assertNull( + MediaCodecAv1StreamNormalizer.normalize( + mediaCodecAv1PacketStream().dropLast(3).toByteArray(), + ), + ) + } +} + +internal fun mediaCodecAv1PacketStream(): ByteArray { + return byteArrayOf( + 0x81.toByte(), + 0x00, + 0x00, + 0x00, + 0x0a, + 0x01, + 0x00, + 0x32, + 0x01, + 0x11, + 0x32, + 0x01, + 0x22, + ) +} 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..4364aabc9f 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 @@ -21,12 +21,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)) } 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..7210813fa7 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 @@ -62,13 +62,13 @@ class SceneVideoInputTest { } @Test - fun `AVIF command has the single bounded native recipe`() { + fun `AV1 encode writes raw MediaCodec packets`() { val input = supportedInput() - val arguments = SceneFfmpegArguments.animatedAvif( + val arguments = SceneFfmpegArguments.av1MediaCodecPackets( input = input, acquiredInputValue = "https://media.example/video.mp4", range = SceneTimeRange(1.25, 11.25), - outputFile = "/cache/output.avif", + outputFile = "/cache/output.obu", encoderName = TEST_AV1_ENCODER_NAME, tlsCaFile = "/files/cacert.pem", ).toList() @@ -88,7 +88,7 @@ class SceneVideoInputTest { ), ) assertTrue(arguments.containsAll(listOf("-ndk_codec", "1", "-pix_fmt", "yuv420p"))) - assertTrue(arguments.containsAll(listOf("-frames:v", "80", "-loop", "0", "-f", "avif"))) + assertTrue(arguments.containsAll(listOf("-frames:v", "80", "-f", "data"))) assertTrue( arguments.containsAll( listOf( @@ -106,7 +106,36 @@ 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) }) + assertFalse(arguments.contains("avif")) + assertFalse(arguments.contains("-loop")) + } + + @Test + fun `AVIF remux copies the normalized OBU stream`() { + assertEquals( + listOf( + "-f", + "obu", + "-framerate", + "8", + "-i", + "/cache/input.obu", + "-map", + "0:v:0", + "-c:v", + "copy", + "-loop", + "0", + "-f", + "avif", + "-y", + "/cache/output.avif", + ), + SceneFfmpegArguments.animatedAvifFromObu( + inputFile = "/cache/input.obu", + outputFile = "/cache/output.avif", + ).toList(), + ) } @Test @@ -115,11 +144,11 @@ class SceneVideoInputTest { val range = SceneTimeRange(1.25, 2.25) val caFile = "/files/cacert.pem" val commands = listOf( - SceneFfmpegArguments.animatedAvif( + SceneFfmpegArguments.av1MediaCodecPackets( input = input, acquiredInputValue = input.value, range = range, - outputFile = "/cache/scene.avif", + outputFile = "/cache/scene.obu", encoderName = TEST_AV1_ENCODER_NAME, tlsCaFile = caFile, ), @@ -138,6 +167,42 @@ class SceneVideoInputTest { } } + /** + * 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.av1MediaCodecPackets( + input = input, + acquiredInputValue = safValue, + range = range, + outputFile = "/cache/scene.obu", + encoderName = TEST_AV1_ENCODER_NAME, + ), + 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,11 +212,11 @@ class SceneVideoInputTest { .sentenceAudio(input, input.value, range, "/cache/audio.m4a", caFile) .toList() val video = SceneFfmpegArguments - .animatedAvif( + .av1MediaCodecPackets( input = input, acquiredInputValue = input.value, range = range, - outputFile = "/cache/scene.avif", + outputFile = "/cache/scene.obu", encoderName = TEST_AV1_ENCODER_NAME, tlsCaFile = caFile, ) From 6c6cbd202e5abebc47c7025f8c2a66900b2873f3 Mon Sep 17 00:00:00 2001 From: "Autumn (Bee)" Date: Fri, 31 Jul 2026 16:23:38 +0100 Subject: [PATCH 2/3] fix(player): make animated scene mining portable across devices (#19) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(player): bound isolated scene callback output * fix(player): isolate scene FFmpeg execution * fix(player): harden animated scene capture across devices * fix(player): guard startup and picture-in-picture * refactor(player): show scene mining progress as a top overlay * refactor(player): trim incidental complexity in scene isolation Fold SceneCommandCallbackDelivery into IsolatedSceneCommandService: the 128 KB Binder payload cap is kept, but the separate payload data class and the retry-with-empty-payload path go away — the retry only ever guarded an arbitrary exception, never a real TransactionTooLargeException, which the size cap already prevents. An oversized successful output now logs a distinct "dropping oversized output" line instead of masquerading as an ffmpeg/ffprobe failure. Drop the duplicate-request bookkeeping in the service: request IDs come from a single AtomicLong in the one main-process executor, so they cannot collide within a service process lifetime. Document why FFmpegKit runs in :scene_processing. The previous one-liner undersold an ABI-level duplicate-SONAME linker conflict between libmpv's and ffmpeg-kit's bundled libav*.so, which is not fixable by a mutex, load ordering, symbol visibility, or dlopen flags. Also record that AnimeDownloader and FFmpegUtils still run FFmpegKit in the main process, so this isolation protects only the scene-capture path. Cross-reference the manifest's android:process with SceneCommandProcess.SUFFIX so renaming one does not silently reintroduce full DI init in the FFmpeg-only child process. Remove selectAv1Encoder's maxOutputDimension parameter, a test-only knob production never passed. --------- Co-authored-by: Autumn Skerritt --- app/src/main/AndroidManifest.xml | 8 + .../player/scene/ISceneCommandCallback.aidl | 5 + .../ui/player/scene/ISceneCommandService.aidl | 14 + app/src/main/java/eu/kanade/tachiyomi/App.kt | 10 + .../tachiyomi/ui/player/AniyomiMPVView.kt | 28 ++ .../ui/player/MpvConfigDirectoryResolver.kt | 17 + .../ui/player/PictureInPictureGuard.kt | 21 + .../tachiyomi/ui/player/PlayerActivity.kt | 72 +++- .../tachiyomi/ui/player/PlayerViewModel.kt | 2 +- .../ui/player/SurfacePlaybackLoadGate.kt | 45 ++ .../ui/player/controls/PlayerControls.kt | 4 +- .../ui/player/controls/PlayerSceneMiningUi.kt | 92 +++- .../scene/AndroidSceneCaptureService.kt | 399 +++++++++++------- .../player/scene/AndroidSceneInputAcquirer.kt | 16 +- .../scene/IsolatedSceneCommandExecutor.kt | 228 ++++++++++ .../scene/IsolatedSceneCommandService.kt | 161 +++++++ .../player/scene/SceneAv1EncoderSelector.kt | 298 +++++++++++++ .../ui/player/scene/SceneCommandProcess.kt | 21 + .../ui/player/scene/SceneMediaProbe.kt | 87 +++- .../ui/player/scene/SceneSafInput.kt | 32 ++ .../player/scene/SceneSentenceAudioService.kt | 2 +- .../ui/player/scene/SceneVideoInput.kt | 59 ++- .../player/MpvConfigDirectoryResolverTest.kt | 79 ++++ .../ui/player/PictureInPictureGuardTest.kt | 89 ++++ .../ui/player/SurfacePlaybackLoadGateTest.kt | 114 +++++ .../scene/AndroidSceneCaptureServiceTest.kt | 181 +++++++- .../scene/SceneAv1EncoderSelectorTest.kt | 303 +++++++++++++ .../ui/player/scene/SceneMediaProbeTest.kt | 70 +++ .../ui/player/scene/SceneVideoInputTest.kt | 67 ++- 29 files changed, 2288 insertions(+), 236 deletions(-) create mode 100644 app/src/main/aidl/eu/kanade/tachiyomi/ui/player/scene/ISceneCommandCallback.aidl create mode 100644 app/src/main/aidl/eu/kanade/tachiyomi/ui/player/scene/ISceneCommandService.aidl create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/player/MpvConfigDirectoryResolver.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/player/PictureInPictureGuard.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/player/SurfacePlaybackLoadGate.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/player/scene/IsolatedSceneCommandExecutor.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/player/scene/IsolatedSceneCommandService.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/player/scene/SceneAv1EncoderSelector.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/player/scene/SceneCommandProcess.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/player/scene/SceneSafInput.kt create mode 100644 app/src/test/kotlin/eu/kanade/tachiyomi/ui/player/MpvConfigDirectoryResolverTest.kt create mode 100644 app/src/test/kotlin/eu/kanade/tachiyomi/ui/player/PictureInPictureGuardTest.kt create mode 100644 app/src/test/kotlin/eu/kanade/tachiyomi/ui/player/SurfacePlaybackLoadGateTest.kt create mode 100644 app/src/test/kotlin/eu/kanade/tachiyomi/ui/player/scene/SceneAv1EncoderSelectorTest.kt 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 f0cc6599bc..b6dd83bb8e 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,14 +22,14 @@ 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 { @@ -41,159 +42,230 @@ internal class AndroidSceneCaptureService private constructor( sceneLog { "prepare: resolvedTiming.animationRange was null" } return AnkiScreenshotPreparation.Failed(stillFallback = null) } - val encoderName = av1EncoderName() - if (encoderName.isNullOrBlank()) { - sceneLog { "prepare: no usable av1 MediaCodec encoder found" } - return AnkiScreenshotPreparation.Failed(stillFallback = null) - } - sceneLog { - "prepare: starting, encoder=$encoderName range=${range.startSeconds}..${range.endSeconds} " + - "(${range.durationSeconds}s) input=${input.describe()}" - } - return withContext(Dispatchers.IO) { - if (!isSafe(input)) { - sceneLog { "prepare: input rejected by ffprobe safety check" } - return@withContext AnkiScreenshotPreparation.Failed(stillFallback = null) - } - val lease = inputAcquirer.acquire(input) - ?: run { - sceneLog { "prepare: could not acquire input lease" } - return@withContext AnkiScreenshotPreparation.Failed(stillFallback = null) - } - sceneDirectory.mkdirs() - val outputBaseName = UUID.randomUUID().toString() - val intermediate = File(sceneDirectory, "$outputBaseName.obu") - val output = File(sceneDirectory, "$outputBaseName.avif") - val inputCleanup = SceneNativeCleanup(lease::close) - val intermediateCleanup = SceneNativeCleanup(intermediate::delete) - var outputCleanup: SceneNativeCleanup? = null - var transferred = false - try { - val encodeResult = commandExecutor.executeFfmpeg( - SceneFfmpegArguments.av1MediaCodecPackets( + 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 = intermediate.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 intermediate = File(sceneDirectory, "$outputBaseName.obu") + 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.av1MediaCodecPackets( + input = input, + acquiredInputValue = lease.ffmpegValue, + range = range, + outputFile = intermediate.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 intermediateCleanup = SceneNativeCleanup(intermediate::delete) + var outputCleanup: SceneNativeCleanup? = null + var transferred = false + return try { + val encodeResult = try { + commandExecutor.executeFfmpeg(encodeArguments) { inputCleanup.nativeFinished() intermediateCleanup.nativeFinished() } - inputCleanup.release() - when (encodeResult) { - SceneCommandResult.Failed -> { - sceneLog { "prepare: pass 1 (av1_mediacodec encode) failed" } - return@withContext AnkiScreenshotPreparation.Failed(stillFallback = null) - } - is SceneCommandResult.Success -> Unit - } - val rawPackets = intermediate - .takeIf { it.isFile && it.length() in 1..MAX_INTERMEDIATE_BYTES } - ?.readBytes() - if (rawPackets == null) { - sceneLog { - "prepare: intermediate unusable, isFile=${intermediate.isFile} " + - "length=${intermediate.length()} max=$MAX_INTERMEDIATE_BYTES" - } - return@withContext AnkiScreenshotPreparation.Failed(stillFallback = null) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + inputCleanup.nativeFinished() + intermediateCleanup.nativeFinished() + throw e + } + inputCleanup.release() + when (encodeResult) { + SceneCommandResult.Failed -> { + sceneLog { "prepare: pass 1 (av1_mediacodec encode) failed" } + return AnkiScreenshotPreparation.Failed(stillFallback = null) } - val normalized = MediaCodecAv1StreamNormalizer.normalize(rawPackets) - if (normalized == null) { - sceneLog { "prepare: AV1 packet normalization rejected ${rawPackets.size} bytes" } - return@withContext AnkiScreenshotPreparation.Failed(stillFallback = null) + is SceneCommandResult.Success -> Unit + } + val rawPackets = intermediate + .takeIf { it.isFile && it.length() in 1..MAX_INTERMEDIATE_BYTES } + ?.readBytes() + if (rawPackets == null) { + sceneLog { + "prepare: intermediate unusable, isFile=${intermediate.isFile} " + + "length=${intermediate.length()} max=$MAX_INTERMEDIATE_BYTES" } - sceneLog { "prepare: normalized ${rawPackets.size} -> ${normalized.size} bytes" } - intermediate.writeBytes(normalized) + return AnkiScreenshotPreparation.Failed(stillFallback = null) + } + val normalized = MediaCodecAv1StreamNormalizer.normalize(rawPackets) + if (normalized == null) { + sceneLog { "prepare: AV1 packet normalization rejected ${rawPackets.size} bytes" } + return AnkiScreenshotPreparation.Failed(stillFallback = null) + } + sceneLog { "prepare: normalized ${rawPackets.size} -> ${normalized.size} bytes" } + intermediate.writeBytes(normalized) - val currentOutputCleanup = SceneNativeCleanup(output::delete) - outputCleanup = currentOutputCleanup - val finishIntermediateRemuxUse = intermediateCleanup.retainNativeUse() - val remuxResult = commandExecutor.executeFfmpeg( - SceneFfmpegArguments.animatedAvifFromObu( - inputFile = intermediate.absolutePath, - outputFile = output.absolutePath, - ), - ) { + val remuxArguments = SceneFfmpegArguments.animatedAvifFromObu( + inputFile = intermediate.absolutePath, + outputFile = output.absolutePath, + ) + val currentOutputCleanup = SceneNativeCleanup(output::delete) + outputCleanup = currentOutputCleanup + val finishIntermediateRemuxUse = intermediateCleanup.retainNativeUse() + val remuxResult = try { + commandExecutor.executeFfmpeg(remuxArguments) { finishIntermediateRemuxUse() currentOutputCleanup.nativeFinished() } - when (remuxResult) { - SceneCommandResult.Failed -> { - sceneLog { "prepare: pass 2 (AVIF remux) failed" } - return@withContext 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@withContext AnkiScreenshotPreparation.Failed(stillFallback = null) - } - val info = validated - .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 - } - ?: run { - sceneLog { - "prepare: AVIF outside bounds, width=${validated.width} height=${validated.height} " + - "(max $MAX_OUTPUT_DIMENSION) frameCount=${validated.frameCount} " + - "(need 2..${SceneFfmpegArguments.MAX_FRAME_COUNT}) " + - "durationMs=${validated.totalDurationMillis}" - } - return@withContext AnkiScreenshotPreparation.Failed(stillFallback = null) - } - val animation = AnkiMediaNaming.sceneFileSource(output) - transferred = true - sceneLog { - "prepare: success, ${info.frameCount} frames ${info.width}x${info.height} " + - "${info.totalDurationMillis}ms ${output.length()} bytes" - } - AnkiScreenshotPreparation.Animated( - animation = animation, - stillFallback = null, - ) } catch (e: CancellationException) { throw e } catch (e: Exception) { - sceneLog(throwable = e) { "prepare: threw during scene generation" } - AnkiScreenshotPreparation.Failed(stillFallback = null) - } finally { - inputCleanup.release() - intermediateCleanup.release() - if (!transferred) { - outputCleanup?.release() ?: output.delete() + finishIntermediateRemuxUse() + currentOutputCleanup.nativeFinished() + throw e + } + when (remuxResult) { + SceneCommandResult.Failed -> { + sceneLog { "prepare: pass 2 (AVIF remux) 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(currentOutputCleanup) + 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() + intermediateCleanup.release() + if (!transferred) { + outputCleanup?.release() ?: output.delete() } } } - private suspend fun isSafe(input: SceneVideoInputSpec): Boolean { + private suspend fun inspectSafeVideo(input: SceneVideoInputSpec): SceneVideoDimensions? { val lease = inputAcquirer.acquire(input) ?: run { sceneLog { "isSafe: could not acquire input lease for probe" } - return false + 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, - ) + 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" } - false + null } is SceneCommandResult.Success -> { // An absent pix_fmt and an HDR rejection both return false, so print the output. - SceneMediaProbe.inspect(result.output).also { accepted -> - if (!accepted) { + SceneMediaProbe.inspectVideo(result.output).also { inspected -> + if (inspected == null) { val output = redactSceneLogLine(result.output) sceneLog { "isSafe: probe rejected input, ffprobe output=<<<$output>>>" } } @@ -207,7 +279,6 @@ internal class AndroidSceneCaptureService private constructor( internal companion object { private const val SCENE_CACHE_DIRECTORY = "chimahon_scene_capture" - private const val MAX_OUTPUT_DIMENSION = 640 private const val MAX_INTERMEDIATE_BYTES = 12L * 1024L * 1024L fun forTests( @@ -215,18 +286,18 @@ internal class AndroidSceneCaptureService private constructor( 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 && @@ -235,36 +306,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 a72f82c86a..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 @@ -2,7 +2,6 @@ package eu.kanade.tachiyomi.ui.player.scene import android.content.Context import android.net.Uri -import com.arthenica.ffmpegkit.FFmpegKitConfig import java.io.Closeable import java.io.File @@ -49,24 +48,15 @@ internal class AndroidSceneInputAcquirer( * 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. + * 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 uri = runCatching { Uri.parse(value) }.getOrNull() ?: run { sceneLog { "acquire: could not parse content uri" } return null } - // Registers the uri and returns "saf:."; the descriptor is opened lazily, by - // FFmpegKit's native handler, and closed by it once FFmpeg closes the stream. The - // registration is consumed by that first open, so a lease must not be reused across - // invocations -- every call site here acquires a fresh one per FFmpeg command. - val safValue = runCatching { - FFmpegKitConfig.getSafParameterForRead(applicationContext, uri) - }.getOrNull()?.takeIf(String::isNotBlank) ?: run { - sceneLog { "acquire: FFmpegKit refused a saf parameter for the content uri" } - return null - } - return acquired(safValue) + return acquired(SceneSafInput.encodeForRead(uri)) } private fun acquired( 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..82936811a0 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/player/scene/IsolatedSceneCommandExecutor.kt @@ -0,0 +1,228 @@ +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`) to avoid a duplicate-SONAME linker + * conflict with libmpv. + * + * Both AARs ship FFmpeg shared objects with the *same* SONAMEs (`libavcodec.so`, `libavformat.so`, + * `libavutil.so`, `libswscale.so`, ...): `aniyomi-mpv-lib`'s `libmpv.so` DT_NEEDEDs them, and + * `ffmpeg-kit` bundles its own build of the same names. Android's dynamic linker resolves by SONAME + * within a process namespace, so only one `libavcodec.so` et al. can be loaded per process, and + * whichever loads second silently gets the other's (differently configured, different-version) + * implementation. This is an ABI-level conflict: it is NOT fixable by a mutex, load ordering, symbol + * visibility, or `dlopen` flags. A separate process gives each library set its own linker namespace. + * The only in-process alternative would be renamed or statically-linked libraries, which is an + * upstream AAR change. + * + * Do not "simplify" this back into the main process: scene mining ran in-process before and the + * collision is device/timing-dependent, so its absence in a quick test is not evidence it is safe. + * + * Caveat: [eu.kanade.tachiyomi.data.animedownload.AnimeDownloader] and + * [eu.kanade.tachiyomi.util.storage.FFmpegUtils] still invoke FFmpegKit in the main process, so this + * isolation currently protects only the scene-capture path. + */ +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/SceneAv1EncoderSelector.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/player/scene/SceneAv1EncoderSelector.kt new file mode 100644 index 0000000000..c17d065197 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/player/scene/SceneAv1EncoderSelector.kt @@ -0,0 +1,298 @@ +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_PIXEL_ALIGNMENT) + ?: return@forEach + val heightAlignment = combinedAlignment(candidate.heightAlignment, SCENE_PIXEL_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_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/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 b111c0cdcc..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,24 +1,63 @@ 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")) { @@ -32,11 +71,31 @@ internal object SceneMediaProbe { 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 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/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 3aff14ceb5..34687c04e0 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 @@ -196,6 +196,8 @@ internal object SceneFfmpegArguments { range: SceneTimeRange, outputFile: String, encoderName: String, + contentSize: SceneVideoDimensions, + outputSize: SceneVideoDimensions, tlsCaFile: String? = null, ): Array { require(encoderName.isNotBlank()) { "AV1 encoder name must not be blank" } @@ -213,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") @@ -271,7 +273,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) @@ -369,14 +374,52 @@ 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, + ) { + "Scene output size must be even 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 d5e8bb05aa..3a79985a54 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,9 +4,12 @@ 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 @@ -58,21 +61,149 @@ class AndroidSceneCaptureServiceTest { } @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, 180, 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) @@ -116,24 +247,40 @@ class AndroidSceneCaptureServiceTest { 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) }, - av1EncoderName: () -> String? = { TEST_AV1_ENCODER_NAME }, + 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 }, + ), + ), + ) + }, ): 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, ) } @@ -182,7 +329,10 @@ class AndroidSceneCaptureServiceTest { "-t", "3", "-vf", - SceneFfmpegArguments.FRAME_FILTER, + SceneFfmpegArguments.frameFilter( + contentSize = SceneVideoDimensions(width = 320, height = 180), + outputSize = SceneVideoDimensions(width = 320, height = 180), + ), "-frames:v", "80", "-c:v", @@ -273,7 +423,8 @@ 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() 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..72375f23e2 --- /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 checks an aspect preserving codec aligned output`() { + val checkedSizes = mutableListOf() + val selection = selectAv1Encoder( + source = SceneVideoDimensions(width = 320, height = 180), + candidates = sequenceOf( + candidate { size, _ -> + checkedSizes += size + size == SceneVideoDimensions(width = 320, height = 180) + }, + ), + ) + + assertEquals( + Av1EncoderSelection( + name = ENCODER_NAME, + contentSize = SceneVideoDimensions(width = 320, height = 180), + outputSize = SceneVideoDimensions(width = 320, height = 180), + ), + selection, + ) + assertEquals(listOf(SceneVideoDimensions(width = 320, height = 180)), 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 = 360), + 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 = 630), 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 = 360), 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 4364aabc9f..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 @@ -52,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/SceneVideoInputTest.kt b/app/src/test/kotlin/eu/kanade/tachiyomi/ui/player/scene/SceneVideoInputTest.kt index 7210813fa7..b502535670 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 @@ -70,6 +70,8 @@ class SceneVideoInputTest { range = SceneTimeRange(1.25, 11.25), outputFile = "/cache/output.obu", encoderName = TEST_AV1_ENCODER_NAME, + contentSize = SceneVideoDimensions(width = 640, height = 360), + outputSize = SceneVideoDimensions(width = 640, height = 360), tlsCaFile = "/files/cacert.pem", ).toList() @@ -104,12 +106,44 @@ 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")) + assertEquals( + "fps=8,scale=w=640:h=360,setsar=1", + arguments[arguments.indexOf("-vf") + 1], + ) assertFalse(arguments.contains("avif")) assertFalse(arguments.contains("-loop")) } + @Test + fun `AV1 encode pads aspect preserving content into the codec canvas`() { + val arguments = SceneFfmpegArguments.av1MediaCodecPackets( + input = supportedInput(), + acquiredInputValue = "https://media.example/video.mp4", + range = SceneTimeRange(1.25, 11.25), + outputFile = "/cache/output.obu", + 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=180:x=0:y=0:color=black", + SceneFfmpegArguments.frameFilter( + contentSize = SceneVideoDimensions(width = 318, height = 178), + outputSize = SceneVideoDimensions(width = 320, height = 180), + ), + ) + } + @Test fun `AVIF remux copies the normalized OBU stream`() { assertEquals( @@ -150,6 +184,8 @@ class SceneVideoInputTest { range = range, outputFile = "/cache/scene.obu", encoderName = TEST_AV1_ENCODER_NAME, + contentSize = SceneVideoDimensions(width = 640, height = 360), + outputSize = SceneVideoDimensions(width = 640, height = 360), tlsCaFile = caFile, ), SceneFfmpegArguments.videoProbe(input, input.value, caFile), @@ -167,6 +203,29 @@ 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.av1MediaCodecPackets( + input = input, + acquiredInputValue = input.value, + range = SceneTimeRange(1.25, 2.25), + outputFile = "/cache/scene.obu", + encoderName = TEST_AV1_ENCODER_NAME, + contentSize = SceneVideoDimensions(width = 640, height = 360), + outputSize = SceneVideoDimensions(width = 640, height = 360), + 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. @@ -188,6 +247,8 @@ class SceneVideoInputTest { range = range, outputFile = "/cache/scene.obu", encoderName = TEST_AV1_ENCODER_NAME, + contentSize = SceneVideoDimensions(width = 640, height = 360), + outputSize = SceneVideoDimensions(width = 640, height = 360), ), SceneFfmpegArguments.videoProbe(input, safValue), SceneFfmpegArguments.audioProbe(input, safValue), @@ -218,6 +279,8 @@ class SceneVideoInputTest { range = range, outputFile = "/cache/scene.obu", encoderName = TEST_AV1_ENCODER_NAME, + contentSize = SceneVideoDimensions(width = 640, height = 360), + outputSize = SceneVideoDimensions(width = 640, height = 360), tlsCaFile = caFile, ) .toList() From 14fa5185c9f5bf70019edc4497217b1404138d21 Mon Sep 17 00:00:00 2001 From: "Autumn (Bee)" Date: Sat, 1 Aug 2026 00:46:32 +0100 Subject: [PATCH 3/3] fix(player): use patched ffmpeg-kit for portable animated AVIF (#20) * fix(player): use patched ffmpeg-kit for portable animated AVIF Point the ffmpeg-kit dependency at com.github.bee-san:ffmpeg-kit:1.17.1, a fork of jmir1/ffmpeg-kit 1.17 that backports one FFmpeg fix needed for animated AVIF scene mining: avformat/av1: fix uvlc loop past end of bitstream (FFmpeg e44d76f61f) libavformat/av1.c:uvlc() looped on 'while (get_bits_left(gb))'. When an earlier skip_bits_long() has already pushed the reader past the end of a truncated AV1 sequence header, get_bits_left() is negative -- which is truthy -- so the loop never terminates and leading_zeros climbs toward INT_MAX. uvlc() is reached from parse_sequence_header() via ff_isom_write_av1c(), the exact path that builds the av1C box when muxing av1_mediacodec output into AVIF. The fork is otherwise identical to upstream 1.17 (same FFmpeg n7.1 base, same components, same aniyomi SAF/custom-protocol patches). Fork sources and the idempotent android.sh patch loop: bee-san/ffmpeg-kit @ tag 1.17.1. Also correct the IsolatedSceneCommandExecutor doc comment: it claimed the process split avoids a duplicate-SONAME conflict because both AARs ship competing libav*.so. That is false -- aniyomi-mpv-lib ships no libav*.so; its libmpv.so DT_NEEDEDs the SONAMEs that ffmpeg-kit alone provides, so the two share one FFmpeg build (as the main-process AnimeDownloader/FFmpegUtils callers already do). The comment now records the real, unproven-here rationale (FFmpegKit global-state isolation, crash containment). * [verified] fix(player): mux MediaCodec AV1 directly to AVIF * fix(player): harden MediaCodec AVIF portability * build: consume MediaCodec reset ffmpeg-kit * build: consume immutable ffmpeg-kit artifact * build: consume AVIF first-frame guard artifact --- .../scene/AndroidSceneCaptureService.kt | 64 ++--------- .../ui/player/scene/AnimatedAvifValidator.kt | 23 ++++ .../scene/IsolatedSceneCommandExecutor.kt | 32 +++--- .../scene/MediaCodecAv1StreamNormalizer.kt | 103 ------------------ .../player/scene/SceneAv1EncoderSelector.kt | 5 +- .../ui/player/scene/SceneVideoInput.kt | 36 ++---- .../scene/AndroidSceneCaptureServiceTest.kt | 77 ++++--------- .../player/scene/AnimatedAvifValidatorTest.kt | 19 +++- .../MediaCodecAv1StreamNormalizerTest.kt | 57 ---------- .../scene/SceneAv1EncoderSelectorTest.kt | 14 +-- .../ui/player/scene/SceneVideoInputTest.kt | 80 ++++++-------- gradle/libs.versions.toml | 4 +- 12 files changed, 134 insertions(+), 380 deletions(-) delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/player/scene/MediaCodecAv1StreamNormalizer.kt delete mode 100644 app/src/test/kotlin/eu/kanade/tachiyomi/ui/player/scene/MediaCodecAv1StreamNormalizerTest.kt 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 b6dd83bb8e..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 @@ -95,7 +95,6 @@ internal class AndroidSceneCaptureService private constructor( ): AnkiScreenshotPreparation { sceneDirectory.mkdirs() val outputBaseName = UUID.randomUUID().toString() - val intermediate = File(sceneDirectory, "$outputBaseName.obu") val output = File(sceneDirectory, "$outputBaseName.avif") val lease = inputAcquirer.acquire(input) ?: run { @@ -103,11 +102,11 @@ internal class AndroidSceneCaptureService private constructor( return AnkiScreenshotPreparation.Failed(stillFallback = null) } val encodeArguments = try { - SceneFfmpegArguments.av1MediaCodecPackets( + SceneFfmpegArguments.animatedAvifMediaCodec( input = input, acquiredInputValue = lease.ffmpegValue, range = range, - outputFile = intermediate.absolutePath, + outputFile = output.absolutePath, encoderName = encoder.name, contentSize = encoder.contentSize, outputSize = encoder.outputSize, @@ -119,70 +118,25 @@ internal class AndroidSceneCaptureService private constructor( return AnkiScreenshotPreparation.Failed(stillFallback = null) } val inputCleanup = SceneNativeCleanup(lease::close) - val intermediateCleanup = SceneNativeCleanup(intermediate::delete) - var outputCleanup: SceneNativeCleanup? = null + val outputCleanup = SceneNativeCleanup(output::delete) var transferred = false return try { val encodeResult = try { commandExecutor.executeFfmpeg(encodeArguments) { inputCleanup.nativeFinished() - intermediateCleanup.nativeFinished() + outputCleanup.nativeFinished() } } catch (e: CancellationException) { throw e } catch (e: Exception) { inputCleanup.nativeFinished() - intermediateCleanup.nativeFinished() + outputCleanup.nativeFinished() throw e } inputCleanup.release() when (encodeResult) { SceneCommandResult.Failed -> { - sceneLog { "prepare: pass 1 (av1_mediacodec encode) failed" } - return AnkiScreenshotPreparation.Failed(stillFallback = null) - } - is SceneCommandResult.Success -> Unit - } - val rawPackets = intermediate - .takeIf { it.isFile && it.length() in 1..MAX_INTERMEDIATE_BYTES } - ?.readBytes() - if (rawPackets == null) { - sceneLog { - "prepare: intermediate unusable, isFile=${intermediate.isFile} " + - "length=${intermediate.length()} max=$MAX_INTERMEDIATE_BYTES" - } - return AnkiScreenshotPreparation.Failed(stillFallback = null) - } - val normalized = MediaCodecAv1StreamNormalizer.normalize(rawPackets) - if (normalized == null) { - sceneLog { "prepare: AV1 packet normalization rejected ${rawPackets.size} bytes" } - return AnkiScreenshotPreparation.Failed(stillFallback = null) - } - sceneLog { "prepare: normalized ${rawPackets.size} -> ${normalized.size} bytes" } - intermediate.writeBytes(normalized) - - val remuxArguments = SceneFfmpegArguments.animatedAvifFromObu( - inputFile = intermediate.absolutePath, - outputFile = output.absolutePath, - ) - val currentOutputCleanup = SceneNativeCleanup(output::delete) - outputCleanup = currentOutputCleanup - val finishIntermediateRemuxUse = intermediateCleanup.retainNativeUse() - val remuxResult = try { - commandExecutor.executeFfmpeg(remuxArguments) { - finishIntermediateRemuxUse() - currentOutputCleanup.nativeFinished() - } - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - finishIntermediateRemuxUse() - currentOutputCleanup.nativeFinished() - throw e - } - when (remuxResult) { - SceneCommandResult.Failed -> { - sceneLog { "prepare: pass 2 (AVIF remux) failed" } + sceneLog { "prepare: direct av1_mediacodec AVIF encode failed" } return AnkiScreenshotPreparation.Failed(stillFallback = null) } is SceneCommandResult.Success -> Unit @@ -214,7 +168,7 @@ internal class AndroidSceneCaptureService private constructor( animation = animation, stillFallback = null, ) - undeliveredOutput.set(currentOutputCleanup) + undeliveredOutput.set(outputCleanup) transferred = true sceneLog { "prepare: success, ${info.frameCount} frames ${info.width}x${info.height} " + @@ -228,9 +182,8 @@ internal class AndroidSceneCaptureService private constructor( return AnkiScreenshotPreparation.Failed(stillFallback = null) } finally { inputCleanup.release() - intermediateCleanup.release() if (!transferred) { - outputCleanup?.release() ?: output.delete() + outputCleanup.release() } } } @@ -279,7 +232,6 @@ internal class AndroidSceneCaptureService private constructor( internal companion object { private const val SCENE_CACHE_DIRECTORY = "chimahon_scene_capture" - private const val MAX_INTERMEDIATE_BYTES = 12L * 1024L * 1024L fun forTests( sceneDirectory: File, 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/IsolatedSceneCommandExecutor.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/player/scene/IsolatedSceneCommandExecutor.kt index 82936811a0..499a2f7861 100644 --- 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 @@ -10,25 +10,23 @@ import java.util.concurrent.atomic.AtomicLong import kotlin.coroutines.resume /** - * Runs FFmpegKit in a dedicated process (`:scene_processing`) to avoid a duplicate-SONAME linker - * conflict with libmpv. + * Runs FFmpegKit in a dedicated process (`:scene_processing`). * - * Both AARs ship FFmpeg shared objects with the *same* SONAMEs (`libavcodec.so`, `libavformat.so`, - * `libavutil.so`, `libswscale.so`, ...): `aniyomi-mpv-lib`'s `libmpv.so` DT_NEEDEDs them, and - * `ffmpeg-kit` bundles its own build of the same names. Android's dynamic linker resolves by SONAME - * within a process namespace, so only one `libavcodec.so` et al. can be loaded per process, and - * whichever loads second silently gets the other's (differently configured, different-version) - * implementation. This is an ABI-level conflict: it is NOT fixable by a mutex, load ordering, symbol - * visibility, or `dlopen` flags. A separate process gives each library set its own linker namespace. - * The only in-process alternative would be renamed or statically-linked libraries, which is an - * upstream AAR change. + * 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. * - * Do not "simplify" this back into the main process: scene mining ran in-process before and the - * collision is device/timing-dependent, so its absence in a quick test is not evidence it is safe. - * - * Caveat: [eu.kanade.tachiyomi.data.animedownload.AnimeDownloader] and - * [eu.kanade.tachiyomi.util.storage.FFmpegUtils] still invoke FFmpegKit in the main process, so this - * isolation currently protects only the scene-capture path. + * 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, diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/player/scene/MediaCodecAv1StreamNormalizer.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/player/scene/MediaCodecAv1StreamNormalizer.kt deleted file mode 100644 index c7fd9f227e..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/player/scene/MediaCodecAv1StreamNormalizer.kt +++ /dev/null @@ -1,103 +0,0 @@ -package eu.kanade.tachiyomi.ui.player.scene - -import java.io.ByteArrayOutputStream - -/** - * Removes Android's AV1CodecConfigurationRecord and restores temporal-unit boundaries that raw - * packet output loses. FFmpeg's MediaCodec wrapper incorrectly prepends the record to frame data. - */ -internal object MediaCodecAv1StreamNormalizer { - fun normalize(input: ByteArray): ByteArray? { - if (input.size < AV1C_HEADER_SIZE + 1) return null - val start = if (isAv1CodecConfigurationRecord(input)) AV1C_HEADER_SIZE else 0 - val obus = parseObus(input, start) ?: return null - if (obus.none { it.type == OBU_SEQUENCE_HEADER } || - obus.count { it.type == OBU_FRAME || it.type == OBU_FRAME_HEADER } < 2 - ) { - return null - } - - val output = ByteArrayOutputStream(input.size + obus.size * TEMPORAL_DELIMITER.size) - var frameStarted = false - if (obus.first().type != OBU_TEMPORAL_DELIMITER) { - output.write(TEMPORAL_DELIMITER) - } - obus.forEach { obu -> - when (obu.type) { - OBU_TEMPORAL_DELIMITER -> { - if (!output.endsWithTemporalDelimiter()) { - output.write(TEMPORAL_DELIMITER) - } - frameStarted = false - } - OBU_FRAME, - OBU_FRAME_HEADER, - -> { - if (frameStarted) output.write(TEMPORAL_DELIMITER) - output.write(input, obu.offset, obu.length) - frameStarted = true - } - else -> output.write(input, obu.offset, obu.length) - } - } - return output.toByteArray() - } - - private fun isAv1CodecConfigurationRecord(input: ByteArray): Boolean { - val first = input[0].toInt() and 0xff - return first and 0x80 != 0 && first and 0x7f == 1 - } - - private fun parseObus(input: ByteArray, start: Int): List? { - val result = mutableListOf() - var offset = start - while (offset < input.size) { - val header = input[offset].toInt() and 0xff - if (header and 0x80 != 0 || header and 0x01 != 0 || header and 0x02 == 0) return null - val extensionBytes = if (header and 0x04 != 0) 1 else 0 - val sizeOffset = offset + 1 + extensionBytes - if (sizeOffset >= input.size) return null - val size = readLeb128(input, sizeOffset) ?: return null - val payloadOffset = sizeOffset + size.bytes - val end = payloadOffset.toLong() + size.value - if (end > input.size || end > Int.MAX_VALUE) return null - result += Obu( - type = header shr 3 and 0x0f, - offset = offset, - length = end.toInt() - offset, - ) - offset = end.toInt() - } - return result.takeIf { it.isNotEmpty() } - } - - private fun readLeb128(input: ByteArray, offset: Int): Leb128? { - var value = 0L - for (index in 0 until MAX_LEB128_BYTES) { - val position = offset + index - if (position >= input.size) return null - val byte = input[position].toInt() and 0xff - value = value or ((byte and 0x7f).toLong() shl (index * 7)) - if (byte and 0x80 == 0) return Leb128(value, index + 1) - } - return null - } - - private fun ByteArrayOutputStream.endsWithTemporalDelimiter(): Boolean { - val bytes = toByteArray() - return bytes.size >= TEMPORAL_DELIMITER.size && - bytes[bytes.lastIndex - 1] == TEMPORAL_DELIMITER[0] && - bytes[bytes.lastIndex] == TEMPORAL_DELIMITER[1] - } - - private data class Obu(val type: Int, val offset: Int, val length: Int) - private data class Leb128(val value: Long, val bytes: Int) - - private val TEMPORAL_DELIMITER = byteArrayOf(0x12, 0x00) - private const val AV1C_HEADER_SIZE = 4 - private const val MAX_LEB128_BYTES = 8 - private const val OBU_SEQUENCE_HEADER = 1 - private const val OBU_TEMPORAL_DELIMITER = 2 - private const val OBU_FRAME_HEADER = 3 - private const val OBU_FRAME = 6 -} 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 index c17d065197..aaaa996c98 100644 --- 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 @@ -56,9 +56,9 @@ internal fun selectAv1Encoder( return@forEach } - val widthAlignment = combinedAlignment(candidate.widthAlignment, SCENE_PIXEL_ALIGNMENT) + val widthAlignment = combinedAlignment(candidate.widthAlignment, SCENE_MEDIACODEC_CANVAS_ALIGNMENT) ?: return@forEach - val heightAlignment = combinedAlignment(candidate.heightAlignment, SCENE_PIXEL_ALIGNMENT) + val heightAlignment = combinedAlignment(candidate.heightAlignment, SCENE_MEDIACODEC_CANVAS_ALIGNMENT) ?: return@forEach if (widthAlignment > boundedOutputDimension || heightAlignment > boundedOutputDimension) { return@forEach @@ -294,5 +294,6 @@ private fun combinedAlignment(first: Int, second: Int): Int? { 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/SceneVideoInput.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/player/scene/SceneVideoInput.kt index 34687c04e0..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 @@ -190,7 +190,7 @@ internal object SceneVideoInputResolver { } internal object SceneFfmpegArguments { - fun av1MediaCodecPackets( + fun animatedAvifMediaCodec( input: SceneVideoInputSpec, acquiredInputValue: String, range: SceneTimeRange, @@ -230,37 +230,15 @@ internal object SceneFfmpegArguments { add("1") add("-pix_fmt") add("yuv420p") + add("-loop") + add("0") add("-f") - add("data") + add("avif") add("-y") add(outputFile) }.toTypedArray() } - fun animatedAvifFromObu( - inputFile: String, - outputFile: String, - ): Array { - return arrayOf( - "-f", - "obu", - "-framerate", - FRAME_RATE.toInt().toString(), - "-i", - inputFile, - "-map", - "0:v:0", - "-c:v", - "copy", - "-loop", - "0", - "-f", - "avif", - "-y", - outputFile, - ) - } - fun videoProbe( input: SceneVideoInputSpec, acquiredInputValue: String, @@ -382,9 +360,11 @@ internal object SceneFfmpegArguments { 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.height % SCENE_PIXEL_ALIGNMENT == 0 && + outputSize.width % SCENE_MEDIACODEC_CANVAS_ALIGNMENT == 0 && + outputSize.height % SCENE_MEDIACODEC_CANVAS_ALIGNMENT == 0, ) { - "Scene output size must be even and no larger than $SCENE_MAX_OUTPUT_DIMENSION" + "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 && 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 3a79985a54..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 @@ -28,11 +28,11 @@ class AndroidSceneCaptureServiceTest { lateinit var tempDirectory: File @Test - fun `successful capture normalizes AV1 packets then remuxes them to animated AVIF`() = 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()) @@ -40,23 +40,11 @@ class AndroidSceneCaptureServiceTest { val animated = result as AnkiScreenshotPreparation.Animated assertEquals("avif", animated.animation.extension) assertTrue(animated.animation.preferredBaseName.startsWith("chimahon_scene_")) - assertEquals(2, executor.ffmpegArguments.size) + assertEquals(1, executor.ffmpegArguments.size) assertArrayEquals( - expectedAv1Arguments(animated.animation.file.absolutePath.replaceAfterLast('.', "obu")), + expectedAv1Arguments(animated.animation.file.absolutePath), executor.ffmpegArguments[0], ) - assertArrayEquals( - expectedAvifRemuxArguments( - animated.animation.file.absolutePath.replaceAfterLast('.', "obu"), - animated.animation.file.absolutePath, - ), - executor.ffmpegArguments[1], - ) - val intermediate = File( - animated.animation.file.parentFile, - "${animated.animation.file.nameWithoutExtension}.obu", - ) - assertFalse(intermediate.exists()) animated.animation.file.delete() } @@ -137,7 +125,7 @@ class AndroidSceneCaptureServiceTest { executor = executor, validate = { callerJob.cancel() - AnimatedAvifInfo(320, 180, 24, 3_000) + AnimatedAvifInfo(320, 192, 24, 3_000) }, ) @@ -220,28 +208,24 @@ class AndroidSceneCaptureServiceTest { } @Test - fun `cancellation reaches native remux and defers file cleanup until native return`() = runTest { - val executor = RecordingExecutor(writeOutput = true, suspendRemux = true) + 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.remuxStarted.await() } + withTimeout(5_000) { executor.encodeStarted.await() } } - val remuxArguments = executor.ffmpegArguments.last() - val intermediate = File(remuxArguments[remuxArguments.indexOf("-i") + 1]) - val output = File(remuxArguments.last()) + val output = File(executor.ffmpegArguments.last().last()) preparation.cancelAndJoin() withContext(Dispatchers.Default) { withTimeout(5_000) { executor.cancellationObserved.await() } } - assertTrue(intermediate.isFile) assertTrue(output.isFile) executor.finishNative() - assertFalse(intermediate.exists()) assertFalse(output.exists()) } @@ -256,7 +240,7 @@ class AndroidSceneCaptureServiceTest { } }, validate: (File) -> AnimatedAvifInfo? = { - AnimatedAvifInfo(320, 180, 24, 3_000) + AnimatedAvifInfo(320, 192, 24, 3_000) }, av1Encoder: (SceneVideoDimensions) -> Av1EncoderSelection? = { source -> selectAv1Encoder( @@ -331,7 +315,7 @@ class AndroidSceneCaptureServiceTest { "-vf", SceneFfmpegArguments.frameFilter( contentSize = SceneVideoDimensions(width = 320, height = 180), - outputSize = SceneVideoDimensions(width = 320, height = 180), + outputSize = SceneVideoDimensions(width = 320, height = 192), ), "-frames:v", "80", @@ -347,25 +331,6 @@ class AndroidSceneCaptureServiceTest { "1", "-pix_fmt", "yuv420p", - "-f", - "data", - "-y", - output, - ) - } - - private fun expectedAvifRemuxArguments(input: String, output: String): Array { - return arrayOf( - "-f", - "obu", - "-framerate", - "8", - "-i", - input, - "-map", - "0:v:0", - "-c:v", - "copy", "-loop", "0", "-f", @@ -377,14 +342,14 @@ class AndroidSceneCaptureServiceTest { private class RecordingExecutor( private val writeOutput: Boolean, - private val suspendRemux: Boolean = false, + private val suspendEncode: Boolean = false, ) : SceneCommandExecutor { var probeCalls = 0 var ffmpegCalls = 0 val ffmpegArguments = mutableListOf>() - val remuxStarted = CompletableDeferred() + val encodeStarted = CompletableDeferred() val cancellationObserved = CompletableDeferred() - private lateinit var onRemuxFinished: () -> Unit + private lateinit var onEncodeFinished: () -> Unit override suspend fun executeFfmpeg( arguments: Array, @@ -394,15 +359,11 @@ class AndroidSceneCaptureServiceTest { ffmpegArguments += arguments val output = File(arguments.last()) if (writeOutput) { - val bytes = when (output.extension) { - "obu" -> mediaCodecAv1PacketStream() - else -> byteArrayOf(1, 2, 3) - } - output.writeBytes(bytes) + output.writeBytes(byteArrayOf(1, 2, 3)) } - if (suspendRemux && output.extension == "avif") { - onRemuxFinished = onNativeFinished - remuxStarted.complete(Unit) + if (suspendEncode && output.extension == "avif") { + onEncodeFinished = onNativeFinished + encodeStarted.complete(Unit) return suspendCancellableCoroutine { continuation -> continuation.invokeOnCancellation { cancellationObserved.complete(Unit) @@ -432,7 +393,7 @@ class AndroidSceneCaptureServiceTest { } fun finishNative() { - onRemuxFinished() + onEncodeFinished() } } 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/MediaCodecAv1StreamNormalizerTest.kt b/app/src/test/kotlin/eu/kanade/tachiyomi/ui/player/scene/MediaCodecAv1StreamNormalizerTest.kt deleted file mode 100644 index f16e9caa19..0000000000 --- a/app/src/test/kotlin/eu/kanade/tachiyomi/ui/player/scene/MediaCodecAv1StreamNormalizerTest.kt +++ /dev/null @@ -1,57 +0,0 @@ -package eu.kanade.tachiyomi.ui.player.scene - -import org.junit.jupiter.api.Assertions.assertArrayEquals -import org.junit.jupiter.api.Assertions.assertNull -import org.junit.jupiter.api.Test - -class MediaCodecAv1StreamNormalizerTest { - @Test - fun `strips av1C and restores temporal boundaries`() { - assertArrayEquals( - byteArrayOf( - 0x12, - 0x00, - 0x0a, - 0x01, - 0x00, - 0x32, - 0x01, - 0x11, - 0x12, - 0x00, - 0x32, - 0x01, - 0x22, - ), - MediaCodecAv1StreamNormalizer.normalize(mediaCodecAv1PacketStream()), - ) - } - - @Test - fun `rejects malformed and single-frame streams`() { - assertNull(MediaCodecAv1StreamNormalizer.normalize(byteArrayOf(0x81.toByte(), 0x00))) - assertNull( - MediaCodecAv1StreamNormalizer.normalize( - mediaCodecAv1PacketStream().dropLast(3).toByteArray(), - ), - ) - } -} - -internal fun mediaCodecAv1PacketStream(): ByteArray { - return byteArrayOf( - 0x81.toByte(), - 0x00, - 0x00, - 0x00, - 0x0a, - 0x01, - 0x00, - 0x32, - 0x01, - 0x11, - 0x32, - 0x01, - 0x22, - ) -} 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 index 72375f23e2..b700635fa1 100644 --- 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 @@ -6,14 +6,14 @@ import org.junit.jupiter.api.Test class SceneAv1EncoderSelectorTest { @Test - fun `landscape input checks an aspect preserving codec aligned output`() { + 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 = 180) + size == SceneVideoDimensions(width = 320, height = 192) }, ), ) @@ -22,11 +22,11 @@ class SceneAv1EncoderSelectorTest { Av1EncoderSelection( name = ENCODER_NAME, contentSize = SceneVideoDimensions(width = 320, height = 180), - outputSize = SceneVideoDimensions(width = 320, height = 180), + outputSize = SceneVideoDimensions(width = 320, height = 192), ), selection, ) - assertEquals(listOf(SceneVideoDimensions(width = 320, height = 180)), checkedSizes) + assertEquals(listOf(SceneVideoDimensions(width = 320, height = 192)), checkedSizes) } @Test @@ -57,7 +57,7 @@ class SceneAv1EncoderSelectorTest { SceneVideoDimensions(width = 1248, height = 702), ).forEach { source -> assertEquals( - SceneVideoDimensions(width = 640, height = 360), + SceneVideoDimensions(width = 640, height = 368), selectAv1Encoder( source = source, candidates = sequenceOf(candidate()), @@ -77,7 +77,7 @@ class SceneAv1EncoderSelectorTest { ), ) - assertEquals(SceneVideoDimensions(width = 528, height = 630), selection?.outputSize) + assertEquals(SceneVideoDimensions(width = 528, height = 640), selection?.outputSize) } @Test @@ -224,7 +224,7 @@ class SceneAv1EncoderSelectorTest { candidates = sequenceOf(candidate()), ) - assertEquals(SceneVideoDimensions(width = 640, height = 360), selection?.outputSize) + assertEquals(SceneVideoDimensions(width = 640, height = 368), selection?.outputSize) } @Test 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 b502535670..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,16 +63,16 @@ class SceneVideoInputTest { } @Test - fun `AV1 encode writes raw MediaCodec packets`() { + fun `AV1 encode muxes MediaCodec output directly into animated AVIF`() { val input = supportedInput() - val arguments = SceneFfmpegArguments.av1MediaCodecPackets( + val arguments = SceneFfmpegArguments.animatedAvifMediaCodec( input = input, acquiredInputValue = "https://media.example/video.mp4", range = SceneTimeRange(1.25, 11.25), - outputFile = "/cache/output.obu", + outputFile = "/cache/output.avif", encoderName = TEST_AV1_ENCODER_NAME, contentSize = SceneVideoDimensions(width = 640, height = 360), - outputSize = SceneVideoDimensions(width = 640, height = 360), + outputSize = SceneVideoDimensions(width = 640, height = 368), tlsCaFile = "/files/cacert.pem", ).toList() @@ -90,7 +91,7 @@ class SceneVideoInputTest { ), ) assertTrue(arguments.containsAll(listOf("-ndk_codec", "1", "-pix_fmt", "yuv420p"))) - assertTrue(arguments.containsAll(listOf("-frames:v", "80", "-f", "data"))) + assertTrue(arguments.containsAll(listOf("-frames:v", "80", "-loop", "0", "-f", "avif"))) assertTrue( arguments.containsAll( listOf( @@ -107,20 +108,19 @@ class SceneVideoInputTest { ) assertEquals(1, arguments.count { it == "-c:v" }) assertEquals( - "fps=8,scale=w=640:h=360,setsar=1", + "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], ) - assertFalse(arguments.contains("avif")) - assertFalse(arguments.contains("-loop")) + assertEquals("/cache/output.avif", arguments.last()) } @Test fun `AV1 encode pads aspect preserving content into the codec canvas`() { - val arguments = SceneFfmpegArguments.av1MediaCodecPackets( + val arguments = SceneFfmpegArguments.animatedAvifMediaCodec( input = supportedInput(), acquiredInputValue = "https://media.example/video.mp4", range = SceneTimeRange(1.25, 11.25), - outputFile = "/cache/output.obu", + outputFile = "/cache/output.avif", encoderName = TEST_AV1_ENCODER_NAME, contentSize = SceneVideoDimensions(width = 320, height = 180), outputSize = SceneVideoDimensions(width = 320, height = 192), @@ -136,40 +136,22 @@ class SceneVideoInputTest { @Test fun `AV1 padding uses explicit chroma aligned offsets`() { assertEquals( - "fps=8,scale=w=318:h=178,setsar=1,pad=w=320:h=180:x=0:y=0:color=black", + "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 = 180), + outputSize = SceneVideoDimensions(width = 320, height = 192), ), ) } @Test - fun `AVIF remux copies the normalized OBU stream`() { - assertEquals( - listOf( - "-f", - "obu", - "-framerate", - "8", - "-i", - "/cache/input.obu", - "-map", - "0:v:0", - "-c:v", - "copy", - "-loop", - "0", - "-f", - "avif", - "-y", - "/cache/output.avif", - ), - SceneFfmpegArguments.animatedAvifFromObu( - inputFile = "/cache/input.obu", - outputFile = "/cache/output.avif", - ).toList(), - ) + 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 @@ -178,14 +160,14 @@ class SceneVideoInputTest { val range = SceneTimeRange(1.25, 2.25) val caFile = "/files/cacert.pem" val commands = listOf( - SceneFfmpegArguments.av1MediaCodecPackets( + SceneFfmpegArguments.animatedAvifMediaCodec( input = input, acquiredInputValue = input.value, range = range, - outputFile = "/cache/scene.obu", + outputFile = "/cache/scene.avif", encoderName = TEST_AV1_ENCODER_NAME, contentSize = SceneVideoDimensions(width = 640, height = 360), - outputSize = SceneVideoDimensions(width = 640, height = 360), + outputSize = SceneVideoDimensions(width = 640, height = 368), tlsCaFile = caFile, ), SceneFfmpegArguments.videoProbe(input, input.value, caFile), @@ -208,14 +190,14 @@ class SceneVideoInputTest { val input = supportedInput() val commands = listOf( SceneFfmpegArguments.videoProbe(input, input.value, "/files/cacert.pem"), - SceneFfmpegArguments.av1MediaCodecPackets( + SceneFfmpegArguments.animatedAvifMediaCodec( input = input, acquiredInputValue = input.value, range = SceneTimeRange(1.25, 2.25), - outputFile = "/cache/scene.obu", + outputFile = "/cache/scene.avif", encoderName = TEST_AV1_ENCODER_NAME, contentSize = SceneVideoDimensions(width = 640, height = 360), - outputSize = SceneVideoDimensions(width = 640, height = 360), + outputSize = SceneVideoDimensions(width = 640, height = 368), tlsCaFile = "/files/cacert.pem", ), ) @@ -241,14 +223,14 @@ class SceneVideoInputTest { val safValue = "saf:37.mp4" val range = SceneTimeRange(1.25, 2.25) val commands = listOf( - SceneFfmpegArguments.av1MediaCodecPackets( + SceneFfmpegArguments.animatedAvifMediaCodec( input = input, acquiredInputValue = safValue, range = range, - outputFile = "/cache/scene.obu", + outputFile = "/cache/scene.avif", encoderName = TEST_AV1_ENCODER_NAME, contentSize = SceneVideoDimensions(width = 640, height = 360), - outputSize = SceneVideoDimensions(width = 640, height = 360), + outputSize = SceneVideoDimensions(width = 640, height = 368), ), SceneFfmpegArguments.videoProbe(input, safValue), SceneFfmpegArguments.audioProbe(input, safValue), @@ -273,14 +255,14 @@ class SceneVideoInputTest { .sentenceAudio(input, input.value, range, "/cache/audio.m4a", caFile) .toList() val video = SceneFfmpegArguments - .av1MediaCodecPackets( + .animatedAvifMediaCodec( input = input, acquiredInputValue = input.value, range = range, - outputFile = "/cache/scene.obu", + outputFile = "/cache/scene.avif", encoderName = TEST_AV1_ENCODER_NAME, contentSize = SceneVideoDimensions(width = 640, height = 360), - outputSize = 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" }