Cole o plano recebido da IA. O painel valida o formato antes de executar.
@@ -314,8 +317,8 @@
Faixas de áudio
Transcrição
—
-
-
+
+
Mede pitch, energia, velocidade de fala e pausas por trecho. Não classifica emoção e tem custo baixo comparado à transcrição.
Hugging Face não configurado
A lista inclui somente modelos encontrados nas pastas locais configuradas. O token do Hugging Face é usado apenas na diarização; nunca é salvo no perfil nem no relatório.
@@ -325,6 +328,7 @@
Perfil padrão pronto.
+
Este Scanner já foi processado
Se continuar, a transcrição e as análises serão executadas novamente. Quer reprocessar o áudio?
Relatório pronto
diff --git a/code/cep-plugin/main.js b/code/cep-plugin/main.js
index 5d830ab..a782bed 100755
--- a/code/cep-plugin/main.js
+++ b/code/cep-plugin/main.js
@@ -1108,6 +1108,8 @@ var SCANNER_ALL_RESOURCES = ["faces", "qualidade_facial", "pessoas", "pose", "ma
var scannerTimeline = null;
var scannerReportPath = null;
+var scannerRelatorioExistente = null;
+var scannerReprocessamentoPendente = false;
var scannerProcesso = null;
var scannerCancelSolicitado = false;
var SCANNER_ENGINE_SCRIPT = "/Volumes/Merongo/SISTEMAS/GENIAL SISTEMAS/Jhonny/code/engine/executar_scanner.py";
@@ -1115,6 +1117,34 @@ var SCANNER_ENGINE_SCRIPT = "/Volumes/Merongo/SISTEMAS/GENIAL SISTEMAS/Jhonny/co
// `path` e `os` são carregados mais abaixo, junto com o runtime Node do CEP.
// Calcular isso aqui interrompia todo o script antes da inicialização do painel.
function scannerOutputRoot() { return path.join(os.homedir(), ".premiere-mcp", "scanner"); }
+function scannerNomeSeguro(nome) { return String(nome || "timeline").replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "timeline"; }
+
+function scannerCarregarRelatorioExistente() {
+ scannerRelatorioExistente = null;
+ if (!scannerTimeline || !scannerTimeline.name) return;
+ var caso = scannerNomeSeguro(scannerTimeline.name);
+ var caminho = path.join(scannerOutputRoot(), caso, caso + "-resultado.json");
+ try {
+ if (!fs.existsSync(caminho)) return;
+ var relatorio = JSON.parse(fs.readFileSync(caminho, "utf-8"));
+ if (relatorio.artefatos && relatorio.artefatos.audio && relatorio.artefatos.audio.length) { scannerRelatorioExistente = caminho; scannerReportPath = caminho; }
+ } catch (_) { scannerRelatorioExistente = null; }
+}
+
+function scannerAtualizarComplementos() {
+ var relatorio = scannerRelatorioExistente;
+ var arquivoDiarizacao = document.getElementById("btnScannerDiarization");
+ var arquivoMetricas = document.getElementById("btnScannerVoiceFeatures");
+ if (!relatorio) { if (arquivoDiarizacao) arquivoDiarizacao.disabled = true; if (arquivoMetricas) arquivoMetricas.disabled = true; return; }
+ var dados = {};
+ try { dados = JSON.parse(fs.readFileSync(relatorio, "utf-8")); } catch (_) {}
+ var transcricao = dados.perfil && dados.perfil.transcricao || {};
+ var audio = dados.artefatos && dados.artefatos.audio || [];
+ var metricas = !!(dados.perfil && dados.perfil.metricas_de_fala) || !!transcricao.metricas_de_fala;
+ var diarizacao = !!transcricao.diarizacao;
+ if (arquivoDiarizacao) { arquivoDiarizacao.disabled = diarizacao || !loadHfToken(); arquivoDiarizacao.textContent = diarizacao ? "Concluído" : "Processar"; }
+ if (arquivoMetricas) { arquivoMetricas.disabled = metricas || !audio.length; arquivoMetricas.textContent = metricas ? "Concluído" : "Processar"; }
+}
function scannerSelectedTracks(kind) { return Array.prototype.slice.call(document.querySelectorAll("[data-scanner-track='" + kind + "']:checked")).map(function (item) { return Number(item.value); }).sort(function (a, b) { return a - b; }); }
@@ -1215,10 +1245,21 @@ function modelNameFromPath(folder) { var value=String(folder).replace(/.*faster-
function scannerDetectTracks() {
setBusy("btnScannerDetectTracks", true); document.getElementById("scannerTracksHelp").textContent = "Lendo as faixas da sequência ativa…";
- cs.evalScript("(function(){try{var s=app.project.activeSequence;if(!s)return JSON.stringify({ok:false,error:'Nenhuma sequência ativa.'});function tracks(c,a){for(var i=0;i Path:
analisador_de_metricas_de_voz=analisador_de_metricas_de_voz).executar(
Timeline(timeline.identificador, timeline.nome, faixas=faixas_de_audio))
# Persiste a transcrição (segmentos + falas) no banco de análises.
- repositorio_de_analises.registrar_transcricoes(timeline.identificador, transcricoes)
+ repositorio_de_analises.substituir_transcricoes(timeline.identificador, transcricoes)
for transcricao in transcricoes:
transcricoes_por_clipe[transcricao.identificador_do_clipe] = [
{"inicio": item.inicio, "fim": item.fim, "texto": item.texto,
@@ -193,9 +194,98 @@ def executar(entrada: Path, saida: Path) -> Path:
return saida
+def executar_complemento(entrada: Path, relatorio: Path, acao: str) -> Path:
+ """Processa locutores ou métricas usando a transcrição já persistida."""
+ pedido = json.loads(entrada.read_text(encoding="utf-8"))
+ dados_relatorio = json.loads(relatorio.read_text(encoding="utf-8"))
+ timeline = ConversorDeTimeline().converter(pedido["timeline"])
+ clipes = {clipe.identificador: clipe for faixa in timeline.faixas for clipe in faixa.clipes}
+ partes_por_arquivo: dict[str, list] = {}
+ diarizacao_por_parte: dict[str, list] = {}
+ if acao == "diarizacao":
+ if not os.environ.get("HF_TOKEN"):
+ raise RuntimeError("Token do Hugging Face não configurado para identificar locutores.")
+ from engine.integracoes.huggingface import ProviderDeDiarizacaoHuggingFace
+ diarizador = ProviderDeDiarizacaoHuggingFace(os.environ.get("HF_DIARIZATION_MODEL", MODELO_DIARIZACAO_PADRAO))
+ analisador = None
+ elif acao == "metricas_de_fala":
+ from engine.integracoes.audio import AnalisadorDeMetricasDeVoz
+ diarizador = None
+ analisador = AnalisadorDeMetricasDeVoz()
+ else:
+ raise ValueError(f"Complemento desconhecido: {acao}")
+
+ arquivos_audio = dados_relatorio["artefatos"].get("audio", [])
+ repositorio_de_analises = RepositorioDeAnalisesSQLite(CAMINHO_DO_BANCO)
+ total = max(1, len(arquivos_audio))
+ for indice_arquivo, nome_arquivo in enumerate(arquivos_audio, 1):
+ caminho_relatorio = relatorio.parent / nome_arquivo
+ dados_audio = json.loads(caminho_relatorio.read_text(encoding="utf-8"))
+ for grupo in dados_audio.get("segmentos_por_clipe", []):
+ clipe = clipes.get(grupo.get("clipe"))
+ if not clipe or not clipe.arquivo:
+ continue
+ caminho_origem = str(clipe.arquivo)
+ if caminho_origem not in partes_por_arquivo:
+ partes_por_arquivo[caminho_origem] = ExtracaoDeAudio().extrair(
+ caminho_origem, relatorio.parent / ".cache-audio-complementar" / str(abs(hash(caminho_origem))))
+ partes = partes_por_arquivo[caminho_origem]
+ inicio_origem = clipe.intervalo_na_origem.inicio if clipe.intervalo_na_origem else 0.0
+ fim_anterior = None
+ for segmento in grupo.get("segmentos", []):
+ inicio = inicio_origem + segmento["inicio"] - clipe.intervalo_na_timeline.inicio
+ fim = inicio_origem + segmento["fim"] - clipe.intervalo_na_timeline.inicio
+ parte = next((item for item in partes if item.inicio <= inicio <= item.fim), partes[-1])
+ inicio_local = max(0.0, inicio - parte.inicio)
+ fim_local = max(inicio_local, min(fim, parte.fim) - parte.inicio)
+ chave = str(parte.caminho)
+ if acao == "diarizacao":
+ if chave not in diarizacao_por_parte:
+ diarizacao_por_parte[chave] = diarizador.analisar(parte.caminho)
+ falas = diarizacao_por_parte[chave]
+ sobreposicoes = [(min(fim_local, saida) - max(inicio_local, entrada), falante)
+ for entrada, saida, falante in falas
+ if entrada < fim_local and saida > inicio_local]
+ segmento["falante"] = max(sobreposicoes, default=(0.0, None))[1]
+ repositorio_de_analises.atualizar_enriquecimento_de_transcricao(
+ timeline.identificador,
+ clipe.identificador,
+ segmento["inicio"],
+ segmento["fim"],
+ falante=segmento.get("falante"),
+ )
+ else:
+ palavras = [SimpleNamespace(inicio=0.0, fim=0.0)
+ for _ in segmento.get("texto", "").split()]
+ segmento["caracteristicas_acusticas"] = analisador.analisar(
+ parte.caminho, inicio_local, fim_local, palavras,
+ segmento["inicio"], fim_anterior)
+ repositorio_de_analises.atualizar_enriquecimento_de_transcricao(
+ timeline.identificador,
+ clipe.identificador,
+ segmento["inicio"],
+ segmento["fim"],
+ caracteristicas_acusticas=segmento["caracteristicas_acusticas"],
+ )
+ fim_anterior = segmento["fim"]
+ dados_audio.setdefault("transcricao", {})["diarizacao" if acao == "diarizacao" else "metricas_de_fala"] = True
+ caminho_relatorio.write_text(json.dumps(dados_audio, ensure_ascii=False, indent=2), encoding="utf-8")
+ print(json.dumps({"evento": "progresso", "etapa": "Complementando transcrição",
+ "percentual": round(100 * indice_arquivo / total, 1)}, ensure_ascii=False), flush=True)
+ dados_relatorio.setdefault("perfil", {}).setdefault("transcricao", {})[
+ "diarizacao" if acao == "diarizacao" else "metricas_de_fala"] = True
+ dados_relatorio.write_text(json.dumps(dados_relatorio, ensure_ascii=False, indent=2), encoding="utf-8")
+ return relatorio
+
+
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("entrada", type=Path)
parser.add_argument("saida", type=Path)
+ parser.add_argument("--complemento", choices=("diarizacao", "metricas_de_fala"))
args = parser.parse_args()
- print(json.dumps({"evento": "concluido", "arquivo": str(executar(args.entrada, args.saida))}, ensure_ascii=False))
+ if args.complemento:
+ caminho = executar_complemento(args.entrada, args.saida, args.complemento)
+ else:
+ caminho = executar(args.entrada, args.saida)
+ print(json.dumps({"evento": "concluido", "arquivo": str(caminho)}, ensure_ascii=False))
diff --git a/code/engine/persistencia/repositorio_de_analises_sqlite.py b/code/engine/persistencia/repositorio_de_analises_sqlite.py
index 3462278..fee2f12 100644
--- a/code/engine/persistencia/repositorio_de_analises_sqlite.py
+++ b/code/engine/persistencia/repositorio_de_analises_sqlite.py
@@ -65,6 +65,56 @@ class RepositorioDeAnalisesSQLite:
for segmento in transcricao.segmentos:
self._inserir_segmento(video_id, transcricao.identificador_do_clipe, segmento)
+ def substituir_transcricoes(
+ self, video_id: str, transcricoes: Iterable[TranscricaoDoClipe],
+ ) -> None:
+ """Substitui a transcrição somente dos clipes recebidos.
+
+ O método torna o reprocessamento idempotente sem apagar análises de
+ outros clipes ou faixas que não participaram da execução atual.
+ """
+ with self.conexao:
+ self._garantir_video(video_id)
+ transcricoes_materializadas = tuple(transcricoes)
+ for transcricao in transcricoes_materializadas:
+ self.conexao.execute(
+ """DELETE FROM segmentos_de_transcricao
+ WHERE video_id = ? AND clipe_id = ?""",
+ (video_id, transcricao.identificador_do_clipe),
+ )
+ for segmento in transcricao.segmentos:
+ self._inserir_segmento(video_id, transcricao.identificador_do_clipe, segmento)
+
+ def atualizar_enriquecimento_de_transcricao(
+ self,
+ video_id: str,
+ clipe_id: str,
+ inicio: float,
+ fim: float,
+ *,
+ falante: str | None = None,
+ caracteristicas_acusticas: dict[str, object] | None = None,
+ ) -> None:
+ """Atualiza diarização e métricas de um segmento já persistido."""
+ campos = []
+ parametros: list[object] = []
+ if falante is not None:
+ campos.append("falante = ?")
+ parametros.append(falante)
+ if caracteristicas_acusticas is not None:
+ campos.append("caracteristicas_acusticas = ?")
+ parametros.append(json.dumps(caracteristicas_acusticas, ensure_ascii=False))
+ if not campos:
+ return
+ parametros.extend((video_id, clipe_id, inicio, fim))
+ with self.conexao:
+ self.conexao.execute(
+ f"""UPDATE segmentos_de_transcricao
+ SET {', '.join(campos)}
+ WHERE video_id = ? AND clipe_id = ? AND inicio = ? AND fim = ?""",
+ parametros,
+ )
+
def _inserir_segmento(
self, video_id: str, clipe_id: str, segmento: SegmentoDeTranscricao,
) -> int:
diff --git a/code/engine/testes/test_persistencia_sqlite.py b/code/engine/testes/test_persistencia_sqlite.py
index 445a8b1..a55b5ce 100644
--- a/code/engine/testes/test_persistencia_sqlite.py
+++ b/code/engine/testes/test_persistencia_sqlite.py
@@ -156,6 +156,43 @@ class TesteRepositorioDeAnalisesSQLite(unittest.TestCase):
self.assertEqual(recarregado.caracteristicas_acusticas, {"pitch_medio": 120.5})
self.assertEqual(recarregado.voz_aparente, "masculina")
+ def test_substituir_transcricoes_nao_duplica_ao_reprocessar(self):
+ with tempfile.TemporaryDirectory() as pasta:
+ repositorio = RepositorioDeAnalisesSQLite(Path(pasta) / "analises.db")
+ primeiro = TranscricaoDoClipe(
+ "clip-1", "/v.mp4", 0.0, 2.0,
+ [SegmentoDeTranscricao(0.0, 1.0, "primeira versão", 0.9)],
+ )
+ segundo = TranscricaoDoClipe(
+ "clip-1", "/v.mp4", 0.0, 2.0,
+ [SegmentoDeTranscricao(0.0, 1.0, "segunda versão", 0.95)],
+ )
+
+ repositorio.substituir_transcricoes("v1", [primeiro])
+ repositorio.substituir_transcricoes("v1", [segundo])
+ carregados = repositorio.carregar_transcricoes("v1", "clip-1")
+
+ self.assertEqual(len(carregados), 1)
+ self.assertEqual(carregados[0].texto, "segunda versão")
+
+ def test_atualizar_enriquecimento_persiste_falante_e_metricas(self):
+ with tempfile.TemporaryDirectory() as pasta:
+ repositorio = RepositorioDeAnalisesSQLite(Path(pasta) / "analises.db")
+ segmento = SegmentoDeTranscricao(0.0, 2.0, "fala", 0.9)
+ repositorio.registrar_transcricoes(
+ "v1", [TranscricaoDoClipe("clip-1", "/v.mp4", 0.0, 2.0, [segmento])],
+ )
+
+ repositorio.atualizar_enriquecimento_de_transcricao(
+ "v1", "clip-1", 0.0, 2.0,
+ falante="SPEAKER_00",
+ caracteristicas_acusticas={"energia_rms": 0.12},
+ )
+ carregado = repositorio.carregar_transcricoes("v1", "clip-1")[0]
+
+ self.assertEqual(carregado.falante, "SPEAKER_00")
+ self.assertEqual(carregado.caracteristicas_acusticas, {"energia_rms": 0.12})
+
def test_registrar_evidencias_visuais_e_cenas(self):
with tempfile.TemporaryDirectory() as pasta:
repositorio = RepositorioDeAnalisesSQLite(Path(pasta) / "analises.db")
diff --git a/code/relatorios/plano-edicao-depoimento-erika-instagram.json b/code/relatorios/plano-edicao-depoimento-erika-instagram.json
new file mode 100644
index 0000000..5661d4f
--- /dev/null
+++ b/code/relatorios/plano-edicao-depoimento-erika-instagram.json
@@ -0,0 +1,89 @@
+{
+ "schema": "plano_edicao_v1",
+ "source": "0E6A8829.MP4",
+ "analysis": {
+ "video_id": "12669c9c-038d-4a3f-99f9-fe6f6827b5f4",
+ "clip_id": "000f476b",
+ "profile": "depoimento",
+ "platform": "instagram",
+ "aspect_ratio": "9:16",
+ "target_duration_seconds": 60,
+ "maximum_duration_seconds": 120,
+ "voice_metrics_available": false,
+ "diarization_available": false
+ },
+ "editorial_intent": {
+ "video_type": "Depoimento de cliente",
+ "goal": "Gerar credibilidade e confiança",
+ "narrative": "problema vivido -> decisão de mudar -> resultado percebido",
+ "preserve": [
+ "autenticidade",
+ "fala espontânea",
+ "transformação pessoal",
+ "pausas naturais"
+ ],
+ "remove": [
+ "perguntas do entrevistador",
+ "hesitações",
+ "repetições",
+ "desvios da narrativa"
+ ],
+ "captions": "complete",
+ "broll": false,
+ "music": "discreta"
+ },
+ "preserved_ranges": [
+ {
+ "start": 256.3,
+ "end": 294.18,
+ "purpose": "Problema vivido e decisão de mudar de vida."
+ },
+ {
+ "start": 856.33,
+ "end": 879.55,
+ "purpose": "Resultado, autoestima e transformação percebida."
+ }
+ ],
+ "actions": [
+ {
+ "id": "corte-001",
+ "kind": "cut",
+ "start": 0.0,
+ "end": 256.3,
+ "reason": "Remover introdução, preparação da entrevista e conteúdo anterior ao depoimento principal.",
+ "params": {
+ "tipo": "conteudo_fora_da_narrativa",
+ "confianca": 0.98
+ }
+ },
+ {
+ "id": "corte-002",
+ "kind": "cut",
+ "start": 294.18,
+ "end": 856.33,
+ "reason": "Remover perguntas do entrevistador, desenvolvimento intermediário e trechos fora do corte curto para Instagram.",
+ "params": {
+ "tipo": "selecao_de_narrativa",
+ "confianca": 0.86
+ }
+ },
+ {
+ "id": "corte-003",
+ "kind": "cut",
+ "start": 879.55,
+ "end": 1018.225542,
+ "reason": "Remover encerramento longo e recomendação final para manter o vídeo próximo de 60 segundos nesta primeira versão.",
+ "params": {
+ "tipo": "duracao_maxima",
+ "confianca": 0.9
+ }
+ }
+ ],
+ "implementation_notes": [
+ "As ações usam segundos do arquivo de origem, não posições depois dos cortes.",
+ "O resultado estimado é de aproximadamente 61,1 segundos.",
+ "As legendas completas ainda precisam ser geradas pelo fluxo de legendagem; esta versão não cria clipes de texto automaticamente.",
+ "O plano não insere B-roll nem música porque essas ações ainda não fazem parte do contrato atual.",
+ "Revisar o primeiro e o último frame no Premiere antes de aplicar definitivamente."
+ ]
+}
diff --git a/docs/PLANO-EDICAO-DE-VIDEO-POR-VOZ.md b/docs/PLANO-EDICAO-DE-VIDEO-POR-VOZ.md
index 8453c5e..ea12b16 100644
--- a/docs/PLANO-EDICAO-DE-VIDEO-POR-VOZ.md
+++ b/docs/PLANO-EDICAO-DE-VIDEO-POR-VOZ.md
@@ -133,6 +133,95 @@ Premiere Pro
O Scanner produz fatos. A IA decide. A engine valida e aplica. A interface coordena e apresenta.
+## 3.1. Fluxo pós-Scanner na tela Editar vídeo
+
+Depois que o Scanner concluir, a tela **Editar vídeo** será a etapa de decisão
+e aplicação:
+
+```text
+Scanner concluído
+ ↓
+Editar vídeo seleciona a análise do Scanner
+ ↓
+Usuário copia o JSON devolvido pela IA
+ ↓
+Cola o JSON no campo do plano
+ ↓
+Painel valida o formato e mostra as ações
+ ↓
+Usuário clica em Executar plano
+ ↓
+Painel salva um arquivo temporário UTF-8
+ ↓
+aplicar_plano_de_edicao.py
+ ↓
+LeitorDePlanoDeEdicao
+ ↓
+validação contextual e backup
+ ↓
+AplicadorDePlanoDeEdicao
+ ↓
+resultado ação por ação na tela
+```
+
+O clipboard serve somente para transportar o texto JSON até o painel. Ele não
+deve ser executado como código nem enviado diretamente ao Premiere.
+
+### Comportamento do campo de plano
+
+Ao colar o JSON, a tela deve:
+
+- fazer `JSON.parse` localmente;
+- verificar se existe `source` e `actions`;
+- mostrar quantidade e tipos de ações;
+- mostrar erros de estrutura antes de liberar o botão;
+- aceitar metadados extras como `schema`, `analysis` e `target`;
+- não modificar o plano colado silenciosamente;
+- salvar uma cópia com data e hora para auditoria.
+
+### Comportamento do botão Executar plano
+
+O botão deve chamar a entrada da engine, e não interpretar as ações no
+JavaScript. A entrada é:
+
+```text
+code/engine/aplicar_plano_de_edicao.py
+```
+
+Essa entrada usa as mesmas classes do fluxo de aplicação da engine:
+
+- `LeitorDePlanoDeEdicao` lê e valida o contrato;
+- `BackupDaSequencia` cria uma cópia antes da mutação;
+- `ConversorDeTimeline` lê a sequência ativa;
+- `MapeadorDeTempoDeOrigemParaTimeline` encontra os clipes corretos;
+- `AplicadorDePlanoDeEdicao` ordena e aplica as ações;
+- `EscritaNoEditor` executa as alterações pelo bridge do Premiere;
+- `ResultadoDaAplicacao` devolve sucesso ou falha por ação.
+
+O painel deve exibir o resultado recebido, incluindo ações aplicadas, ações
+que falharam, detalhes da falha e caminho do plano salvo.
+
+### Diferença entre testar e executar
+
+O fluxo **Testar ações** é um protótipo de validação manual do formato. O
+fluxo definitivo **Executar plano** deve usar a entrada Python da engine para
+compartilhar o mesmo domínio, mapeador, backup e aplicador. Não devem existir
+duas implementações diferentes para cortar a mesma timeline.
+
+### Segurança e confirmação
+
+Antes de iniciar:
+
+- confirmar que a sequência correta está ativa;
+- conferir o arquivo de origem do plano;
+- conferir a quantidade de ações;
+- criar backup;
+- pedir confirmação quando houver cortes.
+
+Durante a execução, o painel deve permitir cancelar o processo e informar se o
+cancelamento ocorreu antes ou depois de alguma ação. O resultado parcial deve
+ser preservado para diagnóstico.
+
## 4. Situação atual do código
### Já existe
@@ -151,10 +240,10 @@ O Scanner produz fatos. A IA decide. A engine valida e aplica. A interface coord
### Lacunas conhecidas
- `listar_falas_no_intervalo()` não retorna métricas acústicas por padrão.
-- Editar vídeo ainda trabalha principalmente com `transcricao.json` e não seleciona diretamente uma análise do Scanner.
+- Editar vídeo aceita o JSON de ações colado no painel ou carregado por arquivo e grava uma cópia local antes da aplicação.
- O contexto editorial ainda não é um módulo explícito da engine.
- O plano atual identifica a origem principalmente por nome e tempo; com várias faixas e clipes, precisa também de `video_id` e `clipe_id`.
-- Reprocessamentos precisam de uma política explícita para não duplicar segmentos no banco.
+- A validação contextual com `video_id`, versão da análise e `clipe_id` ainda precisa ser conectada ao aplicador.
## 5. Consultas SQL compactas
@@ -397,16 +486,58 @@ Novo comportamento a adicionar:
## 10. Fluxo da tela Editar vídeo
-1. Selecionar origem: `Transcrever este vídeo` ou `Usar análise do Scanner`.
-2. Listar análises disponíveis e mostrar modelo, idioma, diarização, métricas e data.
-3. Escolher objetivo: depoimento, entrevista, redes sociais ou personalizado.
-4. Definir regras: remover silêncio, remover repetição, preservar pausas emocionais, locutores e intensidade.
-5. Carregar contexto compacto pela engine.
-6. Mostrar falas, tempos, locutores, métricas e status da análise.
-7. Exportar contexto para IA ou chamar um Provider de IA futuramente.
-8. Importar o plano devolvido.
-9. Mostrar cada ação com intervalo, texto, motivo, confiança e validação.
-10. Criar backup, validar e aplicar pela engine.
+O Scanner acontece antes da tela de edição. Dentro da tela **Editar vídeo**,
+o fluxo terá somente três etapas, mantendo a experiência já conhecida:
+
+### Etapa 3 — Revisar falantes e áudio (opcional)
+
+Esta etapa recebe a análise do Scanner e permite:
+
+- revisar ou nomear falantes quando a diarização estiver disponível;
+- conferir a transcrição e os intervalos;
+- conferir métricas de voz quando tiverem sido calculadas;
+- definir ou revisar o perfil editorial;
+- definir o tipo e o objetivo do vídeo;
+- escolher regras como remoção de silêncios, repetições e hesitações.
+
+Quando a edição usar somente transcrição, a revisão de falantes continua
+opcional. O botão de avanço não deve exigir diarização.
+
+### Etapa 4 — Gerar arquivo JSON
+
+Esta etapa reúne:
+
+- a análise selecionada do Scanner;
+- as falas e palavras necessárias;
+- as métricas solicitadas;
+- o perfil editorial;
+- o tipo e objetivo do vídeo;
+- as regras de edição.
+
+A engine gera o contexto editorial para a IA. Depois que a IA devolver o plano,
+o usuário poderá colar o JSON no campo da etapa ou selecionar um arquivo. O
+painel valida a estrutura e mostra a quantidade de `actions` antes de liberar
+a etapa seguinte.
+
+O arquivo gerado/devolvido deve continuar sendo UTF-8 e conter `source` e
+`actions`, podendo também conter `analysis`, `schema`, `target` e metadados
+editoriais.
+
+### Etapa 5 — Aplicar na timeline
+
+Esta etapa:
+
+- mostra o resumo das ações;
+- confirma a sequência ativa;
+- cria backup;
+- valida a origem e os intervalos;
+- chama `aplicar_plano_de_edicao.py`;
+- usa as classes oficiais da engine;
+- exibe o resultado ação por ação;
+- preserva o relatório em caso de falha ou cancelamento.
+
+Não criar novas etapas para seleção de fonte, contexto ou aplicação. Essas
+responsabilidades devem aparecer dentro das três etapas existentes.
## 11. Classes e módulos planejados