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:
co-authored by
Claude Opus 5
parent
e7748c2c58
commit
1bebee4359
@@ -0,0 +1,808 @@
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
|
||||
/// Guia passo a passo do fluxo completo: projeto → transcrição → análise de
|
||||
/// voz → copiar para o chat e trazer as decisões → revisar as ênfases →
|
||||
/// processamento final. Existe para que o usuário não precise entender a ordem
|
||||
/// certa de botões espalhados em várias abas — cada etapa só libera a próxima
|
||||
/// quando o passo anterior terminou, e a "ponte" com o chat (que hoje exigia
|
||||
/// 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
|
||||
var id: Int { rawValue }
|
||||
|
||||
var titulo: String {
|
||||
switch self {
|
||||
case .projeto: return "Projeto"
|
||||
case .transcricao: return "Transcrever"
|
||||
case .analise: return "Analisar voz"
|
||||
case .exportarChat: return "Decisões da IA"
|
||||
case .revisar: return "Revisar ênfases"
|
||||
case .finalizar: return "Processar"
|
||||
case .concluido: return "Concluído"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct WizardView: View {
|
||||
@State private var step: WizardStep = .projeto
|
||||
|
||||
// Passo 1 — projeto
|
||||
@State private var outputFolder: String?
|
||||
@State private var projectPath: String?
|
||||
@State private var catalog: Catalog?
|
||||
|
||||
// Passo 2 — transcrição
|
||||
@State private var isTranscribing = false
|
||||
@State private var transcribeProgress: Double = 0
|
||||
@State private var transcribeStage = ""
|
||||
@State private var transcribeResults: [TranscriptResult] = []
|
||||
|
||||
// Passo 3 — análise de voz
|
||||
@State private var isAnalyzing = false
|
||||
@State private var voiceTimelinePath: String?
|
||||
@State private var voiceAnalysisMessage = ""
|
||||
@State private var acousticsAvailable: Bool?
|
||||
@State private var showVoiceTimelineReuseAlert = false
|
||||
@State private var existingVoiceTimelinePath: String?
|
||||
|
||||
// Passo 4 — enviar ao chat e trazer as decisões de volta
|
||||
@State private var copiedFeedback = ""
|
||||
@State private var decisionsText = ""
|
||||
@State private var isApplyingDecisions = false
|
||||
@State private var appliedPath: String?
|
||||
@State private var skippedVoiceEdit = false
|
||||
|
||||
// Passo 5 — revisar ênfases
|
||||
@StateObject private var reviewModel = PhraseReviewModel()
|
||||
@State private var reviewLoadedFor: String?
|
||||
@State private var phraseReviewPath: String?
|
||||
|
||||
// Passo 6 — processamento final
|
||||
@State private var finalSilences = true
|
||||
@State private var finalFillers = false
|
||||
@State private var finalSubtitles = true
|
||||
@State private var finalDynamicSubtitles = false
|
||||
@State private var isFinalizing = false
|
||||
@State private var finalStatus = ""
|
||||
@State private var finalPath: String?
|
||||
|
||||
@State private var errorMessage: String?
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
stepperHeader
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.top, 20)
|
||||
.padding(.bottom, 16)
|
||||
|
||||
Divider()
|
||||
|
||||
// A revisão é uma sala de edição, não um formulário: ela precisa da
|
||||
// largura toda e rola por conta própria (timeline horizontal, lista
|
||||
// vertical). As demais etapas continuam na coluna estreita, que é o
|
||||
// que mantém um passo a passo legível.
|
||||
if step == .revisar {
|
||||
revisarStep
|
||||
} else {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
if let errorMessage, !errorMessage.isEmpty {
|
||||
Label(errorMessage, systemImage: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.red)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
content
|
||||
}
|
||||
.padding(24)
|
||||
.frame(maxWidth: 640, alignment: .leading)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
navFooter
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.vertical, 16)
|
||||
}
|
||||
.task {
|
||||
loadProjectConfig()
|
||||
await loadCatalog()
|
||||
}
|
||||
.alert("Análise de voz já existe", isPresented: $showVoiceTimelineReuseAlert) {
|
||||
Button("Usar existente") {
|
||||
if let existingVoiceTimelinePath {
|
||||
voiceTimelinePath = existingVoiceTimelinePath
|
||||
voiceAnalysisMessage = "Reaproveitando análise existente: \(existingVoiceTimelinePath)"
|
||||
}
|
||||
}
|
||||
Button("Reprocessar") {
|
||||
analyzeVoice(forceReprocess: true)
|
||||
}
|
||||
Button("Cancelar", role: .cancel) {}
|
||||
} message: {
|
||||
Text("Já existe um arquivo voice_timeline para este projeto. Quer manter o processamento anterior para ganhar tempo?")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Cabeçalho com os passos
|
||||
|
||||
private var stepperHeader: some View {
|
||||
HStack(spacing: 6) {
|
||||
ForEach(WizardStep.allCases) { s in
|
||||
HStack(spacing: 6) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(colorFor(s))
|
||||
.frame(width: 24, height: 24)
|
||||
if s.rawValue < step.rawValue {
|
||||
Image(systemName: "checkmark")
|
||||
.font(.caption2.weight(.bold))
|
||||
.foregroundStyle(.white)
|
||||
} else {
|
||||
Text("\(s.rawValue + 1)")
|
||||
.font(.caption2.weight(.bold))
|
||||
.foregroundStyle(s == step ? .white : .secondary)
|
||||
}
|
||||
}
|
||||
Text(s.titulo)
|
||||
.font(.caption)
|
||||
.foregroundStyle(s == step ? .primary : .secondary)
|
||||
.fontWeight(s == step ? .semibold : .regular)
|
||||
}
|
||||
if s != WizardStep.allCases.last {
|
||||
Rectangle()
|
||||
.fill(s.rawValue < step.rawValue ? Color.accentColor : Color.secondary.opacity(0.25))
|
||||
.frame(height: 2)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func colorFor(_ s: WizardStep) -> Color {
|
||||
if s.rawValue < step.rawValue { return .accentColor }
|
||||
if s == step { return .accentColor }
|
||||
return Color.secondary.opacity(0.25)
|
||||
}
|
||||
|
||||
// MARK: - Conteúdo por etapa
|
||||
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
switch step {
|
||||
case .projeto: projetoStep
|
||||
case .transcricao: transcricaoStep
|
||||
case .analise: analiseStep
|
||||
case .exportarChat: exportarChatStep
|
||||
case .revisar: revisarStep
|
||||
case .finalizar: finalizarStep
|
||||
case .concluido: concluidoStep
|
||||
}
|
||||
}
|
||||
|
||||
private var projetoStep: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("1. Escolha o projeto").font(.title3.weight(.semibold))
|
||||
Text("A pasta é onde tudo o que for gerado nesse fluxo fica salvo. O arquivo é o .fcpxml exportado do Final Cut Pro.")
|
||||
.font(.callout).foregroundStyle(.secondary)
|
||||
|
||||
fieldRow(icon: "folder", label: outputFolder ?? "Nenhuma pasta selecionada", isSet: outputFolder != nil) {
|
||||
pickOutputFolder()
|
||||
}
|
||||
fieldRow(icon: "doc.text", label: projectPath.map { URL(fileURLWithPath: $0).lastPathComponent } ?? "Nenhum arquivo selecionado", isSet: projectPath != nil) {
|
||||
pickProjectFile()
|
||||
}
|
||||
|
||||
if looksLikeGeneratedFile(projectPath) {
|
||||
Label("Esse arquivo parece já ter sido processado por este fluxo (o nome tem um sufixo como \"_voice_edit\" ou \"_silence_removed\"). Rodar o wizard de novo em cima dele reaplica os cortes por cima de cortes já feitos. Selecione o .fcpxml original do Final Cut, a menos que a intenção seja mesmo reprocessar.",
|
||||
systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.caption).foregroundStyle(.orange)
|
||||
}
|
||||
|
||||
if (catalog?.installedCount ?? 0) == 0 {
|
||||
Label("Nenhum modelo de transcrição instalado. Baixe um na aba \"Modelos\" antes de continuar.",
|
||||
systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.caption).foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var transcricaoStep: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("2. Transcreva o áudio").font(.title3.weight(.semibold))
|
||||
Text("Roda localmente com o modelo escolhido na aba Modelos. Vira a base de tudo que vem depois — o corte por voz, as legendas, os marcadores.")
|
||||
.font(.callout).foregroundStyle(.secondary)
|
||||
|
||||
Button {
|
||||
startTranscription()
|
||||
} label: {
|
||||
if isTranscribing {
|
||||
HStack { ProgressView().controlSize(.small); Text(transcribeStage.isEmpty ? "Transcrevendo…" : transcribeStage) }
|
||||
.frame(maxWidth: .infinity)
|
||||
} else {
|
||||
Label(transcribeResults.isEmpty ? "Transcrever" : "Transcrever novamente", systemImage: "waveform")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.large)
|
||||
.disabled(isTranscribing || projectPath == nil || outputFolder == nil)
|
||||
|
||||
if isTranscribing {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
ProgressView(value: transcribeProgress)
|
||||
Text("\(Int(transcribeProgress * 100))%").font(.caption).foregroundStyle(.secondary).monospacedDigit()
|
||||
}
|
||||
}
|
||||
|
||||
if !transcribeResults.isEmpty {
|
||||
ForEach(transcribeResults, id: \.media) { r in
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Image(systemName: "checkmark.circle.fill").foregroundStyle(.green)
|
||||
Text(r.media).font(.body.weight(.medium))
|
||||
Spacer()
|
||||
Text("\(r.language) · \(r.words) palavras").font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
Text(r.preview).font(.caption).foregroundStyle(.secondary).lineLimit(2)
|
||||
}
|
||||
.padding(12)
|
||||
.background(RoundedRectangle(cornerRadius: 8).fill(Color.secondary.opacity(0.06)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var analiseStep: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("3. Analise a voz").font(.title3.weight(.semibold))
|
||||
Text("Gera o JSON com transcrição, locutor e intensidade (pitch/energia/ritmo) por palavra — é esse arquivo que o chat lê para decidir o que cortar. Não corta nada sozinho.")
|
||||
.font(.callout).foregroundStyle(.secondary)
|
||||
|
||||
Button {
|
||||
analyzeVoice()
|
||||
} label: {
|
||||
if isAnalyzing {
|
||||
HStack { ProgressView().controlSize(.small); Text("Analisando…") }.frame(maxWidth: .infinity)
|
||||
} else {
|
||||
Label(voiceTimelinePath == nil ? "Analisar voz" : "Analisar novamente", systemImage: "waveform.badge.magnifyingglass")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.large)
|
||||
.disabled(isAnalyzing || projectPath == nil || outputFolder == nil)
|
||||
|
||||
if let voiceTimelinePath {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Label("Análise pronta", systemImage: "checkmark.circle.fill").foregroundStyle(.green)
|
||||
Text(voiceTimelinePath).font(.caption).foregroundStyle(.secondary).lineLimit(1).truncationMode(.middle)
|
||||
}
|
||||
.padding(12)
|
||||
.background(RoundedRectangle(cornerRadius: 8).fill(Color.secondary.opacity(0.06)))
|
||||
|
||||
if acousticsAvailable == false {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Label("Sem análise acústica real", systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.caption.weight(.semibold)).foregroundStyle(.orange)
|
||||
Text("Falta o componente \"librosa\" — os cortes ainda são decididos pelo texto, mas o chat não vai propor zoom com confiança. Instale em Avançado → Modelos → \"Análise Acústica\", e refaça esta etapa depois.")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(12)
|
||||
.background(RoundedRectangle(cornerRadius: 8).fill(Color.orange.opacity(0.08)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var exportarChatStep: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("4. Envie para o chat decidir os cortes").font(.title3.weight(.semibold))
|
||||
Text("Esta é a única etapa manual que sobra: o julgamento de qual tomada usar, onde dar zoom e o que escrever na tela é feito pela IA numa conversa, não por um botão. Copie abaixo, cole numa sessão do Claude e peça pra rodar a skill \"editar-por-voz\".")
|
||||
.font(.callout).foregroundStyle(.secondary)
|
||||
|
||||
if let voiceTimelinePath {
|
||||
Button {
|
||||
copyForChat(path: voiceTimelinePath)
|
||||
} label: {
|
||||
Label("Copiar para colar no chat", systemImage: "doc.on.clipboard")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.large)
|
||||
|
||||
if !copiedFeedback.isEmpty {
|
||||
Label(copiedFeedback, systemImage: "checkmark.circle.fill")
|
||||
.font(.caption).foregroundStyle(.green)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("O que é copiado").font(.caption.weight(.semibold)).foregroundStyle(.secondary)
|
||||
Text("Um pedido pronto + o conteúdo de \(URL(fileURLWithPath: voiceTimelinePath).lastPathComponent), já formatado. É só colar (⌘V) numa conversa com o Claude.")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(12)
|
||||
.background(RoundedRectangle(cornerRadius: 8).fill(Color.secondary.opacity(0.06)))
|
||||
|
||||
Divider().padding(.vertical, 4)
|
||||
|
||||
Text("Cole aqui o que o chat devolveu").font(.callout.weight(.semibold))
|
||||
Text("Na próxima etapa essas decisões aparecem já marcadas na timeline, frase por frase, para você lapidar.")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
|
||||
HStack {
|
||||
Button {
|
||||
if let s = NSPasteboard.general.string(forType: .string) {
|
||||
decisionsText = s
|
||||
}
|
||||
} label: {
|
||||
Label("Colar da área de transferência", systemImage: "list.clipboard")
|
||||
}
|
||||
Spacer()
|
||||
if !decisionsText.isEmpty {
|
||||
Label(jsonIsValid ? "JSON válido" : "JSON inválido",
|
||||
systemImage: jsonIsValid ? "checkmark.circle.fill" : "xmark.circle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(jsonIsValid ? .green : .red)
|
||||
}
|
||||
}
|
||||
|
||||
TextEditor(text: $decisionsText)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.frame(minHeight: 140)
|
||||
.padding(8)
|
||||
.background(RoundedRectangle(cornerRadius: 8).fill(Color.secondary.opacity(0.06)))
|
||||
.overlay(RoundedRectangle(cornerRadius: 8).stroke(Color.secondary.opacity(0.2)))
|
||||
|
||||
Button {
|
||||
applyDecisions()
|
||||
} label: {
|
||||
if isApplyingDecisions {
|
||||
HStack { ProgressView().controlSize(.small); Text("Aplicando…") }
|
||||
.frame(maxWidth: .infinity)
|
||||
} else {
|
||||
Label("Aplicar decisões", systemImage: "checkmark.seal")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.large)
|
||||
.disabled(isApplyingDecisions || !jsonIsValid)
|
||||
|
||||
if let appliedPath {
|
||||
Label("Decisões aplicadas — \(URL(fileURLWithPath: appliedPath).lastPathComponent)",
|
||||
systemImage: "checkmark.circle.fill")
|
||||
.font(.caption).foregroundStyle(.green)
|
||||
}
|
||||
|
||||
Divider()
|
||||
Button("Pular esta etapa (revisar as ênfases direto, sem passar pela IA)") {
|
||||
skippedVoiceEdit = true
|
||||
appliedPath = nil
|
||||
decisionsText = ""
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
Label("Volte ao passo anterior e rode a análise de voz primeiro.", systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.caption).foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Etapa 5 — a sala de edição. Diferente das outras, não é um formulário
|
||||
/// dentro da coluna do assistente: ocupa a janela toda e se carrega sozinha
|
||||
/// na primeira vez que aparece para aquela análise de voz.
|
||||
private var revisarStep: some View {
|
||||
Group {
|
||||
if voiceTimelinePath != nil {
|
||||
PhraseReviewView(model: reviewModel)
|
||||
} else {
|
||||
VStack(spacing: 8) {
|
||||
Label("Volte ao passo 3 e rode a análise de voz primeiro.",
|
||||
systemImage: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
.onAppear { loadReviewIfNeeded() }
|
||||
}
|
||||
|
||||
private var finalizarStep: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("6. Finalize o corte").font(.title3.weight(.semibold))
|
||||
Text("Últimos passos automáticos, sem decisão envolvida — rodam com os parâmetros já configurados na aba \"Análise de Voz\" / \"Legendas Dinâmicas\".")
|
||||
.font(.callout).foregroundStyle(.secondary)
|
||||
|
||||
Toggle("Remover silêncios do áudio", isOn: $finalSilences)
|
||||
Toggle("Remover palavras de preenchimento", isOn: $finalFillers)
|
||||
Toggle("Gerar legenda comum (texto editável no FCP)", isOn: $finalSubtitles)
|
||||
Toggle("Gerar legendas dinâmicas (estilo configurado na aba própria)", isOn: $finalDynamicSubtitles)
|
||||
|
||||
Button {
|
||||
finalizeProcessing()
|
||||
} label: {
|
||||
if isFinalizing {
|
||||
HStack { ProgressView().controlSize(.small); Text(finalStatus.isEmpty ? "Processando…" : finalStatus) }
|
||||
.frame(maxWidth: .infinity)
|
||||
} else {
|
||||
Label("Processar", systemImage: "play.fill").frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.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 {
|
||||
Text(finalPath).font(.caption).foregroundStyle(.secondary).lineLimit(1).truncationMode(.middle)
|
||||
HStack {
|
||||
Button("Abrir no Final Cut Pro") { NSWorkspace.shared.open(URL(fileURLWithPath: finalPath)) }
|
||||
.buttonStyle(.borderedProminent)
|
||||
Button("Mostrar no Finder") {
|
||||
NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: finalPath)])
|
||||
}
|
||||
}
|
||||
}
|
||||
Divider().padding(.vertical, 8)
|
||||
Button("Começar outro projeto") { resetWizard() }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Navegação
|
||||
|
||||
private var navFooter: some View {
|
||||
HStack {
|
||||
if step != .projeto && step != .concluido {
|
||||
Button("Voltar") { goBack() }
|
||||
}
|
||||
Spacer()
|
||||
if step != .concluido {
|
||||
Button(step == .finalizar ? "Concluir" : "Continuar") { goNext() }
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(!canAdvance)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var canAdvance: Bool {
|
||||
switch step {
|
||||
case .projeto: return outputFolder != nil && projectPath != nil
|
||||
case .transcricao: return !transcribeResults.isEmpty
|
||||
case .analise: return voiceTimelinePath != nil
|
||||
case .exportarChat: return appliedPath != nil || skippedVoiceEdit
|
||||
// Revisar é opcional: a sugestão da IA já é utilizável como veio, então
|
||||
// o botão nunca trava aqui — o passo existe para lapidar, não para
|
||||
// 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.
|
||||
if step == .revisar {
|
||||
reviewModel.save { path in
|
||||
phraseReviewPath = path
|
||||
}
|
||||
}
|
||||
step = next
|
||||
}
|
||||
|
||||
private func goBack() {
|
||||
guard let prev = WizardStep(rawValue: step.rawValue - 1) else { return }
|
||||
step = prev
|
||||
}
|
||||
|
||||
private func resetWizard() {
|
||||
step = .projeto
|
||||
transcribeResults = []
|
||||
voiceTimelinePath = nil
|
||||
voiceAnalysisMessage = ""
|
||||
decisionsText = ""
|
||||
appliedPath = nil
|
||||
skippedVoiceEdit = false
|
||||
reviewLoadedFor = nil
|
||||
phraseReviewPath = nil
|
||||
finalStatus = ""
|
||||
finalPath = nil
|
||||
errorMessage = nil
|
||||
}
|
||||
|
||||
// MARK: - Componentes auxiliares
|
||||
|
||||
@ViewBuilder
|
||||
private func fieldRow(icon: String, label: String, isSet: Bool, action: @escaping () -> Void) -> some View {
|
||||
HStack {
|
||||
Image(systemName: icon).foregroundStyle(isSet ? .primary : .secondary)
|
||||
Text(label).lineLimit(1).truncationMode(.middle).foregroundStyle(isSet ? .primary : .secondary)
|
||||
Spacer()
|
||||
Button("Escolher…", action: action)
|
||||
}
|
||||
.padding(12)
|
||||
.background(RoundedRectangle(cornerRadius: 8).fill(Color.secondary.opacity(0.06)))
|
||||
}
|
||||
|
||||
/// Todo output do fluxo carrega um destes sufixos no nome (ver
|
||||
/// `_derived_output` / suffixes usados por `apply_voice_actions`,
|
||||
/// `remove_silences`, `generate_dynamic_subtitles` em
|
||||
/// `admin/models_api.py`). Selecionar um deles como "o projeto" no passo
|
||||
/// 1 é o erro que gerou arquivos como `_voice_edit_voice_edit_...`: os
|
||||
/// cortes de voz assumem timestamps da mídia ORIGINAL, então reaplicá-los
|
||||
/// sobre um arquivo já cortado desloca tudo silenciosamente.
|
||||
private static let generatedSuffixes = [
|
||||
"_voice_edit", "_silence_removed", "_dynamic_subtitles",
|
||||
"_transcript_edit", "_fillers_removed", "_markers",
|
||||
]
|
||||
|
||||
private func looksLikeGeneratedFile(_ path: String?) -> Bool {
|
||||
guard let path else { return false }
|
||||
let stem = URL(fileURLWithPath: path).deletingPathExtension().lastPathComponent
|
||||
return Self.generatedSuffixes.contains { stem.contains($0) }
|
||||
}
|
||||
|
||||
private var jsonIsValid: Bool {
|
||||
guard let data = decisionsText.data(using: .utf8), !decisionsText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return false }
|
||||
return (try? JSONSerialization.jsonObject(with: data)) != nil
|
||||
}
|
||||
|
||||
// MARK: - Ações — Python bridge
|
||||
|
||||
private func loadProjectConfig() {
|
||||
PythonBridge.call(command: "project_config") { result, _ in
|
||||
DispatchQueue.main.async {
|
||||
guard let result, result["ok"] as? Bool == true else { return }
|
||||
if let folder = result["folder"] as? String, !folder.isEmpty { outputFolder = folder }
|
||||
if let file = result["file"] as? String, !file.isEmpty { projectPath = file }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func loadCatalog() async {
|
||||
PythonBridge.call(command: "catalog") { result, _ in
|
||||
DispatchQueue.main.async {
|
||||
if let result { catalog = Catalog(json: result) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func pickOutputFolder() {
|
||||
let panel = NSOpenPanel()
|
||||
panel.canChooseFiles = false
|
||||
panel.canChooseDirectories = true
|
||||
panel.allowsMultipleSelection = false
|
||||
panel.prompt = "Usar esta pasta"
|
||||
panel.message = "Escolha a pasta onde os resultados serão salvos."
|
||||
if panel.runModal() == .OK, let url = panel.url {
|
||||
outputFolder = url.path
|
||||
PythonBridge.call(command: "set_project_config", arguments: ["folder": url.path]) { _, _ in }
|
||||
}
|
||||
}
|
||||
|
||||
private func pickProjectFile() {
|
||||
let panel = NSOpenPanel()
|
||||
panel.canChooseFiles = true
|
||||
panel.canChooseDirectories = false
|
||||
panel.allowsMultipleSelection = false
|
||||
panel.prompt = "Selecionar"
|
||||
panel.message = "Selecione o arquivo (.fcpxml) ou o bundle (.fcpxmld) exportado pelo Final Cut Pro."
|
||||
if panel.runModal() == .OK, let url = panel.url {
|
||||
let ext = url.pathExtension.lowercased()
|
||||
if ext == "fcpxml" || ext == "fcpxmld" || ext == "xml" {
|
||||
projectPath = url.path
|
||||
PythonBridge.call(command: "set_project_config", arguments: ["file": url.path]) { _, _ in }
|
||||
} else {
|
||||
errorMessage = "Selecione um arquivo .fcpxml, .fcpxmld ou .xml do Final Cut Pro."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func startTranscription() {
|
||||
guard let projectPath, let outputFolder else { return }
|
||||
isTranscribing = true
|
||||
errorMessage = nil
|
||||
transcribeResults = []
|
||||
transcribeProgress = 0
|
||||
PythonBridge.run(command: "transcribe", arguments: ["path": projectPath, "output_dir": outputFolder]) { obj in
|
||||
DispatchQueue.main.async {
|
||||
let type = obj["type"] as? String
|
||||
if type == "progress" {
|
||||
transcribeProgress = (obj["fraction"] as? NSNumber)?.doubleValue ?? 0
|
||||
transcribeStage = obj["stage"] as? String ?? ""
|
||||
} else if type == "error" {
|
||||
errorMessage = obj["message"] as? String ?? "Erro na transcrição."
|
||||
} else if type == "result", let arr = obj["transcripts"] as? [[String: Any]] {
|
||||
transcribeResults = arr.map(TranscriptResult.init)
|
||||
}
|
||||
}
|
||||
} completion: { code, err in
|
||||
DispatchQueue.main.async {
|
||||
isTranscribing = false
|
||||
transcribeProgress = 1
|
||||
if code != 0 && transcribeResults.isEmpty {
|
||||
errorMessage = err ?? "A transcrição falhou."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func analyzeVoice(forceReprocess: Bool = false) {
|
||||
guard let projectPath, let outputFolder else { return }
|
||||
isAnalyzing = true
|
||||
errorMessage = nil
|
||||
PythonBridge.call(command: "analyze_voice", arguments: [
|
||||
"path": projectPath,
|
||||
"output_dir": outputFolder,
|
||||
"force_reprocess": forceReprocess,
|
||||
]) { result, err in
|
||||
DispatchQueue.main.async {
|
||||
isAnalyzing = false
|
||||
guard result?["ok"] as? Bool == true else {
|
||||
errorMessage = result?["error"] as? String ?? err ?? "Falha ao analisar a voz."
|
||||
return
|
||||
}
|
||||
if result?["reused"] as? Bool == true, !forceReprocess {
|
||||
let timelines = result?["timelines"] as? [String] ?? []
|
||||
existingVoiceTimelinePath = timelines.first ?? extractPath(from: result?["message"] as? String ?? "", marker: "**Timeline JSON**:")
|
||||
showVoiceTimelineReuseAlert = true
|
||||
return
|
||||
}
|
||||
let message = result?["message"] as? String ?? ""
|
||||
voiceAnalysisMessage = message
|
||||
if let path = extractPath(from: message, marker: "**Timeline JSON**:") {
|
||||
voiceTimelinePath = path
|
||||
} else {
|
||||
voiceTimelinePath = nil
|
||||
// ok:true não garante que a análise gerou timeline — se
|
||||
// não houver fala detectável no áudio, o Python volta com
|
||||
// sucesso mas sem "Timeline JSON" na mensagem. Sem isso
|
||||
// aqui, a etapa parecia não fazer nada.
|
||||
errorMessage = "A análise terminou mas não encontrou fala reconhecível no áudio. Mensagem do motor: " + (message.isEmpty ? "(vazia)" : message)
|
||||
}
|
||||
checkAcoustics()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A ênfase de voz (energia/tom) depende do `librosa`, dependência
|
||||
/// opcional. Sem ela, a análise ainda transcreve e corta pelo texto,
|
||||
/// mas nunca deveria propor zoom — por isso avisamos aqui, no ponto
|
||||
/// onde o usuário sentiria falta, em vez de só na aba Modelos.
|
||||
private func checkAcoustics() {
|
||||
PythonBridge.call(command: "acoustics_capability") { result, _ in
|
||||
DispatchQueue.main.async {
|
||||
guard let result, result["ok"] as? Bool == true else { return }
|
||||
acousticsAvailable = result["available"] as? Bool
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Localiza uma linha markdown do tipo "- **Marker**: valor" (usado nas
|
||||
/// mensagens do bridge Python) e devolve o valor. Aceita o marcador de
|
||||
/// lista "- " opcional antes dos asteriscos.
|
||||
private func extractPath(from message: String, marker: String) -> String? {
|
||||
for line in message.split(separator: "\n") {
|
||||
var trimmed = Substring(line.trimmingCharacters(in: .whitespaces))
|
||||
if trimmed.hasPrefix("- ") { trimmed = trimmed.dropFirst(2) }
|
||||
if trimmed.hasPrefix(marker) {
|
||||
return trimmed.dropFirst(marker.count).trimmingCharacters(in: .whitespaces)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func copyForChat(path: String) {
|
||||
guard let content = try? String(contentsOfFile: path, encoding: .utf8) else {
|
||||
errorMessage = "Não foi possível ler \(path)."
|
||||
return
|
||||
}
|
||||
let prompt = """
|
||||
Use a skill "editar-por-voz" para decidir os cortes deste projeto a partir da timeline de voz abaixo. Devolva só o JSON de decisões (cortes, zooms, textos, marcadores) pronto para eu colar de volta no app.
|
||||
|
||||
```json
|
||||
\(content)
|
||||
```
|
||||
"""
|
||||
let pasteboard = NSPasteboard.general
|
||||
pasteboard.clearContents()
|
||||
pasteboard.setString(prompt, forType: .string)
|
||||
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.
|
||||
private func loadReviewIfNeeded() {
|
||||
guard let voiceTimelinePath, reviewLoadedFor != voiceTimelinePath else { return }
|
||||
reviewLoadedFor = voiceTimelinePath
|
||||
// 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 }
|
||||
)
|
||||
if let projectPath { reviewModel.loadProjectFormat(projectPath: projectPath) }
|
||||
}
|
||||
|
||||
private func applyDecisions() {
|
||||
guard let projectPath, let outputFolder,
|
||||
let data = decisionsText.data(using: .utf8),
|
||||
let parsed = try? JSONSerialization.jsonObject(with: data) else { return }
|
||||
isApplyingDecisions = true
|
||||
errorMessage = nil
|
||||
PythonBridge.call(command: "apply_voice_actions", arguments: [
|
||||
"path": projectPath,
|
||||
"output_dir": outputFolder,
|
||||
"actions": parsed,
|
||||
]) { result, err in
|
||||
DispatchQueue.main.async {
|
||||
isApplyingDecisions = false
|
||||
guard result?["ok"] as? Bool == true else {
|
||||
errorMessage = result?["error"] as? String ?? err ?? "Falha ao aplicar as decisões."
|
||||
return
|
||||
}
|
||||
appliedPath = result?["path"] as? String ?? projectPath
|
||||
skippedVoiceEdit = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func finalizeProcessing() {
|
||||
guard let outputFolder else { return }
|
||||
let startPath = appliedPath ?? projectPath
|
||||
guard let startPath else { return }
|
||||
var operations: [String] = []
|
||||
if finalSilences { operations.append("remove_silences") }
|
||||
if finalFillers { operations.append("remove_filler_words") }
|
||||
if finalSubtitles { operations.append("generate_plain_subtitles") }
|
||||
if finalDynamicSubtitles { operations.append("generate_dynamic_subtitles") }
|
||||
guard !operations.isEmpty else { return }
|
||||
isFinalizing = true
|
||||
errorMessage = nil
|
||||
finalStatus = "Iniciando…"
|
||||
finalizeStep(operations, index: 0, currentPath: startPath, outputFolder: outputFolder)
|
||||
}
|
||||
|
||||
private func finalizeStep(_ operations: [String], index: Int, currentPath: String, outputFolder: String) {
|
||||
guard index < operations.count else {
|
||||
isFinalizing = false
|
||||
finalStatus = "Processamento concluído."
|
||||
finalPath = currentPath
|
||||
return
|
||||
}
|
||||
let operation = operations[index]
|
||||
finalStatus = "Processando: \(operation)…"
|
||||
PythonBridge.call(command: operation, arguments: ["path": currentPath, "output_dir": outputFolder]) { result, err in
|
||||
DispatchQueue.main.async {
|
||||
guard result?["ok"] as? Bool == true else {
|
||||
isFinalizing = false
|
||||
errorMessage = result?["error"] as? String ?? err ?? "Falha em \(operation)."
|
||||
finalStatus = "Processamento interrompido."
|
||||
return
|
||||
}
|
||||
let nextPath = result?["path"] as? String ?? currentPath
|
||||
finalizeStep(operations, index: index + 1, currentPath: nextPath, outputFolder: outputFolder)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user