Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
4986e6c
:bug: Fix static L16 payload clock rate to 44100 Hz
steelbrain May 30, 2026
1213404
:lock: Harden RTSP parsers against malformed server input
steelbrain May 30, 2026
083a253
:bug: Harden RTSP session start path
steelbrain May 30, 2026
3a43cff
:fire: Reject UDP transport and remove its dead scaffolding
steelbrain May 30, 2026
1142163
:lock: Harden RTP packet and context handling
steelbrain May 30, 2026
8769b45
:lock: Saturate loss counters to avoid overflow traps
steelbrain May 30, 2026
7778623
:bug: Fix and harden H.264 SPS parsing
steelbrain May 30, 2026
55c48b0
:lock: Harden H.265 depacketizer and parameter-set parsing
steelbrain May 30, 2026
9c0129a
:lock: Fix and strengthen RTSP Digest authentication
steelbrain May 30, 2026
0ed6c2c
:bug: Normalize audio frame data to a standalone Data
steelbrain May 30, 2026
a85701f
:lock: Add a connect timeout to the RTSP TCP transport
steelbrain May 30, 2026
4f62874
:art: Consolidate stream SETUP and honor server interleaved channel
steelbrain May 30, 2026
fd7a4e9
:shirt: Wrap long precondition message in RTSPSerializer
steelbrain May 30, 2026
3556722
:memo: Record retina upstream sync anchor and port checklist [ci skip]
steelbrain May 30, 2026
42010e4
:bug: Ignore RTP MARK bit on H.264 SEI packets
steelbrain May 30, 2026
66ab85e
:bug: Tolerate a complete NAL inside an FU wrapper
steelbrain May 30, 2026
b6ff670
:bug: Accept scheme-less Content-Base header
steelbrain May 30, 2026
ef99e07
:memo: Advance retina sync anchor to 6972ac4 [ci skip]
steelbrain May 30, 2026
85a7fac
:green_heart: Install ffmpeg and mediamtx in CI for live tests
steelbrain May 30, 2026
d28407c
:white_check_mark: Add live ffmpeg/mediamtx RTSP integration tests
steelbrain May 30, 2026
5a977e2
:memo: Document ffmpeg/mediamtx live-test dependencies [ci skip]
steelbrain May 30, 2026
f8cc372
:new: Add RTSP keepalive and route control responses while streaming
steelbrain May 30, 2026
adc747f
:white_check_mark: Add live keepalive test; harden fixture for parall…
steelbrain May 30, 2026
b96300e
:memo: Document RTSP session keepalive [ci skip]
steelbrain May 30, 2026
90342de
:new: Add UDPPair bound socket pair for RTP/RTCP
steelbrain May 30, 2026
865ddf2
:new: Implement real UDP transport for RTP/RTCP
steelbrain May 30, 2026
b34a2ab
:white_check_mark: Add live H.264/H.265/AAC over UDP tests
steelbrain May 30, 2026
5e5738a
:memo: Note BSD-socket UDP RTP/RTCP in architecture tree [ci skip]
steelbrain May 30, 2026
bb3b70c
:white_check_mark: Run live tests single-transport to isolate each path
steelbrain May 30, 2026
a60ac10
:new: Rewrite UDP transport on Network framework with IPv6
steelbrain May 30, 2026
ae8e320
:bug: Strip IPv6 brackets from the RTSP URL host
steelbrain May 30, 2026
98824e5
:white_check_mark: Add IPv6 live tests; move fixture off Darwin
steelbrain May 30, 2026
e317c4e
:arrow_up: Update docs with up to date info
steelbrain May 30, 2026
696c2e4
:memo: Document IPv6 support and fix API.md gaps [ci skip]
steelbrain May 30, 2026
088c69c
:lock: Make ntpUnixEpoch private to module
steelbrain May 30, 2026
e02fadc
:lock: Harden RTSP framing against malformed/hostile input
steelbrain May 30, 2026
3f769b3
:bug: Tolerate sloppy SDP from real cameras
steelbrain May 30, 2026
249f894
:lock: Harden RTSP auth: escape quoted-strings, fix 401 retry
steelbrain May 30, 2026
e26ca02
:bug: Validate RTCP SSRC before anchoring the timeline
steelbrain May 30, 2026
9f3386c
:lock: Stop codec parsers trapping on malformed input
steelbrain May 30, 2026
d426941
:bug: Make H.265 fmtp parsing tolerant (Postel)
steelbrain May 30, 2026
ac11833
:bug: Make AAC depacketization tolerant of real cameras
steelbrain May 30, 2026
7193a04
:lock: Bound depacketizer memory and reset on failure
steelbrain May 30, 2026
4fdd003
:bug: Don't tear down the stream on a single bad packet
steelbrain May 30, 2026
c0c3b0c
:bug: Harden session lifecycle and SETUP tolerance
steelbrain May 30, 2026
2fcafc1
:lock: Surface transport-level failures instead of stalling
steelbrain May 30, 2026
a82b46b
:memo: Clarify depacketizer/session docs and known limits [ci skip]
steelbrain May 30, 2026
0fdabbd
:fire: Remove the dead InternalError enum
steelbrain May 30, 2026
a5202d8
:new: Route remaining silent drops through onDiagnostic
steelbrain May 30, 2026
b5603bf
:white_check_mark: Live-test single-consumer frames() guard
steelbrain May 30, 2026
f30629e
:memo: Update CHANGELOG and README for changes since 0.2.0 [ci skip]
steelbrain May 30, 2026
a8629fb
:green_heart: Run CI on same-repo PRs, skip fork branches
steelbrain May 30, 2026
cc24a1a
:shirt: Make never-mutated timeline test vars constants
steelbrain May 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,23 @@ on:
jobs:
build-and-test:
name: Build & Test
# Run for pushes (post-merge to main) and for pull requests whose head
# branch lives in this repo. Pull requests from forks (foreign branches)
# have a different head repo and are skipped, so they never reach the
# macOS runner or install dependencies.
if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository
runs-on: macos-15
steps:
- uses: actions/checkout@v4
with:
submodules: false

# The live integration tests publish a synthetic stream with ffmpeg to a
# mediamtx RTSP server and pull it back through RTSPClientSession, so both
# tools must be on PATH. See README "Testing".
- name: Install integration test dependencies
run: brew install ffmpeg mediamtx

- name: Build
run: swift build -v

Expand All @@ -23,6 +34,7 @@ jobs:

lint:
name: Lint
if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository
runs-on: macos-15
steps:
- uses: actions/checkout@v4
Expand Down
24 changes: 23 additions & 1 deletion API.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ await session.stop()
### RTSPClientSession

The main entry point. Manages the full RTSP lifecycle (DESCRIBE, SETUP, PLAY, TEARDOWN).
While streaming it sends periodic keepalives (GET_PARAMETER when the server
advertises support, otherwise OPTIONS) at roughly half the negotiated session
timeout, so long-running sessions aren't dropped by the camera. A successful
keepalive is reported via `onDiagnostic` at `.info` severity; a failed one at
`.warning` — the stream keeps running and the next tick retries.

```swift
final class RTSPClientSession: Sendable
Expand All @@ -51,6 +56,10 @@ func frames() -> AsyncThrowingStream<PublicCodecItem, Error>
func stop() async
```

The `url` host may be a hostname, an IPv4 literal, or a bracketed IPv6 literal
(`rtsp://[2001:db8::1]:554/stream`) — including link-local addresses with a zone
id. Both IPv4 and IPv6 cameras work over either transport.

### RTSPDiagnostic

Non-fatal anomalies observed during a session (e.g. cameras deviating from spec). The
Expand Down Expand Up @@ -78,10 +87,12 @@ init(username: String, password: String)
```swift
enum Transport: Sendable {
case tcp // RTP interleaved over RTSP TCP connection
case udp // RTP/RTCP on separate UDP ports
case udp // RTP/RTCP on a separate even/odd UDP port pair
}
```

Both transports work over IPv4 and IPv6 peers.

## Session Description

Returned by `start()` with stream metadata parsed from SDP.
Expand Down Expand Up @@ -405,6 +416,17 @@ enum StreamContext: Sendable {
case dummy
}

struct TcpStreamContext: Sendable {
let rtpChannelId: UInt8 // RTCP channel id is one higher
}

struct UdpStreamContext: Sendable {
let localIP: String
let peerIP: String
let localRtpPort: UInt16 // RTCP port is one higher
let peerRtpPort: UInt16
}

enum PacketContext: Sendable {
case tcp(RtspMessageContext)
case udp(receivedWall: WallTime)
Expand Down
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,38 @@
### New

- ONVIF analytics metadata stream support (`vnd.onvif.metadata` per the ONVIF Streaming Specification). Surfaced as `PublicCodecItem.metadata(PublicMetadataFrame)` in the `session.frames()` stream, with discoverability via `SessionDescription.metadataEncoding`. Best-effort: malformed metadata SDP or a failed application SETUP degrades to a diagnostic without aborting video/audio.
- Real UDP transport for RTP/RTCP. Selecting `Transport.udp` now streams end-to-end over an Apple Network-framework socket pair (an even RTP port and the consecutive odd RTCP port, negotiated via `client_port`), replacing the earlier non-functional scaffolding. A best-effort NAT hole-punch is sent toward the peer after `PLAY`.
- IPv6 support on both transports. The RTSP control connection and the UDP RTP/RTCP pair work over IPv6 peers, and a bracketed IPv6 literal in the URL host (e.g. `rtsp://[2001:db8::1]:554/stream`) is parsed correctly.
- Automatic RTSP session keepalive while streaming: sends `GET_PARAMETER` when the server advertises support, otherwise `OPTIONS`, at roughly half the negotiated session timeout, so long-running sessions aren't dropped by the camera. Each round-trip is reported via `onDiagnostic` at `.info` severity.

### Improvements

- Add visionOS 1.0 to supported platforms
- Make the `ntpUnixEpoch` constant private. It was accidentally exposed as a public top-level symbol but is only used internally; consumers should not have relied on it.
- Add a connect timeout to the RTSP TCP transport so an unreachable or refused camera fails fast instead of hanging. `NWConnection` parks refused/unreachable peers in `.waiting`, which previously burned the full timeout; these now surface the real cause after a short grace window.
- Surface transport-level failures that previously stalled or died silently: a terminal UDP receive error (e.g. an ICMP port-unreachable from a NAT'd camera) is now reported via `onDiagnostic` instead of letting the receive loop die quietly.
- Broaden `onDiagnostic` coverage of the drop/recover paths — interleaved data on an unnegotiated channel, FU-reassembly anomalies, audio/metadata SETUP or init failures, sprop parameter-set parse fallback, interleaved media a camera streams before its `PLAY` response completes, and SDP streams that fail to parse while others succeed. Each condition is rate-limited to fire once so a misbehaving camera can't flood the consumer.
- Live integration test suite: drives `RTSPClientSession` end-to-end against an `ffmpeg`-published stream relayed by `mediamtx`, exercising H.264/H.265/AAC over both RTSP-interleaved TCP and UDP, including IPv6. CI installs `ffmpeg` and `mediamtx`.

### Fixes

- Audio depacketizer init failures now null out the audio stream state (index, encoding name, clock rate, channels), mirroring the metadata-init failure path. Previously the indices stayed set while the depacketizer was nil; packets on that channel were silently dropped by the dispatch loop but `SessionDescription` could still claim the stream existed. Required to keep the "at least one usable stream" guard honest in audio-only sessions.
- Stop the codec, SDP, and RTSP parsers from trapping (crashing the process) on malformed or hostile input — out-of-range reads, bad lengths, and truncated buffers now error or are tolerated instead of aborting.
- Don't tear down the stream on a single bad packet; the packet is dropped and streaming continues.
- Bound depacketizer memory and reset depacketizer state on failure, so a malformed stream can't grow memory without limit.
- Validate the RTCP sender-report SSRC before anchoring the RTP/NTP timeline, so a stray report can't corrupt timestamps.
- Tolerate sloppy SDP and lenient H.265 `fmtp` from real cameras (Postel's law) instead of rejecting the session, and make AAC depacketization tolerant of real-camera deviations.
- Harden the session lifecycle and SETUP tolerance.
- Strengthen RTSP Digest authentication: escape quoted-string parameters correctly and fix the 401 retry path.
- Harden the H.265 depacketizer and parameter-set parsing against malformed input.
- Fix and harden H.264 SPS parsing.
- Harden RTP packet and context handling, and saturate loss counters to avoid overflow traps.
- Harden the RTSP parsers against malformed server input, and harden the session start path.
- Fix the static L16 payload clock rate to 44100 Hz.
- Ignore the RTP MARK bit on H.264 SEI packets (some cameras set it on a trailing SEI, splitting the access unit early).
- Tolerate a complete (un-fragmented) NAL that arrives inside an FU wrapper instead of erroring.
- Accept a scheme-less `Content-Base` header by resolving it against the request URL.
- Normalize audio frame data to a standalone `Data`.

## 0.2.0

Expand Down
24 changes: 18 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,13 @@ A pure-Swift RTSP client library for streaming live video and audio from IP came
- **Audio** — AAC, PCMU, PCMA, G.722, G.726, L16, G.723.1
- **ONVIF analytics metadata** — raw XML documents from the camera's `application` RTSP stream
- **Optional streams** — any combination of video / audio / metadata is supported; audio-only or metadata-only sessions (e.g. Axis `video=0`) work end-to-end
- **Zero dependencies** — only Foundation, Network, and CryptoKit
- **TCP & UDP transport** — RTP/RTCP over RTSP-interleaved TCP or a dedicated UDP socket pair, over IPv4 or IPv6
- **Zero dependencies** — built only on Apple system frameworks (Foundation, Network, CryptoKit)
- **Swift 6** — strict concurrency with async/await and AsyncThrowingStream

## Requirements

- macOS 14.0+
- macOS 13.0+, iOS 16.0+, tvOS 16.0+, Mac Catalyst 16.0+, visionOS 1.0+
- Swift 6.0+

## Installation
Expand All @@ -20,7 +21,7 @@ Add IPCamKit as a dependency in your `Package.swift`:

```swift
dependencies: [
.package(url: "https://github.com/steelbrain/IPCamKit.git", from: "0.1.1"),
.package(url: "https://github.com/steelbrain/IPCamKit.git", from: "0.2.0"),
]
```

Expand Down Expand Up @@ -103,7 +104,9 @@ See [API.md](API.md) for the full API reference.
- SDP parsing with codec parameter extraction
- RTP packet parsing (RFC 3550) with sequence tracking and loss detection
- RTSP authentication (Basic and Digest with MD5)
- Transport: TCP interleaved and UDP
- Automatic session keepalive while streaming (GET_PARAMETER when the server
advertises it, else OPTIONS) so long sessions aren't dropped at the timeout
- Transport: TCP interleaved and UDP, over IPv4 or IPv6

### Compatibility
- Tested with Reolink, Dahua, Hikvision, Longse, GW Security, VStarcam, Tenda, Foscam, and others
Expand Down Expand Up @@ -132,18 +135,27 @@ Sources/IPCamKit/
├── RTP/ RTP/RTCP packets, Timeline, ChannelMapping, InorderParser
├── Codec/ H.264/H.265 depacketizers, NAL/SPS/PPS parsing, audio + metadata depacketizers
├── Auth/ Basic and Digest authentication
├── Transport/ NWConnection TCP/UDP transport
├── Transport/ Network-framework RTSP/TCP control + UDP RTP/RTCP socket pair (IPv4/IPv6)
└── Client/ RTSP session, DESCRIBE/SETUP/PLAY parsers, Presentation
```

## Testing

100+ tests across 15+ suites covering RTSP parsing, SDP, RTP, H.264/H.265 depacketization, AAC, simple audio, ONVIF metadata depacketization, authentication, and integration:
165+ tests across 18 suites covering RTSP parsing, SDP, RTP, H.264/H.265 depacketization, AAC, simple audio, ONVIF metadata depacketization, authentication, and the full pipeline:

```bash
swift test
```

The **live integration suite** drives the real `RTSPClientSession` end to end: `ffmpeg`
publishes a synthetic H.264/H.265/AAC stream to a [mediamtx](https://github.com/bluenviron/mediamtx)
RTSP server, and the client pulls it back over both RTSP-interleaved TCP and UDP. Both tools
must be on `PATH`:

```bash
brew install ffmpeg mediamtx
```

## License

MIT — see [LICENSE](LICENSE) for details.
Expand Down
79 changes: 79 additions & 0 deletions RETINA_SYNC.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# Upstream sync: scottlamb/retina

IPCamKit began as a Swift port of the Rust crate
[`retina`](https://github.com/scottlamb/retina). Most files carry
`// Port of retina src/...` comments with upstream line references.

This file records the last upstream commit we have reconciled against, so a
future sync only has to look at what landed in retina *after* this anchor.

## Status

- **Fork point:** retina ~late February 2026 (IPCamKit's initial commit is
2026-02-28).
- **Upstream HEAD at last review:** `6972ac4261ce7bf5b585da9051606c7b5c0ab82c`
("accept scheme-less `Content-Base` header", 2026-03-30).
- **Last reconciled commit (anchor):** `6972ac4261ce7bf5b585da9051606c7b5c0ab82c`
("accept scheme-less `Content-Base` header", 2026-03-30). All relevant
behavioral changes through this commit have been ported; see the checklist
below for the porting commits and skip rationale.
- **Reviewed on:** 2026-05-30

> IPCamKit has *diverged on purpose*: it is hardened against malformed/hostile
> camera input (overflow-trap guards, bounded parser loops, best-effort
> audio/metadata) and fixes several port bugs. When pulling upstream changes,
> re-apply those hardening patterns rather than reverting to retina's
> debug-panic / `assert!` style.

## Post-fork upstream commits to evaluate (fork → 6972ac4)

Relevant (behavioral) — ported:

- [x] `ff771fe` (2026-03-13) — **H.264: ignore the RTP MARK bit on SEI packets**
(some cameras set MARK on a trailing SEI, splitting the access unit early).
Ported in IPCamKit `42010e4`: `H264Depacketizer.canEndAU` now excludes
NAL type 6 (SEI) alongside SPS/PPS, with test vectors updated to match.
- [x] `8ff7a0f` (2026-03-28, with follow-up `e1c0bbf` "hoist end block") —
**tolerate a complete (un-fragmented) NAL that arrives inside an FU
wrapper** ("struggle on" instead of erroring). Ported in IPCamKit
`66ab85e`: both H.264 (FU-A) and H.265 (FU) depacketizers treat a
START+END fragment as one complete NAL.
- [x] `6972ac4` (2026-03-30) — **accept a scheme-less `Content-Base` header**
(resolve it against the request URL instead of rejecting). Ported in
IPCamKit `b6ff670`: `DescribeParser` prepends the request URL's scheme to
a scheme-less Content-Base/Content-Location.

Evaluated, intentionally skipped:

- `6339bd6` (2026-02-27) — *support stripping H.26x inline parameter sets.* This
is a **feature**, not a bug fix: it adds a configurable `FrameFormat` /
`ParameterSetInsertion` policy controlling whether in-band SPS/PPS/VPS are
inserted into or stripped from output frames. IPCamKit passes NALs through as
received and surfaces parameter sets separately (`VideoStream.sps/.pps/.vps`
and `PublicVideoFrame.sps/.pps/.vps`), so there is no output-framing policy to
configure. Not applicable.

Confirmed no-op for us (API exposure / examples / Rust-only infra) — skipped:

- `58f0042` FrameFormat (tied to the `6339bd6` feature above), `93f5917`
VideoParameters from SPS/PPS, `8ecbeab` expose audio channels, `0799d0e` coded
width/height, `6a4568b` receive timestamps — IPCamKit's public surface already
covers the inspectable data (`VideoParameters.pixelDimensions/rfc6381Codec`,
`AudioStream.channels`, per-frame presentation `timestamp`); these are
feature/API-exposure changes with no behavioral bug fix to port.
- `b6a18c4` own RTSP/1.0 parser, `f2bcec3` derive_more, `7073bb5`/`c92ff24`
fuzz crate, `09c6175` license headers, `80abcd6` build-without-h265,
`44042ba` flaky test, examples/docs/clippy — Rust-specific, not applicable.

## How to sync next time

```sh
git clone https://github.com/scottlamb/retina /tmp/retina
cd /tmp/retina
git log --oneline <anchor>..HEAD
git diff <anchor>..HEAD -- src/rtp.rs src/rtcp.rs src/client/ src/codec/
```

For each relevant change, port it into the matching `Sources/IPCamKit/...`
file, keep the `// Port of retina ...` line references accurate, and update the
**Last reconciled commit** above to the new anchor.
Loading
Loading