diff --git a/.jhonny/analises.db b/.jhonny/analises.db
index e606e8b..766e617 100644
Binary files a/.jhonny/analises.db and b/.jhonny/analises.db differ
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 0000000..6e70c3d
Binary files /dev/null and b/code/.jhonny/analises.db differ
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");');
});