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

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)
}
}
}
}