| 1 | import AppKit |
| 2 | |
| 3 | /// Per-document service bag. Everything that is per-project — the model store, |
| 4 | /// playback clock, players, proxy builder, comps scanner, storyboard rasters, |
| 5 | /// and the view/session state — hangs off one of these. Views reach their |
| 6 | /// state through `ctx.*`; each open `.sq` document owns exactly one context. |
| 7 | final class DocumentContext { |
| 8 | /// The document that owns this context (nil for the headless harness |
| 9 | /// context). Undo/dirty flow back through it via `updateChangeCount`. |
| 10 | weak var document: ProjectDocument? |
| 11 | |
| 12 | /// Per-document bus for the high-frequency `.playheadChanged` signal, so a |
| 13 | /// window playing at 60 Hz only redraws its OWN timeline/viewer/transport, |
| 14 | /// not every other open project's. (Lower-frequency notifications stay on |
| 15 | /// `.default`: they're correct app-wide because every handler reads its own |
| 16 | /// `ctx`, and the redundant redraw is cheap.) |
| 17 | let notify = NotificationCenter() |
| 18 | |
| 19 | let store = Store() |
| 20 | let playback = PlaybackController() |
| 21 | let players = PlayerManager() |
| 22 | let chunks = ChunkManager() |
| 23 | let comps = FusionComps() |
| 24 | let boards = BoardStore() |
| 25 | /// Per-window view/session state (hide/focus/zoom/tool/color). |
| 26 | let session = SessionState() |
| 27 | |
| 28 | private var reconcileObserver: NSObjectProtocol? |
| 29 | |
| 30 | init() { |
| 31 | // Wire each service's back-reference to this context. Deferred work |
| 32 | // (timers, observers) reads `ctx.*`, so this must run before any fires. |
| 33 | store.ctx = self |
| 34 | playback.ctx = self |
| 35 | players.ctx = self |
| 36 | chunks.ctx = self |
| 37 | comps.ctx = self |
| 38 | boards.ctx = self |
| 39 | session.ctx = self |
| 40 | // Keep this document's per-track session state (focus/hide/height) in |
| 41 | // sync with its model, so deleting a focused track unfocuses it instead |
| 42 | // of blanking every surviving lane. |
| 43 | reconcileObserver = NotificationCenter.default.addObserver( |
| 44 | forName: .projectChanged, object: nil, queue: .main) { [weak self] _ in |
| 45 | guard let self else { return } |
| 46 | self.session.reconcileTracks(self.store.project) |
| 47 | } |
| 48 | } |
| 49 | |
| 50 | private var didShutdown = false |
| 51 | |
| 52 | /// Tear the document's services down while `self` is still alive — called |
| 53 | /// from `ProjectDocument.close()`. Services hold `unowned var ctx`; an |
| 54 | /// in-flight chunk build or the 60 Hz clock landing a callback AFTER the |
| 55 | /// context deallocs would trap on that dangling reference. Stopping them here |
| 56 | /// (before dealloc) makes every such late callback a no-op. |
| 57 | func shutdown() { |
| 58 | guard !didShutdown else { return } |
| 59 | didShutdown = true |
| 60 | playback.stop() |
| 61 | chunks.stop() |
| 62 | comps.stopWatching() |
| 63 | if let reconcileObserver { |
| 64 | NotificationCenter.default.removeObserver(reconcileObserver) |
| 65 | self.reconcileObserver = nil |
| 66 | } |
| 67 | } |
| 68 | |
| 69 | deinit { |
| 70 | if let reconcileObserver { NotificationCenter.default.removeObserver(reconcileObserver) } |
| 71 | comps.stopWatching() |
| 72 | } |
| 73 | |
| 74 | /// Start the per-document services (playback clock, comps folder watch, |
| 75 | /// derived-asset warmup). Called once by the window controller after load. |
| 76 | func startServices() { |
| 77 | // Trim the shared media cache under its byte cap now that this project's |
| 78 | // media is registered (so its own cache is protected). Opening a |
| 79 | // document is also the natural moment to re-measure the cache from |
| 80 | // disk (reconcile) so the incremental ledger can't drift for long. |
| 81 | MediaPipeline.shared.evictIfNeeded(reconcile: true) |
| 82 | MediaPipeline.shared.ensureDerivedAssets(for: store.project) |
| 83 | // NOTE: the whole-project proxy pre-build (`chunks.ensure`) is NOT kicked |
| 84 | // here. macOS state restoration reopens *every* previously-open project on |
| 85 | // launch, and each one running `ensure` would transcode its full ProRes |
| 86 | // proxy set in parallel — every open project's media counts as "in use", so |
| 87 | // eviction can't trim any of it and the shared cache blows past its cap and |
| 88 | // fills the disk. Instead the fill is triggered when a document's window |
| 89 | // becomes main (SequencerWindowController.windowDidBecomeMain): the |
| 90 | // frontmost project fills immediately, a background/restored project fills |
| 91 | // only once you switch to it. On-demand playhead builds (`want`) still run |
| 92 | // for whatever is actually playing. |
| 93 | comps.rescan() |
| 94 | comps.startWatching() |
| 95 | playback.start() |
| 96 | players.sync(force: true) |
| 97 | } |
| 98 | |
| 99 | // MARK: - Resolving the "current" context |
| 100 | |
| 101 | /// The front document's context — for app-level actions and singletons |
| 102 | /// (Settings, Export, cache eviction) that operate on whichever project is |
| 103 | /// frontmost. Falls back to the headless context when nothing is open. |
| 104 | static var current: DocumentContext { |
| 105 | // `NSApp` is nil in the headless `--selftest` harness (no NSApplication); |
| 106 | // guard it so callers on the build path can ask "am I frontmost?" without |
| 107 | // crashing — with no app, the headless context is by definition current. |
| 108 | guard let app = NSApp else { return headless } |
| 109 | if let wc = app.keyWindow?.windowController as? SequencerWindowController { |
| 110 | return wc.ctx |
| 111 | } |
| 112 | if let wc = app.mainWindow?.windowController as? SequencerWindowController { |
| 113 | return wc.ctx |
| 114 | } |
| 115 | if let doc = NSDocumentController.shared.currentDocument as? ProjectDocument { |
| 116 | return doc.ctx |
| 117 | } |
| 118 | return headless |
| 119 | } |
| 120 | |
| 121 | /// Fallback context for the `--uitest`/`--selftest` harnesses and any |
| 122 | /// moment with no open document. |
| 123 | static let headless = DocumentContext() |
| 124 | |
| 125 | /// Every live context: one per open document, plus the headless one. Used |
| 126 | /// by the global `MediaPipeline` cache so eviction considers all windows' |
| 127 | /// media, not just the front document's. |
| 128 | static var allLive: [DocumentContext] { |
| 129 | var ctxs = NSDocumentController.shared.documents.compactMap { |
| 130 | ($0 as? ProjectDocument)?.ctx |
| 131 | } |
| 132 | ctxs.append(headless) |
| 133 | return ctxs |
| 134 | } |
| 135 | } |