# -*- 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 "")