import SwiftUI import UniformTypeIdentifiers /// Guia "Transcrição" — importe um projeto FCPXML (drag-and-drop ou seletor de /// arquivo) e transcreva a mídia localmente usando o modelo + idioma escolhidos /// na aba Modelos. struct TranscriptionView: View { private let embedded: Bool @State private var catalog: Catalog? @State private var projectPath: String? @State private var isRunning = false @State private var progress: Double = 0 @State private var stage = "" @State private var results: [TranscriptResult] = [] @State private var errorMessage: String? @State private var processedPath: String? @State private var isRemovingSilences = false @State private var silencePadding: Double = 0.05 @State private var silenceNoiseDb: Double = -30 @State private var silenceMinDuration: Double = 0.5 @State private var isRemovingFillers = false @State private var fillerRemovedPath: String? @State private var phraseInput = "" @State private var isEditingByTranscript = false @State private var transcriptEditPath: String? @State private var isImportingMarkers = false @State private var transcriptMarkersPath: String? @State private var isExportingSubtitles = false @State private var subtitlePaths: [String] = [] @State private var zoomClipName = "" @State private var zoomStart = "" @State private var zoomEnd = "" @State private var zoomScale: Double = 1.3 @State private var zoomEase: Double = 0.3 @State private var isAddingZoom = false @State private var zoomResultPath: String? @State private var zoomClips: [ZoomClip] = [] @State private var zoomSegments: [ZoomSegment] = [] @State private var selectedZoomClipID = "" @State private var selectedZoomStartID: Int? @State private var selectedZoomEndID: Int? @State private var zoomMessage = "" @State private var outputFolder: String? @State private var batchVoiceAnalysis = false @State private var batchVoiceEdit = false @State private var voiceActionsPath: String? @State private var batchSilences = true @State private var batchFillers = false @State private var batchPhrases = false @State private var batchMarkers = false @State private var batchSubtitles = true @State private var batchDynamicSubtitles = false @State private var dynamicSubtitlesPath: String? @State private var isBatchProcessing = false @State private var batchStatus = "" init(projectPath: String? = nil, embedded: Bool = false) { self.embedded = embedded _projectPath = State(initialValue: projectPath) } var body: some View { Form { Section("Pasta do projeto") { HStack { Image(systemName: "folder") Text(outputFolder ?? "Nenhuma pasta selecionada") .foregroundStyle(outputFolder == nil ? .secondary : .primary) .lineLimit(1) Spacer() Button("Escolher…") { pickOutputFolder() } } Text("Obrigatório antes de qualquer outra ação. Tudo o que for gerado — transcrição (.json), XML e SRT — é salvo aqui dentro.") .font(.caption).foregroundStyle(.secondary) if outputFolder == nil { Label("Selecione a pasta do projeto para liberar as opções abaixo.", systemImage: "lock.fill") .font(.caption) .foregroundStyle(.orange) } } if !embedded { Section("Projeto do Final Cut Pro") { HStack { Text(projectName) .foregroundStyle(projectPath == nil ? .secondary : .primary) .lineLimit(1) Spacer() Button("Escolher…") { pickProjectFile() } } if let projectPath { HStack { Image(systemName: "doc.text").foregroundStyle(.secondary) Text(projectPath).font(.caption).foregroundStyle(.secondary).lineLimit(1) } } } .disabled(outputFolder == nil) } Section("Transcrição") { if embedded, let projectPath { Label(URL(fileURLWithPath: projectPath).lastPathComponent, systemImage: "doc.text") .lineLimit(1) } if hasNoInstalledModel { Label("Nenhum modelo instalado. Baixe e selecione um na aba Modelos.", systemImage: "exclamationmark.triangle.fill") .font(.caption) .foregroundStyle(.orange) } Button { startTranscription() } label: { if isRunning { HStack { ProgressView().controlSize(.small) Text(stage.isEmpty ? "Transcrevendo…" : stage) } } else { Label("Transcrever", systemImage: "waveform") } } .disabled(isRunning || projectPath == nil || outputFolder == nil || hasNoInstalledModel) .frame(maxWidth: .infinity) .buttonStyle(.borderedProminent) if isRunning { VStack(alignment: .leading, spacing: 6) { ProgressView(value: progress) HStack { Text(stage.isEmpty ? "Processando o áudio…" : stage) Spacer() Text("\(Int(progress * 100))%").monospacedDigit() } .font(.caption).foregroundStyle(.secondary) } .padding(.top, 4) } } .disabled(outputFolder == nil) if let errorMessage, !errorMessage.isEmpty { Section { Label(errorMessage, systemImage: "exclamationmark.triangle.fill") .foregroundStyle(.red) } } if !results.isEmpty { Section("Transcrição Concluída") { ForEach(results, id: \.media) { r in VStack(alignment: .leading, spacing: 4) { HStack { Text(r.media).font(.body.weight(.medium)) Spacer() Text("\(r.language) · \(r.words) palavras") .font(.caption).foregroundStyle(.secondary) } if !r.speakers.isEmpty { Text("Participantes: " + r.speakers.joined(separator: ", ")) .font(.caption).foregroundStyle(.secondary) } Text(r.preview).font(.caption).foregroundStyle(.secondary).lineLimit(3) Text("Salvo em: \(r.saved)").font(.caption2).foregroundStyle(.tertiary).lineLimit(1) } } } } Section("Processamento") { if outputFolder == nil { Text("Selecione a pasta do projeto para habilitar o processamento.") .font(.caption2).foregroundStyle(.secondary) } else if results.isEmpty { Label( isRunning ? "Aguardando a transcrição terminar…" : "Transcreva o projeto acima para liberar o processamento.", systemImage: "lock.fill" ) .font(.caption).foregroundStyle(.orange) } GroupBox { VStack(alignment: .leading, spacing: 18) { batchOptionRow( toggle: Toggle("Analisar voz (transcrição, locutor, ênfase)", isOn: $batchVoiceAnalysis), expanded: batchVoiceAnalysis ) { Text("Gera o JSON com transcrição, diarização e intensidade (pitch/energia/ritmo) por palavra — a base que o corte por voz usa para decidir tomadas e zooms sem reabrir o áudio depois. Só análise: não corta nada.") .font(.caption).foregroundStyle(.secondary) } Divider() batchOptionRow( toggle: Toggle("Aplicar edição por voz (lista de decisões)", isOn: $batchVoiceEdit), expanded: batchVoiceEdit ) { VStack(alignment: .leading, spacing: 6) { Text("Aplica um JSON de decisões — cortes, zooms, textos e marcadores — gerado a partir da análise de voz. É o passo que descarta bastidor e tomadas repetidas.") .font(.caption).foregroundStyle(.secondary) HStack { Image(systemName: "doc.text") Text(voiceActionsPath.map { URL(fileURLWithPath: $0).lastPathComponent } ?? "Nenhum arquivo de decisões") .foregroundStyle(voiceActionsPath == nil ? .secondary : .primary) .lineLimit(1).truncationMode(.middle) Spacer() Button("Escolher…") { pickVoiceActions() } } } } Divider() batchOptionRow( toggle: Toggle("Remover silêncios do áudio", isOn: $batchSilences), expanded: true ) { VStack(alignment: .leading, spacing: 6) { HStack { Text("Tolerância do corte") .font(.callout) .foregroundStyle(.secondary) Spacer() Text(String(format: "%.2fs", silencePadding)) .font(.callout).foregroundStyle(.secondary).monospacedDigit() } Slider(value: $silencePadding, in: 0...2, step: 0.01) .disabled(!batchSilences) .onChange(of: silencePadding) { _, _ in saveSilenceConfig() } HStack { Text("Limiar de silêncio") .font(.callout) .foregroundStyle(.secondary) Spacer() Text(String(format: "%.0f dB", silenceNoiseDb)) .font(.callout).foregroundStyle(.secondary).monospacedDigit() } Slider(value: $silenceNoiseDb, in: -60...(-10), step: 1) .disabled(!batchSilences) .onChange(of: silenceNoiseDb) { _, _ in saveSilenceConfig() } HStack { Text("Duração mínima") .font(.callout) .foregroundStyle(.secondary) Spacer() Text(String(format: "%.2fs", silenceMinDuration)) .font(.callout).foregroundStyle(.secondary).monospacedDigit() } Slider(value: $silenceMinDuration, in: 0.1...5, step: 0.05) .disabled(!batchSilences) .onChange(of: silenceMinDuration) { _, _ in saveSilenceConfig() } Text("Quanto de silêncio sobra em volta de cada corte, abaixo de que volume conta como silêncio, e quanto tempo ele precisa durar. Fica salvo e vale também fora do app.") .font(.caption).foregroundStyle(.secondary) } } Divider() Toggle("Remover palavras de preenchimento", isOn: $batchFillers) Divider() batchOptionRow( toggle: Toggle("Cortar frases ditas", isOn: $batchPhrases), expanded: batchPhrases ) { TextField("Frases separadas por vírgula", text: $phraseInput) .textFieldStyle(.roundedBorder) } Divider() Toggle("Marcar o que foi dito na timeline", isOn: $batchMarkers) Divider() Toggle("Gerar legenda comum (texto editável no FCP)", isOn: $batchSubtitles) Divider() batchOptionRow( toggle: Toggle("Gerar legendas dinâmicas (cascata, editáveis no FCP)", isOn: $batchDynamicSubtitles), expanded: batchDynamicSubtitles ) { Text("Usa o estilo configurado na aba \"Legendas Dinâmicas\". Roda por último, depois dos outros passos marcados acima, usando o timing já cortado da timeline.") .font(.caption).foregroundStyle(.secondary) } } .padding(.vertical, 8) } VStack(alignment: .leading, spacing: 12) { Button { processBatch() } label: { if isBatchProcessing { HStack { ProgressView().controlSize(.small) Text("Processando…") } .frame(maxWidth: .infinity) } else { Label("Processar selecionados", systemImage: "play.fill") .frame(maxWidth: .infinity) } } .buttonStyle(.borderedProminent) .controlSize(.large) .disabled(isBatchProcessing || isRunning || projectPath == nil || outputFolder == nil || !batchVoiceAnalysis && !batchVoiceEdit && !batchSilences && !batchFillers && !batchPhrases && !batchMarkers && !batchSubtitles && !batchDynamicSubtitles // Ligada sem arquivo, a etapa falharia no meio da // cadeia e interromperia tudo o que vem depois. || batchVoiceEdit && voiceActionsPath == nil) if !batchStatus.isEmpty { Text(batchStatus).font(.caption).foregroundStyle(.secondary) } HStack(spacing: 12) { Button("Abrir pasta selecionada") { if let outputFolder { NSWorkspace.shared.open(URL(fileURLWithPath: outputFolder)) } } .disabled(outputFolder == nil) Button("Abrir no Final Cut Pro") { if let path = dynamicSubtitlesPath ?? processedPath ?? transcriptEditPath ?? transcriptMarkersPath { NSWorkspace.shared.open(URL(fileURLWithPath: path)) } } .disabled(dynamicSubtitlesPath == nil && processedPath == nil && transcriptEditPath == nil && transcriptMarkersPath == nil) Spacer() } } .padding(.top, 4) } .disabled(outputFolder == nil || results.isEmpty) Section("Zoom (aproximar em um trecho)") { if outputFolder != nil && results.isEmpty { Label( isRunning ? "Aguardando a transcrição terminar…" : "Transcreva o projeto acima para liberar o zoom.", systemImage: "lock.fill" ) .font(.caption).foregroundStyle(.orange) } if zoomClips.isEmpty { Text("Carregando clipes do projeto…") .font(.caption).foregroundStyle(.secondary) } else { Picker("Clipe", selection: $selectedZoomClipID) { Text("Selecione um clipe").tag("") ForEach(zoomClips) { clip in Text(clip.label).tag(clip.id) } } .onChange(of: selectedZoomClipID) { _, _ in loadZoomSegments() } if let clip = selectedZoomClip { Text(clip.preview.isEmpty ? "Sem transcrição cacheada para este clipe." : clip.preview) .font(.caption).foregroundStyle(.secondary).lineLimit(3) } Picker("Frase inicial", selection: $selectedZoomStartID) { Text("Escolha a frase inicial").tag(nil as Int?) ForEach(zoomSegments) { segment in Text(segment.label).tag(segment.id as Int?) } } .onChange(of: selectedZoomStartID) { _, _ in updateZoomTimes() } Picker("Frase final", selection: $selectedZoomEndID) { Text("Escolha a frase final").tag(nil as Int?) ForEach(zoomSegments) { segment in Text(segment.label).tag(segment.id as Int?) } } .onChange(of: selectedZoomEndID) { _, _ in updateZoomTimes() } if !zoomMessage.isEmpty { Text(zoomMessage).font(.caption).foregroundStyle(.secondary) } } DisclosureGroup("Ajuste manual avançado") { TextField("Nome/ID do clipe", text: $zoomClipName) .textFieldStyle(.roundedBorder) } HStack { TextField("Início (s)", text: $zoomStart) .textFieldStyle(.roundedBorder) TextField("Fim (s)", text: $zoomEnd) .textFieldStyle(.roundedBorder) } Text("Segundos a partir do início do clipe.") .font(.caption2).foregroundStyle(.secondary) VStack(alignment: .leading, spacing: 4) { HStack { Text("Intensidade").font(.caption) Spacer() Text("\(Int(zoomScale * 100))%").font(.caption).foregroundStyle(.secondary).monospacedDigit() } Slider(value: $zoomScale, in: 1.05...2.0, step: 0.05) } VStack(alignment: .leading, spacing: 4) { HStack { Text("Suavização (entrada/saída)").font(.caption) Spacer() Text(String(format: "%.2fs", zoomEase)).font(.caption).foregroundStyle(.secondary).monospacedDigit() } Slider(value: $zoomEase, in: 0.1...1.0, step: 0.05) } Button { addZoom() } label: { if isAddingZoom { ProgressView().controlSize(.small) } Label("Aplicar Zoom", systemImage: "plus.magnifyingglass") } .disabled(isRunning || isAddingZoom || projectPath == nil || (selectedZoomClip == nil && zoomClipName.trimmingCharacters(in: .whitespaces).isEmpty) || Double(zoomStart) == nil || Double(zoomEnd) == nil) if let zoomResultPath { resultRow(zoomResultPath) } } .disabled(outputFolder == nil || results.isEmpty) } .formStyle(.grouped) .task { await loadCatalog(); loadProjectConfig(); loadZoomClips(); loadSilenceConfig() } .onChange(of: projectPath) { _, newValue in loadZoomClips() saveProjectConfig(file: newValue ?? "") } .onChange(of: outputFolder) { _, newValue in loadZoomClips() saveProjectConfig(folder: newValue ?? "") } } /// Reabre o último projeto salvo em ~/.fcp-mcp-server/config.json — pasta /// de saída e arquivo — para a tela não começar vazia a cada abertura. /// Só preenche o que ainda está vazio, então o projeto aberto pela aba /// Projeto (embedded) nunca é sobrescrito pelo que ficou salvo. Caminhos /// que sumiram do disco voltam vazios do Python e são ignorados aqui. private func loadProjectConfig() { PythonBridge.call(command: "project_config") { result, _ in DispatchQueue.main.async { guard let result, result["ok"] as? Bool == true else { return } if outputFolder == nil, let folder = result["folder"] as? String, !folder.isEmpty { outputFolder = folder } if projectPath == nil, let file = result["file"] as? String, !file.isEmpty { projectPath = file } } } } /// Grava a pasta e/ou o arquivo do projeto no config compartilhado. Campos /// omitidos ficam como estão; string vazia limpa o campo. private func saveProjectConfig(folder: String? = nil, file: String? = nil) { var arguments: [String: Any] = [:] if let folder { arguments["folder"] = folder } if let file { arguments["file"] = file } guard !arguments.isEmpty else { return } PythonBridge.call(command: "set_project_config", arguments: arguments) { _, _ in } } /// Presents an NSOpenPanel configured to select a single file (not a /// folder), filtered to the Final Cut project extensions. private func pickProjectFile() { let panel = NSOpenPanel() panel.canChooseFiles = true panel.canChooseDirectories = false panel.allowsMultipleSelection = false panel.prompt = "Selecionar" panel.message = "Selecione o arquivo de projeto (.fcpxml) exportado pelo Final Cut Pro." if panel.runModal() == .OK, let url = panel.url { let ext = url.pathExtension.lowercased() if ext == "fcpxml" || ext == "xml" { projectPath = url.path } else { errorMessage = "Selecione um arquivo .fcpxml ou .xml do Final Cut Pro." } } } /// Lê os limiares de silêncio salvos em ~/.fcp-mcp-server/config.json — /// os mesmos que remove_media_silence usa por padrão — para os controles /// abrirem já mostrando o que de fato vai rodar. private func loadSilenceConfig() { PythonBridge.call(command: "silence_config") { result, _ in DispatchQueue.main.async { guard let result, result["ok"] as? Bool == true else { return } silencePadding = result["padding"] as? Double ?? silencePadding silenceNoiseDb = result["noise_db"] as? Double ?? silenceNoiseDb silenceMinDuration = result["min_silence"] as? Double ?? silenceMinDuration } } } private func saveSilenceConfig() { PythonBridge.call(command: "set_silence_config", arguments: [ "padding": silencePadding, "noise_db": silenceNoiseDb, "min_silence": silenceMinDuration, ]) { _, _ in } } /// Escolhe o JSON de decisões (cortes/zooms/textos/marcadores) que /// `apply_voice_actions` vai aplicar. Pré-seleciona a pasta do projeto, /// que é onde o arquivo costuma ser gravado. private func pickVoiceActions() { let panel = NSOpenPanel() panel.canChooseFiles = true panel.canChooseDirectories = false panel.allowsMultipleSelection = false panel.allowedContentTypes = [.json] panel.prompt = "Usar este arquivo" panel.message = "Selecione o JSON com a lista de decisões da edição por voz." if let outputFolder { panel.directoryURL = URL(fileURLWithPath: outputFolder) } if panel.runModal() == .OK, let url = panel.url { voiceActionsPath = url.path } } 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 todos os resultados serão salvos." if panel.runModal() == .OK, let url = panel.url { outputFolder = url.path } } private var hasNoInstalledModel: Bool { (catalog?.installedCount ?? 0) == 0 } private var projectName: String { guard let p = projectPath else { return "Nenhum projeto selecionado" } return URL(fileURLWithPath: p).lastPathComponent } private func loadCatalog() async { PythonBridge.call(command: "catalog") { result, _ in DispatchQueue.main.async { if let result { catalog = Catalog(json: result) } } } } private func startTranscription() { guard let projectPath, !projectPath.isEmpty, let outputFolder else { return } isRunning = true errorMessage = "" results = [] progress = 0 stage = "" PythonBridge.run(command: "transcribe", arguments: [ "path": projectPath, "output_dir": outputFolder, ]) { obj in DispatchQueue.main.async { let type = obj["type"] as? String if type == "progress" { progress = (obj["fraction"] as? NSNumber)?.doubleValue ?? 0 stage = 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]] { results = arr.map(TranscriptResult.init) } } } completion: { code, err in DispatchQueue.main.async { isRunning = false progress = 1 if code != 0 && results.isEmpty && (errorMessage?.isEmpty ?? true) { errorMessage = err ?? "A transcrição falhou." } } } } /// Renders a toggle plus its optional expanded detail block, indented and /// visually tied together so batch options don't collapse into one wall /// of controls with no breathing room. @ViewBuilder private func batchOptionRow( toggle: Toggle, expanded: Bool, @ViewBuilder detail: () -> Detail ) -> some View { VStack(alignment: .leading, spacing: 12) { toggle if expanded { detail() .padding(.leading, 20) .padding(.vertical, 10) .padding(.trailing, 8) .background( RoundedRectangle(cornerRadius: 8) .fill(Color.secondary.opacity(0.06)) ) } } } @ViewBuilder private func resultRow(_ path: String) -> some View { Text(path).font(.caption).lineLimit(1) HStack { Button("Abrir no Final Cut Pro") { NSWorkspace.shared.open(URL(fileURLWithPath: path)) } Button("Mostrar no Finder") { NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: path)]) } } } private func removeFillerWords() { guard let projectPath, let outputFolder else { return } isRemovingFillers = true errorMessage = nil PythonBridge.call(command: "remove_filler_words", arguments: ["path": projectPath, "output_dir": outputFolder]) { result, err in DispatchQueue.main.async { isRemovingFillers = false if result?["ok"] as? Bool == true { fillerRemovedPath = result?["path"] as? String } else { errorMessage = result?["error"] as? String ?? err ?? "Falha ao remover palavras de preenchimento." } } } } private func editByTranscript() { guard let projectPath, let outputFolder else { return } let phrases = phraseInput.split(separator: ",") .map { $0.trimmingCharacters(in: .whitespaces) } .filter { !$0.isEmpty } guard !phrases.isEmpty else { return } isEditingByTranscript = true errorMessage = nil PythonBridge.call(command: "edit_by_transcript", arguments: [ "path": projectPath, "phrases": phrases, "output_dir": outputFolder, ]) { result, err in DispatchQueue.main.async { isEditingByTranscript = false if result?["ok"] as? Bool == true { transcriptEditPath = result?["path"] as? String } else { errorMessage = result?["error"] as? String ?? err ?? "Falha ao cortar por frase." } } } } private func importTranscriptMarkers() { guard let projectPath, let outputFolder else { return } isImportingMarkers = true errorMessage = nil PythonBridge.call(command: "transcript_markers", arguments: ["path": projectPath, "output_dir": outputFolder]) { result, err in DispatchQueue.main.async { isImportingMarkers = false if result?["ok"] as? Bool == true { transcriptMarkersPath = result?["path"] as? String } else { errorMessage = result?["error"] as? String ?? err ?? "Falha ao marcar o timeline." } } } } private func exportSubtitles() { // Export captions from the silence-removed cut when one exists, so the // .srt matches the frames that actually remain; otherwise fall back to // the project that is open. let target = (processedPath?.isEmpty == false) ? processedPath! : projectPath guard let target, let outputFolder else { return } isExportingSubtitles = true errorMessage = nil subtitlePaths = [] PythonBridge.call(command: "export_srt", arguments: ["path": target, "output_dir": outputFolder]) { result, err in DispatchQueue.main.async { isExportingSubtitles = false if result?["ok"] as? Bool == true { subtitlePaths = result?["paths"] as? [String] ?? [] } else { errorMessage = result?["error"] as? String ?? err ?? "Falha ao exportar legendas." } } } } private func addZoom() { guard let projectPath, let outputFolder, let start = Double(zoomStart), let end = Double(zoomEnd) else { return } let clipName = selectedZoomClip?.name ?? zoomClipName.trimmingCharacters(in: .whitespaces) guard !clipName.isEmpty else { return } isAddingZoom = true errorMessage = nil PythonBridge.call(command: "add_zoom", arguments: [ "path": projectPath, "clip_id": clipName, "start": start, "end": end, "scale": zoomScale, "ease": zoomEase, "output_dir": outputFolder, ]) { result, err in DispatchQueue.main.async { isAddingZoom = false if result?["ok"] as? Bool == true { zoomResultPath = result?["path"] as? String } else { errorMessage = result?["error"] as? String ?? err ?? "Falha ao aplicar o zoom." } } } } private var selectedZoomClip: ZoomClip? { zoomClips.first { $0.id == selectedZoomClipID } } private func loadZoomClips() { guard let projectPath, let outputFolder else { zoomClips = []; return } PythonBridge.call(command: "zoom_clips", arguments: ["path": projectPath, "output_dir": outputFolder]) { result, err in DispatchQueue.main.async { if result?["ok"] as? Bool == true { zoomClips = (result?["clips"] as? [[String: Any]] ?? []).map(ZoomClip.init) if selectedZoomClipID.isEmpty { selectedZoomClipID = zoomClips.first?.id ?? "" } } else if let err { errorMessage = err } } } } private func loadZoomSegments() { guard let projectPath, let outputFolder, let clip = selectedZoomClip else { return } zoomSegments = []; selectedZoomStartID = nil; selectedZoomEndID = nil PythonBridge.call(command: "zoom_segments", arguments: ["path": projectPath, "index": clip.index, "output_dir": outputFolder]) { result, err in DispatchQueue.main.async { if result?["ok"] as? Bool == true { zoomSegments = (result?["segments"] as? [[String: Any]] ?? []).map(ZoomSegment.init) zoomMessage = result?["message"] as? String ?? "" zoomClipName = clip.name } else { errorMessage = result?["error"] as? String ?? err } } } } private func updateZoomTimes() { guard let startID = selectedZoomStartID, let endID = selectedZoomEndID, let start = zoomSegments.first(where: { $0.id == startID }), let end = zoomSegments.first(where: { $0.id == endID }) else { return } zoomStart = String(format: "%.3f", start.start) zoomEnd = String(format: "%.3f", end.end) } private func removeSilences() { guard let projectPath else { return } isRemovingSilences = true errorMessage = nil // Sem repassar limiares: remove_silences lê os valores salvos em // ~/.fcp-mcp-server/config.json (os mesmos que os controles acima // gravam), então há uma só fonte da verdade. PythonBridge.call(command: "remove_silences", arguments: [ "path": projectPath, ]) { result, err in DispatchQueue.main.async { isRemovingSilences = false if result?["ok"] as? Bool == true { processedPath = result?["path"] as? String } else { errorMessage = result?["error"] as? String ?? err ?? "Falha ao remover silêncios." } } } } private func processBatch() { guard let projectPath, let outputFolder else { return } var operations: [String] = [] // Analysis first: it only writes a JSON sidecar next to the source // media and never touches the project XML, so its position relative // to the other steps doesn't change what they do — but running it // before any cut keeps the mental model simple (measure, then edit). if batchVoiceAnalysis { operations.append("analyze_voice") } // The edit itself, and it has to run FIRST on the intact timeline: // every zoom/text/marker it places is positioned by shifting from its // own cut list, so a step that already rippled the timeline would put // them on the wrong frame — with no visible error. if batchVoiceEdit { operations.append("apply_voice_actions") } if batchSilences { operations.append("remove_silences") } if batchFillers { operations.append("remove_filler_words") } if batchPhrases { operations.append("edit_by_transcript") } if batchMarkers { operations.append("transcript_markers") } if batchSubtitles { operations.append("generate_plain_subtitles") } // Runs last, on the timing already cut by any earlier steps (see the // "Abrir no Final Cut Pro" fallback chain and exportSubtitles()'s own // preference for `processedPath` — same reasoning). if batchDynamicSubtitles { operations.append("generate_dynamic_subtitles") } isBatchProcessing = true errorMessage = nil batchStatus = "Iniciando processamento…" processBatchStep(operations, index: 0, currentPath: projectPath, outputFolder: outputFolder) } private func processBatchStep(_ operations: [String], index: Int, currentPath: String, outputFolder: String) { guard index < operations.count else { isBatchProcessing = false batchStatus = "Processamento concluído. Resultados salvos na pasta escolhida." return } let operation = operations[index] batchStatus = "Processando: \(operation)…" var arguments: [String: Any] = ["path": currentPath, "output_dir": outputFolder] if operation == "apply_voice_actions", let voiceActionsPath { arguments["actions_path"] = voiceActionsPath } if operation == "edit_by_transcript" { let phrases = phraseInput.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty } arguments["phrases"] = phrases } // O estilo das legendas não é repassado aqui: generate_dynamic_subtitles // lê ~/.fcp-mcp-server/config.json (o mesmo arquivo que a aba "Legendas // Dinâmicas" grava) como seu próprio padrão, então há uma só fonte da // verdade em vez de duas cópias podendo divergir. PythonBridge.call(command: operation, arguments: arguments) { result, err in DispatchQueue.main.async { guard result?["ok"] as? Bool == true else { isBatchProcessing = false errorMessage = result?["error"] as? String ?? err ?? "Falha em \(operation)." batchStatus = "Processamento interrompido." return } let nextPath = result?["path"] as? String ?? currentPath if operation == "remove_silences" { processedPath = nextPath } if operation == "generate_plain_subtitles" { subtitlePaths = [nextPath] } if operation == "generate_dynamic_subtitles" { dynamicSubtitlesPath = nextPath } processBatchStep(operations, index: index + 1, currentPath: nextPath, outputFolder: outputFolder) } } } }