import Foundation /// Exports a playlist as a CMX 3600 Edit Decision List (EDL). /// EDLs are widely used in professional audio/video production /// and can be imported by Pro Tools, Audition, Resolve, and many others. struct EDLExporter: DAWExporter { static let formatID = "edl" static let formatName = "Edit Decision List" static let fileExtension = "edl" static func export(playlist: Playlist, to url: URL, options: ExportOptions) throws { let entries = playlist.sortedEntries let sampleRate = options.targetSampleRate ?? 44100 let fps = 30.0 // Standard frame rate for timecode var lines: [String] = [] // EDL header lines.append("TITLE: \(playlist.name)") lines.append("FCM: NON-DROP FRAME") lines.append("") var timelinePosition: TimeInterval = 0 for (index, entry) in entries.enumerated() { guard let track = entry.track, track.hasLocalFile else { continue } let editNumber = String(format: "%03d", index + 1) let reelName = sanitizeReelName(track.title) let startOffset = entry.startOffset let effectiveDuration = entry.effectiveDuration // Source IN/OUT let sourceIn = formatTimecode(startOffset, fps: fps) let sourceOut = formatTimecode(startOffset + effectiveDuration, fps: fps) // Record IN/OUT (timeline position) let recordIn = formatTimecode(timelinePosition, fps: fps) let recordOut = formatTimecode(timelinePosition + effectiveDuration, fps: fps) // Transition type let transition: String if entry.crossfadeDuration > 0 { let crossfadeFrames = Int(entry.crossfadeDuration * fps) transition = "D \(String(format: "%03d", crossfadeFrames))" // Dissolve } else { transition = "C" // Cut } // EDL edit entry: EDIT# REEL TRACK TRANSITION SOURCE_IN SOURCE_OUT RECORD_IN RECORD_OUT lines.append("\(editNumber) \(reelName) AA/V \(transition) \(sourceIn) \(sourceOut) \(recordIn) \(recordOut)") // Source file comment lines.append("* FROM CLIP NAME: \(track.title)") let relativePath = "\(options.audioFilesRelativePath)/\(track.fileURL.lastPathComponent)" lines.append("* SOURCE FILE: \(relativePath)") if !track.artist.isEmpty { lines.append("* ARTIST: \(track.artist)") } if let bpm = track.bpm { lines.append("* BPM: \(String(format: "%.1f", bpm))") } if let key = track.musicalKey { lines.append("* KEY: \(key)") } // Gain adjustment if entry.gainAdjustment != 0 { lines.append("* GAIN: \(String(format: "%.1f", entry.gainAdjustment)) dB") } lines.append("") // Add cue point markers if options.includeCuePoints { for cuePoint in track.cuePoints.sorted() { let markerTime = formatTimecode( timelinePosition + cuePoint.timestamp - startOffset, fps: fps ) let name = cuePoint.name.isEmpty ? cuePoint.type.rawValue : cuePoint.name lines.append("* MARKER: \(markerTime) \(name)") } if !track.cuePoints.isEmpty { lines.append("") } } timelinePosition += effectiveDuration if index + 1 < entries.count { timelinePosition -= entries[index + 1].crossfadeDuration } } let content = lines.joined(separator: "\n") + "\n" try content.write(to: url, atomically: true, encoding: .utf8) } // MARK: - Timecode Formatting /// Format seconds as SMPTE timecode HH:MM:SS:FF. private static func formatTimecode(_ seconds: TimeInterval, fps: Double) -> String { let totalSeconds = max(0, seconds) let hours = Int(totalSeconds) / 3600 let minutes = (Int(totalSeconds) % 3600) / 60 let secs = Int(totalSeconds) % 60 let frames = Int((totalSeconds - Double(Int(totalSeconds))) * fps) return String(format: "%02d:%02d:%02d:%02d", hours, minutes, secs, frames) } /// Sanitize a track title to a valid reel name (max 8 chars, alphanumeric). private static func sanitizeReelName(_ name: String) -> String { let sanitized = name .components(separatedBy: CharacterSet.alphanumerics.inverted) .joined() let truncated = String(sanitized.prefix(8)) return truncated.isEmpty ? "REEL001" : truncated.uppercased().padding(toLength: 8, withPad: " ", startingAt: 0) } }