diff --git a/code/cep-plugin/index.html b/code/cep-plugin/index.html index 21a4cbc..f13a9d6 100755 --- a/code/cep-plugin/index.html +++ b/code/cep-plugin/index.html @@ -237,6 +237,9 @@ Bloqueado
+ + +

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.

+
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