feat: removido o tamanho do modelo do seletor da tela Editar vídeo

- removido o tamanho do modelo do seletor da tela Editar vídeo
- acelerada a transcrição do Scanner com inferência Whisper em lote e cache de hashes
- garantido um resultado separado para cada clipe de áudio selecionado no Scanner
- adicionado cancelamento do processamento do Scanner pela barra de progresso
- exibido estado visual próprio para Scanner cancelado na barra de progresso
- criado modo explícito de análise do Scanner para separar som, imagem ou ambos
- adicionadas métricas opcionais de fala ao Scanner com análise acústica na engine
- corrigido o progresso da transcrição do Scanner durante o processamento do Whisper
- refatorado o runner Swift do Apple Vision para executar requests em lote com retry CPU-only, corrigindo falhas de CVPixelBuffer/ANE/OCR
- documentado o fluxo de edição de vídeo por voz integrado ao Scanner, banco e engine
- formalizado o protocolo de edição por chat com perfil editorial e consultas progressivas
- gerado plano JSON de corte para depoimento vertical da Erika no Instagram
- esquematizado o fluxo de colar e executar planos da IA pela engine após o Scanner
- ajustado o plano para manter somente as três etapas existentes da tela de edição
- adicionado processamento incremental de locutores e métricas no Scanner com confirmação de reprocessamento
- implementado o plano JSON colado na tela e a persistência idempotente das análises do Scanner

Resumo:
- 8 arquivos alterados
- 1 novos
- 7 modificados
- 0 removidos

 7 files changed, 437 insertions(+), 55 deletions(-)

Arquivos:
  - code/cep-plugin/index.html
  - code/cep-plugin/main.js
  - code/cep-plugin/styles.css
  - code/engine/executar_scanner.py
  - code/engine/persistencia/repositorio_de_analises_sqlite.py
  - code/engine/testes/test_persistencia_sqlite.py
  - docs/PLANO-EDICAO-DE-VIDEO-POR-VOZ.md
  - code/relatorios/plano-edicao-depoimento-erika-instagram.json
This commit is contained in:
João Henrique
2026-09-09 19:10:20 -04:00
parent c4c942044f
commit f6dd03f7b4
8 changed files with 526 additions and 55 deletions
+6 -2
View File
@@ -237,6 +237,9 @@
<span class="step-chip" id="step5Chip">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="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">
@@ -314,8 +317,8 @@
<div class="scanner-track-list"><span class="field-label">Faixas de áudio</span><div id="scannerAudioTracks"></div></div>
<div class="scanner-subsection"><div class="card-head"><h3>Transcrição</h3><span class="card-kicker" id="scannerModelCount">—</span></div>
<div class="field-grid"><div><label class="field-label" for="scannerTranscribeModel">Modelo instalado</label><select id="scannerTranscribeModel" class="select-field"></select></div><div><label class="field-label" for="scannerTranscribeLanguage">Idioma</label><select id="scannerTranscribeLanguage" class="select-field"></select></div></div>
<label class="checkbox-field"><input id="scannerDiarization" type="checkbox"><span>Identificar locutores após transcrever</span></label>
<label class="checkbox-field"><input id="scannerVoiceFeatures" type="checkbox"><span>Calcular métricas da fala</span></label>
<div class="scanner-option-row"><label class="checkbox-field"><input id="scannerDiarization" type="checkbox"><span>Identificar locutores após transcrever</span></label><button class="button button-mini button-ghost" id="btnScannerDiarization" onclick="scannerProcessarComplemento('diarizacao')" type="button" disabled>Processar</button></div>
<div class="scanner-option-row"><label class="checkbox-field"><input id="scannerVoiceFeatures" type="checkbox"><span>Calcular métricas da fala</span></label><button class="button button-mini button-ghost" id="btnScannerVoiceFeatures" onclick="scannerProcessarComplemento('metricas_de_fala')" type="button" disabled>Processar</button></div>
<p class="field-help">Mede pitch, energia, velocidade de fala e pausas por trecho. Não classifica emoção e tem custo baixo comparado à transcrição.</p>
<div class="hf-token-status" id="scannerHfStatus" data-state="unset"><span class="check-dot" aria-hidden="true"></span><span id="scannerHfStatusText">Hugging Face não configurado</span><button class="link-button" type="button" onclick="switchTab('settings')">Configurar</button></div>
<p class="field-help">A lista inclui somente modelos encontrados nas pastas locais configuradas. O token do Hugging Face é usado apenas na diarização; nunca é salvo no perfil nem no relatório.</p>
@@ -325,6 +328,7 @@
<section class="card scanner-execution-card">
<div class="button-row"><button class="button button-ghost" onclick="saveScannerProfile()" type="button">Salvar perfil</button><button class="button button-primary" id="btnScannerProcess" onclick="scannerProcess()" type="button" disabled>Processar áudio</button></div>
<p class="field-help" id="scannerProfileStatus">Perfil padrão pronto.</p>
<div class="callout callout-warning" id="scannerReprocessConfirm" hidden><strong>Este Scanner já foi processado</strong><p>Se continuar, a transcrição e as análises serão executadas novamente. Quer reprocessar o áudio?</p><div class="button-row"><button class="button button-primary" onclick="scannerConfirmarReprocessamento(true)" type="button">Reprocessar</button><button class="button button-ghost" onclick="scannerConfirmarReprocessamento(false)" type="button">Cancelar</button></div></div>
<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>
+105 -39
View File
@@ -1108,6 +1108,8 @@ var SCANNER_ALL_RESOURCES = ["faces", "qualidade_facial", "pessoas", "pose", "ma
var scannerTimeline = null;
var scannerReportPath = null;
var scannerRelatorioExistente = null;
var scannerReprocessamentoPendente = false;
var scannerProcesso = null;
var scannerCancelSolicitado = false;
var SCANNER_ENGINE_SCRIPT = "/Volumes/Merongo/SISTEMAS/GENIAL SISTEMAS/Jhonny/code/engine/executar_scanner.py";
@@ -1115,6 +1117,34 @@ var SCANNER_ENGINE_SCRIPT = "/Volumes/Merongo/SISTEMAS/GENIAL SISTEMAS/Jhonny/co
// `path` e `os` são carregados mais abaixo, junto com o runtime Node do CEP.
// Calcular isso aqui interrompia todo o script antes da inicialização do painel.
function scannerOutputRoot() { return path.join(os.homedir(), ".premiere-mcp", "scanner"); }
function scannerNomeSeguro(nome) { return String(nome || "timeline").replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "timeline"; }
function scannerCarregarRelatorioExistente() {
scannerRelatorioExistente = null;
if (!scannerTimeline || !scannerTimeline.name) return;
var caso = scannerNomeSeguro(scannerTimeline.name);
var caminho = path.join(scannerOutputRoot(), caso, caso + "-resultado.json");
try {
if (!fs.existsSync(caminho)) return;
var relatorio = JSON.parse(fs.readFileSync(caminho, "utf-8"));
if (relatorio.artefatos && relatorio.artefatos.audio && relatorio.artefatos.audio.length) { scannerRelatorioExistente = caminho; scannerReportPath = caminho; }
} catch (_) { scannerRelatorioExistente = null; }
}
function scannerAtualizarComplementos() {
var relatorio = scannerRelatorioExistente;
var arquivoDiarizacao = document.getElementById("btnScannerDiarization");
var arquivoMetricas = document.getElementById("btnScannerVoiceFeatures");
if (!relatorio) { if (arquivoDiarizacao) arquivoDiarizacao.disabled = true; if (arquivoMetricas) arquivoMetricas.disabled = true; return; }
var dados = {};
try { dados = JSON.parse(fs.readFileSync(relatorio, "utf-8")); } catch (_) {}
var transcricao = dados.perfil && dados.perfil.transcricao || {};
var audio = dados.artefatos && dados.artefatos.audio || [];
var metricas = !!(dados.perfil && dados.perfil.metricas_de_fala) || !!transcricao.metricas_de_fala;
var diarizacao = !!transcricao.diarizacao;
if (arquivoDiarizacao) { arquivoDiarizacao.disabled = diarizacao || !loadHfToken(); arquivoDiarizacao.textContent = diarizacao ? "Concluído" : "Processar"; }
if (arquivoMetricas) { arquivoMetricas.disabled = metricas || !audio.length; arquivoMetricas.textContent = metricas ? "Concluído" : "Processar"; }
}
function scannerSelectedTracks(kind) { return Array.prototype.slice.call(document.querySelectorAll("[data-scanner-track='" + kind + "']:checked")).map(function (item) { return Number(item.value); }).sort(function (a, b) { return a - b; }); }
@@ -1215,10 +1245,21 @@ function modelNameFromPath(folder) { var value=String(folder).replace(/.*faster-
function scannerDetectTracks() {
setBusy("btnScannerDetectTracks", true); document.getElementById("scannerTracksHelp").textContent = "Lendo as faixas da sequência ativa…";
cs.evalScript("(function(){try{var s=app.project.activeSequence;if(!s)return JSON.stringify({ok:false,error:'Nenhuma sequência ativa.'});function tracks(c,a){for(var i=0;i<c.numTracks;i++){var t=c[i],clips=[];for(var j=0;j<t.clips.numItems;j++){var x=t.clips[j],p=x.projectItem;clips.push({nodeId:x.nodeId,name:x.name,start:Number(x.start.seconds),end:Number(x.end.seconds),inPoint:Number(x.inPoint.seconds),outPoint:Number(x.outPoint.seconds),duration:Number(x.duration.seconds),sourceFile:p&&p.getMediaPath?p.getMediaPath():'',sourceOffline:p&&p.isOffline?p.isOffline():false});}a.push({index:i,name:t.name||('Faixa '+(i+1)),numClips:t.clips.numItems,clips:clips});}}var v=[],a=[];tracks(s.videoTracks,v);tracks(s.audioTracks,a);return JSON.stringify({ok:true,id:s.sequenceID,name:s.name,end:Number(s.end.seconds),videoTracks:v,audioTracks:a});}catch(e){return JSON.stringify({ok:false,error:String(e)});}}())", function(raw) { setBusy("btnScannerDetectTracks", false); var data; try { data=JSON.parse(raw); } catch (_) { data={ok:false,error:"Resposta inválida do Premiere."}; } if (!data.ok) { document.getElementById("scannerTracksHelp").textContent=data.error; showToast("err",data.error); return; } scannerTimeline=data; var saved; try { saved=JSON.parse(localStorage.getItem(SCANNER_PROFILE_KEY)||"null")||{}; } catch (_) { saved={}; } scannerRenderTracks("video",data.videoTracks,saved.faixas_de_video||data.videoTracks.map(function(t){return t.index;})); scannerRenderTracks("audio",data.audioTracks,saved.faixas_de_audio||[]); document.getElementById("scannerTracksHelp").textContent=data.name+": "+data.videoTracks.length+" faixa(s) de vídeo e "+data.audioTracks.length+" de áudio detectadas."; document.getElementById("btnScannerProcess").disabled=false; });
cs.evalScript("(function(){try{var s=app.project.activeSequence;if(!s)return JSON.stringify({ok:false,error:'Nenhuma sequência ativa.'});function tracks(c,a){for(var i=0;i<c.numTracks;i++){var t=c[i],clips=[];for(var j=0;j<t.clips.numItems;j++){var x=t.clips[j],p=x.projectItem;clips.push({nodeId:x.nodeId,name:x.name,start:Number(x.start.seconds),end:Number(x.end.seconds),inPoint:Number(x.inPoint.seconds),outPoint:Number(x.outPoint.seconds),duration:Number(x.duration.seconds),sourceFile:p&&p.getMediaPath?p.getMediaPath():'',sourceOffline:p&&p.isOffline?p.isOffline():false});}a.push({index:i,name:t.name||('Faixa '+(i+1)),numClips:t.clips.numItems,clips:clips});}}var v=[],a=[];tracks(s.videoTracks,v);tracks(s.audioTracks,a);return JSON.stringify({ok:true,id:s.sequenceID,name:s.name,end:Number(s.end.seconds),videoTracks:v,audioTracks:a});}catch(e){return JSON.stringify({ok:false,error:String(e)});}}())", function(raw) { setBusy("btnScannerDetectTracks", false); var data; try { data=JSON.parse(raw); } catch (_) { data={ok:false,error:"Resposta inválida do Premiere."}; } if (!data.ok) { document.getElementById("scannerTracksHelp").textContent=data.error; showToast("err",data.error); return; } scannerTimeline=data; scannerCarregarRelatorioExistente(); scannerAtualizarComplementos(); var saved; try { saved=JSON.parse(localStorage.getItem(SCANNER_PROFILE_KEY)||"null")||{}; } catch (_) { saved={}; } scannerRenderTracks("video",data.videoTracks,saved.faixas_de_video||data.videoTracks.map(function(t){return t.index;})); scannerRenderTracks("audio",data.audioTracks,saved.faixas_de_audio||[]); document.getElementById("scannerTracksHelp").textContent=data.name+": "+data.videoTracks.length+" faixa(s) de vídeo e "+data.audioTracks.length+" de áudio detectadas."; document.getElementById("btnScannerProcess").disabled=false; });
}
function scannerProcess() {
if (scannerRelatorioExistente) { scannerReprocessamentoPendente = true; document.getElementById("scannerReprocessConfirm").hidden = false; return; }
scannerIniciarProcessamento();
}
function scannerConfirmarReprocessamento(confirmado) {
document.getElementById("scannerReprocessConfirm").hidden = true;
if (confirmado && scannerReprocessamentoPendente) scannerIniciarProcessamento();
scannerReprocessamentoPendente = false;
}
function scannerIniciarProcessamento() {
if (!scannerTimeline) { showToast("err", "Primeiro detecte as faixas da sequência."); return; }
var profile; try { profile=scannerProfileFromScreen(); } catch (error) { showToast("err", error.message); return; }
if (profile.modo_de_analise === "som" && !profile.faixas_de_audio.length) { showToast("err", "Selecione ao menos uma faixa de áudio."); return; }
@@ -1242,7 +1283,27 @@ function scannerProcess() {
});
child.stderr.on("data",function(chunk){stderr+=chunk.toString();});
child.on("error",function(error){setBusy("btnScannerProcess",false);taskEnd(false,"Scanner indisponível",error.stack||error.message);showToast("err",error.message);});
child.on("close",function(code){var foiCancelado=scannerCancelSolicitado;scannerProcesso=null;setBusy("btnScannerProcess",false);if(foiCancelado){taskEnd(false,"Scanner cancelado");return;}if(code!==0||!fs.existsSync(scannerReportPath)){taskEnd(false,"Scanner falhou",stderr||"Não foi possível gerar o relatório.");showToast("err",stderr||"Não foi possível gerar o relatório.");return;}taskEnd(true,"Relatório JSON gerado");document.getElementById("scannerReportPath").textContent=scannerReportPath;document.getElementById("scannerReportCard").hidden=false;showToast("ok","Scanner concluído.");});
child.on("close",function(code){var foiCancelado=scannerCancelSolicitado;scannerProcesso=null;setBusy("btnScannerProcess",false);if(foiCancelado){taskEnd(false,"Scanner cancelado");return;}if(code!==0||!fs.existsSync(scannerReportPath)){taskEnd(false,"Scanner falhou",stderr||"Não foi possível gerar o relatório.");showToast("err",stderr||"Não foi possível gerar o relatório.");return;}scannerRelatorioExistente=scannerReportPath;scannerAtualizarComplementos();taskEnd(true,"Relatório JSON gerado");document.getElementById("scannerReportPath").textContent=scannerReportPath;document.getElementById("scannerReportCard").hidden=false;showToast("ok","Scanner concluído.");});
}
function scannerProcessarComplemento(acao) {
if (!scannerRelatorioExistente || !scannerTimeline) { showToast("err", "Processe o áudio uma vez antes de complementar este item."); return; }
var botao = document.getElementById(acao === "diarizacao" ? "btnScannerDiarization" : "btnScannerVoiceFeatures");
setBusy(botao.id, true);
var entrada = path.join(scannerOutputRoot(), "pedido-complemento-" + Date.now() + ".json");
var perfil; try { perfil = scannerProfileFromScreen(); } catch (error) { setBusy(botao.id, false); showToast("err", error.message); return; }
fs.writeFileSync(entrada, JSON.stringify({perfil: perfil, timeline: scannerTimeline}, null, 2), "utf-8");
var opcoes = {cwd: "/Volumes/Merongo/SISTEMAS/GENIAL SISTEMAS/Jhonny/code", stdio: ["ignore", "pipe", "pipe"]};
var hf = hfSpawnOptions(); if (hf && hf.env) opcoes.env = hf.env;
var processo = null;
taskStart("Complementando a análise do Scanner", acao === "diarizacao" ? "Detectando locutores" : "Calculando métricas de voz", null, function () {
if (processo) processo.kill("SIGTERM");
});
processo = childProcess.spawn(SCANNER_PYTHON_BIN, [SCANNER_ENGINE_SCRIPT, entrada, scannerRelatorioExistente, "--complemento", acao], opcoes);
var erro = "";
processo.stderr.on("data", function (bloco) { erro += bloco.toString(); });
processo.stdout.on("data", function (bloco) { bloco.toString().split("\n").forEach(function (linha) { try { var evento = JSON.parse(linha); if (evento.evento === "progresso") taskUpdate({stage: evento.etapa, detail: evento.clipe || "", pct: evento.percentual}); } catch (_) {} }); });
processo.on("close", function (codigo) { processo = null; setBusy(botao.id, false); if (taskState.canceled) { taskEnd(false, "Complemento cancelado"); return; } if (codigo !== 0) { taskEnd(false, "Falha ao processar o complemento.", erro || "Não foi possível processar este complemento."); showToast("err", erro || "Não foi possível processar este complemento."); return; } scannerAtualizarComplementos(); document.getElementById("scannerReportPath").textContent = scannerRelatorioExistente; document.getElementById("scannerReportCard").hidden = false; taskEnd(true, "Complemento concluído"); showToast("ok", acao === "diarizacao" ? "Locutores processados sem refazer a transcrição." : "Métricas da fala processadas sem refazer a transcrição."); });
}
function scannerOpenReport() { if (!scannerReportPath) return; childProcess.spawn("open",[scannerReportPath],{detached:true,stdio:"ignore"}).unref(); }
@@ -2363,6 +2424,7 @@ function runStreamed(bin, args, onLine, onDone, options) {
if (buffer.trim()) handleLine(buffer);
onDone(code, ultimoErro || null);
});
return child;
}
function silenceTranscribe() {
@@ -2785,38 +2847,44 @@ function resolveEditorialActionsScript() {
}
// ---- Aplicar o plano de edição devolvido pela IA (cut/zoom/text/marker) ----
function lerPlanoEditorialDaTela() {
var campoJson = document.getElementById("editorialPlanJson");
var campoCaminho = document.getElementById("editorialPlanPath");
var texto = String(campoJson && campoJson.value || "").trim();
var caminho = String(campoCaminho && campoCaminho.value || "").trim();
if (!texto && caminho) {
if (!fs.existsSync(caminho)) throw new Error("Arquivo do plano não encontrado.");
texto = fs.readFileSync(caminho, "utf-8");
}
if (!texto) throw new Error("Cole o JSON ou escolha um arquivo de plano.");
var plano;
try { plano = JSON.parse(texto); } catch (erro) { throw new Error("JSON inválido: " + erro.message); }
if (!plano || typeof plano !== "object" || !plano.source || !Array.isArray(plano.actions) || !plano.actions.length) {
throw new Error("O plano precisa ter source e uma lista não vazia de actions.");
}
plano.actions.forEach(function (acao, indice) {
if (!acao || !acao.kind || typeof acao.start !== "number" || typeof acao.end !== "number" || !acao.reason) {
throw new Error("actions[" + indice + "] precisa ter kind, start, end e reason.");
}
});
return plano;
}
function applyEditorialActions() {
if (!silenceState.sequenceName) {
silenceLog("Detecte o clipe/sequência primeiro (passo 1).", "err");
showToast("err", "Detecte o vídeo no passo 1 primeiro.");
return;
}
var planPath = document.getElementById("editorialPlanPath").value.trim();
if (!planPath) {
silenceLog("Informe o caminho do arquivo do plano (JSON de actions).", "err");
showToast("err", "Escolha o arquivo do plano de edição.");
return;
}
if (!fs.existsSync(planPath)) {
silenceLog("Arquivo não encontrado: " + planPath, "err");
showToast("err", "Arquivo não encontrado: " + path.basename(planPath));
return;
}
var plan;
try {
plan = JSON.parse(fs.readFileSync(planPath, "utf-8"));
} catch (e) {
silenceLog("Não foi possível ler o JSON do plano: " + e.message, "err");
showToast("err", "O arquivo não é um JSON válido.");
return;
}
var numActions = Array.isArray(plan.actions) ? plan.actions.length : 0;
if (!numActions) {
silenceLog("O plano não tem nenhuma action.", "err");
showToast("err", "O plano não tem nenhuma ação.");
try { plan = lerPlanoEditorialDaTela(); } catch (erroPlano) {
var statusPlano = document.getElementById("editorialPlanJsonStatus");
if (statusPlano) { statusPlano.textContent = erroPlano.message; statusPlano.className = "field-help err"; }
silenceLog(erroPlano.message, "err");
showToast("err", erroPlano.message);
return;
}
var numActions = plan.actions.length;
var summary =
numActions + " ação(ões) no plano (cortes, zooms, textos e/ou marcadores).\n\n" +
@@ -2836,26 +2904,24 @@ function applyEditorialActions() {
silenceLog("Backup criado.", "ok");
}
var applyScript = resolveEditorialActionsScript();
if (!applyScript) {
var caminhoDoPlano = path.join(silenceState.outputDir || os.tmpdir(), "plano-editorial-" + Date.now() + ".json");
try { fs.writeFileSync(caminhoDoPlano, JSON.stringify(plan, null, 2), "utf-8"); } catch (erroEscrita) {
setBusy("btnApplyEditorialActions", false);
setStep(5, "error", "Falhou");
taskEnd(false, "Script apply-editorial-actions.mjs não encontrado.");
silenceLog(
"Script apply-editorial-actions.mjs não encontrado. Rode scripts/install-cep.sh para gravar o repoRoot em " +
CONFIG_FILE + ".",
"err"
);
taskEnd(false, "Não foi possível salvar o plano.", erroEscrita.message);
return;
}
var nodeBin = resolveNodeBin();
silenceLog("Aplicando plano de edição na timeline…");
runStreamed(
nodeBin,
[applyScript, "--plan", planPath, "--sequence", silenceState.sequenceId || silenceState.sequenceName],
var processoAplicacao = null;
silenceLog("Aplicando plano pela engine Python…");
taskStart("Aplicando o plano de edição", "Executando pela engine Python", null, function () {
if (processoAplicacao) processoAplicacao.kill("SIGTERM");
});
processoAplicacao = runStreamed(
SCANNER_PYTHON_BIN,
[TESTAR_ACOES_ENGINE_SCRIPT, caminhoDoPlano],
function (line) { silenceLog(line); },
function (code, lastErr) {
processoAplicacao = null;
setBusy("btnApplyEditorialActions", false);
if (code !== 0) {
setStep(5, "error", "Falhou");
+4
View File
@@ -171,6 +171,10 @@ h1, h2, h3 { margin: 0; }
.scanner-subsection { margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--border); }
.scanner-subsection .card-head { margin-bottom: 7px; }
.scanner-execution-card { margin-top: 0; }
.scanner-option-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.scanner-option-row .checkbox-field { flex: 1; }
.button-mini { padding: 4px 8px; font-size: 10px; }
.editorial-plan-json { width: 100%; min-height: 150px; resize: vertical; box-sizing: border-box; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11px; line-height: 1.45; color: var(--text); background: var(--surface); border: 1px solid var(--border); border-radius: 6px; padding: 9px; }
/* --------------------- Painel de progresso (task dock) ------------------- */
.task-dock {
+92 -2
View File
@@ -8,6 +8,7 @@ from pathlib import Path
import sys
import tempfile
import re
from types import SimpleNamespace
# O painel chama este arquivo diretamente, portanto o Python inclui apenas
# ``code/engine`` no caminho de importação. O pacote público fica em ``code``.
@@ -167,7 +168,7 @@ def executar(entrada: Path, saida: Path) -> Path:
analisador_de_metricas_de_voz=analisador_de_metricas_de_voz).executar(
Timeline(timeline.identificador, timeline.nome, faixas=faixas_de_audio))
# Persiste a transcrição (segmentos + falas) no banco de análises.
repositorio_de_analises.registrar_transcricoes(timeline.identificador, transcricoes)
repositorio_de_analises.substituir_transcricoes(timeline.identificador, transcricoes)
for transcricao in transcricoes:
transcricoes_por_clipe[transcricao.identificador_do_clipe] = [
{"inicio": item.inicio, "fim": item.fim, "texto": item.texto,
@@ -193,9 +194,98 @@ def executar(entrada: Path, saida: Path) -> Path:
return saida
def executar_complemento(entrada: Path, relatorio: Path, acao: str) -> Path:
"""Processa locutores ou métricas usando a transcrição já persistida."""
pedido = json.loads(entrada.read_text(encoding="utf-8"))
dados_relatorio = json.loads(relatorio.read_text(encoding="utf-8"))
timeline = ConversorDeTimeline().converter(pedido["timeline"])
clipes = {clipe.identificador: clipe for faixa in timeline.faixas for clipe in faixa.clipes}
partes_por_arquivo: dict[str, list] = {}
diarizacao_por_parte: dict[str, list] = {}
if acao == "diarizacao":
if not os.environ.get("HF_TOKEN"):
raise RuntimeError("Token do Hugging Face não configurado para identificar locutores.")
from engine.integracoes.huggingface import ProviderDeDiarizacaoHuggingFace
diarizador = ProviderDeDiarizacaoHuggingFace(os.environ.get("HF_DIARIZATION_MODEL", MODELO_DIARIZACAO_PADRAO))
analisador = None
elif acao == "metricas_de_fala":
from engine.integracoes.audio import AnalisadorDeMetricasDeVoz
diarizador = None
analisador = AnalisadorDeMetricasDeVoz()
else:
raise ValueError(f"Complemento desconhecido: {acao}")
arquivos_audio = dados_relatorio["artefatos"].get("audio", [])
repositorio_de_analises = RepositorioDeAnalisesSQLite(CAMINHO_DO_BANCO)
total = max(1, len(arquivos_audio))
for indice_arquivo, nome_arquivo in enumerate(arquivos_audio, 1):
caminho_relatorio = relatorio.parent / nome_arquivo
dados_audio = json.loads(caminho_relatorio.read_text(encoding="utf-8"))
for grupo in dados_audio.get("segmentos_por_clipe", []):
clipe = clipes.get(grupo.get("clipe"))
if not clipe or not clipe.arquivo:
continue
caminho_origem = str(clipe.arquivo)
if caminho_origem not in partes_por_arquivo:
partes_por_arquivo[caminho_origem] = ExtracaoDeAudio().extrair(
caminho_origem, relatorio.parent / ".cache-audio-complementar" / str(abs(hash(caminho_origem))))
partes = partes_por_arquivo[caminho_origem]
inicio_origem = clipe.intervalo_na_origem.inicio if clipe.intervalo_na_origem else 0.0
fim_anterior = None
for segmento in grupo.get("segmentos", []):
inicio = inicio_origem + segmento["inicio"] - clipe.intervalo_na_timeline.inicio
fim = inicio_origem + segmento["fim"] - clipe.intervalo_na_timeline.inicio
parte = next((item for item in partes if item.inicio <= inicio <= item.fim), partes[-1])
inicio_local = max(0.0, inicio - parte.inicio)
fim_local = max(inicio_local, min(fim, parte.fim) - parte.inicio)
chave = str(parte.caminho)
if acao == "diarizacao":
if chave not in diarizacao_por_parte:
diarizacao_por_parte[chave] = diarizador.analisar(parte.caminho)
falas = diarizacao_por_parte[chave]
sobreposicoes = [(min(fim_local, saida) - max(inicio_local, entrada), falante)
for entrada, saida, falante in falas
if entrada < fim_local and saida > inicio_local]
segmento["falante"] = max(sobreposicoes, default=(0.0, None))[1]
repositorio_de_analises.atualizar_enriquecimento_de_transcricao(
timeline.identificador,
clipe.identificador,
segmento["inicio"],
segmento["fim"],
falante=segmento.get("falante"),
)
else:
palavras = [SimpleNamespace(inicio=0.0, fim=0.0)
for _ in segmento.get("texto", "").split()]
segmento["caracteristicas_acusticas"] = analisador.analisar(
parte.caminho, inicio_local, fim_local, palavras,
segmento["inicio"], fim_anterior)
repositorio_de_analises.atualizar_enriquecimento_de_transcricao(
timeline.identificador,
clipe.identificador,
segmento["inicio"],
segmento["fim"],
caracteristicas_acusticas=segmento["caracteristicas_acusticas"],
)
fim_anterior = segmento["fim"]
dados_audio.setdefault("transcricao", {})["diarizacao" if acao == "diarizacao" else "metricas_de_fala"] = True
caminho_relatorio.write_text(json.dumps(dados_audio, ensure_ascii=False, indent=2), encoding="utf-8")
print(json.dumps({"evento": "progresso", "etapa": "Complementando transcrição",
"percentual": round(100 * indice_arquivo / total, 1)}, ensure_ascii=False), flush=True)
dados_relatorio.setdefault("perfil", {}).setdefault("transcricao", {})[
"diarizacao" if acao == "diarizacao" else "metricas_de_fala"] = True
dados_relatorio.write_text(json.dumps(dados_relatorio, ensure_ascii=False, indent=2), encoding="utf-8")
return relatorio
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("entrada", type=Path)
parser.add_argument("saida", type=Path)
parser.add_argument("--complemento", choices=("diarizacao", "metricas_de_fala"))
args = parser.parse_args()
print(json.dumps({"evento": "concluido", "arquivo": str(executar(args.entrada, args.saida))}, ensure_ascii=False))
if args.complemento:
caminho = executar_complemento(args.entrada, args.saida, args.complemento)
else:
caminho = executar(args.entrada, args.saida)
print(json.dumps({"evento": "concluido", "arquivo": str(caminho)}, ensure_ascii=False))
@@ -65,6 +65,56 @@ class RepositorioDeAnalisesSQLite:
for segmento in transcricao.segmentos:
self._inserir_segmento(video_id, transcricao.identificador_do_clipe, segmento)
def substituir_transcricoes(
self, video_id: str, transcricoes: Iterable[TranscricaoDoClipe],
) -> None:
"""Substitui a transcrição somente dos clipes recebidos.
O método torna o reprocessamento idempotente sem apagar análises de
outros clipes ou faixas que não participaram da execução atual.
"""
with self.conexao:
self._garantir_video(video_id)
transcricoes_materializadas = tuple(transcricoes)
for transcricao in transcricoes_materializadas:
self.conexao.execute(
"""DELETE FROM segmentos_de_transcricao
WHERE video_id = ? AND clipe_id = ?""",
(video_id, transcricao.identificador_do_clipe),
)
for segmento in transcricao.segmentos:
self._inserir_segmento(video_id, transcricao.identificador_do_clipe, segmento)
def atualizar_enriquecimento_de_transcricao(
self,
video_id: str,
clipe_id: str,
inicio: float,
fim: float,
*,
falante: str | None = None,
caracteristicas_acusticas: dict[str, object] | None = None,
) -> None:
"""Atualiza diarização e métricas de um segmento já persistido."""
campos = []
parametros: list[object] = []
if falante is not None:
campos.append("falante = ?")
parametros.append(falante)
if caracteristicas_acusticas is not None:
campos.append("caracteristicas_acusticas = ?")
parametros.append(json.dumps(caracteristicas_acusticas, ensure_ascii=False))
if not campos:
return
parametros.extend((video_id, clipe_id, inicio, fim))
with self.conexao:
self.conexao.execute(
f"""UPDATE segmentos_de_transcricao
SET {', '.join(campos)}
WHERE video_id = ? AND clipe_id = ? AND inicio = ? AND fim = ?""",
parametros,
)
def _inserir_segmento(
self, video_id: str, clipe_id: str, segmento: SegmentoDeTranscricao,
) -> int:
@@ -156,6 +156,43 @@ class TesteRepositorioDeAnalisesSQLite(unittest.TestCase):
self.assertEqual(recarregado.caracteristicas_acusticas, {"pitch_medio": 120.5})
self.assertEqual(recarregado.voz_aparente, "masculina")
def test_substituir_transcricoes_nao_duplica_ao_reprocessar(self):
with tempfile.TemporaryDirectory() as pasta:
repositorio = RepositorioDeAnalisesSQLite(Path(pasta) / "analises.db")
primeiro = TranscricaoDoClipe(
"clip-1", "/v.mp4", 0.0, 2.0,
[SegmentoDeTranscricao(0.0, 1.0, "primeira versão", 0.9)],
)
segundo = TranscricaoDoClipe(
"clip-1", "/v.mp4", 0.0, 2.0,
[SegmentoDeTranscricao(0.0, 1.0, "segunda versão", 0.95)],
)
repositorio.substituir_transcricoes("v1", [primeiro])
repositorio.substituir_transcricoes("v1", [segundo])
carregados = repositorio.carregar_transcricoes("v1", "clip-1")
self.assertEqual(len(carregados), 1)
self.assertEqual(carregados[0].texto, "segunda versão")
def test_atualizar_enriquecimento_persiste_falante_e_metricas(self):
with tempfile.TemporaryDirectory() as pasta:
repositorio = RepositorioDeAnalisesSQLite(Path(pasta) / "analises.db")
segmento = SegmentoDeTranscricao(0.0, 2.0, "fala", 0.9)
repositorio.registrar_transcricoes(
"v1", [TranscricaoDoClipe("clip-1", "/v.mp4", 0.0, 2.0, [segmento])],
)
repositorio.atualizar_enriquecimento_de_transcricao(
"v1", "clip-1", 0.0, 2.0,
falante="SPEAKER_00",
caracteristicas_acusticas={"energia_rms": 0.12},
)
carregado = repositorio.carregar_transcricoes("v1", "clip-1")[0]
self.assertEqual(carregado.falante, "SPEAKER_00")
self.assertEqual(carregado.caracteristicas_acusticas, {"energia_rms": 0.12})
def test_registrar_evidencias_visuais_e_cenas(self):
with tempfile.TemporaryDirectory() as pasta:
repositorio = RepositorioDeAnalisesSQLite(Path(pasta) / "analises.db")
@@ -0,0 +1,89 @@
{
"schema": "plano_edicao_v1",
"source": "0E6A8829.MP4",
"analysis": {
"video_id": "12669c9c-038d-4a3f-99f9-fe6f6827b5f4",
"clip_id": "000f476b",
"profile": "depoimento",
"platform": "instagram",
"aspect_ratio": "9:16",
"target_duration_seconds": 60,
"maximum_duration_seconds": 120,
"voice_metrics_available": false,
"diarization_available": false
},
"editorial_intent": {
"video_type": "Depoimento de cliente",
"goal": "Gerar credibilidade e confiança",
"narrative": "problema vivido -> decisão de mudar -> resultado percebido",
"preserve": [
"autenticidade",
"fala espontânea",
"transformação pessoal",
"pausas naturais"
],
"remove": [
"perguntas do entrevistador",
"hesitações",
"repetições",
"desvios da narrativa"
],
"captions": "complete",
"broll": false,
"music": "discreta"
},
"preserved_ranges": [
{
"start": 256.3,
"end": 294.18,
"purpose": "Problema vivido e decisão de mudar de vida."
},
{
"start": 856.33,
"end": 879.55,
"purpose": "Resultado, autoestima e transformação percebida."
}
],
"actions": [
{
"id": "corte-001",
"kind": "cut",
"start": 0.0,
"end": 256.3,
"reason": "Remover introdução, preparação da entrevista e conteúdo anterior ao depoimento principal.",
"params": {
"tipo": "conteudo_fora_da_narrativa",
"confianca": 0.98
}
},
{
"id": "corte-002",
"kind": "cut",
"start": 294.18,
"end": 856.33,
"reason": "Remover perguntas do entrevistador, desenvolvimento intermediário e trechos fora do corte curto para Instagram.",
"params": {
"tipo": "selecao_de_narrativa",
"confianca": 0.86
}
},
{
"id": "corte-003",
"kind": "cut",
"start": 879.55,
"end": 1018.225542,
"reason": "Remover encerramento longo e recomendação final para manter o vídeo próximo de 60 segundos nesta primeira versão.",
"params": {
"tipo": "duracao_maxima",
"confianca": 0.9
}
}
],
"implementation_notes": [
"As ações usam segundos do arquivo de origem, não posições depois dos cortes.",
"O resultado estimado é de aproximadamente 61,1 segundos.",
"As legendas completas ainda precisam ser geradas pelo fluxo de legendagem; esta versão não cria clipes de texto automaticamente.",
"O plano não insere B-roll nem música porque essas ações ainda não fazem parte do contrato atual.",
"Revisar o primeiro e o último frame no Premiere antes de aplicar definitivamente."
]
}