chore: atualização geral
This commit is contained in:
@@ -1,195 +1,416 @@
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
/// Guia "Legendas" — configura e gera legendas dinâmicas como clipes de título
|
||||
/// editáveis no Final Cut Pro (template "Essencial - Título"). Cada palavra
|
||||
/// vira um clipe de título posicionado: as palavras da frase vão surgindo
|
||||
/// conforme são faladas, se acumulam num bloco centralizado, e somem todas
|
||||
/// juntas no fim da frase. Sem Compound Clip.
|
||||
/// 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 projectPath: String?
|
||||
@State private var clipName = ""
|
||||
|
||||
// Posicionamento do bloco
|
||||
@State private var bandHeight: Double = 0.22
|
||||
@State private var blockCenterY: Double = -167
|
||||
|
||||
// Estilo
|
||||
@State private var font = "Helvetica Neue"
|
||||
@State private var fontSize: Double = 90
|
||||
@State private var activeColor = Color.white
|
||||
@State private var inactiveColor = Color(white: 0.7)
|
||||
|
||||
@State private var isGenerating = false
|
||||
@State private var resultPath: String?
|
||||
@State private var config = CaptionStyleConfig.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
|
||||
|
||||
private let fontChoices = [
|
||||
"Helvetica Neue", "Helvetica", "Arial", "Avenir Next",
|
||||
"Futura", "SF Pro Display", "Georgia", "Impact",
|
||||
]
|
||||
|
||||
private let emphasisFontChoices = [
|
||||
"Playfair Display", "Georgia", "Didot", "Futura",
|
||||
"Avenir Next", "Times New Roman", "Helvetica Neue", "Impact",
|
||||
]
|
||||
|
||||
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 colorBound(_ keyPath: WritableKeyPath<CaptionStyleConfig, String>) -> Binding<Color> {
|
||||
Binding(
|
||||
get: { Color(rgbaString: config[keyPath: keyPath]) },
|
||||
set: { config[keyPath: keyPath] = $0.fcpxmlColorString; save() }
|
||||
)
|
||||
}
|
||||
|
||||
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 {
|
||||
Section("Projeto do Final Cut Pro") {
|
||||
HStack {
|
||||
Text(projectName)
|
||||
.foregroundStyle(projectPath == nil ? .secondary : .primary)
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
Button("Escolher…") { pickProjectFile() }
|
||||
if isLoading {
|
||||
Section {
|
||||
ProgressView().controlSize(.small)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
}
|
||||
if let projectPath {
|
||||
HStack {
|
||||
Image(systemName: "doc.text").foregroundStyle(.secondary)
|
||||
Text(projectPath).font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
||||
}
|
||||
}
|
||||
TextField("Nome do clipe (opcional — vazio = todos os clipes)", text: $clipName)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
} else {
|
||||
positionSection
|
||||
bodySection
|
||||
emphasisSection
|
||||
calibrationSection
|
||||
}
|
||||
|
||||
Section {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
Text("Altura do bloco").font(.callout.weight(.medium))
|
||||
Spacer()
|
||||
Text("\(Int(bandHeight * 100))%").font(.caption).foregroundStyle(.secondary).monospacedDigit()
|
||||
}
|
||||
Slider(value: $bandHeight, in: 0.10...0.50, step: 0.01)
|
||||
Text("Quanto da altura do quadro a frase pode ocupar antes de quebrar em outro bloco. Maior = mais palavras juntas na tela.")
|
||||
.font(.caption2).foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
Text("Altura na tela").font(.callout.weight(.medium))
|
||||
Spacer()
|
||||
Text("\(Int(blockCenterY))").font(.caption).foregroundStyle(.secondary).monospacedDigit()
|
||||
}
|
||||
Slider(value: $blockCenterY, in: -700...300, step: 1)
|
||||
Text("Posição vertical do bloco. 0 é o centro do quadro; valores negativos descem.")
|
||||
.font(.caption2).foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
} header: {
|
||||
Text("Posicionamento do Bloco")
|
||||
} footer: {
|
||||
Text("Cada palavra vira um clipe de título solto na timeline (sem Compound Clip), posicionado para não sobrepor as outras palavras da frase.")
|
||||
.font(.caption2).foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Section("Estilo do Texto") {
|
||||
Picker("Fonte", selection: $font) {
|
||||
ForEach(fontChoices, id: \.self) { Text($0).tag($0) }
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
Text("Tamanho da fonte").font(.callout.weight(.medium))
|
||||
Spacer()
|
||||
Text("\(Int(fontSize))pt").font(.caption).foregroundStyle(.secondary).monospacedDigit()
|
||||
}
|
||||
Slider(value: $fontSize, in: 20...200, step: 1)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
|
||||
ColorPicker("Cor A", selection: $activeColor, supportsOpacity: true)
|
||||
ColorPicker("Cor B", selection: $inactiveColor, supportsOpacity: true)
|
||||
|
||||
Text("Tamanho, cor e estilo variam por palavra seguindo um ritmo fixo, calibrado a partir de um projeto real do Final Cut. As cores acima entram nesse ritmo.")
|
||||
.font(.caption2).foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Section {
|
||||
Button {
|
||||
generate()
|
||||
} label: {
|
||||
if isGenerating { ProgressView().controlSize(.small) }
|
||||
Label("Gerar Legendas Dinâmicas", systemImage: "captions.bubble.fill")
|
||||
}
|
||||
.disabled(isGenerating || projectPath == nil)
|
||||
.frame(maxWidth: .infinity)
|
||||
.buttonStyle(.borderedProminent)
|
||||
|
||||
if let errorMessage {
|
||||
if let errorMessage {
|
||||
Section {
|
||||
Label(errorMessage, systemImage: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
if let resultPath {
|
||||
Text(resultPath).font(.caption).lineLimit(1)
|
||||
HStack {
|
||||
Button("Abrir no Final Cut Pro") { NSWorkspace.shared.open(URL(fileURLWithPath: resultPath)) }
|
||||
Button("Mostrar no Finder") {
|
||||
NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: resultPath)])
|
||||
}
|
||||
}
|
||||
}
|
||||
} footer: {
|
||||
Text("Usa a transcrição local (Whisper) já feita na aba Projeto/Transcrição. Se ainda não houver transcrição salva, ela é gerada automaticamente.")
|
||||
.font(.caption2).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
}
|
||||
|
||||
private var projectName: String {
|
||||
guard let p = projectPath else { return "Nenhum projeto selecionado" }
|
||||
return URL(fileURLWithPath: p).lastPathComponent
|
||||
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 func pickProjectFile() {
|
||||
let panel = NSOpenPanel()
|
||||
panel.canChooseFiles = true
|
||||
panel.canChooseDirectories = false
|
||||
panel.allowsMultipleSelection = false
|
||||
panel.prompt = "Selecionar"
|
||||
panel.message = "Selecione o arquivo de projeto (.fcpxml) exportado pelo Final Cut Pro."
|
||||
if panel.runModal() == .OK, let url = panel.url {
|
||||
let ext = url.pathExtension.lowercased()
|
||||
if ext == "fcpxml" || ext == "xml" || ext == "fcpxmld" {
|
||||
projectPath = url.path
|
||||
} else {
|
||||
errorMessage = "Selecione um arquivo .fcpxml ou .xml do Final Cut Pro."
|
||||
private var bodySection: some View {
|
||||
Section("Linhas de apoio") {
|
||||
Picker("Fonte", selection: bound(\.font)) {
|
||||
ForEach(fontChoices, 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(emphasisFontChoices, 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 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)
|
||||
}
|
||||
|
||||
private func generate() {
|
||||
guard let projectPath else { return }
|
||||
isGenerating = true
|
||||
errorMessage = nil
|
||||
resultPath = nil
|
||||
/// Largura útil do canvas de referência (1080 pt menos 4% de cada lado).
|
||||
private static let usableCanvasWidth: CGFloat = 1080 * 0.92
|
||||
|
||||
var args: [String: Any] = [
|
||||
"path": projectPath,
|
||||
"band_height": bandHeight,
|
||||
"block_center_y": blockCenterY,
|
||||
"font": font,
|
||||
"font_size": Int(fontSize),
|
||||
"active_color": activeColor.fcpxmlColorString,
|
||||
"inactive_color": inactiveColor.fcpxmlColorString,
|
||||
]
|
||||
let trimmedClip = clipName.trimmingCharacters(in: .whitespaces)
|
||||
if !trimmedClip.isEmpty {
|
||||
args["clip_name"] = trimmedClip
|
||||
/// 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
|
||||
}
|
||||
|
||||
PythonBridge.call(command: "generate_dynamic_subtitles", arguments: args) { result, err in
|
||||
DispatchQueue.main.async {
|
||||
isGenerating = false
|
||||
if result?["ok"] as? Bool == true {
|
||||
resultPath = result?["path"] as? String
|
||||
} else {
|
||||
errorMessage = result?["error"] as? String ?? err ?? "Falha ao gerar legendas dinâmicas."
|
||||
// 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
|
||||
}
|
||||
isLoading = false
|
||||
continuation.resume()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func save() {
|
||||
PythonBridge.call(command: "set_dynamic_subtitle_config", arguments: config.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,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user