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:
Binary file not shown.
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
# Quantidade fixa de trabalhadores para extração e análise de frames.
|
||||||
|
# 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",
|
||||||
|
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(
|
return DetectorDeCenas(
|
||||||
ExtratorDeQuadrosFFmpeg(intervalo_em_segundos=perfil.intervalo_em_segundos,
|
extrator,
|
||||||
diretorio_de_cache=diretorio_de_cache or ".frames",
|
|
||||||
resolucao_maxima=perfil.resolucao_maxima),
|
|
||||||
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,
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user