Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion Sources/AetherEngine/AetherEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -3914,14 +3925,25 @@ 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
// -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)
// 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
Expand Down
57 changes: 55 additions & 2 deletions Sources/AetherEngine/Video/HLSSegmentProducer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading