Skip to content

Commit 20ce5b3

Browse files
Merge branch 'fix/ae418-placement-measured'
2 parents 87e5385 + edf9db4 commit 20ce5b3

7 files changed

Lines changed: 405 additions & 0 deletions

File tree

Scripts/timecode-fixture.sh

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,13 @@
2929
# would make: about +0.017 (one frame at 24 fps) when the engine describes the axis correctly, and
3030
# -8.983 for each of the three under 6.43.0.
3131
#
32+
# AE#418 round 3 uses the same three arms as a CONTROL. The axis is no longer predicted: the engine
33+
# reads `AVPlayerItem.loadedTimeRanges` after each seam and measures where AVPlayer put the segment,
34+
# so every arm above must print `#418 segN placement confirmed` and none of them may print
35+
# `#418 segN placed on base ...`. The correcting case needs a fetch AVPlayer discards before using
36+
# it, which needs a device slow enough to lag its own seek burst; it lives in the unit tests instead
37+
# (`Issue418PlacementReconcileTests`, built from the reporter's numbers).
38+
#
3239
# For magnitudes this fixture cannot reach, engineer the droughts: a key 3 s below a boundary gives
3340
# -3, 5 s gives -5, and a key under a second below one gives an axis AVPlayer THROWS AWAY at the
3441
# next seek (measured: -0.500 and -0.875 snap to 0, -1.000 and above survive).

Sources/AetherEngine/AetherEngine+Diagnostics.swift

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,47 @@ extension AetherEngine {
6666
}
6767
}
6868

69+
/// AE#418: the item's loaded ranges on the item axis. Where AVPlayer HOLDS what it fetched is the
70+
/// only on-device account of where it placed a segment, and the axis is a statement about exactly
71+
/// that. Off-main for the same reason as the buffer probe (AE#422).
72+
func avPlayerLoadedRanges() async -> [(Double, Double)] {
73+
guard let avPlayer = currentAVPlayer, let item = avPlayer.currentItem else { return [] }
74+
return await AVFoundationOffMain.read(item, on: NativeAVPlayerHost.offMainReadQueue) { item in
75+
item.loadedTimeRanges.map { value in
76+
let r = value.timeRangeValue
77+
return (r.start.seconds, (r.start + r.duration).seconds)
78+
}
79+
}
80+
}
81+
82+
/// AE#418 round 3: check a just-published VOD axis against AVPlayer's own account of the placement
83+
/// it describes, and let the session correct it when the base it composed onto was never carried.
84+
///
85+
/// Polled rather than awaited on an edge, because the publish happens when the segment is FETCHED
86+
/// and the ranges only move once AVPlayer has taken the bytes. Bounded on both ends: it gives up
87+
/// after `placementVerificationAttempts` reads, and it reads nothing at all once the playhead sits
88+
/// inside a range (one answer is all this needs). Late is worse than never here, which is why the
89+
/// window is short: eviction eventually trims a range's start away from the placement it began at.
90+
static let placementVerificationAttempts = 6
91+
static let placementVerificationIntervalMS = 250
92+
93+
func verifyPlacementAgainstLoadedRanges(session: HLSVideoEngine) {
94+
placementVerificationTask?.cancel()
95+
placementVerificationTask = Task { @MainActor [weak self, weak session] in
96+
for _ in 0..<Self.placementVerificationAttempts {
97+
try? await Task.sleep(for: .milliseconds(Self.placementVerificationIntervalMS))
98+
guard !Task.isCancelled, let self, let session else { return }
99+
let ranges = await self.avPlayerLoadedRanges()
100+
guard !Task.isCancelled else { return }
101+
let clock = self.nativeClockSeconds
102+
guard let start = HLSVideoEngine.placementRangeStart(ranges: ranges, itemClock: clock)
103+
else { continue }
104+
session.reconcileAxisWithObservedPlacement(observedItemStart: start, itemClock: clock)
105+
return
106+
}
107+
}
108+
}
109+
69110
// MARK: - Memory diagnostic
70111

71112
/// Cancel any prior probe, then emit one EngineLog line every 30 s under `.engine`. Line shape is documented on `memoryProbeTask`.

Sources/AetherEngine/AetherEngine+Loading.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -709,6 +709,13 @@ extension AetherEngine {
709709
+ "avBufAhead=\(String(format: "%.2f", avBufAhead))s",
710710
category: .session
711711
)
712+
// AE#418 round 3: a fetch is not a placement. The composition assumed AVPlayer's
713+
// timeline was carrying the last axis this side published; the item's own loaded
714+
// ranges say whether it was. Live rebases the whole timeline at a program boundary
715+
// and nothing older comes back on screen, so it composes nothing and checks nothing.
716+
if !self.isLive {
717+
self.verifyPlacementAgainstLoadedRanges(session: session)
718+
}
712719
}
713720
}
714721
session.onSeekStateChanged = { [weak self] inFlight, playlistTime in

Sources/AetherEngine/AetherEngine.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1276,6 +1276,11 @@ public final class AetherEngine: ObservableObject {
12761276
/// Cancelled in stopInternal so it can never outlive its session.
12771277
var liveReloadWatchdogTask: Task<Void, Never>?
12781278

1279+
/// AE#418 round 3: bounded read of the item's loaded ranges after a VOD axis seam, which is where
1280+
/// AVPlayer says it holds the bytes that seam describes. One at a time, newest wins, cancelled in
1281+
/// stopInternal: it exists only to check the axis just published, so an older check is stale.
1282+
var placementVerificationTask: Task<Void, Never>?
1283+
12791284
/// 1 Hz live-telemetry sampler. Lifecycle mirrors memoryProbeTask. Holds a weak engine reference
12801285
/// so the retained task can't keep self alive past teardown.
12811286
var liveTelemetrySampler: LiveTelemetrySampler?
@@ -5461,6 +5466,8 @@ public final class AetherEngine: ObservableObject {
54615466
// populated across the seam (issue #15). SW-path callers must release the preserved host themselves.
54625467
memoryProbeTask?.cancel()
54635468
memoryProbeTask = nil
5469+
placementVerificationTask?.cancel()
5470+
placementVerificationTask = nil
54645471
// AE#446: the outage watcher belongs to the session whose window was closed with ENDLIST.
54655472
liveOutageResumeWatcher?.cancel()
54665473
liveOutageResumeWatcher = nil

Sources/AetherEngine/Video/HLSVideoEngine.swift

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -389,6 +389,13 @@ public final class HLSVideoEngine: @unchecked Sendable {
389389
/// index rewrites it axis-true, which is what `recordingEpochAt` drops the entries above for.
390390
private let anchorShiftLock = NSLock()
391391
private var epochShiftByIndex: [Int: Double] = [:]
392+
/// AE#418 round 3: the placement this session last published an axis for, so the prediction can be
393+
/// checked against where AVPlayer actually put those bytes.
394+
private var lastPublishedPlacement: PublishedPlacement?
395+
/// Axis values this session has published, newest last, seeded with the axis a fresh item carries.
396+
/// A measured base is only ever collapsed onto one of these: AVPlayer composed onto something this
397+
/// side handed it, so a reading that matches none of them is a bad reading rather than a new axis.
398+
private var publishedAxisValues: [Double] = [0]
392399
/// The last index a fetch declared. A cold fetch reaches the provider BEFORE the producer has
393400
/// opened its gate, so the placement can precede the offset it is worth; this is what lets the
394401
/// gate publish for a placement that already happened.
@@ -2410,6 +2417,12 @@ public final class HLSVideoEngine: @unchecked Sendable {
24102417
let current = playlistShiftSeconds
24112418
let composed = Self.axisShift(after: current, placing: epochShift)
24122419
let seam = Self.seamItemSeconds(advertisedStart: plannedStart, currentShift: current)
2420+
// AE#418 round 3: keep what this composition assumed, so the placement can be checked against
2421+
// AVPlayer's own account of where it put the bytes.
2422+
anchorShiftLock.lock()
2423+
lastPublishedPlacement = PublishedPlacement(
2424+
index: index, advertisedStart: plannedStart, worth: epochShift, assumedBase: current)
2425+
anchorShiftLock.unlock()
24132426
EngineLog.emit(
24142427
"[HLSVideoEngine] #418 seg\(index) placed (advertised \(String(format: "%.3f", plannedStart))s, "
24152428
+ "worth \(String(format: "%.3f", epochShift))s): axis shift "
@@ -2467,9 +2480,162 @@ public final class HLSVideoEngine: @unchecked Sendable {
24672480
+ "landing \(String(format: "%.3f", landingItemSeconds))s (AVPlayer snaps a sub-second axis)",
24682481
category: .session
24692482
)
2483+
// Round 3: AVPlayer threw this placement's offset away, so there is no longer a placement for
2484+
// the check to measure. Left standing, the record would be re-read against a run now sitting on
2485+
// the raw playlist, the base would measure 0, and the correction would put back exactly the
2486+
// axis this snap just removed.
2487+
anchorShiftLock.lock()
2488+
lastPublishedPlacement = nil
2489+
anchorShiftLock.unlock()
24702490
publishPlaylistShift(snapped, seamItemSeconds: landingItemSeconds)
24712491
}
24722492

2493+
/// AE#418 round 3: one placement, kept so the composition that was published for it can be checked
2494+
/// against AVPlayer's own account of where those bytes landed.
2495+
struct PublishedPlacement: Sendable {
2496+
let index: Int
2497+
/// The segment's start in the playlist, which is the position AVPlayer reads through its axis.
2498+
let advertisedStart: Double
2499+
/// What the segment carries below that start. Negative for a gate that opened early.
2500+
let worth: Double
2501+
/// The axis the composition assumed AVPlayer's timeline was carrying when it placed this.
2502+
let assumedBase: Double
2503+
}
2504+
2505+
/// How far a measured base may sit from a value the timeline carried and still be read as that
2506+
/// value. One frame at 24 fps is 0.042 s and the smallest offset AVPlayer keeps across a seek is
2507+
/// 1.0 s (`axisSnapsBelowSeconds`), so this separates a rounding difference from a real one with
2508+
/// a factor of four in hand on both sides.
2509+
static let placementMatchToleranceSeconds = 0.25
2510+
2511+
/// The axis AVPlayer's timeline was carrying when it placed a segment advertised at
2512+
/// `advertisedStart` with its content beginning at `observedItemStart`.
2513+
///
2514+
/// AVPlayer puts a placed segment's first sample at its advertised start read through the axis the
2515+
/// timeline already carries, so that one subtraction inverts the placement: the reading is the base
2516+
/// it composed onto, whatever this side assumed.
2517+
static func measuredPlacementBase(advertisedStart: Double, observedItemStart: Double) -> Double {
2518+
return advertisedStart - observedItemStart
2519+
}
2520+
2521+
/// Collapse a measured base onto the nearest axis this timeline has carried, or nil when it matches
2522+
/// none of them. Reading a loaded range is a measurement of a live buffer: it can be taken before
2523+
/// the bytes this seam describes are in it, or after eviction has trimmed the range's start. Both
2524+
/// produce a number, and neither produces a number that lands on an axis the session ever had.
2525+
static func carriedAxisMatch(
2526+
measuredBase: Double, carried: [Double], tolerance: Double = placementMatchToleranceSeconds
2527+
) -> Double? {
2528+
guard measuredBase.isFinite else { return nil }
2529+
return carried.min(by: { abs($0 - measuredBase) < abs($1 - measuredBase) })
2530+
.flatMap { abs($0 - measuredBase) <= tolerance ? $0 : nil }
2531+
}
2532+
2533+
/// Bounded, order-preserving, duplicate-free. A session that restarts often would otherwise grow
2534+
/// this without limit, and the oldest axis is the least likely thing a live buffer still holds.
2535+
static let maxPublishedAxisValues = 32
2536+
2537+
static func recordingPublishedAxis(_ values: [Double], value: Double) -> [Double] {
2538+
guard !values.contains(where: { abs($0 - value) < 0.001 }) else { return values }
2539+
var next = values + [value]
2540+
if next.count > maxPublishedAxisValues { next.removeFirst(next.count - maxPublishedAxisValues) }
2541+
return next
2542+
}
2543+
2544+
/// The loaded range holding `itemClock`, which is the run AVPlayer is presenting. Its start is where
2545+
/// that run was placed. Ranges that end below the clock are older runs, ranges above it are not on
2546+
/// screen yet, and neither says anything about the picture.
2547+
static func placementRangeStart(ranges: [(Double, Double)], itemClock: Double) -> Double? {
2548+
let holding = ranges.filter { $0.0.isFinite && $0.1.isFinite && itemClock >= $0.0 && itemClock <= $0.1 }
2549+
return holding.max(by: { $0.0 < $1.0 })?.0
2550+
}
2551+
2552+
/// What a measured placement says about the axis published for it.
2553+
enum PlacementVerdict: Equatable {
2554+
/// AVPlayer placed it where the composition assumed it would.
2555+
case agrees
2556+
/// It composed onto a different base, and that base is one this session published.
2557+
case corrects(base: Double, axis: Double, seam: Double)
2558+
/// The reading matches no axis this session ever published, so it describes something other
2559+
/// than this placement (a range read before the bytes landed, or one eviction has trimmed).
2560+
case unrecognised(measuredBase: Double)
2561+
}
2562+
2563+
/// AE#418 round 3: read the base AVPlayer composed onto out of where it holds the bytes.
2564+
static func placementVerdict(
2565+
advertisedStart: Double, worth: Double, assumedBase: Double,
2566+
observedItemStart: Double, publishedAxes: [Double],
2567+
tolerance: Double = placementMatchToleranceSeconds
2568+
) -> PlacementVerdict {
2569+
let measured = measuredPlacementBase(
2570+
advertisedStart: advertisedStart, observedItemStart: observedItemStart)
2571+
guard abs(measured - assumedBase) > tolerance else { return .agrees }
2572+
guard let base = carriedAxisMatch(measuredBase: measured, carried: publishedAxes, tolerance: tolerance)
2573+
else { return .unrecognised(measuredBase: measured) }
2574+
guard abs(base - assumedBase) > tolerance else { return .agrees }
2575+
return .corrects(base: base, axis: base + worth, seam: advertisedStart - base)
2576+
}
2577+
2578+
/// AE#418 round 3: check the axis just published against where AVPlayer says it put the bytes, and
2579+
/// correct it when the two disagree.
2580+
///
2581+
/// The composition itself is right and measured: AVPlayer places a segment at its advertised start
2582+
/// read through the axis its timeline already carries, so an epoch that opens below its boundary
2583+
/// moves the axis by that much on top of what was there. What this side cannot see is whether a
2584+
/// placement it counted ever reached that timeline. A fetch is not a placement: during a seek burst
2585+
/// AVPlayer asks for a segment and then seeks away before the bytes are used, so the axis was
2586+
/// composed onto a base its timeline never carried, permanently, for every placement after it.
2587+
///
2588+
/// The reporting case (a six-seek burst): a resume worth `-3.045` and then two restarts worth
2589+
/// `-2.043` and `-5.589` published `-10.677`, while the item's own loaded range began at `791.2`
2590+
/// against an advertised `788.204`, which is a base of `-3.045`. The middle fetch moved nothing,
2591+
/// the honest axis was `-8.634`, and the captions ran the difference behind the picture.
2592+
func reconcileAxisWithObservedPlacement(observedItemStart: Double, itemClock: Double) {
2593+
guard !isLiveSession else { return }
2594+
anchorShiftLock.lock()
2595+
let placement = lastPublishedPlacement
2596+
let published = publishedAxisValues
2597+
anchorShiftLock.unlock()
2598+
guard let placement else { return }
2599+
let verdict = Self.placementVerdict(
2600+
advertisedStart: placement.advertisedStart, worth: placement.worth,
2601+
assumedBase: placement.assumedBase, observedItemStart: observedItemStart,
2602+
publishedAxes: published)
2603+
switch verdict {
2604+
case .agrees:
2605+
// Said out loud, because a check that only speaks when it disagrees cannot be told from
2606+
// one that never ran. This is the line that says the axis is measured on this session.
2607+
EngineLog.emit(
2608+
"[HLSVideoEngine] #418 seg\(placement.index) placement confirmed: AVPlayer holds it "
2609+
+ "from item \(String(format: "%.3f", observedItemStart))s, base "
2610+
+ "\(String(format: "%.3f", placement.assumedBase))s as published",
2611+
category: .session
2612+
)
2613+
case .unrecognised(let measuredBase):
2614+
EngineLog.emit(
2615+
"[HLSVideoEngine] #418 seg\(placement.index) placement reads item "
2616+
+ "\(String(format: "%.3f", observedItemStart))s, a base of \(String(format: "%.3f", measuredBase))s, "
2617+
+ "which is no axis this session published; keeping "
2618+
+ "\(String(format: "%.3f", placement.assumedBase + placement.worth))s",
2619+
category: .session
2620+
)
2621+
case .corrects(let base, let axis, let seam):
2622+
EngineLog.emit(
2623+
"[HLSVideoEngine] #418 seg\(placement.index) placed on base \(String(format: "%.3f", base))s, "
2624+
+ "not \(String(format: "%.3f", placement.assumedBase))s (AVPlayer holds it from item "
2625+
+ "\(String(format: "%.3f", observedItemStart))s, clock \(String(format: "%.3f", itemClock))s): "
2626+
+ "axis \(String(format: "%.3f", placement.assumedBase + placement.worth))s -> "
2627+
+ "\(String(format: "%.3f", axis))s",
2628+
category: .session
2629+
)
2630+
anchorShiftLock.lock()
2631+
lastPublishedPlacement = PublishedPlacement(
2632+
index: placement.index, advertisedStart: placement.advertisedStart,
2633+
worth: placement.worth, assumedBase: base)
2634+
anchorShiftLock.unlock()
2635+
publishPlaylistShift(axis, seamItemSeconds: seam)
2636+
}
2637+
}
2638+
24732639
/// AE#412: how long to give a re-cut its gate open before the seek goes out without it. A restart
24742640
/// is a demuxer seek plus a scan to the covering random-access point; past this the seek is worth
24752641
/// more than the correction, and the landing degrades to what it does today.
@@ -2617,6 +2783,10 @@ public final class HLSVideoEngine: @unchecked Sendable {
26172783

26182784
private func publishPlaylistShift(_ seconds: Double, seamItemSeconds: Double) {
26192785
setPlaylistShiftSeconds(seconds)
2786+
// AE#418 round 3: the candidate set a measured placement base may collapse onto.
2787+
anchorShiftLock.lock()
2788+
publishedAxisValues = Self.recordingPublishedAxis(publishedAxisValues, value: seconds)
2789+
anchorShiftLock.unlock()
26202790
// Refresh every native subtitle store's shift so cuesInWindow stays on the correct AVPlayer
26212791
// axis after a restart (matroska seek can land past the planned keyframe, #55). Snapshot under
26222792
// restartLock: this runs on the pump thread and the array is reassigned by attach* on another

0 commit comments

Comments
 (0)