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()) + } + } +}