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
|
# Temporary files
|
||||||
*.tmp
|
*.tmp
|
||||||
*.temp
|
*.temp
|
||||||
|
.jhonny/*.db-shm
|
||||||
|
.jhonny/*.db-wal
|
||||||
|
|
||||||
# Resumo temporário usado pelo admin/deploy.command para detalhar o commit
|
# Resumo temporário usado pelo admin/deploy.command para detalhar o commit
|
||||||
admin/COMMIT-DETAILS.md
|
admin/COMMIT-DETAILS.md
|
||||||
|
|||||||
Binary file not shown.
@@ -197,8 +197,8 @@
|
|||||||
<div class="step-body">
|
<div class="step-body">
|
||||||
<label class="field-label" for="editorialPlanJson">Plano carregado do banco</label>
|
<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>
|
<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>
|
<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">Cole o plano recebido da IA. O painel valida o formato antes de executar.</p>
|
<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>
|
<label class="field-label" for="editorialPlanPath">Arquivo do plano (JSON devolvido pela IA)</label>
|
||||||
<div class="file-field">
|
<div class="file-field">
|
||||||
<input type="text" id="editorialPlanPath" spellcheck="false" autocomplete="off" placeholder="/caminho/para/plano.json">
|
<input type="text" id="editorialPlanPath" spellcheck="false" autocomplete="off" placeholder="/caminho/para/plano.json">
|
||||||
|
|||||||
+89
-51
@@ -1149,6 +1149,7 @@ var scannerProcesso = null;
|
|||||||
var scannerCancelSolicitado = false;
|
var scannerCancelSolicitado = false;
|
||||||
var SCANNER_ENGINE_SCRIPT = "/Volumes/Merongo/SISTEMAS/GENIAL SISTEMAS/Jhonny/code/engine/executar_scanner.py";
|
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_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_BANCO_PATH = "";
|
||||||
var TRILHAS_MODELOS_PATH = "";
|
var TRILHAS_MODELOS_PATH = "";
|
||||||
var TRILHAS_CONFIG_KEY = "mcp_trilhas_config_v1";
|
var TRILHAS_CONFIG_KEY = "mcp_trilhas_config_v1";
|
||||||
@@ -2959,17 +2960,49 @@ function trilhasRenderizarLista(nomes) {
|
|||||||
var item = document.createElement("div");
|
var item = document.createElement("div");
|
||||||
item.className = "trilha-item";
|
item.className = "trilha-item";
|
||||||
item.id = "trilha-item-" + indice;
|
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.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("strong").textContent = nome;
|
||||||
item.querySelector(".trilha-info > span").textContent = status === "concluida" ? "Analisada" : status === "erro" ? "Erro na análise anterior" : "Aguardando análise";
|
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%";
|
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);
|
lista.appendChild(item);
|
||||||
});
|
});
|
||||||
document.getElementById("trilhasContagem").textContent = (nomes || []).length + " trilha(s)";
|
document.getElementById("trilhasContagem").textContent = (nomes || []).length + " trilha(s)";
|
||||||
document.getElementById("trilhasProgressoCard").hidden = !(nomes || []).length;
|
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) {
|
function trilhasAtualizarEvento(evento) {
|
||||||
if (evento.evento === "catalogo_atualizado") { trilhasRenderizarLista(evento.trilhas || []); return; }
|
if (evento.evento === "catalogo_atualizado") { trilhasRenderizarLista(evento.trilhas || []); return; }
|
||||||
if (evento.evento === "lote_iniciado") { trilhasRenderizarLista(evento.trilhas || evento.nomes || []); return; }
|
if (evento.evento === "lote_iniciado") { trilhasRenderizarLista(evento.trilhas || evento.nomes || []); return; }
|
||||||
@@ -3610,6 +3643,15 @@ function lerPlanoEditorialDaTela() {
|
|||||||
return plano;
|
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() {
|
function applyEditorialActions() {
|
||||||
if (!silenceState.sequenceName) {
|
if (!silenceState.sequenceName) {
|
||||||
silenceLog("Detecte o clipe/sequência primeiro (passo 1).", "err");
|
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;
|
if (typeof window.confirm === "function" && !window.confirm(summary)) return;
|
||||||
|
|
||||||
setBusy("btnApplyEditorialActions", true);
|
setBusy("btnApplyEditorialActions", true);
|
||||||
setStep(3, "running", "Aplicando…");
|
setStep(3, "running", "Validando…");
|
||||||
taskStart("Aplicando o plano de edição", "Criando backup da sequência");
|
taskStart("Aplicando o plano de edição", "Validando frases e bordas de corte");
|
||||||
silenceLog("Criando backup da sequência…");
|
var caminhoDoPlano = path.join(silenceState.outputDir || os.tmpdir(), "plano-editorial-" + Date.now() + ".json");
|
||||||
cs.evalScript(
|
try { fs.writeFileSync(caminhoDoPlano, JSON.stringify(plan, null, 2), "utf-8"); } catch (erroEscrita) {
|
||||||
"(function(){try{app.project.activeSequence.clone();return 'ok';}catch(e){return 'error:'+String(e);}}())",
|
setBusy("btnApplyEditorialActions", false);
|
||||||
function (raw) {
|
setStep(3, "error", "Falhou");
|
||||||
if (String(raw).indexOf("ok") !== 0) {
|
taskEnd(false, "Não foi possível salvar o plano.", erroEscrita.message);
|
||||||
silenceLog("Não foi possível criar o backup automático (" + raw + "). Aplicando mesmo assim.", "err");
|
return;
|
||||||
} else {
|
}
|
||||||
silenceLog("Backup criado.", "ok");
|
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 () {
|
||||||
var caminhoDoPlano = path.join(silenceState.outputDir || os.tmpdir(), "plano-editorial-" + Date.now() + ".json");
|
if (processoAplicacao) processoAplicacao.kill("SIGTERM");
|
||||||
try { fs.writeFileSync(caminhoDoPlano, JSON.stringify(plan, null, 2), "utf-8"); } catch (erroEscrita) {
|
});
|
||||||
setBusy("btnApplyEditorialActions", false);
|
processoAplicacao = runStreamed(
|
||||||
setStep(3, "error", "Falhou");
|
SCANNER_PYTHON_BIN,
|
||||||
taskEnd(false, "Não foi possível salvar o plano.", erroEscrita.message);
|
[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;
|
return;
|
||||||
}
|
}
|
||||||
var processoAplicacao = null;
|
setStep(3, "done", "Aplicado");
|
||||||
silenceLog("Aplicando plano pela engine Python…");
|
taskEnd(true, numActions + " ação(ões) aplicadas na timeline!");
|
||||||
taskStart("Aplicando o plano de edição", "Executando pela engine Python", null, function () {
|
silenceLog("Plano de edição aplicado com sucesso!", "ok");
|
||||||
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");
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -3700,7 +3731,10 @@ function pickEditorialPlanFile() {
|
|||||||
}
|
}
|
||||||
var result = api.showOpenDialog(false, false, "Escolha o plano de edição (JSON)", "", ["json"]);
|
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] : "";
|
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) {
|
} catch (e) {
|
||||||
input.focus();
|
input.focus();
|
||||||
showToast("err", "Não foi possível abrir o seletor: " + e.message);
|
showToast("err", "Não foi possível abrir o seletor: " + e.message);
|
||||||
@@ -4328,6 +4362,10 @@ function downloadModel(model, btn, approxMB) {
|
|||||||
renderEditorPersonalities();
|
renderEditorPersonalities();
|
||||||
restaurarConfiguracaoDeLegendas();
|
restaurarConfiguracaoDeLegendas();
|
||||||
renderizarTiposDeVideoParaEdicao();
|
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.
|
// A aba Editar vídeo começa com a análise já produzida pelo Scanner.
|
||||||
setStep(1, "locked", "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-check { border-color: var(--red); }
|
||||||
.trilha-item[data-state="erro"] .trilha-info > span { color: #ffb0b0; }
|
.trilha-item[data-state="erro"] .trilha-info > span { color: #ffb0b0; }
|
||||||
.trilha-item[data-state="erro"] .progress-fill { background: var(--red); }
|
.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-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; }
|
.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)
|
trilhas = repositorio.registrar_pasta_e_arquivos(pasta, arquivos)
|
||||||
if apenas_listar:
|
if apenas_listar:
|
||||||
emitir("catalogo_atualizado", trilhas=[
|
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
|
for trilha in trilhas
|
||||||
])
|
])
|
||||||
return 0
|
return 0
|
||||||
@@ -51,7 +51,7 @@ def executar(pasta: Path, banco: Path, diretorio_dos_modelos: Path | None, apena
|
|||||||
total=len(pendentes),
|
total=len(pendentes),
|
||||||
pasta=str(pasta),
|
pasta=str(pasta),
|
||||||
nomes=[trilha.nome_do_arquivo for trilha in pendentes],
|
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):
|
for ordem, trilha in enumerate(pendentes, start=1):
|
||||||
repositorio.iniciar_analise(trilha.identificador)
|
repositorio.iniciar_analise(trilha.identificador)
|
||||||
|
|||||||
@@ -33,8 +33,12 @@ from engine.editor import (
|
|||||||
AplicadorDePlanoDeEdicao,
|
AplicadorDePlanoDeEdicao,
|
||||||
BackupDaSequencia,
|
BackupDaSequencia,
|
||||||
EscritaNoEditor,
|
EscritaNoEditor,
|
||||||
|
ErroDePlanoInvalido,
|
||||||
LeitorDePlanoDeEdicao,
|
LeitorDePlanoDeEdicao,
|
||||||
MapeadorDeTempoDeOrigemParaTimeline,
|
MapeadorDeTempoDeOrigemParaTimeline,
|
||||||
|
PlanoDeEdicao,
|
||||||
|
TipoDeAcao,
|
||||||
|
ValidadorSemanticoDePlano,
|
||||||
)
|
)
|
||||||
from engine.integracoes.premiere.cliente_mcp import ClienteMCPPorStdio
|
from engine.integracoes.premiere.cliente_mcp import ClienteMCPPorStdio
|
||||||
from engine.integracoes.premiere.cliente_mcp_com_pausa import ClienteMCPComPausa
|
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"
|
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:
|
def resolver_caminho_do_node() -> str:
|
||||||
"""Resolve o executável Node mesmo quando o painel CEP possui um PATH reduzido.
|
"""Resolve o executável Node mesmo quando o painel CEP possui um PATH reduzido.
|
||||||
|
|
||||||
@@ -129,6 +160,7 @@ def executar(
|
|||||||
caminho_do_plano: Path,
|
caminho_do_plano: Path,
|
||||||
plano_id: int | None = None,
|
plano_id: int | None = None,
|
||||||
caminho_do_banco: Path = CAMINHO_DO_BANCO,
|
caminho_do_banco: Path = CAMINHO_DO_BANCO,
|
||||||
|
exigir_validacao_semantica: bool = False,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Lê o plano em ``caminho_do_plano`` e o aplica na sequência ativa do Premiere.
|
"""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.
|
ação — para o painel exibir o que foi aplicado e o que falhou.
|
||||||
"""
|
"""
|
||||||
plano = LeitorDePlanoDeEdicao().ler(caminho_do_plano)
|
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
|
# A pausa é aplicada no cliente, e não dentro do aplicador, para valer
|
||||||
# igualmente nas leituras da timeline e nas escritas — são todas
|
# igualmente nas leituras da timeline e nas escritas — são todas
|
||||||
@@ -146,11 +184,9 @@ def executar(
|
|||||||
acesso_ao_editor = AcessoAoEditor(AcessoATimeline(cliente))
|
acesso_ao_editor = AcessoAoEditor(AcessoATimeline(cliente))
|
||||||
conversor_de_timeline = ConversorDeTimeline()
|
conversor_de_timeline = ConversorDeTimeline()
|
||||||
|
|
||||||
# Backup antes de qualquer corte, espelhando o painel CEP
|
# A engine é a única responsável pelo backup. A validação semântica já
|
||||||
# (applyEditorialActions() em cep-plugin/main.js): sem undo por
|
# aconteceu antes da conexão, portanto um plano bloqueado não duplica
|
||||||
# script nesta versão do Premiere, é a única forma de recuperar o
|
# a sequência. Uma falha no backup fica registrada em stderr.
|
||||||
# estado anterior se algo sair errado. Uma falha aqui não impede a
|
|
||||||
# aplicação — só fica registrada em stderr, igual ao painel.
|
|
||||||
sequencia_ativa = conversor_de_timeline.converter(acesso_ao_editor.obter_timeline_ativa())
|
sequencia_ativa = conversor_de_timeline.converter(acesso_ao_editor.obter_timeline_ativa())
|
||||||
backup_ok = BackupDaSequencia(cliente).criar_backup(sequencia_ativa.identificador)
|
backup_ok = BackupDaSequencia(cliente).criar_backup(sequencia_ativa.identificador)
|
||||||
print(
|
print(
|
||||||
@@ -194,8 +230,22 @@ def main() -> None:
|
|||||||
argumentos.add_argument("plano", type=Path)
|
argumentos.add_argument("plano", type=Path)
|
||||||
argumentos.add_argument("--plano-id", type=int)
|
argumentos.add_argument("--plano-id", type=int)
|
||||||
argumentos.add_argument("--banco", type=Path, default=CAMINHO_DO_BANCO)
|
argumentos.add_argument("--banco", type=Path, default=CAMINHO_DO_BANCO)
|
||||||
|
argumentos.add_argument("--exigir-validacao-semantica", action="store_true")
|
||||||
opcoes = argumentos.parse_args()
|
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))
|
print(json.dumps(resultado, ensure_ascii=False, indent=2))
|
||||||
if not resultado["todas_bem_sucedidas"]:
|
if not resultado["todas_bem_sucedidas"]:
|
||||||
sys.exit(1)
|
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 .leitura import LeitorDePlanoDeEdicao
|
||||||
from .mapeamento import MapeadorDeTempoDeOrigemParaTimeline
|
from .mapeamento import MapeadorDeTempoDeOrigemParaTimeline
|
||||||
from .modelos import AcaoDeEdicao, PlanoDeEdicao, TipoDeAcao
|
from .modelos import AcaoDeEdicao, PlanoDeEdicao, TipoDeAcao
|
||||||
|
from .validacao_semantica import ProblemaSemanticoDoPlano, ValidadorSemanticoDePlano
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"AcaoDeEdicao",
|
"AcaoDeEdicao",
|
||||||
@@ -31,7 +32,9 @@ __all__ = [
|
|||||||
"LeitorDePlanoDeEdicao",
|
"LeitorDePlanoDeEdicao",
|
||||||
"MapeadorDeTempoDeOrigemParaTimeline",
|
"MapeadorDeTempoDeOrigemParaTimeline",
|
||||||
"PlanoDeEdicao",
|
"PlanoDeEdicao",
|
||||||
|
"ProblemaSemanticoDoPlano",
|
||||||
"ResultadoDaAcao",
|
"ResultadoDaAcao",
|
||||||
"ResultadoDaAplicacao",
|
"ResultadoDaAplicacao",
|
||||||
"TipoDeAcao",
|
"TipoDeAcao",
|
||||||
|
"ValidadorSemanticoDePlano",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -9,11 +9,10 @@ from ..integracoes.premiere.erros_mcp import ErroDeFerramentaMCP
|
|||||||
class BackupDaSequencia:
|
class BackupDaSequencia:
|
||||||
"""Duplica a sequência ativa do Premiere antes de uma edição destrutiva.
|
"""Duplica a sequência ativa do Premiere antes de uma edição destrutiva.
|
||||||
|
|
||||||
Espelha o passo de segurança do painel CEP
|
A engine concentra este passo de segurança para que painel e linha de
|
||||||
(``app.project.activeSequence.clone()`` em ``applyEditorialActions()``,
|
comando não criem cópias duplicadas. Sem undo por script nesta versão do
|
||||||
``cep-plugin/main.js``): sem undo por script nesta versão do Premiere,
|
Premiere, duplicar a sequência antes de cortar é a única forma de recuperar
|
||||||
duplicar a sequência antes de cortar é a única forma de recuperar o
|
o estado anterior se algo sair errado.
|
||||||
estado anterior se algo sair errado.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, cliente_mcp: ClienteMCP) -> None:
|
def __init__(self, cliente_mcp: ClienteMCP) -> None:
|
||||||
@@ -22,10 +21,8 @@ class BackupDaSequencia:
|
|||||||
def criar_backup(self, identificador_da_sequencia: str) -> bool:
|
def criar_backup(self, identificador_da_sequencia: str) -> bool:
|
||||||
"""Duplica a sequência. Devolve ``True`` se conseguiu, ``False`` se o Premiere recusou.
|
"""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
|
Não levanta exceção numa recusa: a engine registra a falha e continua
|
||||||
o plano mesmo se o backup falhar (loga e avisa, não bloqueia) — a
|
a aplicação solicitada, deixando a ausência de backup visível.
|
||||||
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.
|
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
self.cliente_mcp.chamar("duplicate_sequence", {"sequence_id": identificador_da_sequencia})
|
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,
|
LeitorDeDadosParaIA,
|
||||||
)
|
)
|
||||||
from .limpeza import LimpadorDeDuplicatas
|
from .limpeza import LimpadorDeDuplicatas
|
||||||
|
from .leitura_semantica_do_plano import LeitorDeTranscricaoDoPlano
|
||||||
from .repositorio_de_planos import (
|
from .repositorio_de_planos import (
|
||||||
AcaoDoPlano,
|
AcaoDoPlano,
|
||||||
ErroDePlano,
|
ErroDePlano,
|
||||||
@@ -45,6 +46,7 @@ __all__ = [
|
|||||||
"IndexadorSemantico",
|
"IndexadorSemantico",
|
||||||
"IngestorDeVozNoBanco",
|
"IngestorDeVozNoBanco",
|
||||||
"LeitorDeDadosParaIA",
|
"LeitorDeDadosParaIA",
|
||||||
|
"LeitorDeTranscricaoDoPlano",
|
||||||
"LimpadorDeDuplicatas",
|
"LimpadorDeDuplicatas",
|
||||||
"PlanoDeEdicao",
|
"PlanoDeEdicao",
|
||||||
"RepositorioDeEnunciados",
|
"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."""
|
"""Testes da resolução do executável Node usada pelo aplicador de planos."""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from engine import aplicar_plano_de_edicao
|
from engine import aplicar_plano_de_edicao
|
||||||
|
from engine.editor import ErroDePlanoInvalido
|
||||||
|
|
||||||
|
|
||||||
class TesteResolucaoDoNode(unittest.TestCase):
|
class TesteResolucaoDoNode(unittest.TestCase):
|
||||||
@@ -35,5 +38,50 @@ class TesteResolucaoDoNode(unittest.TestCase):
|
|||||||
aplicar_plano_de_edicao.resolver_caminho_do_node()
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.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
|
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
|
# Edit video by voice
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: editar-por-voz
|
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
|
# 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.
|
decida pelo texto e registre a limitação.
|
||||||
6. Decida cortes, zooms, textos e marcadores somente quando o contrato de
|
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.
|
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
|
`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.
|
`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
|
9. Faça preview do texto sobrevivente, lendo em sequência a última frase antes
|
||||||
humana. Uma alteração no plano exige novo preview.
|
e a primeira frase depois de cada corte. Apresente decisões e incertezas
|
||||||
9. Após aprovação explícita, confirme a sequência atual, crie backup ou
|
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
|
duplicata, aplique o plano pelo fluxo suportado do Premiere e registre o
|
||||||
resultado em `aplicacoes_do_plano`.
|
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.
|
o que foi comprovado automaticamente do que exige avaliação visual.
|
||||||
|
|
||||||
## Contrato da decisão
|
## 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.
|
- 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
|
- Não altere o áudio original, a transcrição ou a análise para forçar uma
|
||||||
decisão.
|
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
|
- 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.
|
retorno de ferramenta; exija aplicação e verificação.
|
||||||
- Não execute cortes destrutivos sem aprovação e backup/duplicata.
|
- Não execute cortes destrutivos sem aprovação e backup/duplicata.
|
||||||
|
|||||||
@@ -49,23 +49,17 @@ automático acerta na quase totalidade dos casos.
|
|||||||
|
|
||||||
## Escala
|
## Escala
|
||||||
|
|
||||||
| Valor | Uso | Vira, na tela de revisão |
|
| Valor | Uso |
|
||||||
|---|---|---|
|
|---|---|
|
||||||
| 1,15 | sutil | ênfase **1 — Leve** |
|
| 1,15 | sutil |
|
||||||
| 1,18 – 1,3 | padrão | ênfase **2 — Média** |
|
| 1,18 – 1,3 | padrão |
|
||||||
| 1,5 | forte | ênfase **3 — Forte** |
|
| 1,5 | forte |
|
||||||
|
|
||||||
Em vídeo institucional, fique na faixa baixa. Acima de 3,0 é rejeitado.
|
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
|
Não espalhe zoom "por segurança". Cada ação é aplicada diretamente ao clipe e
|
||||||
recebe um zoom é marcada como **ênfase** na etapa 5, e frase de ênfase recebe
|
excesso de escala vira trabalho de correção manual. Zoom não ativa legenda nem
|
||||||
**legenda dinâmica**; as demais ficam com legenda comum. Ou seja: escolher onde
|
muda o texto por efeito colateral no fluxo atual.
|
||||||
dar zoom é também escolher onde o texto ganha tratamento tipográfico.
|
|
||||||
|
|
||||||
Consequência prática: **não espalhe zoom "por segurança"**. Cada um promove uma
|
|
||||||
frase a destaque em duas dimensões ao mesmo tempo. Na dúvida, deixe sem — o
|
|
||||||
editor promove numa tecla, e despromover custa mais que promover.
|
|
||||||
Detalhe: `10-revisao-humana.md`.
|
|
||||||
|
|
||||||
O zoom é **relativo ao enquadramento existente**: se o clipe já tem escala
|
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
|
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.
|
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
|
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;
|
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.
|
mantida.
|
||||||
|
|
||||||
Caso real (projeto Mastopexia): um corte escrito rente (`10.77 → 95.50`,
|
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
|
não é "silêncio dentro da fala" (isso é `remove_media_silence`), é o mesmo
|
||||||
corte de tomada/bastidor que você já está decidindo.
|
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
|
### O que continua NÃO sendo seu trabalho
|
||||||
|
|
||||||
| Tarefa | Ferramenta | Por quê |
|
| 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;
|
Inclua o dado que embasou: *"abertura: 'Aquela mama' (ênfase 0.42)"* é útil;
|
||||||
*"zoom"* não é.
|
*"zoom"* não é.
|
||||||
|
|
||||||
Não é campo de log: o texto é **exibido na tela de revisão**, ao lado da frase,
|
Não é campo de log: o texto permanece no JSON e no histórico persistido para
|
||||||
e é o que o editor lê antes de manter ou desfazer o que você decidiu.
|
o editor conferir por que a ação foi proposta.
|
||||||
|
|
||||||
### 6. Corte: alinhe à intenção
|
### 6. Corte: preserve a integridade da fala
|
||||||
A tela lê cada `cut` contra as frases da transcrição:
|
|
||||||
|
|
||||||
- cobre **≥ 60%** de uma frase → aquela frase é **removida**;
|
O aplicador executa literalmente o intervalo informado: ele não converte o
|
||||||
- toca só o **começo** ou só o **fim** → vira **trim** (a frase fica, aparada).
|
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
|
Portanto, reconstrua o texto sobrevivente, preserve frases completas e deixe
|
||||||
até a palavra** quando quiser aparar uma hesitação. Um corte de meia frase é
|
ao menos 0,30s de respiro junto às palavras mantidas. A engine bloqueia borda
|
||||||
ambíguo — passa de 60% e apaga a linha toda. Detalhe: `10-revisao-humana.md`.
|
dentro de palavra, continuação gramatical e margem insuficiente. Detalhes em
|
||||||
|
`06-texto-corte-marcador.md` e `10-revisao-humana.md`.
|
||||||
|
|
||||||
## Persistência
|
## Persistência
|
||||||
|
|
||||||
@@ -79,8 +79,10 @@ confirmada.
|
|||||||
|
|
||||||
## Como o programa trata erros
|
## Como o programa trata erros
|
||||||
|
|
||||||
- **Ação inválida** → rejeitada e reportada **individualmente**. Uma linha
|
- **Plano ou ação inválida** → o plano inteiro é bloqueado antes de alterar a
|
||||||
malformada nunca derruba as outras.
|
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
|
- **Ação apontando para material cortado** → descartada e reportada, nunca
|
||||||
deslizada para o conteúdo vizinho.
|
deslizada para o conteúdo vizinho.
|
||||||
- **Ação fora da mídia** → reportada como não colocada.
|
- **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.
|
> **Escopo:** O que o painel realmente permite revisar antes da aplicação.
|
||||||
> **Quando:** ler antes da Fase 5 — ver a ordem de trabalho em `../SKILL.md`.
|
> **Quando:** ler antes de gerar e aprovar o plano.
|
||||||
|
|
||||||
> Leia antes de decidir cortes e zooms. Muda **como** escrever as ações, não
|
O painel atual exibe o JSON do plano e o aplica pela engine Python depois da
|
||||||
> apenas quais.
|
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
|
## Preview obrigatório
|
||||||
abre no fluxo de revisão humana do painel, na **etapa 5 do Assistente**, uma
|
|
||||||
tela onde o editor vê cada frase do roteiro com a sua
|
|
||||||
decisão já aplicada e lapida antes de gerar.
|
|
||||||
|
|
||||||
Isso tem duas consequências práticas:
|
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
|
- intervalo removido e motivo;
|
||||||
motivo explícito parece arbitrária — e será desfeita.
|
- última frase completa que ficará antes;
|
||||||
2. **A tela traduz suas ações para o vocabulário dela.** Se você não escrever
|
- primeira frase completa que ficará depois;
|
||||||
as ações do jeito que essa tradução espera, a intenção se perde no caminho.
|
- 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
|
Ao clicar em **Aplicar plano na timeline**, a engine revalida os cortes antes
|
||||||
projeta suas ações sobre elas.
|
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 |
|
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
|
||||||
| **≥ 60%** da frase | frase **desativada** | apagada, riscada, reativável num clique |
|
registra o resultado. O painel não cria uma segunda cópia.
|
||||||
| só o **começo** ou só o **fim** | **trim** da frase | a frase fica, aparada nas pontas |
|
|
||||||
| um pedaço no **meio** | nada em si | só conta para a regra dos 60% |
|
|
||||||
|
|
||||||
O trim é **encaixado na fronteira de palavra** mais próxima. Você não precisa
|
## `reason` continua obrigatório
|
||||||
acertar o frame: mire na palavra onde a frase deve começar ou terminar.
|
|
||||||
|
|
||||||
**O que isso pede de você:** decida se está removendo *a linha* ou *aparando*
|
Escreva um motivo curto e verificável, com a fala ou comparação que sustentou
|
||||||
uma ponta, e escreva o corte de acordo.
|
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.
|
||||||
- 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.
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: transcript-to-edit-actions
|
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
|
# Transcript to Edit Actions
|
||||||
|
|||||||
Reference in New Issue
Block a user