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/
This commit is contained in:
@@ -1,10 +1,13 @@
|
||||
"""Adapter Python para o runner Swift que concentra Vision e Apple Intelligence."""
|
||||
|
||||
import atexit
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import queue
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
from typing import Any, Callable
|
||||
|
||||
from ...scanner.modelos import EvidenciaVisual
|
||||
@@ -18,27 +21,101 @@ RECURSOS_PADRAO = (
|
||||
)
|
||||
|
||||
|
||||
class _TrabalhadorPersistente:
|
||||
"""Um processo do runner Swift vivo entre chamadas, evitando recarregar Vision/Foundation Models a cada frame."""
|
||||
|
||||
def __init__(self, executavel: Path) -> None:
|
||||
self.processo = subprocess.Popen([str(executavel)], stdin=subprocess.PIPE, stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE, text=True, bufsize=1)
|
||||
|
||||
def executar(self, carga: dict[str, Any], timeout: float) -> dict[str, Any]:
|
||||
if self.processo.poll() is not None or not self.processo.stdin or not self.processo.stdout:
|
||||
raise RuntimeError("Runner Apple Vision não está mais em execução.")
|
||||
try:
|
||||
self.processo.stdin.write(json.dumps(carga) + "\n")
|
||||
self.processo.stdin.flush()
|
||||
except (BrokenPipeError, ValueError) as exc:
|
||||
raise RuntimeError("Runner Apple Vision não aceitou o pedido.") from exc
|
||||
|
||||
resposta: dict[str, str] = {}
|
||||
falha: list[BaseException] = []
|
||||
|
||||
def ler() -> None:
|
||||
try:
|
||||
resposta["linha"] = self.processo.stdout.readline() # type: ignore[union-attr]
|
||||
except BaseException as exc: # noqa: BLE001 - repassado à thread principal
|
||||
falha.append(exc)
|
||||
|
||||
leitor = threading.Thread(target=ler, daemon=True)
|
||||
leitor.start()
|
||||
leitor.join(timeout)
|
||||
if leitor.is_alive():
|
||||
self.encerrar()
|
||||
raise TimeoutError(f"Runner Apple Vision excedeu {timeout}s.")
|
||||
if falha:
|
||||
raise falha[0]
|
||||
linha = resposta.get("linha", "")
|
||||
if not linha:
|
||||
detalhe = (self.processo.stderr.read() if self.processo.stderr else "") or "runner encerrou sem resposta"
|
||||
raise RuntimeError(f"Runner Apple Vision falhou: {detalhe.strip()}")
|
||||
return json.loads(linha)
|
||||
|
||||
def encerrar(self) -> None:
|
||||
if self.processo.poll() is None:
|
||||
self.processo.kill()
|
||||
try:
|
||||
self.processo.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
pass
|
||||
|
||||
|
||||
class ExecutorDoRunnerApple:
|
||||
"""Compila o runner Swift quando necessário e o executa em processo isolado."""
|
||||
"""Compila o runner Swift quando necessário e mantém um pool de processos persistentes."""
|
||||
|
||||
def __init__(self, fonte: str | Path | None = None, compilador: str = "swiftc",
|
||||
diretorio_de_build: str | Path | None = None) -> None:
|
||||
diretorio_de_build: str | Path | None = None, trabalhadores: int = 4) -> None:
|
||||
self.fonte = Path(fonte) if fonte else Path(__file__).with_name("apple_vision_runner.swift")
|
||||
self.compilador = compilador
|
||||
self.diretorio_de_build = Path(diretorio_de_build) if diretorio_de_build else (
|
||||
Path(tempfile.gettempdir()) / "jhonny-apple-vision")
|
||||
self.trabalhadores = max(1, trabalhadores)
|
||||
self._pool: "queue.Queue[_TrabalhadorPersistente]" | None = None
|
||||
self._trava = threading.Lock()
|
||||
self._todos: list[_TrabalhadorPersistente] = []
|
||||
atexit.register(self.encerrar)
|
||||
|
||||
def executar(self, carga: dict[str, Any], timeout: float) -> dict[str, Any]:
|
||||
executavel = self._garantir_compilado()
|
||||
processo = subprocess.run([str(executavel)], input=json.dumps(carga), capture_output=True,
|
||||
text=True, timeout=timeout)
|
||||
if processo.returncode != 0:
|
||||
detalhe = processo.stderr.strip() or "runner Apple terminou sem diagnóstico"
|
||||
raise RuntimeError(f"Runner Apple Vision falhou: {detalhe}")
|
||||
pool = self._garantir_pool(executavel)
|
||||
trabalhador = pool.get()
|
||||
try:
|
||||
return json.loads(processo.stdout)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise RuntimeError(f"Runner Apple Vision devolveu JSON inválido: {processo.stdout!r}") from exc
|
||||
return trabalhador.executar(carga, timeout)
|
||||
except Exception:
|
||||
trabalhador.encerrar()
|
||||
with self._trava:
|
||||
self._todos.remove(trabalhador)
|
||||
trabalhador = _TrabalhadorPersistente(executavel)
|
||||
self._todos.append(trabalhador)
|
||||
raise
|
||||
finally:
|
||||
pool.put(trabalhador)
|
||||
|
||||
def encerrar(self) -> None:
|
||||
with self._trava:
|
||||
for trabalhador in self._todos:
|
||||
trabalhador.encerrar()
|
||||
self._todos.clear()
|
||||
self._pool = None
|
||||
|
||||
def _garantir_pool(self, executavel: Path) -> "queue.Queue[_TrabalhadorPersistente]":
|
||||
with self._trava:
|
||||
if self._pool is None:
|
||||
self._pool = queue.Queue()
|
||||
for _ in range(self.trabalhadores):
|
||||
trabalhador = _TrabalhadorPersistente(executavel)
|
||||
self._todos.append(trabalhador)
|
||||
self._pool.put(trabalhador)
|
||||
return self._pool
|
||||
|
||||
def _garantir_compilado(self) -> Path:
|
||||
if not self.fonte.is_file():
|
||||
|
||||
@@ -350,19 +350,32 @@ func interpretar(_ saida: Saida) async -> String? {
|
||||
} catch { return nil }
|
||||
}
|
||||
|
||||
/// Processa um pedido por linha de stdin e escreve uma resposta por linha em stdout.
|
||||
/// Mantém o processo vivo entre quadros para não recarregar Vision/Foundation Models a
|
||||
/// cada frame — o custo de inicialização é o que tornava a análise lenta em lote.
|
||||
@main struct Principal {
|
||||
static func main() async {
|
||||
do {
|
||||
let entrada = try JSONDecoder().decode(Entrada.self, from: FileHandle.standardInput.readDataToEndOfFile())
|
||||
var saida = entrada.frames?.count ?? 0 > 1 ? analisarSequencia(entrada) : analisarVision(entrada)
|
||||
if entrada.resumir, let resumo = await interpretar(saida) {
|
||||
saida = Saida(evidencias: saida.evidencias + [Evidencia(tipo: "interpretacao_editorial", valor: ["texto": .texto(resumo)], confianca: nil, modelo: "AppleFoundationModels")], avisos: saida.avisos)
|
||||
while let linha = readLine(strippingNewline: true) {
|
||||
guard !linha.isEmpty, let entradaDados = linha.data(using: .utf8) else { continue }
|
||||
let saida: Saida
|
||||
do {
|
||||
let entrada = try JSONDecoder().decode(Entrada.self, from: entradaDados)
|
||||
var resultado = entrada.frames?.count ?? 0 > 1 ? analisarSequencia(entrada) : analisarVision(entrada)
|
||||
if entrada.resumir, let resumo = await interpretar(resultado) {
|
||||
resultado = Saida(evidencias: resultado.evidencias + [Evidencia(
|
||||
tipo: "interpretacao_editorial", valor: ["texto": .texto(resumo)],
|
||||
confianca: nil, modelo: "AppleFoundationModels")], avisos: resultado.avisos)
|
||||
}
|
||||
saida = resultado
|
||||
} catch {
|
||||
saida = Saida(evidencias: [], avisos: ["erro_fatal: \(error.localizedDescription)"])
|
||||
}
|
||||
let dados = try JSONEncoder().encode(saida)
|
||||
FileHandle.standardOutput.write(dados)
|
||||
} catch {
|
||||
FileHandle.standardError.write(Data("\(error.localizedDescription)\n".utf8))
|
||||
exit(1)
|
||||
guard let dados = try? JSONEncoder().encode(saida), let linhaDeSaida = String(data: dados, encoding: .utf8) else {
|
||||
FileHandle.standardError.write(Data("Falha ao serializar resposta\n".utf8))
|
||||
continue
|
||||
}
|
||||
print(linhaDeSaida)
|
||||
fflush(stdout)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Extrator de frames para Vision usando somente ferramentas locais do sistema."""
|
||||
|
||||
import json
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
@@ -16,16 +17,24 @@ class ExtratorDeQuadrosFFmpeg(ExtratorDeQuadros):
|
||||
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:
|
||||
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:
|
||||
@@ -37,28 +46,44 @@ class ExtratorDeQuadrosFFmpeg(ExtratorDeQuadros):
|
||||
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):
|
||||
|
||||
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.
|
||||
destino = self.diretorio_de_cache / arquivo.stem / f"frame-{indice:06d}.png"
|
||||
# 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}")
|
||||
resultado.append(QuadroDeVideo(amostrado, indice, largura, altura, None, destino))
|
||||
return resultado
|
||||
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([self.ffmpeg, "-hide_banner", "-loglevel", "error", "-ss", str(candidato),
|
||||
"-i", str(arquivo), "-frames:v", "1", "-y", str(destino)],
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user