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:
@@ -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)
|
||||
Reference in New Issue
Block a user