From 5cc69e751c80473f0114e02f38c72a1c828c7111 Mon Sep 17 00:00:00 2001 From: Scott Lamb Date: Thu, 3 Jun 2021 16:26:45 -0700 Subject: [PATCH] initial commit --- .github/workflows/check-license.py | 66 + .github/workflows/ci.yml | 49 + .gitignore | 1 + Cargo.lock | 1205 +++++++++++++++++ Cargo.toml | 38 + LICENSE-APACHE.txt | 202 +++ LICENSE-MIT.txt | 20 + README.md | 59 + examples/client/main.rs | 97 ++ examples/client/metadata.rs | 49 + examples/client/mp4.rs | 601 ++++++++ src/client/channel_mapping.rs | 152 +++ src/client/mod.rs | 810 +++++++++++ src/client/parse.rs | 1131 ++++++++++++++++ src/client/rtp.rs | 216 +++ src/client/testdata/bunny_describe.txt | 30 + src/client/testdata/bunny_play.txt | 8 + src/client/testdata/bunny_setup.txt | 9 + .../dahua_describe_h264_aac_onvif.txt | 32 + .../testdata/dahua_describe_h265_pcma.txt | 27 + src/client/testdata/dahua_play.txt | 6 + src/client/testdata/dahua_setup.txt | 6 + src/client/testdata/foscam_describe.txt | 29 + src/client/testdata/gw_main_describe.txt | 25 + src/client/testdata/gw_main_play.txt | 6 + src/client/testdata/gw_main_setup_audio.txt | 5 + src/client/testdata/gw_main_setup_video.txt | 5 + src/client/testdata/gw_sub_describe.txt | 19 + src/client/testdata/gw_sub_play.txt | 6 + src/client/testdata/gw_sub_setup.txt | 5 + src/client/testdata/hikvision_describe.txt | 29 + src/client/testdata/hikvision_play.txt | 6 + src/client/testdata/hikvision_setup.txt | 6 + src/client/testdata/reolink_describe.txt | 31 + src/client/testdata/reolink_play.txt | 8 + src/client/testdata/reolink_setup.txt | 6 + src/client/timeline.rs | 187 +++ src/codec/aac.rs | 680 ++++++++++ src/codec/g723.rs | 70 + src/codec/h264.rs | 621 +++++++++ src/codec/mod.rs | 450 ++++++ src/codec/onvif.rs | 129 ++ src/codec/simple_audio.rs | 76 ++ src/lib.rs | 310 +++++ 44 files changed, 7523 insertions(+) create mode 100755 .github/workflows/check-license.py create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 LICENSE-APACHE.txt create mode 100644 LICENSE-MIT.txt create mode 100644 README.md create mode 100644 examples/client/main.rs create mode 100644 examples/client/metadata.rs create mode 100644 examples/client/mp4.rs create mode 100644 src/client/channel_mapping.rs create mode 100644 src/client/mod.rs create mode 100644 src/client/parse.rs create mode 100644 src/client/rtp.rs create mode 100644 src/client/testdata/bunny_describe.txt create mode 100644 src/client/testdata/bunny_play.txt create mode 100644 src/client/testdata/bunny_setup.txt create mode 100644 src/client/testdata/dahua_describe_h264_aac_onvif.txt create mode 100644 src/client/testdata/dahua_describe_h265_pcma.txt create mode 100644 src/client/testdata/dahua_play.txt create mode 100644 src/client/testdata/dahua_setup.txt create mode 100644 src/client/testdata/foscam_describe.txt create mode 100644 src/client/testdata/gw_main_describe.txt create mode 100644 src/client/testdata/gw_main_play.txt create mode 100644 src/client/testdata/gw_main_setup_audio.txt create mode 100644 src/client/testdata/gw_main_setup_video.txt create mode 100644 src/client/testdata/gw_sub_describe.txt create mode 100644 src/client/testdata/gw_sub_play.txt create mode 100644 src/client/testdata/gw_sub_setup.txt create mode 100644 src/client/testdata/hikvision_describe.txt create mode 100644 src/client/testdata/hikvision_play.txt create mode 100644 src/client/testdata/hikvision_setup.txt create mode 100644 src/client/testdata/reolink_describe.txt create mode 100644 src/client/testdata/reolink_play.txt create mode 100644 src/client/testdata/reolink_setup.txt create mode 100644 src/client/timeline.rs create mode 100644 src/codec/aac.rs create mode 100644 src/codec/g723.rs create mode 100644 src/codec/h264.rs create mode 100644 src/codec/mod.rs create mode 100644 src/codec/onvif.rs create mode 100644 src/codec/simple_audio.rs create mode 100644 src/lib.rs diff --git a/.github/workflows/check-license.py b/.github/workflows/check-license.py new file mode 100755 index 0000000..b95acb4 --- /dev/null +++ b/.github/workflows/check-license.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +# Copyright (C) 2021 Scott Lamb +# SPDX-License-Identifier: MIT OR Apache-2.0 +"""Checks that expected header lines are present. + +Call in either of two modes: + +has-license.py FILE [...] + check if all files with certain extensions have expected lines. + This is useful in a CI action. + +has-license.py + check if stdin has expected lines. + This is useful in a pre-commit hook, as in + git-format-staged --no-write --formatter '.../has-license.py' '*.rs' +""" +import re +import sys + +# Filenames matching this regexp are expected to have the header lines. +FILENAME_MATCHER = re.compile(r'.*\.rs$') + +MAX_LINE_COUNT = 10 + +EXPECTED_LINES = [ + re.compile(r'Copyright \(C\) 20\d{2} Scott Lamb '), + re.compile(r'SPDX-License-Identifier: MIT OR Apache-2\.0'), +] + +def has_license(f): + """Returns if all of EXPECTED_LINES are present within the first + MAX_LINE_COUNT lines of f.""" + needed = set(EXPECTED_LINES) + i = 0 + for line in f: + if i == 10: + break + i += 1 + for e in needed: + if e.search(line): + needed.remove(e) + break + if not needed: + return True + return False + + +def file_has_license(filename): + with open(filename, 'r') as f: + return has_license(f) + + +def main(args): + if not args: + sys.exit(0 if has_license(sys.stdin) else 1) + + missing = [f for f in args + if FILENAME_MATCHER.match(f) and not file_has_license(f)] + if missing: + print('The following files are missing expected copyright/license headers:', file=sys.stderr) + print('\n'.join(missing), file=sys.stderr) + sys.exit(1) + + +if __name__ == '__main__': + main(sys.argv[1:]) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..8ad83db --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,49 @@ +name: CI +on: [push, pull_request] + +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: 1 + +jobs: + rust: + name: Test + strategy: + matrix: + rust: + - stable + - 1.52 + include: + - rust: stable + extra_components: rustfmt + runs-on: ubuntu-20.04 + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Cache + uses: actions/cache@v2 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ matrix.rust }}-cargo-${{ hashFiles('**/Cargo.lock') }} + - name: Install Rust + uses: actions-rs/toolchain@v1 + with: + profile: minimal + toolchain: ${{ matrix.rust }} + override: true + components: ${{ matrix.extra_components }} + - name: Test + run: cargo test --all-features + - name: Check formatting + if: matrix.rust == 'stable' + run: cargo fmt -- --check + license: + name: Check copyright/license headers + runs-on: ubuntu-20.04 + steps: + - name: Checkout + uses: actions/checkout@v2 + - run: find . -type f -print0 | xargs -0 .github/workflows/check-license.py \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..6e842d5 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1205 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +[[package]] +name = "addr2line" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03345e98af8f3d786b6d9f656ccfa6ac316d954e92bc4841f0bba20789d5fb5a" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" + +[[package]] +name = "ansi_term" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee49baf6cb617b853aa8d93bf420db2383fab46d314482ca2803b40d5fde979b" +dependencies = [ + "winapi", +] + +[[package]] +name = "arrayvec" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b" + +[[package]] +name = "async-stream" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "171374e7e3b2504e0e5236e3b59260560f9fe94bfe9ac39ba5e4e929c5590625" +dependencies = [ + "async-stream-impl", + "futures-core", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "648ed8c8d2ce5409ccd57453d9d1b214b342a0d69376a6feda1fd6cae3299308" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atty" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" +dependencies = [ + "hermit-abi", + "libc", + "winapi", +] + +[[package]] +name = "autocfg" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb031dd78e28731d87d56cc8ffef4a8f36ca26c38fe2de700543e627f8a464a" + +[[package]] +name = "backtrace" +version = "0.3.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7815ea54e4d821e791162e078acbebfd6d8c8939cd559c9335dceb1c8ca7282" +dependencies = [ + "addr2line", + "cc", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", +] + +[[package]] +name = "base64" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "904dfeac50f3cdaba28fc6f57fdcddb75f49ed61346676a78c4ffe55877802fd" + +[[package]] +name = "bitflags" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf1de2fe8c75bc145a2f577add951f8134889b4795d47466a54a5c846d691693" + +[[package]] +name = "bitreader" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9178181a7d44239c6c8eaafa8688558a2ab5fa04b8855381f2681e9591fb941b" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "bitvec" +version = "0.19.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8942c8d352ae1838c9dda0b0ca2ab657696ef2232a20147cf1b30ae1a9cb4321" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "block-buffer" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bytes" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b700ce4376041dcd0a327fd0097c41095743c4c8af8887265942faf1100bd040" + +[[package]] +name = "cc" +version = "1.0.68" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a72c244c1ff497a746a7e1fb3d14bd08420ecda70c8f25c7112f2781652d787" + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "chrono" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "670ad68c9088c2a963aaa298cb369688cf3f9465ce5e2d4ca10e6e0098a1ce73" +dependencies = [ + "libc", + "num-integer", + "num-traits", + "time", + "winapi", +] + +[[package]] +name = "clap" +version = "2.33.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37e58ac78573c40708d45522f0d80fa2f01cc4f9b4e2bf749807255454312002" +dependencies = [ + "ansi_term", + "atty", + "bitflags", + "strsim", + "textwrap", + "unicode-width", + "vec_map", +] + +[[package]] +name = "cookie-factory" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396de984970346b0d9e93d1415082923c679e5ae5c3ee3dcbd104f5610af126b" + +[[package]] +name = "cpufeatures" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed00c67cb5d0a7d64a44f6ad2668db7e7530311dd53ea79bcd4fb022c64911c8" +dependencies = [ + "libc", +] + +[[package]] +name = "digest" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" +dependencies = [ + "generic-array", +] + +[[package]] +name = "digest_auth" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa30657988b2ced88f68fe490889e739bf98d342916c33ed3100af1d6f1cbc9c" +dependencies = [ + "digest", + "hex", + "md-5", + "rand", + "sha2", +] + +[[package]] +name = "failure" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d32e9bd16cc02eae7db7ef620b392808b89f6a5e16bb3497d159c6b92a0f4f86" +dependencies = [ + "backtrace", + "failure_derive", +] + +[[package]] +name = "failure_derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa4da3c766cd7a0db8242e326e9e4e081edd567072893ed320008189715366a4" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "form_urlencoded" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fc25a87fa4fd2094bffb06925852034d90a17f0d1e05197d4956d3555752191" +dependencies = [ + "matches", + "percent-encoding", +] + +[[package]] +name = "four-cc" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3958af68a31b1d1384d3f39b6aa33eb14b6009065b5ca305ddd9712a4237124f" + +[[package]] +name = "funty" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fed34cd105917e91daa4da6b3728c47b068749d6a62c59811f06ed2ac71d9da7" + +[[package]] +name = "futures" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7e43a803dae2fa37c1f6a8fe121e1f7bf9548b4dfc0522a42f34145dadfc27" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e682a68b29a882df0545c143dc3646daefe80ba479bcdede94d5a703de2871e2" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0402f765d8a89a26043b889b26ce3c4679d268fa6bb22cd7c6aad98340e179d1" + +[[package]] +name = "futures-executor" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "badaa6a909fac9e7236d0620a2f57f7664640c56575b71a7552fbd68deafab79" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acc499defb3b348f8d8f3f66415835a9131856ff7714bf10dadfc4ec4bdb29a1" + +[[package]] +name = "futures-macro" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4c40298486cdf52cc00cd6d6987892ba502c7656a16a4192a9992b1ccedd121" +dependencies = [ + "autocfg", + "proc-macro-hack", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a57bead0ceff0d6dde8f465ecd96c9338121bb7717d3e7b108059531870c4282" + +[[package]] +name = "futures-task" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a16bef9fc1a4dddb5bee51c989e3fbba26569cbb0e31f5b303c184e3dd33dae" + +[[package]] +name = "futures-util" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "feb5c238d27e2bf94ffdfd27b2c29e3df4a68c4193bb6427384259e2bf191967" +dependencies = [ + "autocfg", + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "proc-macro-hack", + "proc-macro-nested", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "501466ecc8a30d1d3b7fc9229b122b2ce8ed6e9d9223f1138d4babb253e51817" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fcd999463524c52659517fe2cea98493cfe485d10565e7b0fb07dbba7ad2753" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "gimli" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e4075386626662786ddb0ec9081e7c7eeb1ba31951f447ca780ef9f5d568189" + +[[package]] +name = "h264-reader" +version = "0.4.0" +source = "git+https://github.com/dholroyd/h264-reader#dd2d05d54bec596993be9a0833690b54219f6778" +dependencies = [ + "bitreader", + "memchr", + "rfc6381-codec", +] + +[[package]] +name = "heck" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d621efb26863f0e9924c6ac577e8275e5e6b77455db64ffa6c65c904e9e132c" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "hermit-abi" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "322f4de77956e22ed0e5032c359a0f1273f1f7f0d79bfa3b8ffbc730d7fbcc5c" +dependencies = [ + "libc", +] + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "idna" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418a0a6fab821475f634efe3ccc45c013f742efe03d853e8d3355d5cb850ecf8" +dependencies = [ + "matches", + "unicode-bidi", + "unicode-normalization", +] + +[[package]] +name = "instant" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61124eeebbd69b8190558df225adf7e4caafce0d743919e5d6b19652314ec5ec" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "lazy_static" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" + +[[package]] +name = "lexical-core" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6607c62aa161d23d17a9072cc5da0be67cdfc89d3afb1e8d9c842bebc2525ffe" +dependencies = [ + "arrayvec", + "bitflags", + "cfg-if", + "ryu", + "static_assertions", +] + +[[package]] +name = "libc" +version = "0.2.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "789da6d93f1b866ffe175afc5322a4d76c038605a1c3319bb57b06967ca98a36" + +[[package]] +name = "lock_api" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0382880606dff6d15c9476c416d18690b72742aa7b605bb6dd6ec9030fbf07eb" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51b9bbe6c47d51fc3e1a9b945965946b4c44142ab8792c50835a980d362c2710" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "matches" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ffc5c5338469d4d3ea17d269fa8ea3512ad247247c30bd2df69e68309ed0a08" + +[[package]] +name = "md-5" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5a279bb9607f9f53c22d496eade00d138d1bdcccd07d74650387cf94942a15" +dependencies = [ + "block-buffer", + "digest", + "opaque-debug", +] + +[[package]] +name = "memchr" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b16bd47d9e329435e309c58469fe0791c2d0d1ba96ec0954152a5ae2b04387dc" + +[[package]] +name = "miniz_oxide" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92518e98c078586bc6c934028adcca4c92a53d6a958196de835170a01d84e4b" +dependencies = [ + "adler", + "autocfg", +] + +[[package]] +name = "mio" +version = "0.7.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf80d3e903b34e0bd7282b218398aec54e082c840d9baf8339e0080a0c542956" +dependencies = [ + "libc", + "log", + "miow", + "ntapi", + "winapi", +] + +[[package]] +name = "miow" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9f1c5b025cda876f66ef43a113f91ebc9f4ccef34843000e0adf6ebbab84e21" +dependencies = [ + "winapi", +] + +[[package]] +name = "mp4ra-rust" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be9daf03b43bf3842962947c62ba40f411e46a58774c60838038f04a67d17626" +dependencies = [ + "four-cc", +] + +[[package]] +name = "mpeg4-audio-const" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96a1fe2275b68991faded2c80aa4a33dba398b77d276038b8f50701a22e55918" + +[[package]] +name = "mylog" +version = "0.1.0" +source = "git+https://github.com/scottlamb/mylog#2b1085cfb3bd0b1f2afe7d8085045d81858c0050" +dependencies = [ + "chrono", + "libc", + "log", + "parking_lot", +] + +[[package]] +name = "nom" +version = "6.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7413f999671bd4745a7b624bd370a569fb6bc574b23c83a3c5ed2e453f3d5e2" +dependencies = [ + "bitvec", + "funty", + "lexical-core", + "memchr", + "version_check", +] + +[[package]] +name = "ntapi" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6bb902e437b6d86e03cce10a7e2af662292c5dfef23b65899ea3ac9354ad44" +dependencies = [ + "winapi", +] + +[[package]] +name = "num-integer" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2cc698a63b549a70bc047073d2949cce27cd1c7b0a4a862d08a8031bc2801db" +dependencies = [ + "autocfg", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a64b1ec5cda2586e284722486d802acf1f7dbdc623e2bfc57e65ca1cd099290" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_cpus" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05499f3756671c15885fee9034446956fff3f243d6077b91e5767df161f766b3" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "object" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9023c1c0973b327f073c7f2fceb9bcc049862f93a7d14c6feb46c8a56460a0d5" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af8b08b04175473088b46763e51ee54da5f9a164bc162f615b91bc179dbf15a3" + +[[package]] +name = "opaque-debug" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5" + +[[package]] +name = "parking_lot" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d7744ac029df22dca6284efe4e898991d28e3085c706c972bcd7da4a27a15eb" +dependencies = [ + "instant", + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7a782938e745763fe6907fc6ba86946d72f49fe7e21de074e08128a99fb018" +dependencies = [ + "cfg-if", + "instant", + "libc", + "redox_syscall", + "smallvec", + "winapi", +] + +[[package]] +name = "percent-encoding" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fd5641d01c8f18a23da7b6fe29298ff4b55afcccdf78973b24cf3175fee32e" + +[[package]] +name = "pin-project" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7509cc106041c40a4518d2af7a61530e1eed0e6285296a3d8c5472806ccc4a4" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c950132583b500556b1efd71d45b319029f2b71518d979fcc208e16b42426f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0e1f259c92177c30a4c9d177246edd0a3568b25756a977d0632cf8fa37e905" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "ppv-lite86" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac74c624d6b2d21f425f752262f42188365d7b8ff1aff74c82e45136510a4857" + +[[package]] +name = "pretty-hex" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc5c99d529f0d30937f6f4b8a86d988047327bb88d04d2c4afc356de74722131" + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro-hack" +version = "0.5.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbf0c48bc1d91375ae5c3cd81e3722dff1abcf81a30960240640d223f59fe0e5" + +[[package]] +name = "proc-macro-nested" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc881b2c22681370c6a780e47af9840ef841837bc98118431d4e1868bd0c1086" + +[[package]] +name = "proc-macro2" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8caf72986c1a598726adc988bb5984792ef84f5ee5aa50209145ee8077038" +dependencies = [ + "unicode-xid", +] + +[[package]] +name = "quote" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d0b9745dc2debf507c8422de05d7226cc1f0644216dfdfead988f9b1ab32a7" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "radium" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "941ba9d78d8e2f7ce474c015eea4d9c6d25b6a3327f9832ee29a4de27f91bbb8" + +[[package]] +name = "rand" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ef9e7e66b4468674bfcb0c81af8b7fa0bb154fa9f28eb840da5c447baeb8d7e" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", + "rand_hc", +] + +[[package]] +name = "rand_chacha" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e12735cf05c9e10bf21534da50a147b924d555dc7a547c42e6bb2d5b6017ae0d" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34cf66eb183df1c5876e2dcf6b13d57340741e8dc255b48e40a26de954d06ae7" +dependencies = [ + "getrandom", +] + +[[package]] +name = "rand_hc" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3190ef7066a446f2e7f42e239d161e905420ccab01eb967c9eb27d21b2322a73" +dependencies = [ + "rand_core", +] + +[[package]] +name = "redox_syscall" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "742739e41cd49414de871ea5e549afb7e2a3ac77b589bcbebe8c82fab37147fc" +dependencies = [ + "bitflags", +] + +[[package]] +name = "retina" +version = "0.0.1" +dependencies = [ + "async-stream", + "base64", + "bitreader", + "bytes", + "digest_auth", + "failure", + "futures", + "h264-reader", + "hex", + "log", + "mylog", + "once_cell", + "parking_lot", + "pin-project", + "pretty-hex", + "rtcp", + "rtp-rs", + "rtsp-types", + "sdp", + "smallvec", + "structopt", + "time", + "tokio", + "tokio-util", + "url", +] + +[[package]] +name = "rfc6381-codec" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4395f46a67f0d57c57f6a5361f3a9a0c0183a19cab3998892ecdc003de6d8037" +dependencies = [ + "four-cc", + "mp4ra-rust", + "mpeg4-audio-const", +] + +[[package]] +name = "rtcp" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5fb4431b04a948fd91622a75d65a95da3ed2f0be26c902f3d027a23b78fbc96" +dependencies = [ + "bytes", + "thiserror", +] + +[[package]] +name = "rtp-rs" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1110d695193d446e901de09921ffbf2d86ae351bbfde9c5b53863ce177e17f5" + +[[package]] +name = "rtsp-types" +version = "0.0.1" +source = "git+https://github.com/sdroege/rtsp-types#69b7ce00cb91bbd22950b691f3df94ba9dae25c4" +dependencies = [ + "cookie-factory", + "nom", + "tinyvec", + "url", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "410f7acf3cb3a44527c5d9546bad4bf4e6c460915d5f9f2fc524498bfe8f70ce" + +[[package]] +name = "ryu" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71d301d4193d031abdd79ff7e3dd721168a9572ef3fe51a1517aba235bd8f86e" + +[[package]] +name = "scopeguard" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" + +[[package]] +name = "sdp" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f14aa4ddf1473d50e1664f5c353017981f43f27ede9136a54f60c4bb56d8b152" +dependencies = [ + "rand", + "thiserror", + "url", +] + +[[package]] +name = "sha2" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b362ae5752fd2137731f9fa25fd4d9058af34666ca1966fb969119cc35719f12" +dependencies = [ + "block-buffer", + "cfg-if", + "cpufeatures", + "digest", + "opaque-debug", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e51e73328dc4ac0c7ccbda3a494dfa03df1de2f46018127f60c693f2648455b0" +dependencies = [ + "libc", +] + +[[package]] +name = "slab" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f173ac3d1a7e3b28003f40de0b5ce7fe2710f9b9dc3fc38664cebee46b3b6527" + +[[package]] +name = "smallvec" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe0f37c9e8f3c5a4a66ad655a93c74daac4ad00c441533bf5c6e7990bb42604e" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a" + +[[package]] +name = "structopt" +version = "0.3.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5277acd7ee46e63e5168a80734c9f6ee81b1367a7d8772a2d765df2a3705d28c" +dependencies = [ + "clap", + "lazy_static", + "structopt-derive", +] + +[[package]] +name = "structopt-derive" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ba9cdfda491b814720b6b06e0cac513d922fc407582032e8706e9f137976f90" +dependencies = [ + "heck", + "proc-macro-error", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "syn" +version = "1.0.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1e8cdbefb79a9a5a65e0db8b47b723ee907b7c7f8496c76a1770b5c310bab82" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + +[[package]] +name = "synstructure" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b834f2d66f734cb897113e34aaff2f1ab4719ca946f9a7358dba8f8064148701" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "unicode-xid", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "textwrap" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060" +dependencies = [ + "unicode-width", +] + +[[package]] +name = "thiserror" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa6f76457f59514c7eeb4e59d891395fab0b2fd1d40723ae737d64153392e9c6" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a36768c0fbf1bb15eca10defa29526bda730a2376c2ab4393ccfa16fb1a318d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "time" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db9e6914ab8b1ae1c260a4ae7a49b6c5611b40328a735b21862567685e73255" +dependencies = [ + "libc", + "wasi", + "winapi", +] + +[[package]] +name = "tinyvec" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b5220f05bb7de7f3f53c7c065e1199b3172696fe2db9f9c4d8ad9b4ee74c342" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cda74da7e1a664f795bb1f8a87ec406fb89a02522cf6e50620d016add6dbbf5c" + +[[package]] +name = "tokio" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a38d31d7831c6ed7aad00aa4c12d9375fd225a6dd77da1d25b707346319a975" +dependencies = [ + "autocfg", + "bytes", + "libc", + "memchr", + "mio", + "num_cpus", + "once_cell", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "tokio-macros", + "winapi", +] + +[[package]] +name = "tokio-macros" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c49e3df43841dafb86046472506755d8501c5615673955f6aa17181125d13c37" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-util" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1caa0b0c8d94a049db56b5acf8cba99dc0623aab1b26d5b5f5e2d945846b3592" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "log", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "typenum" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f6906492a7cd215bfa4cf595b600146ccfac0c79bcbd1f3000162af5e8b06" + +[[package]] +name = "unicode-bidi" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeb8be209bb1c96b7c177c7420d26e04eccacb0eeae6b980e35fcb74678107e0" +dependencies = [ + "matches", +] + +[[package]] +name = "unicode-normalization" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d54590932941a9e9266f0832deed84ebe1bf2e4c9e4a3554d393d18f5e854bf9" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-segmentation" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0d2e7be6ae3a5fa87eed5fb451aff96f2573d2694942e40543ae0bbe19c796" + +[[package]] +name = "unicode-width" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9337591893a19b88d8d87f2cec1e73fad5cdfd10e5a6f349f498ad6ea2ffb1e3" + +[[package]] +name = "unicode-xid" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ccb82d61f80a663efe1f787a51b16b5a51e3314d6ac365b08639f52387b33f3" + +[[package]] +name = "url" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a507c383b2d33b5fc35d1861e77e6b383d158b2da5e14fe51b83dfedf6fd578c" +dependencies = [ + "form_urlencoded", + "idna", + "matches", + "percent-encoding", +] + +[[package]] +name = "vec_map" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191" + +[[package]] +name = "version_check" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fecdca9a5291cc2b8dcf7dc02453fee791a280f3743cb0905f8822ae463b3fe" + +[[package]] +name = "wasi" +version = "0.10.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a143597ca7c7793eff794def352d41792a93c481eb1042423ff7ff72ba2c31f" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "wyz" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85e60b0d1b5f99db2556934e21937020776a5d31520bf169e851ac44e6420214" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..bc61eea --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "retina" +version = "0.0.1" +authors = ["Scott Lamb "] +license = "MIT/Apache-2.0" +edition = "2018" +keywords = ["rtsp", "multimedia", "video", "streaming", "ip-camera"] +categories = ["network-programming", "multimedia"] +description = "high-level RTSP multimedia streaming library" + +[dependencies] +async-stream = "0.3.1" +base64 = "0.13.0" +bitreader = "0.3.3" +bytes = "1.0.1" +digest_auth = "0.3.0" +failure = "0.1.8" +futures = "0.3.14" +hex = "0.4.3" +h264-reader = { git = "https://github.com/dholroyd/h264-reader" } +log = "0.4.8" +once_cell = "1.7.2" +pin-project = "1.0.7" +pretty-hex = "0.2.1" +rtcp = "0.2.1" +rtp-rs = "0.5.0" +rtsp-types = { git = "https://github.com/sdroege/rtsp-types" } +sdp = "0.1.3" +smallvec = { version = "1.6.1", features = ["union"] } +structopt = "0.3.21" +time = "0.1.43" +tokio = { version = "1.5.0", features = ["full", "parking_lot"] } +tokio-util = { version = "0.6.6", features = ["codec"] } +url = "2.2.1" + +[dev-dependencies] +mylog = { git = "https://github.com/scottlamb/mylog" } +parking_lot = "0.11.0" diff --git a/LICENSE-APACHE.txt b/LICENSE-APACHE.txt new file mode 100644 index 0000000..1c8b3b7 --- /dev/null +++ b/LICENSE-APACHE.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2021 Scott Lamb + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/LICENSE-MIT.txt b/LICENSE-MIT.txt new file mode 100644 index 0000000..878837b --- /dev/null +++ b/LICENSE-MIT.txt @@ -0,0 +1,20 @@ +The MIT License (MIT) +Copyright (c) 2021 Scott Lamb + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..78eb046 --- /dev/null +++ b/README.md @@ -0,0 +1,59 @@ +# retina + +[![CI](https://github.com/scottlamb/retina/workflows/CI/badge.svg)](https://github.com/scottlamb/retina/actions?query=workflow%3ACI) + +High-level RTSP multimedia RTSP streaming library, in Rust. Good support for +IP surveillance cameras, as needed by +[Moonfire NVR](https://github.com/scottlamb/moonfire-nvr). + +Progress: + +* [x] client support +* * [x] digest authentication +* * [x] RTP over TCP via RTSP interleaved channels. +* * [ ] RTP over UDP. (Shouldn't be hard but I haven't needed it.) +* * [ ] SRTP +* * [ ] ONVIF backchannel support (for sending audio). +* [ ] server support +* async +* * [x] tokio +* * [ ] async-std. (Most of the crate's code is independent of the async + library, so I don't expect this would be too hard to add.) +* codec depacketization +* * [x] video: H.264 +* * * [ ] SVC +* * * [ ] periodic infra refresh +* * * [ ] partitioned slices +* * audio +* * * [x] AAC +* * * * [ ] interleaving +* * * [x] RFC 3551 codecs: G.711,G.723, L8/L16 +* * [x] application: ONVIF metadata +* [ ] uniform, documented API. (Currently haphazard in terms of naming, what + fields are exposed directly vs use an accessors, etc.) +* [ ] rich errors. (Currently uses untyped errors with the deprecated failure + crate; some error messages are quite detailed, others aren't.) +* [ ] released versions of all deps. (crates.io publishing requirement.) +* [ ] good functional testing coverage. (Currently lightly / unevenly tested. + The depacketizers have no test coverage, and there's at least one place + left that can panic on bad input.) +* [ ] fuzz testing + +Help welcome! + +## Why "retina"? + +It's a working name. Other ideas welcome. I started by looking at dictionary +words with the letters R, T, S, and P in order and picking out ones related to +video: + +| `$ egrep '^r.*t.*s.*p' /usr/share/dict/words'` | | +| ---------------------------------------------- | ---------------------------------------------------------------------------- | +| retinoscope | close but too long, thus `retina` | +| retrospect | good name for an NVR, but I already picked Moonfire | +| rotascope | misspelling of "rotascope" (animation tool) or archaic name for "gyroscope"? | + +## License + +Your choice of MIT or Apache; see [LICENSE-MIT.txt](LICENSE-MIT.txt) or +[LICENSE-APACHE](LICENSE-APACHE.txt), respectively. diff --git a/examples/client/main.rs b/examples/client/main.rs new file mode 100644 index 0000000..d24c834 --- /dev/null +++ b/examples/client/main.rs @@ -0,0 +1,97 @@ +// Copyright (C) 2021 Scott Lamb +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! RTSP client examples. + +mod metadata; +mod mp4; + +use failure::Error; +use log::{error, info}; +use std::{fmt::Write, str::FromStr}; +use structopt::StructOpt; + +#[derive(StructOpt)] +struct Source { + #[structopt(long, parse(try_from_str))] + url: url::Url, + + #[structopt(long, requires = "password")] + username: Option, + + #[structopt(long, requires = "username")] + password: Option, +} + +#[derive(StructOpt)] +enum Cmd { + Mp4(mp4::Opts), + Metadata(metadata::Opts), +} + +/// Returns a pretty-and-informative version of `e`. +pub fn prettify_failure(e: &failure::Error) -> String { + let mut msg = e.to_string(); + for cause in e.iter_causes() { + write!(&mut msg, "\ncaused by: {}", cause).unwrap(); + } + if e.backtrace().is_empty() { + write!( + &mut msg, + "\n\n(set environment variable RUST_BACKTRACE=1 to see backtraces)" + ) + .unwrap(); + } else { + write!(&mut msg, "\n\nBacktrace:\n{}", e.backtrace()).unwrap(); + } + msg +} + +fn init_logging() -> mylog::Handle { + let h = mylog::Builder::new() + .set_format( + ::std::env::var("MOONFIRE_FORMAT") + .map_err(|_| ()) + .and_then(|s| mylog::Format::from_str(&s)) + .unwrap_or(mylog::Format::Google), + ) + .set_spec(::std::env::var("MOONFIRE_LOG").as_deref().unwrap_or("info")) + .build(); + h.clone().install().unwrap(); + h +} + +#[tokio::main] +async fn main() { + let mut h = init_logging(); + if let Err(e) = { + let _a = h.async_scope(); + main_inner().await + } { + error!("Fatal: {}", prettify_failure(&e)); + std::process::exit(1); + } + info!("Done"); +} + +/// Interpets the `username` and `password` of a [Source]. +fn creds( + username: Option, + password: Option, +) -> Option { + match (username, password) { + (Some(username), Some(password)) => { + Some(retina::client::Credentials { username, password }) + } + (None, None) => None, + _ => unreachable!(), // structopt/clap enforce username and password's mutual "requires". + } +} + +async fn main_inner() -> Result<(), Error> { + let cmd = Cmd::from_args(); + match cmd { + Cmd::Mp4(opts) => mp4::run(opts).await, + Cmd::Metadata(opts) => metadata::run(opts).await, + } +} diff --git a/examples/client/metadata.rs b/examples/client/metadata.rs new file mode 100644 index 0000000..87b55a7 --- /dev/null +++ b/examples/client/metadata.rs @@ -0,0 +1,49 @@ +// Copyright (C) 2021 Scott Lamb +// SPDX-License-Identifier: MIT OR Apache-2.0 + +use failure::{format_err, Error}; +use futures::StreamExt; +use log::info; +use retina::codec::CodecItem; + +#[derive(structopt::StructOpt)] +pub struct Opts { + #[structopt(flatten)] + src: super::Source, +} + +pub async fn run(opts: Opts) -> Result<(), Error> { + let stop = tokio::signal::ctrl_c(); + + let creds = super::creds(opts.src.username, opts.src.password); + let mut session = retina::client::Session::describe(opts.src.url, creds).await?; + let onvif_stream_i = session + .streams() + .iter() + .position(|s| matches!(s.parameters(), Some(retina::codec::Parameters::Message(..)))) + .ok_or_else(|| format_err!("couldn't find onvif stream"))?; + session.setup(onvif_stream_i).await?; + let session = session + .play(retina::client::PlayPolicy::default().ignore_zero_seq(true)) + .await? + .demuxed()?; + + tokio::pin!(session); + tokio::pin!(stop); + loop { + tokio::select! { + item = session.next() => { + match item.ok_or_else(|| format_err!("EOF"))?? { + CodecItem::MessageFrame(m) => { + info!("{}: {}\n", &m.timestamp, std::str::from_utf8(&m.data[..]).unwrap()); + }, + _ => continue, + }; + }, + _ = &mut stop => { + break; + }, + } + } + Ok(()) +} diff --git a/examples/client/mp4.rs b/examples/client/mp4.rs new file mode 100644 index 0000000..2319146 --- /dev/null +++ b/examples/client/mp4.rs @@ -0,0 +1,601 @@ +// Copyright (C) 2021 Scott Lamb +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! Proof-of-concept `.mp4` writer. +//! +//! This writes media data (`mdat`) to a stream, buffering parameters for a +//! `moov` atom at the end. This avoids the need to buffer the media data +//! (`mdat`) first or reserved a fixed size for the `moov`, but it will slow +//! playback, particularly when serving `.mp4` files remotely. +//! +//! For a more high-quality implementation, see [Moonfire NVR](https://github.com/scottlamb/moonfire-nvr). +//! It's better tested, places the `moov` atom at the start, can do HTTP range +//! serving for arbitrary time ranges, and supports standard and fragmented +//! `.mp4` files. +//! +//! See the BMFF spec, ISO/IEC 14496-12:2015: +//! https://github.com/scottlamb/moonfire-nvr/wiki/Standards-and-specifications +//! https://standards.iso.org/ittf/PubliclyAvailableStandards/c068960_ISO_IEC_14496-12_2015.zip + +use bytes::{Buf, BufMut, BytesMut}; +use failure::{bail, format_err, Error}; +use futures::StreamExt; +use log::info; +use retina::codec::{AudioParameters, CodecItem, VideoParameters}; + +use std::convert::TryFrom; +use std::io::SeekFrom; +use std::num::NonZeroU32; +use std::path::PathBuf; +use tokio::io::{AsyncSeek, AsyncSeekExt, AsyncWrite, AsyncWriteExt}; + +#[derive(structopt::StructOpt)] +pub struct Opts { + #[structopt(flatten)] + src: super::Source, + + #[structopt(default_value, long)] + initial_timestamp: retina::client::InitialTimestampPolicy, + + #[structopt(long)] + no_video: bool, + + #[structopt(long)] + no_audio: bool, + + #[structopt(parse(try_from_str))] + out: PathBuf, +} + +/// Writes a box length for everything appended in the supplied scope. +macro_rules! write_box { + ($buf:expr, $fourcc:expr, $b:block) => {{ + let _: &mut BytesMut = $buf; // type-check. + let pos_start = $buf.len(); + let fourcc: &[u8; 4] = $fourcc; + $buf.extend_from_slice(&[0, 0, 0, 0, fourcc[0], fourcc[1], fourcc[2], fourcc[3]]); + let r = { + $b; + }; + let pos_end = $buf.len(); + let len = pos_end.checked_sub(pos_start).unwrap(); + $buf[pos_start..pos_start + 4].copy_from_slice(&u32::try_from(len)?.to_be_bytes()[..]); + r + }}; +} + +async fn write_all_buf( + writer: &mut W, + buf: &mut B, +) -> Result<(), Error> { + // TODO: this doesn't use vectored I/O. Annoying. + while buf.has_remaining() { + writer.write_buf(buf).await?; + } + Ok(()) +} + +/// Writes `.mp4` data to a sink. +/// See module-level documentation for details. +pub struct Mp4Writer { + mdat_start: u32, + mdat_pos: u32, + video_params: Option, + audio_params: Option, + + /// The (1-indexed) video sample (frame) number of each sync sample (random access point). + video_sync_sample_nums: Vec, + + video_trak: TrakTracker, + audio_trak: TrakTracker, + inner: W, +} + +/// Tracks the parts of a `trak` atom which are common between video and audio samples. +#[derive(Default)] +struct TrakTracker { + samples: u32, + next_pos: Option, + chunks: Vec<(u32, u32)>, // (1-based sample_number, byte_pos) + sizes: Vec, + + /// The durations of samples in a run-length encoding form: (number of samples, duration). + /// This lags one sample behind calls to `add_sample` because each sample's duration + /// is calculated using the PTS of the following sample. + durations: Vec<(u32, u32)>, + last_pts: Option, + tot_duration: u64, +} + +impl TrakTracker { + fn add_sample( + &mut self, + pos: u32, + size: u32, + timestamp: retina::Timestamp, + loss: u16, + ) -> Result<(), Error> { + if self.samples > 0 && loss > 0 { + bail!("Lost {} RTP packets mid-stream", loss); + } + self.samples += 1; + if self.next_pos != Some(pos) { + self.chunks.push((self.samples, pos)); + } + self.sizes.push(size); + self.next_pos = Some(pos + size); + if let Some(last_pts) = self.last_pts.replace(timestamp.timestamp()) { + let duration = timestamp.timestamp().checked_sub(last_pts).unwrap(); + self.tot_duration += u64::try_from(duration).unwrap(); + let duration = u32::try_from(duration)?; + match self.durations.last_mut() { + Some((s, d)) if *d == duration => *s += 1, + _ => self.durations.push((1, duration)), + } + } + Ok(()) + } + + fn finish(&mut self) { + if self.last_pts.is_some() { + self.durations.push((1, 0)); + } + } + + /// Estimates the sum of the variable-sized portions of the data. + fn size_estimate(&self) -> usize { + (self.durations.len() * 8) + // stts + (self.chunks.len() * 12) + // stsc + (self.sizes.len() * 4) + // stsz + (self.chunks.len() * 4) // stco + } + + fn write_common_stbl_parts(&self, buf: &mut BytesMut) -> Result<(), Error> { + // TODO: add an edit list so the video and audio tracks are in sync. + write_box!(buf, b"stts", { + buf.put_u32(0); + buf.put_u32(u32::try_from(self.durations.len())?); + for (samples, duration) in &self.durations { + buf.put_u32(*samples); + buf.put_u32(*duration); + } + }); + write_box!(buf, b"stsc", { + buf.put_u32(0); // version + buf.put_u32(u32::try_from(self.chunks.len())?); + let mut prev_sample_number = 1; + let mut chunk_number = 1; + for &(sample_number, _pos) in &self.chunks[1..] { + buf.put_u32(chunk_number); + buf.put_u32(sample_number - prev_sample_number); + buf.put_u32(1); // sample_description_index + prev_sample_number = sample_number; + chunk_number += 1; + } + if !self.chunks.is_empty() { + buf.put_u32(chunk_number); + buf.put_u32(self.samples + 1 - prev_sample_number); + buf.put_u32(1); // sample_description_index + } + }); + write_box!(buf, b"stsz", { + buf.put_u32(0); // version + buf.put_u32(0); // sample_size + buf.put_u32(u32::try_from(self.sizes.len())?); + for s in &self.sizes { + buf.put_u32(*s); + } + }); + write_box!(buf, b"stco", { + buf.put_u32(0); // version + buf.put_u32(u32::try_from(self.chunks.len())?); // entry_count + for &(_sample_number, pos) in &self.chunks { + buf.put_u32(pos); + } + }); + Ok(()) + } +} + +impl Mp4Writer { + pub async fn new( + video_params: Option, + audio_params: Option, + mut inner: W, + ) -> Result { + let mut buf = BytesMut::new(); + write_box!(&mut buf, b"ftyp", { + buf.extend_from_slice(&[ + b'i', b's', b'o', b'm', // major_brand + 0, 0, 0, 0, // minor_version + b'i', b's', b'o', b'm', // compatible_brands[0] + ]); + }); + buf.extend_from_slice(&b"\0\0\0\0mdat"[..]); + let mdat_start = u32::try_from(buf.len())?; + write_all_buf(&mut inner, &mut buf).await?; + Ok(Mp4Writer { + inner, + video_params, + audio_params, + video_trak: TrakTracker::default(), + audio_trak: TrakTracker::default(), + video_sync_sample_nums: Vec::new(), + mdat_start, + mdat_pos: mdat_start, + }) + } + + pub async fn finish(mut self) -> Result<(), Error> { + self.video_trak.finish(); + self.audio_trak.finish(); + let mut buf = BytesMut::with_capacity( + 1024 + self.video_trak.size_estimate() + + self.audio_trak.size_estimate() + + 4 * self.video_sync_sample_nums.len(), + ); + write_box!(&mut buf, b"moov", { + write_box!(&mut buf, b"mvhd", { + buf.put_u32(1 << 24); // version + buf.put_u64(0); // creation_time + buf.put_u64(0); // modification_time + buf.put_u32(90000); // timescale + buf.put_u64(self.video_trak.tot_duration); + buf.put_u32(0x00010000); // rate + buf.put_u16(0x0100); // volume + buf.put_u16(0); // reserved + buf.put_u64(0); // reserved + for v in &[0x00010000, 0, 0, 0, 0x00010000, 0, 0, 0, 0x40000000] { + buf.put_u32(*v); // matrix + } + for _ in 0..6 { + buf.put_u32(0); // pre_defined + } + buf.put_u32(2); // next_track_id + }); + if let Some(p) = self.video_params.as_ref() { + self.write_video_trak(&mut buf, p)?; + } + if let Some(p) = self.audio_params.as_ref() { + self.write_audio_trak(&mut buf, p)?; + } + }); + write_all_buf(&mut self.inner, &mut buf.freeze()).await?; + self.inner + .seek(SeekFrom::Start(u64::from(self.mdat_start - 8))) + .await?; + self.inner + .write_all(&u32::try_from(self.mdat_pos + 8 - self.mdat_start)?.to_be_bytes()[..]) + .await?; + Ok(()) + } + + fn write_video_trak( + &self, + buf: &mut BytesMut, + parameters: &VideoParameters, + ) -> Result<(), Error> { + write_box!(buf, b"trak", { + write_box!(buf, b"tkhd", { + buf.put_u32((1 << 24) | 7); // version, flags + buf.put_u64(0); // creation_time + buf.put_u64(0); // modification_time + buf.put_u32(1); // track_id + buf.put_u32(0); // reserved + buf.put_u64(self.video_trak.tot_duration); + buf.put_u64(0); // reserved + buf.put_u16(0); // layer + buf.put_u16(0); // alternate_group + buf.put_u16(0); // volume + buf.put_u16(0); // reserved + for v in &[0x00010000, 0, 0, 0, 0x00010000, 0, 0, 0, 0x40000000] { + buf.put_u32(*v); // matrix + } + let dims = self + .video_params + .as_ref() + .map(VideoParameters::pixel_dimensions) + .unwrap_or((0, 0)); + let width = u32::from(u16::try_from(dims.0)?) << 16; + let height = u32::from(u16::try_from(dims.1)?) << 16; + buf.put_u32(width); + buf.put_u32(height); + }); + write_box!(buf, b"mdia", { + write_box!(buf, b"mdhd", { + buf.put_u32(1 << 24); // version + buf.put_u64(0); // creation_time + buf.put_u64(0); // modification_time + buf.put_u32(90000); // timebase + buf.put_u64(self.video_trak.tot_duration); + buf.put_u32(0x55c40000); // language=und + pre-defined + }); + write_box!(buf, b"hdlr", { + buf.extend_from_slice(&[ + 0x00, 0x00, 0x00, 0x00, // version + flags + 0x00, 0x00, 0x00, 0x00, // pre_defined + b'v', b'i', b'd', b'e', // handler = vide + 0x00, 0x00, 0x00, 0x00, // reserved[0] + 0x00, 0x00, 0x00, 0x00, // reserved[1] + 0x00, 0x00, 0x00, 0x00, // reserved[2] + 0x00, // name, zero-terminated (empty) + ]); + }); + write_box!(buf, b"minf", { + write_box!(buf, b"vmhd", { + buf.put_u32(1); + buf.put_u64(0); + }); + write_box!(buf, b"dinf", { + write_box!(buf, b"dref", { + buf.put_u32(0); + buf.put_u32(1); // entry_count + write_box!(buf, b"url ", { + buf.put_u32(1); // version, flags=self-contained + }); + }); + }); + write_box!(buf, b"stbl", { + write_box!(buf, b"stsd", { + buf.put_u32(0); // version + buf.put_u32(1); // entry_count + self.write_video_sample_entry(buf, parameters)?; + }); + self.video_trak.write_common_stbl_parts(buf)?; + write_box!(buf, b"stss", { + buf.put_u32(0); // version + buf.put_u32(u32::try_from(self.video_sync_sample_nums.len())?); + for n in &self.video_sync_sample_nums { + buf.put_u32(*n); + } + }); + }); + }); + }); + }); + Ok(()) + } + + fn write_audio_trak( + &self, + buf: &mut BytesMut, + parameters: &AudioParameters, + ) -> Result<(), Error> { + write_box!(buf, b"trak", { + write_box!(buf, b"tkhd", { + buf.put_u32((1 << 24) | 7); // version, flags + buf.put_u64(0); // creation_time + buf.put_u64(0); // modification_time + buf.put_u32(2); // track_id + buf.put_u32(0); // reserved + buf.put_u64(self.audio_trak.tot_duration); + buf.put_u64(0); // reserved + buf.put_u16(0); // layer + buf.put_u16(0); // alternate_group + buf.put_u16(0); // volume + buf.put_u16(0); // reserved + for v in &[0x00010000, 0, 0, 0, 0x00010000, 0, 0, 0, 0x40000000] { + buf.put_u32(*v); // matrix + } + buf.put_u32(0); // width + buf.put_u32(0); // height + }); + write_box!(buf, b"mdia", { + write_box!(buf, b"mdhd", { + buf.put_u32(1 << 24); // version + buf.put_u64(0); // creation_time + buf.put_u64(0); // modification_time + buf.put_u32(parameters.clock_rate()); + buf.put_u64(self.audio_trak.tot_duration); + buf.put_u32(0x55c40000); // language=und + pre-defined + }); + write_box!(buf, b"hdlr", { + buf.extend_from_slice(&[ + 0x00, 0x00, 0x00, 0x00, // version + flags + 0x00, 0x00, 0x00, 0x00, // pre_defined + b's', b'o', b'u', b'n', // handler = soun + 0x00, 0x00, 0x00, 0x00, // reserved[0] + 0x00, 0x00, 0x00, 0x00, // reserved[1] + 0x00, 0x00, 0x00, 0x00, // reserved[2] + 0x00, // name, zero-terminated (empty) + ]); + }); + write_box!(buf, b"minf", { + write_box!(buf, b"smhd", { + buf.extend_from_slice(&[ + 0x00, 0x00, 0x00, 0x00, // version + flags + 0x00, 0x00, // balance + 0x00, 0x00, // reserved + ]); + }); + write_box!(buf, b"dinf", { + write_box!(buf, b"dref", { + buf.put_u32(0); + buf.put_u32(1); // entry_count + write_box!(buf, b"url ", { + buf.put_u32(1); // version, flags=self-contained + }); + }); + }); + write_box!(buf, b"stbl", { + write_box!(buf, b"stsd", { + buf.put_u32(0); // version + buf.put_u32(1); // entry_count + buf.extend_from_slice(¶meters.sample_entry().unwrap()[..]); + }); + self.audio_trak.write_common_stbl_parts(buf)?; + + // AAC requires two samples (really, each is a set of 960 or 1024 samples) + // to decode accurately. See + // https://developer.apple.com/library/archive/documentation/QuickTime/QTFF/QTFFAppenG/QTFFAppenG.html . + write_box!(buf, b"sgpd", { + // BMFF section 8.9.3: SampleGroupDescriptionBox + buf.put_u32(0); // version + buf.extend_from_slice(b"roll"); // grouping type + buf.put_u32(1); // entry_count + // BMFF section 10.1: AudioRollRecoveryEntry + buf.put_i16(-1); // roll_distance + }); + write_box!(buf, b"sbgp", { + // BMFF section 8.9.2: SampleToGroupBox + buf.put_u32(0); // version + buf.extend_from_slice(b"roll"); // grouping type + buf.put_u32(1); // entry_count + buf.put_u32(self.audio_trak.samples); + buf.put_u32(1); // group_description_index + }); + }); + }); + }); + }); + Ok(()) + } + + fn write_video_sample_entry( + &self, + buf: &mut BytesMut, + parameters: &VideoParameters, + ) -> Result<(), Error> { + // TODO: this should move to client::VideoParameters::sample_entry() or some such. + write_box!(buf, b"avc1", { + buf.put_u32(0); + buf.put_u32(1); // data_reference_index = 1 + buf.extend_from_slice(&[0; 16]); + buf.put_u16(u16::try_from(parameters.pixel_dimensions().0)?); + buf.put_u16(u16::try_from(parameters.pixel_dimensions().1)?); + buf.extend_from_slice(&[ + 0x00, 0x48, 0x00, 0x00, // horizresolution + 0x00, 0x48, 0x00, 0x00, // vertresolution + 0x00, 0x00, 0x00, 0x00, // reserved + 0x00, 0x01, // frame count + 0x00, 0x00, 0x00, 0x00, // compressorname + 0x00, 0x00, 0x00, 0x00, // + 0x00, 0x00, 0x00, 0x00, // + 0x00, 0x00, 0x00, 0x00, // + 0x00, 0x00, 0x00, 0x00, // + 0x00, 0x00, 0x00, 0x00, // + 0x00, 0x00, 0x00, 0x00, // + 0x00, 0x00, 0x00, 0x00, // + 0x00, 0x18, 0xff, 0xff, // depth + pre_defined + ]); + write_box!(buf, b"avcC", { + buf.extend_from_slice(parameters.extra_data()); + }); + }); + Ok(()) + } + + async fn video(&mut self, mut frame: retina::codec::VideoFrame) -> Result<(), failure::Error> { + println!( + "{}: {}-byte video frame", + &frame.timestamp, + frame.remaining() + ); + if let Some(ref p) = frame.new_parameters { + bail!("parameters change unimplemented. new parameters: {:#?}", p); + } + let size = u32::try_from(frame.remaining())?; + self.video_trak + .add_sample(self.mdat_pos, size, frame.timestamp, frame.loss)?; + self.mdat_pos = self + .mdat_pos + .checked_add(size) + .ok_or_else(|| format_err!("mdat_pos overflow"))?; + if frame.is_random_access_point { + self.video_sync_sample_nums + .push(u32::try_from(self.video_trak.samples)?); + } + write_all_buf(&mut self.inner, &mut frame).await?; + Ok(()) + } + + async fn audio(&mut self, mut frame: retina::codec::AudioFrame) -> Result<(), failure::Error> { + println!( + "{}: {}-byte audio frame", + &frame.timestamp, + frame.data.remaining() + ); + let size = u32::try_from(frame.data.remaining())?; + self.audio_trak + .add_sample(self.mdat_pos, size, frame.timestamp, frame.loss)?; + self.mdat_pos = self + .mdat_pos + .checked_add(size) + .ok_or_else(|| format_err!("mdat_pos overflow"))?; + write_all_buf(&mut self.inner, &mut frame.data).await?; + Ok(()) + } +} + +pub async fn run(opts: Opts) -> Result<(), Error> { + let creds = super::creds(opts.src.username, opts.src.password); + let stop = tokio::signal::ctrl_c(); + let mut session = retina::client::Session::describe(opts.src.url, creds).await?; + let video_stream = if !opts.no_video { + session + .streams() + .iter() + .enumerate() + .find_map(|(i, s)| match s.parameters() { + Some(retina::codec::Parameters::Video(v)) => Some((i, v.clone())), + _ => None, + }) + } else { + None + }; + if let Some((i, _)) = video_stream { + session.setup(i).await?; + } + let audio_stream = if !opts.no_audio { + session + .streams() + .iter() + .enumerate() + .find_map(|(i, s)| match s.parameters() { + Some(retina::codec::Parameters::Audio(a)) => Some((i, a.clone())), + _ => None, + }) + } else { + None + }; + if let Some((i, _)) = audio_stream { + session.setup(i).await?; + } + let session = session + .play( + retina::client::PlayPolicy::default() + .initial_timestamp(opts.initial_timestamp) + .enforce_timestamps_with_max_jump_secs(NonZeroU32::new(10).unwrap()), + ) + .await? + .demuxed()?; + + // Read RTP data. + let out = tokio::fs::File::create(opts.out).await?; + let mut mp4 = Mp4Writer::new( + video_stream.map(|(_, p)| p), + audio_stream.map(|(_, p)| p), + out, + ) + .await?; + + tokio::pin!(session); + tokio::pin!(stop); + loop { + tokio::select! { + pkt = session.next() => { + match pkt.ok_or_else(|| format_err!("EOF"))?? { + CodecItem::VideoFrame(f) => mp4.video(f).await?, + CodecItem::AudioFrame(f) => mp4.audio(f).await?, + _ => continue, + }; + }, + _ = &mut stop => { + break; + }, + } + } + info!("Stopping"); + mp4.finish().await?; + Ok(()) +} diff --git a/src/client/channel_mapping.rs b/src/client/channel_mapping.rs new file mode 100644 index 0000000..d55d6e3 --- /dev/null +++ b/src/client/channel_mapping.rs @@ -0,0 +1,152 @@ +// Copyright (C) 2021 Scott Lamb +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! Track RTSP interleaved channel->stream assignments. + +use std::num::NonZeroU8; + +use failure::{bail, Error}; + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum ChannelType { + Rtp, + Rtcp, +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub struct ChannelMapping { + pub stream_i: usize, + pub channel_type: ChannelType, +} + +/// Mapping of the 256 possible RTSP interleaved channels to stream indices and +/// RTP/RTCP. Assumptions: +/// * We only need to support 255 possible streams in a presentation. If +/// there are more than 128, we couldn't actually stream them all at once +/// anyway with one RTP and one RTCP channel per stream. +/// * We'll always assign even channels numbers as RTP and their odd +/// successors as RTCP for the same stream. This seems reasonable given +/// that there is no clear way to assign a single channel in the RTSP spec. +/// [RFC 2326 section 10.12](https://tools.ietf.org/html/rfc2326#section-10.12) +/// says that `interleaved=n` also assigns channel `n+1`, and it's ambiguous +/// what `interleaved=n-m` does when `m > n+1` (section 10.12 suggests it +/// assigns only `n` and `m`; section 12.39 the suggests full range `[n, +/// m]`) or when `n==m`. We'll get into trouble if an RTSP server insists on +/// specifying an odd `n`, but that just seems obstinate. +/// These assumptions let us keep the full mapping with little space and an +/// efficient lookup operation. +#[derive(Default)] +pub struct ChannelMappings(smallvec::SmallVec<[Option; 16]>); + +impl ChannelMappings { + /// Returns the next unassigned even channel id, or errors. + pub fn next_unassigned(&self) -> Result { + if let Some(i) = self.0.iter().position(Option::is_none) { + return Ok((i as u8) << 1); + } + if self.0.len() < 128 { + return Ok((self.0.len() as u8) << 1); + } + bail!("all RTSP channels have been assigned"); + } + + /// Assigns an even channel id (to RTP) and its odd successor (to RTCP) or errors. + pub fn assign(&mut self, channel_id: u8, stream_i: usize) -> Result<(), Error> { + if (channel_id & 1) != 0 { + bail!("Can't assign odd channel id {}", channel_id); + } + if stream_i >= 255 { + bail!( + "Can't assign channel to stream id {} because it's >= 255", + stream_i + ); + } + let i = usize::from(channel_id >> 1); + if i >= self.0.len() { + self.0.resize(i + 1, None); + } + let c = &mut self.0[i]; + if let Some(c) = c { + bail!( + "Channel id {} is already assigned to stream {}; won't reassign to stream {}", + channel_id, + c.get() - 1, + channel_id + ); + } + *c = Some(NonZeroU8::new((stream_i + 1) as u8).expect("[0, 255) + 1 is non-zero")); + Ok(()) + } + + /// Looks up a channel id's mapping. + pub fn lookup(&self, channel_id: u8) -> Option { + let i = usize::from(channel_id >> 1); + if i >= self.0.len() { + return None; + } + self.0[i].map(|c| ChannelMapping { + stream_i: usize::from(c.get() - 1), + channel_type: match (channel_id & 1) != 0 { + false => ChannelType::Rtp, + true => ChannelType::Rtcp, + }, + }) + } +} + +impl std::fmt::Debug for ChannelMappings { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_map() + .entries(self.0.iter().enumerate().filter_map(|(i, v)| { + v.map(|v| (format!("{}-{}", i << 1, (i << 1) + 1), v.get() - 1)) + })) + .finish() + } +} + +#[cfg(test)] +mod tests { + use super::{ChannelMapping, ChannelType}; + + #[test] + fn channel_mappings() { + let mut mappings = super::ChannelMappings::default(); + assert_eq!(mappings.next_unassigned().unwrap(), 0); + assert_eq!(mappings.lookup(0), None); + mappings.assign(0, 42).unwrap(); + mappings.assign(0, 43).unwrap_err(); + mappings.assign(1, 43).unwrap_err(); + assert_eq!( + mappings.lookup(0), + Some(ChannelMapping { + stream_i: 42, + channel_type: ChannelType::Rtp, + }) + ); + assert_eq!( + mappings.lookup(1), + Some(ChannelMapping { + stream_i: 42, + channel_type: ChannelType::Rtcp, + }) + ); + assert_eq!(mappings.next_unassigned().unwrap(), 2); + mappings.assign(9, 26).unwrap_err(); + mappings.assign(8, 26).unwrap(); + assert_eq!( + mappings.lookup(8), + Some(ChannelMapping { + stream_i: 26, + channel_type: ChannelType::Rtp, + }) + ); + assert_eq!( + mappings.lookup(9), + Some(ChannelMapping { + stream_i: 26, + channel_type: ChannelType::Rtcp, + }) + ); + assert_eq!(mappings.next_unassigned().unwrap(), 2); + } +} diff --git a/src/client/mod.rs b/src/client/mod.rs new file mode 100644 index 0000000..a51f91a --- /dev/null +++ b/src/client/mod.rs @@ -0,0 +1,810 @@ +// Copyright (C) 2021 Scott Lamb +// SPDX-License-Identifier: MIT OR Apache-2.0 + +use std::num::NonZeroU32; +use std::{borrow::Cow, fmt::Debug, num::NonZeroU16, pin::Pin}; + +use self::channel_mapping::*; +use self::timeline::Timeline; +use async_stream::try_stream; +use bytes::Bytes; +use failure::{bail, format_err, Error}; +use futures::{SinkExt, StreamExt}; +use log::{debug, trace, warn}; +use pin_project::pin_project; +use sdp::session_description::SessionDescription; +use tokio::pin; +use tokio_util::codec::Framed; +use url::Url; + +use crate::{codec::CodecItem, Context}; + +mod channel_mapping; +mod parse; +pub mod rtp; +mod timeline; + +/// Duration between keepalive RTSP requests during [Playing] state. +pub const KEEPALIVE_DURATION: std::time::Duration = std::time::Duration::from_secs(30); + +/// Policy for handling the `rtptime` parameter normally seem in the `RTP-Info` header. +/// This parameter is used to map each stream's RTP timestamp to NPT ("normal play time"), +/// allowing multiple streams to be played in sync. +#[derive(Copy, Clone, Debug)] +pub enum InitialTimestampPolicy { + /// Default policy: currently `Require` when playing multiple streams, + /// `Ignore` otherwise. + Default, + + /// Require the `rtptime` parameter be present and use it to set NPT. Use + /// when accurate multi-stream NPT is important. + Require, + + /// Ignore the `rtptime` parameter and assume the first received packet for + /// each stream is at NPT 0. Use with cameras that are known to set + /// `rtptime` incorrectly. + Ignore, + + /// Use the `rtptime` parameter when playing multiple streams if it's + /// specified for all of them; otherwise assume the first received packet + /// for each stream is at NPT 0. + Permissive, +} + +impl Default for InitialTimestampPolicy { + fn default() -> Self { + InitialTimestampPolicy::Default + } +} + +impl std::fmt::Display for InitialTimestampPolicy { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + InitialTimestampPolicy::Default => f.pad("default"), + InitialTimestampPolicy::Require => f.pad("require"), + InitialTimestampPolicy::Ignore => f.pad("ignore"), + InitialTimestampPolicy::Permissive => f.pad("permissive"), + } + } +} + +impl std::str::FromStr for InitialTimestampPolicy { + type Err = Error; + + fn from_str(s: &str) -> Result { + Ok(match s { + "default" => InitialTimestampPolicy::Default, + "require" => InitialTimestampPolicy::Require, + "ignore" => InitialTimestampPolicy::Ignore, + "permissive" => InitialTimestampPolicy::Permissive, + _ => bail!("Initial timestamp mode {:?} not understood", s), + }) + } +} + +/// Policy decisions to make on `PLAY`. +/// +/// These are mostly adjustments for non-compliant server implementations. +#[derive(Default)] +pub struct PlayPolicy { + initial_timestamp: InitialTimestampPolicy, + ignore_zero_seq: bool, + enforce_timestamps_with_max_jump_secs: Option, +} + +impl PlayPolicy { + pub fn initial_timestamp(self, initial_timestamp: InitialTimestampPolicy) -> Self { + Self { + initial_timestamp, + ..self + } + } + + /// If the `RTP-Time` specifies `seq=0`, ignore it. Some cameras set this value then start + /// the stream with something dramatically different. (Eg the Hikvision DS-2CD2032-I on its + /// metadata stream; the other streams are fine.) + pub fn ignore_zero_seq(self, ignore_zero_seq: bool) -> Self { + Self { + ignore_zero_seq, + ..self + } + } + + /// Enforces that timestamps are non-decreasing and jump forward by no more + /// than the given number of seconds. + /// + /// By default, no enforcement is done, and computed [crate::Timestamp] + /// values will go backward if subsequent 32-bit RTP timestamps differ by + /// more than `i32::MAX`. + pub fn enforce_timestamps_with_max_jump_secs(self, secs: NonZeroU32) -> Self { + Self { + enforce_timestamps_with_max_jump_secs: Some(secs), + ..self + } + } +} + +#[derive(Debug)] +pub struct Presentation { + pub streams: Vec, + base_url: Url, + pub control: Url, + pub accept_dynamic_rate: bool, + sdp: SessionDescription, +} + +/// Information about a stream offered within a presentation. +/// Currently if multiple formats are offered, this only describes the first. +#[derive(Debug)] +pub struct Stream { + /// Media type, as specified in the [IANA SDP parameters media + /// registry](https://www.iana.org/assignments/sdp-parameters/sdp-parameters.xhtml#sdp-parameters-1). + pub media: String, + + /// An encoding name, as specified in the [IANA media type + /// registry](https://www.iana.org/assignments/media-types/media-types.xhtml), with + /// ASCII characters in lowercase. + /// + /// Commonly used but not specified in that registry: the ONVIF types + /// claimed in the + /// [ONVIF Streaming Spec](https://www.onvif.org/specs/stream/ONVIF-Streaming-Spec.pdf): + /// * `vnd.onvif.metadata` + /// * `vnd.onvif.metadata.gzip`, + /// * `vnd.onvif.metadata.exi.onvif` + /// * `vnd.onvif.metadata.exi.ext` + pub encoding_name: String, + + /// RTP payload type. + /// See the [registry](https://www.iana.org/assignments/rtp-parameters/rtp-parameters.xhtml#rtp-parameters-1). + /// It's common to use one of the dynamically assigned values, 96–127. + pub rtp_payload_type: u8, + + /// RTP clock rate, in Hz. + pub clock_rate: u32, + + /// Number of audio channels, if applicable (`media` is `audio`) and known. + pub channels: Option, + + depacketizer: Result, + + /// The specified control URL. + /// This is needed with multiple streams to send `SETUP` requests and + /// interpret the `PLAY` response's `RTP-Info` header. + /// [RFC 2326 section C.3](https://datatracker.ietf.org/doc/html/rfc2326#appendix-C.3) + /// says the server is allowed to omit it when there is only a single stream. + pub control: Option, + + /// Some buggy cameras expect the base URL to be interpreted as if it had an + /// implicit trailing slash. (This is approximately what ffmpeg 4.3.1 does + /// when the base URL has a query string.) If `RTP-Info` matching fails, try + /// again with this URL. + alt_control: Option, + + state: StreamState, +} + +impl Stream { + /// Returns the parameters for this stream. + /// + /// Returns `None` on unknown codecs, bad parameters, or if parameters aren't specified + /// via SDP. Some codecs allow parameters to be specified in-band instead. + pub fn parameters(&self) -> Option<&crate::codec::Parameters> { + self.depacketizer.as_ref().ok().and_then(|d| d.parameters()) + } +} + +#[derive(Debug)] +enum StreamState { + /// Uninitialized; no `SETUP` has yet been sent. + Uninit, + + /// `SETUP` reply has been received. + Init(StreamStateInit), + + /// `PLAY` reply has been received. + Playing { + timeline: Timeline, + rtp_handler: rtp::StrictSequenceChecker, + }, +} + +#[derive(Copy, Clone, Debug, Default)] +struct StreamStateInit { + /// The RTP synchronization source (SSRC), as defined in + /// [RFC 3550](https://tools.ietf.org/html/rfc3550). This is normally + /// supplied in the `SETUP` response's `Transport` header. Reolink cameras + /// instead supply it in the `PLAY` response's `RTP-Info` header. + ssrc: Option, + + /// The initial RTP sequence number, as specified in the `PLAY` response's + /// `RTP-Info` header. This field is only used during the `play()` call + /// itself; by the time it returns, the stream will be in state `Playing`. + initial_seq: Option, + + /// The initial RTP timestamp, as specified in the `PLAY` response's + /// `RTP-Info` header. This field is only used during the `play()` call + /// itself; by the time it returns, the stream will be in state `Playing`. + initial_rtptime: Option, +} + +#[derive(Clone)] +pub struct Credentials { + pub username: String, + pub password: String, +} + +/// Marker trait for the state of a [Session]. +/// This doesn't closely match [RFC 2326 +/// A.1](https://tools.ietf.org/html/rfc2326#appendix-A.1). In practice, we've +/// found that cheap IP cameras are more restrictive than RTSP suggests. Eg, a +/// `DESCRIBE` changes the connection's state such that another one will fail, +/// before assigning a session id. Thus [Session] represents something more like +/// an RTSP connection than an RTSP session. +pub trait State {} + +/// Initial state after a `DESCRIBE`. +/// One or more `SETUP`s may have also been issued, in which case a `session_id` +/// will be assigned. +pub struct Described { + presentation: Presentation, + session_id: Option, + channels: ChannelMappings, +} +impl State for Described {} + +/// State after a `PLAY`. +#[pin_project(project = PlayingProj)] +pub struct Playing { + presentation: Presentation, + session_id: String, + channels: ChannelMappings, + pending_keepalive_cseq: Option, + + #[pin] + keepalive_timer: tokio::time::Sleep, +} +impl State for Playing {} + +/// The raw connection, without tracking session state. +struct RtspConnection { + creds: Option, + requested_auth: Option, + stream: Framed, + user_agent: String, + + /// The next `CSeq` header value to use when sending an RTSP request. + next_cseq: u32, +} + +/// An RTSP session, or a connection that may be used in a proscriptive way. +/// See discussion at [State]. +#[pin_project] +pub struct Session { + conn: RtspConnection, + + #[pin] + state: S, +} + +impl RtspConnection { + async fn connect(url: &Url, creds: Option) -> Result { + if url.scheme() != "rtsp" { + bail!("Only rtsp urls supported (no rtsps yet)"); + } + if url.username() != "" || url.password().is_some() { + // Url apparently doesn't even have a way to clear the credentials, + // so this has to be an error. + bail!("URL must not contain credentials"); + } + let host = url + .host_str() + .ok_or_else(|| format_err!("Must specify host in rtsp url {}", &url))?; + let port = url.port().unwrap_or(554); + let stream = tokio::net::TcpStream::connect((host, port)).await?; + let conn_established_wall = time::get_time(); + let conn_established = std::time::Instant::now(); + let conn_local_addr = stream.local_addr()?; + let conn_peer_addr = stream.peer_addr()?; + let stream = Framed::new( + stream, + crate::Codec { + ctx: crate::Context { + conn_established_wall, + conn_established, + conn_local_addr, + conn_peer_addr, + msg_pos: 0, + msg_received_wall: conn_established_wall, + msg_received: conn_established, + }, + }, + ); + Ok(Self { + creds, + requested_auth: None, + stream, + user_agent: "moonfire-rtsp test".to_string(), + next_cseq: 1, + }) + } + + /// Sends a request and expects the next message from the peer to be its response. + /// Takes care of authorization and `CSeq`. Returns `Error` if not successful. + async fn send( + &mut self, + req: &mut rtsp_types::Request, + ) -> Result, Error> { + loop { + let cseq = self.send_nowait(req).await?; + let msg = self + .stream + .next() + .await + .ok_or_else(|| format_err!("unexpected EOF while waiting for reply"))??; + let resp = match msg.msg { + rtsp_types::Message::Response(r) => r, + o => bail!("Unexpected RTSP message {:?}", &o), + }; + if parse::get_cseq(&resp) != Some(cseq) { + bail!( + "didn't get expected CSeq {:?} on {:?} at {:#?}", + &cseq, + &resp, + &msg.ctx + ); + } + if resp.status() == rtsp_types::StatusCode::Unauthorized { + if self.requested_auth.is_some() { + bail!( + "Received Unauthorized after trying digest auth at {:#?}", + &msg.ctx + ); + } + let www_authenticate = match resp.header(&rtsp_types::headers::WWW_AUTHENTICATE) { + None => bail!( + "Unauthorized without WWW-Authenticate header at {:#?}", + &msg.ctx + ), + Some(h) => h, + }; + let www_authenticate = www_authenticate.as_str(); + if !www_authenticate.starts_with("Digest ") { + bail!( + "Non-digest authentication requested at {:#?}: {}", + &msg.ctx, + www_authenticate + ); + } + let www_authenticate = digest_auth::WwwAuthenticateHeader::parse(www_authenticate)?; + self.requested_auth = Some(www_authenticate); + continue; + } else if !resp.status().is_success() { + bail!( + "RTSP {:?} request returned {} at {:#?}", + req.method(), + resp.status(), + &msg.ctx + ); + } + return Ok(resp); + } + } + + /// Sends a request without waiting for a response, returning the `CSeq`. + async fn send_nowait(&mut self, req: &mut rtsp_types::Request) -> Result { + let cseq = self.next_cseq; + self.next_cseq += 1; + match (self.requested_auth.as_mut(), self.creds.as_ref()) { + (None, _) => {} + (Some(auth), Some(creds)) => { + let uri = req.request_uri().map(|u| u.as_str()).unwrap_or("*"); + let method = digest_auth::HttpMethod(Cow::Borrowed(req.method().into())); + let ctx = digest_auth::AuthContext::new_with_method( + &creds.username, + &creds.password, + uri, + Option::<&'static [u8]>::None, + method, + ); + let authorization = auth.respond(&ctx)?.to_string(); + req.insert_header(rtsp_types::headers::AUTHORIZATION, authorization); + } + (Some(_), None) => bail!("Authentication required; no credentials supplied"), + } + req.insert_header(rtsp_types::headers::CSEQ, cseq.to_string()); + req.insert_header(rtsp_types::headers::USER_AGENT, self.user_agent.clone()); + self.stream + .send(rtsp_types::Message::Request(req.clone())) + .await?; + Ok(cseq) + } +} + +impl Session { + pub async fn describe(url: Url, creds: Option) -> Result { + let mut conn = RtspConnection::connect(&url, creds).await?; + let mut req = + rtsp_types::Request::builder(rtsp_types::Method::Describe, rtsp_types::Version::V1_0) + .header(rtsp_types::headers::ACCEPT, "application/sdp") + .request_uri(url.clone()) + .build(Bytes::new()); + let response = conn.send(&mut req).await?; + let presentation = parse::parse_describe(url, response)?; + Ok(Session { + conn, + state: Described { + presentation, + session_id: None, + channels: ChannelMappings::default(), + }, + }) + } + + pub fn streams(&self) -> &[Stream] { + &self.state.presentation.streams + } + + /// Sends a `SETUP` request for a stream. + /// Note these can't reasonably be pipelined because subsequent requests + /// are expected to adopt the previous response's `Session`. Likewise, + /// the server may override the preferred interleaved channel id and it + /// seems like a bad idea to try to assign more interleaved channels without + /// inspect that first. + /// + /// Panics if `stream_i >= self.streams().len()`. + pub async fn setup(&mut self, stream_i: usize) -> Result<(), Error> { + let stream = &mut self.state.presentation.streams[stream_i]; + if !matches!(stream.state, StreamState::Uninit) { + bail!("stream already set up"); + } + let proposed_channel_id = self.state.channels.next_unassigned()?; + let url = stream + .control + .as_ref() + .unwrap_or(&self.state.presentation.control) + .clone(); + let mut req = + rtsp_types::Request::builder(rtsp_types::Method::Setup, rtsp_types::Version::V1_0) + .request_uri(url) + .header( + rtsp_types::headers::TRANSPORT, + format!( + "RTP/AVP/TCP;unicast;interleaved={}-{}", + proposed_channel_id, + proposed_channel_id + 1 + ), + ) + .header(crate::X_DYNAMIC_RATE.clone(), "1".to_owned()); + if let Some(ref s) = self.state.session_id { + req = req.header(rtsp_types::headers::SESSION, s.clone()); + } + let response = self.conn.send(&mut req.build(Bytes::new())).await?; + debug!("SETUP response: {:#?}", &response); + let response = parse::parse_setup(&response)?; + match self.state.session_id.as_ref() { + Some(old) if old != response.session_id => { + bail!( + "SETUP response changed session id from {:?} to {:?}", + old, + response.session_id + ); + } + Some(_) => {} + None => self.state.session_id = Some(response.session_id.to_owned()), + }; + self.state.channels.assign(response.channel_id, stream_i)?; + stream.state = StreamState::Init(StreamStateInit { + ssrc: response.ssrc, + initial_seq: None, + initial_rtptime: None, + }); + Ok(()) + } + + /// Sends a `PLAY` request for the entire presentation. + /// The presentation must support aggregate control, as defined in [RFC 2326 + /// section 1.3](https://tools.ietf.org/html/rfc2326#section-1.3). + pub async fn play(mut self, policy: PlayPolicy) -> Result, Error> { + let session_id = self + .state + .session_id + .take() + .ok_or_else(|| format_err!("must SETUP before PLAY"))?; + trace!("PLAY with channel mappings: {:#?}", &self.state.channels); + let response = self + .conn + .send( + &mut rtsp_types::Request::builder( + rtsp_types::Method::Play, + rtsp_types::Version::V1_0, + ) + .request_uri(self.state.presentation.control.clone()) + .header(rtsp_types::headers::SESSION, session_id.clone()) + .header(rtsp_types::headers::RANGE, "npt=0.000-".to_owned()) + .build(Bytes::new()), + ) + .await?; + parse::parse_play(response, &mut self.state.presentation)?; + + // Count how many streams have been setup (not how many are in the presentation). + let setup_streams = self + .state + .presentation + .streams + .iter() + .filter(|s| matches!(s.state, StreamState::Init(_))) + .count(); + + let all_have_time = self + .state + .presentation + .streams + .iter() + .all(|s| match s.state { + StreamState::Init(StreamStateInit { + initial_rtptime, .. + }) => initial_rtptime.is_some(), + _ => true, + }); + + // Move all streams that have been set up from Init to Playing state. Check that required + // parameters are present while doing so. + for (i, s) in self.state.presentation.streams.iter_mut().enumerate() { + match s.state { + StreamState::Init(StreamStateInit { + initial_rtptime, + initial_seq, + ssrc, + .. + }) => { + let initial_rtptime = + match policy.initial_timestamp { + InitialTimestampPolicy::Require | InitialTimestampPolicy::Default + if setup_streams > 1 => + { + if initial_rtptime.is_none() { + bail!( + "Expected rtptime on PLAY with mode {:?}, missing on stream \ + {} ({:?}). Consider setting initial timestamp mode \ + use-if-all-present.", + policy.initial_timestamp, i, &s.control); + } + initial_rtptime + } + InitialTimestampPolicy::Permissive + if setup_streams > 1 && all_have_time => + { + initial_rtptime + } + _ => None, + }; + let initial_seq = match initial_seq { + Some(0) if policy.ignore_zero_seq => { + log::info!("Ignoring seq=0 on stream {}", i); + None + } + o => o, + }; + s.state = StreamState::Playing { + timeline: Timeline::new( + initial_rtptime, + s.clock_rate, + policy.enforce_timestamps_with_max_jump_secs, + )?, + rtp_handler: rtp::StrictSequenceChecker::new(ssrc, initial_seq), + }; + } + StreamState::Uninit => {} + StreamState::Playing { .. } => unreachable!(), + }; + } + Ok(Session { + conn: self.conn, + state: Playing { + presentation: self.state.presentation, + session_id, + channels: self.state.channels, + keepalive_timer: tokio::time::sleep(KEEPALIVE_DURATION), + pending_keepalive_cseq: None, + }, + }) + } +} + +pub enum PacketItem { + RtpPacket(rtp::Packet), + SenderReport(rtp::SenderReport), +} + +impl Session { + /// Returns a stream of packets. + pub fn pkts(self) -> impl futures::Stream> { + try_stream! { + let self_ = self; + tokio::pin!(self_); + while let Some(pkt) = self_.as_mut().next().await { + let pkt = pkt?; + yield pkt; + } + } + } + + pub fn demuxed( + mut self, + ) -> Result>, Error> { + for s in &mut self.state.presentation.streams { + if matches!(s.state, StreamState::Playing { .. }) { + if let Err(ref mut e) = s.depacketizer { + return Err(std::mem::replace(e, format_err!("(placeholder)"))); + } + } + } + Ok(try_stream! { + let self_ = self; + tokio::pin!(self_); + while let Some(pkt) = self_.as_mut().next().await { + let pkt = pkt?; + match pkt { + PacketItem::RtpPacket(p) => { + let self_ = self_.as_mut().project(); + let state = self_.state.project(); + let depacketizer = match &mut state.presentation.streams[p.stream_id].depacketizer { + Ok(d) => d, + Err(_) => unreachable!("depacketizer was Ok"), + }; + depacketizer.push(p)?; + while let Some(demuxed) = depacketizer.pull()? { + yield demuxed; + } + }, + PacketItem::SenderReport(p) => yield CodecItem::SenderReport(p), + }; + } + }) + } + + /// Returns the next packet, an error, or `None` on end of stream. + /// Also manages keepalives; this will send them as necessary to keep the + /// stream open, and fail when sending a following keepalive if the + /// previous one was never acknowledged. + /// + /// TODO: this should also pass along RTCP packets. There can be multiple + /// RTCP packets per data message, so that will require keeping more state. + async fn next(self: Pin<&mut Self>) -> Option> { + let this = self.project(); + let mut state = this.state.project(); + loop { + tokio::select! { + // Prefer receiving data to sending keepalives. If we can't keep + // up with the server's data stream, it probably should drop us. + biased; + + msg = this.conn.stream.next() => { + let msg = match msg { + Some(Ok(m)) => m, + Some(Err(e)) => return Some(Err(e)), + None => return None, + }; + match msg.msg { + rtsp_types::Message::Data(data) => { + match Session::handle_data(&mut state, msg.ctx, data) { + Err(e) => return Some(Err(e)), + Ok(Some(pkt)) => return Some(Ok(pkt)), + Ok(None) => continue, + }; + }, + rtsp_types::Message::Response(response) => { + if let Err(e) = Session::handle_response(&mut state, response) { + return Some(Err(e)); + } + }, + rtsp_types::Message::Request(request) => { + warn!("Received RTSP request in Playing state. Responding unimplemented.\n{:#?}", + request); + }, + } + }, + + () = &mut state.keepalive_timer => { + // TODO: deadlock possibility. Once we decide to send a + // keepalive, we don't try receiving anything until the + // keepalive is fully sent. The server might similarly be + // stubbornly trying to send before receiving. If all the + // socket buffers are full, deadlock can result. + // + // This is really unlikely right now when all we send are + // keepalives, which are probably much smaller than our send + // buffer. But if we start supporting ONVIF backchannel, it + // will become more of a concern. + if let Err(e) = Session::handle_keepalive_timer(this.conn, &mut state).await { + return Some(Err(e)); + } + }, + } + } + } + + async fn handle_keepalive_timer( + conn: &mut RtspConnection, + state: &mut PlayingProj<'_>, + ) -> Result<(), Error> { + // Check on the previous keepalive request. + if let Some(cseq) = state.pending_keepalive_cseq { + bail!( + "Server failed to respond to keepalive {} within {:?}", + cseq, + KEEPALIVE_DURATION + ); + } + + // Send a new one and reset the timer. + *state.pending_keepalive_cseq = Some( + conn.send_nowait( + &mut rtsp_types::Request::builder( + rtsp_types::Method::GetParameter, + rtsp_types::Version::V1_0, + ) + .request_uri(state.presentation.base_url.clone()) + .header(rtsp_types::headers::SESSION, state.session_id.clone()) + .build(Bytes::new()), + ) + .await?, + ); + state + .keepalive_timer + .as_mut() + .reset(tokio::time::Instant::now() + KEEPALIVE_DURATION); + Ok(()) + } + + fn handle_response( + state: &mut PlayingProj<'_>, + response: rtsp_types::Response, + ) -> Result<(), Error> { + if matches!(*state.pending_keepalive_cseq, + Some(cseq) if parse::get_cseq(&response) == Some(cseq)) + { + // We don't care if the keepalive response succeeds or fails. Just mark complete. + *state.pending_keepalive_cseq = None; + return Ok(()); + } + + // The only response we expect in this state is to our keepalive request. + bail!("Unexpected RTSP response {:#?}", response); + } + + fn handle_data( + state: &mut PlayingProj<'_>, + ctx: Context, + data: rtsp_types::Data, + ) -> Result, Error> { + let c = data.channel_id(); + let m = match state.channels.lookup(c) { + Some(m) => m, + None => bail!("Data message on unexpected channel {} at {:#?}", c, &ctx), + }; + let stream = &mut state.presentation.streams[m.stream_i]; + let (mut timeline, rtp_handler) = match &mut stream.state { + StreamState::Playing { + timeline, + rtp_handler, + } => (timeline, rtp_handler), + _ => unreachable!("Session's {}->{:?} not in Playing state", c, m), + }; + match m.channel_type { + ChannelType::Rtp => Ok(Some(rtp_handler.rtp( + ctx, + &mut timeline, + m.stream_i, + data.into_body(), + )?)), + ChannelType::Rtcp => { + Ok(rtp_handler.rtcp(ctx, &mut timeline, m.stream_i, data.into_body())?) + } + } + } + + pub fn streams(&self) -> &[Stream] { + &self.state.presentation.streams + } +} diff --git a/src/client/parse.rs b/src/client/parse.rs new file mode 100644 index 0000000..70df8dc --- /dev/null +++ b/src/client/parse.rs @@ -0,0 +1,1131 @@ +// Copyright (C) 2021 Scott Lamb +// SPDX-License-Identifier: MIT OR Apache-2.0 + +use bytes::{Buf, Bytes}; +use failure::{bail, format_err, Error, ResultExt}; +use log::debug; +use sdp::media_description::MediaDescription; +use std::{convert::TryFrom, num::NonZeroU16}; +use url::Url; + +use super::{Presentation, Stream}; + +/// A static payload type in the [RTP parameters +/// registry](https://www.iana.org/assignments/rtp-parameters/rtp-parameters.xhtml#rtp-parameters-1). +#[derive(Debug)] +struct StaticPayloadType { + encoding: &'static str, + media: &'static str, + clock_rate: u32, + channels: Option, +} + +/// All registered static payload types. +/// The registry is officially closed, so this list should never change. +#[rustfmt::skip] +static STATIC_PAYLOAD_TYPES: [Option; 35] = [ + /* 0 */ Some(StaticPayloadType { + encoding: "pcmu", + media: "audio", + clock_rate: 8_000, + channels: NonZeroU16::new(1), + }), + /* 1 */ None, // reserved + /* 2 */ None, // reserved + /* 3 */ Some(StaticPayloadType { + encoding: "gsm", + media: "audio", + clock_rate: 8_000, + channels: NonZeroU16::new(1), + }), + /* 4 */ Some(StaticPayloadType { + encoding: "g723", + media: "audio", + clock_rate: 8_000, + channels: NonZeroU16::new(1), + }), + /* 5 */ Some(StaticPayloadType { + encoding: "dvi4", + media: "audio", + clock_rate: 8_000, + channels: NonZeroU16::new(1), + }), + /* 6 */ Some(StaticPayloadType { + encoding: "dvi4", + media: "audio", + clock_rate: 16_000, + channels: NonZeroU16::new(1), + }), + /* 7 */ Some(StaticPayloadType { + encoding: "lpc", + media: "audio", + clock_rate: 8_000, + channels: NonZeroU16::new(1), + }), + /* 8 */ Some(StaticPayloadType { + encoding: "pcma", + media: "audio", + clock_rate: 8_000, + channels: NonZeroU16::new(1), + }), + /* 9 */ Some(StaticPayloadType { + encoding: "g722", + media: "audio", + clock_rate: 8_000, + channels: NonZeroU16::new(1), + }), + /* 10 */ Some(StaticPayloadType { + encoding: "l16", + media: "audio", + clock_rate: 441_000, + channels: NonZeroU16::new(2), + }), + /* 11 */ Some(StaticPayloadType { + encoding: "l16", + media: "audio", + clock_rate: 441_000, + channels: NonZeroU16::new(1), + }), + /* 12 */ Some(StaticPayloadType { + encoding: "qcelp", + media: "audio", + clock_rate: 8_000, + channels: NonZeroU16::new(1), + }), + /* 13 */ Some(StaticPayloadType { + encoding: "cn", + media: "audio", + clock_rate: 8_000, + channels: NonZeroU16::new(1), + }), + /* 14 */ Some(StaticPayloadType { + encoding: "mpa", + media: "audio", + clock_rate: 90_000, + channels: None, + }), + /* 15 */ Some(StaticPayloadType { + encoding: "g728", + media: "audio", + clock_rate: 8_000, + channels: NonZeroU16::new(1), + }), + /* 16 */ Some(StaticPayloadType { + encoding: "dvi4", + media: "audio", + clock_rate: 11_025, + channels: NonZeroU16::new(1), + }), + /* 17 */ Some(StaticPayloadType { + encoding: "dvi4", + media: "audio", + clock_rate: 22_050, + channels: NonZeroU16::new(1), + }), + /* 18 */ Some(StaticPayloadType { + encoding: "g729", + media: "audio", + clock_rate: 8_000, + channels: NonZeroU16::new(1), + }), + /* 19 */ None, // reserved + /* 20 */ None, // unassigned + /* 21 */ None, // unassigned + /* 22 */ None, // unassigned + /* 23 */ None, // unassigned + /* 24 */ None, // unassigned + /* 25 */ Some(StaticPayloadType { + encoding: "celb", + media: "video", + clock_rate: 90_000, + channels: None, + }), + /* 26 */ Some(StaticPayloadType { + encoding: "jpeg", + media: "video", + clock_rate: 90_000, + channels: None, + }), + /* 27 */ None, // unassigned + /* 28 */ Some(StaticPayloadType { + encoding: "nv", + media: "video", + clock_rate: 90_000, + channels: None, + }), + /* 29 */ None, // unassigned + /* 30 */ None, // unassigned + /* 31 */ Some(StaticPayloadType { + encoding: "h261", + media: "video", + clock_rate: 90_000, + channels: None, + }), + /* 32 */ Some(StaticPayloadType { + encoding: "mpv", + media: "video", + clock_rate: 90_000, + channels: None, + }), + /* 33 */ Some(StaticPayloadType { + encoding: "mp2t", + // The RTP parameters registry says type AV (audio and video). + // The MIME registration says the media type is "video". + // https://datatracker.ietf.org/doc/html/rfc3555#section-4.2.9 + media: "video", + clock_rate: 90_000, + channels: None, + }), + /* 34 */ Some(StaticPayloadType { + encoding: "h263", + media: "video", + clock_rate: 90_000, + channels: None, + }), +]; + +fn join_control(base_url: &Url, control: &str) -> Result { + if control == "*" { + return Ok(base_url.clone()); + } + Ok(base_url.join(control).with_context(|_| { + format_err!( + "unable to join base url {} with control url {:?}", + base_url, + control + ) + })?) +} + +/// Returns the `CSeq` from an RTSP response as a `u32`, or `None` if missing/unparseable. +pub(crate) fn get_cseq(response: &rtsp_types::Response) -> Option { + response + .header(&rtsp_types::headers::CSEQ) + .and_then(|cseq| u32::from_str_radix(cseq.as_str(), 10).ok()) +} + +/// Parses a [MediaDescription] to a [Stream]. +/// On failure, returns an error which is expected to be supplemented with +/// the [MediaDescription] debug string. +fn parse_media( + base_url: &Url, + alt_base_url: &Url, + media_description: &MediaDescription, +) -> Result { + let media = media_description.media_name.media.clone(); + + // https://tools.ietf.org/html/rfc8866#section-5.14 says "If the + // sub-field is "RTP/AVP" or "RTP/SAVP" the sub-fields contain RTP + // payload type numbers." + // https://www.iana.org/assignments/sdp-parameters/sdp-parameters.xhtml#sdp-parameters-2 + // shows several other variants, such as "TCP/RTP/AVP". Looking a "RTP" component + // seems appropriate. + if !media_description + .media_name + .protos + .iter() + .any(|p| p == "RTP") + { + bail!("Expected RTP-based proto"); + } + + // RFC 8866 continues: "When a list of payload type numbers is given, + // this implies that all of these payload formats MAY be used in the + // session, but the first of these formats SHOULD be used as the default + // format for the session." Just use the first until we find a stream + // where this isn't the right thing to do. + let rtp_payload_type_str = media_description + .media_name + .formats + .first() + .ok_or_else(|| format_err!("missing RTP payload type"))?; + let rtp_payload_type = u8::from_str_radix(rtp_payload_type_str, 10) + .map_err(|_| format_err!("invalid RTP payload type"))?; + if (rtp_payload_type & 0x80) != 0 { + bail!("invalid RTP payload type"); + } + + // Capture interesting attributes. + // RFC 8866: "For dynamic payload type assignments, the "a=rtpmap:" + // attribute (see Section 6.6) SHOULD be used to map from an RTP payload + // type number to a media encoding name that identifies the payload + // format. The "a=fmtp:" attribute MAY be used to specify format + // parameters (see Section 6.15)." + let mut rtpmap = None; + let mut fmtp = None; + let mut control = None; + let mut alt_control = None; + for a in &media_description.attributes { + if a.key == "rtpmap" { + let v = a + .value + .as_ref() + .ok_or_else(|| format_err!("rtpmap attribute with no value"))?; + // https://tools.ietf.org/html/rfc8866#section-6.6 + // rtpmap-value = payload-type SP encoding-name + // "/" clock-rate [ "/" encoding-params ] + // payload-type = zero-based-integer + // encoding-name = token + // clock-rate = integer + // encoding-params = channels + // channels = integer + let (rtpmap_payload_type, v) = v + .split_once(' ') + .ok_or_else(|| format_err!("invalid rtmap attribute"))?; + if rtpmap_payload_type == rtp_payload_type_str { + rtpmap = Some(v); + } + } else if a.key == "fmtp" { + // Similarly starts with payload-type SP. + let v = a + .value + .as_ref() + .ok_or_else(|| format_err!("rtpmap attribute with no value"))?; + let (fmtp_payload_type, v) = v + .split_once(' ') + .ok_or_else(|| format_err!("invalid rtmap attribute"))?; + if fmtp_payload_type == rtp_payload_type_str { + fmtp = Some(v); + } + } else if a.key == "control" { + control = a + .value + .as_deref() + .map(|c| join_control(base_url, c)) + .transpose()?; + alt_control = a + .value + .as_deref() + .map(|c| join_control(alt_base_url, c)) + .transpose()?; + } + } + + let encoding_name; + let clock_rate; + let channels; + match rtpmap { + Some(rtpmap) => { + let (e, rtpmap) = rtpmap + .split_once('/') + .ok_or_else(|| format_err!("invalid rtpmap attribute"))?; + encoding_name = e; + let (clock_rate_str, channels_str) = match rtpmap.find('/') { + None => (rtpmap, None), + Some(i) => (&rtpmap[..i], Some(&rtpmap[i + 1..])), + }; + clock_rate = u32::from_str_radix(clock_rate_str, 10) + .map_err(|_| format_err!("bad clockrate in rtpmap"))?; + channels = channels_str + .map(|c| { + u16::from_str_radix(c, 10) + .ok() + .and_then(NonZeroU16::new) + .ok_or_else(|| format_err!("Invalid channels specification {:?}", c)) + }) + .transpose()?; + } + None => { + let type_ = STATIC_PAYLOAD_TYPES + .get(usize::from(rtp_payload_type)) + .and_then(Option::as_ref) + .ok_or_else(|| { + format_err!( + "Expected rtpmap parameter or assigned static payload type (got {})", + rtp_payload_type + ) + })?; + encoding_name = type_.encoding; + clock_rate = type_.clock_rate; + channels = type_.channels; + if type_.media != media { + bail!( + "SDP media type {} must match RTP payload type {:#?}", + &media, + type_ + ); + } + } + } + + let encoding_name = encoding_name.to_ascii_lowercase(); + let depacketizer = + crate::codec::Depacketizer::new(&media, &encoding_name, clock_rate, channels, fmtp); + + Ok(Stream { + media, + encoding_name, + clock_rate, + rtp_payload_type, + depacketizer, + control, + alt_control, + channels, + state: super::StreamState::Uninit, + }) +} + +/// Parses a successful RTSP `DESCRIBE` response into a [Presentation]. +pub(crate) fn parse_describe( + request_url: Url, + response: rtsp_types::Response, +) -> Result { + if !matches!(response.header(&rtsp_types::headers::CONTENT_TYPE), Some(v) if v.as_str() == "application/sdp") + { + bail!( + "Describe response not of expected application/sdp content type: {:#?}", + &response + ); + } + + let sdp; + { + let mut cursor = std::io::Cursor::new(&response.body()[..]); + sdp = sdp::session_description::SessionDescription::unmarshal(&mut cursor)?; + if cursor.has_remaining() { + bail!( + "garbage after sdp: {:?}", + &response.body()[usize::try_from(cursor.position()).unwrap()..] + ); + } + } + + // https://tools.ietf.org/html/rfc2326#appendix-C.1.1 + let base_url = response + .header(&rtsp_types::headers::CONTENT_BASE) + .or_else(|| response.header(&rtsp_types::headers::CONTENT_LOCATION)) + .map(|v| Url::parse(v.as_str())) + .unwrap_or(Ok(request_url))?; + let mut alt_base_url = base_url.clone(); + alt_base_url.set_path(&format!("{}/", base_url.path())); + + let mut control = None; + for a in &sdp.attributes { + if a.key == "control" { + control = a + .value + .as_deref() + .map(|c| join_control(&base_url, c)) + .transpose()?; + break; + } + } + let control = control.ok_or_else(|| format_err!("no control url"))?; + + let streams = sdp + .media_descriptions + .iter() + .enumerate() + .map(|(i, m)| { + parse_media(&base_url, &alt_base_url, &m) + .with_context(|_| format!("Unable to parse stream {}: {:#?}", i, &m)) + .map_err(Error::from) + }) + .collect::, Error>>()?; + + let accept_dynamic_rate = + matches!(response.header(&crate::X_ACCEPT_DYNAMIC_RATE), Some(h) if h.as_str() == "1"); + + Ok(Presentation { + streams, + base_url, + control, + accept_dynamic_rate, + sdp, + }) +} + +pub(crate) struct SetupResponse<'a> { + pub(crate) session_id: &'a str, + pub(crate) ssrc: Option, + pub(crate) channel_id: u8, +} + +/// Parses a `SETUP` response. +/// `session_id` is checked for assignment or reassignment. +/// Returns an assigned interleaved channel id (implying the next channel id +/// is also assigned) or errors. +pub(crate) fn parse_setup(response: &rtsp_types::Response) -> Result { + let session = response + .header(&rtsp_types::headers::SESSION) + .ok_or_else(|| format_err!("SETUP response has no Session header"))?; + let session_id = match session.as_str().find(';') { + None => session.as_str(), + Some(i) => &session.as_str()[..i], + }; + let transport = response + .header(&rtsp_types::headers::TRANSPORT) + .ok_or_else(|| format_err!("SETUP response has no Transport header"))?; + let mut channel_id = None; + let mut ssrc = None; + for part in transport.as_str().split(';') { + if let Some(v) = part.strip_prefix("ssrc=") { + let v = + u32::from_str_radix(v, 16).map_err(|_| format_err!("Unparseable ssrc {}", v))?; + ssrc = Some(v); + break; + } else if let Some(interleaved) = part.strip_prefix("interleaved=") { + let mut channels = interleaved.splitn(2, '-'); + let n = channels.next().expect("splitn returns at least one part"); + let n = + u8::from_str_radix(n, 10).map_err(|_| format_err!("bad channel number {}", n))?; + if let Some(m) = channels.next() { + let m = u8::from_str_radix(m, 10) + .map_err(|_| format_err!("bad second channel number {}", m))?; + if n.checked_add(1) != Some(m) { + bail!("Expected adjacent channels; got {}-{}", n, m); + } + } + channel_id = Some(n); + } + } + let channel_id = channel_id.ok_or_else(|| { + format_err!("SETUP response Transport header has no interleaved parameter") + })?; + Ok(SetupResponse { + session_id, + ssrc, + channel_id, + }) +} + +pub(crate) fn parse_play( + response: rtsp_types::Response, + presentation: &mut Presentation, +) -> Result<(), Error> { + // https://tools.ietf.org/html/rfc2326#section-12.33 + let rtp_info = response + .header(&rtsp_types::headers::RTP_INFO) + .ok_or_else(|| format_err!("PLAY response has no RTP-Info header"))?; + for s in rtp_info.as_str().split(',') { + let s = s.trim(); + let mut parts = s.split(';'); + let url = parts + .next() + .expect("split always returns at least one part") + .strip_prefix("url=") + .ok_or_else(|| format_err!("RTP-Info missing stream URL"))?; + let url = join_control(&presentation.base_url, url)?; + let mut stream; + if presentation.streams.len() == 1 { + // The server is allowed to not specify a stream control URL for + // single-stream presentations. Additionally, some buggy + // cameras (eg the GW Security GW4089IP) use an incorrect URL. + // When there is a single stream in the presentation, there's no + // ambiguity. Be "forgiving", just as RFC 2326 section 14.3 asks + // servers to be forgiving of clients with single-stream + // containers. + // https://datatracker.ietf.org/doc/html/rfc2326#section-14.3 + stream = Some(&mut presentation.streams[0]); + } else { + stream = presentation + .streams + .iter_mut() + .find(|s| matches!(&s.control, Some(u) if u == &url)); + + // If we didn't find a stream, try again with alt_control. Don't do + // this on the first pass because we should check all of the + // proper control URLs first. + if stream.is_none() { + stream = presentation + .streams + .iter_mut() + .find(|s| matches!(&s.alt_control, Some(u) if u == &url)); + } + } + let stream = stream.ok_or_else(|| format_err!("can't find RTP-Info stream {}", url))?; + let state = match &mut stream.state { + super::StreamState::Uninit => { + // This appears to happen for Reolink devices when we did not send a SETUP request + // for all streams. It also happens in some of other the tests + // here simply because I didn't include all the SETUP steps. + debug!( + "PLAY response described stream {} in Uninit state", + stream.control.as_ref().unwrap_or(&presentation.control) + ); + continue; + } + super::StreamState::Init(init) => init, + super::StreamState::Playing { .. } => unreachable!(), + }; + for part in parts { + let (key, value) = part + .split_once('=') + .ok_or_else(|| format_err!("RTP-Info param has no ="))?; + match key { + "seq" => { + let seq = u16::from_str_radix(value, 10) + .map_err(|_| format_err!("bad seq {:?}", value))?; + state.initial_seq = Some(seq); + } + "rtptime" => { + let rtptime = u32::from_str_radix(value, 10) + .map_err(|_| format_err!("bad rtptime {:?}", value))?; + state.initial_rtptime = Some(rtptime); + } + "ssrc" => { + let ssrc = u32::from_str_radix(value, 16) + .map_err(|_| format_err!("Unparseable ssrc {}", value))?; + state.ssrc = Some(ssrc); + } + _ => {} + } + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::num::NonZeroU16; + + use bytes::Bytes; + use failure::Error; + use url::Url; + + use crate::{client::StreamStateInit, codec::Parameters}; + + use super::super::StreamState; + + fn response(raw: &'static [u8]) -> rtsp_types::Response { + let (msg, len) = rtsp_types::Message::parse(raw).unwrap(); + assert_eq!(len, raw.len()); + match msg { + rtsp_types::Message::Response(r) => r.map_body(|b| Bytes::from_static(b)), + _ => panic!("unexpected message type"), + } + } + + fn parse_describe( + raw_url: &str, + raw_response: &'static [u8], + ) -> Result { + let url = Url::parse(raw_url).unwrap(); + super::parse_describe(url, response(raw_response)) + } + + #[test] + fn dahua_h264_aac_onvif() { + // DESCRIBE. + let prefix = "rtsp://192.168.5.111:554/cam/"; + let mut p = parse_describe( + &(prefix.to_string() + "realmonitor?channel=1&subtype=1&unicast=true&proto=Onvif"), + include_bytes!("testdata/dahua_describe_h264_aac_onvif.txt"), + ) + .unwrap(); + assert_eq!( + p.control.as_str(), + &(prefix.to_string() + "realmonitor?channel=1&subtype=1&unicast=true&proto=Onvif/") + ); + assert!(p.accept_dynamic_rate); + + assert_eq!(p.streams.len(), 3); + + // H.264 video stream. + assert_eq!( + p.streams[0].control.as_ref().unwrap().as_str(), + &(prefix.to_string() + "trackID=0") + ); + assert_eq!(p.streams[0].media, "video"); + assert_eq!(p.streams[0].encoding_name, "h264"); + assert_eq!(p.streams[0].rtp_payload_type, 96); + assert_eq!(p.streams[0].clock_rate, 90_000); + match p.streams[0].parameters().unwrap() { + Parameters::Video(v) => { + assert_eq!(v.rfc6381_codec(), "avc1.64001E"); + assert_eq!(v.pixel_dimensions(), (704, 480)); + assert_eq!(v.pixel_aspect_ratio(), None); + assert_eq!(v.frame_rate(), Some((2, 30))); + } + _ => panic!(), + } + + // .mp4 audio stream. + assert_eq!( + p.streams[1].control.as_ref().unwrap().as_str(), + &(prefix.to_string() + "trackID=1") + ); + assert_eq!(p.streams[1].media, "audio"); + assert_eq!(p.streams[1].encoding_name, "mpeg4-generic"); + assert_eq!(p.streams[1].rtp_payload_type, 97); + assert_eq!(p.streams[1].clock_rate, 48_000); + match p.streams[1].parameters() { + Some(Parameters::Audio(_)) => {} + _ => panic!(), + } + + // ONVIF parameters stream. + assert_eq!( + p.streams[2].control.as_ref().unwrap().as_str(), + &(prefix.to_string() + "trackID=4") + ); + assert_eq!(p.streams[2].media, "application"); + assert_eq!(p.streams[2].encoding_name, "vnd.onvif.metadata"); + assert_eq!(p.streams[2].rtp_payload_type, 107); + assert_eq!(p.streams[2].clock_rate, 90_000); + assert!(matches!( + p.streams[2].parameters(), + Some(Parameters::Message(_)) + )); + + // SETUP. + let setup_response = response(include_bytes!("testdata/dahua_setup.txt")); + let setup_response = super::parse_setup(&setup_response).unwrap(); + assert_eq!(setup_response.session_id, "634214675641"); + assert_eq!(setup_response.channel_id, 0); + assert_eq!(setup_response.ssrc, Some(0x30a98ee7)); + p.streams[0].state = StreamState::Init(StreamStateInit { + ssrc: setup_response.ssrc, + initial_seq: None, + initial_rtptime: None, + }); + + // PLAY. + super::parse_play(response(include_bytes!("testdata/dahua_play.txt")), &mut p).unwrap(); + match &p.streams[0].state { + StreamState::Init(s) => { + assert_eq!(s.initial_seq, Some(47121)); + assert_eq!(s.initial_rtptime, Some(3475222385)); + } + _ => panic!(), + }; + // The other streams don't get filled in because they're in state Uninit. + } + + #[test] + fn dahua_h265_pcma() { + let p = parse_describe( + "rtsp://192.168.5.111:554/cam/realmonitor?channel=1&subtype=2", + include_bytes!("testdata/dahua_describe_h265_pcma.txt"), + ) + .unwrap(); + + // Abridged test; similar to the other Dahua test. + assert_eq!(p.streams.len(), 2); + assert_eq!(p.streams[0].media, "video"); + assert_eq!(p.streams[0].encoding_name, "h265"); + assert_eq!(p.streams[0].rtp_payload_type, 98); + assert!(p.streams[0].parameters().is_none()); + assert_eq!(p.streams[1].media, "audio"); + assert_eq!(p.streams[1].encoding_name, "pcma"); + assert_eq!(p.streams[1].rtp_payload_type, 8); + match p.streams[1].parameters().unwrap() { + Parameters::Audio(_) => {} + _ => panic!(), + }; + } + + #[test] + fn hikvision() { + // DESCRIBE. + let prefix = "rtsp://192.168.5.106:554/Streaming/Channels/101"; + let mut p = parse_describe( + &(prefix.to_string() + "?transportmode=unicast&Profile=Profile_1"), + include_bytes!("testdata/hikvision_describe.txt"), + ) + .unwrap(); + assert_eq!( + p.base_url.as_str(), + "rtsp://192.168.5.106:554/Streaming/Channels/101/" + ); + assert!(!p.accept_dynamic_rate); + + assert_eq!(p.streams.len(), 2); + + // H.264 video stream. + assert_eq!( + p.streams[0].control.as_ref().unwrap().as_str(), + &(prefix.to_string() + "/trackID=1?transportmode=unicast&profile=Profile_1") + ); + assert_eq!(p.streams[0].media, "video"); + assert_eq!(p.streams[0].encoding_name, "h264"); + assert_eq!(p.streams[0].rtp_payload_type, 96); + assert_eq!(p.streams[0].clock_rate, 90_000); + match p.streams[0].parameters().unwrap() { + Parameters::Video(v) => { + assert_eq!(v.rfc6381_codec(), "avc1.4D0029"); + assert_eq!(v.pixel_dimensions(), (1920, 1080)); + assert_eq!(v.pixel_aspect_ratio(), None); + assert_eq!(v.frame_rate(), Some((2_000, 60_000))); + } + _ => panic!(), + } + + // ONVIF parameters stream. + assert_eq!( + p.streams[1].control.as_ref().unwrap().as_str(), + &(prefix.to_string() + "/trackID=3?transportmode=unicast&profile=Profile_1") + ); + assert_eq!(p.streams[1].media, "application"); + assert_eq!(p.streams[1].encoding_name, "vnd.onvif.metadata"); + assert_eq!(p.streams[1].rtp_payload_type, 107); + assert_eq!(p.streams[1].clock_rate, 90_000); + assert!(matches!( + p.streams[1].parameters(), + Some(Parameters::Message(_)) + )); + + // SETUP. + let setup_response = response(include_bytes!("testdata/hikvision_setup.txt")); + let setup_response = super::parse_setup(&setup_response).unwrap(); + assert_eq!(setup_response.session_id, "708345999"); + assert_eq!(setup_response.channel_id, 0); + assert_eq!(setup_response.ssrc, Some(0x4cacc3d1)); + p.streams[0].state = StreamState::Init(StreamStateInit { + ssrc: setup_response.ssrc, + initial_seq: None, + initial_rtptime: None, + }); + + // PLAY. + super::parse_play( + response(include_bytes!("testdata/hikvision_play.txt")), + &mut p, + ) + .unwrap(); + match p.streams[0].state { + StreamState::Init(state) => { + assert_eq!(state.initial_seq, Some(24104)); + assert_eq!(state.initial_rtptime, Some(1270711678)); + } + _ => panic!(), + } + // The other stream isn't filled in because it's in state Uninit. + } + + #[test] + fn reolink() { + // DESCRIBE. + let mut p = parse_describe( + "rtsp://192.168.5.206:554/h264Preview_01_main", + include_bytes!("testdata/reolink_describe.txt"), + ) + .unwrap(); + let base = "rtsp://192.168.5.206/h264Preview_01_main/"; + assert_eq!(p.control.as_str(), base); + assert!(!p.accept_dynamic_rate); + + assert_eq!(p.streams.len(), 2); + + // H.264 video stream. + assert_eq!( + p.streams[0].control.as_ref().unwrap().as_str(), + &(base.to_string() + "trackID=1") + ); + assert_eq!(p.streams[0].media, "video"); + assert_eq!(p.streams[0].encoding_name, "h264"); + assert_eq!(p.streams[0].rtp_payload_type, 96); + assert_eq!(p.streams[0].clock_rate, 90_000); + match p.streams[0].parameters().unwrap() { + Parameters::Video(v) => { + assert_eq!(v.rfc6381_codec(), "avc1.640033"); + assert_eq!(v.pixel_dimensions(), (2560, 1440)); + assert_eq!(v.pixel_aspect_ratio(), None); + assert_eq!(v.frame_rate(), None); + } + _ => panic!(), + }; + + // audio stream + assert_eq!( + p.streams[1].control.as_ref().unwrap().as_str(), + &(base.to_string() + "trackID=2") + ); + assert_eq!(p.streams[1].media, "audio"); + assert_eq!(p.streams[1].encoding_name, "mpeg4-generic"); + assert_eq!(p.streams[1].rtp_payload_type, 97); + assert_eq!(p.streams[1].clock_rate, 16_000); + match p.streams[1].parameters() { + Some(Parameters::Audio(_)) => {} + _ => panic!(), + } + + // SETUP. + let setup_response = response(include_bytes!("testdata/reolink_setup.txt")); + let setup_response = super::parse_setup(&setup_response).unwrap(); + assert_eq!(setup_response.session_id, "F8F8E425"); + assert_eq!(setup_response.channel_id, 0); + assert_eq!(setup_response.ssrc, None); + p.streams[0].state = StreamState::Init(StreamStateInit::default()); + p.streams[1].state = StreamState::Init(StreamStateInit::default()); + + // PLAY. + super::parse_play( + response(include_bytes!("testdata/reolink_play.txt")), + &mut p, + ) + .unwrap(); + match p.streams[0].state { + StreamState::Init(state) => { + assert_eq!(state.initial_seq, Some(16852)); + assert_eq!(state.initial_rtptime, Some(1070938629)); + } + _ => panic!(), + }; + match p.streams[1].state { + StreamState::Init(state) => { + assert_eq!(state.initial_rtptime, Some(3075976528)); + assert_eq!(state.ssrc, Some(0x9fc9fff8)); + } + _ => panic!(), + }; + } + + #[test] + fn bunny() { + // This is a public test server for Wowza Streaming Engine. + // https://www.wowza.com/html/mobile.html + + // DESCRIBE. + let prefix = "rtsp://wowzaec2demo.streamlock.net/vod/mp4:BigBuckBunny_115k.mov"; + let mut p = parse_describe(prefix, include_bytes!("testdata/bunny_describe.txt")).unwrap(); + assert_eq!(p.control.as_str(), &(prefix.to_string() + "/")); + assert!(!p.accept_dynamic_rate); + + assert_eq!(p.streams.len(), 2); + + // audio stream + assert_eq!( + p.streams[0].control.as_ref().unwrap().as_str(), + &(prefix.to_string() + "/trackID=1") + ); + assert_eq!(p.streams[0].media, "audio"); + assert_eq!(p.streams[0].encoding_name, "mpeg4-generic"); + assert_eq!(p.streams[0].rtp_payload_type, 96); + assert_eq!(p.streams[0].clock_rate, 12_000); + assert_eq!(p.streams[0].channels, NonZeroU16::new(2)); + match p.streams[0].parameters() { + Some(Parameters::Audio(_)) => {} + _ => panic!(), + } + + // H.264 video stream. + assert_eq!( + p.streams[1].control.as_ref().unwrap().as_str(), + &(prefix.to_string() + "/trackID=2") + ); + assert_eq!(p.streams[1].media, "video"); + assert_eq!(p.streams[1].encoding_name, "h264"); + assert_eq!(p.streams[1].rtp_payload_type, 97); + assert_eq!(p.streams[1].clock_rate, 90_000); + match p.streams[1].parameters().unwrap() { + Parameters::Video(v) => { + assert_eq!(v.rfc6381_codec(), "avc1.42C01E"); + assert_eq!(v.pixel_dimensions(), (240, 160)); + assert_eq!(v.pixel_aspect_ratio(), None); + assert_eq!(v.frame_rate(), Some((2, 48))); + } + _ => panic!(), + } + + // SETUP. + let setup_response = response(include_bytes!("testdata/bunny_setup.txt")); + let setup_response = super::parse_setup(&setup_response).unwrap(); + assert_eq!(setup_response.session_id, "1642021126"); + assert_eq!(setup_response.channel_id, 0); + assert_eq!(setup_response.ssrc, None); + p.streams[0].state = StreamState::Init(StreamStateInit::default()); + p.streams[1].state = StreamState::Init(StreamStateInit::default()); + + // PLAY. + super::parse_play(response(include_bytes!("testdata/bunny_play.txt")), &mut p).unwrap(); + match p.streams[1].state { + StreamState::Init(state) => { + assert_eq!(state.initial_rtptime, Some(0)); + assert_eq!(state.initial_seq, Some(1)); + assert_eq!(state.ssrc, None); + } + _ => panic!(), + }; + } + + #[test] + fn foscam() { + // DESCRIBE. + let prefix = "rtsp://192.168.5.107:65534/videoMain"; + let p = parse_describe(prefix, include_bytes!("testdata/foscam_describe.txt")).unwrap(); + assert_eq!(p.control.as_str(), &(prefix.to_string() + "/")); + assert!(!p.accept_dynamic_rate); + + assert_eq!(p.streams.len(), 2); + + // H.264 video stream. + assert_eq!( + p.streams[0].control.as_ref().unwrap().as_str(), + &(prefix.to_string() + "/track1") + ); + assert_eq!(p.streams[0].media, "video"); + assert_eq!(p.streams[0].encoding_name, "h264"); + assert_eq!(p.streams[0].rtp_payload_type, 96); + assert_eq!(p.streams[0].clock_rate, 90_000); + match p.streams[0].parameters().unwrap() { + Parameters::Video(v) => { + assert_eq!(v.rfc6381_codec(), "avc1.4D001F"); + assert_eq!(v.pixel_dimensions(), (1280, 720)); + assert_eq!(v.pixel_aspect_ratio(), None); + assert_eq!(v.frame_rate(), None); + } + _ => panic!(), + } + + // audio stream + assert_eq!( + p.streams[1].control.as_ref().unwrap().as_str(), + &(prefix.to_string() + "/track2") + ); + assert_eq!(p.streams[1].media, "audio"); + assert_eq!(p.streams[1].encoding_name, "pcmu"); + assert_eq!(p.streams[1].rtp_payload_type, 0); + assert_eq!(p.streams[1].clock_rate, 8_000); + assert_eq!(p.streams[1].channels, NonZeroU16::new(1)); + match p.streams[1].parameters().unwrap() { + Parameters::Audio(_) => {} + _ => panic!(), + }; + } + + /// [GW Security GW4089IP](https://github.com/scottlamb/moonfire-nvr/wiki/Cameras:-GW-Security#gw4089ip), + /// main stream (high-res, audio). + #[test] + fn gw_main() { + // DESCRIBE. + let base = "rtsp://192.168.1.110:5050/H264?channel=1&subtype=0&unicast=true&proto=Onvif"; + let mut p = parse_describe(base, include_bytes!("testdata/gw_main_describe.txt")).unwrap(); + assert_eq!(p.control.as_str(), base); + assert!(!p.accept_dynamic_rate); + + assert_eq!(p.streams.len(), 2); + + // H.264 video stream. + assert_eq!( + p.streams[0].control.as_ref().unwrap().as_str(), + "rtsp://192.168.1.110:5050/video" + ); + assert_eq!(p.streams[0].media, "video"); + assert_eq!(p.streams[0].encoding_name, "h264"); + assert_eq!(p.streams[0].rtp_payload_type, 96); + assert_eq!(p.streams[0].clock_rate, 90_000); + match p.streams[0].parameters().unwrap() { + Parameters::Video(v) => { + assert_eq!(v.rfc6381_codec(), "avc1.4D002A"); + assert_eq!(v.pixel_dimensions(), (1920, 1080)); + assert_eq!(v.pixel_aspect_ratio(), None); + assert_eq!(v.frame_rate(), None); + } + _ => panic!(), + } + + // audio stream + assert_eq!( + p.streams[1].control.as_ref().unwrap().as_str(), + "rtsp://192.168.1.110:5050/audio" + ); + assert_eq!(p.streams[1].media, "audio"); + assert_eq!(p.streams[1].encoding_name, "pcmu"); // rtpmap wins over static list. + assert_eq!(p.streams[1].rtp_payload_type, 8); + assert_eq!(p.streams[1].clock_rate, 8_000); + assert_eq!(p.streams[1].channels, NonZeroU16::new(1)); + match p.streams[1].parameters().unwrap() { + Parameters::Audio(_) => {} + _ => panic!(), + }; + + // SETUP. + let setup_response = response(include_bytes!("testdata/gw_main_setup_video.txt")); + let setup_response = super::parse_setup(&setup_response).unwrap(); + assert_eq!(setup_response.session_id, "9a90de54"); + assert_eq!(setup_response.channel_id, 0); + assert_eq!(setup_response.ssrc, None); + p.streams[0].state = StreamState::Init(StreamStateInit { + ssrc: None, + initial_seq: None, + initial_rtptime: None, + }); + + let setup_response = response(include_bytes!("testdata/gw_main_setup_audio.txt")); + let setup_response = super::parse_setup(&setup_response).unwrap(); + assert_eq!(setup_response.session_id, "9a90de54"); + assert_eq!(setup_response.channel_id, 2); + assert_eq!(setup_response.ssrc, None); + p.streams[1].state = StreamState::Init(StreamStateInit { + ssrc: None, + initial_seq: None, + initial_rtptime: None, + }); + + // PLAY. + super::parse_play( + response(include_bytes!("testdata/gw_main_play.txt")), + &mut p, + ) + .unwrap(); + match &p.streams[0].state { + StreamState::Init(s) => { + assert_eq!(s.initial_seq, Some(271)); + assert_eq!(s.initial_rtptime, Some(1621990950)); + } + _ => panic!(), + }; + match &p.streams[1].state { + StreamState::Init(s) => { + assert_eq!(s.initial_seq, None); + assert_eq!(s.initial_rtptime, None); + } + _ => panic!(), + }; + } + + /// [GW Security GW4089IP](https://github.com/scottlamb/moonfire-nvr/wiki/Cameras:-GW-Security#gw4089ip), + /// sub stream (low-res, no audio). + #[test] + fn gw_sub() { + // DESCRIBE. + let base = "rtsp://192.168.1.110:5049/H264?channel=1&subtype=1&unicast=true&proto=Onvif"; + let mut p = parse_describe(base, include_bytes!("testdata/gw_sub_describe.txt")).unwrap(); + assert_eq!(p.control.as_str(), base); + assert!(!p.accept_dynamic_rate); + + assert_eq!(p.streams.len(), 1); + + // H.264 video stream. + assert_eq!( + p.streams[0].control.as_ref().unwrap().as_str(), + "rtsp://192.168.1.110:5049/video" + ); + assert_eq!(p.streams[0].media, "video"); + assert_eq!(p.streams[0].encoding_name, "h264"); + assert_eq!(p.streams[0].rtp_payload_type, 96); + assert_eq!(p.streams[0].clock_rate, 90_000); + match p.streams[0].parameters().unwrap() { + Parameters::Video(v) => { + assert_eq!(v.rfc6381_codec(), "avc1.4D001E"); + assert_eq!(v.pixel_dimensions(), (720, 480)); + assert_eq!(v.pixel_aspect_ratio(), None); + assert_eq!(v.frame_rate(), None); + } + _ => panic!(), + } + + // SETUP. + let setup_response = response(include_bytes!("testdata/gw_sub_setup.txt")); + let setup_response = super::parse_setup(&setup_response).unwrap(); + assert_eq!(setup_response.session_id, "9b0d0e54"); + assert_eq!(setup_response.channel_id, 0); + assert_eq!(setup_response.ssrc, None); + p.streams[0].state = StreamState::Init(StreamStateInit { + ssrc: None, + initial_seq: None, + initial_rtptime: None, + }); + + // PLAY. + super::parse_play(response(include_bytes!("testdata/gw_sub_play.txt")), &mut p).unwrap(); + match &p.streams[0].state { + StreamState::Init(s) => { + assert_eq!(s.initial_seq, Some(273)); + assert_eq!(s.initial_rtptime, Some(1621810809)); + } + _ => panic!(), + }; + } +} diff --git a/src/client/rtp.rs b/src/client/rtp.rs new file mode 100644 index 0000000..ec1d64a --- /dev/null +++ b/src/client/rtp.rs @@ -0,0 +1,216 @@ +// Copyright (C) 2021 Scott Lamb +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! RTP and RTCP handling; see [RFC 3550](https://datatracker.ietf.org/doc/html/rfc3550). + +use bytes::{Buf, Bytes}; +use failure::{bail, format_err, Error}; +use log::{debug, trace}; +use pretty_hex::PrettyHex; + +use crate::client::PacketItem; + +/// An RTP packet. +#[derive(Debug)] +pub struct Packet { + pub rtsp_ctx: crate::Context, + pub stream_id: usize, + pub timestamp: crate::Timestamp, + pub sequence_number: u16, + + /// Number of skipped sequence numbers since the last packet. + /// + /// In the case of the first packet on the stream, this may also report loss + /// packets since the `RTP-Info` header's `seq` value. However, currently + /// that header is not required to be present and may be ignored (see + /// [`crate::client::PlayPolicy::ignore_zero_seq()`].) + pub loss: u16, + + pub mark: bool, + + /// Guaranteed to be less than u16::MAX bytes. + pub payload: Bytes, +} + +/// An RTCP sender report. +#[derive(Debug)] +pub struct SenderReport { + pub stream_id: usize, + pub rtsp_ctx: crate::Context, + pub timestamp: crate::Timestamp, + pub ntp_timestamp: crate::NtpTimestamp, +} + +/// RTP/RTCP demarshaller which ensures packets have the correct SSRC and +/// monotonically increasing SEQ. +/// +/// This reports packet loss (via [Packet::loss]) but doesn't prohibit it, except for losses +/// of more than `i16::MAX` which would be indistinguishable from non-monotonic sequence numbers. +/// Servers sometimes drop packets internally even when sending data via TCP. +/// +/// At least [one camera](https://github.com/scottlamb/moonfire-nvr/wiki/Cameras:-Reolink#reolink-rlc-410-hardware-version-ipc_3816m) +/// sometimes sends data from old RTSP sessions over new ones. This seems like a +/// serious bug, and currently `StrictSequenceChecker` will error in this case, +/// although it'd be possible to discard the incorrect SSRC instead. +/// +/// [RFC 3550 section 8.2](https://tools.ietf.org/html/rfc3550#section-8.2) says that SSRC +/// can change mid-session with a RTCP BYE message. This currently isn't handled. I'm +/// not sure it will ever come up with IP cameras. +#[derive(Debug)] +pub(super) struct StrictSequenceChecker { + ssrc: Option, + next_seq: Option, +} + +impl StrictSequenceChecker { + pub(super) fn new(ssrc: Option, next_seq: Option) -> Self { + Self { ssrc, next_seq } + } + + pub(super) fn rtp( + &mut self, + rtsp_ctx: crate::Context, + timeline: &mut super::Timeline, + stream_id: usize, + mut data: Bytes, + ) -> Result { + // Terrible hack to try to make sense of the GW Security GW4089IP's audio stream. + // It appears to have one RTSP interleaved message wrapped in another. RTP and RTCP + // packets can never start with '$', so this shouldn't interfere with well-behaved + // servers. + if data.len() > 4 + && data[0] == b'$' + && usize::from(u16::from_be_bytes([data[2], data[3]])) <= data.len() - 4 + { + log::debug!("stripping extra interleaved data header"); + data.advance(4); + // also remove suffix? dunno. + } + + let reader = rtp_rs::RtpReader::new(&data[..]).map_err(|e| { + format_err!( + "corrupt RTP header while expecting seq={:04x?} at {:#?}: {:?}\n{:#?}", + self.next_seq, + &rtsp_ctx, + e, + data.hex_dump() + ) + })?; + let sequence_number = u16::from_be_bytes([data[2], data[3]]); // I don't like rtsp_rs::Seq. + let timestamp = match timeline.advance_to(reader.timestamp()) { + Ok(ts) => ts, + Err(e) => { + return Err(e + .context(format!( + "timestamp error in stream {} seq={:04x} {:#?}", + stream_id, sequence_number, &rtsp_ctx + )) + .into()) + } + }; + let ssrc = reader.ssrc(); + let loss = sequence_number.wrapping_sub(self.next_seq.unwrap_or(sequence_number)); + if matches!(self.ssrc, Some(s) if s != ssrc) || loss > 0x80_00 { + bail!( + "Expected ssrc={:08x?} seq={:04x?} got ssrc={:08x} seq={:04x} ts={} at {:#?}", + self.ssrc, + self.next_seq, + ssrc, + sequence_number, + timestamp, + &rtsp_ctx + ); + } + self.ssrc = Some(ssrc); + let mark = reader.mark(); + let payload_range = crate::as_range(&data, reader.payload()) + .ok_or_else(|| format_err!("empty payload at {:#?}", &rtsp_ctx))?; + trace!( + "{:?} pkt {:04x}{} ts={} len={}", + &rtsp_ctx, + sequence_number, + if mark { " " } else { "(M)" }, + ×tamp, + payload_range.len() + ); + data.truncate(payload_range.end); + data.advance(payload_range.start); + self.next_seq = Some(sequence_number.wrapping_add(1)); + Ok(PacketItem::RtpPacket(Packet { + stream_id, + rtsp_ctx, + timestamp, + sequence_number, + loss, + mark, + payload: data, + })) + } + + pub(super) fn rtcp( + &mut self, + rtsp_ctx: crate::Context, + timeline: &mut super::Timeline, + stream_id: usize, + mut data: Bytes, + ) -> Result, Error> { + use rtcp::packet::Packet; + let mut sr = None; + let mut i = 0; + while !data.is_empty() { + let h = match rtcp::header::Header::unmarshal(&data) { + Err(e) => bail!("corrupt RTCP header at {:#?}: {}", &rtsp_ctx, e), + Ok(h) => h, + }; + let pkt_len = (usize::from(h.length) + 1) * 4; + if pkt_len > data.len() { + bail!( + "rtcp pkt len {} vs remaining body len {} at {:#?}", + pkt_len, + data.len(), + &rtsp_ctx + ); + } + let pkt = data.split_to(pkt_len); + match h.packet_type { + rtcp::header::PacketType::SenderReport => { + if i > 0 { + bail!("RTCP SR must be first in packet"); + } + let pkt = match rtcp::sender_report::SenderReport::unmarshal(&pkt) { + Err(e) => bail!("corrupt RTCP SR at {:#?}: {}", &rtsp_ctx, e), + Ok(p) => p, + }; + + let timestamp = match timeline.place(pkt.rtp_time) { + Ok(ts) => ts, + Err(e) => { + return Err(e + .context(format!( + "bad RTP timestamp in RTCP SR {:#?} at {:#?}", + &pkt, &rtsp_ctx + )) + .into()) + } + }; + + // TODO: verify ssrc. + + sr = Some(SenderReport { + stream_id, + rtsp_ctx, + timestamp, + ntp_timestamp: crate::NtpTimestamp(pkt.ntp_time), + }); + } + /*rtcp::header::PacketType::SourceDescription => { + let pkt = rtcp::source_description::SourceDescription::unmarshal(&pkt)?; + debug!("rtcp source description: {:#?}", &pkt); + },*/ + _ => debug!("rtcp: {:?}", h.packet_type), + } + i += 1; + } + Ok(sr.map(PacketItem::SenderReport)) + } +} diff --git a/src/client/testdata/bunny_describe.txt b/src/client/testdata/bunny_describe.txt new file mode 100644 index 0000000..d8e0223 --- /dev/null +++ b/src/client/testdata/bunny_describe.txt @@ -0,0 +1,30 @@ +RTSP/1.0 200 OK +CSeq: 1 +Server: Wowza Streaming Engine 4.8.10 build20210217143515 +Cache-Control: no-cache +Expires: Sat, 8 May 2021 04:35:51 UTC +Content-Length: 589 +Content-Base: rtsp://wowzaec2demo.streamlock.net/vod/mp4:BigBuckBunny_115k.mov/ +Date: Sat, 8 May 2021 04:35:51 UTC +Content-Type: application/sdp +Session: 1642021126;timeout=60 + +v=0 +o=- 1642021126 1642021126 IN IP4 34.227.104.115 +s=BigBuckBunny_115k.mov +c=IN IP4 34.227.104.115 +t=0 0 +a=sdplang:en +a=range:npt=0- 596.48 +a=control:* +m=audio 0 RTP/AVP 96 +a=rtpmap:96 mpeg4-generic/12000/2 +a=fmtp:96 profile-level-id=1;mode=AAC-hbr;sizelength=13;indexlength=3;indexdeltalength=3;config=1490 +a=control:trackID=1 +m=video 0 RTP/AVP 97 +a=rtpmap:97 H264/90000 +a=fmtp:97 packetization-mode=1;profile-level-id=42C01E;sprop-parameter-sets=Z0LAHtkDxWhAAAADAEAAAAwDxYuS,aMuMsg== +a=cliprect:0,0,160,240 +a=framesize:97 240-160 +a=framerate:24.0 +a=control:trackID=2 diff --git a/src/client/testdata/bunny_play.txt b/src/client/testdata/bunny_play.txt new file mode 100644 index 0000000..bfdb6e2 --- /dev/null +++ b/src/client/testdata/bunny_play.txt @@ -0,0 +1,8 @@ +RTSP/1.0 200 OK +RTP-Info: url=trackID=1;seq=1;rtptime=0,url=rtsp://wowzaec2demo.streamlock.net/vod/mp4:BigBuckBunny_115k.mov/trackID=2;seq=1;rtptime=0 +CSeq: 3 +Server: Wowza Streaming Engine 4.8.10 build20210217143515 +Cache-Control: no-cache +Range: npt=0.0-596.48 +Session: 551045787;timeout=60 + diff --git a/src/client/testdata/bunny_setup.txt b/src/client/testdata/bunny_setup.txt new file mode 100644 index 0000000..dd1602d --- /dev/null +++ b/src/client/testdata/bunny_setup.txt @@ -0,0 +1,9 @@ +RTSP/1.0 200 OK +CSeq: 2 +Server: Wowza Streaming Engine 4.8.10 build20210217143515 +Cache-Control: no-cache +Expires: Sat, 8 May 2021 04:35:51 UTC +Transport: RTP/AVP/TCP;unicast;interleaved=0-1 +Date: Sat, 8 May 2021 04:35:51 UTC +Session: 1642021126;timeout=60 + diff --git a/src/client/testdata/dahua_describe_h264_aac_onvif.txt b/src/client/testdata/dahua_describe_h264_aac_onvif.txt new file mode 100644 index 0000000..cdde09e --- /dev/null +++ b/src/client/testdata/dahua_describe_h264_aac_onvif.txt @@ -0,0 +1,32 @@ +RTSP/1.0 200 OK +CSeq: 3 +x-Accept-Dynamic-Rate: 1 +Content-Base: rtsp://192.168.5.111:554/cam/realmonitor?channel=1&subtype=1&unicast=true&proto=Onvif/ +Cache-Control: must-revalidate +Content-Length: 705 +Content-Type: application/sdp + +v=0 +o=- 2252542845 2252542845 IN IP4 0.0.0.0 +s=Media Server +c=IN IP4 0.0.0.0 +t=0 0 +a=control:* +a=packetization-supported:DH +a=rtppayload-supported:DH +a=range:npt=now- +m=video 0 RTP/AVP 96 +a=control:trackID=0 +a=framerate:15.000000 +a=rtpmap:96 H264/90000 +a=fmtp:96 packetization-mode=1;profile-level-id=64001E;sprop-parameter-sets=Z2QAHqwsaoLA9puCgIKgAAADACAAAAMD0IAA,aO4xshsA +a=recvonly +m=audio 0 RTP/AVP 97 +a=control:trackID=1 +a=rtpmap:97 MPEG4-GENERIC/48000 +a=fmtp:97 streamtype=5;profile-level-id=1;mode=AAC-hbr;sizelength=13;indexlength=3;indexdeltalength=3;config=1188 +a=recvonly +m=application 0 RTP/AVP 107 +a=control:trackID=4 +a=rtpmap:107 vnd.onvif.metadata/90000 +a=recvonly diff --git a/src/client/testdata/dahua_describe_h265_pcma.txt b/src/client/testdata/dahua_describe_h265_pcma.txt new file mode 100644 index 0000000..9ac4109 --- /dev/null +++ b/src/client/testdata/dahua_describe_h265_pcma.txt @@ -0,0 +1,27 @@ +RTSP/1.0 200 OK +CSeq: 3 +x-Accept-Dynamic-Rate: 1 +Content-Base: rtsp://cam-driveway/cam/realmonitor?channel=1&subtype=2/ +Cache-Control: must-revalidate +Content-Length: 531 +Content-Type: application/sdp + +v=0 +o=- 2253040596 2253040596 IN IP4 0.0.0.0 +s=Media Server +c=IN IP4 0.0.0.0 +t=0 0 +a=control:* +a=packetization-supported:DH +a=rtppayload-supported:DH +a=range:npt=now- +m=video 0 RTP/AVP 98 +a=control:trackID=0 +a=framerate:12.000000 +a=rtpmap:98 H265/90000 +a=fmtp:98 profile-id=1;sprop-sps=QgEBAWAAAAMAsAAAAwAAAwBaoAWCAeFja5JFL83BQYFBAAADAAEAAAMADKE=;sprop-pps=RAHA8saNA7NA;sprop-vps=QAEMAf//AWAAAAMAsAAAAwAAAwBarAwAAAMABAAAAwAyqA== +a=recvonly +m=audio 0 RTP/AVP 8 +a=control:trackID=1 +a=rtpmap:8 PCMA/8000 +a=recvonly diff --git a/src/client/testdata/dahua_play.txt b/src/client/testdata/dahua_play.txt new file mode 100644 index 0000000..e14e395 --- /dev/null +++ b/src/client/testdata/dahua_play.txt @@ -0,0 +1,6 @@ +RTSP/1.0 200 OK +CSeq: 7 +Session: 634214684047 +Range: npt=0.000000- +RTP-Info: url=trackID=0;seq=47121;rtptime=3475222385,url=trackID=1;seq=45186;rtptime=2234446919,url=trackID=4;seq=36583;rtptime=816418535 + diff --git a/src/client/testdata/dahua_setup.txt b/src/client/testdata/dahua_setup.txt new file mode 100644 index 0000000..190773b --- /dev/null +++ b/src/client/testdata/dahua_setup.txt @@ -0,0 +1,6 @@ +RTSP/1.0 200 OK +CSeq: 4 +Session: 634214675641;timeout=60 +Transport: RTP/AVP/TCP;unicast;interleaved=0-1;ssrc=30A98EE7 +x-Dynamic-Rate: 1 + diff --git a/src/client/testdata/foscam_describe.txt b/src/client/testdata/foscam_describe.txt new file mode 100644 index 0000000..5323141 --- /dev/null +++ b/src/client/testdata/foscam_describe.txt @@ -0,0 +1,29 @@ +RTSP/1.0 200 OK +CSeq: 2 +Date: Wed, May 12 2021 18:56:25 GMT +Content-Base: rtsp://192.168.5.107:65534/videoMain/ +Content-Type: application/sdp +Content-Length: 518 + +v=0 +o=- 1620845785796009 1 IN IP4 192.168.233.233 +s=IP Camera Video +i=videoMain +t=0 0 +a=tool:LIVE555 Streaming Media v2014.02.10 +a=type:broadcast +a=control:* +a=range:npt=0- +a=x-qt-text-nam:IP Camera Video +a=x-qt-text-inf:videoMain +m=video 0 RTP/AVP 96 +c=IN IP4 0.0.0.0 +b=AS:96 +a=rtpmap:96 H264/90000 +a=range:npt=0- +a=fmtp:96 packetization-mode=1;profile-level-id=4D001F;sprop-parameter-sets=Z00AH5WoFAFuQA==,aO48gA== +a=control:track1 +m=audio 0 RTP/AVP 0 +c=IN IP4 0.0.0.0 +b=AS:64 +a=control:track2 diff --git a/src/client/testdata/gw_main_describe.txt b/src/client/testdata/gw_main_describe.txt new file mode 100644 index 0000000..50e2880 --- /dev/null +++ b/src/client/testdata/gw_main_describe.txt @@ -0,0 +1,25 @@ +RTSP/1.0 200 OK +Server: Rtsp Server/2.0 +CSeq: 1 +Content-Base: rtsp://192.168.1.110:5050/H264?channel=1&subtype=0&unicast=true&proto=Onvif +Content-Type: application/sdp +Content-Length: 451 + +v=0 +o=- 1109162014219182 1109162014219282 IN IP4 x.y.z.w +s=Session streamed by "H264Server" +e=NONE +c=IN IP4 0.0.0.0 +t=0 0 +a=range:npt=now- +a=control:* +m=video 0 RTP/AVP 96 +a=rtpmap:96 H264/90000 +a=control:video +a=fmtp:96 packetization-mode=1;profile-level-id=5046314;sprop-parameter-sets=Z00AKpWoHgCJ+WbgICAgQAAAAAE=,aO48gAAAAAE= +m=audio 0 RTP/AVP 8 +a=control:audio +a=rtpmap:8 PCMU/8000/1 +a=ptime:20 +a=fmtp:8 vad=no +a=appversion:2.0 diff --git a/src/client/testdata/gw_main_play.txt b/src/client/testdata/gw_main_play.txt new file mode 100644 index 0000000..ab29360 --- /dev/null +++ b/src/client/testdata/gw_main_play.txt @@ -0,0 +1,6 @@ +RTSP/1.0 200 OK +CSeq: 4 +Range: npt=0.000- +Session: 9a90de54; timeout=120 +RTP-Info: url=rtsp://192.168.1.110:5050/H264/video;seq=271;rtptime=1621990950 + diff --git a/src/client/testdata/gw_main_setup_audio.txt b/src/client/testdata/gw_main_setup_audio.txt new file mode 100644 index 0000000..230a3b4 --- /dev/null +++ b/src/client/testdata/gw_main_setup_audio.txt @@ -0,0 +1,5 @@ +RTSP/1.0 200 OK +CSeq: 3 +Transport: RTP/AVP/TCP;unicast;destination=192.168.1.210;source=192.168.1.110;interleaved=2-3 +Session: 9a90de54; timeout=60 + diff --git a/src/client/testdata/gw_main_setup_video.txt b/src/client/testdata/gw_main_setup_video.txt new file mode 100644 index 0000000..f02f4b2 --- /dev/null +++ b/src/client/testdata/gw_main_setup_video.txt @@ -0,0 +1,5 @@ +RTSP/1.0 200 OK +CSeq: 2 +Transport: RTP/AVP/TCP;unicast;destination=192.168.1.210;source=192.168.1.110;interleaved=0-1 +Session: 9a90de54; timeout=60 + diff --git a/src/client/testdata/gw_sub_describe.txt b/src/client/testdata/gw_sub_describe.txt new file mode 100644 index 0000000..535570a --- /dev/null +++ b/src/client/testdata/gw_sub_describe.txt @@ -0,0 +1,19 @@ +RTSP/1.0 200 OK +Server: Rtsp Server/2.0 +CSeq: 1 +Content-Base: rtsp://192.168.1.110:5049/H264?channel=1&subtype=1&unicast=true&proto=Onvif +Content-Type: application/sdp +Content-Length: 338 + +v=0 +o=- 1109162014219182 1109162014219282 IN IP4 x.y.z.w +s=Session streamed by "H264Server" +e=NONE +c=IN IP4 0.0.0.0 +t=0 0 +a=range:npt=now- +a=control:* +m=video 0 RTP/AVP 96 +a=rtpmap:96 H264/90000 +a=control:video +a=fmtp:96 packetization-mode=1;profile-level-id=5046302;sprop-parameter-sets=Z00AHpWoLQ9puAgICBAAAAAB,aO48gAAAAAE= diff --git a/src/client/testdata/gw_sub_play.txt b/src/client/testdata/gw_sub_play.txt new file mode 100644 index 0000000..fbf9ba7 --- /dev/null +++ b/src/client/testdata/gw_sub_play.txt @@ -0,0 +1,6 @@ +RTSP/1.0 200 OK +CSeq: 3 +Range: npt=0.000- +Session: 9b0d0e54; timeout=120 +RTP-Info: url=rtsp://192.168.1.110:5049/H264/video;seq=273;rtptime=1621810809 + diff --git a/src/client/testdata/gw_sub_setup.txt b/src/client/testdata/gw_sub_setup.txt new file mode 100644 index 0000000..8b63ea1 --- /dev/null +++ b/src/client/testdata/gw_sub_setup.txt @@ -0,0 +1,5 @@ +RTSP/1.0 200 OK +CSeq: 2 +Transport: RTP/AVP/TCP;unicast;destination=192.168.1.210;source=192.168.1.110;interleaved=0-1 +Session: 9b0d0e54; timeout=60 + diff --git a/src/client/testdata/hikvision_describe.txt b/src/client/testdata/hikvision_describe.txt new file mode 100644 index 0000000..6fd0c9e --- /dev/null +++ b/src/client/testdata/hikvision_describe.txt @@ -0,0 +1,29 @@ +RTSP/1.0 200 OK +CSeq: 2 +Content-Type: application/sdp +Content-Base: rtsp://192.168.5.106:554/Streaming/Channels/101/ +Content-Length: 902 + +v=0 +o=- 1620251477190769 1620251477190769 IN IP4 192.168.5.106 +s=Media Presentation +e=NONE +b=AS:5050 +t=0 0 +a=control:rtsp://192.168.5.106:554/Streaming/Channels/101/?transportmode=unicast&profile=Profile_1 +m=video 0 RTP/AVP 96 +c=IN IP4 0.0.0.0 +b=AS:5000 +a=recvonly +a=x-dimensions:1920,1080 +a=control:rtsp://192.168.5.106:554/Streaming/Channels/101/trackID=1?transportmode=unicast&profile=Profile_1 +a=rtpmap:96 H264/90000 +a=fmtp:96 profile-level-id=420029; packetization-mode=1; sprop-parameter-sets=Z00AKZpkA8ARPyzUBAQFAAADA+gAAOpgBA==,aO48gA== +m=application 0 RTP/AVP 107 +c=IN IP4 0.0.0.0 +b=AS:50 +a=recvonly +a=control:rtsp://192.168.5.106:554/Streaming/Channels/101/trackID=3?transportmode=unicast&profile=Profile_1 +a=rtpmap:107 vnd.onvif.metadata/90000 +a=Media_header:MEDIAINFO=494D4B48010100000400010000000000000000000000000000000000000000000000000000000000; +a=appversion:1.0 diff --git a/src/client/testdata/hikvision_play.txt b/src/client/testdata/hikvision_play.txt new file mode 100644 index 0000000..77b5e47 --- /dev/null +++ b/src/client/testdata/hikvision_play.txt @@ -0,0 +1,6 @@ +RTSP/1.0 200 OK +CSeq: 4 +Session: 708345999 +RTP-Info: url=rtsp://192.168.5.106:554/Streaming/Channels/101/trackID=1?transportmode=unicast&profile=Profile_1;seq=24104;rtptime=1270711678 +Date: Wed, May 05 2021 21:51:17 GMT + diff --git a/src/client/testdata/hikvision_setup.txt b/src/client/testdata/hikvision_setup.txt new file mode 100644 index 0000000..b066afb --- /dev/null +++ b/src/client/testdata/hikvision_setup.txt @@ -0,0 +1,6 @@ +RTSP/1.0 200 OK +CSeq: 3 +Session: 708345999;timeout=60 +Transport: RTP/AVP/TCP;unicast;interleaved=0-1;ssrc=4cacc3d1;mode="play" +Date: Wed, May 05 2021 21:51:17 GMT + diff --git a/src/client/testdata/reolink_describe.txt b/src/client/testdata/reolink_describe.txt new file mode 100644 index 0000000..f8731c9 --- /dev/null +++ b/src/client/testdata/reolink_describe.txt @@ -0,0 +1,31 @@ +RTSP/1.0 200 OK +CSeq: 3 +Date: Fri, Apr 30 2021 20:12:32 GMT +Content-Base: rtsp://192.168.5.206/h264Preview_01_main/ +Content-Type: application/sdp +Content-Length: 734 + +v=0 +o=- 1619813458434609 1 IN IP4 192.168.5.206 +s=Session streamed by "preview" +i=h264Preview_01_main +t=0 0 +a=tool:LIVE555 Streaming Media v2013.04.08 +a=type:broadcast +a=control:* +a=range:npt=0- +a=x-qt-text-nam:Session streamed by "preview" +a=x-qt-text-inf:h264Preview_01_main +m=video 0 RTP/AVP 96 +c=IN IP4 0.0.0.0 +b=AS:500 +a=rtpmap:96 H264/90000 +a=range:npt=0- +a=fmtp:96 packetization-mode=1;profile-level-id=640033;sprop-parameter-sets=Z2QAM6zoAoALWQ==,aO48MA== +a=control:trackID=1 +m=audio 0 RTP/AVP 97 +c=IN IP4 0.0.0.0 +b=AS:256 +a=rtpmap:97 MPEG4-GENERIC/16000 +a=fmtp:97 streamtype=5;profile-level-id=15;mode=AAC-hbr;sizelength=13;indexlength=3;indexdeltalength=3;config=1408; profile=1; +a=control:trackID=2 diff --git a/src/client/testdata/reolink_play.txt b/src/client/testdata/reolink_play.txt new file mode 100644 index 0000000..81d40ec --- /dev/null +++ b/src/client/testdata/reolink_play.txt @@ -0,0 +1,8 @@ +RTSP/1.0 200 OK +Server: Rtsp Server/2.0 +CSeq: 6 +Date: Fri, Apr 30 2021 20:12:32 GMT +Range: npt=0.000- +Session: F8F8E425 +RTP-Info: url=trackID=1;seq=16852;rtptime=1070938629;ssrc=dcc4a0d8,url=trackID=2;seq=39409;rtptime=3075976528;ssrc=9fc9fff8 + diff --git a/src/client/testdata/reolink_setup.txt b/src/client/testdata/reolink_setup.txt new file mode 100644 index 0000000..404b99c --- /dev/null +++ b/src/client/testdata/reolink_setup.txt @@ -0,0 +1,6 @@ +RTSP/1.0 200 OK +CSeq: 4 +Date: Fri, Apr 30 2021 20:12:32 GMT +Transport: RTP/AVP/TCP;unicast;destination=192.168.1.210;source=192.168.5.206;interleaved=0-1 +Session: F8F8E425 + diff --git a/src/client/timeline.rs b/src/client/timeline.rs new file mode 100644 index 0000000..bd986d3 --- /dev/null +++ b/src/client/timeline.rs @@ -0,0 +1,187 @@ +// Copyright (C) 2021 Scott Lamb +// SPDX-License-Identifier: MIT OR Apache-2.0 + +use failure::{bail, format_err, Error}; +use std::{ + convert::TryFrom, + num::{NonZeroI32, NonZeroU32}, +}; + +use crate::Timestamp; + +const MAX_FORWARD_TIME_JUMP_SECS: u32 = 10; + +/// Creates [Timestamp]s (which don't wrap and can be converted to NPT aka normal play time) +/// from 32-bit (wrapping) RTP timestamps. +#[derive(Debug)] +pub(super) struct Timeline { + timestamp: i64, + clock_rate: NonZeroU32, + start: Option, + + /// The maximum forward jump to allow, in clock rate units. + /// If this is absent, don't do any enforcement of sane time units. + max_forward_jump: Option, + + /// The same in seconds, for logging. + max_forward_jump_secs: u32, +} + +impl Timeline { + /// Creates a new timeline, erroring on crazy clock rates. + pub(super) fn new( + start: Option, + clock_rate: u32, + enforce_with_max_forward_jump_secs: Option, + ) -> Result { + let clock_rate = NonZeroU32::new(clock_rate) + .ok_or_else(|| format_err!("clock_rate=0 rejected to prevent division by zero"))?; + let max_forward_jump = + enforce_with_max_forward_jump_secs + .map(|j| i32::try_from(u64::from(j.get()) * u64::from(clock_rate.get()))) + .transpose() + .map_err(|_| { + format_err!( + "clock_rate={} rejected because max forward jump of {} sec exceeds i32::MAX", + clock_rate, MAX_FORWARD_TIME_JUMP_SECS) + })? + .map(|j| NonZeroI32::new(j).expect("non-zero times non-zero must be non-zero")); + Ok(Timeline { + timestamp: i64::from(start.unwrap_or(0)), + start, + clock_rate, + max_forward_jump, + max_forward_jump_secs: enforce_with_max_forward_jump_secs + .map(NonZeroU32::get) + .unwrap_or(0), + }) + } + + /// Advances to the given (wrapping) RTP timestamp. + /// + /// If enforcement was enabled, this produces a monotonically increasing + /// [Timestamp], erroring on excessive or backward time jumps. + pub(super) fn advance_to(&mut self, rtp_timestamp: u32) -> Result { + let (timestamp, delta) = self.ts_and_delta(rtp_timestamp)?; + if matches!(self.max_forward_jump, Some(j) if !(0..j.get()).contains(&delta)) { + bail!( + "Timestamp jumped {} ({:.03} sec) from {} to {}; \ + policy is to allow 0..{} sec only", + delta, + (delta as f64) / f64::from(self.clock_rate.get()), + self.timestamp, + timestamp, + self.max_forward_jump_secs + ); + } + self.timestamp = timestamp.timestamp; + Ok(timestamp) + } + + /// Places `rtp_timestamp` on the timeline without advancing the timeline + /// or applying time jump policy. Will set the NPT epoch if unset. + /// + /// This is useful for RTP timestamps in RTCP packets. They commonly refer + /// to time slightly before the most timestamp of the matching RTP stream. + pub(super) fn place(&mut self, rtp_timestamp: u32) -> Result { + Ok(self.ts_and_delta(rtp_timestamp)?.0) + } + + fn ts_and_delta(&mut self, rtp_timestamp: u32) -> Result<(Timestamp, i32), Error> { + let start = match self.start { + None => { + self.start = Some(rtp_timestamp); + self.timestamp = i64::from(rtp_timestamp); + rtp_timestamp + } + Some(start) => start, + }; + let delta = (rtp_timestamp as i32).wrapping_sub(self.timestamp as i32); + let timestamp = self + .timestamp + .checked_add(i64::from(delta)) + .ok_or_else(|| { + // This probably won't happen even with a hostile server. It'd + // take ~2^31 packets (~ 4 billion) to advance the time this far + // forward or backward even with no limits on time jump per + // packet. + format_err!( + "timestamp {} + delta {} won't fit in i64!", + self.timestamp, + delta + ) + })?; + + // Also error in similarly-unlikely NPT underflow. + if timestamp.checked_sub(i64::from(start)).is_none() { + bail!( + "timestamp {} + delta {} - start {} underflows i64!", + self.timestamp, + delta, + start + ); + } + Ok(( + Timestamp { + timestamp, + clock_rate: self.clock_rate, + start, + }, + delta, + )) + } +} + +#[cfg(test)] +mod tests { + use std::num::NonZeroU32; + + use super::Timeline; + + #[test] + fn timeline() { + // Don't allow crazy clock rates that will get us into trouble. + Timeline::new(Some(0), 0, None).unwrap_err(); + Timeline::new(Some(0), u32::MAX, NonZeroU32::new(10)).unwrap_err(); + + // Don't allow excessive forward jumps when enforcement is enabled. + let mut t = Timeline::new(Some(100), 90_000, NonZeroU32::new(10)).unwrap(); + t.advance_to(100 + (10 * 90_000) + 1).unwrap_err(); + + // Or any backward jump when enforcement is enabled. + let mut t = Timeline::new(Some(100), 90_000, NonZeroU32::new(10)).unwrap(); + t.advance_to(99).unwrap_err(); + + // ...but do allow backward RTP timestamps in RTCP. + let mut t = Timeline::new(Some(100), 90_000, NonZeroU32::new(10)).unwrap(); + assert_eq!(t.place(99).unwrap().elapsed(), -1); + assert_eq!(t.advance_to(101).unwrap().elapsed(), 1); + + // ...and be more permissive when enforcement is disabled. + let mut t = Timeline::new(Some(100), 90_000, None).unwrap(); + t.advance_to(100 + (10 * 90_000) + 1).unwrap(); + let mut t = Timeline::new(Some(100), 90_000, None).unwrap(); + t.advance_to(99).unwrap(); + + // Normal usage. + let mut t = Timeline::new(Some(42), 90_000, NonZeroU32::new(10)).unwrap(); + assert_eq!(t.advance_to(83).unwrap().elapsed(), 83 - 42); + assert_eq!(t.advance_to(453).unwrap().elapsed(), 453 - 42); + + // Wraparound is normal too. + let mut t = Timeline::new(Some(u32::MAX), 90_000, NonZeroU32::new(10)).unwrap(); + assert_eq!(t.advance_to(5).unwrap().elapsed(), 5 + 1); + + // No initial rtptime. + let mut t = Timeline::new(None, 90_000, NonZeroU32::new(10)).unwrap(); + assert_eq!(t.advance_to(218250000).unwrap().elapsed(), 0); + } + + #[test] + fn cast() { + let a = 0x1FFFF_FFFFi64; + let b = 0x10000_0000i64; + assert_eq!(a as i32, -1); + assert_eq!(b as i32, 0); + } +} diff --git a/src/codec/aac.rs b/src/codec/aac.rs new file mode 100644 index 0000000..7e9a897 --- /dev/null +++ b/src/codec/aac.rs @@ -0,0 +1,680 @@ +// Copyright (C) 2021 Scott Lamb +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! AAC (Advanced Audio Codec) depacketization. +//! There are many intertwined standards; see the following references: +//! * [RFC 3640](https://datatracker.ietf.org/doc/html/rfc3640): RTP Payload +//! for Transport of MPEG-4 Elementary Streams. +//! * ISO/IEC 13818-7: Advanced Audio Coding. +//! * ISO/IEC 14496: Information technology — Coding of audio-visual objects +//! * ISO/IEC 14496-1: Systems. +//! * ISO/IEC 14496-3: Audio, subpart 1: Main. +//! * ISO/IEC 14496-3: Audio, subpart 4: General Audio coding (GA) — AAC, TwinVQ, BSAC. +//! * [ISO/IEC 14496-12](https://standards.iso.org/ittf/PubliclyAvailableStandards/c068960_ISO_IEC_14496-12_2015.zip): +//! ISO base media file format. +//! * ISO/IEC 14496-14: MP4 File Format. + +use bytes::{Buf, BufMut, Bytes, BytesMut}; +use failure::{bail, format_err, Error}; +use std::{ + convert::TryFrom, + fmt::Debug, + num::{NonZeroU16, NonZeroU32}, +}; + +use crate::client::rtp::Packet; + +use super::CodecItem; + +/// An AudioSpecificConfig as in ISO/IEC 14496-3 section 1.6.2.1. +/// Currently just a few fields of interest. +#[derive(Clone, Debug)] +pub(super) struct AudioSpecificConfig { + /// See ISO/IEC 14496-3 Table 1.3. + audio_object_type: u8, + frame_length: NonZeroU16, + sampling_frequency: u32, + channels: &'static ChannelConfig, +} + +/// A channel configuration as in ISO/IEC 14496-3 Table 1.19. +#[derive(Debug)] +struct ChannelConfig { + channels: u16, + + /// The "number of considered channels" as defined in ISO/IEC 13818-7 Term + /// 3.58. Roughly, non-subwoofer channels. + ncc: u16, + + /// A human-friendly name for the channel configuration. + name: &'static str, +} + +#[rustfmt::skip] +const CHANNEL_CONFIGS: [Option; 8] = [ + /* 0 */ None, // "defined in AOT related SpecificConfig" + /* 1 */ Some(ChannelConfig { channels: 1, ncc: 1, name: "mono" }), + /* 2 */ Some(ChannelConfig { channels: 2, ncc: 2, name: "stereo" }), + /* 3 */ Some(ChannelConfig { channels: 3, ncc: 3, name: "3.0" }), + /* 4 */ Some(ChannelConfig { channels: 4, ncc: 4, name: "4.0" }), + /* 5 */ Some(ChannelConfig { channels: 5, ncc: 5, name: "5.0" }), + /* 6 */ Some(ChannelConfig { channels: 6, ncc: 5, name: "5.1" }), + /* 7 */ Some(ChannelConfig { channels: 8, ncc: 7, name: "7.1" }), +]; + +impl AudioSpecificConfig { + /// Parses from raw bytes. + fn parse(config: &[u8]) -> Result { + let mut r = bitreader::BitReader::new(config); + let audio_object_type = match r.read_u8(5)? { + 31 => 32 + r.read_u8(6)?, + o => o, + }; + + // ISO/IEC 14496-3 section 1.6.3.4. + let sampling_frequency = match r.read_u8(4)? { + 0x0 => 96_000, + 0x1 => 88_200, + 0x2 => 64_000, + 0x3 => 48_000, + 0x5 => 32_000, + 0x6 => 24_000, + 0x7 => 22_050, + 0x8 => 16_000, + 0x9 => 12_000, + 0xa => 11_025, + 0xb => 8_000, + 0xc => 7_350, + v @ 0xd | v @ 0xe => bail!("reserved sampling_frequency_index value 0x{:x}", v), + 0xf => r.read_u32(24)?, + _ => unreachable!(), + }; + let channels = { + let c = r.read_u8(4)?; + CHANNEL_CONFIGS + .get(usize::from(c)) + .ok_or_else(|| format_err!("reserved channelConfiguration 0x{:x}", c))? + .as_ref() + .ok_or_else(|| format_err!("program_config_element parsing unimplemented"))? + }; + if audio_object_type == 5 || audio_object_type == 29 { + // extensionSamplingFrequencyIndex + extensionSamplingFrequency. + if r.read_u8(4)? == 0xf { + r.skip(24)?; + } + // audioObjectType (a different one) + extensionChannelConfiguration. + if r.read_u8(5)? == 22 { + r.skip(4)?; + } + } + + // The supported types here are the ones that use GASpecificConfig. + match audio_object_type { + 1 | 2 | 3 | 4 | 6 | 7 | 17 | 19 | 20 | 21 | 22 | 23 => {} + o => bail!("unsupported audio_object_type {}", o), + } + + // GASpecificConfig, ISO/IEC 14496-3 section 4.4.1. + let frame_length = match (audio_object_type, r.read_bool()?) { + (3 /* AAC SR */, false) => NonZeroU16::new(256).expect("non-zero"), + (3 /* AAC SR */, true) => bail!("frame_length_flag must be false for AAC SSR"), + (23 /* ER AAC LD */, false) => NonZeroU16::new(512).expect("non-zero"), + (23 /* ER AAC LD */, true) => NonZeroU16::new(480).expect("non-zero"), + (_, false) => NonZeroU16::new(1024).expect("non-zero"), + (_, true) => NonZeroU16::new(960).expect("non-zero"), + }; + + Ok(AudioSpecificConfig { + audio_object_type, + frame_length, + sampling_frequency, + channels, + }) + } +} + +/// Overwrites a buffer with a varint length, returning the length of the length. +/// See ISO/IEC 14496-1 section 8.3.3. +fn set_length(len: usize, data: &mut [u8]) -> Result { + if len < 1 << 7 { + data[0] = len as u8; + Ok(1) + } else if len < 1 << 14 { + data[0] = ((len & 0x7F) | 0x80) as u8; + data[1] = (len >> 7) as u8; + Ok(2) + } else if len < 1 << 21 { + data[0] = ((len & 0x7F) | 0x80) as u8; + data[1] = (((len >> 7) & 0x7F) | 0x80) as u8; + data[2] = (len >> 14) as u8; + Ok(3) + } else if len < 1 << 28 { + data[0] = ((len & 0x7F) | 0x80) as u8; + data[1] = (((len >> 7) & 0x7F) | 0x80) as u8; + data[2] = (((len >> 14) & 0x7F) | 0x80) as u8; + data[3] = (len >> 21) as u8; + Ok(4) + } else { + // BaseDescriptor sets a maximum length of 2**28 - 1. + bail!("length {} too long", len); + } +} + +/// Writes a box length and type (four-character code) for everything appended +/// in the supplied scope. +macro_rules! write_box { + ($buf:expr, $fourcc:expr, $b:block) => {{ + let _: &mut BytesMut = $buf; // type-check. + let pos_start = $buf.len(); + let fourcc: &[u8; 4] = $fourcc; + $buf.extend_from_slice(&[0, 0, 0, 0, fourcc[0], fourcc[1], fourcc[2], fourcc[3]]); + let r = { + $b; + }; + let pos_end = $buf.len(); + let len = pos_end.checked_sub(pos_start).unwrap(); + $buf[pos_start..pos_start + 4].copy_from_slice(&u32::try_from(len)?.to_be_bytes()[..]); + r + }}; +} + +/// Writes a descriptor tag and length for everything appended in the supplied +/// scope. See ISO/IEC 14496-1 Table 1 for the `tag`. +macro_rules! write_descriptor { + ($buf:expr, $tag:expr, $b:block) => {{ + let _: &mut BytesMut = $buf; // type-check. + let _: u8 = $tag; + let pos_start = $buf.len(); + + // Overallocate room for the varint length and append the body. + $buf.extend_from_slice(&[$tag, 0, 0, 0, 0]); + let r = { + $b; + }; + let pos_end = $buf.len(); + + // Then fix it afterward: write the correct varint length and move + // the body backward. This approach seems better than requiring the + // caller to first prepare the body in a separate allocation (and + // awkward code ordering), or (as ffmpeg does) writing a "varint" + // which is padded with leading 0x80 bytes. + let len = pos_end.checked_sub(pos_start + 5).unwrap(); + let len_len = set_length(len, &mut $buf[pos_start + 1..pos_start + 4])?; + $buf.copy_within(pos_start + 5..pos_end, pos_start + 1 + len_len); + $buf.truncate(pos_end + len_len - 4); + r + }}; +} + +/// Returns an MP4AudioSampleEntry (`mp4a`) box as in ISO/IEC 14496-14 section 5.6.1. +/// `config` should be a raw AudioSpecificConfig (matching `parsed`). +pub(super) fn get_mp4a_box(parameters: &super::AudioParameters) -> Result { + let parsed = match parameters.config { + super::AudioCodecConfig::Aac(ref c) => c, + _ => unreachable!(), + }; + let config = ¶meters.extra_data[..]; + let mut buf = BytesMut::new(); + + // Write an MP4AudioSampleEntry (`mp4a`), as in ISO/IEC 14496-14 section 5.6.1. + // It's based on AudioSampleEntry, ISO/IEC 14496-12 section 12.2.3.2, + // in turn based on SampleEntry, ISO/IEC 14496-12 section 8.5.2.2. + write_box!(&mut buf, b"mp4a", { + buf.extend_from_slice(&[ + 0, 0, 0, 0, // SampleEntry.reserved + 0, 0, 0, 1, // SampleEntry.reserved, SampleEntry.data_reference_index (1) + 0, 0, 0, 0, // AudioSampleEntry.reserved + 0, 0, 0, 0, // AudioSampleEntry.reserved + ]); + buf.put_u16(parsed.channels.channels); + buf.extend_from_slice(&[ + 0x00, 0x10, // AudioSampleEntry.samplesize + 0x00, 0x00, 0x00, 0x00, // AudioSampleEntry.pre_defined, AudioSampleEntry.reserved + ]); + + // ISO/IEC 14496-12 section 12.2.3 says to put the samplerate (aka + // clock_rate aka sampling_frequency) as a 16.16 fixed-point number or + // use a SamplingRateBox. The latter also requires changing the + // version/structure of the AudioSampleEntryBox and the version of the + // stsd box. Just support the former for now. + let sampling_frequency = u16::try_from(parsed.sampling_frequency).map_err(|_| { + format_err!( + "aac sampling_frequency={} unsupported", + parsed.sampling_frequency + ) + })?; + buf.put_u32(u32::from(sampling_frequency) << 16); + + // Write the embedded ESDBox (`esds`), as in ISO/IEC 14496-14 section 5.6.1. + write_box!(&mut buf, b"esds", { + buf.put_u32(0); // version + + write_descriptor!(&mut buf, 0x03 /* ES_DescrTag */, { + // The ESDBox contains an ES_Descriptor, defined in ISO/IEC 14496-1 section 8.3.3. + // ISO/IEC 14496-14 section 3.1.2 has advice on how to set its + // fields within the scope of a .mp4 file. + buf.extend_from_slice(&[ + 0, 0, // ES_ID=0 + 0x00, // streamDependenceFlag, URL_Flag, OCRStreamFlag, streamPriority. + ]); + + // DecoderConfigDescriptor, defined in ISO/IEC 14496-1 section 7.2.6.6. + write_descriptor!(&mut buf, 0x04 /* DecoderConfigDescrTag */, { + buf.extend_from_slice(&[ + 0x40, // objectTypeIndication = Audio ISO/IEC 14496-3 + 0x15, // streamType = audio, upstream = false, reserved = 1 + ]); + + // bufferSizeDb is "the size of the decoding buffer for this + // elementary stream in byte". ISO/IEC 13818-7 section + // 8.2.2.1 defines the total decoder input buffer size as + // 6144 bits per NCC. + let buffer_size_bytes = (6144 / 8) * u32::from(parsed.channels.ncc); + debug_assert!(buffer_size_bytes <= 0xFF_FFFF); + + // buffer_size_bytes as a 24-bit number + buf.put_u8((buffer_size_bytes >> 16) as u8); + buf.put_u16(buffer_size_bytes as u16); + + let max_bitrate = (6144 / 1024) + * u32::from(parsed.channels.ncc) + * u32::from(sampling_frequency); + buf.put_u32(max_bitrate); + + // avg_bitrate. ISO/IEC 14496-1 section 7.2.6.6 says "for streams with + // variable bitrate this value shall be set to zero." + buf.put_u32(0); + + // AudioSpecificConfiguration, ISO/IEC 14496-3 subpart 1 section 1.6.2. + write_descriptor!(&mut buf, 0x05 /* DecSpecificInfoTag */, { + buf.extend_from_slice(config); + }); + }); + + // SLConfigDescriptor, ISO/IEC 14496-1 section 7.3.2.3.1. + write_descriptor!(&mut buf, 0x06 /* SLConfigDescrTag */, { + buf.put_u8(2); // predefined = reserved for use in MP4 files + }); + }); + }); + }); + Ok(buf.freeze()) +} + +/// Parses metadata from the `format-specific-params` of a SDP `fmtp` media attribute. +/// The metadata is defined in [RFC 3640 section +/// 4.1](https://datatracker.ietf.org/doc/html/rfc3640#section-4.1). +fn parse_format_specific_params( + clock_rate: u32, + format_specific_params: &str, +) -> Result { + let mut mode = None; + let mut config = None; + let mut size_length = None; + let mut index_length = None; + let mut index_delta_length = None; + for p in format_specific_params.split(';') { + let p = p.trim(); + if p.is_empty() { + // Reolink cameras leave a trailing ';'. + continue; + } + let (key, value) = p + .split_once('=') + .ok_or_else(|| format_err!("bad format-specific-param {}", p))?; + match &key.to_ascii_lowercase()[..] { + "config" => { + config = Some( + hex::decode(value) + .map_err(|_| format_err!("config has invalid hex encoding"))?, + ); + } + "mode" => mode = Some(value), + "sizelength" => { + size_length = Some( + u16::from_str_radix(value, 10).map_err(|_| format_err!("bad sizeLength"))?, + ); + } + "indexlength" => { + index_length = Some( + u16::from_str_radix(value, 10).map_err(|_| format_err!("bad indexLength"))?, + ); + } + "indexdeltalength" => { + index_delta_length = Some( + u16::from_str_radix(value, 10) + .map_err(|_| format_err!("bad indexDeltaLength"))?, + ); + } + _ => {} + } + } + // https://datatracker.ietf.org/doc/html/rfc3640#section-3.3.6 AAC-hbr + if mode != Some("AAC-hbr") { + bail!("Expected mode AAC-hbr, got {:#?}", mode); + } + let config = config.ok_or_else(|| format_err!("config must be specified"))?; + if size_length != Some(13) || index_length != Some(3) || index_delta_length != Some(3) { + bail!( + "Unexpected sizeLength={:?} indexLength={:?} indexDeltaLength={:?}", + size_length, + index_length, + index_delta_length + ); + } + + let parsed = AudioSpecificConfig::parse(&config[..])?; + + // TODO: is this a requirement? I might have read somewhere one can be a multiple of the other. + if clock_rate != parsed.sampling_frequency { + bail!( + "Expected RTP clock rate {} and AAC sampling frequency {} to match", + clock_rate, + parsed.sampling_frequency + ); + } + + // https://datatracker.ietf.org/doc/html/rfc6381#section-3.3 + let rfc6381_codec = Some(format!("mp4a.40.{}", parsed.audio_object_type)); + let frame_length = Some(parsed.frame_length); + Ok(super::AudioParameters { + config: super::AudioCodecConfig::Aac(parsed), + clock_rate, + rfc6381_codec, + frame_length: frame_length.map(NonZeroU32::from), + extra_data: Bytes::from(config), + }) +} + +#[derive(Debug)] +pub(crate) struct Depacketizer { + parameters: super::Parameters, + + /// This is in parameters but duplicated here to avoid destructuring. + frame_length: NonZeroU16, + state: DepacketizerState, +} + +#[derive(Debug)] +struct Aggregate { + ctx: crate::Context, + + /// RTP packets lost before the next frame in this aggregate. Includes old + /// loss that caused a previous fragment to be too short. + /// This should be 0 when `frame_i > 0`. + loss: u16, + + /// True iff there was loss immediately before the packet that started this + /// aggregate. The distinction between old and recent loss is relevant + /// because only the latter should be capable of causing following fragments + /// to be too short. + loss_since_mark: bool, + + stream_id: usize, + + /// The RTP-level timestamp; frame `i` is at timestamp `timestamp + frame_length*i`. + timestamp: crate::Timestamp, + + /// The buffer, positioned at frame 0's header. + buf: Bytes, + + /// The index in range `[0, frame_count)` of the next frame to output. + frame_i: u16, + + /// The non-zero total frames within this aggregate. + frame_count: u16, + + /// The starting byte offset of `frame_i`'s data within `buf`. + data_off: usize, + + /// If a mark was set on this packet. When this is false, this should + /// actually be the start of a fragmented frame, but that conversion is + /// currently deferred until `pull`. + mark: bool, +} + +#[derive(Debug)] +struct Fragment { + rtp_timestamp: u16, + + /// Number of RTP packets lost before between the previous output AudioFrame + /// and now. + loss: u16, + + /// True iff packets have been lost since the last mark. If so, this + /// fragment may be incomplete. + loss_since_mark: bool, + + size: u16, + buf: BytesMut, +} + +#[derive(Debug)] +#[allow(clippy::large_enum_variant)] +enum DepacketizerState { + Idle { prev_loss: u16 }, + Aggregated(Aggregate), + Fragmented(Fragment), + Ready(super::AudioFrame), +} + +impl Depacketizer { + pub(super) fn new( + clock_rate: u32, + channels: Option, + format_specific_params: Option<&str>, + ) -> Result { + let format_specific_params = format_specific_params + .ok_or_else(|| format_err!("AAC requires format specific params"))?; + let parameters = parse_format_specific_params(clock_rate, format_specific_params)?; + let parsed = match parameters.config { + super::AudioCodecConfig::Aac(ref c) => c, + _ => unreachable!(), + }; + if matches!(channels, Some(c) if c.get() != parsed.channels.channels) { + bail!( + "Expected RTP channels {:?} and AAC channels {:?} to match", + channels, + parsed.channels + ); + } + let frame_length = parsed.frame_length; + Ok(Self { + parameters: super::Parameters::Audio(parameters), + frame_length, + state: DepacketizerState::Idle { prev_loss: 0 }, + }) + } + + pub(super) fn parameters(&self) -> Option<&super::Parameters> { + Some(&self.parameters) + } + + pub(super) fn push(&mut self, mut pkt: Packet) -> Result<(), Error> { + if pkt.loss > 0 && matches!(self.state, DepacketizerState::Fragmented(_)) { + log::debug!( + "Discarding fragmented AAC frame due to loss of {} RTP packets.", + pkt.loss + ); + self.state = DepacketizerState::Idle { prev_loss: 0 }; + } + + // Read the AU headers. + if pkt.payload.len() < 2 { + bail!("packet too short for au-header-length"); + } + let au_headers_length_bits = pkt.payload.get_u16(); + + // AAC-hbr requires 16-bit AU headers: 13-bit size, 3-bit index. + if (au_headers_length_bits & 0x7) != 0 { + bail!("bad au-headers-length {}", au_headers_length_bits); + } + let au_headers_count = au_headers_length_bits >> 4; + let data_off = usize::from(au_headers_count) << 1; + if pkt.payload.len() < (usize::from(au_headers_count) << 1) { + bail!("packet too short for au-headers"); + } + match &mut self.state { + DepacketizerState::Fragmented(ref mut frag) => { + if au_headers_count != 1 { + bail!( + "Got {}-AU packet while fragment in progress", + au_headers_count + ); + } + if (pkt.timestamp.timestamp as u16) != frag.rtp_timestamp { + bail!( + "Timestamp changed from 0x{:04x} to 0x{:04x} mid-fragment", + frag.rtp_timestamp, + pkt.timestamp.timestamp as u16 + ); + } + let au_header = u16::from_be_bytes([pkt.payload[0], pkt.payload[1]]); + let size = usize::from(au_header >> 3); + if size != usize::from(frag.size) { + bail!("size changed {}->{} mid-fragment", frag.size, size); + } + let data = &pkt.payload[data_off..]; + match (frag.buf.len() + data.len()).cmp(&size) { + std::cmp::Ordering::Less => { + if pkt.mark { + if frag.loss > 0 { + self.state = DepacketizerState::Idle { + prev_loss: frag.loss, + }; + return Ok(()); + } + bail!( + "frag marked complete when {}+{}<{}", + frag.buf.len(), + data.len(), + size + ); + } + } + std::cmp::Ordering::Equal => { + if !pkt.mark { + bail!("frag not marked complete when full data present"); + } + frag.buf.extend_from_slice(data); + println!("au {}: len-{}, fragmented", &pkt.timestamp, size); + self.state = DepacketizerState::Ready(super::AudioFrame { + ctx: pkt.rtsp_ctx, + loss: frag.loss, + frame_length: NonZeroU32::from(self.frame_length), + stream_id: pkt.stream_id, + timestamp: pkt.timestamp, + data: std::mem::take(&mut frag.buf).freeze(), + }); + } + std::cmp::Ordering::Greater => bail!("too much data in fragment"), + } + } + DepacketizerState::Aggregated(_) => panic!("push when already in state aggregated"), + DepacketizerState::Idle { prev_loss } => { + if au_headers_count == 0 { + bail!("aggregate with no headers"); + } + self.state = DepacketizerState::Aggregated(Aggregate { + ctx: pkt.rtsp_ctx, + loss: *prev_loss + pkt.loss, + loss_since_mark: pkt.loss > 0, + stream_id: pkt.stream_id, + timestamp: pkt.timestamp, + buf: pkt.payload, + frame_i: 0, + frame_count: au_headers_count, + data_off, + mark: pkt.mark, + }); + } + DepacketizerState::Ready(..) => panic!("push when in state ready"), + } + Ok(()) + } + + pub(super) fn pull(&mut self) -> Result, Error> { + match std::mem::replace(&mut self.state, DepacketizerState::Idle { prev_loss: 0 }) { + s @ DepacketizerState::Idle { .. } | s @ DepacketizerState::Fragmented(..) => { + self.state = s; + Ok(None) + } + DepacketizerState::Ready(f) => { + self.state = DepacketizerState::Idle { prev_loss: 0 }; + Ok(Some(CodecItem::AudioFrame(f))) + } + DepacketizerState::Aggregated(mut agg) => { + let i = usize::from(agg.frame_i); + let au_header = u16::from_be_bytes([agg.buf[i << 1], agg.buf[(i << 1) + 1]]); + let size = usize::from(au_header >> 3); + let index = au_header & 0b111; + if index != 0 { + // First AU's index must be zero; subsequent AU's deltas > 1 + // indicate interleaving, which we don't support. + // TODO: https://datatracker.ietf.org/doc/html/rfc3640#section-3.3.6 + // says "receivers MUST support de-interleaving". + bail!("interleaving not yet supported"); + } + if size > agg.buf.len() - agg.data_off { + // start of fragment + if agg.frame_count != 1 { + bail!("fragmented AUs must not share packets"); + } + if agg.mark { + bail!("mark can't be set on beginning of fragment"); + } + let mut buf = BytesMut::with_capacity(size); + buf.extend_from_slice(&agg.buf[agg.data_off..]); + self.state = DepacketizerState::Fragmented(Fragment { + rtp_timestamp: agg.timestamp.timestamp as u16, + loss: agg.loss, + loss_since_mark: agg.loss_since_mark, + size: size as u16, + buf, + }); + return Ok(None); + } + if !agg.mark { + bail!("mark must be set on non-fragmented au"); + } + let frame = super::AudioFrame { + ctx: agg.ctx, + loss: agg.loss, + stream_id: agg.stream_id, + frame_length: NonZeroU32::from(self.frame_length), + + // u16 * u16 can't overflow u32, but i64 + u32 can overflow i64. + timestamp: agg + .timestamp + .try_add(u32::from(agg.frame_i) * u32::from(self.frame_length.get()))?, + data: agg.buf.slice(agg.data_off..agg.data_off + size), + }; + agg.loss = 0; + agg.data_off += size; + agg.frame_i += 1; + if agg.frame_i < agg.frame_count { + self.state = DepacketizerState::Aggregated(agg); + } + Ok(Some(CodecItem::AudioFrame(frame))) + } + } + } +} + +#[cfg(test)] +mod tests { + #[test] + fn parse_audio_specific_config() { + let dahua = super::AudioSpecificConfig::parse(&[0x11, 0x88]).unwrap(); + assert_eq!(dahua.sampling_frequency, 48_000); + assert_eq!(dahua.channels.name, "mono"); + + let bunny = super::AudioSpecificConfig::parse(&[0x14, 0x90]).unwrap(); + assert_eq!(bunny.sampling_frequency, 12_000); + assert_eq!(bunny.channels.name, "stereo"); + + let rfc3640 = super::AudioSpecificConfig::parse(&[0x11, 0xB0]).unwrap(); + assert_eq!(rfc3640.sampling_frequency, 48_000); + assert_eq!(rfc3640.channels.name, "5.1"); + } +} diff --git a/src/codec/g723.rs b/src/codec/g723.rs new file mode 100644 index 0000000..4f50676 --- /dev/null +++ b/src/codec/g723.rs @@ -0,0 +1,70 @@ +// Copyright (C) 2021 Scott Lamb +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! G.723.1 audio as specified in [RFC 3551 section 4.5.3](https://datatracker.ietf.org/doc/html/rfc3551#section-4.5.3). + +use std::num::NonZeroU32; + +use bytes::Bytes; +use failure::{bail, Error}; +use pretty_hex::PrettyHex; + +#[derive(Debug)] +pub(crate) struct Depacketizer { + parameters: super::Parameters, + pending: Option, +} + +impl Depacketizer { + /// Creates a new Depacketizer. + pub(super) fn new(clock_rate: u32) -> Result { + if clock_rate != 8_000 { + bail!("Expected clock rate of 8000 for G.723, got {}", clock_rate); + } + Ok(Self { + parameters: super::Parameters::Audio(super::AudioParameters { + rfc6381_codec: None, + frame_length: NonZeroU32::new(240), + clock_rate, + extra_data: Bytes::new(), + config: super::AudioCodecConfig::Other, + }), + pending: None, + }) + } + + pub(super) fn parameters(&self) -> Option<&super::Parameters> { + Some(&self.parameters) + } + + fn validate(pkt: &crate::client::rtp::Packet) -> bool { + let expected_hdr_bits = match pkt.payload.len() { + 24 => 0b00, + 20 => 0b01, + 4 => 0b10, + _ => return false, + }; + let actual_hdr_bits = pkt.payload[0] & 0b11; + actual_hdr_bits == expected_hdr_bits + } + + pub(super) fn push(&mut self, pkt: crate::client::rtp::Packet) -> Result<(), Error> { + assert!(self.pending.is_none()); + if !Self::validate(&pkt) { + bail!("Invalid G.723 packet: {:#?}", pkt.payload.hex_dump()); + } + self.pending = Some(super::AudioFrame { + ctx: pkt.rtsp_ctx, + loss: pkt.loss, + stream_id: pkt.stream_id, + timestamp: pkt.timestamp, + frame_length: NonZeroU32::new(240).unwrap(), + data: pkt.payload, + }); + Ok(()) + } + + pub(super) fn pull(&mut self) -> Result, Error> { + Ok(self.pending.take().map(super::CodecItem::AudioFrame)) + } +} diff --git a/src/codec/h264.rs b/src/codec/h264.rs new file mode 100644 index 0000000..464dbb4 --- /dev/null +++ b/src/codec/h264.rs @@ -0,0 +1,621 @@ +// Copyright (C) 2021 Scott Lamb +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! [H.264](https://www.itu.int/rec/T-REC-H.264-201906-I/en)-encoded video. + +use std::convert::TryFrom; + +use bytes::{Buf, BufMut, Bytes, BytesMut}; +use failure::{bail, format_err, Error}; +use h264_reader::nal::UnitType; +use log::debug; + +use crate::client::rtp::Packet; + +/// A [super::Depacketizer] implementation which finds access unit boundaries +/// and produces unfragmented NAL units as specified in [RFC +/// 6184](https://tools.ietf.org/html/rfc6184). +/// +/// This doesn't inspect the contents of the NAL units, so it doesn't depend on or +/// verify compliance with H.264 section 7.4.1.2.3 "Order of NAL units and coded +/// pictures and association to access units". +/// +/// Currently expects that the stream starts at an access unit boundary and has no lost packets. +#[derive(Debug)] +pub(crate) struct Depacketizer { + input_state: DepacketizerInputState, + pending: Option, + parameters: InternalParameters, + + /// The largest fragment used. This is used for the buffer capacity on subsequent fragments, minimizing reallocation. + frag_high_water: usize, +} + +#[derive(Debug)] +struct AccessUnit { + start_ctx: crate::Context, + end_ctx: crate::Context, + timestamp: crate::Timestamp, + stream_id: usize, + new_sps: Option, + new_pps: Option, + + /// RTP packets lost as this access unit was starting. + loss: u16, + + /// Currently we expect only a single slice NAL. + picture: Option, +} + +#[derive(Debug)] +struct PreMark { + /// If a FU-A fragment is in progress, the buffer used to accumulate the NAL. + frag_buf: Option, + + access_unit: AccessUnit, +} + +#[derive(Debug)] +#[allow(clippy::clippy::large_enum_variant)] +enum DepacketizerInputState { + /// Not yet processing an access unit. + New, + + Loss { + timestamp: crate::Timestamp, + pkts: u16, + }, + + /// Currently processing an access unit. + /// This will be flushed after a marked packet or when receiving a later timestamp. + PreMark(PreMark), + + /// Finished processing the given packet. It's an error to receive the same timestamp again. + PostMark { + timestamp: crate::Timestamp, + loss: u16, + }, +} + +impl Depacketizer { + pub(super) fn new( + clock_rate: u32, + format_specific_params: Option<&str>, + ) -> Result { + if clock_rate != 90_000 { + bail!("H.264 clock rate must always be 90000"); + } + + // TODO: the spec doesn't require out-of-band parameters, so we shouldn't either. + let format_specific_params = format_specific_params + .ok_or_else(|| format_err!("H.264 depacketizer expects out-of-band parameters"))?; + Ok(Depacketizer { + input_state: DepacketizerInputState::New, + pending: None, + frag_high_water: 0, + parameters: InternalParameters::parse_format_specific_params(format_specific_params)?, + }) + } + + pub(super) fn parameters(&self) -> Option<&super::Parameters> { + Some(&self.parameters.generic_parameters) + } + + pub(super) fn push(&mut self, pkt: Packet) -> Result<(), Error> { + // Push shouldn't be called until pull is exhausted. + if let Some(p) = self.pending.as_ref() { + panic!("push with data already pending: {:?}", p); + } + + // The rtp crate also has [H.264 depacketization + // logic](https://docs.rs/rtp/0.2.2/rtp/codecs/h264/struct.H264Packet.html), + // but it doesn't seem to match my use case. I want to iterate the NALs, + // not re-encode them in Annex B format. + let seq = pkt.sequence_number; + let mut premark = match std::mem::replace( + &mut self.input_state, + DepacketizerInputState::New, + ) { + DepacketizerInputState::New => PreMark { + access_unit: AccessUnit::start(&pkt), + frag_buf: None, + }, + DepacketizerInputState::PreMark(mut premark) => { + if pkt.loss > 0 { + if premark.access_unit.timestamp.timestamp == pkt.timestamp.timestamp { + // Loss within this access unit. Ignore until mark or new timestamp. + self.input_state = if pkt.mark { + DepacketizerInputState::PostMark { + timestamp: pkt.timestamp, + loss: pkt.loss, + } + } else { + DepacketizerInputState::Loss { + timestamp: pkt.timestamp, + pkts: pkt.loss, + } + }; + return Ok(()); + } + // A suffix of a previous access unit was lost; discard it. + // A prefix of the new one may have been lost; try parsing. + PreMark { + access_unit: AccessUnit::start(&pkt), + frag_buf: None, + } + } else { + if premark.access_unit.timestamp.timestamp != pkt.timestamp.timestamp { + if premark.frag_buf.is_some() { + bail!("Timestamp changed from {} to {} in the middle of a fragmented NAL at seq={:04x} {:#?}", premark.access_unit.timestamp, pkt.timestamp, seq, &pkt.rtsp_ctx); + } + premark.access_unit.end_ctx = pkt.rtsp_ctx; + self.pending = Some(std::mem::replace( + &mut premark.access_unit, + AccessUnit::start(&pkt), + )); + } + premark + } + } + DepacketizerInputState::PostMark { + timestamp: state_ts, + loss, + } => { + if state_ts.timestamp == pkt.timestamp.timestamp { + bail!("Received packet with timestamp {} after marked packet with same timestamp at seq={:04x} {:#?}", pkt.timestamp, seq, &pkt.rtsp_ctx); + } + let mut access_unit = AccessUnit::start(&pkt); + access_unit.loss += loss; + PreMark { + access_unit, + frag_buf: None, + } + } + DepacketizerInputState::Loss { + timestamp, + mut pkts, + } => { + if pkt.timestamp.timestamp == timestamp.timestamp { + pkts += pkt.loss; + self.input_state = DepacketizerInputState::Loss { timestamp, pkts }; + return Ok(()); + } + let mut access_unit = AccessUnit::start(&pkt); + access_unit.loss += pkts; + PreMark { + access_unit, + frag_buf: None, + } + } + }; + + let mut data = pkt.payload; + if data.is_empty() { + bail!("Empty NAL at RTP seq {:04x}, {:#?}", seq, &pkt.rtsp_ctx); + } + // https://tools.ietf.org/html/rfc6184#section-5.2 + let nal_header = data[0]; + if (nal_header >> 7) != 0 { + bail!( + "NAL header has F bit set at seq {:04x} {:#?}", + seq, + &pkt.rtsp_ctx + ); + } + match nal_header & 0b11111 { + 1..=23 => { + if premark.frag_buf.is_some() { + bail!( + "Non-fragmented NAL while fragment in progress seq {:04x} {:#?}", + seq, + &pkt.rtsp_ctx + ); + } + premark.access_unit.nal(&mut self.parameters, data)?; + } + 24 => { + // STAP-A. https://tools.ietf.org/html/rfc6184#section-5.7.1 + data.advance(1); // skip the header byte. + loop { + if data.remaining() < 2 { + bail!( + "STAP-A has {} remaining bytes while expecting 2-byte length", + data.remaining() + ); + } + let len = usize::from(data.get_u16()); + match data.remaining().cmp(&len) { + std::cmp::Ordering::Less => bail!( + "STAP-A too short: {} bytes remaining, expecting {}-byte NAL", + data.remaining(), + len + ), + std::cmp::Ordering::Equal => { + premark.access_unit.nal(&mut self.parameters, data)?; + break; + } + std::cmp::Ordering::Greater => premark + .access_unit + .nal(&mut self.parameters, data.split_to(len))?, + } + } + } + 25..=27 | 29 => unimplemented!( + "unimplemented NAL (header 0x{:02x}) at seq {:04x} {:#?}", + nal_header, + seq, + &pkt.rtsp_ctx + ), + 28 => { + // FU-A. https://tools.ietf.org/html/rfc6184#section-5.8 + if data.len() < 3 { + bail!("FU-A is too short at seq {:04x} {:#?}", seq, &pkt.rtsp_ctx); + } + let fu_header = data[1]; + let start = (fu_header & 0b10000000) != 0; + let end = (fu_header & 0b01000000) != 0; + let reserved = (fu_header & 0b00100000) != 0; + let nal_header = (nal_header & 0b011100000) | (fu_header & 0b00011111); + if (start && end) || reserved { + bail!( + "Invalid FU-A header {:08b} at seq {:04x} {:#?}", + fu_header, + seq, + &pkt.rtsp_ctx + ); + } + match (start, premark.frag_buf.take()) { + (true, Some(_)) => bail!( + "FU-A with start bit while frag in progress at seq {:04x} {:#?}", + seq, + &pkt.rtsp_ctx + ), + (true, None) => { + let mut frag_buf = BytesMut::with_capacity(std::cmp::max( + self.frag_high_water, + data.len() - 1, + )); + frag_buf.put_u8(nal_header); + data.advance(2); + frag_buf.put(data); + premark.frag_buf = Some(frag_buf); + } + (false, Some(mut frag_buf)) => { + if frag_buf[0] != nal_header { + bail!("FU-A has inconsistent NAL type: {:08b} then {:08b} at seq {:04x} {:#?}", frag_buf[0], nal_header, seq, &pkt.rtsp_ctx); + } + data.advance(2); + frag_buf.put(data); + if end { + self.frag_high_water = frag_buf.len(); + premark + .access_unit + .nal(&mut self.parameters, frag_buf.freeze())?; + } else if pkt.mark { + bail!( + "FU-A with MARK and no END at seq {:04x} {:#?}", + seq, + pkt.rtsp_ctx + ); + } else { + premark.frag_buf = Some(frag_buf); + } + } + (false, None) => { + if pkt.loss > 0 { + self.input_state = DepacketizerInputState::Loss { + timestamp: pkt.timestamp, + pkts: pkt.loss, + }; + return Ok(()); + } + bail!( + "FU-A with start bit unset while no frag in progress at {:04x} {:#?}", + seq, + &pkt.rtsp_ctx + ); + } + } + } + _ => bail!( + "bad nal header {:0x} at seq {:04x} {:#?}", + nal_header, + seq, + &pkt.rtsp_ctx + ), + } + self.input_state = if pkt.mark { + premark.access_unit.end_ctx = pkt.rtsp_ctx; + self.pending = Some(premark.access_unit); + DepacketizerInputState::PostMark { + timestamp: pkt.timestamp, + loss: 0, + } + } else { + DepacketizerInputState::PreMark(premark) + }; + Ok(()) + } + + pub(super) fn pull(&mut self) -> Result, Error> { + let pending = match self.pending.take() { + None => return Ok(None), + Some(p) => p, + }; + let new_parameters = if pending.new_sps.is_some() || pending.new_pps.is_some() { + let sps_nal = pending + .new_sps + .as_deref() + .unwrap_or(&self.parameters.sps_nal); + let pps_nal = pending + .new_pps + .as_deref() + .unwrap_or(&self.parameters.pps_nal); + self.parameters = InternalParameters::parse_sps_and_pps(sps_nal, pps_nal)?; + match self.parameters.generic_parameters { + super::Parameters::Video(ref p) => Some(p.clone()), + _ => unreachable!(), + } + } else { + None + }; + let picture = pending + .picture + .ok_or_else(|| format_err!("access unit has no picture"))?; + let nal_header = + h264_reader::nal::NalHeader::new(picture[0]).expect("nal header was previously valid"); + Ok(Some(super::CodecItem::VideoFrame(super::VideoFrame { + start_ctx: pending.start_ctx, + end_ctx: pending.end_ctx, + loss: pending.loss, + new_parameters, + timestamp: pending.timestamp, + stream_id: pending.stream_id, + is_random_access_point: nal_header.nal_unit_type() + == UnitType::SliceLayerWithoutPartitioningIdr, + is_disposable: nal_header.nal_ref_idc() == 0, + pos: 0, + data_prefix: u32::try_from(picture.len()).unwrap().to_be_bytes(), + data: picture, + }))) + } +} + +impl AccessUnit { + fn start(pkt: &crate::client::rtp::Packet) -> Self { + AccessUnit { + start_ctx: pkt.rtsp_ctx, + end_ctx: pkt.rtsp_ctx, + timestamp: pkt.timestamp, + stream_id: pkt.stream_id, + loss: pkt.loss, + new_sps: None, + new_pps: None, + picture: None, + } + } + + fn nal(&mut self, parameters: &mut InternalParameters, nal: Bytes) -> Result<(), Error> { + let nal_header = h264_reader::nal::NalHeader::new(nal[0]) + .map_err(|e| format_err!("bad NAL header 0x{:x}: {:#?}", nal[0], e))?; + let unit_type = nal_header.nal_unit_type(); + match unit_type { + UnitType::SeqParameterSet => { + if self.new_sps.is_some() { + bail!("multiple SPSs in access unit"); + } + if nal != parameters.sps_nal { + self.new_sps = Some(nal); + } + } + UnitType::PicParameterSet => { + if self.new_pps.is_some() { + bail!("multiple PPSs in access unit"); + } + if nal != parameters.pps_nal { + self.new_pps = Some(nal); + } + } + UnitType::SliceLayerWithoutPartitioningIdr + | UnitType::SliceLayerWithoutPartitioningNonIdr => { + if self.picture.is_some() { + bail!("currently expect only one picture NAL per access unit"); + } + self.picture = Some(nal); + } + _ => {} + } + Ok(()) + } +} + +/// Decodes a NAL unit (minus header byte) into its RBSP. +/// Stolen from h264-reader's src/avcc.rs. This shouldn't last long, see: +/// . +fn decode(encoded: &[u8]) -> Vec { + struct NalRead(Vec); + use h264_reader::nal::NalHandler; + use h264_reader::Context; + impl NalHandler for NalRead { + type Ctx = (); + fn start(&mut self, _ctx: &mut Context, _header: h264_reader::nal::NalHeader) {} + + fn push(&mut self, _ctx: &mut Context, buf: &[u8]) { + self.0.extend_from_slice(buf) + } + + fn end(&mut self, _ctx: &mut Context) {} + } + let mut decode = h264_reader::rbsp::RbspDecoder::new(NalRead(vec![])); + let mut ctx = Context::new(()); + decode.push(&mut ctx, encoded); + let read = decode.into_handler(); + read.0 +} + +#[derive(Clone, Debug)] +struct InternalParameters { + generic_parameters: super::Parameters, + + /// The (single) SPS NAL. + sps_nal: Bytes, + + /// The (single) PPS NAL. + pps_nal: Bytes, +} + +impl InternalParameters { + /// Parses metadata from the `format-specific-params` of a SDP `fmtp` media attribute. + fn parse_format_specific_params(format_specific_params: &str) -> Result { + let mut sprop_parameter_sets = None; + for p in format_specific_params.split(';') { + let (key, value) = p.trim().split_once('=').unwrap(); + if key == "sprop-parameter-sets" { + sprop_parameter_sets = Some(value); + } + } + let sprop_parameter_sets = sprop_parameter_sets.ok_or_else(|| { + format_err!("no sprop-parameter-sets in H.264 format-specific-params") + })?; + + let mut sps_nal = None; + let mut pps_nal = None; + for nal in sprop_parameter_sets.split(',') { + let nal = + base64::decode(nal).map_err(|_| format_err!("NAL has invalid base64 encoding"))?; + if nal.is_empty() { + bail!("empty NAL"); + } + let header = h264_reader::nal::NalHeader::new(nal[0]) + .map_err(|_| format_err!("bad NAL header {:0x}", nal[0]))?; + match header.nal_unit_type() { + UnitType::SeqParameterSet => { + if sps_nal.is_some() { + bail!("multiple SPSs"); + } + sps_nal = Some(nal); + } + UnitType::PicParameterSet => { + if pps_nal.is_some() { + bail!("multiple PPSs"); + } + pps_nal = Some(nal); + } + _ => bail!("only SPS and PPS expected in parameter sets"), + } + } + let sps_nal = sps_nal.ok_or_else(|| format_err!("no sps"))?; + let pps_nal = pps_nal.ok_or_else(|| format_err!("no pps"))?; + + // GW security GW4089IP leaves Annex B start codes at the end of both + // SPS and PPS in the sprop-parameter-sets. Leaving them in means + // there's an immediate parameter change (from in-band parameters) once + // the first frame is received. Strip them out. + let sps_nal = sps_nal + .strip_suffix(b"\x00\x00\x00\x01") + .unwrap_or(&sps_nal); + let pps_nal = pps_nal + .strip_suffix(b"\x00\x00\x00\x01") + .unwrap_or(&pps_nal); + Self::parse_sps_and_pps(sps_nal, pps_nal) + } + + fn parse_sps_and_pps(sps_nal: &[u8], pps_nal: &[u8]) -> Result { + let sps_rbsp = decode(&sps_nal[1..]); + if sps_rbsp.len() < 4 { + bail!("bad sps"); + } + let rfc6381_codec = format!( + "avc1.{:02X}{:02X}{:02X}", + sps_rbsp[0], sps_rbsp[1], sps_rbsp[2] + ); + let sps = h264_reader::nal::sps::SeqParameterSet::from_bytes(&sps_rbsp) + .map_err(|e| format_err!("Bad SPS: {:?}", e))?; + debug!("sps: {:#?}", &sps); + + let pixel_dimensions = sps + .pixel_dimensions() + .map_err(|e| format_err!("SPS has invalid pixel dimensions: {:?}", e))?; + + // Create the AVCDecoderConfiguration, ISO/IEC 14496-15 section 5.2.4.1. + // The beginning of the AVCDecoderConfiguration takes a few values from + // the SPS (ISO/IEC 14496-10 section 7.3.2.1.1). + let mut avc_decoder_config = BytesMut::with_capacity(11 + sps_nal.len() + pps_nal.len()); + avc_decoder_config.put_u8(1); // configurationVersion + avc_decoder_config.extend(&sps_rbsp[0..=2]); // profile_idc . AVCProfileIndication + // ...misc bits... . profile_compatibility + // level_idc . AVCLevelIndication + + // Hardcode lengthSizeMinusOne to 3, matching TransformSampleData's 4-byte + // lengths. + avc_decoder_config.put_u8(0xff); + + // Only support one SPS and PPS. + // ffmpeg's ff_isom_write_avcc has the same limitation, so it's probably + // fine. This next byte is a reserved 0b111 + a 5-bit # of SPSs (1). + avc_decoder_config.put_u8(0xe1); + avc_decoder_config.extend(&u16::try_from(sps_nal.len())?.to_be_bytes()[..]); + let sps_nal_start = avc_decoder_config.len(); + avc_decoder_config.extend_from_slice(sps_nal); + let sps_nal_end = avc_decoder_config.len(); + avc_decoder_config.put_u8(1); // # of PPSs. + avc_decoder_config.extend(&u16::try_from(pps_nal.len())?.to_be_bytes()[..]); + let pps_nal_start = avc_decoder_config.len(); + avc_decoder_config.extend_from_slice(pps_nal); + let pps_nal_end = avc_decoder_config.len(); + assert_eq!(avc_decoder_config.len(), 11 + sps_nal.len() + pps_nal.len()); + + let (pixel_aspect_ratio, frame_rate); + match sps.vui_parameters { + Some(ref vui) => { + pixel_aspect_ratio = vui + .aspect_ratio_info + .as_ref() + .and_then(|a| a.clone().get()) + .map(|(h, v)| (u32::from(h), (u32::from(v)))); + + // TODO: study H.264, (E-34). This quick'n'dirty calculation isn't always right. + frame_rate = vui + .timing_info + .as_ref() + .map(|t| (2 * t.num_units_in_tick, t.time_scale)); + } + None => { + pixel_aspect_ratio = None; + frame_rate = None; + } + } + let avc_decoder_config = avc_decoder_config.freeze(); + let sps_nal = avc_decoder_config.slice(sps_nal_start..sps_nal_end); + let pps_nal = avc_decoder_config.slice(pps_nal_start..pps_nal_end); + Ok(InternalParameters { + generic_parameters: super::Parameters::Video(super::VideoParameters { + rfc6381_codec, + pixel_dimensions, + pixel_aspect_ratio, + frame_rate, + extra_data: avc_decoder_config, + }), + sps_nal, + pps_nal, + }) + } +} + +#[cfg(test)] +mod tests { + #[test] + fn gw_security() { + let params = super::InternalParameters::parse_format_specific_params( + "packetization-mode=1;\ + profile-level-id=5046302;\ + sprop-parameter-sets=Z00AHpWoLQ9puAgICBAAAAAB,aO48gAAAAAE=", + ) + .unwrap(); + assert_eq!( + ¶ms.sps_nal[..], + b"\x67\x4d\x00\x1e\x95\xa8\x2d\x0f\x69\xb8\x08\x08\x08\x10" + ); + assert_eq!(¶ms.pps_nal[..], b"\x68\xee\x3c\x80"); + } +} diff --git a/src/codec/mod.rs b/src/codec/mod.rs new file mode 100644 index 0000000..1c42a9c --- /dev/null +++ b/src/codec/mod.rs @@ -0,0 +1,450 @@ +// Copyright (C) 2021 Scott Lamb +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! Codec-specific logic (for audio, video, and application media types). +//! +//! Currently this primarily consists of RTP depacketization logic for each +//! codec, as needed for a client during `PLAY` and a server during `RECORD`. +//! Packetization (needed for the reverse) may be added in the future. + +use std::num::{NonZeroU16, NonZeroU32}; + +use crate::client::rtp; +use bytes::{Buf, Bytes}; +use failure::{bail, Error}; +use pretty_hex::PrettyHex; + +pub(crate) mod aac; +pub(crate) mod g723; +pub(crate) mod h264; +pub(crate) mod onvif; +pub(crate) mod simple_audio; + +pub enum CodecItem { + VideoFrame(VideoFrame), + AudioFrame(AudioFrame), + MessageFrame(MessageFrame), + SenderReport(crate::client::rtp::SenderReport), +} + +#[derive(Clone, Debug)] +pub enum Parameters { + Video(VideoParameters), + Audio(AudioParameters), + Message(MessageParameters), +} + +#[derive(Clone)] +pub struct VideoParameters { + pixel_dimensions: (u32, u32), + rfc6381_codec: String, + pixel_aspect_ratio: Option<(u32, u32)>, + frame_rate: Option<(u32, u32)>, + extra_data: Bytes, +} + +impl VideoParameters { + /// Returns a codec description in + /// [RFC-6381](https://tools.ietf.org/html/rfc6381) form, eg `avc1.4D401E`. + // TODO: use https://github.com/dholroyd/rfc6381-codec crate once published? + pub fn rfc6381_codec(&self) -> &str { + &self.rfc6381_codec + } + + /// Returns the overall dimensions of the video frame in pixels, as `(width, height)`. + pub fn pixel_dimensions(&self) -> (u32, u32) { + self.pixel_dimensions + } + + /// Returns the displayed size of a pixel, if known, as a dimensionless ratio `(h_spacing, v_spacing)`. + /// This is as specified in [ISO/IEC 14496-12:2015](https://standards.iso.org/ittf/PubliclyAvailableStandards/c068960_ISO_IEC_14496-12_2015.zip]) + /// section 12.1.4. + /// + /// It's common for IP cameras to use [anamorphic](https://en.wikipedia.org/wiki/Anamorphic_format) sub streams. + /// Eg a 16x9 camera may export the same video source as a 1920x1080 "main" + /// stream and a 704x480 "sub" stream, without cropping. The former has a + /// pixel aspect ratio of `(1, 1)` while the latter has a pixel aspect ratio + /// of `(40, 33)`. + pub fn pixel_aspect_ratio(&self) -> Option<(u32, u32)> { + self.pixel_aspect_ratio + } + + /// Returns the maximum frame rate in seconds as `(numerator, denominator)`, + /// if known. + /// + /// May not be minimized, and may not be in terms of the clock rate. Eg 15 + /// frames per second might be returned as `(1, 15)` or `(6000, 90000)`. The + /// standard NTSC framerate (roughly 29.97 fps) might be returned as + /// `(1001, 30000)`. + /// + /// TODO: maybe return in clock rate units instead? + /// TODO: expose fixed vs max distinction (see H.264 fixed_frame_rate_flag). + pub fn frame_rate(&self) -> Option<(u32, u32)> { + self.frame_rate + } + + /// The codec-specific "extra data" to feed to eg ffmpeg to decode the video frames. + /// * H.264: an AvcDecoderConfig. + pub fn extra_data(&self) -> &Bytes { + &self.extra_data + } +} + +impl std::fmt::Debug for VideoParameters { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("VideoParameters") + .field("rfc6381_codec", &self.rfc6381_codec) + .field("pixel_dimensions", &self.pixel_dimensions) + .field("pixel_aspect_ratio", &self.pixel_aspect_ratio) + .field("frame_rate", &self.frame_rate) + .field("extra_data", &self.extra_data.hex_dump()) + .finish() + } +} + +#[derive(Clone)] +pub struct AudioParameters { + rfc6381_codec: Option, + frame_length: Option, + clock_rate: u32, + extra_data: Bytes, + config: AudioCodecConfig, +} + +#[derive(Clone)] +enum AudioCodecConfig { + Aac(aac::AudioSpecificConfig), + Other, +} + +impl std::fmt::Debug for AudioParameters { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("AudioParameters") + .field("rfc6381_codec", &self.rfc6381_codec) + .field("frame_length", &self.frame_length) + .field("extra_data", &self.extra_data.hex_dump()) + .finish() + } +} + +impl AudioParameters { + pub fn rfc6381_codec(&self) -> Option<&str> { + self.rfc6381_codec.as_deref() + } + + /// The length of each frame (in clock_rate units), if fixed. + pub fn frame_length(&self) -> Option { + self.frame_length + } + + pub fn clock_rate(&self) -> u32 { + self.clock_rate + } + + /// The codec-specific "extra data" to feed to eg ffmpeg to decode the audio. + /// * AAC: a serialized `AudioSpecificConfig`. + pub fn extra_data(&self) -> &Bytes { + &self.extra_data + } + + /// Builds an `.mp4` `SimpleAudioEntry` box (as defined in ISO/IEC 14496-12) if possible. + /// Not all codecs can be placed into a `.mp4` file, and even for supported codecs there + /// may be unsupported edge cases. + pub fn sample_entry(&self) -> Result { + aac::get_mp4a_box(self) + } +} + +/// An audio frame, which consists of one or more samples. +pub struct AudioFrame { + pub ctx: crate::Context, + pub stream_id: usize, + pub timestamp: crate::Timestamp, + pub frame_length: NonZeroU32, + + /// Number of lost RTP packets before this audio frame. See [crate::client::rtp::Packet::loss]. + /// Note that if loss occurs during a fragmented frame, more than this number of packets' worth + /// of data may be skipped. + pub loss: u16, + + // TODO: expose bytes or Buf (for zero-copy)? + pub data: Bytes, +} + +impl std::fmt::Debug for AudioFrame { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("AudioFrame") + .field("stream_id", &self.stream_id) + .field("ctx", &self.ctx) + .field("loss", &self.loss) + .field("timestamp", &self.timestamp) + .field("frame_length", &self.frame_length) + .field("data", &self.data.hex_dump()) + .finish() + } +} + +impl Buf for AudioFrame { + fn remaining(&self) -> usize { + self.data.remaining() + } + + fn chunk(&self) -> &[u8] { + self.data.chunk() + } + + fn advance(&mut self, cnt: usize) { + self.data.advance(cnt) + } +} + +#[derive(Clone, Debug)] +pub struct MessageParameters(onvif::CompressionType); + +pub struct MessageFrame { + pub ctx: crate::Context, + pub timestamp: crate::Timestamp, + pub stream_id: usize, + + /// Number of lost RTP packets before this message frame. See [crate::client::rtp::Packet::loss]. + /// If this is non-zero, a prefix of the message may be missing. + pub loss: u16, + + // TODO: expose bytes or Buf (for zero-copy)? + pub data: Bytes, +} + +impl std::fmt::Debug for MessageFrame { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("AudioFrame") + .field("ctx", &self.ctx) + .field("stream_id", &self.stream_id) + .field("loss", &self.loss) + .field("timestamp", &self.timestamp) + .field("data", &self.data.hex_dump()) + .finish() + } +} + +/// A single encoded video frame (aka picture, video sample, or video access unit). +/// +/// Use the [bytes::Buf] implementation to retrieve data. Durations aren't +/// specified here; they can be calculated from the timestamp of a following +/// picture, or approximated via the frame rate. +pub struct VideoFrame { + pub new_parameters: Option, + + /// Number of lost RTP packets before this video frame. See [crate::client::rtp::Packet::loss]. + /// Note that if loss occurs during a fragmented frame, more than this number of packets' worth + /// of data may be skipped. + pub loss: u16, + + // A pair of contexts: for the start and for the end. + // Having both can be useful to measure the total time elapsed while receiving the frame. + start_ctx: crate::Context, + end_ctx: crate::Context, + + /// This picture's timestamp in the time base associated with the stream. + pub timestamp: crate::Timestamp, + + pub stream_id: usize, + + /// If this is a "random access point (RAP)" aka "instantaneous decoding refresh (IDR)" picture. + /// The former is defined in ISO/IEC 14496-12; the latter in H.264. Both mean that this picture + /// can be decoded without any other AND no pictures following this one depend on any pictures + /// before this one. + pub is_random_access_point: bool, + + /// If no other pictures require this one to be decoded correctly. + /// In H.264 terms, this is a frame with `nal_ref_idc == 0`. + pub is_disposable: bool, + + /// Position within `concat(data_prefix, data)`. + pos: u32, + + data_prefix: [u8; 4], + + /// Frame content in the requested format. Currently in a single [bytes::Bytes] + /// allocation, but this may change when supporting H.264 partitioned slices + /// or if we revise the fragmentation implementation. + data: bytes::Bytes, +} + +impl VideoFrame { + pub fn start_ctx(&self) -> crate::Context { + self.start_ctx + } + + pub fn end_ctx(&self) -> crate::Context { + self.end_ctx + } +} + +impl std::fmt::Debug for VideoFrame { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + //use pretty_hex::PrettyHex; + f.debug_struct("VideoFrame") + .field("timestamp", &self.timestamp) + .field("start_ctx", &self.start_ctx) + .field("end_ctx", &self.end_ctx) + .field("loss", &self.loss) + .field("new_parameters", &self.new_parameters) + .field("is_random_access_point", &self.is_random_access_point) + .field("is_disposable", &self.is_disposable) + .field("pos", &self.pos) + .field("data_len", &(self.data.len() + 4)) + //.field("data", &self.data.hex_dump()) + .finish() + } +} + +impl bytes::Buf for VideoFrame { + fn remaining(&self) -> usize { + self.data.len() + 4 - (self.pos as usize) + } + + fn chunk(&self) -> &[u8] { + let pos = self.pos as usize; + if let Some(pos_within_data) = pos.checked_sub(4) { + &self.data[pos_within_data..] + } else { + &self.data_prefix[pos..] + } + } + + fn advance(&mut self, cnt: usize) { + assert!((self.pos as usize) + cnt <= 4 + self.data.len()); + self.pos += cnt as u32; + } + + fn chunks_vectored<'a>(&'a self, dst: &mut [std::io::IoSlice<'a>]) -> usize { + match dst.len() { + 0 => 0, + 1 => { + dst[0] = std::io::IoSlice::new(self.chunk()); + 1 + } + _ if self.pos < 4 => { + dst[0] = std::io::IoSlice::new(&self.data_prefix[self.pos as usize..]); + dst[1] = std::io::IoSlice::new(&self.data); + 2 + } + _ => { + dst[0] = std::io::IoSlice::new(&self.data[(self.pos - 4) as usize..]); + 1 + } + } + } +} + +#[derive(Debug)] +#[allow(clippy::clippy::large_enum_variant)] +pub(crate) enum Depacketizer { + Aac(aac::Depacketizer), + SimpleAudio(simple_audio::Depacketizer), + G723(g723::Depacketizer), + H264(h264::Depacketizer), + Onvif(onvif::Depacketizer), +} + +impl Depacketizer { + pub(crate) fn new( + media: &str, + encoding_name: &str, + clock_rate: u32, + channels: Option, + format_specific_params: Option<&str>, + ) -> Result { + use onvif::CompressionType; + + // RTP Payload Format Media Types + // https://www.iana.org/assignments/rtp-parameters/rtp-parameters.xhtml#rtp-parameters-2 + match (media, encoding_name) { + ("video", "h264") => Ok(Depacketizer::H264(h264::Depacketizer::new( + clock_rate, + format_specific_params, + )?)), + ("audio", "mpeg4-generic") => Ok(Depacketizer::Aac(aac::Depacketizer::new( + clock_rate, + channels, + format_specific_params, + )?)), + ("audio", "g726-16") => Ok(Depacketizer::SimpleAudio(simple_audio::Depacketizer::new( + clock_rate, 2, + ))), + ("audio", "g726-24") => Ok(Depacketizer::SimpleAudio(simple_audio::Depacketizer::new( + clock_rate, 3, + ))), + ("audio", "dvi4") | ("audio", "g726-32") => Ok(Depacketizer::SimpleAudio( + simple_audio::Depacketizer::new(clock_rate, 4), + )), + ("audio", "g726-40") => Ok(Depacketizer::SimpleAudio(simple_audio::Depacketizer::new( + clock_rate, 5, + ))), + ("audio", "pcma") | ("audio", "pcmu") | ("audio", "u8") | ("audio", "g722") => Ok( + Depacketizer::SimpleAudio(simple_audio::Depacketizer::new(clock_rate, 8)), + ), + ("audio", "l16") => Ok(Depacketizer::SimpleAudio(simple_audio::Depacketizer::new( + clock_rate, 16, + ))), + // Dahua cameras when configured with G723 send packets with a + // non-standard encoding-name "G723.1" and length 40, which doesn't + // make sense. Don't try to depacketize these. + ("audio", "g723") => Ok(Depacketizer::G723(g723::Depacketizer::new(clock_rate)?)), + ("application", "vnd.onvif.metadata") => Ok(Depacketizer::Onvif( + onvif::Depacketizer::new(CompressionType::Uncompressed), + )), + ("application", "vnd.onvif.metadata.gzip") => Ok(Depacketizer::Onvif( + onvif::Depacketizer::new(CompressionType::GzipCompressed), + )), + ("application", "vnd.onvif.metadata.exi.onvif") => Ok(Depacketizer::Onvif( + onvif::Depacketizer::new(CompressionType::ExiDefault), + )), + ("application", "vnd.onvif.metadata.exi.ext") => Ok(Depacketizer::Onvif( + onvif::Depacketizer::new(CompressionType::ExiInBand), + )), + (_, _) => { + log::info!( + "no depacketizer for media/encoding_name {}/{}", + media, + encoding_name + ); + bail!( + "no depacketizer for media/encoding_name {}/{}", + media, + encoding_name + ); + } + } + } + + pub(crate) fn parameters(&self) -> Option<&Parameters> { + match self { + Depacketizer::Aac(d) => d.parameters(), + Depacketizer::G723(d) => d.parameters(), + Depacketizer::H264(d) => d.parameters(), + Depacketizer::Onvif(d) => d.parameters(), + Depacketizer::SimpleAudio(d) => d.parameters(), + } + } + + pub(crate) fn push(&mut self, input: rtp::Packet) -> Result<(), Error> { + match self { + Depacketizer::Aac(d) => d.push(input), + Depacketizer::G723(d) => d.push(input), + Depacketizer::H264(d) => d.push(input), + Depacketizer::Onvif(d) => d.push(input), + Depacketizer::SimpleAudio(d) => d.push(input), + } + } + + pub(crate) fn pull(&mut self) -> Result, Error> { + match self { + Depacketizer::Aac(d) => d.pull(), + Depacketizer::G723(d) => d.pull(), + Depacketizer::H264(d) => d.pull(), + Depacketizer::Onvif(d) => d.pull(), + Depacketizer::SimpleAudio(d) => d.pull(), + } + } +} diff --git a/src/codec/onvif.rs b/src/codec/onvif.rs new file mode 100644 index 0000000..5ea8a81 --- /dev/null +++ b/src/codec/onvif.rs @@ -0,0 +1,129 @@ +// Copyright (C) 2021 Scott Lamb +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! ONVIF metadata streams. +//! +//! See the +//! [ONVIF Streaming Specification](https://www.onvif.org/specs/stream/ONVIF-Streaming-Spec.pdf) +//! version 19.12 section 5.2.1.1. The RTP layer muxing is simple: RTP packets with the MARK +//! bit set end messages. + +use bytes::{Buf, BufMut, BytesMut}; +use failure::{bail, Error}; + +use super::CodecItem; + +#[derive(Clone, Debug)] +pub enum CompressionType { + Uncompressed, + GzipCompressed, + ExiDefault, + ExiInBand, +} + +#[derive(Debug)] +pub(crate) struct Depacketizer { + parameters: super::Parameters, + state: State, + high_water_size: usize, +} + +#[derive(Debug)] +enum State { + Idle, + InProgress(InProgress), + Ready(super::MessageFrame), +} + +#[derive(Debug)] +struct InProgress { + ctx: crate::Context, + timestamp: crate::Timestamp, + data: BytesMut, + loss: u16, +} + +impl Depacketizer { + pub(super) fn new(compression_type: CompressionType) -> Self { + Depacketizer { + parameters: super::Parameters::Message(super::MessageParameters(compression_type)), + state: State::Idle, + high_water_size: 0, + } + } + + pub(super) fn parameters(&self) -> Option<&super::Parameters> { + Some(&self.parameters) + } + + pub(super) fn push(&mut self, pkt: crate::client::rtp::Packet) -> Result<(), failure::Error> { + if pkt.loss > 0 { + if let State::InProgress(in_progress) = &self.state { + log::debug!( + "Discarding {}-byte message prefix due to loss of {} RTP packets", + in_progress.data.len(), + pkt.loss + ); + self.state = State::Idle; + } + } + let mut in_progress = match std::mem::replace(&mut self.state, State::Idle) { + State::InProgress(in_progress) => { + if in_progress.timestamp.timestamp != pkt.timestamp.timestamp { + bail!( + "Timestamp changed from {} to {} (@ seq {:04x}) with message in progress", + &in_progress.timestamp, + &pkt.timestamp, + pkt.sequence_number + ); + } + in_progress + } + State::Ready(..) => panic!("push while in state ready"), + State::Idle => { + if pkt.mark { + // fast-path: avoid copy. + self.state = State::Ready(super::MessageFrame { + stream_id: pkt.stream_id, + loss: pkt.loss, + ctx: pkt.rtsp_ctx, + timestamp: pkt.timestamp, + data: pkt.payload, + }); + return Ok(()); + } + InProgress { + loss: pkt.loss, + ctx: pkt.rtsp_ctx, + timestamp: pkt.timestamp, + data: BytesMut::with_capacity(self.high_water_size), + } + } + }; + in_progress.data.put(pkt.payload); + if pkt.mark { + self.high_water_size = + std::cmp::max(self.high_water_size, in_progress.data.remaining()); + self.state = State::Ready(super::MessageFrame { + stream_id: pkt.stream_id, + ctx: in_progress.ctx, + timestamp: in_progress.timestamp, + data: in_progress.data.freeze(), + loss: in_progress.loss, + }); + } else { + self.state = State::InProgress(in_progress); + } + Ok(()) + } + + pub(super) fn pull(&mut self) -> Result, Error> { + Ok(match std::mem::replace(&mut self.state, State::Idle) { + State::Ready(message) => Some(CodecItem::MessageFrame(message)), + s => { + self.state = s; + None + } + }) + } +} diff --git a/src/codec/simple_audio.rs b/src/codec/simple_audio.rs new file mode 100644 index 0000000..eede742 --- /dev/null +++ b/src/codec/simple_audio.rs @@ -0,0 +1,76 @@ +// Copyright (C) 2021 Scott Lamb +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! Fixed-size audio sample codecs as defined in +//! [RFC 3551 section 4.5](https://datatracker.ietf.org/doc/html/rfc3551#section-4.5). + +use std::num::NonZeroU32; + +use bytes::Bytes; +use failure::format_err; +use failure::Error; + +use super::CodecItem; + +#[derive(Debug)] +pub(crate) struct Depacketizer { + parameters: super::Parameters, + pending: Option, + bits_per_sample: u32, +} + +impl Depacketizer { + /// Creates a new Depacketizer. + pub(super) fn new(clock_rate: u32, bits_per_sample: u32) -> Self { + Self { + parameters: super::Parameters::Audio(super::AudioParameters { + rfc6381_codec: None, + frame_length: None, // variable + clock_rate, + extra_data: Bytes::new(), + config: super::AudioCodecConfig::Other, + }), + bits_per_sample, + pending: None, + } + } + + pub(super) fn parameters(&self) -> Option<&super::Parameters> { + Some(&self.parameters) + } + + fn frame_length(&self, payload_len: usize) -> Option { + // This calculation could be strength-reduced but it's just once per frame anyway. + // Let's do it in a straightforward way. + assert!(payload_len < usize::from(u16::MAX)); + let bits = (payload_len) as u32 * 8; + match (bits % self.bits_per_sample) != 0 { + true => None, + false => NonZeroU32::new(bits / self.bits_per_sample), + } + } + + pub(super) fn push(&mut self, pkt: crate::client::rtp::Packet) -> Result<(), Error> { + assert!(self.pending.is_none()); + let frame_length = self.frame_length(pkt.payload.len()).ok_or_else(|| { + format_err!( + "invalid length {} for payload of {}-bit audio samples", + pkt.payload.len(), + self.bits_per_sample + ) + })?; + self.pending = Some(super::AudioFrame { + loss: pkt.loss, + ctx: pkt.rtsp_ctx, + stream_id: pkt.stream_id, + timestamp: pkt.timestamp, + frame_length, + data: pkt.payload, + }); + Ok(()) + } + + pub(super) fn pull(&mut self) -> Result, Error> { + Ok(self.pending.take().map(CodecItem::AudioFrame)) + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..ba3c297 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,310 @@ +// Copyright (C) 2021 Scott Lamb +// SPDX-License-Identifier: MIT OR Apache-2.0 + +use bytes::{Buf, BufMut, Bytes, BytesMut}; +use failure::{bail, format_err, Error}; +use once_cell::sync::Lazy; +use rtsp_types::Message; +use std::convert::TryFrom; +use std::fmt::{Debug, Display}; +use std::num::NonZeroU32; + +pub mod client; +pub mod codec; + +pub static X_ACCEPT_DYNAMIC_RATE: Lazy = Lazy::new(|| { + rtsp_types::HeaderName::from_static_str("x-Accept-Dynamic-Rate").expect("is ascii") +}); +pub static X_DYNAMIC_RATE: Lazy = + Lazy::new(|| rtsp_types::HeaderName::from_static_str("x-Dynamic-Rate").expect("is ascii")); + +#[derive(Debug)] +pub struct ReceivedMessage { + pub ctx: Context, + pub msg: Message, +} + +/// A monotonically increasing timestamp within an RTP stream. +/// The [Display] and [Debug] implementations display: +/// * the bottom 32 bits, as seen in RTP packet headers. This advances at a +/// codec-specified clock rate. +/// * the full timestamp, with top bits accumulated as RTP packet timestamps wrap around. +/// * a conversion to RTSP "normal play time" (NPT): zero-based and normalized to seconds. +#[derive(Copy, Clone)] +pub struct Timestamp { + /// A timestamp which must be compared to `start`. The top bits are inferred + /// from wraparounds of 32-bit RTP timestamps. The `i64` itself is not + /// allowed to overflow/underflow; similarly `timestamp - start` is not + /// allowed to underflow. + timestamp: i64, + + /// The codec-specified clock rate, in Hz. Must be non-zero. + clock_rate: NonZeroU32, + + /// The stream's starting time, as specified in the RTSP `RTP-Info` header. + start: u32, +} + +impl Timestamp { + /// Returns time since some arbitrary point before the stream started. + #[inline] + pub fn timestamp(&self) -> i64 { + self.timestamp + } + + /// Returns timestamp of the start of the stream. + #[inline] + pub fn start(&self) -> u32 { + self.start + } + + /// Returns codec-specified clock rate, in Hz. + #[inline] + pub fn clock_rate(&self) -> NonZeroU32 { + self.clock_rate + } + + /// Returns elapsed time since the stream start in clock rate units. + #[inline] + pub fn elapsed(&self) -> i64 { + self.timestamp - i64::from(self.start) + } + + /// Returns elapsed time since the stream start in seconds, aka "normal play + /// time" (NPT). + #[inline] + pub fn elapsed_secs(&self) -> f64 { + (self.elapsed() as f64) / (self.clock_rate.get() as f64) + } + + pub fn try_add(&self, delta: u32) -> Result { + // Check for `timestamp` overflow only. We don't need to check for + // `timestamp - start` underflow because delta is non-negative. + Ok(Timestamp { + timestamp: self + .timestamp + .checked_add(i64::from(delta)) + .ok_or_else(|| format_err!("overflow on {:?} + {}", &self, delta))?, + clock_rate: self.clock_rate, + start: self.start, + }) + } +} + +impl Display for Timestamp { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{} (mod-2^32: {}), npt {:.03}", + self.timestamp, + self.timestamp as u32, + self.elapsed_secs() + ) + } +} + +impl Debug for Timestamp { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + Display::fmt(self, f) + } +} + +pub const UNIX_EPOCH: NtpTimestamp = NtpTimestamp((2_208_988_800) << 32); + +/// A wallclock time represented using the format of the Network Time Protocol. +/// This isn't necessarily gathered from a real NTP server. Reported NTP +/// timestamps are allowed to jump backwards and/or be complete nonsense. +#[derive(Copy, Clone, PartialEq, PartialOrd, Eq, Ord)] +pub struct NtpTimestamp(pub u64); + +impl std::fmt::Display for NtpTimestamp { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let since_epoch = self.0.wrapping_sub(UNIX_EPOCH.0); + let sec_since_epoch = (since_epoch >> 32) as u32; + let tm = time::at(time::Timespec { + sec: i64::from(sec_since_epoch), + nsec: 0, + }); + let ms = ((since_epoch & 0xFFFF_FFFF) * 1_000) >> 32; + let zone_minutes = tm.tm_utcoff.abs() / 60; + write!( + f, + "{}.{:03}{}{:02}:{:02}", + tm.strftime("%FT%T").map_err(|_| std::fmt::Error)?, + ms, + if tm.tm_utcoff > 0 { '+' } else { '-' }, + zone_minutes / 60, + zone_minutes % 60 + ) + } +} + +impl std::fmt::Debug for NtpTimestamp { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // Write both the raw and display forms. + write!(f, "{} /* {} */", self.0, self) + } +} + +/// Context of a received message within an RTSP stream. +/// This is meant to help find the correct TCP stream and packet in a matching +/// packet capture. +#[derive(Copy, Clone)] +pub struct Context { + conn_local_addr: std::net::SocketAddr, + conn_peer_addr: std::net::SocketAddr, + conn_established_wall: time::Timespec, + conn_established: std::time::Instant, + + /// The byte position within the input stream. The bottom 32 bits can be + /// compared to the TCP sequence number. + msg_pos: u64, + + /// Time when the application parsed the message. Caveat: this may not + /// closely match the time on a packet capture if the application is + /// overloaded (or `CLOCK_REALTIME` jumps). + msg_received_wall: time::Timespec, + msg_received: std::time::Instant, +} + +impl Context { + pub fn conn_established(&self) -> std::time::Instant { + self.conn_established + } + + pub fn msg_received(&self) -> std::time::Instant { + self.msg_received + } + + pub fn msg_pos(&self) -> u64 { + self.msg_pos + } +} + +impl Debug for Context { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // TODO: this current hardcodes the assumption we are the client. + // Change if/when adding server code. + write!( + f, + "[{}(me)->{}@{} pos={}@{}]", + &self.conn_local_addr, + &self.conn_peer_addr, + time::at(self.conn_established_wall) + .strftime("%FT%T") + .map_err(|_| std::fmt::Error)?, + self.msg_pos, + time::at(self.msg_received_wall) + .strftime("%FT%T") + .map_err(|_| std::fmt::Error)? + ) + } +} + +struct Codec { + ctx: Context, +} + +/// Returns the range within `buf` that represents `subset`. +/// If `subset` is empty, returns None; otherwise panics if `subset` is not within `buf`. +pub(crate) fn as_range(buf: &[u8], subset: &[u8]) -> Option> { + if subset.is_empty() { + return None; + } + let subset_p = subset.as_ptr() as usize; + let buf_p = buf.as_ptr() as usize; + let off = match subset_p.checked_sub(buf_p) { + Some(off) => off, + None => panic!( + "{}-byte subset not within {}-byte buf", + subset.len(), + buf.len() + ), + }; + let end = off + subset.len(); + assert!(end <= buf.len()); + Some(off..end) +} + +impl tokio_util::codec::Decoder for Codec { + type Item = ReceivedMessage; + type Error = failure::Error; + + fn decode(&mut self, src: &mut BytesMut) -> Result, Self::Error> { + let (msg, len): (Message<&[u8]>, _) = match rtsp_types::Message::parse(src) { + Ok((m, l)) => (m, l), + Err(rtsp_types::ParseError::Error) => bail!("RTSP parse error: {:#?}", &self.ctx), + Err(rtsp_types::ParseError::Incomplete) => return Ok(None), + }; + + // Map msg's body to a Bytes representation and advance `src`. Awkward: + // 1. lifetime concerns require mapping twice: first so the message + // doesn't depend on the BytesMut, which needs to be split/advanced; + // then to get the proper Bytes body in place post-split. + // 2. rtsp_types messages must be AsRef<[u8]>, so we can't use the + // range as an intermediate body. + // 3. within a match because the rtsp_types::Message enum itself + // doesn't have body/replace_body/map_body methods. + let msg = match msg { + Message::Request(msg) => { + let body_range = as_range(src, msg.body()); + let msg = msg.replace_body(rtsp_types::Empty); + if let Some(r) = body_range { + let mut raw_msg = src.split_to(len); + raw_msg.advance(r.start); + raw_msg.truncate(r.len()); + Message::Request(msg.replace_body(raw_msg.freeze())) + } else { + src.advance(len); + Message::Request(msg.replace_body(Bytes::new())) + } + } + Message::Response(msg) => { + let body_range = as_range(src, msg.body()); + let msg = msg.replace_body(rtsp_types::Empty); + if let Some(r) = body_range { + let mut raw_msg = src.split_to(len); + raw_msg.advance(r.start); + raw_msg.truncate(r.len()); + Message::Response(msg.replace_body(raw_msg.freeze())) + } else { + src.advance(len); + Message::Response(msg.replace_body(Bytes::new())) + } + } + Message::Data(msg) => { + let body_range = as_range(src, msg.as_slice()); + let msg = msg.replace_body(rtsp_types::Empty); + if let Some(r) = body_range { + let mut raw_msg = src.split_to(len); + raw_msg.advance(r.start); + raw_msg.truncate(r.len()); + Message::Data(msg.replace_body(raw_msg.freeze())) + } else { + src.advance(len); + Message::Data(msg.replace_body(Bytes::new())) + } + } + }; + self.ctx.msg_received_wall = time::get_time(); + self.ctx.msg_received = std::time::Instant::now(); + let msg = ReceivedMessage { ctx: self.ctx, msg }; + self.ctx.msg_pos += u64::try_from(len).expect("usize fits in u64"); + Ok(Some(msg)) + } +} + +impl tokio_util::codec::Encoder> for Codec { + type Error = failure::Error; + + fn encode( + &mut self, + item: rtsp_types::Message, + dst: &mut BytesMut, + ) -> Result<(), Self::Error> { + let mut w = std::mem::replace(dst, BytesMut::new()).writer(); + item.write(&mut w).expect("bytes Writer is infallible"); + *dst = w.into_inner(); + Ok(()) + } +}