Spaces:
Running on Zero
Running on Zero
Download modelos/wan22.py from ConectaPrimoAI/conecta-primo-gpu: direct link, hf CLI and curl.
- Browser
- Download file 10.7 kB
-
https://huggingface.co/spaces/ConectaPrimoAI/conecta-primo-gpu/resolve/main/modelos/wan22.py
- Command line
-
hf download hf://spaces/ConectaPrimoAI/conecta-primo-gpu/modelos/wan22.py
-
curl -L -o wan22.py https://huggingface.co/spaces/ConectaPrimoAI/conecta-primo-gpu/resolve/main/modelos/wan22.py
10.7 kB
| # -*- coding: utf-8 -*- | |
| """ | |
| VÍDEO — Wan 2.2 (TI2V 5B), texto-para-vídeo e imagem-para-vídeo. | |
| A variante 5B é a escolhida de propósito: é a que o próprio projeto publica | |
| como "cabe em uma GPU de consumidor" (24 GB com offload). As variantes A14B | |
| são melhores e NÃO cabem no worker que este projeto consegue pagar — quem | |
| tiver um A100 sobrando troca o VIDEO_MODEL e nada mais. | |
| As duas rotas do backend caem AQUI: `image_url` presente = image-to-video | |
| (o primeiro quadro é a imagem da pessoa); ausente = text-to-video. Um módulo | |
| só, porque é o mesmo pipeline com um argumento a mais — dois módulos seriam | |
| duas cópias do mesmo offload para divergir. | |
| ── POR QUE O PADRÃO NÃO É 720P, E ISSO É CONTA E NÃO GOSTO ───────────────── | |
| O prazo de GPU desta casa é de 180 SEGUNDOS, e ele é o maior valor que o | |
| ZeroGPU gratuito aceita (ver CLAUDE.md: 240 declarados viraram "larger than | |
| the maximum allowed"). O cartão oficial deste modelo diz, com estas palavras, | |
| que ele gera "a 5-second 720P video in under 9 minutes on a single | |
| consumer-grade GPU" — 540 segundos, em 50 passos, a 1280x704. | |
| O prazo é DURO e não tem rede de segurança: `config_menor` só é chamado | |
| depois de um OOM, e estourar o TEMPO não é OOM — é a chamada abortada no | |
| meio, com a quota do dia gasta e nada saindo. Ou seja, o padrão de 720p era | |
| um vídeo que praticamente nunca terminava, e o sintoma disso não é um erro | |
| que aponte para cá: é a geração degradando para o gerador de reserva, como se | |
| a GPU estivesse dormindo. | |
| Então o padrão é 832x480 — a MESMA medida que o `config_menor` abaixo já | |
| escolhia como primeiro degrau, e portanto nenhuma resolução nova entrando no | |
| modelo por esta porta. São 2,3x menos pixels. Com `GPU_DEDICADA=1` volta o | |
| 1280x704, para o dia em que houver prazo de verdade. | |
| Vídeo 480p que sai é infinitamente melhor que 720p que aborta. | |
| ── E A PROPORÇÃO É DE QUEM PEDIU, não de quem escreveu este arquivo ────────── | |
| Relato do dono: "o vídeo sai sempre horizontal". A causa não era o modelo — o | |
| Wan gera vertical tão bem quanto horizontal —, era o `_payload_para` do | |
| backend escrevendo 1280x720 À MÃO em todo pedido e jogando fora a proporção | |
| que a pessoa tinha escolhido. Agora o payload leva `aspect_ratio`, e a tabela | |
| que a traduz em pixels mora AQUI, em `_medida`, num lugar só: os dois lados | |
| da tabela (o que cabe no prazo e o que a GPU dedicada aguenta) têm a MESMA | |
| contagem de pixels por linha, então trocar de proporção nunca troca de risco | |
| de estourar o relógio. Toda medida é múltiplo de 16 — o VAE do TI2V-5B | |
| comprime 16x16, e uma medida quebrada é erro de shape no meio da geração. | |
| """ | |
| from __future__ import annotations | |
| import base64 as b64 | |
| import io | |
| import logging | |
| import os | |
| import tempfile | |
| from typing import Dict, Optional | |
| from . import base | |
| logger = logging.getLogger("worker.wan22") | |
| VRAM_MINIMA_GB = 16 | |
| _pipe = None | |
| _modelo_carregado = "" | |
| def _modelo() -> str: | |
| return os.getenv("VIDEO_MODEL", "Wan-AI/Wan2.2-TI2V-5B-Diffusers").strip() | |
| def _quer_720p() -> bool: | |
| """O 720p nativo do cartão só com prazo de GPU de verdade. | |
| Quem responde é `base.gpu_dedicada()`, e não uma variável própria daqui: | |
| a resolução do vídeo e o número de passos da imagem nascem do MESMO fato | |
| (o teto de 180 s do ZeroGPU gratuito), e duas variáveis para um fato só | |
| é uma delas ficando para trás no dia da GPU dedicada. | |
| """ | |
| return base.gpu_dedicada() | |
| # Proporção -> (largura, altura). Duas tabelas com a MESMA área por linha (ver | |
| # o cabeçalho): ~400 mil pixels na que cabe no prazo, ~900 mil na dedicada. | |
| # Tudo múltiplo de 16. | |
| _MEDIDA_NO_PRAZO = {"16:9": (832, 480), "9:16": (480, 832), "1:1": (624, 624)} | |
| _MEDIDA_DEDICADA = {"16:9": (1280, 704), "9:16": (704, 1280), "1:1": (960, 960)} | |
| ASPECTO_PADRAO = "16:9" | |
| def _aspecto(params: Dict) -> str: | |
| a = str(params.get("aspect_ratio") or "").strip() | |
| return a if a in _MEDIDA_NO_PRAZO else ASPECTO_PADRAO | |
| def _medida(params: Dict) -> tuple: | |
| """A medida em vigor, num lugar SÓ. | |
| Ela é lida em dois lugares (o `gerar` e o `config_menor`), e o padrão | |
| escrito à mão nos dois é como um deles fica para trás: com o padrão antigo | |
| de 1280 no `config_menor`, uma geração já em 832x480 seria "rebaixada" | |
| para 832x480 de novo e a escada do OOM perderia um degrau sem dizer nada. | |
| Quem manda `width`/`height` explícitos ganha da proporção — é o caminho | |
| do `config_menor`, que já escolheu a medida do degrau. | |
| """ | |
| tabela = _MEDIDA_DEDICADA if _quer_720p() else _MEDIDA_NO_PRAZO | |
| padrao = tabela[_aspecto(params)] | |
| return (int(params.get("width") or padrao[0]), | |
| int(params.get("height") or padrao[1])) | |
| def baixar(progresso: base.Progresso) -> None: | |
| base.baixar_repo(_modelo(), progresso) | |
| def carregar(progresso: base.Progresso) -> None: | |
| global _pipe, _modelo_carregado | |
| if base.modo_mock(): | |
| _pipe = "mock" | |
| return | |
| if _pipe is not None and _modelo_carregado == _modelo(): | |
| return | |
| progresso(None, "Carregando o modelo de vídeo…") | |
| import torch | |
| from diffusers import WanPipeline | |
| _pipe = WanPipeline.from_pretrained(_modelo(), torch_dtype=torch.bfloat16, | |
| token=os.getenv("HF_TOKEN") or None) | |
| # O OFFLOAD DEIXOU DE SER INCONDICIONAL, e isto é a metade do parágrafo | |
| # do prazo lá em cima. Ele estava ligado SEMPRE, com o argumento de que | |
| # "o pico do VAE de vídeo no decode é traiçoeiro e é onde o OOM mora" — | |
| # e o argumento continua verdadeiro numa placa apertada. O que ele não | |
| # via é o preço: o offload sobe os blocos da RAM a CADA passo, e num | |
| # prazo de 180 s isso é a diferença entre terminar e ser abortado. | |
| # | |
| # A regra é a mesma que o `qwen_image.py` já usa, e por isso mora aqui do | |
| # mesmo jeito: quem decide é a VRAM que existe, não um palpite escrito à | |
| # mão. Com placa folgada (o H200 do ZeroGPU tem de sobra para um 5B em | |
| # bf16) o modelo vai inteiro para a GPU; com placa apertada o offload | |
| # entra como sempre entrou, e o `config_menor` continua sendo a rede de | |
| # segurança do OOM. | |
| livre = base.vram_livre_gb() | |
| if livre is not None and livre < 24: | |
| _pipe.enable_model_cpu_offload() | |
| else: | |
| _pipe.to(base.dispositivo()) | |
| _modelo_carregado = _modelo() | |
| def gerar(params: Dict, progresso: base.Progresso) -> base.Saida: | |
| if base.modo_mock(): | |
| return _mp4_mock(), "video/mp4" | |
| prompt = (params.get("prompt") or "").strip() | |
| estilo = (params.get("style") or "").strip() | |
| if estilo: | |
| prompt = f"{prompt}, {estilo} style" | |
| # Quem manda width/height explícito ganha; senão a PROPORÇÃO do pedido | |
| # escolhe na tabela, e a tabela é a que cabe no prazo (ver o cabeçalho). | |
| # `GPU_DEDICADA=1` troca para a tabela nativa do cartão. | |
| largura, altura = _medida(params) | |
| # FORA DA GPU DEDICADA, O CLIPE É CURTO E OS PASSOS SÃO MENOS. O prazo do | |
| # vídeo no ZeroGPU caiu para 80 s (ver `nucleo._DURACAO_PADRAO`: 180 | |
| # declarados eram recusados pela quota por chamador), e 5 s a 30 passos | |
| # não terminam nisso. 3 s a 20 passos é um vídeo DE VERDADE que termina — | |
| # um de 5 s que aborta não entrega nada. | |
| curto = not base.gpu_dedicada() | |
| segundos = max(2, min(int(params.get("duration") or (3 if curto else 5)), | |
| int(os.getenv("VIDEO_MAX_SEGUNDOS", "3" if curto else "8")))) | |
| fps = 24 | |
| quadros = segundos * fps + 1 | |
| passos = int(params.get("steps") or (20 if curto else 30)) | |
| imagem = None | |
| if params.get("image_url"): | |
| imagem = _abrir_imagem(params["image_url"], largura, altura) | |
| def a_cada_passo(pipe, passo, t, kw): | |
| progresso(int(95 * (passo + 1) / max(1, passos)), "Gerando o vídeo…") | |
| return kw | |
| extras = {"image": imagem} if imagem is not None else {} | |
| saida = _pipe(prompt=prompt, | |
| negative_prompt=params.get("negative_prompt") or None, | |
| width=largura, height=altura, num_frames=quadros, | |
| num_inference_steps=passos, | |
| generator=base.semente(params), | |
| callback_on_step_end=a_cada_passo, **extras) | |
| progresso(97, "Codificando o MP4…") | |
| from diffusers.utils import export_to_video | |
| with tempfile.TemporaryDirectory() as pasta: | |
| caminho = os.path.join(pasta, "video.mp4") | |
| export_to_video(saida.frames[0], caminho, fps=fps) | |
| with open(caminho, "rb") as f: | |
| return f.read(), "video/mp4" | |
| def descarregar() -> None: | |
| global _pipe | |
| _pipe = None | |
| base.limpar_vram() | |
| def config_menor(params: Dict) -> Optional[Dict]: | |
| """OOM no vídeo: primeiro menos pixels, depois menos segundos. É a ordem | |
| que preserva o que a pessoa pediu — um vídeo menor da cena inteira vale | |
| mais que meio vídeo em alta.""" | |
| p = dict(params) | |
| w, h = _medida(p) | |
| menor = _MEDIDA_NO_PRAZO[_aspecto(p)] | |
| # O degrau é a medida que cabe no prazo NA MESMA PROPORÇÃO: rebaixar um | |
| # vídeo vertical para 832x480 entregaria um horizontal a quem pediu | |
| # vertical — a proporção trocada por um OOM, sem erro em lugar nenhum. | |
| if w * h > menor[0] * menor[1]: | |
| p["width"], p["height"] = menor | |
| return p | |
| dur = int(p.get("duration") or 5) | |
| if dur > 3: | |
| p["duration"] = 3 | |
| return p | |
| return None | |
| def _abrir_imagem(ref: str, largura: int, altura: int): | |
| """A referência chega como data URL (do app) ou como endereço http. O | |
| endereço http só é aceito vindo do BACKEND — e o backend já o passou pelo | |
| `endereco_seguro` dele; ainda assim o download daqui tem teto de tamanho, | |
| porque worker também não baixa gigabyte de ninguém.""" | |
| from PIL import Image | |
| if ref.startswith("data:"): | |
| dados = b64.b64decode(ref.split(",", 1)[1]) | |
| else: | |
| import requests | |
| r = requests.get(ref, timeout=30, stream=True) | |
| r.raise_for_status() | |
| dados = r.raw.read(20 * 1024 * 1024 + 1) | |
| if len(dados) > 20 * 1024 * 1024: | |
| raise ValueError("imagem de referência grande demais") | |
| img = Image.open(io.BytesIO(dados)).convert("RGB") | |
| return img.resize((largura, altura)) | |
| def _mp4_mock() -> bytes: | |
| # Um MP4 mínimo (só o ftyp) — suficiente para os testes conferirem mime e | |
| # transporte. O modo mock nunca finge ser vídeo de verdade: o metadado do | |
| # job diz "mock". | |
| return bytes.fromhex("000000186674797069736f6d0000020069736f6d69736f32") | |