ConectaPrimoAI's picture
Upload folder using huggingface_hub
64e1ec4 verified
Raw History Blame Contribute Delete
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")