@@ -148,6 +148,97 @@ extension AetherEngine {
148148 ?? ( outputFloor + playlistShiftSeconds)
149149 }
150150
151+ /// AE#446 round 4: measure how far the current item's own timeline sits below the session's.
152+ ///
153+ /// A live item's zero is the first segment ITS playlist listed. The producer's window floor and
154+ /// the item's own floor slide together, because one rule sizes both (`LiveWindowSizing` is the
155+ /// single source of truth for the playlist's first visible segment and the cache's eviction), so
156+ /// their difference is the offset and it holds still while both ends move. Measured on the harness
157+ /// across twelve seconds of sliding: 50.00 s at every sample, while the item's floor walked from
158+ /// 0.00 to 15.00 and the producer's from 51.40 to 66.40.
159+ ///
160+ /// Latched per item, because it is a property of the playlist that item loaded, and re-measured
161+ /// when the item under the host changes. It reads 0 for the item a session starts with, which is
162+ /// why nothing needed it until a swap attached a second one.
163+ @MainActor
164+ func measureLiveItemAxisOffset( ) {
165+ guard isLive, let host = nativeHost else { return }
166+ guard host. itemGeneration != liveItemAxisOffsetGeneration else { return }
167+ // A range of zero width is an item that has not reported yet, not an item at the origin.
168+ guard host. seekableEnd > host. seekableStart,
169+ let producerFloor = residentLiveFloorSessionSeconds ( ) else { return }
170+ let offset = Self . liveItemAxisOffset ( producerFloorSession: producerFloor,
171+ itemSeekableStart: host. seekableStart,
172+ shift: playlistShiftSeconds)
173+ guard offset. isFinite else { return }
174+ liveItemAxisOffsetGeneration = host. itemGeneration
175+ liveItemAxisOffsetSeconds = offset
176+ guard liveItemAxisOffsetSeconds > 0.01 else { return }
177+ EngineLog . emit (
178+ " [AetherEngine] #446 this item's playlist began \( String ( format: " %.2f " , liveItemAxisOffsetSeconds) ) s "
179+ + " into the session, so its own clock reads that much below the session's; folding it into "
180+ + " every conversion for as long as this item is the one playing " ,
181+ category: . engine)
182+ }
183+
184+ /// AE#446 round 4: the arithmetic behind `measureLiveItemAxisOffset`, on its own so the case can
185+ /// be stated without a session.
186+ ///
187+ /// The producer's floor is on the session axis; the item's floor is on the item's own. One rule
188+ /// sizes both, so their difference is what separates the axes, and it is a difference rather than
189+ /// an assumption. Negative is not a case that can be acted on (an item claiming to hold content
190+ /// older than the producer does), and it folds to 0, which is the pre-swap behaviour.
191+ nonisolated static func liveItemAxisOffset(
192+ producerFloorSession: Double , itemSeekableStart: Double , shift: Double
193+ ) -> Double {
194+ Swift . max ( 0 , producerFloorSession - ( itemSeekableStart + shift) )
195+ }
196+
197+ /// AE#446 round 4: the three readings a rejoin's placement is argued from, on one line.
198+ ///
199+ /// They were only ever available separately, which is why an item's clock and an item's seekable
200+ /// range could disagree for a whole investigation without anyone being able to say so. Bounded to
201+ /// the seconds after a swap, so a live session does not pay for it.
202+ @MainActor
203+ func auditLiveRejoinPlacement( ) {
204+ guard let until = liveRejoinAuditUntil, let host = nativeHost else { return }
205+ let now = Date ( )
206+ guard now < until else { liveRejoinAuditUntil = nil ; return }
207+ if let last = liveRejoinAuditLastEmit, now. timeIntervalSince ( last) < 1.0 { return }
208+ liveRejoinAuditLastEmit = now
209+ let producer = residentLiveRangeSessionSeconds ( )
210+ EngineLog . emit (
211+ " [AetherEngine] #446 placement audit: item clock \( String ( format: " %.2f " , nativeClockSeconds) ) s "
212+ + " in item range \( String ( format: " %.2f " , host. seekableStart) ) .. \( String ( format: " %.2f " , host. seekableEnd) ) s, "
213+ + " shift \( String ( format: " %.2f " , playlistShiftSeconds) ) s + item offset "
214+ + " \( String ( format: " %.2f " , liveItemAxisOffsetSeconds) ) s -> session \( String ( format: " %.2f " , currentTime) ) s; "
215+ + " the producer holds "
216+ + ( producer. map { " \( String ( format: " %.2f " , $0. lowerBound) ) .. \( String ( format: " %.2f " , $0. upperBound) ) s " } ?? " nothing it can state " ) ,
217+ category: . engine)
218+ }
219+
220+ /// AE#446 round 4: what the producer holds right now, on the session axis, both ends.
221+ ///
222+ /// This is the range a rejoin is measured against, and it is deliberately not either of the two
223+ /// the engine publishes. `LiveWindow.edgeTime` is a running maximum an outage freezes BELOW the
224+ /// playhead that legitimately ran past it, and a freshly swapped item's `seekableEnd` is a range
225+ /// it has not finished reporting at the readiness instant the rejoin replays in (measured on the
226+ /// harness: 43.4 s while the place held was 71.4 s and the producer was cutting past 100 s). The
227+ /// cache is the only party that is neither ahead of nor behind itself.
228+ ///
229+ /// nil where there is no cache to ask, which leaves the rejoin exactly where it was.
230+ func residentLiveRangeSessionSeconds( ) -> ClosedRange < Double > ? {
231+ guard let session = nativeVideoSession,
232+ let floorOutput = session. residentFloorOutputSeconds ( ) ,
233+ let ceilingOutput = session. residentCeilingOutputSeconds ( ) else { return nil }
234+ let floor = presentationAxis. sourceSeconds ( forItemSeconds: floorOutput)
235+ ?? ( floorOutput + playlistShiftSeconds)
236+ let ceiling = presentationAxis. sourceSeconds ( forItemSeconds: ceilingOutput)
237+ ?? ( ceilingOutput + playlistShiftSeconds)
238+ guard ceiling >= floor else { return nil }
239+ return floor... ceiling
240+ }
241+
151242 /// AE#442: the TARGETDURATION the live playlist is serving, nil on every path that serves none
152243 /// (remote HLS live, the software live path, and before the first playlist build).
153244 var liveTargetDurationSeconds : Double ? {
@@ -161,6 +252,7 @@ extension AetherEngine {
161252 w. noteEdge ( edgeSessionTime)
162253 w. notePlayhead ( currentTime)
163254 w. noteResidentFloor ( residentLiveFloorSessionSeconds ( ) )
255+ auditLiveRejoinPlacement ( )
164256 liveWindow = w
165257 // AE#442: tick-to-tick advancement, not a running maximum: a backward DVR seek drops the
166258 // playhead, and the next advancing publish has to be able to record the new, larger distance.
@@ -174,6 +266,32 @@ extension AetherEngine {
174266 clock. behindLiveSeconds = w. behindLiveSeconds
175267 }
176268
269+ /// AE#446 round 4: who asked for a seek. The two differ in exactly two places, both about a live
270+ /// session that advertises no DVR window: whether the seek is refused outright, and whether its
271+ /// landing is measured against what the session offers or against what the item holds.
272+ ///
273+ /// A host that draws no scrubber can still have a place to come back to. Reported from a device:
274+ /// the outage swap carried the held position, the replay went out through the public `seek(to:)`
275+ /// like any host scrub, and the live-only guard refused it before it could land.
276+ enum SeekOrigin : Sendable {
277+ /// A scrub the host asked for, bound by the contract `seekableLiveRange` states.
278+ case host
279+ /// The engine coming back to a position it decided itself (the AE#446 outage swap, AE#442's
280+ /// in-place recovery reload). Not a scrub, and not bound by the scrubber's contract.
281+ case liveRejoin
282+ }
283+
284+ /// AE#446 round 4: whether a live seek is refused for having no DVR window to land in.
285+ ///
286+ /// The refusal is the host contract's defence-in-depth: hosts hide the scrubber when
287+ /// `seekableLiveRange` is nil, and one that does not must not put the item somewhere it cannot
288+ /// play from. It says nothing about the engine's own rejoin, which picked its position out of
289+ /// content the session itself served.
290+ nonisolated static func liveSeekRefusedWithoutDVR( origin: SeekOrigin , windowSeconds: Double ? ) -> Bool {
291+ guard origin == . host else { return false }
292+ return windowSeconds == nil
293+ }
294+
177295 /// AE#446 round 3: where a live seek lands, decided from ONE sample of the item's own clock.
178296 ///
179297 /// The two halves used to read different clocks. The target was clamped against
@@ -199,14 +317,31 @@ extension AetherEngine {
199317 window: LiveWindow ,
200318 itemEnd: Double ,
201319 shift: Double ,
202- axis: PresentationAxisMap
320+ axis: PresentationAxisMap ,
321+ origin: SeekOrigin = . host,
322+ residentRange: ClosedRange < Double > ? = nil ,
323+ itemAxisOffset: Double = 0
203324 ) -> ( sessionTarget: Double , clockTarget: Double ) {
204325 // An item with no seekable range of its own yet has nothing to sample; the window's own edge
205326 // is then the only edge there is, and clamping against `shift` alone would collapse the range.
206- let edge = itemEnd > 0 ? itemEnd + shift : window. edgeTime
207- let sessionTarget = window. clamp ( requested, edge: edge)
327+ let edge = itemEnd > 0 ? itemEnd + shift + itemAxisOffset : window. edgeTime
328+ // AE#446 round 4: a host scrub is bound by what the session ADVERTISES, and the engine's own
329+ // rejoin by what the producer HOLDS. They are different questions, and at the moment a rejoin
330+ // runs they have different answers: the advertised range is measured against an edge that is
331+ // stale in one direction or the other (see `residentLiveRangeSessionSeconds`), while the
332+ // carried position is content this same session cut and served, so the only thing that can
333+ // disqualify it is eviction.
334+ let sessionTarget : Double
335+ if origin == . liveRejoin, let resident = residentRange {
336+ sessionTarget = Swift . min ( Swift . max ( requested, resident. lowerBound) , resident. upperBound)
337+ } else {
338+ sessionTarget = window. clamp ( requested, edge: edge)
339+ }
340+ // AE#446 round 4: and then down onto the item's own axis, which for an item attached after
341+ // the window slid begins above the session's zero. See `measureLiveItemAxisOffset`.
208342 let clockTarget = Swift . max (
209- 0 , axis. itemSeconds ( forSourceSeconds: sessionTarget) ?? ( sessionTarget - shift) )
343+ 0 , ( axis. itemSeconds ( forSourceSeconds: sessionTarget) ?? ( sessionTarget - shift) )
344+ - itemAxisOffset)
210345 return ( sessionTarget, clockTarget)
211346 }
212347
0 commit comments