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