Skip to content

Commit 8eda0ea

Browse files
kixelatedclaude
andauthored
feat(moq-video,libmoq): native H.264 decode (drop ffmpeg dependency) (#1796)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 39904e0 commit 8eda0ea

21 files changed

Lines changed: 1257 additions & 10 deletions

Cargo.lock

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

rs/libmoq/CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- Native video decode C API: `moq_consume_video_raw` (+ `_close`, `_frame`,
13+
`_frame_free`) subscribes to an H.264 track and hands back decoded I420 frames,
14+
the video counterpart to `moq_consume_audio_raw`. Decoding happens inside
15+
libmoq (VideoToolbox / openh264), so consumers no longer need ffmpeg.
16+
1017
## [0.3.7](https://github.com/moq-dev/moq/compare/libmoq-v0.3.6...libmoq-v0.3.7) - 2026-06-19
1118

1219
### Fixed

rs/libmoq/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ moq-audio = { workspace = true }
2424
moq-mux = { workspace = true }
2525
moq-native = { workspace = true, default-features = true }
2626
moq-net = { workspace = true, features = ["serde"] }
27+
moq-video = { workspace = true }
2728
thiserror = "2"
2829
tokio = { workspace = true, features = ["macros"] }
2930
tracing = "0.1"

rs/libmoq/src/consume.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -492,6 +492,25 @@ impl Consume {
492492
Ok(())
493493
}
494494

495+
/// Look up a video rendition by catalog index, returning the
496+
/// (broadcast, config, name) tuple needed to subscribe — mirrors
497+
/// the index-based selection in `video_ordered`.
498+
pub fn video_rendition(
499+
&self,
500+
catalog: Id,
501+
index: usize,
502+
) -> Result<(moq_net::BroadcastConsumer, hang::catalog::VideoConfig, String), Error> {
503+
let consume = self.catalog.get(catalog).ok_or(Error::CatalogNotFound)?;
504+
let (name, config) = consume
505+
.catalog
506+
.video
507+
.renditions
508+
.iter()
509+
.nth(index)
510+
.ok_or(Error::NoIndex)?;
511+
Ok((consume.broadcast.clone(), config.clone(), name.clone()))
512+
}
513+
495514
/// Look up an audio rendition by catalog index, returning the
496515
/// (broadcast, config, name) tuple needed to subscribe — mirrors
497516
/// the index-based selection in `audio_ordered`.

rs/libmoq/src/error.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,10 @@ pub enum Error {
142142
/// Error from the moq-audio codec layer.
143143
#[error("audio error: {0}")]
144144
Audio(Arc<moq_audio::AudioError>),
145+
146+
/// Error from the moq-video codec layer.
147+
#[error("video error: {0}")]
148+
Video(Arc<moq_video::Error>),
145149
}
146150

147151
impl From<moq_audio::AudioError> for Error {
@@ -150,6 +154,12 @@ impl From<moq_audio::AudioError> for Error {
150154
}
151155
}
152156

157+
impl From<moq_video::Error> for Error {
158+
fn from(err: moq_video::Error) -> Self {
159+
Error::Video(Arc::new(err))
160+
}
161+
}
162+
153163
impl From<tracing::metadata::ParseLevelError> for Error {
154164
fn from(err: tracing::metadata::ParseLevelError) -> Self {
155165
Error::Level(Arc::new(err))
@@ -195,6 +205,7 @@ impl ffi::ReturnCode for Error {
195205
Error::Native(_) => -33,
196206
Error::Unauthorized => -34,
197207
Error::Forbidden => -35,
208+
Error::Video(_) => -36,
198209
}
199210
}
200211
}

rs/libmoq/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,13 @@ mod origin;
2626
mod publish;
2727
mod session;
2828
mod state;
29+
mod video;
2930

3031
pub use api::*;
3132
pub use audio::*;
3233
pub use error::*;
3334
pub use id::*;
35+
pub use video::*;
3436

3537
pub(crate) use consume::*;
3638
pub(crate) use origin::*;

rs/libmoq/src/state.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
use std::sync::{LazyLock, Mutex, MutexGuard};
22

3-
use crate::{Consume, Origin, Publish, Session, audio::Audio};
3+
use crate::{Consume, Origin, Publish, Session, audio::Audio, video::Video};
44

55
pub struct State {
66
pub session: Session,
77
pub origin: Origin,
88
pub publish: Publish,
99
pub consume: Consume,
1010
pub audio: Audio,
11+
pub video: Video,
1112
}
1213

1314
impl State {
@@ -18,6 +19,7 @@ impl State {
1819
publish: Publish::default(),
1920
consume: Consume::default(),
2021
audio: Audio::default(),
22+
video: Video::default(),
2123
}
2224
}
2325

rs/libmoq/src/test.rs

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -926,6 +926,106 @@ fn video_publish_consume() {
926926
assert_eq!(moq_origin_close(origin), 0);
927927
}
928928

929+
/// End-to-end native decode: publish real H.264 (encoded by moq-video) and
930+
/// consume it through `moq_consume_video_raw`, asserting decoded I420 frames.
931+
#[test]
932+
fn video_raw_decode() {
933+
// Encode a few gray frames to Annex-B (avc3, SPS/PPS inline on the keyframe).
934+
let mut config = moq_video::encode::Config::new(320, 240, 30);
935+
config.kind = moq_video::encode::Kind::Software;
936+
let mut encoder = moq_video::encode::Encoder::new(&config).expect("openh264 encoder");
937+
let gray = vec![0x80u8; 320 * 240 * 4];
938+
let mut frames: Vec<bytes::Bytes> = Vec::new();
939+
for i in 0..5 {
940+
frames.extend(encoder.encode_rgba(&gray, 320, 240, i == 0).unwrap());
941+
}
942+
frames.extend(encoder.finish().unwrap());
943+
assert!(!frames.is_empty(), "encoder produced no frames");
944+
945+
let origin = id(moq_origin_create());
946+
let broadcast = id(moq_publish_create());
947+
948+
// The init's SPS/PPS only seed catalog metadata; avc3 frames carry their own
949+
// inline parameter sets, so the decoder reads the true 320x240 from the wire.
950+
let init = h264_init();
951+
let format = b"avc3";
952+
let media = id(unsafe {
953+
moq_publish_media_ordered(
954+
broadcast,
955+
format.as_ptr() as *const c_char,
956+
format.len(),
957+
init.as_ptr(),
958+
init.len(),
959+
)
960+
});
961+
962+
let path = b"video-raw-test";
963+
let _publish = id(unsafe { moq_origin_publish(origin, path.as_ptr() as *const c_char, path.len(), broadcast) });
964+
965+
let consume = request_broadcast(origin, path);
966+
let catalog_cb = Callback::new();
967+
let catalog_task = id(unsafe { moq_consume_catalog(consume, Some(channel_callback), catalog_cb.ptr) });
968+
let catalog_id = id(catalog_cb.recv());
969+
970+
// Subscribe + decode before publishing frames so the keyframe group is delivered.
971+
let output = moq_video_decoder_output { latency_max_ms: 10_000 };
972+
let frame_cb = Callback::new();
973+
let consumer = id(unsafe { moq_consume_video_raw(catalog_id, 0, &output, Some(channel_callback), frame_cb.ptr) });
974+
975+
for (i, frame) in frames.iter().enumerate() {
976+
assert_eq!(
977+
unsafe { moq_publish_media_frame(media, frame.as_ptr(), frame.len(), (i as u64) * 33_000) },
978+
0
979+
);
980+
}
981+
982+
// First decoded frame: packed I420 at the encoder resolution.
983+
let frame_id = id(frame_cb.recv());
984+
let mut frame = moq_video_frame {
985+
timestamp_us: 0,
986+
width: 0,
987+
height: 0,
988+
data: std::ptr::null(),
989+
data_size: 0,
990+
};
991+
assert_eq!(unsafe { moq_consume_video_raw_frame(frame_id, &mut frame) }, 0);
992+
assert_eq!(frame.width, 320);
993+
assert_eq!(frame.height, 240);
994+
assert_eq!(frame.data_size, 320 * 240 * 3 / 2, "tightly-packed I420");
995+
assert!(!frame.data.is_null());
996+
997+
assert_eq!(moq_consume_video_raw_frame_free(frame_id), 0);
998+
assert_eq!(moq_consume_video_raw_close(consumer), 0);
999+
1000+
// Drain any other decoded frames already queued, then expect the terminal 0.
1001+
loop {
1002+
let code = frame_cb.recv();
1003+
if code > 0 {
1004+
assert_eq!(moq_consume_video_raw_frame_free(id(code)), 0);
1005+
} else {
1006+
assert_eq!(code, 0, "raw video close delivers terminal 0");
1007+
break;
1008+
}
1009+
}
1010+
assert_eq!(moq_consume_catalog_free(catalog_id), 0);
1011+
assert_eq!(moq_consume_catalog_close(catalog_task), 0);
1012+
// The publisher may emit more than one catalog snapshot (e.g. as the track's
1013+
// stats settle), so drain any extra snapshots before the terminal.
1014+
loop {
1015+
let code = catalog_cb.recv();
1016+
if code > 0 {
1017+
assert_eq!(moq_consume_catalog_free(id(code)), 0);
1018+
} else {
1019+
assert_eq!(code, 0, "catalog close delivers terminal 0");
1020+
break;
1021+
}
1022+
}
1023+
assert_eq!(moq_consume_close(consume), 0);
1024+
assert_eq!(moq_publish_media_close(media), 0);
1025+
assert_eq!(moq_publish_close(broadcast), 0);
1026+
assert_eq!(moq_origin_close(origin), 0);
1027+
}
1028+
9291029
#[test]
9301030
fn multiple_frames_ordering() {
9311031
let origin = id(moq_origin_create());

0 commit comments

Comments
 (0)