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