Spaces:
Running on Zero
Running on Zero
File size: 10,662 Bytes
c7f05a9 64e1ec4 c7f05a9 64e1ec4 550d35b c7f05a9 64e1ec4 c7f05a9 64e1ec4 c7f05a9 64e1ec4 c7f05a9 64e1ec4 c7f05a9 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 | # -*- 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")
|