| 1 | import Foundation |
| 2 | import 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. |
| 14 | final 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 | } |