219 lines
9.0 KiB
Swift
219 lines
9.0 KiB
Swift
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)
|
||
}
|
||
}
|