Files
gart/code/MacApp/Sources/CaptionsView.swift
T
João HenriqueandClaude Opus 5 1bebee4359 feat: etapa 5 do assistente — revisão de ênfases com timeline
Transforma a etapa "colar decisões" numa tela de lapidação: a sugestão da
IA chega carregada e o editor afina frase a frase o que é ênfase e o que
fica fora. Essa marcação é o norte da etapa 6 — só as frases com ênfase
recebem zoom e legenda dinâmica; as demais ficam com legenda comum.

O campo de colar o JSON sobe para a etapa 4, então a numeração das etapas
não muda e a etapa 6 segue intacta.

Backend (fcpxml/phrase_review.py):
- build_phrase_review funde o _voice_timeline.json com as actions da IA
- trim por frase que anda em fronteira de palavra; corte parcial da IA
  chega como trim em vez de ser arredondado fora
- phrase_review_to_actions volta a cuts/zooms + emphasis_spans
- merge_saved_decisions reaplica só as decisões salvas sobre uma revisão
  remontada da análise atual, para reprocessar a voz não ficar mascarado
- resolve_source acha a mídia: o voice timeline guarda só o nome do arquivo

App (SwiftUI):
- layout de sala de edição: preview em cima, inspector à direita, timeline
  atravessando embaixo com seis trilhas rotuladas
- preview enquadra no formato de entrega lido do .fcpxml (fonte horizontal,
  projeto vertical), com alternância para a mídia original
- reprodução pula os trechos removidos e para no fim do trecho
- zoom manual por trecho marcado, sem guardar escala: a forma vem das
  configurações de Análise de Voz no render
- emoção da fala exposta por frase

Correções encontradas no caminho:
- VideoPlayer (AVKit) aborta em runtime no app compilado por swiftc;
  trocado por AVPlayerLayer (ver Engine/docs/05_EXPERIENCIAS.md #22)
- teste que ainda afirmava o default zoom scale=1.3 removido do parser (#21)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 21:29:27 -04:00

546 lines
21 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
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 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, 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 plainSubtitleSection: some View {
Section("Legenda comum") {
Picker("Fonte", selection: plainBound(\.font)) {
ForEach(fontChoices, 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
)
}
}