Compare commits
37
Commits
c1b544f4b5
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c64a866838 | ||
|
|
d2d832eab2 | ||
|
|
77b2bc9414 | ||
|
|
6b148c4e4e | ||
|
|
c22e240abe | ||
|
|
8dfe409f52 | ||
|
|
59f1358f18 | ||
|
|
024b004938 | ||
|
|
e73b094187 | ||
|
|
4170b32340 | ||
|
|
0260254e45 | ||
|
|
2c11ba8dda | ||
|
|
fc7310be39 | ||
|
|
114b63e81f | ||
|
|
dd341e7d4c | ||
|
|
2c9bdf2ff0 | ||
|
|
da400de7d3 | ||
|
|
9edde2df37 | ||
|
|
b038d9b2e4 | ||
|
|
4c04365934 | ||
|
|
b1272a5e6e | ||
|
|
0d3e7397a7 | ||
|
|
63e5e3c22b | ||
|
|
bb4e7ba663 | ||
|
|
5644b42722 | ||
|
|
a4286bbd8a | ||
|
|
a9e5b5ff58 | ||
|
|
eda1b1aadc | ||
|
|
e16244d68d | ||
|
|
b01482d0e7 | ||
|
|
25800b2427 | ||
|
|
37698ba62a | ||
|
|
becdc3afd5 | ||
|
|
1fb0e33f57 | ||
|
|
a5d6c5cbe4 | ||
|
|
b0b8027c7e | ||
|
|
485c35c4e8 |
@@ -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.
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"source": "0E6A8829.MP4",
|
||||
"actions": [
|
||||
{
|
||||
"kind": "cut",
|
||||
"start": 0.0,
|
||||
"end": 124.49,
|
||||
"reason": "Remover apresentação dirigida pelo entrevistador, preparação técnica e bastidores; iniciar na fala completa da depoente sobre o diagnóstico e o início do emagrecimento."
|
||||
},
|
||||
{
|
||||
"kind": "cut",
|
||||
"start": 149.17,
|
||||
"end": 376.07,
|
||||
"reason": "Remover retomadas, perguntas e blocos intermediários; preservar a próxima fala completa da depoente sobre procurar a doutora Larissa e realizar o primeiro procedimento."
|
||||
},
|
||||
{
|
||||
"kind": "cut",
|
||||
"start": 404.85,
|
||||
"end": 848.50,
|
||||
"reason": "Remover consulta, preparação, perguntas e bastidores que interrompem a narrativa; manter o bloco completo sobre a transformação percebida depois da cirurgia."
|
||||
},
|
||||
{
|
||||
"kind": "cut",
|
||||
"start": 875.80,
|
||||
"end": 962.80,
|
||||
"reason": "Remover a pergunta final e a primeira resposta truncada em continuação; encerrar com a fala completa sobre o contato pessoal e a segurança na escolha do médico."
|
||||
},
|
||||
{
|
||||
"kind": "cut",
|
||||
"start": 991.54,
|
||||
"end": 1014.64,
|
||||
"reason": "Remover o fecho adicional que repete a ideia de contato pessoal e segurança; manter como encerramento a recomendação completa sobre consultar, conversar e decidir em paz."
|
||||
}
|
||||
]
|
||||
}
|
||||
+17
@@ -3,6 +3,10 @@
|
||||
## Vocabulário
|
||||
|
||||
- **Scanner**: caso de uso que coordena a leitura, análise, decisão, planejamento, aplicação e validação de uma edição.
|
||||
- **Sequência**: timeline do Premiere e raiz operacional de um trabalho de edição; no banco é representada pelo vídeo identificado por `video_id` e nomeada pelo campo `sequencia`.
|
||||
- **Projeto de edição**: unidade de trabalho que contém exatamente uma sequência, suas escolhas de faixas e clipes, e os planos editoriais associados; uma sequência pode usar vários arquivos de mídia.
|
||||
- **Arquivo de mídia**: arquivo físico de vídeo ou áudio usado por um ou mais clipes da sequência; não é sinônimo de projeto nem de sequência.
|
||||
- **Edição de vídeo**: versão da configuração editorial concluída para uma sequência; registra tipo, instruções e origem, mas não representa ainda uma aplicação na timeline.
|
||||
- **Conteúdo**: unidade analisável identificada por um `content_id`, com texto e metadados.
|
||||
- **Análise**: observações estruturadas produzidas a partir do conteúdo.
|
||||
- **Decisão**: escolha de ações editoriais baseada na análise e na configuração.
|
||||
@@ -13,9 +17,22 @@
|
||||
- **Transcrição**: segmentos de texto sincronizados com um clipe, produzidos por um provider; não é decisão editorial.
|
||||
- **Cena**: intervalo contínuo da timeline com características visuais e/ou textuais semelhantes.
|
||||
- **Evento**: observação temporal detectada no áudio, texto ou vídeo; não implica corte.
|
||||
- **Trilha musical**: obra sonora catalogada para possível uso como música de fundo; possui identidade própria e não depende de um projeto de vídeo.
|
||||
- **Catálogo de trilhas**: conjunto pesquisável de trilhas musicais e suas análises, separado do índice de conteúdo dos vídeos.
|
||||
- **Análise musical**: observações calculadas a partir do sinal de áudio, como BPM, tonalidade, gênero, humor e intensidade; tags do arquivo não são evidência da análise.
|
||||
- **Etiqueta musical**: classificação produzida por um modelo para uma trilha, sempre acompanhada do tipo, da confiança, do provider e da versão do modelo.
|
||||
|
||||
## Decisões atuais
|
||||
|
||||
- A arquitetura Python ficará isolada em `code/engine` enquanto o sistema existente continuar em TypeScript.
|
||||
- O domínio não conhece filesystem, banco de dados, SDK de IA ou plataforma de edição; essas integrações entram por interfaces e adaptadores.
|
||||
- O primeiro fluxo é síncrono e determinístico, permitindo evolução posterior para operações assíncronas sem alterar o domínio.
|
||||
- A sequência é a unidade de retomada: ao selecioná-la, o sistema deve localizar a edição concluída mais recente, reutilizar suas análises e oferecer o plano pendente para aprovação e aplicação.
|
||||
- Cada projeto possui uma única sequência, mas pode reunir vários arquivos de mídia e vários clipes dessa sequência; a seleção do projeto nunca deve misturar timelines diferentes.
|
||||
- Uma edição concluída, um plano gerado e uma aplicação realizada são estados distintos e devem permanecer auditáveis; concluir a configuração não significa que o Premiere já foi alterado.
|
||||
- O plano deve evoluir para referenciar diretamente a versão de edição que o originou, além do `video_id`, para impedir que uma nova configuração editorial seja aplicada por engano sobre um plano antigo.
|
||||
- O catálogo de trilhas musicais é independente dos projetos de edição e não possui `video_id` na sua unidade principal.
|
||||
- A identidade da trilha deve usar uma impressão digital do conteúdo; o caminho do arquivo é uma localização mutável e não a identidade definitiva.
|
||||
- O banco deve guardar referências ao arquivo, descritores compactos, etiquetas com proveniência e versões das análises; não deve guardar o áudio binário.
|
||||
- Uma mesma trilha pode ter várias análises, produzidas por providers ou versões de modelos diferentes. O resultado ativo deve ser selecionável sem apagar o histórico.
|
||||
- O vínculo entre uma trilha musical e um projeto de edição será criado posteriormente em uma relação própria, permitindo reutilização da mesma trilha em vários projetos.
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
# agente de IA buscar o codigo certo. Best-effort: se o
|
||||
# abre o tunel temporariamente quando necessario e fecha o
|
||||
# processo que este ciclo iniciou ao terminar.
|
||||
# 4. LEMBRETE reinicie o Premiere Pro por completo para o painel
|
||||
# 4. LEMBRETE recarregue somente a extensao CEP para o painel
|
||||
# reinstalado ser carregado - este passo so imprime isso
|
||||
#
|
||||
# Nao mexe em git (este projeto nao e um repositorio git) e nao sobe
|
||||
@@ -199,19 +199,18 @@ elif run_step rag; then
|
||||
fi
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# 4) Lembrete de recarregar o painel dentro do Premiere
|
||||
# 4) Lembrete de recarregar somente a extensao dentro do Premiere
|
||||
# ------------------------------------------------------------
|
||||
if run_step reload; then
|
||||
step "4/4 REINICIAR O PREMIERE"
|
||||
echo "O painel ja foi reinstalado (passo 1), mas o Premiere so carrega a"
|
||||
echo "copia nova depois de um restart completo:"
|
||||
echo " 1. Feche o Premiere Pro por completo (Cmd+Q)"
|
||||
echo " 2. Abra de novo e va em Window > Extensions > MCP for Adobe Premiere Pro"
|
||||
echo " 3. Confira se aparecem as abas 'Bridge' e 'Cortar silencio'"
|
||||
step "4/4 RECARREGAR A EXTENSAO CEP"
|
||||
echo "O painel ja foi reinstalado (passo 1). Para carregar a copia nova:"
|
||||
echo " 1. Feche somente o painel MCP for Adobe Premiere Pro"
|
||||
echo " 2. Abra novamente em Window > Extensions > MCP for Adobe Premiere Pro"
|
||||
echo " 3. Confira a aba Editar video e os passos Concluir edicao / Aplicar na timeline"
|
||||
fi
|
||||
|
||||
echo
|
||||
if [ "$FAILED" != "0" ]; then
|
||||
die "ciclo terminou com pendencias - corrija os avisos acima antes de testar no Premiere."
|
||||
fi
|
||||
ok "Ciclo concluido. Pronto para recarregar o painel no Premiere."
|
||||
ok "Ciclo concluido. Pronto para recarregar somente a extensao no Premiere."
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,142 @@
|
||||
"use strict";
|
||||
|
||||
// A guia Projeto mantém a seleção da sequência, faixas e clipes fora das
|
||||
// regras específicas de edição. O estado definitivo será persistido pelo
|
||||
// repositório SQLite na próxima etapa da integração.
|
||||
var projetoSequencias = [];
|
||||
var projetoAtual = null;
|
||||
|
||||
function projetoListarSequencias() {
|
||||
var botao = document.getElementById("btnProjetoCarregar");
|
||||
if (botao) botao.disabled = true;
|
||||
var select = document.getElementById("projetoSequenciaSelect");
|
||||
var info = document.getElementById("projetoInfo");
|
||||
info.textContent = "Lendo sequências, faixas e clipes do Premiere…";
|
||||
cs.evalScript(
|
||||
"(function(){try{" +
|
||||
"if(!app.project)return JSON.stringify({ok:false,error:'Nenhum projeto aberto.'});" +
|
||||
"var saida=[];" +
|
||||
"for(var i=0;i<app.project.sequences.numSequences;i++){" +
|
||||
"var s=app.project.sequences[i], faixas=[];" +
|
||||
"for(var v=0;v<s.videoTracks.numTracks;v++){var tv=s.videoTracks[v],cv=[];for(var j=0;j<tv.clips.numItems;j++){var x=tv.clips[j];cv.push({id:String(x.nodeId),nome:String(x.name||''),inicio:Number(x.start.seconds),fim:Number(x.end.seconds)});}faixas.push({id:'video_'+v,nome:'Vídeo '+(v+1),tipo:'video',indice:v,clipes:cv});}" +
|
||||
"for(var a=0;a<s.audioTracks.numTracks;a++){var ta=s.audioTracks[a],ca=[];for(var k=0;k<ta.clips.numItems;k++){var y=ta.clips[k];ca.push({id:String(y.nodeId),nome:String(y.name||''),inicio:Number(y.start.seconds),fim:Number(y.end.seconds)});}faixas.push({id:'audio_'+a,nome:'Áudio '+(a+1),tipo:'audio',indice:a,clipes:ca});}" +
|
||||
"saida.push({id:String(s.sequenceID),nome:String(s.name||''),duracao:Number(s.end.seconds||0),largura:Number(s.frameSizeHorizontal||0),altura:Number(s.frameSizeVertical||0),ativa:!!(app.project.activeSequence&&s.sequenceID===app.project.activeSequence.sequenceID),faixas:faixas});" +
|
||||
"}return JSON.stringify({ok:true,sequences:saida});}catch(e){return JSON.stringify({ok:false,error:String(e)});}}())",
|
||||
function (raw) {
|
||||
if (botao) botao.disabled = false;
|
||||
var resposta;
|
||||
try { resposta = JSON.parse(raw); } catch (erro) { resposta = {ok: false, error: "Resposta inválida do Premiere."}; }
|
||||
if (!resposta.ok) { info.textContent = resposta.error; return; }
|
||||
projetoSequencias = resposta.sequences || [];
|
||||
select.innerHTML = "";
|
||||
projetoSequencias.forEach(function (sequencia) {
|
||||
var opcao = document.createElement("option");
|
||||
opcao.value = sequencia.id;
|
||||
opcao.textContent = sequencia.nome + (sequencia.ativa ? " · ativa" : "");
|
||||
select.appendChild(opcao);
|
||||
});
|
||||
select.disabled = projetoSequencias.length === 0;
|
||||
if (projetoSequencias.length) {
|
||||
var ativa = projetoSequencias.findIndex(function (item) { return item.ativa; });
|
||||
select.selectedIndex = ativa >= 0 ? ativa : 0;
|
||||
projetoSelecionarSequencia();
|
||||
} else {
|
||||
info.textContent = "Nenhuma sequência encontrada no projeto do Premiere.";
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function projetoSelecionarSequencia() {
|
||||
var select = document.getElementById("projetoSequenciaSelect");
|
||||
projetoAtual = projetoSequencias.find(function (item) { return item.id === select.value; }) || null;
|
||||
if (!projetoAtual) return;
|
||||
document.getElementById("projetoTimelineCard").hidden = false;
|
||||
document.getElementById("projetoStatus").textContent = "Sequência selecionada";
|
||||
document.getElementById("projetoTimelineStatus").textContent = projetoAtual.nome;
|
||||
document.getElementById("projetoDuracao").textContent = formatSequenceDuration(projetoAtual.duracao);
|
||||
document.getElementById("projetoResolucao").textContent = projetoAtual.largura && projetoAtual.altura ? projetoAtual.largura + " × " + projetoAtual.altura : "Não informada";
|
||||
document.getElementById("projetoTotalFaixas").textContent = projetoAtual.faixas.length;
|
||||
document.getElementById("projetoTotalClipes").textContent = projetoAtual.faixas.reduce(function (total, faixa) { return total + faixa.clipes.length; }, 0);
|
||||
var container = document.getElementById("projetoFaixas");
|
||||
container.innerHTML = "";
|
||||
projetoAtual.faixas.forEach(function (faixa) {
|
||||
var bloco = document.createElement("div");
|
||||
bloco.className = "scanner-subsection";
|
||||
bloco.innerHTML = '<label class="checkbox-field"><input type="checkbox" checked data-projeto-faixa="' + faixa.id + '"><span>' + faixa.nome + " · " + faixa.clipes.length + " clipe(s)</span></label>";
|
||||
faixa.clipes.forEach(function (clipe) {
|
||||
bloco.innerHTML += '<label class="checkbox-field" style="padding-left:24px"><input type="checkbox" checked data-projeto-clipe="' + clipe.id + '"><span>' + clipe.nome + " · " + formatSequenceDuration(clipe.fim - clipe.inicio) + "</span></label>";
|
||||
});
|
||||
container.appendChild(bloco);
|
||||
});
|
||||
document.getElementById("btnProjetoContinuar").disabled = false;
|
||||
document.getElementById("btnProjetoSalvar").disabled = false;
|
||||
projetoCarregarTipoDeVideo();
|
||||
document.getElementById("projetoInfo").textContent = "A sequência foi carregada. Selecione as faixas e clipes que participarão do plano.";
|
||||
}
|
||||
|
||||
function projetoCarregarTipoDeVideo() {
|
||||
var select = document.getElementById("projetoTipoVideoSelect");
|
||||
var info = document.getElementById("projetoTipoVideoInfo");
|
||||
select.disabled = true;
|
||||
runStreamed(SCANNER_PYTHON_BIN, [CONSULTAR_EDICAO_SCRIPT, projetoAtual.id, "--banco", ANALISES_DB_PATH],
|
||||
function () {},
|
||||
function (codigo, ultimoErro, ultimaSaida) {
|
||||
if (codigo !== 0) { info.textContent = ultimoErro || "Nenhum tipo de vídeo salvo para esta sequência."; return; }
|
||||
try {
|
||||
var edicao = JSON.parse(ultimaSaida).edicao;
|
||||
var tipos = tiposVideoLer().filter(function (item) { return item.ativo !== false; });
|
||||
var tipo = tipos.filter(function (item) { return item.id === edicao.tipo_de_video; })[0];
|
||||
select.innerHTML = "";
|
||||
tipos.forEach(function (item) {
|
||||
var opcao = document.createElement("option");
|
||||
opcao.value = item.id;
|
||||
opcao.textContent = item.nome;
|
||||
select.appendChild(opcao);
|
||||
});
|
||||
if (!tipo) {
|
||||
var tipoDesconhecido = document.createElement("option");
|
||||
tipoDesconhecido.value = edicao.tipo_de_video;
|
||||
tipoDesconhecido.textContent = edicao.tipo_de_video;
|
||||
select.appendChild(tipoDesconhecido);
|
||||
}
|
||||
select.value = edicao.tipo_de_video;
|
||||
select.disabled = false;
|
||||
silenceState.tipoVideoId = edicao.tipo_de_video;
|
||||
silenceState.tipoVideo = tipo || null;
|
||||
info.textContent = "Tipo carregado da edição concluída mais recente.";
|
||||
} catch (erro) { info.textContent = "Não foi possível interpretar o tipo salvo: " + erro.message; }
|
||||
});
|
||||
}
|
||||
|
||||
function projetoSelecionarTipoVideo() {
|
||||
var tipo = tiposVideoLer().filter(function (item) { return item.id === document.getElementById("projetoTipoVideoSelect").value; })[0] || null;
|
||||
silenceState.tipoVideoId = tipo ? tipo.id : null;
|
||||
silenceState.tipoVideo = tipo;
|
||||
document.getElementById("projetoTipoVideoInfo").textContent = tipo ? tipo.nome + " · disponível para a edição." : "Nenhum tipo selecionado.";
|
||||
}
|
||||
|
||||
function projetoSalvar() {
|
||||
if (!projetoAtual) return;
|
||||
var faixas = Array.prototype.slice.call(document.querySelectorAll("[data-projeto-faixa]:checked")).map(function (item) { return item.getAttribute("data-projeto-faixa"); });
|
||||
var clipes = Array.prototype.slice.call(document.querySelectorAll("[data-projeto-clipe]:checked")).map(function (item) { return item.getAttribute("data-projeto-clipe"); });
|
||||
var pedido = path.join(os.tmpdir(), "pedido-projeto-edicao-" + Date.now() + ".json");
|
||||
fs.writeFileSync(pedido, JSON.stringify({
|
||||
nome: projetoAtual.nome, video_id: projetoAtual.id, faixas: faixas, clipes: clipes,
|
||||
}, null, 2), "utf-8");
|
||||
setBusy("btnProjetoSalvar", true);
|
||||
runStreamed(SCANNER_PYTHON_BIN, [REGISTRAR_PROJETO_SCRIPT, pedido, "--banco", ANALISES_DB_PATH],
|
||||
function (linha) { silenceLog(linha); },
|
||||
function (codigo, ultimoErro) {
|
||||
setBusy("btnProjetoSalvar", false);
|
||||
if (codigo !== 0) { showToast("err", ultimoErro || "Não foi possível salvar o projeto."); return; }
|
||||
document.getElementById("projetoStatus").textContent = "Projeto salvo no banco";
|
||||
showToast("ok", "Projeto salvo com " + faixas.length + " faixa(s) e " + clipes.length + " clipe(s).");
|
||||
});
|
||||
}
|
||||
|
||||
function projetoContinuarEdicao() {
|
||||
if (!projetoAtual) return;
|
||||
switchTab("silence");
|
||||
silenceListSequences();
|
||||
}
|
||||
+98
-131
@@ -10,9 +10,15 @@
|
||||
<div class="panel-shell">
|
||||
|
||||
<header class="panel-header">
|
||||
<div class="brand-mark" aria-hidden="true"><span>M</span></div>
|
||||
<div class="brand-mark" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" role="img" aria-label="Lâmpada mágica">
|
||||
<path d="M8.1 14.2c-1.1-1-1.8-2.4-1.8-4.1 0-3.1 2.5-5.6 5.7-5.6s5.7 2.5 5.7 5.6c0 1.7-.7 3.1-1.8 4.1-.8.8-1.2 1.4-1.3 2.3H9.4c-.1-.9-.5-1.5-1.3-2.3Z" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/>
|
||||
<path d="M9.6 19h4.8M10.5 21h3M4.2 5.8l-1.3-1.3M19.8 5.8l1.3-1.3M12 2.5V1M4.2 10.1H2.5M21.5 10.1h-1.7" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>
|
||||
<path d="m12 7.1.6 1.7 1.7.6-1.7.6-.6 1.7-.6-1.7-1.7-.6 1.7-.6.6-1.7Z" fill="currentColor" stroke="none"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="brand-copy">
|
||||
<h1>MCP for Adobe Premiere Pro</h1>
|
||||
<h1>Jhonny Marrone</h1>
|
||||
<p>Conexão local com o Premiere</p>
|
||||
</div>
|
||||
<div class="header-status" id="headerStatus" data-state="waiting" title="Status da conexão">
|
||||
@@ -22,9 +28,15 @@
|
||||
</header>
|
||||
|
||||
<nav class="tab-bar" role="tablist" aria-label="Painel">
|
||||
<button class="tab-button active" id="tabBtnScanner" role="tab" aria-selected="true" aria-controls="tabScanner" onclick="switchTab('scanner')" type="button">
|
||||
<button class="tab-button active" id="tabBtnProjeto" role="tab" aria-selected="true" aria-controls="tabProjeto" onclick="switchTab('projeto')" type="button">
|
||||
<span class="tab-icon" aria-hidden="true">▣</span>Projeto
|
||||
</button>
|
||||
<button class="tab-button" id="tabBtnScanner" role="tab" aria-selected="false" aria-controls="tabScanner" onclick="switchTab('scanner')" type="button">
|
||||
<span class="tab-icon" aria-hidden="true">◉</span>Scanner
|
||||
</button>
|
||||
<button class="tab-button" id="tabBtnTrilhas" role="tab" aria-selected="false" aria-controls="tabTrilhas" onclick="switchTab('trilhas')" type="button">
|
||||
<span class="tab-icon" aria-hidden="true">♫</span>Trilhas
|
||||
</button>
|
||||
<button class="tab-button" id="tabBtnRefinar" role="tab" aria-selected="false" aria-controls="tabRefinar" onclick="switchTab('refinar')" type="button">
|
||||
<span class="tab-icon" aria-hidden="true">◌</span>Refinar
|
||||
</button>
|
||||
@@ -82,23 +94,56 @@
|
||||
<button type="button" class="toast-close" onclick="hideToast()" aria-label="Fechar aviso">✕</button>
|
||||
</div>
|
||||
|
||||
<!-- ================================ PROJETO ================================ -->
|
||||
<div class="tab-panel" id="tabProjeto" role="tabpanel" aria-labelledby="tabBtnProjeto">
|
||||
<p class="tab-intro">Um projeto contém uma única sequência e pode usar várias faixas, clipes e arquivos de mídia.</p>
|
||||
<section class="card">
|
||||
<div class="card-head"><h2>Sequência do projeto</h2><span class="card-kicker" id="projetoStatus">Aguardando seleção</span></div>
|
||||
<button class="button button-primary" id="btnProjetoCarregar" onclick="projetoListarSequencias()" type="button">Carregar sequências do Premiere</button>
|
||||
<label class="field-label" for="projetoSequenciaSelect">Sequência / timeline</label>
|
||||
<select id="projetoSequenciaSelect" class="select-field" onchange="projetoSelecionarSequencia()" disabled>
|
||||
<option value="">Nenhuma sequência carregada</option>
|
||||
</select>
|
||||
<p class="field-help" id="projetoInfo">Escolha a sequência que será a raiz deste projeto.</p>
|
||||
</section>
|
||||
<section class="card" id="projetoTimelineCard" hidden>
|
||||
<div class="card-head"><h2>Informações da timeline</h2><span class="card-kicker" id="projetoTimelineStatus">—</span></div>
|
||||
<dl class="kv">
|
||||
<div><dt>Duração</dt><dd id="projetoDuracao">—</dd></div>
|
||||
<div><dt>Resolução</dt><dd id="projetoResolucao">—</dd></div>
|
||||
<div><dt>Faixas</dt><dd id="projetoTotalFaixas">—</dd></div>
|
||||
<div><dt>Clipes</dt><dd id="projetoTotalClipes">—</dd></div>
|
||||
</dl>
|
||||
<label class="field-label" for="projetoTipoVideoSelect">Tipo de vídeo</label>
|
||||
<select id="projetoTipoVideoSelect" class="select-field" onchange="projetoSelecionarTipoVideo()" disabled>
|
||||
<option value="">Carregando tipo de vídeo…</option>
|
||||
</select>
|
||||
<p class="field-help" id="projetoTipoVideoInfo">O tipo definido na edição concluída será carregado do banco.</p>
|
||||
<h3>Faixas e clipes utilizados</h3>
|
||||
<div id="projetoFaixas" class="scanner-track-list"></div>
|
||||
<div class="button-row">
|
||||
<button class="button button-primary" id="btnProjetoSalvar" onclick="projetoSalvar()" type="button" disabled>Salvar projeto no banco</button>
|
||||
<button class="button button-primary" id="btnProjetoContinuar" onclick="projetoContinuarEdicao()" type="button" disabled>Continuar para editar vídeo</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- ============================= EDITAR VÍDEO ============================= -->
|
||||
<div class="tab-panel" id="tabSilence" role="tabpanel" aria-labelledby="tabBtnSilence" hidden>
|
||||
|
||||
<p class="tab-intro">A edição usa a análise já concluída pelo Scanner. Revise os dados, gere o plano JSON e aplique-o na timeline.</p>
|
||||
<p class="tab-intro">Os dados analisados e o tipo de vídeo definidos no Projeto já estão disponíveis para esta edição.</p>
|
||||
|
||||
<section class="card scanner-analysis-card" id="scannerAnalysisCard">
|
||||
<div class="card-head"><h2>Análise do Scanner</h2><span class="step-chip" id="scannerAnalysisStatus">Não carregada</span></div>
|
||||
<p class="field-help" id="scannerAnalysisInfo">Carregue a análise mais recente do Scanner antes de gerar o plano de edição.</p>
|
||||
<p class="field-help" id="scannerAnalysisInfo">A análise mais recente da sequência será carregada automaticamente.</p>
|
||||
<ul class="scanner-analysis-checks" id="scannerAnalysisChecks" aria-label="Componentes carregados da análise">
|
||||
<li id="scannerCheckTranscription" data-state="pending"><span class="check-dot" aria-hidden="true"></span><span>Transcrição</span><em>Aguardando</em></li>
|
||||
<li id="scannerCheckDiarization" data-state="pending"><span class="check-dot" aria-hidden="true"></span><span>Detecção de falantes</span><em>Aguardando</em></li>
|
||||
<li id="scannerCheckVoiceFeatures" data-state="pending"><span class="check-dot" aria-hidden="true"></span><span>Métrica de voz</span><em>Aguardando</em></li>
|
||||
</ul>
|
||||
<button id="btnLoadScannerAnalysis" class="button button-primary" onclick="silenceLoadScannerAnalysis()" type="button">Carregar análise do Scanner</button>
|
||||
</section>
|
||||
|
||||
<section class="step" id="step1" data-state="locked">
|
||||
<section class="step" id="step1" data-state="locked" hidden>
|
||||
<div class="step-head">
|
||||
<span class="step-number" aria-hidden="true">1</span>
|
||||
<div class="step-title">
|
||||
@@ -116,127 +161,26 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="step editar-video-legado" id="step2" data-state="locked">
|
||||
<section class="step" id="step2" data-state="locked">
|
||||
<div class="step-head">
|
||||
<span class="step-number" aria-hidden="true">2</span>
|
||||
<div class="step-title">
|
||||
<h2>Transcrever a fala</h2>
|
||||
<p>Gera o texto com marcação de tempo usando o Whisper local.</p>
|
||||
<h2>Revisar plano de edição</h2>
|
||||
<p>Carrega do banco o plano criado para esta sequência e mostra as decisões antes da aplicação.</p>
|
||||
</div>
|
||||
<span class="step-chip" id="step2Chip">Bloqueado</span>
|
||||
<span class="step-chip" id="step2Chip">Aguardando plano</span>
|
||||
</div>
|
||||
<div class="step-body">
|
||||
|
||||
<div class="callout callout-info" id="silenceCachedSection" hidden>
|
||||
<strong>Este vídeo já foi transcrito antes</strong>
|
||||
<p>Reaproveite e pule direto para o plano de cortes — leva segundos em vez de minutos.</p>
|
||||
<label class="field-label" for="silenceCachedSelect">Transcrição salva</label>
|
||||
<select id="silenceCachedSelect" class="select-field"></select>
|
||||
<button id="btnUseCachedTranscript" class="button button-primary" onclick="silenceUseCachedTranscript()" type="button">Usar esta transcrição</button>
|
||||
<p class="field-help" id="planoResumoEdicao">O plano editorial pendente será carregado automaticamente da sequência selecionada.</p>
|
||||
<button id="btnBuildPlan" class="button button-primary" onclick="carregarPlanoEditorialSalvo()" type="button" disabled>Carregar plano salvo</button>
|
||||
<label class="field-label" for="editorialPlanIdInput">Ou escolher um plano específico da lista</label>
|
||||
<p class="field-help">Use quando o plano não está associado a esta sequência no banco (ex.: vídeo de outro projeto/pasta) — cada item mostra data/hora e a intenção que a skill registrou, sem precisar adivinhar o número do id.</p>
|
||||
<div class="file-field">
|
||||
<select id="editorialPlanIdInput"><option value="">Carregando planos…</option></select>
|
||||
<button type="button" class="button button-ghost" id="btnRefreshPlanList" onclick="carregarListaDePlanosEditoriais()" title="Atualizar lista">↻</button>
|
||||
<button type="button" class="button button-ghost" id="btnLoadPlanById" onclick="carregarPlanoEditorialPorId()">Carregar plano</button>
|
||||
</div>
|
||||
|
||||
<!-- LEGADO comentado em 2026-09-09: transcrição/diarização/métricas de voz
|
||||
feitas aqui duplicavam o que a aba Scanner já faz e persiste no banco.
|
||||
Funções JS correspondentes (silenceTranscribe, silenceDiarizeSpeakers,
|
||||
silenceComputeVoiceFeatures) comentadas em main.js.
|
||||
<div class="field-grid">
|
||||
<div>
|
||||
<label class="field-label" for="silenceModel">Modelo</label>
|
||||
<select id="silenceModel" class="select-field">
|
||||
<option value="small" selected>small (recomendado)</option>
|
||||
<option value="base">base (mais rápido)</option>
|
||||
<option value="medium">medium (mais preciso)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<span class="field-label">Idioma</span>
|
||||
<p class="field-static" id="silenceLanguageInfo">Português</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="checkbox-field">
|
||||
<input type="checkbox" id="silenceDiarize">
|
||||
<span>Detectar quem fala logo após transcrever</span>
|
||||
</label>
|
||||
|
||||
<label class="checkbox-field">
|
||||
<input type="checkbox" id="silenceVoiceFeaturesAuto">
|
||||
<span>Calcular métricas de voz logo após transcrever</span>
|
||||
</label>
|
||||
-->
|
||||
|
||||
<div class="speaker-config" id="silenceSpeakerConfig" hidden>
|
||||
<div class="speaker-config-head">
|
||||
<strong>Falantes detectados</strong>
|
||||
<span id="silenceSpeakerCount"></span>
|
||||
</div>
|
||||
<p class="field-help">Dê um nome a cada falante e desative quem não deve aparecer no vídeo. A decisão será enviada no JSON para a IA.</p>
|
||||
<div id="silenceSpeakerList"></div>
|
||||
</div>
|
||||
|
||||
<div class="mini-action" id="silenceRemovalRow">
|
||||
<div>
|
||||
<label class="checkbox-field" style="border:none;padding:0;background:none">
|
||||
<input type="checkbox" id="silenceRemovalEnabled">
|
||||
<span>Remoção de silêncios</span>
|
||||
</label>
|
||||
<span>Só grava a preferência no JSON — não corta nada aqui. A IA decide os cortes usando esse valor.</span>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:6px;flex:0 0 auto">
|
||||
<label class="field-label" for="silenceRemovalMinDuration" style="margin:0">Mín. (s)</label>
|
||||
<input type="number" id="silenceRemovalMinDuration" class="select-field" style="width:64px" step="0.1" min="0" value="0.8">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- LEGADO comentado em 2026-09-09 (ver nota acima).
|
||||
<button id="btnTranscribe" class="button button-primary" onclick="silenceTranscribe()" type="button" disabled>Transcrever</button>
|
||||
<p class="field-help" id="silenceCacheInfo"></p>
|
||||
-->
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- LEGADO comentado em 2026-09-09: "Refazer" chamava silenceDiarizeSpeakers()
|
||||
e silenceComputeVoiceFeatures(), agora comentadas em main.js — a aba
|
||||
Refinar já cobre revisão de falantes com dados vindos do Scanner.
|
||||
<section class="step step-optional" id="step3" data-state="locked">
|
||||
<div class="step-head">
|
||||
<span class="step-number" aria-hidden="true">3</span>
|
||||
<div class="step-title">
|
||||
<h2>Revisar falantes e áudio <span class="badge-optional">opcional</span></h2>
|
||||
<p>Confira os resultados processados e ajuste os dados antes de gerar o JSON.</p>
|
||||
</div>
|
||||
<span class="step-chip" id="step3Chip">Bloqueado</span>
|
||||
</div>
|
||||
<div class="step-body">
|
||||
<div class="mini-action">
|
||||
<div>
|
||||
<strong>Detecção de falantes</strong>
|
||||
<span id="silenceDiarizeInfo">Separa os falantes por trecho. Precisa do token Hugging Face.</span>
|
||||
</div>
|
||||
<button id="btnDiarize" class="button" onclick="silenceDiarizeSpeakers()" type="button" disabled>Refazer</button>
|
||||
</div>
|
||||
<div class="mini-action">
|
||||
<div>
|
||||
<strong>Métricas de voz</strong>
|
||||
<span id="silenceVoiceFeaturesInfo">Pitch, energia, velocidade de fala e pausas de cada trecho.</span>
|
||||
</div>
|
||||
<button id="btnVoiceFeatures" class="button" onclick="silenceComputeVoiceFeatures()" type="button" disabled>Refazer</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
-->
|
||||
|
||||
<section class="step" id="step4" data-state="locked">
|
||||
<div class="step-head">
|
||||
<span class="step-number" aria-hidden="true">4</span>
|
||||
<div class="step-title">
|
||||
<h2>Gerar arquivo JSON</h2>
|
||||
<p>Reúne transcrição, tempos, locutores, métricas de voz e suas configurações de edição. Nada é cortado — o arquivo vai para um agente de IA externo analisar.</p>
|
||||
</div>
|
||||
<span class="step-chip" id="step4Chip">Bloqueado</span>
|
||||
</div>
|
||||
<div class="step-body">
|
||||
<button id="btnBuildPlan" class="button button-primary" onclick="silenceGenerateJson()" type="button" disabled>Gerar arquivo JSON</button>
|
||||
<p class="field-help" id="editorialPlanIdDetalhe"></p>
|
||||
<dl class="kv" id="silenceJsonCard" hidden>
|
||||
<div><dt>Trechos</dt><dd id="jsonSegmentCount">—</dd></div>
|
||||
<div><dt>Arquivo</dt><dd class="kv-path" id="jsonOutputPath">—</dd></div>
|
||||
@@ -249,19 +193,20 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="step" id="step5" data-state="locked">
|
||||
<section class="step" id="step3" data-state="locked">
|
||||
<div class="step-head">
|
||||
<span class="step-number" aria-hidden="true">5</span>
|
||||
<span class="step-number" aria-hidden="true">3</span>
|
||||
<div class="step-title">
|
||||
<h2>Aplicar na timeline</h2>
|
||||
<p>Selecione o plano de edição que a IA devolveu (cortes, zooms, textos, marcadores) e execute na sequência.</p>
|
||||
</div>
|
||||
<span class="step-chip" id="step5Chip">Bloqueado</span>
|
||||
<span class="step-chip" id="step3Chip">Bloqueado</span>
|
||||
</div>
|
||||
<div class="step-body">
|
||||
<label class="field-label" for="editorialPlanJson">JSON de ações devolvido pela IA</label>
|
||||
<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>
|
||||
<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='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">
|
||||
@@ -271,6 +216,8 @@
|
||||
<strong>Sem desfazer</strong>
|
||||
<p>Esta versão do Premiere não expõe undo para o painel. Um backup da sequência é criado automaticamente antes de aplicar.</p>
|
||||
</div>
|
||||
<label class="checkbox-field"><input id="editorialIncrementalMode" type="checkbox"><span>Usar montagem incremental (teste)</span></label>
|
||||
<p class="field-help">Marcado: cria uma sequência nova, limpa somente a cópia e monta nela os trechos preservados. A sequência original não é cortada.</p>
|
||||
<button id="btnApplyEditorialActions" class="button button-danger" onclick="applyEditorialActions()" type="button" disabled>Aplicar plano na timeline</button>
|
||||
</div>
|
||||
</section>
|
||||
@@ -358,20 +305,39 @@
|
||||
<div class="callout callout-info" id="scannerReportCard" hidden><strong>Relatório pronto</strong><p id="scannerReportPath"></p><div class="button-row"><button class="button button-ghost" onclick="scannerOpenReport()" type="button">Abrir JSON</button><button class="button button-ghost" onclick="scannerCopyReport()" type="button">Copiar conteúdo</button></div></div>
|
||||
</section>
|
||||
</div>
|
||||
<!-- =============================== TRILHAS =============================== -->
|
||||
<div class="tab-panel" id="tabTrilhas" role="tabpanel" aria-labelledby="tabBtnTrilhas" hidden>
|
||||
<p class="tab-intro">Organize e analise músicas instrumentais para usar como fundo nos projetos de vídeo.</p>
|
||||
<section class="card">
|
||||
<div class="card-head"><h2>Pasta de trilhas musicais</h2><span class="card-kicker">Catálogo local</span></div>
|
||||
<div class="file-field">
|
||||
<input type="text" id="trilhasPasta" spellcheck="false" autocomplete="off" placeholder="Selecione uma pasta de músicas" oninput="trilhasAtualizarEstado()">
|
||||
<button type="button" class="button button-ghost" onclick="trilhasSelecionarPasta()">Selecionar…</button>
|
||||
</div>
|
||||
<p class="field-help" id="trilhasPastaStatus">O caminho será salvo no banco para reutilizar esta pasta depois.</p>
|
||||
<p class="field-help">A análise usa os modelos Essentia instalados internamente pela aplicação. Você não precisa localizar nem selecionar essa pasta.</p>
|
||||
<button class="button button-primary" id="btnTrilhasAnalisar" onclick="trilhasAnalisar()" type="button" disabled>Iniciar monitoramento</button>
|
||||
</section>
|
||||
<section class="card" id="trilhasProgressoCard" hidden>
|
||||
<div class="card-head"><h2>Progresso da análise</h2><span class="card-kicker" id="trilhasContagem">0 trilhas</span></div>
|
||||
<div id="trilhasLista" class="trilhas-lista" role="list" aria-live="polite"></div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- =============================== REFINAR ============================== -->
|
||||
<div class="tab-panel" id="tabRefinar" role="tabpanel" aria-labelledby="tabBtnRefinar" hidden>
|
||||
<p class="tab-intro">Confirme quem são os falantes antes de editar. O áudio reproduz somente os trechos identificados de cada pessoa.</p>
|
||||
<p class="tab-intro">Confirme quem são os falantes antes de editar. O preview reproduz somente os trechos identificados de cada pessoa.</p>
|
||||
<section class="card scanner-analysis-card">
|
||||
<div class="card-head"><h2>Falantes da análise</h2><span class="step-chip" id="refinarStatus">Aguardando análise</span></div>
|
||||
<p class="field-help" id="refinarInfo">Carregue a análise mais recente do Scanner para consultar os falantes detectados.</p>
|
||||
<div class="refinar-actions">
|
||||
<button id="btnRefinarLoad" class="button button-primary" onclick="refinarCarregarAnalise()" type="button">Carregar análise do Scanner</button>
|
||||
<button id="btnRefinarStopAudio" class="button" onclick="stopSpeakerAudio()" type="button">Parar áudio</button>
|
||||
<button id="btnRefinarLoad" class="button button-ghost" onclick="refinarCarregarAnalise()" type="button">Recarregar análise do Scanner</button>
|
||||
<button id="btnRefinarStopAudio" class="button" onclick="stopSpeakerAudio()" type="button">Parar preview</button>
|
||||
</div>
|
||||
</section>
|
||||
<section class="card" id="refinarSpeakerCard">
|
||||
<div class="card-head"><h2>Identificar falantes</h2><span class="card-kicker" id="refinarSpeakerCount">Nenhum falante carregado</span></div>
|
||||
<p class="field-help">Use o botão de reprodução para reconhecer a voz, informe um nome e escolha quem poderá aparecer no vídeo. A identificação será usada ao gerar o JSON de edição.</p>
|
||||
<p class="field-help">Use o botão de áudio para reconhecer a voz de cada falante, informe um nome e escolha quem poderá aparecer no vídeo. A identificação será usada ao gerar o JSON de edição.</p>
|
||||
<div class="callout callout-info" id="refinarSpeakerEmpty"><strong>Nenhum falante identificado</strong><p>A análise carregada não possui diarização. Execute a diarização no Scanner para liberar esta etapa.</p></div>
|
||||
<div class="speaker-config" id="refinarSpeakerConfig" hidden>
|
||||
<div id="refinarSpeakerList"></div>
|
||||
@@ -775,6 +741,7 @@
|
||||
|
||||
<script charset="utf-8" src="CSInterface.js"></script>
|
||||
<script charset="utf-8" src="updater.cjs"></script>
|
||||
<script charset="utf-8" src="abas/projeto/projeto.js"></script>
|
||||
<script charset="utf-8" src="main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+692
-123
File diff suppressed because it is too large
Load Diff
@@ -81,6 +81,7 @@ h1, h2, h3 { margin: 0; }
|
||||
font-weight: 750;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
.brand-mark svg { width: 21px; height: 21px; }
|
||||
|
||||
.brand-copy { min-width: 0; flex: 1; }
|
||||
.brand-copy h1 { overflow: hidden; font-size: 12px; line-height: 1.25; font-weight: 650; text-overflow: ellipsis; white-space: nowrap; }
|
||||
@@ -251,6 +252,35 @@ h1, h2, h3 { margin: 0; }
|
||||
.task-dock[data-result="ok"] .progress-fill { background: var(--green); }
|
||||
.task-dock[data-result="err"] .progress-fill { background: var(--red); }
|
||||
|
||||
/* --------------------- Catálogo de trilhas ------------------------------- */
|
||||
.trilhas-lista { display: grid; gap: 7px; margin-top: 10px; }
|
||||
.trilha-item { display: flex; align-items: flex-start; gap: 9px; padding: 9px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface-deep); }
|
||||
.trilha-check { display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto; width: 16px; height: 16px; margin-top: 1px; border: 1px solid var(--text-muted); border-radius: 50%; color: #10240f; font-size: 10px; font-weight: 800; }
|
||||
.trilha-info { min-width: 0; flex: 1; }
|
||||
.trilha-info strong { display: block; overflow: hidden; color: var(--text); font-size: 10.5px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.trilha-info > span { display: block; margin: 3px 0 6px; overflow: hidden; color: var(--text-muted); font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.trilha-item .progress-track { height: 4px; }
|
||||
.trilha-item[data-state="analisando"] { border-color: rgba(155, 108, 255, .45); }
|
||||
.trilha-item[data-state="analisando"] .progress-track[data-mode="indeterminate"] .progress-fill { background: var(--violet); }
|
||||
.trilha-item[data-state="concluida"] { border-color: rgba(112, 217, 135, .35); }
|
||||
.trilha-item[data-state="concluida"] .trilha-check { border-color: var(--green); background: var(--green); }
|
||||
.trilha-item[data-state="concluida"] .trilha-check::after { content: "✓"; }
|
||||
.trilha-item[data-state="concluida"] .trilha-info > span { color: var(--green); }
|
||||
.trilha-item[data-state="concluida"] .progress-fill { background: var(--green); }
|
||||
.trilha-item[data-state="erro"] { border-color: rgba(255, 101, 101, .35); }
|
||||
.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; }
|
||||
.task-pct { flex: 0 0 auto; color: var(--text); font: 600 10px var(--font-mono); }
|
||||
|
||||
@@ -11,3 +11,38 @@ assert result.valid
|
||||
```
|
||||
|
||||
As integrações reais devem implementar os protocolos em `content_analyzer.py`, `decision_engine.py`, `edit_plan.py`, `plan_applicator.py`, `validator.py` e `persistence.py`. O pacote não cria dependências externas por padrão.
|
||||
|
||||
## Análise de músicas instrumentais
|
||||
|
||||
A análise musical local fica separada da análise de voz e segue este fluxo:
|
||||
|
||||
```text
|
||||
caso de uso da engine
|
||||
↓
|
||||
AnalisadorDeMusica (contrato)
|
||||
↓
|
||||
AnalisadorDeMusicaInstrumentalEssentia
|
||||
↓
|
||||
ResultadoDaAnaliseMusical
|
||||
```
|
||||
|
||||
Arquivos principais:
|
||||
|
||||
- `integracoes/audio/contratos.py`: contrato substituível do analisador;
|
||||
- `integracoes/audio/modelos_de_analise_musical.py`: resultado interno em PT-BR;
|
||||
- `integracoes/audio/provider_de_analise_musical_essentia.py`: adaptador local do Essentia;
|
||||
- `testes/test_analisador_de_musica_essentia.py`: testes unitários sem instalar Essentia.
|
||||
|
||||
Exemplo de uso:
|
||||
|
||||
```python
|
||||
from engine.integracoes.audio import AnalisadorDeMusicaInstrumentalEssentia
|
||||
|
||||
resultado = AnalisadorDeMusicaInstrumentalEssentia().analisar("musica.wav")
|
||||
print(resultado.generos, resultado.humores, resultado.batidas_por_minuto)
|
||||
```
|
||||
|
||||
A dependência é carregada sob demanda. Para habilitar a análise, instale as
|
||||
dependências de `requirements-audio.txt`. O adaptador não persiste o resultado
|
||||
nem gera a descrição narrativa; essas responsabilidades ficarão no caso de
|
||||
uso e na apresentação, respectivamente.
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Executa a análise em lote de uma pasta de trilhas e emite progresso JSON."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import time
|
||||
|
||||
CAMINHO_DO_CODIGO = Path(__file__).resolve().parent.parent
|
||||
if str(CAMINHO_DO_CODIGO) not in sys.path:
|
||||
sys.path.insert(0, str(CAMINHO_DO_CODIGO))
|
||||
|
||||
from engine.integracoes.audio import AnalisadorDeMusicaInstrumentalEssentia, ClassificadorDeMusicaEssentia
|
||||
from engine.persistencia.conexao import abrir_banco
|
||||
from engine.persistencia.repositorio_de_trilhas import RepositorioDeTrilhasSQLite
|
||||
|
||||
EXTENSOES_DE_AUDIO = {".wav", ".mp3", ".flac", ".m4a", ".aiff", ".ogg"}
|
||||
|
||||
|
||||
def emitir(evento: str, **dados: object) -> None:
|
||||
"""Emite uma linha de progresso consumível pelo painel CEP."""
|
||||
print("@@PROGRESS " + json.dumps({"evento": evento, **dados}, ensure_ascii=False), flush=True)
|
||||
|
||||
|
||||
def executar(pasta: Path, banco: Path, diretorio_dos_modelos: Path | None, apenas_listar: bool = False) -> int:
|
||||
"""Atualiza o catálogo e, opcionalmente, analisa as trilhas pendentes."""
|
||||
arquivos = tuple(sorted(
|
||||
arquivo for arquivo in pasta.rglob("*")
|
||||
if arquivo.is_file() and arquivo.suffix.lower() in EXTENSOES_DE_AUDIO
|
||||
))
|
||||
conexao = abrir_banco(banco)
|
||||
try:
|
||||
repositorio = RepositorioDeTrilhasSQLite(conexao)
|
||||
trilhas = repositorio.registrar_pasta_e_arquivos(pasta, arquivos)
|
||||
repositorio.reabrir_analises_sem_etiquetas()
|
||||
trilhas = repositorio.registrar_pasta_e_arquivos(pasta, arquivos)
|
||||
if apenas_listar:
|
||||
emitir("catalogo_atualizado", trilhas=[
|
||||
{"nome": trilha.nome_do_arquivo, "caminho": str(trilha.caminho), "status": trilha.status}
|
||||
for trilha in trilhas
|
||||
])
|
||||
return 0
|
||||
pendentes = tuple(trilha for trilha in trilhas if trilha.status != "concluida")
|
||||
emitir(
|
||||
"lote_iniciado",
|
||||
total=len(pendentes),
|
||||
pasta=str(pasta),
|
||||
nomes=[trilha.nome_do_arquivo for trilha in pendentes],
|
||||
trilhas=[{"nome": trilha.nome_do_arquivo, "caminho": str(trilha.caminho), "status": trilha.status} for trilha in trilhas],
|
||||
)
|
||||
if not pendentes:
|
||||
emitir("lote_concluido", total=0, percentual=100)
|
||||
return 0
|
||||
classificador = (
|
||||
ClassificadorDeMusicaEssentia(diretorio_dos_modelos)
|
||||
if diretorio_dos_modelos and diretorio_dos_modelos.is_dir()
|
||||
else None
|
||||
)
|
||||
analisador = AnalisadorDeMusicaInstrumentalEssentia(classificador=classificador)
|
||||
for ordem, trilha in enumerate(pendentes, start=1):
|
||||
repositorio.iniciar_analise(trilha.identificador)
|
||||
emitir("trilha_iniciada", nome=trilha.nome_do_arquivo, caminho=str(trilha.caminho), indice=ordem, total=len(trilhas), percentual=(ordem - 1) * 100 / max(1, len(trilhas)))
|
||||
try:
|
||||
resultado = analisador.analisar(trilha.caminho)
|
||||
repositorio.registrar_analise(trilha, resultado)
|
||||
emitir("trilha_concluida", nome=trilha.nome_do_arquivo, caminho=str(trilha.caminho), indice=ordem, total=len(trilhas), percentual=ordem * 100 / max(1, len(trilhas)))
|
||||
except (OSError, RuntimeError, ValueError) as erro:
|
||||
repositorio.registrar_erro(trilha.identificador, str(erro))
|
||||
emitir("trilha_com_erro", nome=trilha.nome_do_arquivo, caminho=str(trilha.caminho), indice=ordem, total=len(trilhas), erro=str(erro), percentual=ordem * 100 / max(1, len(trilhas)))
|
||||
emitir("lote_concluido", total=len(trilhas), percentual=100)
|
||||
return 0
|
||||
finally:
|
||||
conexao.close()
|
||||
|
||||
|
||||
def executar_continuamente(pasta: Path, banco: Path, diretorio_dos_modelos: Path | None, intervalo: float) -> int:
|
||||
"""Mantém a pasta monitorada e processa novas trilhas em baixa frequência."""
|
||||
while True:
|
||||
executar(pasta, banco, diretorio_dos_modelos)
|
||||
emitir("worker_aguardando", intervalo=intervalo)
|
||||
time.sleep(intervalo)
|
||||
|
||||
|
||||
def principal() -> None:
|
||||
"""Lê argumentos e executa o lote de análise."""
|
||||
argumentos = argparse.ArgumentParser()
|
||||
argumentos.add_argument("pasta", type=Path)
|
||||
argumentos.add_argument("--banco", type=Path, default=Path(".jhonny/analises.db"))
|
||||
argumentos.add_argument("--modelos", type=Path, default=None)
|
||||
argumentos.add_argument("--listar", action="store_true")
|
||||
argumentos.add_argument("--continuo", action="store_true")
|
||||
argumentos.add_argument("--intervalo", type=float, default=20.0)
|
||||
opcoes = argumentos.parse_args()
|
||||
try:
|
||||
if opcoes.continuo:
|
||||
raise SystemExit(executar_continuamente(opcoes.pasta, opcoes.banco, opcoes.modelos, opcoes.intervalo))
|
||||
raise SystemExit(executar(opcoes.pasta, opcoes.banco, opcoes.modelos, opcoes.listar))
|
||||
except (OSError, RuntimeError, ValueError) as erro:
|
||||
emitir("lote_com_erro", erro=str(erro))
|
||||
raise SystemExit(1) from erro
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
principal()
|
||||
@@ -0,0 +1,208 @@
|
||||
"""Executa o protótipo de montagem incremental a partir de um plano editorial.
|
||||
|
||||
O script lê um plano ``cut``/``hook`` já carregado pelo painel, calcula o
|
||||
complemento dos cortes e chama uma ferramenta experimental que clona a
|
||||
sequência ativa e monta os intervalos preservados na cópia.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from datetime import datetime
|
||||
import shutil
|
||||
|
||||
CAMINHO_DO_CODIGO = Path(__file__).resolve().parent.parent
|
||||
if str(CAMINHO_DO_CODIGO) not in sys.path:
|
||||
sys.path.insert(0, str(CAMINHO_DO_CODIGO))
|
||||
|
||||
from engine.editor.prototipo_montagem_incremental import AcaoDoPlano, PlanejadorDeMontagemIncremental
|
||||
from engine.integracoes.premiere.cliente_mcp import ClienteMCPPorStdio
|
||||
|
||||
|
||||
class ErroDeMontagemIncremental(RuntimeError):
|
||||
"""Indica que o plano não pôde ser preparado para a montagem experimental."""
|
||||
|
||||
|
||||
def carregar_plano(caminho: Path) -> dict[str, object]:
|
||||
"""Lê o plano editorial JSON fornecido pelo painel."""
|
||||
try:
|
||||
dados = json.loads(caminho.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as erro:
|
||||
raise ErroDeMontagemIncremental(f"Não foi possível ler o plano: {erro}") from erro
|
||||
if not isinstance(dados, dict) or not isinstance(dados.get("actions"), list):
|
||||
raise ErroDeMontagemIncremental("O plano precisa conter uma lista actions válida.")
|
||||
return dados
|
||||
|
||||
|
||||
def preparar_intervalos(
|
||||
plano: dict[str, object], duracao_da_midia: float
|
||||
) -> list[dict[str, object]]:
|
||||
"""Converte cortes e gancho em intervalos contíguos de montagem."""
|
||||
acoes = [
|
||||
AcaoDoPlano(
|
||||
tipo=str(acao["kind"]),
|
||||
inicio=float(acao["start"]),
|
||||
fim=float(acao["end"]),
|
||||
motivo=str(acao.get("reason", "")),
|
||||
)
|
||||
for acao in plano["actions"]
|
||||
if isinstance(acao, dict) and acao.get("kind") in {"cut", "hook"}
|
||||
]
|
||||
if not acoes:
|
||||
return [{
|
||||
"in_seconds": 0.0,
|
||||
"out_seconds": duracao_da_midia,
|
||||
"destination_seconds": 0.0,
|
||||
"label": "mídia completa",
|
||||
}]
|
||||
insercoes = PlanejadorDeMontagemIncremental(duracao_da_midia).planejar(acoes)
|
||||
return [
|
||||
{
|
||||
"in_seconds": insercao.origem.inicio,
|
||||
"out_seconds": insercao.origem.fim,
|
||||
"destination_seconds": insercao.destino_inicio,
|
||||
"label": insercao.tipo.value,
|
||||
}
|
||||
for insercao in insercoes
|
||||
]
|
||||
|
||||
|
||||
def executar(caminho_do_plano: Path) -> dict[str, object]:
|
||||
"""Clona a sequência ativa e monta o plano incremental na nova sequência."""
|
||||
plano = carregar_plano(caminho_do_plano)
|
||||
cliente = ClienteMCPPorStdio([resolver_node(), str(CAMINHO_DO_CODIGO / "dist" / "index.js")], tempo_limite=60)
|
||||
cliente.conectar()
|
||||
try:
|
||||
sequencia = cliente.chamar("get_active_sequence")
|
||||
if not isinstance(sequencia, dict) or not sequencia.get("id"):
|
||||
raise ErroDeMontagemIncremental("Nenhuma sequência ativa foi encontrada no Premiere.")
|
||||
clipe = _primeiro_clipe_de_video(sequencia)
|
||||
intervalos = preparar_intervalos(plano, float(sequencia["end"]))
|
||||
resultado = cliente.chamar(
|
||||
"assemble_incremental_edit_prototype",
|
||||
{
|
||||
"source_sequence_id": str(sequencia["id"]),
|
||||
"source_item_id": str(clipe["sourceProjectItemId"]),
|
||||
"target_name": _nome_da_sequencia(str(sequencia["name"])),
|
||||
"ranges": intervalos,
|
||||
"confirm_prototype": True,
|
||||
},
|
||||
)
|
||||
if plano.get("test_adjustment_layer") is True:
|
||||
alvo = next(
|
||||
(
|
||||
acao
|
||||
for acao in plano["actions"]
|
||||
if isinstance(acao, dict) and acao.get("kind") in {"hook", "zoom"}
|
||||
),
|
||||
None,
|
||||
)
|
||||
if alvo is None:
|
||||
raise ErroDeMontagemIncremental(
|
||||
"O teste da adjustment layer exige uma ação hook ou zoom no plano."
|
||||
)
|
||||
eh_gancho = alvo.get("kind") == "hook"
|
||||
inicio_do_gancho = 0.0 if eh_gancho else float(alvo["start"])
|
||||
fim_do_gancho = (
|
||||
float(alvo["end"]) - float(alvo["start"])
|
||||
if eh_gancho
|
||||
else float(alvo["end"])
|
||||
)
|
||||
faixa = cliente.chamar(
|
||||
"add_track",
|
||||
{"track_type": "video", "count": 1},
|
||||
)
|
||||
indice_da_faixa = int(faixa["totalTracks"]) - 1
|
||||
camada = cliente.chamar(
|
||||
"insert_adjustment_layer_range",
|
||||
{
|
||||
"item_id": "Adjustment Layer",
|
||||
"track_index": indice_da_faixa,
|
||||
"start_seconds": inicio_do_gancho,
|
||||
"end_seconds": fim_do_gancho,
|
||||
},
|
||||
)
|
||||
resultado["adjustment_layer"] = camada
|
||||
escala = float(alvo.get("params", {}).get("scale", 1.0)) if isinstance(alvo.get("params"), dict) else 1.0
|
||||
if escala <= 0:
|
||||
raise ErroDeMontagemIncremental("A escala do zoom precisa ser maior que zero.")
|
||||
recorte = max(0.0, min(49.0, 50.0 * (1.0 - (1.0 / escala))))
|
||||
resultado["crop"] = cliente.chamar(
|
||||
"crop_clip",
|
||||
{
|
||||
"node_id": str(camada["nodeId"]),
|
||||
"left": recorte,
|
||||
"right": recorte,
|
||||
"top": recorte,
|
||||
"bottom": recorte,
|
||||
"zoom": True,
|
||||
},
|
||||
)
|
||||
return {"ok": True, "plano_incremental": True, "resultado": resultado}
|
||||
finally:
|
||||
cliente.desconectar()
|
||||
|
||||
|
||||
def _primeiro_clipe_de_video(sequencia: dict[str, object]) -> dict[str, object]:
|
||||
"""Obtém a mídia do primeiro clipe da faixa de vídeo principal."""
|
||||
faixas = sequencia.get("videoTracks", [])
|
||||
if not isinstance(faixas, list) or not faixas or not isinstance(faixas[0], dict):
|
||||
raise ErroDeMontagemIncremental("A sequência ativa não possui faixa de vídeo.")
|
||||
clipes = faixas[0].get("clips", [])
|
||||
if not isinstance(clipes, list) or not clipes or not isinstance(clipes[0], dict):
|
||||
raise ErroDeMontagemIncremental("A faixa de vídeo principal está vazia.")
|
||||
if not clipes[0].get("sourceProjectItemId"):
|
||||
raise ErroDeMontagemIncremental("Não foi possível identificar a mídia do primeiro clipe.")
|
||||
return clipes[0]
|
||||
|
||||
|
||||
def _nome_da_sequencia(nome_da_origem: str) -> str:
|
||||
"""Cria um nome identificável e único para a sequência experimental."""
|
||||
carimbo = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
return f"PROTÓTIPO incremental - {nome_da_origem} - {carimbo}"[:255]
|
||||
|
||||
|
||||
def resolver_node() -> str:
|
||||
"""Resolve o Node mesmo quando o painel CEP possui um PATH reduzido."""
|
||||
caminho_configurado = os.environ.get("PREMIERE_NODE_BIN", "").strip()
|
||||
if caminho_configurado:
|
||||
if os.path.isfile(caminho_configurado) and os.access(caminho_configurado, os.X_OK):
|
||||
return caminho_configurado
|
||||
raise ErroDeMontagemIncremental(
|
||||
f"Node.js configurado em PREMIERE_NODE_BIN não é executável: {caminho_configurado}"
|
||||
)
|
||||
encontrado_no_path = shutil.which("node.exe" if os.name == "nt" else "node")
|
||||
if encontrado_no_path:
|
||||
return encontrado_no_path
|
||||
caminhos_conhecidos = (
|
||||
("C:/Program Files/nodejs/node.exe", "C:/Program Files/nodejs/nodejs.exe")
|
||||
if os.name == "nt"
|
||||
else ("/opt/homebrew/bin/node", "/usr/local/bin/node", "/usr/bin/node")
|
||||
)
|
||||
for caminho in caminhos_conhecidos:
|
||||
if os.path.isfile(caminho) and os.access(caminho, os.X_OK):
|
||||
return caminho
|
||||
raise ErroDeMontagemIncremental(
|
||||
"Node.js não encontrado. Instale o Node.js ou configure PREMIERE_NODE_BIN "
|
||||
"com o caminho completo do executável."
|
||||
)
|
||||
|
||||
|
||||
def principal() -> None:
|
||||
"""Executa o script e imprime um resultado JSON para o painel."""
|
||||
argumentos = argparse.ArgumentParser()
|
||||
argumentos.add_argument("plano", type=Path)
|
||||
opcoes = argumentos.parse_args()
|
||||
try:
|
||||
print(json.dumps(executar(opcoes.plano), ensure_ascii=False))
|
||||
except (ErroDeMontagemIncremental, OSError, ValueError) as erro:
|
||||
print(json.dumps({"ok": False, "erro": str(erro)}, ensure_ascii=False))
|
||||
raise SystemExit(1) from erro
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
principal()
|
||||
@@ -17,6 +17,7 @@ troca de sequência.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
@@ -32,8 +33,14 @@ from engine.editor import (
|
||||
AplicadorDePlanoDeEdicao,
|
||||
BackupDaSequencia,
|
||||
EscritaNoEditor,
|
||||
ErroDePlanoInvalido,
|
||||
LeitorDePlanoDeEdicao,
|
||||
MapeadorDeTempoDeOrigemParaTimeline,
|
||||
PlanoDeEdicao,
|
||||
ResultadoDaVerificacao,
|
||||
TipoDeAcao,
|
||||
ValidadorSemanticoDePlano,
|
||||
VerificadorDaAplicacao,
|
||||
)
|
||||
from engine.integracoes.premiere.cliente_mcp import ClienteMCPPorStdio
|
||||
from engine.integracoes.premiere.cliente_mcp_com_pausa import ClienteMCPComPausa
|
||||
@@ -47,6 +54,44 @@ CAMINHO_DO_SERVIDOR_MCP = CAMINHO_DO_CODIGO / "dist" / "index.js"
|
||||
CAMINHO_DO_BANCO = CAMINHO_DO_CODIGO.parent / ".jhonny" / "analises.db"
|
||||
|
||||
|
||||
def _garantir_backup_para_ganchos(plano: PlanoDeEdicao, backup_criado: bool) -> None:
|
||||
"""Bloqueia ganchos quando não há uma cópia recuperável da sequência."""
|
||||
if plano.acoes_do_tipo(TipoDeAcao.GANCHO) and not backup_criado:
|
||||
raise ErroDePlanoInvalido(
|
||||
"A ação hook não foi aplicada porque o Premiere não confirmou o backup da sequência."
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
or plano.acoes_do_tipo(TipoDeAcao.GANCHO)
|
||||
):
|
||||
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.
|
||||
|
||||
@@ -81,7 +126,14 @@ def resolver_caminho_do_node() -> str:
|
||||
)
|
||||
|
||||
|
||||
def _registrar_no_banco(caminho_do_plano: Path, resultado: object, sequencia: str) -> None:
|
||||
def _registrar_no_banco(
|
||||
caminho_do_plano: Path,
|
||||
resultado: object,
|
||||
sequencia: str,
|
||||
plano_id: int | None = None,
|
||||
caminho_do_banco: Path = CAMINHO_DO_BANCO,
|
||||
verificacao: ResultadoDaVerificacao | None = None,
|
||||
) -> None:
|
||||
"""Guarda no banco de análises o plano aplicado e o resultado da aplicação.
|
||||
|
||||
O registro é o único vestígio de como uma edição foi decidida: sem ele o
|
||||
@@ -103,27 +155,42 @@ def _registrar_no_banco(caminho_do_plano: Path, resultado: object, sequencia: st
|
||||
)
|
||||
|
||||
dados = json.loads(caminho_do_plano.read_text(encoding="utf-8"))
|
||||
repositorio = RepositorioDePlanos(abrir_banco(CAMINHO_DO_BANCO))
|
||||
plano_id = repositorio.registrar_plano(plano_de_dict(dados))
|
||||
repositorio = RepositorioDePlanos(abrir_banco(caminho_do_banco))
|
||||
if plano_id is None:
|
||||
plano_id = repositorio.registrar_plano(plano_de_dict(dados))
|
||||
aplicadas = sum(1 for item in resultado.resultados if item.sucesso)
|
||||
verificacao_aprovada = verificacao is None or verificacao.aprovada
|
||||
mensagem = verificacao.mensagem if verificacao is not None else None
|
||||
repositorio.registrar_aplicacao(
|
||||
plano_id,
|
||||
sucesso=resultado.todas_bem_sucedidas,
|
||||
sucesso=resultado.todas_bem_sucedidas and verificacao_aprovada,
|
||||
acoes_aplicadas=aplicadas,
|
||||
sequencia=sequencia,
|
||||
mensagem=mensagem,
|
||||
)
|
||||
except (OSError, ValueError, sqlite3.Error) as erro:
|
||||
print(f"Plano aplicado, mas não foi possível registrá-lo no banco: {erro}",
|
||||
file=sys.stderr)
|
||||
|
||||
|
||||
def executar(caminho_do_plano: Path) -> dict:
|
||||
def executar(
|
||||
caminho_do_plano: Path,
|
||||
plano_id: int | None = None,
|
||||
caminho_do_banco: Path = CAMINHO_DO_BANCO,
|
||||
exigir_validacao_semantica: bool = False,
|
||||
) -> dict:
|
||||
"""Lê o plano em ``caminho_do_plano`` e o aplica na sequência ativa do Premiere.
|
||||
|
||||
Devolve um dicionário pronto para virar JSON, com o resultado de cada
|
||||
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
|
||||
@@ -134,13 +201,13 @@ def executar(caminho_do_plano: Path) -> dict:
|
||||
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. Gancho é recusado sem backup porque combina mais de
|
||||
# uma mutação estrutural; as demais ações preservam o fluxo legado.
|
||||
sequencia_ativa = conversor_de_timeline.converter(acesso_ao_editor.obter_timeline_ativa())
|
||||
backup_ok = BackupDaSequencia(cliente).criar_backup(sequencia_ativa.identificador)
|
||||
_garantir_backup_para_ganchos(plano, backup_ok)
|
||||
print(
|
||||
"Backup da sequência criado." if backup_ok else "Não foi possível criar o backup automático; aplicando mesmo assim.",
|
||||
file=sys.stderr,
|
||||
@@ -153,12 +220,28 @@ def executar(caminho_do_plano: Path) -> dict:
|
||||
mapeador=MapeadorDeTempoDeOrigemParaTimeline(),
|
||||
)
|
||||
resultado = aplicador.aplicar(plano)
|
||||
timeline_depois = conversor_de_timeline.converter(
|
||||
acesso_ao_editor.obter_timeline_ativa()
|
||||
)
|
||||
verificacao = VerificadorDaAplicacao().verificar(
|
||||
sequencia_ativa, timeline_depois, plano
|
||||
)
|
||||
|
||||
_registrar_no_banco(caminho_do_plano, resultado, sequencia_ativa.nome)
|
||||
_registrar_no_banco(
|
||||
caminho_do_plano, resultado, sequencia_ativa.nome,
|
||||
plano_id=plano_id, caminho_do_banco=caminho_do_banco,
|
||||
verificacao=verificacao,
|
||||
)
|
||||
|
||||
return {
|
||||
"arquivo_de_origem": plano.arquivo_de_origem,
|
||||
"todas_bem_sucedidas": resultado.todas_bem_sucedidas,
|
||||
"todas_bem_sucedidas": resultado.todas_bem_sucedidas and verificacao.aprovada,
|
||||
"verificacao": {
|
||||
"aprovada": verificacao.aprovada,
|
||||
"mensagem": verificacao.mensagem,
|
||||
"divergencias": list(verificacao.divergencias),
|
||||
"avisos": list(verificacao.avisos),
|
||||
},
|
||||
"acoes": [
|
||||
{
|
||||
"tipo": item.acao.tipo.value,
|
||||
@@ -175,10 +258,27 @@ def executar(caminho_do_plano: Path) -> dict:
|
||||
|
||||
def main() -> None:
|
||||
"""Ponto de entrada da linha de comando."""
|
||||
if len(sys.argv) != 2:
|
||||
print("Uso: python aplicar_plano_de_edicao.py <caminho_do_plano.json>", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
resultado = executar(Path(sys.argv[1]))
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
argumentos = argparse.ArgumentParser()
|
||||
argumentos.add_argument("plano", type=Path)
|
||||
argumentos.add_argument("--plano-id", type=int)
|
||||
argumentos.add_argument("--banco", type=Path, default=CAMINHO_DO_BANCO)
|
||||
argumentos.add_argument("--exigir-validacao-semantica", action="store_true")
|
||||
opcoes = argumentos.parse_args()
|
||||
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,125 @@
|
||||
"""Entrada de linha de comando para carregar um plano salvo no SQLite."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
import sys
|
||||
|
||||
CAMINHO_DO_CODIGO = Path(__file__).resolve().parent.parent
|
||||
if str(CAMINHO_DO_CODIGO) not in sys.path:
|
||||
sys.path.insert(0, str(CAMINHO_DO_CODIGO))
|
||||
|
||||
from engine.persistencia.conexao import abrir_banco
|
||||
from engine.persistencia.repositorio_de_planos import RepositorioDePlanos
|
||||
|
||||
|
||||
def resolver_arquivo_de_origem(
|
||||
conexao: sqlite3.Connection,
|
||||
video_id: str | None,
|
||||
origem_informada: str,
|
||||
) -> str:
|
||||
"""Resolve a mídia física de um plano histórico associado a uma sequência.
|
||||
|
||||
Preserva a origem quando ela já identifica um clipe do vídeo. Quando o
|
||||
plano guardou o nome editorial da sequência, usa automaticamente o
|
||||
arquivo dos clipes somente se todas as ocorrências apontarem para um
|
||||
único nome-base de mídia. Em timelines com fontes distintas, não escolhe
|
||||
uma delas por suposição.
|
||||
"""
|
||||
if not video_id:
|
||||
return origem_informada
|
||||
|
||||
linhas = conexao.execute(
|
||||
"""SELECT nome, arquivo FROM clipes
|
||||
WHERE video_id = ? AND (trim(coalesce(nome, '')) <> ''
|
||||
OR trim(coalesce(arquivo, '')) <> '')""",
|
||||
(video_id,),
|
||||
).fetchall()
|
||||
alvo = Path(origem_informada.strip()).name.casefold()
|
||||
for linha in linhas:
|
||||
nome = str(linha["nome"] or "").strip().casefold()
|
||||
nome_base_do_arquivo = Path(str(linha["arquivo"] or "").strip()).name.casefold()
|
||||
if alvo and alvo in {nome, nome_base_do_arquivo}:
|
||||
return origem_informada
|
||||
|
||||
arquivos_por_nome_base: dict[str, str] = {}
|
||||
for linha in linhas:
|
||||
arquivo = str(linha["arquivo"] or "").strip()
|
||||
if arquivo:
|
||||
arquivos_por_nome_base.setdefault(Path(arquivo).name.casefold(), arquivo)
|
||||
if len(arquivos_por_nome_base) == 1:
|
||||
return next(iter(arquivos_por_nome_base.values()))
|
||||
return origem_informada
|
||||
|
||||
|
||||
def carregar(
|
||||
caminho_do_banco: Path,
|
||||
plano_id: int | None = None,
|
||||
video_id: str | None = None,
|
||||
) -> dict[str, object] | None:
|
||||
"""Carrega um plano existente, preferindo o mais recente do vídeo informado."""
|
||||
conexao = abrir_banco(caminho_do_banco)
|
||||
try:
|
||||
if plano_id is None:
|
||||
if not video_id:
|
||||
raise ValueError("Informe plano_id ou video_id para carregar um plano.")
|
||||
linha = conexao.execute(
|
||||
"""SELECT id FROM planos_de_edicao
|
||||
WHERE video_id = ? ORDER BY criado_em DESC, id DESC LIMIT 1""",
|
||||
(video_id,),
|
||||
).fetchone()
|
||||
if linha is None:
|
||||
return None
|
||||
plano_id = int(linha["id"])
|
||||
plano = RepositorioDePlanos(conexao).carregar_plano(plano_id)
|
||||
if plano is None:
|
||||
return None
|
||||
arquivo_de_origem = resolver_arquivo_de_origem(
|
||||
conexao,
|
||||
plano.video_id,
|
||||
plano.origem,
|
||||
)
|
||||
return {
|
||||
"plano_id": plano_id,
|
||||
"source": arquivo_de_origem,
|
||||
"actions": [
|
||||
{
|
||||
"kind": acao.tipo,
|
||||
"start": acao.inicio,
|
||||
"end": acao.fim,
|
||||
"reason": acao.motivo or "Decisão registrada no plano editorial.",
|
||||
**acao.parametros,
|
||||
}
|
||||
for acao in plano.acoes
|
||||
],
|
||||
"video_id": plano.video_id,
|
||||
"tipo_de_video": plano.tipo_de_video,
|
||||
"modelo_da_ia": plano.modelo_da_ia,
|
||||
"intencao": plano.intencao,
|
||||
}
|
||||
finally:
|
||||
conexao.close()
|
||||
|
||||
|
||||
def principal() -> None:
|
||||
"""Executa o carregamento pela linha de comando."""
|
||||
argumentos = argparse.ArgumentParser()
|
||||
argumentos.add_argument("--banco", type=Path, default=Path(".jhonny/analises.db"))
|
||||
argumentos.add_argument("--plano-id", type=int)
|
||||
argumentos.add_argument("--video-id")
|
||||
opcoes = argumentos.parse_args()
|
||||
try:
|
||||
plano = carregar(opcoes.banco, opcoes.plano_id, opcoes.video_id)
|
||||
if plano is None:
|
||||
raise ValueError("Nenhum plano de edição encontrado.")
|
||||
print(json.dumps({"ok": True, "plano": plano}, ensure_ascii=False))
|
||||
except (OSError, ValueError) as erro:
|
||||
print(json.dumps({"ok": False, "erro": str(erro)}, ensure_ascii=False))
|
||||
raise SystemExit(1) from erro
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
principal()
|
||||
@@ -0,0 +1,67 @@
|
||||
"""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, a.temas_json,
|
||||
a.instrumentos_json, a.etiquetas_json
|
||||
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"],
|
||||
"temas": json.loads(analise["temas_json"] or "[]"),
|
||||
"instrumentos": json.loads(analise["instrumentos_json"] or "[]"),
|
||||
"etiquetas_audio": json.loads(analise["etiquetas_json"] or "[]"),
|
||||
"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()
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Consulta a configuração editorial concluída mais recente de um vídeo."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
import sys
|
||||
|
||||
CAMINHO_DO_CODIGO = Path(__file__).resolve().parent.parent
|
||||
if str(CAMINHO_DO_CODIGO) not in sys.path:
|
||||
sys.path.insert(0, str(CAMINHO_DO_CODIGO))
|
||||
|
||||
from engine.persistencia.conexao import abrir_banco
|
||||
|
||||
|
||||
def consultar(caminho_do_banco: Path, video_id: str) -> dict[str, object] | None:
|
||||
"""Retorna a edição mais recente sem alterar o banco."""
|
||||
conexao = abrir_banco(caminho_do_banco)
|
||||
try:
|
||||
linha = conexao.execute(
|
||||
"""SELECT id, video_id, sequencia, tipo_de_video, configuracao, concluida_em
|
||||
FROM edicoes_de_video WHERE video_id = ?
|
||||
ORDER BY concluida_em DESC, id DESC LIMIT 1""", (video_id,),
|
||||
).fetchone()
|
||||
if linha is None:
|
||||
return None
|
||||
return {
|
||||
"id": linha["id"], "video_id": linha["video_id"],
|
||||
"sequencia": linha["sequencia"], "tipo_de_video": linha["tipo_de_video"],
|
||||
"configuracao": json.loads(linha["configuracao"]),
|
||||
"concluida_em": linha["concluida_em"],
|
||||
}
|
||||
finally:
|
||||
conexao.close()
|
||||
|
||||
|
||||
def principal() -> None:
|
||||
"""Executa a consulta pela linha de comando."""
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("video_id")
|
||||
parser.add_argument("--banco", type=Path, default=Path(".jhonny/analises.db"))
|
||||
opcoes = parser.parse_args()
|
||||
try:
|
||||
edicao = consultar(opcoes.banco, opcoes.video_id)
|
||||
if edicao is None:
|
||||
raise ValueError("Nenhuma edição concluída encontrada para este vídeo.")
|
||||
print(json.dumps({"ok": True, "edicao": edicao}, ensure_ascii=False))
|
||||
except (OSError, ValueError, sqlite3.Error, json.JSONDecodeError) as erro:
|
||||
print(json.dumps({"ok": False, "erro": str(erro)}, ensure_ascii=False))
|
||||
raise SystemExit(1) from erro
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
principal()
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Consulta os intervalos de vídeo necessários ao preview dos falantes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
CAMINHO_DO_CODIGO = Path(__file__).resolve().parent.parent
|
||||
if str(CAMINHO_DO_CODIGO) not in sys.path:
|
||||
sys.path.insert(0, str(CAMINHO_DO_CODIGO))
|
||||
|
||||
from engine.persistencia.consultas import ConsultasDeAnalises
|
||||
|
||||
|
||||
def executar(caminho_do_banco: Path, video_id: str) -> dict:
|
||||
"""Consulta e serializa os intervalos de preview de um vídeo."""
|
||||
intervalos = ConsultasDeAnalises(caminho_do_banco).listar_intervalos_de_preview_dos_falantes(video_id)
|
||||
return {"video_id": video_id, "intervalos": intervalos}
|
||||
|
||||
|
||||
def principal() -> None:
|
||||
"""Executa a consulta pela linha de comando."""
|
||||
argumentos = argparse.ArgumentParser()
|
||||
argumentos.add_argument("video_id")
|
||||
argumentos.add_argument("--banco", type=Path, default=Path(".jhonny/analises.db"))
|
||||
opcoes = argumentos.parse_args()
|
||||
try:
|
||||
print(json.dumps(executar(opcoes.banco, opcoes.video_id), ensure_ascii=False))
|
||||
except (OSError, ValueError) as erro:
|
||||
print(json.dumps({"erro": str(erro)}, ensure_ascii=False))
|
||||
raise SystemExit(1) from erro
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
principal()
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Aplicação de planos de edição (cortes, zooms, marcadores) na timeline do Premiere.
|
||||
"""Aplicação de planos de edição (cortes, ganchos e destaques) no Premiere.
|
||||
|
||||
Lê o JSON de ações produzido pelas skills de seleção de trechos
|
||||
(``kind``/``start``/``end``/``params``/``reason``), traduz os tempos de
|
||||
@@ -18,6 +18,8 @@ from .escrita import EscritaNoEditor
|
||||
from .leitura import LeitorDePlanoDeEdicao
|
||||
from .mapeamento import MapeadorDeTempoDeOrigemParaTimeline
|
||||
from .modelos import AcaoDeEdicao, PlanoDeEdicao, TipoDeAcao
|
||||
from .validacao_semantica import ProblemaSemanticoDoPlano, ValidadorSemanticoDePlano
|
||||
from .verificador_da_aplicacao import ResultadoDaVerificacao, VerificadorDaAplicacao
|
||||
|
||||
__all__ = [
|
||||
"AcaoDeEdicao",
|
||||
@@ -31,7 +33,11 @@ __all__ = [
|
||||
"LeitorDePlanoDeEdicao",
|
||||
"MapeadorDeTempoDeOrigemParaTimeline",
|
||||
"PlanoDeEdicao",
|
||||
"ProblemaSemanticoDoPlano",
|
||||
"ResultadoDaAcao",
|
||||
"ResultadoDaAplicacao",
|
||||
"TipoDeAcao",
|
||||
"ValidadorSemanticoDePlano",
|
||||
"ResultadoDaVerificacao",
|
||||
"VerificadorDaAplicacao",
|
||||
]
|
||||
|
||||
@@ -22,6 +22,9 @@ from .modelos import AcaoDeEdicao, PlanoDeEdicao, TipoDeAcao
|
||||
_TOLERANCIA_DE_BORDA_EM_SEGUNDOS = 0.05
|
||||
_TOLERANCIA_DE_DURACAO_EM_SEGUNDOS = 0.3
|
||||
_RAIO_DE_BUSCA_DA_BORDA_EM_SEGUNDOS = 3.0
|
||||
# Tolerância adicional para encontrar clipes cuja origem foi deslocada pelo
|
||||
# drift do razor_all_tracks (drift observado no bridge real: até ~0.5s).
|
||||
_TOLERANCIA_DE_ORIGEM_EM_SEGUNDOS = 0.5
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -60,13 +63,16 @@ class AplicadorDePlanoDeEdicao:
|
||||
tudo que vem depois dele. Processar do fim para o começo garante que
|
||||
a posição de um corte ainda não aplicado nunca é afetada pelos
|
||||
cortes já aplicados.
|
||||
2. Marcadores e zooms por último, depois que a timeline já está no
|
||||
formato final. Zoom (``set_clip_properties``) muda uma propriedade do
|
||||
*clipe inteiro*, não de um trecho dele — se dois zooms caíssem em
|
||||
instantes diferentes do mesmo clipe ainda não dividido pelos cortes,
|
||||
o segundo sobrescreveria o primeiro no clipe inteiro, e a divisão
|
||||
posterior faria os dois pedaços herdarem só o último valor. Cortar
|
||||
primeiro garante que cada zoom já encontra um clipe próprio.
|
||||
2. O gancho para abertura vem depois dos cortes. Ele localiza o trecho por
|
||||
tempo da mídia original e cria uma cópia no começo da sequência.
|
||||
3. Marcadores e zooms por último, depois que a timeline já está no
|
||||
formato final: ambos precisam do instante *definitivo* na timeline,
|
||||
e cortes/gancho deslocam clipes. Zoom não altera o clipe original —
|
||||
cria uma adjustment layer numa faixa de vídeo própria, recortada no
|
||||
trecho exato do zoom, e escala só essa camada (mesma técnica do
|
||||
executor do painel CEP, ``scripts/apply-editorial-actions.mjs``). A
|
||||
faixa de ajuste é criada uma vez por plano, na primeira ação de zoom,
|
||||
e reaproveitada pelas seguintes.
|
||||
|
||||
A cada ação a timeline ativa é lida de novo no Premiere — nunca se
|
||||
reaproveita uma leitura anterior — porque a ação anterior pode ter
|
||||
@@ -104,13 +110,20 @@ class AplicadorDePlanoDeEdicao:
|
||||
self.conversor_de_timeline = conversor_de_timeline
|
||||
self.escrita = escrita
|
||||
self.mapeador = mapeador
|
||||
self._indice_da_faixa_de_ajuste: int | None = None
|
||||
|
||||
def aplicar(self, plano: PlanoDeEdicao) -> ResultadoDaAplicacao:
|
||||
"""Aplica todas as ações de ``plano`` e devolve o resultado de cada uma."""
|
||||
pontuais = [acao for acao in plano.acoes if acao.tipo is not TipoDeAcao.CORTE]
|
||||
self._indice_da_faixa_de_ajuste = None
|
||||
ganchos = plano.acoes_do_tipo(TipoDeAcao.GANCHO)
|
||||
pontuais = [
|
||||
acao for acao in plano.acoes
|
||||
if acao.tipo not in {TipoDeAcao.CORTE, TipoDeAcao.GANCHO}
|
||||
]
|
||||
cortes = sorted(plano.acoes_do_tipo(TipoDeAcao.CORTE), key=lambda acao: acao.inicio, reverse=True)
|
||||
|
||||
resultados = [self._aplicar_corte(acao, plano.arquivo_de_origem) for acao in cortes]
|
||||
resultados += [self._aplicar_gancho(acao, plano.arquivo_de_origem) for acao in ganchos]
|
||||
resultados += [self._aplicar_acao_pontual(acao, plano.arquivo_de_origem) for acao in pontuais]
|
||||
return ResultadoDaAplicacao(tuple(resultados))
|
||||
|
||||
@@ -125,8 +138,8 @@ class AplicadorDePlanoDeEdicao:
|
||||
self._aplicar_marcador(acao, arquivo_de_origem)
|
||||
return ResultadoDaAcao(acao, True, "marcador criado")
|
||||
if acao.tipo is TipoDeAcao.ZOOM:
|
||||
quantidade = self._aplicar_zoom(acao, arquivo_de_origem)
|
||||
return ResultadoDaAcao(acao, True, f"zoom aplicado em {quantidade} clipe(s)")
|
||||
self._aplicar_zoom(acao, arquivo_de_origem)
|
||||
return ResultadoDaAcao(acao, True, "zoom aplicado via camada de ajuste")
|
||||
if acao.tipo is TipoDeAcao.TEXTO:
|
||||
self._aplicar_texto_como_marcador(acao, arquivo_de_origem)
|
||||
return ResultadoDaAcao(
|
||||
@@ -148,7 +161,14 @@ class AplicadorDePlanoDeEdicao:
|
||||
conteudo = str(acao.parametros.get("content", ""))
|
||||
self.escrita.adicionar_marcador(instante, "TEXTO (aplicar manualmente)", f"{conteudo} — {acao.motivo}")
|
||||
|
||||
def _aplicar_zoom(self, acao: AcaoDeEdicao, arquivo_de_origem: str) -> int:
|
||||
def _aplicar_zoom(self, acao: AcaoDeEdicao, arquivo_de_origem: str) -> None:
|
||||
"""Cria uma adjustment layer cobrindo o trecho do zoom e a escala.
|
||||
|
||||
Traduz ``[acao.inicio, acao.fim]`` (coordenadas de origem) para o
|
||||
intervalo correspondente na timeline atual usando os clipes de
|
||||
vídeo já cortados. Ao contrário do fluxo antigo, a ação não é mais
|
||||
dividida por clipe: sempre gera uma única camada de ajuste.
|
||||
"""
|
||||
fator_de_escala = float(acao.parametros.get("scale", 1.3))
|
||||
faixa_de_video = self._faixa_de_video(self._timeline_atual())
|
||||
clipes = self._clipes_do_arquivo(faixa_de_video, arquivo_de_origem)
|
||||
@@ -157,9 +177,12 @@ class AplicadorDePlanoDeEdicao:
|
||||
raise ErroDeMapeamentoDeTempo(
|
||||
f"Nenhum clipe de vídeo de {arquivo_de_origem!r} sobrepõe {acao.inicio}-{acao.fim}s de origem para o zoom."
|
||||
)
|
||||
for clipe in alvos:
|
||||
self.escrita.aplicar_zoom(clipe.identificador, fator_de_escala)
|
||||
return len(alvos)
|
||||
primeiro, ultimo = alvos[0], alvos[-1]
|
||||
inicio_na_timeline = self._borda_de_entrada(primeiro, acao.inicio)
|
||||
fim_na_timeline = self._borda_de_saida(ultimo, acao.fim)
|
||||
if self._indice_da_faixa_de_ajuste is None:
|
||||
self._indice_da_faixa_de_ajuste = self.escrita.garantir_faixa_de_ajuste()
|
||||
self.escrita.aplicar_zoom(self._indice_da_faixa_de_ajuste, inicio_na_timeline, fim_na_timeline, fator_de_escala)
|
||||
|
||||
def _aplicar_corte(self, acao: AcaoDeEdicao, arquivo_de_origem: str) -> ResultadoDaAcao:
|
||||
"""Remove, em cada faixa afetada, todos os clipes contidos no intervalo do corte.
|
||||
@@ -170,14 +193,49 @@ class AplicadorDePlanoDeEdicao:
|
||||
``inPoint``/``outPoint``) só falharia depois de outra faixa já ter
|
||||
sido cortada — deixando vídeo e áudio dessincronizados mesmo com o
|
||||
corte "reportado" como falho.
|
||||
|
||||
Usa ``razor_all_tracks`` para cortar vídeo e áudio no mesmo instante,
|
||||
evitando drift entre chamadas separadas de ``split_clip``.
|
||||
"""
|
||||
try:
|
||||
faixas = self._timeline_atual().faixas
|
||||
for faixa in faixas:
|
||||
self._clipes_do_arquivo(faixa, arquivo_de_origem)
|
||||
|
||||
faixas_afetadas = [
|
||||
faixa for faixa in faixas
|
||||
if any(
|
||||
self._sobrepoe(clipe, acao.inicio, acao.fim)
|
||||
for clipe in self._clipes_do_arquivo(faixa, arquivo_de_origem)
|
||||
)
|
||||
]
|
||||
if not faixas_afetadas:
|
||||
raise ErroDeMapeamentoDeTempo(
|
||||
f"Nenhum clipe de {arquivo_de_origem!r} sobrepõe o intervalo {acao.inicio}-{acao.fim}s de origem."
|
||||
)
|
||||
|
||||
pontos_de_corte = self._calcular_pontos_de_corte(faixas_afetadas, acao, arquivo_de_origem)
|
||||
razor_bem_sucedido = True
|
||||
for ponto in pontos_de_corte:
|
||||
try:
|
||||
self.escrita.dividir_todas_as_faixas(ponto)
|
||||
except ErroDeEdicao:
|
||||
razor_bem_sucedido = False
|
||||
break
|
||||
|
||||
if not razor_bem_sucedido:
|
||||
raise ErroDeMapeamentoDeTempo(
|
||||
f"O razor falhou ao cortar as faixas em {acao.inicio:.3f}-{acao.fim:.3f}s "
|
||||
f"(Premiere 26.x QE no-op); nada foi removido."
|
||||
)
|
||||
|
||||
faixas = self._timeline_atual().faixas
|
||||
for faixa_id in {f.identificador for f in faixas_afetadas}:
|
||||
faixa_atualizada = next(f for f in faixas if f.identificador == faixa_id)
|
||||
self._validar_bordas_do_razor(faixa_atualizada, acao, arquivo_de_origem)
|
||||
|
||||
alvos_por_faixa = {
|
||||
faixa.identificador: self._preparar_alvos_da_faixa(faixa, acao, arquivo_de_origem)
|
||||
faixa.identificador: self._encontrar_alvos_pos_corte(faixa, acao, arquivo_de_origem)
|
||||
for faixa in faixas
|
||||
}
|
||||
trechos_removidos = self._remover_alvos_coordenados(faixas, alvos_por_faixa)
|
||||
@@ -189,6 +247,214 @@ class AplicadorDePlanoDeEdicao:
|
||||
except ErroDeEdicao as erro:
|
||||
return ResultadoDaAcao(acao, False, str(erro))
|
||||
|
||||
def _calcular_pontos_de_corte(
|
||||
self, faixas_afetadas: list[Faixa], acao: AcaoDeEdicao, arquivo_de_origem: str
|
||||
) -> list[float]:
|
||||
"""Calcula os pontos de timeline onde o razor deve ser aplicado.
|
||||
|
||||
Devolve uma lista ordenada de instantes de timeline (em segundos)
|
||||
correspondentes às bordas de entrada e saída do corte, mapeados a
|
||||
partir do intervalo de origem. Os pontos são deduplicados e
|
||||
ordenados do fim para o começo para que o ``razor_all_tracks``
|
||||
não desloque clipes entre um corte e o próximo.
|
||||
"""
|
||||
pontos: set[float] = set()
|
||||
for faixa in faixas_afetadas:
|
||||
clipes = self._clipes_do_arquivo(faixa, arquivo_de_origem)
|
||||
afetados = [clipe for clipe in clipes if self._sobrepoe(clipe, acao.inicio, acao.fim)]
|
||||
for clipe in afetados:
|
||||
if acao.inicio > clipe.intervalo_na_origem.inicio:
|
||||
pontos.add(self.mapeador.instante_na_timeline(clipe, acao.inicio))
|
||||
if acao.fim < clipe.intervalo_na_origem.fim:
|
||||
pontos.add(self.mapeador.instante_na_timeline(clipe, acao.fim))
|
||||
return sorted(pontos, reverse=True)
|
||||
|
||||
def _validar_bordas_do_razor(
|
||||
self, faixa: Faixa, acao: AcaoDeEdicao, arquivo_de_origem: str
|
||||
) -> None:
|
||||
"""Verifica que o razor criou bordas perto dos pontos esperados.
|
||||
|
||||
Levanta :class:`ErroDeMapeamentoDeTempo` se nenhuma borda de clipe
|
||||
estiver dentro de ``_RAIO_DE_BUSCA_DA_BORDA_EM_SEGUNDOS`` de um dos
|
||||
pontos de corte esperados — nesse caso o drift é grande demais para
|
||||
confiar no resultado.
|
||||
"""
|
||||
clipes = self._clipes_do_arquivo(faixa, arquivo_de_origem)
|
||||
afetados = [clipe for clipe in clipes if self._sobrepoe(clipe, acao.inicio, acao.fim)]
|
||||
if not afetados:
|
||||
return
|
||||
bordas_esperadas: list[float] = []
|
||||
primeiro, ultimo = afetados[0], afetados[-1]
|
||||
if acao.inicio > primeiro.intervalo_na_origem.inicio:
|
||||
bordas_esperadas.append(self._borda_de_entrada(primeiro, acao.inicio))
|
||||
if acao.fim < ultimo.intervalo_na_origem.fim:
|
||||
bordas_esperadas.append(self._borda_de_saida(ultimo, acao.fim))
|
||||
todas_bordas = {
|
||||
clipe.intervalo_na_timeline.inicio for clipe in clipes
|
||||
} | {
|
||||
clipe.intervalo_na_timeline.fim for clipe in clipes
|
||||
}
|
||||
for esperada in bordas_esperadas:
|
||||
mais_proxima = min(todas_bordas, key=lambda b: abs(b - esperada), default=None)
|
||||
if mais_proxima is None or abs(mais_proxima - esperada) > _RAIO_DE_BUSCA_DA_BORDA_EM_SEGUNDOS:
|
||||
raise ErroDeMapeamentoDeTempo(
|
||||
f"Nenhuma borda de clipe foi encontrada perto de {esperada:.3f}s "
|
||||
f"(raio de busca: {_RAIO_DE_BUSCA_DA_BORDA_EM_SEGUNDOS}s); o corte não foi aplicado."
|
||||
)
|
||||
|
||||
def _encontrar_alvos_pos_corte(
|
||||
self, faixa: Faixa, acao: AcaoDeEdicao, arquivo_de_origem: str
|
||||
) -> list[Clipe]:
|
||||
"""Encontra os clipes de uma faixa que ficam entre as bordas do corte.
|
||||
|
||||
Chamado depois que ``razor_all_tracks`` já dividiu todas as faixas;
|
||||
não repete nenhuma divisão. Usa o intervalo de origem para encontrar
|
||||
clipes cuja origem está contida no intervalo do corte, já que o drift
|
||||
do razor pode deslocar as bordas de timeline. Valida que a duração
|
||||
total dos clipes encontrados é compatível com a sobreposição de
|
||||
origem esperada.
|
||||
"""
|
||||
clipes = self._clipes_do_arquivo(faixa, arquivo_de_origem)
|
||||
afetados = [clipe for clipe in clipes if self._sobrepoe(clipe, acao.inicio, acao.fim)]
|
||||
if not afetados:
|
||||
return []
|
||||
|
||||
alvo = [
|
||||
clipe for clipe in afetados
|
||||
if clipe.intervalo_na_origem.inicio >= acao.inicio - _TOLERANCIA_DE_ORIGEM_EM_SEGUNDOS
|
||||
and clipe.intervalo_na_origem.fim <= acao.fim + _TOLERANCIA_DE_ORIGEM_EM_SEGUNDOS
|
||||
]
|
||||
duracao_removida = sum(clipe.intervalo_na_timeline.duracao for clipe in alvo)
|
||||
duracao_esperada = sum(
|
||||
self._sobreposicao_em_segundos(clipe, acao.inicio, acao.fim) for clipe in afetados
|
||||
)
|
||||
if abs(duracao_removida - duracao_esperada) > _TOLERANCIA_DE_DURACAO_EM_SEGUNDOS:
|
||||
raise ErroDeMapeamentoDeTempo(
|
||||
f"Na faixa {faixa.identificador!r}, o corte em {acao.inicio:.3f}-{acao.fim:.3f}s "
|
||||
f"não isolou o trecho esperado (esperado {duracao_esperada:.3f}s, "
|
||||
f"encontrado {duracao_removida:.3f}s); nada foi removido para evitar apagar o trecho errado."
|
||||
)
|
||||
return alvo
|
||||
|
||||
def _aplicar_gancho(self, acao: AcaoDeEdicao, arquivo_de_origem: str) -> ResultadoDaAcao:
|
||||
"""Recorta uma cópia de um par simples e a insere na abertura."""
|
||||
try:
|
||||
faixas = self._timeline_atual().faixas
|
||||
faixas_afetadas = [
|
||||
faixa for faixa in faixas
|
||||
if any(
|
||||
self._sobrepoe(clipe, acao.inicio, acao.fim)
|
||||
for clipe in self._clipes_do_arquivo(faixa, arquivo_de_origem)
|
||||
)
|
||||
]
|
||||
videos = [faixa for faixa in faixas_afetadas if faixa.tipo == "video"]
|
||||
audios = [faixa for faixa in faixas_afetadas if faixa.tipo == "audio"]
|
||||
if len(videos) != 1 or len(audios) != 1:
|
||||
raise ErroDeMapeamentoDeTempo(
|
||||
"A ação hook exige exatamente uma faixa de vídeo e uma de áudio com o trecho de origem."
|
||||
)
|
||||
faixas_adicionais_ocupadas = [
|
||||
faixa.identificador
|
||||
for faixa in faixas
|
||||
if faixa not in faixas_afetadas and faixa.clipes
|
||||
]
|
||||
if faixas_adicionais_ocupadas:
|
||||
raise ErroDeMapeamentoDeTempo(
|
||||
"A ação hook exige uma sequência simples; há conteúdo nas faixas adicionais: "
|
||||
+ ", ".join(faixas_adicionais_ocupadas)
|
||||
)
|
||||
for faixa in faixas_afetadas:
|
||||
afetados = [
|
||||
clipe for clipe in self._clipes_do_arquivo(faixa, arquivo_de_origem)
|
||||
if self._sobrepoe(clipe, acao.inicio, acao.fim)
|
||||
]
|
||||
if len(afetados) != 1:
|
||||
raise ErroDeMapeamentoDeTempo(
|
||||
f"A ação hook precisa estar contida em um único clipe da faixa {faixa.identificador!r}."
|
||||
)
|
||||
intervalo_na_origem = afetados[0].intervalo_na_origem
|
||||
if (
|
||||
intervalo_na_origem is None
|
||||
or acao.inicio < intervalo_na_origem.inicio - _TOLERANCIA_DE_BORDA_EM_SEGUNDOS
|
||||
or acao.fim > intervalo_na_origem.fim + _TOLERANCIA_DE_BORDA_EM_SEGUNDOS
|
||||
):
|
||||
raise ErroDeMapeamentoDeTempo(
|
||||
f"A ação hook ultrapassa o clipe da faixa {faixa.identificador!r}."
|
||||
)
|
||||
inicio_na_timeline = self._borda_de_entrada(afetados[0], acao.inicio)
|
||||
if inicio_na_timeline <= _TOLERANCIA_DE_BORDA_EM_SEGUNDOS:
|
||||
raise ErroDeMapeamentoDeTempo("O trecho da ação hook já está no início da sequência.")
|
||||
if min(clipe.intervalo_na_timeline.inicio for clipe in faixa.clipes) > _TOLERANCIA_DE_BORDA_EM_SEGUNDOS:
|
||||
raise ErroDeMapeamentoDeTempo(
|
||||
f"A faixa {faixa.identificador!r} não começa em 0s; o gancho foi recusado."
|
||||
)
|
||||
|
||||
# A ferramenta de gancho do bridge exige um par já isolado: ela lê
|
||||
# o in/out do próprio clipe, não recebe limites de origem à
|
||||
# parte. Usa ``razor_all_tracks`` para dividir vídeo e áudio
|
||||
# no mesmo instante, evitando drift entre chamadas separadas.
|
||||
pontos_para_cortar: set[float] = set()
|
||||
for faixa in faixas_afetadas:
|
||||
clipes = self._clipes_do_arquivo(faixa, arquivo_de_origem)
|
||||
afetados = [clipe for clipe in clipes if self._sobrepoe(clipe, acao.inicio, acao.fim)]
|
||||
for clipe in afetados:
|
||||
if acao.inicio > clipe.intervalo_na_origem.inicio:
|
||||
pontos_para_cortar.add(self.mapeador.instante_na_timeline(clipe, acao.inicio))
|
||||
if acao.fim < clipe.intervalo_na_origem.fim:
|
||||
pontos_para_cortar.add(self.mapeador.instante_na_timeline(clipe, acao.fim))
|
||||
razor_bem_sucedido = True
|
||||
for ponto in sorted(pontos_para_cortar, reverse=True):
|
||||
try:
|
||||
self.escrita.dividir_todas_as_faixas(ponto)
|
||||
except ErroDeEdicao:
|
||||
razor_bem_sucedido = False
|
||||
break
|
||||
if not razor_bem_sucedido:
|
||||
raise ErroDeMapeamentoDeTempo(
|
||||
f"O razor falhou ao isolar o trecho do gancho "
|
||||
f"({acao.inicio:.3f}-{acao.fim:.3f}s); o gancho não foi aplicado."
|
||||
)
|
||||
|
||||
faixas = self._timeline_atual().faixas
|
||||
faixas_afetadas = [
|
||||
faixa for faixa in faixas
|
||||
if any(
|
||||
self._sobrepoe(clipe, acao.inicio, acao.fim)
|
||||
for clipe in self._clipes_do_arquivo(faixa, arquivo_de_origem)
|
||||
)
|
||||
]
|
||||
alvos: list[Clipe] = []
|
||||
for faixa in faixas_afetadas:
|
||||
clipes = self._clipes_do_arquivo(faixa, arquivo_de_origem)
|
||||
afetados = [clipe for clipe in clipes if self._sobrepoe(clipe, acao.inicio, acao.fim)]
|
||||
if not afetados:
|
||||
continue
|
||||
primeiro, ultimo = afetados[0], afetados[-1]
|
||||
inicio_na_timeline = self._borda_de_entrada(primeiro, acao.inicio)
|
||||
fim_na_timeline = self._borda_de_saida(ultimo, acao.fim)
|
||||
isolados = [
|
||||
clipe for clipe in clipes
|
||||
if inicio_na_timeline - _TOLERANCIA_DE_BORDA_EM_SEGUNDOS <= clipe.intervalo_na_timeline.inicio
|
||||
and clipe.intervalo_na_timeline.fim <= fim_na_timeline + _TOLERANCIA_DE_BORDA_EM_SEGUNDOS
|
||||
]
|
||||
if len(isolados) != 1:
|
||||
raise ErroDeMapeamentoDeTempo(
|
||||
f"A divisão do gancho na faixa {faixa.identificador!r} não isolou exatamente um clipe "
|
||||
f"(encontrado(s) {len(isolados)})."
|
||||
)
|
||||
alvos.append(isolados[0])
|
||||
|
||||
if len(alvos) != 2:
|
||||
raise ErroDeMapeamentoDeTempo(
|
||||
"O Premiere não localizou exatamente um clipe de vídeo e um de áudio para a ação hook."
|
||||
)
|
||||
self.escrita.duplicar_trecho_vinculado_para_o_inicio(
|
||||
[clipe.identificador for clipe in alvos],
|
||||
)
|
||||
return ResultadoDaAcao(acao, True, "gancho vinculado duplicado no início")
|
||||
except ErroDeEdicao as erro:
|
||||
return ResultadoDaAcao(acao, False, str(erro))
|
||||
|
||||
def _preparar_alvos_da_faixa(
|
||||
self, faixa: Faixa, acao: AcaoDeEdicao, arquivo_de_origem: str
|
||||
) -> list[Clipe]:
|
||||
|
||||
@@ -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: o chamador decide se a ação pode
|
||||
continuar. Ganchos estruturais são bloqueados sem essa confirmação.
|
||||
"""
|
||||
try:
|
||||
self.cliente_mcp.chamar("duplicate_sequence", {"sequence_id": identificador_da_sequencia})
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"""Escrita de mutações na sequência ativa do Premiere via MCP.
|
||||
|
||||
Isola o nome das ferramentas MCP e o formato de argumentos externos
|
||||
(``split_clip``, ``remove_from_timeline``, ``remove_linked_from_timeline``, ``set_clip_properties``,
|
||||
``add_marker``) do resto do domínio. Quem chama esta classe fala só em
|
||||
(``split_clip``, ``remove_from_timeline``, ``remove_linked_from_timeline``,
|
||||
``duplicate_linked_range_to_start``, ``set_clip_properties``, ``add_marker``) do
|
||||
resto do domínio. Quem chama esta classe fala só em
|
||||
segundos de timeline, identificador de clipe e fator de escala — nunca em
|
||||
nomes de ferramenta ou ticks do Premiere.
|
||||
"""
|
||||
@@ -78,6 +79,37 @@ class EscritaNoEditor:
|
||||
f"em {instante_da_timeline:.3f}s: {erro}"
|
||||
) from erro
|
||||
|
||||
def dividir_todas_as_faixas(self, instante_da_timeline: float) -> None:
|
||||
"""Corta todas as faixas de vídeo e áudio no mesmo instante.
|
||||
|
||||
Usa ``razor_all_tracks`` para garantir que vídeo e áudio sejam
|
||||
divididos no mesmo ponto de tempo, evitando drift entre chamadas
|
||||
separadas de ``split_clip``.
|
||||
|
||||
Levanta :class:`ErroDeEscritaNoEditor` se o razor reportar uma
|
||||
falha parcial (algumas faixas cortadas, outras não) — o Premiere
|
||||
26.x tem um bug QE que faz o razor falhar silenciosamente em
|
||||
algumas instalações. Uma falha parcial deixa a timeline num estado
|
||||
inconsistente que corrompe operações subsequentes (remoção, zoom,
|
||||
marcador), então a operação é recusada inteiramente.
|
||||
"""
|
||||
try:
|
||||
resultado = self.cliente_mcp.chamar(
|
||||
"razor_all_tracks",
|
||||
{"time_seconds": instante_da_timeline, "track_type": "both"},
|
||||
)
|
||||
except ErroDeFerramentaMCP as erro:
|
||||
raise ErroDeEscritaNoEditor(
|
||||
f"Não foi possível cortar todas as faixas em {instante_da_timeline:.3f}s: {erro}"
|
||||
) from erro
|
||||
elegiveis = int(resultado.get("eligibleTracks", 0))
|
||||
cortados = int(resultado.get("razored", 0))
|
||||
if elegiveis > 0 and cortados < elegiveis:
|
||||
raise ErroDeEscritaNoEditor(
|
||||
f"O razor cortou apenas {cortados} de {elegiveis} faixa(s) elegíveis "
|
||||
f"em {instante_da_timeline:.3f}s (Premiere 26.x QE no-op)"
|
||||
)
|
||||
|
||||
def remover_trecho(self, identificador_do_clipe: str) -> None:
|
||||
"""Remove o clipe da timeline fechando o espaço que ele ocupava.
|
||||
|
||||
@@ -113,19 +145,81 @@ class EscritaNoEditor:
|
||||
f"Não foi possível remover o par vinculado {identificadores_dos_clipes!r}: {erro}"
|
||||
) from erro
|
||||
|
||||
def aplicar_zoom(self, identificador_do_clipe: str, fator_de_escala: float) -> None:
|
||||
"""Aplica um punch-in no clipe, escalando-o por ``fator_de_escala`` (1.0 = tamanho original).
|
||||
def duplicar_trecho_vinculado_para_o_inicio(
|
||||
self,
|
||||
identificadores_dos_clipes: list[str],
|
||||
) -> None:
|
||||
"""Copia para o início um par de vídeo e áudio já isolado por corte.
|
||||
|
||||
Levanta :class:`ErroDeEscritaNoEditor` se o Premiere recusar a
|
||||
alteração.
|
||||
A ferramenta externa lê o in/out do próprio clipe — por isso os dois
|
||||
identificadores precisam já corresponder exatamente ao trecho do
|
||||
gancho, isolado por ``split_clip`` antes desta chamada. Executa
|
||||
inserção com ripple, mantém a ocorrência original intacta e confirma
|
||||
posição, duração e sincronismo. A operação é recusada quando os
|
||||
identificadores não formam um par simples.
|
||||
"""
|
||||
if len(identificadores_dos_clipes) != 2:
|
||||
raise ErroDeEscritaNoEditor("O gancho exige exatamente um clipe de vídeo e um de áudio.")
|
||||
try:
|
||||
self.cliente_mcp.chamar(
|
||||
"set_clip_properties", {"node_id": identificador_do_clipe, "scale": fator_de_escala * 100}
|
||||
"duplicate_linked_range_to_start",
|
||||
{"node_ids": identificadores_dos_clipes},
|
||||
)
|
||||
except ErroDeFerramentaMCP as erro:
|
||||
raise ErroDeEscritaNoEditor(
|
||||
f"Não foi possível aplicar zoom no clipe {identificador_do_clipe!r}: {erro}"
|
||||
f"Não foi possível duplicar o par vinculado {identificadores_dos_clipes!r} no início: {erro}"
|
||||
) from erro
|
||||
|
||||
def garantir_faixa_de_ajuste(self) -> int:
|
||||
"""Cria uma faixa de vídeo nova, no topo da pilha, para hospedar camadas de ajuste de zoom.
|
||||
|
||||
Devolve o índice da faixa criada. Chamado uma única vez por plano —
|
||||
quem chama guarda o índice e reaproveita para todos os zooms do
|
||||
plano, o mesmo padrão do executor do painel CEP
|
||||
(``scripts/apply-editorial-actions.mjs``).
|
||||
"""
|
||||
try:
|
||||
resultado = self.cliente_mcp.chamar("add_track", {"track_type": "video", "count": 1})
|
||||
except ErroDeFerramentaMCP as erro:
|
||||
raise ErroDeEscritaNoEditor(f"Não foi possível criar a faixa de ajuste para zoom: {erro}") from erro
|
||||
return int(resultado["totalTracks"]) - 1
|
||||
|
||||
def aplicar_zoom(
|
||||
self,
|
||||
indice_da_faixa_de_ajuste: int,
|
||||
inicio_na_timeline: float,
|
||||
fim_na_timeline: float,
|
||||
fator_de_escala: float,
|
||||
) -> None:
|
||||
"""Aplica um punch-in criando uma adjustment layer sobre o trecho, sem tocar o clipe original.
|
||||
|
||||
Reproduz, no caminho Python, a técnica já usada pelo executor do
|
||||
painel CEP: move o playhead para o início do trecho, cria a camada
|
||||
de ajuste na faixa indicada, ajusta sua duração e posição para
|
||||
cobrir exatamente ``[inicio_na_timeline, fim_na_timeline)``, e
|
||||
escala só essa camada por ``fator_de_escala`` (1.0 = tamanho
|
||||
original).
|
||||
|
||||
Levanta :class:`ErroDeEscritaNoEditor` se o Premiere recusar
|
||||
qualquer um dos passos.
|
||||
"""
|
||||
duracao = fim_na_timeline - inicio_na_timeline
|
||||
try:
|
||||
self.cliente_mcp.chamar("set_playhead_position", {"time_seconds": inicio_na_timeline})
|
||||
resultado = self.cliente_mcp.chamar(
|
||||
"add_adjustment_layer", {"track_index": indice_da_faixa_de_ajuste}
|
||||
)
|
||||
identificador_da_camada = resultado["nodeId"]
|
||||
self.cliente_mcp.chamar("trim_clip", {"node_id": identificador_da_camada, "new_out_seconds": duracao})
|
||||
self.cliente_mcp.chamar(
|
||||
"move_clip", {"node_id": identificador_da_camada, "new_start_seconds": inicio_na_timeline}
|
||||
)
|
||||
self.cliente_mcp.chamar(
|
||||
"set_clip_properties", {"node_id": identificador_da_camada, "scale": fator_de_escala * 100}
|
||||
)
|
||||
except ErroDeFerramentaMCP as erro:
|
||||
raise ErroDeEscritaNoEditor(
|
||||
f"Não foi possível aplicar zoom em {inicio_na_timeline:.3f}-{fim_na_timeline:.3f}s: {erro}"
|
||||
) from erro
|
||||
|
||||
def adicionar_marcador(self, instante_da_timeline: float, nome: str, comentario: str) -> None:
|
||||
|
||||
@@ -14,12 +14,13 @@ from typing import Any
|
||||
|
||||
|
||||
class TipoDeAcao(str, Enum):
|
||||
"""Os quatro tipos de ação que o plano de edição pode descrever."""
|
||||
"""Os tipos de ação que o plano de edição pode descrever."""
|
||||
|
||||
CORTE = "cut"
|
||||
ZOOM = "zoom"
|
||||
TEXTO = "text"
|
||||
MARCADOR = "marker"
|
||||
GANCHO = "hook"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -46,6 +47,13 @@ class AcaoDeEdicao:
|
||||
)
|
||||
if not self.motivo.strip():
|
||||
raise ValueError("Ação inválida: motivo não pode ser vazio.")
|
||||
if self.tipo is TipoDeAcao.GANCHO:
|
||||
destino = self.parametros.get("target", 0)
|
||||
manter_original = self.parametros.get("keep_original", True)
|
||||
if isinstance(destino, bool) or not isinstance(destino, (int, float)) or float(destino) != 0.0:
|
||||
raise ValueError("Gancho inválido: params.target precisa ser 0 nesta versão.")
|
||||
if manter_original is not True:
|
||||
raise ValueError("Gancho inválido: params.keep_original precisa ser true.")
|
||||
|
||||
@property
|
||||
def duracao(self) -> float:
|
||||
@@ -63,6 +71,16 @@ class PlanoDeEdicao:
|
||||
def __post_init__(self) -> None:
|
||||
if not self.arquivo_de_origem.strip():
|
||||
raise ValueError("Plano de edição inválido: arquivo de origem não informado.")
|
||||
ganchos = self.acoes_do_tipo(TipoDeAcao.GANCHO)
|
||||
if len(ganchos) > 1:
|
||||
raise ValueError("Plano de edição inválido: esta versão aceita somente uma ação hook.")
|
||||
if ganchos:
|
||||
gancho = ganchos[0]
|
||||
for acao in self.acoes:
|
||||
if acao is gancho:
|
||||
continue
|
||||
if acao.inicio < gancho.fim and gancho.inicio < acao.fim:
|
||||
raise ValueError("Plano de edição inválido: outra ação não pode sobrepor o trecho do hook.")
|
||||
|
||||
def acoes_do_tipo(self, tipo: TipoDeAcao) -> tuple[AcaoDeEdicao, ...]:
|
||||
"""Devolve, na ordem original, só as ações do tipo informado."""
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"duracao_da_midia": 180,
|
||||
"acoes": [
|
||||
{
|
||||
"tipo": "hook",
|
||||
"inicio": 141.2,
|
||||
"fim": 147.8,
|
||||
"motivo": "frase completa para abrir o vídeo"
|
||||
},
|
||||
{
|
||||
"tipo": "cut",
|
||||
"inicio": 32.5,
|
||||
"fim": 48.0,
|
||||
"motivo": "remover repetição"
|
||||
},
|
||||
{
|
||||
"tipo": "cut",
|
||||
"inicio": 95.4,
|
||||
"fim": 110.0,
|
||||
"motivo": "remover pausa longa"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"modo_de_execucao": "montagem_incremental",
|
||||
"plano_de_origem": 6,
|
||||
"source_sequence_id": "12669c9c-038d-4a3f-99f9-fe6f6827b5f4",
|
||||
"source_item_id": "000f4289",
|
||||
"target_name": "PROTÓTIPO - plano 6 - montagem incremental",
|
||||
"duracao_da_midia": 1018.22554166667,
|
||||
"ranges": [
|
||||
{
|
||||
"in_seconds": 255.98,
|
||||
"out_seconds": 274.96,
|
||||
"destination_seconds": 0,
|
||||
"label": "gancho de abertura"
|
||||
},
|
||||
{
|
||||
"in_seconds": 124.47,
|
||||
"out_seconds": 215.06,
|
||||
"destination_seconds": 18.98,
|
||||
"label": "corpo 1"
|
||||
},
|
||||
{
|
||||
"in_seconds": 255.98,
|
||||
"out_seconds": 427.32,
|
||||
"destination_seconds": 109.57,
|
||||
"label": "corpo 2 - ocorrência original do gancho"
|
||||
},
|
||||
{
|
||||
"in_seconds": 469.24,
|
||||
"out_seconds": 552.28,
|
||||
"destination_seconds": 280.91,
|
||||
"label": "corpo 3"
|
||||
},
|
||||
{
|
||||
"in_seconds": 848.42,
|
||||
"out_seconds": 879.89,
|
||||
"destination_seconds": 363.95,
|
||||
"label": "corpo 4"
|
||||
},
|
||||
{
|
||||
"in_seconds": 962.78,
|
||||
"out_seconds": 1014.99,
|
||||
"label": "corpo 5",
|
||||
"destination_seconds": 395.42
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"test_adjustment_layer": true,
|
||||
"actions": [
|
||||
{
|
||||
"kind": "hook",
|
||||
"start": 255.98,
|
||||
"end": 274.96,
|
||||
"reason": "Gancho de abertura: Toda minha vida, eu olhava no espelho..."
|
||||
},
|
||||
{ "kind": "cut", "start": 0, "end": 124.47, "reason": "Remover trecho inicial" },
|
||||
{ "kind": "cut", "start": 215.06, "end": 255.98, "reason": "Remover respiro antes do gancho" },
|
||||
{ "kind": "cut", "start": 427.32, "end": 469.24, "reason": "Remover trecho" },
|
||||
{ "kind": "cut", "start": 552.28, "end": 848.42, "reason": "Remover trecho" },
|
||||
{ "kind": "cut", "start": 879.89, "end": 962.78, "reason": "Remover trecho" },
|
||||
{ "kind": "cut", "start": 1014.99, "end": 1018.2255, "reason": "Remover final" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"source": "/Volumes/Merongo/PROJETOS/DEPOIMENTO - ERIKA/0E6A8829.MP4",
|
||||
"actions": [
|
||||
{
|
||||
"kind": "zoom",
|
||||
"start": 157.4,
|
||||
"end": 164.3,
|
||||
"reason": "Ênfase em Sempre tentei levar a minha mudança de vida como meu principal objetivo.",
|
||||
"params": {"scale": 1.18}
|
||||
},
|
||||
{
|
||||
"kind": "zoom",
|
||||
"start": 515.13,
|
||||
"end": 517.09,
|
||||
"reason": "Ênfase em Se algo der errado, eu vou estar aqui.",
|
||||
"params": {"scale": 1.3}
|
||||
},
|
||||
{
|
||||
"kind": "zoom",
|
||||
"start": 877.26,
|
||||
"end": 879.56,
|
||||
"reason": "Ênfase em Então, mudou muito a minha vida em todos os sentidos.",
|
||||
"params": {"scale": 1.2}
|
||||
},
|
||||
{
|
||||
"kind": "zoom",
|
||||
"start": 1010.26,
|
||||
"end": 1014.64,
|
||||
"reason": "Ênfase no fecho sobre a segurança do profissional.",
|
||||
"params": {"scale": 1.5}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
"""Protótipo descartável da montagem incremental de uma edição.
|
||||
|
||||
Este módulo não se conecta ao Premiere. Ele responde à pergunta:
|
||||
"Dado um plano em intervalos da mídia original, qual sequência de inserções
|
||||
seria feita em uma sequência de destino inicialmente vazia?"
|
||||
|
||||
O protótipo mantém a sequência original intacta e transforma ações de corte
|
||||
em trechos preservados. O gancho, quando existir, é colocado primeiro.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
|
||||
class TipoDeTrecho(str, Enum):
|
||||
"""Origem editorial de um trecho que será inserido na montagem."""
|
||||
|
||||
GANCHO = "gancho"
|
||||
CORPO = "corpo"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IntervaloDeOrigem:
|
||||
"""Representa um intervalo contínuo em segundos na mídia original."""
|
||||
|
||||
inicio: float
|
||||
fim: float
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.inicio < 0 or self.fim <= self.inicio:
|
||||
raise ValueError("Intervalo inválido: o fim precisa ser maior que o início.")
|
||||
|
||||
@property
|
||||
def duracao(self) -> float:
|
||||
"""Devolve a duração do intervalo em segundos."""
|
||||
return self.fim - self.inicio
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AcaoDoPlano:
|
||||
"""Ação mínima usada pelo protótipo para representar um plano editorial."""
|
||||
|
||||
tipo: str
|
||||
inicio: float
|
||||
fim: float
|
||||
motivo: str = ""
|
||||
|
||||
def intervalo(self) -> IntervaloDeOrigem:
|
||||
"""Converte a ação em intervalo na mídia original."""
|
||||
return IntervaloDeOrigem(self.inicio, self.fim)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class InsercaoDeMontagem:
|
||||
"""Uma inserção A/V planejada para a sequência de destino."""
|
||||
|
||||
ordem: int
|
||||
tipo: TipoDeTrecho
|
||||
origem: IntervaloDeOrigem
|
||||
destino_inicio: float
|
||||
destino_fim: float
|
||||
motivo: str
|
||||
|
||||
@property
|
||||
def duracao(self) -> float:
|
||||
"""Devolve a duração que será inserida na montagem."""
|
||||
return self.destino_fim - self.destino_inicio
|
||||
|
||||
def como_dicionario(self) -> dict[str, Any]:
|
||||
"""Serializa a inserção para inspeção humana ou integração futura."""
|
||||
return {
|
||||
"ordem": self.ordem,
|
||||
"tipo": self.tipo.value,
|
||||
"origem": {"inicio": self.origem.inicio, "fim": self.origem.fim},
|
||||
"destino": {"inicio": self.destino_inicio, "fim": self.destino_fim},
|
||||
"duracao": self.duracao,
|
||||
"motivo": self.motivo,
|
||||
}
|
||||
|
||||
|
||||
class PlanejadorDeMontagemIncremental:
|
||||
"""Converte cortes e gancho em uma montagem crescente e sem ripple de origem."""
|
||||
|
||||
def __init__(self, duracao_da_midia: float) -> None:
|
||||
if duracao_da_midia <= 0:
|
||||
raise ValueError("A duração da mídia precisa ser positiva.")
|
||||
self.duracao_da_midia = duracao_da_midia
|
||||
|
||||
def planejar(self, acoes: list[AcaoDoPlano]) -> list[InsercaoDeMontagem]:
|
||||
"""Gera as inserções ordenadas que reconstruiriam a edição final."""
|
||||
cortes = self._normalizar_intervalos(
|
||||
acao.intervalo() for acao in acoes if acao.tipo == "cut"
|
||||
)
|
||||
gancho = next((acao for acao in acoes if acao.tipo == "hook"), None)
|
||||
if sum(acao.tipo == "hook" for acao in acoes) > 1:
|
||||
raise ValueError("O protótipo aceita no máximo um gancho.")
|
||||
|
||||
trechos = self._complemento_da_midia(cortes)
|
||||
insercoes: list[InsercaoDeMontagem] = []
|
||||
proximo_destino = 0.0
|
||||
|
||||
if gancho is not None:
|
||||
intervalo_do_gancho = gancho.intervalo()
|
||||
self._validar_intervalo_do_gancho(intervalo_do_gancho, trechos)
|
||||
insercoes.append(
|
||||
self._criar_insercao(
|
||||
len(insercoes) + 1,
|
||||
TipoDeTrecho.GANCHO,
|
||||
intervalo_do_gancho,
|
||||
proximo_destino,
|
||||
gancho.motivo or "gancho de abertura",
|
||||
)
|
||||
)
|
||||
proximo_destino += intervalo_do_gancho.duracao
|
||||
|
||||
for trecho in trechos:
|
||||
insercoes.append(
|
||||
self._criar_insercao(
|
||||
len(insercoes) + 1,
|
||||
TipoDeTrecho.CORPO,
|
||||
trecho,
|
||||
proximo_destino,
|
||||
"trecho preservado após os cortes",
|
||||
)
|
||||
)
|
||||
proximo_destino += trecho.duracao
|
||||
return insercoes
|
||||
|
||||
def _normalizar_intervalos(self, intervalos: Any) -> list[IntervaloDeOrigem]:
|
||||
"""Ordena, limita e rejeita cortes sobrepostos."""
|
||||
normalizados = []
|
||||
for intervalo in sorted(intervalos, key=lambda item: item.inicio):
|
||||
inicio = max(0.0, intervalo.inicio)
|
||||
fim = min(self.duracao_da_midia, intervalo.fim)
|
||||
if fim <= inicio:
|
||||
continue
|
||||
atual = IntervaloDeOrigem(inicio, fim)
|
||||
if normalizados and atual.inicio < normalizados[-1].fim:
|
||||
raise ValueError("Os cortes do protótipo não podem se sobrepor.")
|
||||
normalizados.append(atual)
|
||||
return normalizados
|
||||
|
||||
def _complemento_da_midia(self, cortes: list[IntervaloDeOrigem]) -> list[IntervaloDeOrigem]:
|
||||
"""Calcula os trechos que permanecem depois dos cortes."""
|
||||
trechos: list[IntervaloDeOrigem] = []
|
||||
cursor = 0.0
|
||||
for corte in cortes:
|
||||
if corte.inicio > cursor:
|
||||
trechos.append(IntervaloDeOrigem(cursor, corte.inicio))
|
||||
cursor = corte.fim
|
||||
if cursor < self.duracao_da_midia:
|
||||
trechos.append(IntervaloDeOrigem(cursor, self.duracao_da_midia))
|
||||
return trechos
|
||||
|
||||
def _validar_intervalo_do_gancho(
|
||||
self, gancho: IntervaloDeOrigem, trechos: list[IntervaloDeOrigem]
|
||||
) -> None:
|
||||
"""Confirma que o gancho não foi removido pelo conjunto de cortes."""
|
||||
if not any(self._contem_intervalo(trecho, gancho) for trecho in trechos):
|
||||
raise ValueError("O gancho precisa estar em um trecho preservado da mídia.")
|
||||
|
||||
@staticmethod
|
||||
def _contem_intervalo(
|
||||
recipiente: IntervaloDeOrigem, intervalo: IntervaloDeOrigem
|
||||
) -> bool:
|
||||
"""Informa se um intervalo está completamente dentro de outro."""
|
||||
return recipiente.inicio <= intervalo.inicio and intervalo.fim <= recipiente.fim
|
||||
|
||||
@staticmethod
|
||||
def _criar_insercao(
|
||||
ordem: int,
|
||||
tipo: TipoDeTrecho,
|
||||
origem: IntervaloDeOrigem,
|
||||
destino_inicio: float,
|
||||
motivo: str,
|
||||
) -> InsercaoDeMontagem:
|
||||
"""Cria uma inserção com destino contíguo na sequência de montagem."""
|
||||
return InsercaoDeMontagem(
|
||||
ordem=ordem,
|
||||
tipo=tipo,
|
||||
origem=origem,
|
||||
destino_inicio=destino_inicio,
|
||||
destino_fim=destino_inicio + origem.duracao,
|
||||
motivo=motivo,
|
||||
)
|
||||
|
||||
|
||||
def carregar_pedido(caminho: Path) -> tuple[float, list[AcaoDoPlano]]:
|
||||
"""Carrega um pedido JSON pequeno para execução local do protótipo."""
|
||||
dados = json.loads(caminho.read_text(encoding="utf-8"))
|
||||
acoes = [AcaoDoPlano(**acao) for acao in dados["acoes"]]
|
||||
return float(dados["duracao_da_midia"]), acoes
|
||||
|
||||
|
||||
def executar(caminho: Path) -> None:
|
||||
"""Executa o protótipo e imprime o plano de montagem resultante."""
|
||||
duracao, acoes = carregar_pedido(caminho)
|
||||
insercoes = PlanejadorDeMontagemIncremental(duracao).planejar(acoes)
|
||||
print(json.dumps({"sequencia_de_destino": insercoes}, default=lambda item: item.como_dicionario(), ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) != 2:
|
||||
raise SystemExit("Uso: python3 prototipo_montagem_incremental.py pedido.json")
|
||||
executar(Path(sys.argv[1]))
|
||||
@@ -0,0 +1,300 @@
|
||||
"""Validação semântica de cortes e ganchos 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 editoriais."""
|
||||
|
||||
_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 os problemas semânticos encontrados em cortes e ganchos."""
|
||||
cortes = tuple(
|
||||
(indice, acao)
|
||||
for indice, acao in enumerate(plano.acoes)
|
||||
if acao.tipo is TipoDeAcao.CORTE
|
||||
)
|
||||
ganchos = tuple(
|
||||
(indice, acao)
|
||||
for indice, acao in enumerate(plano.acoes)
|
||||
if acao.tipo is TipoDeAcao.GANCHO
|
||||
)
|
||||
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))
|
||||
for indice, gancho in ganchos:
|
||||
problemas.extend(self._validar_palavras_interceptadas(indice, gancho, palavras))
|
||||
problemas.extend(self._validar_frase_do_gancho(indice, gancho, falas, palavras))
|
||||
return tuple(problemas)
|
||||
|
||||
def garantir_valido(
|
||||
self,
|
||||
plano: PlanoDeEdicao,
|
||||
falas: tuple[SegmentoDeTranscricao, ...],
|
||||
) -> None:
|
||||
"""Levanta erro explicativo quando uma emenda ameaça a integridade da fala."""
|
||||
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_frase_do_gancho(
|
||||
self,
|
||||
indice: int,
|
||||
gancho: AcaoDeEdicao,
|
||||
falas: tuple[SegmentoDeTranscricao, ...],
|
||||
palavras: tuple[PalavraDeTranscricao, ...],
|
||||
) -> list[ProblemaSemanticoDoPlano]:
|
||||
"""Confirma que o gancho duplicado começa e termina como uma fala completa."""
|
||||
contidas = tuple(
|
||||
palavra for palavra in palavras
|
||||
if gancho.inicio - self._TOLERANCIA_EM_SEGUNDOS <= palavra.inicio
|
||||
and palavra.fim <= gancho.fim + self._TOLERANCIA_EM_SEGUNDOS
|
||||
)
|
||||
if not contidas:
|
||||
return [ProblemaSemanticoDoPlano(
|
||||
codigo="gancho_sem_fala",
|
||||
mensagem=f"ação {indice + 1}: o trecho do gancho não contém palavras temporizadas",
|
||||
indice_da_acao=indice,
|
||||
instante=gancho.inicio,
|
||||
)]
|
||||
|
||||
primeira, ultima = contidas[0], contidas[-1]
|
||||
problemas: list[ProblemaSemanticoDoPlano] = []
|
||||
if primeira.inicio - gancho.inicio < self._margem_minima - self._TOLERANCIA_EM_SEGUNDOS:
|
||||
problemas.append(ProblemaSemanticoDoPlano(
|
||||
codigo="margem_inicial_do_gancho_insuficiente",
|
||||
mensagem=(
|
||||
f"ação {indice + 1}: deixe ao menos {self._margem_minima:.2f}s antes "
|
||||
f'da primeira palavra do gancho "{primeira.texto}"'
|
||||
),
|
||||
indice_da_acao=indice,
|
||||
instante=gancho.inicio,
|
||||
))
|
||||
if gancho.fim - ultima.fim < self._margem_minima - self._TOLERANCIA_EM_SEGUNDOS:
|
||||
problemas.append(ProblemaSemanticoDoPlano(
|
||||
codigo="margem_final_do_gancho_insuficiente",
|
||||
mensagem=(
|
||||
f"ação {indice + 1}: deixe ao menos {self._margem_minima:.2f}s após "
|
||||
f'a última palavra do gancho "{ultima.texto}"'
|
||||
),
|
||||
indice_da_acao=indice,
|
||||
instante=gancho.fim,
|
||||
))
|
||||
fala_inicial = self._fala_da_palavra(primeira, falas)
|
||||
primeira_letra = next((letra for letra in primeira.texto if letra.isalpha()), "")
|
||||
comeca_no_meio = (
|
||||
fala_inicial is not None
|
||||
and primeira.inicio > fala_inicial.inicio + self._TOLERANCIA_EM_SEGUNDOS
|
||||
) or bool(primeira_letra and primeira_letra.islower())
|
||||
if comeca_no_meio:
|
||||
problemas.append(ProblemaSemanticoDoPlano(
|
||||
codigo="gancho_comeca_no_meio_da_frase",
|
||||
mensagem=f'ação {indice + 1}: o gancho começa em "{primeira.texto}", no meio da frase',
|
||||
indice_da_acao=indice,
|
||||
instante=gancho.inicio,
|
||||
))
|
||||
if not ultima.texto.rstrip().endswith(self._PONTUACAO_DE_FECHAMENTO):
|
||||
problemas.append(ProblemaSemanticoDoPlano(
|
||||
codigo="gancho_termina_no_meio_da_frase",
|
||||
mensagem=f'ação {indice + 1}: o gancho termina em "{ultima.texto}" sem concluir a frase',
|
||||
indice_da_acao=indice,
|
||||
instante=gancho.fim,
|
||||
))
|
||||
return problemas
|
||||
|
||||
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)
|
||||
@@ -0,0 +1,183 @@
|
||||
"""Verificação estrutural da timeline depois da aplicação de um plano.
|
||||
|
||||
Compara a cobertura de origem que deveria sobreviver aos cortes com a
|
||||
cobertura realmente lida no Premiere depois da mutação. A verificação não
|
||||
avalia pixels, áudio ou qualidade editorial; ela detecta sobras, lacunas e
|
||||
desalinhamentos estruturais em cada tipo de faixa.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from ..dominio import Clipe, Faixa, IntervaloDeTempo, Timeline
|
||||
from .modelos import PlanoDeEdicao, TipoDeAcao
|
||||
|
||||
|
||||
_TOLERANCIA_EM_SEGUNDOS = 0.05
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResultadoDaVerificacao:
|
||||
"""Resultado da comparação entre a timeline esperada e a timeline lida."""
|
||||
|
||||
aprovada: bool
|
||||
divergencias: tuple[str, ...] = ()
|
||||
avisos: tuple[str, ...] = ()
|
||||
|
||||
@property
|
||||
def mensagem(self) -> str:
|
||||
"""Resume a verificação em uma mensagem adequada para o histórico."""
|
||||
if self.aprovada:
|
||||
mensagem = "verificação pós-aplicação aprovada"
|
||||
else:
|
||||
mensagem = "verificação pós-aplicação reprovada: " + " | ".join(self.divergencias)
|
||||
if self.avisos:
|
||||
mensagem += "; avisos: " + " | ".join(self.avisos)
|
||||
return mensagem
|
||||
|
||||
|
||||
class VerificadorDaAplicacao:
|
||||
"""Confere se a cobertura de mídia depois da aplicação bate com o plano."""
|
||||
|
||||
def __init__(self, tolerancia_em_segundos: float = _TOLERANCIA_EM_SEGUNDOS) -> None:
|
||||
"""Configura a tolerância de arredondamento do Premiere."""
|
||||
if tolerancia_em_segundos < 0:
|
||||
raise ValueError("A tolerância da verificação não pode ser negativa.")
|
||||
self._tolerancia = tolerancia_em_segundos
|
||||
|
||||
def verificar(
|
||||
self,
|
||||
timeline_antes: Timeline,
|
||||
timeline_depois: Timeline,
|
||||
plano: PlanoDeEdicao,
|
||||
) -> ResultadoDaVerificacao:
|
||||
"""Compara os intervalos de origem sobreviventes antes e depois da aplicação."""
|
||||
tipos_de_faixa = {faixa.tipo for faixa in timeline_antes.faixas}
|
||||
divergencias: list[str] = []
|
||||
avisos: list[str] = []
|
||||
cortes = tuple(
|
||||
acao for acao in plano.acoes if acao.tipo is TipoDeAcao.CORTE
|
||||
)
|
||||
for tipo_de_faixa in sorted(tipos_de_faixa):
|
||||
esperados = self._intervalos_esperados(
|
||||
timeline_antes, tipo_de_faixa, plano.arquivo_de_origem, cortes
|
||||
)
|
||||
encontrados = self._intervalos_da_timeline(
|
||||
timeline_depois, tipo_de_faixa, plano.arquivo_de_origem
|
||||
)
|
||||
if not self._intervalos_equivalentes(esperados, encontrados):
|
||||
divergencias.append(
|
||||
f"faixa {tipo_de_faixa}: esperado {self._formatar_intervalos(esperados)}, "
|
||||
f"encontrado {self._formatar_intervalos(encontrados)}"
|
||||
)
|
||||
avisos.extend(self._avisar_material_posterior_à_ultima_acao(
|
||||
timeline_depois, plano, tipo_de_faixa
|
||||
))
|
||||
return ResultadoDaVerificacao(not divergencias, tuple(divergencias), tuple(avisos))
|
||||
|
||||
def _avisar_material_posterior_à_ultima_acao(
|
||||
self, timeline: Timeline, plano: PlanoDeEdicao, tipo_de_faixa: str
|
||||
) -> list[str]:
|
||||
"""Sinaliza material depois da última ação para revisão editorial."""
|
||||
if not plano.acoes:
|
||||
return []
|
||||
ultima_acao = max(plano.acoes, key=lambda acao: acao.fim)
|
||||
if ultima_acao.tipo is not TipoDeAcao.CORTE:
|
||||
return []
|
||||
sobras = [
|
||||
clipe.intervalo_na_origem.fim
|
||||
for faixa in timeline.faixas
|
||||
if faixa.tipo == tipo_de_faixa
|
||||
for clipe in self._clipes_do_arquivo(faixa, plano.arquivo_de_origem)
|
||||
if clipe.intervalo_na_origem.inicio >= ultima_acao.fim - self._tolerancia
|
||||
and clipe.intervalo_na_origem.fim > ultima_acao.fim + self._tolerancia
|
||||
]
|
||||
if not sobras:
|
||||
return []
|
||||
return [
|
||||
f"faixa {tipo_de_faixa}: há material após a última ação, "
|
||||
f"a partir de {ultima_acao.fim:.2f}s; conferir possível sobra"
|
||||
]
|
||||
|
||||
def _intervalos_esperados(
|
||||
self,
|
||||
timeline: Timeline,
|
||||
tipo_de_faixa: str,
|
||||
arquivo_de_origem: str,
|
||||
cortes: tuple,
|
||||
) -> tuple[tuple[float, float], ...]:
|
||||
intervalos: list[tuple[float, float]] = []
|
||||
for faixa in timeline.faixas:
|
||||
if faixa.tipo != tipo_de_faixa:
|
||||
continue
|
||||
for clipe in self._clipes_do_arquivo(faixa, arquivo_de_origem):
|
||||
intervalos.extend(self._subtrair_cortes(clipe.intervalo_na_origem, cortes))
|
||||
return self._normalizar_intervalos(intervalos)
|
||||
|
||||
def _intervalos_da_timeline(
|
||||
self, timeline: Timeline, tipo_de_faixa: str, arquivo_de_origem: str
|
||||
) -> tuple[tuple[float, float], ...]:
|
||||
intervalos = [
|
||||
(clipe.intervalo_na_origem.inicio, clipe.intervalo_na_origem.fim)
|
||||
for faixa in timeline.faixas
|
||||
if faixa.tipo == tipo_de_faixa
|
||||
for clipe in self._clipes_do_arquivo(faixa, arquivo_de_origem)
|
||||
]
|
||||
return self._normalizar_intervalos(intervalos)
|
||||
|
||||
def _subtrair_cortes(self, intervalo: IntervaloDeTempo, cortes: tuple) -> list[tuple[float, float]]:
|
||||
partes = [(intervalo.inicio, intervalo.fim)]
|
||||
for corte in cortes:
|
||||
novas_partes: list[tuple[float, float]] = []
|
||||
for inicio, fim in partes:
|
||||
if corte.fim <= inicio or corte.inicio >= fim:
|
||||
novas_partes.append((inicio, fim))
|
||||
continue
|
||||
if inicio < corte.inicio:
|
||||
novas_partes.append((inicio, min(fim, corte.inicio)))
|
||||
if corte.fim < fim:
|
||||
novas_partes.append((max(inicio, corte.fim), fim))
|
||||
partes = novas_partes
|
||||
return partes
|
||||
|
||||
def _intervalos_equivalentes(
|
||||
self, esperados: tuple[tuple[float, float], ...], encontrados: tuple[tuple[float, float], ...]
|
||||
) -> bool:
|
||||
if len(esperados) != len(encontrados):
|
||||
return False
|
||||
return all(
|
||||
abs(inicio_esperado - inicio_encontrado) <= self._tolerancia
|
||||
and abs(fim_esperado - fim_encontrado) <= self._tolerancia
|
||||
for (inicio_esperado, fim_esperado), (inicio_encontrado, fim_encontrado)
|
||||
in zip(esperados, encontrados)
|
||||
)
|
||||
|
||||
def _clipes_do_arquivo(self, faixa: Faixa, arquivo_de_origem: str) -> list[Clipe]:
|
||||
nome_alvo = arquivo_de_origem.rsplit("/", 1)[-1].lower()
|
||||
return [
|
||||
clipe for clipe in faixa.clipes
|
||||
if clipe.intervalo_na_origem is not None
|
||||
and (clipe.nome or "").rsplit("/", 1)[-1].lower() == nome_alvo
|
||||
]
|
||||
|
||||
def _normalizar_intervalos(
|
||||
self, intervalos: list[tuple[float, float]]
|
||||
) -> tuple[tuple[float, float], ...]:
|
||||
ordenados = sorted(intervalos)
|
||||
unidos: list[list[float]] = []
|
||||
for inicio, fim in ordenados:
|
||||
if fim <= inicio + self._tolerancia:
|
||||
continue
|
||||
if unidos and inicio <= unidos[-1][1] + self._tolerancia:
|
||||
unidos[-1][1] = max(unidos[-1][1], fim)
|
||||
else:
|
||||
unidos.append([inicio, fim])
|
||||
return tuple((inicio, fim) for inicio, fim in unidos)
|
||||
|
||||
@staticmethod
|
||||
def _formatar_intervalos(intervalos: tuple[tuple[float, float], ...]) -> str:
|
||||
"""Formata intervalos para mensagens curtas e conferíveis."""
|
||||
if not intervalos:
|
||||
return "[]"
|
||||
return "[" + ", ".join(f"{inicio:.2f}-{fim:.2f}s" for inicio, fim in intervalos) + "]"
|
||||
@@ -40,6 +40,7 @@ if CAMINHO_DA_MIDIA:
|
||||
|
||||
from engine.integracoes.premiere.conversores import ConversorDeTimeline
|
||||
from engine.integracoes.midia import ExtracaoDeAudio
|
||||
from engine.dominio import Clipe
|
||||
from engine.scanner import ConfiguracaoVisualDoScanner, criar_detector_apple_vision, gerar_relatorio_visual
|
||||
from engine.scanner.transcricao_da_timeline import TranscricaoDaTimeline
|
||||
from engine.persistencia import RepositorioDeAnalisesSQLite, RepositorioDeTimelineSQLite
|
||||
@@ -65,6 +66,24 @@ def nome_seguro(nome: str) -> str:
|
||||
return re.sub(r"[^A-Za-z0-9._-]+", "-", nome.strip()).strip(".-") or "timeline"
|
||||
|
||||
|
||||
def serializar_clipe_para_relatorio(clipe: Clipe) -> dict[str, object]:
|
||||
"""Preserva no relatório os dados que identificam a mídia e sua origem.
|
||||
|
||||
As chaves ``sourceFile``, ``inPoint`` e ``outPoint`` mantêm o contrato
|
||||
externo usado pelo painel CEP e pelo bridge do Premiere.
|
||||
"""
|
||||
intervalo_na_origem = clipe.intervalo_na_origem
|
||||
return {
|
||||
"identificador": clipe.identificador,
|
||||
"nome": clipe.nome,
|
||||
"inicio": clipe.intervalo_na_timeline.inicio,
|
||||
"fim": clipe.intervalo_na_timeline.fim,
|
||||
"sourceFile": clipe.arquivo,
|
||||
"inPoint": intervalo_na_origem.inicio if intervalo_na_origem else None,
|
||||
"outPoint": intervalo_na_origem.fim if intervalo_na_origem else None,
|
||||
}
|
||||
|
||||
|
||||
def executar(entrada: Path, saida: Path) -> Path:
|
||||
"""Executa uma análise do Scanner conforme o pedido do painel.
|
||||
|
||||
@@ -139,9 +158,7 @@ def executar(entrada: Path, saida: Path) -> Path:
|
||||
if analisar_som and faixa.tipo == "audio"
|
||||
and faixa.indice in configuracao.faixas_de_audio]
|
||||
audio = [{"indice": faixa.indice, "nome": faixa.nome,
|
||||
"clipes": [{"identificador": clipe.identificador, "nome": clipe.nome,
|
||||
"inicio": clipe.intervalo_na_timeline.inicio, "fim": clipe.intervalo_na_timeline.fim}
|
||||
for clipe in faixa.clipes]}
|
||||
"clipes": [serializar_clipe_para_relatorio(clipe) for clipe in faixa.clipes]}
|
||||
for faixa in faixas_de_audio]
|
||||
caso = nome_seguro(timeline.nome)
|
||||
pasta = saida.parent / caso
|
||||
@@ -165,6 +182,7 @@ def executar(entrada: Path, saida: Path) -> Path:
|
||||
transcricao_configurada = {
|
||||
**transcricao_configurada,
|
||||
"metricas_de_fala": configuracao.metricas_de_fala,
|
||||
"classificar_emocao": configuracao.classificar_emocao,
|
||||
}
|
||||
if faixas_de_audio and transcricao_configurada.get("caminho_modelo"):
|
||||
try:
|
||||
@@ -179,6 +197,10 @@ def executar(entrada: Path, saida: Path) -> Path:
|
||||
analisador_de_metricas_de_voz = (
|
||||
AnalisadorDeMetricasDeVoz() if configuracao.metricas_de_fala else None
|
||||
)
|
||||
analisador_de_emocao = None
|
||||
if configuracao.classificar_emocao:
|
||||
from engine.integracoes.huggingface import ProviderDeEmocaoHuggingFace
|
||||
analisador_de_emocao = ProviderDeEmocaoHuggingFace()
|
||||
diarizador = None
|
||||
if transcricao_configurada.get("diarizacao"):
|
||||
if not os.environ.get("HF_TOKEN"):
|
||||
@@ -193,7 +215,8 @@ def executar(entrada: Path, saida: Path) -> Path:
|
||||
emitir("Transcrevendo áudio", 5)
|
||||
transcricoes = TranscricaoDaTimeline(provider, diretoria_de_trabalho=pasta / ".cache-audio",
|
||||
diarizador=diarizador,
|
||||
analisador_de_metricas_de_voz=analisador_de_metricas_de_voz).executar(
|
||||
analisador_de_metricas_de_voz=analisador_de_metricas_de_voz,
|
||||
analisador_de_emocao=analisador_de_emocao).executar(
|
||||
Timeline(timeline.identificador, timeline.nome, faixas=faixas_de_audio))
|
||||
# Persiste a transcrição (segmentos + falas) no banco de análises.
|
||||
repositorio_de_analises.substituir_transcricoes(timeline.identificador, transcricoes)
|
||||
@@ -201,7 +224,9 @@ def executar(entrada: Path, saida: Path) -> Path:
|
||||
transcricoes_por_clipe[transcricao.identificador_do_clipe] = [
|
||||
{"inicio": item.inicio, "fim": item.fim, "texto": item.texto,
|
||||
"confianca": item.confianca, "falante": item.falante,
|
||||
"caracteristicas_acusticas": item.caracteristicas_acusticas}
|
||||
"caracteristicas_acusticas": item.caracteristicas_acusticas,
|
||||
"emocao": item.emocao, "confianca_emocao": item.confianca_emocao,
|
||||
"voz_aparente": item.voz_aparente, "confianca_voz": item.confianca_voz}
|
||||
for item in transcricao.segmentos]
|
||||
except (ImportError, OSError, RuntimeError, ValueError) as erro:
|
||||
# Dependência ausente (ex.: soundfile), modelo/áudio ilegível, ou
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
"""Integrações locais para análise de áudio."""
|
||||
"""Integrações locais para análise de áudio e música."""
|
||||
|
||||
from .analisador_de_metricas_de_voz import AnalisadorDeMetricasDeVoz
|
||||
from .classificador_de_musica_essentia import ClassificadorDeMusicaEssentia
|
||||
from .modelos_de_analise_musical import PontuacaoDeEtiqueta, ResultadoDaAnaliseMusical
|
||||
from .provider_de_analise_musical_essentia import AnalisadorDeMusicaInstrumentalEssentia
|
||||
|
||||
__all__ = ["AnalisadorDeMetricasDeVoz"]
|
||||
__all__ = [
|
||||
"AnalisadorDeMetricasDeVoz",
|
||||
"ClassificadorDeMusicaEssentia",
|
||||
"PontuacaoDeEtiqueta",
|
||||
"AnalisadorDeMusicaInstrumentalEssentia",
|
||||
"ResultadoDaAnaliseMusical",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
"""
|
||||
Classificação semântica de música por modelos oficiais do Essentia.
|
||||
|
||||
Este módulo carrega os pesos de gênero e humor, extrai um embedding de áudio
|
||||
uma única vez e aplica os classificadores sobre esse embedding. Os arquivos
|
||||
dos modelos ficam fora do repositório e são informados por configuração.
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .modelos_de_analise_musical import PontuacaoDeEtiqueta
|
||||
|
||||
|
||||
class ClassificadorDeMusicaEssentia:
|
||||
"""
|
||||
Classifica gênero e humor diretamente do sinal de áudio.
|
||||
|
||||
A classe não lê tags do arquivo, não produz texto narrativo e não
|
||||
persiste resultados. Ela depende dos pesos ``.pb`` e metadados ``.json``
|
||||
oficiais do Essentia, recebidos por caminho.
|
||||
"""
|
||||
|
||||
NOME_DO_MODELO_DE_EMBEDDING = "discogs-effnet-bs64-1.pb"
|
||||
NOME_DO_MODELO_DE_GENERO = "genre_discogs400-discogs-effnet-1.pb"
|
||||
NOME_DOS_METADADOS_DE_GENERO = "genre_discogs400-discogs-effnet-1.json"
|
||||
NOME_DO_MODELO_DE_HUMOR = "mtg_jamendo_moodtheme-discogs-effnet-1.pb"
|
||||
NOME_DOS_METADADOS_DE_HUMOR = "mtg_jamendo_moodtheme-discogs-effnet-1.json"
|
||||
|
||||
def __init__(self, diretorio_dos_modelos: str | Path) -> None:
|
||||
"""Inicializa o classificador com o diretório dos pesos locais."""
|
||||
self._diretorio_dos_modelos = Path(diretorio_dos_modelos).expanduser().resolve()
|
||||
self._modelos_carregados: tuple[Any, Any, Any] | None = None
|
||||
self._classes_por_tarefa: dict[str, tuple[str, ...]] | None = None
|
||||
|
||||
def classificar(
|
||||
self, arquivo: str | Path,
|
||||
) -> tuple[tuple[PontuacaoDeEtiqueta, ...], tuple[PontuacaoDeEtiqueta, ...], tuple[PontuacaoDeEtiqueta, ...]]:
|
||||
"""
|
||||
Classifica o gênero e o humor diretamente do conteúdo do arquivo.
|
||||
|
||||
Parâmetros:
|
||||
arquivo: Caminho de áudio que será lido pelo Essentia.
|
||||
|
||||
Retorna:
|
||||
Tupla com pontuações de gênero e de humor, em ordem decrescente.
|
||||
|
||||
Pode gerar:
|
||||
FileNotFoundError: quando o arquivo não existir.
|
||||
RuntimeError: quando Essentia ou algum modelo não estiver disponível.
|
||||
"""
|
||||
caminho = Path(arquivo).expanduser().resolve(strict=False)
|
||||
if not caminho.is_file():
|
||||
raise FileNotFoundError(f"Arquivo de áudio não encontrado: {caminho}")
|
||||
try:
|
||||
from essentia.standard import (
|
||||
MonoLoader,
|
||||
TensorflowPredict2D,
|
||||
TensorflowPredictEffnetDiscogs,
|
||||
)
|
||||
except ImportError as erro:
|
||||
raise RuntimeError(
|
||||
"A classificação musical precisa do suporte TensorFlow do Essentia."
|
||||
) from erro
|
||||
|
||||
modelo_de_embedding, modelo_de_genero, modelo_de_humor = self._obter_modelos(
|
||||
TensorflowPredictEffnetDiscogs, TensorflowPredict2D,
|
||||
)
|
||||
audio = MonoLoader(filename=str(caminho), sampleRate=16000, resampleQuality=4)()
|
||||
embedding = modelo_de_embedding(audio)
|
||||
previsao_de_genero = modelo_de_genero(embedding)
|
||||
previsao_de_humor = modelo_de_humor(embedding)
|
||||
pontuacoes_de_humor = self._converter_pontuacoes(previsao_de_humor, "humor")
|
||||
nomes_de_tema = {"action", "adventure", "advertising", "background", "children", "christmas", "commercial", "corporate", "documentary", "drama", "film", "game", "movie", "nature", "sport", "trailer", "travel"}
|
||||
return (
|
||||
self._duas_maiores(self._converter_pontuacoes(previsao_de_genero, "genero")),
|
||||
self._duas_maiores(tuple(item for item in pontuacoes_de_humor if item.etiqueta not in nomes_de_tema)),
|
||||
self._duas_maiores(tuple(item for item in pontuacoes_de_humor if item.etiqueta in nomes_de_tema)),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _duas_maiores(pontuacoes: tuple[PontuacaoDeEtiqueta, ...]) -> tuple[PontuacaoDeEtiqueta, ...]:
|
||||
"""Retorna as duas maiores probabilidades, preservando o ranking."""
|
||||
return pontuacoes[:2]
|
||||
|
||||
def _obter_modelos(self, classe_de_embedding: Any, classe_de_classificacao: Any):
|
||||
if self._modelos_carregados is not None:
|
||||
return self._modelos_carregados
|
||||
caminhos = {
|
||||
"embedding": self._diretorio_dos_modelos / self.NOME_DO_MODELO_DE_EMBEDDING,
|
||||
"genero": self._diretorio_dos_modelos / self.NOME_DO_MODELO_DE_GENERO,
|
||||
"humor": self._diretorio_dos_modelos / self.NOME_DO_MODELO_DE_HUMOR,
|
||||
}
|
||||
ausentes = [str(caminho) for caminho in caminhos.values() if not caminho.is_file()]
|
||||
if ausentes:
|
||||
raise RuntimeError("Modelos Essentia ausentes: " + ", ".join(ausentes))
|
||||
self._modelos_carregados = (
|
||||
classe_de_embedding(
|
||||
graphFilename=str(caminhos["embedding"]), output="PartitionedCall:1",
|
||||
),
|
||||
classe_de_classificacao(
|
||||
graphFilename=str(caminhos["genero"]),
|
||||
input="serving_default_model_Placeholder", output="PartitionedCall:0",
|
||||
),
|
||||
classe_de_classificacao(graphFilename=str(caminhos["humor"])),
|
||||
)
|
||||
self._classes_por_tarefa = {
|
||||
"genero": self._carregar_classes(self.NOME_DOS_METADADOS_DE_GENERO),
|
||||
"humor": self._carregar_classes(self.NOME_DOS_METADADOS_DE_HUMOR),
|
||||
}
|
||||
return self._modelos_carregados
|
||||
|
||||
def _carregar_classes(self, nome_do_arquivo: str) -> tuple[str, ...]:
|
||||
caminho = self._diretorio_dos_modelos / nome_do_arquivo
|
||||
if not caminho.is_file():
|
||||
raise RuntimeError(f"Metadados Essentia ausentes: {caminho}")
|
||||
try:
|
||||
dados = json.loads(caminho.read_text(encoding="utf-8"))
|
||||
classes = dados["classes"]
|
||||
except (OSError, json.JSONDecodeError, KeyError, TypeError) as erro:
|
||||
raise RuntimeError(f"Metadados Essentia inválidos: {caminho}") from erro
|
||||
if not isinstance(classes, list) or not all(isinstance(item, str) for item in classes):
|
||||
raise RuntimeError(f"Lista de classes Essentia inválida: {caminho}")
|
||||
return tuple(classes)
|
||||
|
||||
def _converter_pontuacoes(self, previsoes: Any, tarefa: str) -> tuple[PontuacaoDeEtiqueta, ...]:
|
||||
import numpy as np
|
||||
|
||||
if self._classes_por_tarefa is None:
|
||||
raise RuntimeError("As classes dos modelos ainda não foram carregadas.")
|
||||
valores = np.asarray(previsoes).mean(axis=0).tolist()
|
||||
classes = self._classes_por_tarefa[tarefa]
|
||||
if len(valores) != len(classes):
|
||||
raise RuntimeError(
|
||||
f"Quantidade de previsões incompatível para {tarefa}: "
|
||||
f"{len(valores)} valores e {len(classes)} classes."
|
||||
)
|
||||
pontuacoes = (
|
||||
PontuacaoDeEtiqueta(etiqueta=etiqueta, confianca=float(valor))
|
||||
for etiqueta, valor in zip(classes, valores)
|
||||
)
|
||||
return tuple(sorted(pontuacoes, key=lambda item: item.confianca, reverse=True))
|
||||
@@ -0,0 +1,32 @@
|
||||
"""
|
||||
Contratos para provedores de análise musical.
|
||||
|
||||
O contrato permite que a aplicação use Essentia, um dublê de teste ou outro
|
||||
provedor no futuro sem conhecer detalhes de carregamento e classificação.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Protocol
|
||||
|
||||
from .modelos_de_analise_musical import ResultadoDaAnaliseMusical
|
||||
|
||||
|
||||
class AnalisadorDeMusica(Protocol):
|
||||
"""Define a operação necessária para analisar um arquivo musical."""
|
||||
|
||||
def analisar(self, arquivo: str | Path) -> ResultadoDaAnaliseMusical:
|
||||
"""
|
||||
Analisa um arquivo de música e devolve seus descritores internos.
|
||||
|
||||
Parâmetros:
|
||||
arquivo: Caminho de um arquivo de áudio existente.
|
||||
|
||||
Retorna:
|
||||
Resultado estruturado da análise musical.
|
||||
|
||||
Pode gerar:
|
||||
FileNotFoundError: quando o arquivo não existir.
|
||||
RuntimeError: quando Essentia não estiver instalado.
|
||||
"""
|
||||
|
||||
...
|
||||
@@ -0,0 +1,47 @@
|
||||
"""
|
||||
Modelos internos para representar análises de músicas instrumentais.
|
||||
|
||||
Este módulo define somente os dados produzidos pela análise. Ele não importa
|
||||
Essentia, não lê arquivos e não transforma o resultado em texto para a
|
||||
interface.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PontuacaoDeEtiqueta:
|
||||
"""Representa uma etiqueta musical e a confiança calculada para ela."""
|
||||
|
||||
etiqueta: str
|
||||
confianca: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResultadoDaAnaliseMusical:
|
||||
"""
|
||||
Representa os descritores calculados para uma música instrumental.
|
||||
|
||||
A classe mantém valores objetivos, como BPM e tonalidade, além das
|
||||
classificações fornecidas pelos modelos disponíveis. Ela não afirma uma
|
||||
interpretação definitiva da intenção artística da música.
|
||||
"""
|
||||
|
||||
caminho_do_arquivo: Path
|
||||
duracao_em_segundos: float | None = None
|
||||
batidas_por_minuto: float | None = None
|
||||
tonalidade: str | None = None
|
||||
modo: str | None = None
|
||||
intensidade: float | None = None
|
||||
dancabilidade: float | None = None
|
||||
generos: tuple[str, ...] = field(default_factory=tuple)
|
||||
humores: tuple[str, ...] = field(default_factory=tuple)
|
||||
instrumentos: tuple[str, ...] = field(default_factory=tuple)
|
||||
etiquetas: tuple[str, ...] = field(default_factory=tuple)
|
||||
pontuacoes_de_genero: tuple[PontuacaoDeEtiqueta, ...] = field(default_factory=tuple)
|
||||
pontuacoes_de_humor: tuple[PontuacaoDeEtiqueta, ...] = field(default_factory=tuple)
|
||||
pontuacoes_de_tema: tuple[PontuacaoDeEtiqueta, ...] = field(default_factory=tuple)
|
||||
temas: tuple[str, ...] = field(default_factory=tuple)
|
||||
provedor: str = "Essentia"
|
||||
modelo: str | None = None
|
||||
@@ -0,0 +1,175 @@
|
||||
"""
|
||||
Adaptador local para análise musical usando Essentia.
|
||||
|
||||
O adaptador concentra a dependência externa e converte seus descritores para
|
||||
``ResultadoDaAnaliseMusical``. Ele não persiste resultados nem conhece a
|
||||
interface do painel. As classificações de gênero, humor e instrumentos são
|
||||
lidas quando os modelos de alto nível instalados retornarem essas categorias.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
from .modelos_de_analise_musical import ResultadoDaAnaliseMusical
|
||||
|
||||
|
||||
class AnalisadorDeMusicaInstrumentalEssentia:
|
||||
"""
|
||||
Analisa arquivos de áudio localmente por meio do ``MusicExtractor``.
|
||||
|
||||
A criação do extrator é tardia para que a engine continue importável sem
|
||||
Essentia instalada. Um extrator compatível pode ser injetado nos testes,
|
||||
evitando dependência de modelos pesados durante o teste unitário.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
extrator: Callable[[str], tuple[Any, Any]] | None = None,
|
||||
nome_do_modelo: str | None = None,
|
||||
classificador: Any | None = None,
|
||||
) -> None:
|
||||
"""Inicializa o adaptador sem carregar a dependência externa."""
|
||||
self._extrator = extrator
|
||||
self._nome_do_modelo = nome_do_modelo
|
||||
self._classificador = classificador
|
||||
|
||||
def analisar(self, arquivo: str | Path) -> ResultadoDaAnaliseMusical:
|
||||
"""
|
||||
Analisa um arquivo de música instrumental.
|
||||
|
||||
Parâmetros:
|
||||
arquivo: Caminho de áudio que será analisado.
|
||||
|
||||
Retorna:
|
||||
Descritores objetivos e classificações disponíveis.
|
||||
|
||||
Pode gerar:
|
||||
FileNotFoundError: quando o caminho não apontar para um arquivo.
|
||||
RuntimeError: quando Essentia não estiver instalada.
|
||||
ValueError: quando o resultado externo não possuir formato válido.
|
||||
"""
|
||||
caminho = Path(arquivo).expanduser().resolve(strict=False)
|
||||
if not caminho.is_file():
|
||||
raise FileNotFoundError(f"Arquivo de áudio não encontrado: {caminho}")
|
||||
|
||||
dados, _ = self._obter_extrator()(str(caminho))
|
||||
if dados is None:
|
||||
raise ValueError("O Essentia devolveu uma análise vazia.")
|
||||
pontuacoes_de_genero: tuple[Any, ...] = ()
|
||||
pontuacoes_de_humor: tuple[Any, ...] = ()
|
||||
pontuacoes_de_tema: tuple[Any, ...] = ()
|
||||
if self._classificador is not None:
|
||||
classificacoes = self._classificador.classificar(caminho)
|
||||
pontuacoes_de_genero, pontuacoes_de_humor = classificacoes[:2]
|
||||
if len(classificacoes) > 2:
|
||||
pontuacoes_de_tema = classificacoes[2]
|
||||
generos = (
|
||||
tuple(item.etiqueta for item in pontuacoes_de_genero)
|
||||
if pontuacoes_de_genero
|
||||
else self._etiquetas(dados, ("genre", "genres"))
|
||||
)
|
||||
humores = (
|
||||
tuple(item.etiqueta for item in pontuacoes_de_humor)
|
||||
if pontuacoes_de_humor
|
||||
else self._etiquetas(dados, ("mood", "moods"))
|
||||
)
|
||||
return self._converter_resultado(
|
||||
caminho, dados, pontuacoes_de_genero, pontuacoes_de_humor, generos, humores, pontuacoes_de_tema,
|
||||
)
|
||||
|
||||
def _obter_extrator(self) -> Callable[[str], tuple[Any, Any]]:
|
||||
if self._extrator is not None:
|
||||
return self._extrator
|
||||
try:
|
||||
from essentia.standard import MusicExtractor
|
||||
except ImportError as erro:
|
||||
raise RuntimeError(
|
||||
"A análise musical local precisa da biblioteca Essentia. "
|
||||
"Instale as dependências de requirements-audio.txt."
|
||||
) from erro
|
||||
self._extrator = MusicExtractor()
|
||||
return self._extrator
|
||||
|
||||
def _converter_resultado(
|
||||
self,
|
||||
caminho: Path,
|
||||
dados: Any,
|
||||
pontuacoes_de_genero: tuple[Any, ...] = (),
|
||||
pontuacoes_de_humor: tuple[Any, ...] = (),
|
||||
generos: tuple[str, ...] = (),
|
||||
humores: tuple[str, ...] = (),
|
||||
pontuacoes_de_tema: tuple[Any, ...] = (),
|
||||
) -> ResultadoDaAnaliseMusical:
|
||||
return ResultadoDaAnaliseMusical(
|
||||
caminho_do_arquivo=caminho,
|
||||
duracao_em_segundos=self._numero(self._buscar(dados, "metadata.audio_properties.length")),
|
||||
batidas_por_minuto=self._numero(self._buscar(dados, "rhythm.bpm")),
|
||||
tonalidade=self._texto(self._buscar(dados, "tonal.key_edma.key")),
|
||||
modo=self._texto(self._buscar(dados, "tonal.key_edma.scale")),
|
||||
intensidade=self._numero(self._buscar(dados, "lowlevel.average_loudness")),
|
||||
dancabilidade=self._numero(self._buscar(dados, "rhythm.danceability")),
|
||||
generos=generos,
|
||||
humores=humores,
|
||||
temas=tuple(item.etiqueta for item in pontuacoes_de_tema),
|
||||
instrumentos=self._etiquetas(dados, ("instrument", "instruments")),
|
||||
etiquetas=self._etiquetas(dados, ("tags", "semantic")),
|
||||
pontuacoes_de_genero=pontuacoes_de_genero,
|
||||
pontuacoes_de_humor=pontuacoes_de_humor,
|
||||
pontuacoes_de_tema=pontuacoes_de_tema,
|
||||
modelo=self._nome_do_modelo,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _buscar(dados: Any, caminho: str) -> Any:
|
||||
"""Busca uma chave pontuada em um resultado Essentia ou devolve None.
|
||||
|
||||
O ``Pool`` do Essentia devolve descritores com a chave completa, como
|
||||
``rhythm.bpm``. Dublês de teste e algumas integrações podem devolvê-los
|
||||
como dicionários aninhados; os dois formatos são aceitos aqui.
|
||||
"""
|
||||
try:
|
||||
return dados[caminho]
|
||||
except (KeyError, IndexError, TypeError):
|
||||
pass
|
||||
valor = dados
|
||||
for parte in caminho.split("."):
|
||||
try:
|
||||
valor = valor[parte]
|
||||
except (KeyError, IndexError, TypeError):
|
||||
return None
|
||||
return valor
|
||||
|
||||
@classmethod
|
||||
def _etiquetas(cls, dados: Any, nomes: tuple[str, ...]) -> tuple[str, ...]:
|
||||
"""Converte classificações externas em uma tupla ordenada de textos."""
|
||||
encontrados: list[str] = []
|
||||
for nome in nomes:
|
||||
valor = cls._buscar(dados, f"highlevel.{nome}.value")
|
||||
if isinstance(valor, dict):
|
||||
encontrados.extend(
|
||||
str(chave) for chave, pontuacao in valor.items()
|
||||
if cls._numero(pontuacao) is not None and float(pontuacao) > 0
|
||||
)
|
||||
elif isinstance(valor, (list, tuple)):
|
||||
encontrados.extend(str(item) for item in valor)
|
||||
elif isinstance(valor, str):
|
||||
encontrados.append(valor)
|
||||
return tuple(dict.fromkeys(encontrados))
|
||||
|
||||
@staticmethod
|
||||
def _numero(valor: Any) -> float | None:
|
||||
"""Converte valores numéricos externos sem aceitar booleanos."""
|
||||
if isinstance(valor, bool) or valor is None:
|
||||
return None
|
||||
try:
|
||||
return float(valor)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _texto(valor: Any) -> str | None:
|
||||
"""Converte valores textuais externos, ignorando vazios."""
|
||||
if valor is None or isinstance(valor, (dict, list, tuple)):
|
||||
return None
|
||||
texto = str(valor).strip()
|
||||
return texto or None
|
||||
@@ -63,6 +63,7 @@ class ClienteMCPPorStdio(ClienteMCP):
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
bufsize=1,
|
||||
)
|
||||
self._enviar("initialize", {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "engine", "version": "0.1.0"}})
|
||||
@@ -99,6 +100,12 @@ class ClienteMCPPorStdio(ClienteMCP):
|
||||
raise ErroDeFerramentaMCP(
|
||||
f"A ferramenta {nome_da_ferramenta!r} falhou: {self._mensagem_de_erro(resultado)}"
|
||||
)
|
||||
# O servidor MCP (SDK 2.0+) envolve o dado da ferramenta em
|
||||
# ``structuredContent.data``. Desembrulha para que o chamador receba
|
||||
# o objeto de resultado direto (ex.: ``{"totalTracks": 2, ...}``).
|
||||
sc = resultado.get("structuredContent")
|
||||
if isinstance(sc, dict) and "data" in sc:
|
||||
return sc["data"]
|
||||
return resultado
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -560,7 +560,64 @@ func descreverEvidencia(_ evidencia: Evidencia) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Classificação de ambiente extraída das evidências visuais.
|
||||
/// Usado pelo Foundation Models para categorizar o cenário do frame.
|
||||
struct ClassificacaoAmbiente: Encodable {
|
||||
let ambiente: String
|
||||
let confianca: Double
|
||||
let evidencias_chave: [String]
|
||||
}
|
||||
|
||||
/// Taxonomia de ambientes para classificação editorial.
|
||||
/// Cada ambiente tem descrição de características visuais que o Foundation Models deve procurar.
|
||||
enum TaxonomiaAmbientes {
|
||||
static let instrucoes = """
|
||||
Você identifica o ambiente de cenas de vídeo. Analise as evidências visuais fornecidas \
|
||||
e classifique o ambiente segundo a taxonomia abaixo. Responda APENAS com JSON válido.
|
||||
|
||||
TAXONOMIA DE AMBIENTES:
|
||||
- centro_cirurgico: pessoas com touca/avental/máscara/camisinha cirúrgica, cores branco/azul cirúrgico, \
|
||||
mesa cirúrgica, luzes de sala de operação, monitores cardíacos, instrumentos cirúrgicos, campo estéril verde/azul.
|
||||
- consultorio: mobiliário médico simples, mesa de exame, quadro na parede, equipamento clínico de escritório.
|
||||
- hospital_ambiente: corredor hospitalar, leito, roupa de paciente, enfermeiros, soro, monitor.
|
||||
- laboratorio: microscópio, tubos de ensaio, centroides, bancada com reagentes, jaleco branco.
|
||||
- escritorio: mesa de trabalho, computador, cadeira ergonômica, estantes, iluminação artificial.
|
||||
- sala_reuniao: mesa comprida, cadeiras ao redor, projetor/tela, quadro branco.
|
||||
- externo_urbano: rua, edifícios, trânsito, calçada, céu aberto.
|
||||
- externo_natural: árvores, gramado, água, montanha, praia, campo.
|
||||
- studio: fundo neutro/escuro, iluminação profissional, equipamento de gravação.
|
||||
- industria: maquinário, linha de produção, capacete, colete, ambiente fabril.
|
||||
- domesticos: sala, cozinha, quarto, mobiliário residencial, iluminação caseira.
|
||||
- academia: equipamentos de musculação, esteira, peso, pessoa se exercitando.
|
||||
- outro: qualquer ambiente que não se encaixe nas categorias acima.
|
||||
|
||||
REGRAS:
|
||||
1. Analise as evidências visuais (pessoas, roupas, objetos, texto OCR, categorias Vision).
|
||||
2. Identifique os 2-3 indícios mais fortes para a classificação.
|
||||
3. Atribua confiança de 0.0 a 1.0 baseada na força dos indícios.
|
||||
4. Se houver texto OCR relevante (placas, placas de identificação), use como evidência forte.
|
||||
5. Não invente informações não presentes nas evidências.
|
||||
"""
|
||||
|
||||
static let promptClassificar = """
|
||||
Classifique o ambiente deste frame de vídeo com base nas evidências visuais fornecidas.
|
||||
Responda APENAS com JSON no formato: {"ambiente": "categoria", "confianca": 0.0-1.0, "evidencias_chave": ["evidência 1", "evidência 2"]}
|
||||
"""
|
||||
}
|
||||
|
||||
func interpretar(_ saida: Saida) async -> String? {
|
||||
guard SystemLanguageModel.default.isAvailable else { return nil }
|
||||
let fatos = saida.evidencias.map(descreverEvidencia).joined(separator: "; ")
|
||||
guard !fatos.isEmpty else { return nil }
|
||||
do {
|
||||
let sessao = LanguageModelSession(instructions: TaxonomiaAmbientes.instrucoes)
|
||||
let resposta = try await sessao.respond(to: "\(TaxonomiaAmbientes.promptClassificar)\n\nEvidências visuais: \(fatos)")
|
||||
return resposta.content
|
||||
} catch { return nil }
|
||||
}
|
||||
|
||||
/// Interpretação editorial legível (frase curta) — mantida para compatibilidade.
|
||||
func interpretarFrase(_ saida: Saida) async -> String? {
|
||||
guard SystemLanguageModel.default.isAvailable else { return nil }
|
||||
let fatos = saida.evidencias.map(descreverEvidencia).joined(separator: "; ")
|
||||
guard !fatos.isEmpty else { return nil }
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Entrada de linha de comando para listar planos de edição salvos no SQLite."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
CAMINHO_DO_CODIGO = Path(__file__).resolve().parent.parent
|
||||
if str(CAMINHO_DO_CODIGO) not in sys.path:
|
||||
sys.path.insert(0, str(CAMINHO_DO_CODIGO))
|
||||
|
||||
from engine.persistencia.conexao import abrir_banco
|
||||
from engine.persistencia.repositorio_de_planos import RepositorioDePlanos
|
||||
|
||||
|
||||
def listar(caminho_do_banco: Path, limite: int = 50) -> list[dict[str, object]]:
|
||||
"""Lê os planos mais recentes do banco, de qualquer vídeo."""
|
||||
conexao = abrir_banco(caminho_do_banco)
|
||||
try:
|
||||
return RepositorioDePlanos(conexao).listar_todos_os_planos(limite)
|
||||
finally:
|
||||
conexao.close()
|
||||
|
||||
|
||||
def principal() -> None:
|
||||
"""Executa a listagem pela linha de comando."""
|
||||
argumentos = argparse.ArgumentParser()
|
||||
argumentos.add_argument("--banco", type=Path, default=Path(".jhonny/analises.db"))
|
||||
argumentos.add_argument("--limite", type=int, default=50)
|
||||
opcoes = argumentos.parse_args()
|
||||
try:
|
||||
planos = listar(opcoes.banco, opcoes.limite)
|
||||
print(json.dumps({"ok": True, "planos": planos}, ensure_ascii=False))
|
||||
except (OSError, ValueError) as erro:
|
||||
print(json.dumps({"ok": False, "erro": str(erro)}, ensure_ascii=False))
|
||||
raise SystemExit(1) from erro
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
principal()
|
||||
@@ -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,
|
||||
@@ -25,9 +26,12 @@ from .repositorio_de_planos import (
|
||||
RepositorioDePlanos,
|
||||
plano_de_dict,
|
||||
)
|
||||
from .repositorio_de_projetos import ErroDeProjeto, ProjetoDeEdicao, RepositorioDeProjetos
|
||||
from .repositorio_de_edicoes import EdicaoDeVideo, ErroDeEdicao, RepositorioDeEdicoes, edicao_de_dict
|
||||
from .repositorio_de_analises_sqlite import RepositorioDeAnalisesSQLite
|
||||
from .repositorio_de_retakes_sqlite import RepositorioDeRetakesSQLite
|
||||
from .repositorio_de_timeline_sqlite import RepositorioDeTimelineSQLite
|
||||
from .repositorio_de_trilhas import RepositorioDeTrilhasSQLite, TrilhaMusical
|
||||
|
||||
__all__ = [
|
||||
"AcaoDoPlano",
|
||||
@@ -37,21 +41,31 @@ __all__ = [
|
||||
"ErroDeBusca",
|
||||
"ErroDeIngestaoDeVoz",
|
||||
"ErroDePlano",
|
||||
"ErroDeEdicao",
|
||||
"EdicaoDeVideo",
|
||||
"IndexadorSemantico",
|
||||
"IngestorDeVozNoBanco",
|
||||
"LeitorDeDadosParaIA",
|
||||
"LeitorDeTranscricaoDoPlano",
|
||||
"LimpadorDeDuplicatas",
|
||||
"PlanoDeEdicao",
|
||||
"RepositorioDeEnunciados",
|
||||
"RepositorioDePlanos",
|
||||
"RepositorioDeProjetos",
|
||||
"ProjetoDeEdicao",
|
||||
"ErroDeProjeto",
|
||||
"RepositorioDeEdicoes",
|
||||
"ResultadoDeBusca",
|
||||
"abrir_banco",
|
||||
"criar_esquema",
|
||||
"plano_de_dict",
|
||||
"edicao_de_dict",
|
||||
"reconstruir_indice_de_busca",
|
||||
"RepositorioDeAnalisesSQLite",
|
||||
"RepositorioDeRetakesSQLite",
|
||||
"RepositorioDeTimelineSQLite",
|
||||
"RepositorioDeTrilhasSQLite",
|
||||
"TrilhaMusical",
|
||||
"ConsultasDeAnalises",
|
||||
"ErroDeConsultaInvalida",
|
||||
]
|
||||
|
||||
@@ -135,6 +135,185 @@ class ConsultasDeAnalises:
|
||||
},
|
||||
}
|
||||
|
||||
def consultar_contexto_da_edicao(
|
||||
self,
|
||||
video_id: str | None = None,
|
||||
incluir_palavras: bool = True,
|
||||
) -> dict | None:
|
||||
"""
|
||||
Carrega a edição concluída mais recente e seu contexto editorial.
|
||||
|
||||
Quando ``video_id`` não é informado, seleciona a edição concluída mais
|
||||
recente do banco. O retorno reúne a configuração editorial, o vídeo,
|
||||
os clipes, as falas e o histórico resumido de planos, para que um
|
||||
agente possa iniciar uma edição sem escrever SQL nem procurar arquivos
|
||||
auxiliares.
|
||||
|
||||
Parâmetros:
|
||||
video_id: Restringe a busca a um vídeo quando informado.
|
||||
incluir_palavras: Inclui palavras da transcrição para decisões
|
||||
que dependem de limites precisos.
|
||||
|
||||
Retorna:
|
||||
Contexto completo da edição mais recente, ou None quando não há
|
||||
edição concluída compatível.
|
||||
|
||||
Pode gerar:
|
||||
ErroDeConsultaInvalida: quando ``video_id`` for vazio ou a
|
||||
configuração persistida não for um JSON válido.
|
||||
"""
|
||||
if video_id is not None:
|
||||
self._validar_texto_obrigatorio("video_id", video_id)
|
||||
condicao = "WHERE video_id = ?" if video_id is not None else ""
|
||||
parametros = (video_id,) if video_id is not None else ()
|
||||
edicao = self.conexao.execute(
|
||||
f"""SELECT id, video_id, sequencia, origem, tipo_de_video,
|
||||
configuracao, concluida_em
|
||||
FROM edicoes_de_video
|
||||
{condicao}
|
||||
ORDER BY concluida_em DESC, id DESC
|
||||
LIMIT 1""",
|
||||
parametros,
|
||||
).fetchone()
|
||||
if edicao is None:
|
||||
return None
|
||||
try:
|
||||
configuracao = json.loads(edicao["configuracao"])
|
||||
except (TypeError, json.JSONDecodeError) as erro:
|
||||
raise ErroDeConsultaInvalida(
|
||||
f"A configuração da edição {edicao['id']} não é um JSON válido."
|
||||
) from erro
|
||||
if not isinstance(configuracao, dict):
|
||||
raise ErroDeConsultaInvalida(
|
||||
f"A configuração da edição {edicao['id']} precisa ser um objeto."
|
||||
)
|
||||
|
||||
identificador_do_video = edicao["video_id"]
|
||||
clipes = [
|
||||
dict(linha) for linha in self.conexao.execute(
|
||||
"""SELECT id, faixa_id, nome, inicio_na_timeline, fim_na_timeline,
|
||||
inicio_na_origem, fim_na_origem, arquivo, offline
|
||||
FROM clipes
|
||||
WHERE video_id = ?
|
||||
ORDER BY inicio_na_timeline, id""",
|
||||
(identificador_do_video,),
|
||||
).fetchall()
|
||||
]
|
||||
planos = [
|
||||
dict(linha) for linha in self.conexao.execute(
|
||||
"""SELECT p.id, p.origem, p.tipo_de_video, p.modelo_da_ia,
|
||||
p.intencao, p.criado_em,
|
||||
COUNT(a.id) AS total_de_acoes,
|
||||
(SELECT COUNT(*) FROM aplicacoes_do_plano ap
|
||||
WHERE ap.plano_id = p.id AND ap.sucesso = 1)
|
||||
AS aplicacoes_com_sucesso
|
||||
FROM planos_de_edicao p
|
||||
LEFT JOIN acoes_do_plano a ON a.plano_id = p.id
|
||||
WHERE p.video_id = ?
|
||||
GROUP BY p.id
|
||||
ORDER BY p.criado_em DESC, p.id DESC""",
|
||||
(identificador_do_video,),
|
||||
).fetchall()
|
||||
]
|
||||
return {
|
||||
"edicao": {
|
||||
"id": edicao["id"],
|
||||
"video_id": identificador_do_video,
|
||||
"sequencia": edicao["sequencia"],
|
||||
"origem": edicao["origem"],
|
||||
"tipo_de_video": edicao["tipo_de_video"],
|
||||
"configuracao": configuracao,
|
||||
"concluida_em": edicao["concluida_em"],
|
||||
},
|
||||
"video": self.consultar_resumo_do_video(identificador_do_video),
|
||||
"analise": self.consultar_status_da_analise(identificador_do_video),
|
||||
"clipes": clipes,
|
||||
"falas": self.listar_falas_no_intervalo(
|
||||
identificador_do_video, incluir_palavras=incluir_palavras,
|
||||
),
|
||||
"planos": planos,
|
||||
}
|
||||
|
||||
def listar_intervalos_de_preview_dos_falantes(self, video_id: str) -> list[dict]:
|
||||
"""
|
||||
Lista os intervalos dos falantes convertidos para o arquivo de vídeo.
|
||||
|
||||
Os segmentos de transcrição usam segundos da timeline e normalmente
|
||||
pertencem a um clipe de áudio. A consulta encontra o clipe de vídeo
|
||||
correspondente e calcula os segundos na origem usando os campos
|
||||
``inicio_na_timeline`` e ``inicio_na_origem`` da tabela ``clipes``.
|
||||
|
||||
Parâmetros:
|
||||
video_id: Identificador do vídeo analisado.
|
||||
|
||||
Retorna:
|
||||
Lista ordenada de intervalos com falante, tempos da timeline,
|
||||
tempos no arquivo de origem e caminho do vídeo.
|
||||
|
||||
Pode gerar:
|
||||
ErroDeConsultaInvalida: quando ``video_id`` estiver vazio.
|
||||
"""
|
||||
self._validar_texto_obrigatorio("video_id", video_id)
|
||||
linhas = self.conexao.execute(
|
||||
"""SELECT s.clipe_id, s.inicio, s.fim, s.falante,
|
||||
a.arquivo AS arquivo_do_audio,
|
||||
v.id AS clipe_de_video, v.inicio_na_timeline,
|
||||
v.fim_na_timeline, v.inicio_na_origem,
|
||||
v.fim_na_origem, v.arquivo AS arquivo_do_video
|
||||
FROM segmentos_de_transcricao AS s
|
||||
LEFT JOIN clipes AS a
|
||||
ON a.video_id = s.video_id AND a.id = s.clipe_id
|
||||
LEFT JOIN clipes AS v
|
||||
ON v.video_id = s.video_id
|
||||
AND v.inicio_na_timeline < s.fim
|
||||
AND v.fim_na_timeline > s.inicio
|
||||
LEFT JOIN faixas AS f
|
||||
ON f.video_id = v.video_id AND f.id = v.faixa_id
|
||||
AND f.tipo = 'video'
|
||||
WHERE s.video_id = ?
|
||||
AND s.falante IS NOT NULL
|
||||
AND TRIM(s.falante) <> ''
|
||||
AND (f.id IS NOT NULL OR v.id IS NULL)
|
||||
ORDER BY s.inicio, s.fim,
|
||||
(v.arquivo = a.arquivo) DESC, s.id""",
|
||||
(video_id,),
|
||||
).fetchall()
|
||||
|
||||
resultado = []
|
||||
vistos = set()
|
||||
for linha in linhas:
|
||||
chave = (linha["clipe_id"], linha["inicio"], linha["fim"], linha["falante"])
|
||||
if chave in vistos:
|
||||
continue
|
||||
vistos.add(chave)
|
||||
inicio_na_timeline = float(linha["inicio"])
|
||||
fim_na_timeline = float(linha["fim"])
|
||||
inicio_na_origem = linha["inicio_na_origem"]
|
||||
fim_na_origem = linha["fim_na_origem"]
|
||||
arquivo = linha["arquivo_do_video"] or linha["arquivo_do_audio"]
|
||||
if inicio_na_origem is None:
|
||||
inicio_no_arquivo = inicio_na_timeline
|
||||
fim_no_arquivo = fim_na_timeline
|
||||
else:
|
||||
deslocamento = inicio_na_timeline - float(linha["inicio_na_timeline"])
|
||||
inicio_no_arquivo = float(inicio_na_origem) + deslocamento
|
||||
fim_no_arquivo = inicio_no_arquivo + (fim_na_timeline - inicio_na_timeline)
|
||||
if fim_na_origem is not None:
|
||||
fim_no_arquivo = min(fim_no_arquivo, float(fim_na_origem))
|
||||
if not arquivo or fim_no_arquivo <= inicio_no_arquivo:
|
||||
continue
|
||||
resultado.append({
|
||||
"clipe_id": linha["clipe_id"],
|
||||
"falante": linha["falante"],
|
||||
"inicio_na_timeline": inicio_na_timeline,
|
||||
"fim_na_timeline": fim_na_timeline,
|
||||
"inicio": max(0.0, inicio_no_arquivo),
|
||||
"fim": max(0.0, fim_no_arquivo),
|
||||
"arquivo": arquivo,
|
||||
"clipe_de_video": linha["clipe_de_video"],
|
||||
})
|
||||
return resultado
|
||||
|
||||
def listar_falas_no_intervalo(
|
||||
self,
|
||||
video_id: str,
|
||||
|
||||
@@ -61,6 +61,57 @@ CREATE TABLE IF NOT EXISTS arquivos (
|
||||
UNIQUE (video_id, caminho)
|
||||
);
|
||||
|
||||
-- Catálogo independente de músicas para uso como trilha de fundo.
|
||||
CREATE TABLE IF NOT EXISTS pastas_de_trilhas (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
caminho TEXT NOT NULL UNIQUE,
|
||||
criada_em TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
atualizada_em TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS trilhas_musicais (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
pasta_id INTEGER NOT NULL REFERENCES pastas_de_trilhas(id) ON DELETE CASCADE,
|
||||
caminho TEXT NOT NULL UNIQUE,
|
||||
nome_do_arquivo TEXT NOT NULL,
|
||||
hash_do_conteudo TEXT,
|
||||
tamanho_em_bytes INTEGER,
|
||||
status TEXT NOT NULL DEFAULT 'aguardando',
|
||||
erro TEXT,
|
||||
criada_em TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
atualizada_em TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_trilhas_musicais_pasta ON trilhas_musicais(pasta_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS analises_musicais (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
trilha_id INTEGER NOT NULL REFERENCES trilhas_musicais(id) ON DELETE CASCADE,
|
||||
provedor TEXT NOT NULL,
|
||||
modelo TEXT,
|
||||
duracao_em_segundos REAL,
|
||||
batidas_por_minuto REAL,
|
||||
tonalidade TEXT,
|
||||
modo TEXT,
|
||||
intensidade REAL,
|
||||
dancabilidade REAL,
|
||||
temas_json TEXT,
|
||||
instrumentos_json TEXT,
|
||||
etiquetas_json TEXT,
|
||||
resultado_bruto_json TEXT,
|
||||
criada_em TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_analises_musicais_trilha ON analises_musicais(trilha_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS etiquetas_musicais (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
analise_id INTEGER NOT NULL REFERENCES analises_musicais(id) ON DELETE CASCADE,
|
||||
tipo TEXT NOT NULL,
|
||||
etiqueta TEXT NOT NULL,
|
||||
confianca REAL NOT NULL,
|
||||
ordem INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_etiquetas_musicais_analise ON etiquetas_musicais(analise_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS analises_versao (
|
||||
video_id TEXT NOT NULL REFERENCES videos(id) ON DELETE CASCADE,
|
||||
etapa TEXT NOT NULL,
|
||||
@@ -285,6 +336,46 @@ CREATE TABLE IF NOT EXISTS aplicacoes_do_plano (
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_aplicacoes_plano ON aplicacoes_do_plano(plano_id);
|
||||
|
||||
-- Um projeto representa exatamente uma sequência, mas pode selecionar vários
|
||||
-- arquivos, faixas e clipes pertencentes a ela.
|
||||
CREATE TABLE IF NOT EXISTS projetos_de_edicao (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
video_id TEXT NOT NULL REFERENCES videos(id) ON DELETE CASCADE,
|
||||
nome TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'aberto',
|
||||
criado_em TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
atualizado_em TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_projetos_video ON projetos_de_edicao(video_id, atualizado_em);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS faixas_do_projeto (
|
||||
projeto_id INTEGER NOT NULL REFERENCES projetos_de_edicao(id) ON DELETE CASCADE,
|
||||
faixa_id TEXT NOT NULL,
|
||||
selecionada INTEGER NOT NULL DEFAULT 1,
|
||||
PRIMARY KEY (projeto_id, faixa_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS clipes_do_projeto (
|
||||
projeto_id INTEGER NOT NULL REFERENCES projetos_de_edicao(id) ON DELETE CASCADE,
|
||||
clipe_id TEXT NOT NULL,
|
||||
selecionado INTEGER NOT NULL DEFAULT 1,
|
||||
PRIMARY KEY (projeto_id, clipe_id)
|
||||
);
|
||||
|
||||
-- Configuração editorial escolhida no painel, antes de existir um plano da IA.
|
||||
-- A análise continua normalizada nas tabelas do Scanner; esta tabela guarda
|
||||
-- somente a decisão do usuário e suas opções para a edição.
|
||||
CREATE TABLE IF NOT EXISTS edicoes_de_video (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
video_id TEXT NOT NULL REFERENCES videos(id) ON DELETE CASCADE,
|
||||
sequencia TEXT NOT NULL,
|
||||
origem TEXT,
|
||||
tipo_de_video TEXT NOT NULL,
|
||||
configuracao TEXT NOT NULL,
|
||||
concluida_em TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_edicoes_video ON edicoes_de_video(video_id, concluida_em);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Leituras achatadas
|
||||
-- ---------------------------------------------------------------------------
|
||||
@@ -424,6 +515,9 @@ _VIEWS = (
|
||||
)
|
||||
|
||||
_COLUNAS_ACRESCENTADAS: tuple[tuple[str, str, str], ...] = (
|
||||
("analises_musicais", "temas_json", "TEXT"),
|
||||
("analises_musicais", "instrumentos_json", "TEXT"),
|
||||
("analises_musicais", "etiquetas_json", "TEXT"),
|
||||
("segmentos_de_transcricao", "energia_rms", "REAL"),
|
||||
("segmentos_de_transcricao", "pitch_mediano_hz", "REAL"),
|
||||
("segmentos_de_transcricao", "pitch_desvio_hz", "REAL"),
|
||||
|
||||
@@ -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"])
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Persistência da configuração editorial escolhida no painel CEP.
|
||||
|
||||
Este módulo registra a conclusão da seleção de um tipo de vídeo e suas opções.
|
||||
Ele não executa cortes, não gera o plano da IA e não duplica a análise do
|
||||
Scanner; apenas cria o vínculo persistido entre a análise e a edição desejada.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
class ErroDeEdicao(ValueError):
|
||||
"""Configuração editorial inválida para persistência."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EdicaoDeVideo:
|
||||
"""Representa a configuração editorial concluída para um vídeo."""
|
||||
|
||||
video_id: str
|
||||
sequencia: str
|
||||
tipo_de_video: str
|
||||
configuracao: dict[str, object]
|
||||
origem: str | None = None
|
||||
|
||||
|
||||
class RepositorioDeEdicoes:
|
||||
"""Grava configurações editoriais sem alterar os dados da análise."""
|
||||
|
||||
def __init__(self, conexao: sqlite3.Connection) -> None:
|
||||
"""Inicializa o repositório com uma conexão SQLite preparada."""
|
||||
self.conexao = conexao
|
||||
|
||||
def registrar(self, edicao: EdicaoDeVideo) -> int:
|
||||
"""Registra uma edição concluída e devolve seu identificador."""
|
||||
if not edicao.video_id.strip():
|
||||
raise ErroDeEdicao("A edição precisa indicar o vídeo da análise.")
|
||||
if not edicao.sequencia.strip():
|
||||
raise ErroDeEdicao("A edição precisa indicar a sequência.")
|
||||
if not edicao.tipo_de_video.strip():
|
||||
raise ErroDeEdicao("A edição precisa indicar o tipo de vídeo.")
|
||||
if not edicao.configuracao:
|
||||
raise ErroDeEdicao("A edição precisa ter uma configuração.")
|
||||
|
||||
with self.conexao:
|
||||
self.conexao.execute(
|
||||
"INSERT OR IGNORE INTO videos (id, nome) VALUES (?, ?)",
|
||||
(edicao.video_id, edicao.sequencia),
|
||||
)
|
||||
cursor = self.conexao.execute(
|
||||
"""INSERT INTO edicoes_de_video
|
||||
(video_id, sequencia, origem, tipo_de_video, configuracao)
|
||||
VALUES (?, ?, ?, ?, ?)""",
|
||||
(
|
||||
edicao.video_id,
|
||||
edicao.sequencia,
|
||||
edicao.origem,
|
||||
edicao.tipo_de_video,
|
||||
json.dumps(edicao.configuracao, ensure_ascii=False),
|
||||
),
|
||||
)
|
||||
return int(cursor.lastrowid)
|
||||
|
||||
|
||||
def edicao_de_dict(dados: dict[str, object]) -> EdicaoDeVideo:
|
||||
"""Converte o pedido externo do painel em uma entidade validável."""
|
||||
try:
|
||||
video_id = str(dados["video_id"])
|
||||
sequencia = str(dados["sequencia"])
|
||||
tipo_de_video = str(dados["tipo_de_video"])
|
||||
configuracao = dados["configuracao"]
|
||||
except KeyError as erro:
|
||||
raise ErroDeEdicao(f"Campo obrigatório ausente: {erro.args[0]}.") from erro
|
||||
if not isinstance(configuracao, dict):
|
||||
raise ErroDeEdicao("O campo configuracao precisa ser um objeto.")
|
||||
origem = dados.get("origem")
|
||||
return EdicaoDeVideo(video_id, sequencia, tipo_de_video, configuracao, str(origem) if origem else None)
|
||||
|
||||
|
||||
__all__ = ["EdicaoDeVideo", "ErroDeEdicao", "RepositorioDeEdicoes", "edicao_de_dict"]
|
||||
@@ -222,6 +222,36 @@ class RepositorioDePlanos:
|
||||
)
|
||||
]
|
||||
|
||||
def listar_todos_os_planos(self, limite: int = 50) -> list[dict[str, object]]:
|
||||
"""
|
||||
Lista os planos mais recentes de qualquer vídeo, para escolha manual por id.
|
||||
|
||||
Existe para o caso em que o plano foi registrado sem ``video_id`` (a
|
||||
mídia não tinha edição correspondente em ``edicoes_de_video`` no
|
||||
momento do registro) — ``listar_planos_do_video`` não o encontraria,
|
||||
já que filtra por vídeo conhecido.
|
||||
|
||||
Parâmetros:
|
||||
limite: Quantos planos devolver, do mais recente ao mais antigo.
|
||||
|
||||
Retorna:
|
||||
Um resumo por plano — id, data de criação, origem, intenção,
|
||||
tipo de vídeo e contagem de ações — para montar uma lista de
|
||||
seleção sem carregar o plano inteiro.
|
||||
"""
|
||||
return [
|
||||
dict(linha) for linha in self.conexao.execute(
|
||||
"""SELECT p.id, p.criado_em, p.origem, p.intencao, p.tipo_de_video,
|
||||
p.modelo_da_ia, p.video_id,
|
||||
(SELECT count(*) FROM acoes_do_plano a WHERE a.plano_id = p.id)
|
||||
AS total_de_acoes
|
||||
FROM planos_de_edicao p
|
||||
ORDER BY p.criado_em DESC
|
||||
LIMIT ?""",
|
||||
(limite,),
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def plano_de_dict(
|
||||
dados: dict,
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Persistência dos projetos de edição e das seleções da guia Projeto."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
class ErroDeProjeto(ValueError):
|
||||
"""Projeto inválido para persistência."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProjetoDeEdicao:
|
||||
"""Projeto associado a exatamente uma sequência do Premiere."""
|
||||
|
||||
nome: str
|
||||
video_id: str
|
||||
faixas: tuple[str, ...] = ()
|
||||
clipes: tuple[str, ...] = ()
|
||||
status: str = "aberto"
|
||||
|
||||
|
||||
class RepositorioDeProjetos:
|
||||
"""Grava e recupera projetos sem duplicar dados da timeline."""
|
||||
|
||||
def __init__(self, conexao: sqlite3.Connection) -> None:
|
||||
"""Inicializa o repositório com uma conexão SQLite preparada."""
|
||||
self.conexao = conexao
|
||||
|
||||
def registrar(self, projeto: ProjetoDeEdicao) -> int:
|
||||
"""Cria um projeto e grava suas faixas e clipes selecionados."""
|
||||
if not projeto.nome.strip():
|
||||
raise ErroDeProjeto("O projeto precisa ter um nome.")
|
||||
if not projeto.video_id.strip():
|
||||
raise ErroDeProjeto("O projeto precisa ter uma sequência.")
|
||||
with self.conexao:
|
||||
cursor = self.conexao.execute(
|
||||
"""INSERT INTO projetos_de_edicao (video_id, nome, status)
|
||||
VALUES (?, ?, ?)""",
|
||||
(projeto.video_id, projeto.nome, projeto.status),
|
||||
)
|
||||
projeto_id = int(cursor.lastrowid)
|
||||
self.conexao.executemany(
|
||||
"INSERT INTO faixas_do_projeto (projeto_id, faixa_id) VALUES (?, ?)",
|
||||
[(projeto_id, faixa_id) for faixa_id in projeto.faixas],
|
||||
)
|
||||
self.conexao.executemany(
|
||||
"INSERT INTO clipes_do_projeto (projeto_id, clipe_id) VALUES (?, ?)",
|
||||
[(projeto_id, clipe_id) for clipe_id in projeto.clipes],
|
||||
)
|
||||
return projeto_id
|
||||
|
||||
def carregar(self, projeto_id: int) -> dict[str, object] | None:
|
||||
"""Carrega um projeto com sua sequência, faixas e clipes selecionados."""
|
||||
linha = self.conexao.execute(
|
||||
"SELECT * FROM projetos_de_edicao WHERE id = ?", (projeto_id,),
|
||||
).fetchone()
|
||||
if linha is None:
|
||||
return None
|
||||
return {
|
||||
"id": linha["id"], "nome": linha["nome"], "video_id": linha["video_id"],
|
||||
"status": linha["status"], "faixas": [
|
||||
r["faixa_id"] for r in self.conexao.execute(
|
||||
"""SELECT faixa_id FROM faixas_do_projeto
|
||||
WHERE projeto_id = ? AND selecionada = 1 ORDER BY rowid""",
|
||||
(projeto_id,),
|
||||
)
|
||||
], "clipes": [
|
||||
r["clipe_id"] for r in self.conexao.execute(
|
||||
"""SELECT clipe_id FROM clipes_do_projeto
|
||||
WHERE projeto_id = ? AND selecionado = 1 ORDER BY rowid""",
|
||||
(projeto_id,),
|
||||
)
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
__all__ = ["ErroDeProjeto", "ProjetoDeEdicao", "RepositorioDeProjetos"]
|
||||
@@ -0,0 +1,134 @@
|
||||
"""Persistência do catálogo independente de trilhas musicais."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import sqlite3
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
from ..integracoes.audio.modelos_de_analise_musical import ResultadoDaAnaliseMusical
|
||||
from .conexao import abrir_banco
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TrilhaMusical:
|
||||
"""Representa um arquivo de música catalogado, sem vínculo com vídeo."""
|
||||
|
||||
identificador: int
|
||||
caminho: Path
|
||||
nome_do_arquivo: str
|
||||
status: str
|
||||
erro: str | None = None
|
||||
|
||||
|
||||
class RepositorioDeTrilhasSQLite:
|
||||
"""Salva pastas, trilhas e versões das análises musicais no SQLite."""
|
||||
|
||||
def __init__(self, banco: str | Path | sqlite3.Connection = ".jhonny/analises.db") -> None:
|
||||
"""Abre o banco informado e garante que o esquema esteja disponível."""
|
||||
self.conexao = banco if isinstance(banco, sqlite3.Connection) else abrir_banco(banco)
|
||||
|
||||
def registrar_pasta_e_arquivos(self, pasta: str | Path, arquivos: Iterable[Path]) -> list[TrilhaMusical]:
|
||||
"""Registra a pasta e torna sua lista de arquivos pronta para análise."""
|
||||
caminho_da_pasta = Path(pasta).expanduser().resolve(strict=True)
|
||||
if not caminho_da_pasta.is_dir():
|
||||
raise NotADirectoryError(f"A pasta de trilhas não existe: {caminho_da_pasta}")
|
||||
arquivos_materializados = tuple(Path(arquivo).resolve() for arquivo in arquivos)
|
||||
with self.conexao:
|
||||
self.conexao.execute(
|
||||
"""INSERT INTO pastas_de_trilhas(caminho) VALUES (?)
|
||||
ON CONFLICT(caminho) DO UPDATE SET atualizada_em = strftime('%Y-%m-%dT%H:%M:%fZ','now')""",
|
||||
(str(caminho_da_pasta),),
|
||||
)
|
||||
pasta_id = self.conexao.execute(
|
||||
"SELECT id FROM pastas_de_trilhas WHERE caminho = ?", (str(caminho_da_pasta),)
|
||||
).fetchone()[0]
|
||||
resultado: list[TrilhaMusical] = []
|
||||
for arquivo in arquivos_materializados:
|
||||
linha = self.conexao.execute(
|
||||
"""INSERT INTO trilhas_musicais(pasta_id, caminho, nome_do_arquivo, tamanho_em_bytes)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(caminho) DO UPDATE SET nome_do_arquivo = excluded.nome_do_arquivo,
|
||||
status = CASE WHEN trilhas_musicais.tamanho_em_bytes != excluded.tamanho_em_bytes
|
||||
THEN 'aguardando' ELSE trilhas_musicais.status END,
|
||||
hash_do_conteudo = CASE WHEN trilhas_musicais.tamanho_em_bytes != excluded.tamanho_em_bytes
|
||||
THEN NULL ELSE trilhas_musicais.hash_do_conteudo END,
|
||||
tamanho_em_bytes = excluded.tamanho_em_bytes,
|
||||
atualizada_em = strftime('%Y-%m-%dT%H:%M:%fZ','now')""",
|
||||
(pasta_id, str(arquivo), arquivo.name, arquivo.stat().st_size),
|
||||
)
|
||||
identificador = linha.lastrowid or self.conexao.execute(
|
||||
"SELECT id FROM trilhas_musicais WHERE caminho = ?", (str(arquivo),)
|
||||
).fetchone()[0]
|
||||
registro = self.conexao.execute(
|
||||
"SELECT id, caminho, nome_do_arquivo, status, erro FROM trilhas_musicais WHERE id = ?",
|
||||
(identificador,),
|
||||
).fetchone()
|
||||
resultado.append(TrilhaMusical(registro[0], Path(registro[1]), registro[2], registro[3], registro[4]))
|
||||
return resultado
|
||||
|
||||
def iniciar_analise(self, trilha_id: int) -> None:
|
||||
"""Marca uma trilha como em análise e remove erro anterior."""
|
||||
with self.conexao:
|
||||
self.conexao.execute(
|
||||
"UPDATE trilhas_musicais SET status = 'analisando', erro = NULL, atualizada_em = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?",
|
||||
(trilha_id,),
|
||||
)
|
||||
|
||||
def reabrir_analises_sem_etiquetas(self, pasta_id: int | None = None) -> None:
|
||||
"""Volta a enfileirar análises antigas que não têm classificação semântica."""
|
||||
filtro = "" if pasta_id is None else " AND t.pasta_id = ?"
|
||||
parametros = () if pasta_id is None else (pasta_id,)
|
||||
with self.conexao:
|
||||
self.conexao.execute(
|
||||
"""UPDATE trilhas_musicais AS t SET status = 'aguardando', erro = NULL
|
||||
WHERE t.status = 'concluida' AND EXISTS (
|
||||
SELECT 1 FROM analises_musicais AS a
|
||||
WHERE a.trilha_id = t.id
|
||||
AND NOT EXISTS (SELECT 1 FROM etiquetas_musicais e WHERE e.analise_id = a.id)
|
||||
)""" + filtro,
|
||||
parametros,
|
||||
)
|
||||
|
||||
def registrar_analise(self, trilha: TrilhaMusical, resultado: ResultadoDaAnaliseMusical) -> None:
|
||||
"""Grava descritores e etiquetas produzidos pelo áudio da trilha."""
|
||||
bruto = json.dumps({"generos": resultado.generos, "humores": resultado.humores, "temas": resultado.temas, "instrumentos": resultado.instrumentos, "etiquetas": resultado.etiquetas}, ensure_ascii=False)
|
||||
with self.conexao:
|
||||
self.conexao.execute(
|
||||
"UPDATE trilhas_musicais SET hash_do_conteudo = ?, status = 'concluida', erro = NULL, atualizada_em = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?",
|
||||
(self._calcular_hash(trilha.caminho), trilha.identificador),
|
||||
)
|
||||
cursor = self.conexao.execute(
|
||||
"""INSERT INTO analises_musicais
|
||||
(trilha_id, provedor, modelo, duracao_em_segundos, batidas_por_minuto, tonalidade, modo, intensidade, dancabilidade, temas_json, instrumentos_json, etiquetas_json, resultado_bruto_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(trilha.identificador, resultado.provedor, resultado.modelo, resultado.duracao_em_segundos,
|
||||
resultado.batidas_por_minuto, resultado.tonalidade, resultado.modo, resultado.intensidade,
|
||||
resultado.dancabilidade, json.dumps(resultado.temas, ensure_ascii=False), json.dumps(resultado.instrumentos, ensure_ascii=False), json.dumps(resultado.etiquetas, ensure_ascii=False), bruto),
|
||||
)
|
||||
analise_id = cursor.lastrowid
|
||||
for tipo, etiquetas in (("genero", resultado.pontuacoes_de_genero), ("humor", resultado.pontuacoes_de_humor), ("tema", resultado.pontuacoes_de_tema)):
|
||||
self.conexao.executemany(
|
||||
"INSERT INTO etiquetas_musicais(analise_id, tipo, etiqueta, confianca, ordem) VALUES (?, ?, ?, ?, ?)",
|
||||
((analise_id, tipo, item.etiqueta, item.confianca, ordem) for ordem, item in enumerate(etiquetas)),
|
||||
)
|
||||
|
||||
def registrar_erro(self, trilha_id: int, erro: str) -> None:
|
||||
"""Marca uma trilha como erro sem interromper o lote inteiro."""
|
||||
with self.conexao:
|
||||
self.conexao.execute(
|
||||
"UPDATE trilhas_musicais SET status = 'erro', erro = ?, atualizada_em = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?",
|
||||
(erro[:2000], trilha_id),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _calcular_hash(caminho: Path) -> str:
|
||||
"""Calcula SHA-256 em blocos para não carregar o áudio inteiro na memória."""
|
||||
resumo = hashlib.sha256()
|
||||
with caminho.open("rb") as arquivo:
|
||||
for bloco in iter(lambda: arquivo.read(1024 * 1024), b""):
|
||||
resumo.update(bloco)
|
||||
return resumo.hexdigest()
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Prepara o contexto da edição por voz a partir do banco SQLite."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
CAMINHO_DO_CODIGO = Path(__file__).resolve().parent.parent
|
||||
if str(CAMINHO_DO_CODIGO) not in sys.path:
|
||||
sys.path.insert(0, str(CAMINHO_DO_CODIGO))
|
||||
|
||||
from engine.persistencia.consultas import ConsultasDeAnalises
|
||||
from engine.persistencia.conexao import abrir_banco
|
||||
|
||||
|
||||
def preparar_contexto(
|
||||
caminho_do_banco: Path,
|
||||
video_id: str | None = None,
|
||||
incluir_palavras: bool = True,
|
||||
) -> dict | None:
|
||||
"""
|
||||
Carrega a edição mais recente e seu contexto editorial.
|
||||
|
||||
Parâmetros:
|
||||
caminho_do_banco: Caminho do banco SQLite de análises.
|
||||
video_id: Vídeo específico; quando omitido, usa a edição mais recente.
|
||||
incluir_palavras: Inclui os limites das palavras da transcrição.
|
||||
|
||||
Retorna:
|
||||
Contexto serializável para o agente de edição, ou None quando não há
|
||||
edição concluída compatível.
|
||||
"""
|
||||
conexao = abrir_banco(caminho_do_banco)
|
||||
try:
|
||||
return ConsultasDeAnalises(conexao).consultar_contexto_da_edicao(
|
||||
video_id=video_id,
|
||||
incluir_palavras=incluir_palavras,
|
||||
)
|
||||
finally:
|
||||
conexao.close()
|
||||
|
||||
|
||||
def principal() -> None:
|
||||
"""Imprime o contexto editorial em JSON para consumo do agente."""
|
||||
argumentos = argparse.ArgumentParser(
|
||||
description="Carrega a edição por voz mais recente do banco SQLite."
|
||||
)
|
||||
argumentos.add_argument(
|
||||
"--banco", type=Path, default=Path(".jhonny/analises.db"),
|
||||
help="Caminho do banco SQLite de análises.",
|
||||
)
|
||||
argumentos.add_argument("--video-id", help="Restringe a consulta a um vídeo.")
|
||||
argumentos.add_argument(
|
||||
"--sem-palavras", action="store_true",
|
||||
help="Omite os intervalos palavra a palavra para reduzir a resposta.",
|
||||
)
|
||||
opcoes = argumentos.parse_args()
|
||||
try:
|
||||
contexto = preparar_contexto(
|
||||
opcoes.banco,
|
||||
video_id=opcoes.video_id,
|
||||
incluir_palavras=not opcoes.sem_palavras,
|
||||
)
|
||||
if contexto is None:
|
||||
print(json.dumps({"ok": False, "erro": "Nenhuma edição concluída encontrada."}, ensure_ascii=False))
|
||||
raise SystemExit(1)
|
||||
print(json.dumps({"ok": True, "contexto": contexto}, ensure_ascii=False))
|
||||
except (OSError, ValueError) as erro:
|
||||
print(json.dumps({"ok": False, "erro": str(erro)}, ensure_ascii=False))
|
||||
raise SystemExit(1) from erro
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
principal()
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Entrada de linha de comando para registrar uma edição concluída no banco."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
CAMINHO_DO_CODIGO = Path(__file__).resolve().parent.parent
|
||||
if str(CAMINHO_DO_CODIGO) not in sys.path:
|
||||
sys.path.insert(0, str(CAMINHO_DO_CODIGO))
|
||||
|
||||
from engine.persistencia.conexao import abrir_banco
|
||||
from engine.persistencia.repositorio_de_edicoes import RepositorioDeEdicoes, edicao_de_dict
|
||||
|
||||
|
||||
def registrar(caminho_do_pedido: Path, caminho_do_banco: Path) -> int:
|
||||
"""Lê o pedido do painel e registra a configuração editorial."""
|
||||
dados = json.loads(caminho_do_pedido.read_text(encoding="utf-8"))
|
||||
repositorio = RepositorioDeEdicoes(abrir_banco(caminho_do_banco))
|
||||
return repositorio.registrar(edicao_de_dict(dados))
|
||||
|
||||
|
||||
def principal() -> None:
|
||||
"""Executa o registro pela linha de comando."""
|
||||
argumentos = argparse.ArgumentParser()
|
||||
argumentos.add_argument("pedido", type=Path)
|
||||
argumentos.add_argument("--banco", type=Path, default=Path(".jhonny/analises.db"))
|
||||
opcoes = argumentos.parse_args()
|
||||
try:
|
||||
identificador = registrar(opcoes.pedido, opcoes.banco)
|
||||
print(json.dumps({"ok": True, "edicao_id": identificador}))
|
||||
except (OSError, ValueError) as erro:
|
||||
print(json.dumps({"ok": False, "erro": str(erro)}, ensure_ascii=False))
|
||||
raise SystemExit(1) from erro
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
principal()
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Entrada de linha de comando para salvar um plano de edição no SQLite."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
CAMINHO_DO_CODIGO = Path(__file__).resolve().parent.parent
|
||||
if str(CAMINHO_DO_CODIGO) not in sys.path:
|
||||
sys.path.insert(0, str(CAMINHO_DO_CODIGO))
|
||||
|
||||
from engine.persistencia.conexao import abrir_banco
|
||||
from engine.persistencia.repositorio_de_planos import RepositorioDePlanos, plano_de_dict
|
||||
|
||||
|
||||
def registrar(
|
||||
caminho_do_plano: Path,
|
||||
caminho_do_banco: Path,
|
||||
video_id: str | None = None,
|
||||
tipo_de_video: str | None = None,
|
||||
modelo_da_ia: str | None = None,
|
||||
intencao: str | None = None,
|
||||
) -> int:
|
||||
"""Lê o JSON devolvido pela IA e registra o plano associado à edição mais recente."""
|
||||
dados = json.loads(caminho_do_plano.read_text(encoding="utf-8"))
|
||||
conexao = abrir_banco(caminho_do_banco)
|
||||
try:
|
||||
if video_id is None:
|
||||
origem = dados.get("source")
|
||||
linha = conexao.execute(
|
||||
"""SELECT video_id FROM edicoes_de_video
|
||||
WHERE origem = ? OR sequencia = ?
|
||||
ORDER BY concluida_em DESC, id DESC LIMIT 1""",
|
||||
(origem, origem),
|
||||
).fetchone()
|
||||
video_id = linha["video_id"] if linha is not None else None
|
||||
plano = plano_de_dict(
|
||||
dados,
|
||||
video_id=video_id,
|
||||
tipo_de_video=tipo_de_video,
|
||||
modelo_da_ia=modelo_da_ia,
|
||||
intencao=intencao,
|
||||
)
|
||||
return RepositorioDePlanos(conexao).registrar_plano(plano)
|
||||
finally:
|
||||
conexao.close()
|
||||
|
||||
|
||||
def principal() -> None:
|
||||
"""Executa o registro do plano pela linha de comando."""
|
||||
argumentos = argparse.ArgumentParser()
|
||||
argumentos.add_argument("plano", type=Path)
|
||||
argumentos.add_argument("--banco", type=Path, default=Path(".jhonny/analises.db"))
|
||||
argumentos.add_argument("--video-id")
|
||||
argumentos.add_argument("--tipo-de-video")
|
||||
argumentos.add_argument("--modelo-da-ia")
|
||||
argumentos.add_argument("--intencao")
|
||||
opcoes = argumentos.parse_args()
|
||||
try:
|
||||
identificador = registrar(
|
||||
opcoes.plano,
|
||||
opcoes.banco,
|
||||
video_id=opcoes.video_id,
|
||||
tipo_de_video=opcoes.tipo_de_video,
|
||||
modelo_da_ia=opcoes.modelo_da_ia,
|
||||
intencao=opcoes.intencao,
|
||||
)
|
||||
print(json.dumps({"ok": True, "plano_id": identificador}, ensure_ascii=False))
|
||||
except (OSError, ValueError) as erro:
|
||||
print(json.dumps({"ok": False, "erro": str(erro)}, ensure_ascii=False))
|
||||
raise SystemExit(1) from erro
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
principal()
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Entrada de linha de comando para salvar um projeto de edição no SQLite."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
CAMINHO_DO_CODIGO = Path(__file__).resolve().parent.parent
|
||||
if str(CAMINHO_DO_CODIGO) not in sys.path:
|
||||
sys.path.insert(0, str(CAMINHO_DO_CODIGO))
|
||||
|
||||
from engine.persistencia.conexao import abrir_banco
|
||||
from engine.persistencia.repositorio_de_projetos import ProjetoDeEdicao, RepositorioDeProjetos
|
||||
|
||||
|
||||
def registrar(caminho_do_pedido: Path, caminho_do_banco: Path) -> int:
|
||||
"""Lê a seleção da guia Projeto e grava seu projeto no banco."""
|
||||
dados = json.loads(caminho_do_pedido.read_text(encoding="utf-8"))
|
||||
conexao = abrir_banco(caminho_do_banco)
|
||||
try:
|
||||
projeto = ProjetoDeEdicao(
|
||||
nome=str(dados["nome"]), video_id=str(dados["video_id"]),
|
||||
faixas=tuple(str(valor) for valor in dados.get("faixas", [])),
|
||||
clipes=tuple(str(valor) for valor in dados.get("clipes", [])),
|
||||
)
|
||||
return RepositorioDeProjetos(conexao).registrar(projeto)
|
||||
finally:
|
||||
conexao.close()
|
||||
|
||||
|
||||
def principal() -> None:
|
||||
"""Executa o registro pela linha de comando."""
|
||||
argumentos = argparse.ArgumentParser()
|
||||
argumentos.add_argument("pedido", type=Path)
|
||||
argumentos.add_argument("--banco", type=Path, default=Path(".jhonny/analises.db"))
|
||||
opcoes = argumentos.parse_args()
|
||||
try:
|
||||
identificador = registrar(opcoes.pedido, opcoes.banco)
|
||||
print(json.dumps({"ok": True, "projeto_id": identificador}, ensure_ascii=False))
|
||||
except (OSError, KeyError, ValueError) as erro:
|
||||
print(json.dumps({"ok": False, "erro": str(erro)}, ensure_ascii=False))
|
||||
raise SystemExit(1) from erro
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
principal()
|
||||
@@ -2,3 +2,5 @@
|
||||
# a diarização e a detecção de emoção (integracoes/huggingface/provider_de_diarizacao_local.py
|
||||
# e provider_de_emocao_local.py importam soundfile para ler o áudio extraído).
|
||||
soundfile
|
||||
# Dependência opcional carregada somente pelo adaptador de análise musical.
|
||||
essentia
|
||||
|
||||
@@ -40,6 +40,9 @@ class ConfiguracaoVisualDoScanner:
|
||||
faixas_de_audio: tuple[int, ...] = ()
|
||||
modo_de_analise: str = "ambos"
|
||||
metricas_de_fala: bool = False
|
||||
# Classificação de emoção da fala (ProviderDeEmocaoHuggingFace) — desligada
|
||||
# por padrão, mesmo motivo de metricas_de_fala: custo de processamento.
|
||||
classificar_emocao: bool = False
|
||||
recursos_vision: frozenset[str] = RECURSOS_VISION
|
||||
detectar_cenas: bool = True
|
||||
analisar_continuidade: bool = True
|
||||
@@ -93,6 +96,7 @@ class ConfiguracaoVisualDoScanner:
|
||||
faixas_de_audio=tuple(sorted(set(int(item) for item in dados.get("faixas_de_audio", ())))),
|
||||
modo_de_analise=str(dados.get("modo_de_analise", "ambos")),
|
||||
metricas_de_fala=bool(dados.get("metricas_de_fala", False)),
|
||||
classificar_emocao=bool(dados.get("classificar_emocao", False)),
|
||||
recursos_vision=frozenset(dados.get("recursos_vision", RECURSOS_VISION)),
|
||||
detectar_cenas=bool(dados.get("detectar_cenas", True)),
|
||||
analisar_continuidade=bool(dados.get("analisar_continuidade", True)),
|
||||
@@ -108,6 +112,7 @@ class ConfiguracaoVisualDoScanner:
|
||||
)
|
||||
|
||||
def para_dict(self) -> dict[str, Any]:
|
||||
"""Serializa a configuração no mesmo formato de JSON consumido pelo painel."""
|
||||
return {
|
||||
"versao": 1,
|
||||
"intervalo_em_segundos": self.intervalo_em_segundos,
|
||||
@@ -116,6 +121,7 @@ class ConfiguracaoVisualDoScanner:
|
||||
"faixas_de_audio": list(self.faixas_de_audio),
|
||||
"modo_de_analise": self.modo_de_analise,
|
||||
"metricas_de_fala": self.metricas_de_fala,
|
||||
"classificar_emocao": self.classificar_emocao,
|
||||
"recursos_vision": sorted(self.recursos_vision),
|
||||
"detectar_cenas": self.detectar_cenas,
|
||||
"analisar_continuidade": self.analisar_continuidade,
|
||||
|
||||
@@ -5,7 +5,10 @@ têm posição na timeline diferente da posição no arquivo de origem — o mes
|
||||
descompasso encontrado no projeto real entre ``inicio_na_timeline`` e
|
||||
``inicio_na_origem`` do banco de análises. Implementa só as ferramentas MCP
|
||||
que o módulo ``engine.editor`` usa: ``get_active_sequence``, ``split_clip``,
|
||||
``remove_from_timeline``, ``remove_linked_from_timeline``, ``set_clip_properties`` e ``add_marker``.
|
||||
``remove_from_timeline``, ``remove_linked_from_timeline``,
|
||||
``duplicate_linked_range_to_start``, ``set_clip_properties``, ``add_marker``,
|
||||
``add_track``, ``set_playhead_position``, ``add_adjustment_layer``,
|
||||
``get_clip_at_position``, ``trim_clip`` e ``move_clip``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -46,6 +49,7 @@ class ClienteMCPFalso(ClienteMCP):
|
||||
sem_origem_no_audio: bool = False,
|
||||
clipe_unico: bool = False,
|
||||
desvio_do_split: float = 0.0,
|
||||
simular_falha_razor: bool = False,
|
||||
) -> None:
|
||||
"""``sem_origem_no_audio`` reproduz o bug real do bridge: relatar clipes de
|
||||
áudio sem ``inPoint``/``outPoint`` na resposta de ``get_active_sequence``.
|
||||
@@ -56,12 +60,18 @@ class ClienteMCPFalso(ClienteMCP):
|
||||
|
||||
``desvio_do_split`` reproduz o drift real do ``split_clip``: em vez
|
||||
de cortar exatamente no instante pedido, corta em
|
||||
``instante + desvio_do_split``."""
|
||||
``instante + desvio_do_split``.
|
||||
|
||||
``simular_falha_razor`` reproduz o bug QE do Premiere 26.x: o
|
||||
``razor_all_tracks`` reporta clipes elegíveis mas não corta nenhum
|
||||
(``razored=0, eligibleTracks=N``)."""
|
||||
self.sem_origem_no_audio = sem_origem_no_audio
|
||||
self.desvio_do_split = desvio_do_split
|
||||
self.simular_falha_razor = simular_falha_razor
|
||||
self.chamadas: list[tuple[str, dict[str, Any]]] = []
|
||||
self._proximo_id = 0
|
||||
self._historico: list[dict[str, list[_ClipeFalso]]] = []
|
||||
self._playhead = 0.0
|
||||
self._faixas: dict[str, list[_ClipeFalso]] = {
|
||||
"video_0": [self._novo_clipe(self._ARQUIVO, 0.0, 200.0, 0.0, 200.0, "clipe_unico_video")],
|
||||
"audio_0": [self._novo_clipe(self._ARQUIVO, 0.0, 200.0, 0.0, 200.0, "clipe_unico_audio")],
|
||||
@@ -100,9 +110,17 @@ class ClienteMCPFalso(ClienteMCP):
|
||||
"split_clip": self._simular_split_clip,
|
||||
"remove_from_timeline": self._simular_ripple_delete,
|
||||
"remove_linked_from_timeline": self._simular_remocao_vinculada,
|
||||
"duplicate_linked_range_to_start": self._simular_gancho_vinculado_no_inicio,
|
||||
"set_clip_properties": self._simular_set_clip_properties,
|
||||
"add_marker": self._simular_add_marker,
|
||||
"undo": self._simular_undo,
|
||||
"add_track": self._simular_add_track,
|
||||
"set_playhead_position": self._simular_set_playhead_position,
|
||||
"add_adjustment_layer": self._simular_add_adjustment_layer,
|
||||
"razor_all_tracks": self._simular_razor_all_tracks,
|
||||
"get_clip_at_position": self._simular_get_clip_at_position,
|
||||
"trim_clip": self._simular_trim_clip,
|
||||
"move_clip": self._simular_move_clip,
|
||||
}
|
||||
simulador = despachantes.get(nome_da_ferramenta)
|
||||
if simulador is None:
|
||||
@@ -139,6 +157,14 @@ class ClienteMCPFalso(ClienteMCP):
|
||||
"clips": [_clipe_como_dict(clipe, tipo) for clipe in self._faixas[identificador]],
|
||||
}
|
||||
|
||||
faixas_de_video = sorted(
|
||||
(identificador for identificador in self._faixas if identificador.startswith("video_")),
|
||||
key=lambda identificador: int(identificador.split("_")[1]),
|
||||
)
|
||||
faixas_de_audio = sorted(
|
||||
(identificador for identificador in self._faixas if identificador.startswith("audio_")),
|
||||
key=lambda identificador: int(identificador.split("_")[1]),
|
||||
)
|
||||
dados = {
|
||||
"id": "sequencia_falsa",
|
||||
"name": "Sequência de teste",
|
||||
@@ -146,8 +172,14 @@ class ClienteMCPFalso(ClienteMCP):
|
||||
"frameRate": 29.97,
|
||||
"frameSizeHorizontal": 3840,
|
||||
"frameSizeVertical": 2160,
|
||||
"videoTracks": [_faixa_como_dict("video_0", "video", 0)],
|
||||
"audioTracks": [_faixa_como_dict("audio_0", "audio", 0)],
|
||||
"videoTracks": [
|
||||
_faixa_como_dict(identificador, "video", int(identificador.split("_")[1]))
|
||||
for identificador in faixas_de_video
|
||||
],
|
||||
"audioTracks": [
|
||||
_faixa_como_dict(identificador, "audio", int(identificador.split("_")[1]))
|
||||
for identificador in faixas_de_audio
|
||||
],
|
||||
}
|
||||
return {"structuredContent": {"data": dados}}
|
||||
|
||||
@@ -198,6 +230,64 @@ class ClienteMCPFalso(ClienteMCP):
|
||||
f"No clip strictly spans {instante}s on track {identificador_da_faixa}."
|
||||
)
|
||||
|
||||
def _simular_razor_all_tracks(self, argumentos: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Divide todos os clipes de todas as faixas no instante informado.
|
||||
|
||||
Reproduz o ``razor_all_tracks`` do bridge real: corta todas as faixas
|
||||
de vídeo e/ou áudio no mesmo ponto de tempo, garantindo
|
||||
sincronização entre elas.
|
||||
|
||||
Quando ``simular_falha_razor`` está ativo, reporta clipes elegíveis
|
||||
mas não corta nenhum, reproduzindo o bug QE do Premiere 26.x.
|
||||
"""
|
||||
instante_pedido = float(argumentos["time_seconds"])
|
||||
track_type = argumentos.get("track_type", "both")
|
||||
|
||||
elegiveis = 0
|
||||
for identificador_da_faixa, faixa in self._faixas.items():
|
||||
if track_type == "video" and not identificador_da_faixa.startswith("video_"):
|
||||
continue
|
||||
if track_type == "audio" and not identificador_da_faixa.startswith("audio_"):
|
||||
continue
|
||||
for clipe in faixa:
|
||||
if clipe.inicio_na_timeline < instante_pedido < clipe.fim_na_timeline:
|
||||
elegiveis += 1
|
||||
break
|
||||
|
||||
if self.simular_falha_razor:
|
||||
return {"razored": 0, "eligibleTracks": elegiveis, "verified": False}
|
||||
|
||||
cortados = 0
|
||||
self._salvar_estado()
|
||||
for identificador_da_faixa, faixa in self._faixas.items():
|
||||
if track_type == "video" and not identificador_da_faixa.startswith("video_"):
|
||||
continue
|
||||
if track_type == "audio" and not identificador_da_faixa.startswith("audio_"):
|
||||
continue
|
||||
for posicao, clipe in enumerate(faixa):
|
||||
if clipe.inicio_na_timeline < instante_pedido < clipe.fim_na_timeline:
|
||||
instante = min(
|
||||
max(instante_pedido + self.desvio_do_split, clipe.inicio_na_timeline + 0.001),
|
||||
clipe.fim_na_timeline - 0.001,
|
||||
)
|
||||
deslocamento = instante - clipe.inicio_na_timeline
|
||||
self._proximo_id += 1
|
||||
parte_esquerda = _ClipeFalso(
|
||||
f"{clipe.identificador}_esq{self._proximo_id}", clipe.nome,
|
||||
clipe.inicio_na_timeline, instante,
|
||||
clipe.inicio_na_origem, clipe.inicio_na_origem + deslocamento,
|
||||
)
|
||||
self._proximo_id += 1
|
||||
parte_direita = _ClipeFalso(
|
||||
f"{clipe.identificador}_dir{self._proximo_id}", clipe.nome,
|
||||
instante, clipe.fim_na_timeline,
|
||||
clipe.inicio_na_origem + deslocamento, clipe.fim_na_origem,
|
||||
)
|
||||
faixa[posicao:posicao + 1] = [parte_esquerda, parte_direita]
|
||||
cortados += 1
|
||||
break
|
||||
return {"razored": cortados, "eligibleTracks": elegiveis, "verified": True}
|
||||
|
||||
def _simular_ripple_delete(self, argumentos: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Remove o clipe pedido e fecha o espaço deixado na mesma faixa."""
|
||||
identificador_do_clipe = argumentos["node_id"]
|
||||
@@ -223,11 +313,143 @@ class ClienteMCPFalso(ClienteMCP):
|
||||
self._simular_ripple_delete({"node_id": identificador, "ripple": True})
|
||||
return {"removed": True, "verified": True}
|
||||
|
||||
def _simular_gancho_vinculado_no_inicio(self, argumentos: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Duplica um par já isolado no início e preserva sua ocorrência original.
|
||||
|
||||
Espelha o bridge real: não recebe limites de origem à parte, lê o
|
||||
in/out do próprio clipe. Por isso os identificadores precisam já
|
||||
corresponder a um par isolado por ``split_clip`` — o mesmo clipe
|
||||
inteiro, sem sobra de trecho vizinho.
|
||||
"""
|
||||
identificadores = argumentos["node_ids"]
|
||||
encontrados: list[tuple[str, _ClipeFalso]] = []
|
||||
for identificador_da_faixa, clipes in self._faixas.items():
|
||||
for clipe in clipes:
|
||||
if clipe.identificador in identificadores:
|
||||
encontrados.append((identificador_da_faixa, clipe))
|
||||
if len(encontrados) != 2 or {faixa.split("_")[0] for faixa, _ in encontrados} != {"video", "audio"}:
|
||||
raise ErroDeFerramentaMCP("O gancho exige um par de vídeo e áudio existente.")
|
||||
inicios = {round(clipe.inicio_na_timeline, 6) for _, clipe in encontrados}
|
||||
fins = {round(clipe.fim_na_timeline, 6) for _, clipe in encontrados}
|
||||
if len(inicios) != 1 or len(fins) != 1:
|
||||
raise ErroDeFerramentaMCP("O par não ocupa o mesmo intervalo.")
|
||||
if next(iter(inicios)) <= 0.0:
|
||||
raise ErroDeFerramentaMCP("O trecho pedido já está no início da sequência.")
|
||||
|
||||
self._salvar_estado()
|
||||
duracao = next(iter(fins)) - next(iter(inicios))
|
||||
novos_ids: list[str] = []
|
||||
for identificador_da_faixa, clipe in encontrados:
|
||||
faixa = self._faixas[identificador_da_faixa]
|
||||
for outro in faixa:
|
||||
outro.inicio_na_timeline += duracao
|
||||
outro.fim_na_timeline += duracao
|
||||
novo = self._novo_clipe(
|
||||
clipe.nome,
|
||||
0.0,
|
||||
duracao,
|
||||
clipe.inicio_na_origem,
|
||||
clipe.fim_na_origem,
|
||||
)
|
||||
faixa.append(novo)
|
||||
faixa.sort(key=lambda item: item.inicio_na_timeline)
|
||||
novos_ids.append(novo.identificador)
|
||||
return {"duplicated": True, "verified": True, "insertedNodeIds": novos_ids}
|
||||
|
||||
def _simular_set_clip_properties(self, argumentos: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Aceita a alteração de propriedades sem simular geometria (não é usada pelos testes)."""
|
||||
del argumentos
|
||||
return {"updated": True}
|
||||
|
||||
def _simular_add_track(self, argumentos: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Cria ``count`` faixas de vídeo novas, vazias, no topo da pilha.
|
||||
|
||||
Espelha ``add_track`` do bridge real (usado pelo `.mjs` para criar a
|
||||
track de camada de ajuste): devolve ``totalTracks`` já contando as
|
||||
novas, de forma que ``totalTracks - 1`` seja o índice da última
|
||||
faixa criada.
|
||||
"""
|
||||
if argumentos.get("track_type") != "video":
|
||||
raise ErroDeFerramentaMCP("Este dublê só simula add_track para vídeo.")
|
||||
contagem = int(argumentos.get("count", 1))
|
||||
indices_existentes = [
|
||||
int(identificador.split("_")[1])
|
||||
for identificador in self._faixas
|
||||
if identificador.startswith("video_")
|
||||
]
|
||||
proximo_indice = max(indices_existentes, default=-1) + 1
|
||||
for deslocamento in range(contagem):
|
||||
self._faixas[f"video_{proximo_indice + deslocamento}"] = []
|
||||
total_de_faixas_de_video = proximo_indice + contagem
|
||||
return {"totalTracks": total_de_faixas_de_video}
|
||||
|
||||
def _simular_set_playhead_position(self, argumentos: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Guarda a posição do playhead, usada por :meth:`_simular_add_adjustment_layer`."""
|
||||
self._playhead = float(argumentos["time_seconds"])
|
||||
return {"positioned": True}
|
||||
|
||||
def _simular_add_adjustment_layer(self, argumentos: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Cria uma camada de ajuste na track pedida, no instante atual do playhead.
|
||||
|
||||
A camada nasce com uma duração fixa arbitrária (o `.mjs` real conta
|
||||
com isso: sempre chama ``trim_clip``/``move_clip`` em seguida para
|
||||
ajustar duração e posição definitivas).
|
||||
"""
|
||||
indice_da_faixa = int(argumentos["track_index"])
|
||||
identificador_da_faixa = self._identificador_da_faixa(indice_da_faixa, "video")
|
||||
if identificador_da_faixa not in self._faixas:
|
||||
raise ErroDeFerramentaMCP(f"Faixa de vídeo inexistente: {identificador_da_faixa!r}.")
|
||||
duracao_inicial = 5.0
|
||||
clipe = self._novo_clipe(
|
||||
"Adjustment Layer",
|
||||
self._playhead,
|
||||
self._playhead + duracao_inicial,
|
||||
self._playhead,
|
||||
self._playhead + duracao_inicial,
|
||||
)
|
||||
self._faixas[identificador_da_faixa].append(clipe)
|
||||
self._faixas[identificador_da_faixa].sort(key=lambda item: item.inicio_na_timeline)
|
||||
return {"added": True, "nodeId": clipe.identificador}
|
||||
|
||||
def _simular_get_clip_at_position(self, argumentos: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Devolve o clipe da faixa pedida que cobre ``time_seconds``."""
|
||||
identificador_da_faixa = self._identificador_da_faixa(argumentos["track_index"], argumentos["track_type"])
|
||||
instante = float(argumentos["time_seconds"])
|
||||
faixa = self._faixas.get(identificador_da_faixa, [])
|
||||
clipe = next(
|
||||
(c for c in faixa if c.inicio_na_timeline <= instante < c.fim_na_timeline), None
|
||||
)
|
||||
if clipe is None:
|
||||
raise ErroDeFerramentaMCP(f"Nenhum clipe em {identificador_da_faixa!r} no instante {instante}s.")
|
||||
return {"nodeId": clipe.identificador}
|
||||
|
||||
def _simular_trim_clip(self, argumentos: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Ajusta a duração do clipe, medida a partir do próprio início atual."""
|
||||
clipe = self._localizar_clipe(argumentos["node_id"])
|
||||
nova_duracao = float(argumentos["new_out_seconds"])
|
||||
clipe.fim_na_timeline = clipe.inicio_na_timeline + nova_duracao
|
||||
clipe.fim_na_origem = clipe.inicio_na_origem + nova_duracao
|
||||
return {"trimmed": True}
|
||||
|
||||
def _simular_move_clip(self, argumentos: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Move o clipe para ``new_start_seconds``, preservando sua duração."""
|
||||
clipe = self._localizar_clipe(argumentos["node_id"])
|
||||
duracao = clipe.fim_na_timeline - clipe.inicio_na_timeline
|
||||
novo_inicio = float(argumentos["new_start_seconds"])
|
||||
clipe.inicio_na_timeline = novo_inicio
|
||||
clipe.fim_na_timeline = novo_inicio + duracao
|
||||
for faixa in self._faixas.values():
|
||||
faixa.sort(key=lambda item: item.inicio_na_timeline)
|
||||
return {"moved": True}
|
||||
|
||||
def _localizar_clipe(self, identificador_do_clipe: str) -> _ClipeFalso:
|
||||
"""Encontra um clipe pelo identificador em qualquer faixa, ou levanta erro."""
|
||||
for faixa in self._faixas.values():
|
||||
for clipe in faixa:
|
||||
if clipe.identificador == identificador_do_clipe:
|
||||
return clipe
|
||||
raise ErroDeFerramentaMCP(f"Clipe não encontrado: {identificador_do_clipe!r}.")
|
||||
|
||||
def _simular_add_marker(self, argumentos: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Aceita a criação de marcador sem manter estado (a chamada já fica em :attr:`chamadas`)."""
|
||||
del argumentos
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from engine.integracoes.audio import AnalisadorDeMusicaInstrumentalEssentia
|
||||
from engine.integracoes.audio import PontuacaoDeEtiqueta
|
||||
|
||||
|
||||
class TesteAnalisadorDeMusicaInstrumentalEssentia(unittest.TestCase):
|
||||
def test_converte_descritores_e_classificacoes(self):
|
||||
def extrator(_caminho: str):
|
||||
return {
|
||||
"metadata": {"audio_properties": {"length": 123.4}},
|
||||
"rhythm": {"bpm": 92.0, "danceability": 0.61},
|
||||
"tonal": {"key_edma": {"key": "A", "scale": "minor"}},
|
||||
"lowlevel": {"average_loudness": 0.35},
|
||||
"highlevel": {
|
||||
"genre": {"value": {"ambient": 0.9, "rock": 0.0}},
|
||||
"mood": {"value": ["calm", "melancholic"]},
|
||||
},
|
||||
}, {}
|
||||
|
||||
with tempfile.TemporaryDirectory() as pasta:
|
||||
arquivo = Path(pasta) / "instrumental.wav"
|
||||
arquivo.write_bytes(b"audio")
|
||||
resultado = AnalisadorDeMusicaInstrumentalEssentia(extrator).analisar(arquivo)
|
||||
|
||||
self.assertEqual(resultado.batidas_por_minuto, 92.0)
|
||||
self.assertEqual(resultado.tonalidade, "A")
|
||||
self.assertEqual(resultado.modo, "minor")
|
||||
self.assertEqual(resultado.generos, ("ambient",))
|
||||
self.assertEqual(resultado.humores, ("calm", "melancholic"))
|
||||
|
||||
def test_rejeita_arquivo_inexistente(self):
|
||||
analisador = AnalisadorDeMusicaInstrumentalEssentia(lambda _: ({}, {}))
|
||||
with self.assertRaises(FileNotFoundError):
|
||||
analisador.analisar("/arquivo/inexistente.wav")
|
||||
|
||||
def test_aceita_descritores_com_chaves_pontuadas_do_essentia(self):
|
||||
def extrator(_caminho: str):
|
||||
return {
|
||||
"metadata.audio_properties.length": 10.0,
|
||||
"rhythm.bpm": 110.0,
|
||||
"rhythm.danceability": 0.42,
|
||||
"tonal.key_edma.key": "D",
|
||||
"tonal.key_edma.scale": "major",
|
||||
"lowlevel.average_loudness": 0.22,
|
||||
}, {}
|
||||
|
||||
with tempfile.TemporaryDirectory() as pasta:
|
||||
arquivo = Path(pasta) / "instrumental.wav"
|
||||
arquivo.write_bytes(b"audio")
|
||||
resultado = AnalisadorDeMusicaInstrumentalEssentia(extrator).analisar(arquivo)
|
||||
|
||||
self.assertEqual(resultado.duracao_em_segundos, 10.0)
|
||||
self.assertEqual(resultado.batidas_por_minuto, 110.0)
|
||||
self.assertEqual(resultado.tonalidade, "D")
|
||||
|
||||
def test_incorpora_classificacoes_do_audio_ao_resultado(self):
|
||||
classificador = type("Classificador", (), {
|
||||
"classificar": lambda self, _arquivo: (
|
||||
(PontuacaoDeEtiqueta("Jazz-Funk", 0.8),),
|
||||
(PontuacaoDeEtiqueta("energética", 0.7),),
|
||||
),
|
||||
})()
|
||||
|
||||
def extrator(_caminho: str):
|
||||
return {"rhythm.bpm": 100.0}, {}
|
||||
|
||||
with tempfile.TemporaryDirectory() as pasta:
|
||||
arquivo = Path(pasta) / "instrumental.wav"
|
||||
arquivo.write_bytes(b"audio")
|
||||
resultado = AnalisadorDeMusicaInstrumentalEssentia(
|
||||
extrator, classificador=classificador,
|
||||
).analisar(arquivo)
|
||||
|
||||
self.assertEqual(resultado.generos, ("Jazz-Funk",))
|
||||
self.assertEqual(resultado.humores, ("energética",))
|
||||
self.assertEqual(resultado.pontuacoes_de_genero[0].confianca, 0.8)
|
||||
@@ -74,11 +74,8 @@ class TesteAplicadorDePlanoDeEdicao(unittest.TestCase):
|
||||
self.assertEqual([clipe.identificador for clipe in clipes], ["clipe_real"])
|
||||
|
||||
def test_marcador_e_zoom_sao_aplicados_depois_dos_cortes(self):
|
||||
# Zoom muda uma propriedade do clipe inteiro (set_clip_properties),
|
||||
# não de um trecho dele. Se o zoom rodasse antes do corte, cairia no
|
||||
# clipe original ainda não dividido; dois zooms em pontos diferentes
|
||||
# do mesmo clipe se sobrescreveriam, e a divisão posterior faria os
|
||||
# pedaços herdarem só o último valor. Por isso cortes vêm primeiro.
|
||||
# Zoom e marcador precisam do instante definitivo na timeline, e
|
||||
# cortes deslocam clipes (ripple) — por isso cortes vêm primeiro.
|
||||
plano = PlanoDeEdicao("0E6A8290.mp4", (
|
||||
AcaoDeEdicao(TipoDeAcao.CORTE, inicio=0.0, fim=40.6, motivo="bastidor"),
|
||||
AcaoDeEdicao(TipoDeAcao.MARCADOR, inicio=90.0, fim=90.5, motivo="conferir emenda", parametros={"content": "EMENDA"}),
|
||||
@@ -88,12 +85,16 @@ class TesteAplicadorDePlanoDeEdicao(unittest.TestCase):
|
||||
self.assertTrue(resultado.todas_bem_sucedidas, msg=[r.detalhe for r in resultado.falhas])
|
||||
# corte aconteceu antes de marcador e zoom na sequência de chamadas
|
||||
indice_do_marcador = next(i for i, c in enumerate(self.cliente.chamadas) if c[0] == "add_marker")
|
||||
indice_do_zoom = next(i for i, c in enumerate(self.cliente.chamadas) if c[0] == "set_clip_properties")
|
||||
indice_do_zoom = next(i for i, c in enumerate(self.cliente.chamadas) if c[0] == "add_adjustment_layer")
|
||||
indice_da_remocao = next(
|
||||
i for i, c in enumerate(self.cliente.chamadas) if c[0] == "remove_linked_from_timeline"
|
||||
)
|
||||
self.assertLess(indice_da_remocao, indice_do_marcador)
|
||||
self.assertLess(indice_da_remocao, indice_do_zoom)
|
||||
# a adjustment layer foi criada e escalada, sem tocar o clipe original
|
||||
self.assertTrue(any(c[0] == "add_track" for c in self.cliente.chamadas))
|
||||
chamada_de_escala = next(c for c in self.cliente.chamadas if c[0] == "set_clip_properties")
|
||||
self.assertAlmostEqual(chamada_de_escala[1]["scale"], 115.0)
|
||||
|
||||
def test_dois_cortes_sao_aplicados_do_fim_para_o_comeco(self):
|
||||
plano = PlanoDeEdicao("0E6A8290.mp4", (
|
||||
@@ -121,12 +122,11 @@ class TesteAplicadorDePlanoDeEdicao(unittest.TestCase):
|
||||
# a ação válida (marcador) ainda foi aplicada
|
||||
self.assertTrue(any(c[0] == "add_marker" for c in self.cliente.chamadas))
|
||||
|
||||
def test_dois_zooms_no_mesmo_clipe_original_nao_se_sobrescrevem(self):
|
||||
# Bug real: com um só clipe cobrindo o arquivo inteiro (gravação
|
||||
# "sem corte"), dois zooms em trechos diferentes caíam no mesmo
|
||||
# clipe ainda não dividido; o segundo sobrescrevia a escala do
|
||||
# primeiro no clipe inteiro, e ambos os pedaços resultantes da
|
||||
# divisão herdavam só o último valor.
|
||||
def test_dois_zooms_geram_duas_camadas_de_ajuste_na_mesma_faixa(self):
|
||||
# Com a técnica de adjustment layer, dois zooms em trechos
|
||||
# diferentes do mesmo clipe original (gravação "sem corte") não
|
||||
# competem por uma propriedade do clipe inteiro: cada um ganha sua
|
||||
# própria camada, na mesma faixa de ajuste reaproveitada.
|
||||
cliente = ClienteMCPFalso(clipe_unico=True)
|
||||
aplicador = AplicadorDePlanoDeEdicao(
|
||||
acesso_ao_editor=AcessoAoEditor(AcessoATimeline(cliente)),
|
||||
@@ -141,13 +141,23 @@ class TesteAplicadorDePlanoDeEdicao(unittest.TestCase):
|
||||
))
|
||||
resultado = aplicador.aplicar(plano)
|
||||
self.assertTrue(resultado.todas_bem_sucedidas, msg=[r.detalhe for r in resultado.falhas])
|
||||
# só uma faixa de ajuste foi criada, reaproveitada pelos dois zooms
|
||||
chamadas_de_track = [c for c in cliente.chamadas if c[0] == "add_track"]
|
||||
self.assertEqual(len(chamadas_de_track), 1)
|
||||
chamadas_de_camada = [c for c in cliente.chamadas if c[0] == "add_adjustment_layer"]
|
||||
self.assertEqual(len(chamadas_de_camada), 2)
|
||||
indices_de_faixa = {c[1]["track_index"] for c in chamadas_de_camada}
|
||||
self.assertEqual(len(indices_de_faixa), 1)
|
||||
chamadas_de_zoom = [c for c in cliente.chamadas if c[0] == "set_clip_properties"]
|
||||
self.assertEqual(len(chamadas_de_zoom), 2)
|
||||
node_ids_zoomados = {c[1]["node_id"] for c in chamadas_de_zoom}
|
||||
# os dois zooms caíram em clipes diferentes, um por trecho
|
||||
# os dois zooms caíram em camadas diferentes, uma por trecho
|
||||
self.assertEqual(len(node_ids_zoomados), 2)
|
||||
escalas = sorted(round(c[1]["scale"], 3) for c in chamadas_de_zoom)
|
||||
self.assertEqual(escalas, [115.0, 130.0])
|
||||
# o clipe original não foi escalado
|
||||
node_ids_clipe_original = {"clipe_unico_video", "clipe_unico_audio"}
|
||||
self.assertFalse(node_ids_zoomados & node_ids_clipe_original)
|
||||
|
||||
def test_corte_com_desvio_de_bridge_usa_a_borda_real_em_vez_de_falhar(self):
|
||||
# O split_clip do bridge do Premiere corta alguns décimos de segundo
|
||||
@@ -249,6 +259,112 @@ class TesteAplicadorDePlanoDeEdicao(unittest.TestCase):
|
||||
self.assertEqual(len(chamadas_de_marcador), 1)
|
||||
self.assertIn("MASTOPEXIA", chamadas_de_marcador[0][1]["comments"])
|
||||
|
||||
def test_gancho_isola_video_e_audio_e_duplica_no_inicio(self):
|
||||
cliente = ClienteMCPFalso(clipe_unico=True)
|
||||
aplicador = AplicadorDePlanoDeEdicao(
|
||||
acesso_ao_editor=AcessoAoEditor(AcessoATimeline(cliente)),
|
||||
conversor_de_timeline=ConversorDeTimeline(),
|
||||
escrita=EscritaNoEditor(cliente),
|
||||
mapeador=MapeadorDeTempoDeOrigemParaTimeline(),
|
||||
)
|
||||
plano = PlanoDeEdicao("0E6A8290.mp4", (
|
||||
AcaoDeEdicao(
|
||||
TipoDeAcao.GANCHO,
|
||||
inicio=80.0,
|
||||
fim=90.0,
|
||||
motivo="gancho de abertura",
|
||||
parametros={"target": 0, "keep_original": True},
|
||||
),
|
||||
))
|
||||
|
||||
resultado = aplicador.aplicar(plano)
|
||||
|
||||
self.assertTrue(resultado.todas_bem_sucedidas, msg=[item.detalhe for item in resultado.falhas])
|
||||
ganchos = [chamada for chamada in cliente.chamadas if chamada[0] == "duplicate_linked_range_to_start"]
|
||||
self.assertEqual(len(ganchos), 1)
|
||||
self.assertEqual(len(ganchos[0][1]["node_ids"]), 2)
|
||||
self.assertEqual(cliente._faixas["video_0"][0].inicio_na_origem, 80.0)
|
||||
self.assertEqual(cliente._faixas["audio_0"][0].inicio_na_origem, 80.0)
|
||||
self.assertEqual(cliente._faixas["video_0"][0].inicio_na_timeline, 0.0)
|
||||
self.assertEqual(cliente._faixas["audio_0"][0].inicio_na_timeline, 0.0)
|
||||
self.assertEqual(
|
||||
sum(1 for clipe in cliente._faixas["video_0"] if clipe.inicio_na_origem == 80.0),
|
||||
2,
|
||||
)
|
||||
self.assertEqual(
|
||||
sum(1 for clipe in cliente._faixas["audio_0"] if clipe.inicio_na_origem == 80.0),
|
||||
2,
|
||||
)
|
||||
|
||||
def test_gancho_e_executado_apos_cortes_e_antes_de_zoom(self):
|
||||
cliente = ClienteMCPFalso(clipe_unico=True)
|
||||
aplicador = AplicadorDePlanoDeEdicao(
|
||||
acesso_ao_editor=AcessoAoEditor(AcessoATimeline(cliente)),
|
||||
conversor_de_timeline=ConversorDeTimeline(),
|
||||
escrita=EscritaNoEditor(cliente),
|
||||
mapeador=MapeadorDeTempoDeOrigemParaTimeline(),
|
||||
)
|
||||
plano = PlanoDeEdicao("0E6A8290.mp4", (
|
||||
AcaoDeEdicao(TipoDeAcao.CORTE, 20.0, 30.0, "retirar bastidor"),
|
||||
AcaoDeEdicao(TipoDeAcao.GANCHO, 80.0, 90.0, "gancho"),
|
||||
AcaoDeEdicao(TipoDeAcao.ZOOM, 100.0, 105.0, "ênfase", {"scale": 1.15}),
|
||||
))
|
||||
|
||||
resultado = aplicador.aplicar(plano)
|
||||
|
||||
self.assertTrue(resultado.todas_bem_sucedidas, msg=[item.detalhe for item in resultado.falhas])
|
||||
nomes = [nome for nome, _ in cliente.chamadas]
|
||||
self.assertLess(nomes.index("remove_linked_from_timeline"), nomes.index("duplicate_linked_range_to_start"))
|
||||
self.assertLess(nomes.index("duplicate_linked_range_to_start"), nomes.index("set_clip_properties"))
|
||||
|
||||
def test_corte_falha_limpo_quando_razor_nao_corta_nenhuma_faixa(self):
|
||||
"""Quando o razor_all_tracks falha (QE no-op do Premiere 26.x),
|
||||
o corte deve falhar sem corromper a timeline para operações
|
||||
subsequentes — nenhuma remoção é tentada e marcadores/zooms
|
||||
continuam funcionando."""
|
||||
cliente = ClienteMCPFalso(simular_falha_razor=True)
|
||||
aplicador = AplicadorDePlanoDeEdicao(
|
||||
acesso_ao_editor=AcessoAoEditor(AcessoATimeline(cliente)),
|
||||
conversor_de_timeline=ConversorDeTimeline(),
|
||||
escrita=EscritaNoEditor(cliente),
|
||||
mapeador=MapeadorDeTempoDeOrigemParaTimeline(),
|
||||
)
|
||||
plano = PlanoDeEdicao("0E6A8290.mp4", (
|
||||
AcaoDeEdicao(TipoDeAcao.CORTE, inicio=2.3, fim=20.0, motivo="bastidor"),
|
||||
AcaoDeEdicao(TipoDeAcao.MARCADOR, inicio=90.0, fim=90.5, motivo="ok", parametros={"content": "X"}),
|
||||
))
|
||||
resultado = aplicador.aplicar(plano)
|
||||
self.assertFalse(resultado.todas_bem_sucedidas)
|
||||
self.assertEqual(len(resultado.falhas), 1)
|
||||
self.assertIn("razor", resultado.falhas[0].detalhe.lower())
|
||||
self.assertTrue(any(c[0] == "add_marker" for c in cliente.chamadas))
|
||||
self.assertEqual(cliente.chamadas_de_remocao, [])
|
||||
|
||||
def test_gancho_falha_limpo_quando_razor_nao_corta_nenhuma_faixa(self):
|
||||
"""Quando o razor_all_tracks falha ao isolar o trecho do gancho,
|
||||
a operação deve falhar sem duplicar nada na timeline."""
|
||||
cliente = ClienteMCPFalso(clipe_unico=True, simular_falha_razor=True)
|
||||
aplicador = AplicadorDePlanoDeEdicao(
|
||||
acesso_ao_editor=AcessoAoEditor(AcessoATimeline(cliente)),
|
||||
conversor_de_timeline=ConversorDeTimeline(),
|
||||
escrita=EscritaNoEditor(cliente),
|
||||
mapeador=MapeadorDeTempoDeOrigemParaTimeline(),
|
||||
)
|
||||
plano = PlanoDeEdicao("0E6A8290.mp4", (
|
||||
AcaoDeEdicao(
|
||||
TipoDeAcao.GANCHO,
|
||||
inicio=80.0,
|
||||
fim=90.0,
|
||||
motivo="gancho de abertura",
|
||||
parametros={"target": 0, "keep_original": True},
|
||||
),
|
||||
))
|
||||
resultado = aplicador.aplicar(plano)
|
||||
self.assertFalse(resultado.todas_bem_sucedidas)
|
||||
self.assertEqual(len(resultado.falhas), 1)
|
||||
duplicacoes = [c for c in cliente.chamadas if c[0] == "duplicate_linked_range_to_start"]
|
||||
self.assertEqual(len(duplicacoes), 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -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 AcaoDeEdicao, ErroDePlanoInvalido, PlanoDeEdicao, TipoDeAcao
|
||||
|
||||
|
||||
class TesteResolucaoDoNode(unittest.TestCase):
|
||||
@@ -35,5 +38,67 @@ 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_gancho_exige_backup_confirmado(self) -> None:
|
||||
"""A operação composta não começa quando a recuperação não foi criada."""
|
||||
plano = PlanoDeEdicao("video.mp4", (
|
||||
AcaoDeEdicao(TipoDeAcao.GANCHO, 10.0, 15.0, "gancho"),
|
||||
))
|
||||
|
||||
with self.assertRaisesRegex(ErroDePlanoInvalido, "não confirmou o backup"):
|
||||
aplicar_plano_de_edicao._garantir_backup_para_ganchos(plano, False)
|
||||
|
||||
def test_corte_preserva_comportamento_legado_quando_backup_falha(self) -> None:
|
||||
"""A exigência adicional fica restrita ao gancho estrutural."""
|
||||
plano = PlanoDeEdicao("video.mp4", (
|
||||
AcaoDeEdicao(TipoDeAcao.CORTE, 10.0, 15.0, "retirada"),
|
||||
))
|
||||
|
||||
aplicar_plano_de_edicao._garantir_backup_para_ganchos(plano, False)
|
||||
|
||||
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,74 @@
|
||||
"""Testes do carregamento de planos de edição diretamente do SQLite."""
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from engine.carregar_plano_de_edicao import carregar
|
||||
from engine.dominio import Clipe, Faixa, IntervaloDeTempo, Timeline
|
||||
from engine.persistencia import abrir_banco
|
||||
from engine.persistencia.repositorio_de_planos import RepositorioDePlanos, plano_de_dict
|
||||
from engine.persistencia.repositorio_de_timeline_sqlite import RepositorioDeTimelineSQLite
|
||||
|
||||
|
||||
class TesteCarregarPlanoDeEdicao(unittest.TestCase):
|
||||
"""Garante que a interface receba o plano no contrato de aplicação."""
|
||||
|
||||
def test_carrega_plano_mais_recente_do_video(self):
|
||||
with tempfile.TemporaryDirectory() as pasta:
|
||||
banco = Path(pasta) / "analises.db"
|
||||
conexao = abrir_banco(banco)
|
||||
repositorio = RepositorioDePlanos(conexao)
|
||||
plano_id = repositorio.registrar_plano(plano_de_dict(
|
||||
{"source": "Sequência", "actions": [
|
||||
{"kind": "cut", "start": 1, "end": 2, "reason": "repetição"},
|
||||
]}, video_id="video-1", tipo_de_video="depoimento",
|
||||
))
|
||||
conexao.close()
|
||||
|
||||
carregado = carregar(banco, video_id="video-1")
|
||||
|
||||
self.assertEqual(carregado["plano_id"], plano_id)
|
||||
self.assertEqual(carregado["video_id"], "video-1")
|
||||
self.assertEqual(carregado["actions"][0]["kind"], "cut")
|
||||
self.assertEqual(carregado["actions"][0]["reason"], "repetição")
|
||||
|
||||
def test_resolve_nome_da_sequencia_para_arquivo_unico_do_video(self):
|
||||
"""Entrega ao aplicador a mídia real quando o plano histórico guardou a sequência."""
|
||||
with tempfile.TemporaryDirectory() as pasta:
|
||||
banco = Path(pasta) / "analises.db"
|
||||
conexao = abrir_banco(banco)
|
||||
RepositorioDeTimelineSQLite(conexao).registrar_timeline(Timeline(
|
||||
identificador="video-1",
|
||||
nome="Cópia de 0E6A8829",
|
||||
faixas=[Faixa(
|
||||
identificador="video_0",
|
||||
nome="Vídeo 1",
|
||||
tipo="video",
|
||||
indice=0,
|
||||
clipes=[Clipe(
|
||||
identificador="clipe-1",
|
||||
nome="0E6A8829.MP4",
|
||||
intervalo_na_timeline=IntervaloDeTempo(0.0, 1018.2),
|
||||
intervalo_na_origem=IntervaloDeTempo(0.0, 1018.2),
|
||||
arquivo="/Volumes/Midia/0E6A8829.MP4",
|
||||
)],
|
||||
)],
|
||||
))
|
||||
repositorio = RepositorioDePlanos(conexao)
|
||||
repositorio.registrar_plano(plano_de_dict(
|
||||
{"source": "Cópia de 0E6A8829", "actions": [
|
||||
{"kind": "cut", "start": 0.0, "end": 124.79, "reason": "abertura"},
|
||||
]},
|
||||
video_id="video-1",
|
||||
))
|
||||
conexao.close()
|
||||
|
||||
carregado = carregar(banco, video_id="video-1")
|
||||
|
||||
self.assertEqual(carregado["source"], "/Volumes/Midia/0E6A8829.MP4")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Testes da configuração declarativa do Scanner, incluindo classificar_emocao."""
|
||||
|
||||
import unittest
|
||||
|
||||
from engine.scanner.configuracao_visual import ConfiguracaoVisualDoScanner
|
||||
|
||||
|
||||
class TesteDaFlagClassificarEmocao(unittest.TestCase):
|
||||
"""Garante que classificar_emocao siga o mesmo padrão de metricas_de_fala."""
|
||||
|
||||
def test_classificar_emocao_e_desligada_por_padrao(self):
|
||||
configuracao = ConfiguracaoVisualDoScanner()
|
||||
|
||||
self.assertFalse(configuracao.classificar_emocao)
|
||||
|
||||
def test_de_dict_le_classificar_emocao_do_perfil_do_painel(self):
|
||||
configuracao = ConfiguracaoVisualDoScanner.de_dict({"classificar_emocao": True})
|
||||
|
||||
self.assertTrue(configuracao.classificar_emocao)
|
||||
|
||||
def test_para_dict_serializa_classificar_emocao(self):
|
||||
configuracao = ConfiguracaoVisualDoScanner(classificar_emocao=True)
|
||||
|
||||
self.assertTrue(configuracao.para_dict()["classificar_emocao"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -15,7 +15,9 @@ from engine.dominio import Clipe, Faixa, IntervaloDeTempo, Timeline
|
||||
from engine.persistencia import (
|
||||
ConsultasDeAnalises,
|
||||
ErroDeConsultaInvalida,
|
||||
EdicaoDeVideo,
|
||||
RepositorioDeAnalisesSQLite,
|
||||
RepositorioDeEdicoes,
|
||||
RepositorioDeRetakesSQLite,
|
||||
RepositorioDeTimelineSQLite,
|
||||
)
|
||||
@@ -95,6 +97,24 @@ class TesteConsultasDeAnalises(unittest.TestCase):
|
||||
with self.assertRaises(ErroDeConsultaInvalida):
|
||||
self.consultas.consultar_resumo_do_video("")
|
||||
|
||||
def test_consultar_contexto_da_edicao_carrega_edicao_mais_recente_e_falas(self) -> None:
|
||||
RepositorioDeEdicoes(self.consultas.conexao).registrar(EdicaoDeVideo(
|
||||
video_id=VIDEO_ID,
|
||||
sequencia="Sequência",
|
||||
origem="/videos/entrada.mp4",
|
||||
tipo_de_video="depoimento",
|
||||
configuracao={"objetivo": "Gerar confiança"},
|
||||
))
|
||||
|
||||
contexto = self.consultas.consultar_contexto_da_edicao()
|
||||
|
||||
self.assertIsNotNone(contexto)
|
||||
self.assertEqual(contexto["edicao"]["tipo_de_video"], "depoimento")
|
||||
self.assertEqual(contexto["edicao"]["configuracao"]["objetivo"], "Gerar confiança")
|
||||
self.assertEqual(contexto["video"]["video_id"], VIDEO_ID)
|
||||
self.assertEqual(len(contexto["falas"]), 1)
|
||||
self.assertEqual(contexto["falas"][0]["texto"], "Hoje nós vamos mostrar como funciona o sistema.")
|
||||
|
||||
def test_consultar_status_da_analise_expoe_falantes_e_metricas(self) -> None:
|
||||
status = self.consultas.consultar_status_da_analise(VIDEO_ID)
|
||||
|
||||
@@ -105,6 +125,35 @@ class TesteConsultasDeAnalises(unittest.TestCase):
|
||||
self.assertFalse(status["audio"]["metricas_de_voz_disponiveis"])
|
||||
self.assertEqual(status["audio"]["falantes"], ["locutor"])
|
||||
|
||||
def test_listar_intervalos_de_preview_converte_timeline_para_origem(self) -> None:
|
||||
self.consultas.conexao.executescript(
|
||||
"""INSERT INTO faixas (id, video_id, nome, tipo, indice)
|
||||
VALUES ('audio-2', 'v1', 'A2', 'audio', 1);
|
||||
INSERT INTO faixas (id, video_id, nome, tipo, indice)
|
||||
VALUES ('video-2', 'v1', 'V2', 'video', 1);
|
||||
INSERT INTO clipes
|
||||
(id, video_id, faixa_id, nome, inicio_na_timeline,
|
||||
fim_na_timeline, inicio_na_origem, fim_na_origem, arquivo)
|
||||
VALUES ('audio-clip-2', 'v1', 'audio-2', 'A2', 30.0, 40.0,
|
||||
100.0, 110.0, '/videos/entrevista.mp4');
|
||||
INSERT INTO clipes
|
||||
(id, video_id, faixa_id, nome, inicio_na_timeline,
|
||||
fim_na_timeline, inicio_na_origem, fim_na_origem, arquivo)
|
||||
VALUES ('video-clip-2', 'v1', 'video-2', 'V2', 30.0, 40.0,
|
||||
100.0, 110.0, '/videos/entrevista.mp4');
|
||||
INSERT INTO segmentos_de_transcricao
|
||||
(video_id, clipe_id, inicio, fim, texto, falante)
|
||||
VALUES ('v1', 'audio-clip-2', 32.5, 35.0, 'trecho', 'locutor-2');"""
|
||||
)
|
||||
|
||||
intervalos = self.consultas.listar_intervalos_de_preview_dos_falantes(VIDEO_ID)
|
||||
|
||||
intervalo = next(item for item in intervalos if item["falante"] == "locutor-2")
|
||||
self.assertEqual(intervalo["clipe_de_video"], "video-clip-2")
|
||||
self.assertEqual(intervalo["inicio"], 102.5)
|
||||
self.assertEqual(intervalo["fim"], 105.0)
|
||||
self.assertEqual(intervalo["arquivo"], "/videos/entrevista.mp4")
|
||||
|
||||
# --- listar_falas_no_intervalo ------------------------------------------------------
|
||||
|
||||
def test_listar_falas_no_intervalo_filtra_por_tempo_e_omite_palavras_por_padrao(self) -> None:
|
||||
|
||||
@@ -71,5 +71,19 @@ class TesteDividirClipeSeNecessario(unittest.TestCase):
|
||||
self._dividir("razor changed the track clip count from 4 to 7, expected 5")
|
||||
|
||||
|
||||
class TesteDuplicarTrechosVinculados(unittest.TestCase):
|
||||
"""Cobre o contrato estreito da escrita do gancho editorial."""
|
||||
|
||||
def test_exige_exatamente_dois_identificadores(self):
|
||||
escrita = EscritaNoEditor(_ClienteQueRecusa("não deveria chamar"))
|
||||
with self.assertRaisesRegex(ErroDeEscritaNoEditor, "exatamente"):
|
||||
escrita.duplicar_trecho_vinculado_para_o_inicio(["somente-video"])
|
||||
|
||||
def test_converte_falha_da_ferramenta_em_erro_de_escrita(self):
|
||||
escrita = EscritaNoEditor(_ClienteQueRecusa("faixa adicional ocupada"))
|
||||
with self.assertRaisesRegex(ErroDeEscritaNoEditor, "faixa adicional ocupada"):
|
||||
escrita.duplicar_trecho_vinculado_para_o_inicio(["video", "audio"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -5,6 +5,9 @@ import sys
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
from engine.dominio import Clipe, IntervaloDeTempo
|
||||
from engine.executar_scanner import serializar_clipe_para_relatorio
|
||||
|
||||
|
||||
class TesteDoPontoDeEntradaDoScanner(unittest.TestCase):
|
||||
"""Garante que o Scanner possa ser iniciado pelo caminho usado no painel."""
|
||||
@@ -41,6 +44,22 @@ class TesteDoPontoDeEntradaDoScanner(unittest.TestCase):
|
||||
|
||||
self.assertEqual(resultado.returncode, 0, resultado.stderr)
|
||||
|
||||
def test_relatorio_de_audio_preserva_caminho_e_intervalo_da_origem(self):
|
||||
"""Mantém no artefato os dados necessários para aplicar o plano depois."""
|
||||
clipe = Clipe(
|
||||
identificador="clipe-1",
|
||||
nome="0E6A8829.MP4",
|
||||
intervalo_na_timeline=IntervaloDeTempo(0.0, 100.0),
|
||||
intervalo_na_origem=IntervaloDeTempo(20.0, 120.0),
|
||||
arquivo="/Volumes/Midia/0E6A8829.MP4",
|
||||
)
|
||||
|
||||
dados = serializar_clipe_para_relatorio(clipe)
|
||||
|
||||
self.assertEqual(dados["sourceFile"], "/Volumes/Midia/0E6A8829.MP4")
|
||||
self.assertEqual(dados["inPoint"], 20.0)
|
||||
self.assertEqual(dados["outPoint"], 120.0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -66,6 +66,50 @@ class TesteLeitorDePlanoDeEdicao(unittest.TestCase):
|
||||
"actions": [{"kind": "voar", "start": 0, "end": 1, "reason": "x"}],
|
||||
})
|
||||
|
||||
def test_le_gancho_duplicado_para_o_inicio(self):
|
||||
plano = self.leitor.ler_de_dicionario({
|
||||
"source": "a.mp4",
|
||||
"actions": [{
|
||||
"kind": "hook",
|
||||
"start": 40,
|
||||
"end": 46,
|
||||
"reason": "gancho de abertura",
|
||||
"params": {"target": 0, "keep_original": True},
|
||||
}],
|
||||
})
|
||||
self.assertEqual(plano.acoes[0].tipo, TipoDeAcao.GANCHO)
|
||||
self.assertEqual(plano.acoes[0].parametros["target"], 0)
|
||||
|
||||
def test_gancho_para_destino_diferente_do_inicio_e_rejeitado(self):
|
||||
with self.assertRaisesRegex(ErroDePlanoInvalido, "target precisa ser 0"):
|
||||
self.leitor.ler_de_dicionario({
|
||||
"source": "a.mp4",
|
||||
"actions": [{
|
||||
"kind": "hook", "start": 40, "end": 46,
|
||||
"reason": "reordenar", "params": {"target": 12},
|
||||
}],
|
||||
})
|
||||
|
||||
def test_gancho_que_remove_o_original_e_rejeitado(self):
|
||||
with self.assertRaisesRegex(ErroDePlanoInvalido, "keep_original precisa ser true"):
|
||||
self.leitor.ler_de_dicionario({
|
||||
"source": "a.mp4",
|
||||
"actions": [{
|
||||
"kind": "hook", "start": 40, "end": 46,
|
||||
"reason": "abertura", "params": {"keep_original": False},
|
||||
}],
|
||||
})
|
||||
|
||||
def test_corte_sobreposto_ao_gancho_e_rejeitado(self):
|
||||
with self.assertRaisesRegex(ErroDePlanoInvalido, "não pode sobrepor"):
|
||||
self.leitor.ler_de_dicionario({
|
||||
"source": "a.mp4",
|
||||
"actions": [
|
||||
{"kind": "cut", "start": 42, "end": 44, "reason": "cortar"},
|
||||
{"kind": "hook", "start": 40, "end": 46, "reason": "abertura"},
|
||||
],
|
||||
})
|
||||
|
||||
def test_fim_menor_ou_igual_ao_inicio_levanta_erro(self):
|
||||
with self.assertRaises(ErroDePlanoInvalido):
|
||||
self.leitor.ler_de_dicionario({
|
||||
|
||||
@@ -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()
|
||||
@@ -8,7 +8,9 @@ from pathlib import Path
|
||||
from engine.dominio import Clipe, Faixa, IntervaloDeTempo, Timeline
|
||||
from engine.integracoes.midia.extracao_de_metadados import MetadadosDoArquivo
|
||||
from engine.persistencia import (
|
||||
EdicaoDeVideo,
|
||||
RepositorioDeAnalisesSQLite,
|
||||
RepositorioDeEdicoes,
|
||||
RepositorioDeRetakesSQLite,
|
||||
RepositorioDeTimelineSQLite,
|
||||
abrir_banco,
|
||||
@@ -23,6 +25,7 @@ TABELAS_ESPERADAS = {
|
||||
"segmentos_de_transcricao", "palavras_de_transcricao",
|
||||
"evidencias_visuais", "cenas", "grupos_de_retake",
|
||||
"tomadas_de_retake", "evidencias_de_retake",
|
||||
"edicoes_de_video",
|
||||
}
|
||||
|
||||
|
||||
@@ -129,6 +132,27 @@ class TesteRepositorioDeAnalisesSQLite(unittest.TestCase):
|
||||
self.assertEqual(linha["largura"], 1920)
|
||||
self.assertEqual(linha["hash_do_conteudo"], "hash123")
|
||||
|
||||
|
||||
class TesteRepositorioDeEdicoes(unittest.TestCase):
|
||||
def test_registrar_configuracao_editorial_preserva_dados(self):
|
||||
with tempfile.TemporaryDirectory() as pasta:
|
||||
conexao = abrir_banco(Path(pasta) / "analises.db")
|
||||
repositorio = RepositorioDeEdicoes(conexao)
|
||||
identificador = repositorio.registrar(EdicaoDeVideo(
|
||||
video_id="sequencia-1",
|
||||
sequencia="Depoimento",
|
||||
origem="/videos/depoimento.mp4",
|
||||
tipo_de_video="depoimento",
|
||||
configuracao={"legendas": {"habilitadas": True}},
|
||||
))
|
||||
linha = conexao.execute(
|
||||
"SELECT * FROM edicoes_de_video WHERE id = ?", (identificador,),
|
||||
).fetchone()
|
||||
|
||||
self.assertEqual(linha["video_id"], "sequencia-1")
|
||||
self.assertEqual(linha["tipo_de_video"], "depoimento")
|
||||
self.assertIn("habilitadas", linha["configuracao"])
|
||||
|
||||
def test_registrar_e_carregar_transcricoes_com_palavras(self):
|
||||
with tempfile.TemporaryDirectory() as pasta:
|
||||
repositorio = RepositorioDeAnalisesSQLite(Path(pasta) / "analises.db")
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Testes da entrada de planos de edição no banco SQLite."""
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from engine.persistencia import EdicaoDeVideo, RepositorioDeEdicoes, abrir_banco
|
||||
from engine.registrar_plano_de_edicao import registrar
|
||||
|
||||
|
||||
class TesteRegistrarPlanoDeEdicao(unittest.TestCase):
|
||||
"""Garante que o plano é associado ao contexto editorial mais recente."""
|
||||
|
||||
def test_registra_plano_e_descobre_video_pela_origem(self):
|
||||
with tempfile.TemporaryDirectory() as pasta:
|
||||
banco = Path(pasta) / "analises.db"
|
||||
conexao = abrir_banco(banco)
|
||||
RepositorioDeEdicoes(conexao).registrar(EdicaoDeVideo(
|
||||
video_id="video-1", sequencia="Depoimento", origem="/v.mp4",
|
||||
tipo_de_video="depoimento", configuracao={"legendas": True},
|
||||
))
|
||||
conexao.close()
|
||||
arquivo = Path(pasta) / "plano.json"
|
||||
arquivo.write_text(json.dumps({
|
||||
"source": "/v.mp4",
|
||||
"actions": [{"kind": "cut", "start": 1, "end": 2}],
|
||||
}), encoding="utf-8")
|
||||
|
||||
plano_id = registrar(arquivo, banco, tipo_de_video="depoimento")
|
||||
|
||||
conexao = abrir_banco(banco)
|
||||
linha = conexao.execute(
|
||||
"SELECT video_id, tipo_de_video FROM planos_de_edicao WHERE id = ?",
|
||||
(plano_id,),
|
||||
).fetchone()
|
||||
conexao.close()
|
||||
|
||||
self.assertEqual(dict(linha), {"video_id": "video-1", "tipo_de_video": "depoimento"})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Testes da persistência de projetos com uma sequência e várias seleções."""
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from engine.persistencia import ProjetoDeEdicao, RepositorioDeProjetos, abrir_banco
|
||||
|
||||
|
||||
class TesteRepositorioDeProjetos(unittest.TestCase):
|
||||
"""Garante a regra de uma sequência por projeto."""
|
||||
|
||||
def test_registra_um_projeto_com_varias_faixas_e_clipes(self):
|
||||
with tempfile.TemporaryDirectory() as pasta:
|
||||
conexao = abrir_banco(Path(pasta) / "analises.db")
|
||||
conexao.execute("INSERT INTO videos (id, nome) VALUES ('video-1', 'Sequência')")
|
||||
repositorio = RepositorioDeProjetos(conexao)
|
||||
projeto_id = repositorio.registrar(ProjetoDeEdicao(
|
||||
nome="Projeto depoimento", video_id="video-1",
|
||||
faixas=("video_0", "audio_0"), clipes=("clipe-v", "clipe-a"),
|
||||
))
|
||||
projeto = repositorio.carregar(projeto_id)
|
||||
conexao.close()
|
||||
|
||||
self.assertEqual(projeto["video_id"], "video-1")
|
||||
self.assertEqual(projeto["faixas"], ["video_0", "audio_0"])
|
||||
self.assertEqual(projeto["clipes"], ["clipe-v", "clipe-a"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Testes do catálogo independente de trilhas musicais."""
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from engine.integracoes.audio.modelos_de_analise_musical import (
|
||||
PontuacaoDeEtiqueta,
|
||||
ResultadoDaAnaliseMusical,
|
||||
)
|
||||
from engine.persistencia import RepositorioDeTrilhasSQLite
|
||||
|
||||
|
||||
class TesteRepositorioDeTrilhasSQLite(unittest.TestCase):
|
||||
"""Garante o ciclo de vida e a persistência da análise musical."""
|
||||
|
||||
def test_registra_pasta_atualiza_status_e_salva_etiquetas(self):
|
||||
with tempfile.TemporaryDirectory() as diretorio:
|
||||
pasta = Path(diretorio) / "trilhas"
|
||||
pasta.mkdir()
|
||||
arquivo = pasta / "tema.wav"
|
||||
arquivo.write_bytes(b"audio de teste")
|
||||
repositorio = RepositorioDeTrilhasSQLite(Path(diretorio) / "trilhas.db")
|
||||
|
||||
trilhas = repositorio.registrar_pasta_e_arquivos(pasta, (arquivo,))
|
||||
repositorio.iniciar_analise(trilhas[0].identificador)
|
||||
repositorio.registrar_analise(
|
||||
trilhas[0],
|
||||
ResultadoDaAnaliseMusical(
|
||||
caminho_do_arquivo=arquivo,
|
||||
duracao_em_segundos=12.5,
|
||||
generos=("Blues",),
|
||||
pontuacoes_de_genero=(PontuacaoDeEtiqueta("Blues", 0.91),),
|
||||
),
|
||||
)
|
||||
banco = repositorio.conexao
|
||||
trilha = banco.execute("SELECT status, hash_do_conteudo FROM trilhas_musicais").fetchone()
|
||||
etiqueta = banco.execute("SELECT tipo, etiqueta, confianca FROM etiquetas_musicais").fetchone()
|
||||
banco.close()
|
||||
|
||||
self.assertEqual(trilha[0], "concluida")
|
||||
self.assertEqual(len(trilha[1]), 64)
|
||||
self.assertEqual(tuple(etiqueta), ("genero", "Blues", 0.91))
|
||||
|
||||
def test_reprocessamento_da_mesma_pasta_nao_duplica_trilha(self):
|
||||
with tempfile.TemporaryDirectory() as diretorio:
|
||||
pasta = Path(diretorio) / "trilhas"
|
||||
pasta.mkdir()
|
||||
arquivo = pasta / "tema.mp3"
|
||||
arquivo.write_bytes(b"audio")
|
||||
repositorio = RepositorioDeTrilhasSQLite(Path(diretorio) / "trilhas.db")
|
||||
|
||||
primeira = repositorio.registrar_pasta_e_arquivos(pasta, (arquivo,))
|
||||
segunda = repositorio.registrar_pasta_e_arquivos(pasta, (arquivo,))
|
||||
quantidade = repositorio.conexao.execute("SELECT COUNT(*) FROM trilhas_musicais").fetchone()[0]
|
||||
repositorio.conexao.close()
|
||||
|
||||
self.assertEqual(primeira[0].identificador, segunda[0].identificador)
|
||||
self.assertEqual(quantidade, 1)
|
||||
|
||||
def test_arquivo_alterado_volta_para_aguardando(self):
|
||||
with tempfile.TemporaryDirectory() as diretorio:
|
||||
pasta = Path(diretorio) / "trilhas"
|
||||
pasta.mkdir()
|
||||
arquivo = pasta / "tema.wav"
|
||||
arquivo.write_bytes(b"audio")
|
||||
repositorio = RepositorioDeTrilhasSQLite(Path(diretorio) / "trilhas.db")
|
||||
trilha = repositorio.registrar_pasta_e_arquivos(pasta, (arquivo,))[0]
|
||||
repositorio.iniciar_analise(trilha.identificador)
|
||||
repositorio.registrar_analise(trilha, ResultadoDaAnaliseMusical(caminho_do_arquivo=arquivo))
|
||||
|
||||
arquivo.write_bytes(b"audio alterado")
|
||||
atualizada = repositorio.registrar_pasta_e_arquivos(pasta, (arquivo,))[0]
|
||||
repositorio.conexao.close()
|
||||
|
||||
self.assertEqual(atualizada.status, "aguardando")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,149 @@
|
||||
"""Testes de regressão da integridade semântica das emendas 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, frases incompletas e ganchos truncados."""
|
||||
|
||||
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})
|
||||
|
||||
def test_aceita_gancho_de_frase_completa_com_margens(self) -> None:
|
||||
falas = (
|
||||
SegmentoDeTranscricao(0.0, 4.0, "Antes.", palavras=(
|
||||
PalavraDeTranscricao("Antes.", 3.4, 4.0),
|
||||
)),
|
||||
SegmentoDeTranscricao(10.4, 13.5, "Gancho completo.", palavras=(
|
||||
PalavraDeTranscricao("Gancho", 10.4, 10.9),
|
||||
PalavraDeTranscricao("completo.", 13.0, 13.5),
|
||||
)),
|
||||
SegmentoDeTranscricao(20.0, 21.0, "Depois.", palavras=(
|
||||
PalavraDeTranscricao("Depois.", 20.0, 21.0),
|
||||
)),
|
||||
)
|
||||
plano = PlanoDeEdicao("video.mp4", (
|
||||
AcaoDeEdicao(TipoDeAcao.GANCHO, 10.1, 13.8, "gancho"),
|
||||
))
|
||||
|
||||
self.assertEqual(self.validador.validar(plano, falas), ())
|
||||
|
||||
def test_rejeita_gancho_que_termina_com_frase_truncada(self) -> None:
|
||||
falas = (
|
||||
SegmentoDeTranscricao(0.0, 4.0, "Antes.", palavras=(
|
||||
PalavraDeTranscricao("Antes.", 3.4, 4.0),
|
||||
)),
|
||||
SegmentoDeTranscricao(10.4, 13.5, "Trazer uma seguran", palavras=(
|
||||
PalavraDeTranscricao("Trazer", 10.4, 10.9),
|
||||
PalavraDeTranscricao("seguran", 13.0, 13.5),
|
||||
)),
|
||||
SegmentoDeTranscricao(20.0, 21.0, "Depois.", palavras=(
|
||||
PalavraDeTranscricao("Depois.", 20.0, 21.0),
|
||||
)),
|
||||
)
|
||||
plano = PlanoDeEdicao("video.mp4", (
|
||||
AcaoDeEdicao(TipoDeAcao.GANCHO, 10.1, 13.8, "gancho"),
|
||||
))
|
||||
|
||||
problemas = self.validador.validar(plano, falas)
|
||||
|
||||
self.assertIn("gancho_termina_no_meio_da_frase", {problema.codigo for problema in problemas})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Testes da leitura estrutural depois da aplicação do plano."""
|
||||
|
||||
import unittest
|
||||
|
||||
from engine.dominio import Clipe, Faixa, IntervaloDeTempo, Timeline
|
||||
from engine.editor.modelos import AcaoDeEdicao, PlanoDeEdicao, TipoDeAcao
|
||||
from engine.editor.verificador_da_aplicacao import VerificadorDaAplicacao
|
||||
|
||||
|
||||
class TesteVerificadorDaAplicacao(unittest.TestCase):
|
||||
"""Garante que sobras e lacunas na timeline sejam detectadas."""
|
||||
|
||||
def _timeline(self, fim_do_ultimo_clipe: float) -> Timeline:
|
||||
"""Monta uma timeline simples com vídeo e áudio vinculados por intervalo."""
|
||||
faixas = []
|
||||
for tipo in ("video", "audio"):
|
||||
faixas.append(Faixa(
|
||||
f"{tipo}_0", tipo, tipo, 0, [Clipe(
|
||||
f"clipe_{tipo}", "fonte.mp4",
|
||||
IntervaloDeTempo(0.0, fim_do_ultimo_clipe),
|
||||
IntervaloDeTempo(0.0, fim_do_ultimo_clipe),
|
||||
)]
|
||||
))
|
||||
return Timeline("sequencia", "Sequência", 100.0, faixas=faixas)
|
||||
|
||||
def test_aprova_cobertura_exata_depois_do_corte(self):
|
||||
"""Aprova quando os dois tipos de faixa preservam exatamente o esperado."""
|
||||
antes = self._timeline(100.0)
|
||||
depois = Timeline("sequencia", "Sequência", 90.0, faixas=[
|
||||
Faixa("video_0", "video", "video", 0, [
|
||||
Clipe("v1", "fonte.mp4", IntervaloDeTempo(0, 10), IntervaloDeTempo(0, 10)),
|
||||
Clipe("v2", "fonte.mp4", IntervaloDeTempo(10, 90), IntervaloDeTempo(20, 100)),
|
||||
]),
|
||||
Faixa("audio_0", "audio", "audio", 0, [
|
||||
Clipe("a1", "fonte.mp4", IntervaloDeTempo(0, 10), IntervaloDeTempo(0, 10)),
|
||||
Clipe("a2", "fonte.mp4", IntervaloDeTempo(10, 90), IntervaloDeTempo(20, 100)),
|
||||
]),
|
||||
])
|
||||
plano = PlanoDeEdicao("fonte.mp4", (
|
||||
AcaoDeEdicao(TipoDeAcao.CORTE, 10, 20, "remover trecho"),
|
||||
))
|
||||
|
||||
resultado = VerificadorDaAplicacao().verificar(antes, depois, plano)
|
||||
|
||||
self.assertTrue(resultado.aprovada)
|
||||
self.assertTrue(resultado.avisos)
|
||||
|
||||
def test_reprova_quando_sobra_um_trecho_na_timeline(self):
|
||||
"""Reprova quando o Premiere deixa uma sobra além do plano."""
|
||||
antes = self._timeline(100.0)
|
||||
depois = Timeline("sequencia", "Sequência", 91.0, faixas=[
|
||||
Faixa("video_0", "video", "video", 0, [
|
||||
Clipe("v1", "fonte.mp4", IntervaloDeTempo(0, 10), IntervaloDeTempo(0, 10)),
|
||||
Clipe("v2", "fonte.mp4", IntervaloDeTempo(10, 91), IntervaloDeTempo(20, 101)),
|
||||
]),
|
||||
Faixa("audio_0", "audio", "audio", 0, [
|
||||
Clipe("a1", "fonte.mp4", IntervaloDeTempo(0, 10), IntervaloDeTempo(0, 10)),
|
||||
Clipe("a2", "fonte.mp4", IntervaloDeTempo(10, 91), IntervaloDeTempo(20, 101)),
|
||||
]),
|
||||
])
|
||||
plano = PlanoDeEdicao("fonte.mp4", (
|
||||
AcaoDeEdicao(TipoDeAcao.CORTE, 10, 20, "remover trecho"),
|
||||
))
|
||||
|
||||
resultado = VerificadorDaAplicacao().verificar(antes, depois, plano)
|
||||
|
||||
self.assertFalse(resultado.aprovada)
|
||||
self.assertIn("esperado", resultado.mensagem)
|
||||
|
||||
|
||||
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
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
---
|
||||
name: editar-por-voz
|
||||
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
|
||||
|
||||
Transforma uma edição concluída no banco em um plano editorial revisável e, após aprovação, em uma alteração verificável no Premiere Pro.
|
||||
|
||||
## Fonte de verdade
|
||||
|
||||
O banco de análises é a fonte oficial. Não procure nem exija um arquivo
|
||||
`*_voice_timeline.json` e não trate JSON temporário, cache ou estado do painel
|
||||
como fonte independente.
|
||||
|
||||
Para carregar o contexto sem escrever SQL, execute:
|
||||
|
||||
```text
|
||||
python3 code/engine/preparar_edicao_por_voz.py --banco .jhonny/analises.db
|
||||
```
|
||||
|
||||
Depois de gerar o JSON de ações, registre-o no banco e use o identificador
|
||||
retornado para aplicar exatamente esse plano:
|
||||
|
||||
```bash
|
||||
python3 code/engine/registrar_plano_de_edicao.py plano.json \
|
||||
--banco .jhonny/analises.db --tipo-de-video depoimento
|
||||
python3 code/engine/aplicar_plano_de_edicao.py plano.json \
|
||||
--banco .jhonny/analises.db --plano-id ID_RETORNADO
|
||||
```
|
||||
|
||||
Use `--video-id` quando o usuário indicar um vídeo específico e
|
||||
`--sem-palavras` somente quando os limites palavra a palavra não forem
|
||||
necessários. A resposta válida vem em `contexto`; trate `ok: false` como
|
||||
bloqueio do fluxo.
|
||||
|
||||
Use a edição concluída mais recente em `edicoes_de_video` como raiz do trabalho.
|
||||
Recupere o restante pelo mesmo `video_id`:
|
||||
|
||||
- `videos`, `faixas` e `clipes`: mídia, sequência e estrutura;
|
||||
- `analises_versao`: versão da análise usada;
|
||||
- `segmentos_de_transcricao` e `palavras_de_transcricao`: falas e tempos;
|
||||
- `evidencias_visuais`: contexto visual, quando disponível;
|
||||
- `planos_de_edicao` e `acoes_do_plano`: decisões já geradas;
|
||||
- `aplicacoes_do_plano`: histórico de execução.
|
||||
|
||||
Leia [criterios/00-fonte-de-dados.md](criterios/00-fonte-de-dados.md) antes de
|
||||
consultar o banco ou quando houver dúvida sobre qual registro usar.
|
||||
|
||||
## Estados obrigatórios
|
||||
|
||||
Trate o trabalho como uma sequência de estados:
|
||||
|
||||
```text
|
||||
edicao_concluida
|
||||
→ contexto_validado
|
||||
→ plano_gerado
|
||||
→ preview_aprovado
|
||||
→ aplicado
|
||||
→ verificado
|
||||
```
|
||||
|
||||
Não aplique um plano sem contexto validado e preview aprovado. Se algum estado
|
||||
ou evidência obrigatória estiver ausente, pare e relate exatamente o que falta.
|
||||
|
||||
## Fluxo
|
||||
|
||||
1. Localize a edição concluída mais recente e confirme vídeo, sequência, tipo,
|
||||
origem, transcrição e versão da análise.
|
||||
2. Reúna do banco a transcrição completa, palavras, falantes, tomadas,
|
||||
métricas e evidências disponíveis. Preserve os tempos da mídia original.
|
||||
3. Leia a configuração editorial salva em `edicoes_de_video.configuracao` e
|
||||
extraia objetivo, narrativa, duração, tom, regras de corte e restrições.
|
||||
4. Separe roteiro de conversa de bastidor pelo conteúdo. Depois agrupe frases
|
||||
repetidas e escolha a tomada completa, clara, natural e coerente.
|
||||
5. Reanalise as ênfases apenas depois de definir o material sobrevivente. Se o
|
||||
banco não tiver métricas ou a camada correspondente estiver incompleta,
|
||||
decida pelo texto e registre a limitação.
|
||||
6. Decida cortes, ganchos, zooms, textos e marcadores somente quando o contrato de
|
||||
aplicação suportar a ação. Cada ação deve ter motivo verificável.
|
||||
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.
|
||||
9. Faça preview do texto sobrevivente, lendo em sequência a última frase antes
|
||||
e a primeira frase depois de cada corte. Para `hook`, apresente a frase
|
||||
completa que abrirá o vídeo e a primeira frase original que virá em seguida.
|
||||
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`.
|
||||
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.
|
||||
|
||||
Essa releitura pós-aplicação é obrigatória: uma aplicação só pode ser
|
||||
reportada como bem-sucedida quando a cobertura de origem em vídeo e áudio
|
||||
corresponder ao complemento dos cortes do plano. Sobras, lacunas ou
|
||||
desalinhamentos devem reprovar a verificação e ficar registrados no histórico.
|
||||
|
||||
## Contrato da decisão
|
||||
|
||||
O JSON abaixo é uma representação de trabalho, não a fonte principal:
|
||||
|
||||
```json
|
||||
{
|
||||
"source": "0E6A8829.MP4",
|
||||
"actions": [
|
||||
{
|
||||
"kind": "cut",
|
||||
"start": 12.4,
|
||||
"end": 16.8,
|
||||
"reason": "Repetição da frase anterior."
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`cut` remove um intervalo. `hook` duplica um único trecho vinculado em `0s` e
|
||||
mantém a fala na posição original; use somente
|
||||
`params: {"target": 0, "keep_original": true}`. Todos os tempos referem-se à
|
||||
mídia original. A ação precisa ter `start < end`, estar dentro da duração e
|
||||
não sobrepor outra ação incompatível. Use `reason` para registrar a fala,
|
||||
comparação ou evidência que fundamentou a decisão.
|
||||
|
||||
Leia, conforme a etapa, [criterios/02-triagem-roteiro-vs-conversa.md](criterios/02-triagem-roteiro-vs-conversa.md),
|
||||
[criterios/03-escolha-da-melhor-tomada.md](criterios/03-escolha-da-melhor-tomada.md),
|
||||
[criterios/04-reanalise-do-material-restante.md](criterios/04-reanalise-do-material-restante.md),
|
||||
[criterios/05-zoom.md](criterios/05-zoom.md),
|
||||
[criterios/06-texto-corte-marcador.md](criterios/06-texto-corte-marcador.md),
|
||||
[criterios/07-ritmo.md](criterios/07-ritmo.md) e
|
||||
[criterios/10-revisao-humana.md](criterios/10-revisao-humana.md). Quando o
|
||||
pedido envolver gancho ou reordenação, leia também
|
||||
[criterios/11-gancho.md](criterios/11-gancho.md).
|
||||
|
||||
## Limites
|
||||
|
||||
- A skill decide a edição; o Premiere executa.
|
||||
- Nunca invente falas, identidades, timecodes ou evidências.
|
||||
- Não altere o áudio original, a transcrição ou a análise para forçar uma
|
||||
decisão.
|
||||
- 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.
|
||||
|
||||
## Relato
|
||||
|
||||
Relate sempre em português:
|
||||
|
||||
- edição, vídeo e versão da análise usados;
|
||||
- tomadas encontradas e escolhidas, com motivo;
|
||||
- falas descartadas como bastidor ou repetição;
|
||||
- cortes, ganchos, zooms, textos e marcadores propostos;
|
||||
- decisões rejeitadas ou ambíguas;
|
||||
- plano salvo, aplicação realizada e verificação pendente.
|
||||
@@ -0,0 +1,35 @@
|
||||
# 00 — Fonte de dados: banco de análises
|
||||
|
||||
O banco SQLite do projeto é a fonte oficial da edição por voz. Arquivos JSON
|
||||
podem existir como origem, cache ou exportação, mas não substituem os registros
|
||||
persistidos.
|
||||
|
||||
## Raiz do trabalho
|
||||
|
||||
1. Localize a edição mais recente em `edicoes_de_video`.
|
||||
2. Use seu `video_id` para consultar a análise, a transcrição, os clipes e as
|
||||
evidências.
|
||||
3. Use `edicoes_de_video.configuracao` como briefing editorial salvo.
|
||||
4. Considere a edição duplicada ou antiga somente se o usuário escolher uma
|
||||
edição diferente.
|
||||
|
||||
## Validações mínimas
|
||||
|
||||
Antes de decidir cortes, confirme que existem:
|
||||
|
||||
- vídeo e sequência identificáveis;
|
||||
- pelo menos um clipe com mídia e duração;
|
||||
- segmentos de transcrição com início, fim e texto;
|
||||
- configuração editorial com tipo e objetivo;
|
||||
- versão ou data que permita identificar a análise usada.
|
||||
|
||||
Falantes, palavras, métricas e evidências visuais são camadas adicionais. Se
|
||||
uma camada não existir, reduza a confiança da decisão e registre a limitação;
|
||||
não fabrique valores.
|
||||
|
||||
## Persistência do plano
|
||||
|
||||
O plano deve ser criado em `planos_de_edicao`, e cada ação em
|
||||
`acoes_do_plano`. A aplicação deve ser registrada em `aplicacoes_do_plano`.
|
||||
O JSON pode ser usado durante a revisão, mas o banco deve conservar a decisão
|
||||
que será executada.
|
||||
@@ -0,0 +1,85 @@
|
||||
# 01 — Leitura das evidências do banco
|
||||
|
||||
> **Escopo:** Como ler as evidências persistidas no banco, sem recalcular o que já foi medido.
|
||||
> **Quando:** Fase 1 — ver a ordem de trabalho em `../SKILL.md`.
|
||||
|
||||
O registro da edição e as tabelas relacionadas no banco são a entrada de todo o
|
||||
trabalho. Um JSON exportado pode ser usado como visão de trabalho, mas deve ser
|
||||
reconciliado com o `video_id` e a versão da análise antes de qualquer decisão.
|
||||
Leia em camadas, de cima para baixo, e só desça quando precisar.
|
||||
|
||||
## Camadas
|
||||
|
||||
| Camada | O que traz | Para quê |
|
||||
|---|---|---|
|
||||
| `analises_versao` | o que de fato rodou e qual versão está válida | **leia primeiro** — ver `09-analise-incompleta.md` |
|
||||
| `summary` | forma da peça, `peak_moments`, contagens | visão geral em poucos números |
|
||||
| `segmentos_de_transcricao` | cada fala com seus agregados | **onde você mais trabalha** |
|
||||
| `palavras_de_transcricao` | detalhe por palavra | achar o instante exato de um destaque |
|
||||
| `evidencias_visuais` | observações visuais por intervalo | apoiar a decisão quando disponível |
|
||||
| `edicoes_de_video.configuracao` | objetivo e regras do vídeo | calibrar a seleção editorial |
|
||||
|
||||
## Campos que decidem quase tudo
|
||||
|
||||
**`gap_before`** — silêncio antes da fala, em segundos. É o mapa estrutural
|
||||
da gravação: acima de ~3s (`take_boundary: true`) a câmera parou ou a
|
||||
tomada recomeçou. Num material real de 3min17s isso identificou 6
|
||||
fronteiras, todas exatamente onde a pessoa recomeçava o roteiro.
|
||||
|
||||
**`take_boundary`** — booleano derivado do `gap_before`. Use para agrupar
|
||||
tomadas.
|
||||
|
||||
**`emphasis`** (0–1) — índice combinado de energia, variação de tom,
|
||||
variação de ritmo, pausa anterior e duração. **É relativo ao material
|
||||
analisado**, nunca uma medida absoluta. Ver `02-enfase-e-reanalise.md`.
|
||||
|
||||
**`energy`** (0–1) — intensidade relativa ao trecho mais alto da gravação.
|
||||
|
||||
**`pitch_delta`** (0–1) — quanto o tom se afasta da média do falante.
|
||||
|
||||
**`peak_emphasis`** e **`avg_energy`** (por segmento) — permitem julgar uma
|
||||
frase inteira sem ler palavra por palavra. É por aqui que você avalia o
|
||||
arco narrativo.
|
||||
|
||||
**`energy_raw`** e **`pitch_hz`** — valores brutos, sem normalização. Não
|
||||
use para decidir; existem para permitir a reanálise da Fase 2.
|
||||
|
||||
**`emocao`** e **`confianca_emocao`** — classificação de emoção da fala
|
||||
(quando disponível). São **opcionais e complementares**: dependem de um
|
||||
recurso que precisa estar ligado na análise, então ficam vazios em boa
|
||||
parte dos vídeos. Nunca decidem sozinhos — servem só de reforço/desempate
|
||||
entre candidatos já elegíveis por `emphasis`/relevância narrativa. Ver
|
||||
`05-zoom.md`.
|
||||
|
||||
## O que NÃO fazer
|
||||
|
||||
- **Não recalcule** energia, tom ou ênfase. O sistema mede melhor e de
|
||||
forma reprodutível.
|
||||
- **Não reestime tempos "no olho".** Use os timestamps do JSON.
|
||||
- **Não trate `emphasis` como valor absoluto.** Um 0,35 pode ser o pico de
|
||||
uma gravação e ruído em outra.
|
||||
|
||||
## O timestamp por palavra tem um viés conhecido
|
||||
|
||||
O início de cada palavra vem sistematicamente **adiantado em ~0,3-0,5s** em
|
||||
relação ao ataque real da fala — medido em material real com ffmpeg (`astats`),
|
||||
consistente em 6 pontos do mesmo vídeo. O fim da palavra não tem esse problema
|
||||
(erro de poucos centésimos). Causa: `word_timestamps` do faster-whisper deriva
|
||||
por atenção cruzada, sem alinhamento forçado — ver `05_EXPERIENCIAS.md`, entrada
|
||||
de 2026-08-19.
|
||||
|
||||
**Quando o pipeline já corrigiu isso:** se `layers.alignment` for `true`
|
||||
(transcript gerado com alinhamento forçado fonético via whisperx, implementado
|
||||
depois desse aviso), o viés foi removido na origem — **não aplique o offset
|
||||
manual** abaixo. O aviso vale só para transcripts antigos sem `layers.alignment`.
|
||||
|
||||
Isso não é "reestimar no olho" — é um bug de medição na fonte, não um
|
||||
julgamento seu. Na prática (somente sem `layers.alignment`):
|
||||
|
||||
- Ao posicionar um `zoom` cujo `start` precisa cair exatamente na palavra
|
||||
(não uma frase inteira), some **+0,3 a +0,4s** ao timestamp do JSON antes
|
||||
de decidir, ou confira com `ffmpeg -af astats` se a precisão importar
|
||||
para o frame.
|
||||
- **Não aplique essa correção a `gap_before` para decidir corte** — a régua
|
||||
de silêncio (`06-texto-corte-marcador.md`) já é conservadora o bastante
|
||||
para absorver esse erro; corrigir os dois ao mesmo tempo é redundante.
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
# 02 — Triagem: roteiro vs. conversa de bastidor
|
||||
|
||||
> **Escopo:** Separar o texto do roteiro da conversa de bastidor — tarefa de texto, nunca de limiar.
|
||||
> **Quando:** Fase 2 — ver a ordem de trabalho em `../SKILL.md`.
|
||||
|
||||
**Primeira coisa a fazer, antes de qualquer decisão de efeito.**
|
||||
|
||||
Material bruto de gravação quase nunca é uma tomada só. A pessoa lê o
|
||||
roteiro, erra, conversa com a equipe e recomeça.
|
||||
|
||||
## Por que isso é tarefa sua, e não do sistema
|
||||
|
||||
No áudio essa separação é **invisível** — e pior: o índice de ênfase
|
||||
*favorece* a conversa, que é mais solta e mais alta que o texto decorado.
|
||||
|
||||
Caso real: a fala mais enfática de um vídeo inteiro (energia **1,00**, o
|
||||
topo absoluto da gravação) era *"Amor, eu tô intacto!"*, dita para o marido
|
||||
fora de quadro. Três das sete palavras de maior ênfase do vídeo vinham
|
||||
dessa única frase de bastidor.
|
||||
|
||||
Nenhum limiar acústico separa isso. O **texto** separa sem erro.
|
||||
|
||||
> Atenção: isso também **não é diarização**. Num caso real, a pessoa da
|
||||
> equipe estava fora do microfone — a diarização a ouvia, mas o Whisper não
|
||||
> a transcrevia. As falas a descartar eram da **própria protagonista**:
|
||||
> mesma voz, contexto diferente. "Quem fala" e "isso é tomada válida" são
|
||||
> perguntas diferentes.
|
||||
|
||||
## Descartar — conversa com a equipe
|
||||
|
||||
Reconhece-se pelo **conteúdo**:
|
||||
|
||||
- **vocativo para alguém da sala** — *"Amor, eu tô intacto!"*
|
||||
- **pergunta operacional** — *"Posso começar da mastopexia?"*,
|
||||
*"E aí, continua?"*, *"Mas eu vou ter que falar tudo de novo?"*
|
||||
- **instrução técnica** — *"Só clica aí agora na tela."*, *"Aumenta."*
|
||||
- **comentário sobre a própria gravação** — *"Vou falar só a última frase,
|
||||
só um pouquinho, não pegou?"*
|
||||
|
||||
## Descartar — frases interrompidas
|
||||
|
||||
Texto que morre no meio, tipicamente em reticências ou emendando numa
|
||||
pergunta:
|
||||
|
||||
- *"E tudo isso associado à medida..."*
|
||||
- *"Aquela mama com um formato mais estruturado, com o colo que..."*
|
||||
- *"de pele..."*
|
||||
|
||||
## Sinais estruturais que ajudam
|
||||
|
||||
Use `take_boundary` para achar onde cada tomada recomeça. Num material
|
||||
real, as fronteiras (gaps de 3,6s a 19,8s) caíam exatamente nos pontos onde
|
||||
a médica reiniciava o roteiro — inclusive nas duas retomadas da frase de
|
||||
abertura.
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
# 03 — Escolha da melhor tomada
|
||||
|
||||
> **Escopo:** Qual tomada de cada frase sobrevive, e o que fazer em caso de empate.
|
||||
> **Quando:** Fase 3 — ver a ordem de trabalho em `../SKILL.md`.
|
||||
|
||||
A mesma frase costuma aparecer 2, 3, 4 vezes. Seu trabalho é ficar com
|
||||
**uma**.
|
||||
|
||||
## Como agrupar
|
||||
|
||||
1. Use `take_boundary` para localizar onde cada tomada recomeça.
|
||||
2. Agrupe as repetições **pelo texto**, não pelo tempo — a mesma frase
|
||||
reaparece em pontos distantes da gravação. Num caso real, a abertura
|
||||
*"Aquela mama com um formato mais estruturado"* apareceu aos 2,0s, 64,9s
|
||||
e 86,4s.
|
||||
|
||||
## Critérios, nesta ordem
|
||||
|
||||
### 1. Completa
|
||||
Não morre no meio, não emenda numa pergunta. Uma tomada incompleta está
|
||||
descartada por definição, mesmo que a dicção seja ótima.
|
||||
|
||||
### 2. Dicção limpa
|
||||
Sem tropeço, sem repetição de palavra, sem vício de linguagem. **Compare os
|
||||
textos lado a lado:**
|
||||
|
||||
| Tomada 1 | Tomada 3 | Escolha |
|
||||
|---|---|---|
|
||||
| *"isso é desejo de muitas mulheres"* | *"**aí** isso é desejo de muitas mulheres"* | Tomada 1 |
|
||||
|
||||
### 3. Formulação melhor
|
||||
Quando as duas estão limpas, prefira a mais direta — normalmente a última,
|
||||
porque é onde a pessoa já se ajustou:
|
||||
|
||||
| Antes | Depois | Escolha |
|
||||
|---|---|---|
|
||||
| *"a gente **faz a inserção de** próteses"* | *"a gente **insere** próteses"* | a segunda |
|
||||
| *"reestrutura a mama"* | *"reestrutura a **sua** mama"* | a segunda |
|
||||
|
||||
### 4. Entrega
|
||||
**Só então** desempate por `avg_energy` / `peak_emphasis`.
|
||||
|
||||
Quando duas tomadas têm texto **idêntico palavra por palavra**, aí a
|
||||
energia decide sozinha — é o único sinal disponível. Caso real: o fecho
|
||||
tinha duas tomadas iguais, energia **0,38** e **0,19**. A de 0,38 é a boa,
|
||||
e o texto sozinho jamais diria isso.
|
||||
|
||||
## Regra de ouro
|
||||
|
||||
A **última** tomada costuma ser a melhor — é onde a pessoa acertou. Mas
|
||||
**confirme lendo o texto**; nunca assuma.
|
||||
|
||||
## Quando estiver em dúvida
|
||||
|
||||
Não decida no escuro. Coloque um `marker` nas duas candidatas, explique a
|
||||
dúvida no `reason`, e deixe a escolha para o editor humano.
|
||||
|
||||
## Continuidade
|
||||
|
||||
Ao montar o corte final você pode misturar blocos de tomadas diferentes —
|
||||
abertura da tomada 1, corpo da tomada 3. Isso é normal. Mas **avise nas
|
||||
emendas**: coloque um `marker` em cada junção para o editor conferir se o
|
||||
enquadramento e a posição da pessoa combinam.
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
# 04 — Reanálise do material que sobrou
|
||||
|
||||
> **Escopo:** Renormalizar a ênfase sobre o que sobrou, antes de escolher zooms.
|
||||
> **Quando:** Fase 4 — ver a ordem de trabalho em `../SKILL.md`.
|
||||
|
||||
**Não escolha zooms com os números da análise bruta.**
|
||||
|
||||
## O problema
|
||||
|
||||
Ênfase e energia são **relativas ao conjunto analisado**. Energia é
|
||||
normalizada contra o momento mais alto da gravação; ênfase deriva dela.
|
||||
|
||||
Se esse momento mais alto foi cortado — uma piada, um grito, uma conversa
|
||||
de bastidor — tudo que sobrou continua pontuado contra uma referência que o
|
||||
espectador **nunca verá**. As notas do corte final ficam artificialmente
|
||||
comprimidas, e o ranking aponta para as palavras erradas.
|
||||
|
||||
Caso real: o pico do vídeo era *"Amor, eu tô intacto!"* (energia 1,00),
|
||||
descartado na triagem. Todo o material restante estava sendo medido contra
|
||||
ele.
|
||||
|
||||
## A solução
|
||||
|
||||
Depois de definir os cortes, renormalize sobre os sobreviventes. Solicite uma
|
||||
reanálise dos dados persistidos, passando a mídia e a lista de cortes que você
|
||||
já decidiu. Se essa operação não estiver disponível, recalcule somente a
|
||||
normalização necessária por um adaptador controlado e registre a nova versão
|
||||
em `analises_versao`:
|
||||
|
||||
```
|
||||
refinar_linha_de_voz(media_path, cortes=[{start, end}, ...], min_gap=8.0)
|
||||
```
|
||||
|
||||
Ela devolve, numa chamada só, a comparação bruto × sobreviventes, os picos
|
||||
re-ranqueados e os candidatos a zoom. É barata: renormaliza os números já
|
||||
medidos, sem reabrir o áudio.
|
||||
|
||||
Efeito medido no mesmo material:
|
||||
|
||||
| | Bruto | Só o que sobrou |
|
||||
|---|---|---|
|
||||
| Ênfase média | 0,179 | **0,197** |
|
||||
| *"Aquela"* | 0,39 | **0,42** |
|
||||
| *"mastopexia"* | 0,26 | **0,34** |
|
||||
| *"devolver"* | — | **0,35** |
|
||||
|
||||
*"mastopexia"* só virou candidata legítima depois da reanálise.
|
||||
|
||||
## As janelas que ela propõe
|
||||
|
||||
A seção **Zoom Candidates** da resposta já vem com três coisas resolvidas:
|
||||
|
||||
1. **Pega a palavra de conteúdo mais enfática de cada frase.** Artigos e
|
||||
conectivos são filtrados — um *"a"* falado alto continua sendo um artigo.
|
||||
Sem esse filtro, o ranking bruto apontava para "o", "a", "eu": picos de
|
||||
*entrega*, não de *sentido*.
|
||||
2. **Estende a janela até o fim da frase**, não do segmento (ver
|
||||
`05-zoom.md`).
|
||||
3. **Mantém distância mínima** entre zooms.
|
||||
|
||||
São **candidatos, não obrigações.** Corte a lista pelo ritmo
|
||||
(`07-ritmo.md`). Os tempos continuam na mídia original — vão para as ações
|
||||
persistidas no plano e, depois da aprovação, para o adaptador de aplicação do
|
||||
Premiere.
|
||||
|
||||
`max_zooms` limita a lista, mas prefira cortá-la você mesmo: o corte por
|
||||
ritmo é decisão editorial, não um teto numérico.
|
||||
|
||||
## Princípio geral
|
||||
|
||||
> "Qual o momento mais forte da **gravação**?" e "qual o momento mais forte
|
||||
> do **vídeo final**?" são perguntas diferentes sempre que a métrica for
|
||||
> relativa.
|
||||
|
||||
Toda métrica normalizada precisa ser recalculada quando o conjunto muda —
|
||||
senão ela responde a pergunta errada, silenciosamente.
|
||||
@@ -0,0 +1,89 @@
|
||||
# 05 — Zoom (punch-in)
|
||||
|
||||
> **Escopo:** Onde dar punch-in, qual janela e qual escala — e o que a escala significa além do zoom.
|
||||
> **Quando:** Fase 5 — ver a ordem de trabalho em `../SKILL.md`.
|
||||
|
||||
## Quando usar
|
||||
|
||||
No momento em que o argumento vira. Um pico acústico só merece zoom se for
|
||||
também um pico **de sentido**.
|
||||
|
||||
Palavra gritada sem peso narrativo não ganha nada — e isso inclui os picos
|
||||
que caem em artigos e conectivos, que são picos de entrega, não de conteúdo.
|
||||
|
||||
### Emoção só desempata, nunca decide sozinha
|
||||
|
||||
Quando `emocao`/`confianca_emocao` estiverem disponíveis (ver
|
||||
`01-leitura-do-json.md`) e dois candidatos estiverem próximos em
|
||||
`emphasis`/relevância narrativa, use a emoção com `confianca_emocao` acima
|
||||
de ~0,6 como critério de desempate a favor do trecho com emoção mais forte
|
||||
e coerente com o conteúdo. Isso nunca eleva um trecho sem peso narrativo a
|
||||
candidato de zoom — só decide entre candidatos que já seriam elegíveis de
|
||||
qualquer forma. Quando esses campos vierem vazios (caso mais comum hoje),
|
||||
decida como sempre: `emphasis` + relevância narrativa.
|
||||
|
||||
## A janela
|
||||
|
||||
**`start`** — na palavra de ênfase.
|
||||
|
||||
**`end`** — no **fim da frase**. A frase inteira, não o fim do segmento da
|
||||
transcrição.
|
||||
|
||||
O Whisper corta frases no meio, por respiração e não por gramática:
|
||||
|
||||
> *"Aquela mama com um formato mais estruturado, que valoriza o seu colo,
|
||||
> que dá aquele ar"* **|** *"de elegância, isso é desejo de muitas mulheres,
|
||||
> né?"*
|
||||
|
||||
Soltar o zoom no fim do primeiro segmento libera **no meio do pensamento** —
|
||||
é o que faz um punch-in parecer arbitrário. `suggest_zoom_windows` já
|
||||
estende até a pontuação final (`.` `!` `?` `…`), e nunca atravessa uma
|
||||
fronteira de tomada.
|
||||
|
||||
## A forma — o programa decide sozinho
|
||||
|
||||
Você escolhe `start` e `end`; a forma sai da posição da janela dentro do
|
||||
trecho:
|
||||
|
||||
| Situação | Comportamento | Por quê |
|
||||
|---|---|---|
|
||||
| Começa a **>0,5s** do início do trecho | entrada rápida (~0,25s) | o movimento chega junto com a palavra |
|
||||
| Começa a **≤0,5s** do início | **entra já ampliado, sem transição** | o corte já foi a transição; uma rampa ali lê como a imagem se acomodando |
|
||||
| Frase termina no meio do trecho | **saída seca**, 1 frame | volta ao enquadramento sem chamar atenção |
|
||||
| Frase termina a **≤1s** do corte | **não volta** — segura até o corte | o próximo trecho já abre no enquadramento dele; voltar antes é movimento desperdiçado |
|
||||
|
||||
Os limiares são diferentes de propósito: no fim o corte esconde um retorno
|
||||
inacabado, mas no início a rampa é visível desde o primeiro frame.
|
||||
|
||||
Para forçar manualmente, existem `start_at_peak` e `hold_at_end` — mas o
|
||||
automático acerta na quase totalidade dos casos.
|
||||
|
||||
## Escala
|
||||
|
||||
| Valor | Uso |
|
||||
|---|---|
|
||||
| 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.
|
||||
|
||||
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
|
||||
para 2,09 e preserva rotação e posição.
|
||||
|
||||
## Dois zooms no mesmo clipe
|
||||
|
||||
Depois do corte, dois picos que você escolheu podem cair no **mesmo**
|
||||
trecho sobrevivente (nenhum corte os separou em clipes distintos) — é
|
||||
comum quando a corrida limpa de uma tomada é longa. O sistema resolve isso
|
||||
sozinho, e a regra é a mesma que rege o resto: janelas **distantes**
|
||||
empilham (os dois zooms convivem, cada um voltando ao enquadramento real
|
||||
entre um e outro); janelas que **se sobrepõem** substituem (é o mesmo
|
||||
evento sendo reajustado, não dois). Você não precisa calcular isso na
|
||||
hora de decidir — só respeitar o `min_gap` de `07-ritmo.md`, que já
|
||||
garante que dois zooms escolhidos por você nunca se sobrepõem.
|
||||
@@ -0,0 +1,155 @@
|
||||
# 06 — Texto, corte e marcador
|
||||
|
||||
> **Escopo:** Texto na tela, o que cortar (inclui muletas e lacunas) e quando marcar.
|
||||
> **Quando:** Fase 6 — ver a ordem de trabalho em `../SKILL.md`.
|
||||
|
||||
## Texto
|
||||
|
||||
Para fixar um **conceito, número ou nome** que o espectador precisa reter.
|
||||
|
||||
- Use a palavra **dita**, não uma paráfrase.
|
||||
- Curta, em caixa alta. Até 120 caracteres (é truncado além disso).
|
||||
- Uma por frase, no máximo.
|
||||
|
||||
**Não legende a frase inteira.** Para isso existe
|
||||
`generate_dynamic_subtitles`, que é outra ferramenta e outro propósito.
|
||||
|
||||
Boas candidatas são as palavras-chave que sobram depois de filtrar as
|
||||
funcionais — num caso real: *mastopexia*, *flacidez*, *próteses*,
|
||||
*devolver*, *desejo*.
|
||||
|
||||
## Corte
|
||||
|
||||
Digressão, repetição, frase abandonada, conversa de bastidor, tomada pior —
|
||||
e mais duas coisas que **são** seu trabalho, ao contrário do que parece.
|
||||
|
||||
### Vícios de linguagem entram na sua lista
|
||||
|
||||
Não delegue para `remove_filler_words`. Você já está percorrendo palavra por
|
||||
palavra na triagem; marcar as muletas é uma linha a mais, sem custo. E você
|
||||
tem o que a lista fixa não tem: **contexto**.
|
||||
|
||||
Um *"tipo"* em *"tipo assim, sabe"* é muleta. Em *"esse tipo de cirurgia"*
|
||||
é a palavra principal. Um *"não não não"* pode ser gagueira ou ênfase. A
|
||||
lista fixa não distingue; você distingue.
|
||||
|
||||
### Lacunas longas entram na sua lista — curtas, nunca
|
||||
|
||||
**O tamanho da lacuna muda o que ela é.** A régua está medida em material
|
||||
real (`pause_weight()` em `emphasis.py`, `TAKE_BOUNDARY_GAP` em
|
||||
nas regras de análise de voz do projeto):
|
||||
|
||||
| `pause_before` | O que é | O que fazer |
|
||||
|---|---|---|
|
||||
| até ~1,5s | o falante montando a frase — **isso É a ênfase** | **nunca cortar** |
|
||||
| 1,5–3s | zona cinza | julgue pela frase |
|
||||
| acima de 3s | troca de tomada, ar morto, outra pessoa falando | **cortar** |
|
||||
|
||||
Cortar a pausa curta é o erro grave: ela é uma das cinco entradas do índice
|
||||
de ênfase, então você estaria apagando justamente a batida que faz a palavra
|
||||
seguinte pontuar alto. Uma frase fluida não se aperta.
|
||||
|
||||
Acima de 3s a pausa deixa de contar como ênfase por construção — medido em
|
||||
material real, lacunas de 6–9s rankeavam como os momentos mais enfáticos da
|
||||
gravação só porque a escala saturava.
|
||||
|
||||
### Nunca corte rente à palavra — deixe uma folga
|
||||
|
||||
Um `cut` cujo `start`/`end` cai exatamente no timestamp da palavra (fim da
|
||||
última palavra mantida = início do corte) produz um corte seco: a palavra é
|
||||
engolida antes de terminar de soar, e a fala seguinte começa sem nenhum ar.
|
||||
Isso é diferente de cortar a pausa curta (que seria apagar a própria ênfase,
|
||||
proibido acima) — aqui a pausa **já existe** entre o fim de um bloco mantido
|
||||
e o início do próximo, e o corte está comendo justamente essa margem.
|
||||
|
||||
Ao escrever a borda de um `cut` que encosta em fala mantida (não em silêncio
|
||||
puro), recue **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,30s **depois** do fim real da última palavra
|
||||
mantida;
|
||||
- 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`,
|
||||
exatamente nos timestamps de palavra) soava abrupto nas duas emendas.
|
||||
Recuado para `10.97 → 95.30`, cada lado ganhou ~0,2s de respiro sem alterar
|
||||
o que é dito — e não empurra o próximo zoom/marcador contra a borda do corte
|
||||
(ver `05-zoom.md` sobre janelas encostadas em corte).
|
||||
|
||||
Isso vale também para o **início e o fim do vídeo**: ar morto antes da
|
||||
primeira palavra e depois da última também leva `cut`, com a mesma folga —
|
||||
não é "silêncio dentro da fala" (isso é `remove_media_silence`), é o mesmo
|
||||
corte de tomada/bastidor que você já está decidindo.
|
||||
|
||||
### 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ê |
|
||||
|---|---|---|
|
||||
| Apertar o ar **entre** palavras (sem fala, com ou sem som) | `remove_speech_gaps` | Lê `words[].start/end` da transcrição — sabe onde não tem fala mesmo quando tem som (respiração, ruído) |
|
||||
| Apertar o ar **dentro** da fala | `remove_media_silence` | Lê o áudio real com ffmpeg; só cobre silêncio técnico (dB), que a transcrição não enxerga |
|
||||
|
||||
E cuidado: **ausência de fala não é ausência de som**, e nem sempre é
|
||||
descartável. Respiração, riso, suspiro, a reação depois da frase — nada
|
||||
disso vira palavra, então aparece como lacuna, e às vezes é o melhor frame
|
||||
do vídeo. `remove_speech_gaps` corta **toda** lacuna acima do `min_gap`
|
||||
(0,6s por padrão) sem julgar o que tem nela — é automação de "sem fala",
|
||||
não de "sem conteúdo que vale manter". Se uma reação específica precisa
|
||||
sobreviver, marque-a como `cut` de duração zero antes (para virar um limite
|
||||
de segmento) ou rode com `min_gap` maior nesse trecho; não é a ferramenta
|
||||
que decide o que é bom frame.
|
||||
|
||||
Tanto `remove_speech_gaps` quanto `remove_media_silence` rodam **depois** da
|
||||
aplicação do plano, como acabamento sobre o material que sobrou —
|
||||
`remove_speech_gaps` primeiro (cobre mais, é o corte "grosso" por fala),
|
||||
`remove_media_silence` depois (aperta o que ainda restar dentro da fala).
|
||||
|
||||
**Antes de rodar `remove_media_silence` sobre o corte final, sempre rode a
|
||||
detecção primeiro** (sem aplicar) e leia os spans um a um contra a régua
|
||||
acima. O detector corta por limiar de dB — ele não sabe distinguir "batida
|
||||
de 0,8s entre duas frases", que a régua protege, de "ar morto de emenda",
|
||||
que deveria ser apertado. Aplicar direto, sem essa checagem, é o mesmo erro
|
||||
de cortar pausa curta, só que por outra ferramenta.
|
||||
|
||||
## Marcador
|
||||
|
||||
Quando você quer **sinalizar para o editor humano decidir**, em vez de
|
||||
decidir por ele.
|
||||
|
||||
Use em:
|
||||
|
||||
- **Emendas entre tomadas** — sempre. O editor precisa conferir se o
|
||||
enquadramento e a posição da pessoa combinam na junção.
|
||||
- **Dúvida entre duas tomadas** — marque as duas, explique no `reason`.
|
||||
- **Momentos que talvez mereçam efeito** mas que você não tem confiança
|
||||
para decidir.
|
||||
|
||||
Marcador é um **ponto**, não um trecho: sobrevive mesmo encostado na borda
|
||||
de um corte, o que é justamente o caso das emendas.
|
||||
@@ -0,0 +1,40 @@
|
||||
# 07 — Ritmo
|
||||
|
||||
> **Escopo:** Quantos efeitos cabem: os tetos e como escolher o que fica.
|
||||
> **Quando:** Fase 7 — ver a ordem de trabalho em `../SKILL.md`.
|
||||
|
||||
**O erro mais comum é efeito demais.** Cansa mais que efeito de menos, e
|
||||
denuncia edição automática.
|
||||
|
||||
## Limites
|
||||
|
||||
| Regra | Valor |
|
||||
|---|---|
|
||||
| Distância mínima entre dois zooms | **8–10 segundos** |
|
||||
| Zooms por minuto de vídeo | **2 a 4** (teto) |
|
||||
| Zoom + texto no mesmo instante | só com motivo claro |
|
||||
|
||||
Se dois picos estiverem colados, **escolha o mais forte e abra mão do
|
||||
outro**. Não tente encaixar os dois.
|
||||
|
||||
## Candidatos ≠ obrigações
|
||||
|
||||
`suggest_zoom_windows` devolve uma lista de candidatos. Normalmente você usa
|
||||
uma **fração** dela.
|
||||
|
||||
Caso real: num corte de 47,6s a ferramenta sugeriu **5** janelas. O certo
|
||||
foram **3** — 5 violaria o teto de 2–4 por minuto. Ficaram a abertura, o
|
||||
termo central e o fecho; as duas descartadas eram frases de apoio.
|
||||
|
||||
O mesmo vale para `peak_moments` no `summary`: é lista de candidatos.
|
||||
|
||||
## Como escolher quais manter
|
||||
|
||||
Quando precisar cortar a lista, priorize por **função narrativa**, não por
|
||||
nota:
|
||||
|
||||
1. **A abertura** — prende o espectador.
|
||||
2. **O conceito central** — o termo que o vídeo existe para explicar.
|
||||
3. **O fecho** — a frase que fica.
|
||||
|
||||
Só depois disso, as frases de apoio, por ordem de ênfase.
|
||||
@@ -0,0 +1,106 @@
|
||||
# 08 — Formato de saída
|
||||
|
||||
> **Escopo:** O JSON de entrega: estrutura, regras e como o programa trata erros.
|
||||
> **Quando:** Fase 8 — ver a ordem de trabalho em `../SKILL.md`.
|
||||
|
||||
O produto intermediário do seu trabalho é **este JSON**. Ele serve para
|
||||
revisão e transporte entre componentes; a decisão persistida deve ser salva
|
||||
em `planos_de_edicao` e `acoes_do_plano`. Você nunca escreve XML.
|
||||
|
||||
## Estrutura
|
||||
|
||||
```json
|
||||
{
|
||||
"source": "0E6A8290.mp4",
|
||||
"actions": [
|
||||
{"kind": "cut", "start": 21.9, "end": 127.6,
|
||||
"reason": "tomadas descartadas, frases interrompidas e conversa com a equipe"},
|
||||
{"kind": "hook", "start": 141.2, "end": 147.8,
|
||||
"params": {"target": 0, "keep_original": true},
|
||||
"reason": "frase completa escolhida como gancho de abertura"},
|
||||
{"kind": "zoom", "start": 2.0, "end": 10.7,
|
||||
"params": {"scale": 1.15}, "reason": "abertura: \"Aquela mama\" (ênfase 0.42)"},
|
||||
{"kind": "text", "start": 127.7, "end": 129.0,
|
||||
"params": {"content": "MASTOPEXIA"}, "reason": "fixa o termo central"},
|
||||
{"kind": "marker", "start": 21.85, "end": 22.0,
|
||||
"reason": "EMENDA 1 — conferir junção entre tomadas"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Regras
|
||||
|
||||
### 1. Tempos em segundos da mídia ORIGINAL
|
||||
Exatamente como aparecem nos intervalos persistidos da transcrição no banco.
|
||||
|
||||
**Nunca compense para "depois do corte".** O programa faz esse deslocamento
|
||||
sozinho: ele resolve os cortes primeiro e reposiciona todo o resto. Se você
|
||||
compensar por conta própria, **todo destaque cai no frame errado** — e o
|
||||
erro é silencioso.
|
||||
|
||||
### 2. `end` sempre maior que `start`
|
||||
Ambos ≥ 0. Um `end <= start` é rejeitado.
|
||||
|
||||
### 3. Tipos
|
||||
`cut` · `hook` · `zoom` · `text` · `marker`
|
||||
|
||||
### 4. Parâmetros por tipo
|
||||
|
||||
| Tipo | `params` |
|
||||
|---|---|
|
||||
| `cut` | nenhum |
|
||||
| `hook` | `target: 0` e `keep_original: true` |
|
||||
| `zoom` | `scale` entre 1.0 e 3.0 (padrão 1.3 se omitido) |
|
||||
| `text` | `content` **obrigatório**, até 120 caracteres |
|
||||
| `marker` | opcional: `content` vira o nome do marcador |
|
||||
|
||||
### 5. `reason` — sempre preencha
|
||||
É o que o usuário lê para revisar sua decisão, e o que te obriga a **ter**
|
||||
uma. Um `reason` vazio é sinal de decisão sem critério.
|
||||
|
||||
Inclua o dado que embasou: *"abertura: 'Aquela mama' (ênfase 0.42)"* é útil;
|
||||
*"zoom"* não é.
|
||||
|
||||
Não é campo de log: o texto permanece no JSON e no histórico persistido para
|
||||
o editor conferir por que a ação foi proposta.
|
||||
|
||||
### 6. Corte: preserve a integridade da fala
|
||||
|
||||
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.
|
||||
|
||||
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`.
|
||||
|
||||
### 7. Gancho: uma cópia completa para o início
|
||||
|
||||
Use no máximo um `hook`. O intervalo deve estar contido em um único clipe de
|
||||
vídeo e seu áudio vinculado, em velocidade normal. A frase copiada precisa ter
|
||||
começo, fechamento e 0,30s de margem nas duas bordas. O aplicador aceita apenas
|
||||
o destino `0` e preserva a ocorrência original; nenhuma outra ação pode
|
||||
sobrepor esse intervalo.
|
||||
|
||||
## Persistência
|
||||
|
||||
Depois de validar o JSON, crie um registro em `planos_de_edicao` ligado ao
|
||||
`video_id` e à edição de origem. Grave cada ação em `acoes_do_plano` mantendo a
|
||||
ordem e os motivos. Não considere o plano entregue até a gravação ser
|
||||
confirmada.
|
||||
|
||||
## Como o programa trata erros
|
||||
|
||||
- **Plano ou ação inválida** → o plano inteiro é bloqueado antes de alterar a
|
||||
timeline.
|
||||
- **Borda de corte ou gancho semanticamente insegura** → o plano inteiro é bloqueado
|
||||
antes da conexão com o Premiere e antes do backup.
|
||||
- **Gancho com faixas adicionais ocupadas, mídia retimada ou par A/V
|
||||
ambíguo** → recusado; use uma sequência simples de diálogo ou faça a
|
||||
duplicação manualmente.
|
||||
- **Ação apontando para material cortado** → descartada e reportada, nunca
|
||||
deslizada para o conteúdo vizinho.
|
||||
- **Ação fora da mídia** → reportada como não colocada.
|
||||
|
||||
Você recebe o relatório dos três casos. **Repasse ao usuário** — nunca
|
||||
relate só os acertos.
|
||||
@@ -0,0 +1,53 @@
|
||||
# 09 — Quando a análise veio incompleta
|
||||
|
||||
> **Escopo:** O que fazer quando uma camada da análise não rodou.
|
||||
> **Quando:** Fase 0 — ver a ordem de trabalho em `../SKILL.md`.
|
||||
|
||||
O registro correspondente em `analises_versao` e os campos disponíveis nas
|
||||
tabelas de análise dizem **o que de fato rodou**. Leia isso antes de qualquer
|
||||
outra coisa. Se houver um JSON exportado com `layers`, use-o apenas como
|
||||
informação auxiliar e confira sua versão contra o banco.
|
||||
|
||||
```json
|
||||
"layers": {"transcript": true, "acoustics": false, "speakers": false}
|
||||
```
|
||||
|
||||
## Por que esse bloco existe
|
||||
|
||||
Fala monótona e acústica que não carregou deixam **os mesmos zeros** nos
|
||||
dados. Sem o `layers`, é impossível distinguir "esta pessoa fala de forma
|
||||
uniforme" de "a análise acústica falhou".
|
||||
|
||||
## Os casos
|
||||
|
||||
### `acoustics: false`
|
||||
Todos os valores acústicos são 0. **Você não tem ênfase real.**
|
||||
|
||||
- Decida só pelo texto.
|
||||
- **Avise o usuário** explicitamente.
|
||||
- Prefira `marker` a `zoom` — sinalize em vez de decidir.
|
||||
|
||||
Causa comum: o componente librosa não está instalado, ou o ffmpeg não
|
||||
conseguiu extrair o áudio do container.
|
||||
|
||||
### `speakers: false` num vídeo com várias pessoas
|
||||
A diarização não rodou — falta o token do HuggingFace (aba Modelos do app).
|
||||
|
||||
- Avise antes de tratar tudo como uma voz só.
|
||||
- Lembre que isso **não impede** a triagem roteiro/conversa, que é feita
|
||||
pelo texto (ver `02-triagem-roteiro-vs-conversa.md`).
|
||||
|
||||
### `peak_count: 0`
|
||||
Nada cruzou o piso de ênfase. Duas causas possíveis:
|
||||
|
||||
1. A fala é uniforme mesmo — material sem picos.
|
||||
2. O limiar está alto para esse material.
|
||||
|
||||
Sugira ajustar em **Análise de Voz** no app. **Não force destaques
|
||||
inexistentes** só para entregar alguma coisa.
|
||||
|
||||
## Regra geral
|
||||
|
||||
Não finja precisão que você não tem. Uma edição entregue com a ressalva
|
||||
certa é útil; uma entregue como se estivesse completa, quando metade dos
|
||||
dados faltou, custa a confiança do usuário no sistema inteiro.
|
||||
@@ -0,0 +1,50 @@
|
||||
# 10 — Revisão humana no fluxo atual
|
||||
|
||||
> **Escopo:** O que o painel realmente permite revisar antes da aplicação.
|
||||
> **Quando:** ler antes de gerar e aprovar o plano.
|
||||
|
||||
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.
|
||||
|
||||
## Preview obrigatório
|
||||
|
||||
Antes de pedir aprovação, apresente para cada corte ou gancho:
|
||||
|
||||
- intervalo afetado e motivo;
|
||||
- última frase completa que ficará antes;
|
||||
- primeira frase completa que ficará depois;
|
||||
- texto da emenda resultante quando houver remoção;
|
||||
- margem acústica em cada lado;
|
||||
- qualquer incerteza que exija escuta humana.
|
||||
|
||||
Para `hook`, apresente também a frase completa que passará a abrir o vídeo e
|
||||
confirme que a ocorrência original será preservada.
|
||||
|
||||
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.
|
||||
|
||||
## Defesa automática
|
||||
|
||||
Ao clicar em **Aplicar plano na timeline**, a engine revalida cortes e
|
||||
ganchos antes
|
||||
de se conectar ao Premiere e antes de criar o backup. A aplicação é bloqueada
|
||||
quando encontra:
|
||||
|
||||
- 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.
|
||||
- gancho sem frase completa ou sem margem nas duas extremidades.
|
||||
|
||||
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.
|
||||
|
||||
## `reason` continua obrigatório
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,56 @@
|
||||
# 11 — Gancho duplicado
|
||||
|
||||
> **Escopo:** Copiar uma fala do meio do vídeo para a abertura.
|
||||
> **Quando:** pedidos de gancho ou abertura forte com repetição intencional.
|
||||
|
||||
## Semântica atual
|
||||
|
||||
`hook` cria uma cópia: a fala permanece no corpo do vídeo e sua cópia passa a
|
||||
abrir a sequência. Use somente:
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": "hook",
|
||||
"start": 141.2,
|
||||
"end": 147.8,
|
||||
"params": {"target": 0, "keep_original": true},
|
||||
"reason": "frase completa escolhida como gancho de abertura"
|
||||
}
|
||||
```
|
||||
|
||||
## Escolha editorial
|
||||
|
||||
O gancho deve funcionar sem a frase anterior. Prefira uma promessa, contraste,
|
||||
resultado, pergunta ou afirmação específica que desperte curiosidade e continue
|
||||
verdadeira fora da posição original. Evite pronomes sem referente, continuações
|
||||
como “por isso” ou “então” e conclusões que dependam de explicação anterior.
|
||||
|
||||
Selecione a menor unidade que preserve o sentido completo. Una segmentos do
|
||||
Whisper quando a gramática continuar entre eles. A primeira palavra precisa
|
||||
iniciar a frase, a última precisa fechá-la e cada extremidade precisa conservar
|
||||
ao menos 0,30s de respiro.
|
||||
|
||||
## Limites da execução segura
|
||||
|
||||
A versão atual aceita um único `hook`, destinado a `0s`, dentro de um único
|
||||
clipe de vídeo em velocidade normal e seu áudio correspondente. Ela recusa
|
||||
faixas adicionais ocupadas, par A/V ambíguo, mídia retimada ou invertida e
|
||||
qualquer outra ação que sobreponha o trecho do gancho.
|
||||
|
||||
A operação cria a cópia a partir do mesmo intervalo da mídia de origem; o
|
||||
original conserva seus efeitos, mas a cópia nasce sem efeitos específicos do
|
||||
clipe. Por isso o gancho deve rodar antes de zooms e outros efeitos do plano.
|
||||
Ela só prossegue quando o backup da sequência foi confirmado pelo Premiere.
|
||||
|
||||
## Preview
|
||||
|
||||
Antes da aprovação, mostre:
|
||||
|
||||
- a frase completa escolhida;
|
||||
- o intervalo original e as margens de cada borda;
|
||||
- a nova abertura seguida da primeira frase do conteúdo anterior;
|
||||
- a confirmação de que a ocorrência original será preservada;
|
||||
- o aumento esperado da duração total pelo tamanho do gancho.
|
||||
|
||||
O plano está pronto somente quando a nova emenda preserva sentido, ritmo e
|
||||
respiração.
|
||||
@@ -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
|
||||
|
||||
@@ -3,6 +3,184 @@ import { sendCommand, BridgeOptions } from "../bridge/file-bridge.js";
|
||||
|
||||
export function getTimelineTools(bridgeOptions: BridgeOptions) {
|
||||
return {
|
||||
assemble_incremental_edit_prototype: {
|
||||
description:
|
||||
"PROTOTYPE: clone a source sequence, clear only the clone, and assemble one project item's A/V source ranges in order. The source sequence is not edited.",
|
||||
parameters: {
|
||||
type: "object" as const,
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
source_sequence_id: {
|
||||
type: "string",
|
||||
minLength: 1,
|
||||
description: "Source sequence name or ID to clone",
|
||||
},
|
||||
source_item_id: {
|
||||
type: "string",
|
||||
minLength: 1,
|
||||
description: "Project item name or node ID used by every source range",
|
||||
},
|
||||
target_name: {
|
||||
type: "string",
|
||||
minLength: 1,
|
||||
maxLength: 255,
|
||||
description: "Name for the cloned destination sequence",
|
||||
},
|
||||
ranges: {
|
||||
type: "array",
|
||||
minItems: 1,
|
||||
maxItems: 64,
|
||||
items: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
in_seconds: { type: "number", minimum: 0 },
|
||||
out_seconds: { type: "number", minimum: 0 },
|
||||
destination_seconds: { type: "number", minimum: 0 },
|
||||
label: { type: "string", maxLength: 255 },
|
||||
},
|
||||
required: ["in_seconds", "out_seconds", "destination_seconds"],
|
||||
},
|
||||
},
|
||||
confirm_prototype: {
|
||||
type: "boolean",
|
||||
description: "Must be true because this creates and mutates a Premiere sequence",
|
||||
},
|
||||
},
|
||||
required: ["source_sequence_id", "source_item_id", "target_name", "ranges", "confirm_prototype"],
|
||||
},
|
||||
handler: async (args: {
|
||||
source_sequence_id: string;
|
||||
source_item_id: string;
|
||||
target_name: string;
|
||||
ranges: Array<{
|
||||
in_seconds: number;
|
||||
out_seconds: number;
|
||||
destination_seconds: number;
|
||||
label?: string;
|
||||
}>;
|
||||
confirm_prototype: boolean;
|
||||
}) => {
|
||||
if (args.confirm_prototype !== true) {
|
||||
return { success: false, error: "confirm_prototype must be true for the experimental sequence assembly." };
|
||||
}
|
||||
if (!Array.isArray(args.ranges) || args.ranges.length === 0 || args.ranges.length > 64) {
|
||||
return { success: false, error: "ranges must contain between 1 and 64 source ranges." };
|
||||
}
|
||||
for (const [index, range] of args.ranges.entries()) {
|
||||
if (!Number.isFinite(range.in_seconds) || !Number.isFinite(range.out_seconds) || !Number.isFinite(range.destination_seconds) ||
|
||||
range.in_seconds < 0 || range.out_seconds <= range.in_seconds || range.destination_seconds < 0) {
|
||||
return { success: false, error: `ranges[${index}] has invalid source or destination timing.` };
|
||||
}
|
||||
}
|
||||
|
||||
const sourceSequenceId = escapeForExtendScript(args.source_sequence_id);
|
||||
const sourceItemId = escapeForExtendScript(args.source_item_id);
|
||||
const targetName = escapeForExtendScript(args.target_name);
|
||||
const ranges = args.ranges.map((range) => ({
|
||||
inSeconds: range.in_seconds,
|
||||
outSeconds: range.out_seconds,
|
||||
destinationSeconds: range.destination_seconds,
|
||||
label: range.label || "",
|
||||
}));
|
||||
const rangeJson = JSON.stringify(ranges);
|
||||
|
||||
const script = buildToolScript(`
|
||||
var sourceSequence = __findSequence("${sourceSequenceId}");
|
||||
if (!sourceSequence) return __error("Source sequence not found; no prototype sequence was created.");
|
||||
var item = __findProjectItem("${sourceItemId}");
|
||||
if (!item) return __error("Source project item not found; no prototype sequence was created.");
|
||||
|
||||
var beforeIds = {};
|
||||
var sequenceIndex;
|
||||
for (sequenceIndex = 0; sequenceIndex < app.project.sequences.numSequences; sequenceIndex++) {
|
||||
beforeIds[String(app.project.sequences[sequenceIndex].sequenceID)] = true;
|
||||
}
|
||||
sourceSequence.clone();
|
||||
|
||||
var destinationSequence = null;
|
||||
for (sequenceIndex = 0; sequenceIndex < app.project.sequences.numSequences; sequenceIndex++) {
|
||||
var candidate = app.project.sequences[sequenceIndex];
|
||||
if (!beforeIds[String(candidate.sequenceID)]) {
|
||||
destinationSequence = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!destinationSequence) return __error("Premiere did not expose the cloned destination sequence.");
|
||||
destinationSequence.name = "${targetName}";
|
||||
|
||||
var trackGroup;
|
||||
var trackIndex;
|
||||
var clipIndex;
|
||||
var trackGroups = [destinationSequence.videoTracks, destinationSequence.audioTracks];
|
||||
for (trackGroup = 0; trackGroup < trackGroups.length; trackGroup++) {
|
||||
for (trackIndex = 0; trackIndex < trackGroups[trackGroup].numTracks; trackIndex++) {
|
||||
var track = trackGroups[trackGroup][trackIndex];
|
||||
for (clipIndex = track.clips.numItems - 1; clipIndex >= 0; clipIndex--) {
|
||||
track.clips[clipIndex].remove(false, false);
|
||||
}
|
||||
if (track.clips.numItems !== 0) return __error("Could not clear the cloned destination sequence.");
|
||||
}
|
||||
}
|
||||
if (!destinationSequence.videoTracks.numTracks || !destinationSequence.audioTracks.numTracks) {
|
||||
return __error("The cloned destination sequence must have at least one video and one audio track.");
|
||||
}
|
||||
|
||||
var ranges = ${rangeJson};
|
||||
var oldIn = item.getInPoint(4);
|
||||
var oldOut = item.getOutPoint(4);
|
||||
var oldInTicks = oldIn ? oldIn.ticks : null;
|
||||
var oldOutTicks = oldOut ? oldOut.ticks : null;
|
||||
var placements = [];
|
||||
var insertionError = null;
|
||||
try {
|
||||
for (var rangeIndex = 0; rangeIndex < ranges.length; rangeIndex++) {
|
||||
var range = ranges[rangeIndex];
|
||||
if (range.outSeconds <= range.inSeconds) return __error("A prototype source range has non-positive duration.");
|
||||
item.setInPoint(__secondsToTicks(range.inSeconds).toString(), 4);
|
||||
item.setOutPoint(__secondsToTicks(range.outSeconds).toString(), 4);
|
||||
destinationSequence.insertClip(item, __secondsToTicks(range.destinationSeconds).toString(), 0, 0);
|
||||
placements.push({
|
||||
label: range.label,
|
||||
sourceInSeconds: range.inSeconds,
|
||||
sourceOutSeconds: range.outSeconds,
|
||||
destinationSeconds: range.destinationSeconds,
|
||||
durationSeconds: range.outSeconds - range.inSeconds
|
||||
});
|
||||
}
|
||||
} catch (insertError) {
|
||||
insertionError = insertError.toString();
|
||||
} finally {
|
||||
try {
|
||||
if (oldInTicks !== null) item.setInPoint(String(oldInTicks), 4); else item.clearInPoint();
|
||||
if (oldOutTicks !== null) item.setOutPoint(String(oldOutTicks), 4); else item.clearOutPoint();
|
||||
} catch (restoreError) {
|
||||
return __error("The source item's in/out could not be restored: " + restoreError.toString());
|
||||
}
|
||||
}
|
||||
if (insertionError !== null) return __error("Premiere rejected a prototype source-range insertion: " + insertionError);
|
||||
|
||||
var videoCount = destinationSequence.videoTracks[0].clips.numItems;
|
||||
var audioCount = destinationSequence.audioTracks[0].clips.numItems;
|
||||
if (videoCount !== ranges.length || audioCount !== ranges.length) {
|
||||
return __error("The prototype destination did not receive exactly one video and one audio clip per range.");
|
||||
}
|
||||
return __result({
|
||||
prototype: true,
|
||||
verified: true,
|
||||
sourceSequenceId: String(sourceSequence.sequenceID),
|
||||
destinationSequenceId: String(destinationSequence.sequenceID),
|
||||
destinationSequenceName: destinationSequence.name,
|
||||
videoClipCount: videoCount,
|
||||
audioClipCount: audioCount,
|
||||
placements: placements,
|
||||
note: "The source sequence was not edited. Review the cloned sequence manually before keeping it."
|
||||
});
|
||||
`);
|
||||
return sendCommand(script, bridgeOptions);
|
||||
},
|
||||
},
|
||||
|
||||
add_to_timeline: {
|
||||
description:
|
||||
"Insert a project item at a timeline position and verify Premiere added no unexpected same-track fragments.",
|
||||
@@ -194,6 +372,228 @@ export function getTimelineTools(bridgeOptions: BridgeOptions) {
|
||||
},
|
||||
},
|
||||
|
||||
duplicate_linked_range_to_start: {
|
||||
description:
|
||||
"Duplicate one isolated, normal-speed linked video/audio range at sequence start with a verified ripple insert while preserving the original occurrence.",
|
||||
parameters: {
|
||||
type: "object" as const,
|
||||
properties: {
|
||||
node_ids: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
minItems: 2,
|
||||
maxItems: 2,
|
||||
description: "IDs of the isolated video and audio clips that form the range.",
|
||||
},
|
||||
},
|
||||
required: ["node_ids"],
|
||||
},
|
||||
handler: async (args: { node_ids: string[] }) => {
|
||||
if (!Array.isArray(args.node_ids) || args.node_ids.length !== 2 || args.node_ids.some((id) => typeof id !== "string" || !id)) {
|
||||
return { success: false, error: "node_ids must contain exactly two non-empty clip IDs." };
|
||||
}
|
||||
const ids = args.node_ids.map(escapeForExtendScript);
|
||||
const script = buildToolScript(`
|
||||
var first = __findClip("${ids[0]}");
|
||||
var second = __findClip("${ids[1]}");
|
||||
if (!first || !second) return __error("One of the requested clips was not found; nothing was duplicated.");
|
||||
if (first.trackType === second.trackType) return __error("The range must contain one video clip and one audio clip.");
|
||||
|
||||
var video = first.trackType === "video" ? first : second;
|
||||
var audio = first.trackType === "audio" ? first : second;
|
||||
var seq = app.project.activeSequence;
|
||||
if (!seq) return __error("No active sequence; nothing was duplicated.");
|
||||
var frameTicks = parseFloat(seq.timebase);
|
||||
if (!frameTicks || isNaN(frameTicks)) frameTicks = TICKS_PER_SECOND / 24;
|
||||
var beforeEndTicks = parseFloat(seq.end.ticks);
|
||||
var videoStart = parseFloat(video.clip.start.ticks);
|
||||
var audioStart = parseFloat(audio.clip.start.ticks);
|
||||
var videoEnd = parseFloat(video.clip.end.ticks);
|
||||
var audioEnd = parseFloat(audio.clip.end.ticks);
|
||||
if (Math.abs(videoStart - audioStart) > frameTicks || Math.abs(videoEnd - audioEnd) > frameTicks) {
|
||||
return __error("The video and audio clips do not occupy the same range; nothing was duplicated.");
|
||||
}
|
||||
if (videoStart <= frameTicks) return __error("The requested range is already at sequence start; nothing was duplicated.");
|
||||
if (!video.clip.projectItem || !audio.clip.projectItem || video.clip.projectItem.nodeId !== audio.clip.projectItem.nodeId) {
|
||||
return __error("The video and audio clips do not share one source project item; nothing was duplicated.");
|
||||
}
|
||||
var videoSpeed = 100;
|
||||
var audioSpeed = 100;
|
||||
var videoReversed = false;
|
||||
var audioReversed = false;
|
||||
try { videoSpeed = Number(video.clip.getSpeed()); } catch (e) {}
|
||||
try { audioSpeed = Number(audio.clip.getSpeed()); } catch (e) {}
|
||||
try { videoReversed = video.clip.isSpeedReversed() == 1; } catch (e) {}
|
||||
try { audioReversed = audio.clip.isSpeedReversed() == 1; } catch (e) {}
|
||||
if (Math.abs(videoSpeed - 100) > 0.001 || Math.abs(audioSpeed - 100) > 0.001 || videoReversed || audioReversed) {
|
||||
return __error("Retimed or reversed clips cannot be duplicated by this guarded operation.");
|
||||
}
|
||||
|
||||
var g;
|
||||
var t;
|
||||
var groups = [seq.videoTracks, seq.audioTracks];
|
||||
for (g = 0; g < groups.length; g++) {
|
||||
for (t = 0; t < groups[g].numTracks; t++) {
|
||||
var isTarget = (g === 0 && t === video.trackIndex) || (g === 1 && t === audio.trackIndex);
|
||||
if (!isTarget && groups[g][t].clips.numItems > 0) {
|
||||
return __error("The guarded hook only supports a simple sequence with content on one video and one audio track.");
|
||||
}
|
||||
}
|
||||
}
|
||||
if (seq.videoTracks[video.trackIndex].clips.numItems < 1 || seq.audioTracks[audio.trackIndex].clips.numItems < 1) {
|
||||
return __error("The target tracks are empty; nothing was duplicated.");
|
||||
}
|
||||
if ((seq.videoTracks[video.trackIndex].isLocked && seq.videoTracks[video.trackIndex].isLocked()) ||
|
||||
(seq.audioTracks[audio.trackIndex].isLocked && seq.audioTracks[audio.trackIndex].isLocked())) {
|
||||
return __error("A target track is locked; nothing was duplicated.");
|
||||
}
|
||||
if (parseFloat(seq.videoTracks[video.trackIndex].clips[0].start.ticks) > frameTicks ||
|
||||
parseFloat(seq.audioTracks[audio.trackIndex].clips[0].start.ticks) > frameTicks) {
|
||||
return __error("The guarded hook requires both target tracks to begin at sequence time zero.");
|
||||
}
|
||||
|
||||
var item = video.clip.projectItem;
|
||||
var sourceInTicks = parseFloat(video.clip.inPoint.ticks);
|
||||
var sourceOutTicks = parseFloat(video.clip.outPoint.ticks);
|
||||
if (Math.abs(sourceInTicks - parseFloat(audio.clip.inPoint.ticks)) > frameTicks ||
|
||||
Math.abs(sourceOutTicks - parseFloat(audio.clip.outPoint.ticks)) > frameTicks) {
|
||||
return __error("The linked clips do not share matching source in/out points; nothing was duplicated.");
|
||||
}
|
||||
var durationTicks = videoEnd - videoStart;
|
||||
if (durationTicks <= frameTicks) return __error("The requested range is too short to duplicate safely.");
|
||||
|
||||
var oldIn = item.getInPoint(4);
|
||||
var oldOut = item.getOutPoint(4);
|
||||
var oldInTicks = oldIn ? oldIn.ticks : null;
|
||||
var oldOutTicks = oldOut ? oldOut.ticks : null;
|
||||
var insertionError = null;
|
||||
try {
|
||||
item.setInPoint(String(sourceInTicks), 4);
|
||||
item.setOutPoint(String(sourceOutTicks), 4);
|
||||
seq.insertClip(item, "0", video.trackIndex, audio.trackIndex);
|
||||
} catch (insertError) {
|
||||
insertionError = insertError.toString();
|
||||
} finally {
|
||||
try {
|
||||
if (oldInTicks !== null) item.setInPoint(String(oldInTicks), 4); else item.clearInPoint();
|
||||
if (oldOutTicks !== null) item.setOutPoint(String(oldOutTicks), 4); else item.clearOutPoint();
|
||||
} catch (restoreError) {
|
||||
return __error("The source range could not be restored after insertion: " + restoreError.toString());
|
||||
}
|
||||
}
|
||||
if (insertionError !== null) return __error("Premiere could not insert the range at sequence start: " + insertionError);
|
||||
|
||||
var insertedVideo = null;
|
||||
var insertedAudio = null;
|
||||
var vc;
|
||||
var ac;
|
||||
for (vc = 0; vc < seq.videoTracks[video.trackIndex].clips.numItems; vc++) {
|
||||
var candidateVideo = seq.videoTracks[video.trackIndex].clips[vc];
|
||||
if (candidateVideo.nodeId !== "${ids[0]}" && candidateVideo.nodeId !== "${ids[1]}" &&
|
||||
candidateVideo.projectItem && candidateVideo.projectItem.nodeId === item.nodeId &&
|
||||
Math.abs(parseFloat(candidateVideo.start.ticks)) <= frameTicks) insertedVideo = candidateVideo;
|
||||
}
|
||||
for (ac = 0; ac < seq.audioTracks[audio.trackIndex].clips.numItems; ac++) {
|
||||
var candidateAudio = seq.audioTracks[audio.trackIndex].clips[ac];
|
||||
if (candidateAudio.nodeId !== "${ids[0]}" && candidateAudio.nodeId !== "${ids[1]}" &&
|
||||
candidateAudio.projectItem && candidateAudio.projectItem.nodeId === item.nodeId &&
|
||||
Math.abs(parseFloat(candidateAudio.start.ticks)) <= frameTicks) insertedAudio = candidateAudio;
|
||||
}
|
||||
if (!insertedVideo || !insertedAudio) {
|
||||
return __error("Premiere inserted content but the new linked range could not be verified at sequence start. Restore the automatic sequence backup.");
|
||||
}
|
||||
if (Math.abs((parseFloat(insertedVideo.end.ticks) - parseFloat(insertedVideo.start.ticks)) - durationTicks) > frameTicks ||
|
||||
Math.abs((parseFloat(insertedAudio.end.ticks) - parseFloat(insertedAudio.start.ticks)) - durationTicks) > frameTicks) {
|
||||
return __error("The inserted range has an unexpected duration. Restore the automatic sequence backup.");
|
||||
}
|
||||
var insertedVideoId = String(insertedVideo.nodeId);
|
||||
var insertedAudioId = String(insertedAudio.nodeId);
|
||||
|
||||
var preservedFirst = __findClip("${ids[0]}");
|
||||
var preservedSecond = __findClip("${ids[1]}");
|
||||
if (!preservedFirst || !preservedSecond ||
|
||||
Math.abs(parseFloat(preservedFirst.clip.start.ticks) - (videoStart + durationTicks)) > frameTicks ||
|
||||
Math.abs(parseFloat(preservedSecond.clip.start.ticks) - (audioStart + durationTicks)) > frameTicks) {
|
||||
return __error("The original range was not preserved after the ripple insert. Restore the automatic sequence backup.");
|
||||
}
|
||||
|
||||
var hookVideo = __findClip(insertedVideoId);
|
||||
var hookAudio = __findClip(insertedAudioId);
|
||||
if (!hookVideo || !hookAudio ||
|
||||
Math.abs(parseFloat(hookVideo.clip.start.ticks)) > frameTicks ||
|
||||
Math.abs(parseFloat(hookAudio.clip.start.ticks)) > frameTicks) {
|
||||
return __error("The duplicated hook was not preserved at sequence start. Restore the automatic sequence backup.");
|
||||
}
|
||||
var afterEndTicks = parseFloat(seq.end.ticks);
|
||||
if (Math.abs(afterEndTicks - (beforeEndTicks + durationTicks)) > frameTicks * 2) {
|
||||
return __error("The sequence duration did not grow by the hook duration. Restore the automatic sequence backup.");
|
||||
}
|
||||
return __result({
|
||||
duplicated: true,
|
||||
verified: true,
|
||||
targetSeconds: 0,
|
||||
durationSeconds: __ticksToSeconds(durationTicks),
|
||||
insertedNodeIds: [String(hookVideo.clip.nodeId), String(hookAudio.clip.nodeId)],
|
||||
originalNodeIdsPreserved: ["${ids[0]}", "${ids[1]}"],
|
||||
clipEffectsPreserved: false
|
||||
});
|
||||
`);
|
||||
return sendCommand(script, bridgeOptions);
|
||||
},
|
||||
},
|
||||
|
||||
insert_adjustment_layer_range: {
|
||||
description:
|
||||
"Insert an existing Adjustment Layer project item on a video track and extend the timeline instance to an exact bounded range.",
|
||||
parameters: {
|
||||
type: "object" as const,
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
item_id: { type: "string", minLength: 1, description: "Project item node ID of an existing Adjustment Layer." },
|
||||
track_index: { type: "integer", minimum: 0, description: "Video track index." },
|
||||
start_seconds: { type: "number", minimum: 0, description: "Exact timeline start in seconds." },
|
||||
end_seconds: { type: "number", minimum: 0, description: "Exact timeline end in seconds." },
|
||||
},
|
||||
required: ["item_id", "track_index", "start_seconds", "end_seconds"],
|
||||
},
|
||||
handler: async (args: { item_id: string; track_index: number; start_seconds: number; end_seconds: number }) => {
|
||||
if (!Number.isFinite(args.start_seconds) || !Number.isFinite(args.end_seconds) || args.end_seconds <= args.start_seconds) {
|
||||
return { success: false, error: "The adjustment-layer range must have finite start/end values and positive duration." };
|
||||
}
|
||||
const script = buildToolScript(`
|
||||
var seq = app.project.activeSequence;
|
||||
if (!seq) return __error("No active sequence");
|
||||
var item = __findProjectItem("${escapeForExtendScript(args.item_id)}");
|
||||
if (!item) return __error("Adjustment Layer project item not found");
|
||||
var track = seq.videoTracks[${args.track_index}];
|
||||
if (!track) return __error("Video track not found: ${args.track_index}");
|
||||
var startTicks = __secondsToTicks(${args.start_seconds}).toString();
|
||||
var endTicks = __secondsToTicks(${args.end_seconds}).toString();
|
||||
var beforeIds = {};
|
||||
for (var b = 0; b < track.clips.numItems; b++) beforeIds[String(track.clips[b].nodeId)] = true;
|
||||
seq.insertClip(item, startTicks, ${args.track_index}, 0);
|
||||
var inserted = null;
|
||||
for (var i = 0; i < track.clips.numItems; i++) {
|
||||
var candidate = track.clips[i];
|
||||
if (!beforeIds[String(candidate.nodeId)] && Math.abs(parseFloat(candidate.start.ticks) - parseFloat(startTicks)) < 1) { inserted = candidate; break; }
|
||||
}
|
||||
if (!inserted) return __error("Premiere inserted the Adjustment Layer but it could not be identified");
|
||||
try { inserted.end = endTicks; } catch (e) { return __error("Could not extend the Adjustment Layer to the requested end: " + e.toString()); }
|
||||
var actualStart = __ticksToSeconds(inserted.start.ticks);
|
||||
var actualEnd = __ticksToSeconds(inserted.end.ticks);
|
||||
var frameTicks = seq.timebase ? parseFloat(seq.timebase) : TICKS_PER_SECOND / 24;
|
||||
var tolerance = __ticksToSeconds(frameTicks);
|
||||
if (Math.abs(actualStart - ${args.start_seconds}) > tolerance || Math.abs(actualEnd - ${args.end_seconds}) > tolerance) {
|
||||
return __error("Adjustment Layer range read back as " + actualStart + "-" + actualEnd + "s, not the requested range");
|
||||
}
|
||||
var isAdjustment = false;
|
||||
try { isAdjustment = inserted.isAdjustmentLayer(); } catch (eCheck) {}
|
||||
return __result({ inserted: true, verified: true, nodeId: String(inserted.nodeId), trackIndex: ${args.track_index}, startSeconds: actualStart, endSeconds: actualEnd, isAdjustmentLayer: isAdjustment });
|
||||
`);
|
||||
return sendCommand(script, bridgeOptions);
|
||||
},
|
||||
},
|
||||
|
||||
move_clip: {
|
||||
description: "Move a clip to a new position on the timeline",
|
||||
parameters: {
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const painel = readFileSync(join(process.cwd(), "cep-plugin", "main.js"), "utf8");
|
||||
|
||||
describe("preview de falantes na tela Refinar", () => {
|
||||
it("usa os intervalos convertidos para a origem do WAV", () => {
|
||||
expect(painel).toContain("var intervalosDePreview = silenceConsultarPreviewDeFalantes(silenceState.sequenceId);");
|
||||
expect(painel).toContain("start: Number(intervalo.inicio)");
|
||||
expect(painel).toContain("end: Number(intervalo.fim)");
|
||||
});
|
||||
|
||||
it("não controla a timeline do Premiere durante o preview", () => {
|
||||
expect(painel).not.toContain("function toggleSpeakerTimeline");
|
||||
expect(painel).not.toContain("setPlayerPosition");
|
||||
expect(painel).not.toContain("startPlayback");
|
||||
});
|
||||
});
|
||||
@@ -13,14 +13,14 @@ describe("exportação dos tipos de vídeo no CEP", () => {
|
||||
expect(painel).toContain('<script charset="utf-8" src="main.js"></script>');
|
||||
});
|
||||
|
||||
it("prioriza a cópia nativa com entrada UTF-8", () => {
|
||||
it("prioriza a API nativa de clipboard para preservar Unicode", () => {
|
||||
const lógica = readFileSync(join(raiz, "cep-plugin", "main.js"), "utf8");
|
||||
const início = lógica.indexOf("function copiarTextoParaAreaDeTransferencia(texto)");
|
||||
const início = lógica.indexOf("function copiarTextoParaAreaDeTransferencia(texto, mensagem)");
|
||||
const fim = lógica.indexOf("\n}\n\nfunction copiarComPbcopy", início);
|
||||
const função = lógica.slice(início, fim);
|
||||
|
||||
expect(função.indexOf("copiarComPbcopy(texto, copiarComSelecaoDoPainel)")).toBeLessThan(
|
||||
função.indexOf("navigator.clipboard.writeText"),
|
||||
expect(função.indexOf("navigator.clipboard.writeText")).toBeLessThan(
|
||||
função.indexOf("copiarComPbcopy(texto, copiarComSelecaoDoPainel"),
|
||||
);
|
||||
expect(lógica).toContain('copia.stdin.write(texto, "utf8");');
|
||||
});
|
||||
|
||||
@@ -198,12 +198,12 @@ describe("Tool Module Structure", () => {
|
||||
});
|
||||
|
||||
describe("Total Tool Count", () => {
|
||||
it("all modules together have 342 tools", () => {
|
||||
it("all modules together have 344 tools", () => {
|
||||
let total = 0;
|
||||
for (const mod of ALL_MODULES) {
|
||||
total += Object.keys(mod.getter(bridgeOptions)).length;
|
||||
}
|
||||
expect(total).toBe(343);
|
||||
expect(total).toBe(344);
|
||||
});
|
||||
|
||||
it("there are 40 directly enumerated modules", () => {
|
||||
@@ -527,6 +527,32 @@ describe("Tool Handler Behavior", () => {
|
||||
expect(script).not.toContain("setDisabled(");
|
||||
});
|
||||
|
||||
it("duplicates a linked range at sequence start and preserves the original", async () => {
|
||||
const tools = getTimelineTools(bridgeOptions);
|
||||
await (tools.duplicate_linked_range_to_start.handler as any)({ node_ids: ["video-1", "audio-1"] });
|
||||
|
||||
const script = mockedSendCommand.mock.calls[0][0];
|
||||
expect(script).toContain('first.trackType === second.trackType');
|
||||
expect(script).toContain('video.clip.projectItem.nodeId !== audio.clip.projectItem.nodeId');
|
||||
expect(script).toContain('Retimed or reversed clips cannot be duplicated');
|
||||
expect(script).toContain('groups[g][t].clips.numItems > 0');
|
||||
expect(script).toContain('seq.insertClip(item, "0", video.trackIndex, audio.trackIndex)');
|
||||
expect(script).toContain('item.setInPoint(String(sourceInTicks), 4)');
|
||||
expect(script).toContain('item.setOutPoint(String(sourceOutTicks), 4)');
|
||||
expect(script).toContain('var preservedFirst = __findClip("video-1")');
|
||||
expect(script).toContain('videoStart + durationTicks');
|
||||
expect(script).toContain('beforeEndTicks + durationTicks');
|
||||
expect(script).not.toContain('originalVideo.clip.remove');
|
||||
expect(script).toContain('clipEffectsPreserved: false');
|
||||
});
|
||||
|
||||
it("rejects an invalid linked-range move before contacting Premiere", async () => {
|
||||
const tools = getTimelineTools(bridgeOptions);
|
||||
await expect((tools.duplicate_linked_range_to_start.handler as any)({ node_ids: ["video-only"] }))
|
||||
.resolves.toMatchObject({ success: false, error: expect.stringContaining("exactly two") });
|
||||
expect(mockedSendCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not claim legacy source-monitor or stop-playback requests are verified", async () => {
|
||||
const tools = getPlaybackTools(bridgeOptions);
|
||||
await (tools.stop_playback.handler as any)({});
|
||||
|
||||
Reference in New Issue
Block a user