Files
gart/code/MacApp/Sources/CaptionsView.swift
T
João HenriqueandClaude Sonnet 5 7b5aed79ee feat(voz): legenda por ênfase, forced align, IA local e correções de zoom/revisão
Trabalho da branch feat/revisao-enfases: pipeline de edição por voz ganha
alinhamento forçado (whisperx), roteirização por LLM local (Ollama), e a
etapa 5 (revisão de frases) passa a refletir de verdade o que é aplicado.

- generate_subtitles_by_emphasis: legenda comum cobre o clipe inteiro,
  legenda dinâmica só nas frases de ênfase, e a comum é desativada
  (enabled="0") onde a dinâmica cobre, em vez de nunca ser gerada ali.
- validate_subtitle_layout ignora títulos com enabled="0" — corrige falso
  positivo de colisão contra o que está desativado no lugar dele.
- Corrige zoom/marcador sendo descartado quando a borda encosta exatamente
  no início de um corte.
- Etapa 5 do Assistente: recarrega quando as decisões da IA mudam (com
  fresh=true, ignorando a revisão salva antiga) — resolve a dessincronia
  entre "ativa" na tela e o que já foi cortado no FCPXML.
- Etapa "Processar" reaplica as decisões da revisão (_phrase_actions.json)
  antes da cadeia de remoção de silêncio/legendas — antes, desativar uma
  frase na etapa 5 não tinha efeito nenhum no vídeo final.
- Etapa "Concluído" fundida em "Processar" — abrir no Final Cut/Finder
  aparece assim que termina, sem slide extra.
- Palavra clicável na etapa 5 agora funciona como toggle (clique de novo
  desfaz) e mostra a própria ênfase (sublinhado colorido + peso da fonte).
- fcpxml/forced_align.py, fcpxml/llm_local.py, ai_edit.py: alinhamento
  fonético via whisperx e roteirização local via Ollama/Gemma.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 18:26:04 -04:00

562 lines
22 KiB
Swift
Raw 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 plainConfig = PlainSubtitleConfig.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
/// Todas as famílias de fonte instaladas no macOS (sistema + usuário), as
/// usadas por padrão primeiro, para o seletor listar tudo sem hardcode.
private static let installedFontFamilies: [String] = {
var families = NSFontManager.shared.availableFontFamilies
.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending }
let preferred = ["Helvetica Neue", "Playfair Display", "Georgia", "Didot"]
for family in preferred.reversed() {
if let idx = families.firstIndex(of: family) {
families.remove(at: idx)
families.insert(family, at: 0)
}
}
return families
}()
/// Lista para um picker: todas as famílias instaladas e, se o valor salvo
/// não estiver entre elas (ex.: fonte de outro Mac), ele entra no topo
/// para o seletor continuar exibindo a escolha atual.
private func fontChoices(for current: String) -> [String] {
var list = Self.installedFontFamilies
if !list.contains(current) {
list.insert(current, at: 0)
}
return list
}
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 plainBound<T>(_ keyPath: WritableKeyPath<PlainSubtitleConfig, T>) -> Binding<T> {
Binding(
get: { plainConfig[keyPath: keyPath] },
set: { plainConfig[keyPath: keyPath] = $0; savePlain() }
)
}
private func colorBound(_ keyPath: WritableKeyPath<CaptionStyleConfig, String>) -> Binding<Color> {
Binding(
get: { Color(rgbaString: config[keyPath: keyPath]) },
set: { config[keyPath: keyPath] = $0.fcpxmlColorString; save() }
)
}
private func plainColorBound(_ keyPath: WritableKeyPath<PlainSubtitleConfig, String>) -> Binding<Color> {
Binding(
get: { Color(rgbaString: plainConfig[keyPath: keyPath]) },
set: { plainConfig[keyPath: keyPath] = $0.fcpxmlColorString; savePlain() }
)
}
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
plainSubtitleSection
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(for: config.font), 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(fontChoices(for: config.emphasisFont), 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 plainSubtitleSection: some View {
Section("Legenda comum") {
Picker("Fonte", selection: plainBound(\.font)) {
ForEach(fontChoices(for: plainConfig.font), id: \.self) { Text($0).tag($0) }
}
slider(
"Tamanho",
value: plainBound(\.fontSize), in: 28...300, step: 1,
readout: "\(Int(plainConfig.fontSize))pt",
help: "Tamanho da legenda comum editável no Final Cut."
)
slider(
"Máximo de palavras",
value: plainBound(\.maxWords), in: 1...14, step: 1,
readout: "\(Int(plainConfig.maxWords))",
help: "Quantidade máxima de palavras por bloco de legenda."
)
slider(
"Altura",
value: plainBound(\.positionY), in: -1200...300, step: 1,
readout: "\(Int(plainConfig.positionY))",
help: "Posição vertical da legenda comum no quadro; valores mais negativos descem."
)
ColorPicker("Cor", selection: plainColorBound(\.fontColor), supportsOpacity: true)
Toggle("Usar letra maiúscula", isOn: plainBound(\.uppercase))
Toggle("Manter vírgula e ponto", isOn: plainBound(\.keepPunctuation))
}
}
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
}
PythonBridge.call(command: "plain_subtitle_config") { plainResult, plainError in
DispatchQueue.main.async {
if let plainResult {
plainConfig = PlainSubtitleConfig(from: plainResult)
} else if let plainError {
errorMessage = plainError
}
isLoading = false
continuation.resume()
}
}
}
}
}
}
private func save() {
PythonBridge.call(command: "set_dynamic_subtitle_config", arguments: config.arguments()) { _, error in
DispatchQueue.main.async { errorMessage = error }
}
}
private func savePlain() {
PythonBridge.call(command: "set_plain_subtitle_config", arguments: plainConfig.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,
]
}
}
struct PlainSubtitleConfig {
var font: String
var fontSize: Double
var fontColor: String
var maxWords: Double
var positionY: Double
var uppercase: Bool
var keepPunctuation: Bool
var textScale: Double
static let defaults = PlainSubtitleConfig(
font: "Helvetica Neue",
fontSize: 82,
fontColor: "1 1 1 1",
maxWords: 7,
positionY: -820,
uppercase: false,
keepPunctuation: true,
textScale: 2.0
)
init(from json: [String: Any]) {
let d = PlainSubtitleConfig.defaults
self.init(
font: json["font"] as? String ?? d.font,
fontSize: (json["font_size"] as? NSNumber)?.doubleValue ?? d.fontSize,
fontColor: json["font_color"] as? String ?? d.fontColor,
maxWords: (json["max_words"] as? NSNumber)?.doubleValue ?? d.maxWords,
positionY: (json["position_y"] as? NSNumber)?.doubleValue ?? d.positionY,
uppercase: json["uppercase"] as? Bool ?? d.uppercase,
keepPunctuation: json["keep_punctuation"] as? Bool ?? d.keepPunctuation,
textScale: (json["text_scale"] as? NSNumber)?.doubleValue ?? d.textScale
)
}
init(
font: String, fontSize: Double, fontColor: String, maxWords: Double,
positionY: Double, uppercase: Bool, keepPunctuation: Bool, textScale: Double
) {
self.font = font
self.fontSize = fontSize
self.fontColor = fontColor
self.maxWords = maxWords
self.positionY = positionY
self.uppercase = uppercase
self.keepPunctuation = keepPunctuation
self.textScale = textScale
}
func arguments() -> [String: Any] {
[
"font": font,
"font_size": Int(fontSize),
"font_color": fontColor,
"max_words": Int(maxWords),
"position_y": positionY,
"uppercase": uppercase,
"keep_punctuation": keepPunctuation,
"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
)
}
}