202 lines
8.4 KiB
Swift
202 lines
8.4 KiB
Swift
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
|
|
)
|
|
}
|
|
}
|