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
19 changes: 17 additions & 2 deletions Sources/AetherEngine/AetherEngine+Live.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,24 @@ import CoreGraphics

extension AetherEngine {

/// Frame from the DVR segment cache at `atSessionSeconds` (seekableLiveRange axis). No network: converts session time to raw output via seam history, then decodes locally. nil when no native live session, time outside resident window, or decode fails.
/// Frame from the live DVR window at `atSessionSeconds` (seekableLiveRange axis), decoded
/// locally with no network.
///
/// Two sessions can answer, and both read a buffer the session already holds rather than opening
/// a second connection (a live source is forward-only, so a second demuxer could not seek it).
/// A native session decodes from its DVR segment cache after converting session time to raw
/// output via seam history. A software session has no such cache, so it decodes out of its own
/// packet ring (#544), which is the same buffer the scrubber seeks within. nil when neither is
/// live, when the time is outside the resident window, or when the decode fails.
public func liveScrubThumbnail(atSessionSeconds 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.liveScrubStill(atSessionSeconds: seconds, maxWidth: maxWidth)
// A zap between the request and the frame would hand the new channel the old one's picture.
return loadGeneration == gen ? image : nil
}
// seekableLiveRange is output-time + seam shift; segment table and tfdt live on raw output. Resolve newest seam (inverts $currentTime fold).
let outputSeconds: Double
outputSeconds = presentationAxis.itemSeconds(forSourceSeconds: seconds)
Expand Down
27 changes: 24 additions & 3 deletions Sources/AetherEngine/Decoder/SoftwareVideoDecoder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,10 @@ final class SoftwareVideoDecoder: VideoDecodingPipeline, @unchecked Sendable {
/// applied to the filter there (mutating it mid-stream would need a graph rebuild).
var deinterlaceConfig = DeinterlaceConfig()

/// #544: decode on the calling thread with no frame-level threading. Set before `open`; the
/// still extractor is the only caller, everything on a playback path wants the parallel default.
var decodesSingleThreaded = false

/// AE#499: what the container declared about colour, captured at `open` before a single frame
/// exists. A decoded frame carries the VUI alone, and a remux whose VUI is empty would otherwise
/// reach `attachColorSpace` as an untagged picture, so an HDR10 file decoded in software lost its
Expand Down Expand Up @@ -153,8 +157,15 @@ final class SoftwareVideoDecoder: VideoDecodingPipeline, @unchecked Sendable {
return AV_PIX_FMT_YUV420P
}

ctx.pointee.thread_count = Int32(ProcessInfo.processInfo.activeProcessorCount)
ctx.pointee.thread_type = FF_THREAD_FRAME | FF_THREAD_SLICE
if decodesSingleThreaded {
// #544: a still run is one short GOP decoded once. Frame-level threading buys throughput
// nobody is waiting for and costs output delay plus a second worker pool.
ctx.pointee.thread_count = 1
ctx.pointee.thread_type = 0
} else {
ctx.pointee.thread_count = Int32(ProcessInfo.processInfo.activeProcessorCount)
ctx.pointee.thread_type = FF_THREAD_FRAME | FF_THREAD_SLICE
}

// Belt-and-suspenders hwaccel=none: some decoders ignore get_format.
var opts: OpaquePointer?
Expand Down Expand Up @@ -420,14 +431,24 @@ final class SoftwareVideoDecoder: VideoDecodingPipeline, @unchecked Sendable {
onFrame?(pixelBuffer, cmPTS, hdr10PlusData)
}

/// #544: `resetFilterGraph: false` keeps the deinterlace graph across the flush. The still
/// extractor flushes before every run, and rebuilding the graph means a fresh Metal pipeline, a
/// fresh full-resolution hwframes pool AND an unconditional `[Deinterlace] engaged` line, about
/// sixteen times a second while a viewer holds the scrub. That line alone overwrites a host's
/// whole diagnostic ring in half a minute. A still run decodes a full GOP and returns the frame
/// at its target, so the filter has context from this position by the time that frame is made.
func flush() {
flush(resetFilterGraph: true)
}

func flush(resetFilterGraph: Bool) {
lock.lock()
defer { lock.unlock() }
// AE#492: retires every packet a caller had already decided to send. Bumped under the lock,
// so a feed that has not reached `avcodec_send_packet` yet is refused from here on.
_feedEpoch &+= 1
// Deinterlacer temporal references are stale across seeks; drop the graph (lazily rebuilt on next interlaced frame).
deinterlacer.teardown()
if resetFilterGraph { deinterlacer.teardown() }
guard let ctx = codecContext else { return }
avcodec_flush_buffers(ctx)
}
Expand Down
77 changes: 77 additions & 0 deletions Sources/AetherEngine/Native/SoftwarePlaybackHost.swift
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,34 @@ final class SoftwarePlaybackHost {

// MARK: - Live / DVR

/// #544: decodes a scrub still out of `dvrRing`. Built beside the playback decoder so it never
/// holds a stream pointer of its own, and driven only from `stillQueue`, off the demux and feed
/// loops, so a preview frame never costs playback a packet.
private var stillExtractor: SoftwareStillExtractor?
private let stillQueue = DispatchQueue(label: "engine.sw.still", qos: .userInitiated)
private let stillRequests = StillRequestCounter()

/// Newest-wins ticket for still requests. A held scrub asks about sixteen times a second and the
/// queue is serial, so without a ticket the queue takes work faster than it retires it: the card
/// falls further behind the thumb with every request, and the decoding outlives the commit.
final class StillRequestCounter: @unchecked Sendable {
private let lock = NSLock()
private var value: UInt64 = 0

func next() -> UInt64 {
lock.lock()
defer { lock.unlock() }
value &+= 1
return value
}

var latest: UInt64 {
lock.lock()
defer { lock.unlock() }
return value
}
}

/// Disk-spooled DVR rewind ring; non-nil for live sessions with dvrWindowSeconds set. Demux-thread appended (internally locked).
nonisolated(unsafe) private var dvrRing: PacketRingBuffer?

Expand Down Expand Up @@ -786,6 +814,20 @@ final class SoftwarePlaybackHost {
)
}
}
if isLive, dvrRing != nil {
do {
stillExtractor = try SoftwareStillExtractor(
stream: vStream,
videoStreamIndex: videoStreamIndex,
timeBaseSeconds: videoTimeBaseSeconds,
deinterlace: deinterlaceConfig)
} catch {
// A session without scrub stills still plays; the preview just stays empty.
EngineLog.emit("[SWHost] #544 still extractor unavailable (\(error))", category: .swPlayback)
stillExtractor = nil
}
}

videoDecoder.onFirstHDR10PlusDetected = { [weak self] in
self?.onFirstHDR10PlusDetected?()
}
Expand Down Expand Up @@ -1268,6 +1310,35 @@ final class SoftwarePlaybackHost {
return outcome
}

/// #544: a scrub still for the live DVR window, decoded out of the packet ring.
///
/// Takes the session axis, exactly as `seek` does, and converts it with the same `sessionStartPts`
/// the rewind uses, so the still and the commit can never name two different moments. Runs on its
/// own queue: the ring is internally locked and safe to read alongside the feeder, but decoding on
/// the demux or feed loop would make the viewer pay for the preview in dropped packets.
func liveScrubStill(atSessionSeconds seconds: Double, maxWidth: Int) async -> CGImage? {
guard isLive, let ring = dvrRing, let extractor = stillExtractor else { return nil }
let startPts: Double = {
liveEdgeLock.lock()
defer { liveEdgeLock.unlock() }
return sessionStartPts.isFinite ? sessionStartPts : 0
}()
let targetSource = startPts + seconds
let requests = stillRequests
let ticket = requests.next()
return await withCheckedContinuation { continuation in
stillQueue.async {
guard ticket == requests.latest else {
// Superseded while it waited: decoding it would only push the newer one later.
continuation.resume(returning: nil)
return
}
continuation.resume(
returning: extractor.still(from: ring, 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 startPts: Double = {
Expand Down Expand Up @@ -1371,6 +1442,12 @@ final class SoftwarePlaybackHost {
vodPacketReadAhead = nil
renderer.subtitleCompositor.reset()

if let extractor = stillExtractor {
stillExtractor = nil
// Torn down ON the still queue, so a decode already in flight finishes against a codec
// context that is still open rather than one freed out from under it.
stillQueue.async { extractor.close() }
}
dvrRing?.close()
dvrRing = nil
liveEdgeLock.lock()
Expand Down
199 changes: 199 additions & 0 deletions Sources/AetherEngine/Native/SoftwareStillExtractor.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
import CoreGraphics
import CoreMedia
import CoreVideo
import Foundation
import VideoToolbox
import AetherLibavcodec
import AetherLibavformat

/// Decodes one still out of a run of demuxed packets (#544). No demuxer and no container: the
/// software live path already holds its whole timeshift window as packets, it only ever lacked an
/// image consumer.
///
/// It drives a real `SoftwareVideoDecoder` rather than a minimal one of its own, so the still is the
/// picture the renderer would show. Broadcast is where that matters: interlaced MPEG-2 at a
/// non-square sample aspect is the normal case on a tuner, and both the deinterlace and the SAR
/// resolution behind it are hardened here already. A second decoder would have to re-derive them and
/// would get a combed, stretched frame wrong in exactly the cases the preview exists for.
///
/// Not thread-safe by itself: the host owns one and serialises requests onto its own queue, off the
/// demux and feed loops, so a still never costs playback a packet.
final class SoftwareStillExtractor: @unchecked Sendable {

/// Bounds on one run. A broadcast GOP is well under a second; these refuse the pathological
/// stream rather than letting it hold a request.
struct Limits {
var maxPackets: Int = 900
var maxSpanSeconds: Double = 12
/// 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
}

private let decoder = SoftwareVideoDecoder()
private let videoStreamIndex: Int32
private let timeBaseSeconds: Double
private let limits: Limits
private var isOpen = false

init(stream: UnsafeMutablePointer<AVStream>,
videoStreamIndex: Int32,
timeBaseSeconds: Double,
deinterlace: DeinterlaceConfig,
limits: Limits = Limits()) throws {
self.videoStreamIndex = videoStreamIndex
self.timeBaseSeconds = timeBaseSeconds
self.limits = limits
decoder.deinterlaceConfig = deinterlace
decoder.decodesSingleThreaded = true
try decoder.open(stream: stream) { _, _, _ in }
isOpen = true
}

deinit {
decoder.close()
}

func close() {
guard isOpen else { return }
isOpen = false
decoder.close()
}

/// The frame at `targetPts` (source axis), or nil when the ring cannot serve it.
///
/// Every request is an independent landing, so the decoder is flushed first: a still is a seek,
/// and carrying references across two unrelated positions is what produces a smeared picture.
func still(from ring: PacketRingBuffer, targetPts: Double, maxWidth: Int) -> CGImage? {
guard isOpen, timeBaseSeconds > 0, maxWidth > 0 else { return nil }
guard let run = ring.stillRun(target: targetPts,
maxPackets: limits.maxPackets,
maxSpanSeconds: limits.maxSpanSeconds,
reorderTail: limits.reorderTail),
!run.isEmpty else { return nil }

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)
}

guard let best = collector.best else { return nil }
return Self.image(from: best, maxWidth: maxWidth)
}

// MARK: - Feeding

private func feed(_ packet: PacketRingBuffer.Packet) {
guard !packet.bytes.isEmpty else { return }
// Through the tracked pair, so the still path stays visible to PacketBalanceTracker.
guard let p = trackedPacketAlloc() else { return }
var pkt: UnsafeMutablePointer<AVPacket>? = p
defer { trackedPacketFree(&pkt) }

guard av_new_packet(p, Int32(packet.bytes.count)) >= 0 else { return }
packet.bytes.withUnsafeBytes { raw in
if let base = raw.baseAddress, let dst = p.pointee.data {
memcpy(dst, base, packet.bytes.count)
}
}
p.pointee.pts = Int64((packet.pts / timeBaseSeconds).rounded())
p.pointee.dts = p.pointee.pts
p.pointee.flags = packet.isKeyframe ? AV_PKT_FLAG_KEY : 0
p.pointee.stream_index = videoStreamIndex
decoder.decode(packet: p, epoch: nil)
}

// MARK: - Frame selection

/// Keeps the best candidate as the run decodes rather than every frame it produced. The buffers
/// come out of the decoder's own pool, so holding a whole GOP of them would starve the pool the
/// next run has to draw from. Two are enough: the one the target asks for, and the oldest as the
/// fallback below.
///
/// `onFrame` is `@Sendable` and the decoder calls it from its own drain, so the box is locked
/// even though the run itself is serial.
private final class FrameCollector: @unchecked Sendable {
private let lock = NSLock()
private let target: Double
private var atOrBefore: (pixelBuffer: CVPixelBuffer, seconds: Double)?
private var earliest: (pixelBuffer: CVPixelBuffer, seconds: Double)?

init(target: Double) {
self.target = target
}

func append(pixelBuffer: CVPixelBuffer, seconds: Double) {
guard seconds.isFinite else { return }
lock.lock()
defer { lock.unlock() }
if earliest == nil || seconds < earliest!.seconds {
earliest = (pixelBuffer, seconds)
}
guard seconds <= target else { return }
if atOrBefore == nil || seconds > atOrBefore!.seconds {
atOrBefore = (pixelBuffer, seconds)
}
}

/// The newest frame at or before the target.
///
/// The fallback is not the live edge: a run opens on a keyframe at or before the target, so
/// a frame at or before it normally exists. It covers the eviction race, where that opening
/// keyframe was dropped between the index snapshot and the disk read and the run therefore
/// begins AFTER the target. Returning the earliest frame there is a picture from up to one
/// GOP late, which is a better answer than an empty card.
var best: CVPixelBuffer? {
lock.lock()
defer { lock.unlock() }
return (atOrBefore ?? earliest)?.pixelBuffer
}
}

// MARK: - Image

/// The decoder attaches the resolved sample aspect to the buffer (`#177`), so the still reads
/// its answer rather than resolving one of its own, and a 704x480 4:3 broadcast frame draws 4:3.
static func image(from pixelBuffer: CVPixelBuffer, maxWidth: Int) -> CGImage? {
var cgImage: CGImage?
guard VTCreateCGImageFromCVPixelBuffer(pixelBuffer, options: nil, imageOut: &cgImage) == noErr,
let source = cgImage else { return nil }

let srcW = source.width
let srcH = source.height
guard srcW > 0, srcH > 0 else { return nil }

let (dstW, dstH) = FrameDecodeContext.displayDimensions(
srcW: srcW, srcH: srcH, sar: sampleAspect(of: pixelBuffer), targetWidth: maxWidth)

// Always drawn into an owned bitmap, even at 1:1. VideoToolbox documents the CGImage as
// backed by the CVPixelBuffer it was made from, and that buffer goes back to the decoder's
// pool the moment this run lets go of it, so handing the source out would let the next run
// repaint a picture the host is still showing.
guard let space = CGColorSpace(name: CGColorSpace.sRGB),
let ctx = CGContext(data: nil, width: dstW, height: dstH,
bitsPerComponent: 8, bytesPerRow: 0, space: space,
bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue) else {
return source
}
ctx.interpolationQuality = .high
ctx.draw(source, in: CGRect(x: 0, y: 0, width: dstW, height: dstH))
return ctx.makeImage() ?? source
}

static func sampleAspect(of pixelBuffer: CVPixelBuffer) -> AVRational {
guard let attachment = CVBufferCopyAttachment(
pixelBuffer, kCVImageBufferPixelAspectRatioKey, nil) as? [CFString: Any],
let h = attachment[kCVImageBufferPixelAspectRatioHorizontalSpacingKey] as? Int,
let v = attachment[kCVImageBufferPixelAspectRatioVerticalSpacingKey] as? Int,
h > 0, v > 0 else {
return AVRational(num: 1, den: 1)
}
return AVRational(num: Int32(h), den: Int32(v))
}
}
Loading
Loading