From 87eac7508c3d5fa14b6bb00e72f57e127787a6e0 Mon Sep 17 00:00:00 2001 From: Vincent Herbst Date: Wed, 26 Aug 2026 07:03:21 +0200 Subject: [PATCH] fix(video): a cold seek needs a random-access point below its target (#412) Audio routes packets into segments 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. AVPlayer reaches back a fixed span on a cold seek and does NOT search for one, so where the drought is wider than that reach the picture starts at the next sync sample ABOVE the target and the seek silently skips content. Measured with `play --picture-probe` on a 12 s drought built from `Scripts/timecode-fixture.sh` + `Scripts/mkv-cue-fixture.py`, seeking back from 88 s with the drought cached from a sequential pass and no producer restart: target before after control (cues ARE sync samples) 46.0 46.00 46.00 46.00 50.0 55.00 50.00 50.00 54.0 54.96 54.00 54.00 The control is the same source cut on its real sync samples, where the drought is one segment beginning at its covering IRAP, and it lands exactly on every target. That is the property the fold exists to keep, and the audio cut is what destroys it. The producer now records, per adopted segment, where its first random-access point sits as an offset from the segment's ADVERTISED start, so the claim carries no axis with it. A cold seek asks that of the segment it lands in, and of the ones within reach below it, and re-cuts from the covering point only when nothing down there can open a run at the target. The seek target itself is not moved: measured 3 of 3, AVPlayer puts the re-cut segment at its own tfdt inside the timeline it is already building (`axisErr` 0.000 at offsets of 1, 5 and 9 s), so moving it would land exactly that far past the target instead. For the same reason such an epoch records no axis offset. Two things the issue got wrong, both from reading the extra fetch AVPlayer makes before the target segment as a repair. It is not one: the same reach back happens on the control, where every segment is independent. And the cost is therefore not one extra fetch, it is the landing. Falls back to today's behaviour wherever there is nothing to act on: live, a segment the producer made no claim for, a random-access point already in reach, or a re-cut whose gate does not open within 2 s. 2114 tests green. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RbWZLwBVLGM1xUVXa9NeJ1 --- Sources/AetherEngine/AetherEngine.swift | 24 ++- .../Video/HLSSegmentProducer.swift | 57 +++++- .../AetherEngine/Video/HLSVideoEngine.swift | 166 ++++++++++++++++- Sources/AetherEngine/Video/SegmentCache.swift | 44 ++++- .../Video/VideoSegmentProvider.swift | 6 + .../Issue412VideoReachTests.swift | 173 ++++++++++++++++++ 6 files changed, 465 insertions(+), 5 deletions(-) create mode 100644 Tests/AetherEngineTests/Issue412VideoReachTests.swift 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)) + } +}