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