MyApp/LocalRepository.swift
5.7 KB · 103 lines · swift · Download from the repository page
import Foundationimport AVFoundation @MainActorfinal class LocalRepository { let directory: URL init(directory: URL? = nil) throws { var url = try directory ?? FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true) .appendingPathComponent("SimonSaysParty", isDirectory: true) try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) var values = URLResourceValues() values.isExcludedFromBackup = true try url.setResourceValues(values) self.directory = url } func loadGame() throws -> SavedGame? { let file = url("game.json") guard FileManager.default.fileExists(atPath: file.path) else { return nil } return try JSONDecoder().decode(SavedGame.self, from: Data(contentsOf: file)) } func saveGame(_ game: SavedGame) throws { try JSONEncoder().encode(game).write(to: url("game.json"), options: [.atomic, .completeFileProtection]) } func removeGame() throws { let file = url("game.json") if FileManager.default.fileExists(atPath: file.path) { try FileManager.default.removeItem(at: file) } } func url(_ name: String) -> URL { directory.appendingPathComponent(name) } func load() throws -> [Attempt] { let metadata = url("attempts.json") guard FileManager.default.fileExists(atPath: metadata.path) else { return [] } return try JSONDecoder().decode([Attempt].self, from: Data(contentsOf: metadata)) } func save(_ attempts: [Attempt]) throws { try JSONEncoder().encode(attempts).write(to: url("attempts.json"), options: [.atomic, .completeFileProtection]) } func deleteFiles(_ attempt: Attempt) throws { for name in [attempt.rawFile, attempt.narrationFile, attempt.exportFile].compactMap({ $0 }) { let file = url(name) if FileManager.default.fileExists(atPath: file.path) { try FileManager.default.removeItem(at: file) } } } func cleanup(_ attempts: inout [Attempt]) throws { let cutoff = Date().addingTimeInterval(-7 * 86400) for index in attempts.indices where attempts[index].snapshot.created < cutoff { try deleteFiles(attempts[index]) attempts[index].rawFile = nil attempts[index].narrationFile = nil attempts[index].exportFile = nil } let referenced = Set(attempts.flatMap { [$0.rawFile, $0.narrationFile, $0.exportFile].compactMap { $0 } }) for file in try FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil) { // Only this application's UUID-named media are eligible for orphan cleanup. if ["mov", "caf", "mp4"].contains(file.pathExtension), UUID(uuidString: String(file.deletingPathExtension().lastPathComponent.prefix(36))) != nil, !referenced.contains(file.lastPathComponent) { try FileManager.default.removeItem(at: file) } } try save(attempts) }} enum ClipExportService { static func export(raw: URL, narration: URL, offset: Double, to destination: URL) async throws { let source = AVURLAsset(url: raw) let voice = AVURLAsset(url: narration) let composition = AVMutableComposition() let duration = try await source.load(.duration) guard let sourceVideo = try await source.loadTracks(withMediaType: .video).first, let video = composition.addMutableTrack(withMediaType: .video, preferredTrackID: kCMPersistentTrackID_Invalid), let sourceAudio = try await source.loadTracks(withMediaType: .audio).first, let room = composition.addMutableTrack(withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid), let voiceAudio = try await voice.loadTracks(withMediaType: .audio).first, let clean = composition.addMutableTrack(withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid) else { throw PartyError.message("Clip is missing video, microphone audio or narration.") } let full = CMTimeRange(start: .zero, duration: duration) try video.insertTimeRange(full, of: sourceVideo, at: .zero) video.preferredTransform = try await sourceVideo.load(.preferredTransform) try room.insertTimeRange(full, of: sourceAudio, at: .zero) let start = CMTime(seconds: offset, preferredTimescale: 600) let voiceDuration = try await voice.load(.duration) try clean.insertTimeRange(CMTimeRange(start: .zero, duration: min(voiceDuration, duration - start)), of: voiceAudio, at: start) let mix = AVMutableAudioMix() let roomParameters = AVMutableAudioMixInputParameters(track: room) let fade = CMTime(seconds: 0.08, preferredTimescale: 600) // Reduce microphone echo during narration; the unmodified original remains available. roomParameters.setVolume(1, at: .zero) roomParameters.setVolumeRamp(fromStartVolume: 1, toEndVolume: 0.12, timeRange: CMTimeRange(start: max(.zero, start - fade), duration: fade)) roomParameters.setVolume(0.12, at: start) roomParameters.setVolumeRamp(fromStartVolume: 0.12, toEndVolume: 1, timeRange: CMTimeRange(start: start + voiceDuration, duration: fade)) mix.inputParameters = [roomParameters] guard let exporter = AVAssetExportSession(asset: composition, presetName: AVAssetExportPresetHighestQuality) else { throw PartyError.message("MP4 export unavailable.") } exporter.audioMix = mix try await exporter.export(to: destination, as: .mp4) }}