File size: 12,617 Bytes
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
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
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
# -*- coding: utf-8 -*-
"""
IMAGEM — Qwen-Image (Qwen/Qwen-Image), via diffusers.

Escolha do dono. Conferido no Hub em 2026-09-15: `Qwen/Qwen-Image` existe,
é `QwenImagePipeline`, Apache-2.0, 316 mil downloads — e ele é GRANDE: 57,7 GB
no total (transformer 41 GB em 9 partes, text-encoder 16,6 GB, VAE 0,25 GB).
Esse número não é curiosidade, é o que decide tudo o que está escrito abaixo.

── POR QUE ELE NÃO PODE ENTRAR NO z_image.py ───────────────────────────────
Trocar só o `IMAGE_MODEL` parecia bastar — o `DiffusionPipeline` resolve a
classe pelo `model_index.json` e carregaria o Qwen-Image sem reclamar. O que
não vem junto é a CALIBRAGEM, e ela é o modelo:

  - o z_image pede 8 passos, que é o ponto doce do Z-Image-**Turbo**. O
    Qwen-Image base é um modelo de 50 passos; com 8 ele devolve borrão.
  - o z_image não passa `true_cfg_scale`. O Qwen-Image usa CFG de verdade
    (4.0 é o do cartão do modelo); sem ele o prompt é seguido pela metade.

É a mesma família do defeito que este arquivo existe para não repetir: dois
modelos no mesmo módulo, com os números de um só, é o modelo novo entregando
o pior de si e ninguém sabendo dizer por quê.

── PASSOS E CFG ANDAM JUNTOS, SEMPRE ───────────────────────────────────────
O par é a coisa mais fácil de errar aqui, então ele mora num lugar só
(`_receita`). Com o LoRA Lightning o modelo é DESTILADO: ele quer poucos
passos e CFG DESLIGADO (1.0) — pôr 4.0 em cima de um destilado queima a
imagem. Sem o LoRA é o contrário. Mexer num sem o outro é o defeito.

── O LIGHTNING É O PADRÃO, E ISSO É CONTA, NÃO GOSTO ───────────────────────
`lightx2v/Qwen-Image-Lightning` (0,85 GB, bf16, 8 passos V2.0) é um LoRA que
roda EM CIMA destes mesmos pesos. Ele é o padrão porque 50 passos de um
modelo de 20B não cabem no prazo de GPU que esta casa tem: o ZeroGPU gratuito
recusou 240 s declarados e 180 é o maior valor provado (ver CLAUDE.md), e
esse prazo ainda precisa cobrir o carregamento do disco. Um modelo que estoura
o prazo não é um modelo mais bonito — é uma geração que falha.

`IMAGE_LIGHTNING=0` desliga e devolve a receita de 50 passos, para o dia em
que houver GPU dedicada. O LoRA é reserva de si mesmo: se ele não baixar, a
receita cai para a completa em vez de gerar com 8 passos sem destilação, que
seria o borrão do primeiro parágrafo por outro caminho.

── O QUE NÃO FOI VALIDADO, E É HONESTO DIZER ───────────────────────────────
A inferência NÃO rodou: não há GPU nem pesos neste ambiente. O que está
provado é o que dá para provar aqui — que o id existe, que a classe do
pipeline é a esperada, e a escolha de módulo 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 io
import logging
import os
from typing import Dict, Optional, Tuple

from . import base

logger = logging.getLogger("worker.qwen_image")

# Com offload. O modelo inteiro em VRAM quer bem mais; o offload troca
# velocidade por caber, e caber ganha — imagem lenta ainda é imagem.
VRAM_MINIMA_GB = 12

# O LoRA de destilação. Repositório e ARQUIVO, porque o repositório tem doze
# arquivos e a escolha entre 4 e 8 passos é dele: 8 passos V2.0 é o meio-termo
# medido pelos autores entre velocidade e fidelidade, e o bf16 (0,85 GB) é
# metade do download do fp32 sem diferença que se veja na tela.
_LORA_REPO = "lightx2v/Qwen-Image-Lightning"
_LORA_ARQUIVO = "Qwen-Image-Lightning-8steps-V2.0-bf16.safetensors"

_pipe = None
_modelo_carregado = ""
_lora_vivo = False          # o LoRA ENTROU? Não é o mesmo que "foi pedido".


def _modelo() -> str:
    """O PADRÃO TEM DE SER O QUE CABE, e isto é conserto de defeito.

    Ele era `Qwen/Qwen-Image` — 57,7 GB, num disco de Space de 50. O backend
    manda o id escolhido dentro do pedido, mas quem lê aqui é a VARIÁVEL de
    ambiente do processo: sem `IMAGE_MODEL` configurado no Space, o padrão
    escrito nesta linha é quem manda, e ele mandava baixar 57,7 GB. O disco
    enche, a geração falha lá dentro, e o app degrada para o gerador de
    reserva sem erro em lugar nenhum — o sintoma que o dono já relatou duas
    vezes ("continua indo pro gerador de sempre").

    Padrão que não cabe é o mesmo que padrão que não existe. Este é o FP8 de
    30,1 GB (ver a tabela no `backend/geracao/registro.py`), o mesmo id que o
    registro usa: dois padrões diferentes para a mesma escolha seriam dois
    donos, e o que fica para trás é sempre o que ninguém abre — e o Space é
    justamente esse lado.
    """
    return os.getenv("IMAGE_MODEL", "Ilus-AI/Qwen-Image-2512-FP8").strip()


def _quer_lightning() -> bool:
    return (os.getenv("IMAGE_LIGHTNING", "1").strip() or "1") != "0"


def _receita(params: Dict) -> Tuple[int, float]:
    """(passos, true_cfg_scale) — O PAR, num lugar só.

    Quem manda é o LoRA que de fato ENTROU (`_lora_vivo`), nunca o que foi
    pedido: o download do LoRA pode falhar, e nesse caso gerar com 8 passos
    sem destilação devolve borrão. Errar para a receita completa custa tempo;
    errar para a curta custa a imagem.
    """
    pedido = params.get("steps")
    if _lora_vivo:
        passos = int(pedido or 8)
        # CFG DESLIGADO: o destilado já traz a orientação nos pesos, e somar
        # CFG por cima satura a imagem. 1.0 é "sem CFG" no Qwen-Image.
        return max(4, min(passos, 12)), 1.0
    # SEM O LORA, OS 50 PASSOS DO CARTÃO NÃO CABEM NO PRAZO — e esta era a
    # metade que faltava do parágrafo do Lightning lá em cima. Ele diz que a
    # falha do LoRA "cai para a receita completa em vez de gerar borrão", e
    # isso está certo sobre a QUALIDADE e errado sobre o DESFECHO: num teto
    # duro de 180 s (ZeroGPU gratuito), 50 passos de um modelo de 20B é a
    # chamada abortada no meio, com a quota do dia gasta e NENHUMA imagem —
    # e o sintoma não aponta para cá, é a geração caindo no gerador de
    # reserva como se a GPU estivesse dormindo.
    #
    # Então o teto do padrão é o que cabe. 24 passos sem destilação é uma
    # imagem pior que 50; é infinitamente melhor que imagem nenhuma — a mesma
    # conta do 480p do `wan22.py`. `GPU_DEDICADA=1` devolve os 50 do cartão,
    # e é a MESMA variável que o vídeo lê, porque o fato é um só.
    # PADRÃO e TETO são dois números, e não um: com GPU de verdade o padrão é
    # o do cartão (50) e o teto dá folga para quem pedir mais (60). Sem ela os
    # dois colapsam no que cabe — não adianta aceitar um pedido de 50 passos
    # que vai ser abortado no meio.
    padrao, teto = (50, 60) if base.gpu_dedicada() else (24, 24)
    return max(20, min(int(pedido or padrao), teto)), 4.0


def baixar(progresso: base.Progresso) -> None:
    base.baixar_repo(_modelo(), progresso)
    if _quer_lightning():
        # A FALHA AQUI NÃO DERRUBA NADA. O LoRA é aceleração, não o modelo;
        # sem ele a `_receita` volta para os 50 passos e a imagem sai igual,
        # só mais devagar. Derrubar o job por causa do acelerador seria
        # trocar "mais lento" por "nenhuma imagem".
        try:
            # SÓ O ARQUIVO, e isto não é detalhe: o repositório do Lightning
            # tem 65,5 GB em doze variantes, e o que se usa aqui pesa 0,85.
            base.baixar_repo(_LORA_REPO, progresso, arquivos=[_LORA_ARQUIVO])
        except Exception as e:
            logger.warning("LoRA Lightning não baixou (%s) — segue sem ele", e)


def carregar(progresso: base.Progresso) -> None:
    global _pipe, _modelo_carregado, _lora_vivo
    if base.modo_mock():
        _pipe = "mock"
        _lora_vivo = _quer_lightning()
        return
    if _pipe is not None and _modelo_carregado == _modelo():
        return
    progresso(None, "Carregando o modelo de imagem…")
    import torch
    from diffusers import DiffusionPipeline
    # `DiffusionPipeline` e não `QwenImagePipeline` importado por nome: a
    # classe sai do `model_index.json` do próprio repositório, então um
    # `Qwen-Image-2512` com pipeline própria carrega sem tocar neste arquivo.
    # Importar a classe por nome quebraria com um ImportError num diffusers
    # mais novo que a renomeasse — e esta casa já pagou por id e classe
    # escritos à mão.
    _pipe = DiffusionPipeline.from_pretrained(
        _modelo(), torch_dtype=torch.bfloat16,
        token=os.getenv("HF_TOKEN") or None)

    _lora_vivo = False
    if _quer_lightning():
        try:
            progresso(None, "Carregando o acelerador…")
            _pipe.load_lora_weights(_LORA_REPO, weight_name=_LORA_ARQUIVO)
            _lora_vivo = True
        except Exception as e:
            logger.warning("LoRA Lightning não entrou (%s) — receita completa", e)

    livre = base.vram_livre_gb()
    if livre is not None and livre < 36:
        # O CORTE ACOMPANHA O MODELO, senão ele vira número solto. Eram 60,
        # que era a leitura certa para os 57,7 GB do Qwen-Image cheio; o
        # padrão de hoje é o fp8 de 30,1 GB, e manter 60 ligaria o offload em
        # QUALQUER placa — inclusive naquelas em que os pesos cabem inteiros.
        # O offload sobe blocos da RAM a cada passo, e num prazo de 180 s isso
        # é a diferença entre terminar e ser abortado (a mesma conta do
        # `wan22.py`). 36 = os 30,1 dos pesos mais folga para ativações.
        _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 base.PNG_1X1, "image/png"

    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 1328)
    altura = int(params.get("height") or 1328)
    # A tela manda aspect_ratio; quem manda width/height explícito ganha.
    if not params.get("width") and params.get("aspect_ratio"):
        largura, altura = _do_aspecto(params["aspect_ratio"])
    passos, cfg = _receita(params)

    def a_cada_passo(pipe, passo, t, kw):
        progresso(int(100 * (passo + 1) / max(1, passos)), "Gerando…")
        return kw

    saida = _pipe(prompt=prompt,
                  # O Qwen-Image quer uma STRING no negativo, e não None: o
                  # pipeline dele monta o par de embeddings sempre, e " " é o
                  # vazio que o cartão do modelo usa.
                  negative_prompt=(params.get("negative_prompt") or " "),
                  width=largura, height=altura,
                  num_inference_steps=passos,
                  true_cfg_scale=cfg,
                  generator=base.semente(params),
                  callback_on_step_end=a_cada_passo)
    img = saida.images[0]
    buf = io.BytesIO()
    img.save(buf, format="PNG")
    return buf.getvalue(), "image/png"


def descarregar() -> None:
    global _pipe, _lora_vivo
    _pipe = None
    _lora_vivo = False
    base.limpar_vram()


def config_menor(params: Dict) -> Optional[Dict]:
    """Depois de um OOM: menos pixels primeiro, depois menos passos.

    O piso dos passos depende da receita e por isso NÃO é um número escrito
    aqui: com o Lightning, 4 é o mínimo que ainda desenha; sem ele, cortar
    para 4 devolveria borrão — que é uma falha pior que a falha limpa, porque
    ela parece ter dado certo.
    """
    p = dict(params)
    w = int(p.get("width") or 1328)
    if w > 640:
        p["width"] = max(640, w // 2)
        p["height"] = max(640, int(p.get("height") or 1328) // 2)
        return p
    passos, _ = _receita(p)
    piso = 4 if _lora_vivo else 20
    if passos > piso:
        p["steps"] = piso
        return p
    return None


def _do_aspecto(ratio: str):
    """As medidas OFICIAIS do cartão do Qwen-Image, e não as do Z-Image.

    Elas não são 1024-e-múltiplos: o modelo foi treinado nestas, e sair delas
    custa composição — é o motivo de esta tabela existir em vez de uma conta
    de proporção.
    """
    tabela = {"1:1": (1328, 1328), "16:9": (1664, 928), "9:16": (928, 1664),
              "4:3": (1472, 1140), "3:4": (1140, 1472)}
    return tabela.get(str(ratio).strip(), (1328, 1328))