ci / web (push) Successful in 1m59s
ci / docs-site (push) Successful in 2m13s
ci / rust-arm64 (push) Successful in 5m17s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 5s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 4s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 4s
android / android (push) Successful in 5m52s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
deb / build-publish (push) Successful in 5m38s
ci / rust (push) Failing after 5m52s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 18s
arch / build-publish (push) Failing after 6m7s
deb / build-publish-client-arm64 (push) Successful in 3m55s
docker / builders-arm64cross (push) Successful in 19s
deb / build-publish-host (push) Failing after 4m24s
apple / swift (push) Successful in 4m35s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m13s
docker / deploy-docs (push) Successful in 31s
windows-host / package (push) Failing after 6m22s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Skipped
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m35s
flatpak / build-publish (push) Successful in 6m33s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m22s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 10m8s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 2m56s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 11m43s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 4m1s
release / apple (push) Successful in 25m5s
apple / screenshots (push) Successful in 20m50s
The host's capture tick and a client's panel vsync are independent ~120 Hz oscillators; their drifting phase sweeps every frame's wait-for-latch across a full refresh period (measured on-glass: latch p50 oscillating 5.4-8.9 ms with a fixed margin) — the beat is the residual judder and the fat p95, and no client can fix it alone. Design: punktfunk-planning design/phase-locked-capture.md. Protocol (punktfunk-core): - PhaseReport (control 0x32, next to the clock family): the client's next display latch ALREADY CONVERTED to host clock (the skew offset lives only client-side), panel period, uncertainty, and the measured median arrival-lead — the controller's error signal. ~1 Hz, latest-wins. CLIENT_CAP_PHASE_LOCK advertises it; CtrlRequest::Phase + report_phase() + the C ABI mirror carry it. - The 0xCF host-timing tail grows a phase ACK (applied_phase_ns, 29-byte form) under the same strict-prefix append discipline — old readers parse the shorter forms; degradation pinned by tests. Host engine (arrival-slaved loop — no backend can move the source vsync, per the tick-ownership audit in the design doc): - PhaseCtl bridges control task → encode loop (the fec_target pattern, multi-field). PhaseController walks a per-frame HOLD before submit toward the client's reported lead hitting target = max(2.5 ms, uncertainty+1ms): 1 Hz adjust, 2 ms max step, 300 µs deadband, period-wrapping (the newest-wins capture slot makes a wrapped hold sample fresher content, not staler). A loop local, so every mid-stream rebuild keeps the lock; a new session re-acquires. PUNKTFUNK_PHASE_LOCK=0 disarms. Android reporter: the presenter's 1 Hz pf.present flush returns the window's measured latch p50; the async loop converts the vsync clock's next timeline monotonic→realtime→host and reports. Inert toward old hosts. Gates: docker amd64 clippy --all-targets -D warnings (host+core, nvenc) clean; core suite incl. the new wire tests; cargo ndk arm64 check/clippy clean. On-glass A/B vs the .173 host owed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
409 lines
17 KiB
Rust
409 lines
17 KiB
Rust
//! The timeline presenter — Android's port of the Apple client's stage-4 deadline discipline
|
||
//! (`clients/apple/.../Stage2Pipeline.swift`, the `.deadline` pacing):
|
||
//!
|
||
//! * a **newest-wins slot** (or a small smoothing FIFO, by user intent) between decode and
|
||
//! release, so a burst coalesces in the app — as an explicit, counted drop — instead of
|
||
//! queueing behind the display;
|
||
//! * a **glass budget of exactly one**: at most one undisplayed release in flight to
|
||
//! SurfaceFlinger, reopened on the clock-predicted latch (with a 100 ms stale force-open as
|
||
//! the liveness backstop, mirroring Apple's `PresentGate.staleAfter`). The BufferQueue can
|
||
//! hold at most the frame being scanned out plus one — a standing queue is unconstructible;
|
||
//! * a **timed release**: `AMediaCodec_releaseOutputBufferAtTime` targeting the platform's own
|
||
//! frame timeline (API 33+, via [`super::vsync`]), so the latch phase is deterministic instead
|
||
//! of inheriting network + decode jitter. On the 31/32 fallback the release is ASAP —
|
||
//! identical to the legacy path — and only the budget prediction uses the measured period.
|
||
//!
|
||
//! The legacy behaviour (release the newest ready buffer immediately, unbudgeted) remains
|
||
//! selectable at runtime: `adb shell setprop debug.punktfunk.presenter arrival` — the on-device
|
||
//! A/B needs no rebuild. The user-facing escape hatch stays the "Low-latency mode" master toggle
|
||
//! (off = the synchronous pre-overhaul loop, no presenter at all).
|
||
|
||
use ndk::media::media_codec::MediaCodec;
|
||
use std::collections::VecDeque;
|
||
use std::sync::Mutex;
|
||
use std::time::Instant;
|
||
|
||
use super::display::DisplayTracker;
|
||
use super::latency::now_realtime_ns;
|
||
use super::vsync::VsyncShared;
|
||
|
||
/// Submit-margin ahead of a timeline's EXPECTED PRESENT — SurfaceFlinger's own latch lead: the
|
||
/// released buffer must be in the BufferQueue by SF's wakeup for that vsync (a few ms before
|
||
/// present). A present closer than this is treated as missed and the next one is targeted. This
|
||
/// is deliberately NOT the timeline's `deadline` (which budgets for GPU rendering a video
|
||
/// buffer doesn't do — see `VsyncShared::next_target`); a too-tight gamble here presents one
|
||
/// vsync later, the exact cost the deadline gate paid on every frame.
|
||
///
|
||
/// 2.5 ms: SF's latch runs ~1-2 ms before present on modern devices (its `sfOffset`), and the
|
||
/// release itself is a binder call well under a ms. 4 ms measured latch p50 8-10; each ms cut
|
||
/// here is a ms off every frame's display stage. If a device misses at this margin the `paced`
|
||
/// counter shows it (a miss presents one vsync later, coalescing the next frame) — that is the
|
||
/// signal to widen, not stutter.
|
||
const LATCH_MARGIN_NS: i64 = 2_500_000;
|
||
|
||
/// The budget's liveness backstop: a release whose predicted latch never seems to arrive
|
||
/// (clock glitch, mode switch) force-reopens the budget this long after the release, counted in
|
||
/// `forced` — reads 0 on healthy systems (Apple's `PresentGate.staleAfter`, same value).
|
||
const STALE_REOPEN_NS: i64 = 100_000_000;
|
||
|
||
/// Fallback latch-prediction period while the vsync clock is unmeasured/absent: one 120 Hz frame.
|
||
const FALLBACK_PERIOD_NS: i64 = 8_333_333;
|
||
|
||
/// The user's presentation intent — the Apple client's `PresentPriority`, same resolution rules:
|
||
/// anything but an explicit "smooth" is latency; a smooth buffer outside 1..=3 becomes 2.
|
||
#[derive(Clone, Copy, PartialEq)]
|
||
pub(crate) enum PresentPriority {
|
||
/// Newest-wins, release the instant the budget opens. The default.
|
||
Latency,
|
||
/// A small FIFO (1..=3 frames) drained one per vsync: jitter absorbed at one refresh of
|
||
/// added display latency per slot, which the metrics show rather than hide.
|
||
Smooth { buffer: usize },
|
||
}
|
||
|
||
impl PresentPriority {
|
||
/// From the JNI ints (`presentPriority` 0 = latency / 1 = smooth; `smoothBuffer` 0 = auto).
|
||
pub(crate) fn resolve(priority: i32, buffer: i32) -> PresentPriority {
|
||
if priority != 1 {
|
||
return PresentPriority::Latency;
|
||
}
|
||
let b = if (1..=3).contains(&buffer) {
|
||
buffer as usize
|
||
} else {
|
||
2
|
||
};
|
||
PresentPriority::Smooth { buffer: b }
|
||
}
|
||
}
|
||
|
||
/// One decoded output buffer held for presentation.
|
||
struct HeldFrame {
|
||
index: usize,
|
||
pts_us: u64,
|
||
/// The output callback's `CLOCK_REALTIME` stamp — the pace metric's start (decoded→release).
|
||
decoded_ns: i128,
|
||
}
|
||
|
||
/// The one-in-flight glass budget.
|
||
struct InFlight {
|
||
/// Monotonic instant the budget reopens: the release target's expected present (clock), or
|
||
/// `release + period` on the fallback path.
|
||
reopen_at_ns: i64,
|
||
released_at_ns: i64,
|
||
}
|
||
|
||
/// Latch samples + display confirms recorded by the `OnFrameRendered` callback thread, drained by
|
||
/// the presenter's 1 Hz `pf-present` line. Always on (independent of the HUD) — this is what makes
|
||
/// a HUD-off wireless A/B readable from logcat.
|
||
pub(super) struct PresentMeter {
|
||
inner: Mutex<PresentMeterInner>,
|
||
}
|
||
|
||
struct PresentMeterInner {
|
||
latch_us: Vec<u64>,
|
||
displays: u64,
|
||
}
|
||
|
||
impl PresentMeter {
|
||
pub(super) fn new() -> PresentMeter {
|
||
PresentMeter {
|
||
inner: Mutex::new(PresentMeterInner {
|
||
latch_us: Vec::with_capacity(256),
|
||
displays: 0,
|
||
}),
|
||
}
|
||
}
|
||
|
||
/// One displayed frame's release→displayed latch, µs. Callback thread; poison-proof.
|
||
pub(super) fn note_latch(&self, latch_us: Option<u64>) {
|
||
let mut g = self
|
||
.inner
|
||
.lock()
|
||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||
g.displays += 1;
|
||
if let Some(l) = latch_us {
|
||
if g.latch_us.len() < 4096 {
|
||
g.latch_us.push(l);
|
||
}
|
||
}
|
||
}
|
||
|
||
fn drain(&self) -> (Vec<u64>, u64) {
|
||
let mut g = self
|
||
.inner
|
||
.lock()
|
||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||
let displays = g.displays;
|
||
g.displays = 0;
|
||
(std::mem::take(&mut g.latch_us), displays)
|
||
}
|
||
}
|
||
|
||
/// p50/max of an unsorted µs sample vec, in ms. (0, 0) when empty.
|
||
fn p50_max_ms(mut v: Vec<u64>) -> (f64, f64) {
|
||
if v.is_empty() {
|
||
return (0.0, 0.0);
|
||
}
|
||
v.sort_unstable();
|
||
let p50 = v[v.len() / 2] as f64 / 1000.0;
|
||
let max = *v.last().unwrap() as f64 / 1000.0;
|
||
(p50, max)
|
||
}
|
||
|
||
pub(super) struct Presenter {
|
||
/// 0 = newest-wins; 1..=3 = smoothing FIFO capacity.
|
||
fifo_capacity: usize,
|
||
frames: VecDeque<HeldFrame>,
|
||
/// FIFO preroll: `take` withholds until the buffer filled to capacity once, re-armed on a dry
|
||
/// run — the Apple `FrameStore` semantics (headroom never builds without it).
|
||
prerolled: bool,
|
||
inflight: Option<InFlight>,
|
||
/// A vsync arrived since the last release — the FIFO's one-per-refresh drain pace.
|
||
vsync_tick: bool,
|
||
// -- 1 Hz pf-present window, always on --
|
||
released: u64,
|
||
paced_drops: u64,
|
||
no_budget: u64,
|
||
forced: u64,
|
||
dry: u64,
|
||
pace_us: Vec<u64>,
|
||
last_flush: Instant,
|
||
}
|
||
|
||
impl Presenter {
|
||
pub(super) fn new(priority: PresentPriority) -> Presenter {
|
||
Presenter {
|
||
fifo_capacity: match priority {
|
||
PresentPriority::Latency => 0,
|
||
PresentPriority::Smooth { buffer } => buffer,
|
||
},
|
||
frames: VecDeque::new(),
|
||
prerolled: false,
|
||
inflight: None,
|
||
vsync_tick: false,
|
||
released: 0,
|
||
paced_drops: 0,
|
||
no_budget: 0,
|
||
forced: 0,
|
||
dry: 0,
|
||
pace_us: Vec::with_capacity(256),
|
||
last_flush: Instant::now(),
|
||
}
|
||
}
|
||
|
||
/// A vsync pulse from the clock thread's event — the retry tick for a parked frame and the
|
||
/// FIFO's drain pace.
|
||
pub(super) fn on_vsync(&mut self) {
|
||
self.vsync_tick = true;
|
||
}
|
||
|
||
/// Accept one decoded, gate-approved output buffer. Newest-wins evicts everything older
|
||
/// (released unrendered — the explicit, counted drop); the FIFO evicts its oldest past
|
||
/// capacity. Returns how many frames were dropped by the policy (the HUD's `skipped`).
|
||
pub(super) fn submit(
|
||
&mut self,
|
||
codec: &MediaCodec,
|
||
index: usize,
|
||
pts_us: u64,
|
||
decoded_ns: i128,
|
||
) -> u64 {
|
||
let mut dropped = 0u64;
|
||
if self.fifo_capacity == 0 {
|
||
while let Some(stale) = self.frames.pop_front() {
|
||
release_unrendered(codec, stale.index);
|
||
dropped += 1;
|
||
}
|
||
}
|
||
self.frames.push_back(HeldFrame {
|
||
index,
|
||
pts_us,
|
||
decoded_ns,
|
||
});
|
||
if self.fifo_capacity > 0 && self.frames.len() > self.fifo_capacity {
|
||
if let Some(stale) = self.frames.pop_front() {
|
||
release_unrendered(codec, stale.index);
|
||
dropped += 1;
|
||
}
|
||
}
|
||
self.paced_drops += dropped;
|
||
dropped
|
||
}
|
||
|
||
/// The present decision point — run on every loop pass (frame arrivals, vsync ticks, and the
|
||
/// 5 ms housekeeping wake all land here). Releases AT MOST one frame (the budget). Returns
|
||
/// `true` when a frame was released to glass this call.
|
||
#[allow(clippy::too_many_arguments)] // one call site; the seams are the point
|
||
pub(super) fn pump(
|
||
&mut self,
|
||
codec: &MediaCodec,
|
||
clock: Option<&VsyncShared>,
|
||
tracker: &DisplayTracker,
|
||
stats: &crate::stats::VideoStats,
|
||
now_mono_ns: i64,
|
||
) -> bool {
|
||
// Budget bookkeeping first: reopen on the predicted latch, force-open on the backstop.
|
||
if let Some(f) = &self.inflight {
|
||
if now_mono_ns >= f.reopen_at_ns {
|
||
self.inflight = None;
|
||
} else if now_mono_ns - f.released_at_ns > STALE_REOPEN_NS {
|
||
self.forced += 1;
|
||
self.inflight = None;
|
||
}
|
||
}
|
||
// Pick the frame this pump may release.
|
||
let frame = if self.fifo_capacity == 0 {
|
||
self.frames.pop_back() // submit() kept it a single slot; back == the newest
|
||
} else {
|
||
// FIFO: drain exactly one frame per vsync tick, after preroll; a drain tick that
|
||
// finds the buffer dry re-arms preroll (the Apple `FrameStore` underflow semantics —
|
||
// the previous frame persists on glass, a repeat by omission, while headroom
|
||
// rebuilds). Everything is gated on the tick so an idle stream neither counts
|
||
// underflows nor churns the preroll flag 200×/s.
|
||
if !self.vsync_tick {
|
||
return false;
|
||
}
|
||
if !self.prerolled {
|
||
if self.frames.len() < self.fifo_capacity {
|
||
return false;
|
||
}
|
||
self.prerolled = true;
|
||
}
|
||
if self.frames.is_empty() {
|
||
self.prerolled = false;
|
||
self.dry += 1;
|
||
self.vsync_tick = false; // this tick's drain ran (and found nothing)
|
||
return false;
|
||
}
|
||
self.frames.pop_front()
|
||
};
|
||
let Some(frame) = frame else { return false };
|
||
if self.inflight.is_some() {
|
||
// Budget closed — park it back; a fresher submit replaces it (newest-wins), the next
|
||
// vsync tick / loop pass retries the pairing.
|
||
self.no_budget += 1;
|
||
match self.fifo_capacity {
|
||
0 => self.frames.push_back(frame),
|
||
_ => self.frames.push_front(frame),
|
||
}
|
||
return false;
|
||
}
|
||
// Release: timeline-timed when the clock has one, ASAP otherwise.
|
||
let target = clock.and_then(|c| c.next_target(now_mono_ns, LATCH_MARGIN_NS));
|
||
let released = match target {
|
||
Some(t) => codec
|
||
.release_output_buffer_at_time_by_index(frame.index, t.expected_present_ns)
|
||
.map_err(|e| log::warn!("presenter: release_at_time({}): {e}", frame.index)),
|
||
None => codec
|
||
.release_output_buffer_by_index(frame.index, true)
|
||
.map_err(|e| log::warn!("presenter: release({}): {e}", frame.index)),
|
||
};
|
||
self.vsync_tick = false;
|
||
if released.is_err() {
|
||
return false; // the buffer is gone either way; nothing to book-keep
|
||
}
|
||
let period = clock.map(|c| c.period_ns()).filter(|&p| p > 0);
|
||
// Reopen at SurfaceFlinger's LATCH for the targeted vsync (expected present minus the
|
||
// latch lead) — the instant SF consumes the queued buffer and the slot frees, so the
|
||
// next release can target the NEXT refresh. Not the platform `deadline` (with the
|
||
// aggressive present gate it can already be in the past — an instant reopen would let
|
||
// two releases pile onto the same vsync) and not the present time itself (a period too
|
||
// late — it would cap the sustainable rate at roughly half the panel).
|
||
let reopen_at_ns = target
|
||
.map(|t| t.expected_present_ns - LATCH_MARGIN_NS)
|
||
.unwrap_or(now_mono_ns + period.unwrap_or(FALLBACK_PERIOD_NS));
|
||
self.inflight = Some(InFlight {
|
||
reopen_at_ns,
|
||
released_at_ns: now_mono_ns,
|
||
});
|
||
self.released += 1;
|
||
let release_real_ns = now_realtime_ns();
|
||
let pace_us = ((release_real_ns - frame.decoded_ns).max(0) / 1000) as u64;
|
||
if self.pace_us.len() < 4096 {
|
||
self.pace_us.push(pace_us);
|
||
}
|
||
stats.note_release(pace_us);
|
||
tracker.note_rendered(frame.pts_us, frame.decoded_ns, release_real_ns);
|
||
true
|
||
}
|
||
|
||
/// Release every held buffer unrendered — the teardown path, BEFORE `codec.stop()`.
|
||
pub(super) fn release_all(&mut self, codec: &MediaCodec) {
|
||
while let Some(f) = self.frames.pop_front() {
|
||
release_unrendered(codec, f.index);
|
||
}
|
||
self.inflight = None;
|
||
}
|
||
|
||
/// The 1 Hz `pf-present` logcat mirror (target `pf.present`) — the Apple client's Console
|
||
/// `pf-present` line, so a HUD-off on-device A/B is readable wirelessly:
|
||
/// `released` (to glass) / `displays` (OnFrameRendered confirms) / `paced` (policy drops) /
|
||
/// `noBudget` (waits on the closed budget) / `forced` (stale force-opens — 0 when healthy) /
|
||
/// `qDry` (FIFO underflows) / `pace` (decoded→release) / `latch` (release→displayed) /
|
||
/// `vsync` (the measured panel period).
|
||
///
|
||
/// Returns this window's measured latch p50 (ns) when a window actually flushed — the
|
||
/// phase-lock reporter's `arrival_lead` error signal (design/phase-locked-capture.md §6).
|
||
pub(super) fn flush_log(
|
||
&mut self,
|
||
meter: &PresentMeter,
|
||
clock: Option<&VsyncShared>,
|
||
) -> Option<u64> {
|
||
if self.last_flush.elapsed() < std::time::Duration::from_secs(1) {
|
||
return None;
|
||
}
|
||
self.last_flush = Instant::now();
|
||
let (latch, displays) = meter.drain();
|
||
if self.released == 0 && displays == 0 {
|
||
return None; // idle stream — nothing worth a line
|
||
}
|
||
let (pace_p50, pace_max) = p50_max_ms(std::mem::take(&mut self.pace_us));
|
||
let (latch_p50, latch_max) = p50_max_ms(latch);
|
||
let period_ms = clock.map(|c| c.period_ns() as f64 / 1e6).unwrap_or(0.0);
|
||
let panel_ms = clock
|
||
.map(|c| c.panel_period_ns() as f64 / 1e6)
|
||
.unwrap_or(0.0);
|
||
log::info!(
|
||
target: "pf.present",
|
||
"released={} displays={} paced={} noBudget={} forced={} qDry={} \
|
||
paceMs p50={:.2} max={:.2} latchMs p50={:.2} max={:.2} vsyncMs={:.2} panelMs={:.2}",
|
||
self.released,
|
||
displays,
|
||
self.paced_drops,
|
||
self.no_budget,
|
||
self.forced,
|
||
self.dry,
|
||
pace_p50,
|
||
pace_max,
|
||
latch_p50,
|
||
latch_max,
|
||
period_ms,
|
||
panel_ms,
|
||
);
|
||
self.released = 0;
|
||
self.paced_drops = 0;
|
||
self.no_budget = 0;
|
||
self.forced = 0;
|
||
self.dry = 0;
|
||
(latch_p50 > 0.0).then(|| (latch_p50 * 1e6) as u64)
|
||
}
|
||
}
|
||
|
||
fn release_unrendered(codec: &MediaCodec, index: usize) {
|
||
if let Err(e) = codec.release_output_buffer_by_index(index, false) {
|
||
log::warn!("presenter: release_output_buffer({index}, false): {e}");
|
||
}
|
||
}
|
||
|
||
/// `debug.punktfunk.presenter` sysprop: `arrival` = the legacy release-immediately path,
|
||
/// anything else / unset = the timeline presenter. The rebuild-free on-device A/B lever.
|
||
pub(super) fn presenter_disabled_by_sysprop() -> bool {
|
||
let mut buf = [0u8; 92]; // PROP_VALUE_MAX
|
||
// SAFETY: __system_property_get with a valid name + PROP_VALUE_MAX buffer is always safe.
|
||
let n = unsafe {
|
||
libc::__system_property_get(
|
||
c"debug.punktfunk.presenter".as_ptr(),
|
||
buf.as_mut_ptr().cast(),
|
||
)
|
||
};
|
||
n > 0 && &buf[..n as usize] == b"arrival"
|
||
}
|