chore: atualização geral
This commit is contained in:
@@ -14,6 +14,7 @@ struct GArtApp: App {
|
||||
enum ActiveTab: Hashable {
|
||||
case project
|
||||
case captions
|
||||
case voiceAnalysis
|
||||
case models
|
||||
case about
|
||||
}
|
||||
@@ -26,8 +27,10 @@ struct ContentView: View {
|
||||
List(selection: $activeTab) {
|
||||
Label("Projeto", systemImage: "film")
|
||||
.tag(ActiveTab.project)
|
||||
Label("Legendas", systemImage: "captions.bubble")
|
||||
Label("Legendas Dinâmicas", systemImage: "captions.bubble")
|
||||
.tag(ActiveTab.captions)
|
||||
Label("Análise de Voz", systemImage: "waveform")
|
||||
.tag(ActiveTab.voiceAnalysis)
|
||||
Label("Modelos", systemImage: "tray.and.arrow.down")
|
||||
.tag(ActiveTab.models)
|
||||
Label("Sobre", systemImage: "info.circle")
|
||||
@@ -42,7 +45,10 @@ struct ContentView: View {
|
||||
.navigationTitle("Projeto")
|
||||
case .captions:
|
||||
CaptionsView().id(UUID())
|
||||
.navigationTitle("Legendas")
|
||||
.navigationTitle("Legendas Dinâmicas")
|
||||
case .voiceAnalysis:
|
||||
VoiceAnalysisView().id(UUID())
|
||||
.navigationTitle("Análise de Voz")
|
||||
case .models:
|
||||
ModelDownloadView().id(UUID())
|
||||
.navigationTitle("Modelos")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -105,25 +105,64 @@ struct ModelDownloadView: View {
|
||||
.font(.caption)
|
||||
.foregroundStyle(catalog?.diarization == true ? Color.secondary : Color.orange)
|
||||
}
|
||||
SecureField("Token HuggingFace (diarização)", text: $hfTokenText)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.help("Token com acesso aos modelos gated pyannote (segmentation + speaker-diarization)")
|
||||
tokenField
|
||||
HStack {
|
||||
TextField("Nº de participantes (vazio = automático)", text: $numSpeakersText)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
Button("Salvar") { saveDiarization() }
|
||||
.disabled(isLoading)
|
||||
}
|
||||
setupSteps
|
||||
}
|
||||
} header: {
|
||||
Text("Diarização (participantes)")
|
||||
} footer: {
|
||||
Text("Opcional. Sem token, cada fala é atribuída a um único participante padrão.")
|
||||
Text("Opcional. Sem token, cada fala é atribuída a um único participante padrão. O token só é usado para baixar o modelo uma vez — depois disso a análise roda offline, nesta máquina.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
/// Campo do token. Quando já existe um salvo, mostra o estado em vez de um
|
||||
/// campo vazio ambíguo — e oferece a remoção, já que salvar vazio não apaga.
|
||||
@ViewBuilder
|
||||
private var tokenField: some View {
|
||||
if catalog?.hfTokenSet == true {
|
||||
HStack {
|
||||
Label("Token salvo nesta máquina", systemImage: "key.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Button("Remover") { removeToken() }
|
||||
.buttonStyle(.link)
|
||||
.disabled(isLoading)
|
||||
}
|
||||
}
|
||||
SecureField(
|
||||
catalog?.hfTokenSet == true ? "Substituir token…" : "Token HuggingFace (diarização)",
|
||||
text: $hfTokenText
|
||||
)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.help("Token com acesso aos modelos gated pyannote (segmentation + speaker-diarization)")
|
||||
}
|
||||
|
||||
/// O token sozinho não basta: os dois modelos pyannote são "gated" e exigem
|
||||
/// aceitar os termos na conta antes do download funcionar.
|
||||
private var setupSteps: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Para ativar, uma vez só:")
|
||||
.font(.caption).bold()
|
||||
.foregroundStyle(.secondary)
|
||||
Link("1. Aceitar os termos do modelo de segmentação",
|
||||
destination: URL(string: "https://huggingface.co/pyannote/segmentation-3.0")!)
|
||||
Link("2. Aceitar os termos do modelo de diarização",
|
||||
destination: URL(string: "https://huggingface.co/pyannote/speaker-diarization-3.1")!)
|
||||
Link("3. Criar um token de acesso e colar acima",
|
||||
destination: URL(string: "https://huggingface.co/settings/tokens")!)
|
||||
}
|
||||
.font(.caption)
|
||||
}
|
||||
|
||||
private func saveDiarization() {
|
||||
var args: [String: Any] = ["num_speakers": numSpeakersText]
|
||||
if !hfTokenText.isEmpty {
|
||||
@@ -138,6 +177,15 @@ struct ModelDownloadView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func removeToken() {
|
||||
PythonBridge.call(command: "set_diarization", arguments: ["token": ""]) { _, _ in
|
||||
DispatchQueue.main.async {
|
||||
hfTokenText = ""
|
||||
Task { await refresh() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Storage
|
||||
|
||||
private var storageSection: some View {
|
||||
|
||||
@@ -101,6 +101,25 @@ struct ProjectView: View {
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.task { restoreLastProject() }
|
||||
}
|
||||
|
||||
/// Reabre o último projeto salvo em ~/.fcp-mcp-server/config.json, para o
|
||||
/// app voltar onde parou em vez de pedir o arquivo de novo a cada abertura.
|
||||
/// É aqui que a restauração precisa morar: a TranscriptionView embutida só
|
||||
/// existe depois que há um projeto carregado, então ela não consegue se
|
||||
/// restaurar sozinha. Caminhos que sumiram do disco voltam vazios do
|
||||
/// Python, e nesse caso a tela abre limpa como antes.
|
||||
private func restoreLastProject() {
|
||||
guard project == nil else { return }
|
||||
PythonBridge.call(command: "project_config") { result, _ in
|
||||
DispatchQueue.main.async {
|
||||
guard project == nil,
|
||||
let result, result["ok"] as? Bool == true,
|
||||
let file = result["file"] as? String, !file.isEmpty else { return }
|
||||
inspect(file)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Presents an NSOpenPanel configured to select a single file (not a
|
||||
@@ -156,6 +175,23 @@ struct ProjectView: View {
|
||||
|
||||
private func handleDrop(_ providers: [NSItemProvider]) -> Bool {
|
||||
guard let provider = providers.first else { return false }
|
||||
|
||||
// `loadObject(ofClass: URL.self)` is Foundation's own bridge for a
|
||||
// dropped file URL and handles every representation Finder/FCP may
|
||||
// hand back (NSURL via secure coding, a bookmark, a plain path).
|
||||
// The item can ALSO be manually pulled as raw `Data` — but that only
|
||||
// works if the bytes are exactly `URL.dataRepresentation`'s format
|
||||
// (a UTF-8 absolute-string encoding), which a `.fcpxmld` *bundle*
|
||||
// (a package macOS treats as a directory, not a plain file) does not
|
||||
// always arrive as: the decode silently returns nil, so the drop
|
||||
// looks like it does nothing. Try the robust path first.
|
||||
if provider.canLoadObject(ofClass: URL.self) {
|
||||
_ = provider.loadObject(ofClass: URL.self) { url, _ in
|
||||
self.handleDroppedURL(url)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
provider.loadItem(forTypeIdentifier: UTType.fileURL.identifier, options: nil) { item, _ in
|
||||
var url: URL?
|
||||
if let data = item as? Data {
|
||||
@@ -163,17 +199,21 @@ struct ProjectView: View {
|
||||
} else if let u = item as? URL {
|
||||
url = u
|
||||
}
|
||||
if let url, isAccepted(url) {
|
||||
DispatchQueue.main.async { inspect(url.path) }
|
||||
} else {
|
||||
DispatchQueue.main.async {
|
||||
errorMessage = "Este arquivo não parece ser um projeto do Final Cut Pro."
|
||||
}
|
||||
}
|
||||
self.handleDroppedURL(url)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private func handleDroppedURL(_ url: URL?) {
|
||||
DispatchQueue.main.async {
|
||||
if let url, isAccepted(url) {
|
||||
inspect(url.path)
|
||||
} else {
|
||||
errorMessage = "Este arquivo não parece ser um projeto do Final Cut Pro."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func isAccepted(_ url: URL) -> Bool {
|
||||
let ext = url.pathExtension.lowercased()
|
||||
return ext == "fcpxml" || ext == "fcpxmld" || ext == "xml"
|
||||
@@ -242,6 +282,8 @@ struct ProjectView: View {
|
||||
if let result, result["ok"] as? Bool == true {
|
||||
project = ProjectInfo(json: result)
|
||||
showTranscription = true
|
||||
PythonBridge.call(command: "set_project_config",
|
||||
arguments: ["file": path]) { _, _ in }
|
||||
} else {
|
||||
errorMessage = result?["error"] as? String ?? err ?? "Falha ao ler o projeto."
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,8 @@ struct TranscriptionView: View {
|
||||
@State private var processedPath: String?
|
||||
@State private var isRemovingSilences = false
|
||||
@State private var silencePadding: Double = 0.05
|
||||
@State private var silenceNoiseDb: Double = -30
|
||||
@State private var silenceMinDuration: Double = 0.5
|
||||
@State private var isRemovingFillers = false
|
||||
@State private var fillerRemovedPath: String?
|
||||
@State private var phraseInput = ""
|
||||
@@ -39,27 +41,19 @@ struct TranscriptionView: View {
|
||||
@State private var selectedZoomEndID: Int?
|
||||
@State private var zoomMessage = ""
|
||||
@State private var outputFolder: String?
|
||||
@State private var batchVoiceAnalysis = false
|
||||
@State private var batchVoiceEdit = false
|
||||
@State private var voiceActionsPath: String?
|
||||
@State private var batchSilences = true
|
||||
@State private var batchFillers = false
|
||||
@State private var batchPhrases = false
|
||||
@State private var batchMarkers = false
|
||||
@State private var batchSubtitles = true
|
||||
@State private var batchDynamicSubtitles = false
|
||||
@State private var dynamicSubtitlesBandHeight: Double = 0.22
|
||||
@State private var dynamicSubtitlesBlockCenterY: Double = -167
|
||||
@State private var dynamicSubtitlesFont = "Helvetica Neue"
|
||||
@State private var dynamicSubtitlesFontSize: Double = 90
|
||||
@State private var dynamicSubtitlesActiveColor = Color.white
|
||||
@State private var dynamicSubtitlesInactiveColor = Color(white: 0.7)
|
||||
@State private var dynamicSubtitlesPath: String?
|
||||
@State private var isBatchProcessing = false
|
||||
@State private var batchStatus = ""
|
||||
|
||||
private let dynamicSubtitlesFontChoices = [
|
||||
"Helvetica Neue", "Helvetica", "Arial", "Avenir Next",
|
||||
"Futura", "SF Pro Display", "Georgia", "Impact",
|
||||
]
|
||||
|
||||
init(projectPath: String? = nil, embedded: Bool = false) {
|
||||
self.embedded = embedded
|
||||
_projectPath = State(initialValue: projectPath)
|
||||
@@ -133,6 +127,19 @@ struct TranscriptionView: View {
|
||||
.disabled(isRunning || projectPath == nil || outputFolder == nil || hasNoInstalledModel)
|
||||
.frame(maxWidth: .infinity)
|
||||
.buttonStyle(.borderedProminent)
|
||||
|
||||
if isRunning {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
ProgressView(value: progress)
|
||||
HStack {
|
||||
Text(stage.isEmpty ? "Processando o áudio…" : stage)
|
||||
Spacer()
|
||||
Text("\(Int(progress * 100))%").monospacedDigit()
|
||||
}
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.top, 4)
|
||||
}
|
||||
}
|
||||
.disabled(outputFolder == nil)
|
||||
|
||||
@@ -143,15 +150,6 @@ struct TranscriptionView: View {
|
||||
}
|
||||
}
|
||||
|
||||
if isRunning {
|
||||
Section {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ProgressView(value: progress)
|
||||
Text("\(Int(progress * 100))%").font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !results.isEmpty {
|
||||
Section("Transcrição Concluída") {
|
||||
ForEach(results, id: \.media) { r in
|
||||
@@ -176,105 +174,174 @@ struct TranscriptionView: View {
|
||||
if outputFolder == nil {
|
||||
Text("Selecione a pasta do projeto para habilitar o processamento.")
|
||||
.font(.caption2).foregroundStyle(.secondary)
|
||||
} else if results.isEmpty {
|
||||
Label(
|
||||
isRunning ? "Aguardando a transcrição terminar…" : "Transcreva o projeto acima para liberar o processamento.",
|
||||
systemImage: "lock.fill"
|
||||
)
|
||||
.font(.caption).foregroundStyle(.orange)
|
||||
}
|
||||
GroupBox("Processar em lote") {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Toggle("Remover silêncios do áudio", isOn: $batchSilences)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Text("Tolerância do corte")
|
||||
.foregroundStyle(batchSilences ? .primary : .secondary)
|
||||
Spacer()
|
||||
Text(String(format: "%.2fs", silencePadding))
|
||||
.font(.caption).foregroundStyle(.secondary).monospacedDigit()
|
||||
}
|
||||
Slider(value: $silencePadding, in: 0...2, step: 0.01)
|
||||
.disabled(!batchSilences)
|
||||
Text("Quanto de silêncio sobra em volta de cada corte.")
|
||||
.font(.caption2).foregroundStyle(.secondary)
|
||||
GroupBox {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
batchOptionRow(
|
||||
toggle: Toggle("Analisar voz (transcrição, locutor, ênfase)", isOn: $batchVoiceAnalysis),
|
||||
expanded: batchVoiceAnalysis
|
||||
) {
|
||||
Text("Gera o JSON com transcrição, diarização e intensidade (pitch/energia/ritmo) por palavra — a base que o corte por voz usa para decidir tomadas e zooms sem reabrir o áudio depois. Só análise: não corta nada.")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.leading, 20)
|
||||
|
||||
Divider()
|
||||
batchOptionRow(
|
||||
toggle: Toggle("Aplicar edição por voz (lista de decisões)", isOn: $batchVoiceEdit),
|
||||
expanded: batchVoiceEdit
|
||||
) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Aplica um JSON de decisões — cortes, zooms, textos e marcadores — gerado a partir da análise de voz. É o passo que descarta bastidor e tomadas repetidas.")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
HStack {
|
||||
Image(systemName: "doc.text")
|
||||
Text(voiceActionsPath.map { URL(fileURLWithPath: $0).lastPathComponent }
|
||||
?? "Nenhum arquivo de decisões")
|
||||
.foregroundStyle(voiceActionsPath == nil ? .secondary : .primary)
|
||||
.lineLimit(1).truncationMode(.middle)
|
||||
Spacer()
|
||||
Button("Escolher…") { pickVoiceActions() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
batchOptionRow(
|
||||
toggle: Toggle("Remover silêncios do áudio", isOn: $batchSilences),
|
||||
expanded: true
|
||||
) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
Text("Tolerância do corte")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Text(String(format: "%.2fs", silencePadding))
|
||||
.font(.callout).foregroundStyle(.secondary).monospacedDigit()
|
||||
}
|
||||
Slider(value: $silencePadding, in: 0...2, step: 0.01)
|
||||
.disabled(!batchSilences)
|
||||
.onChange(of: silencePadding) { _, _ in saveSilenceConfig() }
|
||||
HStack {
|
||||
Text("Limiar de silêncio")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Text(String(format: "%.0f dB", silenceNoiseDb))
|
||||
.font(.callout).foregroundStyle(.secondary).monospacedDigit()
|
||||
}
|
||||
Slider(value: $silenceNoiseDb, in: -60...(-10), step: 1)
|
||||
.disabled(!batchSilences)
|
||||
.onChange(of: silenceNoiseDb) { _, _ in saveSilenceConfig() }
|
||||
HStack {
|
||||
Text("Duração mínima")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Text(String(format: "%.2fs", silenceMinDuration))
|
||||
.font(.callout).foregroundStyle(.secondary).monospacedDigit()
|
||||
}
|
||||
Slider(value: $silenceMinDuration, in: 0.1...5, step: 0.05)
|
||||
.disabled(!batchSilences)
|
||||
.onChange(of: silenceMinDuration) { _, _ in saveSilenceConfig() }
|
||||
Text("Quanto de silêncio sobra em volta de cada corte, abaixo de que volume conta como silêncio, e quanto tempo ele precisa durar. Fica salvo e vale também fora do app.")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
Toggle("Remover palavras de preenchimento", isOn: $batchFillers)
|
||||
Toggle("Cortar frases ditas", isOn: $batchPhrases)
|
||||
if batchPhrases {
|
||||
|
||||
Divider()
|
||||
batchOptionRow(
|
||||
toggle: Toggle("Cortar frases ditas", isOn: $batchPhrases),
|
||||
expanded: batchPhrases
|
||||
) {
|
||||
TextField("Frases separadas por vírgula", text: $phraseInput)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.padding(.leading, 20)
|
||||
}
|
||||
|
||||
Divider()
|
||||
Toggle("Marcar o que foi dito na timeline", isOn: $batchMarkers)
|
||||
|
||||
Divider()
|
||||
Toggle("Exportar legendas SRT", isOn: $batchSubtitles)
|
||||
Toggle("Gerar legendas dinâmicas (cascata, editáveis no FCP)", isOn: $batchDynamicSubtitles)
|
||||
if batchDynamicSubtitles {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Picker("Fonte", selection: $dynamicSubtitlesFont) {
|
||||
ForEach(dynamicSubtitlesFontChoices, id: \.self) { Text($0).tag($0) }
|
||||
}
|
||||
HStack {
|
||||
Text("Tamanho da fonte")
|
||||
Spacer()
|
||||
Text("\(Int(dynamicSubtitlesFontSize))pt")
|
||||
.font(.caption).foregroundStyle(.secondary).monospacedDigit()
|
||||
}
|
||||
Slider(value: $dynamicSubtitlesFontSize, in: 20...200, step: 1)
|
||||
ColorPicker("Cor A", selection: $dynamicSubtitlesActiveColor, supportsOpacity: true)
|
||||
ColorPicker("Cor B", selection: $dynamicSubtitlesInactiveColor, supportsOpacity: true)
|
||||
HStack {
|
||||
Text("Altura do bloco")
|
||||
Spacer()
|
||||
Text("\(Int(dynamicSubtitlesBandHeight * 100))%")
|
||||
.font(.caption).foregroundStyle(.secondary).monospacedDigit()
|
||||
}
|
||||
Slider(value: $dynamicSubtitlesBandHeight, in: 0.10...0.50, step: 0.01)
|
||||
HStack {
|
||||
Text("Altura na tela")
|
||||
Spacer()
|
||||
Text("\(Int(dynamicSubtitlesBlockCenterY))")
|
||||
.font(.caption).foregroundStyle(.secondary).monospacedDigit()
|
||||
}
|
||||
Slider(value: $dynamicSubtitlesBlockCenterY, in: -700...300, step: 1)
|
||||
Text("Roda por último, depois dos outros passos marcados acima, usando o timing já cortado da timeline.")
|
||||
.font(.caption2).foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.leading, 20)
|
||||
}
|
||||
Button {
|
||||
processBatch()
|
||||
} label: {
|
||||
if isBatchProcessing { ProgressView().controlSize(.small) }
|
||||
Label("Processar selecionados", systemImage: "play.fill")
|
||||
}
|
||||
.disabled(isBatchProcessing || isRunning || projectPath == nil || outputFolder == nil
|
||||
|| !batchSilences && !batchFillers && !batchPhrases && !batchMarkers
|
||||
&& !batchSubtitles && !batchDynamicSubtitles)
|
||||
if !batchStatus.isEmpty {
|
||||
Text(batchStatus).font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
HStack {
|
||||
Button("Abrir pasta selecionada") {
|
||||
if let outputFolder {
|
||||
NSWorkspace.shared.open(URL(fileURLWithPath: outputFolder))
|
||||
}
|
||||
}
|
||||
.disabled(outputFolder == nil)
|
||||
Button("Abrir no Final Cut Pro") {
|
||||
if let path = dynamicSubtitlesPath ?? processedPath ?? transcriptEditPath ?? transcriptMarkersPath {
|
||||
NSWorkspace.shared.open(URL(fileURLWithPath: path))
|
||||
}
|
||||
}
|
||||
.disabled(dynamicSubtitlesPath == nil && processedPath == nil
|
||||
&& transcriptEditPath == nil && transcriptMarkersPath == nil)
|
||||
|
||||
Divider()
|
||||
batchOptionRow(
|
||||
toggle: Toggle("Gerar legendas dinâmicas (cascata, editáveis no FCP)", isOn: $batchDynamicSubtitles),
|
||||
expanded: batchDynamicSubtitles
|
||||
) {
|
||||
Text("Usa o estilo configurado na aba \"Legendas Dinâmicas\". Roda por último, depois dos outros passos marcados acima, usando o timing já cortado da timeline.")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
if results.isEmpty {
|
||||
Text("Transcreva primeiro para habilitar os cortes por texto.")
|
||||
.font(.caption2).foregroundStyle(.secondary)
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Button {
|
||||
processBatch()
|
||||
} label: {
|
||||
if isBatchProcessing {
|
||||
HStack {
|
||||
ProgressView().controlSize(.small)
|
||||
Text("Processando…")
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
} else {
|
||||
Label("Processar selecionados", systemImage: "play.fill")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.large)
|
||||
.disabled(isBatchProcessing || isRunning || projectPath == nil || outputFolder == nil
|
||||
|| !batchVoiceAnalysis && !batchVoiceEdit && !batchSilences && !batchFillers
|
||||
&& !batchPhrases && !batchMarkers && !batchSubtitles && !batchDynamicSubtitles
|
||||
// Ligada sem arquivo, a etapa falharia no meio da
|
||||
// cadeia e interromperia tudo o que vem depois.
|
||||
|| batchVoiceEdit && voiceActionsPath == nil)
|
||||
|
||||
if !batchStatus.isEmpty {
|
||||
Text(batchStatus).font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Button("Abrir pasta selecionada") {
|
||||
if let outputFolder {
|
||||
NSWorkspace.shared.open(URL(fileURLWithPath: outputFolder))
|
||||
}
|
||||
}
|
||||
.disabled(outputFolder == nil)
|
||||
Button("Abrir no Final Cut Pro") {
|
||||
if let path = dynamicSubtitlesPath ?? processedPath ?? transcriptEditPath ?? transcriptMarkersPath {
|
||||
NSWorkspace.shared.open(URL(fileURLWithPath: path))
|
||||
}
|
||||
}
|
||||
.disabled(dynamicSubtitlesPath == nil && processedPath == nil
|
||||
&& transcriptEditPath == nil && transcriptMarkersPath == nil)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.padding(.top, 4)
|
||||
}
|
||||
.disabled(outputFolder == nil)
|
||||
.disabled(outputFolder == nil || results.isEmpty)
|
||||
|
||||
Section("Zoom (aproximar em um trecho)") {
|
||||
if outputFolder != nil && results.isEmpty {
|
||||
Label(
|
||||
isRunning ? "Aguardando a transcrição terminar…" : "Transcreva o projeto acima para liberar o zoom.",
|
||||
systemImage: "lock.fill"
|
||||
)
|
||||
.font(.caption).foregroundStyle(.orange)
|
||||
}
|
||||
if zoomClips.isEmpty {
|
||||
Text("Carregando clipes do projeto…")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
@@ -347,12 +414,47 @@ struct TranscriptionView: View {
|
||||
resultRow(zoomResultPath)
|
||||
}
|
||||
}
|
||||
.disabled(outputFolder == nil)
|
||||
.disabled(outputFolder == nil || results.isEmpty)
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.task { await loadCatalog(); loadZoomClips() }
|
||||
.onChange(of: projectPath) { _, _ in loadZoomClips() }
|
||||
.onChange(of: outputFolder) { _, _ in loadZoomClips() }
|
||||
.task { await loadCatalog(); loadProjectConfig(); loadZoomClips(); loadSilenceConfig() }
|
||||
.onChange(of: projectPath) { _, newValue in
|
||||
loadZoomClips()
|
||||
saveProjectConfig(file: newValue ?? "")
|
||||
}
|
||||
.onChange(of: outputFolder) { _, newValue in
|
||||
loadZoomClips()
|
||||
saveProjectConfig(folder: newValue ?? "")
|
||||
}
|
||||
}
|
||||
|
||||
/// Reabre o último projeto salvo em ~/.fcp-mcp-server/config.json — pasta
|
||||
/// de saída e arquivo — para a tela não começar vazia a cada abertura.
|
||||
/// Só preenche o que ainda está vazio, então o projeto aberto pela aba
|
||||
/// Projeto (embedded) nunca é sobrescrito pelo que ficou salvo. Caminhos
|
||||
/// que sumiram do disco voltam vazios do Python e são ignorados aqui.
|
||||
private func loadProjectConfig() {
|
||||
PythonBridge.call(command: "project_config") { result, _ in
|
||||
DispatchQueue.main.async {
|
||||
guard let result, result["ok"] as? Bool == true else { return }
|
||||
if outputFolder == nil, let folder = result["folder"] as? String, !folder.isEmpty {
|
||||
outputFolder = folder
|
||||
}
|
||||
if projectPath == nil, let file = result["file"] as? String, !file.isEmpty {
|
||||
projectPath = file
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Grava a pasta e/ou o arquivo do projeto no config compartilhado. Campos
|
||||
/// omitidos ficam como estão; string vazia limpa o campo.
|
||||
private func saveProjectConfig(folder: String? = nil, file: String? = nil) {
|
||||
var arguments: [String: Any] = [:]
|
||||
if let folder { arguments["folder"] = folder }
|
||||
if let file { arguments["file"] = file }
|
||||
guard !arguments.isEmpty else { return }
|
||||
PythonBridge.call(command: "set_project_config", arguments: arguments) { _, _ in }
|
||||
}
|
||||
|
||||
/// Presents an NSOpenPanel configured to select a single file (not a
|
||||
@@ -374,6 +476,45 @@ struct TranscriptionView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Lê os limiares de silêncio salvos em ~/.fcp-mcp-server/config.json —
|
||||
/// os mesmos que remove_media_silence usa por padrão — para os controles
|
||||
/// abrirem já mostrando o que de fato vai rodar.
|
||||
private func loadSilenceConfig() {
|
||||
PythonBridge.call(command: "silence_config") { result, _ in
|
||||
DispatchQueue.main.async {
|
||||
guard let result, result["ok"] as? Bool == true else { return }
|
||||
silencePadding = result["padding"] as? Double ?? silencePadding
|
||||
silenceNoiseDb = result["noise_db"] as? Double ?? silenceNoiseDb
|
||||
silenceMinDuration = result["min_silence"] as? Double ?? silenceMinDuration
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func saveSilenceConfig() {
|
||||
PythonBridge.call(command: "set_silence_config", arguments: [
|
||||
"padding": silencePadding,
|
||||
"noise_db": silenceNoiseDb,
|
||||
"min_silence": silenceMinDuration,
|
||||
]) { _, _ in }
|
||||
}
|
||||
|
||||
/// Escolhe o JSON de decisões (cortes/zooms/textos/marcadores) que
|
||||
/// `apply_voice_actions` vai aplicar. Pré-seleciona a pasta do projeto,
|
||||
/// que é onde o arquivo costuma ser gravado.
|
||||
private func pickVoiceActions() {
|
||||
let panel = NSOpenPanel()
|
||||
panel.canChooseFiles = true
|
||||
panel.canChooseDirectories = false
|
||||
panel.allowsMultipleSelection = false
|
||||
panel.allowedContentTypes = [.json]
|
||||
panel.prompt = "Usar este arquivo"
|
||||
panel.message = "Selecione o JSON com a lista de decisões da edição por voz."
|
||||
if let outputFolder { panel.directoryURL = URL(fileURLWithPath: outputFolder) }
|
||||
if panel.runModal() == .OK, let url = panel.url {
|
||||
voiceActionsPath = url.path
|
||||
}
|
||||
}
|
||||
|
||||
private func pickOutputFolder() {
|
||||
let panel = NSOpenPanel()
|
||||
panel.canChooseFiles = false
|
||||
@@ -438,6 +579,28 @@ struct TranscriptionView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders a toggle plus its optional expanded detail block, indented and
|
||||
/// visually tied together so batch options don't collapse into one wall
|
||||
/// of controls with no breathing room.
|
||||
@ViewBuilder
|
||||
private func batchOptionRow<Toggle: View, Detail: View>(
|
||||
toggle: Toggle, expanded: Bool, @ViewBuilder detail: () -> Detail
|
||||
) -> some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
toggle
|
||||
if expanded {
|
||||
detail()
|
||||
.padding(.leading, 20)
|
||||
.padding(.vertical, 10)
|
||||
.padding(.trailing, 8)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(Color.secondary.opacity(0.06))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func resultRow(_ path: String) -> some View {
|
||||
Text(path).font(.caption).lineLimit(1)
|
||||
@@ -596,9 +759,11 @@ struct TranscriptionView: View {
|
||||
guard let projectPath else { return }
|
||||
isRemovingSilences = true
|
||||
errorMessage = nil
|
||||
// Sem repassar limiares: remove_silences lê os valores salvos em
|
||||
// ~/.fcp-mcp-server/config.json (os mesmos que os controles acima
|
||||
// gravam), então há uma só fonte da verdade.
|
||||
PythonBridge.call(command: "remove_silences", arguments: [
|
||||
"path": projectPath,
|
||||
"padding": silencePadding,
|
||||
]) { result, err in
|
||||
DispatchQueue.main.async {
|
||||
isRemovingSilences = false
|
||||
@@ -614,6 +779,16 @@ struct TranscriptionView: View {
|
||||
private func processBatch() {
|
||||
guard let projectPath, let outputFolder else { return }
|
||||
var operations: [String] = []
|
||||
// Analysis first: it only writes a JSON sidecar next to the source
|
||||
// media and never touches the project XML, so its position relative
|
||||
// to the other steps doesn't change what they do — but running it
|
||||
// before any cut keeps the mental model simple (measure, then edit).
|
||||
if batchVoiceAnalysis { operations.append("analyze_voice") }
|
||||
// The edit itself, and it has to run FIRST on the intact timeline:
|
||||
// every zoom/text/marker it places is positioned by shifting from its
|
||||
// own cut list, so a step that already rippled the timeline would put
|
||||
// them on the wrong frame — with no visible error.
|
||||
if batchVoiceEdit { operations.append("apply_voice_actions") }
|
||||
if batchSilences { operations.append("remove_silences") }
|
||||
if batchFillers { operations.append("remove_filler_words") }
|
||||
if batchPhrases { operations.append("edit_by_transcript") }
|
||||
@@ -638,19 +813,17 @@ struct TranscriptionView: View {
|
||||
let operation = operations[index]
|
||||
batchStatus = "Processando: \(operation)…"
|
||||
var arguments: [String: Any] = ["path": currentPath, "output_dir": outputFolder]
|
||||
if operation == "remove_silences" { arguments["padding"] = silencePadding }
|
||||
if operation == "apply_voice_actions", let voiceActionsPath {
|
||||
arguments["actions_path"] = voiceActionsPath
|
||||
}
|
||||
if operation == "edit_by_transcript" {
|
||||
let phrases = phraseInput.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }
|
||||
arguments["phrases"] = phrases
|
||||
}
|
||||
if operation == "generate_dynamic_subtitles" {
|
||||
arguments["band_height"] = dynamicSubtitlesBandHeight
|
||||
arguments["block_center_y"] = dynamicSubtitlesBlockCenterY
|
||||
arguments["font"] = dynamicSubtitlesFont
|
||||
arguments["font_size"] = Int(dynamicSubtitlesFontSize)
|
||||
arguments["active_color"] = dynamicSubtitlesActiveColor.fcpxmlColorString
|
||||
arguments["inactive_color"] = dynamicSubtitlesInactiveColor.fcpxmlColorString
|
||||
}
|
||||
// O estilo das legendas não é repassado aqui: generate_dynamic_subtitles
|
||||
// lê ~/.fcp-mcp-server/config.json (o mesmo arquivo que a aba "Legendas
|
||||
// Dinâmicas" grava) como seu próprio padrão, então há uma só fonte da
|
||||
// verdade em vez de duas cópias podendo divergir.
|
||||
PythonBridge.call(command: operation, arguments: arguments) { result, err in
|
||||
DispatchQueue.main.async {
|
||||
guard result?["ok"] as? Bool == true else {
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Guia "Análise de Voz" — parâmetros do motor de análise acústica da fala.
|
||||
///
|
||||
/// Controla os limiares que decidem quais palavras são candidatas a
|
||||
/// punch-in/destaque: energia (intensidade da fala), o índice de ênfase e
|
||||
/// seus pesos, e a camada opcional de emoção. Os valores ficam em
|
||||
/// `~/.fcp-mcp-server/config.json` (via `model_manager.save_voice_analysis_config`),
|
||||
/// os mesmos lidos pelas ferramentas de análise — não em UserDefaults, para
|
||||
/// que a análise rode com exatamente o que a tela mostra.
|
||||
struct VoiceAnalysisView: View {
|
||||
@State private var config = VoiceAnalysisConfig.defaults
|
||||
@State private var isLoading = true
|
||||
@State private var errorMessage: String?
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
if isLoading {
|
||||
Section {
|
||||
ProgressView().controlSize(.small)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
}
|
||||
} else {
|
||||
energySection
|
||||
emphasisSection
|
||||
weightsSection
|
||||
emotionSection
|
||||
resetSection
|
||||
}
|
||||
if let errorMessage {
|
||||
Section {
|
||||
Label(errorMessage, systemImage: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.task { await load() }
|
||||
}
|
||||
|
||||
// MARK: - Energia
|
||||
|
||||
private var energySection: some View {
|
||||
Section {
|
||||
sliderRow(
|
||||
title: "Limiar de energia",
|
||||
value: $config.energyThreshold,
|
||||
help: "Acima deste valor a fala conta como \"alta energia\"."
|
||||
)
|
||||
} header: {
|
||||
Text("Energia da Fala")
|
||||
} footer: {
|
||||
Text("A energia de cada palavra é normalizada (0–1) pelo trecho mais alto do áudio. Valores mais baixos marcam mais palavras como intensas.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Ênfase
|
||||
|
||||
private var emphasisSection: some View {
|
||||
Section {
|
||||
sliderRow(
|
||||
title: "Limiar de ênfase",
|
||||
value: $config.emphasisThreshold,
|
||||
help: "Acima deste valor a palavra vira candidata a punch-in/destaque."
|
||||
)
|
||||
} header: {
|
||||
Text("Índice de Ênfase")
|
||||
} footer: {
|
||||
Text("O índice é a média ponderada de energia, variação de tom, variação de ritmo, pausa anterior e duração — com os pesos abaixo. Por ser média, picos reais ficam entre 0,55 e 0,80: acima de 0,85 quase nada é selecionado.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private var weightsSection: some View {
|
||||
Section {
|
||||
sliderRow(title: "Energia", value: $config.weightEnergy, range: 0...1)
|
||||
sliderRow(title: "Variação de tom", value: $config.weightPitch, range: 0...1)
|
||||
sliderRow(title: "Variação de ritmo", value: $config.weightRate, range: 0...1)
|
||||
sliderRow(title: "Pausa anterior", value: $config.weightPause, range: 0...1)
|
||||
sliderRow(title: "Duração da palavra", value: $config.weightDuration, range: 0...1)
|
||||
} header: {
|
||||
Text("Pesos do Índice de Ênfase")
|
||||
} footer: {
|
||||
Text("Não precisam somar 1 — são normalizados internamente. O que importa é a proporção entre eles.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Emoção
|
||||
|
||||
private var emotionSection: some View {
|
||||
Section {
|
||||
Toggle("Detectar emoção durante a fala", isOn: $config.emotionEnabled)
|
||||
.onChange(of: config.emotionEnabled) { _, _ in save() }
|
||||
if config.emotionEnabled {
|
||||
sliderRow(
|
||||
title: "Sensibilidade",
|
||||
value: $config.emotionSensitivity,
|
||||
help: "Confiança mínima para aceitar uma emoção detectada."
|
||||
)
|
||||
}
|
||||
} header: {
|
||||
Text("Emoção")
|
||||
} footer: {
|
||||
Text("A emoção nunca decide um corte sozinha — entra combinada com energia, tom e ênfase. Exige o componente opcional de emoção instalado; sem ele, a análise segue normalmente sem essa camada.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private var resetSection: some View {
|
||||
Section {
|
||||
Button("Restaurar padrões") {
|
||||
config = .defaults
|
||||
save()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Componentes
|
||||
|
||||
/// Slider com rótulo à esquerda e valor numérico à direita, salvando no
|
||||
/// backend só quando o arraste termina (evita uma escrita por quadro).
|
||||
private func sliderRow(
|
||||
title: String,
|
||||
value: Binding<Double>,
|
||||
range: ClosedRange<Double> = 0...1,
|
||||
help: String? = nil
|
||||
) -> some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack {
|
||||
Text(title)
|
||||
Spacer()
|
||||
Text(String(format: "%.2f", value.wrappedValue))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Slider(value: value, in: range) { editing in
|
||||
if !editing { save() }
|
||||
}
|
||||
if let help {
|
||||
Text(help)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Backend
|
||||
|
||||
@MainActor
|
||||
private func load() async {
|
||||
await withCheckedContinuation { continuation in
|
||||
PythonBridge.call(command: "voice_analysis") { result, error in
|
||||
DispatchQueue.main.async {
|
||||
if let result {
|
||||
config = VoiceAnalysisConfig(from: result)
|
||||
} else if let error {
|
||||
errorMessage = error
|
||||
}
|
||||
isLoading = false
|
||||
continuation.resume()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func save() {
|
||||
PythonBridge.call(command: "set_voice_analysis", arguments: config.arguments()) { _, error in
|
||||
DispatchQueue.main.async { errorMessage = error }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Os parâmetros de análise de voz, no formato que a tela edita e o bridge
|
||||
/// (`admin/models_api.py` → `set_voice_analysis`) persiste.
|
||||
struct VoiceAnalysisConfig {
|
||||
var energyThreshold: Double
|
||||
var emphasisThreshold: Double
|
||||
var weightEnergy: Double
|
||||
var weightPitch: Double
|
||||
var weightRate: Double
|
||||
var weightPause: Double
|
||||
var weightDuration: Double
|
||||
var emotionEnabled: Bool
|
||||
var emotionSensitivity: Double
|
||||
|
||||
static let defaults = VoiceAnalysisConfig(
|
||||
energyThreshold: 0.5,
|
||||
emphasisThreshold: 0.60,
|
||||
weightEnergy: 0.30,
|
||||
weightPitch: 0.25,
|
||||
weightRate: 0.20,
|
||||
weightPause: 0.15,
|
||||
weightDuration: 0.10,
|
||||
emotionEnabled: false,
|
||||
emotionSensitivity: 0.5
|
||||
)
|
||||
|
||||
init(
|
||||
energyThreshold: Double,
|
||||
emphasisThreshold: Double,
|
||||
weightEnergy: Double,
|
||||
weightPitch: Double,
|
||||
weightRate: Double,
|
||||
weightPause: Double,
|
||||
weightDuration: Double,
|
||||
emotionEnabled: Bool,
|
||||
emotionSensitivity: Double
|
||||
) {
|
||||
self.energyThreshold = energyThreshold
|
||||
self.emphasisThreshold = emphasisThreshold
|
||||
self.weightEnergy = weightEnergy
|
||||
self.weightPitch = weightPitch
|
||||
self.weightRate = weightRate
|
||||
self.weightPause = weightPause
|
||||
self.weightDuration = weightDuration
|
||||
self.emotionEnabled = emotionEnabled
|
||||
self.emotionSensitivity = emotionSensitivity
|
||||
}
|
||||
|
||||
/// Lê a resposta do bridge, caindo no padrão para qualquer campo ausente.
|
||||
init(from json: [String: Any]) {
|
||||
let defaults = VoiceAnalysisConfig.defaults
|
||||
let weights = json["emphasis_weights"] as? [String: Any] ?? [:]
|
||||
self.init(
|
||||
energyThreshold: json["energy_threshold"] as? Double ?? defaults.energyThreshold,
|
||||
emphasisThreshold: json["emphasis_threshold"] as? Double ?? defaults.emphasisThreshold,
|
||||
weightEnergy: weights["energy"] as? Double ?? defaults.weightEnergy,
|
||||
weightPitch: weights["pitch_variation"] as? Double ?? defaults.weightPitch,
|
||||
weightRate: weights["rate_variation"] as? Double ?? defaults.weightRate,
|
||||
weightPause: weights["pause_before"] as? Double ?? defaults.weightPause,
|
||||
weightDuration: weights["duration"] as? Double ?? defaults.weightDuration,
|
||||
emotionEnabled: json["emotion_enabled"] as? Bool ?? defaults.emotionEnabled,
|
||||
emotionSensitivity: json["emotion_sensitivity"] as? Double ?? defaults.emotionSensitivity
|
||||
)
|
||||
}
|
||||
|
||||
func arguments() -> [String: Any] {
|
||||
[
|
||||
"energy_threshold": energyThreshold,
|
||||
"emphasis_threshold": emphasisThreshold,
|
||||
"emphasis_weights": [
|
||||
"energy": weightEnergy,
|
||||
"pitch_variation": weightPitch,
|
||||
"rate_variation": weightRate,
|
||||
"pause_before": weightPause,
|
||||
"duration": weightDuration,
|
||||
],
|
||||
"emotion_enabled": emotionEnabled,
|
||||
"emotion_sensitivity": emotionSensitivity,
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user