Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
0785276
moq-mux: decouple the opus importer from the broadcast catalog
claude Jun 16, 2026
23f3bcb
moq-mux: decouple the H.264 importer from the broadcast catalog
claude Jun 16, 2026
e315189
moq-boy: fix track() call after moq-video Producer::track() lost its …
claude Jun 16, 2026
433e8ea
moq-mux: port the remaining importers off the broadcast catalog
claude Jun 16, 2026
dc37cea
moq-mux: extract the H.264 byte->frame Split; add the FrameDecode path
claude Jun 16, 2026
f752b7b
moq-mux: make the H.264 Split dumb; move config into the importer
claude Jun 16, 2026
ba7a406
moq-mux: Published owns decode+sync; drop manual sync calls
claude Jun 16, 2026
7f10a4e
moq-mux: replace lenient_start with a MissingKeyframe error
claude Jun 16, 2026
f631821
moq-mux: extract the H.265 byte->frame Split
claude Jun 16, 2026
249ff25
moq-mux: extract the AV1 byte->frame Split
claude Jun 16, 2026
f4a278a
moq-mux: fix stale lenient-start comments in the h264 importer
claude Jun 16, 2026
fd6e459
Merge branch 'dev' into claude/moq-mux-import-export-api-dtkqdp
claude Jun 16, 2026
db6e812
moq-mux: drop the fixed-track concept; config updates in place
claude Jun 16, 2026
de2d201
moq-mux: convert the importers off anyhow to thiserror
claude Jun 16, 2026
b71966f
moq-mux: make the video importers pure frame publishers
claude Jun 17, 2026
fe95e63
moq-mux: slim Split to a pure stream splitter (decode + flush)
claude Jun 17, 2026
1ee9658
moq-mux: drop the unused Split::decode_from
claude Jun 17, 2026
e58d075
moq-mux: remove Split::seed; feed init bytes as the stream
claude Jun 17, 2026
de7cfb3
moq-mux: fold publish into import; add moq_net::TrackDemand
kixelated Jun 17, 2026
5de839c
moq-mux: importers self-catalog; frame-only decode; shared clock
kixelated Jun 17, 2026
e4b7f3c
moq-mux: split the import dispatcher into Track and Container
kixelated Jun 17, 2026
a761a8b
moq-mux: take &[u8] in the decoders instead of Buf
kixelated Jun 18, 2026
5cc564c
moq-mux: drop the unused decode_from container helpers
kixelated Jun 18, 2026
7124d2b
moq-mux: drop the importer is_initialized() readiness check
kixelated Jun 18, 2026
469a565
moq-mux: drop the import format enums; constructors take &str
kixelated Jun 18, 2026
0e9a2cb
moq-mux: collapse Track::new/from_track into one Track::new(track)
kixelated Jun 18, 2026
bdde5c7
moq: reserve tracks so the importer sets the timescale on accept
kixelated Jun 18, 2026
125bf2b
Merge branch 'dev' into claude/moq-mux-import-export-api-dtkqdp
kixelated Jun 18, 2026
7ece8e1
moq-ffi: mirror the Rust API with a distinct MoqTrackRequest
kixelated Jun 18, 2026
a9bbf73
moq-net: fix reserve_track doc link after requested_track moved to Br…
kixelated Jun 18, 2026
6e2f5f2
py/kt: update language wrappers for MoqTrackRequest
kixelated Jun 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Aliases.kt
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ typealias Announcement = uniffi.moq.MoqAnnouncement
typealias BroadcastProducer = uniffi.moq.MoqBroadcastProducer
typealias BroadcastConsumer = uniffi.moq.MoqBroadcastConsumer
typealias TrackProducer = uniffi.moq.MoqTrackProducer
typealias TrackRequest = uniffi.moq.MoqTrackRequest
typealias TrackConsumer = uniffi.moq.MoqTrackConsumer
typealias GroupProducer = uniffi.moq.MoqGroupProducer
typealias GroupConsumer = uniffi.moq.MoqGroupConsumer
Expand Down
4 changes: 2 additions & 2 deletions kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Flows.kt
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import uniffi.moq.MoqGroupConsumer
import uniffi.moq.MoqMediaConsumer
import uniffi.moq.MoqOriginConsumer
import uniffi.moq.MoqTrackConsumer
import uniffi.moq.MoqTrackProducer
import uniffi.moq.MoqTrackRequest

/**
* Stream of catalog updates. Terminates when the underlying track ends.
Expand Down Expand Up @@ -95,7 +95,7 @@ fun MoqTrackConsumer.groupsAsArrived(): Flow<MoqGroupConsumer> = flow {
}

/** Stream of tracks requested by subscribers. */
fun MoqBroadcastDynamic.requestedTracks(): Flow<MoqTrackProducer> = flow {
fun MoqBroadcastDynamic.requestedTracks(): Flow<MoqTrackRequest> = flow {
while (true) {
currentCoroutineContext().ensureActive()
emit(requestedTrack())
Expand Down
2 changes: 2 additions & 0 deletions py/moq-rs/moq/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
MediaProducer,
MediaStreamProducer,
TrackProducer,
TrackRequest,
)
from .server import Request, Server, Transport
from .session import Session
Expand Down Expand Up @@ -81,6 +82,7 @@
"TrackConsumer",
"TrackInfo",
"TrackProducer",
"TrackRequest",
"Transport",
"Video",
"connect",
Expand Down
35 changes: 30 additions & 5 deletions py/moq-rs/moq/publish.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
MoqMediaProducer,
MoqMediaStreamProducer,
MoqTrackProducer,
MoqTrackRequest,
)

from .types import AudioEncoderInput, AudioEncoderOutput, AudioFrame, Subscription, TrackInfo
Expand Down Expand Up @@ -137,6 +138,30 @@ def finish(self) -> None:
self._inner.finish()


class TrackRequest:
"""A subscriber-requested track that hasn't been accepted yet.

Accept it for raw writes, hand it to :meth:`BroadcastProducer.publish_media_on_track`
to publish media (the importer accepts it), or abort it to reject the subscriber.
"""

def __init__(self, inner: MoqTrackRequest) -> None:
self._inner = inner

@property
def name(self) -> str:
"""The requested track name."""
return self._inner.name()

def accept(self) -> TrackProducer:
"""Accept the request as a raw track."""
return TrackProducer(self._inner.accept(None))

def abort(self, error_code: int) -> None:
"""Reject the request with an application error code."""
self._inner.abort(error_code)


class AudioProducer:
"""Publish raw PCM and let libopus encode it on the way out.

Expand Down Expand Up @@ -170,11 +195,11 @@ def __init__(self, inner: MoqBroadcastDynamic) -> None:
def __aiter__(self):
return self

async def __anext__(self) -> TrackProducer:
async def __anext__(self) -> TrackRequest:
return await self.requested_track()

async def requested_track(self) -> TrackProducer:
return TrackProducer(await self._inner.requested_track())
async def requested_track(self) -> TrackRequest:
return TrackRequest(await self._inner.requested_track())

def cancel(self) -> None:
self._inner.cancel()
Expand All @@ -193,8 +218,8 @@ def dynamic(self) -> BroadcastDynamic:
def publish_media(self, format: str, init: bytes) -> MediaProducer:
return MediaProducer(self._inner.publish_media(format, init))

def publish_media_on_track(self, track: TrackProducer, format: str, init: bytes) -> MediaProducer:
return MediaProducer(self._inner.publish_media_on_track(track._inner, format, init))
def publish_media_on_track(self, request: TrackRequest, format: str, init: bytes) -> MediaProducer:
return MediaProducer(self._inner.publish_media_on_track(request._inner, format, init))

def publish_media_stream(self, format: str) -> MediaStreamProducer:
"""Publish a media track fed by a raw byte stream (unknown frame
Expand Down
9 changes: 5 additions & 4 deletions py/moq-rs/tests/test_local.py
Original file line number Diff line number Diff line change
Expand Up @@ -259,13 +259,14 @@ async def test_dynamic_track_request():
dynamic = broadcast.dynamic()
consumer = broadcast.consume()

# The request is accepted by the first producer op (write_frame below), so the
# subscribe stays pending until then; run it concurrently.
# The subscribe stays pending until the request is accepted below; run it concurrently.
subscribe = asyncio.create_task(consumer.subscribe_track("events"))

track = await asyncio.wait_for(dynamic.requested_track(), timeout=5.0)
assert track.name == "events"
request = await asyncio.wait_for(dynamic.requested_track(), timeout=5.0)
assert request.name == "events"

# Accept the request as a raw track (which unblocks the subscribe), then write.
track = request.accept()
payload = b"hello dynamic track"
track.write_frame(payload)

Expand Down
109 changes: 109 additions & 0 deletions rs/libmoq/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,92 @@ pub struct moq_announced {
pub active: bool,
}

/// A snapshot of connection statistics, filled in by [moq_session_stats].
///
/// Each metric has a `*_valid` flag: when `false`, the matching value is meaningless because
/// the transport backend doesn't report it (a `false` flag is NOT the same as a zero value).
/// Native QUIC reports every metric; the browser WebTransport reports few or none. Initialize
/// the struct to zero before the call; [moq_session_stats] overwrites every field.
#[repr(C)]
#[allow(non_camel_case_types)]
pub struct moq_connection_stats {
/// Smoothed round-trip time, in microseconds.
pub rtt_us: u64,
pub rtt_valid: bool,

/// Estimated send bandwidth from the congestion controller, in bits per second.
pub send_rate_bps: u64,
pub send_rate_valid: bool,

/// Estimated receive bandwidth from MoQ PROBE, in bits per second.
pub recv_rate_bps: u64,
pub recv_rate_valid: bool,

/// Total bytes sent, including retransmissions and overhead.
pub bytes_sent: u64,
pub bytes_sent_valid: bool,

/// Total bytes received, including duplicates and overhead.
pub bytes_received: u64,
pub bytes_received_valid: bool,

/// Total bytes lost (detected via retransmission or acknowledgement).
pub bytes_lost: u64,
pub bytes_lost_valid: bool,

/// Total datagrams sent.
pub packets_sent: u64,
pub packets_sent_valid: bool,

/// Total datagrams received.
pub packets_received: u64,
pub packets_received_valid: bool,

/// Total datagrams detected as lost.
pub packets_lost: u64,
pub packets_lost_valid: bool,
}

impl From<&moq_net::ConnectionStats> for moq_connection_stats {
fn from(stats: &moq_net::ConnectionStats) -> Self {
// An Option<u64> becomes a (value, valid) pair; absent metrics report 0/false.
fn split(value: Option<u64>) -> (u64, bool) {
(value.unwrap_or(0), value.is_some())
}

let (rtt_us, rtt_valid) = split(stats.rtt.map(|d| d.as_micros() as u64));
let (send_rate_bps, send_rate_valid) = split(stats.estimated_send_rate);
let (recv_rate_bps, recv_rate_valid) = split(stats.estimated_recv_rate);
let (bytes_sent, bytes_sent_valid) = split(stats.bytes_sent);
let (bytes_received, bytes_received_valid) = split(stats.bytes_received);
let (bytes_lost, bytes_lost_valid) = split(stats.bytes_lost);
let (packets_sent, packets_sent_valid) = split(stats.packets_sent);
let (packets_received, packets_received_valid) = split(stats.packets_received);
let (packets_lost, packets_lost_valid) = split(stats.packets_lost);

Self {
rtt_us,
rtt_valid,
send_rate_bps,
send_rate_valid,
recv_rate_bps,
recv_rate_valid,
bytes_sent,
bytes_sent_valid,
bytes_received,
bytes_received_valid,
bytes_lost,
bytes_lost_valid,
packets_sent,
packets_sent_valid,
packets_received,
packets_received_valid,
packets_lost,
packets_lost_valid,
}
}
}

/// Initialize the library with a log level.
///
/// This should be called before any other functions.
Expand Down Expand Up @@ -195,6 +281,29 @@ pub extern "C" fn moq_session_close(session: u32) -> i32 {
})
}

/// Snapshot the current connection statistics for a session.
///
/// Fills `dst` with a point-in-time view of the underlying QUIC/WebTransport connection
/// (RTT, bandwidth estimates, byte/packet counters). Each metric carries a `*_valid` flag
/// since availability depends on the transport backend; see [moq_connection_stats].
///
/// Returns zero on success, or a negative code on failure: the session handle is unknown, or
/// the session is currently reconnecting and has no live connection (in which case `dst` is
/// left untouched). Safe to call repeatedly to poll stats over the life of the session.
///
/// # Safety
/// - The caller must ensure that `dst` is a valid pointer to a [moq_connection_stats] struct.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn moq_session_stats(session: u32, dst: *mut moq_connection_stats) -> i32 {
ffi::enter(move || {
let session = ffi::parse_id(session)?;
let dst = unsafe { dst.as_mut() }.ok_or(Error::InvalidPointer)?;
let stats = State::lock().session.stats(session)?;
*dst = moq_connection_stats::from(&stats);
Ok(())
})
}

/// Create an origin for publishing broadcasts.
///
/// Origins contain any number of broadcasts addressed by path.
Expand Down
60 changes: 39 additions & 21 deletions rs/libmoq/src/publish.rs
Original file line number Diff line number Diff line change
@@ -1,17 +1,23 @@
use std::str::FromStr;

use bytes::Buf;
use moq_mux::import;

use crate::{Error, Id, NonZeroSlab};

/// A media importer fed whole chunks: either a single codec track or a container
/// that may publish several tracks. The format string picks which at creation.
enum Media {
// Boxed because the codec splitters/imports make this variant much larger
// than the (already boxed) container one.
Track(Box<import::Track>),
Container(import::Container),
}

#[derive(Default)]
pub struct Publish {
/// Active broadcast producers for publishing.
broadcasts: NonZeroSlab<(moq_net::BroadcastProducer, moq_mux::catalog::Producer)>,

/// Active media encoders/decoders for publishing.
media: NonZeroSlab<import::Framed>,
media: NonZeroSlab<Media>,

/// Raw track producers (no media/container/catalog framing).
tracks: NonZeroSlab<moq_net::TrackProducer>,
Expand Down Expand Up @@ -52,36 +58,48 @@ impl Publish {
Ok(())
}

pub fn media_ordered(&mut self, broadcast: Id, format: &str, mut init: &[u8]) -> Result<Id, Error> {
pub fn media_ordered(&mut self, broadcast: Id, format: &str, init: &[u8]) -> Result<Id, Error> {
let (broadcast, catalog) = self.broadcasts.get(broadcast).ok_or(Error::BroadcastNotFound)?;

let format = import::FramedFormat::from_str(format).map_err(|_| Error::UnknownFormat(format.to_string()))?;
let decoder = import::Framed::new(broadcast.clone(), catalog.clone(), format, &mut init)?;

let id = self.media.insert(decoder)?;
// A container may publish several tracks; a single codec fills one reserved
// track. Try the container first so a codec format doesn't reserve a stray
// track on the way to being recognized.
let media = match import::Container::new(broadcast.clone(), catalog.clone(), format, init) {
Ok(container) => Media::Container(container),
Err(moq_mux::Error::UnknownFormat(_)) => {
let mut broadcast = broadcast.clone();
let name = broadcast.unique_name(&format!(".{format}"));
let request = broadcast.reserve_track(name)?;
match import::Track::new(request, catalog.clone(), format, init) {
Ok(track) => Media::Track(Box::new(track)),
Err(moq_mux::Error::UnknownFormat(_)) => return Err(Error::UnknownFormat(format.to_string())),
Err(err) => return Err(err.into()),
}
}
Err(err) => return Err(err.into()),
};

let id = self.media.insert(media)?;
Ok(id)
}

pub fn media_frame(
&mut self,
media: Id,
mut data: &[u8],
timestamp: hang::container::Timestamp,
) -> Result<(), Error> {
pub fn media_frame(&mut self, media: Id, data: &[u8], timestamp: hang::container::Timestamp) -> Result<(), Error> {
let media = self.media.get_mut(media).ok_or(Error::MediaNotFound)?;

media.decode_frame(&mut data, Some(timestamp))?;

if data.has_remaining() {
return Err(Error::BufferNotConsumed);
match media {
Media::Track(track) => track.decode(data, Some(timestamp))?,
Media::Container(container) => container.decode(data)?,
}

Ok(())
}

pub fn media_close(&mut self, media: Id) -> Result<(), Error> {
let mut decoder = self.media.remove(media).ok_or(Error::MediaNotFound)?;
decoder.finish()?;
let mut media = self.media.remove(media).ok_or(Error::MediaNotFound)?;
match &mut media {
Media::Track(track) => track.finish()?,
Media::Container(container) => container.finish()?,
}
Ok(())
}

Expand Down
Loading
Loading