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>
314 lines
13 KiB
Swift
314 lines
13 KiB
Swift
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)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|