Files
gart/code/MacApp/Sources/SubtitlePreviewView.swift

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