MyApp/VisualCueExport.swift
2.9 KB · 53 lines · swift · Download from the repository page
import AVFoundationimport UIKitimport CoreImage @MainActorenum VisualCueExport { static func export(raw: URL, cue: VisualCue, timing: CueTiming, to destination: URL) async throws { let asset = AVURLAsset(url: raw) let duration = try await asset.load(.duration) guard timing.start.isFinite, timing.duration.isFinite, timing.start >= 0, timing.duration > 0, timing.start + timing.duration <= duration.seconds, !(try await asset.loadTracks(withMediaType: .video)).isEmpty else { throw PartyError.message("Visual cue timing or video is unavailable.") } // Rasterize only the neutral badge once; AVFoundation provides upright source frames. let format = UIGraphicsImageRendererFormat() format.scale = 1 let renderer = UIGraphicsImageRenderer(size: CGSize(width: 512, height: 512), format: format) let badge = renderer.image { _ in UIColor.black.withAlphaComponent(0.85).setFill() UIBezierPath(roundedRect: CGRect(x: 0, y: 0, width: 512, height: 512), cornerRadius: 76).fill() let attributes: [NSAttributedString.Key: Any] = [ .font: UIFont.systemFont(ofSize: 384, weight: .black), .foregroundColor: UIColor.white ] let text = cue.letter as NSString let size = text.size(withAttributes: attributes) text.draw(at: CGPoint(x: (512 - size.width) / 2, y: (512 - size.height) / 2), withAttributes: attributes) } guard let cgImage = badge.cgImage else { throw PartyError.message("Could not render visual cue.") } let overlay = CIImage(cgImage: cgImage) let start = timing.start let end = timing.start + timing.duration let composition = try await AVVideoComposition(applyingFiltersTo: asset) { parameters in let source = parameters.sourceImage let time = parameters.compositionTime.seconds guard time >= start && time < end else { return AVCIImageFilteringResult(resultImage: source, ciContext: nil) } let bounds = source.extent let side = min(bounds.width, bounds.height) * 0.44 let scaled = overlay.transformed(by: CGAffineTransform(scaleX: side / 512, y: side / 512)) let positioned = scaled.transformed(by: CGAffineTransform(translationX: bounds.midX - side / 2, y: bounds.midY - side / 2)) return AVCIImageFilteringResult(resultImage: positioned.composited(over: source).cropped(to: bounds), ciContext: nil) } guard let exporter = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetHighestQuality) else { throw PartyError.message("Visual cue export is unavailable.") } exporter.videoComposition = composition // Preserve microphone audio, including the original action; never announce the letter. try await exporter.export(to: destination, as: .mp4) }}