feat: criado repositório jhonny-editor no Gitea

criado repositório jhonny-editor no Gitea
adicionado script admin/deploy.command com commit automático
atualizado admin/DEV-NOTES.md com template limpo

Resumo:
- 48 arquivos alterados
- 26 novos
- 19 modificados
- 3 removidos

 22 files changed, 658 insertions(+), 568 deletions(-)

Arquivos:
  - AGENTS.md
  - admin/DEV-NOTES.md
  - admin/OpenCut.command
  - admin/commit.command
  - admin/deploy.command
  - admin/update.command
  - code/cep-plugin/index.html
  - code/cep-plugin/main.js
  - code/cep-plugin/styles.css
  - code/engine/ARQUITETURA.md
  - code/engine/arquitetura/README.md
  - code/engine/gerar_relatorio_timeline.py
  - code/engine/integracoes/apple_speech/apple_speech_transcriber.swift
  - code/engine/integracoes/apple_speech/provider_de_transcricao_apple.py
  - code/engine/integracoes/midia/__init__.py
  - code/engine/integracoes/whisper/provider_de_transcricao_local.py
  - code/engine/scanner/__init__.py
  - code/engine/scanner/coordenacao/__init__.py
  - code/engine/scanner/descoberta/__init__.py
  - code/engine/scanner/modelos.py
  - code/engine/scanner/transcricao_da_timeline.py
  - code/src/tools/discovery.ts
  - :memory:.ses
  - admin/Jhonny.command
  - admin/inativos/OpenCut.command
  - admin/inativos/update.command
  - code/docs/glossario-analise-emocional.md
  - code/docs/levantamento-apple-vision-e-apple-intelligence.md
  - code/docs/levantamento-ferramentas-analise-visual-local.md
  - code/engine/arquitetura/biblioteca-inteligente-de-videos.md
  - code/engine/executar_scanner.py
  - code/engine/integracoes/huggingface/
  - code/engine/integracoes/midia/extracao_de_metadados.py
  - code/engine/integracoes/visual/
  - code/engine/requirements-visual.txt
  - code/engine/scanner/configuracao_visual.py
  - code/engine/scanner/descoberta/descoberta_de_arquivos.py
  - code/engine/scanner/metadados.py
  - code/engine/scanner/relatorio_visual.py
  - code/engine/scanner/retakes/
  - code/engine/scanner/visual.py
  - code/engine/testes/test_analise_visual_local.py
  - code/engine/testes/test_arquivos_e_metadados.py
  - code/engine/testes/test_provider_de_transcricao_apple.py
  - code/relatorios/analise-brools/
  - code/relatorios/analise-visual/
  - code/relatorios/audio/arquivos/
  - code/relatorios/transcricao-timeline.md
This commit is contained in:
João Henrique
2026-09-08 16:13:27 -04:00
parent b541f502ba
commit b9bf3b2863
76 changed files with 16147 additions and 322 deletions
+41
View File
@@ -25,6 +25,9 @@
<button class="tab-button active" id="tabBtnSilence" role="tab" aria-selected="true" aria-controls="tabSilence" onclick="switchTab('silence')" type="button">
<span class="tab-icon" aria-hidden="true">✂</span>Editar vídeo
</button>
<button class="tab-button" id="tabBtnScanner" role="tab" aria-selected="false" aria-controls="tabScanner" onclick="switchTab('scanner')" type="button">
<span class="tab-icon" aria-hidden="true">◉</span>Scanner
</button>
<button class="tab-button" id="tabBtnBridge" role="tab" aria-selected="false" aria-controls="tabBridge" onclick="switchTab('bridge')" type="button">
<span class="tab-icon" aria-hidden="true">⇄</span>Conexão
</button>
@@ -218,6 +221,44 @@
</div>
<!-- ============================== 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>
<label class="field-label" for="scannerInterval">Frames de vídeo</label>
<select id="scannerInterval" class="select-field"><option value="0.2">Precisa — 5 frames por segundo</option><option value="1" selected>Equilibrada — 1 frame por segundo</option><option value="5">Econômica — 1 frame a cada 5 segundos</option></select>
<p class="field-help">Use a amostragem precisa somente no trecho que será reenquadrado.</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>
</section>
<section class="card"><div class="card-head"><h2>Transcrição</h2><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>
</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 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>
</div>
<!-- ============================== CONEXÃO ============================== -->
<div class="tab-panel" id="tabBridge" role="tabpanel" aria-labelledby="tabBtnBridge" hidden>
+109
View File
@@ -148,6 +148,7 @@ function stepState(n) {
function switchTab(name) {
var tabs = [
{ key: "silence", panel: "tabSilence", btn: "tabBtnSilence" },
{ key: "scanner", panel: "tabScanner", btn: "tabBtnScanner" },
{ key: "bridge", panel: "tabBridge", btn: "tabBtnBridge" },
{ key: "models", panel: "tabModels", btn: "tabBtnModels" },
{ key: "settings", panel: "tabSettings", btn: "tabBtnSettings" },
@@ -164,8 +165,116 @@ function switchTab(name) {
if (name === "models") renderModelList();
if (name === "silence") { syncTranscribeModelSelect(); syncLanguageInfo(); }
if (name === "settings") renderEditorPersonalities();
if (name === "scanner") { renderScannerTranscriptionOptions(); loadScannerProfile(); renderScannerHuggingFaceStatus(); }
}
// ---- Perfil do Scanner -----------------------------------------------------
// O painel e o Engine compartilham este contrato versionado. O perfil não
// contém caminhos de mídia; estes continuam sendo resolvidos pelo MCP.
var SCANNER_PROFILE_KEY = "mcp_scanner_profile_v1";
var SCANNER_ALL_RESOURCES = ["faces", "qualidade_facial", "pessoas", "pose", "maos", "ocr", "codigos", "categorias", "estetica", "horizonte", "retangulos", "contornos", "segmentacao_de_pessoas"];
var scannerTimeline = null;
var scannerReportPath = null;
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.
// Calcular isso aqui interrompia todo o script antes da inicialização do painel.
function scannerOutputRoot() { return path.join(os.homedir(), ".premiere-mcp", "scanner"); }
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; }); }
function scannerRenderTracks(kind, tracks, selected) {
var target = document.getElementById(kind === "video" ? "scannerVideoTracks" : "scannerAudioTracks");
target.innerHTML = "";
if (!tracks.length) { target.textContent = "Nenhuma faixa encontrada."; return; }
tracks.forEach(function (track) { var label = document.createElement("label"); label.className = "checkbox-field"; var input = document.createElement("input"); input.type = "checkbox"; input.setAttribute("data-scanner-track", kind); input.value = track.index; input.checked = selected.indexOf(track.index) >= 0; label.appendChild(input); label.appendChild(document.createTextNode(" " + track.name + " · " + track.numClips + " clipe(s)")); target.appendChild(label); });
}
function scannerProfileFromScreen() {
var enabled = Array.prototype.slice.call(document.querySelectorAll("[data-scanner-resource]:checked")).map(function (item) { return item.getAttribute("data-scanner-resource"); });
function include(source, extras) { if (enabled.indexOf(source) >= 0) extras.forEach(function (item) { if (enabled.indexOf(item) < 0) enabled.push(item); }); }
include("faces", ["qualidade_facial"]); include("pose", ["maos"]); include("ocr", ["codigos"]); include("estetica", ["horizonte", "retangulos", "contornos"]);
var modelo = document.getElementById("scannerTranscribeModel").value;
var opcao = document.getElementById("scannerTranscribeModel").selectedOptions[0];
return { versao: 1, intervalo_em_segundos: Number(document.getElementById("scannerInterval").value), faixas_de_video: scannerSelectedTracks("video"), faixas_de_audio: scannerSelectedTracks("audio"), recursos_vision: SCANNER_ALL_RESOURCES.filter(function (item) { return enabled.indexOf(item) >= 0; }), detectar_cenas: document.getElementById("scannerScenes").checked, analisar_continuidade: document.getElementById("scannerContinuity").checked, preparar_reenquadramento: document.getElementById("scannerReframe").checked, interpretar_cena: document.getElementById("scannerInterpret").checked, transcricao: { modelo: modelo, caminho_modelo: opcao && opcao.dataset.path || "", idioma: document.getElementById("scannerTranscribeLanguage").value, diarizacao: document.getElementById("scannerDiarization").checked, usar_hugging_face: !!loadHfToken() } };
}
function saveScannerProfile() {
try { var profile = scannerProfileFromScreen(); localStorage.setItem(SCANNER_PROFILE_KEY, JSON.stringify(profile)); document.getElementById("scannerProfileStatus").textContent = "Perfil salvo: " + profile.intervalo_em_segundos + " s por frame; " + profile.faixas_de_video.length + " faixa(s) de vídeo."; showToast("ok", "Perfil do Scanner salvo."); } catch (error) { showToast("err", error.message || String(error)); }
}
function loadScannerProfile() {
var saved; try { saved = JSON.parse(localStorage.getItem(SCANNER_PROFILE_KEY) || "null"); } catch (_) { saved = null; }
if (!saved) return;
document.getElementById("scannerInterval").value = String(saved.intervalo_em_segundos || 1);
if (scannerTimeline) { scannerRenderTracks("video", scannerTimeline.videoTracks, saved.faixas_de_video || []); scannerRenderTracks("audio", scannerTimeline.audioTracks, saved.faixas_de_audio || []); }
var resources = saved.recursos_vision || SCANNER_ALL_RESOURCES;
Array.prototype.slice.call(document.querySelectorAll("[data-scanner-resource]")).forEach(function (item) { item.checked = resources.indexOf(item.getAttribute("data-scanner-resource")) >= 0; });
document.getElementById("scannerScenes").checked = saved.detectar_cenas !== false; document.getElementById("scannerContinuity").checked = saved.analisar_continuidade !== false; document.getElementById("scannerReframe").checked = !!saved.preparar_reenquadramento; document.getElementById("scannerInterpret").checked = saved.interpretar_cena !== false;
if (saved.transcricao) { document.getElementById("scannerTranscribeModel").value = saved.transcricao.modelo || ""; document.getElementById("scannerTranscribeLanguage").value = saved.transcricao.idioma || loadLanguage(); document.getElementById("scannerDiarization").checked = !!saved.transcricao.diarizacao && !!loadHfToken(); }
}
function renderScannerTranscriptionOptions() {
var modelos=document.getElementById("scannerTranscribeModel"), idiomas=document.getElementById("scannerTranscribeLanguage"); if (!modelos || !idiomas) return;
var atual=modelos.value; modelos.innerHTML=""; var disponiveis=discoverPortugueseModelsOnMerongo();
if (!disponiveis.length) { var vazio=document.createElement("option"); vazio.value=""; vazio.textContent="Nenhum modelo com português encontrado no Merongo"; modelos.appendChild(vazio); } else { disponiveis.forEach(function(item){var option=document.createElement("option");option.value=item.nome;option.dataset.path=item.caminho;option.textContent=item.nome+" · "+formatMB(item.tamanho);modelos.appendChild(option);}); if (atual) modelos.value=atual; }
idiomas.innerHTML=""; LANGUAGE_CATALOG.forEach(function(item){var option=document.createElement("option");option.value=item.value;option.textContent=item.label;idiomas.appendChild(option);}); idiomas.value=loadLanguage(); document.getElementById("scannerModelCount").textContent=disponiveis.length+" instalado(s)"; renderScannerHuggingFaceStatus();
}
function renderScannerHuggingFaceStatus() {
var status = document.getElementById("scannerHfStatus");
var text = document.getElementById("scannerHfStatusText");
var diarization = document.getElementById("scannerDiarization");
if (!status || !text || !diarization) return;
var available = !!loadHfToken();
status.setAttribute("data-state", available ? "valid" : "unset");
text.textContent = available ? "Hugging Face disponível para identificar locutores" : "Configure o token do Hugging Face para identificar locutores";
diarization.disabled = !available;
if (!available) diarization.checked = false;
}
var MERONGO_MODEL_ROOTS = ["/Volumes/Merongo/SISTEMAS/WHISPERX/whisper/models", "/Volumes/Merongo/SISTEMAS/MODELOSIA/hf-cache", "/Volumes/Merongo/Applications/WhisperX/huggingface/hub", "/Volumes/Merongo/Applications/Trasncritor", "/Volumes/Merongo/SISTEMAS/AUX"];
function discoverPortugueseModelsOnMerongo() {
var encontrados = {}, roots = [effectiveModelsPath()].concat(MERONGO_MODEL_ROOTS);
function walk(dir, profundidade) { if (profundidade > 5) return; var entries; try { entries=fs.readdirSync(dir,{withFileTypes:true}); } catch (_) { return; } entries.forEach(function(entry) { if (!entry.isDirectory() || entry.name === ".locks" || entry.name === ".git") return; var full=path.join(dir,entry.name); if (fs.existsSync(path.join(full,"model.bin")) && modelSupportsPortuguese(full)) { var nome=modelNameFromPath(full); if (!encontrados[nome]) encontrados[nome]={nome:nome,caminho:full,tamanho:folderSizeMB(full)}; return; } walk(full,profundidade+1); }); }
roots.filter(function(root,index){return root && roots.indexOf(root)===index;}).forEach(function(root){walk(root,0);});
return Object.keys(encontrados).sort().map(function(nome){return encontrados[nome];});
}
function modelSupportsPortuguese(folder) { try { var tokenizer=path.join(folder,"tokenizer.json"); if (!fs.existsSync(tokenizer)) return false; return fs.readFileSync(tokenizer,"utf-8").indexOf("<|pt|>") >= 0; } catch (_) { return false; } }
function modelNameFromPath(folder) { var value=String(folder).replace(/.*faster-whisper-/i,"").replace(/[\\/].*$/,""); return value || path.basename(folder); }
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; });
}
function scannerProcess() {
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.faixas_de_video.length && !profile.faixas_de_audio.length) { showToast("err", "Selecione ao menos uma faixa."); return; }
saveScannerProfile(); var outputRoot=scannerOutputRoot(); ensureDir(outputRoot);
var stamp=new Date().toISOString().replace(/[:.]/g,"-");
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);
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("python3",[SCANNER_ENGINE_SCRIPT,input,scannerReportPath],spawnOptions);
var stderr="", stdout="";
child.stdout.on("data",function(chunk) {
stdout += chunk.toString(); var lines=stdout.split("\n"); stdout=lines.pop();
lines.forEach(function(line) { try { var event=JSON.parse(line); if (event.evento === "progresso") taskUpdate({stage:event.etapa, detail:event.clipe || "", pct:event.percentual}); if (event.evento === "concluido" && event.arquivo) scannerReportPath=event.arquivo; } catch (_) {} });
});
child.stderr.on("data",function(chunk){stderr+=chunk.toString();});
child.on("error",function(error){setBusy("btnScannerProcess",false);taskEnd(false,"Scanner indisponível");showToast("err",error.message);});
child.on("close",function(code){setBusy("btnScannerProcess",false);if(code!==0||!fs.existsSync(scannerReportPath)){taskEnd(false,"Scanner falhou");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(); }
function scannerCopyReport() { if (!scannerReportPath) return; try { var copy=childProcess.spawn("pbcopy"); copy.stdin.end(fs.readFileSync(scannerReportPath,"utf-8")); showToast("ok","Conteúdo do relatório copiado."); } catch (error) { showToast("err",error.message); } }
// Mostra na aba de edição o idioma configurado, para não precisar ir até
// Modelos só para conferir com que idioma a transcrição vai rodar.
function syncLanguageInfo() {
+6
View File
@@ -145,6 +145,11 @@ h1, h2, h3 { margin: 0; }
.tab-button.active .tab-icon { color: var(--violet); opacity: 1; }
.tab-panel[hidden] { display: none; }
.tab-intro { margin: 0 0 12px; color: var(--text-muted); font-size: 10px; }
.scanner-track-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; margin-top: 10px; }
.scanner-track-list { min-height: 34px; max-height: 150px; overflow-y: auto; border: 1px solid var(--border); border-radius: 6px; background: var(--surface-deep); padding: 5px; }
.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; }
/* --------------------- Painel de progresso (task dock) ------------------- */
.task-dock {
@@ -584,6 +589,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); }
.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; }
+50
View File
@@ -0,0 +1,50 @@
# Glossário da análise emocional
## Modelo utilizado
O classificador local é `superb/wav2vec2-base-superb-er`, executado com
`transformers` e PyTorch. O modelo foi treinado para classificação de emoção
em fala e retorna quatro classes: `neu`, `hap`, `ang` e `sad`.
## Campos do JSON
### `emocao`
Classe emocional mais provável para o segmento de áudio.
### `confianca_emocao`
Probabilidade atribuída pelo classificador à classe escolhida, entre `0` e
`1`. Não é uma certeza nem uma medida de qualidade da transcrição. Um valor
de `0.58`, por exemplo, indica uma classificação fraca/moderada; valores
próximos de `1` indicam maior segurança relativa do modelo.
### `palavras`
Lista de palavras reconhecidas pelo Whisper, cada uma com seu intervalo de
tempo no arquivo original ou na timeline projetada.
### `caracteristicas_acusticas`
Campo reservado para medidas como pitch, energia, velocidade da fala e
pausas. Ainda não é preenchido pelo classificador atual.
## Glossário das classes
| Código | Significado | Interpretação editorial |
|---|---|---|
| `neu` | Neutral | Fala sem sinal emocional forte ou com expressão equilibrada. |
| `hap` | Happy | Sinal acústico associado a alegria, animação ou entusiasmo. |
| `ang` | Angry | Sinal acústico associado a irritação, tensão ou intensidade agressiva. |
| `sad` | Sad | Sinal acústico associado a tristeza, abatimento ou baixa energia. |
Os códigos são abreviações do modelo: `hap` significa *happy* e `ang`
significa *angry*. Eles não devem ser interpretados como diagnóstico do
estado emocional real da pessoa. Ruído, sotaque, idioma, contexto, atuação,
ironia e qualidade da gravação podem alterar a classificação.
## Decisão de uso no editor
A emoção é calculada por segmento/frase, enquanto as palavras mantêm seus
timestamps individuais. Isso permite usar a emoção como sinal editorial para
ordenar ou sugerir cortes sem atribuir uma emoção artificial a cada palavra.
@@ -0,0 +1,287 @@
# Levantamento: Apple Vision e Apple Intelligence para análise editorial de vídeo
Data: 2026-09-08
Escopo: analisar vídeos localmente, construir contexto confiável para o programa e, somente depois, pedir à IA uma seleção editorial revisável.
## Resumo executivo
A ideia é viável, mas a primeira entrega deve ser um **arquivo cronológico de contexto multimodal**, não um motor de decisão editorial. Esse arquivo será a fonte que posteriormente poderá ser lida pelo editor humano ou por uma IA de decisão.
O pipeline tem duas camadas:
1. **Apple Vision + AVFoundation** leem os frames e produzem fatos temporais: texto, rostos, pessoas, pose, códigos, saliência, qualidade, similaridade e movimento.
2. **Apple Intelligence via Foundation Models**, inicialmente opcional, pode transformar os fatos em descrições de cena e resumos. Mais adiante, outra chamada de IA poderá ler o arquivo completo e decidir cortes, permanências e ordem.
O arquivo de contexto não deve ser confundido com a decisão. A precisão temporal vem do nosso pipeline de frames e da transcrição; a IA pode interpretar e decidir depois. Toda decisão futura deverá apontar de volta para os intervalos e evidências do arquivo, para que o editor saiba “cortar ou manter” e por quê.
## O que cada tecnologia faz
### AVFoundation: acessar o vídeo com timestamp correto
AVFoundation deve ser a camada de decodificação. Ela lê o `AVAsset`, duração, taxa de frames, orientação, dimensões e amostras de vídeo (`CMSampleBuffer`/`CVPixelBuffer`). O ponto importante é preservar o timestamp do arquivo de origem e a transformação de orientação antes de entregar a imagem ao Vision.
O projeto hoje extrai uma amostra via OpenCV. Isso é suficiente para o primeiro protótipo, mas um helper Swift com AVFoundation será melhor quando precisarmos de timestamps exatos, inclusive VFR; leitura de frames próximos a cortes; orientação, HDR e color space explícitos; processamento em streaming; e `CVPixelBuffer` direto para Vision e Foundation Models.
`AVAssetReader` é a API oficial para ler dados de mídia de um `AVAsset`.
### Vision: fatos observáveis por frame
O Vision trabalha com o padrão “criar request → executar sobre imagem/frame → ler observations”. Também possui `VNSequenceRequestHandler` para requests que acompanham uma sequência de frames. As caixas usam coordenadas normalizadas; o adapter deve converter somente na borda do sistema e manter a convenção do domínio documentada.
| Capacidade | API Vision | Evidência útil para edição |
| --- | --- | --- |
| Texto em tela | `RecognizeTextRequest` / `VNRecognizeTextRequest` | texto, confiança, idioma e bounding box; localizar cartelas, placas, telas e erros de legenda |
| Regiões de texto | `DetectTextRectanglesRequest` | área de texto mesmo quando o OCR não consegue ler o conteúdo |
| Rostos | `DetectFaceRectanglesRequest` | quantidade, caixas e presença/posição de rosto |
| Landmarks faciais | `DetectFaceLandmarksRequest` | olhos, boca, sobrancelhas e contornos; útil para enquadramento e olhos fechados, não para inferir emoção |
| Qualidade facial | `DetectFaceCaptureQualityRequest` | sinal de nitidez/qualidade de captura do rosto |
| Pessoas | `DetectHumanRectanglesRequest` e requests de pessoa | presença, quantidade e ocupação aproximada |
| Pose corporal | `DetectHumanBodyPoseRequest` | juntas, posição e confiança; base para postura e ação simples |
| Mãos | `DetectHumanHandPoseRequest` | juntas da mão e confiança; base para gestos definidos por regras |
| Animais | `RecognizeAnimalsRequest` | presença de gato, cachorro e outros animais reconhecidos |
| Barcodes/QR | `DetectBarcodesRequest` | conteúdo, simbologia e localização |
| Classificação | `ClassifyImageRequest` / `VNClassifyImageRequest` | rótulos gerais e confiança; sinal de assunto, não verdade editorial |
| Similaridade visual | `GenerateImageFeaturePrintRequest` | distância entre frames; deduplicar quase-iguais e agrupar takes |
| Saliencia | `GenerateAttentionBasedSaliencyImageRequest` e `GenerateObjectnessBasedSaliencyImageRequest` | regiões que atraem atenção; apoiar crop e composição |
| Máscara de pessoa | `GeneratePersonInstanceMaskRequest` / segmentação | separar pessoa/fundo e medir ocupação |
| Estética | `CalculateImageAestheticsScoresRequest` | score auxiliar para escolher thumbnail ou frame representativo |
| Horizonte | `DetectHorizonRequest` | inclinação do horizonte; sinal técnico/compositivo |
| Movimento/tracking | `TrackObjectRequest`, `TrackRectangleRequest`, optical flow | continuidade de sujeito, deslocamento e movimento entre frames |
Essas APIs não entregam, sozinhas, “essa é a melhor tomada”, “a pessoa está nervosa” ou “este trecho deve entrar no corte”. Elas entregam observações e scores. Conceitos editoriais precisam ser derivados por regras, comparação temporal ou Foundation Models.
### Inventário ampliado da documentação
Além da tabela acima, o framework inclui estas famílias que podem entrar no Scanner conforme o caso:
| Família | O que pode ser extraído |
| --- | --- |
| Documentos | estrutura de documento, palavras, linhas, parágrafos, listas, tabelas, regiões de texto e códigos; útil para cenas com prontuário, formulário, receita ou tela organizada |
| Segmentação interativa | máscara a partir de pontos, retângulo ou rabisco; útil quando o usuário indicar manualmente o sujeito |
| Pose 3D | pontos do corpo humano em espaço 3D relativo à câmera; exige validar se o vídeo e o dispositivo fornecem resultado estável |
| Pose animal | pontos/partes do corpo de animais; útil para identificar ação em cenas com animais |
| Trajetórias | trajetória de formas em movimento parabólico; caso especializado, não um detector geral de ação |
| Contornos | linhas/arestas da imagem; útil para medir forma, desenho e mudanças bruscas, mas gera muitos dados |
| Retângulos | quadriláteros/regiões retangulares projetadas; útil para telas, documentos, quadros e superfícies |
| Mancha na lente | indício de smudge na lente em frame ou vídeo; alerta técnico |
| Registro de imagem | transformação translacional, homográfica ou alinhamento entre imagens; útil para comparar takes e estabilidade |
| Subject lifting | máscara de objetos perceptíveis ou pessoas para separar primeiro plano e fundo |
| Modelo customizado | `CoreMLRequest` executa um modelo Core ML escolhido pelo produto; as classes e atributos passam a depender do modelo distribuído por nós |
Os resultados normalmente são uma combinação de `confidence`, `boundingBox`/região normalizada, pontos/landmarks, rótulos, score, máscara, distância ou transformação. Para vídeo, cada resultado precisa receber o `timestamp` do frame; requests com estado devem usar o mesmo handler de sequência.
**O que o Vision não oferece pronto:** descrição livre de “o que está acontecendo”, emoção da fala, identidade da pessoa, intenção, qualidade narrativa, transcrição de áudio ou decisão de corte. Essas camadas vêm, respectivamente, de Foundation Models/VLM, análise de áudio, regras de privacidade, transcrição e um motor editorial posterior. O Vision pode fornecer os sinais que apoiam algumas dessas inferências, mas não deve receber esse significado como se fosse uma observação nativa.
Fontes: [Vision](https://developer.apple.com/documentation/vision), [VNSequenceRequestHandler](https://developer.apple.com/documentation/vision/vnsequencerequesthandler), [feature prints](https://developer.apple.com/documentation/vision/generateimagefeatureprintrequest) e [thumbnails de vídeo](https://developer.apple.com/documentation/vision/generating-thumbnails-from-videos).
### Foundation Models: interpretar evidências e gerar um plano
`FoundationModels` dá acesso ao `SystemLanguageModel`, o modelo de linguagem on-device que alimenta o Apple Intelligence, quando estiver disponível no dispositivo. Ele é bom para resumir transcrição e evidências; extrair entidades, assuntos e tags; classificar trechos segundo critérios fornecidos; comparar descrições de cenas; gerar JSON/Swift estruturado com `@Generable`; e propor títulos, capítulos, selects e versões para plataformas.
A documentação atual também descreve prompting multimodal: uma imagem pode ser anexada ao prompt e o modelo pode analisá-la. Isso permite enviar frames ou uma contact sheet, mas não transforma a sessão em um analisador de vídeo temporal. A orquestração de frames, timestamps e cenas continua sendo responsabilidade do nosso programa.
O desenho mais forte para nós é usar ferramentas: o modelo recebe contexto textual e pode chamar uma ferramenta controlada que consulta evidências Vision, OCR ou um índice local. A ferramenta retorna fatos; o modelo interpreta; o modelo não ganha permissão implícita para alterar o Premiere.
Limitações relevantes:
- verificar disponibilidade em runtime com `SystemLanguageModel.default.availability`;
- a janela on-device documentada é de 4096 tokens por sessão;
- registrar prompt e versão, pois o modelo muda com atualizações do sistema;
- validar respostas estruturadas; não usar texto livre como contrato de edição;
- limitar imagens por sessão, pois muitas imagens aumentam custo, latência e contexto;
- manter fallback quando o modelo local estiver indisponível.
Fontes: [Foundation Models](https://developer.apple.com/documentation/foundationmodels), [geração e tarefas](https://developer.apple.com/documentation/foundationmodels/generating-content-and-performing-tasks-with-foundation-models), [prompting multimodal](https://developer.apple.com/documentation/foundationmodels/analyzing-images-with-multimodal-prompting), [context window](https://developer.apple.com/documentation/technotes/tn3193-managing-the-on-device-foundation-model-s-context-window) e [SystemLanguageModel](https://developer.apple.com/documentation/foundationmodels/systemlanguagemodel).
## Como analisar frame a frame sem desperdiçar processamento
“Frame a frame” deve significar que cada frame escolhido tem timestamp e evidências próprias — não necessariamente processar todos os 24/30/60 frames por segundo na primeira passagem.
```text
Vídeo original
↓ AVFoundation / sampler
Frames de baixa cadência + frames ao redor de cortes
↓ Vision barato
Mapa temporal de cenas, movimento, qualidade, faces e similaridade
↓ seleção de candidatos
Frames representativos + contact sheet + transcrição por intervalo
↓ Foundation Models
Descrição, tags, ranking e plano editorial estruturado
↓ revisão
Operações Premiere com IDs e guards de revisão
```
**Passagem 1 — cobertura:** 1 frame por segundo, ou cadência ajustada à duração, mais frames imediatamente antes/depois de mudanças de cena. Usar qualidade, faces/pessoas, OCR curto, feature print e diferença entre frames.
**Passagem 2 — refinamento:** subdividir somente as cenas candidatas. Aumentar a cadência para localizar entrada/saída do sujeito, fala, gesto, olho fechado, blur, troca de enquadramento e continuidade.
**Passagem 3 — semântica:** enviar ao Foundation Models uma descrição compacta por cena e, quando necessário, uma contact sheet com poucos frames etiquetados por timestamp. A IA escolhe entre evidências já localizadas, não inventa intervalos.
O exemplo oficial da Apple para thumbnails combina score estético por amostra e feature prints para evitar frames visualmente semelhantes. É um bom ponto de partida para “melhor frame da cena”, mas o score estético deve continuar sendo somente um sinal auxiliar.
## Produto inicial: arquivo cronológico de contexto
O primeiro produto deve ser um arquivo único por projeto ou vídeo, ordenado cronologicamente. Ele funciona como um “roteiro técnico aumentado”: cada intervalo contém o que foi falado, o que aparece na imagem e os sinais que podem ajudar uma decisão futura.
Pode haver duas representações do mesmo conteúdo:
- `contexto-video.json`: formato completo, estável e consumível por máquina;
- `contexto-video.md` ou `.srt` enriquecido: leitura humana, com timestamp, transcrição e descrição da cena no mesmo bloco.
O JSON é a fonte de verdade. Markdown e SRT são visões derivadas e não devem substituir os IDs, timestamps, confiança e revisões do JSON.
Exemplo de unidade cronológica:
```json
{
"segment_id": "clip-07@42.100-49.800",
"source_id": "project-item-123",
"start": 42.1,
"end": 49.8,
"transcription": {
"text": "A frase falada neste intervalo.",
"speaker": "speaker-01",
"confidence": 0.93
},
"caption": {"text": "A frase falada neste intervalo."},
"visual": {
"description": "Pessoa em plano médio falando para a câmera.",
"objects": ["person", "microphone"],
"faces": 1,
"text_on_screen": [],
"composition": {"face_well_framed": true, "caption_area_available": true},
"technical_alerts": []
},
"speech_signals": {
"emotion": "calm",
"confidence": 0.61,
"provider": "speech_emotion_provider"
},
"representative_frames": [
{"frame_id": "clip-07@44.000", "timestamp": 44.0, "path": "frames/...jpg"}
],
"evidence_ids": ["clip-07@44.000:face-01"],
"status": "observed"
}
```
A descrição visual deve ser marcada como `observed`, `inferred` ou `unknown`. “Pessoa em plano médio” pode ser uma descrição apoiada por Vision; “parece confiante” é uma interpretação e deve carregar confiança e provider. Emoção de fala também é um sinal probabilístico, não um fato psicológico. Nunca esconder incerteza dentro de uma frase narrativa.
Esse arquivo permite ao editor navegar cronologicamente e responder, em uma etapa posterior:
- manter ou cortar este intervalo;
- escolher entre takes semelhantes;
- remover repetição, silêncio, erro técnico ou retake;
- preservar uma fala importante mesmo quando o frame não é o mais bonito;
- reorganizar trechos para uma intenção editorial específica.
## Contexto que o programa deve montar
Cada evidência deve ser persistida separadamente da interpretação:
```json
{
"evidence_id": "clip-07@12.480:face-01",
"clip_id": "clip-07",
"source_id": "project-item-123",
"timestamp": 12.48,
"interval": {"start": 12.0, "end": 13.0},
"kind": "face",
"value": {"bounding_box": {"x": 0.31, "y": 0.18, "width": 0.22, "height": 0.42}},
"confidence": 0.97,
"provider": "apple_vision",
"model_or_revision": "vision-revision-x",
"source_revision": "sha256:..."
}
```
O arquivo deve conter intenção opcional do usuário; metadados do clipe e da timeline; transcrição temporal; legenda correspondente; intervalos de cena; OCR consolidado; presença/posição de pessoas e rostos; qualidade e composição; movimento, similaridade e possíveis retakes; sinais de emoção da fala; frames representativos; limitações, falhas de provider e confiança.
Não enviar uma lista gigantesca de observações repetidas. Consolidar eventos contínuos, por exemplo `rosto presente de 12.0s a 18.4s`, mantendo os frames brutos para auditoria.
## Etapa posterior: decisão editorial por IA
Depois que o arquivo cronológico estiver validado, podemos entregá-lo a uma IA de decisão. Essa IA será complementar ao contexto, não parte obrigatória da primeira análise. Ela poderá receber:
1. o JSON completo, quando couber no contexto;
2. chunks cronológicos com estado resumido entre eles;
3. uma intenção editorial, como “vídeo de 60 segundos para apresentação”;
4. regras explícitas, como preservar falas sobre determinado assunto;
5. autorização para produzir somente uma decisão ou também um plano de edição.
Quando o arquivo for grande, não devemos simplesmente truncá-lo. O programa deve fazer chunking cronológico, consolidar o estado e gerar um resultado final com referências globais aos `segment_id` e `evidence_ids` originais.
## Contrato da saída editorial
A saída posterior da IA de decisão deve ser um artefato de planejamento, não uma edição aplicada:
```json
{
"scene_id": "scene-04",
"decision": "candidate",
"score": 0.84,
"reasons": ["fala cobre o tema", "rosto bem enquadrado", "sem alerta técnico"],
"selected_range": {"start": 42.1, "end": 49.8},
"evidence_ids": ["clip-07@42.1:...", "clip-07@47.0:..."],
"warnings": [],
"requires_review": true
}
```
Regras: `selected_range` só aponta para timestamps/evidências existentes; toda decisão tem `evidence_ids`; ausência de evidência vira `unknown`; a IA sugere trim, descarte, agrupamento e ordem, mas não escreve diretamente no projeto; o aplicador confere `source_revision` e `timeline_revision`; decisões de alto impacto exigem confirmação.
Isso encaixa no que já existe em `EditorialContextPack` e `EditorialPlan`: ambos são revision-aware, citáveis e read-only antes da aplicação.
## Situação atual do projeto
Já temos `QuadroDeVideo` com timestamp, índice, dimensões, imagem e caminho; `AnalisadorDeFrame` e `AnalisadorDeSequenciaDeQuadros`; `DetectorDeCenas`; análises OpenCV de qualidade, composição, movimento e continuidade; adapter inicial `AnalisadorAppleVision` para OCR e faces via PyObjC; adapters ONNX/Core ML e MediaPipe; e contexto editorial/plano com evidência, revisões e revisão obrigatória.
Gaps para a implementação Apple completa:
1. ampliar o adapter Vision para pose, mãos, qualidade facial, feature print, saliência, pessoa e estética;
2. adicionar runner Swift/AVFoundation para timestamps e `CVPixelBuffer`, mantendo PyObjC como protótipo;
3. consolidar evidências contínuas e gerar contact sheets etiquetadas;
4. criar `FoundationModelsProvider` separado, com disponibilidade, timeout, versão, token budget e saída tipada;
5. criar store de frames/evidências por hash do arquivo e revision;
6. testar orientação, VFR, HDR, vertical/horizontal e falha parcial de requests;
7. medir precisão editorial em fixtures reais antes de permitir aplicação automática.
## Ordem recomendada
### Fase 1 — Vision determinístico
Implementar OCR, faces, pessoas, qualidade, feature print e estética. O resultado deve ser JSON versionado por frame. Manter OpenCV para métricas existentes e comparar resultados, em vez de substituir tudo de uma vez.
### Fase 2 — cenas e selects
Combinar cortes, similaridade e qualidade para produzir cenas e frames representativos. Criar visualização de auditoria com timeline, thumbnail, timestamp, observações e confiança.
### Fase 3 — descrições de cena
Gerar descrições visuais sincronizadas com os intervalos da transcrição e produzir o JSON cronológico. Começar com descrições baseadas nos fatos Vision; usar Foundation Models somente para transformar evidências em linguagem curta, com marcação de confiança e origem.
### Fase 4 — Apple Intelligence como decisão complementar
Enviar o arquivo cronológico ou seus chunks para uma IA separada. Começar por resumo e tags; depois manter/cortar; por fim plano de edição com saída guiada e validação estrita. Se o modelo estiver indisponível, o Scanner continua entregando o arquivo completo de contexto.
### Fase 5 — Premiere
Usar o plano existente como camada de revisão. Somente depois da confirmação chamar operações de organização, stringout, rough cut, marcadores ou legendas, sempre com readback.
## Decisão
Devemos implementar **Vision como os olhos do Scanner** e um **arquivo cronológico multimodal como o produto central da primeira fase**. O Foundation Models pode ajudar a escrever as descrições de cena. Mais tarde, uma IA de decisão poderá ler esse arquivo e sugerir “corta/mantém/usa este take”, sempre apontando para os intervalos e evidências que justificam a escolha.
Essa arquitetura aproveita o Apple Silicon, preserva privacidade, funciona com o modelo local quando disponível e mantém fallback para OpenCV/ONNX/Whisper e outros hosts. Também evita confundir Apple Intelligence com Media Intelligence do Premiere: são produtos e APIs diferentes.
## Fontes oficiais
- [Apple Vision](https://developer.apple.com/documentation/vision)
- [Processar vídeo e gerar thumbnails](https://developer.apple.com/documentation/vision/generating-thumbnails-from-videos)
- [AVAssetReader](https://developer.apple.com/documentation/avfoundation/avassetreader)
- [Foundation Models](https://developer.apple.com/documentation/foundationmodels)
- [Analisar imagens com prompting multimodal](https://developer.apple.com/documentation/foundationmodels/analyzing-images-with-multimodal-prompting)
- [Gerar conteúdo e executar tarefas](https://developer.apple.com/documentation/foundationmodels/generating-content-and-performing-tasks-with-foundation-models)
- [Janela de contexto do modelo on-device](https://developer.apple.com/documentation/technotes/tn3193-managing-the-on-device-foundation-model-s-context-window)
- [SystemLanguageModel](https://developer.apple.com/documentation/foundationmodels/systemlanguagemodel)
- [Disponibilidade e dispositivos compatíveis com Apple Intelligence](https://support.apple.com/en-us/121115)
@@ -0,0 +1,217 @@
# Levantamento de ferramentas locais para análise visual
Data: 2026-09-08
Escopo: escolher a base para implementar um `ProviderDeAnaliseVisual` real no `engine/scanner`.
## Recomendação executiva
Adotar uma arquitetura em camadas:
1. **Apple Vision + Core ML** como provider principal para macOS: OCR, rostos, pessoas, poses, códigos, classificação, qualidade e similaridade visual.
2. **PySceneDetect** para detectar cortes e delimitar cenas no arquivo de origem.
3. **ONNX Runtime com CoreML Execution Provider** como runtime de modelos customizados, mantendo a possibilidade de usar os mesmos modelos em outras plataformas.
4. **MediaPipe Tasks** como alternativa especializada para pose, mãos, rosto e rastreamento quando o resultado do Vision não for suficiente.
5. **Modelo multimodal local** somente como uma etapa semântica opcional, posterior às análises determinísticas; não deve ser a fonte primária de eventos temporais.
Essa composição aproveita o Apple Silicon sem enviar mídia para a nuvem, separa fatos observáveis de interpretação e permite evoluir os modelos sem alterar o contrato do Scanner.
## Critérios usados
- execução local e offline;
- integração com macOS/Apple Silicon;
- possibilidade de obter intervalos temporais e confiança;
- suporte a frames, vídeo e processamento em lote;
- integração com Python ou bridge nativa pequeno;
- licença adequada para produto comercial;
- maturidade e manutenção do projeto;
- adequação ao nosso domínio: cenas, objetos, pessoas, OCR, qualidade e possíveis retakes.
## Candidatos
### 1. Apple Vision + Core ML — recomendação principal
O Vision oferece APIs pré-treinadas para análise de imagens e vídeo, incluindo texto, códigos, rostos, pessoas, poses, classificação, qualidade e similaridade visual. O processamento ocorre no dispositivo. Core ML executa modelos usando CPU, GPU e Neural Engine, também sem exigir conexão de rede.
**Pontos fortes**
- melhor alinhamento com o ambiente macOS existente;
- privacidade e execução local;
- bons blocos prontos para OCR, faces, pessoas, poses e qualidade;
- suporte nativo a tracking entre frames;
- possibilidade de usar modelos próprios convertidos para Core ML;
- sem dependência de um servidor Python pesado.
**Limitações**
- a API é Swift/Objective-C, não Python;
- será necessário um processo auxiliar Swift ou uma pequena bridge JSON;
- não substitui um detector geral de objetos com classes customizadas;
- os resultados são observações de visão, não uma noção editorial pronta de “take bom” ou “retake”.
**Uso recomendado no Scanner**
- `ProviderDeAnaliseVisualApple` executando um helper Swift;
- entrada: arquivo + lista de timestamps;
- saída: observações por frame, cada uma com `timestamp`, tipo, bounding box, label, confiança e provider;
- usar `VNGenerateImageFeaturePrintRequest`/similaridade visual para apoiar comparação de takes;
- usar análise de qualidade para foco, exposição e problemas técnicos quando disponível.
Fontes: [Apple Vision](https://developer.apple.com/documentation/vision), [detecção de objetos no Vision](https://developer.apple.com/documentation/vision/detecting_objects_in_still_images), [reconhecimento de texto](https://developer.apple.com/documentation/vision/recognizing-text-in-images) e [Apple Core ML](https://developer.apple.com/documentation/coreml).
### 2. PySceneDetect — recomendação para cortes de cena
Biblioteca Python/BSD-3 para detectar mudanças de cena. Possui `ContentDetector`, `AdaptiveDetector` e `ThresholdDetector`, retorna pares de início/fim e oferece integração com FFmpeg.
**Pontos fortes**
- integração direta com o engine Python;
- contrato temporal já próximo do nosso `Cena`;
- simples de testar com arquivos reais e fixtures;
- licença BSD-3-Clause;
- mais apropriado para “onde a cena muda” do que um VLM.
**Limitações**
- detecta transições visuais, não entende o conteúdo sem um segundo modelo;
- pode gerar falsos positivos com movimento de câmera, flashes e mudanças de iluminação;
- deve operar sobre o arquivo de origem e depois ser projetado para os intervalos da timeline.
Fonte: [repositório oficial PySceneDetect](https://github.com/Breakthrough/PySceneDetect).
### 3. ONNX Runtime + CoreML Execution Provider — recomendação como runtime extensível
ONNX Runtime fornece API Python e permite executar modelos ONNX com `CoreMLExecutionProvider`. As wheels oficiais para macOS incluem o provider Core ML; há opções para CPU, GPU e Neural Engine quando suportadas pelo modelo e pelo dispositivo.
**Pontos fortes**
- desacopla o Scanner do framework de treinamento;
- permite plugar modelos de detecção, classificação, pose ou embeddings;
- mantém uma rota futura para Windows/Linux por outros execution providers;
- cacheia modelos compilados do Core ML;
- API Python direta.
**Limitações**
- a compatibilidade depende dos operadores e do formato do modelo;
- precisamos controlar conversão, pré-processamento, pós-processamento e versionamento;
- usar Core ML não garante que 100% do grafo rode na Neural Engine.
**Uso recomendado no Scanner**
- runtime para modelos licenciados de forma compatível com o produto;
- um `ModeloVisualONNX` interno com adaptadores para detecção/classificação;
- fallback CPU explícito e registro do device usado no resultado.
Fonte: [ONNX Runtime — CoreML Execution Provider](https://onnxruntime.ai/docs/execution-providers/CoreML-ExecutionProvider.html).
### 4. MediaPipe Tasks — alternativa especializada
MediaPipe oferece tarefas de visão com APIs para Python e modelos prontos, incluindo detecção de objetos, classificação, segmentação, rosto, mãos, pose e holístico.
**Quando usar**
- pose corporal, mãos, gestos e landmarks;
- tracking de pessoas em trechos curtos;
- quando quisermos manter o mesmo componente em macOS, Linux e Windows.
**Limitações**
- exige escolher e distribuir arquivos de modelo;
- a cobertura e o formato de resultados são diferentes do Vision;
- não deve ser adicionado apenas por sobreposição: primeiro precisamos de um caso que o Vision não resolva.
Fonte: [Google AI Edge — MediaPipe Object Detector para Python](https://ai.google.dev/edge/mediapipe/solutions/vision/object_detector/python).
### 5. Ultralytics YOLO — tecnicamente forte, com risco de licença
Ultralytics oferece detecção, segmentação, pose, classificação e tracking em Python, com modelos pré-treinados e treinamento customizado.
**Vantagens técnicas**
- integração rápida;
- ecossistema amplo;
- bom caminho para objetos específicos do nosso domínio;
- suporta tracking e modelos customizados.
**Risco decisivo**
O código e os artefatos são apresentados sob AGPL-3.0, e a própria documentação indica licença Enterprise para desenvolvimento e produção que não possam cumprir as obrigações da AGPL. Não devemos incorporar Ultralytics ao produto sem uma decisão jurídica/licenciamento explícita.
**Decisão**
Usar apenas em benchmark/protótipo isolado até resolver a licença. Para produção, preferir modelos e runtimes com licença compatível ou adquirir a licença Enterprise.
Fontes: [documentação oficial Ultralytics](https://github.com/ultralytics/ultralytics), [licença e opções comerciais](https://github.com/ultralytics/ultralytics/blob/main/docs/en/index.md).
### 6. OpenCV — fundação de processamento, não provider semântico
OpenCV é útil para decodificação de frames, resize, cor, blur, histograma, métricas técnicas, comparação de imagens e pré/pós-processamento. Também pode executar modelos via DNN, mas não deve ser tratado sozinho como a solução de reconhecimento semântico.
**Uso recomendado**
- extração/amostragem de frames;
- foco, exposição, contraste, ruído e estabilidade;
- diferenças entre frames;
- suporte aos providers Vision, ONNX e MediaPipe.
Fonte: [licença oficial OpenCV](https://opencv.org/license/).
## O que não recomendar como núcleo
Um VLM local pode produzir descrições como “pessoa falando em uma sala”, mas é menos determinístico, mais caro em memória e menos adequado para localizar cortes com precisão. Ele pode entrar depois para gerar descrição semântica de cenas já delimitadas, nunca substituir `PySceneDetect`, Vision ou um detector temporal.
Também não devemos começar pelo YOLO/Ultralytics como dependência central antes de resolver AGPL/licença comercial.
## Matriz resumida
| Ferramenta | Melhor uso | Python direto | Apple Silicon | Offline | Licença/risco | Decisão |
|---|---|---:|---:|---:|---|---|
| Vision | OCR, faces, pessoas, pose, qualidade, similaridade | Não | Excelente | Sim | Framework Apple | Adotar |
| Core ML | Modelos próprios no hardware Apple | Via bridge/runtime | Excelente | Sim | Framework Apple | Adotar |
| PySceneDetect | Cortes e intervalos de cena | Sim | Boa | Sim | BSD-3-Clause | Adotar |
| ONNX Runtime + CoreML EP | Runtime de modelos customizados | Sim | Excelente | Sim | MIT | Adotar como extensão |
| MediaPipe Tasks | Pose, mãos, rosto, holístico | Sim | Boa | Sim | Apache-2.0 no framework; conferir cada modelo | Avaliar por caso |
| OpenCV | Frames e métricas técnicas | Sim | Boa | Sim | Apache-2.0 | Adotar como base auxiliar |
| Ultralytics YOLO | Objetos/pose/tracking | Sim | Boa | Sim | AGPL ou Enterprise | Protótipo somente até decisão |
## Desenho proposto para o nosso código
```text
Clipe/arquivo
↓
ExtratorDeQuadros (FFmpeg/OpenCV)
↓
ProviderDeAnaliseVisual
├── AppleVisionProvider
├── PySceneDetectProvider
├── ONNXCoreMLProvider (opcional)
└── MediaPipeProvider (opcional)
↓
EvidenciaVisual(timestamp, intervalo, tipo, valor, confianca, provider)
↓
ContextoDeAnalise.caracteristicas_visuais / cenas / eventos
```
O contrato atual `ProviderDeAnaliseVisual.analisar(clipe, quadros)` é suficiente para o primeiro passo, mas a saída precisa evoluir de um `dict` livre para evidências temporais. A unidade mínima deveria conter:
- `tipo`: `ocr`, `pessoa`, `objeto`, `pose`, `qualidade`, `similaridade` ou `mudanca_de_cena`;
- `inicio` e `fim` ou `timestamp`;
- `valor` estruturado;
- `confianca` quando fornecida pelo modelo;
- `provider` e `modelo`;
- versão do modelo e hash opcional;
- referência ao arquivo/frame analisado.
## Ordem recomendada de implementação
1. Criar `ExtracaoDeQuadros` usando FFmpeg/OpenCV, com amostragem por intervalo e cache por hash do arquivo.
2. Implementar um helper Swift pequeno para Apple Vision e um `ProviderDeAnaliseVisualApple` Python que troca JSON por stdin/stdout.
3. Adicionar PySceneDetect como etapa própria de detecção de cenas.
4. Criar testes com fixture curta para OCR, presença de pessoa, qualidade e mudança de cena.
5. Medir tempo, memória, cobertura e estabilidade dos resultados em Apple Silicon.
6. Só depois adicionar ONNX/MediaPipe para lacunas concretas.
7. Avaliar um VLM local apenas para descrição semântica de cenas já delimitadas.
## Conclusão
Para o produto atual, a melhor escolha não é uma única biblioteca. É **Vision/Core ML + PySceneDetect + OpenCV**, com **ONNX Runtime/CoreML** como ponto de extensão. Essa combinação cobre a maior parte do Scanner com processamento local, preserva a privacidade e evita amarrar o domínio a uma biblioteca de modelos com licença problemática.
+12
View File
@@ -364,6 +364,18 @@ O modelo utilizado deverá ficar no módulo `providers/transcricao/`.
# 6. Submódulo `visual`
## Regra de acesso ao Premiere
Sempre que o Scanner precisar acessar a timeline, a sequência ativa, as faixas
ou os clipes, deve utilizar `AcessoAoEditor` em
`engine.integracoes.premiere.leitura`. Essa classe já encapsula o cliente MCP e
é a única porta de leitura do Premiere para o Engine.
O submódulo `visual` recebe uma `Timeline` de domínio pelo
`ContextoDeAnalise`; seus adapters não devem chamar o MCP, o CEP ou o UXP
diretamente. Em testes, injete uma timeline pronta ou um fake de
`AcessoAoEditor` na etapa de descoberta.
## `ExtracaoDeQuadros`
### Papel
+1
View File
@@ -26,6 +26,7 @@ Cada módulo principal deverá possuir um documento próprio nesta pasta.
arquitetura/
├── README.md
├── scanner.md
├── biblioteca-inteligente-de-videos.md
├── integracao-com-premiere.md
├── leitura-da-timeline-via-mcp.md
├── plano-primeira-etapa-scanner-e-mcp.md
@@ -0,0 +1,466 @@
# Biblioteca Inteligente de Vídeos
## Objetivo
Transformar uma pasta selecionada pelo usuário em uma biblioteca audiovisual documentada e pesquisável. O sistema deve responder não apenas quais arquivos existem, mas quais evidências de conteúdo aparecem em cada arquivo e em que intervalo temporal.
Exemplos de consultas:
- “Encontre vídeos em que uma pessoa caminha na rua.”
- “Mostre cenas em que alguém fala diante de uma câmera.”
- “Encontre momentos em que aparece um carro vermelho.”
O módulo é de descoberta, análise e recuperação. Ele não decide cortes, não altera a timeline e não deve ser confundido com o índice de Media Intelligence do Premiere. Será um índice local próprio, alimentado pelos adapters disponíveis no projeto.
## Decisões arquiteturais
### 1. Nova unidade de domínio: biblioteca, ativo e evidência
O scanner atual é orientado a uma `Timeline`; este requisito é orientado a uma `BibliotecaDeVideos`. Os dois fluxos podem compartilhar adapters de mídia, mas não devem compartilhar o mesmo contexto de execução.
```text
BibliotecaDeVideos
└── AtivoDeVideo
├── MetadadosDoArquivo
├── SegmentoDeConteudo
│ ├── EvidenciaVisual
│ ├── SegmentoDeTranscricao
│ └── EvidenciaDeAudio
└── RepresentacoesDeBusca
```
Um `AtivoDeVideo` é identificado por uma identidade estável do conteúdo, não apenas pelo nome do arquivo. A identidade inicial deve combinar caminho normalizado, tamanho, data de modificação e uma impressão digital do arquivo. Quando houver colisão ou suspeita de alteração, o hash completo deve confirmar a identidade.
Uma `Evidencia` é um fato observado por um provider, com tipo, valor, intervalo no arquivo de origem, confiança, provider e versão do modelo. Descrições geradas por IA são evidências com proveniência; não são fatos absolutos nem instruções executáveis.
### 2. O módulo externo deve ser profundo
O chamador não deve conhecer FFmpeg, OpenCV, Vision, Whisper, filas, SQLite ou o mecanismo vetorial. A seam pública deve oferecer poucas operações orientadas a intenção:
```python
class BibliotecaDeVideos:
def indexar_pasta(self, pasta: str | Path, configuracao: ConfiguracaoDaBiblioteca) -> IdDaExecucao: ...
def obter_status(self, execucao: IdDaExecucao) -> StatusDaIndexacao: ...
def buscar(self, consulta: str, opcoes: OpcoesDeBusca | None = None) -> list[ResultadoDeBusca]: ...
def obter_ativo(self, identificador: str) -> AtivoDeVideo | None: ...
```
`indexar_pasta` inicia ou agenda uma execução idempotente e retorna imediatamente. O progresso é consultado pelo identificador da execução. `buscar` retorna resultados agrupados por ativo, com trechos temporais e evidências que justificam cada resultado.
O módulo esconde a coordenação entre descoberta incremental, análise, persistência e indexação de busca. Adapters internos podem variar sem alterar essa interface.
### 3. Indexação incremental é regra do domínio
Cada ativo deve registrar:
- impressão digital observada;
- versão do contrato de análise;
- versão de cada provider/modelo usado;
- etapas concluídas;
- etapas com erro ou aviso;
- último status e última execução.
Um arquivo inalterado e já concluído não deve ser analisado novamente. Um arquivo novo entra na fila. Um arquivo cuja impressão digital mudou invalida apenas as etapas derivadas daquele conteúdo. Se somente um provider ou sua versão mudou, a política pode invalidar apenas a etapa correspondente.
Arquivos removidos da pasta não devem ser apagados imediatamente do índice: passam a `ausente_na_origem`, preservando resultados históricos e permitindo recuperação caso retornem. A remoção definitiva deve ser uma operação explícita futura.
## Módulos e responsabilidades
### `biblioteca`
Módulo profundo e seam principal. Recebe a intenção do usuário, cria uma execução, delega o trabalho ao coordenador e expõe status, resultados e erros normalizados.
Não conhece detalhes de providers nem executa chamadas de sistema diretamente.
### `descoberta_da_pasta`
Percorre a pasta autorizada, respeitando configuração de recursão, extensões suportadas, exclusões e links simbólicos. Produz candidatos de arquivos de vídeo e reconcilia o snapshot atual com o último snapshot persistido.
Não extrai metadados, não abre frames e não chama IA.
### `identidade_do_ativo`
Calcula e compara a impressão digital do arquivo. Encapsula a política de “novo”, “inalterado”, “alterado” e “ausente”. Deve ser determinística e testável com um filesystem falso.
### `coordenacao_da_indexacao`
Transforma candidatos em trabalhos por ativo e etapa, respeita dependências, atualiza progresso, trata cancelamento, permite retomada e aplica a política de erro por ativo.
Dependências sugeridas:
```text
descoberta
└── metadados
├── amostragem_de_frames ── análise_visual ── segmentos_visuais
└── extração_de_audio ── transcrição ── segmentos_de_fala
└── análise_de_audio
segmentos + evidências ── consolidação_temporal ── indexação_de_busca
```
Metadados devem ser pré-requisito para calcular duração e amostragem. O ramo visual e o ramo de áudio podem executar em paralelo. A consolidação e a indexação só ocorrem depois dos ramos habilitados, sem exigir que todos tenham sucesso.
### `analise_de_conteudo_audiovisual`
Orquestra os adapters já existentes no projeto:
- `ExtracaoDeMetadados` para dados técnicos;
- `ExtratorDeQuadros` e `DetectorDeCenas` para amostragem e intervalos;
- analisadores OpenCV, Apple Vision, ONNX ou MediaPipe para evidências visuais;
- adapters Whisper, Apple Speech ou Groq para transcrição;
- diarização e emoção local quando habilitadas.
O resultado deve usar modelos do domínio, especialmente `EvidenciaVisual`, `CenaVisual` e `SegmentoDeTranscricao`. Um provider indisponível gera aviso de capacidade e não deve apagar evidências produzidas por outros providers.
O módulo não deve pedir que um modelo de linguagem “assista” ao arquivo inteiro. A análise deve trabalhar com amostras temporais, cenas e agregação de evidências. Uma descrição de cena pode ser criada a partir de um conjunto limitado de frames, sempre mantendo os timestamps que a sustentam.
### `consolidacao_temporal`
Converte observações pontuais em intervalos úteis para busca. Observações do mesmo tipo e valor semelhante podem ser agrupadas quando estão próximas, com uma margem configurável. O resultado precisa conservar as observações originais, pois elas são a evidência auditável do intervalo consolidado.
Esse módulo é o lugar correto para responder “em que momento” e para evitar que a busca retorne apenas o arquivo inteiro.
### `persistencia_do_indice`
Adapter responsável por salvar e ler o estado durável. A primeira implementação deve ser local e transacional, preferencialmente SQLite, com arquivos de frames/áudio tratados como cache reconstruível fora do banco.
Interface mínima:
```python
class RepositorioDaBiblioteca(Protocol):
def reconciliar_snapshot(self, snapshot: SnapshotDaPasta) -> ResultadoDaReconciliacao: ...
def salvar_resultado(self, resultado: ResultadoDoAtivo) -> None: ...
def obter_trabalho_pendente(self, limite: int) -> list[TrabalhoDeIndexacao]: ...
def atualizar_status(self, status: StatusDaIndexacao) -> None: ...
def buscar_evidencias(self, consulta: ConsultaNormalizada) -> list[ResultadoDeBusca]: ...
```
O contrato deve permitir um adapter em memória para testes. Nenhum provider deve escrever diretamente no banco.
### `busca_semantica`
Recebe texto livre, normaliza a consulta e combina três fontes:
1. busca lexical em nomes, transcrições, rótulos e descrições;
2. busca vetorial em descrições de cenas, transcrições e evidências;
3. filtros estruturados por ativo, intervalo, tipo de evidência, confiança e status.
O MVP deve priorizar busca híbrida com ranking explicável. Cada resultado deve informar score, trecho, arquivo e evidências correspondentes. Busca vetorial sem evidência temporal não atende ao requisito.
Um adapter vetorial pode ser adicionado depois. A persistência deve permitir começar com SQLite FTS e embeddings opcionais, sem acoplar o domínio a um banco vetorial específico.
### `integracao_com_timeline_e_editor`
Traduz um `ResultadoDeBusca` para ações de revisão: abrir o arquivo, posicionar o playhead, criar marcador ou propor um clipe para o fluxo editorial. A integração deve ser somente leitura/proposição na primeira versão.
Não deve alterar a timeline automaticamente nem tratar um resultado sem revisão como autorização de edição.
## Modelo de dados lógico
```text
bibliotecas
id, raiz, configuracao_json, criada_em, atualizada_em
ativos
id, biblioteca_id, caminho, nome, extensao, status_origem,
tamanho, modificado_em, fingerprint, criado_em, atualizado_em
metadados_dos_ativos
ativo_id, duracao, largura, altura, fps, codecs, formato, json_extra
execucoes_de_indexacao
id, biblioteca_id, status, motivo, iniciada_em, finalizada_em,
total_trabalhos, concluidos, falhos, cancelada_em
trabalhos_de_indexacao
id, execucao_id, ativo_id, etapa, versao, status, tentativas,
erro, iniciada_em, finalizada_em
segmentos_de_conteudo
id, ativo_id, inicio, fim, tipo, resumo, confianca, origem
evidencias
id, segmento_id, tipo, valor_json, inicio, fim, confianca,
provider, modelo, versao
transcricoes
id, ativo_id, inicio, fim, texto, falante, confianca, provider, versao
representacoes_de_busca
id, alvo_tipo, alvo_id, texto, embedding, indice_lexical
```
Os intervalos são sempre relativos ao arquivo de origem. Quando houver uso numa timeline, a tradução para o intervalo da timeline pertence à integração com o editor, usando o `intervalo_na_origem` do domínio existente.
## Fluxo completo
```text
Usuário seleciona pasta
↓
BibliotecaDeVideos.indexar_pasta()
↓
Execução persistida + trabalhos pendentes
↓
Snapshot da pasta e reconciliação por fingerprint
↓
Metadados por ativo novo/alterado
↓
Ramos visual e áudio em segundo plano
↓
Consolidação de cenas, evidências e falas
↓
Atualização transacional do índice lexical/vetorial
↓
Status consultável e busca com timestamps
```
Cada trabalho deve ser retomável. A gravação de uma etapa deve ser atômica: o índice não pode anunciar uma etapa concluída antes de seus dados e sua versão estarem persistidos.
## Contrato de status e falhas
Status da biblioteca: `nao_indexada`, `indexando`, `parcial`, `concluida`, `falhou` ou `ausente_na_origem`.
Status da etapa: `pendente`, `em_execucao`, `concluida`, `concluida_com_avisos`, `falhou`, `cancelada`.
Falha de um arquivo não deve interromper toda a biblioteca. Falha de descoberta ou persistência deve interromper a execução, pois torna o resultado inconsistente. Falha de um provider deve marcar a capacidade correspondente como indisponível e preservar as demais etapas.
O status deve conter progresso por contagem de trabalhos e por ativo; percentual baseado apenas em duração de vídeo pode ficar enganoso quando há arquivos muito diferentes.
## Escopo recomendado do MVP
1. Seleção e persistência de uma biblioteca local.
2. Descoberta recursiva de extensões configuradas.
3. Fingerprint e reconciliação incremental.
4. Metadados via FFprobe.
5. Amostragem de frames e análise visual já disponível localmente.
6. Transcrição por um adapter configurado, com timestamps.
7. Segmentos temporais e evidências persistidos em SQLite.
8. Busca lexical por nome, transcrição, rótulos e descrições normalizadas.
9. Status, retomada e cancelamento do processamento.
10. Resultado com caminho, intervalo, confiança e evidência.
Ficam para uma segunda etapa: embeddings, ranking híbrido, diarização avançada, reconhecimento de identidade, monitoramento contínuo por filesystem watcher, agrupamento de takes semelhantes e criação automática de marcadores no Premiere.
## Integração com o que já existe
O novo módulo deve reutilizar os adapters de `code/engine/integracoes/midia`, `code/engine/integracoes/visual` e `code/engine/integracoes/whisper`. A implementação atual de `scanner` pode continuar atendendo análises de timeline; o novo coordenador transforma `AtivoDeVideo` em uma entrada compatível com os adapters, sem fazer o domínio da biblioteca depender de `Timeline`.
O `DescobertaDeArquivos` existente contém parte da política de validação local e pode fornecer um adapter compartilhado, desde que sua interface não passe a conhecer biblioteca, fila ou persistência. A análise visual local já produz evidências temporais adequadas, mas a etapa de consolidação deve ficar fora do detector de cenas para manter a separação entre observação e indexação.
As limitações documentadas em `advanced-feature-support.ts` permanecem válidas: o sistema não deve alegar acesso ao índice nativo do Premiere nem iniciar/monitorar operações não expostas por API pública. A biblioteca local é uma capacidade independente.
## Testabilidade e seams
O domínio deve ser testável sem FFmpeg, GPU, macOS Vision, rede ou arquivos reais. Adapters necessários para testes:
- filesystem que retorna snapshots controlados;
- calculador de fingerprint determinístico;
- provider de metadados falso;
- extrator de frames falso;
- providers visual e de transcrição falsos;
- relógio injetável;
- repositório em memória;
- fila síncrona ou executor controlado;
- ranking lexical/vetorial falso.
Testes prioritários:
- arquivo inalterado não gera trabalho novamente;
- novo arquivo gera apenas as etapas necessárias;
- arquivo alterado invalida resultados derivados;
- mudança de versão de provider invalida somente sua etapa;
- falha visual não apaga transcrição;
- timestamps nunca ultrapassam a duração conhecida;
- resultados de busca preservam evidências e intervalo;
- retomada continua do último trabalho persistido;
- cancelamento não deixa etapa marcada como concluída;
- arquivo removido vira ausente sem perder histórico.
## Decisões em aberto
Antes da implementação, ainda precisamos escolher:
1. banco local definitivo: SQLite puro, SQLite com FTS5 e embeddings em arquivo, ou outro adapter;
2. executor em segundo plano: processo Python separado, thread controlada ou integração com o host;
3. provider visual padrão do MVP: OpenCV, Apple Vision ou configuração por perfil;
4. provider de transcrição padrão e política de privacidade para enviar áudio a serviços externos;
5. extensões, exclusões e limite de profundidade da pasta;
6. política de retenção do cache de frames e áudio;
7. formato do contrato de descrição semântica produzido pelo modelo de IA.
Essas escolhas não devem alterar a interface de `BibliotecaDeVideos`; devem apenas selecionar adapters e configuração.
## Catálogo SQLite e processamento contínuo
### SQLite como catálogo, não como depósito de mídia
SQLite é adequado para o catálogo local porque oferece transações, consultas relacionais, FTS5 para busca textual e baixo custo operacional. O banco não deve armazenar vídeos, áudio extraído ou imagens em escala. Esses dados ficam na pasta original ou em um cache controlado; no banco ficam referências, metadados, resultados compactos e estado de processamento.
O arquivo do catálogo deve ficar fora da pasta indexada, em um diretório de dados do aplicativo. Assim, uma pasta pode ser removida, movida ou compartilhada sem levar o estado interno do agente junto com ela. A configuração deve permitir uma biblioteca por pasta e várias bibliotecas no mesmo catálogo.
Para lidar com nomes repetidos, a chave do ativo não será `nome`. O cadastro deve usar um identificador interno e único, por exemplo:
```text
identificador_do_ativo = UUID interno
identidade_do_conteudo = hash do conteúdo + tamanho
localizacao = biblioteca + caminho relativo normalizado
```
O caminho relativo distingue duas cópias iguais em locais diferentes; a identidade do conteúdo permite reconhecer um arquivo renomeado ou movido dentro da mesma biblioteca. O cálculo deve ser progressivo: primeiro comparar tamanho e data de modificação, depois calcular uma impressão digital parcial; o hash completo fica reservado para arquivos suspeitos, duplicatas ou confirmação de identidade.
### Estado persistido por etapa
O catálogo deve tratar a análise como um conjunto de trabalhos independentes, não como uma operação monolítica por vídeo. Cada trabalho tem `ativo_id`, `etapa`, `versao_da_etapa`, `status`, `progresso`, `checkpoint`, `tentativas`, `erro` e timestamps.
Checkpoints possíveis:
- último timestamp de frame processado;
- último intervalo de áudio transcrito;
- identificador da janela de cena atual;
- lote de evidências já persistido;
- versão do modelo e parâmetros efetivos.
Um checkpoint só é confirmado junto com os resultados daquele lote. Em caso de interrupção, o executor retoma a partir do último checkpoint confirmado, com uma pequena sobreposição temporal para não perder eventos na transição entre lotes. A escrita deve ser idempotente usando uma chave lógica como `ativo + etapa + versão + intervalo + provider`.
### Worker de baixa prioridade
O processamento contínuo deve ser implementado como um worker controlado pelo sistema, com uma iteração curta e cooperativa. Ele não deve manter vídeos, frames ou áudios inteiros em memória.
Política inicial:
- um ativo por vez por padrão;
- um lote pequeno de frames por vez;
- áudio extraído em arquivo temporário ou stream, nunca inteiro em memória;
- transação SQLite curta por lote;
- pausa entre lotes quando a máquina estiver ocupada;
- suspensão com bateria, modo de economia de energia, temperatura alta ou pressão de memória;
- limite configurável de CPU, memória, espaço de cache e tempo por ciclo;
- cancelamento cooperativo verificado entre frames, lotes e etapas;
- processos de provider isolados quando uma biblioteca nativa puder bloquear ou consumir memória excessiva.
O worker deve consultar uma política de recursos antes de iniciar cada lote:
```python
class PoliticaDeRecursos(Protocol):
def pode_executar(self, trabalho: TrabalhoDeIndexacao) -> bool: ...
def tamanho_do_lote(self, trabalho: TrabalhoDeIndexacao) -> int: ...
def deve_pausar(self) -> bool: ...
```
O objetivo não é manter o agente analisando a qualquer custo, mas aproveitar períodos ociosos. Se a máquina estiver ocupada, o worker deve dormir e deixar o sistema responsivo. A prioridade do processo e a afinidade de CPU são detalhes de um adapter do host, não regras espalhadas pelo domínio.
### Varredura contínua
O modo contínuo deve combinar duas estratégias:
1. uma varredura periódica e lenta da pasta, que é a fonte de verdade;
2. um watcher opcional para antecipar a descoberta de arquivos novos ou alterados.
O watcher não deve iniciar análise diretamente. Ele apenas marca a biblioteca como “precisa reconciliar”; a próxima varredura confirma o estado, evitando arquivos ainda sendo copiados. Um arquivo só entra na fila depois de permanecer estável por um intervalo configurável e passar por uma leitura mínima de metadados.
Ao iniciar, retomar ou acordar após ocioso, o worker deve:
```text
reconciliar pasta → atualizar origem dos ativos → recalcular trabalhos necessários
→ escolher próximo trabalho → processar lote → persistir checkpoint → repetir
```
### Frequência de frames
Não existe uma frequência única ideal. Analisar todos os frames é caro e, para busca semântica, geralmente redundante. A recomendação para o MVP é uma amostragem em camadas:
| Camada | Frequência inicial | Finalidade |
|---|---:|---|
| triagem | 1 frame a cada 2–5 s | descobrir duração, atividade e mudanças grosseiras |
| cena ativa | 1–2 frames/s | descrever objetos, pessoas e composição |
| transição | frequência temporariamente maior | refinar início/fim de uma mudança de cena |
| áudio | janelas contínuas | transcrição e detecção de fala/silêncio |
O valor de `1 frame/s` é um bom ponto de partida para vídeos comuns, mas deve ser uma configuração, não uma regra fixa. Conteúdo com movimento rápido, cortes frequentes, texto na tela ou ações curtas precisa de amostragem adaptativa. Conteúdo estático pode reduzir a frequência quando os frames consecutivos têm baixa diferença visual.
O detector deve aumentar a frequência localmente quando detectar mudança de cena, movimento relevante, fala, texto novo ou baixa confiança. Deve reduzir a frequência quando houver continuidade visual, silêncio prolongado ou repetição de frames. Cada evidência precisa registrar a amostragem usada, para que a ausência de detecção não seja interpretada como prova de ausência.
### Transcrição
O adapter local baseado em `faster-whisper` é a escolha padrão recomendada para o catálogo: mantém o processamento privado, entrega timestamps e já se encaixa nos providers existentes. O tamanho do modelo deve ser configurável por perfil de recurso:
- perfil econômico: modelo menor, CPU e baixa prioridade;
- perfil equilibrado: modelo intermediário, possivelmente aceleração disponível;
- perfil qualidade: modelo maior, somente quando solicitado ou quando houver tempo ocioso.
Apple Speech pode ser um adapter preferencial em macOS quando o requisito for baixo consumo e o idioma suportado for suficiente. Groq ou outro provider remoto deve ser opt-in, com consentimento explícito, indicação de que o áudio sai da máquina e registro do provider usado. A transcrição persistida deve guardar idioma, modelo, provider, confiança e timestamps; trocar o modelo invalida somente a etapa de transcrição, não os metadados nem necessariamente a análise visual.
### Combinação de fontes
O cadastro útil para o agente deve manter as fontes separadas e criar uma representação consolidada:
```text
metadados técnicos
+ transcrição temporal
+ evidências visuais temporais
+ evidências de áudio
+ mudanças de cena
↓
segmento de conteúdo
↓
texto indexável + filtros + evidências
```
Um segmento pode ter o resumo “pessoa entra em uma sala enquanto fala”, mas deve apontar para: o intervalo temporal, os frames que sustentam “pessoa” e “entra”, e o trecho de transcrição que sustenta “fala”. O resumo serve para recuperação; as evidências servem para auditoria, ranking e revisão humana.
### Estratégia de prioridade
A prioridade deve ser calculada no momento de escolher o próximo trabalho, sem alterar a identidade nem o resultado do ativo. Uma pontuação inicial pode considerar:
```text
prioridade = intenção explícita
+ ativo aberto ou usado recentemente no editor
+ arquivo novo ou alterado
+ etapa necessária para uma busca pendente
+ tamanho/tempo estimado que permita concluir um lote
- custo estimado
- idade da última tentativa com falha
```
Categorias práticas:
1. urgente: ativo solicitado numa busca ou aberto para edição;
2. alta: arquivo novo, alterado ou associado ao projeto atual;
3. normal: arquivos ainda não indexados;
4. baixa: reprocessamento por melhoria de modelo ou análise opcional.
Para evitar starvation, trabalhos de baixa prioridade recebem um aumento gradual de prioridade conforme envelhecem. Um arquivo grande não deve bloquear a fila inteira: o scheduler o divide em lotes e alterna com trabalhos menores.
## Configuração operacional proposta
```python
@dataclass(frozen=True)
class ConfiguracaoDaBiblioteca:
extensoes: tuple[str, ...] = (".mp4", ".mov", ".mxf", ".mkv", ".avi")
recursiva: bool = True
intervalo_da_varredura_em_segundos: int = 900
estabilidade_do_arquivo_em_segundos: int = 30
frequencia_de_triagem_em_fps: float = 0.25
frequencia_de_cena_em_fps: float = 1.0
tamanho_do_lote_de_frames: int = 32
concorrencia: int = 1
limite_de_cache_em_gb: float = 10.0
permitir_provider_remoto: bool = False
perfil_de_recursos: str = "economico"
```
Os defaults favorecem responsividade e privacidade. A configuração não deve expor detalhes de cada biblioteca de visão; deve selecionar perfis e permitir ajustes apenas onde houver evidência de necessidade.
## Fases de evolução
### Fase 1 — catálogo confiável
SQLite, descoberta periódica, identidade por fingerprint, metadados, fila persistida, checkpoints, análise visual em baixa frequência, transcrição local e busca textual temporal.
### Fase 2 — recuperação semântica
Embeddings para segmentos e transcrições, busca híbrida, reranking e consultas por conceitos não presentes literalmente no texto. O embedding deve ser versionado e reconstruível; não deve ser a única representação do conhecimento.
### Fase 3 — integração editorial
Busca a partir do contexto da timeline, abertura no trecho encontrado, marcadores de revisão e propostas de stringout. Qualquer mutação na timeline continua exigindo uma etapa explícita de revisão e autorização.
+136
View File
@@ -0,0 +1,136 @@
"""Entrada local do painel CEP para uma execução configurada do Scanner."""
import argparse
import json
import math
import os
from pathlib import Path
import tempfile
import re
from engine.integracoes.premiere.conversores import ConversorDeTimeline
from engine.scanner import ConfiguracaoVisualDoScanner, criar_detector_apple_vision, gerar_relatorio_visual
from engine.scanner.transcricao_da_timeline import TranscricaoDaTimeline
MODELO_DIARIZACAO_PADRAO = "pyannote/speaker-diarization-community-1"
def nome_seguro(nome: str) -> str:
return re.sub(r"[^A-Za-z0-9._-]+", "-", nome.strip()).strip(".-") or "timeline"
def executar(entrada: Path, saida: Path) -> Path:
pedido = json.loads(entrada.read_text(encoding="utf-8"))
configuracao = ConfiguracaoVisualDoScanner.de_dict(pedido["perfil"])
timeline = ConversorDeTimeline().converter(pedido["timeline"])
clipes = [clipe for faixa in timeline.faixas if faixa.tipo == "video"
and faixa.indice in configuracao.faixas_de_video for clipe in faixa.clipes]
total_estimado = sum(max(1, math.ceil((clipe.intervalo_na_origem or clipe.intervalo_na_timeline).duracao /
configuracao.intervalo_em_segundos)) + 1 for clipe in clipes) or 1
concluidos = 0
def emitir(etapa: str, percentual: float, clipe: str = "") -> None:
print(json.dumps({"evento": "progresso", "etapa": etapa, "percentual": round(percentual, 1),
"clipe": clipe}, ensure_ascii=False), flush=True)
resultados = []
for clipe in clipes:
peso = max(1, math.ceil((clipe.intervalo_na_origem or clipe.intervalo_na_timeline).duracao /
configuracao.intervalo_em_segundos)) + 1
inicio_do_clipe = concluidos
def progresso_atual(atual: int, total: int, nome: str = clipe.nome,
base: int = inicio_do_clipe, peso_do_clipe: int = peso) -> None:
emitir("Analisando frames", 100 * (base + peso_do_clipe * atual / max(total, 1)) / total_estimado, nome)
detector = criar_detector_apple_vision(Path(tempfile.gettempdir()) / "premiere-mcp-scanner",
configuracao=configuracao)
detector.ao_progresso = progresso_atual
emitir("Extraindo frames", 100 * inicio_do_clipe / total_estimado, clipe.nome)
try:
resultado = detector.detectar(clipe)
arquivo = Path(tempfile.gettempdir()) / f"scanner-{clipe.identificador}.json"
gerar_relatorio_visual(clipe, resultado, arquivo, configuracao.intervalo_em_segundos)
resultados.append(json.loads(arquivo.read_text(encoding="utf-8")))
except Exception as erro:
resultados.append({"clipe": {"identificador": clipe.identificador, "nome": clipe.nome,
"arquivo_original": clipe.arquivo}, "erro": str(erro)})
concluidos += peso
faixas_de_audio = [faixa for faixa in timeline.faixas
if faixa.tipo == "audio" and faixa.indice in configuracao.faixas_de_audio]
audio = [{"indice": faixa.indice, "nome": faixa.nome,
"clipes": [{"identificador": clipe.identificador, "nome": clipe.nome,
"inicio": clipe.intervalo_na_timeline.inicio, "fim": clipe.intervalo_na_timeline.fim}
for clipe in faixa.clipes]}
for faixa in faixas_de_audio]
caso = nome_seguro(timeline.nome)
pasta = saida.parent / caso
pasta.mkdir(parents=True, exist_ok=True)
saida = pasta / f"{caso}-resultado.json"
detalhado = pasta / "detalhado"
detalhado.mkdir(parents=True, exist_ok=True)
geometria = []
for resultado in resultados:
for frame in resultado.get("observacoes_por_frame", []):
fatos = [item for item in frame["evidencias"] if item["tipo"] in {"rosto", "pessoa", "pose", "mao"}]
if fatos:
geometria.append({"clipe": resultado["clipe"]["identificador"], "timestamp_na_origem": frame["timestamp_na_origem"], "evidencias": fatos})
frame["evidencias"] = [item for item in frame["evidencias"] if item not in fatos]
(detalhado / "geometria.jsonl").write_text("".join(json.dumps(item, ensure_ascii=False) + "\n" for item in geometria), encoding="utf-8")
video_arquivo = f"{caso}-video.json"
(pasta / video_arquivo).write_text(json.dumps({"clipes": resultados}, ensure_ascii=False, indent=2), encoding="utf-8")
audio_arquivos = []
transcricoes_por_clipe: dict[str, list[dict]] = {}
transcricao_configurada = pedido["perfil"].get("transcricao", {})
if faixas_de_audio and transcricao_configurada.get("caminho_modelo"):
try:
from engine.integracoes.whisper import ProviderDeTranscricaoLocal
from engine.dominio import Timeline
emitir("Transcrevendo áudio", 5)
provider = ProviderDeTranscricaoLocal(transcricao_configurada["caminho_modelo"],
idioma=transcricao_configurada.get("idioma", "pt"))
diarizador = None
if transcricao_configurada.get("diarizacao"):
if not os.environ.get("HF_TOKEN"):
transcricao_configurada = {**transcricao_configurada,
"aviso_diarizacao": "Token do Hugging Face não configurado."}
else:
from engine.integracoes.huggingface import ProviderDeDiarizacaoHuggingFace
modelo_diarizacao = os.environ.get("HF_DIARIZATION_MODEL", MODELO_DIARIZACAO_PADRAO)
diarizador = ProviderDeDiarizacaoHuggingFace(modelo_diarizacao)
transcricao_configurada = {**transcricao_configurada,
"modelo_diarizacao": modelo_diarizacao}
transcricoes = TranscricaoDaTimeline(provider, diretoria_de_trabalho=pasta / ".cache-audio",
diarizador=diarizador).executar(
Timeline(timeline.identificador, timeline.nome, faixas=faixas_de_audio))
for transcricao in transcricoes:
transcricoes_por_clipe[transcricao.identificador_do_clipe] = [
{"inicio": item.inicio, "fim": item.fim, "texto": item.texto,
"confianca": item.confianca, "falante": item.falante}
for item in transcricao.segmentos]
except Exception as erro:
transcricao_configurada = {**transcricao_configurada, "erro": str(erro)}
for faixa in audio:
arquivo = f"{caso}-audio-faixa-{faixa['indice'] + 1}.json"
segmentos = [{"clipe": clipe["identificador"], "segmentos": transcricoes_por_clipe.get(clipe["identificador"], [])}
for clipe in faixa["clipes"]]
(pasta / arquivo).write_text(json.dumps({"faixa": faixa, "transcricao": transcricao_configurada,
"segmentos_por_clipe": segmentos,
"status": "concluida" if transcricoes_por_clipe else "indisponivel"},
ensure_ascii=False, indent=2), encoding="utf-8")
audio_arquivos.append(arquivo)
saida.write_text(json.dumps({"versao": 1, "perfil": pedido["perfil"],
"sequencia": {"id": timeline.identificador, "nome": timeline.nome},
"artefatos": {"audio": audio_arquivos, "video": video_arquivo,
"geometria": "detalhado/geometria.jsonl"}}, ensure_ascii=False, indent=2), encoding="utf-8")
emitir("Relatório JSON gerado", 100)
return saida
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("entrada", type=Path)
parser.add_argument("saida", type=Path)
args = parser.parse_args()
print(json.dumps({"evento": "concluido", "arquivo": str(executar(args.entrada, args.saida))}, ensure_ascii=False))
+23 -4
View File
@@ -1,7 +1,9 @@
import json
from pathlib import Path
from engine.integracoes.midia import ExtracaoDeAudio
from engine.integracoes.groq import ProviderDeTranscricaoGroq
from engine.integracoes.whisper import ProviderDeTranscricaoLocal
from engine.integracoes.huggingface import (ProviderDeDiarizacaoHuggingFace,
ProviderDeEmocaoHuggingFace)
from engine.integracoes.premiere.cliente_mcp import ClienteMCPPorStdio
from engine.integracoes.premiere.conversores import ConversorDeTimeline
from engine.scanner.transcricao_da_timeline import TranscricaoDaTimeline
@@ -10,7 +12,7 @@ ARQUIVO = Path('/Volumes/Merongo/PROJETOS/03 - Mastopexia/0E6A8290.MP4')
CHAVE = json.loads((Path.home()/'.premiere-mcp/config.json').read_text()).get('groqApiKey','')
SAIDA = Path('/Volumes/Merongo/SISTEMAS/GENIAL SISTEMAS/Jhonny/code/relatorios')
cliente = ClienteMCPPorStdio(['node', 'dist/index.js'])
cliente = ClienteMCPPorStdio(['node', str(Path(__file__).resolve().parents[1] / 'dist/index.js')])
cliente.conectar()
bruta = cliente.chamar('get_active_sequence', {})['structuredContent']['data']
timeline = ConversorDeTimeline().converter(bruta)
@@ -18,8 +20,13 @@ for faixa in timeline.faixas:
for clipe in faixa.clipes:
if clipe.nome == '0E6A8290.MP4': clipe.arquivo = str(ARQUIVO)
provider = ProviderDeTranscricaoGroq(CHAVE)
transcricoes = TranscricaoDaTimeline(provider, ExtracaoDeAudio(duracao_da_parte=600), SAIDA/'audio').executar(timeline)
MODELO = Path('/Volumes/Merongo/SISTEMAS/MODELOSIA/hf-cache/hub/models--mobiuslabsgmbh--faster-whisper-large-v3-turbo/snapshots/0a363e9161cbc7ed1431c9597a8ceaf0c4f78fcf')
provider = ProviderDeTranscricaoLocal(str(MODELO), idioma='pt')
MODELO_EMOCAO = Path('/Volumes/Merongo/SISTEMAS/MODELOSIA/models/wav2vec2-xls-r-300m-pt-br-spontaneous-speech-emotion-recognition')
analisador_de_emocao = ProviderDeEmocaoHuggingFace(str(MODELO_EMOCAO))
MODELO_DIARIZACAO = Path('/Volumes/Merongo/SISTEMAS/MODELOSIA/models/pyannote-speaker-diarization-community-1')
diarizador = ProviderDeDiarizacaoHuggingFace(str(MODELO_DIARIZACAO))
transcricoes = TranscricaoDaTimeline(provider, ExtracaoDeAudio(duracao_da_parte=600), SAIDA/'audio', analisador_de_emocao, diarizador).executar(timeline)
linhas = [f'# Relatório de transcrição — {timeline.nome}', '', f'Duração: {bruta["end"]:.3f} s',
f'Clipes processados: {sum(len(f.clipes) for f in timeline.faixas if f.tipo == "video")}', '']
@@ -32,5 +39,17 @@ for faixa in timeline.faixas:
texto = resultado.texto if resultado else ''
linhas += [f'## {clipe.identificador} — {clipe.intervalo_na_timeline.inicio:.3f}s–{clipe.intervalo_na_timeline.fim:.3f}s',
f'Origem: {intervalo.inicio:.3f}s–{intervalo.fim:.3f}s', '', texto or '_Sem fala detectada._', '']
if resultado and resultado.segmentos:
linhas.append('Emoções detectadas por segmento:')
for segmento in resultado.segmentos:
if segmento.emocao:
voz = f', voz {segmento.voz_aparente}' if segmento.voz_aparente else ''
linhas.append(f'- [{segmento.inicio:.3f}s–{segmento.fim:.3f}s] {segmento.falante or "falante_indefinido"}: {segmento.emocao} ({segmento.confianca_emocao:.2f}{voz})')
linhas.append('')
for segmento in resultado.segmentos if resultado else []:
for palavra in segmento.palavras:
linhas.append(f'- [{palavra.inicio:.3f}s–{palavra.fim:.3f}s] {palavra.texto}')
if resultado and resultado.segmentos:
linhas.append('')
(SAIDA/'transcricao-timeline.md').write_text('\n'.join(linhas), encoding='utf-8')
print(SAIDA/'transcricao-timeline.md')
@@ -1,44 +1,82 @@
import AppKit
import Foundation
import Speech
final class AppDelegate: NSObject, NSApplicationDelegate {
func applicationDidFinishLaunching(_ notification: Notification) {
guard CommandLine.arguments.count >= 3 else { finalizar("uso: arquivo locale [somente_no_dispositivo]", 2); return }
let url = URL(fileURLWithPath: CommandLine.arguments[1])
let locale = Locale(identifier: CommandLine.arguments[2])
let somenteNoDispositivo = CommandLine.arguments.count > 3 && CommandLine.arguments[3] == "true"
guard let recognizer = SFSpeechRecognizer(locale: locale), recognizer.isAvailable else {
finalizar("Apple Speech indisponível para o locale solicitado", 3); return
/// Encapsula o ciclo de vida do Apple Speech para a Engine.
///
/// A classe não conhece o scanner, o catálogo ou a persistência. Sua única
/// responsabilidade é converter um arquivo de mídia em JSON temporal. O
/// processo é Foundation-only para poder ser executado como CLI sem iniciar
/// AppKit ou NSApplication.
final class TranscritorDeFalaApple {
private let semaforo = DispatchSemaphore(value: 0)
private var payload: [String: Any] = ["segmentos": []]
private var codigoDeSaida: Int32 = 0
private var finalizado = false
func executar(argumentos: [String]) -> Int32 {
guard argumentos.count >= 3 else {
return finalizar("uso: apple-speech-transcriber arquivo locale [somente_no_dispositivo]", 2)
}
SFSpeechRecognizer.requestAuthorization { status in
guard status == .authorized else { self.finalizar("Permissão do Apple Speech não concedida", 4); return }
let request = SFSpeechURLRecognitionRequest(url: url)
let arquivo = URL(fileURLWithPath: argumentos[1])
let locale = Locale(identifier: argumentos[2])
let somenteNoDispositivo = argumentos.count > 3 && argumentos[3] == "true"
guard let recognizer = SFSpeechRecognizer(locale: locale), recognizer.isAvailable else {
return finalizar("Apple Speech indisponível para o locale solicitado", 3)
}
SFSpeechRecognizer.requestAuthorization { [weak self] status in
guard let self else { return }
guard status == .authorized else {
self.finalizar("Permissão do Apple Speech não concedida", 4)
return
}
let request = SFSpeechURLRecognitionRequest(url: arquivo)
request.shouldReportPartialResults = false
if somenteNoDispositivo && recognizer.supportsOnDeviceRecognition { request.requiresOnDeviceRecognition = true }
recognizer.recognitionTask(with: request) { result, error in
if let result = result, result.isFinal {
let segmentos = result.bestTranscription.segments.map {
if somenteNoDispositivo && recognizer.supportsOnDeviceRecognition {
request.requiresOnDeviceRecognition = true
}
recognizer.recognitionTask(with: request) { [weak self] resultado, erro in
guard let self else { return }
if let resultado, resultado.isFinal {
self.payload["segmentos"] = resultado.bestTranscription.segments.map {
["inicio": $0.timestamp, "fim": $0.timestamp + $0.duration,
"texto": $0.substring, "confianca": $0.confidence] as [String: Any]
}
let data = try! JSONSerialization.data(withJSONObject: ["segmentos": segmentos])
print(String(data: data, encoding: .utf8)!)
self.finalizar(nil, 0)
} else if let erro {
self.finalizar("Falha no Apple Speech: \(erro.localizedDescription)", 5)
}
if error != nil || result?.isFinal == true { self.finalizar(nil, 0) }
}
}
semaforo.wait()
imprimirResultado()
return codigoDeSaida
}
private func finalizar(_ mensagem: String?, _ codigo: Int32) {
@discardableResult
private func finalizar(_ mensagem: String?, _ codigo: Int32) -> Int32 {
guard !finalizado else { return codigoDeSaida }
finalizado = true
if let mensagem { fputs(mensagem + "\n", stderr) }
NSApplication.shared.terminate(nil)
exit(codigo)
codigoDeSaida = codigo
semaforo.signal()
return codigo
}
private func imprimirResultado() {
guard let dados = try? JSONSerialization.data(withJSONObject: payload),
let texto = String(data: dados, encoding: .utf8) else {
fputs("Não foi possível serializar o resultado do Apple Speech\n", stderr)
codigoDeSaida = 6
return
}
print(texto)
}
}
let app = NSApplication.shared
let delegate = AppDelegate()
app.delegate = delegate
app.setActivationPolicy(.accessory)
app.run()
let transcritor = TranscritorDeFalaApple()
exit(transcritor.executar(argumentos: CommandLine.arguments))
@@ -7,10 +7,10 @@ from ...scanner.modelos import SegmentoDeTranscricao
class ProviderDeTranscricaoApple:
"""Provider nativo macOS Speech; o áudio não passa por API Groq."""
"""Provider nativo macOS Speech, local por padrão e sem AppKit."""
def __init__(self, executavel: str = "/private/tmp/AppleSpeechTranscriber.app/Contents/MacOS/apple-speech-transcriber", locale: str = "pt-BR",
somente_no_dispositivo: bool = False) -> None:
somente_no_dispositivo: bool = True) -> None:
self.executavel = executavel
self.locale = locale
self.somente_no_dispositivo = somente_no_dispositivo
@@ -24,5 +24,5 @@ class ProviderDeTranscricaoApple:
check=True, capture_output=True, text=True,
)
dados = json.loads(resultado.stdout)
return [SegmentoDeTranscricao(float(s["inicio"]), float(s["fim"]), str(s["texto"]), s.get("confianca"))
return [SegmentoDeTranscricao(float(s["inicio"]), float(s["fim"]), str(s["texto"]).strip(), s.get("confianca"))
for s in dados.get("segmentos", [])]
@@ -0,0 +1,4 @@
from .provider_de_emocao_local import ProviderDeEmocaoHuggingFace
from .provider_de_diarizacao_local import ProviderDeDiarizacaoHuggingFace
__all__ = ["ProviderDeDiarizacaoHuggingFace", "ProviderDeEmocaoHuggingFace"]
@@ -0,0 +1,21 @@
from pathlib import Path
class ProviderDeDiarizacaoHuggingFace:
"""Identifica intervalos de falas por participante em áudio local."""
def __init__(self, modelo: str) -> None:
from pyannote.audio import Pipeline
self.pipeline = Pipeline.from_pretrained(modelo)
def analisar(self, arquivo: str | Path) -> list[tuple[float, float, str]]:
import soundfile as sf
import torch
audio, taxa = sf.read(str(arquivo), dtype="float32")
if getattr(audio, "ndim", 1) > 1:
audio = audio.mean(axis=1)
saida = self.pipeline({"waveform": torch.from_numpy(audio).unsqueeze(0),
"sample_rate": taxa})
diarizacao = getattr(saida, "exclusive_speaker_diarization", saida)
return [(float(turno.start), float(turno.end), str(falante))
for turno, _, falante in diarizacao.itertracks(yield_label=True)]
@@ -0,0 +1,35 @@
from pathlib import Path
from typing import Any
class ProviderDeEmocaoHuggingFace:
"""Classifica a emoção de uma fala localmente com Transformers."""
def __init__(self, modelo: str = "superb/wav2vec2-base-superb-er") -> None:
from transformers import AutoFeatureExtractor, AutoModelForAudioClassification
self.modelo = modelo
self.processador = AutoFeatureExtractor.from_pretrained(modelo, local_files_only=True)
self.classificador = AutoModelForAudioClassification.from_pretrained(
modelo, local_files_only=True
)
self.classificador.eval()
def analisar(self, arquivo: str | Path, inicio: float, fim: float) -> dict[str, Any]:
import soundfile as sf
import torch
audio, taxa = sf.read(str(arquivo), start=max(0, int(inicio * 16000)),
stop=max(0, int(fim * 16000)), dtype="float32")
if getattr(audio, "ndim", 1) > 1:
audio = audio.mean(axis=1)
entradas = self.processador(audio, sampling_rate=taxa, return_tensors="pt")
with torch.no_grad():
logits = self.classificador(**entradas).logits
probabilidades = torch.softmax(logits[0], dim=-1)
indice = int(torch.argmax(probabilidades))
rotulo = self.classificador.config.id2label[indice]
voz_aparente = {"non-neutral-female": "feminina",
"non-neutral-male": "masculina"}.get(rotulo)
return {"emocao": self.classificador.config.id2label[indice],
"confianca": float(probabilidades[indice]),
"voz_aparente": voz_aparente,
"confianca_voz": float(probabilidades[indice]) if voz_aparente else None}
+2 -1
View File
@@ -1,3 +1,4 @@
from .extracao_de_audio import ExtracaoDeAudio, ParteDeAudio
from .extracao_de_metadados import ExtracaoDeMetadados, MetadadosDoArquivo
__all__ = ["ExtracaoDeAudio", "ParteDeAudio"]
__all__ = ["ExtracaoDeAudio", "ExtracaoDeMetadados", "MetadadosDoArquivo", "ParteDeAudio"]
@@ -0,0 +1,106 @@
import json
import subprocess
from dataclasses import dataclass
from fractions import Fraction
from pathlib import Path
from typing import Any
from ...scanner.modelos import ErroDeAnalise
@dataclass(frozen=True)
class MetadadosDoArquivo:
caminho: Path
formato: str | None = None
duracao: float | None = None
codec_de_video: str | None = None
codec_de_audio: str | None = None
largura: int | None = None
altura: int | None = None
taxa_de_quadros: float | None = None
canais_de_audio: int | None = None
taxa_de_amostragem: int | None = None
tamanho_em_bytes: int | None = None
orientacao: str | None = None
timecode: str | None = None
class ExtracaoDeMetadados:
"""Extrai metadados técnicos via ffprobe, com cache por caminho normalizado."""
def __init__(self, ffprobe: str = "ffprobe") -> None:
self.ffprobe = ffprobe
self._cache: dict[Path, MetadadosDoArquivo] = {}
def extrair(self, arquivo: str | Path) -> MetadadosDoArquivo:
caminho = Path(arquivo).expanduser().resolve(strict=False)
if not caminho.is_file():
raise ErroDeAnalise("arquivo_inacessivel", "Arquivo de mídia não encontrado.", str(caminho))
if caminho in self._cache:
return self._cache[caminho]
try:
processo = subprocess.run(
[self.ffprobe, "-v", "error", "-print_format", "json", "-show_format", "-show_streams", str(caminho)],
check=True, capture_output=True, text=True,
)
dados = json.loads(processo.stdout)
except (OSError, subprocess.SubprocessError, json.JSONDecodeError) as exc:
raise ErroDeAnalise("metadados_indisponiveis", f"Não foi possível ler os metadados: {exc}", str(caminho)) from exc
metadados = self._converter(caminho, dados)
self._cache[caminho] = metadados
return metadados
def limpar_cache(self) -> None:
self._cache.clear()
@classmethod
def _converter(cls, caminho: Path, dados: dict[str, Any]) -> MetadadosDoArquivo:
formato = dados.get("format") or {}
streams = dados.get("streams") or []
video = next((item for item in streams if item.get("codec_type") == "video"), {})
audio = next((item for item in streams if item.get("codec_type") == "audio"), {})
duracao = cls._float(formato.get("duration"))
if duracao is None:
duracao = cls._float(video.get("duration") or audio.get("duration"))
return MetadadosDoArquivo(
caminho=caminho,
formato=cls._format_name(formato.get("format_name")),
duracao=duracao,
codec_de_video=video.get("codec_name") or None,
codec_de_audio=audio.get("codec_name") or None,
largura=cls._int(video.get("width")), altura=cls._int(video.get("height")),
taxa_de_quadros=cls._fps(video.get("avg_frame_rate") or video.get("r_frame_rate")),
canais_de_audio=cls._int(audio.get("channels")),
taxa_de_amostragem=cls._int(audio.get("sample_rate")),
tamanho_em_bytes=cls._int(formato.get("size")) or caminho.stat().st_size,
orientacao=video.get("side_data_list", [{}])[0].get("rotation") if video.get("side_data_list") else None,
timecode=(formato.get("tags") or {}).get("timecode") or (video.get("tags") or {}).get("timecode"),
)
@staticmethod
def _float(valor: Any) -> float | None:
try:
return None if valor in (None, "", "N/A") else float(valor)
except (TypeError, ValueError):
return None
@staticmethod
def _int(valor: Any) -> int | None:
try:
return None if valor in (None, "", "N/A") else int(valor)
except (TypeError, ValueError):
return None
@staticmethod
def _fps(valor: Any) -> float | None:
if valor in (None, "", "0/0", "N/A"):
return None
try:
return float(Fraction(str(valor)))
except (ValueError, ZeroDivisionError):
return None
@staticmethod
def _format_name(valor: Any) -> str | None:
return str(valor).split(",", 1)[0] if valor else None
+161
View File
@@ -0,0 +1,161 @@
# Análise visual local
## Configuração no painel
A configuração do Scanner é apresentada na aba **Scanner** do painel CEP em
`code/cep-plugin/`. O perfil salvo segue o contrato versionado de
`ConfiguracaoVisualDoScanner`; ele define amostragem, faixas, recursos Vision,
cenas, continuidade, reenquadramento e interpretação antes de montar os
adapters.
## Acesso à timeline do Premiere
Quando a análise precisar ler a timeline, a sequência ativa, as faixas ou os
clipes do Premiere, use sempre a classe existente
`engine.integracoes.premiere.leitura.AcessoAoEditor`. O módulo visual não deve
criar chamadas MCP, abrir processos do Premiere ou acessar o bridge diretamente.
O acesso deve seguir esta composição:
```python
from engine.integracoes.premiere.leitura import AcessoAoEditor, AcessoATimeline
from engine.scanner import DescobertaDaTimeline
from engine.integracoes.premiere.conversores import ConversorDeTimeline
acesso_ao_editor = AcessoAoEditor(AcessoATimeline(cliente_mcp))
descoberta = DescobertaDaTimeline(acesso_ao_editor, ConversorDeTimeline())
```
Depois, a timeline convertida entra no `ContextoDeAnalise` e o Scanner executa
`AnaliseVisualDaTimeline`. A classe de acesso isola o MCP e preserva a separação
entre a integração com o Premiere e os analyzers locais.
Não duplicar esse acesso em novos adapters ou analyzers. Para testes, injetar um
fake de `AcessoAoEditor`/`AcessoATimeline` ou usar uma timeline de domínio pronta.
## Captura para análise visual
A análise visual não exporta um frame renderizado pelo Premiere. Para cada clipe
de vídeo, o fluxo usa os campos retornados pelo MCP em `get_active_sequence`:
1. `sourceFile` identifica o arquivo original do `projectItem`.
2. `inPoint` e `outPoint` delimitam o trecho usado pelo clipe.
3. O extrator abre `sourceFile` localmente e amostra o primeiro frame do trecho,
usando `inPoint` como tempo inicial na origem.
4. Vision/OpenCV/ONNX recebem esse frame persistido, mantendo o timestamp de
origem; nenhum frame é exportado da timeline do Premiere.
Portanto, `start`/`end` são tempos da timeline e `inPoint`/`outPoint` são tempos
do arquivo original. Não misturar esses relógios ao analisar um clipe.
O Scanner usa apenas as interfaces em `contratos.py`. As bibliotecas externas
ficam em adapters concretos, para que possam ser habilitadas, substituídas ou
testadas isoladamente.
| Papel | Adapter |
| --- | --- |
| Extrair frames | `ExtratorDeQuadrosOpenCV` |
| Medir qualidade | `AnalisadorDeQualidadeOpenCV` |
| Rosto e olhos visíveis | `AnalisadorDeRostosOpenCV` |
| Composição e área para legendas | `AnalisadorDeComposicaoOpenCV` |
| Tremor/movimento de câmera | `AnalisadorDeTremorOpenCV` |
| Continuidade e frame congelado | `AnalisadorDeContinuidadeOpenCV` |
| Detectar objetos | `AnalisadorDeObjetosONNX` |
| Detectar pose e mãos | `AnalisadorDePoseMediaPipe` |
| OCR e faces macOS | `AnalisadorAppleVision` |
| Vision + Apple Intelligence (Swift isolado) | `AnalisadorAppleVisionNativo` |
| Similaridade temporal por feature print | `AnalisadorSequenciaAppleVisionNativo` |
| Extrair frames para Vision sem OpenCV | `ExtratorDeQuadrosFFmpeg` |
| Detectar cenas | `DetectorDeCenasPySceneDetect` |
`AnalisadorDeObjetosONNX` exige dois adapters específicos do modelo:
`preparar(imagem, entradas)` e `decodificar(saidas, quadro)`. Isso mantém os
detalhes de cada arquivo ONNX fora do Scanner e permite trocar de modelo sem
alterar a interface do domínio.
`AnalisadorDePoseMediaPipe` recebe caminhos explícitos para os arquivos `.task`
de pose e mãos. Os modelos ficam em `/Volumes/Merongo/SISTEMAS/MODELOSIA/mediapipe/`
e não são acoplados ao pacote Python. Ele executa em subprocesso: falhas nativas
do MediaPipe são contidas e retornam como indisponibilidade do adapter, sem
encerrar o processo do Scanner.
`AnalisadorDeRostosOpenCV` usa os arquivos locais
`haarcascade_frontalface_default.xml` e `haarcascade_eye.xml`. Quando a
distribuição do OpenCV não os incluir, forneça `diretorio_de_modelos` apontando
para a pasta que os contém.
`AnalisadorAppleVisionNativo` é a opção Apple recomendada. Seu runner Swift
executa faces/landmarks, qualidade facial, pessoas, pose, mãos, OCR,
classificação e estética de forma independente. A interpretação editorial pelo
`FoundationModels` só é criada a partir dessas evidências e é salva em separado.
Falhas nativas por recurso viram `diagnostico_apple_vision`, sem derrubar o
Scanner nem descartar os fatos que funcionaram.
Além de rostos, pessoas, pose, mãos, OCR, categorias e estética, o runner
Apple retorna códigos/QR, horizonte, retângulos, densidade de contornos,
ocupação da máscara de pessoas e similaridade visual entre frames. Os valores
são fatos normalizados; descrição, contexto e ação devem ser derivados depois,
a partir dessas evidências temporizadas.
## Montagem
```python
from engine.integracoes.visual import (
AnalisadorDeQualidadeOpenCV,
AnalisadorDeRostosOpenCV,
AnalisadorDeComposicaoOpenCV,
AnalisadorDeTremorOpenCV,
AnalisadorDeContinuidadeOpenCV,
DetectorDeCenasPySceneDetect,
ExtratorDeQuadrosOpenCV,
)
from engine.scanner.visual import DetectorDeCenas
detector = DetectorDeCenas(
ExtratorDeQuadrosOpenCV(intervalo_em_segundos=1.0, diretorio_de_cache=".frames"),
analisadores_de_frame=[
AnalisadorDeQualidadeOpenCV(), AnalisadorDeRostosOpenCV(),
AnalisadorDeComposicaoOpenCV(),
],
analisadores_de_sequencia=[
AnalisadorDeTremorOpenCV(), AnalisadorDeContinuidadeOpenCV(),
],
detectores_de_intervalo=[DetectorDeCenasPySceneDetect()],
)
```
Depois, passe esse `DetectorDeCenas` para `AnaliseVisualDaTimeline` ao montar o
`PipelineDoScanner`.
Para o caminho local padrão, a montagem pode ser reduzida a:
```python
from engine.scanner import AnaliseVisualDaTimeline, PipelineDoScanner, criar_detector_visual_local
pipeline = PipelineDoScanner([AnaliseVisualDaTimeline(criar_detector_visual_local())])
contexto = pipeline.executar(contexto)
```
O resultado fica em `contexto.caracteristicas_visuais` (por clipe),
`contexto.cenas_visuais` (cenas com observações) e `contexto.avisos`.
Quando OpenCV/FFmpeg ou outro adapter opcional não estiver disponível, o clipe
é marcado com `disponivel=False` e os demais continuam sendo processados.
## Montagem recomendada para Apple Vision
```python
from engine.scanner import AnaliseVisualDaTimeline, PipelineDoScanner, criar_detector_apple_vision
pipeline = PipelineDoScanner([
AnaliseVisualDaTimeline(criar_detector_apple_vision(intervalo_em_segundos=1.0)),
])
```
Esse detector extrai PNGs do arquivo original com FFmpeg, usando o intervalo
`inPoint`/`outPoint` do clipe, e não exporta imagens renderizadas pelo Premiere.
Ele também recua a amostra no fim de vídeos muito curtos quando não há um frame
decodificável exatamente no timestamp solicitado.
Os analyzers de sequência não tentam concluir a intenção de uma pessoa. Eles
retornam indícios temporais (`possivel_tremor`, `possivel_descontinuidade` e
`possivel_frame_congelado`) para que a camada editorial decida como tratá-los.
@@ -0,0 +1,20 @@
from .analisadores import (AnalisadorAppleVision, AnalisadorDeComposicaoOpenCV,
AnalisadorDeContinuidadeOpenCV, AnalisadorDeObjetosONNX,
AnalisadorDePoseMediaPipe, AnalisadorDeQualidadeOpenCV,
AnalisadorDeRostosOpenCV, AnalisadorDeTremorOpenCV)
from .apple_vision import (AnalisadorAppleVisionNativo, AnalisadorSequenciaAppleVisionNativo,
ExecutorDoRunnerApple)
from .contratos import (AnalisadorDeFrame, AnalisadorDeSequenciaDeQuadros,
DetectorDeIntervalosDeCena, ExtratorDeQuadros)
from .detectores_de_cena import DetectorDeCenasPySceneDetect
from .extrator_open_cv import ExtratorDeQuadrosOpenCV
from .extrator_ffmpeg import ExtratorDeQuadrosFFmpeg
from .modelos import QuadroDeVideo
__all__ = ["AnalisadorAppleVision", "AnalisadorAppleVisionNativo", "AnalisadorSequenciaAppleVisionNativo", "AnalisadorDeComposicaoOpenCV",
"AnalisadorDeContinuidadeOpenCV", "AnalisadorDeFrame",
"AnalisadorDeObjetosONNX", "AnalisadorDePoseMediaPipe",
"AnalisadorDeQualidadeOpenCV", "AnalisadorDeRostosOpenCV",
"AnalisadorDeSequenciaDeQuadros", "AnalisadorDeTremorOpenCV",
"DetectorDeCenasPySceneDetect", "DetectorDeIntervalosDeCena",
"ExecutorDoRunnerApple", "ExtratorDeQuadros", "ExtratorDeQuadrosFFmpeg", "ExtratorDeQuadrosOpenCV", "QuadroDeVideo"]
@@ -0,0 +1,331 @@
import json
from pathlib import Path
import subprocess
import sys
from typing import Any, Callable
from ...scanner.modelos import EvidenciaVisual
from .contratos import AnalisadorDeFrame, AnalisadorDeSequenciaDeQuadros
from .modelos import QuadroDeVideo
class AnalisadorDeQualidadeOpenCV(AnalisadorDeFrame):
"""Mede nitidez, brilho e contraste de cada frame usando OpenCV."""
nome = "opencv"
def __init__(self, cv2_module: Any | None = None) -> None:
self._cv2 = cv2_module
@property
def cv2(self) -> Any:
if self._cv2 is None:
try:
import cv2
except ImportError as exc:
raise RuntimeError("OpenCV não está instalado. Instale opencv-python.") from exc
self._cv2 = cv2
return self._cv2
def analisar(self, quadro: QuadroDeVideo) -> list[EvidenciaVisual]:
imagem = quadro.imagem
cinza = self.cv2.cvtColor(imagem, self.cv2.COLOR_BGR2GRAY) if len(imagem.shape) == 3 else imagem
media, desvio = self.cv2.meanStdDev(cinza)
nitidez = float(self.cv2.Laplacian(cinza, self.cv2.CV_64F).var())
valor = {
"largura": quadro.largura,
"altura": quadro.altura,
"brilho": float(media[0][0]),
"contraste": float(desvio[0][0]),
"nitidez_laplaciana": nitidez,
}
return [EvidenciaVisual("qualidade", quadro.timestamp, quadro.timestamp, valor,
provider=self.nome)]
class _AdapterOpenCV:
"""Implementação compartilhada para adapters OpenCV, com importação tardia."""
def __init__(self, cv2_module: Any | None = None) -> None:
self._cv2 = cv2_module
@property
def cv2(self) -> Any:
if self._cv2 is None:
try:
import cv2
except ImportError as exc:
raise RuntimeError("OpenCV não está instalado. Instale opencv-python.") from exc
self._cv2 = cv2
return self._cv2
class AnalisadorDeRostosOpenCV(_AdapterOpenCV, AnalisadorDeFrame):
"""Encontra rostos e olhos localmente; não infere identidade nem emoção."""
nome = "opencv_haar"
def __init__(self, escala: float = 1.1, vizinhos_minimos: int = 5,
diretorio_de_modelos: str | Path | None = None,
tamanho_minimo_relativo: float = 0.04,
cv2_module: Any | None = None) -> None:
super().__init__(cv2_module)
self.escala = escala
self.vizinhos_minimos = vizinhos_minimos
self.diretorio_de_modelos = Path(diretorio_de_modelos) if diretorio_de_modelos else None
self.tamanho_minimo_relativo = tamanho_minimo_relativo
self._detectores: tuple[Any, Any] | None = None
def analisar(self, quadro: QuadroDeVideo) -> list[EvidenciaVisual]:
detector_de_rostos, detector_de_olhos = self._obter_detectores()
cinza = self.cv2.cvtColor(quadro.imagem, self.cv2.COLOR_BGR2GRAY)
rostos = detector_de_rostos.detectMultiScale(cinza, scaleFactor=self.escala,
minNeighbors=self.vizinhos_minimos)
evidencias: list[EvidenciaVisual] = []
for x, y, largura, altura in rostos:
caixa = _caixa_normalizada(x, y, largura, altura, quadro.largura, quadro.altura)
if min(caixa["largura"], caixa["altura"]) < self.tamanho_minimo_relativo:
continue
rosto = EvidenciaVisual("rosto", quadro.timestamp, quadro.timestamp,
{"bounding_box": caixa, "proximo_da_borda": _proximo_da_borda(caixa)}, provider=self.nome)
olhos = detector_de_olhos.detectMultiScale(cinza[y:y + altura, x:x + largura],
scaleFactor=1.1, minNeighbors=4)
evidencias.extend((rosto, EvidenciaVisual("olhos_visiveis", quadro.timestamp, quadro.timestamp,
{"quantidade": len(olhos), "rosto": caixa}, provider=self.nome)))
return evidencias
def _obter_detectores(self) -> tuple[Any, Any]:
if self._detectores is None:
base = self.diretorio_de_modelos or Path(self.cv2.data.haarcascades)
rostos = self.cv2.CascadeClassifier(str(base / "haarcascade_frontalface_default.xml"))
olhos = self.cv2.CascadeClassifier(str(base / "haarcascade_eye.xml"))
if rostos.empty() or olhos.empty():
raise RuntimeError(f"Cascades Haar do OpenCV não estão disponíveis em: {base}")
self._detectores = rostos, olhos
return self._detectores
class AnalisadorDeComposicaoOpenCV(_AdapterOpenCV, AnalisadorDeFrame):
"""Mede orientação, poluição visual e disponibilidade das zonas para legendas."""
nome = "opencv_composicao"
def analisar(self, quadro: QuadroDeVideo) -> list[EvidenciaVisual]:
cinza = self.cv2.cvtColor(quadro.imagem, self.cv2.COLOR_BGR2GRAY)
bordas = self.cv2.Canny(cinza, 80, 160)
densidade_de_bordas = float((bordas > 0).mean())
terco_inferior = cinza[int(quadro.altura * 2 / 3):, :]
zona_de_legenda_livre = float((self.cv2.Canny(terco_inferior, 80, 160) > 0).mean()) < 0.08
valor = {
"orientacao": "vertical" if quadro.altura > quadro.largura else "horizontal",
"densidade_de_bordas": densidade_de_bordas,
"fundo_visual_poluido": densidade_de_bordas > 0.16,
"zona_inferior_livre_para_legenda": zona_de_legenda_livre,
}
return [EvidenciaVisual("composicao", quadro.timestamp, quadro.timestamp, valor, provider=self.nome)]
class AnalisadorDeTremorOpenCV(_AdapterOpenCV, AnalisadorDeSequenciaDeQuadros):
"""Estima deslocamento global entre frames para sinalizar possível tremor de câmera."""
nome = "opencv_movimento"
def analisar(self, quadros: list[QuadroDeVideo]) -> list[EvidenciaVisual]:
if len(quadros) < 2:
return []
deslocamentos = [_deslocamento_global(self.cv2, anterior.imagem, atual.imagem)
for anterior, atual in zip(quadros, quadros[1:])]
deslocamentos = [item for item in deslocamentos if item is not None]
if not deslocamentos:
return []
medio = sum(deslocamentos) / len(deslocamentos)
variacao = sum(abs(item - medio) for item in deslocamentos) / len(deslocamentos)
inicio, fim = quadros[0].timestamp, quadros[-1].timestamp
return [EvidenciaVisual("movimento_de_camera", inicio, fim, {
"deslocamento_medio_pixels": medio,
"variacao_do_deslocamento": variacao,
"possivel_tremor": variacao > 2.0 and medio > 1.0,
"amostras": len(deslocamentos),
}, provider=self.nome)]
class AnalisadorDeContinuidadeOpenCV(_AdapterOpenCV, AnalisadorDeSequenciaDeQuadros):
"""Compara pares de frames e retorna indícios, não certezas, de descontinuidade ou congelamento."""
nome = "opencv_continuidade"
def analisar(self, quadros: list[QuadroDeVideo]) -> list[EvidenciaVisual]:
evidencias: list[EvidenciaVisual] = []
for anterior, atual in zip(quadros, quadros[1:]):
cinza_anterior = self.cv2.cvtColor(anterior.imagem, self.cv2.COLOR_BGR2GRAY)
cinza_atual = self.cv2.cvtColor(atual.imagem, self.cv2.COLOR_BGR2GRAY)
diferenca = float(self.cv2.absdiff(cinza_anterior, cinza_atual).mean())
evidencias.append(EvidenciaVisual("continuidade", anterior.timestamp, atual.timestamp, {
"diferenca_media_de_luminancia": diferenca,
"possivel_frame_congelado": diferenca < 0.8,
"possivel_descontinuidade": diferenca > 38.0,
}, provider=self.nome))
return evidencias
class AnalisadorDeObjetosONNX(AnalisadorDeFrame):
"""Adapter de modelo ONNX; pré e pós-processamento pertencem ao modelo escolhido."""
nome = "onnxruntime"
def __init__(self, modelo: str | Path, preparar: Callable[[Any, list[str]], dict[str, Any]],
decodificar: Callable[[list[Any], QuadroDeVideo], list[dict[str, Any]]],
usar_coreml: bool = True, ort_module: Any | None = None) -> None:
self.modelo = str(modelo)
self.preparar = preparar
self.decodificar = decodificar
self._ort = ort_module
ort = self.ort
preferidos = ["CoreMLExecutionProvider", "CPUExecutionProvider"] if usar_coreml else ["CPUExecutionProvider"]
disponiveis = set(ort.get_available_providers())
self.providers = [provider for provider in preferidos if provider in disponiveis]
if not self.providers:
raise RuntimeError("ONNX Runtime não possui provider compatível neste ambiente.")
self.sessao = ort.InferenceSession(self.modelo, providers=self.providers)
self.entradas = [entrada.name for entrada in self.sessao.get_inputs()]
@property
def ort(self) -> Any:
if self._ort is None:
try:
import onnxruntime
except ImportError as exc:
raise RuntimeError("ONNX Runtime não está instalado. Instale onnxruntime.") from exc
self._ort = onnxruntime
return self._ort
def analisar(self, quadro: QuadroDeVideo) -> list[EvidenciaVisual]:
entradas = self.preparar(quadro.imagem, self.entradas)
saidas = self.sessao.run(None, entradas)
deteccoes = self.decodificar(saidas, quadro)
return [EvidenciaVisual("objeto", quadro.timestamp, quadro.timestamp, deteccao,
confianca=deteccao.get("confianca"), provider=self.nome,
modelo=Path(self.modelo).name) for deteccao in deteccoes]
class AnalisadorDePoseMediaPipe(AnalisadorDeFrame):
"""Adapter MediaPipe Tasks para pose e mãos em um frame."""
nome = "mediapipe"
def __init__(self, modelo_de_pose: str | Path | None = None,
modelo_de_maos: str | Path | None = None) -> None:
self.modelo_de_pose = Path(modelo_de_pose) if modelo_de_pose else None
self.modelo_de_maos = Path(modelo_de_maos) if modelo_de_maos else None
if self.modelo_de_pose is None and self.modelo_de_maos is None:
raise ValueError("Informe ao menos um modelo MediaPipe de pose ou mãos.")
def analisar(self, quadro: QuadroDeVideo) -> list[EvidenciaVisual]:
if quadro.caminho is None:
raise ValueError("MediaPipe requer que o frame tenha caminho persistido em disco.")
for modelo in (self.modelo_de_pose, self.modelo_de_maos):
if modelo:
self._validar_modelo(modelo)
carga = {
"frame": str(quadro.caminho), "pose": str(self.modelo_de_pose) if self.modelo_de_pose else None,
"maos": str(self.modelo_de_maos) if self.modelo_de_maos else None,
}
processo = subprocess.run(
[sys.executable, "-m", "engine.integracoes.visual.mediapipe_runner"],
input=json.dumps(carga), capture_output=True, text=True, timeout=60,
)
if processo.returncode != 0:
detalhe = processo.stderr.strip().splitlines()[-1] if processo.stderr.strip() else "falha nativa sem diagnóstico"
raise RuntimeError(f"MediaPipe falhou em processo isolado (código {processo.returncode}): {detalhe}")
dados = json.loads(processo.stdout)
return [EvidenciaVisual(item["tipo"], quadro.timestamp, quadro.timestamp, item["valor"],
provider=self.nome) for item in dados]
@staticmethod
def _validar_modelo(caminho: Path) -> None:
if not caminho.is_file():
raise FileNotFoundError(f"Modelo MediaPipe não encontrado: {caminho}")
class AnalisadorAppleVision(AnalisadorDeFrame):
"""Usa PyObjC/Vision para OCR e detecção de faces em frames persistidos."""
nome = "apple_vision"
def __init__(self, reconhecer_texto: bool = True, detectar_faces: bool = True) -> None:
self.reconhecer_texto = reconhecer_texto
self.detectar_faces = detectar_faces
def analisar(self, quadro: QuadroDeVideo) -> list[EvidenciaVisual]:
if quadro.caminho is None:
raise ValueError("Apple Vision requer que o frame tenha caminho persistido em disco.")
try:
from Foundation import NSURL
from Vision import VNDetectFaceRectanglesRequest, VNImageRequestHandler, VNRecognizeTextRequest
except ImportError as exc:
raise RuntimeError("Apple Vision requer PyObjC e macOS.") from exc
requisicoes = []
texto = VNRecognizeTextRequest.alloc().initWithCompletionHandler_(None) if self.reconhecer_texto else None
faces = VNDetectFaceRectanglesRequest.alloc().initWithCompletionHandler_(None) if self.detectar_faces else None
if texto:
requisicoes.append(texto)
if faces:
requisicoes.append(faces)
handler = VNImageRequestHandler.alloc().initWithURL_options_(NSURL.fileURLWithPath_(str(quadro.caminho)), {})
sucesso, erro = handler.performRequests_error_(requisicoes, None)
if not sucesso:
detalhe = str(erro) if erro else "o macOS não retornou detalhe; verifique Vision/Neural Engine no host"
raise RuntimeError(f"Apple Vision falhou: {detalhe}")
evidencias: list[EvidenciaVisual] = []
if texto:
for observacao in texto.results() or []:
candidatos = observacao.topCandidates_(1)
if candidatos:
candidato = candidatos[0]
evidencias.append(EvidenciaVisual("ocr", quadro.timestamp, quadro.timestamp,
{"texto": str(candidato.string()), "bounding_box": _retangulo(observacao.boundingBox())},
confianca=float(candidato.confidence()), provider=self.nome))
if faces:
for observacao in faces.results() or []:
evidencias.append(EvidenciaVisual("rosto", quadro.timestamp, quadro.timestamp,
{"bounding_box": _retangulo(observacao.boundingBox())}, provider=self.nome))
return evidencias
def _retangulo(retangulo: Any) -> dict[str, float]:
return {"x": float(retangulo.origin.x), "y": float(retangulo.origin.y),
"largura": float(retangulo.size.width), "altura": float(retangulo.size.height)}
def _caixa_normalizada(x: int, y: int, largura: int, altura: int,
largura_do_frame: int, altura_do_frame: int) -> dict[str, float]:
return {"x": float(x / largura_do_frame), "y": float(y / altura_do_frame),
"largura": float(largura / largura_do_frame), "altura": float(altura / altura_do_frame)}
def _proximo_da_borda(caixa: dict[str, float], margem: float = 0.04) -> bool:
return (caixa["x"] < margem or caixa["y"] < margem
or caixa["x"] + caixa["largura"] > 1 - margem
or caixa["y"] + caixa["altura"] > 1 - margem)
def _deslocamento_global(cv2: Any, imagem_anterior: Any, imagem_atual: Any) -> float | None:
"""Retorna o módulo do deslocamento de câmera, descartando pares sem pontos úteis."""
import math
anterior = cv2.cvtColor(imagem_anterior, cv2.COLOR_BGR2GRAY)
atual = cv2.cvtColor(imagem_atual, cv2.COLOR_BGR2GRAY)
pontos = cv2.goodFeaturesToTrack(anterior, maxCorners=150, qualityLevel=0.01,
minDistance=12, blockSize=7)
if pontos is None or len(pontos) < 8:
return None
encontrados, status, _ = cv2.calcOpticalFlowPyrLK(anterior, atual, pontos, None)
if encontrados is None or status is None:
return None
origem = pontos[status.ravel() == 1]
destino = encontrados[status.ravel() == 1]
if len(origem) < 8:
return None
matriz, _ = cv2.estimateAffinePartial2D(origem, destino, method=cv2.RANSAC)
if matriz is None:
return None
return math.hypot(float(matriz[0, 2]), float(matriz[1, 2]))
@@ -0,0 +1,126 @@
"""Adapter Python para o runner Swift que concentra Vision e Apple Intelligence."""
import json
import os
from pathlib import Path
import subprocess
import tempfile
from typing import Any, Callable
from ...scanner.modelos import EvidenciaVisual
from .contratos import AnalisadorDeFrame, AnalisadorDeSequenciaDeQuadros
from .modelos import QuadroDeVideo
RECURSOS_PADRAO = (
"faces", "qualidade_facial", "pessoas", "pose", "maos", "ocr", "categorias",
"estetica", "codigos", "horizonte", "retangulos", "contornos", "segmentacao_de_pessoas",
)
class ExecutorDoRunnerApple:
"""Compila o runner Swift quando necessário e o executa em processo isolado."""
def __init__(self, fonte: str | Path | None = None, compilador: str = "swiftc",
diretorio_de_build: str | Path | None = None) -> None:
self.fonte = Path(fonte) if fonte else Path(__file__).with_name("apple_vision_runner.swift")
self.compilador = compilador
self.diretorio_de_build = Path(diretorio_de_build) if diretorio_de_build else (
Path(tempfile.gettempdir()) / "jhonny-apple-vision")
def executar(self, carga: dict[str, Any], timeout: float) -> dict[str, Any]:
executavel = self._garantir_compilado()
processo = subprocess.run([str(executavel)], input=json.dumps(carga), capture_output=True,
text=True, timeout=timeout)
if processo.returncode != 0:
detalhe = processo.stderr.strip() or "runner Apple terminou sem diagnóstico"
raise RuntimeError(f"Runner Apple Vision falhou: {detalhe}")
try:
return json.loads(processo.stdout)
except json.JSONDecodeError as exc:
raise RuntimeError(f"Runner Apple Vision devolveu JSON inválido: {processo.stdout!r}") from exc
def _garantir_compilado(self) -> Path:
if not self.fonte.is_file():
raise FileNotFoundError(f"Fonte do runner Apple não encontrada: {self.fonte}")
self.diretorio_de_build.mkdir(parents=True, exist_ok=True)
executavel = self.diretorio_de_build / "apple-vision-runner"
if not executavel.exists() or executavel.stat().st_mtime < self.fonte.stat().st_mtime:
ambiente = os.environ | {"CLANG_MODULE_CACHE_PATH": str(self.diretorio_de_build / "module-cache")}
processo = subprocess.run([self.compilador, "-parse-as-library", str(self.fonte), "-o", str(executavel)],
capture_output=True, text=True, timeout=120, env=ambiente)
if processo.returncode != 0:
raise RuntimeError(f"Não foi possível compilar runner Apple Vision: {processo.stderr.strip()}")
return executavel
class AnalisadorAppleVisionNativo(AnalisadorDeFrame):
"""Módulo profundo: pede fatos visuais nativos e opcionalmente interpretação local.
A interface recebe somente um `QuadroDeVideo`; Vision, Foundation Models,
Swift, compilação e erros nativos permanecem atrás deste adapter.
"""
nome = "apple_vision"
def __init__(self, recursos: tuple[str, ...] = RECURSOS_PADRAO,
interpretar_com_apple_intelligence: bool = True,
executor: ExecutorDoRunnerApple | None = None,
timeout_em_segundos: float = 90.0) -> None:
self.recursos = recursos
self.interpretar_com_apple_intelligence = interpretar_com_apple_intelligence
self.executor = executor or ExecutorDoRunnerApple()
self.timeout_em_segundos = timeout_em_segundos
def analisar(self, quadro: QuadroDeVideo) -> list[EvidenciaVisual]:
if quadro.caminho is None:
raise ValueError("Apple Vision requer que o frame tenha caminho persistido em disco.")
dados = self.executor.executar({"frame": str(quadro.caminho), "recursos": list(self.recursos),
"resumir": self.interpretar_com_apple_intelligence},
self.timeout_em_segundos)
evidencias = [self._converter(item, quadro) for item in dados.get("evidencias", [])]
avisos = dados.get("avisos", [])
if avisos:
evidencias.append(EvidenciaVisual("diagnostico_apple_vision", quadro.timestamp, quadro.timestamp,
{"avisos": avisos, "recursos_solicitados": list(self.recursos)}, provider=self.nome))
return evidencias
def _converter(self, item: dict[str, Any], quadro: QuadroDeVideo) -> EvidenciaVisual:
return EvidenciaVisual(item["tipo"], quadro.timestamp, quadro.timestamp, item["valor"],
confianca=item.get("confianca"), provider=self.nome,
modelo=item.get("modelo"))
class AnalisadorSequenciaAppleVisionNativo(AnalisadorDeSequenciaDeQuadros):
"""Compara frames com feature prints nativos sem expor o protocolo Swift."""
nome = "apple_vision_sequencia"
def __init__(self, executor: ExecutorDoRunnerApple | None = None,
timeout_em_segundos: float = 90.0) -> None:
self.executor = executor or ExecutorDoRunnerApple()
self.timeout_em_segundos = timeout_em_segundos
def analisar(self, quadros: list[QuadroDeVideo]) -> list[EvidenciaVisual]:
if len(quadros) < 2:
return []
if any(quadro.caminho is None for quadro in quadros):
raise ValueError("Apple Vision de sequência requer frames persistidos em disco.")
dados = self.executor.executar({"frames": [str(quadro.caminho) for quadro in quadros],
"recursos": [], "resumir": False}, self.timeout_em_segundos)
evidencias: list[EvidenciaVisual] = []
for item in dados.get("evidencias", []):
valor = dict(item["valor"])
inicio = int(valor.pop("frame_inicial", 0))
fim = int(valor.pop("frame_final", inicio + 1))
if inicio < 0 or fim >= len(quadros) or fim < inicio:
raise RuntimeError("Runner Apple Vision devolveu índices temporais inválidos.")
evidencias.append(EvidenciaVisual(item["tipo"], quadros[inicio].timestamp,
quadros[fim].timestamp, valor,
confianca=item.get("confianca"), provider=self.nome,
modelo=item.get("modelo")))
avisos = dados.get("avisos", [])
if avisos:
evidencias.append(EvidenciaVisual("diagnostico_apple_vision", quadros[0].timestamp,
quadros[-1].timestamp, {"avisos": avisos}, provider=self.nome))
return evidencias
@@ -0,0 +1,368 @@
import Foundation
import ImageIO
import Vision
import FoundationModels
import CoreVideo
struct Entrada: Decodable {
let frame: String?
let frames: [String]?
let recursos: [String]
let resumir: Bool
}
struct Evidencia: Encodable {
let tipo: String
let valor: [String: JSONValue]
let confianca: Double?
let modelo: String?
}
struct Saida: Encodable {
let evidencias: [Evidencia]
let avisos: [String]
}
enum JSONValue: Encodable {
case texto(String), numero(Double), booleano(Bool), objeto([String: JSONValue]), lista([JSONValue]), nulo
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
switch self {
case .texto(let value): try container.encode(value)
case .numero(let value): try container.encode(value)
case .booleano(let value): try container.encode(value)
case .objeto(let value): try container.encode(value)
case .lista(let value): try container.encode(value)
case .nulo: try container.encodeNil()
}
}
}
func caixa(_ rect: CGRect) -> [String: JSONValue] {
["x": .numero(rect.origin.x), "y": .numero(rect.origin.y),
"largura": .numero(rect.size.width), "altura": .numero(rect.size.height)]
}
func ponto(_ point: CGPoint) -> JSONValue {
.objeto(["x": .numero(point.x), "y": .numero(point.y)])
}
func ponto(_ point: VNRecognizedPoint) -> JSONValue {
.objeto(["x": .numero(point.location.x), "y": .numero(point.location.y),
"confianca": .numero(Double(point.confidence))])
}
func executar<T: VNRequest>(_ request: T, em url: URL) throws -> [VNObservation] {
guard let source = CGImageSourceCreateWithURL(url as CFURL, nil),
let imagem = CGImageSourceCreateImageAtIndex(source, 0, nil) else {
throw NSError(domain: "JhonnyAppleVision", code: 1,
userInfo: [NSLocalizedDescriptionKey: "ImageIO não conseguiu decodificar o frame"])
}
let handler = VNImageRequestHandler(cgImage: imagem, options: [:])
try handler.perform([request])
return request.results ?? []
}
func coberturaDaMascara(_ pixelBuffer: CVPixelBuffer) -> Double {
CVPixelBufferLockBaseAddress(pixelBuffer, .readOnly)
defer { CVPixelBufferUnlockBaseAddress(pixelBuffer, .readOnly) }
guard let base = CVPixelBufferGetBaseAddress(pixelBuffer) else { return 0 }
let altura = CVPixelBufferGetHeight(pixelBuffer)
let largura = CVPixelBufferGetWidth(pixelBuffer)
let stride = CVPixelBufferGetBytesPerRow(pixelBuffer)
let bytes = base.assumingMemoryBound(to: UInt8.self)
var ocupados = 0
for y in 0..<altura {
for x in 0..<largura where bytes[y * stride + x] > 12 { ocupados += 1 }
}
return Double(ocupados) / Double(max(1, altura * largura))
}
func analisarVision(_ entrada: Entrada) -> Saida {
guard let frame = entrada.frame else {
return Saida(evidencias: [], avisos: ["frame: caminho obrigatório para análise individual"])
}
let url = URL(fileURLWithPath: frame)
var evidencias: [Evidencia] = []
var avisos: [String] = []
func tentar(_ recurso: String, _ bloco: () throws -> [Evidencia]) {
guard entrada.recursos.contains(recurso) else { return }
do { evidencias.append(contentsOf: try bloco()) }
catch { avisos.append("\(recurso): \(error.localizedDescription)") }
}
tentar("faces") {
let request = VNDetectFaceLandmarksRequest()
return try executar(request, em: url).compactMap { observacao -> Evidencia? in
guard let face = observacao as? VNFaceObservation else { return nil }
var valor: [String: JSONValue] = ["bounding_box": .objeto(caixa(face.boundingBox))]
if let landmarks = face.landmarks {
let grupos: [(String, VNFaceLandmarkRegion2D?)] = [
("olho_esquerdo", landmarks.leftEye), ("olho_direito", landmarks.rightEye),
("sobrancelha_esquerda", landmarks.leftEyebrow), ("sobrancelha_direita", landmarks.rightEyebrow),
("nariz", landmarks.nose), ("labios", landmarks.outerLips), ("rosto", landmarks.faceContour)
]
valor["landmarks"] = .objeto(Dictionary(uniqueKeysWithValues: grupos.compactMap { nome, regiao in
guard let regiao else { return nil }
return (nome, .lista(regiao.normalizedPoints.map { .objeto(["x": .numero($0.x), "y": .numero($0.y)]) }))
}))
}
return Evidencia(tipo: "rosto", valor: valor, confianca: Double(face.confidence), modelo: "VNDetectFaceLandmarksRequest")
}
}
tentar("qualidade_facial") {
let request = VNDetectFaceCaptureQualityRequest()
return try executar(request, em: url).compactMap { observacao -> Evidencia? in
guard let face = observacao as? VNFaceObservation, let qualidade = face.faceCaptureQuality else { return nil }
return Evidencia(tipo: "qualidade_do_rosto", valor: ["bounding_box": .objeto(caixa(face.boundingBox)),
"score": .numero(Double(qualidade))], confianca: Double(face.confidence), modelo: "VNDetectFaceCaptureQualityRequest")
}
}
tentar("pessoas") {
let request = VNDetectHumanRectanglesRequest()
request.upperBodyOnly = false
return try executar(request, em: url).compactMap { observacao in
guard let pessoa = observacao as? VNHumanObservation else { return nil }
return Evidencia(tipo: "pessoa", valor: ["bounding_box": .objeto(caixa(pessoa.boundingBox)),
"ocupacao_do_quadro": .numero(pessoa.boundingBox.width * pessoa.boundingBox.height)],
confianca: Double(pessoa.confidence), modelo: "VNDetectHumanRectanglesRequest")
}
}
tentar("pose") {
let request = VNDetectHumanBodyPoseRequest()
return try executar(request, em: url).compactMap { observacao in
guard let pose = observacao as? VNHumanBodyPoseObservation else { return nil }
let pontos = try? pose.recognizedPoints(.all)
let dados: [String: JSONValue] = pontos.map { Dictionary(uniqueKeysWithValues: $0.map { (String(describing: $0.key), ponto($0.value)) }) } ?? [:]
return Evidencia(tipo: "pose", valor: ["pontos": .objeto(dados)], confianca: Double(pose.confidence), modelo: "VNDetectHumanBodyPoseRequest")
}
}
tentar("maos") {
let request = VNDetectHumanHandPoseRequest()
request.maximumHandCount = 4
return try executar(request, em: url).compactMap { observacao in
guard let mao = observacao as? VNHumanHandPoseObservation else { return nil }
let pontos = try? mao.recognizedPoints(.all)
let dados: [String: JSONValue] = pontos.map { Dictionary(uniqueKeysWithValues: $0.map { (String(describing: $0.key), ponto($0.value)) }) } ?? [:]
return Evidencia(tipo: "mao", valor: ["pontos": .objeto(dados)], confianca: Double(mao.confidence), modelo: "VNDetectHumanHandPoseRequest")
}
}
tentar("ocr") {
let request = VNRecognizeTextRequest()
request.recognitionLevel = .accurate
request.recognitionLanguages = ["pt-BR", "en-US"]
return try executar(request, em: url).compactMap { observacao in
guard let texto = observacao as? VNRecognizedTextObservation,
let candidato = texto.topCandidates(1).first else { return nil }
return Evidencia(tipo: "ocr", valor: ["texto": .texto(candidato.string),
"bounding_box": .objeto(caixa(texto.boundingBox))], confianca: Double(candidato.confidence), modelo: "VNRecognizeTextRequest")
}
}
tentar("categorias") {
let request = VNClassifyImageRequest()
return try executar(request, em: url).compactMap { observacao in
guard let categoria = observacao as? VNClassificationObservation, categoria.confidence >= 0.2 else { return nil }
return Evidencia(tipo: "categoria", valor: ["identificador": .texto(categoria.identifier)],
confianca: Double(categoria.confidence), modelo: "VNClassifyImageRequest")
}
}
tentar("estetica") {
let request = VNCalculateImageAestheticsScoresRequest()
return try executar(request, em: url).compactMap { observacao in
guard let score = observacao as? VNImageAestheticsScoresObservation else { return nil }
return Evidencia(tipo: "estetica", valor: ["score_global": .numero(Double(score.overallScore))],
confianca: nil, modelo: "VNCalculateImageAestheticsScoresRequest")
}
}
tentar("codigos") {
let request = VNDetectBarcodesRequest()
return try executar(request, em: url).compactMap { observacao in
guard let codigo = observacao as? VNBarcodeObservation else { return nil }
return Evidencia(tipo: "codigo", valor: ["payload": .texto(codigo.payloadStringValue ?? ""),
"simbologia": .texto(codigo.symbology.rawValue), "bounding_box": .objeto(caixa(codigo.boundingBox))],
confianca: Double(codigo.confidence), modelo: "VNDetectBarcodesRequest")
}
}
tentar("horizonte") {
let request = VNDetectHorizonRequest()
return try executar(request, em: url).compactMap { observacao in
guard let horizonte = observacao as? VNHorizonObservation else { return nil }
return Evidencia(tipo: "horizonte", valor: ["angulo_radianos": .numero(Double(horizonte.angle))],
confianca: Double(horizonte.confidence), modelo: "VNDetectHorizonRequest")
}
}
tentar("retangulos") {
let request = VNDetectRectanglesRequest()
return try executar(request, em: url).compactMap { observacao in
guard let retangulo = observacao as? VNRectangleObservation else { return nil }
return Evidencia(tipo: "retangulo", valor: ["bounding_box": .objeto(caixa(retangulo.boundingBox)),
"cantos": .objeto(["superior_esquerdo": ponto(retangulo.topLeft),
"superior_direito": ponto(retangulo.topRight),
"inferior_esquerdo": ponto(retangulo.bottomLeft),
"inferior_direito": ponto(retangulo.bottomRight)])],
confianca: Double(retangulo.confidence), modelo: "VNDetectRectanglesRequest")
}
}
tentar("contornos") {
let request = VNDetectContoursRequest()
return try executar(request, em: url).compactMap { observacao in
guard let contornos = observacao as? VNContoursObservation else { return nil }
return Evidencia(tipo: "contornos", valor: ["quantidade_principal": .numero(Double(contornos.topLevelContours.count))],
confianca: Double(contornos.confidence), modelo: "VNDetectContoursRequest")
}
}
tentar("segmentacao_de_pessoas") {
let request = VNGeneratePersonSegmentationRequest()
request.qualityLevel = .balanced
return try executar(request, em: url).compactMap { observacao in
guard let mascara = observacao as? VNPixelBufferObservation else { return nil }
return Evidencia(tipo: "segmentacao_de_pessoas", valor: ["ocupacao_do_quadro": .numero(coberturaDaMascara(mascara.pixelBuffer))],
confianca: nil, modelo: "VNGeneratePersonSegmentationRequest")
}
}
return Saida(evidencias: evidencias, avisos: avisos)
}
func featurePrint(_ url: URL) throws -> VNFeaturePrintObservation {
let request = VNGenerateImageFeaturePrintRequest()
guard let resultado = try executar(request, em: url).first as? VNFeaturePrintObservation else {
throw NSError(domain: "JhonnyAppleVision", code: 2, userInfo: [NSLocalizedDescriptionKey: "Vision não produziu feature print"])
}
return resultado
}
func analisarSequencia(_ entrada: Entrada) -> Saida {
let caminhos = entrada.frames ?? []
guard caminhos.count >= 2 else { return Saida(evidencias: [], avisos: ["sequencia: informe ao menos dois frames"]) }
var evidencias: [Evidencia] = []
var avisos: [String] = []
for indice in 0..<(caminhos.count - 1) {
do {
var distancia: Float = 0
try featurePrint(URL(fileURLWithPath: caminhos[indice])).computeDistance(
&distancia, to: featurePrint(URL(fileURLWithPath: caminhos[indice + 1])))
evidencias.append(Evidencia(tipo: "similaridade_visual", valor: [
"frame_inicial": .numero(Double(indice)), "frame_final": .numero(Double(indice + 1)),
"distancia_feature_print": .numero(Double(distancia)),
"possivel_mudanca_de_cena": .booleano(distancia > 12),
], confianca: nil, modelo: "VNGenerateImageFeaturePrintRequest"))
} catch { avisos.append("similaridade_visual[\(indice)]: \(error.localizedDescription)") }
}
return Saida(evidencias: evidencias, avisos: avisos)
}
extension Dictionary where Key == String, Value == JSONValue {
func numero(_ chave: String) -> Double? {
if case .numero(let valor)? = self[chave] { return valor }
return nil
}
func texto(_ chave: String) -> String? {
if case .texto(let valor)? = self[chave] { return valor }
return nil
}
func booleano(_ chave: String) -> Bool? {
if case .booleano(let valor)? = self[chave] { return valor }
return nil
}
func objeto(_ chave: String) -> [String: JSONValue]? {
if case .objeto(let valor)? = self[chave] { return valor }
return nil
}
}
/// Traduz uma evidência em uma frase factual com os valores medidos, nunca só o nome do tipo —
/// é isso que alimenta a interpretação editorial, então precisa carregar o fato, não só o rótulo.
func descreverEvidencia(_ evidencia: Evidencia) -> String {
let valor = evidencia.valor
switch evidencia.tipo {
case "rosto":
let grupos = valor.objeto("landmarks")?.count ?? 0
return "rosto detectado (\(grupos) grupos de landmarks mapeados)"
case "qualidade_do_rosto":
guard let score = valor.numero("score") else { return "qualidade do rosto avaliada" }
return "qualidade do rosto: score \(String(format: "%.2f", score))"
case "pessoa":
guard let ocupacao = valor.numero("ocupacao_do_quadro") else { return "pessoa detectada" }
return "pessoa ocupando \(String(format: "%.0f", ocupacao * 100))% do quadro"
case "pose":
let pontos = valor.objeto("pontos")?.count ?? 0
return "pose corporal com \(pontos) pontos reconhecidos"
case "mao":
let pontos = valor.objeto("pontos")?.count ?? 0
return "mão com \(pontos) pontos reconhecidos"
case "ocr":
guard let texto = valor.texto("texto"), !texto.isEmpty else { return "nenhum texto legível na imagem" }
return "texto detectado na imagem: \"\(texto)\""
case "categoria":
guard let identificador = valor.texto("identificador") else { return "categoria detectada" }
let confianca = evidencia.confianca.map { String(format: "%.0f%%", $0 * 100) } ?? "confiança desconhecida"
return "categoria \"\(identificador)\" (\(confianca))"
case "estetica":
guard let score = valor.numero("score_global") else { return "score estético calculado" }
return "score estético global: \(String(format: "%.2f", score))"
case "codigo":
guard let payload = valor.texto("payload"), !payload.isEmpty else { return "código detectado sem payload legível" }
return "código detectado: \"\(payload)\""
case "horizonte":
guard let angulo = valor.numero("angulo_radianos") else { return "horizonte detectado" }
return "horizonte inclinado \(String(format: "%.1f", angulo * 180 / .pi))°"
case "retangulo":
guard let bbox = valor.objeto("bounding_box"), let largura = bbox.numero("largura"), let altura = bbox.numero("altura") else {
return "retângulo/documento detectado no quadro"
}
return "retângulo detectado ocupando \(String(format: "%.0f", largura * 100))%x\(String(format: "%.0f", altura * 100))% do quadro"
case "contornos":
guard let quantidade = valor.numero("quantidade_principal") else { return "contornos detectados" }
return "\(Int(quantidade)) contornos principais detectados"
case "segmentacao_de_pessoas":
guard let ocupacao = valor.numero("ocupacao_do_quadro") else { return "segmentação de pessoa calculada" }
return "silhueta de pessoa cobrindo \(String(format: "%.0f", ocupacao * 100))% do quadro"
case "similaridade_visual":
let mudanca = valor.booleano("possivel_mudanca_de_cena") ?? false
return mudanca ? "possível corte de cena entre os quadros" : "quadros visualmente semelhantes, sem corte de cena"
default:
return evidencia.tipo
}
}
func interpretar(_ saida: Saida) async -> String? {
guard SystemLanguageModel.default.isAvailable else { return nil }
let fatos = saida.evidencias.map(descreverEvidencia).joined(separator: "; ")
guard !fatos.isEmpty else { return nil }
do {
let sessao = LanguageModelSession(instructions: "Você é um assistente editorial. Descreva apenas fatos explicitamente fornecidos; não invente pessoas, emoções, intenção ou identidade. Responda em uma frase curta em português.")
return try await sessao.respond(to: "Evidências visuais disponíveis: \(fatos). Gere uma observação editorial conservadora.").content
} catch { return nil }
}
@main struct Principal {
static func main() async {
do {
let entrada = try JSONDecoder().decode(Entrada.self, from: FileHandle.standardInput.readDataToEndOfFile())
var saida = entrada.frames?.count ?? 0 > 1 ? analisarSequencia(entrada) : analisarVision(entrada)
if entrada.resumir, let resumo = await interpretar(saida) {
saida = Saida(evidencias: saida.evidencias + [Evidencia(tipo: "interpretacao_editorial", valor: ["texto": .texto(resumo)], confianca: nil, modelo: "AppleFoundationModels")], avisos: saida.avisos)
}
let dados = try JSONEncoder().encode(saida)
FileHandle.standardOutput.write(dados)
} catch {
FileHandle.standardError.write(Data("\(error.localizedDescription)\n".utf8))
exit(1)
}
}
}
@@ -0,0 +1,40 @@
from abc import ABC, abstractmethod
from typing import Any
from ...dominio import Clipe
from ...scanner.modelos import Cena, EvidenciaVisual
from .modelos import QuadroDeVideo
class ExtratorDeQuadros(ABC):
"""Interface para obter uma amostra temporal de frames de um clipe."""
@abstractmethod
def extrair(self, clipe: Clipe) -> list[QuadroDeVideo]: ...
class AnalisadorDeFrame(ABC):
"""Interface comum: recebe um frame e devolve evidências do domínio."""
nome: str
@abstractmethod
def analisar(self, quadro: QuadroDeVideo) -> list[EvidenciaVisual]: ...
class AnalisadorDeSequenciaDeQuadros(ABC):
"""Interface para evidências que só existem ao comparar vários frames."""
nome: str
@abstractmethod
def analisar(self, quadros: list[QuadroDeVideo]) -> list[EvidenciaVisual]: ...
class DetectorDeIntervalosDeCena(ABC):
"""Interface para algoritmos que encontram intervalos de cena no clipe."""
nome: str
@abstractmethod
def detectar(self, clipe: Clipe, quadros: list[QuadroDeVideo]) -> list[Cena]: ...
@@ -0,0 +1,34 @@
from typing import Any
from ...dominio import Clipe
from ...scanner.modelos import Cena
from .contratos import DetectorDeIntervalosDeCena
from .modelos import QuadroDeVideo
class DetectorDeCenasPySceneDetect(DetectorDeIntervalosDeCena):
"""Adapter PySceneDetect que devolve somente cenas do domínio."""
nome = "pyscenedetect"
def __init__(self, limiar: float = 27.0, detector: Any | None = None,
detectar_funcao: Any | None = None) -> None:
self.limiar = limiar
self._detector = detector
self._detectar_funcao = detectar_funcao
def detectar(self, clipe: Clipe, quadros: list[QuadroDeVideo]) -> list[Cena]:
if not clipe.arquivo:
raise ValueError("Clipe não possui arquivo de origem.")
if self._detectar_funcao is None:
try:
from scenedetect import ContentDetector, detect
except ImportError as exc:
raise RuntimeError("PySceneDetect não está instalado. Instale scenedetect.") from exc
detector = self._detector or ContentDetector(threshold=self.limiar)
detectar = lambda arquivo: detect(arquivo, detector)
else:
detectar = self._detectar_funcao
resultado = detectar(str(clipe.arquivo))
return [Cena(float(inicio.get_seconds()), float(fim.get_seconds()), referencias=(float(inicio.get_seconds()),))
for inicio, fim in resultado]
@@ -0,0 +1,77 @@
"""Extrator de frames para Vision usando somente ferramentas locais do sistema."""
import json
from pathlib import Path
import subprocess
from ...dominio import Clipe
from .contratos import ExtratorDeQuadros
from .extrator_open_cv import ExtratorDeQuadrosOpenCV
from .modelos import QuadroDeVideo
class ExtratorDeQuadrosFFmpeg(ExtratorDeQuadros):
"""Persiste amostras de um arquivo original sem carregar OpenCV no processo."""
def __init__(self, intervalo_em_segundos: float = 1.0,
diretorio_de_cache: str | Path = ".frames",
maximo_de_quadros: int | None = None,
ffmpeg: str = "ffmpeg", ffprobe: str = "ffprobe") -> None:
if intervalo_em_segundos <= 0:
raise ValueError("intervalo_em_segundos deve ser positivo.")
if maximo_de_quadros is not None and maximo_de_quadros < 1:
raise ValueError("maximo_de_quadros deve ser maior que zero.")
self.intervalo_em_segundos = intervalo_em_segundos
self.diretorio_de_cache = Path(diretorio_de_cache)
self.maximo_de_quadros = maximo_de_quadros
self.ffmpeg = ffmpeg
self.ffprobe = ffprobe
def extrair(self, clipe: Clipe) -> list[QuadroDeVideo]:
if not clipe.arquivo:
raise ValueError("Clipe não possui arquivo de origem.")
arquivo = Path(clipe.arquivo)
if not arquivo.is_file():
raise FileNotFoundError(f"Arquivo do clipe não encontrado: {arquivo}")
largura, altura, duracao = self._metadados(arquivo)
inicio, fim = ExtratorDeQuadrosOpenCV._intervalo_de_origem(clipe, duracao)
timestamps = ExtratorDeQuadrosOpenCV(self.intervalo_em_segundos,
maximo_de_quadros=self.maximo_de_quadros)._timestamps(inicio, fim)
resultado: list[QuadroDeVideo] = []
for indice, timestamp in enumerate(timestamps):
# PNG evita a recodificação JPEG de YUV limitado, que falha em parte
# dos vídeos móveis e ainda preserva melhor OCR/segmentação para Vision.
destino = self.diretorio_de_cache / arquivo.stem / f"frame-{indice:06d}.png"
destino.parent.mkdir(parents=True, exist_ok=True)
amostrado, processo = self._extrair_frame(arquivo, destino, timestamp, inicio)
if processo.returncode != 0 or not destino.is_file():
detalhe = processo.stderr.strip() or "ffmpeg não produziu o frame"
raise RuntimeError(f"Não foi possível extrair frame em {timestamp}s: {detalhe}")
resultado.append(QuadroDeVideo(amostrado, indice, largura, altura, None, destino))
return resultado
def _extrair_frame(self, arquivo: Path, destino: Path, timestamp: float,
inicio: float) -> tuple[float, subprocess.CompletedProcess[str]]:
"""Recua um pouco no fim do arquivo se o decoder não encontrar frame no timestamp."""
ultimo: subprocess.CompletedProcess[str] | None = None
for candidato in (timestamp, max(inicio, timestamp - 0.1)):
if destino.exists():
destino.unlink()
processo = subprocess.run([self.ffmpeg, "-hide_banner", "-loglevel", "error", "-ss", str(candidato),
"-i", str(arquivo), "-frames:v", "1", "-y", str(destino)],
capture_output=True, text=True, timeout=60)
if processo.returncode == 0 and destino.is_file():
return candidato, processo
ultimo = processo
assert ultimo is not None
return timestamp, ultimo
def _metadados(self, arquivo: Path) -> tuple[int, int, float]:
processo = subprocess.run([self.ffprobe, "-v", "error", "-select_streams", "v:0",
"-show_entries", "stream=width,height:format=duration",
"-of", "json", str(arquivo)], capture_output=True, text=True, timeout=30)
if processo.returncode != 0:
raise RuntimeError(processo.stderr.strip() or "ffprobe não conseguiu ler o vídeo")
dados = json.loads(processo.stdout)
stream = (dados.get("streams") or [{}])[0]
return int(stream["width"]), int(stream["height"]), float((dados.get("format") or {}).get("duration") or 0)
@@ -0,0 +1,87 @@
from pathlib import Path
from typing import Any
from ...dominio import Clipe
from .contratos import ExtratorDeQuadros
from .modelos import QuadroDeVideo
class ExtratorDeQuadrosOpenCV(ExtratorDeQuadros):
"""Extrai frames em intervalos regulares sem expor detalhes do OpenCV."""
def __init__(self, intervalo_em_segundos: float = 1.0,
diretorio_de_cache: str | Path | None = None,
maximo_de_quadros: int | None = None,
cv2_module: Any | None = None) -> None:
if intervalo_em_segundos <= 0:
raise ValueError("intervalo_em_segundos deve ser positivo.")
if maximo_de_quadros is not None and maximo_de_quadros < 1:
raise ValueError("maximo_de_quadros deve ser maior que zero.")
self.intervalo_em_segundos = intervalo_em_segundos
self.diretorio_de_cache = Path(diretorio_de_cache) if diretorio_de_cache else None
self.maximo_de_quadros = maximo_de_quadros
self._cv2 = cv2_module
@property
def cv2(self) -> Any:
if self._cv2 is None:
try:
import cv2
except ImportError as exc:
raise RuntimeError("OpenCV não está instalado. Instale opencv-python.") from exc
self._cv2 = cv2
return self._cv2
def extrair(self, clipe: Clipe) -> list[QuadroDeVideo]:
if not clipe.arquivo:
raise ValueError("Clipe não possui arquivo de origem.")
caminho = Path(clipe.arquivo)
if not caminho.is_file():
raise FileNotFoundError(f"Arquivo do clipe não encontrado: {caminho}")
captura = self.cv2.VideoCapture(str(caminho))
if not captura.isOpened():
raise RuntimeError(f"OpenCV não conseguiu abrir o vídeo: {caminho}")
try:
fps = float(captura.get(self.cv2.CAP_PROP_FPS) or 0)
total_de_frames = int(captura.get(self.cv2.CAP_PROP_FRAME_COUNT) or 0)
duracao = total_de_frames / fps if fps > 0 else 0.0
inicio, fim = self._intervalo_de_origem(clipe, duracao)
timestamps = self._timestamps(inicio, fim)
resultado: list[QuadroDeVideo] = []
for indice, timestamp in enumerate(timestamps):
captura.set(self.cv2.CAP_PROP_POS_MSEC, timestamp * 1000.0)
sucesso, imagem = captura.read()
if not sucesso or imagem is None:
continue
altura, largura = imagem.shape[:2]
salvo = self._salvar(caminho, indice, imagem)
resultado.append(QuadroDeVideo(timestamp, indice, largura, altura, imagem, salvo))
return resultado
finally:
captura.release()
def _timestamps(self, inicio: float, fim: float) -> list[float]:
if fim <= inicio:
return [inicio]
quantidade = int((fim - inicio) / self.intervalo_em_segundos) + 1
limite = max(inicio, fim - 0.001)
timestamps = [min(inicio + indice * self.intervalo_em_segundos, limite)
for indice in range(quantidade)]
return timestamps[:self.maximo_de_quadros]
@staticmethod
def _intervalo_de_origem(clipe: Clipe, duracao: float) -> tuple[float, float]:
if clipe.intervalo_na_origem is None:
return 0.0, duracao
inicio = max(0.0, clipe.intervalo_na_origem.inicio)
fim = min(duracao, clipe.intervalo_na_origem.fim) if duracao > 0 else clipe.intervalo_na_origem.fim
return inicio, max(inicio, fim)
def _salvar(self, arquivo: Path, indice: int, imagem: Any) -> Path | None:
if self.diretorio_de_cache is None:
return None
destino = self.diretorio_de_cache / arquivo.stem / f"frame-{indice:06d}.jpg"
destino.parent.mkdir(parents=True, exist_ok=True)
if not self.cv2.imwrite(str(destino), imagem):
raise RuntimeError(f"Não foi possível salvar frame: {destino}")
return destino
@@ -0,0 +1,47 @@
"""Executável interno isolado para evitar que falhas nativas do MediaPipe derrubem o Scanner."""
import json
import sys
from pathlib import Path
def _pontos(landmarks, visibilidade: bool = False):
return [{"x": ponto.x, "y": ponto.y, "z": ponto.z,
**({"visibilidade": getattr(ponto, "visibility", None)} if visibilidade else {})}
for ponto in landmarks]
def executar(carga: dict) -> list[dict]:
import cv2
import mediapipe as mp
imagem_bgr = cv2.imread(carga["frame"])
if imagem_bgr is None:
raise FileNotFoundError(f"Frame não encontrado: {carga['frame']}")
imagem = mp.Image(image_format=mp.ImageFormat.SRGB, data=imagem_bgr[:, :, ::-1])
resultado: list[dict] = []
if carga.get("pose"):
opcoes = mp.tasks.vision.PoseLandmarkerOptions(
base_options=mp.tasks.BaseOptions(model_asset_path=carga["pose"], delegate=mp.tasks.BaseOptions.Delegate.CPU),
running_mode=mp.tasks.vision.RunningMode.IMAGE,
)
with mp.tasks.vision.PoseLandmarker.create_from_options(opcoes) as detector:
for pose in detector.detect(imagem).pose_landmarks:
resultado.append({"tipo": "pose", "valor": {"pontos": _pontos(pose, visibilidade=True)}})
if carga.get("maos"):
opcoes = mp.tasks.vision.HandLandmarkerOptions(
base_options=mp.tasks.BaseOptions(model_asset_path=carga["maos"], delegate=mp.tasks.BaseOptions.Delegate.CPU),
running_mode=mp.tasks.vision.RunningMode.IMAGE, num_hands=4,
)
with mp.tasks.vision.HandLandmarker.create_from_options(opcoes) as detector:
for mao in detector.detect(imagem).hand_landmarks:
resultado.append({"tipo": "mao", "valor": {"pontos": _pontos(mao)}})
return resultado
if __name__ == "__main__":
try:
print(json.dumps(executar(json.loads(sys.stdin.read()))))
except Exception as erro:
print(str(erro), file=sys.stderr)
raise
+15
View File
@@ -0,0 +1,15 @@
from dataclasses import dataclass
from pathlib import Path
from typing import Any
@dataclass(frozen=True)
class QuadroDeVideo:
"""Frame extraído, com timestamp relativo ao arquivo de origem."""
timestamp: float
indice: int
largura: int
altura: int
imagem: Any
caminho: Path | None = None
@@ -1,7 +1,7 @@
from pathlib import Path
from typing import Any
from ...scanner.modelos import SegmentoDeTranscricao
from ...scanner.modelos import PalavraDeTranscricao, SegmentoDeTranscricao
class ProviderDeTranscricaoLocal:
@@ -9,6 +9,9 @@ class ProviderDeTranscricaoLocal:
def __init__(self, modelo: str, dispositivo: str = "cpu", tipo_de_calculo: str = "int8",
idioma: str = "pt") -> None:
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
self.modelo = WhisperModel(modelo, device=dispositivo, compute_type=tipo_de_calculo)
self.idioma = idioma
@@ -17,5 +20,9 @@ class ProviderDeTranscricaoLocal:
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)
return [SegmentoDeTranscricao(float(s.start), float(s.end), s.text.strip()) for s in segmentos]
segmentos, _ = self.modelo.transcribe(str(arquivo), language=self.idioma,
vad_filter=True, word_timestamps=True)
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))
for w in (s.words or []) if w.word.strip())) for s in segmentos]
+7
View File
@@ -0,0 +1,7 @@
# Dependências opcionais para os adapters locais de análise visual.
opencv-python
scenedetect
onnxruntime
mediapipe
# Apenas macOS; permite usar Vision diretamente a partir do Python.
pyobjc-framework-Vision
+19 -4
View File
@@ -1,7 +1,22 @@
from .coordenacao import AnalisadorDeTimeline, ContextoDeAnalise, PipelineDoScanner
__all__ = ["AnalisadorDeTimeline", "ContextoDeAnalise", "PipelineDoScanner"]
from .modelos import Cena, ErroDeAnalise, Evento, ResultadoDaAnalise, SegmentoDeTranscricao, StatusDaAnalise
from .descoberta import ArquivoDescoberto, DescobertaDeArquivos, LeitorLocalDeArquivos
from .metadados import ExtracaoDeMetadados, MetadadosDoArquivo
from .modelos import (Cena, CenaVisual, EvidenciaVisual, ErroDeAnalise, Evento, ObservacaoVisual,
PalavraDeTranscricao,
ResultadoDaAnalise, SegmentoDeTranscricao, StatusDaAnalise)
from .transcricao_da_timeline import TranscricaoDaTimeline, TranscricaoDoClipe
from .visual import (AnaliseVisualDaTimeline, DetectorDeCenas, ResultadoVisualDoClipe,
criar_detector_apple_vision, criar_detector_visual_local)
from .relatorio_visual import gerar_relatorio_visual, gerar_resumo_visual_markdown
from .configuracao_visual import ConfiguracaoVisualDoScanner
__all__ = ["Cena", "ErroDeAnalise", "Evento", "ResultadoDaAnalise", "SegmentoDeTranscricao", "StatusDaAnalise", "TranscricaoDaTimeline", "TranscricaoDoClipe"]
__all__ = ["AnaliseVisualDaTimeline", "AnalisadorDeTimeline", "ArquivoDescoberto",
"Cena", "CenaVisual", "ContextoDeAnalise", "criar_detector_apple_vision", "criar_detector_visual_local", "DescobertaDeArquivos", "DetectorDeCenas",
"ConfiguracaoVisualDoScanner",
"EvidenciaVisual", "ErroDeAnalise", "Evento", "ExtracaoDeMetadados",
"gerar_relatorio_visual",
"gerar_resumo_visual_markdown",
"LeitorLocalDeArquivos", "MetadadosDoArquivo", "ObservacaoVisual", "PalavraDeTranscricao",
"PipelineDoScanner", "ResultadoDaAnalise", "ResultadoVisualDoClipe",
"SegmentoDeTranscricao", "StatusDaAnalise", "TranscricaoDaTimeline",
"TranscricaoDoClipe"]
@@ -0,0 +1,60 @@
"""Perfil declarativo que liga a configuração do painel à leitura visual."""
from dataclasses import dataclass
from typing import Any
RECURSOS_VISION = frozenset((
"faces", "qualidade_facial", "pessoas", "pose", "maos", "ocr", "categorias",
"estetica", "codigos", "horizonte", "retangulos", "contornos", "segmentacao_de_pessoas",
))
@dataclass(frozen=True)
class ConfiguracaoVisualDoScanner:
"""Interface curta para uma execução de leitura visual da timeline."""
intervalo_em_segundos: float = 1.0
faixas_de_video: tuple[int, ...] = ()
faixas_de_audio: tuple[int, ...] = ()
recursos_vision: frozenset[str] = RECURSOS_VISION
detectar_cenas: bool = True
analisar_continuidade: bool = True
preparar_reenquadramento: bool = False
interpretar_cena: bool = True
def __post_init__(self) -> None:
if not 0.04 <= self.intervalo_em_segundos <= 60:
raise ValueError("intervalo_em_segundos deve estar entre 0,04 e 60.")
desconhecidos = self.recursos_vision - RECURSOS_VISION
if desconhecidos:
raise ValueError(f"Recursos Vision desconhecidos: {', '.join(sorted(desconhecidos))}")
if any(indice < 0 for indice in self.faixas_de_video + self.faixas_de_audio):
raise ValueError("Índices de faixa não podem ser negativos.")
@classmethod
def de_dict(cls, dados: dict[str, Any]) -> "ConfiguracaoVisualDoScanner":
"""Lê o mesmo JSON produzido pelo painel, sem vazar detalhes de adapters."""
return cls(
intervalo_em_segundos=float(dados.get("intervalo_em_segundos", 1)),
faixas_de_video=tuple(sorted(set(int(item) for item in dados.get("faixas_de_video", ())))),
faixas_de_audio=tuple(sorted(set(int(item) for item in dados.get("faixas_de_audio", ())))),
recursos_vision=frozenset(dados.get("recursos_vision", RECURSOS_VISION)),
detectar_cenas=bool(dados.get("detectar_cenas", True)),
analisar_continuidade=bool(dados.get("analisar_continuidade", True)),
preparar_reenquadramento=bool(dados.get("preparar_reenquadramento", False)),
interpretar_cena=bool(dados.get("interpretar_cena", True)),
)
def para_dict(self) -> dict[str, Any]:
return {
"versao": 1,
"intervalo_em_segundos": self.intervalo_em_segundos,
"faixas_de_video": list(self.faixas_de_video),
"faixas_de_audio": list(self.faixas_de_audio),
"recursos_vision": sorted(self.recursos_vision),
"detectar_cenas": self.detectar_cenas,
"analisar_continuidade": self.analisar_continuidade,
"preparar_reenquadramento": self.preparar_reenquadramento,
"interpretar_cena": self.interpretar_cena,
}
+5 -2
View File
@@ -14,7 +14,10 @@ class ContextoDeAnalise:
transcricoes: dict[str, list[Any]] = field(default_factory=dict)
caracteristicas_visuais: dict[str, dict[str, Any]] = field(default_factory=dict)
cenas: list[Any] = field(default_factory=list)
cenas_visuais: list[Any] = field(default_factory=list)
eventos: list[Any] = field(default_factory=list)
arquivos: dict[str, Any] = field(default_factory=dict)
metadados_dos_arquivos: dict[str, Any] = field(default_factory=dict)
class Analisador(Protocol):
@@ -42,5 +45,5 @@ class AnalisadorDeTimeline:
def __init__(self, pipeline: PipelineDoScanner) -> None:
self.pipeline = pipeline
def analisar(self) -> ContextoDeAnalise:
return self.pipeline.executar(ContextoDeAnalise())
def analisar(self, timeline: Timeline | None = None) -> ContextoDeAnalise:
return self.pipeline.executar(ContextoDeAnalise(timeline=timeline))
+2 -1
View File
@@ -1,3 +1,4 @@
from .descoberta_da_timeline import DescobertaDaTimeline
from .descoberta_de_arquivos import ArquivoDescoberto, DescobertaDeArquivos, LeitorLocalDeArquivos
__all__ = ["DescobertaDaTimeline"]
__all__ = ["ArquivoDescoberto", "DescobertaDaTimeline", "DescobertaDeArquivos", "LeitorLocalDeArquivos"]
@@ -0,0 +1,97 @@
from dataclasses import dataclass
from pathlib import Path
from typing import Protocol
from ...dominio import Clipe
from ...scanner.modelos import ErroDeAnalise
from ..coordenacao import ContextoDeAnalise
@dataclass(frozen=True)
class ArquivoDescoberto:
"""Resultado da validação do arquivo associado a um clipe."""
caminho: Path
existe: bool
acessivel: bool
tipo_de_midia: str | None = None
tamanho_em_bytes: int | None = None
@property
def offline(self) -> bool:
return not self.existe or not self.acessivel
class LeitorDeArquivos(Protocol):
def descobrir(self, caminho: str | Path) -> ArquivoDescoberto: ...
class LeitorLocalDeArquivos:
"""Adapter que resolve e valida caminhos no sistema de arquivos local."""
def descobrir(self, caminho: str | Path) -> ArquivoDescoberto:
caminho_resolvido = Path(caminho).expanduser().resolve(strict=False)
existe = caminho_resolvido.is_file()
acessivel = existe
tamanho = None
if existe:
try:
tamanho = caminho_resolvido.stat().st_size
with caminho_resolvido.open("rb"):
pass
except (OSError, PermissionError):
acessivel = False
return ArquivoDescoberto(
caminho=caminho_resolvido,
existe=existe,
acessivel=acessivel,
tipo_de_midia=caminho_resolvido.suffix.lower().lstrip(".") or None,
tamanho_em_bytes=tamanho,
)
class DescobertaDeArquivos:
"""Relaciona os clipes da timeline aos arquivos físicos de origem."""
nome = "descoberta_de_arquivos"
def __init__(self, leitor: LeitorDeArquivos | None = None) -> None:
self.leitor = leitor or LeitorLocalDeArquivos()
def executar(self, contexto: ContextoDeAnalise) -> ContextoDeAnalise:
if contexto.timeline is None:
return contexto
vistos: dict[Path, str] = {}
for clipe in self._clipes(contexto):
if not clipe.arquivo:
clipe.offline = True
self._registrar_erro(contexto, "arquivo_ausente", "Clipe sem sourceFile.", clipe)
continue
try:
descoberto = self.leitor.descobrir(clipe.arquivo)
except (OSError, ValueError, TypeError) as exc:
clipe.offline = True
self._registrar_erro(contexto, "arquivo_inacessivel", str(exc), clipe)
continue
clipe.arquivo = str(descoberto.caminho)
clipe.offline = descoberto.offline
contexto.arquivos[clipe.identificador] = descoberto
if descoberto.offline:
self._registrar_erro(contexto, "arquivo_inacessivel", "Arquivo não encontrado ou sem permissão de leitura.", clipe)
elif descoberto.caminho in vistos:
contexto.avisos.append(
f"Arquivo duplicado entre os clipes {vistos[descoberto.caminho]} e {clipe.identificador}."
)
else:
vistos[descoberto.caminho] = clipe.identificador
return contexto
@staticmethod
def _clipes(contexto: ContextoDeAnalise) -> list[Clipe]:
return [clipe for faixa in contexto.timeline.faixas for clipe in faixa.clipes]
@staticmethod
def _registrar_erro(contexto: ContextoDeAnalise, codigo: str, mensagem: str, clipe: Clipe) -> None:
contexto.erros.append(str(ErroDeAnalise(codigo, mensagem, f"clipe[{clipe.identificador}].sourceFile")))
+40
View File
@@ -0,0 +1,40 @@
from typing import Protocol
from ..integracoes.midia.extracao_de_metadados import (
ExtracaoDeMetadados as ExtratorDeMetadados,
MetadadosDoArquivo,
)
from .coordenacao import ContextoDeAnalise
class ExtratorDeMetadadosProtocol(Protocol):
def extrair(self, arquivo: str) -> MetadadosDoArquivo: ...
class ExtracaoDeMetadados:
"""Enriquece cada clipe com os metadados técnicos do seu arquivo."""
nome = "extracao_de_metadados"
def __init__(self, extrator: ExtratorDeMetadadosProtocol | None = None) -> None:
self.extrator = extrator or ExtratorDeMetadados()
def executar(self, contexto: ContextoDeAnalise) -> ContextoDeAnalise:
for identificador, arquivo in contexto.arquivos.items():
if arquivo.offline:
continue
try:
metadados = self.extrator.extrair(str(arquivo.caminho))
contexto.metadados_dos_arquivos[identificador] = metadados
for faixa in contexto.timeline.faixas if contexto.timeline else []:
for clipe in faixa.clipes:
if clipe.identificador == identificador:
clipe.metadados["arquivo"] = metadados
break
except Exception as exc:
# Uma mídia inválida não deve descartar os resultados dos demais clipes.
contexto.erros.append(f"metadados_indisponiveis: {arquivo.caminho}: {exc}")
return contexto
__all__ = ["ExtracaoDeMetadados", "MetadadosDoArquivo"]
+55
View File
@@ -28,12 +28,32 @@ class ResultadoDaAnalise:
avisos: list[str] = field(default_factory=list)
@dataclass(frozen=True)
class PalavraDeTranscricao:
texto: str
inicio: float
fim: float
confianca: float | None = None
falante: str | None = None
def __post_init__(self) -> None:
if self.inicio < 0 or self.fim < self.inicio:
raise ValueError("Intervalo de palavra inválido.")
@dataclass(frozen=True)
class SegmentoDeTranscricao:
inicio: float
fim: float
texto: str
confianca: float | None = None
palavras: tuple[PalavraDeTranscricao, ...] = ()
emocao: str | None = None
confianca_emocao: float | None = None
caracteristicas_acusticas: dict[str, float] = field(default_factory=dict)
falante: str | None = None
voz_aparente: str | None = None
confianca_voz: float | None = None
def __post_init__(self) -> None:
if self.inicio < 0 or self.fim < self.inicio:
@@ -54,3 +74,38 @@ class Evento:
inicio: float
fim: float
confianca: float | None = None
@dataclass(frozen=True)
class EvidenciaVisual:
"""Fato visual normalizado, independente da biblioteca que o produziu."""
tipo: str
inicio: float
fim: float
valor: dict[str, Any]
confianca: float | None = None
provider: str = ""
modelo: str | None = None
def __post_init__(self) -> None:
if self.inicio < 0 or self.fim < self.inicio:
raise ValueError("Intervalo de evidência visual inválido.")
ObservacaoVisual = EvidenciaVisual
@dataclass(frozen=True)
class CenaVisual:
"""Intervalo visual enriquecido com as observações que o sustentam."""
identificador_do_clipe: str
inicio: float
fim: float
observacoes: tuple[EvidenciaVisual, ...] = ()
referencias_de_cena: tuple[float, ...] = ()
def __post_init__(self) -> None:
if self.inicio < 0 or self.fim < self.inicio:
raise ValueError("Intervalo de cena visual inválido.")
+79
View File
@@ -0,0 +1,79 @@
"""Exportação estruturada de uma leitura visual de clipe."""
from collections import defaultdict
from dataclasses import asdict
from datetime import datetime, timezone
import json
from pathlib import Path
from ..dominio import Clipe
from .visual import ResultadoVisualDoClipe
VERSAO_DO_RELATORIO_VISUAL = "1.0"
def gerar_relatorio_visual(clipe: Clipe, resultado: ResultadoVisualDoClipe,
destino: str | Path, intervalo_de_amostragem: float) -> Path:
"""Grava um JSON autocontido, com fatos por frame e fatos temporais separados."""
if intervalo_de_amostragem <= 0:
raise ValueError("intervalo_de_amostragem deve ser positivo.")
caminho = Path(destino)
caminho.parent.mkdir(parents=True, exist_ok=True)
caminho.write_text(json.dumps(_dados_do_relatorio(clipe, resultado, intervalo_de_amostragem),
ensure_ascii=False, indent=2), encoding="utf-8")
return caminho
def gerar_resumo_visual_markdown(clipe: Clipe, resultado: ResultadoVisualDoClipe,
destino: str | Path, intervalo_de_amostragem: float) -> Path:
"""Grava uma visão humana do mesmo resultado exportado em JSON."""
caminho = Path(destino)
caminho.parent.mkdir(parents=True, exist_ok=True)
dados = _dados_do_relatorio(clipe, resultado, intervalo_de_amostragem)
linhas = [f"# Relatório visual — {clipe.nome}", "",
f"- Clipe: `{clipe.identificador}`",
f"- Origem: `{clipe.arquivo}`",
f"- Timeline: {clipe.intervalo_na_timeline.inicio:.3f}s–{clipe.intervalo_na_timeline.fim:.3f}s",
f"- Amostragem: a cada {intervalo_de_amostragem:g} segundo(s)", "",
"## Observações por frame", ""]
for frame in dados["observacoes_por_frame"]:
tipos = ", ".join(item["tipo"] for item in frame["evidencias"])
linhas.extend([f"### Origem {frame['timestamp_na_origem']:.3f}s", "", tipos or "Sem evidências.", ""])
linhas.extend(["## Continuidade", ""])
for evidencia in dados["evidencias_temporais"]:
linhas.append(f"- {evidencia['tipo']}: {evidencia['inicio']:.3f}s–{evidencia['fim']:.3f}s — "
f"`{json.dumps(evidencia['valor'], ensure_ascii=False)}`")
caminho.write_text("\n".join(linhas) + "\n", encoding="utf-8")
return caminho
def _dados_do_relatorio(clipe: Clipe, resultado: ResultadoVisualDoClipe,
intervalo_de_amostragem: float) -> dict:
por_frame = defaultdict(list)
temporais = []
for evidencia in resultado.evidencias:
item = asdict(evidencia)
if evidencia.inicio == evidencia.fim:
por_frame[evidencia.inicio].append(item)
else:
temporais.append(item)
origem = clipe.intervalo_na_origem
return {
"versao": VERSAO_DO_RELATORIO_VISUAL,
"gerado_em": datetime.now(timezone.utc).isoformat(),
"clipe": {
"identificador": clipe.identificador,
"nome": clipe.nome,
"arquivo_original": clipe.arquivo,
"intervalo_na_timeline": asdict(clipe.intervalo_na_timeline),
"intervalo_na_origem": asdict(origem) if origem else None,
},
"amostragem": {"intervalo_em_segundos": intervalo_de_amostragem},
"observacoes_por_frame": [
{"timestamp_na_origem": timestamp, "evidencias": evidencias}
for timestamp, evidencias in sorted(por_frame.items())
],
"evidencias_temporais": temporais,
"cenas": [asdict(cena) for cena in resultado.cenas_visuais],
}
@@ -0,0 +1,116 @@
"""Converte a transcrição existente em falas ordenadas para a análise.
O módulo de retakes não realiza transcrição. Ele recebe a saída do
``TranscricaoDaTimeline`` (lista de ``TranscricaoDoClipe``) e a converte
em ``Fala``s ordenadas ao longo da timeline, preservando o vínculo com o
segmento/clipe de origem e as palavras alinhadas quando disponíveis.
"""
from __future__ import annotations
import re
from typing import Iterable
from ..transcricao_da_timeline import TranscricaoDoClipe
from .modelos_de_retakes import Fala
# Sinais de erro mais comuns vindos dos providers de transcrição.
_PALAVRAS_DE_ERRO = {"", "...", "…"}
_VOYELS = "aeiouáéíóúâêôãõàèìòù"
_CONSOANTES = "bcdfghjklmnpqrstvwxyz"
_PADRAO_SILABA = re.compile(
rf"([{_VOYELS}][^aeiouáéíóúâêôãõàèìòù]*)|([{_CONSOANTES}]+[aeiouáéíóúâêôãõàèìòù]?)",
re.IGNORECASE,
)
def _normalizar(texto: str) -> str:
"""Minúsculas, sem pontuação e sem espaços duplicados."""
sem_pontuacao = re.sub(r"[^\w\s]", " ", texto)
return " ".join(sem_pontuacao.lower().split())
def _prefixo_comum(a: list[str], b: list[str]) -> int:
n = 0
for x, y in zip(a, b):
if x != y:
break
n += 1
return n
def _sucesso_de_silabas(a: list[str], b: list[str]) -> float:
if not a or not b:
return 0.0
silabas_a = [_PADRAO_SILABA.findall(p)[0][0] for p in a]
silabas_b = [_PADRAO_SILABA.findall(p)[0][0] for p in b]
n = _prefixo_comum(silabas_a, silabas_b)
return n / max(len(silabas_a), len(silabas_b))
def _e_ruido(texto: str) -> bool:
texto_limpo = _normalizar(texto)
if texto_limpo in _PALAVRAS_DE_ERRO:
return True
# Fala em branco ou "vazia" por provedor.
return not texto_limpo
class AdaptadorDeFalas:
"""Transforma a transcrição da timeline em falas prontas para análise.
Recebe uma coleção de ``TranscricaoDoClipe`` (uma por faixa de áudio do
vídeo) e devolve as falas ordenadas por tempo. O ``video_id`` é um rótulo
informado pelo chamador; quando ausente, usa o identificador da timeline.
"""
def __init__(self, video_id: str | None = None, faixa_id: str | None = None) -> None:
self.video_id = video_id
self.faixa_id = faixa_id
def converter(
self,
transcricoes: Iterable[TranscricaoDoClipe],
video_id: str | None = None,
faixa_id: str | None = None,
) -> list[Fala]:
video_id = video_id or self.video_id or "video"
faixa_id = faixa_id or self.faixa_id or "faixa"
falas: list[Fala] = []
for transcricao in transcricoes:
segmento_id = transcricao.identificador_do_clipe
for segmento in transcricao.segmentos:
if _e_ruido(segmento.texto):
continue
falas.append(Fala(
id=f"{segmento_id}:{segmento.inicio:.3f}",
video_id=video_id,
faixa_id=faixa_id,
segmento_id=segmento_id,
ordem=-1, # preenchida após a ordenação
inicio=segmento.inicio,
fim=segmento.fim,
texto=segmento.texto,
texto_normalizado=_normalizar(segmento.texto),
palavras=segmento.palavras,
falante=segmento.falante,
))
falas.sort(key=lambda item: (item.inicio, item.fim))
for indice, fala in enumerate(falas):
falas[indice] = Fala(
id=fala.id,
video_id=fala.video_id,
faixa_id=fala.faixa_id,
segmento_id=fala.segmento_id,
ordem=indice,
inicio=fala.inicio,
fim=fala.fim,
texto=fala.texto,
texto_normalizado=fala.texto_normalizado,
palavras=fala.palavras,
falante=fala.falante,
)
return falas
@@ -0,0 +1,148 @@
"""Agrupamento de falas que representam retakes da mesma fala ou ideia.
Trabalha com uma janela temporal: compara cada fala com as ``n`` seguintes
(e só as da mesma faixa de áudio), evitando agrupar frases iguais que
aparecem em partes muito distantes do vídeo. Não decide a classificação —
apenas forma grupos candidatos e reúne as métricas.
"""
from __future__ import annotations
from typing import Iterable
from .analisador_de_intervalos import AnalisadorDeIntervalos
from .comparador_de_falas import ComparadorDeFalas
from .comparador_semantico import ComparadorSemantico
from .detector_de_reinicios import DetectorDeReinicios
from .modelos_de_retakes import (
AgrupamentoDeFalas,
Fala,
ParAgrupado,
ResultadoDoIntervalo,
SinalDeReinicio,
)
# Limiares de candidatura de um par a pertencer a um grupo de retake.
_SIMILARIDADE_MINIMA_CANDIDATO = 0.55
_SIMILARIDADE_FORTE = 0.75
_PROXIMIDADE_AUXILIAR = 0.60
# Janela padrão de falas a serem comparadas com cada uma.
_JANELA_PADRAO = 5
# Região de dúvida que dispara a comparação semântica.
_ZONA_DE_DUVIDA_INFERIOR = 0.60
_ZONA_DE_DUVIDA_SUPERIOR = 0.85
class AgrupadorDeTakes:
"""Gera grupos candidatos conectando falas semelhantes e próximas."""
def __init__(
self,
comparador: ComparadorDeFalas,
comparador_semantico: ComparadorSemantico,
analisador_de_intervalos: AnalisadorDeIntervalos,
detector_de_reinicios: DetectorDeReinicios,
janela: int = _JANELA_PADRAO,
) -> None:
self.comparador = comparador
self.comparador_semantico = comparador_semantico
self.analisador_de_intervalos = analisador_de_intervalos
self.detector_de_reinicios = detector_de_reinicios
self.janela = janela
def agrupar(self, falas: Iterable[Fala]) -> list[AgrupamentoDeFalas]:
falas = sorted(falas, key=lambda item: (item.ordem, item.inicio))
sinais = {sinal.fala_id: sinal for sinal in self.detector_de_reinicios.detectar(falas)}
arestas: list[tuple[int, int, ParAgrupado]] = []
for indice, fala_a in enumerate(falas):
limite = min(len(falas), indice + 1 + self.janela)
for jindice in range(indice + 1, limite):
fala_b = falas[jindice]
if fala_a.faixa_id != fala_b.faixa_id:
continue
par = self._avaliar_par(fala_a, fala_b, sinais)
if par is not None:
arestas.append((indice, jindice, par))
return self._agrupar_por_arestas(falas, arestas, sinais)
def _avaliar_par(
self,
fala_a: Fala,
fala_b: Fala,
sinais: dict[str, SinalDeReinicio],
) -> ParAgrupado | None:
comparacao = self.comparador.comparar(fala_a, fala_b)
intervalo = self.analisador_de_intervalos.analisar(fala_a, fala_b)
tem_reinicio = sinais.get(fala_b.id) is not None
semantica = 0.0
# Comparação semântica só na zona de dúvida, para evitar custo.
texto = comparacao.similaridade_final
if _ZONA_DE_DUVIDA_INFERIOR <= texto <= _ZONA_DE_DUVIDA_SUPERIOR:
semantica = self.comparador_semantico.comparar(fala_a, fala_b)
melhor = max(comparacao.similaridade_final, semantica)
candidato = (
melhor >= _SIMILARIDADE_FORTE
or (melhor >= _SIMILARIDADE_MINIMA_CANDIDATO
and intervalo.proximidade_temporal >= _PROXIMIDADE_AUXILIAR
and (tem_reinicio or intervalo.indicio_de_nova_tentativa))
)
if candidato:
return ParAgrupado(fala_a, fala_b, comparacao, intervalo,
round(semantica, 4))
return None
@staticmethod
def _agrupar_por_arestas(
falas: list[Fala],
arestas: list[tuple[int, int, ParAgrupado]],
sinais: dict[str, SinalDeReinicio],
) -> list[AgrupamentoDeFalas]:
pai = list(range(len(falas)))
def encontrar(x: int) -> int:
while pai[x] != x:
pai[x] = pai[pai[x]]
x = pai[x]
return x
arestas.sort(key=lambda item: (item[0], item[1]))
for a, b, _ in arestas:
raiz_a, raiz_b = encontrar(a), encontrar(b)
if raiz_a != raiz_b:
pai[raiz_b] = raiz_a
componentes: dict[int, list[int]] = {}
for indice in range(len(falas)):
componentes.setdefault(encontrar(indice), []).append(indice)
grupos: list[AgrupamentoDeFalas] = []
for inds in componentes.values():
if len(inds) < 2:
continue
inds.sort()
fala_ids = tuple(falas[indice].id for indice in inds)
pares_por_chave: dict[tuple[str, str], ParAgrupado] = {
(par.fala_a.id, par.fala_b.id): par
for _, _, par in arestas
if par.fala_a.id in fala_ids and par.fala_b.id in fala_ids
}
pares: list[ParAgrupado] = []
for a, b in zip(inds, inds[1:]):
chave = (falas[a].id, falas[b].id)
par = pares_por_chave.get(chave)
if par is None:
par = pares_por_chave.get((falas[b].id, falas[a].id))
if par is not None:
pares.append(par)
sinais_do_grupo = tuple(
sinais[fala_id] for fala_id in fala_ids if fala_id in sinais)
if pares:
grupos.append(AgrupamentoDeFalas(fala_ids, tuple(pares), sinais_do_grupo))
return grupos
@@ -0,0 +1,41 @@
"""Análise da relação temporal entre falas.
Verifica quanto tempo há entre duas falas, se estão na mesma faixa/segmento
e se a proximidade sugere uma nova tentativa. Um intervalo muito grande
reduz a confiança de que seja um retake imediato.
"""
from __future__ import annotations
from .modelos_de_retakes import Fala, ResultadoDoIntervalo
# A partir de quantos segundos o intervalo passa a não sugerir retake.
_INTERVALO_MAXIMO = 12.0
class AnalisadorDeIntervalos:
"""Calcula a proximidade temporal e o indício de nova tentativa."""
def __init__(self, intervalo_maximo: float = _INTERVALO_MAXIMO) -> None:
self.intervalo_maximo = intervalo_maximo
def analisar(self, fala_a: Fala, fala_b: Fala) -> ResultadoDoIntervalo:
intervalo = max(0.0, fala_b.inicio - fala_a.fim)
mesma_faixa = fala_a.faixa_id == fala_b.faixa_id
mesmo_segmento = mesma_faixa and fala_a.segmento_id == fala_b.segmento_id
if intervalo >= self.intervalo_maximo:
proximidade = 0.0
elif intervalo <= 0:
proximidade = 1.0
else:
# Decai suavemente com o intervalo: 1.0 → ~0 em 12s.
proximidade = max(0.0, 1.0 - intervalo / self.intervalo_maximo)
indicio = mesma_faixa and intervalo <= self.intervalo_maximo and proximidade >= 0.5
return ResultadoDoIntervalo(
intervalo_em_segundos=round(intervalo, 3),
mesmo_segmento=mesmo_segmento,
proximidade_temporal=round(proximidade, 4),
indicio_de_nova_tentativa=bool(indicio),
)
@@ -0,0 +1,112 @@
"""Comparação textual entre falas.
Responsável pelos algoritmos de similaridade: Jaccard (conjunto de
palavras), sequência (ordem das palavras via maior subsequência comum) e
similaridade do início/fim da frase. Não decide nada sozinho — apenas
produz métricas para o classificador.
"""
from __future__ import annotations
import re
from .modelos_de_retakes import Fala, ResultadoDaComparacao
def _normalizar(texto: str) -> str:
"""Minúsculas, sem pontuação e sem espaços duplicados."""
sem_pontuacao = re.sub(r"[^\w\s]", " ", texto)
return " ".join(sem_pontuacao.lower().split())
def _lcs(a: list[str], b: list[str]) -> int:
"""Tamanho da maior subsequência comum preservando a ordem."""
anterior = [0] * (len(b) + 1)
for palavra_a in a:
atual = [0] * (len(b) + 1)
for j, palavra_b in enumerate(b):
if palavra_a == palavra_b:
atual[j + 1] = anterior[j] + 1
else:
atual[j + 1] = max(atual[j], anterior[j + 1])
anterior = atual
return anterior[-1]
class ComparadorDeFalas:
"""Compara duas falas e calcula as métricas de similaridade textual."""
@staticmethod
def normalizar(texto: str) -> str:
return _normalizar(texto)
@staticmethod
def _palavras_de(fala: Fala) -> list[str]:
return fala.texto_normalizado.split() or _normalizar(fala.texto).split()
def comparar(self, fala_a: Fala, fala_b: Fala) -> ResultadoDaComparacao:
palavras_a = self._palavras_de(fala_a)
palavras_b = self._palavras_de(fala_b)
if not palavras_a or not palavras_b:
return ResultadoDaComparacao(fala_a.id, fala_b.id)
# 1. Jaccard — conjunto de palavras (rápido, ótimo candidato).
conjunto_a = set(palavras_a)
conjunto_b = set(palavras_b)
intersecao = len(conjunto_a & conjunto_b)
uniao = len(conjunto_a | conjunto_b)
jaccard = intersecao / uniao if uniao else 0.0
# 2. Sequência — ordem das palavras via LCS normalizada.
lcs = _lcs(palavras_a, palavras_b)
sequencia = lcs / max(len(palavras_a), len(palavras_b))
# 3. Início e final da frase.
inicio = self._similaridade_de_prefijo(palavras_a, palavras_b)
final = self._similaridade_de_sufixo(palavras_a, palavras_b)
# 4. Similaridade final ponderada.
fim = 0.45 * sequencia + 0.30 * jaccard + 0.15 * inicio + 0.10 * final
return ResultadoDaComparacao(
fala_a_id=fala_a.id,
fala_b_id=fala_b.id,
similaridade_jaccard=round(jaccard, 4),
similaridade_de_sequencia=round(sequencia, 4),
similaridade_do_inicio=round(inicio, 4),
similaridade_do_final=round(final, 4),
similaridade_final=round(min(1.0, fim), 4),
)
@staticmethod
def _similaridade_de_prefijo(a: list[str], b: list[str]) -> float:
if not a or not b:
return 0.0
n = 0
for x, y in zip(a, b):
if x != y:
break
n += 1
return n / max(len(a), len(b))
@staticmethod
def _similaridade_de_sufixo(a: list[str], b: list[str]) -> float:
if not a or not b:
return 0.0
n = 0
for x, y in zip(reversed(a), reversed(b)):
if x != y:
break
n += 1
return n / max(len(a), len(b))
@staticmethod
def prefixo_comum_em_palavras(texto_a: str, texto_b: str) -> int:
a = _normalizar(texto_a).split()
b = _normalizar(texto_b).split()
n = 0
for x, y in zip(a, b):
if x != y:
break
n += 1
return n
@@ -0,0 +1,113 @@
"""Comparação semântica entre falas (opcional).
A similaridade textual não cobre casos em que as palavras são diferentes
mas a ideia é a mesma. Este módulo define um protocolo para providers de
embeddings e uma implementação local (via Hugging Face Transformers), além
de um fallback puramente lexical usado quando nenhum provider está
disponível.
O `ComparadorSemantico` nunca decide sozinho que há retake — apenas
fornece uma métrica adicional para o classificador.
"""
from __future__ import annotations
import math
from typing import Protocol
from .modelos_de_retakes import Fala
class ProviderDeSimilaridadeSemantica(Protocol):
"""Contrato para quem calcula similaridade semântica entre dois textos."""
def similaridade(self, texto_a: str, texto_b: str) -> float: ...
class ComparadorLexicalSemantico:
"""Fallback puramente lexical para quando não há embeddings.
Reconstrói uma "similaridade semântica" a partir de palavras que
compartilham a mesma raiz (stemming simples por prefixo comum) e de
sinônimos frequentes em pt-PT. Destina-se a permitir executar a análise
sem carregar modelos pesados.
"""
_PARES_SINONIMOS = (
({"mostrar", "explicar", "demonstrar", "apresentar"},),
({"sistema", "programa", "aplicativo", "software"},),
({"configurar", "configuracao", "instalar", "ajustar"},),
)
def similaridade(self, texto_a: str, texto_b: str) -> float:
palavras_a = {p for p in self._palavras(texto_a)}
palavras_b = {p for p in self._palavras(texto_b)}
if not palavras_a or not palavras_b:
return 0.0
intersecao = 0.0
for palavra_a in palavras_a:
for palavra_b in palavras_b:
if palavra_a == palavra_b:
intersecao += 1.0
elif self._mesma_raiz(palavra_a, palavra_b):
intersecao += 0.7
elif self._mesmo_sinonimo(palavra_a, palavra_b):
intersecao += 0.6
tam = max(len(palavras_a), len(palavras_b))
return round(min(1.0, intersecao / tam), 4)
@staticmethod
def _palavras(texto: str) -> list[str]:
return [p for p in texto.lower().split()]
@staticmethod
def _mesma_raiz(a: str, b: str) -> bool:
raiz = min(len(a), len(b), 4)
return raiz >= 4 and a[:raiz] == b[:raiz]
@staticmethod
def _mesmo_sinonimo(a: str, b: str) -> bool:
return any(a in grupo and b in grupo for grupo in ComparadorLexicalSemantico._PARES_SINONIMOS)
class ComparadorSemantico:
"""Calcula similaridade semântica usando um provider injetável.
Quando o provider é ``None``, cai no ``ComparadorLexicalSemantico``
para não bloquear a análise na ausência de modelos locais.
"""
def __init__(self, provider: ProviderDeSimilaridadeSemantica | None = None) -> None:
self.provider = provider or ComparadorLexicalSemantico()
def comparar(self, fala_a: Fala, fala_b: Fala) -> float:
return float(self.provider.similaridade(fala_a.texto, fala_b.texto))
class ProviderDeSimilaridadeHuggingFace:
"""Embeds os textos localmente com um modelo de embeddings.
O modelo é carregado de forma preguiçosa para não custar nada até ser
de fato necessário (zona de dúvida do classificador).
"""
def __init__(self, modelo: str = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2") -> None:
self.modelo = modelo
self._encoder = None
def similaridade(self, texto_a: str, texto_b: str) -> float:
if self._encoder is None:
from sentence_transformers import SentenceTransformer
self._encoder = SentenceTransformer(self.modelo)
embeddings = self._encoder.encode([texto_a, texto_b], normalize_embeddings=True)
return float(_cosseno(embeddings[0], embeddings[1]))
def _cosseno(a: list[float], b: list[float]) -> float:
produto = sum(x * y for x, y in zip(a, b))
norma_a = math.sqrt(sum(x * x for x in a))
norma_b = math.sqrt(sum(y * y for y in b))
if norma_a == 0 or norma_b == 0:
return 0.0
return max(0.0, min(1.0, produto / (norma_a * norma_b)))
@@ -0,0 +1,86 @@
"""Detecção de sinais de reinício/recomeço de fala.
Analisa padrões de que a pessoa começou novamente uma ideia: repetição do
início de uma frase, pausas longas, marcadores explícitos ("não", "pera",
"vamos de novo", "desculpa") e repetição de palavras consecutivas.
"""
from __future__ import annotations
import re
from typing import Iterable
from .comparador_de_falas import ComparadorDeFalas
from .modelos_de_retakes import Fala, SinalDeReinicio
_MARCADORES = (
"não", "pera", "peraí", "pera ai", "espera", "vamos de novo", "de novo",
"vamos recomeçar", "recomeçar", "desculpa", "desculpe", "deixa eu ver",
"vou repetir", "esquece", "hmm", "hm", "uhm", "tá", "ta",
)
_RE_MARCADOR = re.compile(
r"\b(?:" + "|".join(re.escape(m) for m in _MARCADORES) + r")\b",
re.IGNORECASE,
)
_PAUSA_LONGA = 1.2 # segundos a partir dos quais a pausa sugere recomeço.
class DetectorDeReinicios:
"""Encontra ``SinalDeReinicio`` em cada fala em relação à anterior."""
def __init__(
self,
comparador: ComparadorDeFalas | None = None,
pausa_longa: float = _PAUSA_LONGA,
) -> None:
self.comparador = comparador or ComparadorDeFalas()
self.pausa_longa = pausa_longa
def detectar(self, falas: Iterable[Fala]) -> list[SinalDeReinicio]:
falas = list(falas)
sinais: list[SinalDeReinicio] = []
for indice, fala in enumerate(falas):
sinal = self._analisar(fala, falas[indice - 1] if indice > 0 else None)
if sinal:
sinais.append(sinal)
return sinais
def _analisar(self, fala: Fala, anterior: Fala | None) -> SinalDeReinicio | None:
evidencias: list[str] = []
intensidades: list[float] = []
palavras = fala.texto_normalizado.split()
# 1. Marcadores explícitos de recomeço no início.
if palavras and _RE_MARCADOR.match(palavras[0]):
evidencias.append("marcador explícito de recomeço")
intensidades.append(0.9)
if anterior is None:
if intensidades:
return SinalDeReinicio(fala.id, "reinicio_explicito",
round(max(intensidades), 3), evidencias)
return None
# 2. Pausa longa antes da fala.
pausa = fala.inicio - anterior.fim
if pausa >= self.pausa_longa:
evidencias.append(f"pausa de {pausa:.1f} segundos")
intensidades.append(min(1.0, 0.5 + pausa / 8.0))
# 3. Repetição do início da frase anterior.
prefixo = self.comparador.prefixo_comum_em_palavras(anterior.texto, fala.texto)
if prefixo >= 3:
evidencias.append(f"repetição das primeiras {prefixo} palavras")
intensidades.append(min(1.0, 0.4 + prefixo * 0.08))
if not evidencias:
return None
return SinalDeReinicio(
fala_id=fala.id,
tipo="repeticao_do_inicio" if any("repetição" in e for e in evidencias) else "reinicio_pos_pausa",
intensidade=round(min(1.0, max(intensidades)), 3),
evidencias=evidencias,
)
@@ -0,0 +1,154 @@
"""Modelos de dados do submódulo de detecção de retakes.
Mantemos os mesmos princípios de ``scanner/modelos.py``: dataclasses
imutáveis (``frozen=True``) com validação em ``__post_init__`` e sem
dependência de implementações concretas de providers.
"""
from __future__ import annotations
import uuid
from dataclasses import dataclass, field
from datetime import datetime, timezone
from ..modelos import PalavraDeTranscricao
@dataclass(frozen=True)
class Fala:
"""Uma fala normalizada, já posicionada na timeline do vídeo.
Corresponde a um ``SegmentoDeTranscricao`` convertido pelo adaptador,
mas carrega o contexto necessário para a análise temporal e de
agrupamento: vídeo, faixa de áudio, número de sequência e o vínculo
com o segmento/clipe de origem.
"""
id: str
video_id: str
faixa_id: str
segmento_id: str
ordem: int
inicio: float
fim: float
texto: str
texto_normalizado: str = ""
palavras: tuple[PalavraDeTranscricao, ...] = ()
falante: str | None = None
def __post_init__(self) -> None:
if self.inicio < 0 or self.fim < self.inicio:
raise ValueError(f"Intervalo da fala {self.id} inválido.")
@dataclass(frozen=True)
class SinalDeReinicio:
"""Indício de que uma fala recomeçou uma ideia já iniciada."""
fala_id: str
tipo: str
intensidade: float
evidencias: list[str] = field(default_factory=list)
def __post_init__(self) -> None:
if not 0.0 <= self.intensidade <= 1.0:
raise ValueError(f"Intensidade do reinício {self.fala_id} fora de [0, 1].")
@dataclass(frozen=True)
class ResultadoDaComparacao:
"""Resultado da comparação de duas falas, textual e por sequência."""
fala_a_id: str
fala_b_id: str
similaridade_jaccard: float = 0.0
similaridade_de_sequencia: float = 0.0
similaridade_do_inicio: float = 0.0
similaridade_do_final: float = 0.0
similaridade_final: float = 0.0
@dataclass(frozen=True)
class ResultadoDoIntervalo:
"""Relação temporal entre duas falas."""
intervalo_em_segundos: float
mesmo_segmento: bool = False
proximidade_temporal: float = 0.0
indicio_de_nova_tentativa: bool = False
@dataclass(frozen=True)
class EvidenciaDeRetake:
"""Uma evidência legível que sustenta a classificação de um grupo."""
tipo: str
descricao: str
valor: float | None = None
@dataclass(frozen=True)
class TomadaDeRetake:
"""Uma tomada (fala) pertencente a um grupo de retakes."""
ordem: int
fala_id: str
segmento_id: str
inicio: float
fim: float
texto: str
similaridade_com_anterior: float = 0.0
confianca: float = 0.0
def gerar_id_do_grupo() -> str:
"""Gera um id curto e monótono para um grupo de retakes."""
return f"retake_{uuid.uuid4().hex[:6]}"
@dataclass(frozen=True)
class ParAgrupado:
"""Um par de falas que o agrupador juntou como candidato a retake."""
fala_a: Fala
fala_b: Fala
comparacao: ResultadoDaComparacao
intervalo: ResultadoDoIntervalo
similaridade_semantica: float = 0.0
@dataclass(frozen=True)
class AgrupamentoDeFalas:
"""Um grupo candidato formado pelo agrupador, antes da classificação."""
fala_ids: tuple[str, ...]
pares: tuple[ParAgrupado, ...]
sinais_de_reinicio: tuple[SinalDeReinicio, ...] = ()
@dataclass(frozen=True)
class GrupoDeRetake:
"""Agrupa as tomadas que representam tentativas da mesma fala."""
id: str
video_id: str
faixa_id: str
tipo: str
confianca: float
tomadas: list[TomadaDeRetake] = field(default_factory=list)
evidencias: list[EvidenciaDeRetake] = field(default_factory=list)
criado_em: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
def __post_init__(self) -> None:
if not 0.0 <= self.confianca <= 1.0:
raise ValueError(f"Confiança do grupo {self.id} fora de [0, 1].")
@property
def tomada_principal_id(self) -> str | None:
if not self.tomadas:
return None
return max(self.tomadas, key=lambda item: item.confianca).fala_id
@property
def fala_ids(self) -> list[str]:
return [tomada.fala_id for tomada in self.tomadas]
+152 -21
View File
@@ -1,10 +1,11 @@
from dataclasses import asdict, dataclass
import hashlib
import json
from pathlib import Path
from typing import Any, Callable
from ..integracoes.midia import ExtracaoDeAudio
from .modelos import SegmentoDeTranscricao
from .modelos import PalavraDeTranscricao, SegmentoDeTranscricao
@dataclass(frozen=True)
@@ -14,6 +15,7 @@ class TranscricaoDoClipe:
inicio_na_timeline: float
fim_na_timeline: float
segmentos: list[SegmentoDeTranscricao]
intervalo_na_origem: tuple[float, float] | None = None
@property
def texto(self) -> str:
@@ -21,39 +23,168 @@ class TranscricaoDoClipe:
class TranscricaoDaTimeline:
"""Transcreve somente os intervalos efetivamente usados na timeline."""
"""Transcreve cada arquivo uma vez e projeta o resultado nos cortes.
A primeira versão suporta apenas mapeamento linear em velocidade normal.
Os timestamps retornados pelo provider são sempre relativos ao arquivo
original; somente a cópia materializada para cada clipe recebe timestamps
da timeline.
"""
def __init__(self, provider: Any, extrator: ExtracaoDeAudio | None = None,
diretoria_de_trabalho: str | Path = ".transcricao") -> None:
diretoria_de_trabalho: str | Path = ".transcricao",
analisador_de_emocao: Any | None = None,
diarizador: Any | None = None) -> None:
self.provider = provider
self.extrator = extrator or ExtracaoDeAudio()
self.diretoria_de_trabalho = Path(diretoria_de_trabalho)
self.analisador_de_emocao = analisador_de_emocao
self.diarizador = diarizador
def executar(self, timeline: Any) -> list[TranscricaoDoClipe]:
resultados: list[TranscricaoDoClipe] = []
clipes: list[Any] = []
for faixa in timeline.faixas:
for clipe in faixa.clipes:
if not clipe.arquivo or clipe.offline:
continue
intervalo = clipe.intervalo_na_timeline
pasta = self.diretoria_de_trabalho / clipe.identificador
origem = clipe.intervalo_na_origem
partes = self.extrator.extrair_intervalo(
clipe.arquivo, pasta, origem.inicio if origem else 0.0,
origem.fim if origem else None,
)
segmentos: list[SegmentoDeTranscricao] = []
for parte in partes:
for segmento in self.provider.transcrever(type("Audio", (), {"arquivo": str(parte.caminho)})()):
base_origem = origem.inicio if origem else 0.0
inicio = max(intervalo.inicio, intervalo.inicio + (parte.inicio - base_origem) + segmento.inicio)
fim = min(intervalo.fim, intervalo.inicio + (parte.inicio - base_origem) + segmento.fim)
if inicio < intervalo.fim and fim > inicio:
segmentos.append(SegmentoDeTranscricao(inicio, fim, segmento.texto, segmento.confianca))
resultados.append(TranscricaoDoClipe(clipe.identificador, clipe.arquivo,
intervalo.inicio, intervalo.fim, segmentos))
clipes.append(clipe)
transcricoes: dict[str, list[SegmentoDeTranscricao]] = {}
for clipe in clipes:
chave = self._chave_do_arquivo(clipe.arquivo)
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,
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)
inicio = max(segmento.inicio, inicio_origem)
if inicio < fim:
palavras = tuple(
PalavraDeTranscricao(
palavra.texto,
intervalo.inicio + max(palavra.inicio, inicio_origem) - inicio_origem,
intervalo.inicio + min(palavra.fim, fim_origem) - inicio_origem,
palavra.confianca,
palavra.falante,
)
for palavra in segmento.palavras
if palavra.inicio < fim_origem and palavra.fim > inicio_origem
)
segmentos.append(SegmentoDeTranscricao(
intervalo.inicio + inicio - inicio_origem,
intervalo.inicio + fim - inicio_origem,
segmento.texto, segmento.confianca, palavras,
segmento.emocao, segmento.confianca_emocao,
segmento.caracteristicas_acusticas, segmento.falante,
segmento.voz_aparente, segmento.confianca_voz))
resultados.append(TranscricaoDoClipe(clipe.identificador, clipe.arquivo,
intervalo.inicio, intervalo.fim, segmentos,
(inicio_origem, fim_origem) if origem else None))
return resultados
def _chave_do_arquivo(self, arquivo: str) -> str:
caminho = Path(arquivo).expanduser().resolve()
digest = hashlib.sha256()
with caminho.open("rb") as conteudo:
for bloco in iter(lambda: conteudo.read(1024 * 1024), b""):
digest.update(bloco)
return digest.hexdigest()
def _transcrever_arquivo(self, arquivo: str, chave: str) -> list[SegmentoDeTranscricao]:
pasta = self.diretoria_de_trabalho / "arquivos" / chave
nome_arquivo = Path(arquivo).stem
modelo = self._nome_do_modelo()
prefixo = f"transcricao-{self._nome_seguro(nome_arquivo)}-{self._nome_seguro(modelo)}"
cache = pasta / f"{prefixo}.json"
if cache.is_file():
dados = json.loads(cache.read_text(encoding="utf-8"))
if all("palavras" in item and
(self.analisador_de_emocao is None or
("emocao" in item and "voz_aparente" in item))
for item in dados):
return [self._segmento_do_json(item) for item in dados]
partes = self.extrator.extrair(arquivo, pasta / "audio")
segmentos: list[SegmentoDeTranscricao] = []
for parte in partes:
falas = self._diarizar(parte.caminho)
for segmento in self.provider.transcrever(type("Audio", (), {"arquivo": str(parte.caminho)})()):
analise = self._analisar_emocao(parte.caminho, segmento.inicio, segmento.fim)
falante = self._falante_em(falas, segmento.inicio, segmento.fim)
segmentos.append(SegmentoDeTranscricao(parte.inicio + segmento.inicio,
parte.inicio + segmento.fim, segmento.texto, segmento.confianca,
tuple(PalavraDeTranscricao(p.texto, parte.inicio + p.inicio,
parte.inicio + p.fim, p.confianca,
self._falante_em(falas, p.inicio, p.fim))
for p in segmento.palavras),
analise.get("emocao"), analise.get("confianca"),
dict(analise.get("caracteristicas_acusticas", {})), falante,
analise.get("voz_aparente"), analise.get("confianca_voz")))
pasta.mkdir(parents=True, exist_ok=True)
cache.write_text(json.dumps([asdict(item) for item in segmentos], ensure_ascii=False, indent=2), encoding="utf-8")
(pasta / f"{prefixo}.txt").write_text(
" ".join(item.texto for item in segmentos if item.texto).strip() + "\n",
encoding="utf-8",
)
return segmentos
@staticmethod
def _segmento_do_json(item: dict[str, Any]) -> SegmentoDeTranscricao:
palavras = tuple(PalavraDeTranscricao(str(p["texto"]), float(p["inicio"]),
float(p["fim"]), p.get("confianca"),
p.get("falante"))
for p in item.get("palavras", []))
return SegmentoDeTranscricao(float(item["inicio"]), float(item["fim"]),
str(item["texto"]), item.get("confianca"), palavras,
item.get("emocao"), item.get("confianca_emocao"),
dict(item.get("caracteristicas_acusticas", {})), item.get("falante"),
item.get("voz_aparente"), item.get("confianca_voz"))
def _analisar_emocao(self, arquivo: Path, inicio: float, fim: float) -> dict[str, Any]:
if self.analisador_de_emocao is None:
return {}
return dict(self.analisador_de_emocao.analisar(arquivo, inicio, fim))
def _diarizar(self, arquivo: Path) -> list[tuple[float, float, str]]:
if self.diarizador is None:
return []
return list(self.diarizador.analisar(arquivo))
@staticmethod
def _falante_em(falas: list[tuple[float, float, str]], inicio: float, fim: float) -> str | None:
sobreposicoes = [(min(fim, saida) - max(inicio, entrada), falante)
for entrada, saida, falante in falas
if entrada < fim and saida > inicio]
return max(sobreposicoes, default=(0.0, None))[1]
def _nome_do_modelo(self) -> str:
"""Obtém o nome público do modelo sem acoplar o scanner ao provider."""
modelo = getattr(self.provider, "nome_do_modelo", None)
if modelo:
return str(modelo)
modelo = getattr(self.provider, "modelo", None)
if modelo:
return Path(str(modelo)).name
return self.provider.__class__.__name__.lower()
@staticmethod
def _nome_seguro(valor: str) -> str:
return "".join(caractere if caractere.isalnum() or caractere in "._-" else "_"
for caractere in valor).strip("._") or "arquivo"
@staticmethod
def gerar_relatorio(resultados: list[TranscricaoDoClipe], destino: str | Path) -> Path:
caminho = Path(destino)
+184
View File
@@ -0,0 +1,184 @@
from collections.abc import Callable
from dataclasses import dataclass, field
from ..dominio import Clipe
from ..integracoes.visual.contratos import (AnalisadorDeFrame, AnalisadorDeSequenciaDeQuadros,
DetectorDeIntervalosDeCena, ExtratorDeQuadros)
from ..integracoes.visual.modelos import QuadroDeVideo
from .modelos import Cena, CenaVisual, EvidenciaVisual
@dataclass(frozen=True)
class ResultadoVisualDoClipe:
identificador_do_clipe: str
evidencias: list[EvidenciaVisual] = field(default_factory=list)
cenas: list[Cena] = field(default_factory=list)
cenas_visuais: list[CenaVisual] = field(default_factory=list)
class DetectorDeCenas:
"""Módulo profundo que orquestra frames, analisadores e cenas de um clipe.
A interface não expõe OpenCV, ONNX, MediaPipe, PySceneDetect ou Vision.
Cada adapter pode ser trocado sem alterar chamadores nem resultados do domínio.
"""
def __init__(self, extrator: ExtratorDeQuadros,
analisadores_de_frame: list[AnalisadorDeFrame] | None = None,
detectores_de_intervalo: list[DetectorDeIntervalosDeCena] | None = None,
analisadores_de_sequencia: list[AnalisadorDeSequenciaDeQuadros] | None = None,
ao_progresso: Callable[[int, int], None] | None = None) -> None:
self.extrator = extrator
self.analisadores_de_frame = analisadores_de_frame or []
self.analisadores_de_sequencia = analisadores_de_sequencia or []
self.detectores_de_intervalo = detectores_de_intervalo or []
self.ao_progresso = ao_progresso
def detectar(self, clipe: Clipe) -> ResultadoVisualDoClipe:
quadros = self.extrator.extrair(clipe)
total = len(quadros) * len(self.analisadores_de_frame) + len(self.analisadores_de_sequencia)
progresso = [0]
evidencias = self._analisar_quadros(quadros, progresso, total) + self._analisar_sequencia(quadros, progresso, total)
cenas = self._detectar_intervalos(clipe, quadros)
cenas_visuais = [CenaVisual(
clipe.identificador, cena.inicio, cena.fim,
tuple(item for item in evidencias if item.inicio <= cena.fim and item.fim >= cena.inicio),
cena.referencias,
) for cena in cenas]
return ResultadoVisualDoClipe(clipe.identificador, evidencias, cenas, cenas_visuais)
def _analisar_quadros(self, quadros: list[QuadroDeVideo], progresso: list[int] | None = None,
total: int = 0) -> list[EvidenciaVisual]:
resultado: list[EvidenciaVisual] = []
for quadro in quadros:
for analisador in self.analisadores_de_frame:
resultado.extend(analisador.analisar(quadro))
self._avancar_progresso(progresso, total)
return resultado
def _analisar_sequencia(self, quadros: list[QuadroDeVideo], progresso: list[int] | None = None,
total: int = 0) -> list[EvidenciaVisual]:
resultado: list[EvidenciaVisual] = []
for analisador in self.analisadores_de_sequencia:
resultado.extend(analisador.analisar(quadros))
self._avancar_progresso(progresso, total)
return resultado
def _avancar_progresso(self, progresso: list[int] | None, total: int) -> None:
if progresso is None or total <= 0:
return
progresso[0] += 1
if self.ao_progresso:
self.ao_progresso(progresso[0], total)
def _detectar_intervalos(self, clipe: Clipe, quadros: list[QuadroDeVideo]) -> list[Cena]:
cenas: list[Cena] = []
for detector in self.detectores_de_intervalo:
cenas.extend(detector.detectar(clipe, quadros))
cenas = self._consolidar_cenas(cenas)
if not cenas and quadros:
cenas = [Cena(quadros[0].timestamp, quadros[-1].timestamp)]
return cenas
@staticmethod
def _consolidar_cenas(cenas: list[Cena]) -> list[Cena]:
resultado: list[Cena] = []
for cena in sorted(cenas, key=lambda item: (item.inicio, item.fim)):
if resultado and cena.inicio <= resultado[-1].fim:
anterior = resultado[-1]
resultado[-1] = Cena(anterior.inicio, max(anterior.fim, cena.fim),
anterior.confianca or cena.confianca,
anterior.referencias + cena.referencias)
else:
resultado.append(cena)
return resultado
class AnaliseVisualDaTimeline:
"""Etapa do Scanner que guarda evidências e cenas por clipe no contexto."""
nome = "analise_visual_local"
def __init__(self, detector: DetectorDeCenas, continuar_em_falha: bool = True) -> None:
self.detector = detector
self.continuar_em_falha = continuar_em_falha
def executar(self, contexto):
if contexto.timeline is None:
return contexto
for faixa in contexto.timeline.faixas:
for clipe in faixa.clipes:
if clipe.offline or not clipe.arquivo:
continue
try:
resultado = self.detector.detectar(clipe)
except Exception as exc:
if not self.continuar_em_falha:
raise
contexto.avisos.append(
f"analise_visual_indisponivel: clipe {clipe.identificador}: {exc}"
)
contexto.caracteristicas_visuais[clipe.identificador] = {
"evidencias": [], "cenas": [], "cenas_visuais": [], "disponivel": False,
}
continue
contexto.caracteristicas_visuais[clipe.identificador] = {
"evidencias": resultado.evidencias,
"cenas": resultado.cenas,
"cenas_visuais": resultado.cenas_visuais,
"disponivel": True,
}
contexto.cenas.extend(resultado.cenas)
contexto.cenas_visuais.extend(resultado.cenas_visuais)
contexto.cenas = DetectorDeCenas._consolidar_cenas(contexto.cenas)
return contexto
def criar_detector_visual_local(
diretorio_de_cache: str | None = ".frames",
intervalo_em_segundos: float = 1.0,
) -> DetectorDeCenas:
"""Monta o detector local padrão com uma interface curta para o Scanner."""
from ..integracoes.visual import (
AnalisadorDeComposicaoOpenCV,
AnalisadorDeContinuidadeOpenCV,
AnalisadorDeQualidadeOpenCV,
ExtratorDeQuadrosOpenCV,
)
return DetectorDeCenas(
ExtratorDeQuadrosOpenCV(
intervalo_em_segundos=intervalo_em_segundos,
diretorio_de_cache=diretorio_de_cache,
),
analisadores_de_frame=[
AnalisadorDeQualidadeOpenCV(),
AnalisadorDeComposicaoOpenCV(),
],
analisadores_de_sequencia=[AnalisadorDeContinuidadeOpenCV()],
)
def criar_detector_apple_vision(
diretorio_de_cache: str | None = ".frames",
intervalo_em_segundos: float = 1.0,
configuracao=None,
) -> DetectorDeCenas:
"""Monta a análise local de cena baseada em Vision e arquivos de origem."""
from .configuracao_visual import ConfiguracaoVisualDoScanner
from ..integracoes.visual import (AnalisadorAppleVisionNativo,
AnalisadorSequenciaAppleVisionNativo,
ExtratorDeQuadrosFFmpeg)
perfil = configuracao or ConfiguracaoVisualDoScanner(intervalo_em_segundos=intervalo_em_segundos)
return DetectorDeCenas(
ExtratorDeQuadrosFFmpeg(intervalo_em_segundos=perfil.intervalo_em_segundos,
diretorio_de_cache=diretorio_de_cache or ".frames"),
analisadores_de_frame=[AnalisadorAppleVisionNativo(
recursos=tuple(sorted(perfil.recursos_vision)),
interpretar_com_apple_intelligence=perfil.interpretar_cena,
)],
analisadores_de_sequencia=[AnalisadorSequenciaAppleVisionNativo()]
if perfil.analisar_continuidade else [],
)
@@ -0,0 +1,216 @@
import unittest
from pathlib import Path
import json
import tempfile
from engine.dominio import Clipe, Faixa, IntervaloDeTempo, Timeline
from engine.integracoes.visual.contratos import (AnalisadorDeFrame, AnalisadorDeSequenciaDeQuadros,
DetectorDeIntervalosDeCena,
ExtratorDeQuadros)
from engine.integracoes.visual.modelos import QuadroDeVideo
from engine.integracoes.visual.extrator_open_cv import ExtratorDeQuadrosOpenCV
from engine.integracoes.visual.apple_vision import (AnalisadorAppleVisionNativo,
AnalisadorSequenciaAppleVisionNativo)
from engine.scanner.coordenacao import ContextoDeAnalise
from engine.scanner.modelos import Cena, EvidenciaVisual
from engine.scanner.visual import AnaliseVisualDaTimeline, DetectorDeCenas, ResultadoVisualDoClipe
from engine.scanner.relatorio_visual import gerar_relatorio_visual, gerar_resumo_visual_markdown
from engine.scanner.configuracao_visual import ConfiguracaoVisualDoScanner
class ExtratorSimulado(ExtratorDeQuadros):
def extrair(self, clipe):
return [
QuadroDeVideo(0.0, 0, 1920, 1080, "frame-0"),
QuadroDeVideo(1.0, 1, 1920, 1080, "frame-1"),
]
class AnalisadorSimulado(AnalisadorDeFrame):
nome = "simulado"
def analisar(self, quadro):
return [EvidenciaVisual("qualidade", quadro.timestamp, quadro.timestamp,
{"nitidez_laplaciana": 42.0}, provider=self.nome)]
class DetectorSimulado(DetectorDeIntervalosDeCena):
nome = "simulado"
def detectar(self, clipe, quadros):
return [Cena(0.0, 1.0, referencias=(0.0,)), Cena(1.0, 2.0, referencias=(1.0,))]
class AnalisadorTemporalSimulado(AnalisadorDeSequenciaDeQuadros):
nome = "temporal_simulado"
def analisar(self, quadros):
return [EvidenciaVisual("continuidade", quadros[0].timestamp, quadros[-1].timestamp,
{"possivel_descontinuidade": False}, provider=self.nome)]
class ExecutorAppleSimulado:
def executar(self, carga, timeout):
self.carga, self.timeout = carga, timeout
return {"evidencias": [{"tipo": "pessoa", "valor": {"ocupacao_do_quadro": 0.3},
"confianca": 0.9, "modelo": "VNDetectHumanRectanglesRequest"}],
"avisos": ["ocr: indisponível"]}
class ExecutorAppleSequenciaSimulado:
def executar(self, carga, timeout):
self.carga, self.timeout = carga, timeout
return {"evidencias": [{"tipo": "similaridade_visual", "valor": {
"frame_inicial": 0, "frame_final": 1, "distancia_feature_print": 0.2,
"possivel_mudanca_de_cena": False,
}, "modelo": "VNGenerateImageFeaturePrintRequest"}], "avisos": []}
class TesteAnaliseVisualLocal(unittest.TestCase):
def setUp(self):
self.clipe = Clipe("clip-1", "camera", IntervaloDeTempo(0, 2), arquivo="/video.mp4")
def test_detector_envia_cada_frame_aos_analisadores_e_consolida_cenas(self):
detector = DetectorDeCenas(ExtratorSimulado(), [AnalisadorSimulado()], [DetectorSimulado()])
resultado = detector.detectar(self.clipe)
self.assertEqual(resultado.identificador_do_clipe, "clip-1")
self.assertEqual([item.inicio for item in resultado.evidencias], [0.0, 1.0])
self.assertEqual(len(resultado.cenas), 1)
self.assertEqual((resultado.cenas[0].inicio, resultado.cenas[0].fim), (0.0, 2.0))
def test_etapa_da_timeline_guarda_evidencias_por_clipe(self):
detector = DetectorDeCenas(ExtratorSimulado(), [AnalisadorSimulado()], [DetectorSimulado()])
timeline = Timeline("timeline-1", "Principal", faixas=[
Faixa("video-1", "V1", "video", 0, [self.clipe]),
])
contexto = ContextoDeAnalise(timeline=timeline)
AnaliseVisualDaTimeline(detector).executar(contexto)
self.assertEqual(len(contexto.caracteristicas_visuais["clip-1"]["evidencias"]), 2)
self.assertEqual(len(contexto.cenas), 1)
def test_detector_executa_analisadores_de_sequencia_uma_vez_por_clipe(self):
detector = DetectorDeCenas(ExtratorSimulado(), analisadores_de_sequencia=[AnalisadorTemporalSimulado()])
resultado = detector.detectar(self.clipe)
self.assertEqual(len(resultado.evidencias), 1)
self.assertEqual(resultado.evidencias[0].tipo, "continuidade")
def test_fluxo_produz_cena_visual_com_observacoes_e_continuidade(self):
detector = DetectorDeCenas(
ExtratorSimulado(), [AnalisadorSimulado()],
analisadores_de_sequencia=[AnalisadorTemporalSimulado()],
)
contexto = ContextoDeAnalise(timeline=Timeline(
"timeline-1", "Principal", faixas=[Faixa("video-1", "V1", "video", 0, [self.clipe])]
))
AnaliseVisualDaTimeline(detector).executar(contexto)
cena = contexto.cenas_visuais[0]
self.assertEqual(cena.identificador_do_clipe, "clip-1")
self.assertEqual([item.tipo for item in cena.observacoes], ["qualidade", "qualidade", "continuidade"])
self.assertTrue(contexto.caracteristicas_visuais["clip-1"]["disponivel"])
def test_falha_de_adapter_e_registrada_sem_interromper_outro_clipe(self):
class ExtratorQueFalha(ExtratorDeQuadros):
def extrair(self, clipe):
if clipe.identificador == "clip-1":
raise RuntimeError("OpenCV não instalado")
return [QuadroDeVideo(0.0, 0, 1920, 1080, "frame")]
segundo = Clipe("clip-2", "camera-2", IntervaloDeTempo(2, 3), arquivo="/video-2.mp4")
timeline = Timeline("timeline-1", "Principal", faixas=[
Faixa("video-1", "V1", "video", 0, [self.clipe, segundo]),
])
contexto = ContextoDeAnalise(timeline=timeline)
detector = DetectorDeCenas(ExtratorQueFalha(), [AnalisadorSimulado()])
AnaliseVisualDaTimeline(detector).executar(contexto)
self.assertFalse(contexto.caracteristicas_visuais["clip-1"]["disponivel"])
self.assertTrue(contexto.caracteristicas_visuais["clip-2"]["disponivel"])
self.assertEqual(len(contexto.avisos), 1)
def test_adapter_apple_converte_fatos_e_preserva_avisos_por_recurso(self):
executor = ExecutorAppleSimulado()
quadro = QuadroDeVideo(0.0, 0, 1920, 1080, "frame", Path("/frame.jpg"))
evidencias = AnalisadorAppleVisionNativo(executor=executor).analisar(quadro)
self.assertEqual(evidencias[0].tipo, "pessoa")
self.assertEqual(evidencias[0].modelo, "VNDetectHumanRectanglesRequest")
self.assertEqual(evidencias[1].tipo, "diagnostico_apple_vision")
self.assertTrue(executor.carga["resumir"])
def test_adapter_apple_de_sequencia_converte_indices_em_intervalos(self):
executor = ExecutorAppleSequenciaSimulado()
quadros = [QuadroDeVideo(2.0, 0, 1920, 1080, "frame", Path("/frame-0.jpg")),
QuadroDeVideo(3.0, 1, 1920, 1080, "frame", Path("/frame-1.jpg"))]
evidencias = AnalisadorSequenciaAppleVisionNativo(executor=executor).analisar(quadros)
self.assertEqual(evidencias[0].tipo, "similaridade_visual")
self.assertEqual((evidencias[0].inicio, evidencias[0].fim), (2.0, 3.0))
self.assertEqual(evidencias[0].valor["distancia_feature_print"], 0.2)
self.assertEqual(executor.carga["frames"], ["/frame-0.jpg", "/frame-1.jpg"])
def test_evidencia_rejeita_intervalo_invalido(self):
with self.assertRaises(ValueError):
EvidenciaVisual("objeto", 2.0, 1.0, {})
def test_relatorio_visual_separa_frames_de_evidencias_temporais(self):
resultado = ResultadoVisualDoClipe("clip-1", [
EvidenciaVisual("categoria", 2.0, 2.0, {"identificador": "drink"}),
EvidenciaVisual("similaridade_visual", 2.0, 3.0, {"distancia": 0.2}),
])
with tempfile.TemporaryDirectory() as pasta:
caminho = gerar_relatorio_visual(self.clipe, resultado, Path(pasta) / "visual.json", 1.0)
dados = json.loads(caminho.read_text(encoding="utf-8"))
self.assertEqual(dados["clipe"]["identificador"], "clip-1")
self.assertEqual(dados["observacoes_por_frame"][0]["timestamp_na_origem"], 2.0)
self.assertEqual(dados["observacoes_por_frame"][0]["evidencias"][0]["tipo"], "categoria")
self.assertEqual(dados["evidencias_temporais"][0]["tipo"], "similaridade_visual")
def test_resumo_visual_markdown_lista_frames_e_transicoes(self):
resultado = ResultadoVisualDoClipe("clip-1", [
EvidenciaVisual("categoria", 2.0, 2.0, {"identificador": "drink"}),
EvidenciaVisual("similaridade_visual", 2.0, 3.0, {"distancia": 0.2}),
])
with tempfile.TemporaryDirectory() as pasta:
caminho = gerar_resumo_visual_markdown(self.clipe, resultado, Path(pasta) / "visual.md", 1.0)
conteudo = caminho.read_text(encoding="utf-8")
self.assertIn("Origem 2.000s", conteudo)
self.assertIn("similaridade_visual", conteudo)
def test_configuracao_visual_valida_o_perfil_do_painel(self):
perfil = ConfiguracaoVisualDoScanner.de_dict({
"intervalo_em_segundos": 5,
"faixas_de_video": [2, 0, 2],
"recursos_vision": ["faces", "pessoas"],
"analisar_continuidade": False,
})
self.assertEqual(perfil.faixas_de_video, (0, 2))
self.assertEqual(perfil.para_dict()["recursos_vision"], ["faces", "pessoas"])
self.assertFalse(perfil.analisar_continuidade)
def test_extrator_respeita_intervalo_na_origem_e_limite_de_frames(self):
clipe = Clipe("clip-1", "camera", IntervaloDeTempo(0, 2),
intervalo_na_origem=IntervaloDeTempo(2.3, 5.0), arquivo="/video.mp4")
extrator = ExtratorDeQuadrosOpenCV(intervalo_em_segundos=1.0, maximo_de_quadros=1,
cv2_module=object())
inicio, fim = extrator._intervalo_de_origem(clipe, 10.0)
self.assertEqual(extrator._timestamps(inicio, fim), [2.3])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,86 @@
import json
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from engine.dominio import Clipe, Faixa, IntervaloDeTempo, Timeline
from engine.scanner.coordenacao import ContextoDeAnalise
from engine.scanner.descoberta import ArquivoDescoberto, DescobertaDeArquivos
from engine.scanner.metadados import ExtracaoDeMetadados
from engine.integracoes.midia.extracao_de_metadados import ExtracaoDeMetadados as Adapter
class TesteArquivosEMetadados(unittest.TestCase):
def _contexto(self, *arquivos: str | None) -> ContextoDeAnalise:
clipes = [Clipe(str(i), f"clipe-{i}", IntervaloDeTempo(0, 1), arquivo=arquivo)
for i, arquivo in enumerate(arquivos)]
timeline = Timeline("timeline", "Principal", faixas=[Faixa("v1", "V1", "video", 0, clipes)])
return ContextoDeAnalise(timeline=timeline)
def test_descoberta_normaliza_caminho_e_marca_offline(self):
with tempfile.TemporaryDirectory() as pasta:
arquivo = Path(pasta) / "camera.mp4"
arquivo.touch()
contexto = self._contexto(str(arquivo), str(Path(pasta) / "ausente.mp4"), None)
DescobertaDeArquivos().executar(contexto)
self.assertEqual(contexto.timeline.faixas[0].clipes[0].arquivo, str(arquivo.resolve()))
self.assertFalse(contexto.timeline.faixas[0].clipes[0].offline)
self.assertTrue(contexto.timeline.faixas[0].clipes[1].offline)
self.assertTrue(contexto.timeline.faixas[0].clipes[2].offline)
self.assertEqual(len(contexto.erros), 2)
def test_descoberta_identifica_arquivo_duplicado(self):
with tempfile.TemporaryDirectory() as pasta:
arquivo = Path(pasta) / "camera.mov"
arquivo.touch()
contexto = self._contexto(str(arquivo), str(arquivo))
DescobertaDeArquivos().executar(contexto)
self.assertEqual(len(contexto.avisos), 1)
def test_extracao_converte_ffprobe_e_usa_cache(self):
dados = {"format": {"format_name": "mov,mp4,m4a", "duration": "12.5", "size": "900"},
"streams": [{"codec_type": "video", "codec_name": "h264", "width": 1920,
"height": 1080, "avg_frame_rate": "30000/1001"},
{"codec_type": "audio", "codec_name": "aac", "channels": 2,
"sample_rate": "48000"}]}
with tempfile.TemporaryDirectory() as pasta:
arquivo = Path(pasta) / "camera.mp4"
arquivo.touch()
adapter = Adapter(ffprobe="ffprobe-simulado")
processo = type("Processo", (), {"stdout": json.dumps(dados)})()
with patch("engine.integracoes.midia.extracao_de_metadados.subprocess.run", return_value=processo) as run:
primeiro = adapter.extrair(arquivo)
segundo = adapter.extrair(arquivo)
self.assertEqual(primeiro.codec_de_video, "h264")
self.assertEqual(primeiro.codec_de_audio, "aac")
self.assertEqual((primeiro.largura, primeiro.altura), (1920, 1080))
self.assertAlmostEqual(primeiro.taxa_de_quadros, 29.97002997, places=5)
self.assertEqual(primeiro.duracao, 12.5)
self.assertIs(primeiro, segundo)
run.assert_called_once()
def test_etapa_de_metadados_ignora_arquivos_offline(self):
class ExtratorSimulado:
def extrair(self, arquivo):
return "metadados"
with tempfile.TemporaryDirectory() as pasta:
arquivo = Path(pasta) / "camera.mp4"
arquivo.touch()
contexto = self._contexto(str(arquivo), str(Path(pasta) / "ausente.mp4"))
DescobertaDeArquivos().executar(contexto)
ExtracaoDeMetadados(ExtratorSimulado()).executar(contexto)
self.assertEqual(contexto.metadados_dos_arquivos["0"], "metadados")
self.assertNotIn("1", contexto.metadados_dos_arquivos)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,32 @@
import json
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from engine.integracoes.apple_speech import ProviderDeTranscricaoApple
class TesteProviderDeTranscricaoApple(unittest.TestCase):
@patch("engine.integracoes.apple_speech.provider_de_transcricao_apple.subprocess.run")
def test_transcreve_em_pt_br_no_dispositivo_e_preserva_timestamps(self, executar):
executar.return_value = type("Resultado", (), {
"stdout": json.dumps({"segmentos": [
{"inicio": 1.25, "fim": 2.75, "texto": " Olá mundo ", "confianca": 0.93},
]}),
})()
with tempfile.TemporaryDirectory() as pasta:
arquivo = Path(pasta) / "video.mp4"
arquivo.write_bytes(b"video")
clipe = type("Clipe", (), {"arquivo": str(arquivo)})()
resultado = ProviderDeTranscricaoApple(executavel="transcritor").transcrever(clipe)
self.assertEqual(resultado[0].texto, "Olá mundo")
self.assertEqual((resultado[0].inicio, resultado[0].fim), (1.25, 2.75))
comando = executar.call_args.args[0]
self.assertEqual(comando, ["transcritor", str(arquivo), "pt-BR", "true"])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,31 @@
# Teste de transcrição local — `0E6A8870.MP4`
## Configuração
- Modelo: `faster-whisper-base`
- Idioma: `pt`
- Dispositivo: CPU
- Quantização: `int8`
- Provider: `ProviderDeTranscricaoLocal`
- Origem: `/Volumes/Merongo/PROJETOS/brools/0E6A8870.MP4`
- Duração do vídeo: aproximadamente `6,05 s`
## Desempenho
| Medição | Resultado |
|---|---:|
| Carregamento do modelo | 1,849 s |
| Transcrição | 0,802 s |
| Memória máxima do processo | aproximadamente 665 MB |
## Resultado temporal
| Início | Fim | Texto reconhecido |
|---:|---:|---|
| 0,000 s | 6,000 s | “a mais é fazer bem pesado, porque não vai fazer as boguinhas que a gente ame de 11, então aqui...” |
## Observações
O processamento local funcionou sem autorização do Apple Speech e sem acesso de rede. O modelo `base` foi rápido para este arquivo curto, mas a transcrição apresenta baixa clareza em parte do trecho; a qualidade deve ser comparada com o modelo `small` antes de escolher o perfil definitivo.
Para o catálogo SQLite, este resultado deve ser persistido como um segmento de transcrição associado ao identificador do ativo e à versão do modelo. O modelo carregado deve permanecer reutilizável pelo worker, evitando pagar o custo de 1,849 s a cada arquivo.
@@ -0,0 +1,64 @@
# Teste de indexação — `0E6A8873.MP4`
## Identificação do ativo
- Origem: `/Volumes/Merongo/PROJETOS/brools/0E6A8873.MP4`
- Tamanho: `354.817.464` bytes
- Fingerprint parcial SHA-256: `d1072c11b67454a6e2eb0422ca037b1648a1ed05725ab2064c2e01d7194f2fdc`
- Duração: `23,315 s`
- Vídeo: H.264, `3840 × 2160`, aproximadamente `23,976 FPS`
- Áudio: AAC, 2 canais, `48 kHz`
## Resultado por etapa
| Etapa | Status | Tempo | Resultado |
|---|---|---:|---|
| FFprobe/metadados | concluída | 0,097 s | Metadados técnicos lidos |
| Extração de áudio | concluída | 0,078 s | WAV mono, 16 kHz, 745.570 bytes |
| Amostragem visual simples | concluída | 2,147 s | 23 frames, 1 FPS, reduzidos para 640 px |
| Apple Speech `pt-BR` on-device | autorização pendente | 0,011 s | Código 4: permissão não concedida |
## Interpretação
O pipeline barato de preparação é viável para processamento em segundo plano. Para este arquivo, a leitura técnica e a extração de áudio são praticamente instantâneas; a amostragem visual reduzida é a etapa mais custosa entre as etapas executadas, mas ainda terminou em aproximadamente 2,1 segundos.
O Apple Speech não apresentou mais o crash `SIGABRT`. O processo Foundation-only iniciou normalmente e retornou um erro controlado de autorização. Depois que o bundle receber permissão de reconhecimento de fala no macOS, esta mesma etapa poderá ser repetida sem alterar o catálogo nem as etapas já concluídas.
## Como isso será armazenado no SQLite
Este teste deverá produzir, conceitualmente:
```text
ativos
id = UUID interno
caminho_relativo = 0E6A8873.MP4
fingerprint = d1072...
tamanho = 354817464
duracao = 23.314958
status = parcial
trabalhos_de_indexacao
ativo_id = ...
etapa = metadados
status = concluida
tempo = 0.097
trabalhos_de_indexacao
ativo_id = ...
etapa = amostragem_visual
status = concluida
checkpoint = 23.315
trabalhos_de_indexacao
ativo_id = ...
etapa = transcricao
status = aguardando_autorizacao
provider = Apple Speech
locale = pt-BR
```
Os frames e o WAV são cache reconstruível e não devem ser tratados como o cadastro principal. Quando houver transcrição e análise visual, seus segmentos e evidências serão gravados com timestamps no arquivo de origem.
## Próximo teste
Conceder a permissão do Apple Speech ao bundle do transcritor e repetir somente a etapa de transcrição deste arquivo. Em seguida, comparar a qualidade do resultado antes de enfileirar os demais 23 arquivos.
@@ -0,0 +1,50 @@
# Descrição preliminar das cenas — próximos quatro vídeos
## Escopo
Este relatório complementa `proximos-4-transcricoes.md`. A transcrição foi executada
com `ProviderDeTranscricaoLocal`; a análise visual foi tentada através de
`AnalisadorAppleVisionNativo.analisar(QuadroDeVideo)`, usando frames PNG reduzidos
para 640 px de largura.
## Observações preliminares dos frames amostrados
Estas descrições são uma inspeção visual dos frames de teste e ainda não devem ser
gravadas como evidência automática definitiva no catálogo.
| Arquivo | Descrição preliminar |
|---|---|
| `0E6A8871.MP4` | Close de uma mão segurando uma caneta sobre um tablet. A tela mostra um diagrama de rosto com marcações/pontos; a ação principal é indicar ou marcar regiões do rosto. |
| `0E6A8872.MP4` | Mulher de blusa vermelha e cabelo preso demonstra algo usando uma caneta e um tablet com diagrama facial. Há alternância entre enquadramento da apresentadora e close do tablet/caneta. |
| `0E6A8873.MP4` | Demonstração presencial: mulher de blusa vermelha aparece junto de outra pessoa em posição reclinada e, em outro momento, junto de uma pessoa sentada. A atividade visível parece envolver indicação/avaliação da região facial, mas a finalidade não foi inferida. |
| `0E6A8874.MP4` | Plano médio frontal da mulher de blusa vermelha falando e gesticulando. Em alguns momentos ela aponta ou toca o próprio rosto; há um objeto/tablet no primeiro plano. |
## Resultado técnico da tentativa automática
O adapter existente foi chamado corretamente, sem criar uma implementação paralela:
```text
AnalisadorAppleVisionNativo(
recursos=("pessoas", "categorias", "ocr"),
interpretar_com_apple_intelligence=False,
).analisar(QuadroDeVideo(...))
```
O retorno não trouxe `pessoas`, `categorias` nem `ocr`. Trouxe somente uma
evidência `diagnostico_apple_vision`, com estes avisos:
- `pessoas`: falha ao carregar o modelo ANE do Vision;
- `ocr`: erro genérico do framework;
- `categorias`: falha ao criar `CVPixelBufferPool` com dimensões zeradas.
O fallback `AnalisadorDeRostosOpenCV`, `AnalisadorDeComposicaoOpenCV` e
`AnalisadorDeQualidadeOpenCV` também não pôde ser executado neste teste porque o
interpretador usado pelo engine não possui `cv2` instalado.
## Decisão para o catálogo
As descrições acima devem ficar, por enquanto, como resultado de validação manual
do teste. O catálogo deve armazenar a etapa visual como `parcial`/`indisponível`,
preservando os avisos técnicos e os frames amostrados para reprocessamento. Não
devemos registrar como fato automático algo que o Vision não confirmou.
@@ -0,0 +1,59 @@
# Teste de transcrição — próximos quatro vídeos
## Configuração comum
- Provider: `ProviderDeTranscricaoLocal`
- Modelo: `faster-whisper-base`
- Idioma: `pt-BR`
- Dispositivo: CPU
- Cálculo: `int8`
- Modelo carregado uma única vez: `1,613 s`
- Memória máxima observada no processo: aproximadamente `805 MB`
## Desempenho
| Arquivo | Duração | Tempo de transcrição |
|---|---:|---:|
| `0E6A8871.MP4` | 5,797 s | 4,213 s |
| `0E6A8872.MP4` | 17,726 s | 1,377 s |
| `0E6A8873.MP4` | 23,315 s | 1,737 s |
| `0E6A8874.MP4` | 14,556 s | 1,300 s |
Tempo de transcrição dos quatro arquivos: `8,627 s`, além do carregamento inicial do modelo.
## Segmentos reconhecidos
### `0E6A8871.MP4`
- `[0,000–3,840]` Tende um pouquinho mais aqui, acho que pode dratar um pouquinho mais a pé, se eu
- `[3,840–5,580]` doutora do entratante, a noite, ouzo.
### `0E6A8872.MP4`
- `[0,000–3,360]` Então, por isso que eu vou fazer tanto, não se paguei nem com um pouquinho mais lá para trás.
- `[4,120–10,580]` E, espero, por biculares aqui, que é o musco orbicular da boca, que ele serve com o esfímpere, ele fecha sim, né?
- `[10,900–14,700]` Então, quando você faz o biquinho, fica três movinhas de um lado,
- `[15,020–17,680]` é uma, dois de cada lado, basicamente só que essas daqui são um pouco...
### `0E6A8873.MP4`
- `[0,000–4,700]` é que essa parte aqui em perior tem alguns que soem aqui, tem um pouquinho de
- `[4,700–8,960]` classidente de pés, né? Então isso, quando eu passei em dia, tem uma certa
- `[8,960–12,840]` classidade, a gente não bloqueia tanto justamente porque esse bloqueia
- `[12,840–15,940]` vai parecer que está mais classindo ainda, porque daí é um
- `[15,940–20,180]` muito bom explotido, isso vai ficar mais explotidamente. Então quando tem uma
- `[20,180–23,260]` sobre eu, quando você tem bolsa, daí a gente não faz aqui...
### `0E6A8874.MP4`
- `[0,000–2,740]` O profissão da pele, porque a gente está tratando músculo,
- `[2,860–5,880]` a gente está enfraquecendo músculos que estão mudando impressões na pele.
- `[6,280–8,900]` Mas quando a pele está muito fininha que é a parte da Pávera,
- `[9,020–12,700]` a gente tem que tratar a pele como um todo, da parte de dermatológica, entendeu?
- `[13,000–14,520]` Então, por isso que a gente não pode fazer...
## Avaliação
O desempenho é suficiente para um worker de baixa prioridade, especialmente porque o modelo fica carregado e é reutilizado. A qualidade do `base` é compreensível, mas há erros fonéticos relevantes em termos técnicos. Para o catálogo, esses segmentos ainda são úteis para busca aproximada; para texto final de edição, o próximo experimento deve comparar o mesmo conjunto com `faster-whisper-small`.
Cada segmento deverá ser salvo no SQLite com `ativo_id`, `inicio`, `fim`, `texto`, `modelo`, `provider`, `idioma`, `confianca` quando disponível e versão da etapa.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

@@ -0,0 +1,37 @@
# Análise do primeiro frame — corte `000f4763`
- Arquivo: `0E6A8290.MP4`
- Início do corte na timeline: `0.000s`
- Primeiro frame efetivo do corte na origem: `2.3023s`
- Resolução: `3840 × 2160`
- Frame extraído: `frame-000000.jpg`
## OpenCV
- Brilho médio: `114.71 / 255`
- Contraste (desvio padrão): `61.81`
- Nitidez (variância do Laplaciano): `9.73`
A métrica de nitidez é baixa para este frame; há aparência de foco suave e/ou
desfoque de movimento. Brilho e contraste estão em uma faixa utilizável, sem
indício de subexposição extrema no cálculo global.
## Observação visual
O frame mostra uma pessoa em plano médio, bem exposta, diante de fundo neutro
desfocado. A imagem foi registrada deitada no arquivo, apesar da sequência ser
vertical; a correção de orientação deve entrar como etapa própria antes de
qualquer análise semântica ou geração de preview.
## Providers indisponíveis neste host
- MediaPipe 1.0.1: abort nativo em `DrishtiMetalHelper` durante a criação do
`PoseLandmarker` no macOS 26.6.2. O adapter agora roda em subprocesso para
conter esse abort.
- Apple Vision: o host não conseguiu criar o plano de inferência do modelo
nativo (`ANECF error`) neste momento. O adapter retorna indisponibilidade sem
interromper o Scanner.
- ONNX: instalado, mas ainda não há um modelo ONNX e seu decodificador
configurados para detecção de pessoas/objetos.
- PySceneDetect: instalado, mas não se aplica a um único frame; ele precisa de
uma sequência de frames para identificar uma transição.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,35 @@
# Relatório visual — 0E6A8290.MP4
- Clipe: `000f4765`
- Origem: `/Volumes/Merongo/PROJETOS/03 - Mastopexia/0E6A8290.MP4`
- Timeline: 38.272s–42.409s
- Amostragem: a cada 1 segundo(s)
## Observações por frame
### Origem 41.808s
rosto, qualidade_do_rosto, pessoa, pose, mao, mao, categoria, categoria, estetica, horizonte, retangulo, contornos, segmentacao_de_pessoas, interpretacao_editorial
### Origem 42.808s
rosto, qualidade_do_rosto, pessoa, pose, mao, mao, categoria, categoria, estetica, horizonte, retangulo, contornos, segmentacao_de_pessoas, interpretacao_editorial
### Origem 43.808s
rosto, qualidade_do_rosto, pessoa, pose, mao, mao, categoria, categoria, estetica, horizonte, retangulo, contornos, segmentacao_de_pessoas, interpretacao_editorial
### Origem 44.808s
rosto, qualidade_do_rosto, pessoa, pose, mao, mao, categoria, categoria, estetica, horizonte, contornos, segmentacao_de_pessoas, interpretacao_editorial
### Origem 45.808s
rosto, qualidade_do_rosto, pessoa, pose, mao, mao, categoria, categoria, estetica, horizonte, retangulo, contornos, segmentacao_de_pessoas, interpretacao_editorial
## Continuidade
- similaridade_visual: 41.808s–42.808s — `{"possivel_mudanca_de_cena": false, "distancia_feature_print": 0.13040070235729218}`
- similaridade_visual: 42.808s–43.808s — `{"possivel_mudanca_de_cena": false, "distancia_feature_print": 0.1921818107366562}`
- similaridade_visual: 43.808s–44.808s — `{"possivel_mudanca_de_cena": false, "distancia_feature_print": 0.15198232233524323}`
- similaridade_visual: 44.808s–45.808s — `{"possivel_mudanca_de_cena": false, "distancia_feature_print": 0.20895899832248688}`
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 376 KiB

@@ -0,0 +1 @@
Aquela mama com um formato mais estruturado, que valoriza o seu colo, que dá aquele ar de elegância. Ah, isso é o desejo de muitas mulheres, né? Com o tempo, nosso corpo muda e nossas mamas também mudam. É a amamentação, perda de peso, efeito sanfona, tudo isso altera o formato e a sustentação das mamas. Então a mastopexia é uma cirurgia que reestrutura a mama e trata a flacidez, o excesso de pele e a queda das mamas. E associada a essa cirurgia, a gente faz a inserção de prótese de silicone, para dar aquele formato e volume mais proporcional para o seu tórax. E tudo isso associado a medida... Amor, eu tô intacta! Mas eu vou ter que falar tudo de novo? Tudo de novo? Aquela mama com um formato mais estruturado, com o colo que... Amor, é que eu... Isso, só clica aí agora, na tela. Aquela mama com um formato mais estruturado, que valoriza o seu colo, dá um ar de elegância. Ah, isso é desejo de muitas mulheres, né? Com o tempo, o corpo muda e as nossas mamas também mudam. É a amamentação, perda de peso, efeito sanfona, tudo isso modifica o formato e a sustentação das mamas. A mastopexia é a cirurgia que reestrutura a mama. Ela trata a flacidez de pele, o excesso de pele... Posso começar da mastopexia? A mastopexia é a cirurgia que reestrutura a sua mama. A mastopexia é a cirurgia que reestrutura a sua mama, tratando a flacidez, o excesso de pele e a queda das mamas. E associada a essa cirurgia, a gente insere próteses de silicone para dar um formato e um volume mais proporcional para o seu corpo. E associada a técnicas modernas, como sutiã interna e alça muscular, a gente consegue manter por mais tempo o formato e a sustentação das mamas. Vou falar só a última frase, solta aqui. Não pegou? Aumenta. E aí, continua? Então é devolver forma e sustentação de um jeito que pareça que sempre foi assim. Então é devolver forma e sustentação de um jeito que pareça que sempre foi assim.
@@ -0,0 +1,182 @@
[
{
"inicio": 2.23,
"fim": 8.11,
"texto": "Aquela mama com um formato mais estruturado, que valoriza o seu colo, que dá aquele ar de elegância.",
"confianca": null
},
{
"inicio": 8.67,
"fim": 10.79,
"texto": "Ah, isso é o desejo de muitas mulheres, né?",
"confianca": null
},
{
"inicio": 11.43,
"fim": 14.67,
"texto": "Com o tempo, nosso corpo muda e nossas mamas também mudam.",
"confianca": null
},
{
"inicio": 15.15,
"fim": 21.89,
"texto": "É a amamentação, perda de peso, efeito sanfona, tudo isso altera o formato e a sustentação das mamas.",
"confianca": null
},
{
"inicio": 22.41,
"fim": 30.43,
"texto": "Então a mastopexia é uma cirurgia que reestrutura a mama e trata a flacidez, o excesso de pele e a queda das mamas.",
"confianca": null
},
{
"inicio": 30.43,
"fim": 38.35,
"texto": "E associada a essa cirurgia, a gente faz a inserção de prótese de silicone, para dar aquele formato e volume mais proporcional para o seu tórax.",
"confianca": null
},
{
"inicio": 38.83,
"fim": 42.8,
"texto": "E tudo isso associado a medida...",
"confianca": null
},
{
"inicio": 42.8,
"fim": 47.43,
"texto": "Amor, eu tô intacta!",
"confianca": null
},
{
"inicio": 47.73,
"fim": 48.95,
"texto": "Mas eu vou ter que falar tudo de novo?",
"confianca": null
},
{
"inicio": 55.68,
"fim": 56.38,
"texto": "Tudo de novo?",
"confianca": null
},
{
"inicio": 64.98,
"fim": 69.38,
"texto": "Aquela mama com um formato mais estruturado, com o colo que...",
"confianca": null
},
{
"inicio": 70.38,
"fim": 71.64,
"texto": "Amor, é que eu...",
"confianca": null
},
{
"inicio": 71.64,
"fim": 73.78,
"texto": "Isso, só clica aí agora, na tela.",
"confianca": null
},
{
"inicio": 73.78,
"fim": 92.38,
"texto": "Aquela mama com um formato mais estruturado, que valoriza o seu colo, dá um ar de elegância.",
"confianca": null
},
{
"inicio": 92.96,
"fim": 94.88,
"texto": "Ah, isso é desejo de muitas mulheres, né?",
"confianca": null
},
{
"inicio": 95.48,
"fim": 98.74,
"texto": "Com o tempo, o corpo muda e as nossas mamas também mudam.",
"confianca": null
},
{
"inicio": 99.2,
"fim": 106.3,
"texto": "É a amamentação, perda de peso, efeito sanfona, tudo isso modifica o formato e a sustentação das mamas.",
"confianca": null
},
{
"inicio": 106.94,
"fim": 109.92,
"texto": "A mastopexia é a cirurgia que reestrutura a mama.",
"confianca": null
},
{
"inicio": 109.92,
"fim": 113.92,
"texto": "Ela trata a flacidez de pele, o excesso de pele...",
"confianca": null
},
{
"inicio": 113.92,
"fim": 115.82,
"texto": "Posso começar da mastopexia?",
"confianca": null
},
{
"inicio": 116.26,
"fim": 126.12,
"texto": "A mastopexia é a cirurgia que reestrutura a sua mama.",
"confianca": null
},
{
"inicio": 127.22,
"fim": 135.0,
"texto": "A mastopexia é a cirurgia que reestrutura a sua mama, tratando a flacidez, o excesso de pele e a queda das mamas.",
"confianca": null
},
{
"inicio": 135.32,
"fim": 141.8,
"texto": "E associada a essa cirurgia, a gente insere próteses de silicone para dar um formato e um volume mais proporcional para o seu corpo.",
"confianca": null
},
{
"inicio": 141.8,
"fim": 150.34,
"texto": "E associada a técnicas modernas, como sutiã interna e alça muscular, a gente consegue manter por mais tempo o formato e a sustentação das mamas.",
"confianca": null
},
{
"inicio": 151.14,
"fim": 153.0,
"texto": "Vou falar só a última frase, solta aqui.",
"confianca": null
},
{
"inicio": 153.82,
"fim": 154.26,
"texto": "Não pegou?",
"confianca": null
},
{
"inicio": 159.91,
"fim": 160.47,
"texto": "Aumenta.",
"confianca": null
},
{
"inicio": 180.75,
"fim": 181.35,
"texto": "E aí, continua?",
"confianca": null
},
{
"inicio": 184.14,
"fim": 188.7,
"texto": "Então é devolver forma e sustentação de um jeito que pareça que sempre foi assim.",
"confianca": null
},
{
"inicio": 189.9,
"fim": 194.64,
"texto": "Então é devolver forma e sustentação de um jeito que pareça que sempre foi assim.",
"confianca": null
}
]
@@ -0,0 +1 @@
Aquela mama com um formato mais estruturado, que valoriza o seu colo, que dá aquele ar de elegância. Ah, isso é o desejo de muitas mulheres, né? Com o tempo, nosso corpo muda e nossas mamas também mudam. É a amamentação, perda de peso, efeito sanfona, tudo isso altera o formato e a sustentação das mamas. Então a mastopexia é uma cirurgia que reestrutura a mama e trata a flacidez, o excesso de pele e a queda das mamas. E associada a essa cirurgia, a gente faz a inserção de prótese de silicone, para dar aquele formato e volume mais proporcional para o seu tórax. E tudo isso associado a medida... Amor, eu tô intacta! Mas eu vou ter que falar tudo de novo? Tudo de novo? Aquela mama com um formato mais estruturado, com o colo que... Amor, é que eu... Isso, só clica aí agora, na tela. Aquela mama com um formato mais estruturado, que valoriza o seu colo, dá um ar de elegância. Ah, isso é desejo de muitas mulheres, né? Com o tempo, o corpo muda e as nossas mamas também mudam. É a amamentação, perda de peso, efeito sanfona, tudo isso modifica o formato e a sustentação das mamas. A mastopexia é a cirurgia que reestrutura a mama. Ela trata a flacidez de pele, o excesso de pele... Posso começar da mastopexia? A mastopexia é a cirurgia que reestrutura a sua mama. A mastopexia é a cirurgia que reestrutura a sua mama, tratando a flacidez, o excesso de pele e a queda das mamas. E associada a essa cirurgia, a gente insere próteses de silicone para dar um formato e um volume mais proporcional para o seu corpo. E associada a técnicas modernas, como sutiã interna e alça muscular, a gente consegue manter por mais tempo o formato e a sustentação das mamas. Vou falar só a última frase, solta aqui. Não pegou? Aumenta. E aí, continua? Então é devolver forma e sustentação de um jeito que pareça que sempre foi assim. Então é devolver forma e sustentação de um jeito que pareça que sempre foi assim.
+554
View File
@@ -0,0 +1,554 @@
# Relatório de transcrição — VIdeo Laryssa — sem corte
Duração: 141.475 s
Clipes processados: 25
## 000f4763 — 0.000s–38.272s
Origem: 2.302s–40.574s
Aquela mama com um formato mais estruturado, que valoriza o seu colo, que dá aquele ar de elegância. Ah, isso é o desejo de muitas mulheres, né? Com o tempo, nosso corpo muda e nossas mamas também mudam. É a amamentação, perda de peso, efeito sanfona, tudo isso altera o formato e a sustentação das mamas. Então a mastopexia é uma cirurgia que reestrutura a mama e trata a flacidez, o excesso de pele e a queda das mamas. E associada a essa cirurgia, a gente faz a inserção de prótese de silicone, para dar aquele formato e volume mais proporcional para o seu tórax. E tudo isso associado a medida...
Emoções detectadas por segmento:
- [0.000s–5.808s] hap (0.58)
- [6.208s–8.448s] hap (0.97)
- [8.728s–12.348s] ang (0.96)
- [12.808s–19.588s] ang (0.79)
- [19.968s–28.128s] ang (0.76)
- [28.128s–36.028s] ang (0.67)
- [36.288s–38.272s] ang (0.81)
- [0.000s–0.448s] Aquela
- [0.448s–0.688s] mama
- [0.688s–0.908s] com
- [0.908s–1.088s] um
- [1.088s–1.488s] formato
- [1.488s–1.688s] mais
- [1.688s–2.488s] estruturado,
- [2.648s–2.668s] que
- [2.668s–3.328s] valoriza
- [3.328s–3.468s] o
- [3.468s–3.628s] seu
- [3.628s–4.088s] colo,
- [4.208s–4.208s] que
- [4.208s–4.308s] dá
- [4.308s–4.568s] aquele
- [4.568s–4.868s] ar
- [4.868s–5.008s] de
- [5.008s–5.808s] elegância.
- [6.208s–6.508s] Ah,
- [6.508s–6.708s] isso
- [6.708s–6.828s] é
- [6.828s–6.948s] o
- [6.948s–7.068s] desejo
- [7.068s–7.208s] de
- [7.208s–7.608s] muitas
- [7.608s–8.168s] mulheres,
- [8.368s–8.448s] né?
- [8.728s–9.288s] Com
- [9.288s–9.468s] o
- [9.468s–9.748s] tempo,
- [9.808s–10.028s] nosso
- [10.028s–10.368s] corpo
- [10.368s–10.788s] muda
- [10.788s–10.868s] e
- [10.868s–11.148s] nossas
- [11.148s–11.508s] mamas
- [11.508s–11.848s] também
- [11.848s–12.348s] mudam.
- [12.808s–12.948s] É
- [12.948s–13.048s] a
- [13.048s–13.708s] amamentação,
- [14.008s–14.288s] perda
- [14.288s–14.448s] de
- [14.448s–14.788s] peso,
- [15.048s–15.388s] efeito
- [15.388s–16.108s] sanfona,
- [16.388s–16.648s] tudo
- [16.648s–16.928s] isso
- [16.928s–17.468s] altera
- [17.468s–17.588s] o
- [17.588s–18.108s] formato
- [18.108s–18.208s] e
- [18.208s–18.268s] a
- [18.268s–18.928s] sustentação
- [18.928s–19.168s] das
- [19.168s–19.588s] mamas.
- [19.968s–20.308s] Então
- [20.308s–20.468s] a
- [20.468s–21.428s] mastopexia
- [21.428s–21.648s] é
- [21.648s–21.748s] uma
- [21.748s–22.228s] cirurgia
- [22.228s–22.408s] que
- [22.408s–23.248s] reestrutura
- [23.248s–23.408s] a
- [23.408s–23.628s] mama
- [23.628s–24.088s] e
- [24.088s–24.448s] trata
- [24.448s–24.648s] a
- [24.648s–25.208s] flacidez,
- [25.648s–25.708s] o
- [25.708s–26.168s] excesso
- [26.168s–26.288s] de
- [26.288s–26.628s] pele
- [26.628s–26.988s] e
- [26.988s–27.148s] a
- [27.148s–27.428s] queda
- [27.428s–27.688s] das
- [27.688s–28.128s] mamas.
- [28.128s–28.548s] E
- [28.548s–29.048s] associada
- [29.048s–29.168s] a
- [29.168s–29.228s] essa
- [29.228s–29.688s] cirurgia,
- [29.808s–29.808s] a
- [29.808s–29.928s] gente
- [29.928s–30.188s] faz
- [30.188s–30.288s] a
- [30.288s–30.848s] inserção
- [30.848s–31.028s] de
- [31.028s–31.408s] prótese
- [31.408s–31.568s] de
- [31.568s–32.028s] silicone,
- [32.268s–32.508s] para
- [32.508s–32.648s] dar
- [32.648s–32.888s] aquele
- [32.888s–33.548s] formato
- [33.548s–33.688s] e
- [33.688s–34.188s] volume
- [34.188s–34.448s] mais
- [34.448s–35.188s] proporcional
- [35.188s–35.408s] para
- [35.408s–35.408s] o
- [35.408s–35.568s] seu
- [35.568s–36.028s] tórax.
- [36.288s–36.608s] E
- [36.608s–36.788s] tudo
- [36.788s–36.988s] isso
- [36.988s–37.588s] associado
- [37.588s–37.648s] a
- [37.648s–38.272s] medida...
## 000f4765 — 38.272s–42.409s
Origem: 41.808s–45.946s
Amor, eu tô intacta!
Emoções detectadas por segmento:
- [39.063s–40.323s] ang (0.67)
- [39.063s–39.483s] Amor,
- [39.563s–39.663s] eu
- [39.663s–39.803s] tô
- [39.803s–40.323s] intacta!
## 000f4767 — 42.409s–44.344s
Origem: 47.547s–49.483s
Mas eu vou ter que falar tudo de novo?
Emoções detectadas por segmento:
- [42.492s–43.792s] hap (0.96)
- [42.492s–42.732s] Mas
- [42.732s–42.832s] eu
- [42.832s–42.892s] vou
- [42.892s–42.972s] ter
- [42.972s–43.032s] que
- [43.032s–43.172s] falar
- [43.172s–43.372s] tudo
- [43.372s–43.492s] de
- [43.492s–43.792s] novo?
## 000f4769 — 44.344s–46.346s
Origem: 51.418s–53.420s
_Sem fala detectada._
## 000f476b — 46.346s–47.614s
Origem: 55.622s–56.890s
Tudo de novo?
Emoções detectadas por segmento:
- [46.346s–47.104s] neu (0.37)
- [46.346s–46.644s] Tudo
- [46.644s–46.804s] de
- [46.804s–47.104s] novo?
## 000f476d — 47.614s–49.383s
Origem: 59.059s–60.827s
_Sem fala detectada._
## 000f476f — 49.383s–53.453s
Origem: 64.932s–69.002s
Aquela mama com um formato mais estruturado, com o colo que...
Emoções detectadas por segmento:
- [49.391s–53.453s] ang (0.96)
- [49.391s–49.871s] Aquela
- [49.871s–50.131s] mama
- [50.131s–50.371s] com
- [50.371s–50.531s] um
- [50.531s–50.951s] formato
- [50.951s–51.171s] mais
- [51.171s–52.151s] estruturado,
- [52.331s–52.571s] com
- [52.571s–52.751s] o
- [52.751s–53.031s] colo
- [53.031s–53.453s] que...
## 000f4771 — 53.453s–57.224s
Origem: 70.270s–74.041s
Amor, é que eu... Isso, só clica aí agora, na tela.
Emoções detectadas por segmento:
- [53.453s–54.563s] hap (0.94)
- [54.563s–56.883s] ang (0.80)
- [53.453s–53.823s] Amor,
- [53.943s–53.983s] é
- [53.983s–54.063s] que
- [54.063s–54.563s] eu...
- [54.563s–55.103s] Isso,
- [55.363s–55.963s] só
- [55.963s–56.243s] clica
- [56.243s–56.383s] aí
- [56.383s–56.583s] agora,
- [56.743s–56.803s] na
- [56.803s–56.883s] tela.
## 000f4773 — 57.224s–58.091s
Origem: 81.448s–82.316s
_Sem fala detectada._
## 000f4775 — 58.091s–85.953s
Origem: 86.186s–114.047s
Aquela mama com um formato mais estruturado, que valoriza o seu colo, dá um ar de elegância. Ah, isso é desejo de muitas mulheres, né? Com o tempo, o corpo muda e as nossas mamas também mudam. É a amamentação, perda de peso, efeito sanfona, tudo isso modifica o formato e a sustentação das mamas. A mastopexia é a cirurgia que reestrutura a mama. Ela trata a flacidez de pele, o excesso de pele...
Emoções detectadas por segmento:
- [58.325s–64.285s] hap (0.49)
- [64.605s–66.745s] hap (0.67)
- [67.205s–70.605s] ang (0.98)
- [71.045s–78.185s] ang (0.92)
- [78.725s–81.805s] ang (0.97)
- [81.805s–85.953s] ang (0.88)
- [58.325s–58.825s] Aquela
- [58.825s–59.045s] mama
- [59.045s–59.285s] com
- [59.285s–59.485s] um
- [59.485s–59.945s] formato
- [59.945s–60.145s] mais
- [60.145s–61.005s] estruturado,
- [61.165s–61.225s] que
- [61.225s–61.845s] valoriza
- [61.845s–61.985s] o
- [61.985s–62.125s] seu
- [62.125s–62.725s] colo,
- [62.805s–62.885s] dá
- [62.885s–63.045s] um
- [63.045s–63.325s] ar
- [63.325s–63.485s] de
- [63.485s–64.285s] elegância.
- [64.605s–64.985s] Ah,
- [64.985s–65.145s] isso
- [65.145s–65.225s] é
- [65.225s–65.525s] desejo
- [65.525s–65.665s] de
- [65.665s–65.965s] muitas
- [65.965s–66.485s] mulheres,
- [66.645s–66.745s] né?
- [67.205s–67.585s] Com
- [67.585s–67.745s] o
- [67.745s–68.005s] tempo,
- [68.125s–68.225s] o
- [68.225s–68.525s] corpo
- [68.525s–69.005s] muda
- [69.005s–69.145s] e
- [69.145s–69.265s] as
- [69.265s–69.525s] nossas
- [69.525s–69.765s] mamas
- [69.765s–70.105s] também
- [70.105s–70.605s] mudam.
- [71.045s–71.185s] É
- [71.185s–71.265s] a
- [71.265s–71.945s] amamentação,
- [72.245s–72.605s] perda
- [72.605s–72.765s] de
- [72.765s–73.145s] peso,
- [73.425s–73.805s] efeito
- [73.805s–74.505s] sanfona,
- [74.725s–75.105s] tudo
- [75.105s–75.405s] isso
- [75.405s–76.025s] modifica
- [76.025s–76.185s] o
- [76.185s–76.685s] formato
- [76.685s–76.785s] e
- [76.785s–76.865s] a
- [76.865s–77.525s] sustentação
- [77.525s–77.765s] das
- [77.765s–78.185s] mamas.
- [78.725s–78.945s] A
- [78.945s–79.765s] mastopexia
- [79.765s–79.905s] é
- [79.905s–79.925s] a
- [79.925s–80.305s] cirurgia
- [80.305s–80.465s] que
- [80.465s–81.385s] reestrutura
- [81.385s–81.565s] a
- [81.565s–81.805s] mama.
- [81.805s–82.285s] Ela
- [82.285s–82.625s] trata
- [82.625s–82.805s] a
- [82.805s–83.285s] flacidez
- [83.285s–83.485s] de
- [83.485s–83.905s] pele,
- [84.225s–84.445s] o
- [84.445s–85.025s] excesso
- [85.025s–85.125s] de
- [85.125s–85.953s] pele...
## 000f4777 — 85.953s–87.487s
Origem: 114.615s–116.149s
Posso começar da mastopexia?
Emoções detectadas por segmento:
- [85.953s–87.138s] neu (0.90)
- [85.953s–86.218s] Posso
- [86.218s–86.438s] começar
- [86.438s–86.578s] da
- [86.578s–87.138s] mastopexia?
## 000f4779 — 87.487s–91.158s
Origem: 116.650s–120.320s
_Sem fala detectada._
## 000f477b — 91.158s–94.761s
Origem: 122.856s–126.460s
A mastopexia é a cirurgia que reestrutura a sua mama.
Emoções detectadas por segmento:
- [91.202s–94.402s] ang (0.83)
- [91.202s–91.682s] A
- [91.682s–92.162s] mastopexia
- [92.162s–92.302s] é
- [92.302s–92.382s] a
- [92.382s–92.782s] cirurgia
- [92.782s–92.942s] que
- [92.942s–93.782s] reestrutura
- [93.782s–93.962s] a
- [93.962s–94.042s] sua
- [94.042s–94.402s] mama.
## 000f477d — 94.761s–117.851s
Origem: 127.561s–150.650s
A mastopexia é a cirurgia que reestrutura a sua mama, tratando a flacidez, o excesso de pele e a queda das mamas. E associada a essa cirurgia, a gente insere próteses de silicone para dar um formato e um volume mais proporcional para o seu corpo. E associada a técnicas modernas, como sutiã interna e alça muscular, a gente consegue manter por mais tempo o formato e a sustentação das mamas.
Emoções detectadas por segmento:
- [94.861s–102.181s] ang (0.92)
- [102.361s–109.001s] ang (0.91)
- [109.001s–117.521s] ang (0.78)
- [94.861s–95.341s] A
- [95.341s–95.821s] mastopexia
- [95.821s–96.021s] é
- [96.021s–96.021s] a
- [96.021s–96.361s] cirurgia
- [96.361s–96.541s] que
- [96.541s–97.381s] reestrutura
- [97.381s–97.541s] a
- [97.541s–97.621s] sua
- [97.621s–97.961s] mama,
- [98.181s–98.721s] tratando
- [98.721s–98.821s] a
- [98.821s–99.401s] flacidez,
- [99.741s–99.901s] o
- [99.901s–100.341s] excesso
- [100.341s–100.441s] de
- [100.441s–100.821s] pele
- [100.821s–101.121s] e
- [101.121s–101.241s] a
- [101.241s–101.541s] queda
- [101.541s–101.781s] das
- [101.781s–102.181s] mamas.
- [102.361s–102.581s] E
- [102.581s–103.221s] associada
- [103.221s–103.341s] a
- [103.341s–103.421s] essa
- [103.421s–103.961s] cirurgia,
- [104.081s–104.101s] a
- [104.101s–104.241s] gente
- [104.241s–104.681s] insere
- [104.681s–105.221s] próteses
- [105.221s–105.321s] de
- [105.321s–105.741s] silicone
- [105.741s–106.021s] para
- [106.021s–106.161s] dar
- [106.161s–106.301s] um
- [106.301s–106.841s] formato
- [106.841s–106.941s] e
- [106.941s–107.041s] um
- [107.041s–107.441s] volume
- [107.441s–107.701s] mais
- [107.701s–108.261s] proporcional
- [108.261s–108.441s] para
- [108.441s–108.481s] o
- [108.481s–108.621s] seu
- [108.621s–109.001s] corpo.
- [109.001s–109.621s] E
- [109.621s–110.081s] associada
- [110.081s–110.241s] a
- [110.241s–110.561s] técnicas
- [110.561s–111.201s] modernas,
- [111.321s–111.381s] como
- [111.381s–111.821s] sutiã
- [111.821s–112.281s] interna
- [112.281s–112.361s] e
- [112.361s–112.701s] alça
- [112.701s–113.121s] muscular,
- [113.601s–113.661s] a
- [113.661s–113.781s] gente
- [113.781s–114.161s] consegue
- [114.161s–114.641s] manter
- [114.641s–114.881s] por
- [114.881s–115.161s] mais
- [115.161s–115.521s] tempo
- [115.521s–115.661s] o
- [115.661s–116.121s] formato
- [116.121s–116.221s] e
- [116.221s–116.321s] a
- [116.321s–116.861s] sustentação
- [116.861s–117.061s] das
- [117.061s–117.521s] mamas.
## 000f477f — 117.851s–120.287s
Origem: 150.951s–153.387s
Vou falar só a última frase, solta aqui.
Emoções detectadas por segmento:
- [117.851s–119.860s] neu (0.98)
- [117.851s–118.180s] Vou
- [118.180s–118.380s] falar
- [118.380s–118.520s] só
- [118.520s–118.600s] a
- [118.600s–118.760s] última
- [118.760s–119.240s] frase,
- [119.380s–119.640s] solta
- [119.640s–119.860s] aqui.
## 000f4781 — 120.287s–121.221s
Origem: 153.720s–154.655s
Não pegou?
Emoções detectadas por segmento:
- [120.287s–120.827s] neu (0.64)
- [120.287s–120.487s] Não
- [120.487s–120.827s] pegou?
## 000f4783 — 121.221s–123.690s
Origem: 156.356s–158.825s
_Sem fala detectada._
## 000f4785 — 123.690s–126.360s
Origem: 159.726s–162.396s
Aumenta.
Emoções detectadas por segmento:
- [123.814s–124.394s] hap (0.69)
- [123.814s–124.394s] Aumenta.
## 000f4787 — 126.360s–127.794s
Origem: 172.305s–173.740s
_Sem fala detectada._
## 000f4789 — 127.794s–129.429s
Origem: 174.741s–176.376s
_Sem fala detectada._
## 000f478b — 129.429s–131.164s
Origem: 180.547s–182.282s
E aí, continua?
Emoções detectadas por segmento:
- [129.429s–130.212s] hap (0.75)
- [129.429s–129.712s] E
- [129.712s–129.832s] aí,
- [129.912s–130.212s] continua?
## 000f478d — 131.164s–136.403s
Origem: 183.784s–189.022s
Então é devolver forma e sustentação de um jeito que pareça que sempre foi assim.
Emoções detectadas por segmento:
- [131.481s–136.021s] hap (0.56)
- [131.481s–132.061s] Então
- [132.061s–132.201s] é
- [132.201s–132.621s] devolver
- [132.621s–133.061s] forma
- [133.061s–133.181s] e
- [133.181s–133.881s] sustentação
- [133.881s–134.381s] de
- [134.381s–134.441s] um
- [134.441s–134.621s] jeito
- [134.621s–134.761s] que
- [134.761s–135.081s] pareça
- [135.081s–135.221s] que
- [135.221s–135.501s] sempre
- [135.501s–135.741s] foi
- [135.741s–136.021s] assim.
## 000f478f — 136.403s–141.274s
Origem: 190.123s–194.995s
Então é devolver forma e sustentação de um jeito que pareça que sempre foi assim.
Emoções detectadas por segmento:
- [136.403s–140.900s] ang (0.77)
- [136.403s–136.720s] Então
- [136.720s–136.840s] é
- [136.840s–137.360s] devolver
- [137.360s–137.860s] forma
- [137.860s–137.940s] e
- [137.940s–138.680s] sustentação
- [138.680s–138.880s] de
- [138.880s–138.980s] um
- [138.980s–139.220s] jeito
- [139.220s–139.420s] que
- [139.420s–139.860s] pareça
- [139.860s–140.020s] que
- [140.020s–140.340s] sempre
- [140.340s–140.580s] foi
- [140.580s–140.900s] assim.
## 000f4791 — 141.274s–141.475s
Origem: 196.463s–196.663s
_Sem fala detectada._
+8 -1
View File
@@ -137,7 +137,14 @@ export function getDiscoveryTools(bridgeOptions: BridgeOptions) {
end: __ticksToSeconds(clip.end.ticks),
inPoint: __ticksToSeconds(clip.inPoint.ticks),
outPoint: __ticksToSeconds(clip.outPoint.ticks),
duration: __ticksToSeconds(clip.duration.ticks)
duration: __ticksToSeconds(clip.duration.ticks),
sourceFile: clip.projectItem && clip.projectItem.getMediaPath
? String(clip.projectItem.getMediaPath() || "")
: "",
sourceProjectItemId: clip.projectItem ? String(clip.projectItem.nodeId || "") : "",
sourceOffline: clip.projectItem && clip.projectItem.isOffline
? !!clip.projectItem.isOffline()
: false
});
}
videoTracks.push({