Files

423 lines
17 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<T>(_ keyPath: WritableKeyPath<CaptionStyleConfig, T>) -> Binding<T> {
Binding(
get: { config[keyPath: keyPath] },
set: { config[keyPath: keyPath] = $0; save() }
)
}
private func colorBound(_ keyPath: WritableKeyPath<CaptionStyleConfig, String>) -> Binding<Color> {
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<Double>,
in range: ClosedRange<Double>,
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
)
}
}