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:
@@ -28,6 +28,8 @@ build/
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.temp
|
||||
.jhonny/*.db-shm
|
||||
.jhonny/*.db-wal
|
||||
|
||||
# Resumo temporário usado pelo admin/deploy.command para detalhar o commit
|
||||
admin/COMMIT-DETAILS.md
|
||||
|
||||
Binary file not shown.
@@ -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">
|
||||
|
||||
+60
-22
@@ -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,18 +3674,8 @@ 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");
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -3652,13 +3684,13 @@ function applyEditorialActions() {
|
||||
return;
|
||||
}
|
||||
var processoAplicacao = null;
|
||||
silenceLog("Aplicando plano pela engine Python…");
|
||||
taskStart("Aplicando o plano de edição", "Executando pela engine Python", null, function () {
|
||||
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].concat(
|
||||
[TESTAR_ACOES_ENGINE_SCRIPT, caminhoDoPlano, "--exigir-validacao-semantica"].concat(
|
||||
planoEditorialIdCarregado === null ? [] : ["--plano-id", String(planoEditorialIdCarregado), "--banco", ANALISES_DB_PATH]
|
||||
),
|
||||
function (line) { silenceLog(line); },
|
||||
@@ -3671,11 +3703,12 @@ function applyEditorialActions() {
|
||||
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", "Falhou");
|
||||
setStep(3, "error", "Bloqueado");
|
||||
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");
|
||||
taskEnd(false, "O plano não foi aplicado (código " + code + ").", mensagemDaFalha);
|
||||
silenceLog("Plano não aplicado (código " + code + "): " + mensagemDaFalha, "err");
|
||||
return;
|
||||
}
|
||||
setStep(3, "done", "Aplicado");
|
||||
@@ -3684,8 +3717,6 @@ function applyEditorialActions() {
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Abre o seletor de arquivos nativo do CEP em vez de exigir que a pessoa cole
|
||||
// um caminho absoluto à mão.
|
||||
@@ -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");
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
+31
-3
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user