| 1 | import AppKit |
| 2 | import 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. |
| 6 | final 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. |
| 251 | final 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 | } |