Skip to content

AV1 decoder#920

Open
christriants wants to merge 1 commit intomoq-dev:mainfrom
christriants:feat/av1-decoder
Open

AV1 decoder#920
christriants wants to merge 1 commit intomoq-dev:mainfrom
christriants:feat/av1-decoder

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:

  • Issue with showing the current rendition of the stream
  • 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?

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 6, 2026

Walkthrough

This pull request introduces AV1 video codec support to the moq-mux crate. A new av1 cargo feature is added to Cargo.toml with the scuffle-av1 dependency. A new module av01.rs is created with an Av01 struct implementing AV1 decoding functionality, including OBU parsing, frame buffering, and timestamp handling. The decoder system is extended with new Av01 variants in relevant enums (DecoderFormat, StreamFormat, StreamKind, DecoderKind), and decoder constructors are updated to accept CatalogProducer parameters. The AAC and Opus modules are removed from the import system.

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 inconclusive)
Check name Status Explanation Resolution
Title check ❓ Inconclusive The title 'AV1 decoder' is vague and generic, lacking specificity about the scope and impact of the implementation. Consider a more descriptive title such as 'Add AV1 (av01) decoder with inline sequence header support' to better convey the main change.
✅ Passed checks (2 passed)
Check name Status Explanation
Description check ✅ Passed The description is well-related to the changeset, providing clear context about AV1 decoder implementation, API design, and known issues.
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

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

color_primaries: seq_header.color_config.color_primaries,
transfer_characteristics: seq_header.color_config.transfer_characteristics,
matrix_coefficients: seq_header.color_config.matrix_coefficients,
full_range: false,
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 | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n rs/moq-mux/src/import/av01.rs | head -100

Repository: moq-dev/moq

Length of output: 3470


🏁 Script executed:

rg -n "full_range\|color_range\|ColorConfig" --type rust rs/moq-mux/src/import/av01.rs

Repository: moq-dev/moq

Length of output: 37


🏁 Script executed:

rg -n "struct ColorConfig\|pub.*color" --type rust | head -20

Repository: moq-dev/moq

Length of output: 37


🏁 Script executed:

rg -n "full_range_flag\|full_range\|video_full_range\|ColorConfig" --type rust -A 5 | head -50

Repository: moq-dev/moq

Length of output: 37


🏁 Script executed:

# Search in dependencies or external crates
rg -n "struct ColorConfig" --type rust -A 10

Repository: moq-dev/moq

Length of output: 37


🏁 Script executed:

# Check if there's a Cargo.lock or Cargo.toml that shows scuffle_av1 version
cat rs/moq-mux/Cargo.toml | grep -A 2 "scuffle_av1"

Repository: moq-dev/moq

Length of output: 37


🏁 Script executed:

find rs -name "Cargo.toml" -type f

Repository: moq-dev/moq

Length of output: 286


🏁 Script executed:

cat rs/moq-mux/Cargo.toml

Repository: moq-dev/moq

Length of output: 1561


🏁 Script executed:

rg "ColorConfig" --type rust

Repository: moq-dev/moq

Length of output: 37


🏁 Script executed:

rg "color_config\." --type rust -B 2 -A 2

Repository: moq-dev/moq

Length of output: 985


🏁 Script executed:

# Check if there are any examples or tests using color_config
rg "seq_header\|SequenceHeaderObu" --type rust -A 5 | head -60

Repository: moq-dev/moq

Length of output: 37


🌐 Web query:

scuffle_av1 0.1.4 ColorConfig full_range field documentation

💡 Result:

In scuffle-av1 0.1.4, ColorConfig does not have a full_range field; the field is named:

  • pub full_color_range: bool — documented as AV1 color_range (1 bit) in AV1-Spec-2 §5.5.2. [1]

Per the AV1 bitstream specification, color_range is binary:

  • 0 = studio swing (limited/legal range)
  • 1 = full swing (full range)

So, full_color_range: true corresponds to full range, and false corresponds to limited range. [2]

Sources:
[1] scuffle-av1 0.1.4 ColorConfig rustdoc (docs.rs) (docs.rs)
[2] AV1 Bitstream & Decoding Process Specification (color_range semantics) (aomediacodec.github.io)

Citations:


🌐 Web query:

scuffle_av1 crate color_config fields AV1 specification

💡 Result:

In scuffle_av1, seq::ColorConfig is a Rust representation of AV1’s color_config() structure (AV1 Spec §5.5.2). Its fields map as follows:

scuffle_av1::seq::ColorConfig field AV1 spec name / variable in color_config() Notes
bit_depth: i32 BitDepth Derived from seq_profile, high_bitdepth, and (if present) twelve_bit in color_config().
mono_chrome: bool mono_chrome 1 bit. If true, NumPlanes becomes 1 and chroma-related values are forced/inferred by the spec.
num_planes: u8 NumPlanes mono_chrome ? 1 : 3.
color_primaries: u8 color_primaries Present only if color_description_present_flag == 1, else defaults to CP_UNSPECIFIED.
transfer_characteristics: u8 transfer_characteristics Same presence/defaulting rules as color_primaries.
matrix_coefficients: u8 matrix_coefficients Same presence/defaulting rules as color_primaries. If MC_IDENTITY, conformance requires subsampling_x = 0 and subsampling_y = 0.
full_color_range: bool color_range 1 bit (aka “video full range flag” / full swing vs studio swing).
subsampling_x: bool subsampling_x Chroma subsampling signaled/inferred per seq_profile and BitDepth (and forced for monochrome / identity-RGB cases).
subsampling_y: bool subsampling_y Same as above.
chroma_sample_position: u8 chroma_sample_position Only present when subsampling_x && subsampling_y (i.e., 4:2:0).
separate_uv_delta_q: bool separate_uv_delta_q 1 bit; whether U and V can have separate delta quantizers.

Sources: scuffle_av1::seq::ColorConfig docs/fields [1], AV1 color_config() syntax + semantics [2], and how these values are carried into ISOBMFF (e.g., videoFullRangeFlag, chroma_sample_position rules) [3].

References:
[1] https://docs.rs/scuffle-av1/latest/scuffle_av1/seq/struct.ColorConfig.html
[2] https://aomediacodec.github.io/av1-spec/
[3] https://aomediacodec.github.io/av1-isobmff/


Read full_range from sequence header's color_config.full_color_range instead of hardcoding to false.

The ColorConfig struct in scuffle_av1 exposes full_color_range: bool, which maps to the AV1 specification's color_range field. This determines whether the video uses full swing (full range) or studio swing (limited range) color. Hardcoding this to false causes incorrect color metadata for any stream that uses full-range color.

Change line 75 from full_range: false, to full_range: seq_header.color_config.full_color_range, to match the pattern used for other color config fields (bit_depth, color_primaries, transfer_characteristics, etc.).

🤖 Prompt for AI Agents
In `@rs/moq-mux/src/import/av01.rs` at 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.

Comment on lines +113 to +121
pub fn initialize<T: Buf + AsRef<[u8]>>(&mut self, buf: &mut T) -> anyhow::Result<()> {
let data = buf.as_ref();

// Handle av1C format (MP4/container initialization)
if data.len() >= 4 && data[0] == 0x0a && data.len() == 16 {
self.init_from_av1c(data)?;
buf.advance(data.len());
return 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 | 🟠 Major

🧩 Analysis chain

🌐 Web query:

AV1CodecConfigurationRecord av1C first byte marker specification

💡 Result:

In AV1CodecConfigurationRecord (the payload of the ISOBMFF av1C box), the first byte is specified as:

  • marker = 1 bit, must be 1 (the MSB, bit 7)
  • version = 7 bits, must be 1 (bits 6..0)

So the first byte value is:

  • binary: 1 0000001
  • hex: 0x81

The spec note says the marker bit is set so the first byte can’t be confused with an AV1 OBU header byte. [1]

Source: AOMedia “AV1 Codec ISO Media File Format Binding”, AV1CodecConfigurationRecord syntax/semantics. [1]

[1] (aomediacodec.github.io)

Citations:


🏁 Script executed:

# Examine the full context of the av01.rs file
cd rs/moq-mux && find . -name "av01.rs" -type f

Repository: moq-dev/moq

Length of output: 75


🏁 Script executed:

# Look at the av01.rs file to see the full context and implementation
cat -n rs/moq-mux/src/import/av01.rs | head -150

Repository: moq-dev/moq

Length of output: 5249


Incorrect av1C marker byte check — use 0x81 instead of 0x0a

The first byte of an AV1CodecConfigurationRecord (av1C box) must be 0x81 (marker=1, version=1), not 0x0a. The marker bit is explicitly set per the AOMedia specification to distinguish av1C from OBU headers. A 16-byte raw OBU starting with 0x0a (valid SequenceHeader OBU type) would be misidentified as av1C and incorrectly parsed. Change the check to data[0] == 0x81.

🤖 Prompt for AI Agents
In `@rs/moq-mux/src/import/av01.rs` around lines 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.

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.

Comment on lines +236 to +246
ObuType::SequenceHeader => {
match SequenceHeaderObu::parse(header, &mut &obu_data[1..]) {
Ok(seq_header) => {
self.init(&seq_header)?;
}
Err(e) => {
tracing::warn!("Failed to parse sequence header OBU, skipping: {}", e);
}
}

self.current.contains_keyframe = true;
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 | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, check if the file exists and read the context around the mentioned lines
cat -n rs/moq-mux/src/import/av01.rs | sed -n '230,255p'

Repository: moq-dev/moq

Length of output: 1033


🏁 Script executed:

# Check the ObuHeader struct definition
rg -n "struct ObuHeader\|pub.*has_extension" rs/moq-mux/src/import/av01.rs -A 10

Repository: moq-dev/moq

Length of output: 37


🏁 Script executed:

# Check the SequenceHeaderObu::parse signature
rg -n "impl.*SequenceHeaderObu\|fn parse" rs/moq-mux/src/import/av01.rs -B 2 -A 5 | head -50

Repository: moq-dev/moq

Length of output: 37


🏁 Script executed:

# Check for similar OBU parsing patterns in the codebase
rg -n "obu_data\[" rs/moq-mux/src/import/av01.rs -B 2 -A 2

Repository: moq-dev/moq

Length of output: 757


🏁 Script executed:

# Check imports and ObuHeader definition
rg -n "use.*scuffle_av1|ObuHeader|has_extension" rs/moq-mux/src/import/av01.rs -B 1 -A 3 | head -40

Repository: moq-dev/moq

Length of output: 812


🏁 Script executed:

# Check if has_extension is used anywhere in the codebase
rg -n "has_extension\|header\." rs/moq-mux/src/import/av01.rs | head -20

Repository: moq-dev/moq

Length of output: 37


🏁 Script executed:

# Look for the definition of ObuHeader or search for its fields
find . -path "*/scuffle_av1*" -type f | head -10

Repository: moq-dev/moq

Length of output: 37


🏁 Script executed:

# Check what fields ObuHeader might have by looking at how it's used
rg -n "header\." rs/moq-mux/src/import/av01.rs -A 1 | head -30

Repository: moq-dev/moq

Length of output: 987


🏁 Script executed:

# Check Cargo.toml for scuffle_av1 dependency
cat rs/moq-mux/Cargo.toml | grep -A 5 scuffle_av1

Repository: moq-dev/moq

Length of output: 37


🏁 Script executed:

# Search for scuffle_av1 source in the repo
find . -name "scuffle_av1*" -o -name "*scuffle*" -type d | grep -v target | head -10

Repository: moq-dev/moq

Length of output: 37


🏁 Script executed:

# Check workspace Cargo.toml for scuffle
cat Cargo.toml | grep -A 5 scuffle_av1

Repository: moq-dev/moq

Length of output: 37


🏁 Script executed:

# Try to find ObuHeader definition anywhere in the repo
rg -n "struct ObuHeader|pub struct ObuHeader" --type rust

Repository: moq-dev/moq

Length of output: 37


🏁 Script executed:

# Read Cargo.toml directly
ls -la rs/moq-mux/Cargo.toml
cat rs/moq-mux/Cargo.toml

Repository: moq-dev/moq

Length of output: 1632


🏁 Script executed:

# Search for any Cargo.toml in the repo
find . -name "Cargo.toml" -type f | head -5

Repository: moq-dev/moq

Length of output: 161


🏁 Script executed:

# Get more context around the Frame parsing to understand the pattern
cat -n rs/moq-mux/src/import/av01.rs | sed -n '250,270p'

Repository: moq-dev/moq

Length of output: 785


🌐 Web query:

scuffle-av1 crate ObuHeader fields has_extension

💡 Result:

In scuffle-av1 (crate scuffle_av1), ObuHeader does not have a has_extension field. Instead, the presence of the OBU extension is represented by:

  • extension_header: Option<ObuExtensionHeader>Some(...) iff the bitstream’s obu_extension_flag is 1; otherwise None. So “has extension” is effectively header.extension_header.is_some(). [1][2]

The full ObuHeader fields are: obu_type: ObuType, size: Option<u64>, and extension_header: Option<ObuExtensionHeader>. [1]

Where the flag goes: during parsing, the code reads extension_flag from the header bits and uses it only to decide whether to parse/populate extension_header; it does not store the boolean separately. [2]

Sources:
[1] ObuHeader rustdoc (fields) https://docs.rs/scuffle-av1/latest/scuffle_av1/struct.ObuHeader.html
[2] ObuHeader::parse implementation (reads extension_flag) https://docs.rs/scuffle-av1/latest/src/scuffle_av1/obu/mod.rs.html


🌐 Web query:

AV1 OBU extension flag bit position specification

💡 Result:

In the AV1 OBU header (obu_header()), obu_extension_flag is the 1-bit field immediately after obu_type:

obu_forbidden_bit (1) | obu_type (4) | obu_extension_flag (1) | obu_has_size_field (1) | obu_reserved_1bit (1) [1]

So, in the first OBU header byte (bit numbering b7 = MSB … b0 = LSB):

  • b7: obu_forbidden_bit
  • b6..b3: obu_type
  • b2: obu_extension_flag (mask 0x04)
  • b1: obu_has_size_field (mask 0x02)
  • b0: obu_reserved_1bit (mask 0x01) [1][2]

Sequence header OBU payload offset doesn't account for extension byte; keyframe flag set even on parse failure.

Two issues in this block:

  1. &obu_data[1..] always skips exactly one byte. If the OBU has an extension byte, the sequence header payload starts at byte 2. The header.extension_header field indicates whether an extension is present — use it to compute the correct offset.

  2. Line 246 sets contains_keyframe = true unconditionally, even when parsing fails. A malformed sequence header shouldn't assert keyframe status, as the subsequent frame may be incorrectly tagged.

🐛 Proposed fix
 ObuType::SequenceHeader => {
-    match SequenceHeaderObu::parse(header, &mut &obu_data[1..]) {
+    let obu_header_size = if header.extension_header.is_some() { 2 } else { 1 };
+    match SequenceHeaderObu::parse(header, &mut &obu_data[obu_header_size..]) {
         Ok(seq_header) => {
             self.init(&seq_header)?;
+            self.current.contains_keyframe = true;
         }
         Err(e) => {
             tracing::warn!("Failed to parse sequence header OBU, skipping: {}", e);
         }
     }
-
-    self.current.contains_keyframe = true;
 }
📝 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
ObuType::SequenceHeader => {
match SequenceHeaderObu::parse(header, &mut &obu_data[1..]) {
Ok(seq_header) => {
self.init(&seq_header)?;
}
Err(e) => {
tracing::warn!("Failed to parse sequence header OBU, skipping: {}", e);
}
}
self.current.contains_keyframe = true;
ObuType::SequenceHeader => {
let obu_header_size = if header.extension_header.is_some() { 2 } else { 1 };
match SequenceHeaderObu::parse(header, &mut &obu_data[obu_header_size..]) {
Ok(seq_header) => {
self.init(&seq_header)?;
self.current.contains_keyframe = true;
}
Err(e) => {
tracing::warn!("Failed to parse sequence header OBU, skipping: {}", e);
}
}
}
🤖 Prompt for AI Agents
In `@rs/moq-mux/src/import/av01.rs` around lines 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.

Comment on lines +400 to +441
let header = data[0];
let has_size = (header >> 1) & 1 == 1;

if !has_size {
let remaining = self.buf.remaining();
let obu = self.buf.copy_to_bytes(remaining);
return Some(Ok(obu));
}

// Parse LEB128 size
let mut size: usize = 0;
let mut offset = 1;
let mut shift = 0;

loop {
if offset >= data.len() {
return None;
}

let byte = data[offset];
offset += 1;

size |= ((byte & 0x7F) as usize) << shift;
shift += 7;

if byte & 0x80 == 0 {
break;
}

if shift >= 56 {
return Some(Err(anyhow::anyhow!("OBU size too large")));
}
}

let total_size = offset + size;

if total_size > self.buf.remaining() {
return None;
}

let obu = self.buf.copy_to_bytes(total_size);
Some(Ok(obu))
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 | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# First, let's understand the ObuIterator context and see the full method
cat -n rs/moq-mux/src/import/av01.rs | sed -n '358,442p'

Repository: moq-dev/moq

Length of output: 2335


🏁 Script executed:

#!/bin/bash
# Check how SequenceHeaderObu::parse is called to understand expected input
rg -B 5 -A 5 "SequenceHeaderObu::parse" rs/moq-mux/src/import/av01.rs

Repository: moq-dev/moq

Length of output: 405


🏁 Script executed:

#!/bin/bash
# Look at how other decoders (avc3, hev1) handle similar OBU/NAL unit parsing
ls -la rs/moq-mux/src/import/ | grep -E "\.(rs|toml)"

Repository: moq-dev/moq

Length of output: 636


🏁 Script executed:

#!/bin/bash
# Check if there are similar iterator patterns in other decoders
rg -n "has_size\|has_extension" rs/moq-mux/src/import/

Repository: moq-dev/moq

Length of output: 37


🏁 Script executed:

#!/bin/bash
# Look at the full decode_obu function to see how data is being sliced before passing to parse functions
cat -n rs/moq-mux/src/import/av01.rs | sed -n '227,305p'

Repository: moq-dev/moq

Length of output: 2679


🏁 Script executed:

#!/bin/bash
# Check the scuffle_av1 dependency version and explore its crate
rg "scuffle" rs/moq-mux/Cargo.toml

Repository: moq-dev/moq

Length of output: 215


🏁 Script executed:

#!/bin/bash
# Look for any comments or documentation in the code about OBU format handling
rg -B 3 -A 3 "obu_extension" rs/moq-mux/src/import/av01.rs

Repository: moq-dev/moq

Length of output: 217


🏁 Script executed:

#!/bin/bash
# Check if hev1.rs or avc3.rs handle NAL unit extension bytes for comparison
rg -n "extension\|offset" rs/moq-mux/src/import/hev1.rs | head -20

Repository: moq-dev/moq

Length of output: 37


🏁 Script executed:

#!/bin/bash
# Let's verify the consistency: Check if decode_obu is being applied correctly and whether the review's concern is valid
# by examining if there are other places in the code that handle OBU parsing
rg -n "ObuHeader::parse\|SequenceHeaderObu::parse\|FrameHeaderObu::parse" rs/moq-mux/src/import/

Repository: moq-dev/moq

Length of output: 37


🏁 Script executed:

#!/bin/bash
# Check the actual API of scuffle_av1 to understand what parse functions expect
# First, let's see if there are any test cases or examples
rg -B 5 -A 10 "ObuHeader::parse" rs/moq-mux/src/import/av01.rs

Repository: moq-dev/moq

Length of output: 587


🏁 Script executed:

#!/bin/bash
# Critical: Let's check if the extension byte is indeed being skipped incorrectly in SequenceHeaderObu::parse
# by looking at similar patterns in scuffle-h265 usage in hev1.rs
cat -n rs/moq-mux/src/import/hev1.rs | head -100

Repository: moq-dev/moq

Length of output: 3721


🏁 Script executed:

#!/bin/bash
# Let's understand the ObuHeader::parse behavior by checking if it returns info about extension byte
# and verify the actual position after parsing
cat -n rs/moq-mux/src/import/av01.rs | sed -n '227,245p'

Repository: moq-dev/moq

Length of output: 811


🏁 Script executed:

#!/bin/bash
# Let's check if there's test code or integration tests that would catch this ObuIterator bug
find rs/moq-mux -name "*.rs" -type f | xargs grep -l "ObuIterator\|test.*av01\|#\[test\]" | head -5

Repository: moq-dev/moq

Length of output: 145


🏁 Script executed:

#!/bin/bash
# Check if the extension byte is being checked in ObuIterator (which is the snippet in question)
# Looking at lines around 400-441 more carefully
cat -n rs/moq-mux/src/import/av01.rs | sed -n '358,442p' | grep -E "extension|offset|header"

Repository: moq-dev/moq

Length of output: 406


🏁 Script executed:

#!/bin/bash
# Let's trace through exactly what happens: check if ObuIterator is actually being used
# and whether it processes OBUs before or after they're individually extracted
cat -n rs/moq-mux/src/import/av01.rs | sed -n '188,225p'

Repository: moq-dev/moq

Length of output: 1187


🏁 Script executed:

#!/bin/bash
# Verify the OBU header format specification by checking comments and structure
# and confirm the bit positions for extension flag
cat -n rs/moq-mux/src/import/av01.rs | sed -n '393,402p'

Repository: moq-dev/moq

Length of output: 353


🏁 Script executed:

#!/bin/bash
# Cross-check: In decode_obu, does the code handle extension bytes when it receives OBUs from ObuIterator?
# This will tell us if ObuIterator is supposed to handle extension bytes
cat -n rs/moq-mux/src/import/av01.rs | sed -n '250,260p'

Repository: moq-dev/moq

Length of output: 487


🏁 Script executed:

#!/bin/bash
# Let's verify if scuffle_av1::ObuHeader::parse includes information about extension byte size
# by examining how the parsed header is used
rg -A 15 "let header = scuffle_av1::ObuHeader::parse" rs/moq-mux/src/import/av01.rs | head -20

Repository: moq-dev/moq

Length of output: 529


🏁 Script executed:

#!/bin/bash
# Examine what the header type contains
rg "ObuHeader" rs/moq-mux/src/import/av01.rs

Repository: moq-dev/moq

Length of output: 121


🏁 Script executed:

#!/bin/bash
# Check if there are any test files that verify ObuIterator behavior
find rs/moq-mux -name "*.rs" -exec grep -l "ObuIterator\|#\[cfg(test)\]" {} \;

Repository: moq-dev/moq

Length of output: 145


🏁 Script executed:

#!/bin/bash
# Let's look at the specific bit calculation for extension flag in both places
# to confirm they're consistent
rg ">> 2.*& 1\|extension_flag" rs/moq-mux/src/import/av01.rs

Repository: moq-dev/moq

Length of output: 37


OBU extension byte not accounted for when parsing the LEB128 size field in ObuIterator.

The LEB128 size field offset is hardcoded to 1 (line 411), but when obu_extension_flag is set in the header byte, the extension byte occupies the second byte, so the size field actually starts at offset 2. This causes incorrect OBU boundary calculation for any OBU with an extension byte, corrupting all subsequent OBU splits.

The decode_obu function already demonstrates the correct pattern for handling this (lines 252–253), where it checks the extension flag and adjusts the header size accordingly. The same logic should be applied in ObuIterator::next.

🐛 Proposed fix
 let header = data[0];
 let has_size = (header >> 1) & 1 == 1;

 if !has_size {
     let remaining = self.buf.remaining();
     let obu = self.buf.copy_to_bytes(remaining);
     return Some(Ok(obu));
 }

 // Parse LEB128 size
 let mut size: usize = 0;
-let mut offset = 1;
+let has_extension = (header >> 2) & 1 == 1;
+let mut offset = if has_extension { 2 } else { 1 };
 let mut shift = 0;
📝 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
let header = data[0];
let has_size = (header >> 1) & 1 == 1;
if !has_size {
let remaining = self.buf.remaining();
let obu = self.buf.copy_to_bytes(remaining);
return Some(Ok(obu));
}
// Parse LEB128 size
let mut size: usize = 0;
let mut offset = 1;
let mut shift = 0;
loop {
if offset >= data.len() {
return None;
}
let byte = data[offset];
offset += 1;
size |= ((byte & 0x7F) as usize) << shift;
shift += 7;
if byte & 0x80 == 0 {
break;
}
if shift >= 56 {
return Some(Err(anyhow::anyhow!("OBU size too large")));
}
}
let total_size = offset + size;
if total_size > self.buf.remaining() {
return None;
}
let obu = self.buf.copy_to_bytes(total_size);
Some(Ok(obu))
let header = data[0];
let has_size = (header >> 1) & 1 == 1;
if !has_size {
let remaining = self.buf.remaining();
let obu = self.buf.copy_to_bytes(remaining);
return Some(Ok(obu));
}
// Parse LEB128 size
let mut size: usize = 0;
let has_extension = (header >> 2) & 1 == 1;
let mut offset = if has_extension { 2 } else { 1 };
let mut shift = 0;
loop {
if offset >= data.len() {
return None;
}
let byte = data[offset];
offset += 1;
size |= ((byte & 0x7F) as usize) << shift;
shift += 7;
if byte & 0x80 == 0 {
break;
}
if shift >= 56 {
return Some(Err(anyhow::anyhow!("OBU size too large")));
}
}
let total_size = offset + size;
if total_size > self.buf.remaining() {
return None;
}
let obu = self.buf.copy_to_bytes(total_size);
Some(Ok(obu))
🤖 Prompt for AI Agents
In `@rs/moq-mux/src/import/av01.rs` around lines 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).

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.

1 participant