feat: Instalado essentia-tensorflow no venv whisperx-transcricao (
- Instalado essentia-tensorflow no venv whisperx-transcricao (usado pelo painel CEP), corrigindo erro "A classificação musical precisa do suporte TensorFlow do Essentia" no catálogo de trilhas. - Ligada a classificação de emoção da fala (ProviderDeEmocaoHuggingFace) ao scanner via nova flag `classificar_emocao`, e adicionado emoção/confiança como critério de desempate secundário no zoom da skill editar-por-voz. - Zoom do caminho Python (aplicar_plano_de_edicao.py) passou a usar adjustment layer numa faixa de vídeo própria, em vez de escalar o clipe inteiro via set_clip_properties — mesma técnica já usada pelo executor .mjs do painel CEP. - Adicionado campo "Carregar por ID" na aba Editar Vídeo do painel CEP (step 2), para carregar um plano do SQLite direto pelo plano_id quando ele não está associado a nenhum video_id conhecido (ex.: plano de um vídeo que este banco nunca escaneou). - Trocado o campo de ID numérico por uma lista suspensa (novo listar_planos_de_edicao.py + RepositorioDePlanos.listar_todos_os_planos) que mostra data/hora e a intenção registrada pela skill em cada plano salvo, para escolher sem decorar o id. Resumo: - 15 arquivos alterados - 2 novos - 13 modificados - 0 removidos 13 files changed, 451 insertions(+), 65 deletions(-) Arquivos: - .jhonny/analises.db - code/cep-plugin/index.html - code/cep-plugin/main.js - code/engine/editor/aplicador_de_plano_de_edicao.py - code/engine/editor/escrita/escrita_no_editor.py - code/engine/executar_scanner.py - code/engine/persistencia/repositorio_de_planos.py - code/engine/scanner/configuracao_visual.py - code/engine/testes/duplos_de_premiere.py - code/engine/testes/test_aplicador_de_plano_de_edicao.py - code/engine/testes/test_escrita_no_editor.py - code/plugins/premiere-pro/skills/editar-por-voz/criterios/01-leitura-do-json.md - code/plugins/premiere-pro/skills/editar-por-voz/criterios/05-zoom.md - code/engine/listar_planos_de_edicao.py - code/engine/testes/test_configuracao_visual_do_scanner.py
This commit is contained in:
Binary file not shown.
@@ -173,6 +173,14 @@
|
||||
<div class="step-body">
|
||||
<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>
|
||||
<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>
|
||||
|
||||
+126
-1
@@ -269,7 +269,7 @@ function switchTab(name) {
|
||||
if (name === "models") renderModelList();
|
||||
if (name === "trilhas") trilhasAoAbrir();
|
||||
if (name === "projeto") projetoListarSequencias();
|
||||
if (name === "silence") { syncTranscribeModelSelect(); syncLanguageInfo(); renderizarTiposDeVideoParaEdicao(); }
|
||||
if (name === "silence") { syncTranscribeModelSelect(); syncLanguageInfo(); renderizarTiposDeVideoParaEdicao(); carregarListaDePlanosEditoriais(); }
|
||||
if (name === "settings") renderEditorPersonalities();
|
||||
if (name === "scanner") { renderScannerTranscriptionOptions(); loadScannerProfile(); renderScannerHuggingFaceStatus(); scannerListarAnalisesExistentes(); }
|
||||
if (name === "refinar") refinarAoAbrir();
|
||||
@@ -951,6 +951,8 @@ var ANALISE_STATUS_SCRIPT = "/Volumes/Merongo/SISTEMAS/GENIAL SISTEMAS/Jhonny/co
|
||||
var ANALISE_PREVIEW_SCRIPT = "/Volumes/Merongo/SISTEMAS/GENIAL SISTEMAS/Jhonny/code/engine/consultar_preview_de_falantes.py";
|
||||
var REGISTRAR_EDICAO_SCRIPT = "/Volumes/Merongo/SISTEMAS/GENIAL SISTEMAS/Jhonny/code/engine/registrar_edicao.py";
|
||||
var CARREGAR_PLANO_SCRIPT = "/Volumes/Merongo/SISTEMAS/GENIAL SISTEMAS/Jhonny/code/engine/carregar_plano_de_edicao.py";
|
||||
var LISTAR_PLANOS_SCRIPT = "/Volumes/Merongo/SISTEMAS/GENIAL SISTEMAS/Jhonny/code/engine/listar_planos_de_edicao.py";
|
||||
var planosEditoriaisListados = [];
|
||||
var REGISTRAR_PROJETO_SCRIPT = "/Volumes/Merongo/SISTEMAS/GENIAL SISTEMAS/Jhonny/code/engine/registrar_projeto_de_edicao.py";
|
||||
var CONSULTAR_EDICAO_SCRIPT = "/Volumes/Merongo/SISTEMAS/GENIAL SISTEMAS/Jhonny/code/engine/consultar_edicao_concluida.py";
|
||||
var planoEditorialIdCarregado = null;
|
||||
@@ -3553,6 +3555,129 @@ function carregarPlanoEditorialSalvo() {
|
||||
);
|
||||
}
|
||||
|
||||
// Popula o seletor da etapa 2 com os planos mais recentes de qualquer vídeo
|
||||
// (não só o da sequência detectada) — cada opção mostra data/hora e a
|
||||
// intenção que a skill de edição registrou, para escolher sem decorar o id.
|
||||
function carregarListaDePlanosEditoriais() {
|
||||
var seletor = document.getElementById("editorialPlanIdInput");
|
||||
var detalhe = document.getElementById("editorialPlanIdDetalhe");
|
||||
if (!seletor) return;
|
||||
seletor.innerHTML = "";
|
||||
seletor.appendChild(new Option("Carregando planos…", ""));
|
||||
if (detalhe) detalhe.textContent = "";
|
||||
runStreamed(
|
||||
SCANNER_PYTHON_BIN,
|
||||
[LISTAR_PLANOS_SCRIPT, "--banco", ANALISES_DB_PATH, "--limite", "50"],
|
||||
function () {},
|
||||
function (codigo, ultimoErro, ultimaSaida) {
|
||||
seletor.innerHTML = "";
|
||||
if (codigo !== 0) {
|
||||
seletor.appendChild(new Option("Falha ao listar planos", ""));
|
||||
silenceLog("Não foi possível listar os planos salvos.", "err");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
var resposta = JSON.parse(ultimaSaida || "{}");
|
||||
if (!resposta.ok) throw new Error(resposta.erro || "Resposta inválida do banco.");
|
||||
planosEditoriaisListados = resposta.planos || [];
|
||||
if (!planosEditoriaisListados.length) {
|
||||
seletor.appendChild(new Option("Nenhum plano salvo ainda", ""));
|
||||
return;
|
||||
}
|
||||
seletor.appendChild(new Option("Selecione um plano…", ""));
|
||||
planosEditoriaisListados.forEach(function (plano) {
|
||||
var quando = formatarDataHoraDoPlano(plano.criado_em);
|
||||
var intencaoResumida = plano.intencao
|
||||
? (plano.intencao.length > 70 ? plano.intencao.slice(0, 70) + "…" : plano.intencao)
|
||||
: (plano.origem || "sem intenção registrada");
|
||||
var rotulo = "#" + plano.id + " · " + quando + " · " + intencaoResumida;
|
||||
seletor.appendChild(new Option(rotulo, String(plano.id)));
|
||||
});
|
||||
} catch (erro) {
|
||||
seletor.innerHTML = "";
|
||||
seletor.appendChild(new Option("Falha ao listar planos", ""));
|
||||
silenceLog("Falha ao interpretar a lista de planos: " + erro.message, "err");
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function formatarDataHoraDoPlano(criadoEm) {
|
||||
if (!criadoEm) return "data desconhecida";
|
||||
var data = new Date(criadoEm);
|
||||
if (isNaN(data.getTime())) return criadoEm;
|
||||
return data.toLocaleString("pt-BR", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
// Ao trocar a seleção, mostra a intenção completa e demais metadados do
|
||||
// plano escolhido — o rótulo do <option> já vem truncado.
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
var seletor = document.getElementById("editorialPlanIdInput");
|
||||
if (!seletor) return;
|
||||
seletor.addEventListener("change", function () {
|
||||
var detalhe = document.getElementById("editorialPlanIdDetalhe");
|
||||
if (!detalhe) return;
|
||||
var plano = planosEditoriaisListados.filter(function (p) { return String(p.id) === seletor.value; })[0];
|
||||
if (!plano) { detalhe.textContent = ""; return; }
|
||||
detalhe.textContent = "Origem: " + (plano.origem || "—") + " · " + plano.total_de_acoes + " ação(ões) · " +
|
||||
(plano.modelo_da_ia || "modelo não registrado") +
|
||||
(plano.intencao ? " — " + plano.intencao : "");
|
||||
});
|
||||
});
|
||||
|
||||
// Busca um plano pelo id escolhido na lista, sem depender do video_id da
|
||||
// sequência detectada. Existe para o caso em que o plano foi registrado para
|
||||
// uma mídia que este banco nunca escaneou (outro projeto/pasta) —
|
||||
// registrar_plano_de_edicao.py aceita gravar sem video_id quando não acha
|
||||
// correspondência em edicoes_de_video, e sem video_id o carregamento
|
||||
// automático por sequência (carregarPlanoEditorialSalvo) nunca encontraria
|
||||
// esse plano.
|
||||
function carregarPlanoEditorialPorId() {
|
||||
var campoId = document.getElementById("editorialPlanIdInput");
|
||||
var planoId = parseInt(campoId.value, 10);
|
||||
if (!planoId || planoId <= 0) {
|
||||
showToast("err", "Selecione um plano na lista antes de carregar.");
|
||||
return;
|
||||
}
|
||||
setBusy("btnLoadPlanById", true);
|
||||
silenceLog("Lendo o plano " + planoId + " diretamente do banco…");
|
||||
runStreamed(
|
||||
SCANNER_PYTHON_BIN,
|
||||
[CARREGAR_PLANO_SCRIPT, "--banco", ANALISES_DB_PATH, "--plano-id", String(planoId)],
|
||||
function (linha) { silenceLog(linha); },
|
||||
function (codigo, ultimoErro, ultimaSaida) {
|
||||
setBusy("btnLoadPlanById", false);
|
||||
if (codigo !== 0) {
|
||||
silenceLog("Não foi possível carregar o plano " + planoId + ".", "err");
|
||||
showToast("err", ultimoErro || "Plano " + planoId + " não encontrado no banco.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
var resposta = JSON.parse(ultimaSaida || "{}");
|
||||
if (!resposta.ok || !resposta.plano) throw new Error("Resposta inválida do banco.");
|
||||
// Plano sem video_id (ou de outra sequência) não tem como resolver a
|
||||
// mídia real pelo cadastro de clipes — usa a mídia detectada na
|
||||
// sequência ativa só quando o painel já a conhece; senão mantém o
|
||||
// "source" gravado no plano e deixa a engine (que lê a sequência
|
||||
// ativa do Premiere no momento da aplicação) resolver.
|
||||
if (silenceState.mediaPath) resposta.plano.source = silenceState.mediaPath;
|
||||
planoEditorialIdCarregado = resposta.plano.plano_id;
|
||||
document.getElementById("editorialPlanJson").value = JSON.stringify(resposta.plano, null, 2);
|
||||
document.getElementById("planoResumoEdicao").textContent = "Plano " + planoEditorialIdCarregado + " encontrado · " + resposta.plano.actions.length + " ação(ões) · aguardando aprovação.";
|
||||
document.getElementById("editorialSavedPlanStatus").textContent = "Plano " + planoEditorialIdCarregado + " carregado do banco (por ID).";
|
||||
document.getElementById("editorialPlanJsonStatus").textContent = "Plano salvo no SQLite; pronto para aprovação e aplicação.";
|
||||
document.getElementById("btnApplyEditorialActions").disabled = false;
|
||||
setStep(2, "done", "Plano carregado");
|
||||
setStep(3, "ready", "Pronto para aplicar");
|
||||
silenceLog("Plano " + planoEditorialIdCarregado + " carregado diretamente do SQLite (por ID).", "ok");
|
||||
showToast("ok", "Plano " + planoEditorialIdCarregado + " carregado.");
|
||||
} catch (erro) {
|
||||
showToast("err", erro.message);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Não gera decisões de corte: só empacota transcrição + tempos + locutores +
|
||||
// métricas de voz + configurações de edição num JSON para um agente de IA
|
||||
// externo analisar. Quem decide os cortes é a IA, não este painel.
|
||||
|
||||
@@ -63,12 +63,13 @@ class AplicadorDePlanoDeEdicao:
|
||||
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. 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.
|
||||
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
|
||||
@@ -106,9 +107,11 @@ 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."""
|
||||
self._indice_da_faixa_de_ajuste = None
|
||||
ganchos = plano.acoes_do_tipo(TipoDeAcao.GANCHO)
|
||||
pontuais = [
|
||||
acao for acao in plano.acoes
|
||||
@@ -132,8 +135,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(
|
||||
@@ -155,7 +158,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)
|
||||
@@ -164,9 +174,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.
|
||||
@@ -249,20 +262,36 @@ class AplicadorDePlanoDeEdicao:
|
||||
f"A faixa {faixa.identificador!r} não começa em 0s; o gancho foi recusado."
|
||||
)
|
||||
|
||||
alvos = [
|
||||
clipe
|
||||
for faixa in faixas_afetadas
|
||||
for clipe in self._clipes_do_arquivo(faixa, arquivo_de_origem)
|
||||
if self._sobrepoe(clipe, acao.inicio, acao.fim)
|
||||
]
|
||||
# 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. Por isso, ao contrário do fluxo antigo (que passava o
|
||||
# clipe inteiro que continha o trecho), aqui é preciso dividir
|
||||
# cada faixa nas bordas do gancho antes de duplicar — o mesmo
|
||||
# padrão de isolamento que ``_preparar_alvos_da_faixa`` usa para
|
||||
# cortes.
|
||||
alvos: list[Clipe] = []
|
||||
for faixa in faixas_afetadas:
|
||||
inicio_real = self._dividir_e_conferir(acao.inicio, faixa, arquivo_de_origem)
|
||||
fim_real = self._dividir_e_conferir(acao.fim, faixa, arquivo_de_origem)
|
||||
clipes_apos_a_divisao = self._clipes_do_arquivo(self._faixa_atualizada(faixa), arquivo_de_origem)
|
||||
isolados = [
|
||||
clipe for clipe in clipes_apos_a_divisao
|
||||
if inicio_real - _TOLERANCIA_DE_BORDA_EM_SEGUNDOS <= clipe.intervalo_na_timeline.inicio
|
||||
and clipe.intervalo_na_timeline.fim <= fim_real + _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],
|
||||
acao.inicio,
|
||||
acao.fim,
|
||||
)
|
||||
return ResultadoDaAcao(acao, True, "gancho vinculado duplicado no início")
|
||||
except ErroDeEdicao as erro:
|
||||
|
||||
@@ -117,44 +117,78 @@ class EscritaNoEditor:
|
||||
def duplicar_trecho_vinculado_para_o_inicio(
|
||||
self,
|
||||
identificadores_dos_clipes: list[str],
|
||||
inicio_na_origem: float,
|
||||
fim_na_origem: float,
|
||||
) -> None:
|
||||
"""Copia parte de um par de vídeo e áudio para o início da sequência.
|
||||
"""Copia para o início um par de vídeo e áudio já isolado por corte.
|
||||
|
||||
A ferramenta externa 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.
|
||||
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(
|
||||
"duplicate_linked_range_to_start",
|
||||
{
|
||||
"node_ids": identificadores_dos_clipes,
|
||||
"source_in_seconds": inicio_na_origem,
|
||||
"source_out_seconds": fim_na_origem,
|
||||
},
|
||||
{"node_ids": identificadores_dos_clipes},
|
||||
)
|
||||
except ErroDeFerramentaMCP as erro:
|
||||
raise ErroDeEscritaNoEditor(
|
||||
f"Não foi possível duplicar o par vinculado {identificadores_dos_clipes!r} no início: {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 garantir_faixa_de_ajuste(self) -> int:
|
||||
"""Cria uma faixa de vídeo nova, no topo da pilha, para hospedar camadas de ajuste de zoom.
|
||||
|
||||
Levanta :class:`ErroDeEscritaNoEditor` se o Premiere recusar a
|
||||
alteração.
|
||||
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(
|
||||
"set_clip_properties", {"node_id": identificador_do_clipe, "scale": fator_de_escala * 100}
|
||||
"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 no clipe {identificador_do_clipe!r}: {erro}"
|
||||
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:
|
||||
|
||||
@@ -182,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:
|
||||
@@ -196,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"):
|
||||
@@ -210,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)
|
||||
@@ -218,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
|
||||
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -6,7 +6,9 @@ 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``,
|
||||
``duplicate_linked_range_to_start``, ``set_clip_properties`` e ``add_marker``.
|
||||
``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
|
||||
@@ -63,6 +65,7 @@ class ClienteMCPFalso(ClienteMCP):
|
||||
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")],
|
||||
@@ -105,6 +108,12 @@ class ClienteMCPFalso(ClienteMCP):
|
||||
"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,
|
||||
"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:
|
||||
@@ -141,6 +150,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",
|
||||
@@ -148,8 +165,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}}
|
||||
|
||||
@@ -226,7 +249,13 @@ class ClienteMCPFalso(ClienteMCP):
|
||||
return {"removed": True, "verified": True}
|
||||
|
||||
def _simular_gancho_vinculado_no_inicio(self, argumentos: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Duplica um par isolado no início e preserva sua ocorrência original."""
|
||||
"""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():
|
||||
@@ -239,11 +268,11 @@ class ClienteMCPFalso(ClienteMCP):
|
||||
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()
|
||||
inicio_na_origem = float(argumentos["source_in_seconds"])
|
||||
fim_na_origem = float(argumentos["source_out_seconds"])
|
||||
duracao = fim_na_origem - inicio_na_origem
|
||||
duracao = next(iter(fins)) - next(iter(inicios))
|
||||
novos_ids: list[str] = []
|
||||
for identificador_da_faixa, clipe in encontrados:
|
||||
faixa = self._faixas[identificador_da_faixa]
|
||||
@@ -254,8 +283,8 @@ class ClienteMCPFalso(ClienteMCP):
|
||||
clipe.nome,
|
||||
0.0,
|
||||
duracao,
|
||||
inicio_na_origem,
|
||||
fim_na_origem,
|
||||
clipe.inicio_na_origem,
|
||||
clipe.fim_na_origem,
|
||||
)
|
||||
faixa.append(novo)
|
||||
faixa.sort(key=lambda item: item.inicio_na_timeline)
|
||||
@@ -267,6 +296,95 @@ class ClienteMCPFalso(ClienteMCP):
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -77,12 +77,12 @@ class TesteDuplicarTrechosVinculados(unittest.TestCase):
|
||||
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"], 10.0, 15.0)
|
||||
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"], 10.0, 15.0)
|
||||
escrita.duplicar_trecho_vinculado_para_o_inicio(["video", "audio"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -44,6 +44,13 @@ 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
|
||||
|
||||
@@ -11,6 +11,17 @@ 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.
|
||||
|
||||
Reference in New Issue
Block a user