Skip to content
Open
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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,17 @@ the public-API contract.

### Fixed

- **Software VOD exposes retained cache ranges to host timelines.** `residentRanges` now covers
compressed video packets together with the selected audio stream, including retained history,
gaps and disk eviction. Cached seeks keep the same spans; cold seeks and teardown clear them.
Sampling continues while paused and runs off the main actor, with stale seek/stop results
discarded. Hosts no longer need to infer a cache band's start from the moving playhead.
VC-1 uses verified successor timestamps rather than rounded packet-duration islands. Valid
committed packets remain seekable after a source read error, which is still reported after drain;
storage failures remain unusable. Failed resume/seek positioning can no longer publish a false
successful landing. Bounded cold probes avoid speculative detour fetches, and foreground software
positioning has a finite 30 s budget for slow multi-probe remote keyframe searches.

- **A host with its own Objective-C and C++ links again (#752).** Since 7.34.0 LibTrueHD and LibDovi
each brought a `rust_eh_personality`, and with the host's `__objc_personality_v0` and
`__gxx_personality_v0` that made four personality routines, one more than compact unwind encodes
Expand Down
7 changes: 7 additions & 0 deletions Sources/AetherEngine/AetherEngine+Loading.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1941,6 +1941,13 @@ extension AetherEngine {
// #353: the settled picture size, wired next to the frame times because a host laying out an
// overlay needs the rectangle as well as the clock, and both come off this renderer.
mirrorSoftwareDisplaySize(from: host.$videoDisplaySize, storeIn: &softwareCancellables)
host.$residentRanges
.sink { [weak self, weak host] ranges in
guard let self, let host, self.softwareHost === host,
self.loadGeneration == generation, !self.isLive else { return }
self.residentRanges = ranges
}
.store(in: &softwareCancellables)
// SW-PiP: publish the bridge once the session owns its layer (the layer object is stable for
// the session; the host attaches it to the view and, on PiP start, to the system window).
softwarePiPSource = SoftwarePiPSource(layer: host.displayLayer, isLive: isLive, engine: self)
Expand Down
16 changes: 15 additions & 1 deletion Sources/AetherEngine/AetherEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,8 @@ public final class AetherEngine: ObservableObject {
didSet { recomputePlaybackPhase() }
}

/// Where the loopback segment cache holds picture right now, on the same 0-based display axis as
/// Where the loopback segment cache or software VOD packet cache holds picture right now,
/// with selected audio coverage required for software A/V, on the same 0-based display axis as
/// `currentTime`, `duration` and `seek(to:)`. An empty array is the nil-equivalent, and live
/// sessions publish one always: their rewind depth is `clock.seekableLiveRange`, a different
/// contract. Residency is not a promise that a seek inside a range will be instant; the player may
Expand Down Expand Up @@ -6183,6 +6184,19 @@ public final class AetherEngine: ObservableObject {
// A superseding seek owns the final state.
guard loadGeneration == gen, seekGeneration == seekGen else { return }
setPendingRecoverySeekTarget(nil)
if softwareHost != nil, hostReposition != .landed {
setProgrammaticSeek(inFlight: false, target: nil)
closeSeekTicket(&programmaticSeekTicket,
with: hostReposition == .superseded ? .superseded : .stalled)
// Host failure sinks may already have published .error. Never overwrite it with
// .playing/.paused or publish the requested position as though it was reached.
if hostReposition == .stalled {
if case .error = state {} else {
publishError(.softwarePipelineFailed, "Playback source could not reposition.")
}
}
return
}
// Publish the measured native landing, not the optimistic request. The two can differ
// slightly even after a successful callback; subtitle anchors and SeekEvent must agree.
let landedClock = nativeOnly ? (nativeHost?.currentTime ?? clockTarget) : clockTarget
Expand Down
7 changes: 6 additions & 1 deletion Sources/AetherEngine/Demuxer/AVIOReader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2731,7 +2731,12 @@ final class AVIOReader: AVIOProvider, @unchecked Sendable {
if let n = detourCache.serveCached(into: dst, maxLen: maxLen, at: offset) {
return .served(n, fetched: false)
}
guard allowFetch else { return .miss }
// A bounded position search may probe several distant offsets. Waiting for a whole
// speculative 4 MB block at each offset can spend the seek's entire deadline before
// the existing streaming reconnect gets a chance to deliver the requested bytes.
// Keep resident hits above, but let a bounded miss re-anchor immediately. Ordinary
// parse-time ping-pong still fills the detour cache and preserves its connection.
guard allowFetch, readDeadline == .distantFuture else { return .miss }

let blockStart = (offset / Int64(Self.detourBlockSize)) * Int64(Self.detourBlockSize)
let blockLen = Int(min(Int64(Self.detourBlockSize), fileSize - blockStart))
Expand Down
6 changes: 5 additions & 1 deletion Sources/AetherEngine/Demuxer/Demuxer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2409,7 +2409,11 @@ public final class Demuxer: @unchecked Sendable {
// matroska may return success with a partial index after abort; deadline flag
// is authoritative, not ret.
let capped = avioProvider?.readDeadlineFired ?? false
return ret >= 0 && !capped && probeControl?.isStopped != true
let stopped = probeControl?.isStopped == true
// Fixed result flags only: FFmpeg refusal, read deadline, cancellation. No source identity.
let flags = (ret < 0 ? 1 : 0) | (capped ? 2 : 0) | (stopped ? 4 : 0)
EngineLog.emit("[DemuxReposition] flags=\(flags)", category: .swPlayback)
return ret >= 0 && !capped && !stopped
}

/// How an off-actor reposition ended (#254). Named to mirror `SeekEvent.Outcome` so the engine's
Expand Down
26 changes: 26 additions & 0 deletions Sources/AetherEngine/Native/SoftwarePacketCoverage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,32 @@ struct SoftwarePacketCoverage: Sendable {
var rangeCount: Int { ranges.count }
var isFull: Bool { ranges.count >= maximumRangeCount }

/// Immutable presentation spans for a background residency snapshot. Never round over a gap.
func rangesSeconds(timeBaseNumerator: Int32, timeBaseDenominator: Int32) -> [ClosedRange<Double>] {
guard timeBaseNumerator > 0, timeBaseDenominator > 0 else { return [] }
return ranges.compactMap { range in
guard Int64(exactly: Double(range.lowerBound)) == range.lowerBound,
Int64(exactly: Double(range.upperBound)) == range.upperBound else { return nil }
let start = Double(range.lowerBound) * Double(timeBaseNumerator) / Double(timeBaseDenominator)
let end = Double(range.upperBound) * Double(timeBaseNumerator) / Double(timeBaseDenominator)
guard start.isFinite, end.isFinite, start < end else { return nil }
return start...end
}
}

/// Linear intersection of sorted, disjoint spans; selected audio is required even when muted.
static func intersection(_ video: [ClosedRange<Double>], _ audio: [ClosedRange<Double>]) -> [ClosedRange<Double>] {
var result: [ClosedRange<Double>] = []
var v = 0, a = 0
while v < video.count, a < audio.count {
let start = max(video[v].lowerBound, audio[a].lowerBound)
let end = min(video[v].upperBound, audio[a].upperBound)
if start < end { result.append(start...end) }
if video[v].upperBound < audio[a].upperBound { v += 1 } else { a += 1 }
}
return result
}

/// Adds [pts, pts + duration). Invalid or capacity-exceeding input leaves existing coverage
/// unchanged. Rejecting a sparse extension is conservative: it never claims a missing packet.
@discardableResult
Expand Down
1 change: 1 addition & 0 deletions Sources/AetherEngine/Native/SoftwarePacketDiskFIFO.swift
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ final class SoftwarePacketDiskFIFO: @unchecked Sendable {
let staleSweepResult: StaleSweepResult
private let chunkTargetBytes: Int
private let retainConsumed: Bool
var retainsConsumedPackets: Bool { retainConsumed }
private let lock = NSLock()
private var writer: FileHandle?
private var reader: FileHandle?
Expand Down
Loading