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 "); + System.exit(2); + } + + // These two lines separate the ways this can produce no output at all: + // app_process never reaching main, System.load hanging or failing, and + // the native harness itself hanging. + System.out.println("harness: loading " + args[0]); + System.load(args[0]); + System.out.println("harness: loaded, starting"); + + System.exit(run(args[1])); + } +} diff --git a/InstantReplay.Externals/unienc/crates/unienc_harness_android/src/lib.rs b/InstantReplay.Externals/unienc/crates/unienc_harness_android/src/lib.rs new file mode 100644 index 00000000..c8cd07a5 --- /dev/null +++ b/InstantReplay.Externals/unienc/crates/unienc_harness_android/src/lib.rs @@ -0,0 +1,61 @@ +//! Android device harness for the shared end-to-end test. +//! +//! The MediaCodec backend needs a `JavaVM`, and the only way to obtain one is to +//! be loaded by a JVM. A bare executable pushed with `adb` therefore cannot run +//! the encoders, however convenient that would be. +//! +//! MediaCodec and MediaMuxer do not need an `Activity` or a `Context` though, so +//! a JVM is the *only* thing missing, and `app_process` provides one from a +//! shell. That is why this is a library loaded by a small Java shim rather than +//! an instrumented test inside an APK: no Gradle project, no packaging, and the +//! same command works against an emulator and a real device. +//! +//! See `scripts/android-device-test.sh`. + +use std::ffi::{c_int, c_void}; +use std::path::PathBuf; + +use jni::JNIEnv; +use jni::objects::{JClass, JString}; + +use unienc_testkit::E2eConfig; + +/// Called by the JVM when the Java shim loads this library. +/// +/// Handing the `JavaVM` to `unienc_android_mc` here is what makes the encoders +/// usable at all; without it every call fails with `JavaVM not initialized`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn JNI_OnLoad(vm: *mut c_void, reserved: *mut c_void) -> c_int { + unsafe { unienc::android::set_java_vm(vm as *mut _, reserved) } +} + +/// Runs the harness and returns a process exit status: zero when everything the +/// harness checks holds, one when it does not. +/// +/// Both the description of a successful output and the reason for a failure go +/// to stdout, which under `app_process` is the shell that invoked it. +#[unsafe(no_mangle)] +pub extern "system" fn Java_jp_co_cyberagent_unienc_harness_Harness_run( + mut env: JNIEnv, + _class: JClass, + output_path: JString, +) -> c_int { + let output_path: String = match env.get_string(&output_path) { + Ok(path) => path.into(), + Err(error) => { + println!("harness: cannot read the output path argument: {error}"); + return 1; + } + }; + + match unienc_testkit::run_and_verify(&E2eConfig::default(), &PathBuf::from(output_path)) { + Ok(description) => { + println!("harness: ok\n{description}"); + 0 + } + Err(message) => { + println!("harness: FAILED\n{message}"); + 1 + } + } +} diff --git a/InstantReplay.Externals/unienc/crates/unienc_harness_web/Cargo.toml b/InstantReplay.Externals/unienc/crates/unienc_harness_web/Cargo.toml new file mode 100644 index 00000000..51c19424 --- /dev/null +++ b/InstantReplay.Externals/unienc/crates/unienc_harness_web/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "unienc_harness_web" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish = false + +[dependencies] +futures = "0.3.31" +unienc_common = { workspace = true } +unienc_testkit = { workspace = true } diff --git a/InstantReplay.Externals/unienc/crates/unienc_harness_web/src/main.rs b/InstantReplay.Externals/unienc/crates/unienc_harness_web/src/main.rs new file mode 100644 index 00000000..b5e4260d --- /dev/null +++ b/InstantReplay.Externals/unienc/crates/unienc_harness_web/src/main.rs @@ -0,0 +1,138 @@ +//! Browser harness for the shared end-to-end test. +//! +//! The web is the one target where the harness cannot be a `cargo test`. The +//! encoders are the browser's own and report through callbacks delivered as +//! browser tasks, so the only thread must keep returning to the event loop — +//! while libtest expects a test function to run to completion and return. A +//! blocking driver would wait for results the browser cannot deliver. +//! +//! So this is a program instead, and it drives the pipeline the way Unity does in +//! production: a callback per animation frame, polling what has become ready. +//! `emscripten_set_main_loop` here plays the part `unienc_tick_runtime` plays +//! there. +//! +//! ASYNCIFY looks like an easier answer and is not one: it unwinds the wasm stack +//! while suspended, and the encoder callbacks re-enter wasm during exactly that +//! window, which is the reentrancy it does not support. +//! +//! Run it with `scripts/web-browser-test.sh`. + +use std::ffi::c_void; +use std::path::PathBuf; +use std::pin::Pin; +use std::process::ExitCode; +use std::task::{Context, Poll}; + +use futures::executor::LocalPool; +use unienc_common::Result as UniencResult; +use unienc_testkit::e2e::{E2eConfig, E2eReport}; +use unienc_testkit::{TestRuntime, e2e, verify_output}; + +unsafe extern "C" { + fn emscripten_set_main_loop_arg( + callback: extern "C" fn(*mut c_void), + argument: *mut c_void, + fps: i32, + simulate_infinite_loop: i32, + ); + fn emscripten_cancel_main_loop(); + fn emscripten_force_exit(status: i32); +} + +/// Everything the per-frame callback needs, kept alive on the heap for as long as +/// the main loop runs. +struct Harness { + config: E2eConfig, + output_path: PathBuf, + pool: LocalPool, + future: Pin>>>, +} + +fn main() -> ExitCode { + let config = E2eConfig::default(); + // Emscripten's filesystem is in memory; the root is the one directory + // guaranteed to exist. + let output_path = PathBuf::from("/e2e.mp4"); + + // The muxer hands its bytes to a download rather than writing a file, so they + // have to be diverted before the run for the verification to find anything. + if let Err(message) = unienc_testkit::web::capture_muxed_output(&output_path) { + println!("harness: FAILED\n{message}"); + return ExitCode::FAILURE; + } + + let pool = LocalPool::new(); + let runtime = TestRuntime::from_spawner(pool.spawner()); + let encoding_system = e2e::new_platform_system(&config, runtime.clone()); + + // Boxed and leaked into the main loop: `main` returns before the work is + // done, so nothing here may live on its stack. + let harness = Box::new(Harness { + future: Box::pin(e2e::run_with( + encoding_system, + runtime, + config, + output_path.clone(), + )), + config, + output_path, + pool, + }); + + println!("harness: driving the pipeline from the browser's main loop"); + unsafe { + // fps 0 means requestAnimationFrame, and not simulating an infinite loop + // is what lets `main` return while the loop keeps running. + emscripten_set_main_loop_arg(tick, Box::into_raw(harness) as *mut c_void, 0, 0); + } + + // The real status is reported by `finish`; returning here would tear the + // runtime down before the loop has run at all. + ExitCode::SUCCESS +} + +/// Polls the pipeline once per frame, letting the browser run in between. +extern "C" fn tick(argument: *mut c_void) { + // SAFETY: the pointer is the box leaked in `main` and stays valid until + // `finish` reclaims it. + let harness = unsafe { &mut *(argument as *mut Harness) }; + + // Spawned tasks first: the future below is normally waiting on one of them. + harness.pool.run_until_stalled(); + + let waker = futures::task::noop_waker(); + let mut context = Context::from_waker(&waker); + + match harness.future.as_mut().poll(&mut context) { + Poll::Pending => {} + Poll::Ready(result) => finish(argument, result), + } +} + +/// Reports the outcome and exits, having stopped the loop first. +fn finish(argument: *mut c_void, result: UniencResult) { + // SAFETY: as in `tick`; taking ownership back so nothing is polled again. + let harness = unsafe { Box::from_raw(argument as *mut Harness) }; + unsafe { emscripten_cancel_main_loop() }; + + let status = match result { + Err(error) => { + println!("harness: FAILED\nthe encode failed: {error}"); + 1 + } + Ok(report) => match verify_output(&harness.config, &harness.output_path, &report) { + Ok(description) => { + println!("harness: ok\n{description}"); + 0 + } + Err(message) => { + println!("harness: FAILED\n{message}"); + 1 + } + }, + }; + + // The page has no other way to report a status, and emrun turns the exit + // status into the process's. + unsafe { emscripten_force_exit(status) }; +} diff --git a/InstantReplay.Externals/unienc/crates/unienc_testkit/src/driver.rs b/InstantReplay.Externals/unienc/crates/unienc_testkit/src/driver.rs new file mode 100644 index 00000000..98f6a043 --- /dev/null +++ b/InstantReplay.Externals/unienc/crates/unienc_testkit/src/driver.rs @@ -0,0 +1,52 @@ +//! The one call a driver makes. +//! +//! Every target that runs this harness — `cargo test` on a build host, a JVM +//! shim on an Android device, a browser page — needs the same three steps in the +//! same order: encode, check the run, check the file. Putting them here rather +//! than in each driver is what keeps a platform from quietly skipping one. +//! +//! The result is a string either way, because a device harness usually has +//! nothing but a log to report through. + +use std::path::Path; + +// `run_and_verify` is the only user, and it is absent on the web. +#[cfg(not(target_os = "emscripten"))] +use crate::e2e; +use crate::e2e::{E2eConfig, E2eReport}; +use crate::{mp4, verify}; + +/// Encodes to `output_path`, then verifies the run and the file it produced. +/// +/// On success the description of the output is returned, so a driver can log +/// what it got rather than only that it was happy. On failure the message says +/// what did not hold, with the description appended when the file was readable. +#[cfg(not(target_os = "emscripten"))] +pub fn run_and_verify(config: &E2eConfig, output_path: &Path) -> Result { + let report = + e2e::run(config, output_path).map_err(|error| format!("the encode failed: {error}"))?; + verify_output(config, output_path, &report) +} + +/// The checks that follow a run. +/// +/// Split from the run itself for the sake of a driver that cannot block, which +/// has to do these when its future resolves rather than after a blocking call. +pub fn verify_output( + config: &E2eConfig, + output_path: &Path, + report: &E2eReport, +) -> Result { + verify::verify_report(report, config) + .map_err(|error| format!("the run did not do what was asked: {error}"))?; + + let bytes = std::fs::read(output_path) + .map_err(|error| format!("cannot read {}: {error}", output_path.display()))?; + let summary = mp4::summarize(&bytes) + .map_err(|error| format!("the output is not a readable MP4: {error}"))?; + + let described = verify::describe(&summary); + verify::verify_mp4(&summary, config).map_err(|error| format!("{error}{described}"))?; + + Ok(described) +} diff --git a/InstantReplay.Externals/unienc/crates/unienc_testkit/src/e2e.rs b/InstantReplay.Externals/unienc/crates/unienc_testkit/src/e2e.rs index 6516f7fc..51692696 100644 --- a/InstantReplay.Externals/unienc/crates/unienc_testkit/src/e2e.rs +++ b/InstantReplay.Externals/unienc/crates/unienc_testkit/src/e2e.rs @@ -5,7 +5,10 @@ //! linked into a device harness, or from an Emscripten `main` in a browser, //! without the platforms drifting apart. +// Only `run`, which the web does not have, borrows a path. +#[cfg(not(target_os = "emscripten"))] use std::path::Path; +use std::path::PathBuf; use futures::channel::oneshot::Canceled; use unienc_common::{ @@ -81,26 +84,47 @@ pub struct E2eReport { /// Runs the whole pipeline on this platform's encoding system and writes an MP4 /// to `output_path`. +/// +/// Absent on the web, where blocking the only thread would stop the event loop +/// the browser's encoders report through. A driver there owns a `LocalPool`, +/// builds the future with [`run_with`] and polls both from a main-loop callback; +/// see `unienc_harness_web`. +#[cfg(not(target_os = "emscripten"))] pub fn run(config: &E2eConfig, output_path: &Path) -> unienc_common::Result { let runtime = TestRuntime::new(); - let encoding_system = unienc::PlatformEncodingSystem::new( + let encoding_system = new_platform_system(config, runtime.clone()); + futures::executor::block_on(run_with( + encoding_system, + runtime, + *config, + output_path.to_path_buf(), + )) +} + +/// Builds the encoding system this target selects. +pub fn new_platform_system( + config: &E2eConfig, + runtime: TestRuntime, +) -> unienc::PlatformEncodingSystem { + unienc::PlatformEncodingSystem::new( &TestVideoOptions::from(config), &TestAudioOptions::from(config), - runtime.clone(), - ); - - futures::executor::block_on(run_with(encoding_system, runtime, config, output_path)) + runtime, + ) } /// Runs the pipeline on a specific encoding system. /// /// Generic over the system so that a driver can exercise one backend directly /// instead of whatever the target selects. +/// Takes its arguments by value so that the future owns everything it needs. A +/// driver that has to keep the future alive across main-loop callbacks cannot +/// lend it anything from a stack frame that will be gone. pub async fn run_with( encoding_system: S, runtime: TestRuntime, - config: &E2eConfig, - output_path: &Path, + config: E2eConfig, + output_path: PathBuf, ) -> unienc_common::Result where S: EncodingSystem + Send, @@ -109,14 +133,12 @@ where { let video_encoder = encoding_system.new_video_encoder()?; let audio_encoder = encoding_system.new_audio_encoder()?; - let muxer = encoding_system.new_muxer(output_path)?; + let muxer = encoding_system.new_muxer(&output_path)?; let (mut video_input, mut video_output) = video_encoder.get()?; let (mut audio_input, mut audio_output) = audio_encoder.get()?; let (mut mux_video, mut mux_audio, completion) = muxer.get_inputs()?; - let config = *config; - let emit_video = runtime.spawn_with_result(async move { for index in 0..config.video_frames() { let data = pattern::video_frame_bgra32(config.width, config.height, index); @@ -130,7 +152,12 @@ where timestamp: index as f64 / config.fps as f64 + config.timestamp_offset, }) .await?; + + if index == 0 { + crate::progress!("harness: the video encoder accepted the first frame"); + } } + crate::progress!("harness: pushed {} video frames", config.video_frames()); Ok(config.video_frames()) }); @@ -142,7 +169,12 @@ where timestamp_in_samples: second * config.sample_rate as u64, }) .await?; + + if second == 0 { + crate::progress!("harness: the audio encoder accepted the first chunk"); + } } + crate::progress!("harness: pushed {} audio chunks", config.duration_secs); Ok(config.duration_secs) }); @@ -156,8 +188,13 @@ where data.set_timestamp(data.timestamp() - offset); mux_video.push(data).await?; pulled += 1; + + if pulled == 1 { + crate::progress!("harness: the video encoder produced its first output"); + } } mux_video.finish().await?; + crate::progress!("harness: transferred {pulled} encoded video items"); Ok(pulled) }); @@ -167,18 +204,25 @@ where let data = reencode(data)?; mux_audio.push(data).await?; pulled += 1; + + if pulled == 1 { + crate::progress!("harness: the audio encoder produced its first output"); + } } mux_audio.finish().await?; + crate::progress!("harness: transferred {pulled} encoded audio items"); Ok(pulled) }); // The inputs have to be finished before the muxer is waited on, otherwise - // FFmpeg and MediaMuxer never see end of stream. + // FFmpeg and MediaMuxer never see end of stream. Each stage announces itself + // because a harness that hangs on a device gives nothing else to go on. let video_frames_pushed = join(emit_video).await?; let audio_chunks_pushed = join(emit_audio).await?; let video_data_pulled = join(transfer_video).await?; let audio_data_pulled = join(transfer_audio).await?; + crate::progress!("harness: waiting for the muxer"); completion.finish().await?; Ok(E2eReport { diff --git a/InstantReplay.Externals/unienc/crates/unienc_testkit/src/lib.rs b/InstantReplay.Externals/unienc/crates/unienc_testkit/src/lib.rs index e9314470..d085968c 100644 --- a/InstantReplay.Externals/unienc/crates/unienc_testkit/src/lib.rs +++ b/InstantReplay.Externals/unienc/crates/unienc_testkit/src/lib.rs @@ -8,20 +8,25 @@ //! //! ```no_run //! let config = unienc_testkit::E2eConfig::default(); -//! let report = unienc_testkit::e2e::run(&config, std::path::Path::new("out.mp4")).unwrap(); -//! unienc_testkit::verify::verify_report(&report, &config).unwrap(); -//! -//! let bytes = std::fs::read("out.mp4").unwrap(); -//! let summary = unienc_testkit::mp4::summarize(&bytes).unwrap(); -//! unienc_testkit::verify::verify_mp4(&summary, &config).unwrap(); +//! match unienc_testkit::run_and_verify(&config, std::path::Path::new("out.mp4")) { +//! Ok(description) => println!("{description}"), +//! Err(message) => panic!("{message}"), +//! } //! ``` +pub mod driver; pub mod e2e; pub mod mp4; pub mod options; pub mod pattern; +pub mod progress; pub mod runtime; pub mod verify; +#[cfg(target_os = "emscripten")] +pub mod web; +#[cfg(not(target_os = "emscripten"))] +pub use driver::run_and_verify; +pub use driver::verify_output; pub use e2e::{E2eConfig, E2eReport}; pub use runtime::TestRuntime; diff --git a/InstantReplay.Externals/unienc/crates/unienc_testkit/src/mp4.rs b/InstantReplay.Externals/unienc/crates/unienc_testkit/src/mp4.rs index dd950810..356b3085 100644 --- a/InstantReplay.Externals/unienc/crates/unienc_testkit/src/mp4.rs +++ b/InstantReplay.Externals/unienc/crates/unienc_testkit/src/mp4.rs @@ -64,6 +64,12 @@ pub struct Track { /// True when the sample entry carries a decoder configuration, i.e. an /// `avcC` or `esds` box. A track without one does not play back. pub has_decoder_config: bool, + /// H.264 profile and level, read from `avcC`. + /// + /// Which profile an encoder produces is not always what was asked for — on + /// the web it is negotiated with the browser at run time — so this reports + /// what the file actually contains. + pub avc_profile: Option, pub timescale: u32, /// Track duration in seconds, from `mdhd`. /// @@ -171,6 +177,7 @@ fn parse_track(trak: &[u8], movie_timescale: u32) -> Result { kind, format: entry.format, has_decoder_config: entry.has_decoder_config, + avc_profile: entry.avc_profile, timescale, duration: duration as f64 / timescale.max(1) as f64, width: entry.width, @@ -225,10 +232,32 @@ fn parse_leading_empty_edits(elst: &[u8]) -> Result { Ok(delay) } +/// The profile and level an H.264 track was encoded at. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AvcProfile { + pub profile: u8, + pub level: u8, +} + +impl fmt::Display for AvcProfile { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let name = match self.profile { + 0x42 => "Baseline", + 0x4d => "Main", + 0x58 => "Extended", + 0x64 => "High", + _ => "unknown", + }; + // Levels are tenths, so 0x1f is 3.1. + write!(f, "{name} {}.{}", self.level / 10, self.level % 10) + } +} + #[derive(Default)] struct SampleEntry { format: String, has_decoder_config: bool, + avc_profile: Option, width: u32, height: u32, sample_rate: u32, @@ -279,8 +308,20 @@ impl SampleEntry { }; if let Some(children) = entry.get(children_at..) { - parsed.has_decoder_config = iter_boxes(children) - .any(|(box_type, _)| &box_type == b"avcC" || &box_type == b"esds"); + for (box_type, body) in iter_boxes(children) { + match &box_type { + b"avcC" => { + parsed.has_decoder_config = true; + // configurationVersion, AVCProfileIndication, + // profile_compatibility, AVCLevelIndication. + if let (Some(&profile), Some(&level)) = (body.get(1), body.get(3)) { + parsed.avc_profile = Some(AvcProfile { profile, level }); + } + } + b"esds" => parsed.has_decoder_config = true, + _ => {} + } + } } Ok(parsed) diff --git a/InstantReplay.Externals/unienc/crates/unienc_testkit/src/progress.rs b/InstantReplay.Externals/unienc/crates/unienc_testkit/src/progress.rs new file mode 100644 index 00000000..6dfdde25 --- /dev/null +++ b/InstantReplay.Externals/unienc/crates/unienc_testkit/src/progress.rs @@ -0,0 +1,24 @@ +//! Progress reporting that survives a hang. +//! +//! Stdout is block-buffered whenever it is not a terminal, and a device harness +//! runs behind a pipe: its output reaches `adb shell` only when the buffer fills +//! or the process exits. A harness that hangs never exits, so without flushing +//! every line, the run that most needs a trace produces none at all — which is +//! exactly what the first Android CI failure looked like. + +use std::io::Write; + +/// Prints one progress line and flushes it. +pub fn report(args: std::fmt::Arguments<'_>) { + let mut out = std::io::stdout().lock(); + let _ = writeln!(out, "{args}"); + let _ = out.flush(); +} + +/// Reports progress in a way a hung run still shows. See [`report`]. +#[macro_export] +macro_rules! progress { + ($($arg:tt)*) => { + $crate::progress::report(std::format_args!($($arg)*)) + }; +} diff --git a/InstantReplay.Externals/unienc/crates/unienc_testkit/src/runtime.rs b/InstantReplay.Externals/unienc/crates/unienc_testkit/src/runtime.rs index decb2ac8..29163383 100644 --- a/InstantReplay.Externals/unienc/crates/unienc_testkit/src/runtime.rs +++ b/InstantReplay.Externals/unienc/crates/unienc_testkit/src/runtime.rs @@ -1,25 +1,59 @@ use futures::channel::oneshot::Canceled; -use futures::executor::ThreadPool; -use futures::task::SpawnExt; use std::pin::Pin; use unienc_common::{Spawn, SpawnBlocking}; /// The runtime the test harness drives the encoders with. /// -/// It deliberately mirrors `unienc_c::runtime::RuntimeSpawner`, the runtime used -/// in production: a `futures::executor::ThreadPool` for futures and the -/// `blocking` crate's pool for blocking work, with no Tokio runtime anywhere. A -/// backend that reaches for an ambient Tokio reactor therefore fails here -/// exactly as it would in a Unity player. +/// It deliberately mirrors the runtime `unienc_c` builds in production, because a +/// harness that gives the encoders a more capable runtime than they will really +/// have proves very little. That means two shapes, matching `unienc_c`'s +/// `multi-thread` feature: +/// +/// - Everywhere with threads: a `futures::executor::ThreadPool`, and blocking +/// work on the `blocking` crate's pool. No Tokio runtime exists here, so a +/// backend reaching for an ambient Tokio reactor fails exactly as it would in a +/// player. +/// - On Emscripten, which is built without pthreads: a single-threaded +/// `LocalPool`, whose owner has to keep driving it. See +/// [`crate::web::drive_to_completion`]. #[derive(Clone)] pub struct TestRuntime { - pool: ThreadPool, + #[cfg(not(target_os = "emscripten"))] + pool: futures::executor::ThreadPool, + #[cfg(target_os = "emscripten")] + spawner: SingleThreaded, } impl TestRuntime { + /// Builds a runtime backed by a thread pool. + #[cfg(not(target_os = "emscripten"))] pub fn new() -> Self { + // The pool is as wide as the machine by default, as it is in production. + // `UNIENC_TEST_THREADS` narrows it, because a backend that blocks a + // worker while waiting on the encoder behaves differently when there are + // fewer workers than concurrent pipeline stages — and a build host + // usually has far more cores than a CI runner. + let mut builder = futures::executor::ThreadPoolBuilder::new(); + if let Ok(threads) = std::env::var("UNIENC_TEST_THREADS") { + let threads: usize = threads + .parse() + .expect("UNIENC_TEST_THREADS is not a number"); + builder.pool_size(threads.max(1)); + } + + Self { + pool: builder.create().expect("Failed to build thread pool"), + } + } + + /// Builds a runtime that spawns onto a caller-owned `LocalPool`. + /// + /// The pool has to be driven by whoever owns it; nothing here runs on its + /// own. + #[cfg(target_os = "emscripten")] + pub fn from_spawner(spawner: futures::executor::LocalSpawner) -> Self { Self { - pool: ThreadPool::new().expect("Failed to build thread pool"), + spawner: SingleThreaded(spawner), } } @@ -36,6 +70,7 @@ impl TestRuntime { } } +#[cfg(not(target_os = "emscripten"))] impl Default for TestRuntime { fn default() -> Self { Self::new() @@ -44,9 +79,22 @@ impl Default for TestRuntime { impl Spawn for TestRuntime { fn spawn(&self, future: impl Future + Send + 'static) { - self.pool - .spawn(future) - .expect("Failed to spawn task on threaded executor"); + #[cfg(not(target_os = "emscripten"))] + let result = { + use futures::task::SpawnExt; + self.pool.spawn(future) + }; + + // The backends spawn from `Drop`, which runs inside a task the pool is + // already running, so this must not need the pool itself. A cloned + // spawner queues the task without touching it. + #[cfg(target_os = "emscripten")] + let result = { + use futures::task::LocalSpawnExt; + self.spawner.0.spawn_local(future) + }; + + result.expect("Failed to spawn task"); } } @@ -55,8 +103,26 @@ impl SpawnBlocking for TestRuntime { &self, f: impl FnOnce() -> Result + Send + 'static, ) -> Pin + Send + 'static>> { - Box::pin(blocking::unblock(f)) + #[cfg(not(target_os = "emscripten"))] + return Box::pin(blocking::unblock(f)); + + // Nowhere to offload to on a target without threads, so the work happens + // here and the future is already resolved. + #[cfg(target_os = "emscripten")] + return Box::pin(std::future::ready(f())); } } impl unienc_common::Runtime for TestRuntime {} + +/// Asserts `Send` for a value only ever used on one thread. +/// +/// `Runtime` requires `Send`, but this target is built without pthreads, so the +/// process has a single thread and there is nowhere a value could be sent to. +#[cfg(target_os = "emscripten")] +#[derive(Clone)] +struct SingleThreaded(T); + +// SAFETY: see the type's documentation; the target has one thread. +#[cfg(target_os = "emscripten")] +unsafe impl Send for SingleThreaded {} diff --git a/InstantReplay.Externals/unienc/crates/unienc_testkit/src/verify.rs b/InstantReplay.Externals/unienc/crates/unienc_testkit/src/verify.rs index a950b7f3..d83fe7f0 100644 --- a/InstantReplay.Externals/unienc/crates/unienc_testkit/src/verify.rs +++ b/InstantReplay.Externals/unienc/crates/unienc_testkit/src/verify.rs @@ -191,15 +191,20 @@ fn verify_video_track( ) }); - findings.check( - (track.duration - expected_duration).abs() <= DURATION_TOLERANCE_SECS, - || { - format!( - "video track is {:.3} s, expected {:.3} s", - track.duration, expected_duration - ) - }, - ); + // A muxer that derives sample durations from the gaps between presentation + // timestamps has nothing to derive the last one from. MediaMuxer gives it + // zero, so the track legitimately ends a frame short, while AVFoundation and + // FFmpeg give it a full interval. The frame count above is the strict check; + // this one is about the timeline being the right length. + let interval = 1.0 / config.fps as f64; + let shortest = expected_duration - interval - DURATION_TOLERANCE_SECS; + let longest = expected_duration + DURATION_TOLERANCE_SECS; + findings.check((shortest..=longest).contains(&track.duration), || { + format!( + "video track is {:.3} s, expected between {:.3} s and {:.3} s", + track.duration, shortest, longest + ) + }); // A decoder joining at the start needs the first sample to be a keyframe. findings.check(track.is_sync_sample(1), || { @@ -207,7 +212,6 @@ fn verify_video_track( }); let times = track.sample_times(); - let interval = 1.0 / config.fps as f64; let out_of_order = times.windows(2).position(|pair| pair[1] <= pair[0]); findings.check(out_of_order.is_none(), || { let at = out_of_order.unwrap(); @@ -295,14 +299,17 @@ pub fn describe(summary: &Mp4Summary) -> String { for track in &summary.tracks { out.push_str(&match track.kind { TrackKind::Video => format!( - " video: {} {}x{}, {} frames, start {:.3} s, {:.3} s, decoder config: {}\n", + " video: {} {}x{} ({}), {} frames, start {:.3} s, {:.3} s\n", track.format, track.width, track.height, + match track.avc_profile { + Some(profile) => profile.to_string(), + None => "no decoder config".to_string(), + }, track.sample_count, track.start_time, track.duration, - track.has_decoder_config ), TrackKind::Audio => format!( " audio: {} {} Hz {} ch, {} frames, start {:.3} s, {:.3} s, decoder config: {}\n", diff --git a/InstantReplay.Externals/unienc/crates/unienc_testkit/src/web.rs b/InstantReplay.Externals/unienc/crates/unienc_testkit/src/web.rs new file mode 100644 index 00000000..737b9c64 --- /dev/null +++ b/InstantReplay.Externals/unienc/crates/unienc_testkit/src/web.rs @@ -0,0 +1,76 @@ +//! Emscripten-specific glue, so that the web runs the same harness as everyone +//! else. + +use std::ffi::{CString, c_char}; +use std::path::Path; + +unsafe extern "C" { + fn emscripten_run_script(script: *const c_char); +} + +/// Redirects the muxer's browser download into the in-memory filesystem. +/// +/// On the web the muxer hands its finished bytes to a download instead of +/// writing a file, so the shared driver would have nothing to read back. Rather +/// than give the backend a test-only mode, this intercepts the one JavaScript +/// function it calls and writes the same bytes to `path`. Nothing in +/// `unienc_webcodecs` knows the difference. +/// +/// `window.unienc_webcodecs` does not exist yet at this point — the backend +/// creates it lazily when the first encoder is built — so the interception is a +/// property setter that patches the object as it is assigned and then replaces +/// itself with the plain value. +pub fn capture_muxed_output(path: &Path) -> Result<(), String> { + let target = path.to_string_lossy(); + if target.contains('"') || target.contains('\\') { + // The path is interpolated into a script; refuse anything needing care. + return Err(format!( + "output path is not usable from JavaScript: {target}" + )); + } + + let script = format!( + r#" + (function () {{ + const target = "{target}"; + const capture = function (partsPtr, numParts) {{ + const header = Module.HEAPU32.subarray( + partsPtr >> 2, (partsPtr >> 2) + numParts * 2); + const parts = []; + let total = 0; + for (let i = 0; i < numParts; i++) {{ + const ptr = header[i * 2]; + const len = header[i * 2 + 1]; + // Copied rather than viewed: the heap can be reallocated + // while this runs, which would leave a view dangling. + parts.push(Module.HEAPU8.slice(ptr, ptr + len)); + total += len; + }} + const joined = new Uint8Array(total); + let at = 0; + for (const part of parts) {{ + joined.set(part, at); + at += part.length; + }} + (Module.FS || FS).writeFile(target, joined); + }}; + Object.defineProperty(window, "unienc_webcodecs", {{ + configurable: true, + set: function (value) {{ + value.makeDownload = capture; + Object.defineProperty(window, "unienc_webcodecs", {{ + value: value, + writable: true, + configurable: true, + }}); + }}, + }}); + }})(); + "# + ); + + let script = CString::new(script).map_err(|error| error.to_string())?; + // SAFETY: the script is a valid NUL-terminated C string. + unsafe { emscripten_run_script(script.as_ptr()) }; + Ok(()) +} diff --git a/InstantReplay.Externals/unienc/crates/unienc_testkit/tests/e2e.rs b/InstantReplay.Externals/unienc/crates/unienc_testkit/tests/e2e.rs index beb20dc4..e715eeb6 100644 --- a/InstantReplay.Externals/unienc/crates/unienc_testkit/tests/e2e.rs +++ b/InstantReplay.Externals/unienc/crates/unienc_testkit/tests/e2e.rs @@ -1,11 +1,13 @@ -//! Desktop driver for the shared end-to-end harness. +//! Desktop and simulator driver for the shared end-to-end harness. //! //! Whichever backend the target selects is the one under test: VideoToolbox on -//! Apple platforms, Media Foundation on Windows, FFmpeg elsewhere. +//! Apple platforms, Media Foundation on Windows, FFmpeg elsewhere. With a +//! simulator runner configured in `.cargo/config.toml`, the very same test runs +//! inside the iOS simulator. use std::path::PathBuf; -use unienc_testkit::{E2eConfig, e2e, mp4, verify}; +use unienc_testkit::E2eConfig; /// Cargo hands integration tests a directory for their artifacts, which keeps /// the muxed file out of the crate directory. @@ -16,21 +18,11 @@ fn output_path(name: &str) -> PathBuf { #[test] fn encodes_and_muxes_a_playable_mp4() { let config = E2eConfig::default(); - let path = output_path("e2e.mp4"); - let report = e2e::run(&config, &path).expect("the encode failed"); - if let Err(error) = verify::verify_report(&report, &config) { - panic!("the run did not do what was asked: {error}"); - } - - let bytes = std::fs::read(&path).expect("the muxed file is missing"); - let summary = mp4::summarize(&bytes).expect("the output is not a readable MP4"); - - // Printed unconditionally: when this fails on a machine that is not to hand, - // the log is the only evidence of what came out. - println!("{}", verify::describe(&summary)); - - if let Err(error) = verify::verify_mp4(&summary, &config) { - panic!("{}\n{}", error, verify::describe(&summary)); + match unienc_testkit::run_and_verify(&config, &output_path("e2e.mp4")) { + // Printed unconditionally: when this fails on a machine that is not to + // hand, the log is the only evidence of what came out. + Ok(description) => println!("{description}"), + Err(message) => panic!("{message}"), } } diff --git a/InstantReplay.Externals/unienc/docs/device-testing.md b/InstantReplay.Externals/unienc/docs/device-testing.md new file mode 100644 index 00000000..09a2da12 --- /dev/null +++ b/InstantReplay.Externals/unienc/docs/device-testing.md @@ -0,0 +1,149 @@ +# Testing unienc without Unity + +Building a Unity player to check an encoder change is slow, so the end-to-end +harness runs standalone on every platform it can. `unienc_testkit` holds one +pipeline definition and one set of assertions; each platform only supplies a way +to start it. That is the point: a backend cannot pass on a build host for reasons +that would not hold on a phone. + +What every platform runs is the same: encode ten seconds of colour bars and a +tone, mux them, then read the resulting MP4 back and check it. See +`crates/unienc_testkit/src/verify.rs` for the assertions. + +## What this does not cover + +- **The GPU blit path.** `is_blit_supported()` requires a Unity graphics device, + so a standalone harness always exercises the CPU readback path. Testing blit + needs Unity. +- **The static library link.** On iOS the shipped artifact is a `.a` linked into + UnityFramework, with mimalloc symbols localized (see `build-unienc.yml`). The + simulator harness builds its own executable and does not go through that. +- **Vendor hardware encoders.** An emulator and a simulator use software or host + encoders. The frame drops, size alignment faults and colour shifts that have + needed fixing before are specific to a device's own encoder, so a real device + is still the only place they show up. + +## Desktop + +```bash +cargo test -p unienc_testkit -p unienc_common -p +``` + +`` is `unienc_apple_vt` on macOS, `unienc_windows_mf` on Windows +and `unienc_ffmpeg` on Linux; a platform backend only builds for its own +platform, so its unit tests can only run there. On Linux the FFmpeg backend +shells out to `ffmpeg`, which has to be installed and to have an H.264 and an AAC +encoder. This is what CI runs (`.github/workflows/ci-unienc.yml`). + +## iOS simulator + +```bash +scripts/ios-simulator-test.sh +``` + +A Rust test binary is a plain executable, so `simctl` runs one directly — no app +bundle, no provisioning profile, no code signing. The runner that hands the +binary to `simctl` lives in `.cargo/config.toml`, so once a simulator is booted, +plain `cargo test -p unienc_testkit --target aarch64-apple-ios-sim` works too. + +VideoToolbox does encode H.264 in the simulator, so this covers the encoder logic +rather than just compilation. The first run after booting a simulator can take a +minute while the media frameworks load; later runs take a few seconds. + +Most of the Apple backend is shared between iOS and macOS, so the desktop run +already covers it. What the simulator adds is the iOS deployment target and the +iOS variants of the frameworks. + +## Android device or emulator + +```bash +export ANDROID_HOME=~/Library/Android/sdk +export ANDROID_NDK_HOME="$ANDROID_HOME/ndk/" +scripts/android-device-test.sh # add -s to pick a device +``` + +There is no APK and no Gradle project. MediaCodec and MediaMuxer need a `JavaVM` +but neither an `Activity` nor a `Context`, so a JVM is the only thing an `adb` +shell is missing, and `app_process` supplies one. The script builds +`unienc_harness_android` as a shared library, compiles a small Java shim to a +dex, pushes both, and runs them: + +``` +CLASSPATH=…/harness.dex app_process … jp.co.cyberagent.unienc.harness.Harness … +``` + +`System.load` in the shim is what calls `JNI_OnLoad`, which is where the backend +picks up the `JavaVM`. The muxed file is pulled back to +`target/android-harness/e2e.mp4` whether or not the checks passed. + +The same command works against an emulator and a real device, which is the reason +for this shape rather than an instrumented test: reaching for a device when a +result looks suspicious costs nothing extra. + +### A note on track duration + +MediaMuxer derives sample durations from the gaps between presentation +timestamps, and has nothing to derive the last one from, so it gives the final +sample a duration of zero and the video track ends one frame interval short. +AVFoundation and FFmpeg give it a full interval. The harness allows for this; the +frame count is the strict check. + +## Web + +```bash +source /path/to/emsdk/emsdk_env.sh +scripts/web-browser-test.sh # add --release for the release-wasm profile +``` + +This is the one target where the harness is a program rather than a `cargo test`. +The encoders are the browser's own and report through callbacks delivered as +browser tasks, so the only thread has to keep returning to the event loop, while +libtest expects a test function to run to completion. `unienc_harness_web` +therefore drives the pipeline the way Unity does in production: a callback per +animation frame, polling whatever has become ready. +`emscripten_set_main_loop` here plays the part `unienc_tick_runtime` plays there. + +ASYNCIFY looks like an easier answer and is not one. It unwinds the wasm stack +while suspended, and the encoder callbacks re-enter wasm during exactly that +window, which is the reentrancy it does not support. Trying it deadlocks. + +Two more things are specific to this target: + +- **The runtime has no threads.** Emscripten is built here without pthreads, so + `TestRuntime` uses a `LocalPool` rather than a thread pool, matching the + `--no-default-features` build `unienc_c` ships for the web. Blocking work runs + inline because there is nowhere to offload it to. +- **The muxer downloads its output instead of writing a file**, so there would be + nothing for the verification to read. Rather than give the backend a test-only + mode, `unienc_testkit::web::capture_muxed_output` intercepts the one JavaScript + function the muxer calls and writes the same bytes into the in-memory + filesystem. Nothing in `unienc_webcodecs` knows the difference. + +emrun serves the page, forwards its stdout and turns the harness's exit status +into the script's. Chrome's software H.264 encoder (OpenH264) does the encoding on +a machine without hardware support. + +The link flags in `scripts/web-browser-test.sh` are not incidental. The backend's +own JavaScript reaches for `_malloc`, `_free` and the heap views, and it does so +from inside an encoder callback — a browser task, where a `TypeError` is reported +nowhere the harness can see. Omitting those exports does not fail the build or +raise an error: encoded chunks simply never arrive, and the run hangs. The page +errors the runner's HTML forwards to stdout exist for the same reason. + +`cargo` does not treat `EMCC_CFLAGS` as a build input, so a change to the link +flags would leave the previous binary in place and appear to have no effect. The +script records the flags it used and forces a relink when they differ. + +## Real devices + +Both mobile harnesses run unchanged on real hardware, but neither is wired into +CI: + +- **Android**: connect a device with USB debugging and pass `-s `. +- **iOS**: a real device needs the binary wrapped in a signed app bundle. + `cargo-dinghy` automates that, at the cost of a signing identity. An Xcode test + target linking `libunienc_c.a` is the other option, and the only way to cover + the static library link described above. + +Run these before a release, and whenever a change touches a platform backend's +interaction with the vendor encoder. diff --git a/InstantReplay.Externals/unienc/scripts/android-device-test.sh b/InstantReplay.Externals/unienc/scripts/android-device-test.sh new file mode 100755 index 00000000..181b19a9 --- /dev/null +++ b/InstantReplay.Externals/unienc/scripts/android-device-test.sh @@ -0,0 +1,151 @@ +#!/usr/bin/env bash +# +# Runs the unienc end-to-end harness on a connected Android device or emulator. +# +# There is no APK and no Gradle project. MediaCodec and MediaMuxer need a JavaVM +# but neither an Activity nor a Context, so a JVM is the only thing a shell is +# missing, and `app_process` supplies one. The same command therefore works +# against an emulator and a real device, which matters because the defects worth +# catching here are the ones specific to a vendor's hardware encoder. +# +# The GPU blit path is not covered: it needs a Unity graphics device. +# +# Usage: +# scripts/android-device-test.sh [--release] [-s ] +# +# Requirements: ANDROID_HOME (or ANDROID_SDK_ROOT), ANDROID_NDK_HOME, cargo-ndk, +# a JDK for javac, and adb build-tools for d8. + +set -euo pipefail + +profile=dev +profile_dir=debug +serial="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --release) + profile=release + profile_dir=release + shift + ;; + -s) + serial="$2" + shift 2 + ;; + *) + echo "unknown argument: $1" >&2 + exit 2 + ;; + esac +done + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$here" + +sdk="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}}" +if [[ -z "$sdk" ]]; then + echo "set ANDROID_HOME to the Android SDK" >&2 + exit 1 +fi +if [[ -z "${ANDROID_NDK_HOME:-}" ]]; then + # cargo-ndk needs this and gives a less obvious error without it. + echo "set ANDROID_NDK_HOME to an NDK under $sdk/ndk" >&2 + exit 1 +fi + +# Kept as one array including the binary so that an empty serial does not +# expand to an unset element, which bash 3.2 rejects under `set -u`. +adb=("$sdk/platform-tools/adb") +if [[ -n "$serial" ]]; then + adb+=(-s "$serial") +fi +# Any recent build-tools release will do; take the highest installed. +build_tools="$(ls -1 "$sdk/build-tools" | sort -V | tail -1)" +d8="$sdk/build-tools/$build_tools/d8" + +# The library has to match the device, not the host. +abi="$("${adb[@]}" shell getprop ro.product.cpu.abi | tr -d '\r')" +case "$abi" in + arm64-v8a) triple=aarch64-linux-android ;; + armeabi-v7a) triple=armv7-linux-androideabi ;; + x86_64) triple=x86_64-linux-android ;; + *) + echo "unsupported device ABI: $abi" >&2 + exit 1 + ;; +esac + +echo "==> device ABI $abi, building $profile" +# --platform 26 matches the minimum the Java API bindings are checked against. +cargo ndk -t "$abi" --platform 26 build --profile "$profile" -p unienc_harness_android + +library="target/$triple/$profile_dir/libunienc_harness_android.so" +[[ -f "$library" ]] || { + echo "missing $library" >&2 + exit 1 +} + +echo "==> building the JVM shim" +staging="$(mktemp -d)" +trap 'rm -rf "$staging"' EXIT +java_src=crates/unienc_harness_android/java/jp/co/cyberagent/unienc/harness/Harness.java +# The shim only touches java.lang, so it needs no android.jar to compile. +javac --release 11 -d "$staging/classes" "$java_src" +"$d8" --min-api 26 --output "$staging" \ + "$staging/classes/jp/co/cyberagent/unienc/harness/Harness.class" + +remote=/data/local/tmp/unienc-harness +echo "==> pushing to $remote" +"${adb[@]}" shell "rm -rf $remote && mkdir -p $remote" +"${adb[@]}" push "$library" "$remote/libunienc_harness_android.so" >/dev/null +"${adb[@]}" push "$staging/classes.dex" "$remote/harness.dex" >/dev/null + +echo "==> running" +# Start from an empty log so the dump below only holds this run, and so a native +# crash or an ANR is attributable. +"${adb[@]}" logcat -c 2>/dev/null || true + +# The harness runs under the device's own `timeout` rather than a host-side one, +# because what hangs is the process on the device. Without this a hang holds the +# job until its overall timeout and leaves nothing to look at. TERM first, then +# KILL, so a process ignoring TERM still goes. +# +# app_process wants a "parent directory" argument it does not use for anything +# here, and finds the shim through CLASSPATH. +set +e +"${adb[@]}" shell "cd $remote && timeout -s KILL ${UNIENC_TEST_TIMEOUT:-300} env CLASSPATH=$remote/harness.dex ${UNIENC_TEST_THREADS:+UNIENC_TEST_THREADS=$UNIENC_TEST_THREADS} app_process $remote jp.co.cyberagent.unienc.harness.Harness $remote/libunienc_harness_android.so $remote/e2e.mp4; echo EXIT:\$?" \ + | tr -d '\r' | tee "$staging/output" +set -e + +status="$(grep '^EXIT:' "$staging/output" | tail -1 | cut -d: -f2)" + +# `timeout` reports 137 for a KILL. Say so plainly: the distinction between a +# hang and a failed check matters more than the exit code. +if [[ "${status:-1}" == "137" ]]; then + echo "==> the harness did not finish within ${UNIENC_TEST_TIMEOUT:-300}s and was killed" >&2 +fi + +# Keep the muxed file for inspection whether or not the checks passed. +if "${adb[@]}" shell "test -f $remote/e2e.mp4" 2>/dev/null; then + mkdir -p target/android-harness + "${adb[@]}" pull "$remote/e2e.mp4" target/android-harness/e2e.mp4 >/dev/null + echo "==> pulled target/android-harness/e2e.mp4" +fi + +# On failure the device log is the only place a native crash, a missing library +# or an ART complaint shows up; the harness's own output stops at whatever it +# managed to print. +if [[ "${status:-1}" != "0" ]]; then + mkdir -p target/android-harness + "${adb[@]}" logcat -d > target/android-harness/logcat.txt 2>/dev/null || true + echo "==> device log saved to target/android-harness/logcat.txt" >&2 + # Narrow to the codec and process machinery. A `google_apis` image runs Play + # services, whose logging drowns out everything else, and the harness's own + # output goes to stdout rather than here. + echo "--- last 40 relevant lines ---" >&2 + grep -aiE "unienc|harness|app_process|AndroidRuntime|DEBUG *:|CCodec|Codec2|MediaCodec|OMX|c2\.android|ACodec|BufferQueue" \ + target/android-harness/logcat.txt | tail -40 >&2 || true +fi + +exit "${status:-1}" diff --git a/InstantReplay.Externals/unienc/scripts/ios-simulator-test.sh b/InstantReplay.Externals/unienc/scripts/ios-simulator-test.sh new file mode 100755 index 00000000..158ec0b2 --- /dev/null +++ b/InstantReplay.Externals/unienc/scripts/ios-simulator-test.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# +# Runs the unienc end-to-end harness inside an iOS simulator. +# +# A Rust test binary is a plain executable, so simctl can run one directly: no +# app bundle, no provisioning profile, no code signing. The runner that hands the +# binary to simctl is configured in .cargo/config.toml, so all this script has to +# do is make sure a simulator is booted first. +# +# VideoToolbox does encode H.264 in the simulator, so this covers the encoders' +# own logic. It does not cover the static library link, where the iOS build +# differs from macOS most, nor a real device's hardware encoder. Both still need +# a device and a signing identity. +# +# Usage: +# scripts/ios-simulator-test.sh [] +# +# With no argument, any already booted simulator is used, otherwise the newest +# available iPhone is booted. + +set -euo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$here" + +case "$(uname -m)" in + arm64) target=aarch64-apple-ios-sim ;; + x86_64) target=x86_64-apple-ios ;; + *) + echo "unsupported host architecture: $(uname -m)" >&2 + exit 1 + ;; +esac + +wanted="${1:-}" +if [[ -n "$wanted" ]]; then + device="$wanted" + # Consumed here so that anything after it reaches cargo. + shift +else + device="$(xcrun simctl list devices booted | grep -oE '\(([0-9A-F-]{36})\)' | head -1 | tr -d '()' || true)" + if [[ -z "$device" ]]; then + # Newest runtime last, so the last iPhone listed is the newest. The + # character class is spelled out because BSD grep does not take \s, and + # `|| true` keeps a no-match from ending the script through `set -e` + # before the message below can explain what is missing. + device="$(xcrun simctl list devices available \ + | grep -E '^[[:space:]]+iPhone' | tail -1 \ + | grep -oE '\(([0-9A-F-]{36})\)' | head -1 | tr -d '()' || true)" + if [[ -z "$device" ]]; then + echo "no iPhone simulator is available; install one through Xcode" >&2 + exit 1 + fi + fi +fi + +echo "==> booting $device" +# Already booted is not an error worth stopping for. +xcrun simctl boot "$device" 2>/dev/null || true +xcrun simctl bootstatus "$device" -b + +echo "==> testing on $target" +# The first run in a freshly booted simulator can take a minute or so while the +# media frameworks load for the first time. +rustup target add "$target" >/dev/null +exec cargo test -p unienc_testkit --target "$target" "$@" diff --git a/InstantReplay.Externals/unienc/scripts/run-in-browser.sh b/InstantReplay.Externals/unienc/scripts/run-in-browser.sh new file mode 100755 index 00000000..06db4b9b --- /dev/null +++ b/InstantReplay.Externals/unienc/scripts/run-in-browser.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# +# Cargo runner for wasm32-unknown-emscripten: runs a test binary in a browser. +# +# The WebCodecs backend drives the browser's own encoders through +# `emscripten_run_script` and touches `window`, so there is no headless +# JavaScript runtime that can stand in for a real browser. emrun serves the page, +# forwards its stdout and reports its exit status, which is what makes a browser +# usable as a cargo test runner at all. +# +# emcc emits a .js module rather than a page, so a minimal HTML shell to load it +# is generated here. `Module.arguments` is how the test binary receives the +# arguments cargo appends, such as a test name filter or --nocapture. +# +# Invoked through .cargo/config.toml; see scripts/web-browser-test.sh for the +# wrapper that sets up the build. + +set -euo pipefail + +module="$1" +shift + +if [[ ! -f "$module" ]]; then + echo "no such module: $module" >&2 + exit 1 +fi + +browser="${UNIENC_TEST_BROWSER:-/Applications/Google Chrome.app/Contents/MacOS/Google Chrome}" +if [[ ! -e "$browser" ]]; then + # On Linux runners Chrome is on PATH under one of these names. + for candidate in google-chrome chromium chromium-browser; do + if command -v "$candidate" >/dev/null; then + browser="$(command -v "$candidate")" + break + fi + done +fi +if [[ ! -e "$browser" ]]; then + echo "no browser found; set UNIENC_TEST_BROWSER" >&2 + exit 1 +fi + +# Cargo's arguments become the process arguments, as a JSON array for the shell. +arguments="" +for argument in "$@"; do + escaped="${argument//\\/\\\\}" + escaped="${escaped//\"/\\\"}" + arguments+="\"$escaped\"," +done + +page="${module%.js}.html" +cat >"$page" < + +unienc harness + + + + + +HTML + +# --kill-start clears any browser left behind by an interrupted run: it would +# still be showing the previous page and posting its output to this server, which +# is indistinguishable from the current run misbehaving. +# --kill-exit stops the browser once the page calls exit, otherwise emrun waits +# for a window that headless Chrome never shows. The silence timeout is the +# backstop for a page that fails before it can report anything. +# A stale server from an interrupted run would otherwise hold the default port +# and this would fail with nothing but a Python traceback. +exec emrun \ + --port "${UNIENC_TEST_PORT:-6931}" \ + --browser "$browser" \ + --browser-args="--headless=new --no-sandbox --disable-gpu --autoplay-policy=no-user-gesture-required" \ + --kill-start \ + --kill-exit \ + --silence-timeout "${UNIENC_TEST_SILENCE_TIMEOUT:-120}" \ + --timeout "${UNIENC_TEST_TIMEOUT:-300}" \ + "$page" diff --git a/InstantReplay.Externals/unienc/scripts/web-browser-test.sh b/InstantReplay.Externals/unienc/scripts/web-browser-test.sh new file mode 100755 index 00000000..a1653bce --- /dev/null +++ b/InstantReplay.Externals/unienc/scripts/web-browser-test.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# +# Runs the unienc end-to-end harness in a browser, on WebAssembly. +# +# There is no headless JavaScript runtime that can stand in for a browser here: +# the WebCodecs backend drives the browser's own encoders and reaches for +# `window`, so the harness is a page. emrun serves it, forwards its stdout and +# turns its exit status into this script's. +# +# The build follows build-unienc.yml: nightly with build-std, because the +# Emscripten target has no prebuilt std, and mvp so the output runs where Unity's +# does. +# +# Requirements: EMSDK (an activated emsdk, i.e. `source emsdk_env.sh`), a nightly +# toolchain with rust-src, and Chrome or Chromium. + +set -euo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$here" + +if ! command -v emcc >/dev/null; then + echo "emcc is not on PATH; source your emsdk's emsdk_env.sh first" >&2 + exit 1 +fi + +profile=dev +profile_dir=debug +if [[ "${1:-}" == "--release" ]]; then + # The wasm profile is where the workspace keeps its size settings. + profile=release-wasm + profile_dir=release-wasm + shift +fi + +# What the backend's own JavaScript reaches for has to be exported, or it fails +# inside a browser task where nothing reports it: +# _malloc, _free copying an encoded chunk out of the browser's encoder +# HEAPU8, HEAPU32 the same copies, and reading the muxer's fragments back +# UTF8ToString reading string arguments +# printErr read by emrun's own injected code; a read of an +# unexported runtime method aborts the page +# The harness itself needs two more: +# FORCE_FILESYSTEM, FS reading the muxed file back out of the in-memory +# filesystem, which a build making no filesystem calls +# would otherwise omit +# EXIT_RUNTIME reporting pass or fail as an exit status +export EMCC_CFLAGS="${EMCC_CFLAGS:-} -sEXIT_RUNTIME=1 -sALLOW_MEMORY_GROWTH=1 -sFORCE_FILESYSTEM=1 -sEXPORTED_FUNCTIONS=_main,_malloc,_free -sEXPORTED_RUNTIME_METHODS=FS,UTF8ToString,HEAPU8,HEAPU32,printErr --emrun" + +# cargo does not treat EMCC_CFLAGS as a build input, so a changed link line would +# otherwise be ignored and the stale binary reused — which is hard to spot, +# because the symptom is the previous flags still being in effect. +flags_stamp="target/.emcc-cflags-$profile_dir" +if [[ ! -f "$flags_stamp" || "$(cat "$flags_stamp")" != "$EMCC_CFLAGS" ]]; then + mkdir -p "$(dirname "$flags_stamp")" + printf '%s' "$EMCC_CFLAGS" >"$flags_stamp" + touch crates/unienc_harness_web/src/main.rs +fi + +echo "==> building for wasm32-unknown-emscripten ($profile)" +rustup target add wasm32-unknown-emscripten >/dev/null +rustup component add rust-src --toolchain nightly >/dev/null +RUSTFLAGS="${RUSTFLAGS:-} -Ctarget-cpu=mvp" \ + cargo +nightly build -Z build-std=panic_abort,std \ + --target wasm32-unknown-emscripten --profile "$profile" \ + -p unienc_harness_web + +module="target/wasm32-unknown-emscripten/$profile_dir/unienc_harness_web.js" +[[ -f "$module" ]] || { + echo "missing $module" >&2 + exit 1 +} + +echo "==> running in a browser" +exec scripts/run-in-browser.sh "$module" "$@"