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

### Added

- **Frame-accurate downscaled stills through `FrameExtractor.preview(at:maxWidth:)`.**
Previews decode forward to the requested frame while retaining full reconstruction and high-quality
scaling. They yield to starved playback and keep a small, separate cache. The CLI exposes the same
behavior with `aetherctl extract --preview --width <px>`.

- **Remote MPEG-DASH on-demand manifests play (AE#859, thanks @qoli).**
A static, single-Period MPD whose representations are single files addressed by `SegmentBase`
failed at open with `Invalid data found when processing input`: the FFmpeg build has no DASH
Expand Down
40 changes: 31 additions & 9 deletions Sources/AetherEngine/FrameExtractor/FrameCache.swift
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
import Foundation
import CoreGraphics

/// Size-bounded LRU cache of rendered frames keyed by `(mode, position bucket)`.
/// Per-mode count limit caps the cache at `thumbnailLimit + snapshotLimit` CGImages.
/// Key omits target size: one extractor serves one fixed-size surface, so a repeat
/// position at a different maxWidth/maxSize returns the first-rendered image.
/// Count-bounded LRU cache of rendered frames with mode-specific position keys.
/// Per-mode count limit caps the cache at `thumbnailLimit + previewLimit + snapshotLimit` CGImages.
/// Preview keys include exact requested time and width. Thumbnail and snapshot stores
/// retain their existing position-bucket keys.
/// Not thread-safe; FrameExtractor owns the only instance and touches it actor-isolated.
final class FrameCache {
private struct PreviewKey: Hashable {
let seconds: Double
let width: Int
}
private let previewLimit: Int
private var previewOrder: [PreviewKey] = []
private var previewStore: [PreviewKey: CGImage] = [:]

private let thumbnailLimit: Int
private let snapshotLimit: Int
private let thumbnailBucketSeconds: Double
Expand All @@ -21,7 +29,9 @@ final class FrameCache {
private var thumbnailStore: [Int: CGImage] = [:]
private var snapshotStore: [Int: CGImage] = [:]

init(thumbnailLimit: Int, snapshotLimit: Int, thumbnailBucketSeconds: Double) {
init(thumbnailLimit: Int, snapshotLimit: Int, thumbnailBucketSeconds: Double, previewLimit: Int = 2) {
precondition(previewLimit >= 0, "previewLimit must not be negative")
self.previewLimit = previewLimit
precondition(thumbnailBucketSeconds > 0, "thumbnailBucketSeconds must be positive")
self.thumbnailLimit = thumbnailLimit
self.snapshotLimit = snapshotLimit
Expand All @@ -38,9 +48,14 @@ final class FrameCache {
return rounded < 0x1p62 ? Int(rounded) : 1 << 62
}

func get(mode: FrameMode, seconds: Double) -> CGImage? {
func get(mode: FrameMode, seconds: Double, targetWidth: Int = 0) -> CGImage? {
let key = bucket(seconds, mode: mode)
switch mode {
case .preview:
let previewKey = PreviewKey(seconds: seconds, width: targetWidth)
guard let img = previewStore[previewKey] else { return nil }
touch(&previewOrder, previewKey)
return img
case .thumbnail:
guard let img = thumbnailStore[key] else { return nil }
touch(&thumbnailOrder, key)
Expand All @@ -52,9 +67,14 @@ final class FrameCache {
}
}

func set(_ image: CGImage, mode: FrameMode, seconds: Double) {
func set(_ image: CGImage, mode: FrameMode, seconds: Double, targetWidth: Int = 0) {
let key = bucket(seconds, mode: mode)
switch mode {
case .preview:
let previewKey = PreviewKey(seconds: seconds, width: targetWidth)
previewStore[previewKey] = image
touch(&previewOrder, previewKey)
evict(&previewOrder, &previewStore, limit: previewLimit)
case .thumbnail:
thumbnailStore[key] = image
touch(&thumbnailOrder, key)
Expand All @@ -67,20 +87,22 @@ final class FrameCache {
}

func clear() {
previewOrder.removeAll()
previewStore.removeAll()
thumbnailOrder.removeAll()
snapshotOrder.removeAll()
thumbnailStore.removeAll()
snapshotStore.removeAll()
}

private func touch(_ order: inout [Int], _ key: Int) {
private func touch<Key: Hashable>(_ order: inout [Key], _ key: Key) {
if let idx = order.firstIndex(of: key) {
order.remove(at: idx)
}
order.insert(key, at: 0)
}

private func evict(_ order: inout [Int], _ store: inout [Int: CGImage], limit: Int) {
private func evict<Key: Hashable>(_ order: inout [Key], _ store: inout [Key: CGImage], limit: Int) {
while store.count > limit, let lru = order.popLast() {
store[lru] = nil
}
Expand Down
30 changes: 16 additions & 14 deletions Sources/AetherEngine/FrameExtractor/FrameDecodeContext.swift
Original file line number Diff line number Diff line change
Expand Up @@ -357,13 +357,6 @@ final class FrameDecodeContext: @unchecked Sendable {
activeProcessorCount: ProcessInfo.processInfo.activeProcessorCount))
ctx.pointee.thread_type = FF_THREAD_FRAME | FF_THREAD_SLICE

// Still extraction needs no deblock or full-rate quality: skip loop filter
// and enable the fast/inaccurate path to cut per-frame CPU on big HEVC/AV1 keyframes.
// AV_CODEC_FLAG2_FAST is a C #define (1 << 0) that does not bridge to Swift.
let AV_CODEC_FLAG2_FAST_VALUE: Int32 = 1 << 0
ctx.pointee.skip_loop_filter = AVDISCARD_ALL
ctx.pointee.flags2 |= AV_CODEC_FLAG2_FAST_VALUE

var opts: OpaquePointer?
if !useHardware {
// Belt-and-suspenders with the software get_format callback above.
Expand Down Expand Up @@ -437,6 +430,7 @@ final class FrameDecodeContext: @unchecked Sendable {
/// Decode one frame at/after `seconds`.
///
/// - thumbnail: first frame after the seek (keyframe), downscaled to `targetWidth`.
/// - preview: decode forward until pts >= seconds, downscaled to `targetWidth`.
/// - snapshot: decode forward until pts >= seconds, return at `maxSize` (aspect-preserved) or native.
///
/// `isCancelled` is polled between packets and before conversion so a superseded scrub
Expand All @@ -462,6 +456,16 @@ final class FrameDecodeContext: @unchecked Sendable {
// (the green / magenta still of #103): no still at all, and nothing read or decoded for it.
guard Self.yieldsStill(dolbyVisionNoBaseLayer: isDolbyVisionNoBaseLayer) else { return nil }

// Full reconstruction matters for previews and snapshots; only coarse keyframe
// thumbnails opt into skipped loop filtering and the codec's inaccurate fast path.
let codecFastFlag: Int32 = 1 << 0 // AV_CODEC_FLAG2_FAST does not bridge to Swift.
ctx.pointee.skip_loop_filter = mode.isFrameAccurate ? AVDISCARD_DEFAULT : AVDISCARD_ALL
if mode.isFrameAccurate {
ctx.pointee.flags2 &= ~codecFastFlag
} else {
ctx.pointee.flags2 |= codecFastFlag
}

// Bound this decode's HTTP reads so a stalled remote source can't park the
// serial decode queue and freeze the scrub preview (issue #27). No-op for
// file:// / custom sources. Disarmed on every exit path.
Expand Down Expand Up @@ -557,7 +561,7 @@ final class FrameDecodeContext: @unchecked Sendable {
av_frame_free(&transferredFrame)
}
}
let width = mode == .thumbnail
let width = mode.usesTargetWidth
? targetWidth
: Self.clampedWidth(frame: f, maxSize: maxSize)
#if DolbyVisionStillReshaping
Expand All @@ -577,7 +581,7 @@ final class FrameDecodeContext: @unchecked Sendable {
}
// tone-map failed: fall through to sws path (degraded but non-nil)
}
return convertToCGImage(frame: f, targetWidth: width)
return convertToCGImage(frame: f, targetWidth: width, highQuality: mode.isFrameAccurate)
}

// Once the demuxer hits EOF, flush the decoder (NULL packet) and keep draining: the
Expand Down Expand Up @@ -637,9 +641,7 @@ final class FrameDecodeContext: @unchecked Sendable {
targetPTS = f.pointee.pts + ticks
offsetTicks = nil
}
if mode == .snapshot,
f.pointee.pts != Int64.min,
f.pointee.pts < targetPTS {
if !mode.accepts(framePTS: f.pointee.pts, targetPTS: targetPTS) {
continue
}

Expand Down Expand Up @@ -770,7 +772,7 @@ final class FrameDecodeContext: @unchecked Sendable {

/// sws_scale the frame to RGBA at `targetWidth` (height from source aspect) into an
/// owned-buffer CGImage. Mirrors the sws tuple-pointer dance in SoftwareVideoDecoder.
private func convertToCGImage(frame: UnsafeMutablePointer<AVFrame>, targetWidth: Int) -> CGImage? {
private func convertToCGImage(frame: UnsafeMutablePointer<AVFrame>, targetWidth: Int, highQuality: Bool) -> CGImage? {
let srcW = Int(frame.pointee.width)
let srcH = Int(frame.pointee.height)
guard srcW > 0, srcH > 0, targetWidth > 0 else { return nil }
Expand All @@ -787,7 +789,7 @@ final class FrameDecodeContext: @unchecked Sendable {
swsContext,
Int32(srcW), Int32(srcH), srcFmt,
Int32(dstW), Int32(dstH), AV_PIX_FMT_RGBA,
Int32(SWS_BILINEAR.rawValue), nil, nil, nil
Int32((highQuality ? SWS_BICUBIC : SWS_BILINEAR).rawValue), nil, nil, nil
)
guard swsContext != nil else { return nil }
// Without this sws converts every picture with its BT.601 default, which put a 1080p
Expand Down
26 changes: 16 additions & 10 deletions Sources/AetherEngine/FrameExtractor/FrameExtractor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,15 @@ public struct BoundedStillFrame: @unchecked Sendable {
}

/// Produces still images from a media URL via an isolated FFmpeg decode context,
/// separate from playback. Two modes share one decode core: `snapshot` (frame-accurate,
/// full-res) and `thumbnail` (keyframe-snapped, low-res, fast). Lazy: context opens on
/// separate from playback. Three modes share one decode core: `snapshot` (frame-accurate,
/// full-res), `preview` (frame-accurate, downscaled) and `thumbnail` (keyframe-snapped, fast). Lazy: context opens on
/// first use; blocking FFmpeg work runs on a dedicated serial queue, not the cooperative pool.
/// Create one per URL; for the playing item prefer `AetherEngine.makeFrameExtractor()`.
public actor FrameExtractor {
private let context: FrameDecodeContext
private let cache: FrameCache
private let decodeQueue: DispatchQueue
/// #93 startup: session-coupled extractors yield elective thumbnail decodes while the
/// #93 startup: session-coupled extractors yield elective thumbnail/preview decodes while the
/// playback pipeline is starved (see `shouldYield`). nil = never yield (standalone use).
private let yieldWhile: (@Sendable () -> Bool)?

Expand Down Expand Up @@ -137,7 +137,7 @@ public actor FrameExtractor {
yieldWhile: nil, residentIdentity: identity)
}

/// Yield decision for elective (thumbnail) extraction. The disposable decode's demuxer pulls
/// Yield decision for elective thumbnail/preview extraction. The disposable decode's demuxer pulls
/// megabytes over the same link the segment producer needs; during startup and recovery that
/// contention tipped the first segment past CoreMedia's ~4 s media timeout and killed the
/// AVPlayer loader (-15628, #93 startup). Yield while a producer restart is in flight or the
Expand All @@ -159,6 +159,12 @@ public actor FrameExtractor {
await produce(at: seconds, mode: .thumbnail, targetWidth: maxWidth, maxSize: nil)
}

/// Produce a frame-accurate, downscaled still for interactive scrubbing.
/// Like thumbnails, uncached previews yield while the playback pipeline is starved.
public func preview(at seconds: Double, maxWidth: Int = 320) async -> CGImage? {
await produce(at: seconds, mode: .preview, targetWidth: maxWidth, maxSize: nil)
}

public func snapshot(at seconds: Double, maxSize: CGSize? = nil) async -> CGImage? {
// targetWidth is inert for snapshot; FrameDecodeContext.clampedWidth governs size.
await produce(at: seconds, mode: .snapshot, targetWidth: 0, maxSize: maxSize)
Expand Down Expand Up @@ -265,7 +271,7 @@ public actor FrameExtractor {

/// Permanently tear down the decode context and clear the cache. Awaits teardown on
/// the decode queue, so on return the FFmpeg demuxer/codec/sws are fully released.
/// After shutdown() the extractor is dead: thumbnail/snapshot/prewarm return nil/no-op
/// After shutdown() the extractor is dead: thumbnail/preview/snapshot/prewarm return nil/no-op
/// and do NOT reopen; create a new FrameExtractor to extract again.
public func shutdown() async {
idleTask?.cancel()
Expand All @@ -284,14 +290,14 @@ public actor FrameExtractor {
private func produce(at seconds: Double, mode: FrameMode, targetWidth: Int, maxSize: CGSize?) async -> CGImage? {
// Audit BIT-105: a 0/0 scrub fraction or `CMTime.indefinite.seconds` is a host bug, not a position.
guard !isShutDown, seconds.isFinite, seconds >= 0 else { return nil }
if let hit = cache.get(mode: mode, seconds: seconds) {
if let hit = cache.get(mode: mode, seconds: seconds, targetWidth: targetWidth) {
scheduleIdleClose()
return hit
}
// Elective thumbnails yield to a starved playback pipeline (snapshots are deliberate
// Elective thumbnails/previews yield to a starved playback pipeline (snapshots are deliberate
// one-shot user actions and stay ungated). Checked after the cache: hits are free.
if mode == .thumbnail, yieldWhile?() == true {
EngineLog.emit("[FrameExtractor] thumbnail yielded: playback pipeline starved",
if mode.isElective, yieldWhile?() == true {
EngineLog.emit("[FrameExtractor] \(mode) yielded: playback pipeline starved",
category: .swPlayback, level: .verbose)
return nil
}
Expand Down Expand Up @@ -331,7 +337,7 @@ public actor FrameExtractor {
return FrameResult(image: image)
}
if let image = result.image, !token.isCancelled {
cache.set(image, mode: mode, seconds: seconds)
cache.set(image, mode: mode, seconds: seconds, targetWidth: targetWidth)
}
scheduleIdleClose()
return result.image
Expand Down
12 changes: 11 additions & 1 deletion Sources/AetherEngine/FrameExtractor/FrameTypes.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,19 @@ enum ScrubSegmentTime {

/// Which extraction mode produced (or is requested for) a frame.
///
/// - `thumbnail`: nearest keyframe, no forward decode, downscaled. Cheap; scrub/Recents.
/// - `thumbnail`: nearest keyframe, no forward decode, downscaled. Cheap; posters/Recents.
/// - `preview`: frame-accurate forward decode, downscaled. Interactive scrub previews.
/// - `snapshot`: frame-accurate (decode forward to exact PTS), full/requested res; stills.
public enum FrameMode: Sendable, Hashable {
case thumbnail
case preview
case snapshot

var isFrameAccurate: Bool { self != .thumbnail }
var usesTargetWidth: Bool { self != .snapshot }
var isElective: Bool { self != .snapshot }

func accepts(framePTS: Int64, targetPTS: Int64) -> Bool {
!isFrameAccurate || framePTS == Int64.min || framePTS >= targetPTS
}
}
1 change: 1 addition & 0 deletions Sources/aetherctl/Extract.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ func runExtract(url: URL, at seconds: Double, mode: FrameMode, loops: Int, maxWi
let image: CGImage? = runBlocking {
switch mode {
case .thumbnail: return await extractor.thumbnail(at: pos, maxWidth: maxWidth)
case .preview: return await extractor.preview(at: pos, maxWidth: maxWidth)
case .snapshot: return await extractor.snapshot(at: pos, maxSize: nil)
}
}
Expand Down
16 changes: 11 additions & 5 deletions Sources/aetherctl/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ func printUsage() {
aetherctl dovitest [--out PATH.hevc] <file>
aetherctl feltest [--frames N] <file> (DualLayerDolbyVision trait)
aetherctl dvsurvey [--frames N] [--list K] <file>
aetherctl extract [--at <sec>] [--snapshot] [--width <px>] [--loops <n>] <url>
aetherctl extract [--at <sec>] [--preview|--snapshot] [--width <px>] [--loops <n>] <url>
aetherctl audio [--seconds N] <url>
aetherctl audiotap [--duration S] [--out PATH.wav] [--remote | --software] <url>
(#95: decode the loopback audio track to mono 48k WAV, print continuity stats;
Expand Down Expand Up @@ -206,9 +206,10 @@ func printUsage() {

Flags (extract only):
--at <sec> Seek position in seconds (default 60.0).
--preview Frame-accurate decode downscaled to --width.
--snapshot Frame-accurate decode at full resolution instead
of nearest-keyframe thumbnail.
--width <px> Max output width for thumbnail mode (default 320).
--width <px> Max output width for thumbnail/preview mode (default 320).
--loops <n> Repeat extraction N times, cycling through 8
positions. Useful with `leaks --atExit`.

Expand Down Expand Up @@ -277,8 +278,8 @@ func printUsage() {

extract Extract a still frame from a source. Thumbnail mode
(default) seeks to the nearest keyframe and downscales
to --width. Snapshot mode (--snapshot) decodes
frame-accurately at full resolution. Use --loops N
to --width. Preview mode (--preview) decodes frame-accurately
at --width; snapshot mode (--snapshot) uses full resolution. Use --loops N
with `leaks --atExit` to detect memory leaks.
Writes the first frame to aetherctl-extract-<mode>.png
in a private per-user temporary directory, printed.
Expand Down Expand Up @@ -1076,6 +1077,7 @@ if ["probe", "serve", "validate", "swdecode", "extract", "audio", "customio"].co
let atSeconds = takeDoubleFlag("--at", from: &rest) ?? 60.0
let extractLoops = takeIntFlag("--loops", from: &rest) ?? 1
let extractWidth = takeIntFlag("--width", from: &rest) ?? 320
let previewMode = takeFlag("--preview", from: &rest)
let snapshotMode = takeFlag("--snapshot", from: &rest)
// The opt-in detail passes of `AetherEngine.probe(url:detecting:)`, so both are observable from the CLI
// instead of only through a host. Each costs reads past find_stream_info; the bare `probe` does neither.
Expand Down Expand Up @@ -1196,10 +1198,14 @@ if ["probe", "serve", "validate", "swdecode", "extract", "audio", "customio"].co
case "swdecode":
exit(runSWDecode(url: url, maxPackets: framesOverride ?? 100))
case "extract":
if previewMode, snapshotMode {
print("ERROR: extract accepts only one of --preview or --snapshot")
exit(64)
}
exit(runExtract(
url: url,
at: atSeconds,
mode: snapshotMode ? .snapshot : .thumbnail,
mode: previewMode ? .preview : (snapshotMode ? .snapshot : .thumbnail),
loops: extractLoops,
maxWidth: extractWidth
))
Expand Down
Loading