1import Foundation
2import AVFoundation
3import QuartzCore
4
5/// RAM cache of decoded, full-quality stand-in frames.
6///
7/// The players keep only ~1s of decoded video buffered, so the instant of a
8/// clip boundary (an item swap, or a long seek inside a stitched composition)
9/// has nothing exact to show for a beat — the viewer fell back to a 240px
10/// filmstrip thumb, or the "Loading Media…" spinner when even that missed.
11/// This cache holds the *exact* frames those moments need: cut heads and
12/// tails near the playhead are warmed before the boundary arrives, and any
13/// frame the viewer is currently unable to show gets decoded on demand from
14/// the sharpest thing on disk (chunk, rescue slice, or playable original).
15///
16/// Budget comes from the `maxRAMGB` default (Settings → Global, 2 GB when
17/// unset); a 1080p frame is ~8 MB, so even the default holds a couple of
18/// hundred boundaries. Frames are keyed on 0.25s buckets — a stand-in a
19/// fraction of a second off is indistinguishable during the sub-second hold
20/// it covers.
21final class FrameCache {
22 static let shared = FrameCache()
23 private static let bucket = 0.25
24
25 static var ramGB: Int {
26 let gb = UserDefaults.standard.integer(forKey: "maxRAMGB")
27 return gb > 0 ? gb : 2
28 }
29
30 private let cache = NSCache<NSString, CGImage>()
31 /// Decodes in flight / recently failed (source vanished mid-decode, or a
32 /// truncated file) — failures back off so a hopeless frame isn't retried
33 /// every tick. Main-thread only.
34 private var inFlight = Set<String>()
35 private var failedAt: [String: Double] = [:]
36 private let genQueue = DispatchQueue(label: "sequencer.framecache",
37 qos: .userInitiated)
38
39 init() { refreshBudget() }
40
41 /// Re-read the budget after the Settings field changes.
42 func refreshBudget() {
43 cache.totalCostLimit = Self.ramGB * 1_000_000_000
44 }
45
46 private func key(_ mediaKey: String, bucket: Int) -> NSString {
47 "\(mediaKey)@\(bucket)" as NSString
48 }
49 private func bucketIndex(_ src: Double) -> Int {
50 Int((src / Self.bucket).rounded())
51 }
52
53 /// The cached frame nearest `src`, if any (this bucket or a neighbor).
54 func image(media: MediaItem, at src: Double) -> CGImage? {
55 let b = bucketIndex(src)
56 for d in [0, -1, 1] {
57 if let img = cache.object(forKey: key(media.cacheKey, bucket: b + d)) {
58 return img
59 }
60 }
61 return nil
62 }
63
64 /// Decode the frame at `src` into the cache if it isn't there already.
65 /// `source` is a playable (file, time-in-file) pair for this moment,
66 /// resolved on the main thread by `ChunkManager.frameSource` — nil (no
67 /// chunk, no playable original) is a silent no-op. Posts
68 /// `.viewerNeedsRefresh` when the frame lands so a waiting cell picks it
69 /// up. Cheap and self-deduping; safe to call every tick.
70 /// `SEQ_NOWARM=1` disables decodes (diagnostic kill switch).
71 private static let disabled = ProcessInfo.processInfo.environment["SEQ_NOWARM"] != nil
72
73 func warm(mediaKey: String, at src: Double, source: (url: URL, time: Double)?) {
74 guard !Self.disabled, let source else { return }
75 let k = key(mediaKey, bucket: bucketIndex(src))
76 let ks = k as String
77 guard cache.object(forKey: k) == nil, !inFlight.contains(ks) else { return }
78 let now = CACurrentMediaTime()
79 if let failed = failedAt[ks], now - failed < 5 { return }
80 inFlight.insert(ks)
81 genQueue.async { [self] in
82 let asset = AVURLAsset(url: source.url)
83 let gen = AVAssetImageGenerator(asset: asset)
84 gen.appliesPreferredTrackTransform = true
85 // Exact going in, a hair of slack after: the frame ON the cut is
86 // what a boundary needs, but a keyframe-snap decode is far cheaper
87 // than a precise reverse walk.
88 gen.requestedTimeToleranceBefore = .zero
89 gen.requestedTimeToleranceAfter = CMTime(seconds: 0.2, preferredTimescale: 600)
90 let t = CMTime(seconds: max(0, source.time), preferredTimescale: 60000)
91 let img = try? gen.copyCGImage(at: t, actualTime: nil)
92 DispatchQueue.main.async {
93 self.inFlight.remove(ks)
94 if let img {
95 self.failedAt.removeValue(forKey: ks)
96 self.cache.setObject(img, forKey: k,
97 cost: img.bytesPerRow * img.height)
98 NotificationCenter.default.post(name: .viewerNeedsRefresh,
99 object: nil)
100 } else {
101 self.failedAt[ks] = CACurrentMediaTime()
102 }
103 }
104 }
105 }
106}