Skip to content

Commit 5eb31fb

Browse files
committed
fix(hls): prime mp4 moov with a parsed audio packet on backward-seek muxer restart (#92)
Under +delay_moov the mp4 muxer writes moov lazily on the first av_write_frame(ctx, nil). For AC-3/E-AC-3/TrueHD the audio sample entry (dac3/dec3/dmlp) can only be built from a PARSED audio packet, so a first moov flush that fires video-only errors -22 "Cannot write moov atom before EAC3 packets parsed", the segment cut fails, the muxer wedges, and the segment is retried forever (AVPlayer 503 -> forever-loading spinner). On a mid-file backward seek that lands out of the segment cache the producer tears down and rebuilds a fresh muxer at the restart segment; if that muxer's first moov flush (a #64 RAM-cap interim flush, or the first segment cut) fires before any audio packet is written, it hits this. AAC never triggers it (its sample entry needs no parsed packet). Fix: - Latch audioPacketWritten once the first audio packet is written. - Gate the #64 RAM-cap interim flushPendingFragment() on that latch, scoped via codec_id to the codecs whose sample entry needs a parsed packet (AC-3/E-AC-3/TrueHD) so AAC keeps its full RAM-cap bound. - In the video-leads-audio case, proactively flush on the first audio packet so moov is emitted with a parsed audio packet present. The first segment cut is intentionally left unguarded: if audio never arrives, moov must still be attempted (fail-as-before) rather than being deferred forever, which would convert the wedge into a permanent stall. Audio routing/placement is untouched, so no audio-dropout regression.
1 parent ab93219 commit 5eb31fb

1 file changed

Lines changed: 57 additions & 2 deletions

File tree

Sources/AetherEngine/Video/MP4SegmentMuxer.swift

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,17 @@ final class MP4SegmentMuxer {
130130
/// +delay_moov: first cut may need a second av_write_frame(nil) because FFmpeg can split
131131
/// ftyp+moov and moof+mdat across calls; gate ensures it only fires once.
132132
private var moovFlushed: Bool = false
133+
/// EAC3/AC-3 moov-wedge guard (#92 follow-up): latched once the first audio packet is written so
134+
/// the first moov flush can never fire before FFmpeg has parsed an audio packet — mov_write_moov
135+
/// builds the E-AC-3 `dec3` / AC-3 `dac3` (and TrueHD `dmlp`) sample-entry box from a parsed packet,
136+
/// and flushing moov video-only errors -22 "Cannot write moov atom before EAC3 packets parsed" and
137+
/// wedges the muxer.
138+
private var audioPacketWritten: Bool = false
139+
/// True only when the audio codec's mp4 sample entry requires a PARSED packet before moov can be
140+
/// written (AC-3 `dac3`, E-AC-3 `dec3`, TrueHD `dmlp`). AAC and other codecs build their sample entry
141+
/// from codecpar alone, so they never wedge — and gating the #64 RAM-cap flush on them would needlessly
142+
/// weaken that memory bound. Latched at init from the audio codec_id.
143+
private let audioNeedsParsedPacketForMoov: Bool
133144
/// Latched when the next staging file open fails; producer must stop the pump.
134145
private(set) var isWedged: Bool = false
135146
/// Latched after avformat_write_header; mp4 muxer rewrites time_base to its own pick
@@ -178,6 +189,16 @@ final class MP4SegmentMuxer {
178189
self.currentSegmentIndex = initialSegmentIndex
179190
self.sessionDir = sessionDir
180191
self.haveAudio = audio != nil
192+
// Only AC-3 / E-AC-3 / TrueHD build their mp4 sample entry from a parsed packet (dac3/dec3/dmlp),
193+
// so only they can hit the "moov before audio parsed" wedge and need the #64-flush guard.
194+
if let audioCodecID = audio?.codecpar.pointee.codec_id {
195+
self.audioNeedsParsedPacketForMoov =
196+
audioCodecID == AV_CODEC_ID_AC3 ||
197+
audioCodecID == AV_CODEC_ID_EAC3 ||
198+
audioCodecID == AV_CODEC_ID_TRUEHD
199+
} else {
200+
self.audioNeedsParsedPacketForMoov = false
201+
}
181202

182203
let firstPath = Self.stagingPath(forSegmentIndex: initialSegmentIndex,
183204
in: sessionDir)
@@ -515,12 +536,14 @@ final class MP4SegmentMuxer {
515536
packet.pointee.pts = clean.pts
516537
packet.pointee.dts = clean.dts
517538

539+
let streamIndex = packet.pointee.stream_index
540+
518541
// #64 mid-segment flush bound: cap libavformat's interleaver RAM on a very long segment
519542
// (degenerate sparse-keyframe plan, or an audio stream that decodes to nothing) by emitting a
520543
// moof+mdat into the current staging file before the buffered span grows without bound. Tracked
521544
// on the video output stream only; audio/subtitle packets ride along and are force-drained by the
522545
// flush. Flush BEFORE writing the triggering packet so it opens a fresh window.
523-
if packet.pointee.stream_index == videoOutputStreamIndex, packet.pointee.dts != Int64.min {
546+
if streamIndex == videoOutputStreamIndex, packet.pointee.dts != Int64.min {
524547
let dts = packet.pointee.dts
525548
if fragmentWindowFirstVideoDts == Int64.min {
526549
fragmentWindowFirstVideoDts = dts
@@ -537,7 +560,31 @@ final class MP4SegmentMuxer {
537560
// av_write_frame was tried as a leak hypothesis; no impact on 8 MB/s mallocMB growth
538561
// (leak was Data(d) dispatch_data aliasing in AVIOReader). Reverted to interleaved for
539562
// cross-stream DTS monotonicity and audio+video re-ordering via libavformat.
540-
return av_interleaved_write_frame(ctx, packet)
563+
let rc = av_interleaved_write_frame(ctx, packet)
564+
565+
// EAC3/AC-3/TrueHD moov-wedge guard (#92 follow-up). Under +delay_moov the first fragment flush
566+
// writes moov lazily, and FFmpeg's mp4 muxer can only build the AC-3/E-AC-3/TrueHD dac3/dec3/dmlp
567+
// sample-entry box once it has PARSED an audio packet. On a mid-file backward seek the producer
568+
// tears down and rebuilds a FRESH muxer at the restart segment; if that muxer's first moov flush
569+
// (a #64 RAM-cap flush, or the first segment cut) fires before any audio packet is written,
570+
// mov_write_moov errors -22 "Cannot write moov atom before EAC3 packets parsed", the cut fails, and
571+
// the segment is retried forever (AVPlayer 503 -> forever-loading). AAC never hits this (its sample
572+
// entry needs no parsed packet). Fix has two parts: (1) latch that an audio packet has been written;
573+
// (2) in the video-leads-audio case — the first audio packet arrives after a video packet is already
574+
// in the fragment window — proactively flush so moov is emitted WITH a parsed audio packet present
575+
// rather than waiting for the first cut. In the common backward-seek path the #74 pregate buffer
576+
// replays captured audio BEFORE the first video look-behind packet, so fragmentWindowFirstVideoDts
577+
// is still unset here and this proactive arm is skipped — moov is instead primed correctly at the
578+
// first cut, which already holds the audio in the interleaver. Idempotent once moovFlushed. Audio
579+
// routing/placement is untouched, so no audio dropouts.
580+
if streamIndex == audioOutputStreamIndex {
581+
audioPacketWritten = true
582+
if !moovFlushed, fragmentWindowFirstVideoDts != Int64.min {
583+
flushPendingFragment()
584+
}
585+
}
586+
587+
return rc
541588
}
542589

543590
/// Emit a moof+mdat for everything buffered into the CURRENT staging file, without rotating the fd or
@@ -546,6 +593,14 @@ final class MP4SegmentMuxer {
546593
/// ftyp+moov under +delay_moov, populating init.mp4 early instead of only at the (far-off) first cut.
547594
private func flushPendingFragment() {
548595
guard let ctx = formatContext, headerWritten, fd >= 0 else { return }
596+
// EAC3/AC-3/TrueHD moov-wedge guard (#92 follow-up): never let a video-only flush emit moov while
597+
// an audio stream whose sample entry needs a parsed packet is declared but no audio packet has been
598+
// written yet — mov_write_moov needs a parsed AC-3/E-AC-3/TrueHD packet for its dac3/dec3/dmlp box
599+
// (see writePacket). Scoped to those codecs so AAC (which never wedges) keeps the full #64 RAM-cap
600+
// bound. Skipping an interim #64 RAM-cap flush is harmless (the interleaver window just grows a
601+
// little longer); the first audio packet primes moov here or at the first cut (which already holds
602+
// the audio in the interleaver).
603+
if audioNeedsParsedPacketForMoov, !audioPacketWritten, !moovFlushed { return }
549604
_ = av_interleaved_write_frame(ctx, nil)
550605
_ = av_write_frame(ctx, nil)
551606
if !moovFlushed {

0 commit comments

Comments
 (0)