feat: etapa 5 do assistente — revisão de ênfases com timeline
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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
e7748c2c58
commit
1bebee4359
@@ -15,6 +15,11 @@ struct ModelDownloadView: View {
|
||||
@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"),
|
||||
@@ -33,6 +38,7 @@ struct ModelDownloadView: View {
|
||||
var body: some View {
|
||||
Form {
|
||||
storageSection
|
||||
acousticsSection
|
||||
diarizationSection
|
||||
if let errorMessage {
|
||||
Section {
|
||||
@@ -65,7 +71,7 @@ struct ModelDownloadView: View {
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.task { await refresh() }
|
||||
.task { await refresh(); checkAcoustics() }
|
||||
}
|
||||
|
||||
// MARK: - Transcription language
|
||||
@@ -95,6 +101,98 @@ struct ModelDownloadView: View {
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user