diff --git a/.github/workflows/ci-unienc.yml b/.github/workflows/ci-unienc.yml
index ceb4d3ca..09aef5b6 100644
--- a/.github/workflows/ci-unienc.yml
+++ b/.github/workflows/ci-unienc.yml
@@ -5,11 +5,26 @@ on:
paths:
- '.github/workflows/ci-unienc.yml'
- 'InstantReplay.Externals/unienc/**'
+ # Only the trunk, because a branch with an open pull request is already
+ # covered by the `pull_request` trigger and an unrestricted `push` runs the
+ # whole matrix a second time for the same commit. That cost nothing when this
+ # workflow was one `cargo fmt --check`; it is a duplicate of every job below.
push:
+ branches:
+ - main
paths:
- '.github/workflows/ci-unienc.yml'
- 'InstantReplay.Externals/unienc/**'
+# A new push to a branch makes the run already in flight for it pointless, and
+# these jobs are long enough — a simulator boot, an emulator boot, two macOS
+# runners — that leaving it to finish holds runners for a result nobody will
+# read. Runs on the trunk are left alone: each of those is the record for one
+# merged commit, so cancelling one loses the only result that commit ever gets.
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
+
jobs:
fmt:
name: Format
@@ -81,3 +96,114 @@ jobs:
path: InstantReplay.Externals/unienc/target/tmp/*.mp4
if-no-files-found: warn
retention-days: 7
+
+ test-ios-simulator:
+ name: Test (iOS simulator)
+ runs-on: macos-15
+ timeout-minutes: 45
+ env:
+ RUST_BACKTRACE: 1
+ defaults:
+ run:
+ working-directory: InstantReplay.Externals/unienc
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ submodules: 'recursive'
+ - run: rustup default stable
+ # A Rust test binary is a plain executable, so simctl runs it directly and
+ # no signing identity is needed. VideoToolbox does encode H.264 in the
+ # simulator, so this covers the encoder logic and not just the build.
+ - name: Run tests in the simulator
+ run: scripts/ios-simulator-test.sh
+ - name: Upload muxed output
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: e2e-output-ios-simulator
+ path: InstantReplay.Externals/unienc/target/aarch64-apple-ios-sim/tmp/*.mp4
+ if-no-files-found: warn
+ retention-days: 7
+
+ test-android-emulator:
+ name: Test (Android emulator)
+ runs-on: ubuntu-latest
+ timeout-minutes: 45
+ env:
+ RUST_BACKTRACE: 1
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ submodules: 'recursive'
+ - run: rustup default stable
+ - run: rustup target add aarch64-linux-android x86_64-linux-android
+ - run: cargo install cargo-ndk
+ - uses: actions/setup-java@v4
+ with:
+ distribution: temurin
+ java-version: '17'
+ - run: echo "ANDROID_NDK_HOME=$ANDROID_NDK_LATEST_HOME" >> $GITHUB_ENV
+ # KVM has to be reachable or the emulator falls back to an unusably slow
+ # software CPU.
+ - name: Enable KVM
+ run: |
+ echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
+ sudo udevadm control --reload-rules
+ sudo udevadm trigger --name-match=kvm
+ # The emulator's encoder is software (c2.android.avc.encoder), so this
+ # covers the MediaCodec and MediaMuxer plumbing rather than any vendor's
+ # hardware encoder. Those still need a real device.
+ - name: Run the harness on an emulator
+ uses: reactivecircus/android-emulator-runner@v2
+ with:
+ api-level: 34
+ arch: x86_64
+ target: google_apis
+ script: InstantReplay.Externals/unienc/scripts/android-device-test.sh
+ - name: Upload muxed output
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: e2e-output-android-emulator
+ path: InstantReplay.Externals/unienc/target/android-harness/
+ if-no-files-found: warn
+ retention-days: 7
+
+ test-web:
+ name: Test (WebAssembly, browser)
+ # macOS rather than Linux because of the audio. Chrome reaches WebCodecs'
+ # AAC encoder through `MojoAudioEncoder`, which is gated on the
+ # `media::kPlatformAudioEncoder` feature; `media/base/media_switches.cc`
+ # enables that by default only on Windows, macOS and Android, because those
+ # are the platforms with an OS-level AAC encoder behind it. On Linux the
+ # harness's audio configuration is refused outright, so the backend's audio
+ # path — the one that had never worked before #178 — could not be covered
+ # there at all.
+ runs-on: macos-15
+ timeout-minutes: 45
+ env:
+ RUST_BACKTRACE: 1
+ defaults:
+ run:
+ working-directory: InstantReplay.Externals/unienc
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ submodules: 'recursive'
+ # build-std needs a nightly toolchain, because the Emscripten target has no
+ # prebuilt std. This matches build-unienc.yml.
+ - run: rustup toolchain install nightly --component rust-src
+ - run: rustup target add wasm32-unknown-emscripten
+ # `unienc_webcodecs`'s build script shells out to `tsc`. The Linux runner
+ # image happens to ship TypeScript globally and the macOS one does not, so
+ # install it rather than depend on what an image includes.
+ - run: npm install -g typescript
+ - uses: mymindstorm/setup-emsdk@v14
+ # The WebCodecs backend drives the browser's own encoders, so a real
+ # browser is the only thing that can run this, and it has to be Google
+ # Chrome rather than Chromium: Chromium ships without the proprietary
+ # codecs and refuses every H.264 profile the harness offers. The runner
+ # image already carries Chrome at the path `run-in-browser.sh` defaults
+ # to, so nothing needs installing or pointing at.
+ - name: Run the harness in a browser
+ run: scripts/web-browser-test.sh
diff --git a/InstantReplay.Externals/unienc/.cargo/config.toml b/InstantReplay.Externals/unienc/.cargo/config.toml
index e1f7f0ff..0eb2f077 100644
--- a/InstantReplay.Externals/unienc/.cargo/config.toml
+++ b/InstantReplay.Externals/unienc/.cargo/config.toml
@@ -1,2 +1,18 @@
[target.'cfg(windows)']
rustflags = ["-C", "link-args=/Brepro"]
+
+# A Rust test binary is a plain executable, so the iOS simulator can run one
+# directly through simctl. No app bundle, no provisioning profile and no code
+# signing are involved, which is what keeps `cargo test --target
+# aarch64-apple-ios-sim` usable as an everyday command. A simulator has to be
+# booted first; scripts/ios-simulator-test.sh does that.
+#
+# This covers the encoders' own logic on the Apple platform. It does not cover
+# the static library link, where the iOS build differs from macOS most (see the
+# mimalloc symbol localization in build-unienc.yml), nor the hardware encoder of
+# a real device.
+[target.aarch64-apple-ios-sim]
+runner = ["xcrun", "simctl", "spawn", "--standalone", "booted"]
+
+[target.x86_64-apple-ios]
+runner = ["xcrun", "simctl", "spawn", "--standalone", "booted"]
diff --git a/InstantReplay.Externals/unienc/Cargo.lock b/InstantReplay.Externals/unienc/Cargo.lock
index bada5554..81681327 100644
--- a/InstantReplay.Externals/unienc/Cargo.lock
+++ b/InstantReplay.Externals/unienc/Cargo.lock
@@ -1203,6 +1203,24 @@ dependencies = [
"unienc_common",
]
+[[package]]
+name = "unienc_harness_android"
+version = "1.4.1"
+dependencies = [
+ "jni",
+ "unienc",
+ "unienc_testkit",
+]
+
+[[package]]
+name = "unienc_harness_web"
+version = "1.4.1"
+dependencies = [
+ "futures",
+ "unienc_common",
+ "unienc_testkit",
+]
+
[[package]]
name = "unienc_testkit"
version = "1.4.1"
diff --git a/InstantReplay.Externals/unienc/crates/unienc_harness_android/Cargo.toml b/InstantReplay.Externals/unienc/crates/unienc_harness_android/Cargo.toml
new file mode 100644
index 00000000..b70cf44a
--- /dev/null
+++ b/InstantReplay.Externals/unienc/crates/unienc_harness_android/Cargo.toml
@@ -0,0 +1,15 @@
+[package]
+name = "unienc_harness_android"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+publish = false
+
+[lib]
+crate-type = ["cdylib"]
+
+[dependencies]
+jni = "0.21.1"
+unienc = { workspace = true }
+unienc_testkit = { workspace = true }
diff --git a/InstantReplay.Externals/unienc/crates/unienc_harness_android/java/jp/co/cyberagent/unienc/harness/Harness.java b/InstantReplay.Externals/unienc/crates/unienc_harness_android/java/jp/co/cyberagent/unienc/harness/Harness.java
new file mode 100644
index 00000000..7b83fe5a
--- /dev/null
+++ b/InstantReplay.Externals/unienc/crates/unienc_harness_android/java/jp/co/cyberagent/unienc/harness/Harness.java
@@ -0,0 +1,33 @@
+package jp.co.cyberagent.unienc.harness;
+
+/**
+ * Shim that gives the native harness a JavaVM.
+ *
+ *
Loading the library is the whole point: {@code System.load} calls
+ * {@code JNI_OnLoad}, which is where the MediaCodec backend picks up the
+ * JavaVM it cannot work without. Everything else happens in native code.
+ *
+ *
Run through {@code app_process} so that no APK is needed; see
+ * {@code scripts/android-device-test.sh}.
+ */
+public final class Harness {
+ private Harness() {}
+
+ private static native int run(String outputPath);
+
+ public static void main(String[] args) {
+ if (args.length != 2) {
+ System.out.println("usage: Harness