From d6bba6ee5bd9abc930104c66c26d74f288528fc5 Mon Sep 17 00:00:00 2001 From: Vincent Herbst Date: Tue, 25 Aug 2026 23:41:40 +0200 Subject: [PATCH] fix(video): an axis offset composes, it is not owned by a decode run (#418) Round 1 fixed what the offset IS and got its lifetime wrong. It keyed the offset to "the decode run AVPlayer began here" and answered that from the fetch order: any request that did not follow its predecessor. rrgomes' seek burst falsified it. A fetch out of sequence happens while AVPlayer stays inside the run it is already playing, and the axis was then dropped to zero under a picture that had not moved, which is the pre-fix shape re-entered through the burst. Measured with `play --picture-probe` on the lying-Cues fixture, at re-aims of 0.5, 0.875, 1, 3, 5, 7, 9 and 11 s: AVPlayer places a segment at its advertised start read through the mapping its timeline ALREADY carries, so the offset COMPOSES. A resume that opened 9 s below its boundary reads axisErr -9.000. A seek that makes AVPlayer fetch that same segment a second time reads -18.000, one that provokes a restart re-aiming 5 s more reads -14.000, and the reporter's shape (a seek onto an axis-true segment) reads -9.000 still. Round 1 published 0.000 for all three, so capErr read -8.983 where the picture had not moved at all. So the axis moves when AVPlayer PLACES a segment, by exactly what that segment carries below its advertised start, and the seam belongs at that advertised start read through the axis in effect before it landed. Only an epoch's first segment can carry anything; every later one is cut on its own boundary. The record is keyed by index now, because several epochs can leave such a segment in the cache at once, and a new epoch drops the entries at and above its own index (those are rewritten axis-true). One exception, also measured: AVPlayer discards a sub-second axis at a seek and snaps back to the playlist. -0.500 and -0.875 read axisErr 0.000 after one; -1.000, -1.083, -1.292, -1.500, -3, -4, -7, -9 and -11 all survive unchanged. The VOD seek path publishes that snap from the landing forward. Thirteen arms of the fixture matrix, including the four that read -8.983 before, now read capErr +0.017, which is one frame at 24 fps. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RbWZLwBVLGM1xUVXa9NeJ1 --- Sources/AetherEngine/AetherEngine.swift | 7 + .../AetherEngine/Video/HLSVideoEngine.swift | 153 ++++++++++---- .../Video/VideoSegmentProvider.swift | 40 ++-- .../Issue418ReaimedGateAxisTests.swift | 192 ++++++++++++------ 4 files changed, 264 insertions(+), 128 deletions(-) diff --git a/Sources/AetherEngine/AetherEngine.swift b/Sources/AetherEngine/AetherEngine.swift index f8a056e6..affe0305 100644 --- a/Sources/AetherEngine/AetherEngine.swift +++ b/Sources/AetherEngine/AetherEngine.swift @@ -3915,6 +3915,13 @@ public final class AetherEngine: ObservableObject { // i.e. clockTarget == the 0-based playlist time (AE#105). Origin 0 off disc, so this stays // `target - playlistShiftSeconds` for normal VOD; SW/audio hosts run on source time (shift 0), no-op. let clockTarget = PresentationAxis.source(displayTime: target, origin: sourcePresentationOrigin) - playlistShiftSeconds + // AE#418 round 2: AVPlayer throws a sub-second axis offset away at a seek and snaps back to + // the playlist; a larger one it carries through unchanged (measured with `play + // --picture-probe`: -0.500 and -0.875 read `axisErr=0.000` after a seek, -1.000 through + // -11.000 all survive one). The target above is deliberately computed on the axis AVPlayer + // still had when the seek was issued; from the landing forward the clock describes the axis + // it will have instead. + nativeVideoSession?.snapAxisAfterSeek(landingItemSeconds: clockTarget) let gen = loadGeneration // Publish the native-path seek target up front so the scrub clock snaps immediately (#37); the host // suppresses periodic-observer reads until landing. SW/audio hosts resolve synchronously and write diff --git a/Sources/AetherEngine/Video/HLSVideoEngine.swift b/Sources/AetherEngine/Video/HLSVideoEngine.swift index dbade1e5..a2cd1aba 100644 --- a/Sources/AetherEngine/Video/HLSVideoEngine.swift +++ b/Sources/AetherEngine/Video/HLSVideoEngine.swift @@ -377,15 +377,19 @@ public final class HLSVideoEngine: @unchecked Sendable { shiftLock.lock(); _playlistShiftSeconds = value; shiftLock.unlock() } - /// AE#418: the segment whose own start establishes the axis of the run AVPlayer is playing, and - /// by how much. Only an epoch's FIRST segment can carry a non-zero offset: a gate that had to - /// open below its boundary puts that much extra content into that one segment, and every segment - /// the epoch cuts after it starts exactly on its boundary. One pair, not a table, because a later - /// epoch marching through the same index produces an axis-true segment there and a table would - /// keep claiming the old offset for it. + /// AE#418 round 2: what each stored segment adds to AVPlayer's axis when AVPlayer PLACES it. + /// + /// Only an epoch's FIRST segment can carry a non-zero offset: a gate that had to open below its + /// boundary puts that much extra content into that one segment, and every segment the epoch cuts + /// after it starts exactly on its boundary. Keyed by index rather than kept as one pair, because + /// several epochs can leave such a segment in the cache at once; an epoch marching through an + /// index rewrites it axis-true, which is what `recordingEpochAt` drops the entries above for. private let anchorShiftLock = NSLock() - private var anchorShiftIndex: Int = .min - private var anchorShiftSeconds: Double = 0 + private var epochShiftByIndex: [Int: Double] = [:] + /// The last index a fetch declared. A cold fetch reaches the provider BEFORE the producer has + /// opened its gate, so the placement can precede the offset it is worth; this is what lets the + /// gate publish for a placement that already happened. + private var lastPlacedIndex: Int = .min private let shiftLock = NSLock() private var _playlistShiftSeconds: Double = 0 @@ -1586,12 +1590,12 @@ public final class HLSVideoEngine: @unchecked Sendable { // 7. Wire provider, server, and URL. let manifestCodecs = audioHLSCodecs.map { "\(primaryCodecs),\($0)" } ?? primaryCodecs - // AE#418: where AVPlayer starts a fresh decode run decides the axis it plays on. - var coldAnchorHandler: (@Sendable (Int) -> Void)? + // AE#418: a segment AVPlayer places is what moves the axis it reads. + var segmentPlacedHandler: (@Sendable (Int) -> Void)? if !isLiveSession { - coldAnchorHandler = { [weak self] idx in + segmentPlacedHandler = { [weak self] idx in guard let self else { return } - self.handleColdAnchor(at: idx) + self.handleSegmentPlaced(at: idx) } } let prov = VideoSegmentProvider( @@ -1647,7 +1651,7 @@ public final class HLSVideoEngine: @unchecked Sendable { nativeSubtitleDefaultOrdinal: nativeSubtitleDefaultOrdinal, nativeSubtitleWholeProgram: nativeSubtitleWholeProgram, currentShiftSeconds: { [weak self] in (self?.playlistShiftSeconds ?? 0) + (self?.subtitleStreamStartSeconds ?? 0) }, - coldAnchorHandler: coldAnchorHandler + segmentPlacedHandler: segmentPlacedHandler ) self.provider = prov if isLiveSession { @@ -2227,55 +2231,114 @@ public final class HLSVideoEngine: @unchecked Sendable { private func handleVideoShiftKnown(_ shiftPts: Int64, firstItemTfdtPts: Int64) { let seconds = shiftPts == Int64.min ? 0 : Double(shiftPts) * sourceVideoTbSeconds let seamItemSeconds = Double(firstItemTfdtPts) * sourceVideoTbSeconds - // AE#418: record which segment this axis belongs to, so a decode run AVPlayer starts - // somewhere else does not inherit it. - if !isLiveSession { - let index = segmentIndexForPlaylistTime(seamItemSeconds) - anchorShiftLock.lock() - anchorShiftIndex = index - anchorShiftSeconds = seconds - anchorShiftLock.unlock() + // Live rebases the whole timeline at a program boundary and nothing older comes back on + // screen, so its axis is the epoch's own and it publishes here as it always has. + guard !isLiveSession else { + publishPlaylistShift(seconds, seamItemSeconds: seamItemSeconds) + return + } + // AE#418 round 2: on VOD the axis moves when AVPlayer PLACES this segment, not when the + // producer writes it. Record what the segment is worth and let the placement publish it; an + // epoch AVPlayer never fetches from must not move the clock at all. + let index = segmentIndexForPlaylistTime(seamItemSeconds) + anchorShiftLock.lock() + epochShiftByIndex = Self.epochShiftTable( + epochShiftByIndex, recordingEpochAt: index, shift: seconds) + let placementAlreadyHappened = lastPlacedIndex == index + anchorShiftLock.unlock() + if placementAlreadyHappened { + handleSegmentPlaced(at: index) } - publishPlaylistShift(seconds, seamItemSeconds: seamItemSeconds) } - /// AE#418: AVPlayer began a fresh decode run at `index`, so the axis is whatever THAT segment's - /// own start is worth, and the previous run's offset stops applying. + /// AE#418 round 2: AVPlayer put the segment at `index` into its timeline, so whatever that + /// segment carries below its advertised start moves the axis by that much. + /// + /// Measured with `play --picture-probe` on a fixture whose picture states its own source time, + /// at re-aims of 1, 3, 5, 7, 9 and 11 s: the offset **composes**. A run keeps the offset it has + /// across every boundary it plays through (round 1 measured that), and re-placing an overlong + /// segment adds its offset again on top: a resume that opened 9 s below its boundary reads + /// `axisErr=-9.000`, and a seek that makes AVPlayer fetch that same segment a second time reads + /// `-18.000`, not `-9.000` and not the `0.000` round 1 published there. /// - /// Measured with `play --picture-probe` on a fixture whose picture states its own source time: a - /// run started on an epoch's overlong first segment carries that segment's offset for as long as - /// it plays (across segment boundaries, so it is a property of the run and not of each segment), - /// and a seek that leaves the loaded region without provoking a restart lands on a sequentially - /// cut, axis-true segment, where the previous offset must not still be folded in. Publishing the - /// producer's offset alone got the first case right and the second wrong by the same amount. - func handleColdAnchor(at index: Int) { + /// Round 1 keyed this to "the run began here" and answered it from the fetch sequence, which is + /// what the reporter's seek burst falsified: a fetch that does not follow its predecessor happens + /// while AVPlayer stays inside the run it is already playing, and the axis was then dropped to + /// zero under a picture that had not moved. Reproduced on the fixture (`start 53`, seek to 80: + /// `capErr=-8.983`), and gone once the axis only ever moves by what a placed segment is worth. + func handleSegmentPlaced(at index: Int) { guard !isLiveSession else { return } anchorShiftLock.lock() - let shift = Self.axisShiftForRun( - beginningAt: index, anchorIndex: anchorShiftIndex, anchorShiftSeconds: anchorShiftSeconds) + lastPlacedIndex = index + let epochShift = epochShiftByIndex[index] ?? 0 anchorShiftLock.unlock() - guard abs(shift - playlistShiftSeconds) > 0.001 else { return } + guard epochShift != 0 else { return } restartLock.lock() let plannedStart = index >= 0 && index < segmentPlan.count ? segmentPlan[index].startSeconds : nil restartLock.unlock() guard let plannedStart else { return } + let current = playlistShiftSeconds + let composed = Self.axisShift(after: current, placing: epochShift) + let seam = Self.seamItemSeconds(advertisedStart: plannedStart, currentShift: current) EngineLog.emit( - "[HLSVideoEngine] #418 decode run re-anchored at seg\(index) " - + "(advertised \(String(format: "%.3f", plannedStart))s): axis shift " - + "\(String(format: "%.3f", playlistShiftSeconds))s -> \(String(format: "%.3f", shift))s", + "[HLSVideoEngine] #418 seg\(index) placed (advertised \(String(format: "%.3f", plannedStart))s, " + + "worth \(String(format: "%.3f", epochShift))s): axis shift " + + "\(String(format: "%.3f", current))s -> \(String(format: "%.3f", composed))s " + + "from item \(String(format: "%.3f", seam))s", category: .session ) - publishPlaylistShift(shift, seamItemSeconds: plannedStart) + publishPlaylistShift(composed, seamItemSeconds: seam) + } + + /// AE#418 round 2: the axis after AVPlayer places a segment worth `epochShift`. It composes, + /// because AVPlayer puts the placed segment's advertised start where its CURRENT mapping says + /// that position is, not where the playlist says it is. + static func axisShift(after currentShift: Double, placing epochShift: Double) -> Double { + return currentShift + epochShift + } + + /// The item position the placed segment's content begins at: its advertised start, read through + /// the axis that was in effect before it landed. Everything below that is still the old epoch's. + static func seamItemSeconds(advertisedStart: Double, currentShift: Double) -> Double { + return advertisedStart - currentShift + } + + /// Record what the epoch beginning at `index` is worth, dropping every entry at or above it: a + /// producer that starts writing there rewrites those segments on their own boundaries, so an + /// older epoch's offset must stop being claimed for them. + static func epochShiftTable( + _ table: [Int: Double], recordingEpochAt index: Int, shift: Double + ) -> [Int: Double] { + var next = table.filter { $0.key < index } + if shift != 0 { next[index] = shift } + return next + } + + /// AVPlayer discards a sub-second axis offset at a seek and snaps back to the playlist; a larger + /// one it keeps. Measured on the fixture: `-0.500` and `-0.875` read `axisErr=0.000` after a seek, + /// `-1.000`, `-1.083`, `-1.292`, `-1.500`, `-3.000`, `-4.000`, `-7.000`, `-9.000` and `-11.000` + /// all survive one unchanged. Anything below this is worth less than the frame it would move. + static let axisSnapsBelowSeconds = 1.0 + + static func axisShiftAfterSeek(_ shift: Double) -> Double { + return abs(shift) < axisSnapsBelowSeconds ? 0 : shift } - /// AE#418: the axis a decode run beginning at `index` plays on. Only the segment an epoch's gate - /// opened into can carry an offset; every other index was cut on its own boundary and is worth - /// exactly its advertised position, so a run beginning there must not inherit the previous run's. - static func axisShiftForRun( - beginningAt index: Int, anchorIndex: Int, anchorShiftSeconds: Double - ) -> Double { - return index == anchorIndex ? anchorShiftSeconds : 0 + /// AE#418 round 2: the host seeked and the axis was small enough for AVPlayer to throw away. + /// Publishing zero from the landing forward is what keeps the clock over the picture; the seam + /// leaves everything below the landing on the axis its bytes were placed with. + func snapAxisAfterSeek(landingItemSeconds: Double) { + guard !isLiveSession else { return } + let current = playlistShiftSeconds + let snapped = Self.axisShiftAfterSeek(current) + guard snapped != current else { return } + EngineLog.emit( + "[HLSVideoEngine] #418 axis \(String(format: "%.3f", current))s discarded at the seek " + + "landing \(String(format: "%.3f", landingItemSeconds))s (AVPlayer snaps a sub-second axis)", + category: .session + ) + publishPlaylistShift(snapped, seamItemSeconds: landingItemSeconds) } private func publishPlaylistShift(_ seconds: Double, seamItemSeconds: Double) { diff --git a/Sources/AetherEngine/Video/VideoSegmentProvider.swift b/Sources/AetherEngine/Video/VideoSegmentProvider.swift index 7e6805c6..d4354655 100644 --- a/Sources/AetherEngine/Video/VideoSegmentProvider.swift +++ b/Sources/AetherEngine/Video/VideoSegmentProvider.swift @@ -212,9 +212,9 @@ final class VideoSegmentProvider: HLSSegmentProvider, @unchecked Sendable { /// Current engine playlist shift (AVPlayer clock = source_pts - shift), read at serve time so whole-program /// cues land on the same AVPlayer axis as the video even when the shift was not known at load (Sodalite#32). private let currentShiftSeconds: @Sendable () -> Double - /// AE#418: fired with the index AVPlayer starts a fresh decode run on (a fetch that does not - /// follow the previous one). Where that run begins decides the axis for everything it plays. - private let coldAnchorHandler: (@Sendable (Int) -> Void)? + /// AE#418: fired with the index AVPlayer just placed into its timeline. What that segment + /// carries below its advertised start is what moves the axis every consumer folds with. + private let segmentPlacedHandler: (@Sendable (Int) -> Void)? /// Sodalite#32 Phase 2: tap-fed stores can carry raw ASS event lines (the overlay renders the /// styling); the WebVTT rendition must serve plain text, so strip at build time. private let stripASSMarkupInVTT: Bool @@ -367,7 +367,7 @@ final class VideoSegmentProvider: HLSSegmentProvider, @unchecked Sendable { nativeSubtitleDefaultOrdinal: Int = 0, nativeSubtitleWholeProgram: Bool = false, currentShiftSeconds: @escaping @Sendable () -> Double = { 0 }, - coldAnchorHandler: (@Sendable (Int) -> Void)? = nil + segmentPlacedHandler: (@Sendable (Int) -> Void)? = nil ) { self.cache = cache self.segments = segments @@ -402,7 +402,7 @@ final class VideoSegmentProvider: HLSSegmentProvider, @unchecked Sendable { self.nativeSubtitleDefaultOrdinal = nativeSubtitleDefaultOrdinal self.nativeSubtitleWholeProgram = nativeSubtitleWholeProgram self.currentShiftSeconds = currentShiftSeconds - self.coldAnchorHandler = coldAnchorHandler + self.segmentPlacedHandler = segmentPlacedHandler } /// Append a finalized live segment. Index must equal segments.count; out-of-order ignored. @@ -680,13 +680,18 @@ final class VideoSegmentProvider: HLSSegmentProvider, @unchecked Sendable { case fail } - /// AE#418: whether this request is where AVPlayer begins a fresh decode run. + /// AE#418 round 2: whether this request puts a segment into AVPlayer's timeline anew. /// - /// Following its predecessor means the run continues, and a run carries the axis of the segment - /// it began on across every boundary it plays through. Asking for the SAME index again is a - /// retry, not a new run: treating it as one would republish an axis mid-run. - static func beginsFreshDecodeRun(index: Int, previousTarget: Int) -> Bool { - return index != previousTarget &+ 1 && index != previousTarget + /// Round 1 asked a narrower question here, whether the fetch BEGAN a decode run, and answered it + /// from the fetch order: anything that did not follow its predecessor. The reporter's seek burst + /// falsified that. A fetch out of sequence happens while AVPlayer stays inside the run it is + /// already playing, so the axis was republished from under a picture that had not moved. + /// + /// What the axis actually turns on is placement, and every fetch is one, whatever its order. + /// Asking for the SAME index again is the one exception: that is a retry of a placement already + /// counted, and counting it twice would move the axis by an offset AVPlayer applied once. + static func placesSegmentAnew(index: Int, previousTarget: Int) -> Bool { + return index != previousTarget } static func foldedTargetDecision(folds: Int, alreadyReanchoredHere: Bool) -> FoldedTargetDecision { @@ -709,13 +714,12 @@ final class VideoSegmentProvider: HLSSegmentProvider, @unchecked Sendable { let previousTarget = cache.targetIndex cache.declareTarget(index) - // AE#418: a request that does not follow its predecessor is where AVPlayer begins a fresh - // decode run, and a fresh run is anchored at the manifest position of the segment it begins - // on (measured with `play --picture-probe`). A re-run of the same index is a retry, not an - // anchor. Everything after this fetch is presented continuously from here, so the axis this - // segment establishes holds until the next such fetch. - if Self.beginsFreshDecodeRun(index: index, previousTarget: previousTarget) { - coldAnchorHandler?(index) + // AE#418: AVPlayer places this segment at the position the PLAYLIST gives it, read through + // the axis its timeline already carries (measured with `play --picture-probe`). So a segment + // whose content starts below its advertised start moves the axis by that much, every time it + // is placed. A re-request of the same index is a retry of one placement, not a second one. + if Self.placesSegmentAnew(index: index, previousTarget: previousTarget) { + segmentPlacedHandler?(index) } // #358: the consumer is asking for a plan index a pump folded away, because no keyframe diff --git a/Tests/AetherEngineTests/Issue418ReaimedGateAxisTests.swift b/Tests/AetherEngineTests/Issue418ReaimedGateAxisTests.swift index ba483ad9..8d3289b7 100644 --- a/Tests/AetherEngineTests/Issue418ReaimedGateAxisTests.swift +++ b/Tests/AetherEngineTests/Issue418ReaimedGateAxisTests.swift @@ -7,39 +7,44 @@ import Foundation /// about eight after restarts that re-aimed 3.1 and 5.0, and a synced host reports the wrong position /// with them. Lip sync survives, because audio and video sit in the same segment. /// -/// AE#408 shipped the early-opening gate on the assumption that a segment keeping its own timestamps -/// leaves the item axis where the plan puts it, so it published no shift for that case. The reporter -/// measured the opposite from AVPlayer's loaded ranges, and `aetherctl play --picture-probe` measures -/// it directly: on a fixture whose picture states its own source time in binary, a resume at 53 s -/// whose gate re-aimed to 38.417 (boundary 52.000) showed source frame 41.250 while AVPlayer reported -/// item time 54.791, an axis error of exactly the re-aim, constant for the whole run. +/// **AVPlayer presents a segment at the position the PLAYLIST gives it, not at the tfdt it carries.** +/// AE#408 shipped the early-opening gate on the opposite assumption and published no offset for that +/// case. `aetherctl play --picture-probe` measures it directly, on a fixture whose picture states its +/// own source time in binary. /// -/// **AVPlayer presents a segment at the position the PLAYLIST gives it, not at the tfdt it carries, -/// and then plays continuously from there.** Two consequences, and the fix needs both: +/// Round 1 (6.43.0) got the offset right and its LIFETIME wrong. It keyed the offset to "the decode +/// run AVPlayer began here" and answered that from the fetch order, treating any request that did not +/// follow its predecessor as a fresh run. The reporter's seek burst falsified it: a fetch out of +/// sequence happens while AVPlayer stays inside the run it is already playing, and the axis was then +/// republished from under a picture that had not moved, which is the pre-fix shape re-entered. /// -/// 1. The offset a consumer must fold is measured against the segment's ADVERTISED start, never -/// against where the gate actually opened. A pinned (late) gate makes the two identical, which is -/// why publishing the muxer's shift held until AE#408 added a gate that opens early. -/// 2. That offset belongs to the decode RUN, not to the timeline. A seek that leaves the loaded -/// region without provoking a restart lands on a sequentially cut, axis-true segment, and the -/// previous run's offset must stop applying there. Measured before this half landed: the same -/// run read `capErr=+0.892` after such a seek, the mirror image of the defect it had just fixed. +/// Round 2 measures what the offset actually does, at re-aims of 0.5, 0.875, 1, 3, 5, 7, 9 and 11 s: /// -/// Measured on `tc-cues-lie.mkv` (Cues injected at non-sync positions, 12 s keyframe drought at -/// 43 s), source time decoded from the picture itself: +/// - It **composes**. AVPlayer places a segment at its advertised start read through the mapping its +/// timeline ALREADY carries, so re-placing an overlong segment adds its offset again. A resume that +/// opened 9 s below its boundary reads `axisErr=-9.000`; a seek that makes AVPlayer fetch that same +/// segment a second time reads `-18.000`, and one that provokes a restart re-aiming 5 s more reads +/// `-14.000`. Round 1 published `0.000` for all three. +/// - It survives a seek unchanged, which is what the reporter's case turns on, **unless it is under a +/// second**: `-0.500` and `-0.875` read `axisErr=0.000` after a seek, `-1.000` and everything above +/// it survive one. AVPlayer discards a sub-second axis and snaps back to the playlist. /// -/// | arm | gate | published shift | picture vs item axis | picture vs `sourceTime` | -/// | --- | --- | --- | --- | --- | -/// | before AE#408 | late, pinned | +3.000 | +3.000 | +0.075 | -/// | AE#408 as shipped | early, 38.417 | 0.000 | -13.583 | -13.550 | -/// | with this change | early, 38.417 | -13.583 | -13.583 | -0.009 | +/// Measured on `tc-cues-lie.mkv` (Cues injected at non-sync positions, 12 s keyframe drought at 43 s), +/// `capErr` being the error a host placing a cue at `sourceTime` would make: +/// +/// | case | round 1 | round 2 | +/// | --- | --- | --- | +/// | resume 53, seek to 80 (the reporter's shape) | `-8.983` | `+0.017` | +/// | resume 53, seek to 65 (re-places the anchor) | `-8.983` | `+0.017` | +/// | resume 53, seek to 60 (restart re-aims again) | `-8.983` | `+0.017` | +/// | resume 10, burst (sub-second, snaps) | `-0.008` | `+0.017` | struct Issue418ReaimedGateAxisTests { // The reporter's `#65 ledger`, in the millisecond time base its lines are printed in. // Each row is (advertised boundary, where the gate actually opened, the drift he tabulated). private static let ledger: [(boundary: Int64, gateOpenedAt: Int64, drift: Int64)] = [ (352_936, 349_891, -3_045), // session 1 + 2, the resume at 354.0 - (676_134, 662_078, -14_056), // session 1, seek to 682.0, re-aimed 4s, 8s, 16s + (676_134, 662_078, -14_056), // session 1, seek to 682.0, re-aimed 4s, 8s, 12s, 16s (972_847, 969_802, -3_045), // session 1, seek to 992.0 (1_084_375, 1_083_332, -1_043), // session 1, seek to 1102.0 (512_053, 508_925, -3_128), // session 2, seek to 519.3 @@ -61,10 +66,8 @@ struct Issue418ReaimedGateAxisTests { @Test("a gate that opened early publishes a negative shift, which is what was missing") func earlyGatePublishesNegative() { - // The harness case: boundary 52.000 s, gate re-aimed three times and opened at 38.417 s. - let published = HLSSegmentProducer.presentedShiftPts( - actualFirstDts: 38_417, desiredTfdtPts: 52_000) - #expect(published == -13_583) + // The harness case: boundary 52.000 s, gate re-aimed and opened at 43.000 s. + #expect(HLSSegmentProducer.presentedShiftPts(actualFirstDts: 43_000, desiredTfdtPts: 52_000) == -9_000) } @Test("a gate that opened exactly on its boundary publishes nothing") @@ -82,59 +85,118 @@ struct Issue418ReaimedGateAxisTests { @Test("an unresolved timestamp publishes nothing rather than a garbage offset") func unresolvedPublishesZero() { #expect(HLSSegmentProducer.presentedShiftPts(actualFirstDts: .min, desiredTfdtPts: 52_000) == 0) - #expect(HLSSegmentProducer.presentedShiftPts(actualFirstDts: 38_417, desiredTfdtPts: .min) == 0) + #expect(HLSSegmentProducer.presentedShiftPts(actualFirstDts: 43_000, desiredTfdtPts: .min) == 0) } - // MARK: - The offset belongs to the run, not to the timeline + // MARK: - The offset composes when a segment is placed + + @Test("the first placement of a session establishes the axis") + func firstPlacementEstablishesTheAxis() { + #expect(HLSVideoEngine.axisShift(after: 0, placing: -9.0) == -9.0) + } - @Test("a run beginning on the segment the gate opened into carries that offset") - func runOnTheAnchorCarriesTheOffset() { - let shift = HLSVideoEngine.axisShiftForRun( - beginningAt: 13, anchorIndex: 13, anchorShiftSeconds: -13.583) - #expect(shift == -13.583) + @Test("an axis-true segment leaves the axis where it is") + func axisTrueSegmentChangesNothing() { + // The reporter's seek burst: seg179 was cut on its own boundary, and the run kept -14.056. + #expect(HLSVideoEngine.axisShift(after: -14.056, placing: 0) == -14.056) } - @Test("a run beginning anywhere else is worth its advertised position") - func runElsewhereIsAxisTrue() { - // The measured case: the seek left the loaded region, no restart followed, and AVPlayer - // anchored on seg11, which a previous pump had cut on its own boundary. - #expect(HLSVideoEngine.axisShiftForRun( - beginningAt: 11, anchorIndex: 2, anchorShiftSeconds: -0.875) == 0) - #expect(HLSVideoEngine.axisShiftForRun( - beginningAt: 14, anchorIndex: 13, anchorShiftSeconds: -13.583) == 0) + @Test("re-placing the segment the gate opened into adds its offset again") + func rePlacingTheAnchorComposes() { + // Measured: resume 53 reads -9.000, and a seek that re-fetches that same segment reads -18.000. + #expect(HLSVideoEngine.axisShift(after: -9.0, placing: -9.0) == -18.0) + // And at the deepest tier of the tiered fixture, -11.000 -> -22.000. + #expect(HLSVideoEngine.axisShift(after: -11.0, placing: -11.0) == -22.0) } - @Test("with no epoch on record a run inherits nothing") - func noAnchorOnRecord() { - #expect(HLSVideoEngine.axisShiftForRun( - beginningAt: 0, anchorIndex: .min, anchorShiftSeconds: 0) == 0) + @Test("a later epoch's own re-aim composes onto what the timeline already carries") + func newEpochComposesOntoTheOldAxis() { + // Measured: a -9.000 run, seek to 60, producer restarts at seg12 and re-aims 5 s, reads -14.000. + #expect(HLSVideoEngine.axisShift(after: -9.0, placing: -5.0) == -14.0) + // Tiered fixture: a -11.000 run whose seek restarts at seg23 re-aiming 7 s reads -18.000. + #expect(HLSVideoEngine.axisShift(after: -11.0, placing: -7.0) == -18.0) } - // MARK: - What counts as the beginning of a run + // MARK: - Where the new axis starts applying - @Test("the next index in sequence continues the run") - func sequentialFetchContinuesTheRun() { - #expect(!VideoSegmentProvider.beginsFreshDecodeRun(index: 12, previousTarget: 11)) + @Test("the seam is the advertised start read through the axis already in effect") + func seamIsReadThroughTheOldAxis() { + // seg12 advertised at 48.0 landing on a timeline that already carries -9.0 begins at item 57.0, + // which is where the picture probe found its first frame. + #expect(HLSVideoEngine.seamItemSeconds(advertisedStart: 48.0, currentShift: -9.0) == 57.0) } - @Test("the same index again is a retry, not a new run") - func repeatedFetchIsARetry() { - // A republished axis mid-run would move the clock under a picture that never changed. - #expect(!VideoSegmentProvider.beginsFreshDecodeRun(index: 11, previousTarget: 11)) + @Test("on a fresh timeline the seam is the advertised start itself") + func seamOnAFreshTimeline() { + #expect(HLSVideoEngine.seamItemSeconds(advertisedStart: 52.0, currentShift: 0) == 52.0) + } + + // MARK: - What a producer restart does to the record + + @Test("a new epoch drops what older epochs claimed at and above its own index") + func newEpochDropsTheIndicesItRewrites() { + let table = HLSVideoEngine.epochShiftTable([11: -0.875, 13: -9.0], recordingEpochAt: 12, shift: -5.0) + #expect(table == [11: -0.875, 12: -5.0]) + // seg13 is now cut on its own boundary by the new producer, so claiming -9.0 for it would be + // the table-shaped mistake round 1 avoided by keeping a single pair. + #expect(table[13] == nil) + } + + @Test("an epoch that opened on its boundary records nothing") + func exactEpochRecordsNothing() { + #expect(HLSVideoEngine.epochShiftTable([13: -9.0], recordingEpochAt: 20, shift: 0) == [13: -9.0]) } - @Test("a jump in either direction begins a run") - func jumpBeginsARun() { - #expect(VideoSegmentProvider.beginsFreshDecodeRun(index: 40, previousTarget: 11)) - #expect(VideoSegmentProvider.beginsFreshDecodeRun(index: 3, previousTarget: 11)) - // One short of contiguous is still a jump: the segment between them was never fetched. - #expect(VideoSegmentProvider.beginsFreshDecodeRun(index: 13, previousTarget: 11)) + @Test("segments below a restart keep the offset their bytes still carry") + func segmentsBelowARestartAreUntouched() { + let table = HLSVideoEngine.epochShiftTable([2: -0.875], recordingEpochAt: 13, shift: -9.0) + #expect(table == [2: -0.875, 13: -9.0]) + } + + // MARK: - What a seek does to the axis + + @Test("a sub-second axis does not survive a seek") + func subSecondAxisSnaps() { + // Measured: -0.500 and -0.875 both read axisErr 0.000 after a seek. + #expect(HLSVideoEngine.axisShiftAfterSeek(-0.5) == 0) + #expect(HLSVideoEngine.axisShiftAfterSeek(-0.875) == 0) + #expect(HLSVideoEngine.axisShiftAfterSeek(0.375) == 0) + } + + @Test("a second or more survives a seek unchanged") + func secondOrMoreSurvivesASeek() { + // Measured, every one of them across a seek: -1.000, -1.083, -1.292, -1.500, -3.000, + // -4.000, -7.000, -9.000, -11.000. The boundary sits between -0.875 and -1.000. + for shift in [-1.0, -1.083, -1.292, -1.5, -3.0, -4.0, -7.0, -9.0, -11.0, -22.0] { + #expect(HLSVideoEngine.axisShiftAfterSeek(shift) == shift) + } + } + + @Test("an axis of zero stays zero") + func zeroStaysZero() { + #expect(HLSVideoEngine.axisShiftAfterSeek(0) == 0) + } + + // MARK: - What counts as a placement + + @Test("every fetch places a segment, whatever its order") + func everyFetchIsAPlacement() { + #expect(VideoSegmentProvider.placesSegmentAnew(index: 12, previousTarget: 11)) + // The reporter's burst: seg179 after seg169, which round 1 called a fresh run and this calls + // what it is, a placement of a segment worth nothing. + #expect(VideoSegmentProvider.placesSegmentAnew(index: 179, previousTarget: 169)) + #expect(VideoSegmentProvider.placesSegmentAnew(index: 3, previousTarget: 11)) + } + + @Test("the same index again is a retry of one placement, not a second one") + func repeatedFetchIsARetry() { + // Counting it twice would move the axis by an offset AVPlayer applied once; the restart path + // serves exactly this shape (`fetch seg12 prev=seg12` right after a producer rebuild). + #expect(!VideoSegmentProvider.placesSegmentAnew(index: 11, previousTarget: 11)) } - @Test("the session's first fetch begins a run") - func firstFetchBeginsARun() { - // `cache.targetIndex` before any declaration, so the resume segment is an anchor and gets - // the epoch's offset published against it. - #expect(VideoSegmentProvider.beginsFreshDecodeRun(index: 13, previousTarget: -1)) + @Test("the session's first fetch is a placement") + func firstFetchIsAPlacement() { + #expect(VideoSegmentProvider.placesSegmentAnew(index: 13, previousTarget: -1)) } }