import SwiftUI import AppKit /// Guia passo a passo do fluxo completo: projeto → transcrição → análise de /// voz → copiar para o chat e trazer as decisões → revisar as ênfases → /// processamento final. Existe para que o usuário não precise entender a ordem /// certa de botões espalhados em várias abas — cada etapa só libera a próxima /// quando o passo anterior terminou, e a "ponte" com o chat (que hoje exigia /// sair do app e escolher um arquivo na mão) vira copiar/colar assistido /// dentro da própria tela. enum WizardStep: Int, CaseIterable, Identifiable { case projeto, transcricao, analise, exportarChat, revisar, finalizar, concluido var id: Int { rawValue } var titulo: String { switch self { case .projeto: return "Projeto" case .transcricao: return "Transcrever" case .analise: return "Analisar voz" case .exportarChat: return "Decisões da IA" case .revisar: return "Revisar ênfases" case .finalizar: return "Processar" case .concluido: return "Concluído" } } } struct WizardView: View { @State private var step: WizardStep = .projeto // Passo 1 — projeto @State private var outputFolder: String? @State private var projectPath: String? @State private var catalog: Catalog? // Passo 2 — transcrição @State private var isTranscribing = false @State private var transcribeProgress: Double = 0 @State private var transcribeStage = "" @State private var transcribeResults: [TranscriptResult] = [] // Passo 3 — análise de voz @State private var isAnalyzing = false @State private var voiceTimelinePath: String? @State private var voiceAnalysisMessage = "" @State private var acousticsAvailable: Bool? @State private var showVoiceTimelineReuseAlert = false @State private var existingVoiceTimelinePath: String? // Passo 4 — enviar ao chat e trazer as decisões de volta @State private var copiedFeedback = "" @State private var decisionsText = "" @State private var isApplyingDecisions = false @State private var appliedPath: String? @State private var skippedVoiceEdit = false // Passo 5 — revisar ênfases @StateObject private var reviewModel = PhraseReviewModel() @State private var reviewLoadedFor: String? @State private var phraseReviewPath: String? // Passo 6 — processamento final @State private var finalSilences = true @State private var finalFillers = false @State private var finalSubtitles = true @State private var finalDynamicSubtitles = false @State private var isFinalizing = false @State private var finalStatus = "" @State private var finalPath: String? @State private var errorMessage: String? var body: some View { VStack(spacing: 0) { stepperHeader .padding(.horizontal, 24) .padding(.top, 20) .padding(.bottom, 16) Divider() // A revisão é uma sala de edição, não um formulário: ela precisa da // largura toda e rola por conta própria (timeline horizontal, lista // vertical). As demais etapas continuam na coluna estreita, que é o // que mantém um passo a passo legível. if step == .revisar { revisarStep } else { ScrollView { VStack(alignment: .leading, spacing: 18) { if let errorMessage, !errorMessage.isEmpty { Label(errorMessage, systemImage: "exclamationmark.triangle.fill") .foregroundStyle(.red) .padding(.top, 4) } content } .padding(24) .frame(maxWidth: 640, alignment: .leading) .frame(maxWidth: .infinity) } } Divider() navFooter .padding(.horizontal, 24) .padding(.vertical, 16) } .task { loadProjectConfig() await loadCatalog() } .alert("Análise de voz já existe", isPresented: $showVoiceTimelineReuseAlert) { Button("Usar existente") { if let existingVoiceTimelinePath { voiceTimelinePath = existingVoiceTimelinePath voiceAnalysisMessage = "Reaproveitando análise existente: \(existingVoiceTimelinePath)" } } Button("Reprocessar") { analyzeVoice(forceReprocess: true) } Button("Cancelar", role: .cancel) {} } message: { Text("Já existe um arquivo voice_timeline para este projeto. Quer manter o processamento anterior para ganhar tempo?") } } // MARK: - Cabeçalho com os passos private var stepperHeader: some View { HStack(spacing: 6) { ForEach(WizardStep.allCases) { s in HStack(spacing: 6) { ZStack { Circle() .fill(colorFor(s)) .frame(width: 24, height: 24) if s.rawValue < step.rawValue { Image(systemName: "checkmark") .font(.caption2.weight(.bold)) .foregroundStyle(.white) } else { Text("\(s.rawValue + 1)") .font(.caption2.weight(.bold)) .foregroundStyle(s == step ? .white : .secondary) } } Text(s.titulo) .font(.caption) .foregroundStyle(s == step ? .primary : .secondary) .fontWeight(s == step ? .semibold : .regular) } if s != WizardStep.allCases.last { Rectangle() .fill(s.rawValue < step.rawValue ? Color.accentColor : Color.secondary.opacity(0.25)) .frame(height: 2) .frame(maxWidth: .infinity) } } } } private func colorFor(_ s: WizardStep) -> Color { if s.rawValue < step.rawValue { return .accentColor } if s == step { return .accentColor } return Color.secondary.opacity(0.25) } // MARK: - Conteúdo por etapa @ViewBuilder private var content: some View { switch step { case .projeto: projetoStep case .transcricao: transcricaoStep case .analise: analiseStep case .exportarChat: exportarChatStep case .revisar: revisarStep case .finalizar: finalizarStep case .concluido: concluidoStep } } private var projetoStep: some View { VStack(alignment: .leading, spacing: 16) { Text("1. Escolha o projeto").font(.title3.weight(.semibold)) Text("A pasta é onde tudo o que for gerado nesse fluxo fica salvo. O arquivo é o .fcpxml exportado do Final Cut Pro.") .font(.callout).foregroundStyle(.secondary) fieldRow(icon: "folder", label: outputFolder ?? "Nenhuma pasta selecionada", isSet: outputFolder != nil) { pickOutputFolder() } fieldRow(icon: "doc.text", label: projectPath.map { URL(fileURLWithPath: $0).lastPathComponent } ?? "Nenhum arquivo selecionado", isSet: projectPath != nil) { pickProjectFile() } if looksLikeGeneratedFile(projectPath) { Label("Esse arquivo parece já ter sido processado por este fluxo (o nome tem um sufixo como \"_voice_edit\" ou \"_silence_removed\"). Rodar o wizard de novo em cima dele reaplica os cortes por cima de cortes já feitos. Selecione o .fcpxml original do Final Cut, a menos que a intenção seja mesmo reprocessar.", systemImage: "exclamationmark.triangle.fill") .font(.caption).foregroundStyle(.orange) } if (catalog?.installedCount ?? 0) == 0 { Label("Nenhum modelo de transcrição instalado. Baixe um na aba \"Modelos\" antes de continuar.", systemImage: "exclamationmark.triangle.fill") .font(.caption).foregroundStyle(.orange) } } } private var transcricaoStep: some View { VStack(alignment: .leading, spacing: 16) { Text("2. Transcreva o áudio").font(.title3.weight(.semibold)) Text("Roda localmente com o modelo escolhido na aba Modelos. Vira a base de tudo que vem depois — o corte por voz, as legendas, os marcadores.") .font(.callout).foregroundStyle(.secondary) Button { startTranscription() } label: { if isTranscribing { HStack { ProgressView().controlSize(.small); Text(transcribeStage.isEmpty ? "Transcrevendo…" : transcribeStage) } .frame(maxWidth: .infinity) } else { Label(transcribeResults.isEmpty ? "Transcrever" : "Transcrever novamente", systemImage: "waveform") .frame(maxWidth: .infinity) } } .buttonStyle(.borderedProminent) .controlSize(.large) .disabled(isTranscribing || projectPath == nil || outputFolder == nil) if isTranscribing { VStack(alignment: .leading, spacing: 6) { ProgressView(value: transcribeProgress) Text("\(Int(transcribeProgress * 100))%").font(.caption).foregroundStyle(.secondary).monospacedDigit() } } if !transcribeResults.isEmpty { ForEach(transcribeResults, id: \.media) { r in VStack(alignment: .leading, spacing: 4) { HStack { Image(systemName: "checkmark.circle.fill").foregroundStyle(.green) Text(r.media).font(.body.weight(.medium)) Spacer() Text("\(r.language) · \(r.words) palavras").font(.caption).foregroundStyle(.secondary) } Text(r.preview).font(.caption).foregroundStyle(.secondary).lineLimit(2) } .padding(12) .background(RoundedRectangle(cornerRadius: 8).fill(Color.secondary.opacity(0.06))) } } } } private var analiseStep: some View { VStack(alignment: .leading, spacing: 16) { Text("3. Analise a voz").font(.title3.weight(.semibold)) Text("Gera o JSON com transcrição, locutor e intensidade (pitch/energia/ritmo) por palavra — é esse arquivo que o chat lê para decidir o que cortar. Não corta nada sozinho.") .font(.callout).foregroundStyle(.secondary) Button { analyzeVoice() } label: { if isAnalyzing { HStack { ProgressView().controlSize(.small); Text("Analisando…") }.frame(maxWidth: .infinity) } else { Label(voiceTimelinePath == nil ? "Analisar voz" : "Analisar novamente", systemImage: "waveform.badge.magnifyingglass") .frame(maxWidth: .infinity) } } .buttonStyle(.borderedProminent) .controlSize(.large) .disabled(isAnalyzing || projectPath == nil || outputFolder == nil) if let voiceTimelinePath { VStack(alignment: .leading, spacing: 6) { Label("Análise pronta", systemImage: "checkmark.circle.fill").foregroundStyle(.green) Text(voiceTimelinePath).font(.caption).foregroundStyle(.secondary).lineLimit(1).truncationMode(.middle) } .padding(12) .background(RoundedRectangle(cornerRadius: 8).fill(Color.secondary.opacity(0.06))) if acousticsAvailable == false { VStack(alignment: .leading, spacing: 4) { Label("Sem análise acústica real", systemImage: "exclamationmark.triangle.fill") .font(.caption.weight(.semibold)).foregroundStyle(.orange) Text("Falta o componente \"librosa\" — os cortes ainda são decididos pelo texto, mas o chat não vai propor zoom com confiança. Instale em Avançado → Modelos → \"Análise Acústica\", e refaça esta etapa depois.") .font(.caption).foregroundStyle(.secondary) } .padding(12) .background(RoundedRectangle(cornerRadius: 8).fill(Color.orange.opacity(0.08))) } } } } private var exportarChatStep: some View { VStack(alignment: .leading, spacing: 16) { Text("4. Envie para o chat decidir os cortes").font(.title3.weight(.semibold)) Text("Esta é a única etapa manual que sobra: o julgamento de qual tomada usar, onde dar zoom e o que escrever na tela é feito pela IA numa conversa, não por um botão. Copie abaixo, cole numa sessão do Claude e peça pra rodar a skill \"editar-por-voz\".") .font(.callout).foregroundStyle(.secondary) if let voiceTimelinePath { Button { copyForChat(path: voiceTimelinePath) } label: { Label("Copiar para colar no chat", systemImage: "doc.on.clipboard") .frame(maxWidth: .infinity) } .buttonStyle(.borderedProminent) .controlSize(.large) if !copiedFeedback.isEmpty { Label(copiedFeedback, systemImage: "checkmark.circle.fill") .font(.caption).foregroundStyle(.green) } VStack(alignment: .leading, spacing: 8) { Text("O que é copiado").font(.caption.weight(.semibold)).foregroundStyle(.secondary) Text("Um pedido pronto + o conteúdo de \(URL(fileURLWithPath: voiceTimelinePath).lastPathComponent), já formatado. É só colar (⌘V) numa conversa com o Claude.") .font(.caption).foregroundStyle(.secondary) } .padding(12) .background(RoundedRectangle(cornerRadius: 8).fill(Color.secondary.opacity(0.06))) Divider().padding(.vertical, 4) Text("Cole aqui o que o chat devolveu").font(.callout.weight(.semibold)) Text("Na próxima etapa essas decisões aparecem já marcadas na timeline, frase por frase, para você lapidar.") .font(.caption).foregroundStyle(.secondary) HStack { Button { if let s = NSPasteboard.general.string(forType: .string) { decisionsText = s } } label: { Label("Colar da área de transferência", systemImage: "list.clipboard") } Spacer() if !decisionsText.isEmpty { Label(jsonIsValid ? "JSON válido" : "JSON inválido", systemImage: jsonIsValid ? "checkmark.circle.fill" : "xmark.circle.fill") .font(.caption) .foregroundStyle(jsonIsValid ? .green : .red) } } TextEditor(text: $decisionsText) .font(.system(.caption, design: .monospaced)) .frame(minHeight: 140) .padding(8) .background(RoundedRectangle(cornerRadius: 8).fill(Color.secondary.opacity(0.06))) .overlay(RoundedRectangle(cornerRadius: 8).stroke(Color.secondary.opacity(0.2))) Button { applyDecisions() } label: { if isApplyingDecisions { HStack { ProgressView().controlSize(.small); Text("Aplicando…") } .frame(maxWidth: .infinity) } else { Label("Aplicar decisões", systemImage: "checkmark.seal") .frame(maxWidth: .infinity) } } .buttonStyle(.borderedProminent) .controlSize(.large) .disabled(isApplyingDecisions || !jsonIsValid) if let appliedPath { Label("Decisões aplicadas — \(URL(fileURLWithPath: appliedPath).lastPathComponent)", systemImage: "checkmark.circle.fill") .font(.caption).foregroundStyle(.green) } Divider() Button("Pular esta etapa (revisar as ênfases direto, sem passar pela IA)") { skippedVoiceEdit = true appliedPath = nil decisionsText = "" } .buttonStyle(.plain) .font(.caption) .foregroundStyle(.secondary) } else { Label("Volte ao passo anterior e rode a análise de voz primeiro.", systemImage: "exclamationmark.triangle.fill") .font(.caption).foregroundStyle(.orange) } } } /// Etapa 5 — a sala de edição. Diferente das outras, não é um formulário /// dentro da coluna do assistente: ocupa a janela toda e se carrega sozinha /// na primeira vez que aparece para aquela análise de voz. private var revisarStep: some View { Group { if voiceTimelinePath != nil { PhraseReviewView(model: reviewModel) } else { VStack(spacing: 8) { Label("Volte ao passo 3 e rode a análise de voz primeiro.", systemImage: "exclamationmark.triangle.fill") .foregroundStyle(.orange) } .frame(maxWidth: .infinity, maxHeight: .infinity) } } .onAppear { loadReviewIfNeeded() } } private var finalizarStep: some View { VStack(alignment: .leading, spacing: 16) { Text("6. Finalize o corte").font(.title3.weight(.semibold)) Text("Últimos passos automáticos, sem decisão envolvida — rodam com os parâmetros já configurados na aba \"Análise de Voz\" / \"Legendas Dinâmicas\".") .font(.callout).foregroundStyle(.secondary) Toggle("Remover silêncios do áudio", isOn: $finalSilences) Toggle("Remover palavras de preenchimento", isOn: $finalFillers) Toggle("Gerar legenda comum (texto editável no FCP)", isOn: $finalSubtitles) Toggle("Gerar legendas dinâmicas (estilo configurado na aba própria)", isOn: $finalDynamicSubtitles) Button { finalizeProcessing() } label: { if isFinalizing { HStack { ProgressView().controlSize(.small); Text(finalStatus.isEmpty ? "Processando…" : finalStatus) } .frame(maxWidth: .infinity) } else { Label("Processar", systemImage: "play.fill").frame(maxWidth: .infinity) } } .buttonStyle(.borderedProminent) .controlSize(.large) .disabled(isFinalizing || (!finalSilences && !finalFillers && !finalSubtitles && !finalDynamicSubtitles)) if !finalStatus.isEmpty && !isFinalizing { Text(finalStatus).font(.caption).foregroundStyle(.secondary) } } } private var concluidoStep: some View { VStack(alignment: .leading, spacing: 16) { Label("Concluído", systemImage: "checkmark.seal.fill") .font(.title3.weight(.semibold)) .foregroundStyle(.green) if let finalPath { Text(finalPath).font(.caption).foregroundStyle(.secondary).lineLimit(1).truncationMode(.middle) HStack { Button("Abrir no Final Cut Pro") { NSWorkspace.shared.open(URL(fileURLWithPath: finalPath)) } .buttonStyle(.borderedProminent) Button("Mostrar no Finder") { NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: finalPath)]) } } } Divider().padding(.vertical, 8) Button("Começar outro projeto") { resetWizard() } } } // MARK: - Navegação private var navFooter: some View { HStack { if step != .projeto && step != .concluido { Button("Voltar") { goBack() } } Spacer() if step != .concluido { Button(step == .finalizar ? "Concluir" : "Continuar") { goNext() } .buttonStyle(.borderedProminent) .disabled(!canAdvance) } } } private var canAdvance: Bool { switch step { case .projeto: return outputFolder != nil && projectPath != nil case .transcricao: return !transcribeResults.isEmpty case .analise: return voiceTimelinePath != nil case .exportarChat: return appliedPath != nil || skippedVoiceEdit // Revisar é opcional: a sugestão da IA já é utilizável como veio, então // o botão nunca trava aqui — o passo existe para lapidar, não para // exigir mais uma confirmação. case .revisar: return true case .finalizar: return finalPath != nil && !isFinalizing case .concluido: return false } } private func goNext() { guard let next = WizardStep(rawValue: step.rawValue + 1) else { return } // Sair da revisão grava o que foi decidido (e as ações derivadas dela) // ao lado da análise de voz. Nada é renderizado aqui: a etapa 6 é que // lê esse arquivo para dar zoom e legenda dinâmica só nas ênfases. if step == .revisar { reviewModel.save { path in phraseReviewPath = path } } step = next } private func goBack() { guard let prev = WizardStep(rawValue: step.rawValue - 1) else { return } step = prev } private func resetWizard() { step = .projeto transcribeResults = [] voiceTimelinePath = nil voiceAnalysisMessage = "" decisionsText = "" appliedPath = nil skippedVoiceEdit = false reviewLoadedFor = nil phraseReviewPath = nil finalStatus = "" finalPath = nil errorMessage = nil } // MARK: - Componentes auxiliares @ViewBuilder private func fieldRow(icon: String, label: String, isSet: Bool, action: @escaping () -> Void) -> some View { HStack { Image(systemName: icon).foregroundStyle(isSet ? .primary : .secondary) Text(label).lineLimit(1).truncationMode(.middle).foregroundStyle(isSet ? .primary : .secondary) Spacer() Button("Escolher…", action: action) } .padding(12) .background(RoundedRectangle(cornerRadius: 8).fill(Color.secondary.opacity(0.06))) } /// Todo output do fluxo carrega um destes sufixos no nome (ver /// `_derived_output` / suffixes usados por `apply_voice_actions`, /// `remove_silences`, `generate_dynamic_subtitles` em /// `admin/models_api.py`). Selecionar um deles como "o projeto" no passo /// 1 é o erro que gerou arquivos como `_voice_edit_voice_edit_...`: os /// cortes de voz assumem timestamps da mídia ORIGINAL, então reaplicá-los /// sobre um arquivo já cortado desloca tudo silenciosamente. private static let generatedSuffixes = [ "_voice_edit", "_silence_removed", "_dynamic_subtitles", "_transcript_edit", "_fillers_removed", "_markers", ] private func looksLikeGeneratedFile(_ path: String?) -> Bool { guard let path else { return false } let stem = URL(fileURLWithPath: path).deletingPathExtension().lastPathComponent return Self.generatedSuffixes.contains { stem.contains($0) } } private var jsonIsValid: Bool { guard let data = decisionsText.data(using: .utf8), !decisionsText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return false } return (try? JSONSerialization.jsonObject(with: data)) != nil } // MARK: - Ações — Python bridge private func loadProjectConfig() { PythonBridge.call(command: "project_config") { result, _ in DispatchQueue.main.async { guard let result, result["ok"] as? Bool == true else { return } if let folder = result["folder"] as? String, !folder.isEmpty { outputFolder = folder } if let file = result["file"] as? String, !file.isEmpty { projectPath = file } } } } private func loadCatalog() async { PythonBridge.call(command: "catalog") { result, _ in DispatchQueue.main.async { if let result { catalog = Catalog(json: result) } } } } private func pickOutputFolder() { let panel = NSOpenPanel() panel.canChooseFiles = false panel.canChooseDirectories = true panel.allowsMultipleSelection = false panel.prompt = "Usar esta pasta" panel.message = "Escolha a pasta onde os resultados serão salvos." if panel.runModal() == .OK, let url = panel.url { outputFolder = url.path PythonBridge.call(command: "set_project_config", arguments: ["folder": url.path]) { _, _ in } } } private func pickProjectFile() { let panel = NSOpenPanel() panel.canChooseFiles = true panel.canChooseDirectories = false panel.allowsMultipleSelection = false panel.prompt = "Selecionar" panel.message = "Selecione o arquivo (.fcpxml) ou o bundle (.fcpxmld) exportado pelo Final Cut Pro." if panel.runModal() == .OK, let url = panel.url { let ext = url.pathExtension.lowercased() if ext == "fcpxml" || ext == "fcpxmld" || ext == "xml" { projectPath = url.path PythonBridge.call(command: "set_project_config", arguments: ["file": url.path]) { _, _ in } } else { errorMessage = "Selecione um arquivo .fcpxml, .fcpxmld ou .xml do Final Cut Pro." } } } private func startTranscription() { guard let projectPath, let outputFolder else { return } isTranscribing = true errorMessage = nil transcribeResults = [] transcribeProgress = 0 PythonBridge.run(command: "transcribe", arguments: ["path": projectPath, "output_dir": outputFolder]) { obj in DispatchQueue.main.async { let type = obj["type"] as? String if type == "progress" { transcribeProgress = (obj["fraction"] as? NSNumber)?.doubleValue ?? 0 transcribeStage = obj["stage"] as? String ?? "" } else if type == "error" { errorMessage = obj["message"] as? String ?? "Erro na transcrição." } else if type == "result", let arr = obj["transcripts"] as? [[String: Any]] { transcribeResults = arr.map(TranscriptResult.init) } } } completion: { code, err in DispatchQueue.main.async { isTranscribing = false transcribeProgress = 1 if code != 0 && transcribeResults.isEmpty { errorMessage = err ?? "A transcrição falhou." } } } } private func analyzeVoice(forceReprocess: Bool = false) { guard let projectPath, let outputFolder else { return } isAnalyzing = true errorMessage = nil PythonBridge.call(command: "analyze_voice", arguments: [ "path": projectPath, "output_dir": outputFolder, "force_reprocess": forceReprocess, ]) { result, err in DispatchQueue.main.async { isAnalyzing = false guard result?["ok"] as? Bool == true else { errorMessage = result?["error"] as? String ?? err ?? "Falha ao analisar a voz." return } if result?["reused"] as? Bool == true, !forceReprocess { let timelines = result?["timelines"] as? [String] ?? [] existingVoiceTimelinePath = timelines.first ?? extractPath(from: result?["message"] as? String ?? "", marker: "**Timeline JSON**:") showVoiceTimelineReuseAlert = true return } let message = result?["message"] as? String ?? "" voiceAnalysisMessage = message if let path = extractPath(from: message, marker: "**Timeline JSON**:") { voiceTimelinePath = path } else { voiceTimelinePath = nil // ok:true não garante que a análise gerou timeline — se // não houver fala detectável no áudio, o Python volta com // sucesso mas sem "Timeline JSON" na mensagem. Sem isso // aqui, a etapa parecia não fazer nada. errorMessage = "A análise terminou mas não encontrou fala reconhecível no áudio. Mensagem do motor: " + (message.isEmpty ? "(vazia)" : message) } checkAcoustics() } } } /// A ênfase de voz (energia/tom) depende do `librosa`, dependência /// opcional. Sem ela, a análise ainda transcreve e corta pelo texto, /// mas nunca deveria propor zoom — por isso avisamos aqui, no ponto /// onde o usuário sentiria falta, em vez de só na aba Modelos. private func checkAcoustics() { PythonBridge.call(command: "acoustics_capability") { result, _ in DispatchQueue.main.async { guard let result, result["ok"] as? Bool == true else { return } acousticsAvailable = result["available"] as? Bool } } } /// Localiza uma linha markdown do tipo "- **Marker**: valor" (usado nas /// mensagens do bridge Python) e devolve o valor. Aceita o marcador de /// lista "- " opcional antes dos asteriscos. private func extractPath(from message: String, marker: String) -> String? { for line in message.split(separator: "\n") { var trimmed = Substring(line.trimmingCharacters(in: .whitespaces)) if trimmed.hasPrefix("- ") { trimmed = trimmed.dropFirst(2) } if trimmed.hasPrefix(marker) { return trimmed.dropFirst(marker.count).trimmingCharacters(in: .whitespaces) } } return nil } private func copyForChat(path: String) { guard let content = try? String(contentsOfFile: path, encoding: .utf8) else { errorMessage = "Não foi possível ler \(path)." return } let prompt = """ Use a skill "editar-por-voz" para decidir os cortes deste projeto a partir da timeline de voz abaixo. Devolva só o JSON de decisões (cortes, zooms, textos, marcadores) pronto para eu colar de volta no app. ```json \(content) ``` """ let pasteboard = NSPasteboard.general pasteboard.clearContents() pasteboard.setString(prompt, forType: .string) copiedFeedback = "Copiado — cole (⌘V) numa conversa com o Claude." } /// Monta a revisão uma vez por análise de voz. Voltar e avançar de novo não /// recarrega: isso jogaria fora as edições manuais em silêncio, que é /// exatamente o que esta tela existe para preservar. private func loadReviewIfNeeded() { guard let voiceTimelinePath, reviewLoadedFor != voiceTimelinePath else { return } reviewLoadedFor = voiceTimelinePath // A pasta do projeto e a do .fcpxml entram como onde procurar a mídia: // a análise de voz guarda só o nome do arquivo, não o caminho. reviewModel.load( voiceTimelinePath: voiceTimelinePath, decisionsJSON: decisionsText, outputFolder: outputFolder, mediaFolder: projectPath.map { URL(fileURLWithPath: $0).deletingLastPathComponent().path } ) if let projectPath { reviewModel.loadProjectFormat(projectPath: projectPath) } } private func applyDecisions() { guard let projectPath, let outputFolder, let data = decisionsText.data(using: .utf8), let parsed = try? JSONSerialization.jsonObject(with: data) else { return } isApplyingDecisions = true errorMessage = nil PythonBridge.call(command: "apply_voice_actions", arguments: [ "path": projectPath, "output_dir": outputFolder, "actions": parsed, ]) { result, err in DispatchQueue.main.async { isApplyingDecisions = false guard result?["ok"] as? Bool == true else { errorMessage = result?["error"] as? String ?? err ?? "Falha ao aplicar as decisões." return } appliedPath = result?["path"] as? String ?? projectPath skippedVoiceEdit = false } } } private func finalizeProcessing() { guard let outputFolder else { return } let startPath = appliedPath ?? projectPath guard let startPath else { return } var operations: [String] = [] if finalSilences { operations.append("remove_silences") } if finalFillers { operations.append("remove_filler_words") } if finalSubtitles { operations.append("generate_plain_subtitles") } if finalDynamicSubtitles { operations.append("generate_dynamic_subtitles") } guard !operations.isEmpty else { return } isFinalizing = true errorMessage = nil finalStatus = "Iniciando…" finalizeStep(operations, index: 0, currentPath: startPath, outputFolder: outputFolder) } private func finalizeStep(_ operations: [String], index: Int, currentPath: String, outputFolder: String) { guard index < operations.count else { isFinalizing = false finalStatus = "Processamento concluído." finalPath = currentPath return } let operation = operations[index] finalStatus = "Processando: \(operation)…" PythonBridge.call(command: operation, arguments: ["path": currentPath, "output_dir": outputFolder]) { result, err in DispatchQueue.main.async { guard result?["ok"] as? Bool == true else { isFinalizing = false errorMessage = result?["error"] as? String ?? err ?? "Falha em \(operation)." finalStatus = "Processamento interrompido." return } let nextPath = result?["path"] as? String ?? currentPath finalizeStep(operations, index: index + 1, currentPath: nextPath, outputFolder: outputFolder) } } } }