Spaces:
Sleeping
Sleeping
Worker ZeroGPU do Conecta Primo
Browse files- README.md +32 -6
- __pycache__/armazenamento.cpython-311.pyc +0 -0
- __pycache__/nucleo.cpython-311.pyc +0 -0
- app.py +95 -0
- armazenamento.py +64 -0
- modelos/__init__.py +3 -0
- modelos/__pycache__/__init__.cpython-311.pyc +0 -0
- modelos/__pycache__/ace_step.cpython-311.pyc +0 -0
- modelos/__pycache__/base.cpython-311.pyc +0 -0
- modelos/__pycache__/qwen3_tts.cpython-311.pyc +0 -0
- modelos/__pycache__/wan22.cpython-311.pyc +0 -0
- modelos/__pycache__/z_image.cpython-311.pyc +0 -0
- modelos/ace_step.py +88 -0
- modelos/base.py +107 -0
- modelos/ltx.py +51 -0
- modelos/qwen3_tts.py +135 -0
- modelos/wan22.py +142 -0
- modelos/z_image.py +114 -0
- nucleo.py +225 -0
- requirements.txt +18 -0
README.md
CHANGED
|
@@ -1,13 +1,39 @@
|
|
| 1 |
---
|
| 2 |
-
title: Conecta Primo
|
| 3 |
-
emoji:
|
| 4 |
colorFrom: indigo
|
| 5 |
-
colorTo:
|
| 6 |
sdk: gradio
|
| 7 |
-
sdk_version:
|
| 8 |
-
python_version: '3.12'
|
| 9 |
app_file: app.py
|
| 10 |
pinned: false
|
|
|
|
| 11 |
---
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|