import Foundation /// A curated transcription model (mirrors `fcpxml/models.json`). struct ModelInfo: Identifiable { let displayName: String let internalName: String let language: String let storage: String let accuracy: Int let speed: Int var isDownloaded: Bool var isSelected: Bool var isRecommended: Bool var id: String { internalName } } /// Full catalog snapshot returned by the `catalog` command. struct Catalog { var models: [ModelInfo] var selected: String var language: String var modelsDir: String var installedCount: Int var diarization: Bool var diarizationMessage: String var hfTokenSet: Bool var numSpeakers: String init(json: [String: Any]) { let recommended = Set((json["recommended"] as? [String] ?? [])) let installed = Set((json["installed"] as? [String] ?? [])) let selected = json["selected"] as? String ?? "" let rawModels = json["models"] as? [[String: Any]] ?? [] var parsed: [ModelInfo] = [] for m in rawModels { let name = m["internal_name"] as? String ?? "" parsed.append(ModelInfo( displayName: m["display_name"] as? String ?? name, internalName: name, language: m["language"] as? String ?? "", storage: m["storage"] as? String ?? "", accuracy: m["accuracy"] as? Int ?? 0, speed: m["speed"] as? Int ?? 0, isDownloaded: installed.contains(name), isSelected: name == selected, isRecommended: recommended.contains(name) )) } self.models = parsed self.selected = selected self.language = json["language"] as? String ?? "auto" self.modelsDir = json["models_dir"] as? String ?? "" self.installedCount = json["installed_count"] as? Int ?? 0 self.diarization = json["diarization"] as? Bool ?? false self.diarizationMessage = json["diarization_message"] as? String ?? "" self.hfTokenSet = json["hf_token_set"] as? Bool ?? false self.numSpeakers = json["num_speakers"] as? String ?? "" } /// Models sorted: recommended first, then by download state, then name. var orderedModels: [ModelInfo] { models.sorted { if $0.isDownloaded != $1.isDownloaded { return $0.isDownloaded } if $0.isRecommended != $1.isRecommended { return $0.isRecommended } return $0.displayName < $1.displayName } } } /// A single transcript result from the `transcribe` command. struct TranscriptResult { let media: String let language: String let words: Int let duration: Double let preview: String let saved: String let speakers: [String] init(json: [String: Any]) { media = json["media"] as? String ?? "" language = json["language"] as? String ?? "?" words = json["words"] as? Int ?? 0 duration = json["duration"] as? Double ?? 0 preview = json["preview"] as? String ?? "" saved = json["saved"] as? String ?? "" speakers = json["speakers"] as? [String] ?? [] } } struct ZoomClip: Identifiable { let id: String let index: Int let name: String let start: Double let duration: Double let media: String let preview: String let hasTranscript: Bool init(json: [String: Any]) { id = json["id"] as? String ?? "" index = json["index"] as? Int ?? 0 name = json["name"] as? String ?? "Clipe" start = json["start"] as? Double ?? 0 duration = json["duration"] as? Double ?? 0 media = json["media"] as? String ?? "" preview = json["preview"] as? String ?? "" hasTranscript = json["has_transcript"] as? Bool ?? false } var label: String { let time = String(format: "%02d:%02d", Int(start) / 60, Int(start) % 60) return "Clipe \(index + 1) · \(name) · \(time)" } } /// One word inside a phrase, with the acoustics that justify an emphasis. struct ReviewWord: Identifiable { let id: Int let text: String let start: Double let end: Double let energy: Double let emphasis: Double init(id: Int, json: [String: Any]) { self.id = id text = json["text"] as? String ?? "" start = json["start"] as? Double ?? 0 end = json["end"] as? Double ?? 0 energy = json["energy"] as? Double ?? 0 emphasis = json["emphasis"] as? Double ?? 0 } } /// A phrase in the review step — one spoken line plus the decision made about /// it. Mirrors `fcpxml/phrase_review.py`; `emphasis` is 0–3 and everything /// mutable here is what the editor is allowed to change. struct ReviewPhrase: Identifiable { let id: Int let start: Double let end: Double var trimStart: Double var trimEnd: Double var text: String let speaker: String var active: Bool var emphasis: Int var track: String let peakEmphasis: Double let emotion: String let emotionConfidence: Double let takeBoundary: Bool let gapBefore: Double let reason: String let words: [ReviewWord] static let trackScript = "roteiro" static let trackBackstage = "bastidor" /// Delivery emotion as the analysis names it, in the user's language plus a /// glyph — the label alone is too easy to skim past in a dense list. static func emotionLabel(_ emotion: String) -> (String, String) { switch emotion { case "excited": return ("Empolgado", "flame") case "tense": return ("Tenso", "bolt") case "calm": return ("Calmo", "leaf") case "reflective": return ("Reflexivo", "moon") default: return ("Neutro", "circle") } } init(json: [String: Any]) { id = json["index"] as? Int ?? 0 start = json["start"] as? Double ?? 0 end = json["end"] as? Double ?? 0 trimStart = json["trim_start"] as? Double ?? (json["start"] as? Double ?? 0) trimEnd = json["trim_end"] as? Double ?? (json["end"] as? Double ?? 0) text = json["text"] as? String ?? "" speaker = json["speaker"] as? String ?? "" active = json["active"] as? Bool ?? true emphasis = json["emphasis"] as? Int ?? 0 track = json["track"] as? String ?? ReviewPhrase.trackScript peakEmphasis = json["peak_emphasis"] as? Double ?? 0 emotion = json["emotion"] as? String ?? "neutral" emotionConfidence = json["emotion_confidence"] as? Double ?? 0 takeBoundary = json["take_boundary"] as? Bool ?? false gapBefore = json["gap_before"] as? Double ?? 0 reason = json["reason"] as? String ?? "" words = (json["words"] as? [[String: Any]] ?? []) .enumerated().map { ReviewWord(id: $0.offset, json: $0.element) } } var asJSON: [String: Any] { [ "index": id, "start": start, "end": end, "trim_start": trimStart, "trim_end": trimEnd, "text": text, "speaker": speaker, "active": active, "emphasis": emphasis, "track": track, "reason": reason, ] } var isBackstage: Bool { track == ReviewPhrase.trackBackstage } var isTrimmed: Bool { trimStart > start + 0.001 || trimEnd < end - 0.001 } var timecode: String { String(format: "%02d:%02d", Int(start) / 60, Int(start) % 60) } /// The word boundaries a trim handle is allowed to land on. func snap(_ time: Double, edge: TrimEdge) -> Double { let boundaries = words.map { edge == .start ? $0.start : $0.end }.filter { $0 > 0 } guard let nearest = boundaries.min(by: { abs($0 - time) < abs($1 - time) }) else { return time } return nearest } } enum TrimEdge { case start, end } /// A punch-in the editor placed by hand over an arbitrary range, next to the /// whole-phrase zoom that an emphasis level produces. It stores only *when* — /// the scale and the ramp come from the Voice Analysis settings at render time. struct ManualZoom: Identifiable { let id = UUID() var start: Double var end: Double /// Below this a punch-in has no room to ramp in and back out; the writer /// rejects the window, so offering it would place nothing. static let minimumDuration: Double = 0.4 init(start: Double, end: Double) { self.start = start self.end = end } init?(json: [String: Any]) { guard let start = json["start"] as? Double, let end = json["end"] as? Double, end - start >= ManualZoom.minimumDuration else { return nil } self.start = start self.end = end } var asJSON: [String: Any] { ["start": start, "end": end] } } struct ZoomSegment: Identifiable { let id: Int let start: Double let end: Double let text: String init(json: [String: Any]) { id = json["id"] as? Int ?? 0 start = json["start"] as? Double ?? 0 end = json["end"] as? Double ?? 0 text = json["text"] as? String ?? "" } var label: String { String(format: "%06.2f–%06.2f %@", start, end, text) } }