From b5e8d7174c2749b84b9708f9fffb026a9d853f1c Mon Sep 17 00:00:00 2001 From: Florian Egger Date: Mon, 7 Sep 2026 10:48:17 +0200 Subject: [PATCH] feat(network): implement Phase 4 RTP framing with FU-A fragmentation Add the sc_network library: RFC 3550 RtpHeader/RtpPacket serialize and parse (the receiver tolerates CSRC lists, extension headers, and padding by skipping/stripping them) and RFC 6184 H.264 payloading via H264Packetizer/H264Depacketizer. The packetizer splits Annex-B frames into NAL units (3- and 4-byte start codes), emitting single-NAL packets or FU-A fragments within the configured MTU, with the marker bit closing each frame and randomized SSRC/sequence by default. The depacketizer reassembles access units with 3-byte start codes, so both start-code widths round-trip byte-exactly; frames damaged by sequence gaps or missing fragments are dropped until the Phase 7 loss-recovery work. test_rtp covers header and packet round-trips, malformed-input rejections, splitter behavior, FU-A chunk bounds, full packetize -> depacketize round-trip, gap dropping, marker-only frame separation, sequence wrap, and empty inputs. meson test 3/3, valgrind clean. --- .agents/MEMORY.md | 24 +- docs/PHASES.md | 8 +- include/screencast/network/h264_packetizer.h | 66 ++++ src/meson.build | 2 + src/network/h264_packetizer.cpp | 245 ++++++++++++ src/network/meson.build | 14 + src/network/rtp_packet.cpp | 136 +++++++ tests/meson.build | 6 + tests/network/test_rtp.cpp | 378 +++++++++++++++++++ 9 files changed, 870 insertions(+), 9 deletions(-) create mode 100644 include/screencast/network/h264_packetizer.h create mode 100644 src/network/h264_packetizer.cpp create mode 100644 src/network/meson.build create mode 100644 src/network/rtp_packet.cpp create mode 100644 tests/network/test_rtp.cpp diff --git a/.agents/MEMORY.md b/.agents/MEMORY.md index 7b420f3..98180b2 100644 --- a/.agents/MEMORY.md +++ b/.agents/MEMORY.md @@ -1,13 +1,27 @@ # Project Memory — screen_cast -Last updated: Phase 3 validated and complete; current phase is Phase 4. +Last updated: Phase 4 (RTP framing) complete and tested; current phase is +Phase 5. ## Project state -- **Phase 3 is done and validated on the desktop**: a manual - `./build/tools/capture_smoke 10 out.h264` run on Wayland/Hyprland produced - a valid 2256x1504 H.264 elementary stream (ffprobe clean). `docs/PHASES.md` - is ticked; current phase is Phase 4 — RTP framing. +- Phase 4 done: `sc_network` library implements `RtpHeader`/`RtpPacket` + (RFC 3550 serialize/parse; tolerates CSRC lists, extension headers, and + padding on the receive side) plus `H264Packetizer`/`H264Depacketizer` + (RFC 6184 single-NAL + FU-A; STAP-A never emitted, unsupported types mark + the frame damaged on receive). +- Depacketized access units use **3-byte start codes**, and the splitter + keeps a zero byte preceding a start code with the previous NAL, so both + 3- and 4-byte-start-code streams round-trip byte-exactly (tested). +- Loss handling is drop-on-damage: a sequence gap or missing FU fragment + marks the frame damaged and it is dropped silently at its marker. Full + loss recovery / jitter handling is Phase 7. +- `test_rtp` covers header/packet round-trips, malformed rejections, NAL + splitting (3- and 4-byte codes), FU-A chunking with MTU bounds, full + packetize→depacketize round-trip, gap dropping, frame separation by + marker alone, sequence wrap, and random default SSRC/sequence. + Valgrind-clean; `meson test` 3/3. +- Phase 3 remains validated; earlier review fixes still in place. - The first smoke run emitted `impl_ext_end_proxy called from wrong context` warnings: `pw_context_connect_fd` and `pw_core_disconnect` ran outside the thread-loop lock. Fixed by holding the lock across all pw setup/teardown diff --git a/docs/PHASES.md b/docs/PHASES.md index b95fed0..290f9d6 100644 --- a/docs/PHASES.md +++ b/docs/PHASES.md @@ -44,9 +44,9 @@ H.264 bitstream. **Goal**: packetize NAL units into RTP and depacketize them. -- Implement `RtpHeader` and `RtpPacket` serialize/parse. -- Add H.264 NAL splitting and FU-A fragmentation. -- Unit test for serialization, fragmentation, and reassembly. +- [x] Implement `RtpHeader` and `RtpPacket` serialize/parse. +- [x] Add H.264 NAL splitting and FU-A fragmentation. +- [x] Unit test for serialization, fragmentation, and reassembly. **Validation**: unit tests cover single-NAL and fragmented packet paths. @@ -89,4 +89,4 @@ where available. ## Current phase -Phase 4 — RTP Framing. +Phase 5 — Local UDP Sender → Receiver Loopback. diff --git a/include/screencast/network/h264_packetizer.h b/include/screencast/network/h264_packetizer.h new file mode 100644 index 0000000..526d497 --- /dev/null +++ b/include/screencast/network/h264_packetizer.h @@ -0,0 +1,66 @@ +#pragma once + +#include "screencast/network/rtp_packet.h" + +#include +#include +#include +#include + +namespace sc { + +struct EncodedFrame; + +// Smallest usable MTU: 12-byte RTP header + 2-byte FU-A prefix + 1 data byte. +inline constexpr std::size_t kMinimumRtpMtu = 15; + +struct RtpPacketizerConfig { + // Zero values ask the packetizer to choose random values, as recommended + // by RFC 3550 for SSRC and the initial sequence number. + std::uint32_t ssrc = 0; + std::uint16_t initial_sequence_number = 0; + std::uint8_t payload_type = 96; // dynamic payload type range + std::size_t mtu = 1200; // maximum size of a serialized RTP packet +}; + +// Splits Annex-B encoded frames into RFC 6184 RTP packets using single NAL +// unit packets and FU-A fragmentation. STAP-A is never emitted. +class H264Packetizer { + public: + explicit H264Packetizer(const RtpPacketizerConfig& config = RtpPacketizerConfig{}); + + // One encoded frame becomes one or more packets sharing the frame's RTP + // timestamp; the final packet carries the marker bit. Returns an empty + // vector for frames without NAL units or for an unusable MTU. + std::vector packetize(const EncodedFrame& frame); + + private: + RtpPacket next_packet(uint32_t timestamp); + void append_fu_a_packets(std::span nal, uint32_t timestamp, std::vector& out); + + RtpPacketizerConfig config_; + std::uint16_t next_sequence_number_ = 0; +}; + +// Reassembles RFC 6184 packet streams (single NAL unit packets and FU-A) +// into Annex-B access units. Packets must arrive in order; frames damaged by +// sequence gaps or missing fragments are dropped silently. +class H264Depacketizer { + public: + // Feed one packet. Returns the completed access unit (Annex-B with 3-byte + // start codes) when the packet closes a frame, nullopt otherwise. + std::optional> depacketize(const RtpPacket& packet); + + private: + void drop_frame(); + + std::optional last_sequence_number_; + bool frame_started_ = false; + bool frame_damaged_ = false; + std::uint32_t frame_timestamp_ = 0; + std::vector access_unit_; + bool fu_active_ = false; + std::vector fu_nal_; +}; + +} // namespace sc diff --git a/src/meson.build b/src/meson.build index f1dd981..7e510b8 100644 --- a/src/meson.build +++ b/src/meson.build @@ -22,3 +22,5 @@ sc_codec_dep = declare_dependency( link_with : sc_codec, include_directories : sc_core_inc, dependencies : [dep_avcodec, dep_avutil, dep_swscale]) + +subdir('network') diff --git a/src/network/h264_packetizer.cpp b/src/network/h264_packetizer.cpp new file mode 100644 index 0000000..6889d23 --- /dev/null +++ b/src/network/h264_packetizer.cpp @@ -0,0 +1,245 @@ +#include "screencast/network/h264_packetizer.h" + +#include "screencast/codec/encoder.h" + +#include +#include + +namespace sc { +namespace { + +constexpr uint8_t u8(std::byte value) noexcept { + return std::to_integer(value); +} + +constexpr std::byte b8(uint8_t value) noexcept { + return std::byte{value}; +} + +constexpr uint8_t kFuA = 28; + +std::optional find_start_code(std::span data, std::size_t from) noexcept { + for (std::size_t i = from; i + 3 <= data.size(); ++i) { + if (u8(data[i]) == 0x00 && u8(data[i + 1]) == 0x00 && u8(data[i + 2]) == 0x01) { + return i; + } + } + return std::nullopt; +} + +// Split an Annex-B stream into NAL unit byte ranges (start codes removed). +// A zero byte directly preceding a start code stays with the previous NAL: +// it is either its trailing zero padding or part of a 4-byte start code, and +// keeping it makes streams with 4-byte start codes round-trip byte-exactly. +std::vector> split_annex_b_nal_units(std::span data) { + std::vector> units; + std::optional start = find_start_code(data, 0); + while (start.has_value()) { + const std::size_t unit_begin = *start + 3; + const std::optional next = find_start_code(data, unit_begin); + const std::size_t unit_end = next.value_or(data.size()); + if (unit_end > unit_begin) { + units.push_back(data.subspan(unit_begin, unit_end - unit_begin)); + } + start = next; + } + return units; +} + +void append_start_code(std::vector& out) { + out.push_back(b8(0x00)); + out.push_back(b8(0x00)); + out.push_back(b8(0x01)); +} + +uint32_t random_u32() { + static std::mt19937 engine{std::random_device{}()}; + return static_cast(engine()); +} + +} // namespace + +H264Packetizer::H264Packetizer(const RtpPacketizerConfig& config) : config_(config) { + if (config_.ssrc == 0) { + config_.ssrc = random_u32(); + } + next_sequence_number_ = config_.initial_sequence_number != 0 ? config_.initial_sequence_number + : static_cast(random_u32()); +} + +std::vector H264Packetizer::packetize(const EncodedFrame& frame) { + std::vector packets; + if (config_.mtu < kMinimumRtpMtu || frame.data.empty()) { + return packets; + } + + const std::vector> units = split_annex_b_nal_units(frame.data); + const std::size_t max_single_nal = config_.mtu - 12; + + for (const std::span unit : units) { + if (unit.empty()) { + continue; + } + if (unit.size() <= max_single_nal) { + RtpPacket packet = next_packet(frame.rtp_timestamp); + packet.payload.assign(unit.begin(), unit.end()); + packets.push_back(std::move(packet)); + } else { + append_fu_a_packets(unit, frame.rtp_timestamp, packets); + } + } + + if (!packets.empty()) { + packets.back().header.marker = true; + } + return packets; +} + +RtpPacket H264Packetizer::next_packet(uint32_t timestamp) { + RtpPacket packet; + packet.header.version = 2; + packet.header.payload_type = config_.payload_type; + packet.header.sequence_number = next_sequence_number_++; + packet.header.timestamp = timestamp; + packet.header.ssrc = config_.ssrc; + return packet; +} + +void H264Packetizer::append_fu_a_packets(std::span nal, + uint32_t timestamp, + std::vector& out) { + const std::size_t max_chunk = config_.mtu - 12 - 2; + const uint8_t nal_header = u8(nal.front()); + // The FU indicator keeps the original NAL's F bit (0) and NRI, and + // declares type 28; the FU header carries S/E flags plus the real type. + const uint8_t fu_indicator = static_cast((nal_header & 0xE0) | kFuA); + const uint8_t nal_type = static_cast(nal_header & 0x1F); + const std::span payload = nal.subspan(1); + + std::size_t offset = 0; + bool first = true; + while (true) { + const std::size_t chunk = std::min(payload.size() - offset, max_chunk); + const bool last = offset + chunk == payload.size(); + + RtpPacket packet = next_packet(timestamp); + packet.payload.reserve(2 + chunk); + packet.payload.push_back(b8(fu_indicator)); + packet.payload.push_back(b8(static_cast((first ? 0x80 : 0x00) | (last ? 0x40 : 0x00) | nal_type))); + packet.payload.insert(packet.payload.end(), + payload.begin() + static_cast(offset), + payload.begin() + static_cast(offset + chunk)); + out.push_back(std::move(packet)); + + offset += chunk; + first = false; + if (last) { + break; + } + } +} + +std::optional> H264Depacketizer::depacketize(const RtpPacket& packet) { + // Track sequence continuity: a gap means packets were lost. + if (last_sequence_number_.has_value()) { + const std::uint16_t expected = static_cast(*last_sequence_number_ + 1); + if (packet.header.sequence_number != expected) { + fu_active_ = false; + fu_nal_.clear(); + if (frame_started_) { + frame_damaged_ = true; + } + } + } + last_sequence_number_ = packet.header.sequence_number; + + // A timestamp change without a closing marker means the previous frame + // lost its tail and can no longer be recovered. + if (frame_started_ && packet.header.timestamp != frame_timestamp_) { + drop_frame(); + } + if (!frame_started_) { + frame_started_ = true; + frame_damaged_ = false; + frame_timestamp_ = packet.header.timestamp; + access_unit_.clear(); + } + + if (!packet.payload.empty()) { + const uint8_t type = static_cast(u8(packet.payload.front()) & 0x1F); + if (type >= 1 && type <= 23) { + // Single NAL unit packet. + if (fu_active_) { + // The previous fragmented NAL never received its end packet. + frame_damaged_ = true; + fu_active_ = false; + fu_nal_.clear(); + } + append_start_code(access_unit_); + access_unit_.insert(access_unit_.end(), packet.payload.begin(), packet.payload.end()); + } else if (type == kFuA) { + if (packet.payload.size() < 2) { + frame_damaged_ = true; + } else { + const uint8_t fu_header = u8(packet.payload[1]); + const bool start = (fu_header & 0x80) != 0; + const bool end = (fu_header & 0x40) != 0; + const std::span fragment = std::span{packet.payload}.subspan(2); + if (start) { + if (fu_active_) { + // The previous fragmented NAL lost its end packet. + frame_damaged_ = true; + } + fu_active_ = true; + fu_nal_.clear(); + fu_nal_.push_back( + b8(static_cast((u8(packet.payload.front()) & 0xE0) | (fu_header & 0x1F)))); + fu_nal_.insert(fu_nal_.end(), fragment.begin(), fragment.end()); + } else if (!fu_active_) { + // Continuation without a start: the head of the NAL is lost. + frame_damaged_ = true; + } else { + fu_nal_.insert(fu_nal_.end(), fragment.begin(), fragment.end()); + if (end) { + append_start_code(access_unit_); + access_unit_.insert(access_unit_.end(), fu_nal_.begin(), fu_nal_.end()); + fu_active_ = false; + fu_nal_.clear(); + } + } + } + } else { + // Unsupported packetization mode (STAP-A, MTAP, FU-B): the frame + // cannot be reconstructed. + frame_damaged_ = true; + } + } + + if (!packet.header.marker) { + return std::nullopt; + } + + if (fu_active_) { + // The marker arrived while a NAL was still fragmented. + frame_damaged_ = true; + fu_active_ = false; + fu_nal_.clear(); + } + + std::optional> result; + if (!frame_damaged_ && !access_unit_.empty()) { + result = std::move(access_unit_); + } + drop_frame(); + return result; +} + +void H264Depacketizer::drop_frame() { + frame_started_ = false; + frame_damaged_ = false; + access_unit_.clear(); + fu_active_ = false; + fu_nal_.clear(); +} + +} // namespace sc diff --git a/src/network/meson.build b/src/network/meson.build new file mode 100644 index 0000000..8633e5e --- /dev/null +++ b/src/network/meson.build @@ -0,0 +1,14 @@ +# Phase 4 RTP framing. Pure C++ with no external dependencies. + +sc_network_sources = files( + 'rtp_packet.cpp', + 'h264_packetizer.cpp', +) + +sc_network = static_library('sc_network', + sc_network_sources, + include_directories : sc_core_inc) + +sc_network_dep = declare_dependency( + link_with : sc_network, + include_directories : sc_core_inc) \ No newline at end of file diff --git a/src/network/rtp_packet.cpp b/src/network/rtp_packet.cpp new file mode 100644 index 0000000..4f71ce8 --- /dev/null +++ b/src/network/rtp_packet.cpp @@ -0,0 +1,136 @@ +#include "screencast/network/rtp_packet.h" + +#include +#include + +namespace sc { +namespace { + +constexpr uint8_t u8(std::byte value) noexcept { + return std::to_integer(value); +} + +constexpr std::byte b8(uint8_t value) noexcept { + return std::byte{value}; +} + +void store_u16(std::span out, std::size_t offset, uint16_t value) noexcept { + out[offset] = b8(static_cast(value >> 8)); + out[offset + 1] = b8(static_cast(value)); +} + +void store_u32(std::span out, std::size_t offset, uint32_t value) noexcept { + out[offset] = b8(static_cast(value >> 24)); + out[offset + 1] = b8(static_cast(value >> 16)); + out[offset + 2] = b8(static_cast(value >> 8)); + out[offset + 3] = b8(static_cast(value)); +} + +uint16_t load_u16(std::span in, std::size_t offset) noexcept { + return static_cast((static_cast(u8(in[offset])) << 8) | u8(in[offset + 1])); +} + +uint32_t load_u32(std::span in, std::size_t offset) noexcept { + return (static_cast(u8(in[offset])) << 24) | (static_cast(u8(in[offset + 1])) << 16) | + (static_cast(u8(in[offset + 2])) << 8) | static_cast(u8(in[offset + 3])); +} + +} // namespace + +bool RtpHeader::serialize(std::span out) const noexcept { + // The fixed-extent view can only carry the bare 12-byte header. + if (version != 2 || csrc_count != 0 || extension) { + return false; + } + out[0] = b8(static_cast((version << 6) | (padding ? 0x20 : 0) | csrc_count)); + out[1] = b8(static_cast((marker ? 0x80 : 0) | (payload_type & 0x7F))); + store_u16(out, 2, sequence_number); + store_u32(out, 4, timestamp); + store_u32(out, 8, ssrc); + return true; +} + +std::optional RtpHeader::parse(std::span in) noexcept { + RtpHeader header; + header.version = static_cast(u8(in[0]) >> 6); + if (header.version != 2) { + return std::nullopt; + } + header.padding = (u8(in[0]) & 0x20) != 0; + header.extension = (u8(in[0]) & 0x10) != 0; + header.csrc_count = static_cast(u8(in[0]) & 0x0F); + header.marker = (u8(in[1]) & 0x80) != 0; + header.payload_type = static_cast(u8(in[1]) & 0x7F); + header.sequence_number = load_u16(in, 2); + header.timestamp = load_u32(in, 4); + header.ssrc = load_u32(in, 8); + return header; +} + +std::vector RtpPacket::serialize() const { + std::vector bytes; + // This sender never emits CSRC lists, extension headers, or padding, so + // those flags must stay clear or the wire format could not be honored. + if (header.csrc_count != 0 || header.extension || header.padding) { + return bytes; + } + std::array buffer{}; + if (!header.serialize(buffer)) { + return bytes; + } + bytes.reserve(buffer.size() + payload.size()); + bytes.insert(bytes.end(), buffer.begin(), buffer.end()); + bytes.insert(bytes.end(), payload.begin(), payload.end()); + return bytes; +} + +std::optional RtpPacket::parse(std::span in) noexcept { + if (in.size() < 12) { + return std::nullopt; + } + const std::optional header = RtpHeader::parse(in.first<12>()); + if (!header.has_value()) { + return std::nullopt; + } + + std::size_t offset = 12 + static_cast(header->csrc_count) * 4; + if (in.size() < offset) { + return std::nullopt; + } + + if (header->extension) { + // Skip profile (2 bytes) + length in 32-bit words (2 bytes) + data. + if (in.size() < offset + 4) { + return std::nullopt; + } + const uint16_t extension_words = + static_cast((static_cast(u8(in[offset + 2])) << 8) | u8(in[offset + 3])); + const std::size_t extension_bytes = 4 + static_cast(extension_words) * 4; + if (in.size() < offset + extension_bytes) { + return std::nullopt; + } + offset += extension_bytes; + } + + std::size_t payload_size = in.size() - offset; + if (header->padding) { + // RFC 3550: the last byte of the packet holds the padding size, + // which includes itself. + if (payload_size == 0) { + return std::nullopt; + } + const uint8_t padding_size = u8(in.back()); + if (padding_size == 0 || padding_size > payload_size) { + return std::nullopt; + } + payload_size -= padding_size; + } + + RtpPacket packet; + packet.header = *header; + packet.payload.assign(in.begin() + static_cast(offset), + in.begin() + static_cast(offset + payload_size)); + return packet; +} + +} // namespace sc diff --git a/tests/meson.build b/tests/meson.build index 03eff97..72c0ce9 100644 --- a/tests/meson.build +++ b/tests/meson.build @@ -9,3 +9,9 @@ test_codec_roundtrip = executable('test_codec_roundtrip', dependencies : sc_codec_dep) test('h264 roundtrip', test_codec_roundtrip) + +test_rtp = executable('test_rtp', + 'network/test_rtp.cpp', + dependencies : sc_network_dep) + +test('rtp framing', test_rtp) diff --git a/tests/network/test_rtp.cpp b/tests/network/test_rtp.cpp new file mode 100644 index 0000000..4b8c35e --- /dev/null +++ b/tests/network/test_rtp.cpp @@ -0,0 +1,378 @@ +#include "screencast/codec/encoder.h" +#include "screencast/network/h264_packetizer.h" +#include "screencast/network/rtp_packet.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +[[noreturn]] void fail(const char* what) { + std::fprintf(stderr, "test_rtp: FAIL: %s\n", what); + std::abort(); +} + +void check(bool condition, const char* what) { + if (!condition) { + fail(what); + } +} + +std::vector bytes(std::initializer_list values) { + std::vector out; + out.reserve(values.size()); + for (const int value : values) { + out.push_back(std::byte{static_cast(value)}); + } + return out; +} + +void append_bytes(std::vector& destination, std::initializer_list values) { + for (const int value : values) { + destination.push_back(std::byte{static_cast(value)}); + } +} + +bool equal_bytes(const std::vector& lhs, const std::vector& rhs) { + return lhs.size() == rhs.size() && std::equal(lhs.begin(), lhs.end(), rhs.begin()); +} + +std::uint8_t u8(std::byte value) { + return std::to_integer(value); +} + +// Joins NAL units with 3-byte start codes, the canonical form the +// depacketizer reconstructs. +std::vector annex_b(const std::vector>& nals) { + std::vector out; + for (const std::vector& nal : nals) { + append_bytes(out, {0x00, 0x00, 0x01}); + out.insert(out.end(), nal.begin(), nal.end()); + } + return out; +} + +std::vector nal(int header, std::size_t size) { + std::vector out; + out.reserve(size); + out.push_back(std::byte{static_cast(header)}); + for (std::size_t i = 1; i < size; ++i) { + out.push_back(std::byte{static_cast((i * 7 + 1) & 0xFF)}); + } + return out; +} + +sc::EncodedFrame make_frame(const std::vector& data, std::uint32_t rtp_timestamp) { + sc::EncodedFrame frame; + frame.data = data; + frame.rtp_timestamp = rtp_timestamp; + frame.is_keyframe = true; + return frame; +} + +sc::RtpPacketizerConfig test_config(std::uint16_t initial_sequence, std::size_t mtu) { + sc::RtpPacketizerConfig config; + config.ssrc = 0x12345678; + config.initial_sequence_number = initial_sequence; + config.mtu = mtu; + return config; +} + +void test_header_roundtrip() { + sc::RtpHeader header; + header.marker = true; + header.payload_type = 97; + header.sequence_number = 0xABCD; + header.timestamp = 0x11223344; + header.ssrc = 0xDEADBEEF; + + std::array buffer{}; + check(header.serialize(buffer), "header serialize"); + const std::optional parsed = sc::RtpHeader::parse(buffer); + check(parsed.has_value(), "header parse"); + check(parsed->version == 2, "version"); + check(parsed->marker, "marker"); + check(parsed->payload_type == 97, "payload type"); + check(parsed->sequence_number == 0xABCD, "sequence number"); + check(parsed->timestamp == 0x11223344, "timestamp"); + check(parsed->ssrc == 0xDEADBEEF, "ssrc"); + check(!parsed->padding && !parsed->extension && parsed->csrc_count == 0, "flags"); +} + +void test_header_rejections() { + std::array buffer{}; + + sc::RtpHeader bad_version; + bad_version.version = 3; + check(!bad_version.serialize(buffer), "reject version 3"); + + sc::RtpHeader bad_csrc; + bad_csrc.csrc_count = 2; + check(!bad_csrc.serialize(buffer), "reject csrc count"); + + sc::RtpHeader bad_extension; + bad_extension.extension = true; + check(!bad_extension.serialize(buffer), "reject extension flag"); + + const std::array zeros{}; // version 0 on the wire + check(!sc::RtpHeader::parse(zeros).has_value(), "reject version 0 input"); +} + +void test_packet_roundtrip() { + sc::RtpPacket packet; + packet.header.sequence_number = 7; + packet.header.timestamp = 0x0A0B0C0D; + packet.header.ssrc = 0x01020304; + packet.payload = bytes({0x67, 0x42, 0x00, 0x01, 0xFF}); + + const std::vector wire = packet.serialize(); + check(!wire.empty(), "packet serialize"); + const std::optional parsed = sc::RtpPacket::parse(wire); + check(parsed.has_value(), "packet parse"); + check(parsed->header.sequence_number == 7 && parsed->header.timestamp == 0x0A0B0C0D && + parsed->header.ssrc == 0x01020304 && parsed->header.payload_type == 96, + "packet header fields"); + check(equal_bytes(parsed->payload, packet.payload), "packet payload"); +} + +void test_packet_parse_tolerances() { + // CSRC list is skipped, payload preserved. + std::vector csrc_wire = bytes({0x81, 0x60, 0x00, 0x01, 0x00, 0x00, 0x00, 0x02, 0x11, 0x22, 0x33, 0x44}); + append_bytes(csrc_wire, {0xDE, 0xAD, 0xBE, 0xEF}); + append_bytes(csrc_wire, {0xAA, 0xBB}); + const std::optional csrc_parsed = sc::RtpPacket::parse(csrc_wire); + check(csrc_parsed.has_value(), "csrc parse"); + check(csrc_parsed->header.csrc_count == 1, "csrc count"); + check(equal_bytes(csrc_parsed->payload, bytes({0xAA, 0xBB})), "csrc payload preserved"); + + // Extension header is skipped, payload preserved. + std::vector ext_wire = bytes({0x90, 0x60, 0x00, 0x01, 0x00, 0x00, 0x00, 0x02, 0x11, 0x22, 0x33, 0x44}); + append_bytes(ext_wire, {0xAB, 0xCD, 0x00, 0x01}); // profile + 1 word + append_bytes(ext_wire, {0x55, 0x55, 0x55, 0x55}); // extension data + append_bytes(ext_wire, {0xAA, 0xBB}); + const std::optional ext_parsed = sc::RtpPacket::parse(ext_wire); + check(ext_parsed.has_value(), "extension parse"); + check(ext_parsed->header.extension, "extension flag"); + check(equal_bytes(ext_parsed->payload, bytes({0xAA, 0xBB})), "extension payload preserved"); + + // Padding is stripped. + std::vector pad_wire = bytes({0xA0, 0x60, 0x00, 0x01, 0x00, 0x00, 0x00, 0x02, 0x11, 0x22, 0x33, 0x44}); + append_bytes(pad_wire, {0xAA, 0xBB}); + append_bytes(pad_wire, {0x00, 0x00, 0x03}); // 3 padding bytes, count last + const std::optional pad_parsed = sc::RtpPacket::parse(pad_wire); + check(pad_parsed.has_value(), "padding parse"); + check(pad_parsed->header.padding, "padding flag"); + check(equal_bytes(pad_parsed->payload, bytes({0xAA, 0xBB})), "padding stripped"); + + // Malformed input is rejected. + check(!sc::RtpPacket::parse(bytes({0x80, 0x60, 0x00, 0x01, 0x00, 0x00, 0x00, 0x02, 0x11, 0x22, 0x33})).has_value(), + "reject short packet"); + + std::vector truncated_csrc = + bytes({0x83, 0x60, 0x00, 0x01, 0x00, 0x00, 0x00, 0x02, 0x11, 0x22, 0x33, 0x44}); + append_bytes(truncated_csrc, {0xAA, 0xBB}); + check(!sc::RtpPacket::parse(truncated_csrc).has_value(), "reject truncated csrc"); + + std::vector overlong_extension = + bytes({0x90, 0x60, 0x00, 0x01, 0x00, 0x00, 0x00, 0x02, 0x11, 0x22, 0x33, 0x44}); + append_bytes(overlong_extension, {0xAB, 0xCD, 0x00, 0x04}); // claims 4 words + check(!sc::RtpPacket::parse(overlong_extension).has_value(), "reject overlong extension"); + + std::vector zero_padding = + bytes({0xA0, 0x60, 0x00, 0x01, 0x00, 0x00, 0x00, 0x02, 0x11, 0x22, 0x33, 0x44}); + append_bytes(zero_padding, {0xAA, 0x00}); + check(!sc::RtpPacket::parse(zero_padding).has_value(), "reject zero padding count"); + + std::vector oversized_padding = + bytes({0xA0, 0x60, 0x00, 0x01, 0x00, 0x00, 0x00, 0x02, 0x11, 0x22, 0x33, 0x44}); + append_bytes(oversized_padding, {0x00, 0x09}); + check(!sc::RtpPacket::parse(oversized_padding).has_value(), "reject oversized padding"); +} + +void test_single_nal_packetization() { + const std::vector sps = bytes({0x67, 0x42, 0x00, 0x1F}); + const std::vector pps = bytes({0x68, 0xCE, 0x06, 0x0D}); + const sc::EncodedFrame frame = make_frame(annex_b({sps, pps}), 90000); + + sc::H264Packetizer packetizer(test_config(0x0100, 1200)); + const std::vector packets = packetizer.packetize(frame); + check(packets.size() == 2, "two packets"); + check(equal_bytes(packets[0].payload, sps), "sps payload"); + check(equal_bytes(packets[1].payload, pps), "pps payload"); + check(!packets[0].header.marker && packets[1].header.marker, "marker placement"); + check(packets[0].header.sequence_number == 0x0100 && packets[1].header.sequence_number == 0x0101, "sequences"); + check(packets[0].header.timestamp == 90000 && packets[1].header.timestamp == 90000, "timestamps"); + check(packets[0].header.ssrc == 0x12345678, "ssrc"); +} + +void test_three_byte_start_codes() { + const std::vector sps = bytes({0x67, 0x01}); + const std::vector pps = bytes({0x68, 0x02}); + + std::vector data = bytes({0x00, 0x00, 0x01}); + append_bytes(data, {0x67, 0x01}); + append_bytes(data, {0x00, 0x00, 0x01}); + append_bytes(data, {0x68, 0x02}); + + sc::H264Packetizer packetizer(test_config(1, 1200)); + const std::vector packets = packetizer.packetize(make_frame(data, 1)); + check(packets.size() == 2, "3-byte start codes split"); + check(equal_bytes(packets[0].payload, sps) && equal_bytes(packets[1].payload, pps), "3-byte start code payloads"); +} + +void test_four_byte_start_codes() { + // A zero byte preceding a start code belongs to the previous NAL, so + // 4-byte start code streams keep their exact byte layout. + const std::vector sps_with_trailing_zero = bytes({0x67, 0x01, 0x00}); + const std::vector pps = bytes({0x68, 0x02}); + + std::vector data = bytes({0x00, 0x00, 0x00, 0x01}); + append_bytes(data, {0x67, 0x01}); + append_bytes(data, {0x00, 0x00, 0x00, 0x01}); + append_bytes(data, {0x68, 0x02}); + + sc::H264Packetizer packetizer(test_config(1, 1200)); + const std::vector packets = packetizer.packetize(make_frame(data, 1)); + check(packets.size() == 2, "4-byte start codes split"); + check(equal_bytes(packets[0].payload, sps_with_trailing_zero), "absorbed trailing zero"); + check(equal_bytes(packets[1].payload, pps), "4-byte start code last payload"); +} + +void test_fu_a_fragmentation() { + const std::vector big_nal = nal(0x65, 26); // IDR slice, 25 payload bytes + const sc::EncodedFrame frame = make_frame(annex_b({big_nal}), 12345); + + // 24-byte packets: 12 header + 2 FU bytes + 10 data per chunk. + sc::H264Packetizer packetizer(test_config(0x00F0, 24)); + const std::vector packets = packetizer.packetize(frame); + check(packets.size() == 3, "three fu-a packets"); + for (const sc::RtpPacket& packet : packets) { + check(packet.serialize().size() <= 24, "mtu respected"); + check(u8(packet.payload[0]) == ((0x65 & 0xE0) | 28), "fu indicator"); + check((u8(packet.payload[1]) & 0x1F) == 5, "nal type preserved"); + } + check((u8(packets[0].payload[1]) & 0x80) != 0, "start flag"); + check((u8(packets[1].payload[1]) & 0xC0) == 0, "middle flags"); + check((u8(packets[2].payload[1]) & 0x40) != 0, "end flag"); + check(!packets[0].header.marker && !packets[1].header.marker && packets[2].header.marker, "fu marker"); + check(packets[0].header.sequence_number == 0x00F0 && packets[1].header.sequence_number == 0x00F1 && + packets[2].header.sequence_number == 0x00F2, + "fu sequences"); + + std::vector reassembled; + reassembled.push_back( + std::byte{static_cast((u8(packets[0].payload[0]) & 0xE0) | (u8(packets[0].payload[1]) & 0x1F))}); + for (const sc::RtpPacket& packet : packets) { + reassembled.insert(reassembled.end(), packet.payload.begin() + 2, packet.payload.end()); + } + check(equal_bytes(reassembled, big_nal), "fu reassembly"); +} + +void test_depacketize_roundtrip() { + const std::vector sps = bytes({0x67, 0x42, 0x00}); + const std::vector pps = bytes({0x68, 0xCE}); + const std::vector big = nal(0x65, 40); + const std::vector access_unit = annex_b({sps, pps, big}); + + // MTU 20: SPS and PPS fit single packets; the 40-byte NAL becomes 7 FU-A + // chunks of 6 bytes (39 payload bytes), 9 packets total. + sc::H264Packetizer packetizer(test_config(0x1000, 20)); + const std::vector packets = packetizer.packetize(make_frame(access_unit, 3000)); + check(packets.size() == 9, "round-trip packet count"); + + sc::H264Depacketizer depacketizer; + std::optional> completed; + for (const sc::RtpPacket& packet : packets) { + if (auto result = depacketizer.depacketize(packet)) { + check(!completed.has_value(), "only one completion"); + completed = std::move(result); + } + } + check(completed.has_value(), "frame completed"); + check(equal_bytes(*completed, access_unit), "access unit round-trip"); +} + +void test_depacketizer_drops_gapped_frames() { + const std::vector big_nal = nal(0x65, 26); + sc::H264Packetizer packetizer(test_config(0x0100, 24)); + const std::vector packets = packetizer.packetize(make_frame(annex_b({big_nal}), 5000)); + check(packets.size() == 3, "gap test packet count"); + + sc::H264Depacketizer depacketizer; + check(!depacketizer.depacketize(packets[0]).has_value(), "first fu chunk accepted"); + // packets[1] is lost in transit; the tail cannot complete the frame. + check(!depacketizer.depacketize(packets[2]).has_value(), "tail after gap dropped"); +} + +void test_depacketizer_separate_frames() { + const std::vector f1 = annex_b({bytes({0x67, 0x01})}); + const std::vector f2 = annex_b({bytes({0x41, 0x02})}); + + sc::H264Packetizer packetizer(test_config(0x0001, 1200)); + const std::vector first = packetizer.packetize(make_frame(f1, 90000)); + const std::vector second = packetizer.packetize(make_frame(f2, 90000)); + + sc::H264Depacketizer depacketizer; + const std::optional> au1 = depacketizer.depacketize(first[0]); + check(au1.has_value() && equal_bytes(*au1, f1), "first frame"); + + // Same RTP timestamp on purpose: the marker alone separates frames. + const std::optional> au2 = depacketizer.depacketize(second[0]); + check(au2.has_value() && equal_bytes(*au2, f2), "second frame with same timestamp"); +} + +void test_sequence_wrap() { + sc::H264Packetizer packetizer(test_config(0xFFFE, 1200)); + const std::vector packets = + packetizer.packetize(make_frame(annex_b({bytes({0x67, 0x01}), bytes({0x68, 0x02}), bytes({0x65, 0x03})}), 100)); + check(packets.size() == 3, "wrap packet count"); + check(packets[0].header.sequence_number == 0xFFFE && packets[1].header.sequence_number == 0xFFFF && + packets[2].header.sequence_number == 0x0000, + "sequence wrap"); +} + +void test_default_config_randomizes() { + sc::H264Packetizer first; + sc::H264Packetizer second; + const std::vector from_first = first.packetize(make_frame(annex_b({bytes({0x67, 0x01})}), 1)); + const std::vector from_second = second.packetize(make_frame(annex_b({bytes({0x67, 0x01})}), 1)); + check(!from_first.empty() && !from_second.empty(), "default packetize"); + check(from_first[0].header.ssrc != from_second[0].header.ssrc, "random ssrc"); + check(from_first[0].header.sequence_number != from_second[0].header.sequence_number, "random sequence"); +} + +void test_empty_inputs() { + sc::H264Packetizer packetizer(test_config(1, 1200)); + check(packetizer.packetize(sc::EncodedFrame{}).empty(), "empty frame data"); + check(packetizer.packetize(make_frame(bytes({0x00, 0x01, 0x02}), 1)).empty(), "no start codes"); + + sc::H264Packetizer tiny_mtu(test_config(1, 8)); + check(tiny_mtu.packetize(make_frame(annex_b({bytes({0x67, 0x01})}), 1)).empty(), "unusable mtu"); +} + +} // namespace + +int main() { + test_header_roundtrip(); + test_header_rejections(); + test_packet_roundtrip(); + test_packet_parse_tolerances(); + test_single_nal_packetization(); + test_three_byte_start_codes(); + test_four_byte_start_codes(); + test_fu_a_fragmentation(); + test_depacketize_roundtrip(); + test_depacketizer_drops_gapped_frames(); + test_depacketizer_separate_frames(); + test_sequence_wrap(); + test_default_config_randomizes(); + test_empty_inputs(); + std::puts("test_rtp: all checks passed"); + return 0; +}