File size: 6,390 Bytes
64f4129
 
 
02d457f
64f4129
55fe803
02d457f
9dc2680
64f4129
 
02d457f
 
 
 
 
 
 
64f4129
9dc2680
02d457f
64f4129
 
 
 
 
 
 
 
 
 
 
 
 
 
 
02d457f
4816ffd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64f4129
 
 
 
 
 
 
4816ffd
 
 
64f4129
 
 
 
02d457f
 
64f4129
02d457f
 
 
 
 
 
 
 
 
 
64f4129
02d457f
 
4816ffd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64f4129
 
 
 
 
 
 
 
 
55fe803
 
64f4129
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
import os
import tempfile

import spaces
import torch
import gradio as gr
from diffusers import DiffusionPipeline

# Load the pipeline once at startup. The Space is a ZeroGPU space, so the
# model weights stay resident and `@spaces.GPU` allocates a worker per call.
print("Loading Z-Image-Turbo pipeline...")
pipe = DiffusionPipeline.from_pretrained(
    "Tongyi-MAI/Z-Image-Turbo",
    torch_dtype=torch.bfloat16,
    low_cpu_mem_usage=False,
)
pipe.to("cuda")
print("Pipeline loaded!")


def _save_image(image) -> dict:
    """Mirror of `gradio.workflow._save_tmp`: serialize a PIL.Image as a JSON
    pointer the canvas can render. `Workflow.launch()` already adds the
    tempdir to `allowed_paths`, so the /gradio_api/file=… URL resolves."""
    path = os.path.join(
        tempfile.gettempdir(), f"zimage_{os.urandom(8).hex()}.png"
    )
    image.save(path)
    return {
        "path": path,
        "url": f"/gradio_api/file={path}",
        "orig_name": "zimage.png",
        "mime_type": "image/png",
    }


def _estimate_duration(prompt, height, width, num_inference_steps, seed, randomize_seed) -> int:
    """Rough wall-clock estimate (seconds) for one Z-Image-Turbo call.

    ZeroGPU's default per-call duration is 60s. Requesting less than you need
    *raises* queue priority (shorter tasks get scheduled sooner) and — crucially
    for a busy shared Space — frees the GPU slot for the next visitor far
    sooner than holding it for a full minute, so far fewer users hit the
    Space's "reached its GPU limit" rejection. Scaled by pixel count and steps;
    clamped to a small floor/ceiling so a runaway slider can't starve the queue
    or under-budget a big call.

    Signature mirrors the GPU function exactly because @spaces.GPU passes the
    decorated function's inputs straight through to the duration callable.

    See: https://huggingface.co/docs/hub/en/spaces-zerogpu#duration-management
    """
    pixels = max(int(height), 1) * max(int(width), 1)
    # ~0.4s/step at 1024^2, linear-ish in pixels. Big calls still need headroom.
    per_step = 0.4 * (pixels / (1024 * 1024))
    seconds = int(num_inference_steps) * per_step
    return max(20, min(int(seconds) + 15, 120))


def _friendly_gpu_error(err: Exception) -> str:
    """Turn ZeroGPU's terse allocator rejections into a clear, honest message.

    'Space app has reached its GPU limit' is a *Space-level* capacity rejection
    (the shared ZeroGPU pool is saturated), not a per-user quota wall — it
    reproduces regardless of inputs, account tier, or sign-in state. Don't make
    an upgrade claim whose truth we can't pin down, so the message is neutral:
    shared GPU at capacity, retry shortly.
    """
    msg = (str(err) or "").lower()
    capacity_hints = (
        "gpu limit", "reached its gpu limit", "gpu quota", "out of quota",
        "quota", "no gpu", "could not allocate", "gpu is busy", "too many",
        "concurrent",
    )
    if any(h in msg for h in capacity_hints):
        return (
            "⛔ This demo's shared GPU is at capacity right now — it's not a "
            "problem with your prompt or your account. The GPU pool is fully "
            "booked by other users at the moment. Please wait a minute and "
            "retry; demand clears between bursts."
        )
    if "out of memory" in msg or "oom" in msg or "cuda" in msg:
        return (
            "💥 Image generation ran out of GPU memory. Try a smaller "
            "Height/Width or fewer Inference Steps, then retry."
        )
    return (
        "⚠️ Image generation failed. Please try again in a moment — if it "
        "keeps happening, simplify your prompt or lower the resolution."
    )


@spaces.GPU(duration=_estimate_duration)
def _generate_image_gpu(
    prompt: str,
    height: int,
    width: int,
    num_inference_steps: int,
    seed: int,
    randomize_seed: bool,
):
    """The GPU-decorated worker. Runs only under a ZeroGPU allocation; the
    allocator raises *before* this body if no GPU can be granted, which is why
    the rewording lives in the plain `generate_image` wrapper below, not here.
    """
    if not prompt or not prompt.strip():
        raise gr.Error("Please enter a prompt.")

    if randomize_seed:
        seed = torch.randint(0, 2**32 - 1, (1,)).item()

    generator = torch.Generator("cuda").manual_seed(int(seed))
    image = pipe(
        prompt=prompt,
        height=int(height),
        width=int(width),
        num_inference_steps=int(num_inference_steps),
        guidance_scale=0.0,
        generator=generator,
    ).images[0]

    return _save_image(image), int(seed)


def generate_image(
    prompt: str,
    height: int,
    width: int,
    num_inference_steps: int,
    seed: int,
    randomize_seed: bool,
):
    """Workflow-facing wrapper around the GPU worker.

    Bound to the canvas as a `fn` operator node — the workflow calls this
    Python function directly server-side, so the entire pipeline (frontend +
    ZeroGPU) lives in a single Space. This non-GPU wrapper catches rejections
    from the `@spaces.GPU` allocator (which fire before the worker body runs)
    and rewords them into a clear, honest user-facing message.

    Returns (image_dict, seed_used). The image is serialized to a /gradio_api
    file URL so JSON serialization across the fn bridge succeeds; the
    executor's `fromGradioOutput` turns the dict back into an image port value.
    """
    try:
        return _generate_image_gpu(
            prompt, height, width, num_inference_steps, seed, randomize_seed
        )
    except gr.Error:
        # Already a user-facing validation message (e.g. empty prompt) — pass
        # it through unchanged.
        raise
    except Exception as e:
        # Allocator rejection (GPU limit / quota / OOM / etc.) — reword.
        raise gr.Error(_friendly_gpu_error(e)) from e


# The workflow (workflow.json) wires `generate_image` as a `fn` operator:
#   Prompt, Height, Width, Inference Steps, Seed, Randomize Seed ─▶
#   generate_image (fn operator, kind="fn") ─▶ Output Image, Seed Used
#
# On a Space with `hf_oauth: true`, visiting the canvas runs this function
# under a ZeroGPU worker using each visitor's own HF token.
demo = gr.Workflow(
    graph="workflow.json",
    bind={"generate_image": generate_image},
)

if __name__ == "__main__":
    demo.launch()