chore: atualização geral

This commit is contained in:
João Henrique
2026-08-19 16:35:29 -04:00
parent 8fca456ceb
commit e7748c2c58
66 changed files with 13037 additions and 4237 deletions
+8 -2
View File
@@ -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")
+374 -153
View File
@@ -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)
+52 -4
View File
@@ -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 {
+49 -7
View File
@@ -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)
}
}
+290 -117
View File
@@ -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 {
+258
View File
@@ -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,
]
}
}