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 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 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 dynamicSubtitlesBandHeight: Double = 0.22 @State private var dynamicSubtitlesBlockCenterY: Double = -167 @State private var dynamicSubtitlesFont = "Helvetica Neue" @State private var dynamicSubtitlesFontSize: Double = 90 @State private var dynamicSubtitlesActiveColor = Color.white @State private var dynamicSubtitlesInactiveColor = Color(white: 0.7) @State private var dynamicSubtitlesPath: String? @State private var isBatchProcessing = false @State private var batchStatus = "" private let dynamicSubtitlesFontChoices = [ "Helvetica Neue", "Helvetica", "Arial", "Avenir Next", "Futura", "SF Pro Display", "Georgia", "Impact", ] 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) } .disabled(outputFolder == nil) if let errorMessage, !errorMessage.isEmpty { Section { Label(errorMessage, systemImage: "exclamationmark.triangle.fill") .foregroundStyle(.red) } } if isRunning { Section { VStack(alignment: .leading, spacing: 8) { ProgressView(value: progress) Text("\(Int(progress * 100))%").font(.caption).foregroundStyle(.secondary) } } } 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) } GroupBox("Processar em lote") { VStack(alignment: .leading, spacing: 6) { Toggle("Remover silêncios do áudio", isOn: $batchSilences) VStack(alignment: .leading, spacing: 4) { HStack { Text("Tolerância do corte") .foregroundStyle(batchSilences ? .primary : .secondary) Spacer() Text(String(format: "%.2fs", silencePadding)) .font(.caption).foregroundStyle(.secondary).monospacedDigit() } Slider(value: $silencePadding, in: 0...2, step: 0.01) .disabled(!batchSilences) Text("Quanto de silêncio sobra em volta de cada corte.") .font(.caption2).foregroundStyle(.secondary) } .padding(.leading, 20) Toggle("Remover palavras de preenchimento", isOn: $batchFillers) Toggle("Cortar frases ditas", isOn: $batchPhrases) if batchPhrases { TextField("Frases separadas por vírgula", text: $phraseInput) .textFieldStyle(.roundedBorder) .padding(.leading, 20) } Toggle("Marcar o que foi dito na timeline", isOn: $batchMarkers) Toggle("Exportar legendas SRT", isOn: $batchSubtitles) Toggle("Gerar legendas dinâmicas (cascata, editáveis no FCP)", isOn: $batchDynamicSubtitles) if batchDynamicSubtitles { VStack(alignment: .leading, spacing: 6) { Picker("Fonte", selection: $dynamicSubtitlesFont) { ForEach(dynamicSubtitlesFontChoices, id: \.self) { Text($0).tag($0) } } HStack { Text("Tamanho da fonte") Spacer() Text("\(Int(dynamicSubtitlesFontSize))pt") .font(.caption).foregroundStyle(.secondary).monospacedDigit() } Slider(value: $dynamicSubtitlesFontSize, in: 20...200, step: 1) ColorPicker("Cor A", selection: $dynamicSubtitlesActiveColor, supportsOpacity: true) ColorPicker("Cor B", selection: $dynamicSubtitlesInactiveColor, supportsOpacity: true) HStack { Text("Altura do bloco") Spacer() Text("\(Int(dynamicSubtitlesBandHeight * 100))%") .font(.caption).foregroundStyle(.secondary).monospacedDigit() } Slider(value: $dynamicSubtitlesBandHeight, in: 0.10...0.50, step: 0.01) HStack { Text("Altura na tela") Spacer() Text("\(Int(dynamicSubtitlesBlockCenterY))") .font(.caption).foregroundStyle(.secondary).monospacedDigit() } Slider(value: $dynamicSubtitlesBlockCenterY, in: -700...300, step: 1) Text("Roda por último, depois dos outros passos marcados acima, usando o timing já cortado da timeline.") .font(.caption2).foregroundStyle(.secondary) } .padding(.leading, 20) } Button { processBatch() } label: { if isBatchProcessing { ProgressView().controlSize(.small) } Label("Processar selecionados", systemImage: "play.fill") } .disabled(isBatchProcessing || isRunning || projectPath == nil || outputFolder == nil || !batchSilences && !batchFillers && !batchPhrases && !batchMarkers && !batchSubtitles && !batchDynamicSubtitles) if !batchStatus.isEmpty { Text(batchStatus).font(.caption).foregroundStyle(.secondary) } HStack { 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) } } } if results.isEmpty { Text("Transcreva primeiro para habilitar os cortes por texto.") .font(.caption2).foregroundStyle(.secondary) } } .disabled(outputFolder == nil) Section("Zoom (aproximar em um trecho)") { 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) } .formStyle(.grouped) .task { await loadCatalog(); loadZoomClips() } .onChange(of: projectPath) { _, _ in loadZoomClips() } .onChange(of: outputFolder) { _, _ in loadZoomClips() } } /// 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." } } } 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." } } } } @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 PythonBridge.call(command: "remove_silences", arguments: [ "path": projectPath, "padding": silencePadding, ]) { 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] = [] 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("export_srt") } // 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 == "remove_silences" { arguments["padding"] = silencePadding } if operation == "edit_by_transcript" { let phrases = phraseInput.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty } arguments["phrases"] = phrases } if operation == "generate_dynamic_subtitles" { arguments["band_height"] = dynamicSubtitlesBandHeight arguments["block_center_y"] = dynamicSubtitlesBlockCenterY arguments["font"] = dynamicSubtitlesFont arguments["font_size"] = Int(dynamicSubtitlesFontSize) arguments["active_color"] = dynamicSubtitlesActiveColor.fcpxmlColorString arguments["inactive_color"] = dynamicSubtitlesInactiveColor.fcpxmlColorString } 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 == "export_srt" { subtitlePaths = result?["paths"] as? [String] ?? [] } if operation == "generate_dynamic_subtitles" { dynamicSubtitlesPath = nextPath } processBatchStep(operations, index: index + 1, currentPath: nextPath, outputFolder: outputFolder) } } } }