Files
jhonny-editor/code/engine/integracoes/visual/extrator_ffmpeg.py
T
João Henrique ee181360c8 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:
- 32 arquivos alterados
- 16 novos
- 15 modificados
- 0 removidos

 16 files changed, 638 insertions(+), 61 deletions(-)

Arquivos:
  - code/engine/skills/boas-praticas-oo.md -> .agents/skills/boas-praticas-oo/SKILL.md
  - AGENTS.md
  - code/cep-plugin/index.html
  - code/cep-plugin/main.js
  - code/cep-plugin/styles.css
  - code/engine/integracoes/visual/apple_vision.py
  - code/engine/integracoes/visual/apple_vision_runner.swift
  - code/engine/integracoes/visual/extrator_ffmpeg.py
  - code/engine/scanner/__init__.py
  - code/engine/scanner/configuracao_visual.py
  - code/engine/scanner/coordenacao/__init__.py
  - code/engine/scanner/retakes/adaptador_de_falas.py
  - code/engine/scanner/retakes/agrupador_de_takes.py
  - code/engine/scanner/retakes/detector_de_reinicios.py
  - code/engine/scanner/retakes/modelos_de_retakes.py
  - code/engine/scanner/visual.py
  - .jhonny/
  - .retakes/
  - CODING_STANDARDS.md
  - code/engine/executar_retakes.py
  - code/engine/persistencia/
  - code/engine/scanner/retakes/__init__.py
  - code/engine/scanner/retakes/carregador_de_artefatos.py
  - code/engine/scanner/retakes/classificador_de_retakes.py
  - code/engine/scanner/retakes/coordenador_de_retakes.py
  - code/engine/scanner/retakes/deteccao_de_retakes.py
  - code/engine/scanner/retakes/gerador_de_evidencias.py
  - code/engine/scanner/retakes/repositorio_de_retakes.py
  - code/engine/testes/test_consultas_de_persistencia.py
  - code/engine/testes/test_deteccao_de_retakes.py
  - code/engine/testes/test_persistencia_sqlite.py
  - code/relatorios/audio/arquivos/92f97877259a86a8095b1eecafdefe357212a12247d04b78df60e0c0ae38b2ea/
2026-09-08 17:13:19 -04:00

103 lines
5.9 KiB
Python

"""Extrator de frames para Vision usando somente ferramentas locais do sistema."""
import json
from concurrent.futures import ThreadPoolExecutor
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",
trabalhadores_em_paralelo: int = 4,
resolucao_maxima: int = 0) -> 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.")
if resolucao_maxima < 0:
raise ValueError("resolucao_maxima não pode ser negativa.")
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
self.trabalhadores_em_paralelo = max(1, trabalhadores_em_paralelo)
# 0 preserva a resolução original; caso contrário, limita a maior dimensão
# do frame extraído, sem nunca ampliar (force_original_aspect_ratio=decrease).
self.resolucao_maxima = resolucao_maxima
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)
def extrair_um(indice_e_timestamp: tuple[int, float]) -> QuadroDeVideo:
indice, timestamp = indice_e_timestamp
# 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.
# O nome carrega clipe, timestamp e resolução: dois clipes do mesmo arquivo
# de origem não podem colidir no mesmo caminho, e mudar o intervalo ou a
# resolução entre execuções não pode reaproveitar um frame de outra config.
resolucao_rotulo = str(self.resolucao_maxima) if self.resolucao_maxima else "orig"
destino = (self.diretorio_de_cache / arquivo.stem / clipe.identificador /
f"frame-{timestamp:09.3f}s-{resolucao_rotulo}.png")
destino.parent.mkdir(parents=True, exist_ok=True)
if destino.is_file():
return QuadroDeVideo(timestamp, indice, largura, altura, None, destino)
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}")
return QuadroDeVideo(amostrado, indice, largura, altura, None, destino)
tarefas = list(enumerate(timestamps))
if self.trabalhadores_em_paralelo == 1 or len(tarefas) <= 1:
return [extrair_um(tarefa) for tarefa in tarefas]
with ThreadPoolExecutor(max_workers=self.trabalhadores_em_paralelo) as executor:
return list(executor.map(extrair_um, tarefas))
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."""
comando_base = [self.ffmpeg, "-hide_banner", "-loglevel", "error"]
filtro = (["-vf", f"scale=w={self.resolucao_maxima}:h={self.resolucao_maxima}:force_original_aspect_ratio=decrease"]
if self.resolucao_maxima else [])
ultimo: subprocess.CompletedProcess[str] | None = None
for candidato in (timestamp, max(inicio, timestamp - 0.1)):
if destino.exists():
destino.unlink()
processo = subprocess.run([*comando_base, "-ss", str(candidato), "-i", str(arquivo),
"-frames:v", "1", *filtro, "-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)