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