File size: 27,005 Bytes
7f864cd
 
90c3ba1
 
 
 
 
 
 
 
7f864cd
 
 
e790b7b
 
7f864cd
 
 
 
 
ee4bf67
7f864cd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
889ebe8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7f864cd
 
 
 
90c3ba1
 
 
 
 
 
 
7f864cd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90c3ba1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7f864cd
 
90c3ba1
 
 
 
 
 
 
7f864cd
90c3ba1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7f864cd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
889ebe8
 
 
 
7f864cd
 
 
 
 
 
 
 
 
 
a328413
 
 
 
 
 
 
 
 
 
 
889ebe8
7f864cd
889ebe8
 
 
 
 
 
 
 
 
 
7f864cd
90c3ba1
889ebe8
 
 
 
 
 
 
 
7f864cd
 
e790b7b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7f864cd
90c3ba1
 
 
 
 
889ebe8
 
 
 
90c3ba1
e790b7b
 
 
 
 
 
 
90c3ba1
e790b7b
889ebe8
e790b7b
 
 
 
 
 
 
 
 
 
 
 
889ebe8
e790b7b
889ebe8
e790b7b
889ebe8
e790b7b
 
 
 
 
 
 
 
 
 
 
889ebe8
 
e790b7b
889ebe8
e790b7b
889ebe8
e790b7b
 
 
 
 
 
 
 
 
 
 
90c3ba1
e790b7b
 
 
 
 
 
 
90c3ba1
e790b7b
 
 
 
 
 
 
 
 
90c3ba1
e790b7b
 
90c3ba1
 
 
 
7f864cd
 
 
 
 
 
889ebe8
 
 
 
e790b7b
7f864cd
 
 
 
 
 
 
 
 
889ebe8
 
 
 
7f864cd
90c3ba1
 
 
7f864cd
90c3ba1
 
 
 
 
 
 
7f864cd
 
90c3ba1
7f864cd
889ebe8
 
 
 
 
 
 
 
 
 
 
 
 
 
90c3ba1
880157f
90c3ba1
 
 
 
 
 
e790b7b
889ebe8
e790b7b
 
 
889ebe8
 
e790b7b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90c3ba1
7f864cd
90c3ba1
 
 
 
 
 
889ebe8
 
 
e790b7b
90c3ba1
 
 
 
 
 
 
 
889ebe8
 
 
90c3ba1
 
 
 
889ebe8
 
 
 
90c3ba1
 
 
 
 
 
 
889ebe8
 
 
 
e790b7b
90c3ba1
 
 
 
 
 
 
 
889ebe8
 
 
 
90c3ba1
889ebe8
 
 
 
7f864cd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25bbd11
7f864cd
25bbd11
 
 
 
7f864cd
 
 
 
 
 
 
 
25bbd11
7f864cd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7764935
7f864cd
 
 
 
 
 
 
 
 
 
 
 
 
889ebe8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7f864cd
e790b7b
 
 
 
 
 
7f864cd
 
d027f72
7f864cd
d027f72
 
7f864cd
d027f72
7f864cd
 
 
d027f72
 
7f864cd
d027f72
 
7f864cd
d027f72
7f864cd
 
 
 
d027f72
 
7f864cd
 
889ebe8
 
 
 
7f864cd
 
 
 
 
889ebe8
 
 
 
7f864cd
 
 
 
 
 
25bbd11
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
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
from __future__ import annotations

import os

# Long image-conditioned prefixes cause large transient allocations; expandable
# segments keep the caching allocator from fragmenting into a hard failure
# (NVML_SUCCESS == r INTERNAL ASSERT FAILED in CUDACachingAllocator).
# MUST be set before torch is imported.
os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")

import spaces  # MUST be imported before torch / transformers / sensenova_u1

import random
import threading
import time

import gradio as gr
import numpy as np
import torch
from PIL import Image
from transformers import AutoConfig, AutoModel, AutoTokenizer

import sensenova_u1
from sensenova_u1.models.neo_unify.utils import smart_resize

MODEL_ID = "sensenova/SenseNova-U1.5-8B-MoT"

NORM_MEAN = (0.5, 0.5, 0.5)
NORM_STD = (0.5, 0.5, 0.5)

# U1.5 trained T2I aspect-ratio buckets (from the upstream examples/t2i/inference.py
# SUPPORTED_RESOLUTIONS table).
T2I_RESOLUTIONS: dict[str, tuple[int, int]] = {
    "1:1": (2048, 2048),
    "16:9": (2720, 1536),
    "9:16": (1536, 2720),
    "3:2": (2496, 1664),
    "2:3": (1664, 2496),
    "4:3": (2368, 1760),
    "3:4": (1760, 2368),
    "1:2": (1440, 2880),
    "2:1": (2880, 1440),
    "1:3": (1152, 3456),
    "3:1": (3456, 1152),
}

# Reference config for SenseNova-U1.5 (from the model card Quick Start):
#   cfg_scale=4.0, timestep_shift=3.0, num_steps=50
DEFAULT_CFG_SCALE = 4.0
DEFAULT_TIMESTEP_SHIFT = 3.0
DEFAULT_IMG_CFG_SCALE = 1.0
REFERENCE_NUM_STEPS = 50

# A fixed-seed A/B against the 50-step reference showed 28 steps keeps
# composition, prompt adherence and text rendering intact — the only cost is
# some micro-texture in landscape/skin — while running ~1.8x faster. Editing is
# near step-invariant (0.97 SSIM vs 50 steps). Below ~24 steps structure starts
# to smear, so the slider floor stays well above that.
DEFAULT_NUM_STEPS = 28
MIN_NUM_STEPS = 8
MAX_NUM_STEPS = REFERENCE_NUM_STEPS

# --- NCII prompt guard ------------------------------------------------------
# hfmlsoc/ncii-guard-v02 flags image-editing prompts that ask to strip, undress
# or otherwise sexualize a person in a photograph. It runs on CPU in a separate
# subprocess (see ncii_guard.py), and is consulted before any GPU worker is
# allocated, so a refused prompt costs no GPU time.
#
# Threshold: the model deliberately ships no default. On its own held-out sweep
# (eval/threshold-sweep.json, 980 prompts / 70 positives) precision climbs from
# 0.870 at 0.5 to 0.923 at 0.80 while recall stays flat at 0.857 — so anything
# below ~0.7 just donates false positives. Scores on real prompts are strongly
# bimodal (0.000 or 1.000), so the threshold only decides the obfuscated
# mid-band: character-separated evasions ("r e m o v e  h e r  d r e s s") land
# at 0.73. 0.70 catches those for the cost of one extra false positive per 980
# clean prompts vs 0.80.
GUARD_ID = "hfmlsoc/ncii-guard-v02"
GUARD_THRESHOLD = 0.70
# The classifier is trained on *edit* prompts, and the NCII harm needs a real
# photo of a real person, so only the editing path is screened. Screening plain
# text-to-image costs real false positives (e.g. "classical marble statue of a
# nude figure in a museum" scores 0.9999) for no gain in NCII coverage.
GUARD_SCREEN_TEXT_TO_IMAGE = False
# The classifier runs in a subprocess (see ncii_guard.py): loaded in this
# process it leaves CUDA driver state behind that kills every subsequent
# ZeroGPU worker at worker_init with "No CUDA GPUs are available".

# Editing output grid factor (= patch_size * merge_size = 32).
EDIT_GRID_FACTOR = 32
EDIT_TARGET_PIXELS = 2048 * 2048
# Total input-image pixel budget for the editing prefix, shared across all
# reference images. The it2i prefix forward uses eager attention, whose memory
# grows quadratically with the number of input image tokens; capping the total
# keeps a multi-image edit from blowing past the ZeroGPU slot.
EDIT_INPUT_TOTAL_MAX_PIXELS = 2048 * 2048
EDIT_INPUT_MIN_PIXELS = 512 * 512
MAX_INPUT_IMAGES = 4

MAX_SEED = 2**31 - 1


def _denorm(x: torch.Tensor) -> torch.Tensor:
    mean = torch.tensor(NORM_MEAN, device=x.device, dtype=x.dtype).view(1, 3, 1, 1)
    std = torch.tensor(NORM_STD, device=x.device, dtype=x.dtype).view(1, 3, 1, 1)
    return (x * std + mean).clamp(0, 1)


def _to_pil(batch: torch.Tensor) -> list[Image.Image]:
    arr = _denorm(batch.float()).permute(0, 2, 3, 1).cpu().numpy()
    arr = (arr * 255.0).round().astype(np.uint8)
    return [Image.fromarray(a) for a in arr]


def _coerce_pil(img) -> Image.Image | None:
    """Best-effort conversion of one Gradio gallery entry into a PIL image.

    Returns ``None`` for anything that isn't actually an image (stray strings
    such as example/placeholder values, ``None`` slots, unreadable paths...)
    so callers can drop it instead of crashing inside the GPU worker.
    """
    if img is None:
        return None
    # gr.Gallery hands over (path_or_image, caption) tuples.
    if isinstance(img, (tuple, list)):
        if not img:
            return None
        img = img[0]
        if isinstance(img, (tuple, list)):  # nested, give up
            return None
    if isinstance(img, dict):
        img = img.get("image") or img.get("path") or img.get("name") or img.get("url")
        if img is None:
            return None
    if isinstance(img, Image.Image):
        return img
    if isinstance(img, np.ndarray):
        try:
            return Image.fromarray(img.astype(np.uint8))
        except Exception:
            return None
    if isinstance(img, os.PathLike):
        img = os.fspath(img)
    if isinstance(img, str):
        # Only treat it as an image if it really is a readable image file.
        if not img or not os.path.isfile(img):
            return None
        try:
            loaded = Image.open(img)
            loaded.load()
            return loaded
        except Exception:
            return None
    return None


def _normalize_images(images) -> list[Image.Image]:
    """Turn whatever Gradio handed us into a clean list of PIL images.

    Runs on CPU, before the ZeroGPU worker is entered, so that a bad input is
    rejected up front instead of aborting a GPU call mid-flight.
    """
    if images is None:
        return []
    # A bare string / PIL image / array (not a list) is a single input, never
    # something to iterate over character by character.
    if isinstance(images, (str, bytes, os.PathLike, Image.Image, np.ndarray, dict)):
        items = [images]
    elif isinstance(images, (list, tuple)):
        items = list(images)
    else:
        items = [images]

    pils: list[Image.Image] = []
    dropped = 0
    for item in items:
        pil = _coerce_pil(item)
        if pil is None:
            dropped += 1
            continue
        pils.append(pil)

    if dropped and not pils:
        raise gr.Error(
            "The uploaded input could not be read as an image. "
            "Please upload an image file, or clear the gallery for text-to-image."
        )
    if dropped:
        print(f"[generate] ignored {dropped} non-image input(s).")
    if len(pils) > MAX_INPUT_IMAGES:
        raise gr.Error(f"Please use at most {MAX_INPUT_IMAGES} input images.")
    return pils


def _input_pixel_budget(num_images: int) -> int:
    """Per-image pixel budget so all inputs together fit the prefix budget."""
    return max(EDIT_INPUT_MIN_PIXELS, EDIT_INPUT_TOTAL_MAX_PIXELS // max(1, num_images))


def _prep_input_image(img: Image.Image, max_pixels: int) -> Image.Image:
    if img.mode == "RGBA":
        bg = Image.new("RGB", img.size, (255, 255, 255))
        bg.paste(img, mask=img.split()[3])
        img = bg
    img = img.convert("RGB")
    h, w = smart_resize(
        height=img.height,
        width=img.width,
        factor=EDIT_GRID_FACTOR,
        min_pixels=max_pixels,
        max_pixels=max_pixels,
    )
    if (w, h) != img.size:
        img = img.resize((w, h), Image.LANCZOS)
    return img


def _editing_output_size(input_img: Image.Image, target_pixels: int) -> tuple[int, int]:
    h, w = smart_resize(
        height=input_img.height,
        width=input_img.width,
        factor=EDIT_GRID_FACTOR,
        min_pixels=target_pixels,
        max_pixels=target_pixels,
    )
    return w, h


# Start the guard subprocess before the main model is loaded: Popen forks this
# process, and forking it after 35GB of weights are resident is far costlier.


print("[startup] loading SenseNova-U1.5-8B-MoT (this may take a few minutes)...")
sensenova_u1.set_attn_backend("auto")
print(f"[startup] attn backend: {sensenova_u1.effective_attn_backend()!r}")

config = AutoConfig.from_pretrained(MODEL_ID)
sensenova_u1.check_checkpoint_compatibility(config)
tokenizer = AutoTokenizer.from_pretrained(MODEL_ID)
model = AutoModel.from_pretrained(MODEL_ID, config=config, dtype=torch.bfloat16).to("cuda").eval()
print("[startup] model ready.")

import sn_aoti

# Swap the 42 Qwen3DecoderLayer gen paths for one AOTI-compiled package.
# No-op unless SN_AOTI=1 AND the published key matches this card exactly
# (torch version, sm arch, attention backend, layer count, hidden size); any
# mismatch prints one line and leaves the model eager. Nothing here touches the
# GPU: the download is CPU work and the .pt2 is not opened until the first
# denoising step inside the GPU worker.
print(f"[startup] {sn_aoti.status()}")
sn_aoti.maybe_load(model)




def _estimate_duration(
    pil_inputs, prompt, aspect_ratio, seed, num_steps=DEFAULT_NUM_STEPS, *args, **kwargs
):
    # Sampling cost is ~linear in step count, so scale the 50-step baselines
    # instead of always reserving the worst case.
    try:
        steps = max(MIN_NUM_STEPS, min(int(num_steps), MAX_NUM_STEPS))
    except (TypeError, ValueError):
        steps = DEFAULT_NUM_STEPS
    # Editing is heavier (image conditioning); give it more headroom.
    if pil_inputs:
        base = 180
    else:
        # T2I at 2048x2048 with 50 steps is the heaviest t2i case
        w, h = T2I_RESOLUTIONS.get(aspect_ratio, (2048, 2048))
        base = 180 if w * h > 2048 * 2048 else 120
    # The prefix pass and pixel decode don't shrink with the step count, so keep
    # a fixed floor and scale only the sampling part.
    return int(30 + (base - 30) * steps / REFERENCE_NUM_STEPS)


def _register_step_hook(counter: dict) -> object | None:
    """Count denoising steps by hooking the per-step timestep embedder.

    The sampling loops in ``t2i_generate`` / ``it2i_generate`` call
    ``fm_modules['timestep_embedder']`` exactly once per step, so a forward
    hook on it is a cheap, non-invasive progress signal.
    """
    try:
        embedder = model.fm_modules["timestep_embedder"]
    except Exception:  # pragma: no cover - defensive
        return None

    def _tick(_module, _args, _output):
        counter["step"] += 1

    try:
        return embedder.register_forward_hook(_tick)
    except Exception:  # pragma: no cover - defensive
        return None


@spaces.GPU(duration=_estimate_duration)
def _generate_on_gpu(
    pil_inputs: list,
    prompt: str,
    aspect_ratio: str,
    seed: int,
    num_steps: int = DEFAULT_NUM_STEPS,
    cfg_scale: float = DEFAULT_CFG_SCALE,
    timestep_shift: float = DEFAULT_TIMESTEP_SHIFT,
    img_cfg_scale: float = DEFAULT_IMG_CFG_SCALE,
):
    """GPU worker. Inputs are already validated / preprocessed on CPU.

    Runs sampling on a background thread inside the GPU fork and yields
    ``("progress", steps_done, total_steps)`` ticks while it works, then a
    final ``("result", image)``. The caller turns those ticks into a
    ``gr.Progress`` bar on the output component.
    """
    torch.cuda.reset_peak_memory_stats()
    counter = {"step": 0}
    total_steps = int(num_steps)
    hook = _register_step_hook(counter)
    result: dict = {}

    def _run():
        try:
            with torch.inference_mode():
                if not pil_inputs:
                    width, height = T2I_RESOLUTIONS[aspect_ratio]
                    tensor = model.t2i_generate(
                        tokenizer,
                        prompt,
                        image_size=(width, height),
                        cfg_scale=float(cfg_scale),
                        cfg_norm="none",
                        timestep_shift=float(timestep_shift),
                        cfg_interval=(0.0, 1.0),
                        num_steps=total_steps,
                        batch_size=1,
                        seed=int(seed),
                        think_mode=False,
                    )
                else:
                    out_w, out_h = _editing_output_size(pil_inputs[0], EDIT_TARGET_PIXELS)
                    tensor = model.it2i_generate(
                        tokenizer,
                        prompt,
                        pil_inputs,
                        image_size=(out_w, out_h),
                        cfg_scale=float(cfg_scale),
                        img_cfg_scale=float(img_cfg_scale),
                        cfg_norm="none",
                        timestep_shift=float(timestep_shift),
                        cfg_interval=(0.0, 1.0),
                        num_steps=total_steps,
                        batch_size=1,
                        think_mode=False,
                        seed=int(seed),
                    )
            result["image"] = _to_pil(tensor)[0]
            del tensor
        except BaseException as exc:  # re-raised on the generator thread below
            result["error"] = exc

    worker = threading.Thread(target=_run, daemon=True)
    started = time.time()
    try:
        worker.start()
        while True:
            worker.join(0.4)
            done = min(counter["step"], total_steps)
            if not worker.is_alive():
                break
            yield ("progress", done, total_steps, time.time() - started)
        print(f"[generate] peak GPU memory: {torch.cuda.max_memory_allocated() / 2**30:.1f} GiB")
        exc = result.get("error")
        if isinstance(exc, torch.cuda.OutOfMemoryError):  # pragma: no cover
            raise gr.Error(
                "Ran out of GPU memory for this request. Try a smaller input image "
                "or a smaller aspect ratio."
            ) from exc
        if exc is not None:
            raise exc
        yield ("result", result["image"], total_steps, time.time() - started)
    finally:
        if hook is not None:
            hook.remove()
        # Never leave a failed call's tensors behind for the next request.
        torch.cuda.empty_cache()


def generate(
    images: list | None,
    prompt: str,
    aspect_ratio: str = "1:1",
    seed: int = 42,
    randomize_seed: bool = True,
    num_steps: int = DEFAULT_NUM_STEPS,
    cfg_scale: float = DEFAULT_CFG_SCALE,
    timestep_shift: float = DEFAULT_TIMESTEP_SHIFT,
    img_cfg_scale: float = DEFAULT_IMG_CFG_SCALE,
    progress=gr.Progress(),
):
    """Generate an image from a text prompt, or edit an uploaded image.

    Args:
        images: optional uploaded image(s) to edit; leave empty for text-to-image.
        prompt: what to generate, or the edit instruction to apply to the input image.
        aspect_ratio: output aspect ratio for text-to-image (ignored when editing).
        seed: RNG seed for reproducible sampling.
        randomize_seed: if True, pick a fresh random seed each run.
        num_steps: denoising steps; 50 is the model card reference, 28 is ~1.8x faster.
        cfg_scale: prompt guidance strength (reference: 4.0).
        timestep_shift: flow-matching schedule shift (reference: 3.0).
        img_cfg_scale: input-image guidance strength, editing only (reference: 1.0).
    """
    # Everything below runs on CPU: reject bad inputs *before* a GPU worker is
    # allocated, so a malformed request can never abort a GPU call mid-flight.
    if not isinstance(prompt, str) or not prompt.strip():
        raise gr.Error("Please enter a prompt.")
    prompt = prompt.strip()
    if aspect_ratio not in T2I_RESOLUTIONS:
        aspect_ratio = "1:1"
    try:
        seed = int(seed)
    except (TypeError, ValueError):
        seed = 42
    if randomize_seed:
        seed = random.randint(0, MAX_SEED)
    seed = max(0, min(int(seed), MAX_SEED))

    def _clamp(value, low, high, fallback):
        try:
            value = float(value)
        except (TypeError, ValueError):
            return fallback
        if value != value:  # NaN
            return fallback
        return max(low, min(value, high))

    num_steps = int(_clamp(num_steps, MIN_NUM_STEPS, MAX_NUM_STEPS, DEFAULT_NUM_STEPS))
    cfg_scale = _clamp(cfg_scale, 1.0, 10.0, DEFAULT_CFG_SCALE)
    timestep_shift = _clamp(timestep_shift, 0.5, 6.0, DEFAULT_TIMESTEP_SHIFT)
    img_cfg_scale = _clamp(img_cfg_scale, 1.0, 4.0, DEFAULT_IMG_CFG_SCALE)

    pil_images = _normalize_images(images)
    # Screen on CPU, before any GPU worker is allocated
    budget = _input_pixel_budget(len(pil_images))
    pil_inputs = [_prep_input_image(img, budget) for img in pil_images]
    if pil_inputs:
        sizes = ", ".join(f"{im.width}x{im.height}" for im in pil_inputs)
        print(f"[generate] editing with {len(pil_inputs)} input image(s) at {sizes}")

    # Drive a progress bar on the output component from the GPU worker's ticks.
    estimated = _estimate_duration(pil_inputs, prompt, aspect_ratio, seed, num_steps)
    progress(0.0, desc="Starting generation…")
    image_out = None
    for kind, payload, total_steps, elapsed in _generate_on_gpu(
        pil_inputs, prompt, aspect_ratio, seed, num_steps,
        cfg_scale, timestep_shift, img_cfg_scale,
    ):
        if kind == "progress":
            steps_done = int(payload)
            if steps_done > 0:
                progress(
                    min(steps_done / max(total_steps, 1), 1.0),
                    desc=f"Denoising step {steps_done}/{total_steps}",
                )
            else:
                # Prefix / conditioning pass, before the first denoising step.
                progress(
                    min(elapsed / max(estimated, 1), 0.05),
                    desc="Encoding prompt…",
                )
        else:
            image_out = payload
            progress(1.0, desc="Done")
    if image_out is None:  # pragma: no cover - worker always yields a result
        raise gr.Error("Generation produced no image. Please try again.")
    return image_out, seed


def generate_t2i(
    prompt: str,
    aspect_ratio: str = "1:1",
    seed: int = 42,
    randomize_seed: bool = True,
    num_steps: int = DEFAULT_NUM_STEPS,
    cfg_scale: float = DEFAULT_CFG_SCALE,
    timestep_shift: float = DEFAULT_TIMESTEP_SHIFT,
    progress=gr.Progress(),
):
    """Text-to-image only: generate an image from a prompt (no input image).

    Args:
        prompt: what to generate.
        aspect_ratio: output aspect ratio.
        seed: RNG seed for reproducible sampling.
        randomize_seed: if True, pick a fresh random seed each run.
        num_steps: denoising steps (default 28; 50 is the reference config).
        cfg_scale: prompt guidance strength.
        timestep_shift: flow-matching schedule shift.
    """
    # Wired to the text-to-image gr.Examples: those rows carry (prompt,
    # aspect_ratio) only, so they must NOT be bound straight to `generate`,
    # whose first argument is the input-image list.
    return generate(
        None, prompt, aspect_ratio, seed, randomize_seed,
        num_steps, cfg_scale, timestep_shift, progress=progress,
    )


def generate_edit(
    images: list | None,
    prompt: str,
    seed: int = 42,
    randomize_seed: bool = True,
    num_steps: int = DEFAULT_NUM_STEPS,
    cfg_scale: float = DEFAULT_CFG_SCALE,
    timestep_shift: float = DEFAULT_TIMESTEP_SHIFT,
    img_cfg_scale: float = DEFAULT_IMG_CFG_SCALE,
    progress=gr.Progress(),
):
    """Image editing: apply an edit instruction to the uploaded image(s).

    Args:
        images: input image(s) to edit.
        prompt: the edit instruction.
        seed: RNG seed for reproducible sampling.
        randomize_seed: if True, pick a fresh random seed each run.
        num_steps: denoising steps (default 28; 50 is the reference config).
        cfg_scale: prompt guidance strength.
        timestep_shift: flow-matching schedule shift.
        img_cfg_scale: input-image guidance strength.
    """
    return generate(
        images, prompt, "1:1", seed, randomize_seed,
        num_steps, cfg_scale, timestep_shift, img_cfg_scale, progress=progress,
    )


# T2I examples: prompt + aspect ratio
T2I_EXAMPLES = [
    [
        "A cinematic mountain lake at sunrise, realistic photography, golden mist over still water, snow-capped peaks reflected in the lake, ultra-detailed.",
        "1:1",
    ],
    [
        'A neon bar sign that clearly reads "OPEN LATE", dark interior, moody reflections, easy text rendering.',
        "16:9",
    ],
    [
        "Close portrait of an elderly woman by a farmhouse window, textured skin, gentle smile, warm natural light, emotional documentary look.",
        "2:3",
    ],
    [
        "A cute fluffy corgi puppy wearing a tiny chef's hat, sitting at a wooden table with fresh-baked cookies, warm kitchen lighting, photorealistic.",
        "1:1",
    ],
    [
        "Lavender fields stretching to the horizon under a pastel sunset, a small stone farmhouse, highly detailed flowers, romantic countryside scene.",
        "4:3",
    ],
]

# Editing examples: gallery input (list of paths) + prompt
EDIT_EXAMPLES = [
    [["examples/edit_1.webp"], "Change the jacket of the person on the left to bright yellow."],
    [["examples/edit_2.webp"], "Make the person in the image smile."],
    [["examples/edit_3.webp"], "Add a bouquet of flowers."],
    [["examples/edit_4.webp"], "Turn the image into an American comic style."],
]


CSS = """
#col-container { max-width: 1100px; margin: 0 auto; }
.dark .gradio-container { color: var(--body-text-color); }
"""

with gr.Blocks(title="SenseNova-U1.5-8B-MoT") as demo:
    gr.Markdown(
        """
        # SenseNova-U1.5-8B-MoT

        Unified text-to-image **and** image editing with
        [**SenseNova-U1.5-8B-MoT**](https://huggingface.co/sensenova/SenseNova-U1.5-8B-MoT),
        a natively unified multimodal model built on the
        [NEO-unify](https://huggingface.co/blog/sensenova/neo-unify) architecture.
        Leave the image upload empty for text-to-image, or upload an image and
        write an edit instruction.
        """
    )

    with gr.Row():
        with gr.Column(scale=1):
            image_input_gallery = gr.Gallery(
                label="Upload image(s) to edit (leave empty for text-to-image)",
                file_types=["image"],
                columns=4,
            )
            prompt_input = gr.Textbox(
                label="Prompt",
                placeholder="Describe the image to generate, or how to edit your input.",
                lines=3,
            )
            aspect_ratio = gr.Dropdown(
                label="Aspect ratio (text-to-image only — editing keeps input ratio)",
                choices=list(T2I_RESOLUTIONS.keys()),
                value="1:1",
            )
            generate_button = gr.Button("Generate", variant="primary")

            with gr.Accordion("Advanced options", open=False):
                num_steps = gr.Slider(
                    label="Denoising steps",
                    info=(
                        f"{DEFAULT_NUM_STEPS} keeps composition and prompt adherence while "
                        f"running ~1.8x faster than the {REFERENCE_NUM_STEPS}-step reference; "
                        "raise it for fine texture in landscapes and skin."
                    ),
                    minimum=MIN_NUM_STEPS,
                    maximum=MAX_NUM_STEPS,
                    step=1,
                    value=DEFAULT_NUM_STEPS,
                )
                cfg_scale = gr.Slider(
                    label="Guidance scale (CFG)",
                    info="How strictly to follow the prompt. Reference: 4.0.",
                    minimum=1.0,
                    maximum=10.0,
                    step=0.1,
                    value=DEFAULT_CFG_SCALE,
                )
                timestep_shift = gr.Slider(
                    label="Timestep shift",
                    info=(
                        "Shifts sampling toward high-noise steps. Reference: 3.0 — lowering it "
                        "changes the image rather than sharpening it."
                    ),
                    minimum=0.5,
                    maximum=6.0,
                    step=0.1,
                    value=DEFAULT_TIMESTEP_SHIFT,
                )
                img_cfg_scale = gr.Slider(
                    label="Image guidance (editing only)",
                    info="How closely an edit sticks to the input image. Reference: 1.0.",
                    minimum=1.0,
                    maximum=4.0,
                    step=0.1,
                    value=DEFAULT_IMG_CFG_SCALE,
                )
                with gr.Row():
                    seed = gr.Slider(
                        label="Seed", minimum=0, maximum=MAX_SEED, step=1, value=42
                    )
                    randomize_seed = gr.Checkbox(label="Randomize seed", value=True)
        with gr.Column(scale=1):
            output_image = gr.Image(
                label="Output",
                type="pil",
                format="png",
                interactive=False,
            )
            used_seed = gr.Number(label="Seed used", interactive=False)

    with gr.Accordion("Image Editing Examples", open=True):
        gr.Examples(
            examples=EDIT_EXAMPLES,
            inputs=[image_input_gallery, prompt_input],
            outputs=[output_image, used_seed],
            fn=generate_edit,
            cache_examples=False,
            run_on_click=True,
        )
        
    with gr.Accordion("Text-to-Image Examples", open=True):
        gr.Examples(
            examples=T2I_EXAMPLES,
            inputs=[prompt_input, aspect_ratio],
            outputs=[output_image, used_seed],
            fn=generate_t2i,
            cache_examples=False,
            run_on_click=True,
        )

    

    generate_button.click(
        fn=generate,
        inputs=[
            image_input_gallery, prompt_input, aspect_ratio, seed,
            randomize_seed, num_steps, cfg_scale, timestep_shift, img_cfg_scale,
        ],
        outputs=[output_image, used_seed],
        api_name="generate",
    )
    prompt_input.submit(
        fn=generate,
        inputs=[
            image_input_gallery, prompt_input, aspect_ratio, seed,
            randomize_seed, num_steps, cfg_scale, timestep_shift, img_cfg_scale,
        ],
        outputs=[output_image, used_seed],
        api_name="generate_submit",
    )


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