Skip to content

Conversation

@christriants
Copy link
Contributor

@christriants christriants commented Feb 6, 2026

Related to: #740
MoQ OBS Plugin update: moq-dev/obs#20

This PR adds AV1 (av01) decoder support with inline sequence headers, following the format of the avc3 decoder. Key implementation details:

Core Decoder (av01.rs):

  • Dual format support: Handles both av1C (MP4 container format) and raw OBU streams

    • initialize() - Detects format by examining first bytes: 0x0a signature with 16-byte length indicates av1C, otherwise treats as raw OBUs
    • init_from_av1c() - Parses 16-byte av1C box to extract codec parameters (profile, level, tier, etc). Resolution is set to None and deferred to inline sequence headers since av1C doesn't contain dimension info
    • decode_obu() - Processes individual Open Bitstream Units, handling sequence headers, frame headers, temporal delimiters, and tile groups
  • av1C initialization: Parses 16-byte av1C box to extract codec parameters (profile, level, tier, bit depth, chroma subsampling), defers resolution extraction to inline sequence headers

  • Sequence header handling: Uses scuffle_av1 crate to parse inline sequence headers for resolution and color configuration

  • Frame detection:

    • Parses AV1 uncompressed header to identify frame types (bits 5-6 of first byte after OBU header)
    • Handles show_existing_frame flag (bit 7) which OBS uses for display reordering - preserves keyframe state from preceding sequence header rather than parsing referenced frame
    • maybe_start_frame() - Flushes completed frames to track when temporal delimiter or metadata OBU is encountered
  • OBU iteration: Custom iterator that parses LEB128-encoded size fields to split OBU boundaries in streams

API Alignment:

  • Implements existing new(), initialize(), decode_stream(), and decode_frame() methods

A couple of bugs I am trying to sort out / understand:

  • Frontend console error: invalidStateError: Failed to execute 'clone' on 'VideoFrame': Cannot clone closed VideoFrame. at #runRender (renderer.ts:90:29) - I think we can fix this separately.
Screenshot 2026-02-06 at 1 05 28 PM

@christriants christriants force-pushed the feat/av1-decoder branch 2 times, most recently from 26dca8d to 06f9b31 Compare February 6, 2026 03:03
@christriants christriants marked this pull request as ready for review February 6, 2026 18:17
display_ratio_width: None,
display_ratio_height: None,
optimize_for_latency: None,
container: hang::catalog::Container::Legacy,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is Legacy correct here?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's basically a big fat TODO to future me.

At the very least timestamps should be delta encoded.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 6, 2026

Walkthrough

Adds AV1 support behind a new av1 Cargo feature (added to defaults) and an optional scuffle-av1 = "0.1.4" dependency. Introduces rs/moq-mux/src/import/av01.rs with a public Av01 decoder implementing OBU parsing, frame buffering, sequence-header parsing, and timestamp handling. Extends decoder enums and paths (DecoderFormat, StreamFormat, StreamKind, DecoderKind, StreamDecoder, Decoder) with Av01 variants and handlers. Removes the aac and opus import modules and their public re-exports.

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title 'AV1 decoder' clearly and directly summarizes the main change in the PR: adding AV1 codec support to the moq-mux decoder module.
Description check ✅ Passed The description is comprehensive and directly related to the changeset, explaining the implementation details, design decisions, and API alignment for the new AV1 decoder functionality.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

No actionable comments were generated in the recent review. 🎉

🧹 Recent nitpick comments
rs/moq-mux/src/import/av01.rs (4)

112-153: init_minimal lacks dedup/cleanup, but is adequately guarded by caller.

The call site at line 301 only invokes this when self.track.is_none(), so no track leak occurs. However, if a future caller doesn't check, this could leak. Consider adding a guard similar to init() for defensive safety.


161-161: Redundant length check: data.len() >= 4 is subsumed by data.len() >= 16.

Minor nit — the first check is unnecessary.

Suggested simplification
-		if data.len() >= 4 && data[0] == 0x81 && data.len() >= 16 {
+		if data.len() >= 16 && data[0] == 0x81 {

314-318: Dead code: data.is_empty() check is unreachable.

Line 314 already ensures obu_data.len() > payload_offset, so &obu_data[payload_offset..] is guaranteed non-empty. The if data.is_empty() branch at line 316 can never be taken.

Simplification
 			let is_keyframe = if obu_data.len() > payload_offset {
 				let data = &obu_data[payload_offset..];
-				if data.is_empty() {
-					false
-				} else {
-					let first_byte = data[0];
-
-					let show_existing_frame = (first_byte >> 7) & 1;
-
-					if show_existing_frame == 1 {
-						self.current.contains_keyframe
-					} else {
-						let frame_type = (first_byte >> 5) & 0b11;
-
-						frame_type == 0
-					}
-				}
+				let first_byte = data[0];
+				let show_existing_frame = (first_byte >> 7) & 1;
+
+				if show_existing_frame == 1 {
+					self.current.contains_keyframe
+				} else {
+					let frame_type = (first_byte >> 5) & 0b11;
+					frame_type == 0
+				}

299-304: Sequence header parse error is silently discarded — log the error for debuggability.

The Err(_) arm drops the actual error. At minimum, capture and log it so malformed streams can be diagnosed.

Suggested fix
-				Err(_) => {
-					// Use minimal config so stream can work (catalog won't have full info)
+				Err(e) => {
+					// Use minimal config so stream can work (catalog won't have full info)
 					if self.track.is_none() {
-						tracing::debug!("Sequence header parsing failed, initializing with minimal config");
+						tracing::warn!(error = %e, "Sequence header parsing failed, initializing with minimal config");
 						self.init_minimal()?;
 					}
 				}

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🤖 Fix all issues with AI agents
In `@rs/moq-mux/src/import/av01.rs`:
- Around line 136-186: init_from_av1c currently always creates a new track and
leaks any previous one; change it to mirror init() by (1) comparing the newly
built config with self.config and returning early if equal, and (2) if unequal,
lock the catalog, remove the old track from the catalog/broadcast (use the same
removal calls used in init() for removing the prior track referenced by
self.track), then create the new catalog track with catalog.video.create_track
and call self.broadcast.create_track, and finally update self.config and
self.track. Ensure you use the same helper/method names used in init() for
removing tracks so behavior is consistent.
- Around line 113-121: The av1C detection in initialize is using the wrong
marker byte (0x0a); update the conditional that currently checks data[0] == 0x0a
to check data[0] == 0x81 so the AV1CodecConfigurationRecord is correctly
identified, leaving the existing length check and subsequent call to
init_from_av1c(data) and buf.advance(data.len()) unchanged; ensure the change is
made in the initialize<T: Buf + AsRef<[u8]>> method where init_from_av1c is
invoked.
- Line 75: The ColorConfig's full_range is being hardcoded to false; update the
code that constructs the ColorConfig (where full_range: false is set) to read
the value from the sequence header by using
seq_header.color_config.full_color_range instead, matching how other fields like
bit_depth, color_primaries, and transfer_characteristics are populated.
- Around line 236-246: The SequenceHeader OBU handling incorrectly always slices
payload as &obu_data[1..] and sets self.current.contains_keyframe = true even on
parse failure; update the payload offset using header.extension_header (if true
use &obu_data[2..] else &obu_data[1..]) when calling
SequenceHeaderObu::parse(header, ...), and only call self.init(&seq_header) and
set self.current.contains_keyframe = true inside the Ok arm (do not set the
keyframe flag on Err); references: ObuType::SequenceHeader,
header.extension_header, SequenceHeaderObu::parse, self.init, and
self.current.contains_keyframe.
- Around line 400-441: In ObuIterator::next you must account for the OBU
extension byte before parsing the LEB128 size: read the header byte, detect the
obu_extension_flag (same test used in decode_obu), and set the initial size
offset to 2 when the extension flag is present (otherwise 1); update the
subsequent bounds checks (data.len(), self.buf.remaining()) and LEB128 loop to
use that computed offset so the computed total_size and copy_to_bytes call use
the correct start of the size field and avoid corrupting OBU boundaries (mirror
the logic in decode_obu to locate the header/extension/size bytes).
🧹 Nitpick comments (2)
rs/moq-mux/src/import/av01.rs (1)

255-258: Redundant data.is_empty() check.

On line 255, obu_data.len() > obu_header_size guarantees that data (a subslice starting at obu_header_size) is non-empty. The if data.is_empty() check at line 257 is unreachable.

♻️ Simplify
 let is_keyframe = if obu_data.len() > obu_header_size {
     let data = &obu_data[obu_header_size..];
-    if data.is_empty() {
-        false
-    } else {
-        let first_byte = data[0];
-        let show_existing_frame = (first_byte >> 7) & 1;
-        if show_existing_frame == 1 {
-            self.current.contains_keyframe
-        } else {
-            let frame_type = (first_byte >> 5) & 0b11;
-            frame_type == 0
-        }
-    }
+    let first_byte = data[0];
+    let show_existing_frame = (first_byte >> 7) & 1;
+    if show_existing_frame == 1 {
+        self.current.contains_keyframe
+    } else {
+        let frame_type = (first_byte >> 5) & 0b11;
+        frame_type == 0
+    }
 } else {
rs/moq-mux/src/import/mod.rs (1)

17-17: aac and opus modules are not feature-gated, though their features exist in Cargo.toml.

mod aac and mod opus (and their pub use re-exports) are compiled unconditionally, even though aac and opus feature flags are defined in Cargo.toml. The other codecs (h264, h265, mp4, av1) are all guarded. This appears to be a pre-existing inconsistency rather than something introduced in this PR, but worth noting since you're touching the module wiring.

Also applies to: 31-31, 33-33, 45-45

Comment on lines 136 to 186
fn init_from_av1c(&mut self, data: &[u8]) -> anyhow::Result<()> {
// Parse av1C box structure
let seq_profile = (data[1] >> 5) & 0x07;
let seq_level_idx = data[1] & 0x1F;
let tier = ((data[2] >> 7) & 0x01) == 1;
let high_bitdepth = ((data[2] >> 6) & 0x01) == 1;
let twelve_bit = ((data[2] >> 5) & 0x01) == 1;

let config = hang::catalog::VideoConfig {
// Resolution unknown from av1C - will be updated when first sequence header arrives
coded_width: None,
coded_height: None,
codec: hang::catalog::AV1 {
profile: seq_profile,
level: seq_level_idx,
tier: if tier { 'H' } else { 'M' },
bitdepth: if high_bitdepth {
if twelve_bit { 12 } else { 10 }
} else {
8
},
mono_chrome: ((data[2] >> 4) & 0x01) == 1,
chroma_subsampling_x: ((data[2] >> 3) & 0x01) == 1,
chroma_subsampling_y: ((data[2] >> 2) & 0x01) == 1,
chroma_sample_position: data[2] & 0x03,
color_primaries: 1,
transfer_characteristics: 1,
matrix_coefficients: 1,
full_range: false,
}
.into(),
description: None,
framerate: None,
bitrate: None,
display_ratio_width: None,
display_ratio_height: None,
optimize_for_latency: None,
container: hang::catalog::Container::Legacy,
jitter: None,
};

let mut catalog = self.catalog.lock();
let track = catalog.video.create_track("av01", config.clone());
drop(catalog);

let track = self.broadcast.create_track(track);
self.config = Some(config);
self.track = Some(track.into());

Ok(())
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

init_from_av1c doesn't clean up existing track or deduplicate config, unlike init().

init() (line 88–97) checks for config equality and removes the old track before creating a new one. init_from_av1c always creates a new track without cleanup. If initialize() is called more than once with av1C data, the previous track leaks in the catalog.

🛡️ Suggested: add cleanup logic matching init()
 fn init_from_av1c(&mut self, data: &[u8]) -> anyhow::Result<()> {
     // ... config construction ...

+    if let Some(old) = &self.config && old == &config {
+        return Ok(());
+    }
+
+    if let Some(track) = self.track.take() {
+        tracing::debug!(name = ?track.info.name, "reinitializing track");
+        self.catalog.lock().video.remove_track(&track.info);
+    }
+
     let mut catalog = self.catalog.lock();
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
fn init_from_av1c(&mut self, data: &[u8]) -> anyhow::Result<()> {
// Parse av1C box structure
let seq_profile = (data[1] >> 5) & 0x07;
let seq_level_idx = data[1] & 0x1F;
let tier = ((data[2] >> 7) & 0x01) == 1;
let high_bitdepth = ((data[2] >> 6) & 0x01) == 1;
let twelve_bit = ((data[2] >> 5) & 0x01) == 1;
let config = hang::catalog::VideoConfig {
// Resolution unknown from av1C - will be updated when first sequence header arrives
coded_width: None,
coded_height: None,
codec: hang::catalog::AV1 {
profile: seq_profile,
level: seq_level_idx,
tier: if tier { 'H' } else { 'M' },
bitdepth: if high_bitdepth {
if twelve_bit { 12 } else { 10 }
} else {
8
},
mono_chrome: ((data[2] >> 4) & 0x01) == 1,
chroma_subsampling_x: ((data[2] >> 3) & 0x01) == 1,
chroma_subsampling_y: ((data[2] >> 2) & 0x01) == 1,
chroma_sample_position: data[2] & 0x03,
color_primaries: 1,
transfer_characteristics: 1,
matrix_coefficients: 1,
full_range: false,
}
.into(),
description: None,
framerate: None,
bitrate: None,
display_ratio_width: None,
display_ratio_height: None,
optimize_for_latency: None,
container: hang::catalog::Container::Legacy,
jitter: None,
};
let mut catalog = self.catalog.lock();
let track = catalog.video.create_track("av01", config.clone());
drop(catalog);
let track = self.broadcast.create_track(track);
self.config = Some(config);
self.track = Some(track.into());
Ok(())
}
fn init_from_av1c(&mut self, data: &[u8]) -> anyhow::Result<()> {
// Parse av1C box structure
let seq_profile = (data[1] >> 5) & 0x07;
let seq_level_idx = data[1] & 0x1F;
let tier = ((data[2] >> 7) & 0x01) == 1;
let high_bitdepth = ((data[2] >> 6) & 0x01) == 1;
let twelve_bit = ((data[2] >> 5) & 0x01) == 1;
let config = hang::catalog::VideoConfig {
// Resolution unknown from av1C - will be updated when first sequence header arrives
coded_width: None,
coded_height: None,
codec: hang::catalog::AV1 {
profile: seq_profile,
level: seq_level_idx,
tier: if tier { 'H' } else { 'M' },
bitdepth: if high_bitdepth {
if twelve_bit { 12 } else { 10 }
} else {
8
},
mono_chrome: ((data[2] >> 4) & 0x01) == 1,
chroma_subsampling_x: ((data[2] >> 3) & 0x01) == 1,
chroma_subsampling_y: ((data[2] >> 2) & 0x01) == 1,
chroma_sample_position: data[2] & 0x03,
color_primaries: 1,
transfer_characteristics: 1,
matrix_coefficients: 1,
full_range: false,
}
.into(),
description: None,
framerate: None,
bitrate: None,
display_ratio_width: None,
display_ratio_height: None,
optimize_for_latency: None,
container: hang::catalog::Container::Legacy,
jitter: None,
};
if let Some(old) = &self.config && old == &config {
return Ok(());
}
if let Some(track) = self.track.take() {
tracing::debug!(name = ?track.info.name, "reinitializing track");
self.catalog.lock().video.remove_track(&track.info);
}
let mut catalog = self.catalog.lock();
let track = catalog.video.create_track("av01", config.clone());
drop(catalog);
let track = self.broadcast.create_track(track);
self.config = Some(config);
self.track = Some(track.into());
Ok(())
}
🤖 Prompt for AI Agents
In `@rs/moq-mux/src/import/av01.rs` around lines 136 - 186, init_from_av1c
currently always creates a new track and leaks any previous one; change it to
mirror init() by (1) comparing the newly built config with self.config and
returning early if equal, and (2) if unequal, lock the catalog, remove the old
track from the catalog/broadcast (use the same removal calls used in init() for
removing the prior track referenced by self.track), then create the new catalog
track with catalog.video.create_track and call self.broadcast.create_track, and
finally update self.config and self.track. Ensure you use the same helper/method
names used in init() for removing tracks so behavior is consistent.

@kixelated
Copy link
Collaborator

kixelated commented Feb 6, 2026

  • Frontend console error: invalidStateError: Failed to execute 'clone' on 'VideoFrame': Cannot clone closed VideoFrame. at #runRender (renderer.ts:90:29) - I think we can fix this separately.

That's an existing race condition. I'm not sure what causes it, but it's harmless.

Copy link
Collaborator

@kixelated kixelated left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The MoQ stuff looks gud. You figure out if the AI bot is hallucinating; I haven't used AV1 before.

#[cfg(feature = "mp4")]
"fmp4" | "cmaf" => Ok(StreamFormat::Fmp4),
#[cfg(feature = "av1")]
"av01" | "av1" => Ok(StreamFormat::Av01),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe throw in av1C too since both are supported.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Fix all issues with AI agents
In `@rs/moq-mux/src/import/av01.rs`:
- Around line 161-164: The init_from_av1c implementation is hardcoding
color_primaries/transfer_characteristics/matrix_coefficients/full_range to
BT.709; update init_from_av1c to parse the av1C box's configOBUs (bytes 4+) to
extract those color metadata values (or if parsing is not implemented, remove
the hardcoded defaults and leave them unset/None or document why defaults are
acceptable) so init() won't be misled by incorrect defaults; look for symbols
init_from_av1c, av1C, configOBUs, and the fields
color_primaries/transfer_characteristics/matrix_coefficients/full_range to make
the change.
- Around line 350-359: The wall-clock PTS is currently captured once per
decode_stream/decode_frame and reused for all OBUs flushed in that call,
producing zero-duration frames; to fix, stop capturing a single pts at the start
and instead call pts() each time a new frame is started so each OBU flush gets a
fresh timestamp. Locate the code paths that call pts() (the pts method and its
current single-call site in decode_stream/decode_frame) and move/invoke
self.pts(None) at the point where frames are actually created/flushed (e.g.,
inside maybe_start_frame or right before constructing a frame for a
TemporalDelimiter OBU) so each frame gets a new Timestamp based on current
zero.elapsed(); keep the zero field and pts() semantics but ensure it is called
per-OBU flush rather than once per decode invocation.
- Around line 246-259: The payload_offset currently set to 1 or 2 ignores the
LEB128 size field and causes SequenceHeaderObu::parse and frame-type detection
to read the size bytes instead of the payload; fix by using the reader position
returned/advanced by ObuHeader::parse (i.e., use the actual payload start
computed by ObuHeader parsing) as the payload_offset when calling
SequenceHeaderObu::parse and when handling the FrameHeader | Frame arm, and
remove the manual obu_header_size calculation that tries to rederive the header
length so both arms consistently slice the true payload bytes.
🧹 Nitpick comments (2)
rs/moq-mux/src/import/av01.rs (2)

268-292: Redundant data.is_empty() check after already verifying obu_data.len() > obu_header_size.

Line 270 checks data.is_empty() but data is &obu_data[obu_header_size..] which is guaranteed non-empty by the outer if obu_data.len() > obu_header_size condition. This is dead code.

Also, note that the bit extraction at lines 275 and 280 is affected by the payload offset bug flagged above — once the offset is corrected, the frame type detection logic itself is sound.


294-296: Redundant keyframe propagation.

if is_keyframe || self.current.contains_keyframe { self.current.contains_keyframe = true; } can be simplified to self.current.contains_keyframe |= is_keyframe; — it's equivalent and clearer.

Proposed simplification
-			if is_keyframe || self.current.contains_keyframe {
-				self.current.contains_keyframe = true;
-			}
+			self.current.contains_keyframe |= is_keyframe;

@christriants christriants marked this pull request as draft February 7, 2026 02:44
@christriants christriants marked this pull request as ready for review February 7, 2026 02:52
@christriants
Copy link
Contributor Author

Issue with showing the current rendition of the stream

This should be fixed with 56606ee. Good coderabbitai.

@kixelated kixelated merged commit 750728d into moq-dev:main Feb 9, 2026
1 check passed
@kixelated
Copy link
Collaborator

Thanks Chris! Looks awesome

This was referenced Feb 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants