Skip to content
RPRichard PruittTech & Cyber
Navigation
repo://simon-says-duo/MyApp/LocalRepository.swift

MyApp/LocalRepository.swift

5.7 KB · 103 lines · swift · Download from the repository page

MyApp/LocalRepository.swift
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)    }}