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
12 changes: 11 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,17 @@ the public-API contract.

## [Unreleased]

_Nothing yet._
### Added

- **Scrub stills on the software VOD path, with no second connection (AE#605).** A VOD session the
device cannot hardware-decode (MPEG-4 Part 2, MPEG-2, interlaced H.264, everything on the iOS
Simulator) already spools its packets to a disk cache its seeks land in, but `scrubThumbnail`
had no arm for it, so every such session scrubbed blind on a source that refuses a second
request. It now decodes the still out of that cache, keyframe to target, through the same
extractor, queue and newest-wins ticket the live software path uses (#544), and the consumer
cursor is never moved, so playback reads on undisturbed. A target past what is retained answers
nil rather than the frame before it. `supportsCacheBackedStills` is true for such a session, and
now also for a software live session, which already served stills but reported false.

## [7.14.0] - 2026-09-22

Expand Down
25 changes: 20 additions & 5 deletions Sources/AetherEngine/AetherEngine+ScrubThumbnail.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,17 @@ extension AetherEngine {
/// to 0: thumbnail mode returns the first frame after the seek, so 0 lands on that segment's
/// first keyframe whether its fMP4 tfdt is absolute or zero-based (post-restart). This makes
/// the decode axis-independent and correct by construction. Per-segment granularity.
///
/// A software session (AE#605) has no segments; it decodes the still out of the packet cache
/// its seeks already land in, keyframe to target, on the same session axis `seek(to:)` takes.
public func vodScrubThumbnail(atSeconds seconds: Double, maxWidth: Int = 320) async -> CGImage? {
guard !isLive, let session = nativeVideoSession else { return nil }
guard !isLive else { return nil }
guard let session = nativeVideoSession else {
guard let host = softwareHost else { return nil }
let gen = loadGeneration
let image = await host.vodScrubStill(atSessionSeconds: seconds, maxWidth: maxWidth)
return loadGeneration == gen ? image : nil
}
let gen = loadGeneration
let source = await Task.detached(priority: .userInitiated) { [session] in
session.scrubThumbnailSource(atSeconds: seconds)
Expand Down Expand Up @@ -61,12 +70,18 @@ extension AetherEngine {
}
}

/// True when a native session (live or VOD) is active, so `scrubThumbnail` can serve
/// cache-backed stills with no second connection. False on the software path (no
/// SegmentCache) and before load. Hosts on a single-connection source should gate the
/// True when the active session can serve cache-backed stills from `scrubThumbnail` with no
/// second connection: any native session (live or VOD, SegmentCache), a software live session
/// (DVR packet ring, #544) and a software VOD session reading a remote source (packet cache,
/// AE#605). False before load and on a software session that plays a local file, which keeps no
/// cache because re-reading the file is free; `makeFrameExtractor()` serves that case. Hosts on a single-connection source should gate the
/// scrub-preview affordance on this: true means use `scrubThumbnail`; false means hide
/// the preview rather than show blank frames from a refused second-connection
/// FrameExtractor (#106). It reports capability, not per-frame availability: transient
/// nils from `scrubThumbnail` while a segment is still being produced are expected.
public var supportsCacheBackedStills: Bool { nativeVideoSession != nil }
public var supportsCacheBackedStills: Bool {
if nativeVideoSession != nil { return true }
guard let softwareHost else { return false }
return isLive || softwareHost.servesPacketCacheStills
}
}
54 changes: 54 additions & 0 deletions Sources/AetherEngine/Native/SoftwarePacketDiskFIFO.swift
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,60 @@ final class SoftwarePacketDiskFIFO: @unchecked Sendable {
}
}

/// AE#605: walks retained records from `cursor` on without touching the consumer's reader, so a
/// scrub still can decode out of history while playback keeps reading its own position.
///
/// The lock is held only to validate the cursor and to snapshot the tail. The disk reads run
/// outside it on handles of their own, because a GOP is megabytes and the producer and the
/// consumer both serialize on this lock. That is safe for two reasons: an unlinked chunk stays
/// readable through a handle opened before the unlink, and the tail is read only up to the
/// length snapshotted here, never into a record still being written. What it cannot see is a
/// reset recreating the same chunk names underneath it, so the generation is checked again
/// once the walk ends, and a walk that raced a reset throws `invalidCursor` AFTER visiting: the
/// caller discards whatever it collected. `visit` returns false to stop early. Failures never
/// poison the store: a still is optional, playback is not.
func readHistory(from cursor: Cursor, visit: (Data) throws -> Bool) throws {
lock.lock()
do { try requireUsable() } catch { lock.unlock(); throw error }
guard retainConsumed else { lock.unlock(); throw Failure.retentionDisabled }
guard cursor.generation == generation, chunks > 0,
cursor.chunkID >= oldestChunkID, cursor.chunkID <= tailID,
cursor.recordIndex >= 0, cursor.recordIndex < writtenRecordCount else {
lock.unlock()
throw Failure.invalidCursor
}
let lastChunk = tailID
let lastChunkBytes = tailBytes
lock.unlock()

var chunkID = cursor.chunkID
var offset = cursor.offset
walk: while chunkID <= lastChunk {
guard let handle = try? FileHandle(forReadingFrom: chunkURL(chunkID)) else {
throw Failure.invalidCursor
}
defer { try? handle.close() }
let limit = chunkID == lastChunk ? lastChunkBytes : try handle.seekToEnd()
try handle.seek(toOffset: offset)
while offset < limit {
guard limit - offset >= 8 else { throw Failure.corruptRecord }
let header = try readExactly(8, from: handle)
let length = header.reduce(UInt64(0)) { ($0 << 8) | UInt64($1) }
guard length <= limit - offset - 8 else { throw Failure.corruptRecord }
let record = try readExactly(Int(length), from: handle)
offset += 8 + length
if try !visit(record) { break walk }
}
chunkID += 1
offset = 0
}

lock.lock()
let stillValid = cursor.generation == generation && !isClosed
lock.unlock()
guard stillValid else { throw Failure.invalidCursor }
}

/// Evict only complete history chunks strictly before the reader, oldest first. A budget is
/// not permission to discard unread packets or the current reader/writer chunk; the resident
/// count can therefore remain above budget until the consumer advances. No packet/chunk index
Expand Down
48 changes: 48 additions & 0 deletions Sources/AetherEngine/Native/SoftwarePacketReadAhead.swift
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,54 @@ final class SoftwarePacketReadAhead: @unchecked Sendable {
return false
}

/// AE#605: the retained video packets a scrub still at `seconds` needs, keyframe first, or nil
/// when the store cannot answer it. Any thread except main: it reads the disk.
///
/// Eligibility is the cached seek's own rule, coverage past the target plus a retained keyframe
/// at or before it, so a still is offered exactly where a commit to that position would be a
/// cache hit, and the card never shows a picture the seek then has to fetch. Unlike the live
/// ring there is no clamp at the end: a VOD target past the frontier has a real frame the store
/// does not hold yet, and the frame before it is the wrong answer. The consumer cursor is not
/// touched, so playback reads on from where it stood.
func stillRun(atSeconds seconds: Double, maxPackets: Int, maxSpanSeconds: Double,
reorderTail: Int) -> [SoftwareStoredPacket]? {
guard seconds.isFinite, maxPackets > 0 else { return nil }
condition.lock()
let eligible = !closed && !sourceRepositioning && !resetPending && failure == nil
&& (frontierLocked(at: seconds).map { $0 > seconds } ?? false)
let anchor = eligible
? keyframes.filter { $0.seconds <= seconds }.max { $0.seconds < $1.seconds } : nil
condition.unlock()
guard let anchor, seconds - anchor.seconds <= maxSpanSeconds else { return nil }

var run: [SoftwareStoredPacket] = []
var reached = false
var tail = reorderTail
var overflow = false
do {
try fifo.readHistory(from: anchor.cursor) { data in
let packet = try SoftwareStoredPacket.decode(data)
guard packet.streamIndex == video.index else { return true }
if run.isEmpty, packet.flags & 1 == 0 { overflow = true; return false }
run.append(packet)
if run.count > maxPackets { overflow = true; return false }
if reached {
tail -= 1
return tail > 0
}
if let pts = videoSeconds(pts: packet.pts), pts >= seconds {
reached = true
return tail > 0
}
return true
}
} catch {
return nil
}
guard reached, !overflow else { return nil }
return run
}

func endSeek(_ token: UInt64, sourceClock: Double) {
condition.lock(); defer { condition.unlock() }
guard token == generation, !closed else { return }
Expand Down
34 changes: 34 additions & 0 deletions Sources/AetherEngine/Native/SoftwarePlaybackHost.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1433,6 +1433,40 @@ final class SoftwarePlaybackHost {
}
}

/// AE#605: true when this VOD session keeps a packet cache a still can be decoded from. A local
/// file has none (it is read directly, see the spool's setup), and neither does a session whose
/// cache could not be built.
var servesPacketCacheStills: Bool { !isLive && vodPacketReadAhead != nil }

/// AE#605: the VOD twin of `liveScrubStill`, decoded out of the retained packet cache.
///
/// Same queue, same newest-wins ticket and same extractor as the live still, and the same session
/// axis conversion `seek` uses, so the card and the commit name one moment. The disk read runs on
/// the still queue too, never on the demux or feed loop.
func vodScrubStill(atSessionSeconds seconds: Double, maxWidth: Int) async -> CGImage? {
guard !isLive, let cache = vodPacketReadAhead, let extractor = resolveStillExtractor() else {
return nil
}
let targetSource = sourceSeconds(forSession: seconds)
let limits = SoftwareStillExtractor.Limits.vod
let requests = stillRequests
let ticket = requests.next()
return await withCheckedContinuation { continuation in
stillQueue.async {
guard ticket == requests.latest,
let run = cache.stillRun(atSeconds: targetSource,
maxPackets: limits.maxPackets,
maxSpanSeconds: limits.maxSpanSeconds,
reorderTail: limits.reorderTail) else {
continuation.resume(returning: nil)
return
}
continuation.resume(
returning: extractor.still(from: run, targetPts: targetSource, maxWidth: maxWidth))
}
}
}

/// Live DVR rewind: reseeds decoder from the ring (source PTS axis; maps via sessionStartPts) without touching the live demuxer. After return, the loop reads new packets forward and plays back to live.
private func seekLiveDVR(to targetSession: Double, ring: PacketRingBuffer, wasPlaying: Bool) async {
let targetSource = sourceSeconds(forSession: targetSession)
Expand Down
32 changes: 29 additions & 3 deletions Sources/AetherEngine/Native/SoftwareStillExtractor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ final class SoftwareStillExtractor: @unchecked Sendable {
/// Packets are stored in decode order, so the frame at the target can sit behind the first
/// packet that reaches it. Two B-frames is the common broadcast shape; four covers the rest.
var reorderTail: Int = 4

/// AE#605: a file's GOP is not a broadcast one. x264's default keyint is 250 pictures,
/// ten seconds at 25 fps and four at 60, and B-pyramids reorder deeper than two B-frames.
static let vod = Limits(maxPackets: 900, maxSpanSeconds: 12, reorderTail: 16)
}

private let decoder = SoftwareVideoDecoder()
Expand Down Expand Up @@ -72,16 +76,38 @@ final class SoftwareStillExtractor: @unchecked Sendable {
reorderTail: limits.reorderTail),
!run.isEmpty else { return nil }

return decodeRun(targetPts: targetPts, maxWidth: maxWidth) {
for packet in run { feed(packet) }
}
}

/// AE#605: the same still out of a software VOD session's retained packets. They arrive as the
/// demuxer produced them, envelope and all, so they are replayed as is: a VOD stream carries real
/// decode timestamps and side data that the ring's pts-only shape has no room for.
func still(from run: [SoftwareStoredPacket], targetPts: Double, maxWidth: Int) -> CGImage? {
guard isOpen, maxWidth > 0, let first = run.first, first.flags & AV_PKT_FLAG_KEY != 0 else {
return nil
}
return decodeRun(targetPts: targetPts, maxWidth: maxWidth) {
for stored in run {
guard let p = try? stored.makeAVPacket() else { continue }
var packet: UnsafeMutablePointer<AVPacket>? = p
defer { trackedPacketFree(&packet) }
p.pointee.stream_index = videoStreamIndex
decoder.decode(packet: p, epoch: nil)
}
}
}

private func decodeRun(targetPts: Double, maxWidth: Int, feedRun: () -> Void) -> CGImage? {
let collector = FrameCollector(target: targetPts)
decoder.onFrame = { pixelBuffer, pts, _ in
collector.append(pixelBuffer: pixelBuffer, seconds: pts.seconds)
}
decoder.flush(resetFilterGraph: false)
defer { decoder.onFrame = nil }

for packet in run {
feed(packet)
}
feedRun()

guard let best = collector.best else { return nil }
return Self.image(from: best, maxWidth: maxWidth)
Expand Down
28 changes: 23 additions & 5 deletions Sources/aetherctl/PlaybackCmd.swift
Original file line number Diff line number Diff line change
Expand Up @@ -990,21 +990,39 @@ private func playSmokeTest(url: URL, seconds: Double, live: Bool, forceSoftware:
if hostCalls.contains("still"), [15, 20, 25].contains(tick) {
// The third aim is the live EDGE itself, not the playhead: a target a fraction past the
// newest packet is the clamp case, and it is where a live viewer sits most.
// AE#605: a VOD session aims behind the playhead (retained history), ahead of it (the
// forward buffer) and far past what is retained, where the right answer is a MISS: the
// frame there exists, the cache just does not hold it, and the one before it is wrong.
let isLive = engine.isLive
let target: Double
let label: String
switch tick {
case 15:
switch (tick, isLive) {
case (15, true):
target = max(0, engine.currentTime - 20)
label = "playhead-20"
case 20:
case (20, true):
target = max(0, engine.currentTime - 5)
label = "playhead-5"
default:
case (_, true):
target = engine.seekableLiveRange?.upperBound ?? engine.currentTime
label = "edge"
case (15, false):
target = max(0, engine.currentTime - 10.5)
label = "playhead-10.5"
case (20, false):
target = engine.currentTime + 4.5
label = "playhead+4.5"
default:
target = min(engine.currentTime + 600, max(0, engine.duration - 1))
label = "past-frontier"
}
if tick == 15 {
print(" HOSTCALL supportsCacheBackedStills -> \(engine.supportsCacheBackedStills)")
}
let started = Date()
let image = await engine.liveScrubThumbnail(atSessionSeconds: target, maxWidth: 320)
let image = isLive
? await engine.liveScrubThumbnail(atSessionSeconds: target, maxWidth: 320)
: await engine.scrubThumbnail(atSeconds: target, maxWidth: 320)
let ms = Int(Date().timeIntervalSince(started) * 1000)
stillAttempts += 1
if let image {
Expand Down
Loading
Loading