diff --git a/KINETIC/KINETIC/Services/GPX/GPXParser.swift b/KINETIC/KINETIC/Services/GPX/GPXParser.swift new file mode 100644 index 0000000..982aa32 --- /dev/null +++ b/KINETIC/KINETIC/Services/GPX/GPXParser.swift @@ -0,0 +1,311 @@ +import Foundation +import CoreLocation + +/// Parses GPX files and extracts telemetry data (coordinates, speed, altitude, time). +final class GPXParser: NSObject, XMLParserDelegate { + + // MARK: - Types + + struct GPXTrackPoint: Identifiable { + let id = UUID() + let coordinate: CLLocationCoordinate2D + let altitude: Double // meters + let timestamp: Date + let speed: Double? // m/s (from GPX extension or calculated) + let heartRate: Int? // bpm (from GPX extension) + let cadence: Int? // rpm (from GPX extension) + let power: Int? // watts (from GPX extension) + } + + struct GPXTrack { + let name: String? + let points: [GPXTrackPoint] + + // MARK: - Computed properties + + var startTime: Date? { points.first?.timestamp } + var endTime: Date? { points.last?.timestamp } + var duration: TimeInterval { + guard let start = startTime, let end = endTime else { return 0 } + return end.timeIntervalSince(start) + } + + var totalDistance: Double { + var distance: Double = 0 + for i in 1.. 0 else { return 0 } + return (totalDistance / 1000) / (duration / 3600) // km/h + } + + var maxAltitude: Double { + points.map(\.altitude).max() ?? 0 + } + + var minAltitude: Double { + points.map(\.altitude).min() ?? 0 + } + + var elevationGain: Double { + var gain: Double = 0 + for i in 1.. 1 { gain += delta } // threshold for noise + } + return gain + } + + /// Calculate speeds between each pair of points (m/s) + func calculatedSpeeds() -> [Double] { + guard points.count > 1 else { return [0] } + + var speeds: [Double] = [0] // First point has 0 speed + for i in 1..= 0 { + speeds.append(speed) + continue + } + + // Otherwise calculate from distance/time + let prev = CLLocation(latitude: points[i-1].coordinate.latitude, longitude: points[i-1].coordinate.longitude) + let curr = CLLocation(latitude: points[i].coordinate.latitude, longitude: points[i].coordinate.longitude) + let dist = curr.distance(from: prev) + let time = points[i].timestamp.timeIntervalSince(points[i-1].timestamp) + + if time > 0 { + speeds.append(dist / time) + } else { + speeds.append(speeds.last ?? 0) + } + } + return speeds + } + + /// Get interpolated data at a specific time offset from start + func dataAt(timeOffset: TimeInterval) -> InterpolatedData? { + guard let start = startTime, points.count > 1 else { return nil } + + let targetTime = start.addingTimeInterval(timeOffset) + + // Find the two surrounding points + var beforeIdx = 0 + var afterIdx = 1 + + for i in 0..= targetTime { + beforeIdx = i + afterIdx = i + 1 + break + } + if i == points.count - 2 { + beforeIdx = i + afterIdx = i + 1 + } + } + + let before = points[beforeIdx] + let after = points[afterIdx] + + let timeBetween = after.timestamp.timeIntervalSince(before.timestamp) + let fraction = timeBetween > 0 ? (targetTime.timeIntervalSince(before.timestamp)) / timeBetween : 0 + let clampedFraction = min(max(fraction, 0), 1) + + // Interpolate + let lat = before.coordinate.latitude + (after.coordinate.latitude - before.coordinate.latitude) * clampedFraction + let lon = before.coordinate.longitude + (after.coordinate.longitude - before.coordinate.longitude) * clampedFraction + let alt = before.altitude + (after.altitude - before.altitude) * clampedFraction + + // Speed from calculated speeds + let speeds = calculatedSpeeds() + let speedBefore = beforeIdx < speeds.count ? speeds[beforeIdx] : 0 + let speedAfter = afterIdx < speeds.count ? speeds[afterIdx] : 0 + let speed = (speedBefore + (speedAfter - speedBefore) * clampedFraction) * 3.6 // km/h + + // Accumulated distance up to this point + var dist: Double = 0 + for i in 1...min(beforeIdx, points.count - 1) { + let p = CLLocation(latitude: points[i-1].coordinate.latitude, longitude: points[i-1].coordinate.longitude) + let c = CLLocation(latitude: points[i].coordinate.latitude, longitude: points[i].coordinate.longitude) + dist += c.distance(from: p) + } + + // Accumulated elevation gain + var elGain: Double = 0 + for i in 1...min(beforeIdx, points.count - 1) { + let delta = points[i].altitude - points[i-1].altitude + if delta > 1 { elGain += delta } + } + + return InterpolatedData( + coordinate: CLLocationCoordinate2D(latitude: lat, longitude: lon), + altitude: alt, + speed: max(0, speed), + distance: dist / 1000, // km + elevationGain: elGain, + elapsed: timeOffset, + heading: calculateHeading(from: before.coordinate, to: after.coordinate) + ) + } + + private func calculateHeading(from: CLLocationCoordinate2D, to: CLLocationCoordinate2D) -> Double { + let dLon = (to.longitude - from.longitude) * .pi / 180 + let lat1 = from.latitude * .pi / 180 + let lat2 = to.latitude * .pi / 180 + let y = sin(dLon) * cos(lat2) + let x = cos(lat1) * sin(lat2) - sin(lat1) * cos(lat2) * cos(dLon) + var heading = atan2(y, x) * 180 / .pi + if heading < 0 { heading += 360 } + return heading + } + } + + struct InterpolatedData { + let coordinate: CLLocationCoordinate2D + let altitude: Double // meters + let speed: Double // km/h + let distance: Double // km + let elevationGain: Double // meters + let elapsed: TimeInterval // seconds + let heading: Double // degrees + } + + // MARK: - Parsing + + private var currentElement = "" + private var currentTrackName: String? + private var trackPoints: [GPXTrackPoint] = [] + + // Current point being parsed + private var currentLat: Double = 0 + private var currentLon: Double = 0 + private var currentAlt: Double? + private var currentTime: Date? + private var currentSpeed: Double? + private var currentHR: Int? + private var currentCadence: Int? + private var currentPower: Int? + private var characterBuffer = "" + + private let dateFormatter: ISO8601DateFormatter = { + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return f + }() + + private let dateFormatterAlt: ISO8601DateFormatter = { + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime] + return f + }() + + /// Parse a GPX file from URL + func parse(url: URL) -> GPXTrack? { + guard let parser = XMLParser(contentsOf: url) else { return nil } + + trackPoints = [] + currentTrackName = nil + parser.delegate = self + + guard parser.parse() else { return nil } + + return GPXTrack(name: currentTrackName, points: trackPoints) + } + + /// Parse GPX from Data + func parse(data: Data) -> GPXTrack? { + let parser = XMLParser(data: data) + + trackPoints = [] + currentTrackName = nil + parser.delegate = self + + guard parser.parse() else { return nil } + + return GPXTrack(name: currentTrackName, points: trackPoints) + } + + // MARK: - XMLParserDelegate + + func parser(_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, + qualifiedName qName: String?, attributes attributeDict: [String: String] = [:]) { + currentElement = elementName + characterBuffer = "" + + if elementName == "trkpt" || elementName == "rtept" { + currentLat = Double(attributeDict["lat"] ?? "0") ?? 0 + currentLon = Double(attributeDict["lon"] ?? "0") ?? 0 + currentAlt = nil + currentTime = nil + currentSpeed = nil + currentHR = nil + currentCadence = nil + currentPower = nil + } + } + + func parser(_ parser: XMLParser, foundCharacters string: String) { + characterBuffer += string + } + + func parser(_ parser: XMLParser, didEndElement elementName: String, namespaceURI: String?, + qualifiedName qName: String?) { + let value = characterBuffer.trimmingCharacters(in: .whitespacesAndNewlines) + + switch elementName { + case "name": + if currentTrackName == nil { currentTrackName = value } + + case "ele": + currentAlt = Double(value) + + case "time": + currentTime = dateFormatter.date(from: value) ?? dateFormatterAlt.date(from: value) + + case "speed": + currentSpeed = Double(value) + + case "hr", "gpxtpx:hr": + currentHR = Int(value) + + case "cad", "gpxtpx:cad": + currentCadence = Int(value) + + case "power", "gpxtpx:power": + currentPower = Int(value) + + case "trkpt", "rtept": + if let time = currentTime { + let point = GPXTrackPoint( + coordinate: CLLocationCoordinate2D(latitude: currentLat, longitude: currentLon), + altitude: currentAlt ?? 0, + timestamp: time, + speed: currentSpeed, + heartRate: currentHR, + cadence: currentCadence, + power: currentPower + ) + trackPoints.append(point) + } + + default: + break + } + + currentElement = "" + } +} diff --git a/KINETIC/KINETIC/Services/Overlay/OverlayRenderer.swift b/KINETIC/KINETIC/Services/Overlay/OverlayRenderer.swift new file mode 100644 index 0000000..533aa51 --- /dev/null +++ b/KINETIC/KINETIC/Services/Overlay/OverlayRenderer.swift @@ -0,0 +1,228 @@ +import UIKit +import CoreGraphics + +/// Renders telemetry data as an overlay image to composite onto video frames. +final class OverlayRenderer { + + // MARK: - Configuration + + struct OverlayConfig { + var showSpeed: Bool = true + var showDistance: Bool = true + var showTime: Bool = true + var showAltitude: Bool = true + var showMaxSpeed: Bool = false + var showElevationGain: Bool = false + var showHeading: Bool = false + var useMetric: Bool = true // km/h vs mph + var position: OverlayPosition = .bottomLeft + var style: OverlayStyle = .modern + var opacity: CGFloat = 0.9 + var scale: CGFloat = 1.0 + } + + enum OverlayPosition { + case topLeft, topRight, bottomLeft, bottomRight, center + } + + enum OverlayStyle { + case modern // Clean, rounded cards + case minimal // Just text, no background + case racing // Bold, high contrast + case classic // Retro speedometer feel + } + + // MARK: - Render + + /// Render an overlay image for the given telemetry data at a specific video frame size. + static func renderOverlay( + data: GPXParser.InterpolatedData, + videoSize: CGSize, + config: OverlayConfig + ) -> UIImage? { + + let renderer = UIGraphicsImageRenderer(size: videoSize) + + return renderer.image { context in + let ctx = context.cgContext + + // Calculate overlay area + let padding: CGFloat = 20 * config.scale + let cardWidth: CGFloat = 200 * config.scale + let lineHeight: CGFloat = 32 * config.scale + + var items: [(label: String, value: String, unit: String)] = [] + + if config.showSpeed { + let speed = config.useMetric ? data.speed : data.speed * 0.621371 + let unit = config.useMetric ? "km/h" : "mph" + items.append(("SPEED", String(format: "%.0f", speed), unit)) + } + + if config.showDistance { + let dist = config.useMetric ? data.distance : data.distance * 0.621371 + let unit = config.useMetric ? (data.distance < 1 ? "m" : "km") : "mi" + let value = data.distance < 1 ? String(format: "%.0f", dist * 1000) : String(format: "%.2f", dist) + items.append(("DIST", value, unit)) + } + + if config.showTime { + let h = Int(data.elapsed) / 3600 + let m = (Int(data.elapsed) % 3600) / 60 + let s = Int(data.elapsed) % 60 + let time = h > 0 ? String(format: "%d:%02d:%02d", h, m, s) : String(format: "%02d:%02d", m, s) + items.append(("TIME", time, "")) + } + + if config.showAltitude { + let alt = config.useMetric ? data.altitude : data.altitude * 3.281 + let unit = config.useMetric ? "m" : "ft" + items.append(("ALT", String(format: "%.0f", alt), unit)) + } + + if config.showElevationGain { + let gain = config.useMetric ? data.elevationGain : data.elevationGain * 3.281 + let unit = config.useMetric ? "m" : "ft" + items.append(("ELEV ↑", String(format: "%.0f", gain), unit)) + } + + guard !items.isEmpty else { return } + + let totalHeight = CGFloat(items.count) * lineHeight + padding * 2 + + // Position + var originX: CGFloat = padding + var originY: CGFloat = videoSize.height - totalHeight - padding + + switch config.position { + case .topLeft: + originX = padding + originY = padding + 60 * config.scale // Below safe area + case .topRight: + originX = videoSize.width - cardWidth - padding + originY = padding + 60 * config.scale + case .bottomLeft: + originX = padding + originY = videoSize.height - totalHeight - padding + case .bottomRight: + originX = videoSize.width - cardWidth - padding + originY = videoSize.height - totalHeight - padding + case .center: + originX = (videoSize.width - cardWidth) / 2 + originY = (videoSize.height - totalHeight) / 2 + } + + switch config.style { + case .modern: + renderModern(ctx: ctx, items: items, origin: CGPoint(x: originX, y: originY), + cardWidth: cardWidth, lineHeight: lineHeight, padding: padding, config: config) + case .minimal: + renderMinimal(ctx: ctx, items: items, origin: CGPoint(x: originX, y: originY), + lineHeight: lineHeight, config: config) + case .racing: + renderRacing(ctx: ctx, items: items, origin: CGPoint(x: originX, y: originY), + cardWidth: cardWidth, lineHeight: lineHeight, padding: padding, config: config) + case .classic: + renderModern(ctx: ctx, items: items, origin: CGPoint(x: originX, y: originY), + cardWidth: cardWidth, lineHeight: lineHeight, padding: padding, config: config) + } + } + } + + // MARK: - Style Renderers + + private static func renderModern( + ctx: CGContext, items: [(label: String, value: String, unit: String)], + origin: CGPoint, cardWidth: CGFloat, lineHeight: CGFloat, padding: CGFloat, config: OverlayConfig + ) { + let totalHeight = CGFloat(items.count) * lineHeight + padding * 2 + let rect = CGRect(x: origin.x, y: origin.y, width: cardWidth, height: totalHeight) + + // Background card with rounded corners + let path = UIBezierPath(roundedRect: rect, cornerRadius: 12 * config.scale) + UIColor.black.withAlphaComponent(0.6 * config.opacity).setFill() + path.fill() + + // Items + for (index, item) in items.enumerated() { + let y = origin.y + padding + CGFloat(index) * lineHeight + + // Label + let labelAttrs: [NSAttributedString.Key: Any] = [ + .font: UIFont.systemFont(ofSize: 10 * config.scale, weight: .medium), + .foregroundColor: UIColor.white.withAlphaComponent(0.6) + ] + let labelStr = NSString(string: item.label) + labelStr.draw(at: CGPoint(x: origin.x + padding, y: y), withAttributes: labelAttrs) + + // Value + let valueAttrs: [NSAttributedString.Key: Any] = [ + .font: UIFont.monospacedDigitSystemFont(ofSize: 20 * config.scale, weight: .bold), + .foregroundColor: UIColor.white + ] + let valueStr = NSString(string: item.value) + let valueSize = valueStr.size(withAttributes: valueAttrs) + valueStr.draw(at: CGPoint(x: origin.x + cardWidth - padding - valueSize.width - 30 * config.scale, y: y - 2), withAttributes: valueAttrs) + + // Unit + if !item.unit.isEmpty { + let unitAttrs: [NSAttributedString.Key: Any] = [ + .font: UIFont.systemFont(ofSize: 10 * config.scale, weight: .regular), + .foregroundColor: UIColor.white.withAlphaComponent(0.5) + ] + let unitStr = NSString(string: item.unit) + unitStr.draw(at: CGPoint(x: origin.x + cardWidth - padding - 25 * config.scale, y: y + 4), withAttributes: unitAttrs) + } + } + } + + private static func renderMinimal( + ctx: CGContext, items: [(label: String, value: String, unit: String)], + origin: CGPoint, lineHeight: CGFloat, config: OverlayConfig + ) { + for (index, item) in items.enumerated() { + let y = origin.y + CGFloat(index) * lineHeight + + let text = item.unit.isEmpty ? item.value : "\(item.value) \(item.unit)" + let attrs: [NSAttributedString.Key: Any] = [ + .font: UIFont.monospacedDigitSystemFont(ofSize: 18 * config.scale, weight: .bold), + .foregroundColor: UIColor.white, + .strokeColor: UIColor.black, + .strokeWidth: -3.0 // Negative = fill + stroke + ] + let str = NSString(string: text) + str.draw(at: CGPoint(x: origin.x, y: y), withAttributes: attrs) + } + } + + private static func renderRacing( + ctx: CGContext, items: [(label: String, value: String, unit: String)], + origin: CGPoint, cardWidth: CGFloat, lineHeight: CGFloat, padding: CGFloat, config: OverlayConfig + ) { + let totalHeight = CGFloat(items.count) * lineHeight + padding * 2 + let rect = CGRect(x: origin.x, y: origin.y, width: cardWidth, height: totalHeight) + + // Red accent background + let path = UIBezierPath(roundedRect: rect, cornerRadius: 4 * config.scale) + UIColor.black.withAlphaComponent(0.8 * config.opacity).setFill() + path.fill() + + // Red left border + let borderRect = CGRect(x: origin.x, y: origin.y, width: 4 * config.scale, height: totalHeight) + let borderPath = UIBezierPath(rect: borderRect) + UIColor(red: 1, green: 0.2, blue: 0.1, alpha: 1).setFill() + borderPath.fill() + + for (index, item) in items.enumerated() { + let y = origin.y + padding + CGFloat(index) * lineHeight + + let valueAttrs: [NSAttributedString.Key: Any] = [ + .font: UIFont.monospacedDigitSystemFont(ofSize: 22 * config.scale, weight: .black), + .foregroundColor: UIColor.white + ] + let text = item.unit.isEmpty ? "\(item.label) \(item.value)" : "\(item.value) \(item.unit)" + let str = NSString(string: text) + str.draw(at: CGPoint(x: origin.x + padding + 4 * config.scale, y: y - 2), withAttributes: valueAttrs) + } + } +} diff --git a/KINETIC/KINETIC/Services/VideoProcessor/VideoExporter.swift b/KINETIC/KINETIC/Services/VideoProcessor/VideoExporter.swift new file mode 100644 index 0000000..36879f4 --- /dev/null +++ b/KINETIC/KINETIC/Services/VideoProcessor/VideoExporter.swift @@ -0,0 +1,273 @@ +import AVFoundation +import UIKit +import CoreImage + +/// Exports a video with telemetry overlay burned in, synced from a GPX track. +final class VideoExporter { + + // MARK: - Types + + enum ExportError: Error, LocalizedError { + case invalidVideoURL + case noVideoTrack + case noGPXData + case exportFailed(String) + case cancelled + + var errorDescription: String? { + switch self { + case .invalidVideoURL: return "Invalid video URL" + case .noVideoTrack: return "No video track found" + case .noGPXData: return "No GPX data loaded" + case .exportFailed(let msg): return "Export failed: \(msg)" + case .cancelled: return "Export cancelled" + } + } + } + + struct ExportConfig { + var videoURL: URL + var gpxTrack: GPXParser.GPXTrack + var overlayConfig: OverlayRenderer.OverlayConfig + var timeOffset: TimeInterval = 0 // Offset between video start and GPX start (seconds) + var outputQuality: OutputQuality = .high + + enum OutputQuality { + case medium // 720p + case high // 1080p + case original // Same as input + } + } + + // MARK: - Progress + + var progress: Float = 0 + var isExporting: Bool = false + + private var exportSession: AVAssetExportSession? + + // MARK: - Export + + /// Export video with overlay. Returns URL to the exported file. + func export(config: ExportConfig) async throws -> URL { + isExporting = true + progress = 0 + + defer { isExporting = false } + + let asset = AVAsset(url: config.videoURL) + + // Get video track + guard let videoTrack = try await asset.loadTracks(withMediaType: .video).first else { + throw ExportError.noVideoTrack + } + + let naturalSize = try await videoTrack.load(.naturalSize) + let transform = try await videoTrack.load(.preferredTransform) + let duration = try await asset.load(.duration) + + // Determine actual video size (accounting for transform/rotation) + let videoSize = applyTransform(naturalSize, transform: transform) + + // Create composition + let composition = AVMutableComposition() + + // Add video track + guard let compositionVideoTrack = composition.addMutableTrack( + withMediaType: .video, + preferredTrackID: kCMPersistentTrackID_Invalid + ) else { throw ExportError.exportFailed("Could not create video track") } + + try compositionVideoTrack.insertTimeRange( + CMTimeRange(start: .zero, duration: duration), + of: videoTrack, + at: .zero + ) + compositionVideoTrack.preferredTransform = transform + + // Add audio track if present + if let audioTrack = try await asset.loadTracks(withMediaType: .audio).first { + if let compositionAudioTrack = composition.addMutableTrack( + withMediaType: .audio, + preferredTrackID: kCMPersistentTrackID_Invalid + ) { + try compositionAudioTrack.insertTimeRange( + CMTimeRange(start: .zero, duration: duration), + of: audioTrack, + at: .zero + ) + } + } + + // Create video composition with overlay + let videoComposition = AVMutableVideoComposition( + propertiesOf: composition + ) + videoComposition.renderSize = videoSize + videoComposition.frameDuration = CMTime(value: 1, timescale: 30) // 30fps + + // Custom compositor for overlay + videoComposition.customVideoCompositorClass = OverlayCompositor.self + + // Store overlay config in a shared place the compositor can access + OverlayCompositor.shared.gpxTrack = config.gpxTrack + OverlayCompositor.shared.overlayConfig = config.overlayConfig + OverlayCompositor.shared.timeOffset = config.timeOffset + OverlayCompositor.shared.videoSize = videoSize + + // Create instruction + let instruction = AVMutableVideoCompositionInstruction() + instruction.timeRange = CMTimeRange(start: .zero, duration: duration) + + let layerInstruction = AVMutableVideoCompositionLayerInstruction(assetTrack: compositionVideoTrack) + instruction.layerInstructions = [layerInstruction] + videoComposition.instructions = [instruction] + + // Output URL + let outputURL = FileManager.default.temporaryDirectory + .appendingPathComponent("kinetic_\(UUID().uuidString).mp4") + + // Remove existing file + try? FileManager.default.removeItem(at: outputURL) + + // Export + guard let exportSession = AVAssetExportSession( + asset: composition, + presetName: exportPreset(for: config.outputQuality, videoSize: videoSize) + ) else { throw ExportError.exportFailed("Could not create export session") } + + self.exportSession = exportSession + exportSession.outputURL = outputURL + exportSession.outputFileType = .mp4 + exportSession.videoComposition = videoComposition + exportSession.shouldOptimizeForNetworkUse = true + + // Monitor progress + let progressTask = Task { + while !Task.isCancelled && exportSession.status == .exporting { + progress = exportSession.progress + try await Task.sleep(nanoseconds: 100_000_000) // 0.1s + } + } + + await exportSession.export() + progressTask.cancel() + progress = 1.0 + + switch exportSession.status { + case .completed: + return outputURL + case .cancelled: + throw ExportError.cancelled + default: + throw ExportError.exportFailed(exportSession.error?.localizedDescription ?? "Unknown error") + } + } + + /// Cancel ongoing export + func cancel() { + exportSession?.cancelExport() + } + + // MARK: - Helpers + + private func applyTransform(_ size: CGSize, transform: CGAffineTransform) -> CGSize { + let rect = CGRect(origin: .zero, size: size).applying(transform) + return CGSize(width: abs(rect.width), height: abs(rect.height)) + } + + private func exportPreset(for quality: ExportConfig.OutputQuality, videoSize: CGSize) -> String { + switch quality { + case .medium: return AVAssetExportPreset1280x720 + case .high: return AVAssetExportPreset1920x1080 + case .original: return AVAssetExportPresetHighestQuality + } + } +} + +// MARK: - Overlay Compositor + +/// Custom video compositor that renders telemetry overlay on each frame. +final class OverlayCompositor: NSObject, AVVideoCompositing { + + static let shared = OverlayCompositor() + + var gpxTrack: GPXParser.GPXTrack? + var overlayConfig = OverlayRenderer.OverlayConfig() + var timeOffset: TimeInterval = 0 + var videoSize: CGSize = .zero + + // MARK: - AVVideoCompositing + + var sourcePixelBufferAttributes: [String: Any]? { + [kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA] + } + + var requiredPixelBufferAttributesForRenderContext: [String: Any] { + [kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA] + } + + func renderContextChanged(_ newRenderContext: AVVideoCompositionRenderContext) {} + + func startRequest(_ asyncVideoCompositionRequest: AVAsynchronousVideoCompositionRequest) { + guard let sourceBuffer = asyncVideoCompositionRequest.sourceFrame( + byTrackID: asyncVideoCompositionRequest.sourceTrackIDs.first?.int32Value ?? 0 + ) else { + asyncVideoCompositionRequest.finish(with: NSError(domain: "OverlayCompositor", code: -1)) + return + } + + let time = CMTimeGetSeconds(asyncVideoCompositionRequest.compositionTime) + + // Get interpolated GPX data for this frame + let gpxTime = time + timeOffset + + if let track = gpxTrack, let data = track.dataAt(timeOffset: gpxTime) { + // Render overlay + if let overlayImage = OverlayRenderer.renderOverlay( + data: data, + videoSize: videoSize, + config: overlayConfig + ) { + // Composite overlay onto video frame + if let outputBuffer = compositeOverlay(overlayImage, onto: sourceBuffer) { + asyncVideoCompositionRequest.finish(withComposedVideoFrame: outputBuffer) + return + } + } + } + + // If no overlay needed, pass through original frame + asyncVideoCompositionRequest.finish(withComposedVideoFrame: sourceBuffer) + } + + func cancelAllPendingVideoCompositionRequests() {} + + // MARK: - Compositing + + private func compositeOverlay(_ overlay: UIImage, onto pixelBuffer: CVPixelBuffer) -> CVPixelBuffer? { + let ciImage = CIImage(cvPixelBuffer: pixelBuffer) + guard let overlayCIImage = CIImage(image: overlay) else { return pixelBuffer } + + let composited = overlayCIImage.composited(over: ciImage) + + let context = CIContext() + var outputBuffer: CVPixelBuffer? + + let width = CVPixelBufferGetWidth(pixelBuffer) + let height = CVPixelBufferGetHeight(pixelBuffer) + + CVPixelBufferCreate( + kCFAllocatorDefault, + width, height, + kCVPixelFormatType_32BGRA, + nil, + &outputBuffer + ) + + guard let output = outputBuffer else { return pixelBuffer } + context.render(composited, to: output) + + return output + } +}