Skip to content

Conversation

@mcintyrehh
Copy link
Contributor

@mcintyrehh mcintyrehh commented Jan 4, 2026

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

This PR adds an HEVC hev1 decoder, following the format of the avc1 import. The decoders are largely identical with the exception of:

  • init - the avc3 decoder uses the h264_parser crate to handle parsing the SPS Nal unit, this PR adds a new scuffle_h265 crate to the same end. A new method get_vui_data pulls out framerate and display_ratio_width/height from the VUI Parameters of the SPS. Framerate ends up getting passed through to the catalog! The FFmpeg HEVC encoder in OBS doesn't pass through an aspect_ratio_idc, so I haven't been able to confirm the display ratio fields. I am the babiest of Crab-lings so shout out to Claude for the Rust syntax help here!
  • decode_nal - similar to the avc3 parser. Ignores the second NAL header byte containing nuh_layer_id and nuh_temporal_id_plus1. Not sure if there is anything we need to do with these?
    • matches NAL type by Non-VCL (SPS, then AUD/PPS/SEIs), then keyframe containing slices, then finally non-keyframe slices

HEVC with framerate and Opus! 🤯
Screenshot from 2026-01-04 00-01-51

Other changes:

  • moved the the NalIterator and its child functions (after_start_code, find_start_code) into a new annexb.rs module and added a new test_nal_iterator_realistic_h265

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Jan 4, 2026

Walkthrough

Adds H.265 support and Annex B parsing: introduces dependency scuffle-h265 = "0.2.2"; adds a new annexb module with start-code detection, NalIterator, and START_CODE; adds a new hev1 module implementing an HEV1 decoder with SPS/PPS handling, frame assembly, and track management; refactors avc3 to use annexb; extends DecoderFormat and DecoderKind with Hev1; and re-exports annexb and hev1 from the import module.

Pre-merge checks and finishing touches

✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title 'hev1 decoder' is concise and directly related to the main change: adding a new HEVC hev1 decoder implementation.
Description check ✅ Passed The description is well-related to the changeset, explaining the hev1 decoder implementation, referencing related issues/PRs, and documenting key implementation details and code refactoring.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
✨ Finishing touches
  • 📝 Generate docstrings

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: 1

🧹 Nitpick comments (2)
rs/hang/src/import/hev1.rs (2)

318-328: Minor: Use implicit return for idiomatic Rust.

The explicit return statement is non-idiomatic in Rust when it's the last expression.

🔎 Suggested fix
 fn pack_constraint_flags(profile: &scuffle_h265::Profile) -> [u8; 6] {
 	let mut flags = [0u8; 6];
 	flags[0] = ((profile.progressive_source_flag as u8) << 7)
 		| ((profile.interlaced_source_flag as u8) << 6)
 		| ((profile.non_packed_constraint_flag as u8) << 5)
 		| ((profile.frame_only_constraint_flag as u8) << 4);

 	// @todo: pack the rest of the optional flags in profile.additional_flags
-	return flags;
+	flags
 }

374-396: Consider adding inline unit tests.

As per coding guidelines, Rust crates should have tests integrated within source files using inline test modules. Consider adding a #[cfg(test)] module with tests for:

  • decode_nal with various NAL types
  • pack_constraint_flags output verification
  • aspect_ratio_from_idc mappings
  • get_vui_data extraction logic

Would you like me to generate a starter test module for this file?

📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 59f50c8 and 3fcb2d0.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (6)
  • rs/hang/Cargo.toml
  • rs/hang/src/import/annexb.rs
  • rs/hang/src/import/avc3.rs
  • rs/hang/src/import/decoder.rs
  • rs/hang/src/import/hev1.rs
  • rs/hang/src/import/mod.rs
🧰 Additional context used
📓 Path-based instructions (2)
rs/**/Cargo.toml

📄 CodeRabbit inference engine (CLAUDE.md)

For Rust development, use the workspace configuration in rs/Cargo.toml

Files:

  • rs/hang/Cargo.toml
**/*.rs

📄 CodeRabbit inference engine (CLAUDE.md)

In Rust crates, tests should be integrated within source files using inline test modules

Files:

  • rs/hang/src/import/mod.rs
  • rs/hang/src/import/avc3.rs
  • rs/hang/src/import/annexb.rs
  • rs/hang/src/import/decoder.rs
  • rs/hang/src/import/hev1.rs
🧬 Code graph analysis (2)
rs/hang/src/import/decoder.rs (1)
rs/hang/src/import/hev1.rs (2)
  • new (32-40)
  • pts (267-274)
rs/hang/src/import/hev1.rs (4)
rs/hang/src/import/annexb.rs (1)
  • new (12-14)
rs/hang/src/import/avc3.rs (10)
  • new (30-38)
  • init (40-98)
  • initialize (101-113)
  • decode_stream (121-136)
  • pts (248-255)
  • decode_frame (145-168)
  • decode_nal (170-218)
  • maybe_start_frame (220-242)
  • is_initialized (244-246)
  • drop (259-264)
rs/hang/src/import/decoder.rs (5)
  • new (80-90)
  • initialize (98-110)
  • decode_stream (125-136)
  • decode_frame (148-162)
  • is_initialized (165-173)
rs/hang/src/model/timestamp.rs (1)
  • from_micros (40-46)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Check
🔇 Additional comments (23)
rs/hang/src/import/mod.rs (1)

2-2: LGTM! Clean module organization.

The new annexb and hev1 modules follow the existing pattern consistently with proper declarations and re-exports.

Also applies to: 6-6, 11-11, 15-15

rs/hang/src/import/decoder.rs (1)

5-5: LGTM! Hev1 integration follows the established Avc3 pattern.

The Hev1 decoder is properly integrated across all decoder methods with consistent handling. The deprecation warnings for "h265"/"hevc" aliases are a nice touch for backward compatibility.

Also applies to: 15-16, 33-37, 51-51, 64-65, 84-84, 102-102, 129-129, 156-156, 169-169

rs/hang/src/import/avc3.rs (1)

1-3: LGTM! Clean refactoring to use the shared annexb module.

The migration from local NAL parsing logic to the centralized annexb module improves code reusability without changing the logic.

rs/hang/src/import/annexb.rs (6)

4-4: LGTM! Standard Annex B start code.

The 4-byte start code constant is correctly defined using Bytes::from_static for efficient cloning.


6-32: LGTM! Well-designed NAL iterator with flush support.

The iterator caches the start code position for efficient flushing, and properly handles both cached and uncached start positions.


34-53: LGTM! Iterator implementation is correct.

The iterator properly handles error propagation, advances the buffer correctly, and caches start positions for efficient iteration.


56-72: LGTM! Robust start code validation.

The function correctly validates both 3-byte and 4-byte Annex B start codes with appropriate error handling for incomplete or invalid patterns.


75-135: LGTM! Clever optimization for start code detection.

The algorithm efficiently searches for start codes by checking the 3rd byte first, allowing skips of up to 3 bytes when no pattern is detected. The implementation correctly handles both 3-byte and 4-byte start codes with proper edge case handling.


137-535: LGTM! Excellent test coverage.

The test suite is comprehensive and covers:

  • Valid 3-byte and 4-byte start codes
  • Incomplete and invalid patterns
  • Edge cases (empty NALs, embedded zeros, consecutive zeros)
  • Realistic H.264 and H.265 streams
  • Flush behavior in various scenarios

The tests provide strong confidence in the correctness of the NAL parsing logic.

rs/hang/Cargo.toml (1)

32-32: Version 0.2.2 of scuffle-h265 is valid and currently the latest published release. No known security vulnerabilities or CVEs have been identified for this package.

rs/hang/src/import/hev1.rs (13)

10-29: LGTM!

The struct definition follows the established pattern from Avc3 and includes appropriate documentation noting the single-layer limitation.


31-40: LGTM!

Constructor follows the established pattern from Avc3::new().


42-98: LGTM!

The initialization logic correctly mirrors Avc3::init() - handles config comparison to avoid redundant reinitialization, properly removes old tracks from the catalog before creating new ones, and extracts H.265-specific codec parameters from the SPS.


100-113: LGTM!

Follows the established Avc3::initialize() pattern exactly.


115-136: LGTM!

The streaming decode implementation matches the Avc3 pattern. The TODO about PTS association is acknowledged and documented.


138-168: LGTM!

Follows the established Avc3::decode_frame() pattern - iterates NALs, flushes the final NAL, and triggers frame emission.


239-261: LGTM!

Frame emission logic correctly matches Avc3::maybe_start_frame().


263-274: LGTM!

Both methods follow the Avc3 pattern. The u128 to u64 cast in pts() is safe for practical stream durations.


277-284: LGTM!

Proper cleanup on drop, matching Avc3's Drop implementation.


286-316: LGTM!

The NAL type values correctly match ITU H.265 Table 7-1, and reserved ranges are appropriately excluded.


330-335: LGTM!

Frame accumulation struct follows the established pattern.


337-372: LGTM!

VUI parameter extraction is well-structured. The use of NonZero types (via .get()) for timing values safely prevents division by zero.


374-396: LGTM!

Aspect ratio mapping covers all predefined SAR values with appropriate fallbacks for unspecified and reserved values.

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: 2

📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 3fcb2d0 and a262cb9.

📒 Files selected for processing (1)
  • rs/hang/src/import/hev1.rs
🧰 Additional context used
📓 Path-based instructions (1)
**/*.rs

📄 CodeRabbit inference engine (CLAUDE.md)

In Rust crates, tests should be integrated within source files using inline test modules

Files:

  • rs/hang/src/import/hev1.rs
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Check
🔇 Additional comments (1)
rs/hang/src/import/hev1.rs (1)

170-237: NAL parsing logic looks solid.

The NAL unit parsing correctly:

  • Validates the forbidden zero bit
  • Extracts the NAL unit type from the first header byte
  • Handles HEVC-specific frame boundaries (SPS, AUD, PPS, SEI, keyframe slices, non-keyframe slices)
  • Checks first_slice_segment_in_pic_flag for slice NALs to detect frame boundaries
  • Accumulates NALs with 4-byte start codes for downstream processing

The logic aligns well with the HEVC specification and the pattern established by the existing avc3 decoder.

Comment on lines 1 to 396
use crate as hang;
use crate::import::annexb::{NalIterator, START_CODE};

use anyhow::Context;
use buf_list::BufList;
use bytes::{Buf, Bytes};
use moq_lite as moq;
use scuffle_h265::SpsNALUnit;

/// A decoder for H.265 with inline SPS/PPS.
/// Only supports single layer streams, ignores VPS.
pub struct Hev1 {
// The broadcast being produced.
// This `hang` variant includes a catalog.
broadcast: hang::BroadcastProducer,

// The track being produced.
track: Option<hang::TrackProducer>,

// Whether the track has been initialized.
// If it changes, then we'll reinitialize with a new track.
config: Option<hang::catalog::VideoConfig>,

// The current frame being built.
current: Frame,

// Used to compute wall clock timestamps if needed.
zero: Option<tokio::time::Instant>,
}

impl Hev1 {
pub fn new(broadcast: hang::BroadcastProducer) -> Self {
Self {
broadcast,
track: None,
config: None,
current: Default::default(),
zero: None,
}
}

fn init(&mut self, sps: &SpsNALUnit) -> anyhow::Result<()> {
let profile = &sps.rbsp.profile_tier_level.general_profile;
let vui_data = get_vui_data(&sps.rbsp.vui_parameters);

let config = hang::catalog::VideoConfig {
coded_width: Some(sps.rbsp.cropped_width() as u32),
coded_height: Some(sps.rbsp.cropped_height() as u32),
codec: hang::catalog::H265 {
in_band: true, // We only support `hev1` with inline SPS/PPS for now
profile_space: profile.profile_space,
profile_idc: profile.profile_idc,
profile_compatibility_flags: profile.profile_compatibility_flag.bits().to_be_bytes(),
tier_flag: profile.tier_flag,
level_idc: profile.level_idc.context("missing level_idc in SPS")?,
constraint_flags: pack_constraint_flags(profile),
}
.into(),
description: None,
framerate: vui_data.framerate,
bitrate: None,
display_ratio_width: vui_data.display_ratio_width,
display_ratio_height: vui_data.display_ratio_height,
optimize_for_latency: None,
};

if let Some(old) = &self.config {
if old == &config {
return Ok(());
}
}

if let Some(track) = &self.track.take() {
tracing::debug!(name = ?track.info.name, "reinitializing track");
self.broadcast.catalog.lock().remove_video(&track.info.name);
}

let track = moq::Track {
name: self.broadcast.track_name("video"),
priority: 2,
};

tracing::debug!(name = ?track.name, ?config, "starting track");

{
let mut catalog = self.broadcast.catalog.lock();
let video = catalog.insert_video(track.name.clone(), config.clone());
video.priority = 2;
}

let track = track.produce();
self.broadcast.insert_track(track.consumer);

self.config = Some(config);
self.track = Some(track.producer.into());

Ok(())
}

/// Initialize the decoder with SPS/PPS and other non-slice NALs.
pub fn initialize<T: Buf + AsRef<[u8]>>(&mut self, buf: &mut T) -> anyhow::Result<()> {
let mut nals = NalIterator::new(buf);

while let Some(nal) = nals.next().transpose()? {
self.decode_nal(nal, None)?;
}

if let Some(nal) = nals.flush()? {
self.decode_nal(nal, None)?;
}

Ok(())
}

/// Decode as much data as possible from the given buffer.
///
/// Unlike [Self::decode_frame], this method needs the start code for the next frame.
/// This means it works for streaming media (ex. stdin) but adds a frame of latency.
///
/// TODO: This currently associates PTS with the *previous* frame, as part of `maybe_start_frame`.
pub fn decode_stream<T: Buf + AsRef<[u8]>>(
&mut self,
buf: &mut T,
pts: Option<hang::Timestamp>,
) -> anyhow::Result<()> {
let pts = self.pts(pts)?;

// Iterate over the NAL units in the buffer based on start codes.
let nals = NalIterator::new(buf);

for nal in nals {
self.decode_nal(nal?, Some(pts))?;
}

Ok(())
}

/// Decode all data in the buffer, assuming the buffer contains (the rest of) a frame.
///
/// Unlike [Self::decode_stream], this is called when we know NAL boundaries.
/// This can avoid a frame of latency just waiting for the next frame's start code.
/// This can also be used when EOF is detected to flush the final frame.
///
/// NOTE: The next decode will fail if it doesn't begin with a start code.
pub fn decode_frame<T: Buf + AsRef<[u8]>>(
&mut self,
buf: &mut T,
pts: Option<hang::Timestamp>,
) -> anyhow::Result<()> {
let pts = self.pts(pts)?;
// Iterate over the NAL units in the buffer based on start codes.
let mut nals = NalIterator::new(buf);

// Iterate over each NAL that is followed by a start code.
while let Some(nal) = nals.next().transpose()? {
self.decode_nal(nal, Some(pts))?;
}

// Assume the rest of the buffer is a single NAL.
if let Some(nal) = nals.flush()? {
self.decode_nal(nal, Some(pts))?;
}

// Flush the frame if we read a slice.
self.maybe_start_frame(Some(pts))?;

Ok(())
}

/// Decode a single NAL unit. Only reads the first header byte to extract nal_unit_type,
/// Ignores nuh_layer_id and nuh_temporal_id_plus1.
fn decode_nal(&mut self, nal: Bytes, pts: Option<hang::Timestamp>) -> anyhow::Result<()> {
anyhow::ensure!(nal.len() >= 2, "NAL unit is too short");
// u16 header: [forbidden_zero_bit(1) | nal_unit_type(6) | nuh_layer_id(6) | nuh_temporal_id_plus1(3)]
let header = nal.first().context("NAL unit is too short")?;

let forbidden_zero_bit = (header >> 7) & 1;
anyhow::ensure!(forbidden_zero_bit == 0, "forbidden zero bit is not zero");

// Bits 1-6: nal_unit_type
let nal_unit_type = (header >> 1) & 0b111111;
let nal_type = HevcNalType::try_from(nal_unit_type).ok();

match nal_type {
Some(HevcNalType::Sps) => {
self.maybe_start_frame(pts)?;

// Try to reinitialize the track if the SPS has changed.
let sps = SpsNALUnit::parse(&mut &nal[..]).context("failed to parse SPS NAL unit")?;
self.init(&sps)?;
}
// TODO parse the SPS again and reinitialize the track if needed
Some(HevcNalType::Aud | HevcNalType::Pps | HevcNalType::SeiPrefix | HevcNalType::SeiSuffix) => {
self.maybe_start_frame(pts)?;
}
// Keyframe containing slices
Some(
HevcNalType::IdrWRadl
| HevcNalType::IdrNLp
| HevcNalType::BlaNLp
| HevcNalType::BlaWRadl
| HevcNalType::BlaWLp
| HevcNalType::Cra,
) => {
self.current.contains_idr = true;
self.current.contains_slice = true;
}
// All other slice types (both N and R variants)
Some(
HevcNalType::TrailN
| HevcNalType::TrailR
| HevcNalType::TsaN
| HevcNalType::TsaR
| HevcNalType::StsaN
| HevcNalType::StsaR
| HevcNalType::RadlN
| HevcNalType::RadlR
| HevcNalType::RaslN
| HevcNalType::RaslR,
) => {
// Check first_slice_segment_in_pic_flag (bit 7 of third byte, after 2-byte header)
if nal.get(2).context("NAL unit is too short")? & 0x80 != 0 {
self.maybe_start_frame(pts)?;
}
self.current.contains_slice = true;
}
_ => {}
}

// Rather than keeping the original size of the start code, we replace it with a 4 byte start code.
// It's just marginally easier and potentially more efficient down the line (JS player with MSE).
// NOTE: This is ref-counted and static, so it's extremely cheap to clone.
self.current.chunks.push_chunk(START_CODE.clone());
self.current.chunks.push_chunk(nal);

Ok(())
}

fn maybe_start_frame(&mut self, pts: Option<hang::Timestamp>) -> anyhow::Result<()> {
// If we haven't seen any slices, we shouldn't flush yet.
if !self.current.contains_slice {
return Ok(());
}

let track = self.track.as_mut().context("expected SPS before any frames")?;
let pts = pts.context("missing timestamp")?;

let payload = std::mem::take(&mut self.current.chunks);
let frame = hang::Frame {
timestamp: pts,
keyframe: self.current.contains_idr,
payload,
};

track.write(frame)?;

self.current.contains_idr = false;
self.current.contains_slice = false;

Ok(())
}

pub fn is_initialized(&self) -> bool {
self.track.is_some()
}

fn pts(&mut self, hint: Option<hang::Timestamp>) -> anyhow::Result<hang::Timestamp> {
if let Some(pts) = hint {
return Ok(pts);
}

let zero = self.zero.get_or_insert_with(tokio::time::Instant::now);
Ok(hang::Timestamp::from_micros(zero.elapsed().as_micros() as u64)?)
}
}

impl Drop for Hev1 {
fn drop(&mut self) {
if let Some(track) = &self.track {
tracing::debug!(name = ?track.info.name, "ending track");
self.broadcast.catalog.lock().remove_video(&track.info.name);
}
}
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, num_enum::TryFromPrimitive)]
#[repr(u8)]
pub enum HevcNalType {
TrailN = 0,
TrailR = 1,
TsaN = 2,
TsaR = 3,
StsaN = 4,
StsaR = 5,
RadlN = 6,
RadlR = 7,
RaslN = 8,
RaslR = 9,
// 10 -> 15 reserved
BlaWLp = 16,
BlaWRadl = 17,
BlaNLp = 18,
IdrWRadl = 19,
IdrNLp = 20,
Cra = 21,
// 22 -> 31 reserved
Vps = 32,
Sps = 33,
Pps = 34,
Aud = 35,
EndOfSequence = 36,
EndOfBitstream = 37,
Filler = 38,
SeiPrefix = 39,
SeiSuffix = 40,
} // ITU H.265 V10 Table 7-1 – NAL unit type codes and NAL unit type classes

// Packs the constraint flags from ITU H.265 V10 Section 7.3.3 Profile, tier and level syntax
fn pack_constraint_flags(profile: &scuffle_h265::Profile) -> [u8; 6] {
let mut flags = [0u8; 6];
flags[0] = ((profile.progressive_source_flag as u8) << 7)
| ((profile.interlaced_source_flag as u8) << 6)
| ((profile.non_packed_constraint_flag as u8) << 5)
| ((profile.frame_only_constraint_flag as u8) << 4);

// @todo: pack the rest of the optional flags in profile.additional_flags
flags
}

#[derive(Default)]
struct Frame {
chunks: BufList,
contains_idr: bool,
contains_slice: bool,
}

struct VuiData {
framerate: Option<f64>,
display_ratio_width: Option<u32>,
display_ratio_height: Option<u32>,
}

fn get_vui_data(vui: &Option<scuffle_h265::VuiParameters>) -> VuiData {
// FPS = time_scale / num_units_in_tick
let framerate = vui
.as_ref()
.and_then(|v| v.vui_timing_info.as_ref())
.map(|t| t.time_scale.get() as f64 / t.num_units_in_tick.get() as f64);

let (display_ratio_width, display_ratio_height) = vui
.as_ref()
.map(|v| &v.aspect_ratio_info)
.and_then(|ar| {
match ar {
// Extended SAR has explicit arbitrary values for width and height.
scuffle_h265::AspectRatioInfo::ExtendedSar { sar_width, sar_height } => {
Some((Some(*sar_width as u32), Some(*sar_height as u32)))
}
// Predefined map to known values.
scuffle_h265::AspectRatioInfo::Predefined(idc) => {
aspect_ratio_from_idc(*idc).map(|(w, h)| (Some(w), Some(h)))
}
}
})
.unwrap_or((None, None));

VuiData {
framerate,
display_ratio_width,
display_ratio_height,
}
}

fn aspect_ratio_from_idc(idc: scuffle_h265::AspectRatioIdc) -> Option<(u32, u32)> {
match idc {
scuffle_h265::AspectRatioIdc::Unspecified => None,
scuffle_h265::AspectRatioIdc::Square => Some((1, 1)),
scuffle_h265::AspectRatioIdc::Aspect12_11 => Some((12, 11)),
scuffle_h265::AspectRatioIdc::Aspect10_11 => Some((10, 11)),
scuffle_h265::AspectRatioIdc::Aspect16_11 => Some((16, 11)),
scuffle_h265::AspectRatioIdc::Aspect40_33 => Some((40, 33)),
scuffle_h265::AspectRatioIdc::Aspect24_11 => Some((24, 11)),
scuffle_h265::AspectRatioIdc::Aspect20_11 => Some((20, 11)),
scuffle_h265::AspectRatioIdc::Aspect32_11 => Some((32, 11)),
scuffle_h265::AspectRatioIdc::Aspect80_33 => Some((80, 33)),
scuffle_h265::AspectRatioIdc::Aspect18_11 => Some((18, 11)),
scuffle_h265::AspectRatioIdc::Aspect15_11 => Some((15, 11)),
scuffle_h265::AspectRatioIdc::Aspect64_33 => Some((64, 33)),
scuffle_h265::AspectRatioIdc::Aspect160_99 => Some((160, 99)),
scuffle_h265::AspectRatioIdc::Aspect4_3 => Some((4, 3)),
scuffle_h265::AspectRatioIdc::Aspect3_2 => Some((3, 2)),
scuffle_h265::AspectRatioIdc::Aspect2_1 => Some((2, 1)),
scuffle_h265::AspectRatioIdc::ExtendedSar => None,
_ => None, // Reserved
}
}
Copy link
Contributor

@coderabbitai coderabbitai bot Jan 4, 2026

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

Add inline tests per coding guidelines.

Per the coding guidelines, Rust crates should have integrated tests within source files using inline test modules. This file implements complex HEVC parsing logic but lacks tests. Consider adding a #[cfg(test)] module to cover:

  • NAL type classification (HevcNalType::try_from)
  • Frame boundary detection logic (especially the first_slice_segment_in_pic_flag check)
  • Aspect ratio mapping (aspect_ratio_from_idc)
  • VUI data extraction (get_vui_data)
  • Constraint flags packing (pack_constraint_flags)

The existing test_nal_iterator_realistic_h265 in annexb.rs provides a good example pattern to follow.

Based on coding guidelines, inline tests are required for Rust source files.

🤖 Prompt for AI Agents
rs/hang/src/import/hev1.rs lines 1-396: Missing inline tests for HEVC parsing
logic as required by project guidelines; add a #[cfg(test)] mod tests with unit
tests covering HevcNalType::try_from (various nal_unit_type values), frame
boundary detection (especially first_slice_segment_in_pic_flag behavior using
constructed NAL byte sequences to assert maybe_start_frame triggers),
aspect_ratio_from_idc mapping for key AspectRatioIdc variants, get_vui_data
extraction for timing and aspect ratio (including ExtendedSar and predefined
cases), and pack_constraint_flags output for representative
scuffle_h265::Profile values; implement tests using small byte buffers and the
existing NalIterator pattern from annexb.rs, assert expected outputs with
assert_eq!/assert!(...), and ensure tests compile by importing necessary types
and constructing minimal Hev1/Frame state where needed.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good call Robo Rabbit, will add!

Copy link
Contributor

Choose a reason for hiding this comment

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

@mcintyrehh Great! If you need any help structuring the tests or want me to generate some starter test cases for the key functions I mentioned (like HevcNalType::try_from, aspect_ratio_from_idc, pack_constraint_flags, etc.), feel free to ask. The pattern from annexb.rs should work well as a template.


🧠 Learnings used
Learnt from: CR
Repo: moq-dev/moq PR: 0
File: CLAUDE.md:0-0
Timestamp: 2025-12-10T04:00:14.871Z
Learning: Applies to **/*.rs : In Rust crates, tests should be integrated within source files using inline test modules

Comment on lines +115 to +136
/// Decode as much data as possible from the given buffer.
///
/// Unlike [Self::decode_frame], this method needs the start code for the next frame.
/// This means it works for streaming media (ex. stdin) but adds a frame of latency.
///
/// TODO: This currently associates PTS with the *previous* frame, as part of `maybe_start_frame`.
pub fn decode_stream<T: Buf + AsRef<[u8]>>(
&mut self,
buf: &mut T,
pts: Option<hang::Timestamp>,
) -> anyhow::Result<()> {
let pts = self.pts(pts)?;

// Iterate over the NAL units in the buffer based on start codes.
let nals = NalIterator::new(buf);

for nal in nals {
self.decode_nal(nal?, Some(pts))?;
}

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

Address the PTS timing issue noted in the TODO comment.

The TODO comment at line 120 correctly identifies that decode_stream associates the PTS with the previous frame. This happens because maybe_start_frame is called when a boundary NAL (e.g., SPS, AUD, or first slice of next frame) is detected, but it writes the accumulated frame from the previous group of NALs. This causes Frame N to receive Frame N+1's timestamp, leading to incorrect playback timing.

Note that decode_frame (line 165) doesn't have this issue since it explicitly flushes after processing the buffer.

Do you want me to propose a fix? One approach would be to buffer the PTS and apply it when the frame is actually started (first NAL after a boundary) rather than when the boundary is detected.

🤖 Prompt for AI Agents
In rs/hang/src/import/hev1.rs around lines 115 to 136, decode_stream currently
computes pts and passes it to decode_nal immediately, which causes the PTS to be
applied to the previous frame (see TODO). Change the logic to compute pts once
at the start of decode_stream but do not apply it immediately; instead store it
as a pending_pts (add a field like self.pending_pts: Option<Timestamp>) and
change maybe_start_frame / decode_nal flow so that when a boundary NAL is seen
you do NOT tag the flushed (previous) frame with the pending_pts, but when the
first NAL of the next frame is started you consume pending_pts and attach it to
that new frame. Concretely: 1) remove passing Some(pts) into decode_nal for
boundary-detection; 2) set self.pending_pts = Some(pts) when a boundary is
detected (or at entry if buffer starts a new frame); 3) in the code path that
actually starts a frame (maybe_start_frame when opening a new accumulator /
first slice), if self.pending_pts.is_some() take() it and assign to the new
frame’s pts; 4) ensure decode_frame behavior remains unchanged and tests updated
to verify timestamps align with the frame they describe.

Filler = 38,
SeiPrefix = 39,
SeiSuffix = 40,
} // ITU H.265 V10 Table 7-1 – NAL unit type codes and NAL unit type classes
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Alternatively, we could use the scuffle NALUnitType enum.

@kixelated
Copy link
Collaborator

looks good 2 me

@kixelated kixelated enabled auto-merge (squash) January 7, 2026 02:52
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: 1

🧹 Nitpick comments (1)
rs/hang/src/import/hev1.rs (1)

282-292: Constraint flags packing is incomplete.

The pack_constraint_flags function only packs the first 4 constraint flags (bits 7-4 of byte 0), leaving the remaining 5.5 bytes as zeros. While the TODO at line 290 documents this limitation, incomplete constraint flags might cause compatibility issues with strict decoders or players that validate profile conformance.

Consider extracting and packing the additional flags from profile.additional_flags to provide complete profile information.

📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between a262cb9 and a6c8c90.

📒 Files selected for processing (3)
  • rs/hang/src/import/decoder.rs
  • rs/hang/src/import/hev1.rs
  • rs/hang/src/import/mod.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • rs/hang/src/import/mod.rs
🧰 Additional context used
📓 Path-based instructions (1)
**/*.rs

📄 CodeRabbit inference engine (CLAUDE.md)

In Rust crates, tests should be integrated within source files using inline test modules

Files:

  • rs/hang/src/import/decoder.rs
  • rs/hang/src/import/hev1.rs
🧠 Learnings (1)
📚 Learning: 2025-12-10T04:00:14.871Z
Learnt from: CR
Repo: moq-dev/moq PR: 0
File: CLAUDE.md:0-0
Timestamp: 2025-12-10T04:00:14.871Z
Learning: Applies to **/*.rs : In Rust crates, tests should be integrated within source files using inline test modules

Applied to files:

  • rs/hang/src/import/hev1.rs
🧬 Code graph analysis (2)
rs/hang/src/import/decoder.rs (1)
rs/hang/src/import/hev1.rs (3)
  • new (32-40)
  • new (309-332)
  • pts (263-270)
rs/hang/src/import/hev1.rs (3)
rs/hang/src/import/decoder.rs (5)
  • new (76-86)
  • initialize (94-106)
  • decode_stream (121-132)
  • decode_frame (144-158)
  • is_initialized (161-169)
rs/hang/src/import/avc3.rs (10)
  • new (30-38)
  • init (40-98)
  • initialize (101-113)
  • decode_stream (121-136)
  • pts (248-255)
  • decode_frame (145-168)
  • decode_nal (170-218)
  • maybe_start_frame (220-242)
  • is_initialized (244-246)
  • drop (259-264)
rs/hang/src/model/timestamp.rs (1)
  • from_micros (40-46)
🔇 Additional comments (6)
rs/hang/src/import/decoder.rs (1)

5-5: LGTM! Clean integration following established patterns.

The Hev1 decoder integration is consistent with existing decoders (Avc3, Fmp4, Aac, Opus). All necessary dispatch points are present: imports, enum variants, string parsing, Display implementation, constructor, and all decoder methods (initialize, decode_stream, decode_frame, is_initialized).

Also applies to: 15-16, 33-33, 47-47, 60-61, 80-80, 98-98, 125-125, 152-152, 165-165

rs/hang/src/import/hev1.rs (5)

1-29: Well-structured decoder implementation.

The imports and struct design follow the established Avc3 pattern. The optional track and config fields enable lazy initialization and reinitialization when SPS changes, while zero provides wall-clock timestamp fallback.


31-98: Initialization logic correctly handles SPS parsing and track management.

The constructor and init method properly extract video configuration from SPS NAL units, handle VUI parameters for timing/aspect ratio, and reinitialize tracks when configuration changes. The pattern matches Avc3 and integrates cleanly with the broadcast catalog.


100-168: Decoding methods follow the established pattern.

The initialize, decode_stream, and decode_frame methods properly handle NAL unit iteration and frame boundaries. The explicit flush in decode_frame (line 165) correctly handles the final frame in the buffer. The PTS timing issue noted in the TODO at line 120 is already documented in past reviews.


235-280: Frame management and lifecycle methods are correctly implemented.

The maybe_start_frame method properly guards against premature flushing, validates required state (track and PTS), and correctly constructs and writes frames. The pts helper provides sensible timestamp fallback using wall-clock time. The Drop implementation ensures proper catalog cleanup.


301-357: VUI data extraction and aspect ratio mapping are correctly implemented.

The VuiData::new method properly extracts framerate (using the correct formula: time_scale / num_units_in_tick) and handles both extended SAR and predefined aspect ratios. The aspect_ratio_from_idc function provides comprehensive coverage of all HEVC aspect ratio indicators.

Comment on lines +170 to +233
/// Decode a single NAL unit. Only reads the first header byte to extract nal_unit_type,
/// Ignores nuh_layer_id and nuh_temporal_id_plus1.
fn decode_nal(&mut self, nal: Bytes, pts: Option<hang::Timestamp>) -> anyhow::Result<()> {
anyhow::ensure!(nal.len() >= 2, "NAL unit is too short");
// u16 header: [forbidden_zero_bit(1) | nal_unit_type(6) | nuh_layer_id(6) | nuh_temporal_id_plus1(3)]
let header = nal.first().context("NAL unit is too short")?;

let forbidden_zero_bit = (header >> 7) & 1;
anyhow::ensure!(forbidden_zero_bit == 0, "forbidden zero bit is not zero");

// Bits 1-6: nal_unit_type
let nal_unit_type = (header >> 1) & 0b111111;
let nal_type = NALUnitType::from(nal_unit_type);

match nal_type {
NALUnitType::SpsNut => {
self.maybe_start_frame(pts)?;

// Try to reinitialize the track if the SPS has changed.
let sps = SpsNALUnit::parse(&mut &nal[..]).context("failed to parse SPS NAL unit")?;
self.init(&sps)?;
}
// TODO parse the SPS again and reinitialize the track if needed
NALUnitType::AudNut | NALUnitType::PpsNut | NALUnitType::PrefixSeiNut | NALUnitType::SuffixSeiNut => {
self.maybe_start_frame(pts)?;
}
// Keyframe containing slices
NALUnitType::IdrWRadl
| NALUnitType::IdrNLp
| NALUnitType::BlaNLp
| NALUnitType::BlaWRadl
| NALUnitType::BlaWLp
| NALUnitType::CraNut => {
self.current.contains_idr = true;
self.current.contains_slice = true;
}
// All other slice types (both N and R variants)
NALUnitType::TrailN
| NALUnitType::TrailR
| NALUnitType::TsaN
| NALUnitType::TsaR
| NALUnitType::StsaN
| NALUnitType::StsaR
| NALUnitType::RadlN
| NALUnitType::RadlR
| NALUnitType::RaslN
| NALUnitType::RaslR => {
// Check first_slice_segment_in_pic_flag (bit 7 of third byte, after 2-byte header)
if nal.get(2).context("NAL unit is too short")? & 0x80 != 0 {
self.maybe_start_frame(pts)?;
}
self.current.contains_slice = true;
}
_ => {}
}

// Rather than keeping the original size of the start code, we replace it with a 4 byte start code.
// It's just marginally easier and potentially more efficient down the line (JS player with MSE).
// NOTE: This is ref-counted and static, so it's extremely cheap to clone.
self.current.chunks.push_chunk(START_CODE.clone());
self.current.chunks.push_chunk(nal);

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

NAL parsing logic is mostly correct, but has a confusing comment.

The NAL header parsing and slice type handling are correct:

  • Extracts nal_unit_type from bits 1-6 of the first byte ✓
  • The first_slice_segment_in_pic_flag check at line 218 correctly examines bit 7 of byte 2 (after the 2-byte NAL header) ✓
  • Comprehensive coverage of HEVC slice types (IDR, BLA, CRA, and all non-keyframe slice variants) ✓

However, line 192's comment is unclear. It appears next to PpsNut handling but says "parse the SPS again," which is confusing. Consider clarifying what this TODO means or removing it if the intention is unclear.

Optional: Explicitly handle VPS NAL units

Currently, VpsNut falls through to the default case and gets appended to the frame payload (lines 229-230). While line 11 documents that VPS is ignored, consider adding an explicit case to make this behavior clearer:

 			| NALUnitType::RaslR => {
 				// Check first_slice_segment_in_pic_flag (bit 7 of third byte, after 2-byte header)
 				if nal.get(2).context("NAL unit is too short")? & 0x80 != 0 {
 					self.maybe_start_frame(pts)?;
 				}
 				self.current.contains_slice = true;
 			}
+			// VPS is ignored per struct documentation (line 11)
+			NALUnitType::VpsNut => {
+				// VPS NAL units are not parsed but are included in the stream
+			}
 			_ => {}
 		}

@kixelated kixelated merged commit dbc06d3 into moq-dev:main Jan 7, 2026
1 check passed
@moq-bot moq-bot bot mentioned this pull request Jan 7, 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