1import Foundation
2import AVFoundation
3
4/// Demand-driven proxy generation in 30-second chunks.
5///
6/// Instead of transcoding whole files up front, each media gets ProRes Proxy
7/// chunks rendered around where the user is actually viewing: the chunk under
8/// the playhead (and the next one) jump the queue; the ranges used by clips
9/// on the timeline fill in behind. Playback runs off a per-media
10/// AVComposition that stitches ready chunks together, falling back to the
11/// original file for not-yet-rendered ranges (or showing nothing + a
12/// "processing…" badge when the original isn't AVFoundation-playable, e.g.
13/// DNx). Legacy whole-file `proxy.mov` caches are still used when present.
14final class ChunkManager {
15 /// The document context that owns this manager. Set at construction.
16 unowned var ctx: DocumentContext!
17 static let chunkSeconds: Double = 30
18
19 /// Adaptive proxy quality *ladder*, expressed as a fraction of a build
20 /// target width rather than an absolute size. Level 0 is the target itself;
21 /// lower levels trade resolution (and, further down, frame rate) for encode
22 /// speed so realtime playback keeps up even when the target is large. The
23 /// target width itself comes from the preview size (`previewTargetWidth`) —
24 /// a big preview asks for sharper proxies, a small one isn't over-rendered.
25 struct Quality { let widthFraction: Double; let fpsDivisor: Int }
26 static let qualities: [Quality] = [
27 Quality(widthFraction: 1.0, fpsDivisor: 1), // full target
28 Quality(widthFraction: 0.66, fpsDivisor: 1), // reduced
29 Quality(widthFraction: 0.5, fpsDivisor: 2), // low
30 Quality(widthFraction: 0.33, fpsDivisor: 2), // minimum
31 ]
32
33 /// Largest on-screen preview width in device pixels, reported by the viewer
34 /// (`setPreviewTargetWidth`). Background optimisation targets this so the
35 /// proxy is as sharp as the preview it's displayed in. Defaults to the old
36 /// fixed 960 until the viewer measures itself.
37 private(set) var previewTargetWidth = 960
38
39 /// Hard upper bound on proxy resolution: 1080p-wide. A proxy only has to be
40 /// sharp enough to edit against, not master from — and a 4K source is ~8×
41 /// the pixels (and cache bytes) of 1080p for detail an editing preview can't
42 /// use. (It also caps content that was cheaply up-scaled to 4K — e.g. 800×600
43 /// gameplay blown up to 4K — back to a size that reflects its real detail.)
44 static let maxProxyWidth = 1920
45
46 /// The width a fresh proxy for this media should reach — the preview target,
47 /// never upscaled past the source, and never above the 1080p cap.
48 private func targetWidth(for media: MediaItem) -> Int {
49 let native = media.width > 0 ? media.width : previewTargetWidth
50 return min(native, previewTargetWidth, Self.maxProxyWidth)
51 }
52
53 /// Effective encode width at `level` for this media (even, ffmpeg-friendly).
54 private func buildWidth(level: Int, media: MediaItem) -> Int {
55 let q = Self.qualities[min(max(0, level), Self.qualities.count - 1)]
56 let w = Int((Double(targetWidth(for: media)) * q.widthFraction).rounded())
57 return max(160, w - (w % 2))
58 }
59
60 /// Whether a chunk already on disk at width `built` should be re-encoded:
61 /// either it's below the current sharpness target (sharpen up toward it), or
62 /// it's above the hard 1080p cap (a legacy 4K proxy to shrink back down — the
63 /// cap is constant, so this converges and never churns on window resize). The
64 /// `attempted` guard stops a failed re-encode from looping.
65 private func needsReencode(built: Int, attempted: Int?, target: Int) -> Bool {
66 if built < target { return (attempted ?? 0) < target }
67 if built > Self.maxProxyWidth { return (attempted ?? built) > Self.maxProxyWidth }
68 return false
69 }
70
71 /// A short "rescue" slice standing in for a chunk the playhead landed on
72 /// cold: only `dur` seconds starting `offset` into the chunk's grid slot
73 /// exist on disk (as `rNNNNNN.mov`). Session-only — never persisted; stale
74 /// slice files from a crash are purged by the launch reconcile walk.
75 struct Rescue { let offset: Double; let dur: Double; let width: Int }
76
77 private struct MediaState {
78 var built: [Int: Int] = [:] // chunk index → effective width on disk
79 var attempted: [Int: Int] = [:] // chunk index → width of the last attempt
80 var partial: [Int: Rescue] = [:] // chunk index → rescue slice on disk
81 var rescueAttempted: Set<Int> = []
82 var inFlight: Set<Int> = []
83 var failed: Set<Int> = []
84 var urgent: [Int] = []
85 var background: [Int] = []
86 var version = 0
87 var scanned = false
88 var composition: AVComposition?
89 var compositionVersion = -1
90 var originalPlayable: Bool?
91 // Adaptive-quality controller state (see decideQuality).
92 var qualityIndex = 0
93 var normWall: [Int: Double] = [:] // EMA of wall/realtime at each level
94 var networkLimited = false
95 var fastStreak = 0
96 }
97
98 // Main-thread only.
99 private var states: [String: MediaState] = [:]
100 private var mediaByKey: [String: MediaItem] = [:]
101 private var activeBuilds = 0
102 private let maxBuilds = 2
103
104 /// When paused, no NEW chunk builds start; in-flight ones finish and the
105 /// queue is retained, resuming where it left off. Main-thread only.
106 private(set) var isPaused = false
107
108 /// Set when the owning document closes. In-flight builds run off-main and
109 /// their completions land back on main; a completion (via `pump`) reaches
110 /// `ctx`, which is `unowned` and may already be gone once the document is
111 /// torn down. `stopped` makes every ctx-touching entry point a no-op, so a
112 /// build finishing after close can't trap on a dangling context.
113 private var stopped = false
114 func stop() {
115 stopped = true
116 geometryPending?.cancel()
117 geometryPending = nil
118 }
119
120 /// Toggle proxy optimization on/off (driven by the status-bar readout).
121 func setPaused(_ paused: Bool) {
122 guard paused != isPaused else { return }
123 isPaused = paused
124 if !paused { pump() }
125 NotificationCenter.default.post(name: .mediaStatusChanged, object: nil)
126 }
127
128 /// Playback started: while paused, resume building the chunks it needs.
129 func playbackDidStart() { pump() }
130
131 /// The viewer reports its largest preview cell's width in device pixels so
132 /// optimisation can target a proxy that's actually sharp at that size.
133 /// Quantised to a ladder so layout jitter of a few pixels doesn't churn the
134 /// target; growing it opens fresh upgrade work.
135 func setPreviewTargetWidth(_ pixels: Int) {
136 let steps = [640, 960, 1280, 1600, 1920, 2560, 3200, 3840]
137 let q = steps.first { $0 >= pixels } ?? steps.last!
138 guard q != previewTargetWidth else { return }
139 let grew = q > previewTargetWidth
140 previewTargetWidth = q
141 if grew { pump() } // below-target chunks are now upgrade candidates
142 }
143
144 /// Serialises the tiny per-media width manifests (index → built width) so
145 /// upgrades survive relaunch and a larger preview knows what to re-render.
146 private let manifestQueue = DispatchQueue(label: "sequencer.chunk.manifest")
147
148 init() {
149 NotificationCenter.default.addObserver(
150 forName: .projectChanged, object: nil, queue: .main) { [weak self] _ in
151 self?.scheduleRebuild()
152 }
153 // Hiding/focusing a track changes what to optimize first.
154 NotificationCenter.default.addObserver(
155 forName: .viewOptionsChanged, object: nil, queue: .main) { [weak self] _ in
156 self?.updateDemand(force: true)
157 }
158 }
159
160 private var geometryPending: DispatchWorkItem?
161 /// Coalesce project-geometry rebuilds. During a continuous drag
162 /// `.projectChanged` fires ~60×/s; rebuilding the whole background queue each
163 /// time is pure waste — moving a clip in TIME doesn't change which source
164 /// chunks it needs. Debounce so the queue rebuilds once the edit settles.
165 /// (Initial load calls `ensure` directly via `startServices`, and playback
166 /// refreshes demand every tick, so nothing waits on this.)
167 private func scheduleRebuild() {
168 guard !stopped else { return }
169 geometryPending?.cancel()
170 let w = DispatchWorkItem { [weak self] in
171 guard let self, !self.stopped else { return }
172 self.geometryPending = nil
173 self.ensure(for: self.ctx.store.project)
174 self.updateDemand(force: true)
175 }
176 geometryPending = w
177 DispatchQueue.main.asyncAfter(deadline: .now() + 0.12, execute: w)
178 }
179
180 // MARK: - Paths
181
182 private func chunksDir(_ key: String) -> URL {
183 // Same sanitization as MediaPipeline's cache paths: a blank/garbage key
184 // must not resolve to the cache root or escape it.
185 let safe = MediaPipeline.isValidCacheKey(key) ? key
186 : MediaPipeline.hashedKey("invalid|\(key)")
187 return MediaPipeline.shared.cacheRoot
188 .appendingPathComponent(safe, isDirectory: true)
189 .appendingPathComponent("chunks", isDirectory: true)
190 }
191 private func chunkURL(key: String, index: Int) -> URL {
192 chunksDir(key).appendingPathComponent(String(format: "c%06d.mov", index))
193 }
194 private func rescueURL(key: String, index: Int) -> URL {
195 chunksDir(key).appendingPathComponent(String(format: "r%06d.mov", index))
196 }
197 private func manifestURL(_ key: String) -> URL {
198 chunksDir(key).appendingPathComponent("widths.json")
199 }
200
201 /// Legacy width assumed for a chunk on disk with no manifest entry — the
202 /// old fixed proxy size. Correct enough that a normal-sized preview won't
203 /// pointlessly re-render old caches, while a bigger one still upgrades them.
204 private static let legacyWidth = 960
205
206 private func loadWidths(_ key: String) -> [Int: Int] {
207 guard let data = try? Data(contentsOf: manifestURL(key)),
208 let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Int]
209 else { return [:] }
210 return Dictionary(uniqueKeysWithValues: obj.compactMap { k, v in
211 Int(k).map { ($0, v) }
212 })
213 }
214 private func persistWidths(key: String) {
215 let widths = states[key]?.built ?? [:]
216 let url = manifestURL(key)
217 manifestQueue.async {
218 let obj = Dictionary(uniqueKeysWithValues: widths.map { (String($0.key), $0.value) })
219 if let data = try? JSONSerialization.data(withJSONObject: obj) {
220 try? data.write(to: url)
221 }
222 }
223 }
224 static func chunkIndex(forSource t: Double) -> Int { max(0, Int(t / chunkSeconds)) }
225 static func chunkCount(duration: Double) -> Int {
226 max(1, Int(ceil(duration / chunkSeconds)))
227 }
228
229 private func state(for media: MediaItem) -> MediaState {
230 mediaByKey[media.cacheKey] = media
231 var s = states[media.cacheKey] ?? MediaState()
232 if !s.scanned {
233 s.scanned = true
234 // Cold big network media starts the realtime ladder two rungs
235 // down: the first urgent (playhead) build must land in seconds,
236 // and a full-target encode of 4K source rarely does — the
237 // adaptive controller would only learn that AFTER the user
238 // stared at a placeholder. It climbs back once builds measure
239 // comfortably fast; background upgrades restore full quality.
240 if media.width >= 2560, Self.isNetworkPath(media.path) {
241 s.qualityIndex = 2
242 }
243 let widths = loadWidths(media.cacheKey)
244 if let names = try? FileManager.default
245 .contentsOfDirectory(atPath: chunksDir(media.cacheKey).path) {
246 for n in names where n.hasPrefix("c") && n.hasSuffix(".mov") {
247 if let i = Int(n.dropFirst().dropLast(4)) {
248 s.built[i] = widths[i] ?? Self.legacyWidth
249 }
250 }
251 }
252 states[media.cacheKey] = s
253 }
254 return s
255 }
256
257 private func hasFullProxy(_ media: MediaItem) -> Bool {
258 MediaPipeline.shared.status(for: media).proxyReady
259 }
260
261 // MARK: - Demand
262
263 /// Called continuously from playback/scrub with the source time each
264 /// track is showing. Marks the covering chunk (and the next) urgent.
265 func want(media: MediaItem, sourceTime: Double) {
266 guard media.duration > 0, !media.isAudio, !hasFullProxy(media) else { return }
267 var s = state(for: media)
268 let n = Self.chunkCount(duration: media.duration)
269 let i = min(n - 1, Self.chunkIndex(forSource: sourceTime))
270 let wanted = [i, i + 1].filter {
271 $0 < n && s.built[$0] == nil && !s.inFlight.contains($0) && !s.failed.contains($0)
272 }
273 guard s.urgent != wanted else { return }
274 s.urgent = wanted
275 states[media.cacheKey] = s
276 windowStarved = false // fresh urgency → re-attempt admission
277 pump()
278 }
279
280 /// A live trim/slip drag is exposing this source moment at a clip edge —
281 /// start building its chunk (and the neighbor in the drag direction)
282 /// BEFORE mouse-up, so a newly extended range is covered by the time the
283 /// user plays across it. Cheap and self-deduping; safe to call per drag
284 /// tick.
285 func noteGestureExposure(media: MediaItem, sourceTime: Double, direction: Int) {
286 guard !stopped, media.duration > 0, !media.isAudio, !hasFullProxy(media) else { return }
287 var s = state(for: media)
288 let n = Self.chunkCount(duration: media.duration)
289 let i = min(n - 1, max(0, Self.chunkIndex(forSource: sourceTime)))
290 let wanted = [i, i + (direction < 0 ? -1 : 1)].filter {
291 $0 >= 0 && $0 < n && s.built[$0] == nil
292 && !s.inFlight.contains($0) && !s.failed.contains($0)
293 }
294 guard s.urgent != wanted else { return }
295 s.urgent = wanted
296 states[media.cacheKey] = s
297 windowStarved = false // fresh urgency → re-attempt admission
298 pump()
299 }
300
301 /// Rebuild the background fill queue from the project: every chunk in
302 /// every clip's used source range. Cut heads first (the first chunk of
303 /// every clip is the landing pad for clip-to-clip navigation — a sliver
304 /// of the bytes for most of the "timeline feels instant" effect), then
305 /// everything else; both passes nearest-the-playhead first, so the fill
306 /// grows the working set outward instead of marching from t=0.
307 func ensure(for project: ProjectModel) {
308 guard !stopped else { return }
309 let ph = ctx.playback.playhead
310 for media in project.media {
311 guard media.duration > 0, !media.isAudio, !hasFullProxy(media) else { continue }
312 var s = state(for: media)
313 let n = Self.chunkCount(duration: media.duration)
314 var heads: [(i: Int, d: Double)] = []
315 var rest: [(i: Int, d: Double)] = []
316 var seen = Set<Int>()
317 for clip in project.clips where clip.mediaId == media.id {
318 let a = min(n - 1, Self.chunkIndex(forSource: clip.srcIn))
319 let b = min(n - 1, Self.chunkIndex(forSource: clip.srcIn + clip.duration - 0.001))
320 let d = abs(clip.start - ph)
321 for i in a...max(a, b) where seen.insert(i).inserted {
322 if i == a { heads.append((i, d)) } else { rest.append((i, d)) }
323 }
324 }
325 heads.sort { $0.d < $1.d }
326 rest.sort { $0.d < $1.d }
327 s.background = heads.map(\.i) + rest.map(\.i)
328 states[media.cacheKey] = s
329 }
330 // Geometry changed: clips removed from the timeline may have freed
331 // evictable chunks, so a starved fill is worth one more attempt.
332 fillStarved = false
333 // The scan above is also what makes this document's chunks visible as
334 // eviction candidates. If the cache is over cap (launch reconcile ran
335 // before any document had scanned — nothing was evictable then), this
336 // is the moment eviction can actually see the cold chunks: re-check.
337 MediaPipeline.shared.evictIfNeeded()
338 pump()
339 }
340
341 // MARK: - Edit locus
342
343 /// Timeline positions of recent edits, oldest first. Editors scrub and
344 /// re-play around where they're cutting, so chunks near these positions
345 /// build early and evict late. Fed by `Store` after each committed edit;
346 /// entries expire after ~15 minutes.
347 private var editLoci: [(time: Double, at: Date)] = []
348
349 func noteEdits(times: [Double]) {
350 guard !stopped, !times.isEmpty else { return }
351 let now = Date()
352 for t in times {
353 if let i = editLoci.firstIndex(where: { abs($0.time - t) < 30 }) {
354 editLoci[i] = (t, now)
355 } else {
356 editLoci.append((t, now))
357 }
358 }
359 if editLoci.count > 8 { editLoci.removeFirst(editLoci.count - 8) }
360 updateDemand(force: true)
361 }
362
363 /// Loci still fresh enough to matter.
364 private func activeEditLoci() -> [Double] {
365 let cutoff = Date().addingTimeInterval(-15 * 60)
366 editLoci.removeAll { $0.at < cutoff }
367 return editLoci.map(\.time)
368 }
369
370 /// The timeline window the user can currently SEE (when zoomed in enough
371 /// to be meaningful) — scrubbing happens inside it. Set by TimelineView.
372 private func visibleWindow() -> ClosedRange<Double>? {
373 guard let r = ctx.session.visibleTimeRange,
374 r.upperBound - r.lowerBound <= 600 else { return nil }
375 return r
376 }
377
378 /// Distance from a timeline interval to a point (0 when inside).
379 private static func dist(_ t: Double, _ lo: Double, _ hi: Double) -> Double {
380 t < lo ? lo - t : (t > hi ? t - hi : 0)
381 }
382
383 // MARK: - Prefetch demand (what to optimize first)
384
385 private struct DemandKey: Hashable { let key: String; let index: Int }
386 /// Chunks to build first, best-first. Recomputed from the playhead, playback
387 /// direction, and visibility — supersedes the crude current+next `urgent`.
388 private var demand: [DemandKey] = []
389 /// The subset close enough ahead that playback will hit it imminently — these
390 /// build at the adaptive realtime quality so they land in time; everything
391 /// else builds at the full preview-quality target.
392 private var demandImminent: Set<DemandKey> = []
393 /// Uncovered chunks the playhead is sitting INSIDE right now, mapped to the
394 /// source time being shown — the trigger (and anchor) for rescue slices.
395 private var demandRescue: [DemandKey: Double] = [:]
396 private var lastDemandPlayhead = -1e9
397 private var lastDemandSign = 0.0
398
399 /// How far ahead of the playhead (in the playback direction) to prefetch, and
400 /// how far behind to keep. Ahead is generous so a run of playback never
401 /// out-paces the encoder; behind is small (for a quick reverse / re-view).
402 private static let prefetchAhead = 120.0
403 private static let prefetchBehind = 20.0
404 /// Uncovered chunks within this many seconds ahead are "imminent".
405 private static let imminentAhead = 45.0
406
407 /// Recompute the build order — the heart of "optimize the right thing first."
408 /// For every video clip near an ANCHOR we score the proxy chunks its
409 /// source range needs and sort them: coverage before sharpening, visible
410 /// (and focused) tracks before hidden, and nearer the anchor before
411 /// farther. Anchors, hottest first: the playhead (direction-weighted),
412 /// recent edit sites, and the visible timeline window — the places the
413 /// user is most likely to play next. Chunks outside every window fall
414 /// through to the whole-project background queue (`ensure`). Cheap; safe
415 /// to call as the playhead moves (self-throttled).
416 func updateDemand(force: Bool = false) {
417 guard !stopped else { return }
418 let ph = ctx.playback.playhead
419 let sign = ctx.playback.rate < 0 ? -1.0 : 1.0
420 guard force || abs(ph - lastDemandPlayhead) > 1.5 || sign != lastDemandSign else { return }
421 lastDemandPlayhead = ph
422 lastDemandSign = sign
423
424 let project = ctx.store.project
425 let dir = sign
426 let anyFocused = !ctx.session.focusedTracks.isEmpty
427
428 // Secondary anchors: recent edit sites, then the visible window.
429 // Their bias keeps them strictly behind playhead-window work but far
430 // ahead of the whole-project background fill.
431 struct Window { let lo: Double; let hi: Double; let anchor: Double; let bias: Double }
432 var windows = [Window(lo: ph - (dir > 0 ? Self.prefetchBehind : Self.prefetchAhead),
433 hi: ph + (dir > 0 ? Self.prefetchAhead : Self.prefetchBehind),
434 anchor: ph, bias: 0)]
435 for l in activeEditLoci() {
436 windows.append(Window(lo: l - 45, hi: l + 45, anchor: l, bias: 2_000))
437 }
438 if let vis = visibleWindow() {
439 windows.append(Window(lo: vis.lowerBound, hi: vis.upperBound,
440 anchor: (vis.lowerBound + vis.upperBound) / 2, bias: 6_000))
441 }
442
443 struct Cand { let key: DemandKey; let score: Double; let imminent: Bool }
444 var cands: [Cand] = []
445 var rescue: [DemandKey: Double] = [:]
446 for clip in project.clips where clip.kind == .video {
447 guard let media = project.media(clip.mediaId), media.duration > 0,
448 !media.isAudio, !hasFullProxy(media) else { continue }
449 let visible = !ctx.session.hiddenTracks.contains(clip.track)
450 let focused = ctx.session.focusedTracks.contains(clip.track)
451 var n = 0, target = 0
452 var s: MediaState?
453 for w in windows {
454 let a = max(clip.start, w.lo), b = min(clip.end, w.hi)
455 guard b > a else { continue }
456 if s == nil { // lazy: only scan media that some window needs
457 s = state(for: media)
458 n = Self.chunkCount(duration: media.duration)
459 target = targetWidth(for: media)
460 }
461 guard let st = s else { continue }
462 let srcA = clip.srcIn + (a - clip.start) * clip.speed
463 let srcB = clip.srcIn + (b - clip.start) * clip.speed
464 let ci = min(n - 1, Self.chunkIndex(forSource: min(srcA, srcB)))
465 let cj = min(n - 1, Self.chunkIndex(forSource: max(srcA, srcB) - 1e-6))
466 for idx in ci...max(ci, cj) {
467 if (st.built[idx] ?? 0) >= target { continue } // already good enough
468 let covered = st.built[idx] != nil
469 // Timeline interval this chunk's content plays inside this
470 // clip. Scoring by the INTERVAL (not the chunk's start
471 // moment) is what puts the chunk UNDER the playhead at
472 // score 0 — its start is always "behind", and treating it
473 // that way made the builder prefetch a dozen ahead-chunks
474 // while the user stared at "Loading Media…" on the frame
475 // they'd actually landed on.
476 let t0 = clip.start
477 + (Double(idx) * Self.chunkSeconds - clip.srcIn) / max(1e-4, clip.speed)
478 let t1 = clip.start
479 + (Double(idx + 1) * Self.chunkSeconds - clip.srcIn) / max(1e-4, clip.speed)
480 let cLo = max(min(t0, t1), a), cHi = min(max(t0, t1), b)
481 let isPlayhead = w.bias == 0
482 var score: Double
483 var under = false
484 if isPlayhead {
485 let ahead = dir > 0 ? cLo - ph : ph - cHi // >0 = strictly ahead
486 let behind = dir > 0 ? ph - cHi : cLo - ph // >0 = strictly behind
487 under = ahead <= 0 && behind <= 0 // playing right now
488 score = ahead > 0 ? ahead : (behind > 0 ? behind * 4 : 0)
489 } else {
490 score = w.bias + max(0, max(cLo - w.anchor, w.anchor - cHi))
491 }
492 if covered { score += 10_000 } // coverage beats sharpening
493 if !visible { score += 100_000 } // hidden tracks last
494 else if anyFocused && !focused { score += 1_000 } // the enlarged pane first
495 let ahead = dir > 0 ? cLo - ph : ph - cHi
496 let imminent = isPlayhead && visible && !covered
497 && (under || (ahead >= 0 && ahead < Self.imminentAhead))
498 let dk = DemandKey(key: media.cacheKey, index: idx)
499 if isPlayhead, visible, under, !covered {
500 rescue[dk] = clip.srcIn + (ph - clip.start) * clip.speed
501 }
502 cands.append(Cand(key: dk, score: score, imminent: imminent))
503 }
504 }
505 }
506 cands.sort { $0.score < $1.score }
507 let oldDemand = demand
508 demand.removeAll(keepingCapacity: true)
509 demandImminent.removeAll(keepingCapacity: true)
510 demandRescue = rescue
511 var seen = Set<DemandKey>()
512 for c in cands where seen.insert(c.key).inserted {
513 demand.append(c.key)
514 if c.imminent { demandImminent.insert(c.key) }
515 }
516 // The window moved: what starved before may fit now (different chunks,
517 // and colder ones may have fallen out of the protected radius).
518 if demand != oldDemand { windowStarved = false }
519 pump()
520 }
521
522 // MARK: - Status queries
523
524 func isCovered(media: MediaItem, sourceTime: Double) -> Bool {
525 if media.isAudio { return true } // audio plays the original directly
526 if hasFullProxy(media) { return true }
527 let s = state(for: media)
528 let i = Self.chunkIndex(forSource: sourceTime)
529 if s.built[i] != nil { return true }
530 if let p = s.partial[i] { // a rescue slice covers only part of the slot
531 let t = sourceTime - Double(i) * Self.chunkSeconds
532 return t >= p.offset - 0.05 && t <= p.offset + p.dur - 0.05
533 }
534 return false
535 }
536
537 /// Last known answer; unknown kicks the async composition build (which
538 /// determines it) and reports false meanwhile. Never blocks on media I/O.
539 func originalPlayable(media: MediaItem) -> Bool {
540 if media.isAudio { return true }
541 if let known = states[media.cacheKey]?.originalPlayable { return known }
542 kickCompositionBuild(media: media)
543 return false
544 }
545
546 // MARK: - Cache budget (admission + eviction support)
547
548 /// Set when a build was skipped because the cache is at its cap and
549 /// nothing colder could be evicted to make room. `window` covers the
550 /// playhead prefetch; `fill` the whole-project background queue. Cleared
551 /// whenever the budget or the demand changes.
552 private var windowStarved = false
553 private var fillStarved = false
554 /// The cache is full and optimization is deliberately not building
555 /// everything — drives the status-bar messaging.
556 var budgetStarved: Bool { windowStarved || fillStarved }
557
558 /// The global cache budget moved (reconcile finished, eviction freed
559 /// space, cap changed): try again from a clean slate.
560 func budgetChanged() {
561 guard !stopped else { return }
562 windowStarved = false
563 fillStarved = false
564 pump()
565 }
566
567 /// `SEQ_BUILDLOG=1`: log every build START and admission failure — the
568 /// queue's decisions, not just its results. For chasing "why isn't chunk
569 /// X building" (success completions are otherwise silent).
570 static let buildLog = ProcessInfo.processInfo.environment["SEQ_BUILDLOG"] != nil
571
572 /// Global correction factor: measured chunk bytes ÷ raw estimate, EMA'd.
573 /// Starts at 1 (the raw model is a ProRes-proxy ballpark) and converges on
574 /// the actual footage within a few chunks.
575 private static var chunkRateEMA = 1.0
576
577 /// Ballpark bytes for a chunk at `width` before correction: ProRes proxy
578 /// ≈ 0.09 bytes per pixel per frame, plus PCM audio when present.
579 /// `seconds` overrides the encoded duration (rescue slices).
580 private func rawChunkEstimate(media: MediaItem, width: Int, index: Int,
581 seconds: Double? = nil) -> Double {
582 let dur = seconds ?? min(Self.chunkSeconds,
583 max(1, media.duration - Double(index) * Self.chunkSeconds))
584 let aspect = (media.width > 0 && media.height > 0)
585 ? Double(media.height) / Double(media.width) : 9.0 / 16
586 let fps = min(60.0, max(10.0, media.fps))
587 var b = 0.09 * Double(width) * (Double(width) * aspect) * fps * dur
588 if media.hasAudio { b += 200_000 * dur }
589 return b
590 }
591
592 /// Corrected + safety-margined estimate the admission gate reserves.
593 private func estimateChunkBytes(media: MediaItem, width: Int, index: Int,
594 seconds: Double? = nil) -> Int64 {
595 Int64(rawChunkEstimate(media: media, width: width, index: index, seconds: seconds)
596 * Self.chunkRateEMA * 1.3)
597 }
598
599 /// A cold, already-built chunk the global cache may delete to make room.
600 struct EvictionCandidate {
601 let key: String
602 let index: Int
603 let url: URL
604 /// Timeline seconds from this document's playhead to the nearest use
605 /// of the chunk; 1e12 when no clip uses it at all (media edited off
606 /// the timeline — the coldest bytes there are).
607 let coldness: Double
608 }
609
610 /// Timeline distance within which built chunks are HARD-protected: the
611 /// frames playback will hit imminently. Everything beyond is merely
612 /// ranked by distance — evictable coldest-first — so when the working set
613 /// alone exceeds the cap, it shrinks to fit instead of wedging eviction.
614 private static let hardProtectRadius = 60.0
615
616 /// Every built chunk of this document that eviction MAY delete, scored by
617 /// coldness (timeline distance from the playhead; off-timeline chunks are
618 /// coldest). Hard-excluded: the demand window, anything mid-build or
619 /// urgent, and chunks within `hardProtectRadius` of the playhead — which
620 /// for a background window is exactly its resume neighborhood.
621 func evictionCandidates() -> [EvictionCandidate] {
622 guard !stopped else { return [] }
623 let project = ctx.store.project
624 let ph = ctx.playback.playhead
625 var protected: Set<DemandKey> = Set(demand)
626 for (key, s) in states {
627 for i in s.inFlight { protected.insert(DemandKey(key: key, index: i)) }
628 for i in s.urgent { protected.insert(DemandKey(key: key, index: i)) }
629 }
630 // Score every chunk any clip uses by its distance from the nearest
631 // heat anchor (playhead, then recent edit sites and the visible
632 // window at a penalty so the playhead wins ties); hard-protect only
633 // the playhead-imminent ones. Cut heads read as 4× closer than they
634 // are, so the "instant timeline" landing pads die last.
635 let loci = activeEditLoci()
636 let vis = visibleWindow()
637 var distance: [DemandKey: Double] = [:]
638 var heads: Set<DemandKey> = []
639 for clip in project.clips where clip.kind == .video {
640 guard let media = project.media(clip.mediaId), media.duration > 0,
641 !media.isAudio else { continue }
642 let n = Self.chunkCount(duration: media.duration)
643 let a = min(n - 1, Self.chunkIndex(forSource: clip.srcIn))
644 let b = min(n - 1, Self.chunkIndex(forSource: clip.srcIn + clip.duration - 0.001))
645 heads.insert(DemandKey(key: media.cacheKey, index: a))
646 for i in a...max(a, b) {
647 let t0 = clip.start + (Double(i) * Self.chunkSeconds - clip.srcIn) / max(1e-4, clip.speed)
648 let t1 = clip.start + (Double(i + 1) * Self.chunkSeconds - clip.srcIn) / max(1e-4, clip.speed)
649 let lo = max(clip.start, min(t0, t1)), hi = min(clip.end, max(t0, t1))
650 var d = Self.dist(ph, lo, hi)
651 for l in loci { d = min(d, Self.dist(l, lo, hi) + 60) }
652 if let vis { // gap between the chunk's interval and the visible range
653 let gap = max(0, max(lo - vis.upperBound, vis.lowerBound - hi))
654 d = min(d, gap + 120)
655 }
656 let dk = DemandKey(key: media.cacheKey, index: i)
657 distance[dk] = min(distance[dk] ?? .infinity, d)
658 if Self.dist(ph, lo, hi) < Self.hardProtectRadius { protected.insert(dk) }
659 }
660 }
661 var out: [EvictionCandidate] = []
662 var builtTotal = 0
663 for (key, s) in states {
664 builtTotal += s.built.count
665 for i in s.built.keys {
666 let dk = DemandKey(key: key, index: i)
667 guard !protected.contains(dk), !s.inFlight.contains(i) else { continue }
668 var coldness = distance[dk] ?? 1e12
669 if heads.contains(dk) { coldness *= 0.25 } // landing pads die last
670 out.append(EvictionCandidate(key: key, index: i,
671 url: chunkURL(key: key, index: i),
672 coldness: coldness))
673 }
674 // Rescue slices are evictable like any chunk (the playhead radius
675 // protects the live one); they just live in an rNNNNNN.mov file.
676 for i in s.partial.keys where s.built[i] == nil {
677 let dk = DemandKey(key: key, index: i)
678 guard !protected.contains(dk), !s.inFlight.contains(i) else { continue }
679 out.append(EvictionCandidate(key: key, index: i,
680 url: rescueURL(key: key, index: i),
681 coldness: distance[dk] ?? 1e12))
682 }
683 }
684 NSLog("[cache] candidates: %d of %d built chunks (%d protected, playhead %.0fs)",
685 out.count, builtTotal, protected.count, ph)
686 return out
687 }
688
689 /// The global cache deleted these chunk files: drop them from state and
690 /// bump the version so compositions rebuild onto the original-file
691 /// fallback instead of pointing at deleted movs.
692 func noteEvicted(key: String, indices: [Int]) {
693 guard var s = states[key] else { return }
694 var changed = false
695 for i in indices {
696 if s.built.removeValue(forKey: i) != nil {
697 s.attempted.removeValue(forKey: i)
698 changed = true
699 }
700 if s.partial.removeValue(forKey: i) != nil { changed = true }
701 }
702 guard changed else { return }
703 s.version += 1
704 states[key] = s
705 persistWidths(key: key)
706 }
707
708 /// Chunk states for the timeline's optimization strip — one cheap value
709 /// snapshot per media per strip rebuild. nil when the media hasn't been
710 /// scanned yet (unknown; the strip paints it as unoptimized until the
711 /// initial `ensure` pass scans it).
712 struct StripSnapshot {
713 let n: Int
714 let target: Int
715 let built: [Int: Int]
716 let inFlight: Set<Int>
717 let partial: Set<Int>
718 let failed: Set<Int>
719 let fullProxy: Bool
720 }
721
722 func stripSnapshot(media: MediaItem) -> StripSnapshot? {
723 guard media.duration > 0, !media.isAudio else { return nil }
724 if hasFullProxy(media) {
725 return StripSnapshot(n: 1, target: 0, built: [:], inFlight: [],
726 partial: [], failed: [], fullProxy: true)
727 }
728 guard let s = states[media.cacheKey], s.scanned else { return nil }
729 return StripSnapshot(n: Self.chunkCount(duration: media.duration),
730 target: targetWidth(for: media),
731 built: s.built, inFlight: s.inFlight,
732 partial: Set(s.partial.keys), failed: s.failed,
733 fullProxy: false)
734 }
735
736 /// (building now, waiting in queue) across all media — for the status bar.
737 /// A chunk counts as queued while it's either missing OR still below the
738 /// preview-quality target (i.e. an upgrade is pending).
739 func queueSummary() -> (building: Int, queued: Int) {
740 var building = 0, queued = 0
741 for (key, s) in states {
742 building += s.inFlight.count
743 let target = mediaByKey[key].map { targetWidth(for: $0) } ?? previewTargetWidth
744 for i in Set(s.urgent + s.background)
745 where !s.inFlight.contains(i) && !s.failed.contains(i) {
746 if (s.built[i] ?? 0) < target { queued += 1 }
747 }
748 }
749 return (building, queued)
750 }
751
752 /// Per-chunk proxy width right now (players record this at item-swap time
753 /// to judge whether a later swap upgrades the frame under the playhead).
754 /// Rescue slices report as width 1: "something is there", and any full
755 /// build over them reads as a strict upgrade so the player adopts it.
756 func builtWidths(media: MediaItem) -> [Int: Int] {
757 let s = state(for: media)
758 guard !s.partial.isEmpty else { return s.built }
759 var w = s.built
760 for i in s.partial.keys where w[i] == nil { w[i] = 1 }
761 return w
762 }
763
764 func builtChunkURL(media: MediaItem, index: Int) -> URL? {
765 state(for: media).built[index] != nil
766 ? chunkURL(key: media.cacheKey, index: index) : nil
767 }
768
769 /// A playable (file, time-in-file) pair that shows `sourceTime` — the
770 /// sharpest thing on disk right now: legacy full proxy, built chunk,
771 /// covering rescue slice, or the original when it's known playable. nil
772 /// when this frame genuinely can't be decoded yet. Feeds the RAM frame
773 /// cache's stand-in decodes; main-thread.
774 func frameSource(media: MediaItem, sourceTime: Double) -> (url: URL, time: Double)? {
775 guard !media.isAudio else { return nil }
776 if let proxy = MediaPipeline.shared.proxyURL(for: media) {
777 return (proxy, sourceTime)
778 }
779 let s = state(for: media)
780 let i = Self.chunkIndex(forSource: sourceTime)
781 let local = sourceTime - Double(i) * Self.chunkSeconds
782 if s.built[i] != nil {
783 return (chunkURL(key: media.cacheKey, index: i), local)
784 }
785 if let p = s.partial[i], local >= p.offset, local <= p.offset + p.dur - 0.05 {
786 return (rescueURL(key: media.cacheKey, index: i), local - p.offset)
787 }
788 if s.originalPlayable == true { return (media.url, sourceTime) }
789 return nil
790 }
791
792 /// Rough bytes this project needs to be FULLY optimized — every chunk any
793 /// clip uses, at the current preview target width — and how many bytes its
794 /// media already have on disk. Estimates use the same corrected model as
795 /// the admission gate (sans safety margin); `built` is a real disk walk of
796 /// each media's chunk dir, so call this on demand (Settings), not per frame.
797 func optimizeEstimate(for project: ProjectModel) -> (total: Int64, built: Int64) {
798 var total: Int64 = 0, built: Int64 = 0
799 for media in project.media where !media.isAudio && media.duration > 0 {
800 if hasFullProxy(media), let proxy = MediaPipeline.shared.proxyURL(for: media) {
801 let sz = (try? FileManager.default.attributesOfItem(atPath: proxy.path)[.size]
802 as? NSNumber)?.int64Value ?? 0
803 total += sz
804 built += sz
805 continue
806 }
807 let n = Self.chunkCount(duration: media.duration)
808 var used = Set<Int>()
809 for clip in project.clips where clip.mediaId == media.id && clip.kind == .video {
810 let a = min(n - 1, Self.chunkIndex(forSource: clip.srcIn))
811 let b = min(n - 1, Self.chunkIndex(forSource: clip.srcIn + clip.duration - 0.001))
812 for i in a...max(a, b) { used.insert(i) }
813 }
814 guard !used.isEmpty else { continue }
815 let target = targetWidth(for: media)
816 for i in used {
817 total += Int64(rawChunkEstimate(media: media, width: target, index: i)
818 * Self.chunkRateEMA)
819 }
820 built += MediaPipeline.directorySize(chunksDir(media.cacheKey))
821 }
822 return (total, built)
823 }
824
825 /// One-line explanation of why the frame at `sourceTime` might not be
826 /// showing — the [miss] log's payload. Every "Loading Media…" spinner the
827 /// viewer escalates to gets one of these, so a spinner sighting is
828 /// diagnosable from the log instead of a shrug: was the chunk missing
829 /// entirely (and did the demand scorer even know about it?), mid-build,
830 /// built-but-not-yet-stitched, or built with the player just late?
831 func missDiagnosis(media: MediaItem, sourceTime: Double) -> String {
832 guard !media.isAudio else { return "audio" }
833 guard !hasFullProxy(media) else { return "full-proxy player-late" }
834 let s = state(for: media)
835 let i = Self.chunkIndex(forSource: sourceTime)
836 var bits = ["\(media.cacheKey.prefix(8))#\(i)"]
837 if let w = s.built[i] {
838 bits.append("built(w=\(w))")
839 bits.append(s.compositionVersion != s.version ? "composition-lag"
840 : "player-late")
841 } else if let p = s.partial[i] {
842 let t = sourceTime - Double(i) * Self.chunkSeconds
843 bits.append(String(format: "rescue(%.0fs@%.0fs t=%.1f)", p.dur, p.offset, t))
844 bits.append(s.compositionVersion != s.version ? "composition-lag"
845 : "player-late")
846 } else if s.inFlight.contains(i) {
847 bits.append("building")
848 } else if s.failed.contains(i) {
849 bits.append("build-failed")
850 } else {
851 let dk = DemandKey(key: media.cacheKey, index: i)
852 if let rank = demand.firstIndex(of: dk) {
853 bits.append("queued(demand#\(rank)\(demandImminent.contains(dk) ? " imminent" : ""))")
854 } else if s.background.contains(i) {
855 bits.append("queued(background)") // demand window missed it
856 } else {
857 bits.append("NOT-QUEUED") // heuristic gap — the bad one
858 }
859 }
860 if budgetStarved { bits.append("budget-starved") }
861 if isPaused { bits.append("opt-paused") }
862 if s.originalPlayable == nil { bits.append("orig-unknown") }
863 else if s.originalPlayable == false { bits.append("orig-unplayable") }
864 return bits.joined(separator: " ")
865 }
866
867 /// The proxy chunk covering `sourceTime` tried to build and hard-failed
868 /// (both encoders) with nothing usable — the viewer surfaces this instead of
869 /// spinning "processing…" forever.
870 func buildFailed(media: MediaItem, sourceTime: Double) -> Bool {
871 if media.isAudio || hasFullProxy(media) { return false }
872 return state(for: media).failed.contains(Self.chunkIndex(forSource: sourceTime))
873 }
874
875 // MARK: - Build queue
876
877 /// Which budget tier a job builds for. `window` jobs (playhead prefetch,
878 /// urgent coverage) may evict cold chunks of open documents to make room;
879 /// `fill` jobs (whole-project background) may only consume free budget or
880 /// space freed from closed projects — never evict another chunk. That
881 /// asymmetry is what makes the cache converge instead of thrash: a build
882 /// can only displace bytes strictly colder than itself.
883 private enum JobTier { case window, fill }
884
885 /// Next chunk to build, in priority order:
886 /// 0. urgent — coverage the playhead needs NOW, at any quality;
887 /// 1. missing — background chunks not yet built at all (coverage first);
888 /// 2. upgrade — background chunks built below the preview-quality target.
889 /// Coverage always beats sharpening, so playback never stalls waiting on a
890 /// quality upgrade of a frame that's already visible.
891 private func nextJob(frontDoc: Bool) -> (media: MediaItem, index: Int, urgent: Bool, tier: JobTier)? {
892 // Only the frontmost document builds proxies. macOS state restoration
893 // reopens every previously-open project on launch; if each one built its
894 // proxies, they'd transcode their full ProRes sets in parallel. Every open
895 // project's media counts as "in use", so eviction can't reclaim any of it —
896 // the shared cache blows past its cap and fills the disk (the reported bug).
897 // A background project builds nothing until you switch to it (its window
898 // becoming main re-pumps it — see windowDidBecomeMain); the document under
899 // the playhead is always the front one, so playback is unaffected.
900 guard frontDoc else { return nil }
901 // 1. Prefetch demand — already ordered best-first (visible/focused, near,
902 // coverage before sharpening). Coverage rides the adaptive realtime
903 // quality when imminent; an upgrade goes for the full target.
904 if !windowStarved {
905 for dk in demand {
906 guard let media = mediaByKey[dk.key] else { continue }
907 let s = states[dk.key] ?? state(for: media)
908 guard !s.inFlight.contains(dk.index), !s.failed.contains(dk.index) else { continue }
909 let target = targetWidth(for: media)
910 if let have = s.built[dk.index] {
911 if needsReencode(built: have, attempted: s.attempted[dk.index], target: target) {
912 return (media, dk.index, false, .window) // sharpen or shrink → target
913 }
914 } else {
915 return (media, dk.index, demandImminent.contains(dk), .window) // coverage
916 }
917 }
918 // 2. Legacy urgent (headless `want`).
919 for (key, s) in states {
920 guard let media = mediaByKey[key] else { continue }
921 for i in s.urgent where s.built[i] == nil
922 && !s.inFlight.contains(i) && !s.failed.contains(i) {
923 return (media, i, true, .window)
924 }
925 }
926 }
927 // 3. Whole-project background fill for chunks off-screen of the
928 // prefetch window — the tier that stops when the cache is full.
929 guard !fillStarved else { return nil }
930 for (key, s) in states {
931 guard let media = mediaByKey[key] else { continue }
932 for i in s.background where s.built[i] == nil
933 && !s.inFlight.contains(i) && !s.failed.contains(i) {
934 return (media, i, false, .fill)
935 }
936 }
937 for (key, s) in states {
938 guard let media = mediaByKey[key] else { continue }
939 let target = targetWidth(for: media)
940 for i in s.background where !s.inFlight.contains(i) {
941 if let have = s.built[i],
942 needsReencode(built: have, attempted: s.attempted[i], target: target) {
943 return (media, i, false, .fill)
944 }
945 }
946 }
947 return nil
948 }
949
950 private func pump() {
951 guard !stopped else { return } // document closing — don't touch ctx
952 // Paused stops idle background fill, but playback still optimizes the
953 // chunks it's about to need.
954 // Only the front document runs its whole-project background fill (see
955 // nextJob) — this is the guard that stops N restored projects transcoding
956 // their full proxy sets in parallel and overflowing the cache.
957 let frontDoc = DocumentContext.current === ctx
958 while (!isPaused || ctx.playback.isPlaying),
959 activeBuilds < maxBuilds + 1, let job = nextJob(frontDoc: frontDoc) {
960 // URGENT coverage (the playhead just landed on/near this chunk) may
961 // take one OVERFLOW slot: a couple of minutes-long 4K background
962 // encodes must never wall off the frame the user is looking at —
963 // that wait was the last reproducible "Loading Media…" spinner.
964 // Everything else respects maxBuilds, and during playback keeps one
965 // slot free for urgent coverage on top.
966 if !job.urgent {
967 if activeBuilds >= maxBuilds { break }
968 if ctx.playback.isPlaying, activeBuilds >= maxBuilds - 1 { break }
969 }
970 let (media, index, urgent, tier) = job
971 // RESCUE: the playhead is sitting on this uncovered chunk right
972 // now. A full 30-second encode makes the user wait for content
973 // they're already staring at — so first land a short slice
974 // starting AT the playhead (read-bound NAS sources scale with
975 // encoded seconds, so this is fast even when quality drops
976 // aren't), then immediately re-queue the full chunk behind it.
977 var slice: (offset: Double, dur: Double)? = nil
978 let dk = DemandKey(key: media.cacheKey, index: index)
979 if urgent, let st = states[media.cacheKey], st.built[index] == nil,
980 st.partial[index] == nil, !st.rescueAttempted.contains(index),
981 let srcT = demandRescue[dk] {
982 let chunkStart = Double(index) * Self.chunkSeconds
983 let content = min(Self.chunkSeconds, media.duration - chunkStart)
984 let offset = min(max(0, (srcT - chunkStart - 1).rounded(.down)),
985 max(0, content - 2))
986 let dur = min(Self.rescueSeconds, content - offset)
987 // Only worth two encodes when the slice is a real shortcut.
988 if dur >= 4, dur <= content - offset, dur < content * 0.7 {
989 slice = (offset, dur)
990 }
991 }
992 // Urgent builds ride the adaptive realtime level; background builds
993 // go for the full preview-quality target. A rescue slice drops one
994 // more rung — landing NOW is its whole purpose.
995 var level = urgent ? (states[media.cacheKey]?.qualityIndex ?? 0) : 0
996 if slice != nil { level = min(Self.qualities.count - 1, level + 1) }
997 let width = buildWidth(level: level, media: media)
998 let fpsDiv = Self.qualities[min(max(0, level), Self.qualities.count - 1)].fpsDivisor
999 let fps = max(1, Int((media.fps / Double(fpsDiv)).rounded()))
1000 // Admission gate: the cap is enforced BEFORE bytes hit the disk.
1001 // No reservation, no build — first try to make room by evicting
1002 // strictly-colder bytes (closed projects for any tier; open
1003 // documents' cold chunks only for window builds), and if nothing
1004 // colder exists, this tier starves until the budget changes.
1005 let est = estimateChunkBytes(media: media, width: width, index: index,
1006 seconds: slice?.dur)
1007 let ceiling = tier == .window ? MediaPipeline.shared.maxCacheBytes
1008 : MediaPipeline.shared.lowWatermarkBytes
1009 if !MediaPipeline.shared.tryReserve(bytes: est, upTo: ceiling) {
1010 if Self.buildLog {
1011 SeqLog.log("[cache] admission blocked %@#%d est=%.0fMB tier=%@",
1012 String(media.cacheKey.prefix(8)), index,
1013 Double(est) / 1e6, tier == .window ? "window" : "fill")
1014 }
1015 // Try to make room by evicting strictly-colder bytes: closed
1016 // projects' dirs for any tier; open documents' cold chunks
1017 // only for window builds (fill must never displace a chunk —
1018 // it stops at the watermark instead, which is what keeps
1019 // fill and eviction from fighting over the same bytes).
1020 MediaPipeline.shared.evictToFit(need: est, openDocChunks: tier == .window,
1021 upTo: ceiling) { [weak self] ok in
1022 guard let self, !self.stopped, !ok else { return }
1023 // Eviction couldn't free enough (or one is already running
1024 // — resolved via budgetChanged when it lands): starve the
1025 // tier so pump stops retrying until the budget moves.
1026 if tier == .window { self.windowStarved = true }
1027 else { self.fillStarved = true }
1028 NotificationCenter.default.post(name: .mediaStatusChanged, object: nil)
1029 }
1030 break
1031 }
1032 states[media.cacheKey]?.inFlight.insert(index)
1033 if slice == nil {
1034 states[media.cacheKey]?.attempted[index] = width
1035 } else {
1036 states[media.cacheKey]?.rescueAttempted.insert(index)
1037 }
1038 activeBuilds += 1
1039 if Self.buildLog {
1040 SeqLog.log("[cache] start %@#%d w=%d urgent=%d tier=%@%@",
1041 String(media.cacheKey.prefix(8)), index, width, urgent ? 1 : 0,
1042 tier == .window ? "window" : "fill",
1043 slice != nil ? " rescue" : "")
1044 }
1045 DispatchQueue.global(qos: .userInitiated).async { [self] in
1046 let r = buildChunk(media: media, index: index, width: width, fps: fps,
1047 slice: slice)
1048 DispatchQueue.main.async {
1049 if !r.ok {
1050 SeqLog.log("[cache] build FAILED %@#%d w=%d%@",
1051 String(media.cacheKey.prefix(8)), index, width,
1052 slice != nil ? " (rescue)" : "")
1053 }
1054 MediaPipeline.shared.commitBuild(
1055 reserved: est, delta: r.ok ? r.newBytes - r.oldBytes : 0)
1056 if r.ok, r.newBytes > 0, slice == nil {
1057 // Fold the measured size into the estimator (clamped so
1058 // one weird chunk can't poison admissions).
1059 let raw = self.rawChunkEstimate(media: media, width: width, index: index)
1060 if raw > 0 {
1061 let ratio = Double(r.newBytes) / raw
1062 Self.chunkRateEMA = min(10, max(0.1,
1063 Self.chunkRateEMA * 0.7 + ratio * 0.3))
1064 }
1065 }
1066 self.activeBuilds -= 1
1067 var s = self.states[media.cacheKey] ?? MediaState()
1068 s.inFlight.remove(index)
1069 if r.ok, let slice {
1070 // Slice landed: cover the playhead NOW; the chunk still
1071 // reads as unbuilt so the full encode queues right behind.
1072 s.partial[index] = Rescue(offset: slice.offset, dur: r.dur,
1073 width: width)
1074 s.failed.remove(index)
1075 s.version += 1
1076 SeqLog.log("[cache] rescue %@#%d %.0fs@%.0fs w=%d in %.1fs",
1077 String(media.cacheKey.prefix(8)), index, r.dur,
1078 slice.offset, width, r.wall)
1079 } else if r.ok {
1080 s.built[index] = width
1081 s.failed.remove(index)
1082 s.version += 1
1083 // The full chunk supersedes any rescue slice under it.
1084 if s.partial.removeValue(forKey: index) != nil {
1085 let rURL = self.rescueURL(key: media.cacheKey, index: index)
1086 DispatchQueue.global(qos: .utility).async {
1087 let sz = (try? FileManager.default.attributesOfItem(
1088 atPath: rURL.path)[.size] as? NSNumber)?.int64Value ?? 0
1089 try? FileManager.default.removeItem(at: rURL)
1090 if sz > 0 {
1091 DispatchQueue.main.async {
1092 MediaPipeline.shared.noteBytesAdded(-sz)
1093 }
1094 }
1095 }
1096 }
1097 } else if s.built[index] == nil, slice == nil {
1098 // Only a hard failure when we have NOTHING; a failed
1099 // upgrade just keeps the existing lower-quality chunk.
1100 // (A failed rescue is not a failure — the full build
1101 // is still queued and gets its own attempt.)
1102 s.failed.insert(index)
1103 }
1104 self.states[media.cacheKey] = s
1105 if r.ok, slice == nil {
1106 self.persistWidths(key: media.cacheKey)
1107 // Only realtime (urgent) builds inform the realtime
1108 // controller — a slow, quality-first background build
1109 // would falsely make it think the box can't keep up.
1110 if urgent {
1111 self.adaptQuality(key: media.cacheKey, level: level,
1112 wall: r.wall, dur: r.dur, isNetwork: r.isNetwork)
1113 }
1114 }
1115 NotificationCenter.default.post(name: .mediaStatusChanged, object: nil)
1116 MediaPipeline.shared.evictIfNeeded()
1117 self.pump()
1118 }
1119 }
1120 }
1121 }
1122
1123 struct BuildResult {
1124 var ok: Bool; var wall: Double; var dur: Double; var isNetwork: Bool
1125 /// Bytes of the finished chunk file / of the file it replaced (an
1126 /// upgrade re-encode) — the ledger records the difference.
1127 var newBytes: Int64 = 0; var oldBytes: Int64 = 0
1128 }
1129
1130 /// Seconds of content a rescue slice encodes — enough to watch while the
1131 /// full chunk builds behind it, small enough to land in a few seconds.
1132 private static let rescueSeconds = 10.0
1133
1134 private func buildChunk(media: MediaItem, index: Int, width: Int, fps: Int,
1135 slice: (offset: Double, dur: Double)? = nil) -> BuildResult {
1136 let isNet = Self.isNetworkPath(media.path)
1137 func fail(_ wall: Double = 0, _ dur: Double = 0) -> BuildResult {
1138 BuildResult(ok: false, wall: wall, dur: dur, isNetwork: isNet)
1139 }
1140 guard let ffmpeg = MediaPipeline.findExecutable("ffmpeg") else { return fail() }
1141 let dir = chunksDir(media.cacheKey)
1142 try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
1143 let final = slice == nil ? chunkURL(key: media.cacheKey, index: index)
1144 : rescueURL(key: media.cacheKey, index: index)
1145 let tmp = dir.appendingPathComponent(String(
1146 format: slice == nil ? ".c%06d.partial.mov" : ".r%06d.partial.mov", index))
1147 try? FileManager.default.removeItem(at: tmp)
1148 let start = Double(index) * Self.chunkSeconds + (slice?.offset ?? 0)
1149 var dur = min(Self.chunkSeconds - (slice?.offset ?? 0), media.duration - start)
1150 if let slice { dur = min(dur, slice.dur) }
1151 guard dur > 0.01 else { return fail() }
1152
1153 func args(encoder: String) -> [String] {
1154 var a = ["-y", "-hwaccel", "videotoolbox",
1155 "-ss", String(format: "%.3f", start),
1156 "-i", media.path,
1157 "-t", String(format: "%.3f", dur),
1158 "-map", "0:v:0",
1159 // Lanczos downscale: swscale's default (bicubic) softens the
1160 // hard edges of up-scaled/pixel-art content into an annoying
1161 // blur; lanczos keeps the downscaled proxy crisp (not
1162 // nearest-neighbour "pixely", just sharp) — which matters more
1163 // than resolution for editing legibility.
1164 "-vf", "scale=w='min(\(width),iw)':h=-2:flags=lanczos,fps=\(fps)",
1165 "-c:v", encoder, "-profile:v", "proxy"]
1166 if media.hasAudio { a += ["-map", "0:a:0", "-c:a", "pcm_s16le"] }
1167 a.append(tmp.path)
1168 return a
1169 }
1170 let t0 = Date()
1171 var res = MediaPipeline.run(ffmpeg, args(encoder: "prores_videotoolbox"))
1172 if res.exitCode != 0 {
1173 res = MediaPipeline.run(ffmpeg, args(encoder: "prores_ks"))
1174 }
1175 let wall = Date().timeIntervalSince(t0)
1176 if res.exitCode == 0 {
1177 func size(_ url: URL) -> Int64 {
1178 (try? FileManager.default.attributesOfItem(atPath: url.path)[.size]
1179 as? NSNumber)?.int64Value ?? 0
1180 }
1181 let newBytes = size(tmp), oldBytes = size(final)
1182 try? FileManager.default.removeItem(at: final)
1183 do { try FileManager.default.moveItem(at: tmp, to: final) }
1184 catch { return fail(wall, dur) }
1185 return BuildResult(ok: true, wall: wall, dur: dur, isNetwork: isNet,
1186 newBytes: newBytes, oldBytes: oldBytes)
1187 }
1188 try? FileManager.default.removeItem(at: tmp)
1189 return fail(wall, dur)
1190 }
1191
1192 /// Whether a path lives on a network mount (SMB/NFS NAS) rather than a
1193 /// local disk — a fast, data-free `statfs`. Used to decide whether a slow
1194 /// build can honestly be blamed on network I/O.
1195 static func isNetworkPath(_ path: String) -> Bool {
1196 var st = statfs()
1197 guard statfs(path, &st) == 0 else { return false }
1198 return (st.f_flags & UInt32(MNT_LOCAL)) == 0
1199 }
1200
1201 // MARK: - Adaptive quality
1202
1203 /// Any media currently held back by a network read the box can't keep up
1204 /// with (drives the toolbar warning).
1205 var isNetworkLimited: Bool { states.values.contains { $0.networkLimited } }
1206
1207 /// Fold one finished build's timing into the controller and pick the
1208 /// quality for this media's NEXT chunk.
1209 private func adaptQuality(key: String, level: Int, wall: Double, dur: Double,
1210 isNetwork: Bool) {
1211 guard dur >= 5 else { return } // tail chunks are too short to time reliably
1212 var s = states[key] ?? MediaState()
1213 let norm = wall / dur
1214 s.normWall[level] = s.normWall[level].map { $0 * 0.5 + norm * 0.5 } ?? norm
1215 let d = Self.decideQuality(level: level, norm: s.normWall[level]!,
1216 normByLevel: s.normWall, fastStreak: s.fastStreak,
1217 sourceIsNetwork: isNetwork,
1218 levelCount: Self.qualities.count)
1219 s.qualityIndex = d.nextIndex
1220 s.networkLimited = d.networkLimited
1221 s.fastStreak = d.fastStreak
1222 states[key] = s
1223 }
1224
1225 struct QualityDecision: Equatable { var nextIndex: Int; var networkLimited: Bool; var fastStreak: Int }
1226
1227 /// Pure adaptive-quality decision (so it's deterministic + unit-testable).
1228 /// `norm` is the just-built chunk's wall-time ÷ its real-time duration:
1229 /// < 1 means we encoded faster than the footage plays. Given the ratios
1230 /// measured at neighbouring levels, decide the level for the next chunk.
1231 ///
1232 /// The core trick for telling a slow ENCODE apart from a slow READ: when a
1233 /// build is struggling, check whether stepping down from the next-higher
1234 /// quality actually made the encode faster. If it barely moved, the encode
1235 /// wasn't the bottleneck — the source read is — so degrading further is
1236 /// futile: we stop degrading (restoring the wasted quality) and, when the
1237 /// source is on a network mount, flag it as network-limited.
1238 static func decideQuality(level: Int, norm: Double, normByLevel: [Int: Double],
1239 fastStreak: Int, sourceIsNetwork: Bool,
1240 levelCount: Int) -> QualityDecision {
1241 let struggling = 0.8 // wall > 0.8× realtime → at risk of not keeping up
1242 let comfy = 0.45 // wall < 0.45× realtime → safe to restore quality
1243 var next = level
1244 var network = false
1245 var streak = fastStreak
1246
1247 if norm > struggling {
1248 streak = 0
1249 // level-1 is the next-higher quality; if it was ~as fast as this
1250 // (lower-quality) build, dropping quality isn't buying speed.
1251 let higher = normByLevel[level - 1]
1252 let degradeHelps = higher.map { ($0 - norm) / $0 >= 0.15 } ?? true
1253 if degradeHelps && level < levelCount - 1 {
1254 next = level + 1 // faster encode
1255 } else {
1256 network = sourceIsNetwork // read-bound (or at the floor)
1257 if !degradeHelps && level > 0 { next = level - 1 } // stop wasting quality
1258 }
1259 } else if norm < comfy {
1260 streak += 1
1261 if streak >= 2 && level > 0 { next = level - 1; streak = 0 } // recover quality
1262 }
1263 return QualityDecision(nextIndex: next, networkLimited: network, fastStreak: streak)
1264 }
1265
1266 /// Drop cached state for evicted cache keys.
1267 func forget(keys: [String]) {
1268 for k in keys {
1269 states.removeValue(forKey: k)
1270 mediaByKey.removeValue(forKey: k)
1271 }
1272 }
1273
1274 // MARK: - Playback composition
1275
1276 private var compBuilding: Set<String> = []
1277
1278 /// Stitched asset: ready chunks as ProRes, missing ranges from the
1279 /// original (when playable) or empty. `version` changes whenever a chunk
1280 /// lands so players know to swap items. NEVER blocks on media I/O — a
1281 /// stale (or empty, version -2) composition is returned while the fresh
1282 /// one assembles on a background queue; .mediaStatusChanged fires when
1283 /// it's ready. (Synchronous AVAsset loading on the main thread hangs the
1284 /// whole app if an SMB mount stalls.)
1285 func composition(for media: MediaItem) -> (asset: AVAsset, version: Int) {
1286 let s = state(for: media)
1287 if s.composition == nil || s.compositionVersion != s.version {
1288 kickCompositionBuild(media: media)
1289 }
1290 if let comp = states[media.cacheKey]?.composition {
1291 return (comp, states[media.cacheKey]?.compositionVersion ?? -2)
1292 }
1293 return (AVMutableComposition(), -2)
1294 }
1295
1296 /// One stitched piece of a media's composition: a full chunk (offset 0,
1297 /// dur nil) or a rescue slice sitting `offset` seconds into its grid slot.
1298 private struct CompPart {
1299 let url: URL
1300 let offset: Double
1301 let dur: Double?
1302 }
1303
1304 private func kickCompositionBuild(media: MediaItem) {
1305 let key = media.cacheKey
1306 guard !compBuilding.contains(key) else { return }
1307 compBuilding.insert(key)
1308 let s = state(for: media)
1309 let version = s.version
1310 var parts: [Int: CompPart] = [:]
1311 for i in s.built.keys {
1312 parts[i] = CompPart(url: chunkURL(key: key, index: i), offset: 0, dur: nil)
1313 }
1314 for (i, p) in s.partial where parts[i] == nil {
1315 parts[i] = CompPart(url: rescueURL(key: key, index: i),
1316 offset: p.offset, dur: p.dur)
1317 }
1318 Task.detached(priority: .userInitiated) {
1319 let (comp, playable) = await Self.assemble(media: media, parts: parts)
1320 await MainActor.run { [self] in
1321 self.compBuilding.remove(key)
1322 var s = self.states[key] ?? MediaState()
1323 s.composition = comp
1324 s.compositionVersion = version
1325 s.originalPlayable = playable
1326 self.states[key] = s
1327 NotificationCenter.default.post(name: .mediaStatusChanged, object: nil)
1328 if s.version != version { self.kickCompositionBuild(media: media) }
1329 }
1330 }
1331 }
1332
1333 /// A chunk asset's tracks, loaded and ready to insert. `asset` is what
1334 /// keeps the tracks alive: an AVAssetTrack does NOT retain its asset, and
1335 /// inserting a track whose asset has been deallocated fails with
1336 /// -11800/-12780 — silently under `try?`, leaving a black GAP in the
1337 /// composition for a chunk that's perfectly healthy on disk. (Bit us for
1338 /// real: the task-group refactor returned bare tracks, and whether a slot
1339 /// went black depended on autorelease timing.)
1340 private struct LoadedPart {
1341 let index: Int
1342 let asset: AVURLAsset
1343 let v: AVAssetTrack
1344 let a: AVAssetTrack?
1345 let duration: CMTime
1346 let offset: Double
1347 }
1348
1349 private static func assemble(media: MediaItem,
1350 parts: [Int: CompPart]) async -> (AVComposition, Bool) {
1351 let comp = AVMutableComposition()
1352 guard let vTrack = comp.addMutableTrack(
1353 withMediaType: .video, preferredTrackID: kCMPersistentTrackID_Invalid)
1354 else { return (comp, false) }
1355 let aTrack = media.hasAudio ? comp.addMutableTrack(
1356 withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid) : nil
1357
1358 let original = AVURLAsset(url: media.url)
1359 let origV = try? await original.loadTracks(withMediaType: .video).first
1360 let origA = try? await original.loadTracks(withMediaType: .audio).first
1361
1362 // Load every chunk asset's tracks CONCURRENTLY (bounded), then insert
1363 // in order. The old one-await-per-chunk loop made a media with
1364 // hundreds of built chunks take seconds to reassemble — and a
1365 // reassembly runs every time a chunk lands, right when the user is
1366 // waiting to see it.
1367 let wantAudio = aTrack != nil
1368 var loaded: [Int: LoadedPart] = [:]
1369 await withTaskGroup(of: LoadedPart?.self) { group in
1370 var pending = Array(parts).sorted { $0.key < $1.key }[...]
1371 var inFlight = 0
1372 func addNext() {
1373 guard let (i, part) = pending.first else { return }
1374 pending = pending.dropFirst()
1375 inFlight += 1
1376 group.addTask {
1377 let chunk = AVURLAsset(url: part.url)
1378 guard let v = try? await chunk.loadTracks(withMediaType: .video).first
1379 else { return nil }
1380 let d = (try? await chunk.load(.duration)) ?? .zero
1381 let a = wantAudio
1382 ? try? await chunk.loadTracks(withMediaType: .audio).first : nil
1383 return LoadedPart(index: i, asset: chunk, v: v, a: a, duration: d,
1384 offset: part.offset)
1385 }
1386 }
1387 for _ in 0..<8 { addNext() }
1388 while inFlight > 0 {
1389 guard let r = await group.next() else { break }
1390 inFlight -= 1
1391 if let r { loaded[r.index] = r }
1392 addNext()
1393 }
1394 }
1395
1396 func fillFromOriginal(_ range: CMTimeRange) {
1397 if let origV {
1398 try? vTrack.insertTimeRange(range, of: origV, at: range.start)
1399 if let aTrack, let origA {
1400 try? aTrack.insertTimeRange(range, of: origA, at: range.start)
1401 }
1402 } else {
1403 vTrack.insertEmptyTimeRange(range)
1404 }
1405 }
1406
1407 let n = Self.chunkCount(duration: media.duration)
1408 for i in 0..<n {
1409 let startSec = Double(i) * Self.chunkSeconds
1410 let durSec = min(Self.chunkSeconds, media.duration - startSec)
1411 guard durSec > 0.001 else { break }
1412 let at = CMTime(seconds: startSec, preferredTimescale: 600)
1413 let dur = CMTime(seconds: durSec, preferredTimescale: 600)
1414 guard let part = loaded[i] else {
1415 fillFromOriginal(CMTimeRange(start: at, duration: dur))
1416 continue
1417 }
1418 let sliceAt = CMTime(seconds: startSec + part.offset, preferredTimescale: 600)
1419 let sliceDur = min(part.duration,
1420 CMTime(seconds: durSec - part.offset, preferredTimescale: 600))
1421 // Rescue slice: original (or empty) leads in, the slice covers the
1422 // playhead's neighborhood, original (or empty) fills the tail.
1423 if part.offset > 0.001 {
1424 fillFromOriginal(CMTimeRange(start: at, end: sliceAt))
1425 }
1426 let r = CMTimeRange(start: .zero, duration: sliceDur)
1427 do {
1428 try vTrack.insertTimeRange(r, of: part.v, at: sliceAt)
1429 } catch {
1430 // A healthy chunk that fails to stitch plays back as a BLACK
1431 // gap — never let that be silent again (a dropped asset
1432 // reference made every insert fail exactly this way once).
1433 SeqLog.log("[cache] comp insert FAILED %@#%d dur=%.2f: %@",
1434 String(media.cacheKey.prefix(8)), i, sliceDur.seconds,
1435 String(describing: error))
1436 }
1437 if let aTrack, let a = part.a {
1438 try? aTrack.insertTimeRange(r, of: a, at: sliceAt)
1439 }
1440 let sliceEnd = sliceAt + sliceDur
1441 let slotEnd = at + dur
1442 if sliceEnd + CMTime(seconds: 0.001, preferredTimescale: 600) < slotEnd {
1443 fillFromOriginal(CMTimeRange(start: sliceEnd, end: slotEnd))
1444 }
1445 }
1446 return (comp, origV != nil)
1447 }
1448}