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
- reorganizada a tela Scanner em blocos separados de imagem e som

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

 6 files changed, 92 insertions(+), 40 deletions(-)

Arquivos:
  - .jhonny/analises.db
  - code/cep-plugin/index.html
  - code/cep-plugin/main.js
  - code/cep-plugin/styles.css
  - code/engine/integracoes/whisper/provider_de_transcricao_local.py
  - code/engine/scanner/transcricao_da_timeline.py
  - code/engine/testes/test_provider_de_transcricao_local.py
This commit is contained in:
João Henrique
2026-09-09 16:57:29 -04:00
parent 245dcd8015
commit 53a8307f38
7 changed files with 154 additions and 40 deletions
+30 -19
View File
@@ -60,6 +60,7 @@
<span class="task-spinner" id="taskSpinner" aria-hidden="true"></span>
<strong class="task-title" id="taskTitle">Processando…</strong>
<span class="task-elapsed" id="taskElapsed">00:00</span>
<button type="button" class="task-cancel" id="taskCancel" onclick="taskCancel()" hidden>Cancelar</button>
<button type="button" class="task-copy" id="taskCopy" onclick="taskCopyError()" aria-label="Copiar erro técnico" title="Copiar erro técnico" hidden>⧉</button>
</div>
<div class="progress-track" id="taskTrack" data-mode="indeterminate">
@@ -259,8 +260,10 @@
<!-- ============================== SCANNER =============================== -->
<div class="tab-panel" id="tabScanner" role="tabpanel" aria-labelledby="tabBtnScanner" hidden>
<p class="tab-intro">Escolha o que será lido no arquivo original. O Scanner usa os tempos da timeline, mas não exporta frames pelo Premiere.</p>
<section class="card">
<div class="card-head"><h2>Amostragem e faixas</h2></div>
<div class="scanner-media-grid">
<section class="card scanner-media-card">
<div class="card-head"><h2>Imagem</h2><span class="card-kicker">Vídeo e cenas</span></div>
<p class="field-help">Escolha as faixas e os recursos visuais que o Scanner deve analisar.</p>
<label class="field-label" for="scannerInterval">Segundos por quadro</label>
<div class="range-field-row">
<input id="scannerInterval" type="range" min="0.2" max="10" step="0.1" value="1" oninput="scannerUpdateIntervalLabel()">
@@ -276,32 +279,40 @@
<p class="field-help">Reduzir a resolução acelera a extração e a leitura Vision sem perder precisão no rosto, pose ou objetos.</p>
<button class="button button-ghost" id="btnScannerDetectTracks" onclick="scannerDetectTracks()" type="button">Detectar faixas da sequência</button>
<p class="field-help" id="scannerTracksHelp">Abra a sequência desejada e detecte as faixas para selecioná-las.</p>
<div class="scanner-track-grid"><div><span class="field-label">Faixas de vídeo</span><div class="scanner-track-list" id="scannerVideoTracks"></div></div><div><span class="field-label">Faixas de áudio</span><div class="scanner-track-list" id="scannerAudioTracks"></div></div></div>
<div class="scanner-track-list"><span class="field-label">Faixas de vídeo</span><div id="scannerVideoTracks"></div></div>
<div class="scanner-subsection"><div class="card-head"><h3>O que detectar</h3></div>
<label class="checkbox-field"><input type="checkbox" data-scanner-resource="faces" checked><span>Rostos e qualidade facial</span></label>
<label class="checkbox-field"><input type="checkbox" data-scanner-resource="pessoas" checked><span>Pessoas</span></label>
<label class="checkbox-field"><input type="checkbox" data-scanner-resource="pose" checked><span>Pose e mãos</span></label>
<label class="checkbox-field"><input type="checkbox" data-scanner-resource="ocr" checked><span>Texto e códigos</span></label>
<label class="checkbox-field"><input type="checkbox" data-scanner-resource="categorias" checked><span>Objetos e categorias</span></label>
<label class="checkbox-field"><input type="checkbox" data-scanner-resource="estetica" checked><span>Estética, horizonte e geometria</span></label>
<label class="checkbox-field"><input type="checkbox" data-scanner-resource="segmentacao_de_pessoas" checked><span>Segmentação de pessoas</span></label>
</div>
<div class="scanner-subsection"><div class="card-head"><h3>Leitura temporal</h3></div>
<label class="checkbox-field"><input id="scannerScenes" type="checkbox" checked><span>Agrupar cenas</span></label>
<label class="checkbox-field"><input id="scannerContinuity" type="checkbox" checked><span>Medir continuidade e cortes</span></label>
<label class="checkbox-field"><input id="scannerReframe" type="checkbox"><span>Guardar geometria para reenquadramento</span></label>
<label class="checkbox-field"><input id="scannerInterpret" type="checkbox" checked><span>Descrever a cena com Foundation Models</span></label>
</div>
</section>
<section class="card"><div class="card-head"><h2>Transcrição</h2><span class="card-kicker" id="scannerModelCount">—</span></div>
<section class="card scanner-media-card">
<div class="card-head"><h2>Som</h2><span class="card-kicker">Áudio e fala</span></div>
<p class="field-help">Escolha as faixas de áudio e configure a transcrição local do Whisper.</p>
<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>
<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>
</div>
</section>
<div class="scanner-options-grid"><section class="card"><div class="card-head"><h2>O que detectar</h2></div>
<label class="checkbox-field"><input type="checkbox" data-scanner-resource="faces" checked><span>Rostos e qualidade facial</span></label>
<label class="checkbox-field"><input type="checkbox" data-scanner-resource="pessoas" checked><span>Pessoas</span></label>
<label class="checkbox-field"><input type="checkbox" data-scanner-resource="pose" checked><span>Pose e mãos</span></label>
<label class="checkbox-field"><input type="checkbox" data-scanner-resource="ocr" checked><span>Texto e códigos</span></label>
<label class="checkbox-field"><input type="checkbox" data-scanner-resource="categorias" checked><span>Objetos e categorias</span></label>
<label class="checkbox-field"><input type="checkbox" data-scanner-resource="estetica" checked><span>Estética, horizonte e geometria</span></label>
<label class="checkbox-field"><input type="checkbox" data-scanner-resource="segmentacao_de_pessoas" checked><span>Segmentação de pessoas</span></label>
</section>
<section class="card"><div class="card-head"><h2>Leitura temporal</h2></div>
<label class="checkbox-field"><input id="scannerScenes" type="checkbox" checked><span>Agrupar cenas</span></label>
<label class="checkbox-field"><input id="scannerContinuity" type="checkbox" checked><span>Medir continuidade e cortes</span></label>
<label class="checkbox-field"><input id="scannerReframe" type="checkbox"><span>Guardar geometria para reenquadramento</span></label>
<label class="checkbox-field"><input id="scannerInterpret" type="checkbox" checked><span>Descrever a cena com Foundation Models</span></label>
</div>
<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</button></div>
<p class="field-help" id="scannerProfileStatus">Perfil padrão pronto.</p>
<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>
</section>
</div>
<!-- ============================== RETAKES ============================== -->
<div class="tab-panel" id="tabRetakes" role="tabpanel" aria-labelledby="tabBtnRetakes" hidden>
+30 -8
View File
@@ -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) {
+11 -1
View File
@@ -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; }
@@ -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))
@@ -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)
@@ -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()