chore: atualização geral

This commit is contained in:
João Henrique
2026-08-19 16:35:29 -04:00
parent 8fca456ceb
commit e7748c2c58
66 changed files with 13037 additions and 4237 deletions
+374 -153
View File
@@ -1,195 +1,416 @@
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.
/// 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 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 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 {
Section("Projeto do Final Cut Pro") {
HStack {
Text(projectName)
.foregroundStyle(projectPath == nil ? .secondary : .primary)
.lineLimit(1)
Spacer()
Button("Escolher…") { pickProjectFile() }
if isLoading {
Section {
ProgressView().controlSize(.small)
.frame(maxWidth: .infinity, alignment: .center)
}
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)
} else {
positionSection
bodySection
emphasisSection
calibrationSection
}
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 {
if let errorMessage {
Section {
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 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 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 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)
}
private func generate() {
guard let projectPath else { return }
isGenerating = true
errorMessage = nil
resultPath = nil
/// Largura útil do canvas de referência (1080 pt menos 4% de cada lado).
private static let usableCanvasWidth: CGFloat = 1080 * 0.92
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
/// 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
}
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."
// 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)