1import AppKit
2import UniformTypeIdentifiers
3
4/// The Export sheet: pick a format + resolution, tick the tracks to flatten,
5/// or exclusively pick the whole Fusion render / the whole storyboard.
6final class ExportDialog: NSObject {
7 static let shared = ExportDialog()
8
9 private var window: NSWindow?
10 private var trackRows: [(index: Int, checkbox: NSButton)] = []
11 private var fusionCheckbox: NSButton?
12 private var storyboardCheckbox: NSButton?
13 private let formatPopup = NSPopUpButton()
14 private let resolutionPopup = NSPopUpButton()
15 private let hud = ExportProgressHUD()
16
17 private var project: ProjectModel { DocumentContext.current.store.project }
18
19 func show() {
20 // Rebuilt every time — the track list depends on the current project.
21 buildWindow()
22 window?.center()
23 window?.makeKeyAndOrderFront(nil)
24 NSApp.activate(ignoringOtherApps: true)
25 }
26
27 // MARK: - Build
28
29 private func buildWindow() {
30 trackRows = []
31 fusionCheckbox = nil
32 storyboardCheckbox = nil
33
34 let w = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 420, height: 400),
35 styleMask: [.titled, .closable], backing: .buffered, defer: false)
36 w.title = "Export"
37 w.isReleasedWhenClosed = false
38
39 let root = NSStackView()
40 root.orientation = .vertical
41 root.alignment = .leading
42 root.spacing = 12
43 root.edgeInsets = NSEdgeInsets(top: 16, left: 18, bottom: 16, right: 18)
44 root.translatesAutoresizingMaskIntoConstraints = false
45
46 // Format + resolution row.
47 formatPopup.removeAllItems()
48 for f in ExportFormat.allCases { formatPopup.addItem(withTitle: f.title) }
49 formatPopup.target = self
50 formatPopup.action = #selector(formatChanged)
51
52 resolutionPopup.removeAllItems()
53 for r in ExportResolution.allCases { resolutionPopup.addItem(withTitle: r.title) }
54 resolutionPopup.selectItem(at: 2) // 1080p default
55
56 let formatRow = NSStackView(views: [
57 NSTextField(labelWithString: "Format"), formatPopup,
58 NSTextField(labelWithString: "Size"), resolutionPopup,
59 ])
60 formatRow.spacing = 8
61 root.addArrangedSubview(formatRow)
62
63 let sep = NSBox(); sep.boxType = .separator
64 sep.translatesAutoresizingMaskIntoConstraints = false
65 root.addArrangedSubview(sep)
66 sep.widthAnchor.constraint(equalTo: root.widthAnchor, constant: -36).isActive = true
67
68 root.addArrangedSubview(NSTextField(labelWithString: "Tracks to export:"))
69
70 // Special exclusive rows on top.
71 if !DocumentContext.current.comps.comps.isEmpty {
72 let cb = makeExclusiveRow(title: "Fusion (all comps)", color: FusionComps.yellow, into: root)
73 fusionCheckbox = cb
74 }
75 if project.hasStoryboard {
76 let cb = makeExclusiveRow(title: "Storyboard",
77 color: color(hue: project.hue(for: .storyboard)), into: root)
78 storyboardCheckbox = cb
79 }
80
81 // Regular track rows: only tracks that actually hold clips.
82 for index in project.tracks.indices {
83 let clips = project.clips(onVideo: index)
84 guard !clips.isEmpty else { continue }
85 let cb = NSButton(checkboxWithTitle: trackName(index), target: self,
86 action: #selector(trackToggled))
87 cb.state = isAudioOnly(index) ? .on : .off // audio beds start ticked
88 let swatch = colorSwatch(color(hue: project.hue(for: .video(index))))
89 let row = NSStackView(views: [swatch, cb])
90 row.spacing = 6
91 root.addArrangedSubview(row)
92 trackRows.append((index, cb))
93 }
94
95 // Buttons.
96 let cancel = NSButton(title: "Cancel", target: self, action: #selector(closeWindow))
97 cancel.keyEquivalent = "\u{1b}"
98 let export = NSButton(title: "Export…", target: self, action: #selector(startExport))
99 export.keyEquivalent = "\r"
100 let spacer = NSView()
101 spacer.setContentHuggingPriority(.init(1), for: .horizontal)
102 let buttons = NSStackView(views: [spacer, cancel, export])
103 buttons.spacing = 8
104 buttons.translatesAutoresizingMaskIntoConstraints = false
105
106 let container = NSView()
107 container.addSubview(root)
108 container.addSubview(buttons)
109 NSLayoutConstraint.activate([
110 root.topAnchor.constraint(equalTo: container.topAnchor),
111 root.leadingAnchor.constraint(equalTo: container.leadingAnchor),
112 root.trailingAnchor.constraint(equalTo: container.trailingAnchor),
113 buttons.topAnchor.constraint(greaterThanOrEqualTo: root.bottomAnchor, constant: 8),
114 buttons.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 18),
115 buttons.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -18),
116 buttons.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -14),
117 ])
118 w.contentView = container
119 w.setContentSize(container.fittingSize)
120 window = w
121 refreshEnablement()
122 }
123
124 private func makeExclusiveRow(title: String, color: NSColor, into root: NSStackView) -> NSButton {
125 let cb = NSButton(checkboxWithTitle: title, target: self, action: #selector(exclusiveToggled))
126 let row = NSStackView(views: [colorSwatch(color), cb])
127 row.spacing = 6
128 root.addArrangedSubview(row)
129 return cb
130 }
131
132 private func colorSwatch(_ c: NSColor) -> NSView {
133 let v = NSView()
134 v.wantsLayer = true
135 v.layer?.backgroundColor = c.cgColor
136 v.layer?.cornerRadius = 3
137 v.translatesAutoresizingMaskIntoConstraints = false
138 v.widthAnchor.constraint(equalToConstant: 14).isActive = true
139 v.heightAnchor.constraint(equalToConstant: 14).isActive = true
140 return v
141 }
142
143 private func color(hue: Double) -> NSColor {
144 NSColor(calibratedHue: hue, saturation: 0.55, brightness: 0.85, alpha: 1)
145 }
146
147 private func trackName(_ index: Int) -> String {
148 let clips = project.clips(onVideo: index)
149 if let first = clips.first, let m = project.media(first.mediaId) { return m.displayName }
150 return "Track \(index + 1)"
151 }
152
153 private func isAudioOnly(_ index: Int) -> Bool {
154 let clips = project.clips(onVideo: index)
155 return !clips.isEmpty && clips.allSatisfy {
156 $0.kind == .audio || (project.media($0.mediaId)?.isAudio ?? false)
157 }
158 }
159
160 // MARK: - Actions
161
162 @objc private func formatChanged() {
163 let f = ExportFormat.allCases[formatPopup.indexOfSelectedItem]
164 resolutionPopup.isEnabled = f.isVideo
165 }
166
167 @objc private func trackToggled() {}
168
169 @objc private func exclusiveToggled(_ sender: NSButton) {
170 // Fusion and storyboard are mutually exclusive with each other too.
171 if sender === fusionCheckbox, sender.state == .on { storyboardCheckbox?.state = .off }
172 if sender === storyboardCheckbox, sender.state == .on { fusionCheckbox?.state = .off }
173 refreshEnablement()
174 }
175
176 /// Ticking Fusion or Storyboard disables and clears everything else.
177 private func refreshEnablement() {
178 let fusionOn = fusionCheckbox?.state == .on
179 let storyOn = storyboardCheckbox?.state == .on
180 let special = fusionOn || storyOn
181 for (_, cb) in trackRows {
182 cb.isEnabled = !special
183 if special { cb.state = .off }
184 }
185 fusionCheckbox?.isEnabled = !storyOn
186 storyboardCheckbox?.isEnabled = !fusionOn
187 let f = ExportFormat.allCases[formatPopup.indexOfSelectedItem]
188 resolutionPopup.isEnabled = f.isVideo
189 }
190
191 @objc private func closeWindow() { window?.close() }
192
193 @objc private func startExport() {
194 let format = ExportFormat.allCases[formatPopup.indexOfSelectedItem]
195 let resolution = ExportResolution.allCases[resolutionPopup.indexOfSelectedItem]
196
197 let source: ExportJob.Source
198 if fusionCheckbox?.state == .on {
199 source = .fusion
200 } else if storyboardCheckbox?.state == .on {
201 source = .storyboard
202 } else {
203 let checked = trackRows.filter { $0.checkbox.state == .on }.map(\.index)
204 guard !checked.isEmpty else {
205 alert("Select at least one track to export."); return
206 }
207 let ordered = checked.sorted().map { TrackRef.video($0) }
208 source = .tracks(video: ordered, audio: Set(ordered))
209 }
210
211 let save = NSSavePanel()
212 if let t = UTType(filenameExtension: format.ext) { save.allowedContentTypes = [t] }
213 save.nameFieldStringValue = "\(defaultBaseName()).\(format.ext)"
214 save.canCreateDirectories = true
215 guard save.runModal() == .OK, let dest = save.url else { return }
216
217 let job = ExportJob(source: source, format: format, resolution: resolution,
218 fps: project.fps, dest: dest)
219 window?.close()
220 hud.begin(title: "Exporting \(dest.lastPathComponent)")
221 Exporter.run(job, progress: { [hud] f in hud.setFraction(f) }) { [hud] result in
222 hud.end()
223 switch result {
224 case .success(let url):
225 NotificationCenter.default.post(name: .transientStatus, object: nil,
226 userInfo: ["text": "Exported \(url.lastPathComponent)"])
227 NSWorkspace.shared.activateFileViewerSelecting([url])
228 case .failure(let err):
229 let a = NSAlert()
230 a.messageText = "Export failed"
231 a.informativeText = (err as? LocalizedError)?.errorDescription ?? "\(err)"
232 a.alertStyle = .warning
233 a.runModal()
234 }
235 }
236 }
237
238 private func defaultBaseName() -> String {
239 if let url = DocumentContext.current.document?.fileURL {
240 return url.deletingPathExtension().lastPathComponent
241 }
242 return "Export"
243 }
244
245 private func alert(_ text: String) {
246 let a = NSAlert(); a.messageText = text; a.runModal()
247 }
248}
249
250/// Small always-on-top determinate progress panel shown during an export.
251final class ExportProgressHUD {
252 private var panel: NSPanel?
253 private let bar = NSProgressIndicator()
254 private let label = NSTextField(labelWithString: "")
255
256 func begin(title: String) {
257 label.stringValue = title
258 bar.isIndeterminate = false
259 bar.minValue = 0; bar.maxValue = 1; bar.doubleValue = 0
260 bar.style = .bar
261
262 let p = NSPanel(contentRect: NSRect(x: 0, y: 0, width: 340, height: 90),
263 styleMask: [.titled], backing: .buffered, defer: false)
264 p.title = "Export"
265 let stack = NSStackView(views: [label, bar])
266 stack.orientation = .vertical
267 stack.alignment = .leading
268 stack.spacing = 12
269 stack.edgeInsets = NSEdgeInsets(top: 18, left: 18, bottom: 18, right: 18)
270 stack.translatesAutoresizingMaskIntoConstraints = false
271 p.contentView?.addSubview(stack)
272 NSLayoutConstraint.activate([
273 stack.topAnchor.constraint(equalTo: p.contentView!.topAnchor),
274 stack.leadingAnchor.constraint(equalTo: p.contentView!.leadingAnchor),
275 stack.trailingAnchor.constraint(equalTo: p.contentView!.trailingAnchor),
276 bar.widthAnchor.constraint(equalTo: stack.widthAnchor, constant: -36),
277 ])
278 p.center()
279 p.makeKeyAndOrderFront(nil)
280 panel = p
281 }
282
283 func setFraction(_ f: Double) { bar.doubleValue = min(1, max(0, f)) }
284
285 func end() {
286 panel?.close()
287 panel = nil
288 }
289}