diff --git a/Sources/AetherEngine/AetherEngine.swift b/Sources/AetherEngine/AetherEngine.swift index affe0305..410218f3 100644 --- a/Sources/AetherEngine/AetherEngine.swift +++ b/Sources/AetherEngine/AetherEngine.swift @@ -263,6 +263,17 @@ public final class AetherEngine: ObservableObject { setDeferredSeek(inFlight: false, target: nil) } + /// AE#412: run `preparedSeekLanding` off the main actor. It parks on the pump while a re-cut + /// opens its gate, and a seek must not hold the main actor for that (AE#422). + private static func prepareSeekLanding( + session: HLSVideoEngine?, itemSeconds: Double + ) async -> Double { + guard let session else { return itemSeconds } + return await Task.detached(priority: .userInitiated) { + session.preparedSeekLanding(itemSeconds: itemSeconds) + }.value + } + /// Rejection path: the seek never reached a host, so it gets a standalone event and no `.began`. func emitSeekRejected(_ reason: SeekEvent.Rejection, target: Double) { emitSeekEvent(id: nextSeekEventID(), origin: .programmatic, outcome: .rejected(reason), target: target) @@ -3914,7 +3925,7 @@ public final class AetherEngine: ObservableObject { // STC base so `target` (0-based, matching duration) lands on the source-PTS shift the producer subtracts, // 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 + var 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 @@ -3922,6 +3933,17 @@ public final class AetherEngine: ObservableObject { // still had when the seek was issued; from the landing forward the clock describes the axis // it will have instead. nativeVideoSession?.snapAxisAfterSeek(landingItemSeconds: clockTarget) + // AE#412: inside a keyframe drought, audio opened plan boundaries the keyframe-gated cutter + // folded, so the landing segment can carry no random-access point at all. AVPlayer reaches + // back a fixed few seconds on a cold seek and does not look for one, so the picture would + // start at the next sync sample ABOVE the target and the seek would silently skip content + // (measured: a seek to 50.0 s landed at 55.0 s on a 12 s drought). This re-cuts that segment + // from its covering random-access point so it covers the target before the seek goes out. + // The target itself is unchanged: AVPlayer places the re-cut segment at its own tfdt inside + // the timeline it is already building. Off the main actor: it parks on the pump. + clockTarget = await Self.prepareSeekLanding( + session: nativeVideoSession, itemSeconds: clockTarget) + guard loadGeneration == loadGen, seekGeneration == seekGen else { return } 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/HLSSegmentProducer.swift b/Sources/AetherEngine/Video/HLSSegmentProducer.swift index 02bd56c8..222dc7b3 100644 --- a/Sources/AetherEngine/Video/HLSSegmentProducer.swift +++ b/Sources/AetherEngine/Video/HLSSegmentProducer.swift @@ -416,6 +416,11 @@ final class HLSSegmentProducer: @unchecked Sendable { /// Live segment index captured when pending packet was examined; the live cutter advances at keyframes. private var pendingVideoSegIndex: Int = 0 + /// AE#412: item-axis timestamp of the FIRST random-access point routed into each segment this + /// epoch has open, consumed at adopt. Keyed by the muxer's index, not the cutter's: audio can + /// have advanced the muxer past a boundary the keyframe-gated cutter folded, and a fetch asks + /// for the segment the bytes ended up in. Pump thread only, like every other routing field. + private var firstSyncItemPtsBySegment: [Int: Int64] = [:] private var pendingAudioSegIndex: Int = 0 /// VOD keyframe-gated cutter: opens each segment at the IRAP that reaches its plan boundary (#92). @@ -2022,7 +2027,8 @@ final class HLSSegmentProducer: @unchecked Sendable { ) cache.adopt(index: currentMuxerSegmentIndex, stagingPath: path, - byteCount: bytesWritten) + byteCount: bytesWritten, + videoReach: takeVideoReach(forSegmentIndex: currentMuxerSegmentIndex)) // AE#286: per-epoch head. cache.highestStoredIndex is monotonic across restarts and would // credit this pump with the previous epoch's production. pumpEpochHighestStored = max(pumpEpochHighestStored, currentMuxerSegmentIndex) @@ -2201,6 +2207,41 @@ final class HLSSegmentProducer: @unchecked Sendable { onLiveSegmentFinalized?(index, duration, startSeconds, discontinuous) } + /// AE#412: what the segment finalized at `index` offers a cold arrival, as an offset from its + /// ADVERTISED start, so the answer carries no axis with it. Consumes the recording. + /// + /// nil means "no claim", which is exactly today's behaviour: live (its playlist only ever offers + /// what was finalized), an unresolved video time base, or an index outside this epoch's plan. + /// `.none` is a claim, and a load-bearing one: audio opened a boundary the cutter folded, so the + /// segment carries no random-access point at all and nothing in it can start a decode run. + private func takeVideoReach(forSegmentIndex index: Int) -> SegmentCache.VideoReach? { + let syncPts = firstSyncItemPtsBySegment.removeValue(forKey: index) + firstSyncItemPtsBySegment = firstSyncItemPtsBySegment.filter { $0.key > index } + guard !isLive, sourceVideoTbSeconds > 0 else { return nil } + let localI = index - baseIndex + guard localI >= 0, localI < segmentBoundaries.count else { return nil } + guard let syncPts, syncPts != Int64.min else { + EngineLog.emit( + "[HLSSegmentProducer] #412 seg-\(index) carries no random-access point " + + "(advertised \(String(format: "%.3f", Double(segmentBoundaries[localI]) * sourceVideoTbSeconds))s); " + + "a cold arrival cannot start a decode run in it", + category: .session + ) + return SegmentCache.VideoReach.none + } + let shiftTicks = videoShiftPts == Int64.min ? 0 : videoShiftPts + let syncSourcePts = syncPts &+ shiftTicks + let offset = Double(syncSourcePts &- segmentBoundaries[localI]) * sourceVideoTbSeconds + if offset > 0 { + EngineLog.emit( + "[HLSSegmentProducer] #412 seg-\(index) opens \(String(format: "%.3f", offset))s " + + "below its first random-access point; a cold arrival below that lands late", + category: .session, level: .verbose + ) + } + return .syncAt(offsetSeconds: offset) + } + private func finalizeSessionMuxerAndAdopt() { guard let muxer = currentMuxer else { return } let idx = currentMuxerSegmentIndex @@ -2210,7 +2251,8 @@ final class HLSSegmentProducer: @unchecked Sendable { category: .session, level: .verbose ) cache.adopt(index: idx, stagingPath: result.path, - byteCount: result.bytesWritten) + byteCount: result.bytesWritten, + videoReach: takeVideoReach(forSegmentIndex: idx)) if isLive { reportLiveSegmentFinalized(index: idx, nextIndex: nil) } else if onSequentialSegmentFinalized != nil { @@ -3543,6 +3585,17 @@ final class HLSSegmentProducer: @unchecked Sendable { ) } if let muxer = ensureMuxer(forSegmentIndex: prevSeg) { + // AE#412: a cold arrival can only start a decode run on a random-access + // point, so what a segment is worth to one is where its first sync sample + // sits. Recorded here, against the muxer's own index, because that is the + // segment these bytes are actually in. + if !isLive, (prev.pointee.flags & AV_PKT_FLAG_KEY) != 0 { + let openIdx = muxer.currentSegmentIndex + if firstSyncItemPtsBySegment[openIdx] == nil { + firstSyncItemPtsBySegment[openIdx] = + prev.pointee.dts != Int64.min ? prev.pointee.dts : prev.pointee.pts + } + } finalizeAndWriteVideo(prev, nextDts: packet.pointee.dts, muxer: muxer) bumpPacketsWritten() } else { diff --git a/Sources/AetherEngine/Video/HLSVideoEngine.swift b/Sources/AetherEngine/Video/HLSVideoEngine.swift index a2cd1aba..269eb9c4 100644 --- a/Sources/AetherEngine/Video/HLSVideoEngine.swift +++ b/Sources/AetherEngine/Video/HLSVideoEngine.swift @@ -390,6 +390,17 @@ public final class HLSVideoEngine: @unchecked Sendable { /// 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 + /// AE#412: the index the most recent gate open belongs to and what it was worth. Separate from + /// `epochShiftByIndex`, which deliberately keeps no entry for a zero offset, so it cannot answer + /// "has this epoch opened yet" at all. Signalled through `gateOpenCondition`. + private var lastGateOpen: (index: Int, shift: Double)? + private let gateOpenCondition = NSCondition() + /// AE#412: indices whose epoch an AE#412 re-cut started. Such a segment goes into a timeline + /// AVPlayer is already building, and it puts it at its own tfdt there (measured 3 of 3 with + /// `play --picture-probe`: `axisErr` 0.000 at offsets of 1, 5 and 9 s), so its distance to its + /// advertised start is not an axis offset and must not compose into one. Guarded by + /// `anchorShiftLock`, alongside the table it keeps entries out of. + private var recutIndices: Set = [] private let shiftLock = NSLock() private var _playlistShiftSeconds: Double = 0 @@ -2242,10 +2253,18 @@ public final class HLSVideoEngine: @unchecked Sendable { // epoch AVPlayer never fetches from must not move the clock at all. let index = segmentIndexForPlaylistTime(seamItemSeconds) anchorShiftLock.lock() + // AE#412: a re-cut opens below its boundary on purpose, and AVPlayer places what it produces + // at its own tfdt, so the epoch is worth nothing to the axis. Recording zero still drops the + // entries at and above it, which is what the rewrite calls for. + let isRecut = recutIndices.remove(index) != nil epochShiftByIndex = Self.epochShiftTable( - epochShiftByIndex, recordingEpochAt: index, shift: seconds) + epochShiftByIndex, recordingEpochAt: index, shift: isRecut ? 0 : seconds) let placementAlreadyHappened = lastPlacedIndex == index anchorShiftLock.unlock() + gateOpenCondition.lock() + lastGateOpen = (index: index, shift: seconds) + gateOpenCondition.broadcast() + gateOpenCondition.unlock() if placementAlreadyHappened { handleSegmentPlaced(at: index) } @@ -2341,6 +2360,151 @@ public final class HLSVideoEngine: @unchecked Sendable { publishPlaylistShift(snapped, seamItemSeconds: landingItemSeconds) } + /// AE#412: how long to give a re-cut its gate open before the seek goes out without it. A restart + /// is a demuxer seek plus a scan to the covering random-access point; past this the seek is worth + /// more than the correction, and the landing degrades to what it does today. + static let recutGateWaitSeconds: TimeInterval = 2.0 + + /// AE#412: how far below a cold seek target AVPlayer is assumed to reach for a random-access + /// point. It reaches back a fixed span and does NOT search for one: measured with + /// `play --picture-probe` on a 4 s grid, the first fetch of a cold seek was the segment holding + /// `target - 8 s` in one run shape and `target - 6 s` in another, on the case fixture and on a + /// control whose every segment is independent alike. 4 s is deliberately below both, so a landing + /// AVPlayer would have managed on its own is never re-cut; the cost of the margin is a re-cut + /// that was not strictly needed, not a landing that is wrong. + static let coldSeekLookbackSeconds: Double = 4.0 + + /// AE#412: the item position to seek AVPlayer to, having first made sure the segment it lands in + /// can start a decode run there. + /// + /// Audio routes packets by plan boundary while video routes them keyframe-gated, so inside a + /// keyframe drought audio opens boundaries the cutter folded and those segments carry no + /// random-access point at all. Because AVPlayer's reach back is fixed rather than a search, a + /// drought wider than that reach leaves the picture starting at the next sync sample ABOVE the + /// target, and the seek silently skips content. Measured on a 12 s drought, seeking back from + /// 88 s: a seek to 50.0 s landed at 55.0 s and one to 54.0 s at 54.96 s, while the same source cut + /// on its real sync samples landed exactly on every target tested. + /// + /// The repair is the producer's own gate: a restart anchored at that index opens on the covering + /// random-access point, so the segment begins at or below its advertised start and covers the + /// target. The seek target is NOT moved to compensate: measured 3 of 3, AVPlayer puts the re-cut + /// segment at its own tfdt inside the timeline it is already building (`axisErr` 0.000), so item + /// time is source time and a correction would land exactly that far past the target instead. + /// + /// Runs off the main actor (it parks on the pump). Returns `itemSeconds` unchanged whenever there + /// is nothing to act on: live, no recorded reach for the landing segment, a random-access point + /// already within reach below the target, or a re-cut whose gate did not open in time. + func preparedSeekLanding(itemSeconds: Double) -> Double { + guard !isLiveSession, let provider else { return itemSeconds } + let index = segmentIndexForPlaylistTime(itemSeconds) + guard let reach = provider.videoReach(at: index) else { return itemSeconds } + guard let advertised = advertisedStartSeconds(index) else { return itemSeconds } + let covering = coveringSyncItemSeconds(atOrBelow: itemSeconds, from: index, provider: provider) + guard Self.needsRecut(landingReach: reach, + offsetIntoSegment: itemSeconds - advertised, + coveringSyncDistance: covering.map { itemSeconds - $0 }) else { + return itemSeconds + } + EngineLog.emit( + "[HLSVideoEngine] #412 seek to item \(String(format: "%.3f", itemSeconds))s lands in seg\(index) " + + "(advertised \(String(format: "%.3f", advertised))s), which cannot start a decode run there " + + "and no random-access point is within reach below it; re-cutting from the covering one", + category: .session + ) + gateOpenCondition.lock() + lastGateOpen = nil + gateOpenCondition.unlock() + anchorShiftLock.lock() + recutIndices.insert(index) + anchorShiftLock.unlock() + requestRestart(at: index, authoritative: true) + guard let shift = awaitGateOpen(forIndex: index, timeout: Self.recutGateWaitSeconds) else { + anchorShiftLock.lock() + recutIndices.remove(index) + anchorShiftLock.unlock() + EngineLog.emit( + "[HLSVideoEngine] #412 seg\(index) re-cut did not open a gate within " + + "\(String(format: "%.1f", Self.recutGateWaitSeconds))s; seeking on the uncorrected position", + category: .session + ) + return itemSeconds + } + EngineLog.emit( + "[HLSVideoEngine] #412 seg\(index) re-cut opened \(String(format: "%.3f", shift))s below its " + + "boundary and now covers item \(String(format: "%.3f", itemSeconds))s", + category: .session + ) + return itemSeconds + } + + /// AE#412 pure decision: whether the segment a cold seek lands in has to be re-cut before the + /// seek goes out. + /// + /// Three ways it does not. The segment carries a random-access point at or below the target, so + /// it opens a run there itself. Or one sits close enough below the target that AVPlayer's fixed + /// reach back picks it up out of an earlier segment, which is the case that lands exactly today. + /// Anything else leaves the picture starting above the target, which is the defect. + /// + /// `coveringSyncDistance` is how far BELOW the target the nearest known random-access point sits; + /// nil when none is known down there. + static func needsRecut( + landingReach: SegmentCache.VideoReach, + offsetIntoSegment: Double, + coveringSyncDistance: Double? + ) -> Bool { + if landingReach.serves(offsetSeconds: offsetIntoSegment) { return false } + guard let coveringSyncDistance else { return true } + return coveringSyncDistance > coldSeekLookbackSeconds + } + + /// `segmentPlan[index].startSeconds` on the AVPlayer/playlist axis, taking `restartLock` itself. + private func advertisedStartSeconds(_ index: Int) -> Double? { + restartLock.lock() + defer { restartLock.unlock() } + guard index >= 0, index < segmentPlan.count else { return nil } + return segmentPlan[index].startSeconds + } + + /// AE#412: the highest recorded random-access point at or below `itemSeconds`, walking down from + /// `index` until one segment starts below AVPlayer's reach. nil when nothing down there carries + /// one, and also when a walked segment made no claim: an unrecorded segment might well carry a + /// sync sample, and re-cutting on a guess would cost a restart on a landing that works today. + private func coveringSyncItemSeconds( + atOrBelow itemSeconds: Double, from index: Int, provider: VideoSegmentProvider + ) -> Double? { + var best: Double? + var j = index + while j >= 0 { + guard let advertised = advertisedStartSeconds(j) else { return best } + guard let reach = provider.videoReach(at: j) else { return itemSeconds } + if case .syncAt(let offset) = reach { + let sync = advertised + offset + if sync <= itemSeconds { best = max(best ?? sync, sync) } + } + if advertised <= itemSeconds - Self.coldSeekLookbackSeconds { return best } + j -= 1 + } + return best + } + + /// Blocks until the epoch anchored at `index` has opened its video gate, and answers what it + /// opened worth. nil on timeout. Polls against the condition rather than nesting locks: the pump + /// records under `anchorShiftLock` and signals under `gateOpenCondition`, and taking them in the + /// other order here would be the classic inversion. + private func awaitGateOpen(forIndex index: Int, timeout: TimeInterval) -> Double? { + let deadline = Date().addingTimeInterval(timeout) + while true { + gateOpenCondition.lock() + if let open = lastGateOpen, open.index == index { + gateOpenCondition.unlock() + return open.shift + } + let signalled = gateOpenCondition.wait(until: deadline) + gateOpenCondition.unlock() + if !signalled, Date() >= deadline { return nil } + } + } + private func publishPlaylistShift(_ seconds: Double, seamItemSeconds: Double) { setPlaylistShiftSeconds(seconds) // Refresh every native subtitle store's shift so cuesInWindow stays on the correct AVPlayer diff --git a/Sources/AetherEngine/Video/SegmentCache.swift b/Sources/AetherEngine/Video/SegmentCache.swift index c0e2af5c..72c63b14 100644 --- a/Sources/AetherEngine/Video/SegmentCache.swift +++ b/Sources/AetherEngine/Video/SegmentCache.swift @@ -9,6 +9,25 @@ import Foundation // across the producer/provider threads and capture in @Sendable closures. final class SegmentCache: @unchecked Sendable { + /// AE#412: where a stored segment's first random-access point sits, as an offset from the + /// segment's ADVERTISED start (its plan boundary). An offset, not an absolute time, so it is + /// independent of the item / source / display axes and survives an epoch that opened early. + /// + /// `<= 0` means the segment opens on a sync sample and serves any position inside it. A positive + /// offset means the first sync sample sits that far in, so only positions at or after it can + /// start a decode run. `.none` means the segment carries no sync sample at all: audio cut it on + /// a plan boundary the keyframe-gated cutter had folded, so nothing in it can start one. + enum VideoReach: Equatable, Sendable { + case syncAt(offsetSeconds: Double) + case none + + /// Whether a cold arrival aiming `offsetSeconds` into this segment can be served from it. + func serves(offsetSeconds: Double) -> Bool { + guard case .syncAt(let syncOffset) = self else { return false } + return syncOffset <= offsetSeconds + } + } + private let condition = NSCondition() private let forwardWindow: Int @@ -55,6 +74,10 @@ final class SegmentCache: @unchecked Sendable { /// Plan index -> how many pumps passed it without opening a segment (#358). Survives producer /// restarts on purpose: the repeat across a restart is the signal. private var foldCounts: [Int: Int] = [:] + /// AE#412: what a stored segment's video is worth to a COLD arrival, per index. Absent = the + /// producer did not record it (live, or an unresolved time base), and callers must treat that + /// as "no claim" rather than as bad news. + private var videoReaches: [Int: VideoReach] = [:] /// #369: log-classification threshold: a run wider than this is a discontinuity-scale cut /// leap, not a long GOP. (It used to DROP such runs from the counters on the assumption they /// were repositions; the field case was a 2^33 wrap folding 312 indices, and dropping it left @@ -171,7 +194,10 @@ final class SegmentCache: @unchecked Sendable { } /// Adopt a staging file via rename(2). Page cache pages stay warm; skips a Swift Data round trip. - func adopt(index: Int, stagingPath: URL, byteCount: Int) { + /// + /// `videoReach` (AE#412) is what this segment's video offers a cold arrival; nil leaves the + /// previous claim in place only if the index is re-adopted without one, which no caller does. + func adopt(index: Int, stagingPath: URL, byteCount: Int, videoReach: VideoReach? = nil) { let fileURL = sessionDir.appendingPathComponent("seg-\(index).m4s") let renameOK: Bool do { @@ -204,6 +230,9 @@ final class SegmentCache: @unchecked Sendable { // A later epoch produced what an earlier one passed over: a re-anchor moved the // boundaries and this index is no longer a hole (#358). foldCounts.removeValue(forKey: index) + // AE#412: the claim describes THESE bytes, so a re-adoption replaces it, and an + // adoption that cannot state one must not leave the old epoch's claim standing. + videoReaches[index] = videoReach } let doomed = pruneOutsideWindow() condition.broadcast() @@ -217,6 +246,7 @@ final class SegmentCache: @unchecked Sendable { let dir = sessionDir entries.removeAll(keepingCapacity: false) entryBytes.removeAll(keepingCapacity: false) + videoReaches.removeAll(keepingCapacity: false) initSegment = nil initVersions.removeAll(keepingCapacity: false) _totalBytes = 0 @@ -373,6 +403,16 @@ final class SegmentCache: @unchecked Sendable { return foldCounts[index] ?? 0 } + /// AE#412: what the stored segment at `index` offers a cold arrival, or nil when nothing was + /// recorded for it (live, or a producer that could not resolve its time base). A caller must not + /// read nil as "unreachable": an unrecorded segment is exactly today's behaviour, not a defect. + func videoReach(_ index: Int) -> VideoReach? { + condition.lock() + defer { condition.unlock() } + guard entries[index] != nil else { return nil } + return videoReaches[index] + } + /// Record plan indices a cut jumped over. VOD only: a live playlist is built from what was /// finalized, so it never offers an index the pump skipped. /// #369: runs wider than `maxFoldRunLength` count too, the #358 arms exist precisely for a @@ -547,6 +587,7 @@ final class SegmentCache: @unchecked Sendable { _totalBytes -= bytes entryBytes.removeValue(forKey: k) entries.removeValue(forKey: k) + videoReaches.removeValue(forKey: k) doomed.append(url) } } @@ -557,6 +598,7 @@ final class SegmentCache: @unchecked Sendable { _totalBytes -= entryBytes[k] ?? byteSize(of: url) entryBytes.removeValue(forKey: k) entries.removeValue(forKey: k) + videoReaches.removeValue(forKey: k) doomed.append(url) } } diff --git a/Sources/AetherEngine/Video/VideoSegmentProvider.swift b/Sources/AetherEngine/Video/VideoSegmentProvider.swift index d4354655..50ba6043 100644 --- a/Sources/AetherEngine/Video/VideoSegmentProvider.swift +++ b/Sources/AetherEngine/Video/VideoSegmentProvider.swift @@ -657,6 +657,12 @@ final class VideoSegmentProvider: HLSSegmentProvider, @unchecked Sendable { return (index, cache.foldCount(index)) } + /// AE#412: what the stored segment at `index` offers a cold arrival, or nil when the producer + /// made no claim for it. See `SegmentCache.VideoReach`. + func videoReach(at index: Int) -> SegmentCache.VideoReach? { + return cache.videoReach(index) + } + /// AE#421: whether the segment for `index` is already on disk, answered without reading it. /// /// This is what separates the two repairs for a wedge. A producer re-anchor is the fix for a diff --git a/Tests/AetherEngineTests/Issue412VideoReachTests.swift b/Tests/AetherEngineTests/Issue412VideoReachTests.swift new file mode 100644 index 00000000..96e9f4ba --- /dev/null +++ b/Tests/AetherEngineTests/Issue412VideoReachTests.swift @@ -0,0 +1,173 @@ +import Foundation +import Testing +@testable import AetherEngine + +/// AE#412: what a stored segment is worth to a COLD arrival. +/// +/// Audio routes packets by plan boundary while video routes them keyframe-gated, so inside a +/// keyframe drought audio opens boundaries the cutter folded, and those segments carry no +/// random-access point. AVPlayer reaches back a fixed few seconds on a cold seek and does not search +/// for one, so a target below the next sync sample lands late and silently skips content. +/// +/// Measured on a 12 s drought (`Scripts/timecode-fixture.sh` + `Scripts/mkv-cue-fixture.py`, seek from +/// 88 s back): a seek to 50.0 s landed at 55.0 s and a seek to 54.0 s at 54.96 s, while the same +/// source cut on its real sync samples landed exactly on all three targets tested. +@Suite("AE#412 segment video reach") +struct Issue412VideoReachTests { + + private func makeData(_ n: Int) -> Data { Data(repeating: 0xAA, count: n) } + + private func adopt(_ cache: SegmentCache, index: Int, + reach: SegmentCache.VideoReach?) throws { + let staging = cache.sessionDir.appendingPathComponent("staging-\(index).tmp") + try makeData(64).write(to: staging) + cache.adopt(index: index, stagingPath: staging, byteCount: 64, videoReach: reach) + } + + @Test("A segment opening on a sync sample serves every position inside it") + func opensOnSyncServesEverything() { + let reach = SegmentCache.VideoReach.syncAt(offsetSeconds: 0) + #expect(reach.serves(offsetSeconds: 0)) + #expect(reach.serves(offsetSeconds: 3.9)) + } + + @Test("A sync sample partway in serves only from there on") + func syncPartwayIn() { + // seg10 of the measured fixture: advertised 40.0, first sync at 43.0. + let reach = SegmentCache.VideoReach.syncAt(offsetSeconds: 3.0) + #expect(!reach.serves(offsetSeconds: 0)) + #expect(!reach.serves(offsetSeconds: 2.999)) + #expect(reach.serves(offsetSeconds: 3.0)) + #expect(reach.serves(offsetSeconds: 3.5)) + } + + @Test("A segment with no random-access point serves nothing") + func noSyncServesNothing() { + // seg11 and seg12 of the measured fixture: audio cut them on boundaries the cutter folded. + let reach = SegmentCache.VideoReach.none + #expect(!reach.serves(offsetSeconds: 0)) + #expect(!reach.serves(offsetSeconds: 3.9)) + } + + @Test("A gate that opened BELOW its boundary serves its whole segment") + func negativeOffsetServesEverything() { + // What a re-cut produces: the covering random-access point sits below the advertised start. + let reach = SegmentCache.VideoReach.syncAt(offsetSeconds: -5.0) + #expect(reach.serves(offsetSeconds: 0)) + #expect(reach.serves(offsetSeconds: 2.0)) + } + + @Test("The cache hands back what was adopted, per index") + func cacheRoundTrip() throws { + let cache = SegmentCache(forwardWindow: 5, backwardWindow: 5) + defer { cache.close() } + try adopt(cache, index: 10, reach: .syncAt(offsetSeconds: 3.0)) + try adopt(cache, index: 11, reach: SegmentCache.VideoReach.none) + #expect(cache.videoReach(10) == .syncAt(offsetSeconds: 3.0)) + #expect(cache.videoReach(11) == SegmentCache.VideoReach.none) + } + + @Test("An index nobody stored has no claim, and neither has one adopted without one") + func absentIsNoClaim() throws { + let cache = SegmentCache(forwardWindow: 5, backwardWindow: 5) + defer { cache.close() } + #expect(cache.videoReach(7) == nil) + try adopt(cache, index: 7, reach: nil) + #expect(cache.videoReach(7) == nil) + } + + /// The claim describes the BYTES, so an epoch that rewrites an index must not inherit the + /// previous epoch's answer: a re-cut turns exactly this `.none` into a servable segment, and a + /// stale claim would make the repair look like it never happened. + @Test("Re-adopting an index replaces its claim") + func readoptReplacesClaim() throws { + let cache = SegmentCache(forwardWindow: 5, backwardWindow: 5) + defer { cache.close() } + try adopt(cache, index: 11, reach: SegmentCache.VideoReach.none) + #expect(cache.videoReach(11) == SegmentCache.VideoReach.none) + try adopt(cache, index: 11, reach: .syncAt(offsetSeconds: -1.0)) + #expect(cache.videoReach(11) == .syncAt(offsetSeconds: -1.0)) + } + + /// A claim outliving its bytes would answer for a segment that is no longer there. + @Test("Pruning an entry drops its claim with it") + func pruneDropsClaim() throws { + let cache = SegmentCache(forwardWindow: 1, backwardWindow: 1) + defer { cache.close() } + cache.declareTarget(10) + try adopt(cache, index: 10, reach: SegmentCache.VideoReach.none) + #expect(cache.videoReach(10) == SegmentCache.VideoReach.none) + cache.declareTarget(50) + #expect(cache.peekURL(index: 10) == nil) + #expect(cache.videoReach(10) == nil) + } +} + +/// AE#412: when a cold seek's landing segment has to be re-cut. +/// +/// The offsets in these cases are the ones measured on the 12 s-drought fixture: seg11 and seg12 +/// carry no random-access point at all (audio cut them on boundaries the keyframe-gated cutter +/// folded), seg10 opens 3.0 s below its first one, seg13 opens 3.0 s below its own at 55.0 s. +@Suite("AE#412 re-cut decision") +struct Issue412RecutDecisionTests { + + @Test("A segment that opens a run at the target is left alone") + func servesItself() { + // seg10: advertised 40.0, sync at 43.0, target 46.0. + #expect(!HLSVideoEngine.needsRecut( + landingReach: .syncAt(offsetSeconds: 3.0), + offsetIntoSegment: 6.0, + coveringSyncDistance: 3.0)) + } + + /// The measured shape of the target that already landed exactly: seg11 carries nothing, but the + /// random-access point at 43.0 is 3.0 s below the target and AVPlayer reaches further than that. + /// Re-cutting here would spend a restart on a landing that works. + @Test("A random-access point within reach below the target is enough") + func coveredByReachBack() { + #expect(!HLSVideoEngine.needsRecut( + landingReach: SegmentCache.VideoReach.none, + offsetIntoSegment: 2.0, + coveringSyncDistance: 3.0)) + } + + /// Target 50.0 of the measured run: nothing below it inside the drought, and the covering point + /// at 43.0 is 7.0 s down, past the reach. This is the seek that landed at 55.0 s. + @Test("Nothing in reach below the target means a re-cut") + func outOfReachNeedsRecut() { + #expect(HLSVideoEngine.needsRecut( + landingReach: SegmentCache.VideoReach.none, + offsetIntoSegment: 2.0, + coveringSyncDistance: 7.0)) + } + + @Test("No known random-access point below the target at all means a re-cut") + func noCoveringPointNeedsRecut() { + #expect(HLSVideoEngine.needsRecut( + landingReach: SegmentCache.VideoReach.none, + offsetIntoSegment: 2.0, + coveringSyncDistance: nil)) + } + + /// Target 54.0: seg13 does carry a sync sample, at 55.0 s, which is ABOVE the target. A reach + /// answered per segment rather than against the target would call this servable and land late. + @Test("A sync sample above the target does not serve it") + func syncAboveTargetDoesNotServe() { + #expect(HLSVideoEngine.needsRecut( + landingReach: .syncAt(offsetSeconds: 3.0), + offsetIntoSegment: 2.0, + coveringSyncDistance: 11.0)) + } + + @Test("The reach boundary is inclusive") + func reachBoundaryInclusive() { + #expect(!HLSVideoEngine.needsRecut( + landingReach: SegmentCache.VideoReach.none, + offsetIntoSegment: 1.0, + coveringSyncDistance: HLSVideoEngine.coldSeekLookbackSeconds)) + #expect(HLSVideoEngine.needsRecut( + landingReach: SegmentCache.VideoReach.none, + offsetIntoSegment: 1.0, + coveringSyncDistance: HLSVideoEngine.coldSeekLookbackSeconds + 0.001)) + } +}