chore: adiciona .gitignore e commit.command
This commit is contained in:
@@ -0,0 +1,670 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user