Files
gart/code/MacApp/Sources/TranscriptionView.swift

844 lines
40 KiB
Swift

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("Exportar legendas SRT", 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: View, Detail: View>(
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("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 == "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 == "export_srt" { subtitlePaths = result?["paths"] as? [String] ?? [] }
if operation == "generate_dynamic_subtitles" { dynamicSubtitlesPath = nextPath }
processBatchStep(operations, index: index + 1, currentPath: nextPath, outputFolder: outputFolder)
}
}
}
}