Skip to content

moq-mux: export SCTE-35 sections back to MPEG-TS#1685

Merged
kixelated merged 5 commits into
moq-dev:mainfrom
arielmol:scte35-export-rebase
Jun 12, 2026
Merged

moq-mux: export SCTE-35 sections back to MPEG-TS#1685
kixelated merged 5 commits into
moq-dev:mainfrom
arielmol:scte35-export-rebase

Conversation

@arielmol

@arielmol arielmol commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

Follow-up to #1617 (SCTE-35 ingest, merged). That PR carried SCTE-35 cues from
MPEG-TS into a MoQ catalog extension; this one mirrors it on the way out, writing
the cue tracks back to MPEG-TS. Together they form a byte-exact MoQ <-> MPEG-TS
round-trip for splice information.

Export

A .scte35 cue track is remuxed back to the transport stream as private sections:

  • The PMT gains the program-level CUEI registration descriptor and an elementary
    stream at stream_type 0x86, so a re-import re-detects and reassembles the cue
    (the exact signal moq-mux: ingest SCTE-35 from MPEG-TS, and tolerate mid-stream joins #1617 keys off).
  • Each section rides as a Section payload (pointer_field, unit-start bit) plus
    Raw continuations, emitted opaquely: no CRC, no reframing. The
    splice_info_section round-trips byte-for-byte.
  • A frame that isn't a complete section (table_id 0xFC, length matching
    section_length) is dropped with a warning rather than emitted malformed. This
    is transport policy, not payload interpretation; the verbatim bytes are never
    touched.

The bridge stays media-agnostic at the MoQ layer: cues are carried as opaque
frames and never parsed semantically.

A cue program requires a video track

The bridge clocks each cue from the video PTS at ingest; a splice_info_section
carries no PTS of its own. With no video track the export has no clock the cues
belong to (an audio-only ingest stamps them at zero), so a cue program without
video is rejected. Programs without cues keep the existing video-or-audio PCR
fallback.

This is a scope choice for broadcast plus the current clock model, not a SCTE-35
conformance claim: SCTE-35 is program-level, component splicing can target audio,
and audio dynamic ad insertion uses SCTE-35 without video. This rule can be
revisited if the bridge grows a clock source independent of video PTS.

API

Export<E: scte35::Catalog = ()>. The plain constructors (new,
with_catalog_format) live on impl Export<()> and stay media-only; moq-cli
does not reference the SCTE types, so the default Export<()> path is unchanged.
A concrete impl Export<scte35::Ext> adds Export::with_scte35, which adds the
cue path; pinning the extension on the impl means callers write
Export::with_scte35(..) with no turbofish (same way new pins Export<()>).
The fmp4/mkv exporters keep their media-only view via .media().

Tests

A round-trip corpus runs the full TS -> MoQ -> TS seam and asserts every
splice_info_section survives byte-for-byte, pinning each fixture's total and
distinct cue counts so a clip that lost variety to duplicate sections fails:

  • ffmpeg, GStreamer, and BigBuckBunny clips with injected SCTE-35 cues, each
    covering a muxer/codec/audio axis the others don't (H.264 video-only; H.264
    480i + AAC; H.265 + Opus).
  • TSDuck-authored splices: the only section-repetition coverage (five distinct
    cues sent twice).
  • The real Ateme CM5000 ("Kyrion") mid-stream capture from moq-mux: ingest SCTE-35 from MPEG-TS, and tolerate mid-stream joins #1617, reused as a
    real-encoder case.

TSDuck decodes every checked-in section CRC32-OK; the decode is checked in beside
each clip as <fixture>_tsduck.txt. examples/scte35_inject.rs generates the
three injected fixtures.

The three commits are bisectable (export, force-video, corpus), each green alone.

Test plan

  • cargo test -p moq-mux green (203 tests; 201/202/203 per commit)
  • clippy + fmt clean on the rebased branch
  • round-trip byte-exact across the corpus, including the real Kyrion capture
  • moq-cli does not reference the renamed SCTE types (default Export<()> path unchanged)

No JS or doc sync: carriage only, no wire or catalog-format change.

arielmol added 3 commits June 11, 2026 17:40
Mirror the SCTE-35 import: the catalog extension's scte35 cue tracks are
written back to the transport stream as private sections. The PMT gains the
program-level CUEI registration descriptor and an elementary stream at
stream_type 0x86, so a re-import re-detects and reassembles the cue.

Sections ride as Section (pointer_field) + Raw continuations, which the writer
emits opaquely (no CRC, no reframing), so the splice_info_section round-trips
byte-for-byte. A frame that isn't a complete section (table_id 0xFC, length
matching section_length) is dropped with a warning rather than emitted
malformed. SCTE-35 has no PTS and cannot carry the PCR, so a video or audio
track is required; SCTE-35-only broadcasts are rejected.

The exporter mirrors the importer's narrow generics, consumer side:
Export<E: scte35::Catalog = ()> keeps the existing constructors on the ()
default (in-tree callers compile unchanged) and adds
Export::<scte35::Ext>::with_extension for the extension path. CatalogSource<E>
yields the full Catalog<E>; the MSF arm fills an empty extension (MSF is
media-only by design), and the fmp4/mkv exporters keep their media-only view
via .media().

A round-trip test (import bbb.ts plus a .scte35 track, export, re-import)
asserts the cue survives byte-for-byte; a unit test covers section validation.
SCTE-35 annotates a broadcast program, and this bridge clocks each cue from
the video PTS at ingest; a splice_info_section carries no PTS of its own. With
no video track, the export has no clock the cues belong to (an audio-only
ingest stamps them at zero), so a cue program without video is rejected.
Programs without cues keep the existing video-or-audio PCR fallback.

scte35_without_video_export_is_rejected pins the rule.

CONTEXT

Rejected alternatives:
- Keep the audio PCR fallback for cue programs. The splice times inside the
  verbatim bytes survive either way, but the cue's delivery timing drives
  downstream placement, so a silently misplaced cue is worse than a loud
  rejection.
- Justify the rule as a SCTE-35 conformance requirement. SCTE-35 does not
  mandate video: the splice_info_section is program-level, splice_insert
  component splicing (program_splice_flag = 0, per component_tag) can target
  audio components, audio dynamic ad insertion uses SCTE-35 without video, and
  the PCR may sit on any PID. Requiring video is a scope choice for broadcast,
  where the spliced program is video, plus the current clock model; it is not a
  conformance claim, and it reopens when the clock samples audio PIDs.

Key decisions:
- The cue's frame timestamp is its delivery time, the moment it arrives on the
  program clock, never the in-band splice_time. That splice_time is the splice
  instruction and travels verbatim in the payload; conflating the two misplaces
  the cue. This bridge samples the program clock from the video PTS only, so a
  program with no video collapses the delivery time to zero, the concrete
  failure the rule prevents.
Add a fixture corpus that runs the full TS -> MoQ -> TS seam and asserts every
splice_info_section survives byte-for-byte. Four synthetic fixtures each cover an
axis no other does: ffmpeg (baseline H.264, no audio), GStreamer (a second
muxer, 480i, AAC), BigBuckBunny (H.265 + Opus, real frames), and TSDuck-authored
splices (section repetition). The existing Kyrion mid-stream capture is reused
as a fifth, real-encoder round-trip case.

Each fixture pins its total cue count and its distinct cue count, so a clip that
lost variety to duplicate sections fails. Every cue's splice_command_type must
be a known SCTE-35 command; only the TSDuck-authored fixture, whose cues we
control, pins its exact command-type set. TSDuck decodes every checked-in
section CRC32 OK, and the decode is checked in beside each clip as
<fixture>_tsduck.txt. examples/scte35_inject.rs generates the three injected
fixtures by augmenting the PMT with CUEI + 0x86 and placing cues at spread
positions.

CONTEXT

Rejected alternatives:
- Assert only byte-exact survival, or pin every external fixture's exact
  command-type set. Pure survival would miss variety collapsing into duplicates,
  while pinning external threefive cue types would over-claim fixtures we do not
  author. The test pins (total, distinct) for every fixture and the exact
  command-type set only for the TSDuck-authored one.
- Add regen or CI freshness machinery for the fixtures (R38b). The clips and
  their checked-in TSDuck decodes are static evidence; the regen command lives
  in the test doc comment, and more machinery would be maintenance without a
  maintainer.
- Use threefive's TS-stream parser as the authority. It chokes on the Opus clip,
  so TSDuck's section decode is the authority; threefive's section-level decoder
  was used only to cross-check the hand-built custom cues before baking.

Key decisions:
- TSDuck sends five distinct cues twice, ten on the wire. This is the only
  coverage of section repetition; the reassembler keeps advancing-CC repeats and
  drops byte-identical same-CC transport duplicates.
- Kyrion is not part of the authored injected corpus. It is reused as a separate
  real-encoder fixture, captured mid-stream, cues external and unpinned, to prove
  the round-trip on a real live feed.
@coderabbitai

coderabbitai Bot commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 50fe9d95-ea4e-4650-97fa-a84aeeb9b8dd

📥 Commits

Reviewing files that changed from the base of the PR and between 5594f10 and f6acc9d.

📒 Files selected for processing (3)
  • CLAUDE.md
  • rs/moq-mux/src/container/ts/export.rs
  • rs/moq-mux/src/container/ts/export_test.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • rs/moq-mux/src/container/ts/export_test.rs

Walkthrough

This PR adds SCTE-35 splice cue support to the MOQ muxer's MPEG-TS output. The catalog infrastructure becomes generic over extension types (e.g., SCTE-35), enabling specialized track handling. The TS exporter gains SCTE-35 as a new track kind, allocates dedicated PIDs, injects CUEI program-level descriptors in the PMT, and packetizes cue payloads as private sections instead of PES. Container adapters update to use media-only catalog snapshots. Tests validate round-trip export/import, PMT structure validation, and fixture byte-for-byte survival. An example utility demonstrates creating SCTE-35 TS fixtures by augmenting input streams.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title directly and clearly summarizes the main change: exporting SCTE-35 sections back to MPEG-TS format.
Description check ✅ Passed The description is comprehensive and directly related to the changeset, covering export implementation, API design, clocking requirements, and test coverage for SCTE-35 export to MPEG-TS.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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

✨ Finishing Touches
✨ Simplify code
  • Create PR with simplified code

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
rs/moq-mux/src/container/ts/export.rs (1)

214-219: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Don't surface a chunk for SCTE-35 frames you explicitly drop.

When write_section() decides a cue is malformed, it returns Ok(()), but write_frame() still hands poll_next() a chunk for that frame. On Line 472 that becomes an empty Bytes when PSI is not due, and a PAT/PMT-only chunk when PSI is due. That is not a real “drop”, and zero-length outputs can make callers spin.

💡 One way to keep the drop path truly silent
-			let chunk = self.write_frame(&name, frame)?;
-			return Poll::Ready(Ok(Some(chunk)));
+			if let Some(chunk) = self.write_frame(&name, frame)? {
+				return Poll::Ready(Ok(Some(chunk)));
+			}
+			continue;
 		}
@@
-	fn write_frame(&mut self, name: &str, frame: Frame) -> anyhow::Result<Bytes> {
+	fn write_frame(&mut self, name: &str, frame: Frame) -> anyhow::Result<Option<Bytes>> {
@@
-		match es_payload {
+		match es_payload {
 			// SCTE-35 rides in private sections, not PES; carry the bytes verbatim.
-			None => self.write_section(&mut out, pid, &frame.payload)?,
+			None => {
+				if !self.write_section(&mut out, pid, &frame.payload)? {
+					return Ok(None);
+				}
+			}
 			Some(es_payload) => {
@@
-		Ok(Bytes::from(out))
+		Ok(Some(Bytes::from(out)))
 	}
@@
-	fn write_section(&mut self, out: &mut Vec<u8>, pid: u16, section: &[u8]) -> anyhow::Result<()> {
+	fn write_section(&mut self, out: &mut Vec<u8>, pid: u16, section: &[u8]) -> anyhow::Result<bool> {
@@
 		if !is_complete_scte35_section(section) {
 			tracing::warn!(pid, len = section.len(), "dropping malformed SCTE-35 section on export");
-			return Ok(());
+			return Ok(false);
 		}
@@
-		Ok(())
+		Ok(true)
 	}

Also applies to: 470-484, 564-571

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rs/moq-mux/src/container/ts/export.rs` around lines 214 - 219, poll_next is
emitting a chunk even for SCTE-35 frames that write_section treats as malformed
(write_section returns Ok(())), leading to empty Bytes or PAT/PMT-only outputs;
modify write_frame (called from poll_next/pick_next_track) so it returns a
Result<Option<Chunk>, Error> (or otherwise signals "dropped") and ensure
poll_next only returns a Poll::Ready(Ok(Some(chunk))) when write_frame indicates
a real chunk; specifically update write_frame to check the result of
write_section and return None when write_section intentionally drops the cue,
and update the call sites (in poll_next where pick_next_track and
tracks.get_mut(...).pending are used) to skip emitting any chunk if write_frame
signals None.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@rs/moq-mux/examples/scte35_inject.rs`:
- Around line 69-72: The code uses direct indexing (args[1], args[2]) which
panics with "index out of bounds" on missing args; change to consistent safe
parsing using args.get(1).expect(...), args.get(2).expect(...), and keep the
existing args.get(3).expect(...) usage so all three arguments produce the same
helpful usage message. Update the variables input, out_path, and set assignments
to fetch via .get(...) and then call std::fs::read on the unwrapped input
string, ensuring the error messages mention the expected usage.

---

Outside diff comments:
In `@rs/moq-mux/src/container/ts/export.rs`:
- Around line 214-219: poll_next is emitting a chunk even for SCTE-35 frames
that write_section treats as malformed (write_section returns Ok(())), leading
to empty Bytes or PAT/PMT-only outputs; modify write_frame (called from
poll_next/pick_next_track) so it returns a Result<Option<Chunk>, Error> (or
otherwise signals "dropped") and ensure poll_next only returns a
Poll::Ready(Ok(Some(chunk))) when write_frame indicates a real chunk;
specifically update write_frame to check the result of write_section and return
None when write_section intentionally drops the cue, and update the call sites
(in poll_next where pick_next_track and tracks.get_mut(...).pending are used) to
skip emitting any chunk if write_frame signals None.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 135f485f-0ed5-45c5-a838-7045c9754b16

📥 Commits

Reviewing files that changed from the base of the PR and between c3c5754 and 5594f10.

📒 Files selected for processing (14)
  • rs/moq-mux/examples/scte35_inject.rs
  • rs/moq-mux/src/container/fmp4/export.rs
  • rs/moq-mux/src/container/mkv/export.rs
  • rs/moq-mux/src/container/source.rs
  • rs/moq-mux/src/container/ts/export.rs
  • rs/moq-mux/src/container/ts/export_test.rs
  • rs/moq-mux/src/container/ts/test_data/scte35/bbb5s.ts
  • rs/moq-mux/src/container/ts/test_data/scte35/bbb5s_tsduck.txt
  • rs/moq-mux/src/container/ts/test_data/scte35/ffmpeg.ts
  • rs/moq-mux/src/container/ts/test_data/scte35/ffmpeg_tsduck.txt
  • rs/moq-mux/src/container/ts/test_data/scte35/gst480i.ts
  • rs/moq-mux/src/container/ts/test_data/scte35/gst480i_tsduck.txt
  • rs/moq-mux/src/container/ts/test_data/scte35/tsduck.ts
  • rs/moq-mux/src/container/ts/test_data/scte35/tsduck_tsduck.txt

Comment on lines +69 to +72
let args: Vec<String> = std::env::args().collect();
let input = std::fs::read(&args[1]).expect("read input TS");
let out_path = &args[2];
let set = args.get(3).expect("usage: scte35_inject input.ts output.ts <set>");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Inconsistent argument parsing produces unhelpful error messages.

Lines 70-71 use unsafe indexing (args[1], args[2]) while line 72 uses safe .get(3). If the example is invoked with missing arguments, the user sees "index out of bounds" instead of the helpful usage message from line 72.

🛠️ Proposed fix for consistent error handling
 fn main() {
 	let args: Vec<String> = std::env::args().collect();
-	let input = std::fs::read(&args[1]).expect("read input TS");
-	let out_path = &args[2];
-	let set = args.get(3).expect("usage: scte35_inject input.ts output.ts <set>");
+	if args.len() < 4 {
+		eprintln!("usage: scte35_inject input.ts output.ts <set>");
+		std::process::exit(1);
+	}
+	let input = std::fs::read(&args[1]).expect("read input TS");
+	let out_path = &args[2];
+	let set = &args[3];
 	let cues_b64 = &SETS
 		.iter()
-		.find(|(name, _)| name == set)
+		.find(|(name, _)| name == &set)
 		.unwrap_or_else(|| panic!("unknown set '{set}'; available: gst480i, bbb5s, ffmpeg"))
 		.1;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rs/moq-mux/examples/scte35_inject.rs` around lines 69 - 72, The code uses
direct indexing (args[1], args[2]) which panics with "index out of bounds" on
missing args; change to consistent safe parsing using args.get(1).expect(...),
args.get(2).expect(...), and keep the existing args.get(3).expect(...) usage so
all three arguments produce the same helpful usage message. Update the variables
input, out_path, and set assignments to fetch via .get(...) and then call
std::fs::read on the unwrapped input string, ensuring the error messages mention
the expected usage.

@arielmol

arielmol commented Jun 12, 2026

Copy link
Copy Markdown
Contributor Author

ok rabbit, last comments find a minor bug accepted, it lives in the examples not in production code, but will fix alonside @kixelated reviews/comments.

kixelated and others added 2 commits June 11, 2026 19:25
Replace the generic `with_extension` constructor with a concrete one on
`impl Export<scte35::Ext>`. Because the impl pins `E`, callers write
`Export::with_scte35(..)` with no `::<scte35::Ext>` turbofish, mirroring how
`Export::new`/`with_catalog_format` pin `Export<()>`. Both delegate to a private
generic `build`. The `scte35::Catalog` bound only ever resolves to `()` or
`scte35::Ext`, so a single shared generic constructor bought no real generality.

Also fold two `match res { Some => .., None => break }` test loops into
`let ... else { break }`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a note under PR Reviews to enumerate public API changes (and flag breaking
ones per Branch Targeting), distinguishing public surface from pub(crate).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@kixelated
kixelated merged commit f3a73ed into moq-dev:main Jun 12, 2026
1 of 2 checks passed
@moq-bot moq-bot Bot mentioned this pull request Jun 12, 2026
@moq-bot moq-bot Bot mentioned this pull request Jun 16, 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