Skip to content

fix(android): fast step-down + opt-in HD repair for a main with no fmtp (#590) - #591

Merged
badbread merged 3 commits into
mainfrom
fix/android-lpr-main-fmtp
Aug 8, 2026
Merged

fix(android): fast step-down + opt-in HD repair for a main with no fmtp (#590)#591
badbread merged 3 commits into
mainfrom
fix/android-lpr-main-fmtp

Conversation

@badbread

@badbread badbread commented Aug 8, 2026

Copy link
Copy Markdown
Owner

Fixes #590.

Two independent fixes for a camera whose main stream advertises an RTSP SDP
with no a=fmtp (parameter sets in-band only), which Media3's RTSP client rejects
with IllegalArgumentException: missing attribute fmtp. Captured on device via the
CrumbLiveFallback logging (#561) on a Uniview H.265 LPR main.

1. Fast step-down (client, ships on)

The failure arrives wrapped in an IO error code (2000), so the reactive fallback
ladder retried it across the whole back-off curve (~30 s) before dropping to the
H.264 sub. It is deterministic (identical SDP every attempt), so missing attribute fmtp is added to LiveStreamFallback's deterministic step-down signatures next to
processAggregationPacket. The broken main now steps down to SD on the first
failure.

Narrow by design: matched on the exception-chain message, not by widening any error
code to step down — that would reintroduce the #560 eager-SD regression. A unit
test pins that an fmtp-signature error steps down immediately while a generic 2000
IO error still needs the multi-failure threshold.

2. Opt-in HD repair _mainv (server, default OFF)

A per-camera, detection-driven repaired main <name>_mainv, advertised as
rtsp_mainv_url and tried by Android right after the raw main
(main → mainv → subv → sub → mobile; wall no-sub main → mainv → mobile). It is a
full-resolution H.265→H.264 transcode, gated behind
MAIN_REPAIR_TRANSCODE_ENABLED (default false) because it costs recorder CPU
(libx264) while a fullscreen viewer is attached — go2rtc spawns it lazily, so idle
cost is zero. Detection reuses sdp_video_lacks_fmtp on the MAIN producer (always
warm, the recorder consumes the main through go2rtc), sticky-unknown exactly like
_subv.

Why a transcode and not the cheaper _subv-style copy

Verified empirically against the real LPR stream (ffprobe + tshark RTP capture on
the recorder's embedded go2rtc):

stream fmtp in SDP? RTP packetization Media3
raw main (native restream) no FU only, AP-free fails at DESCRIBE (no fmtp)
ffmpeg:<name>#video=copy yes FU + HEVC AP (VPS/SPS/PPS/SEI bundled) at each keyframe fails on processAggregationPacket
ffmpeg:<name>#video=h264 (this PR) yes (H.264) H.264 FU-A/STAP-A plays

So a copy-remux only trades the fmtp rejection for an Aggregation-Packet crash
(androidx/media#1008). Only a re-encode plays on Android. Full analysis in the
docs/DECISIONS.md 2026-08-08 _mainv entry.

Behavior

  • Repair off (default): nothing changes except the instant step-down — an
    fmtp-less main is SD (H.264 sub) on Android, same as before but immediate.
  • Repair on: that camera's Android fullscreen becomes HD at recorder CPU cost.
  • Cheapest fix for operators who can reach the camera: set its main to H.264 in the
    camera UI (no server transcode). Documented in camera-compatibility.json and the
    env reference.

Tests / verification (gate green on dev2)

  • Rust: cargo fmt --all --check, clippy --all-targets -D warnings, cargo test --workspace against a throwaway Postgres (DB-backed tests confirmed to run). New:
    mainv_name_and_src_shapes, mainv_src_is_a_transcode_not_a_copy,
    mainv_src_is_never_an_rtsp_alias_collision, rtsp_mainv_url_*.
  • Android: ./gradlew testDebugUnitTest assembleDebug. New:
    the missing-fmtp failure steps down at once…, fullscreen chain puts the repaired main right after the raw main, metered fullscreen keeps the repaired main a last resort…, a no-sub camera puts the repaired main before the transcode…, the repaired main rung is absent when the server does not publish one.
  • docker compose config validated on real Docker.

What still needs the maintainer's phone: end-to-end HD playback of the
transcoded _mainv on the LPR (enable MAIN_REPAIR_TRANSCODE_ENABLED, redeploy,
open the LPR fullscreen on Android). The client fast-step-down and all stream-level
facts (SDP fmtp, FU-vs-AP packetization) are verified on the server; only the
on-device render of the transcoded main is unverified.

Do not merge — for maintainer review.

A camera whose H.265 main advertises an RTSP SDP with no `a=fmtp` (parameter
sets only in-band, seen on a Uniview LPR main) is rejected by Media3's RTSP
client with `IllegalArgumentException: missing attribute fmtp`. Two problems,
two fixes:

1. Fast step-down (client, on by default). The failure surfaces wrapped in an
   IO code (2000), so the reactive ladder retried it across the whole back-off
   curve (~30 s) before dropping to the H.264 sub. It is deterministic, so add
   `missing attribute fmtp` to the step-down signatures next to
   `processAggregationPacket`: the broken main now drops to SD on the first
   failure. Matched on the exception-chain message only, not by widening any
   error code, so the #560 eager-SD fix is preserved.

2. Opt-in HD repair (server, default off). A per-camera, detection-driven
   repaired main `<name>_mainv`, advertised as `rtsp_mainv_url` and tried by
   Android right after the raw main (`main -> mainv -> subv -> sub -> mobile`).
   It is a full-res H.265->H.264 transcode gated behind
   `MAIN_REPAIR_TRANSCODE_ENABLED` (default off) because it costs recorder CPU
   while a fullscreen viewer is attached. Detection reuses
   `sdp_video_lacks_fmtp` on the main producer, sticky-unknown like `_subv`.

Why a transcode and not the cheaper `_subv`-style copy: verified against the
real stream (ffprobe + RTP capture) that a copy-remux restores fmtp but go2rtc
then emits an HEVC parameter-set Aggregation Packet at each keyframe that
Media3 cannot depacketize, so only a re-encode plays on Android.

With the repair off (default) nothing changes except the instant step-down; on,
the SD downgrade becomes HD at recorder CPU cost. The cheapest fix of all, when
the camera allows it, is still to set its main to H.264 in the camera UI.

Tests: LiveStreamFallbackTest (fmtp fast step-down, MAINV chain ordering/badge);
go2rtc mainv_name/mainv_src (transcode-not-copy, no alias collision); playback
mainv_url gating. Docs: DECISIONS, COMPONENT-MAP, camera-compatibility.json,
.env.example, docker-compose.yml, environment-reference.md.

Fixes #590

Signed-off-by: badbread <badbread@users.noreply.github.com>
@badbread
badbread merged commit 26ae14c into main Aug 8, 2026
8 checks passed
@badbread
badbread deleted the fix/android-lpr-main-fmtp branch August 8, 2026 20:35
badbread added a commit that referenced this pull request Aug 8, 2026
#592)

* fix(api): flag the _mainv repair from the SERVED SDP, not the producer

#591's _mainv detection reused sdp_video_lacks_fmtp on the MAIN producer SDP.
The reference LPR camera (Uniview H.265) publishes a producer fmtp with
sprop-sps+sprop-pps but no sprop-vps, so the producer verdict is Some(false)
("has fmtp") and the camera is never flagged -- yet go2rtc, unable to build a
complete HEVC parameter set, serves consumers an SDP with no a=fmtp at all,
which is what makes Media3 throw 'missing attribute fmtp'. The merged repair was
therefore inert for exactly the camera it targeted.

Detect on the SDP go2rtc SERVES to RTSP consumers instead: add
stream_served_video_lacks_fmtp + StreamIndex::video_lacks_fmtp_served, used for
_mainv only. _subv keeps the producer side (a sub is often un-consumed, so it has
no served SDP); the main is always consumed by the recorder, so its served SDP
is reliably present. Verified against the live prod go2rtc: LPR served SDP lacks
fmtp while frontyard/driveway/garage (H.265, playable) serve a complete fmtp
with sprop-vps. Unit tests use the real captured SDPs. DECISIONS.md corrected.

Signed-off-by: badbread <badbread@users.noreply.github.com>

* review: sync mainv docs to served-SDP, soften 'always consumed', add no-verdict debug log

Addresses the Fable review of #592:
- config.rs / dto.rs doc comments still described PRODUCER-side detection
  (sdp_video_lacks_fmtp); point them at stream_served_video_lacks_fmtp / the
  served SDP (same-change doc-sync).
- 'the main is always consumed by the recorder' is only true for the default
  record-from-main policy; soften in the go2rtc.rs docstring, the reconcile
  comment, and the DECISIONS correction (a record-from-sub main has no RTSP
  consumer -> served verdict None -> unrepaired, by design).
- add a reconcile debug log when the repair is enabled but a main has no
  served-SDP verdict, so 'enabled but silently does nothing' is observable.
- extra unit cases: webrtc-before-rtsp ordering, and a mid-handshake rtsp
  consumer (no sdp) skipped for the next one.

record-from-sub main-repair blindness and the sub-side incomplete-fmtp class are
tracked as follow-ups, not this PR.

Signed-off-by: badbread <badbread@users.noreply.github.com>

---------

Signed-off-by: badbread <badbread@users.noreply.github.com>
Co-authored-by: badbread <badbread@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Android: main with no fmtp in its SDP is slow to fall back and has no HD path (Uniview H.265 LPR)

1 participant