Skip to content

Commit a4223c7

Browse files
MaxHeimbrockclaude
andauthored
Expose Frame Packet Trailer (FrameMetadata) for video (#286)
* Expose FrameMetadata send/receive for video frames Surfaces the Rust FFI Frame Packet Trailer feature in the Unity SDK: attach FrameMetadata (user_timestamp, frame_id) to outgoing frames via RtcVideoSource.MetadataProvider, and read it off VideoFrame.Metadata on received frames. PacketTrailerFeatures on TrackPublishOptions is already proto-passthrough so no wrapper change is needed there. Adds a PlayMode E2E test that publishes with both packet-trailer features enabled, attaches known metadata to outgoing frames, and asserts the subscriber sees matching frame_id and user_timestamp. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Add TrackPublishOptionsExtensions.WithPacketTrailerFeatures Provides a Google.Protobuf-free entry point for enabling packet trailer features on TrackPublishOptions. Required because the LiveKit SDK ships Google.Protobuf.dll as an explicitly-referenced plugin (deliberate, to avoid conflicts with com.unity.ai.assistant), so a Unity project's default Assembly-CSharp cannot use the proto's repeated-field collection initializer or call .Add on PacketTrailerFeatures directly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Use frame metadata in the Meet sample Publishes the local camera with PTF_USER_TIMESTAMP and PTF_FRAME_ID packet trailers and attaches a monotonic frame_id plus UnixTimeUs user_timestamp to each outgoing frame via the new MetadataProvider hook. Logs received trailer values (throttled to ~1 second per stream) for both main and extra-video tiles, so the Unity console / on-screen ScrollingLog shows the metadata round-tripping from remote publishers. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Consolidate StubVideoSource and MetadataTestVideoSource into TestVideoSource Single test helper with a pushFrames flag covers both modes: signaling-only publication propagation (default) and continuous media flow for round-trip tests. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Remove meet sample changes --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent efbd61c commit a4223c7

12 files changed

Lines changed: 226 additions & 40 deletions

Runtime/Scripts/Participant.cs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1001,4 +1001,21 @@ public LocalDataTrack Track
10011001

10021002
public PublishDataTrackError Error { get; private set; }
10031003
}
1004+
1005+
/// Helpers for setting <see cref="TrackPublishOptions"/> fields whose underlying type
1006+
/// lives in Google.Protobuf (e.g. RepeatedField&lt;T&gt;). Unity's default Assembly-CSharp
1007+
/// does not auto-reference Google.Protobuf, so callers without an asmdef cannot use a
1008+
/// collection initializer or call <c>.Add</c> on the repeated field directly. These
1009+
/// helpers keep Google.Protobuf types out of the caller's signature.
1010+
public static class TrackPublishOptionsExtensions
1011+
{
1012+
public static TrackPublishOptions WithPacketTrailerFeatures(
1013+
this TrackPublishOptions options,
1014+
params PacketTrailerFeature[] features)
1015+
{
1016+
foreach (var feature in features)
1017+
options.PacketTrailerFeatures.Add(feature);
1018+
return options;
1019+
}
1020+
}
10041021
}

Runtime/Scripts/RtcVideoSource.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,12 @@ public enum VideoStreamSource
3030
/// Called when we receive a new texture (first texture or the resolution changed)
3131
public event TextureReceiveDelegate TextureReceived;
3232

33+
public delegate FrameMetadata FrameMetadataDelegate();
34+
/// Invoked once per outgoing frame. Return null (default) to send no trailer.
35+
/// To actually serialize the trailer onto RTP, also enable the matching
36+
/// PacketTrailerFeatures on the TrackPublishOptions used at publish time.
37+
public FrameMetadataDelegate MetadataProvider { get; set; }
38+
3339
protected Texture2D _previewTexture;
3440
protected NativeArray<byte> _captureBuffer;
3541
protected VideoStreamSource _sourceType;
@@ -175,6 +181,8 @@ protected virtual bool SendFrame()
175181
var now = DateTimeOffset.UtcNow;
176182
capture.TimestampUs = now.ToUnixTimeMilliseconds() * 1000 + (now.Ticks % TimeSpan.TicksPerMillisecond) / 10;
177183
capture.Buffer = buffer;
184+
var metadata = MetadataProvider?.Invoke();
185+
if (metadata != null) capture.Metadata = metadata;
178186
using var response = request.Send();
179187
_reading = false;
180188
_requestPending = false;

Runtime/Scripts/VideoFrame.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,14 @@ public sealed class VideoFrame
1212

1313
public long Timestamp;
1414
public VideoRotation Rotation;
15+
public FrameMetadata Metadata;
1516

16-
public VideoFrame(VideoBufferInfo info, long timeStamp, VideoRotation rotation)
17+
public VideoFrame(VideoBufferInfo info, long timeStamp, VideoRotation rotation, FrameMetadata metadata = null)
1718
{
1819
_info = info;
1920
Timestamp = timeStamp;
2021
Rotation = rotation;
22+
Metadata = metadata;
2123
}
2224
}
2325

Runtime/Scripts/VideoStream.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -242,7 +242,7 @@ private void OnVideoStreamEvent(VideoStreamEvent e)
242242
// Avoid allocating VideoFrame objects when nobody is observing them.
243243
if (FrameReceived != null)
244244
{
245-
var frame = new VideoFrame(frameInfo, e.FrameReceived.TimestampUs, e.FrameReceived.Rotation);
245+
var frame = new VideoFrame(frameInfo, e.FrameReceived.TimestampUs, e.FrameReceived.Rotation, e.FrameReceived.Metadata);
246246
FrameReceived.Invoke(frame);
247247
}
248248
}

Tests/PlayMode/TrackTests.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@ public IEnumerator RemoteTrackPublication_SetVideoQuality_DoesNotThrow()
149149
var publisherRoom = context.Rooms[0];
150150
var subscriberRoom = context.Rooms[1];
151151

152-
var videoSource = new StubVideoSource();
152+
var videoSource = new TestVideoSource();
153153
var localTrack = LocalVideoTrack.CreateVideoTrack(VideoTrackName, videoSource, publisherRoom);
154154

155155
// Video track uses a stub source that never pushes frames. TrackSubscribed may not
@@ -283,10 +283,10 @@ public IEnumerator RemoteTrackPublication_PublisherDisablesCamera_UpdatesFlagAnd
283283
var publisherRoom = context.Rooms[0];
284284
var subscriberRoom = context.Rooms[1];
285285

286-
var videoSource = new StubVideoSource();
286+
var videoSource = new TestVideoSource();
287287
var localTrack = LocalVideoTrack.CreateVideoTrack(VideoTrackName, videoSource, publisherRoom);
288288

289-
// StubVideoSource never pushes frames, so TrackSubscribed may not fire on the
289+
// TestVideoSource (pushFrames=false) never pushes frames, so TrackSubscribed may not fire on the
290290
// subscriber. The RemoteTrackPublication still propagates via TrackPublished.
291291
var publishedExp = new Expectation(timeoutSeconds: 10f);
292292
subscriberRoom.TrackPublished += (_, _) => publishedExp.Fulfill();
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
using UnityEngine;
2+
3+
namespace LiveKit.PlayModeTests.Utils
4+
{
5+
/// <summary>
6+
/// Empty MonoBehaviour used by tests to host long-running coroutines
7+
/// (e.g. <see cref="RtcVideoSource.Update"/> and <see cref="VideoStream.Update"/>)
8+
/// that the test itself cannot host because <c>[UnityTest]</c> bodies must yield
9+
/// back to the test runner.
10+
/// </summary>
11+
public class CoroutineRunner : MonoBehaviour { }
12+
}

Tests/PlayMode/Utils/StubVideoSource.cs.meta renamed to Tests/PlayMode/Utils/CoroutineRunner.cs.meta

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Tests/PlayMode/Utils/StubVideoSource.cs

Lines changed: 0 additions & 31 deletions
This file was deleted.
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
using LiveKit.Proto;
2+
using Unity.Collections;
3+
4+
namespace LiveKit.PlayModeTests.Utils
5+
{
6+
/// <summary>
7+
/// Test-only <see cref="RtcVideoSource"/> registered with FFI at a fixed
8+
/// resolution. Two modes via <paramref name="pushFrames"/>:
9+
/// <list type="bullet">
10+
/// <item><c>false</c> (default): never pushes frames. Use when the test only
11+
/// needs the publication to propagate via signaling (e.g.
12+
/// <see cref="RemoteTrackPublication"/> APIs that operate on metadata).</item>
13+
/// <item><c>true</c>: pushes a continuous stream of zero-filled RGBA frames.
14+
/// Use when the test needs actual media flow (e.g. validating per-frame
15+
/// metadata round-trips through the FFI / RTP path).</item>
16+
/// </list>
17+
/// </summary>
18+
public sealed class TestVideoSource : RtcVideoSource
19+
{
20+
private readonly int _width;
21+
private readonly int _height;
22+
private readonly bool _pushFrames;
23+
24+
public override int GetWidth() => _width;
25+
public override int GetHeight() => _height;
26+
27+
protected override VideoRotation GetVideoRotation() => VideoRotation._0;
28+
29+
protected override bool ReadBuffer()
30+
{
31+
if (!_pushFrames) return false;
32+
33+
if (!_captureBuffer.IsCreated)
34+
{
35+
_captureBuffer = new NativeArray<byte>(
36+
_width * _height * 4,
37+
Allocator.Persistent,
38+
NativeArrayOptions.ClearMemory);
39+
}
40+
_requestPending = true;
41+
return false;
42+
}
43+
44+
public TestVideoSource(bool pushFrames = false, int width = 16, int height = 16)
45+
: base(VideoStreamSource.Texture, VideoBufferType.Rgba)
46+
{
47+
_width = width;
48+
_height = height;
49+
_pushFrames = pushFrames;
50+
Init();
51+
}
52+
}
53+
}

Tests/PlayMode/Utils/TestVideoSource.cs.meta

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)