# -*- coding: utf-8 -*- """ O CONTRATO DE UM MODELO DENTRO DO WORKER. Cada modelo é um módulo com quatro deveres, e o gerenciador só conversa por eles: carregar() — traz os pesos para a GPU. Import de torch/diffusers é feito AQUI DENTRO, nunca no topo do arquivo: o worker precisa subir e responder /health mesmo numa máquina sem as bibliotecas pesadas instaladas. gerar(params, progresso) — produz bytes + mime. `progresso(pct, etapa)` é chamado quando o modelo sabe onde está (a difusão sabe o passo; TTS muitas vezes não — aí não chama). descarregar() — devolve a VRAM. É o gerenciador que decide QUANDO. config_menor(params) — a versão do pedido que cabe em menos VRAM (menos passos, resolução menor, duração menor). Devolve None quando não há o que reduzir — aí o OOM vira falha limpa em vez de tentativa infinita. E um modo de MENTIRA declarada: com WORKER_MOCK=1 os modelos devolvem arquivos minúsculos válidos (PNG 1x1, WAV de silêncio, MP4 de um quadro) sem tocar em GPU nenhuma. Ele existe para os testes automatizados e para subir a pilha inteira num notebook sem CUDA — e o resultado DIZ que é mock no metadado, porque fingir inferência real é o tipo de mentira que este projeto não conta. """ from __future__ import annotations import os import struct from typing import Callable, Dict, Optional, Tuple Progresso = Callable[[Optional[int], str], None] # bytes, mime Saida = Tuple[bytes, str] def modo_mock() -> bool: return os.getenv("WORKER_MOCK", "").strip() == "1" # --------------------------------------------------------------------------- # Os arquivos mínimos do modo mock — válidos de verdade (abrem em qualquer # visualizador), só que vazios de conteúdo. # --------------------------------------------------------------------------- PNG_1X1 = bytes.fromhex( "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c489" "0000000d4944415478da63fcffff3f0300050201f34d31b20000000049454e44ae426082") def wav_silencio(segundos: float = 0.2, taxa: int = 16000) -> bytes: n = int(segundos * taxa) dados = b"\x00\x00" * n return (b"RIFF" + struct.pack(" None: """Traz os pesos para o CACHE local (disco), sem tocar em GPU nenhuma. Existe por causa do ZeroGPU: lá a GPU só vive dentro da chamada decorada, com prazo — e o primeiro `from_pretrained` gastava o prazo inteiro BAIXANDO, que é trabalho de rede. Medido: pedir 240s de duração para caber o download estourou o teto do ZeroGPU ("requested GPU duration (360s) is larger than the maximum allowed"). Com o download feito antes, o `carregar()` de dentro da GPU só lê do disco. No worker CUDA chamar isto é inócuo: o from_pretrained acha o cache e não baixa de novo. `arquivos` RECORTA o que desce, e ele não é conveniência. Um repositório do Hub costuma guardar várias variantes do mesmo peso — o do LoRA Lightning tem doze arquivos e 65,5 GB para entregar os 0,85 GB de um deles. Sem recorte, pedir o acelerador baixaria setenta vezes o que ele pesa, e num Space de disco efêmero isso não é lentidão: é o disco acabando antes de o modelo principal caber.""" if modo_mock(): return progresso(None, "Baixando o modelo…") from huggingface_hub import snapshot_download snapshot_download(repo_id, revision=revision, allow_patterns=arquivos or None, token=os.getenv("HF_TOKEN") or None) def vram_livre_gb() -> Optional[float]: """Quanta VRAM sobra agora. None = sem CUDA (aí ninguém decide por VRAM).""" try: import torch if not torch.cuda.is_available(): return None livre, _total = torch.cuda.mem_get_info() return livre / (1024 ** 3) except Exception: return None def limpar_vram() -> None: """Solta o que der: cache de CUDA e lixo do Python. Chamado pelo gerenciador depois de descarregar e depois de um OOM — nas duas horas em que 'quase liberado' e 'liberado' são a diferença entre caber e não.""" import gc gc.collect() try: import torch if torch.cuda.is_available(): torch.cuda.empty_cache() torch.cuda.ipc_collect() except Exception: pass def dispositivo() -> str: try: import torch return "cuda" if torch.cuda.is_available() else "cpu" except Exception: return "cpu" def gpu_dedicada() -> bool: """Há prazo de GPU de verdade? `GPU_DEDICADA=1` diz que sim. UM FATO, UMA VARIÁVEL. O ZeroGPU gratuito corta a chamada em 180 segundos (é o maior valor que ele aceita — ver CLAUDE.md), e esse teto é DURO: estourá-lo não é OOM, então nenhuma escada de recuperação roda — a chamada morre no meio com a quota do dia gasta e nada saindo. Isso obriga CADA módulo a ter um padrão que cabe: o vídeo na resolução, a imagem no número de passos. Os dois padrões nascem do MESMO fato, então eles leem a MESMA variável. Uma por módulo (`VIDEO_720P`, `IMAGE_PASSOS_CHEIOS`…) seriam vários donos de uma decisão só, e no dia em que houver GPU dedicada metade ficaria para trás — com o sintoma de sempre: nada quebra, só entrega pior. Nasce DESLIGADA de propósito: produção é o ZeroGPU gratuito, e um padrão que assume folga é uma geração que aborta para todo mundo. """ return (os.getenv("GPU_DEDICADA", "0").strip() or "0") != "0" def semente(params: Dict): """torch.Generator com a seed pedida — ou None para o aleatório de sempre. Seed existe para 'gera de novo IGUAL', então ela é respeitada em todo modelo que aceita generator.""" seed = params.get("seed") if seed in (None, ""): return None try: import torch return torch.Generator(device=dispositivo()).manual_seed(int(seed)) except Exception: return None