diff --git a/.github/workflows/builds.yml b/.github/workflows/builds.yml index 6a218178..7690bda4 100644 --- a/.github/workflows/builds.yml +++ b/.github/workflows/builds.yml @@ -169,6 +169,13 @@ jobs: restore-keys: | ${{ runner.os }}-${{ matrix.name }}-cargo-target- + # restore-keys can hydrate target/ from a prior submodule SHA. cxxbridge + # artifacts in webrtc-sys are not compatible across those bumps. + - name: Drop stale cargo target on submodule SHA cache miss + if: steps.cache-cargo-target.outputs.cache-hit != 'true' + shell: bash + run: rm -rf client-sdk-rust/target/ + # ---------- Build environment setup ---------- - name: Set Linux build environment if: runner.os == 'Linux' diff --git a/.github/workflows/make-release.yml b/.github/workflows/make-release.yml index 2bc7dbab..f1be6ed5 100644 --- a/.github/workflows/make-release.yml +++ b/.github/workflows/make-release.yml @@ -157,6 +157,7 @@ jobs: ${{ runner.os }}-${{ matrix.name }}-cargo-registry- - name: Cache cargo target + id: cache-cargo-target uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: client-sdk-rust/target/ @@ -164,6 +165,13 @@ jobs: restore-keys: | ${{ runner.os }}-${{ matrix.name }}-cargo-target- + # restore-keys can hydrate target/ from a prior submodule SHA. cxxbridge + # artifacts in webrtc-sys are not compatible across those bumps. + - name: Drop stale cargo target on submodule SHA cache miss + if: steps.cache-cargo-target.outputs.cache-hit != 'true' + shell: bash + run: rm -rf client-sdk-rust/target/ + # ---------- Build environment setup ---------- - name: Set Linux build environment if: runner.os == 'Linux' diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 02139930..386117dc 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -165,6 +165,13 @@ jobs: restore-keys: | ${{ runner.os }}-${{ matrix.name }}-cargo-target- + # restore-keys can hydrate target/ from a prior submodule SHA. cxxbridge + # artifacts in webrtc-sys are not compatible across those bumps. + - name: Drop stale cargo target on submodule SHA cache miss + if: steps.cache-cargo-target.outputs.cache-hit != 'true' + shell: bash + run: rm -rf client-sdk-rust/target/ + # ---------- Build environment setup ---------- - name: Set Linux build environment if: runner.os == 'Linux' diff --git a/README.md b/README.md index d9fd9d71..b4a60220 100644 --- a/README.md +++ b/README.md @@ -233,7 +233,10 @@ See [docs/tools.md](docs/tools.md). ## Deprecation -Future deprecations and deprecation dates will be listed here for the next major release. +The following features are deprecated and will be removed in the next major release. + +- `PacketTrailerFeatures` is deprecated. Use `FrameMetadataFeatures` via + `TrackPublishOptions::frame_metadata_features` instead. ### `v1.0.0` diff --git a/client-sdk-rust b/client-sdk-rust index 8e551062..dad794d4 160000 --- a/client-sdk-rust +++ b/client-sdk-rust @@ -1 +1 @@ -Subproject commit 8e551062c59f912159b8cebac44b2cdcce0024ef +Subproject commit dad794d414fda9e8c1de83af1c0f190506a15f8f diff --git a/include/livekit/room_event_types.h b/include/livekit/room_event_types.h index cb55f7b0..a6e1e959 100644 --- a/include/livekit/room_event_types.h +++ b/include/livekit/room_event_types.h @@ -273,15 +273,22 @@ struct AudioEncodingOptions { std::uint64_t max_bitrate = 0; }; -/// Optional RTP packet-trailer features for published video tracks. -struct PacketTrailerFeatures { +/// Optional frame metadata features for published video tracks. +struct FrameMetadataFeatures { /// Embed a user-supplied wall-clock timestamp. bool user_timestamp = false; /// Embed a monotonically increasing frame identifier. bool frame_id = false; + + /// Embed user-supplied data. + bool user_data = false; }; +/// @deprecated Use FrameMetadataFeatures instead. +using PacketTrailerFeatures [[deprecated("PacketTrailerFeatures is deprecated; use FrameMetadataFeatures instead")]] = + FrameMetadataFeatures; + /// Options for publishing a track to the room. struct TrackPublishOptions { /// Optional video encoding parameters. @@ -311,8 +318,11 @@ struct TrackPublishOptions { /// Enable pre-connect buffering for lower startup latency. std::optional preconnect_buffer; - /// Optional packet-trailer features to enable for published video. - PacketTrailerFeatures packet_trailer_features{}; + /// Optional frame metadata features to enable for published video. + std::optional frame_metadata_features; + + /// @deprecated Use frame_metadata_features instead. + FrameMetadataFeatures packet_trailer_features{}; }; // --------------------------------------------------------- diff --git a/include/livekit/video_source.h b/include/livekit/video_source.h index 1763144c..44e69d8f 100644 --- a/include/livekit/video_source.h +++ b/include/livekit/video_source.h @@ -18,6 +18,7 @@ #include #include +#include #include "livekit/ffi_handle.h" #include "livekit/visibility.h" @@ -38,18 +39,22 @@ enum class VideoRotation { /// Optional packet-trailer metadata carried alongside a video frame. /// -/// Each field is independently optional because the corresponding transport -/// feature can be negotiated separately. +/// Each field is independently optional. Enable the corresponding features in +/// @ref TrackPublishOptions before publishing the track. struct VideoFrameMetadata { + /// User-supplied capture time in microseconds. std::optional user_timestamp_us; + /// Monotonically increasing frame identifier. std::optional frame_id; + /// Free-form data to attach to the frame. + std::optional> user_data; }; /// Capture options for a single outbound video frame. struct VideoCaptureOptions { std::int64_t timestamp_us = 0; VideoRotation rotation = VideoRotation::VIDEO_ROTATION_0; - /// Populate meta data when you want to send user timestamps or frame IDs. + /// Optional per-frame metadata (timestamps, frame IDs, user data). std::optional metadata; }; diff --git a/src/room_proto_converter.cpp b/src/room_proto_converter.cpp index 53b49c59..5ebedbcf 100644 --- a/src/room_proto_converter.cpp +++ b/src/room_proto_converter.cpp @@ -28,28 +28,34 @@ std::string bytesToString(const std::vector& bytes) { return std::string(reinterpret_cast(bytes.data()), bytes.size()); } -std::vector toProto(const PacketTrailerFeatures& features) { - std::vector out; - out.reserve(2); +std::vector toProto(const FrameMetadataFeatures& features) { + std::vector out; + out.reserve(3); if (features.user_timestamp) { - out.push_back(proto::PacketTrailerFeature::PTF_USER_TIMESTAMP); + out.push_back(proto::FrameMetadataFeature::FMF_USER_TIMESTAMP); } if (features.frame_id) { - out.push_back(proto::PacketTrailerFeature::PTF_FRAME_ID); + out.push_back(proto::FrameMetadataFeature::FMF_FRAME_ID); + } + if (features.user_data) { + out.push_back(proto::FrameMetadataFeature::FMF_USER_DATA); } return out; } -PacketTrailerFeatures fromProto(const google::protobuf::RepeatedField& features) { - PacketTrailerFeatures out{}; +FrameMetadataFeatures fromProto(const google::protobuf::RepeatedField& features) { + FrameMetadataFeatures out{}; for (const int feature : features) { switch (feature) { - case proto::PacketTrailerFeature::PTF_USER_TIMESTAMP: + case proto::FrameMetadataFeature::FMF_USER_TIMESTAMP: out.user_timestamp = true; break; - case proto::PacketTrailerFeature::PTF_FRAME_ID: + case proto::FrameMetadataFeature::FMF_FRAME_ID: out.frame_id = true; break; + case proto::FrameMetadataFeature::FMF_USER_DATA: + out.user_data = true; + break; default: break; } @@ -57,6 +63,15 @@ PacketTrailerFeatures fromProto(const google::protobuf::RepeatedField& feat return out; } +// Compatibility shim: only needed while deprecated packet_trailer_features remains public. +FrameMetadataFeatures mergedFrameMetadataFeatures(const TrackPublishOptions& in) { + FrameMetadataFeatures out = in.frame_metadata_features.value_or(FrameMetadataFeatures{}); + out.user_timestamp = out.user_timestamp || in.packet_trailer_features.user_timestamp; + out.frame_id = out.frame_id || in.packet_trailer_features.frame_id; + out.user_data = out.user_data || in.packet_trailer_features.user_data; + return out; +} + } // namespace // --------- enum conversions --------- @@ -502,8 +517,8 @@ proto::TrackPublishOptions toProto(const TrackPublishOptions& in) { if (in.preconnect_buffer) { msg.set_preconnect_buffer(*in.preconnect_buffer); } - for (const proto::PacketTrailerFeature feature : toProto(in.packet_trailer_features)) { - msg.add_packet_trailer_features(feature); + for (const proto::FrameMetadataFeature feature : toProto(mergedFrameMetadataFeatures(in))) { + msg.add_frame_metadata_features(feature); } return msg; } @@ -537,7 +552,11 @@ TrackPublishOptions fromProto(const proto::TrackPublishOptions& in) { if (in.has_preconnect_buffer()) { out.preconnect_buffer = in.preconnect_buffer(); } - out.packet_trailer_features = fromProto(in.packet_trailer_features()); + const FrameMetadataFeatures frame_metadata_features = fromProto(in.frame_metadata_features()); + if (in.frame_metadata_features_size() > 0) { + out.frame_metadata_features = frame_metadata_features; + } + out.packet_trailer_features = frame_metadata_features; return out; } diff --git a/src/tests/common/test_common.h b/src/tests/common/test_common.h index 427cd4ca..40a1ba56 100644 --- a/src/tests/common/test_common.h +++ b/src/tests/common/test_common.h @@ -537,6 +537,13 @@ class LiveKitTestBase : public ::testing::Test { } } + /// Fail the test if the required environment variables are not set + void failIfNotConfigured() { + if (!config_.available) { + FAIL() << "LIVEKIT_URL, LIVEKIT_TOKEN_A, and LIVEKIT_TOKEN_B not set"; + } + } + /** * Register a custom trace event to analyze in TearDown(). * diff --git a/src/tests/integration/test_video_frame_metadata.cpp b/src/tests/integration/test_video_frame_metadata.cpp index 3c205454..4c309b05 100644 --- a/src/tests/integration/test_video_frame_metadata.cpp +++ b/src/tests/integration/test_video_frame_metadata.cpp @@ -22,6 +22,7 @@ #include #include #include +#include #include "tests/common/test_common.h" @@ -30,7 +31,7 @@ namespace livekit::test { class VideoFrameMetadataServerTest : public LiveKitTestBase {}; TEST_F(VideoFrameMetadataServerTest, UserTimestampRoundTripsToReceiverEventCallback) { - skipIfNotConfigured(); + failIfNotConfigured(); Room sender_room; Room receiver_room; @@ -69,7 +70,8 @@ TEST_F(VideoFrameMetadataServerTest, UserTimestampRoundTripsToReceiverEventCallb TrackPublishOptions publish_options; publish_options.source = TrackSource::SOURCE_CAMERA; publish_options.simulcast = false; - publish_options.packet_trailer_features.user_timestamp = true; + publish_options.frame_metadata_features.emplace(); + publish_options.frame_metadata_features->user_timestamp = true; ASSERT_NO_THROW(lockLocalParticipant(sender_room)->publishTrack(track, publish_options)); @@ -155,4 +157,127 @@ TEST_F(VideoFrameMetadataServerTest, UserTimestampRoundTripsToReceiverEventCallb EXPECT_LT(received_at - *received_user_timestamp_snapshot, 1000000u); } +TEST_F(VideoFrameMetadataServerTest, UserDataRoundTripsToReceiverEventCallback) { + failIfNotConfigured(); + + Room sender_room; + Room receiver_room; + RoomOptions options; + + ASSERT_TRUE(receiver_room.connect(config_.url, config_.token_b, options)); + ASSERT_TRUE(sender_room.connect(config_.url, config_.token_a, options)); + ASSERT_FALSE(sender_room.localParticipant().expired()); + ASSERT_FALSE(receiver_room.localParticipant().expired()); + + const std::string sender_identity = lockLocalParticipant(sender_room)->identity(); + ASSERT_FALSE(sender_identity.empty()); + ASSERT_TRUE(waitForParticipant(&receiver_room, sender_identity, 10s)); + + std::mutex mutex; + std::condition_variable cv; + std::optional> received_user_data; + + const std::string track_name = "userdata-track"; + const std::vector expected_user_data{0x01, 0x02, 0xab, 0xcd, 0xef}; + + receiver_room.setOnVideoFrameEventCallback(sender_identity, track_name, + [&mutex, &cv, &received_user_data](const VideoFrameEvent& event) { + std::lock_guard lock(mutex); + if (!event.metadata || !event.metadata->user_data.has_value()) { + return; + } + if (!event.metadata->user_data->empty()) { + received_user_data = event.metadata->user_data; + cv.notify_all(); + } + }); + + auto source = std::make_shared(16, 16); + auto track = LocalVideoTrack::createLocalVideoTrack(track_name, source); + + TrackPublishOptions publish_options; + publish_options.source = TrackSource::SOURCE_CAMERA; + publish_options.simulcast = false; + publish_options.frame_metadata_features.emplace(); + publish_options.frame_metadata_features->user_data = true; + + ASSERT_NO_THROW(lockLocalParticipant(sender_room)->publishTrack(track, publish_options)); + + const auto track_ready_deadline = std::chrono::steady_clock::now() + 10s; + bool receiver_track_ready = false; + while (std::chrono::steady_clock::now() < track_ready_deadline) { + auto sender_on_receiver = receiver_room.remoteParticipant(sender_identity).lock(); + if (sender_on_receiver != nullptr) { + for (const auto& [sid, publication] : sender_on_receiver->trackPublications()) { + (void)sid; + if (publication == nullptr) { + continue; + } + + if (publication->name() != track_name || publication->kind() != TrackKind::KIND_VIDEO) { + continue; + } + + if (publication->subscribed() && publication->track() != nullptr) { + receiver_track_ready = true; + break; + } + } + } + + if (receiver_track_ready) { + break; + } + + std::this_thread::sleep_for(10ms); + } + + ASSERT_TRUE(receiver_track_ready) << "Timed out waiting for receiver video track subscription"; + + std::atomic publishing{true}; + std::thread publisher([&]() { + VideoFrame frame = VideoFrame::create(16, 16, VideoBufferType::RGBA); + std::fill(frame.data(), frame.data() + frame.dataSize(), 0x7f); + + while (publishing.load(std::memory_order_relaxed)) { + VideoCaptureOptions capture_options; + capture_options.timestamp_us = static_cast(getTimestampUs()); + capture_options.metadata = VideoFrameMetadata{}; + capture_options.metadata->user_data = expected_user_data; + + try { + source->captureFrame(frame, capture_options); + } catch (...) { + publishing.store(false, std::memory_order_relaxed); + break; + } + std::this_thread::sleep_for(50ms); + } + }); + + bool got_metadata = false; + { + std::unique_lock lock(mutex); + got_metadata = cv.wait_for(lock, 10s, [&received_user_data] { return received_user_data.has_value(); }); + } + + publishing.store(false, std::memory_order_relaxed); + publisher.join(); + + std::optional> received_user_data_snapshot; + { + std::lock_guard lock(mutex); + received_user_data_snapshot = received_user_data; + } + + receiver_room.clearOnVideoFrameCallback(sender_identity, track_name); + if (track->publication()) { + lockLocalParticipant(sender_room)->unpublishTrack(track->publication()->sid()); + } + + ASSERT_TRUE(got_metadata) << "Timed out waiting for user data metadata"; + ASSERT_TRUE(received_user_data_snapshot.has_value()); + EXPECT_EQ(*received_user_data_snapshot, expected_user_data); +} + } // namespace livekit::test diff --git a/src/tests/unit/test_room_event_types.cpp b/src/tests/unit/test_room_event_types.cpp index db423142..29d08f8c 100644 --- a/src/tests/unit/test_room_event_types.cpp +++ b/src/tests/unit/test_room_event_types.cpp @@ -43,8 +43,10 @@ TEST(RoomEventTypesTest, AttributeEntryConstruction) { TEST(RoomEventTypesTest, TrackPublishOptionsDefaults) { TrackPublishOptions options; + EXPECT_FALSE(options.frame_metadata_features.has_value()); EXPECT_FALSE(options.packet_trailer_features.user_timestamp); EXPECT_FALSE(options.packet_trailer_features.frame_id); + EXPECT_FALSE(options.packet_trailer_features.user_data); } TEST(RoomEventTypesTest, UserPacketDataDefaults) { diff --git a/src/tests/unit/test_video_frame_metadata.cpp b/src/tests/unit/test_video_frame_metadata.cpp index 7f08ff6b..b87bbdab 100644 --- a/src/tests/unit/test_video_frame_metadata.cpp +++ b/src/tests/unit/test_video_frame_metadata.cpp @@ -74,24 +74,118 @@ TEST(VideoFrameMetadataTest, BothFieldsArePreserved) { EXPECT_EQ(round_trip->frame_id, std::optional(456)); } +TEST(VideoFrameMetadataTest, UserDataOnlyIsPreserved) { + VideoFrameMetadata metadata; + metadata.user_data = std::vector{0x01, 0x02, 0xab, 0xcd}; + + auto proto_metadata = toProto(metadata); + ASSERT_TRUE(proto_metadata.has_value()); + EXPECT_FALSE(proto_metadata->has_user_timestamp()); + EXPECT_FALSE(proto_metadata->has_frame_id()); + EXPECT_TRUE(proto_metadata->has_user_data()); + EXPECT_EQ(proto_metadata->user_data(), std::string("\x01\x02\xab\xcd", 4)); + + auto round_trip = fromProto(*proto_metadata); + ASSERT_TRUE(round_trip.has_value()); + EXPECT_EQ(round_trip->user_timestamp_us, std::nullopt); + EXPECT_EQ(round_trip->frame_id, std::nullopt); + ASSERT_TRUE(round_trip->user_data.has_value()); + EXPECT_EQ(*round_trip->user_data, (std::vector{0x01, 0x02, 0xab, 0xcd})); +} + +TEST(VideoFrameMetadataTest, AllFieldsArePreserved) { + VideoFrameMetadata metadata; + metadata.user_timestamp_us = 123; + metadata.frame_id = 456; + metadata.user_data = std::vector{0xde, 0xad}; + + auto proto_metadata = toProto(metadata); + ASSERT_TRUE(proto_metadata.has_value()); + EXPECT_TRUE(proto_metadata->has_user_timestamp()); + EXPECT_TRUE(proto_metadata->has_frame_id()); + EXPECT_TRUE(proto_metadata->has_user_data()); + + auto round_trip = fromProto(*proto_metadata); + ASSERT_TRUE(round_trip.has_value()); + EXPECT_EQ(round_trip->user_timestamp_us, std::optional(123)); + EXPECT_EQ(round_trip->frame_id, std::optional(456)); + ASSERT_TRUE(round_trip->user_data.has_value()); + EXPECT_EQ(*round_trip->user_data, (std::vector{0xde, 0xad})); +} + TEST(VideoFrameMetadataTest, EmptyProtoMetadataIsIgnored) { proto::FrameMetadata metadata; EXPECT_FALSE(fromProto(metadata).has_value()); } -TEST(TrackPublishOptionsTest, PacketTrailerFeaturesRoundTrip) { +TEST(TrackPublishOptionsTest, FrameMetadataFeaturesRoundTrip) { TrackPublishOptions options; - options.packet_trailer_features.user_timestamp = true; - options.packet_trailer_features.frame_id = true; + options.frame_metadata_features.emplace(); + options.frame_metadata_features->user_timestamp = true; + options.frame_metadata_features->frame_id = true; + options.frame_metadata_features->user_data = true; + + proto::TrackPublishOptions proto_options = toProto(options); + ASSERT_EQ(proto_options.frame_metadata_features_size(), 3); + EXPECT_EQ(proto_options.frame_metadata_features(0), proto::FrameMetadataFeature::FMF_USER_TIMESTAMP); + EXPECT_EQ(proto_options.frame_metadata_features(1), proto::FrameMetadataFeature::FMF_FRAME_ID); + EXPECT_EQ(proto_options.frame_metadata_features(2), proto::FrameMetadataFeature::FMF_USER_DATA); + + TrackPublishOptions round_trip = fromProto(proto_options); + ASSERT_TRUE(round_trip.frame_metadata_features.has_value()); + EXPECT_TRUE(round_trip.frame_metadata_features->user_timestamp); + EXPECT_TRUE(round_trip.frame_metadata_features->frame_id); + EXPECT_TRUE(round_trip.frame_metadata_features->user_data); +} + +TEST(TrackPublishOptionsTest, DeprecatedPacketTrailerFeaturesAreMerged) { + TrackPublishOptions options; + +#if defined(__clang__) +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdeprecated-declarations" +#elif defined(__GNUC__) +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" +#endif + PacketTrailerFeatures legacy_features; + legacy_features.user_timestamp = true; + legacy_features.frame_id = true; + legacy_features.user_data = true; +#if defined(__clang__) +#pragma clang diagnostic pop +#elif defined(__GNUC__) +#pragma GCC diagnostic pop +#endif + options.packet_trailer_features = legacy_features; proto::TrackPublishOptions proto_options = toProto(options); - ASSERT_EQ(proto_options.packet_trailer_features_size(), 2); - EXPECT_EQ(proto_options.packet_trailer_features(0), proto::PacketTrailerFeature::PTF_USER_TIMESTAMP); - EXPECT_EQ(proto_options.packet_trailer_features(1), proto::PacketTrailerFeature::PTF_FRAME_ID); + ASSERT_EQ(proto_options.frame_metadata_features_size(), 3); + EXPECT_EQ(proto_options.frame_metadata_features(0), proto::FrameMetadataFeature::FMF_USER_TIMESTAMP); + EXPECT_EQ(proto_options.frame_metadata_features(1), proto::FrameMetadataFeature::FMF_FRAME_ID); + EXPECT_EQ(proto_options.frame_metadata_features(2), proto::FrameMetadataFeature::FMF_USER_DATA); TrackPublishOptions round_trip = fromProto(proto_options); + ASSERT_TRUE(round_trip.frame_metadata_features.has_value()); + EXPECT_TRUE(round_trip.frame_metadata_features->user_timestamp); + EXPECT_TRUE(round_trip.frame_metadata_features->frame_id); + EXPECT_TRUE(round_trip.frame_metadata_features->user_data); + +#if defined(__clang__) +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdeprecated-declarations" +#elif defined(__GNUC__) +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" +#endif EXPECT_TRUE(round_trip.packet_trailer_features.user_timestamp); EXPECT_TRUE(round_trip.packet_trailer_features.frame_id); + EXPECT_TRUE(round_trip.packet_trailer_features.user_data); +#if defined(__clang__) +#pragma clang diagnostic pop +#elif defined(__GNUC__) +#pragma GCC diagnostic pop +#endif } } // namespace livekit::test diff --git a/src/video_utils.cpp b/src/video_utils.cpp index 19bd4300..faa3fba8 100644 --- a/src/video_utils.cpp +++ b/src/video_utils.cpp @@ -18,6 +18,7 @@ #include #include +#include #include #include "ffi.pb.h" @@ -98,8 +99,12 @@ std::optional toProto(const std::optionalframe_id.has_value()) { proto_metadata.set_frame_id(*metadata->frame_id); } + if (metadata->user_data.has_value() && !metadata->user_data->empty()) { + const auto& bytes = *metadata->user_data; + proto_metadata.set_user_data(std::string(reinterpret_cast(bytes.data()), bytes.size())); + } - if (!proto_metadata.has_user_timestamp() && !proto_metadata.has_frame_id()) { + if (!proto_metadata.has_user_timestamp() && !proto_metadata.has_frame_id() && !proto_metadata.has_user_data()) { return std::nullopt; } @@ -114,8 +119,12 @@ std::optional fromProto(const proto::FrameMetadata& metadata if (metadata.has_frame_id()) { out.frame_id = metadata.frame_id(); } + if (metadata.has_user_data()) { + const auto& bytes = metadata.user_data(); + out.user_data = std::vector(bytes.begin(), bytes.end()); + } - if (!out.user_timestamp_us.has_value() && !out.frame_id.has_value()) { + if (!out.user_timestamp_us.has_value() && !out.frame_id.has_value() && !out.user_data.has_value()) { return std::nullopt; }