Skip to content

Commit 5ef7b18

Browse files
author
DatanoiseTV
committed
fix(hls): carry DTS for B-frame reordering — no more video "boomerang"
The PES header only advertised PTS (PTS_DTS_flags = 0b10), which means decoders displayed frames in decode order. B-frames in H.264 reference frames both before and after them in display order, so without a separate DTS the picture jumps back and forth between the B-frame pair and the P-frame they reference — the "boomerang" mpv and the web player were both showing. Changes: - Frame struct gains a DTS field (90 kHz). Audio always sets DTS == PTS (no B-frames in audio). - RTMP OnVideo now reads videoTag.CompositionTime (int32 ms, signed) and splits it into DTS = ts*90, PTS = (ts+comp)*90, matching how FLV already encodes A/V timing. OBS and most other H.264 RTMP publishers populate CompositionTime whenever B-frames are enabled. - buildPESHeader takes (pts, dts) and emits both when they differ, using the spec-correct prefix nibbles (0011 for PTS, 0001 for DTS) via a shared encodePTSWithPrefix helper. When dts == pts it emits only the 5-byte PTS header exactly as before. - writeVideoPES now takes a dts parameter and derives PCR from the lower of DTS/PTS (PCR must never be ahead of any DTS). - writeAudioPES passes dts == pts (no change in PES size). - pesUnit in the HLS framed segmenter carries dts too; videoBatch and audioBatch entries both populate it on frame dispatch. - MuxAVSegment's legacy byte-buffer path (single PES per segment) keeps dts == pts since it doesn't have CompositionTime info.
1 parent 12dec98 commit 5ef7b18

4 files changed

Lines changed: 84 additions & 36 deletions

File tree

relay/frames.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,10 @@ const (
2323
// single non-IDR slice). For AAC it's one ADTS frame. For MP3 it's one
2424
// MPEG audio frame.
2525
type Frame struct {
26-
Kind FrameKind
27-
PTS int64 // 90 kHz units
28-
Data []byte
26+
Kind FrameKind
27+
PTS int64 // presentation timestamp, 90 kHz units
28+
DTS int64 // decode timestamp, 90 kHz units (== PTS for audio and B-frame-less video)
29+
Data []byte
2930
Keyframe bool // video only; ignored for audio
3031
}
3132

relay/ingest_rtmp.go

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -343,14 +343,16 @@ func (h *rtmpHandler) OnAudio(timestamp uint32, payload io.Reader) error {
343343

344344
// Broadcast the raw audio data to the stream (for byte-level
345345
// Icecast listeners) and publish a Frame to the hub (for HLS /
346-
// other per-frame consumers).
346+
// other per-frame consumers). Audio has no B-frames so DTS == PTS.
347347
h.stream.Broadcast(data, h.relay)
348348
if h.stream.Frames != nil {
349349
frameData := make([]byte, len(data))
350350
copy(frameData, data)
351+
pts := ptsFromFLVTimestamp(timestamp)
351352
h.stream.Frames.Publish(Frame{
352353
Kind: FrameAudio,
353-
PTS: ptsFromFLVTimestamp(timestamp),
354+
PTS: pts,
355+
DTS: pts,
354356
Data: frameData,
355357
})
356358
}
@@ -407,15 +409,20 @@ func (h *rtmpHandler) OnVideo(timestamp uint32, payload io.Reader) error {
407409
}
408410

409411
h.videoStream.Broadcast(annexB, h.relay)
410-
// Also publish a per-frame record with the real FLV timestamp
411-
// so the HLS segmenter can emit one PES per frame with the
412-
// right PTS instead of faking a single timestamp per segment.
412+
// Publish a per-frame record carrying PTS *and* DTS. FLV
413+
// video tags have CompositionTime (int32 ms, signed) which
414+
// is PTS - DTS; without splitting them, B-frames display
415+
// in decode order and the picture boomerangs between I/P
416+
// and the two Bs either side of it.
413417
if h.videoStream.Frames != nil {
414418
frameData := make([]byte, len(annexB))
415419
copy(frameData, annexB)
420+
dts := ptsFromFLVTimestamp(timestamp)
421+
pts := dts + int64(videoTag.CompositionTime)*90
416422
h.videoStream.Frames.Publish(Frame{
417423
Kind: FrameVideo,
418-
PTS: ptsFromFLVTimestamp(timestamp),
424+
PTS: pts,
425+
DTS: dts,
419426
Data: frameData,
420427
Keyframe: isKeyframe,
421428
})

relay/mux_mpegts.go

Lines changed: 59 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -72,9 +72,12 @@ func (m *TSMuxer) MuxAVSegment(audioData []byte, videoData []byte, audioPTS, vid
7272
// Write PMT (updated for A/V, with the correct audio stream_type)
7373
m.writePMTAV(&buf, audioStreamType)
7474

75-
// Write video PES first (usually larger, keyframe-aligned)
75+
// Write video PES first (usually larger, keyframe-aligned). This is
76+
// the legacy one-PES-per-segment path (kept for byte-buffer HLS);
77+
// with no CompositionTime information we pass DTS == PTS and
78+
// buildPESHeader will emit just the 5-byte PTS form.
7679
if len(videoData) > 0 {
77-
m.writeVideoPES(&buf, videoData, videoPTS)
80+
m.writeVideoPES(&buf, videoData, videoPTS, videoPTS)
7881
}
7982

8083
// Write audio PES
@@ -135,13 +138,20 @@ func (m *TSMuxer) writePMTAV(buf *bytes.Buffer, audioStreamType byte) {
135138
buf.Write(packet)
136139
}
137140

138-
func (m *TSMuxer) writeVideoPES(buf *bytes.Buffer, data []byte, pts int64) {
139-
pesHeader := buildPESHeader(h264StreamID, len(data), pts)
141+
// writeVideoPES emits one video PES. Pass dts == pts (or dts < 0) when
142+
// the stream has no B-frames; pass the real decode timestamp otherwise
143+
// so the decoder schedules B-frames correctly instead of the picture
144+
// "boomeranging" between frames.
145+
func (m *TSMuxer) writeVideoPES(buf *bytes.Buffer, data []byte, pts, dts int64) {
146+
pesHeader := buildPESHeader(h264StreamID, len(data), pts, dts)
140147
payload := append(pesHeader, data...)
141-
// The PMT for A/V declares video as PCR_PID, so we emit the PCR on
142-
// the first TS packet of the video PES. pts is already on the 90 kHz
143-
// clock; PCR base is also 90 kHz so pass it through directly.
144-
m.writePESPackets(buf, videoPID, &m.videoContinuity, payload, pts)
148+
// PCR uses DTS (the earliest timestamp in the stream) when we have
149+
// both, otherwise PTS. PCR on the PID declared as PCR_PID in the PMT.
150+
pcr := dts
151+
if pcr < 0 || pcr > pts {
152+
pcr = pts
153+
}
154+
m.writePESPackets(buf, videoPID, &m.videoContinuity, payload, pcr)
145155
}
146156

147157
func (m *TSMuxer) writePAT(buf *bytes.Buffer) {
@@ -232,7 +242,9 @@ func (m *TSMuxer) writePMT(buf *bytes.Buffer) {
232242
}
233243

234244
func (m *TSMuxer) writeAudioPES(buf *bytes.Buffer, data []byte, pts int64) {
235-
pesHeader := buildPESHeader(mp3StreamID, len(data), pts)
245+
// Audio has no B-frames, so DTS == PTS and the PES header only
246+
// advertises PTS (the 5-byte shorter form).
247+
pesHeader := buildPESHeader(mp3StreamID, len(data), pts, pts)
236248
payload := append(pesHeader, data...)
237249
// Audio-only PMT declares audio as PCR_PID, so the audio PES carries
238250
// the PCR. For A/V the PMT uses video as PCR_PID and writeVideoPES
@@ -342,8 +354,12 @@ func writePCR(dst []byte, base90k uint64) {
342354
dst[5] = 0x00
343355
}
344356

345-
// buildPESHeader creates a PES packet header with PTS.
346-
func buildPESHeader(streamID byte, dataLen int, pts int64) []byte {
357+
// buildPESHeader creates a PES packet header carrying PTS (and optionally
358+
// DTS). When dts != pts, both are emitted — the decoder needs DTS to
359+
// schedule decoding of B-frames, and PTS to decide display order. For
360+
// audio streams and for I/P-only video, pass dts == pts and the PES will
361+
// advertise PTS only.
362+
func buildPESHeader(streamID byte, dataLen int, pts, dts int64) []byte {
347363
var buf bytes.Buffer
348364

349365
// PES start code: 00 00 01
@@ -352,33 +368,53 @@ func buildPESHeader(streamID byte, dataLen int, pts int64) []byte {
352368
// Stream ID
353369
buf.WriteByte(streamID)
354370

355-
// PES packet length (0 = unbounded, but for segments we set it)
356-
headerDataLen := 5 // PTS is 5 bytes
371+
includeDTS := dts >= 0 && dts != pts
372+
headerDataLen := 5
373+
ptsDtsFlags := byte(0x80) // PTS only
374+
if includeDTS {
375+
headerDataLen = 10
376+
ptsDtsFlags = 0xC0 // PTS + DTS
377+
}
357378
pesLen := 3 + headerDataLen + dataLen // 3 = flags(2) + header_data_length(1)
358379
if pesLen > 0xFFFF {
359-
pesLen = 0 // unbounded for large segments
380+
pesLen = 0
360381
}
361382
buf.WriteByte(byte(pesLen >> 8))
362383
buf.WriteByte(byte(pesLen & 0xFF))
363384

364-
// Flags: marker bits, PTS present
365385
buf.WriteByte(0x80) // 10 marker, no scrambling, no priority, no alignment, no copyright, no original
366-
buf.WriteByte(0x80) // PTS only (no DTS)
367-
368-
// PES header data length
386+
buf.WriteByte(ptsDtsFlags)
369387
buf.WriteByte(byte(headerDataLen))
370388

371-
// PTS (5 bytes)
372-
buf.Write(encodePTS(pts))
389+
if includeDTS {
390+
// Spec: when both flags are set, the PTS prefix nibble is 0x3
391+
// and the DTS prefix nibble is 0x1. encodePTSWithPrefix builds
392+
// the 5-byte bitfield with the right first-nibble markers.
393+
buf.Write(encodePTSWithPrefix(pts, 0x3))
394+
buf.Write(encodePTSWithPrefix(dts, 0x1))
395+
} else {
396+
buf.Write(encodePTS(pts))
397+
}
373398

374399
return buf.Bytes()
375400
}
376401

377-
// encodePTS encodes a 33-bit PTS value into the 5-byte MPEG-TS format.
402+
// encodePTS encodes a 33-bit PTS value into the 5-byte MPEG-TS format
403+
// (PTS-only PES header — first nibble is 0b0010).
378404
func encodePTS(pts int64) []byte {
405+
return encodePTSWithPrefix(pts, 0x2)
406+
}
407+
408+
// encodePTSWithPrefix encodes a 33-bit timestamp into the 5-byte
409+
// MPEG-TS PES-header format with a caller-supplied 4-bit prefix nibble.
410+
// The PES spec uses different prefix values for PTS-only (0010), PTS
411+
// when DTS also present (0011), and DTS (0001) — passing the prefix
412+
// lets a single routine produce all three.
413+
func encodePTSWithPrefix(pts int64, prefix byte) []byte {
379414
p := make([]byte, 5)
380-
// Format: 0010 xxx1 | xxxxxxxx | xxxxxxx1 | xxxxxxxx | xxxxxxx1
381-
p[0] = 0x21 | byte((pts>>29)&0x0E)
415+
prefix &= 0x0F
416+
// Format: <prefix[3:0]> xxx1 | xxxxxxxx | xxxxxxx1 | xxxxxxxx | xxxxxxx1
417+
p[0] = (prefix << 4) | 0x01 | byte((pts>>29)&0x0E)
382418
p[1] = byte(pts >> 22)
383419
p[2] = 0x01 | byte((pts>>14)&0xFE)
384420
p[3] = byte(pts >> 7)

relay/output_hls.go

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,13 @@ import (
1111
)
1212

1313
// pesUnit is a single access unit queued for inclusion in the next HLS
14-
// segment, already tagged with its 90 kHz PTS.
14+
// segment, with both its presentation (PTS) and decode (DTS) timestamps
15+
// on the 90 kHz MPEG clock. For audio and for video without B-frames
16+
// dts == pts; for video with B-frames dts comes from the FLV
17+
// CompositionTime adjustment.
1518
type pesUnit struct {
1619
pts int64
20+
dts int64
1721
data []byte
1822
}
1923

@@ -207,7 +211,7 @@ func (h *HLSOutput) segmentLoopFramed(ctx context.Context, audio, video *Track)
207211
if !ok {
208212
return
209213
}
210-
audioBatch = append(audioBatch, pesUnit{pts: f.PTS, data: f.Data})
214+
audioBatch = append(audioBatch, pesUnit{pts: f.PTS, dts: f.DTS, data: f.Data})
211215
case f, ok := <-videoFrames:
212216
if !ok {
213217
return
@@ -224,7 +228,7 @@ func (h *HLSOutput) segmentLoopFramed(ctx context.Context, audio, video *Track)
224228
}
225229
segHasIDR = true
226230
}
227-
videoBatch = append(videoBatch, pesUnit{pts: f.PTS, data: f.Data})
231+
videoBatch = append(videoBatch, pesUnit{pts: f.PTS, dts: f.DTS, data: f.Data})
228232
case <-timer.C:
229233
// Time-based flush safety net.
230234
if time.Since(segStart) >= h.config.SegmentDuration {
@@ -242,7 +246,7 @@ func (h *HLSOutput) buildAVSegment(audioBatch, videoBatch []pesUnit, audioStream
242246
h.muxer.writePAT(&buf)
243247
h.muxer.writePMTAV(&buf, audioStreamType)
244248
for _, v := range videoBatch {
245-
h.muxer.writeVideoPES(&buf, v.data, v.pts)
249+
h.muxer.writeVideoPES(&buf, v.data, v.pts, v.dts)
246250
}
247251
for _, a := range audioBatch {
248252
h.muxer.writeAudioPES(&buf, a.data, a.pts)

0 commit comments

Comments
 (0)