diff --git a/.env.example b/.env.example index 587dc145..c529c351 100644 --- a/.env.example +++ b/.env.example @@ -133,6 +133,12 @@ EXPORT_TTL_SECONDS=86400 # camera won't decode on-device (e.g. H.265 all the way down). # Built lazily, only while something is watching. # MOBILE_STREAM_WIDTH=640 # transcode width in px (floored at 160) +# MAIN_REPAIR_TRANSCODE_ENABLED=false # opt-in, per-camera FULL-RES H.265->H.264 transcode of a main + # whose SDP lacks fmtp (Android/Media3 rejects it with "missing + # attribute fmtp", e.g. some Uniview LPR cams). A cheap copy-remux + # does NOT work here (go2rtc then emits an HEVC aggregation packet + # Media3 can't read), so this is a real re-encode: leave off and + # Android just uses the H.264 sub (SD); on = HD at recorder CPU cost. # SEGMENT_LOW_CACHE_MAX_BYTES=2147483648 # 2 GiB budget for the low-res playback segment cache # --- Scrub-preview cache (optional; five of these are also editable in the console, which wins) --- diff --git a/apps/android/app/src/main/java/video/crumb/app/data/Models.kt b/apps/android/app/src/main/java/video/crumb/app/data/Models.kt index d5e9333f..991e814a 100644 --- a/apps/android/app/src/main/java/video/crumb/app/data/Models.kt +++ b/apps/android/app/src/main/java/video/crumb/app/data/Models.kt @@ -304,6 +304,21 @@ data class LiveStreamsResponse( @SerialName("webrtc_main_url") val webrtcMainUrl: String? = null, @SerialName("webrtc_sub_url") val webrtcSubUrl: String? = null, @SerialName("rtsp_main_url") val rtspMainUrl: String, + /** + * Repaired full-resolution main (`_mainv`): a per-camera H.265->H.264 + * **transcode** the server registers only for a main it detected publishes + * video with no `a=fmtp`, and only when the operator opts the repair in (it + * costs recorder CPU). Media3's RTSP client rejects a fmtp-less SDP with + * `IllegalArgumentException: missing attribute fmtp`; a copy-remux would fix + * that but go2rtc then bundles the H.265 parameter sets into an RTP Aggregation + * Packet Media3 cannot depacketize, so the repair has to be a transcode. Full + * resolution (no SD badge), tried right after [rtspMainUrl]. + * + * Normally `null`: the camera's main is fine, the operator has not enabled the + * repair, the camera is unmanaged (Frigate-served / legacy), or the server + * predates the field — in every case the client just skips this rung. + */ + @SerialName("rtsp_mainv_url") val rtspMainvUrl: String? = null, @SerialName("rtsp_sub_url") val rtspSubUrl: String? = null, /** * Video-only sub restream (`_subv`): the raw sub run through an ffmpeg diff --git a/apps/android/app/src/main/java/video/crumb/app/feature/live/LiveStreamFallback.kt b/apps/android/app/src/main/java/video/crumb/app/feature/live/LiveStreamFallback.kt index b397edfd..9324c76a 100644 --- a/apps/android/app/src/main/java/video/crumb/app/feature/live/LiveStreamFallback.kt +++ b/apps/android/app/src/main/java/video/crumb/app/feature/live/LiveStreamFallback.kt @@ -32,6 +32,18 @@ enum class StreamTier { /** Full-resolution main stream (`rtsp_main_url`). */ MAIN, + /** + * The server's repaired full-resolution main (`rtsp_mainv_url`, `_mainv`): + * a per-camera H.265->H.264 **transcode** the server registers only for a main + * it has detected publishes video with no `a=fmtp`, and only when the operator + * has opted the repair in (it costs recorder CPU). Unlike [SUBV] this is a + * re-encode, not a copy: a copy-remux of an H.265 main fixes the SDP but go2rtc + * then bundles the parameter sets into an RTP Aggregation Packet Media3 cannot + * depacketize, so only a transcode yields a main this device can actually play. + * Full resolution, so it carries no SD badge. Normally `null`. + */ + MAINV, + /** * The server's video-only sub restream (`rtsp_subv_url`, `_subv`): the * raw sub run through an ffmpeg **copy** so go2rtc republishes a proper @@ -55,6 +67,7 @@ enum class StreamTier { /** The URL for [tier], or `null` when this camera does not expose that rung. */ fun LiveStreamsResponse.urlForTier(tier: StreamTier): String? = when (tier) { StreamTier.MAIN -> rtspMainUrl + StreamTier.MAINV -> rtspMainvUrl StreamTier.SUBV -> rtspSubvUrl StreamTier.SUB -> rtspSubUrl StreamTier.MOBILE -> rtspMobileUrl @@ -76,29 +89,32 @@ private fun LiveStreamsResponse.chainOf(vararg order: StreamTier): List = if (subStreamUrl() != null) { chainOf(StreamTier.SUBV, StreamTier.SUB, StreamTier.MOBILE) } else { - chainOf(StreamTier.MAIN, StreamTier.MOBILE) + chainOf(StreamTier.MAIN, StreamTier.MAINV, StreamTier.MOBILE) } /** * Fallback ladder for **fullscreen live**. * * Off a metered link fullscreen is the one place HD is worth it, so it starts on - * the main and walks down: `main → subv → sub → mobile`. On a metered link the - * data-saver order applies (unchanged from before: low-res first, the transcode - * when the camera has no sub), with the main kept as the last resort so a broken - * sub still leaves something to watch rather than a spinner. + * the main and walks down: `main → mainv → subv → sub → mobile`. The repaired main + * (`mainv`) sits right after the raw main so a camera whose main is unplayable for + * a fmtp/packetization reason gets HD from the server-side repair before dropping + * to an SD rung. On a metered link the data-saver order applies (low-res first, the + * transcode when the camera has no sub), with both HD mains kept as the last + * resort so a broken sub still leaves something to watch rather than a spinner. */ fun LiveStreamsResponse.fullscreenStreamChain(metered: Boolean): List = if (metered) { - chainOf(StreamTier.SUBV, StreamTier.SUB, StreamTier.MOBILE, StreamTier.MAIN) + chainOf(StreamTier.SUBV, StreamTier.SUB, StreamTier.MOBILE, StreamTier.MAIN, StreamTier.MAINV) } else { - chainOf(StreamTier.MAIN, StreamTier.SUBV, StreamTier.SUB, StreamTier.MOBILE) + chainOf(StreamTier.MAIN, StreamTier.MAINV, StreamTier.SUBV, StreamTier.SUB, StreamTier.MOBILE) } /** @@ -174,19 +190,32 @@ fun isCodecAgnosticError(errorCode: Int): Boolean = * Substrings that identify a failure Media3 will hit **every single time** on this * stream, from the exception chain rather than the error code. * - * The one that matters today is `RtpH265Reader`'s - * `UnsupportedOperationException("need to implement processAggregationPacket")`, - * verified present in the pinned `media3-exoplayer-rtsp:1.4.1`. RFC 7798 §4.4.2 - * Aggregation Packets pack several SMALL NALs into one RTP packet, and Media3 has - * never implemented that path (androidx/media#1008). It is thrown from inside the - * loader, so it reaches the app wrapped in an `IOException` and arrives with an - * IO error code that looks exactly like a network problem — which is precisely - * why the code alone is not enough to classify it. + * Two matter today, both thrown from inside the RTSP loader, so both reach the app + * wrapped in an `IOException` and arrive with an IO error code that looks exactly + * like a network problem — which is precisely why the code alone is not enough to + * classify either: + * + * - `processAggregationPacket`: `RtpH265Reader`'s + * `UnsupportedOperationException("need to implement processAggregationPacket")`, + * verified present in the pinned `media3-exoplayer-rtsp:1.4.1`. RFC 7798 §4.4.2 + * Aggregation Packets pack several SMALL NALs into one RTP packet, and Media3 has + * never implemented that path (androidx/media#1008). + * - `missing attribute fmtp`: the RTSP client rejects an SDP whose media track has + * no `a=fmtp` line (an H264/H265 track with no out-of-band parameter sets), the + * `IllegalArgumentException` #483 is about. Depending on where it surfaces this + * can arrive as a `1004 FAILED_RUNTIME_CHECK` (already an unplayable format code) + * OR — as seen on an LPR camera's H.265 MAIN over RTSP — wrapped through + * `RtspPlaybackException` into a `2000` IO code, which the graduated threshold + * would otherwise retry ~30 s before giving up. The SDP does not change between + * attempts, so it is deterministic; the signature makes the step-down immediate + * regardless of the code it happens to wear. NB the server-side `_subv`/`_mainv` + * fmtp repair fixes only cameras it manages and detects — this is the client's + * backstop for the rest. * * This is a *narrow* list on purpose: a signature here means "step down now, do * not retry", so only failures that are structurally deterministic belong in it. */ -private val UNPLAYABLE_FAILURE_SIGNATURES = listOf("processAggregationPacket") +private val UNPLAYABLE_FAILURE_SIGNATURES = listOf("processAggregationPacket", "missing attribute fmtp") /** * True when a failure's [detail] (see [failureDetail]) names a deterministic, @@ -369,7 +398,8 @@ fun attachLogLine( * fact the device is watching a server-side re-encode (#524). */ fun sdBadgeLabel(tier: StreamTier?): String? = when (tier) { - null, StreamTier.MAIN -> null + // MAINV is a full-resolution repaired main — HD, so no badge, like MAIN. + null, StreamTier.MAIN, StreamTier.MAINV -> null StreamTier.SUBV, StreamTier.SUB -> "SD · tap for HD" StreamTier.MOBILE -> "SD transcode · tap for HD" } diff --git a/apps/android/app/src/test/java/video/crumb/app/feature/live/LiveStreamFallbackTest.kt b/apps/android/app/src/test/java/video/crumb/app/feature/live/LiveStreamFallbackTest.kt index 0e75e262..e2b5770f 100644 --- a/apps/android/app/src/test/java/video/crumb/app/feature/live/LiveStreamFallbackTest.kt +++ b/apps/android/app/src/test/java/video/crumb/app/feature/live/LiveStreamFallbackTest.kt @@ -34,12 +34,14 @@ class LiveStreamFallbackTest { private fun streams( main: String? = "rtsp://u:p@host:18554/drive", + mainv: String? = null, sub: String? = "rtsp://u:p@host:18554/drive_sub", subv: String? = null, mobile: String? = "rtsp://u:p@host:18554/drive_mobile", ) = LiveStreamsResponse( cameraId = "8f14e45f-ceea-467a-9f3a-8f14e45fceea", rtspMainUrl = main ?: "rtsp://u:p@host:18554/drive", + rtspMainvUrl = mainv, rtspSubUrl = sub, rtspSubvUrl = subv, rtspMobileUrl = mobile, @@ -125,6 +127,48 @@ class LiveStreamFallbackTest { assertEquals(listOf(StreamTier.SUBV, StreamTier.MOBILE), chain) } + @Test + fun `fullscreen chain puts the repaired main right after the raw main`() { + val chain = streams( + mainv = "rtsp://u:p@host:18554/drive_mainv", + subv = "rtsp://u:p@host:18554/drive_subv", + ).fullscreenStreamChain(metered = false) + assertEquals( + listOf( + StreamTier.MAIN, StreamTier.MAINV, + StreamTier.SUBV, StreamTier.SUB, StreamTier.MOBILE, + ), + chain, + ) + // The repaired main is HD, so it must carry no SD badge. + assertNull(sdBadgeLabel(StreamTier.MAINV)) + } + + @Test + fun `metered fullscreen keeps the repaired main a last resort with the raw main`() { + val chain = streams(mainv = "rtsp://u:p@host:18554/drive_mainv") + .fullscreenStreamChain(metered = true) + assertEquals( + listOf(StreamTier.SUB, StreamTier.MOBILE, StreamTier.MAIN, StreamTier.MAINV), + chain, + ) + } + + @Test + fun `a no-sub camera puts the repaired main before the transcode on the wall`() { + val chain = streams(mainv = "rtsp://u:p@host:18554/drive_mainv", sub = null) + .wallStreamChain() + assertEquals(listOf(StreamTier.MAIN, StreamTier.MAINV, StreamTier.MOBILE), chain) + } + + @Test + fun `the repaired main rung is absent when the server does not publish one`() { + // The common case: mainv is null, so the rung is simply not in the ladder + // and every existing chain is unchanged. + assertFalse(StreamTier.MAINV in streams().fullscreenStreamChain(metered = false)) + assertFalse(StreamTier.MAINV in streams().wallStreamChain()) + } + // ── walking the ladder ──────────────────────────────────────────────────── @Test @@ -270,6 +314,47 @@ class LiveStreamFallbackTest { assertFalse(isUnplayableFailureDetail(null)) } + @Test + fun `the missing-fmtp failure steps down at once instead of waiting out the IO backoff`() { + // An LPR camera's H.265 MAIN advertises an SDP with no `a=fmtp`, so Media3's + // RTSP client throws IllegalArgumentException("missing attribute fmtp"). On + // the MAIN over RTSP it surfaces wrapped through RtspPlaybackException into a + // 2000 IO code (captured on device, #561), which the graduated threshold + // would otherwise retry ~30 s before escaping. The SDP is identical every + // attempt, so the signature must step it down on the FIRST failure. + val detail = failureDetail( + java.io.IOException( + "Source error", + IllegalArgumentException("missing attribute fmtp"), + ), + ) + assertTrue(detail!!, isUnplayableFailureDetail(detail)) + assertTrue( + shouldFallBackToNextTier( + hasNextTier = true, + everReady = false, + errorCode = 2000, + failureDetail = detail, + preFirstFrameFailures = 1, + ), + ) + // Contrast: a generic 2000 IO failure with no such signature still has to + // repeat across the whole backoff curve before the rung is abandoned — the + // fmtp match must not have widened the codec-agnostic retry path (#560). + (1 until CODEC_AGNOSTIC_FAILURES_BEFORE_FALLBACK).forEach { n -> + assertFalse( + "generic IO failure $n must still retry", + shouldFallBackToNextTier( + hasNextTier = true, + everReady = false, + errorCode = 2000, + failureDetail = failureDetail(java.io.IOException("Source error")), + preFirstFrameFailures = n, + ), + ) + } + } + @Test fun `a failure detail never carries stream credentials into logcat`() { // RTSP URLs here embed the stream user and password; Media3 puts the URL it diff --git a/data/camera-compatibility.json b/data/camera-compatibility.json index 9486da83..7f76d594 100644 --- a/data/camera-compatibility.json +++ b/data/camera-compatibility.json @@ -23,7 +23,7 @@ "streams": { "main": { "codec": "H265", - "notes": "Full-range (yuvj420p), RTP aggregation-packet packetization, and a 'PPS id out of range' bitstream quirk. Benign to ffmpeg (server ingest, recording, desktop, and web are unaffected); rejected by strict RTSP HEVC readers." + "notes": "The RTSP SDP advertises the H265 video track with NO 'a=fmtp' (no sprop-vps/sps/pps) — the parameter sets ship only in-band. Android's Media3 RTSP client requires fmtp and rejects the DESCRIBE with 'IllegalArgumentException: missing attribute fmtp' (confirmed on-device 2026-08-08). Also full-range (yuvj420p) with a 'PPS id out of range' bitstream quirk. All benign to ffmpeg, so server ingest, recording, desktop, and web are unaffected." }, "sub": { "codec": "H264", @@ -44,18 +44,19 @@ { "summary": "Android fullscreen HD live falls back to SD", "affects": ["android"], - "detail": "The H265 main uses RTP aggregation packets plus a 'PPS id out of range' bitstream quirk that Android's RTSP HEVC path (Media3/ExoPlayer) cannot handle, so fullscreen live silently drops to the H264 sub and shows an 'SD' badge. It is specific to this camera's stream, not to H265 in general: clean 4K H265 mains from other cameras play full HD on the same phone. Server ingest, recording, desktop, and web are all unaffected because ffmpeg tolerates the quirk. iOS is untested." + "detail": "The confirmed blocker is the missing 'a=fmtp' in the H265 main's SDP: Media3's RTSP client throws 'missing attribute fmtp' at DESCRIBE, before any video decodes, and the tile drops to the H264 sub with an 'SD' badge. It is specific to this camera's stream, not to H265 in general: clean 4K H265 mains from other cameras play full HD on the same phone. Note the H264 '_subv' fmtp repair (an ffmpeg copy that recovers the parameter sets) does NOT translate to this H265 main: a copy-remux restores fmtp but go2rtc then bundles the VPS/SPS/PPS/SEI into an RTP Aggregation Packet at each keyframe, which Media3's RtpH265Reader cannot depacketize (androidx/media#1008) — so the copy only trades the fmtp rejection for a 'processAggregationPacket' crash (verified by packet capture 2026-08-08). Only a full-res H265->H264 transcode ('_mainv', opt-in via MAIN_REPAIR_TRANSCODE_ENABLED) yields an HD main this device can play. Server ingest, recording, desktop, and web are all unaffected." } ], "recommended_settings": [ - "For phone HD live: set the Main stream codec to H264 in the camera web UI (Setup > Video > Encoding).", - "Leave the Sub stream on H264 (the default).", + "For phone HD live: set the Main stream codec to H264 in the camera web UI (Setup > Video > Encoding). This is the cheapest fix and avoids any server transcode.", + "Alternatively, enable MAIN_REPAIR_TRANSCODE_ENABLED on the server for an opt-in, per-camera H265->H264 transcode of the main (costs recorder CPU while an Android viewer is fullscreen).", + "Leave the Sub stream on H264 (the default); Android steps down to it (SD) automatically and now does so instantly on the deterministic fmtp error.", "Keep the keyframe (I-frame) interval near 1 second (the default) so the stream starts quickly." ], "tested": { "by": "badbread", - "date": "2026-07-10", - "method": "ffprobe on the go2rtc restream plus on-device Android playback; make/model via ONVIF GetDeviceInformation" + "date": "2026-08-08", + "method": "ffprobe + RTP packet capture (tshark) on the go2rtc restream and copy-remux, on-device Android playback (Media3 1.4.1), and CrumbLiveFallback logging (#561); make/model via ONVIF GetDeviceInformation" }, "references": [ "https://github.com/androidx/media/issues/1008", diff --git a/docker-compose.yml b/docker-compose.yml index 24c00633..494403eb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -203,6 +203,7 @@ services: # value set in .env actually reaches the api (compose only passes listed keys). MOBILE_STREAM_ENABLED: ${MOBILE_STREAM_ENABLED:-} MOBILE_STREAM_WIDTH: ${MOBILE_STREAM_WIDTH:-} + MAIN_REPAIR_TRANSCODE_ENABLED: ${MAIN_REPAIR_TRANSCODE_ENABLED:-} SEGMENT_LOW_CACHE_MAX_BYTES: ${SEGMENT_LOW_CACHE_MAX_BYTES:-} EXPORT_CACHE_MAX_BYTES: ${EXPORT_CACHE_MAX_BYTES:-} # Behind the bundled Caddy (or any reverse proxy), set TRUST_PROXY=1 so the diff --git a/docs-site/docs/configuration/environment-reference.md b/docs-site/docs/configuration/environment-reference.md index 004981df..f0e3f8cb 100644 --- a/docs-site/docs/configuration/environment-reference.md +++ b/docs-site/docs/configuration/environment-reference.md @@ -185,6 +185,7 @@ are sized for a phone on a slow link. |---|---|---| | `MOBILE_STREAM_ENABLED` | `true` | the on-demand H.264 transcode that mobile clients fall back to when a camera's own streams won't decode on the device, notably a camera that is H.265 all the way down. Turning it off saves server CPU and costs those cameras live view on Android | | `MOBILE_STREAM_WIDTH` | `640` | transcode width in pixels, floored at 160 | +| `MAIN_REPAIR_TRANSCODE_ENABLED` | `false` | opt-in, per-camera full-resolution H.265 to H.264 transcode of a main stream whose SDP has no `fmtp` attribute. Android's video player rejects such a main ("missing attribute fmtp", seen on some Uniview LPR cameras) and otherwise steps down to the H.264 sub in SD. Leave it off and those cameras play in SD on Android; turn it on to get HD, at the cost of recorder CPU while an Android viewer is watching that camera fullscreen. A cheaper copy-only repair does not work for this case, which is why it is a real re-encode and off by default. The cheapest fix of all, when the camera allows it, is to set the camera's main stream to H.264 in its own web UI | | `SEGMENT_LOW_CACHE_MAX_BYTES` | `2147483648` (2 GiB) | size budget for the cache of low-resolution playback segments | ## Database backup diff --git a/docs/COMPONENT-MAP.md b/docs/COMPONENT-MAP.md index 7396dbd8..3c7b58c4 100644 --- a/docs/COMPONENT-MAP.md +++ b/docs/COMPONENT-MAP.md @@ -310,7 +310,8 @@ is not. The web admin console doubles as the desktop's management surface | LPR A/B engine benchmark (`docs/DECISIONS.md` 2026-07-17 A/B entry; backend: `GET /lpr/ab-report` `view_plates` + `POST /lpr/ab-confirm` admin-only in `plates.rs`, pure pairing in `services/common/src/lpr_ab.rs`, `lpr_pass_truth` migration `0070`; applies only to `lpr_engine = 'both'` cameras) | Deferred (compact read-only stat view is a nice-to-have) | **Benchmark** dialog off the Plates tab (`apps/desktop-flutter/lib/ui/plates/ab_benchmark.dart`), button auto-hidden unless the server reports a `both` camera; confirm-true-plate is admin-only | Deferred | Deferred | | LPR plate names (issue #363, `docs/DECISIONS.md` 2026-07-31 plate-names entry; backend: `plate_labels` migration `0073`, `PUT /lpr/plate-labels` + `DELETE /lpr/plate-labels/:plate` admin-only in `plates.rs`, display-name resolution `COALESCE(plate_labels.label, lpr_watchlist.label)` on the normalized plate folded into `list_plate_reads` + the watchlist read in `db.rs`, `display_name` added to `PlateRead`/`PlateWatchlistEntry`, alert text in `detection_ingester.rs` uses it; exact-normalized keying, no fuzzy-variant naming in v1) | LPR reads + Watchlist rows show the resolved name; per-row **Name / Rename** (blank clears) affordance (`namePlate`, admin-only server-side) | reads + watchlist rows render `display_name`, plus set/edit/clear from a read row, the read pop-up, and a watchlist row (`plates_screen.dart` + `plate_name_dialog.dart`, admin-gated on `canNamePlates`) | reads + watchlist rows render `display_name` (`feature/plates/PlatesScreen.kt`) | reads + watchlist rows render `display_name`, **plus set / rename / clear** from a read row, a gallery card, or a watchlist row (`Features/Plates/PlatesView.swift` `PlateNameSheet`, pure logic in `PlateNaming.swift`, `CrumbAPI.setPlateLabel`/`clearPlateLabel`), gated on `AppContainer.isAdmin` to match the admin-only endpoints; blank clears, and the sheet states that naming is not alerting. Same surfaces carry a **copy-plate-number** affordance (button + context menu, `CrumbClipboard` in `Platform/Platform.swift`) that always copies the raw plate, never the name. **Deferred:** web PDF report + set/clear from the desktop/Android clients | | Adaptive live-wall quality (issues #382 desktop / #383 Apple / #384 Android, `docs/DECISIONS.md` 2026-07-20 live-wall entry; two-stage predictive guardrail (75%) + reactive backpressure (85% shed / 60% restore, hysteresis), shed order protects the focused/zoomed tile, "SD" badge; client-local per-machine thresholds, NO server change) | N/A (console has no live wall) | guardrail nudge + `gpuDecUtil` backpressure shed/restore (`apps/desktop-flutter/lib/state/adaptive_wall.dart`, `ui/wall_screen.dart`) | guardrail + ExoPlayer `DecoderCounters` / `PowerManager` thermal backpressure (`feature/live/WallDecodeMonitor.kt`); sheds to snapshot (wall is sub-preferring), count-based guardrail (no per-camera resolution client-side) | guardrail + `ProcessInfo.thermalState` backpressure (`Features/Live/WallLoadController.swift`); main-vs-sub as resolution proxy. **Deferred:** `AVSampleBufferDisplayLayer` frame-health refinement | -| Targeted `_subv` sub-stream repair (issues #483/#485/#501/#526; backend: `services/api/src/go2rtc.rs` `subv_name()` (`_subv`), `subv_src()`, `resolve_needs_subv()` + the per-pass `needs_subv` map with `AppState::set_subv_needed`/`retain_subv_needed`; `rtsp_subv_url` in `services/api/src/dto.rs`. NO migration, the state is in-memory and self-clears on api restart. A video-only copy restream so go2rtc republishes a proper `fmtp` line for subs whose SDP lacks one. Registration rule: only when the sub SDP is POSITIVELY detected as lacking `fmtp` AND the rtpmap encoding is H264/H265/HEVC (MJPEG has no `fmtp` by RFC 2435 and must never be flagged); an "unknown" verdict is sticky, not a re-registration. go2rtc spawns the ffmpeg only while a consumer is attached, so an idle `_subv` costs nothing. Absent field ⇒ the client behaves exactly as before) | N/A (console has no native RTSP live path) | Deliberately NOT consumed: libmpv parses the raw sub fine, so adding `_subv` here would buy a remux for nothing. Decision, not a gap | **The only consumer**: `feature/live/LiveStreamFallback.kt` (wall `subv → sub → mobile`, fullscreen `main → subv → sub → mobile`), `LiveCameraTile.kt`, `LiveFullscreenScreen.kt`, `data/Models.kt`; tests `SubStreamUrlTest.kt`, `LiveStreamFallbackTest.kt` | Deliberately NOT consumed, same rationale as desktop | +| Targeted `_subv` sub-stream repair (issues #483/#485/#501/#526; backend: `services/api/src/go2rtc.rs` `subv_name()` (`_subv`), `subv_src()`, `resolve_needs_subv()` + the per-pass `needs_subv` map with `AppState::set_subv_needed`/`retain_subv_needed`; `rtsp_subv_url` in `services/api/src/dto.rs`. NO migration, the state is in-memory and self-clears on api restart. A video-only copy restream so go2rtc republishes a proper `fmtp` line for subs whose SDP lacks one. Registration rule: only when the sub SDP is POSITIVELY detected as lacking `fmtp` AND the rtpmap encoding is H264/H265/HEVC (MJPEG has no `fmtp` by RFC 2435 and must never be flagged); an "unknown" verdict is sticky, not a re-registration. go2rtc spawns the ffmpeg only while a consumer is attached, so an idle `_subv` costs nothing. Absent field ⇒ the client behaves exactly as before) | N/A (console has no native RTSP live path) | Deliberately NOT consumed: libmpv parses the raw sub fine, so adding `_subv` here would buy a remux for nothing. Decision, not a gap | **The only consumer**: `feature/live/LiveStreamFallback.kt` (wall `subv → sub → mobile`, fullscreen `main → mainv → subv → sub → mobile`), `LiveCameraTile.kt`, `LiveFullscreenScreen.kt`, `data/Models.kt`; tests `SubStreamUrlTest.kt`, `LiveStreamFallbackTest.kt` | Deliberately NOT consumed, same rationale as desktop | +| Targeted `_mainv` MAIN repair, OPT-IN transcode (`docs/DECISIONS.md` 2026-08-08 `_mainv` entry; backend: `services/api/src/go2rtc.rs` `mainv_name()` (`_mainv`), `mainv_src()` (`ffmpeg:#video=h264#audio=aac` — a TRANSCODE, not a copy) + the per-pass `needs_mainv` map with `AppState::set_mainv_needed`/`retain_mainv_needed`; `mainv_url()` + `rtsp_mainv_url` in `services/api/src/playback.rs`/`dto.rs`; config `main_repair_transcode_enabled` (`MAIN_REPAIR_TRANSCODE_ENABLED`, default false) in `services/api/src/config.rs`, forwarded in `docker-compose.yml`. NO migration, state in-memory. Registration rule: only when the operator opts in AND the MAIN SDP is POSITIVELY detected as lacking `fmtp` (reuses `sdp_video_lacks_fmtp` on the main producer, always warm because the recorder consumes the main through go2rtc); sticky-unknown like `_subv`. WHY a transcode not a copy: a copy-remux restores `fmtp` but go2rtc then emits an HEVC parameter-set Aggregation Packet Media3 can't depacketize (verified 2026-08-08), so only a re-encode plays on Android. Absent field ⇒ client behaves exactly as before) | N/A (console has no native RTSP live path) | Deliberately NOT consumed: libmpv plays the raw H.265 main fine (no fmtp/AP sensitivity), so a transcode would buy nothing. Decision, not a gap | **The only consumer**: `feature/live/LiveStreamFallback.kt` (`MAINV` tier, fullscreen `main → mainv → subv → sub → mobile`, wall no-sub `main → mainv → mobile`; HD, no SD badge), `data/Models.kt` `rtspMainvUrl`; tests `LiveStreamFallbackTest.kt`. Also here: `missing attribute fmtp` added to the deterministic step-down signatures so a broken main drops to SD instantly even with the repair off | Deliberately NOT consumed, same rationale as desktop | | Playback timeline "solo selected camera" (client-only UI preference, desktop-only, NO server change; `apps/desktop-flutter/lib/ui/motion_timeline/`) | N/A (console playback has no per-camera stacked motion timeline) | Toggle in the Playback legend bar collapses the stacked per-camera motion/detection strip to just the focused (maximized else selected) camera and follows the focus; pure decision `visibleTimelineCameras` in `motion_timeline_controller.dart` (unit-tested), persisted across sessions via `PlaybackPrefs.getSoloSelectedCamera`/`setSoloSelectedCamera`. Solo falls back to the full stacked view when there is no loaded selection (never a misleading empty strip) | N/A (Android timeline has no cross-camera stacked histogram) | N/A (iOS timeline has no cross-camera stacked histogram) | Parity walk for a new feature: diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 014a178b..3633918d 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -8,6 +8,112 @@ revisit. --- +## 2026-08-08, A main whose SDP lacks `fmtp` is repaired for Android by an OPT-IN H.265->H.264 transcode (`_mainv`), not the `_subv`-style copy — because a copy-remux reintroduces an AP Media3 can't read + +**Context.** Some cameras (verified: a Uniview H.265 LPR main) publish their main +over RTSP with an SDP whose video track carries no `a=fmtp` / no +`sprop-parameter-sets` — the parameter sets ship only in-band. Android's Media3 +RTSP client requires `fmtp` and rejects the DESCRIBE outright with +`IllegalArgumentException: missing attribute fmtp`, so the main never brings up a +frame. `CrumbLiveFallback` logging (#561) captured exactly this on the LPR main: +`errorCode=2000/retryable ... detail="... IllegalArgumentException: missing +attribute fmtp <- RtspPlaybackException: missing attribute fmtp"`. Recording, +motion, desktop (libmpv) and web are all unaffected — ffmpeg recovers the in-band +sets. This is the same failure class as the H.264 `_subv` repair (#483/#501), now +seen on an H.265 MAIN. + +The obvious fix was to mirror `_subv`: register `ffmpeg:#video=copy` as a +`_mainv` and hand Android that. **Empirically it does not work for H.265**, +and the reason is the whole point of this entry. + +**Empirical finding (2026-08-08, against the real LPR stream via the recorder's +embedded go2rtc; ffprobe + tshark RTP capture over loopback).** + +- The raw `lpr` main SDP has `a=rtpmap:96 H265/90000` and **no `a=fmtp`** — the + confirmed Media3 blocker. Its native go2rtc packetization is **AP-free** (399 FU + fragments + single-NAL parameter sets, zero aggregation packets), i.e. it would + be Media3-playable *if only it had fmtp*. +- A known-good 4K H.265 main (`driveway`, which plays full-HD on the same phone) is + likewise AP-free — confirming native restreams don't aggregate. +- A **copy-remux** (`ffmpeg:lpr#video=copy`) DOES restore `a=fmtp:96 + sprop-vps/sps/pps=...` — but go2rtc then emits, at every keyframe, an HEVC RTP + **Aggregation Packet (payload type 48)** bundling VPS+SPS+PPS+SEI (measured: 2 APs + across 2 keyframes, alongside 1824 FU). Media3's `RtpH265Reader` has never + implemented AP depacketization (androidx/media#1008) and throws + `processAggregationPacket` on the first one — which is the keyframe it needs to + start. So the copy-remux only trades the `missing attribute fmtp` rejection for a + `processAggregationPacket` crash. The AP is introduced by the `ffmpeg:` republish + path, not present in the native restream. + +Conclusion: for H.265, an SDP-only repair is not achievable through go2rtc's +copy-remux idiom. The only repair that yields a main Media3 can decode is a +**re-encode to H.264** (H.264 RTP from go2rtc uses STAP-A/FU-A that Media3 fully +supports). + +**Decision.** + +- Two independent fixes, in one change: + 1. **Client (ships on, free):** add `missing attribute fmtp` to + `LiveStreamFallback`'s deterministic step-down signatures (next to + `processAggregationPacket`). The error is identical on every retry, so the LPR + main now steps down to the H.264 sub (SD) on the FIRST failure instead of + waiting out ~5 IO-coded retries (~30 s). Narrow by design — matched on the + exception-chain message, not by widening any error *code* to step down (that + would reintroduce #560's eager-SD). + 2. **Server (opt-in, default OFF):** a per-camera, detection-driven repaired main + `_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` because + it costs real recorder CPU (a libx264 encode) for as long as an Android + 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 across an unknown verdict, + exactly like `_subv`. +- The recommended fix for an operator who can reach the camera remains the free + one: set the camera's main to H.264 in its own UI. The transcode is for cameras + that can't, and the client fast-step-down means even with everything off the + camera is watchable in SD immediately. + +**Rejected:** + +- **Mirror `_subv` with `ffmpeg:#video=copy` for the main (cheap, no CPU).** + This is what a reader of the `_subv` code would reach for first. Proven not to + work for H.265 above: the copy-remux reintroduces a parameter-set Aggregation + Packet Media3 can't depacketize. Kept off the table with an empirical citation so + the next session doesn't re-try it. +- **Enable the transcode by default / for every fmtp-less main.** A full-res + libx264 encode is not free; silently turning it on would tax the recorder for a + minority of cameras. Opt-in + per-camera detection keeps the cost where the + operator chose to pay it. +- **A hardware (NVENC/VAAPI) transcode to cut CPU.** Attractive on this host (it + has an iGPU + an RTX 4000) but out of scope here, and VAAPI on this deployment is + already fragile (render-node reorder incidents). Left as a future option below. +- **Do nothing server-side, rely on the client fast-step-down alone.** Enough for + "watchable" (instant SD), but the maintainer specifically wanted an HD path that + isn't "nuclear"; this provides it without imposing the cost. + +**Trades knowingly accepted.** + +- With the repair off (default), an fmtp-less H.265 main is SD on Android (the + H.264 sub) — same as before, only now instant. +- With it on, that camera's fullscreen Android view costs one full-res libx264 + process on the recorder while watched. +- `_mainv` is a TRANSCODE while `_subv` is a COPY, an intentional asymmetry the + code and tests call out (`mainv_src_is_a_transcode_not_a_copy`). + +**Revisit triggers.** + +- Media3 shipping HEVC RTP Aggregation-Packet support (androidx/media#1008) ⇒ the + cheap `#video=copy` `_mainv` would then work; switch to it and drop the transcode. +- go2rtc gaining a way to republish a native (single-NAL/FU, AP-free) HEVC restream + *with* a synthesized `fmtp` ⇒ same, an SDP-only repair becomes possible. +- Operators enabling the transcode at scale and hitting recorder CPU limits ⇒ wire + a hardware-encoder path (`#hardware`/NVENC) behind the same flag. +- The server growing per-stream codec metadata (already a trigger on the ladder + entry) ⇒ the client could pick `mainv` proactively instead of after a failure. + +--- + ## 2026-08-08, Boot storage seeding is PATH-idempotent (skip a name whose directory is already covered) + runtime name lookups fall back to path — supersedes #557's "detect, never fix" **Context.** `db::upsert_storage` is idempotent by NAME only diff --git a/services/api/src/config.rs b/services/api/src/config.rs index 11441c53..def0cd59 100644 --- a/services/api/src/config.rs +++ b/services/api/src/config.rs @@ -222,6 +222,27 @@ pub struct ApiConfig { /// transcode; height is derived to preserve aspect. Default: `640`. pub mobile_stream_width: u32, + /// `MAIN_REPAIR_TRANSCODE_ENABLED` -- register a per-camera `_mainv` + /// go2rtc stream: a FULL-RESOLUTION H.265->H.264 transcode of the camera's + /// MAIN, registered ONLY for a main the reconcile loop has detected publishes + /// video with no `a=fmtp` (see `go2rtc::sdp_video_lacks_fmtp`). Exposed to + /// clients as `rtsp_mainv_url` so Android's Media3 gets a playable HD main for + /// cameras whose real main it cannot bring up (`IllegalArgumentException: + /// missing attribute fmtp`). + /// + /// **Default: `false`, and deliberately so.** Unlike `_subv` (a cheap + /// ffmpeg *copy* that only re-extracts the SDP parameter sets), this MUST be a + /// re-encode: a copy-remux of an H.265 main fixes the fmtp but go2rtc then + /// bundles the parameter sets into an RTP Aggregation Packet Media3 cannot + /// depacketize (verified on a real LPR stream, 2026-08-08), so only a transcode + /// yields a main this device can actually play. A full-res libx264 encode costs + /// a real CPU slice on the recorder host for as long as a fullscreen consumer + /// is attached (go2rtc pulls it lazily, so idle cost is still zero). It is + /// therefore opt-in: leave it off and Android steps the broken main down to the + /// H.264 sub (SD) exactly as before; turn it on to trade recorder CPU for HD on + /// the affected cameras. See `docs/DECISIONS.md`. + pub main_repair_transcode_enabled: bool, + /// `THUMB_EXTRACT_MAX_CONCURRENCY` -- max concurrent on-demand thumbnail /// ffmpeg extractions (the filmstrip scrubber). Each cache miss spawns one /// single-frame ffmpeg; a fast multi-camera scrub would otherwise spawn a @@ -453,6 +474,7 @@ impl ApiConfig { export_cache_max_bytes: parse_env("EXPORT_CACHE_MAX_BYTES", 21_474_836_480_u64)?, mobile_stream_enabled: parse_env("MOBILE_STREAM_ENABLED", true)?, mobile_stream_width: parse_env("MOBILE_STREAM_WIDTH", 640_u32)?.max(160), + main_repair_transcode_enabled: parse_env("MAIN_REPAIR_TRANSCODE_ENABLED", false)?, thumb_extract_max_concurrency: parse_env( "THUMB_EXTRACT_MAX_CONCURRENCY", default_thumb_concurrency(), diff --git a/services/api/src/dto.rs b/services/api/src/dto.rs index 9c4c03f6..93d4d066 100644 --- a/services/api/src/dto.rs +++ b/services/api/src/dto.rs @@ -1346,6 +1346,25 @@ pub struct LiveStreamsResponse { /// camera on this server; treat this response like any other /// authenticated, per-user payload (JWT/RBAC-gated, not further exposed). pub rtsp_main_url: String, + /// RTSP URL for the client-facing REPAIRED MAIN `_mainv` — a full-res + /// H.265->H.264 **transcode** (unlike `rtsp_subv_url`, which is a copy). + /// + /// **Normally `None` — it is opt-in and the exception, not the rule.** Set only + /// when the operator has enabled `MAIN_REPAIR_TRANSCODE_ENABLED` AND the + /// reconcile loop has POSITIVELY DETECTED that this camera's main advertises + /// video with no `a=fmtp` (see `go2rtc::sdp_video_lacks_fmtp`) — the condition + /// that makes Media3's RTSP client throw `IllegalArgumentException: missing + /// attribute fmtp`. A copy-remux would fix the fmtp but go2rtc then bundles the + /// H.265 parameter sets into an RTP Aggregation Packet Media3 cannot + /// depacketize, so the repair must re-encode; that costs recorder CPU while a + /// consumer is attached, which is why it is off by default. Also `None` when + /// reconcile does not manage the camera (Frigate-served / legacy) and before + /// the first reconcile pass has reached a verdict. + /// + /// **Only Media3/ExoPlayer clients (Android) should use this**; it is the HD + /// rung tried right after `rtsp_main_url`. Same embedded-credential + + /// sensitivity note as `rtsp_main_url`. + pub rtsp_mainv_url: Option, /// RTSP URL for the RAW sub stream `_sub` (same credential note as /// `rtsp_main_url`). This is the always-warm restream: reconcile keeps one /// producer per camera running, so a consumer attaches to it immediately. diff --git a/services/api/src/go2rtc.rs b/services/api/src/go2rtc.rs index be241656..9e813a60 100644 --- a/services/api/src/go2rtc.rs +++ b/services/api/src/go2rtc.rs @@ -86,6 +86,16 @@ pub(crate) fn subv_name(go2rtc_name: &str) -> String { format!("{go2rtc_name}_subv") } +/// The go2rtc stream name for a camera's CLIENT-facing REPAIRED MAIN +/// (`_mainv`). `pub(crate)` because `playback.rs` builds the client +/// `rtsp_mainv_url` from it — the two must never drift apart. +/// +/// Registered ONLY when `main_repair_transcode_enabled` is on AND the reconcile +/// pass has detected the main lacks video `fmtp` (per-camera). See [`reconcile`]. +pub(crate) fn mainv_name(go2rtc_name: &str) -> String { + format!("{go2rtc_name}_mainv") +} + /// The go2rtc stream name for a camera's on-demand MOBILE transcode. fn mobile_name(go2rtc_name: &str) -> String { format!("{go2rtc_name}_mobile") @@ -127,6 +137,32 @@ fn subv_src(sub_stream: &str) -> String { format!("ffmpeg:{sub_stream}#video=copy") } +/// Build the go2rtc source for a camera's `_mainv` — the client-facing +/// REPAIRED MAIN the Android live client plays for a camera whose real main +/// Media3 cannot bring up (`IllegalArgumentException: missing attribute fmtp`). +/// +/// It reads the EXISTING `` main stream by name (go2rtc's documented +/// restream-and-transcode form), so it shares that stream's single producer and +/// adds no extra camera session; go2rtc only spawns the ffmpeg process while a +/// consumer is attached, so an idle `_mainv` costs nothing. +/// +/// **This is a TRANSCODE (`#video=h264`), not a copy — and that difference is the +/// whole point.** The obvious cheaper mirror of [`subv_src`] +/// (`ffmpeg:#video=copy`) does fix the missing `fmtp` (the copy re-extracts +/// the H.265 parameter sets so go2rtc republishes `sprop-vps/sps/pps`), but go2rtc +/// then bundles those parameter sets into an RTP **Aggregation Packet** at every +/// keyframe, and Media3's `RtpH265Reader` has never implemented AP depacketization +/// (androidx/media#1008) — so the copy-remux only trades the fmtp rejection for a +/// `processAggregationPacket` crash. Verified on a real LPR H.265 stream +/// (2026-08-08): the raw main and a copy-remux both stay unplayable on Media3; +/// only re-encoding to H.264 produces a main this device can decode. No `#width`, +/// so the transcode stays at the source resolution (this is the HD rung); `#audio=aac` +/// keeps audio in a form an RTSP client can play, as [`mobile_src`] does. +/// Pure + unit-tested. +fn mainv_src(main_stream: &str) -> String { + format!("ffmpeg:{main_stream}#video=h264#audio=aac") +} + /// Build the go2rtc source for a camera's `_mobile` transcode. It reads /// `input_stream` (an EXISTING go2rtc stream — the camera's sub when present, /// else main) and re-encodes to H.264 capped at `width` px (height derived to @@ -706,6 +742,32 @@ pub async fn reconcile(state: &AppState) -> Result<()> { let mobile_enabled = state.config().mobile_stream_enabled; let mobile_width = state.config().mobile_stream_width; + let main_repair_enabled = state.config().main_repair_transcode_enabled; + + // Which cameras actually need the REPAIRED-MAIN `_mainv` transcode, this pass. + // + // Gated on `main_repair_transcode_enabled` (default off): the repair is a + // full-res re-encode with real recorder CPU cost, so it is opt-in — off means + // the map stays empty and nothing changes for anyone. When on, it is still + // per-camera and detection-driven, exactly like `_subv`: a main is flagged + // only when its producer SDP advertises video with no `a=fmtp` (the condition + // that makes Media3 throw `missing attribute fmtp`). The main producer always + // has a consumer (the recorder records through go2rtc), so its SDP is reliably + // present in the index. `resolve_needs_subv` is reused for the same + // sticky-across-unknown semantics. + let mut needs_mainv: std::collections::HashMap<&str, bool> = + std::collections::HashMap::with_capacity(streams.len()); + for s in &streams { + let needed = main_repair_enabled + && resolve_needs_subv( + index.video_lacks_fmtp.get(&s.go2rtc_name).copied(), + state.mainv_needed(&s.go2rtc_name), + ); + needs_mainv.insert(s.go2rtc_name.as_str(), needed); + state.set_mainv_needed(&s.go2rtc_name, needed); + } + // Forget cameras that no longer exist (deleted between passes). + state.retain_mainv_needed(&streams.iter().map(|s| s.go2rtc_name.clone()).collect()); // Which cameras actually need the video-only `_subv` repair, this pass. // @@ -765,6 +827,13 @@ pub async fn reconcile(state: &AppState) -> Result<()> { { names.push(subv_name(&s.go2rtc_name)); } + if needs_mainv + .get(s.go2rtc_name.as_str()) + .copied() + .unwrap_or(false) + { + names.push(mainv_name(&s.go2rtc_name)); + } if mobile_enabled { names.push(mobile_name(&s.go2rtc_name)); } @@ -849,6 +918,40 @@ pub async fn reconcile(state: &AppState) -> Result<()> { tracing::info!(stream = %subv, "go2rtc: sub no longer needs the fmtp repair; removed"); } } + // The client-facing REPAIRED MAIN (`_mainv`), a full-res H.265->H.264 + // transcode registered ONLY when the operator opted the repair in AND this + // camera's main was detected publishing video with no `a=fmtp`. Kept in + // lockstep with `playback.rs`'s `rtsp_mainv_url` for the same reason as + // `_subv`: a stale `_mainv` left registered would let the client advertise + // a stream we no longer maintain. Deleted the moment it stops being needed + // (repair disabled, firmware fix, or camera swap behind the same row). + let mainv = mainv_name(&s.go2rtc_name); + if needs_mainv + .get(s.go2rtc_name.as_str()) + .copied() + .unwrap_or(false) + { + apply_stream_logged( + &c, + api_base, + &mainv, + &mainv_src(&s.go2rtc_name), + existing, + &managed, + auth, + ) + .await; + } else if existing.contains(&mainv) { + if let Err(e) = delete_stream(&c, api_base, &mainv, auth).await { + tracing::warn!( + stream = %mainv, + error = %crumb_common::redact::redact_url_credentials(&format!("{e:#}")), + "go2rtc: dropping no-longer-needed repaired main failed (will retry)" + ); + } else { + tracing::info!(stream = %mainv, "go2rtc: main no longer needs the fmtp repair; removed"); + } + } // On-demand mobile transcode: source the SUB stream when the camera has // one (already low-res), else the MAIN stream. go2rtc pulls it lazily. if mobile_enabled { @@ -1039,6 +1142,9 @@ pub async fn remove(state: &AppState, go2rtc_name: &str) -> Result<()> { // Best-effort: drop the video-only client sub too (a no-op if the camera // never had a sub — go2rtc DELETE tolerates a missing name). let _ = delete_stream(&c, api_base, &subv_name(go2rtc_name), auth).await; + // Best-effort: drop the repaired-main transcode too (a no-op if it was never + // registered — go2rtc DELETE tolerates a missing name). + let _ = delete_stream(&c, api_base, &mainv_name(go2rtc_name), auth).await; // Best-effort: drop the mobile transcode too (a no-op if it was never // registered — go2rtc DELETE tolerates a missing name). let _ = delete_stream(&c, api_base, &mobile_name(go2rtc_name), auth).await; @@ -1080,6 +1186,11 @@ pub async fn reconnect(state: &AppState, go2rtc_name: &str) -> Result<()> { if let Err(e) = delete_stream(&c, api_base, &subv_name(go2rtc_name), auth).await { tracing::warn!(go2rtc_name, error = %format!("{e:#}"), "reconnect: DELETE video-only sub stream failed (ignoring)"); } + // The repaired main is derived from ``, so it must be re-dialled with it + // (its ffmpeg reader is bound to the old main object otherwise). + if let Err(e) = delete_stream(&c, api_base, &mainv_name(go2rtc_name), auth).await { + tracing::warn!(go2rtc_name, error = %format!("{e:#}"), "reconnect: DELETE repaired-main stream failed (ignoring)"); + } // Drop the mobile transcode too, so a source-URL change re-derives it fresh // (its input stream name is unchanged, but symmetry with reconcile's PUT-all // keeps the managed set consistent). Best-effort. @@ -1896,6 +2007,43 @@ mod tests { )); } + // ── repaired main (`_mainv`) transcode (LPR H.265 / missing-fmtp) ──────── + + #[test] + fn mainv_name_and_src_shapes() { + assert_eq!(mainv_name("lpr"), "lpr_mainv"); + // Sources the MAIN stream by name (shares its producer). + assert_eq!(mainv_src("lpr"), "ffmpeg:lpr#video=h264#audio=aac"); + } + + #[test] + fn mainv_src_is_a_transcode_not_a_copy() { + // The critical difference from `_subv`, verified empirically (2026-08-08): + // a copy-remux of an H.265 main fixes the fmtp but go2rtc then emits an RTP + // Aggregation Packet for the parameter sets that Media3 cannot depacketize, + // so the repaired main MUST re-encode to H.264 to be playable on Android. + let src = mainv_src("lpr"); + assert!(src.contains("#video=h264"), "must re-encode to h264: {src}"); + assert!(!src.contains("#video=copy"), "must not be a copy: {src}"); + // Full resolution: no width cap (this is the HD rung, unlike `_mobile`). + assert!( + !src.contains("#width"), + "must stay at source resolution: {src}" + ); + } + + #[test] + fn mainv_src_is_never_an_rtsp_alias_collision() { + // Like `_subv`/`_mobile`, an `ffmpeg:` source can never trip the PATCH + // alias guard, so `_mainv` PATCHes in place rather than forcing PUT. + let managed = names(&["lpr", "lpr_sub", "lpr_mainv"]); + assert!(!is_patch_alias_collision(&mainv_src("lpr"), &managed)); + assert_eq!( + choose_verb(true, &mainv_src("lpr"), &managed), + StreamVerb::Patch + ); + } + // ── choose_verb (create-vs-patch fan-out fix) ─────────────────────────── #[test] diff --git a/services/api/src/playback.rs b/services/api/src/playback.rs index dcdc1b53..4cf350ea 100644 --- a/services/api/src/playback.rs +++ b/services/api/src/playback.rs @@ -762,6 +762,24 @@ async fn live_streams( &crumb_rtsp_authed, ); + // The client-facing REPAIRED MAIN `_mainv` — a full-res H.265->H.264 + // transcode the reconcile loop registers ONLY when the operator has enabled + // the repair (`MAIN_REPAIR_TRANSCODE_ENABLED`) AND this camera's main was + // detected publishing video with no `a=fmtp` (`state.mainv_needed`, set from + // the producer SDP in `go2rtc::reconcile`). Unlike `_subv` this has to be a + // re-encode: a copy-remux fixes the fmtp but go2rtc then emits an RTP + // Aggregation Packet for the H.265 parameter sets that Media3 cannot + // depacketize, so only a transcode gives a main Android can actually play. It + // is the HD rung the Android client tries right after the raw main; every + // other client and every camera whose main is fine (or whose operator left the + // repair off) advertises `None` and is unaffected. Same lazy-spawn economics as + // `_subv`/`_mobile`: go2rtc runs the ffmpeg only while a consumer is attached. + let repaired_main_url = mainv_url( + crumb_managed && state.mainv_needed(&cam.go2rtc_name), + &cam.go2rtc_name, + &crumb_rtsp_authed, + ); + // WebRTC signaling now goes through the AUTHENTICATED API proxy // (`POST /live/{camera_id}/webrtc?stream=main|sub`), NOT directly at // go2rtc's REST API — go2rtc's :1984 has no LAN host-publish (see @@ -802,6 +820,7 @@ async fn live_streams( webrtc_main_url, webrtc_sub_url, rtsp_main_url, + rtsp_mainv_url: repaired_main_url, rtsp_sub_url, rtsp_subv_url: video_only_sub_url, rtsp_mobile_url, @@ -854,6 +873,27 @@ fn subv_url( }) } +/// Build the client-facing REPAIRED MAIN URL (`_mainv`), or `None` when the +/// repair is not registered for this camera. +/// +/// `registered` is the caller's answer to "does this `_mainv` stream actually +/// exist in go2rtc right now" — reconcile manages the camera, the operator has +/// enabled `MAIN_REPAIR_TRANSCODE_ENABLED`, AND reconcile has positively flagged +/// the main as missing fmtp. Every camera has a main, so (unlike [`subv_url`]) +/// there is no has-sub gate; the `registered` flag carries the whole decision. +/// +/// See the `rtsp_mainv_url` block in [`live_streams`] for why the repair has to be +/// a transcode rather than the cheaper `_subv`-style copy. Pure + unit-tested. +fn mainv_url(registered: bool, go2rtc_name: &str, crumb_rtsp_authed: &str) -> Option { + registered.then(|| { + format!( + "{}/{}", + crumb_rtsp_authed.trim_end_matches('/'), + crate::go2rtc::mainv_name(go2rtc_name) + ) + }) +} + /// Convert a [`crumb_common::Segment`] to the [`ResolvedSegment`] DTO. /// /// The `url` field is the `/segments/{id}` URL the client uses to retrieve the @@ -1193,6 +1233,50 @@ mod tests { } } + // ── repaired main (`_mainv`) selection (LPR H.265 / missing-fmtp) ───────── + + /// `rtsp_mainv_url` is present ONLY for a camera reconcile has flagged and + /// registered a `_mainv` for. `registered` folds together Crumb-managed, the + /// operator's opt-in, and "detected as missing fmtp"; the handler passes + /// `crumb_managed && state.mainv_needed(name)`. Unlike `_subv` there is no + /// has-sub gate — every camera has a main. + #[test] + fn rtsp_mainv_url_present_only_when_registered() { + assert_eq!( + mainv_url(true, "lpr", BASE).as_deref(), + Some("rtsp://u:p@host:18554/lpr_mainv"), + ); + // A trailing slash on the base must not double up. + assert_eq!( + mainv_url(true, "lpr", "rtsp://u:p@host:18554/").as_deref(), + Some("rtsp://u:p@host:18554/lpr_mainv"), + ); + // Clean URL: no go2rtc query string, same reason as `_subv`. + assert!(!mainv_url(true, "lpr", BASE).unwrap().contains('?')); + // Not registered ⇒ absent, so the client keeps its normal ladder. + assert_eq!(mainv_url(false, "lpr", BASE), None); + } + + /// The gate the handler actually computes: `crumb_managed && mainv_needed` + /// (the latter already implies the operator's `MAIN_REPAIR_TRANSCODE_ENABLED`, + /// since reconcile only sets the flag when the feature is on). A healthy or + /// unmanaged camera advertises no `_mainv` and its ladder is unchanged. + #[test] + fn rtsp_mainv_url_is_gated_on_managed_and_needed() { + for (managed, needed, want) in [ + (true, true, Some("rtsp://u:p@host:18554/lpr_mainv")), + (true, false, None), + (false, true, None), + (false, false, None), + ] { + assert_eq!( + mainv_url(managed && needed, "lpr", BASE).as_deref(), + want, + "managed={managed} needed={needed}", + ); + } + } + // ── guard_path_traversal ────────────────────────────────────────────────── /// On Linux containers (the deployment target) /tmp always exists. diff --git a/services/api/src/state.rs b/services/api/src/state.rs index df296449..775ad067 100644 --- a/services/api/src/state.rs +++ b/services/api/src/state.rs @@ -178,6 +178,18 @@ struct Inner { /// falls back to the raw sub and is no worse off than before #483. subv_needed: DashMap, + /// Cameras (by `go2rtc_name`) whose MAIN stream needs the `_mainv` repair — + /// the reconcile pass read the main producer's SDP out of `GET /api/streams` + /// and found a video track with no `a=fmtp` (see `go2rtc::sdp_video_lacks_fmtp`). + /// `DashMap<_, ()>` used as a concurrent set, exactly like + /// [`subv_needed`](Inner::subv_needed), and sticky across an unknown verdict + /// for the same reason. In-memory / no migration for the same reason too: it is + /// a runtime property of go2rtc's current answer, not operator configuration. + /// Only ever populated when `main_repair_transcode_enabled` is on; empty + /// otherwise, so a client sees no `rtsp_mainv_url` and behaves exactly as + /// before this feature. + mainv_needed: DashMap, + /// Cameras (by `go2rtc_name`) whose stream go2rtc is currently REJECTING — /// the reconcile pass tried to create the stream, go2rtc answered a /// non-success status, and a confirming `GET /api/streams` showed the @@ -313,6 +325,7 @@ impl AppState { clip_inflight: DashMap::new(), go2rtc_reconcile_lock: Arc::new(tokio::sync::Mutex::new(())), subv_needed: DashMap::new(), + mainv_needed: DashMap::new(), stream_rejected: DashMap::new(), thumb_semaphore, thumb_inflight: DashMap::new(), @@ -541,6 +554,33 @@ impl AppState { self.0.subv_needed.retain(|name, ()| live.contains(name)); } + // ── `_mainv` repair flags (LPR H.265 main / missing-fmtp) ────────────────── + + /// Does this camera's MAIN stream need the `_mainv` repair transcode? See the + /// field docs on [`mainv_needed`](Inner::mainv_needed). `false` for anything + /// the reconcile pass has not positively flagged, including a cold start and + /// every camera when `main_repair_transcode_enabled` is off. + #[inline] + pub fn mainv_needed(&self, go2rtc_name: &str) -> bool { + self.0.mainv_needed.contains_key(go2rtc_name) + } + + /// Record this pass's verdict for one camera. Idempotent, so the reconcile + /// pass can call it unconditionally every time. + pub fn set_mainv_needed(&self, go2rtc_name: &str, needed: bool) { + if needed { + self.0.mainv_needed.insert(go2rtc_name.to_owned(), ()); + } else { + self.0.mainv_needed.remove(go2rtc_name); + } + } + + /// Drop flags for cameras that no longer exist, so a deleted camera's entry + /// cannot pin memory or resurface if its `go2rtc_name` is later reused. + pub fn retain_mainv_needed(&self, live: &std::collections::HashSet) { + self.0.mainv_needed.retain(|name, ()| live.contains(name)); + } + // ── go2rtc stream-rejection latch (issue #519) ──────────────────────────── /// Has the operator already been alerted that go2rtc is rejecting this