Spaces:
Running on Zero
Running on Zero
File size: 7,832 Bytes
c7f05a9 64e1ec4 c7f05a9 64e1ec4 c7f05a9 64e1ec4 c7f05a9 64e1ec4 c7f05a9 64e1ec4 c7f05a9 64e1ec4 c7f05a9 64e1ec4 c7f05a9 64e1ec4 c7f05a9 64e1ec4 c7f05a9 64e1ec4 c7f05a9 64e1ec4 c7f05a9 64e1ec4 c7f05a9 64e1ec4 | 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 | # -*- coding: utf-8 -*-
"""
VÍDEO — a família LTX-2 da Lightricks (LTX-2, LTX-2.3, LTX-2.5), via diffusers.
Pedido do dono: "quero um modelo de vídeo muito bom". Este é o caminho da
qualidade; o Wan 2.2 continua sendo o caminho do custo zero, e qual dos dois
responde é o `VIDEO_MODEL` — o `escolha.py` manda pela FAMÍLIA do id.
── O QUE O LEVANTAMENTO MOSTROU (Hub, 2026-09-15) ──────────────────────────
Perguntei ao Hub quem lidera image-to-video em diffusers, em vez de escolher
de memória. A ordem é: `Lightricks/LTX-2.5` (1,58 milhão de downloads, 3.932
curtidas), `Lightricks/LTX-2.3` (1,12 milhão), `Lightricks/LTX-Video` (762
mil). O Wan 2.2 TI2V-5B que está publicado aqui aparece com 172 mil. Não é
empate: a família LTX-2 é a escolha da comunidade por uma ordem de grandeza.
E ela NÃO é de graça, e este é o ponto que decide tudo:
LTX-2.5 200,9 GB
LTX-2.3 156,0 GB
LTX-2.3-fp8 58,7 GB <- o único que chega perto de caber
Wan2.2-TI2V-5B 34,2 GB <- o padrão de hoje
Num Space ZeroGPU gratuito o disco é efêmero: cada vez que ele dorme, os
pesos VOLTAM a ser baixados. 34 GB já são minutos; 200 GB não é lentidão, é
um recurso que nunca termina de subir. Por isso o padrão do registro CONTINUA
sendo o Wan — trocá-lo por decisão minha entregaria um vídeo que não gera.
── ENTÃO POR QUE ESTE ARQUIVO EXISTE AGORA ─────────────────────────────────
Porque antes ele era um ESQUELETO que levantava `NotImplementedError`, e o
`escolha.py` passou a rotear a família LTX para cá: apontar o `VIDEO_MODEL`
para um LTX daria um erro seco em vez de um vídeo. Um caminho que o roteamento
alcança tem de funcionar.
Com uma GPU dedicada (ver a tabela de custo em `docs/ZEROGPU.md`), o dono
troca UMA variável — `VIDEO_MODEL=Lightricks/LTX-2.3-fp8` — e o vídeo bom
entra sem deploy nenhum. É para isso que o registro existe.
── O QUE NÃO FOI VALIDADO, E É HONESTO DIZER ───────────────────────────────
A inferência NÃO rodou: não há GPU nem pesos neste ambiente, e baixar 58 GB
para provar uma chamada não caberia aqui de qualquer jeito. O que está
provado é o que dá para provar: que os ids existem, que o `model_index.json`
do LTX-2 declara `LTX2Pipeline`, e o roteamento por família
(`t_modelo_imagem.py`). O que conferir no primeiro deploy com GPU está
nomeado em `docs/MODELS.md`.
"""
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.ltx")
VRAM_MINIMA_GB = 20
_pipe = None
_modelo_carregado = ""
def _modelo() -> str:
return os.getenv("VIDEO_MODEL", "Lightricks/LTX-2.3-fp8").strip()
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 DiffusionPipeline
# `DiffusionPipeline` resolve a classe pelo `model_index.json` do próprio
# repositório (`LTX2Pipeline` no LTX-2). Importar a classe por nome
# quebraria com ImportError num diffusers que a renomeasse, e prenderia
# este arquivo a UMA versão da família — o oposto do que ele existe para
# fazer.
_pipe = DiffusionPipeline.from_pretrained(
_modelo(), torch_dtype=torch.bfloat16,
token=os.getenv("HF_TOKEN") or None)
# Vídeo é o maior dos quatro: offload SEMPRE, mesmo com VRAM folgada — o
# pico do VAE de vídeo no decode é traiçoeiro e é onde o OOM mora. É a
# mesma decisão do wan22.py, e pelo mesmo motivo medido.
_pipe.enable_model_cpu_offload()
_modelo_carregado = _modelo()
def gerar(params: Dict, progresso: base.Progresso) -> base.Saida:
if base.modo_mock():
return b"", "video/mp4"
prompt = (params.get("prompt") or "").strip()
estilo = (params.get("style") or "").strip()
if estilo:
prompt = f"{prompt}, {estilo} style"
largura = int(params.get("width") or 1280)
altura = int(params.get("height") or 704)
segundos = max(2, min(int(params.get("duration") or 5),
int(os.getenv("VIDEO_MAX_SEGUNDOS", "8"))))
fps = int(params.get("fps") or 24)
# O LTX quer contagem de quadros em 8n+1 (o VAE temporal comprime de 8 em
# 8, e um resto sobrando sai como um quadro final corrompido). A conta
# ARREDONDA PARA BAIXO: passar do que foi pedido custaria prazo de GPU num
# modelo que já é o mais caro dos quatro.
quadros = max(9, ((segundos * fps) // 8) * 8 + 1)
passos = int(params.get("steps") or 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,
frame_rate=fps,
num_inference_steps=passos,
generator=base.semente(params),
callback_on_step_end=a_cada_passo, **extras)
return _para_mp4(saida.frames[0], fps), "video/mp4"
def descarregar() -> None:
global _pipe
_pipe = None
base.limpar_vram()
def config_menor(params: Dict) -> Optional[Dict]:
"""Depois de um OOM: menos pixels, depois menos tempo, depois menos passos.
A ORDEM é a do wan22.py e não é arbitrária — no vídeo o pico de memória é
quadros × pixels, então cortar resolução rende mais que cortar passos, e
cortar passos é o que mais estraga a imagem.
"""
p = dict(params)
w = int(p.get("width") or 1280)
if w > 704:
p["width"] = 704
p["height"] = max(384, int((p.get("height") or 704) * 704 // max(1, w)))
return p
seg = int(p.get("duration") or 5)
if seg > 3:
p["duration"] = 3
return p
passos = int(p.get("steps") or 30)
if passos > 20:
p["steps"] = 20
return p
return None
def _abrir_imagem(dado: str, largura: int, altura: int):
"""A primeira imagem do image-to-video. Aceita data URL e endereço, que
são as duas formas em que ela chega do app."""
from PIL import Image
if dado.startswith("data:"):
bruto = b64.b64decode(dado.split(",", 1)[1])
else:
import requests
bruto = requests.get(dado, timeout=30).content
img = Image.open(io.BytesIO(bruto)).convert("RGB")
return img.resize((largura, altura))
def _para_mp4(quadros, fps: int) -> bytes:
"""Os quadros viram um MP4 em arquivo temporário.
`export_to_video` do diffusers escreve em disco e não devolve bytes, e o
contrato desta casa é bytes — quem guarda é o `armazenamento.py`, que
pode estar num bucket. O temporário morre aqui.
"""
from diffusers.utils import export_to_video
with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as f:
caminho = f.name
try:
export_to_video(quadros, caminho, fps=fps)
with open(caminho, "rb") as f:
return f.read()
finally:
try:
os.unlink(caminho)
except OSError:
pass
|