Transforma a etapa "colar decisões" numa tela de lapidação: a sugestão da IA chega carregada e o editor afina frase a frase o que é ênfase e o que fica fora. Essa marcação é o norte da etapa 6 — só as frases com ênfase recebem zoom e legenda dinâmica; as demais ficam com legenda comum. O campo de colar o JSON sobe para a etapa 4, então a numeração das etapas não muda e a etapa 6 segue intacta. Backend (fcpxml/phrase_review.py): - build_phrase_review funde o _voice_timeline.json com as actions da IA - trim por frase que anda em fronteira de palavra; corte parcial da IA chega como trim em vez de ser arredondado fora - phrase_review_to_actions volta a cuts/zooms + emphasis_spans - merge_saved_decisions reaplica só as decisões salvas sobre uma revisão remontada da análise atual, para reprocessar a voz não ficar mascarado - resolve_source acha a mídia: o voice timeline guarda só o nome do arquivo App (SwiftUI): - layout de sala de edição: preview em cima, inspector à direita, timeline atravessando embaixo com seis trilhas rotuladas - preview enquadra no formato de entrega lido do .fcpxml (fonte horizontal, projeto vertical), com alternância para a mídia original - reprodução pula os trechos removidos e para no fim do trecho - zoom manual por trecho marcado, sem guardar escala: a forma vem das configurações de Análise de Voz no render - emoção da fala exposta por frase Correções encontradas no caminho: - VideoPlayer (AVKit) aborta em runtime no app compilado por swiftc; trocado por AVPlayerLayer (ver Engine/docs/05_EXPERIENCIAS.md #22) - teste que ainda afirmava o default zoom scale=1.3 removido do parser (#21) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
546 lines
22 KiB
Swift
546 lines
22 KiB
Swift
import AppKit
|
|
import SwiftUI
|
|
|
|
/// Guia "Modelos" — replica a UI de gerenciamento de modelos do Hex:
|
|
/// cards curados com badge "Recomendado", estrelas de precisão/velocidade,
|
|
/// estado Instalado/Em uso, barra de progresso de download, e ações
|
|
/// Download / Selecionar / Mostrar no Finder / Remover.
|
|
struct ModelDownloadView: View {
|
|
@State private var catalog: Catalog?
|
|
@State private var downloading: String?
|
|
@State private var progress: Double = 0
|
|
@State private var isLoading = false
|
|
@State private var errorMessage: String?
|
|
@State private var modelsDirText: String = ""
|
|
@State private var hfTokenText: String = ""
|
|
@State private var numSpeakersText: String = ""
|
|
@State private var language: String = "auto"
|
|
@State private var acousticsAvailable: Bool?
|
|
@State private var acousticsMessage: String = ""
|
|
@State private var isInstallingAcoustics = false
|
|
@State private var acousticsInstallLog: String = ""
|
|
@State private var acousticsInstallError: String?
|
|
|
|
private let languages: [(String, String)] = [
|
|
("auto", "Detectar automaticamente"),
|
|
("pt", "Português"),
|
|
("en", "Inglês"),
|
|
("es", "Espanhol"),
|
|
("fr", "Francês"),
|
|
("de", "Alemão"),
|
|
("it", "Italiano"),
|
|
("nl", "Holandês"),
|
|
("ja", "Japonês"),
|
|
("ko", "Coreano"),
|
|
("zh", "Chinês"),
|
|
]
|
|
|
|
var body: some View {
|
|
Form {
|
|
storageSection
|
|
acousticsSection
|
|
diarizationSection
|
|
if let errorMessage {
|
|
Section {
|
|
Label(errorMessage, systemImage: "exclamationmark.triangle.fill")
|
|
.foregroundStyle(.red)
|
|
}
|
|
}
|
|
Section {
|
|
if isLoading {
|
|
ProgressView().controlSize(.small)
|
|
.frame(maxWidth: .infinity, alignment: .center)
|
|
} else if let catalog {
|
|
if catalog.installedCount == 0 {
|
|
noModelChooser(catalog)
|
|
}
|
|
ForEach(catalog.orderedModels) { model in
|
|
modelRow(model)
|
|
}
|
|
}
|
|
} header: {
|
|
Text("Modelos de Transcrição")
|
|
} footer: {
|
|
Label("Os modelos rodam localmente nesta máquina. Downloads ficam na pasta configurada acima.",
|
|
systemImage: "lock.shield")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
if catalog?.installedCount ?? 0 > 0 {
|
|
languageSection
|
|
}
|
|
}
|
|
.formStyle(.grouped)
|
|
.task { await refresh(); checkAcoustics() }
|
|
}
|
|
|
|
// MARK: - Transcription language
|
|
|
|
/// Idiooma padrão usado em todas as transcrições do programa. Aparece abaixo
|
|
/// do modelo escolhido, conforme o modelo selecionado na aba Modelos.
|
|
private var languageSection: some View {
|
|
Section {
|
|
Picker("Idioma", selection: $language) {
|
|
ForEach(languages, id: \.0) { code, label in
|
|
Text(label).tag(code)
|
|
}
|
|
}
|
|
} header: {
|
|
Text("Idioma de Transcrição")
|
|
} footer: {
|
|
Text("Usado em todas as transcrições do programa. Escolha conforme o modelo selecionado acima.")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.onChange(of: language) { _, newValue in
|
|
saveLanguage(newValue)
|
|
}
|
|
}
|
|
|
|
private func saveLanguage(_ code: String) {
|
|
PythonBridge.call(command: "set_language", arguments: ["language": code]) { _, _ in }
|
|
}
|
|
|
|
// MARK: - Acoustic analysis (librosa)
|
|
|
|
/// A ênfase de voz (pitch/energia) precisa do `librosa`, que é uma
|
|
/// dependência opcional — sem ela `layers.acoustics` vem `false` na
|
|
/// análise e a decisão de zoom fica sem base real. Antes disso só dava
|
|
/// pra descobrir lendo o JSON exportado; agora o app já diz e resolve.
|
|
private var acousticsSection: some View {
|
|
Section {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
if let acousticsAvailable {
|
|
Label(
|
|
acousticsMessage.isEmpty
|
|
? (acousticsAvailable ? "Disponível" : "Indisponível")
|
|
: acousticsMessage,
|
|
systemImage: acousticsAvailable ? "checkmark.circle.fill" : "exclamationmark.triangle.fill"
|
|
)
|
|
.font(.caption)
|
|
.foregroundStyle(acousticsAvailable ? Color.green : Color.orange)
|
|
} else {
|
|
Label("Verificando…", systemImage: "hourglass")
|
|
.font(.caption).foregroundStyle(.secondary)
|
|
}
|
|
|
|
if acousticsAvailable == false {
|
|
Button {
|
|
installAcoustics()
|
|
} label: {
|
|
if isInstallingAcoustics {
|
|
HStack { ProgressView().controlSize(.small); Text("Instalando…") }
|
|
} else {
|
|
Label("Instalar (uv sync --all-extras)", systemImage: "arrow.down.circle")
|
|
}
|
|
}
|
|
.disabled(isInstallingAcoustics)
|
|
|
|
if !acousticsInstallLog.isEmpty {
|
|
ScrollView {
|
|
Text(acousticsInstallLog)
|
|
.font(.system(.caption2, design: .monospaced))
|
|
.foregroundStyle(.secondary)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
.frame(height: 90)
|
|
.background(RoundedRectangle(cornerRadius: 6).fill(Color.secondary.opacity(0.06)))
|
|
}
|
|
if let acousticsInstallError {
|
|
Label(acousticsInstallError, systemImage: "xmark.circle.fill")
|
|
.font(.caption).foregroundStyle(.red)
|
|
}
|
|
}
|
|
}
|
|
} header: {
|
|
Text("Análise Acústica (zoom por voz)")
|
|
} footer: {
|
|
Text("Mede a energia e o tom de voz de verdade, para os candidatos a zoom da edição por voz. Sem isso, a análise ainda transcreve e decide cortes pelo texto — só o zoom fica sem base acústica.")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
private func checkAcoustics() {
|
|
PythonBridge.call(command: "acoustics_capability") { result, err in
|
|
DispatchQueue.main.async {
|
|
guard let result, result["ok"] as? Bool == true else { return }
|
|
acousticsAvailable = result["available"] as? Bool
|
|
acousticsMessage = result["message"] as? String ?? ""
|
|
}
|
|
}
|
|
}
|
|
|
|
private func installAcoustics() {
|
|
isInstallingAcoustics = true
|
|
acousticsInstallLog = ""
|
|
acousticsInstallError = nil
|
|
// --all-extras, não só "intelligence": `uv sync` substitui o
|
|
// ambiente pelos extras pedidos em vez de somar, então um sync
|
|
// parcial aqui derrubaria dev/transcribe/diarização já instalados.
|
|
PythonBridge.runUV(arguments: ["sync", "--all-extras"]) { line in
|
|
DispatchQueue.main.async {
|
|
acousticsInstallLog += (acousticsInstallLog.isEmpty ? "" : "\n") + line
|
|
}
|
|
} completion: { code, err in
|
|
DispatchQueue.main.async {
|
|
isInstallingAcoustics = false
|
|
if code != 0 {
|
|
acousticsInstallError = err ?? "Falha ao instalar."
|
|
}
|
|
checkAcoustics()
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Diarization
|
|
|
|
private var diarizationSection: some View {
|
|
Section {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
if let msg = catalog?.diarizationMessage, !msg.isEmpty {
|
|
Label(msg, systemImage: catalog?.diarization == true ? "checkmark.circle" : "exclamationmark.triangle")
|
|
.font(.caption)
|
|
.foregroundStyle(catalog?.diarization == true ? Color.secondary : Color.orange)
|
|
}
|
|
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. 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 {
|
|
args["token"] = hfTokenText
|
|
}
|
|
PythonBridge.call(command: "set_diarization", arguments: args) { result, _ in
|
|
DispatchQueue.main.async {
|
|
hfTokenText = ""
|
|
numSpeakersText = result?["num_speakers"] as? String ?? numSpeakersText
|
|
Task { await refresh() }
|
|
}
|
|
}
|
|
}
|
|
|
|
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 {
|
|
Section("Local de Armazenamento") {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
HStack(spacing: 8) {
|
|
TextField("Pasta de modelos", text: $modelsDirText)
|
|
.textFieldStyle(.roundedBorder)
|
|
.disabled(true)
|
|
Button {
|
|
pickAndSaveFolder()
|
|
} label: {
|
|
Image(systemName: "folder")
|
|
}
|
|
.help("Selecionar pasta de modelos e salvar")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Present an NSOpenPanel to choose a folder, then save it and refresh the
|
|
/// installed-model list so newly scanned downloads appear immediately.
|
|
private func pickAndSaveFolder() {
|
|
let panel = NSOpenPanel()
|
|
panel.canChooseDirectories = true
|
|
panel.canChooseFiles = false
|
|
panel.allowsMultipleSelection = false
|
|
panel.prompt = "Selecionar"
|
|
panel.message = "Escolha a pasta onde os modelos serão salvos"
|
|
if let current = URL(string: modelsDirText), FileManager.default.fileExists(atPath: current.path) {
|
|
panel.directoryURL = current
|
|
}
|
|
if panel.runModal() == .OK, let url = panel.url {
|
|
modelsDirText = url.path
|
|
saveDir()
|
|
}
|
|
}
|
|
|
|
// MARK: - No model chooser (first run)
|
|
|
|
private func noModelChooser(_ catalog: Catalog) -> some View {
|
|
let recommended = catalog.orderedModels.filter { $0.isRecommended }
|
|
return VStack(alignment: .leading, spacing: 10) {
|
|
HStack(spacing: 12) {
|
|
Image(systemName: "arrow.down.circle.fill")
|
|
.font(.title3)
|
|
.foregroundStyle(.tint)
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
Text("Escolha um modelo de transcrição")
|
|
.font(.body.weight(.medium))
|
|
Text("Baixe um modelo local para começar a transcrever.")
|
|
.font(.caption).foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
if !recommended.isEmpty {
|
|
HStack(spacing: 10) {
|
|
ForEach(recommended) { model in
|
|
noModelCard(model)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding(10)
|
|
.background(Color(nsColor: .controlBackgroundColor),
|
|
in: RoundedRectangle(cornerRadius: 8))
|
|
}
|
|
|
|
private func noModelCard(_ model: ModelInfo) -> some View {
|
|
Button {
|
|
startDownload(model)
|
|
} label: {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
HStack(alignment: .top, spacing: 8) {
|
|
Text(model.displayName).font(.body.weight(.medium))
|
|
Spacer(minLength: 8)
|
|
Text("Recomendado")
|
|
.font(.caption2.weight(.medium))
|
|
.foregroundStyle(.tint)
|
|
.padding(.horizontal, 6).padding(.vertical, 2)
|
|
.background(Color.accentColor.opacity(0.12), in: Capsule())
|
|
}
|
|
HStack(spacing: 6) {
|
|
Text(model.language)
|
|
Text("·")
|
|
Text(model.storage)
|
|
Spacer()
|
|
Image(systemName: "arrow.down.circle")
|
|
}
|
|
.font(.caption).foregroundStyle(.secondary)
|
|
}
|
|
.padding(12)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(Color(nsColor: .windowBackgroundColor).opacity(0.45),
|
|
in: RoundedRectangle(cornerRadius: 8))
|
|
.overlay(RoundedRectangle(cornerRadius: 8).stroke(Color.accentColor.opacity(0.4), lineWidth: 1))
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
|
|
// MARK: - Model row
|
|
|
|
private func modelRow(_ model: ModelInfo) -> some View {
|
|
let isThisDownloading = downloading == model.internalName
|
|
return HStack(spacing: 0) {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
HStack(spacing: 7) {
|
|
Text(model.displayName).font(.body.weight(.medium))
|
|
if model.isRecommended && !model.isDownloaded {
|
|
badge("Recomendado", color: .blue)
|
|
}
|
|
if model.isDownloaded {
|
|
badge("Instalado", color: .green)
|
|
}
|
|
if model.isSelected {
|
|
badge("Em uso", color: .purple)
|
|
}
|
|
}
|
|
HStack(spacing: 10) {
|
|
Text(model.language).font(.caption).foregroundStyle(.secondary)
|
|
rating("Precisão", filled: model.accuracy)
|
|
rating("Velocidade", filled: model.speed)
|
|
Spacer()
|
|
Text(model.storage).font(.caption).foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
trailingControls(for: model, isDownloading: isThisDownloading)
|
|
}
|
|
.padding(.vertical, 6)
|
|
}
|
|
|
|
private func rating(_ label: String, filled: Int) -> some View {
|
|
HStack(spacing: 4) {
|
|
Text(label).font(.caption2).foregroundStyle(.secondary)
|
|
HStack(spacing: 3) {
|
|
ForEach(0..<5, id: \.self) { i in
|
|
Image(systemName: i < filled ? "circle.fill" : "circle")
|
|
.font(.system(size: 7))
|
|
.foregroundColor(i < filled ? .blue : .gray.opacity(0.5))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func badge(_ text: String, color: Color) -> some View {
|
|
Text(text)
|
|
.font(.caption2.weight(.medium))
|
|
.foregroundStyle(color)
|
|
.padding(.horizontal, 6).padding(.vertical, 2)
|
|
.background(color.opacity(0.12), in: Capsule())
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func trailingControls(for model: ModelInfo, isDownloading: Bool) -> some View {
|
|
if isDownloading {
|
|
HStack(spacing: 8) {
|
|
ProgressView(value: progress).progressViewStyle(.linear).frame(width: 120)
|
|
Text("\(Int(progress * 100))%").font(.caption).monospacedDigit().foregroundStyle(.secondary)
|
|
Button("Cancelar", role: .destructive) { cancelDownload(model) }
|
|
.controlSize(.small)
|
|
}
|
|
} else if model.isDownloaded {
|
|
if model.isSelected {
|
|
Label("Em uso", systemImage: "checkmark.circle.fill")
|
|
.foregroundStyle(.green).font(.callout)
|
|
} else {
|
|
Button("Selecionar") { select(model) }
|
|
.buttonStyle(.borderedProminent).controlSize(.small)
|
|
}
|
|
Menu {
|
|
Button { openInFinder(model) } label: {
|
|
Label("Mostrar no Finder", systemImage: "folder")
|
|
}
|
|
Divider()
|
|
Button(role: .destructive) { delete(model) } label: {
|
|
Label("Remover Download…", systemImage: "trash")
|
|
}
|
|
} label: {
|
|
Image(systemName: "ellipsis.circle").foregroundStyle(.secondary)
|
|
}
|
|
.menuStyle(.borderlessButton).fixedSize()
|
|
} else {
|
|
Button("Download") { startDownload(model) }
|
|
.buttonStyle(.borderedProminent).controlSize(.small)
|
|
}
|
|
}
|
|
|
|
// MARK: - Actions
|
|
|
|
private func refresh() async {
|
|
isLoading = true
|
|
errorMessage = nil
|
|
PythonBridge.call(command: "catalog") { result, err in
|
|
DispatchQueue.main.async {
|
|
isLoading = false
|
|
if let result {
|
|
catalog = Catalog(json: result)
|
|
modelsDirText = catalog?.modelsDir ?? ""
|
|
downloading = nil
|
|
progress = 0
|
|
numSpeakersText = catalog?.numSpeakers ?? ""
|
|
language = catalog?.language ?? "auto"
|
|
} else {
|
|
errorMessage = err ?? "Falha ao carregar modelos."
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func startDownload(_ model: ModelInfo) {
|
|
downloading = model.internalName
|
|
progress = 0
|
|
PythonBridge.run(command: "download", arguments: ["model": model.internalName]) { obj in
|
|
DispatchQueue.main.async {
|
|
if let f = obj["fraction"] as? NSNumber { progress = f.doubleValue }
|
|
if obj["type"] as? String == "error" {
|
|
errorMessage = obj["message"] as? String
|
|
}
|
|
}
|
|
} completion: { _, _ in
|
|
DispatchQueue.main.async { Task { await refresh() } }
|
|
}
|
|
}
|
|
|
|
private func cancelDownload(_ model: ModelInfo) {
|
|
PythonBridge.call(command: "cancel", arguments: ["model": model.internalName]) { _, _ in }
|
|
}
|
|
|
|
private func select(_ model: ModelInfo) {
|
|
PythonBridge.call(command: "select", arguments: ["model": model.internalName]) { _, _ in
|
|
DispatchQueue.main.async { Task { await refresh() } }
|
|
}
|
|
}
|
|
|
|
private func delete(_ model: ModelInfo) {
|
|
PythonBridge.call(command: "delete", arguments: ["model": model.internalName]) { _, _ in
|
|
DispatchQueue.main.async { Task { await refresh() } }
|
|
}
|
|
}
|
|
|
|
private func openInFinder(_ model: ModelInfo) {
|
|
PythonBridge.call(command: "open_finder", arguments: ["model": model.internalName]) { _, _ in }
|
|
}
|
|
|
|
private func saveDir() {
|
|
let path = modelsDirText.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !path.isEmpty else { return }
|
|
PythonBridge.call(command: "set_models_dir", arguments: ["dir": path]) { result, _ in
|
|
DispatchQueue.main.async {
|
|
if let result, result["ok"] as? Bool == true {
|
|
self.catalog?.modelsDir = result["models_dir"] as? String ?? ""
|
|
self.modelsDirText = result["models_dir"] as? String ?? path
|
|
Task { await refresh() }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|