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
Binary file not shown.
+8 -4
View File
@@ -27,6 +27,7 @@ BRANCH="main"
GRN="$(tput setaf 2 2>/dev/null)"; YLW="$(tput setaf 3 2>/dev/null)" GRN="$(tput setaf 2 2>/dev/null)"; YLW="$(tput setaf 3 2>/dev/null)"
RD="$(tput setaf 1 2>/dev/null)"; BLU="$(tput setaf 4 2>/dev/null)" RD="$(tput setaf 1 2>/dev/null)"; BLU="$(tput setaf 4 2>/dev/null)"
BD="$(tput bold 2>/dev/null)"
NC="$(tput sgr0 2>/dev/null)" NC="$(tput sgr0 2>/dev/null)"
say() { echo "${BLU}== ${NC}$*"; } say() { echo "${BLU}== ${NC}$*"; }
ok() { echo "${GRN}-> OK ${NC}$*"; } ok() { echo "${GRN}-> OK ${NC}$*"; }
@@ -99,7 +100,8 @@ else
BODY="(sem nota em DEV-NOTES.md)" BODY="(sem nota em DEV-NOTES.md)"
fi fi
COMMIT_MSG="feat: $(echo "$BODY" | head -1 | cut -c1-50) ASSUNTO="$(echo "$BODY" | head -1 | sed 's/^[[:space:]]*[-*][[:space:]]*//' | cut -c1-60)"
COMMIT_MSG="feat: $ASSUNTO
$BODY $BODY
@@ -114,10 +116,12 @@ $STAT
Arquivos: Arquivos:
$FILES" $FILES"
say "Mensagem do commit:" echo
echo "----------------------------------------------" echo "${BD}${YLW}============================================================${NC}"
echo "${BD}${YLW} MENSAGEM QUE SERÁ GRAVADA NO COMMIT${NC}"
echo "${BD}${YLW}============================================================${NC}"
echo "$COMMIT_MSG" echo "$COMMIT_MSG"
echo "----------------------------------------------" echo "${BD}${YLW}============================================================${NC}"
echo echo
if [[ "${DRY:-0}" = "1" ]]; then if [[ "${DRY:-0}" = "1" ]]; then
+42
View File
@@ -15,13 +15,41 @@ CAMINHO_DO_CODIGO = Path(__file__).resolve().parent.parent
if str(CAMINHO_DO_CODIGO) not in sys.path: if str(CAMINHO_DO_CODIGO) not in sys.path:
sys.path.insert(0, str(CAMINHO_DO_CODIGO)) sys.path.insert(0, str(CAMINHO_DO_CODIGO))
def _encontrar_caminho_da_midia() -> str:
"""Localiza a pasta com ``ffmpeg``/``ffprobe`` para o PATH dos subprocessos.
O Premiere é aberto pelo Finder/LaunchServices (sem shell de login), então o
PATH herdado pelo painel não costuma trazer ``/opt/homebrew/bin``. Essa
função devolve o primeiro caminho conhecido onde essas ferramentas existem,
ou string vazia quando nenhum é encontrado.
"""
for candidato in ("/opt/homebrew/bin", "/usr/local/bin"):
if Path(candidato, "ffprobe").is_file() or Path(candidato, "ffmpeg").is_file():
return candidato
return ""
# O mesmo ajuste de PATH já usado em ``admin/deploy.command`` e
# ``admin/install.command``: sem ele, a extração de frames e a transcrição
# falham com ``No such file or directory: 'ffprobe'``.
CAMINHO_DA_MIDIA = _encontrar_caminho_da_midia()
if CAMINHO_DA_MIDIA:
os.environ["PATH"] = CAMINHO_DA_MIDIA + os.pathsep + os.environ.get("PATH", "")
from engine.integracoes.premiere.conversores import ConversorDeTimeline from engine.integracoes.premiere.conversores import ConversorDeTimeline
from engine.scanner import ConfiguracaoVisualDoScanner, criar_detector_apple_vision, gerar_relatorio_visual from engine.scanner import ConfiguracaoVisualDoScanner, criar_detector_apple_vision, gerar_relatorio_visual
from engine.scanner.transcricao_da_timeline import TranscricaoDaTimeline from engine.scanner.transcricao_da_timeline import TranscricaoDaTimeline
from engine.persistencia import RepositorioDeAnalisesSQLite, RepositorioDeTimelineSQLite
MODELO_DIARIZACAO_PADRAO = "pyannote/speaker-diarization-community-1" MODELO_DIARIZACAO_PADRAO = "pyannote/speaker-diarization-community-1"
# O padrão do banco de análises é ``.jhonny/analises.db`` relativo ao cwd, mas o
# painel roda o scanner com cwd ``code``. Fixamos o caminho absoluto na raiz do
# projeto para garantir que a gravação sempre caia no mesmo banco.
CAMINHO_DO_BANCO = CAMINHO_DO_CODIGO.parent / ".jhonny" / "analises.db"
def nome_seguro(nome: str) -> str: def nome_seguro(nome: str) -> str:
return re.sub(r"[^A-Za-z0-9._-]+", "-", nome.strip()).strip(".-") or "timeline" return re.sub(r"[^A-Za-z0-9._-]+", "-", nome.strip()).strip(".-") or "timeline"
@@ -41,6 +69,13 @@ def executar(entrada: Path, saida: Path) -> Path:
print(json.dumps({"evento": "progresso", "etapa": etapa, "percentual": round(percentual, 1), print(json.dumps({"evento": "progresso", "etapa": etapa, "percentual": round(percentual, 1),
"clipe": clipe}, ensure_ascii=False), flush=True) "clipe": clipe}, ensure_ascii=False), flush=True)
# Persistência: timeline/estrutura + análise visual + transcrição vão para o
# banco SQLite do projeto (mesmo dos relatórios gerados).
repositorio_de_timeline = RepositorioDeTimelineSQLite(CAMINHO_DO_BANCO)
repositorio_de_analises = RepositorioDeAnalisesSQLite(CAMINHO_DO_BANCO)
emitir("Persistindo timeline", 0)
repositorio_de_timeline.registrar_timeline(timeline)
resultados = [] resultados = []
for clipe in clipes: for clipe in clipes:
peso = max(1, math.ceil((clipe.intervalo_na_origem or clipe.intervalo_na_timeline).duracao / peso = max(1, math.ceil((clipe.intervalo_na_origem or clipe.intervalo_na_timeline).duracao /
@@ -60,6 +95,11 @@ def executar(entrada: Path, saida: Path) -> Path:
arquivo = Path(tempfile.gettempdir()) / f"scanner-{clipe.identificador}.json" arquivo = Path(tempfile.gettempdir()) / f"scanner-{clipe.identificador}.json"
gerar_relatorio_visual(clipe, resultado, arquivo, configuracao.intervalo_em_segundos) gerar_relatorio_visual(clipe, resultado, arquivo, configuracao.intervalo_em_segundos)
resultados.append(json.loads(arquivo.read_text(encoding="utf-8"))) resultados.append(json.loads(arquivo.read_text(encoding="utf-8")))
# Grava no banco os fatos visuais e as cenas detectadas deste clipe.
repositorio_de_analises.registrar_evidencias_visuais(
timeline.identificador, clipe.identificador, resultado.evidencias)
repositorio_de_analises.registrar_cenas(
timeline.identificador, resultado.cenas, clipe.identificador)
except Exception as erro: except Exception as erro:
resultados.append({"clipe": {"identificador": clipe.identificador, "nome": clipe.nome, resultados.append({"clipe": {"identificador": clipe.identificador, "nome": clipe.nome,
"arquivo_original": clipe.arquivo}, "erro": str(erro)}) "arquivo_original": clipe.arquivo}, "erro": str(erro)})
@@ -111,6 +151,8 @@ def executar(entrada: Path, saida: Path) -> Path:
transcricoes = TranscricaoDaTimeline(provider, diretoria_de_trabalho=pasta / ".cache-audio", transcricoes = TranscricaoDaTimeline(provider, diretoria_de_trabalho=pasta / ".cache-audio",
diarizador=diarizador).executar( diarizador=diarizador).executar(
Timeline(timeline.identificador, timeline.nome, faixas=faixas_de_audio)) Timeline(timeline.identificador, timeline.nome, faixas=faixas_de_audio))
# Persiste a transcrição (segmentos + falas) no banco de análises.
repositorio_de_analises.registrar_transcricoes(timeline.identificador, transcricoes)
for transcricao in transcricoes: for transcricao in transcricoes:
transcricoes_por_clipe[transcricao.identificador_do_clipe] = [ transcricoes_por_clipe[transcricao.identificador_do_clipe] = [
{"inicio": item.inicio, "fim": item.fim, "texto": item.texto, {"inicio": item.inicio, "fim": item.fim, "texto": item.texto,
+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_audio import ExtracaoDeAudio, ParteDeAudio
from .extracao_de_metadados import ExtracaoDeMetadados, MetadadosDoArquivo 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, def __init__(self, recursos: tuple[str, ...] = RECURSOS_PADRAO,
interpretar_com_apple_intelligence: bool = True, interpretar_com_apple_intelligence: bool = True,
executor: ExecutorDoRunnerApple | None = None, 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.recursos = recursos
self.interpretar_com_apple_intelligence = interpretar_com_apple_intelligence self.interpretar_com_apple_intelligence = interpretar_com_apple_intelligence
self.executor = executor or ExecutorDoRunnerApple() self.executor = executor or ExecutorDoRunnerApple()
self.timeout_em_segundos = timeout_em_segundos 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: if quadro.caminho is None:
raise ValueError("Apple Vision requer que o frame tenha caminho persistido em disco.") 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), 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) self.timeout_em_segundos)
evidencias = [self._converter(item, quadro) for item in dados.get("evidencias", [])] evidencias = [self._converter(item, quadro) for item in dados.get("evidencias", [])]
avisos = dados.get("avisos", []) avisos = dados.get("avisos", [])
@@ -162,6 +170,27 @@ class AnalisadorAppleVisionNativo(AnalisadorDeFrame):
{"avisos": avisos, "recursos_solicitados": list(self.recursos)}, provider=self.nome)) {"avisos": avisos, "recursos_solicitados": list(self.recursos)}, provider=self.nome))
return evidencias 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: def _converter(self, item: dict[str, Any], quadro: QuadroDeVideo) -> EvidenciaVisual:
return EvidenciaVisual(item["tipo"], quadro.timestamp, quadro.timestamp, item["valor"], return EvidenciaVisual(item["tipo"], quadro.timestamp, quadro.timestamp, item["valor"],
confianca=item.get("confianca"), provider=self.nome, confianca=item.get("confianca"), provider=self.nome,
@@ -3,6 +3,8 @@
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any from typing import Any
from ..integracoes.midia import RESOLUCAO_PADRAO_DO_PROXY
RECURSOS_VISION = frozenset(( RECURSOS_VISION = frozenset((
"faces", "qualidade_facial", "pessoas", "pose", "maos", "ocr", "categorias", "faces", "qualidade_facial", "pessoas", "pose", "maos", "ocr", "categorias",
@@ -21,6 +23,13 @@ SENSIBILIDADES_VISUAIS: dict[str, float] = {
} }
def _inteiro_ou_nulo(valor: Any) -> int | None:
"""Converte um valor de perfil em int, preservando None."""
if valor is None or valor == "":
return None
return int(valor)
@dataclass(frozen=True) @dataclass(frozen=True)
class ConfiguracaoVisualDoScanner: class ConfiguracaoVisualDoScanner:
"""Interface curta para uma execução de leitura visual da timeline.""" """Interface curta para uma execução de leitura visual da timeline."""
@@ -38,6 +47,13 @@ class ConfiguracaoVisualDoScanner:
usar_analise_visual: bool = False usar_analise_visual: bool = False
detectar_cortes: bool = True detectar_cortes: bool = True
sensibilidade: str = "media" sensibilidade: str = "media"
# Leitura via proxy compactado. Para arquivos de origem pesados (MOV de
# vários GB), ler os frames de uma cópia leve é muito mais rápido.
usar_proxy: bool = True
resolucao_do_proxy: int = RESOLUCAO_PADRAO_DO_PROXY
# Limita quantos frames por clipe recebem a interpretação editorial da Apple
# Intelligence (Foundation Models) — a etapa mais cara. None interpreta todos.
maximo_de_frames_interpretados: int | None = None
def __post_init__(self) -> None: def __post_init__(self) -> None:
if not 0.04 <= self.intervalo_em_segundos <= 60: if not 0.04 <= self.intervalo_em_segundos <= 60:
@@ -75,6 +91,9 @@ class ConfiguracaoVisualDoScanner:
usar_analise_visual=bool(dados.get("usar_analise_visual", False)), usar_analise_visual=bool(dados.get("usar_analise_visual", False)),
detectar_cortes=bool(dados.get("detectar_cortes", True)), detectar_cortes=bool(dados.get("detectar_cortes", True)),
sensibilidade=str(dados.get("sensibilidade", "media")), sensibilidade=str(dados.get("sensibilidade", "media")),
usar_proxy=bool(dados.get("usar_proxy", True)),
resolucao_do_proxy=int(dados.get("resolucao_do_proxy", RESOLUCAO_PADRAO_DO_PROXY)),
maximo_de_frames_interpretados=_inteiro_ou_nulo(dados.get("maximo_de_frames_interpretados", None)),
) )
def para_dict(self) -> dict[str, Any]: def para_dict(self) -> dict[str, Any]:
@@ -92,4 +111,7 @@ class ConfiguracaoVisualDoScanner:
"usar_analise_visual": self.usar_analise_visual, "usar_analise_visual": self.usar_analise_visual,
"detectar_cortes": self.detectar_cortes, "detectar_cortes": self.detectar_cortes,
"sensibilidade": self.sensibilidade, "sensibilidade": self.sensibilidade,
"usar_proxy": self.usar_proxy,
"resolucao_do_proxy": self.resolucao_do_proxy,
"maximo_de_frames_interpretados": self.maximo_de_frames_interpretados,
} }
+34 -3
View File
@@ -40,6 +40,7 @@ class DetectorDeCenas:
def detectar(self, clipe: Clipe) -> ResultadoVisualDoClipe: def detectar(self, clipe: Clipe) -> ResultadoVisualDoClipe:
quadros = self.extrator.extrair(clipe) quadros = self.extrator.extrair(clipe)
self._preparar_analisadores(quadros)
total = len(quadros) * len(self.analisadores_de_frame) + len(self.analisadores_de_sequencia) total = len(quadros) * len(self.analisadores_de_frame) + len(self.analisadores_de_sequencia)
progresso = [0] progresso = [0]
evidencias = self._analisar_quadros(quadros, progresso, total) + self._analisar_sequencia(quadros, progresso, total) evidencias = self._analisar_quadros(quadros, progresso, total) + self._analisar_sequencia(quadros, progresso, total)
@@ -51,6 +52,19 @@ class DetectorDeCenas:
) for cena in cenas] ) for cena in cenas]
return ResultadoVisualDoClipe(clipe.identificador, evidencias, cenas, cenas_visuais) return ResultadoVisualDoClipe(clipe.identificador, evidencias, cenas, cenas_visuais)
def _preparar_analisadores(self, quadros: list[QuadroDeVideo]) -> None:
"""Prepara analisadores que precisam saber a quantidade de frames do clipe.
Caso especial do `AnalisadorAppleVisionNativo`: ele distribui a
interpretação editorial (Apple Intelligence / Foundation Models) em um
subconjunto dos frames para manter a cobertura de cena com custo menor.
"""
quantidade_de_quadros = len(quadros)
for analisador in self.analisadores_de_frame:
preparar = getattr(analisador, "preparar_interpretacao_por_clipe", None)
if callable(preparar):
preparar(quantidade_de_quadros)
def _analisar_quadros(self, quadros: list[QuadroDeVideo], progresso: list[int] | None = None, def _analisar_quadros(self, quadros: list[QuadroDeVideo], progresso: list[int] | None = None,
total: int = 0) -> list[EvidenciaVisual]: total: int = 0) -> list[EvidenciaVisual]:
if not self.analisadores_de_frame or not quadros: if not self.analisadores_de_frame or not quadros:
@@ -205,18 +219,35 @@ def criar_detector_apple_vision(
from ..integracoes.visual import (AnalisadorAppleVisionNativo, from ..integracoes.visual import (AnalisadorAppleVisionNativo,
AnalisadorSequenciaAppleVisionNativo, AnalisadorSequenciaAppleVisionNativo,
ExtratorDeQuadrosFFmpeg) ExtratorDeQuadrosFFmpeg)
from ..integracoes.midia import CompactadorDeProxy, ExtratorDeQuadrosDoProxy
perfil = configuracao or ConfiguracaoVisualDoScanner(intervalo_em_segundos=intervalo_em_segundos) perfil = configuracao or ConfiguracaoVisualDoScanner(intervalo_em_segundos=intervalo_em_segundos)
return DetectorDeCenas( # Quantidade fixa de trabalhadores para extração e análise de frames.
ExtratorDeQuadrosFFmpeg(intervalo_em_segundos=perfil.intervalo_em_segundos, # Em volumes externos (ex.: /Volumes/Merongo), mais threads do que isso
# satura a E/S e trava os processos de mídia a ~0% de CPU.
QUANTIDADE_DE_TRABALHADORES = 4
extrator = ExtratorDeQuadrosFFmpeg(intervalo_em_segundos=perfil.intervalo_em_segundos,
diretorio_de_cache=diretorio_de_cache or ".frames", diretorio_de_cache=diretorio_de_cache or ".frames",
resolucao_maxima=perfil.resolucao_maxima), resolucao_maxima=perfil.resolucao_maxima,
trabalhadores_em_paralelo=QUANTIDADE_DE_TRABALHADORES)
# Leitura via proxy compactado: para arquivos pesados, evita buscar frame
# por frame no arquivo original (um -ss por frame num MOV de vários GB).
if perfil.usar_proxy:
extrator = ExtratorDeQuadrosDoProxy(
extrator, CompactadorDeProxy(), largura_maxima=perfil.resolucao_do_proxy
)
return DetectorDeCenas(
extrator,
analisadores_de_frame=[AnalisadorAppleVisionNativo( analisadores_de_frame=[AnalisadorAppleVisionNativo(
recursos=tuple(sorted(perfil.recursos_vision)), recursos=tuple(sorted(perfil.recursos_vision)),
interpretar_com_apple_intelligence=perfil.interpretar_cena, interpretar_com_apple_intelligence=perfil.interpretar_cena,
maximo_de_frames_interpretados=perfil.maximo_de_frames_interpretados,
)], )],
analisadores_de_sequencia=[AnalisadorSequenciaAppleVisionNativo()] analisadores_de_sequencia=[AnalisadorSequenciaAppleVisionNativo()]
if perfil.analisar_continuidade else [], if perfil.analisar_continuidade else [],
detectores_de_intervalo=_detectores_de_cena_do_perfil(perfil), detectores_de_intervalo=_detectores_de_cena_do_perfil(perfil),
trabalhadores_em_paralelo=QUANTIDADE_DE_TRABALHADORES,
) )