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? /// Aspect ratio of the footage as recorded. @Published var videoAspect: Double = 16.0 / 9.0 /// Aspect ratio the project delivers in, read from the .fcpxml. It is /// routinely *not* the footage's: these takes are shot horizontal and /// delivered vertical, so previewing the raw frame would show a crop the /// audience never sees — and the emphasis decisions are about what lands on /// screen. Nil until the project is known. @Published var projectAspect: Double? /// Whether the preview crops to the delivery frame. On by default whenever /// the two aspects disagree. @Published var matchProjectFraming = true /// What the preview should actually draw. var previewAspect: Double { guard matchProjectFraming, let projectAspect else { return videoAspect } return projectAspect } /// True when the delivery frame differs enough from the footage that the /// preview is showing a crop rather than the whole take. var isCropping: Bool { guard matchProjectFraming, let projectAspect else { return false } return abs(projectAspect - videoAspect) > 0.01 } 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`. /// Reads the delivery format from the project so the preview can frame the /// take the way it will actually be seen. func loadProjectFormat(projectPath: String) { PythonBridge.call(command: "inspect", arguments: ["path": projectPath]) { [weak self] result, _ in Task { @MainActor in guard let self, let timelines = result?["timelines"] as? [[String: Any]], let first = timelines.first, let width = first["width"] as? Int, let height = first["height"] as? Int, width > 0, height > 0 else { return } self.projectAspect = Double(width) / Double(height) } } } func load(voiceTimelinePath: String, decisionsJSON: String, outputFolder: String? = nil, mediaFolder: String? = nil) { 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 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 readAspect(from: asset) // 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) } } } /// The displayed aspect ratio, honouring the rotation the camera recorded. /// A phone take is stored 1920×1080 with a 90° transform: reading /// `naturalSize` alone would call a vertical video horizontal. private func readAspect(from asset: AVURLAsset) { Task { [weak self] in guard let track = try? await asset.loadTracks(withMediaType: .video).first, let size = try? await track.load(.naturalSize), let transform = try? await track.load(.preferredTransform) else { return } let displayed = size.applying(transform) let width = abs(displayed.width), height = abs(displayed.height) guard width > 0, height > 0 else { return } await MainActor.run { self?.videoAspect = width / height } } } // 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. func trimToWord(_ word: ReviewWord, edge: TrimEdge, in id: Int) { 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) } } /// Persists the edited review plus the actions derived from it. Called when /// the wizard advances — the render itself happens in the next step. func save(completion: @escaping (String?) -> Void) { guard !voiceTimelinePath.isEmpty, !phrases.isEmpty else { completion(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) _ = error return } completion(result?["review_path"] as? String) } } } }