File size: 29,116 Bytes
2ed3187
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6a4beb4
 
2ed3187
6a4beb4
 
2ed3187
6a4beb4
2ed3187
6a4beb4
2ed3187
 
 
c2930b8
8ffe1ab
2ed3187
 
 
c2930b8
eb151ae
6a4beb4
4a719e2
6a4beb4
2ed3187
6a4beb4
2ed3187
f90c37f
 
 
2ed3187
f90c37f
2ed3187
f90c37f
 
2ed3187
 
 
 
6a4beb4
2ed3187
 
 
 
 
 
 
 
 
6a4beb4
2ed3187
 
 
 
37358b3
c2930b8
 
 
 
 
 
 
 
 
2ed3187
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f90c37f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6a4beb4
2ed3187
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f90c37f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2ed3187
f90c37f
2ed3187
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f90c37f
 
 
 
 
 
 
 
 
 
2ed3187
 
 
f90c37f
 
 
2ed3187
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c2930b8
2ed3187
 
 
 
f90c37f
2ed3187
 
 
 
6a4beb4
 
 
 
2ed3187
 
 
 
6a4beb4
2ed3187
6a4beb4
 
 
2ed3187
 
 
 
 
 
 
 
 
6a4beb4
2ed3187
 
 
6a4beb4
2ed3187
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6a4beb4
 
2ed3187
 
 
 
 
 
f90c37f
 
2ed3187
 
 
 
f90c37f
 
2ed3187
 
 
 
 
 
 
 
 
 
 
 
 
6a4beb4
 
 
2ed3187
 
 
6a4beb4
2ed3187
6a4beb4
 
2ed3187
 
 
 
 
 
f90c37f
 
 
 
3a1adc0
 
6a4beb4
 
2ed3187
 
 
 
 
 
 
 
6a4beb4
2ed3187
6a4beb4
8ffe1ab
2ed3187
 
f90c37f
 
 
 
 
 
 
 
2ed3187
 
 
 
 
 
6a4beb4
2ed3187
 
 
 
 
 
 
 
 
 
6a4beb4
2ed3187
 
 
 
6a4beb4
 
2ed3187
 
6a4beb4
2ed3187
 
 
6a4beb4
 
 
2ed3187
 
 
6a4beb4
2ed3187
 
 
6a4beb4
 
 
 
2ed3187
 
 
f90c37f
2ed3187
 
6a4beb4
 
2ed3187
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
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
"""Krea 2 Identity Edit — instruction-based, identity-preserving image editing.

This Space runs the community LoRA `conradlocke/krea2-identity-edit` on top of
`krea/Krea-2-Turbo` (the distilled 8-step checkpoint). The LoRA is trained with a
*dual conditioning* recipe that stock text-to-image `Krea2Pipeline` does not
provide, so this app reproduces the two custom pieces from the reference
ComfyUI-Krea2Edit node pack (https://github.com/lbouaraba/comfyui-krea2edit) in
plain diffusers / PyTorch:

  1. Krea2EditGroundedEncode — the instruction is encoded *together with the
     source image* through the Qwen3-VL text encoder (vision tokens inserted via
     the image-grounded chat template), and the 12 selected decoder layers are
     tapped, exactly like the text-only Krea 2 path but with the image grounding
     the semantics ("the man on the left", "the sign in the back").

  2. Krea2EditModelPatch — the VAE-encoded SOURCE latent is prepended to the
     transformer sequence as a block of *clean* tokens, distinguished from the
     noisy target purely by the 3-axis RoPE frame index (source frame = 1,
     target frame = 0, h/w aligned). The sequence becomes
     [text | source(frame=1) | target(frame=0)] and only the target tokens are
     kept as the velocity prediction — mirroring ai-toolkit's
     `predict_velocity_edit`.

Everything runs on ZeroGPU: modules go on CUDA at module scope, inference is
wrapped in @spaces.GPU, no torch.compile, no CPU offload.
"""

import os

# Disable Gradio server-side rendering: SSR + the gradio_client / ZeroGPU
# interaction has been observed to surface as opaque CUDA allocator asserts.
os.environ.setdefault("GRADIO_SSR_MODE", "False")

import random

import numpy as np
import spaces
import torch
from torch.nn.attention import sdpa_kernel, SDPBackend
import gradio as gr
from PIL import Image

from diffusers import Krea2Pipeline
from diffusers.pipelines.krea2.pipeline_krea2 import retrieve_timesteps
from transformers import AutoProcessor
from sdnq.loader import apply_sdnq_options_to_model

# --------------------------------------------------------------------------------------
# Constants
# --------------------------------------------------------------------------------------
#BASE_MODEL = "krea/Krea-2-Turbo"
BASE_MODEL = "OzzyGT/Krea_2_Turbo_sdnq_dynamic_4bit"
LORA_REPO = "conradlocke/krea2-identity-edit"
LORA_WEIGHT = "krea2_identity_edit_v1_2.safetensors"
DTYPE = torch.bfloat16
MAX_SEED = np.iinfo(np.int32).max

# Turbo recipe for "most edits" (add / recolor / restyle / re-stage), per the LoRA card:
#   Turbo, ~10 steps, CFG 1.0 (== guidance disabled in the Krea convention).
#   v1.2: 8 = stronger composition/instruction adherence, 12 = more face detail.
DEFAULT_STEPS = 10
DEFAULT_GUIDANCE = 0.0          # Krea convention: 0.0 disables guidance
DEFAULT_GROUNDING_PX = 768      # trained dial 384-768; higher often still works
DEFAULT_LORA_SCALE = 1.0
DEFAULT_REF_BOOST = 4.0         # v1.2 likeness dial: 1.0 = off, ~4 = strong likeness
                                # (the release workflow's preset); >10 over-copies.
MAX_MEGAPIXELS = 1.0           # card allows <=2MP; we cap at 1MP because the edit
                               # path prepends the source latent (~2x image tokens).
                               # Inference runs under torch.inference_mode(), so no
                               # autograd buffers are held.

# The image-grounded instruction template from ComfyUI-Krea2Edit. The system
# prefix is byte-identical to the diffusers Krea 2 text template; the difference
# is the <|vision_start|><|image_pad|><|vision_end|> block inserted before the
# instruction so the VLM grounds the edit on the source image.
GROUNDED_TEMPLATE = (
    "<|im_start|>system\nDescribe the image by detailing the color, shape, size, "
    "texture, quantity, text, spatial relationships of the objects and background:"
    "<|im_end|>\n<|im_start|>user\n<|vision_start|><|image_pad|><|vision_end|>"
    "{}<|im_end|>\n<|im_start|>assistant\n"
)

# --------------------------------------------------------------------------------------
# Load the pipeline (module scope, eager CUDA placement — required for ZeroGPU)
# --------------------------------------------------------------------------------------
pipe = Krea2Pipeline.from_pretrained(BASE_MODEL, torch_dtype=DTYPE, is_distilled=True)

for name in ['transformer']:
    module = getattr(pipe, name, None)
    if module is not None:
        setattr(
            pipe,
            name,
            apply_sdnq_options_to_model(module, dtype=torch.bfloat16, use_quantized_matmul=True),
        )


def _convert_lora_keys(state_dict):
    """Convert the ai-toolkit / ComfyUI Krea2 LoRA naming to the diffusers
    `Krea2Transformer2DModel` module names so `load_lora_adapter` matches.

    ai-toolkit keys look like `diffusion_model.blocks.0.attn.wq.lora_A.weight`;
    diffusers expects `transformer_blocks.0.attn.to_q.lora_A.weight` (and
    `txtfusion` -> `text_fusion`, `mlp` -> `ff`, `wo` -> `to_out.0`, etc.).
    """
    attn_map = {
        ".attn.wq.": ".attn.to_q.",
        ".attn.wk.": ".attn.to_k.",
        ".attn.wv.": ".attn.to_v.",
        ".attn.wo.": ".attn.to_out.0.",
        ".attn.gate.": ".attn.to_gate.",
        ".mlp.gate.": ".ff.gate.",
        ".mlp.up.": ".ff.up.",
        ".mlp.down.": ".ff.down.",
    }
    out = {}
    for k, v in state_dict.items():
        nk = k
        if nk.startswith("diffusion_model."):
            nk = nk[len("diffusion_model."):]
        nk = nk.replace("blocks.", "transformer_blocks.", 1) if nk.startswith("blocks.") else nk
        nk = nk.replace("txtfusion.", "text_fusion.", 1) if nk.startswith("txtfusion.") else nk
        for a, b in attn_map.items():
            if a in nk:
                nk = nk.replace(a, b)
        out[nk] = v
    return out


# Load the identity-edit LoRA onto the transformer (Krea 2 LoRAs load through the
# transformer's adapter API). The weights ship in ai-toolkit naming, so convert
# the keys to the diffusers convention first.
from safetensors.torch import load_file as _load_safetensors
from huggingface_hub import hf_hub_download as _hf_hub_download

_lora_path = _hf_hub_download(LORA_REPO, LORA_WEIGHT)
_lora_sd = _convert_lora_keys(_load_safetensors(_lora_path))
# Keys are already in diffusers module-path form (transformer_blocks.N.attn.to_q...),
# so bypass the default prefix="transformer" filter (which would drop every key
# because "transformer_blocks." does not start with "transformer.").
pipe.transformer.load_lora_adapter(_lora_sd, adapter_name="default", prefix=None)
pipe.transformer.set_adapters("default", weights=DEFAULT_LORA_SCALE)
# Fuse the LoRA into the base weights at strength 1.0 (the card's recommended
# setting) and unload the adapter. This bakes the delta into the linear weights so
# inference runs through plain nn.Linear rather than peft's LoRA layer wrapper —
# faster, and it sidesteps a ZeroGPU caching-allocator assert triggered inside the
# peft LoRA forward. The adjustable lora_scale dial is therefore fixed at 1.0.
pipe.transformer.fuse_lora(lora_scale=DEFAULT_LORA_SCALE)
pipe.transformer.unload_lora()

# --- ref_boost kernel compatibility -------------------------------------------------
# Krea2 attention is GQA (48 query heads / 12 kv heads). With a dense float
# attn_mask (the ref_boost bias) every fused SDPA kernel refuses GQA inputs
# ("dense input requires q/k/v same num_heads") -> "No available kernel".
# The reference ComfyUI node sidesteps this by always repeat_interleaving K/V to
# the full head count before attention. Do the same here, but only when a mask
# is present: mask=None keeps the stock GQA fast path (identical to upstream).
from diffusers.models.transformers.transformer_krea2 import (  # noqa: E402
    Krea2AttnProcessor as _K2Proc,
)
from diffusers.models.attention_dispatch import dispatch_attention_fn as _dispatch  # noqa: E402
from diffusers.models.embeddings import apply_rotary_emb as _apply_rope  # noqa: E402


class _MaskCompatProcessor(_K2Proc):
    """Krea2AttnProcessor + dense-mask support under GQA (kv repeated to full heads)."""

    def __call__(self, attn, hidden_states, attention_mask=None, image_rotary_emb=None):
        if attention_mask is None:  # stock fast path, byte-identical behavior
            return super().__call__(attn, hidden_states, attention_mask, image_rotary_emb)
        query = attn.to_q(hidden_states).unflatten(-1, (attn.num_heads, attn.head_dim))
        key = attn.to_k(hidden_states).unflatten(-1, (attn.num_kv_heads, attn.head_dim))
        value = attn.to_v(hidden_states).unflatten(-1, (attn.num_kv_heads, attn.head_dim))
        gate = attn.to_gate(hidden_states)
        query = attn.norm_q(query)
        key = attn.norm_k(key)
        if image_rotary_emb is not None:
            query = _apply_rope(query, image_rotary_emb, sequence_dim=1)
            key = _apply_rope(key, image_rotary_emb, sequence_dim=1)
        rep = attn.num_heads // attn.num_kv_heads
        if rep > 1:  # materialize GQA so the mem-efficient kernel accepts the mask
            key = key.repeat_interleave(rep, dim=2)      # (B, L, H, D) layout
            value = value.repeat_interleave(rep, dim=2)
        hidden_states = _dispatch(
            query, key, value,
            attn_mask=attention_mask,
            enable_gqa=False,
            backend=self._attention_backend,
            parallel_config=self._parallel_config,
        )
        hidden_states = hidden_states.flatten(2, 3)
        hidden_states = hidden_states * torch.sigmoid(gate)
        return attn.to_out[0](hidden_states)


for _blk in pipe.transformer.transformer_blocks:
    _blk.attn.set_processor(_MaskCompatProcessor())

pipe.to("cuda")

# A Qwen3-VL processor for the grounded (image + text) encode. The Krea 2 repo
# ships only a text tokenizer; the vision-side preprocessing (image mean/std,
# 16px patch, spatial-merge=2) comes from the Qwen3-VL processor. We reuse the
# Krea 2 tokenizer's special tokens by aligning the template above with the
# processor's <|image_pad|> expansion.
try:
    processor = AutoProcessor.from_pretrained("Qwen/Qwen3-VL-4B-Instruct")
except Exception as e:  # pragma: no cover - surfaced in logs if it happens
    print(f"[warn] could not load Qwen3-VL processor, falling back: {e}")
    processor = None

# VAE latent normalization stats (the transformer works in normalized latent
# space; randn target latents already live there, so the source must be
# normalized the same way as the pipeline: (z - mean) / std).
_LATENTS_MEAN = (
    torch.tensor(pipe.vae.config.latents_mean).view(1, pipe.vae.config.z_dim, 1, 1, 1)
)
_LATENTS_STD = (
    torch.tensor(pipe.vae.config.latents_std).view(1, pipe.vae.config.z_dim, 1, 1, 1)
)


# --------------------------------------------------------------------------------------
# Grounded instruction encoding (semantic path)
# --------------------------------------------------------------------------------------
def _grounded_encode(instruction: str, source: Image.Image, grounding_px: int):
    """Encode the instruction grounded on the source image through Qwen3-VL.

    Returns (prompt_embeds, prompt_embeds_mask) shaped like the diffusers
    Krea 2 text conditioning: (1, seq, num_text_layers, text_hidden_dim) and
    (1, seq).
    """
    device = pipe._execution_device
    select_layers = pipe.text_encoder_select_layers
    prefix_idx = pipe.prompt_template_encode_start_idx  # 34: drop the system prefix

    # Cap the longest side fed to the VLM (the LoRA trained with 384-768px jitter).
    img = source.convert("RGB")
    if grounding_px and max(img.size) > grounding_px:
        s = grounding_px / max(img.size)
        img = img.resize(
            (max(16, round(img.size[0] * s)), max(16, round(img.size[1] * s))),
            Image.LANCZOS,
        )

    text = GROUNDED_TEMPLATE.format(instruction or "")

    inputs = processor(
        text=[text],
        images=[img],
        padding=True,
        return_tensors="pt",
    ).to(device)

    te_kwargs = dict(
        input_ids=inputs["input_ids"],
        attention_mask=inputs.get("attention_mask"),
        pixel_values=inputs.get("pixel_values"),
        image_grid_thw=inputs.get("image_grid_thw"),
        output_hidden_states=True,
    )
    # Qwen3-VL needs mm_token_type_ids (returned by the processor) to compute
    # multimodal M-RoPE positions when image tokens are present.
    if inputs.get("mm_token_type_ids") is not None:
        te_kwargs["mm_token_type_ids"] = inputs["mm_token_type_ids"]
    outputs = pipe.text_encoder(**te_kwargs)
    hidden_states = torch.stack(
        [outputs.hidden_states[i] for i in select_layers], dim=2
    )  # (1, seq, num_layers, dim)

    attention_mask = inputs.get("attention_mask")
    if attention_mask is None:
        attention_mask = torch.ones(
            hidden_states.shape[:2], device=device, dtype=torch.bool
        )
    else:
        attention_mask = attention_mask.bool()

    # Drop the system-prefix tokens (identical prefix to the text-only path).
    hidden_states = hidden_states[:, prefix_idx:]
    attention_mask = attention_mask[:, prefix_idx:]
    return hidden_states.to(DTYPE), attention_mask


def _text_only_encode(instruction: str):
    """Fallback text-only encode if the processor is unavailable."""
    return pipe.encode_prompt(prompt=instruction, device=pipe._execution_device)


# --------------------------------------------------------------------------------------
# Source-preservation forward (appearance path)
# --------------------------------------------------------------------------------------
def _encode_source_latent(source: Image.Image, height: int, width: int):
    """VAE-encode the source image to a packed, normalized latent block matching
    the target grid, ready to prepend to the transformer sequence."""
    device = pipe._execution_device
    # Preprocess to the target resolution (training pairs are same-size; the card
    # says match output AR to the source, which we enforce upstream).
    px = pipe.image_processor.preprocess(source.convert("RGB"), height=height, width=width)
    px = px.unsqueeze(2).to(device=device, dtype=pipe.vae.dtype)  # (B,C,1,H,W)

    latent = pipe.vae.encode(px).latent_dist.mode()  # (B, z, 1, lh, lw), unnormalized
    mean = _LATENTS_MEAN.to(latent.device, latent.dtype)
    std = _LATENTS_STD.to(latent.device, latent.dtype)
    latent = (latent - mean) / std               # normalized latent space (matches pipeline)
    latent = latent[:, :, 0]                      # (B, z, lh, lw)

    b, c, lh, lw = latent.shape
    packed = pipe._pack_latents(latent, b, c, lh, lw)  # (B, lh*lw/p^2, c*p*p)
    return packed.to(DTYPE)


def _edit_position_ids(text_seq_len, grid_h, grid_w, n_src, device):
    """Build (text + n_src*grid + grid, 3) rotary coords:
    text @ (0,0,0); each source block @ frame=(i+1) with (h,w); target @ frame=0.
    """
    text_ids = torch.zeros(text_seq_len, 3, device=device)

    def _img_ids(frame):
        ids = torch.zeros(grid_h, grid_w, 3, device=device)
        ids[..., 0] = frame
        ids[..., 1] = torch.arange(grid_h, device=device)[:, None]
        ids[..., 2] = torch.arange(grid_w, device=device)[None, :]
        return ids.reshape(grid_h * grid_w, 3)

    blocks = [text_ids]
    blocks += [_img_ids(i + 1) for i in range(n_src)]  # sources frame=1..N
    blocks += [_img_ids(0)]                              # target frame=0
    return torch.cat(blocks, dim=0)


def _ref_boost_bias(text_len, src_len, tgt_len, ref_boost, device, dtype):
    """Additive attention-logit bias implementing the v1.2 `ref_boost` fidelity
    dial (ComfyUI-Krea2Edit's _ref_attn_bias, single-ref case): target queries
    get log(ref_boost) added on the source-block keys — equivalent to
    multiplying the refs' post-softmax attention weight before renormalization.
    1.0 = off (return None and keep the maskless fast path). The float bias is
    handled chunk-wise by the mem-efficient SDPA backend, so the full score
    matrix is never materialized (the OOM note above concerns *boolean* masks,
    which force the math path)."""
    import math as _math
    if ref_boost == 1.0:
        return None
    L = text_len + src_len + tgt_len
    bias = torch.zeros(1, 1, L, L, device=device, dtype=dtype)
    rows0 = text_len + src_len                       # target rows
    bias[:, :, rows0:, text_len:rows0] = _math.log(max(float(ref_boost), 1e-4))
    return bias


def _edit_transformer_forward(latents, src_packed, prompt_embeds, prompt_mask,
                              timestep, position_ids, ref_boost=1.0):
    """Run the Krea 2 transformer with the source latent block prepended, keeping
    only the target tokens out. Reproduces ComfyUI-Krea2Edit's krea2_edit_forward
    against the diffusers Krea2Transformer2DModel (img_in == m.first,
    transformer_blocks == m.blocks, text_fusion/txt_in == m.txtfusion/m.txtmlp,
    rotary_emb == m.pe_embedder, final_layer == m.last)."""
    m = pipe.transformer
    combined_img = torch.cat([src_packed, latents], dim=1)  # [source | target] packed

    temb = m.time_embed(timestep, dtype=latents.dtype)
    temb_mod = m.time_mod_proj(torch.nn.functional.gelu(temb, approximate="tanh"))

    # Text fusion + projection (attention mask over text only; all image tokens valid).
    text_attn_mask = prompt_mask[:, None, None, :] if prompt_mask is not None else None
    enc = m.text_fusion(prompt_embeds, attention_mask=text_attn_mask)
    enc = m.txt_in(enc)

    img = m.img_in(combined_img)
    hidden = torch.cat([enc, img], dim=1)  # [text | source | target]

    image_rotary_emb = m.rotary_emb(position_ids)

    # Single prompt, batch=1: the grounded encode uses padding=True on one
    # sequence, so there is no padding and every token is valid. With
    # ref_boost=1.0 the mask stays None and SDPA picks the flash / mem-efficient
    # kernels (a dense *boolean* mask would force the score-materializing math
    # path and OOM given the doubled token count). If padding is ever introduced
    # (batched or CFG with unequal lengths), trim to the shorter length instead
    # of masking.
    attention_mask = _ref_boost_bias(
        enc.shape[1], src_packed.shape[1], latents.shape[1],
        ref_boost, hidden.device, hidden.dtype,
    )

    # Prefer the flash / mem-efficient SDPA kernels (they never materialize the
    # full seq x seq score matrix, which the MATH kernel would OOM on given the
    # doubled token count from prepending the source latent). With a float
    # ref_boost bias, flash declines the mask and SDPA falls through to the
    # mem-efficient kernel, which applies the bias chunk-wise.
    with sdpa_kernel([SDPBackend.FLASH_ATTENTION, SDPBackend.EFFICIENT_ATTENTION]):
        for block in m.transformer_blocks:
            hidden = block(hidden, temb_mod, image_rotary_emb, attention_mask)

    text_seq_len = enc.shape[1]
    tgt_len = latents.shape[1]
    hidden = hidden[:, text_seq_len:]              # drop text -> [source | target]
    hidden = hidden[:, -tgt_len:]                  # keep target tokens only
    return m.final_layer(hidden, temb)


# --------------------------------------------------------------------------------------
# Sizing helpers
# --------------------------------------------------------------------------------------
def _target_size(source: Image.Image):
    """Match the output AR to the source (card requirement) and cap at ~2MP,
    snapping each side to a multiple of vae_scale_factor * patch_size."""
    multiple = pipe.vae_scale_factor * pipe.patch_size  # 8 * 2 = 16
    w, h = source.size
    mp = (w * h) / 1e6
    if mp > MAX_MEGAPIXELS:
        s = (MAX_MEGAPIXELS / mp) ** 0.5
        w, h = round(w * s), round(h * s)
    w = max(multiple, (w // multiple) * multiple)
    h = max(multiple, (h // multiple) * multiple)
    return h, w


# --------------------------------------------------------------------------------------
# Inference
# --------------------------------------------------------------------------------------
@spaces.GPU(duration=10)
@torch.inference_mode()
def edit(
    source_image,
    instruction,
    ref_boost=DEFAULT_REF_BOOST,
    grounding_px=DEFAULT_GROUNDING_PX,
    lora_scale=DEFAULT_LORA_SCALE,
    steps=DEFAULT_STEPS,
    guidance_scale=DEFAULT_GUIDANCE,
    seed=0,
    randomize_seed=True,
    progress=gr.Progress(track_tqdm=True),
):
    if source_image is None:
        raise gr.Error("Please upload a source image to edit.")
    if not instruction or not instruction.strip():
        raise gr.Error("Please describe the edit you want (e.g. 'put this person at a night market').")

    if randomize_seed:
        seed = random.randint(0, MAX_SEED)
    seed = int(seed)

    device = pipe._execution_device
    source = source_image if isinstance(source_image, Image.Image) else Image.fromarray(source_image)
    source = source.convert("RGB")

    # The identity-edit LoRA is fused into the base weights at load time (strength
    # 1.0, the card's recommended setting), so lora_scale is fixed and not re-applied
    # per call.

    height, width = _target_size(source)

    # --- Semantic path: grounded instruction encode -----------------------------------
    if processor is not None:
        prompt_embeds, prompt_mask = _grounded_encode(instruction.strip(), source, int(grounding_px))
    else:
        prompt_embeds, prompt_mask = _text_only_encode(instruction.strip())

    do_cfg = float(guidance_scale) > 0
    if do_cfg:
        # At CFG > 1 the card says ground the negative too: empty prompt, same image.
        if processor is not None:
            neg_embeds, neg_mask = _grounded_encode("", source, int(grounding_px))
        else:
            neg_embeds, neg_mask = _text_only_encode("")

    # --- Appearance path: encode + pack the source latent -----------------------------
    src_packed = _encode_source_latent(source, height, width)

    # --- Prepare noisy target latents -------------------------------------------------
    num_channels_latents = pipe.transformer.config.in_channels // (pipe.patch_size ** 2)
    generator = torch.Generator(device=device).manual_seed(seed)
    latents = pipe.prepare_latents(
        1, num_channels_latents, height, width, DTYPE, device, generator, None
    )

    grid_h = height // (pipe.vae_scale_factor * pipe.patch_size)
    grid_w = width // (pipe.vae_scale_factor * pipe.patch_size)
    position_ids = _edit_position_ids(prompt_embeds.shape[1], grid_h, grid_w, 1, device)

    # --- Timesteps (distilled schedule: fixed mu = 1.15) ------------------------------
    sigmas = np.linspace(1.0, 1 / int(steps), int(steps))
    timesteps, num_steps = retrieve_timesteps(
        pipe.scheduler, int(steps), device, sigmas=sigmas, mu=1.15
    )

    # --- Denoising loop ---------------------------------------------------------------
    pipe.scheduler.set_begin_index(0)
    for t in progress.tqdm(timesteps, desc="Editing"):
        timestep = (t / pipe.scheduler.config.num_train_timesteps).expand(latents.shape[0]).to(latents.dtype)

        noise_pred = _edit_transformer_forward(
            latents, src_packed, prompt_embeds, prompt_mask, timestep, position_ids,
            ref_boost=float(ref_boost),
        )
        if do_cfg:
            neg_position_ids = _edit_position_ids(neg_embeds.shape[1], grid_h, grid_w, 1, device)
            neg_pred = _edit_transformer_forward(
                latents, src_packed, neg_embeds, neg_mask, timestep, neg_position_ids,
                ref_boost=float(ref_boost),
            )
            noise_pred = noise_pred + float(guidance_scale) * (noise_pred - neg_pred)

        latents = pipe.scheduler.step(noise_pred, t, latents, return_dict=False)[0]

    # --- Decode -----------------------------------------------------------------------
    latents = pipe._unpack_latents(latents, height, width).to(pipe.vae.dtype)
    mean = _LATENTS_MEAN.to(latents.device, latents.dtype)
    std = _LATENTS_STD.to(latents.device, latents.dtype)
    latents = latents * std + mean               # denorm matches pipeline: normalized * std + mean
    image = pipe.vae.decode(latents, return_dict=False)[0][:, :, 0]
    image = pipe.image_processor.postprocess(image, output_type="pil")[0]

    return image, seed


# --------------------------------------------------------------------------------------
# UI
# --------------------------------------------------------------------------------------
CSS = """
#col-container { max-width: 1100px; margin: 0 auto; }
"""

DESCRIPTION = """
# 🧬 Krea 2 Identity Edit

Instruction-based, **identity-preserving** image editing on
[Krea 2 Turbo](https://huggingface.co/krea/Krea-2-Turbo) with the community LoRA
[`conradlocke/krea2-identity-edit`](https://huggingface.co/conradlocke/krea2-identity-edit).

**Now running v1.2** — better face likeness, head/face swap, outpainting, inpainting,
try-on, and the new **Likeness dial** below (`ref_boost`, ships at 4).
[ComfyUI nodes](https://github.com/lbouaraba/comfyui-krea2edit) for local use.

A solo hobby project, free and open. Optional tip jar for GPU compute: [ko-fi.com/conradlocke](https://ko-fi.com/conradlocke) ☕
"""

with gr.Blocks(css=CSS, title="Krea 2 Identity Edit") as demo:
    with gr.Column(elem_id="col-container"):
        gr.Markdown(DESCRIPTION)

        with gr.Row(equal_height=True):
            with gr.Column():
                source_image = gr.Image(label="Source image", type="pil", height=420)
                instruction = gr.Textbox(
                    label="Edit instruction",
                    placeholder="e.g. create a photo of this person at a night market",
                    lines=2,
                )
                run_button = gr.Button("Edit", variant="primary", size="lg")

                ref_boost = gr.Slider(
                    label="Likeness (ref_boost)",
                    minimum=0.0, maximum=10.0, step=0.5, value=DEFAULT_REF_BOOST,
                    info="v1.2 fidelity dial: how hard the edit holds the reference. "
                         "1 = off (v1.1 behavior), 4 = strong likeness (recommended). "
                         "8+ over-copies: stylized/comic edits and removals start failing.",
                )

                with gr.Accordion("Advanced settings", open=False):
                    grounding_px = gr.Slider(
                        label="Grounding resolution (px)",
                        minimum=512, maximum=1536, step=64, value=DEFAULT_GROUNDING_PX,
                        info="Lower = stronger edit adherence; higher = stronger identity/likeness. Try 1024+ for people.",
                    )
                    lora_scale = gr.Slider(
                        label="LoRA strength (fused at 1.0)",
                        minimum=0.0, maximum=1.5, step=0.05, value=DEFAULT_LORA_SCALE,
                        interactive=False,
                        info="The identity-edit LoRA is fused into the base weights at the "
                             "card-recommended strength 1.0.",
                    )
                    steps = gr.Slider(
                        label="Steps",
                        minimum=4, maximum=28, step=1, value=DEFAULT_STEPS,
                        info="Turbo default is 8. For removals/large deletions try more steps + guidance > 0.",
                    )
                    guidance_scale = gr.Slider(
                        label="Guidance (0 = off, Turbo default)",
                        minimum=0.0, maximum=5.0, step=0.5, value=DEFAULT_GUIDANCE,
                        info="Krea convention: 0 disables guidance. Raise (e.g. 3) for removals/large edits.",
                    )
                    with gr.Row():
                        seed = gr.Slider(label="Seed", minimum=0, maximum=MAX_SEED, step=1, value=0)
                        randomize_seed = gr.Checkbox(label="Randomize seed", value=True)

            with gr.Column():
                result = gr.Image(label="Edited image", height=420)
                used_seed = gr.Number(label="Seed used", interactive=False)

        gr.Examples(
            examples=[
                ["examples/woman.jpg", "create a photo of this person at a busy night market at night"],
                ["examples/businessman_suit.jpg", "change the suit jacket to a red leather jacket"],
                ["examples/man_beach.jpg", "make it a vintage film photo with warm golden-hour light"],
            ],
            inputs=[source_image, instruction],
            outputs=[result, used_seed],
            fn=edit,
            cache_examples=True,
            cache_mode="lazy",
        )

    gr.on(
        triggers=[run_button.click, instruction.submit],
        fn=edit,
        inputs=[source_image, instruction, ref_boost, grounding_px, lora_scale, steps, guidance_scale, seed, randomize_seed],
        outputs=[result, used_seed],
    )

if __name__ == "__main__":
    demo.launch(theme=gr.themes.Citrus(), css=CSS, show_error=True)