diff --git a/.gitignore b/.gitignore index aa724b7..9184f80 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,6 @@ .externalNativeBuild .cxx local.properties + +# Built FFmpeg AAR - see tools/ffmpeg/ for the build recipe +app/libs/*.aar diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c824f74 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/LICENSES/README.md b/LICENSES/README.md new file mode 100644 index 0000000..f0ac010 --- /dev/null +++ b/LICENSES/README.md @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..746efce --- /dev/null +++ b/README.md @@ -0,0 +1,82 @@ +# Media Converter + +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: early development.** The project scaffold and UI shell exist; the conversion +> pipeline is being built out. Not yet usable. + +## 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." + +## Building + +Requires JDK 17+ and the Android SDK with API 37. + +``` +./gradlew :app:assembleDebug +``` + +The FFmpeg native library is built separately from source; that build is not yet wired +into this repository. + +## 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`. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f9a698d..5646484 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,41 +1,92 @@ 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 = "dev.jasonmross.mediaconverter" + compileSdk { version = release(37) } defaultConfig { - applicationId = "com.example.androidmediaconverter" + applicationId = "dev.jasonmross.mediaconverter" 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. + ndk { + abiFilters += listOf("arm64-v8a", "x86_64") + } + } + + buildFeatures { + compose = true } buildTypes { release { optimization { + // Left off until the JNI keep rules land with FFmpeg (see plan, Phase 6). enable = false } } } + 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) + + 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) -} \ No newline at end of file + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(libs.compose.ui.test.junit4) + debugImplementation(libs.compose.ui.test.manifest) +} diff --git a/app/src/androidTest/java/com/example/androidmediaconverter/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/example/androidmediaconverter/ExampleInstrumentedTest.kt deleted file mode 100644 index d83a5b4..0000000 --- a/app/src/androidTest/java/com/example/androidmediaconverter/ExampleInstrumentedTest.kt +++ /dev/null @@ -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) - } -} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 0d7c056..2117410 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,6 +1,5 @@ - + + android:theme="@style/Theme.MediaConverter"> - \ No newline at end of file + + + + + + + + + diff --git a/app/src/main/java/dev/jasonmross/mediaconverter/ui/theme/Color.kt b/app/src/main/java/dev/jasonmross/mediaconverter/ui/theme/Color.kt new file mode 100644 index 0000000..8734722 --- /dev/null +++ b/app/src/main/java/dev/jasonmross/mediaconverter/ui/theme/Color.kt @@ -0,0 +1,12 @@ +package dev.jasonmross.mediaconverter.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) diff --git a/app/src/main/java/dev/jasonmross/mediaconverter/ui/theme/Theme.kt b/app/src/main/java/dev/jasonmross/mediaconverter/ui/theme/Theme.kt new file mode 100644 index 0000000..74e4d36 --- /dev/null +++ b/app/src/main/java/dev/jasonmross/mediaconverter/ui/theme/Theme.kt @@ -0,0 +1,51 @@ +package dev.jasonmross.mediaconverter.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 MediaConverterTheme( + 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, + ) +} diff --git a/app/src/main/java/dev/jasonmross/mediaconverter/ui/theme/Type.kt b/app/src/main/java/dev/jasonmross/mediaconverter/ui/theme/Type.kt new file mode 100644 index 0000000..1f69a0f --- /dev/null +++ b/app/src/main/java/dev/jasonmross/mediaconverter/ui/theme/Type.kt @@ -0,0 +1,5 @@ +package dev.jasonmross.mediaconverter.ui.theme + +import androidx.compose.material3.Typography + +val Typography = Typography() diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml index ad32eb5..03efa9d 100644 --- a/app/src/main/res/values-night/themes.xml +++ b/app/src/main/res/values-night/themes.xml @@ -1,16 +1,3 @@ - - - - \ No newline at end of file + + - \ No newline at end of file + +