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) } }