671 lines
31 KiB
Swift
671 lines
31 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 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)
|
|
}
|
|
}
|
|
}
|
|
}
|