Escolha o que será lido no arquivo original. O Scanner usa os tempos da timeline, mas não exporta frames pelo Premiere.
- Amostragem e faixas
+
diff --git a/code/cep-plugin/main.js b/code/cep-plugin/main.js
index d5dbd68..a8dfc92 100755
--- a/code/cep-plugin/main.js
+++ b/code/cep-plugin/main.js
@@ -28,7 +28,7 @@ function log(msg, cls) {
// Antes, tudo que rodava só aparecia como linhas no log lá embaixo e a pessoa
// não sabia o que estava acontecendo. Agora todo processo longo abre um card
// reposicionado na aba de origem com etapa atual, barra e tempo decorrido.
-var taskState = { active: false, startedAt: 0, timer: null, clearTimer: null, mirror: null, originTab: "silence", originPanelId: "tabSilence", errorText: "" };
+var taskState = { active: false, startedAt: 0, timer: null, clearTimer: null, mirror: null, cancel: null, canceled: false, originTab: "silence", originPanelId: "tabSilence", errorText: "" };
var TIPOS_VIDEO_CHAVE = "schedule_light_tipos_video";
var tipoVideoAtualId = null;
@@ -75,11 +75,13 @@ function taskMoveToOriginTab() {
taskState.originPanelId = panel.id;
}
-function taskStart(title, stage, mirror) {
+function taskStart(title, stage, mirror, cancel) {
if (taskState.clearTimer) { clearTimeout(taskState.clearTimer); taskState.clearTimer = null; }
taskState.active = true;
taskState.startedAt = Date.now();
taskState.mirror = mirror || null;
+ taskState.cancel = typeof cancel === "function" ? cancel : null;
+ taskState.canceled = false;
taskState.originTab = activeTab();
taskState.errorText = "";
taskMoveToOriginTab();
@@ -96,6 +98,9 @@ function taskStart(title, stage, mirror) {
taskEl("taskCopy").hidden = true;
taskEl("taskErrorDetails").hidden = true;
taskEl("taskErrorDetails").textContent = "";
+ taskEl("taskCancel").hidden = !taskState.cancel;
+ taskEl("taskCancel").disabled = false;
+ taskEl("taskCancel").textContent = "Cancelar";
hideToast();
if (taskState.timer) clearInterval(taskState.timer);
@@ -127,12 +132,15 @@ function taskEnd(ok, message, technicalDetails) {
taskState.active = false;
var dock = taskEl("taskDock");
- dock.setAttribute("data-result", ok ? "ok" : "err");
+ var cancelado = taskState.canceled;
+ dock.setAttribute("data-result", cancelado ? "cancelled" : (ok ? "ok" : "err"));
taskEl("taskTrack").setAttribute("data-mode", "determinate");
taskEl("taskFill").style.width = "100%";
- taskEl("taskStage").textContent = message || (ok ? "Concluído" : "Falhou");
+ taskEl("taskStage").textContent = message || (cancelado ? "Cancelado" : (ok ? "Concluído" : "Falhou"));
taskEl("taskPct").textContent = ok ? "100%" : "";
- if (!ok) {
+ taskEl("taskCancel").hidden = true;
+ taskEl("taskCancel").disabled = false;
+ if (!ok && !cancelado) {
taskState.errorText = String(technicalDetails || message || "Falha sem detalhes técnicos.");
taskEl("taskCopy").hidden = false;
taskEl("taskErrorDetails").textContent = taskState.errorText;
@@ -141,6 +149,7 @@ function taskEnd(ok, message, technicalDetails) {
}
taskState.mirror = null;
+ taskState.cancel = null;
// O próprio card já carrega o desfecho e fica na tela por alguns segundos —
// repetir a mesma frase no toast só empilhava dois avisos idênticos.
taskState.clearTimer = setTimeout(function () {
@@ -148,6 +157,15 @@ function taskEnd(ok, message, technicalDetails) {
}, ok ? 4000 : 9000);
}
+function taskCancel() {
+ if (!taskState.active || typeof taskState.cancel !== "function") return;
+ taskState.canceled = true;
+ taskEl("taskCancel").disabled = true;
+ taskEl("taskCancel").textContent = "Cancelando…";
+ taskEl("taskStage").textContent = "Cancelando…";
+ try { taskState.cancel(); } catch (erro) { taskEnd(false, "Falha ao cancelar", erro.message); }
+}
+
function taskCopyError() {
var texto = taskState.errorText;
if (!texto) return;
@@ -1090,6 +1108,8 @@ var SCANNER_ALL_RESOURCES = ["faces", "qualidade_facial", "pessoas", "pose", "ma
var scannerTimeline = null;
var scannerReportPath = null;
+var scannerProcesso = null;
+var scannerCancelSolicitado = false;
var SCANNER_ENGINE_SCRIPT = "/Volumes/Merongo/SISTEMAS/GENIAL SISTEMAS/Jhonny/code/engine/executar_scanner.py";
// `path` e `os` são carregados mais abaixo, junto com o runtime Node do CEP.
@@ -1180,10 +1200,12 @@ function scannerProcess() {
var input=path.join(outputRoot,"pedido-"+stamp+".json");
scannerReportPath=path.join(outputRoot,"relatorio-"+stamp+".json");
fs.writeFileSync(input,JSON.stringify({perfil:profile,timeline:scannerTimeline},null,2),"utf-8");
- setBusy("btnScannerProcess",true); taskStart("Scanner", "Preparando análise", null);
+ setBusy("btnScannerProcess",true);
var spawnOptions={cwd:"/Volumes/Merongo/SISTEMAS/GENIAL SISTEMAS/Jhonny/code",stdio:["ignore","pipe","pipe"]};
var huggingFaceEnvironment=hfSpawnOptions(); if(huggingFaceEnvironment&&huggingFaceEnvironment.env) spawnOptions.env=huggingFaceEnvironment.env;
var child=childProcess.spawn(SCANNER_PYTHON_BIN,[SCANNER_ENGINE_SCRIPT,input,scannerReportPath],spawnOptions);
+ scannerProcesso=child; scannerCancelSolicitado=false;
+ taskStart("Scanner", "Preparando análise", null, function () { scannerCancelSolicitado=true; if (scannerProcesso) scannerProcesso.kill("SIGTERM"); });
var stderr="", stdout="";
child.stdout.on("data",function(chunk) {
stdout += chunk.toString(); var lines=stdout.split("\n"); stdout=lines.pop();
@@ -1191,7 +1213,7 @@ 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){setBusy("btnScannerProcess",false);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;}taskEnd(true,"Relatório JSON gerado");document.getElementById("scannerReportPath").textContent=scannerReportPath;document.getElementById("scannerReportCard").hidden=false;showToast("ok","Scanner concluído.");});
}
function scannerOpenReport() { if (!scannerReportPath) return; childProcess.spawn("open",[scannerReportPath],{detached:true,stdio:"ignore"}).unref(); }
@@ -3118,7 +3140,7 @@ function syncTranscribeModelSelect() {
modelosDisponiveis.forEach(function (modelo) {
var opt = document.createElement("option");
opt.value = modelo.nome;
- opt.textContent = modelo.nome + " · " + formatMB(modelo.tamanho);
+ opt.textContent = modelo.nome;
select.appendChild(opt);
});
if (!modelosDisponiveis.length) {
diff --git a/code/cep-plugin/styles.css b/code/cep-plugin/styles.css
index 39a2dca..5c2d41a 100755
--- a/code/cep-plugin/styles.css
+++ b/code/cep-plugin/styles.css
@@ -156,6 +156,12 @@ h1, h2, h3 { margin: 0; }
.scanner-track-list .checkbox-field { margin: 2px 0; padding: 5px; border: 0; background: none; }
.scanner-options-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; align-items: start; }
.scanner-options-grid .card { margin: 0 0 10px; }
+.scanner-media-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; align-items: start; }
+.scanner-media-card { margin: 0 0 10px; }
+.scanner-media-card h3 { margin: 0; font-size: 12px; }
+.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; }
/* --------------------- Painel de progresso (task dock) ------------------- */
.task-dock {
@@ -178,6 +184,9 @@ h1, h2, h3 { margin: 0; }
.task-elapsed { flex: 0 0 auto; color: var(--text-muted); font: 10px var(--font-mono); }
.task-copy { flex: 0 0 auto; padding: 2px 5px; border: 1px solid var(--border); border-radius: 4px; background: transparent; color: var(--text-secondary); cursor: pointer; font-size: 12px; line-height: 1; }
.task-copy:hover { border-color: var(--red); color: var(--text); }
+.task-cancel { flex: 0 0 auto; padding: 3px 8px; border: 1px solid rgba(255, 101, 101, .55); border-radius: 5px; background: transparent; color: #ffb0b0; cursor: pointer; font-size: 10px; }
+.task-cancel:hover { border-color: var(--red); background: rgba(255, 101, 101, .12); color: var(--text); }
+.task-cancel:disabled { cursor: wait; opacity: .65; }
.task-spinner {
width: 12px;
@@ -191,6 +200,7 @@ h1, h2, h3 { margin: 0; }
.task-dock[data-result] .task-spinner { animation: none; border: none; }
.task-dock[data-result="ok"] .task-spinner::before { content: "✓"; color: var(--green); font-size: 12px; font-weight: 700; }
.task-dock[data-result="err"] .task-spinner::before { content: "!"; color: var(--red); font-size: 12px; font-weight: 700; }
+.task-dock[data-result="cancelled"] .task-spinner::before { content: "×"; color: var(--text-muted); font-size: 14px; font-weight: 700; }
@keyframes spin { to { transform: rotate(360deg); } }
.progress-track {
@@ -647,7 +657,7 @@ button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-
.tab-button { font-size: 0; gap: 0; padding: 10px 2px; }
.tab-icon { font-size: 14px; }
.field-grid { grid-template-columns: minmax(0, 1fr); }
- .scanner-track-grid, .scanner-options-grid { grid-template-columns: minmax(0, 1fr); }
+ .scanner-track-grid, .scanner-options-grid, .scanner-media-grid { grid-template-columns: minmax(0, 1fr); }
.status-panel { grid-template-columns: 38px minmax(0, 1fr); }
.command-stat { grid-column: 2; padding: 8px 0 0; text-align: left; }
.command-stat strong, .command-stat span { display: inline; }
diff --git a/code/engine/integracoes/whisper/provider_de_transcricao_local.py b/code/engine/integracoes/whisper/provider_de_transcricao_local.py
index 3d0991c..ce05993 100644
--- a/code/engine/integracoes/whisper/provider_de_transcricao_local.py
+++ b/code/engine/integracoes/whisper/provider_de_transcricao_local.py
@@ -1,3 +1,5 @@
+"""Adapta o faster-whisper para a transcrição local do Scanner."""
+
from pathlib import Path
from typing import Any
@@ -5,23 +7,31 @@ from ...scanner.modelos import PalavraDeTranscricao, SegmentoDeTranscricao
class ProviderDeTranscricaoLocal:
- """Provider local usando faster-whisper, sem transmitir mídia."""
+ """Executa transcrição local em lote, sem transmitir a mídia."""
def __init__(self, modelo: str, dispositivo: str = "cpu", tipo_de_calculo: str = "int8",
- idioma: str = "pt") -> None:
+ idioma: str = "pt", tamanho_do_lote: int = 16) -> None:
+ """Carrega o modelo local e configura o tamanho do lote de inferência."""
+ if tamanho_do_lote < 1:
+ raise ValueError("O tamanho do lote deve ser maior que zero.")
self.nome_do_modelo = Path(modelo).name
if "faster-whisper-" in modelo:
self.nome_do_modelo = modelo.split("faster-whisper-", 1)[1].split("/", 1)[0]
- from faster_whisper import WhisperModel
+ from faster_whisper import BatchedInferencePipeline, WhisperModel
self.modelo = WhisperModel(modelo, device=dispositivo, compute_type=tipo_de_calculo)
+ self.pipeline = BatchedInferencePipeline(model=self.modelo)
self.idioma = idioma
+ self.tamanho_do_lote = tamanho_do_lote
def transcrever(self, clipe: Any) -> list[SegmentoDeTranscricao]:
+ """Transcreve o áudio com VAD, timestamps e inferência em lote."""
arquivo = Path(clipe.arquivo)
if not arquivo.is_file():
raise FileNotFoundError(f"Arquivo de áudio não encontrado: {arquivo}")
- segmentos, _ = self.modelo.transcribe(str(arquivo), language=self.idioma,
- vad_filter=True, word_timestamps=True)
+ segmentos, _ = self.pipeline.transcribe(
+ str(arquivo), language=self.idioma, vad_filter=True,
+ word_timestamps=True, batch_size=self.tamanho_do_lote,
+ )
return [SegmentoDeTranscricao(float(s.start), float(s.end), s.text.strip(),
None, tuple(PalavraDeTranscricao(w.word.strip(), float(w.start), float(w.end),
getattr(w, "probability", None))
diff --git a/code/engine/scanner/transcricao_da_timeline.py b/code/engine/scanner/transcricao_da_timeline.py
index 21802eb..b66da84 100644
--- a/code/engine/scanner/transcricao_da_timeline.py
+++ b/code/engine/scanner/transcricao_da_timeline.py
@@ -50,24 +50,23 @@ class TranscricaoDaTimeline:
clipes.append(clipe)
transcricoes: dict[str, list[SegmentoDeTranscricao]] = {}
+ chaves_por_arquivo: dict[str, str] = {}
for clipe in clipes:
- chave = self._chave_do_arquivo(clipe.arquivo)
+ chave = chaves_por_arquivo.get(clipe.arquivo)
+ if chave is None:
+ chave = self._chave_do_arquivo(clipe.arquivo)
+ chaves_por_arquivo[clipe.arquivo] = chave
if chave not in transcricoes:
transcricoes[chave] = self._transcrever_arquivo(clipe.arquivo, chave)
resultados: list[TranscricaoDoClipe] = []
- vistos: set[tuple[str, float, float, float, float]] = set()
for clipe in clipes:
intervalo = clipe.intervalo_na_timeline
origem = clipe.intervalo_na_origem
inicio_origem = origem.inicio if origem else 0.0
fim_origem = origem.fim if origem else float("inf")
- chave = (self._chave_do_arquivo(clipe.arquivo), inicio_origem, fim_origem,
+ chave = (chaves_por_arquivo[clipe.arquivo], inicio_origem, fim_origem,
intervalo.inicio, intervalo.fim)
- # Vídeo e áudio vinculados podem representar o mesmo corte.
- if chave in vistos:
- continue
- vistos.add(chave)
segmentos = []
for segmento in transcricoes[chave[0]]:
fim = min(segmento.fim, fim_origem)
diff --git a/code/engine/testes/test_provider_de_transcricao_local.py b/code/engine/testes/test_provider_de_transcricao_local.py
new file mode 100644
index 0000000..805b04a
--- /dev/null
+++ b/code/engine/testes/test_provider_de_transcricao_local.py
@@ -0,0 +1,62 @@
+"""Testes do provider local de transcrição em lote."""
+
+import sys
+import tempfile
+import types
+import unittest
+from pathlib import Path
+from types import SimpleNamespace
+from unittest.mock import patch
+
+from engine.integracoes.whisper import ProviderDeTranscricaoLocal
+
+
+class TesteDoProviderDeTranscricaoLocal(unittest.TestCase):
+ """Verifica a configuração e a conversão dos segmentos do provider."""
+
+ def test_usa_pipeline_em_lote_com_vad_e_timestamps_de_palavras(self):
+ """Mantém as opções de qualidade ao usar inferência em lote."""
+ chamadas = {}
+
+ class ModeloFalso:
+ """Representa o modelo externo durante o teste."""
+
+ def __init__(self, *argumentos, **opcoes):
+ chamadas["modelo"] = (argumentos, opcoes)
+
+ class PipelineFalso:
+ """Representa o pipeline em lote durante o teste."""
+
+ def __init__(self, model):
+ chamadas["pipeline_modelo"] = model
+
+ def transcribe(self, *argumentos, **opcoes):
+ chamadas["transcricao"] = (argumentos, opcoes)
+ palavra = SimpleNamespace(word="Olá", start=0.0, end=0.5, probability=0.99)
+ segmento = SimpleNamespace(start=0.0, end=0.5, text=" Olá ", words=[palavra])
+ return iter([segmento]), SimpleNamespace(duration=0.5)
+
+ modulo_falso = types.SimpleNamespace(
+ BatchedInferencePipeline=PipelineFalso,
+ WhisperModel=ModeloFalso,
+ )
+ with tempfile.TemporaryDirectory() as pasta:
+ arquivo = Path(pasta) / "audio.wav"
+ arquivo.write_bytes(b"audio")
+ with patch.dict(sys.modules, {"faster_whisper": modulo_falso}):
+ provider = ProviderDeTranscricaoLocal("small", tamanho_do_lote=8)
+ resultado = provider.transcrever(SimpleNamespace(arquivo=str(arquivo)))
+
+ self.assertEqual(resultado[0].texto, "Olá")
+ self.assertEqual(chamadas["transcricao"][1]["batch_size"], 8)
+ self.assertTrue(chamadas["transcricao"][1]["vad_filter"])
+ self.assertTrue(chamadas["transcricao"][1]["word_timestamps"])
+
+ def test_rejeita_tamanho_de_lote_invalido(self):
+ """Impede configuração silenciosa de um lote inválido."""
+ with self.assertRaises(ValueError):
+ ProviderDeTranscricaoLocal("small", tamanho_do_lote=0)
+
+
+if __name__ == "__main__":
+ unittest.main()