Skip to content

feat: add mp2ts-loop for seamless looping of constant-rate transport streams - #33

Merged
tobbee merged 20 commits into
mainfrom
ts-loop
Oct 7, 2026
Merged

tobbee merged 20 commits into
mainfrom
ts-loop

Conversation

@tobbee

@tobbee tobbee commented Oct 6, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds mp2ts-loop, a new tool that loops a single-program, constant-rate MPEG-2 transport stream seamlessly and forever, to a file and/or UDP. It also moves the module to Go 1.24 and mp4ff v0.58.0, and takes -version from Go's build information.

mp2ts-loop

SCAN → SELECT → PREPARE (in memory) → LOOP/SEND. -scan prints the analysis and the chosen loop as JSON, and -d caps the loop duration.

  • Loop points.
    • IDR: used when the stream has them.
    • HEVC CRA: rewritten to BLA at the seam, so the leading RASL pictures are discarded instead of corrupting the first GOP.
    • MPEG-2: an I picture after a sequence header, closed GOP preferred. An open-GOP loop point gets broken_link.
    • Selection: a loop-point class with fewer than two points falls through to the next. The longest loop is preferred that is a whole number of nominal frame periods (an even frame count at 59.94 fps, a multiple of four at 23.976). A loop never spans a signalled discontinuity.
  • Video: H.264, H.265, MPEG-2. Audio: AAC, MP2, AC-3.
    • Shift: audio is shifted per wrap by less than one audio frame, with no accumulated drift.
    • Splitting: frame-aligned multi-frame PES are split to one frame per PES (with data_alignment_indicator), so the loop can cut on any frame.
  • Transport timing.
    • Slots: the segment keeps one slot per source packet, so every packet keeps its source timing.
    • Audio: audio multiplexed outside the loop's packets (it usually lags the video) is moved across the loop boundary to where it sits relative to the video.
    • PCR: regenerated linearly, exact across wraps. It can be on the video PID or its own PID, following the PMT's PCR_PID.
    • Seam: packets just before the seam move by the buffer-level difference between the two loop points.
    • UDP: paced at exactly the PCR rate, on the monotonic clock.
  • SCTE-35.
    • Kept: a cue only when its whole break (CUE-OUT to CUE-IN, or CUE-OUT + duration) is inside the loop. A cue sent before the loop for such a break is moved to the loop start.
    • Each wrap: pts_adjustment advances by the loop duration and every event ID by the span of IDs used in the loop. The CRC is recomputed.
    • Dropped: everything else; the scan says why.
  • Other PIDs such as SMPTE-2038 ANC are passed through and timestamp-shifted per wrap.
  • Refused, with the reason:
    • variable-rate streams, detected from the PCR, with signalled discontinuities respected;
    • audio or video PES without a PTS;
    • streams without two loop points, e.g. GDR / intra-refresh.

Design notes and the remaining plan are in cmd/mp2ts-loop/PLAN.md.

Other changes

  • Toolchain: requires Go 1.24 and mp4ff v0.58.0 (no code changes needed). CI tests Go 1.24 and 1.27, and the lint job sets up Go itself. The old Go 1.19 job would have failed on log/slog.
  • Version: -version reports the version Go embeds from the git tag and commit (internal.Version(), debug.ReadBuildInfo).
    • internal/version.go and the Makefile -ldflags are removed.
    • The Makefile now builds packages, via a .PHONY pattern rule.
    • make build now includes mp2ts-pidfilter.
    • The shared -version handler names the right tool, not "ts-info".
  • mp2ts-info: lists MP2, AC-3, and MPEG-2 video PIDs.

Testing

  • Unit and golden tests:
    • Scan reports: golden reports for 7 fixtures. New fixtures are mpeg2_open_mp2.ts and avc_aac_cbr.ts (constant-rate); the older AVC/HEVC fixtures are variable-rate and now refused.
    • Video and seams: MPEG-2 parsing, CRA→BLA and broken_link.
    • SCTE-35: pairing rules, section reassembly, per-wrap rewrite, and an end-to-end loop with injected cues.
    • Audio and timing: audio slot placement, drift continuity with moved audio, loop selection (whole frame periods, discontinuities), PCR analysis across discontinuities, a separate PCR PID, and the UDP pacing schedule.
    • Toolchains: all pass on Go 1.24 and 1.27; golangci-lint is clean.
  • Real streams (broadcast captures and ffmpeg encodes, 3 to 200 wraps):
    • Decode: ffmpeg decodes with no errors, and there are 0 continuity-counter errors.
    • PCR: within about 0.5 tick of a straight line over all wraps.
    • Video: exact PTS grids at 25, 29.97, 50, 59.94, and 23.976 fps.
    • Audio: the per-wrap shift stays under one audio frame in every wrap. Each audio PES arrives at the same time as in the source (median 0 ms change), except up to about 110 ms just before the seam, where those loops' buffer mismatch has to be absorbed.
    • UDP: the measured send rate against the stream's own PCR is −0.4 ppm (before the fix: +13.6 ppm).

Known limitations / follow-ups

  • Variable-rate streams are refused; supporting them would mean keeping each packet's source PCR timing.
  • Interlace: frame-coded interlace works (MPEG-2, AVC MBAFF). AVC PAFF, MPEG-2 field pictures, and HEVC field coding are planned in PLAN.md; HEVC is waiting for example files.
  • GDR / intra-refresh streams cannot be looped.
  • Non-PES-aligned audio (frames straddling PES) is copied whole, so the cut is PES-granular there.
  • PCR on an audio PID is not handled.
  • Open-GOP MPEG-2: the B-pictures leading each seam show a brief transient (broken_link is set).
  • Bounded runs (-m): the first and last wraps lack the moved audio that would come from the neighbouring wrap.
  • Buffer matching: loop points are not yet chosen for matching buffer levels, which would shrink the shift just before the seam.

🤖 Generated with Claude Code

tobbee and others added 20 commits October 5, 2026 13:47
Scans a single-program TS for the longest loop bounded by an IDR (or CRA when no IDR) video random-access point, keeping constant frame rate and GOP duration. Audio is matched to the loop duration without accumulated drift, null stuffing and PCR are regenerated (linear PCR, CBR) so the loop boundary is seamless even though audio/video/PCR do not end at the same byte position.

Outputs to a file (-m wraps) and/or UDP (-a, 7 TS packets per 1316-byte datagram, constant-rate drift-free pacing, unicast or multicast); also -scan (JSON analysis) and -write-prepared (single loop segment). UDP write errors are non-fatal.

The engine lives in internal/ on gots/v2: PES reassembly (elstream/pesdata), continuity-counter regeneration across the seam (ccounter), HEVC CRA/RAP detection (video_rap), PCR/jitter math (pcr/const), packet-walk reader with PCR/bitrate capture (tsstream), and the scan/select/prepare/send passes (loop_*). Verified seamless on an HEVC10/AAC capture (ffmpeg clean decode, CC continuous, CBR PCR across seams, no audio drift).

Supports AVC/HEVC video and PES-aligned AAC. Non-PES-aligned/AC-3 audio and CRA open-GOP handling to follow.
Add an AudioFramer interface with an AAC/ADTS implementation that walks ADTS frames within a PES, computing exact sample rate, per-frame 90kHz duration and timestamps, and carrying a frame that straddles a PES boundary (Offset/Missing) for non-PES-aligned audio. The scan now reports sampleRate, frame count, frame duration and a nonPESAligned flag, and the loop selection uses the exact frame duration for the audio fit/residual.

Also detect HEVC RASL leading pictures (IsRASL) as groundwork for CRA open-GOP loop seams. Unit-tested with synthetic ADTS frames (single/multi/spill/align-carry) and RASL NAL types; verified on the HEVC10/AAC capture (48kHz, 1920-tick frames, PES-aligned).
Add an MPEG-1/2 (Layer 2 = MP2) audio framer behind the AudioFramer interface, selected via a codec factory alongside AAC; the scan now parses MP2 frame boundaries, sample rate and duration.

Implement a per-wrap audio deltaPTS offset so audio stays drift-free when the loop duration is not a whole number of audio frames (the MP2 / fractional-fps case). Each wrap carries the sub-frame overshoot of the boundary-crossing frame (bounded in [0, frameDur)), the audio is emitted shifted by that delta so the frame cadence is continuous across the seam, and the boundary frame is dropped and null-stuffed to preserve CBR/PCR. Prepare now includes a couple of spare audio frames past the loop end so the controller always has the boundary frame available.

Fractional frame rates (29.97/59.94) loop correctly because the loop is defined by the actual IDR PTS values and every timestamp is offset by that exact distance; constant-rate detection tolerates the +/-1-tick dither of 59.94.

Unit-tested: drift controller continuity and no-accumulation over 200 wraps across five residual cases; MP2 framing (single/multi/spill). The existing HEVC/AAC capture still loops seamlessly (CC 0 errors, CBR PCR, clean ffmpeg decode).
mp2ts-info defaults to the SCTE-35 path, which classifies PMT streams with the gots-based ParseElementaryStreamInfo; neither it nor the astits-based ParseAstitsElementaryStreamInfo recognized MPEG-1/2 audio, so MP2 audio PIDs were silently omitted. Add stream types 0x03 (ISO/IEC 11172-3) and 0x04 (ISO/IEC 13818-3) as codec "MP2" to both classifiers, and map 0x04 in the loop engine's codecFromStreamType. Verified on an HEVC/MP2 capture (now lists the MP2 audio pid); AAC/HEVC output unchanged.
Add an AC3Framer (AudioFramer impl): sync word 0x0B77, frame size from the A/52 frame-size table (frameSizesInWords[3*frmsizecod+fscod]*2), 1536 samples/frame (2880 ticks @48khz); E-AC-3 (bsid 16) deferred. Wired via the newAudioFramer factory (CODEC_AC3, already classified by InitTS through stream type 0x81 or the AC-3 descriptor).

Fix the loop preparer to keep the drift-controller's spare audio in PES units (audioSparePES) rather than frame units: AC-3 commonly packs multiple frames per PES (3 here), and a frame-unit spare was smaller than one PES, so the boundary-crossing PES was missing from the segment and the per-wrap deltaPTS never engaged, leaving a residual-sized gap at each seam. With PES-unit spares the audio is frame-continuous across seams again.

Classify AC-3 (0x81) and E-AC-3 (0x87) in both mp2ts-info stream-info classifiers (gots and astits paths).

Unit-tested: AC-3 framing (single/multi/spill) and drift continuity including a 3-frame-per-PES case. Validated end-to-end on an HRD-compliant libx264 + AC-3 capture (nal-hrd=cbr, CBR muxrate, ~30ms PCR): file and UDP loops are seamless (CC 0 errors, continuous CBR PCR, constant video DTS, AC-3 PES PTS continuous across seams with no gap and no drift, ffmpeg decodes clean).
…oops

Re-packetize multi-frame audio PES (e.g. AC-3, which carries 3 frames per PES here) into one PES per frame during loop preparation, so the loop boundary can be cut on a frame boundary rather than a whole PES. Each per-frame PES is inserted at the original PES's start position (audio thus arrives slightly earlier, which is buffer-safe); single-frame or non-aligned PES are copied unchanged, preserving the AAC/MP2 paths and any frame that straddles a PES boundary.

The drift controller's keep window and spare cutoff are now anchored to audioStart+loopDur (matching the emitter) with spares counted in frames, so the boundary-crossing frame is present and the cut is drift-free with no overlap.

Adds pespack.go (buildAudioPES + packetizePES) with a round-trip test. On a real HRD-compliant AVC+AC-3 capture the AC-3 audio is now frame-continuous across every seam (PTS step all 2880), the per-loop A/V offset drops from ~one PES (~96ms) to sub-frame, and ffmpeg decodes the loop cleanly; AAC/MP2/HEVC loops are unchanged.
A CRA used as a loop seam appears mid-stream, where NoRaslOutputFlag is 0,
so a decoder tries to decode the picture's leading RASL units and looks for
their pre-CRA references. After the splice those references are the previous
wrap's tail (unrelated POCs), corrupting the entire first GOP at every seam.

Rewrite the loop-point picture's CRA slice NAL units to BLA_W_LP in the
prepared segment. BLA forces NoRaslOutputFlag=1: the decoder discards the RASL
and resets POC, making the seam clean. Baking it into the segment is also
correct for wrap 0, where a BLA at stream start behaves exactly like the CRA.

Validated on real open-GOP HEVC captures (AVC/HEVC, 50p and 59.94p): ffmpeg
decode goes from 600+ ref/RPS errors to 0 over 3 and 8 wraps, with CC and PCR
continuity unchanged.
…lden scan tests

InitTS now records non-audio/video elementary streams it copies through the loop
(the per-wrap PTS shift already applies to them), labelling SMPTE-2038 ANC via the
ancillary-data descriptor (0xc4). mp2ts-loop -scan reports them in a new passThrough
section so the operator can see what is preserved.

Adds golden scan-report tests over the bundled fixtures: AVC IDR (bbb_1s), HEVC IDR
(obs_hevc_aac), a longer AVC stream (80s_with_ad), and a no-loop stream (avc_with_time).
Loop MPEG-2 (H.262) video. A loop point is an I picture preceded by a
sequence header, parsed from start codes. Its GOP maps onto the existing
loop-point classes: closed (closed_gop=1, or no B-picture following the I
in coding order) is IDR-class, labelled I-closed; open is CRA-class,
labelled I-open.

At an open-GOP seam the leading B-pictures predict from the previous
wrap's tail. They decode without errors but show wrong content, so the
loop-point GOP gets broken_link=1 (the 13818-2 edit marker, like CRA->BLA
for HEVC) and the plan warns. The seam rewrite is now chosen by codec and
is a no-op for closed GOPs.

Loop-point selection now falls through to the next class when a class has
fewer than two points. ffmpeg's mpeg2video (and x265 open-gop) closes only
the first GOP, which previously left a single IDR-class point and no loop.

mp2ts-info now lists MPEG-2 video PIDs.

Validated on ffmpeg encodes looped 3 wraps: 576p25 and 1080i25 open GOP
with MP2, 720p29.97 closed GOP with AC-3, and 720p50 without B-frames with
AAC. All have 0 CC errors, an exact video PTS grid across seams, PCR within
0.5 tick of linear, and clean ffmpeg decodes. The HEVC CRA captures and the
AVC/HEVC IDR streams still loop cleanly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add an interlace section to the mp2ts-loop plan. It covers shared
field/frame infrastructure (PES without PTS, parity and pairing, folding
fields into frames, first-field loop points with an even field count),
the MPEG-2 remainder, AVC MBAFF vs PAFF (plus open-GOP recovery-point
loops with broken_link_flag), HEVC field coding (pic_struct parity, CRA
field pairs needing both fields rewritten to BLA), and pulldown.

Records what already loops cleanly today: frame-coded interlace (MPEG-2
+ilme+ildct, AVC MBAFF), and x265 field-coded HEVC, which works only
because every IDR is a top field at an even period.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The loop engine selects, trims, and shifts every PES by its PTS, but a PES
without one read as PTS 0, which would silently corrupt the loop window,
DTS steps, and the MPEG-2 leading-B check. PESData now records HasPTS. The
scan counts such PES per stream (pesWithoutPTS in the -scan report) and
skips them. Scan reports "cannot loop" in the note with no loop plan, and
PrepareLoop returns an error.

Also update the plan: every PES must carry a PTS, and HEVC field coding is
on hold until example files arrive, since ffmpeg's HEVC interlace support
cannot be relied on (FFmpeg PR #23616).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A perfect loop must be a whole number of nominal frame periods. At 59.94
fps a frame is 1501.5 ticks, so a loop with an odd frame count (e.g. 11
GOPs of 15 frames) has no integer duration. It was rounded to the next
tick, half a tick off nominal, and the source's strict 1501/1502 DTS
dither broke at every seam (200 repeats in 200 wraps). 23.976 (3753.75
ticks) needs a multiple of four frames.

selectLoop now considers every pair of eligible loop points within the
duration cap and picks the longest whose duration times the rate equals a
whole frame count (rate from -fps or the measured DTS step). Ties go to the
earliest start. An inexact loop is used only if no exact one exists, with
a warning giving the per-wrap offset. LoopPlan reports the frame count
(frames). The frame-rate table is shared between the label and the exact
rational rate.

Validated 29.97/59.94/23.976 AVC with AAC+MP2+AC-3, -d 3000 x 200 wraps
and full length x 30 wraps: the per-wrap audio shift stays in [0, frame)
for every wrap and audio cadence is continuous across seams. Video PTS
and DTS keep the source's exact rational-grid dither across all seams,
except a 1-tick DTS quirk x264 puts in its first GOP, which a loop
starting at the encoder start replays. The 59.94 HEVC captures still pick
the same, already exact, loops.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
udpSink scheduled packet idx at start + idx*stepNs, with stepNs the
per-packet interval rounded to a whole nanosecond. The rounding error
(< 0.5 ns per packet) accumulated linearly, so the send rate differed from
the rate the PCR is stamped at: e.g. +5.6 ppm (20 ms/hour) for a 22 Mbps
loop and -2.1 ppm for 27 Mbps. A receiver with a fixed output clock slowly
over- or underflows, and the stream drifts against real time. The start time
also came from the wall clock (UnixNano), so an NTP step caused a burst or
a stall, and NTP slewing changed the rate.

Each send time is now computed exactly from the packet index:
idx*loopDurTicks*1e9 / (90000*numPackets), formed in 128 bits
(paceOffsetNs), so nothing accumulates. It is measured as time.Since on
the monotonic clock.

Measured over 90 s of loopback UDP from a 22 Mbps loop, fitting arrival
time against the stream's PCR: old +13.6 ppm (the 5.6 ppm rounding plus this
Mac's wall clock running 7.7 ppm slow vs monotonic), new -0.4 ppm (twice),
zero within the receiver's timing noise.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Move to mp4ff v0.58.0, which needs Go 1.23. Go 1.24 is the floor because
the next commit takes the version from the build information Go 1.24
embeds from the git tag. It also covers log/slog (Go 1.21), which the
mp2ts-loop engine already uses, so the CI job on Go 1.19 would have failed.

The CI matrix tests the new minimum and the latest release (1.24, 1.27).
The lint job now sets up Go itself, rather than relying on the runner's
preinstalled version.

mp4ff v0.58.0 needed no code changes. All tests pass unchanged, including
the mp2ts-nallister golden output.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Since Go 1.24, building a package in a git checkout embeds the version
from the tag: v0.3.0 at the tag, a pseudo-version such as
v0.3.1-0.20261005135250-04dccf46c785 after it, and +dirty for uncommitted
changes, together with the commit time. internal.Version reads that with
debug.ReadBuildInfo, as hi265 does. A release no longer means editing
internal/version.go, and a build between releases names its commit instead
of the last release. internal/version.go and the Makefile -ldflags are gone.

The Makefile built ./cmd/<name>/main.go as files, which embeds no version
(it gives "(devel)"). It now builds ./cmd/<name> as packages, through
one .PHONY static pattern rule for out/<name>, so a binary built before a
commit or tag is never kept naming the old one. make <name> still works,
make build now also builds mp2ts-pidfilter, and make install runs go
install, which embeds the version too.

-version from the shared ParseParams always said "ts-info version". It
now names the running tool.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…reaks

SCTE-35 sections were copied into the loop segment and replayed byte for
byte every wrap. From the second wrap on, every cue pointed at a splice time
in the first wrap and reused its event IDs.

The scan now reassembles SCTE-35 sections and pairs the cues. A
splice_insert CUE-OUT is closed by a CUE-IN with its event ID (else the
latest open CUE-OUT without a duration), or by its own break duration. A
time_signal segmentation start is closed by an end that gots' CanClose
accepts, or by its duration. Repeats of a cue count as one event. A cue is
kept only if its whole interval, from CUE-OUT to CUE-IN or CUE-OUT plus
duration, is inside the loop. Anything that loses its partner is dropped:
unclosed CUE-OUTs, CUE-INs and cancels without a CUE-OUT, and cues cut by
the loop edge. A section is kept only if all its events are, repeated until
stable. splice_null heartbeats are kept. splice_schedule, private commands,
and unparsable or encrypted sections are dropped. Cues are usually sent
seconds ahead, so a section sent before the loop, for a break inside it, is
moved to the start of the segment. 80s_with_ad.ts has its only cue one
packet before the loop.

Each wrap re-sends the kept sections with pts_adjustment advanced by
wrap*loopDur (mod 2^33), every splice_event_id and segmentation_event_id
advanced by wrap*step, and CRC_32 recomputed. The step is the span of event
IDs used in the loop (max-min+1), so IDs never repeat. Dropped sections
leave null packets. The scan report lists every cue with its interval and
why it was dropped (loop.scte35).

The section assembler stops at anything but table_id 0xfc after a section:
football_with_scte35.ts pads with 0x00 instead of the 0xff the standard
requires.

Validated on football_with_scte35.ts (2 auto-return breaks in a 52.8 s
loop) over 3 wraps: event IDs 256/257, 258/259, 260/261; effective splice
times exactly wrap-0 time + n*4752000; CRC valid; CC 0 errors; PCR within
0.57 tick of linear; ffmpeg decode clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…loop boundary

The segment builder dropped all null packets, spread new ones evenly, and
appended audio PES at their source position relative to the video. Two
things moved packets away from their source timing.

1. A constant-rate mux puts stuffing wherever it has nothing to send, so
   even re-spreading moved every packet by up to a few hundred ms. For
   avc720p50_ac3, audio delay (PTS minus arrival) was 248-891 ms against
   565-617 in the source.
2. Audio is selected by PTS but multiplexed with an offset to the video
   (it usually lags). The end-of-loop audio, which sits after the window's
   last packet, was appended before the seam as a burst (38 packets in
   19 ms for football), and every wrap started without audio for ~0.5 s.

The segment now keeps one slot per source packet of the video window. Video
and other packets stay put, so in a constant-rate stream the regenerated
linear PCR equals the source's. Stuffing and the window's original audio
slots are free. placeAudio puts each kept audio PES into the first free
slots at or after its target, never before the end of its PID's previous
PES. Audio from after the window moves back by one loop duration
(idealPackets) and belongs to the previous wrap (rot -1). Audio from before
the window moves to the end and belongs to the next wrap (rot +1). A moved
PES is never placed past its PID's in-place audio, so PTS order holds. The
window's transmission time differs from the loop duration by the change in
buffer delay between the loop points (stuffingDelta); free slots are removed
from the end of the window, or nulls added before the seam.

audioWrapState now derives each logical wrap's deltaPTS from the segment's
audio frame list rather than as frames go by, so moved frames, sent a wrap
early or late, get the same decisions. Wrap 0 sends no rot -1 audio, and
the last wrap of a bounded run no rot +1.

This also fixes a latent bug. When audio led video, the window started at
the first kept audio packet, so pre-loop video and SMPTE-2038 packets were
copied into every wrap (hevc10b: 2 out-of-order ANC PTS per seam).

Measured per PES against the source on captures: 0 ms change (median) for
most of each wrap, and up to 80-113 ms just before the seam, which is those
loops' buffer mismatch. Audio cadence is continuous across all seams, the
29.97/59.94/23.976 grids are exact over 50 wraps, CC has 0 errors, and
ffmpeg decodes cleanly.

The plan now reports how far the source PCR is from a constant rate
(pcr.sourceDeviationMs) and warns for variable-rate streams. Those are
still looped at the average rate: football is up to 1.1 s off, and so are
three of the four bundled fixtures (0.24-1.0 s).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ties

The loop is re-timed with a linear PCR at the stream's rate. That keeps
each packet's source timing only for a constant-rate stream. A VBR stream
was looped at its average rate, which moved packets up to ±1.1 s off
(football), so audio could arrive after its presentation time. VBR streams
are now refused, detected from the PCR as before: the rate between
consecutive PCRs must stay within 0.5%. The scan says why in its note, and
looping exits with an error. Three of the four bundled fixtures are VBR
(0.24-1.0 s from a constant rate), so their golden scan reports now carry
that note.

A signalled time-base discontinuity (discontinuity_indicator) must not make
a constant-rate stream look variable-rate. PCRBitrate skips intervals
across one, and the new PCRDeviationMs fits each stretch between them on
its own. A loop can't span a discontinuity, since PCR and timestamps jump
there. selectLoop only pairs loop points with no flagged packet (PCR,
audio, or video PID) between them.

The SCTE-35 loop test used 80s_with_ad.ts, which is VBR. It now injects
cues into the constant-rate MPEG-2 fixture (an SCTE-35 PID added to the
PMT, cues written into null packets): an auto-return break inside the
loop, one past its end (dropped), and a CUE-OUT/CUE-IN pair. It checks each
wrap's event IDs and times.

Split audio PES now set data_alignment_indicator, since each starts with
a frame.

The constant-rate test streams choose the same loops as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The PCR PID was taken to be the first PID seen carrying a PCR, and gots
does not expose the PMT's PCR_PID. InitTS now reads PCR_PID from the first
PMT section, and falls back to the first PCR-bearing PID only when the PMT
declares none. If another PID also carries PCR fields, the rate check and
PCR plan now follow the declared one.

The rest of the PCR handling was already PID-agnostic: re-stamping, the
per-wrap offset, and continuity counters that don't advance on payload-less
packets. A PCR-only PID is not audio, so its packets keep their source
slots. TestLoopSeparatePCRPID moves every PCR of a constant-rate fixture
into adaptation-field-only packets on PID 0x1f0, in the next null packet,
and points PCR_PID at it. It checks that the plan follows it, PCR appears
only on that PID, the CC does not move, and PCR stays linear across three
wraps. The same stream through the CLI gives 0 CC errors, PCR within 0.52
tick of linear, and a clean ffmpeg decode.

New fixture avc_aac_cbr.ts: 3 s of constant-rate AVC + AAC (189 KB, 35%
null packets), with a golden scan report. The other AVC/HEVC fixtures are
variable-rate and now refused.

Also fixes TestLoopSCTE35 being flaky. Its helper placed cues from a map, and
the fixture has no null packets between packets 260 and 556, so cue order
followed map iteration. Cues now go in order into successive null packets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@tobbee
tobbee merged commit fe0e971 into main Oct 7, 2026
7 checks passed
@tobbee tobbee mentioned this pull request Oct 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant