Trabalho da branch feat/revisao-enfases: pipeline de edição por voz ganha alinhamento forçado (whisperx), roteirização por LLM local (Ollama), e a etapa 5 (revisão de frases) passa a refletir de verdade o que é aplicado. - generate_subtitles_by_emphasis: legenda comum cobre o clipe inteiro, legenda dinâmica só nas frases de ênfase, e a comum é desativada (enabled="0") onde a dinâmica cobre, em vez de nunca ser gerada ali. - validate_subtitle_layout ignora títulos com enabled="0" — corrige falso positivo de colisão contra o que está desativado no lugar dele. - Corrige zoom/marcador sendo descartado quando a borda encosta exatamente no início de um corte. - Etapa 5 do Assistente: recarrega quando as decisões da IA mudam (com fresh=true, ignorando a revisão salva antiga) — resolve a dessincronia entre "ativa" na tela e o que já foi cortado no FCPXML. - Etapa "Processar" reaplica as decisões da revisão (_phrase_actions.json) antes da cadeia de remoção de silêncio/legendas — antes, desativar uma frase na etapa 5 não tinha efeito nenhum no vídeo final. - Etapa "Concluído" fundida em "Processar" — abrir no Final Cut/Finder aparece assim que termina, sem slide extra. - Palavra clicável na etapa 5 agora funciona como toggle (clique de novo desfaz) e mostra a própria ênfase (sublinhado colorido + peso da fonte). - fcpxml/forced_align.py, fcpxml/llm_local.py, ai_edit.py: alinhamento fonético via whisperx e roteirização local via Ollama/Gemma. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
562 lines
22 KiB
Swift
562 lines
22 KiB
Swift
import SwiftUI
|
||
import UniformTypeIdentifiers
|
||
|
||
/// Guia "Legendas Dinâmicas" — apenas configuração de estilo. Nenhum
|
||
/// processamento acontece aqui: a geração das legendas roda na aba
|
||
/// "Projeto".
|
||
///
|
||
/// Os valores ficam em `~/.fcp-mcp-server/config.json` (via
|
||
/// `model_manager.save_dynamic_subtitle_config`), os mesmos lidos por
|
||
/// `generate_dynamic_subtitles` como padrão — não em UserDefaults/
|
||
/// `@AppStorage`, para que a geração renderize exatamente o que esta tela
|
||
/// mostra, e para que qualquer chamador (app, MCP, uma sessão de IA) veja o
|
||
/// mesmo estilo sem precisar repassar os 11 campos a cada chamada. Mesmo
|
||
/// desenho de `VoiceAnalysisView`.
|
||
///
|
||
/// Layout em duas colunas: à esquerda, um **preview vertical 9:16** fixo que
|
||
/// reproduz a composição real (posição, escala, quebra de linhas e o
|
||
/// escalonamento das linhas de apoio); à direita, os controles agrupados por
|
||
/// assunto.
|
||
struct CaptionsView: View {
|
||
@State private var config = CaptionStyleConfig.defaults
|
||
@State private var plainConfig = PlainSubtitleConfig.defaults
|
||
@State private var isLoading = true
|
||
@State private var errorMessage: String?
|
||
|
||
// Frase de amostra do preview — só conveniência local, não afeta a
|
||
// geração real (que usa as palavras de verdade da transcrição), então
|
||
// continua em @AppStorage em vez do config compartilhado.
|
||
@AppStorage("capSampleBefore") private var sampleBefore = "que vão"
|
||
@AppStorage("capSampleEmphasis") private var sampleEmphasis = "melhorar"
|
||
@AppStorage("capSampleAfter") private var sampleAfter = "sua legenda"
|
||
@AppStorage("capShowGuides") private var showsGuides = true
|
||
|
||
/// Todas as famílias de fonte instaladas no macOS (sistema + usuário), as
|
||
/// usadas por padrão primeiro, para o seletor listar tudo sem hardcode.
|
||
private static let installedFontFamilies: [String] = {
|
||
var families = NSFontManager.shared.availableFontFamilies
|
||
.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending }
|
||
let preferred = ["Helvetica Neue", "Playfair Display", "Georgia", "Didot"]
|
||
for family in preferred.reversed() {
|
||
if let idx = families.firstIndex(of: family) {
|
||
families.remove(at: idx)
|
||
families.insert(family, at: 0)
|
||
}
|
||
}
|
||
return families
|
||
}()
|
||
|
||
/// Lista para um picker: todas as famílias instaladas e, se o valor salvo
|
||
/// não estiver entre elas (ex.: fonte de outro Mac), ele entra no topo
|
||
/// para o seletor continuar exibindo a escolha atual.
|
||
private func fontChoices(for current: String) -> [String] {
|
||
var list = Self.installedFontFamilies
|
||
if !list.contains(current) {
|
||
list.insert(current, at: 0)
|
||
}
|
||
return list
|
||
}
|
||
|
||
private let emphasisFaceChoices = [
|
||
"Medium Italic", "Italic", "Bold Italic", "Bold", "Regular", "Light Italic",
|
||
]
|
||
|
||
/// Salva no arquivo a cada mudança e devolve um Binding, para os controles
|
||
/// continuarem simples (`$config.x` viraria só memória local).
|
||
private func bound<T>(_ keyPath: WritableKeyPath<CaptionStyleConfig, T>) -> Binding<T> {
|
||
Binding(
|
||
get: { config[keyPath: keyPath] },
|
||
set: { config[keyPath: keyPath] = $0; save() }
|
||
)
|
||
}
|
||
|
||
private func plainBound<T>(_ keyPath: WritableKeyPath<PlainSubtitleConfig, T>) -> Binding<T> {
|
||
Binding(
|
||
get: { plainConfig[keyPath: keyPath] },
|
||
set: { plainConfig[keyPath: keyPath] = $0; savePlain() }
|
||
)
|
||
}
|
||
|
||
private func colorBound(_ keyPath: WritableKeyPath<CaptionStyleConfig, String>) -> Binding<Color> {
|
||
Binding(
|
||
get: { Color(rgbaString: config[keyPath: keyPath]) },
|
||
set: { config[keyPath: keyPath] = $0.fcpxmlColorString; save() }
|
||
)
|
||
}
|
||
|
||
private func plainColorBound(_ keyPath: WritableKeyPath<PlainSubtitleConfig, String>) -> Binding<Color> {
|
||
Binding(
|
||
get: { Color(rgbaString: plainConfig[keyPath: keyPath]) },
|
||
set: { plainConfig[keyPath: keyPath] = $0.fcpxmlColorString; savePlain() }
|
||
)
|
||
}
|
||
|
||
var body: some View {
|
||
HSplitView {
|
||
previewColumn
|
||
.frame(minWidth: 250, idealWidth: 300, maxWidth: 380)
|
||
controlsColumn
|
||
.frame(minWidth: 380, idealWidth: 460)
|
||
}
|
||
.task { await load() }
|
||
}
|
||
|
||
// MARK: - Coluna da pré-visualização
|
||
|
||
private var previewColumn: some View {
|
||
VStack(alignment: .leading, spacing: 14) {
|
||
HStack {
|
||
Label("Pré-visualização", systemImage: "rectangle.on.rectangle.angled")
|
||
.font(.headline)
|
||
Spacer()
|
||
Toggle("Guias", isOn: $showsGuides)
|
||
.toggleStyle(.switch)
|
||
.controlSize(.mini)
|
||
.labelsHidden()
|
||
.help("Mostra a faixa do bloco e a linha de centro do quadro.")
|
||
}
|
||
|
||
SubtitlePreviewView(
|
||
bodyFont: config.font,
|
||
bodySize: config.fontSize,
|
||
bodyColor: Color(rgbaString: config.activeColor),
|
||
emphasisFont: config.emphasisFont,
|
||
emphasisFace: config.emphasisFace,
|
||
emphasisSize: config.emphasisSize,
|
||
emphasisColor: Color(rgbaString: config.emphasisColor),
|
||
bandHeight: config.bandHeight,
|
||
blockCenterY: config.blockCenterY,
|
||
lineGap: config.lineGap,
|
||
beforeText: $sampleBefore,
|
||
emphasisText: $sampleEmphasis,
|
||
afterText: $sampleAfter,
|
||
showsGuides: showsGuides
|
||
)
|
||
.frame(maxWidth: .infinity, maxHeight: 420)
|
||
|
||
GroupBox("Frase de amostra") {
|
||
VStack(spacing: 6) {
|
||
LabeledContent("Antes") {
|
||
TextField("", text: $sampleBefore).textFieldStyle(.roundedBorder)
|
||
}
|
||
LabeledContent("Ênfase") {
|
||
TextField("", text: $sampleEmphasis).textFieldStyle(.roundedBorder)
|
||
}
|
||
LabeledContent("Depois") {
|
||
TextField("", text: $sampleAfter).textFieldStyle(.roundedBorder)
|
||
}
|
||
}
|
||
.padding(.vertical, 4)
|
||
}
|
||
|
||
if emphasisOverflows {
|
||
Label(
|
||
"A palavra de ênfase é mais larga que o quadro nesse tamanho — reduza o tamanho da ênfase para não sair cortada.",
|
||
systemImage: "exclamationmark.triangle.fill"
|
||
)
|
||
.font(.caption)
|
||
.foregroundStyle(.orange)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
}
|
||
|
||
Text("Quadro vertical 9:16 na mesma geometria do Final Cut: a ênfase fica centrada e as linhas de apoio se deslocam para os lados alternados. Larguras são estimadas — a posição e a escala são reais.")
|
||
.font(.caption2)
|
||
.foregroundStyle(.secondary)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
|
||
Spacer(minLength: 0)
|
||
}
|
||
.padding(20)
|
||
.frame(maxHeight: .infinity, alignment: .top)
|
||
}
|
||
|
||
// MARK: - Coluna de controles
|
||
|
||
private var controlsColumn: some View {
|
||
Form {
|
||
if isLoading {
|
||
Section {
|
||
ProgressView().controlSize(.small)
|
||
.frame(maxWidth: .infinity, alignment: .center)
|
||
}
|
||
} else {
|
||
positionSection
|
||
bodySection
|
||
emphasisSection
|
||
plainSubtitleSection
|
||
calibrationSection
|
||
}
|
||
if let errorMessage {
|
||
Section {
|
||
Label(errorMessage, systemImage: "exclamationmark.triangle.fill")
|
||
.foregroundStyle(.red)
|
||
}
|
||
}
|
||
}
|
||
.formStyle(.grouped)
|
||
}
|
||
|
||
private var positionSection: some View {
|
||
Section {
|
||
slider(
|
||
"Altura do bloco",
|
||
value: bound(\.bandHeight), in: 0.10...0.50, step: 0.01,
|
||
readout: "\(Int(config.bandHeight * 100))%",
|
||
help: "Quanto da altura do quadro a frase pode ocupar antes de quebrar em outro bloco."
|
||
)
|
||
slider(
|
||
"Posição vertical",
|
||
value: bound(\.blockCenterY), in: -700...300, step: 1,
|
||
readout: "\(Int(config.blockCenterY))",
|
||
help: "0 é o centro do quadro; negativos descem, positivos sobem."
|
||
)
|
||
slider(
|
||
"Espaço entre linhas",
|
||
value: bound(\.lineGap), in: -80...120, step: 1,
|
||
readout: "\(Int(config.lineGap))pt",
|
||
help: "Distância entre uma linha e a outra, além das próprias letras. Negativo sobrepõe."
|
||
)
|
||
} header: {
|
||
Text("Posicionamento no Quadro")
|
||
} footer: {
|
||
Text("Medidas em pontos do canvas de referência 2160×3840. O espaço entre linhas é contado a partir da tinta real de cada linha — em 0 elas se encostam, e em negativo uma entra na outra.")
|
||
.font(.caption2).foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
|
||
private var bodySection: some View {
|
||
Section("Linhas de apoio") {
|
||
Picker("Fonte", selection: bound(\.font)) {
|
||
ForEach(fontChoices(for: config.font), id: \.self) { Text($0).tag($0) }
|
||
}
|
||
slider(
|
||
"Tamanho",
|
||
value: bound(\.fontSize), in: 20...200, step: 1,
|
||
readout: "\(Int(config.fontSize))pt",
|
||
help: "Tamanho das linhas que acompanham a palavra-chave."
|
||
)
|
||
ColorPicker("Cor", selection: colorBound(\.activeColor), supportsOpacity: true)
|
||
}
|
||
}
|
||
|
||
private var emphasisSection: some View {
|
||
Section("Palavra de ênfase") {
|
||
Picker("Fonte", selection: bound(\.emphasisFont)) {
|
||
ForEach(fontChoices(for: config.emphasisFont), id: \.self) { Text($0).tag($0) }
|
||
}
|
||
Picker("Estilo", selection: bound(\.emphasisFace)) {
|
||
ForEach(emphasisFaceChoices, id: \.self) { Text($0).tag($0) }
|
||
}
|
||
slider(
|
||
"Tamanho",
|
||
value: bound(\.emphasisSize), in: 60...400, step: 1,
|
||
readout: "\(Int(config.emphasisSize))pt",
|
||
help: "A palavra-chave da frase, sozinha em sua linha e maior."
|
||
)
|
||
ColorPicker("Cor", selection: colorBound(\.emphasisColor), supportsOpacity: true)
|
||
}
|
||
}
|
||
|
||
private var plainSubtitleSection: some View {
|
||
Section("Legenda comum") {
|
||
Picker("Fonte", selection: plainBound(\.font)) {
|
||
ForEach(fontChoices(for: plainConfig.font), id: \.self) { Text($0).tag($0) }
|
||
}
|
||
slider(
|
||
"Tamanho",
|
||
value: plainBound(\.fontSize), in: 28...300, step: 1,
|
||
readout: "\(Int(plainConfig.fontSize))pt",
|
||
help: "Tamanho da legenda comum editável no Final Cut."
|
||
)
|
||
slider(
|
||
"Máximo de palavras",
|
||
value: plainBound(\.maxWords), in: 1...14, step: 1,
|
||
readout: "\(Int(plainConfig.maxWords))",
|
||
help: "Quantidade máxima de palavras por bloco de legenda."
|
||
)
|
||
slider(
|
||
"Altura",
|
||
value: plainBound(\.positionY), in: -1200...300, step: 1,
|
||
readout: "\(Int(plainConfig.positionY))",
|
||
help: "Posição vertical da legenda comum no quadro; valores mais negativos descem."
|
||
)
|
||
ColorPicker("Cor", selection: plainColorBound(\.fontColor), supportsOpacity: true)
|
||
Toggle("Usar letra maiúscula", isOn: plainBound(\.uppercase))
|
||
Toggle("Manter vírgula e ponto", isOn: plainBound(\.keepPunctuation))
|
||
}
|
||
}
|
||
|
||
private var calibrationSection: some View {
|
||
Section {
|
||
slider(
|
||
"Escala no Final Cut",
|
||
value: bound(\.textScale), in: 0.5...3.0, step: 0.05,
|
||
readout: String(format: "%.2f×", config.textScale),
|
||
help: "Converte o tamanho escolhido para o espaço em que o modelo de título do Final Cut desenha o texto."
|
||
)
|
||
|
||
HStack {
|
||
Spacer()
|
||
Button("Restaurar padrão") {
|
||
config = CaptionStyleConfig.defaults
|
||
save()
|
||
}
|
||
.buttonStyle(.link)
|
||
}
|
||
} header: {
|
||
Text("Calibração")
|
||
} footer: {
|
||
VStack(alignment: .leading, spacing: 6) {
|
||
Text("O modelo \"Text\" do Final Cut trabalha no espaço do quadro inteiro (2160×3840), enquanto o layout é calculado em pontos — metade disso. Por isso o padrão é 2,00×, aplicado ao tamanho E à posição juntos. Ajuste só se o seu modelo usar outra proporção.")
|
||
Text("Este estilo fica salvo em ~/.fcp-mcp-server/config.json e é usado automaticamente ao gerar legendas dinâmicas — nada é processado aqui.")
|
||
}
|
||
.font(.caption2).foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
|
||
/// Slider com rótulo, leitura numérica alinhada e explicação em tooltip —
|
||
/// mantém as linhas do formulário com a mesma altura.
|
||
private func slider(
|
||
_ title: String,
|
||
value: Binding<Double>,
|
||
in range: ClosedRange<Double>,
|
||
step: Double,
|
||
readout: String,
|
||
help: String
|
||
) -> some View {
|
||
LabeledContent(title) {
|
||
HStack(spacing: 10) {
|
||
Slider(value: value, in: range, step: step)
|
||
Text(readout)
|
||
.font(.caption).monospacedDigit()
|
||
.foregroundStyle(.secondary)
|
||
.frame(width: 46, alignment: .trailing)
|
||
}
|
||
}
|
||
.help(help)
|
||
}
|
||
|
||
/// Largura útil do canvas de referência (1080 pt menos 4% de cada lado).
|
||
private static let usableCanvasWidth: CGFloat = 1080 * 0.92
|
||
|
||
/// A palavra de ênfase da amostra passa da largura do quadro no tamanho
|
||
/// escolhido? Medida no mesmo canvas que o backend usa.
|
||
private var emphasisOverflows: Bool {
|
||
let text = sampleEmphasis.trimmingCharacters(in: .whitespaces)
|
||
guard !text.isEmpty else { return false }
|
||
var descriptor = NSFontDescriptor(fontAttributes: [.family: config.emphasisFont])
|
||
if !config.emphasisFace.isEmpty {
|
||
descriptor = descriptor.addingAttributes([.face: config.emphasisFace])
|
||
}
|
||
let nsFont = NSFont(descriptor: descriptor, size: CGFloat(config.emphasisSize))
|
||
?? NSFont.systemFont(ofSize: CGFloat(config.emphasisSize))
|
||
let width = (text as NSString).size(withAttributes: [.font: nsFont]).width
|
||
return width > Self.usableCanvasWidth
|
||
}
|
||
|
||
// MARK: - Backend
|
||
|
||
@MainActor
|
||
private func load() async {
|
||
await withCheckedContinuation { continuation in
|
||
PythonBridge.call(command: "dynamic_subtitle_config") { result, error in
|
||
DispatchQueue.main.async {
|
||
if let result {
|
||
config = CaptionStyleConfig(from: result)
|
||
} else if let error {
|
||
errorMessage = error
|
||
}
|
||
PythonBridge.call(command: "plain_subtitle_config") { plainResult, plainError in
|
||
DispatchQueue.main.async {
|
||
if let plainResult {
|
||
plainConfig = PlainSubtitleConfig(from: plainResult)
|
||
} else if let plainError {
|
||
errorMessage = plainError
|
||
}
|
||
isLoading = false
|
||
continuation.resume()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private func save() {
|
||
PythonBridge.call(command: "set_dynamic_subtitle_config", arguments: config.arguments()) { _, error in
|
||
DispatchQueue.main.async { errorMessage = error }
|
||
}
|
||
}
|
||
|
||
private func savePlain() {
|
||
PythonBridge.call(command: "set_plain_subtitle_config", arguments: plainConfig.arguments()) { _, error in
|
||
DispatchQueue.main.async { errorMessage = error }
|
||
}
|
||
}
|
||
}
|
||
|
||
/// O estilo das legendas dinâmicas, no formato que a tela edita e o bridge
|
||
/// (`admin/models_api.py` → `set_dynamic_subtitle_config`) persiste.
|
||
struct CaptionStyleConfig {
|
||
var bandHeight: Double
|
||
var blockCenterY: Double
|
||
var lineGap: Double
|
||
var font: String
|
||
var fontSize: Double
|
||
var emphasisFont: String
|
||
var emphasisFace: String
|
||
var emphasisSize: Double
|
||
var activeColor: String
|
||
var emphasisColor: String
|
||
var textScale: Double
|
||
|
||
static let defaults = CaptionStyleConfig(
|
||
bandHeight: 0.22,
|
||
blockCenterY: -167,
|
||
lineGap: 8,
|
||
font: "Helvetica Neue",
|
||
fontSize: 104,
|
||
emphasisFont: "Playfair Display",
|
||
emphasisFace: "Medium Italic",
|
||
emphasisSize: 265,
|
||
activeColor: "1 1 1 1",
|
||
emphasisColor: "1 1 1 1",
|
||
textScale: 2.0
|
||
)
|
||
|
||
/// Lê a resposta do bridge, caindo no padrão para qualquer campo ausente.
|
||
init(from json: [String: Any]) {
|
||
let d = CaptionStyleConfig.defaults
|
||
self.init(
|
||
bandHeight: json["band_height"] as? Double ?? d.bandHeight,
|
||
blockCenterY: json["block_center_y"] as? Double ?? d.blockCenterY,
|
||
lineGap: json["line_gap"] as? Double ?? d.lineGap,
|
||
font: json["font"] as? String ?? d.font,
|
||
fontSize: (json["font_size"] as? NSNumber)?.doubleValue ?? d.fontSize,
|
||
emphasisFont: json["emphasis_font"] as? String ?? d.emphasisFont,
|
||
emphasisFace: json["emphasis_face"] as? String ?? d.emphasisFace,
|
||
emphasisSize: (json["emphasis_size"] as? NSNumber)?.doubleValue ?? d.emphasisSize,
|
||
activeColor: json["active_color"] as? String ?? d.activeColor,
|
||
emphasisColor: json["emphasis_color"] as? String ?? d.emphasisColor,
|
||
textScale: json["text_scale"] as? Double ?? d.textScale
|
||
)
|
||
}
|
||
|
||
init(
|
||
bandHeight: Double, blockCenterY: Double, lineGap: Double,
|
||
font: String, fontSize: Double,
|
||
emphasisFont: String, emphasisFace: String, emphasisSize: Double,
|
||
activeColor: String, emphasisColor: String, textScale: Double
|
||
) {
|
||
self.bandHeight = bandHeight
|
||
self.blockCenterY = blockCenterY
|
||
self.lineGap = lineGap
|
||
self.font = font
|
||
self.fontSize = fontSize
|
||
self.emphasisFont = emphasisFont
|
||
self.emphasisFace = emphasisFace
|
||
self.emphasisSize = emphasisSize
|
||
self.activeColor = activeColor
|
||
self.emphasisColor = emphasisColor
|
||
self.textScale = textScale
|
||
}
|
||
|
||
func arguments() -> [String: Any] {
|
||
[
|
||
"band_height": bandHeight,
|
||
"block_center_y": blockCenterY,
|
||
"line_gap": lineGap,
|
||
"font": font,
|
||
"font_size": Int(fontSize),
|
||
"emphasis_font": emphasisFont,
|
||
"emphasis_face": emphasisFace,
|
||
"emphasis_size": Int(emphasisSize),
|
||
"active_color": activeColor,
|
||
"emphasis_color": emphasisColor,
|
||
"text_scale": textScale,
|
||
]
|
||
}
|
||
}
|
||
|
||
struct PlainSubtitleConfig {
|
||
var font: String
|
||
var fontSize: Double
|
||
var fontColor: String
|
||
var maxWords: Double
|
||
var positionY: Double
|
||
var uppercase: Bool
|
||
var keepPunctuation: Bool
|
||
var textScale: Double
|
||
|
||
static let defaults = PlainSubtitleConfig(
|
||
font: "Helvetica Neue",
|
||
fontSize: 82,
|
||
fontColor: "1 1 1 1",
|
||
maxWords: 7,
|
||
positionY: -820,
|
||
uppercase: false,
|
||
keepPunctuation: true,
|
||
textScale: 2.0
|
||
)
|
||
|
||
init(from json: [String: Any]) {
|
||
let d = PlainSubtitleConfig.defaults
|
||
self.init(
|
||
font: json["font"] as? String ?? d.font,
|
||
fontSize: (json["font_size"] as? NSNumber)?.doubleValue ?? d.fontSize,
|
||
fontColor: json["font_color"] as? String ?? d.fontColor,
|
||
maxWords: (json["max_words"] as? NSNumber)?.doubleValue ?? d.maxWords,
|
||
positionY: (json["position_y"] as? NSNumber)?.doubleValue ?? d.positionY,
|
||
uppercase: json["uppercase"] as? Bool ?? d.uppercase,
|
||
keepPunctuation: json["keep_punctuation"] as? Bool ?? d.keepPunctuation,
|
||
textScale: (json["text_scale"] as? NSNumber)?.doubleValue ?? d.textScale
|
||
)
|
||
}
|
||
|
||
init(
|
||
font: String, fontSize: Double, fontColor: String, maxWords: Double,
|
||
positionY: Double, uppercase: Bool, keepPunctuation: Bool, textScale: Double
|
||
) {
|
||
self.font = font
|
||
self.fontSize = fontSize
|
||
self.fontColor = fontColor
|
||
self.maxWords = maxWords
|
||
self.positionY = positionY
|
||
self.uppercase = uppercase
|
||
self.keepPunctuation = keepPunctuation
|
||
self.textScale = textScale
|
||
}
|
||
|
||
func arguments() -> [String: Any] {
|
||
[
|
||
"font": font,
|
||
"font_size": Int(fontSize),
|
||
"font_color": fontColor,
|
||
"max_words": Int(maxWords),
|
||
"position_y": positionY,
|
||
"uppercase": uppercase,
|
||
"keep_punctuation": keepPunctuation,
|
||
"text_scale": textScale,
|
||
]
|
||
}
|
||
}
|
||
|
||
extension Color {
|
||
/// Parses an FCPXML "R G B A" space-separated 0-1 string into a Color.
|
||
init(rgbaString: String) {
|
||
let parts = rgbaString.split(separator: " ").compactMap { Double($0) }
|
||
guard parts.count >= 3 else { self = .white; return }
|
||
let r = parts[0], g = parts[1], b = parts[2], a = parts.count >= 4 ? parts[3] : 1.0
|
||
self.init(.sRGB, red: r, green: g, blue: b, opacity: a)
|
||
}
|
||
|
||
/// Converts to FCPXML's "R G B A" space-separated 0-1 string (sRGB).
|
||
var fcpxmlColorString: String {
|
||
let ns = NSColor(self).usingColorSpace(.sRGB) ?? NSColor(self)
|
||
return String(
|
||
format: "%.4g %.4g %.4g %.4g",
|
||
ns.redComponent, ns.greenComponent, ns.blueComponent, ns.alphaComponent
|
||
)
|
||
}
|
||
}
|