From 2e0cf2f5d60c435c5fc066cc0cca8cca14d1b07a Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Sat, 22 Aug 2026 07:07:52 -0500 Subject: [PATCH] Let the user pick a container and codecs independently, and remux without re-encoding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OutputFormat was a closed enum of twelve (container, videoCodec, audioCodec) triples, defended on the grounds that a closed set was what made routing decidable. Two things it could not express: changing the container while copying the streams, and choosing codecs per track. OutputSpec replaces it as the vocabulary; OutputFormat stays as presets over it. Decidability moves to ContainerCapabilities, which is explicit and unit-tested rather than implicit in whichever combinations somebody enumerated. The matrix is indexed by (container, codec, trackType, mode), not one boolean. "Can MP4 carry AV1" and "can this app encode AV1" have different answers, and copy is where the difference shows: a single flag would refuse a legitimate remux or promise an encode neither engine can deliver. COPY is a codec value rather than a flag, so every exhaustive `when` in the codebase had to say what it does about copying. CopyPlanner resolves it before anything else reads the request, and inherits ConcatPlanner's rule that an unproven match is never a copy — a needless re-encode costs time, a wrong stream copy costs a file that will not play. Container now drives -f, the extension and the SAF MIME type, so Matroska without video is .mka and MP4 without video is .m4a without a preset for each. FLAC was declared as Container.MKV with a .flac extension, inert only while nothing read the container; it now has its own. Six containers added: MOV, MKV audio, MPEG-TS, AVI, FLV and WMV/ASF. Routing asks the plan, never the request. COPY belongs to none of the capability sets, so testing the request directly sends every remux to FFmpeg on the first check — and nothing notices, because -c copy produces a correct file, just on the CPU. The router also learns what Media3 can *carry* as opposed to encode: its MP4 muxer takes AAC, Opus, Vorbis and PCM but neither MP3 nor FLAC. MediaProbe now separates "no video track" from "could not parse" and reports the source container, which MediaExtractor cannot supply at all. FFprobe runs on every pick for that reason, not as a fallback. The Advanced picker shows the whole matrix and lets an impossible combination be selected on purpose, then explains it and offers alternatives. Convert is what blocks the job. ConversionWorker validates too, so a stale queued spec fails with the reason rather than being coerced into something else. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 58 +++- app/src/androidTest/assets/sample_aac.m4a | Bin 0 -> 27651 bytes app/src/androidTest/assets/sample_h264.mkv | Bin 0 -> 73553 bytes app/src/androidTest/assets/sample_still.png | Bin 0 -> 12520 bytes app/src/androidTest/assets/sample_vp9.webm | Bin 0 -> 45538 bytes .../bench/RealMediaBenchmark.kt | 8 +- .../convert/Media3EngineTest.kt | 11 +- .../libremediaconverter/convert/RemuxTest.kt | 277 +++++++++++++++ .../fallback/FakeFailures.kt | 2 +- .../fallback/ForcedFailureTest.kt | 2 +- .../fallback/HardwareFallbackTest.kt | 2 +- .../ffmpeg/FFmpegEngineTest.kt | 4 +- .../work/ConversionWorkerTest.kt | 60 +++- .../codec/AndroidDeviceCodecs.kt | 5 +- .../convert/ConversionViewModel.kt | 83 ++++- .../convert/ConverterScreen.kt | 230 ++++++++++++- .../convert/Media3Engine.kt | 52 ++- .../convert/Media3Muxers.kt | 9 +- .../libremediaconverter/convert/MediaProbe.kt | 213 ++++++++++-- .../convert/Transcoders.kt | 7 +- .../ffmpeg/FFmpegCommandBuilder.kt | 250 ++++++++------ .../libremediaconverter/model/CodecNames.kt | 50 +++ .../model/ContainerCapabilities.kt | 323 ++++++++++++++++++ .../model/ConversionRouter.kt | 128 +++++-- .../libremediaconverter/model/CopyPlanner.kt | 119 +++++++ .../libremediaconverter/model/OutputFormat.kt | 193 +++++++++-- .../work/ConversionWorker.kt | 75 +++- .../convert/Media3MuxersTest.kt | 93 +++++ .../convert/MediaProbeFormatTest.kt | 86 +++++ .../ffmpeg/FFmpegCommandBuilderTest.kt | 171 +++++++++- .../model/CodecNamesTest.kt | 51 +++ .../model/ContainerCapabilitiesTest.kt | 171 ++++++++++ .../model/ConversionRouterTest.kt | 133 +++++++- .../model/CopyPlannerTest.kt | 159 +++++++++ .../model/OutputFormatTest.kt | 62 ++++ 35 files changed, 2824 insertions(+), 263 deletions(-) create mode 100644 app/src/androidTest/assets/sample_aac.m4a create mode 100644 app/src/androidTest/assets/sample_h264.mkv create mode 100644 app/src/androidTest/assets/sample_still.png create mode 100644 app/src/androidTest/assets/sample_vp9.webm create mode 100644 app/src/androidTest/java/org/libremediaconverter/convert/RemuxTest.kt create mode 100644 app/src/main/java/org/libremediaconverter/model/CodecNames.kt create mode 100644 app/src/main/java/org/libremediaconverter/model/ContainerCapabilities.kt create mode 100644 app/src/main/java/org/libremediaconverter/model/CopyPlanner.kt create mode 100644 app/src/test/java/org/libremediaconverter/convert/MediaProbeFormatTest.kt create mode 100644 app/src/test/java/org/libremediaconverter/model/CodecNamesTest.kt create mode 100644 app/src/test/java/org/libremediaconverter/model/ContainerCapabilitiesTest.kt create mode 100644 app/src/test/java/org/libremediaconverter/model/CopyPlannerTest.kt diff --git a/README.md b/README.md index 8024ee2..f8c1cbb 100644 --- a/README.md +++ b/README.md @@ -23,20 +23,26 @@ Two conversion engines behind an explicit router, because neither one covers the ### AndroidX Media3 Transformer — the hardware path -Handles the common cases: MP4/MOV in and out, H.264/HEVC, resolution and frame-rate -changes, rotation, overlays, audio to AAC, and stream-copy transmuxing. Fully hardware -accelerated end to end — MediaCodec decodes to a GL surface and MediaCodec re-encodes, -so frames never round-trip through the CPU. Roughly 7–8× realtime on 720p. +Handles the common cases: H.264/HEVC, resolution and frame-rate changes, rotation, +overlays, and audio to AAC. Fully hardware accelerated end to end — MediaCodec decodes to +a GL surface and MediaCodec re-encodes, so frames never round-trip through the CPU. +Roughly 7–8× realtime on 720p. + +It writes MP4, WebM, Ogg, WAV and raw AAC — the five containers `media3-muxer` provides a +muxer for. It *reads* far more than it writes, Matroska included, which is what makes +MKV → MP4 a hardware remux. ### FFmpeg — the long tail Everything Media3 structurally cannot do: -- Containers outside MP4/WebM/Ogg/WAV/AAC — MKV, AVI, FLV, MPEG-TS +- Containers outside MP4/WebM/Ogg/WAV/AAC — MKV, MOV, AVI, FLV, MPEG-TS, WMV/ASF - **MP3 output** — Android has no MP3 encoder at any version; this is a platform gap - GIF and image sequences - Input codecs with no platform decoder on the device - CRF and 2-pass rate control, for the quality tier +- Codecs Media3's muxers decline even on a stream copy — its MP4 muxer carries AAC, Opus, + Vorbis and PCM, but neither MP3 nor FLAC ### Quality tiers @@ -66,14 +72,35 @@ extension appears in any Android Vulkan Profile tier. So this app is hardware accelerated via MediaCodec, and GPU accelerated for effects via GL shaders. Both are real; neither is "the GPU decoding video." +## Remuxing + +Changing the container without touching the streams. Copying an H.264 track from MKV into +MP4 moves the same samples into a different wrapper: it finishes in seconds instead of +minutes, costs no quality, and needs no encoder — which is why it stays on the hardware +path even on a device that cannot encode the codec in question. + +`Copy` is a codec choice like any other, so it can be mixed: copy the video and re-encode +only the audio, or the reverse. Picking a codec the source already uses is upgraded to a +copy automatically **when the container is changing** — if container and codec both already +match, the only reason to run the job is to re-encode it, so it does. + +A copy is never attempted on a stream whose codec could not be identified. A needless +re-encode costs time; a wrong stream copy costs a file that will not play. + ## Features | | Formats | |---|---| -| Video out | MP4 (H.264/H.265), MKV (H.264/H.265), WebM (VP9) | -| Audio out | MP3, AAC/M4A, FLAC, Opus, WAV | +| Video out | MP4, MOV, MKV, WebM, MPEG-TS, AVI, FLV, WMV/ASF | +| Video codecs | H.264, H.265, VP9, or copy the source stream | +| Audio out | MP3, AAC/M4A, FLAC, Opus, WAV, MKA | +| Audio codecs | AAC, Opus, MP3, FLAC, PCM, or copy the source stream | | Images | GIF, PNG frame sequences | -| Other | Join several files into one | +| Other | Remux without re-encoding; join several files into one | + +Presets cover the common combinations in one tap. The Advanced picker exposes the full +container × codec matrix — including combinations that cannot work, which it explains and +offers alternatives for rather than hiding. Conversions run as durable background work, so they survive leaving the app and are restored after a restart. @@ -101,14 +128,17 @@ obligation. ## Testing -Unit tests cover the parts that decide correctness without needing hardware: the -routing matrix, the FFmpeg argument builder, and the stream-copy-versus-re-encode -planner. They run against fabricated device profiles, so branches like "this device -cannot encode HEVC" are reachable regardless of what the test machine is. +Unit tests cover the parts that decide correctness without needing hardware: the routing +matrix, the container × codec capability matrix, the FFmpeg argument builder, and both +stream-copy-versus-re-encode planners. They run against fabricated device profiles, so +branches like "this device cannot encode HEVC" are reachable regardless of what the test +machine is. Instrumented tests cover the parts that only a device can prove: real hardware -transcoding, the foreground service type, and each FFmpeg output format asserted -against the produced file rather than the exit code. +transcoding, the foreground service type, and each FFmpeg output format asserted against +the produced file rather than the exit code. The remux tests additionally assert which +*engine* ran — a stream copy produces an identical file either way, so an output-only +assertion cannot tell a hardware transmux from FFmpeg's `-c copy`. ## Privacy diff --git a/app/src/androidTest/assets/sample_aac.m4a b/app/src/androidTest/assets/sample_aac.m4a new file mode 100644 index 0000000000000000000000000000000000000000..f014643c732731394e71e4e8127630021487afda GIT binary patch literal 27651 zcmZr$WmFsQ*9`9N6k5EvyAz}kT!Op16o=wYad&t3;_eQ`DNv+11&V84`2D}WXOpv= zlP~-1K6mcinE?O*B<3!jj#kbNb^ri0;QjpeWw&$WWCH*Ih~{6-%m4uMK|50;mrZDZ zoROOeH#;*sFEbk}I{@0SN~c`uD^0P-&F)=IYs#XGbxJbTm_SfI6g=M7OLyWUCYv>V zlfDj<>Ej1Ml@$(Q*@JAZdBKgTj_-zlm?>+U<(q9+)y}}VuIiK_CQce3xP#1xSL%5f zm?rp^>v>P@>37XrXVgs!4KjMTd~HY`S8tjbZx~%{uC&c-rwnp+gj;-UAIonZsyH;Q z8VjZ)7t{-qxjERzy5U3b4-{CC}ht1S8detMxeGuGzidu@m0XeQMPTsGv(4^A`&9HRA>U$cCB zG~hN%A@f&{&yK`xJ&+&E=V#=VyZqRCumtxRtgM%RdVLZVPVwo!aTgf5_GQeYw0w4s zruc+HU=tlkVym4|UM+^43g&hexoqNE@2NVBGqh=g4Gw~VG5w2;jg5+m`gfcBGgKJ+ z-|f%D@TSC99eda~3D3{Ht3>(S*yk4^npTzJNAMtJP*L<`)#|F6y_Jn!%POa~Bwb@V zeZ_aVV?&2T7(Xo}cq&v>Y;4d~&UHUGBkT(!QlzasX&-v zzPo83ix=Kl)e|^2k-Q8|_dWwG){-D^? zCbD~B`t&r;@$G{@;EDn(s3=kc zvw1FzqzqS=TackAzo%Cr*Abma*9_brPwq)&W5trlZu`JUz5rT#2OoYzuCcDK-*+K# zeWBnORQ47gmB0BiY&2D@;?TVKl!(cUmND`)_JU~&M+K6{Ow585aeBm&96bZ`mb3|< z\r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 literal 0 HcmV?d00001 diff --git a/app/src/androidTest/assets/sample_h264.mkv b/app/src/androidTest/assets/sample_h264.mkv new file mode 100644 index 0000000000000000000000000000000000000000..b5d33e80eabf60179114077262def231f9358102 GIT binary patch literal 73553 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 t^Ci4vOB0VQowI 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 literal 0 HcmV?d00001 diff --git a/app/src/androidTest/assets/sample_still.png b/app/src/androidTest/assets/sample_still.png new file mode 100644 index 0000000000000000000000000000000000000000..8398379fb7785ddf8cb97d5e2c7555f239ffac09 GIT binary patch literal 12520 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 literal 0 HcmV?d00001 diff --git a/app/src/androidTest/assets/sample_vp9.webm b/app/src/androidTest/assets/sample_vp9.webm new file mode 100644 index 0000000000000000000000000000000000000000..7703b0572bffdae406a06035f2f379f2becab506 GIT binary patch literal 45538 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 fHe}{VRGO@#n seen += percent } assertTrue("export produced no file", output.exists()) @@ -95,7 +96,7 @@ class Media3EngineTest { val audio = File(context.cacheDir, "out_audio.m4a") audio.delete() try { - engine.transcode(Uri.fromFile(input), audio, OutputFormat.M4A_AAC) + engine.transcode(Uri.fromFile(input), audio, ConversionRequest(OutputFormat.M4A_AAC.spec)) assertTrue("export produced no file", audio.exists() && audio.length() > 0) val tracks = trackMimeTypesOf(audio) @@ -121,7 +122,7 @@ class Media3EngineTest { val wav = File(context.cacheDir, "out_audio.wav") wav.delete() try { - engine.transcode(Uri.fromFile(input), wav, OutputFormat.WAV) + engine.transcode(Uri.fromFile(input), wav, ConversionRequest(OutputFormat.WAV.spec)) assertTrue("export produced no file", wav.exists() && wav.length() > 0) assertEquals("RIFF", String(wav.readBytes().copyOfRange(0, 4), Charsets.US_ASCII)) @@ -141,7 +142,7 @@ class Media3EngineTest { val ogg = File(context.cacheDir, "out_audio.opus") ogg.delete() try { - engine.transcode(Uri.fromFile(input), ogg, OutputFormat.OPUS) + engine.transcode(Uri.fromFile(input), ogg, ConversionRequest(OutputFormat.OPUS.spec)) assertTrue("export produced no file", ogg.exists() && ogg.length() > 0) assertEquals("OggS", String(ogg.readBytes().copyOfRange(0, 4), Charsets.US_ASCII)) @@ -197,7 +198,7 @@ class Media3EngineTest { val failure = runCatching { runBlocking { withTimeout(30_000) { - engine.transcode(Uri.fromFile(input), impossible, OutputFormat.MP4_H265) {} + engine.transcode(Uri.fromFile(input), impossible, ConversionRequest(OutputFormat.MP4_H265.spec)) {} } } }.exceptionOrNull() diff --git a/app/src/androidTest/java/org/libremediaconverter/convert/RemuxTest.kt b/app/src/androidTest/java/org/libremediaconverter/convert/RemuxTest.kt new file mode 100644 index 0000000..827a212 --- /dev/null +++ b/app/src/androidTest/java/org/libremediaconverter/convert/RemuxTest.kt @@ -0,0 +1,277 @@ +package org.libremediaconverter.convert + +import android.media.MediaExtractor +import android.media.MediaFormat +import android.net.Uri +import androidx.media3.common.util.UnstableApi +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.work.WorkInfo +import androidx.work.WorkManager +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.libremediaconverter.model.AudioCodec +import org.libremediaconverter.model.Container +import org.libremediaconverter.model.ConversionRequest +import org.libremediaconverter.model.Engine +import org.libremediaconverter.model.InputKind +import org.libremediaconverter.model.OutputSpec +import org.libremediaconverter.model.VideoCodec +import org.libremediaconverter.work.ConversionWorker +import java.io.File + +/** + * Remuxing end to end, on a device. + * + * ## Why these assert the engine, not just the file + * + * A remux routed to FFmpeg produces a perfectly correct file — `-c copy` moves the same samples + * into the same container. So an output-only assertion passes whether the hardware transmux path + * ran or never executed at all, and `COPY` belongs to none of the router's capability sets, which + * makes "silently always FFmpeg" the most likely way for this feature to regress. + * + * Timing is not a usable proxy either: it is flaky on emulators and cannot distinguish an FFmpeg + * stream copy from a Media3 transmux, since both are fast. `ConversionWorker` already reports + * `KEY_ENGINE_USED`, so these assert that. + * + * ## Fixtures + * + * Every committed asset was ISO MP4 before this feature, so a remux had no second container to + * move between. The new ones are generated rather than sourced, and committed rather than built at + * test time, following the convention `HardwareFallbackTest` documents — a regression test that + * silently skips is worse than no test. Recipes, run against FFmpeg 8.1.2: + * + * ``` + * # sample_h264.mkv — the existing fixture's streams in Matroska. A pure remux, so the two files + * # hold byte-identical samples and differ only in the container, which is exactly the axis under + * # test. No encoder is involved, so the recipe reproduces anywhere. + * ffmpeg -i sample_h264.mp4 -c copy -f matroska sample_h264.mkv + * + * # sample_aac.m4a — the same fixture's audio track alone, for AUDIO_ONLY probing and MKA output. + * ffmpeg -i sample_h264.mp4 -vn -c:a copy -f mp4 sample_aac.m4a + * + * # sample_vp9.webm — the one fixture that must be encoded, since no committed source is VP9. + * ffmpeg -i sample_h264.mp4 -c:v libvpx-vp9 -crf 40 -b:v 0 -deadline realtime -cpu-used 8 \ + * -c:a libopus -b:a 64k -f webm sample_vp9.webm + * + * # sample_still.png — a single frame, to reach the probe's IMAGE branch. + * ffmpeg -i sample_h264.mp4 -frames:v 1 -f image2 sample_still.png + * ``` + */ +@UnstableApi +@RunWith(AndroidJUnit4::class) +class RemuxTest { + + private val context = InstrumentationRegistry.getInstrumentation().targetContext + private val staged = mutableListOf() + + @Before + fun setUp() { + WorkManager.getInstance(context).cancelAllWork() + } + + @After + fun tearDown() { + staged.forEach { it.delete() } + WorkManager.getInstance(context).cancelAllWork() + } + + private fun asset(name: String): File = + File(context.cacheDir, name).also { file -> + file.delete() + staged += file + InstrumentationRegistry.getInstrumentation().context.assets + .open(name) + .use { asset -> file.outputStream().use { asset.copyTo(it) } } + } + + // --- probing ------------------------------------------------------------ + + @Test + fun probeIdentifiesTheSourceContainerOfEachFixture() { + assertEquals(Container.MP4, MediaProbe.probe(context, Uri.fromFile(asset("sample_h264.mp4"))).container) + assertEquals(Container.MKV, MediaProbe.probe(context, Uri.fromFile(asset("sample_h264.mkv"))).container) + assertEquals(Container.WEBM, MediaProbe.probe(context, Uri.fromFile(asset("sample_vp9.webm"))).container) + } + + /** + * The distinction the old probe could not make. + * + * It reported `hasVideo = true, videoCodec = UNPARSEABLE` for anything the extractor refused, + * so an audio file, a picture and a corrupt file were indistinguishable — and the source-info + * card cannot describe any of them honestly until they are separate. + */ + @Test + fun probeDistinguishesAudioFromImagesFromRubbish() { + val audio = MediaProbe.probe(context, Uri.fromFile(asset("sample_aac.m4a"))) + assertEquals(InputKind.AUDIO_ONLY, audio.kind) + assertEquals(false, audio.hasVideo) + assertNotNull("audio codec should still be identified", audio.audioCodec) + + val image = MediaProbe.probe(context, Uri.fromFile(asset("sample_still.png"))) + assertEquals(InputKind.IMAGE, image.kind) + + val rubbish = File(context.cacheDir, "not_media.bin").also { + staged += it + it.writeBytes(ByteArray(4096) { i -> (i % 251).toByte() }) + } + val unreadable = MediaProbe.probe(context, Uri.fromFile(rubbish)) + assertEquals(InputKind.UNPARSEABLE, unreadable.kind) + } + + // --- the hardware remux path -------------------------------------------- + + /** + * MKV in, MP4 out, nothing re-encoded — and it must stay on Media3. + * + * Media3 cannot *write* Matroska but reads it perfectly well, which is what makes this + * direction a hardware remux. If this reports FFMPEG, the transmux path is dead. + */ + @Test + fun mkvToMp4RemuxesOnHardware() { + val input = asset("sample_h264.mkv") + val result = runConversion( + input, + OutputSpec(Container.MP4, VideoCodec.COPY, AudioCodec.COPY), + ) + + assertEquals(Engine.MEDIA3.name, result.engineUsed) + assertEquals("h264 must survive a copy untouched", "video/avc", videoMimeOf(result.output)) + assertTrue("output has no duration", durationMsOf(result.output) > 0) + } + + /** The reverse direction: Media3 has no Matroska muxer, so this is FFmpeg's. */ + @Test + fun mp4ToMkvRemuxesOnFFmpeg() { + val input = asset("sample_h264.mp4") + val result = runConversion( + input, + OutputSpec(Container.MKV, VideoCodec.COPY, AudioCodec.COPY), + ) + + assertEquals(Engine.FFMPEG.name, result.engineUsed) + assertEquals( + "expected an EBML header", + listOf(0x1A, 0x45, 0xDF, 0xA3), + result.output.readBytes().take(4).map { it.toInt() and 0xFF }, + ) + } + + @Test + fun webmToMkvKeepsVp9WithoutReencoding() { + val input = asset("sample_vp9.webm") + val result = runConversion( + input, + OutputSpec(Container.MKV, VideoCodec.COPY, AudioCodec.COPY), + ) + + assertTrue(result.output.length() > 0) + assertEquals("vp9 must survive a copy", "video/x-vnd.on2.vp9", videoMimeOf(result.output)) + } + + @Test + fun audioOnlySourceRemuxesIntoMka() { + val input = asset("sample_aac.m4a") + val spec = OutputSpec(Container.MKV, VideoCodec.NONE, AudioCodec.COPY) + val result = runConversion(input, spec) + + assertEquals("mka", spec.extension) + assertTrue(result.output.length() > 0) + val tracks = trackMimesOf(result.output) + assertTrue("expected audio only, got $tracks", tracks.none { it.startsWith("video/") }) + } + + @Test + fun mp4ToMpegTsAndAviProduceTheirOwnContainers() { + val input = asset("sample_h264.mp4") + + val ts = runConversion(input, OutputSpec(Container.MPEG_TS, VideoCodec.COPY, AudioCodec.COPY)) + assertEquals(Engine.FFMPEG.name, ts.engineUsed) + // Every MPEG-TS packet starts with the 0x47 sync byte. + assertEquals(0x47, ts.output.readBytes().first().toInt() and 0xFF) + + val avi = runConversion(input, OutputSpec(Container.AVI, VideoCodec.COPY, AudioCodec.COPY)) + assertEquals("RIFF", String(avi.output.readBytes().copyOfRange(0, 4), Charsets.US_ASCII)) + } + + // --- plumbing ----------------------------------------------------------- + + private class Result(val output: File, val engineUsed: String) + + /** + * Runs one conversion through the real worker. + * + * Deliberately through `ConversionWorker` rather than an engine directly: the routing decision + * is the thing under test, and only the worker makes it. + */ + private fun runConversion(input: File, spec: OutputSpec): Result = runBlocking { + val manager = WorkManager.getInstance(context) + val request = ConversionWorker.request( + inputUri = Uri.fromFile(input), + displayName = input.name, + sizeBytes = input.length(), + spec = spec, + ) + manager.enqueue(request).result.get() + + val terminal = withTimeout(TIMEOUT_MS) { + manager.getWorkInfoByIdFlow(request.id).first { info -> + info != null && info.state.isFinished + } + } + + assertEquals( + "conversion did not succeed: " + + terminal?.outputData?.getString(ConversionWorker.KEY_ERROR), + WorkInfo.State.SUCCEEDED, + terminal?.state, + ) + val path = requireNotNull(terminal?.outputData?.getString(ConversionWorker.KEY_OUTPUT_PATH)) + val output = File(path).also { staged += it } + assertTrue("output is empty", output.length() > 0) + Result( + output = output, + engineUsed = terminal?.outputData + ?.getString(ConversionWorker.KEY_ENGINE_USED).orEmpty(), + ) + } + + private fun trackMimesOf(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() + } + } + + private fun videoMimeOf(file: File): String? = + trackMimesOf(file).firstOrNull { it.startsWith("video/") } + + private fun durationMsOf(file: File): Long { + val extractor = MediaExtractor() + return try { + extractor.setDataSource(file.absolutePath) + (0 until extractor.trackCount) + .map { extractor.getTrackFormat(it) } + .filter { it.containsKey(MediaFormat.KEY_DURATION) } + .maxOfOrNull { it.getLong(MediaFormat.KEY_DURATION) / 1000 } ?: 0L + } finally { + extractor.release() + } + } + + private companion object { + const val TIMEOUT_MS = 180_000L + } +} diff --git a/app/src/androidTest/java/org/libremediaconverter/fallback/FakeFailures.kt b/app/src/androidTest/java/org/libremediaconverter/fallback/FakeFailures.kt index f2839d4..3171d9a 100644 --- a/app/src/androidTest/java/org/libremediaconverter/fallback/FakeFailures.kt +++ b/app/src/androidTest/java/org/libremediaconverter/fallback/FakeFailures.kt @@ -25,7 +25,7 @@ object FakeFailures { override suspend fun transcode( input: Uri, output: File, - format: OutputFormat, + request: ConversionRequest, onProgress: (Int) -> Unit, ) { called = true diff --git a/app/src/androidTest/java/org/libremediaconverter/fallback/ForcedFailureTest.kt b/app/src/androidTest/java/org/libremediaconverter/fallback/ForcedFailureTest.kt index 654468e..618308a 100644 --- a/app/src/androidTest/java/org/libremediaconverter/fallback/ForcedFailureTest.kt +++ b/app/src/androidTest/java/org/libremediaconverter/fallback/ForcedFailureTest.kt @@ -66,7 +66,7 @@ class ForcedFailureTest { inputUri = Uri.fromFile(input), displayName = SAMPLE, sizeBytes = input.length(), - format = format, + spec = format.spec, quality = QualityTier.FAST, ) diff --git a/app/src/androidTest/java/org/libremediaconverter/fallback/HardwareFallbackTest.kt b/app/src/androidTest/java/org/libremediaconverter/fallback/HardwareFallbackTest.kt index c6f3dac..97c64f3 100644 --- a/app/src/androidTest/java/org/libremediaconverter/fallback/HardwareFallbackTest.kt +++ b/app/src/androidTest/java/org/libremediaconverter/fallback/HardwareFallbackTest.kt @@ -70,7 +70,7 @@ class HardwareFallbackTest { inputUri = Uri.fromFile(input), displayName = SAMPLE, sizeBytes = input.length(), - format = OutputFormat.MP4_H265, + spec = OutputFormat.MP4_H265.spec, // Fast deliberately: this is the tier the router sends to Media3, so it is // the tier where the fallback has to rescue the conversion. quality = QualityTier.FAST, diff --git a/app/src/androidTest/java/org/libremediaconverter/ffmpeg/FFmpegEngineTest.kt b/app/src/androidTest/java/org/libremediaconverter/ffmpeg/FFmpegEngineTest.kt index 56f0c84..40264a2 100644 --- a/app/src/androidTest/java/org/libremediaconverter/ffmpeg/FFmpegEngineTest.kt +++ b/app/src/androidTest/java/org/libremediaconverter/ffmpeg/FFmpegEngineTest.kt @@ -53,7 +53,7 @@ class FFmpegEngineTest { val out = outputFor("out_${format.name.lowercase()}.${format.extension}") runBlocking { engine.run( - request = ConversionRequest(format = format, quality = quality), + request = ConversionRequest(spec = format.spec, quality = quality), inputPath = input.absolutePath, output = out, durationMs = 3_000, @@ -155,7 +155,7 @@ class FFmpegEngineTest { val failure = runCatching { runBlocking { engine.run( - request = ConversionRequest(format = OutputFormat.MP4_H264), + request = ConversionRequest(spec = OutputFormat.MP4_H264.spec), inputPath = "/does/not/exist.mp4", output = out, durationMs = 1_000, diff --git a/app/src/androidTest/java/org/libremediaconverter/work/ConversionWorkerTest.kt b/app/src/androidTest/java/org/libremediaconverter/work/ConversionWorkerTest.kt index 84cf9ff..9b4d5dd 100644 --- a/app/src/androidTest/java/org/libremediaconverter/work/ConversionWorkerTest.kt +++ b/app/src/androidTest/java/org/libremediaconverter/work/ConversionWorkerTest.kt @@ -10,9 +10,12 @@ import androidx.work.WorkInfo import androidx.work.WorkManager import org.libremediaconverter.codec.AndroidDeviceCodecs import org.libremediaconverter.model.Engine +import org.libremediaconverter.model.AudioCodec +import org.libremediaconverter.model.Container import org.libremediaconverter.model.OutputFormat -import org.libremediaconverter.model.QualityTier +import org.libremediaconverter.model.OutputSpec import org.libremediaconverter.model.VideoCodec +import org.libremediaconverter.model.QualityTier import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout @@ -78,7 +81,7 @@ class ConversionWorkerTest { inputUri = Uri.fromFile(input), displayName = "worker_sample.mp4", sizeBytes = input.length(), - format = OutputFormat.MP4_H264, + spec = OutputFormat.MP4_H264.spec, ) workManager.enqueue(request).result.get() @@ -114,7 +117,7 @@ class ConversionWorkerTest { inputUri = Uri.fromFile(input), displayName = "worker_sample.mp4", sizeBytes = input.length(), - format = OutputFormat.MP3, + spec = OutputFormat.MP3.spec, ) workManager.enqueue(request).result.get() @@ -152,7 +155,7 @@ class ConversionWorkerTest { inputUri = Uri.fromFile(input), displayName = "worker_sample.mp4", sizeBytes = input.length(), - format = OutputFormat.MP4_H265, + spec = OutputFormat.MP4_H265.spec, quality = QualityTier.FAST, ) workManager.enqueue(request).result.get() @@ -181,7 +184,7 @@ class ConversionWorkerTest { inputUri = Uri.fromFile(input), displayName = "worker_sample.mp4", sizeBytes = input.length(), - format = OutputFormat.MP4_H264, + spec = OutputFormat.MP4_H264.spec, quality = QualityTier.BEST, ) workManager.enqueue(request).result.get() @@ -199,9 +202,50 @@ class ConversionWorkerTest { @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)) + assertEquals( + "clip_converted.mp3", + ConversionWorker.outputNameFor("clip.mp4", OutputFormat.MP3.spec), + ) + assertEquals( + "clip_converted.gif", + ConversionWorker.outputNameFor("clip.mov", OutputFormat.GIF.spec), + ) + assertEquals( + "clip_converted.mkv", + ConversionWorker.outputNameFor("clip", OutputFormat.MKV_H264.spec), + ) + } + + /** + * The extension follows the container and whether a video track survives. + * + * It used to be a literal on each preset, so the only names reachable were the ones somebody + * had enumerated. Matroska without video is `.mka` and MP4 without video is `.m4a` — neither + * had a preset before, and both are now one tap away in the Advanced picker. + */ + @Test + fun outputNameDistinguishesAudioOnlyVariantsOfAContainer() { + assertEquals( + "clip_converted.mka", + ConversionWorker.outputNameFor( + "clip.mkv", + OutputSpec(Container.MKV, VideoCodec.NONE, AudioCodec.FLAC), + ), + ) + assertEquals( + "clip_converted.m4a", + ConversionWorker.outputNameFor( + "clip.mp4", + OutputSpec(Container.MP4, VideoCodec.NONE, AudioCodec.AAC), + ), + ) + assertEquals( + "clip_converted.mkv", + ConversionWorker.outputNameFor( + "clip.mp4", + OutputSpec(Container.MKV, VideoCodec.COPY, AudioCodec.COPY), + ), + ) } private companion object { diff --git a/app/src/main/java/org/libremediaconverter/codec/AndroidDeviceCodecs.kt b/app/src/main/java/org/libremediaconverter/codec/AndroidDeviceCodecs.kt index 8f91a82..de630bb 100644 --- a/app/src/main/java/org/libremediaconverter/codec/AndroidDeviceCodecs.kt +++ b/app/src/main/java/org/libremediaconverter/codec/AndroidDeviceCodecs.kt @@ -84,7 +84,10 @@ class AndroidDeviceCodecs private constructor( VideoCodec.VP8 -> MediaFormat.MIMETYPE_VIDEO_VP8 VideoCodec.VP9 -> MediaFormat.MIMETYPE_VIDEO_VP9 VideoCodec.AV1 -> MediaFormat.MIMETYPE_VIDEO_AV1 - VideoCodec.NONE -> null + // Nothing is encoded for either, so there is no encoder to look for. Returning null + // makes canEncode answer true, which is the right answer: a copied or absent track + // places no demand on the hardware. + VideoCodec.COPY, VideoCodec.NONE -> null } /** Maps an FFprobe-style codec name onto a MediaFormat MIME type. */ diff --git a/app/src/main/java/org/libremediaconverter/convert/ConversionViewModel.kt b/app/src/main/java/org/libremediaconverter/convert/ConversionViewModel.kt index 14ef94f..e53a045 100644 --- a/app/src/main/java/org/libremediaconverter/convert/ConversionViewModel.kt +++ b/app/src/main/java/org/libremediaconverter/convert/ConversionViewModel.kt @@ -8,15 +8,25 @@ import androidx.lifecycle.viewModelScope import androidx.media3.common.util.UnstableApi import androidx.work.WorkInfo import androidx.work.WorkManager +import org.libremediaconverter.model.AudioCodec +import org.libremediaconverter.model.Container +import org.libremediaconverter.model.ContainerCapabilities import org.libremediaconverter.model.EnginePreference +import org.libremediaconverter.model.InputProbe import org.libremediaconverter.model.OutputFormat +import org.libremediaconverter.model.OutputSpec import org.libremediaconverter.model.QualityTier +import org.libremediaconverter.model.Validation +import org.libremediaconverter.model.VideoCodec import org.libremediaconverter.work.ConversionWorker import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -25,15 +35,27 @@ import java.util.UUID /** User-chosen conversion settings. */ data class ConversionSettings( - val format: OutputFormat = OutputFormat.MP4_H265, + val spec: OutputSpec = OutputFormat.MP4_H265.spec, val quality: QualityTier = QualityTier.FAST, val enginePreference: EnginePreference = EnginePreference.AUTO, -) +) { + /** The preset this spec corresponds to, or null once it has been edited past all of them. */ + val matchingPreset: OutputFormat? + get() = OutputFormat.entries.firstOrNull { it.spec == spec } +} data class InputFile( val uri: Uri, val displayName: String, val sizeBytes: Long, + /** + * What probing found. Null only while the probe is still running. + * + * Held here rather than recomputed because three things need it: the source-info card, the + * validity check for the chosen output, and the copy planner's decision about whether a track + * can be stream-copied. + */ + val probe: InputProbe? = null, ) sealed interface ConversionState { @@ -68,15 +90,52 @@ class ConversionViewModel(app: Application) : AndroidViewModel(app) { private val _settings = MutableStateFlow(ConversionSettings()) val settings: StateFlow = _settings.asStateFlow() - fun setFormat(format: OutputFormat) = _settings.update { it.copy(format = format) } + /** + * Whether the chosen output can actually be produced from the chosen input. + * + * Derived rather than stored so it cannot go stale: it recomputes when either the settings or + * the picked file changes. The Advanced picker deliberately allows an invalid combination to be + * selected, so this is what turns that into an explanation and a disabled Convert button. + */ + val validation: StateFlow = combine(_state, _settings) { state, settings -> + ContainerCapabilities.validate(settings.spec, state.probe() ?: InputProbe()) + }.stateIn(viewModelScope, SharingStarted.Eagerly, Validation.Valid) + + fun setPreset(format: OutputFormat) = _settings.update { it.copy(spec = format.spec) } + fun setContainer(container: Container) = + _settings.update { it.copy(spec = it.spec.copy(container = container)) } + + fun setVideoCodec(codec: VideoCodec) = + _settings.update { it.copy(spec = it.spec.copy(videoCodec = codec)) } + + fun setAudioCodec(codec: AudioCodec) = + _settings.update { it.copy(spec = it.spec.copy(audioCodec = codec)) } + + fun applySuggestion(spec: OutputSpec) = _settings.update { it.copy(spec = spec) } + 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) } - _state.value = ConversionState.Ready(info) + // Both the metadata query and the probe touch disk, and the probe spawns FFprobe. + // Neither belongs on the main thread. + val file = withContext(Dispatchers.IO) { queryFile(uri) } + // Show the file as soon as its name and size are known. Probing now runs FFprobe on + // every pick, which is a native process spawn, and making the whole screen wait on it + // would read as the app having ignored the tap. + _state.value = ConversionState.Ready(file) + + val probe = withContext(Dispatchers.IO) { MediaProbe.probe(getApplication(), uri) } + // Only fill in the probe if the user has not moved on in the meantime. + _state.update { current -> + if (current is ConversionState.Ready && current.input.uri == uri) { + ConversionState.Ready(file.copy(probe = probe)) + } else { + current + } + } } } @@ -95,7 +154,7 @@ class ConversionViewModel(app: Application) : AndroidViewModel(app) { inputUri = input.uri, displayName = input.displayName, sizeBytes = input.sizeBytes, - format = settings.format, + spec = settings.spec, quality = settings.quality, enginePreference = settings.enginePreference, ) @@ -169,7 +228,7 @@ class ConversionViewModel(app: Application) : AndroidViewModel(app) { _state.value = ConversionState.Saved( ConversionWorker.outputNameFor( converted.input.displayName, - _settings.value.format, + _settings.value.spec, ) ) }.onFailure { e -> @@ -188,9 +247,17 @@ class ConversionViewModel(app: Application) : AndroidViewModel(app) { fun suggestedOutputName(): String = ConversionWorker.outputNameFor( currentInput()?.displayName ?: "output", - _settings.value.format, + _settings.value.spec, ) + private fun ConversionState.probe(): InputProbe? = when (this) { + is ConversionState.Ready -> input.probe + is ConversionState.Converting -> input.probe + is ConversionState.Waiting -> input.probe + is ConversionState.Converted -> input.probe + else -> null + } + private fun currentInput(): InputFile? = when (val s = _state.value) { is ConversionState.Ready -> s.input is ConversionState.Converting -> s.input diff --git a/app/src/main/java/org/libremediaconverter/convert/ConverterScreen.kt b/app/src/main/java/org/libremediaconverter/convert/ConverterScreen.kt index 6fde369..12c999f 100644 --- a/app/src/main/java/org/libremediaconverter/convert/ConverterScreen.kt +++ b/app/src/main/java/org/libremediaconverter/convert/ConverterScreen.kt @@ -3,6 +3,7 @@ package org.libremediaconverter.convert import android.Manifest import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi @@ -16,13 +17,19 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AssistChip import androidx.compose.material3.Button import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign @@ -33,9 +40,16 @@ import org.libremediaconverter.ui.ScreenPaddingVertical import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import androidx.media3.common.util.UnstableApi +import org.libremediaconverter.model.AudioCodec +import org.libremediaconverter.model.CodecNames +import org.libremediaconverter.model.Container import org.libremediaconverter.model.EnginePreference +import org.libremediaconverter.model.InputKind import org.libremediaconverter.model.OutputFormat +import org.libremediaconverter.model.OutputSpec import org.libremediaconverter.model.QualityTier +import org.libremediaconverter.model.Validation +import org.libremediaconverter.model.VideoCodec import java.util.Locale @UnstableApi @@ -46,6 +60,7 @@ fun ConverterScreen( ) { val state by viewModel.state.collectAsStateWithLifecycle() val settings by viewModel.settings.collectAsStateWithLifecycle() + val validation by viewModel.validation.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. @@ -55,7 +70,7 @@ fun ConverterScreen( ) { uri -> uri?.let(viewModel::onInputPicked) } val chooseDestination = rememberLauncherForActivityResult( - ActivityResultContracts.CreateDocument(settings.format.mimeType) + ActivityResultContracts.CreateDocument(settings.spec.mimeType) ) { uri -> uri?.let(viewModel::save) } // Requested at the point of use rather than on first launch, so the ask carries its @@ -110,13 +125,24 @@ fun ConverterScreen( is ConversionState.Ready -> { FileCard(s.input) - FormatPicker(settings.format, viewModel::setFormat) + FormatPicker(settings.matchingPreset, viewModel::setPreset) + AdvancedPicker( + spec = settings.spec, + validation = validation, + onContainer = viewModel::setContainer, + onVideoCodec = viewModel::setVideoCodec, + onAudioCodec = viewModel::setAudioCodec, + onSuggestion = viewModel::applySuggestion, + ) QualityPicker(settings.quality, viewModel::setQuality) EnginePicker(settings.enginePreference, viewModel::setEnginePreference) Button( onClick = { requestNotifications.launch(Manifest.permission.POST_NOTIFICATIONS) }, + // The Advanced picker lets an impossible combination be selected on + // purpose, so this is what stops it from being run. + enabled = validation.isValid, modifier = Modifier.fillMaxWidth().height(PrimaryButtonHeight), ) { Text("Convert") } OutlinedButton( @@ -159,8 +185,9 @@ fun ConverterScreen( ) 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. + // explains why a job was slow, makes the software fallback + // visible, and is how the user learns a remux happened rather + // than a re-encode. AssistChip(onClick = {}, label = { Text(s.routeReason) }) } Button( @@ -200,7 +227,7 @@ fun ConverterScreen( @OptIn(ExperimentalLayoutApi::class) @Composable -private fun FormatPicker(selected: OutputFormat, onSelect: (OutputFormat) -> Unit) { +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 -> @@ -211,6 +238,128 @@ private fun FormatPicker(selected: OutputFormat, onSelect: (OutputFormat) -> Uni ) } } + if (selected == null) { + Text( + "Custom — set below.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +/** + * The full container × codec matrix. + * + * Every combination stays selectable, including the ones that cannot work. Disabling or hiding + * them would leave the user guessing why the option they wanted is not there; letting them pick it + * and then saying what is wrong — and what would work instead — teaches the constraint. The + * Convert button is what actually blocks the job. + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun AdvancedPicker( + spec: OutputSpec, + validation: Validation, + onContainer: (Container) -> Unit, + onVideoCodec: (VideoCodec) -> Unit, + onAudioCodec: (AudioCodec) -> Unit, + onSuggestion: (OutputSpec) -> Unit, +) { + var expanded by rememberSaveable { mutableStateOf(false) } + + TextButton(onClick = { expanded = !expanded }) { + Text(if (expanded) "Hide advanced" else "Advanced") + } + + AnimatedVisibility(visible = expanded) { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text("Container", style = MaterialTheme.typography.titleSmall) + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Container.entries.forEach { container -> + FilterChip( + selected = container == spec.container, + onClick = { onContainer(container) }, + label = { Text(container.label) }, + ) + } + } + + Text("Video", style = MaterialTheme.typography.titleSmall) + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + VideoCodec.entries.forEach { codec -> + FilterChip( + selected = codec == spec.videoCodec, + onClick = { onVideoCodec(codec) }, + label = { Text(codec.label) }, + ) + } + } + + Text("Audio", style = MaterialTheme.typography.titleSmall) + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + AudioCodec.entries.forEach { codec -> + FilterChip( + selected = codec == spec.audioCodec, + onClick = { onAudioCodec(codec) }, + label = { Text(codec.label) }, + ) + } + } + + Text( + "Copy keeps the original stream — no re-encoding, so it finishes in seconds.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + if (validation is Validation.Invalid) { + ValidationError(validation, onSuggestion) + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun ValidationError(invalid: Validation.Invalid, onSuggestion: (OutputSpec) -> Unit) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer, + ), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text(invalid.message, style = MaterialTheme.typography.bodyMedium) + if (invalid.suggestions.isNotEmpty()) { + Text("Try instead:", style = MaterialTheme.typography.labelMedium) + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + invalid.suggestions.forEach { suggestion -> + AssistChip( + onClick = { onSuggestion(suggestion) }, + label = { Text(describe(suggestion)) }, + ) + } + } + } + } + } +} + +private fun describe(spec: OutputSpec): String { + val video = when (spec.videoCodec) { + VideoCodec.NONE -> null + else -> spec.videoCodec.label + } + val audio = when (spec.audioCodec) { + AudioCodec.NONE -> null + else -> spec.audioCodec.label + } + val tracks = listOfNotNull(video, audio).joinToString(" + ") + return if (tracks.isEmpty()) spec.container.label else "${spec.container.label} · $tracks" } @OptIn(ExperimentalLayoutApi::class) @@ -250,16 +399,87 @@ private fun EnginePreference.label(): String = when (this) { EnginePreference.FORCE_SOFTWARE -> "Force software" } +/** + * Name, size, and what the file actually turned out to be. + * + * The codec lines are what make "Copy" a meaningful choice — without knowing the source is H.264, + * "copy the video" is a guess. They degrade explicitly rather than silently: an audio file says so + * instead of showing a blank video row, and a file nothing could read says that rather than + * pretending it has an unknown codec. + */ @Composable private fun FileCard(input: InputFile) { Card(modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.padding(16.dp)) { Text(input.displayName, style = MaterialTheme.typography.titleMedium) Text(formatBytes(input.sizeBytes), style = MaterialTheme.typography.bodySmall) + + val probe = input.probe + if (probe == null) { + Text("Reading…", style = MaterialTheme.typography.bodySmall) + return@Column + } + + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + + when (probe.kind) { + InputKind.UNPARSEABLE -> Text( + "Could not identify this file. It will be converted with FFmpeg.", + style = MaterialTheme.typography.bodySmall, + ) + + InputKind.IMAGE -> { + DetailRow("Type", "Image") + if (probe.width > 0) DetailRow("Size", "${probe.width}×${probe.height}") + } + + InputKind.AUDIO_ONLY -> { + DetailRow("Container", probe.container?.label ?: "Unknown") + DetailRow("Video", "No video track") + DetailRow("Audio", CodecNames.describeAudio(probe.audioCodec)) + if (probe.durationMs > 0) DetailRow("Length", formatDuration(probe.durationMs)) + } + + InputKind.VIDEO -> { + DetailRow("Container", probe.container?.label ?: "Unknown") + DetailRow( + "Video", + buildString { + append(CodecNames.describeVideo(probe.videoCodec)) + if (probe.width > 0) append(" · ${probe.width}×${probe.height}") + }, + ) + DetailRow( + "Audio", + if (probe.audioCodec == null) { + "No audio track" + } else { + CodecNames.describeAudio(probe.audioCodec) + }, + ) + if (probe.durationMs > 0) DetailRow("Length", formatDuration(probe.durationMs)) + } + } } } } +@Composable +private fun DetailRow(label: String, value: String) { + Text( + "$label: $value", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) +} + +private fun formatDuration(ms: Long): String { + val totalSeconds = ms / 1000 + val minutes = totalSeconds / 60 + val seconds = totalSeconds % 60 + return String.format(Locale.US, "%d:%02d", minutes, seconds) +} + private fun formatBytes(bytes: Long): String = when { bytes >= 1_000_000_000 -> String.format(Locale.US, "%.1f GB", bytes / 1e9) bytes >= 1_000_000 -> String.format(Locale.US, "%.1f MB", bytes / 1e6) diff --git a/app/src/main/java/org/libremediaconverter/convert/Media3Engine.kt b/app/src/main/java/org/libremediaconverter/convert/Media3Engine.kt index 0c78040..71d1b79 100644 --- a/app/src/main/java/org/libremediaconverter/convert/Media3Engine.kt +++ b/app/src/main/java/org/libremediaconverter/convert/Media3Engine.kt @@ -9,6 +9,7 @@ import androidx.media3.common.MimeTypes import androidx.media3.common.util.UnstableApi import androidx.media3.transformer.Composition import androidx.media3.transformer.EditedMediaItem +import androidx.media3.transformer.EditedMediaItemSequence import androidx.media3.transformer.ExportException import androidx.media3.transformer.ExportResult import androidx.media3.transformer.ProgressHolder @@ -16,8 +17,12 @@ import androidx.media3.transformer.Transformer import kotlinx.coroutines.CancellableContinuation import kotlinx.coroutines.suspendCancellableCoroutine import org.libremediaconverter.model.AudioCodec -import org.libremediaconverter.model.OutputFormat +import org.libremediaconverter.model.AudioPlan +import org.libremediaconverter.model.ConversionPlan +import org.libremediaconverter.model.ConversionRequest +import org.libremediaconverter.model.CopyPlanner import org.libremediaconverter.model.VideoCodec +import org.libremediaconverter.model.VideoPlan import java.io.File import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException @@ -58,18 +63,27 @@ class Media3Engine(private val context: Context) : HardwareTranscoder { override suspend fun transcode( input: Uri, output: File, - format: OutputFormat, + request: ConversionRequest, onProgress: (Int) -> Unit, ): Unit = suspendCancellableCoroutine { cont -> + val plan = CopyPlanner.plan(request.spec, request.probe) handler.post { - val transformer = runCatching { buildTransformer(format, cont) } + val transformer = runCatching { buildTransformer(plan, cont) } .getOrElse { cont.resumeWithException(it); return@post } - // Dropping the tracks the target format does not have is what stops an audio-only - // export from carrying a re-encoded video track. Without setRemoveVideo, asking for - // M4A produced an HEVC stream in a file named .m4a. + + // Dropping the tracks the target does not have is what stops an audio-only export + // from carrying a re-encoded video track. Without setRemoveVideo, asking for M4A + // produced an HEVC stream in a file named .m4a. val item = EditedMediaItem.Builder(MediaItem.fromUri(input)) - .setRemoveVideo(format.videoCodec == VideoCodec.NONE) - .setRemoveAudio(format.audioCodec == AudioCodec.NONE) + .setRemoveVideo(plan.video == VideoPlan.Drop) + .setRemoveAudio(plan.audio == AudioPlan.Drop) + .build() + + // A Composition is the only way to ask for transmuxing; the plain + // start(EditedMediaItem, path) overload always re-encodes. This is the remux path. + val composition = Composition.Builder(EditedMediaItemSequence.Builder(item).build()) + .setTransmuxVideo(plan.video == VideoPlan.Copy) + .setTransmuxAudio(plan.audio == AudioPlan.Copy) .build() cont.invokeOnCancellation { @@ -77,7 +91,7 @@ class Media3Engine(private val context: Context) : HardwareTranscoder { handler.post { runCatching { transformer.cancel() } } } - runCatching { transformer.start(item, output.absolutePath) } + runCatching { transformer.start(composition, output.absolutePath) } .onFailure { cont.resumeWithException(it); return@post } pollProgress(transformer, cont, onProgress) @@ -85,27 +99,28 @@ class Media3Engine(private val context: Context) : HardwareTranscoder { } /** - * @throws IllegalArgumentException if [format] names a container Media3 cannot mux. That is a + * @throws IllegalArgumentException if [plan] names a container Media3 cannot mux. That is a * routing bug rather than a runtime condition — [org.libremediaconverter.model.ConversionRouter] * is supposed to have sent such a job to FFmpeg — so it fails loudly instead of quietly * writing MP4, which is what the old code did. */ private fun buildTransformer( - format: OutputFormat, + plan: ConversionPlan, cont: CancellableContinuation, ): Transformer { - val muxerFactory = requireNotNull(Media3Muxers.factoryFor(format.container)) { - "Media3 cannot mux ${format.container}; this job should have routed to FFmpeg." + val muxerFactory = requireNotNull(Media3Muxers.factoryFor(plan.container)) { + "Media3 cannot mux ${plan.container}; this job should have routed to FFmpeg." } val builder = Transformer.Builder(context) .setLooper(thread.looper) .setMuxerFactory(muxerFactory) - // Only name a MIME type for a track the output actually keeps. Naming one for a removed + // Name a MIME type only for a track that is actually being encoded. Setting one for a + // transmuxed track contradicts setTransmuxVideo/Audio, and setting one for a removed // track makes Transformer build an encoder for samples that will never arrive. - videoMimeTypeFor(format.videoCodec)?.let(builder::setVideoMimeType) - audioMimeTypeFor(format.audioCodec)?.let(builder::setAudioMimeType) + (plan.video as? VideoPlan.Encode)?.let { videoMimeTypeFor(it.codec)?.let(builder::setVideoMimeType) } + (plan.audio as? AudioPlan.Encode)?.let { audioMimeTypeFor(it.codec)?.let(builder::setAudioMimeType) } return builder .addListener(object : Transformer.Listener { @@ -134,7 +149,8 @@ class Media3Engine(private val context: Context) : HardwareTranscoder { private fun videoMimeTypeFor(codec: VideoCodec): String? = when (codec) { VideoCodec.H264 -> MimeTypes.VIDEO_H264 VideoCodec.H265 -> MimeTypes.VIDEO_H265 - VideoCodec.NONE -> null + // Never reached: only an Encode plan consults this, and COPY/NONE are not Encode. + VideoCodec.COPY, VideoCodec.NONE -> null VideoCodec.VP8, VideoCodec.VP9, VideoCodec.AV1 -> null } @@ -143,7 +159,7 @@ class Media3Engine(private val context: Context) : HardwareTranscoder { AudioCodec.OPUS -> MimeTypes.AUDIO_OPUS AudioCodec.VORBIS -> MimeTypes.AUDIO_VORBIS AudioCodec.PCM -> MimeTypes.AUDIO_RAW - AudioCodec.NONE -> null + AudioCodec.COPY, AudioCodec.NONE -> null // MP3 and FLAC have no Android encoder; the router routes them to FFmpeg. AudioCodec.MP3, AudioCodec.FLAC -> null } diff --git a/app/src/main/java/org/libremediaconverter/convert/Media3Muxers.kt b/app/src/main/java/org/libremediaconverter/convert/Media3Muxers.kt index 5417186..9fc4786 100644 --- a/app/src/main/java/org/libremediaconverter/convert/Media3Muxers.kt +++ b/app/src/main/java/org/libremediaconverter/convert/Media3Muxers.kt @@ -49,9 +49,16 @@ object Media3Muxers { Container.OGG -> OggFactory Container.WAV -> WavFactory Container.AAC_ADTS -> AacFactory - // Matroska, MP3 and the image outputs have no Media3 muxer. FFmpeg owns them. + // Everything else has no Media3 muxer. Matroska and the legacy containers are FFmpeg's, + // and `Mp4Muxer` exposes no QuickTime file format, so MOV is too. Container.MKV, + Container.MOV, + Container.MPEG_TS, + Container.AVI, + Container.FLV, + Container.ASF, Container.MP3, + Container.FLAC, Container.GIF, Container.IMAGE_SEQUENCE, -> null diff --git a/app/src/main/java/org/libremediaconverter/convert/MediaProbe.kt b/app/src/main/java/org/libremediaconverter/convert/MediaProbe.kt index 20889c1..c4f73c7 100644 --- a/app/src/main/java/org/libremediaconverter/convert/MediaProbe.kt +++ b/app/src/main/java/org/libremediaconverter/convert/MediaProbe.kt @@ -5,26 +5,99 @@ import android.media.MediaExtractor import android.media.MediaFormat import android.net.Uri import android.util.Log +import com.arthenica.ffmpegkit.FFmpegKitConfig +import com.arthenica.ffmpegkit.FFprobeKit +import com.arthenica.ffmpegkit.MediaInformation import org.libremediaconverter.model.ConcatInput +import org.libremediaconverter.model.Container +import org.libremediaconverter.model.InputKind import org.libremediaconverter.model.InputProbe /** - * Reads just enough about an input to route it. + * Reads what the router, the copy planner and the source-info card each need to know. * - * 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. + * ## Two probes, deliberately + * + * The platform extractor answers the routing question directly: a file `MediaExtractor` cannot open + * is a file Media3 cannot convert, so a failure there is itself the answer. It needs no native + * library and works straight off a `content://` URI. + * + * What it cannot do is name the *container*. There is no `MediaExtractor` API for it at all — and + * [org.libremediaconverter.model.CopyPlanner] needs the source container to tell a remux (change the + * container, keep the streams) from a re-encode of something already in the target container. It + * also refuses to open images, which the picker has to describe rather than reject. + * + * So FFprobe runs too, and the two are merged. FFprobe is not a fallback here: on the common path, + * where the extractor succeeds, it is still the only source of the container name. That costs a + * native process spawn per file pick, which is why callers run this off the main thread. */ object MediaProbe { fun probe(context: Context, uri: Uri): InputProbe { + val extracted = probeWithExtractor(context, uri) + val info = probeWithFFprobe(context, uri) + + val videoCodec = extracted?.videoCodec ?: info?.videoCodec + val audioCodec = extracted?.audioCodec ?: info?.audioCodec + val kind = classify(extracted, info) + + if (kind == InputKind.UNPARSEABLE) { + // 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, "Neither MediaExtractor nor FFprobe could read $uri; routing to FFmpeg.") + return InputProbe( + videoCodec = InputProbe.UNPARSEABLE, + hasVideo = true, + durationMs = 0, + kind = InputKind.UNPARSEABLE, + ) + } + + return InputProbe( + videoCodec = videoCodec, + audioCodec = audioCodec, + hasVideo = videoCodec != null, + durationMs = maxOf(extracted?.durationMs ?: 0L, info?.durationMs ?: 0L), + kind = kind, + container = info?.container, + width = extracted?.width ?: info?.width ?: 0, + height = extracted?.height ?: info?.height ?: 0, + ) + } + + /** + * Distinguishes "no video track" from "could not parse". + * + * The old code collapsed both into `hasVideo = true, videoCodec = UNPARSEABLE`, which made an + * audio file and a corrupt file indistinguishable. The source-info card cannot describe either + * honestly until they are separate, and neither can the copy planner. + */ + private fun classify(extracted: Extracted?, info: FFprobeInfo?): InputKind = when { + info?.isImage == true -> InputKind.IMAGE + extracted == null && info == null -> InputKind.UNPARSEABLE + (extracted?.videoCodec ?: info?.videoCodec) != null -> InputKind.VIDEO + (extracted?.audioCodec ?: info?.audioCodec) != null -> InputKind.AUDIO_ONLY + // Parsed, but with no stream either probe recognised. Nothing to convert. + else -> InputKind.UNPARSEABLE + } + + private class Extracted( + val videoCodec: String?, + val audioCodec: String?, + val durationMs: Long, + val width: Int, + val height: Int, + ) + + private fun probeWithExtractor(context: Context, uri: Uri): Extracted? { val extractor = MediaExtractor() return try { extractor.setDataSource(context, uri, null) var video: String? = null var audio: String? = null var durationUs = 0L + var width = 0 + var height = 0 for (i in 0 until extractor.trackCount) { val format = extractor.getTrackFormat(i) @@ -33,31 +106,132 @@ object MediaProbe { durationUs = maxOf(durationUs, format.getLong(MediaFormat.KEY_DURATION)) } when { - mime.startsWith("video/") && video == null -> video = shortName(mime) + mime.startsWith("video/") && video == null -> { + video = shortName(mime) + width = format.intOr(MediaFormat.KEY_WIDTH) + height = format.intOr(MediaFormat.KEY_HEIGHT) + } + mime.startsWith("audio/") && audio == null -> audio = shortName(mime) } } - - InputProbe( - videoCodec = video, - audioCodec = audio, - hasVideo = video != null, - durationMs = durationUs / 1000, - ) + Extracted(video, audio, durationUs / 1000, width, height) } 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) + Log.i(TAG, "Platform extractor could not read $uri.", e) + null } finally { runCatching { extractor.release() } } } + private class FFprobeInfo( + val container: Container?, + val videoCodec: String?, + val audioCodec: String?, + val durationMs: Long, + val width: Int, + val height: Int, + val isImage: Boolean, + ) + + private fun probeWithFFprobe(context: Context, uri: Uri): FFprobeInfo? = try { + // The same SAF bridge ConversionWorker uses for the FFmpeg read side. + val path = if (uri.scheme == "content") { + FFmpegKitConfig.getSafParameterForRead(context, uri) + } else { + uri.path + } + path?.let { readMediaInformation(it) } + } catch (e: Exception) { + Log.i(TAG, "FFprobe could not read $uri.", e) + null + } + + private fun readMediaInformation(path: String): FFprobeInfo? { + // ffmpeg-kit-next is compiled from Kotlin with private backing fields, so these have to go + // through the Java getters rather than property syntax. + val info: MediaInformation = FFprobeKit.getMediaInformation(path).getMediaInformation() + ?: return null + + val streams = info.getStreams().orEmpty() + val video = streams.firstOrNull { it.getType() == "video" } + val audio = streams.firstOrNull { it.getType() == "audio" } + val formatName = info.getFormat().orEmpty() + + return FFprobeInfo( + container = containerFrom(formatName, video?.getCodec()), + videoCodec = video?.getCodec(), + audioCodec = audio?.getCodec(), + durationMs = info.getDuration()?.toDoubleOrNull()?.times(1000)?.toLong() ?: 0L, + width = video?.getWidth()?.toInt() ?: 0, + height = video?.getHeight()?.toInt() ?: 0, + isImage = isImageFormat(formatName), + ) + } + + /** + * Maps FFprobe's `format_name` onto a [Container]. + * + * FFprobe reports a comma-separated list of every format that shares the demuxer, so a plain + * MP4 comes back as `mov,mp4,m4a,3gp,3g2,mj2` — the first entry is not authoritative and the + * whole string never equals one container name. Matching against the set is the only correct + * reading, and getting this wrong silently disables the remux fast path rather than failing. + * + * ## Matroska and WebM are genuinely indistinguishable here + * + * WebM *is* a Matroska profile and they share a demuxer, so FFprobe reports `matroska,webm` for + * both — a `.mkv` of H.264 and a `.webm` of VP9 give byte-identical format names, which the + * committed fixtures confirm. The only signal left is the codec: WebM permits VP8/VP9/AV1 and + * nothing else, so anything outside that set is certainly Matroska. A VP9 file could still be + * either, and is reported as WebM, which is right far more often than not. + * + * That residual ambiguity is safe for the copy planner: each container accepts the codecs the + * other holds, so a wrong guess still reaches a valid decision and only the label on the + * source-info card suffers. + * + * Returns null for anything unrecognised, which the copy planner treats as "cannot prove the + * container is changing" and therefore declines to upgrade to a stream copy. + */ + internal fun containerFrom(formatName: String, videoCodec: String? = null): Container? { + val names = formatName.split(',').map { it.trim().lowercase() }.filter { it.isNotEmpty() } + if (names.isEmpty()) return null + + // Order matters: the MP4 demuxer also claims mov, so check the more specific membership + // first or every MP4 is reported as QuickTime. + return when { + "matroska" in names || "webm" in names -> matroskaOrWebm(videoCodec) + "mp4" in names -> Container.MP4 + "mov" in names || "qt" in names -> Container.MOV + "mpegts" in names || "mpegtsraw" in names -> Container.MPEG_TS + "avi" in names -> Container.AVI + "flv" in names -> Container.FLV + "asf" in names || "asf_o" in names -> Container.ASF + "ogg" in names -> Container.OGG + "wav" in names -> Container.WAV + "aac" in names || "adts" in names -> Container.AAC_ADTS + "mp3" in names -> Container.MP3 + "flac" in names -> Container.FLAC + "gif" in names -> Container.GIF + else -> null + } + } + + /** WebM's codec whitelist is the only thing separating it from Matroska. See [containerFrom]. */ + private fun matroskaOrWebm(videoCodec: String?): Container = when (videoCodec?.lowercase()) { + "vp8", "vp9", "vp09", "av1", "av01" -> Container.WEBM + else -> Container.MKV + } + + /** FFprobe describes still images through the image demuxers rather than a media container. */ + private fun isImageFormat(formatName: String): Boolean { + val names = formatName.split(',').map { it.trim().lowercase() } + return names.any { it == "image2" || it.endsWith("_pipe") } + } + /** * 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. + * re-encoding. Unknown values stay null, which [org.libremediaconverter.model.ConcatPlanner] + * treats as "cannot prove a match" rather than as agreement. */ fun probeForConcat(context: Context, uri: Uri): ConcatInput { val extractor = MediaExtractor() @@ -105,6 +279,7 @@ object MediaProbe { MediaFormat.MIMETYPE_AUDIO_OPUS -> "opus" MediaFormat.MIMETYPE_AUDIO_FLAC -> "flac" MediaFormat.MIMETYPE_AUDIO_VORBIS -> "vorbis" + MediaFormat.MIMETYPE_AUDIO_RAW -> "pcm" else -> mime.substringAfter('/') } diff --git a/app/src/main/java/org/libremediaconverter/convert/Transcoders.kt b/app/src/main/java/org/libremediaconverter/convert/Transcoders.kt index ac284d1..ea7f810 100644 --- a/app/src/main/java/org/libremediaconverter/convert/Transcoders.kt +++ b/app/src/main/java/org/libremediaconverter/convert/Transcoders.kt @@ -12,16 +12,17 @@ import java.io.File /** The hardware conversion path. Implemented by [Media3Engine]. */ interface HardwareTranscoder : AutoCloseable { /** - * Takes the whole [OutputFormat] rather than just a video MIME type. + * Takes the whole [ConversionRequest] rather than just a video MIME type. * * The narrower signature was the reason "extract audio to M4A" produced an HEVC video track: * the container, the audio codec and "this output has no video at all" had nowhere to travel, - * so the engine defaulted all three. + * so the engine defaulted all three. Passing the request also carries the input probe, which + * is what `CopyPlanner` needs to decide whether a track can be transmuxed. */ suspend fun transcode( input: Uri, output: File, - format: OutputFormat = OutputFormat.MP4_H265, + request: ConversionRequest = ConversionRequest(OutputFormat.MP4_H265.spec), onProgress: (Int) -> Unit = {}, ) } diff --git a/app/src/main/java/org/libremediaconverter/ffmpeg/FFmpegCommandBuilder.kt b/app/src/main/java/org/libremediaconverter/ffmpeg/FFmpegCommandBuilder.kt index d76b653..4dde3ba 100644 --- a/app/src/main/java/org/libremediaconverter/ffmpeg/FFmpegCommandBuilder.kt +++ b/app/src/main/java/org/libremediaconverter/ffmpeg/FFmpegCommandBuilder.kt @@ -1,8 +1,15 @@ package org.libremediaconverter.ffmpeg +import org.libremediaconverter.model.AudioCodec +import org.libremediaconverter.model.AudioPlan +import org.libremediaconverter.model.CodecNames +import org.libremediaconverter.model.Container +import org.libremediaconverter.model.ConversionPlan import org.libremediaconverter.model.ConversionRequest -import org.libremediaconverter.model.OutputFormat +import org.libremediaconverter.model.CopyPlanner import org.libremediaconverter.model.QualityTier +import org.libremediaconverter.model.VideoCodec +import org.libremediaconverter.model.VideoPlan /** * Builds FFmpeg argument lists. @@ -13,6 +20,9 @@ import org.libremediaconverter.model.QualityTier * * 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. + * + * Every decision comes from [CopyPlanner], so "copy this track" and "re-encode that one" are + * settled before any flag is chosen, and the same plan drives the routing decision. */ object FFmpegCommandBuilder { @@ -34,122 +44,158 @@ object FFmpegCommandBuilder { * "Invalid to call at Released state" partway through the export — and hardware * players reject 4:4:4 output anyway. Naming the pixel format makes FFmpeg insert * the conversion instead of failing. + * + * Deliberately absent from the copy path: a stream copy never decodes frames, so there is + * no pixel format to convert and the flag would be meaningless. */ private val PIX_FMT = listOf("-pix_fmt", "yuv420p") + /** Containers in the ISO base-media family, where HEVC needs the hvc1 brand. */ + private val MP4_FAMILY = setOf(Container.MP4, Container.MOV) + 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) + ): List { + val plan = CopyPlanner.plan(request.spec, request.probe) + return 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) { - org.libremediaconverter.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", - ) + PIX_FMT - org.libremediaconverter.model.VideoCodec.VP9 -> listOf( - "-c:v", "libvpx-vp9", "-crf", "31", "-b:v", "0", - ) + PIX_FMT - else -> listOf( - "-c:v", "libx264", "-crf", "$CRF_H264", "-preset", "medium", - ) + PIX_FMT - } - - // Software encoding, always. - // - // FFmpeg's *_mediacodec encoders used to be selected here, on the theory - // that a job routed to FFmpeg for container reasons could still encode in - // hardware. In practice they are undocumented, per-device flaky, and were - // observed failing twice on real footage on a Pixel 10 Pro XL -- once - // binding to a software codec while claiming to be the fast path, and once - // dying mid-export with "Error submitting video frame to the encoder" even - // after the pixel format was pinned. - // - // They also duplicate, badly, something Media3 already does properly. A - // job only reaches FFmpeg because Media3 could not handle it, which is - // itself evidence that hardware encoding is unlikely to work for that - // input. Fast therefore means a fast *preset*, not a different encoder. - QualityTier.FAST -> when (format.videoCodec) { - org.libremediaconverter.model.VideoCodec.H265 -> listOf( - "-c:v", "libx265", "-crf", "$CRF_H265", "-preset", "veryfast", - "-tag:v", "hvc1", - ) + PIX_FMT - org.libremediaconverter.model.VideoCodec.VP9 -> listOf( - "-c:v", "libvpx-vp9", "-crf", "31", "-b:v", "0", - "-deadline", "realtime", - ) + PIX_FMT - else -> listOf( - "-c:v", "libx264", "-crf", "$CRF_H264", "-preset", "veryfast", - ) + PIX_FMT - } + if (request.spec.isImageOutput) { + addAll(imageArgs(request)) + } else { + addAll(videoArgs(plan, request)) + addAll(audioArgs(plan)) } + addAll(containerArgs(plan)) + + add(outputPath) } } - 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 imageArgs(request: ConversionRequest): List = when (request.container) { + Container.GIF -> listOf( + "-an", + // 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", + ) + + else -> listOf("-an", "-vf", "fps=1", "-vsync", "0") } - 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. - org.libremediaconverter.model.Container.MP4 -> listOf("-movflags", "+faststart") - else -> emptyList() + private fun videoArgs(plan: ConversionPlan, request: ConversionRequest): List = + when (val video = plan.video) { + // -vn drops video entirely. Without it FFmpeg will happily try to carry a video + // stream into an audio container and fail at the muxer. + VideoPlan.Drop -> listOf("-vn") + + VideoPlan.Copy -> buildList { + add("-c:v"); add("copy") + // The hvc1 brand matters on the copy path too, not just when encoding: remuxing + // HEVC out of Matroska into MP4 otherwise produces a file Apple devices and many + // hardware players refuse, even though the samples are byte-identical. + addAll(hevcTagIfNeeded(plan, request)) + } + + is VideoPlan.Encode -> encodeVideo(video.codec, request.quality) + } + + private fun encodeVideo(codec: VideoCodec, quality: QualityTier): List { + val preset = if (quality == QualityTier.BEST) "medium" else "veryfast" + return when (codec) { + // 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. + // + // Software encoding, always. FFmpeg's *_mediacodec encoders used to be selected + // here, on the theory that a job routed to FFmpeg for container reasons could still + // encode in hardware. In practice they are undocumented, per-device flaky, and were + // observed failing twice on real footage on a Pixel 10 Pro XL -- once binding to a + // software codec while claiming to be the fast path, and once dying mid-export with + // "Error submitting video frame to the encoder" even after the pixel format was + // pinned. They also duplicate, badly, something Media3 already does properly. A job + // only reaches FFmpeg because Media3 could not handle it, which is itself evidence + // that hardware encoding is unlikely to work for that input. Fast therefore means a + // fast *preset*, not a different encoder. + VideoCodec.H265 -> listOf( + "-c:v", "libx265", "-crf", "$CRF_H265", "-preset", preset, + // Without this, many players and Apple devices refuse HEVC in MP4. + "-tag:v", "hvc1", + ) + PIX_FMT + + VideoCodec.VP9 -> buildList { + addAll(listOf("-c:v", "libvpx-vp9", "-crf", "31", "-b:v", "0")) + if (quality == QualityTier.FAST) addAll(listOf("-deadline", "realtime")) + addAll(PIX_FMT) + } + + VideoCodec.H264 -> listOf( + "-c:v", "libx264", "-crf", "$CRF_H264", "-preset", preset, + ) + PIX_FMT + + // No silent substitution. A trailing `else -> libx264` would hand back H.264 for a + // VP8 or AV1 request without a word — structurally the same defect as the old + // `media3MimeType()`, whose `else -> VIDEO_H265` is what put an HEVC video track in a + // file named `.m4a`. ContainerCapabilities refuses these combinations and + // ConversionWorker checks before enqueuing, so reaching here is a bug worth hearing + // about rather than papering over. + VideoCodec.VP8, VideoCodec.AV1 -> error( + "This app cannot encode ${codec.label}; it can only copy an existing " + + "${codec.label} stream." + ) + + VideoCodec.COPY, VideoCodec.NONE -> error( + "encodeVideo called for $codec, which is not an encode" + ) + } + } + + private fun hevcTagIfNeeded(plan: ConversionPlan, request: ConversionRequest): List { + if (plan.container !in MP4_FAMILY) return emptyList() + val sourceIsHevc = request.probe.videoCodec + ?.let(CodecNames::videoFromName) == VideoCodec.H265 + return if (sourceIsHevc) listOf("-tag:v", "hvc1") else emptyList() + } + + private fun audioArgs(plan: ConversionPlan): List = + when (val audio = plan.audio) { + AudioPlan.Drop -> listOf("-an") + AudioPlan.Copy -> listOf("-c:a", "copy") + is AudioPlan.Encode -> when (audio.codec) { + AudioCodec.MP3 -> listOf("-c:a", "libmp3lame", "-q:a", "2") + AudioCodec.FLAC -> listOf("-c:a", "flac") + AudioCodec.PCM -> listOf("-c:a", "pcm_s16le") + AudioCodec.OPUS -> listOf("-c:a", "libopus", "-b:a", "128k") + AudioCodec.VORBIS -> listOf("-c:a", "libvorbis", "-q:a", "5") + else -> listOf("-c:a", "aac", "-b:a", "192k") + } + } + + private fun containerArgs(plan: ConversionPlan): List = buildList { + // Name the muxer rather than letting FFmpeg infer it from the output path. Inference is + // unreliable for MPEG-TS and ASF, and the app now lets the user pick a container + // independently of the preset that used to imply it. + add("-f"); add(plan.container.ffmpegFormat) + + if (plan.container in MP4_FAMILY) { + // 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. + add("-movflags"); add("+faststart") + } } /** 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 + fun outputPattern(container: Container, baseName: String): String = + if (container == Container.IMAGE_SEQUENCE) "${baseName}_%04d.png" else baseName } diff --git a/app/src/main/java/org/libremediaconverter/model/CodecNames.kt b/app/src/main/java/org/libremediaconverter/model/CodecNames.kt new file mode 100644 index 0000000..3b68815 --- /dev/null +++ b/app/src/main/java/org/libremediaconverter/model/CodecNames.kt @@ -0,0 +1,50 @@ +package org.libremediaconverter.model + +/** + * Maps the short codec names probing produces onto the app's codec enums. + * + * Three vocabularies meet here: `MediaExtractor` MIME types (normalised to short names by + * `MediaProbe.shortName`), FFprobe's `codec_name`, and this app's enums. Stream copy needs the + * round trip — "the source says `hevc`; is that the same thing as [VideoCodec.H265], and can the + * target container hold it?" — so the mapping has to live somewhere both [CopyPlanner] and + * [ContainerCapabilities] can reach, and somewhere a JVM test can exercise it. + * + * An unrecognised name returns null. That is deliberately not "no match": a copy planner that + * treated unknown as compatible would stream-copy a codec into a container that cannot hold it, + * and the failure would land on the user as a file that will not play. + */ +object CodecNames { + + fun videoFromName(name: String?): VideoCodec? = when (name?.lowercase()) { + null, InputProbe.UNPARSEABLE -> null + "h264", "avc", "avc1", "x264" -> VideoCodec.H264 + "hevc", "h265", "hvc1", "hev1", "x265" -> VideoCodec.H265 + "vp8" -> VideoCodec.VP8 + "vp9", "vp09" -> VideoCodec.VP9 + "av1", "av01" -> VideoCodec.AV1 + else -> null + } + + fun audioFromName(name: String?): AudioCodec? = when (name?.lowercase()) { + null -> null + "aac", "mp4a", "aac_latm" -> AudioCodec.AAC + "opus" -> AudioCodec.OPUS + "vorbis" -> AudioCodec.VORBIS + "mp3", "mp3float", "mpga" -> AudioCodec.MP3 + "flac" -> AudioCodec.FLAC + "pcm", "raw", "pcm_s16le", "pcm_s24le", "pcm_f32le" -> AudioCodec.PCM + else -> null + } + + /** Human-readable name for the source-info card. Falls back to the raw probe string. */ + fun describeVideo(name: String?): String = when { + name == null -> "Unknown" + name == InputProbe.UNPARSEABLE -> "Unrecognised" + else -> videoFromName(name)?.label ?: name + } + + fun describeAudio(name: String?): String = when { + name == null -> "Unknown" + else -> audioFromName(name)?.label ?: name + } +} diff --git a/app/src/main/java/org/libremediaconverter/model/ContainerCapabilities.kt b/app/src/main/java/org/libremediaconverter/model/ContainerCapabilities.kt new file mode 100644 index 0000000..e5c3644 --- /dev/null +++ b/app/src/main/java/org/libremediaconverter/model/ContainerCapabilities.kt @@ -0,0 +1,323 @@ +package org.libremediaconverter.model + +/** + * Which codecs may go in which container, and whether this app can actually produce them. + * + * ## Why two questions, not one + * + * "Can MP4 carry AV1?" and "can this app make AV1?" have different answers, and remux is exactly + * where the difference shows. MP4 carries AV1 and ALAC happily; neither engine here encodes them. + * Matroska carries Vorbis; nothing in [org.libremediaconverter.ffmpeg.FFmpegCommandBuilder] emits a + * Vorbis encoder. A single `isValid` boolean would answer one of those questions and give the wrong + * error for the other — telling a user "MP4 cannot hold AV1" when the truth is "your AV1 file can be + * copied into MP4, just not re-encoded to it". + * + * So the matrix is indexed by mode: [CodecMode.COPY] asks only what the muxer accepts, + * [CodecMode.ENCODE] additionally asks what this app can encode. + * + * This object is the source of truth for container support. `Media3Muxers` reports a narrower set + * to Transformer — that is the *hardware* subset, and the router decides between them. + */ +object ContainerCapabilities { + + /** Video codecs each container can mux, regardless of whether this app can encode them. */ + private val CARRIES_VIDEO: Map> = mapOf( + Container.MP4 to setOf(VideoCodec.H264, VideoCodec.H265, VideoCodec.VP9, VideoCodec.AV1), + Container.MOV to setOf(VideoCodec.H264, VideoCodec.H265, VideoCodec.VP9, VideoCodec.AV1), + // Matroska is the permissive one: it is a general-purpose container and takes essentially + // any codec. That is what makes it the natural remux target. + Container.MKV to setOf( + VideoCodec.H264, VideoCodec.H265, VideoCodec.VP8, VideoCodec.VP9, VideoCodec.AV1, + ), + Container.WEBM to setOf(VideoCodec.VP8, VideoCodec.VP9, VideoCodec.AV1), + Container.MPEG_TS to setOf(VideoCodec.H264, VideoCodec.H265), + // AVI predates H.265 and has no standard mapping for it. + Container.AVI to setOf(VideoCodec.H264), + Container.FLV to setOf(VideoCodec.H264), + Container.ASF to setOf(VideoCodec.H264), + Container.GIF to emptySet(), + Container.IMAGE_SEQUENCE to emptySet(), + Container.OGG to emptySet(), + Container.WAV to emptySet(), + Container.AAC_ADTS to emptySet(), + Container.MP3 to emptySet(), + Container.FLAC to emptySet(), + ) + + private val CARRIES_AUDIO: Map> = mapOf( + Container.MP4 to setOf(AudioCodec.AAC, AudioCodec.MP3, AudioCodec.OPUS, AudioCodec.FLAC), + Container.MOV to setOf(AudioCodec.AAC, AudioCodec.MP3, AudioCodec.PCM), + Container.MKV to setOf( + AudioCodec.AAC, AudioCodec.OPUS, AudioCodec.VORBIS, + AudioCodec.MP3, AudioCodec.FLAC, AudioCodec.PCM, + ), + Container.WEBM to setOf(AudioCodec.OPUS, AudioCodec.VORBIS), + Container.MPEG_TS to setOf(AudioCodec.AAC, AudioCodec.MP3), + Container.AVI to setOf(AudioCodec.MP3, AudioCodec.PCM, AudioCodec.AAC), + Container.FLV to setOf(AudioCodec.AAC, AudioCodec.MP3), + Container.ASF to setOf(AudioCodec.AAC, AudioCodec.MP3), + Container.OGG to setOf(AudioCodec.OPUS, AudioCodec.VORBIS, AudioCodec.FLAC), + Container.WAV to setOf(AudioCodec.PCM), + Container.AAC_ADTS to setOf(AudioCodec.AAC), + Container.MP3 to setOf(AudioCodec.MP3), + Container.FLAC to setOf(AudioCodec.FLAC), + Container.GIF to emptySet(), + Container.IMAGE_SEQUENCE to emptySet(), + ) + + /** + * Video codecs this app can encode, via either engine. + * + * VP8 and AV1 are absent deliberately: `FFmpegCommandBuilder` has no encoder branch for either, + * and Media3's `setVideoMimeType` rejects both. They remain copyable. + */ + private val ENCODABLE_VIDEO = setOf(VideoCodec.H264, VideoCodec.H265, VideoCodec.VP9) + + /** Vorbis is absent for the same reason: nothing here emits a Vorbis encoder. */ + private val ENCODABLE_AUDIO = setOf( + AudioCodec.AAC, AudioCodec.OPUS, AudioCodec.MP3, AudioCodec.FLAC, AudioCodec.PCM, + ) + + fun accepts(container: Container, codec: VideoCodec, mode: CodecMode): Boolean = when (codec) { + VideoCodec.NONE -> true + VideoCodec.COPY -> error("Resolve COPY to a concrete codec before asking the matrix") + else -> codec in CARRIES_VIDEO.getValue(container) && + (mode == CodecMode.COPY || codec in ENCODABLE_VIDEO) + } + + fun accepts(container: Container, codec: AudioCodec, mode: CodecMode): Boolean = when (codec) { + AudioCodec.NONE -> true + AudioCodec.COPY -> error("Resolve COPY to a concrete codec before asking the matrix") + else -> codec in CARRIES_AUDIO.getValue(container) && + (mode == CodecMode.COPY || codec in ENCODABLE_AUDIO) + } + + /** Every video codec [container] can be asked to produce, for building the picker. */ + fun encodableVideo(container: Container): List = + CARRIES_VIDEO.getValue(container).filter { it in ENCODABLE_VIDEO } + + fun encodableAudio(container: Container): List = + CARRIES_AUDIO.getValue(container).filter { it in ENCODABLE_AUDIO } + + /** + * Checks a user's choice against the input they picked. + * + * Returns alternatives rather than merely refusing, because the Advanced picker deliberately + * lets an incompatible combination be selected: the error has to explain what would work. + */ + fun validate(spec: OutputSpec, probe: InputProbe): Validation { + if (spec.isImageOutput) { + return if (spec.videoCodec == VideoCodec.NONE && spec.audioCodec == AudioCodec.NONE) { + Validation.Valid + } else { + Validation.Invalid( + "${spec.container.label} is an image format and carries no codecs.", + listOf(spec.copy(videoCodec = VideoCodec.NONE, audioCodec = AudioCodec.NONE)), + ) + } + } + + if (spec.videoCodec == VideoCodec.NONE && spec.audioCodec == AudioCodec.NONE) { + return Validation.Invalid( + "This would produce an empty file — keep at least one track.", + suggestions( + // Ask for both tracks back, then let repair settle what this container and + // this input can actually give. + spec.copy(videoCodec = VideoCodec.COPY, audioCodec = AudioCodec.COPY), + probe, + exclude = spec, + ), + ) + } + + if (spec.videoCodec != VideoCodec.NONE && !spec.container.canHoldVideo) { + return Validation.Invalid( + "${spec.container.label} holds audio only.", + suggestions(spec, probe), + ) + } + + validateVideo(spec, probe)?.let { return it } + validateAudio(spec, probe)?.let { return it } + return Validation.Valid + } + + private fun validateVideo(spec: OutputSpec, probe: InputProbe): Validation.Invalid? { + val codec = spec.videoCodec + if (codec == VideoCodec.NONE) return null + + if (codec == VideoCodec.COPY) { + if (!probe.hasVideo) { + return Validation.Invalid( + "This file has no video track to copy.", + listOf(spec.copy(videoCodec = VideoCodec.NONE)), + ) + } + val source = CodecNames.videoFromName(probe.videoCodec) + ?: return Validation.Invalid( + // Never guess. A copy of an unidentified codec is how you ship a file that + // does not play — the same reasoning ConcatPlanner records for the join flow. + "The source video codec could not be identified, so it cannot be copied.", + suggestions(spec, probe), + ) + if (!accepts(spec.container, source, CodecMode.COPY)) { + return Validation.Invalid( + "${spec.container.label} cannot hold ${source.label} video.", + suggestions(spec, probe), + ) + } + return null + } + + if (codec !in CARRIES_VIDEO.getValue(spec.container)) { + return Validation.Invalid( + "${spec.container.label} cannot hold ${codec.label} video.", + suggestions(spec, probe), + ) + } + if (codec !in ENCODABLE_VIDEO) { + return Validation.Invalid( + "This app cannot encode ${codec.label}. It can still be copied from a " + + "${codec.label} source.", + suggestions(spec, probe), + ) + } + return null + } + + private fun validateAudio(spec: OutputSpec, probe: InputProbe): Validation.Invalid? { + val codec = spec.audioCodec + if (codec == AudioCodec.NONE) return null + + if (codec == AudioCodec.COPY) { + val source = CodecNames.audioFromName(probe.audioCodec) + ?: return Validation.Invalid( + "The source audio codec could not be identified, so it cannot be copied.", + suggestions(spec, probe), + ) + if (!accepts(spec.container, source, CodecMode.COPY)) { + return Validation.Invalid( + "${spec.container.label} cannot hold ${source.label} audio.", + suggestions(spec, probe), + ) + } + return null + } + + if (codec !in CARRIES_AUDIO.getValue(spec.container)) { + return Validation.Invalid( + "${spec.container.label} cannot hold ${codec.label} audio.", + suggestions(spec, probe), + ) + } + if (codec !in ENCODABLE_AUDIO) { + return Validation.Invalid( + "This app cannot encode ${codec.label} audio. It can still be copied from a " + + "${codec.label} source.", + suggestions(spec, probe), + ) + } + return null + } + + /** + * Combinations that would work, closest to what was asked for. + * + * Every entry is repaired on *both* codec axes, not just the one that failed. Fixing only the + * offending axis is the obvious implementation and it is wrong: "H.264 in WebM" swaps the video + * to VP9 and leaves AAC behind, which WebM cannot hold either, so the suggestion is as invalid + * as the thing it was meant to fix. `ContainerCapabilitiesTest` asserts every suggestion + * validates, which is what caught that. + */ + private fun suggestions( + spec: OutputSpec, + probe: InputProbe, + /** What not to suggest. Differs from [spec] when the caller repaired it first. */ + exclude: OutputSpec = spec, + ): List { + val containers = buildList { + add(spec.container) + // A container that can hold what the user actually asked for keeps their intent. + firstContainerHolding(spec.videoCodec, probe)?.let(::add) + CodecNames.videoFromName(probe.videoCodec) + ?.let { firstContainerHolding(it, probe) } + ?.let(::add) + } + + return containers.distinct() + .mapNotNull { repair(spec.copy(container = it), probe) } + .filter { it != exclude } + .distinct() + .filter { validate(it, probe).isValid } + .take(3) + } + + /** Best valid spec for this container, preserving as much of the request as possible. */ + private fun repair(spec: OutputSpec, probe: InputProbe): OutputSpec? { + val container = spec.container + if (container == Container.GIF || container == Container.IMAGE_SEQUENCE) return null + + val video = repairVideo(spec, probe) + val audio = repairAudio(spec, probe) + if (video == VideoCodec.NONE && audio == AudioCodec.NONE) return null + return spec.copy(videoCodec = video, audioCodec = audio) + } + + private fun repairVideo(spec: OutputSpec, probe: InputProbe): VideoCodec { + val container = spec.container + if (spec.videoCodec == VideoCodec.NONE || !container.canHoldVideo) return VideoCodec.NONE + + val source = CodecNames.videoFromName(probe.videoCodec).takeIf { probe.hasVideo } + val copyable = source != null && accepts(container, source, CodecMode.COPY) + + return when { + // An explicit copy that works is exactly what was asked for. + spec.videoCodec == VideoCodec.COPY && copyable -> VideoCodec.COPY + // The requested codec is what the source already is, and we cannot encode it — but we + // can carry it across untouched. That is the useful answer for AV1 and VP8. + copyable && source == spec.videoCodec -> VideoCodec.COPY + spec.videoCodec != VideoCodec.COPY && + accepts(container, spec.videoCodec, CodecMode.ENCODE) -> spec.videoCodec + else -> encodableVideo(container).firstOrNull() ?: VideoCodec.NONE + } + } + + private fun repairAudio(spec: OutputSpec, probe: InputProbe): AudioCodec { + val container = spec.container + if (spec.audioCodec == AudioCodec.NONE) return AudioCodec.NONE + + val source = CodecNames.audioFromName(probe.audioCodec) + val copyable = source != null && accepts(container, source, CodecMode.COPY) + + return when { + spec.audioCodec == AudioCodec.COPY && copyable -> AudioCodec.COPY + copyable && source == spec.audioCodec -> AudioCodec.COPY + spec.audioCodec != AudioCodec.COPY && + accepts(container, spec.audioCodec, CodecMode.ENCODE) -> spec.audioCodec + else -> encodableAudio(container).firstOrNull() ?: AudioCodec.NONE + } + } + + /** A container that can hold [codec], preferring the one the input already uses. */ + private fun firstContainerHolding(codec: VideoCodec, probe: InputProbe): Container? { + if (codec == VideoCodec.NONE || codec == VideoCodec.COPY) return null + val holders = Container.entries.filter { codec in CARRIES_VIDEO.getValue(it) } + return holders.firstOrNull { it == probe.container } ?: holders.firstOrNull() + } +} + +/** Whether a track is being copied through or re-encoded. */ +enum class CodecMode { COPY, ENCODE } + +sealed interface Validation { + data object Valid : Validation + + /** + * @param suggestions combinations that would work. Never empty in practice, and the Advanced + * picker renders them as one-tap fixes; every entry is itself valid, which + * `ContainerCapabilitiesTest` asserts. + */ + data class Invalid(val message: String, val suggestions: List) : Validation + + val isValid: Boolean get() = this is Valid +} diff --git a/app/src/main/java/org/libremediaconverter/model/ConversionRouter.kt b/app/src/main/java/org/libremediaconverter/model/ConversionRouter.kt index 49c3fd3..06afebd 100644 --- a/app/src/main/java/org/libremediaconverter/model/ConversionRouter.kt +++ b/app/src/main/java/org/libremediaconverter/model/ConversionRouter.kt @@ -10,6 +10,15 @@ package org.libremediaconverter.model * * The rules below are capability boundaries, not preferences. Each one exists because * Media3 would fail or silently produce the wrong thing. + * + * ## Every rule asks the plan, not the request + * + * [CopyPlanner] resolves [VideoCodec.COPY] into a concrete per-track decision *before* any rule + * runs, and each rule below consults that plan rather than the user's raw choice. The ordering is + * load-bearing: `COPY` belongs to none of the capability sets, so a rule that tested the request + * directly would send every remux to FFmpeg on the first check and Media3's transmux path would + * never run. Worse, nothing would notice — FFmpeg's `-c copy` produces a perfectly correct file, + * just more slowly and on the CPU. */ object ConversionRouter { @@ -28,15 +37,40 @@ object ConversionRouter { 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) + /** + * What Media3's muxers can *carry*, as distinct from what Media3 can encode. + * + * Only stream copy makes the difference visible, and it is not a small one: Media3's MP4 muxer + * accepts AAC, Opus, Vorbis and PCM but neither MP3 nor FLAC, so remuxing an MP3 track into MP4 + * — legal, and something FFmpeg does without complaint — has to leave the hardware path. Before + * remuxing existed nothing could reach that combination, so nothing had to know. + * + * Transcribed from each `Muxer.Factory.getSupportedSampleMimeTypes` in `Media3Muxers`; + * `Media3MuxersTest` asserts the transcription still matches. + */ + internal val MEDIA3_MUXABLE_VIDEO: Map> = mapOf( + Container.MP4 to setOf(VideoCodec.H264, VideoCodec.H265, VideoCodec.VP9, VideoCodec.AV1), + Container.WEBM to setOf(VideoCodec.VP8, VideoCodec.VP9), + Container.OGG to emptySet(), + Container.WAV to emptySet(), + Container.AAC_ADTS to emptySet(), + ) - /** Video codecs Media3 can be asked to encode (`Transformer.setVideoMimeType`). */ - private val MEDIA3_VIDEO = setOf(VideoCodec.H264, VideoCodec.H265, VideoCodec.NONE) + internal val MEDIA3_MUXABLE_AUDIO: Map> = mapOf( + Container.MP4 to setOf( + AudioCodec.AAC, AudioCodec.OPUS, AudioCodec.VORBIS, AudioCodec.PCM, + ), + Container.WEBM to setOf(AudioCodec.OPUS, AudioCodec.VORBIS), + Container.OGG to setOf(AudioCodec.OPUS, AudioCodec.VORBIS), + Container.WAV to setOf(AudioCodec.PCM), + Container.AAC_ADTS to setOf(AudioCodec.AAC), + ) + + /** Video codecs Media3 can encode (`Transformer.setVideoMimeType`). */ + private val MEDIA3_VIDEO = setOf(VideoCodec.H264, VideoCodec.H265) /** Audio codecs Media3 can encode. Notably absent: MP3 and FLAC. */ - private val MEDIA3_AUDIO = setOf(AudioCodec.AAC, AudioCodec.OPUS, AudioCodec.PCM, AudioCodec.NONE) + private val MEDIA3_AUDIO = setOf(AudioCodec.AAC, AudioCodec.OPUS, AudioCodec.PCM) fun route(request: ConversionRequest, device: DeviceCodecs): Decision { when (request.enginePreference) { @@ -45,6 +79,10 @@ object ConversionRouter { else -> Unit } + val plan = CopyPlanner.plan(request.spec, request.probe) + val videoEncode = (plan.video as? VideoPlan.Encode)?.codec + val audioEncode = (plan.audio as? AudioPlan.Encode)?.codec + // 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; @@ -52,36 +90,59 @@ object ConversionRouter { // 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) { + // can produce MP3 at all. Asked only of a track being encoded: copying an MP3 + // stream into a different container needs no encoder and stays on hardware. + if (audioEncode != null && audioEncode !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) { + if (request.spec.isImageOutput) { return Decision(Engine.FFMPEG, Reason.IMAGE_OUTPUT) } - // Containers Media3 cannot mux at all, chiefly Matroska. - if (request.format.container !in MEDIA3_CONTAINERS) { + // Containers Media3 cannot mux at all, chiefly Matroska. This is about the *output*: + // Media3 reads Matroska perfectly well, which is what makes MKV -> MP4 a hardware remux. + if (plan.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) + // The container is supported but its muxer is codec-restricted. What matters is what ends + // up in the file, so a copied track is judged by its source codec rather than the request. + if (!media3CanMux(plan, request.probe)) { + return Decision( + Engine.FFMPEG, + if (plan.container == Container.WEBM) { + Reason.WEBM_CODEC_UNSUPPORTED + } else { + Reason.CONTAINER_CODEC_UNSUPPORTED + }, + ) + } + + // A file the platform extractor could not open cannot be read at all, copied or not. + if (request.probe.videoCodec == InputProbe.UNPARSEABLE) { + return Decision(Engine.FFMPEG, Reason.NO_PLATFORM_DECODER) } // 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. + // + // Only relevant for a track being re-encoded — a stream copy never decodes, which is + // precisely why remuxing an exotic codec into a new container still works on hardware. val inputCodec = request.probe.videoCodec - if (inputCodec != null && !device.canDecode(inputCodec)) { + if (videoEncode != null && inputCodec != null && !device.canDecode(inputCodec)) { return Decision(Engine.FFMPEG, Reason.NO_PLATFORM_DECODER) } + // Nothing is re-encoded, so none of the encoder rules below apply and neither does the + // quality tier: there is no quality decision to make when no samples are recompressed. + // This is the fast path the remux feature exists for. + if (plan.isPureRemux) { + return Decision(Engine.MEDIA3, Reason.REMUX_NO_REENCODE) + } + // 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) { @@ -90,27 +151,52 @@ object ConversionRouter { // 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) { + if (videoEncode != null && videoEncode !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) - ) { + if (videoEncode != null && !device.canEncode(videoEncode)) { return Decision(Engine.FFMPEG, Reason.NO_HARDWARE_ENCODER) } return Decision(Engine.MEDIA3, Reason.HARDWARE_CAPABLE) } + /** Whether Media3's muxer for this container can carry what the plan will produce. */ + private fun media3CanMux(plan: ConversionPlan, probe: InputProbe): Boolean { + val video = when (val v = plan.video) { + is VideoPlan.Encode -> v.codec + VideoPlan.Copy -> CodecNames.videoFromName(probe.videoCodec) + VideoPlan.Drop -> null + } + val audio = when (val a = plan.audio) { + is AudioPlan.Encode -> a.codec + AudioPlan.Copy -> CodecNames.audioFromName(probe.audioCodec) + AudioPlan.Drop -> null + } + + // A copied track whose codec we could not name is unproven, not permitted. CopyPlanner + // should already have refused it, so this is the second line of defence. + if (plan.video == VideoPlan.Copy && video == null) return false + if (plan.audio == AudioPlan.Copy && audio == null) return false + + val muxableVideo = MEDIA3_MUXABLE_VIDEO[plan.container].orEmpty() + val muxableAudio = MEDIA3_MUXABLE_AUDIO[plan.container].orEmpty() + if (video != null && video !in muxableVideo) return false + if (audio != null && audio !in muxableAudio) return false + return true + } + data class Decision(val engine: Engine, val reason: Reason) enum class Reason(val explanation: String) { HARDWARE_CAPABLE("Hardware accelerated"), + REMUX_NO_REENCODE("Remuxed — streams copied, nothing re-encoded"), CONTAINER_UNSUPPORTED("This container needs FFmpeg"), WEBM_CODEC_UNSUPPORTED("WebM only supports VP8/VP9 with Opus or Vorbis"), + CONTAINER_CODEC_UNSUPPORTED("That codec needs FFmpeg for this container"), 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"), diff --git a/app/src/main/java/org/libremediaconverter/model/CopyPlanner.kt b/app/src/main/java/org/libremediaconverter/model/CopyPlanner.kt new file mode 100644 index 0000000..3542944 --- /dev/null +++ b/app/src/main/java/org/libremediaconverter/model/CopyPlanner.kt @@ -0,0 +1,119 @@ +package org.libremediaconverter.model + +/** What happens to the video track. */ +sealed interface VideoPlan { + /** Stream copy — the samples are muxed across untouched. */ + data object Copy : VideoPlan + data class Encode(val codec: VideoCodec) : VideoPlan + data object Drop : VideoPlan +} + +/** What happens to the audio track. */ +sealed interface AudioPlan { + data object Copy : AudioPlan + data class Encode(val codec: AudioCodec) : AudioPlan + data object Drop : AudioPlan +} + +/** + * A resolved conversion: no [VideoCodec.COPY] left to interpret, every track decided. + */ +data class ConversionPlan( + val container: Container, + val video: VideoPlan, + val audio: AudioPlan, +) { + /** No track is re-encoded and at least one is copied: a container change and nothing more. */ + val isPureRemux: Boolean + get() = video !is VideoPlan.Encode && audio !is AudioPlan.Encode && + (video is VideoPlan.Copy || audio is AudioPlan.Copy) + + val hasVideo: Boolean get() = video != VideoPlan.Drop +} + +/** + * Turns a requested [OutputSpec] plus what is known about the input into a concrete plan. + * + * ## Unknown is not a match + * + * When the source codec cannot be identified — [InputProbe.UNPARSEABLE], or FFprobe reported + * something this app does not recognise — a copy is never attempted. It falls back to re-encoding. + * This is the same rule [ConcatPlanner] states for the join flow, for the same reason: a needless + * re-encode costs time, while a wrong stream copy costs the user a file that will not play, and + * they may not find out until long after the source is gone. + * + * ## Why a matching codec is not automatically a copy + * + * When the user asks for H.264 and the source is already H.264, copying is usually what they want + * — but only if the *container* is changing. If both container and codec already match, the only + * reason to run the job at all is to re-encode it, most likely to make it smaller, and silently + * copying would hand back a byte-identical file and call it done. + * + * That rule assumes re-encoding is the only other reason to convert. If bitrate, resolution or + * frame-rate controls are ever added, this is the decision that has to be revisited: at that point + * "same container, same codec" stops implying "compress it". + */ +object CopyPlanner { + + fun plan(spec: OutputSpec, probe: InputProbe): ConversionPlan = ConversionPlan( + container = spec.container, + video = planVideo(spec, probe), + audio = planAudio(spec, probe), + ) + + private fun planVideo(spec: OutputSpec, probe: InputProbe): VideoPlan { + val requested = spec.videoCodec + if (requested == VideoCodec.NONE) return VideoPlan.Drop + if (!probe.hasVideo) return VideoPlan.Drop + + val source = CodecNames.videoFromName(probe.videoCodec) + + if (requested == VideoCodec.COPY) { + val copyable = source != null && + ContainerCapabilities.accepts(spec.container, source, CodecMode.COPY) + if (copyable) return VideoPlan.Copy + // Asked to copy but cannot prove it is safe. Re-encode rather than guess; the picker + // normally refuses this combination before it gets here, so this is the belt to + // validation's braces — a stale queued job must not turn into a corrupt file. + return fallbackVideoEncode(spec.container) + } + + val shouldCopyInstead = source == requested && + probe.container != null && + probe.container != spec.container && + ContainerCapabilities.accepts(spec.container, requested, CodecMode.COPY) + + return if (shouldCopyInstead) VideoPlan.Copy else VideoPlan.Encode(requested) + } + + private fun planAudio(spec: OutputSpec, probe: InputProbe): AudioPlan { + val requested = spec.audioCodec + if (requested == AudioCodec.NONE) return AudioPlan.Drop + + val source = CodecNames.audioFromName(probe.audioCodec) + + if (requested == AudioCodec.COPY) { + val copyable = source != null && + ContainerCapabilities.accepts(spec.container, source, CodecMode.COPY) + if (copyable) return AudioPlan.Copy + return fallbackAudioEncode(spec.container) + } + + val shouldCopyInstead = source == requested && + probe.container != null && + probe.container != spec.container && + ContainerCapabilities.accepts(spec.container, requested, CodecMode.COPY) + + return if (shouldCopyInstead) AudioPlan.Copy else AudioPlan.Encode(requested) + } + + private fun fallbackVideoEncode(container: Container): VideoPlan = + ContainerCapabilities.encodableVideo(container).firstOrNull() + ?.let(VideoPlan::Encode) + ?: VideoPlan.Drop + + private fun fallbackAudioEncode(container: Container): AudioPlan = + ContainerCapabilities.encodableAudio(container).firstOrNull() + ?.let(AudioPlan::Encode) + ?: AudioPlan.Drop +} diff --git a/app/src/main/java/org/libremediaconverter/model/OutputFormat.kt b/app/src/main/java/org/libremediaconverter/model/OutputFormat.kt index c0ce6bd..1ff906e 100644 --- a/app/src/main/java/org/libremediaconverter/model/OutputFormat.kt +++ b/app/src/main/java/org/libremediaconverter/model/OutputFormat.kt @@ -1,45 +1,153 @@ package org.libremediaconverter.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 } +/** + * Container families. + * + * Each entry owns the three things that used to be duplicated per preset: the FFmpeg muxer name, + * the file extension, and the MIME type handed to the SAF `CreateDocument` contract. Keeping them + * here is what makes an open container × codec matrix possible — a preset no longer has to exist + * for every combination someone might want. + * + * Extension and MIME type depend on whether a video track survives: Matroska with video is `.mkv` + * and without it `.mka`, MP4 is `.mp4` or `.m4a`. That distinction is why they are functions rather + * than properties. + * + * @param ffmpegFormat the `-f` value. Named explicitly rather than left to extension inference, + * which is unreliable for MPEG-TS and ASF. + */ +enum class Container( + val label: String, + val ffmpegFormat: String, + private val videoExtension: String?, + private val audioExtension: String, + private val videoMime: String?, + private val audioMime: String, +) { + MP4("MP4", "mp4", "mp4", "m4a", "video/mp4", "audio/mp4"), + MOV("MOV", "mov", "mov", "m4a", "video/quicktime", "audio/mp4"), + MKV("Matroska", "matroska", "mkv", "mka", "video/x-matroska", "audio/x-matroska"), + WEBM("WebM", "webm", "webm", "weba", "video/webm", "audio/webm"), + MPEG_TS("MPEG-TS", "mpegts", "ts", "ts", "video/mp2t", "video/mp2t"), + AVI("AVI", "avi", "avi", "avi", "video/x-msvideo", "video/x-msvideo"), + FLV("FLV", "flv", "flv", "flv", "video/x-flv", "video/x-flv"), + ASF("WMV/ASF", "asf", "wmv", "wma", "video/x-ms-wmv", "audio/x-ms-wma"), -/** 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 } + OGG("Ogg", "ogg", null, "opus", null, "audio/ogg"), + WAV("WAV", "wav", null, "wav", null, "audio/wav"), + AAC_ADTS("AAC", "adts", null, "aac", null, "audio/aac"), + MP3("MP3", "mp3", null, "mp3", null, "audio/mpeg"), + FLAC("FLAC", "flac", null, "flac", null, "audio/flac"), + + GIF("GIF", "gif", "gif", "gif", "image/gif", "image/gif"), + IMAGE_SEQUENCE("PNG frames", "image2", "png", "png", "image/png", "image/png"), + ; + + /** Whether this container can hold a video track at all. */ + val canHoldVideo: Boolean get() = videoExtension != null + + fun extensionFor(hasVideo: Boolean): String = + if (hasVideo) videoExtension ?: audioExtension else audioExtension + + fun mimeTypeFor(hasVideo: Boolean): String = + if (hasVideo) videoMime ?: audioMime else audioMime +} /** - * A user-selectable output format. + * Codecs this app can be asked to produce. * - * 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. + * [COPY] is a first-class value rather than a flag alongside them: "keep whatever the source has" + * sits in exactly the same slot as "make it H.264", and modelling it as a codec means every + * exhaustive `when` in the codebase is forced to say what it does about copying. */ -enum class OutputFormat( - val label: String, +enum class VideoCodec(val label: String) { + COPY("Copy"), + H264("H.264"), + H265("H.265"), + VP9("VP9"), + VP8("VP8"), + AV1("AV1"), + NONE("None"), +} + +enum class AudioCodec(val label: String) { + COPY("Copy"), + AAC("AAC"), + OPUS("Opus"), + VORBIS("Vorbis"), + MP3("MP3"), + FLAC("FLAC"), + PCM("PCM"), + NONE("None"), +} + +/** + * A concrete output: one container, one choice per track. + * + * This replaces the closed enum of twelve triples that used to be the only way to describe an + * output. The closed set was defended on the grounds that it made routing decidable; decidability + * now comes from [ContainerCapabilities] instead, which is explicit and unit-tested rather than + * implicit in which combinations someone remembered to enumerate. + */ +data class OutputSpec( 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"), + /** Whether the output keeps a video track — the thing extension and MIME type turn on. */ + val hasVideo: Boolean get() = videoCodec != VideoCodec.NONE - 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"), + val isAudioOnly: Boolean + get() = videoCodec == VideoCodec.NONE && audioCodec != AudioCodec.NONE - 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 + + /** True when neither track is re-encoded, i.e. this is a pure container change. */ + val isPureRemux: Boolean + get() = videoCodec.isCopyOrAbsent() && audioCodec.isCopyOrAbsent() && + (videoCodec == VideoCodec.COPY || audioCodec == AudioCodec.COPY) + + val extension: String get() = container.extensionFor(hasVideo) + val mimeType: String get() = container.mimeTypeFor(hasVideo) + + private fun VideoCodec.isCopyOrAbsent() = this == VideoCodec.COPY || this == VideoCodec.NONE + private fun AudioCodec.isCopyOrAbsent() = this == AudioCodec.COPY || this == AudioCodec.NONE +} + +/** + * The one-tap presets. + * + * Still a closed list, but it is now a convenience layer over [OutputSpec] rather than the only + * vocabulary available. Anything not here is reachable through the Advanced picker. + */ +enum class OutputFormat(val label: String, val spec: OutputSpec) { + MP4_H264("MP4 (H.264)", OutputSpec(Container.MP4, VideoCodec.H264, AudioCodec.AAC)), + MP4_H265("MP4 (H.265)", OutputSpec(Container.MP4, VideoCodec.H265, AudioCodec.AAC)), + WEBM_VP9("WebM (VP9)", OutputSpec(Container.WEBM, VideoCodec.VP9, AudioCodec.OPUS)), + MKV_H264("MKV (H.264)", OutputSpec(Container.MKV, VideoCodec.H264, AudioCodec.AAC)), + MKV_H265("MKV (H.265)", OutputSpec(Container.MKV, VideoCodec.H265, AudioCodec.AAC)), + + /** Container change only. The headline of the remux feature, given a preset of its own. */ + REMUX_MP4("Remux to MP4", OutputSpec(Container.MP4, VideoCodec.COPY, AudioCodec.COPY)), + REMUX_MKV("Remux to MKV", OutputSpec(Container.MKV, VideoCodec.COPY, AudioCodec.COPY)), + + MP3("MP3", OutputSpec(Container.MP3, VideoCodec.NONE, AudioCodec.MP3)), + M4A_AAC("M4A (AAC)", OutputSpec(Container.MP4, VideoCodec.NONE, AudioCodec.AAC)), + OPUS("Opus", OutputSpec(Container.OGG, VideoCodec.NONE, AudioCodec.OPUS)), + FLAC("FLAC", OutputSpec(Container.FLAC, VideoCodec.NONE, AudioCodec.FLAC)), + WAV("WAV", OutputSpec(Container.WAV, VideoCodec.NONE, AudioCodec.PCM)), + + GIF("GIF", OutputSpec(Container.GIF, VideoCodec.NONE, AudioCodec.NONE)), + FRAMES_PNG("PNG frames", OutputSpec(Container.IMAGE_SEQUENCE, VideoCodec.NONE, AudioCodec.NONE)), + ; + + val container: Container get() = spec.container + val videoCodec: VideoCodec get() = spec.videoCodec + val audioCodec: AudioCodec get() = spec.audioCodec + val extension: String get() = spec.extension + val mimeType: String get() = spec.mimeType + val isAudioOnly: Boolean get() = spec.isAudioOnly + val isImageOutput: Boolean get() = spec.isImageOutput } /** @@ -59,12 +167,33 @@ 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. */ +/** What kind of file the input turned out to be. */ +enum class InputKind { + VIDEO, + AUDIO_ONLY, + IMAGE, + + /** Nothing could read it. Distinct from the others, and never evidence of a codec match. */ + UNPARSEABLE, +} + +/** What we know about the input, as far as routing and stream copy are concerned. */ data class InputProbe( val videoCodec: String? = null, val audioCodec: String? = null, val hasVideo: Boolean = true, val durationMs: Long = 0, + val kind: InputKind = InputKind.VIDEO, + /** + * The source container, when it could be identified. + * + * `MediaExtractor` cannot report this at all, so it comes from FFprobe. It matters because + * [CopyPlanner] only upgrades a matching codec to a stream copy when the container is actually + * changing — see its `plan` KDoc. + */ + val container: Container? = null, + val width: Int = 0, + val height: Int = 0, ) { companion object { /** @@ -80,7 +209,7 @@ data class InputProbe( } data class ConversionRequest( - val format: OutputFormat, + val spec: OutputSpec, val quality: QualityTier = QualityTier.FAST, val enginePreference: EnginePreference = EnginePreference.AUTO, val probe: InputProbe = InputProbe(), @@ -93,4 +222,8 @@ data class ConversionRequest( * genuinely fast software preset instead of a mislabelled slow one. */ val hardwareEncodeAvailable: Boolean = true, -) +) { + val container: Container get() = spec.container + val videoCodec: VideoCodec get() = spec.videoCodec + val audioCodec: AudioCodec get() = spec.audioCodec +} diff --git a/app/src/main/java/org/libremediaconverter/work/ConversionWorker.kt b/app/src/main/java/org/libremediaconverter/work/ConversionWorker.kt index f05a577..ea28f12 100644 --- a/app/src/main/java/org/libremediaconverter/work/ConversionWorker.kt +++ b/app/src/main/java/org/libremediaconverter/work/ConversionWorker.kt @@ -14,12 +14,18 @@ import androidx.work.workDataOf import com.arthenica.ffmpegkit.FFmpegKitConfig import org.libremediaconverter.convert.ConversionDependencies import org.libremediaconverter.convert.MediaProbe +import org.libremediaconverter.model.AudioCodec +import org.libremediaconverter.model.Container +import org.libremediaconverter.model.ContainerCapabilities import org.libremediaconverter.model.ConversionRequest import org.libremediaconverter.model.ConversionRouter import org.libremediaconverter.model.Engine import org.libremediaconverter.model.EnginePreference import org.libremediaconverter.model.OutputFormat +import org.libremediaconverter.model.OutputSpec import org.libremediaconverter.model.QualityTier +import org.libremediaconverter.model.Validation +import org.libremediaconverter.model.VideoCodec import java.io.File /** @@ -48,9 +54,7 @@ class ConversionWorker( ?: 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 format = OutputFormat.valueOf( - inputData.getString(KEY_FORMAT) ?: OutputFormat.MP4_H265.name - ) + val spec = readSpec() val quality = QualityTier.valueOf( inputData.getString(KEY_QUALITY) ?: QualityTier.FAST.name ) @@ -67,16 +71,26 @@ class ConversionWorker( val probe = MediaProbe.probe(applicationContext, inputUri) val devices = ConversionDependencies.deviceCodecs() val request = ConversionRequest( - format = format, + spec = spec, quality = quality, enginePreference = preference, probe = probe, - hardwareEncodeAvailable = devices.canEncode(format.videoCodec), + hardwareEncodeAvailable = devices.canEncode(spec.videoCodec), ) - val decision = ConversionRouter.route(request, devices) - Log.i(TAG, "Routing $displayName -> ${format.name} via ${decision.engine} (${decision.reason})") + // The picker refuses an impossible combination before Convert is tappable, but a job can + // also arrive from a queued request made before the settings changed, or from a direct + // ConversionWorker.request(...) call. Checking here means an invalid spec fails with the + // reason rather than being silently coerced into something else. + val validation = ContainerCapabilities.validate(spec, probe) + if (validation is Validation.Invalid) { + Log.w(TAG, "Refusing $spec for $displayName: ${validation.message}") + return Result.failure(workDataOf(KEY_ERROR to validation.message)) + } - val staged = publisher.createStagingFile(outputNameFor(displayName, format)) + val decision = ConversionRouter.route(request, devices) + Log.i(TAG, "Routing $displayName -> $spec via ${decision.engine} (${decision.reason})") + + val staged = publisher.createStagingFile(outputNameFor(displayName, spec)) return try { when (decision.engine) { @@ -113,7 +127,7 @@ class ConversionWorker( ) { val engine = ConversionDependencies.hardware(applicationContext) try { - engine.transcode(inputUri, staged, request.format) { percent -> + engine.transcode(inputUri, staged, request) { percent -> publishProgress(displayName, percent) } return @@ -187,6 +201,28 @@ class ConversionWorker( } } + /** + * Reads the output spec out of the worker's input Data. + * + * Carried as three separate strings rather than one preset name: the picker can now produce + * combinations no preset covers, so there is no enum entry to name. Unknown or missing values + * fall back to the default preset rather than throwing — a worker that crashes on malformed + * input reports "conversion failed" with no useful message. + */ + private fun readSpec(): OutputSpec { + val fallback = OutputFormat.MP4_H265.spec + val container = inputData.getString(KEY_CONTAINER) + ?.let { name -> Container.entries.firstOrNull { it.name == name } } + ?: return fallback + val video = inputData.getString(KEY_VIDEO_CODEC) + ?.let { name -> VideoCodec.entries.firstOrNull { it.name == name } } + ?: return fallback + val audio = inputData.getString(KEY_AUDIO_CODEC) + ?.let { name -> AudioCodec.entries.firstOrNull { it.name == name } } + ?: return fallback + return OutputSpec(container, video, audio) + } + override suspend fun getForegroundInfo(): ForegroundInfo = foregroundInfo( inputData.getString(KEY_DISPLAY_NAME) ?: "input", @@ -205,7 +241,9 @@ class ConversionWorker( const val KEY_INPUT_URI = "input_uri" const val KEY_DISPLAY_NAME = "display_name" const val KEY_SIZE_BYTES = "size_bytes" - const val KEY_FORMAT = "format" + const val KEY_CONTAINER = "container" + const val KEY_VIDEO_CODEC = "video_codec" + const val KEY_AUDIO_CODEC = "audio_codec" const val KEY_QUALITY = "quality" const val KEY_ENGINE_PREFERENCE = "engine_preference" const val KEY_PROGRESS = "progress" @@ -218,15 +256,22 @@ class ConversionWorker( private const val NOTIFICATION_INTERVAL_MS = 1_000L private const val TAG = "ConversionWorker" - fun outputNameFor(inputName: String, format: OutputFormat): String = + /** + * The staged and suggested filename. + * + * The extension comes from the container and whether a video track survives, so Matroska + * yields `.mkv` or `.mka` and MP4 yields `.mp4` or `.m4a` without a preset having to + * enumerate both. + */ + fun outputNameFor(inputName: String, spec: OutputSpec): String = inputName.substringBeforeLast('.', inputName) + - "_converted.${format.extension}" + "_converted.${spec.extension}" fun request( inputUri: Uri, displayName: String, sizeBytes: Long, - format: OutputFormat = OutputFormat.MP4_H265, + spec: OutputSpec = OutputFormat.MP4_H265.spec, quality: QualityTier = QualityTier.FAST, enginePreference: EnginePreference = EnginePreference.AUTO, ) = OneTimeWorkRequestBuilder() @@ -235,7 +280,9 @@ class ConversionWorker( .putString(KEY_INPUT_URI, inputUri.toString()) .putString(KEY_DISPLAY_NAME, displayName) .putLong(KEY_SIZE_BYTES, sizeBytes) - .putString(KEY_FORMAT, format.name) + .putString(KEY_CONTAINER, spec.container.name) + .putString(KEY_VIDEO_CODEC, spec.videoCodec.name) + .putString(KEY_AUDIO_CODEC, spec.audioCodec.name) .putString(KEY_QUALITY, quality.name) .putString(KEY_ENGINE_PREFERENCE, enginePreference.name) .build() diff --git a/app/src/test/java/org/libremediaconverter/convert/Media3MuxersTest.kt b/app/src/test/java/org/libremediaconverter/convert/Media3MuxersTest.kt index 4a4b314..73c97a3 100644 --- a/app/src/test/java/org/libremediaconverter/convert/Media3MuxersTest.kt +++ b/app/src/test/java/org/libremediaconverter/convert/Media3MuxersTest.kt @@ -4,12 +4,17 @@ import androidx.media3.common.C import androidx.media3.common.MimeTypes import androidx.media3.common.util.UnstableApi import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test +import org.libremediaconverter.model.AudioCodec +import org.libremediaconverter.model.CodecMode import org.libremediaconverter.model.Container +import org.libremediaconverter.model.ContainerCapabilities import org.libremediaconverter.model.ConversionRouter +import org.libremediaconverter.model.VideoCodec /** * Guards the agreement between the router's container set and the muxers that back it. @@ -95,4 +100,92 @@ class Media3MuxersTest { .getSupportedSampleMimeTypes(C.TRACK_TYPE_AUDIO), ) } + + /** + * The router's transcription of what Media3 can carry must match the factories themselves. + * + * `ConversionRouter` cannot import a `Muxer.Factory` — `model/` is deliberately free of Android + * and Media3 types so the routing rules stay JVM-testable — so it restates these sets by hand. + * Two hand-written answers to one question drift; this is what stops them. + * + * The check runs against the factory's own `getSupportedSampleMimeTypes`, which is what + * `Transformer` consults, so it is ground truth rather than another transcription. + */ + @Test + fun `the router's muxable sets match what the factories report`() { + ConversionRouter.MEDIA3_CONTAINERS.forEach { container -> + val factory = requireNotNull(Media3Muxers.factoryFor(container)) + + val reportedVideo = factory.getSupportedSampleMimeTypes(C.TRACK_TYPE_VIDEO) + .mapNotNull(::videoCodecOf).toSet() + val reportedAudio = factory.getSupportedSampleMimeTypes(C.TRACK_TYPE_AUDIO) + .mapNotNull(::audioCodecOf).toSet() + + assertEquals( + "$container video", + reportedVideo, + ConversionRouter.MEDIA3_MUXABLE_VIDEO[container].orEmpty(), + ) + assertEquals( + "$container audio", + reportedAudio, + ConversionRouter.MEDIA3_MUXABLE_AUDIO[container].orEmpty(), + ) + } + } + + /** + * Records the two places Media3's muxers and the app-wide matrix deliberately disagree. + * + * Neither is a bug, and pinning them is the point: if either moves, one of the two documents + * has changed its mind and somebody should say so on purpose. + * + * - Media3's MP4 muxer accepts Vorbis; [ContainerCapabilities] declines to offer it, because + * Vorbis-in-MP4 is poorly supported by players. + * - The matrix offers MP3 and FLAC in MP4, which is legal and which FFmpeg writes happily, but + * Media3's MP4 muxer carries neither — so those jobs route to FFmpeg rather than failing. + */ + @Test + fun `the documented divergences between Media3 and the matrix still hold`() { + val media3Mp4Audio = ConversionRouter.MEDIA3_MUXABLE_AUDIO.getValue(Container.MP4) + + assertTrue( + "Media3 still takes Vorbis in MP4", + AudioCodec.VORBIS in media3Mp4Audio, + ) + assertFalse( + "the matrix still declines to offer it", + ContainerCapabilities.accepts(Container.MP4, AudioCodec.VORBIS, CodecMode.COPY), + ) + + listOf(AudioCodec.MP3, AudioCodec.FLAC).forEach { codec -> + assertTrue( + "the matrix still allows $codec in MP4", + ContainerCapabilities.accepts(Container.MP4, codec, CodecMode.COPY), + ) + assertFalse( + "Media3's MP4 muxer still cannot carry $codec", + codec in media3Mp4Audio, + ) + } + } + + private fun videoCodecOf(mime: String): VideoCodec? = when (mime) { + MimeTypes.VIDEO_H264 -> VideoCodec.H264 + MimeTypes.VIDEO_H265 -> VideoCodec.H265 + MimeTypes.VIDEO_VP8 -> VideoCodec.VP8 + MimeTypes.VIDEO_VP9 -> VideoCodec.VP9 + MimeTypes.VIDEO_AV1 -> VideoCodec.AV1 + else -> null + } + + private fun audioCodecOf(mime: String): AudioCodec? = when (mime) { + MimeTypes.AUDIO_AAC -> AudioCodec.AAC + MimeTypes.AUDIO_OPUS -> AudioCodec.OPUS + MimeTypes.AUDIO_VORBIS -> AudioCodec.VORBIS + MimeTypes.AUDIO_RAW -> AudioCodec.PCM + MimeTypes.AUDIO_MPEG -> AudioCodec.MP3 + MimeTypes.AUDIO_FLAC -> AudioCodec.FLAC + else -> null + } } diff --git a/app/src/test/java/org/libremediaconverter/convert/MediaProbeFormatTest.kt b/app/src/test/java/org/libremediaconverter/convert/MediaProbeFormatTest.kt new file mode 100644 index 0000000..8fa8a2c --- /dev/null +++ b/app/src/test/java/org/libremediaconverter/convert/MediaProbeFormatTest.kt @@ -0,0 +1,86 @@ +package org.libremediaconverter.convert + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.libremediaconverter.model.Container + +/** + * Reading FFprobe's `format_name`, which is the only source of the source container. + * + * The trap is that FFprobe names every format sharing the demuxer, not the one the file actually + * is: a plain MP4 comes back as `mov,mp4,m4a,3gp,3g2,mj2`. Matching the whole string against a + * container name never succeeds, and taking the first entry reports every MP4 as a MOV. + * + * Getting this wrong fails silently rather than loudly — a null container just means `CopyPlanner` + * cannot tell the container is changing, so it declines to upgrade to a stream copy and everything + * is re-encoded. The feature would look like it simply did not work. + */ +class MediaProbeFormatTest { + + @Test + fun `an MP4 is not mistaken for a MOV`() { + assertEquals( + Container.MP4, + MediaProbe.containerFrom("mov,mp4,m4a,3gp,3g2,mj2"), + ) + } + + @Test + fun `a real QuickTime file is recognised`() { + assertEquals(Container.MOV, MediaProbe.containerFrom("mov")) + } + + /** + * WebM and Matroska share a demuxer and report the same format name. + * + * Both committed fixtures — an H.264 `.mkv` and a VP9 `.webm` — come back as `matroska,webm`, + * so the string alone cannot separate them. The codec is the only remaining signal: WebM + * permits VP8/VP9/AV1 and nothing else. + */ + @Test + fun `a Matroska file carrying H264 is not reported as WebM`() { + assertEquals(Container.MKV, MediaProbe.containerFrom("matroska,webm", "h264")) + } + + @Test + fun `a WebM-legal codec in that container is reported as WebM`() { + assertEquals(Container.WEBM, MediaProbe.containerFrom("matroska,webm", "vp9")) + assertEquals(Container.WEBM, MediaProbe.containerFrom("matroska,webm", "av1")) + } + + /** With no video track there is nothing to disambiguate on; Matroska is the general case. */ + @Test + fun `an audio-only Matroska file is reported as Matroska`() { + assertEquals(Container.MKV, MediaProbe.containerFrom("matroska,webm", null)) + } + + @Test + fun `the remaining containers map to themselves`() { + assertEquals(Container.MPEG_TS, MediaProbe.containerFrom("mpegts")) + assertEquals(Container.AVI, MediaProbe.containerFrom("avi")) + assertEquals(Container.FLV, MediaProbe.containerFrom("flv")) + assertEquals(Container.ASF, MediaProbe.containerFrom("asf")) + assertEquals(Container.WAV, MediaProbe.containerFrom("wav")) + assertEquals(Container.MP3, MediaProbe.containerFrom("mp3")) + assertEquals(Container.FLAC, MediaProbe.containerFrom("flac")) + assertEquals(Container.OGG, MediaProbe.containerFrom("ogg")) + } + + @Test + fun `whitespace and case do not matter`() { + assertEquals(Container.MP4, MediaProbe.containerFrom("MOV, MP4, M4A")) + } + + /** + * Unknown means unknown, not a guess. + * + * A wrong container would let the copy planner believe the container is changing when it is + * not, or the reverse — and the planner's whole discipline is that it never acts on a guess. + */ + @Test + fun `an unrecognised format name yields null rather than a guess`() { + assertNull(MediaProbe.containerFrom("some_new_format")) + assertNull(MediaProbe.containerFrom("")) + } +} diff --git a/app/src/test/java/org/libremediaconverter/ffmpeg/FFmpegCommandBuilderTest.kt b/app/src/test/java/org/libremediaconverter/ffmpeg/FFmpegCommandBuilderTest.kt index ccd3197..f119133 100644 --- a/app/src/test/java/org/libremediaconverter/ffmpeg/FFmpegCommandBuilderTest.kt +++ b/app/src/test/java/org/libremediaconverter/ffmpeg/FFmpegCommandBuilderTest.kt @@ -1,7 +1,12 @@ package org.libremediaconverter.ffmpeg +import org.libremediaconverter.model.Container import org.libremediaconverter.model.ConversionRequest +import org.libremediaconverter.model.InputProbe import org.libremediaconverter.model.OutputFormat +import org.libremediaconverter.model.AudioCodec +import org.libremediaconverter.model.OutputSpec +import org.libremediaconverter.model.VideoCodec import org.libremediaconverter.model.QualityTier import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -14,14 +19,22 @@ class FFmpegCommandBuilderTest { format: OutputFormat, quality: QualityTier = QualityTier.BEST, hardwareEncodeAvailable: Boolean = true, + ): List = cmd(format.spec, quality, hardwareEncodeAvailable) + + private fun cmd( + spec: OutputSpec, + quality: QualityTier = QualityTier.BEST, + hardwareEncodeAvailable: Boolean = true, + probe: InputProbe = InputProbe(videoCodec = "h264", audioCodec = "aac"), ): List = FFmpegCommandBuilder.build( ConversionRequest( - format = format, + spec = spec, quality = quality, + probe = probe, hardwareEncodeAvailable = hardwareEncodeAvailable, ), inputPath = "/cache/in.mp4", - outputPath = "/cache/out.${format.extension}", + outputPath = "/cache/out.${spec.extension}", ) /** Asserts `flag` is present and immediately followed by `value`. */ @@ -183,7 +196,7 @@ class FFmpegCommandBuilderTest { assertPair(args, "-vf", "fps=1") assertEquals( "shot_%04d.png", - FFmpegCommandBuilder.outputPattern(OutputFormat.FRAMES_PNG, "shot"), + FFmpegCommandBuilder.outputPattern(Container.IMAGE_SEQUENCE, "shot"), ) } @@ -191,7 +204,7 @@ class FFmpegCommandBuilderTest { fun `single file formats keep their plain name`() { assertEquals( "clip.mp4", - FFmpegCommandBuilder.outputPattern(OutputFormat.MP4_H264, "clip.mp4"), + FFmpegCommandBuilder.outputPattern(Container.MP4, "clip.mp4"), ) } @@ -228,4 +241,154 @@ class FFmpegCommandBuilderTest { } } } + + // --- stream copy -------------------------------------------------------- + + @Test + fun `a pure remux copies both tracks and encodes neither`() { + val args = cmd(OutputSpec(Container.MKV, VideoCodec.COPY, AudioCodec.COPY)) + + assertPair(args, "-c:v", "copy") + assertPair(args, "-c:a", "copy") + assertFalse("a copy must not name an encoder: $args", args.contains("libx264")) + assertFalse(args.contains("libx265")) + assertFalse(args.contains("aac")) + } + + /** + * A stream copy decodes nothing, so there are no frames to convert. + * + * Every encode path pins yuv420p for good reason, but carrying the flag onto the copy path + * would be meaningless at best and would force a decode at worst. + */ + @Test + fun `a stream copy does not pin a pixel format`() { + val args = cmd(OutputSpec(Container.MKV, VideoCodec.COPY, AudioCodec.COPY)) + assertFalse("copying needs no -pix_fmt: $args", args.contains("-pix_fmt")) + } + + @Test + fun `copying video while re-encoding audio does both`() { + val args = cmd(OutputSpec(Container.MP4, VideoCodec.COPY, AudioCodec.OPUS)) + assertPair(args, "-c:v", "copy") + assertPair(args, "-c:a", "libopus") + } + + /** + * The brand matters on the copy path too. + * + * Remuxing HEVC out of Matroska into MP4 moves byte-identical samples, but without the hvc1 + * brand Apple devices and many hardware players refuse the result. + */ + @Test + fun `copying HEVC into MP4 still tags it hvc1`() { + val args = cmd( + OutputSpec(Container.MP4, VideoCodec.COPY, AudioCodec.COPY), + probe = InputProbe(videoCodec = "hevc", audioCodec = "aac", container = Container.MKV), + ) + assertPair(args, "-c:v", "copy") + assertPair(args, "-tag:v", "hvc1") + } + + @Test + fun `copying HEVC into Matroska does not tag it`() { + val args = cmd( + OutputSpec(Container.MKV, VideoCodec.COPY, AudioCodec.COPY), + probe = InputProbe(videoCodec = "hevc", audioCodec = "aac", container = Container.MP4), + ) + assertFalse("hvc1 is an ISO-BMFF brand, not a Matroska one: $args", args.contains("-tag:v")) + } + + @Test + fun `copying H264 into MP4 does not tag it hvc1`() { + val args = cmd( + OutputSpec(Container.MP4, VideoCodec.COPY, AudioCodec.COPY), + probe = InputProbe(videoCodec = "h264", audioCodec = "aac", container = Container.MKV), + ) + assertFalse(args.contains("hvc1")) + } + + // --- explicit muxer selection ------------------------------------------- + + /** + * Every container names its muxer. + * + * The builder used to rely on FFmpeg inferring the format from the output extension, which is + * unreliable for MPEG-TS and ASF and became untenable once the container stopped being implied + * by the preset. + */ + @Test + fun `every container passes an explicit -f`() { + Container.entries.forEach { container -> + val spec = when { + container == Container.GIF || container == Container.IMAGE_SEQUENCE -> + OutputSpec(container, VideoCodec.NONE, AudioCodec.NONE) + + container.canHoldVideo -> OutputSpec(container, VideoCodec.COPY, AudioCodec.COPY) + else -> OutputSpec(container, VideoCodec.NONE, AudioCodec.COPY) + } + assertPair(cmd(spec), "-f", container.ffmpegFormat) + } + } + + @Test + fun `the new containers name the muxers FFmpeg actually uses`() { + assertEquals("matroska", Container.MKV.ffmpegFormat) + assertEquals("mpegts", Container.MPEG_TS.ffmpegFormat) + assertEquals("asf", Container.ASF.ffmpegFormat) + assertEquals("mov", Container.MOV.ffmpegFormat) + assertEquals("adts", Container.AAC_ADTS.ffmpegFormat) + } + + @Test + fun `faststart applies to the MP4 family only`() { + assertTrue(cmd(OutputFormat.MP4_H264).contains("+faststart")) + assertTrue( + cmd(OutputSpec(Container.MOV, VideoCodec.COPY, AudioCodec.COPY)) + .contains("+faststart"), + ) + assertFalse( + cmd(OutputSpec(Container.MKV, VideoCodec.COPY, AudioCodec.COPY)) + .contains("+faststart"), + ) + } + + // --- no silent substitution --------------------------------------------- + + /** + * Asking for a codec this app cannot encode must fail, not quietly become H.264. + * + * The builder used to end its codec `when` with `else -> libx264`, which is the same shape as + * the `media3MimeType()` bug that put an HEVC video track in a file named `.m4a`: the user asks + * for one thing, gets another, and nothing reports it. VP8 and AV1 are copyable but not + * encodable, so they are exactly the requests that would land in that branch. + */ + @Test + fun `requesting an unencodable codec fails instead of substituting H264`() { + listOf(VideoCodec.VP8, VideoCodec.AV1).forEach { codec -> + val failure = runCatching { + cmd(OutputSpec(Container.MKV, codec, AudioCodec.AAC)) + }.exceptionOrNull() + + assertTrue( + "$codec should be refused, not silently encoded as something else", + failure != null, + ) + assertTrue( + "the message should name the codec, got: ${failure?.message}", + failure?.message?.contains(codec.label) == true, + ) + } + } + + @Test + fun `the codecs this app does encode still produce their own encoder`() { + assertPair(cmd(OutputSpec(Container.MP4, VideoCodec.H264, AudioCodec.AAC)), "-c:v", "libx264") + assertPair(cmd(OutputSpec(Container.MP4, VideoCodec.H265, AudioCodec.AAC)), "-c:v", "libx265") + assertPair( + cmd(OutputSpec(Container.WEBM, VideoCodec.VP9, AudioCodec.OPUS)), + "-c:v", + "libvpx-vp9", + ) + } } diff --git a/app/src/test/java/org/libremediaconverter/model/CodecNamesTest.kt b/app/src/test/java/org/libremediaconverter/model/CodecNamesTest.kt new file mode 100644 index 0000000..6f21931 --- /dev/null +++ b/app/src/test/java/org/libremediaconverter/model/CodecNamesTest.kt @@ -0,0 +1,51 @@ +package org.libremediaconverter.model + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** + * The bridge between what probing reports and what the app's enums mean. + * + * Three vocabularies meet: `MediaExtractor` MIME types, FFprobe `codec_name` strings, and the + * enums. Stream copy depends on the round trip, so a missing alias here shows up as "we could not + * identify the source codec" and silently costs the user a re-encode. + */ +class CodecNamesTest { + + @Test + fun `the aliases both probes emit resolve to the same codec`() { + listOf("h264", "avc", "avc1", "AVC1").forEach { + assertEquals("$it should be H.264", VideoCodec.H264, CodecNames.videoFromName(it)) + } + listOf("hevc", "h265", "hvc1", "hev1").forEach { + assertEquals("$it should be H.265", VideoCodec.H265, CodecNames.videoFromName(it)) + } + assertEquals(VideoCodec.AV1, CodecNames.videoFromName("av01")) + assertEquals(AudioCodec.AAC, CodecNames.audioFromName("mp4a")) + assertEquals(AudioCodec.PCM, CodecNames.audioFromName("pcm_s16le")) + } + + /** The sentinel must never look like a codec — that is the whole point of it. */ + @Test + fun `the unparseable sentinel resolves to nothing`() { + assertNull(CodecNames.videoFromName(InputProbe.UNPARSEABLE)) + } + + @Test + fun `an unknown name resolves to nothing rather than a default`() { + assertNull(CodecNames.videoFromName("cinepak")) + assertNull(CodecNames.audioFromName("qdm2")) + assertNull(CodecNames.videoFromName(null)) + } + + /** The source-info card shows these, so they must never be blank or a raw sentinel. */ + @Test + fun `descriptions stay readable for unknown and missing codecs`() { + assertEquals("H.264", CodecNames.describeVideo("h264")) + assertEquals("Unknown", CodecNames.describeVideo(null)) + assertEquals("Unrecognised", CodecNames.describeVideo(InputProbe.UNPARSEABLE)) + // An unrecognised but real codec name is more useful shown than hidden. + assertEquals("cinepak", CodecNames.describeVideo("cinepak")) + } +} diff --git a/app/src/test/java/org/libremediaconverter/model/ContainerCapabilitiesTest.kt b/app/src/test/java/org/libremediaconverter/model/ContainerCapabilitiesTest.kt new file mode 100644 index 0000000..ff88663 --- /dev/null +++ b/app/src/test/java/org/libremediaconverter/model/ContainerCapabilitiesTest.kt @@ -0,0 +1,171 @@ +package org.libremediaconverter.model + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The matrix that replaced the closed format enum. + * + * `OutputFormat` used to be twelve hand-picked triples, and its KDoc defended that on the grounds + * that a closed set was what made routing decidable. Opening it up moves that burden here, so this + * is where decidability now has to be proven. + */ +class ContainerCapabilitiesTest { + + private val h264Source = InputProbe( + videoCodec = "h264", + audioCodec = "aac", + container = Container.MP4, + ) + + // --- copy and encode are different questions ---------------------------- + + /** + * The case that makes the mode axis necessary. + * + * A single boolean would have to answer one way or the other, and either answer is wrong half + * the time: refusing AV1 in MP4 blocks a legitimate remux, allowing it promises an encode + * neither engine can deliver. + */ + @Test + fun `MP4 carries AV1 on copy but cannot encode it`() { + assertTrue(ContainerCapabilities.accepts(Container.MP4, VideoCodec.AV1, CodecMode.COPY)) + assertFalse(ContainerCapabilities.accepts(Container.MP4, VideoCodec.AV1, CodecMode.ENCODE)) + } + + @Test + fun `Matroska carries Vorbis on copy but nothing here encodes it`() { + assertTrue(ContainerCapabilities.accepts(Container.MKV, AudioCodec.VORBIS, CodecMode.COPY)) + assertFalse( + ContainerCapabilities.accepts(Container.MKV, AudioCodec.VORBIS, CodecMode.ENCODE), + ) + } + + @Test + fun `a codec the container cannot hold is refused in both modes`() { + listOf(CodecMode.COPY, CodecMode.ENCODE).forEach { mode -> + assertFalse( + "WebM should never accept H.264 ($mode)", + ContainerCapabilities.accepts(Container.WEBM, VideoCodec.H264, mode), + ) + assertFalse( + "WAV should never accept AAC ($mode)", + ContainerCapabilities.accepts(Container.WAV, AudioCodec.AAC, mode), + ) + } + } + + @Test + fun `H265 in AVI is refused — AVI predates it`() { + assertFalse(ContainerCapabilities.accepts(Container.AVI, VideoCodec.H265, CodecMode.COPY)) + assertTrue(ContainerCapabilities.accepts(Container.AVI, VideoCodec.H264, CodecMode.COPY)) + } + + @Test + fun `resolving COPY before asking the matrix is required`() { + // The matrix cannot answer for COPY; the caller has to resolve it against the probe first. + // Failing loudly is what stops a caller from silently getting "false" and refusing a + // perfectly good remux. + runCatching { ContainerCapabilities.accepts(Container.MP4, VideoCodec.COPY, CodecMode.COPY) } + .onSuccess { throw AssertionError("expected COPY to be rejected by the matrix") } + } + + // --- validation --------------------------------------------------------- + + @Test + fun `every preset is a valid spec`() { + OutputFormat.entries.forEach { preset -> + val result = ContainerCapabilities.validate(preset.spec, h264Source) + assertTrue("${preset.name} is not valid: $result", result.isValid) + } + } + + /** A suggestion that is itself invalid is worse than no suggestion. */ + @Test + fun `every suggestion is itself valid`() { + val broken = OutputSpec(Container.WEBM, VideoCodec.H264, AudioCodec.AAC) + val result = ContainerCapabilities.validate(broken, h264Source) + + val invalid = result as? Validation.Invalid + ?: throw AssertionError("expected H.264 in WebM to be rejected") + assertTrue("no alternatives offered", invalid.suggestions.isNotEmpty()) + invalid.suggestions.forEach { suggestion -> + assertTrue( + "suggested $suggestion is itself invalid", + ContainerCapabilities.validate(suggestion, h264Source).isValid, + ) + } + } + + @Test + fun `an unidentifiable source codec cannot be copied`() { + val unknown = InputProbe(videoCodec = InputProbe.UNPARSEABLE, audioCodec = null) + val spec = OutputSpec(Container.MP4, VideoCodec.COPY, AudioCodec.NONE) + + val result = ContainerCapabilities.validate(spec, unknown) + assertFalse("copying an unknown codec must be refused", result.isValid) + } + + @Test + fun `copying a video track into an audio-only container is refused`() { + val spec = OutputSpec(Container.MP3, VideoCodec.COPY, AudioCodec.MP3) + val result = ContainerCapabilities.validate(spec, h264Source) + + val invalid = result as? Validation.Invalid + ?: throw AssertionError("expected video in MP3 to be rejected") + assertTrue(invalid.message.contains("audio only")) + } + + @Test + fun `dropping both tracks is refused rather than producing an empty file`() { + val spec = OutputSpec(Container.MP4, VideoCodec.NONE, AudioCodec.NONE) + val result = ContainerCapabilities.validate(spec, h264Source) + + assertFalse(result.isValid) + assertTrue((result as Validation.Invalid).suggestions.isNotEmpty()) + } + + @Test + fun `copying is offered as the fix when the codec is right but unencodable`() { + val av1Source = InputProbe(videoCodec = "av1", audioCodec = "aac", container = Container.MKV) + val spec = OutputSpec(Container.MP4, VideoCodec.AV1, AudioCodec.AAC) + + val invalid = ContainerCapabilities.validate(spec, av1Source) as? Validation.Invalid + ?: throw AssertionError("expected an AV1 encode to be rejected") + assertTrue( + "should offer to copy the AV1 track instead, got ${invalid.suggestions}", + invalid.suggestions.any { it.videoCodec == VideoCodec.COPY }, + ) + } + + // --- the picker reads these --------------------------------------------- + + @Test + fun `encodable lists never contain a codec the container cannot hold`() { + Container.entries.forEach { container -> + ContainerCapabilities.encodableVideo(container).forEach { + assertTrue( + "$container claims to encode $it but cannot hold it", + ContainerCapabilities.accepts(container, it, CodecMode.COPY), + ) + } + ContainerCapabilities.encodableAudio(container).forEach { + assertTrue( + "$container claims to encode $it but cannot hold it", + ContainerCapabilities.accepts(container, it, CodecMode.COPY), + ) + } + } + } + + @Test + fun `audio-only containers offer no video codecs`() { + listOf(Container.MP3, Container.WAV, Container.FLAC, Container.OGG, Container.AAC_ADTS) + .forEach { container -> + assertFalse("$container claims to hold video", container.canHoldVideo) + assertEquals(emptyList(), ContainerCapabilities.encodableVideo(container)) + } + } +} diff --git a/app/src/test/java/org/libremediaconverter/model/ConversionRouterTest.kt b/app/src/test/java/org/libremediaconverter/model/ConversionRouterTest.kt index 36ab736..3d1a81f 100644 --- a/app/src/test/java/org/libremediaconverter/model/ConversionRouterTest.kt +++ b/app/src/test/java/org/libremediaconverter/model/ConversionRouterTest.kt @@ -20,7 +20,18 @@ class ConversionRouterTest { probe: InputProbe = InputProbe(videoCodec = "h264"), device: DeviceCodecs = DeviceCodecs.PERMISSIVE, ) = ConversionRouter.route( - ConversionRequest(format, quality, preference, probe), + ConversionRequest(format.spec, quality, preference, probe), + device, + ) + + private fun route( + spec: OutputSpec, + quality: QualityTier = QualityTier.FAST, + preference: EnginePreference = EnginePreference.AUTO, + probe: InputProbe = InputProbe(videoCodec = "h264"), + device: DeviceCodecs = DeviceCodecs.PERMISSIVE, + ) = ConversionRouter.route( + ConversionRequest(spec, quality, preference, probe), device, ) @@ -173,6 +184,126 @@ class ConversionRouterTest { assertEquals(Reason.USER_FORCED_SOFTWARE, d.reason) } + // --- remux -------------------------------------------------------------- + + /** + * The test that keeps the Media3 transmux path alive. + * + * `COPY` belongs to none of the router's capability sets, so the obvious implementation — test + * the request's codecs directly — sends every remux to FFmpeg on the very first check. Nothing + * would fail: FFmpeg's `-c copy` produces a correct file, just on the CPU. Only an assertion + * about the *engine* catches it, which is why this one exists. + */ + @Test + fun `a full stream copy into a Media3 container stays on hardware`() { + val d = route( + OutputSpec(Container.MP4, VideoCodec.COPY, AudioCodec.COPY), + probe = InputProbe(videoCodec = "h264", audioCodec = "aac", container = Container.MKV), + ) + assertEquals(Engine.MEDIA3, d.engine) + assertEquals(Reason.REMUX_NO_REENCODE, d.reason) + } + + /** A copy needs no encoder at all, so a device with none must not push it to software. */ + @Test + fun `a stream copy ignores the device encoder capabilities`() { + val noEncoders = object : DeviceCodecs { + override fun canEncode(codec: VideoCodec) = false + override fun canDecode(codecName: String) = true + } + val d = route( + OutputSpec(Container.MP4, VideoCodec.COPY, AudioCodec.COPY), + probe = InputProbe(videoCodec = "h264", audioCodec = "aac", container = Container.MKV), + device = noEncoders, + ) + assertEquals(Engine.MEDIA3, d.engine) + } + + /** Nor does it decode, so a codec the device cannot decode is still copyable. */ + @Test + fun `a stream copy ignores the device decoder capabilities`() { + val noDecoders = object : DeviceCodecs { + override fun canEncode(codec: VideoCodec) = true + override fun canDecode(codecName: String) = false + } + val d = route( + OutputSpec(Container.MP4, VideoCodec.COPY, AudioCodec.COPY), + probe = InputProbe(videoCodec = "av1", audioCodec = "aac", container = Container.MKV), + device = noDecoders, + ) + assertEquals(Engine.MEDIA3, d.engine) + } + + /** + * Copying MP3 audio into MP4 needs no encoder — but Media3's MP4 muxer cannot carry MP3. + * + * Two separate limits that used to be indistinguishable because nothing could reach this + * combination. The encoder gap is real and permanent (Android has no MP3 encoder); the muxer + * gap is what actually decides this job, and the reason has to say so rather than blaming an + * encoder nobody asked for. + */ + @Test + fun `copying MP3 audio into MP4 needs FFmpeg because Media3 cannot mux it`() { + val d = route( + OutputSpec(Container.MP4, VideoCodec.COPY, AudioCodec.COPY), + probe = InputProbe(videoCodec = "h264", audioCodec = "mp3", container = Container.MKV), + ) + assertEquals(Engine.FFMPEG, d.engine) + assertEquals(Reason.CONTAINER_CODEC_UNSUPPORTED, d.reason) + } + + /** Opus, by contrast, Media3's MP4 muxer does carry. */ + @Test + fun `copying Opus audio into MP4 stays on hardware`() { + val d = route( + OutputSpec(Container.MP4, VideoCodec.COPY, AudioCodec.COPY), + probe = InputProbe(videoCodec = "h264", audioCodec = "opus", container = Container.MKV), + ) + assertEquals(Engine.MEDIA3, d.engine) + assertEquals(Reason.REMUX_NO_REENCODE, d.reason) + } + + /** But Best quality is meaningless for a copy, so it must not force software. */ + @Test + fun `best quality does not force software for a pure remux`() { + val d = route( + OutputSpec(Container.MP4, VideoCodec.COPY, AudioCodec.COPY), + quality = QualityTier.BEST, + probe = InputProbe(videoCodec = "h264", audioCodec = "aac", container = Container.MKV), + ) + assertEquals(Engine.MEDIA3, d.engine) + } + + /** An unreadable file cannot be copied either — there is nothing to demux. */ + @Test + fun `an unparseable input still goes to FFmpeg even when a copy was asked for`() { + val d = route( + OutputSpec(Container.MP4, VideoCodec.COPY, AudioCodec.COPY), + probe = InputProbe(videoCodec = InputProbe.UNPARSEABLE), + ) + assertEquals(Engine.FFMPEG, d.engine) + assertEquals(Reason.NO_PLATFORM_DECODER, d.reason) + } + + @Test + fun `the new containers are all FFmpeg-only`() { + listOf( + Container.MOV, Container.MKV, Container.MPEG_TS, + Container.AVI, Container.FLV, Container.ASF, + ).forEach { container -> + val d = route( + OutputSpec(container, VideoCodec.COPY, AudioCodec.COPY), + probe = InputProbe( + videoCodec = "h264", + audioCodec = "aac", + container = Container.MP4, + ), + ) + assertEquals("$container should need FFmpeg", Engine.FFMPEG, d.engine) + assertEquals(Reason.CONTAINER_UNSUPPORTED, d.reason) + } + } + // --- exhaustiveness ---------------------------------------------------- @Test diff --git a/app/src/test/java/org/libremediaconverter/model/CopyPlannerTest.kt b/app/src/test/java/org/libremediaconverter/model/CopyPlannerTest.kt new file mode 100644 index 0000000..58fb1cd --- /dev/null +++ b/app/src/test/java/org/libremediaconverter/model/CopyPlannerTest.kt @@ -0,0 +1,159 @@ +package org.libremediaconverter.model + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The stream-copy decision for a single file. + * + * Deliberately mirrors [ConcatPlannerTest]'s shape, because it enforces the same rule for the same + * reason: an unproven match must never become a stream copy. A needless re-encode costs time; a + * wrong copy costs the user a file that will not play, and they may not notice until the source is + * gone. + */ +class CopyPlannerTest { + + private val mp4H264 = InputProbe( + videoCodec = "h264", + audioCodec = "aac", + container = Container.MP4, + ) + + // --- explicit copy ------------------------------------------------------ + + @Test + fun `asking to copy a known codec the container accepts copies it`() { + val plan = CopyPlanner.plan( + OutputSpec(Container.MKV, VideoCodec.COPY, AudioCodec.COPY), + mp4H264, + ) + assertEquals(VideoPlan.Copy, plan.video) + assertEquals(AudioPlan.Copy, plan.audio) + assertTrue("this is a pure container change", plan.isPureRemux) + } + + @Test + fun `an unparseable source is never copied`() { + val plan = CopyPlanner.plan( + OutputSpec(Container.MP4, VideoCodec.COPY, AudioCodec.COPY), + InputProbe(videoCodec = InputProbe.UNPARSEABLE, audioCodec = null), + ) + assertTrue("must not copy an unidentified stream", plan.video !is VideoPlan.Copy) + assertTrue(plan.audio !is AudioPlan.Copy) + } + + /** Two unknowns are not evidence of agreement — the rule ConcatPlanner already states. */ + @Test + fun `an unrecognised codec name falls back to re-encoding`() { + val exotic = InputProbe(videoCodec = "cinepak", audioCodec = "qdm2", container = Container.MOV) + val plan = CopyPlanner.plan( + OutputSpec(Container.MP4, VideoCodec.COPY, AudioCodec.COPY), + exotic, + ) + assertTrue(plan.video is VideoPlan.Encode) + assertTrue(plan.audio is AudioPlan.Encode) + } + + @Test + fun `copying into a container that cannot hold the codec re-encodes instead`() { + // H.264 cannot go in WebM. Asking to copy anyway must not produce a broken file. + val plan = CopyPlanner.plan( + OutputSpec(Container.WEBM, VideoCodec.COPY, AudioCodec.NONE), + mp4H264, + ) + assertEquals(VideoPlan.Encode(VideoCodec.VP9), plan.video) + } + + // --- the auto-upgrade rule ---------------------------------------------- + + @Test + fun `a matching codec with a changing container is upgraded to a copy`() { + val plan = CopyPlanner.plan( + OutputSpec(Container.MKV, VideoCodec.H264, AudioCodec.AAC), + mp4H264, + ) + assertEquals("MP4 h264 -> MKV h264 is a remux", VideoPlan.Copy, plan.video) + assertEquals(AudioPlan.Copy, plan.audio) + } + + /** + * The case that stops auto-upgrade from breaking compression. + * + * Same container, same codec means the only reason to run the job is to re-encode it — almost + * always to make it smaller. Copying would hand back a byte-identical file and call it done. + */ + @Test + fun `a matching codec in the same container still re-encodes`() { + val plan = CopyPlanner.plan( + OutputSpec(Container.MP4, VideoCodec.H264, AudioCodec.AAC), + mp4H264, + ) + assertEquals(VideoPlan.Encode(VideoCodec.H264), plan.video) + assertEquals(AudioPlan.Encode(AudioCodec.AAC), plan.audio) + } + + @Test + fun `an unknown source container never triggers auto-upgrade`() { + // Without a source container there is no way to know the container is changing, so the + // conservative answer is to encode. + val noContainer = InputProbe(videoCodec = "h264", audioCodec = "aac", container = null) + val plan = CopyPlanner.plan( + OutputSpec(Container.MKV, VideoCodec.H264, AudioCodec.AAC), + noContainer, + ) + assertEquals(VideoPlan.Encode(VideoCodec.H264), plan.video) + } + + @Test + fun `a different codec is always re-encoded`() { + val plan = CopyPlanner.plan( + OutputSpec(Container.MKV, VideoCodec.H265, AudioCodec.OPUS), + mp4H264, + ) + assertEquals(VideoPlan.Encode(VideoCodec.H265), plan.video) + assertEquals(AudioPlan.Encode(AudioCodec.OPUS), plan.audio) + } + + // --- track removal ------------------------------------------------------ + + @Test + fun `audio-only output drops the video track`() { + val plan = CopyPlanner.plan( + OutputSpec(Container.MP4, VideoCodec.NONE, AudioCodec.AAC), + mp4H264, + ) + assertEquals(VideoPlan.Drop, plan.video) + assertTrue(plan.audio is AudioPlan.Encode) + assertTrue("dropping plus encoding is not a remux", !plan.isPureRemux) + } + + @Test + fun `a source with no video track drops video even when video was requested`() { + val audioOnly = InputProbe( + videoCodec = null, + audioCodec = "aac", + hasVideo = false, + container = Container.MP4, + kind = InputKind.AUDIO_ONLY, + ) + val plan = CopyPlanner.plan( + OutputSpec(Container.MKV, VideoCodec.H264, AudioCodec.COPY), + audioOnly, + ) + assertEquals(VideoPlan.Drop, plan.video) + assertEquals(AudioPlan.Copy, plan.audio) + assertTrue("copying the only track is still a remux", plan.isPureRemux) + } + + @Test + fun `copying one track and encoding the other is not a pure remux`() { + val plan = CopyPlanner.plan( + OutputSpec(Container.MKV, VideoCodec.COPY, AudioCodec.OPUS), + mp4H264, + ) + assertEquals(VideoPlan.Copy, plan.video) + assertEquals(AudioPlan.Encode(AudioCodec.OPUS), plan.audio) + assertTrue(!plan.isPureRemux) + } +} diff --git a/app/src/test/java/org/libremediaconverter/model/OutputFormatTest.kt b/app/src/test/java/org/libremediaconverter/model/OutputFormatTest.kt index 911d413..25fb4a0 100644 --- a/app/src/test/java/org/libremediaconverter/model/OutputFormatTest.kt +++ b/app/src/test/java/org/libremediaconverter/model/OutputFormatTest.kt @@ -61,4 +61,66 @@ class OutputFormatTest { assertTrue(it.description.isNotBlank()) } } + + // --- containers are now load-bearing ------------------------------------ + + /** + * Extension and MIME type moved from the preset onto the container. + * + * They used to be per-preset literals, which is why `OutputFormat.FLAC` could declare + * `Container.MKV` with extension `flac` and nobody noticed — nothing read the container. Now + * `-f`, the filename and the SAF create-document MIME all derive from it. + */ + @Test + fun `every container names an extension, a mime type and an ffmpeg muxer`() { + Container.entries.forEach { container -> + listOf(true, false).forEach { hasVideo -> + val ext = container.extensionFor(hasVideo) + assertTrue("$container has no extension", ext.isNotBlank()) + assertFalse("$container extension has a dot", ext.startsWith(".")) + assertTrue( + "$container has no mime type", + container.mimeTypeFor(hasVideo).contains('/'), + ) + } + assertTrue("$container names no muxer", container.ffmpegFormat.isNotBlank()) + } + } + + @Test + fun `audio-only variants of a container get their own extension`() { + assertEquals("mp4", Container.MP4.extensionFor(hasVideo = true)) + assertEquals("m4a", Container.MP4.extensionFor(hasVideo = false)) + assertEquals("mkv", Container.MKV.extensionFor(hasVideo = true)) + assertEquals("mka", Container.MKV.extensionFor(hasVideo = false)) + } + + /** Regression guard: FLAC used to be declared as Matroska with a `.flac` extension. */ + @Test + fun `FLAC is its own container, not Matroska`() { + assertEquals(Container.FLAC, OutputFormat.FLAC.container) + assertEquals("flac", OutputFormat.FLAC.extension) + assertEquals("flac", Container.FLAC.ffmpegFormat) + } + + @Test + fun `the remux presets copy both tracks`() { + listOf(OutputFormat.REMUX_MP4, OutputFormat.REMUX_MKV).forEach { + assertEquals(VideoCodec.COPY, it.videoCodec) + assertEquals(AudioCodec.COPY, it.audioCodec) + assertTrue("${it.name} should be a pure remux", it.spec.isPureRemux) + } + } + + @Test + fun `a spec that encodes anything is not a remux`() { + assertFalse(OutputFormat.MP4_H265.spec.isPureRemux) + assertFalse( + OutputSpec(Container.MP4, VideoCodec.COPY, AudioCodec.AAC).isPureRemux, + ) + assertFalse( + "dropping both tracks copies nothing", + OutputSpec(Container.MP4, VideoCodec.NONE, AudioCodec.NONE).isPureRemux, + ) + } }