diff --git a/go.mod b/go.mod index 1cb7c844..560e986e 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,7 @@ require ( github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731 github.com/livekit/media-sdk v0.0.0-20251106223430-dd8f5e0de2cf github.com/livekit/mediatransportutil v0.0.0-20251128105421-19c7a7b81c22 - github.com/livekit/protocol v1.45.1 + github.com/livekit/protocol v1.45.2 github.com/magefile/mage v1.15.0 github.com/moby/buildkit v0.26.2 github.com/moby/patternmatcher v0.6.0 @@ -60,7 +60,7 @@ require ( github.com/frostbyte73/core v0.1.1 github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/gammazero/deque v1.2.1 - github.com/go-jose/go-jose/v3 v3.0.4 // indirect + github.com/go-jose/go-jose/v3 v3.0.5 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/gofrs/flock v0.13.0 // indirect diff --git a/go.sum b/go.sum index d2e67699..257c8925 100644 --- a/go.sum +++ b/go.sum @@ -101,8 +101,8 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/gammazero/deque v1.2.1 h1:9fnQVFCCZ9/NOc7ccTNqzoKd1tCWOqeI05/lPqFPMGQ= github.com/gammazero/deque v1.2.1/go.mod h1:5nSFkzVm+afG9+gy0VIowlqVAW4N8zNcMne+CMQVD2g= -github.com/go-jose/go-jose/v3 v3.0.4 h1:Wp5HA7bLQcKnf6YYao/4kpRpVMp/yf6+pJKV8WFSaNY= -github.com/go-jose/go-jose/v3 v3.0.4/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ= +github.com/go-jose/go-jose/v3 v3.0.5 h1:BLLJWbC4nMZOfuPVxoZIxeYsn6Nl2r1fITaJ78UQlVQ= +github.com/go-jose/go-jose/v3 v3.0.5/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -159,8 +159,8 @@ github.com/livekit/media-sdk v0.0.0-20251106223430-dd8f5e0de2cf h1:gvun6axx2Mrh8 github.com/livekit/media-sdk v0.0.0-20251106223430-dd8f5e0de2cf/go.mod h1:7ssWiG+U4xnbvLih9WiZbhQP6zIKMjgXdUtIE1bm/E8= github.com/livekit/mediatransportutil v0.0.0-20251128105421-19c7a7b81c22 h1:dzCBxOGLLWVtQhL7OYK2EGN+5Q+23Mq/jfz4vQisirA= github.com/livekit/mediatransportutil v0.0.0-20251128105421-19c7a7b81c22/go.mod h1:mSNtYzSf6iY9xM3UX42VEI+STHvMgHmrYzEHPcdhB8A= -github.com/livekit/protocol v1.45.1 h1:4cbynsPZW32gS2z6nUWfAfr4YaTUwZSKUiLpSpjX+lQ= -github.com/livekit/protocol v1.45.1/go.mod h1:63AUi0vQak6Y6gPqSBHLc+ExYTUwEqF/m4b2IRW1iO0= +github.com/livekit/protocol v1.45.2 h1:JZMqVj3wTnYROfSPSaT0Tg3wmGot6aHXxmwXmUojfeM= +github.com/livekit/protocol v1.45.2/go.mod h1:e6QdWDkfot+M2nRh0eitJUS0ZLuwvKCsfiz2pWWSG3s= github.com/livekit/psrpc v0.7.1 h1:ms37az0QTD3UXIWuUC5D/SkmKOlRMVRsI261eBWu/Vw= github.com/livekit/psrpc v0.7.1/go.mod h1:bZ4iHFQptTkbPnB0LasvRNu/OBYXEu1NA6O5BMFo9kk= github.com/magefile/mage v1.15.0 h1:BvGheCMAsG3bWUDbZ8AyXXpCNwU9u5CB6sM+HNb9HYg= diff --git a/localparticipant.go b/localparticipant.go index 53596bdd..d6def4a2 100644 --- a/localparticipant.go +++ b/localparticipant.go @@ -131,17 +131,18 @@ func (p *LocalParticipant) PublishTrack(track webrtc.TrackLocal, opts *TrackPubl } req := &livekit.AddTrackRequest{ - Cid: track.ID(), - Name: opts.Name, - Source: opts.Source, - Type: kind.ProtoType(), - Width: uint32(opts.VideoWidth), - Height: uint32(opts.VideoHeight), - DisableDtx: opts.DisableDTX, - Stereo: opts.Stereo, - Stream: opts.Stream, - Encryption: opts.Encryption, - BackupCodecPolicy: opts.BackupCodecPolicy, + Cid: track.ID(), + Name: opts.Name, + Source: opts.Source, + Type: kind.ProtoType(), + Width: uint32(opts.VideoWidth), + Height: uint32(opts.VideoHeight), + DisableDtx: opts.DisableDTX, + Stereo: opts.Stereo, + Stream: opts.Stream, + Encryption: opts.Encryption, + BackupCodecPolicy: opts.BackupCodecPolicy, + PacketTrailerFeatures: packetTrailerFeaturesFromOpts(opts), } if kind == TrackKindVideo { // single layer @@ -287,13 +288,14 @@ func (p *LocalParticipant) PublishSimulcastTrack(tracks []*LocalTrack, opts *Tra layers = append(layers, st.videoLayer) } req := &livekit.AddTrackRequest{ - Cid: mainTrack.ID(), - Name: opts.Name, - Source: opts.Source, - Type: pub.Kind().ProtoType(), - Width: mainTrack.videoLayer.Width, - Height: mainTrack.videoLayer.Height, - Layers: layers, + Cid: mainTrack.ID(), + Name: opts.Name, + Source: opts.Source, + Type: pub.Kind().ProtoType(), + Width: mainTrack.videoLayer.Width, + Height: mainTrack.videoLayer.Height, + Layers: layers, + PacketTrailerFeatures: packetTrailerFeaturesFromOpts(opts), SimulcastCodecs: []*livekit.SimulcastCodec{ { Codec: mainTrack.Codec().MimeType, @@ -1102,6 +1104,20 @@ func (p *LocalParticipant) SendFile(filePath string, options StreamBytesOptions) return &writer.Info, nil } +func packetTrailerFeaturesFromOpts(opts *TrackPublicationOptions) []livekit.PacketTrailerFeature { + if opts == nil || (!opts.AttachUserTimestamp && !opts.AttachFrameId) { + return nil + } + var features []livekit.PacketTrailerFeature + if opts.AttachUserTimestamp { + features = append(features, livekit.PacketTrailerFeature_PTF_USER_TIMESTAMP) + } + if opts.AttachFrameId { + features = append(features, livekit.PacketTrailerFeature_PTF_FRAME_ID) + } + return features +} + func (p *LocalParticipant) getPublishTransport() *PCTransport { publisher, ok := p.engine.Publisher() if ok { diff --git a/packet_trailer.go b/packet_trailer.go new file mode 100644 index 00000000..04172cf6 --- /dev/null +++ b/packet_trailer.go @@ -0,0 +1,177 @@ +package lksdk + +import ( + "bytes" + "encoding/binary" +) + +// packetTrailerSEIUUID is the UUID embedded in H.264/H.265 SEI +// user_data_unregistered messages that carry an LKTS packet trailer. +var packetTrailerSEIUUID = [16]byte{ + 0x3f, 0xa8, 0x5f, 0x64, 0x57, 0x17, 0x45, 0x62, + 0xb3, 0xfc, 0x2c, 0x96, 0x3f, 0x66, 0xaf, 0xa6, +} + +const ( + // packetTrailerMagic must remain consistent with kPacketTrailerMagic + // in rust-sdks/webrtc-sys/include/livekit/packet_trailer.h. + packetTrailerMagic = "LKTS" + + // TLV tag IDs (XORed with 0xFF on the wire). + tagTimestampUs = 0x01 // value: 8 bytes big-endian uint64 + tagFrameId = 0x02 // value: 4 bytes big-endian uint32 + + // TLV element sizes: tag(1) + len(1) + value. + timestampTlvSize = 10 // 1 + 1 + 8 + frameIdTlvSize = 6 // 1 + 1 + 4 + + // Trailer envelope: [trailer_len: 1B XORed] [magic: 4B raw] = 5 bytes. + trailerEnvelopeSize = 5 + + packetTrailerMinSize = timestampTlvSize + trailerEnvelopeSize + packetTrailerMaxSize = timestampTlvSize + frameIdTlvSize + trailerEnvelopeSize +) + +// FrameMetadata holds the metadata embedded in a packet trailer. +type FrameMetadata struct { + UserTimestampUs uint64 + FrameId uint32 +} + +// appendPacketTrailer returns a new slice containing data followed by a +// TLV-encoded packet trailer. All TLV bytes are XORed with 0xFF to prevent +// H.264 NAL start-code sequences from appearing inside the trailer. +// +// Wire layout: +// +// [original data] +// [TLV: tag=0x01 ^ 0xFF, len=8 ^ 0xFF, 8-byte BE timestamp ^ 0xFF] +// [TLV: tag=0x02 ^ 0xFF, len=4 ^ 0xFF, 4-byte BE frame_id ^ 0xFF] (omitted when frameId == 0) +// [trailer_len ^ 0xFF] +// [magic "LKTS" raw] +func appendPacketTrailer(data []byte, meta FrameMetadata) []byte { + hasFrameId := meta.FrameId != 0 + trailerLen := timestampTlvSize + trailerEnvelopeSize + if hasFrameId { + trailerLen += frameIdTlvSize + } + + out := make([]byte, len(data)+trailerLen) + copy(out, data) + pos := len(data) + + // TLV: timestamp_us + out[pos] = byte(tagTimestampUs) ^ 0xFF + out[pos+1] = 8 ^ 0xFF + binary.BigEndian.PutUint64(out[pos+2:], ^meta.UserTimestampUs) + pos += timestampTlvSize + + // TLV: frame_id (only when non-zero) + if hasFrameId { + out[pos] = byte(tagFrameId) ^ 0xFF + out[pos+1] = 4 ^ 0xFF + binary.BigEndian.PutUint32(out[pos+2:], ^meta.FrameId) + pos += frameIdTlvSize + } + + // Envelope: trailer_len (XORed) + magic (raw) + out[pos] = byte(trailerLen) ^ 0xFF + copy(out[pos+1:], packetTrailerMagic) + + return out +} + +// parsePacketTrailer attempts to parse a packet trailer from the end of the +// provided buffer. It returns the extracted FrameMetadata and true when a +// valid trailer is present. +func parsePacketTrailer(data []byte) (FrameMetadata, bool) { + if len(data) < trailerEnvelopeSize { + return FrameMetadata{}, false + } + + magicStart := len(data) - len(packetTrailerMagic) + if string(data[magicStart:]) != packetTrailerMagic { + return FrameMetadata{}, false + } + + trailerLen := int(data[magicStart-1] ^ 0xFF) + if trailerLen < trailerEnvelopeSize || trailerLen > len(data) { + return FrameMetadata{}, false + } + + tlvStart := len(data) - trailerLen + tlvRegionLen := trailerLen - trailerEnvelopeSize + + var meta FrameMetadata + foundAny := false + pos := 0 + + for pos+2 <= tlvRegionLen { + tag := data[tlvStart+pos] ^ 0xFF + length := int(data[tlvStart+pos+1] ^ 0xFF) + pos += 2 + + if pos+length > tlvRegionLen { + break + } + + valStart := tlvStart + pos + + switch { + case tag == tagTimestampUs && length == 8: + var ts uint64 + for i := 0; i < 8; i++ { + ts = (ts << 8) | uint64(data[valStart+i]^0xFF) + } + meta.UserTimestampUs = ts + foundAny = true + case tag == tagFrameId && length == 4: + var fid uint32 + for i := 0; i < 4; i++ { + fid = (fid << 8) | uint32(data[valStart+i]^0xFF) + } + meta.FrameId = fid + foundAny = true + } + + pos += length + } + + if !foundAny { + return FrameMetadata{}, false + } + return meta, true +} + +// stripPacketTrailer returns the data with the packet trailer removed, if +// present. If no valid trailer is found, the original slice is returned. +func stripPacketTrailer(data []byte) []byte { + if len(data) < trailerEnvelopeSize { + return data + } + + magicStart := len(data) - len(packetTrailerMagic) + if string(data[magicStart:]) != packetTrailerMagic { + return data + } + + trailerLen := int(data[magicStart-1] ^ 0xFF) + if trailerLen < trailerEnvelopeSize || trailerLen > len(data) { + return data + } + + return data[:len(data)-trailerLen] +} + +// parseSEIUserData validates the UUID prefix of an SEI user_data_unregistered +// payload and parses the remaining bytes as an LKTS packet trailer. +// userData must start at the UUID (i.e. the first 16 bytes are the UUID). +func parseSEIUserData(userData []byte) (FrameMetadata, bool) { + if len(userData) < 16+packetTrailerMinSize { + return FrameMetadata{}, false + } + if !bytes.Equal(userData[:16], packetTrailerSEIUUID[:]) { + return FrameMetadata{}, false + } + return parsePacketTrailer(userData[16:]) +} diff --git a/packet_trailer_h264_parser.go b/packet_trailer_h264_parser.go new file mode 100644 index 00000000..1dbc5e16 --- /dev/null +++ b/packet_trailer_h264_parser.go @@ -0,0 +1,55 @@ +package lksdk + +// parseH264SEIPacketTrailer parses H264 SEI NAL units (type 6) carrying +// user_data_unregistered messages with an LKTS packet trailer and returns +// the embedded FrameMetadata when detected. +// +// Expected payload format (after the 1-byte NAL header): +// +// payloadType = 5 (user_data_unregistered) +// payloadSize = variable +// UUID = 16 bytes (3fa85f64-5717-4562-b3fc-2c963f66afa6) +// trailer = LKTS TLV-encoded packet trailer (XOR'd with 0xFF) +func parseH264SEIPacketTrailer(nalData []byte) (FrameMetadata, bool) { + if len(nalData) < 2 { + return FrameMetadata{}, false + } + + // Skip 1-byte NAL header. + payload := nalData[1:] + i := 0 + + // Parse payloadType (can be extended with 0xFF bytes). + payloadType := 0 + for i < len(payload) && payload[i] == 0xFF { + payloadType += 255 + i++ + } + if i >= len(payload) { + return FrameMetadata{}, false + } + payloadType += int(payload[i]) + i++ + + if payloadType != 5 { + return FrameMetadata{}, false + } + + // Parse payloadSize (can be extended with 0xFF bytes). + payloadSize := 0 + for i < len(payload) && payload[i] == 0xFF { + payloadSize += 255 + i++ + } + if i >= len(payload) { + return FrameMetadata{}, false + } + payloadSize += int(payload[i]) + i++ + + if len(payload) < i+payloadSize { + return FrameMetadata{}, false + } + + return parseSEIUserData(payload[i : i+payloadSize]) +} diff --git a/packet_trailer_h264_parser_test.go b/packet_trailer_h264_parser_test.go new file mode 100644 index 00000000..98d89198 --- /dev/null +++ b/packet_trailer_h264_parser_test.go @@ -0,0 +1,86 @@ +package lksdk + +import ( + "testing" +) + +func TestParseH264SEIPacketTrailer(t *testing.T) { + wantMeta := FrameMetadata{UserTimestampUs: 1234567890} + + buildNAL := func(uuid [16]byte, meta FrameMetadata) []byte { + // NAL header for SEI (nal_unit_type = 6). + nal := []byte{0x06} + + // Build user_data payload: UUID + LKTS trailer. + trailer := appendPacketTrailer(nil, meta) + userData := append(uuid[:], trailer...) + + // payloadType = 5, payloadSize = len(userData) + nal = append(nal, 0x05, byte(len(userData))) + nal = append(nal, userData...) + return nal + } + + t.Run("accepts matching UUID with timestamp only", func(t *testing.T) { + got, ok := parseH264SEIPacketTrailer(buildNAL(packetTrailerSEIUUID, wantMeta)) + if !ok { + t.Fatalf("expected ok=true") + } + if got.UserTimestampUs != wantMeta.UserTimestampUs { + t.Fatalf("timestamp mismatch: got %d want %d", got.UserTimestampUs, wantMeta.UserTimestampUs) + } + if got.FrameId != 0 { + t.Fatalf("expected frame_id 0, got %d", got.FrameId) + } + }) + + t.Run("accepts matching UUID with timestamp and frame_id", func(t *testing.T) { + meta := FrameMetadata{UserTimestampUs: 42, FrameId: 12345} + got, ok := parseH264SEIPacketTrailer(buildNAL(packetTrailerSEIUUID, meta)) + if !ok { + t.Fatalf("expected ok=true") + } + if got.UserTimestampUs != 42 { + t.Fatalf("timestamp mismatch: got %d want 42", got.UserTimestampUs) + } + if got.FrameId != 12345 { + t.Fatalf("frame_id mismatch: got %d want 12345", got.FrameId) + } + }) + + t.Run("rejects non-matching UUID", func(t *testing.T) { + badUUID := packetTrailerSEIUUID + badUUID[0] ^= 0xFF + + _, ok := parseH264SEIPacketTrailer(buildNAL(badUUID, wantMeta)) + if ok { + t.Fatalf("expected ok=false for non-matching UUID") + } + }) + + t.Run("rejects truncated trailer", func(t *testing.T) { + nal := buildNAL(packetTrailerSEIUUID, wantMeta) + // Chop off the last few bytes to corrupt the trailer. + nal = nal[:len(nal)-3] + _, ok := parseH264SEIPacketTrailer(nal) + if ok { + t.Fatalf("expected ok=false for truncated trailer") + } + }) + + t.Run("rejects wrong payloadType", func(t *testing.T) { + nal := []byte{0x06, 0x04, 0x18} // payloadType=4, payloadSize=24 + nal = append(nal, make([]byte, 24)...) + _, ok := parseH264SEIPacketTrailer(nal) + if ok { + t.Fatalf("expected ok=false for wrong payloadType") + } + }) + + t.Run("rejects nal too short", func(t *testing.T) { + _, ok := parseH264SEIPacketTrailer([]byte{0x06}) + if ok { + t.Fatalf("expected ok=false for short NAL") + } + }) +} diff --git a/packet_trailer_h265_parser.go b/packet_trailer_h265_parser.go new file mode 100644 index 00000000..64b9b2b3 --- /dev/null +++ b/packet_trailer_h265_parser.go @@ -0,0 +1,55 @@ +package lksdk + +// parseH265SEIPacketTrailer parses H265 prefix SEI NAL units (type 39) carrying +// user_data_unregistered messages with an LKTS packet trailer and returns +// the embedded FrameMetadata when detected. +// +// Expected payload format (after the 2-byte NAL header): +// +// payloadType = 5 (user_data_unregistered) +// payloadSize = variable +// UUID = 16 bytes (3fa85f64-5717-4562-b3fc-2c963f66afa6) +// trailer = LKTS TLV-encoded packet trailer (XOR'd with 0xFF) +func parseH265SEIPacketTrailer(nalData []byte) (FrameMetadata, bool) { + if len(nalData) < 3 { + return FrameMetadata{}, false + } + + // Skip 2-byte NAL header. + payload := nalData[2:] + i := 0 + + // Parse payloadType (can be extended with 0xFF bytes). + payloadType := 0 + for i < len(payload) && payload[i] == 0xFF { + payloadType += 255 + i++ + } + if i >= len(payload) { + return FrameMetadata{}, false + } + payloadType += int(payload[i]) + i++ + + if payloadType != 5 { + return FrameMetadata{}, false + } + + // Parse payloadSize (can be extended with 0xFF bytes). + payloadSize := 0 + for i < len(payload) && payload[i] == 0xFF { + payloadSize += 255 + i++ + } + if i >= len(payload) { + return FrameMetadata{}, false + } + payloadSize += int(payload[i]) + i++ + + if len(payload) < i+payloadSize { + return FrameMetadata{}, false + } + + return parseSEIUserData(payload[i : i+payloadSize]) +} diff --git a/packet_trailer_h265_parser_test.go b/packet_trailer_h265_parser_test.go new file mode 100644 index 00000000..fe3c2ebc --- /dev/null +++ b/packet_trailer_h265_parser_test.go @@ -0,0 +1,85 @@ +package lksdk + +import ( + "testing" +) + +func TestParseH265SEIPacketTrailer(t *testing.T) { + wantMeta := FrameMetadata{UserTimestampUs: 1234567890} + + buildNAL := func(uuid [16]byte, meta FrameMetadata) []byte { + // 2-byte NAL header for prefix SEI (nal_unit_type = 39). + nal := []byte{0x4e, 0x01} + + // Build user_data payload: UUID + LKTS trailer. + trailer := appendPacketTrailer(nil, meta) + userData := append(uuid[:], trailer...) + + // payloadType = 5, payloadSize = len(userData) + nal = append(nal, 0x05, byte(len(userData))) + nal = append(nal, userData...) + return nal + } + + t.Run("accepts matching UUID with timestamp only", func(t *testing.T) { + got, ok := parseH265SEIPacketTrailer(buildNAL(packetTrailerSEIUUID, wantMeta)) + if !ok { + t.Fatalf("expected ok=true") + } + if got.UserTimestampUs != wantMeta.UserTimestampUs { + t.Fatalf("timestamp mismatch: got %d want %d", got.UserTimestampUs, wantMeta.UserTimestampUs) + } + if got.FrameId != 0 { + t.Fatalf("expected frame_id 0, got %d", got.FrameId) + } + }) + + t.Run("accepts matching UUID with timestamp and frame_id", func(t *testing.T) { + meta := FrameMetadata{UserTimestampUs: 42, FrameId: 12345} + got, ok := parseH265SEIPacketTrailer(buildNAL(packetTrailerSEIUUID, meta)) + if !ok { + t.Fatalf("expected ok=true") + } + if got.UserTimestampUs != 42 { + t.Fatalf("timestamp mismatch: got %d want 42", got.UserTimestampUs) + } + if got.FrameId != 12345 { + t.Fatalf("frame_id mismatch: got %d want 12345", got.FrameId) + } + }) + + t.Run("rejects non-matching UUID", func(t *testing.T) { + badUUID := packetTrailerSEIUUID + badUUID[0] ^= 0xFF + + _, ok := parseH265SEIPacketTrailer(buildNAL(badUUID, wantMeta)) + if ok { + t.Fatalf("expected ok=false for non-matching UUID") + } + }) + + t.Run("rejects truncated trailer", func(t *testing.T) { + nal := buildNAL(packetTrailerSEIUUID, wantMeta) + nal = nal[:len(nal)-3] + _, ok := parseH265SEIPacketTrailer(nal) + if ok { + t.Fatalf("expected ok=false for truncated trailer") + } + }) + + t.Run("rejects wrong payloadType", func(t *testing.T) { + nal := []byte{0x4e, 0x01, 0x04, 0x18} // payloadType=4, payloadSize=24 + nal = append(nal, make([]byte, 24)...) + _, ok := parseH265SEIPacketTrailer(nal) + if ok { + t.Fatalf("expected ok=false for wrong payloadType") + } + }) + + t.Run("rejects nal too short", func(t *testing.T) { + _, ok := parseH265SEIPacketTrailer([]byte{0x4e, 0x01}) + if ok { + t.Fatalf("expected ok=false for short NAL") + } + }) +} diff --git a/packet_trailer_test.go b/packet_trailer_test.go new file mode 100644 index 00000000..db6fadaf --- /dev/null +++ b/packet_trailer_test.go @@ -0,0 +1,418 @@ +package lksdk + +import ( + "bytes" + "encoding/binary" + "testing" +) + +func TestAppendParseRoundTrip_TimestampOnly(t *testing.T) { + payload := []byte{0xDE, 0xAD, 0xBE, 0xEF} + meta := FrameMetadata{UserTimestampUs: 1_700_000_000_000_000} + + result := appendPacketTrailer(payload, meta) + + if !bytes.HasPrefix(result, payload) { + t.Fatal("original payload not preserved as prefix") + } + if !bytes.HasSuffix(result, []byte(packetTrailerMagic)) { + t.Fatal("magic bytes missing at end") + } + if len(result) != len(payload)+packetTrailerMinSize { + t.Fatalf("expected len %d, got %d", len(payload)+packetTrailerMinSize, len(result)) + } + + got, ok := parsePacketTrailer(result) + if !ok { + t.Fatal("parsePacketTrailer returned false") + } + if got.UserTimestampUs != meta.UserTimestampUs { + t.Fatalf("timestamp mismatch: got %d, want %d", got.UserTimestampUs, meta.UserTimestampUs) + } + if got.FrameId != 0 { + t.Fatalf("expected frame_id 0, got %d", got.FrameId) + } +} + +func TestAppendParseRoundTrip_WithFrameId(t *testing.T) { + payload := []byte{0x01, 0x02, 0x03} + meta := FrameMetadata{UserTimestampUs: 42, FrameId: 12345} + + result := appendPacketTrailer(payload, meta) + + if len(result) != len(payload)+packetTrailerMaxSize { + t.Fatalf("expected len %d, got %d", len(payload)+packetTrailerMaxSize, len(result)) + } + + got, ok := parsePacketTrailer(result) + if !ok { + t.Fatal("parsePacketTrailer returned false") + } + if got.UserTimestampUs != 42 { + t.Fatalf("timestamp mismatch: got %d, want 42", got.UserTimestampUs) + } + if got.FrameId != 12345 { + t.Fatalf("frame_id mismatch: got %d, want 12345", got.FrameId) + } +} + +func TestAppendParseRoundTrip_ZeroTimestamp(t *testing.T) { + payload := []byte{0xFF, 0xFF} + meta := FrameMetadata{UserTimestampUs: 0} + + result := appendPacketTrailer(payload, meta) + got, ok := parsePacketTrailer(result) + if !ok { + t.Fatal("parsePacketTrailer returned false for zero timestamp") + } + if got.UserTimestampUs != 0 { + t.Fatalf("expected 0, got %d", got.UserTimestampUs) + } +} + +func TestAppendParseRoundTrip_MaxTimestamp(t *testing.T) { + payload := []byte{0xAA} + meta := FrameMetadata{UserTimestampUs: ^uint64(0)} + + result := appendPacketTrailer(payload, meta) + got, ok := parsePacketTrailer(result) + if !ok { + t.Fatal("parsePacketTrailer returned false") + } + if got.UserTimestampUs != ^uint64(0) { + t.Fatalf("timestamp mismatch: got %d, want %d", got.UserTimestampUs, ^uint64(0)) + } +} + +func TestAppendParseRoundTrip_MaxFrameId(t *testing.T) { + payload := []byte{0x00} + meta := FrameMetadata{UserTimestampUs: 1, FrameId: 0xFFFFFFFF} + + result := appendPacketTrailer(payload, meta) + got, ok := parsePacketTrailer(result) + if !ok { + t.Fatal("parsePacketTrailer returned false") + } + if got.FrameId != 0xFFFFFFFF { + t.Fatalf("frame_id mismatch: got %d, want %d", got.FrameId, uint32(0xFFFFFFFF)) + } +} + +func TestAppendParseRoundTrip_EmptyPayload(t *testing.T) { + meta := FrameMetadata{UserTimestampUs: 7, FrameId: 3} + + result := appendPacketTrailer(nil, meta) + got, ok := parsePacketTrailer(result) + if !ok { + t.Fatal("parsePacketTrailer returned false on empty payload") + } + if got.UserTimestampUs != 7 || got.FrameId != 3 { + t.Fatalf("metadata mismatch: got %+v", got) + } +} + +func TestAppendPacketTrailer_WireFormat_TimestampOnly(t *testing.T) { + payload := []byte{0xDE, 0xAD} + meta := FrameMetadata{UserTimestampUs: 0x0102030405060708} + + result := appendPacketTrailer(payload, meta) + + // Expected: payload(2) + timestamp TLV(10) + envelope(5) = 17 bytes + if len(result) != 17 { + t.Fatalf("expected len 17, got %d", len(result)) + } + + // Original payload preserved + if result[0] != 0xDE || result[1] != 0xAD { + t.Fatalf("payload corrupted: %x", result[:2]) + } + + off := 2 // start of trailer + + // Timestamp TLV: tag + if result[off] != byte(tagTimestampUs)^0xFF { + t.Fatalf("timestamp tag: got %02x, want %02x", result[off], byte(tagTimestampUs)^0xFF) + } + // Timestamp TLV: length + if result[off+1] != 8^0xFF { + t.Fatalf("timestamp len: got %02x, want %02x", result[off+1], byte(8^0xFF)) + } + // Timestamp TLV: value (each byte XORed with 0xFF) + var tsBuf [8]byte + binary.BigEndian.PutUint64(tsBuf[:], meta.UserTimestampUs) + for i := 0; i < 8; i++ { + want := tsBuf[i] ^ 0xFF + if result[off+2+i] != want { + t.Fatalf("timestamp byte %d: got %02x, want %02x", i, result[off+2+i], want) + } + } + off += timestampTlvSize + + // Envelope: trailer_len XORed + wantTrailerLen := byte(timestampTlvSize + trailerEnvelopeSize) + if result[off] != wantTrailerLen^0xFF { + t.Fatalf("trailer_len: got %02x, want %02x", result[off], wantTrailerLen^0xFF) + } + + // Envelope: magic raw + if string(result[off+1:]) != packetTrailerMagic { + t.Fatalf("magic: got %q, want %q", result[off+1:], packetTrailerMagic) + } +} + +func TestAppendPacketTrailer_WireFormat_WithFrameId(t *testing.T) { + payload := []byte{0xCA, 0xFE, 0xBA, 0xBE} + meta := FrameMetadata{UserTimestampUs: 1000, FrameId: 0xAABBCCDD} + + result := appendPacketTrailer(payload, meta) + + // Expected: payload(4) + timestamp TLV(10) + frame_id TLV(6) + envelope(5) = 25 + if len(result) != 25 { + t.Fatalf("expected len 25, got %d", len(result)) + } + + off := len(payload) + + // Timestamp TLV + if result[off] != byte(tagTimestampUs)^0xFF { + t.Fatalf("timestamp tag: got %02x", result[off]) + } + off += timestampTlvSize + + // Frame ID TLV: tag + if result[off] != byte(tagFrameId)^0xFF { + t.Fatalf("frame_id tag: got %02x, want %02x", result[off], byte(tagFrameId)^0xFF) + } + // Frame ID TLV: length + if result[off+1] != 4^0xFF { + t.Fatalf("frame_id len: got %02x, want %02x", result[off+1], byte(4^0xFF)) + } + // Frame ID TLV: value + var fidBuf [4]byte + binary.BigEndian.PutUint32(fidBuf[:], meta.FrameId) + for i := 0; i < 4; i++ { + want := fidBuf[i] ^ 0xFF + if result[off+2+i] != want { + t.Fatalf("frame_id byte %d: got %02x, want %02x", i, result[off+2+i], want) + } + } + off += frameIdTlvSize + + // Envelope: trailer_len + wantTrailerLen := byte(timestampTlvSize + frameIdTlvSize + trailerEnvelopeSize) + if result[off] != wantTrailerLen^0xFF { + t.Fatalf("trailer_len: got %02x, want %02x", result[off], wantTrailerLen^0xFF) + } + if string(result[off+1:]) != packetTrailerMagic { + t.Fatalf("magic: got %q", result[off+1:]) + } +} + +func TestAppendPacketTrailer_FrameIdZero_Omitted(t *testing.T) { + meta := FrameMetadata{UserTimestampUs: 1, FrameId: 0} + result := appendPacketTrailer(nil, meta) + + // With FrameId==0 the frame_id TLV must be absent. + if len(result) != packetTrailerMinSize { + t.Fatalf("expected len %d (no frame_id TLV), got %d", packetTrailerMinSize, len(result)) + } + + // The only tag present should be tagTimestampUs. + if result[0]^0xFF != tagTimestampUs { + t.Fatalf("first tag: got %02x, want timestamp tag", result[0]^0xFF) + } +} + +func TestAppendPacketTrailer_DoesNotMutateInput(t *testing.T) { + original := []byte{0x01, 0x02, 0x03, 0x04} + frozen := make([]byte, len(original)) + copy(frozen, original) + + _ = appendPacketTrailer(original, FrameMetadata{UserTimestampUs: 42, FrameId: 7}) + + if !bytes.Equal(original, frozen) { + t.Fatalf("input slice was mutated: got %x, want %x", original, frozen) + } +} + +func TestAppendPacketTrailer_NilPayload(t *testing.T) { + result := appendPacketTrailer(nil, FrameMetadata{UserTimestampUs: 99}) + if len(result) != packetTrailerMinSize { + t.Fatalf("expected len %d, got %d", packetTrailerMinSize, len(result)) + } + if string(result[len(result)-4:]) != packetTrailerMagic { + t.Fatal("magic missing") + } +} + +func TestStripPacketTrailer(t *testing.T) { + payload := []byte{0x10, 0x20, 0x30, 0x40} + meta := FrameMetadata{UserTimestampUs: 100, FrameId: 200} + + result := appendPacketTrailer(payload, meta) + stripped := stripPacketTrailer(result) + + if !bytes.Equal(stripped, payload) { + t.Fatalf("stripped data mismatch: got %x, want %x", stripped, payload) + } +} + +func TestStripPacketTrailer_NoTrailer(t *testing.T) { + data := []byte{0x01, 0x02, 0x03} + stripped := stripPacketTrailer(data) + if !bytes.Equal(stripped, data) { + t.Fatal("stripPacketTrailer should return original data when no trailer") + } +} + +func TestParsePacketTrailer_NoMagic(t *testing.T) { + data := []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08} + _, ok := parsePacketTrailer(data) + if ok { + t.Fatal("expected false for data without magic") + } +} + +func TestParsePacketTrailer_TooShort(t *testing.T) { + _, ok := parsePacketTrailer([]byte{0x01}) + if ok { + t.Fatal("expected false for data shorter than envelope") + } + + _, ok = parsePacketTrailer(nil) + if ok { + t.Fatal("expected false for nil data") + } +} + +func TestParsePacketTrailer_BadTrailerLen(t *testing.T) { + // Craft a buffer that has magic but an invalid trailer_len (too large). + buf := []byte{0x00, 0x00, 0x00} // padding + buf = append(buf, 0xFF^0xFF) // trailer_len = 0 (below minimum) + buf = append(buf, packetTrailerMagic...) + + _, ok := parsePacketTrailer(buf) + if ok { + t.Fatal("expected false for trailer_len below minimum") + } +} + +func TestNoNALStartCodes(t *testing.T) { + // Verify the trailer never contains 0x000001 or 0x00000001 sequences, + // which would confuse H.264/H.265 parsers. + testCases := []FrameMetadata{ + {UserTimestampUs: 0, FrameId: 0}, + {UserTimestampUs: 0, FrameId: 1}, + {UserTimestampUs: 1, FrameId: 0}, + {UserTimestampUs: 0x00000001, FrameId: 0x00000001}, + {UserTimestampUs: 0x0000000100000001, FrameId: 0}, + {UserTimestampUs: ^uint64(0)}, + } + + for _, meta := range testCases { + result := appendPacketTrailer(nil, meta) + for i := 0; i+2 < len(result); i++ { + if result[i] == 0x00 && result[i+1] == 0x00 { + if i+2 < len(result) && result[i+2] == 0x01 { + t.Fatalf("found 3-byte start code at offset %d for meta %+v: %x", i, meta, result) + } + if i+3 < len(result) && result[i+2] == 0x00 && result[i+3] == 0x01 { + t.Fatalf("found 4-byte start code at offset %d for meta %+v: %x", i, meta, result) + } + } + } + } +} + +func TestCrossCompatWithRustAppendTrailer(t *testing.T) { + // Build a trailer byte-for-byte matching what the C++/Rust AppendTrailer + // produces, then verify our Go parser can decode it. + userTs := uint64(1_700_000_000_000_000) + frameId := uint32(42) + + var trailer []byte + + // TLV: timestamp (tag=0x01 ^ 0xFF, len=8 ^ 0xFF, 8-byte BE value ^ 0xFF) + trailer = append(trailer, 0x01^0xFF) + trailer = append(trailer, 8^0xFF) + var tsBuf [8]byte + binary.BigEndian.PutUint64(tsBuf[:], userTs) + for _, b := range tsBuf { + trailer = append(trailer, b^0xFF) + } + + // TLV: frame_id (tag=0x02 ^ 0xFF, len=4 ^ 0xFF, 4-byte BE value ^ 0xFF) + trailer = append(trailer, 0x02^0xFF) + trailer = append(trailer, 4^0xFF) + var fidBuf [4]byte + binary.BigEndian.PutUint32(fidBuf[:], frameId) + for _, b := range fidBuf { + trailer = append(trailer, b^0xFF) + } + + // Envelope: trailer_len ^ 0xFF, then raw magic + totalTrailerLen := byte(len(trailer) + trailerEnvelopeSize) + trailer = append(trailer, totalTrailerLen^0xFF) + trailer = append(trailer, packetTrailerMagic...) + + payload := []byte{0xDE, 0xAD} + data := append(payload, trailer...) + + got, ok := parsePacketTrailer(data) + if !ok { + t.Fatal("parsePacketTrailer failed on hand-crafted Rust-compatible trailer") + } + if got.UserTimestampUs != userTs { + t.Fatalf("timestamp mismatch: got %d, want %d", got.UserTimestampUs, userTs) + } + if got.FrameId != frameId { + t.Fatalf("frame_id mismatch: got %d, want %d", got.FrameId, frameId) + } + + stripped := stripPacketTrailer(data) + if !bytes.Equal(stripped, payload) { + t.Fatalf("stripped mismatch: got %x, want %x", stripped, payload) + } +} + +func TestParseSkipsUnknownTags(t *testing.T) { + // Build a trailer with an unknown tag (0x99) inserted between + // the timestamp and frame_id TLVs. The parser should skip it. + var trailer []byte + + // TLV: timestamp + trailer = append(trailer, 0x01^0xFF, 8^0xFF) + var tsBuf [8]byte + binary.BigEndian.PutUint64(tsBuf[:], 777) + for _, b := range tsBuf { + trailer = append(trailer, b^0xFF) + } + + // TLV: unknown tag 0x99, len=2, value=0xAA,0xBB + trailer = append(trailer, 0x99^0xFF, 2^0xFF, 0xAA^0xFF, 0xBB^0xFF) + + // TLV: frame_id + trailer = append(trailer, 0x02^0xFF, 4^0xFF) + var fidBuf [4]byte + binary.BigEndian.PutUint32(fidBuf[:], 55) + for _, b := range fidBuf { + trailer = append(trailer, b^0xFF) + } + + totalLen := byte(len(trailer) + trailerEnvelopeSize) + trailer = append(trailer, totalLen^0xFF) + trailer = append(trailer, packetTrailerMagic...) + + data := append([]byte{0x00}, trailer...) + + got, ok := parsePacketTrailer(data) + if !ok { + t.Fatal("parsePacketTrailer should succeed with unknown tags") + } + if got.UserTimestampUs != 777 { + t.Fatalf("timestamp mismatch: got %d, want 777", got.UserTimestampUs) + } + if got.FrameId != 55 { + t.Fatalf("frame_id mismatch: got %d, want 55", got.FrameId) + } +} diff --git a/protocolversion.go b/protocolversion.go index d52f0849..11cebf0a 100644 --- a/protocolversion.go +++ b/protocolversion.go @@ -14,4 +14,4 @@ package lksdk -const PROTOCOL = 16 +const PROTOCOL = 17 diff --git a/publication.go b/publication.go index 159470ec..9f28d82a 100644 --- a/publication.go +++ b/publication.go @@ -636,6 +636,9 @@ type TrackPublicationOptions struct { // encryption type Encryption livekit.Encryption_Type BackupCodecPolicy livekit.BackupCodecPolicy + // Packet trailer features signaled to the server. + AttachUserTimestamp bool + AttachFrameId bool } type MuteFunc func(muted bool) error diff --git a/readersampleprovider.go b/readersampleprovider.go index f3af92d1..e0c03f9c 100644 --- a/readersampleprovider.go +++ b/readersampleprovider.go @@ -77,12 +77,13 @@ type ReaderSampleProvider struct { trackOpts []LocalTrackOptions h26xStreamingFormat H26xStreamingFormat appendUserTimestamp bool + appendFrameId bool - // When appendUserTimestamp is enabled, we will attempt to parse timestamps from - // H264 SEI user_data_unregistered NALs that precede frame NALs. - // We then stash the parsed timestamp and attach it to the next frame as an LKTS trailer. - pendingUserTimestampUs int64 - hasPendingUserTimestamp bool + // When appendUserTimestamp is enabled, we parse LKTS packet trailers from + // H264/H265 SEI user_data_unregistered NALs that precede frame NALs. + // The parsed metadata is stashed and re-attached to the next frame. + pendingFrameMetadata FrameMetadata + hasPendingFrameMeta bool // Allow various types of ingress reader io.ReadCloser @@ -160,6 +161,14 @@ func ReaderTrackWithUserTimestamp(enabled bool) func(provider *ReaderSampleProvi } } +// ReaderTrackWithFrameId enables parsing and re-attaching frame IDs from +// LKTS packet trailers embedded in H264/H265 SEI NALs. +func ReaderTrackWithFrameId(enabled bool) func(provider *ReaderSampleProvider) { + return func(provider *ReaderSampleProvider) { + provider.appendFrameId = enabled + } +} + // NewLocalFileTrack creates an *os.File reader for NewLocalReaderTrack func NewLocalFileTrack(file string, options ...ReaderSampleProviderOption) (*LocalTrack, error) { // File health check @@ -343,10 +352,10 @@ func (p *ReaderSampleProvider) NextSample(ctx context.Context) (media.Sample, er } if nalUnitType == h264reader.NalUnitTypeSEI { - if p.appendUserTimestamp { - if ts, ok := parseH264SEIUserTimestamp(nalUnitData); ok { - p.pendingUserTimestampUs = ts - p.hasPendingUserTimestamp = true + if p.appendUserTimestamp || p.appendFrameId { + if meta, ok := parseH264SEIPacketTrailer(nalUnitData); ok { + p.pendingFrameMetadata = meta + p.hasPendingFrameMeta = true } } // If SEI, clear the data and do not return a frame. @@ -373,17 +382,10 @@ func (p *ReaderSampleProvider) NextSample(ctx context.Context) (media.Sample, er return sample, nil } - // Attach the LKTS trailer to the encoded frame payload when enabled. - // If we didn't see a preceding timestamp, we still append a trailer with - // a zero timestamp. - if p.appendUserTimestamp { - ts := int64(0) - if p.hasPendingUserTimestamp { - ts = p.pendingUserTimestampUs - p.hasPendingUserTimestamp = false - p.pendingUserTimestampUs = 0 - } - sample.Data = appendUserTimestampTrailer(sample.Data, ts) + if (p.appendUserTimestamp || p.appendFrameId) && p.hasPendingFrameMeta { + sample.Data = appendPacketTrailer(sample.Data, p.pendingFrameMetadata) + p.hasPendingFrameMeta = false + p.pendingFrameMetadata = FrameMetadata{} } sample.Duration = defaultH264FrameDuration @@ -428,10 +430,10 @@ func (p *ReaderSampleProvider) NextSample(ctx context.Context) (media.Sample, er } if nal.NalUnitType == 39 { // prefix SEI - if p.appendUserTimestamp { - if ts, ok := parseH265SEIUserTimestamp(nal.Data); ok { - p.pendingUserTimestampUs = ts - p.hasPendingUserTimestamp = true + if p.appendUserTimestamp || p.appendFrameId { + if meta, ok := parseH265SEIPacketTrailer(nal.Data); ok { + p.pendingFrameMetadata = meta + p.hasPendingFrameMeta = true } } // If SEI and no frame yet, skip it unless we're only holding param sets. @@ -477,17 +479,10 @@ func (p *ReaderSampleProvider) NextSample(ctx context.Context) (media.Sample, er return sample, nil } - // Attach the LKTS trailer to the encoded frame payload when enabled. - // If we didn't see a preceding timestamp, we still append a trailer with - // a zero timestamp. - if p.appendUserTimestamp { - ts := int64(0) - if p.hasPendingUserTimestamp { - ts = p.pendingUserTimestampUs - p.hasPendingUserTimestamp = false - p.pendingUserTimestampUs = 0 - } - sample.Data = appendUserTimestampTrailer(sample.Data, ts) + if (p.appendUserTimestamp || p.appendFrameId) && p.hasPendingFrameMeta { + sample.Data = appendPacketTrailer(sample.Data, p.pendingFrameMetadata) + p.hasPendingFrameMeta = false + p.pendingFrameMetadata = FrameMetadata{} } sample.Duration = defaultH265FrameDuration diff --git a/readersampleprovider_test.go b/readersampleprovider_test.go index 8e248cb5..740e0034 100644 --- a/readersampleprovider_test.go +++ b/readersampleprovider_test.go @@ -3,7 +3,6 @@ package lksdk import ( "bytes" "context" - "encoding/binary" "io" "testing" @@ -349,11 +348,11 @@ func TestH265NextSample_PrefixSEIBeforeVCLSkipped(t *testing.T) { } func TestH265NextSample_WithUserTimestamp(t *testing.T) { - // Prefix SEI with user timestamp, then VCL. Timestamp should be attached. + // Prefix SEI with packet trailer metadata, then VCL. Metadata should be attached. sc := []byte{0, 0, 0, 1} - const wantTS = int64(9876543210) - seiNAL := buildH265UserTimestampSEI(wantTS) + wantMeta := FrameMetadata{UserTimestampUs: 9876543210, FrameId: 77} + seiNAL := buildH265PacketTrailerSEI(wantMeta) vcl := makeH265VCLData(1, true, []byte{0xAA}) stream := concat(sc, seiNAL, sc, vcl) @@ -377,7 +376,7 @@ func TestH265NextSample_WithUserTimestamp(t *testing.T) { t.Fatalf("expected nil data for SEI sample, got %x", s1.Data) } - // Second call returns the VCL frame with timestamp trailer + // Second call returns the VCL frame with packet trailer s2, err := p.NextSample(context.Background()) if err != nil { t.Fatalf("NextSample (VCL): %v", err) @@ -389,12 +388,15 @@ func TestH265NextSample_WithUserTimestamp(t *testing.T) { t.Fatalf("expected VCL prefix %x in sample data %x", vcl, s2.Data) } - gotTS, ok := parseUserTimestampTrailer(s2.Data) + gotMeta, ok := parsePacketTrailer(s2.Data) if !ok { t.Fatal("expected LKTS trailer in sample data") } - if gotTS != wantTS { - t.Fatalf("timestamp mismatch: got %d, want %d", gotTS, wantTS) + if gotMeta.UserTimestampUs != wantMeta.UserTimestampUs { + t.Fatalf("timestamp mismatch: got %d, want %d", gotMeta.UserTimestampUs, wantMeta.UserTimestampUs) + } + if gotMeta.FrameId != wantMeta.FrameId { + t.Fatalf("frame_id mismatch: got %d, want %d", gotMeta.FrameId, wantMeta.FrameId) } } @@ -433,20 +435,19 @@ func makeH265VCLData(nalType h265reader.NalUnitType, firstSlice bool, payload [] return append(data, payload...) } -// buildH265UserTimestampSEI builds a prefix SEI NAL (type 39) containing a -// user_data_unregistered message with the LKTS UUID and the given timestamp. -func buildH265UserTimestampSEI(ts int64) []byte { +// buildH265PacketTrailerSEI builds a prefix SEI NAL (type 39) containing a +// user_data_unregistered message with the LKTS UUID and an LKTS packet trailer. +func buildH265PacketTrailerSEI(meta FrameMetadata) []byte { // 2-byte NAL header for prefix SEI (type 39) b0 := byte(39) << 1 b1 := byte(0x01) nal := []byte{b0, b1} - // payloadType = 5 (user_data_unregistered), payloadSize = 24 - nal = append(nal, 0x05, 0x18) - nal = append(nal, userTimestampSEIUUID[:]...) + trailer := appendPacketTrailer(nil, meta) + userData := append(packetTrailerSEIUUID[:], trailer...) - var tsBuf [8]byte - binary.BigEndian.PutUint64(tsBuf[:], uint64(ts)) - nal = append(nal, tsBuf[:]...) + // payloadType = 5, payloadSize = len(userData) + nal = append(nal, 0x05, byte(len(userData))) + nal = append(nal, userData...) return nal } diff --git a/user_timestamp.go b/user_timestamp.go deleted file mode 100644 index 7551ae3a..00000000 --- a/user_timestamp.go +++ /dev/null @@ -1,53 +0,0 @@ -package lksdk - -import "encoding/binary" - -const ( - // userTimestampMagic must remain consistent with kUserTimestampMagic - // and kUserTimestampTrailerSize in rust-sdks/webrtc-sys/include/livekit/user_timestamp.h. - userTimestampMagic = "LKTS" - userTimestampTrailerSize = 8 + len(userTimestampMagic) -) - -// appendUserTimestampTrailer returns a new slice containing data followed by -// a user timestamp trailer: -// - 8-byte big-endian int64 timestamp in microseconds -// - 4-byte ASCII magic "LKTS" -func appendUserTimestampTrailer(data []byte, userTimestampUs int64) []byte { - outLen := len(data) + userTimestampTrailerSize - out := make([]byte, outLen) - copy(out, data) - - // Write timestamp (big-endian) just before the magic bytes. - tsOffset := len(data) - binary.BigEndian.PutUint64(out[tsOffset:tsOffset+8], uint64(userTimestampUs)) - - // Append magic bytes. - copy(out[tsOffset+8:], userTimestampMagic) - - return out -} - -// parseUserTimestampTrailer attempts to parse an LKTS trailer from the end -// of the provided buffer. It returns the timestamp in microseconds and true -// when a valid trailer is present. -func parseUserTimestampTrailer(data []byte) (int64, bool) { - if len(data) < userTimestampTrailerSize { - return 0, false - } - - // Check magic bytes at the very end. - magicStart := len(data) - len(userTimestampMagic) - if string(data[magicStart:]) != userTimestampMagic { - return 0, false - } - - // Timestamp is placed immediately before the magic. - tsStart := magicStart - 8 - if tsStart < 0 { - return 0, false - } - - ts := int64(binary.BigEndian.Uint64(data[tsStart : tsStart+8])) - return ts, true -} diff --git a/user_timestamp_h264_parser.go b/user_timestamp_h264_parser.go deleted file mode 100644 index 2fac95a9..00000000 --- a/user_timestamp_h264_parser.go +++ /dev/null @@ -1,100 +0,0 @@ -package lksdk - -import ( - "bytes" - "encoding/binary" - "fmt" -) - -// userTimestampSEIUUID is the UUID of the user timestamp SEI NAL unit. -var userTimestampSEIUUID = [16]byte{ - 0x3f, 0xa8, 0x5f, 0x64, 0x57, 0x17, 0x45, 0x62, - 0xb3, 0xfc, 0x2c, 0x96, 0x3f, 0x66, 0xaf, 0xa6, -} - -// parseH264SEIUserTimestamp parses H264 SEI NAL units (type 6) carrying -// user_data_unregistered messages and returns a timestamp (microseconds) when detected. -// -// Expected payload format (after the NAL header byte): -// -// payloadType = 5 (user_data_unregistered) -// payloadSize = 24 -// UUID = 16 bytes (3fa85f64-5717-4562-b3fc-2c963f66afa6) -// timestamp_us = 8 bytes, big-endian -// trailing = 0x80 (stop bits + padding) -func parseH264SEIUserTimestamp(nalData []byte) (int64, bool) { - if len(nalData) < 2 { - logger.Infow("H264 SEI user_data_unregistered: nal too short", "nal_len", len(nalData)) - return 0, false - } - - // Skip NAL header (first byte). - payload := nalData[1:] - i := 0 - - // Parse payloadType (can be extended with 0xFF bytes). - payloadType := 0 - for i < len(payload) && payload[i] == 0xFF { - payloadType += 255 - i++ - } - if i >= len(payload) { - logger.Infow("H264 SEI user_data_unregistered: payloadType truncated", "payload_len", len(payload)) - return 0, false - } - payloadType += int(payload[i]) - i++ - - // We only care about user_data_unregistered (type 5). - if payloadType != 5 { - return 0, false - } - - // Parse payloadSize (can be extended with 0xFF bytes). - payloadSize := 0 - for i < len(payload) && payload[i] == 0xFF { - payloadSize += 255 - i++ - } - if i >= len(payload) { - logger.Infow("H264 SEI user_data_unregistered: payloadSize truncated", "payload_len", len(payload)) - return 0, false - } - payloadSize += int(payload[i]) - i++ - - if payloadSize < 24 || len(payload) < i+payloadSize { - // Not enough data for UUID (16) + timestamp (8). - logger.Infow( - "H264 SEI user_data_unregistered: insufficient data for UUID + timestamp", - "payloadSize", payloadSize, - "payload_len", len(payload), - "offset", i, - ) - return 0, false - } - - userData := payload[i : i+payloadSize] - uuidBytes := userData[:16] - tsBytes := userData[16:24] - - // Validate the UUID matches the exact user timestamp UUID we expect. - if !bytes.Equal(uuidBytes, userTimestampSEIUUID[:]) { - return 0, false - } - - timestampUS := binary.BigEndian.Uint64(tsBytes) - - // Format UUID as 8-4-4-4-12 hex segments (for debug logs). - uuid := fmt.Sprintf("%x-%x-%x-%x-%x", - uuidBytes[0:4], - uuidBytes[4:6], - uuidBytes[6:8], - uuidBytes[8:10], - uuidBytes[10:16], - ) - - logger.Debugw("H264 SEI user_data_unregistered parsed", "uuid", uuid, "timestamp_us", timestampUS) - - return int64(timestampUS), true -} diff --git a/user_timestamp_h264_parser_test.go b/user_timestamp_h264_parser_test.go deleted file mode 100644 index 5c72ec56..00000000 --- a/user_timestamp_h264_parser_test.go +++ /dev/null @@ -1,45 +0,0 @@ -package lksdk - -import ( - "encoding/binary" - "testing" -) - -func TestParseH264SEIUserTimestamp_UUIDValidation(t *testing.T) { - const wantTS = int64(1234567890) - - var tsBuf [8]byte - binary.BigEndian.PutUint64(tsBuf[:], uint64(wantTS)) - - buildNAL := func(uuid [16]byte) []byte { - // NAL header for SEI (nal_unit_type = 6). - nal := []byte{0x06} - - // payloadType = 5 (user_data_unregistered) - // payloadSize = 24 (16-byte UUID + 8-byte timestamp) - nal = append(nal, 0x05, 0x18) - nal = append(nal, uuid[:]...) - nal = append(nal, tsBuf[:]...) - return nal - } - - t.Run("accepts matching UUID", func(t *testing.T) { - gotTS, ok := parseH264SEIUserTimestamp(buildNAL(userTimestampSEIUUID)) - if !ok { - t.Fatalf("expected ok=true") - } - if gotTS != wantTS { - t.Fatalf("timestamp mismatch: got %d want %d", gotTS, wantTS) - } - }) - - t.Run("rejects non-matching UUID", func(t *testing.T) { - badUUID := userTimestampSEIUUID - badUUID[0] ^= 0xff - - _, ok := parseH264SEIUserTimestamp(buildNAL(badUUID)) - if ok { - t.Fatalf("expected ok=false") - } - }) -} diff --git a/user_timestamp_h265_parser.go b/user_timestamp_h265_parser.go deleted file mode 100644 index 7c2ea14f..00000000 --- a/user_timestamp_h265_parser.go +++ /dev/null @@ -1,94 +0,0 @@ -package lksdk - -import ( - "bytes" - "encoding/binary" - "fmt" -) - -// parseH265SEIUserTimestamp parses H265 prefix SEI NAL units (type 39) carrying -// user_data_unregistered messages and returns a timestamp (microseconds) when detected. -// -// Expected payload format (after the 2-byte NAL header): -// -// payloadType = 5 (user_data_unregistered) -// payloadSize = 24 -// UUID = 16 bytes (3fa85f64-5717-4562-b3fc-2c963f66afa6) -// timestamp_us = 8 bytes, big-endian -// trailing = 0x80 (stop bits + padding) -func parseH265SEIUserTimestamp(nalData []byte) (int64, bool) { - if len(nalData) < 3 { - logger.Infow("H265 SEI user_data_unregistered: nal too short", "nal_len", len(nalData)) - return 0, false - } - - // Skip 2-byte NAL header. - payload := nalData[2:] - i := 0 - - // Parse payloadType (can be extended with 0xFF bytes). - payloadType := 0 - for i < len(payload) && payload[i] == 0xFF { - payloadType += 255 - i++ - } - if i >= len(payload) { - logger.Infow("H265 SEI user_data_unregistered: payloadType truncated", "payload_len", len(payload)) - return 0, false - } - payloadType += int(payload[i]) - i++ - - // We only care about user_data_unregistered (type 5). - if payloadType != 5 { - return 0, false - } - - // Parse payloadSize (can be extended with 0xFF bytes). - payloadSize := 0 - for i < len(payload) && payload[i] == 0xFF { - payloadSize += 255 - i++ - } - if i >= len(payload) { - logger.Infow("H265 SEI user_data_unregistered: payloadSize truncated", "payload_len", len(payload)) - return 0, false - } - payloadSize += int(payload[i]) - i++ - - if payloadSize < 24 || len(payload) < i+payloadSize { - // Not enough data for UUID (16) + timestamp (8). - logger.Infow( - "H265 SEI user_data_unregistered: insufficient data for UUID + timestamp", - "payloadSize", payloadSize, - "payload_len", len(payload), - "offset", i, - ) - return 0, false - } - - userData := payload[i : i+payloadSize] - uuidBytes := userData[:16] - tsBytes := userData[16:24] - - // Validate the UUID matches the exact user timestamp UUID we expect. - if !bytes.Equal(uuidBytes, userTimestampSEIUUID[:]) { - return 0, false - } - - timestampUS := binary.BigEndian.Uint64(tsBytes) - - // Format UUID as 8-4-4-4-12 hex segments (for debug logs). - uuid := fmt.Sprintf("%x-%x-%x-%x-%x", - uuidBytes[0:4], - uuidBytes[4:6], - uuidBytes[6:8], - uuidBytes[8:10], - uuidBytes[10:16], - ) - - logger.Debugw("H265 SEI user_data_unregistered parsed", "uuid", uuid, "timestamp_us", timestampUS) - - return int64(timestampUS), true -} diff --git a/user_timestamp_h265_parser_test.go b/user_timestamp_h265_parser_test.go deleted file mode 100644 index f672b16b..00000000 --- a/user_timestamp_h265_parser_test.go +++ /dev/null @@ -1,45 +0,0 @@ -package lksdk - -import ( - "encoding/binary" - "testing" -) - -func TestParseH265SEIUserTimestamp_UUIDValidation(t *testing.T) { - const wantTS = int64(1234567890) - - var tsBuf [8]byte - binary.BigEndian.PutUint64(tsBuf[:], uint64(wantTS)) - - buildNAL := func(uuid [16]byte) []byte { - // 2-byte NAL header for prefix SEI (nal_unit_type = 39). - nal := []byte{0x4e, 0x01} - - // payloadType = 5 (user_data_unregistered) - // payloadSize = 24 (16-byte UUID + 8-byte timestamp) - nal = append(nal, 0x05, 0x18) - nal = append(nal, uuid[:]...) - nal = append(nal, tsBuf[:]...) - return nal - } - - t.Run("accepts matching UUID", func(t *testing.T) { - gotTS, ok := parseH265SEIUserTimestamp(buildNAL(userTimestampSEIUUID)) - if !ok { - t.Fatalf("expected ok=true") - } - if gotTS != wantTS { - t.Fatalf("timestamp mismatch: got %d want %d", gotTS, wantTS) - } - }) - - t.Run("rejects non-matching UUID", func(t *testing.T) { - badUUID := userTimestampSEIUUID - badUUID[0] ^= 0xff - - _, ok := parseH265SEIUserTimestamp(buildNAL(badUUID)) - if ok { - t.Fatalf("expected ok=false") - } - }) -}