import SwiftUI import UniformTypeIdentifiers /// Guia "Legendas Dinâmicas" — apenas configuração de estilo. Nenhum /// processamento acontece aqui: a geração das legendas roda na aba /// "Projeto". /// /// Os valores ficam em `~/.fcp-mcp-server/config.json` (via /// `model_manager.save_dynamic_subtitle_config`), os mesmos lidos por /// `generate_dynamic_subtitles` como padrão — não em UserDefaults/ /// `@AppStorage`, para que a geração renderize exatamente o que esta tela /// mostra, e para que qualquer chamador (app, MCP, uma sessão de IA) veja o /// mesmo estilo sem precisar repassar os 11 campos a cada chamada. Mesmo /// desenho de `VoiceAnalysisView`. /// /// Layout em duas colunas: à esquerda, um **preview vertical 9:16** fixo que /// reproduz a composição real (posição, escala, quebra de linhas e o /// escalonamento das linhas de apoio); à direita, os controles agrupados por /// assunto. struct CaptionsView: View { @State private var config = CaptionStyleConfig.defaults @State private var isLoading = true @State private var errorMessage: String? // Frase de amostra do preview — só conveniência local, não afeta a // geração real (que usa as palavras de verdade da transcrição), então // continua em @AppStorage em vez do config compartilhado. @AppStorage("capSampleBefore") private var sampleBefore = "que vão" @AppStorage("capSampleEmphasis") private var sampleEmphasis = "melhorar" @AppStorage("capSampleAfter") private var sampleAfter = "sua legenda" @AppStorage("capShowGuides") private var showsGuides = true private let fontChoices = [ "Helvetica Neue", "Helvetica", "Arial", "Avenir Next", "Futura", "SF Pro Display", "Georgia", "Impact", ] private let emphasisFontChoices = [ "Playfair Display", "Georgia", "Didot", "Futura", "Avenir Next", "Times New Roman", "Helvetica Neue", "Impact", ] private let emphasisFaceChoices = [ "Medium Italic", "Italic", "Bold Italic", "Bold", "Regular", "Light Italic", ] /// Salva no arquivo a cada mudança e devolve um Binding, para os controles /// continuarem simples (`$config.x` viraria só memória local). private func bound(_ keyPath: WritableKeyPath) -> Binding { Binding( get: { config[keyPath: keyPath] }, set: { config[keyPath: keyPath] = $0; save() } ) } private func colorBound(_ keyPath: WritableKeyPath) -> Binding { Binding( get: { Color(rgbaString: config[keyPath: keyPath]) }, set: { config[keyPath: keyPath] = $0.fcpxmlColorString; save() } ) } var body: some View { HSplitView { previewColumn .frame(minWidth: 250, idealWidth: 300, maxWidth: 380) controlsColumn .frame(minWidth: 380, idealWidth: 460) } .task { await load() } } // MARK: - Coluna da pré-visualização private var previewColumn: some View { VStack(alignment: .leading, spacing: 14) { HStack { Label("Pré-visualização", systemImage: "rectangle.on.rectangle.angled") .font(.headline) Spacer() Toggle("Guias", isOn: $showsGuides) .toggleStyle(.switch) .controlSize(.mini) .labelsHidden() .help("Mostra a faixa do bloco e a linha de centro do quadro.") } SubtitlePreviewView( bodyFont: config.font, bodySize: config.fontSize, bodyColor: Color(rgbaString: config.activeColor), emphasisFont: config.emphasisFont, emphasisFace: config.emphasisFace, emphasisSize: config.emphasisSize, emphasisColor: Color(rgbaString: config.emphasisColor), bandHeight: config.bandHeight, blockCenterY: config.blockCenterY, lineGap: config.lineGap, beforeText: $sampleBefore, emphasisText: $sampleEmphasis, afterText: $sampleAfter, showsGuides: showsGuides ) .frame(maxWidth: .infinity, maxHeight: 420) GroupBox("Frase de amostra") { VStack(spacing: 6) { LabeledContent("Antes") { TextField("", text: $sampleBefore).textFieldStyle(.roundedBorder) } LabeledContent("Ênfase") { TextField("", text: $sampleEmphasis).textFieldStyle(.roundedBorder) } LabeledContent("Depois") { TextField("", text: $sampleAfter).textFieldStyle(.roundedBorder) } } .padding(.vertical, 4) } if emphasisOverflows { Label( "A palavra de ênfase é mais larga que o quadro nesse tamanho — reduza o tamanho da ênfase para não sair cortada.", systemImage: "exclamationmark.triangle.fill" ) .font(.caption) .foregroundStyle(.orange) .fixedSize(horizontal: false, vertical: true) } Text("Quadro vertical 9:16 na mesma geometria do Final Cut: a ênfase fica centrada e as linhas de apoio se deslocam para os lados alternados. Larguras são estimadas — a posição e a escala são reais.") .font(.caption2) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) Spacer(minLength: 0) } .padding(20) .frame(maxHeight: .infinity, alignment: .top) } // MARK: - Coluna de controles private var controlsColumn: some View { Form { if isLoading { Section { ProgressView().controlSize(.small) .frame(maxWidth: .infinity, alignment: .center) } } else { positionSection bodySection emphasisSection calibrationSection } if let errorMessage { Section { Label(errorMessage, systemImage: "exclamationmark.triangle.fill") .foregroundStyle(.red) } } } .formStyle(.grouped) } private var positionSection: some View { Section { slider( "Altura do bloco", value: bound(\.bandHeight), in: 0.10...0.50, step: 0.01, readout: "\(Int(config.bandHeight * 100))%", help: "Quanto da altura do quadro a frase pode ocupar antes de quebrar em outro bloco." ) slider( "Posição vertical", value: bound(\.blockCenterY), in: -700...300, step: 1, readout: "\(Int(config.blockCenterY))", help: "0 é o centro do quadro; negativos descem, positivos sobem." ) slider( "Espaço entre linhas", value: bound(\.lineGap), in: -80...120, step: 1, readout: "\(Int(config.lineGap))pt", help: "Distância entre uma linha e a outra, além das próprias letras. Negativo sobrepõe." ) } header: { Text("Posicionamento no Quadro") } footer: { Text("Medidas em pontos do canvas de referência 2160×3840. O espaço entre linhas é contado a partir da tinta real de cada linha — em 0 elas se encostam, e em negativo uma entra na outra.") .font(.caption2).foregroundStyle(.secondary) } } private var bodySection: some View { Section("Linhas de apoio") { Picker("Fonte", selection: bound(\.font)) { ForEach(fontChoices, id: \.self) { Text($0).tag($0) } } slider( "Tamanho", value: bound(\.fontSize), in: 20...200, step: 1, readout: "\(Int(config.fontSize))pt", help: "Tamanho das linhas que acompanham a palavra-chave." ) ColorPicker("Cor", selection: colorBound(\.activeColor), supportsOpacity: true) } } private var emphasisSection: some View { Section("Palavra de ênfase") { Picker("Fonte", selection: bound(\.emphasisFont)) { ForEach(emphasisFontChoices, id: \.self) { Text($0).tag($0) } } Picker("Estilo", selection: bound(\.emphasisFace)) { ForEach(emphasisFaceChoices, id: \.self) { Text($0).tag($0) } } slider( "Tamanho", value: bound(\.emphasisSize), in: 60...400, step: 1, readout: "\(Int(config.emphasisSize))pt", help: "A palavra-chave da frase, sozinha em sua linha e maior." ) ColorPicker("Cor", selection: colorBound(\.emphasisColor), supportsOpacity: true) } } private var calibrationSection: some View { Section { slider( "Escala no Final Cut", value: bound(\.textScale), in: 0.5...3.0, step: 0.05, readout: String(format: "%.2f×", config.textScale), help: "Converte o tamanho escolhido para o espaço em que o modelo de título do Final Cut desenha o texto." ) HStack { Spacer() Button("Restaurar padrão") { config = CaptionStyleConfig.defaults save() } .buttonStyle(.link) } } header: { Text("Calibração") } footer: { VStack(alignment: .leading, spacing: 6) { Text("O modelo \"Text\" do Final Cut trabalha no espaço do quadro inteiro (2160×3840), enquanto o layout é calculado em pontos — metade disso. Por isso o padrão é 2,00×, aplicado ao tamanho E à posição juntos. Ajuste só se o seu modelo usar outra proporção.") Text("Este estilo fica salvo em ~/.fcp-mcp-server/config.json e é usado automaticamente ao gerar legendas dinâmicas — nada é processado aqui.") } .font(.caption2).foregroundStyle(.secondary) } } /// Slider com rótulo, leitura numérica alinhada e explicação em tooltip — /// mantém as linhas do formulário com a mesma altura. private func slider( _ title: String, value: Binding, in range: ClosedRange, step: Double, readout: String, help: String ) -> some View { LabeledContent(title) { HStack(spacing: 10) { Slider(value: value, in: range, step: step) Text(readout) .font(.caption).monospacedDigit() .foregroundStyle(.secondary) .frame(width: 46, alignment: .trailing) } } .help(help) } /// Largura útil do canvas de referência (1080 pt menos 4% de cada lado). private static let usableCanvasWidth: CGFloat = 1080 * 0.92 /// A palavra de ênfase da amostra passa da largura do quadro no tamanho /// escolhido? Medida no mesmo canvas que o backend usa. private var emphasisOverflows: Bool { let text = sampleEmphasis.trimmingCharacters(in: .whitespaces) guard !text.isEmpty else { return false } var descriptor = NSFontDescriptor(fontAttributes: [.family: config.emphasisFont]) if !config.emphasisFace.isEmpty { descriptor = descriptor.addingAttributes([.face: config.emphasisFace]) } let nsFont = NSFont(descriptor: descriptor, size: CGFloat(config.emphasisSize)) ?? NSFont.systemFont(ofSize: CGFloat(config.emphasisSize)) let width = (text as NSString).size(withAttributes: [.font: nsFont]).width return width > Self.usableCanvasWidth } // MARK: - Backend @MainActor private func load() async { await withCheckedContinuation { continuation in PythonBridge.call(command: "dynamic_subtitle_config") { result, error in DispatchQueue.main.async { if let result { config = CaptionStyleConfig(from: result) } else if let error { errorMessage = error } isLoading = false continuation.resume() } } } } private func save() { PythonBridge.call(command: "set_dynamic_subtitle_config", arguments: config.arguments()) { _, error in DispatchQueue.main.async { errorMessage = error } } } } /// O estilo das legendas dinâmicas, no formato que a tela edita e o bridge /// (`admin/models_api.py` → `set_dynamic_subtitle_config`) persiste. struct CaptionStyleConfig { var bandHeight: Double var blockCenterY: Double var lineGap: Double var font: String var fontSize: Double var emphasisFont: String var emphasisFace: String var emphasisSize: Double var activeColor: String var emphasisColor: String var textScale: Double static let defaults = CaptionStyleConfig( bandHeight: 0.22, blockCenterY: -167, lineGap: 8, font: "Helvetica Neue", fontSize: 104, emphasisFont: "Playfair Display", emphasisFace: "Medium Italic", emphasisSize: 265, activeColor: "1 1 1 1", emphasisColor: "1 1 1 1", textScale: 2.0 ) /// Lê a resposta do bridge, caindo no padrão para qualquer campo ausente. init(from json: [String: Any]) { let d = CaptionStyleConfig.defaults self.init( bandHeight: json["band_height"] as? Double ?? d.bandHeight, blockCenterY: json["block_center_y"] as? Double ?? d.blockCenterY, lineGap: json["line_gap"] as? Double ?? d.lineGap, font: json["font"] as? String ?? d.font, fontSize: (json["font_size"] as? NSNumber)?.doubleValue ?? d.fontSize, emphasisFont: json["emphasis_font"] as? String ?? d.emphasisFont, emphasisFace: json["emphasis_face"] as? String ?? d.emphasisFace, emphasisSize: (json["emphasis_size"] as? NSNumber)?.doubleValue ?? d.emphasisSize, activeColor: json["active_color"] as? String ?? d.activeColor, emphasisColor: json["emphasis_color"] as? String ?? d.emphasisColor, textScale: json["text_scale"] as? Double ?? d.textScale ) } init( bandHeight: Double, blockCenterY: Double, lineGap: Double, font: String, fontSize: Double, emphasisFont: String, emphasisFace: String, emphasisSize: Double, activeColor: String, emphasisColor: String, textScale: Double ) { self.bandHeight = bandHeight self.blockCenterY = blockCenterY self.lineGap = lineGap self.font = font self.fontSize = fontSize self.emphasisFont = emphasisFont self.emphasisFace = emphasisFace self.emphasisSize = emphasisSize self.activeColor = activeColor self.emphasisColor = emphasisColor self.textScale = textScale } func arguments() -> [String: Any] { [ "band_height": bandHeight, "block_center_y": blockCenterY, "line_gap": lineGap, "font": font, "font_size": Int(fontSize), "emphasis_font": emphasisFont, "emphasis_face": emphasisFace, "emphasis_size": Int(emphasisSize), "active_color": activeColor, "emphasis_color": emphasisColor, "text_scale": textScale, ] } } extension Color { /// Parses an FCPXML "R G B A" space-separated 0-1 string into a Color. init(rgbaString: String) { let parts = rgbaString.split(separator: " ").compactMap { Double($0) } guard parts.count >= 3 else { self = .white; return } let r = parts[0], g = parts[1], b = parts[2], a = parts.count >= 4 ? parts[3] : 1.0 self.init(.sRGB, red: r, green: g, blue: b, opacity: a) } /// 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 ) } }