Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,35 @@ the public-API contract.

### Fixed

- **Every segment of a Matroska with B-frames now opens on a keyframe (AE#561).** The
keyframe-aligned plan's boundaries ARE the container's index entries, and containers disagree
about what an entry's timestamp means: a mov/mp4 sample table holds decode times, a Matroska Cue
holds a presentation time. The cutter gate compared decode times against both (#358), so on any
MKV whose video carries composition offsets no IRAP ever reached its own boundary. The gate never
opened on the planned keyframe, audio (routed by boundary and not gated) opened the segment
instead, and every segment began mid-GOP about one IRAP below its own first random-access point,
with nothing in it a cold decode could start from. Playback survived only while AVPlayer decoded
THROUGH the boundaries; the first time it had to decode FROM one it stopped with
`CoreMediaErrorDomain -19602`, at a position that depends on the encode rather than on elapsed
time, and the automatic item reload died on the same segment. The gate now compares a packet on
the plan's own axis (`PlanBoundaryAxis`, read from the demuxer's format name), so a keyframe hits
its own boundary exactly on either container, and the AE#412 reach is measured on that same axis.
mov/mp4 sessions are unchanged, byte for byte. Pinned by `PlanBoundaryAxisTests` on one HEVC
stream muxed into both containers, where the stamping is the only difference.


- **Bridged multichannel audio no longer publishes its first fragment 584 thousand years out
(AE#561 follow-up).** `baseMediaDecodeTime` is `unsigned int(64)`, so a negative published
timestamp is unrepresentable rather than merely unusual. The audio bridge stamps the frame it
hands the encoder, and an encoder that declares `initial_padding` stamps its first packet a
padding below that frame (256 samples on the AC-3 family, which is what surround-compat mode
reaches for above two channels; FLAC declares none). At source position 0 that published -256 as
2^64 - 256, and the session lost the ~190 ms of audio in that fragment. Nothing discards the
priming here, because the muxer writes no edit list on purpose, so the counter now carries the
padding and the content pays its 5.3 ms instead, two orders below the lip-sync threshold. Applied
on every rebase, so a restart mid-file keeps the same relationship instead of stepping by a
padding. Pinned by `BridgedAudioOriginTests` on a 5.1 PCM Matroska.

- **A live recording now starts at zero instead of carrying the broadcast's own clock.**
Copying the source timestamps verbatim produced a recording whose first presentation timestamp
lay hours past its own beginning, which a duration probe reports as the offset rather than the
Expand Down Expand Up @@ -80,6 +109,20 @@ the public-API contract.
10-bit HEVC with no record, the demuxer now reads the first RPU and, if it reads profile 5, adds the
missing record so the existing Profile 5 paths apply. Any other source is left alone.

### Changed

- **A restart into a Matroska boundary re-aims on the distance it actually overshot (AE#561).** The
AE#408 tolerance, which decides when a segment opens so far past its boundary that going back for
an earlier sync sample is worth it, carried the stream's reorder depth. That term pays for a
boundary stamped in decode time being judged by presentation time, not for anything the stream
does, and a Matroska Cue is already a presentation time: there a correctly indexed keyframe
presents exactly at its boundary, and the term only widened the window in which a genuinely late
open escaped its re-aim. On that axis the tolerance is now the floor, which sharpens the decision
on the container AE#408 was reported against. mov/mp4 keeps the reorder term, because there the
skew is real. The gate's own comparison is deliberately left lenient; the reported AE#169 geometry
has the boundary falling between the anchor keyframe's two timestamps, matching neither axis, and
only the permissive reading admits it at all.

## [7.8.0] - 2026-09-20

### Added
Expand Down
33 changes: 33 additions & 0 deletions Scripts/fetch-fixtures.sh
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
# restart-witness-subs.mkv - same as MKV with an embedded SRT track (pump tap)
# a53-captions.mp4 - H.264 with in-picture A/53 CEA-608 SEI (#131, #259)
# hev1-inband-xps.mp4 - HEVC with in-band VPS/SPS/PPS and an empty hvcC
# cue-axis-bframes.mkv/.mp4 - one HEVC B-pyramid stream in both containers (AE#561)
# bridge-eac3-51.mkv - 5.1 PCM in MKV, drives the EAC3 audio bridge (AE#561 follow-up)
#
# Real-world DV / Atmos / multichannel sources have to come from your
# own library. Drop those into ./Fixtures/user/ (also gitignored)
Expand Down Expand Up @@ -280,6 +282,37 @@ data[start:start + size] = replacement
open(path, 'wb').write(bytes(data))
PY

# AE#561: one HEVC elementary stream in both containers, so the only difference between the two
# files is what their index entries are stamped on. A Matroska Cue stores a presentation time, a
# mov/mp4 sample table stores decode times, and the keyframe-aligned plan's boundaries ARE those
# entries. B-frames (b-pyramid) are what makes the two axes differ at all; a dense keyint puts an
# IRAP well inside every 4 s segment, so a segment that lost its own IRAP still carries a later one
# and the defect shows as "opens below its first random-access point" rather than as no picture.
# The .mp4 is a stream copy on purpose: same packets, same timestamps, different index stamping.
echo "→ cue-axis-bframes.mkv + .mp4 (HEVC B-pyramid, same stream in both containers, 16s)"
ffmpeg -hide_banner -loglevel error -y \
-f lavfi -i "testsrc2=size=480x270:rate=24" \
-f lavfi -i "sine=frequency=440:sample_rate=48000" -t 16 \
-c:v libx265 -preset ultrafast -pix_fmt yuv420p \
-x265-params "keyint=21:min-keyint=21:scenecut=0:bframes=4:b-pyramid=1:log-level=none" \
-c:a aac -b:a 96k "$FIXTURES_DIR/cue-axis-bframes.mkv"
ffmpeg -hide_banner -loglevel error -y \
-i "$FIXTURES_DIR/cue-axis-bframes.mkv" -c copy \
"$FIXTURES_DIR/cue-axis-bframes.mp4"

# AE#561 follow-up: multichannel PCM in Matroska, which routes audio through the bridge in
# surround-compat mode, so the encoder is EAC3 rather than FLAC. That distinction is the whole
# point: FFmpeg's AC-3 family declares `initial_padding = 256` and stamps its first packet a
# padding below the frame it encoded, while FLAC declares none. 5.1 because the mode only reaches
# for EAC3 above two channels.
echo "→ bridge-eac3-51.mkv (5.1 PCM in MKV, drives the EAC3 bridge, 8s)"
ffmpeg -hide_banner -loglevel error -y \
-f lavfi -i "testsrc2=size=320x180:rate=24" \
-f lavfi -i "sine=frequency=440:sample_rate=48000" -t 8 \
-c:v libx264 -preset veryfast -g 48 -pix_fmt yuv420p -b:v 200k \
-af "pan=5.1|c0=c0|c1=c0|c2=c0|c3=c0|c4=c0|c5=c0" \
-c:a pcm_s24le "$FIXTURES_DIR/bridge-eac3-51.mkv"

# AetherEngine#268: finite HEVC-in-MPEG-TS HLS VOD, the carriage AVFoundation refuses to build a
# video track for. Three shapes, because each one only shows its own defect:
# hls-hevc-vod/ PTS origin at ffmpeg's default 1.4 s, 6 s segments, 2 s GOP
Expand Down
15 changes: 15 additions & 0 deletions Sources/AetherEngine/Audio/AudioBridge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -861,7 +861,22 @@ final class AudioBridge: @unchecked Sendable {
}
stats.framesDecoded += 1
if rebaseFromNextSourcePTS, packetPts != Self.avNoPTS {
// AE#561 follow-up: this counter stamps the FRAME handed to the encoder, and an
// encoder that declares `initial_padding` stamps its first PACKET a padding BELOW
// that frame (256 samples on the AC-3 family, 0 on FLAC), so that a consumer which
// discards the priming lands back on the source position. Nothing discards it
// here: the muxer writes no edit list on purpose, since the init segment has to
// stay restart-invariant, so the priming plays as the silence it is. Without the
// offset the published timeline therefore STARTS a padding below the source, and
// at source 0 that is a negative `baseMediaDecodeTime`, a field that is
// `unsigned int(64)`: -256 went out as 2^64 - 256 and AVPlayer placed the whole
// first audio fragment 584 thousand years out, losing its ~190 ms of audio. The
// offset costs the content the padding's 5.3 ms instead, which is what an
// unsignalled priming is worth and two orders below the lip-sync threshold. It is
// applied on every rebase, not only near zero, so a restart mid-file inherits the
// same relationship instead of stepping by a padding.
nextEncoderPTS = av_rescale_q(packetPts, srcTimeBase, encoderTimeBase)
&+ Int64(enc.pointee.initial_padding)
rebaseFromNextSourcePTS = false
}
try resampleAndPushIntoFIFO(srcFrame: sf, enc: enc, swr: swr, fifo: fifoPtr)
Expand Down
80 changes: 57 additions & 23 deletions Sources/AetherEngine/Video/HLSSegmentProducer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -474,6 +474,10 @@ final class HLSSegmentProducer: @unchecked Sendable {
/// IRAP (AE#268), so neither may be re-anchored for opening past its target.
private let boundaryClaimsRandomAccess: Bool

/// AE#561: the axis `segmentBoundaries` are stamped on, so the cutter gate compares like with
/// like. Decode for a mov/mp4 index, presentation for Matroska Cues; see `PlanBoundaryAxis`.
private let planBoundaryAxis: PlanBoundaryAxis

/// AE#408: the gate target actually in force. Starts at `restartTargetVideoPts` and moves BACK when
/// the boundary turns out not to be openable, so the segment covers its own advertised start
/// instead of carrying content from the far side of a keyframe drought.
Expand Down Expand Up @@ -862,11 +866,23 @@ final class HLSSegmentProducer: @unchecked Sendable {
}

/// AE#169 round 3 pure decision: whether a video packet opens the restart scan-forward gate.
/// The gate target is a plan-boundary PTS (`segmentPlan[baseIndex].startPts`), so the packet
/// is judged by presentation time. Comparing DTS dropped the exact IRAP the restart seeked
/// for (a keyframe's DTS sits a reorder delay below its own PTS; same defect class as the #92
/// cutter fix): mid-file the next IRAP rescued the miss one GOP late, but at the file tail no
/// later IRAP exists, so the unbounded VOD gate starved to EOF with zero packets written.
/// The gate target is a plan boundary (`segmentPlan[baseIndex].startPts`), and the packet is
/// judged by presentation time. Comparing DTS dropped the exact IRAP the restart seeked for (a
/// keyframe's DTS sits a reorder delay below its own PTS; same defect class as the #92 cutter
/// fix): mid-file the next IRAP rescued the miss one GOP late, but at the file tail no later
/// IRAP exists, so the unbounded VOD gate starved to EOF with zero packets written.
///
/// AE#561 deliberately did NOT make this axis-matched the way the cutter gate is, and the
/// reason is that the two answer different questions. The cutter decides which segment a packet
/// belongs to, where being wrong by a composition offset costs a segment its IRAP, and it can
/// afford exactness because it sees every packet. This gate decides where production STARTS
/// after a seek, and being wrong in the strict direction costs the whole restart: the reported
/// geometry (target 2878501, the anchor IRAP at dts 2878495 and pts 2878620) has the boundary
/// falling BETWEEN that keyframe's two timestamps, so it matches neither axis and only the
/// lenient comparison admits it. Since `pts >= dts` holds for any conforming stream, judging
/// presentation time is the most permissive reading and cannot starve on a skew in either
/// direction. What the axis buys here is knowing how much of the resulting overshoot is real,
/// which is what `boundaryOpenToleranceTicks` spends it on.
static func videoGateTargetSatisfied(pts: Int64, dts: Int64, targetPts: Int64) -> Bool {
if targetPts == Int64.min { return true }
let ts = pts != Int64.min ? pts : dts
Expand Down Expand Up @@ -897,16 +913,24 @@ final class HLSSegmentProducer: @unchecked Sendable {

/// AE#408: how far past the boundary a sync sample may present before it is worth going back for.
///
/// A container index entry is a DECODE timestamp while the gate judges presentation time
/// (AE#169 round 3), so even a perfectly formed index puts the keyframe's PTS a reorder delay
/// above the boundary it was indexed at. Charging that skew a second seek would re-aim on every
/// restart of a B-pyramid encode, so the tolerance covers the stream's own declared depth plus a
/// frame, and never falls below the floor.
/// The gate judges presentation time (AE#169 round 3), so where the plan's boundaries are DECODE
/// timestamps even a perfectly formed index puts the keyframe's PTS a reorder delay above the
/// boundary it was indexed at. Charging that skew a second seek would re-aim on every restart of
/// a B-pyramid encode, so the tolerance covers the stream's own declared depth plus a frame, and
/// never falls below the floor.
///
/// AE#561: that reorder term pays for an AXIS MISMATCH, not for anything the stream does. A plan
/// whose boundaries are PRESENTATION timestamps (Matroska Cues) has no such skew, a correctly
/// indexed keyframe presents exactly at its boundary, and the term would only widen the window in
/// which a genuinely late open escapes its re-aim. On that axis the tolerance is the floor, which
/// sharpens the decision on the very container AE#408 was reported against. The floor still
/// absorbs an index that is approximate rather than skewed, which Matroska Cues frequently are.
static func boundaryOpenToleranceTicks(
reorderFrames: Int32, frameDurationPts: Int64, floorTicks: Int64
reorderFrames: Int32, frameDurationPts: Int64, floorTicks: Int64,
planAxis: PlanBoundaryAxis = .decode
) -> Int64 {
let frame = Swift.max(0, frameDurationPts)
let reorder = Int64(Swift.max(0, reorderFrames)) &* frame
let reorder = planAxis == .decode ? Int64(Swift.max(0, reorderFrames)) &* frame : 0
return Swift.max(floorTicks, reorder &+ frame)
}

Expand Down Expand Up @@ -1364,6 +1388,7 @@ final class HLSSegmentProducer: @unchecked Sendable {
audioFallbackDurationPts: Int64 = 0,
restartTargetVideoPts: Int64 = Int64.min,
boundaryClaimsRandomAccess: Bool = false,
planBoundaryAxis: PlanBoundaryAxis = .decode,
closedCaptionStreamIndex: Int32 = -1,
subtitleTapStreamIndices: Set<Int32> = [],
subtitlePacketStreamIndices: Set<Int32> = [],
Expand Down Expand Up @@ -1440,6 +1465,7 @@ final class HLSSegmentProducer: @unchecked Sendable {
self.audioFallbackDurationPts = audioFallbackDurationPts
self.restartTargetVideoPts = restartTargetVideoPts
self.boundaryClaimsRandomAccess = boundaryClaimsRandomAccess
self.planBoundaryAxis = planBoundaryAxis
self.effectiveGateTargetPts = restartTargetVideoPts
self.gateProvenEmptyFromPts = restartTargetVideoPts
// Audio target set dynamically once video gate opens (rescaled to audio TB).
Expand Down Expand Up @@ -3474,7 +3500,8 @@ final class HLSSegmentProducer: @unchecked Sendable {
toleranceTicks: Self.boundaryOpenToleranceTicks(
reorderFrames: videoConfig.codecpar.pointee.video_delay,
frameDurationPts: videoFallbackDurationPts,
floorTicks: Int64(Self.boundaryOpenToleranceSeconds / sourceVideoTbSeconds)),
floorTicks: Int64(Self.boundaryOpenToleranceSeconds / sourceVideoTbSeconds),
planAxis: planBoundaryAxis),
attemptsUsed: gateBackoffAttempts,
maxAttempts: Self.gateBackoffStepsSeconds.count),
reanchorGateBelowBoundary(reason: "its first sync sample presents "
Expand Down Expand Up @@ -3827,17 +3854,19 @@ final class HLSSegmentProducer: @unchecked Sendable {
// at the IRAP that reaches its plan boundary, so the IRAP is the segment's first sample
// and its open-GOP RASL leading pictures stay with it (#92). Routing by DTS against PTS
// boundaries used to drop the IRAP (dts < pts) into the previous segment.
// #358: the VOD plan's boundaries are the mov/mp4 index's sync-sample timestamps,
// which are DECODE times, so the gate compares decode times too. Comparing the
// presentation time against them let a keyframe reach boundaries beyond its own
// by its composition offset (3 s on the field report's remux), consuming plan
// indices that then never opened a segment. Keyframe gating is unchanged, so
// #92 holds: the IRAP is still the segment's first sample and its RASL pictures
// still follow it in decode order.
// #358 / AE#561: the VOD plan's boundaries ARE the container's index entries, and
// what an entry's timestamp means depends on the container: decode times from a
// mov/mp4 sample table, presentation times from a Matroska Cue. The gate compares
// on the plan's own axis (`PlanBoundaryAxis`), because either mismatch costs a
// segment its IRAP: a presentation packet against decode boundaries let a keyframe
// reach boundaries beyond its own (#358), a decode packet against presentation
// boundaries let no keyframe reach its own at all (AE#561). Keyframe gating itself
// is unchanged, so #92 holds: the IRAP is the segment's first sample and its
// open-GOP RASL pictures still follow it in decode order.
let thisVideoSeg = isLive
? liveVideoSegmentIndex(pts: packet.pointee.pts, isKeyframe: isVideoKeyframe)
: vodCutter.index(pts: packet.pointee.dts != Int64.min
? packet.pointee.dts : packet.pointee.pts,
: vodCutter.index(pts: planBoundaryAxis.timestamp(dts: packet.pointee.dts,
pts: packet.pointee.pts),
isKeyframe: isVideoKeyframe)
if thisVideoSeg != pumpQoSLastSeg {
pumpQoSLastSeg = thisVideoSeg
Expand Down Expand Up @@ -3907,8 +3936,13 @@ final class HLSSegmentProducer: @unchecked Sendable {
if !isLive, (prev.pointee.flags & AV_PKT_FLAG_KEY) != 0 {
let openIdx = muxer.currentSegmentIndex
if firstSyncItemPtsBySegment[openIdx] == nil {
// AE#561: measured against a plan boundary, so stamped on the
// plan's axis. On the wrong one the reach is off by the frame's
// composition offset, which is the distance this very number
// exists to report.
firstSyncItemPtsBySegment[openIdx] =
prev.pointee.dts != Int64.min ? prev.pointee.dts : prev.pointee.pts
planBoundaryAxis.timestamp(dts: prev.pointee.dts,
pts: prev.pointee.pts)
}
}
finalizeAndWriteVideo(prev, nextDts: packet.pointee.dts, muxer: muxer)
Expand Down
Loading
Loading