JMR-devandClaude Opus 5 dcdcbfd3af Let a cancelled conversion stay cancelled
Both workers' outer `catch (e: Throwable)` caught `CancellationException` along with everything
else and answered it with a `Result`. `runMedia3OrFallBack` goes out of its way to rethrow
cancellation rather than fall back to software, and then the catch above it converted it anyway.
A coroutine that reports completion inside a scope which has already been cancelled is
structured concurrency's one rule broken, and it is the kind of break that stays quiet: nothing
downstream complains, and the next thing to hold a resource across that boundary is the thing
that finds out.

Nothing on screen disagreed today, which is why this is a low-severity entry rather than a bug
report. WorkManager cancels the worker's coroutine through `WorkerWrapper.interrupt`, which
cancels `workerJob` with a `WorkerStoppedException`; the surrounding `withContext(workerJob)`
then throws that whatever the worker returned, and `launch()` resolves it as `ResetWorkerStatus`.
The returned `Result` is read only when nothing stopped the worker at all. So the change is
about the shape of the code rather than about a symptom.

One behaviour does move, and it is worth naming rather than discovering later. A cancellation
that is *not* WorkManager stopping us -- FFmpegKit reporting `ReturnCode.isCancel`, which cancels
the continuation -- now leaves `doWork` as a cancellation, and `WorkerWrapper` resolves a
self-cancelled worker as `Resolution.Failed()` with no output data instead of the
`Result.failure(KEY_ERROR ...)` it used to build. Both ViewModels already fall back on blank
output data, deliberately and with a test, so the user sees "Conversion failed." either way. That
is also the honest answer: the only route to `isCancel` is a cancellation someone asked for.

The `staged.delete()` on that path is kept, and moved into the new branch rather than left to the
one below it. A cancelled attempt leaves a partial in staging, the next attempt starts `doWork()`
from the top rather than resuming it, and this catch holds the only handle to the file.

Reaching any of this from a JVM test needed one more thing: `doWork` called `MediaProbe.probe`
directly, and it was the last caller bypassing `ConversionDependencies.probe`. FFprobe's loader
throws a bare `java.lang.Error` with no native library present, so no unit test could reach a
single line below it. The seam's own KDoc says this is what it is for -- coverage of the branches
that only run when something goes wrong -- and the default is the same real probe, so the app and
the instrumented tests are unchanged.

`WorkerCancellationTest` drives the real worker through a real `WorkManager` to the software
engine, forced with `FORCE_SOFTWARE` because it is the one preference that decides without
consulting the input, so the test does not depend on a routing rule it is not about. The engine
stub writes bytes before it throws, which is what makes the delete assertion mean something: a
stub that only threw would let a missing `delete()` pass. Three cases -- cancellation propagates,
cancellation still deletes, and an ordinary failure is still answered with a `Result` carrying
its message, which is the half that would break if the rethrow were widened past cancellation.

Before the fix the first of those failed with "cancellation must leave doWork as cancellation,
not as a Result; got null" -- `runCatching` had nothing to report, because `doWork` had returned.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 20:11:44 -05:00
2026-08-19 17:29:15 -05:00

LibreMediaConverter

A free and open-source media converter for Android — batch video transcoding and compression, audio extraction and conversion, GIF and frame export, and file merging.

Android 13+ (API 33). Built with Jetpack Compose and Material 3.

Status: working, unreleased. Both conversion engines, the router, the background job queue and the join flow are implemented and building. The FFmpeg format tests have been written but not yet executed on a device.

Licensing at a glance

  • Source code: MIT
  • Distributed APK: GPL-3.0 — because it bundles FFmpeg built with x264/x265

That split is deliberate, not an oversight. See LICENSES/README.md for the reasoning and the corresponding-source obligations.

Architecture

Two conversion engines behind an explicit router, because neither one covers the job alone.

AndroidX Media3 Transformer — the hardware path

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 and nothing else. media3-muxer ships WebM, Ogg, WAV and AAC muxers too, but none can be driven by Transformer — they throw from addMetadataEntry, which the muxer wrapper calls for every metadata entry a real recording carries. 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, 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

The router is surfaced to users as a quality choice rather than hidden:

Tier Engine Rate control Trade-off
Fast (default) Media3 / MediaCodec bitrate-targeted ~7–8× realtime, low battery cost
Best quality FFmpeg + x264/x265 CRF or 2-pass ~realtime or slower, better quality per byte

A note on "GPU acceleration"

Android has no GPU video codec path. There are three distinct tiers, and conflating them causes a lot of confusion:

  1. Fixed-function video codec silicon — reached through MediaCodec. This is what "hardware accelerated" means for encode and decode. It is not the GPU.
  2. GPU shader cores — genuinely used, but only for filters, scaling, and color effects on already-decoded frames, via OpenGL ES. Never for entropy coding.
  3. CPU — x264, x265, and software decoders.

FFmpeg's -hwaccel is meaningful on Android only as mediacodec, and even then it targets direct-to-Surface playback rather than file-to-file transcoding. Vulkan Video exists in FFmpeg 8.0+ but no shipping Android GPU driver exposes it — no VK_KHR_video_* 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, 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 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.

Building

Requires JDK 17+ (AGP 9 will not run on older) and the Android SDK with API 37.

FFmpeg is committed as a prebuilt archive under bin/, so a clone builds without a cross-compile. That is deliberate: rebuilding it per CI run made test results ambiguous, because a red build could mean broken code or a build that hiccuped. See bin/README.md for its provenance and how to regenerate it.

./gradlew :app:assembleDebug          # debug APK
./gradlew :app:testDebugUnitTest      # JVM tests
./gradlew :app:connectedDebugAndroidTest   # device tests, needs a running device
./gradlew :app:assembleRelease        # R8-minified release

See tools/ffmpeg/README.md for why the build is containerised and which flags matter. That recipe remains the authority — the committed archive is its output, and is also what satisfies the GPL corresponding-source obligation.

Testing

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. 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

The app has no INTERNET permission, so it cannot open a network connection at all. Nothing is uploaded, and there is no analytics or advertising. See PRIVACY.md, which also explains the permissions WorkManager adds automatically.

Contributing

Contributions are welcome. Note that contributions to the source are under MIT, while the distributed binary remains GPL-3.0 for the reasons described in LICENSES/README.md.

S
Description
On-device FOSS media converter for mobile. Android first, iOS aspirational. Android flavor uses Media3 and FFMPEG.
Readme MIT
70 MiB
Languages
Kotlin 91.1%
Shell 7.4%
Java 1.4%
Dockerfile 0.1%