chore: atualização geral
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user