diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d3f853d..6376a52 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -153,7 +153,7 @@ jobs: - uses: dtolnay/rust-toolchain@master with: toolchain: "1.92.0" - targets: aarch64-apple-darwin,x86_64-unknown-linux-musl + targets: aarch64-apple-darwin,aarch64-linux-android,x86_64-unknown-linux-musl - uses: Swatinem/rust-cache@v2 - name: Invalid combos must fail with the compile_error text run: | @@ -182,6 +182,12 @@ jobs: run: | # Explicit pin on its native target. cargo check -p rawshift-image --no-default-features --features hw-vaapi --target x86_64-unknown-linux-gnu + # Android's Java API is needed only at runtime; `cargo check` on a + # Linux runner fully type-checks the JNI/NDK implementation and the + # public re-exports without an emulator or cross linker. + cargo check -p rawshift-hwdec --features mediacodec --target aarch64-linux-android + cargo check -p rawshift-image --no-default-features --features hw-mediacodec --target aarch64-linux-android + cargo check -p rawshift --no-default-features --features image,hw-mediacodec --target aarch64-linux-android # The portable `hw` is valid everywhere: it selects the native # backend where one exists and compiles the no-backend stub (with a # build-script warning) where none does (musl). diff --git a/.gitignore b/.gitignore index e4cc6ec..8dce5c2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,8 @@ /target +/android/mediacodec-harness/.gradle/ +/android/mediacodec-harness/app/build/ +/android/mediacodec-harness/native/target/ +/android/mediacodec-harness/local.properties .DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md index 8728a8b..290e593 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,6 +70,11 @@ All entries below are **breaking**, grouped by area. #### Per-codec migrations (`rawshift-image`) +- **Android hardware decode** now implements the fixed MediaCodec backend for + HEVC/HEIC and AV1/AVIF on API 29+. Applications initialize it once with the + process `JavaVM`; hardware, non-alias components are runtime-probed. Output + is dense I420, or strict P010 on API 33+ with no silent downconversion. + - **JPEG** → gamut-jpeg (pure Rust, baseline + progressive both ways). Decode replaces `zune-jpeg` (CMYK/YCCK conversion bit-identical); encode replaces `jpeg-encoder` **and** the vendored jpegli stack with one diff --git a/Cargo.lock b/Cargo.lock index f33e310..8a69de8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -264,6 +264,16 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + [[package]] name = "core_maths" version = "0.1.1" @@ -381,6 +391,12 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "errno" version = "0.3.14" @@ -539,7 +555,7 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b17c79c8672b675d538dceffda1dd5d00ba279cbcca4f75c7b4fa847741d5d31" dependencies = [ - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -575,7 +591,7 @@ checksum = "c9ce8735a1fc71d77b5361f4f126676abab8811cdd3797a6bacdb920cd20f85d" dependencies = [ "gamut-core", "gamut-ifd", - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -598,7 +614,7 @@ checksum = "09916f0aea3f9dbe1c5ac67e0e29956b9af43c8c86a0fbe6b47c597b63fd8cee" dependencies = [ "gamut-core", "md-5", - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -618,7 +634,7 @@ checksum = "99b7bb8412adf69f48657d207ff5358750ebe4efe81c2dd7b32091034f354d6b" dependencies = [ "gamut-core", "gamut-xmp", - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -684,7 +700,7 @@ dependencies = [ "gamut-icc", "gamut-iptc", "gamut-xmp", - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -730,7 +746,7 @@ checksum = "fd39a4ebd0d14800751e3ae64a0c86a07e00b77f3ac564570e0e8987d41945bb" dependencies = [ "gamut-core", "quick-xml", - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -764,6 +780,12 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + [[package]] name = "heck" version = "0.5.0" @@ -798,6 +820,16 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + [[package]] name = "is-terminal" version = "0.4.17" @@ -830,6 +862,64 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] + [[package]] name = "jpegxl-src" version = "0.12.0" @@ -862,7 +952,7 @@ dependencies = [ "jxl_transforms", "num-derive", "num-traits", - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -1001,6 +1091,29 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.11.0", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -1030,6 +1143,28 @@ dependencies = [ "autocfg", ] +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -1124,6 +1259,15 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro-error-attr2" version = "2.0.0" @@ -1201,8 +1345,11 @@ name = "rawshift-hwdec" version = "0.1.1" dependencies = [ "gamut-color", + "jni", "libloading", - "thiserror", + "ndk", + "thiserror 2.0.18", + "tracing", ] [[package]] @@ -1255,7 +1402,7 @@ dependencies = [ "resvg", "serde", "serde_json", - "thiserror", + "thiserror 2.0.18", "tiff", "tokio", "tracing", @@ -1297,7 +1444,7 @@ dependencies = [ "rawshift-core", "rawshift-hwdec", "serde", - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -1578,6 +1725,15 @@ version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -1617,6 +1773,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.228" @@ -1691,6 +1853,22 @@ version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "simplecss" version = "0.2.2" @@ -1767,13 +1945,33 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -1889,6 +2087,36 @@ dependencies = [ "syn", ] +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + [[package]] name = "tracing" version = "0.1.44" @@ -2153,6 +2381,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + [[package]] name = "xmlwriter" version = "0.1.0" diff --git a/README.md b/README.md index 8595071..c4dd628 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,11 @@ The `rawshift` facade deliberately exposes only coarse features: and fail the compile elsewhere (see [docs/SUPPORT.md](./docs/SUPPORT.md)). - `full` — every image format, `serde`, and `hw`. +Android API 29+ applications call the safely re-exported +`initialize_android_hw_decode(JavaVM)` before querying or decoding. The +physical-device acceptance matrix is in the +[`android/mediacodec-harness`](./android/mediacodec-harness/README.md). + There is no `video` feature: video is parked for v1 (see [Video](#video)). The facade does **not** re-export per-format flags. Cargo cannot forward a child diff --git a/android/mediacodec-harness/README.md b/android/mediacodec-harness/README.md new file mode 100644 index 0000000..40051ce --- /dev/null +++ b/android/mediacodec-harness/README.md @@ -0,0 +1,71 @@ +# MediaCodec physical-device harness + +This Gradle project is the merge gate for rawshift's Android hardware decoder. +It builds the Rust JNI library for `arm64-v8a`, installs an instrumentation +test, rejects emulators, and decodes checked-in lossless HEVC and AV1 fixtures. + +The test verifies all decoded bytes, not merely that MediaCodec returned an +output buffer. It decodes every required fixture twice through one decoder: +the first duration is reported as `cold_us`, and the second (exact-format +session reuse) as `warm_us`. These are diagnostic numbers, not thresholds. + +## Toolchain + +- JDK 17 +- Android SDK Platform 36 and Build Tools 36.0.0 +- Android NDK `29.0.14206865` +- Rust 1.92.0 with `aarch64-linux-android` +- Gradle 9.4.1 (checked wrapper) and Android Gradle Plugin 9.2.1 + +Install the Android pieces with: + +```sh +sdkmanager "platforms;android-36" "build-tools;36.0.0" "ndk;29.0.14206865" +rustup target add aarch64-linux-android +``` + +Set `ANDROID_HOME` and set `ANDROID_NDK_HOME` to the NDK directory. Then +connect exactly one unlocked ARM64 physical device with USB debugging enabled: + +```sh +adb devices -l +cd android/mediacodec-harness +./gradlew connectedDebugAndroidTest +``` + +The full acceptance matrix is two physical-device runs: + +| Run | Device requirements | Required result | +| --- | --- | --- | +| Compatibility floor | API 29, ARM64, hardware HEVC and AV1 decoders | byte-exact 8-bit HEVC + AV1, cold and warm | +| High bit depth | API 33+, ARM64, hardware HEVC and AV1 decoders | the same 8-bit checks plus byte-exact P010 for at least one codec | + +Attach both `rawshift-hwdec` report blocks from logcat/test output to the pull +request before changing it from draft to ready. An emulator is intentionally +not accepted: its advertised codec inventory and acceleration path depend on +host passthrough, so it cannot establish the physical hardware contract. + +## Why this is the Android boundary + +`AMediaCodec` is the decoder. `AImageReader` supplies its CPU-readable output +surface. A small JNI classification step calls `MediaCodecList` because API 29 +is the first public Android API that definitively exposes +`MediaCodecInfo.isHardwareAccelerated()` and `isAlias()`. JNI therefore does +not introduce a second decode backend; it identifies the exact named hardware +components that the NDK `AMediaCodec_createCodecByName` path opens. + +This is the only Android decode API rawshift targets. Media3/ExoPlayer are +playback orchestration layers over MediaCodec, while `ImageDecoder` accepts +complete supported image sources rather than the HEIF/AVIF item codestream +seam rawshift must implement. The relevant platform contracts are: + +- +- +- +- +- + +P010 is deliberately API 33+ in rawshift: although `ImageFormat.YCBCR_P010` +appeared earlier, `MediaCodecInfo.CodecCapabilities.COLOR_FormatYUVP010` is a +public codec capability only from API 33. The backend never silently +downconverts 10-bit input. diff --git a/android/mediacodec-harness/app/build.gradle.kts b/android/mediacodec-harness/app/build.gradle.kts new file mode 100644 index 0000000..95a35d9 --- /dev/null +++ b/android/mediacodec-harness/app/build.gradle.kts @@ -0,0 +1,62 @@ +plugins { + id("com.android.application") +} + +abstract class BuildRustTask : Exec() { + @get:OutputDirectory + abstract val outputDirectory: DirectoryProperty +} + +android { + namespace = "org.visualcommons.rawshift.harness" + compileSdk = 36 + ndkVersion = "29.0.14206865" + + defaultConfig { + applicationId = "org.visualcommons.rawshift.harness" + minSdk = 29 + targetSdk = 36 + versionCode = 1 + versionName = "1.0" + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + ndk { + abiFilters += "arm64-v8a" + } + } + +} + +val buildRust by tasks.registering(BuildRustTask::class) { + inputs.files( + fileTree("../../..") { + include("Cargo.toml", "Cargo.lock") + include("crates/rawshift-hwdec/src/**") + include("crates/rawshift-hwdec/Cargo.toml") + include("android/mediacodec-harness/native/**") + include("android/mediacodec-harness/fixtures/data/**") + exclude("android/mediacodec-harness/native/target/**") + } + ) + outputDirectory.set(layout.buildDirectory.dir("rust-jni-libs")) + doFirst { + commandLine( + "bash", + rootProject.file("build-rust.sh").absolutePath, + outputDirectory.get().dir("arm64-v8a").asFile.absolutePath, + ) + } +} + +androidComponents { + onVariants { variant -> + variant.sources.jniLibs?.addGeneratedSourceDirectory( + buildRust, + BuildRustTask::outputDirectory, + ) + } +} + +dependencies { + androidTestImplementation("androidx.test.ext:junit:1.3.0") + androidTestImplementation("androidx.test:runner:1.7.0") +} diff --git a/android/mediacodec-harness/app/src/androidTest/java/org/visualcommons/rawshift/harness/MediaCodecDeviceTest.java b/android/mediacodec-harness/app/src/androidTest/java/org/visualcommons/rawshift/harness/MediaCodecDeviceTest.java new file mode 100644 index 0000000..3dbc19a --- /dev/null +++ b/android/mediacodec-harness/app/src/androidTest/java/org/visualcommons/rawshift/harness/MediaCodecDeviceTest.java @@ -0,0 +1,36 @@ +package org.visualcommons.rawshift.harness; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import android.os.Build; +import android.util.Log; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import org.junit.Test; +import org.junit.runner.RunWith; + +@RunWith(AndroidJUnit4.class) +public final class MediaCodecDeviceTest { + @Test + public void hardwareStillDecodeFixtures() { + assertFalse("This is a physical-device gate; emulator results are not accepted", isEmulator()); + assertTrue("rawshift supports Android API 29+", Build.VERSION.SDK_INT >= 29); + + String report = NativeHarness.runSuite(Build.VERSION.SDK_INT); + Log.i("rawshift-hwdec", "\n" + report); + assertTrue(report, report.startsWith("PASS\n") || report.startsWith("PASS sdk=")); + } + + private static boolean isEmulator() { + return Build.FINGERPRINT.startsWith("generic") + || Build.FINGERPRINT.toLowerCase().contains("emulator") + || Build.MODEL.contains("google_sdk") + || Build.MODEL.contains("Emulator") + || Build.MODEL.contains("Android SDK built for") + || Build.MANUFACTURER.contains("Genymotion") + || (Build.BRAND.startsWith("generic") && Build.DEVICE.startsWith("generic")) + || Build.PRODUCT.contains("sdk_gphone") + || Build.PRODUCT.contains("google_sdk") + || Build.PRODUCT.contains("simulator"); + } +} diff --git a/android/mediacodec-harness/app/src/main/AndroidManifest.xml b/android/mediacodec-harness/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..2b5ce12 --- /dev/null +++ b/android/mediacodec-harness/app/src/main/AndroidManifest.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + diff --git a/android/mediacodec-harness/app/src/main/java/org/visualcommons/rawshift/harness/MainActivity.java b/android/mediacodec-harness/app/src/main/java/org/visualcommons/rawshift/harness/MainActivity.java new file mode 100644 index 0000000..f931d77 --- /dev/null +++ b/android/mediacodec-harness/app/src/main/java/org/visualcommons/rawshift/harness/MainActivity.java @@ -0,0 +1,18 @@ +package org.visualcommons.rawshift.harness; + +import android.app.Activity; +import android.os.Build; +import android.os.Bundle; +import android.widget.TextView; + +public final class MainActivity extends Activity { + @Override + protected void onCreate(Bundle state) { + super.onCreate(state); + TextView report = new TextView(this); + report.setText(NativeHarness.runSuite(Build.VERSION.SDK_INT)); + report.setTextIsSelectable(true); + report.setPadding(32, 32, 32, 32); + setContentView(report); + } +} diff --git a/android/mediacodec-harness/app/src/main/java/org/visualcommons/rawshift/harness/NativeHarness.java b/android/mediacodec-harness/app/src/main/java/org/visualcommons/rawshift/harness/NativeHarness.java new file mode 100644 index 0000000..a7e1b21 --- /dev/null +++ b/android/mediacodec-harness/app/src/main/java/org/visualcommons/rawshift/harness/NativeHarness.java @@ -0,0 +1,11 @@ +package org.visualcommons.rawshift.harness; + +final class NativeHarness { + static { + System.loadLibrary("rawshift_mediacodec_harness_native"); + } + + private NativeHarness() {} + + static native String runSuite(int sdk); +} diff --git a/android/mediacodec-harness/build-rust.sh b/android/mediacodec-harness/build-rust.sh new file mode 100755 index 0000000..d6fa7d6 --- /dev/null +++ b/android/mediacodec-harness/build-rust.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -ne 1 ]]; then + echo "usage: build-rust.sh " >&2 + exit 2 +fi + +ndk_root=${ANDROID_NDK_HOME:-${ANDROID_NDK_ROOT:-}} +if [[ -z "${ndk_root}" ]]; then + echo "ANDROID_NDK_HOME must point to NDK 29.0.14206865" >&2 + exit 2 +fi + +case "$(uname -s)-$(uname -m)" in + Linux-x86_64) host_tag=linux-x86_64 ;; + Darwin-x86_64|Darwin-arm64) host_tag=darwin-x86_64 ;; + *) echo "unsupported build host: $(uname -s)-$(uname -m)" >&2; exit 2 ;; +esac + +linker="${ndk_root}/toolchains/llvm/prebuilt/${host_tag}/bin/aarch64-linux-android29-clang" +if [[ ! -x "${linker}" ]]; then + echo "Android API 29 linker not found: ${linker}" >&2 + exit 2 +fi + +script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +output_dir=$1 + +rustup target add aarch64-linux-android +CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER="${linker}" \ + cargo build \ + --manifest-path "${script_dir}/native/Cargo.toml" \ + --target aarch64-linux-android \ + --release + +mkdir -p "${output_dir}" +cp "${script_dir}/native/target/aarch64-linux-android/release/librawshift_mediacodec_harness_native.so" \ + "${output_dir}/librawshift_mediacodec_harness_native.so" diff --git a/android/mediacodec-harness/build.gradle.kts b/android/mediacodec-harness/build.gradle.kts new file mode 100644 index 0000000..fd4b881 --- /dev/null +++ b/android/mediacodec-harness/build.gradle.kts @@ -0,0 +1,3 @@ +plugins { + id("com.android.application") version "9.2.1" apply false +} diff --git a/android/mediacodec-harness/fixtures/README.md b/android/mediacodec-harness/fixtures/README.md new file mode 100644 index 0000000..94e01d2 --- /dev/null +++ b/android/mediacodec-harness/fixtures/README.md @@ -0,0 +1,38 @@ +# Fixture provenance + +The four `.rshw` files are 64×64, single-frame, lossless test vectors generated +with FFmpeg 8.1.2 from its deterministic `testsrc2` source. They contain the +container-style codec configuration, coded item payload, and expected dense +decoded pixels. The expected layouts are I420 for 8-bit and MSB-aligned, +interleaved P010 for 10-bit. + +Regeneration on Linux or macOS requires FFmpeg with `libx265` and `libaom-av1`: + +```sh +tmp=$(mktemp -d) +ffmpeg -f lavfi -i 'testsrc2=size=64x64:rate=1' -frames:v 1 -pix_fmt yuv420p \ + -c:v libx265 -preset ultrafast \ + -x265-params 'lossless=1:keyint=1:min-keyint=1:repeat-headers=1:annexb=1' \ + -f hevc "$tmp/hevc8.hevc" +ffmpeg -f lavfi -i 'testsrc2=size=64x64:rate=1' -frames:v 1 -pix_fmt yuv420p10le \ + -c:v libx265 -preset ultrafast \ + -x265-params 'lossless=1:keyint=1:min-keyint=1:repeat-headers=1:annexb=1' \ + -f hevc "$tmp/hevc10.hevc" +ffmpeg -f lavfi -i 'testsrc2=size=64x64:rate=1' -frames:v 1 -pix_fmt yuv420p \ + -c:v libaom-av1 -cpu-used 8 -still-picture 1 -crf 0 -b:v 0 -f obu "$tmp/av1-8.obu" +ffmpeg -f lavfi -i 'testsrc2=size=64x64:rate=1' -frames:v 1 -pix_fmt yuv420p10le \ + -c:v libaom-av1 -cpu-used 8 -still-picture 1 -crf 0 -b:v 0 -f obu "$tmp/av1-10.obu" +ffmpeg -f lavfi -i 'testsrc2=size=64x64:rate=1' -frames:v 1 -pix_fmt yuv420p \ + -f rawvideo "$tmp/yuv8.raw" +ffmpeg -f lavfi -i 'testsrc2=size=64x64:rate=1' -frames:v 1 -pix_fmt yuv420p10le \ + -f rawvideo "$tmp/yuv10.raw" +rustc --edition 2024 package.rs -o "$tmp/package" +"$tmp/package" hevc 8 "$tmp/hevc8.hevc" "$tmp/yuv8.raw" data/hevc-8.rshw +"$tmp/package" hevc 10 "$tmp/hevc10.hevc" "$tmp/yuv10.raw" data/hevc-10.rshw +"$tmp/package" av1 8 "$tmp/av1-8.obu" "$tmp/yuv8.raw" data/av1-8.rshw +"$tmp/package" av1 10 "$tmp/av1-10.obu" "$tmp/yuv10.raw" data/av1-10.rshw +``` + +The host unit tests parse all four packages and exercise codec preparation plus +stride/crop normalization. The physical-device harness uses the same bytes and +compares every decoded output byte with the embedded expected frame. diff --git a/android/mediacodec-harness/fixtures/data/av1-10.rshw b/android/mediacodec-harness/fixtures/data/av1-10.rshw new file mode 100644 index 0000000..340055f Binary files /dev/null and b/android/mediacodec-harness/fixtures/data/av1-10.rshw differ diff --git a/android/mediacodec-harness/fixtures/data/av1-8.rshw b/android/mediacodec-harness/fixtures/data/av1-8.rshw new file mode 100644 index 0000000..1508b6a Binary files /dev/null and b/android/mediacodec-harness/fixtures/data/av1-8.rshw differ diff --git a/android/mediacodec-harness/fixtures/data/hevc-10.rshw b/android/mediacodec-harness/fixtures/data/hevc-10.rshw new file mode 100644 index 0000000..544de97 Binary files /dev/null and b/android/mediacodec-harness/fixtures/data/hevc-10.rshw differ diff --git a/android/mediacodec-harness/fixtures/data/hevc-8.rshw b/android/mediacodec-harness/fixtures/data/hevc-8.rshw new file mode 100644 index 0000000..fd88d1f Binary files /dev/null and b/android/mediacodec-harness/fixtures/data/hevc-8.rshw differ diff --git a/android/mediacodec-harness/fixtures/package.rs b/android/mediacodec-harness/fixtures/package.rs new file mode 100644 index 0000000..55dc734 --- /dev/null +++ b/android/mediacodec-harness/fixtures/package.rs @@ -0,0 +1,197 @@ +//! Packages ffmpeg elementary streams and their source YUV into `.rshw` +//! fixtures consumed by the host tests and Android device harness. +//! +//! This deliberately uses only `std` so fixture regeneration does not alter +//! the workspace dependency graph. See `README.md` for exact commands. + +use std::env; +use std::fs; +use std::path::Path; + +const WIDTH: u16 = 64; +const HEIGHT: u16 = 64; + +fn main() { + let args: Vec<_> = env::args().collect(); + assert_eq!( + args.len(), + 6, + "package <8|10> " + ); + let codec = &args[1]; + let depth: u8 = args[2].parse().expect("bit depth"); + let stream = fs::read(&args[3]).expect("stream"); + let source = fs::read(&args[4]).expect("source YUV"); + let (codec_id, config, payload) = match codec.as_str() { + "hevc" => { + let (config, payload) = package_hevc(&stream, depth); + (1, config, payload) + } + "av1" => { + let (config, payload) = package_av1(&stream, depth); + (2, config, payload) + } + _ => panic!("unknown codec"), + }; + let expected = expected_frame(depth, &source); + let mut output = b"RSHW0001".to_vec(); + output.extend_from_slice(&[codec_id, depth]); + output.extend_from_slice(&WIDTH.to_le_bytes()); + output.extend_from_slice(&HEIGHT.to_le_bytes()); + output.extend_from_slice(&(config.len() as u32).to_le_bytes()); + output.extend_from_slice(&(payload.len() as u32).to_le_bytes()); + output.extend_from_slice(&(expected.len() as u32).to_le_bytes()); + output.extend_from_slice(&config); + output.extend_from_slice(&payload); + output.extend_from_slice(&expected); + fs::write(Path::new(&args[5]), output).expect("fixture output"); +} + +fn package_hevc(stream: &[u8], depth: u8) -> (Vec, Vec) { + let units = annex_b_units(stream); + let mut parameter_sets = Vec::new(); + let mut payload = Vec::new(); + for unit in units { + let kind = (unit[0] >> 1) & 0x3f; + if matches!(kind, 32..=34) { + parameter_sets.push((kind, unit)); + } else if kind < 32 { + payload.extend_from_slice(&(unit.len() as u32).to_be_bytes()); + payload.extend_from_slice(unit); + } + } + for required in 32..=34 { + assert!(parameter_sets.iter().any(|(kind, _)| *kind == required)); + } + assert!(!payload.is_empty(), "no coded HEVC slice"); + + let mut hvcc = vec![0; 23]; + hvcc[0] = 1; + hvcc[16] = 0xf1; // reserved bits + 4:2:0 chroma_format_idc + hvcc[17] = 0xf8 | depth.saturating_sub(8); + hvcc[18] = hvcc[17]; + hvcc[21] = 0xff; // four-byte NAL length fields + hvcc[22] = 3; + for kind in 32..=34 { + let matching: Vec<_> = parameter_sets + .iter() + .filter(|(found, _)| *found == kind) + .map(|(_, bytes)| *bytes) + .collect(); + hvcc.push(0x80 | kind); + hvcc.extend_from_slice(&(matching.len() as u16).to_be_bytes()); + for unit in matching { + hvcc.extend_from_slice(&(unit.len() as u16).to_be_bytes()); + hvcc.extend_from_slice(unit); + } + } + (hvcc, payload) +} + +fn annex_b_units(stream: &[u8]) -> Vec<&[u8]> { + fn start_code(data: &[u8], from: usize) -> Option<(usize, usize)> { + (from..data.len().saturating_sub(2)).find_map(|index| { + if data[index..].starts_with(&[0, 0, 0, 1]) { + Some((index, 4)) + } else if data[index..].starts_with(&[0, 0, 1]) { + Some((index, 3)) + } else { + None + } + }) + } + let mut units = Vec::new(); + let Some((first, first_prefix)) = start_code(stream, 0) else { + return units; + }; + let mut begin = first + first_prefix; + loop { + match start_code(stream, begin) { + Some((next, next_prefix)) => { + if next > begin { + units.push(&stream[begin..next]); + } + begin = next + next_prefix; + } + None => { + if begin < stream.len() { + units.push(&stream[begin..]); + } + break; + } + } + } + units +} + +fn package_av1(stream: &[u8], depth: u8) -> (Vec, Vec) { + let mut config_obus = Vec::new(); + let mut payload = Vec::new(); + let mut offset = 0; + while offset < stream.len() { + let begin = offset; + let header = stream[offset]; + offset += 1; + assert_eq!(header & 0x80, 0, "AV1 forbidden bit"); + let kind = (header >> 3) & 0x0f; + if header & 0x04 != 0 { + offset += 1; + } + assert_ne!(header & 0x02, 0, "fixture OBU needs an explicit size"); + let (size, leb_bytes) = read_leb128(&stream[offset..]); + offset += leb_bytes; + offset += size; + assert!(offset <= stream.len(), "truncated AV1 OBU"); + if kind == 1 { + config_obus.extend_from_slice(&stream[begin..offset]); + } else { + payload.extend_from_slice(&stream[begin..offset]); + } + } + assert!(!config_obus.is_empty(), "no AV1 sequence header"); + assert!(!payload.is_empty(), "no AV1 coded frame"); + let high_bitdepth = if depth == 10 { 0x40 } else { 0 }; + // Profile 0, level 0, 4:2:0, no initial presentation delay. The sequence + // header remains authoritative, but these av1C summary bits must agree. + let mut av1c = vec![0x81, 0, high_bitdepth | 0x0c, 0]; + av1c.extend_from_slice(&config_obus); + (av1c, payload) +} + +fn read_leb128(data: &[u8]) -> (usize, usize) { + let mut value = 0usize; + for (index, byte) in data.iter().copied().take(8).enumerate() { + value |= usize::from(byte & 0x7f) << (7 * index); + if byte & 0x80 == 0 { + return (value, index + 1); + } + } + panic!("invalid leb128") +} + +fn expected_frame(depth: u8, source: &[u8]) -> Vec { + let pixels = usize::from(WIDTH) * usize::from(HEIGHT); + match depth { + 8 => { + assert_eq!(source.len(), pixels * 3 / 2); + source.to_vec() + } + 10 => { + assert_eq!(source.len(), pixels * 3); + let (y, chroma) = source.split_at(pixels * 2); + let (u, v) = chroma.split_at(pixels / 2); + let mut output = Vec::with_capacity(source.len()); + for word in y.chunks_exact(2) { + output.extend_from_slice( + &(u16::from_le_bytes([word[0], word[1]]) << 6).to_le_bytes(), + ); + } + for (u, v) in u.chunks_exact(2).zip(v.chunks_exact(2)) { + output.extend_from_slice(&(u16::from_le_bytes([u[0], u[1]]) << 6).to_le_bytes()); + output.extend_from_slice(&(u16::from_le_bytes([v[0], v[1]]) << 6).to_le_bytes()); + } + output + } + _ => panic!("unsupported depth"), + } +} diff --git a/android/mediacodec-harness/gradle.properties b/android/mediacodec-harness/gradle.properties new file mode 100644 index 0000000..344ead5 --- /dev/null +++ b/android/mediacodec-harness/gradle.properties @@ -0,0 +1,2 @@ +android.useAndroidX=true +org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8 diff --git a/android/mediacodec-harness/gradle/wrapper/gradle-wrapper.jar b/android/mediacodec-harness/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..d997cfc Binary files /dev/null and b/android/mediacodec-harness/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android/mediacodec-harness/gradle/wrapper/gradle-wrapper.properties b/android/mediacodec-harness/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..8e61ef1 --- /dev/null +++ b/android/mediacodec-harness/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,8 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionSha256Sum=2ab2958f2a1e51120c326cad6f385153bb11ee93b3c216c5fccebfdfbb7ec6cb +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/android/mediacodec-harness/gradlew b/android/mediacodec-harness/gradlew new file mode 100755 index 0000000..4bb327e --- /dev/null +++ b/android/mediacodec-harness/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/b631911858264c0b6e4d6603d677ff5218766cee/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/android/mediacodec-harness/gradlew.bat b/android/mediacodec-harness/gradlew.bat new file mode 100644 index 0000000..bd8a8c0 --- /dev/null +++ b/android/mediacodec-harness/gradlew.bat @@ -0,0 +1,93 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS=-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/android/mediacodec-harness/mise.toml b/android/mediacodec-harness/mise.toml new file mode 100644 index 0000000..18090eb --- /dev/null +++ b/android/mediacodec-harness/mise.toml @@ -0,0 +1,2 @@ +[tools] +java = "17" diff --git a/android/mediacodec-harness/native/Cargo.lock b/android/mediacodec-harness/native/Cargo.lock new file mode 100644 index 0000000..afaf4e1 --- /dev/null +++ b/android/mediacodec-harness/native/Cargo.lock @@ -0,0 +1,493 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "gamut-color" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fde55ae76aee646990fd4f353574fbcfaf9f37a5f3e54ba0907c7d384936e56a" +dependencies = [ + "gamut-core", +] + +[[package]] +name = "gamut-core" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b17c79c8672b675d538dceffda1dd5d00ba279cbcca4f75c7b4fa847741d5d31" +dependencies = [ + "thiserror 2.0.19", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.19", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rawshift-hwdec" +version = "0.1.1" +dependencies = [ + "gamut-color", + "jni", + "libloading", + "ndk", + "thiserror 2.0.19", + "tracing", +] + +[[package]] +name = "rawshift-mediacodec-harness-native" +version = "0.0.0" +dependencies = [ + "jni", + "rawshift-hwdec", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +dependencies = [ + "thiserror-impl 2.0.19", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] diff --git a/android/mediacodec-harness/native/Cargo.toml b/android/mediacodec-harness/native/Cargo.toml new file mode 100644 index 0000000..b2cb6a2 --- /dev/null +++ b/android/mediacodec-harness/native/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "rawshift-mediacodec-harness-native" +version = "0.0.0" +edition = "2024" +publish = false + +[lib] +crate-type = ["cdylib"] + +[target.'cfg(target_os = "android")'.dependencies] +jni = "0.22.4" +rawshift-hwdec = { path = "../../../crates/rawshift-hwdec", features = ["mediacodec"] } + +[workspace] diff --git a/android/mediacodec-harness/native/src/lib.rs b/android/mediacodec-harness/native/src/lib.rs new file mode 100644 index 0000000..0a66eba --- /dev/null +++ b/android/mediacodec-harness/native/src/lib.rs @@ -0,0 +1,224 @@ +#![cfg_attr(not(target_os = "android"), allow(dead_code))] + +#[cfg(target_os = "android")] +mod android { + use std::time::Instant; + + use jni::errors::ThrowRuntimeExAndDefault; + use jni::objects::{JClass, JString}; + use jni::{Env, EnvUnowned}; + use rawshift_hwdec::{ + ChromaSubsampling, CodecConfig, HwCodec, HwDecodeError, PixelFormat, StillDecodeRequest, + available_codecs, backend, decoder, initialize_android_hw_decode, + }; + + const HEVC_8: &[u8] = include_bytes!("../../fixtures/data/hevc-8.rshw"); + const HEVC_10: &[u8] = include_bytes!("../../fixtures/data/hevc-10.rshw"); + const AV1_8: &[u8] = include_bytes!("../../fixtures/data/av1-8.rshw"); + const AV1_10: &[u8] = include_bytes!("../../fixtures/data/av1-10.rshw"); + + struct Fixture<'a> { + codec: HwCodec, + depth: u8, + width: u32, + height: u32, + config: &'a [u8], + payload: &'a [u8], + expected: &'a [u8], + } + + enum Outcome { + Pass(String), + Unavailable(String), + } + + #[unsafe(no_mangle)] + pub extern "system" fn Java_org_visualcommons_rawshift_harness_NativeHarness_runSuite< + 'local, + >( + mut unowned_env: EnvUnowned<'local>, + _class: JClass<'local>, + sdk: i32, + ) -> JString<'local> { + unowned_env + .with_env(|env| -> jni::errors::Result<_> { + let report = run_suite(env, sdk).unwrap_or_else(|error| format!("FAIL\n{error}")); + JString::from_str(env, report) + }) + .resolve::() + } + + fn run_suite(env: &Env<'_>, sdk: i32) -> Result { + initialize_android_hw_decode(env.get_java_vm().map_err(|e| e.to_string())?) + .map_err(|e| e.to_string())?; + // Prove same-VM initialization is idempotent, not merely documented. + initialize_android_hw_decode(env.get_java_vm().map_err(|e| e.to_string())?) + .map_err(|e| e.to_string())?; + let found = available_codecs(); + for codec in [HwCodec::Hevc, HwCodec::Av1] { + if !found.contains(&codec) { + return Err(format!("no hardware {codec} decoder was discovered")); + } + } + + let mut lines = vec![format!( + "PASS sdk={sdk} backend={} codecs={found:?}", + backend().ok_or_else(|| "backend() returned None after discovery".to_string())? + )]; + for bytes in [HEVC_8, AV1_8] { + match run_fixture(bytes)? { + Outcome::Pass(line) => lines.push(line), + Outcome::Unavailable(reason) => { + return Err(format!("required 8-bit fixture unavailable: {reason}")); + } + } + } + + if sdk >= 33 { + let mut p010_passes = 0; + for bytes in [HEVC_10, AV1_10] { + match run_fixture(bytes)? { + Outcome::Pass(line) => { + p010_passes += 1; + lines.push(line); + } + Outcome::Unavailable(reason) => lines.push(format!("SKIP p010 {reason}")), + } + } + if p010_passes == 0 { + return Err("API 33+ device exposed no working hardware P010 path".to_string()); + } + } else { + lines.push("SKIP p010 requires API 33+".to_string()); + } + Ok(lines.join("\n")) + } + + fn run_fixture(bytes: &[u8]) -> Result { + let fixture = fixture(bytes)?; + let request = StillDecodeRequest { + config: match fixture.codec { + HwCodec::Hevc => CodecConfig::Hvcc(fixture.config), + HwCodec::Av1 => CodecConfig::Av1c(fixture.config), + }, + payload: fixture.payload, + width: fixture.width, + height: fixture.height, + bit_depth: fixture.depth, + chroma: ChromaSubsampling::Cs420, + }; + let Some(mut decoder) = decoder(fixture.codec) else { + return Ok(Outcome::Unavailable(format!( + "{} {}-bit decoder() returned None", + fixture.codec, fixture.depth + ))); + }; + + let cold_start = Instant::now(); + let cold = match decoder.decode_still(&request) { + Ok(frame) => frame, + Err(HwDecodeError::Unavailable { reason, .. }) => { + return Ok(Outcome::Unavailable(format!( + "{} {}-bit: {reason}", + fixture.codec, fixture.depth + ))); + } + Err(error) => return Err(error.to_string()), + }; + let cold_us = cold_start.elapsed().as_micros(); + verify_frame(&fixture, &cold)?; + + // The second request is byte-for-byte identical and therefore must + // exercise the exact-format session reuse path. + let warm_start = Instant::now(); + let warm = decoder.decode_still(&request).map_err(|e| e.to_string())?; + let warm_us = warm_start.elapsed().as_micros(); + verify_frame(&fixture, &warm)?; + Ok(Outcome::Pass(format!( + "{} {}-bit cold_us={cold_us} warm_us={warm_us}", + fixture.codec, fixture.depth + ))) + } + + fn verify_frame( + fixture: &Fixture<'_>, + frame: &rawshift_hwdec::DecodedFrame, + ) -> Result<(), String> { + if (frame.width(), frame.height(), frame.bit_depth()) + != (fixture.width, fixture.height, fixture.depth) + { + return Err(format!( + "{} output geometry/depth mismatch: {}x{} {}-bit", + fixture.codec, + frame.width(), + frame.height(), + frame.bit_depth() + )); + } + let expected_format = if fixture.depth == 8 { + PixelFormat::I420 + } else { + PixelFormat::P010 + }; + if frame.format() != expected_format { + return Err(format!( + "{} expected {expected_format:?}, got {:?}", + fixture.codec, + frame.format() + )); + } + let actual: Vec<_> = frame + .planes() + .iter() + .flat_map(|plane| plane.data.iter().copied()) + .collect(); + if actual != fixture.expected { + return Err(format!( + "{} {}-bit decoded pixels differ (expected fnv={:016x}, actual fnv={:016x})", + fixture.codec, + fixture.depth, + fnv1a(fixture.expected), + fnv1a(&actual) + )); + } + Ok(()) + } + + fn fnv1a(bytes: &[u8]) -> u64 { + bytes.iter().fold(0xcbf2_9ce4_8422_2325, |hash, byte| { + (hash ^ u64::from(*byte)).wrapping_mul(0x0000_0100_0000_01b3) + }) + } + + fn fixture(bytes: &[u8]) -> Result, String> { + if bytes.get(..8) != Some(b"RSHW0001") { + return Err("bad fixture magic".to_string()); + } + let codec = match bytes[8] { + 1 => HwCodec::Hevc, + 2 => HwCodec::Av1, + value => return Err(format!("bad fixture codec {value}")), + }; + let depth = bytes[9]; + let width = u32::from(u16::from_le_bytes(bytes[10..12].try_into().unwrap())); + let height = u32::from(u16::from_le_bytes(bytes[12..14].try_into().unwrap())); + let config_len = u32::from_le_bytes(bytes[14..18].try_into().unwrap()) as usize; + let payload_len = u32::from_le_bytes(bytes[18..22].try_into().unwrap()) as usize; + let expected_len = u32::from_le_bytes(bytes[22..26].try_into().unwrap()) as usize; + let config_end = 26 + config_len; + let payload_end = config_end + payload_len; + let expected_end = payload_end + expected_len; + if expected_end != bytes.len() { + return Err("bad fixture lengths".to_string()); + } + Ok(Fixture { + codec, + depth, + width, + height, + config: &bytes[26..config_end], + payload: &bytes[config_end..payload_end], + expected: &bytes[payload_end..expected_end], + }) + } +} diff --git a/android/mediacodec-harness/settings.gradle.kts b/android/mediacodec-harness/settings.gradle.kts new file mode 100644 index 0000000..e356950 --- /dev/null +++ b/android/mediacodec-harness/settings.gradle.kts @@ -0,0 +1,18 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "rawshift-mediacodec-harness" +include(":app") diff --git a/crates/rawshift-hwdec/Cargo.toml b/crates/rawshift-hwdec/Cargo.toml index c24b58e..45038ec 100644 --- a/crates/rawshift-hwdec/Cargo.toml +++ b/crates/rawshift-hwdec/Cargo.toml @@ -30,6 +30,11 @@ thiserror = { workspace = true } [target.'cfg(target_os = "linux")'.dependencies] libloading = "0.8" +[target.'cfg(target_os = "android")'.dependencies] +jni = "0.22.4" +ndk = { version = "0.9", default-features = false, features = ["media", "api-level-29"] } +tracing = { workspace = true } + [features] # Verified backend feature flags — see docs/SUPPORT.md for the permanent # target/API matrix. Each explicit backend flag hard-fails the compile @@ -39,9 +44,10 @@ libloading = "0.8" # (windows-msvc, linux-musl, wasm) emits a build-script warning and compiles # the no-backend stub. # -# Backends implemented: VAAPI (linux-gnu; dlopen'd libva, HEVC Main/Main10 + -# AV1 Profile 0 still pictures). VideoToolbox / MediaCodec land as separate -# issues; builds without a selected backend compile the no-backend stub — +# Backends implemented: VAAPI (linux-gnu; dlopen'd libva) and MediaCodec +# (Android API 29+, explicitly initialized through JNI), both for HEVC +# Main/Main10 + AV1 Profile 0 still pictures. VideoToolbox lands separately; +# builds without a selected backend compile the no-backend stub — # `decoder()` returns `None`, `backend()` returns `None`, and # `available_codecs()` is empty. videotoolbox = [] diff --git a/crates/rawshift-hwdec/README.md b/crates/rawshift-hwdec/README.md index 2098724..a8dd48e 100644 --- a/crates/rawshift-hwdec/README.md +++ b/crates/rawshift-hwdec/README.md @@ -8,8 +8,8 @@ MediaCodec (Android). This is the **only** crate in the workspace where platform FFI may live (`#![deny(unsafe_op_in_unsafe_fn)]`, safe public items, documented invariants -on every unsafe block). The **VAAPI backend is implemented**; VideoToolbox -and MediaCodec land as separate issues. On builds/targets with no backend the +on every unsafe block). The **VAAPI and Android MediaCodec backends are +implemented**; VideoToolbox lands separately. On builds/targets with no backend the crate compiles a no-backend stub: `decoder()` returns `None`, `backend()` returns `None`, `available_codecs()` is empty, and dependants surface `HwDecoderUnavailable`. @@ -40,11 +40,29 @@ picks it up through the same dlopen path with no rawshift changes. This is why rawshift has no separate NVDEC backend; see the permanent matrix and justification in [`docs/SUPPORT.md`](../../docs/SUPPORT.md). +## MediaCodec backend (Android API 29+) + +Call `initialize_android_hw_decode(JavaVM)` once before capability queries or +decode. Initialization is idempotent for the same VM, rejects another VM, and +can be retried after a JNI discovery failure. The API is also re-exported by +`rawshift-image` and the `rawshift` facade on Android hardware builds. + +The backend uses Java `MediaCodecList` only to select hardware, non-alias +components, then performs decode with NDK `AMediaCodec` into an `AImageReader` +surface. It tries every eligible component within one five-second deadline, +keeps NDK handles on a dedicated worker, and reuses a session only when the +codec configuration, authoritative sequence header, dimensions, and bit depth +are identical. Output is dense I420 at 8-bit and strict P010 at 9/10-bit; +P010 requires Android API 33+ and is never silently downconverted. + +The reproducible physical-device gate and lossless fixtures live in +[`android/mediacodec-harness`](../../android/mediacodec-harness/README.md). + ## Feature flags (verified) | Feature | Meaning | | --- | --- | -| `hw` | Portable: select the native backend for the compile target (VAAPI on linux-gnu; build-script warning + stub on targets with no hardware decode API). | +| `hw` | Portable: select the native backend for the compile target (VAAPI on linux-gnu, MediaCodec on Android; build-script warning + stub on targets with no hardware decode API). | | `videotoolbox` | Pin VideoToolbox; `compile_error!` on non-Apple targets. | | `vaapi` | Pin VAAPI; `compile_error!` off linux-gnu. | | `mediacodec` | Pin MediaCodec; `compile_error!` off Android. | diff --git a/crates/rawshift-hwdec/src/vaapi/av1.rs b/crates/rawshift-hwdec/src/bitstream/av1.rs similarity index 99% rename from crates/rawshift-hwdec/src/vaapi/av1.rs rename to crates/rawshift-hwdec/src/bitstream/av1.rs index 66b6d96..89ea799 100644 --- a/crates/rawshift-hwdec/src/vaapi/av1.rs +++ b/crates/rawshift-hwdec/src/bitstream/av1.rs @@ -1,5 +1,6 @@ -//! AV1 still-picture parsing for the VAAPI backend — **safe Rust only** -//! (no `unsafe`; FFI stays in `sys.rs`/`mod.rs`). +#![cfg_attr(not(hwdec_backend = "vaapi"), allow(dead_code))] +//! AV1 still-picture parsing shared by hardware backends — **safe Rust only** +//! (no `unsafe`; platform FFI stays in backend modules). //! //! ## Scope //! @@ -16,7 +17,8 @@ //! `VASliceParameterBufferAV1` per tile. use super::bits::{BitReader, PResult, ParseError, clip3}; -use super::sys; +#[cfg(hwdec_backend = "vaapi")] +use crate::vaapi::sys; // ── OBU framing (§5.3) ────────────────────────────────────────────────────── @@ -1144,6 +1146,7 @@ pub fn parse_still_picture(config_obus: &[u8], payload: &[u8]) -> PResult sys::VASliceParameterBufferAV1 { sys::VASliceParameterBufferAV1 { slice_data_size: tile.data.len() as u32, @@ -1517,6 +1521,7 @@ mod tests { } #[test] + #[cfg(hwdec_backend = "vaapi")] fn pic_param_maps_seq_and_frame_fields() { let pic = parse_still_picture(&[], AV1_64X64_LIBAOM).unwrap(); let p = build_pic_param(&pic, 5, sys::VA_INVALID_SURFACE).unwrap(); diff --git a/crates/rawshift-hwdec/src/vaapi/bits.rs b/crates/rawshift-hwdec/src/bitstream/bits.rs similarity index 98% rename from crates/rawshift-hwdec/src/vaapi/bits.rs rename to crates/rawshift-hwdec/src/bitstream/bits.rs index 4e34c14..fb67c6b 100644 --- a/crates/rawshift-hwdec/src/vaapi/bits.rs +++ b/crates/rawshift-hwdec/src/bitstream/bits.rs @@ -1,4 +1,5 @@ -//! Safe bitstream readers shared by the HEVC and AV1 header parsers. +#![cfg_attr(not(hwdec_backend = "vaapi"), allow(dead_code))] +//! Safe bitstream readers shared by the HEVC and AV1 still-picture parsers. //! //! **Safe Rust only** — this module (like `hevc.rs` / `av1.rs`) contains no //! `unsafe`; all FFI stays in `sys.rs` and the call sites in `mod.rs`. diff --git a/crates/rawshift-hwdec/src/vaapi/hevc.rs b/crates/rawshift-hwdec/src/bitstream/hevc.rs similarity index 98% rename from crates/rawshift-hwdec/src/vaapi/hevc.rs rename to crates/rawshift-hwdec/src/bitstream/hevc.rs index f9df68c..97c1d66 100644 --- a/crates/rawshift-hwdec/src/vaapi/hevc.rs +++ b/crates/rawshift-hwdec/src/bitstream/hevc.rs @@ -1,5 +1,6 @@ -//! HEVC still-picture header parsing for the VAAPI backend — **safe Rust -//! only** (no `unsafe`; FFI stays in `sys.rs`/`mod.rs`). +#![cfg_attr(not(hwdec_backend = "vaapi"), allow(dead_code))] +//! HEVC still-picture header parsing shared by hardware backends — **safe +//! Rust only** (no `unsafe`; platform FFI stays in backend modules). //! //! ## Scope //! @@ -23,7 +24,8 @@ //! `slice_data()`) and the emulation-prevention-byte count VAAPI wants. use super::bits::{BitReader, PResult, ParseError, Rbsp, rbsp_from_nal_payload}; -use super::sys; +#[cfg(hwdec_backend = "vaapi")] +use crate::vaapi::sys; // ── NAL classification ────────────────────────────────────────────────────── @@ -743,6 +745,7 @@ fn ceil_log2(x: u32) -> u32 { /// Uniform tile partition of `total` CTBs into `count` parts, as the /// `minus1` sizes VAAPI wants (H.265 §6.5.1 derivation). +#[cfg(hwdec_backend = "vaapi")] fn uniform_partition_minus1(total: u32, count: u32) -> Vec { (0..count) .map(|i| (((i + 1) * total) / count - (i * total) / count - 1) as u16) @@ -752,6 +755,7 @@ fn uniform_partition_minus1(total: u32, count: u32) -> Vec { /// Fill `VAPictureParameterBufferHEVC` for a still picture decoded into /// `surface`. `nut` is the slice NAL type; `st_rps_bits` comes from the /// first independent slice header. +#[cfg(hwdec_backend = "vaapi")] pub fn build_pic_param( sps: &Sps, pps: &Pps, @@ -894,6 +898,7 @@ pub fn build_pic_param( /// Fill `VASliceParameterBufferHEVC` for one coded slice NAL of /// `slice_data_size` bytes at offset 0 of its own data buffer. +#[cfg(hwdec_backend = "vaapi")] pub fn build_slice_param( sh: &SliceHeader, pps: &Pps, @@ -1020,6 +1025,7 @@ mod tests { } #[test] + #[cfg(hwdec_backend = "vaapi")] fn pic_param_packs_bitfields() { let sps = parse_sps(SPS_64X64_X265).unwrap(); let pps = parse_pps(PPS_64X64_X265).unwrap(); @@ -1045,6 +1051,7 @@ mod tests { } #[test] + #[cfg(hwdec_backend = "vaapi")] fn uniform_tile_partition_covers_exactly() { // 10 CTBs into 3 columns: 3+3+4 (spec derivation gives 3,3,4). let parts = uniform_partition_minus1(10, 3); @@ -1053,6 +1060,7 @@ mod tests { } #[test] + #[cfg(hwdec_backend = "vaapi")] fn slice_param_marks_last_slice_and_i_type() { let sh = SliceHeader { slice_type: 2, diff --git a/crates/rawshift-hwdec/src/bitstream/mod.rs b/crates/rawshift-hwdec/src/bitstream/mod.rs new file mode 100644 index 0000000..548f1b4 --- /dev/null +++ b/crates/rawshift-hwdec/src/bitstream/mod.rs @@ -0,0 +1,5 @@ +//! Safe parsing and framing shared by the platform hardware-decode backends. + +pub(crate) mod av1; +pub(crate) mod bits; +pub(crate) mod hevc; diff --git a/crates/rawshift-hwdec/src/lib.rs b/crates/rawshift-hwdec/src/lib.rs index 5d77541..f95ce4c 100644 --- a/crates/rawshift-hwdec/src/lib.rs +++ b/crates/rawshift-hwdec/src/lib.rs @@ -11,8 +11,9 @@ //! crate and nowhere else: `#![deny(unsafe_op_in_unsafe_fn)]`, every public //! item is safe, and every `unsafe` block documents its invariants inside the //! platform backend module that owns it. The **VAAPI backend** (linux-gnu, -//! dlopen'd libva — see the `vaapi` module) is implemented; VideoToolbox and -//! MediaCodec land as separate issues. On targets/builds with no backend +//! dlopen'd libva — see the `vaapi` module) and **MediaCodec backend** +//! (Android API 29+, explicitly initialized from the process VM) are +//! implemented; VideoToolbox lands separately. On targets/builds with no backend //! every entry point reports "no decoder" — [`decoder`] returns `None`, //! [`backend`] returns `None`, and [`available_codecs`] is empty. //! @@ -48,6 +49,12 @@ #![deny(unsafe_op_in_unsafe_fn)] +#[cfg(any(hwdec_backend = "vaapi", hwdec_backend = "mediacodec", test))] +mod bitstream; + +#[cfg(any(hwdec_backend = "mediacodec", test))] +mod mediacodec; + // The VAAPI platform backend: compiled only when build.rs selected it // (`vaapi` explicit flag, or `hw` on a linux-gnu target). #[cfg(hwdec_backend = "vaapi")] @@ -87,6 +94,48 @@ use thiserror::Error; pub use gamut_color::{ChromaSubsampling, ColorRange}; +/// The process Java VM used to discover Android codecs. +/// +/// This is re-exported so Android applications do not need to align a second +/// `jni` crate version merely to initialize hardware decode. +#[cfg(hwdec_backend = "mediacodec")] +pub use jni::JavaVM as AndroidJavaVm; + +/// Errors from [`initialize_android_hw_decode`]. +#[cfg(hwdec_backend = "mediacodec")] +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum AndroidHwDecodeInitError { + /// Initialization already succeeded with another VM in this process. + #[error("Android hardware decode is already initialized with a different Java VM")] + DifferentJavaVm, + /// JNI could not enumerate the platform's hardware codecs. + #[error("Android MediaCodec initialization failed during {operation}: {message}")] + Jni { + /// The discovery operation that failed. + operation: &'static str, + /// The JNI error or Java exception summary. + message: String, + }, +} + +/// Initializes Android hardware decode from the application's process VM. +/// +/// Call this once from an Android/JNI entry point before querying +/// [`available_codecs`] or decoding HEIC/AVIF pixels. Repeating the call with +/// the same VM is harmless. A failed attempt does not latch state and may be +/// retried; a successful initialization cannot be replaced with another VM. +/// +/// # Errors +/// +/// Returns [`AndroidHwDecodeInitError::DifferentJavaVm`] if a different VM +/// was already installed, or [`AndroidHwDecodeInitError::Jni`] when codec +/// discovery fails. +#[cfg(hwdec_backend = "mediacodec")] +pub fn initialize_android_hw_decode(vm: AndroidJavaVm) -> Result<(), AndroidHwDecodeInitError> { + mediacodec::initialize(vm) +} + // ── Codec / backend identity ──────────────────────────────────────────────── /// A codec this crate can decode still frames of. @@ -479,15 +528,19 @@ pub enum HwDecodeError { /// dlopens libva on first use and answers from the driver's actual /// profile/entrypoint list; missing libraries, render nodes, or driver /// support all degrade to `None` (never a link or startup failure). -/// VideoToolbox and MediaCodec land as separate issues; without a backend -/// this returns `None` everywhere. +/// Android MediaCodec additionally requires an explicit successful +/// [`initialize_android_hw_decode`] call before this can return a decoder. #[must_use] pub fn decoder(codec: HwCodec) -> Option> { #[cfg(hwdec_backend = "vaapi")] { vaapi::decoder(codec) } - #[cfg(not(hwdec_backend = "vaapi"))] + #[cfg(hwdec_backend = "mediacodec")] + { + mediacodec::decoder(codec) + } + #[cfg(not(any(hwdec_backend = "vaapi", hwdec_backend = "mediacodec")))] { let _ = codec; None @@ -506,7 +559,11 @@ pub fn backend() -> Option { { vaapi::backend() } - #[cfg(not(hwdec_backend = "vaapi"))] + #[cfg(hwdec_backend = "mediacodec")] + { + mediacodec::backend() + } + #[cfg(not(any(hwdec_backend = "vaapi", hwdec_backend = "mediacodec")))] { None } @@ -522,7 +579,11 @@ pub fn available_codecs() -> &'static [HwCodec] { { vaapi::available_codecs() } - #[cfg(not(hwdec_backend = "vaapi"))] + #[cfg(hwdec_backend = "mediacodec")] + { + mediacodec::available_codecs() + } + #[cfg(not(any(hwdec_backend = "vaapi", hwdec_backend = "mediacodec")))] { &[] } @@ -534,14 +595,14 @@ mod tests { // ── stub behaviour (builds with no selected backend) ──────────────────── - #[cfg(not(hwdec_backend = "vaapi"))] + #[cfg(not(any(hwdec_backend = "vaapi", hwdec_backend = "mediacodec")))] #[test] fn stub_has_no_decoder_for_any_codec() { assert!(decoder(HwCodec::Hevc).is_none()); assert!(decoder(HwCodec::Av1).is_none()); } - #[cfg(not(hwdec_backend = "vaapi"))] + #[cfg(not(any(hwdec_backend = "vaapi", hwdec_backend = "mediacodec")))] #[test] fn stub_reports_no_backend_and_no_codecs() { assert_eq!(backend(), None); diff --git a/crates/rawshift-hwdec/src/mediacodec/core.rs b/crates/rawshift-hwdec/src/mediacodec/core.rs new file mode 100644 index 0000000..f9a533e --- /dev/null +++ b/crates/rawshift-hwdec/src/mediacodec/core.rs @@ -0,0 +1,644 @@ +//! Platform-independent MediaCodec request preparation and image-plane copy. + +#![cfg_attr(not(hwdec_backend = "mediacodec"), allow(dead_code))] + +use crate::bitstream::{av1, bits::ParseError, hevc}; +use crate::{ + CodecConfig, ColorRange, DecodedFrame, HwCodec, HwDecodeError, PixelFormat, Plane, + StillDecodeRequest, +}; + +pub(super) const MIME_HEVC: &str = "video/hevc"; +pub(super) const MIME_AV1: &str = "video/av01"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct SessionKey { + pub codec: HwCodec, + pub csd0: Vec, + pub sequence_header: Vec, + pub width: u32, + pub height: u32, + pub bit_depth: u8, +} + +#[derive(Debug, Clone)] +pub(super) struct PreparedRequest { + pub codec: HwCodec, + pub mime: &'static str, + pub csd0: Vec, + pub access_unit: Vec, + pub width: u32, + pub height: u32, + pub bit_depth: u8, + pub range: ColorRange, + pub key: SessionKey, +} + +fn parse_error(codec: HwCodec, error: ParseError) -> HwDecodeError { + HwDecodeError::Decode { + codec, + message: error.0.to_string(), + } +} + +pub(super) fn prepare(request: &StillDecodeRequest<'_>) -> Result { + match request.config { + CodecConfig::Hvcc(hvcc) => prepare_hevc(hvcc, request.payload), + CodecConfig::Av1c(av1c) => prepare_av1(av1c, request.payload), + } +} + +fn prepare_hevc(hvcc_bytes: &[u8], payload: &[u8]) -> Result { + const NAL_VPS: u8 = 32; + let codec = HwCodec::Hevc; + let hvcc = hevc::parse_hvcc(hvcc_bytes).map_err(|e| parse_error(codec, e))?; + let payload_nals = hevc::split_length_prefixed(payload, hvcc.nal_length_size) + .map_err(|e| parse_error(codec, e))?; + + let mut sps = None; + let mut has_pps = false; + let mut has_vps = false; + let mut has_irap = false; + let mut csd0 = Vec::new(); + for nal in hvcc + .nal_units + .iter() + .map(Vec::as_slice) + .chain(payload_nals.iter().copied()) + { + let kind = hevc::nal_type(nal).map_err(|e| parse_error(codec, e))?; + match kind { + NAL_VPS => has_vps = true, + hevc::NAL_SPS => { + sps = Some(hevc::parse_sps(nal).map_err(|e| parse_error(codec, e))?); + } + hevc::NAL_PPS => { + hevc::parse_pps(nal).map_err(|e| parse_error(codec, e))?; + has_pps = true; + } + kind if hevc::is_irap(kind) => has_irap = true, + kind if kind < 32 => { + return Err(HwDecodeError::Decode { + codec, + message: "HEVC non-IRAP coded slice is outside the still-picture scope" + .to_string(), + }); + } + _ => {} + } + if matches!(kind, NAL_VPS | hevc::NAL_SPS | hevc::NAL_PPS) { + csd0.extend_from_slice(&[0, 0, 0, 1]); + csd0.extend_from_slice(nal); + } + } + let sps = sps.ok_or_else(|| HwDecodeError::Decode { + codec, + message: "HEVC stream carries no SPS".to_string(), + })?; + if !has_vps || !has_pps || !has_irap { + let missing = if !has_vps { + "VPS" + } else if !has_pps { + "PPS" + } else { + "IRAP coded slice" + }; + return Err(HwDecodeError::Decode { + codec, + message: format!("HEVC stream carries no {missing}"), + }); + } + if sps.chroma_format_idc > 1 { + return Err(HwDecodeError::Decode { + codec, + message: "HEVC 4:2:2/4:4:4 is outside the Main/Main10 scope".to_string(), + }); + } + let bit_depth = sps.bit_depth_luma.max(sps.bit_depth_chroma); + if !matches!(bit_depth, 8..=10) { + return Err(HwDecodeError::Decode { + codec, + message: "HEVC bit depth is outside the Main/Main10 scope".to_string(), + }); + } + let (width, height) = sps.cropped_size(); + if width == 0 || height == 0 { + return Err(HwDecodeError::Decode { + codec, + message: "HEVC conformance window crops away the frame".to_string(), + }); + } + let mut access_unit = Vec::with_capacity(payload.len() + payload_nals.len() * 4); + for nal in payload_nals { + access_unit.extend_from_slice(&[0, 0, 0, 1]); + access_unit.extend_from_slice(nal); + } + let range = if sps.video_full_range { + ColorRange::Full + } else { + ColorRange::Limited + }; + let key = SessionKey { + codec, + csd0: csd0.clone(), + sequence_header: csd0.clone(), + width, + height, + bit_depth, + }; + Ok(PreparedRequest { + codec, + mime: MIME_HEVC, + csd0, + access_unit, + width, + height, + bit_depth, + range, + key, + }) +} + +fn prepare_av1(av1c_bytes: &[u8], payload: &[u8]) -> Result { + let codec = HwCodec::Av1; + let av1c = av1::parse_av1c(av1c_bytes).map_err(|e| parse_error(codec, e))?; + if av1c.seq_profile != 0 { + return Err(HwDecodeError::Decode { + codec, + message: "AV1 profile 1/2 is outside the Profile 0 scope".to_string(), + }); + } + let picture = + av1::parse_still_picture(av1c.config_obus, payload).map_err(|e| parse_error(codec, e))?; + if !(picture.seq.mono_chrome || picture.seq.subsampling_x && picture.seq.subsampling_y) { + return Err(HwDecodeError::Decode { + codec, + message: "AV1 4:2:2/4:4:4 is outside the Profile 0 scope".to_string(), + }); + } + if !matches!(picture.seq.bit_depth, 8 | 10) { + return Err(HwDecodeError::Decode { + codec, + message: "AV1 bit depth is outside 8/10".to_string(), + }); + } + let width = picture.fh.upscaled_width; + let height = picture.fh.frame_height; + if width == 0 || height == 0 { + return Err(HwDecodeError::Decode { + codec, + message: "AV1 frame dimensions are zero".to_string(), + }); + } + let sequence_header = av1::split_obus(av1c.config_obus) + .map_err(|e| parse_error(codec, e))? + .into_iter() + .chain(av1::split_obus(payload).map_err(|e| parse_error(codec, e))?) + .find(|obu| obu.obu_type == av1::OBU_SEQUENCE_HEADER) + .map(|obu| obu.payload.to_vec()) + .ok_or_else(|| HwDecodeError::Decode { + codec, + message: "AV1 stream carries no sequence header".to_string(), + })?; + let range = if picture.seq.color_range_full { + ColorRange::Full + } else { + ColorRange::Limited + }; + // Android's MediaCodec contract defines AV1 csd-0 as the complete + // AV1CodecConfigurationRecord (`av1C`) data. + let csd0 = av1c_bytes.to_vec(); + let key = SessionKey { + codec, + csd0: csd0.clone(), + sequence_header, + width, + height, + bit_depth: picture.seq.bit_depth, + }; + Ok(PreparedRequest { + codec, + mime: MIME_AV1, + csd0, + access_unit: payload.to_vec(), + width, + height, + bit_depth: picture.seq.bit_depth, + range, + key, + }) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum ImageLayout { + Yuv420, + P010, +} + +#[derive(Debug, Clone, Copy)] +pub(super) struct PlaneView<'a> { + pub data: &'a [u8], + pub row_stride: usize, + pub pixel_stride: usize, +} + +#[derive(Debug)] +pub(super) struct ImageView<'a> { + pub layout: ImageLayout, + pub width: u32, + pub height: u32, + pub crop: (u32, u32, u32, u32), + pub planes: Vec>, +} + +fn copy_samples( + plane: PlaneView<'_>, + x: usize, + y: usize, + width: usize, + height: usize, + sample_bytes: usize, +) -> Option> { + if plane.pixel_stride < sample_bytes || plane.row_stride == 0 { + return None; + } + let capacity = width.checked_mul(height)?.checked_mul(sample_bytes)?; + let mut out = Vec::with_capacity(capacity); + for row in 0..height { + for column in 0..width { + let start = (y + row) + .checked_mul(plane.row_stride)? + .checked_add((x + column).checked_mul(plane.pixel_stride)?)?; + out.extend_from_slice(plane.data.get(start..start + sample_bytes)?); + } + } + Some(out) +} + +pub(super) fn normalize_image( + image: &ImageView<'_>, + expected_size: (u32, u32), + bit_depth: u8, + range: ColorRange, +) -> Result { + let invalid = |message: &str| HwDecodeError::InvalidFrame { + message: message.to_string(), + }; + let (left, top, right, bottom) = image.crop; + if left > right || top > bottom || right > image.width || bottom > image.height { + return Err(invalid("AImage crop rectangle is outside the image")); + } + let width = right - left; + let height = bottom - top; + if (width, height) != expected_size { + return Err(invalid( + "AImage crop dimensions differ from the coded picture", + )); + } + if left % 2 != 0 || top % 2 != 0 { + return Err(invalid("4:2:0 AImage crop origin must be even")); + } + let (w, h) = (width as usize, height as usize); + let (cw, ch) = (width.div_ceil(2) as usize, height.div_ceil(2) as usize); + match image.layout { + ImageLayout::Yuv420 => { + if bit_depth != 8 || image.planes.len() != 3 { + return Err(invalid("YUV_420_888 requires three 8-bit planes")); + } + let y = copy_samples(image.planes[0], left as usize, top as usize, w, h, 1) + .ok_or_else(|| invalid("AImage luma plane is too small"))?; + let u = copy_samples( + image.planes[1], + left as usize / 2, + top as usize / 2, + cw, + ch, + 1, + ) + .ok_or_else(|| invalid("AImage Cb plane is too small"))?; + let v = copy_samples( + image.planes[2], + left as usize / 2, + top as usize / 2, + cw, + ch, + 1, + ) + .ok_or_else(|| invalid("AImage Cr plane is too small"))?; + DecodedFrame::new( + PixelFormat::I420, + width, + height, + bit_depth, + range, + vec![ + Plane { data: y, stride: w }, + Plane { + data: u, + stride: cw, + }, + Plane { + data: v, + stride: cw, + }, + ], + ) + } + ImageLayout::P010 => { + if bit_depth <= 8 || !matches!(image.planes.len(), 2 | 3) { + return Err(invalid("P010 requires two or three high-bit-depth planes")); + } + let y = copy_samples(image.planes[0], left as usize, top as usize, w, h, 2) + .ok_or_else(|| invalid("AImage P010 luma plane is too small"))?; + let chroma = if image.planes.len() == 2 { + copy_samples( + image.planes[1], + left as usize / 2, + top as usize / 2, + cw, + ch, + 4, + ) + .ok_or_else(|| invalid("AImage P010 chroma plane is too small"))? + } else { + let u = copy_samples( + image.planes[1], + left as usize / 2, + top as usize / 2, + cw, + ch, + 2, + ) + .ok_or_else(|| invalid("AImage P010 Cb plane is too small"))?; + let v = copy_samples( + image.planes[2], + left as usize / 2, + top as usize / 2, + cw, + ch, + 2, + ) + .ok_or_else(|| invalid("AImage P010 Cr plane is too small"))?; + let mut interleaved = Vec::with_capacity(cw * ch * 4); + for (u, v) in u.chunks_exact(2).zip(v.chunks_exact(2)) { + interleaved.extend_from_slice(u); + interleaved.extend_from_slice(v); + } + interleaved + }; + DecodedFrame::new( + PixelFormat::P010, + width, + height, + bit_depth, + range, + vec![ + Plane { + data: y, + stride: w * 2, + }, + Plane { + data: chroma, + stride: cw * 4, + }, + ], + ) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const HEVC_8: &[u8] = + include_bytes!("../../../../android/mediacodec-harness/fixtures/data/hevc-8.rshw"); + const HEVC_10: &[u8] = + include_bytes!("../../../../android/mediacodec-harness/fixtures/data/hevc-10.rshw"); + const AV1_8: &[u8] = + include_bytes!("../../../../android/mediacodec-harness/fixtures/data/av1-8.rshw"); + const AV1_10: &[u8] = + include_bytes!("../../../../android/mediacodec-harness/fixtures/data/av1-10.rshw"); + + struct Fixture<'a> { + codec: HwCodec, + depth: u8, + width: u32, + height: u32, + config: &'a [u8], + payload: &'a [u8], + expected: &'a [u8], + } + + fn fixture(bytes: &[u8]) -> Fixture<'_> { + assert_eq!(&bytes[..8], b"RSHW0001"); + let codec = match bytes[8] { + 1 => HwCodec::Hevc, + 2 => HwCodec::Av1, + value => panic!("unknown fixture codec {value}"), + }; + let depth = bytes[9]; + let width = u32::from(u16::from_le_bytes(bytes[10..12].try_into().unwrap())); + let height = u32::from(u16::from_le_bytes(bytes[12..14].try_into().unwrap())); + let config_len = u32::from_le_bytes(bytes[14..18].try_into().unwrap()) as usize; + let payload_len = u32::from_le_bytes(bytes[18..22].try_into().unwrap()) as usize; + let expected_len = u32::from_le_bytes(bytes[22..26].try_into().unwrap()) as usize; + let config_end = 26 + config_len; + let payload_end = config_end + payload_len; + let expected_end = payload_end + expected_len; + assert_eq!(expected_end, bytes.len()); + Fixture { + codec, + depth, + width, + height, + config: &bytes[26..config_end], + payload: &bytes[config_end..payload_end], + expected: &bytes[payload_end..expected_end], + } + } + + fn request<'a>(fixture: &'a Fixture<'a>) -> StillDecodeRequest<'a> { + StillDecodeRequest { + config: match fixture.codec { + HwCodec::Hevc => CodecConfig::Hvcc(fixture.config), + HwCodec::Av1 => CodecConfig::Av1c(fixture.config), + }, + payload: fixture.payload, + width: fixture.width, + height: fixture.height, + bit_depth: fixture.depth, + chroma: gamut_color::ChromaSubsampling::Cs420, + } + } + + #[test] + fn checked_in_codec_fixtures_prepare_with_authoritative_geometry() { + for bytes in [HEVC_8, HEVC_10, AV1_8, AV1_10] { + let fixture = fixture(bytes); + let prepared = prepare(&request(&fixture)).expect("fixture prepares"); + assert_eq!(prepared.codec, fixture.codec); + assert_eq!(prepared.bit_depth, fixture.depth); + assert_eq!((prepared.width, prepared.height), (64, 64)); + assert!(!prepared.csd0.is_empty()); + assert!(!prepared.access_unit.is_empty()); + match fixture.codec { + HwCodec::Hevc => assert!(prepared.csd0.starts_with(&[0, 0, 0, 1])), + HwCodec::Av1 => assert_eq!(prepared.csd0, fixture.config), + } + } + } + + #[test] + fn checked_in_expected_planes_round_trip_through_normalization() { + for bytes in [HEVC_8, AV1_8] { + let fixture = fixture(bytes); + let pixels = (fixture.width * fixture.height) as usize; + let chroma = pixels / 4; + let image = ImageView { + layout: ImageLayout::Yuv420, + width: fixture.width, + height: fixture.height, + crop: (0, 0, fixture.width, fixture.height), + planes: vec![ + PlaneView { + data: &fixture.expected[..pixels], + row_stride: fixture.width as usize, + pixel_stride: 1, + }, + PlaneView { + data: &fixture.expected[pixels..pixels + chroma], + row_stride: fixture.width as usize / 2, + pixel_stride: 1, + }, + PlaneView { + data: &fixture.expected[pixels + chroma..], + row_stride: fixture.width as usize / 2, + pixel_stride: 1, + }, + ], + }; + let frame = normalize_image(&image, (64, 64), 8, ColorRange::Limited).unwrap(); + let flattened: Vec<_> = frame + .planes() + .iter() + .flat_map(|plane| plane.data.iter().copied()) + .collect(); + assert_eq!(flattened, fixture.expected); + } + + for bytes in [HEVC_10, AV1_10] { + let fixture = fixture(bytes); + let luma_bytes = (fixture.width * fixture.height * 2) as usize; + let image = ImageView { + layout: ImageLayout::P010, + width: fixture.width, + height: fixture.height, + crop: (0, 0, fixture.width, fixture.height), + planes: vec![ + PlaneView { + data: &fixture.expected[..luma_bytes], + row_stride: fixture.width as usize * 2, + pixel_stride: 2, + }, + PlaneView { + data: &fixture.expected[luma_bytes..], + row_stride: fixture.width as usize * 2, + pixel_stride: 4, + }, + ], + }; + let frame = normalize_image(&image, (64, 64), 10, ColorRange::Limited).unwrap(); + let flattened: Vec<_> = frame + .planes() + .iter() + .flat_map(|plane| plane.data.iter().copied()) + .collect(); + assert_eq!(flattened, fixture.expected); + } + } + + #[test] + fn normalizes_padded_semiplanar_yuv_to_i420() { + let y = [1, 2, 3, 4, 0, 0, 5, 6, 7, 8, 0, 0]; + let uv = [10, 20, 11, 21, 0, 0]; + let image = ImageView { + layout: ImageLayout::Yuv420, + width: 4, + height: 2, + crop: (0, 0, 4, 2), + planes: vec![ + PlaneView { + data: &y, + row_stride: 6, + pixel_stride: 1, + }, + PlaneView { + data: &uv, + row_stride: 6, + pixel_stride: 2, + }, + PlaneView { + data: &uv[1..], + row_stride: 6, + pixel_stride: 2, + }, + ], + }; + let frame = normalize_image(&image, (4, 2), 8, ColorRange::Limited).unwrap(); + assert_eq!(frame.format(), PixelFormat::I420); + assert_eq!(frame.planes()[0].data, [1, 2, 3, 4, 5, 6, 7, 8]); + assert_eq!(frame.planes()[1].data, [10, 11]); + assert_eq!(frame.planes()[2].data, [20, 21]); + } + + #[test] + fn normalizes_three_plane_p010_to_interleaved_msb_words() { + let y = [0x00, 0x04, 0x00, 0x08, 0, 0, 0x00, 0x0c, 0x00, 0x10]; + let u = [0x00, 0x14, 0, 0]; + let v = [0x00, 0x18, 0, 0]; + let image = ImageView { + layout: ImageLayout::P010, + width: 2, + height: 2, + crop: (0, 0, 2, 2), + planes: vec![ + PlaneView { + data: &y, + row_stride: 6, + pixel_stride: 2, + }, + PlaneView { + data: &u, + row_stride: 4, + pixel_stride: 4, + }, + PlaneView { + data: &v, + row_stride: 4, + pixel_stride: 4, + }, + ], + }; + let frame = normalize_image(&image, (2, 2), 10, ColorRange::Full).unwrap(); + assert_eq!(frame.format(), PixelFormat::P010); + assert_eq!(frame.planes()[1].data, [0x00, 0x14, 0x00, 0x18]); + } + + #[test] + fn rejects_mismatched_crop_and_odd_origin() { + let plane = PlaneView { + data: &[0; 64], + row_stride: 8, + pixel_stride: 1, + }; + let image = ImageView { + layout: ImageLayout::Yuv420, + width: 8, + height: 8, + crop: (1, 0, 5, 4), + planes: vec![plane; 3], + }; + assert!(normalize_image(&image, (4, 4), 8, ColorRange::Limited).is_err()); + } +} diff --git a/crates/rawshift-hwdec/src/mediacodec/mod.rs b/crates/rawshift-hwdec/src/mediacodec/mod.rs new file mode 100644 index 0000000..cc07b28 --- /dev/null +++ b/crates/rawshift-hwdec/src/mediacodec/mod.rs @@ -0,0 +1,9 @@ +//! Android MediaCodec still-frame decoder. + +mod core; + +#[cfg(hwdec_backend = "mediacodec")] +mod platform; + +#[cfg(hwdec_backend = "mediacodec")] +pub(crate) use platform::{available_codecs, backend, decoder, initialize}; diff --git a/crates/rawshift-hwdec/src/mediacodec/platform.rs b/crates/rawshift-hwdec/src/mediacodec/platform.rs new file mode 100644 index 0000000..f082b0c --- /dev/null +++ b/crates/rawshift-hwdec/src/mediacodec/platform.rs @@ -0,0 +1,932 @@ +//! Android MediaCodec backend. +//! +//! Java is used only for the definitive `MediaCodecList` capability query. +//! Decode and output acquisition use the NDK `AMediaCodec`/`AImageReader` +//! APIs. All NDK wrappers stay on one worker because their raw handles are +//! intentionally not `Send`. + +use std::collections::HashSet; +use std::panic::{AssertUnwindSafe, catch_unwind}; +use std::sync::mpsc::{self, Receiver, SyncSender}; +use std::sync::{Arc, Condvar, Mutex, OnceLock}; +use std::thread::{self, JoinHandle}; +use std::time::{Duration, Instant}; + +use jni::objects::{JIntArray, JObject, JObjectArray, JString}; +use jni::{Env, JValue, JavaVM, jni_sig, jni_str}; +use ndk::hardware_buffer::HardwareBufferUsage; +use ndk::media::image_reader::{AcquireResult, Image, ImageFormat, ImageReader}; +use ndk::media::media_codec::{ + DequeuedInputBufferResult, DequeuedOutputBufferInfoResult, MediaCodec, MediaCodecDirection, +}; +use ndk::media::media_format::MediaFormat; + +use super::core::{ + ImageLayout, ImageView, MIME_AV1, MIME_HEVC, PlaneView, PreparedRequest, SessionKey, + normalize_image, prepare, +}; +use crate::{ + AndroidHwDecodeInitError, DecodedFrame, HwBackend, HwCodec, HwDecodeError, HwStillDecoder, + StillDecodeRequest, +}; + +const API_P010: i32 = 33; +const COLOR_FORMAT_P010: i32 = 54; +const COLOR_FORMAT_YUV420_FLEXIBLE: i32 = 0x7f42_0888; +const BUFFER_FLAG_CODEC_CONFIG: u32 = 2; +const BUFFER_FLAG_END_OF_STREAM: u32 = 4; +const DECODE_DEADLINE: Duration = Duration::from_secs(5); +const POLL_SLICE: Duration = Duration::from_millis(25); + +const HEVC_ONLY: &[HwCodec] = &[HwCodec::Hevc]; +const AV1_ONLY: &[HwCodec] = &[HwCodec::Av1]; +const BOTH_CODECS: &[HwCodec] = &[HwCodec::Hevc, HwCodec::Av1]; + +#[derive(Debug, Clone, PartialEq, Eq)] +struct Candidate { + name: String, + p010: bool, +} + +#[derive(Debug)] +struct Runtime { + vm: JavaVM, + sdk: i32, + hevc: Vec, + av1: Vec, +} + +#[derive(Debug, Clone)] +struct RuntimeSnapshot { + sdk: i32, + candidates: Vec, +} + +static RUNTIME: OnceLock>> = OnceLock::new(); + +fn runtime() -> &'static Mutex> { + RUNTIME.get_or_init(|| Mutex::new(None)) +} + +fn lock_runtime() -> std::sync::MutexGuard<'static, Option> { + runtime() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} + +pub(crate) fn initialize(vm: JavaVM) -> Result<(), AndroidHwDecodeInitError> { + let mut state = lock_runtime(); + if let Some(current) = state.as_ref() { + return if current.vm.get_raw() == vm.get_raw() { + Ok(()) + } else { + Err(AndroidHwDecodeInitError::DifferentJavaVm) + }; + } + + // Do not publish partial state. JNI/Java failures leave the slot empty so + // applications can retry after their runtime is fully initialized. + let discovery = vm.attach_current_thread(discover_codecs).map_err(|error| { + AndroidHwDecodeInitError::Jni { + operation: "MediaCodecList enumeration", + message: error.to_string(), + } + })?; + *state = Some(Runtime { + vm, + sdk: discovery.sdk, + hevc: discovery.hevc, + av1: discovery.av1, + }); + Ok(()) +} + +#[derive(Debug)] +struct Discovery { + sdk: i32, + hevc: Vec, + av1: Vec, +} + +fn discover_codecs(env: &mut Env<'_>) -> jni::errors::Result { + let sdk = env + .get_static_field( + jni_str!("android/os/Build$VERSION"), + jni_str!("SDK_INT"), + jni_sig!(int), + )? + .i()?; + let list = env.new_object( + jni_str!("android/media/MediaCodecList"), + jni_sig!((int) -> void), + &[JValue::Int(1)], // MediaCodecList.ALL_CODECS + )?; + let infos = env + .call_method( + &list, + jni_str!("getCodecInfos"), + jni_sig!(() -> [android.media.MediaCodecInfo]), + &[], + )? + .l()?; + let infos: JObjectArray<'_, JObject<'_>> = JObjectArray::::cast_local(env, infos)?; + + let mut hevc = Vec::new(); + let mut av1 = Vec::new(); + for index in 0..infos.len(env)? { + match env.with_local_frame(32, |env| { + let info = infos.get_element(env, index)?; + inspect_codec(env, &info) + }) { + Ok(found) => { + for (mime, candidate) in found { + match mime { + MIME_HEVC => hevc.push(candidate), + MIME_AV1 => av1.push(candidate), + _ => unreachable!("only requested MIME types are returned"), + } + } + } + Err(error) => tracing::debug!( + target: "rawshift_hwdec::mediacodec", + codec_index = index, + %error, + "skipping MediaCodecInfo after capability-query failure" + ), + } + } + Ok(Discovery { + sdk, + hevc: deduplicate(hevc), + av1: deduplicate(av1), + }) +} + +fn inspect_codec<'local>( + env: &mut Env<'local>, + info: &JObject<'_>, +) -> jni::errors::Result> { + if env + .call_method(info, jni_str!("isEncoder"), jni_sig!(() -> boolean), &[])? + .z()? + || env + .call_method(info, jni_str!("isAlias"), jni_sig!(() -> boolean), &[])? + .z()? + || !env + .call_method( + info, + jni_str!("isHardwareAccelerated"), + jni_sig!(() -> boolean), + &[], + )? + .z()? + { + return Ok(Vec::new()); + } + + let name = env + .call_method(info, jni_str!("getName"), jni_sig!(() -> JString), &[])? + .l()?; + let name = JString::cast_local(env, name)?.try_to_string(env)?; + let types = env + .call_method( + info, + jni_str!("getSupportedTypes"), + jni_sig!(() -> [JString]), + &[], + )? + .l()?; + let types: JObjectArray<'_, JString<'_>> = JObjectArray::::cast_local(env, types)?; + let mut found = Vec::new(); + for index in 0..types.len(env)? { + let java_mime = types.get_element(env, index)?; + let mime = java_mime.try_to_string(env)?; + let mime = match mime.as_str() { + MIME_HEVC => MIME_HEVC, + MIME_AV1 => MIME_AV1, + _ => continue, + }; + let capabilities = env + .call_method( + info, + jni_str!("getCapabilitiesForType"), + jni_sig!("(Ljava/lang/String;)Landroid/media/MediaCodecInfo$CodecCapabilities;"), + &[JValue::Object(java_mime.as_ref())], + )? + .l()?; + if feature_required(env, &capabilities, "secure-playback")? + || feature_required(env, &capabilities, "tunneled-playback")? + { + continue; + } + let formats = env + .get_field(&capabilities, jni_str!("colorFormats"), jni_sig!([int]))? + .l()?; + let formats = JIntArray::cast_local(env, formats)?; + let mut values = vec![0; formats.len(env)?]; + formats.get_region(env, 0, &mut values)?; + found.push(( + mime, + Candidate { + name: name.clone(), + p010: values.contains(&COLOR_FORMAT_P010), + }, + )); + } + Ok(found) +} + +fn feature_required( + env: &mut Env<'_>, + capabilities: &JObject<'_>, + feature: &str, +) -> jni::errors::Result { + let feature = env.new_string(feature)?; + env.call_method( + capabilities, + jni_str!("isFeatureRequired"), + jni_sig!((JString) -> boolean), + &[JValue::Object(feature.as_ref())], + )? + .z() +} + +fn deduplicate(candidates: Vec) -> Vec { + let mut names = HashSet::new(); + candidates + .into_iter() + .filter(|candidate| names.insert(candidate.name.clone())) + .collect() +} + +fn snapshot(codec: HwCodec) -> Option { + let state = lock_runtime(); + let runtime = state.as_ref()?; + Some(RuntimeSnapshot { + sdk: runtime.sdk, + candidates: match codec { + HwCodec::Hevc => runtime.hevc.clone(), + HwCodec::Av1 => runtime.av1.clone(), + }, + }) +} + +pub(crate) fn available_codecs() -> &'static [HwCodec] { + let state = lock_runtime(); + let Some(runtime) = state.as_ref() else { + return &[]; + }; + match (runtime.hevc.is_empty(), runtime.av1.is_empty()) { + (false, false) => BOTH_CODECS, + (false, true) => HEVC_ONLY, + (true, false) => AV1_ONLY, + (true, true) => &[], + } +} + +pub(crate) fn backend() -> Option { + (!available_codecs().is_empty()).then_some(HwBackend::MediaCodec) +} + +pub(crate) fn decoder(codec: HwCodec) -> Option> { + let snapshot = snapshot(codec)?; + if snapshot.candidates.is_empty() { + None + } else { + MediaCodecStillDecoder::new(codec, snapshot) + .map(|decoder| Box::new(decoder) as Box) + } +} + +enum WorkerMessage { + Decode { + request: PreparedRequest, + reply: SyncSender>, + }, + Shutdown, +} + +struct MediaCodecStillDecoder { + codec: HwCodec, + runtime: RuntimeSnapshot, + sender: SyncSender, + worker: Option>, +} + +impl MediaCodecStillDecoder { + fn new(codec: HwCodec, runtime: RuntimeSnapshot) -> Option { + let (sender, receiver) = mpsc::sync_channel(1); + let worker_runtime = runtime.clone(); + let worker = match thread::Builder::new() + .name(format!("rawshift-{codec}-mediacodec")) + .spawn(move || worker_main(receiver, worker_runtime)) + { + Ok(worker) => worker, + Err(error) => { + tracing::debug!( + target: "rawshift_hwdec::mediacodec", + %error, + "failed to create MediaCodec worker" + ); + return None; + } + }; + Some(Self { + codec, + runtime, + sender, + worker: Some(worker), + }) + } + + fn restart_worker(&mut self) -> Result<(), String> { + // A timed-out NDK call may still own thread-affine handles. Detach the + // old thread and create a completely fresh worker/session. + self.worker.take(); + let (sender, receiver) = mpsc::sync_channel(1); + let runtime = self.runtime.clone(); + self.sender = sender; + self.worker = Some( + thread::Builder::new() + .name(format!("rawshift-{}-mediacodec", self.codec)) + .spawn(move || worker_main(receiver, runtime)) + .map_err(|error| error.to_string())?, + ); + Ok(()) + } + + fn restart_suffix(&mut self) -> String { + match self.restart_worker() { + Ok(()) => "; a fresh worker is ready".to_string(), + Err(error) => format!("; creating a fresh worker also failed: {error}"), + } + } +} + +impl HwStillDecoder for MediaCodecStillDecoder { + fn decode_still( + &mut self, + request: &StillDecodeRequest<'_>, + ) -> Result { + let prepared = prepare(request)?; + debug_assert_eq!(prepared.codec, self.codec); + let (reply, response) = mpsc::sync_channel(1); + if self + .sender + .send(WorkerMessage::Decode { + request: prepared, + reply, + }) + .is_err() + { + let suffix = self.restart_suffix(); + return Err(HwDecodeError::Decode { + codec: self.codec, + message: format!("MediaCodec worker exited unexpectedly{suffix}"), + }); + } + match response.recv_timeout(DECODE_DEADLINE) { + Ok(result) => result, + Err(mpsc::RecvTimeoutError::Timeout) => { + let suffix = self.restart_suffix(); + Err(HwDecodeError::Decode { + codec: self.codec, + message: format!( + "MediaCodec decode exceeded the fixed 5 second deadline{suffix}" + ), + }) + } + Err(mpsc::RecvTimeoutError::Disconnected) => { + let suffix = self.restart_suffix(); + Err(HwDecodeError::Decode { + codec: self.codec, + message: format!("MediaCodec worker exited without a result{suffix}"), + }) + } + } + } +} + +impl Drop for MediaCodecStillDecoder { + fn drop(&mut self) { + let _ = self.sender.send(WorkerMessage::Shutdown); + if let Some(worker) = self.worker.take() { + let _ = worker.join(); + } + } +} + +fn worker_main(receiver: Receiver, runtime: RuntimeSnapshot) { + let mut state = WorkerState { + runtime, + session: None, + }; + while let Ok(message) = receiver.recv() { + match message { + WorkerMessage::Decode { request, reply } => { + let _ = reply.send(state.decode(request)); + } + WorkerMessage::Shutdown => break, + } + } +} + +struct WorkerState { + runtime: RuntimeSnapshot, + session: Option, +} + +impl WorkerState { + fn decode(&mut self, request: PreparedRequest) -> Result { + if request.bit_depth > 8 && self.runtime.sdk < API_P010 { + return Err(HwDecodeError::Unavailable { + codec: request.codec, + reason: format!( + "10-bit output requires Android API {API_P010}+ (device is API {})", + self.runtime.sdk + ), + }); + } + let mut candidates: Vec = self + .runtime + .candidates + .iter() + .filter(|candidate| request.bit_depth == 8 || candidate.p010) + .cloned() + .collect(); + if candidates.is_empty() { + return Err(HwDecodeError::Unavailable { + codec: request.codec, + reason: if request.bit_depth > 8 { + "no hardware decoder advertises COLOR_FormatYUVP010".to_string() + } else { + "no hardware decoder candidate remains".to_string() + }, + }); + } + + if let Some(session) = self.session.as_ref() + && let Some(index) = candidates + .iter() + .position(|candidate| candidate.name == session.candidate.name) + { + candidates.swap(0, index); + } + + let total_deadline = Instant::now() + DECODE_DEADLINE; + let mut failures = Vec::new(); + for (index, candidate) in candidates.iter().enumerate() { + let now = Instant::now(); + if now >= total_deadline { + failures.push(format!( + "{}@deadline: total deadline exhausted", + candidate.name + )); + continue; + } + // Reserve a fair portion of the remaining fixed budget for every + // candidate, so a broken first codec cannot prevent fallback. + let remaining_candidates = (candidates.len() - index) as u32; + let candidate_deadline = now + (total_deadline - now) / remaining_candidates; + let result = catch_unwind(AssertUnwindSafe(|| { + self.decode_candidate(candidate, &request, candidate_deadline) + })) + .unwrap_or_else(|panic| { + Err(PlatformFailure::message( + "panic", + panic_message(panic.as_ref()), + )) + }); + match result { + Ok(frame) => return Ok(frame), + Err(failure) => { + tracing::debug!( + target: "rawshift_hwdec::mediacodec", + codec = %request.codec, + component = %candidate.name, + stage = failure.stage, + error = %failure.message, + "MediaCodec hardware candidate failed" + ); + failures.push(format!( + "{}@{}: {}", + candidate.name, failure.stage, failure.message + )); + self.discard_session(); + } + } + } + tracing::warn!( + target: "rawshift_hwdec::mediacodec", + codec = %request.codec, + failures = %failures.join("; "), + "all MediaCodec hardware candidates failed" + ); + Err(HwDecodeError::Decode { + codec: request.codec, + message: format!( + "all MediaCodec hardware candidates failed: {}", + failures.join("; ") + ), + }) + } + + fn decode_candidate( + &mut self, + candidate: &Candidate, + request: &PreparedRequest, + deadline: Instant, + ) -> Result { + let reusable = self.session.as_ref().is_some_and(|session| { + session.candidate.name == candidate.name && session.key == request.key + }); + if reusable { + let session = self.session.as_mut().expect("checked above"); + session + .codec + .flush() + .map_err(|e| PlatformFailure::new("flush", e))?; + // The NDK contract requires AMediaCodec_start after every flush, + // including for this synchronous, buffer-input decoder. + session + .codec + .start() + .map_err(|e| PlatformFailure::new("restart-after-flush", e))?; + session.drain_images()?; + } else { + self.discard_session(); + self.session = Some(Session::create(candidate.clone(), request)?); + } + self.session + .as_mut() + .expect("session was created") + .decode(request, deadline) + } + + fn discard_session(&mut self) { + if let Some(session) = self.session.take() + && let Err(panic) = catch_unwind(AssertUnwindSafe(|| drop(session))) + { + tracing::debug!( + target: "rawshift_hwdec::mediacodec", + error = %panic_message(panic.as_ref()), + "MediaCodec session teardown panicked" + ); + } + } +} + +#[derive(Debug)] +struct PlatformFailure { + stage: &'static str, + message: String, +} + +impl PlatformFailure { + fn new(stage: &'static str, error: impl std::fmt::Display) -> Self { + Self { + stage, + message: error.to_string(), + } + } + + fn message(stage: &'static str, message: impl Into) -> Self { + Self { + stage, + message: message.into(), + } + } +} + +struct Session { + candidate: Candidate, + key: SessionKey, + codec: MediaCodec, + reader: ImageReader, + image_signal: Arc<(Mutex, Condvar)>, +} + +impl Session { + fn create(candidate: Candidate, request: &PreparedRequest) -> Result { + let image_format = if request.bit_depth == 8 { + ImageFormat::YUV_420_888 + } else { + ImageFormat::__Unknown(COLOR_FORMAT_P010) + }; + let mut reader = ImageReader::new_with_usage( + request + .width + .try_into() + .map_err(|_| PlatformFailure::message("reader", "width exceeds i32"))?, + request + .height + .try_into() + .map_err(|_| PlatformFailure::message("reader", "height exceeds i32"))?, + image_format, + HardwareBufferUsage::CPU_READ_OFTEN, + 2, + ) + .map_err(|e| PlatformFailure::new("reader", e))?; + let image_signal = Arc::new((Mutex::new(0u64), Condvar::new())); + let callback_signal = Arc::clone(&image_signal); + reader + .set_image_listener(Box::new(move |_| { + let (lock, ready) = &*callback_signal; + let mut generation = lock + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + *generation = (*generation).wrapping_add(1); + ready.notify_all(); + })) + .map_err(|e| PlatformFailure::new("reader-listener", e))?; + let window = reader + .window() + .map_err(|e| PlatformFailure::new("reader-window", e))?; + let codec = MediaCodec::from_codec_name(&candidate.name).ok_or_else(|| { + PlatformFailure::message("create", "AMediaCodec_createCodecByName returned null") + })?; + let mut format = MediaFormat::new(); + format.set_str("mime", request.mime); + format.set_i32("width", request.width as i32); + format.set_i32("height", request.height as i32); + format.set_i32("max-input-size", request.access_unit.len() as i32); + format.set_i32( + "color-format", + if request.bit_depth == 8 { + COLOR_FORMAT_YUV420_FLEXIBLE + } else { + COLOR_FORMAT_P010 + }, + ); + format.set_buffer("csd-0", &request.csd0); + codec + .configure(&format, Some(&window), MediaCodecDirection::Decoder) + .map_err(|e| PlatformFailure::new("configure", e))?; + codec + .start() + .map_err(|e| PlatformFailure::new("start", e))?; + Ok(Self { + candidate, + key: request.key.clone(), + codec, + reader, + image_signal, + }) + } + + fn drain_images(&self) -> Result<(), PlatformFailure> { + loop { + match self + .reader + .acquire_next_image() + .map_err(|e| PlatformFailure::new("drain-image", e))? + { + AcquireResult::Image(image) => drop(image), + AcquireResult::NoBufferAvailable => return Ok(()), + AcquireResult::MaxImagesAcquired => { + return Err(PlatformFailure::message( + "drain-image", + "AImageReader reports max images acquired", + )); + } + } + } + } + + fn decode( + &mut self, + request: &PreparedRequest, + deadline: Instant, + ) -> Result { + self.queue_access_unit(request, deadline)?; + let mut frame = None; + let mut eos = false; + while Instant::now() < deadline && (!eos || frame.is_none()) { + let timeout = poll_timeout(deadline); + match self + .codec + .dequeue_output_buffer(timeout) + .map_err(|e| PlatformFailure::new("dequeue-output", e))? + { + DequeuedOutputBufferInfoResult::Buffer(output) => { + let flags = output.info().flags(); + let render = flags & BUFFER_FLAG_CODEC_CONFIG == 0 && output.info().size() > 0; + self.codec + .release_output_buffer(output, render) + .map_err(|e| PlatformFailure::new("release-output", e))?; + if render && frame.is_none() { + frame = Some(self.wait_for_image(request, deadline)?); + } + eos |= flags & BUFFER_FLAG_END_OF_STREAM != 0; + } + DequeuedOutputBufferInfoResult::TryAgainLater + | DequeuedOutputBufferInfoResult::OutputFormatChanged + | DequeuedOutputBufferInfoResult::OutputBuffersChanged => {} + } + } + frame.ok_or_else(|| PlatformFailure::message("output", "deadline elapsed before a frame")) + } + + fn queue_access_unit( + &self, + request: &PreparedRequest, + deadline: Instant, + ) -> Result<(), PlatformFailure> { + while Instant::now() < deadline { + match self + .codec + .dequeue_input_buffer(poll_timeout(deadline)) + .map_err(|e| PlatformFailure::new("dequeue-input", e))? + { + DequeuedInputBufferResult::TryAgainLater => {} + DequeuedInputBufferResult::Buffer(mut input) => { + let buffer = input.buffer_mut(); + if buffer.len() < request.access_unit.len() { + return Err(PlatformFailure::message( + "input-buffer", + format!( + "codec supplied {} bytes for a {} byte access unit", + buffer.len(), + request.access_unit.len() + ), + )); + } + for (slot, byte) in buffer.iter_mut().zip(&request.access_unit) { + slot.write(*byte); + } + self.codec + .queue_input_buffer( + input, + 0, + request.access_unit.len(), + 0, + BUFFER_FLAG_END_OF_STREAM, + ) + .map_err(|e| PlatformFailure::new("queue-input", e))?; + return Ok(()); + } + } + } + Err(PlatformFailure::message( + "input-buffer", + "deadline elapsed before an input buffer", + )) + } + + fn wait_for_image( + &self, + request: &PreparedRequest, + deadline: Instant, + ) -> Result { + loop { + match self + .reader + .acquire_next_image() + .map_err(|e| PlatformFailure::new("acquire-image", e))? + { + AcquireResult::Image(image) => return image_to_frame(&image, request), + AcquireResult::MaxImagesAcquired => { + return Err(PlatformFailure::message( + "acquire-image", + "AImageReader reports max images acquired", + )); + } + AcquireResult::NoBufferAvailable => {} + } + let now = Instant::now(); + if now >= deadline { + return Err(PlatformFailure::message( + "acquire-image", + "deadline elapsed before AImageReader delivered a frame", + )); + } + let (lock, ready) = &*self.image_signal; + let generation = lock + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let _ = ready + .wait_timeout(generation, (deadline - now).min(POLL_SLICE)) + .unwrap_or_else(std::sync::PoisonError::into_inner); + } + } +} + +impl Drop for Session { + fn drop(&mut self) { + let _ = self.codec.stop(); + } +} + +fn poll_timeout(deadline: Instant) -> Duration { + deadline + .saturating_duration_since(Instant::now()) + .min(POLL_SLICE) +} + +fn panic_message(payload: &(dyn std::any::Any + Send)) -> String { + payload + .downcast_ref::<&str>() + .map(|message| (*message).to_string()) + .or_else(|| payload.downcast_ref::().cloned()) + .unwrap_or_else(|| "non-string panic payload".to_string()) +} + +fn image_to_frame( + image: &Image, + request: &PreparedRequest, +) -> Result { + let format = image + .format() + .map_err(|e| PlatformFailure::new("image-format", e))?; + let layout = match format { + ImageFormat::YUV_420_888 if request.bit_depth == 8 => ImageLayout::Yuv420, + ImageFormat::__Unknown(COLOR_FORMAT_P010) if request.bit_depth > 8 => ImageLayout::P010, + other => { + return Err(PlatformFailure::message( + "image-format", + format!("unexpected AImage format {other:?}"), + )); + } + }; + let width = positive_u32("image-width", image.width())?; + let height = positive_u32("image-height", image.height())?; + let crop = image + .crop_rect() + .map_err(|e| PlatformFailure::new("image-crop", e))?; + let plane_count = image + .number_of_planes() + .map_err(|e| PlatformFailure::new("image-planes", e))?; + if plane_count < 0 { + return Err(PlatformFailure::message( + "image-planes", + "AImage returned a negative plane count", + )); + } + let mut planes = Vec::with_capacity(plane_count as usize); + for index in 0..plane_count { + let data = image + .plane_data(index) + .map_err(|e| PlatformFailure::new("image-plane-data", e))?; + let row_stride = positive_usize("image-row-stride", image.plane_row_stride(index))?; + let pixel_stride = positive_usize("image-pixel-stride", image.plane_pixel_stride(index))?; + planes.push(PlaneView { + data, + row_stride, + pixel_stride, + }); + } + let view = ImageView { + layout, + width, + height, + crop: ( + nonnegative_u32("crop-left", crop.left)?, + nonnegative_u32("crop-top", crop.top)?, + nonnegative_u32("crop-right", crop.right)?, + nonnegative_u32("crop-bottom", crop.bottom)?, + ), + planes, + }; + normalize_image( + &view, + (request.width, request.height), + request.bit_depth, + request.range, + ) + .map_err(|e| PlatformFailure::new("normalize-image", e)) +} + +fn positive_u32( + stage: &'static str, + value: Result, +) -> Result { + let value = value.map_err(|e| PlatformFailure::new(stage, e))?; + u32::try_from(value) + .ok() + .filter(|value| *value > 0) + .ok_or_else(|| PlatformFailure::message(stage, format!("invalid value {value}"))) +} + +fn nonnegative_u32(stage: &'static str, value: i32) -> Result { + u32::try_from(value) + .map_err(|_| PlatformFailure::message(stage, format!("invalid value {value}"))) +} + +fn positive_usize( + stage: &'static str, + value: Result, +) -> Result { + let value = value.map_err(|e| PlatformFailure::new(stage, e))?; + usize::try_from(value) + .ok() + .filter(|value| *value > 0) + .ok_or_else(|| PlatformFailure::message(stage, format!("invalid value {value}"))) +} + +// Compile-time assertion: prepared jobs and replies are the only values that +// cross threads; no NDK wrapper is made Send with unsafe code. +const _: fn() = || { + fn assert_send() {} + assert_send::(); + assert_send::>(); +}; diff --git a/crates/rawshift-hwdec/src/vaapi/mod.rs b/crates/rawshift-hwdec/src/vaapi/mod.rs index faf0aed..52fff27 100644 --- a/crates/rawshift-hwdec/src/vaapi/mod.rs +++ b/crates/rawshift-hwdec/src/vaapi/mod.rs @@ -36,16 +36,14 @@ //! invariant. The bitstream parsers ([`bits`], [`hevc`], [`av1`]) are safe //! Rust. -mod av1; -mod bits; -mod hevc; -mod sys; +pub(crate) mod sys; use std::ffi::c_int; use std::fs::File; use std::os::fd::AsRawFd; use std::sync::OnceLock; +use crate::bitstream::{av1, bits, hevc}; use crate::{ CodecConfig, ColorRange, DecodedFrame, HwBackend, HwCodec, HwDecodeError, HwStillDecoder, PixelFormat, Plane, StillDecodeRequest, diff --git a/crates/rawshift-image/src/lib.rs b/crates/rawshift-image/src/lib.rs index 0d0cf60..ffe987f 100644 --- a/crates/rawshift-image/src/lib.rs +++ b/crates/rawshift-image/src/lib.rs @@ -110,3 +110,6 @@ pub mod processing; pub mod transforms; pub mod prelude; + +#[cfg(all(target_os = "android", feature = "hw"))] +pub use rawshift_hwdec::{AndroidHwDecodeInitError, AndroidJavaVm, initialize_android_hw_decode}; diff --git a/crates/rawshift-image/src/prelude.rs b/crates/rawshift-image/src/prelude.rs index 7de57c6..c7134ef 100644 --- a/crates/rawshift-image/src/prelude.rs +++ b/crates/rawshift-image/src/prelude.rs @@ -22,6 +22,9 @@ //! `apply_white_balance_raw`, `apply_color_matrix`, `apply_tone_reproduction`, //! `apply_tonemap`, `compute_camera_to_srgb`, `ColorSpaceTransform`, and more. +#[cfg(all(target_os = "android", feature = "hw"))] +pub use crate::{AndroidHwDecodeInitError, AndroidJavaVm, initialize_android_hw_decode}; + // core pub use crate::core::image::{CfaPattern, RawImage, Rect, XTransPattern}; pub use crate::core::metadata::{ diff --git a/docs/SUPPORT.md b/docs/SUPPORT.md index 24768e7..7b4adf9 100644 --- a/docs/SUPPORT.md +++ b/docs/SUPPORT.md @@ -21,7 +21,7 @@ upstream dependencies require — it is never raised independently. | `aarch64-unknown-linux-gnu` | 1 (CI build + test) | VAAPI (runtime dlopen) | | | `aarch64-apple-darwin` | 1 (CI build + test) | VideoToolbox | | | `aarch64-apple-ios` | 1 (CI build) | VideoToolbox | device tests are manual | -| `aarch64-linux-android` | 1 (CI build) | MediaCodec | minimum API level fixed when the backend lands | +| `aarch64-linux-android` | 1 (CI build) | MediaCodec | Android API 29+; physical-device gate is manual | | `x86_64-pc-windows-msvc` | 1 (CI build + test) | none (justified below) | HEIC/AVIF pixel decode unavailable until a software AV1 decoder lands upstream; everything else works | | `x86_64-unknown-linux-musl` | 2 (CI build) | none | static deploys; no dlopen | | `wasm32-unknown-unknown` | 2 (CI build) | none | in-memory API only; no hardware decode, threads, or file IO | @@ -39,12 +39,22 @@ fixed: | --- | --- | --- | --- | --- | --- | | VideoToolbox | ✅ in | macOS 11+, iOS 14+ | ✅ | ✅ runtime-probed (M3+ / A17 Pro+ hardware) | system framework | | VAAPI (libva) | ✅ in | Linux (gnu) | ✅ Main / Main10 | ✅ AV1 Main (driver-dependent) | dlopen at runtime — absence degrades to "no decoder", never a link failure | -| MediaCodec (NDK) | ✅ in | Android | ✅ | ✅ (device codec; mandated on newer API levels) | NDK | +| MediaCodec (NDK) | ✅ in | Android API 29+ | ✅ hardware-probed | ✅ hardware-probed | NDK decode; JNI `MediaCodecList` discovery | VAAPI covers Intel and AMD natively, **and NVIDIA via the maintained [`nvidia-vaapi-driver`](https://github.com/elFarto/nvidia-vaapi-driver) translation layer over NVDEC**. +Android uses one decode backend only: NDK `AMediaCodec`, with an +`AImageReader` output surface. A caller explicitly supplies its process +`JavaVM` once so rawshift can use the API-29 `MediaCodecInfo` hardware and +alias classification methods; the selected component is then opened by exact +name through `AMediaCodec`. Eight-bit output is normalized to dense I420. +P010 is strict (no downconversion) and requires API 33+, where the public +MediaCodec P010 capability constant is defined; it is normalized to dense, +MSB-aligned biplanar P010. See `android/mediacodec-harness/README.md` for the +formal physical-device acceptance matrix. + ### Excluded APIs, with justification - **Windows Media Foundation** — deliberate scope decision: HEVC decode