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
@@ -0,0 +1,218 @@
import SwiftUI
/// Simulação ao vivo da composição `phrase` das legendas dinâmicas como um
/// **quadro vertical 9:16**, na mesma geometria que `fcpxml/text_layout.py`
/// usa para posicionar os títulos no Final Cut.
///
/// Mapeamento fiel ao backend (`compose_sentence`):
/// * o canvas de referência é **1080×1920 pt** (2160×3840 a `POINT_SCALE = 0.5`);
/// * `bodySize`/`emphasisSize` e `blockCenterY` são pontos nesse canvas — o
/// preview multiplica tudo por ``altura do preview / 1920``;
/// * a linha de ênfase fica centrada e as linhas de apoio "penduram" nas
/// bordas dela, alternando lados a partir da esquerda, deslocadas por
/// `REFERENCE_STAGGER_RATIO` (0.8) da folga em relação à linha mais larga;
/// * as linhas empilham com `lineGap` pontos de tinta a tinta entre elas — 0
/// as encosta e negativo sobrepõe — e o bloco inteiro é centrado em
/// `blockCenterY`;
/// * o corpo é sempre **Bold**, a ênfase usa a face escolhida.
///
/// As larguras são estimadas pelo próprio layout de texto do macOS, então o
/// resultado é uma **aproximação** do render do FCP — mas a posição relativa,
/// as proporções e o escalonamento são reais.
struct SubtitlePreviewView: View {
var bodyFont: String
var bodySize: Double
var bodyColor: Color
var emphasisFont: String
var emphasisFace: String
var emphasisSize: Double
var emphasisColor: Color
var bandHeight: Double
var blockCenterY: Double
var lineGap: Double
@Binding var beforeText: String
@Binding var emphasisText: String
@Binding var afterText: String
/// Mostra a faixa (banda) e a linha de centro por cima do quadro.
var showsGuides: Bool = true
/// Canvas de referência do FCP: 2160×3840 px a POINT_SCALE 0.5.
private let canvasHeight: CGFloat = 1920
private let canvasWidth: CGFloat = 1080
/// REFERENCE_STAGGER_RATIO.
private let staggerRatio: CGFloat = 0.8
/// `side_margin` de LayoutBox.for_frame.
private let sideMargin: CGFloat = 0.04
var body: some View {
GeometryReader { geo in
let scale = geo.size.height / canvasHeight
let lines = composedLines(scale: scale, usableWidth: geo.size.width * (1 - 2 * sideMargin))
let bandPixels = geo.size.height * CGFloat(bandHeight)
// y cresce para cima no FCP; na tela cresce para baixo.
let centerY = geo.size.height * 0.5 - CGFloat(blockCenterY) * scale
ZStack {
LinearGradient(
colors: [Color(white: 0.14), Color(white: 0.03)],
startPoint: .top, endPoint: .bottom
)
if showsGuides {
Rectangle()
.fill(Color.accentColor.opacity(0.10))
.frame(height: bandPixels)
.overlay(alignment: .top) { guideRule }
.overlay(alignment: .bottom) { guideRule }
.position(x: geo.size.width / 2, y: centerY)
Rectangle()
.fill(Color.white.opacity(0.16))
.frame(height: 1)
.position(x: geo.size.width / 2, y: geo.size.height * 0.5)
}
ForEach(Array(lines.enumerated()), id: \.offset) { _, line in
Text(line.text)
.font(line.font)
.foregroundColor(line.color)
.lineLimit(1)
.fixedSize()
.position(
x: geo.size.width / 2 + line.x,
y: centerY + line.y
)
}
}
.clipShape(RoundedRectangle(cornerRadius: 10))
.overlay(
RoundedRectangle(cornerRadius: 10)
.strokeBorder(Color.white.opacity(0.15), lineWidth: 1)
)
}
.aspectRatio(canvasWidth / canvasHeight, contentMode: .fit)
}
private var guideRule: some View {
Rectangle().fill(Color.accentColor.opacity(0.45)).frame(height: 1)
}
// MARK: - Composição
private struct Line {
var text: String
var font: Font
var nsFont: NSFont
var color: Color
var isEmphasis: Bool
var width: CGFloat
var height: CGFloat
var x: CGFloat = 0
var y: CGFloat = 0
}
/// Reproduz `compose_sentence`: quebra as linhas de apoio na largura útil,
/// empilha os blocos com `lineGap` e desloca as linhas de apoio para os
/// lados alternados da linha mais larga.
private func composedLines(scale: CGFloat, usableWidth: CGFloat) -> [Line] {
let bodyPt = max(3, CGFloat(bodySize) * scale)
let emphasisPt = max(3, CGFloat(emphasisSize) * scale)
var lines: [Line] = []
lines += bodyLines(before(), size: bodyPt, maxWidth: usableWidth)
let emphasis = emphasisText.trimmingCharacters(in: .whitespaces)
lines.append(makeLine(
emphasis.isEmpty ? " " : emphasis,
nsFont: resolvedFont(emphasisFont, size: emphasisPt, face: emphasisFace),
color: emphasisColor,
isEmphasis: true
))
lines += bodyLines(after(), size: bodyPt, maxWidth: usableWidth)
guard !lines.isEmpty else { return [] }
let gap = CGFloat(lineGap) * scale
let stackHeight = lines.reduce(0) { $0 + $1.height } + gap * CGFloat(lines.count - 1)
let anchor = lines.map(\.width).max() ?? 0
// Topo do bloco relativo ao seu próprio centro.
var edge = -stackHeight / 2
var side: CGFloat = -1
for index in lines.indices {
if index > 0 { edge += gap }
// `.position` centra o frame do Text, cujo centro fica acima do
// centro da tinta — corrige para a tinta cair onde o FCP a põe.
let f = lines[index].nsFont
lines[index].y = edge + lines[index].height / 2 + (f.capHeight - f.ascender) / 2
edge += lines[index].height
if !lines[index].isEmphasis {
lines[index].x = side * (anchor - lines[index].width) / 2 * staggerRatio
side = -side
}
}
return lines
}
private func before() -> String { beforeText.trimmingCharacters(in: .whitespaces) }
private func after() -> String { afterText.trimmingCharacters(in: .whitespaces) }
/// Quebra um trecho de apoio em linhas que cabem em `maxWidth`, palavra a
/// palavra — o mesmo critério de `body_lines` no backend.
private func bodyLines(_ run: String, size: CGFloat, maxWidth: CGFloat) -> [Line] {
guard !run.isEmpty else { return [] }
let nsFont = resolvedFont(bodyFont, size: size, face: "Bold")
var out: [Line] = []
var current = ""
for word in run.split(separator: " ").map(String.init) {
let trial = current.isEmpty ? word : current + " " + word
if !current.isEmpty, textWidth(trial, nsFont) > maxWidth {
out.append(makeLine(current, nsFont: nsFont, color: bodyColor, isEmphasis: false))
current = word
} else {
current = trial
}
}
if !current.isEmpty {
out.append(makeLine(current, nsFont: nsFont, color: bodyColor, isEmphasis: false))
}
return out
}
private func makeLine(_ text: String, nsFont: NSFont, color: Color, isEmphasis: Bool) -> Line {
Line(
text: text,
font: Font(nsFont as CTFont),
nsFont: nsFont,
color: color,
isEmphasis: isEmphasis,
// Empilha sobre a tinta real (cap-height + descida), como ink_extent.
width: textWidth(text, nsFont),
height: nsFont.capHeight + abs(nsFont.descender)
)
}
private func textWidth(_ text: String, _ font: NSFont) -> CGFloat {
(text as NSString).size(withAttributes: [.font: font]).width
}
/// Resolve família + face para uma `NSFont`, caindo para a fonte do sistema
/// quando a família não está instalada — assim o preview nunca some.
private func resolvedFont(_ family: String, size: CGFloat, face: String?) -> NSFont {
var descriptor = NSFontDescriptor(fontAttributes: [.family: family])
if let face, !face.isEmpty {
descriptor = descriptor.addingAttributes([.face: face])
}
if let font = NSFont(descriptor: descriptor, size: size) {
return font
}
var traits: NSFontDescriptor.SymbolicTraits = []
if face?.localizedCaseInsensitiveContains("italic") == true { traits.insert(.italic) }
if face?.localizedCaseInsensitiveContains("bold") == true { traits.insert(.bold) }
let fallback = NSFontDescriptor(fontAttributes: [.family: family])
.withSymbolicTraits(traits)
return NSFont(descriptor: fallback, size: size)
?? NSFont.systemFont(ofSize: size)
}
}