feat: destacado no deploy o resumo que será gravado na mensagem do

- destacado no deploy o resumo que será gravado na mensagem do commit

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

 7 files changed, 143 insertions(+), 11 deletions(-)

Arquivos:
  - .jhonny/analises.db
  - admin/deploy.command
  - code/engine/executar_scanner.py
  - code/engine/integracoes/midia/__init__.py
  - code/engine/integracoes/visual/apple_vision.py
  - code/engine/scanner/configuracao_visual.py
  - code/engine/scanner/visual.py
  - code/engine/integracoes/midia/compactador_de_proxy.py
This commit is contained in:
João Henrique
2026-09-08 18:40:52 -04:00
parent 5cc50f00a4
commit 400c3c93dd
8 changed files with 313 additions and 11 deletions
+5 -1
View File
@@ -1,4 +1,8 @@
from .compactador_de_proxy import (CompactadorDeProxy, ExtratorDeQuadrosDoProxy,
RESOLUCAO_PADRAO_DO_PROXY)
from .extracao_de_audio import ExtracaoDeAudio, ParteDeAudio
from .extracao_de_metadados import ExtracaoDeMetadados, MetadadosDoArquivo
__all__ = ["ExtracaoDeAudio", "ExtracaoDeMetadados", "MetadadosDoArquivo", "ParteDeAudio"]
__all__ = ["CompactadorDeProxy", "ExtracaoDeAudio", "ExtracaoDeMetadados",
"ExtratorDeQuadrosDoProxy", "MetadadosDoArquivo", "ParteDeAudio",
"RESOLUCAO_PADRAO_DO_PROXY"]
@@ -0,0 +1,170 @@
"""Transcodifica vídeos de origem para um proxy leve usado na leitura visual e de cenas.
O gargalo de ler frames direto dos arquivos de origem (ex.: MOV com vários GB)
está em buscar com ``-ss`` em arquivos pesados e não indexados. Este módulo
produz, uma única vez por arquivo e por resolução, uma cópia leve (proxy) em
H.264 com keyframes frequentes e ``+faststart``, sem áudio. Toda a análise
visual e detecção de cena passa a ler desse proxy, que é pequeno, tem seek
rápido e leitura sequencial barata — enquanto o arquivo original fica intocado
para áudio/transcrição.
"""
from __future__ import annotations
import hashlib
from pathlib import Path
import subprocess
from ...dominio.entidades.modelos import Clipe, IntervaloDeTempo
from ..visual.contratos import ExtratorDeQuadros
# Resolução padrão do proxy: Full HD (1080) — mantém a qualidade de leitura
# visual (faces/OCR/pose) num arquivo bem mais leve e rápido de processar do
# que o original em 4K. O formato H.264/MP4 +faststart com keyframes frequentes
# é o que torna o seek barato.
RESOLUCAO_PADRAO_DO_PROXY = 1080
# Prazo máximo para transcodificar um único arquivo (protetor contra falhas).
LIMITE_DE_TEMPO_DA_TRANSCODIFICACAO = 1800 # 30 min
class CompactadorDeProxy:
"""Transcodifica arquivos de vídeo para um proxy leve e o reaproveita."""
def __init__(self, ffmpeg: str = "ffmpeg", ffprobe: str = "ffprobe",
diretorio_de_cache: str | Path | None = None,
largura_maxima: int = RESOLUCAO_PADRAO_DO_PROXY) -> None:
self.ffmpeg = ffmpeg
self.ffprobe = ffprobe
self.largura_maxima = int(largura_maxima)
self.diretorio_de_cache = (
Path(diretorio_de_cache) if diretorio_de_cache
else Path.home() / ".cache" / "premiere-mcp" / "proxies"
)
self.diretorio_de_cache.mkdir(parents=True, exist_ok=True)
def obter(self, arquivo: str | Path,
largura_maxima: int | None = None) -> Path:
"""Devolve o caminho do proxy do arquivo, gerando-o quando necessário.
O proxy é idempotente: se ele já existir e o arquivo de origem não tiver
mudado desde a última geração, devolve o caminho sem re-transcodificar.
"""
largura = int(largura_maxima or self.largura_maxima)
origem = Path(arquivo).expanduser().resolve(strict=False)
if not origem.is_file():
raise FileNotFoundError(f"Arquivo de origem não encontrado: {origem}")
proxy = self._caminho_do_proxy(origem, largura)
if proxy.is_file() and self._proxies_sao_validos(origem, proxy):
return proxy
self._transcodificar(origem, proxy, largura)
return proxy
def obter_por_clipe(self, clipe,
largura_maxima: int | None = None) -> Path | None:
"""Devolve o proxy do arquivo do clipe, ou None quando não houver arquivo.
Se o clipe for offline ou não tiver arquivo, devolve None (sem proxy).
"""
if not getattr(clipe, "arquivo", None) or getattr(clipe, "offline", False):
return None
try:
return self.obter(clipe.arquivo, largura_maxima=largura_maxima)
except (FileNotFoundError, RuntimeError):
return None
# ------------------------------------------------------------------ internos
def _caminho_do_proxy(self, origem: Path, largura_maxima: int) -> Path:
"""Monta o nome estável do proxy a partir do caminho e da largura."""
resumo = hashlib.sha1(str(origem).encode("utf-8")).hexdigest()[:12]
return self.diretorio_de_cache / f"{origem.stem}-{resumo}-{largura_maxima}p.mp4"
@staticmethod
def _alteracao_do_arquivo(caminho: Path) -> float:
"""Última alteração do arquivo, útil para detectar que a origem mudou."""
try:
return caminho.stat().st_mtime_ns
except OSError:
return 0.0
def _proxies_sao_validos(self, origem: Path, proxy: Path) -> bool:
"""O proxy é válido só se nasceu depois do arquivo de origem mudar."""
return proxy.stat().st_mtime_ns >= self._alteracao_do_arquivo(origem)
def _transcodificar(self, origem: Path, proxy: Path, largura_maxima: int) -> None:
"""Transcodifica o arquivo de origem para o proxy H.264 de leitura visual."""
comando = [
self.ffmpeg, "-y", "-hide_banner", "-loglevel", "error",
"-i", str(origem),
"-map", "0:v:0",
"-vf", (
f"scale=w={largura_maxima}:h={largura_maxima}:"
"force_original_aspect_ratio=decrease"
),
"-c:v", "libx264", "-preset", "veryfast", "-crf", "28",
"-g", "30", "-keyint_min", "30", "-sc_threshold", "0",
"-pix_fmt", "yuv420p",
"-an",
"-movflags", "+faststart",
str(proxy),
]
try:
processo = subprocess.run(
comando, capture_output=True, text=True,
timeout=LIMITE_DE_TEMPO_DA_TRANSCODIFICACAO,
)
except subprocess.TimeoutExpired as exc:
raise RuntimeError(
f"Transcodificação do proxy excedeu o tempo: {origem}"
) from exc
if processo.returncode != 0 or not proxy.is_file():
detalhe = (processo.stderr or processo.stdout or "").strip()
raise RuntimeError(
f"Não foi possível gerar o proxy de {origem}: {detalhe}"
)
def _clipe_com_arquivo(clipe: Clipe, caminho: Path) -> Clipe:
"""Devolve uma cópia do clipe com o ``arquivo`` substituído pelo caminho dado.
Mantém a identidade, os intervalos e os metadados; só troca o arquivo lido.
"""
origem = clipe.intervalo_na_origem
return Clipe(
identificador=clipe.identificador,
nome=clipe.nome,
intervalo_na_timeline=clipe.intervalo_na_timeline,
intervalo_na_origem=IntervaloDeTempo(origem.inicio, origem.fim) if origem else None,
arquivo=str(caminho),
identificador_da_faixa=clipe.identificador_da_faixa,
offline=clipe.offline,
metadados=clipe.metadados,
)
class ExtratorDeQuadrosDoProxy:
"""Extrai frames de um clipe a partir do seu proxy, sem duplicar lógica.
Delega todo o trabalho ao ``ExtratorDeQuadrosFFmpeg`` informado, apontando-o
para o proxy. Os timestamps continuam relativos ao tempo do arquivo original,
pois o proxy preserva a mesma cronologia.
"""
def __init__(self, extrator: ExtratorDeQuadros, compactador: CompactadorDeProxy,
largura_maxima: int | None = None) -> None:
self.extrator = extrator
self.compactador = compactador
self.largura_maxima = largura_maxima
def extrair(self, clipe: Clipe) -> list:
"""Extrai frames usando o proxy; usa o arquivo original quando não há proxy."""
proxy = (
self.compactador.obter_por_clipe(clipe)
if self.largura_maxima is None
else self.compactador.obter_por_clipe(clipe, largura_maxima=self.largura_maxima)
)
alvo = clipe if proxy is None else _clipe_com_arquivo(clipe, proxy)
return self.extrator.extrair(alvo)
+32 -3
View File
@@ -143,17 +143,25 @@ class AnalisadorAppleVisionNativo(AnalisadorDeFrame):
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:
timeout_em_segundos: float = 90.0,
maximo_de_frames_interpretados: int | None = None) -> 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
# Limita quantos frames por clipe fazem a interpretação editorial (Apple
# Intelligence / Foundation Models), que é a parte mais cara. Os demais
# frames continuam com a análise visual completa. None = interpreta todos.
self.maximo_de_frames_interpretados = maximo_de_frames_interpretados
def analisar(self, quadro: QuadroDeVideo) -> list[EvidenciaVisual]:
def analisar(self, quadro: QuadroDeVideo, interpretar: bool | None = None) -> list[EvidenciaVisual]:
if quadro.caminho is None:
raise ValueError("Apple Vision requer que o frame tenha caminho persistido em disco.")
if self._indices_para_interpretar and quadro.indice not in self._indices_para_interpretar:
interpretar = False
deve_interpretar = interpretar if interpretar is not None else self.interpretar_com_apple_intelligence
dados = self.executor.executar({"frame": str(quadro.caminho), "recursos": list(self.recursos),
"resumir": self.interpretar_com_apple_intelligence},
"resumir": deve_interpretar},
self.timeout_em_segundos)
evidencias = [self._converter(item, quadro) for item in dados.get("evidencias", [])]
avisos = dados.get("avisos", [])
@@ -162,6 +170,27 @@ class AnalisadorAppleVisionNativo(AnalisadorDeFrame):
{"avisos": avisos, "recursos_solicitados": list(self.recursos)}, provider=self.nome))
return evidencias
def preparar_interpretacao_por_clipe(self, quantidade_de_frames: int) -> None:
"""Marca os índices de frames que receberão a interpretação editorial.
Distribui a interpretação ao longo do clipe (início, pontos médios e fim)
em vez de aplicar a todos os frames, mantendo a cobertura de cena com
custo muito menor. Quando não há limite (None), interpreta todos.
"""
if self.maximo_de_frames_interpretados is None or quantidade_de_frames <= 0:
self._indices_para_interpretar = set(range(quantidade_de_frames))
return
quantidade = min(quantidade_de_frames, max(1, self.maximo_de_frames_interpretados))
indices = set()
if quantidade_de_frames == 1:
indices.add(0)
else:
passo = max(1, quantidade_de_frames // quantidade)
indices = set(range(0, quantidade_de_frames, passo)) | {quantidade_de_frames - 1}
self._indices_para_interpretar = {i for i in indices if i < quantidade_de_frames}
_indices_para_interpretar: set[int] = set()
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,