From 0c352cdcfb56577accca83b10881c139df1d529e Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Wed, 19 Aug 2026 22:39:19 -0500 Subject: [PATCH] Route conversions between hardware and software engines Adds the second engine and the rules that choose between them, turning a video transcoder into a converter. The router encodes capability boundaries, not preferences. Media3 handles what it genuinely can and FFmpeg takes the rest: MKV, and any container Media3 cannot mux MP3, which Android cannot encode at any API level -- a platform gap rather than a Media3 limitation GIF and PNG frame sequences, which have no Media3 muxer VP9 and AV1 targets: Transformer.setVideoMimeType accepts only H.263/H.264/H.265/MP4V, so the WebM muxer has no encoder behind it inputs with no platform decoder, since Transformer ignores ExoPlayer's bundled software decoders and the dav1d extension does not rescue it the Best quality tier, because CRF and two-pass come from x264/x265 and no Android hardware encoder exposes either Rule order matters and is deliberate: specific reasons are checked before general ones because the reason is shown to the user. "Android has no encoder for this format" is actionable for MP3; "this container needs FFmpeg" is not. A test caught the original ordering getting this backwards. Hardware support is vendor-declared and, per the platform's own docs, "cannot be tested for correctness", so the static rules are backed by a dynamic fallback: a Media3 export that fails is retried on FFmpeg rather than surfaced as a failed conversion. Joining files chooses between a stream copy and a re-encode by inspecting the inputs. The concat demuxer requires matching codec, resolution and timebase, and does not reliably fail when they differ -- it can emit a file whose later segments are garbled. Unknown properties count as a mismatch, because two nulls are not evidence of agreement. The UI surfaces the routing decision rather than hiding it, so a slow job explains itself, and offers a per-job engine override. Navigation is adaptive: a bottom bar on phones, a side rail on wider screens. Not cosmetic -- from targetSdk 37 Android ignores screenOrientation and resizableActivity on displays at least 600dp wide, with no opt-out, so the app is resized whether or not it is ready. 62 unit tests cover the routing matrix, the FFmpeg argument builder and the concat planner on the JVM, against fabricated device profiles so branches like "this device cannot encode HEVC" are reachable without that hardware. Device-level tests for the FFmpeg formats are written but not yet run; the emulator in this environment will not stay up. Co-Authored-By: Claude Opus 5 (1M context) --- .idea/vcs.xml | 6 + app/build.gradle.kts | 5 + .../mediaconverter/ffmpeg/FFmpegEngineTest.kt | 167 +++++++++++++++ .../work/ConversionWorkerTest.kt | 90 ++++++++ .../jasonmross/mediaconverter/MainActivity.kt | 92 +++++++- .../codec/AndroidDeviceCodecs.kt | 107 ++++++++++ .../convert/ConversionViewModel.kt | 50 ++++- .../mediaconverter/convert/ConverterScreen.kt | 119 +++++++++-- .../mediaconverter/convert/MediaProbe.kt | 112 ++++++++++ .../mediaconverter/ffmpeg/ConcatEngine.kt | 85 ++++++++ .../ffmpeg/FFmpegCommandBuilder.kt | 129 ++++++++++++ .../ffmpeg/FFmpegConcatCommand.kt | 69 ++++++ .../mediaconverter/ffmpeg/FFmpegEngine.kt | 89 ++++++++ .../mediaconverter/join/JoinScreen.kt | 129 ++++++++++++ .../mediaconverter/join/JoinViewModel.kt | 141 +++++++++++++ .../mediaconverter/model/ConcatStrategy.kt | 65 ++++++ .../mediaconverter/model/ConversionRouter.kt | 140 +++++++++++++ .../mediaconverter/model/OutputFormat.kt | 87 ++++++++ .../mediaconverter/work/ConcatWorker.kt | 113 ++++++++++ .../mediaconverter/work/ConversionWorker.kt | 174 ++++++++++++---- .../ffmpeg/FFmpegCommandBuilderTest.kt | 180 ++++++++++++++++ .../ffmpeg/FFmpegConcatCommandTest.kt | 96 +++++++++ .../mediaconverter/model/ConcatPlannerTest.kt | 83 ++++++++ .../model/ConversionRouterTest.kt | 197 ++++++++++++++++++ .../mediaconverter/model/OutputFormatTest.kt | 64 ++++++ 25 files changed, 2517 insertions(+), 72 deletions(-) create mode 100644 .idea/vcs.xml create mode 100644 app/src/androidTest/java/dev/jasonmross/mediaconverter/ffmpeg/FFmpegEngineTest.kt create mode 100644 app/src/main/java/dev/jasonmross/mediaconverter/codec/AndroidDeviceCodecs.kt create mode 100644 app/src/main/java/dev/jasonmross/mediaconverter/convert/MediaProbe.kt create mode 100644 app/src/main/java/dev/jasonmross/mediaconverter/ffmpeg/ConcatEngine.kt create mode 100644 app/src/main/java/dev/jasonmross/mediaconverter/ffmpeg/FFmpegCommandBuilder.kt create mode 100644 app/src/main/java/dev/jasonmross/mediaconverter/ffmpeg/FFmpegConcatCommand.kt create mode 100644 app/src/main/java/dev/jasonmross/mediaconverter/ffmpeg/FFmpegEngine.kt create mode 100644 app/src/main/java/dev/jasonmross/mediaconverter/join/JoinScreen.kt create mode 100644 app/src/main/java/dev/jasonmross/mediaconverter/join/JoinViewModel.kt create mode 100644 app/src/main/java/dev/jasonmross/mediaconverter/model/ConcatStrategy.kt create mode 100644 app/src/main/java/dev/jasonmross/mediaconverter/model/ConversionRouter.kt create mode 100644 app/src/main/java/dev/jasonmross/mediaconverter/model/OutputFormat.kt create mode 100644 app/src/main/java/dev/jasonmross/mediaconverter/work/ConcatWorker.kt create mode 100644 app/src/test/java/dev/jasonmross/mediaconverter/ffmpeg/FFmpegCommandBuilderTest.kt create mode 100644 app/src/test/java/dev/jasonmross/mediaconverter/ffmpeg/FFmpegConcatCommandTest.kt create mode 100644 app/src/test/java/dev/jasonmross/mediaconverter/model/ConcatPlannerTest.kt create mode 100644 app/src/test/java/dev/jasonmross/mediaconverter/model/ConversionRouterTest.kt create mode 100644 app/src/test/java/dev/jasonmross/mediaconverter/model/OutputFormatTest.kt diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 65b69c3..34c514a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -74,6 +74,11 @@ dependencies { implementation(libs.media3.common) implementation(libs.media3.muxer) + // FFmpeg, built from source by tools/ffmpeg. Not on any Maven repo: ffmpeg-kit was + // archived and delisted, and ffmpeg-kit-next is source-only by design. + // The AAR is gitignored; see app/libs/README.md to produce it. + implementation(files("libs/ffmpeg-kit-next-8.1.1.aar")) + // Durable job queue. WorkManager survives process death, which is what makes the // queue resumable after the foreground-service timeout fires. implementation(libs.androidx.work.runtime.ktx) diff --git a/app/src/androidTest/java/dev/jasonmross/mediaconverter/ffmpeg/FFmpegEngineTest.kt b/app/src/androidTest/java/dev/jasonmross/mediaconverter/ffmpeg/FFmpegEngineTest.kt new file mode 100644 index 0000000..698a094 --- /dev/null +++ b/app/src/androidTest/java/dev/jasonmross/mediaconverter/ffmpeg/FFmpegEngineTest.kt @@ -0,0 +1,167 @@ +package dev.jasonmross.mediaconverter.ffmpeg + +import android.media.MediaExtractor +import android.media.MediaFormat +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import dev.jasonmross.mediaconverter.model.ConversionRequest +import dev.jasonmross.mediaconverter.model.OutputFormat +import dev.jasonmross.mediaconverter.model.QualityTier +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.io.File + +/** + * Exercises the bundled FFmpeg on-device. + * + * These are the formats the app exists for that Media3 structurally cannot produce, so + * they are also the ones with no other coverage. Each test asserts against the produced + * file rather than the exit code, because FFmpeg will happily report success after + * writing something unplayable if the arguments are wrong. + */ +@RunWith(AndroidJUnit4::class) +class FFmpegEngineTest { + + private val context = InstrumentationRegistry.getInstrumentation().targetContext + private val engine = FFmpegEngine() + private lateinit var input: File + private val outputs = mutableListOf() + + @Before + fun setUp() { + input = File(context.cacheDir, "ffmpeg_sample.mp4") + InstrumentationRegistry.getInstrumentation().context.assets + .open("sample_h264.mp4") + .use { asset -> input.outputStream().use { asset.copyTo(it) } } + } + + @After + fun tearDown() { + input.delete() + outputs.forEach { it.delete() } + } + + private fun outputFor(name: String) = + File(context.cacheDir, name).also { it.delete(); outputs += it } + + private fun convert(format: OutputFormat, quality: QualityTier = QualityTier.BEST): File { + val out = outputFor("out_${format.name.lowercase()}.${format.extension}") + runBlocking { + engine.run( + request = ConversionRequest(format = format, quality = quality), + inputPath = input.absolutePath, + output = out, + durationMs = 3_000, + ) + } + return out + } + + private fun trackMimes(file: File): List { + val extractor = MediaExtractor() + return try { + extractor.setDataSource(file.absolutePath) + (0 until extractor.trackCount).map { + extractor.getTrackFormat(it).getString(MediaFormat.KEY_MIME).orEmpty() + } + } finally { + extractor.release() + } + } + + // --- the formats that justify bundling FFmpeg at all ------------------- + + @Test + fun encodesMp3WhichAndroidCannotDoAtAnyApiLevel() { + val out = convert(OutputFormat.MP3) + assertTrue("no MP3 produced", out.exists() && out.length() > 0) + assertTrue( + "expected an mpeg audio track, got ${trackMimes(out)}", + trackMimes(out).any { it.contains("mp") && it.startsWith("audio/") }, + ) + } + + @Test + fun encodesGifWithAGeneratedPalette() { + val out = convert(OutputFormat.GIF) + assertTrue("no GIF produced", out.exists() && out.length() > 0) + // GIF87a / GIF89a magic. Proves a real GIF rather than a mislabelled file. + val magic = out.inputStream().use { String(it.readNBytes(6)) } + assertTrue("not a GIF: $magic", magic.startsWith("GIF")) + } + + @Test + fun encodesMatroskaWhichMedia3CannotMux() { + val out = convert(OutputFormat.MKV_H264) + assertTrue("no MKV produced", out.exists() && out.length() > 0) + // EBML magic, the Matroska container header. + val magic = out.inputStream().use { it.readNBytes(4) } + assertEquals(0x1A.toByte(), magic[0]) + assertEquals(0x45.toByte(), magic[1]) + assertEquals(0xDF.toByte(), magic[2]) + assertEquals(0xA3.toByte(), magic[3]) + } + + @Test + fun encodesFlacLosslessAudio() { + val out = convert(OutputFormat.FLAC) + assertTrue("no FLAC produced", out.exists() && out.length() > 0) + } + + @Test + fun encodesWav() { + val out = convert(OutputFormat.WAV) + assertTrue("no WAV produced", out.exists() && out.length() > 0) + val magic = out.inputStream().use { String(it.readNBytes(4)) } + assertEquals("RIFF", magic) + } + + @Test + fun encodesOpus() { + val out = convert(OutputFormat.OPUS) + assertTrue("no Opus produced", out.exists() && out.length() > 0) + } + + // --- the quality tier the GPL licence was taken for -------------------- + + @Test + fun bestQualityProducesAPlayableH264File() { + val out = convert(OutputFormat.MP4_H264, QualityTier.BEST) + assertTrue("no MP4 produced", out.exists() && out.length() > 0) + assertTrue( + "expected an AVC track, got ${trackMimes(out)}", + trackMimes(out).any { it == MediaFormat.MIMETYPE_VIDEO_AVC }, + ) + } + + @Test + fun bestQualityProducesAPlayableH265File() { + val out = convert(OutputFormat.MP4_H265, QualityTier.BEST) + assertTrue("no MP4 produced", out.exists() && out.length() > 0) + assertTrue( + "expected an HEVC track, got ${trackMimes(out)}", + trackMimes(out).any { it == MediaFormat.MIMETYPE_VIDEO_HEVC }, + ) + } + + @Test + fun failureSurfacesAsAnExceptionRatherThanASilentEmptyFile() { + val out = outputFor("nope.mp4") + val failure = runCatching { + runBlocking { + engine.run( + request = ConversionRequest(format = OutputFormat.MP4_H264), + inputPath = "/does/not/exist.mp4", + output = out, + durationMs = 1_000, + ) + } + }.exceptionOrNull() + assertTrue("expected an FFmpegException, got $failure", failure is FFmpegEngine.FFmpegException) + } +} diff --git a/app/src/androidTest/java/dev/jasonmross/mediaconverter/work/ConversionWorkerTest.kt b/app/src/androidTest/java/dev/jasonmross/mediaconverter/work/ConversionWorkerTest.kt index 2660305..caf72e9 100644 --- a/app/src/androidTest/java/dev/jasonmross/mediaconverter/work/ConversionWorkerTest.kt +++ b/app/src/androidTest/java/dev/jasonmross/mediaconverter/work/ConversionWorkerTest.kt @@ -8,6 +8,9 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import androidx.work.WorkInfo import androidx.work.WorkManager +import dev.jasonmross.mediaconverter.model.Engine +import dev.jasonmross.mediaconverter.model.OutputFormat +import dev.jasonmross.mediaconverter.model.QualityTier import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout @@ -87,6 +90,93 @@ class ConversionWorkerTest { assertTrue("output file empty", output.length() > 0) } + /** + * The whole point of the router, end to end. + * + * MP3 cannot be produced by Media3 on any Android version, so this asserts both + * that the job completes and that it was actually carried out by FFmpeg. Checking + * only that a file appeared would pass even if the routing were broken. + */ + @Test + fun routesAnMp3JobToFfmpegAndProducesAFile() = runBlocking { + val request = ConversionWorker.request( + inputUri = Uri.fromFile(input), + displayName = "worker_sample.mp4", + sizeBytes = input.length(), + format = OutputFormat.MP3, + ) + workManager.enqueue(request).result.get() + + val terminal = withTimeout(TIMEOUT_MS) { + workManager.getWorkInfoByIdFlow(request.id).first { it != null && it.state.isFinished } + } + + assertEquals( + "mp3 job did not succeed: ${terminal?.outputData?.getString(ConversionWorker.KEY_ERROR)}", + WorkInfo.State.SUCCEEDED, + terminal?.state, + ) + assertEquals( + Engine.FFMPEG.name, + terminal?.outputData?.getString(ConversionWorker.KEY_ENGINE_USED), + ) + val output = File(terminal!!.outputData.getString(ConversionWorker.KEY_OUTPUT_PATH)!!) + assertTrue("no mp3 written", output.exists() && output.length() > 0) + assertTrue("wrong extension: ${output.name}", output.name.endsWith(".mp3")) + } + + @Test + fun routesAFastMp4JobToMedia3() = runBlocking { + val request = ConversionWorker.request( + inputUri = Uri.fromFile(input), + displayName = "worker_sample.mp4", + sizeBytes = input.length(), + format = OutputFormat.MP4_H265, + quality = QualityTier.FAST, + ) + workManager.enqueue(request).result.get() + + val terminal = withTimeout(TIMEOUT_MS) { + workManager.getWorkInfoByIdFlow(request.id).first { it != null && it.state.isFinished } + } + + assertEquals(WorkInfo.State.SUCCEEDED, terminal?.state) + assertEquals( + Engine.MEDIA3.name, + terminal?.outputData?.getString(ConversionWorker.KEY_ENGINE_USED), + ) + } + + /** The quality tier is the reason the shipped binary is GPL, so verify it routes. */ + @Test + fun routesABestQualityJobToFfmpeg() = runBlocking { + val request = ConversionWorker.request( + inputUri = Uri.fromFile(input), + displayName = "worker_sample.mp4", + sizeBytes = input.length(), + format = OutputFormat.MP4_H264, + quality = QualityTier.BEST, + ) + workManager.enqueue(request).result.get() + + val terminal = withTimeout(TIMEOUT_MS) { + workManager.getWorkInfoByIdFlow(request.id).first { it != null && it.state.isFinished } + } + + assertEquals(WorkInfo.State.SUCCEEDED, terminal?.state) + assertEquals( + Engine.FFMPEG.name, + terminal?.outputData?.getString(ConversionWorker.KEY_ENGINE_USED), + ) + } + + @Test + fun outputNameTakesTheExtensionOfTheChosenFormat() { + assertEquals("clip_converted.mp3", ConversionWorker.outputNameFor("clip.mp4", OutputFormat.MP3)) + assertEquals("clip_converted.gif", ConversionWorker.outputNameFor("clip.mov", OutputFormat.GIF)) + assertEquals("clip_converted.mkv", ConversionWorker.outputNameFor("clip", OutputFormat.MKV_H264)) + } + private companion object { const val TIMEOUT_MS = 180_000L } diff --git a/app/src/main/java/dev/jasonmross/mediaconverter/MainActivity.kt b/app/src/main/java/dev/jasonmross/mediaconverter/MainActivity.kt index 5e684ea..ddb35ba 100644 --- a/app/src/main/java/dev/jasonmross/mediaconverter/MainActivity.kt +++ b/app/src/main/java/dev/jasonmross/mediaconverter/MainActivity.kt @@ -4,27 +4,109 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.NavigationRail +import androidx.compose.material3.NavigationRailItem import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi +import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass +import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.media3.common.util.UnstableApi import dev.jasonmross.mediaconverter.convert.ConverterScreen +import dev.jasonmross.mediaconverter.join.JoinScreen import dev.jasonmross.mediaconverter.ui.theme.MediaConverterTheme +private enum class Destination(val label: String) { + CONVERT("Convert"), + JOIN("Join"), +} + @UnstableApi class MainActivity : ComponentActivity() { + + @OptIn(ExperimentalMaterial3WindowSizeClassApi::class) override fun onCreate(savedInstanceState: Bundle?) { - // Edge-to-edge is enforced from targetSdk 35 and has no opt-out at 36+, - // so opt in explicitly rather than relying on the default. + // Edge-to-edge is enforced from targetSdk 35 and has no opt-out at 36+, so opt + // in explicitly rather than relying on the default. enableEdgeToEdge() super.onCreate(savedInstanceState) setContent { MediaConverterTheme { - Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> - ConverterScreen(modifier = Modifier.padding(innerPadding)) - } + AppRoot(widthSizeClass = calculateWindowSizeClass(this).widthSizeClass) } } } } + +/** + * Adaptive shell: a bottom bar on phones, a side rail on wider screens. + * + * This is not cosmetic. From targetSdk 37, Android ignores `screenOrientation`, + * `resizableActivity` and aspect-ratio limits on any display at least 600dp wide, and + * the Android 16 opt-out no longer applies. The app will be resized and rotated + * whether or not it is ready, so it has to lay out properly at every width. + */ +@UnstableApi +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun AppRoot(widthSizeClass: WindowWidthSizeClass) { + var destination by remember { mutableStateOf(Destination.CONVERT) } + val useRail = widthSizeClass != WindowWidthSizeClass.Compact + + if (useRail) { + Row(modifier = Modifier.fillMaxSize()) { + NavigationRail { + Destination.entries.forEach { item -> + NavigationRailItem( + selected = destination == item, + onClick = { destination = item }, + icon = {}, + label = { Text(item.label) }, + ) + } + } + Scaffold(modifier = Modifier.fillMaxSize()) { padding -> + Content(destination, Modifier.padding(padding)) + } + } + } else { + Scaffold( + modifier = Modifier.fillMaxSize(), + bottomBar = { + NavigationBar { + Destination.entries.forEach { item -> + NavigationBarItem( + selected = destination == item, + onClick = { destination = item }, + icon = {}, + label = { Text(item.label) }, + ) + } + } + }, + ) { padding -> + Content(destination, Modifier.padding(padding)) + } + } +} + +@UnstableApi +@Composable +private fun Content(destination: Destination, modifier: Modifier) { + when (destination) { + Destination.CONVERT -> ConverterScreen(modifier = modifier) + Destination.JOIN -> JoinScreen(modifier = modifier) + } +} diff --git a/app/src/main/java/dev/jasonmross/mediaconverter/codec/AndroidDeviceCodecs.kt b/app/src/main/java/dev/jasonmross/mediaconverter/codec/AndroidDeviceCodecs.kt new file mode 100644 index 0000000..8295826 --- /dev/null +++ b/app/src/main/java/dev/jasonmross/mediaconverter/codec/AndroidDeviceCodecs.kt @@ -0,0 +1,107 @@ +package dev.jasonmross.mediaconverter.codec + +import android.media.MediaCodecInfo +import android.media.MediaCodecList +import android.media.MediaFormat +import android.util.Log +import dev.jasonmross.mediaconverter.model.DeviceCodecs +import dev.jasonmross.mediaconverter.model.InputProbe +import dev.jasonmross.mediaconverter.model.VideoCodec + +/** + * What this device's codec hardware can actually do. + * + * Enumerated once and cached: MediaCodecList is not cheap, and the answer cannot + * change while the process is alive. + * + * Two details that are easy to get wrong: + * + * - Devices expose **aliases** for the same underlying codec, so the list must be + * deduplicated by canonical name or capabilities get counted several times. + * - `isHardwareAccelerated()` is declared by the vendor and, in the platform's own + * words, "cannot be tested for correctness". It is a hint, not a guarantee, which is + * why the router treats a failed hardware export as a signal to fall back rather + * than trusting this up front. + */ +class AndroidDeviceCodecs private constructor( + private val hardwareEncodeMimes: Set, + private val decodeMimes: Set, +) : DeviceCodecs { + + override fun canEncode(codec: VideoCodec): Boolean = + mimeFor(codec)?.let { it in hardwareEncodeMimes } ?: true + + override fun canDecode(codecName: String): Boolean { + // The platform already failed to parse this input, so there is nothing to + // decode with. Send it to FFmpeg rather than letting Media3 fail later. + if (codecName == InputProbe.UNPARSEABLE) return false + return mimeForCodecName(codecName)?.let { it in decodeMimes } ?: true + } + + fun hardwareEncoders(): Set = hardwareEncodeMimes + + companion object { + private const val TAG = "AndroidDeviceCodecs" + + @Volatile + private var cached: AndroidDeviceCodecs? = null + + fun get(): AndroidDeviceCodecs = + cached ?: synchronized(this) { cached ?: probe().also { cached = it } } + + private fun probe(): AndroidDeviceCodecs { + val encoders = mutableSetOf() + val decoders = mutableSetOf() + val seen = mutableSetOf() + + runCatching { + MediaCodecList(MediaCodecList.REGULAR_CODECS).codecInfos.forEach { info -> + // Aliases point at the same underlying codec; counting both would + // double-count capabilities. + if (info.isAlias) return@forEach + if (!seen.add(info.canonicalName)) return@forEach + + info.supportedTypes.forEach { mime -> + if (!mime.startsWith("video/")) return@forEach + if (info.isEncoder) { + if (info.isHardwareAccelerated && !info.isSoftwareOnly) { + encoders += mime + } + } else { + decoders += mime + } + } + } + }.onFailure { Log.w(TAG, "Codec enumeration failed; assuming permissive.", it) } + + Log.i(TAG, "Hardware video encoders: $encoders") + return AndroidDeviceCodecs(encoders, decoders) + } + + private fun mimeFor(codec: VideoCodec): String? = when (codec) { + VideoCodec.H264 -> MediaFormat.MIMETYPE_VIDEO_AVC + VideoCodec.H265 -> MediaFormat.MIMETYPE_VIDEO_HEVC + VideoCodec.VP8 -> MediaFormat.MIMETYPE_VIDEO_VP8 + VideoCodec.VP9 -> MediaFormat.MIMETYPE_VIDEO_VP9 + VideoCodec.AV1 -> MediaFormat.MIMETYPE_VIDEO_AV1 + VideoCodec.NONE -> null + } + + /** Maps an FFprobe-style codec name onto a MediaFormat MIME type. */ + private fun mimeForCodecName(name: String): String? = when (name.lowercase()) { + "h264", "avc", "avc1" -> MediaFormat.MIMETYPE_VIDEO_AVC + "hevc", "h265", "hvc1" -> MediaFormat.MIMETYPE_VIDEO_HEVC + "vp8" -> MediaFormat.MIMETYPE_VIDEO_VP8 + "vp9" -> MediaFormat.MIMETYPE_VIDEO_VP9 + "av1", "av01" -> MediaFormat.MIMETYPE_VIDEO_AV1 + "mpeg4" -> MediaFormat.MIMETYPE_VIDEO_MPEG4 + // Unknown to us: assume the platform can handle it and let a failed export + // trigger the FFmpeg fallback, rather than pre-emptively refusing hardware. + else -> null + } + + /** Test seam: lets instrumented tests build a probe from explicit sets. */ + fun forTesting(encoders: Set, decoders: Set) = + AndroidDeviceCodecs(encoders, decoders) + } +} diff --git a/app/src/main/java/dev/jasonmross/mediaconverter/convert/ConversionViewModel.kt b/app/src/main/java/dev/jasonmross/mediaconverter/convert/ConversionViewModel.kt index 821707a..aeba52a 100644 --- a/app/src/main/java/dev/jasonmross/mediaconverter/convert/ConversionViewModel.kt +++ b/app/src/main/java/dev/jasonmross/mediaconverter/convert/ConversionViewModel.kt @@ -8,17 +8,28 @@ import androidx.lifecycle.viewModelScope import androidx.media3.common.util.UnstableApi import androidx.work.WorkInfo import androidx.work.WorkManager +import dev.jasonmross.mediaconverter.model.EnginePreference +import dev.jasonmross.mediaconverter.model.OutputFormat +import dev.jasonmross.mediaconverter.model.QualityTier import dev.jasonmross.mediaconverter.work.ConversionWorker import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.io.File import java.util.UUID +/** User-chosen conversion settings. */ +data class ConversionSettings( + val format: OutputFormat = OutputFormat.MP4_H265, + val quality: QualityTier = QualityTier.FAST, + val enginePreference: EnginePreference = EnginePreference.AUTO, +) + data class InputFile( val uri: Uri, val displayName: String, @@ -31,7 +42,12 @@ sealed interface ConversionState { data class Converting(val input: InputFile, val percent: Int) : ConversionState /** Budget for foreground work ran out; WorkManager will retry when it can. */ data class Waiting(val input: InputFile) : ConversionState - data class Converted(val input: InputFile, val staged: File) : ConversionState + data class Converted( + val input: InputFile, + val staged: File, + val engineUsed: String = "", + val routeReason: String = "", + ) : ConversionState data class Saved(val displayName: String) : ConversionState data class Failed(val message: String) : ConversionState } @@ -48,6 +64,15 @@ class ConversionViewModel(app: Application) : AndroidViewModel(app) { private var observer: Job? = null private var activeWorkId: UUID? = null + /** Conversion settings, kept separate from the job state machine. */ + private val _settings = MutableStateFlow(ConversionSettings()) + val settings: StateFlow = _settings.asStateFlow() + + fun setFormat(format: OutputFormat) = _settings.update { it.copy(format = format) } + fun setQuality(quality: QualityTier) = _settings.update { it.copy(quality = quality) } + fun setEnginePreference(preference: EnginePreference) = + _settings.update { it.copy(enginePreference = preference) } + fun onInputPicked(uri: Uri) { viewModelScope.launch { val info = withContext(Dispatchers.IO) { queryFile(uri) } @@ -65,10 +90,14 @@ class ConversionViewModel(app: Application) : AndroidViewModel(app) { fun convert() { val input = currentInput() ?: return + val settings = _settings.value val request = ConversionWorker.request( inputUri = input.uri, displayName = input.displayName, sizeBytes = input.sizeBytes, + format = settings.format, + quality = settings.quality, + enginePreference = settings.enginePreference, ) activeWorkId = request.id workManager.enqueue(request) @@ -101,7 +130,14 @@ class ConversionViewModel(app: Application) : AndroidViewModel(app) { if (path == null) { ConversionState.Failed("Conversion reported success but produced no file.") } else { - ConversionState.Converted(input, File(path)) + ConversionState.Converted( + input = input, + staged = File(path), + engineUsed = info.outputData + .getString(ConversionWorker.KEY_ENGINE_USED).orEmpty(), + routeReason = info.outputData + .getString(ConversionWorker.KEY_ROUTE_REASON).orEmpty(), + ) } } @@ -131,7 +167,10 @@ class ConversionViewModel(app: Application) : AndroidViewModel(app) { } }.onSuccess { _state.value = ConversionState.Saved( - ConversionWorker.outputNameFor(converted.input.displayName) + ConversionWorker.outputNameFor( + converted.input.displayName, + _settings.value.format, + ) ) }.onFailure { e -> _state.value = ConversionState.Failed(e.message ?: "Could not save the file.") @@ -147,7 +186,10 @@ class ConversionViewModel(app: Application) : AndroidViewModel(app) { } fun suggestedOutputName(): String = - ConversionWorker.outputNameFor(currentInput()?.displayName ?: "output") + ConversionWorker.outputNameFor( + currentInput()?.displayName ?: "output", + _settings.value.format, + ) private fun currentInput(): InputFile? = when (val s = _state.value) { is ConversionState.Ready -> s.input diff --git a/app/src/main/java/dev/jasonmross/mediaconverter/convert/ConverterScreen.kt b/app/src/main/java/dev/jasonmross/mediaconverter/convert/ConverterScreen.kt index b85d2ea..8126d47 100644 --- a/app/src/main/java/dev/jasonmross/mediaconverter/convert/ConverterScreen.kt +++ b/app/src/main/java/dev/jasonmross/mediaconverter/convert/ConverterScreen.kt @@ -5,26 +5,31 @@ import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AssistChip import androidx.compose.material3.Button import androidx.compose.material3.Card +import androidx.compose.material3.FilterChip import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import androidx.media3.common.util.UnstableApi +import dev.jasonmross.mediaconverter.model.EnginePreference +import dev.jasonmross.mediaconverter.model.OutputFormat +import dev.jasonmross.mediaconverter.model.QualityTier import java.util.Locale @UnstableApi @@ -34,6 +39,7 @@ fun ConverterScreen( viewModel: ConversionViewModel = viewModel(), ) { val state by viewModel.state.collectAsStateWithLifecycle() + val settings by viewModel.settings.collectAsStateWithLifecycle() // ACTION_OPEN_DOCUMENT rather than the photo picker: the picker is images and video // only, offers no audio at all, and will not reliably surface .mkv/.flac/.webm. @@ -43,23 +49,22 @@ fun ConverterScreen( ) { uri -> uri?.let(viewModel::onInputPicked) } val chooseDestination = rememberLauncherForActivityResult( - ActivityResultContracts.CreateDocument("video/mp4") + ActivityResultContracts.CreateDocument(settings.format.mimeType) ) { uri -> uri?.let(viewModel::save) } - // Requested at the point of use rather than on first launch, so the ask carries - // its own justification. The conversion starts either way: without the permission - // the foreground service still runs, but its progress notification is confined to - // the Task Manager instead of the shade. + // Requested at the point of use rather than on first launch, so the ask carries its + // own justification. The conversion starts either way: without the permission the + // foreground service still runs, but its progress notification is confined to the + // Task Manager instead of the shade. val requestNotifications = rememberLauncherForActivityResult( ActivityResultContracts.RequestPermission() ) { viewModel.convert() } - fun startConversion() { - requestNotifications.launch(Manifest.permission.POST_NOTIFICATIONS) - } - Column( - modifier = modifier.fillMaxSize().padding(24.dp), + modifier = modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(24.dp), verticalArrangement = Arrangement.spacedBy(16.dp), ) { Text("Media Converter", style = MaterialTheme.typography.headlineMedium) @@ -67,22 +72,32 @@ fun ConverterScreen( when (val s = state) { is ConversionState.Idle -> { Text( - "Pick a video to transcode to H.265.", + "Pick a file to convert.", style = MaterialTheme.typography.bodyMedium, ) - Button(onClick = { pickInput.launch(arrayOf("video/*")) }) { - Text("Choose file") - } + Button(onClick = { pickInput.launch(arrayOf("*/*")) }) { Text("Choose file") } } is ConversionState.Ready -> { FileCard(s.input) - Text( - "We'll show progress in a notification so you can leave the app.", - style = MaterialTheme.typography.bodySmall, + FormatPicker( + selected = settings.format, + onSelect = viewModel::setFormat, ) - Button(onClick = ::startConversion) { Text("Convert") } - OutlinedButton(onClick = { pickInput.launch(arrayOf("video/*")) }) { + QualityPicker( + selected = settings.quality, + onSelect = viewModel::setQuality, + ) + EnginePicker( + selected = settings.enginePreference, + onSelect = viewModel::setEnginePreference, + ) + Button( + onClick = { + requestNotifications.launch(Manifest.permission.POST_NOTIFICATIONS) + } + ) { Text("Convert") } + OutlinedButton(onClick = { pickInput.launch(arrayOf("*/*")) }) { Text("Choose a different file") } } @@ -113,9 +128,15 @@ fun ConverterScreen( "Done — ${formatBytes(s.staged.length())} output.", style = MaterialTheme.typography.bodyMedium, ) + if (s.routeReason.isNotBlank()) { + // Surfacing the routing decision rather than hiding it: it explains + // why a job was slow, and makes the software fallback visible. + AssistChip(onClick = {}, label = { Text(s.routeReason) }) + } Button(onClick = { chooseDestination.launch(viewModel.suggestedOutputName()) }) { Text("Save file") } + OutlinedButton(onClick = viewModel::reset) { Text("Start over") } } is ConversionState.Saved -> { @@ -135,6 +156,58 @@ fun ConverterScreen( } } +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun FormatPicker(selected: OutputFormat, onSelect: (OutputFormat) -> Unit) { + Text("Output format", style = MaterialTheme.typography.titleSmall) + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutputFormat.entries.forEach { format -> + FilterChip( + selected = format == selected, + onClick = { onSelect(format) }, + label = { Text(format.label) }, + ) + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun QualityPicker(selected: QualityTier, onSelect: (QualityTier) -> Unit) { + Text("Quality", style = MaterialTheme.typography.titleSmall) + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + QualityTier.entries.forEach { tier -> + FilterChip( + selected = tier == selected, + onClick = { onSelect(tier) }, + label = { Text(tier.label) }, + ) + } + } + Text(selected.description, style = MaterialTheme.typography.bodySmall) +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun EnginePicker(selected: EnginePreference, onSelect: (EnginePreference) -> Unit) { + Text("Engine", style = MaterialTheme.typography.titleSmall) + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + EnginePreference.entries.forEach { preference -> + FilterChip( + selected = preference == selected, + onClick = { onSelect(preference) }, + label = { Text(preference.label()) }, + ) + } + } +} + +private fun EnginePreference.label(): String = when (this) { + EnginePreference.AUTO -> "Automatic" + EnginePreference.PREFER_HARDWARE -> "Prefer hardware" + EnginePreference.FORCE_SOFTWARE -> "Force software" +} + @Composable private fun FileCard(input: InputFile) { Card(modifier = Modifier.fillMaxWidth()) { diff --git a/app/src/main/java/dev/jasonmross/mediaconverter/convert/MediaProbe.kt b/app/src/main/java/dev/jasonmross/mediaconverter/convert/MediaProbe.kt new file mode 100644 index 0000000..d0fba4e --- /dev/null +++ b/app/src/main/java/dev/jasonmross/mediaconverter/convert/MediaProbe.kt @@ -0,0 +1,112 @@ +package dev.jasonmross.mediaconverter.convert + +import android.content.Context +import android.media.MediaExtractor +import android.media.MediaFormat +import android.net.Uri +import android.util.Log +import dev.jasonmross.mediaconverter.model.ConcatInput +import dev.jasonmross.mediaconverter.model.InputProbe + +/** + * Reads just enough about an input to route it. + * + * Uses the platform extractor rather than FFprobe: it needs no native library, works + * directly on a content:// URI, and the router only needs the codec and duration. If + * the platform cannot parse the file at all, that is itself the answer — a file + * MediaExtractor cannot open is one Media3 cannot convert, so it routes to FFmpeg. + */ +object MediaProbe { + + fun probe(context: Context, uri: Uri): InputProbe { + val extractor = MediaExtractor() + return try { + extractor.setDataSource(context, uri, null) + var video: String? = null + var audio: String? = null + var durationUs = 0L + + for (i in 0 until extractor.trackCount) { + val format = extractor.getTrackFormat(i) + val mime = format.getString(MediaFormat.KEY_MIME).orEmpty() + if (format.containsKey(MediaFormat.KEY_DURATION)) { + durationUs = maxOf(durationUs, format.getLong(MediaFormat.KEY_DURATION)) + } + when { + mime.startsWith("video/") && video == null -> video = shortName(mime) + mime.startsWith("audio/") && audio == null -> audio = shortName(mime) + } + } + + InputProbe( + videoCodec = video, + audioCodec = audio, + hasVideo = video != null, + durationMs = durationUs / 1000, + ) + } catch (e: Exception) { + // Not a failure: an unparseable input is a strong signal that this job + // belongs on FFmpeg. Reporting an unknown codec makes the router say so. + Log.i(TAG, "Platform extractor could not read $uri; routing to FFmpeg.", e) + InputProbe(videoCodec = InputProbe.UNPARSEABLE, hasVideo = true, durationMs = 0) + } finally { + runCatching { extractor.release() } + } + } + + /** + * Reads the properties that decide whether inputs can be joined without + * re-encoding. Unknown values stay null, which [ConcatPlanner] treats as "cannot + * prove a match" rather than as agreement. + */ + fun probeForConcat(context: Context, uri: Uri): ConcatInput { + val extractor = MediaExtractor() + return try { + extractor.setDataSource(context, uri, null) + var video: String? = null + var audio: String? = null + var width = 0 + var height = 0 + var fps = 0 + + for (i in 0 until extractor.trackCount) { + val format = extractor.getTrackFormat(i) + val mime = format.getString(MediaFormat.KEY_MIME).orEmpty() + if (mime.startsWith("video/") && video == null) { + video = shortName(mime) + width = format.intOr(MediaFormat.KEY_WIDTH) + height = format.intOr(MediaFormat.KEY_HEIGHT) + fps = format.intOr(MediaFormat.KEY_FRAME_RATE) + } else if (mime.startsWith("audio/") && audio == null) { + audio = shortName(mime) + } + } + ConcatInput(video, audio, width, height, fps) + } catch (e: Exception) { + Log.i(TAG, "Could not probe $uri for concat; will re-encode.", e) + ConcatInput(null, null, 0, 0, 0) + } finally { + runCatching { extractor.release() } + } + } + + private fun MediaFormat.intOr(key: String, fallback: Int = 0): Int = + if (containsKey(key)) runCatching { getInteger(key) }.getOrDefault(fallback) else fallback + + /** MediaFormat MIME -> the short codec names the router and FFmpeg both speak. */ + private fun shortName(mime: String): String = when (mime) { + MediaFormat.MIMETYPE_VIDEO_AVC -> "h264" + MediaFormat.MIMETYPE_VIDEO_HEVC -> "hevc" + MediaFormat.MIMETYPE_VIDEO_VP8 -> "vp8" + MediaFormat.MIMETYPE_VIDEO_VP9 -> "vp9" + MediaFormat.MIMETYPE_VIDEO_AV1 -> "av1" + MediaFormat.MIMETYPE_VIDEO_MPEG4 -> "mpeg4" + MediaFormat.MIMETYPE_AUDIO_AAC -> "aac" + MediaFormat.MIMETYPE_AUDIO_OPUS -> "opus" + MediaFormat.MIMETYPE_AUDIO_FLAC -> "flac" + MediaFormat.MIMETYPE_AUDIO_VORBIS -> "vorbis" + else -> mime.substringAfter('/') + } + + private const val TAG = "MediaProbe" +} diff --git a/app/src/main/java/dev/jasonmross/mediaconverter/ffmpeg/ConcatEngine.kt b/app/src/main/java/dev/jasonmross/mediaconverter/ffmpeg/ConcatEngine.kt new file mode 100644 index 0000000..0bb4277 --- /dev/null +++ b/app/src/main/java/dev/jasonmross/mediaconverter/ffmpeg/ConcatEngine.kt @@ -0,0 +1,85 @@ +package dev.jasonmross.mediaconverter.ffmpeg + +import android.content.Context +import android.net.Uri +import android.util.Log +import com.arthenica.ffmpegkit.FFmpegKit +import com.arthenica.ffmpegkit.FFmpegKitConfig +import com.arthenica.ffmpegkit.ReturnCode +import dev.jasonmross.mediaconverter.convert.MediaProbe +import dev.jasonmross.mediaconverter.model.ConcatPlanner +import dev.jasonmross.mediaconverter.model.ConcatStrategy +import dev.jasonmross.mediaconverter.model.OutputFormat +import kotlinx.coroutines.suspendCancellableCoroutine +import java.io.File +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +/** + * Joins several inputs into one file. + * + * Picks between a stream copy and a full re-encode by inspecting the inputs, because + * the `concat` demuxer requires matching codec, resolution and timebase and does not + * reliably fail when they differ — it can emit a file whose later segments are + * garbled. See [ConcatPlanner]. + */ +class ConcatEngine(private val context: Context) { + + data class Result(val strategy: ConcatStrategy, val output: File) + + suspend fun join( + inputs: List, + output: File, + format: OutputFormat = OutputFormat.MP4_H264, + ): Result { + require(inputs.size >= 2) { "Joining needs at least two files." } + + val paths = inputs.map { uri -> + if (uri.scheme == "content") { + FFmpegKitConfig.getSafParameterForRead(context, uri) + } else { + uri.path + } ?: error("Could not open one of the input files.") + } + + val strategy = ConcatPlanner.plan(inputs.map { MediaProbe.probeForConcat(context, it) }) + Log.i(TAG, "Joining ${inputs.size} files using $strategy") + + // The demuxer reads its input list from a file, which must live somewhere + // FFmpeg can read; app cache is a real path, so it just works. + val listFile = File(output.parentFile, "concat_list.txt").apply { + writeText(FFmpegConcatCommand.listFileContents(paths)) + } + + val args = FFmpegConcatCommand.build(strategy, paths, listFile, output, format) + try { + execute(args) + } finally { + listFile.delete() + } + return Result(strategy, output) + } + + private suspend fun execute(args: List) = suspendCancellableCoroutine { cont -> + Log.i(TAG, "ffmpeg ${args.joinToString(" ")}") + val session = FFmpegKit.executeWithArgumentsAsync(args.toTypedArray()) { completed -> + val rc = completed.getReturnCode() + when { + ReturnCode.isSuccess(rc) -> cont.resume(Unit) + ReturnCode.isCancel(rc) -> cont.cancel() + else -> cont.resumeWithException( + FFmpegEngine.FFmpegException( + "Joining failed (${rc?.value}): " + + completed.getAllLogsAsString(LOG_TAIL_LIMIT).orEmpty() + ) + ) + } + } + cont.invokeOnCancellation { FFmpegKit.cancel(session.getSessionId()) } + } + + private companion object { + const val TAG = "ConcatEngine" + const val LOG_TAIL_LIMIT = 40 + } +} diff --git a/app/src/main/java/dev/jasonmross/mediaconverter/ffmpeg/FFmpegCommandBuilder.kt b/app/src/main/java/dev/jasonmross/mediaconverter/ffmpeg/FFmpegCommandBuilder.kt new file mode 100644 index 0000000..3765b81 --- /dev/null +++ b/app/src/main/java/dev/jasonmross/mediaconverter/ffmpeg/FFmpegCommandBuilder.kt @@ -0,0 +1,129 @@ +package dev.jasonmross.mediaconverter.ffmpeg + +import dev.jasonmross.mediaconverter.model.ConversionRequest +import dev.jasonmross.mediaconverter.model.OutputFormat +import dev.jasonmross.mediaconverter.model.QualityTier + +/** + * Builds FFmpeg argument lists. + * + * Kept free of Android types so the whole matrix can be unit tested on the JVM. A + * wrong flag here produces a corrupt file or a silent quality regression, which is + * exactly the kind of thing that should not need a device to catch. + * + * Arguments are produced as a list rather than a shell string: paths routinely contain + * spaces, and a list has no quoting rules to get wrong. + */ +object FFmpegCommandBuilder { + + /** + * CRF values, chosen per codec rather than shared. + * + * x265 is roughly one CRF step "stronger" than x264 at the same number, so a + * shared constant would silently make HEVC output larger than intended. + */ + private const val CRF_H264 = 20 + private const val CRF_H265 = 24 + + fun build( + request: ConversionRequest, + inputPath: String, + outputPath: String, + ): List = buildList { + add("-hide_banner") + // Overwrite: the output path is one we just created in our own cache. + add("-y") + add("-i"); add(inputPath) + + addAll(streamSelection(request.format)) + addAll(videoArgs(request)) + addAll(audioArgs(request.format)) + addAll(containerArgs(request.format)) + + add(outputPath) + } + + private fun streamSelection(format: OutputFormat): List = when { + // -vn drops video entirely. Without it FFmpeg will happily try to carry a video + // stream into an audio container and fail at the muxer. + format.isAudioOnly -> listOf("-vn") + format == OutputFormat.GIF || format == OutputFormat.FRAMES_PNG -> listOf("-an") + else -> emptyList() + } + + private fun videoArgs(request: ConversionRequest): List { + val format = request.format + if (format.isAudioOnly) return emptyList() + + return when (format) { + OutputFormat.GIF -> listOf( + // One pass with a generated palette. GIF is limited to 256 colours, and + // the default palette produces visibly banded output; split+palettegen + // and paletteuse in a single graph avoids a temporary palette file. + "-vf", + "fps=12,scale=480:-1:flags=lanczos,split[a][b];" + + "[a]palettegen=stats_mode=diff[p];[b][p]paletteuse=dither=bayer", + "-loop", "0", + ) + + OutputFormat.FRAMES_PNG -> listOf("-vf", "fps=1", "-vsync", "0") + + else -> when (request.quality) { + // Software encoding: this is what the GPL licence buys. CRF targets a + // quality level and lets the bitrate fall where it may, which is what + // "compress this well" actually needs. No hardware encoder on Android + // exposes it. + QualityTier.BEST -> when (format.videoCodec) { + dev.jasonmross.mediaconverter.model.VideoCodec.H265 -> listOf( + "-c:v", "libx265", "-crf", "$CRF_H265", "-preset", "medium", + // Without this, many players and Apple devices refuse HEVC in MP4. + "-tag:v", "hvc1", + ) + dev.jasonmross.mediaconverter.model.VideoCodec.VP9 -> listOf( + "-c:v", "libvpx-vp9", "-crf", "31", "-b:v", "0", + ) + else -> listOf( + "-c:v", "libx264", "-crf", "$CRF_H264", "-preset", "medium", + // 4:2:0 for compatibility; some sources decode to 4:4:4, which + // most hardware players cannot handle. + "-pix_fmt", "yuv420p", + ) + } + + // FFmpeg's MediaCodec wrappers. Used when a job landed on FFmpeg for + // container reasons but the user still asked for speed. + QualityTier.FAST -> when (format.videoCodec) { + dev.jasonmross.mediaconverter.model.VideoCodec.H265 -> + listOf("-c:v", "hevc_mediacodec", "-b:v", "5M", "-tag:v", "hvc1") + dev.jasonmross.mediaconverter.model.VideoCodec.VP9 -> + listOf("-c:v", "libvpx-vp9", "-crf", "31", "-b:v", "0") + else -> + listOf("-c:v", "h264_mediacodec", "-b:v", "5M") + } + } + } + } + + private fun audioArgs(format: OutputFormat): List = when (format) { + OutputFormat.MP3 -> listOf("-c:a", "libmp3lame", "-q:a", "2") + OutputFormat.FLAC -> listOf("-c:a", "flac") + OutputFormat.WAV -> listOf("-c:a", "pcm_s16le") + OutputFormat.OPUS -> listOf("-c:a", "libopus", "-b:a", "128k") + OutputFormat.M4A_AAC -> listOf("-c:a", "aac", "-b:a", "192k") + OutputFormat.GIF, OutputFormat.FRAMES_PNG -> emptyList() + OutputFormat.WEBM_VP9 -> listOf("-c:a", "libopus", "-b:a", "128k") + else -> listOf("-c:a", "aac", "-b:a", "192k") + } + + private fun containerArgs(format: OutputFormat): List = when (format.container) { + // Move the moov atom to the front so the file starts playing before it is + // fully downloaded. This is also the reason output never goes through a SAF + // file descriptor: faststart has to seek backwards to rewrite the header. + dev.jasonmross.mediaconverter.model.Container.MP4 -> listOf("-movflags", "+faststart") + else -> emptyList() + } + + /** Output filename pattern for formats that emit many files. */ + fun outputPattern(format: OutputFormat, baseName: String): String = + if (format == OutputFormat.FRAMES_PNG) "${baseName}_%04d.png" else baseName +} diff --git a/app/src/main/java/dev/jasonmross/mediaconverter/ffmpeg/FFmpegConcatCommand.kt b/app/src/main/java/dev/jasonmross/mediaconverter/ffmpeg/FFmpegConcatCommand.kt new file mode 100644 index 0000000..52c82af --- /dev/null +++ b/app/src/main/java/dev/jasonmross/mediaconverter/ffmpeg/FFmpegConcatCommand.kt @@ -0,0 +1,69 @@ +package dev.jasonmross.mediaconverter.ffmpeg + +import dev.jasonmross.mediaconverter.model.ConcatStrategy +import dev.jasonmross.mediaconverter.model.OutputFormat +import java.io.File + +/** + * Builds the two different FFmpeg invocations for joining files. + * + * Pure so the argument shapes can be unit tested; the caller supplies paths. + */ +object FFmpegConcatCommand { + + /** + * Content of the list file the `concat` demuxer reads. + * + * Single quotes are escaped the way the demuxer expects, because file names + * routinely contain apostrophes and an unescaped one silently truncates the entry. + */ + fun listFileContents(paths: List): String = + paths.joinToString("\n") { "file '${it.replace("'", "'\\''")}'" } + "\n" + + fun build( + strategy: ConcatStrategy, + inputPaths: List, + listFile: File, + output: File, + format: OutputFormat, + ): List = when (strategy) { + ConcatStrategy.STREAM_COPY -> buildList { + add("-hide_banner"); add("-y") + // -safe 0 permits absolute paths in the list file, which ours are. + add("-f"); add("concat") + add("-safe"); add("0") + add("-i"); add(listFile.absolutePath) + add("-c"); add("copy") + if (format.container == dev.jasonmross.mediaconverter.model.Container.MP4) { + add("-movflags"); add("+faststart") + } + add(output.absolutePath) + } + + ConcatStrategy.REENCODE -> buildList { + add("-hide_banner"); add("-y") + inputPaths.forEach { add("-i"); add(it) } + // Normalise every input to a common size and frame rate before joining, + // otherwise the concat filter refuses mismatched inputs. + val filter = buildString { + inputPaths.indices.forEach { i -> + append("[$i:v]scale=1280:720:force_original_aspect_ratio=decrease,") + append("pad=1280:720:-1:-1,setsar=1,fps=30[v$i];") + } + inputPaths.indices.forEach { i -> append("[v$i][$i:a]") } + append("concat=n=${inputPaths.size}:v=1:a=1[v][a]") + } + add("-filter_complex"); add(filter) + add("-map"); add("[v]") + add("-map"); add("[a]") + add("-c:v"); add("libx264") + add("-crf"); add("20") + add("-pix_fmt"); add("yuv420p") + add("-c:a"); add("aac") + if (format.container == dev.jasonmross.mediaconverter.model.Container.MP4) { + add("-movflags"); add("+faststart") + } + add(output.absolutePath) + } + } +} diff --git a/app/src/main/java/dev/jasonmross/mediaconverter/ffmpeg/FFmpegEngine.kt b/app/src/main/java/dev/jasonmross/mediaconverter/ffmpeg/FFmpegEngine.kt new file mode 100644 index 0000000..f575068 --- /dev/null +++ b/app/src/main/java/dev/jasonmross/mediaconverter/ffmpeg/FFmpegEngine.kt @@ -0,0 +1,89 @@ +package dev.jasonmross.mediaconverter.ffmpeg + +import android.util.Log +import com.arthenica.ffmpegkit.FFmpegKit +import com.arthenica.ffmpegkit.FFmpegKitConfig +import com.arthenica.ffmpegkit.Level +import com.arthenica.ffmpegkit.ReturnCode +import dev.jasonmross.mediaconverter.model.ConversionRequest +import kotlinx.coroutines.suspendCancellableCoroutine +import java.io.File +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +/** + * Software conversion via the bundled FFmpeg. + * + * Handles everything Media3 structurally cannot: Matroska, MP3, GIF, frame sequences, + * inputs with no platform decoder, and the CRF quality tier. + * + * Like [dev.jasonmross.mediaconverter.convert.Media3Engine], input arrives as a real + * filesystem path and output is written to app-private cache. FFmpeg is perfectly able + * to write through a SAF descriptor via its ffkitsaf protocol, but MP4 faststart has + * to seek backwards to rewrite the moov atom, which a SAF descriptor does not reliably + * support — so staging is the safe default for every format rather than a special case. + */ +class FFmpegEngine { + + init { + FFmpegKitConfig.setLogLevel(Level.AV_LOG_WARNING) + } + + /** + * Runs a conversion, reporting progress 0..100. + * + * Progress is derived from the statistics callback's timestamp against the known + * input duration. FFmpeg has no native notion of percentage complete, so a + * [durationMs] of zero means progress simply cannot be reported — the caller gets + * an indeterminate job rather than a fabricated number. + */ + suspend fun run( + request: ConversionRequest, + inputPath: String, + output: File, + durationMs: Long, + onProgress: (Int) -> Unit = {}, + ): Unit = suspendCancellableCoroutine { cont -> + val args = FFmpegCommandBuilder.build(request, inputPath, output.absolutePath) + Log.i(TAG, "ffmpeg ${args.joinToString(" ")}") + + val session = FFmpegKit.executeWithArgumentsAsync( + args.toTypedArray(), + { completed -> + val rc = completed.getReturnCode() + when { + ReturnCode.isSuccess(rc) -> cont.resume(Unit) + ReturnCode.isCancel(rc) -> + cont.cancel() + else -> cont.resumeWithException( + FFmpegException( + "FFmpeg failed (${rc?.value}): " + + completed.getFailStackTrace().orEmpty().ifBlank { + completed.getAllLogsAsString(LOG_TAIL_LIMIT).orEmpty() + } + ) + ) + } + }, + { log -> Log.d(TAG, log.message.trimEnd()) }, + { stats -> + if (durationMs > 0) { + val percent = (stats.time / durationMs * 100).toInt().coerceIn(0, 100) + onProgress(percent) + } + }, + ) + + cont.invokeOnCancellation { + FFmpegKit.cancel(session.getSessionId()) + output.delete() + } + } + + class FFmpegException(message: String) : RuntimeException(message) + + private companion object { + const val TAG = "FFmpegEngine" + const val LOG_TAIL_LIMIT = 40 + } +} diff --git a/app/src/main/java/dev/jasonmross/mediaconverter/join/JoinScreen.kt b/app/src/main/java/dev/jasonmross/mediaconverter/join/JoinScreen.kt new file mode 100644 index 0000000..18471e6 --- /dev/null +++ b/app/src/main/java/dev/jasonmross/mediaconverter/join/JoinScreen.kt @@ -0,0 +1,129 @@ +package dev.jasonmross.mediaconverter.join + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.media3.common.util.UnstableApi +import dev.jasonmross.mediaconverter.convert.InputFile +import dev.jasonmross.mediaconverter.model.ConcatStrategy + +@UnstableApi +@Composable +fun JoinScreen( + modifier: Modifier = Modifier, + viewModel: JoinViewModel = viewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + + val pickInputs = rememberLauncherForActivityResult( + ActivityResultContracts.OpenMultipleDocuments() + ) { uris -> if (uris.isNotEmpty()) viewModel.onInputsPicked(uris) } + + val chooseDestination = rememberLauncherForActivityResult( + ActivityResultContracts.CreateDocument("video/mp4") + ) { uri -> uri?.let(viewModel::save) } + + Column( + modifier = modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text("Join files", style = MaterialTheme.typography.headlineMedium) + + when (val s = state) { + is JoinState.Idle -> { + Text( + "Pick two or more files to join, in the order you want them.", + style = MaterialTheme.typography.bodyMedium, + ) + Button(onClick = { pickInputs.launch(arrayOf("video/*")) }) { + Text("Choose files") + } + } + + is JoinState.Ready -> { + s.inputs.forEach { FileRow(it) } + Button(onClick = viewModel::join) { Text("Join ${s.inputs.size} files") } + OutlinedButton(onClick = { pickInputs.launch(arrayOf("video/*")) }) { + Text("Choose different files") + } + } + + is JoinState.Joining -> { + Text("Joining ${s.inputs.size} files…") + // Indeterminate on purpose: FFmpeg reports progress against a single + // input's duration, which means nothing across a concatenation. A + // fabricated percentage would be worse than none. + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + OutlinedButton(onClick = viewModel::cancel) { Text("Cancel") } + } + + is JoinState.Waiting -> { + Text( + "Paused. The system limits background media processing to six " + + "hours a day, so this will resume automatically.", + style = MaterialTheme.typography.bodyMedium, + ) + OutlinedButton(onClick = viewModel::cancel) { Text("Cancel") } + } + + is JoinState.Joined -> { + Text("Joined — ${s.staged.length() / 1_000_000} MB.") + Text( + when (s.strategy) { + ConcatStrategy.STREAM_COPY -> + "Files matched, so they were joined without re-encoding — no quality loss." + ConcatStrategy.REENCODE -> + "Files differed in format, so they were re-encoded to match." + }, + style = MaterialTheme.typography.bodySmall, + ) + Button(onClick = { chooseDestination.launch("joined.mp4") }) { Text("Save file") } + OutlinedButton(onClick = viewModel::reset) { Text("Start over") } + } + + is JoinState.Saved -> { + Text("Saved ${s.displayName}.", style = MaterialTheme.typography.bodyLarge) + Button(onClick = viewModel::reset) { Text("Join more") } + } + + is JoinState.Failed -> { + Text( + s.message, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium, + ) + Button(onClick = viewModel::reset) { Text("Start over") } + } + } + } +} + +@Composable +private fun FileRow(input: InputFile) { + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(12.dp)) { + Text(input.displayName, style = MaterialTheme.typography.bodyMedium) + } + } +} diff --git a/app/src/main/java/dev/jasonmross/mediaconverter/join/JoinViewModel.kt b/app/src/main/java/dev/jasonmross/mediaconverter/join/JoinViewModel.kt new file mode 100644 index 0000000..2d27b37 --- /dev/null +++ b/app/src/main/java/dev/jasonmross/mediaconverter/join/JoinViewModel.kt @@ -0,0 +1,141 @@ +package dev.jasonmross.mediaconverter.join + +import android.app.Application +import android.net.Uri +import android.provider.OpenableColumns +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import androidx.media3.common.util.UnstableApi +import androidx.work.WorkInfo +import androidx.work.WorkManager +import dev.jasonmross.mediaconverter.convert.InputFile +import dev.jasonmross.mediaconverter.convert.OutputPublisher +import dev.jasonmross.mediaconverter.model.ConcatStrategy +import dev.jasonmross.mediaconverter.work.ConcatWorker +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File +import java.util.UUID + +sealed interface JoinState { + data object Idle : JoinState + data class Ready(val inputs: List) : JoinState + data class Joining(val inputs: List) : JoinState + data class Waiting(val inputs: List) : JoinState + data class Joined(val staged: File, val strategy: ConcatStrategy) : JoinState + data class Saved(val displayName: String) : JoinState + data class Failed(val message: String) : JoinState +} + +@UnstableApi +class JoinViewModel(app: Application) : AndroidViewModel(app) { + + private val workManager = WorkManager.getInstance(app) + private val publisher = OutputPublisher(app) + + private val _state = MutableStateFlow(JoinState.Idle) + val state: StateFlow = _state.asStateFlow() + + private var observer: Job? = null + private var activeWorkId: UUID? = null + + fun onInputsPicked(uris: List) { + if (uris.size < 2) { + _state.value = JoinState.Failed("Pick at least two files to join.") + return + } + viewModelScope.launch { + val files = withContext(Dispatchers.IO) { uris.map(::queryFile) } + _state.value = JoinState.Ready(files) + } + } + + fun join() { + val inputs = (_state.value as? JoinState.Ready)?.inputs ?: return + val request = ConcatWorker.request( + inputs = inputs.map { it.uri }, + totalBytes = inputs.sumOf { it.sizeBytes }, + ) + activeWorkId = request.id + workManager.enqueue(request) + _state.value = JoinState.Joining(inputs) + + observer?.cancel() + observer = viewModelScope.launch { + workManager.getWorkInfoByIdFlow(request.id).collect { info -> + if (info == null) return@collect + _state.value = when (info.state) { + WorkInfo.State.RUNNING, WorkInfo.State.BLOCKED -> JoinState.Joining(inputs) + WorkInfo.State.ENQUEUED -> + if (info.runAttemptCount > 0) JoinState.Waiting(inputs) + else JoinState.Joining(inputs) + + WorkInfo.State.SUCCEEDED -> { + val path = info.outputData.getString(ConcatWorker.KEY_OUTPUT_PATH) + val strategy = info.outputData.getString(ConcatWorker.KEY_STRATEGY) + ?.let(ConcatStrategy::valueOf) ?: ConcatStrategy.REENCODE + if (path == null) { + JoinState.Failed("Joining reported success but produced no file.") + } else { + JoinState.Joined(File(path), strategy) + } + } + + WorkInfo.State.FAILED -> JoinState.Failed( + info.outputData.getString(ConcatWorker.KEY_ERROR) ?: "Joining failed." + ) + + WorkInfo.State.CANCELLED -> JoinState.Ready(inputs) + } + } + } + } + + fun cancel() { + activeWorkId?.let(workManager::cancelWorkById) + } + + fun save(destination: Uri) { + val joined = _state.value as? JoinState.Joined ?: return + viewModelScope.launch { + runCatching { + withContext(Dispatchers.IO) { + publisher.publish(joined.staged, destination) + joined.staged.delete() + } + }.onSuccess { + _state.value = JoinState.Saved("joined.mp4") + }.onFailure { e -> + _state.value = JoinState.Failed(e.message ?: "Could not save the file.") + } + } + } + + fun reset() { + observer?.cancel() + observer = null + activeWorkId = null + _state.value = JoinState.Idle + } + + private fun queryFile(uri: Uri): InputFile { + var name = "input" + var size = 0L + getApplication().contentResolver + .query(uri, null, null, null, null) + ?.use { cursor -> + if (cursor.moveToFirst()) { + cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME).takeIf { it >= 0 } + ?.let { name = cursor.getString(it) ?: name } + cursor.getColumnIndex(OpenableColumns.SIZE).takeIf { it >= 0 } + ?.let { size = cursor.getLong(it) } + } + } + return InputFile(uri, name, size) + } +} diff --git a/app/src/main/java/dev/jasonmross/mediaconverter/model/ConcatStrategy.kt b/app/src/main/java/dev/jasonmross/mediaconverter/model/ConcatStrategy.kt new file mode 100644 index 0000000..81ee1f4 --- /dev/null +++ b/app/src/main/java/dev/jasonmross/mediaconverter/model/ConcatStrategy.kt @@ -0,0 +1,65 @@ +package dev.jasonmross.mediaconverter.model + +/** + * How to join several inputs into one file. + * + * The naive answer — FFmpeg's `concat` demuxer — only works when every input shares a + * codec, resolution and timebase. That is true for clips from one camera in one + * session, and false for the case users actually hit: two clips from different phones, + * or a screen recording joined to a camera clip. The demuxer does not fail loudly on + * mismatch; it can produce a file whose second half is garbled. + * + * So the strategy is chosen from the inputs rather than assumed. + */ +enum class ConcatStrategy { + /** + * Stream copy via the `concat` demuxer. No re-encode, near-instant, lossless. + * Requires every input to agree on codec, resolution, frame rate and timebase. + */ + STREAM_COPY, + + /** + * Re-encode through the `concat` filter, normalising to a common format. + * Slower and lossy, but it is the only correct answer for mismatched inputs. + */ + REENCODE, +} + +/** The properties that decide whether inputs can be joined without re-encoding. */ +data class ConcatInput( + val videoCodec: String?, + val audioCodec: String?, + val width: Int, + val height: Int, + val frameRate: Int, +) + +object ConcatPlanner { + + /** + * Picks a strategy for [inputs]. + * + * Errs towards [ConcatStrategy.REENCODE]: a needless re-encode costs time, while a + * wrong stream copy costs the user a corrupt file they may not notice until later. + */ + fun plan(inputs: List): ConcatStrategy { + if (inputs.size < 2) return ConcatStrategy.STREAM_COPY + + val first = inputs.first() + // A null codec means we could not determine it. That is not evidence of a + // match, so it must not be treated as one. + if (inputs.any { it.videoCodec == null || it.videoCodec != first.videoCodec }) { + return ConcatStrategy.REENCODE + } + if (inputs.any { it.audioCodec != first.audioCodec }) { + return ConcatStrategy.REENCODE + } + if (inputs.any { it.width != first.width || it.height != first.height }) { + return ConcatStrategy.REENCODE + } + if (inputs.any { it.frameRate != first.frameRate }) { + return ConcatStrategy.REENCODE + } + return ConcatStrategy.STREAM_COPY + } +} diff --git a/app/src/main/java/dev/jasonmross/mediaconverter/model/ConversionRouter.kt b/app/src/main/java/dev/jasonmross/mediaconverter/model/ConversionRouter.kt new file mode 100644 index 0000000..ea26256 --- /dev/null +++ b/app/src/main/java/dev/jasonmross/mediaconverter/model/ConversionRouter.kt @@ -0,0 +1,140 @@ +package dev.jasonmross.mediaconverter.model + +/** + * Chooses the engine for a conversion. + * + * Media3 Transformer is preferred wherever it is capable, because it is hardware + * accelerated end to end (MediaCodec decode -> GL surface -> MediaCodec encode) and + * runs several times faster than software encoding at a fraction of the battery cost. + * FFmpeg handles everything Media3 structurally cannot. + * + * The rules below are capability boundaries, not preferences. Each one exists because + * Media3 would fail or silently produce the wrong thing. + */ +object ConversionRouter { + + /** Containers Media3 can mux. Anything else has to go to FFmpeg. */ + private val MEDIA3_CONTAINERS = setOf( + Container.MP4, + Container.WEBM, + Container.OGG, + Container.WAV, + Container.AAC_ADTS, + ) + + /** WebM is codec-restricted: Media3's WebmMuxer writes only these. */ + private val WEBM_AUDIO = setOf(AudioCodec.OPUS, AudioCodec.VORBIS) + private val WEBM_VIDEO = setOf(VideoCodec.VP8, VideoCodec.VP9, VideoCodec.NONE) + + /** Video codecs Media3 can be asked to encode (`Transformer.setVideoMimeType`). */ + private val MEDIA3_VIDEO = setOf(VideoCodec.H264, VideoCodec.H265, VideoCodec.NONE) + + /** Audio codecs Media3 can encode. Notably absent: MP3 and FLAC. */ + private val MEDIA3_AUDIO = setOf(AudioCodec.AAC, AudioCodec.OPUS, AudioCodec.PCM, AudioCodec.NONE) + + fun route(request: ConversionRequest, device: DeviceCodecs): Decision { + when (request.enginePreference) { + EnginePreference.FORCE_SOFTWARE -> + return Decision(Engine.FFMPEG, Reason.USER_FORCED_SOFTWARE) + else -> Unit + } + + // Order matters below: the specific reasons are checked before the general + // ones, because the reason string is shown to the user. "Android has no + // encoder for this format" tells them something actionable about MP3; + // "this container needs FFmpeg" does not. + + // MP3 has no encoder anywhere on Android, at any API level. That is a platform + // gap rather than a Media3 limitation, and libmp3lame is the only way the app + // can produce MP3 at all. + if (request.format.audioCodec !in MEDIA3_AUDIO) { + return Decision(Engine.FFMPEG, Reason.NO_PLATFORM_ENCODER) + } + + // GIF and frame sequences are image outputs; Media3 has no muxer for them. + if (request.format.isImageOutput) { + return Decision(Engine.FFMPEG, Reason.IMAGE_OUTPUT) + } + + // Containers Media3 cannot mux at all, chiefly Matroska. + if (request.format.container !in MEDIA3_CONTAINERS) { + return Decision(Engine.FFMPEG, Reason.CONTAINER_UNSUPPORTED) + } + + // WebM is codec-restricted even though the container itself is supported. + if (request.format.container == Container.WEBM && + (request.format.audioCodec !in WEBM_AUDIO || request.format.videoCodec !in WEBM_VIDEO) + ) { + return Decision(Engine.FFMPEG, Reason.WEBM_CODEC_UNSUPPORTED) + } + + // Media3 does not bundle ExoPlayer's software decoders, so an input codec with + // no platform decoder cannot be read at all. The dav1d extension does not + // rescue this: Transformer ignores bundled software decoder modules. + val inputCodec = request.probe.videoCodec + if (inputCodec != null && !device.canDecode(inputCodec)) { + return Decision(Engine.FFMPEG, Reason.NO_PLATFORM_DECODER) + } + + // CRF and two-pass are the whole point of the quality tier, and MediaCodec + // exposes neither, so BEST always means software encoding. + if (request.quality == QualityTier.BEST) { + return Decision(Engine.FFMPEG, Reason.QUALITY_TIER_REQUIRES_CRF) + } + + // Transformer.setVideoMimeType accepts only H.263/H.264/H.265/MP4V, so VP9 and + // AV1 targets cannot be encoded by Media3 regardless of what the device can do. + if (request.format.videoCodec !in MEDIA3_VIDEO) { + return Decision(Engine.FFMPEG, Reason.NO_PLATFORM_ENCODER) + } + + // Finally, the target codec has to be hardware-encodable on this specific + // device. HEVC is near-universal; AV1 encode is rare. + if (request.format.videoCodec != VideoCodec.NONE && + !device.canEncode(request.format.videoCodec) + ) { + return Decision(Engine.FFMPEG, Reason.NO_HARDWARE_ENCODER) + } + + return Decision(Engine.MEDIA3, Reason.HARDWARE_CAPABLE) + } + + data class Decision(val engine: Engine, val reason: Reason) + + enum class Reason(val explanation: String) { + HARDWARE_CAPABLE("Hardware accelerated"), + CONTAINER_UNSUPPORTED("This container needs FFmpeg"), + WEBM_CODEC_UNSUPPORTED("WebM only supports VP8/VP9 with Opus or Vorbis"), + NO_PLATFORM_ENCODER("Android has no encoder for this format"), + NO_PLATFORM_DECODER("This device cannot decode the input in hardware"), + NO_HARDWARE_ENCODER("This device has no hardware encoder for that codec"), + IMAGE_OUTPUT("Image output needs FFmpeg"), + QUALITY_TIER_REQUIRES_CRF("Best quality uses software encoding"), + USER_FORCED_SOFTWARE("Software encoding was requested"), + MEDIA3_FAILED("Hardware conversion failed; retrying in software"), + } +} + +/** + * What this device's codecs can actually do. + * + * An interface so the routing rules can be unit tested against fabricated device + * profiles rather than whatever hardware the test happens to run on. + */ +interface DeviceCodecs { + fun canEncode(codec: VideoCodec): Boolean + fun canDecode(codecName: String): Boolean + + companion object { + /** + * Assumes everything works, except an input the platform could not parse. + * + * That exception matters: a device double that claims it can decode an + * unparseable file would let the router send a doomed job to Media3. + */ + val PERMISSIVE = object : DeviceCodecs { + override fun canEncode(codec: VideoCodec) = true + override fun canDecode(codecName: String) = codecName != InputProbe.UNPARSEABLE + } + } +} diff --git a/app/src/main/java/dev/jasonmross/mediaconverter/model/OutputFormat.kt b/app/src/main/java/dev/jasonmross/mediaconverter/model/OutputFormat.kt new file mode 100644 index 0000000..e62ab36 --- /dev/null +++ b/app/src/main/java/dev/jasonmross/mediaconverter/model/OutputFormat.kt @@ -0,0 +1,87 @@ +package dev.jasonmross.mediaconverter.model + +/** Container families, used by the router to decide which engine can mux the result. */ +enum class Container { MP4, WEBM, MKV, OGG, WAV, AAC_ADTS, MP3, GIF, IMAGE_SEQUENCE } + +/** Codecs this app can be asked to produce. */ +enum class VideoCodec { H264, H265, VP9, VP8, AV1, NONE } +enum class AudioCodec { AAC, OPUS, VORBIS, MP3, FLAC, PCM, NONE } + +/** + * A user-selectable output format. + * + * Deliberately a closed set rather than a free-form codec/container matrix: most + * combinations are either invalid or pointless, and the closed set is what makes the + * routing rules decidable. + */ +enum class OutputFormat( + val label: String, + val container: Container, + val videoCodec: VideoCodec, + val audioCodec: AudioCodec, + val extension: String, + val mimeType: String, +) { + MP4_H264("MP4 (H.264)", Container.MP4, VideoCodec.H264, AudioCodec.AAC, "mp4", "video/mp4"), + MP4_H265("MP4 (H.265)", Container.MP4, VideoCodec.H265, AudioCodec.AAC, "mp4", "video/mp4"), + WEBM_VP9("WebM (VP9)", Container.WEBM, VideoCodec.VP9, AudioCodec.OPUS, "webm", "video/webm"), + MKV_H264("MKV (H.264)", Container.MKV, VideoCodec.H264, AudioCodec.AAC, "mkv", "video/x-matroska"), + MKV_H265("MKV (H.265)", Container.MKV, VideoCodec.H265, AudioCodec.AAC, "mkv", "video/x-matroska"), + + MP3("MP3", Container.MP3, VideoCodec.NONE, AudioCodec.MP3, "mp3", "audio/mpeg"), + M4A_AAC("M4A (AAC)", Container.MP4, VideoCodec.NONE, AudioCodec.AAC, "m4a", "audio/mp4"), + OPUS("Opus", Container.OGG, VideoCodec.NONE, AudioCodec.OPUS, "opus", "audio/opus"), + FLAC("FLAC", Container.MKV, VideoCodec.NONE, AudioCodec.FLAC, "flac", "audio/flac"), + WAV("WAV", Container.WAV, VideoCodec.NONE, AudioCodec.PCM, "wav", "audio/wav"), + + GIF("GIF", Container.GIF, VideoCodec.NONE, AudioCodec.NONE, "gif", "image/gif"), + FRAMES_PNG("PNG frames", Container.IMAGE_SEQUENCE, VideoCodec.NONE, AudioCodec.NONE, "png", "image/png"); + + val isAudioOnly: Boolean get() = videoCodec == VideoCodec.NONE && audioCodec != AudioCodec.NONE + val isImageOutput: Boolean + get() = container == Container.GIF || container == Container.IMAGE_SEQUENCE +} + +/** + * How hard to work for quality. + * + * This is the user-facing form of the engine split. [BEST] is the entire reason the + * shipped binary is GPL: CRF and two-pass rate control come from x264/x265 and are not + * exposed by any Android hardware encoder. + */ +enum class QualityTier(val label: String, val description: String) { + FAST("Fast", "Hardware accelerated. Best for sharing and batches."), + BEST("Best quality", "Software encode with CRF. Slower, smaller files."), +} + +/** Escape hatch, mostly for debugging and for devices with broken encoders. */ +enum class EnginePreference { AUTO, PREFER_HARDWARE, FORCE_SOFTWARE } + +enum class Engine { MEDIA3, FFMPEG } + +/** What we know about the input, as far as routing is concerned. */ +data class InputProbe( + val videoCodec: String? = null, + val audioCodec: String? = null, + val hasVideo: Boolean = true, + val durationMs: Long = 0, +) { + companion object { + /** + * Codec name used when the platform could not parse the input at all. + * + * Distinct from `null` (nothing known, assume the platform copes) and from a + * real codec name. [DeviceCodecs] treats it as undecodable, which routes the + * job to FFmpeg — the correct answer when the platform extractor has already + * failed to open the file. + */ + const val UNPARSEABLE = "\u0000unparseable" + } +} + +data class ConversionRequest( + val format: OutputFormat, + val quality: QualityTier = QualityTier.FAST, + val enginePreference: EnginePreference = EnginePreference.AUTO, + val probe: InputProbe = InputProbe(), +) diff --git a/app/src/main/java/dev/jasonmross/mediaconverter/work/ConcatWorker.kt b/app/src/main/java/dev/jasonmross/mediaconverter/work/ConcatWorker.kt new file mode 100644 index 0000000..e34c7e4 --- /dev/null +++ b/app/src/main/java/dev/jasonmross/mediaconverter/work/ConcatWorker.kt @@ -0,0 +1,113 @@ +package dev.jasonmross.mediaconverter.work + +import android.content.Context +import android.net.Uri +import android.util.Log +import androidx.media3.common.util.UnstableApi +import androidx.work.CoroutineWorker +import androidx.work.Data +import androidx.work.ForegroundInfo +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkInfo +import androidx.work.WorkerParameters +import androidx.work.workDataOf +import dev.jasonmross.mediaconverter.convert.OutputPublisher +import dev.jasonmross.mediaconverter.ffmpeg.ConcatEngine +import dev.jasonmross.mediaconverter.model.OutputFormat + +/** + * Joins several files into one, as durable foreground work. + * + * Separate from [ConversionWorker] rather than folded into it: joining takes a list of + * inputs, has its own stream-copy-versus-re-encode decision, and reports a different + * result. Overloading one worker with both would make the input contract ambiguous. + * + * Progress is not reported. FFmpeg's statistics callback gives a timestamp against a + * single input's duration, which is meaningless once several files are being + * concatenated; showing a fabricated percentage would be worse than showing none. + */ +@UnstableApi +class ConcatWorker( + context: Context, + params: WorkerParameters, +) : CoroutineWorker(context, params) { + + private val notifications = ConversionNotifications(applicationContext) + private val publisher = OutputPublisher(applicationContext) + + override suspend fun doWork(): Result { + val uris = inputData.getStringArray(KEY_INPUT_URIS)?.map(Uri::parse) + ?: return Result.failure(workDataOf(KEY_ERROR to "No input files.")) + if (uris.size < 2) { + return Result.failure(workDataOf(KEY_ERROR to "Pick at least two files to join.")) + } + val totalBytes = inputData.getLong(KEY_TOTAL_BYTES, 0L) + val format = OutputFormat.valueOf( + inputData.getString(KEY_FORMAT) ?: OutputFormat.MP4_H264.name + ) + + if (!publisher.hasSpaceFor(totalBytes)) { + return Result.failure(workDataOf(KEY_ERROR to "Not enough free space to join these files.")) + } + + setForeground( + ForegroundInfo( + NOTIFICATION_ID, + notifications.build(id, "Joining ${uris.size} files", 0, indeterminate = true), + ConversionForegroundType.current(), + ) + ) + + val staged = publisher.createStagingFile("joined.${format.extension}") + return try { + val result = ConcatEngine(applicationContext).join(uris, staged, format) + Result.success( + workDataOf( + KEY_OUTPUT_PATH to staged.absolutePath, + KEY_STRATEGY to result.strategy.name, + ) + ) + } catch (e: Throwable) { + staged.delete() + if (stopReason == WorkInfo.STOP_REASON_FOREGROUND_SERVICE_TIMEOUT) { + Log.w(TAG, "Foreground budget exhausted while joining; will retry.", e) + Result.retry() + } else { + Log.e(TAG, "Joining failed.", e) + Result.failure(workDataOf(KEY_ERROR to (e.message ?: "Joining failed."))) + } + } + } + + override suspend fun getForegroundInfo(): ForegroundInfo = ForegroundInfo( + NOTIFICATION_ID, + notifications.build(id, "Joining files", 0, indeterminate = true), + ConversionForegroundType.current(), + ) + + companion object { + const val KEY_INPUT_URIS = "input_uris" + const val KEY_TOTAL_BYTES = "total_bytes" + const val KEY_FORMAT = "format" + const val KEY_OUTPUT_PATH = "output_path" + const val KEY_STRATEGY = "strategy" + const val KEY_ERROR = "error" + + private const val NOTIFICATION_ID = 1002 + private const val TAG = "ConcatWorker" + + fun request( + inputs: List, + totalBytes: Long, + format: OutputFormat = OutputFormat.MP4_H264, + ) = OneTimeWorkRequestBuilder() + .setInputData( + Data.Builder() + .putStringArray(KEY_INPUT_URIS, inputs.map(Uri::toString).toTypedArray()) + .putLong(KEY_TOTAL_BYTES, totalBytes) + .putString(KEY_FORMAT, format.name) + .build() + ) + .build() + } +} diff --git a/app/src/main/java/dev/jasonmross/mediaconverter/work/ConversionWorker.kt b/app/src/main/java/dev/jasonmross/mediaconverter/work/ConversionWorker.kt index 0d43207..c9375cb 100644 --- a/app/src/main/java/dev/jasonmross/mediaconverter/work/ConversionWorker.kt +++ b/app/src/main/java/dev/jasonmross/mediaconverter/work/ConversionWorker.kt @@ -12,16 +12,28 @@ import androidx.work.OneTimeWorkRequestBuilder import androidx.work.WorkInfo import androidx.work.WorkerParameters import androidx.work.workDataOf +import com.arthenica.ffmpegkit.FFmpegKitConfig +import dev.jasonmross.mediaconverter.codec.AndroidDeviceCodecs import dev.jasonmross.mediaconverter.convert.Media3Engine +import dev.jasonmross.mediaconverter.convert.MediaProbe import dev.jasonmross.mediaconverter.convert.OutputPublisher +import dev.jasonmross.mediaconverter.ffmpeg.FFmpegEngine +import dev.jasonmross.mediaconverter.model.ConversionRequest +import dev.jasonmross.mediaconverter.model.ConversionRouter +import dev.jasonmross.mediaconverter.model.Engine +import dev.jasonmross.mediaconverter.model.EnginePreference +import dev.jasonmross.mediaconverter.model.OutputFormat +import dev.jasonmross.mediaconverter.model.QualityTier +import dev.jasonmross.mediaconverter.model.VideoCodec +import java.io.File /** * Runs one conversion as durable, cancellable background work. * - * WorkManager rather than a bare foreground service: the queue survives process - * death, cancellation and progress are already modelled, and `WorkInfo` gives the UI - * a Flow to observe. That durability is what makes the six-hour foreground-service - * timeout recoverable instead of fatal — see [handleTimeoutIfNeeded]. + * WorkManager rather than a bare foreground service: the queue survives process death, + * cancellation and progress are already modelled, and `WorkInfo` gives the UI a Flow to + * observe. That durability is what makes the six-hour foreground-service timeout + * recoverable instead of fatal. * * Expedited work is deliberately *not* used. It maps to JobScheduler expedited jobs * with a short quota, which is the wrong shape for a multi-minute transcode. @@ -36,10 +48,19 @@ class ConversionWorker( private val publisher = OutputPublisher(applicationContext) override suspend fun doWork(): Result { - val inputUri = inputData.getString(KEY_INPUT_URI)?.toUri() ?: return Result.failure() + val inputUri = inputData.getString(KEY_INPUT_URI)?.let(Uri::parse) + ?: return Result.failure(workDataOf(KEY_ERROR to "No input file.")) val displayName = inputData.getString(KEY_DISPLAY_NAME) ?: "input" val sizeBytes = inputData.getLong(KEY_SIZE_BYTES, 0L) - val mimeType = inputData.getString(KEY_VIDEO_MIME) ?: MimeTypes.VIDEO_H265 + val format = OutputFormat.valueOf( + inputData.getString(KEY_FORMAT) ?: OutputFormat.MP4_H265.name + ) + val quality = QualityTier.valueOf( + inputData.getString(KEY_QUALITY) ?: QualityTier.FAST.name + ) + val preference = EnginePreference.valueOf( + inputData.getString(KEY_ENGINE_PREFERENCE) ?: EnginePreference.AUTO.name + ) if (!publisher.hasSpaceFor(sizeBytes)) { return Result.failure(workDataOf(KEY_ERROR to "Not enough free space to convert.")) @@ -47,52 +68,121 @@ class ConversionWorker( setForeground(foregroundInfo(displayName, percent = 0, indeterminate = true)) - val staged = publisher.createStagingFile(outputNameFor(displayName)) - // The engine owns its own Looper thread, so it is safe to call from this - // Looper-less worker thread. See Media3Engine. - val engine = Media3Engine(applicationContext) + val probe = MediaProbe.probe(applicationContext, inputUri) + val request = ConversionRequest(format, quality, preference, probe) + val decision = ConversionRouter.route(request, AndroidDeviceCodecs.get()) + Log.i(TAG, "Routing $displayName -> ${format.name} via ${decision.engine} (${decision.reason})") + + val staged = publisher.createStagingFile(outputNameFor(displayName, format)) return try { - var lastPublished = 0L - engine.transcode(inputUri, staged, mimeType) { percent -> - setProgressAsync(workDataOf(KEY_PROGRESS to percent)) - // Throttle the notification to ~1/sec. The underlying progress updates - // several times a second, and pushing every one janks the system UI. - val now = System.currentTimeMillis() - if (now - lastPublished >= NOTIFICATION_INTERVAL_MS) { - lastPublished = now - notifications - .build(id, displayName, percent) - .let { notificationManager().notify(NOTIFICATION_ID, it) } - } + when (decision.engine) { + Engine.MEDIA3 -> runMedia3OrFallBack(request, inputUri, staged, displayName) + Engine.FFMPEG -> runFFmpeg(request, inputUri, staged, displayName) } - Result.success(workDataOf(KEY_OUTPUT_PATH to staged.absolutePath)) + Result.success( + workDataOf( + KEY_OUTPUT_PATH to staged.absolutePath, + KEY_ENGINE_USED to decision.engine.name, + KEY_ROUTE_REASON to decision.reason.explanation, + ) + ) } catch (e: Throwable) { staged.delete() handleTimeoutIfNeeded(e) + } + } + + /** + * The dynamic half of the routing rules. + * + * The static predicates catch what is knowably unsupported, but hardware encoders + * are vendor-declared and, per the platform's own documentation, "cannot be tested + * for correctness". A device that claims HEVC support and then fails mid-export is + * a real and common failure. Rather than surface that to the user as a failed + * conversion, retry the same job in software — slower, but it produces the file. + */ + private suspend fun runMedia3OrFallBack( + request: ConversionRequest, + inputUri: Uri, + staged: File, + displayName: String, + ) { + val engine = Media3Engine(applicationContext) + try { + engine.transcode(inputUri, staged, media3MimeType(request.format)) { percent -> + publishProgress(displayName, percent) + } + return + } catch (e: Throwable) { + if (isCancellation(e)) throw e + Log.w(TAG, "Hardware conversion failed; retrying in software.", e) } finally { engine.close() } + + staged.delete() + runFFmpeg(request, inputUri, staged, displayName) } + private suspend fun runFFmpeg( + request: ConversionRequest, + inputUri: Uri, + staged: File, + displayName: String, + ) { + // FFmpeg needs a path. ffkitsaf bridges a content:// URI for reading; the read + // side is seekable for local providers, which is all the demuxer needs. Output + // still goes to a real cache path — see OutputPublisher. + val inputPath = if (inputUri.scheme == "content") { + FFmpegKitConfig.getSafParameterForRead(applicationContext, inputUri) + } else { + inputUri.path + } ?: error("Could not open the input file.") + + FFmpegEngine().run(request, inputPath, staged, request.probe.durationMs) { percent -> + publishProgress(displayName, percent) + } + } + + private var lastNotified = 0L + + private fun publishProgress(displayName: String, percent: Int) { + setProgressAsync(workDataOf(KEY_PROGRESS to percent)) + // Throttle to ~1/sec: progress arrives several times a second and pushing every + // update janks the system UI. + val now = System.currentTimeMillis() + if (now - lastNotified >= NOTIFICATION_INTERVAL_MS) { + lastNotified = now + applicationContext.getSystemService(android.app.NotificationManager::class.java) + .notify(NOTIFICATION_ID, notifications.build(id, displayName, percent)) + } + } + + private fun isCancellation(e: Throwable): Boolean = + e is kotlinx.coroutines.CancellationException || isStopped + /** * Distinguishes a genuine failure from the foreground-service budget expiring. * * `mediaProcessing` allows six hours out of every twenty-four, shared across the * app. When that runs out WorkManager reports - * `STOP_REASON_FOREGROUND_SERVICE_TIMEOUT`, and the correct response is to retry + * `STOP_REASON_FOREGROUND_SERVICE_TIMEOUT`, and the right response is to retry * later rather than tell the user the conversion failed — the work is still valid, * there is simply no budget right now. */ private fun handleTimeoutIfNeeded(cause: Throwable): Result { - val timedOut = stopReason == WorkInfo.STOP_REASON_FOREGROUND_SERVICE_TIMEOUT - return if (timedOut) { + if (stopReason == WorkInfo.STOP_REASON_FOREGROUND_SERVICE_TIMEOUT) { Log.w(TAG, "Foreground service budget exhausted; will retry.", cause) - Result.retry() - } else { - Log.e(TAG, "Conversion failed.", cause) - Result.failure(workDataOf(KEY_ERROR to (cause.message ?: "Conversion failed."))) + return Result.retry() } + Log.e(TAG, "Conversion failed.", cause) + return Result.failure(workDataOf(KEY_ERROR to (cause.message ?: "Conversion failed."))) + } + + private fun media3MimeType(format: OutputFormat): String = when (format.videoCodec) { + VideoCodec.H264 -> MimeTypes.VIDEO_H264 + else -> MimeTypes.VIDEO_H265 } override suspend fun getForegroundInfo(): ForegroundInfo = @@ -109,39 +199,43 @@ class ConversionWorker( ConversionForegroundType.current(), ) - private fun notificationManager() = - applicationContext.getSystemService(android.app.NotificationManager::class.java) - - private fun String.toUri(): Uri? = runCatching { Uri.parse(this) }.getOrNull() - companion object { const val KEY_INPUT_URI = "input_uri" const val KEY_DISPLAY_NAME = "display_name" const val KEY_SIZE_BYTES = "size_bytes" - const val KEY_VIDEO_MIME = "video_mime" + const val KEY_FORMAT = "format" + const val KEY_QUALITY = "quality" + const val KEY_ENGINE_PREFERENCE = "engine_preference" const val KEY_PROGRESS = "progress" const val KEY_OUTPUT_PATH = "output_path" + const val KEY_ENGINE_USED = "engine_used" + const val KEY_ROUTE_REASON = "route_reason" const val KEY_ERROR = "error" private const val NOTIFICATION_ID = 1001 private const val NOTIFICATION_INTERVAL_MS = 1_000L private const val TAG = "ConversionWorker" - fun outputNameFor(inputName: String): String = - inputName.substringBeforeLast('.', inputName) + "_converted.mp4" + fun outputNameFor(inputName: String, format: OutputFormat): String = + inputName.substringBeforeLast('.', inputName) + + "_converted.${format.extension}" fun request( inputUri: Uri, displayName: String, sizeBytes: Long, - videoMimeType: String = MimeTypes.VIDEO_H265, + format: OutputFormat = OutputFormat.MP4_H265, + quality: QualityTier = QualityTier.FAST, + enginePreference: EnginePreference = EnginePreference.AUTO, ) = OneTimeWorkRequestBuilder() .setInputData( Data.Builder() .putString(KEY_INPUT_URI, inputUri.toString()) .putString(KEY_DISPLAY_NAME, displayName) .putLong(KEY_SIZE_BYTES, sizeBytes) - .putString(KEY_VIDEO_MIME, videoMimeType) + .putString(KEY_FORMAT, format.name) + .putString(KEY_QUALITY, quality.name) + .putString(KEY_ENGINE_PREFERENCE, enginePreference.name) .build() ) .build() diff --git a/app/src/test/java/dev/jasonmross/mediaconverter/ffmpeg/FFmpegCommandBuilderTest.kt b/app/src/test/java/dev/jasonmross/mediaconverter/ffmpeg/FFmpegCommandBuilderTest.kt new file mode 100644 index 0000000..067739e --- /dev/null +++ b/app/src/test/java/dev/jasonmross/mediaconverter/ffmpeg/FFmpegCommandBuilderTest.kt @@ -0,0 +1,180 @@ +package dev.jasonmross.mediaconverter.ffmpeg + +import dev.jasonmross.mediaconverter.model.ConversionRequest +import dev.jasonmross.mediaconverter.model.OutputFormat +import dev.jasonmross.mediaconverter.model.QualityTier +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class FFmpegCommandBuilderTest { + + private fun cmd( + format: OutputFormat, + quality: QualityTier = QualityTier.BEST, + ): List = FFmpegCommandBuilder.build( + ConversionRequest(format = format, quality = quality), + inputPath = "/cache/in.mp4", + outputPath = "/cache/out.${format.extension}", + ) + + /** Asserts `flag` is present and immediately followed by `value`. */ + private fun assertPair(args: List, flag: String, value: String) { + val i = args.indexOf(flag) + assertTrue("missing $flag in $args", i >= 0) + assertEquals("wrong value for $flag in $args", value, args[i + 1]) + } + + // --- structure --------------------------------------------------------- + + @Test + fun `input precedes output and both are present`() { + val args = cmd(OutputFormat.MP4_H264) + assertPair(args, "-i", "/cache/in.mp4") + assertEquals("/cache/out.mp4", args.last()) + } + + @Test + fun `overwrite is enabled because the output path is our own cache file`() { + assertTrue(cmd(OutputFormat.MP4_H264).contains("-y")) + } + + // --- the GPL quality path --------------------------------------------- + + @Test + fun `best quality h264 uses libx264 with crf, not a bitrate target`() { + val args = cmd(OutputFormat.MP4_H264, QualityTier.BEST) + assertPair(args, "-c:v", "libx264") + assertPair(args, "-crf", "20") + assertFalse("CRF and -b:v are mutually exclusive", args.contains("-b:v")) + } + + @Test + fun `best quality h265 uses libx265 with a higher crf than h264`() { + val args = cmd(OutputFormat.MP4_H265, QualityTier.BEST) + assertPair(args, "-c:v", "libx265") + // x265 is roughly a step stronger at the same number, so sharing a constant + // with x264 would silently change the size target. + assertPair(args, "-crf", "24") + } + + @Test + fun `hevc in mp4 is tagged hvc1 for player compatibility`() { + assertPair(cmd(OutputFormat.MP4_H265, QualityTier.BEST), "-tag:v", "hvc1") + } + + @Test + fun `h264 output is forced to yuv420p for hardware player compatibility`() { + assertPair(cmd(OutputFormat.MP4_H264, QualityTier.BEST), "-pix_fmt", "yuv420p") + } + + // --- the hardware fallback path --------------------------------------- + + @Test + fun `fast quality uses the mediacodec wrappers`() { + assertPair(cmd(OutputFormat.MP4_H264, QualityTier.FAST), "-c:v", "h264_mediacodec") + assertPair(cmd(OutputFormat.MP4_H265, QualityTier.FAST), "-c:v", "hevc_mediacodec") + } + + @Test + fun `fast quality targets a bitrate because mediacodec has no crf`() { + val args = cmd(OutputFormat.MP4_H264, QualityTier.FAST) + assertTrue(args.contains("-b:v")) + assertFalse(args.contains("-crf")) + } + + // --- audio ------------------------------------------------------------- + + @Test + fun `mp3 uses libmp3lame and drops video`() { + val args = cmd(OutputFormat.MP3) + assertPair(args, "-c:a", "libmp3lame") + assertTrue("audio-only output must drop the video stream", args.contains("-vn")) + } + + @Test + fun `flac wav and opus select the right encoders`() { + assertPair(cmd(OutputFormat.FLAC), "-c:a", "flac") + assertPair(cmd(OutputFormat.WAV), "-c:a", "pcm_s16le") + assertPair(cmd(OutputFormat.OPUS), "-c:a", "libopus") + } + + @Test + fun `audio only formats never carry a video encoder`() { + listOf(OutputFormat.MP3, OutputFormat.FLAC, OutputFormat.WAV, OutputFormat.OPUS) + .forEach { format -> + val args = cmd(format) + assertFalse("$format should not set -c:v", args.contains("-c:v")) + assertTrue("$format should set -vn", args.contains("-vn")) + } + } + + // --- image outputs ----------------------------------------------------- + + @Test + fun `gif generates a palette to avoid banding and drops audio`() { + val args = cmd(OutputFormat.GIF) + val filter = args[args.indexOf("-vf") + 1] + assertTrue("gif needs palettegen: $filter", filter.contains("palettegen")) + assertTrue("gif needs paletteuse: $filter", filter.contains("paletteuse")) + assertTrue(args.contains("-an")) + } + + @Test + fun `gif loops forever`() { + assertPair(cmd(OutputFormat.GIF), "-loop", "0") + } + + @Test + fun `frame export sets an fps filter and numbered output`() { + val args = cmd(OutputFormat.FRAMES_PNG) + assertPair(args, "-vf", "fps=1") + assertEquals( + "shot_%04d.png", + FFmpegCommandBuilder.outputPattern(OutputFormat.FRAMES_PNG, "shot"), + ) + } + + @Test + fun `single file formats keep their plain name`() { + assertEquals( + "clip.mp4", + FFmpegCommandBuilder.outputPattern(OutputFormat.MP4_H264, "clip.mp4"), + ) + } + + // --- containers -------------------------------------------------------- + + @Test + fun `mp4 output enables faststart`() { + assertPair(cmd(OutputFormat.MP4_H264), "-movflags", "+faststart") + } + + @Test + fun `non mp4 containers do not get faststart`() { + assertFalse(cmd(OutputFormat.MKV_H264).contains("-movflags")) + assertFalse(cmd(OutputFormat.WAV).contains("-movflags")) + } + + @Test + fun `mkv accepts h264 and h265 without faststart`() { + assertPair(cmd(OutputFormat.MKV_H264, QualityTier.BEST), "-c:v", "libx264") + assertPair(cmd(OutputFormat.MKV_H265, QualityTier.BEST), "-c:v", "libx265") + } + + // --- exhaustiveness ---------------------------------------------------- + + @Test + fun `every format builds a well formed command`() { + OutputFormat.entries.forEach { format -> + listOf(QualityTier.FAST, QualityTier.BEST).forEach { quality -> + val args = cmd(format, quality) + assertTrue("$format/$quality has no input", args.contains("-i")) + assertTrue("$format/$quality has too few args", args.size >= 5) + // Every flag that takes a value must actually have one. + assertFalse("$format/$quality ends on a dangling flag", args.last().startsWith("-")) + } + } + } +} diff --git a/app/src/test/java/dev/jasonmross/mediaconverter/ffmpeg/FFmpegConcatCommandTest.kt b/app/src/test/java/dev/jasonmross/mediaconverter/ffmpeg/FFmpegConcatCommandTest.kt new file mode 100644 index 0000000..da1a71b --- /dev/null +++ b/app/src/test/java/dev/jasonmross/mediaconverter/ffmpeg/FFmpegConcatCommandTest.kt @@ -0,0 +1,96 @@ +package dev.jasonmross.mediaconverter.ffmpeg + +import dev.jasonmross.mediaconverter.model.ConcatStrategy +import dev.jasonmross.mediaconverter.model.OutputFormat +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File + +class FFmpegConcatCommandTest { + + private val listFile = File("/cache/list.txt") + private val output = File("/cache/joined.mp4") + private val inputs = listOf("/cache/a.mp4", "/cache/b.mp4") + + @Test + fun `list file quotes each entry for the concat demuxer`() { + val text = FFmpegConcatCommand.listFileContents(inputs) + assertEquals("file '/cache/a.mp4'\nfile '/cache/b.mp4'\n", text) + } + + @Test + fun `apostrophes in filenames are escaped`() { + // An unescaped apostrophe silently truncates the entry, so the file would be + // skipped rather than the command failing. + val text = FFmpegConcatCommand.listFileContents(listOf("/cache/jason's clip.mp4")) + assertTrue("apostrophe not escaped: $text", text.contains("""jason'\''s clip.mp4""")) + } + + @Test + fun `stream copy uses the concat demuxer and copies codecs`() { + val args = FFmpegConcatCommand.build( + ConcatStrategy.STREAM_COPY, inputs, listFile, output, OutputFormat.MP4_H264, + ) + assertTrue(args.contains("concat")) + assertEquals("copy", args[args.indexOf("-c") + 1]) + assertEquals(listFile.absolutePath, args[args.indexOf("-i") + 1]) + assertFalse("stream copy must not re-encode", args.contains("libx264")) + } + + @Test + fun `stream copy allows absolute paths in the list file`() { + val args = FFmpegConcatCommand.build( + ConcatStrategy.STREAM_COPY, inputs, listFile, output, OutputFormat.MP4_H264, + ) + // Without -safe 0 the demuxer rejects the absolute paths we generate. + assertEquals("0", args[args.indexOf("-safe") + 1]) + } + + @Test + fun `re-encode passes every input separately and builds a filter graph`() { + val args = FFmpegConcatCommand.build( + ConcatStrategy.REENCODE, inputs, listFile, output, OutputFormat.MP4_H264, + ) + assertEquals(2, args.count { it == "-i" }) + val filter = args[args.indexOf("-filter_complex") + 1] + assertTrue("missing concat filter: $filter", filter.contains("concat=n=2:v=1:a=1")) + assertTrue("inputs must be normalised before joining", filter.contains("scale=")) + assertTrue("frame rates must be normalised", filter.contains("fps=30")) + assertTrue(args.contains("libx264")) + } + + @Test + fun `re-encode maps the filter outputs rather than raw streams`() { + val args = FFmpegConcatCommand.build( + ConcatStrategy.REENCODE, inputs, listFile, output, OutputFormat.MP4_H264, + ) + assertTrue(args.contains("[v]")) + assertTrue(args.contains("[a]")) + } + + @Test + fun `mp4 output gets faststart on both strategies`() { + listOf(ConcatStrategy.STREAM_COPY, ConcatStrategy.REENCODE).forEach { strategy -> + val args = FFmpegConcatCommand.build(strategy, inputs, listFile, output, OutputFormat.MP4_H264) + assertTrue("$strategy missing faststart", args.contains("+faststart")) + } + } + + @Test + fun `mkv output does not get faststart`() { + val args = FFmpegConcatCommand.build( + ConcatStrategy.STREAM_COPY, inputs, listFile, File("/cache/j.mkv"), OutputFormat.MKV_H264, + ) + assertFalse(args.contains("+faststart")) + } + + @Test + fun `output path is always last`() { + listOf(ConcatStrategy.STREAM_COPY, ConcatStrategy.REENCODE).forEach { strategy -> + val args = FFmpegConcatCommand.build(strategy, inputs, listFile, output, OutputFormat.MP4_H264) + assertEquals(output.absolutePath, args.last()) + } + } +} diff --git a/app/src/test/java/dev/jasonmross/mediaconverter/model/ConcatPlannerTest.kt b/app/src/test/java/dev/jasonmross/mediaconverter/model/ConcatPlannerTest.kt new file mode 100644 index 0000000..eedad46 --- /dev/null +++ b/app/src/test/java/dev/jasonmross/mediaconverter/model/ConcatPlannerTest.kt @@ -0,0 +1,83 @@ +package dev.jasonmross.mediaconverter.model + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * The stream-copy-vs-re-encode decision. + * + * This matters more than it looks: the `concat` demuxer does not reject mismatched + * inputs loudly, it can emit a file whose later segments are garbled. So the default + * on any doubt has to be re-encoding. + */ +class ConcatPlannerTest { + + private fun clip( + video: String? = "h264", + audio: String? = "aac", + width: Int = 1920, + height: Int = 1080, + fps: Int = 30, + ) = ConcatInput(video, audio, width, height, fps) + + @Test + fun `identical clips can be stream copied`() { + assertEquals( + ConcatStrategy.STREAM_COPY, + ConcatPlanner.plan(listOf(clip(), clip())), + ) + } + + @Test + fun `a single input needs no re-encode`() { + assertEquals(ConcatStrategy.STREAM_COPY, ConcatPlanner.plan(listOf(clip()))) + } + + @Test + fun `different video codecs force a re-encode`() { + assertEquals( + ConcatStrategy.REENCODE, + ConcatPlanner.plan(listOf(clip(video = "h264"), clip(video = "hevc"))), + ) + } + + @Test + fun `different audio codecs force a re-encode`() { + assertEquals( + ConcatStrategy.REENCODE, + ConcatPlanner.plan(listOf(clip(audio = "aac"), clip(audio = "opus"))), + ) + } + + @Test + fun `different resolutions force a re-encode`() { + assertEquals( + ConcatStrategy.REENCODE, + ConcatPlanner.plan(listOf(clip(width = 1920, height = 1080), clip(width = 1280, height = 720))), + ) + } + + @Test + fun `different frame rates force a re-encode`() { + assertEquals( + ConcatStrategy.REENCODE, + ConcatPlanner.plan(listOf(clip(fps = 30), clip(fps = 60))), + ) + } + + @Test + fun `an unknown codec is not treated as a match`() { + // Two nulls are not evidence of agreement. Assuming they match is exactly how + // a silent corrupt concat happens. + assertEquals( + ConcatStrategy.REENCODE, + ConcatPlanner.plan(listOf(clip(video = null), clip(video = null))), + ) + } + + @Test + fun `a mismatch anywhere in a longer list is caught`() { + val clips = listOf(clip(), clip(), clip(), clip(width = 640, height = 480), clip()) + assertEquals(ConcatStrategy.REENCODE, ConcatPlanner.plan(clips)) + } +} diff --git a/app/src/test/java/dev/jasonmross/mediaconverter/model/ConversionRouterTest.kt b/app/src/test/java/dev/jasonmross/mediaconverter/model/ConversionRouterTest.kt new file mode 100644 index 0000000..5a51ba1 --- /dev/null +++ b/app/src/test/java/dev/jasonmross/mediaconverter/model/ConversionRouterTest.kt @@ -0,0 +1,197 @@ +package dev.jasonmross.mediaconverter.model + +import dev.jasonmross.mediaconverter.model.ConversionRouter.Reason +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * One test per routing predicate. + * + * These run on the JVM against fabricated device profiles rather than on hardware, so + * every branch is reachable — including "this device cannot encode AV1", which would + * otherwise depend on which phone the suite happened to run on. + */ +class ConversionRouterTest { + + private fun route( + format: OutputFormat, + quality: QualityTier = QualityTier.FAST, + preference: EnginePreference = EnginePreference.AUTO, + probe: InputProbe = InputProbe(videoCodec = "h264"), + device: DeviceCodecs = DeviceCodecs.PERMISSIVE, + ) = ConversionRouter.route( + ConversionRequest(format, quality, preference, probe), + device, + ) + + // --- the happy path ----------------------------------------------------- + + @Test + fun `mp4 h264 on a capable device uses hardware`() { + val d = route(OutputFormat.MP4_H264) + assertEquals(Engine.MEDIA3, d.engine) + assertEquals(Reason.HARDWARE_CAPABLE, d.reason) + } + + @Test + fun `mp4 h265 uses hardware`() { + assertEquals(Engine.MEDIA3, route(OutputFormat.MP4_H265).engine) + } + + @Test + fun `webm vp9 routes to ffmpeg because media3 cannot encode vp9`() { + // Transformer.setVideoMimeType accepts only H.263/H.264/H.265/MP4V. The WebM + // muxer exists, but there is no VP9 *encoder* behind it, so producing WebM + // video is FFmpeg's job even though the container is nominally supported. + val d = route(OutputFormat.WEBM_VP9) + assertEquals(Engine.FFMPEG, d.engine) + assertEquals(Reason.NO_PLATFORM_ENCODER, d.reason) + } + + @Test + fun `aac audio extraction stays on hardware`() { + assertEquals(Engine.MEDIA3, route(OutputFormat.M4A_AAC).engine) + } + + // --- 1. container ------------------------------------------------------ + + @Test + fun `mkv routes to ffmpeg because media3 has no matroska muxer`() { + val d = route(OutputFormat.MKV_H264) + assertEquals(Engine.FFMPEG, d.engine) + assertEquals(Reason.CONTAINER_UNSUPPORTED, d.reason) + } + + // --- 3. no platform encoder ------------------------------------------- + + @Test + fun `mp3 routes to ffmpeg because android has no mp3 encoder at any api level`() { + val d = route(OutputFormat.MP3) + assertEquals(Engine.FFMPEG, d.engine) + assertEquals(Reason.NO_PLATFORM_ENCODER, d.reason) + } + + @Test + fun `flac routes to ffmpeg`() { + assertEquals(Engine.FFMPEG, route(OutputFormat.FLAC).engine) + } + + // --- 4. image output --------------------------------------------------- + + @Test + fun `gif routes to ffmpeg as an image output`() { + val d = route(OutputFormat.GIF) + assertEquals(Engine.FFMPEG, d.engine) + assertEquals(Reason.IMAGE_OUTPUT, d.reason) + } + + @Test + fun `png frame export routes to ffmpeg`() { + assertEquals(Engine.FFMPEG, route(OutputFormat.FRAMES_PNG).engine) + } + + // --- 5. no platform decoder ------------------------------------------- + + @Test + fun `av1 input on a device without av1 decode routes to ffmpeg`() { + val noAv1 = object : DeviceCodecs { + override fun canEncode(codec: VideoCodec) = true + override fun canDecode(codecName: String) = codecName != "av1" + } + val d = route(OutputFormat.MP4_H264, probe = InputProbe(videoCodec = "av1"), device = noAv1) + assertEquals(Engine.FFMPEG, d.engine) + assertEquals(Reason.NO_PLATFORM_DECODER, d.reason) + } + + @Test + fun `av1 input on a device with av1 decode stays on hardware`() { + val d = route(OutputFormat.MP4_H264, probe = InputProbe(videoCodec = "av1")) + assertEquals(Engine.MEDIA3, d.engine) + } + + @Test + fun `an input the platform cannot parse routes to ffmpeg`() { + // MediaProbe reports this when the platform extractor fails to open the file. + // Media3 cannot convert what the platform cannot read, so the job must not be + // sent down the hardware path only to fail there. + val d = route( + OutputFormat.MP4_H264, + probe = InputProbe(videoCodec = InputProbe.UNPARSEABLE), + ) + assertEquals(Engine.FFMPEG, d.engine) + assertEquals(Reason.NO_PLATFORM_DECODER, d.reason) + } + + @Test + fun `a null input codec is treated as no information, not as a failure`() { + // Nothing known about the input is different from "known to be unreadable": + // the former should still take the fast path. + val d = route(OutputFormat.MP4_H264, probe = InputProbe(videoCodec = null)) + assertEquals(Engine.MEDIA3, d.engine) + } + + // --- 6. quality tier --------------------------------------------------- + + @Test + fun `best quality routes to ffmpeg because mediacodec exposes no crf`() { + val d = route(OutputFormat.MP4_H264, quality = QualityTier.BEST) + assertEquals(Engine.FFMPEG, d.engine) + assertEquals(Reason.QUALITY_TIER_REQUIRES_CRF, d.reason) + } + + @Test + fun `fast quality is the hardware path`() { + assertEquals(Engine.MEDIA3, route(OutputFormat.MP4_H264, quality = QualityTier.FAST).engine) + } + + // --- 7. hardware encoder availability --------------------------------- + + @Test + fun `h265 target on a device without hevc encode falls back to ffmpeg`() { + val noHevc = object : DeviceCodecs { + override fun canEncode(codec: VideoCodec) = codec != VideoCodec.H265 + override fun canDecode(codecName: String) = true + } + val d = route(OutputFormat.MP4_H265, device = noHevc) + assertEquals(Engine.FFMPEG, d.engine) + assertEquals(Reason.NO_HARDWARE_ENCODER, d.reason) + } + + // --- user override ----------------------------------------------------- + + @Test + fun `forcing software overrides an otherwise hardware-capable job`() { + val d = route(OutputFormat.MP4_H264, preference = EnginePreference.FORCE_SOFTWARE) + assertEquals(Engine.FFMPEG, d.engine) + assertEquals(Reason.USER_FORCED_SOFTWARE, d.reason) + } + + @Test + fun `forcing software wins even for formats ffmpeg would take anyway`() { + val d = route(OutputFormat.MP3, preference = EnginePreference.FORCE_SOFTWARE) + assertEquals(Engine.FFMPEG, d.engine) + assertEquals(Reason.USER_FORCED_SOFTWARE, d.reason) + } + + // --- exhaustiveness ---------------------------------------------------- + + @Test + fun `every output format routes somewhere without throwing`() { + OutputFormat.entries.forEach { format -> + val decision = route(format) + assertEquals( + "format $format produced no engine", + true, + decision.engine == Engine.MEDIA3 || decision.engine == Engine.FFMPEG, + ) + } + } + + @Test + fun `audio-only formats are flagged as such`() { + assertEquals(true, OutputFormat.MP3.isAudioOnly) + assertEquals(true, OutputFormat.FLAC.isAudioOnly) + assertEquals(false, OutputFormat.MP4_H264.isAudioOnly) + assertEquals(false, OutputFormat.GIF.isAudioOnly) + } +} diff --git a/app/src/test/java/dev/jasonmross/mediaconverter/model/OutputFormatTest.kt b/app/src/test/java/dev/jasonmross/mediaconverter/model/OutputFormatTest.kt new file mode 100644 index 0000000..d184a89 --- /dev/null +++ b/app/src/test/java/dev/jasonmross/mediaconverter/model/OutputFormatTest.kt @@ -0,0 +1,64 @@ +package dev.jasonmross.mediaconverter.model + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Guards the format table itself. + * + * The router, the FFmpeg command builder and the output filename all read from these + * entries, so a wrong extension or a mislabelled codec propagates everywhere. + */ +class OutputFormatTest { + + @Test + fun `every format has a non-empty label and extension`() { + OutputFormat.entries.forEach { + assertTrue("${it.name} has no label", it.label.isNotBlank()) + assertTrue("${it.name} has no extension", it.extension.isNotBlank()) + assertFalse("${it.name} extension should not include a dot", it.extension.startsWith(".")) + assertTrue("${it.name} has no mime type", it.mimeType.contains('/')) + } + } + + @Test + fun `audio only formats carry no video codec`() { + OutputFormat.entries.filter { it.isAudioOnly }.forEach { + assertEquals("${it.name} should have no video codec", VideoCodec.NONE, it.videoCodec) + } + } + + @Test + fun `image outputs carry neither video nor audio codecs`() { + OutputFormat.entries.filter { it.isImageOutput }.forEach { + assertEquals(VideoCodec.NONE, it.videoCodec) + assertEquals(AudioCodec.NONE, it.audioCodec) + assertFalse("${it.name} is not audio-only", it.isAudioOnly) + } + } + + @Test + fun `extensions match their containers`() { + assertEquals("mp4", OutputFormat.MP4_H264.extension) + assertEquals("mkv", OutputFormat.MKV_H264.extension) + assertEquals("mp3", OutputFormat.MP3.extension) + assertEquals("gif", OutputFormat.GIF.extension) + assertEquals("wav", OutputFormat.WAV.extension) + } + + @Test + fun `video formats are not misreported as audio only`() { + listOf(OutputFormat.MP4_H264, OutputFormat.MP4_H265, OutputFormat.MKV_H265, OutputFormat.WEBM_VP9) + .forEach { assertFalse("${it.name} should not be audio-only", it.isAudioOnly) } + } + + @Test + fun `quality tiers describe themselves for the UI`() { + QualityTier.entries.forEach { + assertTrue(it.label.isNotBlank()) + assertTrue(it.description.isNotBlank()) + } + } +}