From a9e5b5ff584aa7dc2c31b3c761a6a8701241f6b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Henrique?= Date: Thu, 10 Sep 2026 13:09:01 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20copiada=20a=20skill=20editar-por-voz=20?= =?UTF-8?q?e=20seus=20crit=C3=A9rios=20para=20as=20skil?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - copiada a skill editar-por-voz e seus critérios para as skills do projeto - adaptada a skill editar-por-voz para usar o banco SQLite como fonte oficial - corrigida a cópia do JSON do tipo de vídeo para priorizar clipboard Unicode - criado carregador único do contexto de edição por voz a partir do SQLite - criada entrada para registrar planos no SQLite e reaproveitar o mesmo plano na aplicação - habilitado carregamento do plano editorial diretamente do SQLite na interface - corrigido o bloqueio visual da etapa de carregamento do plano salvo - configurado carregamento automático do plano salvo após selecionar a sequência - documentada a sequência como raiz de retomada do fluxo de edição - criada a estrutura local para análise de músicas instrumentais com Essentia Resumo: - 22 arquivos alterados - 11 novos - 11 modificados - 0 removidos 11 files changed, 270 insertions(+), 34 deletions(-) Arquivos: - .jhonny/analises.db - CONTEXT.md - code/cep-plugin/index.html - code/cep-plugin/main.js - code/engine/README.md - code/engine/aplicar_plano_de_edicao.py - code/engine/integracoes/audio/__init__.py - code/engine/persistencia/consultas.py - code/engine/requirements-audio.txt - code/engine/testes/test_consultas_de_persistencia.py - code/tests/cep-tipos-video-encoding.test.ts - code/.jhonny/ - code/engine/carregar_plano_de_edicao.py - code/engine/integracoes/audio/contratos.py - code/engine/integracoes/audio/modelos_de_analise_musical.py - code/engine/integracoes/audio/provider_de_analise_musical_essentia.py - code/engine/preparar_edicao_por_voz.py - code/engine/registrar_plano_de_edicao.py - code/engine/testes/test_analisador_de_musica_essentia.py - code/engine/testes/test_carregar_plano_de_edicao.py - code/engine/testes/test_registrar_plano_de_edicao.py - code/plugins/premiere-pro/skills/editar-por-voz/ --- .jhonny/analises.db | Bin 1073152 -> 1085440 bytes CONTEXT.md | 5 + code/.jhonny/analises.db | Bin 0 -> 212992 bytes code/cep-plugin/index.html | 4 + code/cep-plugin/main.js | 86 ++++++++--- code/engine/README.md | 35 +++++ code/engine/aplicar_plano_de_edicao.py | 35 +++-- code/engine/carregar_plano_de_edicao.py | 80 ++++++++++ code/engine/integracoes/audio/__init__.py | 10 +- code/engine/integracoes/audio/contratos.py | 32 ++++ .../audio/modelos_de_analise_musical.py | 35 +++++ .../provider_de_analise_musical_essentia.py | 142 ++++++++++++++++++ code/engine/persistencia/consultas.py | 99 ++++++++++++ code/engine/preparar_edicao_por_voz.py | 76 ++++++++++ code/engine/registrar_plano_de_edicao.py | 77 ++++++++++ code/engine/requirements-audio.txt | 2 + .../test_analisador_de_musica_essentia.py | 56 +++++++ .../testes/test_carregar_plano_de_edicao.py | 37 +++++ .../testes/test_consultas_de_persistencia.py | 20 +++ .../testes/test_registrar_plano_de_edicao.py | 43 ++++++ .../skills/editar-por-voz/SKILL.md | 142 ++++++++++++++++++ .../criterios/00-fonte-de-dados.md | 35 +++++ .../criterios/01-leitura-do-json.md | 78 ++++++++++ .../02-triagem-roteiro-vs-conversa.md | 54 +++++++ .../criterios/03-escolha-da-melhor-tomada.md | 63 ++++++++ .../04-reanalise-do-material-restante.md | 76 ++++++++++ .../editar-por-voz/criterios/05-zoom.md | 84 +++++++++++ .../criterios/06-texto-corte-marcador.md | 127 ++++++++++++++++ .../editar-por-voz/criterios/07-ritmo.md | 40 +++++ .../criterios/08-formato-de-saida.md | 89 +++++++++++ .../criterios/09-analise-incompleta.md | 53 +++++++ .../criterios/10-revisao-humana.md | 122 +++++++++++++++ code/tests/cep-tipos-video-encoding.test.ts | 8 +- 33 files changed, 1811 insertions(+), 34 deletions(-) create mode 100644 code/.jhonny/analises.db create mode 100644 code/engine/carregar_plano_de_edicao.py create mode 100644 code/engine/integracoes/audio/contratos.py create mode 100644 code/engine/integracoes/audio/modelos_de_analise_musical.py create mode 100644 code/engine/integracoes/audio/provider_de_analise_musical_essentia.py create mode 100644 code/engine/preparar_edicao_por_voz.py create mode 100644 code/engine/registrar_plano_de_edicao.py create mode 100644 code/engine/testes/test_analisador_de_musica_essentia.py create mode 100644 code/engine/testes/test_carregar_plano_de_edicao.py create mode 100644 code/engine/testes/test_registrar_plano_de_edicao.py create mode 100644 code/plugins/premiere-pro/skills/editar-por-voz/SKILL.md create mode 100644 code/plugins/premiere-pro/skills/editar-por-voz/criterios/00-fonte-de-dados.md create mode 100644 code/plugins/premiere-pro/skills/editar-por-voz/criterios/01-leitura-do-json.md create mode 100644 code/plugins/premiere-pro/skills/editar-por-voz/criterios/02-triagem-roteiro-vs-conversa.md create mode 100644 code/plugins/premiere-pro/skills/editar-por-voz/criterios/03-escolha-da-melhor-tomada.md create mode 100644 code/plugins/premiere-pro/skills/editar-por-voz/criterios/04-reanalise-do-material-restante.md create mode 100644 code/plugins/premiere-pro/skills/editar-por-voz/criterios/05-zoom.md create mode 100644 code/plugins/premiere-pro/skills/editar-por-voz/criterios/06-texto-corte-marcador.md create mode 100644 code/plugins/premiere-pro/skills/editar-por-voz/criterios/07-ritmo.md create mode 100644 code/plugins/premiere-pro/skills/editar-por-voz/criterios/08-formato-de-saida.md create mode 100644 code/plugins/premiere-pro/skills/editar-por-voz/criterios/09-analise-incompleta.md create mode 100644 code/plugins/premiere-pro/skills/editar-por-voz/criterios/10-revisao-humana.md diff --git a/.jhonny/analises.db b/.jhonny/analises.db index e606e8b7f3841d320b91bc6761c2139efa963d82..766e6178a1c3cc02ded2e979f20650de8aad45b3 100644 GIT binary patch delta 1332 zcmZ8hO=ufO6xQsnepazOmTl_NpLB94G++r?wyZ|NR4TZQ>A?`1i!o6~yJL4j+F52- z3Q2HfQM`ecHg0p!p@*bxXfQpvK@jz(g04-|^wLuy^wL5hP)b6fq4dq#R@4rXW|;S8 z-uLrH+uxSAZ@j+y+$5R|g^(T^j@b2&XR`{LynX)W^~oq+58+?&&v*y-@g8>YdN+Cg z4V<2gswWXOICCo|uQ)Yo^YBzeP3gfBHd%$4mO=Ros=9TII$YS z7sr1Ye=Bh>vD!V6SxU6t4aY+|nu!GG zKoxiiDF?C?2?bNZI=~Hk;J;usD!sP^lM^MLptifw;-Y?}BohX!g8Kn51#s!omJ3M< ztNQ6r@g?VG>B7$MJIB8{b0kV|$y22dC31-+^{7K54?YtXb&JDB69A=J;|7Nm%dlvx zMqFMMOm?V6H*N43S`dT+!u5t3aFCqgAq2QI1h`^Tv1ofUz*Wg!U4P}#;0O<`GT}7@ zos|WLY7lR*XF$0?r6I<^$I~N577sBAVn5Z3l6S!8K^JI13%D+)HPA;kei%Op;lJ@C z{1E;G9(0qt-(wQdbcD2UFsWKFDQZ~Lb);yT9@9VHCZGJEjVbssT!3^q{urLY!$|L+ zXVkx9a4U)_@DoKoQGKHMBFc^Nu2pIcL|z*~jyc(32b*&_0QPYX!@5EY80u5fzcBG0*>pjX8-^I delta 339 zcmZoz;M8!yae|Z(!yyI+Mm7dGV3VJyW5mR8Xk)^X`FyPWtqlCn`S0?#^6%$gwOKHs zn4eXKnc0?c`o%aV30ZfbE_TL(L}6BDUCz{$%;fyk;`o%*__EBD)cofC>)ZFQXPmx& zJ(KHZmWIFbiy0dfKzKW2f&jBXJBtD{5VHU=D-g2*F*^`*05K;Ja{)0o5c4c&QQ)1x z#mK*(0f>wk7`F3m;ML`?58r&9%eG*NL-S5 z*(D{_A$)eL+>XFE4p8;>@Becj0IJhzv;(P=2di^VEiNfdNz5w&Dlba|0hl)X_y5{} jsu@%oZ9&TU!OES0%2+dJ0zv!!{k%ZTw|)P9e#Zp>;pSr> diff --git a/CONTEXT.md b/CONTEXT.md index b36ab76..1105d0a 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -3,6 +3,8 @@ ## Vocabulário - **Scanner**: caso de uso que coordena a leitura, análise, decisão, planejamento, aplicação e validação de uma edição. +- **Sequência**: timeline do Premiere e raiz operacional de um trabalho de edição; no banco é representada pelo vídeo identificado por `video_id` e nomeada pelo campo `sequencia`. +- **Edição de vídeo**: versão da configuração editorial concluída para uma sequência; registra tipo, instruções e origem, mas não representa ainda uma aplicação na timeline. - **Conteúdo**: unidade analisável identificada por um `content_id`, com texto e metadados. - **Análise**: observações estruturadas produzidas a partir do conteúdo. - **Decisão**: escolha de ações editoriais baseada na análise e na configuração. @@ -19,3 +21,6 @@ - A arquitetura Python ficará isolada em `code/engine` enquanto o sistema existente continuar em TypeScript. - O domínio não conhece filesystem, banco de dados, SDK de IA ou plataforma de edição; essas integrações entram por interfaces e adaptadores. - O primeiro fluxo é síncrono e determinístico, permitindo evolução posterior para operações assíncronas sem alterar o domínio. +- A sequência é a unidade de retomada: ao selecioná-la, o sistema deve localizar a edição concluída mais recente, reutilizar suas análises e oferecer o plano pendente para aprovação e aplicação. +- Uma edição concluída, um plano gerado e uma aplicação realizada são estados distintos e devem permanecer auditáveis; concluir a configuração não significa que o Premiere já foi alterado. +- O plano deve evoluir para referenciar diretamente a versão de edição que o originou, além do `video_id`, para impedir que uma nova configuração editorial seja aplicada por engano sobre um plano antigo. diff --git a/code/.jhonny/analises.db b/code/.jhonny/analises.db new file mode 100644 index 0000000000000000000000000000000000000000..6e70c3d0972a91db54dbbe3e80f2aae8c25c9eec GIT binary patch literal 212992 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 literal 0 HcmV?d00001 diff --git a/code/cep-plugin/index.html b/code/cep-plugin/index.html index 7df80be..5f15ac9 100755 --- a/code/cep-plugin/index.html +++ b/code/cep-plugin/index.html @@ -156,6 +156,10 @@
+
+ +
+

O plano será lido diretamente do banco da edição concluída.

Cole o plano recebido da IA. O painel valida o formato antes de executar.

diff --git a/code/cep-plugin/main.js b/code/cep-plugin/main.js index 432a2c9..9f539a3 100755 --- a/code/cep-plugin/main.js +++ b/code/cep-plugin/main.js @@ -479,7 +479,7 @@ function testarAcoesExecutar() { SCANNER_PYTHON_BIN, [TESTAR_ACOES_ENGINE_SCRIPT, entrada], function (linha) { testarAcoesLog(linha); }, - function (codigo, ultimoErro) { + function (codigo, ultimoErro, ultimaSaida) { if (botao) botao.disabled = false; if (codigo !== 0) { testarAcoesAtualizarStatus("Falha na execução. Consulte os detalhes técnicos.", "err"); @@ -792,19 +792,19 @@ function copiarTextoParaAreaDeTransferencia(texto, mensagem) { else showToast("err", "Não foi possível copiar o JSON. Selecione e copie manualmente."); } - // O CEP antigo pode corromper caracteres UTF-8 pela API de clipboard do Chromium. - // O pbcopy recebe bytes UTF-8 diretamente e evita essa conversão intermediária. - copiarComPbcopy(texto, function () { - try { - if (navigator.clipboard && navigator.clipboard.writeText) { - navigator.clipboard.writeText(texto).then(function () { - showToast("ok", mensagem); - }).catch(copiarComSelecaoDoPainel); - return; - } - } catch (_) {} - copiarComSelecaoDoPainel(); - }, mensagem); + // A API nativa preserva o texto Unicode no clipboard do CEP/Chromium. + // Use pbcopy apenas quando a API não existir ou falhar. + try { + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(texto).then(function () { + showToast("ok", mensagem); + }).catch(function () { + copiarComPbcopy(texto, copiarComSelecaoDoPainel, mensagem); + }); + return; + } + } catch (_) {} + copiarComPbcopy(texto, copiarComSelecaoDoPainel, mensagem); } function copiarComPbcopy(texto, fallback, mensagem) { @@ -946,6 +946,8 @@ var SCANNER_PYTHON_BIN = "/Volumes/Merongo/SISTEMAS/venvs/whisperx-transcricao/b var ANALISE_STATUS_SCRIPT = "/Volumes/Merongo/SISTEMAS/GENIAL SISTEMAS/Jhonny/code/engine/consultar_status_da_analise.py"; var ANALISE_PREVIEW_SCRIPT = "/Volumes/Merongo/SISTEMAS/GENIAL SISTEMAS/Jhonny/code/engine/consultar_preview_de_falantes.py"; var REGISTRAR_EDICAO_SCRIPT = "/Volumes/Merongo/SISTEMAS/GENIAL SISTEMAS/Jhonny/code/engine/registrar_edicao.py"; +var CARREGAR_PLANO_SCRIPT = "/Volumes/Merongo/SISTEMAS/GENIAL SISTEMAS/Jhonny/code/engine/carregar_plano_de_edicao.py"; +var planoEditorialIdCarregado = null; var retakesCasoAtual = null; var retakesGrupos = []; var retakesProcesso = null; @@ -2699,12 +2701,15 @@ function silenceSelectSequence() { setStep(1, "done", "Timeline escolhida"); lockStepsFrom(2); setStep(2, "ready", "Sua vez"); + setStep(3, "ready", "Carregar plano salvo"); + document.getElementById("btnLoadSavedEditorialPlan").disabled = false; document.getElementById("btnTranscribe").disabled = false; document.getElementById("silenceJsonCard").hidden = true; document.getElementById("jsonFileActions").hidden = true; document.getElementById("silencePlanInfo").textContent = ""; silenceLog("Timeline escolhida: " + result.sequenceName + " — " + result.mediaPath); renderCachedTranscripts(); + carregarPlanoEditorialSalvo(); } ); } @@ -2716,9 +2721,10 @@ function silenceDetectClip() { // Volta os passos seguintes ao estado travado quando o vídeo (ou a transcrição) // muda — evita aplicar um plano gerado para outro material. function lockStepsFrom(first) { + if (first <= 3) planoEditorialIdCarregado = null; var buttons = { 2: ["btnBuildPlan"], - 3: ["btnApplyEditorialActions"], + 3: ["btnApplyEditorialActions", "btnLoadSavedEditorialPlan"], }; for (var n = first; n <= 5; n++) { setStep(n, "locked", "Bloqueado"); @@ -2815,6 +2821,7 @@ function runStreamed(bin, args, onLine, onDone, options) { var child = childProcess.spawn(bin, args, options || {}); var buffer = ""; var ultimoErro = ""; + var ultimaSaida = ""; // Linhas "@@PROGRESS {json}" são o canal de progresso dos scripts Python/Node: // vão para a barra no topo em vez de virarem ruído no log. function handleLine(line) { @@ -2835,12 +2842,12 @@ function runStreamed(bin, args, onLine, onDone, options) { if (parts[i].trim()) handleLine(parts[i]); } } - child.stdout.on("data", function (d) { flushLines(d.toString()); }); + child.stdout.on("data", function (d) { ultimaSaida += d.toString(); flushLines(d.toString()); }); child.stderr.on("data", function (d) { ultimoErro = d.toString().trim() || ultimoErro; flushLines(d.toString()); }); child.on("error", function (err) { onDone(1, "spawn error: " + err.message); }); child.on("close", function (code) { if (buffer.trim()) handleLine(buffer); - onDone(code, ultimoErro || null); + onDone(code, ultimoErro || null, ultimaSaida); }); return child; } @@ -3288,7 +3295,7 @@ function silenceConcluirEdicao() { SCANNER_PYTHON_BIN, [REGISTRAR_EDICAO_SCRIPT, caminhoDoPedido, "--banco", ANALISES_DB_PATH], function (linha) { silenceLog(linha); }, - function (codigo, ultimoErro) { + function (codigo, ultimoErro, ultimaSaida) { setBusy("btnBuildPlan", false); if (codigo !== 0) { setStep(2, "error", "Falhou"); @@ -3299,6 +3306,7 @@ function silenceConcluirEdicao() { } setStep(2, "done", "Edição concluída"); setStep(3, "locked", "Aguardando plano da IA"); + document.getElementById("btnLoadSavedEditorialPlan").disabled = false; taskEnd(true, "Edição concluída e salva no banco."); silenceLog("Edição concluída e salva no banco de análises.", "ok"); showToast("ok", "Edição concluída e salva no banco."); @@ -3306,11 +3314,49 @@ function silenceConcluirEdicao() { ); } +// Busca o plano já registrado no SQLite; não cria JSON novo nem duplica plano. +function carregarPlanoEditorialSalvo() { + if (!silenceState.sequenceId) { + showToast("err", "Detecte a sequência antes de carregar o plano."); + return; + } + setBusy("btnLoadSavedEditorialPlan", true); + silenceLog("Lendo o plano editorial diretamente do banco…"); + runStreamed( + SCANNER_PYTHON_BIN, + [CARREGAR_PLANO_SCRIPT, "--banco", ANALISES_DB_PATH, "--video-id", String(silenceState.sequenceId)], + function (linha) { silenceLog(linha); }, + function (codigo, ultimoErro) { + setBusy("btnLoadSavedEditorialPlan", false); + if (codigo !== 0) { + silenceLog("Não foi possível carregar o plano salvo.", "err"); + showToast("err", ultimoErro || "Nenhum plano salvo encontrado para este vídeo."); + return; + } + try { + var resposta = JSON.parse(ultimaSaida || "{}"); + if (!resposta.ok || !resposta.plano) throw new Error("Resposta inválida do banco."); + planoEditorialIdCarregado = resposta.plano.plano_id; + document.getElementById("editorialPlanJson").value = JSON.stringify(resposta.plano, null, 2); + document.getElementById("editorialSavedPlanStatus").textContent = "Plano " + planoEditorialIdCarregado + " carregado do banco."; + document.getElementById("editorialPlanJsonStatus").textContent = "Plano salvo no SQLite; pronto para aprovação e aplicação."; + document.getElementById("btnApplyEditorialActions").disabled = false; + setStep(3, "ready", "Plano carregado"); + silenceLog("Plano " + planoEditorialIdCarregado + " carregado diretamente do SQLite.", "ok"); + showToast("ok", "Plano salvo carregado."); + } catch (erro) { + showToast("err", erro.message); + } + } + ); +} + // Não gera decisões de corte: só empacota transcrição + tempos + locutores + // métricas de voz + configurações de edição num JSON para um agente de IA // externo analisar. Quem decide os cortes é a IA, não este painel. function silenceGenerateJson() { if (!silenceState.transcriptPath) return; + planoEditorialIdCarregado = null; if (!silenceState.tipoVideo) { showToast("err", "Selecione o tipo de vídeo antes de gerar o JSON."); setStep(1, "ready", "Selecione um tipo"); @@ -3457,7 +3503,9 @@ function applyEditorialActions() { }); processoAplicacao = runStreamed( SCANNER_PYTHON_BIN, - [TESTAR_ACOES_ENGINE_SCRIPT, caminhoDoPlano], + [TESTAR_ACOES_ENGINE_SCRIPT, caminhoDoPlano].concat( + planoEditorialIdCarregado === null ? [] : ["--plano-id", String(planoEditorialIdCarregado), "--banco", ANALISES_DB_PATH] + ), function (line) { silenceLog(line); }, function (code, lastErr) { processoAplicacao = null; diff --git a/code/engine/README.md b/code/engine/README.md index 98b0d15..f873c0f 100644 --- a/code/engine/README.md +++ b/code/engine/README.md @@ -11,3 +11,38 @@ assert result.valid ``` As integrações reais devem implementar os protocolos em `content_analyzer.py`, `decision_engine.py`, `edit_plan.py`, `plan_applicator.py`, `validator.py` e `persistence.py`. O pacote não cria dependências externas por padrão. + +## Análise de músicas instrumentais + +A análise musical local fica separada da análise de voz e segue este fluxo: + +```text +caso de uso da engine + ↓ +AnalisadorDeMusica (contrato) + ↓ +AnalisadorDeMusicaInstrumentalEssentia + ↓ +ResultadoDaAnaliseMusical +``` + +Arquivos principais: + +- `integracoes/audio/contratos.py`: contrato substituível do analisador; +- `integracoes/audio/modelos_de_analise_musical.py`: resultado interno em PT-BR; +- `integracoes/audio/provider_de_analise_musical_essentia.py`: adaptador local do Essentia; +- `testes/test_analisador_de_musica_essentia.py`: testes unitários sem instalar Essentia. + +Exemplo de uso: + +```python +from engine.integracoes.audio import AnalisadorDeMusicaInstrumentalEssentia + +resultado = AnalisadorDeMusicaInstrumentalEssentia().analisar("musica.wav") +print(resultado.generos, resultado.humores, resultado.batidas_por_minuto) +``` + +A dependência é carregada sob demanda. Para habilitar a análise, instale as +dependências de `requirements-audio.txt`. O adaptador não persiste o resultado +nem gera a descrição narrativa; essas responsabilidades ficarão no caso de +uso e na apresentação, respectivamente. diff --git a/code/engine/aplicar_plano_de_edicao.py b/code/engine/aplicar_plano_de_edicao.py index 579433a..4f1c6cc 100644 --- a/code/engine/aplicar_plano_de_edicao.py +++ b/code/engine/aplicar_plano_de_edicao.py @@ -17,6 +17,7 @@ troca de sequência. from __future__ import annotations +import argparse import json import os import shutil @@ -81,7 +82,13 @@ def resolver_caminho_do_node() -> str: ) -def _registrar_no_banco(caminho_do_plano: Path, resultado: object, sequencia: str) -> None: +def _registrar_no_banco( + caminho_do_plano: Path, + resultado: object, + sequencia: str, + plano_id: int | None = None, + caminho_do_banco: Path = CAMINHO_DO_BANCO, +) -> None: """Guarda no banco de análises o plano aplicado e o resultado da aplicação. O registro é o único vestígio de como uma edição foi decidida: sem ele o @@ -103,8 +110,9 @@ def _registrar_no_banco(caminho_do_plano: Path, resultado: object, sequencia: st ) dados = json.loads(caminho_do_plano.read_text(encoding="utf-8")) - repositorio = RepositorioDePlanos(abrir_banco(CAMINHO_DO_BANCO)) - plano_id = repositorio.registrar_plano(plano_de_dict(dados)) + repositorio = RepositorioDePlanos(abrir_banco(caminho_do_banco)) + if plano_id is None: + plano_id = repositorio.registrar_plano(plano_de_dict(dados)) aplicadas = sum(1 for item in resultado.resultados if item.sucesso) repositorio.registrar_aplicacao( plano_id, @@ -117,7 +125,11 @@ def _registrar_no_banco(caminho_do_plano: Path, resultado: object, sequencia: st file=sys.stderr) -def executar(caminho_do_plano: Path) -> dict: +def executar( + caminho_do_plano: Path, + plano_id: int | None = None, + caminho_do_banco: Path = CAMINHO_DO_BANCO, +) -> dict: """Lê o plano em ``caminho_do_plano`` e o aplica na sequência ativa do Premiere. Devolve um dicionário pronto para virar JSON, com o resultado de cada @@ -154,7 +166,10 @@ def executar(caminho_do_plano: Path) -> dict: ) resultado = aplicador.aplicar(plano) - _registrar_no_banco(caminho_do_plano, resultado, sequencia_ativa.nome) + _registrar_no_banco( + caminho_do_plano, resultado, sequencia_ativa.nome, + plano_id=plano_id, caminho_do_banco=caminho_do_banco, + ) return { "arquivo_de_origem": plano.arquivo_de_origem, @@ -175,10 +190,12 @@ def executar(caminho_do_plano: Path) -> dict: def main() -> None: """Ponto de entrada da linha de comando.""" - if len(sys.argv) != 2: - print("Uso: python aplicar_plano_de_edicao.py ", file=sys.stderr) - sys.exit(2) - resultado = executar(Path(sys.argv[1])) + argumentos = argparse.ArgumentParser() + argumentos.add_argument("plano", type=Path) + argumentos.add_argument("--plano-id", type=int) + argumentos.add_argument("--banco", type=Path, default=CAMINHO_DO_BANCO) + opcoes = argumentos.parse_args() + resultado = executar(opcoes.plano, opcoes.plano_id, opcoes.banco) print(json.dumps(resultado, ensure_ascii=False, indent=2)) if not resultado["todas_bem_sucedidas"]: sys.exit(1) diff --git a/code/engine/carregar_plano_de_edicao.py b/code/engine/carregar_plano_de_edicao.py new file mode 100644 index 0000000..f2746ee --- /dev/null +++ b/code/engine/carregar_plano_de_edicao.py @@ -0,0 +1,80 @@ +"""Entrada de linha de comando para carregar um plano salvo no SQLite.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +import sys + +CAMINHO_DO_CODIGO = Path(__file__).resolve().parent.parent +if str(CAMINHO_DO_CODIGO) not in sys.path: + sys.path.insert(0, str(CAMINHO_DO_CODIGO)) + +from engine.persistencia.conexao import abrir_banco +from engine.persistencia.repositorio_de_planos import RepositorioDePlanos + + +def carregar( + caminho_do_banco: Path, + plano_id: int | None = None, + video_id: str | None = None, +) -> dict[str, object] | None: + """Carrega um plano existente, preferindo o mais recente do vídeo informado.""" + conexao = abrir_banco(caminho_do_banco) + try: + if plano_id is None: + if not video_id: + raise ValueError("Informe plano_id ou video_id para carregar um plano.") + linha = conexao.execute( + """SELECT id FROM planos_de_edicao + WHERE video_id = ? ORDER BY criado_em DESC, id DESC LIMIT 1""", + (video_id,), + ).fetchone() + if linha is None: + return None + plano_id = int(linha["id"]) + plano = RepositorioDePlanos(conexao).carregar_plano(plano_id) + if plano is None: + return None + return { + "plano_id": plano_id, + "source": plano.origem, + "actions": [ + { + "kind": acao.tipo, + "start": acao.inicio, + "end": acao.fim, + "reason": acao.motivo or "Decisão registrada no plano editorial.", + **acao.parametros, + } + for acao in plano.acoes + ], + "video_id": plano.video_id, + "tipo_de_video": plano.tipo_de_video, + "modelo_da_ia": plano.modelo_da_ia, + "intencao": plano.intencao, + } + finally: + conexao.close() + + +def principal() -> None: + """Executa o carregamento pela linha de comando.""" + argumentos = argparse.ArgumentParser() + argumentos.add_argument("--banco", type=Path, default=Path(".jhonny/analises.db")) + argumentos.add_argument("--plano-id", type=int) + argumentos.add_argument("--video-id") + opcoes = argumentos.parse_args() + try: + plano = carregar(opcoes.banco, opcoes.plano_id, opcoes.video_id) + if plano is None: + raise ValueError("Nenhum plano de edição encontrado.") + print(json.dumps({"ok": True, "plano": plano}, ensure_ascii=False)) + except (OSError, ValueError) as erro: + print(json.dumps({"ok": False, "erro": str(erro)}, ensure_ascii=False)) + raise SystemExit(1) from erro + + +if __name__ == "__main__": + principal() diff --git a/code/engine/integracoes/audio/__init__.py b/code/engine/integracoes/audio/__init__.py index d080080..359e612 100644 --- a/code/engine/integracoes/audio/__init__.py +++ b/code/engine/integracoes/audio/__init__.py @@ -1,5 +1,11 @@ -"""Integrações locais para análise de áudio.""" +"""Integrações locais para análise de áudio e música.""" from .analisador_de_metricas_de_voz import AnalisadorDeMetricasDeVoz +from .modelos_de_analise_musical import ResultadoDaAnaliseMusical +from .provider_de_analise_musical_essentia import AnalisadorDeMusicaInstrumentalEssentia -__all__ = ["AnalisadorDeMetricasDeVoz"] +__all__ = [ + "AnalisadorDeMetricasDeVoz", + "AnalisadorDeMusicaInstrumentalEssentia", + "ResultadoDaAnaliseMusical", +] diff --git a/code/engine/integracoes/audio/contratos.py b/code/engine/integracoes/audio/contratos.py new file mode 100644 index 0000000..2925c0f --- /dev/null +++ b/code/engine/integracoes/audio/contratos.py @@ -0,0 +1,32 @@ +""" +Contratos para provedores de análise musical. + +O contrato permite que a aplicação use Essentia, um dublê de teste ou outro +provedor no futuro sem conhecer detalhes de carregamento e classificação. +""" + +from pathlib import Path +from typing import Protocol + +from .modelos_de_analise_musical import ResultadoDaAnaliseMusical + + +class AnalisadorDeMusica(Protocol): + """Define a operação necessária para analisar um arquivo musical.""" + + def analisar(self, arquivo: str | Path) -> ResultadoDaAnaliseMusical: + """ + Analisa um arquivo de música e devolve seus descritores internos. + + Parâmetros: + arquivo: Caminho de um arquivo de áudio existente. + + Retorna: + Resultado estruturado da análise musical. + + Pode gerar: + FileNotFoundError: quando o arquivo não existir. + RuntimeError: quando Essentia não estiver instalado. + """ + + ... diff --git a/code/engine/integracoes/audio/modelos_de_analise_musical.py b/code/engine/integracoes/audio/modelos_de_analise_musical.py new file mode 100644 index 0000000..6a82940 --- /dev/null +++ b/code/engine/integracoes/audio/modelos_de_analise_musical.py @@ -0,0 +1,35 @@ +""" +Modelos internos para representar análises de músicas instrumentais. + +Este módulo define somente os dados produzidos pela análise. Ele não importa +Essentia, não lê arquivos e não transforma o resultado em texto para a +interface. +""" + +from dataclasses import dataclass, field +from pathlib import Path + + +@dataclass(frozen=True) +class ResultadoDaAnaliseMusical: + """ + Representa os descritores calculados para uma música instrumental. + + A classe mantém valores objetivos, como BPM e tonalidade, além das + classificações fornecidas pelos modelos disponíveis. Ela não afirma uma + interpretação definitiva da intenção artística da música. + """ + + caminho_do_arquivo: Path + duracao_em_segundos: float | None = None + batidas_por_minuto: float | None = None + tonalidade: str | None = None + modo: str | None = None + intensidade: float | None = None + dancabilidade: float | None = None + generos: tuple[str, ...] = field(default_factory=tuple) + humores: tuple[str, ...] = field(default_factory=tuple) + instrumentos: tuple[str, ...] = field(default_factory=tuple) + etiquetas: tuple[str, ...] = field(default_factory=tuple) + provedor: str = "Essentia" + modelo: str | None = None diff --git a/code/engine/integracoes/audio/provider_de_analise_musical_essentia.py b/code/engine/integracoes/audio/provider_de_analise_musical_essentia.py new file mode 100644 index 0000000..1e9fcf8 --- /dev/null +++ b/code/engine/integracoes/audio/provider_de_analise_musical_essentia.py @@ -0,0 +1,142 @@ +""" +Adaptador local para análise musical usando Essentia. + +O adaptador concentra a dependência externa e converte seus descritores para +``ResultadoDaAnaliseMusical``. Ele não persiste resultados nem conhece a +interface do painel. As classificações de gênero, humor e instrumentos são +lidas quando os modelos de alto nível instalados retornarem essas categorias. +""" + +from pathlib import Path +from typing import Any, Callable + +from .modelos_de_analise_musical import ResultadoDaAnaliseMusical + + +class AnalisadorDeMusicaInstrumentalEssentia: + """ + Analisa arquivos de áudio localmente por meio do ``MusicExtractor``. + + A criação do extrator é tardia para que a engine continue importável sem + Essentia instalada. Um extrator compatível pode ser injetado nos testes, + evitando dependência de modelos pesados durante o teste unitário. + """ + + def __init__( + self, + extrator: Callable[[str], tuple[Any, Any]] | None = None, + nome_do_modelo: str | None = None, + ) -> None: + """Inicializa o adaptador sem carregar a dependência externa.""" + self._extrator = extrator + self._nome_do_modelo = nome_do_modelo + + def analisar(self, arquivo: str | Path) -> ResultadoDaAnaliseMusical: + """ + Analisa um arquivo de música instrumental. + + Parâmetros: + arquivo: Caminho de áudio que será analisado. + + Retorna: + Descritores objetivos e classificações disponíveis. + + Pode gerar: + FileNotFoundError: quando o caminho não apontar para um arquivo. + RuntimeError: quando Essentia não estiver instalada. + ValueError: quando o resultado externo não possuir formato válido. + """ + caminho = Path(arquivo).expanduser().resolve(strict=False) + if not caminho.is_file(): + raise FileNotFoundError(f"Arquivo de áudio não encontrado: {caminho}") + + dados, _ = self._obter_extrator()(str(caminho)) + if dados is None: + raise ValueError("O Essentia devolveu uma análise vazia.") + return self._converter_resultado(caminho, dados) + + def _obter_extrator(self) -> Callable[[str], tuple[Any, Any]]: + if self._extrator is not None: + return self._extrator + try: + from essentia.standard import MusicExtractor + except ImportError as erro: + raise RuntimeError( + "A análise musical local precisa da biblioteca Essentia. " + "Instale as dependências de requirements-audio.txt." + ) from erro + self._extrator = MusicExtractor() + return self._extrator + + def _converter_resultado( + self, caminho: Path, dados: Any, + ) -> ResultadoDaAnaliseMusical: + return ResultadoDaAnaliseMusical( + caminho_do_arquivo=caminho, + duracao_em_segundos=self._numero(self._buscar(dados, "metadata.audio_properties.length")), + batidas_por_minuto=self._numero(self._buscar(dados, "rhythm.bpm")), + tonalidade=self._texto(self._buscar(dados, "tonal.key_edma.key")), + modo=self._texto(self._buscar(dados, "tonal.key_edma.scale")), + intensidade=self._numero(self._buscar(dados, "lowlevel.average_loudness")), + dancabilidade=self._numero(self._buscar(dados, "rhythm.danceability")), + generos=self._etiquetas(dados, ("genre", "genres")), + humores=self._etiquetas(dados, ("mood", "moods")), + instrumentos=self._etiquetas(dados, ("instrument", "instruments")), + etiquetas=self._etiquetas(dados, ("tags", "semantic")), + modelo=self._nome_do_modelo, + ) + + @staticmethod + def _buscar(dados: Any, caminho: str) -> Any: + """Busca uma chave pontuada em um resultado Essentia ou devolve None. + + O ``Pool`` do Essentia devolve descritores com a chave completa, como + ``rhythm.bpm``. Dublês de teste e algumas integrações podem devolvê-los + como dicionários aninhados; os dois formatos são aceitos aqui. + """ + try: + return dados[caminho] + except (KeyError, IndexError, TypeError): + pass + valor = dados + for parte in caminho.split("."): + try: + valor = valor[parte] + except (KeyError, IndexError, TypeError): + return None + return valor + + @classmethod + def _etiquetas(cls, dados: Any, nomes: tuple[str, ...]) -> tuple[str, ...]: + """Converte classificações externas em uma tupla ordenada de textos.""" + encontrados: list[str] = [] + for nome in nomes: + valor = cls._buscar(dados, f"highlevel.{nome}.value") + if isinstance(valor, dict): + encontrados.extend( + str(chave) for chave, pontuacao in valor.items() + if cls._numero(pontuacao) is not None and float(pontuacao) > 0 + ) + elif isinstance(valor, (list, tuple)): + encontrados.extend(str(item) for item in valor) + elif isinstance(valor, str): + encontrados.append(valor) + return tuple(dict.fromkeys(encontrados)) + + @staticmethod + def _numero(valor: Any) -> float | None: + """Converte valores numéricos externos sem aceitar booleanos.""" + if isinstance(valor, bool) or valor is None: + return None + try: + return float(valor) + except (TypeError, ValueError): + return None + + @staticmethod + def _texto(valor: Any) -> str | None: + """Converte valores textuais externos, ignorando vazios.""" + if valor is None or isinstance(valor, (dict, list, tuple)): + return None + texto = str(valor).strip() + return texto or None diff --git a/code/engine/persistencia/consultas.py b/code/engine/persistencia/consultas.py index 95beb9e..403673e 100644 --- a/code/engine/persistencia/consultas.py +++ b/code/engine/persistencia/consultas.py @@ -135,6 +135,105 @@ class ConsultasDeAnalises: }, } + def consultar_contexto_da_edicao( + self, + video_id: str | None = None, + incluir_palavras: bool = True, + ) -> dict | None: + """ + Carrega a edição concluída mais recente e seu contexto editorial. + + Quando ``video_id`` não é informado, seleciona a edição concluída mais + recente do banco. O retorno reúne a configuração editorial, o vídeo, + os clipes, as falas e o histórico resumido de planos, para que um + agente possa iniciar uma edição sem escrever SQL nem procurar arquivos + auxiliares. + + Parâmetros: + video_id: Restringe a busca a um vídeo quando informado. + incluir_palavras: Inclui palavras da transcrição para decisões + que dependem de limites precisos. + + Retorna: + Contexto completo da edição mais recente, ou None quando não há + edição concluída compatível. + + Pode gerar: + ErroDeConsultaInvalida: quando ``video_id`` for vazio ou a + configuração persistida não for um JSON válido. + """ + if video_id is not None: + self._validar_texto_obrigatorio("video_id", video_id) + condicao = "WHERE video_id = ?" if video_id is not None else "" + parametros = (video_id,) if video_id is not None else () + edicao = self.conexao.execute( + f"""SELECT id, video_id, sequencia, origem, tipo_de_video, + configuracao, concluida_em + FROM edicoes_de_video + {condicao} + ORDER BY concluida_em DESC, id DESC + LIMIT 1""", + parametros, + ).fetchone() + if edicao is None: + return None + try: + configuracao = json.loads(edicao["configuracao"]) + except (TypeError, json.JSONDecodeError) as erro: + raise ErroDeConsultaInvalida( + f"A configuração da edição {edicao['id']} não é um JSON válido." + ) from erro + if not isinstance(configuracao, dict): + raise ErroDeConsultaInvalida( + f"A configuração da edição {edicao['id']} precisa ser um objeto." + ) + + identificador_do_video = edicao["video_id"] + clipes = [ + dict(linha) for linha in self.conexao.execute( + """SELECT id, faixa_id, nome, inicio_na_timeline, fim_na_timeline, + inicio_na_origem, fim_na_origem, arquivo, offline + FROM clipes + WHERE video_id = ? + ORDER BY inicio_na_timeline, id""", + (identificador_do_video,), + ).fetchall() + ] + planos = [ + dict(linha) for linha in self.conexao.execute( + """SELECT p.id, p.origem, p.tipo_de_video, p.modelo_da_ia, + p.intencao, p.criado_em, + COUNT(a.id) AS total_de_acoes, + (SELECT COUNT(*) FROM aplicacoes_do_plano ap + WHERE ap.plano_id = p.id AND ap.sucesso = 1) + AS aplicacoes_com_sucesso + FROM planos_de_edicao p + LEFT JOIN acoes_do_plano a ON a.plano_id = p.id + WHERE p.video_id = ? + GROUP BY p.id + ORDER BY p.criado_em DESC, p.id DESC""", + (identificador_do_video,), + ).fetchall() + ] + return { + "edicao": { + "id": edicao["id"], + "video_id": identificador_do_video, + "sequencia": edicao["sequencia"], + "origem": edicao["origem"], + "tipo_de_video": edicao["tipo_de_video"], + "configuracao": configuracao, + "concluida_em": edicao["concluida_em"], + }, + "video": self.consultar_resumo_do_video(identificador_do_video), + "analise": self.consultar_status_da_analise(identificador_do_video), + "clipes": clipes, + "falas": self.listar_falas_no_intervalo( + identificador_do_video, incluir_palavras=incluir_palavras, + ), + "planos": planos, + } + def listar_intervalos_de_preview_dos_falantes(self, video_id: str) -> list[dict]: """ Lista os intervalos dos falantes convertidos para o arquivo de vídeo. diff --git a/code/engine/preparar_edicao_por_voz.py b/code/engine/preparar_edicao_por_voz.py new file mode 100644 index 0000000..c895ed2 --- /dev/null +++ b/code/engine/preparar_edicao_por_voz.py @@ -0,0 +1,76 @@ +"""Prepara o contexto da edição por voz a partir do banco SQLite.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +import sys + +CAMINHO_DO_CODIGO = Path(__file__).resolve().parent.parent +if str(CAMINHO_DO_CODIGO) not in sys.path: + sys.path.insert(0, str(CAMINHO_DO_CODIGO)) + +from engine.persistencia.consultas import ConsultasDeAnalises +from engine.persistencia.conexao import abrir_banco + + +def preparar_contexto( + caminho_do_banco: Path, + video_id: str | None = None, + incluir_palavras: bool = True, +) -> dict | None: + """ + Carrega a edição mais recente e seu contexto editorial. + + Parâmetros: + caminho_do_banco: Caminho do banco SQLite de análises. + video_id: Vídeo específico; quando omitido, usa a edição mais recente. + incluir_palavras: Inclui os limites das palavras da transcrição. + + Retorna: + Contexto serializável para o agente de edição, ou None quando não há + edição concluída compatível. + """ + conexao = abrir_banco(caminho_do_banco) + try: + return ConsultasDeAnalises(conexao).consultar_contexto_da_edicao( + video_id=video_id, + incluir_palavras=incluir_palavras, + ) + finally: + conexao.close() + + +def principal() -> None: + """Imprime o contexto editorial em JSON para consumo do agente.""" + argumentos = argparse.ArgumentParser( + description="Carrega a edição por voz mais recente do banco SQLite." + ) + argumentos.add_argument( + "--banco", type=Path, default=Path(".jhonny/analises.db"), + help="Caminho do banco SQLite de análises.", + ) + argumentos.add_argument("--video-id", help="Restringe a consulta a um vídeo.") + argumentos.add_argument( + "--sem-palavras", action="store_true", + help="Omite os intervalos palavra a palavra para reduzir a resposta.", + ) + opcoes = argumentos.parse_args() + try: + contexto = preparar_contexto( + opcoes.banco, + video_id=opcoes.video_id, + incluir_palavras=not opcoes.sem_palavras, + ) + if contexto is None: + print(json.dumps({"ok": False, "erro": "Nenhuma edição concluída encontrada."}, ensure_ascii=False)) + raise SystemExit(1) + print(json.dumps({"ok": True, "contexto": contexto}, ensure_ascii=False)) + except (OSError, ValueError) as erro: + print(json.dumps({"ok": False, "erro": str(erro)}, ensure_ascii=False)) + raise SystemExit(1) from erro + + +if __name__ == "__main__": + principal() diff --git a/code/engine/registrar_plano_de_edicao.py b/code/engine/registrar_plano_de_edicao.py new file mode 100644 index 0000000..5a52b1c --- /dev/null +++ b/code/engine/registrar_plano_de_edicao.py @@ -0,0 +1,77 @@ +"""Entrada de linha de comando para salvar um plano de edição no SQLite.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +import sys + +CAMINHO_DO_CODIGO = Path(__file__).resolve().parent.parent +if str(CAMINHO_DO_CODIGO) not in sys.path: + sys.path.insert(0, str(CAMINHO_DO_CODIGO)) + +from engine.persistencia.conexao import abrir_banco +from engine.persistencia.repositorio_de_planos import RepositorioDePlanos, plano_de_dict + + +def registrar( + caminho_do_plano: Path, + caminho_do_banco: Path, + video_id: str | None = None, + tipo_de_video: str | None = None, + modelo_da_ia: str | None = None, + intencao: str | None = None, +) -> int: + """Lê o JSON devolvido pela IA e registra o plano associado à edição mais recente.""" + dados = json.loads(caminho_do_plano.read_text(encoding="utf-8")) + conexao = abrir_banco(caminho_do_banco) + try: + if video_id is None: + origem = dados.get("source") + linha = conexao.execute( + """SELECT video_id FROM edicoes_de_video + WHERE origem = ? OR sequencia = ? + ORDER BY concluida_em DESC, id DESC LIMIT 1""", + (origem, origem), + ).fetchone() + video_id = linha["video_id"] if linha is not None else None + plano = plano_de_dict( + dados, + video_id=video_id, + tipo_de_video=tipo_de_video, + modelo_da_ia=modelo_da_ia, + intencao=intencao, + ) + return RepositorioDePlanos(conexao).registrar_plano(plano) + finally: + conexao.close() + + +def principal() -> None: + """Executa o registro do plano pela linha de comando.""" + argumentos = argparse.ArgumentParser() + argumentos.add_argument("plano", type=Path) + argumentos.add_argument("--banco", type=Path, default=Path(".jhonny/analises.db")) + argumentos.add_argument("--video-id") + argumentos.add_argument("--tipo-de-video") + argumentos.add_argument("--modelo-da-ia") + argumentos.add_argument("--intencao") + opcoes = argumentos.parse_args() + try: + identificador = registrar( + opcoes.plano, + opcoes.banco, + video_id=opcoes.video_id, + tipo_de_video=opcoes.tipo_de_video, + modelo_da_ia=opcoes.modelo_da_ia, + intencao=opcoes.intencao, + ) + print(json.dumps({"ok": True, "plano_id": identificador}, ensure_ascii=False)) + except (OSError, ValueError) as erro: + print(json.dumps({"ok": False, "erro": str(erro)}, ensure_ascii=False)) + raise SystemExit(1) from erro + + +if __name__ == "__main__": + principal() diff --git a/code/engine/requirements-audio.txt b/code/engine/requirements-audio.txt index 7a7a124..0479e0f 100644 --- a/code/engine/requirements-audio.txt +++ b/code/engine/requirements-audio.txt @@ -2,3 +2,5 @@ # a diarização e a detecção de emoção (integracoes/huggingface/provider_de_diarizacao_local.py # e provider_de_emocao_local.py importam soundfile para ler o áudio extraído). soundfile +# Dependência opcional carregada somente pelo adaptador de análise musical. +essentia diff --git a/code/engine/testes/test_analisador_de_musica_essentia.py b/code/engine/testes/test_analisador_de_musica_essentia.py new file mode 100644 index 0000000..6bd5fb9 --- /dev/null +++ b/code/engine/testes/test_analisador_de_musica_essentia.py @@ -0,0 +1,56 @@ +import tempfile +import unittest +from pathlib import Path + +from engine.integracoes.audio import AnalisadorDeMusicaInstrumentalEssentia + + +class TesteAnalisadorDeMusicaInstrumentalEssentia(unittest.TestCase): + def test_converte_descritores_e_classificacoes(self): + def extrator(_caminho: str): + return { + "metadata": {"audio_properties": {"length": 123.4}}, + "rhythm": {"bpm": 92.0, "danceability": 0.61}, + "tonal": {"key_edma": {"key": "A", "scale": "minor"}}, + "lowlevel": {"average_loudness": 0.35}, + "highlevel": { + "genre": {"value": {"ambient": 0.9, "rock": 0.0}}, + "mood": {"value": ["calm", "melancholic"]}, + }, + }, {} + + with tempfile.TemporaryDirectory() as pasta: + arquivo = Path(pasta) / "instrumental.wav" + arquivo.write_bytes(b"audio") + resultado = AnalisadorDeMusicaInstrumentalEssentia(extrator).analisar(arquivo) + + self.assertEqual(resultado.batidas_por_minuto, 92.0) + self.assertEqual(resultado.tonalidade, "A") + self.assertEqual(resultado.modo, "minor") + self.assertEqual(resultado.generos, ("ambient",)) + self.assertEqual(resultado.humores, ("calm", "melancholic")) + + def test_rejeita_arquivo_inexistente(self): + analisador = AnalisadorDeMusicaInstrumentalEssentia(lambda _: ({}, {})) + with self.assertRaises(FileNotFoundError): + analisador.analisar("/arquivo/inexistente.wav") + + def test_aceita_descritores_com_chaves_pontuadas_do_essentia(self): + def extrator(_caminho: str): + return { + "metadata.audio_properties.length": 10.0, + "rhythm.bpm": 110.0, + "rhythm.danceability": 0.42, + "tonal.key_edma.key": "D", + "tonal.key_edma.scale": "major", + "lowlevel.average_loudness": 0.22, + }, {} + + with tempfile.TemporaryDirectory() as pasta: + arquivo = Path(pasta) / "instrumental.wav" + arquivo.write_bytes(b"audio") + resultado = AnalisadorDeMusicaInstrumentalEssentia(extrator).analisar(arquivo) + + self.assertEqual(resultado.duracao_em_segundos, 10.0) + self.assertEqual(resultado.batidas_por_minuto, 110.0) + self.assertEqual(resultado.tonalidade, "D") diff --git a/code/engine/testes/test_carregar_plano_de_edicao.py b/code/engine/testes/test_carregar_plano_de_edicao.py new file mode 100644 index 0000000..1e31fd8 --- /dev/null +++ b/code/engine/testes/test_carregar_plano_de_edicao.py @@ -0,0 +1,37 @@ +"""Testes do carregamento de planos de edição diretamente do SQLite.""" + +import json +import tempfile +import unittest +from pathlib import Path + +from engine.carregar_plano_de_edicao import carregar +from engine.persistencia import abrir_banco +from engine.persistencia.repositorio_de_planos import RepositorioDePlanos, plano_de_dict + + +class TesteCarregarPlanoDeEdicao(unittest.TestCase): + """Garante que a interface receba o plano no contrato de aplicação.""" + + def test_carrega_plano_mais_recente_do_video(self): + with tempfile.TemporaryDirectory() as pasta: + banco = Path(pasta) / "analises.db" + conexao = abrir_banco(banco) + repositorio = RepositorioDePlanos(conexao) + plano_id = repositorio.registrar_plano(plano_de_dict( + {"source": "Sequência", "actions": [ + {"kind": "cut", "start": 1, "end": 2, "reason": "repetição"}, + ]}, video_id="video-1", tipo_de_video="depoimento", + )) + conexao.close() + + carregado = carregar(banco, video_id="video-1") + + self.assertEqual(carregado["plano_id"], plano_id) + self.assertEqual(carregado["video_id"], "video-1") + self.assertEqual(carregado["actions"][0]["kind"], "cut") + self.assertEqual(carregado["actions"][0]["reason"], "repetição") + + +if __name__ == "__main__": + unittest.main() diff --git a/code/engine/testes/test_consultas_de_persistencia.py b/code/engine/testes/test_consultas_de_persistencia.py index f68fbf1..cbd9eaf 100644 --- a/code/engine/testes/test_consultas_de_persistencia.py +++ b/code/engine/testes/test_consultas_de_persistencia.py @@ -15,7 +15,9 @@ from engine.dominio import Clipe, Faixa, IntervaloDeTempo, Timeline from engine.persistencia import ( ConsultasDeAnalises, ErroDeConsultaInvalida, + EdicaoDeVideo, RepositorioDeAnalisesSQLite, + RepositorioDeEdicoes, RepositorioDeRetakesSQLite, RepositorioDeTimelineSQLite, ) @@ -95,6 +97,24 @@ class TesteConsultasDeAnalises(unittest.TestCase): with self.assertRaises(ErroDeConsultaInvalida): self.consultas.consultar_resumo_do_video("") + def test_consultar_contexto_da_edicao_carrega_edicao_mais_recente_e_falas(self) -> None: + RepositorioDeEdicoes(self.consultas.conexao).registrar(EdicaoDeVideo( + video_id=VIDEO_ID, + sequencia="Sequência", + origem="/videos/entrada.mp4", + tipo_de_video="depoimento", + configuracao={"objetivo": "Gerar confiança"}, + )) + + contexto = self.consultas.consultar_contexto_da_edicao() + + self.assertIsNotNone(contexto) + self.assertEqual(contexto["edicao"]["tipo_de_video"], "depoimento") + self.assertEqual(contexto["edicao"]["configuracao"]["objetivo"], "Gerar confiança") + self.assertEqual(contexto["video"]["video_id"], VIDEO_ID) + self.assertEqual(len(contexto["falas"]), 1) + self.assertEqual(contexto["falas"][0]["texto"], "Hoje nós vamos mostrar como funciona o sistema.") + def test_consultar_status_da_analise_expoe_falantes_e_metricas(self) -> None: status = self.consultas.consultar_status_da_analise(VIDEO_ID) diff --git a/code/engine/testes/test_registrar_plano_de_edicao.py b/code/engine/testes/test_registrar_plano_de_edicao.py new file mode 100644 index 0000000..92ff69b --- /dev/null +++ b/code/engine/testes/test_registrar_plano_de_edicao.py @@ -0,0 +1,43 @@ +"""Testes da entrada de planos de edição no banco SQLite.""" + +import json +import tempfile +import unittest +from pathlib import Path + +from engine.persistencia import EdicaoDeVideo, RepositorioDeEdicoes, abrir_banco +from engine.registrar_plano_de_edicao import registrar + + +class TesteRegistrarPlanoDeEdicao(unittest.TestCase): + """Garante que o plano é associado ao contexto editorial mais recente.""" + + def test_registra_plano_e_descobre_video_pela_origem(self): + with tempfile.TemporaryDirectory() as pasta: + banco = Path(pasta) / "analises.db" + conexao = abrir_banco(banco) + RepositorioDeEdicoes(conexao).registrar(EdicaoDeVideo( + video_id="video-1", sequencia="Depoimento", origem="/v.mp4", + tipo_de_video="depoimento", configuracao={"legendas": True}, + )) + conexao.close() + arquivo = Path(pasta) / "plano.json" + arquivo.write_text(json.dumps({ + "source": "/v.mp4", + "actions": [{"kind": "cut", "start": 1, "end": 2}], + }), encoding="utf-8") + + plano_id = registrar(arquivo, banco, tipo_de_video="depoimento") + + conexao = abrir_banco(banco) + linha = conexao.execute( + "SELECT video_id, tipo_de_video FROM planos_de_edicao WHERE id = ?", + (plano_id,), + ).fetchone() + conexao.close() + + self.assertEqual(dict(linha), {"video_id": "video-1", "tipo_de_video": "depoimento"}) + + +if __name__ == "__main__": + unittest.main() diff --git a/code/plugins/premiere-pro/skills/editar-por-voz/SKILL.md b/code/plugins/premiere-pro/skills/editar-por-voz/SKILL.md new file mode 100644 index 0000000..d4554f6 --- /dev/null +++ b/code/plugins/premiere-pro/skills/editar-por-voz/SKILL.md @@ -0,0 +1,142 @@ +--- +name: editar-por-voz +description: Edita um vídeo a partir das evidências de voz, transcrição e configuração editorial mantidas no banco SQLite do projeto. Use quando o usuário pedir para editar vídeo, editar por voz, escolher tomadas, limpar repetições ou montar um corte automático. +--- + +# Editar por voz + +Transforma uma edição concluída no banco em um plano editorial revisável e, após aprovação, em uma alteração verificável no Premiere Pro. + +## Fonte de verdade + +O banco de análises é a fonte oficial. Não procure nem exija um arquivo +`*_voice_timeline.json` e não trate JSON temporário, cache ou estado do painel +como fonte independente. + +Para carregar o contexto sem escrever SQL, execute: + +```text +python3 code/engine/preparar_edicao_por_voz.py --banco .jhonny/analises.db +``` + +Depois de gerar o JSON de ações, registre-o no banco e use o identificador +retornado para aplicar exatamente esse plano: + +```bash +python3 code/engine/registrar_plano_de_edicao.py plano.json \ + --banco .jhonny/analises.db --tipo-de-video depoimento +python3 code/engine/aplicar_plano_de_edicao.py plano.json \ + --banco .jhonny/analises.db --plano-id ID_RETORNADO +``` + +Use `--video-id` quando o usuário indicar um vídeo específico e +`--sem-palavras` somente quando os limites palavra a palavra não forem +necessários. A resposta válida vem em `contexto`; trate `ok: false` como +bloqueio do fluxo. + +Use a edição concluída mais recente em `edicoes_de_video` como raiz do trabalho. +Recupere o restante pelo mesmo `video_id`: + +- `videos`, `faixas` e `clipes`: mídia, sequência e estrutura; +- `analises_versao`: versão da análise usada; +- `segmentos_de_transcricao` e `palavras_de_transcricao`: falas e tempos; +- `evidencias_visuais`: contexto visual, quando disponível; +- `planos_de_edicao` e `acoes_do_plano`: decisões já geradas; +- `aplicacoes_do_plano`: histórico de execução. + +Leia [criterios/00-fonte-de-dados.md](criterios/00-fonte-de-dados.md) antes de +consultar o banco ou quando houver dúvida sobre qual registro usar. + +## Estados obrigatórios + +Trate o trabalho como uma sequência de estados: + +```text +edicao_concluida +→ contexto_validado +→ plano_gerado +→ preview_aprovado +→ aplicado +→ verificado +``` + +Não aplique um plano sem contexto validado e preview aprovado. Se algum estado +ou evidência obrigatória estiver ausente, pare e relate exatamente o que falta. + +## Fluxo + +1. Localize a edição concluída mais recente e confirme vídeo, sequência, tipo, + origem, transcrição e versão da análise. +2. Reúna do banco a transcrição completa, palavras, falantes, tomadas, + métricas e evidências disponíveis. Preserve os tempos da mídia original. +3. Leia a configuração editorial salva em `edicoes_de_video.configuracao` e + extraia objetivo, narrativa, duração, tom, regras de corte e restrições. +4. Separe roteiro de conversa de bastidor pelo conteúdo. Depois agrupe frases + repetidas e escolha a tomada completa, clara, natural e coerente. +5. Reanalise as ênfases apenas depois de definir o material sobrevivente. Se o + banco não tiver métricas ou a camada correspondente estiver incompleta, + decida pelo texto e registre a limitação. +6. Decida cortes, zooms, textos e marcadores somente quando o contrato de + aplicação suportar a ação. Cada ação deve ter motivo verificável. +7. Gere um plano com tempos na mídia original. Salve o plano em + `planos_de_edicao` e suas ações ordenadas em `acoes_do_plano`, associado ao + `video_id`, ao tipo de vídeo e ao contexto editorial carregado. +8. Faça preview do plano e apresente as decisões e incertezas para revisão + humana. Uma alteração no plano exige novo preview. +9. Após aprovação explícita, confirme a sequência atual, crie backup ou + duplicata, aplique o plano pelo fluxo suportado do Premiere e registre o + resultado em `aplicacoes_do_plano`. +10. Reconsulte a timeline, compare com o plano e registre a verificação. Separe + o que foi comprovado automaticamente do que exige avaliação visual. + +## Contrato da decisão + +O JSON abaixo é uma representação de trabalho, não a fonte principal: + +```json +{ + "source": "0E6A8829.MP4", + "actions": [ + { + "kind": "cut", + "start": 12.4, + "end": 16.8, + "reason": "Repetição da frase anterior." + } + ] +} +``` + +`cut` remove um intervalo. Todos os tempos referem-se à mídia original. A +ação precisa ter `start < end`, estar dentro da duração e não sobrepor outra +ação incompatível. Use `reason` para registrar a fala, comparação ou evidência +que fundamentou a decisão. + +Leia, conforme a etapa, [criterios/02-triagem-roteiro-vs-conversa.md](criterios/02-triagem-roteiro-vs-conversa.md), +[criterios/03-escolha-da-melhor-tomada.md](criterios/03-escolha-da-melhor-tomada.md), +[criterios/04-reanalise-do-material-restante.md](criterios/04-reanalise-do-material-restante.md), +[criterios/05-zoom.md](criterios/05-zoom.md), +[criterios/06-texto-corte-marcador.md](criterios/06-texto-corte-marcador.md), +[criterios/07-ritmo.md](criterios/07-ritmo.md) e +[criterios/10-revisao-humana.md](criterios/10-revisao-humana.md). + +## Limites + +- A skill decide a edição; o Premiere executa. +- Nunca invente falas, identidades, timecodes ou evidências. +- Não altere o áudio original, a transcrição ou a análise para forçar uma + decisão. +- Não declare uma edição concluída com base apenas em um JSON, preview ou + retorno de ferramenta; exija aplicação e verificação. +- Não execute cortes destrutivos sem aprovação e backup/duplicata. + +## Relato + +Relate sempre em português: + +- edição, vídeo e versão da análise usados; +- tomadas encontradas e escolhidas, com motivo; +- falas descartadas como bastidor ou repetição; +- cortes, zooms, textos e marcadores propostos; +- decisões rejeitadas ou ambíguas; +- plano salvo, aplicação realizada e verificação pendente. diff --git a/code/plugins/premiere-pro/skills/editar-por-voz/criterios/00-fonte-de-dados.md b/code/plugins/premiere-pro/skills/editar-por-voz/criterios/00-fonte-de-dados.md new file mode 100644 index 0000000..4be2eed --- /dev/null +++ b/code/plugins/premiere-pro/skills/editar-por-voz/criterios/00-fonte-de-dados.md @@ -0,0 +1,35 @@ +# 00 — Fonte de dados: banco de análises + +O banco SQLite do projeto é a fonte oficial da edição por voz. Arquivos JSON +podem existir como origem, cache ou exportação, mas não substituem os registros +persistidos. + +## Raiz do trabalho + +1. Localize a edição mais recente em `edicoes_de_video`. +2. Use seu `video_id` para consultar a análise, a transcrição, os clipes e as + evidências. +3. Use `edicoes_de_video.configuracao` como briefing editorial salvo. +4. Considere a edição duplicada ou antiga somente se o usuário escolher uma + edição diferente. + +## Validações mínimas + +Antes de decidir cortes, confirme que existem: + +- vídeo e sequência identificáveis; +- pelo menos um clipe com mídia e duração; +- segmentos de transcrição com início, fim e texto; +- configuração editorial com tipo e objetivo; +- versão ou data que permita identificar a análise usada. + +Falantes, palavras, métricas e evidências visuais são camadas adicionais. Se +uma camada não existir, reduza a confiança da decisão e registre a limitação; +não fabrique valores. + +## Persistência do plano + +O plano deve ser criado em `planos_de_edicao`, e cada ação em +`acoes_do_plano`. A aplicação deve ser registrada em `aplicacoes_do_plano`. +O JSON pode ser usado durante a revisão, mas o banco deve conservar a decisão +que será executada. diff --git a/code/plugins/premiere-pro/skills/editar-por-voz/criterios/01-leitura-do-json.md b/code/plugins/premiere-pro/skills/editar-por-voz/criterios/01-leitura-do-json.md new file mode 100644 index 0000000..633e8a4 --- /dev/null +++ b/code/plugins/premiere-pro/skills/editar-por-voz/criterios/01-leitura-do-json.md @@ -0,0 +1,78 @@ +# 01 — Leitura das evidências do banco + +> **Escopo:** Como ler as evidências persistidas no banco, sem recalcular o que já foi medido. +> **Quando:** Fase 1 — ver a ordem de trabalho em `../SKILL.md`. + +O registro da edição e as tabelas relacionadas no banco são a entrada de todo o +trabalho. Um JSON exportado pode ser usado como visão de trabalho, mas deve ser +reconciliado com o `video_id` e a versão da análise antes de qualquer decisão. +Leia em camadas, de cima para baixo, e só desça quando precisar. + +## Camadas + +| Camada | O que traz | Para quê | +|---|---|---| +| `analises_versao` | o que de fato rodou e qual versão está válida | **leia primeiro** — ver `09-analise-incompleta.md` | +| `summary` | forma da peça, `peak_moments`, contagens | visão geral em poucos números | +| `segmentos_de_transcricao` | cada fala com seus agregados | **onde você mais trabalha** | +| `palavras_de_transcricao` | detalhe por palavra | achar o instante exato de um destaque | +| `evidencias_visuais` | observações visuais por intervalo | apoiar a decisão quando disponível | +| `edicoes_de_video.configuracao` | objetivo e regras do vídeo | calibrar a seleção editorial | + +## Campos que decidem quase tudo + +**`gap_before`** — silêncio antes da fala, em segundos. É o mapa estrutural +da gravação: acima de ~3s (`take_boundary: true`) a câmera parou ou a +tomada recomeçou. Num material real de 3min17s isso identificou 6 +fronteiras, todas exatamente onde a pessoa recomeçava o roteiro. + +**`take_boundary`** — booleano derivado do `gap_before`. Use para agrupar +tomadas. + +**`emphasis`** (0–1) — índice combinado de energia, variação de tom, +variação de ritmo, pausa anterior e duração. **É relativo ao material +analisado**, nunca uma medida absoluta. Ver `02-enfase-e-reanalise.md`. + +**`energy`** (0–1) — intensidade relativa ao trecho mais alto da gravação. + +**`pitch_delta`** (0–1) — quanto o tom se afasta da média do falante. + +**`peak_emphasis`** e **`avg_energy`** (por segmento) — permitem julgar uma +frase inteira sem ler palavra por palavra. É por aqui que você avalia o +arco narrativo. + +**`energy_raw`** e **`pitch_hz`** — valores brutos, sem normalização. Não +use para decidir; existem para permitir a reanálise da Fase 2. + +## O que NÃO fazer + +- **Não recalcule** energia, tom ou ênfase. O sistema mede melhor e de + forma reprodutível. +- **Não reestime tempos "no olho".** Use os timestamps do JSON. +- **Não trate `emphasis` como valor absoluto.** Um 0,35 pode ser o pico de + uma gravação e ruído em outra. + +## O timestamp por palavra tem um viés conhecido + +O início de cada palavra vem sistematicamente **adiantado em ~0,3-0,5s** em +relação ao ataque real da fala — medido em material real com ffmpeg (`astats`), +consistente em 6 pontos do mesmo vídeo. O fim da palavra não tem esse problema +(erro de poucos centésimos). Causa: `word_timestamps` do faster-whisper deriva +por atenção cruzada, sem alinhamento forçado — ver `05_EXPERIENCIAS.md`, entrada +de 2026-08-19. + +**Quando o pipeline já corrigiu isso:** se `layers.alignment` for `true` +(transcript gerado com alinhamento forçado fonético via whisperx, implementado +depois desse aviso), o viés foi removido na origem — **não aplique o offset +manual** abaixo. O aviso vale só para transcripts antigos sem `layers.alignment`. + +Isso não é "reestimar no olho" — é um bug de medição na fonte, não um +julgamento seu. Na prática (somente sem `layers.alignment`): + +- Ao posicionar um `zoom` cujo `start` precisa cair exatamente na palavra + (não uma frase inteira), some **+0,3 a +0,4s** ao timestamp do JSON antes + de decidir, ou confira com `ffmpeg -af astats` se a precisão importar + para o frame. +- **Não aplique essa correção a `gap_before` para decidir corte** — a régua + de silêncio (`06-texto-corte-marcador.md`) já é conservadora o bastante + para absorver esse erro; corrigir os dois ao mesmo tempo é redundante. diff --git a/code/plugins/premiere-pro/skills/editar-por-voz/criterios/02-triagem-roteiro-vs-conversa.md b/code/plugins/premiere-pro/skills/editar-por-voz/criterios/02-triagem-roteiro-vs-conversa.md new file mode 100644 index 0000000..f01cb6a --- /dev/null +++ b/code/plugins/premiere-pro/skills/editar-por-voz/criterios/02-triagem-roteiro-vs-conversa.md @@ -0,0 +1,54 @@ +# 02 — Triagem: roteiro vs. conversa de bastidor + +> **Escopo:** Separar o texto do roteiro da conversa de bastidor — tarefa de texto, nunca de limiar. +> **Quando:** Fase 2 — ver a ordem de trabalho em `../SKILL.md`. + +**Primeira coisa a fazer, antes de qualquer decisão de efeito.** + +Material bruto de gravação quase nunca é uma tomada só. A pessoa lê o +roteiro, erra, conversa com a equipe e recomeça. + +## Por que isso é tarefa sua, e não do sistema + +No áudio essa separação é **invisível** — e pior: o índice de ênfase +*favorece* a conversa, que é mais solta e mais alta que o texto decorado. + +Caso real: a fala mais enfática de um vídeo inteiro (energia **1,00**, o +topo absoluto da gravação) era *"Amor, eu tô intacto!"*, dita para o marido +fora de quadro. Três das sete palavras de maior ênfase do vídeo vinham +dessa única frase de bastidor. + +Nenhum limiar acústico separa isso. O **texto** separa sem erro. + +> Atenção: isso também **não é diarização**. Num caso real, a pessoa da +> equipe estava fora do microfone — a diarização a ouvia, mas o Whisper não +> a transcrevia. As falas a descartar eram da **própria protagonista**: +> mesma voz, contexto diferente. "Quem fala" e "isso é tomada válida" são +> perguntas diferentes. + +## Descartar — conversa com a equipe + +Reconhece-se pelo **conteúdo**: + +- **vocativo para alguém da sala** — *"Amor, eu tô intacto!"* +- **pergunta operacional** — *"Posso começar da mastopexia?"*, + *"E aí, continua?"*, *"Mas eu vou ter que falar tudo de novo?"* +- **instrução técnica** — *"Só clica aí agora na tela."*, *"Aumenta."* +- **comentário sobre a própria gravação** — *"Vou falar só a última frase, + só um pouquinho, não pegou?"* + +## Descartar — frases interrompidas + +Texto que morre no meio, tipicamente em reticências ou emendando numa +pergunta: + +- *"E tudo isso associado à medida..."* +- *"Aquela mama com um formato mais estruturado, com o colo que..."* +- *"de pele..."* + +## Sinais estruturais que ajudam + +Use `take_boundary` para achar onde cada tomada recomeça. Num material +real, as fronteiras (gaps de 3,6s a 19,8s) caíam exatamente nos pontos onde +a médica reiniciava o roteiro — inclusive nas duas retomadas da frase de +abertura. diff --git a/code/plugins/premiere-pro/skills/editar-por-voz/criterios/03-escolha-da-melhor-tomada.md b/code/plugins/premiere-pro/skills/editar-por-voz/criterios/03-escolha-da-melhor-tomada.md new file mode 100644 index 0000000..8bc5614 --- /dev/null +++ b/code/plugins/premiere-pro/skills/editar-por-voz/criterios/03-escolha-da-melhor-tomada.md @@ -0,0 +1,63 @@ +# 03 — Escolha da melhor tomada + +> **Escopo:** Qual tomada de cada frase sobrevive, e o que fazer em caso de empate. +> **Quando:** Fase 3 — ver a ordem de trabalho em `../SKILL.md`. + +A mesma frase costuma aparecer 2, 3, 4 vezes. Seu trabalho é ficar com +**uma**. + +## Como agrupar + +1. Use `take_boundary` para localizar onde cada tomada recomeça. +2. Agrupe as repetições **pelo texto**, não pelo tempo — a mesma frase + reaparece em pontos distantes da gravação. Num caso real, a abertura + *"Aquela mama com um formato mais estruturado"* apareceu aos 2,0s, 64,9s + e 86,4s. + +## Critérios, nesta ordem + +### 1. Completa +Não morre no meio, não emenda numa pergunta. Uma tomada incompleta está +descartada por definição, mesmo que a dicção seja ótima. + +### 2. Dicção limpa +Sem tropeço, sem repetição de palavra, sem vício de linguagem. **Compare os +textos lado a lado:** + +| Tomada 1 | Tomada 3 | Escolha | +|---|---|---| +| *"isso é desejo de muitas mulheres"* | *"**aí** isso é desejo de muitas mulheres"* | Tomada 1 | + +### 3. Formulação melhor +Quando as duas estão limpas, prefira a mais direta — normalmente a última, +porque é onde a pessoa já se ajustou: + +| Antes | Depois | Escolha | +|---|---|---| +| *"a gente **faz a inserção de** próteses"* | *"a gente **insere** próteses"* | a segunda | +| *"reestrutura a mama"* | *"reestrutura a **sua** mama"* | a segunda | + +### 4. Entrega +**Só então** desempate por `avg_energy` / `peak_emphasis`. + +Quando duas tomadas têm texto **idêntico palavra por palavra**, aí a +energia decide sozinha — é o único sinal disponível. Caso real: o fecho +tinha duas tomadas iguais, energia **0,38** e **0,19**. A de 0,38 é a boa, +e o texto sozinho jamais diria isso. + +## Regra de ouro + +A **última** tomada costuma ser a melhor — é onde a pessoa acertou. Mas +**confirme lendo o texto**; nunca assuma. + +## Quando estiver em dúvida + +Não decida no escuro. Coloque um `marker` nas duas candidatas, explique a +dúvida no `reason`, e deixe a escolha para o editor humano. + +## Continuidade + +Ao montar o corte final você pode misturar blocos de tomadas diferentes — +abertura da tomada 1, corpo da tomada 3. Isso é normal. Mas **avise nas +emendas**: coloque um `marker` em cada junção para o editor conferir se o +enquadramento e a posição da pessoa combinam. diff --git a/code/plugins/premiere-pro/skills/editar-por-voz/criterios/04-reanalise-do-material-restante.md b/code/plugins/premiere-pro/skills/editar-por-voz/criterios/04-reanalise-do-material-restante.md new file mode 100644 index 0000000..e25513f --- /dev/null +++ b/code/plugins/premiere-pro/skills/editar-por-voz/criterios/04-reanalise-do-material-restante.md @@ -0,0 +1,76 @@ +# 04 — Reanálise do material que sobrou + +> **Escopo:** Renormalizar a ênfase sobre o que sobrou, antes de escolher zooms. +> **Quando:** Fase 4 — ver a ordem de trabalho em `../SKILL.md`. + +**Não escolha zooms com os números da análise bruta.** + +## O problema + +Ênfase e energia são **relativas ao conjunto analisado**. Energia é +normalizada contra o momento mais alto da gravação; ênfase deriva dela. + +Se esse momento mais alto foi cortado — uma piada, um grito, uma conversa +de bastidor — tudo que sobrou continua pontuado contra uma referência que o +espectador **nunca verá**. As notas do corte final ficam artificialmente +comprimidas, e o ranking aponta para as palavras erradas. + +Caso real: o pico do vídeo era *"Amor, eu tô intacto!"* (energia 1,00), +descartado na triagem. Todo o material restante estava sendo medido contra +ele. + +## A solução + +Depois de definir os cortes, renormalize sobre os sobreviventes. Solicite uma +reanálise dos dados persistidos, passando a mídia e a lista de cortes que você +já decidiu. Se essa operação não estiver disponível, recalcule somente a +normalização necessária por um adaptador controlado e registre a nova versão +em `analises_versao`: + +``` +refinar_linha_de_voz(media_path, cortes=[{start, end}, ...], min_gap=8.0) +``` + +Ela devolve, numa chamada só, a comparação bruto × sobreviventes, os picos +re-ranqueados e os candidatos a zoom. É barata: renormaliza os números já +medidos, sem reabrir o áudio. + +Efeito medido no mesmo material: + +| | Bruto | Só o que sobrou | +|---|---|---| +| Ênfase média | 0,179 | **0,197** | +| *"Aquela"* | 0,39 | **0,42** | +| *"mastopexia"* | 0,26 | **0,34** | +| *"devolver"* | — | **0,35** | + +*"mastopexia"* só virou candidata legítima depois da reanálise. + +## As janelas que ela propõe + +A seção **Zoom Candidates** da resposta já vem com três coisas resolvidas: + +1. **Pega a palavra de conteúdo mais enfática de cada frase.** Artigos e + conectivos são filtrados — um *"a"* falado alto continua sendo um artigo. + Sem esse filtro, o ranking bruto apontava para "o", "a", "eu": picos de + *entrega*, não de *sentido*. +2. **Estende a janela até o fim da frase**, não do segmento (ver + `05-zoom.md`). +3. **Mantém distância mínima** entre zooms. + +São **candidatos, não obrigações.** Corte a lista pelo ritmo +(`07-ritmo.md`). Os tempos continuam na mídia original — vão para as ações +persistidas no plano e, depois da aprovação, para o adaptador de aplicação do +Premiere. + +`max_zooms` limita a lista, mas prefira cortá-la você mesmo: o corte por +ritmo é decisão editorial, não um teto numérico. + +## Princípio geral + +> "Qual o momento mais forte da **gravação**?" e "qual o momento mais forte +> do **vídeo final**?" são perguntas diferentes sempre que a métrica for +> relativa. + +Toda métrica normalizada precisa ser recalculada quando o conjunto muda — +senão ela responde a pergunta errada, silenciosamente. diff --git a/code/plugins/premiere-pro/skills/editar-por-voz/criterios/05-zoom.md b/code/plugins/premiere-pro/skills/editar-por-voz/criterios/05-zoom.md new file mode 100644 index 0000000..f6484b5 --- /dev/null +++ b/code/plugins/premiere-pro/skills/editar-por-voz/criterios/05-zoom.md @@ -0,0 +1,84 @@ +# 05 — Zoom (punch-in) + +> **Escopo:** Onde dar punch-in, qual janela e qual escala — e o que a escala significa além do zoom. +> **Quando:** Fase 5 — ver a ordem de trabalho em `../SKILL.md`. + +## Quando usar + +No momento em que o argumento vira. Um pico acústico só merece zoom se for +também um pico **de sentido**. + +Palavra gritada sem peso narrativo não ganha nada — e isso inclui os picos +que caem em artigos e conectivos, que são picos de entrega, não de conteúdo. + +## A janela + +**`start`** — na palavra de ênfase. + +**`end`** — no **fim da frase**. A frase inteira, não o fim do segmento da +transcrição. + +O Whisper corta frases no meio, por respiração e não por gramática: + +> *"Aquela mama com um formato mais estruturado, que valoriza o seu colo, +> que dá aquele ar"* **|** *"de elegância, isso é desejo de muitas mulheres, +> né?"* + +Soltar o zoom no fim do primeiro segmento libera **no meio do pensamento** — +é o que faz um punch-in parecer arbitrário. `suggest_zoom_windows` já +estende até a pontuação final (`.` `!` `?` `…`), e nunca atravessa uma +fronteira de tomada. + +## A forma — o programa decide sozinho + +Você escolhe `start` e `end`; a forma sai da posição da janela dentro do +trecho: + +| Situação | Comportamento | Por quê | +|---|---|---| +| Começa a **>0,5s** do início do trecho | entrada rápida (~0,25s) | o movimento chega junto com a palavra | +| Começa a **≤0,5s** do início | **entra já ampliado, sem transição** | o corte já foi a transição; uma rampa ali lê como a imagem se acomodando | +| Frase termina no meio do trecho | **saída seca**, 1 frame | volta ao enquadramento sem chamar atenção | +| Frase termina a **≤1s** do corte | **não volta** — segura até o corte | o próximo trecho já abre no enquadramento dele; voltar antes é movimento desperdiçado | + +Os limiares são diferentes de propósito: no fim o corte esconde um retorno +inacabado, mas no início a rampa é visível desde o primeiro frame. + +Para forçar manualmente, existem `start_at_peak` e `hold_at_end` — mas o +automático acerta na quase totalidade dos casos. + +## Escala + +| Valor | Uso | Vira, na tela de revisão | +|---|---|---| +| 1,15 | sutil | ênfase **1 — Leve** | +| 1,18 – 1,3 | padrão | ênfase **2 — Média** | +| 1,5 | forte | ênfase **3 — Forte** | + +Em vídeo institucional, fique na faixa baixa. Acima de 3,0 é rejeitado. + +**A escala tem um segundo efeito, e ele é maior que o zoom.** A frase que +recebe um zoom é marcada como **ênfase** na etapa 5, e frase de ênfase recebe +**legenda dinâmica**; as demais ficam com legenda comum. Ou seja: escolher onde +dar zoom é também escolher onde o texto ganha tratamento tipográfico. + +Consequência prática: **não espalhe zoom "por segurança"**. Cada um promove uma +frase a destaque em duas dimensões ao mesmo tempo. Na dúvida, deixe sem — o +editor promove numa tecla, e despromover custa mais que promover. +Detalhe: `10-revisao-humana.md`. + +O zoom é **relativo ao enquadramento existente**: se o clipe já tem escala +1,77 (material gravado de lado e reenquadrado), um zoom 1,18 anima de 1,77 +para 2,09 e preserva rotação e posição. + +## Dois zooms no mesmo clipe + +Depois do corte, dois picos que você escolheu podem cair no **mesmo** +trecho sobrevivente (nenhum corte os separou em clipes distintos) — é +comum quando a corrida limpa de uma tomada é longa. O sistema resolve isso +sozinho, e a regra é a mesma que rege o resto: janelas **distantes** +empilham (os dois zooms convivem, cada um voltando ao enquadramento real +entre um e outro); janelas que **se sobrepõem** substituem (é o mesmo +evento sendo reajustado, não dois). Você não precisa calcular isso na +hora de decidir — só respeitar o `min_gap` de `07-ritmo.md`, que já +garante que dois zooms escolhidos por você nunca se sobrepõem. diff --git a/code/plugins/premiere-pro/skills/editar-por-voz/criterios/06-texto-corte-marcador.md b/code/plugins/premiere-pro/skills/editar-por-voz/criterios/06-texto-corte-marcador.md new file mode 100644 index 0000000..7e14ed8 --- /dev/null +++ b/code/plugins/premiere-pro/skills/editar-por-voz/criterios/06-texto-corte-marcador.md @@ -0,0 +1,127 @@ +# 06 — Texto, corte e marcador + +> **Escopo:** Texto na tela, o que cortar (inclui muletas e lacunas) e quando marcar. +> **Quando:** Fase 6 — ver a ordem de trabalho em `../SKILL.md`. + +## Texto + +Para fixar um **conceito, número ou nome** que o espectador precisa reter. + +- Use a palavra **dita**, não uma paráfrase. +- Curta, em caixa alta. Até 120 caracteres (é truncado além disso). +- Uma por frase, no máximo. + +**Não legende a frase inteira.** Para isso existe +`generate_dynamic_subtitles`, que é outra ferramenta e outro propósito. + +Boas candidatas são as palavras-chave que sobram depois de filtrar as +funcionais — num caso real: *mastopexia*, *flacidez*, *próteses*, +*devolver*, *desejo*. + +## Corte + +Digressão, repetição, frase abandonada, conversa de bastidor, tomada pior — +e mais duas coisas que **são** seu trabalho, ao contrário do que parece. + +### Vícios de linguagem entram na sua lista + +Não delegue para `remove_filler_words`. Você já está percorrendo palavra por +palavra na triagem; marcar as muletas é uma linha a mais, sem custo. E você +tem o que a lista fixa não tem: **contexto**. + +Um *"tipo"* em *"tipo assim, sabe"* é muleta. Em *"esse tipo de cirurgia"* +é a palavra principal. Um *"não não não"* pode ser gagueira ou ênfase. A +lista fixa não distingue; você distingue. + +### Lacunas longas entram na sua lista — curtas, nunca + +**O tamanho da lacuna muda o que ela é.** A régua está medida em material +real (`pause_weight()` em `emphasis.py`, `TAKE_BOUNDARY_GAP` em +nas regras de análise de voz do projeto): + +| `pause_before` | O que é | O que fazer | +|---|---|---| +| até ~1,5s | o falante montando a frase — **isso É a ênfase** | **nunca cortar** | +| 1,5–3s | zona cinza | julgue pela frase | +| acima de 3s | troca de tomada, ar morto, outra pessoa falando | **cortar** | + +Cortar a pausa curta é o erro grave: ela é uma das cinco entradas do índice +de ênfase, então você estaria apagando justamente a batida que faz a palavra +seguinte pontuar alto. Uma frase fluida não se aperta. + +Acima de 3s a pausa deixa de contar como ênfase por construção — medido em +material real, lacunas de 6–9s rankeavam como os momentos mais enfáticos da +gravação só porque a escala saturava. + +### Nunca corte rente à palavra — deixe uma folga + +Um `cut` cujo `start`/`end` cai exatamente no timestamp da palavra (fim da +última palavra mantida = início do corte) produz um corte seco: a palavra é +engolida antes de terminar de soar, e a fala seguinte começa sem nenhum ar. +Isso é diferente de cortar a pausa curta (que seria apagar a própria ênfase, +proibido acima) — aqui a pausa **já existe** entre o fim de um bloco mantido +e o início do próximo, e o corte está comendo justamente essa margem. + +Ao escrever a borda de um `cut` que encosta em fala mantida (não em silêncio +puro), recue **~0,15–0,25s** para dentro do próprio corte, nos dois lados: + +- o `start` do corte fica ~0,2s **depois** do fim real da última palavra + mantida; +- o `end` do corte fica ~0,2s **antes** do início real da próxima palavra + mantida. + +Caso real (projeto Mastopexia): um corte escrito rente (`10.77 → 95.50`, +exatamente nos timestamps de palavra) soava abrupto nas duas emendas. +Recuado para `10.97 → 95.30`, cada lado ganhou ~0,2s de respiro sem alterar +o que é dito — e não empurra o próximo zoom/marcador contra a borda do corte +(ver `05-zoom.md` sobre janelas encostadas em corte). + +Isso vale também para o **início e o fim do vídeo**: ar morto antes da +primeira palavra e depois da última também leva `cut`, com a mesma folga — +não é "silêncio dentro da fala" (isso é `remove_media_silence`), é o mesmo +corte de tomada/bastidor que você já está decidindo. + +### O que continua NÃO sendo seu trabalho + +| Tarefa | Ferramenta | Por quê | +|---|---|---| +| Apertar o ar **entre** palavras (sem fala, com ou sem som) | `remove_speech_gaps` | Lê `words[].start/end` da transcrição — sabe onde não tem fala mesmo quando tem som (respiração, ruído) | +| Apertar o ar **dentro** da fala | `remove_media_silence` | Lê o áudio real com ffmpeg; só cobre silêncio técnico (dB), que a transcrição não enxerga | + +E cuidado: **ausência de fala não é ausência de som**, e nem sempre é +descartável. Respiração, riso, suspiro, a reação depois da frase — nada +disso vira palavra, então aparece como lacuna, e às vezes é o melhor frame +do vídeo. `remove_speech_gaps` corta **toda** lacuna acima do `min_gap` +(0,6s por padrão) sem julgar o que tem nela — é automação de "sem fala", +não de "sem conteúdo que vale manter". Se uma reação específica precisa +sobreviver, marque-a como `cut` de duração zero antes (para virar um limite +de segmento) ou rode com `min_gap` maior nesse trecho; não é a ferramenta +que decide o que é bom frame. + +Tanto `remove_speech_gaps` quanto `remove_media_silence` rodam **depois** da +aplicação do plano, como acabamento sobre o material que sobrou — +`remove_speech_gaps` primeiro (cobre mais, é o corte "grosso" por fala), +`remove_media_silence` depois (aperta o que ainda restar dentro da fala). + +**Antes de rodar `remove_media_silence` sobre o corte final, sempre rode a +detecção primeiro** (sem aplicar) e leia os spans um a um contra a régua +acima. O detector corta por limiar de dB — ele não sabe distinguir "batida +de 0,8s entre duas frases", que a régua protege, de "ar morto de emenda", +que deveria ser apertado. Aplicar direto, sem essa checagem, é o mesmo erro +de cortar pausa curta, só que por outra ferramenta. + +## Marcador + +Quando você quer **sinalizar para o editor humano decidir**, em vez de +decidir por ele. + +Use em: + +- **Emendas entre tomadas** — sempre. O editor precisa conferir se o + enquadramento e a posição da pessoa combinam na junção. +- **Dúvida entre duas tomadas** — marque as duas, explique no `reason`. +- **Momentos que talvez mereçam efeito** mas que você não tem confiança + para decidir. + +Marcador é um **ponto**, não um trecho: sobrevive mesmo encostado na borda +de um corte, o que é justamente o caso das emendas. diff --git a/code/plugins/premiere-pro/skills/editar-por-voz/criterios/07-ritmo.md b/code/plugins/premiere-pro/skills/editar-por-voz/criterios/07-ritmo.md new file mode 100644 index 0000000..ec6190b --- /dev/null +++ b/code/plugins/premiere-pro/skills/editar-por-voz/criterios/07-ritmo.md @@ -0,0 +1,40 @@ +# 07 — Ritmo + +> **Escopo:** Quantos efeitos cabem: os tetos e como escolher o que fica. +> **Quando:** Fase 7 — ver a ordem de trabalho em `../SKILL.md`. + +**O erro mais comum é efeito demais.** Cansa mais que efeito de menos, e +denuncia edição automática. + +## Limites + +| Regra | Valor | +|---|---| +| Distância mínima entre dois zooms | **8–10 segundos** | +| Zooms por minuto de vídeo | **2 a 4** (teto) | +| Zoom + texto no mesmo instante | só com motivo claro | + +Se dois picos estiverem colados, **escolha o mais forte e abra mão do +outro**. Não tente encaixar os dois. + +## Candidatos ≠ obrigações + +`suggest_zoom_windows` devolve uma lista de candidatos. Normalmente você usa +uma **fração** dela. + +Caso real: num corte de 47,6s a ferramenta sugeriu **5** janelas. O certo +foram **3** — 5 violaria o teto de 2–4 por minuto. Ficaram a abertura, o +termo central e o fecho; as duas descartadas eram frases de apoio. + +O mesmo vale para `peak_moments` no `summary`: é lista de candidatos. + +## Como escolher quais manter + +Quando precisar cortar a lista, priorize por **função narrativa**, não por +nota: + +1. **A abertura** — prende o espectador. +2. **O conceito central** — o termo que o vídeo existe para explicar. +3. **O fecho** — a frase que fica. + +Só depois disso, as frases de apoio, por ordem de ênfase. diff --git a/code/plugins/premiere-pro/skills/editar-por-voz/criterios/08-formato-de-saida.md b/code/plugins/premiere-pro/skills/editar-por-voz/criterios/08-formato-de-saida.md new file mode 100644 index 0000000..57d5b97 --- /dev/null +++ b/code/plugins/premiere-pro/skills/editar-por-voz/criterios/08-formato-de-saida.md @@ -0,0 +1,89 @@ +# 08 — Formato de saída + +> **Escopo:** O JSON de entrega: estrutura, regras e como o programa trata erros. +> **Quando:** Fase 8 — ver a ordem de trabalho em `../SKILL.md`. + +O produto intermediário do seu trabalho é **este JSON**. Ele serve para +revisão e transporte entre componentes; a decisão persistida deve ser salva +em `planos_de_edicao` e `acoes_do_plano`. Você nunca escreve XML. + +## Estrutura + +```json +{ + "source": "0E6A8290.mp4", + "actions": [ + {"kind": "cut", "start": 21.9, "end": 127.6, + "reason": "tomadas descartadas, frases interrompidas e conversa com a equipe"}, + {"kind": "zoom", "start": 2.0, "end": 10.7, + "params": {"scale": 1.15}, "reason": "abertura: \"Aquela mama\" (ênfase 0.42)"}, + {"kind": "text", "start": 127.7, "end": 129.0, + "params": {"content": "MASTOPEXIA"}, "reason": "fixa o termo central"}, + {"kind": "marker", "start": 21.85, "end": 22.0, + "reason": "EMENDA 1 — conferir junção entre tomadas"} + ] +} +``` + +## Regras + +### 1. Tempos em segundos da mídia ORIGINAL +Exatamente como aparecem nos intervalos persistidos da transcrição no banco. + +**Nunca compense para "depois do corte".** O programa faz esse deslocamento +sozinho: ele resolve os cortes primeiro e reposiciona todo o resto. Se você +compensar por conta própria, **todo destaque cai no frame errado** — e o +erro é silencioso. + +### 2. `end` sempre maior que `start` +Ambos ≥ 0. Um `end <= start` é rejeitado. + +### 3. Tipos +`cut` · `zoom` · `text` · `marker` + +### 4. Parâmetros por tipo + +| Tipo | `params` | +|---|---| +| `cut` | nenhum | +| `zoom` | `scale` entre 1.0 e 3.0 (padrão 1.3 se omitido) | +| `text` | `content` **obrigatório**, até 120 caracteres | +| `marker` | opcional: `content` vira o nome do marcador | + +### 5. `reason` — sempre preencha +É o que o usuário lê para revisar sua decisão, e o que te obriga a **ter** +uma. Um `reason` vazio é sinal de decisão sem critério. + +Inclua o dado que embasou: *"abertura: 'Aquela mama' (ênfase 0.42)"* é útil; +*"zoom"* não é. + +Não é campo de log: o texto é **exibido na tela de revisão**, ao lado da frase, +e é o que o editor lê antes de manter ou desfazer o que você decidiu. + +### 6. Corte: alinhe à intenção +A tela lê cada `cut` contra as frases da transcrição: + +- cobre **≥ 60%** de uma frase → aquela frase é **removida**; +- toca só o **começo** ou só o **fim** → vira **trim** (a frase fica, aparada). + +Então corte a frase **inteira** quando quiser removê-la, e corte **só da borda +até a palavra** quando quiser aparar uma hesitação. Um corte de meia frase é +ambíguo — passa de 60% e apaga a linha toda. Detalhe: `10-revisao-humana.md`. + +## Persistência + +Depois de validar o JSON, crie um registro em `planos_de_edicao` ligado ao +`video_id` e à edição de origem. Grave cada ação em `acoes_do_plano` mantendo a +ordem e os motivos. Não considere o plano entregue até a gravação ser +confirmada. + +## Como o programa trata erros + +- **Ação inválida** → rejeitada e reportada **individualmente**. Uma linha + malformada nunca derruba as outras. +- **Ação apontando para material cortado** → descartada e reportada, nunca + deslizada para o conteúdo vizinho. +- **Ação fora da mídia** → reportada como não colocada. + +Você recebe o relatório dos três casos. **Repasse ao usuário** — nunca +relate só os acertos. diff --git a/code/plugins/premiere-pro/skills/editar-por-voz/criterios/09-analise-incompleta.md b/code/plugins/premiere-pro/skills/editar-por-voz/criterios/09-analise-incompleta.md new file mode 100644 index 0000000..8b1af8a --- /dev/null +++ b/code/plugins/premiere-pro/skills/editar-por-voz/criterios/09-analise-incompleta.md @@ -0,0 +1,53 @@ +# 09 — Quando a análise veio incompleta + +> **Escopo:** O que fazer quando uma camada da análise não rodou. +> **Quando:** Fase 0 — ver a ordem de trabalho em `../SKILL.md`. + +O registro correspondente em `analises_versao` e os campos disponíveis nas +tabelas de análise dizem **o que de fato rodou**. Leia isso antes de qualquer +outra coisa. Se houver um JSON exportado com `layers`, use-o apenas como +informação auxiliar e confira sua versão contra o banco. + +```json +"layers": {"transcript": true, "acoustics": false, "speakers": false} +``` + +## Por que esse bloco existe + +Fala monótona e acústica que não carregou deixam **os mesmos zeros** nos +dados. Sem o `layers`, é impossível distinguir "esta pessoa fala de forma +uniforme" de "a análise acústica falhou". + +## Os casos + +### `acoustics: false` +Todos os valores acústicos são 0. **Você não tem ênfase real.** + +- Decida só pelo texto. +- **Avise o usuário** explicitamente. +- Prefira `marker` a `zoom` — sinalize em vez de decidir. + +Causa comum: o componente librosa não está instalado, ou o ffmpeg não +conseguiu extrair o áudio do container. + +### `speakers: false` num vídeo com várias pessoas +A diarização não rodou — falta o token do HuggingFace (aba Modelos do app). + +- Avise antes de tratar tudo como uma voz só. +- Lembre que isso **não impede** a triagem roteiro/conversa, que é feita + pelo texto (ver `02-triagem-roteiro-vs-conversa.md`). + +### `peak_count: 0` +Nada cruzou o piso de ênfase. Duas causas possíveis: + +1. A fala é uniforme mesmo — material sem picos. +2. O limiar está alto para esse material. + +Sugira ajustar em **Análise de Voz** no app. **Não force destaques +inexistentes** só para entregar alguma coisa. + +## Regra geral + +Não finja precisão que você não tem. Uma edição entregue com a ressalva +certa é útil; uma entregue como se estivesse completa, quando metade dos +dados faltou, custa a confiança do usuário no sistema inteiro. diff --git a/code/plugins/premiere-pro/skills/editar-por-voz/criterios/10-revisao-humana.md b/code/plugins/premiere-pro/skills/editar-por-voz/criterios/10-revisao-humana.md new file mode 100644 index 0000000..8ae8536 --- /dev/null +++ b/code/plugins/premiere-pro/skills/editar-por-voz/criterios/10-revisao-humana.md @@ -0,0 +1,122 @@ +# 10 — A revisão humana: o que acontece com o seu JSON + +> **Escopo:** O que o app faz com o seu JSON na etapa 5 — muda como escrever as ações. +> **Quando:** ler antes da Fase 5 — ver a ordem de trabalho em `../SKILL.md`. + +> Leia antes de decidir cortes e zooms. Muda **como** escrever as ações, não +> apenas quais. + +Seu JSON não vai direto para a timeline. Ele é salvo como plano de revisão e +abre no fluxo de revisão humana do painel, na **etapa 5 do Assistente**, uma +tela onde o editor vê cada frase do roteiro com a sua +decisão já aplicada e lapida antes de gerar. + +Isso tem duas consequências práticas: + +1. **Suas decisões são lidas por uma pessoa, frase a frase.** Uma decisão sem + motivo explícito parece arbitrária — e será desfeita. +2. **A tela traduz suas ações para o vocabulário dela.** Se você não escrever + as ações do jeito que essa tradução espera, a intenção se perde no caminho. + +--- + +## Como cada ação sua é lida + +O app quebra a gravação em **frases** (os segmentos do voice timeline) e +projeta suas ações sobre elas. + +### `cut` + +| O corte cobre… | Vira | Na tela | +|---|---|---| +| **≥ 60%** da frase | frase **desativada** | apagada, riscada, reativável num clique | +| só o **começo** ou só o **fim** | **trim** da frase | a frase fica, aparada nas pontas | +| um pedaço no **meio** | nada em si | só conta para a regra dos 60% | + +O trim é **encaixado na fronteira de palavra** mais próxima. Você não precisa +acertar o frame: mire na palavra onde a frase deve começar ou terminar. + +**O que isso pede de você:** decida se está removendo *a linha* ou *aparando* +uma ponta, e escreva o corte de acordo. + +- Removendo a linha → corte a frase inteira, de ponta a ponta. +- Aparando um falso começo → corte só da borda até a palavra onde a fala + engata. Um corte que cobre meia frase é ambíguo: passa de 60% e apaga a linha + toda, quando você só queria tirar a hesitação. + +### `zoom` e `text` + +Qualquer `zoom` ou `text` que toque uma frase marca aquela frase como +**ênfase** — e ênfase, nesta tela, significa **duas coisas**: + +> **A frase de ênfase recebe zoom E legenda dinâmica. As demais recebem +> legenda comum.** + +O nível vem da sua `scale`: + +| `scale` | Nível na tela | | +|---|---|---| +| 1,15 | 1 — Leve | | +| 1,3 | 2 — Média | | +| 1,5 | 3 — Forte | | +| omitida, ou uma ação `text` | 2 — Média | padrão | + +Sem nenhuma ação sua, a tela deriva o nível do `peak_emphasis` da frase +(< 0,25 → sem ênfase; < 0,45 → leve; < 0,65 → média; acima → forte). **A sua +decisão sempre ganha da derivação automática.** + +**O que isso pede de você:** escolher a escala com intenção. Ela não é só +"quanto amplia" — é o peso que aquela frase terá no vídeo inteiro, incluindo o +tratamento da legenda. Um zoom leve numa frase de apoio não é neutro: promove +aquela frase a destaque tipográfico também. + +### `marker` + +Não altera a frase. Continua sendo o seu recado para o editor conferir uma +emenda — e é a ferramenta certa quando você está em dúvida (ver +`03-escolha-da-melhor-tomada.md`). + +--- + +## `reason` aparece na tela + +Não é campo de log. O texto que você escreve em `reason` é exibido para o +editor ao lado da frase selecionada, e é o que ele lê antes de manter ou +desfazer a sua decisão. + +Escreva para quem está com pressa e vai decidir na hora: + +- **Bom:** `"fecho, pico em 'devolver' (ênfase 0.34) — escala mais forte por ser o fechamento da peça"` +- **Ruim:** `"zoom"` · `"corte necessário"` · `"melhor tomada"` + +A regra prática: se o `reason` não contém **o dado** que embasou (a palavra, o +número, a comparação entre tomadas), você provavelmente não tinha critério — +tinha impressão. + +--- + +## O que a tela NÃO desfaz por você + +- **Tempo errado continua errado.** A tela mostra suas ações no eixo da mídia + original; se você compensou para pós-corte, tudo aparece no lugar errado e o + editor não tem como adivinhar o que você quis dizer. +- **Excesso de zoom continua excesso.** A tela não impõe o teto de 2–4 por + minuto (`07-ritmo.md`) — ela mostra o que você mandou. Efeito demais chega + ao editor como trabalho de limpeza. +- **Frase promovida a ênfase sem querer.** Como zoom e legenda dinâmica andam + juntos, espalhar zooms "de segurança" enche o vídeo de legenda dinâmica. Na + dúvida, deixe sem — o editor promove; é mais barato que despromover. + +--- + +## Depois da revisão + +O editor pode, na tela: mudar o nível de ênfase (0–3), desativar ou reativar +frases, corrigir o texto, aparar as pontas por palavra, reclassificar entre +roteiro e bastidor e acrescentar zooms manuais em trechos arbitrários. + +O resultado vira um `_phrase_review.json` e o `_phrase_actions.json` derivado — +e é esse que a geração usa. **Seu JSON é o ponto de partida da conversa, não a +palavra final.** Trabalhe para ser um bom ponto de partida: decisões +defensáveis, motivos legíveis e nenhuma escolha que o editor precise desfazer +antes de começar. diff --git a/code/tests/cep-tipos-video-encoding.test.ts b/code/tests/cep-tipos-video-encoding.test.ts index 6344167..e75fe8f 100644 --- a/code/tests/cep-tipos-video-encoding.test.ts +++ b/code/tests/cep-tipos-video-encoding.test.ts @@ -13,14 +13,14 @@ describe("exportação dos tipos de vídeo no CEP", () => { expect(painel).toContain(''); }); - it("prioriza a cópia nativa com entrada UTF-8", () => { + it("prioriza a API nativa de clipboard para preservar Unicode", () => { const lógica = readFileSync(join(raiz, "cep-plugin", "main.js"), "utf8"); - const início = lógica.indexOf("function copiarTextoParaAreaDeTransferencia(texto)"); + const início = lógica.indexOf("function copiarTextoParaAreaDeTransferencia(texto, mensagem)"); const fim = lógica.indexOf("\n}\n\nfunction copiarComPbcopy", início); const função = lógica.slice(início, fim); - expect(função.indexOf("copiarComPbcopy(texto, copiarComSelecaoDoPainel)")).toBeLessThan( - função.indexOf("navigator.clipboard.writeText"), + expect(função.indexOf("navigator.clipboard.writeText")).toBeLessThan( + função.indexOf("copiarComPbcopy(texto, copiarComSelecaoDoPainel"), ); expect(lógica).toContain('copia.stdin.write(texto, "utf8");'); });