Three defects in the 0.23.0 timeline presenter, all found while root-causing the field report that turned out to be the slice wire. None of them is that bug; all three are real, and the first is the one that would still bite once it is fixed. The panel-period learner could only ever narrow. It is seeded from the display mode Kotlin asked for — and `preferredDisplayModeId` is a REQUEST the system may refuse (Smooth Display off, battery saver, thermal, an OEM governor). Ask for 120 Hz on a panel that stays at 60 and the presenter pins an 8.33 ms grid on a 16.67 ms display with no way back, for the rest of the session: it then aims at instants that never arrive and releases faster than the panel scans. The learner moves both ways now, and lives in `punktfunk_core::phase::PanelGrid` where it is host-testable and where the iOS and desktop presenters can share it. The asymmetry is kept and made explicit — narrowing is immediate (a finer real grid is always safe to subdivide onto, and it is the per-uid down-rate case the seed most often gets wrong), widening needs eight consecutive agreeing observations and then takes the narrowest of them, because one wide sample is a missed callback and eight in a row is a display that really did slow down. The glass budget was a prediction with nothing underneath it. `OnFrameRendered` already reports what actually reached glass, but the budget never consulted it, so a wrong grid could hand SurfaceFlinger frames indefinitely: BufferQueue fills, MediaCodec runs out of output buffers, the decoder stalls, and the no-output backstop starts begging for keyframes. Releases are now counted against their confirms and the presenter holds back past six outstanding — loose on purpose, since the callbacks are allowed to arrive batched and a held frame in the newest-wins slot is a dropped one. It self-clears when the confirms catch up, and writes the ledger off after the same 100 ms the stale reopen uses, so a platform that stops confirming can never wedge the stream. `qWait` and `unconfirmed` join the 1 Hz pf.present line, which is what would have made this visible from a log. The adaptive latch margin widened on `paced_drops` — the newest-wins store's own policy evictions, which happen whenever the stream out-runs the panel and say nothing about SurfaceFlinger's latch lead. On a healthy device that walked the margin to its 2.5 ms ceiling and re-imposed the display latency the P2e sweep had just measured away. It now widens on the measured latch exceeding one panel period plus the live margin, which is what a missed vsync actually looks like. Also corrects two doc comments that named `display.refreshRate` as the panel_hz source; it has been the mode table since the A024 down-rate fix. Gates: 278 punktfunk-core lib tests (7 new PanelGrid cases incl. the refused-mode regression), clippy -D warnings and fmt clean, cargo ndk check green on arm64 and armv7. Android clippy reports the same 4 warnings as the base commit and no new ones. NOT yet confirmed on glass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
144 lines
8.3 KiB
Rust
144 lines
8.3 KiB
Rust
//! Android video decode (android-only): pull HEVC access units from the connector and render them
|
|
//! to the SurfaceView via NDK `AMediaCodec` — hardware decode, zero per-frame JNI.
|
|
//!
|
|
//! One-in/one-out: the host opens every stream with an IDR carrying VPS/SPS/PPS **in-band**, so the
|
|
//! decoder needs no out-of-band codec-specific data — we configure with mime + the negotiated
|
|
//! WxH (from [`NativeClient::mode`]) and feed each access unit as it arrives. The decode thread owns
|
|
//! the codec + window for its whole life; [`crate::session`] signals it to stop via the shared flag.
|
|
|
|
mod async_loop;
|
|
mod display;
|
|
mod latency;
|
|
mod presenter;
|
|
mod setup;
|
|
mod sync_loop;
|
|
mod vsync;
|
|
|
|
use async_loop::run_async;
|
|
pub(crate) use setup::{codec_label, codec_mime};
|
|
use sync_loop::run_sync;
|
|
|
|
use ndk::native_window::NativeWindow;
|
|
use punktfunk_core::client::NativeClient;
|
|
use std::sync::atomic::AtomicBool;
|
|
use std::sync::Arc;
|
|
|
|
/// Cap on AUs parked in the async loop awaiting a free codec input slot. Matches the connector's
|
|
/// own frame-channel depth; on sustained overflow the oldest is dropped and a keyframe requested
|
|
/// (same recovery as a reassembler drop). In steady state this stays near-empty.
|
|
const FRAME_PARK_CAP: usize = 16;
|
|
|
|
/// Cap on the pts→received-timestamp map below: MediaCodec holds only a handful of frames in
|
|
/// flight, so anything beyond this is stale (codec flushed / HUD toggled) and gets evicted.
|
|
const IN_FLIGHT_CAP: usize = 64;
|
|
|
|
/// Cap on received AUs awaiting their 0xCF host timing (Phase 2 host/network split): the timing
|
|
/// datagram trails its AU by at most the wire, so a match lands within a frame or two — anything
|
|
/// this deep is a lost datagram (or an old host that never sends any) and gets evicted.
|
|
const PENDING_SPLIT_CAP: usize = 256;
|
|
|
|
/// Cap on rendered frames parked in [`DisplayTracker`] awaiting their `OnFrameRendered` render
|
|
/// timestamp: the callback trails its release by at most a vsync or two, so anything this deep
|
|
/// means the platform stopped delivering render callbacks (allowed under load, per the docs) and
|
|
/// gets evicted.
|
|
const RENDERED_CAP: usize = 64;
|
|
|
|
/// How long the decoder may be FED while producing nothing before we treat it as un-anchored and
|
|
/// ask the host for a fresh IDR.
|
|
///
|
|
/// The shared gate's per-AU streak ([`punktfunk_core::reanchor::ReanchorGate::on_no_output`]) can't
|
|
/// be used verbatim here: it counts one-in/one-out decodes (the desktop clients' `LOW_DELAY`
|
|
/// libavcodec path and Apple's VideoToolbox), while MediaCodec is pipelined — inputs and outputs
|
|
/// don't pair up, so "this AU produced no output" isn't a thing this loop can observe. A wall-clock
|
|
/// silence window is the same signal in the shape Android can measure.
|
|
///
|
|
/// Why it matters: the host opens a stream with an IDR and, under infinite GOP, sends no other one
|
|
/// unless asked. Miss that one — the decode thread only starts at `surfaceCreated`, so a slow TV box
|
|
/// can be handed the stream mid-GOP — and every later AU references a picture the decoder never had.
|
|
/// A hardware decoder doesn't error on that; it simply emits nothing. Without this backstop the
|
|
/// session sat there forever: AUs arriving, a healthy HUD, and a black surface, because nothing in
|
|
/// the Android loops ever asked for the keyframe that would re-anchor it.
|
|
///
|
|
/// 500 ms because it must never fire on a decoder that is merely slow to spin up: even the pokiest
|
|
/// hardware decoder emits its first frame within a couple of frame periods, and a wedge that only
|
|
/// costs half a second before it self-heals is not a bug the user reports.
|
|
const NO_OUTPUT_PATIENCE: std::time::Duration = std::time::Duration::from_millis(500);
|
|
|
|
/// How long a session may deliver NO access unit at all before we ask for a keyframe and say so.
|
|
///
|
|
/// [`NO_OUTPUT_PATIENCE`] covers "fed but silent", and it deliberately requires `fed` to have moved
|
|
/// so an idle stream never asks for anything. That leaves its mirror image uncovered: a session that
|
|
/// receives nothing whatsoever. A decoder cannot be starved of output when it was handed no input,
|
|
/// so no signal in either loop fires, and the session sits connected — audio, input and the control
|
|
/// plane all alive — behind a black surface with a HUD reading `0 fps · 0.0 Mb/s`, which is exactly
|
|
/// how it comes back in reports (2026-07-30).
|
|
///
|
|
/// Asking costs one small control message, and it is the right ask in the case we can actually fix:
|
|
/// the host is encoding, but under infinite GOP every picture it sends references an IDR this client
|
|
/// never saw. When the host is sending nothing at all, the request changes nothing — but the log line
|
|
/// beside it is what separates that from "we received AUs and lost them", which no previous black
|
|
/// screen report could tell us.
|
|
const NO_VIDEO_PATIENCE: std::time::Duration = std::time::Duration::from_millis(1500);
|
|
|
|
/// Re-ask cadence once [`NO_VIDEO_PATIENCE`] has elapsed with still nothing received. Slow, because
|
|
/// this state is either self-healing on the first ask or not ours to heal — and each pass logs.
|
|
const NO_VIDEO_RETRY: std::time::Duration = std::time::Duration::from_millis(2000);
|
|
|
|
/// Whether low-latency mode uses the event-driven async decode loop (default) or the synchronous
|
|
/// poll loop. Flip to `false` to A/B the two on the HUD (`design/…`); the async loop presents a
|
|
/// decoded frame the instant it's ready instead of waiting out a poll interval. Only consulted when
|
|
/// the user's "Low-latency mode" toggle is ON (now the default) — off, the sync loop always runs (the
|
|
/// original pipeline, kept as the per-device escape hatch).
|
|
const USE_ASYNC_DECODE: bool = true;
|
|
|
|
/// Per-session decode configuration, resolved by the JNI layer (`nativeStartVideo`) and passed to
|
|
/// the decode loop. Bundled so the loop entry points don't sprout a wide argument list.
|
|
pub(crate) struct DecodeOptions {
|
|
/// The decoder Kotlin ranked from `MediaCodecList` (`VideoDecoders.pickDecoder`). `None`/empty ⇒
|
|
/// let the platform resolve the default decoder for the MIME.
|
|
pub decoder_name: Option<String>,
|
|
/// Whether Kotlin found the chosen decoder advertises `FEATURE_LowLatency` (queryable only via
|
|
/// the Java `CodecCapabilities` API) — surfaced on the HUD next to the decoder name.
|
|
pub ll_feature: bool,
|
|
/// The user's "Low-latency mode" master toggle. On (default) ⇒ the full fast pipeline: async
|
|
/// decode loop, per-SoC vendor keys, pipeline thread boosts, ADPF max-performance, forced TV
|
|
/// mode switch. Off ⇒ the original synchronous pre-overhaul pipeline, kept as the per-device
|
|
/// escape hatch.
|
|
pub low_latency_mode: bool,
|
|
/// TV form factor (Kotlin's `UiModeManager`): actively drive the HDMI output into the stream's
|
|
/// refresh mode, vs. the softer seamless hint on a phone/tablet.
|
|
pub is_tv: bool,
|
|
/// The user's presentation intent (`present_priority` setting): 0 = lowest latency
|
|
/// (newest-wins), 1 = smoothness (a small FIFO). Resolved by
|
|
/// [`presenter::PresentPriority::resolve`]; anything else = latency.
|
|
pub present_priority: i32,
|
|
/// The smoothness buffer depth (`smooth_buffer` setting): 0 = automatic (2), else 1..=3.
|
|
/// Only meaningful with `present_priority` = smooth.
|
|
pub smooth_buffer: i32,
|
|
/// SEED for the panel's refresh period — the latch grid the presenter subdivides onto when
|
|
/// the app's choreographer stream is down-rated below the panel (see `vsync.rs`). Kotlin
|
|
/// resolves it from the display mode TABLE (`MainActivity.streamPanelFps`), not
|
|
/// `display.refreshRate`, which reports a per-uid override rather than the panel. 0 = unknown.
|
|
///
|
|
/// ⚠ Only a seed: `preferredDisplayModeId` is a REQUEST the system may refuse, so the mode
|
|
/// named here is not necessarily the one the panel ends up in. The measured timeline spacing
|
|
/// corrects it in both directions ([`punktfunk_core::phase::PanelGrid`]).
|
|
pub panel_hz: i32,
|
|
}
|
|
|
|
/// The decode entry point on the `pf-decode` thread: dispatches to the async or synchronous loop.
|
|
/// Both run until `shutdown` is set or the session closes.
|
|
pub fn run(
|
|
client: Arc<NativeClient>,
|
|
window: NativeWindow,
|
|
shutdown: Arc<AtomicBool>,
|
|
stats: Arc<crate::stats::VideoStats>,
|
|
opts: DecodeOptions,
|
|
) {
|
|
if opts.low_latency_mode && USE_ASYNC_DECODE {
|
|
run_async(client, window, shutdown, stats, opts);
|
|
} else {
|
|
run_sync(client, window, shutdown, stats, opts);
|
|
}
|
|
}
|