import AVFoundation import Combine import Foundation /// State behind the wizard's emphasis-review step. /// /// Holds the phrases, the selection, and the player — together, because they /// are one thing to the user: clicking a phrase moves the playhead, playing /// moves the selection, and skipping a removed line only works if whoever owns /// playback also knows which lines are removed. /// /// The preview deliberately plays the *original* media and jumps over whatever /// the edit removes, instead of rendering a cut first. Rendering to check a /// toggle would put minutes between a decision and its result; jumping gives /// the same reading instantly, and the real cut is generated later from the /// exact same phrase list. @MainActor final class PhraseReviewModel: ObservableObject { @Published var phrases: [ReviewPhrase] = [] @Published var selection: Int? @Published var isLoading = false @Published var errorMessage: String? @Published var currentTime: Double = 0 @Published var isPlaying = false @Published var pixelsPerSecond: Double = 40 @Published var skipRemoved = true @Published var zooms: [ManualZoom] = [] /// In/out the editor dragged on the timeline, in source seconds. @Published var rangeStart: Double? @Published var rangeEnd: Double? private(set) var source = "" private(set) var sourcePath = "" private(set) var duration: Double = 0 private(set) var speakers: [String] = [] private(set) var emotionAvailable = false private(set) var player: AVPlayer? private var voiceTimelinePath = "" private var timeObserver: Any? private var playbackLimit: Double? let minPixelsPerSecond: Double = 8 let maxPixelsPerSecond: Double = 400 deinit { if let timeObserver, let player { player.removeTimeObserver(timeObserver) } } // MARK: - Carregar /// Builds the review from the voice timeline plus whatever the AI decided. /// A review saved on a previous visit wins — see `cmd_build_phrase_review` — /// UNLESS `fresh` is true, in which case that saved review is ignored and /// `active`/`emphasis`/etc. come straight from this call's `decisionsJSON`. /// Pass `fresh: true` when the decisions themselves changed since the /// review was last built (the caller re-pasted/regenerated the AI's JSON /// and re-ran `apply_voice_actions`) — otherwise the saved review from the /// PREVIOUS decisions silently wins over the fresh cut it should reflect, /// which is exactly the desync the wizard's "active" toggle showed against /// the just-reapplied FCPXML. func load(voiceTimelinePath: String, decisionsJSON: String, outputFolder: String? = nil, mediaFolder: String? = nil, fresh: Bool = false) { self.voiceTimelinePath = voiceTimelinePath isLoading = true errorMessage = nil var arguments: [String: Any] = ["voice_timeline": voiceTimelinePath] if let outputFolder { arguments["output_dir"] = outputFolder } if let mediaFolder { arguments["media_dir"] = mediaFolder } if fresh { arguments["fresh"] = true } if let data = decisionsJSON.data(using: .utf8), let parsed = try? JSONSerialization.jsonObject(with: data) { arguments["actions"] = parsed } PythonBridge.call(command: "build_phrase_review", arguments: arguments) { [weak self] result, error in Task { @MainActor in guard let self else { return } self.isLoading = false if let error { self.errorMessage = error return } guard let result, result["ok"] as? Bool == true else { self.errorMessage = result?["error"] as? String ?? "Não foi possível montar a revisão." return } self.apply(result) } } } private func apply(_ result: [String: Any]) { source = result["source"] as? String ?? "" // The timeline JSON stores only the media's file name; the bridge // resolves it to something openable (see phrase_review.resolve_source). sourcePath = result["source_path"] as? String ?? "" duration = result["duration"] as? Double ?? 0 speakers = result["speakers"] as? [String] ?? [] emotionAvailable = result["emotion_available"] as? Bool ?? false phrases = (result["phrases"] as? [[String: Any]] ?? []).map { ReviewPhrase(json: $0) } zooms = (result["zooms"] as? [[String: Any]] ?? []).compactMap { ManualZoom(json: $0) } selection = phrases.first?.id if let errors = result["errors"] as? [String], !errors.isEmpty { errorMessage = "A IA mandou \(errors.count) decisão(ões) que não deu para ler — o resto foi aplicado." } preparePlayer() } /// Point the preview at a media file the user chose by hand — the way out /// when the footage moved somewhere the automatic lookup can't reach. func useMedia(at path: String) { sourcePath = path preparePlayer() } private func preparePlayer() { guard !sourcePath.isEmpty, FileManager.default.fileExists(atPath: sourcePath) else { player = nil return } if let timeObserver, let player { player.removeTimeObserver(timeObserver) self.timeObserver = nil } let asset = AVURLAsset(url: URL(fileURLWithPath: sourcePath)) let player = AVPlayer(playerItem: AVPlayerItem(asset: asset)) self.player = player // 60 Hz: the same observer drives the playhead *and* decides when to // jump a removed stretch, so its period is the worst-case amount of cut // material that can be heard before the skip lands. At 20 Hz that was an // audible blip on every join. let interval = CMTime(seconds: 1.0 / 60.0, preferredTimescale: 600) timeObserver = player.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] time in Task { @MainActor in self?.tick(time.seconds) } } } // MARK: - Reprodução private func tick(_ time: Double) { currentTime = time guard isPlaying else { return } // Playing a single phrase or a marked range stops at its out point // instead of running on into the rest of the take. if let limit = playbackLimit, time >= limit { pause() seek(to: limit) return } if skipRemoved, let jump = nextKeptTime(after: time), jump > time { seek(to: jump) } if let phrase = phrase(at: time), selection != phrase.id { selection = phrase.id } } /// Where playback should resume when `time` lands on removed material. /// Returns nil when the time is on material that survives. func nextKeptTime(after time: Double) -> Double? { for phrase in phrases where time >= phrase.start - 0.001 && time < phrase.end { if !phrase.active { return phrase.end } if time < phrase.trimStart { return phrase.trimStart } if time >= phrase.trimEnd { return phrase.end } return nil } return nil } func togglePlay() { if isPlaying { pause() } else { playbackLimit = nil play() } } private func play() { guard let player else { return } if skipRemoved, let jump = nextKeptTime(after: currentTime) { seek(to: jump) } player.play() isPlaying = true } func pause() { player?.pause() isPlaying = false playbackLimit = nil } /// Play exactly one span and stop — how a cut is judged: in context, at /// speed, without hunting for the out point by hand. func playRange(from start: Double, to end: Double) { guard end > start else { return } seek(to: start) playbackLimit = end player?.play() isPlaying = true } func playSelectedPhrase() { guard let selection, let phrase = phrases.first(where: { $0.id == selection }) else { return } playRange(from: phrase.active ? phrase.trimStart : phrase.start, to: phrase.active ? phrase.trimEnd : phrase.end) } func seek(to time: Double) { currentTime = max(0, time) player?.seek(to: CMTime(seconds: max(0, time), preferredTimescale: 600), toleranceBefore: .zero, toleranceAfter: .zero) } /// Move the playhead to a phrase and select it. func goTo(phraseID: Int) { guard let phrase = phrases.first(where: { $0.id == phraseID }) else { return } selection = phraseID seek(to: phrase.active ? phrase.trimStart : phrase.start) } func phrase(at time: Double) -> ReviewPhrase? { phrases.first { time >= $0.start && time < $0.end } } func selectNeighbour(_ delta: Int) { guard let selection, let index = phrases.firstIndex(where: { $0.id == selection }) else { if let first = phrases.first { goTo(phraseID: first.id) } return } let next = min(max(0, index + delta), phrases.count - 1) goTo(phraseID: phrases[next].id) } // MARK: - Edições private func update(_ id: Int, _ change: (inout ReviewPhrase) -> Void) { guard let index = phrases.firstIndex(where: { $0.id == id }) else { return } change(&phrases[index]) } func setEmphasis(_ level: Int, for id: Int) { update(id) { $0.emphasis = min(3, max(0, level)) } } func toggleActive(_ id: Int) { update(id) { $0.active.toggle() } } func setTrack(_ track: String, for id: Int) { update(id) { $0.track = track } } func setText(_ text: String, for id: Int) { update(id) { $0.text = text } } /// Trim a phrase's head or tail, landing on a word boundary. /// A trim that would swallow the whole line is refused — deactivating the /// phrase is the way to remove it, and doing it by accident with a drag /// would lose the emphasis decision along with the line. func trim(_ id: Int, edge: TrimEdge, to time: Double) { update(id) { phrase in let snapped = phrase.snap(time, edge: edge) switch edge { case .start: let value = min(max(phrase.start, snapped), phrase.trimEnd - 0.1) if value < phrase.trimEnd { phrase.trimStart = value } case .end: let value = max(min(phrase.end, snapped), phrase.trimStart + 0.1) if value > phrase.trimStart { phrase.trimEnd = value } } } } func resetTrim(_ id: Int) { update(id) { $0.trimStart = $0.start; $0.trimEnd = $0.end } } /// Trim everything before/after a given word — the text-first way to cut, /// since the editor reads the line and points at where it should begin. /// Clicking the word that is ALREADY that edge toggles it back off — /// the trim on that side resets to the phrase's own start/end — so the /// same click that sets a boundary also clears it, instead of needing /// the separate "Inteira" button for a one-sided undo. func trimToWord(_ word: ReviewWord, edge: TrimEdge, in id: Int) { guard let phrase = phrases.first(where: { $0.id == id }) else { return } let epsilon = 0.001 switch edge { case .start where abs(word.start - phrase.trimStart) < epsilon: update(id) { $0.trimStart = $0.start } case .end where abs(word.end - phrase.trimEnd) < epsilon: update(id) { $0.trimEnd = $0.end } default: trim(id, edge: edge, to: edge == .start ? word.start : word.end) } } // MARK: - Trecho marcado e zooms var hasRange: Bool { guard let rangeStart, let rangeEnd else { return false } return rangeEnd - rangeStart >= ManualZoom.minimumDuration } var rangeSpan: (start: Double, end: Double)? { guard let rangeStart, let rangeEnd, rangeEnd > rangeStart else { return nil } return (rangeStart, rangeEnd) } func setRange(from start: Double, to end: Double) { rangeStart = min(start, end) rangeEnd = max(start, end) } func clearRange() { rangeStart = nil rangeEnd = nil } /// Add a punch-in over the marked range. Scale and ramp are not stored: /// they come from the "Análise de Voz" settings when the edit is rendered, /// so changing the look there restyles every zoom at once. func addZoomForRange() { guard let span = rangeSpan, span.end - span.start >= ManualZoom.minimumDuration else { return } zooms.append(ManualZoom(start: span.start, end: span.end)) zooms.sort { $0.start < $1.start } clearRange() } func addZoomForPhrase(_ id: Int) { guard let phrase = phrases.first(where: { $0.id == id }) else { return } zooms.append(ManualZoom(start: phrase.trimStart, end: phrase.trimEnd)) zooms.sort { $0.start < $1.start } } func removeZoom(_ id: UUID) { zooms.removeAll { $0.id == id } } func zoom(at time: Double) -> ManualZoom? { zooms.first { time >= $0.start && time <= $0.end } } func setEmphasisForAll(_ level: Int) { for index in phrases.indices where phrases[index].active { phrases[index].emphasis = level } } // MARK: - Resumo e gravação var emphasisCount: Int { phrases.filter { $0.active && $0.emphasis >= 1 }.count } var removedCount: Int { phrases.filter { !$0.active }.count } var keptDuration: Double { phrases.filter { $0.active }.reduce(0) { $0 + ($1.trimEnd - $1.trimStart) } } // MARK: - Tempo compactado (sem os vãos do que foi cortado) /// Kept spans of source media, in order, each carrying the position it /// lands at once every removed stretch between phrases is squeezed out. /// The timeline draws and scrubs in this space so it reads like the cut /// itself instead of the raw take with holes in it. private var keptSegments: [(rawStart: Double, rawEnd: Double, compactStart: Double)] { var offset = 0.0 var segments: [(Double, Double, Double)] = [] for phrase in phrases.sorted(by: { $0.start < $1.start }) where phrase.active { guard phrase.trimEnd > phrase.trimStart else { continue } segments.append((phrase.trimStart, phrase.trimEnd, offset)) offset += phrase.trimEnd - phrase.trimStart } return segments } /// Maps a raw source-media time to its position on the compacted timeline. /// Time inside removed material collapses to the boundary of the nearest /// kept segment, so cut stretches take up no space at all. func compactTime(_ raw: Double) -> Double { let segments = keptSegments for segment in segments { if raw < segment.rawStart { return segment.compactStart } if raw <= segment.rawEnd { return segment.compactStart + (raw - segment.rawStart) } } guard let last = segments.last else { return 0 } return raw >= last.rawEnd ? last.compactStart + (last.rawEnd - last.rawStart) : 0 } /// The inverse of `compactTime`: where a click on the compacted timeline /// lands in the raw source media, for seeking and scrubbing. func rawTime(fromCompact compact: Double) -> Double { let segments = keptSegments for segment in segments { let compactEnd = segment.compactStart + (segment.rawEnd - segment.rawStart) if compact <= compactEnd { return segment.rawStart + max(0, compact - segment.compactStart) } } return segments.last?.rawEnd ?? 0 } /// Persists the edited review plus the actions derived from it. Called when /// the wizard advances — the render itself happens in the next step. /// Persists the edited review and hands back BOTH paths it wrote: /// `review_path` (the human-readable `_phrase_review.json`) and /// `actions_path` (`_phrase_actions.json`, the cut/zoom list derived from /// it — what `finalizeProcessing` needs to actually apply the review's /// active/inactive decisions instead of just filing them away). func save(completion: @escaping (_ reviewPath: String?, _ actionsPath: String?) -> Void) { guard !voiceTimelinePath.isEmpty, !phrases.isEmpty else { completion(nil, nil) return } let arguments: [String: Any] = [ "voice_timeline": voiceTimelinePath, "source": source, "duration": duration, "speakers": speakers, "phrases": phrases.map { $0.asJSON }, "zooms": zooms.map { $0.asJSON }, ] PythonBridge.call(command: "save_phrase_review", arguments: arguments) { result, error in Task { @MainActor in if let error { completion(nil, nil) _ = error return } completion(result?["review_path"] as? String, result?["actions_path"] as? String) } } } }