diff --git a/CMakeLists.txt b/CMakeLists.txt index 1fa34d15..3cf46fee 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -408,6 +408,7 @@ add_library(livekit SHARED src/ffi_handle.cpp src/ffi_client.cpp src/ffi_client.h + src/length_prefixed_access_unit_parser.cpp src/livekit.cpp src/logging.cpp src/local_audio_track.cpp diff --git a/include/livekit/length_prefixed_access_unit_parser.h b/include/livekit/length_prefixed_access_unit_parser.h new file mode 100644 index 00000000..e36d2657 --- /dev/null +++ b/include/livekit/length_prefixed_access_unit_parser.h @@ -0,0 +1,103 @@ +/* + * Copyright 2026 LiveKit + * + * 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. + */ + +#pragma once + +#include +#include +#include +#include + +#include "livekit/encoded_video_source.h" +#include "livekit/video_codec.h" +#include "livekit/visibility.h" + +namespace livekit { + +/// @brief Splits a length-prefixed H.264 (AVC) or H.265 (HVCC) NAL unit +/// stream into access units ready for EncodedVideoSource::captureFrame. +/// +/// This is the framing of `stream-format=avc3` (H.264) and `hev1` (H.265), +/// with parameter sets in-band. Each access unit is converted to Annex-B, +/// which the pre-encoded passthrough requires. +/// +/// Push bytes as they arrive (any chunking), then drain complete access +/// units with next(). Call flush() once at the end of the stream. Not +/// thread-safe. +class LIVEKIT_API LengthPrefixedAccessUnitParser final { +public: + struct Options { + /// Size of each big-endian NAL length prefix, 1 to 4 bytes + /// (`lengthSizeMinusOne + 1` from `avcC`/`hvcC`; 4 for most encoders). + std::uint8_t nal_length_size = 4; + /// Complete each access unit as soon as its first slice arrives, instead + /// of when the next access unit begins. + /// + /// The end of an access unit is otherwise only known from the NAL that + /// starts the next one, which holds every picture for a full frame + /// interval unless the encoder terminates access units with an AUD. + /// Enable this only for streams with one slice per picture, which is + /// what hardware encoders typically emit. + bool single_slice_flush = false; + /// Fill Frame::metadata from an LKTS `user_data_unregistered` SEI ahead + /// of the slice (the packet-trailer convention of the LiveKit Go SDK). + bool frame_metadata_from_sei = false; + }; + + /// @throws std::invalid_argument if the codec is not H.264 or H.265, or + /// nal_length_size is not 1 to 4. + explicit LengthPrefixedAccessUnitParser(VideoCodec codec); + /// @throws std::invalid_argument if the codec is not H.264 or H.265, or + /// nal_length_size is not 1 to 4. + LengthPrefixedAccessUnitParser(VideoCodec codec, Options options); + + /// @brief Appends stream bytes. + void push(const std::uint8_t* data, std::size_t size); + + /// @brief Returns the next complete access unit, if one is buffered. + /// + /// Frame::width/height are left at zero (the source resolution) and + /// Frame::timestamp_us at zero (capture time). + /// @throws std::runtime_error on malformed input, or when an access unit + /// exceeds 32 MiB. + std::optional next(); + + /// @brief Returns the remaining buffered NAL units as the final access unit. + /// Call repeatedly until it returns nullopt. + /// @throws std::runtime_error on a truncated final NAL unit. + std::optional flush(); + +private: + struct NalRange { + std::size_t start; + std::size_t end; + }; + + void scanPending(); + std::optional splitIndex() const; + EncodedVideoSource::Frame takeAccessUnit(std::size_t nal_count); + + VideoCodec codec_; + Options options_; + std::vector pending_; + // Complete NAL units of the access unit being assembled, as payload + // ranges (after the length prefix) into pending_. + std::vector nals_; + // Offset of the first unparsed length prefix in pending_. + std::size_t scan_cursor_ = 0; +}; + +} // namespace livekit diff --git a/include/livekit/livekit.h b/include/livekit/livekit.h index 9b22b7e9..50b83388 100644 --- a/include/livekit/livekit.h +++ b/include/livekit/livekit.h @@ -23,6 +23,7 @@ #include "livekit/build.h" #include "livekit/e2ee.h" #include "livekit/encoded_video_source.h" +#include "livekit/length_prefixed_access_unit_parser.h" #include "livekit/local_audio_track.h" #include "livekit/local_participant.h" #include "livekit/local_track_publication.h" diff --git a/src/length_prefixed_access_unit_parser.cpp b/src/length_prefixed_access_unit_parser.cpp new file mode 100644 index 00000000..9953abe0 --- /dev/null +++ b/src/length_prefixed_access_unit_parser.cpp @@ -0,0 +1,304 @@ +/* + * Copyright 2026 LiveKit + * + * 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. + */ + +#include "livekit/length_prefixed_access_unit_parser.h" + +#include +#include +#include +#include + +namespace livekit { +namespace { + +// Upper bound on bytes buffered while waiting for an access-unit boundary. +constexpr std::size_t kMaxPendingAccessUnitBytes = std::size_t{32} * 1024U * 1024U; + +constexpr std::array kStartCode = {0, 0, 0, 1}; + +// UUID of the `user_data_unregistered` SEI carrying an LKTS packet trailer. +constexpr std::array kPacketTrailerSeiUuid = { + 0x3f, 0xa8, 0x5f, 0x64, 0x57, 0x17, 0x45, 0x62, 0xb3, 0xfc, 0x2c, 0x96, 0x3f, 0x66, 0xaf, 0xa6}; +constexpr std::array kPacketTrailerMagic = {'L', 'K', 'T', 'S'}; +constexpr std::uint8_t kTagUserTimestamp = 0x01; +constexpr std::uint8_t kTagFrameId = 0x02; + +bool isH265(VideoCodec codec) { return codec == VideoCodec::H265; } + +std::size_t headerLength(VideoCodec codec) { return isH265(codec) ? 2 : 1; } + +std::uint8_t nalType(VideoCodec codec, const std::uint8_t* nal) { + return isH265(codec) ? static_cast((nal[0] >> 1) & 0x3f) : static_cast(nal[0] & 0x1f); +} + +bool isVcl(VideoCodec codec, std::uint8_t type) { return isH265(codec) ? type <= 31 : (type >= 1 && type <= 5); } + +// Mirrors livekit-capture's access-unit boundary rules. +bool startsNewAccessUnit(VideoCodec codec, const std::uint8_t* nal, std::size_t size) { + const std::uint8_t type = nalType(codec, nal); + if (isH265(codec)) { + // VPS(32), SPS(33), PPS(34), AUD(35), and prefix SEI(39). + if ((type >= 32 && type <= 35) || type == 39) { + return true; + } + // first_slice_segment_in_pic_flag, the bit after the 2-byte header. + return type <= 31 && size >= 3 && (nal[2] & 0x80) != 0; + } + // Prefix SEI(6), SPS(7), PPS(8), and AUD(9). + if (type >= 6 && type <= 9) { + return true; + } + // first_mb_in_slice == 0: ue(v) 0 is a lone 1 bit. + return type >= 1 && type <= 5 && size >= 2 && (nal[1] & 0x80) != 0; +} + +std::vector removeEmulationPrevention(const std::uint8_t* data, std::size_t size) { + std::vector rbsp; + rbsp.reserve(size); + int zeros = 0; + for (std::size_t i = 0; i < size; ++i) { + const std::uint8_t byte = data[i]; + if (zeros >= 2 && byte == 0x03) { + zeros = 0; + continue; + } + zeros = byte == 0 ? zeros + 1 : 0; + rbsp.push_back(byte); + } + return rbsp; +} + +std::optional parsePacketTrailer(const std::uint8_t* data, std::size_t size) { + constexpr std::size_t kEnvelope = 1 + kPacketTrailerMagic.size(); + if (size < kEnvelope || + !std::equal(kPacketTrailerMagic.begin(), kPacketTrailerMagic.end(), data + size - kPacketTrailerMagic.size())) { + return std::nullopt; + } + const std::size_t magic_start = size - kPacketTrailerMagic.size(); + const std::size_t trailer_len = static_cast(data[magic_start - 1] ^ 0xff); + if (trailer_len < kEnvelope || trailer_len > size) { + return std::nullopt; + } + const std::uint8_t* tlvs = data + size - trailer_len; + const std::size_t tlvs_len = trailer_len - kEnvelope; + + VideoFrameMetadata metadata; + bool found = false; + std::size_t pos = 0; + while (pos + 2 <= tlvs_len) { + const std::uint8_t tag = tlvs[pos] ^ 0xff; + const std::size_t len = static_cast(tlvs[pos + 1] ^ 0xff); + pos += 2; + if (pos + len > tlvs_len) { + break; + } + std::uint64_t value = 0; + for (std::size_t i = 0; i < len && i < 8; ++i) { + value = (value << 8) | static_cast(tlvs[pos + i] ^ 0xff); + } + if (tag == kTagUserTimestamp && len == 8) { + metadata.user_timestamp_us = value; + found = true; + } else if (tag == kTagFrameId && len == 4) { + metadata.frame_id = static_cast(value); + found = true; + } + pos += len; + } + return found ? std::optional(metadata) : std::nullopt; +} + +// Parses the SEI messages of one SEI NAL (after its header) for an LKTS +// `user_data_unregistered` payload. +std::optional frameMetadataFromSei(const std::uint8_t* ebsp, std::size_t size) { + const std::vector rbsp = removeEmulationPrevention(ebsp, size); + std::size_t i = 0; + const auto read_varlen = [&](std::size_t& out) { + out = 0; + while (i < rbsp.size() && rbsp[i] == 0xff) { + out += 255; + ++i; + } + if (i >= rbsp.size()) { + return false; + } + out += rbsp[i++]; + return true; + }; + // more_rbsp_data(): stop at the trailing stop bit. + while (i + 1 < rbsp.size()) { + std::size_t payload_type = 0; + std::size_t payload_size = 0; + if (!read_varlen(payload_type) || !read_varlen(payload_size) || payload_size > rbsp.size() - i) { + return std::nullopt; + } + const std::uint8_t* payload = rbsp.data() + i; + i += payload_size; + if (payload_type == 5 && payload_size >= kPacketTrailerSeiUuid.size() && + std::equal(kPacketTrailerSeiUuid.begin(), kPacketTrailerSeiUuid.end(), payload)) { + return parsePacketTrailer(payload + kPacketTrailerSeiUuid.size(), payload_size - kPacketTrailerSeiUuid.size()); + } + } + return std::nullopt; +} + +} // namespace + +LengthPrefixedAccessUnitParser::LengthPrefixedAccessUnitParser(VideoCodec codec) + : LengthPrefixedAccessUnitParser(codec, Options{}) {} + +LengthPrefixedAccessUnitParser::LengthPrefixedAccessUnitParser(VideoCodec codec, Options options) + : codec_(codec), options_(options) { + if (codec != VideoCodec::H264 && codec != VideoCodec::H265) { + throw std::invalid_argument("LengthPrefixedAccessUnitParser: codec must be H.264 or H.265"); + } + if (options.nal_length_size < 1 || options.nal_length_size > 4) { + throw std::invalid_argument("LengthPrefixedAccessUnitParser: nal_length_size must be 1 to 4"); + } +} + +void LengthPrefixedAccessUnitParser::push(const std::uint8_t* data, std::size_t size) { + pending_.insert(pending_.end(), data, data + size); +} + +void LengthPrefixedAccessUnitParser::scanPending() { + const std::size_t prefix = options_.nal_length_size; + while (pending_.size() - scan_cursor_ >= prefix) { + std::size_t len = 0; + for (std::size_t i = 0; i < prefix; ++i) { + len = (len << 8) | pending_[scan_cursor_ + i]; + } + if (len == 0) { + throw std::runtime_error("LengthPrefixedAccessUnitParser: empty NAL unit"); + } + const std::size_t start = scan_cursor_ + prefix; + if (len > pending_.size() - start) { + break; // NAL still streaming in + } + nals_.push_back({start, start + len}); + scan_cursor_ = start + len; + } +} + +std::optional LengthPrefixedAccessUnitParser::splitIndex() const { + bool seen_vcl = false; + for (std::size_t i = 0; i < nals_.size(); ++i) { + const std::uint8_t* nal = pending_.data() + nals_[i].start; + const std::size_t size = nals_[i].end - nals_[i].start; + if (size < headerLength(codec_)) { + throw std::runtime_error("LengthPrefixedAccessUnitParser: NAL unit shorter than its header"); + } + const bool vcl = isVcl(codec_, nalType(codec_, nal)); + if (options_.single_slice_flush) { + // Scanned NALs are complete, so the first slice holds the picture. + if (vcl) { + return i + 1; + } + continue; + } + if (seen_vcl && startsNewAccessUnit(codec_, nal, size)) { + return i; + } + seen_vcl = seen_vcl || vcl; + } + return std::nullopt; +} + +EncodedVideoSource::Frame LengthPrefixedAccessUnitParser::takeAccessUnit(std::size_t nal_count) { + const std::size_t prefix = options_.nal_length_size; + const std::size_t au_end = nals_[nal_count - 1].end; + + EncodedVideoSource::Frame frame; + bool has_vps = false; + bool has_sps = false; + bool has_pps = false; + bool has_idr = false; + bool seen_vcl = false; + for (std::size_t i = 0; i < nal_count; ++i) { + const std::uint8_t* nal = pending_.data() + nals_[i].start; + const std::size_t size = nals_[i].end - nals_[i].start; + const std::uint8_t type = nalType(codec_, nal); + if (isH265(codec_)) { + has_vps = has_vps || type == 32; + has_sps = has_sps || type == 33; + has_pps = has_pps || type == 34; + has_idr = has_idr || type == 19 || type == 20; + } else { + has_idr = has_idr || type == 5; + } + if (options_.frame_metadata_from_sei && !seen_vcl && !frame.metadata && + type == (isH265(codec_) ? 39 : 6)) { + frame.metadata = frameMetadataFromSei(nal + headerLength(codec_), size - headerLength(codec_)); + } + seen_vcl = seen_vcl || isVcl(codec_, type); + } + // Same rule as livekit-capture: H.265 needs parameter sets with the IDR. + frame.is_keyframe = isH265(codec_) ? (has_vps && has_sps && has_pps && has_idr) : has_idr; + + if (prefix == kStartCode.size()) { + // Same size as the start code: convert while copying. + frame.data.assign(pending_.begin(), pending_.begin() + static_cast(au_end)); + for (std::size_t i = 0; i < nal_count; ++i) { + std::memcpy(frame.data.data() + nals_[i].start - prefix, kStartCode.data(), kStartCode.size()); + } + } else { + std::size_t total = 0; + for (std::size_t i = 0; i < nal_count; ++i) { + total += kStartCode.size() + nals_[i].end - nals_[i].start; + } + frame.data.reserve(total); + for (std::size_t i = 0; i < nal_count; ++i) { + frame.data.insert(frame.data.end(), kStartCode.begin(), kStartCode.end()); + frame.data.insert(frame.data.end(), pending_.begin() + static_cast(nals_[i].start), + pending_.begin() + static_cast(nals_[i].end)); + } + } + + pending_.erase(pending_.begin(), pending_.begin() + static_cast(au_end)); + nals_.erase(nals_.begin(), nals_.begin() + static_cast(nal_count)); + for (auto& nal : nals_) { + nal.start -= au_end; + nal.end -= au_end; + } + scan_cursor_ -= au_end; + return frame; +} + +std::optional LengthPrefixedAccessUnitParser::next() { + scanPending(); + if (const auto split = splitIndex(); split && *split > 0) { + return takeAccessUnit(*split); + } + if (pending_.size() > kMaxPendingAccessUnitBytes) { + throw std::runtime_error("LengthPrefixedAccessUnitParser: access unit exceeds maximum buffered size"); + } + return std::nullopt; +} + +std::optional LengthPrefixedAccessUnitParser::flush() { + if (auto frame = next()) { + return frame; + } + if (scan_cursor_ != pending_.size()) { + throw std::runtime_error("LengthPrefixedAccessUnitParser: truncated NAL unit at end of stream"); + } + if (nals_.empty()) { + return std::nullopt; + } + return takeAccessUnit(nals_.size()); +} + +} // namespace livekit diff --git a/src/tests/unit/test_length_prefixed_access_unit_parser.cpp b/src/tests/unit/test_length_prefixed_access_unit_parser.cpp new file mode 100644 index 00000000..29d0d47a --- /dev/null +++ b/src/tests/unit/test_length_prefixed_access_unit_parser.cpp @@ -0,0 +1,218 @@ +/* + * Copyright 2026 LiveKit + * + * 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. + */ + +#include +#include + +#include +#include +#include + +namespace livekit::test { +namespace { + +using Bytes = std::vector; +using Parser = LengthPrefixedAccessUnitParser; + +/// An H.265 stream laid out like streaming-tools' gstreamer-source: +/// prefix SEI, parameter sets on keyframes, one slice, trailing AUD. +const Bytes kHev1Stream = { + 0, 0, 0, 3, 0x4e, 0x01, 0x05, // prefix SEI + 0, 0, 0, 3, 0x40, 0x01, 0x0c, // VPS + 0, 0, 0, 3, 0x42, 0x01, 0x02, // SPS + 0, 0, 0, 3, 0x44, 0x01, 0x03, // PPS + 0, 0, 0, 4, 0x26, 0x01, 0xaf, 0x08, // IDR_W_RADL (ends at 36) + 0, 0, 0, 3, 0x46, 0x01, 0x50, // AUD + 0, 0, 0, 3, 0x4e, 0x01, 0x05, // prefix SEI + 0, 0, 0, 4, 0x02, 0x01, 0xd0, 0x09, // TRAIL_R + 0, 0, 0, 3, 0x46, 0x01, 0x50, // AUD (ends at 65) +}; + +/// LKTS SEI as written by streaming-tools: user timestamp +/// 1'761'534'000'123'456, frame id 42. +const Bytes kLktsSeiH265 = {0x4e, 0x01, 0x05, 0x25, 0x3f, 0xa8, 0x5f, 0x64, 0x57, 0x17, 0x45, 0x62, 0xb3, 0xfc, + 0x2c, 0x96, 0x3f, 0x66, 0xaf, 0xa6, 0xfe, 0xf7, 0xff, 0xf9, 0xbd, 0xe4, 0xe7, 0xa9, + 0xb1, 0xbf, 0xfd, 0xfb, 0xff, 0xff, 0xff, 0xd5, 0xea, 0x4c, 0x4b, 0x54, 0x53, 0x80}; + +Bytes slice(const Bytes& bytes, std::size_t from, std::size_t to) { + return Bytes(bytes.begin() + static_cast(from), bytes.begin() + static_cast(to)); +} + +void push(Parser& parser, const Bytes& bytes) { parser.push(bytes.data(), bytes.size()); } + +Bytes lengthPrefixed(const std::vector& nals) { + Bytes out; + for (const auto& nal : nals) { + const auto n = static_cast(nal.size()); + out.insert(out.end(), {static_cast(n >> 24), static_cast(n >> 16), + static_cast(n >> 8), static_cast(n)}); + out.insert(out.end(), nal.begin(), nal.end()); + } + return out; +} + +std::vector h265NalTypes(const Bytes& annex_b) { + std::vector types; + for (std::size_t i = 0; i + 4 < annex_b.size(); ++i) { + if (annex_b[i] == 0 && annex_b[i + 1] == 0 && annex_b[i + 2] == 0 && annex_b[i + 3] == 1) { + types.push_back((annex_b[i + 4] >> 1) & 0x3f); + i += 3; + } + } + return types; +} + +std::vector collect(Parser parser, const Bytes& stream, std::size_t chunk) { + std::vector units; + for (std::size_t i = 0; i < stream.size(); i += chunk) { + push(parser, slice(stream, i, std::min(stream.size(), i + chunk))); + while (auto frame = parser.next()) { + units.push_back(frame->data); + } + } + while (auto frame = parser.flush()) { + units.push_back(frame->data); + } + return units; +} + +} // namespace + +TEST(LengthPrefixedAccessUnitParserTest, RejectsUnsupportedConfiguration) { + EXPECT_THROW((void)Parser(VideoCodec::VP8), std::invalid_argument); + Parser::Options options; + options.nal_length_size = 5; + EXPECT_THROW((void)Parser(VideoCodec::H265, options), std::invalid_argument); +} + +TEST(LengthPrefixedAccessUnitParserTest, WaitsForNextAccessUnitWithoutSingleSliceFlush) { + Parser parser(VideoCodec::H265); + push(parser, slice(kHev1Stream, 0, 36)); + EXPECT_FALSE(parser.next().has_value()); + + // The terminating AUD starts the next access unit, completing this one. + push(parser, slice(kHev1Stream, 36, 43)); + const auto frame = parser.next(); + ASSERT_TRUE(frame.has_value()); + EXPECT_TRUE(frame->is_keyframe); + EXPECT_EQ(h265NalTypes(frame->data), (std::vector{39, 32, 33, 34, 19})); +} + +TEST(LengthPrefixedAccessUnitParserTest, SingleSliceFlushCompletesAtTheSlice) { + Parser::Options options; + options.single_slice_flush = true; + Parser parser(VideoCodec::H265, options); + + push(parser, slice(kHev1Stream, 0, 35)); + EXPECT_FALSE(parser.next().has_value()); + push(parser, slice(kHev1Stream, 35, 36)); + auto frame = parser.next(); + ASSERT_TRUE(frame.has_value()); + EXPECT_TRUE(frame->is_keyframe); + EXPECT_EQ(frame->width, 0U); + EXPECT_EQ(frame->timestamp_us, 0); + EXPECT_EQ(frame->data, (Bytes{0, 0, 0, 1, 0x4e, 0x01, 0x05, 0, 0, 0, 1, 0x40, 0x01, 0x0c, 0, 0, 0, 1, + 0x42, 0x01, 0x02, 0, 0, 0, 1, 0x44, 0x01, 0x03, 0, 0, 0, 1, 0x26, 0x01, 0xaf, 0x08})); + + // The trailing AUD leads the next access unit. + push(parser, slice(kHev1Stream, 36, kHev1Stream.size())); + frame = parser.next(); + ASSERT_TRUE(frame.has_value()); + EXPECT_FALSE(frame->is_keyframe); + EXPECT_EQ(h265NalTypes(frame->data), (std::vector{35, 39, 1})); + EXPECT_FALSE(parser.next().has_value()); +} + +TEST(LengthPrefixedAccessUnitParserTest, ChunkedPushesMatchOneShotParsing) { + for (const bool single_slice_flush : {false, true}) { + Parser::Options options; + options.single_slice_flush = single_slice_flush; + const Parser parser(VideoCodec::H265, options); + const auto baseline = collect(parser, kHev1Stream, kHev1Stream.size()); + // Both modes end with the final AUD flushed on its own. + EXPECT_EQ(baseline.size(), 3U); + for (const std::size_t chunk : {1, 7}) { + EXPECT_EQ(collect(parser, kHev1Stream, chunk), baseline); + } + } +} + +TEST(LengthPrefixedAccessUnitParserTest, ConvertsTwoByteLengthPrefixes) { + Parser::Options options; + options.nal_length_size = 2; + Parser parser(VideoCodec::H264, options); + push(parser, Bytes{0, 4, 0x67, 1, 2, 3, 0, 3, 0x65, 0x88, 5}); + const auto frame = parser.flush(); + ASSERT_TRUE(frame.has_value()); + EXPECT_TRUE(frame->is_keyframe); + EXPECT_EQ(frame->data, (Bytes{0, 0, 0, 1, 0x67, 1, 2, 3, 0, 0, 0, 1, 0x65, 0x88, 5})); +} + +TEST(LengthPrefixedAccessUnitParserTest, ReadsFrameMetadataFromLktsSei) { + Parser::Options options; + options.single_slice_flush = true; + options.frame_metadata_from_sei = true; + Parser parser(VideoCodec::H265, options); + push(parser, lengthPrefixed({{0x46, 0x01, 0x50}, kLktsSeiH265, {0x02, 0x01, 0xd0, 0x09}})); + const auto frame = parser.next(); + ASSERT_TRUE(frame.has_value()); + ASSERT_TRUE(frame->metadata.has_value()); + EXPECT_EQ(frame->metadata->user_timestamp_us, 1'761'534'000'123'456ULL); + EXPECT_EQ(frame->metadata->frame_id, 42U); +} + +TEST(LengthPrefixedAccessUnitParserTest, ReadsFrameMetadataThroughEmulationPrevention) { + // The masked timestamp 0x0000'ffff'fc00'0000 contains 00 00 03. + const Bytes sei = {0x4e, 0x01, 0x05, 0x25, 0x3f, 0xa8, 0x5f, 0x64, 0x57, 0x17, 0x45, 0x62, 0xb3, 0xfc, 0x2c, + 0x96, 0x3f, 0x66, 0xaf, 0xa6, 0xfe, 0xf7, 0xff, 0xff, 0x00, 0x00, 0x03, 0x03, 0xff, 0xff, + 0xff, 0xfd, 0xfb, 0xff, 0xff, 0xff, 0xf8, 0xea, 0x4c, 0x4b, 0x54, 0x53, 0x80}; + Parser::Options options; + options.single_slice_flush = true; + options.frame_metadata_from_sei = true; + Parser parser(VideoCodec::H265, options); + push(parser, lengthPrefixed({sei, {0x02, 0x01, 0xd0, 0x09}})); + const auto frame = parser.next(); + ASSERT_TRUE(frame.has_value()); + ASSERT_TRUE(frame->metadata.has_value()); + EXPECT_EQ(frame->metadata->user_timestamp_us, 0x0000'ffff'fc00'0000ULL); + EXPECT_EQ(frame->metadata->frame_id, 7U); +} + +TEST(LengthPrefixedAccessUnitParserTest, IgnoresSeiMetadataUnlessEnabled) { + Parser::Options options; + options.single_slice_flush = true; + Parser parser(VideoCodec::H265, options); + push(parser, lengthPrefixed({kLktsSeiH265, {0x02, 0x01, 0xd0, 0x09}})); + const auto frame = parser.next(); + ASSERT_TRUE(frame.has_value()); + EXPECT_FALSE(frame->metadata.has_value()); +} + +TEST(LengthPrefixedAccessUnitParserTest, RejectsTruncatedStreamAndOversizedAccessUnit) { + Parser parser(VideoCodec::H265); + push(parser, Bytes{0, 0, 0, 4, 0x26, 0x01}); + EXPECT_FALSE(parser.next().has_value()); + EXPECT_THROW((void)parser.flush(), std::runtime_error); + + Parser big(VideoCodec::H264); + const std::uint32_t len = 33U * 1024U * 1024U; + push(big, Bytes{static_cast(len >> 24), static_cast(len >> 16), + static_cast(len >> 8), static_cast(len)}); + push(big, Bytes(32U * 1024U * 1024U, 0x41)); + EXPECT_THROW((void)big.next(), std::runtime_error); +} + +} // namespace livekit::test