From e66d57e8b59ad7aedb12ed3f042e6512858aff39 Mon Sep 17 00:00:00 2001 From: Vincent Herbst Date: Tue, 22 Sep 2026 22:27:10 +0200 Subject: [PATCH] feat(stills): scrub stills on the software VOD path from the packet cache (AE#605) A VOD session the device cannot hardware-decode already spools its packets to a disk cache with keyframe cursors, and its seeks land in that cache, but scrubThumbnail had no arm for it and supportsCacheBackedStills was keyed on the native segment cache alone. On a source that refuses a second request (an IPTV account capped at one connection, a debrid link) the FrameExtractor fallback cannot open either, so every such session scrubbed blind. The still now comes out of that cache. SoftwarePacketDiskFIFO gains a history walk from a cursor on handles of its own, so the consumer's reader never moves; SoftwarePacketReadAhead plans the run (newest keyframe at or before the target, video only, through the target plus a reorder tail) under the cached seek's own eligibility rule; SoftwareStillExtractor replays the stored packets with their full envelope. It runs on the #544 still queue with the same newest-wins ticket and the same lazily built extractor. A target past what is retained answers nil rather than the frame before it. supportsCacheBackedStills is now true for a software VOD session reading a remote source and for a software live session, which already served stills but reported false. A local file keeps no cache and stays false. aetherctl play --sw --host-calls still gains a VOD arm. On a 300 s H.264 fixture over HTTP: before 0 of 3, after 2 of 3 (4.37 s shows 4, 24.37 s shows 24, in 53 and 41 ms, no network), and the aim 600 s past the frontier misses as it must. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014AVNwe8YDG9EQM7uHdjT3f --- CHANGELOG.md | 12 +- .../AetherEngine+ScrubThumbnail.swift | 25 +- .../Native/SoftwarePacketDiskFIFO.swift | 54 +++++ .../Native/SoftwarePacketReadAhead.swift | 48 ++++ .../Native/SoftwarePlaybackHost.swift | 34 +++ .../Native/SoftwareStillExtractor.swift | 32 ++- Sources/aetherctl/PlaybackCmd.swift | 28 ++- .../Issue605SoftwareVODStillTests.swift | 218 ++++++++++++++++++ docs/api.md | 6 +- docs/cli.md | 2 +- 10 files changed, 441 insertions(+), 18 deletions(-) create mode 100644 Tests/AetherEngineTests/Issue605SoftwareVODStillTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index effa67da2..04c042da8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Sources/AetherEngine/AetherEngine+ScrubThumbnail.swift b/Sources/AetherEngine/AetherEngine+ScrubThumbnail.swift index ddc1f2741..feb8af635 100644 --- a/Sources/AetherEngine/AetherEngine+ScrubThumbnail.swift +++ b/Sources/AetherEngine/AetherEngine+ScrubThumbnail.swift @@ -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) @@ -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 + } } diff --git a/Sources/AetherEngine/Native/SoftwarePacketDiskFIFO.swift b/Sources/AetherEngine/Native/SoftwarePacketDiskFIFO.swift index 26bb7b548..ae4dd092e 100644 --- a/Sources/AetherEngine/Native/SoftwarePacketDiskFIFO.swift +++ b/Sources/AetherEngine/Native/SoftwarePacketDiskFIFO.swift @@ -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 diff --git a/Sources/AetherEngine/Native/SoftwarePacketReadAhead.swift b/Sources/AetherEngine/Native/SoftwarePacketReadAhead.swift index 135c579f2..4a0a350d0 100644 --- a/Sources/AetherEngine/Native/SoftwarePacketReadAhead.swift +++ b/Sources/AetherEngine/Native/SoftwarePacketReadAhead.swift @@ -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 } diff --git a/Sources/AetherEngine/Native/SoftwarePlaybackHost.swift b/Sources/AetherEngine/Native/SoftwarePlaybackHost.swift index 04d412ccf..5592fb111 100644 --- a/Sources/AetherEngine/Native/SoftwarePlaybackHost.swift +++ b/Sources/AetherEngine/Native/SoftwarePlaybackHost.swift @@ -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) diff --git a/Sources/AetherEngine/Native/SoftwareStillExtractor.swift b/Sources/AetherEngine/Native/SoftwareStillExtractor.swift index 2234cc82d..609f15e17 100644 --- a/Sources/AetherEngine/Native/SoftwareStillExtractor.swift +++ b/Sources/AetherEngine/Native/SoftwareStillExtractor.swift @@ -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() @@ -72,6 +76,30 @@ 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? = 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) @@ -79,9 +107,7 @@ final class SoftwareStillExtractor: @unchecked Sendable { 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) diff --git a/Sources/aetherctl/PlaybackCmd.swift b/Sources/aetherctl/PlaybackCmd.swift index 0eae838cb..68e301068 100644 --- a/Sources/aetherctl/PlaybackCmd.swift +++ b/Sources/aetherctl/PlaybackCmd.swift @@ -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 { diff --git a/Tests/AetherEngineTests/Issue605SoftwareVODStillTests.swift b/Tests/AetherEngineTests/Issue605SoftwareVODStillTests.swift new file mode 100644 index 000000000..dbc01f84d --- /dev/null +++ b/Tests/AetherEngineTests/Issue605SoftwareVODStillTests.swift @@ -0,0 +1,218 @@ +import Foundation +import Testing +@testable import AetherEngine + +/// AE#605: a software VOD session decodes scrub stills out of the packet cache its seeks already +/// land in, instead of opening a second connection a single-slot origin refuses. +/// +/// Two properties carry the feature. The run a still needs has to be read from HISTORY without +/// moving the consumer, or every preview would rewind playback. And the run is offered exactly where +/// a commit would be a cache hit: a target past the retained frontier has a real frame the store +/// does not hold yet, so the frame before it is the wrong answer rather than a close one. +@Suite("Software VOD scrub stills from the packet cache (AE#605)") +struct Issue605SoftwareVODStillTests { + + // MARK: - FIFO history walk + + private static func makeRoot() throws -> URL { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("issue605-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + return root + } + + private static func record(_ value: UInt8) -> Data { Data(repeating: value, count: 20) } + + @Test("A history walk crosses chunks and leaves the consumer where it stood") + func historyWalkDoesNotMoveConsumer() throws { + let root = try Self.makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + // 64-byte chunks hold two 28-byte records, so ten records span five chunks. + let fifo = try SoftwarePacketDiskFIFO(chunkTargetBytes: 64, retainConsumed: true, + parentDirectory: root) + var cursors: [SoftwarePacketDiskFIFO.Cursor] = [] + for value in UInt8(0)..<10 { cursors.append(try fifo.append(Self.record(value))) } + + var seen: [UInt8] = [] + try fifo.readHistory(from: cursors[3]) { data in + seen.append(data.first ?? 255) + return true + } + #expect(seen == Array(3..<10)) + #expect(try fifo.pop() == Self.record(0)) + #expect(fifo.snapshot.count == 9) + } + + @Test("A history walk stops when the visitor says so") + func historyWalkStopsEarly() throws { + let root = try Self.makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + let fifo = try SoftwarePacketDiskFIFO(chunkTargetBytes: 64, retainConsumed: true, + parentDirectory: root) + var cursors: [SoftwarePacketDiskFIFO.Cursor] = [] + for value in UInt8(0)..<10 { cursors.append(try fifo.append(Self.record(value))) } + + var seen: [UInt8] = [] + try fifo.readHistory(from: cursors[1]) { data in + seen.append(data.first ?? 255) + return seen.count < 3 + } + #expect(seen == [1, 2, 3]) + } + + @Test("A cursor from before a reset is refused, and the store stays usable") + func historyWalkRefusesStaleCursor() throws { + let root = try Self.makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + let fifo = try SoftwarePacketDiskFIFO(chunkTargetBytes: 64, retainConsumed: true, + parentDirectory: root) + let stale = try fifo.append(Self.record(1)) + try fifo.reset() + try fifo.append(Self.record(2)) + + #expect(throws: SoftwarePacketDiskFIFO.Failure.invalidCursor) { + try fifo.readHistory(from: stale) { _ in true } + } + #expect(!fifo.snapshot.hasFailure) + #expect(try fifo.pop() == Self.record(2)) + } + + @Test("A cursor whose chunk was evicted is refused") + func historyWalkRefusesEvictedCursor() throws { + let root = try Self.makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + let fifo = try SoftwarePacketDiskFIFO(chunkTargetBytes: 64, retainConsumed: true, + parentDirectory: root) + var cursors: [SoftwarePacketDiskFIFO.Cursor] = [] + for value in UInt8(0)..<10 { cursors.append(try fifo.append(Self.record(value))) } + for _ in 0..<6 { _ = try fifo.pop() } + try fifo.trimConsumed(toByteBudget: 0) + + #expect(throws: SoftwarePacketDiskFIFO.Failure.invalidCursor) { + try fifo.readHistory(from: cursors[0]) { _ in true } + } + var seen: [UInt8] = [] + try fifo.readHistory(from: cursors[6]) { seen.append($0.first ?? 255); return true } + #expect(seen == Array(6..<10)) + } + + // MARK: - Still run planning + + /// 25 pictures a second on a 1/1000 time base, a keyframe every second, and a non-video packet + /// after every picture so the run has something to filter out. + private static let tickRate: Int32 = 1000 + private static let holdTicks: Int64 = 40 + private static let pictureRate = 25 + + private final class Counter: @unchecked Sendable { + private let lock = NSLock() + private var count = 0 + func next() -> Int { lock.lock(); defer { lock.unlock() }; count += 1; return count } + var value: Int { lock.lock(); defer { lock.unlock() }; return count } + } + + private static func sourcePacket(index: Int, keyframeEvery: Int) -> SoftwareStoredPacket { + let picture = (index - 1) / 2 + let pts = Int64(picture) * holdTicks + let isVideo = (index - 1) % 2 == 0 + let isKey = isVideo && picture % keyframeEvery == 0 + return SoftwareStoredPacket(pts: pts, dts: pts, duration: holdTicks, position: 0, + streamIndex: isVideo ? 0 : 5, flags: isKey ? 0x1 : 0, + timeBaseNumerator: 1, timeBaseDenominator: tickRate, + bytes: Data(repeating: 7, count: 100), sideData: []) + } + + /// A producer that has filled its forward window and parked, with no consumer yet. + private static func parkedCache(root: URL, forwardSeconds: Double, + keyframeEvery: Int = pictureRate) throws -> SoftwarePacketReadAhead { + let counter = Counter() + let fifo = try SoftwarePacketDiskFIFO(chunkTargetBytes: 64 * 1024, retainConsumed: true, + parentDirectory: root) + let cache = SoftwarePacketReadAhead( + video: .init(index: 0, numerator: 1, denominator: tickRate), audio: nil, + byteBudget: 50_000_000, forwardSeconds: forwardSeconds, initialSourceClock: 0, + fifo: fifo + ) { _ in Self.sourcePacket(index: counter.next(), keyframeEvery: keyframeEvery) } + cache.start() + let deadline = Date().addingTimeInterval(10) + var previous = -1 + while Date() < deadline { + Thread.sleep(forTimeInterval: 0.15) + let now = counter.value + if now == previous, now > 0 { break } + previous = now + } + return cache + } + + private static func seconds(_ packet: SoftwareStoredPacket) -> Double { + Double(packet.pts) / Double(tickRate) + } + + @Test("A run opens on the keyframe before the target and reaches past it, video only") + func runOpensOnKeyframeAndReachesTarget() throws { + let root = try Self.makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + let cache = try Self.parkedCache(root: root, forwardSeconds: 10) + defer { cache.close() } + + let run = try #require(cache.stillRun(atSeconds: 3.5, maxPackets: 900, + maxSpanSeconds: 12, reorderTail: 4)) + #expect(run.first.map(Self.seconds) == 3.0) + #expect(run.first.map { $0.flags & 1 != 0 } == true) + #expect(run.allSatisfy { $0.streamIndex == 0 }) + // 3.00 through 3.52 is fourteen pictures (the first at or past 3.5), plus the tail of four. + #expect(run.count == 18) + #expect(run.contains { Self.seconds($0) >= 3.5 }) + } + + @Test("A still leaves the consumer at the packet it would have read next") + func runDoesNotMoveConsumer() throws { + let root = try Self.makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + let cache = try Self.parkedCache(root: root, forwardSeconds: 10) + defer { cache.close() } + + #expect(cache.stillRun(atSeconds: 6.2, maxPackets: 900, maxSpanSeconds: 12, + reorderTail: 4) != nil) + let next = try #require(try cache.read()) + #expect(next.pts == 0) + #expect(next.streamIndex == 0) + } + + @Test("A target past the retained frontier gets no still, not the frame before it") + func runRefusesPastFrontier() throws { + let root = try Self.makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + let cache = try Self.parkedCache(root: root, forwardSeconds: 10) + defer { cache.close() } + + #expect(cache.stillRun(atSeconds: 60, maxPackets: 900, maxSpanSeconds: 12, + reorderTail: 4) == nil) + } + + @Test("A keyframe further back than the span bound gets no still") + func runRefusesWideSpan() throws { + let root = try Self.makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + // One keyframe every eight seconds. + let cache = try Self.parkedCache(root: root, forwardSeconds: 10, keyframeEvery: 200) + defer { cache.close() } + + #expect(cache.stillRun(atSeconds: 5, maxPackets: 900, maxSpanSeconds: 2, + reorderTail: 4) == nil) + #expect(cache.stillRun(atSeconds: 5, maxPackets: 900, maxSpanSeconds: 12, + reorderTail: 4) != nil) + } + + @Test("A run longer than the packet bound gets no still") + func runRefusesPacketOverflow() throws { + let root = try Self.makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + let cache = try Self.parkedCache(root: root, forwardSeconds: 10) + defer { cache.close() } + + #expect(cache.stillRun(atSeconds: 0.9, maxPackets: 10, maxSpanSeconds: 12, + reorderTail: 4) == nil) + } +} diff --git a/docs/api.md b/docs/api.md index 208e495a9..705ff000c 100644 --- a/docs/api.md +++ b/docs/api.md @@ -833,9 +833,9 @@ say so on the tracker rather than working around it. | Symbol | Notes | | --- | --- | -| `scrubThumbnail(atSeconds:maxWidth:)` | Cache-backed still for the active native session, live or VOD. Decodes bytes already produced, so it opens no second connection and works on single-connection sources (debrid / torrent links) where a second demuxer is refused. | -| `vodScrubThumbnail(atSeconds:maxWidth:)`, `liveScrubThumbnail(atSessionSeconds:maxWidth:)` | The two arms, for callers that know which axis they hold. The live arm also serves software sessions, out of the DVR packet ring rather than a segment cache (#544). | -| `supportsCacheBackedStills` | True while a native session exists, which is what the SEGMENT CACHE needs. Gate the scrub-preview affordance on it: it reports capability, not per-frame availability, so a transient nil from `scrubThumbnail` while a segment is still being produced is expected and means "time only, no image". It stays false on a software session, and a live one nonetheless serves stills from its packet ring, so a live caller asks `liveScrubThumbnail` rather than this flag. | +| `scrubThumbnail(atSeconds:maxWidth:)` | Cache-backed still for the active session, live or VOD. Decodes bytes the session already holds, so it opens no second connection and works on single-connection sources (debrid / torrent links, IPTV accounts capped at one request) where a second demuxer is refused. A native session decodes from its segment cache, a software session from its packet cache (VOD, AE#605) or its DVR packet ring (live, #544). A VOD target outside what is retained returns nil rather than the nearest retained frame. | +| `vodScrubThumbnail(atSeconds:maxWidth:)`, `liveScrubThumbnail(atSessionSeconds:maxWidth:)` | The two arms, for callers that know which axis they hold. Both arms also serve software sessions: the live arm out of the DVR packet ring (#544), the VOD arm out of the packet cache its seeks land in (AE#605), keyframe to target, on the same axis `seek(to:)` takes. | +| `supportsCacheBackedStills` | True while the session can serve `scrubThumbnail` without a second connection: any native session, a software live session, and a software VOD session reading a remote source. Gate the scrub-preview affordance on it: it reports capability, not per-frame availability, so a transient nil from `scrubThumbnail` while a segment is still being produced, or for a position the cache does not hold, is expected and means "time only, no image". False on a software session playing a local file, which keeps no cache because re-reading the file is free; `makeFrameExtractor()` serves that case. Before AE#605 this was false on every software session, live included. | ## Certificate trust diff --git a/docs/cli.md b/docs/cli.md index 8af49846b..55d5484a7 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -140,7 +140,7 @@ the same clock as `FIRSTFRAME`. The 1 Hz tick samples the phase, which is far to signal apart from the moment the rate rolls; a healthy native join is exactly two edges, `loading` at the load and `playing` at the roll (AE#440). -`--subs ` matches against the track's libavcodec name or language and logs every overlay cue and cue trim as it lands. `--host-calls` replays host post-load behavior against the fresh session: `play`, `extractor` (`makeFrameExtractor`), `setrate` (`setRate(1.0)`), `pausestart` (Sodalite#104 round 4: `pause()` the instant load returns, before any frame exists, and `play()` at t=8; the shape of a host that holds a fresh load paused, and a software session used to answer it with eight ticks of `enq=+0 status=unknown r4d=n`, a black picture under a paused clock; now the `[SWHost] #104` lines show the first frame presented at a stopped clock and `startup 8/8 presenting` arrives while paused), `ratehold` (set 1.5, pause at tick 3, resume at tick 5, then read the rate back off the transport itself: the #436 drill, and it fails the run if the resume came back at 1.0), `reloadlive` (reload the URL on the live path when the probe flags it live, the AetherPlayer Open URL flow), `seekback` (rewind 20 s into the DVR window at t=15, return to the live edge at t=30), `overlapseek` (the #292 seek-window drills below), `pausehold` (Sodalite#104: pause at t=10 and HOLD until ten seconds before the end, printing the playhead, the edge and the resident depth every second, which is how a session paused for longer than its own DVR window is measured without waiting out a real one: pair it with `--dvr-window 30` and `--seconds 90` and the ninety minute question becomes a ninety second run), `still` (#544: asks for a scrub still at three aims, 20 s behind the playhead at t=15, 5 s behind at t=20 and at the edge at t=25, writing each to `/tmp/aetherctl-still-.png` and reporting hit or MISS with the decode time; pair it with `--sw --dvr-window N`, where the picture comes out of the DVR packet ring rather than a segment cache, and read the FILE as well as the count, because the bundled seed burns its own second into the frame so a still asked for 14.85 s showing `14` is the verdict that it decoded the right moment and not merely an image; exit 6 when nothing hit, 5 when the session ended before the first aim), `stallclock` (AE#549: stop the master clock at t=4 behind the host's back, the way an interrupted audio session does, then call a plain `play()` at t=7 and read whether the playhead moves again; the interruption itself cannot be staged on macOS, its outcome can, and without the `RendererClockResume` branch the run ends with the clock standing exactly where the stall left it, which is the field log's shape), and `pauseseek` (pause at t=12, seek at t=15 while paused, resume at t=20; with `--sw` the five paused ticks between landing and resume show what the `[SWDiag]` line reports while the pump is parked and has not heard of the seek, the AE#479 shape); this is how the pre-arming `setRate` wedge was isolated. +`--subs ` matches against the track's libavcodec name or language and logs every overlay cue and cue trim as it lands. `--host-calls` replays host post-load behavior against the fresh session: `play`, `extractor` (`makeFrameExtractor`), `setrate` (`setRate(1.0)`), `pausestart` (Sodalite#104 round 4: `pause()` the instant load returns, before any frame exists, and `play()` at t=8; the shape of a host that holds a fresh load paused, and a software session used to answer it with eight ticks of `enq=+0 status=unknown r4d=n`, a black picture under a paused clock; now the `[SWHost] #104` lines show the first frame presented at a stopped clock and `startup 8/8 presenting` arrives while paused), `ratehold` (set 1.5, pause at tick 3, resume at tick 5, then read the rate back off the transport itself: the #436 drill, and it fails the run if the resume came back at 1.0), `reloadlive` (reload the URL on the live path when the probe flags it live, the AetherPlayer Open URL flow), `seekback` (rewind 20 s into the DVR window at t=15, return to the live edge at t=30), `overlapseek` (the #292 seek-window drills below), `pausehold` (Sodalite#104: pause at t=10 and HOLD until ten seconds before the end, printing the playhead, the edge and the resident depth every second, which is how a session paused for longer than its own DVR window is measured without waiting out a real one: pair it with `--dvr-window 30` and `--seconds 90` and the ninety minute question becomes a ninety second run), `still` (#544: asks for a scrub still at three aims, 20 s behind the playhead at t=15, 5 s behind at t=20 and at the edge at t=25, writing each to `/tmp/aetherctl-still-.png` and reporting hit or MISS with the decode time; pair it with `--sw --dvr-window N`, where the picture comes out of the DVR packet ring rather than a segment cache, and read the FILE as well as the count, because the bundled seed burns its own second into the frame so a still asked for 14.85 s showing `14` is the verdict that it decoded the right moment and not merely an image; exit 6 when nothing hit, 5 when the session ended before the first aim; on a VOD session (AE#605) the three aims are 10.5 s behind the playhead, 4.5 s ahead of it and 600 s past it, through `scrubThumbnail`, and the third is EXPECTED to miss, because a frame the cache does not hold yet must not be answered with the one before it: `--sw` against an HTTP source is the software packet-cache case, and the testsrc fixture's counter in the PNG is the verdict that the still decoded to the target rather than snapping to its keyframe), `stallclock` (AE#549: stop the master clock at t=4 behind the host's back, the way an interrupted audio session does, then call a plain `play()` at t=7 and read whether the playhead moves again; the interruption itself cannot be staged on macOS, its outcome can, and without the `RendererClockResume` branch the run ends with the clock standing exactly where the stall left it, which is the field log's shape), and `pauseseek` (pause at t=12, seek at t=15 while paused, resume at t=20; with `--sw` the five paused ticks between landing and resume show what the `[SWDiag]` line reports while the pump is parked and has not heard of the seek, the AE#479 shape); this is how the pre-arming `setRate` wedge was isolated. `--seek-every N` seeks once every N ticks past tick 10, walking `--seek-pattern ` if one is given (a short backward hop otherwise), and `--seek-count K` stops after K seeks so a run can be a BURST and then play. Both halves are needed for anything about what a seek sequence leaves behind: the burst puts the store in the state under test, and only the playing half shows what the overlay carries through it. That pairing is what made AE#362's second mechanism reproducible (a hole between a restarted pump and the island the previous run left ahead of it, decoded across and then never re-read).