Files
gart/code/MacApp/Sources/PhraseReviewView.swift
T
João HenriqueandClaude Sonnet 5 7b5aed79ee feat(voz): legenda por ênfase, forced align, IA local e correções de zoom/revisão
Trabalho da branch feat/revisao-enfases: pipeline de edição por voz ganha
alinhamento forçado (whisperx), roteirização por LLM local (Ollama), e a
etapa 5 (revisão de frases) passa a refletir de verdade o que é aplicado.

- generate_subtitles_by_emphasis: legenda comum cobre o clipe inteiro,
  legenda dinâmica só nas frases de ênfase, e a comum é desativada
  (enabled="0") onde a dinâmica cobre, em vez de nunca ser gerada ali.
- validate_subtitle_layout ignora títulos com enabled="0" — corrige falso
  positivo de colisão contra o que está desativado no lugar dele.
- Corrige zoom/marcador sendo descartado quando a borda encosta exatamente
  no início de um corte.
- Etapa 5 do Assistente: recarrega quando as decisões da IA mudam (com
  fresh=true, ignorando a revisão salva antiga) — resolve a dessincronia
  entre "ativa" na tela e o que já foi cortado no FCPXML.
- Etapa "Processar" reaplica as decisões da revisão (_phrase_actions.json)
  antes da cadeia de remoção de silêncio/legendas — antes, desativar uma
  frase na etapa 5 não tinha efeito nenhum no vídeo final.
- Etapa "Concluído" fundida em "Processar" — abrir no Final Cut/Finder
  aparece assim que termina, sem slide extra.
- Palavra clicável na etapa 5 agora funciona como toggle (clique de novo
  desfaz) e mostra a própria ênfase (sublinhado colorido + peso da fonte).
- fcpxml/forced_align.py, fcpxml/llm_local.py, ai_edit.py: alinhamento
  fonético via whisperx e roteirização local via Ollama/Gemma.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 18:26:04 -04:00

314 lines
13 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
/// The wizard's emphasis-review step.
///
/// Every decision here is about a *sentence* read from the original
/// transcription, so the phrases are listed in full — each line shows the text
/// as it will be said, a switch to keep or drop it from the cut, and the
/// emphasis level. Selecting a line in the list also selects its block on the
/// timeline below, and vice-versa.
struct PhraseReviewView: View {
@ObservedObject var model: PhraseReviewModel
var body: some View {
VSplitView {
VStack(spacing: 0) {
inspectorHeader
Divider()
List(selection: $model.selection) {
ForEach($model.phrases) { $phrase in
PhraseRow(phrase: $phrase, model: model)
.tag(phrase.id)
}
}
.listStyle(.inset)
.onChange(of: model.selection) { _, newValue in
if let newValue { model.goTo(phraseID: newValue) }
}
Divider()
summaryBar
}
.frame(minHeight: 240)
TimelineTracksView(model: model)
.frame(minHeight: 190, idealHeight: 210)
}
.overlay { if model.isLoading { loadingOverlay } }
.focusable()
.onKeyPress(.space) { model.togglePlay(); return .handled }
.onKeyPress(.return) { model.playSelectedPhrase(); return .handled }
.onKeyPress(.leftArrow) { model.selectNeighbour(-1); return .handled }
.onKeyPress(.rightArrow) { model.selectNeighbour(1); return .handled }
.onKeyPress(characters: .decimalDigits) { press in
guard let level = Int(press.characters), (0...3).contains(level),
let selection = model.selection else { return .ignored }
model.setEmphasis(level, for: selection)
return .handled
}
}
private var loadingOverlay: some View {
ZStack {
Color(nsColor: .windowBackgroundColor).opacity(0.85)
VStack(spacing: 10) {
ProgressView()
Text("Montando a revisão…").font(.callout).foregroundStyle(.secondary)
}
}
}
private var summaryBar: some View {
HStack(spacing: 16) {
summaryItem("text.quote", "\(model.phrases.count) frases")
summaryItem("sparkles", "\(model.emphasisCount) com ênfase")
summaryItem("scissors", "\(model.removedCount) fora do corte")
summaryItem("clock", durationLabel(model.keptDuration))
if !model.zooms.isEmpty {
summaryItem("plus.magnifyingglass", "\(model.zooms.count) zooms")
}
Spacer()
if let phrase = selectedPhrase, !phrase.reason.isEmpty {
Label(phrase.reason, systemImage: "brain")
.font(.caption).foregroundStyle(.secondary)
.lineLimit(1).truncationMode(.tail)
}
}
.padding(.horizontal, 14)
.padding(.vertical, 8)
}
private func summaryItem(_ icon: String, _ text: String) -> some View {
Label(text, systemImage: icon).font(.caption).foregroundStyle(.secondary)
}
private func durationLabel(_ seconds: Double) -> String {
String(format: "%02d:%02d finais", Int(seconds) / 60, Int(seconds) % 60)
}
private func pickMedia() {
let panel = NSOpenPanel()
panel.canChooseFiles = true
panel.canChooseDirectories = false
panel.allowsMultipleSelection = false
panel.prompt = "Usar esta mídia"
panel.message = model.source.isEmpty
? "Escolha o arquivo de vídeo desta gravação."
: "Escolha onde está \(model.source)."
if panel.runModal() == .OK, let url = panel.url {
model.useMedia(at: url.path)
}
}
private var selectedPhrase: ReviewPhrase? {
guard let selection = model.selection else { return nil }
return model.phrases.first { $0.id == selection }
}
// MARK: - Inspector de frases
private var inspectorPane: some View {
VStack(spacing: 0) {
inspectorHeader
Divider()
List(selection: $model.selection) {
ForEach($model.phrases) { $phrase in
PhraseRow(phrase: $phrase, model: model)
.tag(phrase.id)
}
}
.listStyle(.inset)
.onChange(of: model.selection) { _, newValue in
if let newValue { model.goTo(phraseID: newValue) }
}
}
}
private var inspectorHeader: some View {
VStack(alignment: .leading, spacing: 6) {
Text("Frases").font(.headline)
Text("Só as frases com ênfase recebem zoom e legenda dinâmica. O resto fica com legenda comum.")
.font(.caption).foregroundStyle(.secondary)
if !model.emotionAvailable {
Label("Emoção da fala não foi detectada nesta análise — ligue em Avançado → Análise de Voz e refaça o passo 3.",
systemImage: "waveform.path.ecg")
.font(.caption2).foregroundStyle(.secondary)
}
HStack(spacing: 8) {
Button("Limpar ênfases") { model.setEmphasisForAll(0) }
.buttonStyle(.link).font(.caption)
Spacer()
Text("0–3 no teclado · ← → navega")
.font(.caption2).foregroundStyle(.secondary)
}
}
.padding(12)
}
}
/// One phrase in the inspector: the line as it will be said, plus every
/// decision attached to it. Kept in one row on purpose — jumping to a separate
/// detail pane to set a toggle would double the clicks on the most repeated
/// action in the screen.
private struct PhraseRow: View {
@Binding var phrase: ReviewPhrase
@ObservedObject var model: PhraseReviewModel
@State private var isEditing = false
var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 6) {
Text(phrase.timecode)
.font(.system(.caption2, design: .monospaced))
.foregroundStyle(.secondary)
if phrase.takeBoundary {
Image(systemName: "scissors.badge.ellipsis")
.font(.caption2).foregroundStyle(.orange)
.help("Nova tomada começa aqui")
}
if phrase.isTrimmed {
Image(systemName: "arrow.left.and.right.square")
.font(.caption2).foregroundStyle(.blue)
.help("Frase cortada nas pontas")
}
if model.emotionAvailable {
emotionChip
}
Spacer()
Toggle("", isOn: $phrase.active)
.toggleStyle(.switch)
.controlSize(.mini)
.labelsHidden()
.help(phrase.active ? "No corte" : "Fora do corte")
}
if isEditing {
TextField("Texto da frase", text: $phrase.text, axis: .vertical)
.textFieldStyle(.roundedBorder)
.font(.callout)
.onSubmit { isEditing = false }
} else {
Text(phrase.text.isEmpty ? "(sem texto)" : phrase.text)
.font(.callout)
.foregroundStyle(phrase.active ? .primary : .secondary)
.strikethrough(!phrase.active)
.onTapGesture(count: 2) { isEditing = true }
}
HStack(spacing: 8) {
Picker("", selection: $phrase.emphasis) {
ForEach(0..<4, id: \.self) { level in
Text(EmphasisPalette.label(level)).tag(level)
}
}
.pickerStyle(.segmented)
.controlSize(.mini)
.labelsHidden()
.disabled(!phrase.active)
Picker("", selection: $phrase.track) {
Text("Roteiro").tag(ReviewPhrase.trackScript)
Text("Bastidor").tag(ReviewPhrase.trackBackstage)
}
.pickerStyle(.menu)
.controlSize(.mini)
.labelsHidden()
.frame(width: 92)
}
if model.selection == phrase.id && !phrase.words.isEmpty {
wordTrimmer
}
}
.padding(.vertical, 4)
.opacity(phrase.active ? 1 : 0.55)
}
/// The delivery emotion the acoustics suggest. Shown faded below its own
/// confidence: a guess the analysis is unsure about should not compete for
/// attention with the emphasis decision, which is the point of the row.
private var emotionChip: some View {
let (label, icon) = ReviewPhrase.emotionLabel(phrase.emotion)
return Label(label, systemImage: icon)
.font(.caption2)
.padding(.horizontal, 5)
.padding(.vertical, 1)
.background(
Capsule().fill(Color.secondary.opacity(0.12))
)
.foregroundStyle(phrase.emotionConfidence >= 0.5 ? .secondary : .tertiary)
.help("Emoção da entrega: \(label) — confiança \(Int(phrase.emotionConfidence * 100))%")
}
/// Trimming by pointing at the transcript: click a word to start the phrase
/// there, option-click to end it there. Same edit as dragging the block's
/// edge on the timeline, but reachable while reading the line.
private var wordTrimmer: some View {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 4) {
Text("Cortar pelas palavras").font(.caption2).foregroundStyle(.secondary)
Spacer()
if phrase.isTrimmed {
Button("Inteira") { model.resetTrim(phrase.id) }
.buttonStyle(.link).font(.caption2)
}
}
FlowWords(words: phrase.words, phrase: phrase) { word, edge in
model.trimToWord(word, edge: edge, in: phrase.id)
}
Text("Clique = começa/desfaz aqui · ⌥clique = termina/desfaz aqui · sublinhado = ênfase da palavra")
.font(.caption2).foregroundStyle(.tertiary)
}
.padding(.top, 2)
}
}
/// The phrase's words as wrapping chips, dimmed where they fall outside the
/// trim and underlined where the acoustics mark them as an emphasis peak —
/// the same word-level signal `05-zoom.md` picks a punch-in's `start` from,
/// made visible instead of buried in the JSON.
private struct FlowWords: View {
let words: [ReviewWord]
let phrase: ReviewPhrase
let onTrim: (ReviewWord, TrimEdge) -> Void
var body: some View {
// A LazyVGrid with adaptive columns wraps chips without a custom layout;
// phrases are short enough that the slight raggedness beats the cost of
// hand-rolling a flow layout here.
LazyVGrid(columns: [GridItem(.adaptive(minimum: 44), spacing: 3)],
alignment: .leading, spacing: 3) {
ForEach(words) { word in
let kept = word.start >= phrase.trimStart - 0.001 && word.end <= phrase.trimEnd + 0.001
let level = EmphasisPalette.levelFromScore(word.emphasis)
Text(word.text)
.font(.caption2)
.fontWeight(level >= 2 ? .semibold : .regular)
.padding(.horizontal, 4)
.padding(.vertical, 2)
.background(
RoundedRectangle(cornerRadius: 3)
.fill(kept ? Color.accentColor.opacity(0.12) : Color.secondary.opacity(0.08))
)
.overlay(alignment: .bottom) {
if level >= 1 {
Rectangle()
.fill(EmphasisPalette.color(level))
.frame(height: 2)
.padding(.horizontal, 3)
}
}
.foregroundStyle(kept ? .primary : .secondary)
.strikethrough(!kept)
.help(
level >= 1
? "Ênfase \(EmphasisPalette.label(level).lowercased()) (\(Int(word.emphasis * 100))%)"
: "Sem ênfase"
)
.onTapGesture {
onTrim(word, NSEvent.modifierFlags.contains(.option) ? .end : .start)
}
}
}
}
}