@@ -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