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:
João Henrique
2026-08-19 21:29:27 -04:00
co-authored by Claude Opus 5
parent e7748c2c58
commit 1bebee4359
31 changed files with 4622 additions and 83 deletions
+99 -1
View File
@@ -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 {