Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion doc/lib/c/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ inside. The header is the reference: each function carries a doc comment.
- **Discovery.** Announcement captures are always a list of wildcard matches, empty when the filter has no wildcards.
- **Ending cleanly.** When the input ends, finish every track, then call `moq_publish_catalog_finish()` to finish the catalog, which ends the content. The broadcast stays announced and keeps serving the finished tracks, so a subscriber still catching up can drain them, until `moq_publish_close()` takes it offline. How long to keep it up is yours to decide. A track still open at the close ends with `UNROUTABLE`, which readers take as the publisher leaving, and its handle is released.
- **Timeline breaks.** On a seek or pause, call `moq_publish_media_discontinuity`, then keep timestamps moving forward and resume video on a keyframe.
- **Timestamps.** A `moq_timestamp` is exact: `value` units at `scale` units per second, so a 90 kHz tick survives a read and republish. Frames, datagrams, and JSON values point `timestamp` at theirs, NULL on an untimed track and valid as long as the payload. Tracks moq-c creates are timed, so their raw writes need one. JSON and binary writes take one too, converted to their track's milliseconds; NULL stamps the broadcast clock's current time. A zero scale or an out-of-range value fails with `MOQ_ERROR_TIMESTAMP_OVERFLOW`.
- **Timestamps.** A `moq_timestamp` is exact: `value` units at `scale` units per second, so a 90 kHz tick survives a read and republish. Frames, datagrams, and JSON values point `timestamp` at theirs, NULL on an untimed track and valid as long as the payload. Tracks moq-c creates are timed, so their raw writes need one. JSON writes, including window pushes and pops, take one too, converted to their track's milliseconds; NULL stamps the broadcast clock's current time. A zero scale or an out-of-range value fails with `MOQ_ERROR_TIMESTAMP_OVERFLOW`.
- **JSON values.** `moq_consume_json_value` fills `moq_json_value` with the document and its frame's timestamp. A snapshot consumer's callback fires for each state in order, but a group superseded before it is read is skipped.
- **JSON windows.** `moq_publish_json_window` publishes a sliding run of records: `moq_publish_json_window_push` appends at the back, `moq_publish_json_window_pop` drops from the front, and `moq_publish_json_window_cut` closes the open group at a natural boundary. The catalog has no window mode, so a reader names the track. `moq_consume_json_window_event` fills `moq_json_window_event` with a push (the record at `start`, in `json`), a pop of `[start, end)`, or a skip of `[start, end)` that this reader joined too late to receive. Set `checkpoint_records` to bound what each new group restates for a window you never pop.
- **Decoded reads.** `moq_decode_audio` and `moq_decode_video` deliver read ids in order: a frame, a gap where media was lost, or an end where the publisher stopped its encoder. Check `kind` on what `moq_decode_audio_read` or `moq_decode_video_read` writes. Free each id with `moq_decode_audio_read_free` or `moq_decode_video_read_free` promptly: a held video frame holds a decoder buffer, and the decoder stalls. A decoder reads everything the track still holds unless a live player sets `start` to `MOQ_DECODE_START_LATEST`, which joins at the newest group and skips the cached backlog.
Expand Down
2 changes: 1 addition & 1 deletion quest/m1/c/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,5 @@ Public API: moq-c gains each m1 binding feature. Wire: none.

## Required

- [API gaps](/quest/m1/c/api-gaps.md) - caller timestamps for JSON windows and disabling pre-encoded simulcast rungs
- [API gaps](/quest/m1/c/api-gaps.md) - disabling pre-encoded simulcast rungs after the shared importer support lands
- [Every consumer cancels while pending](/quest/m1/c/consumer-cancel.md) - catalog, raw, datagram, and media consumers end cleanly on cancel before their subscription is accepted
46 changes: 27 additions & 19 deletions quest/m1/c/api-gaps.md
Original file line number Diff line number Diff line change
@@ -1,25 +1,33 @@
# [S] moq-c closes its small API gaps
# [S] moq-c disables pre-encoded simulcast rungs

## Goal

moq-c stamps a JSON window entry with a caller's timestamp and can disable a
simulcast rung on pre-encoded media.
moq-c can disable a simulcast rung on pre-encoded media, with its lower
rendition's priority and a rate control driven by the reservation.

## Plan

- `moq_publish_json_window_push` and `_pop` always stamp the broadcast clock.
Let them take an optional timestamp like the JSON snapshot and stream
writers do since [moq-dev/moq#6946](https://github.com/moq-dev/moq/pull/6946).
Decided 2026-10-30 (maintainer, /quest-plan of the 2026-10-30 /quest-complete follow-ups):
✅ add it here / drop.
- Add C ABI support for disabling simulcast rungs on pre-encoded media, which
[moq-dev/moq#7029](https://github.com/moq-dev/moq/pull/7029) added to moq-ffi
for OBS. Decided 2026-10-30 (maintainer, /quest-plan of the 2026-10-30 /quest-complete follow-ups): the C epic's rule (every m1
binding feature missing from `moq.h` lands before the cut) overrides
#7029's decision 4, which parked it until a C consumer asked:
✅ add to m1 C gaps / exempt it.

Update `moq.h`, `rs/moq-c/README.md` and `doc/lib/c` as needed.

Public API: moq-c gains an optional caller timestamp on JSON window push and
pop, and a call to disable a rung of pre-encoded media. Wire: none.
- Add C ABI support for disabling simulcast rungs on pre-encoded media after
[moq-dev/moq#7029](https://github.com/moq-dev/moq/pull/7029) lands its shared
importer support. It is still open as of 2026-11-02: main lacks
`moq_mux::import::Track::set_enabled`, `import::VideoInit.priority`, and the
AV1/VP8/VP9 importer setters. That PR owns those changes and the moq-ffi
`MoqRateControl`, `MoqVideoInit.lower`, and media producer `set_enabled`
interfaces. Do not duplicate or import its unmerged source changes here.
- After it merges, expose media `set_enabled`, lower-rendition ordering, and
a rate control over the C reservation. Inspect the final shared interfaces
first. Decided 2026-10-30 (maintainer, /quest-plan of the 2026-10-30
/quest-complete follow-ups): the C epic's rule (every m1 binding feature
missing from `moq.h` lands before the cut) overrides that PR's decision 4,
which parked it until a C consumer asked: ✅ add to m1 C gaps / exempt it.

Update the generated `moq.h`, `rs/moq-c/README.md` and `doc/lib/c` as needed.
The independent JSON window caller timestamp gap is implemented in
[moq-dev/moq#7614](https://github.com/moq-dev/moq/pull/7614).

Public API: moq-c gains pre-encoded rung disable, lower-rendition ordering,
and reservation rate control. Wire: none.

## Required

- [OBS rung disable](/quest/m1/simulcast-rung-disable.md) - land the shared importer and FFI prerequisites in [moq-dev/moq#7029](https://github.com/moq-dev/moq/pull/7029) before adding their C ABI counterparts
7 changes: 7 additions & 0 deletions rs/moq-c/MIGRATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@ How to update your code for each breaking change, newest first.

## Unreleased

### JSON window edits take an optional timestamp

`moq_publish_json_window_push` and `moq_publish_json_window_pop` take a final
`const moq_timestamp *timestamp`. Pass `NULL` to keep stamping the broadcast
clock, or a pointer to the edit's capture time, converted to track milliseconds.
Recompile callers against the regenerated `moq.h`.

### Announcement captures are required ([#7484](https://github.com/moq-dev/moq/pull/7484))

`moq_announce_event.has_captures` is removed, changing the C ABI. Read `captures_len` directly: zero means the filter has no wildcards. Rebuild consumers against the regenerated header and matching native library.
Expand Down
9 changes: 9 additions & 0 deletions rs/moq-c/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,3 +149,12 @@ are exact `moq_timestamp` records, `value` units at `scale` units per second.
track's timescale, a microsecond one unless `moq_track_info.timescale` sets
another, and `moq_consume_track_frame` points `moq_frame.timestamp` at the
timestamp read, valid until the frame is freed, or NULL on an untimed track.

JSON window pushes and pops accept an optional `const moq_timestamp *timestamp`,
like snapshot updates and stream appends. It is when the edit occurred,
converted to the track's milliseconds; pass NULL to stamp the broadcast clock.

```c
int32_t moq_publish_json_window_push(uint32_t window, const char *value, uintptr_t value_len, const moq_timestamp *timestamp);
int32_t moq_publish_json_window_pop(uint32_t window, uint64_t count, const moq_timestamp *timestamp);
```
33 changes: 27 additions & 6 deletions rs/moq-c/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3567,7 +3567,8 @@ pub unsafe extern "C" fn moq_publish_json_snapshot(
/// unchanged from the previous update.
///
/// `timestamp` is when the value was captured, converted to the track's millisecond timescale;
/// NULL stamps the broadcast clock's current time.
/// NULL stamps the broadcast clock's current time. A timestamp too large for milliseconds fails
/// with `MOQ_ERROR_MOQ` and publishes nothing.
///
/// Returns a zero on success, or a negative code on failure.
///
Expand Down Expand Up @@ -3626,7 +3627,8 @@ pub unsafe extern "C" fn moq_publish_json_stream(broadcast: u32, name: *const c_
/// Append one record to a JSON stream track. `value` is a UTF-8 JSON document.
///
/// `timestamp` is when the value was captured, converted to the track's millisecond timescale;
/// NULL stamps the broadcast clock's current time.
/// NULL stamps the broadcast clock's current time. A timestamp too large for milliseconds fails
/// with `MOQ_ERROR_MOQ` and publishes nothing.
///
/// Returns a zero on success, or a negative code on failure.
///
Expand Down Expand Up @@ -3699,28 +3701,47 @@ pub unsafe extern "C" fn moq_publish_json_window(

/// Append one record to the back of a JSON window. `value` is a UTF-8 JSON document.
///
/// `timestamp` is when the edit occurred, converted to the track's millisecond timescale;
/// NULL stamps the broadcast clock's current time. A timestamp too large for milliseconds fails
/// with `MOQ_ERROR_MOQ` and publishes nothing.
///
/// Returns a zero on success, or a negative code on failure.
///
/// # Safety
/// - The caller must ensure `value` is a valid pointer to `value_len` bytes.
/// - `timestamp` must be NULL or a valid pointer to a [moq_timestamp].
#[unsafe(no_mangle)]
pub unsafe extern "C" fn moq_publish_json_window_push(window: u32, value: *const c_char, value_len: usize) -> i32 {
pub unsafe extern "C" fn moq_publish_json_window_push(
window: u32,
value: *const c_char,
value_len: usize,
timestamp: *const moq_timestamp,
) -> i32 {
ffi::enter(move || {
let window = ffi::parse_id(window)?;
let value = unsafe { ffi::parse_slice(value.cast::<u8>(), value_len)? };
let value = serde_json::from_slice(value)?;
State::lock().publish.json_window_push(window, value)
let timestamp = unsafe { ffi::parse_timestamp(timestamp)? };
State::lock().publish.json_window_push(window, value, timestamp)
})
}

/// Drop `count` records from the front of a JSON window, clamped to what it holds.
///
/// `timestamp` is when the edit occurred, converted to the track's millisecond timescale;
/// NULL stamps the broadcast clock's current time. A timestamp too large for milliseconds fails
/// with `MOQ_ERROR_MOQ` and publishes nothing.
///
/// Returns a zero on success, or a negative code on failure.
///
/// # Safety
/// - `timestamp` must be NULL or a valid pointer to a [moq_timestamp].
#[unsafe(no_mangle)]
pub extern "C" fn moq_publish_json_window_pop(window: u32, count: u64) -> i32 {
pub unsafe extern "C" fn moq_publish_json_window_pop(window: u32, count: u64, timestamp: *const moq_timestamp) -> i32 {
ffi::enter(move || {
let window = ffi::parse_id(window)?;
State::lock().publish.json_window_pop(window, count)
let timestamp = unsafe { ffi::parse_timestamp(timestamp)? };
State::lock().publish.json_window_pop(window, count, timestamp)
})
}

Expand Down
24 changes: 16 additions & 8 deletions rs/moq-c/src/publish.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1369,20 +1369,28 @@ impl Publish {
}

/// Append one record to the back of a JSON window.
pub fn json_window_push(&mut self, window: Id, value: serde_json::Value) -> Result<(), Error> {
pub fn json_window_push(
&mut self,
window: Id,
value: serde_json::Value,
timestamp: Option<moq_net::Timestamp>,
) -> Result<(), Error> {
let window = &mut self.json_window.get_mut(window).ok_or(Error::TrackNotFound)?.data;
window
.producer
.push(moq_net::Timed::from(&value).at(window.clock.now()))?;
let at = timestamp.unwrap_or_else(|| window.clock.now());
window.producer.push(moq_net::Timed::from(&value).at(at))?;
Ok(())
}

/// Drop `count` records from the front of a JSON window, clamped to what it holds.
pub fn json_window_pop(&mut self, window: Id, count: u64) -> Result<(), Error> {
pub fn json_window_pop(
&mut self,
window: Id,
count: u64,
timestamp: Option<moq_net::Timestamp>,
) -> Result<(), Error> {
let window = &mut self.json_window.get_mut(window).ok_or(Error::TrackNotFound)?.data;
window
.producer
.pop(moq_net::Timed::from(count).at(window.clock.now()))?;
let at = timestamp.unwrap_or_else(|| window.clock.now());
window.producer.pop(moq_net::Timed::from(count).at(at))?;
Ok(())
}

Expand Down
95 changes: 92 additions & 3 deletions rs/moq-c/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2733,7 +2733,7 @@ fn recv_window(cb: &Callback) -> WindowEvent {
fn window_push(handle: u32, n: u64) {
let value = format!(r#"{{"n":{n}}}"#);
assert_eq!(
unsafe { moq_publish_json_window_push(handle, value.as_ptr() as *const c_char, value.len()) },
unsafe { moq_publish_json_window_push(handle, value.as_ptr() as *const c_char, value.len(), std::ptr::null()) },
0
);
}
Expand Down Expand Up @@ -2776,7 +2776,7 @@ fn json_window_publish_consume() {
for n in 0..3 {
window_push(producer, n);
}
assert_eq!(moq_publish_json_window_pop(producer, 2), 0);
assert_eq!(unsafe { moq_publish_json_window_pop(producer, 2, std::ptr::null()) }, 0);

let mut last = moq_net::Timestamp::ZERO;
for n in 0..3 {
Expand All @@ -2797,12 +2797,101 @@ fn json_window_publish_consume() {
assert_eq!(moq_publish_close(broadcast), 0);
assert!(event_cb.recv_terminal() < 0, "an aborted window is not a clean end");
assert!(moq_consume_json_cancel(consumer) < 0, "the task removed itself");
assert!(moq_publish_json_window_pop(producer, 1) < 0, "the handle was released");
assert!(
unsafe { moq_publish_json_window_pop(producer, 1, std::ptr::null()) } < 0,
"the handle was released"
);
assert!(moq_publish_json_window_finish(producer) < 0, "the handle was released");
assert_eq!(moq_consume_close(consume), 0);
assert_eq!(moq_origin_close(origin), 0);
}

/// Caller timestamps include zero and non-millisecond scales; refusing an invalid timestamp
/// must leave both the records and the emitted events unchanged.
#[test]
fn json_window_caller_timestamps() {
use moq_json_window_kind::*;

let origin = id(moq_origin_create());
let path = b"json-window-timestamps";
let broadcast = publish_broadcast(origin, path);
let name = b"log";
let config = moq_json_window_config { checkpoint_records: 0 };
let producer = id(unsafe { moq_publish_json_window(broadcast, name.as_ptr().cast(), name.len(), &config) });
let consume = request_broadcast(origin, path);
let event_cb = Callback::new();
let consumer = window_consume(consume, name, &event_cb);
let value = br#"{"n":1}"#;
let invalid = moq_timestamp { value: 1, scale: 0 };
// Valid, but too large once converted to the track's milliseconds.
let unconvertible = moq_timestamp {
value: (1 << 61) - 1,
scale: 1,
};

assert_eq!(
unsafe { moq_publish_json_window_push(producer, value.as_ptr().cast(), value.len(), &invalid) },
MOQ_ERROR_TIMESTAMP_OVERFLOW
);
assert_eq!(
unsafe { moq_publish_json_window_push(producer, value.as_ptr().cast(), value.len(), &unconvertible) },
MOQ_ERROR_MOQ
);
for timestamp in [
micros(0),
moq_timestamp {
value: 111_111,
scale: 90_000,
},
] {
assert_eq!(
unsafe { moq_publish_json_window_push(producer, value.as_ptr().cast(), value.len(), &timestamp) },
0
);
}
assert_eq!(
unsafe { moq_publish_json_window_pop(producer, 1, &invalid) },
MOQ_ERROR_TIMESTAMP_OVERFLOW
);
assert_eq!(
unsafe { moq_publish_json_window_pop(producer, 1, &unconvertible) },
MOQ_ERROR_MOQ
);
assert_eq!(
unsafe { moq_publish_json_window_pop(producer, 1, &micros(2_500_999)) },
0
);
assert_eq!(moq_publish_json_window_finish(producer), 0);

for (n, millis) in [0, 1234].into_iter().enumerate() {
let (kind, range, json, timestamp) = recv_window(&event_cb);
assert_eq!(
(kind, range, json, timestamp),
(
MOQ_JSON_WINDOW_KIND_PUSH,
n as u64..n as u64 + 1,
Some(serde_json::json!({ "n": 1 })),
Some(moq_net::Timestamp::from_millis(millis).unwrap()),
)
);
}
let (kind, range, json, timestamp) = recv_window(&event_cb);
assert_eq!(
(kind, range, json, timestamp),
(
MOQ_JSON_WINDOW_KIND_POP,
0..1,
None,
Some(moq_net::Timestamp::from_millis(2500).unwrap())
)
);
assert_eq!(event_cb.recv_terminal(), 0);
assert!(moq_consume_json_cancel(consumer) < 0, "the task removed itself");
assert_eq!(moq_consume_close(consume), 0);
assert_eq!(moq_publish_close(broadcast), 0);
assert_eq!(moq_origin_close(origin), 0);
}

/// A join past the refresh ratio gets the window restated in a fresh group holding only the
/// checkpoint's records, and a finished window still serves a reader joining afterwards, which
/// learns of the older records as a skip.
Expand Down
Loading
Loading