Spaces:
Running on Zero
Running on Zero
File size: 14,758 Bytes
c7f05a9 64e1ec4 c7f05a9 64e1ec4 c7f05a9 64e1ec4 c7f05a9 64e1ec4 c7f05a9 64e1ec4 c7f05a9 550d35b c7f05a9 3cd6712 c7f05a9 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 | # -*- coding: utf-8 -*-
"""
O NÚCLEO DO SPACE — tudo do adapter ZeroGPU que NÃO é Gradio.
Por que a separação: o Gradio e o `spaces` só existem DENTRO do Space, e a
lógica que importa (autenticação, duração de GPU por tipo, despacho para os
modelos, tradução dos erros de quota) precisa ser testável nesta casa, onde
nenhum dos dois está instalado. O `app.py` é só a casca que liga isto ao
Gradio; este arquivo é o que se mede no `t_zerogpu.py`.
OS MODELOS NÃO SÃO REESCRITOS. Este arquivo importa os MESMOS módulos de
`services/gpu-worker/modelos/` (e o mesmo `armazenamento.py`): no repositório
ele os acha pelo caminho relativo; no Space publicado eles são enviados JUNTO
(o `publicar.sh` faz isso — uma cópia de DEPLOY, nunca uma segunda
implementação no git). Dois fontes dos mesmos modelos seria o defeito de
sempre: um conserto entra num e o outro fica para trás.
POR QUE ISTO NÃO É O WORKER FASTAPI NUM SPACE: o ZeroGPU não é uma VM CUDA.
A GPU só existe DENTRO de uma chamada decorada com `@spaces.GPU`, com prazo
declarado, e o Space precisa ser Gradio. Então a fila daqui é a do PRÓPRIO
Gradio (cada chamada é um job do backend, síncrona do lado de cá), e quem
continua dono do estado do job é o backend — este processo não guarda nada
que não possa evaporar junto com a GPU.
"""
from __future__ import annotations
import json
import logging
import os
import secrets
import sys
from typing import Callable, Dict, Iterator, Optional
logger = logging.getLogger("zerogpu.nucleo")
# ---------------------------------------------------------------------------
# Onde moram os modelos: no repo, na pasta irmã gpu-worker; no Space, ao lado
# (o publicar.sh os põe lá). A ordem tenta o Space primeiro — é onde roda de
# verdade.
# ---------------------------------------------------------------------------
_AQUI = os.path.dirname(os.path.abspath(__file__))
for _cand in (_AQUI, os.path.join(_AQUI, "..", "gpu-worker")):
if os.path.isdir(os.path.join(_cand, "modelos")):
if _cand not in sys.path:
sys.path.insert(0, _cand)
break
from modelos import base as mbase # noqa: E402
from modelos import ace_step, escolha, qwen3_tts # noqa: E402
import armazenamento # noqa: E402
# QUEM ESCOLHE O MÓDULO É O `escolha.py`, o MESMO que o worker CUDA usa. Esta
# tabela e a do `gerenciador.py` eram iguais por sorte enquanto cada tipo
# tinha um módulo só; com dois módulos possíveis para imagem, duas tabelas
# seriam duas respostas para a mesma pergunta — e o Space é justamente o lado
# que ninguém abre para conferir.
_MODULOS = {"image": escolha.modulo_de("image"),
"speech": qwen3_tts, "voice": qwen3_tts,
"music": ace_step, "video": escolha.modulo_de("video")}
_carregado: Optional[str] = None
# ---------------------------------------------------------------------------
# DURAÇÃO DE GPU POR TIPO — o prazo que o decorator @spaces.GPU declara.
# Configurável porque é a variável mais sensível do ZeroGPU: curto demais, a
# geração é abortada no meio (e a quota foi gasta mesmo assim); longo demais,
# cada chamada reserva mais quota do que usa. Os padrões saem dos tempos de
# catálogo dos modelos escolhidos, com folga para a carga do modelo frio.
# Este prazo é DA GPU, separado de propósito do timeout HTTP do backend
# (_ESPERA_MAX do adapter): o backend espera mais do que a GPU promete, para
# a falha vir sempre daqui, com o motivo certo, e nunca das duas pontas ao
# mesmo tempo.
#
# A CONTA DO ZEROGPU, medida na primeira validação real: o que ele RESERVA é
# duração × 1,5 (180 declarados viraram "270s requested" na mensagem de
# quota), e o plano gratuito tem um teto POR CHAMADA — 240 declarados (360
# reservados) voltaram "larger than the maximum allowed"; 180 (270) passaram.
# Vídeo era 300 e nunca alocaria numa conta gratuita: 180 é o maior valor
# provado. E o download dos pesos NÃO conta aqui — ele roda fora da GPU
# (nucleo.preparar), então o prazo só cobre carregar do disco + inferir.
# ---------------------------------------------------------------------------
# A IMAGEM SUBIU DE 60 PARA 180, e é por causa do modelo, não por gosto. Os
# 60 s bastavam para o Z-Image-Turbo (8 passos, poucos GB). O Qwen-Image pesa
# 57,7 GB, e o `carregar()` roda DENTRO da chamada de GPU — o download é que
# ficou fora (ver `base.baixar_repo`). A primeira chamada depois de um Space
# frio paga o disco inteiro; as seguintes acham o pipeline residente e não
# pagam nada disso. 180 é o TETO provado nesta casa: o plano gratuito recusou
# 240 declarados ("requested GPU duration (360s) is larger than the maximum
# allowed" — o ZeroGPU reserva duração × 1,5).
#
# Declarar mais do que se usa custa RESERVA, não consumo; declarar menos custa
# a geração inteira, no meio, com a quota já gasta. Entre os dois erros, este
# é o barato.
# O VÍDEO CAIU DE 180 PARA 80 — e é o que faz ele SAIR. Medido em produção em
# 25/09/2026: 180 declarados (270 reservados) voltaram "larger than the maximum
# allowed". A quota do ZeroGPU é POR CHAMADOR (docs do Hugging Face, lidas no
# mesmo dia): chamador sem login tem 2 MINUTOS por dia, conta gratuita 5, PRO
# 40 — e uma reserva maior que o que o chamador tem é recusada inteira. 80
# declarados são 120 reservados: cabem até no chamador anônimo. O Wan cabe
# nisso porque o pedido também encolheu (3 s, 20 passos, 480p — ver
# `wan22.gerar`). Quem tiver mais cota sobe `VIDEO_GPU_SECONDS` no Space.
_DURACAO_PADRAO = {"image": 180, "speech": 60, "voice": 60,
"music": 180, "video": 80}
_VAR_DURACAO = {"image": "IMAGE_GPU_SECONDS", "speech": "TTS_GPU_SECONDS",
"voice": "TTS_GPU_SECONDS", "music": "MUSIC_GPU_SECONDS",
"video": "VIDEO_GPU_SECONDS"}
def duracao_gpu(tipo: str) -> int:
try:
valor = int(os.getenv(_VAR_DURACAO.get(tipo, ""), "") or 0)
except ValueError:
valor = 0
return valor if valor > 0 else _DURACAO_PADRAO.get(tipo, 120)
# ---------------------------------------------------------------------------
# A PORTA. O endpoint de um Space é público por natureza — qualquer pessoa
# que achar a URL pode chamar a API do Gradio. Quem protege a quota é ISTO:
# o segredo é o primeiro argumento de toda chamada, conferido ANTES de a
# função de GPU ser invocada — chamada sem segredo morre aqui, sem alocar um
# segundo de ZeroGPU. Fail closed, como no worker CUDA: sem WORKER_SECRET
# configurado no Space, ninguém gera nada.
# ---------------------------------------------------------------------------
def autenticar(segredo: str) -> Optional[str]:
"""None = pode passar; string = o motivo da recusa (sem eco do que veio)."""
esperado = os.getenv("WORKER_SECRET", "").strip()
if not esperado:
return ("WORKER_SECRET não configurado no Space — configure o secret "
"com o MESMO valor usado no backend.")
if not segredo or not secrets.compare_digest(str(segredo), esperado):
return "segredo inválido"
return None
# ---------------------------------------------------------------------------
# QUOTA É ESTADO. O `spaces` sinaliza quota esgotada/GPU indisponível com
# exceções próprias cuja MENSAGEM é estável ("GPU quota exceeded", "ZeroGPU");
# a classe muda entre versões do pacote, então o reconhecimento é pelo texto
# — e o resultado leva `erro_gpu`, que o backend traduz para
# GeracaoIndisponivel (degrada ou espera), nunca para falha do pedido.
# ---------------------------------------------------------------------------
_SINAIS_DE_QUOTA = ("quota", "zerogpu", "gpu task aborted", "no gpu",
"gpu unavailable", "queue is full",
# O PRAZO ACIMA DO TETO também é recusa de ALOCAÇÃO, e não
# estava aqui: `The requested GPU duration (270s) is larger
# than the maximum allowed` saía como `erro`, e a tarefa de
# vídeo FALHAVA em vez de o backend cair no gerador local
# (medido em produção, 25/09/2026).
"maximum allowed", "requested gpu duration")
def e_erro_de_gpu(e: BaseException) -> bool:
texto = f"{type(e).__name__}: {e}".lower()
return any(s in texto for s in _SINAIS_DE_QUOTA)
# ---------------------------------------------------------------------------
# A GERAÇÃO — o corpo que o app.py decora com @spaces.GPU.
#
# É um GERADOR: cada yield intermediário vira um evento `generating` no SSE
# do Gradio (o progresso que a tela do app mostra), e o último yield é o
# resultado. O contrato do dict é o que o adapter do backend lê:
# {"etapa","progress"} no meio
# {"resultado": {...}} no fim
# {"erro": ...} falha do pedido (não adianta tentar em outro lugar)
# {"erro_gpu": ...} GPU/quota indisponível (degradar ou esperar)
# ---------------------------------------------------------------------------
def preparar(tipo: str) -> Optional[str]:
"""Baixa os pesos para o disco ANTES da chamada de GPU — download é
trabalho de rede, e dentro do @spaces.GPU ele queimava o prazo inteiro:
pedir prazo para caber o download estourou o teto do ZeroGPU ("requested
GPU duration (360s) is larger than the maximum allowed"). Devolve None
quando está pronto; string = o motivo da falha (repo inexistente, rede)."""
modulo = _MODULOS.get(tipo)
if not modulo:
return f"tipo desconhecido: {tipo}"
baixar = getattr(modulo, "baixar", None)
if not baixar:
return None
try:
baixar(lambda pct, etapa: None)
return None
except Exception as e:
_log(tipo, "download_falhou", e)
return f"não consegui baixar o modelo: {str(e)[:200]}"
def gerar(tipo: str, params_json: str) -> Iterator[Dict]:
global _carregado
try:
params = json.loads(params_json or "{}")
if not isinstance(params, dict):
raise ValueError("params não é um objeto")
except ValueError as e:
yield {"erro": f"parâmetros ilegíveis: {str(e)[:80]}"}
return
modulo = _MODULOS.get(tipo)
if not modulo:
yield {"erro": f"tipo desconhecido: {tipo}"}
return
progresso_atual: Dict = {}
def progresso(pct, etapa):
# O gerador não pode "yield" de dentro do callback; o valor fica
# anotado e sai no próximo passo do laço de fases abaixo.
if pct is not None:
progresso_atual["progress"] = max(0, min(100, int(pct)))
if etapa:
progresso_atual["etapa"] = etapa
yield {"etapa": "Carregando o modelo…", "progress": None}
tentativa_oom = 0
while True:
try:
# A troca de residente vale aqui também — na mesma chamada podem
# sobrar pesos da chamada anterior NA RAM (o processo do Space
# sobrevive entre alocações de GPU; a GPU é que evapora). Nunca
# dependa de estado NA GPU entre chamadas: os módulos usam
# cpu_offload, que sobe os blocos por passada — é exatamente o
# desenho que o ZeroGPU pede.
if _carregado and _carregado != tipo:
antigo = _MODULOS.get(_carregado)
if antigo:
try:
antigo.descarregar()
except Exception:
pass
mbase.limpar_vram()
_carregado = None
modulo.carregar(progresso)
_carregado = tipo
yield {"etapa": "Gerando…", "progress": progresso_atual.get("progress")}
saida, mime = modulo.gerar(params, progresso)
yield {"etapa": "Finalizando…", "progress": 97}
resultado = armazenamento.como_url_ou_base64(saida, mime)
resultado["mock"] = mbase.modo_mock()
_log(tipo, "completed")
yield {"resultado": resultado}
return
except BaseException as e: # BaseException: o abort do
if e_erro_de_gpu(e): # spaces nem sempre é Exception
_log(tipo, "gpu_indisponivel", e)
yield {"erro_gpu": f"ZeroGPU indisponível agora: {str(e)[:120]}"}
return
if isinstance(e, ImportError):
# Biblioteca que não está neste runtime (o ACE-Step saiu do
# requirements por cravar transformers==4.50.0 contra o
# >=4.51 do Z-Image). Não é falha DO PEDIDO: é este worker
# não fazendo este tipo — erro_gpu, para o backend degradar
# ao gerador local em vez de devolver erro à pessoa.
_log(tipo, "sem_biblioteca", e)
yield {"erro_gpu": f"este runtime não tem a biblioteca de "
f"{tipo}: {str(e)[:120]}"}
return
if "out of memory" in str(e).lower():
tentativa_oom += 1
mbase.limpar_vram()
menor = modulo.config_menor(params)
if menor is not None and tentativa_oom <= 2:
params = menor
yield {"etapa": "Memória curta: tentando uma versão menor…",
"progress": None}
continue
_log(tipo, "failed_oom", e)
yield {"erro": "a GPU não teve memória para este pedido"}
return
if isinstance(e, (KeyboardInterrupt, SystemExit)):
raise
_log(tipo, "failed", e)
yield {"erro": str(e)[:300]}
return
def saude() -> Dict:
"""O retrato para o /api/health/gpu do backend. No ZeroGPU não há GPU
residente para medir — CUDA só existe dentro de uma chamada decorada — e
o retrato DIZ isso em vez de fingir um número."""
return {"worker_status": "ready", "runtime": "zerogpu",
"mock": mbase.modo_mock(),
"cuda": None, # None de propósito: "só se sabe dentro da chamada"
"duracoes": {t: duracao_gpu(t)
for t in ("image", "speech", "music", "video")},
"modelo_residente": _carregado}
def _log(tipo: str, status: str, erro: BaseException = None) -> None:
# O mesmo log estruturado do worker CUDA — e igualmente SEM o prompt:
# prompt é conteúdo da pessoa, e log de Space é ainda mais público que o
# nosso (aparece no painel do Hugging Face).
logger.info("geracao runtime=zerogpu tipo=%s status=%s%s", tipo, status,
f" erro={type(erro).__name__}" if erro else "")
|