refactor(android/W8): split decode.rs into decode/ directory module
Break the 1747-line clients/android/native/src/decode.rs into a decode/ directory
module (mod.rs + 5 concern submodules):
- decode/setup.rs : codec creation + low-latency config + thread/frame-rate
tuning + HDR static-info encode
- decode/display.rs : DisplayTracker + render-callback registration + HDR dataspace
- decode/latency.rs : realtime clock + decoded-pts / user-flags stat recording
- decode/sync_loop.rs : the synchronous poll decode loop (+ feed/drain) — moved WHOLE
- decode/async_loop.rs : the event-driven async decode loop (+ helpers) — moved WHOLE
decode/mod.rs keeps the consts, DecodeOptions, and the `run` entry point + the
`codec_mime`/`codec_label` re-export, so every crate::decode::X path stays byte-stable.
The module has no decoder struct (free functions + small types), so both decode loops
move byte-for-byte and their separately-inlined received-stat recording is NOT unified.
16 helper fns/types became pub(super) for sibling access; zero field bumps. lib.rs
unchanged (`#[cfg(target_os="android")] mod decode;` resolves to decode/mod.rs).
Verified: cargo-ndk check (aarch64-linux-android, clean) + the gradle cargoNdkDebug
build (arm64-v8a / armeabi-v7a / x86_64). On-device runtime re-verification still owed
per the plan (the two decode loops are a hot path).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,254 @@
|
||||
//! Codec creation, low-latency config, thread/frame-rate tuning, HDR static-info encode.
|
||||
|
||||
use ndk::media::media_codec::MediaCodec;
|
||||
use ndk::media::media_format::MediaFormat;
|
||||
use ndk::native_window::NativeWindow;
|
||||
use std::ffi::c_void;
|
||||
|
||||
/// The MediaCodec MIME for the codec the host resolved (`Welcome.codec`). Shared by the decode
|
||||
/// thread and `nativeVideoMime` (which tells Kotlin what to rank decoders for). AV1 uses the
|
||||
/// AOSP `video/av01` type; anything not H.264/AV1 is treated as HEVC (every pre-negotiation host
|
||||
/// emitted HEVC).
|
||||
pub(crate) fn codec_mime(codec: u8) -> &'static str {
|
||||
match codec {
|
||||
punktfunk_core::quic::CODEC_H264 => "video/avc",
|
||||
punktfunk_core::quic::CODEC_AV1 => "video/av01",
|
||||
_ => "video/hevc",
|
||||
}
|
||||
}
|
||||
|
||||
/// A short human label for the codec the host resolved, for the stats HUD's video-feed line
|
||||
/// (`"H.264"` / `"HEVC"` / `"AV1"` / `"PyroWave"`). Mirrors [`codec_mime`]'s fallback: anything
|
||||
/// not H.264/AV1/PyroWave is reported as HEVC (every pre-negotiation host emitted HEVC). Kept
|
||||
/// beside [`codec_mime`] because the MIME collapses PyroWave onto `video/hevc` and so can't name it.
|
||||
pub(crate) fn codec_label(codec: u8) -> &'static str {
|
||||
match codec {
|
||||
punktfunk_core::quic::CODEC_H264 => "H.264",
|
||||
punktfunk_core::quic::CODEC_AV1 => "AV1",
|
||||
punktfunk_core::quic::CODEC_PYROWAVE => "PyroWave",
|
||||
_ => "HEVC",
|
||||
}
|
||||
}
|
||||
|
||||
/// Create the decoder: prefer the specific codec Kotlin ranked from `MediaCodecList`
|
||||
/// (`from_codec_name`), falling back to the platform's default decoder for the MIME
|
||||
/// (`from_decoder_type`) if that name can't be created (codec busy / renamed across an OS update).
|
||||
pub(super) fn create_codec(mime: &str, preferred: Option<&str>) -> Option<MediaCodec> {
|
||||
if let Some(name) = preferred.filter(|n| !n.is_empty()) {
|
||||
if let Some(c) = MediaCodec::from_codec_name(name) {
|
||||
return Some(c);
|
||||
}
|
||||
log::warn!(
|
||||
"decode: from_codec_name({name}) failed — falling back to default {mime} decoder"
|
||||
);
|
||||
}
|
||||
MediaCodec::from_decoder_type(mime)
|
||||
}
|
||||
|
||||
/// Apply the low-latency MediaFormat keys for `codec_name`.
|
||||
///
|
||||
/// `aggressive` = the "Low-latency mode" master toggle. **Off** ⇒ the pre-overhaul key set,
|
||||
/// byte-for-byte — the standard `low-latency` key, the blind Qualcomm vendor twin, `priority = 0` AND
|
||||
/// `operating-rate = MAX` set together — kept as the per-device escape hatch (the profile every device
|
||||
/// streamed with before the overhaul). **On** (default) ⇒ the Moonlight-parity
|
||||
/// profile: MediaTek's `vdec-lowlatency` (unconditionally — ignored off MediaTek), the per-SoC
|
||||
/// vendor extension keys (gated on the decoder-name prefix the way Moonlight-Android does, since a
|
||||
/// key one vendor honours is meaningless on another), and one *mutually exclusive* clock hint.
|
||||
///
|
||||
/// Vendor keys mirror Moonlight's `MediaCodecHelper` (verified against current source): Qualcomm
|
||||
/// picture-order + low-latency, Exynos (also Google Tensor), Amlogic, HiSilicon, MediaTek. NVIDIA
|
||||
/// Tegra / Rockchip / Realtek expose no such key (nor does Moonlight) — they're covered by the
|
||||
/// standard key + clock hint + being ranked first in `VideoDecoders`.
|
||||
pub(super) fn configure_low_latency(format: &mut MediaFormat, codec_name: &str, aggressive: bool) {
|
||||
// Standard key: request the no-reorder low-latency path where the platform decoder supports it.
|
||||
format.set_i32("low-latency", 1);
|
||||
if !aggressive {
|
||||
// The original profile: the Qualcomm vendor twin set blind (unknown keys are ignored by
|
||||
// other vendors' codecs), realtime priority, and the AOSP "unbounded" operating-rate
|
||||
// sentinel — decode each frame at max clocks rather than pacing to the frame rate.
|
||||
format.set_i32("vendor.qti-ext-dec-low-latency.enable", 1);
|
||||
format.set_i32("priority", 0); // 0 = realtime
|
||||
format.set_i32("operating-rate", i16::MAX as i32); // 32767 = "as fast as possible"
|
||||
return;
|
||||
}
|
||||
// MediaTek's low-latency key — very common (mid/budget phones + many Google TV / Fire TV boxes).
|
||||
// Set unconditionally like the standard key: MediaTek decoders honour it, others ignore it, so it
|
||||
// covers MediaTek whatever the exact decoder name (omx.mtk / c2.mtk / an OEM rename). Moonlight
|
||||
// does the same, and also relies on it for Amazon's Amlogic fork.
|
||||
format.set_i32("vdec-lowlatency", 1);
|
||||
let name = codec_name.to_ascii_lowercase();
|
||||
let is = |prefix: &str| name.starts_with(prefix);
|
||||
// Qualcomm Snapdragon (the most common phone SoC): picture-order forces decode-order output
|
||||
// (kills the reorder buffer on decoders that predate the standard key); low-latency is the older
|
||||
// vendor twin.
|
||||
if is("omx.qcom") || is("c2.qti") {
|
||||
format.set_i32("vendor.qti-ext-dec-picture-order.enable", 1);
|
||||
format.set_i32("vendor.qti-ext-dec-low-latency.enable", 1);
|
||||
}
|
||||
// Samsung Exynos — also covers Google Tensor (Pixel 6+), whose hardware decoder is `c2.exynos.*`.
|
||||
if is("omx.exynos") || is("c2.exynos") {
|
||||
format.set_i32("vendor.rtc-ext-dec-low-latency.enable", 1);
|
||||
}
|
||||
// Amlogic — the Android TV boxes (onn 4K, Chromecast w/ Google TV, Homatics).
|
||||
if is("omx.amlogic") || is("c2.amlogic") {
|
||||
format.set_i32("vendor.low-latency.enable", 1);
|
||||
}
|
||||
// HiSilicon / Kirin (older Huawei; paired req/rdy keys).
|
||||
if is("omx.hisi") || is("c2.hisi") {
|
||||
format.set_i32(
|
||||
"vendor.hisi-ext-low-latency-video-dec.video-scene-for-low-latency-req",
|
||||
1,
|
||||
);
|
||||
format.set_i32(
|
||||
"vendor.hisi-ext-low-latency-video-dec.video-scene-for-low-latency-rdy",
|
||||
-1,
|
||||
);
|
||||
}
|
||||
// NVIDIA Tegra (Shield TV) and Rockchip/Realtek (budget TV boxes / smart TVs) expose no
|
||||
// low-latency vendor key (Moonlight has none either) — their decoders are already low-latency
|
||||
// oriented, so the standard `low-latency` key + the clock hint below + being ranked first
|
||||
// (see `VideoDecoders`) is their treatment.
|
||||
//
|
||||
// Clock hint, mutually exclusive (matching Moonlight): the AOSP "unbounded" operating-rate
|
||||
// sentinel (Short.MAX) tells the decoder to run each frame at max clocks and finish ASAP rather
|
||||
// than pace to the frame rate — shaving per-frame decode latency at a power/heat cost. Only
|
||||
// Qualcomm is known to handle the sentinel; every other vendor mis-paces on it, so they get the
|
||||
// plain realtime `priority` hint instead.
|
||||
if decoder_supports_max_operating_rate(&name) {
|
||||
format.set_i32("operating-rate", i16::MAX as i32); // 32767 = "as fast as possible"
|
||||
} else {
|
||||
format.set_i32("priority", 0); // 0 = realtime
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a decoder tolerates `operating-rate = Short.MAX` rather than regressing on it. Follows
|
||||
/// Moonlight's allowlist: Qualcomm decoders honour the sentinel (the Adreno 620 generation is the
|
||||
/// known exception Moonlight excludes by GPU model — undetectable from native code here, so it
|
||||
/// rides the master toggle as its escape hatch). Other vendors fall back to the plain `priority`
|
||||
/// hint above.
|
||||
fn decoder_supports_max_operating_rate(name_lower: &str) -> bool {
|
||||
name_lower.starts_with("omx.qcom") || name_lower.starts_with("c2.qti")
|
||||
}
|
||||
|
||||
/// Raise the pipeline's OTHER hot threads — the core's data-plane pump (UDP receive + FEC
|
||||
/// reassembly) and the audio decode thread — toward the display band, matching this decode thread's
|
||||
/// own boost. `setpriority(PRIO_PROCESS, tid)` targets any task in the process, so we do it from
|
||||
/// here once their tids are known (the same set ADPF hints), without a per-platform priority hook
|
||||
/// in the shared core. Slightly below the decode thread's -10 so the display path still wins.
|
||||
/// Best-effort; skips this thread (already boosted) and is non-fatal if the platform refuses.
|
||||
pub(super) fn boost_hot_threads(tids: &[i32]) {
|
||||
// SAFETY: `gettid` is an always-safe syscall on the calling thread.
|
||||
let self_tid = unsafe { libc::gettid() };
|
||||
for &tid in tids {
|
||||
if tid == self_tid {
|
||||
continue;
|
||||
}
|
||||
// SAFETY: `setpriority` with PRIO_PROCESS + a live tid in our own process is an always-safe
|
||||
// syscall; a refusal is reported via the return value, not UB.
|
||||
unsafe {
|
||||
if libc::setpriority(libc::PRIO_PROCESS, tid as libc::id_t, -8) != 0 {
|
||||
log::debug!("decode: setpriority(-8) on hot tid {tid} failed (non-fatal)");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort: raise the decode thread toward Android's URGENT_DISPLAY band so background work
|
||||
/// can't preempt it under load (which shows up as late/dropped frames). Non-fatal if the platform
|
||||
/// refuses (foreground apps may set their own threads; the exact floor is policy-dependent).
|
||||
pub(super) fn boost_thread_priority() {
|
||||
// SAFETY: `gettid`/`setpriority` on the calling thread are always-safe syscalls. PRIO_PROCESS
|
||||
// with a TID targets that one task on Linux — the same idiom `Process.setThreadPriority` uses.
|
||||
unsafe {
|
||||
let tid = libc::gettid();
|
||||
if libc::setpriority(libc::PRIO_PROCESS, tid as libc::id_t, -10) != 0 {
|
||||
log::warn!(
|
||||
"decode: setpriority(-10) failed (non-fatal): {}",
|
||||
std::io::Error::last_os_error()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the surface's frame-rate hint to the stream's refresh so SurfaceFlinger picks a matching
|
||||
/// display mode and aligns vsync (no 60-in-120 judder). Both NDK entry points sit above our API-28
|
||||
/// floor, so both are dlsym-resolved at runtime (a hard import of a >floor symbol makes
|
||||
/// `dlopen`/`System.load` fail on every API-28/29 device, even where this path is never hit —
|
||||
/// mirrors [`crate::adpf`]):
|
||||
/// - On a **TV** (`is_tv`): `ANativeWindow_setFrameRateWithChangeStrategy` (**API 31**) with
|
||||
/// `changeFrameRateStrategy = ALWAYS`, which actively drives the HDMI output into the matching
|
||||
/// mode (e.g. 60↔120) instead of leaving the panel at its default and judder-matching. The
|
||||
/// forced switch may blank the panel briefly — acceptable once at stream start, not wanted on a
|
||||
/// phone. Falls through to the 2-arg hint on API 30.
|
||||
/// - Otherwise: `ANativeWindow_setFrameRate` (**API 30**) with `compatibility = DEFAULT` — the
|
||||
/// softer, seamless-preferred hint for phones/tablets and the universal fallback.
|
||||
///
|
||||
/// Returns `true` when the platform accepted a hint; `false` on API < 30 (symbols absent) or a
|
||||
/// decline.
|
||||
pub(super) fn try_set_frame_rate(window: &NativeWindow, frame_rate: f32, is_tv: bool) -> bool {
|
||||
// int32_t ANativeWindow_setFrameRate(ANativeWindow*, float frameRate, int8_t compatibility)
|
||||
type SetFrameRateFn = unsafe extern "C" fn(*mut c_void, f32, i8) -> i32;
|
||||
// int32_t ANativeWindow_setFrameRateWithChangeStrategy(
|
||||
// ANativeWindow*, float frameRate, int8_t compatibility, int8_t changeFrameRateStrategy)
|
||||
type SetFrameRateStrategyFn = unsafe extern "C" fn(*mut c_void, f32, i8, i8) -> i32;
|
||||
// SAFETY: `dlopen` of the always-mapped `libandroid.so` (only bumps its refcount; never closed —
|
||||
// process-lifetime handle). Each `dlsym` returns null when the symbol is absent (device below the
|
||||
// symbol's API level), checked before transmuting the non-null pointer to its fn-pointer type.
|
||||
// `window.ptr()` is the live `ANativeWindow` this `NativeWindow` owns for the call's duration.
|
||||
unsafe {
|
||||
let lib = libc::dlopen(c"libandroid.so".as_ptr(), libc::RTLD_NOW);
|
||||
if lib.is_null() {
|
||||
return false;
|
||||
}
|
||||
// TV: prefer the API-31 change-strategy form to force the mode switch (strategy 1 = ALWAYS,
|
||||
// compatibility 0 = DEFAULT). Absent on API 30 ⇒ fall through to the 2-arg hint below.
|
||||
if is_tv {
|
||||
let sym = libc::dlsym(
|
||||
lib,
|
||||
c"ANativeWindow_setFrameRateWithChangeStrategy".as_ptr(),
|
||||
);
|
||||
if !sym.is_null() {
|
||||
let set = std::mem::transmute::<*mut c_void, SetFrameRateStrategyFn>(sym);
|
||||
return set(window.ptr().as_ptr().cast(), frame_rate, 0, 1) == 0;
|
||||
}
|
||||
}
|
||||
let sym = libc::dlsym(lib, c"ANativeWindow_setFrameRate".as_ptr());
|
||||
if sym.is_null() {
|
||||
return false; // device API < 30 — no per-surface frame-rate hint
|
||||
}
|
||||
let set_frame_rate = std::mem::transmute::<*mut c_void, SetFrameRateFn>(sym);
|
||||
set_frame_rate(window.ptr().as_ptr().cast(), frame_rate, 0) == 0
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialize [`HdrMeta`](punktfunk_core::quic::HdrMeta) into Android's `KEY_HDR_STATIC_INFO`
|
||||
/// (`hdr-static-info`) layout: a 25-byte CTA-861.3 / `HDRStaticInfo.Type1` blob — descriptor id 0,
|
||||
/// then primaries in **R, G, B** order, white point, max/min display luminance, MaxCLL, MaxFALL, all
|
||||
/// **little-endian** `u16`. Two conversions vs our wire form: HdrMeta stores primaries in ST.2086
|
||||
/// **G, B, R** order (reorder to R, G, B), and `max_display_mastering_luminance` is in 0.0001-cd/m²
|
||||
/// units while Android wants **whole nits** (min stays 0.0001-nit). Chromaticities (1/50000) and
|
||||
/// MaxCLL/MaxFALL (nits) match 1:1.
|
||||
pub(super) fn android_hdr_static_info(m: &punktfunk_core::quic::HdrMeta) -> [u8; 25] {
|
||||
let [g, b_, r] = m.display_primaries; // ST.2086 G, B, R
|
||||
let max_nits = (m.max_display_mastering_luminance / 10_000).min(u16::MAX as u32) as u16;
|
||||
let min_units = m.min_display_mastering_luminance.min(u16::MAX as u32) as u16;
|
||||
let fields: [u16; 12] = [
|
||||
r[0],
|
||||
r[1],
|
||||
g[0],
|
||||
g[1],
|
||||
b_[0],
|
||||
b_[1], // R, G, B primaries
|
||||
m.white_point[0],
|
||||
m.white_point[1], // white point
|
||||
max_nits,
|
||||
min_units, // max (nits) / min (0.0001-nit) display luminance
|
||||
m.max_cll,
|
||||
m.max_fall, // MaxCLL / MaxFALL (nits)
|
||||
];
|
||||
let mut out = [0u8; 25]; // out[0] = 0 (Type 1 descriptor id), already zero
|
||||
for (i, v) in fields.iter().enumerate() {
|
||||
out[1 + i * 2..3 + i * 2].copy_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
out
|
||||
}
|
||||
Reference in New Issue
Block a user