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:
@@ -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
@@ -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");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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."
|
||||
]
|
||||
}
|
||||
@@ -133,6 +133,95 @@ Premiere Pro
|
||||
|
||||
O Scanner produz fatos. A IA decide. A engine valida e aplica. A interface coordena e apresenta.
|
||||
|
||||
## 3.1. Fluxo pós-Scanner na tela Editar vídeo
|
||||
|
||||
Depois que o Scanner concluir, a tela **Editar vídeo** será a etapa de decisão
|
||||
e aplicação:
|
||||
|
||||
```text
|
||||
Scanner concluído
|
||||
↓
|
||||
Editar vídeo seleciona a análise do Scanner
|
||||
↓
|
||||
Usuário copia o JSON devolvido pela IA
|
||||
↓
|
||||
Cola o JSON no campo do plano
|
||||
↓
|
||||
Painel valida o formato e mostra as ações
|
||||
↓
|
||||
Usuário clica em Executar plano
|
||||
↓
|
||||
Painel salva um arquivo temporário UTF-8
|
||||
↓
|
||||
aplicar_plano_de_edicao.py
|
||||
↓
|
||||
LeitorDePlanoDeEdicao
|
||||
↓
|
||||
validação contextual e backup
|
||||
↓
|
||||
AplicadorDePlanoDeEdicao
|
||||
↓
|
||||
resultado ação por ação na tela
|
||||
```
|
||||
|
||||
O clipboard serve somente para transportar o texto JSON até o painel. Ele não
|
||||
deve ser executado como código nem enviado diretamente ao Premiere.
|
||||
|
||||
### Comportamento do campo de plano
|
||||
|
||||
Ao colar o JSON, a tela deve:
|
||||
|
||||
- fazer `JSON.parse` localmente;
|
||||
- verificar se existe `source` e `actions`;
|
||||
- mostrar quantidade e tipos de ações;
|
||||
- mostrar erros de estrutura antes de liberar o botão;
|
||||
- aceitar metadados extras como `schema`, `analysis` e `target`;
|
||||
- não modificar o plano colado silenciosamente;
|
||||
- salvar uma cópia com data e hora para auditoria.
|
||||
|
||||
### Comportamento do botão Executar plano
|
||||
|
||||
O botão deve chamar a entrada da engine, e não interpretar as ações no
|
||||
JavaScript. A entrada é:
|
||||
|
||||
```text
|
||||
code/engine/aplicar_plano_de_edicao.py <caminho-do-plano.json>
|
||||
```
|
||||
|
||||
Essa entrada usa as mesmas classes do fluxo de aplicação da engine:
|
||||
|
||||
- `LeitorDePlanoDeEdicao` lê e valida o contrato;
|
||||
- `BackupDaSequencia` cria uma cópia antes da mutação;
|
||||
- `ConversorDeTimeline` lê a sequência ativa;
|
||||
- `MapeadorDeTempoDeOrigemParaTimeline` encontra os clipes corretos;
|
||||
- `AplicadorDePlanoDeEdicao` ordena e aplica as ações;
|
||||
- `EscritaNoEditor` executa as alterações pelo bridge do Premiere;
|
||||
- `ResultadoDaAplicacao` devolve sucesso ou falha por ação.
|
||||
|
||||
O painel deve exibir o resultado recebido, incluindo ações aplicadas, ações
|
||||
que falharam, detalhes da falha e caminho do plano salvo.
|
||||
|
||||
### Diferença entre testar e executar
|
||||
|
||||
O fluxo **Testar ações** é um protótipo de validação manual do formato. O
|
||||
fluxo definitivo **Executar plano** deve usar a entrada Python da engine para
|
||||
compartilhar o mesmo domínio, mapeador, backup e aplicador. Não devem existir
|
||||
duas implementações diferentes para cortar a mesma timeline.
|
||||
|
||||
### Segurança e confirmação
|
||||
|
||||
Antes de iniciar:
|
||||
|
||||
- confirmar que a sequência correta está ativa;
|
||||
- conferir o arquivo de origem do plano;
|
||||
- conferir a quantidade de ações;
|
||||
- criar backup;
|
||||
- pedir confirmação quando houver cortes.
|
||||
|
||||
Durante a execução, o painel deve permitir cancelar o processo e informar se o
|
||||
cancelamento ocorreu antes ou depois de alguma ação. O resultado parcial deve
|
||||
ser preservado para diagnóstico.
|
||||
|
||||
## 4. Situação atual do código
|
||||
|
||||
### Já existe
|
||||
@@ -151,10 +240,10 @@ O Scanner produz fatos. A IA decide. A engine valida e aplica. A interface coord
|
||||
### Lacunas conhecidas
|
||||
|
||||
- `listar_falas_no_intervalo()` não retorna métricas acústicas por padrão.
|
||||
- Editar vídeo ainda trabalha principalmente com `transcricao.json` e não seleciona diretamente uma análise do Scanner.
|
||||
- Editar vídeo aceita o JSON de ações colado no painel ou carregado por arquivo e grava uma cópia local antes da aplicação.
|
||||
- O contexto editorial ainda não é um módulo explícito da engine.
|
||||
- O plano atual identifica a origem principalmente por nome e tempo; com várias faixas e clipes, precisa também de `video_id` e `clipe_id`.
|
||||
- Reprocessamentos precisam de uma política explícita para não duplicar segmentos no banco.
|
||||
- A validação contextual com `video_id`, versão da análise e `clipe_id` ainda precisa ser conectada ao aplicador.
|
||||
|
||||
## 5. Consultas SQL compactas
|
||||
|
||||
@@ -397,16 +486,58 @@ Novo comportamento a adicionar:
|
||||
|
||||
## 10. Fluxo da tela Editar vídeo
|
||||
|
||||
1. Selecionar origem: `Transcrever este vídeo` ou `Usar análise do Scanner`.
|
||||
2. Listar análises disponíveis e mostrar modelo, idioma, diarização, métricas e data.
|
||||
3. Escolher objetivo: depoimento, entrevista, redes sociais ou personalizado.
|
||||
4. Definir regras: remover silêncio, remover repetição, preservar pausas emocionais, locutores e intensidade.
|
||||
5. Carregar contexto compacto pela engine.
|
||||
6. Mostrar falas, tempos, locutores, métricas e status da análise.
|
||||
7. Exportar contexto para IA ou chamar um Provider de IA futuramente.
|
||||
8. Importar o plano devolvido.
|
||||
9. Mostrar cada ação com intervalo, texto, motivo, confiança e validação.
|
||||
10. Criar backup, validar e aplicar pela engine.
|
||||
O Scanner acontece antes da tela de edição. Dentro da tela **Editar vídeo**,
|
||||
o fluxo terá somente três etapas, mantendo a experiência já conhecida:
|
||||
|
||||
### Etapa 3 — Revisar falantes e áudio (opcional)
|
||||
|
||||
Esta etapa recebe a análise do Scanner e permite:
|
||||
|
||||
- revisar ou nomear falantes quando a diarização estiver disponível;
|
||||
- conferir a transcrição e os intervalos;
|
||||
- conferir métricas de voz quando tiverem sido calculadas;
|
||||
- definir ou revisar o perfil editorial;
|
||||
- definir o tipo e o objetivo do vídeo;
|
||||
- escolher regras como remoção de silêncios, repetições e hesitações.
|
||||
|
||||
Quando a edição usar somente transcrição, a revisão de falantes continua
|
||||
opcional. O botão de avanço não deve exigir diarização.
|
||||
|
||||
### Etapa 4 — Gerar arquivo JSON
|
||||
|
||||
Esta etapa reúne:
|
||||
|
||||
- a análise selecionada do Scanner;
|
||||
- as falas e palavras necessárias;
|
||||
- as métricas solicitadas;
|
||||
- o perfil editorial;
|
||||
- o tipo e objetivo do vídeo;
|
||||
- as regras de edição.
|
||||
|
||||
A engine gera o contexto editorial para a IA. Depois que a IA devolver o plano,
|
||||
o usuário poderá colar o JSON no campo da etapa ou selecionar um arquivo. O
|
||||
painel valida a estrutura e mostra a quantidade de `actions` antes de liberar
|
||||
a etapa seguinte.
|
||||
|
||||
O arquivo gerado/devolvido deve continuar sendo UTF-8 e conter `source` e
|
||||
`actions`, podendo também conter `analysis`, `schema`, `target` e metadados
|
||||
editoriais.
|
||||
|
||||
### Etapa 5 — Aplicar na timeline
|
||||
|
||||
Esta etapa:
|
||||
|
||||
- mostra o resumo das ações;
|
||||
- confirma a sequência ativa;
|
||||
- cria backup;
|
||||
- valida a origem e os intervalos;
|
||||
- chama `aplicar_plano_de_edicao.py`;
|
||||
- usa as classes oficiais da engine;
|
||||
- exibe o resultado ação por ação;
|
||||
- preserva o relatório em caso de falha ou cancelamento.
|
||||
|
||||
Não criar novas etapas para seleção de fonte, contexto ou aplicação. Essas
|
||||
responsabilidades devem aparecer dentro das três etapas existentes.
|
||||
|
||||
## 11. Classes e módulos planejados
|
||||
|
||||
|
||||
Reference in New Issue
Block a user