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
+19 -11
View File
@@ -12,6 +12,7 @@ struct GArtApp: App {
}
enum ActiveTab: Hashable {
case wizard
case project
case captions
case voiceAnalysis
@@ -20,19 +21,23 @@ enum ActiveTab: Hashable {
}
struct ContentView: View {
@State private var activeTab: ActiveTab? = .project
@State private var activeTab: ActiveTab? = .wizard
var body: some View {
NavigationSplitView {
List(selection: $activeTab) {
Label("Projeto", systemImage: "film")
.tag(ActiveTab.project)
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("Assistente", systemImage: "wand.and.stars")
.tag(ActiveTab.wizard)
Section("Avançado") {
Label("Projeto", systemImage: "film")
.tag(ActiveTab.project)
Label("Legendas", 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")
.tag(ActiveTab.about)
}
@@ -40,19 +45,22 @@ struct ContentView: View {
.navigationSplitViewColumnWidth(min: 180, ideal: 200)
} detail: {
switch activeTab {
case .wizard, nil:
WizardView().id(UUID())
.navigationTitle("Assistente")
case .project:
ProjectView().id(UUID())
.navigationTitle("Projeto")
case .captions:
CaptionsView().id(UUID())
.navigationTitle("Legendas Dinâmicas")
.navigationTitle("Legendas")
case .voiceAnalysis:
VoiceAnalysisView().id(UUID())
.navigationTitle("Análise de Voz")
case .models:
ModelDownloadView().id(UUID())
.navigationTitle("Modelos")
case .about, nil:
case .about:
AboutView()
.navigationTitle("Sobre")
}