Overview
Frame metadata is a set of optional fields associated with individual video frames. Subscribers receive the metadata together with the frame it describes. You can use it for frame-accurate timing, latency measurement, and per-frame application data.
Each field is negotiated independently when the track is published, so a track includes only the fields you enable:
- User timestamp: Wall-clock capture time in microseconds. Use it to measure end-to-end latency or align frames with an external clock.
- Frame ID: A user-defined numeric identifier associated with a frame. For example, use a monotonically increasing value to detect dropped frames or correlate a frame with state computed elsewhere.
- User data: Bytes defined by your application. Use this field to attach small, per-frame binary payloads that are relevant to each video frame. For example, you might send a list of numbers representing a robot arm's joint positions.
Frame metadata applies to video frames only. Audio frames don't carry frame metadata.
The user timestamp and frame ID fields are available in the Rust, C++, Python, JavaScript, and Unity SDKs. The user data field is currently supported by the Rust SDK (publish and read) and the browser JavaScript SDK (read only).
Publishing frame metadata
Publishing frame metadata is a two-step process: enable the features you want when publishing the track so they're negotiated with the server, then provide the matching fields for each frame. A field is only serialized onto the wire if its feature was enabled at publish time.
How the fields are provided depends on the SDK. SDKs that publish from a backend raw video source (Rust, C++, Python, and Unity) can attach metadata to each captured frame. The browser JavaScript SDK captures frames itself, so it derives the timestamp and frame ID automatically once the feature is enabled.
Enable the features you need on TrackPublishOptions when publishing the track:
use livekit::options::{FrameMetadataFeatures, TrackPublishOptions};use livekit::prelude::*;let mut features = FrameMetadataFeatures::default();features.user_timestamp = true;features.frame_id = true;features.user_data = true;let options = TrackPublishOptions {source: TrackSource::Camera,frame_metadata_features: features,..Default::default()};room.local_participant().publish_track(LocalTrack::Video(track), options).await?;
Then attach metadata to each frame before capturing it on the video source:
use livekit::webrtc::video_frame::{FrameMetadata, VideoFrame, VideoRotation};let mut frame = VideoFrame {rotation: VideoRotation::VideoRotation0,timestamp_us: 0,frame_metadata: None,buffer, // your filled I420 (or other) buffer};// Every field is optional; set `None` to omit it for this frame.frame.frame_metadata = Some(FrameMetadata {user_timestamp: Some(capture_time_us),frame_id: Some(frame_id),user_data: Some(payload_bytes),});source.capture_frame(&frame);
Enable the features you need on TrackPublishOptions, then publish the track:
#include "livekit/local_video_track.h"#include "livekit/video_source.h"auto source = std::make_shared<livekit::VideoSource>(width, height);auto track = livekit::LocalVideoTrack::createLocalVideoTrack("camera", source);livekit::TrackPublishOptions options;options.source = livekit::TrackSource::SOURCE_CAMERA;options.frame_metadata_features.emplace();options.frame_metadata_features->user_timestamp = true;options.frame_metadata_features->frame_id = true;room->localParticipant().lock()->publishTrack(track, options);
Then attach metadata to each frame through VideoCaptureOptions when you capture it:
livekit::VideoCaptureOptions capture_options;capture_options.timestamp_us = capture_time_us;capture_options.metadata = livekit::VideoFrameMetadata{};capture_options.metadata->user_timestamp_us = capture_time_us;capture_options.metadata->frame_id = frame_id;source->captureFrame(frame, capture_options);
Enable the features you need on TrackPublishOptions, then publish the track:
from livekit import rtcsource = rtc.VideoSource(width, height)track = rtc.LocalVideoTrack.create_video_track("camera", source)options = rtc.TrackPublishOptions(source=rtc.TrackSource.SOURCE_CAMERA,frame_metadata_features=[rtc.FrameMetadataFeature.FMF_USER_TIMESTAMP,rtc.FrameMetadataFeature.FMF_FRAME_ID,],)publication = await room.local_participant.publish_track(track, options)
Then build a FrameMetadata for each frame and pass it to capture_frame:
metadata = rtc.FrameMetadata()metadata.user_timestamp = capture_time_us # wall-clock microsecondsmetadata.frame_id = frame_id # numeric identifiersource.capture_frame(frame, timestamp_us=timestamp_us, metadata=metadata)
Enable the features with WithPacketTrailerFeatures on TrackPublishOptions, and set a MetadataProvider on the source to supply the fields for each outgoing frame:
using LiveKit;using LiveKit.Proto;// Negotiate the per-frame fields you want with the server.var options = new TrackPublishOptions{Source = TrackSource.SourceCamera,}.WithPacketTrailerFeatures(PacketTrailerFeature.PtfUserTimestamp,PacketTrailerFeature.PtfFrameId);// Invoked once per outgoing frame. Return null to send no metadata.source.MetadataProvider = () => new FrameMetadata{UserTimestamp = captureTimeUs, // wall-clock microsecondsFrameId = frameId, // numeric identifier};var track = LocalVideoTrack.CreateVideoTrack("camera", source, room);yield return room.LocalParticipant.PublishTrack(track, options);
Reading frame metadata
On the subscribing side, metadata arrives attached to each received frame. Fields are present only when the publisher enabled the corresponding feature and set a value for that frame, so check each field before using it. Some SDKs also expose the negotiated feature set on the track publication.
The browser JavaScript SDK delivers frames through the browser video pipeline rather than a frame iterator. Instead of reading metadata off a frame object, applications look it up by RTP timestamp as frames are rendered. See the JavaScript tab for details.
use futures::StreamExt;use livekit::webrtc::video_stream::native::NativeVideoStream;// Inspect the features negotiated for this publication.let features = publication.frame_metadata_features();let mut stream = NativeVideoStream::new(video_track.rtc_track());while let Some(frame) = stream.next().await {if let Some(metadata) = &frame.frame_metadata {if let Some(user_timestamp) = metadata.user_timestamp {// Compare against the local clock to measure latency.}if let Some(frame_id) = metadata.frame_id {// Detect gaps in the frame sequence.}if let Some(user_data) = metadata.user_data.as_deref() {// Decode your per-frame payload.}}}
Read frames from a VideoStream. Each VideoFrameEvent carries an optional VideoFrameMetadata:
#include "livekit/video_stream.h"auto stream = livekit::VideoStream::fromTrack(video_track, {});livekit::VideoFrameEvent event;while (stream->read(event)) {if (!event.metadata) {continue;}if (event.metadata->user_timestamp_us) {// Compare against the local clock to measure latency.}if (event.metadata->frame_id) {// Detect gaps in the frame sequence.}}
Iterate a VideoStream. Each VideoFrameEvent exposes an optional metadata field:
from livekit import rtc# Inspect the features negotiated for this publication.features = publication.frame_metadata_featuresstream = rtc.VideoStream.from_track(video_track)async for event in stream:metadata = event.metadataif metadata is None:continueif metadata.HasField("user_timestamp"):# Compare against the local clock to measure latency....if metadata.HasField("frame_id"):# Detect gaps in the frame sequence....
Subscribe to a VideoStream and read the optional Metadata off each received frame:
using LiveKit;var stream = new VideoStream(remoteVideoTrack);stream.FrameReceived += frame =>{var metadata = frame.Metadata;if (metadata == null) return;if (metadata.HasUserTimestamp){// Compare against the local clock to measure latency.}if (metadata.HasFrameId){// Detect gaps in the frame sequence.}};stream.Start();
Constraints
Keep the following limits in mind when designing around frame metadata:
- Video only. Frame metadata is carried alongside video frames. Audio frames don't support it.
- Size limit. All enabled metadata fields share a budget of approximately 232 bytes per frame. The fixed fields (
user_timestampandframe_id) consume part of this budget, reducing the space available foruser_datawhen they're enabled. - Oversize metadata is dropped, not truncated. If
user_datacauses the frame metadata to exceed the size limit, the entire metadata payload is dropped before transmission rather than being truncated. Keep payloads small and fixed-size where possible.