844 lines
40 KiB
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)
|
|
}
|
|
}
|
|
}
|
|
}
|