chore: adiciona .gitignore e commit.command

This commit is contained in:
João Henrique
2026-08-18 08:25:29 -04:00
parent 68958fde00
commit 8fca456ceb
215 changed files with 65752 additions and 0 deletions
+19
View File
@@ -0,0 +1,19 @@
import SwiftUI
struct AboutView: View {
var body: some View {
VStack(spacing: 16) {
Image(systemName: "waveform.circle.fill")
.font(.system(size: 72))
.foregroundStyle(.tint)
Text("G-ART")
.font(.largeTitle.weight(.bold))
Text("Transcrição e edição de depoimentos com Final Cut Pro.")
.font(.callout).foregroundStyle(.secondary)
Text("Motor de transcrição local (faster-whisper) + interface nativa macOS.")
.font(.caption).foregroundStyle(.secondary)
}
.padding(40)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
+55
View File
@@ -0,0 +1,55 @@
import SwiftUI
@main
struct GArtApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.frame(minWidth: 720, minHeight: 560)
}
.windowStyle(.automatic)
}
}
enum ActiveTab: Hashable {
case project
case captions
case models
case about
}
struct ContentView: View {
@State private var activeTab: ActiveTab? = .project
var body: some View {
NavigationSplitView {
List(selection: $activeTab) {
Label("Projeto", systemImage: "film")
.tag(ActiveTab.project)
Label("Legendas", systemImage: "captions.bubble")
.tag(ActiveTab.captions)
Label("Modelos", systemImage: "tray.and.arrow.down")
.tag(ActiveTab.models)
Label("Sobre", systemImage: "info.circle")
.tag(ActiveTab.about)
}
.listStyle(.sidebar)
.navigationSplitViewColumnWidth(min: 180, ideal: 200)
} detail: {
switch activeTab {
case .project:
ProjectView().id(UUID())
.navigationTitle("Projeto")
case .captions:
CaptionsView().id(UUID())
.navigationTitle("Legendas")
case .models:
ModelDownloadView().id(UUID())
.navigationTitle("Modelos")
case .about, nil:
AboutView()
.navigationTitle("Sobre")
}
}
}
}
+201
View File
@@ -0,0 +1,201 @@
import SwiftUI
import UniformTypeIdentifiers
/// Guia "Legendas" — configura e gera legendas dinâmicas como clipes de título
/// editáveis no Final Cut Pro (template "Essencial - Título"). Cada palavra
/// vira um clipe de título posicionado: as palavras da frase vão surgindo
/// conforme são faladas, se acumulam num bloco centralizado, e somem todas
/// juntas no fim da frase. Sem Compound Clip.
struct CaptionsView: View {
@State private var projectPath: String?
@State private var clipName = ""
// Posicionamento do bloco
@State private var bandHeight: Double = 0.22
@State private var blockCenterY: Double = -167
// Estilo
@State private var font = "Helvetica Neue"
@State private var fontSize: Double = 90
@State private var activeColor = Color.white
@State private var inactiveColor = Color(white: 0.7)
@State private var isGenerating = false
@State private var resultPath: String?
@State private var errorMessage: String?
private let fontChoices = [
"Helvetica Neue", "Helvetica", "Arial", "Avenir Next",
"Futura", "SF Pro Display", "Georgia", "Impact",
]
var body: some View {
Form {
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)
}
}
TextField("Nome do clipe (opcional — vazio = todos os clipes)", text: $clipName)
.textFieldStyle(.roundedBorder)
}
Section {
VStack(alignment: .leading, spacing: 6) {
HStack {
Text("Altura do bloco").font(.callout.weight(.medium))
Spacer()
Text("\(Int(bandHeight * 100))%").font(.caption).foregroundStyle(.secondary).monospacedDigit()
}
Slider(value: $bandHeight, in: 0.10...0.50, step: 0.01)
Text("Quanto da altura do quadro a frase pode ocupar antes de quebrar em outro bloco. Maior = mais palavras juntas na tela.")
.font(.caption2).foregroundStyle(.secondary)
}
.padding(.vertical, 4)
VStack(alignment: .leading, spacing: 6) {
HStack {
Text("Altura na tela").font(.callout.weight(.medium))
Spacer()
Text("\(Int(blockCenterY))").font(.caption).foregroundStyle(.secondary).monospacedDigit()
}
Slider(value: $blockCenterY, in: -700...300, step: 1)
Text("Posição vertical do bloco. 0 é o centro do quadro; valores negativos descem.")
.font(.caption2).foregroundStyle(.secondary)
}
.padding(.vertical, 4)
} header: {
Text("Posicionamento do Bloco")
} footer: {
Text("Cada palavra vira um clipe de título solto na timeline (sem Compound Clip), posicionado para não sobrepor as outras palavras da frase.")
.font(.caption2).foregroundStyle(.secondary)
}
Section("Estilo do Texto") {
Picker("Fonte", selection: $font) {
ForEach(fontChoices, id: \.self) { Text($0).tag($0) }
}
VStack(alignment: .leading, spacing: 6) {
HStack {
Text("Tamanho da fonte").font(.callout.weight(.medium))
Spacer()
Text("\(Int(fontSize))pt").font(.caption).foregroundStyle(.secondary).monospacedDigit()
}
Slider(value: $fontSize, in: 20...200, step: 1)
}
.padding(.vertical, 4)
ColorPicker("Cor A", selection: $activeColor, supportsOpacity: true)
ColorPicker("Cor B", selection: $inactiveColor, supportsOpacity: true)
Text("Tamanho, cor e estilo variam por palavra seguindo um ritmo fixo, calibrado a partir de um projeto real do Final Cut. As cores acima entram nesse ritmo.")
.font(.caption2).foregroundStyle(.secondary)
}
Section {
Button {
generate()
} label: {
if isGenerating { ProgressView().controlSize(.small) }
Label("Gerar Legendas Dinâmicas", systemImage: "captions.bubble.fill")
}
.disabled(isGenerating || projectPath == nil)
.frame(maxWidth: .infinity)
.buttonStyle(.borderedProminent)
if let errorMessage {
Label(errorMessage, systemImage: "exclamationmark.triangle.fill")
.foregroundStyle(.red)
}
if let resultPath {
Text(resultPath).font(.caption).lineLimit(1)
HStack {
Button("Abrir no Final Cut Pro") { NSWorkspace.shared.open(URL(fileURLWithPath: resultPath)) }
Button("Mostrar no Finder") {
NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: resultPath)])
}
}
}
} footer: {
Text("Usa a transcrição local (Whisper) já feita na aba Projeto/Transcrição. Se ainda não houver transcrição salva, ela é gerada automaticamente.")
.font(.caption2).foregroundStyle(.secondary)
}
}
.formStyle(.grouped)
}
private var projectName: String {
guard let p = projectPath else { return "Nenhum projeto selecionado" }
return URL(fileURLWithPath: p).lastPathComponent
}
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" || ext == "fcpxmld" {
projectPath = url.path
} else {
errorMessage = "Selecione um arquivo .fcpxml ou .xml do Final Cut Pro."
}
}
}
private func generate() {
guard let projectPath else { return }
isGenerating = true
errorMessage = nil
resultPath = nil
var args: [String: Any] = [
"path": projectPath,
"band_height": bandHeight,
"block_center_y": blockCenterY,
"font": font,
"font_size": Int(fontSize),
"active_color": activeColor.fcpxmlColorString,
"inactive_color": inactiveColor.fcpxmlColorString,
]
let trimmedClip = clipName.trimmingCharacters(in: .whitespaces)
if !trimmedClip.isEmpty {
args["clip_name"] = trimmedClip
}
PythonBridge.call(command: "generate_dynamic_subtitles", arguments: args) { result, err in
DispatchQueue.main.async {
isGenerating = false
if result?["ok"] as? Bool == true {
resultPath = result?["path"] as? String
} else {
errorMessage = result?["error"] as? String ?? err ?? "Falha ao gerar legendas dinâmicas."
}
}
}
}
}
extension Color {
/// Converts to FCPXML's "R G B A" space-separated 0-1 string (sRGB).
var fcpxmlColorString: String {
let ns = NSColor(self).usingColorSpace(.sRGB) ?? NSColor(self)
return String(
format: "%.4g %.4g %.4g %.4g",
ns.redComponent, ns.greenComponent, ns.blueComponent, ns.alphaComponent
)
}
}
+399
View File
@@ -0,0 +1,399 @@
import AppKit
import SwiftUI
/// Guia "Modelos" — replica a UI de gerenciamento de modelos do Hex:
/// cards curados com badge "Recomendado", estrelas de precisão/velocidade,
/// estado Instalado/Em uso, barra de progresso de download, e ações
/// Download / Selecionar / Mostrar no Finder / Remover.
struct ModelDownloadView: View {
@State private var catalog: Catalog?
@State private var downloading: String?
@State private var progress: Double = 0
@State private var isLoading = false
@State private var errorMessage: String?
@State private var modelsDirText: String = ""
@State private var hfTokenText: String = ""
@State private var numSpeakersText: String = ""
@State private var language: String = "auto"
private let languages: [(String, String)] = [
("auto", "Detectar automaticamente"),
("pt", "Português"),
("en", "Inglês"),
("es", "Espanhol"),
("fr", "Francês"),
("de", "Alemão"),
("it", "Italiano"),
("nl", "Holandês"),
("ja", "Japonês"),
("ko", "Coreano"),
("zh", "Chinês"),
]
var body: some View {
Form {
storageSection
diarizationSection
if let errorMessage {
Section {
Label(errorMessage, systemImage: "exclamationmark.triangle.fill")
.foregroundStyle(.red)
}
}
Section {
if isLoading {
ProgressView().controlSize(.small)
.frame(maxWidth: .infinity, alignment: .center)
} else if let catalog {
if catalog.installedCount == 0 {
noModelChooser(catalog)
}
ForEach(catalog.orderedModels) { model in
modelRow(model)
}
}
} header: {
Text("Modelos de Transcrição")
} footer: {
Label("Os modelos rodam localmente nesta máquina. Downloads ficam na pasta configurada acima.",
systemImage: "lock.shield")
.font(.caption)
.foregroundStyle(.secondary)
}
if catalog?.installedCount ?? 0 > 0 {
languageSection
}
}
.formStyle(.grouped)
.task { await refresh() }
}
// MARK: - Transcription language
/// Idiooma padrão usado em todas as transcrições do programa. Aparece abaixo
/// do modelo escolhido, conforme o modelo selecionado na aba Modelos.
private var languageSection: some View {
Section {
Picker("Idioma", selection: $language) {
ForEach(languages, id: \.0) { code, label in
Text(label).tag(code)
}
}
} header: {
Text("Idioma de Transcrição")
} footer: {
Text("Usado em todas as transcrições do programa. Escolha conforme o modelo selecionado acima.")
.font(.caption)
.foregroundStyle(.secondary)
}
.onChange(of: language) { _, newValue in
saveLanguage(newValue)
}
}
private func saveLanguage(_ code: String) {
PythonBridge.call(command: "set_language", arguments: ["language": code]) { _, _ in }
}
// MARK: - Diarization
private var diarizationSection: some View {
Section {
VStack(alignment: .leading, spacing: 10) {
if let msg = catalog?.diarizationMessage, !msg.isEmpty {
Label(msg, systemImage: catalog?.diarization == true ? "checkmark.circle" : "exclamationmark.triangle")
.font(.caption)
.foregroundStyle(catalog?.diarization == true ? Color.secondary : Color.orange)
}
SecureField("Token HuggingFace (diarização)", text: $hfTokenText)
.textFieldStyle(.roundedBorder)
.help("Token com acesso aos modelos gated pyannote (segmentation + speaker-diarization)")
HStack {
TextField("Nº de participantes (vazio = automático)", text: $numSpeakersText)
.textFieldStyle(.roundedBorder)
Button("Salvar") { saveDiarization() }
.disabled(isLoading)
}
}
} header: {
Text("Diarização (participantes)")
} footer: {
Text("Opcional. Sem token, cada fala é atribuída a um único participante padrão.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
private func saveDiarization() {
var args: [String: Any] = ["num_speakers": numSpeakersText]
if !hfTokenText.isEmpty {
args["token"] = hfTokenText
}
PythonBridge.call(command: "set_diarization", arguments: args) { result, _ in
DispatchQueue.main.async {
hfTokenText = ""
numSpeakersText = result?["num_speakers"] as? String ?? numSpeakersText
Task { await refresh() }
}
}
}
// MARK: - Storage
private var storageSection: some View {
Section("Local de Armazenamento") {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
TextField("Pasta de modelos", text: $modelsDirText)
.textFieldStyle(.roundedBorder)
.disabled(true)
Button {
pickAndSaveFolder()
} label: {
Image(systemName: "folder")
}
.help("Selecionar pasta de modelos e salvar")
}
}
}
}
/// Present an NSOpenPanel to choose a folder, then save it and refresh the
/// installed-model list so newly scanned downloads appear immediately.
private func pickAndSaveFolder() {
let panel = NSOpenPanel()
panel.canChooseDirectories = true
panel.canChooseFiles = false
panel.allowsMultipleSelection = false
panel.prompt = "Selecionar"
panel.message = "Escolha a pasta onde os modelos serão salvos"
if let current = URL(string: modelsDirText), FileManager.default.fileExists(atPath: current.path) {
panel.directoryURL = current
}
if panel.runModal() == .OK, let url = panel.url {
modelsDirText = url.path
saveDir()
}
}
// MARK: - No model chooser (first run)
private func noModelChooser(_ catalog: Catalog) -> some View {
let recommended = catalog.orderedModels.filter { $0.isRecommended }
return VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 12) {
Image(systemName: "arrow.down.circle.fill")
.font(.title3)
.foregroundStyle(.tint)
VStack(alignment: .leading, spacing: 3) {
Text("Escolha um modelo de transcrição")
.font(.body.weight(.medium))
Text("Baixe um modelo local para começar a transcrever.")
.font(.caption).foregroundStyle(.secondary)
}
}
if !recommended.isEmpty {
HStack(spacing: 10) {
ForEach(recommended) { model in
noModelCard(model)
}
}
}
}
.padding(10)
.background(Color(nsColor: .controlBackgroundColor),
in: RoundedRectangle(cornerRadius: 8))
}
private func noModelCard(_ model: ModelInfo) -> some View {
Button {
startDownload(model)
} label: {
VStack(alignment: .leading, spacing: 8) {
HStack(alignment: .top, spacing: 8) {
Text(model.displayName).font(.body.weight(.medium))
Spacer(minLength: 8)
Text("Recomendado")
.font(.caption2.weight(.medium))
.foregroundStyle(.tint)
.padding(.horizontal, 6).padding(.vertical, 2)
.background(Color.accentColor.opacity(0.12), in: Capsule())
}
HStack(spacing: 6) {
Text(model.language)
Text("·")
Text(model.storage)
Spacer()
Image(systemName: "arrow.down.circle")
}
.font(.caption).foregroundStyle(.secondary)
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color(nsColor: .windowBackgroundColor).opacity(0.45),
in: RoundedRectangle(cornerRadius: 8))
.overlay(RoundedRectangle(cornerRadius: 8).stroke(Color.accentColor.opacity(0.4), lineWidth: 1))
}
.buttonStyle(.plain)
}
// MARK: - Model row
private func modelRow(_ model: ModelInfo) -> some View {
let isThisDownloading = downloading == model.internalName
return HStack(spacing: 0) {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 7) {
Text(model.displayName).font(.body.weight(.medium))
if model.isRecommended && !model.isDownloaded {
badge("Recomendado", color: .blue)
}
if model.isDownloaded {
badge("Instalado", color: .green)
}
if model.isSelected {
badge("Em uso", color: .purple)
}
}
HStack(spacing: 10) {
Text(model.language).font(.caption).foregroundStyle(.secondary)
rating("Precisão", filled: model.accuracy)
rating("Velocidade", filled: model.speed)
Spacer()
Text(model.storage).font(.caption).foregroundStyle(.secondary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
trailingControls(for: model, isDownloading: isThisDownloading)
}
.padding(.vertical, 6)
}
private func rating(_ label: String, filled: Int) -> some View {
HStack(spacing: 4) {
Text(label).font(.caption2).foregroundStyle(.secondary)
HStack(spacing: 3) {
ForEach(0..<5, id: \.self) { i in
Image(systemName: i < filled ? "circle.fill" : "circle")
.font(.system(size: 7))
.foregroundColor(i < filled ? .blue : .gray.opacity(0.5))
}
}
}
}
private func badge(_ text: String, color: Color) -> some View {
Text(text)
.font(.caption2.weight(.medium))
.foregroundStyle(color)
.padding(.horizontal, 6).padding(.vertical, 2)
.background(color.opacity(0.12), in: Capsule())
}
@ViewBuilder
private func trailingControls(for model: ModelInfo, isDownloading: Bool) -> some View {
if isDownloading {
HStack(spacing: 8) {
ProgressView(value: progress).progressViewStyle(.linear).frame(width: 120)
Text("\(Int(progress * 100))%").font(.caption).monospacedDigit().foregroundStyle(.secondary)
Button("Cancelar", role: .destructive) { cancelDownload(model) }
.controlSize(.small)
}
} else if model.isDownloaded {
if model.isSelected {
Label("Em uso", systemImage: "checkmark.circle.fill")
.foregroundStyle(.green).font(.callout)
} else {
Button("Selecionar") { select(model) }
.buttonStyle(.borderedProminent).controlSize(.small)
}
Menu {
Button { openInFinder(model) } label: {
Label("Mostrar no Finder", systemImage: "folder")
}
Divider()
Button(role: .destructive) { delete(model) } label: {
Label("Remover Download…", systemImage: "trash")
}
} label: {
Image(systemName: "ellipsis.circle").foregroundStyle(.secondary)
}
.menuStyle(.borderlessButton).fixedSize()
} else {
Button("Download") { startDownload(model) }
.buttonStyle(.borderedProminent).controlSize(.small)
}
}
// MARK: - Actions
private func refresh() async {
isLoading = true
errorMessage = nil
PythonBridge.call(command: "catalog") { result, err in
DispatchQueue.main.async {
isLoading = false
if let result {
catalog = Catalog(json: result)
modelsDirText = catalog?.modelsDir ?? ""
downloading = nil
progress = 0
numSpeakersText = catalog?.numSpeakers ?? ""
language = catalog?.language ?? "auto"
} else {
errorMessage = err ?? "Falha ao carregar modelos."
}
}
}
}
private func startDownload(_ model: ModelInfo) {
downloading = model.internalName
progress = 0
PythonBridge.run(command: "download", arguments: ["model": model.internalName]) { obj in
DispatchQueue.main.async {
if let f = obj["fraction"] as? NSNumber { progress = f.doubleValue }
if obj["type"] as? String == "error" {
errorMessage = obj["message"] as? String
}
}
} completion: { _, _ in
DispatchQueue.main.async { Task { await refresh() } }
}
}
private func cancelDownload(_ model: ModelInfo) {
PythonBridge.call(command: "cancel", arguments: ["model": model.internalName]) { _, _ in }
}
private func select(_ model: ModelInfo) {
PythonBridge.call(command: "select", arguments: ["model": model.internalName]) { _, _ in
DispatchQueue.main.async { Task { await refresh() } }
}
}
private func delete(_ model: ModelInfo) {
PythonBridge.call(command: "delete", arguments: ["model": model.internalName]) { _, _ in
DispatchQueue.main.async { Task { await refresh() } }
}
}
private func openInFinder(_ model: ModelInfo) {
PythonBridge.call(command: "open_finder", arguments: ["model": model.internalName]) { _, _ in }
}
private func saveDir() {
let path = modelsDirText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !path.isEmpty else { return }
PythonBridge.call(command: "set_models_dir", arguments: ["dir": path]) { result, _ in
DispatchQueue.main.async {
if let result, result["ok"] as? Bool == true {
self.catalog?.modelsDir = result["models_dir"] as? String ?? ""
self.modelsDirText = result["models_dir"] as? String ?? path
Task { await refresh() }
}
}
}
}
}
+135
View File
@@ -0,0 +1,135 @@
import Foundation
/// A curated transcription model (mirrors `fcpxml/models.json`).
struct ModelInfo: Identifiable {
let displayName: String
let internalName: String
let language: String
let storage: String
let accuracy: Int
let speed: Int
var isDownloaded: Bool
var isSelected: Bool
var isRecommended: Bool
var id: String { internalName }
}
/// Full catalog snapshot returned by the `catalog` command.
struct Catalog {
var models: [ModelInfo]
var selected: String
var language: String
var modelsDir: String
var installedCount: Int
var diarization: Bool
var diarizationMessage: String
var hfTokenSet: Bool
var numSpeakers: String
init(json: [String: Any]) {
let recommended = Set((json["recommended"] as? [String] ?? []))
let installed = Set((json["installed"] as? [String] ?? []))
let selected = json["selected"] as? String ?? ""
let rawModels = json["models"] as? [[String: Any]] ?? []
var parsed: [ModelInfo] = []
for m in rawModels {
let name = m["internal_name"] as? String ?? ""
parsed.append(ModelInfo(
displayName: m["display_name"] as? String ?? name,
internalName: name,
language: m["language"] as? String ?? "",
storage: m["storage"] as? String ?? "",
accuracy: m["accuracy"] as? Int ?? 0,
speed: m["speed"] as? Int ?? 0,
isDownloaded: installed.contains(name),
isSelected: name == selected,
isRecommended: recommended.contains(name)
))
}
self.models = parsed
self.selected = selected
self.language = json["language"] as? String ?? "auto"
self.modelsDir = json["models_dir"] as? String ?? ""
self.installedCount = json["installed_count"] as? Int ?? 0
self.diarization = json["diarization"] as? Bool ?? false
self.diarizationMessage = json["diarization_message"] as? String ?? ""
self.hfTokenSet = json["hf_token_set"] as? Bool ?? false
self.numSpeakers = json["num_speakers"] as? String ?? ""
}
/// Models sorted: recommended first, then by download state, then name.
var orderedModels: [ModelInfo] {
models.sorted {
if $0.isDownloaded != $1.isDownloaded { return $0.isDownloaded }
if $0.isRecommended != $1.isRecommended { return $0.isRecommended }
return $0.displayName < $1.displayName
}
}
}
/// A single transcript result from the `transcribe` command.
struct TranscriptResult {
let media: String
let language: String
let words: Int
let duration: Double
let preview: String
let saved: String
let speakers: [String]
init(json: [String: Any]) {
media = json["media"] as? String ?? ""
language = json["language"] as? String ?? "?"
words = json["words"] as? Int ?? 0
duration = json["duration"] as? Double ?? 0
preview = json["preview"] as? String ?? ""
saved = json["saved"] as? String ?? ""
speakers = json["speakers"] as? [String] ?? []
}
}
struct ZoomClip: Identifiable {
let id: String
let index: Int
let name: String
let start: Double
let duration: Double
let media: String
let preview: String
let hasTranscript: Bool
init(json: [String: Any]) {
id = json["id"] as? String ?? ""
index = json["index"] as? Int ?? 0
name = json["name"] as? String ?? "Clipe"
start = json["start"] as? Double ?? 0
duration = json["duration"] as? Double ?? 0
media = json["media"] as? String ?? ""
preview = json["preview"] as? String ?? ""
hasTranscript = json["has_transcript"] as? Bool ?? false
}
var label: String {
let time = String(format: "%02d:%02d", Int(start) / 60, Int(start) % 60)
return "Clipe \(index + 1) · \(name) · \(time)"
}
}
struct ZoomSegment: Identifiable {
let id: Int
let start: Double
let end: Double
let text: String
init(json: [String: Any]) {
id = json["id"] as? Int ?? 0
start = json["start"] as? Double ?? 0
end = json["end"] as? Double ?? 0
text = json["text"] as? String ?? ""
}
var label: String {
String(format: "%06.2f–%06.2f %@", start, end, text)
}
}
+251
View File
@@ -0,0 +1,251 @@
import SwiftUI
import UniformTypeIdentifiers
/// Project summary returned by the `inspect` command.
struct TimelineSummary {
let name: String
let duration: Double
let frameRate: Double
let width: Int
let height: Int
let clips: Int
let cuts: Int
let connected: Int
let markers: Int
init(json: [String: Any]) {
name = json["name"] as? String ?? "Untitled"
duration = json["duration_seconds"] as? Double ?? 0
frameRate = json["frame_rate"] as? Double ?? 0
width = json["width"] as? Int ?? 0
height = json["height"] as? Int ?? 0
clips = json["clips"] as? Int ?? 0
cuts = json["cuts"] as? Int ?? 0
connected = json["connected"] as? Int ?? 0
markers = json["markers"] as? Int ?? 0
}
var durationText: String {
let t = Int(duration)
return String(format: "%02d:%02d:%02d", t / 3600, (t % 3600) / 60, t % 60)
}
}
struct ProjectInfo {
let path: String
let name: String
let version: String
let timelines: [TimelineSummary]
init(json: [String: Any]) {
path = json["path"] as? String ?? ""
name = json["name"] as? String ?? "Untitled"
version = json["fcpxml_version"] as? String ?? "?"
timelines = (json["timelines"] as? [[String: Any]] ?? []).map(TimelineSummary.init)
}
}
/// Guia "Projeto" — primeira tela do app. Permite importar um arquivo FCPXML
/// clicando para selecionar ou arrastando e soltando do Final Cut Pro, e mostra
/// um resumo do projeto carregado.
struct ProjectView: View {
@State private var project: ProjectInfo?
@State private var isInspecting = false
@State private var errorMessage: String?
@State private var isTargeted = false
@State private var showTranscription = false
var body: some View {
Form {
Section {
dropZone
HStack {
Button("Selecionar Arquivo…") { pickProjectFile() }
Spacer()
if let project {
Button {
NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: project.path)])
} label: {
Image(systemName: "folder")
}
.help("Mostrar no Finder")
}
}
if showTranscription, let project {
TranscriptionView(projectPath: project.path, embedded: true)
}
} header: {
Text("Projeto do Final Cut Pro")
} footer: {
Text("Selecione ou arraste o arquivo XML (.fcpxml) ou o bundle (.fcpxmld) exportado pelo Final Cut Pro.")
.font(.caption)
.foregroundStyle(.secondary)
}
if let errorMessage {
Section {
Label(errorMessage, systemImage: "exclamationmark.triangle.fill")
.foregroundStyle(.red)
}
}
if isInspecting {
Section {
ProgressView().controlSize(.small)
.frame(maxWidth: .infinity, alignment: .center)
}
}
if let project {
summarySection(project)
}
}
.formStyle(.grouped)
}
/// Presents an NSOpenPanel configured to select a single file (not a
/// folder). The panel filters to the project extensions that matter.
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 {
if isAccepted(url) {
inspect(url.path)
} else {
errorMessage = "Selecione um arquivo .fcpxml ou .xml do Final Cut Pro."
}
}
}
// MARK: - Drop zone
private var dropZone: some View {
VStack(spacing: 10) {
Image(systemName: isTargeted ? "arrow.down.doc.fill" : "doc.badge.arrow.down")
.font(.system(size: 40))
.foregroundStyle(isTargeted ? Color.accentColor : .secondary)
Text(isTargeted ? "Solte para importar" : "Arraste o XML do Final Cut Pro aqui")
.font(.callout.weight(.medium))
Text("Ou clique em “Selecionar Arquivo…” abaixo.")
.font(.caption).foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, minHeight: 120)
.padding()
.background(
RoundedRectangle(cornerRadius: 10)
.fill(Color.accentColor.opacity(isTargeted ? 0.12 : 0.05))
)
.overlay(
RoundedRectangle(cornerRadius: 10)
.strokeBorder(
Color.accentColor.opacity(isTargeted ? 1 : 0.4),
style: StrokeStyle(lineWidth: 2, dash: [6])
)
)
.onDrop(
of: [.fileURL],
isTargeted: $isTargeted
) { providers in
handleDrop(providers)
}
}
private func handleDrop(_ providers: [NSItemProvider]) -> Bool {
guard let provider = providers.first else { return false }
provider.loadItem(forTypeIdentifier: UTType.fileURL.identifier, options: nil) { item, _ in
var url: URL?
if let data = item as? Data {
url = URL(dataRepresentation: data, relativeTo: nil)
} else if let u = item as? URL {
url = u
}
if let url, isAccepted(url) {
DispatchQueue.main.async { inspect(url.path) }
} else {
DispatchQueue.main.async {
errorMessage = "Este arquivo não parece ser um projeto do Final Cut Pro."
}
}
}
return true
}
private func isAccepted(_ url: URL) -> Bool {
let ext = url.pathExtension.lowercased()
return ext == "fcpxml" || ext == "fcpxmld" || ext == "xml"
}
// MARK: - Summary
@ViewBuilder
private func summarySection(_ project: ProjectInfo) -> some View {
Section {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 10) {
Image(systemName: "film.fill")
.font(.title2)
.foregroundStyle(.tint)
VStack(alignment: .leading, spacing: 2) {
Text(project.name).font(.headline)
Text("FCPXML v\(project.version)")
.font(.caption).foregroundStyle(.secondary)
}
}
if project.timelines.isEmpty {
Text("Nenhuma timeline encontrada.")
.font(.callout).foregroundStyle(.secondary)
} else {
ForEach(project.timelines, id: \.name) { tl in
timelineRow(tl)
}
}
}
} header: {
Text("Resumo do Projeto")
}
}
private func timelineRow(_ tl: TimelineSummary) -> some View {
HStack(alignment: .top, spacing: 12) {
VStack(alignment: .leading, spacing: 4) {
Text(tl.name).font(.body.weight(.medium))
Text("\(tl.width) × \(tl.height) · \(formattedFPS(tl.frameRate)) fps")
.font(.caption).foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
VStack(alignment: .trailing, spacing: 4) {
Text(tl.durationText).font(.body.weight(.medium)).monospacedDigit()
Text("\(tl.clips) clipes · \(tl.cuts) cortes")
.font(.caption).foregroundStyle(.secondary)
}
}
.padding(.vertical, 2)
}
private func formattedFPS(_ fps: Double) -> String {
if fps == fps.rounded() { return String(format: "%.0f", fps) }
return String(format: "%.2f", fps)
}
// MARK: - Actions
private func inspect(_ path: String) {
isInspecting = true
errorMessage = nil
PythonBridge.call(command: "inspect", arguments: ["path": path]) { result, err in
DispatchQueue.main.async {
isInspecting = false
if let result, result["ok"] as? Bool == true {
project = ProjectInfo(json: result)
showTranscription = true
} else {
errorMessage = result?["error"] as? String ?? err ?? "Falha ao ler o projeto."
}
}
}
}
}
+163
View File
@@ -0,0 +1,163 @@
import Foundation
/// Bridges the SwiftUI app to the Python engine via the `admin/models_api.py`
/// JSON bridge. The app launches the script as a subprocess and reads
/// newline-delimited JSON on stdout (JSON-lines), so long-running commands can
/// stream progress.
enum PythonBridge {
/// Resolve the Python interpreter + script path.
/// Prefers `uv run` so extras are available; falls back to `python3`.
static var scriptURL: URL {
// Search order: cwd + ancestors, the app's own bundle root, home, and
// some common mount points (the repo often lives under /Volumes/...).
let cwd = URL(fileURLWithPath: FileManager.default.currentDirectoryPath)
var searchRoots: [URL] = []
var dir = cwd
for _ in 0..<8 { searchRoots.append(dir); dir.deleteLastPathComponent() }
if let bundle = Bundle.main.resourceURL {
searchRoots.append(bundle)
}
searchRoots.append(URL(fileURLWithPath: NSHomeDirectory()))
searchRoots.append(URL(fileURLWithPath: "/Volumes/Merongo/SISTEMAS/GENIAL SISTEMAS/G-ART"))
for root in searchRoots {
let candidate = root.appendingPathComponent("admin/models_api.py")
if FileManager.default.fileExists(atPath: candidate.path) {
return candidate
}
}
// Fall back to cwd.
return cwd.appendingPathComponent("admin/models_api.py")
}
/// Project root containing `admin/`.
static var projectRoot: URL {
scriptURL.deletingLastPathComponent().deletingLastPathComponent()
}
static var pythonCommand: [String] {
// Prefer `uv run python` (has extras); fall back to `python3`.
if let uv = findUV() {
return [uv, "run", "python", scriptURL.path]
}
return ["python3", scriptURL.path]
}
static var workingDirectory: URL {
projectRoot
}
/// Locate `uv` on PATH or in common install locations.
private static func findUV() -> String? {
if let onPath = which("uv") { return onPath }
let candidates = [
"/usr/local/bin/uv",
"/opt/homebrew/bin/uv",
"/opt/local/bin/uv",
"\(NSHomeDirectory())/.local/bin/uv",
"\(NSHomeDirectory())/.cargo/bin/uv",
"/Library/Frameworks/Python.framework/Versions/3.14/bin/uv",
]
for c in candidates where FileManager.default.isExecutableFile(atPath: c) {
return c
}
return nil
}
private static func which(_ name: String) -> String? {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
process.arguments = ["which", name]
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = Pipe()
do { try process.run() } catch { return nil }
process.waitUntilExit()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
let path = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)
return (path?.isEmpty ?? true) ? nil : path
}
// MARK: - Streaming JSON-lines
/// Runs a command and collects every parsed JSON document on stdout.
/// `onProgress` is called for each document (used for download progress).
static func run(command: String, arguments: [String: Any] = [:],
onLine: @escaping ([String: Any]) -> Void,
completion: @escaping (Int, String?) -> Void) {
var cmd = pythonCommand
cmd.append(command)
if !arguments.isEmpty, let data = try? JSONSerialization.data(withJSONObject: arguments),
let str = String(data: data, encoding: .utf8) {
cmd.append(str)
}
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
process.arguments = cmd
process.currentDirectoryURL = workingDirectory
let pipe = Pipe()
process.standardOutput = pipe
let errPipe = Pipe()
process.standardError = errPipe
var stderrBuffer = ""
var stdoutBuffer = ""
let outputLock = NSLock()
errPipe.fileHandleForReading.readabilityHandler = { handle in
let data = handle.availableData
if let s = String(data: data, encoding: .utf8) { stderrBuffer += s }
}
pipe.fileHandleForReading.readabilityHandler = { handle in
let data = handle.availableData
guard !data.isEmpty, let s = String(data: data, encoding: .utf8) else { return }
outputLock.lock()
stdoutBuffer += s
let parts = stdoutBuffer.split(separator: "\n", omittingEmptySubsequences: false)
stdoutBuffer = String(parts.last ?? "")
let lines = parts.dropLast()
outputLock.unlock()
for line in lines {
if let obj = try? JSONSerialization.jsonObject(with: Data(line.utf8)) as? [String: Any] {
onLine(obj)
}
}
}
process.terminationHandler = { p in
pipe.fileHandleForReading.readabilityHandler = nil
errPipe.fileHandleForReading.readabilityHandler = nil
outputLock.lock()
let finalLine = stdoutBuffer.trimmingCharacters(in: .whitespacesAndNewlines)
stdoutBuffer = ""
outputLock.unlock()
if !finalLine.isEmpty,
let obj = try? JSONSerialization.jsonObject(with: Data(finalLine.utf8)) as? [String: Any] {
onLine(obj)
}
let error = p.terminationStatus == 0 ? nil : (stderrBuffer.isEmpty ? "O comando \(command) falhou (código \(p.terminationStatus))." : stderrBuffer)
completion(Int(p.terminationStatus), error)
}
do {
try process.run()
} catch {
completion(1, error.localizedDescription)
}
}
// MARK: - Convenience: single JSON result
/// Runs a command and delivers the first parsed JSON document as the result.
static func call(command: String, arguments: [String: Any] = [:],
completion: @escaping ([String: Any]?, String?) -> Void) {
var result: [String: Any]?
run(command: command, arguments: arguments, onLine: { obj in
if result == nil { result = obj }
}, completion: { code, err in
completion(result, err)
})
}
}
+670
View File
@@ -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)
}
}
}
}
+23
View File
@@ -0,0 +1,23 @@
import Foundation
import UniformTypeIdentifiers
extension UTType {
/// Type for a `.fcpxml` file. Built from the filename extension tag and
/// conforming to `.xml`, so macOS file panels and drop zones recognise it
/// instead of greylisting it.
static var fcpxml: UTType {
UTType(tag: "fcpxml", tagClass: .filenameExtension, conformingTo: .xml)
?? UTType(importedAs: "com.genial.fcpxml", conformingTo: .xml)
}
/// The content types allowed when importing a Final Cut project. Only file
/// types (no folders), so the file panel selects files rather than a
/// directory. `.fcpxmld` bundles are excluded on purpose — presenting a
/// folder-conforming type would switch the panel into directory mode.
static var fcpxProjectTypes: [UTType] {
[
.fcpxml,
.xml,
]
}
}