feat: aprimorada a edição por voz com validação de frases, margens

- aprimorada a edição por voz com validação de frases, margens seguras e backup único antes da timeline.
- adicionada consulta expansível dos detalhes de cada trilha diretamente do banco

Resumo:
- 23 arquivos alterados
- 5 novos
- 18 modificados
- 0 removidos

 18 files changed, 321 insertions(+), 216 deletions(-)

Arquivos:
  - .gitignore
  - .jhonny/analises.db
  - code/cep-plugin/index.html
  - code/cep-plugin/main.js
  - code/cep-plugin/styles.css
  - code/engine/analisar_trilhas.py
  - code/engine/aplicar_plano_de_edicao.py
  - code/engine/editor/__init__.py
  - code/engine/editor/backup_de_sequencia.py
  - code/engine/persistencia/__init__.py
  - code/engine/testes/test_aplicar_plano_de_edicao.py
  - code/plugins/premiere-pro/skills/edit-video-by-voice/SKILL.md
  - code/plugins/premiere-pro/skills/editar-por-voz/SKILL.md
  - code/plugins/premiere-pro/skills/editar-por-voz/criterios/05-zoom.md
  - code/plugins/premiere-pro/skills/editar-por-voz/criterios/06-texto-corte-marcador.md
  - code/plugins/premiere-pro/skills/editar-por-voz/criterios/08-formato-de-saida.md
  - code/plugins/premiere-pro/skills/editar-por-voz/criterios/10-revisao-humana.md
  - code/plugins/premiere-pro/skills/transcript-to-edit-actions/SKILL.md
  - code/engine/consultar_detalhes_de_trilha.py
  - code/engine/editor/validacao_semantica.py
  - code/engine/persistencia/leitura_semantica_do_plano.py
  - code/engine/testes/test_leitura_semantica_do_plano.py
  - code/engine/testes/test_validador_semantico_de_plano.py
This commit is contained in:
João Henrique
2026-09-10 14:38:42 -04:00
parent b038d9b2e4
commit 9edde2df37
23 changed files with 873 additions and 216 deletions
+2 -2
View File
@@ -197,8 +197,8 @@
<div class="step-body">
<label class="field-label" for="editorialPlanJson">Plano carregado do banco</label>
<p class="field-help" id="editorialSavedPlanStatus">O plano será lido diretamente do banco da edição concluída.</p>
<textarea id="editorialPlanJson" class="editorial-plan-json" rows="10" spellcheck="false" placeholder='Cole aqui o JSON com source e actions.'></textarea>
<p class="field-help" id="editorialPlanJsonStatus">Cole o plano recebido da IA. O painel valida o formato antes de executar.</p>
<textarea id="editorialPlanJson" class="editorial-plan-json" rows="10" spellcheck="false" placeholder='Carregue do banco o JSON com source e actions.'></textarea>
<p class="field-help" id="editorialPlanJsonStatus">Carregue o plano salvo no SQLite. O painel valida frases, palavras e margens antes de executar.</p>
<label class="field-label" for="editorialPlanPath">Arquivo do plano (JSON devolvido pela IA)</label>
<div class="file-field">
<input type="text" id="editorialPlanPath" spellcheck="false" autocomplete="off" placeholder="/caminho/para/plano.json">
+89 -51
View File
@@ -1149,6 +1149,7 @@ var scannerProcesso = null;
var scannerCancelSolicitado = false;
var SCANNER_ENGINE_SCRIPT = "/Volumes/Merongo/SISTEMAS/GENIAL SISTEMAS/Jhonny/code/engine/executar_scanner.py";
var TRILHAS_ENGINE_SCRIPT = "/Volumes/Merongo/SISTEMAS/GENIAL SISTEMAS/Jhonny/code/engine/analisar_trilhas.py";
var TRILHAS_CONSULTAR_SCRIPT = "/Volumes/Merongo/SISTEMAS/GENIAL SISTEMAS/Jhonny/code/engine/consultar_detalhes_de_trilha.py";
var TRILHAS_BANCO_PATH = "";
var TRILHAS_MODELOS_PATH = "";
var TRILHAS_CONFIG_KEY = "mcp_trilhas_config_v1";
@@ -2959,17 +2960,49 @@ function trilhasRenderizarLista(nomes) {
var item = document.createElement("div");
item.className = "trilha-item";
item.id = "trilha-item-" + indice;
item.setAttribute("role", "button");
item.setAttribute("tabindex", "0");
item.setAttribute("aria-expanded", "false");
item.setAttribute("data-caminho", (typeof registro === "string" ? path.join(document.getElementById("trilhasPasta").value.trim(), nome) : registro.caminho) || "");
item.setAttribute("data-state", status === "concluida" ? "concluida" : status === "erro" ? "erro" : "aguardando");
item.innerHTML = '<span class="trilha-check" aria-hidden="true"></span><div class="trilha-info"><strong></strong><span>Aguardando</span><div class="progress-track"><div class="progress-fill" style="width:0%"></div></div></div>';
item.innerHTML = '<span class="trilha-check" aria-hidden="true"></span><div class="trilha-info"><strong></strong><span>Aguardando</span><div class="progress-track"><div class="progress-fill" style="width:0%"></div></div><div class="trilha-detalhes" hidden></div></div>';
item.querySelector("strong").textContent = nome;
item.querySelector(".trilha-info > span").textContent = status === "concluida" ? "Analisada" : status === "erro" ? "Erro na análise anterior" : "Aguardando análise";
if (status === "concluida") item.querySelector(".progress-fill").style.width = "100%";
item.onclick = function () { trilhasAlternarDetalhes(item); };
item.onkeydown = function (evento) { if (evento.key === "Enter" || evento.key === " ") { evento.preventDefault(); trilhasAlternarDetalhes(item); } };
lista.appendChild(item);
});
document.getElementById("trilhasContagem").textContent = (nomes || []).length + " trilha(s)";
document.getElementById("trilhasProgressoCard").hidden = !(nomes || []).length;
}
function trilhasAlternarDetalhes(item) {
var detalhes = item.querySelector(".trilha-detalhes");
var aberto = !detalhes.hidden;
detalhes.hidden = aberto;
item.setAttribute("aria-expanded", String(!aberto));
if (!aberto && !detalhes.getAttribute("data-carregado")) trilhasCarregarDetalhes(item, detalhes);
}
function trilhasCarregarDetalhes(item, detalhes) {
detalhes.removeAttribute("data-carregado");
detalhes.innerHTML = "Consultando análise salva…";
var resposta = childProcess.spawnSync(SCANNER_PYTHON_BIN, [TRILHAS_CONSULTAR_SCRIPT, item.getAttribute("data-caminho"), "--banco", TRILHAS_BANCO_PATH], { encoding: "utf8" });
if (resposta.error || resposta.status !== 0) { detalhes.textContent = "Não foi possível consultar os detalhes desta trilha."; return; }
try {
var dados = JSON.parse(resposta.stdout.trim());
if (!dados.encontrada) { detalhes.textContent = dados.mensagem || "Ainda não há análise salva."; return; }
var etiquetas = dados.etiquetas || [];
var generos = etiquetas.filter(function (etiqueta) { return etiqueta.tipo === "genero"; });
var humores = etiquetas.filter(function (etiqueta) { return etiqueta.tipo === "humor"; });
function formatar(valor, casas) { return valor === null || valor === undefined ? "—" : Number(valor).toFixed(casas || 2); }
function listaEtiquetas(itens) { return itens.length ? itens.map(function (item) { return item.etiqueta + " (" + formatar(Number(item.confianca) * 100, 1) + "%)"; }).join(" · ") : "Não classificado"; }
detalhes.innerHTML = '<div class="trilha-detalhes-grid"><div><small>Duração</small><strong>' + formatar(dados.duracao_em_segundos, 1) + ' s</strong></div><div><small>BPM</small><strong>' + formatar(dados.batidas_por_minuto, 1) + '</strong></div><div><small>Tonalidade</small><strong>' + (dados.tonalidade || "—") + ' ' + (dados.modo || "") + '</strong></div><div><small>Intensidade</small><strong>' + formatar(dados.intensidade, 2) + '</strong></div><div><small>Dançabilidade</small><strong>' + formatar(dados.dancabilidade, 2) + '</strong></div></div><div class="trilha-etiquetas"><div><small>Estilos</small><p>' + listaEtiquetas(generos) + '</p></div><div><small>Humores</small><p>' + listaEtiquetas(humores) + '</p></div></div><small class="trilha-fonte">Análise salva por ' + (dados.provedor || "Essentia") + (dados.modelo ? " · " + dados.modelo : "") + '</small>';
detalhes.setAttribute("data-carregado", "true");
} catch (_) { detalhes.textContent = "A resposta da análise está inválida."; }
}
function trilhasAtualizarEvento(evento) {
if (evento.evento === "catalogo_atualizado") { trilhasRenderizarLista(evento.trilhas || []); return; }
if (evento.evento === "lote_iniciado") { trilhasRenderizarLista(evento.trilhas || evento.nomes || []); return; }
@@ -3610,6 +3643,15 @@ function lerPlanoEditorialDaTela() {
return plano;
}
function invalidarVinculoDoPlanoEditorial() {
planoEditorialIdCarregado = null;
var status = document.getElementById("editorialPlanJsonStatus");
if (status) {
status.textContent = "Plano alterado fora do banco. Registre e carregue a nova versão antes de aplicar cortes.";
status.className = "field-help err";
}
}
function applyEditorialActions() {
if (!silenceState.sequenceName) {
silenceLog("Detecte o clipe/sequência primeiro (passo 1).", "err");
@@ -3632,57 +3674,46 @@ function applyEditorialActions() {
if (typeof window.confirm === "function" && !window.confirm(summary)) return;
setBusy("btnApplyEditorialActions", true);
setStep(3, "running", "Aplicando…");
taskStart("Aplicando o plano de edição", "Criando backup da sequência");
silenceLog("Criando backup da sequência…");
cs.evalScript(
"(function(){try{app.project.activeSequence.clone();return 'ok';}catch(e){return 'error:'+String(e);}}())",
function (raw) {
if (String(raw).indexOf("ok") !== 0) {
silenceLog("Não foi possível criar o backup automático (" + raw + "). Aplicando mesmo assim.", "err");
} else {
silenceLog("Backup criado.", "ok");
}
var caminhoDoPlano = path.join(silenceState.outputDir || os.tmpdir(), "plano-editorial-" + Date.now() + ".json");
try { fs.writeFileSync(caminhoDoPlano, JSON.stringify(plan, null, 2), "utf-8"); } catch (erroEscrita) {
setBusy("btnApplyEditorialActions", false);
setStep(3, "error", "Falhou");
taskEnd(false, "Não foi possível salvar o plano.", erroEscrita.message);
setStep(3, "running", "Validando…");
taskStart("Aplicando o plano de edição", "Validando frases e bordas de corte");
var caminhoDoPlano = path.join(silenceState.outputDir || os.tmpdir(), "plano-editorial-" + Date.now() + ".json");
try { fs.writeFileSync(caminhoDoPlano, JSON.stringify(plan, null, 2), "utf-8"); } catch (erroEscrita) {
setBusy("btnApplyEditorialActions", false);
setStep(3, "error", "Falhou");
taskEnd(false, "Não foi possível salvar o plano.", erroEscrita.message);
return;
}
var processoAplicacao = null;
silenceLog("Validando o plano antes de alterar a timeline…");
taskStart("Aplicando o plano de edição", "Validando e executando pela engine Python", null, function () {
if (processoAplicacao) processoAplicacao.kill("SIGTERM");
});
processoAplicacao = runStreamed(
SCANNER_PYTHON_BIN,
[TESTAR_ACOES_ENGINE_SCRIPT, caminhoDoPlano, "--exigir-validacao-semantica"].concat(
planoEditorialIdCarregado === null ? [] : ["--plano-id", String(planoEditorialIdCarregado), "--banco", ANALISES_DB_PATH]
),
function (line) { silenceLog(line); },
function (code, lastErr, lastOutput) {
processoAplicacao = null;
setBusy("btnApplyEditorialActions", false);
if (code !== 0) {
var detalheDaAplicacao = "";
try {
var resultadoDaAplicacao = JSON.parse(lastOutput || "{}");
var falhasDaAplicacao = (resultadoDaAplicacao.acoes || []).filter(function (acao) { return acao.sucesso === false; });
if (falhasDaAplicacao.length) detalheDaAplicacao = falhasDaAplicacao.map(function (acao) { return acao.detalhe; }).join(" | ");
if (!detalheDaAplicacao && resultadoDaAplicacao.erro) detalheDaAplicacao = resultadoDaAplicacao.erro;
} catch (_) {}
setStep(3, "error", "Bloqueado");
var mensagemDaFalha = detalheDaAplicacao || lastErr || "A engine não retornou detalhes.";
taskEnd(false, "O plano não foi aplicado (código " + code + ").", mensagemDaFalha);
silenceLog("Plano não aplicado (código " + code + "): " + mensagemDaFalha, "err");
return;
}
var processoAplicacao = null;
silenceLog("Aplicando plano pela engine Python…");
taskStart("Aplicando o plano de edição", "Executando pela engine Python", null, function () {
if (processoAplicacao) processoAplicacao.kill("SIGTERM");
});
processoAplicacao = runStreamed(
SCANNER_PYTHON_BIN,
[TESTAR_ACOES_ENGINE_SCRIPT, caminhoDoPlano].concat(
planoEditorialIdCarregado === null ? [] : ["--plano-id", String(planoEditorialIdCarregado), "--banco", ANALISES_DB_PATH]
),
function (line) { silenceLog(line); },
function (code, lastErr, lastOutput) {
processoAplicacao = null;
setBusy("btnApplyEditorialActions", false);
if (code !== 0) {
var detalheDaAplicacao = "";
try {
var resultadoDaAplicacao = JSON.parse(lastOutput || "{}");
var falhasDaAplicacao = (resultadoDaAplicacao.acoes || []).filter(function (acao) { return acao.sucesso === false; });
if (falhasDaAplicacao.length) detalheDaAplicacao = falhasDaAplicacao.map(function (acao) { return acao.detalhe; }).join(" | ");
} catch (_) {}
setStep(3, "error", "Falhou");
var mensagemDaFalha = detalheDaAplicacao || lastErr || "A engine não retornou detalhes.";
taskEnd(false, "Falha ao aplicar o plano (código " + code + ").", mensagemDaFalha);
silenceLog("Falha ao aplicar o plano (código " + code + "): " + mensagemDaFalha, "err");
return;
}
setStep(3, "done", "Aplicado");
taskEnd(true, numActions + " ação(ões) aplicadas na timeline!");
silenceLog("Plano de edição aplicado com sucesso!", "ok");
}
);
setStep(3, "done", "Aplicado");
taskEnd(true, numActions + " ação(ões) aplicadas na timeline!");
silenceLog("Plano de edição aplicado com sucesso!", "ok");
}
);
}
@@ -3700,7 +3731,10 @@ function pickEditorialPlanFile() {
}
var result = api.showOpenDialog(false, false, "Escolha o plano de edição (JSON)", "", ["json"]);
var chosen = result && result.data && result.data.length ? result.data[0] : "";
if (chosen) input.value = toFsPath(chosen);
if (chosen) {
input.value = toFsPath(chosen);
invalidarVinculoDoPlanoEditorial();
}
} catch (e) {
input.focus();
showToast("err", "Não foi possível abrir o seletor: " + e.message);
@@ -4328,6 +4362,10 @@ function downloadModel(model, btn, approxMB) {
renderEditorPersonalities();
restaurarConfiguracaoDeLegendas();
renderizarTiposDeVideoParaEdicao();
var campoDoPlanoEditorial = document.getElementById("editorialPlanJson");
if (campoDoPlanoEditorial) {
campoDoPlanoEditorial.addEventListener("input", invalidarVinculoDoPlanoEditorial);
}
// A aba Editar vídeo começa com a análise já produzida pelo Scanner.
setStep(1, "locked", "Scanner");
+9
View File
@@ -271,6 +271,15 @@ h1, h2, h3 { margin: 0; }
.trilha-item[data-state="erro"] .trilha-check { border-color: var(--red); }
.trilha-item[data-state="erro"] .trilha-info > span { color: #ffb0b0; }
.trilha-item[data-state="erro"] .progress-fill { background: var(--red); }
.trilha-item { cursor: pointer; }
.trilha-item:focus-visible { outline: 2px solid var(--violet); outline-offset: 2px; }
.trilha-detalhes { margin-top: 9px; padding-top: 9px; border-top: 1px solid var(--border); color: var(--text-secondary); cursor: default; }
.trilha-detalhes-grid { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 7px; }
.trilha-detalhes small { display: block; color: var(--text-muted); font-size: 8px; text-transform: uppercase; letter-spacing: .05em; }
.trilha-detalhes strong { display: block; margin-top: 2px; color: var(--text); font-size: 10px; }
.trilha-etiquetas { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 9px; }
.trilha-etiquetas p { margin: 3px 0 0; color: var(--text); font-size: 9.5px; line-height: 1.45; }
.trilha-fonte { margin-top: 8px; }
.task-foot { display: flex; align-items: baseline; gap: 8px; margin-top: 7px; }
.task-stage { flex: 1; min-width: 0; overflow: hidden; color: var(--text-secondary); font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
+2 -2
View File
@@ -35,7 +35,7 @@ def executar(pasta: Path, banco: Path, diretorio_dos_modelos: Path | None, apena
trilhas = repositorio.registrar_pasta_e_arquivos(pasta, arquivos)
if apenas_listar:
emitir("catalogo_atualizado", trilhas=[
{"nome": trilha.nome_do_arquivo, "status": trilha.status}
{"nome": trilha.nome_do_arquivo, "caminho": str(trilha.caminho), "status": trilha.status}
for trilha in trilhas
])
return 0
@@ -51,7 +51,7 @@ def executar(pasta: Path, banco: Path, diretorio_dos_modelos: Path | None, apena
total=len(pendentes),
pasta=str(pasta),
nomes=[trilha.nome_do_arquivo for trilha in pendentes],
trilhas=[{"nome": trilha.nome_do_arquivo, "status": trilha.status} for trilha in trilhas],
trilhas=[{"nome": trilha.nome_do_arquivo, "caminho": str(trilha.caminho), "status": trilha.status} for trilha in trilhas],
)
for ordem, trilha in enumerate(pendentes, start=1):
repositorio.iniciar_analise(trilha.identificador)
+56 -6
View File
@@ -33,8 +33,12 @@ from engine.editor import (
AplicadorDePlanoDeEdicao,
BackupDaSequencia,
EscritaNoEditor,
ErroDePlanoInvalido,
LeitorDePlanoDeEdicao,
MapeadorDeTempoDeOrigemParaTimeline,
PlanoDeEdicao,
TipoDeAcao,
ValidadorSemanticoDePlano,
)
from engine.integracoes.premiere.cliente_mcp import ClienteMCPPorStdio
from engine.integracoes.premiere.cliente_mcp_com_pausa import ClienteMCPComPausa
@@ -48,6 +52,33 @@ CAMINHO_DO_SERVIDOR_MCP = CAMINHO_DO_CODIGO / "dist" / "index.js"
CAMINHO_DO_BANCO = CAMINHO_DO_CODIGO.parent / ".jhonny" / "analises.db"
def _validar_integridade_semantica(
plano: PlanoDeEdicao,
plano_id: int | None,
caminho_do_banco: Path,
exigir_validacao: bool = False,
) -> None:
"""Valida palavras, frases e respiros antes de iniciar a sessão do Premiere."""
if not plano.acoes_do_tipo(TipoDeAcao.CORTE):
return
if plano_id is None:
if exigir_validacao:
raise ErroDePlanoInvalido(
"Salve ou carregue o plano no banco antes de aplicar: sem o identificador "
"não é possível conferir a transcrição palavra por palavra."
)
return
from engine.persistencia.conexao import abrir_banco
from engine.persistencia.leitura_semantica_do_plano import LeitorDeTranscricaoDoPlano
conexao = abrir_banco(caminho_do_banco)
try:
falas = LeitorDeTranscricaoDoPlano(conexao).carregar(plano_id)
finally:
conexao.close()
ValidadorSemanticoDePlano().garantir_valido(plano, falas)
def resolver_caminho_do_node() -> str:
"""Resolve o executável Node mesmo quando o painel CEP possui um PATH reduzido.
@@ -129,6 +160,7 @@ def executar(
caminho_do_plano: Path,
plano_id: int | None = None,
caminho_do_banco: Path = CAMINHO_DO_BANCO,
exigir_validacao_semantica: bool = False,
) -> dict:
"""Lê o plano em ``caminho_do_plano`` e o aplica na sequência ativa do Premiere.
@@ -136,6 +168,12 @@ def executar(
ação — para o painel exibir o que foi aplicado e o que falhou.
"""
plano = LeitorDePlanoDeEdicao().ler(caminho_do_plano)
_validar_integridade_semantica(
plano,
plano_id,
caminho_do_banco,
exigir_validacao_semantica,
)
# A pausa é aplicada no cliente, e não dentro do aplicador, para valer
# igualmente nas leituras da timeline e nas escritas — são todas
@@ -146,11 +184,9 @@ def executar(
acesso_ao_editor = AcessoAoEditor(AcessoATimeline(cliente))
conversor_de_timeline = ConversorDeTimeline()
# Backup antes de qualquer corte, espelhando o painel CEP
# (applyEditorialActions() em cep-plugin/main.js): sem undo por
# script nesta versão do Premiere, é a única forma de recuperar o
# estado anterior se algo sair errado. Uma falha aqui não impede a
# aplicação — só fica registrada em stderr, igual ao painel.
# A engine é a única responsável pelo backup. A validação semântica já
# aconteceu antes da conexão, portanto um plano bloqueado não duplica
# a sequência. Uma falha no backup fica registrada em stderr.
sequencia_ativa = conversor_de_timeline.converter(acesso_ao_editor.obter_timeline_ativa())
backup_ok = BackupDaSequencia(cliente).criar_backup(sequencia_ativa.identificador)
print(
@@ -194,8 +230,22 @@ def main() -> None:
argumentos.add_argument("plano", type=Path)
argumentos.add_argument("--plano-id", type=int)
argumentos.add_argument("--banco", type=Path, default=CAMINHO_DO_BANCO)
argumentos.add_argument("--exigir-validacao-semantica", action="store_true")
opcoes = argumentos.parse_args()
resultado = executar(opcoes.plano, opcoes.plano_id, opcoes.banco)
try:
resultado = executar(
opcoes.plano,
opcoes.plano_id,
opcoes.banco,
opcoes.exigir_validacao_semantica,
)
except ErroDePlanoInvalido as erro:
print(json.dumps({
"todas_bem_sucedidas": False,
"erro": str(erro),
"acoes": [],
}, ensure_ascii=False, indent=2))
sys.exit(1)
print(json.dumps(resultado, ensure_ascii=False, indent=2))
if not resultado["todas_bem_sucedidas"]:
sys.exit(1)
@@ -0,0 +1,63 @@
"""Consulta no SQLite os detalhes da análise mais recente de uma trilha."""
from __future__ import annotations
import argparse
import json
import sqlite3
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))
def consultar(caminho: Path, banco: Path) -> dict[str, object]:
"""Retorna descritores e etiquetas da última análise do arquivo informado."""
caminho_do_banco = banco.expanduser().resolve()
conexao = sqlite3.connect(f"file:{caminho_do_banco}?mode=ro", uri=True)
conexao.row_factory = sqlite3.Row
try:
analise = conexao.execute(
"""SELECT a.id, a.provedor, a.modelo, a.duracao_em_segundos,
a.batidas_por_minuto, a.tonalidade, a.modo,
a.intensidade, a.dancabilidade
FROM trilhas_musicais t
JOIN analises_musicais a ON a.trilha_id = t.id
WHERE t.caminho = ?
ORDER BY a.criada_em DESC, a.id DESC LIMIT 1""",
(str(caminho.expanduser().resolve()),),
).fetchone()
if analise is None:
return {"encontrada": False, "mensagem": "Esta trilha ainda não possui uma análise salva."}
etiquetas = conexao.execute(
"SELECT tipo, etiqueta, confianca FROM etiquetas_musicais WHERE analise_id = ? ORDER BY tipo, ordem",
(analise["id"],),
).fetchall()
return {
"encontrada": True,
"provedor": analise["provedor"],
"modelo": analise["modelo"],
"duracao_em_segundos": analise["duracao_em_segundos"],
"batidas_por_minuto": analise["batidas_por_minuto"],
"tonalidade": analise["tonalidade"],
"modo": analise["modo"],
"intensidade": analise["intensidade"],
"dancabilidade": analise["dancabilidade"],
"etiquetas": [dict(etiqueta) for etiqueta in etiquetas],
}
finally:
conexao.close()
def principal() -> None:
"""Lê os argumentos e imprime a consulta como JSON."""
argumentos = argparse.ArgumentParser()
argumentos.add_argument("caminho", type=Path)
argumentos.add_argument("--banco", type=Path, required=True)
opcoes = argumentos.parse_args()
print(json.dumps(consultar(opcoes.caminho, opcoes.banco), ensure_ascii=False))
if __name__ == "__main__":
principal()
+3
View File
@@ -18,6 +18,7 @@ from .escrita import EscritaNoEditor
from .leitura import LeitorDePlanoDeEdicao
from .mapeamento import MapeadorDeTempoDeOrigemParaTimeline
from .modelos import AcaoDeEdicao, PlanoDeEdicao, TipoDeAcao
from .validacao_semantica import ProblemaSemanticoDoPlano, ValidadorSemanticoDePlano
__all__ = [
"AcaoDeEdicao",
@@ -31,7 +32,9 @@ __all__ = [
"LeitorDePlanoDeEdicao",
"MapeadorDeTempoDeOrigemParaTimeline",
"PlanoDeEdicao",
"ProblemaSemanticoDoPlano",
"ResultadoDaAcao",
"ResultadoDaAplicacao",
"TipoDeAcao",
"ValidadorSemanticoDePlano",
]
+6 -9
View File
@@ -9,11 +9,10 @@ from ..integracoes.premiere.erros_mcp import ErroDeFerramentaMCP
class BackupDaSequencia:
"""Duplica a sequência ativa do Premiere antes de uma edição destrutiva.
Espelha o passo de segurança do painel CEP
(``app.project.activeSequence.clone()`` em ``applyEditorialActions()``,
``cep-plugin/main.js``): sem undo por script nesta versão do Premiere,
duplicar a sequência antes de cortar é a única forma de recuperar o
estado anterior se algo sair errado.
A engine concentra este passo de segurança para que painel e linha de
comando não criem cópias duplicadas. Sem undo por script nesta versão do
Premiere, duplicar a sequência antes de cortar é a única forma de recuperar
o estado anterior se algo sair errado.
"""
def __init__(self, cliente_mcp: ClienteMCP) -> None:
@@ -22,10 +21,8 @@ class BackupDaSequencia:
def criar_backup(self, identificador_da_sequencia: str) -> bool:
"""Duplica a sequência. Devolve ``True`` se conseguiu, ``False`` se o Premiere recusou.
Não levanta exceção numa recusa: o painel CEP também segue aplicando
o plano mesmo se o backup falhar (loga e avisa, não bloqueia) — a
falta de backup não deve impedir a edição que o usuário pediu, só
precisa ficar visível para quem chama decidir o que fazer.
Não levanta exceção numa recusa: a engine registra a falha e continua
a aplicação solicitada, deixando a ausência de backup visível.
"""
try:
self.cliente_mcp.chamar("duplicate_sequence", {"sequence_id": identificador_da_sequencia})
+227
View File
@@ -0,0 +1,227 @@
"""Validação semântica das bordas de cortes antes de alterar a timeline."""
from __future__ import annotations
from dataclasses import dataclass
from engine.scanner.modelos import PalavraDeTranscricao, SegmentoDeTranscricao
from .erros import ErroDePlanoInvalido
from .modelos import AcaoDeEdicao, PlanoDeEdicao, TipoDeAcao
@dataclass(frozen=True)
class ProblemaSemanticoDoPlano:
"""Descreve uma borda insegura e a ação em que ela foi encontrada."""
codigo: str
mensagem: str
indice_da_acao: int
instante: float
class ValidadorSemanticoDePlano:
"""Protege palavras, frases completas e respiros nas emendas de cortes."""
_TOLERANCIA_EM_SEGUNDOS = 0.01
_PONTUACAO_DE_FECHAMENTO = (".", "!", "?", "…")
def __init__(self, margem_minima_em_segundos: float = 0.3) -> None:
"""Configura a folga acústica mínima exigida junto à fala mantida."""
if margem_minima_em_segundos < 0:
raise ValueError("A margem mínima não pode ser negativa.")
self._margem_minima = margem_minima_em_segundos
def validar(
self,
plano: PlanoDeEdicao,
falas: tuple[SegmentoDeTranscricao, ...],
) -> tuple[ProblemaSemanticoDoPlano, ...]:
"""Devolve todos os problemas semânticos encontrados nas ações de corte."""
cortes = tuple(
(indice, acao)
for indice, acao in enumerate(plano.acoes)
if acao.tipo is TipoDeAcao.CORTE
)
palavras = tuple(
sorted(
(palavra for fala in falas for palavra in fala.palavras),
key=lambda palavra: (palavra.inicio, palavra.fim),
)
)
problemas: list[ProblemaSemanticoDoPlano] = []
for indice, corte in cortes:
problemas.extend(self._validar_palavras_interceptadas(indice, corte, palavras))
problemas.extend(self._validar_borda_inicial(indice, corte, cortes, palavras))
problemas.extend(self._validar_borda_final(indice, corte, cortes, falas, palavras))
return tuple(problemas)
def garantir_valido(
self,
plano: PlanoDeEdicao,
falas: tuple[SegmentoDeTranscricao, ...],
) -> None:
"""Levanta erro explicativo quando qualquer corte ameaça a fala mantida."""
problemas = self.validar(plano, falas)
if not problemas:
return
detalhes = " | ".join(problema.mensagem for problema in problemas)
raise ErroDePlanoInvalido(
"Plano bloqueado pela validação de integridade da fala: " + detalhes
)
def _validar_palavras_interceptadas(
self,
indice: int,
corte: AcaoDeEdicao,
palavras: tuple[PalavraDeTranscricao, ...],
) -> list[ProblemaSemanticoDoPlano]:
problemas = []
for borda, instante in (("início", corte.inicio), ("fim", corte.fim)):
palavra = next(
(
item
for item in palavras
if item.inicio + self._TOLERANCIA_EM_SEGUNDOS
< instante
< item.fim - self._TOLERANCIA_EM_SEGUNDOS
),
None,
)
if palavra is not None:
problemas.append(ProblemaSemanticoDoPlano(
codigo="palavra_interceptada",
mensagem=(
f"ação {indice + 1}: o {borda} em {instante:.2f}s corta "
f'a palavra "{palavra.texto}"'
),
indice_da_acao=indice,
instante=instante,
))
return problemas
def _validar_borda_inicial(
self,
indice: int,
corte: AcaoDeEdicao,
cortes: tuple[tuple[int, AcaoDeEdicao], ...],
palavras: tuple[PalavraDeTranscricao, ...],
) -> list[ProblemaSemanticoDoPlano]:
palavra_anterior = self._ultima_palavra_mantida_antes(corte.inicio, cortes, palavras)
if palavra_anterior is None:
return []
problemas = []
margem = corte.inicio - palavra_anterior.fim
if margem < self._margem_minima - self._TOLERANCIA_EM_SEGUNDOS:
problemas.append(ProblemaSemanticoDoPlano(
codigo="margem_antes_do_corte_insuficiente",
mensagem=(
f"ação {indice + 1}: deixe ao menos {self._margem_minima:.2f}s após "
f'a palavra mantida "{palavra_anterior.texto}" antes de iniciar o corte'
),
indice_da_acao=indice,
instante=corte.inicio,
))
if not palavra_anterior.texto.rstrip().endswith(self._PONTUACAO_DE_FECHAMENTO):
problemas.append(ProblemaSemanticoDoPlano(
codigo="frase_interrompida_antes_do_corte",
mensagem=(
f"ação {indice + 1}: a fala mantida termina em "
f'"{palavra_anterior.texto}" sem concluir a frase'
),
indice_da_acao=indice,
instante=corte.inicio,
))
return problemas
def _validar_borda_final(
self,
indice: int,
corte: AcaoDeEdicao,
cortes: tuple[tuple[int, AcaoDeEdicao], ...],
falas: tuple[SegmentoDeTranscricao, ...],
palavras: tuple[PalavraDeTranscricao, ...],
) -> list[ProblemaSemanticoDoPlano]:
proxima_palavra = self._primeira_palavra_mantida_depois(corte.fim, cortes, palavras)
if proxima_palavra is None:
return []
problemas = []
margem = proxima_palavra.inicio - corte.fim
if margem < self._margem_minima - self._TOLERANCIA_EM_SEGUNDOS:
problemas.append(ProblemaSemanticoDoPlano(
codigo="margem_depois_do_corte_insuficiente",
mensagem=(
f"ação {indice + 1}: termine o corte ao menos {self._margem_minima:.2f}s "
f'antes da palavra mantida "{proxima_palavra.texto}"'
),
indice_da_acao=indice,
instante=corte.fim,
))
fala = self._fala_da_palavra(proxima_palavra, falas)
comeca_no_meio_do_segmento = (
fala is not None
and proxima_palavra.inicio > fala.inicio + self._TOLERANCIA_EM_SEGUNDOS
)
primeira_letra = next((letra for letra in proxima_palavra.texto if letra.isalpha()), "")
comeca_em_minuscula = bool(primeira_letra and primeira_letra.islower())
if comeca_no_meio_do_segmento or comeca_em_minuscula:
problemas.append(ProblemaSemanticoDoPlano(
codigo="retomada_no_meio_da_frase",
mensagem=(
f"ação {indice + 1}: o trecho mantido retoma em "
f'"{proxima_palavra.texto}" ({proxima_palavra.inicio:.2f}s), no meio da frase'
),
indice_da_acao=indice,
instante=corte.fim,
))
return problemas
@staticmethod
def _fala_da_palavra(
palavra: PalavraDeTranscricao,
falas: tuple[SegmentoDeTranscricao, ...],
) -> SegmentoDeTranscricao | None:
return next(
(
fala
for fala in falas
if fala.inicio - 0.01 <= palavra.inicio <= fala.fim + 0.01
),
None,
)
@staticmethod
def _palavra_esta_mantida(
palavra: PalavraDeTranscricao,
cortes: tuple[tuple[int, AcaoDeEdicao], ...],
) -> bool:
centro = (palavra.inicio + palavra.fim) / 2
return not any(corte.inicio <= centro <= corte.fim for _, corte in cortes)
def _ultima_palavra_mantida_antes(
self,
instante: float,
cortes: tuple[tuple[int, AcaoDeEdicao], ...],
palavras: tuple[PalavraDeTranscricao, ...],
) -> PalavraDeTranscricao | None:
candidatas = (
palavra
for palavra in palavras
if palavra.fim <= instante + self._TOLERANCIA_EM_SEGUNDOS
and self._palavra_esta_mantida(palavra, cortes)
)
return max(candidatas, key=lambda palavra: palavra.fim, default=None)
def _primeira_palavra_mantida_depois(
self,
instante: float,
cortes: tuple[tuple[int, AcaoDeEdicao], ...],
palavras: tuple[PalavraDeTranscricao, ...],
) -> PalavraDeTranscricao | None:
candidatas = (
palavra
for palavra in palavras
if palavra.inicio >= instante - self._TOLERANCIA_EM_SEGUNDOS
and self._palavra_esta_mantida(palavra, cortes)
)
return min(candidatas, key=lambda palavra: palavra.inicio, default=None)
+2
View File
@@ -18,6 +18,7 @@ from .ingestao_de_voz import (
LeitorDeDadosParaIA,
)
from .limpeza import LimpadorDeDuplicatas
from .leitura_semantica_do_plano import LeitorDeTranscricaoDoPlano
from .repositorio_de_planos import (
AcaoDoPlano,
ErroDePlano,
@@ -45,6 +46,7 @@ __all__ = [
"IndexadorSemantico",
"IngestorDeVozNoBanco",
"LeitorDeDadosParaIA",
"LeitorDeTranscricaoDoPlano",
"LimpadorDeDuplicatas",
"PlanoDeEdicao",
"RepositorioDeEnunciados",
@@ -0,0 +1,85 @@
"""Leitura da transcrição vinculada a um plano para validação prévia."""
from __future__ import annotations
import sqlite3
from engine.editor.erros import ErroDePlanoInvalido
from engine.scanner.modelos import PalavraDeTranscricao, SegmentoDeTranscricao
class LeitorDeTranscricaoDoPlano:
"""Reconstrói tempos de origem das falas associadas a um plano salvo."""
def __init__(self, conexao: sqlite3.Connection) -> None:
"""Recebe a conexão de leitura do banco de análises."""
self._conexao = conexao
def carregar(self, plano_id: int) -> tuple[SegmentoDeTranscricao, ...]:
"""Carrega falas e palavras do plano, convertidas para a mídia original."""
plano = self._conexao.execute(
"SELECT video_id FROM planos_de_edicao WHERE id = ?",
(plano_id,),
).fetchone()
if plano is None:
raise ErroDePlanoInvalido(f"O plano {plano_id} não existe no banco de análises.")
video_id = plano["video_id"]
if not video_id:
raise ErroDePlanoInvalido(
f"O plano {plano_id} não está associado a um vídeo analisado."
)
linhas = self._conexao.execute(
"""SELECT s.id, s.inicio, s.fim, s.texto, s.confianca, s.falante,
c.inicio_na_timeline, c.inicio_na_origem
FROM segmentos_de_transcricao AS s
LEFT JOIN clipes AS c
ON c.video_id = s.video_id AND c.id = s.clipe_id
WHERE s.video_id = ?
ORDER BY s.inicio, s.id""",
(video_id,),
).fetchall()
if not linhas:
raise ErroDePlanoInvalido(
"Não há transcrição para validar as frases deste plano. "
"Rode o Scanner novamente."
)
falas = tuple(self._converter_fala(linha) for linha in linhas)
if not any(fala.palavras for fala in falas):
raise ErroDePlanoInvalido(
"A transcrição não possui tempos por palavra. "
"Rode o Scanner novamente antes de aplicar."
)
return falas
def _converter_fala(self, linha: sqlite3.Row) -> SegmentoDeTranscricao:
deslocamento = self._calcular_deslocamento(linha)
palavras = tuple(
PalavraDeTranscricao(
texto=palavra["texto"],
inicio=max(0.0, float(palavra["inicio"]) + deslocamento),
fim=max(0.0, float(palavra["fim"]) + deslocamento),
confianca=palavra["confianca"],
falante=palavra["falante"],
)
for palavra in self._conexao.execute(
"""SELECT texto, inicio, fim, confianca, falante
FROM palavras_de_transcricao
WHERE segmento_id = ?
ORDER BY ordem""",
(linha["id"],),
).fetchall()
)
return SegmentoDeTranscricao(
inicio=max(0.0, float(linha["inicio"]) + deslocamento),
fim=max(0.0, float(linha["fim"]) + deslocamento),
texto=linha["texto"],
confianca=linha["confianca"],
palavras=palavras,
falante=linha["falante"],
)
@staticmethod
def _calcular_deslocamento(linha: sqlite3.Row) -> float:
if linha["inicio_na_origem"] is None or linha["inicio_na_timeline"] is None:
return 0.0
return float(linha["inicio_na_origem"]) - float(linha["inicio_na_timeline"])
@@ -1,10 +1,13 @@
"""Testes da resolução do executável Node usada pelo aplicador de planos."""
import os
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from engine import aplicar_plano_de_edicao
from engine.editor import ErroDePlanoInvalido
class TesteResolucaoDoNode(unittest.TestCase):
@@ -35,5 +38,50 @@ class TesteResolucaoDoNode(unittest.TestCase):
aplicar_plano_de_edicao.resolver_caminho_do_node()
class TestePreValidacaoDoPlano(unittest.TestCase):
"""Garante que planos semanticamente perigosos não alcancem o Premiere."""
def test_interrompe_antes_de_resolver_node_e_criar_backup(self) -> None:
"""A validação semântica ocorre antes de abrir a sessão do editor."""
with tempfile.TemporaryDirectory() as pasta:
caminho_do_plano = Path(pasta) / "plano.json"
caminho_do_plano.write_text(
'{"source":"video.mp4","actions":['
'{"kind":"cut","start":1.0,"end":2.0,"reason":"teste"}]}',
encoding="utf-8",
)
with patch(
"engine.aplicar_plano_de_edicao._validar_integridade_semantica",
side_effect=ErroDePlanoInvalido("frase interrompida"),
) as validar, patch(
"engine.aplicar_plano_de_edicao.resolver_caminho_do_node"
) as resolver_node:
with self.assertRaisesRegex(ErroDePlanoInvalido, "frase interrompida"):
aplicar_plano_de_edicao.executar(caminho_do_plano, plano_id=7)
validar.assert_called_once()
resolver_node.assert_not_called()
def test_painel_rejeita_corte_sem_plano_associado_ao_banco(self) -> None:
"""Não permite contornar a validação colando um corte sem transcrição vinculada."""
with tempfile.TemporaryDirectory() as pasta:
caminho_do_plano = Path(pasta) / "plano.json"
caminho_do_plano.write_text(
'{"source":"video.mp4","actions":['
'{"kind":"cut","start":1.0,"end":2.0,"reason":"teste"}]}',
encoding="utf-8",
)
with patch(
"engine.aplicar_plano_de_edicao.resolver_caminho_do_node"
) as resolver_node:
with self.assertRaisesRegex(ErroDePlanoInvalido, "Salve ou carregue"):
aplicar_plano_de_edicao.executar(
caminho_do_plano,
exigir_validacao_semantica=True,
)
resolver_node.assert_not_called()
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,68 @@
"""Testes da leitura de falas usada antes da aplicação de um plano."""
import tempfile
import unittest
from pathlib import Path
from engine.editor import ErroDePlanoInvalido
from engine.persistencia import LeitorDeTranscricaoDoPlano, abrir_banco
class TesteLeitorDeTranscricaoDoPlano(unittest.TestCase):
"""Valida vínculo do plano e conversão de timeline para mídia original."""
def setUp(self) -> None:
"""Cria um banco isolado com um clipe deslocado na timeline."""
self._pasta_temporaria = tempfile.TemporaryDirectory()
self.addCleanup(self._pasta_temporaria.cleanup)
caminho_do_banco = Path(self._pasta_temporaria.name) / "analises.db"
self.conexao = abrir_banco(caminho_do_banco)
self.addCleanup(self.conexao.close)
with self.conexao:
self.conexao.execute(
"INSERT INTO videos (id, nome, duracao) VALUES ('video-1', 'Vídeo', 40.0)"
)
self.conexao.execute(
"""INSERT INTO faixas (id, video_id, nome, tipo, indice)
VALUES ('audio-1', 'video-1', 'A1', 'audio', 0)"""
)
self.conexao.execute(
"""INSERT INTO clipes
(id, video_id, faixa_id, nome, inicio_na_timeline, fim_na_timeline,
inicio_na_origem, fim_na_origem, arquivo, offline, metadados)
VALUES ('clipe-1', 'video-1', 'audio-1', 'Clipe', 10.0, 30.0,
2.0, 22.0, '/video.mp4', 0, '{}')"""
)
segmento = self.conexao.execute(
"""INSERT INTO segmentos_de_transcricao
(video_id, clipe_id, inicio, fim, texto)
VALUES ('video-1', 'clipe-1', 12.0, 14.0, 'Frase completa.')"""
)
self.conexao.execute(
"""INSERT INTO palavras_de_transcricao
(segmento_id, ordem, texto, inicio, fim)
VALUES (?, 0, 'Frase', 12.0, 12.5)""",
(segmento.lastrowid,),
)
self.conexao.execute(
"""INSERT INTO planos_de_edicao (video_id, origem)
VALUES ('video-1', '/video.mp4')"""
)
def test_converte_tempos_para_a_origem_do_clipe(self) -> None:
"""Subtrai a posição da timeline e soma o início na mídia original."""
falas = LeitorDeTranscricaoDoPlano(self.conexao).carregar(1)
self.assertEqual(falas[0].inicio, 4.0)
self.assertEqual(falas[0].fim, 6.0)
self.assertEqual(falas[0].palavras[0].inicio, 4.0)
self.assertEqual(falas[0].palavras[0].fim, 4.5)
def test_rejeita_plano_inexistente(self) -> None:
"""Evita validar uma transcrição que não pertence ao plano solicitado."""
with self.assertRaisesRegex(ErroDePlanoInvalido, "não existe"):
LeitorDeTranscricaoDoPlano(self.conexao).carregar(999)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,109 @@
"""Testes de regressão da integridade semântica dos cortes editoriais."""
import unittest
from engine.editor import AcaoDeEdicao, PlanoDeEdicao, TipoDeAcao
from engine.editor.validacao_semantica import ValidadorSemanticoDePlano
from engine.scanner.modelos import PalavraDeTranscricao, SegmentoDeTranscricao
class TesteValidadorSemanticoDePlano(unittest.TestCase):
"""Impede cortes de palavra e retomadas no meio de uma frase."""
def setUp(self) -> None:
"""Cria o validador com a margem segura usada pela aplicação real."""
self.validador = ValidadorSemanticoDePlano(margem_minima_em_segundos=0.3)
def test_rejeita_caso_real_que_resultou_em_trazer_uma_seguranca_cortado(self) -> None:
"""Reproduz as duas bordas problemáticas do último clipe aplicado."""
falas = (
SegmentoDeTranscricao(
inicio=906.15,
fim=934.63,
texto="Eu acho que hoje, além de uma técnica boa, é você...",
palavras=(
PalavraDeTranscricao("você...", 934.10, 934.63),
),
),
SegmentoDeTranscricao(
inicio=934.65,
fim=962.60,
texto=(
"conseguir achar um profissional que te passe confiança. "
"Então é trazer uma segurança."
),
palavras=(
PalavraDeTranscricao("conseguir", 934.65, 935.20),
PalavraDeTranscricao("Então", 959.05, 959.55),
PalavraDeTranscricao("é", 959.55, 960.45),
PalavraDeTranscricao("trazer", 960.45, 961.81),
PalavraDeTranscricao("uma", 961.94, 962.20),
PalavraDeTranscricao("segurança.", 962.20, 962.60),
),
),
SegmentoDeTranscricao(
inicio=963.10,
fim=970.0,
texto="Eu recomendaria esse profissional.",
palavras=(PalavraDeTranscricao("Eu", 963.10, 963.30),),
),
)
plano = PlanoDeEdicao(
"0E6A8829.MP4",
(
AcaoDeEdicao(TipoDeAcao.CORTE, 875.50, 934.65, "Remove trecho anterior."),
AcaoDeEdicao(TipoDeAcao.CORTE, 962.60, 1018.22, "Encerra após a conclusão."),
),
)
problemas = self.validador.validar(plano, falas)
codigos = {problema.codigo for problema in problemas}
self.assertIn("retomada_no_meio_da_frase", codigos)
self.assertIn("margem_antes_do_corte_insuficiente", codigos)
def test_aceita_frases_completas_com_respiro_nas_duas_bordas(self) -> None:
"""Aceita a remoção posicionada no silêncio entre frases completas."""
falas = (
SegmentoDeTranscricao(
inicio=0.0,
fim=4.0,
texto="Esta é uma frase completa.",
palavras=(PalavraDeTranscricao("completa.", 3.4, 4.0),),
),
SegmentoDeTranscricao(
inicio=8.0,
fim=12.0,
texto="Esta é a próxima frase.",
palavras=(PalavraDeTranscricao("Esta", 8.0, 8.4),),
),
)
plano = PlanoDeEdicao(
"video.mp4",
(AcaoDeEdicao(TipoDeAcao.CORTE, 4.30, 7.70, "Remove silêncio longo."),),
)
self.assertEqual(self.validador.validar(plano, falas), ())
def test_rejeita_borda_que_intercepta_uma_palavra(self) -> None:
"""Distingue palavra realmente cortada de simples ausência de margem."""
falas = (
SegmentoDeTranscricao(
inicio=0.0,
fim=3.0,
texto="Vamos trazer segurança.",
palavras=(PalavraDeTranscricao("segurança.", 2.0, 3.0),),
),
)
plano = PlanoDeEdicao(
"video.mp4",
(AcaoDeEdicao(TipoDeAcao.CORTE, 2.5, 5.0, "Corte inválido."),),
)
problemas = self.validador.validar(plano, falas)
self.assertIn("palavra_interceptada", {problema.codigo for problema in problemas})
if __name__ == "__main__":
unittest.main()
@@ -1,6 +1,6 @@
---
name: edit-video-by-voice
description: Analyze a video voice timeline JSON and produce a validated, executable JSON edit plan. Use when the user asks to edit a video from speech, transcript, repeated takes, spoken emphasis, or editorial selections. This skill decides editorial actions; a separate video editor executes them.
description: Analyze an attached or pasted voice-timeline JSON and produce a portable edit plan outside the Jhonny persisted workflow. Use when no Jhonny SQLite editing context exists. Do not use for the MCP for Adobe Premiere Pro panel; use editar-por-voz there.
---
# Edit video by voice
@@ -1,6 +1,6 @@
---
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.
description: Skill canônica para editar por voz dentro do projeto Jhonny e do painel MCP for Adobe Premiere Pro, usando a transcrição e a configuração editorial do SQLite. Use neste projeto para escolher tomadas, limpar repetições ou montar o corte automático. Não use as skills portáteis edit-video-by-voice ou transcript-to-edit-actions neste fluxo persistido.
---
# Editar por voz
@@ -78,15 +78,22 @@ ou evidência obrigatória estiver ausente, pare e relate exatamente o que falta
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
7. Antes de salvar, reconstrua o texto que sobreviverá a cada emenda. Um
segmento do Whisper não equivale a uma frase: una segmentos adjacentes
sempre que a gramática continuar. Exija frase completa e ao menos 0,30s de
respiro junto a cada palavra mantida. Se uma borda interceptar palavra,
deixar a fala sem fechamento ou retomar em continuação minúscula, corrija
o plano; nunca delegue esse defeito à aplicação.
8. 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
9. Faça preview do texto sobrevivente, lendo em sequência a última frase antes
e a primeira frase depois de cada corte. Apresente decisões e incertezas
para revisão humana. Uma alteração no plano exige novo preview.
10. 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
11. 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
@@ -126,6 +133,8 @@ Leia, conforme a etapa, [criterios/02-triagem-roteiro-vs-conversa.md](criterios/
- 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.
- Nunca use o começo ou o fim de um segmento de transcrição como borda só
porque o timestamp já existe. A unidade editorial é a frase completa.
- 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.
@@ -49,23 +49,17 @@ 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** |
| Valor | Uso |
|---|---|
| 1,15 | sutil |
| 1,18 – 1,3 | padrão |
| 1,5 | 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`.
Não espalhe zoom "por segurança". Cada ação é aplicada diretamente ao clipe e
excesso de escala vira trabalho de correção manual. Zoom não ativa legenda nem
muda o texto por efeito colateral no fluxo atual.
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
@@ -63,11 +63,12 @@ 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:
puro), recue **no mínimo 0,30s** para dentro do próprio corte, nos dois lados.
Essa margem cobre a imprecisão observada entre transcrição e frame de corte:
- o `start` do corte fica ~0,2s **depois** do fim real da última palavra
- o `start` do corte fica ≥0,30s **depois** do fim real da última palavra
mantida;
- o `end` do corte fica ~0,2s **antes** do início real da próxima palavra
- o `end` do corte fica ≥0,30s **antes** do início real da próxima palavra
mantida.
Caso real (projeto Mastopexia): um corte escrito rente (`10.77 → 95.50`,
@@ -81,6 +82,33 @@ 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.
### Segmento de transcrição não é frase
O Whisper pode terminar um segmento no meio de uma oração e continuar o
segmento seguinte em minúscula. Portanto, **nunca** transforme automaticamente
`segment.end` ou `next_segment.start` em borda de corte.
Antes de aceitar cada emenda, leia em voz contínua:
1. a última oração que ficará antes do `start`;
2. a primeira oração que ficará depois do `end`;
3. a junção formada por essas duas partes.
Bloqueie o plano se a última palavra mantida não fechar a oração, se a próxima
fala começar como continuação gramatical, ou se qualquer borda cair dentro de
uma palavra. Caso real bloqueante: remover até `934.65s` faria a fala sobreviver
em `"conseguir achar um profissional..."`, continuação da frase anterior; cortar
em `962.60s`, exatamente após `"segurança."`, pode produzir
`"trazer uma seguran..."` por falta de cauda acústica.
Checklist obrigatório antes de salvar qualquer plano com `cut`:
- zero palavras interceptadas;
- zero trechos mantidos começando no meio de frase;
- zero frases mantidas sem fechamento antes do corte;
- ao menos 0,30s de margem junto às palavras mantidas;
- leitura do texto sobrevivente completa, na ordem final.
### O que continua NÃO sendo seu trabalho
| Tarefa | Ferramenta | Por quê |
@@ -57,18 +57,18 @@ 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.
Não é campo de log: o texto permanece no JSON e no histórico persistido para
o editor conferir por que a ação foi proposta.
### 6. Corte: alinhe à intenção
A tela lê cada `cut` contra as frases da transcrição:
### 6. Corte: preserve a integridade da fala
- cobre **≥ 60%** de uma frase → aquela frase é **removida**;
- toca só o **começo** ou só o **fim** → vira **trim** (a frase fica, aparada).
O aplicador executa literalmente o intervalo informado: ele não converte o
corte em decisão por frase nem encaixa a borda na palavra mais próxima.
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`.
Portanto, reconstrua o texto sobrevivente, preserve frases completas e deixe
ao menos 0,30s de respiro junto às palavras mantidas. A engine bloqueia borda
dentro de palavra, continuação gramatical e margem insuficiente. Detalhes em
`06-texto-corte-marcador.md` e `10-revisao-humana.md`.
## Persistência
@@ -79,8 +79,10 @@ confirmada.
## Como o programa trata erros
- **Ação inválida** → rejeitada e reportada **individualmente**. Uma linha
malformada nunca derruba as outras.
- **Plano ou ação inválida** → o plano inteiro é bloqueado antes de alterar a
timeline.
- **Borda de corte semanticamente insegura** → o plano inteiro é bloqueado
antes da conexão com o Premiere e antes do backup.
- **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.
@@ -1,122 +1,45 @@
# 10 — A revisão humana: o que acontece com o seu JSON
# 10 — Revisão humana no fluxo atual
> **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`.
> **Escopo:** O que o painel realmente permite revisar antes da aplicação.
> **Quando:** ler antes de gerar e aprovar o plano.
> Leia antes de decidir cortes e zooms. Muda **como** escrever as ações, não
> apenas quais.
O painel atual exibe o JSON do plano e o aplica pela engine Python depois da
confirmação. Ele **não possui ainda** uma tela frase a frase, não encaixa trims
automaticamente em palavras e não gera `_phrase_review.json`. Por isso, não
presuma que uma etapa posterior corrigirá bordas editoriais imprecisas.
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.
## Preview obrigatório
Isso tem duas consequências práticas:
Antes de pedir aprovação, apresente para cada corte:
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.
- intervalo removido e motivo;
- última frase completa que ficará antes;
- primeira frase completa que ficará depois;
- texto da emenda resultante;
- margem acústica em cada lado;
- qualquer incerteza que exija escuta humana.
---
O editor deve aprovar o conteúdo sobrevivente, não apenas a quantidade de
ações. Se o preview mostrar palavra truncada, oração incompleta ou continuação
sem contexto, o plano volta para edição e recebe novos limites.
## Como cada ação sua é lida
## Defesa automática
O app quebra a gravação em **frases** (os segmentos do voice timeline) e
projeta suas ações sobre elas.
Ao clicar em **Aplicar plano na timeline**, a engine revalida os cortes antes
de se conectar ao Premiere e antes de criar o backup. A aplicação é bloqueada
quando encontra:
### `cut`
- uma borda dentro de palavra;
- menos de 0,30s entre a borda e a palavra mantida;
- fala mantida interrompida antes do corte;
- retomada no meio de frase depois do corte.
| 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% |
Essa validação é uma rede de segurança, não substitui o preview. Depois que o
plano passa, a engine cria uma única cópia de segurança, aplica as ações e
registra o resultado. O painel não cria uma segunda cópia.
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.
## `reason` continua obrigatório
**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.
Escreva um motivo curto e verificável, com a fala ou comparação que sustentou
a decisão. Evite motivos genéricos como `"corte necessário"`: eles não ajudam o
editor a conferir o plano nem permitem melhorar os critérios depois.
@@ -1,6 +1,6 @@
---
name: transcript-to-edit-actions
description: Transform transcript-based editorial selections into validated executable Premiere edit plans when the user asks to edit by voice, edit by transcript, summarize an interview, select excerpts, or generate JSON actions.
description: Convert exact keep/remove intervals already selected by the user into cut-only JSON for a portable workflow. Use only when the editorial selection is already decided and no Jhonny SQLite context exists. Do not choose takes or use this skill in the MCP for Adobe Premiere Pro panel; use editar-por-voz there.
---
# Transcript to Edit Actions