chore: adiciona .gitignore e commit.command
This commit is contained in:
Binary file not shown.
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]
|
||||
}
|
||||
}
|
||||
Executable
+76
@@ -0,0 +1,76 @@
|
||||
#!/bin/bash
|
||||
# build_app.sh — Compila o app SwiftUI (GArt.app) a partir dos fontes em MacApp/Sources.
|
||||
#
|
||||
# Uso:
|
||||
# ./MacApp/build_app.sh # compila e monta o .app em MacApp/build/
|
||||
# ./MacApp/build_app.sh --run # compila e abre o app
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
SDK="$(xcrun --sdk macosx --show-sdk-path)"
|
||||
TARGET="arm64-apple-macosx26.0"
|
||||
|
||||
APP_NAME="GArt"
|
||||
BUILD_DIR="$ROOT/MacApp/build"
|
||||
APP_BUNDLE="$BUILD_DIR/$APP_NAME.app"
|
||||
CONTENTS="$APP_BUNDLE/Contents"
|
||||
MACOS="$CONTENTS/MacOS"
|
||||
RESOURCES="$CONTENTS/Resources"
|
||||
|
||||
echo "==> Compilando SwiftUI ($APP_NAME)..."
|
||||
mkdir -p "$MACOS" "$RESOURCES"
|
||||
swiftc -parse-as-library -O \
|
||||
-sdk "$SDK" \
|
||||
-target "$TARGET" \
|
||||
"$ROOT"/MacApp/Sources/*.swift \
|
||||
-o "$MACOS/$APP_NAME"
|
||||
|
||||
# Info.plist
|
||||
cat > "$CONTENTS/Info.plist" << PLIST
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleName</key><string>$APP_NAME</string>
|
||||
<key>CFBundleDisplayName</key><string>G-ART</string>
|
||||
<key>CFBundleIdentifier</key><string>com.gart.transcription</string>
|
||||
<key>CFBundleVersion</key><string>0.1</string>
|
||||
<key>CFBundleShortVersionString</key><string>0.1.0</string>
|
||||
<key>CFBundleExecutable</key><string>$APP_NAME</string>
|
||||
<key>CFBundlePackageType</key><string>APPL</string>
|
||||
<key>LSMinimumSystemVersion</key><string>14.0</string>
|
||||
<key>NSPrincipalClass</key><string>NSApplication</string>
|
||||
<key>NSHighResolutionCapable</key><true/>
|
||||
<key>UTExportedTypeDeclarations</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>UTTypeIdentifier</key><string>com.genial.fcpxml</string>
|
||||
<key>UTTypeDescription</key><string>Final Cut Pro XML</string>
|
||||
<key>UTTypeConformsTo</key><array><string>public.xml</string></array>
|
||||
<key>UTTypeTagSpecification</key>
|
||||
<dict>
|
||||
<key>public.filename-extension</key>
|
||||
<array><string>fcpxml</string></array>
|
||||
<key>public.mime-type</key>
|
||||
<string>text/xml</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
PLIST
|
||||
|
||||
# Ícone (se existir)
|
||||
if [ -f "$ROOT/MacApp/Assets/AppIcon.icns" ]; then
|
||||
cp "$ROOT/MacApp/Assets/AppIcon.icns" "$RESOURCES/AppIcon.icns"
|
||||
# Referenciar no Info.plist
|
||||
/usr/libexec/PlistBuddy -c "Add :CFBundleIconFile string AppIcon" "$CONTENTS/Info.plist" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
chmod +x "$MACOS/$APP_NAME"
|
||||
echo "==> App criado: $APP_BUNDLE"
|
||||
|
||||
if [[ "${1:-}" == "--run" ]]; then
|
||||
echo "==> Abrindo app..."
|
||||
open "$APP_BUNDLE"
|
||||
fi
|
||||
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env swift
|
||||
// Generates the G-ART app icon (a gradient tile with a white waveform)
|
||||
// and writes AppIcon.icns. Run from the repo root:
|
||||
// swift MacApp/make_icon.swift
|
||||
import AppKit
|
||||
import CoreGraphics
|
||||
import Foundation
|
||||
|
||||
let size: CGFloat = 1024
|
||||
let image = NSImage(size: NSSize(width: size, height: size))
|
||||
image.lockFocus()
|
||||
|
||||
guard let ctx = NSGraphicsContext.current?.cgContext else {
|
||||
print("no ctx"); exit(1)
|
||||
}
|
||||
|
||||
// Background: vertical gradient blue → indigo.
|
||||
let colors = [
|
||||
NSColor(calibratedRed: 0.0, green: 0.48, blue: 1.0, alpha: 1).cgColor,
|
||||
NSColor(calibratedRed: 0.30, green: 0.20, blue: 0.85, alpha: 1).cgColor,
|
||||
]
|
||||
let space = CGColorSpaceCreateDeviceRGB()
|
||||
if let grad = CGGradient(colorsSpace: space, colors: colors as CFArray, locations: [0, 1]) {
|
||||
ctx.drawLinearGradient(grad, start: CGPoint(x: 0, y: size),
|
||||
end: CGPoint(x: size, y: 0), options: [])
|
||||
}
|
||||
|
||||
// Rounded-corner mask (squircle-ish).
|
||||
let rect = CGRect(x: 0, y: 0, width: size, height: size)
|
||||
let corner: CGFloat = 230
|
||||
let path = CGPath(roundedRect: rect, cornerWidth: corner, cornerHeight: corner, transform: nil)
|
||||
ctx.addPath(path)
|
||||
ctx.clip()
|
||||
|
||||
// White waveform: several vertical bars of varying height.
|
||||
ctx.setFillColor(NSColor.white.cgColor)
|
||||
let barW: CGFloat = 70
|
||||
let gap: CGFloat = 45
|
||||
let totalW = 5 * barW + 4 * gap
|
||||
let startX = (size - totalW) / 2
|
||||
let heights: [CGFloat] = [240, 500, 700, 500, 240]
|
||||
let baseY = (size - 700) / 2
|
||||
for (i, h) in heights.enumerated() {
|
||||
let x = startX + CGFloat(i) * (barW + gap)
|
||||
let bar = CGRect(x: x, y: baseY, width: barW, height: h)
|
||||
let barPath = CGPath(roundedRect: bar, cornerWidth: barW / 2, cornerHeight: barW / 2, transform: nil)
|
||||
ctx.addPath(barPath)
|
||||
ctx.fillPath()
|
||||
}
|
||||
|
||||
image.unlockFocus()
|
||||
|
||||
// Convert to 1024 PNG temp, then build .icns via iconutil.
|
||||
let outDir = FileManager.default.temporaryDirectory.appendingPathComponent("gart_icon")
|
||||
try? FileManager.default.removeItem(at: outDir)
|
||||
try FileManager.default.createDirectory(at: outDir, withIntermediateDirectories: true)
|
||||
|
||||
let pngSizes = [16, 32, 64, 128, 256, 512, 1024]
|
||||
let iconset = outDir.appendingPathComponent("AppIcon.iconset")
|
||||
try FileManager.default.createDirectory(at: iconset, withIntermediateDirectories: true)
|
||||
|
||||
func write(_ size: Int, _ name: String) throws {
|
||||
let w = NSSize(width: size, height: size)
|
||||
let rep = NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: size, pixelsHigh: size,
|
||||
bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true,
|
||||
isPlanar: false, colorSpaceName: .deviceRGB,
|
||||
bytesPerRow: 0, bitsPerPixel: 0)!
|
||||
rep.size = w
|
||||
NSGraphicsContext.saveGraphicsState()
|
||||
NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: rep)
|
||||
image.draw(in: NSRect(origin: .zero, size: w),
|
||||
from: .zero, operation: .sourceOver, fraction: 1)
|
||||
NSGraphicsContext.restoreGraphicsState()
|
||||
let data = rep.representation(using: .png, properties: [:])!
|
||||
try data.write(to: iconset.appendingPathComponent(name))
|
||||
}
|
||||
|
||||
try write(16, "icon_16x16.png")
|
||||
try write(32, "icon_16x16@2x.png")
|
||||
try write(32, "icon_32x32.png")
|
||||
try write(64, "icon_32x32@2x.png")
|
||||
try write(128, "icon_128x128.png")
|
||||
try write(256, "icon_128x128@2x.png")
|
||||
try write(256, "icon_256x256.png")
|
||||
try write(512, "icon_256x256@2x.png")
|
||||
try write(512, "icon_512x512.png")
|
||||
try write(1024, "icon_512x512@2x.png")
|
||||
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/iconutil")
|
||||
process.arguments = ["-c", "icns", iconset.path, "-o", "MacApp/Assets/AppIcon.icns"]
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
|
||||
print("Icon written to MacApp/Assets/AppIcon.icns")
|
||||
Reference in New Issue
Block a user