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)) } }