1import AppKit
2import AVFoundation
3import ImageIO
4
5// MARK: - Formats
6
7/// The four output presets. Video formats carry a picture; audio formats are
8/// sound only (the resolution picker is disabled for them).
9enum ExportFormat: String, CaseIterable {
10 case h264mp4, webm, mp3, wav
11
12 var title: String {
13 switch self {
14 case .h264mp4: return "H.264 MP4"
15 case .webm: return "WebM (VP9)"
16 case .mp3: return "MP3 audio"
17 case .wav: return "WAV audio"
18 }
19 }
20 var ext: String {
21 switch self {
22 case .h264mp4: return "mp4"
23 case .webm: return "webm"
24 case .mp3: return "mp3"
25 case .wav: return "wav"
26 }
27 }
28 var isVideo: Bool { self == .h264mp4 || self == .webm }
29
30 /// ffmpeg codec/quality flags (no -vf; the caller prepends the scale).
31 var encoderArgs: [String] {
32 switch self {
33 case .h264mp4:
34 return ["-c:v", "libx264", "-preset", "medium", "-crf", "18",
35 "-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", "192k"]
36 case .webm:
37 return ["-c:v", "libvpx-vp9", "-b:v", "0", "-crf", "30", "-row-mt", "1",
38 "-pix_fmt", "yuv420p", "-c:a", "libopus", "-b:a", "160k"]
39 case .mp3:
40 return ["-vn", "-c:a", "libmp3lame", "-q:a", "2"]
41 case .wav:
42 return ["-vn", "-c:a", "pcm_s16le"]
43 }
44 }
45}
46
47/// Output size, expressed as a target height (width follows the source aspect,
48/// kept even). `.source` keeps native size, only forcing even dimensions.
49enum ExportResolution: CaseIterable {
50 case source, uhd, hd1080, hd720, sd480
51
52 var title: String {
53 switch self {
54 case .source: return "Source"
55 case .uhd: return "2160p (4K)"
56 case .hd1080: return "1080p"
57 case .hd720: return "720p"
58 case .sd480: return "480p"
59 }
60 }
61 var height: Int? {
62 switch self {
63 case .source: return nil
64 case .uhd: return 2160
65 case .hd1080: return 1080
66 case .hd720: return 720
67 case .sd480: return 480
68 }
69 }
70 /// ffmpeg scale filter. Never upscales past the source (min(ih, H)); the
71 /// comma inside min() is escaped so ffmpeg's filter parser keeps it whole.
72 var scaleFilter: String {
73 if let h = height { return "scale=-2:min(ih\\,\(h))" }
74 return "scale=trunc(iw/2)*2:trunc(ih/2)*2"
75 }
76}
77
78// MARK: - Pure planning logic (headless-testable)
79
80/// A single flattened output segment: one source clip's range, laid at a
81/// timeline position with no overlap. Adjacent same-clip pieces are coalesced.
82struct FlatSegment: Equatable {
83 var clipId: UUID
84 var mediaId: UUID
85 var start: Double // timeline seconds
86 var duration: Double // timeline seconds
87 var srcIn: Double // source seconds
88 var speed: Double
89 var end: Double { start + duration }
90}
91
92enum ExportPlan {
93
94 /// Flatten the selected tracks into one video layer: at every instant the
95 /// TOPMOST selected track (smallest index, passed first in `trackRefs`) that
96 /// has a clip there wins — no crossfades, just pick-top.
97 static func flattenTopmost(project: ProjectModel, trackRefs: [TrackRef]) -> [FlatSegment] {
98 let priority = Dictionary(uniqueKeysWithValues: trackRefs.enumerated().map { ($0.element, $0.offset) })
99 let clips = project.clips.filter {
100 priority[$0.track] != nil && $0.kind == .video && $0.mediaId != nil
101 }
102 guard !clips.isEmpty else { return [] }
103
104 // Cut points: every clip edge across the selected tracks.
105 var bounds = Set<Double>()
106 for c in clips { bounds.insert(c.start); bounds.insert(c.end) }
107 let cuts = bounds.sorted()
108 guard cuts.count >= 2 else { return [] }
109
110 var raw: [FlatSegment] = []
111 for i in 0..<(cuts.count - 1) {
112 let a = cuts[i], b = cuts[i + 1]
113 guard b - a > 1e-9 else { continue }
114 let mid = (a + b) / 2
115 // Topmost covering clip: lowest track priority, then latest start
116 // (the "most recent cut" on a track, matching clipAt()).
117 let win = clips.filter { $0.start <= mid && mid < $0.end }.min {
118 let pa = priority[$0.track]!, pb = priority[$1.track]!
119 if pa != pb { return pa < pb }
120 return $0.start > $1.start
121 }
122 guard let win else { continue }
123 raw.append(FlatSegment(clipId: win.id, mediaId: win.mediaId!,
124 start: a, duration: b - a,
125 srcIn: win.sourceTime(at: a), speed: win.speed))
126 }
127
128 // Coalesce contiguous pieces of the same clip back into one segment.
129 var out: [FlatSegment] = []
130 for s in raw {
131 if var last = out.last, last.clipId == s.clipId,
132 abs(last.end - s.start) < 1e-6 {
133 last.duration += s.duration
134 out[out.count - 1] = last
135 } else {
136 out.append(s)
137 }
138 }
139 return out
140 }
141
142 /// Every audio-bearing, unmuted clip on the selected tracks. Audio layers
143 /// freely (per the app's design), so these all mix together on export.
144 static func audioClips(project: ProjectModel, trackRefs: Set<TrackRef>) -> [Clip] {
145 project.clips.filter {
146 trackRefs.contains($0.track) && $0.kind != .storyboard && !$0.muted
147 && (project.media($0.mediaId)?.hasAudio ?? false)
148 }
149 }
150
151 /// Frame ranges the Fusion comps FAIL to cover across [min,max] — empty
152 /// means gapless. Each returned pair is an inclusive missing range.
153 static func fusionCoverageGaps(_ comps: [FusionComp]) -> [(Int, Int)] {
154 guard !comps.isEmpty else { return [] }
155 let ranges = comps.map { ($0.startFrame, $0.endFrame) }.sorted { $0.0 < $1.0 }
156 var gaps: [(Int, Int)] = []
157 var coveredTo = ranges[0].0 - 1 // last frame covered so far
158 for (s, e) in ranges {
159 if s > coveredTo + 1 { gaps.append((coveredTo + 1, s - 1)) }
160 coveredTo = max(coveredTo, e)
161 }
162 return gaps
163 }
164}
165
166// MARK: - Job description
167
168struct ExportJob {
169 enum Source {
170 case tracks(video: [TrackRef], audio: Set<TrackRef>) // video lanes in priority order
171 case fusion
172 case storyboard
173 }
174 var source: Source
175 var format: ExportFormat
176 var resolution: ExportResolution
177 var fps: Double
178 var dest: URL
179}
180
181// MARK: - Exporter
182
183enum ExportError: Error, LocalizedError {
184 case noFfmpeg
185 case nothingToExport(String)
186 case fusion(String)
187 case intermediateFailed
188 case encodeFailed(String)
189 case missingMedia([String])
190
191 var errorDescription: String? {
192 switch self {
193 case .noFfmpeg: return "ffmpeg was not found. Install it (e.g. `brew install ffmpeg`)."
194 case .nothingToExport(let s): return s
195 case .fusion(let s): return s
196 case .intermediateFailed: return "Could not render the timeline composition."
197 case .encodeFailed(let s): return "ffmpeg failed to encode the output.\n\n\(s)"
198 case .missingMedia(let names):
199 let list = names.map { " • \($0)" }.joined(separator: "\n")
200 return "Export was stopped because this media could not be read — the "
201 + "output would silently drop those clips. Reconnect the drive or "
202 + "relink the files and try again:\n\n\(list)"
203 }
204 }
205}
206
207/// Runs an ExportJob off the main thread. `progress` (0…1) and `completion`
208/// are always delivered on the main thread.
209enum Exporter {
210
211 static func run(_ job: ExportJob,
212 progress: @escaping (Double) -> Void,
213 completion: @escaping (Result<URL, Error>) -> Void) {
214 DispatchQueue.global(qos: .userInitiated).async {
215 let result: Result<URL, Error>
216 do {
217 switch job.source {
218 case let .tracks(video, audio):
219 try exportTracks(job, video: video, audio: audio, progress: progress)
220 case .fusion:
221 try exportFusion(job, progress: progress)
222 case .storyboard:
223 try exportStoryboard(job, progress: progress)
224 }
225 result = .success(job.dest)
226 } catch {
227 result = .failure(error)
228 }
229 DispatchQueue.main.async { completion(result) }
230 }
231 }
232
233 // MARK: Track path (AVComposition intermediate → ffmpeg)
234
235 private static func exportTracks(_ job: ExportJob, video: [TrackRef], audio: Set<TrackRef>,
236 progress: @escaping (Double) -> Void) throws {
237 let project = DocumentContext.current.store.project
238 let segments = ExportPlan.flattenTopmost(project: project, trackRefs: video)
239 let audioClips = ExportPlan.audioClips(project: project, trackRefs: audio)
240
241 if job.format.isVideo && segments.isEmpty {
242 throw ExportError.nothingToExport(
243 "No video clips on the selected tracks for a video format.")
244 }
245 if !job.format.isVideo && audioClips.isEmpty {
246 throw ExportError.nothingToExport(
247 "No audio on the selected tracks for an audio-only format.")
248 }
249
250 let comp = AVMutableComposition()
251 let wantVideo = job.format.isVideo && !segments.isEmpty
252
253 // Media that couldn't be read while building the composition. A silent
254 // gap here means the render quietly drops content, so we collect every
255 // offending source and abort (below) rather than hand ffmpeg a truncated
256 // intermediate that looks like a successful export.
257 var missing = Set<String>()
258
259 // Video: one track, segments appended left-to-right with empty gaps.
260 if wantVideo, let vTrack = comp.addMutableTrack(
261 withMediaType: .video, preferredTrackID: kCMPersistentTrackID_Invalid) {
262 var cursor = 0.0
263 for seg in segments {
264 guard let media = project.media(seg.mediaId) else {
265 missing.insert("(missing media reference)"); continue
266 }
267 if seg.start > cursor + 1e-6 {
268 vTrack.insertEmptyTimeRange(cmRange(cursor, seg.start - cursor))
269 cursor = seg.start
270 }
271 let asset = AVURLAsset(url: media.url)
272 guard let src = loadTracksSync(asset, mediaType: .video).first else {
273 missing.insert(media.displayName)
274 vTrack.insertEmptyTimeRange(cmRange(cursor, seg.duration)); cursor += seg.duration; continue
275 }
276 let srcDur = seg.duration * seg.speed
277 let range = cmRange(seg.srcIn, srcDur)
278 let at = cm(cursor)
279 do { try vTrack.insertTimeRange(range, of: src, at: at) }
280 catch { missing.insert(media.displayName) }
281 if abs(seg.speed - 1) > 1e-6 {
282 // Nothing has been appended after `at` yet, so scaling this
283 // range back to timeline duration is safe.
284 vTrack.scaleTimeRange(cmRange(cursor, srcDur), toDuration: cm(seg.duration))
285 }
286 cursor += seg.duration
287 }
288 }
289
290 // Audio: one composition track per clip so they mix; fades become
291 // volume ramps in the audio mix.
292 var mixParams: [AVMutableAudioMixInputParameters] = []
293 for clip in audioClips {
294 guard let media = project.media(clip.mediaId),
295 let aTrack = comp.addMutableTrack(
296 withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid)
297 else { continue }
298 let asset = AVURLAsset(url: media.url)
299 guard let src = loadTracksSync(asset, mediaType: .audio).first else {
300 missing.insert(media.displayName); continue
301 }
302 let srcDur = clip.duration * clip.speed
303 do { try aTrack.insertTimeRange(cmRange(clip.srcIn, srcDur), of: src, at: cm(clip.start)) }
304 catch { missing.insert(media.displayName) }
305 if abs(clip.speed - 1) > 1e-6 {
306 aTrack.scaleTimeRange(cmRange(clip.start, srcDur), toDuration: cm(clip.duration))
307 }
308 let p = AVMutableAudioMixInputParameters(track: aTrack)
309 if clip.fadeIn > 0.001 {
310 p.setVolumeRamp(fromStartVolume: 0, toEndVolume: 1,
311 timeRange: cmRange(clip.start, clip.fadeIn))
312 }
313 if clip.fadeOut > 0.001 {
314 p.setVolumeRamp(fromStartVolume: 1, toEndVolume: 0,
315 timeRange: cmRange(clip.end - clip.fadeOut, clip.fadeOut))
316 }
317 mixParams.append(p)
318 }
319
320 // Any unreadable source means the render would be silently truncated —
321 // stop and tell the user exactly which files, rather than produce a
322 // partial export that looks complete.
323 if !missing.isEmpty {
324 throw ExportError.missingMedia(missing.sorted())
325 }
326
327 // Render the composition to an intermediate the encoder can read.
328 let tmp = tempDir()
329 defer { try? FileManager.default.removeItem(at: tmp) }
330 let interExt = wantVideo ? "mov" : "m4a"
331 let intermediate = tmp.appendingPathComponent("intermediate.\(interExt)")
332 let preset = wantVideo ? AVAssetExportPresetHighestQuality : AVAssetExportPresetAppleM4A
333 guard let session = AVAssetExportSession(asset: comp, presetName: preset) else {
334 throw ExportError.intermediateFailed
335 }
336 session.outputURL = intermediate
337 session.outputFileType = wantVideo ? .mov : .m4a
338 if !mixParams.isEmpty {
339 let mix = AVMutableAudioMix(); mix.inputParameters = mixParams
340 session.audioMix = mix
341 }
342
343 let sema = DispatchSemaphore(value: 0)
344 // AV export is the first half of the progress bar; poll it until done.
345 let polling = AtomicFlag(true)
346 DispatchQueue.global(qos: .utility).async {
347 while polling.value {
348 DispatchQueue.main.async { progress(0.5 * Double(session.progress)) }
349 usleep(200_000)
350 }
351 }
352 session.exportAsynchronously { sema.signal() }
353 sema.wait()
354 polling.value = false
355 guard session.status == .completed else { throw ExportError.intermediateFailed }
356
357 try encodeWithFfmpeg(input: intermediate, job: job, base: 0.5, span: 0.5,
358 progress: progress)
359 }
360
361 // MARK: Fusion path (image sequence → ffmpeg)
362
363 private static func exportFusion(_ job: ExportJob, progress: @escaping (Double) -> Void) throws {
364 let comps = DocumentContext.current.comps.comps
365 guard !comps.isEmpty else { throw ExportError.fusion("No Fusion comps in this project.") }
366
367 let gaps = ExportPlan.fusionCoverageGaps(comps)
368 if let g = gaps.first {
369 throw ExportError.fusion("The Fusion comps have a gap at frames \(g.0)–\(g.1). "
370 + "Export needs a gapless range.")
371 }
372 let start = comps.map(\.startFrame).min()!
373 let end = comps.map(\.endFrame).max()!
374
375 // Resolve every frame to a file, checking size uniformity as we go.
376 var frames: [(url: URL, duration: Double)] = []
377 var size: (Int, Int)?
378 let frameDur = 1.0 / max(1, job.fps)
379 for f in start...end {
380 guard let url = DocumentContext.current.comps.renderedFrameURL(atFrame: f) else {
381 throw ExportError.fusion("Frame \(f) has not been rendered yet.")
382 }
383 if let s = imagePixelSize(url) {
384 if let known = size, known != s {
385 throw ExportError.fusion(
386 "Frame \(f) is \(s.0)×\(s.1) but earlier frames are \(known.0)×\(known.1). "
387 + "All comps must render at the same resolution.")
388 }
389 size = size ?? s
390 }
391 frames.append((url, frameDur))
392 }
393 try encodeSlideshow(frames: frames, job: job, progress: progress)
394 }
395
396 // MARK: Storyboard path (panel composites → ffmpeg)
397
398 private static func exportStoryboard(_ job: ExportJob, progress: @escaping (Double) -> Void) throws {
399 let project = DocumentContext.current.store.project
400 guard project.hasStoryboard else {
401 throw ExportError.nothingToExport("There is no storyboard in this project.")
402 }
403 let panels = project.clips(on: .storyboard)
404 .filter { $0.kind == .storyboard && $0.board != nil }
405 guard !panels.isEmpty else {
406 throw ExportError.nothingToExport("The storyboard has no panels.")
407 }
408
409 let tmp = tempDir()
410 defer { try? FileManager.default.removeItem(at: tmp) }
411 var frames: [(url: URL, duration: Double)] = []
412 for (i, panel) in panels.enumerated() {
413 let img = DocumentContext.current.boards.composite(for: panel.board!)
414 let url = tmp.appendingPathComponent(String(format: "panel%04d.png", i))
415 guard writePNG(img, to: url) else {
416 throw ExportError.nothingToExport("Could not render storyboard panel \(i + 1).")
417 }
418 frames.append((url, max(1.0 / max(1, job.fps), panel.duration)))
419 }
420 try encodeSlideshow(frames: frames, job: job, progress: progress)
421 }
422
423 // MARK: - ffmpeg back ends
424
425 /// Encode a variable-duration still-image sequence via the concat demuxer.
426 private static func encodeSlideshow(frames: [(url: URL, duration: Double)],
427 job: ExportJob,
428 progress: @escaping (Double) -> Void) throws {
429 guard let ffmpeg = MediaPipeline.findExecutable("ffmpeg") else { throw ExportError.noFfmpeg }
430 guard !frames.isEmpty else { throw ExportError.nothingToExport("Nothing to render.") }
431 if !job.format.isVideo {
432 throw ExportError.nothingToExport("A storyboard or Fusion export has no audio for \(job.format.title).")
433 }
434 let tmp = tempDir()
435 defer { try? FileManager.default.removeItem(at: tmp) }
436 let list = tmp.appendingPathComponent("frames.txt")
437 var text = "ffconcat version 1.0\n"
438 for fr in frames {
439 text += "file '\(escapeConcat(fr.url.path))'\nduration \(String(format: "%.5f", fr.duration))\n"
440 }
441 // The concat demuxer drops the final entry's duration unless the last
442 // file is repeated.
443 if let last = frames.last { text += "file '\(escapeConcat(last.url.path))'\n" }
444 try? text.write(to: list, atomically: true, encoding: .utf8)
445
446 var args = ["-y", "-f", "concat", "-safe", "0", "-i", list.path,
447 "-vf", job.resolution.scaleFilter, "-r", String(format: "%.5f", job.fps)]
448 args += job.format.encoderArgs
449 args += ["-progress", "pipe:1", "-nostats", job.dest.path]
450 let total = frames.reduce(0) { $0 + $1.duration }
451 let res = MediaPipeline.run(ffmpeg, args, duration: total) { p in
452 DispatchQueue.main.async { progress(p) }
453 }
454 if res.exitCode != 0 { throw ExportError.encodeFailed(res.stdout) }
455 }
456
457 /// Transcode an intermediate (mov/m4a) into the chosen delivery format.
458 private static func encodeWithFfmpeg(input: URL, job: ExportJob,
459 base: Double, span: Double,
460 progress: @escaping (Double) -> Void) throws {
461 guard let ffmpeg = MediaPipeline.findExecutable("ffmpeg") else { throw ExportError.noFfmpeg }
462 var args = ["-y", "-i", input.path]
463 if job.format.isVideo { args += ["-vf", job.resolution.scaleFilter] }
464 args += job.format.encoderArgs
465 args += ["-progress", "pipe:1", "-nostats", job.dest.path]
466 let dur = assetDuration(input)
467 let res = MediaPipeline.run(ffmpeg, args, duration: dur) { p in
468 DispatchQueue.main.async { progress(base + span * p) }
469 }
470 if res.exitCode != 0 { throw ExportError.encodeFailed(res.stdout) }
471 }
472
473 // MARK: - Helpers
474
475 private static func cm(_ s: Double) -> CMTime { CMTime(seconds: s, preferredTimescale: 600) }
476 private static func cmRange(_ start: Double, _ dur: Double) -> CMTimeRange {
477 CMTimeRange(start: cm(start), duration: cm(max(0, dur)))
478 }
479
480 private static func tempDir() -> URL {
481 let dir = URL(fileURLWithPath: NSTemporaryDirectory())
482 .appendingPathComponent("SequencerExport-\(UUID().uuidString)", isDirectory: true)
483 try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
484 return dir
485 }
486
487 private static func assetDuration(_ url: URL) -> Double {
488 CMTimeGetSeconds(loadDurationSync(AVURLAsset(url: url)))
489 }
490
491 /// Blocks the calling (background) thread until the async track load
492 /// completes. Safe here because callers always run off the main thread.
493 private static func loadTracksSync(_ asset: AVURLAsset, mediaType: AVMediaType) -> [AVAssetTrack] {
494 let sem = DispatchSemaphore(value: 0)
495 var tracks: [AVAssetTrack] = []
496 Task {
497 tracks = (try? await asset.loadTracks(withMediaType: mediaType)) ?? []
498 sem.signal()
499 }
500 sem.wait()
501 return tracks
502 }
503
504 /// Blocks the calling (background) thread until the async duration load
505 /// completes. Safe here because callers always run off the main thread.
506 private static func loadDurationSync(_ asset: AVURLAsset) -> CMTime {
507 let sem = DispatchSemaphore(value: 0)
508 var duration = CMTime.zero
509 Task {
510 duration = (try? await asset.load(.duration)) ?? .zero
511 sem.signal()
512 }
513 sem.wait()
514 return duration
515 }
516
517 static func imagePixelSize(_ url: URL) -> (Int, Int)? {
518 guard let src = CGImageSourceCreateWithURL(url as CFURL, nil),
519 let props = CGImageSourceCopyPropertiesAtIndex(src, 0, nil) as? [CFString: Any],
520 let w = props[kCGImagePropertyPixelWidth] as? Int,
521 let h = props[kCGImagePropertyPixelHeight] as? Int else { return nil }
522 return (w, h)
523 }
524
525 private static func writePNG(_ image: NSImage, to url: URL) -> Bool {
526 guard let tiff = image.tiffRepresentation,
527 let rep = NSBitmapImageRep(data: tiff),
528 let png = rep.representation(using: .png, properties: [:]) else { return false }
529 return (try? png.write(to: url)) != nil
530 }
531
532 /// Escape a path for a concat-demuxer `file '…'` line.
533 private static func escapeConcat(_ path: String) -> String {
534 path.replacingOccurrences(of: "'", with: "'\\''")
535 }
536}
537
538/// Minimal lock-guarded boolean shared between the export thread and its
539/// progress-poll thread.
540private final class AtomicFlag {
541 private let lock = NSLock()
542 private var _value: Bool
543 init(_ v: Bool) { _value = v }
544 var value: Bool {
545 get { lock.lock(); defer { lock.unlock() }; return _value }
546 set { lock.lock(); _value = newValue; lock.unlock() }
547 }
548}