Media3 + FFmpeg conversion pipeline #1
@@ -0,0 +1,130 @@
|
||||
name: Build
|
||||
|
||||
# Pull requests are covered by status_check.yml, which runs the unit tests and the
|
||||
# instrumented suite across API 33-37. This workflow keeps the post-merge and release
|
||||
# duties and does not duplicate PR validation.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ['v*']
|
||||
|
||||
# Actions are pinned to a commit rather than a tag, with the release in a trailing
|
||||
# comment. A tag is mutable -- the owner can repoint it at new code -- so a tag
|
||||
# reference amounts to running whatever that repository contains tomorrow. This matters
|
||||
# more here than on pull requests: these jobs sign nothing today, but they do publish
|
||||
# the artifacts people install.
|
||||
env:
|
||||
GRADLE_CACHE_PATHS: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Unit tests
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '17' # AGP 9 requires JDK 17
|
||||
|
||||
# Gradle runs through the committed wrapper rather than a setup action. The
|
||||
# wrapper verifies its own distribution against distributionSha256Sum, and
|
||||
# caching is a handful of lines, so the action earned little here.
|
||||
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: ${{ env.GRADLE_CACHE_PATHS }}
|
||||
key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle.kts', 'gradle/libs.versions.toml', 'gradle/wrapper/gradle-wrapper.properties') }}
|
||||
restore-keys: gradle-${{ runner.os }}-
|
||||
|
||||
- name: Unit tests
|
||||
run: ./gradlew :app:testDebugUnitTest
|
||||
|
||||
- name: Upload test report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: unit-test-report
|
||||
path: app/build/reports/tests/
|
||||
|
||||
release:
|
||||
name: Release
|
||||
needs: [test]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
permissions:
|
||||
# Needed to create the release. Declared explicitly rather than relying on the
|
||||
# repository default, so the token's reach is visible here.
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '17'
|
||||
|
||||
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: ${{ env.GRADLE_CACHE_PATHS }}
|
||||
key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle.kts', 'gradle/libs.versions.toml', 'gradle/wrapper/gradle-wrapper.properties') }}
|
||||
restore-keys: gradle-${{ runner.os }}-
|
||||
|
||||
# No -PabiFilters here: released artifacts must carry every ABI. That override
|
||||
# exists only so emulator jobs skip libraries they cannot execute.
|
||||
- name: Build release artifacts
|
||||
run: ./gradlew :app:assembleRelease :app:bundleRelease
|
||||
|
||||
- name: Verify the released artifacts
|
||||
run: |
|
||||
APK=$(ls app/build/outputs/apk/release/*.apk | head -1)
|
||||
# A release that shipped one ABI, or lost 16 KB alignment, would install
|
||||
# fine on a test device and fail for users or at Play submission. Both are
|
||||
# cheap to check and expensive to discover later.
|
||||
for abi in arm64-v8a x86_64; do
|
||||
n=$(unzip -l "$APK" | grep -c "lib/$abi/.*\.so$" || true)
|
||||
echo " $abi: $n shared libraries"
|
||||
test "$n" -gt 0 || { echo "::error::release APK is missing $abi"; exit 1; }
|
||||
done
|
||||
unzip -q -o "$APK" 'lib/*' -d /tmp/relcheck
|
||||
bad=0
|
||||
for f in /tmp/relcheck/lib/*/*.so; do
|
||||
align=$(readelf -lW "$f" | awk '$1=="LOAD"{print $NF}' | sort -u)
|
||||
[ "$align" = "0x4000" ] || { echo "::error::$(basename "$f") is $align"; bad=1; }
|
||||
done
|
||||
test "$bad" -eq 0 || exit 1
|
||||
echo " all libraries are 16 KB aligned"
|
||||
|
||||
# GPL-3.0 requires that complete corresponding source accompany the binary.
|
||||
# FFmpeg's guidance says to host it on the same server as the binary; for a Play
|
||||
# listing that is impossible, so it is attached to the GitHub release next to the
|
||||
# APK and linked from both the store listing and the in-app About screen.
|
||||
- name: Assemble corresponding source
|
||||
run: |
|
||||
mkdir -p release-source
|
||||
cp -r tools/ffmpeg release-source/
|
||||
cp bin/README.md release-source/PREBUILT.md
|
||||
{
|
||||
echo "FFmpeg corresponding source for ${GITHUB_REF_NAME}"
|
||||
echo
|
||||
echo "Upstream: https://github.com/arthenica/ffmpeg-kit-next"
|
||||
echo "Tag: v8.1.1 (FFmpeg 8.1.2)"
|
||||
echo
|
||||
echo "tools/ffmpeg reproduces the binary shipped in this release."
|
||||
echo "PREBUILT.md records its provenance, including the SHA-256 and the"
|
||||
echo "configure line read back out of the shipped libavutil."
|
||||
} > release-source/README.txt
|
||||
tar czf ffmpeg-corresponding-source.tar.gz release-source
|
||||
|
||||
- uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
|
||||
with:
|
||||
files: |
|
||||
app/build/outputs/apk/release/*.apk
|
||||
app/build/outputs/bundle/release/*.aab
|
||||
ffmpeg-corresponding-source.tar.gz
|
||||
LICENSE
|
||||
LICENSES/README.md
|
||||
@@ -0,0 +1,214 @@
|
||||
name: Status check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
# A newer push to the same PR makes the in-flight run obsolete. An emulator matrix is
|
||||
# expensive, so cancel rather than let runs pile up.
|
||||
concurrency:
|
||||
group: status-check-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
# Third-party actions are pinned to a commit rather than a tag. A tag is mutable: the
|
||||
# owner can repoint v4 at new code, so a tag reference is an open invitation to run
|
||||
# whatever that repository contains tomorrow. The trailing comment records which
|
||||
# release each hash corresponds to, since a bare hash is unreadable.
|
||||
env:
|
||||
GRADLE_CACHE_PATHS: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
|
||||
jobs:
|
||||
# ---------------------------------------------------------------------------
|
||||
# Validates the committed FFmpeg archive. It does not build anything: the whole
|
||||
# point of checking the binary in is that a red run means broken code rather than
|
||||
# a cross-compile that hiccuped.
|
||||
#
|
||||
# Its own job so a bad archive reports once, clearly, instead of surfacing as five
|
||||
# confusing emulator failures. It takes seconds, so gating the matrix on it costs
|
||||
# almost nothing.
|
||||
# ---------------------------------------------------------------------------
|
||||
ffmpeg:
|
||||
name: FFmpeg binary
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Verify the committed archive
|
||||
run: |
|
||||
AAR=bin/ffmpeg-kit-next-8.1.1.aar
|
||||
test -f "$AAR" || { echo "::error::$AAR is missing"; exit 1; }
|
||||
|
||||
# A truncated file, or a Git LFS pointer checked out without LFS, would pass
|
||||
# a file-exists check and then surface much later as a confusing linker
|
||||
# error. Assert the archive actually carries native libraries for both ABIs.
|
||||
for abi in arm64-v8a x86_64; do
|
||||
n=$(unzip -l "$AAR" | grep -c "jni/$abi/.*\.so$" || true)
|
||||
echo " $abi: $n shared libraries"
|
||||
test "$n" -gt 0 || { echo "::error::AAR has no $abi libraries"; exit 1; }
|
||||
done
|
||||
|
||||
# 16 KB alignment is a Play requirement and is easy to lose in a rebuild,
|
||||
# so it is checked here rather than discovered at submission.
|
||||
unzip -q -o "$AAR" 'jni/*' -d /tmp/aarcheck
|
||||
bad=0
|
||||
for f in /tmp/aarcheck/jni/*/*.so; do
|
||||
align=$(readelf -lW "$f" | awk '$1=="LOAD"{print $NF}' | sort -u)
|
||||
if [ "$align" != "0x4000" ]; then
|
||||
echo "::error::$(basename "$f") is $align, not 16 KB aligned"; bad=1
|
||||
fi
|
||||
done
|
||||
test "$bad" -eq 0 || exit 1
|
||||
echo " all libraries are 16 KB aligned"
|
||||
|
||||
# Record what shipped, so a failing run elsewhere can be tied to a version.
|
||||
echo " sha256: $(sha256sum "$AAR" | cut -d' ' -f1)"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# JVM tests: the routing matrix, the FFmpeg argument builder, the concat planner
|
||||
# and the retry rule. No device needed, so this is the fastest signal on a PR.
|
||||
# ---------------------------------------------------------------------------
|
||||
unit:
|
||||
name: Unit tests
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '17' # AGP 9 will not run on anything older
|
||||
|
||||
# Gradle is invoked through the committed wrapper rather than a setup action.
|
||||
# The wrapper verifies its own distribution against distributionSha256Sum, and
|
||||
# caching is a handful of lines, so the action earned little here.
|
||||
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: ${{ env.GRADLE_CACHE_PATHS }}
|
||||
key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle.kts', 'gradle/libs.versions.toml', 'gradle/wrapper/gradle-wrapper.properties') }}
|
||||
restore-keys: gradle-${{ runner.os }}-
|
||||
|
||||
- name: Unit tests
|
||||
run: ./gradlew :app:testDebugUnitTest
|
||||
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: always()
|
||||
with:
|
||||
name: unit-test-report
|
||||
path: app/build/reports/tests/
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# One runner per API level, across the whole supported range.
|
||||
#
|
||||
# The range is the point: minSdk is 33, and the foreground service type differs
|
||||
# across it -- none below 34, dataSync at 34, mediaProcessing from 35. Testing a
|
||||
# single level would leave two thirds of that branch unexercised.
|
||||
#
|
||||
# It stops at 36 rather than targetSdk 37 because the android-37.0 emulator image
|
||||
# is broken, not because 37 does not matter. See docs/api-37-emulator-crash.md.
|
||||
#
|
||||
# FFmpeg is not built here. The AAR is committed under bin/, so a red run means the
|
||||
# code is broken rather than that a cross-compile hiccuped.
|
||||
# ---------------------------------------------------------------------------
|
||||
e2e:
|
||||
name: E2E API ${{ matrix.label }}
|
||||
runs-on: ubuntu-latest
|
||||
needs: ffmpeg
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
# Report every API level rather than stopping at the first red one. Knowing
|
||||
# whether a failure is universal or specific to one level is most of the
|
||||
# diagnosis.
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- label: "33"
|
||||
api-level: "33"
|
||||
- label: "34"
|
||||
api-level: "34"
|
||||
- label: "35"
|
||||
api-level: "35"
|
||||
- label: "36"
|
||||
api-level: "36"
|
||||
# No API 37 row. targetSdk is 37, but the android-37.0 emulator image
|
||||
# crash-loops surfaceflinger inside its own gralloc mapper, so every test
|
||||
# fails there no matter what this app does. Ruling that in took four CI
|
||||
# rounds, so the evidence and the ruled-out fixes are written down rather
|
||||
# than left to be rediscovered: docs/api-37-emulator-crash.md. That file
|
||||
# also records what to try first when re-adding it -- note that the row
|
||||
# needs api-level "37.0", since a bare 37 fails during SDK setup.
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '17'
|
||||
|
||||
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: ${{ env.GRADLE_CACHE_PATHS }}
|
||||
key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle.kts', 'gradle/libs.versions.toml', 'gradle/wrapper/gradle-wrapper.properties') }}
|
||||
restore-keys: gradle-${{ runner.os }}-
|
||||
|
||||
# Without this the emulator falls back to software rendering and takes minutes
|
||||
# longer to boot, when it boots at all.
|
||||
- name: Enable KVM
|
||||
run: |
|
||||
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \
|
||||
| sudo tee /etc/udev/rules.d/99-kvm4all.rules
|
||||
sudo udevadm control --reload-rules
|
||||
sudo udevadm trigger --name-match=kvm
|
||||
|
||||
- name: Instrumented tests
|
||||
uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2.38.0
|
||||
with:
|
||||
api-level: ${{ matrix.api-level }}
|
||||
target: google_apis
|
||||
arch: x86_64
|
||||
profile: pixel_6
|
||||
# swiftshader_indirect is correct here only because runners have no GPU to
|
||||
# pass through. On a workstation the same setting routes through
|
||||
# SwiftShader's JIT, which is a known crash source.
|
||||
emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
|
||||
disable-animations: true
|
||||
# The default userdata partition is not big enough for this APK once the
|
||||
# FFmpeg libraries are in it. One level failed outright with "Requested
|
||||
# internal only, but not enough space", and the margin was thin everywhere
|
||||
# else, so give them all room.
|
||||
disk-size: 8G
|
||||
# Pinned because the emulator's own default is not uniform: it raises an
|
||||
# undersized guest to a floor that varies by API level -- 2048M at 33, 2560M
|
||||
# at 34 through 36 -- and skips levels it does not recognise entirely. 2560M
|
||||
# is the highest of those floors, so no level gets less memory than it
|
||||
# already had, and none of them depend on that heuristic any more.
|
||||
ram-size: 2560M
|
||||
# Build only the ABI the emulator can execute. FFmpeg's native libraries
|
||||
# dominate the APK, so shipping arm64 to an x86_64 emulator doubles the
|
||||
# install for code that can never run: 114 MB against 80 MB.
|
||||
#
|
||||
# The probe lines survive from diagnosing the API 37 crash and are kept
|
||||
# because a red instrumented run is otherwise near-impossible to read from a
|
||||
# log alone. The first reports what the guest actually got, so a wrong
|
||||
# emulator configuration is visible on a green run too; the crash dump runs
|
||||
# only on failure, so a green run is unchanged.
|
||||
#
|
||||
# Each line here is a separate `sh -c` -- the action splits the script on
|
||||
# newlines -- so the failure handler has to stay on one line. The action does
|
||||
# not pass ignoreReturnCode, so a non-zero line fails the job outright: the
|
||||
# probe ends in `|| true` because a grep that matches nothing exits 1, and a
|
||||
# diagnostic must never be the thing that turns a run red.
|
||||
script: |
|
||||
adb shell cat /proc/meminfo | grep -E 'MemTotal|MemAvailable|SwapTotal' || true
|
||||
./gradlew :app:connectedDebugAndroidTest -PabiFilters=x86_64 || { echo "=== guest memory at failure ==="; adb shell cat /proc/meminfo | grep -E 'MemTotal|MemAvailable|SwapTotal'; echo "=== native crashes ==="; adb logcat -d -b crash | tail -60; exit 1; }
|
||||
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: always()
|
||||
with:
|
||||
name: e2e-report-api${{ matrix.label }}
|
||||
path: |
|
||||
app/build/reports/androidTests/
|
||||
app/build/outputs/androidTest-results/
|
||||
@@ -13,3 +13,7 @@
|
||||
.externalNativeBuild
|
||||
.cxx
|
||||
local.properties
|
||||
|
||||
# The FFmpeg AAR is committed under bin/ so test runs do not depend on a rebuild.
|
||||
# Build outputs from tools/ffmpeg are not.
|
||||
tools/ffmpeg/out/
|
||||
|
||||
Generated
-1
@@ -1 +0,0 @@
|
||||
Android(MediaConverter
|
||||
Generated
+1
-1
@@ -4,7 +4,7 @@
|
||||
<selectionStates>
|
||||
<SelectionState runConfigName="app">
|
||||
<option name="selectionMode" value="DROPDOWN" />
|
||||
<DropdownSelection timestamp="2026-08-19T22:25:01.327233679Z">
|
||||
<DropdownSelection timestamp="2026-08-20T13:53:52.273519182Z">
|
||||
<Target type="DEFAULT_BOOT">
|
||||
<handle>
|
||||
<DeviceId pluginId="LocalEmulator" identifier="path=/home/jasonross/.android/avd/Test_Device.avd" />
|
||||
|
||||
Generated
+1
@@ -1,5 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="GradleMigrationSettings" migrationVersion="1" />
|
||||
<component name="GradleSettings">
|
||||
<option name="linkedExternalProjectsSettings">
|
||||
<GradleProjectSettings>
|
||||
|
||||
Generated
+1
-1
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_25" project-jdk-name="jbr-25" project-jdk-type="JavaSDK">
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_25" default="true" project-jdk-name="temurin-25" project-jdk-type="JavaSDK">
|
||||
<output url="file://$PROJECT_DIR$/build/classes" />
|
||||
</component>
|
||||
<component name="ProjectType">
|
||||
|
||||
Generated
+6
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Jason Ross
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,62 @@
|
||||
# Licensing
|
||||
|
||||
This project has a **split license**, and the distinction matters.
|
||||
|
||||
## Source code — MIT
|
||||
|
||||
Everything in this repository that we wrote is MIT licensed. See [`../LICENSE`](../LICENSE).
|
||||
|
||||
## Distributed binary (APK / AAB) — GPL-3.0
|
||||
|
||||
The shipped application binary is **GPL-3.0**, not MIT.
|
||||
|
||||
The app bundles FFmpeg built with `--enable-gpl`, which pulls in **x264** and **x265**
|
||||
(both GPL-2.0-or-later). Combining those with the application makes the *distributed
|
||||
binary* a GPL work. MIT → GPL is a compatible direction, so our own source stays MIT;
|
||||
the combined binary you install is GPL-3.0.
|
||||
|
||||
### Why GPL rather than LGPL
|
||||
|
||||
Building without `--enable-gpl` would keep the binary LGPL, and most features survive
|
||||
that: hardware H.264/HEVC encode via MediaCodec, MP3 via libmp3lame, AV1, VP9, Opus,
|
||||
FLAC, and even subtitle burn-in (libass is ISC licensed, not GPL — a common
|
||||
misconception).
|
||||
|
||||
What GPL specifically buys is **x264/x265 software encode**, which is the only way to
|
||||
get CRF and 2-pass rate control. Those are the tools that actually deliver
|
||||
quality-per-bitrate for "compress this video well", and no hardware encoder on Android
|
||||
exposes either. That capability was judged worth the license.
|
||||
|
||||
### Corresponding source
|
||||
|
||||
GPL-3.0 requires that complete corresponding source accompany the binary. For each
|
||||
release we publish, alongside the APK:
|
||||
|
||||
- the exact FFmpeg source tarball or commit used,
|
||||
- the full `configure` line, and
|
||||
- any patches applied.
|
||||
|
||||
FFmpeg's own guidance says to host the source "on the same webserver" as the binary.
|
||||
That is not literally possible for a Google Play listing, so the source is attached to
|
||||
the corresponding **GitHub Release next to the APK**, and linked from both the Play
|
||||
listing and the in-app About screen.
|
||||
|
||||
## Third-party components
|
||||
|
||||
| Component | License | Notes |
|
||||
|---|---|---|
|
||||
| AndroidX / Jetpack Compose | Apache-2.0 | |
|
||||
| AndroidX Media3 (Transformer, Effect) | Apache-2.0 | primary hardware conversion path |
|
||||
| FFmpeg | LGPL-2.1+, **GPL-2.0+ as built here** | `--enable-gpl` |
|
||||
| x264 | GPL-2.0+ | the reason the binary is GPL |
|
||||
| x265 | GPL-2.0+ | |
|
||||
| libass | **ISC** | subtitle burn-in; not GPL |
|
||||
| libmp3lame | LGPL | MP3 encode |
|
||||
| libvpx, dav1d, SVT-AV1, libopus | BSD-style | |
|
||||
|
||||
### Explicitly excluded
|
||||
|
||||
- **`--enable-nonfree`** is never used. FFmpeg states it makes the resulting binary
|
||||
*unredistributable*. This rules out `libfdk_aac` and `decklink`.
|
||||
- **OpenH264** is not used. Its BSD-2 source license is *not* a patent grant — Cisco's
|
||||
royalty payment covers only Cisco's own precompiled binary module.
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
# Privacy Policy
|
||||
|
||||
**LibreMediaConverter does not collect any data.**
|
||||
|
||||
That is the whole policy, but here is what it means concretely.
|
||||
|
||||
## No data leaves your device
|
||||
|
||||
The app has **no network access**. It does not declare the `INTERNET` permission, so it
|
||||
is not merely a promise not to transmit anything — the operating system will not let it.
|
||||
Your files are converted on your device and stay there.
|
||||
|
||||
## No analytics, advertising or tracking
|
||||
|
||||
There is no analytics SDK, no crash reporter, no advertising identifier, and no
|
||||
third-party service of any kind.
|
||||
|
||||
## What the app accesses, and why
|
||||
|
||||
| Access | Why |
|
||||
|---|---|
|
||||
| Files you explicitly pick | Read as conversion input. The app uses the system file picker and can only see files you choose. It never scans your storage. |
|
||||
| The destination you choose for output | Write the converted file. Again, only where you point it. |
|
||||
| Notifications | Show conversion progress so you can leave the app while a long job runs. Optional; conversions work without it. |
|
||||
|
||||
The app does not request storage permissions. It uses the Storage Access Framework,
|
||||
which grants access only to the individual files you select.
|
||||
|
||||
## The full permission list
|
||||
|
||||
Inspecting the app will show a few permissions that are not in the table above. They are
|
||||
added automatically by the Jetpack WorkManager library, which runs conversions in the
|
||||
background. Listing them here rather than leaving you to wonder:
|
||||
|
||||
| Permission | Origin | What it does here |
|
||||
|---|---|---|
|
||||
| `FOREGROUND_SERVICE`, `FOREGROUND_SERVICE_MEDIA_PROCESSING`, `FOREGROUND_SERVICE_DATA_SYNC` | Ours | Keep a conversion running when the app is not in the foreground. Android requires a declared service type for this. |
|
||||
| `POST_NOTIFICATIONS` | Ours | Show conversion progress. Optional. |
|
||||
| `WAKE_LOCK` | WorkManager | Stop the device sleeping mid-conversion. |
|
||||
| `RECEIVE_BOOT_COMPLETED` | WorkManager | Restore an unfinished job queue after a restart. |
|
||||
| `ACCESS_NETWORK_STATE` | WorkManager | WorkManager can gate jobs on connectivity. **This app does not use that feature**, and the permission only allows reading whether a network exists — it does not permit any network communication. |
|
||||
|
||||
Notably absent is `INTERNET`. Without it the operating system will not allow the app to
|
||||
open a network connection at all, so "your files stay on your device" is enforced by
|
||||
Android rather than resting on our word.
|
||||
|
||||
## Where files are stored
|
||||
|
||||
Conversions are written to the app's private cache while they run, then copied to the
|
||||
location you choose. The temporary copy is deleted afterwards. Uninstalling the app
|
||||
removes everything in its private storage.
|
||||
|
||||
## Contact
|
||||
|
||||
Report issues at the project's repository.
|
||||
@@ -0,0 +1,122 @@
|
||||
# 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`](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: 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.
|
||||
|
||||
### FFmpeg — the long tail
|
||||
|
||||
Everything Media3 structurally cannot do:
|
||||
|
||||
- Containers outside MP4/WebM/Ogg/WAV/AAC — MKV, AVI, FLV, MPEG-TS
|
||||
- **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
|
||||
|
||||
### 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."
|
||||
|
||||
## Features
|
||||
|
||||
| | Formats |
|
||||
|---|---|
|
||||
| Video out | MP4 (H.264/H.265), MKV (H.264/H.265), WebM (VP9) |
|
||||
| Audio out | MP3, AAC/M4A, FLAC, Opus, WAV |
|
||||
| Images | GIF, PNG frame sequences |
|
||||
| Other | Join several files into one |
|
||||
|
||||
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/`](bin/README.md), 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`](bin/README.md) for its provenance and how to regenerate it.
|
||||
|
||||
```sh
|
||||
./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`](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 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.
|
||||
|
||||
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.
|
||||
|
||||
## 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](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`.
|
||||
+93
-10
@@ -1,41 +1,124 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
// Required even under AGP 9: the Compose compiler plugin is NOT built in.
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.example.androidmediaconverter"
|
||||
namespace = "org.libremediaconverter"
|
||||
|
||||
compileSdk {
|
||||
version = release(37)
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.example.androidmediaconverter"
|
||||
applicationId = "org.libremediaconverter"
|
||||
minSdk = 33
|
||||
// AGP 9 defaults targetSdk to compileSdk, so always state it explicitly.
|
||||
targetSdk = 37
|
||||
versionCode = 1
|
||||
versionName = "1.0"
|
||||
versionName = "0.1.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
// 64-bit only. The 16 KB page-size rules apply to these ABIs; 32-bit is not
|
||||
// required by Play and would add ~2 more copies of the FFmpeg .so files.
|
||||
//
|
||||
// Overridable so a test run can build for just the ABI it will execute on.
|
||||
// FFmpeg's native libraries dominate the APK, so shipping both ABIs to an
|
||||
// x86_64 emulator doubles it for no benefit -- and on API 37, whose system
|
||||
// image leaves less free userdata, the full APK does not fit at all:
|
||||
// "Requested internal only, but not enough space".
|
||||
//
|
||||
// ./gradlew :app:connectedDebugAndroidTest -PabiFilters=x86_64
|
||||
//
|
||||
// Release builds ignore this and always ship both.
|
||||
ndk {
|
||||
val requested = (findProperty("abiFilters") as String?)
|
||||
?.split(",")
|
||||
?.map { it.trim() }
|
||||
?.filter { it.isNotEmpty() }
|
||||
abiFilters += requested ?: listOf("arm64-v8a", "x86_64")
|
||||
}
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
optimization {
|
||||
enable = false
|
||||
// R8 full mode. Keep rules for the JNI boundary live in
|
||||
// src/main/keepRules/rules.keep -- without them the native FFmpeg
|
||||
// calls break at runtime in release builds only.
|
||||
enable = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
targetCompatibility = JavaVersion.VERSION_11
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
packaging {
|
||||
jniLibs {
|
||||
// Uncompressed .so, so the APK zip-aligns them on 16 KB boundaries.
|
||||
useLegacyPackaging = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Top-level: android.kotlinOptions {} was removed in AGP 9.
|
||||
// jvmTarget is inherited from compileOptions.targetCompatibility.
|
||||
kotlin {
|
||||
compilerOptions {}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.androidx.appcompat)
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.material)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||
|
||||
// Media3 Transformer: the hardware conversion path (MediaCodec decode -> GL surface
|
||||
// -> MediaCodec encode). Apache-2.0, so no licensing exposure.
|
||||
implementation(libs.media3.transformer)
|
||||
implementation(libs.media3.effect)
|
||||
implementation(libs.media3.common)
|
||||
implementation(libs.media3.muxer)
|
||||
|
||||
// FFmpeg, committed under bin/. Not on any Maven repo: ffmpeg-kit was archived and
|
||||
// delisted, and ffmpeg-kit-next is source-only by design.
|
||||
//
|
||||
// The prebuilt archive is checked in on purpose. Rebuilding it per CI run made test
|
||||
// results ambiguous -- a red build could mean broken code or a cross-compile that
|
||||
// hiccuped. See bin/README.md for provenance and how to regenerate it.
|
||||
implementation(files(rootProject.file("bin/ffmpeg-kit-next-8.1.1.aar")))
|
||||
// A local .aar carries no transitive dependencies, so the wrapper's own runtime
|
||||
// dependency has to be declared here explicitly.
|
||||
implementation(libs.smart.exception.java)
|
||||
|
||||
// Durable job queue. WorkManager survives process death, which is what makes the
|
||||
// queue resumable after the foreground-service timeout fires.
|
||||
implementation(libs.androidx.work.runtime.ktx)
|
||||
|
||||
implementation(platform(libs.compose.bom))
|
||||
implementation(libs.compose.ui)
|
||||
implementation(libs.compose.ui.graphics)
|
||||
implementation(libs.compose.ui.tooling.preview)
|
||||
implementation(libs.compose.material3)
|
||||
implementation(libs.compose.material3.windowsize)
|
||||
debugImplementation(libs.compose.ui.tooling)
|
||||
|
||||
testImplementation(libs.junit)
|
||||
androidTestImplementation(libs.androidx.espresso.core)
|
||||
|
||||
androidTestImplementation(platform(libs.compose.bom))
|
||||
androidTestImplementation(libs.androidx.junit)
|
||||
}
|
||||
androidTestImplementation(libs.androidx.espresso.core)
|
||||
androidTestImplementation(libs.compose.ui.test.junit4)
|
||||
androidTestImplementation(libs.androidx.work.testing)
|
||||
debugImplementation(libs.compose.ui.test.manifest)
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,24 +0,0 @@
|
||||
package com.example.androidmediaconverter
|
||||
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
import org.junit.Assert.*
|
||||
|
||||
/**
|
||||
* Instrumented test, which will execute on an Android device.
|
||||
*
|
||||
* See [testing documentation](http://d.android.com/tools/testing).
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ExampleInstrumentedTest {
|
||||
@Test
|
||||
fun useAppContext() {
|
||||
// Context of the app under test.
|
||||
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
assertEquals("com.example.androidmediaconverter", appContext.packageName)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package org.libremediaconverter.bench
|
||||
|
||||
import android.media.MediaExtractor
|
||||
import android.media.MediaFormat
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.media3.common.MimeTypes
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import org.libremediaconverter.codec.AndroidDeviceCodecs
|
||||
import org.libremediaconverter.convert.Media3Engine
|
||||
import org.libremediaconverter.convert.MediaProbe
|
||||
import org.libremediaconverter.ffmpeg.FFmpegEngine
|
||||
import org.libremediaconverter.model.ConversionRequest
|
||||
import org.libremediaconverter.model.ConversionRouter
|
||||
import org.libremediaconverter.model.OutputFormat
|
||||
import org.libremediaconverter.model.QualityTier
|
||||
import org.libremediaconverter.model.VideoCodec
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assume.assumeTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Measures the two claims that motivate the whole architecture, against real media.
|
||||
*
|
||||
* **This is a benchmark, not part of the automated suite.** It needs real, long-form
|
||||
* media that is not committed to the repository, so it skips unless someone stages
|
||||
* files deliberately. Do not read a passing run of the test suite as evidence these
|
||||
* numbers still hold — correctness lives in the tests that ship their own fixtures.
|
||||
*
|
||||
* Not a correctness test — the assertions are deliberately loose. These exist to
|
||||
* produce numbers for two decisions that were otherwise taken on faith:
|
||||
*
|
||||
* 1. that the hardware path is worth having a second engine for at all, and
|
||||
* 2. that x264's CRF is worth the GPL licence the app carries for it.
|
||||
*
|
||||
* Skips itself when the sample files are absent, so it is harmless in CI. Populate with:
|
||||
* adb push <file>.mp4 /sdcard/Android/data/org.libremediaconverter/files/
|
||||
*/
|
||||
@UnstableApi
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class RealMediaBenchmark {
|
||||
|
||||
private val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
/**
|
||||
* Internal storage, not the external files dir.
|
||||
*
|
||||
* Files placed in the external dir by `adb push` or `adb shell cp` stay owned by
|
||||
* the shell user, and the app then gets EACCES trying to read them — which
|
||||
* presents as an unparseable input rather than a permission problem. Piping
|
||||
* through `run-as` writes as the app's own uid, so ownership is unambiguous.
|
||||
*/
|
||||
private val samples: File get() = context.filesDir
|
||||
|
||||
private fun sample(name: String): File? =
|
||||
File(samples, name).takeIf { it.exists() && it.length() > 0 }
|
||||
|
||||
private fun durationMs(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()
|
||||
}
|
||||
}
|
||||
|
||||
/** Records what this device can actually do, so the numbers below have context. */
|
||||
@Test
|
||||
fun reportDeviceEncoderCapabilities() {
|
||||
val codecs = AndroidDeviceCodecs.get()
|
||||
val summary = VideoCodec.entries
|
||||
.filter { it != VideoCodec.NONE }
|
||||
.joinToString(", ") { "${it.name}=${codecs.canEncode(it)}" }
|
||||
Log.i(TAG, "BENCH hardware-encoders: ${codecs.hardwareEncoders()}")
|
||||
Log.i(TAG, "BENCH can-encode: $summary")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hardwareVersusSoftwareOnRealVideo(): Unit = runBlocking {
|
||||
val input = sample(H264_SAMPLE)
|
||||
assumeTrue("$H264_SAMPLE not present; skipping", input != null)
|
||||
input!!
|
||||
|
||||
val sourceMs = durationMs(input)
|
||||
val uri = Uri.fromFile(input)
|
||||
Log.i(TAG, "BENCH source: ${input.name} ${input.length() / 1_000_000}MB ${sourceMs}ms")
|
||||
|
||||
// --- hardware, via Media3 -------------------------------------------
|
||||
// Not every real file survives this path. This source is H.264 High 4:4:4
|
||||
// Predictive (avc1.F4001F), which neither the hardware decoder nor Android's
|
||||
// software c2.google.avc.decoder supports, so Media3 cannot read it at all.
|
||||
// Record that rather than failing: it is exactly why the FFmpeg fallback exists.
|
||||
val hwOut = File(context.cacheDir, "bench_hw.mp4").apply { delete() }
|
||||
val engine = Media3Engine(context)
|
||||
val hwMs = try {
|
||||
runCatching { timed { engine.transcode(uri, hwOut, MimeTypes.VIDEO_H265) } }
|
||||
.onFailure { Log.w(TAG, "BENCH hardware: UNSUPPORTED (${it.message})") }
|
||||
.getOrNull()
|
||||
} finally {
|
||||
engine.close()
|
||||
}
|
||||
|
||||
// --- software, via FFmpeg + x264 CRF --------------------------------
|
||||
val swOut = File(context.cacheDir, "bench_sw.mp4").apply { delete() }
|
||||
val swMs = timed {
|
||||
FFmpegEngine().run(
|
||||
request = ConversionRequest(
|
||||
format = OutputFormat.MP4_H264,
|
||||
quality = QualityTier.BEST,
|
||||
),
|
||||
inputPath = input.absolutePath,
|
||||
output = swOut,
|
||||
durationMs = sourceMs,
|
||||
)
|
||||
}
|
||||
|
||||
if (hwMs != null) {
|
||||
Log.i(
|
||||
TAG,
|
||||
"BENCH hardware: ${hwMs}ms -> ${hwOut.length() / 1_000_000}MB " +
|
||||
"(${"%.1f".format(sourceMs.toDouble() / hwMs)}x realtime)",
|
||||
)
|
||||
}
|
||||
Log.i(
|
||||
TAG,
|
||||
"BENCH software: ${swMs}ms -> ${swOut.length() / 1_000_000}MB " +
|
||||
"(${"%.2f".format(sourceMs.toDouble() / swMs)}x realtime)",
|
||||
)
|
||||
if (hwMs != null) {
|
||||
Log.i(TAG, "BENCH speedup: ${"%.1f".format(swMs.toDouble() / hwMs)}x")
|
||||
}
|
||||
|
||||
hwOut.delete()
|
||||
swOut.delete()
|
||||
}
|
||||
|
||||
/**
|
||||
* AV1 input is the sharpest routing case: Transformer cannot fall back to a
|
||||
* software decoder, so a device without hardware AV1 decode must go to FFmpeg.
|
||||
*/
|
||||
@Test
|
||||
fun av1InputRoutesAccordingToDeviceDecodeSupport(): Unit = runBlocking {
|
||||
val input = sample(AV1_SAMPLE)
|
||||
assumeTrue("$AV1_SAMPLE not present; skipping", input != null)
|
||||
input!!
|
||||
|
||||
val probe = MediaProbe.probe(context, Uri.fromFile(input))
|
||||
val decision = ConversionRouter.route(
|
||||
ConversionRequest(format = OutputFormat.MP4_H265, probe = probe),
|
||||
AndroidDeviceCodecs.get(),
|
||||
)
|
||||
Log.i(TAG, "BENCH av1 probe: codec=${probe.videoCodec} duration=${probe.durationMs}ms")
|
||||
Log.i(TAG, "BENCH av1 route: ${decision.engine} (${decision.reason})")
|
||||
|
||||
val out = File(context.cacheDir, "bench_av1_out.mp4").apply { delete() }
|
||||
val engine = Media3Engine(context)
|
||||
val ms = try {
|
||||
timed { engine.transcode(Uri.fromFile(input), out, MimeTypes.VIDEO_H265) }
|
||||
} finally {
|
||||
engine.close()
|
||||
}
|
||||
Log.i(
|
||||
TAG,
|
||||
"BENCH av1 transcode: ${ms}ms -> ${out.length() / 1_000_000}MB " +
|
||||
"(${"%.1f".format(probe.durationMs.toDouble() / ms)}x realtime)",
|
||||
)
|
||||
out.delete()
|
||||
}
|
||||
|
||||
private inline fun timed(block: () -> Unit): Long {
|
||||
val start = System.currentTimeMillis()
|
||||
block()
|
||||
return System.currentTimeMillis() - start
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "RealMediaBenchmark"
|
||||
const val H264_SAMPLE = "bench_h264_720p.mp4"
|
||||
const val AV1_SAMPLE = "bench_av1_1080p.mp4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package org.libremediaconverter.convert
|
||||
|
||||
import android.media.MediaExtractor
|
||||
import android.media.MediaFormat
|
||||
import android.net.Uri
|
||||
import androidx.media3.common.MimeTypes
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.io.File
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* End-to-end hardware transcode through [Media3Engine].
|
||||
*
|
||||
* This is the Phase 1 verification: it proves the MediaCodec pipeline actually runs on
|
||||
* a device, and — more importantly — that the engine can be driven from a thread with
|
||||
* no Looper of its own without tripping Transformer's single-thread requirement.
|
||||
*/
|
||||
@UnstableApi
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class Media3EngineTest {
|
||||
|
||||
private val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
private lateinit var engine: Media3Engine
|
||||
private lateinit var input: File
|
||||
private lateinit var output: File
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
engine = Media3Engine(context)
|
||||
input = File(context.cacheDir, "sample_h264.mp4")
|
||||
InstrumentationRegistry.getInstrumentation().context.assets
|
||||
.open("sample_h264.mp4")
|
||||
.use { asset -> input.outputStream().use { asset.copyTo(it) } }
|
||||
output = File(context.cacheDir, "out_hevc.mp4")
|
||||
output.delete()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
engine.close()
|
||||
input.delete()
|
||||
output.delete()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun transcodesH264ToH265AndReportsProgress(): Unit = runBlocking {
|
||||
val seen = mutableListOf<Int>()
|
||||
|
||||
engine.transcode(
|
||||
input = Uri.fromFile(input),
|
||||
output = output,
|
||||
videoMimeType = MimeTypes.VIDEO_H265,
|
||||
) { percent -> seen += percent }
|
||||
|
||||
assertTrue("export produced no file", output.exists())
|
||||
assertTrue("export produced an empty file", output.length() > 0)
|
||||
// Assert against the muxed file, not just the reported result: this is what
|
||||
// actually proves the output is HEVC rather than a silent fallback to H.264.
|
||||
assertEquals(MimeTypes.VIDEO_H265, videoMimeTypeOf(output))
|
||||
// Assert against the muxed file rather than the engine's own report: a result
|
||||
// object can claim success for a file that will not play.
|
||||
assertTrue("output has no duration", durationMsOf(output) > 0)
|
||||
// Deliberately NOT asserting that progress fired. Polling is on a 250 ms tick,
|
||||
// and a 3 s 320x240 clip can finish inside one tick on fast hardware, which
|
||||
// would make the assertion fail intermittently for no real defect.
|
||||
seen.forEach { assertTrue("progress out of range: $it", it in 0..100) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Regression guard for the Transformer threading trap.
|
||||
*
|
||||
* Transformer binds to the Looper of the thread that built it, falling back to the
|
||||
* main Looper when that thread has none — and then throws IllegalStateException
|
||||
* when start() is called from elsewhere. A WorkManager Worker runs on exactly such
|
||||
* a Looper-less thread, so this test drives the engine from one to prove the
|
||||
* HandlerThread indirection holds before any of that lands in Phase 2.
|
||||
*/
|
||||
@Test
|
||||
fun runsFromAThreadWithNoLooper() {
|
||||
val pool = Executors.newSingleThreadExecutor()
|
||||
try {
|
||||
val task = pool.submit<Throwable?> {
|
||||
check(android.os.Looper.myLooper() == null) {
|
||||
"precondition failed: this thread should have no Looper"
|
||||
}
|
||||
runCatching {
|
||||
runBlocking { engine.transcode(Uri.fromFile(input), output) }
|
||||
}.exceptionOrNull()
|
||||
}
|
||||
val failure = task.get(TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||
assertTrue(
|
||||
"transcode from a Looper-less thread failed: $failure",
|
||||
failure == null,
|
||||
)
|
||||
assertTrue(output.exists() && output.length() > 0)
|
||||
} finally {
|
||||
pool.shutdownNow()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transformer.start() failing synchronously.
|
||||
*
|
||||
* Previously written off as Media3-internal and unreachable. It is not: an output
|
||||
* path whose parent directory does not exist makes start() fail, and the engine has
|
||||
* to surface that as a rejected suspension rather than hanging forever waiting for
|
||||
* a listener callback that will never come. A hang here would be far worse than an
|
||||
* exception, because the worker would sit holding a foreground service.
|
||||
*/
|
||||
@Test
|
||||
fun anUnwritableOutputPathFailsInsteadOfHanging() {
|
||||
val impossible = File("/does/not/exist/nested/out.mp4")
|
||||
val failure = runCatching {
|
||||
runBlocking {
|
||||
withTimeout(30_000) {
|
||||
engine.transcode(Uri.fromFile(input), impossible, MimeTypes.VIDEO_H265) {}
|
||||
}
|
||||
}
|
||||
}.exceptionOrNull()
|
||||
|
||||
assertTrue(
|
||||
"an unwritable output must raise, not hang or silently pass; got $failure",
|
||||
failure != null && failure !is kotlinx.coroutines.TimeoutCancellationException,
|
||||
)
|
||||
}
|
||||
|
||||
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 fun videoMimeTypeOf(file: File): String? {
|
||||
val extractor = MediaExtractor()
|
||||
try {
|
||||
extractor.setDataSource(file.absolutePath)
|
||||
for (i in 0 until extractor.trackCount) {
|
||||
val format = extractor.getTrackFormat(i)
|
||||
val mime = format.getString(MediaFormat.KEY_MIME).orEmpty()
|
||||
if (mime.startsWith("video/")) return mime
|
||||
}
|
||||
return null
|
||||
} finally {
|
||||
extractor.release()
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TIMEOUT_SECONDS = 120L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package org.libremediaconverter.fallback
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import org.libremediaconverter.convert.ConversionDependencies
|
||||
import org.libremediaconverter.convert.HardwareTranscoder
|
||||
import org.libremediaconverter.convert.OutputPublisher
|
||||
import org.libremediaconverter.convert.SoftwareTranscoder
|
||||
import org.libremediaconverter.model.ConversionRequest
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Test doubles that force the failure paths.
|
||||
*
|
||||
* These exist because error handling is otherwise the least-tested code in the app: by
|
||||
* definition it only runs when something goes wrong, which is exactly what a healthy
|
||||
* test run avoids. Without a way to inject failure, the branches a user meets on a bad
|
||||
* day are the ones that were never executed.
|
||||
*/
|
||||
object FakeFailures {
|
||||
|
||||
class ExplodingHardware(private val message: String = "hardware exploded") : HardwareTranscoder {
|
||||
var called = false
|
||||
override suspend fun transcode(
|
||||
input: Uri,
|
||||
output: File,
|
||||
videoMimeType: String,
|
||||
onProgress: (Int) -> Unit,
|
||||
) {
|
||||
called = true
|
||||
throw IllegalStateException(message)
|
||||
}
|
||||
|
||||
override fun close() = Unit
|
||||
}
|
||||
|
||||
class ExplodingSoftware(private val message: String = "software exploded") : SoftwareTranscoder {
|
||||
var called = false
|
||||
override suspend fun run(
|
||||
request: ConversionRequest,
|
||||
inputPath: String,
|
||||
output: File,
|
||||
durationMs: Long,
|
||||
onProgress: (Int) -> Unit,
|
||||
) {
|
||||
called = true
|
||||
throw IllegalStateException(message)
|
||||
}
|
||||
}
|
||||
|
||||
/** Records that it ran and writes a plausible output, without doing real work. */
|
||||
class RecordingSoftware : SoftwareTranscoder {
|
||||
var called = false
|
||||
override suspend fun run(
|
||||
request: ConversionRequest,
|
||||
inputPath: String,
|
||||
output: File,
|
||||
durationMs: Long,
|
||||
onProgress: (Int) -> Unit,
|
||||
) {
|
||||
called = true
|
||||
output.parentFile?.mkdirs()
|
||||
output.writeBytes(ByteArray(1024))
|
||||
onProgress(100)
|
||||
}
|
||||
}
|
||||
|
||||
class FullDisk(context: Context) : OutputPublisher(context) {
|
||||
override fun hasSpaceFor(bytes: Long): Boolean = false
|
||||
}
|
||||
|
||||
/** Restores the real implementations. Always call this from @After. */
|
||||
fun reset() = ConversionDependencies.reset()
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package org.libremediaconverter.fallback
|
||||
|
||||
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 org.libremediaconverter.convert.ConversionDependencies
|
||||
import org.libremediaconverter.model.DeviceCodecs
|
||||
import org.libremediaconverter.model.Engine
|
||||
import org.libremediaconverter.model.OutputFormat
|
||||
import org.libremediaconverter.model.QualityTier
|
||||
import org.libremediaconverter.work.ConcatWorker
|
||||
import org.libremediaconverter.work.ConversionWorker
|
||||
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.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Forces each failure path that real media cannot reliably trigger.
|
||||
*
|
||||
* Paired with [HardwareFallbackTest], which drives the same fallback with a genuinely
|
||||
* undecodable file. This one covers the branches that would otherwise need a full disk,
|
||||
* a broken codec, or a six-hour foreground-service budget to reach.
|
||||
*/
|
||||
@UnstableApi
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ForcedFailureTest {
|
||||
|
||||
private val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
private val workManager = WorkManager.getInstance(context)
|
||||
private lateinit var input: File
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
input = File(context.cacheDir, SAMPLE)
|
||||
InstrumentationRegistry.getInstrumentation().context.assets
|
||||
.open(SAMPLE)
|
||||
.use { asset -> input.outputStream().use { asset.copyTo(it) } }
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
FakeFailures.reset()
|
||||
input.delete()
|
||||
File(context.cacheDir, "conversions").listFiles()?.forEach { it.delete() }
|
||||
}
|
||||
|
||||
private suspend fun runToCompletion(request: androidx.work.OneTimeWorkRequest): WorkInfo? {
|
||||
workManager.enqueue(request).result.get()
|
||||
return withTimeout(TIMEOUT_MS) {
|
||||
workManager.getWorkInfoByIdFlow(request.id).first { it != null && it.state.isFinished }
|
||||
}
|
||||
}
|
||||
|
||||
private fun convertRequest(format: OutputFormat = OutputFormat.MP4_H265) =
|
||||
ConversionWorker.request(
|
||||
inputUri = Uri.fromFile(input),
|
||||
displayName = SAMPLE,
|
||||
sizeBytes = input.length(),
|
||||
format = format,
|
||||
quality = QualityTier.FAST,
|
||||
)
|
||||
|
||||
// --- the dynamic fallback, forced rather than provoked -------------------
|
||||
|
||||
@Test
|
||||
fun hardwareFailureFallsBackToSoftware(): Unit = runBlocking {
|
||||
val hardware = FakeFailures.ExplodingHardware()
|
||||
val software = FakeFailures.RecordingSoftware()
|
||||
ConversionDependencies.hardware = { hardware }
|
||||
ConversionDependencies.software = { software }
|
||||
// Pin the device profile. This test is about the fallback mechanism, not about
|
||||
// routing, and most emulators expose no hardware video encoder at all -- so the
|
||||
// router would legitimately send the job straight to FFmpeg and the hardware
|
||||
// path would never be attempted. Without this the test passes on a Pixel and
|
||||
// fails on every emulator, which says nothing about the code under test.
|
||||
ConversionDependencies.deviceCodecs = { DeviceCodecs.PERMISSIVE }
|
||||
|
||||
val terminal = runToCompletion(convertRequest(OutputFormat.MP4_H264))
|
||||
|
||||
assertEquals(WorkInfo.State.SUCCEEDED, terminal?.state)
|
||||
assertTrue("the hardware path should have been attempted", hardware.called)
|
||||
assertTrue("the software path should have rescued it", software.called)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun whenBothEnginesFailTheJobFailsWithTheReason(): Unit = runBlocking {
|
||||
ConversionDependencies.hardware = { FakeFailures.ExplodingHardware() }
|
||||
ConversionDependencies.software = { FakeFailures.ExplodingSoftware("no codec available") }
|
||||
|
||||
val terminal = runToCompletion(convertRequest(OutputFormat.MP4_H264))
|
||||
|
||||
assertEquals(WorkInfo.State.FAILED, terminal?.state)
|
||||
assertEquals(
|
||||
"the user should see why it failed",
|
||||
"no codec available",
|
||||
terminal?.outputData?.getString(ConversionWorker.KEY_ERROR),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aJobRoutedStraightToFfmpegDoesNotTouchTheHardwarePath(): Unit = runBlocking {
|
||||
val hardware = FakeFailures.ExplodingHardware()
|
||||
val software = FakeFailures.RecordingSoftware()
|
||||
ConversionDependencies.hardware = { hardware }
|
||||
ConversionDependencies.software = { software }
|
||||
|
||||
// MP3 has no Android encoder at all, so the router must bypass Media3 entirely.
|
||||
val terminal = runToCompletion(convertRequest(OutputFormat.MP3))
|
||||
|
||||
assertEquals(WorkInfo.State.SUCCEEDED, terminal?.state)
|
||||
assertEquals(
|
||||
Engine.FFMPEG.name,
|
||||
terminal?.outputData?.getString(ConversionWorker.KEY_ENGINE_USED),
|
||||
)
|
||||
assertTrue(!hardware.called, "the hardware path must not be attempted for MP3")
|
||||
assertTrue("software should have run", software.called)
|
||||
}
|
||||
|
||||
// --- the free-space precheck -------------------------------------------
|
||||
|
||||
@Test
|
||||
fun aFullDiskFailsBeforeAnyConversionStarts(): Unit = runBlocking {
|
||||
val hardware = FakeFailures.ExplodingHardware()
|
||||
ConversionDependencies.publisher = { FakeFailures.FullDisk(it) }
|
||||
ConversionDependencies.hardware = { hardware }
|
||||
|
||||
val terminal = runToCompletion(convertRequest())
|
||||
|
||||
assertEquals(WorkInfo.State.FAILED, terminal?.state)
|
||||
assertTrue(
|
||||
"the message should mention space, was: " +
|
||||
terminal?.outputData?.getString(ConversionWorker.KEY_ERROR),
|
||||
terminal?.outputData?.getString(ConversionWorker.KEY_ERROR)
|
||||
.orEmpty().contains("space", ignoreCase = true),
|
||||
)
|
||||
assertTrue(
|
||||
"no conversion should be attempted when the disk is full",
|
||||
!hardware.called,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aFullDiskFailsAJoinBeforeItStarts(): Unit = runBlocking {
|
||||
ConversionDependencies.publisher = { FakeFailures.FullDisk(it) }
|
||||
|
||||
val request = ConcatWorker.request(
|
||||
inputs = listOf(Uri.fromFile(input), Uri.fromFile(input)),
|
||||
totalBytes = input.length() * 2,
|
||||
)
|
||||
val terminal = runToCompletion(request)
|
||||
|
||||
assertEquals(WorkInfo.State.FAILED, terminal?.state)
|
||||
assertTrue(
|
||||
terminal?.outputData?.getString(ConcatWorker.KEY_ERROR)
|
||||
.orEmpty().contains("space", ignoreCase = true),
|
||||
)
|
||||
}
|
||||
|
||||
// --- malformed input ----------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun aMissingInputFailsRatherThanCrashing(): Unit = runBlocking {
|
||||
val request = ConversionWorker.request(
|
||||
inputUri = Uri.fromFile(File(context.cacheDir, "does_not_exist.mp4")),
|
||||
displayName = "does_not_exist.mp4",
|
||||
sizeBytes = 1,
|
||||
)
|
||||
val terminal = runToCompletion(request)
|
||||
assertEquals(WorkInfo.State.FAILED, terminal?.state)
|
||||
}
|
||||
|
||||
private fun assertTrue(message: String, condition: Boolean) =
|
||||
org.junit.Assert.assertTrue(message, condition)
|
||||
|
||||
private fun assertTrue(condition: Boolean, message: String) =
|
||||
org.junit.Assert.assertTrue(message, condition)
|
||||
|
||||
private companion object {
|
||||
const val SAMPLE = "sample_h264.mp4"
|
||||
const val TIMEOUT_MS = 300_000L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package org.libremediaconverter.fallback
|
||||
|
||||
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 org.libremediaconverter.model.OutputFormat
|
||||
import org.libremediaconverter.model.QualityTier
|
||||
import org.libremediaconverter.work.ConversionWorker
|
||||
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.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* The runtime fallback, driven by a file Media3 genuinely cannot decode.
|
||||
*
|
||||
* The fixture is H.264 **High 4:4:4 Predictive**. Hardware AVC decoders implement High
|
||||
* 4:2:0, and Android's software `c2.google.avc.decoder` does not cover 4:4:4 either, so
|
||||
* Media3 fails partway through the export on every device.
|
||||
*
|
||||
* The static routing rules cannot predict that: the container is MP4, the codec reports
|
||||
* as "h264", and the device advertises AVC decode and encode. Everything looks viable
|
||||
* until the codec is configured. This is the one case that proves the runtime fallback
|
||||
* works, so it deliberately uses a **committed fixture** rather than media staged by
|
||||
* hand — a regression test that silently skips is worse than no test, because the count
|
||||
* still reads as coverage.
|
||||
*
|
||||
* The fixture was produced with x264, which the host toolchain cannot do (Fedora's
|
||||
* ffmpeg ships openh264, which is Constrained Baseline only):
|
||||
*
|
||||
* ffmpeg -f lavfi -i testsrc=duration=3:size=320x240:rate=15 \
|
||||
* -f lavfi -i sine=frequency=440:duration=3 \
|
||||
* -c:v libx264 -profile:v high444 -pix_fmt yuv444p -preset ultrafast \
|
||||
* -c:a aac -shortest sample_h264_444.mp4
|
||||
*/
|
||||
@UnstableApi
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class HardwareFallbackTest {
|
||||
|
||||
private val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
private val workManager = WorkManager.getInstance(context)
|
||||
private lateinit var input: File
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
input = File(context.cacheDir, SAMPLE)
|
||||
InstrumentationRegistry.getInstrumentation().context.assets
|
||||
.open(SAMPLE)
|
||||
.use { asset -> input.outputStream().use { asset.copyTo(it) } }
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
input.delete()
|
||||
File(context.cacheDir, "conversions").listFiles()?.forEach { it.delete() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aFileMedia3CannotDecodeStillConvertsViaFfmpeg(): Unit = runBlocking {
|
||||
val request = ConversionWorker.request(
|
||||
inputUri = Uri.fromFile(input),
|
||||
displayName = SAMPLE,
|
||||
sizeBytes = input.length(),
|
||||
format = OutputFormat.MP4_H265,
|
||||
// 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,
|
||||
)
|
||||
workManager.enqueue(request).result.get()
|
||||
|
||||
val terminal = withTimeout(TIMEOUT_MS) {
|
||||
workManager.getWorkInfoByIdFlow(request.id).first { it != null && it.state.isFinished }
|
||||
}
|
||||
|
||||
val error = terminal?.outputData?.getString(ConversionWorker.KEY_ERROR)
|
||||
assertEquals(
|
||||
"a file Media3 cannot decode must still convert, but failed with: $error",
|
||||
WorkInfo.State.SUCCEEDED,
|
||||
terminal?.state,
|
||||
)
|
||||
|
||||
val out = File(terminal!!.outputData.getString(ConversionWorker.KEY_OUTPUT_PATH)!!)
|
||||
assertTrue("no output produced", out.exists() && out.length() > 0)
|
||||
out.delete()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val SAMPLE = "sample_h264_444.mp4"
|
||||
const val TIMEOUT_MS = 600_000L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package org.libremediaconverter.fallback
|
||||
|
||||
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.Data
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import org.libremediaconverter.convert.OutputPublisher
|
||||
import org.libremediaconverter.ffmpeg.ConcatEngine
|
||||
import org.libremediaconverter.work.ConcatWorker
|
||||
import org.libremediaconverter.work.ConversionWorker
|
||||
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.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* The branches that handle a URI the app cannot open.
|
||||
*
|
||||
* These were previously written off as needing a SAF grant to be revoked mid-job. That
|
||||
* was wrong: a URI that was *never* valid reaches exactly the same null branch as one
|
||||
* whose permission was withdrawn, and pointing at a provider that does not exist is
|
||||
* trivial to arrange.
|
||||
*
|
||||
* The assertions deliberately check the *outcome* rather than a specific exception
|
||||
* type. Whether the resolver returns null or throws is an implementation detail of the
|
||||
* provider; what matters is that the job fails cleanly and tells the user something,
|
||||
* instead of crashing the worker.
|
||||
*/
|
||||
@UnstableApi
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class UnopenableUriTest {
|
||||
|
||||
private val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
private val workManager = WorkManager.getInstance(context)
|
||||
private lateinit var staged: File
|
||||
|
||||
/** A syntactically valid content URI whose authority does not exist. */
|
||||
private val bogus: Uri = Uri.parse("content://org.libremediaconverter.nonexistent.provider/media/1")
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
staged = File(context.cacheDir, "unopenable_src.bin").apply { writeBytes(ByteArray(2048)) }
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
FakeFailures.reset()
|
||||
staged.delete()
|
||||
File(context.cacheDir, "conversions").listFiles()?.forEach { it.delete() }
|
||||
}
|
||||
|
||||
private suspend fun runToCompletion(request: androidx.work.OneTimeWorkRequest): WorkInfo? {
|
||||
workManager.enqueue(request).result.get()
|
||||
return withTimeout(TIMEOUT_MS) {
|
||||
workManager.getWorkInfoByIdFlow(request.id).first { it != null && it.state.isFinished }
|
||||
}
|
||||
}
|
||||
|
||||
// --- 1. conversion input that cannot be opened ---------------------------
|
||||
|
||||
@Test
|
||||
fun aConversionInputThatCannotBeOpenedFailsCleanly(): Unit = runBlocking {
|
||||
val request = ConversionWorker.request(
|
||||
inputUri = bogus,
|
||||
displayName = "gone.mp4",
|
||||
sizeBytes = 1024,
|
||||
)
|
||||
val terminal = runToCompletion(request)
|
||||
|
||||
assertEquals(WorkInfo.State.FAILED, terminal?.state)
|
||||
assertTrue(
|
||||
"the failure should carry a message for the user",
|
||||
!terminal?.outputData?.getString(ConversionWorker.KEY_ERROR).isNullOrBlank(),
|
||||
)
|
||||
}
|
||||
|
||||
// --- 2. destination that cannot be written -------------------------------
|
||||
|
||||
@Test
|
||||
fun publishingToAnUnwritableDestinationThrowsRatherThanSilentlySucceeding() {
|
||||
val publisher = OutputPublisher(context)
|
||||
val failure = runCatching { publisher.publish(staged, bogus) }.exceptionOrNull()
|
||||
assertTrue(
|
||||
"publishing to a dead provider must not appear to succeed, got $failure",
|
||||
failure != null,
|
||||
)
|
||||
}
|
||||
|
||||
// --- 3. concat input that cannot be opened -------------------------------
|
||||
|
||||
@Test
|
||||
fun aJoinInputThatCannotBeOpenedFailsCleanly(): Unit = runBlocking {
|
||||
val out = File(context.cacheDir, "joined_unopenable.mp4").also { it.delete() }
|
||||
val failure = runCatching {
|
||||
ConcatEngine(context).join(listOf(bogus, bogus), out)
|
||||
}.exceptionOrNull()
|
||||
assertTrue("joining unopenable inputs must fail, got $failure", failure != null)
|
||||
out.delete()
|
||||
}
|
||||
|
||||
// --- 4. the concat worker's missing-input-array branch -------------------
|
||||
|
||||
@Test
|
||||
fun aJoinWithNoInputArrayFailsWithAMessage(): Unit = runBlocking {
|
||||
// ConcatWorker.request() always sets the array, so this branch is unreachable
|
||||
// through it -- but a worker is just input Data, and WorkManager will happily
|
||||
// run one built without it. That is also what a corrupted or version-skewed
|
||||
// queue entry would look like after an app update.
|
||||
val request = OneTimeWorkRequestBuilder<ConcatWorker>()
|
||||
.setInputData(Data.Builder().putLong(ConcatWorker.KEY_TOTAL_BYTES, 1).build())
|
||||
.build()
|
||||
|
||||
val terminal = runToCompletion(request)
|
||||
|
||||
assertEquals(WorkInfo.State.FAILED, terminal?.state)
|
||||
assertEquals(
|
||||
"No input files.",
|
||||
terminal?.outputData?.getString(ConcatWorker.KEY_ERROR),
|
||||
)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TIMEOUT_MS = 300_000L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package org.libremediaconverter.ffmpeg
|
||||
|
||||
import android.media.MediaExtractor
|
||||
import android.media.MediaFormat
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import org.libremediaconverter.model.ConversionRequest
|
||||
import org.libremediaconverter.model.OutputFormat
|
||||
import org.libremediaconverter.model.QualityTier
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Exercises the bundled FFmpeg on-device.
|
||||
*
|
||||
* These are the formats the app exists for that Media3 structurally cannot produce, so
|
||||
* they are also the ones with no other coverage. Each test asserts against the produced
|
||||
* file rather than the exit code, because FFmpeg will happily report success after
|
||||
* writing something unplayable if the arguments are wrong.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class FFmpegEngineTest {
|
||||
|
||||
private val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
private val engine: org.libremediaconverter.convert.SoftwareTranscoder = FFmpegEngine()
|
||||
private lateinit var input: File
|
||||
private val outputs = mutableListOf<File>()
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
input = File(context.cacheDir, "ffmpeg_sample.mp4")
|
||||
InstrumentationRegistry.getInstrumentation().context.assets
|
||||
.open("sample_h264.mp4")
|
||||
.use { asset -> input.outputStream().use { asset.copyTo(it) } }
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
input.delete()
|
||||
outputs.forEach { it.delete() }
|
||||
}
|
||||
|
||||
private fun outputFor(name: String) =
|
||||
File(context.cacheDir, name).also { it.delete(); outputs += it }
|
||||
|
||||
private fun convert(format: OutputFormat, quality: QualityTier = QualityTier.BEST): File {
|
||||
val out = outputFor("out_${format.name.lowercase()}.${format.extension}")
|
||||
runBlocking {
|
||||
engine.run(
|
||||
request = ConversionRequest(format = format, quality = quality),
|
||||
inputPath = input.absolutePath,
|
||||
output = out,
|
||||
durationMs = 3_000,
|
||||
)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private fun trackMimes(file: File): List<String> {
|
||||
val extractor = MediaExtractor()
|
||||
return try {
|
||||
extractor.setDataSource(file.absolutePath)
|
||||
(0 until extractor.trackCount).map {
|
||||
extractor.getTrackFormat(it).getString(MediaFormat.KEY_MIME).orEmpty()
|
||||
}
|
||||
} finally {
|
||||
extractor.release()
|
||||
}
|
||||
}
|
||||
|
||||
// --- the formats that justify bundling FFmpeg at all -------------------
|
||||
|
||||
@Test
|
||||
fun encodesMp3WhichAndroidCannotDoAtAnyApiLevel() {
|
||||
val out = convert(OutputFormat.MP3)
|
||||
assertTrue("no MP3 produced", out.exists() && out.length() > 0)
|
||||
assertTrue(
|
||||
"expected an mpeg audio track, got ${trackMimes(out)}",
|
||||
trackMimes(out).any { it.contains("mp") && it.startsWith("audio/") },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun encodesGifWithAGeneratedPalette() {
|
||||
val out = convert(OutputFormat.GIF)
|
||||
assertTrue("no GIF produced", out.exists() && out.length() > 0)
|
||||
// GIF87a / GIF89a magic. Proves a real GIF rather than a mislabelled file.
|
||||
val magic = out.inputStream().use { String(it.readNBytes(6)) }
|
||||
assertTrue("not a GIF: $magic", magic.startsWith("GIF"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun encodesMatroskaWhichMedia3CannotMux() {
|
||||
val out = convert(OutputFormat.MKV_H264)
|
||||
assertTrue("no MKV produced", out.exists() && out.length() > 0)
|
||||
// EBML magic, the Matroska container header.
|
||||
val magic = out.inputStream().use { it.readNBytes(4) }
|
||||
assertEquals(0x1A.toByte(), magic[0])
|
||||
assertEquals(0x45.toByte(), magic[1])
|
||||
assertEquals(0xDF.toByte(), magic[2])
|
||||
assertEquals(0xA3.toByte(), magic[3])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun encodesFlacLosslessAudio() {
|
||||
val out = convert(OutputFormat.FLAC)
|
||||
assertTrue("no FLAC produced", out.exists() && out.length() > 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun encodesWav() {
|
||||
val out = convert(OutputFormat.WAV)
|
||||
assertTrue("no WAV produced", out.exists() && out.length() > 0)
|
||||
val magic = out.inputStream().use { String(it.readNBytes(4)) }
|
||||
assertEquals("RIFF", magic)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun encodesOpus() {
|
||||
val out = convert(OutputFormat.OPUS)
|
||||
assertTrue("no Opus produced", out.exists() && out.length() > 0)
|
||||
}
|
||||
|
||||
// --- the quality tier the GPL licence was taken for --------------------
|
||||
|
||||
@Test
|
||||
fun bestQualityProducesAPlayableH264File() {
|
||||
val out = convert(OutputFormat.MP4_H264, QualityTier.BEST)
|
||||
assertTrue("no MP4 produced", out.exists() && out.length() > 0)
|
||||
assertTrue(
|
||||
"expected an AVC track, got ${trackMimes(out)}",
|
||||
trackMimes(out).any { it == MediaFormat.MIMETYPE_VIDEO_AVC },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bestQualityProducesAPlayableH265File() {
|
||||
val out = convert(OutputFormat.MP4_H265, QualityTier.BEST)
|
||||
assertTrue("no MP4 produced", out.exists() && out.length() > 0)
|
||||
assertTrue(
|
||||
"expected an HEVC track, got ${trackMimes(out)}",
|
||||
trackMimes(out).any { it == MediaFormat.MIMETYPE_VIDEO_HEVC },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun failureSurfacesAsAnExceptionRatherThanASilentEmptyFile() {
|
||||
val out = outputFor("nope.mp4")
|
||||
val failure = runCatching {
|
||||
runBlocking {
|
||||
engine.run(
|
||||
request = ConversionRequest(format = OutputFormat.MP4_H264),
|
||||
inputPath = "/does/not/exist.mp4",
|
||||
output = out,
|
||||
durationMs = 1_000,
|
||||
)
|
||||
}
|
||||
}.exceptionOrNull()
|
||||
assertTrue("expected an FFmpegException, got $failure", failure is FFmpegEngine.FFmpegException)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package org.libremediaconverter.join
|
||||
|
||||
import android.media.MediaExtractor
|
||||
import android.media.MediaFormat
|
||||
import android.net.Uri
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import org.libremediaconverter.convert.MediaProbe
|
||||
import org.libremediaconverter.ffmpeg.ConcatEngine
|
||||
import org.libremediaconverter.model.ConcatStrategy
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* The join path, end to end on a device.
|
||||
*
|
||||
* The point of these tests is the strategy decision, not merely that a file appears.
|
||||
* FFmpeg's `concat` demuxer does not reliably reject mismatched inputs -- it can emit a
|
||||
* file whose later segments are garbled -- so "it produced output" is not evidence of
|
||||
* correctness. Each test therefore checks which strategy ran *and* that the result is
|
||||
* long enough to contain both inputs.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ConcatEngineTest {
|
||||
|
||||
private val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
private val engine = ConcatEngine(context)
|
||||
private val staged = mutableListOf<File>()
|
||||
|
||||
private lateinit var clipA: File
|
||||
private lateinit var clipB: File
|
||||
private lateinit var clipMismatched: File
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
clipA = copyAsset("clip_a.mp4")
|
||||
clipB = copyAsset("clip_b.mp4")
|
||||
clipMismatched = copyAsset("clip_c_mismatched.mp4")
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
(staged + listOf(clipA, clipB, clipMismatched)).forEach { it.delete() }
|
||||
}
|
||||
|
||||
private fun copyAsset(name: String): File {
|
||||
val out = File(context.cacheDir, name)
|
||||
InstrumentationRegistry.getInstrumentation().context.assets
|
||||
.open(name)
|
||||
.use { asset -> out.outputStream().use { asset.copyTo(it) } }
|
||||
return out
|
||||
}
|
||||
|
||||
private fun output(name: String) =
|
||||
File(context.cacheDir, name).also { it.delete(); staged += it }
|
||||
|
||||
private fun durationMs(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()
|
||||
}
|
||||
}
|
||||
|
||||
// --- the fast path ------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun matchingClipsAreJoinedByStreamCopy(): Unit = runBlocking {
|
||||
val out = output("joined_matching.mp4")
|
||||
val result = engine.join(listOf(Uri.fromFile(clipA), Uri.fromFile(clipB)), out)
|
||||
|
||||
assertEquals(
|
||||
"identical inputs should not need re-encoding",
|
||||
ConcatStrategy.STREAM_COPY,
|
||||
result.strategy,
|
||||
)
|
||||
assertTrue("no output produced", out.exists() && out.length() > 0)
|
||||
// Both 2 s inputs must be present, not just the first.
|
||||
assertTrue(
|
||||
"joined duration ${durationMs(out)}ms is too short to hold both clips",
|
||||
durationMs(out) >= 3_500,
|
||||
)
|
||||
}
|
||||
|
||||
// --- the correctness path ----------------------------------------------
|
||||
|
||||
@Test
|
||||
fun mismatchedClipsAreReEncodedRatherThanStreamCopied(): Unit = runBlocking {
|
||||
val out = output("joined_mismatched.mp4")
|
||||
val result = engine.join(listOf(Uri.fromFile(clipA), Uri.fromFile(clipMismatched)), out)
|
||||
|
||||
// This is the case a naive implementation gets wrong: the demuxer would accept
|
||||
// these and produce a corrupt second half.
|
||||
assertEquals(
|
||||
"differing resolution must force a re-encode",
|
||||
ConcatStrategy.REENCODE,
|
||||
result.strategy,
|
||||
)
|
||||
assertTrue("no output produced", out.exists() && out.length() > 0)
|
||||
assertTrue(
|
||||
"joined duration ${durationMs(out)}ms is too short to hold both clips",
|
||||
durationMs(out) >= 3_500,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reEncodedOutputIsPlayableAndCarriesBothTracks(): Unit = runBlocking {
|
||||
val out = output("joined_playable.mp4")
|
||||
engine.join(listOf(Uri.fromFile(clipA), Uri.fromFile(clipMismatched)), out)
|
||||
|
||||
val extractor = MediaExtractor()
|
||||
try {
|
||||
extractor.setDataSource(out.absolutePath)
|
||||
val mimes = (0 until extractor.trackCount).map {
|
||||
extractor.getTrackFormat(it).getString(MediaFormat.KEY_MIME).orEmpty()
|
||||
}
|
||||
assertTrue("no video track in $mimes", mimes.any { it.startsWith("video/") })
|
||||
assertTrue("no audio track in $mimes", mimes.any { it.startsWith("audio/") })
|
||||
} finally {
|
||||
extractor.release()
|
||||
}
|
||||
}
|
||||
|
||||
// --- guards -------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun joiningRefusesFewerThanTwoInputs() {
|
||||
val out = output("joined_single.mp4")
|
||||
val failure = runCatching {
|
||||
runBlocking { engine.join(listOf(Uri.fromFile(clipA)), out) }
|
||||
}.exceptionOrNull()
|
||||
assertTrue(
|
||||
"expected an IllegalArgumentException, got $failure",
|
||||
failure is IllegalArgumentException,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theListFileIsCleanedUpAfterJoining(): Unit = runBlocking {
|
||||
val out = output("joined_cleanup.mp4")
|
||||
engine.join(listOf(Uri.fromFile(clipA), Uri.fromFile(clipB)), out)
|
||||
assertTrue(
|
||||
"the concat list file was left behind",
|
||||
!File(out.parentFile, "concat_list.txt").exists(),
|
||||
)
|
||||
}
|
||||
|
||||
// --- the probe the planner depends on ----------------------------------
|
||||
|
||||
@Test
|
||||
fun probeReadsThePropertiesTheStrategyDependsOn() {
|
||||
val a = MediaProbe.probeForConcat(context, Uri.fromFile(clipA))
|
||||
val mismatched = MediaProbe.probeForConcat(context, Uri.fromFile(clipMismatched))
|
||||
|
||||
assertEquals("h264", a.videoCodec)
|
||||
assertEquals(320, a.width)
|
||||
assertEquals(240, a.height)
|
||||
|
||||
assertEquals(640, mismatched.width)
|
||||
assertEquals(480, mismatched.height)
|
||||
assertTrue(
|
||||
"the probe must actually distinguish these clips, or the planner cannot",
|
||||
a.width != mismatched.width || a.height != mismatched.height,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package org.libremediaconverter.join
|
||||
|
||||
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 org.libremediaconverter.model.ConcatStrategy
|
||||
import org.libremediaconverter.work.ConcatWorker
|
||||
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.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Joining as background work.
|
||||
*
|
||||
* Covers what the engine test cannot: the WorkManager round trip, the foreground
|
||||
* service, and the fact that the chosen strategy is reported back to the UI rather than
|
||||
* lost inside the worker.
|
||||
*/
|
||||
@UnstableApi
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ConcatWorkerTest {
|
||||
|
||||
private val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
private val workManager = WorkManager.getInstance(context)
|
||||
private lateinit var clipA: File
|
||||
private lateinit var clipB: File
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
clipA = copyAsset("clip_a.mp4")
|
||||
clipB = copyAsset("clip_b.mp4")
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
clipA.delete()
|
||||
clipB.delete()
|
||||
File(context.cacheDir, "conversions").listFiles()?.forEach { it.delete() }
|
||||
}
|
||||
|
||||
private fun copyAsset(name: String): File {
|
||||
val out = File(context.cacheDir, name)
|
||||
InstrumentationRegistry.getInstrumentation().context.assets
|
||||
.open(name)
|
||||
.use { asset -> out.outputStream().use { asset.copyTo(it) } }
|
||||
return out
|
||||
}
|
||||
|
||||
@Test
|
||||
fun joinsTwoFilesAndReportsTheStrategyUsed(): Unit = runBlocking {
|
||||
val request = ConcatWorker.request(
|
||||
inputs = listOf(Uri.fromFile(clipA), Uri.fromFile(clipB)),
|
||||
totalBytes = clipA.length() + clipB.length(),
|
||||
)
|
||||
workManager.enqueue(request).result.get()
|
||||
|
||||
val terminal = withTimeout(TIMEOUT_MS) {
|
||||
workManager.getWorkInfoByIdFlow(request.id).first { it != null && it.state.isFinished }
|
||||
}
|
||||
|
||||
assertEquals(
|
||||
"join did not succeed: ${terminal?.outputData?.getString(ConcatWorker.KEY_ERROR)}",
|
||||
WorkInfo.State.SUCCEEDED,
|
||||
terminal?.state,
|
||||
)
|
||||
// The strategy has to reach the UI: it is what tells the user whether their
|
||||
// files were re-encoded or copied losslessly.
|
||||
assertEquals(
|
||||
ConcatStrategy.STREAM_COPY.name,
|
||||
terminal?.outputData?.getString(ConcatWorker.KEY_STRATEGY),
|
||||
)
|
||||
val out = File(terminal!!.outputData.getString(ConcatWorker.KEY_OUTPUT_PATH)!!)
|
||||
assertTrue("no joined file written", out.exists() && out.length() > 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aSingleInputFailsWithAnActionableMessage(): Unit = runBlocking {
|
||||
val request = ConcatWorker.request(
|
||||
inputs = listOf(Uri.fromFile(clipA)),
|
||||
totalBytes = clipA.length(),
|
||||
)
|
||||
workManager.enqueue(request).result.get()
|
||||
|
||||
val terminal = withTimeout(TIMEOUT_MS) {
|
||||
workManager.getWorkInfoByIdFlow(request.id).first { it != null && it.state.isFinished }
|
||||
}
|
||||
|
||||
assertEquals(WorkInfo.State.FAILED, terminal?.state)
|
||||
val message = terminal?.outputData?.getString(ConcatWorker.KEY_ERROR).orEmpty()
|
||||
assertTrue(
|
||||
"the message should tell the user what to do, was: '$message'",
|
||||
message.contains("two", ignoreCase = true),
|
||||
)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TIMEOUT_MS = 180_000L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package org.libremediaconverter.work
|
||||
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
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 org.libremediaconverter.codec.AndroidDeviceCodecs
|
||||
import org.libremediaconverter.model.Engine
|
||||
import org.libremediaconverter.model.OutputFormat
|
||||
import org.libremediaconverter.model.QualityTier
|
||||
import org.libremediaconverter.model.VideoCodec
|
||||
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.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Exercises the real WorkManager path, not a test double.
|
||||
*
|
||||
* The point is to cover what a fake worker runner would skip: `setForeground` with a
|
||||
* foreground service type, on a device whose API level actually enforces the rules.
|
||||
* Declaring the wrong type — or forgetting to declare it on WorkManager's
|
||||
* SystemForegroundService in the manifest — fails here rather than in production.
|
||||
*/
|
||||
@UnstableApi
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ConversionWorkerTest {
|
||||
|
||||
private val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
private val workManager = WorkManager.getInstance(context)
|
||||
private lateinit var input: File
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
input = File(context.cacheDir, "worker_sample.mp4")
|
||||
InstrumentationRegistry.getInstrumentation().context.assets
|
||||
.open("sample_h264.mp4")
|
||||
.use { asset -> input.outputStream().use { asset.copyTo(it) } }
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
input.delete()
|
||||
File(context.cacheDir, "conversions").listFiles()?.forEach { it.delete() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliberately derives the expectation from the running API rather than pinning a
|
||||
* value, so the same test is meaningful on an API 33, 34 or 35+ device. The three
|
||||
* regimes are the whole reason ConversionForegroundType exists.
|
||||
*/
|
||||
@Test
|
||||
fun foregroundTypeMatchesTheRunningApiLevel() {
|
||||
val expected = when {
|
||||
Build.VERSION.SDK_INT >= 35 -> ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROCESSING
|
||||
Build.VERSION.SDK_INT >= 34 -> ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
|
||||
else -> 0
|
||||
}
|
||||
assertEquals(expected, ConversionForegroundType.current())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun runsAConversionThroughWorkManager(): Unit = runBlocking {
|
||||
// H.264 rather than the H.265 default: on a device with no hardware encoder
|
||||
// this runs in software, and AVC is markedly quicker than HEVC there. The point
|
||||
// of this test is the WorkManager round trip, not the codec.
|
||||
val request = ConversionWorker.request(
|
||||
inputUri = Uri.fromFile(input),
|
||||
displayName = "worker_sample.mp4",
|
||||
sizeBytes = input.length(),
|
||||
format = OutputFormat.MP4_H264,
|
||||
)
|
||||
workManager.enqueue(request).result.get()
|
||||
|
||||
val terminal = withTimeout(TIMEOUT_MS) {
|
||||
workManager.getWorkInfoByIdFlow(request.id).first { info ->
|
||||
info != null && info.state.isFinished
|
||||
}
|
||||
}
|
||||
|
||||
assertEquals(
|
||||
"worker did not succeed: ${terminal?.outputData?.getString(ConversionWorker.KEY_ERROR)}",
|
||||
WorkInfo.State.SUCCEEDED,
|
||||
terminal?.state,
|
||||
)
|
||||
|
||||
val path = terminal?.outputData?.getString(ConversionWorker.KEY_OUTPUT_PATH)
|
||||
assertTrue("no output path in result", path != null)
|
||||
val output = File(path!!)
|
||||
assertTrue("output file missing", output.exists())
|
||||
assertTrue("output file empty", output.length() > 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole point of the router, end to end.
|
||||
*
|
||||
* MP3 cannot be produced by Media3 on any Android version, so this asserts both
|
||||
* that the job completes and that it was actually carried out by FFmpeg. Checking
|
||||
* only that a file appeared would pass even if the routing were broken.
|
||||
*/
|
||||
@Test
|
||||
fun routesAnMp3JobToFfmpegAndProducesAFile(): Unit = runBlocking {
|
||||
val request = ConversionWorker.request(
|
||||
inputUri = Uri.fromFile(input),
|
||||
displayName = "worker_sample.mp4",
|
||||
sizeBytes = input.length(),
|
||||
format = OutputFormat.MP3,
|
||||
)
|
||||
workManager.enqueue(request).result.get()
|
||||
|
||||
val terminal = withTimeout(TIMEOUT_MS) {
|
||||
workManager.getWorkInfoByIdFlow(request.id).first { it != null && it.state.isFinished }
|
||||
}
|
||||
|
||||
assertEquals(
|
||||
"mp3 job did not succeed: ${terminal?.outputData?.getString(ConversionWorker.KEY_ERROR)}",
|
||||
WorkInfo.State.SUCCEEDED,
|
||||
terminal?.state,
|
||||
)
|
||||
assertEquals(
|
||||
Engine.FFMPEG.name,
|
||||
terminal?.outputData?.getString(ConversionWorker.KEY_ENGINE_USED),
|
||||
)
|
||||
val output = File(terminal!!.outputData.getString(ConversionWorker.KEY_OUTPUT_PATH)!!)
|
||||
assertTrue("no mp3 written", output.exists() && output.length() > 0)
|
||||
assertTrue("wrong extension: ${output.name}", output.name.endsWith(".mp3"))
|
||||
}
|
||||
|
||||
/**
|
||||
* A Fast MP4 job takes the hardware path *when the device has one*.
|
||||
*
|
||||
* The expectation is derived from the device rather than assumed. Emulators
|
||||
* typically expose only `c2.android.*` software codecs, which the capability probe
|
||||
* correctly rejects as non-hardware, so the same job legitimately routes to FFmpeg
|
||||
* there. Asserting MEDIA3 unconditionally tests the test machine, not the router.
|
||||
*/
|
||||
@Test
|
||||
fun routesAFastMp4JobByDeviceCapability(): Unit = runBlocking {
|
||||
val hasHardwareHevc = AndroidDeviceCodecs.get().canEncode(VideoCodec.H265)
|
||||
|
||||
val request = ConversionWorker.request(
|
||||
inputUri = Uri.fromFile(input),
|
||||
displayName = "worker_sample.mp4",
|
||||
sizeBytes = input.length(),
|
||||
format = OutputFormat.MP4_H265,
|
||||
quality = QualityTier.FAST,
|
||||
)
|
||||
workManager.enqueue(request).result.get()
|
||||
|
||||
val terminal = withTimeout(TIMEOUT_MS) {
|
||||
workManager.getWorkInfoByIdFlow(request.id).first { it != null && it.state.isFinished }
|
||||
}
|
||||
|
||||
assertEquals(
|
||||
"job did not succeed: ${terminal?.outputData?.getString(ConversionWorker.KEY_ERROR)}",
|
||||
WorkInfo.State.SUCCEEDED,
|
||||
terminal?.state,
|
||||
)
|
||||
assertEquals(
|
||||
if (hasHardwareHevc) "hardware HEVC present, expected the Media3 path"
|
||||
else "no hardware HEVC encoder, expected the FFmpeg path",
|
||||
if (hasHardwareHevc) Engine.MEDIA3.name else Engine.FFMPEG.name,
|
||||
terminal?.outputData?.getString(ConversionWorker.KEY_ENGINE_USED),
|
||||
)
|
||||
}
|
||||
|
||||
/** The quality tier is the reason the shipped binary is GPL, so verify it routes. */
|
||||
@Test
|
||||
fun routesABestQualityJobToFfmpeg(): Unit = runBlocking {
|
||||
val request = ConversionWorker.request(
|
||||
inputUri = Uri.fromFile(input),
|
||||
displayName = "worker_sample.mp4",
|
||||
sizeBytes = input.length(),
|
||||
format = OutputFormat.MP4_H264,
|
||||
quality = QualityTier.BEST,
|
||||
)
|
||||
workManager.enqueue(request).result.get()
|
||||
|
||||
val terminal = withTimeout(TIMEOUT_MS) {
|
||||
workManager.getWorkInfoByIdFlow(request.id).first { it != null && it.state.isFinished }
|
||||
}
|
||||
|
||||
assertEquals(WorkInfo.State.SUCCEEDED, terminal?.state)
|
||||
assertEquals(
|
||||
Engine.FFMPEG.name,
|
||||
terminal?.outputData?.getString(ConversionWorker.KEY_ENGINE_USED),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun outputNameTakesTheExtensionOfTheChosenFormat() {
|
||||
assertEquals("clip_converted.mp3", ConversionWorker.outputNameFor("clip.mp4", OutputFormat.MP3))
|
||||
assertEquals("clip_converted.gif", ConversionWorker.outputNameFor("clip.mov", OutputFormat.GIF))
|
||||
assertEquals("clip_converted.mkv", ConversionWorker.outputNameFor("clip", OutputFormat.MKV_H264))
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TIMEOUT_MS = 180_000L
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,24 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
|
||||
<!--
|
||||
mediaProcessing is API 35+. Foreground service types became mandatory at API 34,
|
||||
but this type did not exist yet, so API 34 has to fall back to dataSync. minSdk is
|
||||
33, where no type is required at all. See ConversionForegroundType.
|
||||
-->
|
||||
<uses-permission
|
||||
android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROCESSING"
|
||||
android:minSdkVersion="35" />
|
||||
<uses-permission
|
||||
android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"
|
||||
android:maxSdkVersion="34" />
|
||||
|
||||
<!-- Runtime permission since API 33, so it always applies here. Notifications are
|
||||
off by default on new installs. -->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
@@ -10,6 +28,28 @@
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.AndroidMediaConverter" />
|
||||
android:theme="@style/Theme.LibreMediaConverter">
|
||||
|
||||
</manifest>
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.LibreMediaConverter">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!--
|
||||
setForeground() runs WorkManager's own SystemForegroundService, so the type
|
||||
must be declared on THAT service, not on one of ours. Both types are listed
|
||||
because the manifest cannot branch on API level; the type actually passed at
|
||||
runtime is chosen in ConversionForegroundType.
|
||||
-->
|
||||
<service
|
||||
android:name="androidx.work.impl.foreground.SystemForegroundService"
|
||||
android:foregroundServiceType="mediaProcessing|dataSync"
|
||||
tools:node="merge" />
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
package org.libremediaconverter
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.NavigationBar
|
||||
import androidx.compose.material3.NavigationBarItem
|
||||
import androidx.compose.material3.NavigationRail
|
||||
import androidx.compose.material3.NavigationRailItem
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
|
||||
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
|
||||
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import org.libremediaconverter.convert.ConverterScreen
|
||||
import org.libremediaconverter.join.JoinScreen
|
||||
import org.libremediaconverter.ui.theme.LibreMediaConverterTheme
|
||||
|
||||
private enum class Destination(val label: String) {
|
||||
CONVERT("Convert"),
|
||||
JOIN("Join"),
|
||||
}
|
||||
|
||||
@UnstableApi
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
// Edge-to-edge is enforced from targetSdk 35 and has no opt-out at 36+, so opt
|
||||
// in explicitly rather than relying on the default.
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
setContent {
|
||||
LibreMediaConverterTheme {
|
||||
AppRoot(widthSizeClass = calculateWindowSizeClass(this).widthSizeClass)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adaptive shell: a bottom bar on phones, a side rail on wider screens.
|
||||
*
|
||||
* This is not cosmetic. From targetSdk 37, Android ignores `screenOrientation`,
|
||||
* `resizableActivity` and aspect-ratio limits on any display at least 600dp wide, and
|
||||
* the Android 16 opt-out no longer applies. The app will be resized and rotated
|
||||
* whether or not it is ready, so it has to lay out properly at every width.
|
||||
*/
|
||||
@UnstableApi
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun AppRoot(widthSizeClass: WindowWidthSizeClass) {
|
||||
var destination by remember { mutableStateOf(Destination.CONVERT) }
|
||||
val useRail = widthSizeClass != WindowWidthSizeClass.Compact
|
||||
|
||||
if (useRail) {
|
||||
Row(modifier = Modifier.fillMaxSize()) {
|
||||
NavigationRail {
|
||||
Destination.entries.forEach { item ->
|
||||
NavigationRailItem(
|
||||
selected = destination == item,
|
||||
onClick = { destination = item },
|
||||
icon = {},
|
||||
label = { Text(item.label) },
|
||||
)
|
||||
}
|
||||
}
|
||||
Scaffold(modifier = Modifier.fillMaxSize()) { padding ->
|
||||
Content(destination, Modifier.padding(padding))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Scaffold(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
bottomBar = {
|
||||
NavigationBar {
|
||||
Destination.entries.forEach { item ->
|
||||
NavigationBarItem(
|
||||
selected = destination == item,
|
||||
onClick = { destination = item },
|
||||
icon = {},
|
||||
label = { Text(item.label) },
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
) { padding ->
|
||||
Content(destination, Modifier.padding(padding))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@UnstableApi
|
||||
@Composable
|
||||
private fun Content(destination: Destination, modifier: Modifier) {
|
||||
when (destination) {
|
||||
Destination.CONVERT -> ConverterScreen(modifier = modifier)
|
||||
Destination.JOIN -> JoinScreen(modifier = modifier)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package org.libremediaconverter.codec
|
||||
|
||||
import android.media.MediaCodecInfo
|
||||
import android.media.MediaCodecList
|
||||
import android.media.MediaFormat
|
||||
import android.util.Log
|
||||
import org.libremediaconverter.model.DeviceCodecs
|
||||
import org.libremediaconverter.model.InputProbe
|
||||
import org.libremediaconverter.model.VideoCodec
|
||||
|
||||
/**
|
||||
* What this device's codec hardware can actually do.
|
||||
*
|
||||
* Enumerated once and cached: MediaCodecList is not cheap, and the answer cannot
|
||||
* change while the process is alive.
|
||||
*
|
||||
* Two details that are easy to get wrong:
|
||||
*
|
||||
* - Devices expose **aliases** for the same underlying codec, so the list must be
|
||||
* deduplicated by canonical name or capabilities get counted several times.
|
||||
* - `isHardwareAccelerated()` is declared by the vendor and, in the platform's own
|
||||
* words, "cannot be tested for correctness". It is a hint, not a guarantee, which is
|
||||
* why the router treats a failed hardware export as a signal to fall back rather
|
||||
* than trusting this up front.
|
||||
*/
|
||||
class AndroidDeviceCodecs private constructor(
|
||||
private val hardwareEncodeMimes: Set<String>,
|
||||
private val decodeMimes: Set<String>,
|
||||
) : DeviceCodecs {
|
||||
|
||||
override fun canEncode(codec: VideoCodec): Boolean =
|
||||
mimeFor(codec)?.let { it in hardwareEncodeMimes } ?: true
|
||||
|
||||
override fun canDecode(codecName: String): Boolean {
|
||||
// The platform already failed to parse this input, so there is nothing to
|
||||
// decode with. Send it to FFmpeg rather than letting Media3 fail later.
|
||||
if (codecName == InputProbe.UNPARSEABLE) return false
|
||||
return mimeForCodecName(codecName)?.let { it in decodeMimes } ?: true
|
||||
}
|
||||
|
||||
fun hardwareEncoders(): Set<String> = hardwareEncodeMimes
|
||||
|
||||
companion object {
|
||||
private const val TAG = "AndroidDeviceCodecs"
|
||||
|
||||
@Volatile
|
||||
private var cached: AndroidDeviceCodecs? = null
|
||||
|
||||
fun get(): AndroidDeviceCodecs =
|
||||
cached ?: synchronized(this) { cached ?: probe().also { cached = it } }
|
||||
|
||||
private fun probe(): AndroidDeviceCodecs {
|
||||
val encoders = mutableSetOf<String>()
|
||||
val decoders = mutableSetOf<String>()
|
||||
val seen = mutableSetOf<String>()
|
||||
|
||||
runCatching {
|
||||
MediaCodecList(MediaCodecList.REGULAR_CODECS).codecInfos.forEach { info ->
|
||||
// Aliases point at the same underlying codec; counting both would
|
||||
// double-count capabilities.
|
||||
if (info.isAlias) return@forEach
|
||||
if (!seen.add(info.canonicalName)) return@forEach
|
||||
|
||||
info.supportedTypes.forEach { mime ->
|
||||
if (!mime.startsWith("video/")) return@forEach
|
||||
if (info.isEncoder) {
|
||||
if (info.isHardwareAccelerated && !info.isSoftwareOnly) {
|
||||
encoders += mime
|
||||
}
|
||||
} else {
|
||||
decoders += mime
|
||||
}
|
||||
}
|
||||
}
|
||||
}.onFailure { Log.w(TAG, "Codec enumeration failed; assuming permissive.", it) }
|
||||
|
||||
Log.i(TAG, "Hardware video encoders: $encoders")
|
||||
return AndroidDeviceCodecs(encoders, decoders)
|
||||
}
|
||||
|
||||
private fun mimeFor(codec: VideoCodec): String? = when (codec) {
|
||||
VideoCodec.H264 -> MediaFormat.MIMETYPE_VIDEO_AVC
|
||||
VideoCodec.H265 -> MediaFormat.MIMETYPE_VIDEO_HEVC
|
||||
VideoCodec.VP8 -> MediaFormat.MIMETYPE_VIDEO_VP8
|
||||
VideoCodec.VP9 -> MediaFormat.MIMETYPE_VIDEO_VP9
|
||||
VideoCodec.AV1 -> MediaFormat.MIMETYPE_VIDEO_AV1
|
||||
VideoCodec.NONE -> null
|
||||
}
|
||||
|
||||
/** Maps an FFprobe-style codec name onto a MediaFormat MIME type. */
|
||||
private fun mimeForCodecName(name: String): String? = when (name.lowercase()) {
|
||||
"h264", "avc", "avc1" -> MediaFormat.MIMETYPE_VIDEO_AVC
|
||||
"hevc", "h265", "hvc1" -> MediaFormat.MIMETYPE_VIDEO_HEVC
|
||||
"vp8" -> MediaFormat.MIMETYPE_VIDEO_VP8
|
||||
"vp9" -> MediaFormat.MIMETYPE_VIDEO_VP9
|
||||
"av1", "av01" -> MediaFormat.MIMETYPE_VIDEO_AV1
|
||||
"mpeg4" -> MediaFormat.MIMETYPE_VIDEO_MPEG4
|
||||
// Unknown to us: assume the platform can handle it and let a failed export
|
||||
// trigger the FFmpeg fallback, rather than pre-emptively refusing hardware.
|
||||
else -> null
|
||||
}
|
||||
|
||||
/** Test seam: lets instrumented tests build a probe from explicit sets. */
|
||||
fun forTesting(encoders: Set<String>, decoders: Set<String>) =
|
||||
AndroidDeviceCodecs(encoders, decoders)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package org.libremediaconverter.convert
|
||||
|
||||
import android.app.Application
|
||||
import android.net.Uri
|
||||
import android.provider.OpenableColumns
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import org.libremediaconverter.model.EnginePreference
|
||||
import org.libremediaconverter.model.OutputFormat
|
||||
import org.libremediaconverter.model.QualityTier
|
||||
import org.libremediaconverter.work.ConversionWorker
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
|
||||
/** User-chosen conversion settings. */
|
||||
data class ConversionSettings(
|
||||
val format: OutputFormat = OutputFormat.MP4_H265,
|
||||
val quality: QualityTier = QualityTier.FAST,
|
||||
val enginePreference: EnginePreference = EnginePreference.AUTO,
|
||||
)
|
||||
|
||||
data class InputFile(
|
||||
val uri: Uri,
|
||||
val displayName: String,
|
||||
val sizeBytes: Long,
|
||||
)
|
||||
|
||||
sealed interface ConversionState {
|
||||
data object Idle : ConversionState
|
||||
data class Ready(val input: InputFile) : ConversionState
|
||||
data class Converting(val input: InputFile, val percent: Int) : ConversionState
|
||||
/** Budget for foreground work ran out; WorkManager will retry when it can. */
|
||||
data class Waiting(val input: InputFile) : ConversionState
|
||||
data class Converted(
|
||||
val input: InputFile,
|
||||
val staged: File,
|
||||
val engineUsed: String = "",
|
||||
val routeReason: String = "",
|
||||
) : ConversionState
|
||||
data class Saved(val displayName: String) : ConversionState
|
||||
data class Failed(val message: String) : ConversionState
|
||||
}
|
||||
|
||||
@UnstableApi
|
||||
class ConversionViewModel(app: Application) : AndroidViewModel(app) {
|
||||
|
||||
private val workManager = WorkManager.getInstance(app)
|
||||
private val publisher = OutputPublisher(app)
|
||||
|
||||
private val _state = MutableStateFlow<ConversionState>(ConversionState.Idle)
|
||||
val state: StateFlow<ConversionState> = _state.asStateFlow()
|
||||
|
||||
private var observer: Job? = null
|
||||
private var activeWorkId: UUID? = null
|
||||
|
||||
/** Conversion settings, kept separate from the job state machine. */
|
||||
private val _settings = MutableStateFlow(ConversionSettings())
|
||||
val settings: StateFlow<ConversionSettings> = _settings.asStateFlow()
|
||||
|
||||
fun setFormat(format: OutputFormat) = _settings.update { it.copy(format = format) }
|
||||
fun setQuality(quality: QualityTier) = _settings.update { it.copy(quality = quality) }
|
||||
fun setEnginePreference(preference: EnginePreference) =
|
||||
_settings.update { it.copy(enginePreference = preference) }
|
||||
|
||||
fun onInputPicked(uri: Uri) {
|
||||
viewModelScope.launch {
|
||||
val info = withContext(Dispatchers.IO) { queryFile(uri) }
|
||||
_state.value = ConversionState.Ready(info)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueues the conversion rather than running it inline.
|
||||
*
|
||||
* Going through WorkManager means the job outlives this ViewModel, survives the
|
||||
* process being killed, and keeps running when the user leaves the app — none of
|
||||
* which a viewModelScope coroutine would do.
|
||||
*/
|
||||
fun convert() {
|
||||
val input = currentInput() ?: return
|
||||
|
||||
val settings = _settings.value
|
||||
val request = ConversionWorker.request(
|
||||
inputUri = input.uri,
|
||||
displayName = input.displayName,
|
||||
sizeBytes = input.sizeBytes,
|
||||
format = settings.format,
|
||||
quality = settings.quality,
|
||||
enginePreference = settings.enginePreference,
|
||||
)
|
||||
activeWorkId = request.id
|
||||
workManager.enqueue(request)
|
||||
_state.value = ConversionState.Converting(input, 0)
|
||||
observe(request.id, input)
|
||||
}
|
||||
|
||||
private fun observe(id: UUID, input: InputFile) {
|
||||
observer?.cancel()
|
||||
observer = viewModelScope.launch {
|
||||
workManager.getWorkInfoByIdFlow(id).collect { info ->
|
||||
if (info == null) return@collect
|
||||
_state.value = when (info.state) {
|
||||
WorkInfo.State.RUNNING -> ConversionState.Converting(
|
||||
input,
|
||||
info.progress.getInt(ConversionWorker.KEY_PROGRESS, 0),
|
||||
)
|
||||
|
||||
// ENQUEUED after a run means a retry is pending — most likely the
|
||||
// six-hour foreground budget was exhausted mid-job.
|
||||
WorkInfo.State.ENQUEUED ->
|
||||
if (info.runAttemptCount > 0) {
|
||||
ConversionState.Waiting(input)
|
||||
} else {
|
||||
ConversionState.Converting(input, 0)
|
||||
}
|
||||
|
||||
WorkInfo.State.SUCCEEDED -> {
|
||||
val path = info.outputData.getString(ConversionWorker.KEY_OUTPUT_PATH)
|
||||
if (path == null) {
|
||||
ConversionState.Failed("Conversion reported success but produced no file.")
|
||||
} else {
|
||||
ConversionState.Converted(
|
||||
input = input,
|
||||
staged = File(path),
|
||||
engineUsed = info.outputData
|
||||
.getString(ConversionWorker.KEY_ENGINE_USED).orEmpty(),
|
||||
routeReason = info.outputData
|
||||
.getString(ConversionWorker.KEY_ROUTE_REASON).orEmpty(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
WorkInfo.State.FAILED -> ConversionState.Failed(
|
||||
info.outputData.getString(ConversionWorker.KEY_ERROR)
|
||||
?: "Conversion failed."
|
||||
)
|
||||
|
||||
WorkInfo.State.CANCELLED -> ConversionState.Ready(input)
|
||||
WorkInfo.State.BLOCKED -> ConversionState.Converting(input, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun cancel() {
|
||||
activeWorkId?.let(workManager::cancelWorkById)
|
||||
}
|
||||
|
||||
fun save(destination: Uri) {
|
||||
val converted = _state.value as? ConversionState.Converted ?: return
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
publisher.publish(converted.staged, destination)
|
||||
converted.staged.delete()
|
||||
}
|
||||
}.onSuccess {
|
||||
_state.value = ConversionState.Saved(
|
||||
ConversionWorker.outputNameFor(
|
||||
converted.input.displayName,
|
||||
_settings.value.format,
|
||||
)
|
||||
)
|
||||
}.onFailure { e ->
|
||||
_state.value = ConversionState.Failed(e.message ?: "Could not save the file.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
observer?.cancel()
|
||||
observer = null
|
||||
activeWorkId = null
|
||||
_state.value = ConversionState.Idle
|
||||
}
|
||||
|
||||
fun suggestedOutputName(): String =
|
||||
ConversionWorker.outputNameFor(
|
||||
currentInput()?.displayName ?: "output",
|
||||
_settings.value.format,
|
||||
)
|
||||
|
||||
private fun currentInput(): InputFile? = when (val s = _state.value) {
|
||||
is ConversionState.Ready -> s.input
|
||||
is ConversionState.Converting -> s.input
|
||||
is ConversionState.Waiting -> s.input
|
||||
is ConversionState.Converted -> s.input
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun queryFile(uri: Uri): InputFile {
|
||||
var name = "input"
|
||||
var size = 0L
|
||||
getApplication<Application>().contentResolver
|
||||
.query(uri, null, null, null, null)
|
||||
?.use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||
.takeIf { it >= 0 }
|
||||
?.let { name = cursor.getString(it) ?: name }
|
||||
cursor.getColumnIndex(OpenableColumns.SIZE)
|
||||
.takeIf { it >= 0 }
|
||||
?.let { size = cursor.getLong(it) }
|
||||
}
|
||||
}
|
||||
return InputFile(uri, name, size)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
package org.libremediaconverter.convert
|
||||
|
||||
import android.Manifest
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AssistChip
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.libremediaconverter.ui.PrimaryButtonHeight
|
||||
import org.libremediaconverter.ui.ScreenPaddingHorizontal
|
||||
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.EnginePreference
|
||||
import org.libremediaconverter.model.OutputFormat
|
||||
import org.libremediaconverter.model.QualityTier
|
||||
import java.util.Locale
|
||||
|
||||
@UnstableApi
|
||||
@Composable
|
||||
fun ConverterScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: ConversionViewModel = viewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val settings by viewModel.settings.collectAsStateWithLifecycle()
|
||||
|
||||
// ACTION_OPEN_DOCUMENT rather than the photo picker: the picker is images and video
|
||||
// only, offers no audio at all, and will not reliably surface .mkv/.flac/.webm.
|
||||
// SAF needs no runtime permission.
|
||||
val pickInput = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.OpenDocument()
|
||||
) { uri -> uri?.let(viewModel::onInputPicked) }
|
||||
|
||||
val chooseDestination = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.CreateDocument(settings.format.mimeType)
|
||||
) { uri -> uri?.let(viewModel::save) }
|
||||
|
||||
// Requested at the point of use rather than on first launch, so the ask carries its
|
||||
// own justification. The conversion starts either way: without the permission the
|
||||
// foreground service still runs, but its progress notification is confined to the
|
||||
// Task Manager instead of the shade.
|
||||
val requestNotifications = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission()
|
||||
) { viewModel.convert() }
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = ScreenPaddingHorizontal, vertical = ScreenPaddingVertical),
|
||||
) {
|
||||
Text(
|
||||
"LibreMediaConverter",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
modifier = Modifier.padding(bottom = 16.dp),
|
||||
)
|
||||
|
||||
// The empty state is centred in whatever space is left. The working states
|
||||
// scroll instead, since their content can exceed the screen.
|
||||
val body = Modifier.fillMaxWidth().weight(1f)
|
||||
|
||||
when (val s = state) {
|
||||
is ConversionState.Idle -> Column(
|
||||
modifier = body,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
"Pick a file to convert.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(bottom = 16.dp),
|
||||
)
|
||||
Button(
|
||||
onClick = { pickInput.launch(arrayOf("*/*")) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(PrimaryButtonHeight),
|
||||
) { Text("Choose file") }
|
||||
}
|
||||
|
||||
else -> Column(
|
||||
modifier = body.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
when (s) {
|
||||
is ConversionState.Idle -> Unit
|
||||
|
||||
is ConversionState.Ready -> {
|
||||
FileCard(s.input)
|
||||
FormatPicker(settings.format, viewModel::setFormat)
|
||||
QualityPicker(settings.quality, viewModel::setQuality)
|
||||
EnginePicker(settings.enginePreference, viewModel::setEnginePreference)
|
||||
Button(
|
||||
onClick = {
|
||||
requestNotifications.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().height(PrimaryButtonHeight),
|
||||
) { Text("Convert") }
|
||||
OutlinedButton(
|
||||
onClick = { pickInput.launch(arrayOf("*/*")) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Choose a different file") }
|
||||
}
|
||||
|
||||
is ConversionState.Converting -> {
|
||||
FileCard(s.input)
|
||||
Text("Converting… ${s.percent}%")
|
||||
LinearProgressIndicator(
|
||||
progress = { s.percent / 100f },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedButton(
|
||||
onClick = viewModel::cancel,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Cancel") }
|
||||
}
|
||||
|
||||
is ConversionState.Waiting -> {
|
||||
FileCard(s.input)
|
||||
Text(
|
||||
"Paused. The system limits background media processing to " +
|
||||
"six hours a day, so this will resume automatically.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
OutlinedButton(
|
||||
onClick = viewModel::cancel,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Cancel") }
|
||||
}
|
||||
|
||||
is ConversionState.Converted -> {
|
||||
FileCard(s.input)
|
||||
Text(
|
||||
"Done — ${formatBytes(s.staged.length())} output.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
if (s.routeReason.isNotBlank()) {
|
||||
// Surfacing the routing decision rather than hiding it: it
|
||||
// explains why a job was slow, and makes the software
|
||||
// fallback visible.
|
||||
AssistChip(onClick = {}, label = { Text(s.routeReason) })
|
||||
}
|
||||
Button(
|
||||
onClick = { chooseDestination.launch(viewModel.suggestedOutputName()) },
|
||||
modifier = Modifier.fillMaxWidth().height(PrimaryButtonHeight),
|
||||
) { Text("Save file") }
|
||||
OutlinedButton(
|
||||
onClick = viewModel::reset,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Start over") }
|
||||
}
|
||||
|
||||
is ConversionState.Saved -> {
|
||||
Text("Saved ${s.displayName}.", style = MaterialTheme.typography.bodyLarge)
|
||||
Button(
|
||||
onClick = viewModel::reset,
|
||||
modifier = Modifier.fillMaxWidth().height(PrimaryButtonHeight),
|
||||
) { Text("Convert another") }
|
||||
}
|
||||
|
||||
is ConversionState.Failed -> {
|
||||
Text(
|
||||
s.message,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Button(
|
||||
onClick = viewModel::reset,
|
||||
modifier = Modifier.fillMaxWidth().height(PrimaryButtonHeight),
|
||||
) { Text("Start over") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun FormatPicker(selected: OutputFormat, onSelect: (OutputFormat) -> Unit) {
|
||||
Text("Output format", style = MaterialTheme.typography.titleSmall)
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutputFormat.entries.forEach { format ->
|
||||
FilterChip(
|
||||
selected = format == selected,
|
||||
onClick = { onSelect(format) },
|
||||
label = { Text(format.label) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun QualityPicker(selected: QualityTier, onSelect: (QualityTier) -> Unit) {
|
||||
Text("Quality", style = MaterialTheme.typography.titleSmall)
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
QualityTier.entries.forEach { tier ->
|
||||
FilterChip(
|
||||
selected = tier == selected,
|
||||
onClick = { onSelect(tier) },
|
||||
label = { Text(tier.label) },
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(selected.description, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun EnginePicker(selected: EnginePreference, onSelect: (EnginePreference) -> Unit) {
|
||||
Text("Engine", style = MaterialTheme.typography.titleSmall)
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
EnginePreference.entries.forEach { preference ->
|
||||
FilterChip(
|
||||
selected = preference == selected,
|
||||
onClick = { onSelect(preference) },
|
||||
label = { Text(preference.label()) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun EnginePreference.label(): String = when (this) {
|
||||
EnginePreference.AUTO -> "Automatic"
|
||||
EnginePreference.PREFER_HARDWARE -> "Prefer hardware"
|
||||
EnginePreference.FORCE_SOFTWARE -> "Force software"
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FileCard(input: InputFile) {
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text(input.displayName, style = MaterialTheme.typography.titleMedium)
|
||||
Text(formatBytes(input.sizeBytes), style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
bytes >= 1_000 -> String.format(Locale.US, "%.0f kB", bytes / 1e3)
|
||||
else -> "$bytes B"
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package org.libremediaconverter.convert
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Handler
|
||||
import android.os.HandlerThread
|
||||
import androidx.media3.common.MediaItem
|
||||
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.ExportException
|
||||
import androidx.media3.transformer.ExportResult
|
||||
import androidx.media3.transformer.ProgressHolder
|
||||
import androidx.media3.transformer.Transformer
|
||||
import kotlinx.coroutines.CancellableContinuation
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import java.io.File
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
|
||||
/**
|
||||
* Hardware conversion via Media3 Transformer.
|
||||
*
|
||||
* ## Why the HandlerThread
|
||||
*
|
||||
* Transformer documents that instances "must be accessed from a single application
|
||||
* thread", and both [Transformer.start] and [Transformer.cancel] throw
|
||||
* [IllegalStateException] when called from anywhere else. The thread it binds to is
|
||||
* whichever one had a Looper when the Builder was constructed — falling back to the
|
||||
* *main* Looper if the constructing thread has none.
|
||||
*
|
||||
* That default is a trap for background execution. A WorkManager `Worker` runs on an
|
||||
* executor thread with no Looper, so a Transformer built there silently binds to the
|
||||
* main thread, and the subsequent `start()` from the worker thread throws.
|
||||
*
|
||||
* This class therefore owns a dedicated [HandlerThread], passes its Looper explicitly
|
||||
* via `setLooper`, and marshals every Transformer call onto it. Callers get a plain
|
||||
* suspending function and never have to think about it.
|
||||
*/
|
||||
@UnstableApi
|
||||
class Media3Engine(private val context: Context) : HardwareTranscoder {
|
||||
|
||||
private val thread = HandlerThread("media3-transformer").apply { start() }
|
||||
private val handler = Handler(thread.looper)
|
||||
|
||||
/**
|
||||
* Transcodes [input] to [output], reporting progress 0..100.
|
||||
*
|
||||
* [output] must be a real filesystem path, not a SAF document. Writing through a
|
||||
* SAF file descriptor breaks MP4 muxing, because faststart needs to seek back and
|
||||
* rewrite the moov atom, and a SAF fd is not reliably seekable. Callers stage into
|
||||
* app-private storage and publish afterwards.
|
||||
*/
|
||||
override suspend fun transcode(
|
||||
input: Uri,
|
||||
output: File,
|
||||
videoMimeType: String,
|
||||
onProgress: (Int) -> Unit,
|
||||
): Unit = suspendCancellableCoroutine { cont ->
|
||||
handler.post {
|
||||
val transformer = buildTransformer(videoMimeType, cont)
|
||||
val item = EditedMediaItem.Builder(MediaItem.fromUri(input)).build()
|
||||
|
||||
cont.invokeOnCancellation {
|
||||
// cancel() has the same single-thread requirement as start().
|
||||
handler.post { runCatching { transformer.cancel() } }
|
||||
}
|
||||
|
||||
runCatching { transformer.start(item, output.absolutePath) }
|
||||
.onFailure { cont.resumeWithException(it); return@post }
|
||||
|
||||
pollProgress(transformer, cont, onProgress)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildTransformer(
|
||||
videoMimeType: String,
|
||||
cont: CancellableContinuation<Unit>,
|
||||
): Transformer = Transformer.Builder(context)
|
||||
.setLooper(thread.looper)
|
||||
.setVideoMimeType(videoMimeType)
|
||||
.addListener(object : Transformer.Listener {
|
||||
override fun onCompleted(composition: Composition, result: ExportResult) {
|
||||
if (cont.isActive) cont.resume(Unit)
|
||||
}
|
||||
|
||||
override fun onError(
|
||||
composition: Composition,
|
||||
result: ExportResult,
|
||||
exception: ExportException,
|
||||
) {
|
||||
if (cont.isActive) cont.resumeWithException(exception)
|
||||
}
|
||||
})
|
||||
.build()
|
||||
|
||||
/**
|
||||
* Polls export progress on the Transformer's own thread.
|
||||
*
|
||||
* Deliberately ~4x/second: the underlying value updates far more often than a UI
|
||||
* or a notification can usefully consume, and over-frequent notification updates
|
||||
* will jank the system UI.
|
||||
*/
|
||||
private fun pollProgress(
|
||||
transformer: Transformer,
|
||||
cont: CancellableContinuation<Unit>,
|
||||
onProgress: (Int) -> Unit,
|
||||
) {
|
||||
val holder = ProgressHolder()
|
||||
val tick = object : Runnable {
|
||||
override fun run() {
|
||||
if (!cont.isActive) return
|
||||
if (transformer.getProgress(holder) == Transformer.PROGRESS_STATE_AVAILABLE) {
|
||||
onProgress(holder.progress)
|
||||
}
|
||||
handler.postDelayed(this, PROGRESS_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
handler.postDelayed(tick, PROGRESS_INTERVAL_MS)
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
thread.quitSafely()
|
||||
}
|
||||
|
||||
|
||||
private companion object {
|
||||
const val PROGRESS_INTERVAL_MS = 250L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package org.libremediaconverter.convert
|
||||
|
||||
import android.content.Context
|
||||
import android.media.MediaExtractor
|
||||
import android.media.MediaFormat
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import org.libremediaconverter.model.ConcatInput
|
||||
import org.libremediaconverter.model.InputProbe
|
||||
|
||||
/**
|
||||
* Reads just enough about an input to route it.
|
||||
*
|
||||
* Uses the platform extractor rather than FFprobe: it needs no native library, works
|
||||
* directly on a content:// URI, and the router only needs the codec and duration. If
|
||||
* the platform cannot parse the file at all, that is itself the answer — a file
|
||||
* MediaExtractor cannot open is one Media3 cannot convert, so it routes to FFmpeg.
|
||||
*/
|
||||
object MediaProbe {
|
||||
|
||||
fun probe(context: Context, uri: Uri): InputProbe {
|
||||
val extractor = MediaExtractor()
|
||||
return try {
|
||||
extractor.setDataSource(context, uri, null)
|
||||
var video: String? = null
|
||||
var audio: String? = null
|
||||
var durationUs = 0L
|
||||
|
||||
for (i in 0 until extractor.trackCount) {
|
||||
val format = extractor.getTrackFormat(i)
|
||||
val mime = format.getString(MediaFormat.KEY_MIME).orEmpty()
|
||||
if (format.containsKey(MediaFormat.KEY_DURATION)) {
|
||||
durationUs = maxOf(durationUs, format.getLong(MediaFormat.KEY_DURATION))
|
||||
}
|
||||
when {
|
||||
mime.startsWith("video/") && video == null -> video = shortName(mime)
|
||||
mime.startsWith("audio/") && audio == null -> audio = shortName(mime)
|
||||
}
|
||||
}
|
||||
|
||||
InputProbe(
|
||||
videoCodec = video,
|
||||
audioCodec = audio,
|
||||
hasVideo = video != null,
|
||||
durationMs = durationUs / 1000,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
// Not a failure: an unparseable input is a strong signal that this job
|
||||
// belongs on FFmpeg. Reporting an unknown codec makes the router say so.
|
||||
Log.i(TAG, "Platform extractor could not read $uri; routing to FFmpeg.", e)
|
||||
InputProbe(videoCodec = InputProbe.UNPARSEABLE, hasVideo = true, durationMs = 0)
|
||||
} finally {
|
||||
runCatching { extractor.release() }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the properties that decide whether inputs can be joined without
|
||||
* re-encoding. Unknown values stay null, which [ConcatPlanner] treats as "cannot
|
||||
* prove a match" rather than as agreement.
|
||||
*/
|
||||
fun probeForConcat(context: Context, uri: Uri): ConcatInput {
|
||||
val extractor = MediaExtractor()
|
||||
return try {
|
||||
extractor.setDataSource(context, uri, null)
|
||||
var video: String? = null
|
||||
var audio: String? = null
|
||||
var width = 0
|
||||
var height = 0
|
||||
var fps = 0
|
||||
|
||||
for (i in 0 until extractor.trackCount) {
|
||||
val format = extractor.getTrackFormat(i)
|
||||
val mime = format.getString(MediaFormat.KEY_MIME).orEmpty()
|
||||
if (mime.startsWith("video/") && video == null) {
|
||||
video = shortName(mime)
|
||||
width = format.intOr(MediaFormat.KEY_WIDTH)
|
||||
height = format.intOr(MediaFormat.KEY_HEIGHT)
|
||||
fps = format.intOr(MediaFormat.KEY_FRAME_RATE)
|
||||
} else if (mime.startsWith("audio/") && audio == null) {
|
||||
audio = shortName(mime)
|
||||
}
|
||||
}
|
||||
ConcatInput(video, audio, width, height, fps)
|
||||
} catch (e: Exception) {
|
||||
Log.i(TAG, "Could not probe $uri for concat; will re-encode.", e)
|
||||
ConcatInput(null, null, 0, 0, 0)
|
||||
} finally {
|
||||
runCatching { extractor.release() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun MediaFormat.intOr(key: String, fallback: Int = 0): Int =
|
||||
if (containsKey(key)) runCatching { getInteger(key) }.getOrDefault(fallback) else fallback
|
||||
|
||||
/** MediaFormat MIME -> the short codec names the router and FFmpeg both speak. */
|
||||
private fun shortName(mime: String): String = when (mime) {
|
||||
MediaFormat.MIMETYPE_VIDEO_AVC -> "h264"
|
||||
MediaFormat.MIMETYPE_VIDEO_HEVC -> "hevc"
|
||||
MediaFormat.MIMETYPE_VIDEO_VP8 -> "vp8"
|
||||
MediaFormat.MIMETYPE_VIDEO_VP9 -> "vp9"
|
||||
MediaFormat.MIMETYPE_VIDEO_AV1 -> "av1"
|
||||
MediaFormat.MIMETYPE_VIDEO_MPEG4 -> "mpeg4"
|
||||
MediaFormat.MIMETYPE_AUDIO_AAC -> "aac"
|
||||
MediaFormat.MIMETYPE_AUDIO_OPUS -> "opus"
|
||||
MediaFormat.MIMETYPE_AUDIO_FLAC -> "flac"
|
||||
MediaFormat.MIMETYPE_AUDIO_VORBIS -> "vorbis"
|
||||
else -> mime.substringAfter('/')
|
||||
}
|
||||
|
||||
private const val TAG = "MediaProbe"
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package org.libremediaconverter.convert
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Staging and publication of conversion output.
|
||||
*
|
||||
* Conversions never write directly to the destination the user picked. FFmpeg and the
|
||||
* MP4 muxer both need to seek backwards to finalise a file — faststart rewrites the
|
||||
* moov atom at the end — and a SAF file descriptor is not reliably seekable. Writing
|
||||
* through one produces a truncated or unplayable file.
|
||||
*
|
||||
* So every job writes to app-private cache, which is a real POSIX path with no
|
||||
* permissions and no scoped-storage rules, and the finished file is copied out to the
|
||||
* user's chosen destination afterwards.
|
||||
*
|
||||
* The cost is one extra copy and transient double disk usage, which is why
|
||||
* [hasSpaceFor] exists.
|
||||
*/
|
||||
open class OutputPublisher(private val context: Context) {
|
||||
|
||||
private val stagingDir: File
|
||||
get() = File(context.cacheDir, "conversions").apply { mkdirs() }
|
||||
|
||||
open fun createStagingFile(name: String): File = File(stagingDir, name)
|
||||
|
||||
/**
|
||||
* True if there is room for a further [bytes], including headroom.
|
||||
*
|
||||
* Staging means peak usage is roughly input + output at once, so a job that would
|
||||
* just barely fit is rejected rather than failing partway through.
|
||||
*/
|
||||
open fun hasSpaceFor(bytes: Long): Boolean =
|
||||
stagingDir.usableSpace > bytes + SPACE_HEADROOM_BYTES
|
||||
|
||||
/** Copies a finished staging file into a user-chosen SAF destination. */
|
||||
open fun publish(staged: File, destination: Uri) {
|
||||
context.contentResolver.openOutputStream(destination)?.use { out ->
|
||||
staged.inputStream().use { it.copyTo(out) }
|
||||
} ?: error("Could not open destination for writing: $destination")
|
||||
}
|
||||
|
||||
fun clearStaging() {
|
||||
stagingDir.listFiles()?.forEach { it.delete() }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val SPACE_HEADROOM_BYTES = 128L * 1024 * 1024
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package org.libremediaconverter.convert
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import org.libremediaconverter.ffmpeg.FFmpegEngine
|
||||
import org.libremediaconverter.model.ConversionRequest
|
||||
import org.libremediaconverter.model.DeviceCodecs
|
||||
import org.libremediaconverter.codec.AndroidDeviceCodecs
|
||||
import java.io.File
|
||||
|
||||
/** The hardware conversion path. Implemented by [Media3Engine]. */
|
||||
interface HardwareTranscoder : AutoCloseable {
|
||||
suspend fun transcode(
|
||||
input: Uri,
|
||||
output: File,
|
||||
videoMimeType: String = androidx.media3.common.MimeTypes.VIDEO_H265,
|
||||
onProgress: (Int) -> Unit = {},
|
||||
)
|
||||
}
|
||||
|
||||
/** The software conversion path. Implemented by [FFmpegEngine]. */
|
||||
interface SoftwareTranscoder {
|
||||
suspend fun run(
|
||||
request: ConversionRequest,
|
||||
inputPath: String,
|
||||
output: File,
|
||||
durationMs: Long,
|
||||
onProgress: (Int) -> Unit = {},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The seam that lets tests force failure paths.
|
||||
*
|
||||
* Workers are constructed by WorkManager, so they cannot take constructor arguments,
|
||||
* and the app deliberately carries no DI framework. This holds the few collaborators a
|
||||
* conversion needs, defaulting to the real implementations.
|
||||
*
|
||||
* Its reason for existing is coverage of the branches that only run when something goes
|
||||
* wrong. Those branches are, by definition, the ones that never execute in a healthy
|
||||
* test run — and they are also the ones a user meets on a bad day, so leaving them
|
||||
* unexercised means the error handling is the least-tested code in the app.
|
||||
*
|
||||
* Tests must call [reset] afterwards; `FakeFailures` in the androidTest source set
|
||||
* does that for them.
|
||||
*
|
||||
* Every failure branch in the conversion and join paths is now forced by a test. The
|
||||
* three that needed a seam are covered here; the rest are reachable directly, either
|
||||
* with a content URI pointing at a provider that does not exist, or by constructing a
|
||||
* worker's input Data by hand rather than through its request() helper.
|
||||
*/
|
||||
object ConversionDependencies {
|
||||
|
||||
@Volatile
|
||||
var hardware: (Context) -> HardwareTranscoder = { Media3Engine(it) }
|
||||
|
||||
@Volatile
|
||||
var software: () -> SoftwareTranscoder = { FFmpegEngine() }
|
||||
|
||||
@Volatile
|
||||
var publisher: (Context) -> OutputPublisher = { OutputPublisher(it) }
|
||||
|
||||
@Volatile
|
||||
var deviceCodecs: () -> DeviceCodecs = { AndroidDeviceCodecs.get() }
|
||||
|
||||
fun reset() {
|
||||
hardware = { Media3Engine(it) }
|
||||
software = { FFmpegEngine() }
|
||||
publisher = { OutputPublisher(it) }
|
||||
deviceCodecs = { AndroidDeviceCodecs.get() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package org.libremediaconverter.ffmpeg
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import com.arthenica.ffmpegkit.FFmpegKit
|
||||
import com.arthenica.ffmpegkit.FFmpegKitConfig
|
||||
import com.arthenica.ffmpegkit.ReturnCode
|
||||
import org.libremediaconverter.convert.MediaProbe
|
||||
import org.libremediaconverter.model.ConcatPlanner
|
||||
import org.libremediaconverter.model.ConcatStrategy
|
||||
import org.libremediaconverter.model.OutputFormat
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import java.io.File
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
|
||||
/**
|
||||
* Joins several inputs into one file.
|
||||
*
|
||||
* Picks between a stream copy and a full re-encode by inspecting the inputs, because
|
||||
* the `concat` demuxer requires matching codec, resolution and timebase and does not
|
||||
* reliably fail when they differ — it can emit a file whose later segments are
|
||||
* garbled. See [ConcatPlanner].
|
||||
*/
|
||||
class ConcatEngine(private val context: Context) {
|
||||
|
||||
data class Result(val strategy: ConcatStrategy, val output: File)
|
||||
|
||||
suspend fun join(
|
||||
inputs: List<Uri>,
|
||||
output: File,
|
||||
format: OutputFormat = OutputFormat.MP4_H264,
|
||||
): Result {
|
||||
require(inputs.size >= 2) { "Joining needs at least two files." }
|
||||
|
||||
val paths = inputs.map { uri ->
|
||||
if (uri.scheme == "content") {
|
||||
FFmpegKitConfig.getSafParameterForRead(context, uri)
|
||||
} else {
|
||||
uri.path
|
||||
} ?: error("Could not open one of the input files.")
|
||||
}
|
||||
|
||||
val strategy = ConcatPlanner.plan(inputs.map { MediaProbe.probeForConcat(context, it) })
|
||||
Log.i(TAG, "Joining ${inputs.size} files using $strategy")
|
||||
|
||||
// The demuxer reads its input list from a file, which must live somewhere
|
||||
// FFmpeg can read; app cache is a real path, so it just works.
|
||||
val listFile = File(output.parentFile, "concat_list.txt").apply {
|
||||
writeText(FFmpegConcatCommand.listFileContents(paths))
|
||||
}
|
||||
|
||||
val args = FFmpegConcatCommand.build(strategy, paths, listFile, output, format)
|
||||
try {
|
||||
execute(args)
|
||||
} finally {
|
||||
listFile.delete()
|
||||
}
|
||||
return Result(strategy, output)
|
||||
}
|
||||
|
||||
private suspend fun execute(args: List<String>) = suspendCancellableCoroutine { cont ->
|
||||
Log.i(TAG, "ffmpeg ${args.joinToString(" ")}")
|
||||
val session = FFmpegKit.executeWithArgumentsAsync(args.toTypedArray()) { completed ->
|
||||
val rc = completed.getReturnCode()
|
||||
when {
|
||||
ReturnCode.isSuccess(rc) -> cont.resume(Unit)
|
||||
ReturnCode.isCancel(rc) -> cont.cancel()
|
||||
else -> cont.resumeWithException(
|
||||
FFmpegEngine.FFmpegException(
|
||||
"Joining failed (${rc?.value}): " +
|
||||
completed.getAllLogsAsString(LOG_TAIL_LIMIT).orEmpty()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
cont.invokeOnCancellation { FFmpegKit.cancel(session.getSessionId()) }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "ConcatEngine"
|
||||
const val LOG_TAIL_LIMIT = 40
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package org.libremediaconverter.ffmpeg
|
||||
|
||||
import org.libremediaconverter.model.ConversionRequest
|
||||
import org.libremediaconverter.model.OutputFormat
|
||||
import org.libremediaconverter.model.QualityTier
|
||||
|
||||
/**
|
||||
* Builds FFmpeg argument lists.
|
||||
*
|
||||
* Kept free of Android types so the whole matrix can be unit tested on the JVM. A
|
||||
* wrong flag here produces a corrupt file or a silent quality regression, which is
|
||||
* exactly the kind of thing that should not need a device to catch.
|
||||
*
|
||||
* Arguments are produced as a list rather than a shell string: paths routinely contain
|
||||
* spaces, and a list has no quoting rules to get wrong.
|
||||
*/
|
||||
object FFmpegCommandBuilder {
|
||||
|
||||
/**
|
||||
* CRF values, chosen per codec rather than shared.
|
||||
*
|
||||
* x265 is roughly one CRF step "stronger" than x264 at the same number, so a
|
||||
* shared constant would silently make HEVC output larger than intended.
|
||||
*/
|
||||
private const val CRF_H264 = 20
|
||||
private const val CRF_H265 = 24
|
||||
|
||||
/**
|
||||
* Force 4:2:0 chroma on every video encode.
|
||||
*
|
||||
* Sources are not always 4:2:0. Real footage encoded as H.264 High 4:4:4 Predictive
|
||||
* exists, and FFmpeg will happily decode it to yuv444p and then hand those frames to
|
||||
* an encoder that cannot take them. The MediaCodec wrappers fail hard in that case —
|
||||
* "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.
|
||||
*/
|
||||
private val PIX_FMT = listOf("-pix_fmt", "yuv420p")
|
||||
|
||||
fun build(
|
||||
request: ConversionRequest,
|
||||
inputPath: String,
|
||||
outputPath: String,
|
||||
): List<String> = 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<String> = 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<String> {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun audioArgs(format: OutputFormat): List<String> = when (format) {
|
||||
OutputFormat.MP3 -> listOf("-c:a", "libmp3lame", "-q:a", "2")
|
||||
OutputFormat.FLAC -> listOf("-c:a", "flac")
|
||||
OutputFormat.WAV -> listOf("-c:a", "pcm_s16le")
|
||||
OutputFormat.OPUS -> listOf("-c:a", "libopus", "-b:a", "128k")
|
||||
OutputFormat.M4A_AAC -> listOf("-c:a", "aac", "-b:a", "192k")
|
||||
OutputFormat.GIF, OutputFormat.FRAMES_PNG -> emptyList()
|
||||
OutputFormat.WEBM_VP9 -> listOf("-c:a", "libopus", "-b:a", "128k")
|
||||
else -> listOf("-c:a", "aac", "-b:a", "192k")
|
||||
}
|
||||
|
||||
private fun containerArgs(format: OutputFormat): List<String> = 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()
|
||||
}
|
||||
|
||||
/** 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
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package org.libremediaconverter.ffmpeg
|
||||
|
||||
import org.libremediaconverter.model.ConcatStrategy
|
||||
import org.libremediaconverter.model.OutputFormat
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Builds the two different FFmpeg invocations for joining files.
|
||||
*
|
||||
* Pure so the argument shapes can be unit tested; the caller supplies paths.
|
||||
*/
|
||||
object FFmpegConcatCommand {
|
||||
|
||||
/**
|
||||
* Content of the list file the `concat` demuxer reads.
|
||||
*
|
||||
* Single quotes are escaped the way the demuxer expects, because file names
|
||||
* routinely contain apostrophes and an unescaped one silently truncates the entry.
|
||||
*/
|
||||
fun listFileContents(paths: List<String>): String =
|
||||
paths.joinToString("\n") { "file '${it.replace("'", "'\\''")}'" } + "\n"
|
||||
|
||||
fun build(
|
||||
strategy: ConcatStrategy,
|
||||
inputPaths: List<String>,
|
||||
listFile: File,
|
||||
output: File,
|
||||
format: OutputFormat,
|
||||
): List<String> = when (strategy) {
|
||||
ConcatStrategy.STREAM_COPY -> buildList {
|
||||
add("-hide_banner"); add("-y")
|
||||
// -safe 0 permits absolute paths in the list file, which ours are.
|
||||
add("-f"); add("concat")
|
||||
add("-safe"); add("0")
|
||||
add("-i"); add(listFile.absolutePath)
|
||||
add("-c"); add("copy")
|
||||
if (format.container == org.libremediaconverter.model.Container.MP4) {
|
||||
add("-movflags"); add("+faststart")
|
||||
}
|
||||
add(output.absolutePath)
|
||||
}
|
||||
|
||||
ConcatStrategy.REENCODE -> buildList {
|
||||
add("-hide_banner"); add("-y")
|
||||
inputPaths.forEach { add("-i"); add(it) }
|
||||
// Normalise every input to a common size and frame rate before joining,
|
||||
// otherwise the concat filter refuses mismatched inputs.
|
||||
val filter = buildString {
|
||||
inputPaths.indices.forEach { i ->
|
||||
append("[$i:v]scale=1280:720:force_original_aspect_ratio=decrease,")
|
||||
append("pad=1280:720:-1:-1,setsar=1,fps=30[v$i];")
|
||||
}
|
||||
inputPaths.indices.forEach { i -> append("[v$i][$i:a]") }
|
||||
append("concat=n=${inputPaths.size}:v=1:a=1[v][a]")
|
||||
}
|
||||
add("-filter_complex"); add(filter)
|
||||
add("-map"); add("[v]")
|
||||
add("-map"); add("[a]")
|
||||
add("-c:v"); add("libx264")
|
||||
add("-crf"); add("20")
|
||||
add("-pix_fmt"); add("yuv420p")
|
||||
add("-c:a"); add("aac")
|
||||
if (format.container == org.libremediaconverter.model.Container.MP4) {
|
||||
add("-movflags"); add("+faststart")
|
||||
}
|
||||
add(output.absolutePath)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package org.libremediaconverter.ffmpeg
|
||||
|
||||
import android.util.Log
|
||||
import com.arthenica.ffmpegkit.FFmpegKit
|
||||
import com.arthenica.ffmpegkit.FFmpegKitConfig
|
||||
import com.arthenica.ffmpegkit.Level
|
||||
import com.arthenica.ffmpegkit.ReturnCode
|
||||
import org.libremediaconverter.convert.SoftwareTranscoder
|
||||
import org.libremediaconverter.model.ConversionRequest
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import java.io.File
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
|
||||
/**
|
||||
* Software conversion via the bundled FFmpeg.
|
||||
*
|
||||
* Handles everything Media3 structurally cannot: Matroska, MP3, GIF, frame sequences,
|
||||
* inputs with no platform decoder, and the CRF quality tier.
|
||||
*
|
||||
* Like [org.libremediaconverter.convert.Media3Engine], input arrives as a real
|
||||
* filesystem path and output is written to app-private cache. FFmpeg is perfectly able
|
||||
* to write through a SAF descriptor via its ffkitsaf protocol, but MP4 faststart has
|
||||
* to seek backwards to rewrite the moov atom, which a SAF descriptor does not reliably
|
||||
* support — so staging is the safe default for every format rather than a special case.
|
||||
*/
|
||||
class FFmpegEngine : SoftwareTranscoder {
|
||||
|
||||
init {
|
||||
FFmpegKitConfig.setLogLevel(Level.AV_LOG_WARNING)
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a conversion, reporting progress 0..100.
|
||||
*
|
||||
* Progress is derived from the statistics callback's timestamp against the known
|
||||
* input duration. FFmpeg has no native notion of percentage complete, so a
|
||||
* [durationMs] of zero means progress simply cannot be reported — the caller gets
|
||||
* an indeterminate job rather than a fabricated number.
|
||||
*/
|
||||
override suspend fun run(
|
||||
request: ConversionRequest,
|
||||
inputPath: String,
|
||||
output: File,
|
||||
durationMs: Long,
|
||||
onProgress: (Int) -> Unit,
|
||||
): Unit = suspendCancellableCoroutine { cont ->
|
||||
val args = FFmpegCommandBuilder.build(request, inputPath, output.absolutePath)
|
||||
Log.i(TAG, "ffmpeg ${args.joinToString(" ")}")
|
||||
|
||||
val session = FFmpegKit.executeWithArgumentsAsync(
|
||||
args.toTypedArray(),
|
||||
{ completed ->
|
||||
val rc = completed.getReturnCode()
|
||||
when {
|
||||
ReturnCode.isSuccess(rc) -> cont.resume(Unit)
|
||||
ReturnCode.isCancel(rc) ->
|
||||
cont.cancel()
|
||||
else -> cont.resumeWithException(
|
||||
FFmpegException(
|
||||
"FFmpeg failed (${rc?.value}): " +
|
||||
completed.getFailStackTrace().orEmpty().ifBlank {
|
||||
completed.getAllLogsAsString(LOG_TAIL_LIMIT).orEmpty()
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
},
|
||||
{ log -> Log.d(TAG, log.message.trimEnd()) },
|
||||
{ stats ->
|
||||
if (durationMs > 0) {
|
||||
val percent = (stats.time / durationMs * 100).toInt().coerceIn(0, 100)
|
||||
onProgress(percent)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
cont.invokeOnCancellation {
|
||||
FFmpegKit.cancel(session.getSessionId())
|
||||
output.delete()
|
||||
}
|
||||
}
|
||||
|
||||
class FFmpegException(message: String) : RuntimeException(message)
|
||||
|
||||
private companion object {
|
||||
const val TAG = "FFmpegEngine"
|
||||
const val LOG_TAIL_LIMIT = 40
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package org.libremediaconverter.join
|
||||
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import org.libremediaconverter.convert.InputFile
|
||||
import org.libremediaconverter.model.ConcatStrategy
|
||||
import org.libremediaconverter.ui.PrimaryButtonHeight
|
||||
import org.libremediaconverter.ui.ScreenPaddingHorizontal
|
||||
import org.libremediaconverter.ui.ScreenPaddingVertical
|
||||
|
||||
@UnstableApi
|
||||
@Composable
|
||||
fun JoinScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: JoinViewModel = viewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
val pickInputs = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.OpenMultipleDocuments()
|
||||
) { uris -> if (uris.isNotEmpty()) viewModel.onInputsPicked(uris) }
|
||||
|
||||
val chooseDestination = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.CreateDocument("video/mp4")
|
||||
) { uri -> uri?.let(viewModel::save) }
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = ScreenPaddingHorizontal, vertical = ScreenPaddingVertical),
|
||||
) {
|
||||
Text(
|
||||
"Join files",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
modifier = Modifier.padding(bottom = 16.dp),
|
||||
)
|
||||
|
||||
// Same split as the converter screen: the empty state is centred, the working
|
||||
// states scroll because their content can exceed the screen.
|
||||
val body = Modifier.fillMaxWidth().weight(1f)
|
||||
|
||||
when (val s = state) {
|
||||
is JoinState.Idle -> Column(
|
||||
modifier = body,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
"Pick two or more files to join, in the order you want them.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(bottom = 16.dp),
|
||||
)
|
||||
Button(
|
||||
onClick = { pickInputs.launch(arrayOf("video/*")) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(PrimaryButtonHeight),
|
||||
) { Text("Choose files") }
|
||||
}
|
||||
|
||||
else -> Column(
|
||||
modifier = body.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
when (s) {
|
||||
is JoinState.Idle -> Unit
|
||||
|
||||
is JoinState.Ready -> {
|
||||
s.inputs.forEach { FileRow(it) }
|
||||
Button(
|
||||
onClick = viewModel::join,
|
||||
modifier = Modifier.fillMaxWidth().height(PrimaryButtonHeight),
|
||||
) { Text("Join ${s.inputs.size} files") }
|
||||
OutlinedButton(
|
||||
onClick = { pickInputs.launch(arrayOf("video/*")) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Choose different files") }
|
||||
}
|
||||
|
||||
is JoinState.Joining -> {
|
||||
Text("Joining ${s.inputs.size} files…")
|
||||
// Indeterminate on purpose: FFmpeg reports progress against a
|
||||
// single input's duration, which means nothing across a
|
||||
// concatenation. A fabricated percentage would be worse than none.
|
||||
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
|
||||
OutlinedButton(
|
||||
onClick = viewModel::cancel,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Cancel") }
|
||||
}
|
||||
|
||||
is JoinState.Waiting -> {
|
||||
Text(
|
||||
"Paused. The system limits background media processing to " +
|
||||
"six hours a day, so this will resume automatically.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
OutlinedButton(
|
||||
onClick = viewModel::cancel,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Cancel") }
|
||||
}
|
||||
|
||||
is JoinState.Joined -> {
|
||||
Text("Joined — ${s.staged.length() / 1_000_000} MB.")
|
||||
Text(
|
||||
when (s.strategy) {
|
||||
ConcatStrategy.STREAM_COPY ->
|
||||
"Files matched, so they were joined without " +
|
||||
"re-encoding — no quality loss."
|
||||
ConcatStrategy.REENCODE ->
|
||||
"Files differed in format, so they were re-encoded " +
|
||||
"to match."
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
Button(
|
||||
onClick = { chooseDestination.launch("joined.mp4") },
|
||||
modifier = Modifier.fillMaxWidth().height(PrimaryButtonHeight),
|
||||
) { Text("Save file") }
|
||||
OutlinedButton(
|
||||
onClick = viewModel::reset,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Start over") }
|
||||
}
|
||||
|
||||
is JoinState.Saved -> {
|
||||
Text("Saved ${s.displayName}.", style = MaterialTheme.typography.bodyLarge)
|
||||
Button(
|
||||
onClick = viewModel::reset,
|
||||
modifier = Modifier.fillMaxWidth().height(PrimaryButtonHeight),
|
||||
) { Text("Join more") }
|
||||
}
|
||||
|
||||
is JoinState.Failed -> {
|
||||
Text(
|
||||
s.message,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Button(
|
||||
onClick = viewModel::reset,
|
||||
modifier = Modifier.fillMaxWidth().height(PrimaryButtonHeight),
|
||||
) { Text("Start over") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FileRow(input: InputFile) {
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
Text(input.displayName, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package org.libremediaconverter.join
|
||||
|
||||
import android.app.Application
|
||||
import android.net.Uri
|
||||
import android.provider.OpenableColumns
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import org.libremediaconverter.convert.InputFile
|
||||
import org.libremediaconverter.convert.OutputPublisher
|
||||
import org.libremediaconverter.model.ConcatStrategy
|
||||
import org.libremediaconverter.work.ConcatWorker
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
|
||||
sealed interface JoinState {
|
||||
data object Idle : JoinState
|
||||
data class Ready(val inputs: List<InputFile>) : JoinState
|
||||
data class Joining(val inputs: List<InputFile>) : JoinState
|
||||
data class Waiting(val inputs: List<InputFile>) : JoinState
|
||||
data class Joined(val staged: File, val strategy: ConcatStrategy) : JoinState
|
||||
data class Saved(val displayName: String) : JoinState
|
||||
data class Failed(val message: String) : JoinState
|
||||
}
|
||||
|
||||
@UnstableApi
|
||||
class JoinViewModel(app: Application) : AndroidViewModel(app) {
|
||||
|
||||
private val workManager = WorkManager.getInstance(app)
|
||||
private val publisher = OutputPublisher(app)
|
||||
|
||||
private val _state = MutableStateFlow<JoinState>(JoinState.Idle)
|
||||
val state: StateFlow<JoinState> = _state.asStateFlow()
|
||||
|
||||
private var observer: Job? = null
|
||||
private var activeWorkId: UUID? = null
|
||||
|
||||
fun onInputsPicked(uris: List<Uri>) {
|
||||
if (uris.size < 2) {
|
||||
_state.value = JoinState.Failed("Pick at least two files to join.")
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
val files = withContext(Dispatchers.IO) { uris.map(::queryFile) }
|
||||
_state.value = JoinState.Ready(files)
|
||||
}
|
||||
}
|
||||
|
||||
fun join() {
|
||||
val inputs = (_state.value as? JoinState.Ready)?.inputs ?: return
|
||||
val request = ConcatWorker.request(
|
||||
inputs = inputs.map { it.uri },
|
||||
totalBytes = inputs.sumOf { it.sizeBytes },
|
||||
)
|
||||
activeWorkId = request.id
|
||||
workManager.enqueue(request)
|
||||
_state.value = JoinState.Joining(inputs)
|
||||
|
||||
observer?.cancel()
|
||||
observer = viewModelScope.launch {
|
||||
workManager.getWorkInfoByIdFlow(request.id).collect { info ->
|
||||
if (info == null) return@collect
|
||||
_state.value = when (info.state) {
|
||||
WorkInfo.State.RUNNING, WorkInfo.State.BLOCKED -> JoinState.Joining(inputs)
|
||||
WorkInfo.State.ENQUEUED ->
|
||||
if (info.runAttemptCount > 0) JoinState.Waiting(inputs)
|
||||
else JoinState.Joining(inputs)
|
||||
|
||||
WorkInfo.State.SUCCEEDED -> {
|
||||
val path = info.outputData.getString(ConcatWorker.KEY_OUTPUT_PATH)
|
||||
val strategy = info.outputData.getString(ConcatWorker.KEY_STRATEGY)
|
||||
?.let(ConcatStrategy::valueOf) ?: ConcatStrategy.REENCODE
|
||||
if (path == null) {
|
||||
JoinState.Failed("Joining reported success but produced no file.")
|
||||
} else {
|
||||
JoinState.Joined(File(path), strategy)
|
||||
}
|
||||
}
|
||||
|
||||
WorkInfo.State.FAILED -> JoinState.Failed(
|
||||
info.outputData.getString(ConcatWorker.KEY_ERROR) ?: "Joining failed."
|
||||
)
|
||||
|
||||
WorkInfo.State.CANCELLED -> JoinState.Ready(inputs)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun cancel() {
|
||||
activeWorkId?.let(workManager::cancelWorkById)
|
||||
}
|
||||
|
||||
fun save(destination: Uri) {
|
||||
val joined = _state.value as? JoinState.Joined ?: return
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
publisher.publish(joined.staged, destination)
|
||||
joined.staged.delete()
|
||||
}
|
||||
}.onSuccess {
|
||||
_state.value = JoinState.Saved("joined.mp4")
|
||||
}.onFailure { e ->
|
||||
_state.value = JoinState.Failed(e.message ?: "Could not save the file.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
observer?.cancel()
|
||||
observer = null
|
||||
activeWorkId = null
|
||||
_state.value = JoinState.Idle
|
||||
}
|
||||
|
||||
private fun queryFile(uri: Uri): InputFile {
|
||||
var name = "input"
|
||||
var size = 0L
|
||||
getApplication<Application>().contentResolver
|
||||
.query(uri, null, null, null, null)
|
||||
?.use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME).takeIf { it >= 0 }
|
||||
?.let { name = cursor.getString(it) ?: name }
|
||||
cursor.getColumnIndex(OpenableColumns.SIZE).takeIf { it >= 0 }
|
||||
?.let { size = cursor.getLong(it) }
|
||||
}
|
||||
}
|
||||
return InputFile(uri, name, size)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package org.libremediaconverter.model
|
||||
|
||||
/**
|
||||
* How to join several inputs into one file.
|
||||
*
|
||||
* The naive answer — FFmpeg's `concat` demuxer — only works when every input shares a
|
||||
* codec, resolution and timebase. That is true for clips from one camera in one
|
||||
* session, and false for the case users actually hit: two clips from different phones,
|
||||
* or a screen recording joined to a camera clip. The demuxer does not fail loudly on
|
||||
* mismatch; it can produce a file whose second half is garbled.
|
||||
*
|
||||
* So the strategy is chosen from the inputs rather than assumed.
|
||||
*/
|
||||
enum class ConcatStrategy {
|
||||
/**
|
||||
* Stream copy via the `concat` demuxer. No re-encode, near-instant, lossless.
|
||||
* Requires every input to agree on codec, resolution, frame rate and timebase.
|
||||
*/
|
||||
STREAM_COPY,
|
||||
|
||||
/**
|
||||
* Re-encode through the `concat` filter, normalising to a common format.
|
||||
* Slower and lossy, but it is the only correct answer for mismatched inputs.
|
||||
*/
|
||||
REENCODE,
|
||||
}
|
||||
|
||||
/** The properties that decide whether inputs can be joined without re-encoding. */
|
||||
data class ConcatInput(
|
||||
val videoCodec: String?,
|
||||
val audioCodec: String?,
|
||||
val width: Int,
|
||||
val height: Int,
|
||||
val frameRate: Int,
|
||||
)
|
||||
|
||||
object ConcatPlanner {
|
||||
|
||||
/**
|
||||
* Picks a strategy for [inputs].
|
||||
*
|
||||
* Errs towards [ConcatStrategy.REENCODE]: a needless re-encode costs time, while a
|
||||
* wrong stream copy costs the user a corrupt file they may not notice until later.
|
||||
*/
|
||||
fun plan(inputs: List<ConcatInput>): ConcatStrategy {
|
||||
if (inputs.size < 2) return ConcatStrategy.STREAM_COPY
|
||||
|
||||
val first = inputs.first()
|
||||
// A null codec means we could not determine it. That is not evidence of a
|
||||
// match, so it must not be treated as one.
|
||||
if (inputs.any { it.videoCodec == null || it.videoCodec != first.videoCodec }) {
|
||||
return ConcatStrategy.REENCODE
|
||||
}
|
||||
if (inputs.any { it.audioCodec != first.audioCodec }) {
|
||||
return ConcatStrategy.REENCODE
|
||||
}
|
||||
if (inputs.any { it.width != first.width || it.height != first.height }) {
|
||||
return ConcatStrategy.REENCODE
|
||||
}
|
||||
if (inputs.any { it.frameRate != first.frameRate }) {
|
||||
return ConcatStrategy.REENCODE
|
||||
}
|
||||
return ConcatStrategy.STREAM_COPY
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package org.libremediaconverter.model
|
||||
|
||||
/**
|
||||
* Chooses the engine for a conversion.
|
||||
*
|
||||
* Media3 Transformer is preferred wherever it is capable, because it is hardware
|
||||
* accelerated end to end (MediaCodec decode -> GL surface -> MediaCodec encode) and
|
||||
* runs several times faster than software encoding at a fraction of the battery cost.
|
||||
* FFmpeg handles everything Media3 structurally cannot.
|
||||
*
|
||||
* The rules below are capability boundaries, not preferences. Each one exists because
|
||||
* Media3 would fail or silently produce the wrong thing.
|
||||
*/
|
||||
object ConversionRouter {
|
||||
|
||||
/** Containers Media3 can mux. Anything else has to go to FFmpeg. */
|
||||
private val MEDIA3_CONTAINERS = setOf(
|
||||
Container.MP4,
|
||||
Container.WEBM,
|
||||
Container.OGG,
|
||||
Container.WAV,
|
||||
Container.AAC_ADTS,
|
||||
)
|
||||
|
||||
/** WebM is codec-restricted: Media3's WebmMuxer writes only these. */
|
||||
private val WEBM_AUDIO = setOf(AudioCodec.OPUS, AudioCodec.VORBIS)
|
||||
private val WEBM_VIDEO = setOf(VideoCodec.VP8, VideoCodec.VP9, VideoCodec.NONE)
|
||||
|
||||
/** Video codecs Media3 can be asked to encode (`Transformer.setVideoMimeType`). */
|
||||
private val MEDIA3_VIDEO = setOf(VideoCodec.H264, VideoCodec.H265, VideoCodec.NONE)
|
||||
|
||||
/** Audio codecs Media3 can encode. Notably absent: MP3 and FLAC. */
|
||||
private val MEDIA3_AUDIO = setOf(AudioCodec.AAC, AudioCodec.OPUS, AudioCodec.PCM, AudioCodec.NONE)
|
||||
|
||||
fun route(request: ConversionRequest, device: DeviceCodecs): Decision {
|
||||
when (request.enginePreference) {
|
||||
EnginePreference.FORCE_SOFTWARE ->
|
||||
return Decision(Engine.FFMPEG, Reason.USER_FORCED_SOFTWARE)
|
||||
else -> Unit
|
||||
}
|
||||
|
||||
// Order matters below: the specific reasons are checked before the general
|
||||
// ones, because the reason string is shown to the user. "Android has no
|
||||
// encoder for this format" tells them something actionable about MP3;
|
||||
// "this container needs FFmpeg" does not.
|
||||
|
||||
// MP3 has no encoder anywhere on Android, at any API level. That is a platform
|
||||
// gap rather than a Media3 limitation, and libmp3lame is the only way the app
|
||||
// can produce MP3 at all.
|
||||
if (request.format.audioCodec !in MEDIA3_AUDIO) {
|
||||
return Decision(Engine.FFMPEG, Reason.NO_PLATFORM_ENCODER)
|
||||
}
|
||||
|
||||
// GIF and frame sequences are image outputs; Media3 has no muxer for them.
|
||||
if (request.format.isImageOutput) {
|
||||
return Decision(Engine.FFMPEG, Reason.IMAGE_OUTPUT)
|
||||
}
|
||||
|
||||
// Containers Media3 cannot mux at all, chiefly Matroska.
|
||||
if (request.format.container !in MEDIA3_CONTAINERS) {
|
||||
return Decision(Engine.FFMPEG, Reason.CONTAINER_UNSUPPORTED)
|
||||
}
|
||||
|
||||
// WebM is codec-restricted even though the container itself is supported.
|
||||
if (request.format.container == Container.WEBM &&
|
||||
(request.format.audioCodec !in WEBM_AUDIO || request.format.videoCodec !in WEBM_VIDEO)
|
||||
) {
|
||||
return Decision(Engine.FFMPEG, Reason.WEBM_CODEC_UNSUPPORTED)
|
||||
}
|
||||
|
||||
// Media3 does not bundle ExoPlayer's software decoders, so an input codec with
|
||||
// no platform decoder cannot be read at all. The dav1d extension does not
|
||||
// rescue this: Transformer ignores bundled software decoder modules.
|
||||
val inputCodec = request.probe.videoCodec
|
||||
if (inputCodec != null && !device.canDecode(inputCodec)) {
|
||||
return Decision(Engine.FFMPEG, Reason.NO_PLATFORM_DECODER)
|
||||
}
|
||||
|
||||
// CRF and two-pass are the whole point of the quality tier, and MediaCodec
|
||||
// exposes neither, so BEST always means software encoding.
|
||||
if (request.quality == QualityTier.BEST) {
|
||||
return Decision(Engine.FFMPEG, Reason.QUALITY_TIER_REQUIRES_CRF)
|
||||
}
|
||||
|
||||
// Transformer.setVideoMimeType accepts only H.263/H.264/H.265/MP4V, so VP9 and
|
||||
// AV1 targets cannot be encoded by Media3 regardless of what the device can do.
|
||||
if (request.format.videoCodec !in MEDIA3_VIDEO) {
|
||||
return Decision(Engine.FFMPEG, Reason.NO_PLATFORM_ENCODER)
|
||||
}
|
||||
|
||||
// Finally, the target codec has to be hardware-encodable on this specific
|
||||
// device. HEVC is near-universal; AV1 encode is rare.
|
||||
if (request.format.videoCodec != VideoCodec.NONE &&
|
||||
!device.canEncode(request.format.videoCodec)
|
||||
) {
|
||||
return Decision(Engine.FFMPEG, Reason.NO_HARDWARE_ENCODER)
|
||||
}
|
||||
|
||||
return Decision(Engine.MEDIA3, Reason.HARDWARE_CAPABLE)
|
||||
}
|
||||
|
||||
data class Decision(val engine: Engine, val reason: Reason)
|
||||
|
||||
enum class Reason(val explanation: String) {
|
||||
HARDWARE_CAPABLE("Hardware accelerated"),
|
||||
CONTAINER_UNSUPPORTED("This container needs FFmpeg"),
|
||||
WEBM_CODEC_UNSUPPORTED("WebM only supports VP8/VP9 with Opus or Vorbis"),
|
||||
NO_PLATFORM_ENCODER("Android has no encoder for this format"),
|
||||
NO_PLATFORM_DECODER("This device cannot decode the input in hardware"),
|
||||
NO_HARDWARE_ENCODER("This device has no hardware encoder for that codec"),
|
||||
IMAGE_OUTPUT("Image output needs FFmpeg"),
|
||||
QUALITY_TIER_REQUIRES_CRF("Best quality uses software encoding"),
|
||||
USER_FORCED_SOFTWARE("Software encoding was requested"),
|
||||
MEDIA3_FAILED("Hardware conversion failed; retrying in software"),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What this device's codecs can actually do.
|
||||
*
|
||||
* An interface so the routing rules can be unit tested against fabricated device
|
||||
* profiles rather than whatever hardware the test happens to run on.
|
||||
*/
|
||||
interface DeviceCodecs {
|
||||
fun canEncode(codec: VideoCodec): Boolean
|
||||
fun canDecode(codecName: String): Boolean
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Assumes everything works, except an input the platform could not parse.
|
||||
*
|
||||
* That exception matters: a device double that claims it can decode an
|
||||
* unparseable file would let the router send a doomed job to Media3.
|
||||
*/
|
||||
val PERMISSIVE = object : DeviceCodecs {
|
||||
override fun canEncode(codec: VideoCodec) = true
|
||||
override fun canDecode(codecName: String) = codecName != InputProbe.UNPARSEABLE
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
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 }
|
||||
|
||||
/** Codecs this app can be asked to produce. */
|
||||
enum class VideoCodec { H264, H265, VP9, VP8, AV1, NONE }
|
||||
enum class AudioCodec { AAC, OPUS, VORBIS, MP3, FLAC, PCM, NONE }
|
||||
|
||||
/**
|
||||
* A user-selectable output format.
|
||||
*
|
||||
* Deliberately a closed set rather than a free-form codec/container matrix: most
|
||||
* combinations are either invalid or pointless, and the closed set is what makes the
|
||||
* routing rules decidable.
|
||||
*/
|
||||
enum class OutputFormat(
|
||||
val label: String,
|
||||
val container: Container,
|
||||
val videoCodec: VideoCodec,
|
||||
val audioCodec: AudioCodec,
|
||||
val extension: String,
|
||||
val mimeType: String,
|
||||
) {
|
||||
MP4_H264("MP4 (H.264)", Container.MP4, VideoCodec.H264, AudioCodec.AAC, "mp4", "video/mp4"),
|
||||
MP4_H265("MP4 (H.265)", Container.MP4, VideoCodec.H265, AudioCodec.AAC, "mp4", "video/mp4"),
|
||||
WEBM_VP9("WebM (VP9)", Container.WEBM, VideoCodec.VP9, AudioCodec.OPUS, "webm", "video/webm"),
|
||||
MKV_H264("MKV (H.264)", Container.MKV, VideoCodec.H264, AudioCodec.AAC, "mkv", "video/x-matroska"),
|
||||
MKV_H265("MKV (H.265)", Container.MKV, VideoCodec.H265, AudioCodec.AAC, "mkv", "video/x-matroska"),
|
||||
|
||||
MP3("MP3", Container.MP3, VideoCodec.NONE, AudioCodec.MP3, "mp3", "audio/mpeg"),
|
||||
M4A_AAC("M4A (AAC)", Container.MP4, VideoCodec.NONE, AudioCodec.AAC, "m4a", "audio/mp4"),
|
||||
OPUS("Opus", Container.OGG, VideoCodec.NONE, AudioCodec.OPUS, "opus", "audio/opus"),
|
||||
FLAC("FLAC", Container.MKV, VideoCodec.NONE, AudioCodec.FLAC, "flac", "audio/flac"),
|
||||
WAV("WAV", Container.WAV, VideoCodec.NONE, AudioCodec.PCM, "wav", "audio/wav"),
|
||||
|
||||
GIF("GIF", Container.GIF, VideoCodec.NONE, AudioCodec.NONE, "gif", "image/gif"),
|
||||
FRAMES_PNG("PNG frames", Container.IMAGE_SEQUENCE, VideoCodec.NONE, AudioCodec.NONE, "png", "image/png");
|
||||
|
||||
val isAudioOnly: Boolean get() = videoCodec == VideoCodec.NONE && audioCodec != AudioCodec.NONE
|
||||
val isImageOutput: Boolean
|
||||
get() = container == Container.GIF || container == Container.IMAGE_SEQUENCE
|
||||
}
|
||||
|
||||
/**
|
||||
* How hard to work for quality.
|
||||
*
|
||||
* This is the user-facing form of the engine split. [BEST] is the entire reason the
|
||||
* shipped binary is GPL: CRF and two-pass rate control come from x264/x265 and are not
|
||||
* exposed by any Android hardware encoder.
|
||||
*/
|
||||
enum class QualityTier(val label: String, val description: String) {
|
||||
FAST("Fast", "Hardware accelerated. Best for sharing and batches."),
|
||||
BEST("Best quality", "Software encode with CRF. Slower, smaller files."),
|
||||
}
|
||||
|
||||
/** Escape hatch, mostly for debugging and for devices with broken encoders. */
|
||||
enum class EnginePreference { AUTO, PREFER_HARDWARE, FORCE_SOFTWARE }
|
||||
|
||||
enum class Engine { MEDIA3, FFMPEG }
|
||||
|
||||
/** What we know about the input, as far as routing is concerned. */
|
||||
data class InputProbe(
|
||||
val videoCodec: String? = null,
|
||||
val audioCodec: String? = null,
|
||||
val hasVideo: Boolean = true,
|
||||
val durationMs: Long = 0,
|
||||
) {
|
||||
companion object {
|
||||
/**
|
||||
* Codec name used when the platform could not parse the input at all.
|
||||
*
|
||||
* Distinct from `null` (nothing known, assume the platform copes) and from a
|
||||
* real codec name. [DeviceCodecs] treats it as undecodable, which routes the
|
||||
* job to FFmpeg — the correct answer when the platform extractor has already
|
||||
* failed to open the file.
|
||||
*/
|
||||
const val UNPARSEABLE = "\u0000unparseable"
|
||||
}
|
||||
}
|
||||
|
||||
data class ConversionRequest(
|
||||
val format: OutputFormat,
|
||||
val quality: QualityTier = QualityTier.FAST,
|
||||
val enginePreference: EnginePreference = EnginePreference.AUTO,
|
||||
val probe: InputProbe = InputProbe(),
|
||||
/**
|
||||
* Whether this device has a real hardware encoder for the target codec.
|
||||
*
|
||||
* When it does not, FFmpeg's `*_mediacodec` wrappers still appear to work: they
|
||||
* bind to the platform's *software* codec (`c2.android.*`) and encode at a crawl
|
||||
* while presenting as the fast path. Knowing this lets the Fast tier choose a
|
||||
* genuinely fast software preset instead of a mislabelled slow one.
|
||||
*/
|
||||
val hardwareEncodeAvailable: Boolean = true,
|
||||
)
|
||||
@@ -0,0 +1,24 @@
|
||||
package org.libremediaconverter.ui
|
||||
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* Layout constants shared by the top-level screens.
|
||||
*
|
||||
* Shared rather than duplicated so the two tabs cannot drift apart: an empty state that
|
||||
* looks different depending on which tab you are on reads as a bug.
|
||||
*/
|
||||
|
||||
/** Primary actions are taller than the Material default so they read as the main affordance. */
|
||||
val PrimaryButtonHeight: Dp = 56.dp
|
||||
|
||||
/**
|
||||
* Horizontal screen inset.
|
||||
*
|
||||
* Deliberately tighter than the vertical inset so a full-width primary button reaches
|
||||
* close to both edges of the display.
|
||||
*/
|
||||
val ScreenPaddingHorizontal: Dp = 16.dp
|
||||
|
||||
val ScreenPaddingVertical: Dp = 24.dp
|
||||
@@ -0,0 +1,12 @@
|
||||
package org.libremediaconverter.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
// Static fallback palette, used when dynamic color is unavailable or disabled.
|
||||
val Purple80 = Color(0xFFD0BCFF)
|
||||
val PurpleGrey80 = Color(0xFFCCC2DC)
|
||||
val Pink80 = Color(0xFFEFB8C8)
|
||||
|
||||
val Purple40 = Color(0xFF6650a4)
|
||||
val PurpleGrey40 = Color(0xFF625b71)
|
||||
val Pink40 = Color(0xFF7D5260)
|
||||
@@ -0,0 +1,51 @@
|
||||
package org.libremediaconverter.ui.theme
|
||||
|
||||
import android.app.Activity
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
private val DarkColorScheme = darkColorScheme(
|
||||
primary = Purple80,
|
||||
secondary = PurpleGrey80,
|
||||
tertiary = Pink80,
|
||||
)
|
||||
|
||||
private val LightColorScheme = lightColorScheme(
|
||||
primary = Purple40,
|
||||
secondary = PurpleGrey40,
|
||||
tertiary = Pink40,
|
||||
)
|
||||
|
||||
/**
|
||||
* Material 3 theme.
|
||||
*
|
||||
* Dynamic color (Material You) needs API 31+; minSdk is 33, so it is available
|
||||
* unconditionally and no version guard is required. It stays switchable so users can
|
||||
* opt back to the brand palette.
|
||||
*/
|
||||
@Composable
|
||||
fun LibreMediaConverterTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
dynamicColor: Boolean = true,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val colorScheme = when {
|
||||
dynamicColor && darkTheme -> dynamicDarkColorScheme(context)
|
||||
dynamicColor -> dynamicLightColorScheme(context)
|
||||
darkTheme -> DarkColorScheme
|
||||
else -> LightColorScheme
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = Typography,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package org.libremediaconverter.ui.theme
|
||||
|
||||
import androidx.compose.material3.Typography
|
||||
|
||||
val Typography = Typography()
|
||||
@@ -0,0 +1,116 @@
|
||||
package org.libremediaconverter.work
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.Data
|
||||
import androidx.work.ForegroundInfo
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkerParameters
|
||||
import androidx.work.workDataOf
|
||||
import org.libremediaconverter.convert.ConversionDependencies
|
||||
import org.libremediaconverter.ffmpeg.ConcatEngine
|
||||
import org.libremediaconverter.model.OutputFormat
|
||||
|
||||
/**
|
||||
* Joins several files into one, as durable foreground work.
|
||||
*
|
||||
* Separate from [ConversionWorker] rather than folded into it: joining takes a list of
|
||||
* inputs, has its own stream-copy-versus-re-encode decision, and reports a different
|
||||
* result. Overloading one worker with both would make the input contract ambiguous.
|
||||
*
|
||||
* Progress is not reported. FFmpeg's statistics callback gives a timestamp against a
|
||||
* single input's duration, which is meaningless once several files are being
|
||||
* concatenated; showing a fabricated percentage would be worse than showing none.
|
||||
*/
|
||||
@UnstableApi
|
||||
class ConcatWorker(
|
||||
context: Context,
|
||||
params: WorkerParameters,
|
||||
) : CoroutineWorker(context, params) {
|
||||
|
||||
private val notifications = ConversionNotifications(applicationContext)
|
||||
private val publisher = ConversionDependencies.publisher(applicationContext)
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
val uris = inputData.getStringArray(KEY_INPUT_URIS)?.map(Uri::parse)
|
||||
?: return Result.failure(workDataOf(KEY_ERROR to "No input files."))
|
||||
if (uris.size < 2) {
|
||||
return Result.failure(workDataOf(KEY_ERROR to "Pick at least two files to join."))
|
||||
}
|
||||
val totalBytes = inputData.getLong(KEY_TOTAL_BYTES, 0L)
|
||||
val format = OutputFormat.valueOf(
|
||||
inputData.getString(KEY_FORMAT) ?: OutputFormat.MP4_H264.name
|
||||
)
|
||||
|
||||
if (!publisher.hasSpaceFor(totalBytes)) {
|
||||
return Result.failure(workDataOf(KEY_ERROR to "Not enough free space to join these files."))
|
||||
}
|
||||
|
||||
setForeground(
|
||||
ForegroundInfo(
|
||||
NOTIFICATION_ID,
|
||||
notifications.build(id, "Joining ${uris.size} files", 0, indeterminate = true),
|
||||
ConversionForegroundType.current(),
|
||||
)
|
||||
)
|
||||
|
||||
val staged = publisher.createStagingFile("joined.${format.extension}")
|
||||
return try {
|
||||
val result = ConcatEngine(applicationContext).join(uris, staged, format)
|
||||
Result.success(
|
||||
workDataOf(
|
||||
KEY_OUTPUT_PATH to staged.absolutePath,
|
||||
KEY_STRATEGY to result.strategy.name,
|
||||
)
|
||||
)
|
||||
} catch (e: Throwable) {
|
||||
staged.delete()
|
||||
when (FailureOutcome.forStopReason(stopReason)) {
|
||||
FailureOutcome.RETRY -> {
|
||||
Log.w(TAG, "Foreground budget exhausted while joining; will retry.", e)
|
||||
Result.retry()
|
||||
}
|
||||
FailureOutcome.FAIL -> {
|
||||
Log.e(TAG, "Joining failed.", e)
|
||||
Result.failure(workDataOf(KEY_ERROR to (e.message ?: "Joining failed.")))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getForegroundInfo(): ForegroundInfo = ForegroundInfo(
|
||||
NOTIFICATION_ID,
|
||||
notifications.build(id, "Joining files", 0, indeterminate = true),
|
||||
ConversionForegroundType.current(),
|
||||
)
|
||||
|
||||
companion object {
|
||||
const val KEY_INPUT_URIS = "input_uris"
|
||||
const val KEY_TOTAL_BYTES = "total_bytes"
|
||||
const val KEY_FORMAT = "format"
|
||||
const val KEY_OUTPUT_PATH = "output_path"
|
||||
const val KEY_STRATEGY = "strategy"
|
||||
const val KEY_ERROR = "error"
|
||||
|
||||
private const val NOTIFICATION_ID = 1002
|
||||
private const val TAG = "ConcatWorker"
|
||||
|
||||
fun request(
|
||||
inputs: List<Uri>,
|
||||
totalBytes: Long,
|
||||
format: OutputFormat = OutputFormat.MP4_H264,
|
||||
) = OneTimeWorkRequestBuilder<ConcatWorker>()
|
||||
.setInputData(
|
||||
Data.Builder()
|
||||
.putStringArray(KEY_INPUT_URIS, inputs.map(Uri::toString).toTypedArray())
|
||||
.putLong(KEY_TOTAL_BYTES, totalBytes)
|
||||
.putString(KEY_FORMAT, format.name)
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package org.libremediaconverter.work
|
||||
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.Build
|
||||
|
||||
/**
|
||||
* Picks the foreground service type for a conversion job.
|
||||
*
|
||||
* There are three regimes across the supported range, which is why this is not a
|
||||
* single constant:
|
||||
*
|
||||
* | API | Regime |
|
||||
* |------------|-----------------------------------------------------------------|
|
||||
* | 33 | Foreground service types are not required at all. |
|
||||
* | 34 | A type is mandatory, but `mediaProcessing` does not exist yet, |
|
||||
* | | so `dataSync` is the only sensible fit. |
|
||||
* | 35+ | `mediaProcessing` exists and is the correct type. Its own docs |
|
||||
* | | describe it as "converting media to different formats". |
|
||||
*
|
||||
* Both types carry the same budget: **six hours out of every twenty-four**, shared
|
||||
* across all of the app's foreground services. On expiry the system calls
|
||||
* `Service.onTimeout` and the app has seconds to stop before taking an ANR.
|
||||
*/
|
||||
object ConversionForegroundType {
|
||||
|
||||
/**
|
||||
* The `foregroundServiceType` to pass to `ForegroundInfo`.
|
||||
*
|
||||
* Returns 0 on API 33, where passing a type is unnecessary — and where the
|
||||
* `mediaProcessing` constant does not exist to pass in the first place.
|
||||
*/
|
||||
fun current(): Int = when {
|
||||
Build.VERSION.SDK_INT >= 35 -> ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROCESSING
|
||||
Build.VERSION.SDK_INT >= 34 -> ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
|
||||
else -> 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package org.libremediaconverter.work
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.work.WorkManager
|
||||
import org.libremediaconverter.R
|
||||
import java.util.UUID
|
||||
|
||||
/** Progress notification for a running conversion. */
|
||||
class ConversionNotifications(private val context: Context) {
|
||||
|
||||
init {
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
context.getString(R.string.channel_conversions),
|
||||
// Low importance: a long-running progress bar should not make noise or
|
||||
// push a heads-up card on every update.
|
||||
NotificationManager.IMPORTANCE_LOW,
|
||||
).apply {
|
||||
description = context.getString(R.string.channel_conversions_description)
|
||||
setShowBadge(false)
|
||||
}
|
||||
context.getSystemService(NotificationManager::class.java)
|
||||
.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
fun build(id: UUID, title: String, percent: Int, indeterminate: Boolean = false) =
|
||||
NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setContentTitle(title)
|
||||
.setContentText(
|
||||
if (indeterminate) {
|
||||
context.getString(R.string.notification_preparing)
|
||||
} else {
|
||||
context.getString(R.string.notification_progress, percent)
|
||||
}
|
||||
)
|
||||
.setSmallIcon(android.R.drawable.stat_sys_download)
|
||||
.setOngoing(true)
|
||||
// Progress updates far outpace what the UI can use; alerting once keeps
|
||||
// the system UI from being hammered.
|
||||
.setOnlyAlertOnce(true)
|
||||
.setProgress(100, percent, indeterminate)
|
||||
.addAction(
|
||||
android.R.drawable.ic_menu_close_clear_cancel,
|
||||
context.getString(R.string.action_cancel),
|
||||
WorkManager.getInstance(context).createCancelPendingIntent(id),
|
||||
)
|
||||
.build()
|
||||
|
||||
/**
|
||||
* True when notifications can actually be shown.
|
||||
*
|
||||
* A foreground service still starts without POST_NOTIFICATIONS, but its
|
||||
* notification appears only in the Task Manager rather than the shade — so
|
||||
* progress silently vanishes from the user's point of view.
|
||||
*/
|
||||
fun areEnabled(): Boolean =
|
||||
context.getSystemService(NotificationManager::class.java)
|
||||
.areNotificationsEnabled()
|
||||
.also { if (!it) Log.i(TAG, "Notifications disabled; progress will not be visible.") }
|
||||
|
||||
companion object {
|
||||
const val CHANNEL_ID = "conversions"
|
||||
private const val TAG = "ConversionNotifications"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
package org.libremediaconverter.work
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.media3.common.MimeTypes
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.Data
|
||||
import androidx.work.ForegroundInfo
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkerParameters
|
||||
import androidx.work.workDataOf
|
||||
import com.arthenica.ffmpegkit.FFmpegKitConfig
|
||||
import org.libremediaconverter.convert.ConversionDependencies
|
||||
import org.libremediaconverter.convert.MediaProbe
|
||||
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.QualityTier
|
||||
import org.libremediaconverter.model.VideoCodec
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Runs one conversion as durable, cancellable background work.
|
||||
*
|
||||
* WorkManager rather than a bare foreground service: the queue survives process death,
|
||||
* cancellation and progress are already modelled, and `WorkInfo` gives the UI a Flow to
|
||||
* observe. That durability is what makes the six-hour foreground-service timeout
|
||||
* recoverable instead of fatal.
|
||||
*
|
||||
* Expedited work is deliberately *not* used. It maps to JobScheduler expedited jobs
|
||||
* with a short quota, which is the wrong shape for a multi-minute transcode.
|
||||
*/
|
||||
@UnstableApi
|
||||
class ConversionWorker(
|
||||
context: Context,
|
||||
params: WorkerParameters,
|
||||
) : CoroutineWorker(context, params) {
|
||||
|
||||
private val notifications = ConversionNotifications(applicationContext)
|
||||
// Resolved through ConversionDependencies so tests can force the failure paths.
|
||||
private val publisher = ConversionDependencies.publisher(applicationContext)
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
val inputUri = inputData.getString(KEY_INPUT_URI)?.let(Uri::parse)
|
||||
?: return Result.failure(workDataOf(KEY_ERROR to "No input file."))
|
||||
val displayName = inputData.getString(KEY_DISPLAY_NAME) ?: "input"
|
||||
val sizeBytes = inputData.getLong(KEY_SIZE_BYTES, 0L)
|
||||
val format = OutputFormat.valueOf(
|
||||
inputData.getString(KEY_FORMAT) ?: OutputFormat.MP4_H265.name
|
||||
)
|
||||
val quality = QualityTier.valueOf(
|
||||
inputData.getString(KEY_QUALITY) ?: QualityTier.FAST.name
|
||||
)
|
||||
val preference = EnginePreference.valueOf(
|
||||
inputData.getString(KEY_ENGINE_PREFERENCE) ?: EnginePreference.AUTO.name
|
||||
)
|
||||
|
||||
if (!publisher.hasSpaceFor(sizeBytes)) {
|
||||
return Result.failure(workDataOf(KEY_ERROR to "Not enough free space to convert."))
|
||||
}
|
||||
|
||||
setForeground(foregroundInfo(displayName, percent = 0, indeterminate = true))
|
||||
|
||||
val probe = MediaProbe.probe(applicationContext, inputUri)
|
||||
val devices = ConversionDependencies.deviceCodecs()
|
||||
val request = ConversionRequest(
|
||||
format = format,
|
||||
quality = quality,
|
||||
enginePreference = preference,
|
||||
probe = probe,
|
||||
hardwareEncodeAvailable = devices.canEncode(format.videoCodec),
|
||||
)
|
||||
val decision = ConversionRouter.route(request, devices)
|
||||
Log.i(TAG, "Routing $displayName -> ${format.name} via ${decision.engine} (${decision.reason})")
|
||||
|
||||
val staged = publisher.createStagingFile(outputNameFor(displayName, format))
|
||||
|
||||
return try {
|
||||
when (decision.engine) {
|
||||
Engine.MEDIA3 -> runMedia3OrFallBack(request, inputUri, staged, displayName)
|
||||
Engine.FFMPEG -> runFFmpeg(request, inputUri, staged, displayName)
|
||||
}
|
||||
Result.success(
|
||||
workDataOf(
|
||||
KEY_OUTPUT_PATH to staged.absolutePath,
|
||||
KEY_ENGINE_USED to decision.engine.name,
|
||||
KEY_ROUTE_REASON to decision.reason.explanation,
|
||||
)
|
||||
)
|
||||
} catch (e: Throwable) {
|
||||
staged.delete()
|
||||
handleTimeoutIfNeeded(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The dynamic half of the routing rules.
|
||||
*
|
||||
* The static predicates catch what is knowably unsupported, but hardware encoders
|
||||
* are vendor-declared and, per the platform's own documentation, "cannot be tested
|
||||
* for correctness". A device that claims HEVC support and then fails mid-export is
|
||||
* a real and common failure. Rather than surface that to the user as a failed
|
||||
* conversion, retry the same job in software — slower, but it produces the file.
|
||||
*/
|
||||
private suspend fun runMedia3OrFallBack(
|
||||
request: ConversionRequest,
|
||||
inputUri: Uri,
|
||||
staged: File,
|
||||
displayName: String,
|
||||
) {
|
||||
val engine = ConversionDependencies.hardware(applicationContext)
|
||||
try {
|
||||
engine.transcode(inputUri, staged, media3MimeType(request.format)) { percent ->
|
||||
publishProgress(displayName, percent)
|
||||
}
|
||||
return
|
||||
} catch (e: Throwable) {
|
||||
if (isCancellation(e)) throw e
|
||||
Log.w(TAG, "Hardware conversion failed; retrying in software.", e)
|
||||
} finally {
|
||||
engine.close()
|
||||
}
|
||||
|
||||
staged.delete()
|
||||
runFFmpeg(request, inputUri, staged, displayName)
|
||||
}
|
||||
|
||||
private suspend fun runFFmpeg(
|
||||
request: ConversionRequest,
|
||||
inputUri: Uri,
|
||||
staged: File,
|
||||
displayName: String,
|
||||
) {
|
||||
// FFmpeg needs a path. ffkitsaf bridges a content:// URI for reading; the read
|
||||
// side is seekable for local providers, which is all the demuxer needs. Output
|
||||
// still goes to a real cache path — see OutputPublisher.
|
||||
val inputPath = if (inputUri.scheme == "content") {
|
||||
FFmpegKitConfig.getSafParameterForRead(applicationContext, inputUri)
|
||||
} else {
|
||||
inputUri.path
|
||||
} ?: error("Could not open the input file.")
|
||||
|
||||
ConversionDependencies.software()
|
||||
.run(request, inputPath, staged, request.probe.durationMs) { percent ->
|
||||
publishProgress(displayName, percent)
|
||||
}
|
||||
}
|
||||
|
||||
private var lastNotified = 0L
|
||||
|
||||
private fun publishProgress(displayName: String, percent: Int) {
|
||||
setProgressAsync(workDataOf(KEY_PROGRESS to percent))
|
||||
// Throttle to ~1/sec: progress arrives several times a second and pushing every
|
||||
// update janks the system UI.
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastNotified >= NOTIFICATION_INTERVAL_MS) {
|
||||
lastNotified = now
|
||||
applicationContext.getSystemService(android.app.NotificationManager::class.java)
|
||||
.notify(NOTIFICATION_ID, notifications.build(id, displayName, percent))
|
||||
}
|
||||
}
|
||||
|
||||
private fun isCancellation(e: Throwable): Boolean =
|
||||
e is kotlinx.coroutines.CancellationException || isStopped
|
||||
|
||||
/**
|
||||
* Distinguishes a genuine failure from the foreground-service budget expiring.
|
||||
*
|
||||
* `mediaProcessing` allows six hours out of every twenty-four, shared across the
|
||||
* app. When that runs out WorkManager reports
|
||||
* `STOP_REASON_FOREGROUND_SERVICE_TIMEOUT`, and the right response is to retry
|
||||
* later rather than tell the user the conversion failed — the work is still valid,
|
||||
* there is simply no budget right now.
|
||||
*/
|
||||
private fun handleTimeoutIfNeeded(cause: Throwable): Result =
|
||||
when (FailureOutcome.forStopReason(stopReason)) {
|
||||
FailureOutcome.RETRY -> {
|
||||
Log.w(TAG, "Foreground service budget exhausted; will retry.", cause)
|
||||
Result.retry()
|
||||
}
|
||||
FailureOutcome.FAIL -> {
|
||||
Log.e(TAG, "Conversion failed.", cause)
|
||||
Result.failure(workDataOf(KEY_ERROR to (cause.message ?: "Conversion failed.")))
|
||||
}
|
||||
}
|
||||
|
||||
private fun media3MimeType(format: OutputFormat): String = when (format.videoCodec) {
|
||||
VideoCodec.H264 -> MimeTypes.VIDEO_H264
|
||||
else -> MimeTypes.VIDEO_H265
|
||||
}
|
||||
|
||||
override suspend fun getForegroundInfo(): ForegroundInfo =
|
||||
foregroundInfo(
|
||||
inputData.getString(KEY_DISPLAY_NAME) ?: "input",
|
||||
percent = 0,
|
||||
indeterminate = true,
|
||||
)
|
||||
|
||||
private fun foregroundInfo(title: String, percent: Int, indeterminate: Boolean) =
|
||||
ForegroundInfo(
|
||||
NOTIFICATION_ID,
|
||||
notifications.build(id, title, percent, indeterminate),
|
||||
ConversionForegroundType.current(),
|
||||
)
|
||||
|
||||
companion object {
|
||||
const val KEY_INPUT_URI = "input_uri"
|
||||
const val KEY_DISPLAY_NAME = "display_name"
|
||||
const val KEY_SIZE_BYTES = "size_bytes"
|
||||
const val KEY_FORMAT = "format"
|
||||
const val KEY_QUALITY = "quality"
|
||||
const val KEY_ENGINE_PREFERENCE = "engine_preference"
|
||||
const val KEY_PROGRESS = "progress"
|
||||
const val KEY_OUTPUT_PATH = "output_path"
|
||||
const val KEY_ENGINE_USED = "engine_used"
|
||||
const val KEY_ROUTE_REASON = "route_reason"
|
||||
const val KEY_ERROR = "error"
|
||||
|
||||
private const val NOTIFICATION_ID = 1001
|
||||
private const val NOTIFICATION_INTERVAL_MS = 1_000L
|
||||
private const val TAG = "ConversionWorker"
|
||||
|
||||
fun outputNameFor(inputName: String, format: OutputFormat): String =
|
||||
inputName.substringBeforeLast('.', inputName) +
|
||||
"_converted.${format.extension}"
|
||||
|
||||
fun request(
|
||||
inputUri: Uri,
|
||||
displayName: String,
|
||||
sizeBytes: Long,
|
||||
format: OutputFormat = OutputFormat.MP4_H265,
|
||||
quality: QualityTier = QualityTier.FAST,
|
||||
enginePreference: EnginePreference = EnginePreference.AUTO,
|
||||
) = OneTimeWorkRequestBuilder<ConversionWorker>()
|
||||
.setInputData(
|
||||
Data.Builder()
|
||||
.putString(KEY_INPUT_URI, inputUri.toString())
|
||||
.putString(KEY_DISPLAY_NAME, displayName)
|
||||
.putLong(KEY_SIZE_BYTES, sizeBytes)
|
||||
.putString(KEY_FORMAT, format.name)
|
||||
.putString(KEY_QUALITY, quality.name)
|
||||
.putString(KEY_ENGINE_PREFERENCE, enginePreference.name)
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package org.libremediaconverter.work
|
||||
|
||||
import androidx.work.WorkInfo
|
||||
|
||||
/**
|
||||
* Decides whether a failed job should be retried or reported as failed.
|
||||
*
|
||||
* A pure function rather than a branch inside the worker, because the case that matters
|
||||
* cannot be provoked in a test: the foreground-service budget is six hours per
|
||||
* twenty-four, and no test is going to exhaust it. Isolating the decision means the
|
||||
* rule itself can still be verified on the JVM, even though the condition that triggers
|
||||
* it in production cannot be reproduced.
|
||||
*/
|
||||
enum class FailureOutcome {
|
||||
/** Budget exhausted, not a real failure — the work is still valid, so try later. */
|
||||
RETRY,
|
||||
|
||||
/** A genuine failure; report it to the user. */
|
||||
FAIL;
|
||||
|
||||
companion object {
|
||||
fun forStopReason(stopReason: Int): FailureOutcome =
|
||||
if (stopReason == WorkInfo.STOP_REASON_FOREGROUND_SERVICE_TIMEOUT) RETRY else FAIL
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,39 @@
|
||||
# Add project specific R8 rules here.
|
||||
# AGP will combine all keep rule files in src/main/keepRules to pass to R8
|
||||
#
|
||||
# For more details, see
|
||||
# https://d.android.com/r/tools/r8/keep-rules
|
||||
# R8 keep rules.
|
||||
# AGP combines every file under src/<variant>/keepRules and passes them to R8.
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
# --- FFmpegKit JNI boundary -------------------------------------------------
|
||||
# The native library looks these classes and members up by name through JNI.
|
||||
# R8 cannot see those references, so without explicit keeps it will rename or
|
||||
# remove them and the native calls fail at runtime with NoSuchMethodError --
|
||||
# only in release builds, and only once a conversion is actually attempted.
|
||||
-keep class com.arthenica.ffmpegkit.** { *; }
|
||||
-keep class com.arthenica.smartexception.** { *; }
|
||||
|
||||
# Callback types the native layer instantiates and invokes.
|
||||
-keep interface com.arthenica.ffmpegkit.FFmpegSessionCompleteCallback { *; }
|
||||
-keep interface com.arthenica.ffmpegkit.FFprobeSessionCompleteCallback { *; }
|
||||
-keep interface com.arthenica.ffmpegkit.MediaInformationSessionCompleteCallback { *; }
|
||||
-keep interface com.arthenica.ffmpegkit.LogCallback { *; }
|
||||
-keep interface com.arthenica.ffmpegkit.StatisticsCallback { *; }
|
||||
|
||||
# Any method the native side calls back into.
|
||||
-keepclasseswithmembernames class * {
|
||||
native <methods>;
|
||||
}
|
||||
|
||||
# --- WorkManager ------------------------------------------------------------
|
||||
# Workers are constructed reflectively from a class name stored in the WorkManager
|
||||
# database, so a renamed worker breaks jobs that were enqueued before the update.
|
||||
-keep class * extends androidx.work.ListenableWorker {
|
||||
public <init>(android.content.Context, androidx.work.WorkerParameters);
|
||||
}
|
||||
|
||||
# --- Media3 -----------------------------------------------------------------
|
||||
# Transformer selects codecs and muxers reflectively in places.
|
||||
-keep class androidx.media3.** { *; }
|
||||
-dontwarn androidx.media3.**
|
||||
|
||||
# Keep the source file and line numbers so release crash reports stay readable,
|
||||
# then hide the original file name.
|
||||
-keepattributes SourceFile,LineNumberTable
|
||||
-renamesourcefileattribute SourceFile
|
||||
|
||||
@@ -1,16 +1,3 @@
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<!-- Base application theme. -->
|
||||
<style name="Theme.AndroidMediaConverter" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
|
||||
<!-- Primary brand color. -->
|
||||
<item name="colorPrimary">@color/purple_200</item>
|
||||
<item name="colorPrimaryVariant">@color/purple_700</item>
|
||||
<item name="colorOnPrimary">@color/black</item>
|
||||
<!-- Secondary brand color. -->
|
||||
<item name="colorSecondary">@color/teal_200</item>
|
||||
<item name="colorSecondaryVariant">@color/teal_200</item>
|
||||
<item name="colorOnSecondary">@color/black</item>
|
||||
<!-- Status bar color. -->
|
||||
<item name="android:statusBarColor">?attr/colorPrimaryVariant</item>
|
||||
<!-- Customize your theme here. -->
|
||||
</style>
|
||||
</resources>
|
||||
<resources>
|
||||
<style name="Theme.LibreMediaConverter" parent="android:Theme.Material.NoActionBar" />
|
||||
</resources>
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="purple_200">#FFBB86FC</color>
|
||||
<color name="purple_500">#FF6200EE</color>
|
||||
<color name="purple_700">#FF3700B3</color>
|
||||
<color name="teal_200">#FF03DAC5</color>
|
||||
<color name="teal_700">#FF018786</color>
|
||||
<color name="black">#FF000000</color>
|
||||
<color name="white">#FFFFFFFF</color>
|
||||
</resources>
|
||||
@@ -1,3 +1,9 @@
|
||||
<resources>
|
||||
<string name="app_name">Android MediaConverter</string>
|
||||
</resources>
|
||||
<string name="app_name">LibreMediaConverter</string>
|
||||
|
||||
<string name="channel_conversions">Conversions</string>
|
||||
<string name="channel_conversions_description">Progress for running conversions</string>
|
||||
<string name="notification_progress">Converting… %1$d%%</string>
|
||||
<string name="notification_preparing">Preparing…</string>
|
||||
<string name="action_cancel">Cancel</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,16 +1,3 @@
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<!-- Base application theme. -->
|
||||
<style name="Theme.AndroidMediaConverter" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
|
||||
<!-- Primary brand color. -->
|
||||
<item name="colorPrimary">@color/purple_500</item>
|
||||
<item name="colorPrimaryVariant">@color/purple_700</item>
|
||||
<item name="colorOnPrimary">@color/white</item>
|
||||
<!-- Secondary brand color. -->
|
||||
<item name="colorSecondary">@color/teal_200</item>
|
||||
<item name="colorSecondaryVariant">@color/teal_700</item>
|
||||
<item name="colorOnSecondary">@color/black</item>
|
||||
<!-- Status bar color. -->
|
||||
<item name="android:statusBarColor">?attr/colorPrimaryVariant</item>
|
||||
<!-- Customize your theme here. -->
|
||||
</style>
|
||||
</resources>
|
||||
<resources>
|
||||
<style name="Theme.LibreMediaConverter" parent="android:Theme.Material.Light.NoActionBar" />
|
||||
</resources>
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
package com.example.androidmediaconverter
|
||||
|
||||
import org.junit.Test
|
||||
|
||||
import org.junit.Assert.*
|
||||
|
||||
/**
|
||||
* Example local unit test, which will execute on the development machine (host).
|
||||
*
|
||||
* See [testing documentation](http://d.android.com/tools/testing).
|
||||
*/
|
||||
class ExampleUnitTest {
|
||||
@Test
|
||||
fun addition_isCorrect() {
|
||||
assertEquals(4, 2 + 2)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
package org.libremediaconverter.ffmpeg
|
||||
|
||||
import org.libremediaconverter.model.ConversionRequest
|
||||
import org.libremediaconverter.model.OutputFormat
|
||||
import org.libremediaconverter.model.QualityTier
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class FFmpegCommandBuilderTest {
|
||||
|
||||
private fun cmd(
|
||||
format: OutputFormat,
|
||||
quality: QualityTier = QualityTier.BEST,
|
||||
hardwareEncodeAvailable: Boolean = true,
|
||||
): List<String> = FFmpegCommandBuilder.build(
|
||||
ConversionRequest(
|
||||
format = format,
|
||||
quality = quality,
|
||||
hardwareEncodeAvailable = hardwareEncodeAvailable,
|
||||
),
|
||||
inputPath = "/cache/in.mp4",
|
||||
outputPath = "/cache/out.${format.extension}",
|
||||
)
|
||||
|
||||
/** Asserts `flag` is present and immediately followed by `value`. */
|
||||
private fun assertPair(args: List<String>, flag: String, value: String) {
|
||||
val i = args.indexOf(flag)
|
||||
assertTrue("missing $flag in $args", i >= 0)
|
||||
assertEquals("wrong value for $flag in $args", value, args[i + 1])
|
||||
}
|
||||
|
||||
// --- structure ---------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `input precedes output and both are present`() {
|
||||
val args = cmd(OutputFormat.MP4_H264)
|
||||
assertPair(args, "-i", "/cache/in.mp4")
|
||||
assertEquals("/cache/out.mp4", args.last())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `overwrite is enabled because the output path is our own cache file`() {
|
||||
assertTrue(cmd(OutputFormat.MP4_H264).contains("-y"))
|
||||
}
|
||||
|
||||
// --- the GPL quality path ---------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `best quality h264 uses libx264 with crf, not a bitrate target`() {
|
||||
val args = cmd(OutputFormat.MP4_H264, QualityTier.BEST)
|
||||
assertPair(args, "-c:v", "libx264")
|
||||
assertPair(args, "-crf", "20")
|
||||
assertFalse("CRF and -b:v are mutually exclusive", args.contains("-b:v"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `best quality h265 uses libx265 with a higher crf than h264`() {
|
||||
val args = cmd(OutputFormat.MP4_H265, QualityTier.BEST)
|
||||
assertPair(args, "-c:v", "libx265")
|
||||
// x265 is roughly a step stronger at the same number, so sharing a constant
|
||||
// with x264 would silently change the size target.
|
||||
assertPair(args, "-crf", "24")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hevc in mp4 is tagged hvc1 for player compatibility`() {
|
||||
assertPair(cmd(OutputFormat.MP4_H265, QualityTier.BEST), "-tag:v", "hvc1")
|
||||
}
|
||||
|
||||
/**
|
||||
* Regression test for a failure found with real footage on a Pixel 10 Pro XL.
|
||||
*
|
||||
* The source was H.264 High 4:4:4 Predictive. FFmpeg decodes that to yuv444p and,
|
||||
* without an explicit pixel format, hands those frames straight to an encoder that
|
||||
* cannot accept them — hevc_mediacodec died with "Invalid to call at Released
|
||||
* state" partway through. Every video encode path has to name the format so FFmpeg
|
||||
* inserts the conversion, not just the one path that happened to have it.
|
||||
*/
|
||||
@Test
|
||||
fun `every video encode path forces yuv420p`() {
|
||||
val videoFormats = listOf(
|
||||
OutputFormat.MP4_H264, OutputFormat.MP4_H265,
|
||||
OutputFormat.MKV_H264, OutputFormat.MKV_H265,
|
||||
OutputFormat.WEBM_VP9,
|
||||
)
|
||||
videoFormats.forEach { format ->
|
||||
listOf(QualityTier.FAST, QualityTier.BEST).forEach { quality ->
|
||||
listOf(true, false).forEach { hw ->
|
||||
val args = cmd(format, quality, hardwareEncodeAvailable = hw)
|
||||
assertPair(args, "-pix_fmt", "yuv420p")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `audio only outputs do not set a pixel format`() {
|
||||
listOf(OutputFormat.MP3, OutputFormat.FLAC, OutputFormat.WAV).forEach {
|
||||
assertFalse("${it.name} should not set -pix_fmt", cmd(it).contains("-pix_fmt"))
|
||||
}
|
||||
}
|
||||
|
||||
// --- the hardware fallback path ---------------------------------------
|
||||
|
||||
/**
|
||||
* Regression test for a defect found on a device with no hardware HEVC encoder.
|
||||
*
|
||||
* FFmpeg's *_mediacodec wrappers do not fail on such a device — they quietly bind
|
||||
* to the platform software codec (c2.android.*) and encode far slower than
|
||||
* libx264/libx265 would, while still presenting as the fast path. When no hardware
|
||||
* encoder exists, a real software encoder on a fast preset is both quicker and
|
||||
* honest about what it is doing.
|
||||
*/
|
||||
@Test
|
||||
fun `the encoder choice no longer depends on hardware availability`() {
|
||||
// Once FFmpeg stopped selecting MediaCodec encoders, this flag only affects
|
||||
// whether the router sends the job to Media3 at all -- not what FFmpeg does.
|
||||
listOf(OutputFormat.MP4_H264, OutputFormat.MP4_H265).forEach { format ->
|
||||
assertEquals(
|
||||
cmd(format, QualityTier.FAST, hardwareEncodeAvailable = true),
|
||||
cmd(format, QualityTier.FAST, hardwareEncodeAvailable = false),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fast software preset is faster than the best quality preset`() {
|
||||
// Both use libx264; the distinction between the tiers has to survive the
|
||||
// fallback, otherwise Fast and Best become the same slow thing.
|
||||
val fast = cmd(OutputFormat.MP4_H264, QualityTier.FAST, hardwareEncodeAvailable = false)
|
||||
val best = cmd(OutputFormat.MP4_H264, QualityTier.BEST)
|
||||
assertEquals("veryfast", fast[fast.indexOf("-preset") + 1])
|
||||
assertEquals("medium", best[best.indexOf("-preset") + 1])
|
||||
}
|
||||
|
||||
// --- audio -------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `mp3 uses libmp3lame and drops video`() {
|
||||
val args = cmd(OutputFormat.MP3)
|
||||
assertPair(args, "-c:a", "libmp3lame")
|
||||
assertTrue("audio-only output must drop the video stream", args.contains("-vn"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `flac wav and opus select the right encoders`() {
|
||||
assertPair(cmd(OutputFormat.FLAC), "-c:a", "flac")
|
||||
assertPair(cmd(OutputFormat.WAV), "-c:a", "pcm_s16le")
|
||||
assertPair(cmd(OutputFormat.OPUS), "-c:a", "libopus")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `audio only formats never carry a video encoder`() {
|
||||
listOf(OutputFormat.MP3, OutputFormat.FLAC, OutputFormat.WAV, OutputFormat.OPUS)
|
||||
.forEach { format ->
|
||||
val args = cmd(format)
|
||||
assertFalse("$format should not set -c:v", args.contains("-c:v"))
|
||||
assertTrue("$format should set -vn", args.contains("-vn"))
|
||||
}
|
||||
}
|
||||
|
||||
// --- image outputs -----------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `gif generates a palette to avoid banding and drops audio`() {
|
||||
val args = cmd(OutputFormat.GIF)
|
||||
val filter = args[args.indexOf("-vf") + 1]
|
||||
assertTrue("gif needs palettegen: $filter", filter.contains("palettegen"))
|
||||
assertTrue("gif needs paletteuse: $filter", filter.contains("paletteuse"))
|
||||
assertTrue(args.contains("-an"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `gif loops forever`() {
|
||||
assertPair(cmd(OutputFormat.GIF), "-loop", "0")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `frame export sets an fps filter and numbered output`() {
|
||||
val args = cmd(OutputFormat.FRAMES_PNG)
|
||||
assertPair(args, "-vf", "fps=1")
|
||||
assertEquals(
|
||||
"shot_%04d.png",
|
||||
FFmpegCommandBuilder.outputPattern(OutputFormat.FRAMES_PNG, "shot"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `single file formats keep their plain name`() {
|
||||
assertEquals(
|
||||
"clip.mp4",
|
||||
FFmpegCommandBuilder.outputPattern(OutputFormat.MP4_H264, "clip.mp4"),
|
||||
)
|
||||
}
|
||||
|
||||
// --- containers --------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `mp4 output enables faststart`() {
|
||||
assertPair(cmd(OutputFormat.MP4_H264), "-movflags", "+faststart")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `non mp4 containers do not get faststart`() {
|
||||
assertFalse(cmd(OutputFormat.MKV_H264).contains("-movflags"))
|
||||
assertFalse(cmd(OutputFormat.WAV).contains("-movflags"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mkv accepts h264 and h265 without faststart`() {
|
||||
assertPair(cmd(OutputFormat.MKV_H264, QualityTier.BEST), "-c:v", "libx264")
|
||||
assertPair(cmd(OutputFormat.MKV_H265, QualityTier.BEST), "-c:v", "libx265")
|
||||
}
|
||||
|
||||
// --- exhaustiveness ----------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `every format builds a well formed command`() {
|
||||
OutputFormat.entries.forEach { format ->
|
||||
listOf(QualityTier.FAST, QualityTier.BEST).forEach { quality ->
|
||||
val args = cmd(format, quality)
|
||||
assertTrue("$format/$quality has no input", args.contains("-i"))
|
||||
assertTrue("$format/$quality has too few args", args.size >= 5)
|
||||
// Every flag that takes a value must actually have one.
|
||||
assertFalse("$format/$quality ends on a dangling flag", args.last().startsWith("-"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package org.libremediaconverter.ffmpeg
|
||||
|
||||
import org.libremediaconverter.model.ConcatStrategy
|
||||
import org.libremediaconverter.model.OutputFormat
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.io.File
|
||||
|
||||
class FFmpegConcatCommandTest {
|
||||
|
||||
private val listFile = File("/cache/list.txt")
|
||||
private val output = File("/cache/joined.mp4")
|
||||
private val inputs = listOf("/cache/a.mp4", "/cache/b.mp4")
|
||||
|
||||
@Test
|
||||
fun `list file quotes each entry for the concat demuxer`() {
|
||||
val text = FFmpegConcatCommand.listFileContents(inputs)
|
||||
assertEquals("file '/cache/a.mp4'\nfile '/cache/b.mp4'\n", text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `apostrophes in filenames are escaped`() {
|
||||
// An unescaped apostrophe silently truncates the entry, so the file would be
|
||||
// skipped rather than the command failing.
|
||||
val text = FFmpegConcatCommand.listFileContents(listOf("/cache/jason's clip.mp4"))
|
||||
assertTrue("apostrophe not escaped: $text", text.contains("""jason'\''s clip.mp4"""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stream copy uses the concat demuxer and copies codecs`() {
|
||||
val args = FFmpegConcatCommand.build(
|
||||
ConcatStrategy.STREAM_COPY, inputs, listFile, output, OutputFormat.MP4_H264,
|
||||
)
|
||||
assertTrue(args.contains("concat"))
|
||||
assertEquals("copy", args[args.indexOf("-c") + 1])
|
||||
assertEquals(listFile.absolutePath, args[args.indexOf("-i") + 1])
|
||||
assertFalse("stream copy must not re-encode", args.contains("libx264"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stream copy allows absolute paths in the list file`() {
|
||||
val args = FFmpegConcatCommand.build(
|
||||
ConcatStrategy.STREAM_COPY, inputs, listFile, output, OutputFormat.MP4_H264,
|
||||
)
|
||||
// Without -safe 0 the demuxer rejects the absolute paths we generate.
|
||||
assertEquals("0", args[args.indexOf("-safe") + 1])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `re-encode passes every input separately and builds a filter graph`() {
|
||||
val args = FFmpegConcatCommand.build(
|
||||
ConcatStrategy.REENCODE, inputs, listFile, output, OutputFormat.MP4_H264,
|
||||
)
|
||||
assertEquals(2, args.count { it == "-i" })
|
||||
val filter = args[args.indexOf("-filter_complex") + 1]
|
||||
assertTrue("missing concat filter: $filter", filter.contains("concat=n=2:v=1:a=1"))
|
||||
assertTrue("inputs must be normalised before joining", filter.contains("scale="))
|
||||
assertTrue("frame rates must be normalised", filter.contains("fps=30"))
|
||||
assertTrue(args.contains("libx264"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `re-encode maps the filter outputs rather than raw streams`() {
|
||||
val args = FFmpegConcatCommand.build(
|
||||
ConcatStrategy.REENCODE, inputs, listFile, output, OutputFormat.MP4_H264,
|
||||
)
|
||||
assertTrue(args.contains("[v]"))
|
||||
assertTrue(args.contains("[a]"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mp4 output gets faststart on both strategies`() {
|
||||
listOf(ConcatStrategy.STREAM_COPY, ConcatStrategy.REENCODE).forEach { strategy ->
|
||||
val args = FFmpegConcatCommand.build(strategy, inputs, listFile, output, OutputFormat.MP4_H264)
|
||||
assertTrue("$strategy missing faststart", args.contains("+faststart"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mkv output does not get faststart`() {
|
||||
val args = FFmpegConcatCommand.build(
|
||||
ConcatStrategy.STREAM_COPY, inputs, listFile, File("/cache/j.mkv"), OutputFormat.MKV_H264,
|
||||
)
|
||||
assertFalse(args.contains("+faststart"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `output path is always last`() {
|
||||
listOf(ConcatStrategy.STREAM_COPY, ConcatStrategy.REENCODE).forEach { strategy ->
|
||||
val args = FFmpegConcatCommand.build(strategy, inputs, listFile, output, OutputFormat.MP4_H264)
|
||||
assertEquals(output.absolutePath, args.last())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package org.libremediaconverter.model
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The stream-copy-vs-re-encode decision.
|
||||
*
|
||||
* This matters more than it looks: the `concat` demuxer does not reject mismatched
|
||||
* inputs loudly, it can emit a file whose later segments are garbled. So the default
|
||||
* on any doubt has to be re-encoding.
|
||||
*/
|
||||
class ConcatPlannerTest {
|
||||
|
||||
private fun clip(
|
||||
video: String? = "h264",
|
||||
audio: String? = "aac",
|
||||
width: Int = 1920,
|
||||
height: Int = 1080,
|
||||
fps: Int = 30,
|
||||
) = ConcatInput(video, audio, width, height, fps)
|
||||
|
||||
@Test
|
||||
fun `identical clips can be stream copied`() {
|
||||
assertEquals(
|
||||
ConcatStrategy.STREAM_COPY,
|
||||
ConcatPlanner.plan(listOf(clip(), clip())),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a single input needs no re-encode`() {
|
||||
assertEquals(ConcatStrategy.STREAM_COPY, ConcatPlanner.plan(listOf(clip())))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `different video codecs force a re-encode`() {
|
||||
assertEquals(
|
||||
ConcatStrategy.REENCODE,
|
||||
ConcatPlanner.plan(listOf(clip(video = "h264"), clip(video = "hevc"))),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `different audio codecs force a re-encode`() {
|
||||
assertEquals(
|
||||
ConcatStrategy.REENCODE,
|
||||
ConcatPlanner.plan(listOf(clip(audio = "aac"), clip(audio = "opus"))),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `different resolutions force a re-encode`() {
|
||||
assertEquals(
|
||||
ConcatStrategy.REENCODE,
|
||||
ConcatPlanner.plan(listOf(clip(width = 1920, height = 1080), clip(width = 1280, height = 720))),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `different frame rates force a re-encode`() {
|
||||
assertEquals(
|
||||
ConcatStrategy.REENCODE,
|
||||
ConcatPlanner.plan(listOf(clip(fps = 30), clip(fps = 60))),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unknown codec is not treated as a match`() {
|
||||
// Two nulls are not evidence of agreement. Assuming they match is exactly how
|
||||
// a silent corrupt concat happens.
|
||||
assertEquals(
|
||||
ConcatStrategy.REENCODE,
|
||||
ConcatPlanner.plan(listOf(clip(video = null), clip(video = null))),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a mismatch anywhere in a longer list is caught`() {
|
||||
val clips = listOf(clip(), clip(), clip(), clip(width = 640, height = 480), clip())
|
||||
assertEquals(ConcatStrategy.REENCODE, ConcatPlanner.plan(clips))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package org.libremediaconverter.model
|
||||
|
||||
import org.libremediaconverter.model.ConversionRouter.Reason
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* One test per routing predicate.
|
||||
*
|
||||
* These run on the JVM against fabricated device profiles rather than on hardware, so
|
||||
* every branch is reachable — including "this device cannot encode AV1", which would
|
||||
* otherwise depend on which phone the suite happened to run on.
|
||||
*/
|
||||
class ConversionRouterTest {
|
||||
|
||||
private fun route(
|
||||
format: OutputFormat,
|
||||
quality: QualityTier = QualityTier.FAST,
|
||||
preference: EnginePreference = EnginePreference.AUTO,
|
||||
probe: InputProbe = InputProbe(videoCodec = "h264"),
|
||||
device: DeviceCodecs = DeviceCodecs.PERMISSIVE,
|
||||
) = ConversionRouter.route(
|
||||
ConversionRequest(format, quality, preference, probe),
|
||||
device,
|
||||
)
|
||||
|
||||
// --- the happy path -----------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `mp4 h264 on a capable device uses hardware`() {
|
||||
val d = route(OutputFormat.MP4_H264)
|
||||
assertEquals(Engine.MEDIA3, d.engine)
|
||||
assertEquals(Reason.HARDWARE_CAPABLE, d.reason)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mp4 h265 uses hardware`() {
|
||||
assertEquals(Engine.MEDIA3, route(OutputFormat.MP4_H265).engine)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `webm vp9 routes to ffmpeg because media3 cannot encode vp9`() {
|
||||
// Transformer.setVideoMimeType accepts only H.263/H.264/H.265/MP4V. The WebM
|
||||
// muxer exists, but there is no VP9 *encoder* behind it, so producing WebM
|
||||
// video is FFmpeg's job even though the container is nominally supported.
|
||||
val d = route(OutputFormat.WEBM_VP9)
|
||||
assertEquals(Engine.FFMPEG, d.engine)
|
||||
assertEquals(Reason.NO_PLATFORM_ENCODER, d.reason)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `aac audio extraction stays on hardware`() {
|
||||
assertEquals(Engine.MEDIA3, route(OutputFormat.M4A_AAC).engine)
|
||||
}
|
||||
|
||||
// --- 1. container ------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `mkv routes to ffmpeg because media3 has no matroska muxer`() {
|
||||
val d = route(OutputFormat.MKV_H264)
|
||||
assertEquals(Engine.FFMPEG, d.engine)
|
||||
assertEquals(Reason.CONTAINER_UNSUPPORTED, d.reason)
|
||||
}
|
||||
|
||||
// --- 3. no platform encoder -------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `mp3 routes to ffmpeg because android has no mp3 encoder at any api level`() {
|
||||
val d = route(OutputFormat.MP3)
|
||||
assertEquals(Engine.FFMPEG, d.engine)
|
||||
assertEquals(Reason.NO_PLATFORM_ENCODER, d.reason)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `flac routes to ffmpeg`() {
|
||||
assertEquals(Engine.FFMPEG, route(OutputFormat.FLAC).engine)
|
||||
}
|
||||
|
||||
// --- 4. image output ---------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `gif routes to ffmpeg as an image output`() {
|
||||
val d = route(OutputFormat.GIF)
|
||||
assertEquals(Engine.FFMPEG, d.engine)
|
||||
assertEquals(Reason.IMAGE_OUTPUT, d.reason)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `png frame export routes to ffmpeg`() {
|
||||
assertEquals(Engine.FFMPEG, route(OutputFormat.FRAMES_PNG).engine)
|
||||
}
|
||||
|
||||
// --- 5. no platform decoder -------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `av1 input on a device without av1 decode routes to ffmpeg`() {
|
||||
val noAv1 = object : DeviceCodecs {
|
||||
override fun canEncode(codec: VideoCodec) = true
|
||||
override fun canDecode(codecName: String) = codecName != "av1"
|
||||
}
|
||||
val d = route(OutputFormat.MP4_H264, probe = InputProbe(videoCodec = "av1"), device = noAv1)
|
||||
assertEquals(Engine.FFMPEG, d.engine)
|
||||
assertEquals(Reason.NO_PLATFORM_DECODER, d.reason)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `av1 input on a device with av1 decode stays on hardware`() {
|
||||
val d = route(OutputFormat.MP4_H264, probe = InputProbe(videoCodec = "av1"))
|
||||
assertEquals(Engine.MEDIA3, d.engine)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an input the platform cannot parse routes to ffmpeg`() {
|
||||
// MediaProbe reports this when the platform extractor fails to open the file.
|
||||
// Media3 cannot convert what the platform cannot read, so the job must not be
|
||||
// sent down the hardware path only to fail there.
|
||||
val d = route(
|
||||
OutputFormat.MP4_H264,
|
||||
probe = InputProbe(videoCodec = InputProbe.UNPARSEABLE),
|
||||
)
|
||||
assertEquals(Engine.FFMPEG, d.engine)
|
||||
assertEquals(Reason.NO_PLATFORM_DECODER, d.reason)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a null input codec is treated as no information, not as a failure`() {
|
||||
// Nothing known about the input is different from "known to be unreadable":
|
||||
// the former should still take the fast path.
|
||||
val d = route(OutputFormat.MP4_H264, probe = InputProbe(videoCodec = null))
|
||||
assertEquals(Engine.MEDIA3, d.engine)
|
||||
}
|
||||
|
||||
// --- 6. quality tier ---------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `best quality routes to ffmpeg because mediacodec exposes no crf`() {
|
||||
val d = route(OutputFormat.MP4_H264, quality = QualityTier.BEST)
|
||||
assertEquals(Engine.FFMPEG, d.engine)
|
||||
assertEquals(Reason.QUALITY_TIER_REQUIRES_CRF, d.reason)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fast quality is the hardware path`() {
|
||||
assertEquals(Engine.MEDIA3, route(OutputFormat.MP4_H264, quality = QualityTier.FAST).engine)
|
||||
}
|
||||
|
||||
// --- 7. hardware encoder availability ---------------------------------
|
||||
|
||||
@Test
|
||||
fun `h265 target on a device without hevc encode falls back to ffmpeg`() {
|
||||
val noHevc = object : DeviceCodecs {
|
||||
override fun canEncode(codec: VideoCodec) = codec != VideoCodec.H265
|
||||
override fun canDecode(codecName: String) = true
|
||||
}
|
||||
val d = route(OutputFormat.MP4_H265, device = noHevc)
|
||||
assertEquals(Engine.FFMPEG, d.engine)
|
||||
assertEquals(Reason.NO_HARDWARE_ENCODER, d.reason)
|
||||
}
|
||||
|
||||
// --- user override -----------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `forcing software overrides an otherwise hardware-capable job`() {
|
||||
val d = route(OutputFormat.MP4_H264, preference = EnginePreference.FORCE_SOFTWARE)
|
||||
assertEquals(Engine.FFMPEG, d.engine)
|
||||
assertEquals(Reason.USER_FORCED_SOFTWARE, d.reason)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `forcing software wins even for formats ffmpeg would take anyway`() {
|
||||
val d = route(OutputFormat.MP3, preference = EnginePreference.FORCE_SOFTWARE)
|
||||
assertEquals(Engine.FFMPEG, d.engine)
|
||||
assertEquals(Reason.USER_FORCED_SOFTWARE, d.reason)
|
||||
}
|
||||
|
||||
// --- exhaustiveness ----------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `every output format routes somewhere without throwing`() {
|
||||
OutputFormat.entries.forEach { format ->
|
||||
val decision = route(format)
|
||||
assertEquals(
|
||||
"format $format produced no engine",
|
||||
true,
|
||||
decision.engine == Engine.MEDIA3 || decision.engine == Engine.FFMPEG,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `audio-only formats are flagged as such`() {
|
||||
assertEquals(true, OutputFormat.MP3.isAudioOnly)
|
||||
assertEquals(true, OutputFormat.FLAC.isAudioOnly)
|
||||
assertEquals(false, OutputFormat.MP4_H264.isAudioOnly)
|
||||
assertEquals(false, OutputFormat.GIF.isAudioOnly)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package org.libremediaconverter.model
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Guards the format table itself.
|
||||
*
|
||||
* The router, the FFmpeg command builder and the output filename all read from these
|
||||
* entries, so a wrong extension or a mislabelled codec propagates everywhere.
|
||||
*/
|
||||
class OutputFormatTest {
|
||||
|
||||
@Test
|
||||
fun `every format has a non-empty label and extension`() {
|
||||
OutputFormat.entries.forEach {
|
||||
assertTrue("${it.name} has no label", it.label.isNotBlank())
|
||||
assertTrue("${it.name} has no extension", it.extension.isNotBlank())
|
||||
assertFalse("${it.name} extension should not include a dot", it.extension.startsWith("."))
|
||||
assertTrue("${it.name} has no mime type", it.mimeType.contains('/'))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `audio only formats carry no video codec`() {
|
||||
OutputFormat.entries.filter { it.isAudioOnly }.forEach {
|
||||
assertEquals("${it.name} should have no video codec", VideoCodec.NONE, it.videoCodec)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `image outputs carry neither video nor audio codecs`() {
|
||||
OutputFormat.entries.filter { it.isImageOutput }.forEach {
|
||||
assertEquals(VideoCodec.NONE, it.videoCodec)
|
||||
assertEquals(AudioCodec.NONE, it.audioCodec)
|
||||
assertFalse("${it.name} is not audio-only", it.isAudioOnly)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `extensions match their containers`() {
|
||||
assertEquals("mp4", OutputFormat.MP4_H264.extension)
|
||||
assertEquals("mkv", OutputFormat.MKV_H264.extension)
|
||||
assertEquals("mp3", OutputFormat.MP3.extension)
|
||||
assertEquals("gif", OutputFormat.GIF.extension)
|
||||
assertEquals("wav", OutputFormat.WAV.extension)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `video formats are not misreported as audio only`() {
|
||||
listOf(OutputFormat.MP4_H264, OutputFormat.MP4_H265, OutputFormat.MKV_H265, OutputFormat.WEBM_VP9)
|
||||
.forEach { assertFalse("${it.name} should not be audio-only", it.isAudioOnly) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `quality tiers describe themselves for the UI`() {
|
||||
QualityTier.entries.forEach {
|
||||
assertTrue(it.label.isNotBlank())
|
||||
assertTrue(it.description.isNotBlank())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package org.libremediaconverter.work
|
||||
|
||||
import androidx.work.WorkInfo
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The retry-versus-fail rule.
|
||||
*
|
||||
* Isolated from the worker precisely so it can be tested: the condition that triggers
|
||||
* a retry in production is the foreground-service budget running out, six hours per
|
||||
* twenty-four, which no test can reach. Extracting the decision means the rule is still
|
||||
* verified even though its trigger cannot be reproduced.
|
||||
*/
|
||||
class FailureOutcomeTest {
|
||||
|
||||
@Test
|
||||
fun `a foreground service timeout is a retry, not a failure`() {
|
||||
// The work is still valid; there is simply no budget right now. Telling the
|
||||
// user their conversion failed would be wrong.
|
||||
assertEquals(
|
||||
FailureOutcome.RETRY,
|
||||
FailureOutcome.forStopReason(WorkInfo.STOP_REASON_FOREGROUND_SERVICE_TIMEOUT),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an ordinary failure is reported as a failure`() {
|
||||
assertEquals(
|
||||
FailureOutcome.FAIL,
|
||||
FailureOutcome.forStopReason(WorkInfo.STOP_REASON_NOT_STOPPED),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every other stop reason fails rather than retrying forever`() {
|
||||
// Retrying on, say, a user cancellation or a battery constraint would either
|
||||
// ignore the user or spin. Only the timeout earns a retry.
|
||||
val others = listOf(
|
||||
WorkInfo.STOP_REASON_CANCELLED_BY_APP,
|
||||
WorkInfo.STOP_REASON_USER,
|
||||
WorkInfo.STOP_REASON_CONSTRAINT_BATTERY_NOT_LOW,
|
||||
WorkInfo.STOP_REASON_CONSTRAINT_CHARGING,
|
||||
WorkInfo.STOP_REASON_CONSTRAINT_CONNECTIVITY,
|
||||
WorkInfo.STOP_REASON_CONSTRAINT_DEVICE_IDLE,
|
||||
WorkInfo.STOP_REASON_CONSTRAINT_STORAGE_NOT_LOW,
|
||||
WorkInfo.STOP_REASON_DEVICE_STATE,
|
||||
WorkInfo.STOP_REASON_QUOTA,
|
||||
WorkInfo.STOP_REASON_BACKGROUND_RESTRICTION,
|
||||
WorkInfo.STOP_REASON_APP_STANDBY,
|
||||
WorkInfo.STOP_REASON_TIMEOUT,
|
||||
WorkInfo.STOP_REASON_UNKNOWN,
|
||||
)
|
||||
others.forEach {
|
||||
assertEquals("stop reason $it should fail", FailureOutcome.FAIL, FailureOutcome.forStopReason(it))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
# Prebuilt FFmpeg
|
||||
|
||||
`ffmpeg-kit-next-8.1.1.aar` is committed here deliberately, and this file records what
|
||||
it is so the binary is auditable rather than opaque.
|
||||
|
||||
## Why it is committed
|
||||
|
||||
Tests must be deterministic. When CI rebuilt FFmpeg on every run, a red build could mean
|
||||
"the code is broken" or "a 40-minute cross-compile hiccuped", and those are not the same
|
||||
signal. Committing the artifact removes the second possibility entirely: a test run
|
||||
either passes or points at real code.
|
||||
|
||||
It also removes roughly forty minutes from every cold CI run.
|
||||
|
||||
## Provenance
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Upstream | [arthenica/ffmpeg-kit-next](https://github.com/arthenica/ffmpeg-kit-next) v8.1.1 |
|
||||
| FFmpeg | 8.1.2 |
|
||||
| NDK | r27d (27.3.13750724), pinned by the upstream flake |
|
||||
| API level | 33, matching the app's minSdk |
|
||||
| ABIs | arm64-v8a, x86_64 |
|
||||
| Shared libraries | 20 (10 per ABI) |
|
||||
| SHA-256 | `ae188c9aec3c89a1c87a169589253c85438d57cfdcc3ce8b40fb3e87de368ff2` |
|
||||
|
||||
Configure line, read back out of the shipped `libavutil.so`:
|
||||
|
||||
```
|
||||
--enable-asm --enable-cross-compile --enable-gpl --enable-iconv
|
||||
--enable-inline-asm --enable-jni --enable-libass --enable-libdav1d
|
||||
--enable-libfontconfig --enable-libfreetype --enable-libfribidi
|
||||
--enable-libharfbuzz --enable-libjxl --enable-libmp3lame --enable-libopus
|
||||
--enable-libsvtav1 --enable-libvpx --enable-libx264 --enable-libx265
|
||||
--enable-lto --enable-mediacodec --enable-neon --enable-optimizations
|
||||
--enable-pic --enable-pthreads --enable-shared --enable-small
|
||||
--enable-swscale --enable-v4l2-m2m --enable-version3 --enable-zlib
|
||||
```
|
||||
|
||||
Every `.so` reports `LOAD align 0x4000`, so the archive satisfies the 16 KB page-size
|
||||
requirement. Verify with:
|
||||
|
||||
```sh
|
||||
unzip -o bin/ffmpeg-kit-next-8.1.1.aar 'jni/*' -d /tmp/aarcheck
|
||||
for f in /tmp/aarcheck/jni/*/*.so; do
|
||||
readelf -lW "$f" | awk -v f="$f" '$1=="LOAD"{print f, $NF}'
|
||||
done | sort -u -k2
|
||||
```
|
||||
|
||||
## Licensing
|
||||
|
||||
Built with `--enable-gpl` and `--enable-version3`, so this binary is **GPL-3.0** and the
|
||||
distributed APK is GPL-3.0 with it. That is deliberate: x264 and x265 are the only route
|
||||
to CRF and two-pass rate control, which no Android hardware encoder exposes. See
|
||||
[`../LICENSES/README.md`](../LICENSES/README.md).
|
||||
|
||||
GPL-3.0 obliges us to ship corresponding source with the binary. The recipe in
|
||||
[`../tools/ffmpeg`](../tools/ffmpeg) is that source, and it remains the authority: this
|
||||
archive is its output, not a substitute for it.
|
||||
|
||||
## Rebuilding
|
||||
|
||||
```sh
|
||||
cd tools/ffmpeg
|
||||
podman build -t ffmpeg-kit-builder:local -f Containerfile .
|
||||
mkdir -p out
|
||||
podman run --name ffmpeg-build -v "$PWD/out":/work/out:Z localhost/ffmpeg-kit-builder:local full
|
||||
cp out/ffmpeg-kit-next-*.aar ../../bin/
|
||||
```
|
||||
|
||||
Update the SHA-256 above when you do. Note that replacing this file adds another ~34 MB
|
||||
blob to git history permanently, so rebuild only when the FFmpeg version or the configure
|
||||
flags actually change.
|
||||
|
||||
## F-Droid
|
||||
|
||||
F-Droid's scanner flags checked-in prebuilt native libraries. If the app is submitted
|
||||
there, the metadata needs a `scandelete` entry for `bin/` so their build uses the recipe
|
||||
in `tools/ffmpeg` rather than this archive. Nothing here prevents a from-source build;
|
||||
the recipe is complete on its own.
|
||||
Binary file not shown.
@@ -0,0 +1,214 @@
|
||||
# API 37 is not tested in CI: a crash in Google's `android-37.0` emulator image
|
||||
|
||||
**Status:** open upstream, worked around by removing API 37 from the E2E matrix.
|
||||
The app itself is verified good on real API 37 hardware — this is an emulator bug only.
|
||||
**Last verified:** 2026-08-21, against emulator `37.1.11.0` and system image revision 6
|
||||
|
||||
`minSdk` is 33 and `targetSdk` is 37, and the E2E matrix in
|
||||
[`status_check.yml`](../.github/workflows/status_check.yml) runs API 33 through 36.
|
||||
API 37 is deliberately absent. This is why.
|
||||
|
||||
## Summary
|
||||
|
||||
The `android-37.0` emulator system image crashes `surfaceflinger` in a loop. The app
|
||||
under test never gets a working framework, so every instrumented test fails regardless
|
||||
of what the app does. The bug is in the emulator image, not in this project.
|
||||
|
||||
The crash is an assertion inside the emulator's own gralloc implementation:
|
||||
|
||||
```
|
||||
Executable: /system/bin/surfaceflinger
|
||||
signal 6 (SIGABRT), code -1 (SI_QUEUE), tid: RegionSampling
|
||||
Abort message: 'Assertion failed: !rcEnc->featureInfo()->hasReadColorBufferDma'
|
||||
|
||||
#03 mapper.ranchu.so GoldfishMapper::readFromHost(cb_handle_t const&) const
|
||||
#04 mapper.ranchu.so GoldfishMapper::GoldfishMapper()::'lambda'(...)::__invoke
|
||||
#05 libui.so android::Gralloc5Mapper::lock(...)
|
||||
#06 libui.so android::GraphicBufferMapper::lock(...)
|
||||
#07 libui.so android::GraphicBuffer::lockAsync(...)
|
||||
#08 libui.so android::GraphicBuffer::lock(...)
|
||||
#09 surfaceflinger android::RegionSamplingThread::threadMain()
|
||||
```
|
||||
|
||||
`RegionSamplingThread` is SystemUI's navigation-bar luma sampling. It calls
|
||||
`GraphicBuffer::lock`, which routes into `GoldfishMapper::readFromHost`, which asserts
|
||||
that the host has *not* negotiated the `ReadColorBufferDma` capability. On this image
|
||||
the host has, so the assertion fails and `surfaceflinger` aborts. It restarts and
|
||||
aborts again.
|
||||
|
||||
## Impact
|
||||
|
||||
The failure surfaces in two different ways depending on how far the job gets, which is
|
||||
why it took several rounds to identify:
|
||||
|
||||
| Guest RAM | Where it dies | What CI reports |
|
||||
|---|---|---|
|
||||
| 1536 MB | during APK install | `Unknown failure: cmd: Can't find service: package` |
|
||||
| 2560 MB | during the test run | `There were failing tests` — all of them |
|
||||
|
||||
At 2560 MB the install succeeds and the tests actually execute, then fail wholesale.
|
||||
The first failure in the report is misleading:
|
||||
|
||||
```
|
||||
kotlin.UninitializedPropertyAccessException: lateinit property output has not
|
||||
been initialized
|
||||
at Media3EngineTest.tearDown(Media3EngineTest.kt:53)
|
||||
|
||||
java.lang.IllegalStateException: WorkManager is not initialized properly.
|
||||
You have explicitly disabled WorkManagerInitializer in your manifest, ...
|
||||
```
|
||||
|
||||
Neither is a real defect in this project. `tearDown` throws because `setUp` never got
|
||||
far enough to assign `output`, and WorkManager's `InitializationProvider` never runs
|
||||
because content-provider installation fails on a framework whose `surfaceflinger` is
|
||||
crash-looping. The same tests pass at API 33, 34, 35, and 36 in the same CI run, and
|
||||
the first `surfaceflinger` abort is timestamped *before* the test results are reported.
|
||||
|
||||
This is not inference. The full suite was run against a physical API 37 device and
|
||||
passed — see [Verified on real API 37 hardware](#verified-on-real-api-37-hardware)
|
||||
below. `ConversionWorkerTest` and `ConcatWorkerTest`, which drive a real WorkManager
|
||||
round trip and are among the tests that failed this way in CI, both pass there.
|
||||
|
||||
## Environment
|
||||
|
||||
Reproduced identically in two unrelated environments, so it is not specific to a host
|
||||
GPU, driver, or CI runner.
|
||||
|
||||
| | GitHub Actions | Local workstation |
|
||||
|---|---|---|
|
||||
| Host | `ubuntu-latest`, no GPU | Fedora, Intel Iris Xe (TGL GT2) |
|
||||
| Emulator | `37.1.11.0` (build 15917651) | `37.1.11.0` (build 15917651) |
|
||||
| GPU mode | `swiftshader_indirect` | `host` |
|
||||
| Result | boots, aborts during tests | aborts before boot completes |
|
||||
|
||||
System image: `system-images;android-37.0;google_apis;x86_64`, `Pkg.Revision=6`,
|
||||
`AndroidVersion.ApiLevel=37.0`, `AndroidVersion.ExtensionLevel=22`
|
||||
|
||||
```
|
||||
Build fingerprint: google/sdk_gphone64_x86_64/emu64xa:17/CE2A.260420.019/15611780:userdebug/dev-keys
|
||||
Kernel Release: 6.12.58-android16-6-gccafb60de224-ab14828483
|
||||
```
|
||||
|
||||
## What was ruled out, and how
|
||||
|
||||
Each of these was tested rather than reasoned about, because the first three attempts
|
||||
at this bug were plausible fixes that turned out to address earlier, unrelated failures.
|
||||
|
||||
**Guest memory.** The emulator raises an undersized guest to a minimum on its own, but
|
||||
only for API levels it recognises, and it does not recognise `"37.0"`. API 33 bumps to
|
||||
2048 MB and 34/35/36 to 2560 MB, while API 37 logged no bump at all and ran at the
|
||||
`pixel_6` default of 1536 MB. Setting `ram-size: 2560M` explicitly fixed that asymmetry
|
||||
and did change the outcome — the job got past install and into the test run — but it is
|
||||
not the underlying bug. At the moment of failure the guest reported `MemTotal 2527392
|
||||
kB` with `MemAvailable 1507104 kB`: 1.5 GB free, and no OOM kills.
|
||||
|
||||
**GPU mode.** Both `swiftshader_indirect` and `host` crash, with the same assertion and
|
||||
the same frames. The crash is in the gralloc mapper, below the renderer.
|
||||
|
||||
**Disabling the DMA feature.** `GLDMA` is the host feature that most plausibly backs the
|
||||
guest's `hasReadColorBufferDma`. Launching with `-feature -GLDMA` was accepted by the
|
||||
emulator — the log confirms `Feature 'GLDMA' (51) is overridden to 'disabled'` — and
|
||||
`surfaceflinger` still aborted 13 times and the device never finished booting. Whatever
|
||||
sets that guest capability, it is not this flag.
|
||||
|
||||
**An ATD image.** `google_atd` / `aosp_atd` images are built for automated testing and
|
||||
ship without the SystemUI package set, which is what drives `RegionSamplingThread` in
|
||||
the first place. That would likely sidestep the bug class entirely, but **no ATD image
|
||||
exists for `android-37.0`** — only `google_apis`, `google_apis_playstore`, the `ps16k`
|
||||
16 KB-page variants, and Wear OS. Check again when revisiting; if an ATD image appears,
|
||||
try it before anything else here.
|
||||
|
||||
## Verified on real API 37 hardware
|
||||
|
||||
The bug is confined to the emulator image. On 2026-08-21 the whole instrumented suite
|
||||
was run against a physical device and passed:
|
||||
|
||||
```
|
||||
Device: Pixel 10 Pro XL (mustang), arm64-v8a
|
||||
Build: google/mustang/mustang:17/CP2A.260805.005/15828068:user/release-keys
|
||||
API: 37 (Android 17, codename REL -- a release build, not a preview)
|
||||
|
||||
./gradlew :app:connectedDebugAndroidTest -PabiFilters=arm64-v8a
|
||||
40 tests, 0 failures, 0 errors, 2 skipped BUILD SUCCESSFUL
|
||||
```
|
||||
|
||||
The two skips are `RealMediaBenchmark.hardwareVersusSoftwareOnRealVideo` and
|
||||
`av1InputRoutesAccordingToDeviceDecodeSupport`, which `assumeTrue` their sample files
|
||||
are present and skip when they are not. That is by design and unrelated to API level.
|
||||
|
||||
One harmless warning appears during the run and can be ignored:
|
||||
`No UID for androidx.test.services in user 0`, from an `appops` call the test services
|
||||
package makes before it is fully registered.
|
||||
|
||||
So the app is correct on Android 17. What is missing is only *automated* coverage in
|
||||
CI. Until the image is fixed, run the suite on a physical API 37 device before release;
|
||||
that is the substitute for the missing matrix row.
|
||||
|
||||
## Reproducing it
|
||||
|
||||
Locally, with `-gpu host` so the emulator itself does not segfault on Intel graphics:
|
||||
|
||||
```bash
|
||||
sdkmanager --install "system-images;android-37.0;google_apis;x86_64"
|
||||
echo no | avdmanager create avd -n api37_repro \
|
||||
-k "system-images;android-37.0;google_apis;x86_64" -d pixel_6 --force
|
||||
|
||||
$ANDROID_HOME/emulator/emulator -avd api37_repro \
|
||||
-no-window -gpu host -noaudio -no-boot-anim -camera-back none -no-snapshot &
|
||||
|
||||
# Boot never completes. Count the aborts:
|
||||
adb logcat -d -b crash | grep -c hasReadColorBufferDma
|
||||
```
|
||||
|
||||
`sys.boot_completed` never reaches `1`, `pgrep -f system_server` stays empty, and
|
||||
`keystore2`'s watchdog logs `await_boot_completed ... Overdue` indefinitely.
|
||||
|
||||
To see the CI-side form instead, restore the API 37 row in the E2E matrix of
|
||||
`status_check.yml` (`api-level: "37.0"` — a bare `37` fails earlier still, during SDK
|
||||
setup, because there is no `platforms;android-37`).
|
||||
|
||||
## Filing this upstream
|
||||
|
||||
Not yet filed. To file it:
|
||||
|
||||
1. Go to <https://issuetracker.google.com/> and sign in with a Google account.
|
||||
2. Choose **Report an issue**, then pick the component for the Android emulator — search
|
||||
the component picker for "Emulator"; it sits under the Android Studio component tree.
|
||||
If the picker is unclear, Android Studio's **Help → Submit Feedback** opens the same
|
||||
tracker with the component preselected, and the emulator's own **Extended controls →
|
||||
Help → File a bug** does likewise.
|
||||
3. Title it for the mechanism, not the symptom, so it is searchable — for example:
|
||||
`surfaceflinger aborts in GoldfishMapper::readFromHost (hasReadColorBufferDma) on
|
||||
android-37.0 google_apis x86_64`.
|
||||
4. Paste the assertion and backtrace from the top of this document, the environment
|
||||
table, and the reproduction steps above. State explicitly that it reproduces on two
|
||||
unrelated hosts under both GPU modes — that is the detail that stops it being closed
|
||||
as a local graphics problem.
|
||||
5. List what was ruled out. Bugs that arrive with `-feature -GLDMA` already eliminated
|
||||
tend not to bounce back asking for it.
|
||||
6. Attach:
|
||||
- the guest tombstone, via `adb pull /data/tombstones` (or the `pbtombstone` output
|
||||
the crash log names)
|
||||
- `adb logcat -d -b crash > crash.txt`
|
||||
- the emulator's own stdout log, captured by redirecting the launch command
|
||||
- the AVD's `config.ini`
|
||||
- a link to a failing CI job, which shows it on hardware you do not control:
|
||||
<https://github.com/JMR-dev/LibreMediaConverter/actions/runs/32545625459/job/96963461184>
|
||||
|
||||
Record the issue number here once filed.
|
||||
|
||||
## When to revisit
|
||||
|
||||
Re-add the API 37 row when any of these happens:
|
||||
|
||||
- a new `android-37.0` system image revision ships (this was revision 6)
|
||||
- an ATD image appears for API 37
|
||||
- the upstream issue is marked fixed
|
||||
|
||||
Until then the gap is narrower than the missing row suggests. `targetSdk` is 37, so the
|
||||
app is compiled and unit-tested against it; the API-dependent behaviour this matrix
|
||||
exists to exercise — the foreground service type, absent below 34, `dataSync` at 34,
|
||||
`mediaProcessing` from 35 — is covered at 35 and 36; and the full instrumented suite has
|
||||
been run green on real API 37 hardware. What is missing is *automated* API 37 coverage,
|
||||
so a regression there would not be caught by a pull request. Run the suite on a physical
|
||||
API 37 device before each release for as long as this row is absent.
|
||||
@@ -0,0 +1,7 @@
|
||||
First release.
|
||||
|
||||
* Video conversion between MP4, MKV and WebM
|
||||
* Audio extraction to MP3, AAC, FLAC, Opus and WAV
|
||||
* GIF and PNG frame export
|
||||
* Joining several files into one
|
||||
* Hardware acceleration where the device supports it, with a visible fallback
|
||||
@@ -0,0 +1,18 @@
|
||||
A media converter that runs entirely on your device. Nothing is uploaded anywhere.
|
||||
|
||||
Convert video between MP4, MKV and WebM. Extract and convert audio to MP3, AAC,
|
||||
FLAC, Opus or WAV. Export GIFs and PNG frame sequences. Join several clips into one
|
||||
file.
|
||||
|
||||
Two conversion engines work together. Common video jobs run on your device's video
|
||||
hardware, which is several times faster than software encoding and much easier on the
|
||||
battery. Everything the hardware cannot do — Matroska, MP3, GIF, frame export, and the
|
||||
highest quality settings — runs through a bundled FFmpeg build. The app tells you which
|
||||
one it used and why, and lets you force software encoding if you prefer.
|
||||
|
||||
Quality settings:
|
||||
|
||||
* Fast — hardware accelerated, ideal for sharing and batches
|
||||
* Best quality — software encoding with CRF rate control, for archiving
|
||||
|
||||
Free and open source. No advertising, no analytics, no network access.
|
||||
@@ -0,0 +1 @@
|
||||
Convert video and audio on your device. No ads, no tracking, no uploads.
|
||||
@@ -0,0 +1 @@
|
||||
LibreMediaConverter
|
||||
@@ -1,20 +1,81 @@
|
||||
[versions]
|
||||
# Build tooling.
|
||||
# NOTE: AGP 9 has BUILT-IN Kotlin support. Applying org.jetbrains.kotlin.android
|
||||
# FAILS the build. AGP 9.3.1 brings kotlin-gradle-plugin 2.2.10 transitively, so the
|
||||
# Compose compiler plugin below must match that version, not the newest Kotlin release.
|
||||
agp = "9.3.1"
|
||||
coreKtx = "1.10.1"
|
||||
kotlin = "2.2.10"
|
||||
ksp = "2.3.11"
|
||||
|
||||
# AndroidX / Compose
|
||||
composeBom = "2026.08.00"
|
||||
coreKtx = "1.19.0"
|
||||
activityCompose = "1.13.0"
|
||||
lifecycle = "2.11.0"
|
||||
navigation = "2.9.8"
|
||||
work = "2.11.2"
|
||||
datastore = "1.2.1"
|
||||
media3 = "1.11.0"
|
||||
room = "2.8.4"
|
||||
documentfile = "1.1.0"
|
||||
annotation = "1.10.0"
|
||||
|
||||
# Test
|
||||
junit = "4.13.2"
|
||||
junitVersion = "1.1.5"
|
||||
espressoCore = "3.5.1"
|
||||
appcompat = "1.6.1"
|
||||
material = "1.10.0"
|
||||
androidxJunit = "1.3.0"
|
||||
espressoCore = "3.7.0"
|
||||
smartException = "0.2.1"
|
||||
|
||||
[libraries]
|
||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
||||
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" }
|
||||
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" }
|
||||
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
|
||||
|
||||
# Compose — versions come from the BOM, so no version.ref here.
|
||||
compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
|
||||
compose-material3 = { group = "androidx.compose.material3", name = "material3" }
|
||||
compose-material3-windowsize = { group = "androidx.compose.material3", name = "material3-window-size-class" }
|
||||
compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
|
||||
compose-ui = { group = "androidx.compose.ui", name = "ui" }
|
||||
compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
|
||||
compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
|
||||
compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
|
||||
compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
|
||||
compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
|
||||
|
||||
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation" }
|
||||
androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "work" }
|
||||
androidx-work-testing = { group = "androidx.work", name = "work-testing", version.ref = "work" }
|
||||
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
|
||||
androidx-documentfile = { group = "androidx.documentfile", name = "documentfile", version.ref = "documentfile" }
|
||||
androidx-annotation = { group = "androidx.annotation", name = "annotation", version.ref = "annotation" }
|
||||
|
||||
# Media3 — the hardware fast path (Apache-2.0).
|
||||
media3-transformer = { group = "androidx.media3", name = "media3-transformer", version.ref = "media3" }
|
||||
media3-effect = { group = "androidx.media3", name = "media3-effect", version.ref = "media3" }
|
||||
media3-common = { group = "androidx.media3", name = "media3-common", version.ref = "media3" }
|
||||
media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" }
|
||||
media3-muxer = { group = "androidx.media3", name = "media3-muxer", version.ref = "media3" }
|
||||
|
||||
# Room — job history. Added in a later phase; KSP plugin stays unapplied until then.
|
||||
androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
|
||||
androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
|
||||
androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
|
||||
|
||||
# Required at runtime by the ffmpeg-kit-next wrapper: AbstractSession.fail()
|
||||
# references smartexception.java.Exceptions. Debug builds tolerate its absence through
|
||||
# lazy class loading, so this only surfaces as a crash on the first FFmpeg error --
|
||||
# or, as it did here, as an R8 missing-class error.
|
||||
smart-exception-java = { group = "com.arthenica", name = "smart-exception-java", version.ref = "smartException" }
|
||||
|
||||
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
||||
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
|
||||
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxJunit" }
|
||||
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
|
||||
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
|
||||
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
|
||||
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
|
||||
# DO NOT add org.jetbrains.kotlin.android — AGP 9 built-in Kotlin makes it a build failure.
|
||||
|
||||
+1
-1
@@ -22,5 +22,5 @@ dependencyResolutionManagement {
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "Android(MediaConverter"
|
||||
rootProject.name = "LibreMediaConverter"
|
||||
include(":app")
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# Reproducible FFmpeg build environment for ffmpeg-kit-next.
|
||||
#
|
||||
# ffmpeg-kit-next is Nix-only: the repository ships nix-android.sh and a flake, and
|
||||
# there is no plain android.sh. Rather than install Nix on a developer machine, the
|
||||
# whole toolchain lives in this image. It also serves as the reproducibility artifact
|
||||
# F-Droid expects.
|
||||
#
|
||||
# The flake pins Android NDK 27.3.13750724 (r27d) itself and applies
|
||||
# -Wl,-z,max-page-size=16384 for arm64-v8a and x86_64 in android/jni/Android.mk, so
|
||||
# the output is 16 KB page-size compliant without needing NDK r28+ on the host.
|
||||
#
|
||||
# The base image already provides git, bash, curl and tar. Compilers and build tools
|
||||
# (make, cmake, autotools, pkg-config) are supplied by the flake's devShell at build
|
||||
# time, so nothing further is installed here — `nix profile install` would in fact
|
||||
# collide with the base image's existing profile entries.
|
||||
|
||||
FROM docker.io/nixos/nix:2.35.2
|
||||
|
||||
# nix-android.sh points NIX_USER_CONF_FILES at the repo's own nix.conf, which enables
|
||||
# the flakes and nix-command experimental features. Set them here too so that plain
|
||||
# `nix` invocations behave the same way.
|
||||
RUN mkdir -p /etc/nix && \
|
||||
printf 'experimental-features = nix-command flakes\naccept-flake-config = true\nwarn-dirty = false\nmax-jobs = auto\n' \
|
||||
>> /etc/nix/nix.conf
|
||||
|
||||
# Upstream's scripts/*.sh use `#!/bin/bash`, but this image provides only /bin/sh.
|
||||
RUN ln -sf /bin/sh /bin/bash
|
||||
|
||||
WORKDIR /work
|
||||
COPY build-ffmpeg.sh /usr/local/bin/build-ffmpeg.sh
|
||||
RUN chmod +x /usr/local/bin/build-ffmpeg.sh
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/build-ffmpeg.sh"]
|
||||
@@ -0,0 +1,124 @@
|
||||
# FFmpeg build
|
||||
|
||||
Builds [`ffmpeg-kit-next`](https://github.com/arthenica/ffmpeg-kit-next) into an Android
|
||||
AAR that the app consumes.
|
||||
|
||||
## Why this exists
|
||||
|
||||
`arthenica/ffmpeg-kit` — the library nearly every Android FFmpeg tutorial still points at
|
||||
— was **archived**, and its binaries were **deleted from Maven Central**. Every
|
||||
`com.arthenica:ffmpeg-kit-*` coordinate now returns 404, and all of its GitHub release
|
||||
tags have zero attached assets. Maven Central's search index still *lists* the old
|
||||
versions, which is misleading; the files behind those entries are gone.
|
||||
|
||||
Its successor, `ffmpeg-kit-next`, is **source-only by design** and publishes no
|
||||
prebuilt packages. So building FFmpeg ourselves is not a preference, it is the only
|
||||
remaining option.
|
||||
|
||||
Community forks publishing prebuilt 16 KB-aligned AARs do exist, but each fails on
|
||||
license, ABI coverage, or publisher credibility — and at least one redistributes a
|
||||
non-free FDK-AAC build, which FFmpeg states is *unredistributable*.
|
||||
|
||||
## Why a container
|
||||
|
||||
`ffmpeg-kit-next` is **Nix-only**. There is no plain `android.sh`; the repository ships
|
||||
`nix-android.sh` plus a flake, and the flake pins the entire toolchain including
|
||||
**Android NDK 27.3.13750724 (r27d)**.
|
||||
|
||||
Rather than install Nix on a developer machine, the toolchain lives in a container
|
||||
image. That keeps the host clean and doubles as the reproducibility artifact F-Droid
|
||||
wants.
|
||||
|
||||
Note the NDK version: **do not** "helpfully" upgrade to r28+. The flake pins r27d and
|
||||
`android/jni/Android.mk` applies `-Wl,-z,max-page-size=16384` manually for `arm64-v8a`
|
||||
and `x86_64` precisely because r27 predates automatic 16 KB alignment. The output is
|
||||
16 KB compliant as-is.
|
||||
|
||||
## Usage
|
||||
|
||||
```sh
|
||||
podman build -t ffmpeg-kit-builder:local -f Containerfile .
|
||||
|
||||
mkdir -p out
|
||||
podman run --name ffmpeg-build -v "$PWD/out":/work/out:Z \
|
||||
localhost/ffmpeg-kit-builder:local full
|
||||
```
|
||||
|
||||
Two modes:
|
||||
|
||||
| Mode | Libraries | Purpose |
|
||||
|---|---|---|
|
||||
| `spike` | minimal | Validates the toolchain end to end without waiting on x264/x265/SVT-AV1 |
|
||||
| `full` | shipping set | The GPL configuration that ships |
|
||||
|
||||
The run deliberately omits `--rm`: the container's writable layer retains the several
|
||||
gigabytes of Nix store contents (Android SDK and NDK), so subsequent builds skip the
|
||||
download. Reuse it with `podman start -a ffmpeg-build`.
|
||||
|
||||
Only `arm64-v8a` and `x86_64` are built, matching the app's `abiFilters`. Dropping the
|
||||
32-bit ABIs roughly halves both build time and APK size, and Play does not require them.
|
||||
|
||||
## Library selection
|
||||
|
||||
Flag names come from `get_library_name()` in the upstream `scripts/function.sh`. Two
|
||||
that are easy to get wrong:
|
||||
|
||||
- It is **`--enable-lame`**, not `--enable-libmp3lame`.
|
||||
- It is **`--enable-libsvtav1`** for SVT-AV1.
|
||||
|
||||
MP3 deserves a note: **Android has no MP3 encoder at any API level**. That is a platform
|
||||
gap, not a Media3 limitation, so `--enable-lame` is the only way the app can output MP3.
|
||||
|
||||
`--enable-android-media-codec` gives FFmpeg the `h264_mediacodec` / `hevc_mediacodec`
|
||||
wrappers (added in FFmpeg 6.0). These act as a fallback-within-the-fallback: hardware
|
||||
encode from the FFmpeg side when a job was routed away from Media3 for container reasons
|
||||
but still wants hardware speed.
|
||||
|
||||
## Licensing
|
||||
|
||||
The `full` build passes `--enable-gpl` with **x264** and **x265**, which makes the
|
||||
distributed binary **GPL-3.0**. This is deliberate — see [`../../LICENSES/README.md`](../../LICENSES/README.md).
|
||||
|
||||
Worth recording, because it is widely misunderstood: **libass is ISC licensed, not GPL**,
|
||||
so subtitle burn-in does not require the GPL flag. The only things GPL genuinely buys are
|
||||
x264/x265 software encode (and with them CRF and 2-pass rate control), vidstab, and the
|
||||
GPL filter set.
|
||||
|
||||
Never build with `--enable-nonfree`. FFmpeg states it renders the binary
|
||||
*unredistributable*.
|
||||
|
||||
## Verified build output (2026-08-19, ffmpeg-kit-next v8.1.1 / FFmpeg 8.1.2)
|
||||
|
||||
The `full` build produced a 35 MB AAR with 10 shared libraries per ABI for `arm64-v8a`
|
||||
and `x86_64`. Confirmed against the artifact rather than assumed:
|
||||
|
||||
- **16 KB page alignment**: every `.so` on both ABIs reports `LOAD align 0x4000`
|
||||
(`readelf -lW`). This is the hard Play gate.
|
||||
- **Separate shared libraries**, not a static monolith — which is what the LGPL/GPL
|
||||
relinking obligation requires.
|
||||
- **Embedded configure line** (from `strings libavutil.so`):
|
||||
`--enable-gpl --enable-version3 --enable-libx264 --enable-libx265 --enable-libsvtav1
|
||||
--enable-libvpx --enable-libmp3lame --enable-libopus --enable-libdav1d --enable-libass
|
||||
--enable-libfontconfig --enable-libfreetype --enable-libfribidi --enable-libharfbuzz
|
||||
--enable-mediacodec --enable-jni --enable-shared --enable-small --enable-lto`
|
||||
- Present and verified: `libx264` (with an x264 core banner, so genuinely linked),
|
||||
`libx265`, `libsvtav1`, `libmp3lame`, `h264_mediacodec`, `hevc_mediacodec`, `libopus`,
|
||||
`libdav1d`, the GIF encoder and muxer, libass internals (`ass_shaper_new`), and the
|
||||
`subtitles`, `scale`, `palettegen`, `paletteuse` and `concat` filters.
|
||||
|
||||
Note `--enable-version3`: combined with `--enable-gpl` this makes the binary **GPL-3.0**,
|
||||
which is what `LICENSES/README.md` states.
|
||||
|
||||
A caution on verifying this yourself: the build uses `--enable-small` and `--enable-lto`,
|
||||
so internal symbols like `ff_libx264_encoder` do **not** appear in `strings` output.
|
||||
Their absence proves nothing. Check the configure line and the registered codec *names*
|
||||
instead.
|
||||
|
||||
## Release checklist
|
||||
|
||||
GPL-3.0 requires corresponding source alongside the binary. For each release, attach to
|
||||
the GitHub Release next to the APK:
|
||||
|
||||
- the exact `ffmpeg-kit-next` tag and FFmpeg version used,
|
||||
- the full configure line (printed in this script's build log), and
|
||||
- any patches applied.
|
||||
Executable
+134
@@ -0,0 +1,134 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Builds ffmpeg-kit-next into an Android AAR. Runs INSIDE the container image
|
||||
# defined by the sibling Containerfile.
|
||||
#
|
||||
# Usage: build-ffmpeg.sh [spike|full]
|
||||
#
|
||||
# spike - minimal library set. Validates that the toolchain works and produces an
|
||||
# AAR, without waiting on x264/x265/SVT-AV1. Use this first.
|
||||
# full - the shipping configuration (GPL: x264 + x265).
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
# The upstream scripts use `#!/bin/bash`, but the nixos/nix image ships only /bin/sh
|
||||
# (itself bash, via the Nix store). Without this, start-android.sh dies with
|
||||
# "cannot execute: required file not found" AFTER the whole toolchain has been built,
|
||||
# which is an expensive way to discover a missing symlink.
|
||||
if [[ ! -e /bin/bash ]]; then
|
||||
ln -sf "$(command -v bash)" /bin/bash
|
||||
fi
|
||||
|
||||
MODE="${1:-spike}"
|
||||
TAG="${FFMPEG_KIT_TAG:-v8.1.1}"
|
||||
SRC=/work/ffmpeg-kit-next
|
||||
OUT=/work/out
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Library selection
|
||||
# ---------------------------------------------------------------------------
|
||||
# Flag names come from get_library_name() in scripts/function.sh — note it is
|
||||
# --enable-lame, NOT --enable-libmp3lame.
|
||||
#
|
||||
# android-media-codec gives FFmpeg the h264_mediacodec / hevc_mediacodec wrappers.
|
||||
# Those are the fallback-within-the-fallback: hardware encode from the FFmpeg side
|
||||
# when a job has been routed away from Media3 for container reasons but still wants
|
||||
# hardware encode.
|
||||
|
||||
COMMON_LIBS=(
|
||||
--enable-android-media-codec
|
||||
--enable-android-zlib
|
||||
--enable-lame # MP3 encode. Android has NO MP3 encoder at any API level,
|
||||
# so this is the only way the app can output MP3 at all.
|
||||
--enable-opus
|
||||
--enable-dav1d # fast AV1 decode
|
||||
)
|
||||
|
||||
SUBTITLE_LIBS=(
|
||||
--enable-libass # ISC licensed, NOT GPL - subtitle burn-in is LGPL-safe
|
||||
--enable-fontconfig
|
||||
--enable-freetype
|
||||
--enable-fribidi
|
||||
--enable-harfbuzz
|
||||
)
|
||||
|
||||
# GPL. These are the reason the shipped binary is GPL-3.0 rather than LGPL: they are
|
||||
# the only route to CRF and 2-pass rate control, which no Android hardware encoder
|
||||
# exposes. See LICENSES/README.md.
|
||||
GPL_LIBS=(
|
||||
--enable-gpl
|
||||
--enable-x264
|
||||
--enable-x265
|
||||
)
|
||||
|
||||
EXTRA_LIBS=(
|
||||
--enable-libvpx # VP8/VP9
|
||||
--enable-libsvtav1 # fast AV1 encode
|
||||
)
|
||||
|
||||
case "$MODE" in
|
||||
spike) LIBS=("${COMMON_LIBS[@]}") ;;
|
||||
full) LIBS=("${COMMON_LIBS[@]}" "${SUBTITLE_LIBS[@]}" "${GPL_LIBS[@]}" "${EXTRA_LIBS[@]}") ;;
|
||||
*) echo "unknown mode: $MODE (expected 'spike' or 'full')" >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
echo "=============================================="
|
||||
echo " ffmpeg-kit-next build"
|
||||
echo " mode : $MODE"
|
||||
echo " tag : $TAG"
|
||||
echo " libraries : ${LIBS[*]}"
|
||||
echo " started : $(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
echo "=============================================="
|
||||
|
||||
if [[ ! -d "$SRC" ]]; then
|
||||
git clone --branch "$TAG" --depth 1 \
|
||||
https://github.com/arthenica/ffmpeg-kit-next.git "$SRC"
|
||||
fi
|
||||
|
||||
cd "$SRC"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AAPT2 override
|
||||
# ---------------------------------------------------------------------------
|
||||
# The final step packages the .so files into an AAR with Gradle. Gradle's default
|
||||
# AAPT2 comes from Maven as a prebuilt binary dynamically linked against normal FHS
|
||||
# paths (/lib64/ld-linux-x86-64.so.2). Those do not exist in a Nix image, so it dies
|
||||
# with "AAPT2 ... Daemon startup failed" AFTER the entire native build has succeeded.
|
||||
#
|
||||
# The Android SDK that Nix provides has an aapt2 that nixpkgs has already patchelf'd,
|
||||
# so point Gradle at that one instead.
|
||||
AAPT2="$(find /nix/store -maxdepth 6 -name aapt2 -type f 2>/dev/null | head -1)"
|
||||
if [[ -n "$AAPT2" ]]; then
|
||||
echo "using nix-provided aapt2: $AAPT2"
|
||||
grep -v 'aapt2FromMavenOverride' android/gradle.properties > /tmp/gradle.properties.new || true
|
||||
mv /tmp/gradle.properties.new android/gradle.properties
|
||||
echo "android.aapt2FromMavenOverride=$AAPT2" >> android/gradle.properties
|
||||
else
|
||||
echo "WARNING: no nix aapt2 found; the AAR packaging step will probably fail." >&2
|
||||
fi
|
||||
|
||||
# 64-bit only, matching the app's abiFilters. Dropping the 32-bit ABIs roughly halves
|
||||
# build time and APK size, and Play does not require them.
|
||||
# --api-level matches the app's minSdk 33 (default is 24), so the native code may use
|
||||
# the newer NDK media APIs. ffmpeg-kit protocols (ffkitsaf, ffkitmem, ffkitstream) are
|
||||
# left enabled: ffkitsaf is the SAF bridge that replaces the old getSafParameter trick.
|
||||
# Output still stages through a real cache path rather than a SAF fd, because MP4
|
||||
# faststart needs to seek back to rewrite the moov atom.
|
||||
./nix-android.sh -p android-r27d \
|
||||
--api-level=33 \
|
||||
--disable-arm-v7a \
|
||||
--disable-arm-v7a-neon \
|
||||
--disable-x86 \
|
||||
"${LIBS[@]}"
|
||||
|
||||
mkdir -p "$OUT"
|
||||
# Only the ffmpeg-kit AAR. A bare '*.aar' find also sweeps up every AAR that Gradle
|
||||
# happens to have unpacked into its own caches (junit, espresso, tracing...), which
|
||||
# is confusing noise in the output directory.
|
||||
find "$SRC/android/ffmpeg-kit-next-android-lib/build/outputs/aar" \
|
||||
"$SRC/prebuilt" \
|
||||
-name 'ffmpeg-kit-next*.aar' -exec cp -v {} "$OUT/" \; 2>/dev/null
|
||||
|
||||
echo "=============================================="
|
||||
echo " finished : $(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
ls -la "$OUT" || true
|
||||
Reference in New Issue
Block a user