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>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
711c397dfe
commit
7b5aed79ee
@@ -9,7 +9,7 @@ import AppKit
|
||||
/// sair do app e escolher um arquivo na mão) vira copiar/colar assistido
|
||||
/// dentro da própria tela.
|
||||
enum WizardStep: Int, CaseIterable, Identifiable {
|
||||
case projeto, transcricao, analise, exportarChat, revisar, finalizar, concluido
|
||||
case projeto, transcricao, analise, exportarChat, revisar, finalizar
|
||||
var id: Int { rawValue }
|
||||
|
||||
var titulo: String {
|
||||
@@ -20,7 +20,6 @@ enum WizardStep: Int, CaseIterable, Identifiable {
|
||||
case .exportarChat: return "Decisões da IA"
|
||||
case .revisar: return "Revisar ênfases"
|
||||
case .finalizar: return "Processar"
|
||||
case .concluido: return "Concluído"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -54,10 +53,18 @@ struct WizardView: View {
|
||||
@State private var appliedPath: String?
|
||||
@State private var skippedVoiceEdit = false
|
||||
|
||||
// Passo 4 (alternativa) — gerar o roteiro direto por IA local (Ollama/Gemma 3)
|
||||
@State private var isGeneratingScript = false
|
||||
@State private var generateScriptModel = "gemma3:12b"
|
||||
@State private var generateScriptFeedback = ""
|
||||
@State private var ollamaModels: [String] = []
|
||||
|
||||
// Passo 5 — revisar ênfases
|
||||
@StateObject private var reviewModel = PhraseReviewModel()
|
||||
@State private var reviewLoadedFor: String?
|
||||
@State private var reviewLoadedForDecisions: String?
|
||||
@State private var phraseReviewPath: String?
|
||||
@State private var phraseActionsPath: String?
|
||||
|
||||
// Passo 6 — processamento final
|
||||
@State private var finalSilences = true
|
||||
@@ -178,7 +185,6 @@ struct WizardView: View {
|
||||
case .exportarChat: exportarChatStep
|
||||
case .revisar: revisarStep
|
||||
case .finalizar: finalizarStep
|
||||
case .concluido: concluidoStep
|
||||
}
|
||||
}
|
||||
|
||||
@@ -304,6 +310,54 @@ struct WizardView: View {
|
||||
.font(.callout).foregroundStyle(.secondary)
|
||||
|
||||
if let voiceTimelinePath {
|
||||
// Alternativa automática: em vez de copiar/colar no chat, manda a
|
||||
// própria voice timeline (o arquivo inteiro) junto com o brief para
|
||||
// o modelo local (Ollama/Gemma 3) decidir a edição de uma vez —
|
||||
// cortes, zooms e textos numa única chamada, sem sair do app.
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("OU gere o roteiro por IA local (Ollama/Gemma 3)").font(.callout.weight(.semibold))
|
||||
Text("O app envia a voice timeline completa (o arquivo) acompanhada do pedido para o modelo local decidir os cortes, zooms e textos de uma vez. Nada de copiar e colar.")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
HStack {
|
||||
if ollamaModels.isEmpty {
|
||||
TextField("Modelo (ex.: gemma3:12b, llama3)", text: $generateScriptModel)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: 260)
|
||||
} else {
|
||||
Picker("Modelo", selection: $generateScriptModel) {
|
||||
ForEach(ollamaModels, id: \.self) { m in
|
||||
Text(m).tag(m)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.frame(maxWidth: 260)
|
||||
TextField("Ou outro", text: $generateScriptModel)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: 120)
|
||||
}
|
||||
Button {
|
||||
generateScript(voiceTimelinePath: voiceTimelinePath)
|
||||
} label: {
|
||||
if isGeneratingScript {
|
||||
HStack { ProgressView().controlSize(.small); Text("Gerando…") }
|
||||
} else {
|
||||
Label("Gerar roteiro por IA local", systemImage: "sparkles")
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(isGeneratingScript || voiceTimelinePath.isEmpty)
|
||||
}
|
||||
if !generateScriptFeedback.isEmpty {
|
||||
Label(generateScriptFeedback, systemImage: "checkmark.circle.fill")
|
||||
.font(.caption).foregroundStyle(.green)
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.background(RoundedRectangle(cornerRadius: 8).fill(Color.green.opacity(0.07)))
|
||||
.onAppear { fetchOllamaModels() }
|
||||
|
||||
Divider().padding(.vertical, 4)
|
||||
|
||||
Button {
|
||||
copyForChat(path: voiceTimelinePath)
|
||||
} label: {
|
||||
@@ -412,6 +466,11 @@ struct WizardView: View {
|
||||
.onAppear { loadReviewIfNeeded() }
|
||||
}
|
||||
|
||||
/// Processing and its result live on the SAME slide: the moment the last
|
||||
/// operation finishes (`finalPath` gets set), the open/reveal buttons
|
||||
/// appear right below the "Processar" button instead of gating behind a
|
||||
/// separate "Concluído" step the user has to click into — there was
|
||||
/// nothing on that slide worth a click of its own.
|
||||
private var finalizarStep: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("6. Finalize o corte").font(.title3.weight(.semibold))
|
||||
@@ -437,18 +496,11 @@ struct WizardView: View {
|
||||
.controlSize(.large)
|
||||
.disabled(isFinalizing || (!finalSilences && !finalFillers && !finalSubtitles && !finalDynamicSubtitles))
|
||||
|
||||
if !finalStatus.isEmpty && !isFinalizing {
|
||||
Text(finalStatus).font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var concluidoStep: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Label("Concluído", systemImage: "checkmark.seal.fill")
|
||||
.font(.title3.weight(.semibold))
|
||||
.foregroundStyle(.green)
|
||||
if let finalPath {
|
||||
if let finalPath, !isFinalizing {
|
||||
Divider().padding(.vertical, 4)
|
||||
Label("Concluído", systemImage: "checkmark.seal.fill")
|
||||
.font(.callout.weight(.semibold))
|
||||
.foregroundStyle(.green)
|
||||
Text(finalPath).font(.caption).foregroundStyle(.secondary).lineLimit(1).truncationMode(.middle)
|
||||
HStack {
|
||||
Button("Abrir no Final Cut Pro") { NSWorkspace.shared.open(URL(fileURLWithPath: finalPath)) }
|
||||
@@ -456,22 +508,27 @@ struct WizardView: View {
|
||||
Button("Mostrar no Finder") {
|
||||
NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: finalPath)])
|
||||
}
|
||||
Spacer()
|
||||
Button("Começar outro projeto") { resetWizard() }
|
||||
}
|
||||
} else if !finalStatus.isEmpty && !isFinalizing {
|
||||
Text(finalStatus).font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
Divider().padding(.vertical, 8)
|
||||
Button("Começar outro projeto") { resetWizard() }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Navegação
|
||||
|
||||
/// `.finalizar` is the last step now — once it has a `finalPath`, the
|
||||
/// slide's own "Começar outro projeto" button is the way forward, so the
|
||||
/// footer's "Continuar" would be a second, redundant path to nowhere.
|
||||
private var navFooter: some View {
|
||||
HStack {
|
||||
if step != .projeto && step != .concluido {
|
||||
if step != .projeto {
|
||||
Button("Voltar") { goBack() }
|
||||
}
|
||||
Spacer()
|
||||
if step != .concluido {
|
||||
if step != .finalizar || finalPath == nil {
|
||||
Button(step == .finalizar ? "Concluir" : "Continuar") { goNext() }
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(!canAdvance)
|
||||
@@ -490,18 +547,19 @@ struct WizardView: View {
|
||||
// exigir mais uma confirmação.
|
||||
case .revisar: return true
|
||||
case .finalizar: return finalPath != nil && !isFinalizing
|
||||
case .concluido: return false
|
||||
}
|
||||
}
|
||||
|
||||
private func goNext() {
|
||||
guard let next = WizardStep(rawValue: step.rawValue + 1) else { return }
|
||||
// Sair da revisão grava o que foi decidido (e as ações derivadas dela)
|
||||
// ao lado da análise de voz. Nada é renderizado aqui: a etapa 6 é que
|
||||
// lê esse arquivo para dar zoom e legenda dinâmica só nas ênfases.
|
||||
// Sair da revisão grava o que foi decidido e as ações derivadas dela
|
||||
// (`_phrase_actions.json`) ao lado da análise de voz — é esse arquivo
|
||||
// que `finalizeProcessing` reaplica na etapa 6, para que desativar uma
|
||||
// frase aqui realmente a remova do vídeo final, e não só do registro.
|
||||
if step == .revisar {
|
||||
reviewModel.save { path in
|
||||
phraseReviewPath = path
|
||||
reviewModel.save { reviewPath, actionsPath in
|
||||
phraseReviewPath = reviewPath
|
||||
phraseActionsPath = actionsPath
|
||||
}
|
||||
}
|
||||
step = next
|
||||
@@ -521,7 +579,9 @@ struct WizardView: View {
|
||||
appliedPath = nil
|
||||
skippedVoiceEdit = false
|
||||
reviewLoadedFor = nil
|
||||
reviewLoadedForDecisions = nil
|
||||
phraseReviewPath = nil
|
||||
phraseActionsPath = nil
|
||||
finalStatus = ""
|
||||
finalPath = nil
|
||||
errorMessage = nil
|
||||
@@ -727,21 +787,33 @@ struct WizardView: View {
|
||||
copiedFeedback = "Copiado — cole (⌘V) numa conversa com o Claude."
|
||||
}
|
||||
|
||||
/// Monta a revisão uma vez por análise de voz. Voltar e avançar de novo não
|
||||
/// recarrega: isso jogaria fora as edições manuais em silêncio, que é
|
||||
/// exatamente o que esta tela existe para preservar.
|
||||
/// Monta a revisão uma vez por análise de voz. Voltar e avançar de novo com
|
||||
/// as MESMAS decisões não recarrega: isso jogaria fora as edições manuais
|
||||
/// em silêncio, que é exatamente o que esta tela existe para preservar.
|
||||
///
|
||||
/// Mas se o usuário voltou à etapa 4 e colou/gerou um JSON de decisões
|
||||
/// DIFERENTE do que gerou a revisão atual, isso é recarregado — e com
|
||||
/// `fresh: true`, para que o `active`/ênfase recém-derivado dessas
|
||||
/// decisões novas não seja imediatamente sobrescrito pela revisão salva
|
||||
/// da visita anterior (`merge_saved_decisions`, do lado Python). Sem isso,
|
||||
/// a tela ficava presa nas decisões antigas mesmo depois de reaplicar o
|
||||
/// corte — a dessincronia relatada entre "ativa aqui" e "já cortado no
|
||||
/// FCPXML".
|
||||
private func loadReviewIfNeeded() {
|
||||
guard let voiceTimelinePath, reviewLoadedFor != voiceTimelinePath else { return }
|
||||
guard let voiceTimelinePath else { return }
|
||||
let decisionsChanged = reviewLoadedForDecisions != nil && reviewLoadedForDecisions != decisionsText
|
||||
guard reviewLoadedFor != voiceTimelinePath || decisionsChanged else { return }
|
||||
reviewLoadedFor = voiceTimelinePath
|
||||
reviewLoadedForDecisions = decisionsText
|
||||
// A pasta do projeto e a do .fcpxml entram como onde procurar a mídia:
|
||||
// a análise de voz guarda só o nome do arquivo, não o caminho.
|
||||
reviewModel.load(
|
||||
voiceTimelinePath: voiceTimelinePath,
|
||||
decisionsJSON: decisionsText,
|
||||
outputFolder: outputFolder,
|
||||
mediaFolder: projectPath.map { URL(fileURLWithPath: $0).deletingLastPathComponent().path }
|
||||
mediaFolder: projectPath.map { URL(fileURLWithPath: $0).deletingLastPathComponent().path },
|
||||
fresh: decisionsChanged
|
||||
)
|
||||
if let projectPath { reviewModel.loadProjectFormat(projectPath: projectPath) }
|
||||
}
|
||||
|
||||
private func applyDecisions() {
|
||||
@@ -767,6 +839,55 @@ struct WizardView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Etapa 4 (alternativa): manda a voice timeline inteira para um modelo
|
||||
/// local (Ollama/Gemma 3) que dirige a edição de uma vez — sem copiar e
|
||||
/// colar. O motor devolve o roteiro legível + o JSON de ações e já aplica
|
||||
/// no FCPXML (non-destructive), igual ao fluxo manual "Aplicar decisões".
|
||||
private func fetchOllamaModels() {
|
||||
guard ollamaModels.isEmpty else { return }
|
||||
PythonBridge.call(command: "list_ollama_models", arguments: [:]) { result, err in
|
||||
DispatchQueue.main.async {
|
||||
if let models = result?["models"] as? [String], !models.isEmpty {
|
||||
ollamaModels = models
|
||||
if !models.contains(generateScriptModel) {
|
||||
generateScriptModel = models.first ?? generateScriptModel
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func generateScript(voiceTimelinePath: String) {
|
||||
guard let projectPath, let outputFolder else { return }
|
||||
isGeneratingScript = true
|
||||
generateScriptFeedback = ""
|
||||
errorMessage = nil
|
||||
PythonBridge.call(command: "generate_voice_script", arguments: [
|
||||
"voice_timeline": voiceTimelinePath,
|
||||
"filepath": projectPath,
|
||||
"output_dir": outputFolder,
|
||||
"model": generateScriptModel,
|
||||
"apply_to_fcpxml": true,
|
||||
]) { result, err in
|
||||
DispatchQueue.main.async {
|
||||
isGeneratingScript = false
|
||||
guard result?["ok"] as? Bool == true else {
|
||||
errorMessage = result?["error"] as? String ?? err ?? "Falha ao gerar roteiro por IA local."
|
||||
return
|
||||
}
|
||||
// Traz as decisões de volta para a tela de revisão (etapa 5) e
|
||||
// marca como aplicadas, exatamente como o "Aplicar decisões".
|
||||
if let actionsPath = result?["actions_path"] as? String,
|
||||
let content = try? String(contentsOfFile: actionsPath, encoding: .utf8) {
|
||||
decisionsText = content
|
||||
}
|
||||
appliedPath = result?["applied_path"] as? String ?? projectPath
|
||||
skippedVoiceEdit = false
|
||||
generateScriptFeedback = "Roteiro gerado e aplicado — revise as ênfases na próxima etapa."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func finalizeProcessing() {
|
||||
guard let outputFolder else { return }
|
||||
let startPath = appliedPath ?? projectPath
|
||||
@@ -780,7 +901,45 @@ struct WizardView: View {
|
||||
isFinalizing = true
|
||||
errorMessage = nil
|
||||
finalStatus = "Iniciando…"
|
||||
finalizeStep(operations, index: 0, currentPath: startPath, outputFolder: outputFolder)
|
||||
applyReviewDecisions(startPath: startPath, outputFolder: outputFolder) { reviewedPath in
|
||||
finalizeStep(operations, index: 0, currentPath: reviewedPath, outputFolder: outputFolder)
|
||||
}
|
||||
}
|
||||
|
||||
/// Reapplies whatever the etapa-5 review decided (active/inactive
|
||||
/// phrases, manual zooms) on top of `startPath` before the finishing
|
||||
/// chain runs below. Without this, `appliedPath` stayed frozen at
|
||||
/// whatever `exportarChat`'s `apply_voice_actions` produced BEFORE the
|
||||
/// human review — so toggling a phrase off in the review only updated
|
||||
/// `_phrase_actions.json` on disk, never the video the wizard actually
|
||||
/// exports. A no-op (just hands `startPath` straight through) when the
|
||||
/// review step was never visited/saved this session.
|
||||
private func applyReviewDecisions(
|
||||
startPath: String, outputFolder: String, completion: @escaping (String) -> Void
|
||||
) {
|
||||
guard let phraseActionsPath,
|
||||
let data = try? Data(contentsOf: URL(fileURLWithPath: phraseActionsPath)),
|
||||
let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let actions = parsed["actions"] else {
|
||||
completion(startPath)
|
||||
return
|
||||
}
|
||||
finalStatus = "Aplicando a revisão…"
|
||||
PythonBridge.call(command: "apply_voice_actions", arguments: [
|
||||
"path": startPath,
|
||||
"output_dir": outputFolder,
|
||||
"actions": actions,
|
||||
]) { result, err in
|
||||
DispatchQueue.main.async {
|
||||
guard result?["ok"] as? Bool == true else {
|
||||
isFinalizing = false
|
||||
errorMessage = result?["error"] as? String ?? err ?? "Falha ao aplicar a revisão."
|
||||
finalStatus = "Processamento interrompido."
|
||||
return
|
||||
}
|
||||
completion(result?["path"] as? String ?? startPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func finalizeStep(_ operations: [String], index: Int, currentPath: String, outputFolder: String) {
|
||||
|
||||
Reference in New Issue
Block a user