1import AppKit
2
3extension Notification.Name {
4 static let compsChanged = Notification.Name("compsChanged")
5}
6
7/// One Fusion composition discovered in the project's comps folder. The frame
8/// range comes from the filename prefix ("0200-0681_intro.comp"); the output
9/// image sequence comes from the comp's Saver (preferring one named
10/// MainOutput).
11struct FusionComp: Equatable, Identifiable {
12 var path: String
13 var name: String // filename, also the preferred-take key
14 var title: String // suffix after the range
15 var startFrame: Int
16 var endFrame: Int // inclusive
17 var mtime: Date = .distantPast
18 var saverPath: String? // Saver clip filename (image sequence base)
19
20 var id: String { path }
21 func startSeconds(fps: Double) -> Double { Double(startFrame) / fps }
22 func endSeconds(fps: Double) -> Double { Double(endFrame + 1) / fps }
23}
24
25/// Scans the comps folder, parses Savers, lays comps out into sub-lanes when
26/// ranges overlap, and resolves output frames to image-sequence files.
27/// All file I/O runs off-main (NAS!); results land on main.
28final class FusionComps {
29 /// The document context that owns this scanner. Set at construction.
30 unowned var ctx: DocumentContext!
31
32 /// The exact yellow of the Fusion app icon.
33 static let yellow = NSColor(calibratedRed: 1.0, green: 0.878, blue: 0.0, alpha: 1)
34 /// Synthetic id for the viewer cell.
35 static let viewerCellId = UUID(uuidString: "F0510000-0000-0000-0000-000000000001")!
36
37 private(set) var comps: [FusionComp] = []
38 var selectedCompPath: String?
39 private var scannedFolder: String?
40 private var scanning = false
41 // path → (frame → file URL) for each comp's rendered sequence.
42 // Lock-guarded: built on background queues (directory listing hits the NAS).
43 private var sequenceCache: [String: [Int: URL]] = [:]
44 private let sequenceLock = NSLock()
45 private let imageCache = NSCache<NSString, NSImage>()
46
47 var visible: Bool {
48 ctx.store.project.compsFolder != nil && !comps.isEmpty
49 }
50
51 init() {
52 imageCache.countLimit = 120
53 NotificationCenter.default.addObserver(
54 forName: .projectChanged, object: nil, queue: .main) { [weak self] _ in
55 guard let self else { return }
56 let folder = self.ctx.store.project.compsFolder
57 if folder != self.scannedFolder { self.rescan() }
58 }
59 }
60
61 // MARK: - Scanning
62
63 /// Filename prefix parse: "0200-0681_intro.comp" → (200, 681, "intro").
64 static func parseCompName(_ name: String) -> (start: Int, end: Int, title: String)? {
65 guard name.hasSuffix(".comp") else { return nil }
66 let stem = String(name.dropLast(5))
67 let pattern = #"^(\d+)-(\d+)[_\- ]?(.*)$"#
68 guard let re = try? NSRegularExpression(pattern: pattern),
69 let m = re.firstMatch(in: stem, range: NSRange(stem.startIndex..., in: stem)),
70 let r1 = Range(m.range(at: 1), in: stem),
71 let r2 = Range(m.range(at: 2), in: stem),
72 let a = Int(stem[r1]), let b = Int(stem[r2]), b >= a
73 else { return nil }
74 let title = Range(m.range(at: 3), in: stem).map { String(stem[$0]) } ?? ""
75 return (a, b, title)
76 }
77
78 /// Find the output Saver's clip filename. Prefers a tool named
79 /// MainOutput; falls back to the first Saver with a filename.
80 static func parseSaverPath(compText: String) -> String? {
81 let pattern = #"([A-Za-z0-9_]+)\s*=\s*Saver\s*\{"#
82 guard let re = try? NSRegularExpression(pattern: pattern) else { return nil }
83 let ns = compText as NSString
84 var candidates: [(name: String, filename: String)] = []
85 re.enumerateMatches(in: compText,
86 range: NSRange(location: 0, length: ns.length)) { m, _, _ in
87 guard let m else { return }
88 let name = ns.substring(with: m.range(at: 1))
89 // Search a window after the Saver header for its Clip Filename.
90 let searchStart = m.range.location + m.range.length
91 let window = NSRange(location: searchStart,
92 length: min(4000, ns.length - searchStart))
93 if let fre = try? NSRegularExpression(pattern: #"Filename\s*=\s*"([^"]*)""#),
94 let fm = fre.firstMatch(in: compText, range: window) {
95 candidates.append((name, ns.substring(with: fm.range(at: 1))))
96 }
97 }
98 let best = candidates.first { $0.name.localizedCaseInsensitiveContains("mainoutput") }
99 ?? candidates.first { !$0.filename.isEmpty }
100 return best?.filename.isEmpty == false ? best?.filename : nil
101 }
102
103 func rescan() {
104 let folder = ctx.store.project.compsFolder
105 scannedFolder = folder
106 guard let folder else {
107 comps = []
108 sequenceLock.lock(); sequenceCache = [:]; sequenceLock.unlock()
109 NotificationCenter.default.post(name: .compsChanged, object: nil)
110 return
111 }
112 guard !scanning else { return }
113 scanning = true
114 DispatchQueue.global(qos: .userInitiated).async { [weak self] in
115 let fm = FileManager.default
116 var found: [FusionComp] = []
117 let names = (try? fm.contentsOfDirectory(atPath: folder)) ?? []
118 for name in names.sorted() {
119 guard let (a, b, title) = Self.parseCompName(name) else { continue }
120 let path = (folder as NSString).appendingPathComponent(name)
121 var comp = FusionComp(path: path, name: name, title: title,
122 startFrame: a, endFrame: b)
123 let attrs = try? fm.attributesOfItem(atPath: path)
124 comp.mtime = (attrs?[.modificationDate] as? Date) ?? .distantPast
125 if let text = try? String(contentsOfFile: path, encoding: .utf8) {
126 comp.saverPath = Self.parseSaverPath(compText: text)
127 }
128 found.append(comp)
129 }
130 DispatchQueue.main.async {
131 guard let self else { return }
132 self.scanning = false
133 self.comps = found
134 self.sequenceLock.lock(); self.sequenceCache = [:]; self.sequenceLock.unlock()
135 self.imageCache.removeAllObjects()
136 NotificationCenter.default.post(name: .compsChanged, object: nil)
137 if self.scannedFolder != self.ctx.store.project.compsFolder { self.rescan() }
138 }
139 }
140 }
141
142 // MARK: - Layout & stacking
143
144 /// Sub-lane assignment: overlapping comps share the band, each at reduced
145 /// height. Returns (comp, lane, laneCount-in-its-cluster).
146 func stacked() -> [(comp: FusionComp, lane: Int, lanes: Int)] {
147 let sorted = comps.sorted { ($0.startFrame, $0.endFrame) < ($1.startFrame, $1.endFrame) }
148 var placed: [(comp: FusionComp, lane: Int, cluster: Int)] = []
149 var laneEnds: [Int] = [] // per-lane last endFrame, current cluster
150 var clusterOf: [Int] = [] // lane → cluster id
151 var clusterId = -1
152 var clusterMaxEnd = Int.min
153 for comp in sorted {
154 if comp.startFrame > clusterMaxEnd {
155 clusterId += 1
156 laneEnds = []
157 clusterOf = []
158 }
159 clusterMaxEnd = max(clusterMaxEnd, comp.endFrame)
160 var lane = laneEnds.firstIndex { $0 < comp.startFrame }
161 if lane == nil {
162 laneEnds.append(comp.endFrame)
163 clusterOf.append(clusterId)
164 lane = laneEnds.count - 1
165 } else {
166 laneEnds[lane!] = comp.endFrame
167 }
168 placed.append((comp, lane!, clusterId))
169 }
170 var clusterLanes: [Int: Int] = [:]
171 for p in placed {
172 clusterLanes[p.cluster] = max(clusterLanes[p.cluster] ?? 1, p.lane + 1)
173 }
174 return placed.map { ($0.comp, $0.lane, clusterLanes[$0.cluster] ?? 1) }
175 }
176
177 func comp(at path: String?) -> FusionComp? {
178 guard let path else { return nil }
179 return comps.first { $0.path == path }
180 }
181
182 /// Comps covering a frame, topmost first. Preferred takes win; ties go to
183 /// the most recently modified comp.
184 func comps(atFrame f: Int) -> [FusionComp] {
185 let preferred = Set(ctx.store.project.preferredTakes)
186 return comps
187 .filter { f >= $0.startFrame && f <= $0.endFrame }
188 .sorted {
189 let pa = preferred.contains($0.name), pb = preferred.contains($1.name)
190 if pa != pb { return pa }
191 return $0.mtime > $1.mtime
192 }
193 }
194
195 func topmost(atFrame f: Int) -> FusionComp? { comps(atFrame: f).first }
196
197 /// Exact rendered image file for the topmost comp at a global frame (no
198 /// nearest-frame fallback — export needs the real frame or nothing). File
199 /// I/O here (the directory listing) is cached after the first call.
200 func renderedFrameURL(atFrame f: Int) -> URL? {
201 guard let comp = topmost(atFrame: f) else { return nil }
202 return sequence(for: comp)[f]
203 }
204
205 // MARK: - Rendered output
206
207 /// Frame → file map for a comp's Saver sequence, built from a directory
208 /// listing (Fusion writes "<base><frame digits><ext>", frame numbers are
209 /// comp-global).
210 private func sequence(for comp: FusionComp) -> [Int: URL] {
211 sequenceLock.lock()
212 if let cached = sequenceCache[comp.path] {
213 sequenceLock.unlock()
214 return cached
215 }
216 sequenceLock.unlock()
217 var map: [Int: URL] = [:]
218 defer {
219 sequenceLock.lock()
220 sequenceCache[comp.path] = map
221 sequenceLock.unlock()
222 }
223 guard let saver = comp.saverPath else { return map }
224 let url = URL(fileURLWithPath: saver)
225 let dir = url.deletingLastPathComponent()
226 let base = url.deletingPathExtension().lastPathComponent
227 let ext = url.pathExtension.lowercased()
228 guard let names = try? FileManager.default
229 .contentsOfDirectory(atPath: dir.path) else { return map }
230 for n in names {
231 guard n.lowercased().hasSuffix(".\(ext)"), n.hasPrefix(base) else { continue }
232 let digits = n.dropFirst(base.count).dropLast(ext.count + 1)
233 guard !digits.isEmpty, digits.allSatisfy(\.isNumber),
234 let f = Int(digits) else { continue }
235 map[f] = dir.appendingPathComponent(n)
236 }
237 return map
238 }
239
240 /// Rendered image for the topmost comp at a timeline frame; nearest
241 /// available frame within the comp range fills gaps mid-render.
242 /// Loads async off-main and posts .compsChanged when the image lands.
243 func frameImage(atFrame f: Int) -> (comp: FusionComp, image: NSImage?)? {
244 guard let comp = topmost(atFrame: f) else { return nil }
245 let key = "\(comp.path)#\(f)" as NSString
246 if let img = imageCache.object(forKey: key) { return (comp, img) }
247 DispatchQueue.global(qos: .userInitiated).async { [weak self] in
248 guard let self else { return }
249 let seq = self.sequence(for: comp)
250 let url = seq[f] ?? seq.keys.sorted { abs($0 - f) < abs($1 - f) }.first
251 .flatMap { seq[$0] }
252 guard let url, let img = Self.downsampled(url: url, maxDim: 1280) else { return }
253 DispatchQueue.main.async {
254 self.imageCache.setObject(img, forKey: key)
255 NotificationCenter.default.post(name: .compsChanged, object: nil)
256 }
257 }
258 return (comp, nil)
259 }
260
261 static func downsampled(url: URL, maxDim: CGFloat) -> NSImage? {
262 guard let src = CGImageSourceCreateWithURL(url as CFURL, nil) else { return nil }
263 let opts: [CFString: Any] = [
264 kCGImageSourceCreateThumbnailFromImageAlways: true,
265 kCGImageSourceThumbnailMaxPixelSize: maxDim,
266 kCGImageSourceCreateThumbnailWithTransform: true,
267 ]
268 guard let cg = CGImageSourceCreateThumbnailAtIndex(src, 0, opts as CFDictionary)
269 else { return nil }
270 return NSImage(cgImage: cg, size: NSSize(width: cg.width, height: cg.height))
271 }
272
273 // MARK: - Actions
274
275 // MARK: - Live updates
276 // kqueue-style file events are unreliable on SMB mounts, so instead of
277 // polling on a timer, a background pass runs whenever the app regains
278 // focus (you were just in Fusion rendering — that's the moment new comps
279 // or frames exist). It fingerprints the comps folder (names + mtimes) and
280 // each Saver's render directory (file count + last name); any change
281 // rescans / refreshes previews.
282
283 private var polling = false
284 private var folderSignature: String?
285 private var renderSignatures: [String: String] = [:]
286 private var activeObserver: NSObjectProtocol?
287
288 func startWatching() {
289 activeObserver = NotificationCenter.default.addObserver(
290 forName: NSApplication.didBecomeActiveNotification, object: nil,
291 queue: .main) { [weak self] _ in
292 self?.poll()
293 }
294 }
295
296 /// Remove the app-active poll observer when the document closes (block-based
297 /// observers aren't auto-removed, so a closed window would keep polling).
298 func stopWatching() {
299 if let activeObserver {
300 NotificationCenter.default.removeObserver(activeObserver)
301 self.activeObserver = nil
302 }
303 }
304
305 private func poll() {
306 guard !polling, !scanning,
307 let folder = ctx.store.project.compsFolder else { return }
308 polling = true
309 let comps = self.comps
310 DispatchQueue.global(qos: .utility).async { [weak self] in
311 guard let self else { return }
312 let fm = FileManager.default
313 // Comps folder fingerprint
314 var parts: [String] = []
315 for name in ((try? fm.contentsOfDirectory(atPath: folder)) ?? []).sorted()
316 where name.hasSuffix(".comp") {
317 let path = (folder as NSString).appendingPathComponent(name)
318 let mtime = (try? fm.attributesOfItem(atPath: path))?[.modificationDate] as? Date
319 parts.append("\(name)@\(mtime?.timeIntervalSince1970 ?? 0)")
320 }
321 let folderSig = parts.joined(separator: "|")
322
323 // Render dir fingerprints (distinct Saver directories, capped)
324 var renderSigs: [String: String] = [:]
325 let dirs = Set(comps.compactMap { $0.saverPath }
326 .map { (($0 as NSString).deletingLastPathComponent) }).prefix(12)
327 for dir in dirs {
328 let names = (try? fm.contentsOfDirectory(atPath: dir)) ?? []
329 renderSigs[dir] = "\(names.count)#\(names.max() ?? "")"
330 }
331
332 DispatchQueue.main.async {
333 defer { self.polling = false }
334 var changed = false
335 if let old = self.folderSignature, old != folderSig {
336 self.rescan()
337 changed = true
338 }
339 self.folderSignature = folderSig
340 if !changed {
341 for (dir, sig) in renderSigs {
342 if let old = self.renderSignatures[dir], old != sig {
343 // New frames landed: refresh sequences + previews.
344 self.sequenceLock.lock()
345 self.sequenceCache = [:]
346 self.sequenceLock.unlock()
347 self.imageCache.removeAllObjects()
348 NotificationCenter.default.post(name: .compsChanged, object: nil)
349 break
350 }
351 }
352 }
353 self.renderSignatures = renderSigs
354 }
355 }
356 }
357
358 func togglePreferredTake() {
359 guard let comp = comp(at: selectedCompPath) else {
360 NotificationCenter.default.post(name: .transientStatus, object: nil,
361 userInfo: ["text": "Select a Fusion comp to set the preferred take"])
362 return
363 }
364 ctx.store.mutate { model in
365 if let i = model.preferredTakes.firstIndex(of: comp.name) {
366 model.preferredTakes.remove(at: i)
367 } else {
368 model.preferredTakes.append(comp.name)
369 }
370 }
371 let on = ctx.store.project.preferredTakes.contains(comp.name)
372 NotificationCenter.default.post(name: .transientStatus, object: nil,
373 userInfo: ["text": on ? "★ \(comp.name) is the preferred take"
374 : "\(comp.name) is no longer preferred"])
375 }
376
377 func openInFusion(_ comp: FusionComp) {
378 NSWorkspace.shared.open(URL(fileURLWithPath: comp.path))
379 }
380}