ConectaPrimoAI commited on
Commit
c7f05a9
·
verified ·
1 Parent(s): b7addfb

Worker ZeroGPU do Conecta Primo

Browse files
README.md CHANGED
@@ -1,13 +1,39 @@
1
  ---
2
- title: Conecta Primo Gpu
3
- emoji: 🏆
4
  colorFrom: indigo
5
- colorTo: yellow
6
  sdk: gradio
7
- sdk_version: 6.26.0
8
- python_version: '3.12'
9
  app_file: app.py
10
  pinned: false
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Conecta Primo GPU
3
+ emoji: 🎛️
4
  colorFrom: indigo
5
+ colorTo: blue
6
  sdk: gradio
7
+ sdk_version: "5.9.1"
 
8
  app_file: app.py
9
  pinned: false
10
+ license: mit
11
  ---
12
 
13
+ # Conecta Primo — worker de GPU (ZeroGPU)
14
+
15
+ Este Space é o **adapter ZeroGPU** do Conecta Primo AI: a mesma plataforma de
16
+ geração do repositório principal, rodando os mesmos modelos
17
+ (`modelos/` — Z-Image, Qwen3-TTS, ACE-Step, Wan 2.2) na GPU temporária do
18
+ Hugging Face. Ele atende **somente** o backend do app, autenticado por
19
+ segredo — a página não tem controles de propósito.
20
+
21
+ O guia completo (criar o Space, secrets, conectar ao Render, testar) está em
22
+ `docs/ZEROGPU.md` no repositório principal. O resumo:
23
+
24
+ 1. Crie um Space **Gradio** com hardware **ZeroGPU**.
25
+ 2. Publique estes arquivos MAIS `modelos/` e `armazenamento.py` do
26
+ `services/gpu-worker/` (o `publicar.sh` faz os dois passos).
27
+ 3. Em *Settings → Variables and secrets*:
28
+ - secret `WORKER_SECRET` — o mesmo valor que irá no Render;
29
+ - secret `HF_TOKEN` — se algum modelo for *gated*;
30
+ - secrets `STORAGE_*` — recomendado (obrigatório na prática para vídeo);
31
+ - variables `IMAGE_GPU_SECONDS` / `TTS_GPU_SECONDS` /
32
+ `MUSIC_GPU_SECONDS` / `VIDEO_GPU_SECONDS` — duração de GPU por tipo
33
+ (padrões: 60/60/180/300).
34
+ 4. No Render: `GPU_MODE=zerogpu`, `WORKER_URL=https://<user>-<space>.hf.space`
35
+ e `WORKER_SECRET` com o mesmo valor.
36
+
37
+ A quota do ZeroGPU é **diária e acaba** — o backend trata isso como
38
+ indisponibilidade (degrada para os geradores locais, ou segura a fila com
39
+ `GERACAO_SO_WORKER=1`), nunca como erro para quem usa o app.
__pycache__/armazenamento.cpython-311.pyc ADDED
Binary file (4.4 kB). View file
 
__pycache__/nucleo.cpython-311.pyc ADDED
Binary file (10.2 kB). View file
 
app.py ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ A CASCA GRADIO DO ADAPTER ZEROGPU — o que o Hugging Face executa.
4
+
5
+ Este arquivo é fino de propósito: toda a lógica mora em `nucleo.py` (testado
6
+ no repositório, sem Gradio). Aqui só se faz o que EXIGE o ambiente do Space:
7
+
8
+ - decorar a geração com `@spaces.GPU(duration=...)` — é o decorator que
9
+ faz o ZeroGPU alocar a placa durante a chamada, e a duração é POR TIPO
10
+ (IMAGE_GPU_SECONDS etc., lidas no nucleo);
11
+ - expor as funções como API do Gradio (`api_name=...`), que é o único
12
+ transporte que um Space ZeroGPU oferece: POST → event_id → SSE. Os
13
+ yields do gerador viram eventos `generating` (o progresso do app);
14
+ - manter a PÁGINA do Space inofensiva: quem abrir a URL num navegador vê
15
+ um aviso, não um formulário que gasta quota — gerar exige o segredo, e a
16
+ conferência roda ANTES do decorator de GPU ser invocado.
17
+
18
+ Fora do Space (sem `spaces` instalado) o decorator vira identidade e o
19
+ WORKER_MOCK=1 permite subir a casca inteira num notebook para teste.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import gradio as gr
24
+
25
+ import nucleo
26
+
27
+ try:
28
+ import spaces
29
+ _GPU = spaces.GPU
30
+ except ImportError: # fora do Hugging Face: sem decorator
31
+ def _GPU(*a, **k):
32
+ def envolve(fn):
33
+ return fn
34
+ return envolve
35
+
36
+
37
+ def _fazer_endpoint(tipo: str):
38
+ """Uma função por tipo, porque a DURAÇÃO da GPU é do decorator e vídeo
39
+ não pode viver com o prazo de imagem (abortaria no meio, com a quota já
40
+ gasta)."""
41
+
42
+ @_GPU(duration=nucleo.duracao_gpu(tipo))
43
+ def _na_gpu(params_json: str):
44
+ yield from nucleo.gerar(tipo, params_json)
45
+
46
+ def endpoint(segredo: str, params_json: str):
47
+ # A porta fica AQUI FORA: recusar antes de `_na_gpu` significa que a
48
+ # chamada sem segredo não aloca GPU e não consome quota de ninguém.
49
+ recusa = nucleo.autenticar(segredo)
50
+ if recusa:
51
+ yield {"erro": recusa}
52
+ return
53
+ yield from _na_gpu(params_json)
54
+
55
+ return endpoint
56
+
57
+
58
+ def _saude(segredo: str):
59
+ recusa = nucleo.autenticar(segredo)
60
+ if recusa:
61
+ return {"erro": recusa}
62
+ return nucleo.saude()
63
+
64
+
65
+ with gr.Blocks(title="Conecta Primo — GPU") as demo:
66
+ # A página humana diz o mínimo. Nada aqui dispara geração: os botões nem
67
+ # existem — só a API, e ela pede o segredo.
68
+ gr.Markdown("## Conecta Primo — worker de GPU\n"
69
+ "Este Space atende **somente** o backend do aplicativo, "
70
+ "autenticado por segredo. Não há nada para usar nesta página.")
71
+
72
+ segredo_in = gr.Textbox(visible=False)
73
+ params_in = gr.Textbox(visible=False)
74
+ saida = gr.JSON(visible=False)
75
+
76
+ # gr.api registra função como endpoint puro (Gradio >= 4.44). Os botões
77
+ # invisíveis são a forma equivalente que funciona em toda versão 4/5 —
78
+ # ficam os dois? Não: DOIS caminhos para a mesma função é o defeito de
79
+ # sempre. Fica o dos componentes, que é o estável.
80
+ for _tipo, _nome in (("image", "gerar_imagem"), ("voice", "gerar_fala"),
81
+ ("music", "gerar_musica"), ("video", "gerar_video")):
82
+ _btn = gr.Button(visible=False)
83
+ _btn.click(_fazer_endpoint(_tipo), inputs=[segredo_in, params_in],
84
+ outputs=[saida], api_name=_nome)
85
+
86
+ _btn_saude = gr.Button(visible=False)
87
+ _btn_saude.click(_saude, inputs=[segredo_in], outputs=[saida],
88
+ api_name="saude")
89
+
90
+ if __name__ == "__main__":
91
+ # Fila do Gradio LIGADA com concorrência 1: no ZeroGPU cada chamada é uma
92
+ # alocação de GPU, e duas em paralelo é OOM ou quota dobrada — a mesma
93
+ # decisão do MAX_CONCURRENT_JOBS=1 do worker CUDA, no dialeto do Gradio.
94
+ demo.queue(default_concurrency_limit=1, max_size=16)
95
+ demo.launch(show_api=False)
armazenamento.py ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ O STORAGE DO LADO DO WORKER.
4
+
5
+ O vídeo pronto tem dezenas de MB, e o caminho dele até a tela decide tudo:
6
+ com S3 configurado, o worker sobe o arquivo e devolve SÓ a URL — o backend e
7
+ o Firestore nunca carregam os bytes. Sem S3, volta base64 (funciona, mas
8
+ esbarra no teto de 1 MB do documento; o job store do backend já sabe marcar
9
+ `resultado_grande`). O DEPLOYMENT.md manda configurar S3 para vídeo, e este
10
+ comentário é o porquê.
11
+
12
+ Cópia deliberadamente PEQUENA do armazenamento do backend, e não um import
13
+ dele: os dois serviços têm deploys separados, e um worker que importa código
14
+ do backend acopla os ciclos de vida — a primeira mudança lá derruba cá.
15
+ O contrato compartilhado é o FORMATO da resposta ({url} ou {base64}), que é
16
+ minúsculo e está testado dos dois lados.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import base64
21
+ import logging
22
+ import os
23
+ import secrets
24
+ import time
25
+
26
+ logger = logging.getLogger("worker.armazenamento")
27
+
28
+ _EXTENSAO = {"image/png": ".png", "image/webp": ".webp", "image/jpeg": ".jpg",
29
+ "audio/wav": ".wav", "audio/mpeg": ".mp3", "video/mp4": ".mp4"}
30
+
31
+
32
+ def _s3_configurado() -> bool:
33
+ return bool(os.getenv("STORAGE_ENDPOINT", "").strip()
34
+ and os.getenv("STORAGE_BUCKET", "").strip())
35
+
36
+
37
+ def como_url_ou_base64(conteudo: bytes, mime: str) -> dict:
38
+ if _s3_configurado():
39
+ try:
40
+ import boto3 # type: ignore
41
+ nome = (f"ger/{time.strftime('%Y%m')}/"
42
+ f"{secrets.token_urlsafe(16)}{_EXTENSAO.get(mime, '.bin')}")
43
+ cliente = boto3.client(
44
+ "s3", endpoint_url=os.getenv("STORAGE_ENDPOINT"),
45
+ aws_access_key_id=os.getenv("STORAGE_ACCESS_KEY", ""),
46
+ aws_secret_access_key=os.getenv("STORAGE_SECRET_KEY", ""),
47
+ region_name=os.getenv("STORAGE_REGION", "auto"))
48
+ cliente.put_object(Bucket=os.getenv("STORAGE_BUCKET"), Key=nome,
49
+ Body=conteudo, ContentType=mime)
50
+ base_url = os.getenv("STORAGE_URL_PUBLICA", "").strip().rstrip("/")
51
+ if base_url:
52
+ return {"url": f"{base_url}/{nome}", "mime": mime}
53
+ url = cliente.generate_presigned_url(
54
+ "get_object",
55
+ Params={"Bucket": os.getenv("STORAGE_BUCKET"), "Key": nome},
56
+ ExpiresIn=int(os.getenv("STORAGE_LINK_VALIDADE", "604800")))
57
+ return {"url": url, "mime": mime}
58
+ except Exception as e:
59
+ # Storage caído não joga fora uma geração que custou GPU: cai
60
+ # para o base64 e registra — a criação chega, só que pesada.
61
+ logger.warning("S3 falhou (%s); devolvendo embutido", str(e)[:120])
62
+ return {"base64": "data:%s;base64,%s" % (mime,
63
+ base64.b64encode(conteudo).decode()),
64
+ "mime": mime}
modelos/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """Os modelos do worker. Import tardio SEMPRE — o worker sobe e responde
3
+ /health mesmo sem torch instalado; o peso só entra quando um job pede."""
modelos/__pycache__/__init__.cpython-311.pyc ADDED
Binary file (402 Bytes). View file
 
modelos/__pycache__/ace_step.cpython-311.pyc ADDED
Binary file (5.35 kB). View file
 
modelos/__pycache__/base.cpython-311.pyc ADDED
Binary file (5.91 kB). View file
 
modelos/__pycache__/qwen3_tts.cpython-311.pyc ADDED
Binary file (7.91 kB). View file
 
modelos/__pycache__/wan22.cpython-311.pyc ADDED
Binary file (8.83 kB). View file
 
modelos/__pycache__/z_image.cpython-311.pyc ADDED
Binary file (6.54 kB). View file
 
modelos/ace_step.py ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ MÚSICA — ACE-Step 1.5, rodando na GPU do worker.
4
+
5
+ É a primeira vez que este app tem MÚSICA de verdade sem provedor pago: a
6
+ trilha do `midia_livre` é síntese de acordes (e a tela sempre disse isso);
7
+ o ACE-Step compõe — com letra, inclusive. Quando o worker está de pé, é ele
8
+ quem responde "music"; quando dorme, a trilha sintetizada volta a valer, e a
9
+ diferença de qualidade é o preço anunciado da GPU gratuita.
10
+
11
+ `lyrics` aceita o formato de seções do projeto ([Verse]/[Chorus]); sem letra
12
+ vai "[instrumental]" — mandar string vazia faz alguns checkpoints alucinarem
13
+ voz. A chamada usa o pacote `acestep` (pip) e a API de pipeline dele; como no
14
+ TTS, a chamada exata deve ser conferida no primeiro deploy com GPU (o
15
+ MODELS.md marca), porque este ambiente não alcança os pesos.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import logging
20
+ import os
21
+ import tempfile
22
+ from typing import Dict, Optional
23
+
24
+ from . import base
25
+
26
+ logger = logging.getLogger("worker.ace_step")
27
+
28
+ VRAM_MINIMA_GB = 10
29
+
30
+ _pipe = None
31
+ _modelo_carregado = ""
32
+
33
+
34
+ def _modelo() -> str:
35
+ return os.getenv("MUSIC_MODEL", "ACE-Step/ACE-Step-v1.5").strip()
36
+
37
+
38
+ def carregar(progresso: base.Progresso) -> None:
39
+ global _pipe, _modelo_carregado
40
+ if base.modo_mock():
41
+ _pipe = "mock"
42
+ return
43
+ if _pipe is not None and _modelo_carregado == _modelo():
44
+ return
45
+ progresso(None, "Carregando o modelo de música…")
46
+ from acestep.pipeline_ace_step import ACEStepPipeline
47
+ _pipe = ACEStepPipeline(checkpoint_dir=os.getenv("ACE_CHECKPOINT_DIR")
48
+ or None, dtype="bfloat16")
49
+ _modelo_carregado = _modelo()
50
+
51
+
52
+ def gerar(params: Dict, progresso: base.Progresso) -> base.Saida:
53
+ if base.modo_mock():
54
+ return base.wav_silencio(0.5), "audio/wav"
55
+
56
+ prompt = (params.get("prompt") or "").strip()
57
+ estilo = (params.get("style") or "").strip()
58
+ if estilo:
59
+ prompt = f"{estilo}, {prompt}"
60
+ letra = (params.get("lyrics") or "").strip() or "[instrumental]"
61
+ # Duração com teto: cada segundo é tempo de GPU, e um pedido de "3600"
62
+ # vindo de um cliente forjado não pode virar uma hora de placa ocupada.
63
+ duracao = max(10, min(int(params.get("duration") or 60),
64
+ int(os.getenv("MUSIC_MAX_SEGUNDOS", "240"))))
65
+
66
+ progresso(5, "Compondo…")
67
+ with tempfile.TemporaryDirectory() as pasta:
68
+ saida = os.path.join(pasta, "musica.wav")
69
+ _pipe(prompt=prompt, lyrics=letra, audio_duration=duracao,
70
+ save_path=saida)
71
+ progresso(95, "Finalizando…")
72
+ with open(saida, "rb") as f:
73
+ return f.read(), "audio/wav"
74
+
75
+
76
+ def descarregar() -> None:
77
+ global _pipe
78
+ _pipe = None
79
+ base.limpar_vram()
80
+
81
+
82
+ def config_menor(params: Dict) -> Optional[Dict]:
83
+ p = dict(params)
84
+ dur = int(p.get("duration") or 60)
85
+ if dur > 30:
86
+ p["duration"] = 30
87
+ return p
88
+ return None
modelos/base.py ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ O CONTRATO DE UM MODELO DENTRO DO WORKER.
4
+
5
+ Cada modelo é um módulo com quatro deveres, e o gerenciador só conversa por
6
+ eles:
7
+
8
+ carregar() — traz os pesos para a GPU. Import de torch/diffusers é
9
+ feito AQUI DENTRO, nunca no topo do arquivo: o worker
10
+ precisa subir e responder /health mesmo numa máquina
11
+ sem as bibliotecas pesadas instaladas.
12
+ gerar(params, progresso) — produz bytes + mime. `progresso(pct, etapa)` é
13
+ chamado quando o modelo sabe onde está (a difusão sabe
14
+ o passo; TTS muitas vezes não — aí não chama).
15
+ descarregar() — devolve a VRAM. É o gerenciador que decide QUANDO.
16
+ config_menor(params) — a versão do pedido que cabe em menos VRAM (menos
17
+ passos, resolução menor, duração menor). Devolve None
18
+ quando não há o que reduzir — aí o OOM vira falha limpa
19
+ em vez de tentativa infinita.
20
+
21
+ E um modo de MENTIRA declarada: com WORKER_MOCK=1 os modelos devolvem
22
+ arquivos minúsculos válidos (PNG 1x1, WAV de silêncio, MP4 de um quadro) sem
23
+ tocar em GPU nenhuma. Ele existe para os testes automatizados e para subir a
24
+ pilha inteira num notebook sem CUDA — e o resultado DIZ que é mock no
25
+ metadado, porque fingir inferência real é o tipo de mentira que este projeto
26
+ não conta.
27
+ """
28
+ from __future__ import annotations
29
+
30
+ import os
31
+ import struct
32
+ from typing import Callable, Dict, Optional, Tuple
33
+
34
+ Progresso = Callable[[Optional[int], str], None]
35
+
36
+ # bytes, mime
37
+ Saida = Tuple[bytes, str]
38
+
39
+
40
+ def modo_mock() -> bool:
41
+ return os.getenv("WORKER_MOCK", "").strip() == "1"
42
+
43
+
44
+ # ---------------------------------------------------------------------------
45
+ # Os arquivos mínimos do modo mock — válidos de verdade (abrem em qualquer
46
+ # visualizador), só que vazios de conteúdo.
47
+ # ---------------------------------------------------------------------------
48
+ PNG_1X1 = bytes.fromhex(
49
+ "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c489"
50
+ "0000000d4944415478da63fcffff3f0300050201f34d31b20000000049454e44ae426082")
51
+
52
+
53
+ def wav_silencio(segundos: float = 0.2, taxa: int = 16000) -> bytes:
54
+ n = int(segundos * taxa)
55
+ dados = b"\x00\x00" * n
56
+ return (b"RIFF" + struct.pack("<I", 36 + len(dados)) + b"WAVEfmt "
57
+ + struct.pack("<IHHIIHH", 16, 1, 1, taxa, taxa * 2, 2, 16)
58
+ + b"data" + struct.pack("<I", len(dados)) + dados)
59
+
60
+
61
+ def vram_livre_gb() -> Optional[float]:
62
+ """Quanta VRAM sobra agora. None = sem CUDA (aí ninguém decide por VRAM)."""
63
+ try:
64
+ import torch
65
+ if not torch.cuda.is_available():
66
+ return None
67
+ livre, _total = torch.cuda.mem_get_info()
68
+ return livre / (1024 ** 3)
69
+ except Exception:
70
+ return None
71
+
72
+
73
+ def limpar_vram() -> None:
74
+ """Solta o que der: cache de CUDA e lixo do Python. Chamado pelo
75
+ gerenciador depois de descarregar e depois de um OOM — nas duas horas em
76
+ que 'quase liberado' e 'liberado' são a diferença entre caber e não."""
77
+ import gc
78
+ gc.collect()
79
+ try:
80
+ import torch
81
+ if torch.cuda.is_available():
82
+ torch.cuda.empty_cache()
83
+ torch.cuda.ipc_collect()
84
+ except Exception:
85
+ pass
86
+
87
+
88
+ def dispositivo() -> str:
89
+ try:
90
+ import torch
91
+ return "cuda" if torch.cuda.is_available() else "cpu"
92
+ except Exception:
93
+ return "cpu"
94
+
95
+
96
+ def semente(params: Dict):
97
+ """torch.Generator com a seed pedida — ou None para o aleatório de
98
+ sempre. Seed existe para 'gera de novo IGUAL', então ela é respeitada em
99
+ todo modelo que aceita generator."""
100
+ seed = params.get("seed")
101
+ if seed in (None, ""):
102
+ return None
103
+ try:
104
+ import torch
105
+ return torch.Generator(device=dispositivo()).manual_seed(int(seed))
106
+ except Exception:
107
+ return None
modelos/ltx.py ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ VÍDEO (FUTURO) — LTX-2.3: o lugar dele já existe, o modelo ainda não entrou.
4
+
5
+ A arquitetura pedida deixa o LTX "pronto para": este módulo cumpre o contrato
6
+ (carregar/gerar/descarregar/config_menor) e está REGISTRADO no gerenciador,
7
+ mas desligado — `disponivel()` responde False até alguém implementar o
8
+ `gerar` de verdade e ligar `LTX_ATIVO=1`.
9
+
10
+ Por que um arquivo que recusa, em vez de arquivo nenhum: quando o LTX entrar,
11
+ quem o implementa preenche DUAS funções aqui dentro e troca VIDEO_MODEL — sem
12
+ tocar no gerenciador, no app.py, no backend nem no frontend. E até lá,
13
+ qualquer pedido que aponte para ele falha ALTO com esta explicação, em vez de
14
+ falhar baixo com um ImportError misterioso.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import os
19
+ from typing import Dict, Optional
20
+
21
+ from . import base
22
+
23
+ VRAM_MINIMA_GB = 20
24
+
25
+
26
+ def disponivel() -> bool:
27
+ return os.getenv("LTX_ATIVO", "").strip() == "1"
28
+
29
+
30
+ def carregar(progresso: base.Progresso) -> None:
31
+ if base.modo_mock():
32
+ return
33
+ raise NotImplementedError(
34
+ "LTX-2.3 ainda não foi implementado neste worker. O lugar dele é "
35
+ "este arquivo (services/gpu-worker/modelos/ltx.py): preencha "
36
+ "carregar()/gerar() com o pipeline do LTX, ligue LTX_ATIVO=1 e "
37
+ "aponte VIDEO_MODEL para o checkpoint. Nada mais precisa mudar.")
38
+
39
+
40
+ def gerar(params: Dict, progresso: base.Progresso) -> base.Saida:
41
+ if base.modo_mock():
42
+ return b"", "video/mp4"
43
+ raise NotImplementedError("ver carregar()")
44
+
45
+
46
+ def descarregar() -> None:
47
+ base.limpar_vram()
48
+
49
+
50
+ def config_menor(params: Dict) -> Optional[Dict]:
51
+ return None
modelos/qwen3_tts.py ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ NARRAÇÃO — Qwen3-TTS (variantes 0.6B / 1.7B), rodando na GPU do worker.
4
+
5
+ O contrato de fora é o de sempre: texto entra, WAV sai. `voice` escolhe um
6
+ timbre nomeado; `voice_reference` (um WAV curto em base64) liga o VOICE
7
+ CLONING quando o modelo o suporta — o campo já existe no contrato para a
8
+ tela não precisar de rota nova no dia em que for oferecido.
9
+
10
+ HONESTIDADE DE CARGA: a chamada exata de geração (nomes de método e de
11
+ parâmetro) tem de ser conferida contra o model card da versão baixada — o
12
+ projeto Qwen troca detalhes entre releases, e este ambiente de desenvolvimento
13
+ não alcança os pesos para provar a chamada. O que está aqui segue a API
14
+ publicada do projeto; o MODELS.md marca este arquivo como "validar no primeiro
15
+ deploy com GPU". Errar isso quebra ALTO (exceção no primeiro uso), não baixo —
16
+ que é o jeito certo de errar.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import base64 as b64
21
+ import io
22
+ import logging
23
+ import os
24
+ from typing import Dict, Optional
25
+
26
+ from . import base
27
+
28
+ logger = logging.getLogger("worker.qwen3_tts")
29
+
30
+ VRAM_MINIMA_GB = 4
31
+
32
+ _modelo_obj = None
33
+ _modelo_carregado = ""
34
+
35
+ # Timbres nomeados → os "speakers" do modelo. A tela mostra nomes nossos
36
+ # (regra da casa: nome de provedor não chega à tela); a tradução mora aqui.
37
+ _VOZES = {"default": "Vivian", "feminina": "Vivian", "masculina": "Ethan"}
38
+
39
+
40
+ def _modelo() -> str:
41
+ return os.getenv("TTS_MODEL", "Qwen/Qwen3-TTS-1.7B").strip()
42
+
43
+
44
+ def carregar(progresso: base.Progresso) -> None:
45
+ global _modelo_obj, _modelo_carregado
46
+ if base.modo_mock():
47
+ _modelo_obj = "mock"
48
+ return
49
+ if _modelo_obj is not None and _modelo_carregado == _modelo():
50
+ return
51
+ progresso(None, "Carregando o modelo de voz…")
52
+ import torch
53
+ from transformers import AutoModel, AutoProcessor
54
+ # trust_remote_code: o Qwen3-TTS traz o código de inferência junto dos
55
+ # pesos. É código de terceiro rodando NO WORKER — que não tem segredo
56
+ # nenhum do app (ver GPU_WORKER.md); ainda assim, fixe a revisão em
57
+ # produção (TTS_MODEL_REV) para o código não trocar sob os seus pés.
58
+ rev = os.getenv("TTS_MODEL_REV") or None
59
+ _modelo_obj = {
60
+ "proc": AutoProcessor.from_pretrained(_modelo(), revision=rev,
61
+ trust_remote_code=True,
62
+ token=os.getenv("HF_TOKEN") or None),
63
+ "model": AutoModel.from_pretrained(_modelo(), revision=rev,
64
+ torch_dtype=torch.bfloat16,
65
+ trust_remote_code=True,
66
+ token=os.getenv("HF_TOKEN") or None
67
+ ).to(base.dispositivo()).eval(),
68
+ }
69
+ _modelo_carregado = _modelo()
70
+
71
+
72
+ def gerar(params: Dict, progresso: base.Progresso) -> base.Saida:
73
+ if base.modo_mock():
74
+ return base.wav_silencio(), "audio/wav"
75
+
76
+ texto = (params.get("text") or "").strip()
77
+ if not texto:
78
+ raise ValueError("nada para narrar")
79
+ voz = _VOZES.get((params.get("voice") or "default").lower(),
80
+ params.get("voice") or "Vivian")
81
+ idioma = params.get("language") or "pt-BR"
82
+
83
+ progresso(None, "Gerando a narração…")
84
+ modelo, proc = _modelo_obj["model"], _modelo_obj["proc"]
85
+
86
+ referencia = None
87
+ if params.get("voice_reference"):
88
+ # Voice cloning: o WAV de referência chega em base64 pelo backend.
89
+ referencia = io.BytesIO(b64.b64decode(
90
+ params["voice_reference"].split(",")[-1]))
91
+
92
+ import torch
93
+ with torch.no_grad():
94
+ if referencia is not None and hasattr(modelo, "clone"):
95
+ onda, taxa = modelo.clone(text=texto, reference=referencia,
96
+ language=idioma)
97
+ else:
98
+ entradas = proc(text=texto, speaker=voz, language=idioma,
99
+ return_tensors="pt").to(base.dispositivo())
100
+ saida = modelo.generate(**entradas)
101
+ onda = saida.audio if hasattr(saida, "audio") else saida[0]
102
+ taxa = getattr(saida, "sampling_rate", 24000)
103
+
104
+ return _wav_de(onda, taxa), "audio/wav"
105
+
106
+
107
+ def descarregar() -> None:
108
+ global _modelo_obj
109
+ _modelo_obj = None
110
+ base.limpar_vram()
111
+
112
+
113
+ def config_menor(params: Dict) -> Optional[Dict]:
114
+ # TTS de 1.7B não tem botão de resolução; o corte honesto é o TEXTO.
115
+ p = dict(params)
116
+ t = p.get("text") or ""
117
+ if len(t) > 600:
118
+ p["text"] = t[:600]
119
+ return p
120
+ return None
121
+
122
+
123
+ def _wav_de(onda, taxa: int) -> bytes:
124
+ """Tensor/array → WAV 16-bit. Sem soundfile: struct resolve e é uma
125
+ dependência a menos na imagem."""
126
+ import struct
127
+ try:
128
+ dados = onda.detach().float().cpu().numpy()
129
+ except AttributeError:
130
+ dados = onda
131
+ dados = dados.reshape(-1)
132
+ inteiros = (dados.clip(-1, 1) * 32767).astype("<i2").tobytes()
133
+ return (b"RIFF" + struct.pack("<I", 36 + len(inteiros)) + b"WAVEfmt "
134
+ + struct.pack("<IHHIIHH", 16, 1, 1, int(taxa), int(taxa) * 2, 2, 16)
135
+ + b"data" + struct.pack("<I", len(inteiros)) + inteiros)
modelos/wan22.py ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ VÍDEO — Wan 2.2 (TI2V 5B), texto-para-vídeo e imagem-para-vídeo.
4
+
5
+ A variante 5B é a escolhida de propósito: é a que o próprio projeto publica
6
+ como "cabe em uma GPU de consumidor" (24 GB com offload). As variantes A14B
7
+ são melhores e NÃO cabem no worker que este projeto consegue pagar — quem
8
+ tiver um A100 sobrando troca o VIDEO_MODEL e nada mais.
9
+
10
+ As duas rotas do backend caem AQUI: `image_url` presente = image-to-video
11
+ (o primeiro quadro é a imagem da pessoa); ausente = text-to-video. Um módulo
12
+ só, porque é o mesmo pipeline com um argumento a mais — dois módulos seriam
13
+ duas cópias do mesmo offload para divergir.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import base64 as b64
18
+ import io
19
+ import logging
20
+ import os
21
+ import tempfile
22
+ from typing import Dict, Optional
23
+
24
+ from . import base
25
+
26
+ logger = logging.getLogger("worker.wan22")
27
+
28
+ VRAM_MINIMA_GB = 16
29
+
30
+ _pipe = None
31
+ _modelo_carregado = ""
32
+
33
+
34
+ def _modelo() -> str:
35
+ return os.getenv("VIDEO_MODEL", "Wan-AI/Wan2.2-TI2V-5B-Diffusers").strip()
36
+
37
+
38
+ def carregar(progresso: base.Progresso) -> None:
39
+ global _pipe, _modelo_carregado
40
+ if base.modo_mock():
41
+ _pipe = "mock"
42
+ return
43
+ if _pipe is not None and _modelo_carregado == _modelo():
44
+ return
45
+ progresso(None, "Carregando o modelo de vídeo…")
46
+ import torch
47
+ from diffusers import WanPipeline
48
+ _pipe = WanPipeline.from_pretrained(_modelo(), torch_dtype=torch.bfloat16,
49
+ token=os.getenv("HF_TOKEN") or None)
50
+ # Vídeo é o maior dos quatro: offload SEMPRE, mesmo com VRAM folgada —
51
+ # o pico do VAE de vídeo no decode é traiçoeiro e é onde o OOM mora.
52
+ _pipe.enable_model_cpu_offload()
53
+ _modelo_carregado = _modelo()
54
+
55
+
56
+ def gerar(params: Dict, progresso: base.Progresso) -> base.Saida:
57
+ if base.modo_mock():
58
+ return _mp4_mock(), "video/mp4"
59
+
60
+ prompt = (params.get("prompt") or "").strip()
61
+ estilo = (params.get("style") or "").strip()
62
+ if estilo:
63
+ prompt = f"{prompt}, {estilo} style"
64
+ largura = int(params.get("width") or 1280)
65
+ altura = int(params.get("height") or 704)
66
+ segundos = max(2, min(int(params.get("duration") or 5),
67
+ int(os.getenv("VIDEO_MAX_SEGUNDOS", "8"))))
68
+ fps = 24
69
+ quadros = segundos * fps + 1
70
+ passos = int(params.get("steps") or 30)
71
+
72
+ imagem = None
73
+ if params.get("image_url"):
74
+ imagem = _abrir_imagem(params["image_url"], largura, altura)
75
+
76
+ def a_cada_passo(pipe, passo, t, kw):
77
+ progresso(int(95 * (passo + 1) / max(1, passos)), "Gerando o vídeo…")
78
+ return kw
79
+
80
+ extras = {"image": imagem} if imagem is not None else {}
81
+ saida = _pipe(prompt=prompt,
82
+ negative_prompt=params.get("negative_prompt") or None,
83
+ width=largura, height=altura, num_frames=quadros,
84
+ num_inference_steps=passos,
85
+ generator=base.semente(params),
86
+ callback_on_step_end=a_cada_passo, **extras)
87
+
88
+ progresso(97, "Codificando o MP4…")
89
+ from diffusers.utils import export_to_video
90
+ with tempfile.TemporaryDirectory() as pasta:
91
+ caminho = os.path.join(pasta, "video.mp4")
92
+ export_to_video(saida.frames[0], caminho, fps=fps)
93
+ with open(caminho, "rb") as f:
94
+ return f.read(), "video/mp4"
95
+
96
+
97
+ def descarregar() -> None:
98
+ global _pipe
99
+ _pipe = None
100
+ base.limpar_vram()
101
+
102
+
103
+ def config_menor(params: Dict) -> Optional[Dict]:
104
+ """OOM no vídeo: primeiro menos pixels, depois menos segundos. É a ordem
105
+ que preserva o que a pessoa pediu — um vídeo menor da cena inteira vale
106
+ mais que meio vídeo em alta."""
107
+ p = dict(params)
108
+ w = int(p.get("width") or 1280)
109
+ if w > 832:
110
+ p["width"], p["height"] = 832, 480
111
+ return p
112
+ dur = int(p.get("duration") or 5)
113
+ if dur > 3:
114
+ p["duration"] = 3
115
+ return p
116
+ return None
117
+
118
+
119
+ def _abrir_imagem(ref: str, largura: int, altura: int):
120
+ """A referência chega como data URL (do app) ou como endereço http. O
121
+ endereço http só é aceito vindo do BACKEND — e o backend já o passou pelo
122
+ `endereco_seguro` dele; ainda assim o download daqui tem teto de tamanho,
123
+ porque worker também não baixa gigabyte de ninguém."""
124
+ from PIL import Image
125
+ if ref.startswith("data:"):
126
+ dados = b64.b64decode(ref.split(",", 1)[1])
127
+ else:
128
+ import requests
129
+ r = requests.get(ref, timeout=30, stream=True)
130
+ r.raise_for_status()
131
+ dados = r.raw.read(20 * 1024 * 1024 + 1)
132
+ if len(dados) > 20 * 1024 * 1024:
133
+ raise ValueError("imagem de referência grande demais")
134
+ img = Image.open(io.BytesIO(dados)).convert("RGB")
135
+ return img.resize((largura, altura))
136
+
137
+
138
+ def _mp4_mock() -> bytes:
139
+ # Um MP4 mínimo (só o ftyp) — suficiente para os testes conferirem mime e
140
+ # transporte. O modo mock nunca finge ser vídeo de verdade: o metadado do
141
+ # job diz "mock".
142
+ return bytes.fromhex("000000186674797069736f6d0000020069736f6d69736f32")
modelos/z_image.py ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ IMAGEM — Z-Image / Z-Image-Turbo (Tongyi-MAI), via diffusers.
4
+
5
+ Por que ele: é open-weight, entende prompt em duas línguas e a variante Turbo
6
+ resolve em ~8 passos — numa GPU de 24 GB isso é imagem em poucos segundos, que
7
+ é o que o Estúdio precisa. O id exato vem do ambiente (IMAGE_MODEL); este
8
+ arquivo não prende ninguém a uma versão.
9
+
10
+ O pipeline fica em bfloat16 e com `enable_model_cpu_offload` quando a VRAM é
11
+ curta: offload troca velocidade por caber, e caber ganha — imagem lenta ainda
12
+ é imagem; OOM é nada.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import io
17
+ import logging
18
+ import os
19
+ from typing import Dict, Optional
20
+
21
+ from . import base
22
+
23
+ logger = logging.getLogger("worker.z_image")
24
+
25
+ VRAM_MINIMA_GB = 8 # com offload; folgado ele usa mais e voa
26
+
27
+ _pipe = None
28
+ _modelo_carregado = ""
29
+
30
+
31
+ def _modelo() -> str:
32
+ return os.getenv("IMAGE_MODEL", "Tongyi-MAI/Z-Image-Turbo").strip()
33
+
34
+
35
+ def carregar(progresso: base.Progresso) -> None:
36
+ global _pipe, _modelo_carregado
37
+ if base.modo_mock():
38
+ _pipe = "mock"
39
+ return
40
+ if _pipe is not None and _modelo_carregado == _modelo():
41
+ return
42
+ progresso(None, "Carregando o modelo de imagem…")
43
+ import torch
44
+ from diffusers import DiffusionPipeline
45
+ _pipe = DiffusionPipeline.from_pretrained(
46
+ _modelo(), torch_dtype=torch.bfloat16,
47
+ token=os.getenv("HF_TOKEN") or None)
48
+ livre = base.vram_livre_gb()
49
+ if livre is not None and livre < 20:
50
+ # Pouca VRAM: o offload deixa os blocos na RAM e sobe um por vez.
51
+ _pipe.enable_model_cpu_offload()
52
+ else:
53
+ _pipe.to(base.dispositivo())
54
+ _modelo_carregado = _modelo()
55
+
56
+
57
+ def gerar(params: Dict, progresso: base.Progresso) -> base.Saida:
58
+ if base.modo_mock():
59
+ return base.PNG_1X1, "image/png"
60
+
61
+ prompt = (params.get("prompt") or "").strip()
62
+ estilo = (params.get("style") or "").strip()
63
+ if estilo:
64
+ prompt = f"{prompt}, {estilo} style"
65
+ largura = int(params.get("width") or 1024)
66
+ altura = int(params.get("height") or 1024)
67
+ # A tela manda aspect_ratio; quem manda width/height explícito ganha.
68
+ if not params.get("width") and params.get("aspect_ratio"):
69
+ largura, altura = _do_aspecto(params["aspect_ratio"])
70
+ passos = int(params.get("steps") or 8) # Turbo: 8 é o ponto doce
71
+
72
+ def a_cada_passo(pipe, passo, t, kw):
73
+ progresso(int(100 * (passo + 1) / max(1, passos)), "Gerando…")
74
+ return kw
75
+
76
+ saida = _pipe(prompt,
77
+ negative_prompt=params.get("negative_prompt") or None,
78
+ width=largura, height=altura,
79
+ num_inference_steps=passos,
80
+ generator=base.semente(params),
81
+ callback_on_step_end=a_cada_passo)
82
+ img = saida.images[0]
83
+ buf = io.BytesIO()
84
+ img.save(buf, format="PNG")
85
+ return buf.getvalue(), "image/png"
86
+
87
+
88
+ def descarregar() -> None:
89
+ global _pipe
90
+ _pipe = None
91
+ base.limpar_vram()
92
+
93
+
94
+ def config_menor(params: Dict) -> Optional[Dict]:
95
+ """Depois de um OOM: menos pixels primeiro, depois menos passos. Abaixo
96
+ de 512px e 4 passos não há mais gordura — devolve None e o job falha
97
+ limpo dizendo o porquê."""
98
+ p = dict(params)
99
+ w = int(p.get("width") or 1024)
100
+ if w > 512:
101
+ p["width"] = max(512, w // 2)
102
+ p["height"] = max(512, int(p.get("height") or 1024) // 2)
103
+ return p
104
+ passos = int(p.get("steps") or 8)
105
+ if passos > 4:
106
+ p["steps"] = 4
107
+ return p
108
+ return None
109
+
110
+
111
+ def _do_aspecto(ratio: str):
112
+ tabela = {"1:1": (1024, 1024), "16:9": (1280, 720), "9:16": (720, 1280),
113
+ "4:3": (1152, 864), "3:4": (864, 1152)}
114
+ return tabela.get(str(ratio).strip(), (1024, 1024))
nucleo.py ADDED
@@ -0,0 +1,225 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ O NÚCLEO DO SPACE — tudo do adapter ZeroGPU que NÃO é Gradio.
4
+
5
+ Por que a separação: o Gradio e o `spaces` só existem DENTRO do Space, e a
6
+ lógica que importa (autenticação, duração de GPU por tipo, despacho para os
7
+ modelos, tradução dos erros de quota) precisa ser testável nesta casa, onde
8
+ nenhum dos dois está instalado. O `app.py` é só a casca que liga isto ao
9
+ Gradio; este arquivo é o que se mede no `t_zerogpu.py`.
10
+
11
+ OS MODELOS NÃO SÃO REESCRITOS. Este arquivo importa os MESMOS módulos de
12
+ `services/gpu-worker/modelos/` (e o mesmo `armazenamento.py`): no repositório
13
+ ele os acha pelo caminho relativo; no Space publicado eles são enviados JUNTO
14
+ (o `publicar.sh` faz isso — uma cópia de DEPLOY, nunca uma segunda
15
+ implementação no git). Dois fontes dos mesmos modelos seria o defeito de
16
+ sempre: um conserto entra num e o outro fica para trás.
17
+
18
+ POR QUE ISTO NÃO É O WORKER FASTAPI NUM SPACE: o ZeroGPU não é uma VM CUDA.
19
+ A GPU só existe DENTRO de uma chamada decorada com `@spaces.GPU`, com prazo
20
+ declarado, e o Space precisa ser Gradio. Então a fila daqui é a do PRÓPRIO
21
+ Gradio (cada chamada é um job do backend, síncrona do lado de cá), e quem
22
+ continua dono do estado do job é o backend — este processo não guarda nada
23
+ que não possa evaporar junto com a GPU.
24
+ """
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ import logging
29
+ import os
30
+ import secrets
31
+ import sys
32
+ from typing import Callable, Dict, Iterator, Optional
33
+
34
+ logger = logging.getLogger("zerogpu.nucleo")
35
+
36
+ # ---------------------------------------------------------------------------
37
+ # Onde moram os modelos: no repo, na pasta irmã gpu-worker; no Space, ao lado
38
+ # (o publicar.sh os põe lá). A ordem tenta o Space primeiro — é onde roda de
39
+ # verdade.
40
+ # ---------------------------------------------------------------------------
41
+ _AQUI = os.path.dirname(os.path.abspath(__file__))
42
+ for _cand in (_AQUI, os.path.join(_AQUI, "..", "gpu-worker")):
43
+ if os.path.isdir(os.path.join(_cand, "modelos")):
44
+ if _cand not in sys.path:
45
+ sys.path.insert(0, _cand)
46
+ break
47
+
48
+ from modelos import base as mbase # noqa: E402
49
+ from modelos import ace_step, qwen3_tts, wan22, z_image # noqa: E402
50
+ import armazenamento # noqa: E402
51
+
52
+ _MODULOS = {"image": z_image, "speech": qwen3_tts, "voice": qwen3_tts,
53
+ "music": ace_step, "video": wan22}
54
+ _carregado: Optional[str] = None
55
+
56
+ # ---------------------------------------------------------------------------
57
+ # DURAÇÃO DE GPU POR TIPO — o prazo que o decorator @spaces.GPU declara.
58
+ # Configurável porque é a variável mais sensível do ZeroGPU: curto demais, a
59
+ # geração é abortada no meio (e a quota foi gasta mesmo assim); longo demais,
60
+ # cada chamada reserva mais quota do que usa. Os padrões saem dos tempos de
61
+ # catálogo dos modelos escolhidos, com folga para a carga do modelo frio.
62
+ # Este prazo é DA GPU, separado de propósito do timeout HTTP do backend
63
+ # (_ESPERA_MAX do adapter): o backend espera mais do que a GPU promete, para
64
+ # a falha vir sempre daqui, com o motivo certo, e nunca das duas pontas ao
65
+ # mesmo tempo.
66
+ # ---------------------------------------------------------------------------
67
+ _DURACAO_PADRAO = {"image": 60, "speech": 60, "voice": 60,
68
+ "music": 180, "video": 300}
69
+ _VAR_DURACAO = {"image": "IMAGE_GPU_SECONDS", "speech": "TTS_GPU_SECONDS",
70
+ "voice": "TTS_GPU_SECONDS", "music": "MUSIC_GPU_SECONDS",
71
+ "video": "VIDEO_GPU_SECONDS"}
72
+
73
+
74
+ def duracao_gpu(tipo: str) -> int:
75
+ try:
76
+ valor = int(os.getenv(_VAR_DURACAO.get(tipo, ""), "") or 0)
77
+ except ValueError:
78
+ valor = 0
79
+ return valor if valor > 0 else _DURACAO_PADRAO.get(tipo, 120)
80
+
81
+
82
+ # ---------------------------------------------------------------------------
83
+ # A PORTA. O endpoint de um Space é público por natureza — qualquer pessoa
84
+ # que achar a URL pode chamar a API do Gradio. Quem protege a quota é ISTO:
85
+ # o segredo é o primeiro argumento de toda chamada, conferido ANTES de a
86
+ # função de GPU ser invocada — chamada sem segredo morre aqui, sem alocar um
87
+ # segundo de ZeroGPU. Fail closed, como no worker CUDA: sem WORKER_SECRET
88
+ # configurado no Space, ninguém gera nada.
89
+ # ---------------------------------------------------------------------------
90
+ def autenticar(segredo: str) -> Optional[str]:
91
+ """None = pode passar; string = o motivo da recusa (sem eco do que veio)."""
92
+ esperado = os.getenv("WORKER_SECRET", "").strip()
93
+ if not esperado:
94
+ return ("WORKER_SECRET não configurado no Space — configure o secret "
95
+ "com o MESMO valor usado no backend.")
96
+ if not segredo or not secrets.compare_digest(str(segredo), esperado):
97
+ return "segredo inválido"
98
+ return None
99
+
100
+
101
+ # ---------------------------------------------------------------------------
102
+ # QUOTA É ESTADO. O `spaces` sinaliza quota esgotada/GPU indisponível com
103
+ # exceções próprias cuja MENSAGEM é estável ("GPU quota exceeded", "ZeroGPU");
104
+ # a classe muda entre versões do pacote, então o reconhecimento é pelo texto
105
+ # — e o resultado leva `erro_gpu`, que o backend traduz para
106
+ # GeracaoIndisponivel (degrada ou espera), nunca para falha do pedido.
107
+ # ---------------------------------------------------------------------------
108
+ _SINAIS_DE_QUOTA = ("quota", "zerogpu", "gpu task aborted", "no gpu",
109
+ "gpu unavailable", "queue is full")
110
+
111
+
112
+ def e_erro_de_gpu(e: BaseException) -> bool:
113
+ texto = f"{type(e).__name__}: {e}".lower()
114
+ return any(s in texto for s in _SINAIS_DE_QUOTA)
115
+
116
+
117
+ # ---------------------------------------------------------------------------
118
+ # A GERAÇÃO — o corpo que o app.py decora com @spaces.GPU.
119
+ #
120
+ # É um GERADOR: cada yield intermediário vira um evento `generating` no SSE
121
+ # do Gradio (o progresso que a tela do app mostra), e o último yield é o
122
+ # resultado. O contrato do dict é o que o adapter do backend lê:
123
+ # {"etapa","progress"} no meio
124
+ # {"resultado": {...}} no fim
125
+ # {"erro": ...} falha do pedido (não adianta tentar em outro lugar)
126
+ # {"erro_gpu": ...} GPU/quota indisponível (degradar ou esperar)
127
+ # ---------------------------------------------------------------------------
128
+ def gerar(tipo: str, params_json: str) -> Iterator[Dict]:
129
+ global _carregado
130
+ try:
131
+ params = json.loads(params_json or "{}")
132
+ if not isinstance(params, dict):
133
+ raise ValueError("params não é um objeto")
134
+ except ValueError as e:
135
+ yield {"erro": f"parâmetros ilegíveis: {str(e)[:80]}"}
136
+ return
137
+
138
+ modulo = _MODULOS.get(tipo)
139
+ if not modulo:
140
+ yield {"erro": f"tipo desconhecido: {tipo}"}
141
+ return
142
+
143
+ progresso_atual: Dict = {}
144
+
145
+ def progresso(pct, etapa):
146
+ # O gerador não pode "yield" de dentro do callback; o valor fica
147
+ # anotado e sai no próximo passo do laço de fases abaixo.
148
+ if pct is not None:
149
+ progresso_atual["progress"] = max(0, min(100, int(pct)))
150
+ if etapa:
151
+ progresso_atual["etapa"] = etapa
152
+
153
+ yield {"etapa": "Carregando o modelo…", "progress": None}
154
+ tentativa_oom = 0
155
+ while True:
156
+ try:
157
+ # A troca de residente vale aqui também — na mesma chamada podem
158
+ # sobrar pesos da chamada anterior NA RAM (o processo do Space
159
+ # sobrevive entre alocações de GPU; a GPU é que evapora). Nunca
160
+ # dependa de estado NA GPU entre chamadas: os módulos usam
161
+ # cpu_offload, que sobe os blocos por passada — é exatamente o
162
+ # desenho que o ZeroGPU pede.
163
+ if _carregado and _carregado != tipo:
164
+ antigo = _MODULOS.get(_carregado)
165
+ if antigo:
166
+ try:
167
+ antigo.descarregar()
168
+ except Exception:
169
+ pass
170
+ mbase.limpar_vram()
171
+ _carregado = None
172
+ modulo.carregar(progresso)
173
+ _carregado = tipo
174
+
175
+ yield {"etapa": "Gerando…", "progress": progresso_atual.get("progress")}
176
+ saida, mime = modulo.gerar(params, progresso)
177
+
178
+ yield {"etapa": "Finalizando…", "progress": 97}
179
+ resultado = armazenamento.como_url_ou_base64(saida, mime)
180
+ resultado["mock"] = mbase.modo_mock()
181
+ _log(tipo, "completed")
182
+ yield {"resultado": resultado}
183
+ return
184
+ except BaseException as e: # BaseException: o abort do
185
+ if e_erro_de_gpu(e): # spaces nem sempre é Exception
186
+ _log(tipo, "gpu_indisponivel", e)
187
+ yield {"erro_gpu": f"ZeroGPU indisponível agora: {str(e)[:120]}"}
188
+ return
189
+ if "out of memory" in str(e).lower():
190
+ tentativa_oom += 1
191
+ mbase.limpar_vram()
192
+ menor = modulo.config_menor(params)
193
+ if menor is not None and tentativa_oom <= 2:
194
+ params = menor
195
+ yield {"etapa": "Memória curta: tentando uma versão menor…",
196
+ "progress": None}
197
+ continue
198
+ _log(tipo, "failed_oom", e)
199
+ yield {"erro": "a GPU não teve memória para este pedido"}
200
+ return
201
+ if isinstance(e, (KeyboardInterrupt, SystemExit)):
202
+ raise
203
+ _log(tipo, "failed", e)
204
+ yield {"erro": str(e)[:300]}
205
+ return
206
+
207
+
208
+ def saude() -> Dict:
209
+ """O retrato para o /api/health/gpu do backend. No ZeroGPU não há GPU
210
+ residente para medir — CUDA só existe dentro de uma chamada decorada — e
211
+ o retrato DIZ isso em vez de fingir um número."""
212
+ return {"worker_status": "ready", "runtime": "zerogpu",
213
+ "mock": mbase.modo_mock(),
214
+ "cuda": None, # None de propósito: "só se sabe dentro da chamada"
215
+ "duracoes": {t: duracao_gpu(t)
216
+ for t in ("image", "speech", "music", "video")},
217
+ "modelo_residente": _carregado}
218
+
219
+
220
+ def _log(tipo: str, status: str, erro: BaseException = None) -> None:
221
+ # O mesmo log estruturado do worker CUDA — e igualmente SEM o prompt:
222
+ # prompt é conteúdo da pessoa, e log de Space é ainda mais público que o
223
+ # nosso (aparece no painel do Hugging Face).
224
+ logger.info("geracao runtime=zerogpu tipo=%s status=%s%s", tipo, status,
225
+ f" erro={type(erro).__name__}" if erro else "")
requirements.txt ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # O Space ZeroGPU. `gradio` vem do SDK do Space (o front-matter do README o
2
+ # declara); `spaces` é injetado pelo próprio Hugging Face em hardware ZeroGPU.
3
+ # O torch NÃO se fixa aqui: no ZeroGPU a versão de torch/CUDA compatível é a
4
+ # do ambiente deles, e fixar a nossa por cima é como se quebra a alocação.
5
+ #
6
+ # O resto é a MESMA camada de inferência do worker CUDA
7
+ # (services/gpu-worker/requirements.txt) — mesmos modelos, mesmas versões,
8
+ # de propósito: dois pares de versões seriam dois comportamentos.
9
+ diffusers==0.32.2
10
+ transformers==4.48.0
11
+ accelerate==1.2.1
12
+ safetensors==0.5.2
13
+ Pillow==11.1.0
14
+ imageio==2.36.1
15
+ imageio-ffmpeg==0.6.0
16
+ acestep==1.5.0
17
+ boto3==1.35.90
18
+ requests==2.31.0