AST-1320 commited on
Commit
c2eed40
·
verified ·
1 Parent(s): 39a2f1d

Upload 3 files

Browse files
Files changed (3) hide show
  1. app.py +999 -0
  2. requirements.txt +1 -3
  3. security.py +655 -0
app.py ADDED
@@ -0,0 +1,999 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import gc
2
+ import html
3
+ import importlib.metadata
4
+ import os
5
+ import random
6
+ import sys
7
+ import threading
8
+ import time
9
+
10
+ NOISE_MARKERS = (b"CUDACachingAllocator.cpp", b"negative_prompt")
11
+
12
+
13
+ def _install_stderr_filter():
14
+ """Drops known-noisy lines from stderr (must run before torch is imported)."""
15
+ try:
16
+ real_fd = os.dup(2)
17
+ read_fd, write_fd = os.pipe()
18
+ os.dup2(write_fd, 2)
19
+ os.close(write_fd)
20
+
21
+ def pump():
22
+ while True:
23
+ try:
24
+ chunk = os.read(read_fd, 65536)
25
+ except OSError:
26
+ break
27
+ if not chunk:
28
+ break
29
+ chunk = b"".join(
30
+ ln for ln in chunk.splitlines(keepends=True)
31
+ if not any(m in ln for m in NOISE_MARKERS)
32
+ )
33
+ if chunk:
34
+ try:
35
+ os.write(real_fd, chunk)
36
+ except OSError:
37
+ break
38
+
39
+ threading.Thread(target=pump, daemon=True).start()
40
+ except Exception as exc:
41
+ sys.__stdout__.write(f"[log filter] not installed: {exc}\n")
42
+
43
+
44
+ _install_stderr_filter()
45
+ os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
46
+
47
+ import gradio as gr
48
+ import torch
49
+ import spaces
50
+
51
+ import torchao.quantization as _torchao_quantization
52
+ print(f"[versions] torchao={importlib.metadata.version('torchao')} torch={torch.__version__}")
53
+ if not hasattr(_torchao_quantization, "FqnToConfig"):
54
+ class FqnToConfig:
55
+ pass
56
+ _torchao_quantization.FqnToConfig = FqnToConfig
57
+
58
+ from huggingface_hub import hf_hub_download
59
+ from PIL import Image
60
+ from safetensors import safe_open
61
+ from starlette.middleware import Middleware
62
+ from torchao.quantization import quantize_, Float8DynamicActivationFloat8WeightConfig
63
+
64
+ from qwenimage.pipeline_qwenimage_edit_plus import QwenImageEditPlusPipeline
65
+ from qwenimage.transformer_qwenimage import QwenImageTransformer2DModel
66
+ from qwenimage.qwen_fa3_processor import QwenDoubleStreamAttnProcessorFA3
67
+
68
+ from security import (
69
+ FloodGuard,
70
+ check_admin_password,
71
+ check_admin_tab_visibility,
72
+ check_user_password,
73
+ clear_all_admin_log,
74
+ delete_admin_log_entry,
75
+ end_session,
76
+ is_admin,
77
+ is_quota_error,
78
+ is_user,
79
+ load_admin_gallery,
80
+ load_rate_stats,
81
+ log_generation,
82
+ mark_browser_session,
83
+ rate_acquire,
84
+ rate_release,
85
+ save_input_images,
86
+ show_rate_message,
87
+ )
88
+
89
+ _GR6 = int(str(gr.__version__).split(".")[0]) >= 6
90
+ _PRIVATE = {"api_visibility": "private"} if _GR6 else {"api_name": False}
91
+
92
+ import inspect as _inspect
93
+ _SHOW_PROGRESS_ON = "show_progress_on" in _inspect.signature(gr.on).parameters
94
+
95
+ _original_image_preprocess = gr.Image.preprocess
96
+
97
+
98
+ def _safe_image_preprocess(self, *args, **kwargs):
99
+ try:
100
+ return _original_image_preprocess(self, *args, **kwargs)
101
+ except FileNotFoundError:
102
+ print("[image] stale client-side file reference (likely a Space restart) — treating this input as empty.")
103
+ return None
104
+
105
+
106
+ gr.Image.preprocess = _safe_image_preprocess
107
+
108
+ torch.cuda.is_current_stream_capturing = lambda: False
109
+
110
+
111
+ dtype = torch.bfloat16
112
+ device = "cuda" if torch.cuda.is_available() else "cpu"
113
+
114
+ if torch.cuda.is_available():
115
+ torch.backends.cuda.matmul.allow_tf32 = True
116
+ torch.backends.cudnn.allow_tf32 = True
117
+
118
+ MODEL_QWEN = "qwen"
119
+
120
+ PRESET_RESOLUTIONS = [
121
+ ("720×1280 (9:16)", 720, 1280),
122
+ ("864×1536 (9:16)", 864, 1536),
123
+ ("1080×1920 (9:16)", 1080, 1920),
124
+ ("1440×2560 (9:16)", 1440, 2560),
125
+ ("1600×2560 (10:16)", 1600, 2560),
126
+ ("1536×2728 (9:16)", 1536, 2728),
127
+ ("1280×720 (16:9)", 1280, 720),
128
+ ("1536×864 (16:9)", 1536, 864),
129
+ ("1920×1080 (16:9)", 1920, 1080),
130
+ ("2560×1440 (16:9)", 2560, 1440),
131
+ ("2560×1600 (16:10)", 2560, 1600),
132
+ ("2728×1536 (16:9)", 2728, 1536),
133
+ ("768×1024 (3:4)", 768, 1024),
134
+ ("1080×1440 (3:4)", 1080, 1440),
135
+ ("1536×2048 (3:4)", 1536, 2048),
136
+ ("1024×1536 (2:3)", 1024, 1536),
137
+ ("1440×1800 (4:5)", 1440, 1800),
138
+ ("1080×2400 (9:20)", 1080, 2400),
139
+ ("1024×768 (4:3)", 1024, 768),
140
+ ("1440×1080 (4:3)", 1440, 1080),
141
+ ("2048×1536 (4:3)", 2048, 1536),
142
+ ("1536×1024 (3:2)", 1536, 1024),
143
+ ("1800×1440 (5:4)", 1800, 1440),
144
+ ("2560×1080 (21:9)", 2560, 1080),
145
+ ]
146
+ PRESET_RESOLUTION_MAP = {label: (w, h) for label, w, h in PRESET_RESOLUTIONS}
147
+ PRESET_TALL = [label for label, w, h in PRESET_RESOLUTIONS if w < h]
148
+ PRESET_WIDE = [label for label, w, h in PRESET_RESOLUTIONS if w > h]
149
+ PRESET_ALL = PRESET_TALL + PRESET_WIDE
150
+ PRESET_DEFAULT = "720×1280 (9:16)"
151
+ AUTO_SCALES = ["1x", "2x", "3x", "4x"]
152
+ AUTO_BASE_PIXELS = 1024 * 1024
153
+ AUTO_MAX_PIXELS = 4194304
154
+
155
+ from diffusers.utils import logging as diffusers_logging
156
+
157
+ diffusers_logging.set_verbosity_error()
158
+ transformer = QwenImageTransformer2DModel.from_pretrained(
159
+ "AST-1320/Qwen-2511-Image-To-Image-Edit-V.17-Diffusers", subfolder="transformer", dtype=dtype
160
+ )
161
+ diffusers_logging.set_verbosity_warning()
162
+ gc.collect()
163
+
164
+ QWEN_TRANSFORMER_MODE = "fp8_exact"
165
+
166
+
167
+ class _FP8ExactLinear(torch.nn.Module):
168
+ def __init__(self, linear: torch.nn.Linear):
169
+ super().__init__()
170
+ self.in_features = linear.in_features
171
+ self.out_features = linear.out_features
172
+ self.register_buffer("weight", linear.weight.detach().to(torch.float8_e4m3fn), persistent=False)
173
+ self.bias = linear.bias
174
+
175
+ def forward(self, x):
176
+ b = self.bias.to(x.dtype) if self.bias is not None else None
177
+ return torch.nn.functional.linear(x, self.weight.to(x.dtype), b)
178
+
179
+
180
+ def _convert_linears_to_fp8_exact(model) -> int:
181
+ """Replaces every nn.Linear with _FP8ExactLinear and returns how many were replaced
182
+ (0 = nothing changed). Must run on CPU tensors, BEFORE pipe.to(device)."""
183
+ targets = [(n, m) for n, m in model.named_modules() if isinstance(m, torch.nn.Linear)]
184
+ if not targets:
185
+ return 0
186
+ for i, (n, m) in enumerate(targets):
187
+ if i % 8 == 0:
188
+ w = m.weight.detach()
189
+ if not torch.equal(w.to(torch.float8_e4m3fn).to(w.dtype), w):
190
+ print(f"[qwen] exact-FP8 check failed on {n!r}: weights are not plain E4M3 values - not converting")
191
+ return 0
192
+ for n, m in targets:
193
+ parent_name, _, child = n.rpartition(".")
194
+ parent = model.get_submodule(parent_name) if parent_name else model
195
+ setattr(parent, child, _FP8ExactLinear(m))
196
+ return len(targets)
197
+
198
+
199
+ def _enable_vae_tiling(p, tile=None):
200
+ vae = getattr(p, "vae", None)
201
+ if vae is None:
202
+ return
203
+ try:
204
+ vae.enable_tiling()
205
+ vae.enable_slicing()
206
+ except Exception:
207
+ pass
208
+ if tile:
209
+ stride = (tile * 7 // 8) // 8 * 8
210
+ for name, value in (
211
+ ("tile_sample_min_height", tile),
212
+ ("tile_sample_min_width", tile),
213
+ ("tile_sample_stride_height", stride),
214
+ ("tile_sample_stride_width", stride),
215
+ ):
216
+ if hasattr(vae, name):
217
+ setattr(vae, name, value)
218
+ print("[vae] tiling applied:", {k: getattr(vae, k, None) for k in ("tile_sample_min_height", "tile_sample_min_width", "tile_sample_stride_height", "tile_sample_stride_width")})
219
+
220
+
221
+ QWEN_VAE_TILE = int(os.environ.get("QWEN_VAE_TILE", "1600"))
222
+
223
+
224
+ pipe = QwenImageEditPlusPipeline.from_pretrained(
225
+ "Qwen/Qwen-Image-Edit-2511",
226
+ transformer=transformer,
227
+ torch_dtype=dtype,
228
+ ignore_patterns=["transformer/*"],
229
+ )
230
+
231
+ _qwen_fp8_exact = False
232
+ if QWEN_TRANSFORMER_MODE == "fp8_exact":
233
+ try:
234
+ _n_lin = _convert_linears_to_fp8_exact(pipe.transformer)
235
+ _qwen_fp8_exact = _n_lin > 0
236
+ if _qwen_fp8_exact:
237
+ print(f"[qwen] exact FP8 mode: {_n_lin} Linear layers keep the checkpoint's FP8 weights (bf16 compute)")
238
+ else:
239
+ print("[qwen] exact FP8 mode unavailable - falling back to torchao dynamic FP8")
240
+ except Exception as _e_fp8x:
241
+ print(f"[qwen] exact FP8 conversion failed ({_e_fp8x}) - falling back to torchao dynamic FP8")
242
+
243
+ pipe = pipe.to(device)
244
+
245
+ if _qwen_fp8_exact:
246
+ print("[qwen] transformer: exact FP8 weights, no torchao re-quantization")
247
+ else:
248
+ quantize_(pipe.transformer, Float8DynamicActivationFloat8WeightConfig())
249
+ print("[qwen] FP8 quantization: SUCCESS (transformer)")
250
+
251
+ _enable_vae_tiling(pipe, QWEN_VAE_TILE)
252
+
253
+ pipe.transformer.__class__ = QwenImageTransformer2DModel
254
+ pipe.transformer.set_attn_processor(QwenDoubleStreamAttnProcessorFA3())
255
+
256
+ gc.collect()
257
+
258
+
259
+ MAX_SEED = 2**31 - 1
260
+ _VALID_FORMATS = ("PNG", "JPG", "WebP")
261
+
262
+ GPU_DURATION_MIN = 10
263
+ GPU_DURATION_MAX = 120
264
+ GPU_DURATION_STEP = 5
265
+ GPU_DURATION_DEFAULT = 40
266
+ _GPU_DURATION_STEPS = list(range(GPU_DURATION_MIN, GPU_DURATION_MAX + 1, GPU_DURATION_STEP))
267
+ GPU_SIZE_OPTIONS = ("large", "xlarge")
268
+ GPU_SIZE_DEFAULT = "large"
269
+
270
+
271
+ def _to_int(value, lo, hi, default):
272
+ try:
273
+ v = int(value)
274
+ except (TypeError, ValueError, OverflowError):
275
+ return default
276
+ return max(lo, min(hi, v))
277
+
278
+
279
+ LIMIT_GUIDANCE_MIN = 1.0
280
+ LIMIT_GUIDANCE_MAX = 2.0
281
+
282
+
283
+ def _to_float(value, lo, hi, default):
284
+ try:
285
+ v = float(value)
286
+ except (TypeError, ValueError, OverflowError):
287
+ return default
288
+ if v != v: # NaN
289
+ return default
290
+ return max(lo, min(hi, v))
291
+
292
+
293
+ def _round_size(width, height, multiple=8):
294
+ return max(multiple, (int(width) // multiple) * multiple), max(multiple, (int(height) // multiple) * multiple)
295
+
296
+
297
+ def _auto_size(scale_choice, image_1, image_2):
298
+ src = image_1 if image_1 is not None else image_2
299
+ if src is None:
300
+ return None, None
301
+ try:
302
+ with Image.open(src if isinstance(src, str) else src.name) as im:
303
+ width, height = im.size
304
+ except Exception:
305
+ return None, None
306
+ factor = _to_int(str(scale_choice).rstrip("x"), 1, 4, 1)
307
+ pixels = min(AUTO_BASE_PIXELS * factor, AUTO_MAX_PIXELS)
308
+ ratio = width / height
309
+ out_w = (pixels * ratio) ** 0.5
310
+ return _round_size(int(out_w), int(out_w / ratio), multiple=16)
311
+
312
+
313
+ def _resolve_size(mode, preset, custom_width, custom_height, auto_scale, image_1, image_2):
314
+ try:
315
+ if mode == "Preset" and preset in PRESET_RESOLUTION_MAP:
316
+ return _round_size(*PRESET_RESOLUTION_MAP[preset])
317
+ if mode == "Custom":
318
+ return _round_size(int(custom_width), int(custom_height))
319
+ if mode == "Auto":
320
+ return _auto_size(auto_scale, image_1, image_2)
321
+ except (TypeError, ValueError, OverflowError):
322
+ pass
323
+ return None, None
324
+
325
+
326
+ def _load_image(img):
327
+ if not isinstance(img, Image.Image):
328
+ img = Image.open(img if isinstance(img, str) else img.name)
329
+ return img.convert("RGB")
330
+
331
+
332
+ def _sanitize_common(seed, gpu_duration, output_format):
333
+ seed = _to_int(seed, 0, MAX_SEED, 0)
334
+ gpu_duration = _to_int(gpu_duration, GPU_DURATION_MIN, GPU_DURATION_MAX, GPU_DURATION_DEFAULT)
335
+ output_format = output_format if output_format in _VALID_FORMATS else "PNG"
336
+ return seed, gpu_duration, output_format
337
+
338
+
339
+ def _first_path(gallery_items):
340
+ first = gallery_items[0]
341
+ return first[0] if isinstance(first, (list, tuple)) else first
342
+
343
+
344
+ def _free_vram_cache():
345
+ gc.collect()
346
+ if torch.cuda.is_available():
347
+ torch.cuda.empty_cache()
348
+
349
+
350
+ def log_vram(label):
351
+ if not torch.cuda.is_available():
352
+ print(f"[VRAM] {label}: CUDA not available")
353
+ return
354
+ print(
355
+ f"[VRAM] {label}: allocated={torch.cuda.memory_allocated() / 1e9:.2f}GB "
356
+ f"reserved={torch.cuda.memory_reserved() / 1e9:.2f}GB "
357
+ f"peak={torch.cuda.max_memory_allocated() / 1e9:.2f}GB / "
358
+ f"device_total={torch.cuda.get_device_properties(0).total_memory / 1e9:.2f}GB"
359
+ )
360
+
361
+
362
+ def _generate_core(
363
+ image_1,
364
+ image_2,
365
+ prompt,
366
+ seed,
367
+ randomize_seed,
368
+ true_guidance_scale,
369
+ num_inference_steps,
370
+ height,
371
+ width,
372
+ duplicate_input=True,
373
+ progress=gr.Progress(track_tqdm=True),
374
+ ):
375
+ if randomize_seed:
376
+ seed = random.randint(0, MAX_SEED)
377
+ generator = torch.Generator(device=device).manual_seed(seed)
378
+
379
+ pil_images = []
380
+ for img in (image_1, image_2):
381
+ if img is None:
382
+ continue
383
+ try:
384
+ pil_images.append(_load_image(img))
385
+ except Exception:
386
+ continue
387
+
388
+ duplicated = False
389
+ if duplicate_input and len(pil_images) == 1:
390
+ pil_images.append(pil_images[0].copy())
391
+ duplicated = True
392
+
393
+ res_txt = f"{width}x{height}" if (width and height) else "auto"
394
+ print(f"[gen] model={MODEL_QWEN} guidance={true_guidance_scale} steps={num_inference_steps} resolution={res_txt}")
395
+
396
+ if torch.cuda.is_available():
397
+ torch.cuda.reset_peak_memory_stats(device)
398
+ log_vram(f"{MODEL_QWEN} before generation")
399
+
400
+ try:
401
+ images_pil = pipe(
402
+ image=pil_images or None,
403
+ prompt=prompt,
404
+ height=height,
405
+ width=width,
406
+ negative_prompt=" ",
407
+ num_inference_steps=num_inference_steps,
408
+ generator=generator,
409
+ true_cfg_scale=true_guidance_scale,
410
+ num_images_per_prompt=1,
411
+ ).images
412
+
413
+ except torch.cuda.OutOfMemoryError:
414
+ _free_vram_cache()
415
+ raise
416
+ log_vram(f"{MODEL_QWEN} after generation")
417
+
418
+ return {
419
+ "images": images_pil, "seed": seed, "width": width, "height": height,
420
+ "duplicated": duplicated, "steps": num_inference_steps,
421
+ "guidance": true_guidance_scale, "model": MODEL_QWEN,
422
+ }
423
+
424
+
425
+ def _finalize(res, image_1, image_2, prompt, output_format):
426
+ seed = res["seed"]
427
+ input_paths = save_input_images((image_1, image_2), seed)
428
+
429
+ os.makedirs("outputs", exist_ok=True)
430
+ ext = {"PNG": "png", "JPG": "jpg", "WebP": "webp"}.get(output_format, "png")
431
+ output_paths = []
432
+ for idx, img in enumerate(res["images"]):
433
+ output_path = f"outputs/output_{seed}_{idx}_{int(time.time() * 1000)}.{ext}"
434
+ if ext == "jpg":
435
+ img.convert("RGB").save(output_path, quality=100)
436
+ elif ext == "webp":
437
+ img.save(output_path, quality=100)
438
+ else:
439
+ img.save(output_path)
440
+ output_paths.append(output_path)
441
+ log_generation(
442
+ path=output_path,
443
+ prompt=prompt,
444
+ seed=seed,
445
+ steps=res["steps"],
446
+ guidance=res["guidance"],
447
+ model=res["model"],
448
+ width=res["width"],
449
+ height=res["height"],
450
+ out_width=img.width,
451
+ out_height=img.height,
452
+ duplicated=res["duplicated"],
453
+ inputs=input_paths,
454
+ )
455
+ has_output = bool(output_paths)
456
+ return output_paths, seed, gr.update(visible=has_output)
457
+
458
+
459
+ def _make_gpu_variant(duration_seconds, size):
460
+ def _infer_gpu_call(*args, **kwargs):
461
+ return _generate_core(*args, **kwargs)
462
+
463
+ _infer_gpu_call.__name__ = f"_infer_impl_gpu_{size}_{duration_seconds}s"
464
+ _infer_gpu_call.__qualname__ = _infer_gpu_call.__name__
465
+ return spaces.GPU(duration=duration_seconds, size=size)(_infer_gpu_call)
466
+
467
+
468
+ _gpu_variants = {(s, d): _make_gpu_variant(d, s) for s in GPU_SIZE_OPTIONS for d in _GPU_DURATION_STEPS}
469
+
470
+
471
+ def _pick_gpu_variant(gpu_duration, gpu_size=GPU_SIZE_DEFAULT):
472
+ requested = _to_int(gpu_duration, GPU_DURATION_MIN, GPU_DURATION_MAX, GPU_DURATION_DEFAULT)
473
+ chosen = next((d for d in _GPU_DURATION_STEPS if d >= requested), _GPU_DURATION_STEPS[-1])
474
+ size = gpu_size if gpu_size in GPU_SIZE_OPTIONS else GPU_SIZE_DEFAULT
475
+ gpu_fn = _gpu_variants[(size, chosen)]
476
+
477
+ def _call(image_1, image_2, prompt, *args, output_format="PNG", **kwargs):
478
+ res = gpu_fn(image_1, image_2, prompt, *args, **kwargs)
479
+ return _finalize(res, image_1, image_2, prompt, output_format)
480
+
481
+ return _call
482
+
483
+
484
+ def _run_limited(request, seed, run):
485
+ if not is_user(request):
486
+ gr.Warning("Please log in first.")
487
+ return gr.update(), seed, gr.update()
488
+ ok, key, msg = rate_acquire(request)
489
+ if not ok:
490
+ show_rate_message(msg)
491
+ return gr.update(), seed, gr.update()
492
+ refund = False
493
+ try:
494
+ return run()
495
+ except Exception as exc:
496
+ refund = is_quota_error(exc)
497
+ raise
498
+ finally:
499
+ rate_release(key, refund=refund)
500
+
501
+
502
+ def infer_qwen_only(
503
+ image_1,
504
+ image_2,
505
+ prompt,
506
+ seed,
507
+ randomize_seed,
508
+ true_guidance_scale,
509
+ num_inference_steps,
510
+ gpu_duration,
511
+ gpu_size,
512
+ resolution_mode,
513
+ resolution_preset,
514
+ custom_width,
515
+ custom_height,
516
+ auto_scale,
517
+ output_format,
518
+ duplicate_input,
519
+ progress=gr.Progress(track_tqdm=True),
520
+ request: gr.Request = None,
521
+ ):
522
+ if not (isinstance(prompt, str) and prompt.strip()):
523
+ return gr.update(), seed, gr.update()
524
+
525
+ def run():
526
+ clean_seed, duration, fmt = _sanitize_common(seed, gpu_duration, output_format)
527
+ width, height = _resolve_size(resolution_mode, resolution_preset, custom_width, custom_height, auto_scale, image_1, image_2)
528
+ return _pick_gpu_variant(duration, gpu_size)(
529
+ image_1,
530
+ image_2,
531
+ prompt if isinstance(prompt, str) else "",
532
+ seed=clean_seed,
533
+ randomize_seed=bool(randomize_seed),
534
+ true_guidance_scale=_to_float(true_guidance_scale, LIMIT_GUIDANCE_MIN, LIMIT_GUIDANCE_MAX, 1.0),
535
+ num_inference_steps=int(num_inference_steps),
536
+ height=height,
537
+ width=width,
538
+ output_format=fmt,
539
+ duplicate_input=bool(duplicate_input),
540
+ progress=progress,
541
+ )
542
+
543
+ return _run_limited(request, seed, run)
544
+
545
+
546
+ def use_output_as_input(output_images):
547
+ if not output_images:
548
+ return gr.update()
549
+ return gr.update(value=_first_path(output_images))
550
+
551
+
552
+ def _sync_result_followups(output_images):
553
+ return gr.update(visible=bool(output_images))
554
+
555
+
556
+ def _clear_all_admin_images(request: gr.Request):
557
+ if not is_admin(request):
558
+ return []
559
+ clear_all_admin_log()
560
+ return load_admin_gallery(request)
561
+
562
+
563
+ css = """
564
+ #col-container {
565
+ margin: 0 auto;
566
+ max-width: 1024px;
567
+ }
568
+ #logo-title {
569
+ text-align: center;
570
+ }
571
+
572
+ @keyframes ast-title-flash {
573
+ 0% { background-position: 0% 50%; }
574
+ 100% { background-position: 200% 50%; }
575
+ }
576
+ #ast-main-title {
577
+ font-style: italic;
578
+ display: inline-block;
579
+ background: linear-gradient(90deg, #FF8C00, #FF3CAC, #7B2FF7, #00E5FF, #FF8C00);
580
+ background-size: 400% 400%;
581
+ -webkit-background-clip: text;
582
+ background-clip: text;
583
+ -webkit-text-fill-color: transparent;
584
+ color: transparent;
585
+ animation: ast-title-flash 10s linear infinite;
586
+ }
587
+ /* Force a 2-column grid for the preset radio (Gradio's default flex-wrap stacks into one column on mobile). */
588
+ .preset-grid > .wrap,
589
+ .preset-grid .wrap {
590
+ display: grid !important;
591
+ grid-template-columns: 1fr 1fr !important;
592
+ gap: 8px !important;
593
+ }
594
+ #adv-settings:has(#res-mode input[value="Preset"]:checked) #res-custom-height,
595
+ #adv-settings:has(#res-mode input[value="Preset"]:checked) #res-custom-width,
596
+ #adv-settings:has(#res-mode input[value="Preset"]:checked) #res-auto-scale,
597
+ #adv-settings:has(#res-mode input[value="Custom"]:checked) #res-preset,
598
+ #adv-settings:has(#res-mode input[value="Custom"]:checked) #res-auto-scale,
599
+ #adv-settings:has(#res-mode input[value="Auto"]:checked) #res-preset,
600
+ #adv-settings:has(#res-mode input[value="Auto"]:checked) #res-custom-height,
601
+ #adv-settings:has(#res-mode input[value="Auto"]:checked) #res-custom-width {
602
+ display: none !important;
603
+ }
604
+ /* Seed is an output component: hide Gradio's progress/status overlay so it never covers the number box. */
605
+ #seed-slider .wrap.generating,
606
+ #seed-slider .wrap.pending,
607
+ #seed-slider .progress-text,
608
+ #seed-slider .progress-level {
609
+ display: none !important;
610
+ }
611
+ .preset-grid label {
612
+ width: 100% !important;
613
+ margin: 0 !important;
614
+ box-sizing: border-box !important;
615
+ }
616
+ #res-mode .wrap {
617
+ display: grid !important;
618
+ grid-template-columns: repeat(3, 1fr) !important;
619
+ gap: 8px !important;
620
+ }
621
+ #res-mode label {
622
+ width: 100% !important;
623
+ margin: 0 !important;
624
+ box-sizing: border-box !important;
625
+ justify-content: center !important;
626
+ }
627
+ #output-format .wrap {
628
+ display: grid !important;
629
+ grid-template-columns: repeat(3, 1fr) !important;
630
+ gap: 8px !important;
631
+ }
632
+ #output-format label {
633
+ width: 100% !important;
634
+ margin: 0 !important;
635
+ box-sizing: border-box !important;
636
+ justify-content: center !important;
637
+ }
638
+ #gpu-size .wrap {
639
+ display: grid !important;
640
+ grid-template-columns: repeat(2, 1fr) !important;
641
+ gap: 8px !important;
642
+ }
643
+ #gpu-size label {
644
+ width: 100% !important;
645
+ margin: 0 !important;
646
+ box-sizing: border-box !important;
647
+ justify-content: center !important;
648
+ }
649
+ #gpu-size-note,
650
+ #gpu-size-note * {
651
+ background: transparent !important;
652
+ border: none !important;
653
+ box-shadow: none !important;
654
+ }
655
+ #gpu-size-note {
656
+ background: var(--block-background-fill) !important;
657
+ padding: 14px 12px 16px 12px !important;
658
+ container-type: inline-size;
659
+ }
660
+ #gpu-size-note p {
661
+ margin: 0 !important;
662
+ font-family: inherit !important;
663
+ font-size: var(--checkbox-label-text-size, var(--text-md, 1rem)) !important;
664
+ font-size: min(var(--checkbox-label-text-size, var(--text-md, 1rem)), 5.2cqw) !important;
665
+ white-space: nowrap !important;
666
+ font-style: normal !important;
667
+ line-height: 1.5 !important;
668
+ opacity: 0.7;
669
+ }
670
+ #res-auto-scale .wrap {
671
+ display: grid !important;
672
+ grid-template-columns: repeat(4, minmax(0, 1fr)) !important;
673
+ gap: 6px !important;
674
+ }
675
+ #res-auto-scale label {
676
+ width: 100% !important;
677
+ min-width: 0 !important;
678
+ margin: 0 !important;
679
+ padding-left: 8px !important;
680
+ padding-right: 8px !important;
681
+ box-sizing: border-box !important;
682
+ justify-content: center !important;
683
+ }
684
+ """
685
+
686
+ _div_a = f'.preset-grid label:has(input[value="{PRESET_WIDE[0]}"])'
687
+ _div_b = f'.preset-grid label:has(input[value="{PRESET_WIDE[1]}"])'
688
+ css += (
689
+ f"{_div_a}, {_div_b} {{ position: relative !important; margin-top: 12px !important; overflow: visible !important; }}\n"
690
+ f"{_div_a}::before, {_div_b}::before {{ content: \"\"; position: absolute; top: -10px; height: 1px; "
691
+ f"background: rgba(128, 128, 128, 0.55); pointer-events: none; }}\n"
692
+ f"{_div_a}::before {{ left: 0; right: -4px; }}\n"
693
+ f"{_div_b}::before {{ left: -4px; right: 0; }}\n"
694
+ )
695
+
696
+ _theme_dir = os.path.dirname(os.path.abspath(__file__))
697
+ _theme_pick = os.environ.get("UI_THEME", "")
698
+ if _theme_pick == "off":
699
+ _theme_name = None
700
+ elif _theme_pick:
701
+ _theme_name = f"theme_{_theme_pick}.css"
702
+ else:
703
+ _found = sorted(f for f in os.listdir(_theme_dir) if f.startswith("theme_") and f.endswith(".css"))
704
+ _theme_name = _found[0] if _found else None
705
+ if _theme_name and os.path.isfile(os.path.join(_theme_dir, _theme_name)):
706
+ with open(os.path.join(_theme_dir, _theme_name), encoding="utf-8") as _f:
707
+ css = _f.read() + css
708
+
709
+ with gr.Blocks(**({} if _GR6 else {"css": css})) as demo:
710
+ with gr.Tabs():
711
+ with gr.Tab("Editor"):
712
+ with gr.Group(visible=True) as login_group:
713
+ login_password = gr.Textbox(label="Password", type="password", placeholder="Enter password")
714
+ login_btn = gr.Button("Login", variant="primary")
715
+ login_status = gr.Markdown("")
716
+ with gr.Column(elem_id="col-container", visible=False) as app_col:
717
+ gr.HTML('<div id="logo-title"><h1 id="ast-main-title">Qwen 2511 Image Edit</h1></div>')
718
+ with gr.Row():
719
+ with gr.Column():
720
+ with gr.Row():
721
+ image_1 = gr.Image(label="Image 1", type="filepath", interactive=True)
722
+ with gr.Column():
723
+ image_2 = gr.Image(label="Image 2 (optional)", type="filepath", interactive=True)
724
+ duplicate_input = gr.Checkbox(
725
+ label="Duplicate image 1",
726
+ value=False,
727
+ )
728
+
729
+ prompt = gr.Text(
730
+ label="Prompt 🪄",
731
+ show_label=True,
732
+ placeholder="Enter your prompt here...",
733
+ )
734
+ run_button = gr.Button("Edit!", variant="primary", interactive=False)
735
+
736
+ with gr.Group():
737
+ gpu_size = gr.Radio(
738
+ label="GPU Size",
739
+ choices=[("Large", "large"), ("Xlarge", "xlarge")],
740
+ value=GPU_SIZE_DEFAULT,
741
+ elem_id="gpu-size",
742
+ )
743
+ gr.Markdown(
744
+ "⚠️ Note: Xlarge is faster but uses 2× quota",
745
+ elem_id="gpu-size-note",
746
+ )
747
+
748
+ with gr.Group():
749
+ output_format = gr.Radio(
750
+ label="Output Format",
751
+ choices=["PNG", "JPG", "WebP"],
752
+ value="PNG",
753
+ elem_id="output-format",
754
+ )
755
+
756
+ with gr.Accordion("Advanced Settings", open=False, elem_id="adv-settings"):
757
+ seed = gr.Slider(label="Seed", minimum=0, maximum=MAX_SEED, step=1, value=0, elem_id="seed-slider")
758
+ randomize_seed = gr.Checkbox(label="Randomize seed", value=True)
759
+
760
+ with gr.Row():
761
+ true_guidance_scale = gr.Slider(
762
+ label="True guidance scale",
763
+ minimum=1.0,
764
+ maximum=2.0,
765
+ step=0.1,
766
+ value=1.0,
767
+ )
768
+ num_inference_steps = gr.Slider(
769
+ label="Number of inference steps",
770
+ minimum=1,
771
+ maximum=20,
772
+ step=1,
773
+ value=4,
774
+ )
775
+ gpu_duration = gr.Slider(
776
+ label="GPU duration (seconds)",
777
+ minimum=GPU_DURATION_MIN,
778
+ maximum=GPU_DURATION_MAX,
779
+ step=GPU_DURATION_STEP,
780
+ value=GPU_DURATION_DEFAULT,
781
+ )
782
+
783
+ with gr.Group():
784
+ resolution_mode = gr.Radio(
785
+ label="Resolution mode",
786
+ choices=["Auto", "Preset", "Custom"],
787
+ value="Auto",
788
+ elem_id="res-mode",
789
+ )
790
+ resolution_preset = gr.Radio(
791
+ label="Resolution",
792
+ choices=PRESET_ALL,
793
+ value=PRESET_DEFAULT,
794
+ elem_classes=["preset-grid"],
795
+ elem_id="res-preset",
796
+ )
797
+ custom_height = gr.Slider(
798
+ label="Height", minimum=256, maximum=2048, step=8, value=256,
799
+ buttons=[], elem_id="res-custom-height",
800
+ )
801
+ custom_width = gr.Slider(
802
+ label="Width", minimum=256, maximum=2048, step=8, value=256,
803
+ buttons=[], elem_id="res-custom-width",
804
+ )
805
+ auto_scale = gr.Radio(
806
+ label="Auto resolution",
807
+ choices=AUTO_SCALES,
808
+ value="1x",
809
+ elem_id="res-auto-scale",
810
+ )
811
+
812
+ with gr.Column():
813
+ result = gr.Gallery(label="Result", show_label=False, type="filepath")
814
+ with gr.Row():
815
+ use_output_btn = gr.Button("↗️ Use as input", variant="secondary", size="sm", visible=False)
816
+
817
+ gr.HTML('<div style="height:1024px"></div>', elem_id="login-spacer")
818
+
819
+ with gr.Tab("🔒", visible=False) as admin_tab:
820
+ with gr.Group(visible=True) as admin_lock_group:
821
+ admin_password = gr.Textbox(label="Password", type="password", placeholder="Enter admin password")
822
+ admin_unlock_btn = gr.Button("Unlock", size="sm", variant="primary")
823
+ admin_status = gr.Markdown("")
824
+ with gr.Group(visible=False) as admin_unlocked_group:
825
+ with gr.Row():
826
+ admin_refresh_btn = gr.Button("🔄 Refresh", size="sm")
827
+ admin_clear_all_btn = gr.Button("🗑️ Clear ALL", size="sm", variant="stop")
828
+ admin_stats_md = gr.Markdown("")
829
+ admin_items_state = gr.State([])
830
+
831
+ @gr.render(inputs=[admin_items_state])
832
+ def _render_admin_grid(items):
833
+ if not items:
834
+ gr.Markdown("*No images in history.*")
835
+ return
836
+ for row_start in range(0, len(items), 3):
837
+ with gr.Row():
838
+ for item in items[row_start:row_start + 3]:
839
+ is_pair = isinstance(item, (tuple, list))
840
+ path = item[0] if is_pair else item
841
+ caption = item[1] if is_pair and len(item) > 1 else ""
842
+ inputs = list(item[2]) if is_pair and len(item) > 2 else []
843
+ shown = (
844
+ [(p, f"Input {n}") for n, p in enumerate(inputs, 1)] + [(path, "Output")]
845
+ if inputs else [path]
846
+ )
847
+ with gr.Column(min_width=160):
848
+ gr.Gallery(
849
+ value=shown,
850
+ show_label=False,
851
+ columns=len(shown),
852
+ rows=1,
853
+ height=320,
854
+ object_fit="contain",
855
+ allow_preview=True,
856
+ )
857
+ if caption:
858
+ gr.HTML(f"<small>{html.escape(str(caption))}</small>")
859
+ del_btn = gr.Button("🗑️ Remove", size="sm", variant="stop")
860
+
861
+ def _delete_this(request: gr.Request, _path=path):
862
+ if not is_admin(request):
863
+ return []
864
+ delete_admin_log_entry(_path)
865
+ return load_admin_gallery(request)
866
+
867
+ del_btn.click(
868
+ fn=_delete_this,
869
+ inputs=None,
870
+ outputs=[admin_items_state],
871
+ **_PRIVATE,
872
+ )
873
+
874
+ gr.on(
875
+ triggers=[run_button.click, prompt.submit],
876
+ fn=infer_qwen_only,
877
+ inputs=[
878
+ image_1,
879
+ image_2,
880
+ prompt,
881
+ seed,
882
+ randomize_seed,
883
+ true_guidance_scale,
884
+ num_inference_steps,
885
+ gpu_duration,
886
+ gpu_size,
887
+ resolution_mode,
888
+ resolution_preset,
889
+ custom_width,
890
+ custom_height,
891
+ auto_scale,
892
+ output_format,
893
+ duplicate_input,
894
+ ],
895
+ outputs=[result, seed, use_output_btn],
896
+ **({"show_progress_on": [result]} if _SHOW_PROGRESS_ON else {}),
897
+ ).then(
898
+ fn=_sync_result_followups,
899
+ inputs=result,
900
+ outputs=use_output_btn,
901
+ show_progress="hidden",
902
+ )
903
+
904
+ prompt.change(
905
+ fn=None,
906
+ inputs=prompt,
907
+ outputs=run_button,
908
+ js="(text) => ({__type__: 'update', interactive: !!(text && text.trim())})",
909
+ show_progress="hidden",
910
+ **_PRIVATE,
911
+ )
912
+
913
+ use_output_btn.click(fn=use_output_as_input, inputs=[result], outputs=[image_1])
914
+
915
+ result.change(
916
+ fn=_sync_result_followups,
917
+ inputs=result,
918
+ outputs=use_output_btn,
919
+ show_progress="hidden",
920
+ )
921
+
922
+ gr.on(
923
+ triggers=[login_btn.click, login_password.submit],
924
+ fn=check_user_password,
925
+ inputs=[login_password],
926
+ outputs=[login_group, app_col, login_status],
927
+ **_PRIVATE,
928
+ ).then(
929
+ fn=None, inputs=None, outputs=None,
930
+ js="""() => {
931
+ const nudge = () => {
932
+ window.dispatchEvent(new Event("resize"));
933
+ try { if (window.parentIFrame && window.parentIFrame.size) window.parentIFrame.size(); } catch (e) {}
934
+ };
935
+ setTimeout(() => {
936
+ const sp = document.getElementById("login-spacer");
937
+ const col = document.getElementById("col-container");
938
+ if (sp && col && col.offsetParent !== null) sp.style.display = "none";
939
+ nudge();
940
+ }, 600);
941
+ [900, 1500, 3000].forEach((ms) => setTimeout(nudge, ms));
942
+ }""",
943
+ show_progress="hidden",
944
+ **_PRIVATE,
945
+ )
946
+
947
+ resolution_mode.change(
948
+ fn=None, inputs=None, outputs=None,
949
+ js="""() => {
950
+ const nudge = () => {
951
+ window.dispatchEvent(new Event("resize"));
952
+ try { if (window.parentIFrame && window.parentIFrame.size) window.parentIFrame.size(); } catch (e) {}
953
+ };
954
+ [0, 100, 300, 800].forEach((ms) => setTimeout(nudge, ms));
955
+ }""",
956
+ show_progress="hidden",
957
+ **_PRIVATE,
958
+ )
959
+
960
+ try:
961
+ demo.unload(end_session)
962
+ except Exception as e:
963
+ print(f"[auth] demo.unload not available, sessions persist until restart: {e}")
964
+
965
+ demo.load(fn=mark_browser_session, inputs=None, outputs=None, **_PRIVATE)
966
+ demo.load(fn=check_admin_tab_visibility, inputs=None, outputs=[admin_tab], **_PRIVATE)
967
+
968
+ admin_unlock_btn.click(
969
+ fn=check_admin_password,
970
+ inputs=[admin_password],
971
+ outputs=[admin_lock_group, admin_unlocked_group, admin_status],
972
+ **_PRIVATE,
973
+ ).then(
974
+ fn=load_admin_gallery, inputs=None, outputs=[admin_items_state], **_PRIVATE,
975
+ ).then(
976
+ fn=load_rate_stats, inputs=None, outputs=[admin_stats_md], **_PRIVATE,
977
+ )
978
+ admin_refresh_btn.click(
979
+ fn=load_admin_gallery, inputs=None, outputs=[admin_items_state], **_PRIVATE,
980
+ ).then(
981
+ fn=load_rate_stats, inputs=None, outputs=[admin_stats_md], **_PRIVATE,
982
+ )
983
+ admin_clear_all_btn.click(
984
+ fn=_clear_all_admin_images, inputs=None, outputs=[admin_items_state], **_PRIVATE,
985
+ )
986
+
987
+ if __name__ == "__main__":
988
+ demo.queue(max_size=int(os.environ.get("QUEUE_MAX_SIZE", "30")))
989
+ launch_kwargs = dict(
990
+ ssr_mode=False,
991
+ max_file_size="20mb",
992
+ app_kwargs={"middleware": [Middleware(FloodGuard)]},
993
+ )
994
+ if _GR6:
995
+ launch_kwargs["css"] = css
996
+ launch_kwargs["footer_links"] = ["gradio", "settings"]
997
+ else:
998
+ launch_kwargs["show_api"] = False
999
+ demo.launch(**launch_kwargs)
requirements.txt CHANGED
@@ -7,6 +7,4 @@ torchvision==0.28.0
7
  torchao==0.18.0
8
  huggingface-hub==1.33.0
9
  tokenizers==0.23.3
10
- pillow==12.3.0
11
- opencv-python-headless==4.13.0.92
12
- mediapipe==0.10.35
 
7
  torchao==0.18.0
8
  huggingface-hub==1.33.0
9
  tokenizers==0.23.3
10
+ pillow==12.3.0
 
 
security.py ADDED
@@ -0,0 +1,655 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import hmac
2
+ import ipaddress
3
+ import json
4
+ import os
5
+ import shutil
6
+ import threading
7
+ import time
8
+
9
+ import gradio as gr
10
+
11
+ ADMIN_PASSWORD = os.environ.get("ADMIN_PASSWORD", "")
12
+ ADMIN_URL_KEY = os.environ.get("ADMIN_URL_KEY", "")
13
+ ADMIN_LOG_PATH = "outputs/generation_log.jsonl"
14
+ ADMIN_INPUT_DIR = "outputs/inputs"
15
+ ADMIN_MAX_ENTRIES = 300
16
+
17
+
18
+ def _session_id(request):
19
+ return getattr(request, "session_hash", None)
20
+
21
+
22
+ def _read_log_lines():
23
+ if not os.path.exists(ADMIN_LOG_PATH):
24
+ return None
25
+ try:
26
+ with open(ADMIN_LOG_PATH, "r", encoding="utf-8") as f:
27
+ return [line.strip() for line in f if line.strip()]
28
+ except Exception as e:
29
+ print(f"[admin log] read failed: {e}")
30
+ return None
31
+
32
+
33
+ def _write_log_lines(lines):
34
+ try:
35
+ with open(ADMIN_LOG_PATH, "w", encoding="utf-8") as f:
36
+ f.write("\n".join(lines) + ("\n" if lines else ""))
37
+ except Exception as e:
38
+ print(f"[admin log] write failed: {e}")
39
+
40
+
41
+ def _remove_file(path):
42
+ try:
43
+ if path and os.path.exists(path):
44
+ os.remove(path)
45
+ except Exception as e:
46
+ print(f"[admin log] failed to remove {path}: {e}")
47
+
48
+
49
+ def _entry_path(line):
50
+ try:
51
+ return json.loads(line).get("path")
52
+ except Exception:
53
+ return None
54
+
55
+
56
+ def _entry_inputs(line):
57
+ try:
58
+ return list(json.loads(line).get("inputs") or [])
59
+ except Exception:
60
+ return []
61
+
62
+
63
+ def _entry_files(line):
64
+ return [_entry_path(line), *_entry_inputs(line)]
65
+
66
+
67
+ def save_input_images(sources, seed):
68
+ paths = []
69
+ try:
70
+ os.makedirs(ADMIN_INPUT_DIR, exist_ok=True)
71
+ stamp = int(time.time() * 1000)
72
+ for idx, src in enumerate([s for s in sources if s is not None]):
73
+ try:
74
+ src_path = src if isinstance(src, str) else getattr(src, "name", None)
75
+ if src_path and os.path.isfile(src_path):
76
+ ext = os.path.splitext(src_path)[1].lower()[:8] or ".png"
77
+ input_path = f"{ADMIN_INPUT_DIR}/input_{seed}_{idx}_{stamp}{ext}"
78
+ shutil.copy2(src_path, input_path)
79
+ else:
80
+ input_path = f"{ADMIN_INPUT_DIR}/input_{seed}_{idx}_{stamp}.png"
81
+ src.save(input_path)
82
+ paths.append(input_path)
83
+ except Exception as e:
84
+ print(f"[admin log] failed to save input image {idx}: {e}")
85
+ except Exception as e:
86
+ print(f"[admin log] failed to save input images: {e}")
87
+ return paths
88
+
89
+
90
+ def log_generation(path, prompt, seed, steps, guidance, model, width, height,
91
+ out_width=None, out_height=None, duplicated=False, inputs=None):
92
+ try:
93
+ os.makedirs("outputs", exist_ok=True)
94
+ entry = {
95
+ "path": path,
96
+ "prompt": prompt,
97
+ "seed": seed,
98
+ "steps": steps,
99
+ "guidance": guidance,
100
+ "upscaler": model,
101
+ "width": width,
102
+ "height": height,
103
+ "out_width": out_width,
104
+ "out_height": out_height,
105
+ "duplicated_input": duplicated,
106
+ "inputs": list(inputs or []),
107
+ "timestamp": time.time(),
108
+ }
109
+ with open(ADMIN_LOG_PATH, "a", encoding="utf-8") as f:
110
+ f.write(json.dumps(entry, ensure_ascii=False) + "\n")
111
+ _prune_log()
112
+ except Exception as e:
113
+ print(f"[admin log] failed to write entry: {e}")
114
+
115
+
116
+ def _prune_log():
117
+ lines = _read_log_lines()
118
+ if lines is None or len(lines) <= ADMIN_MAX_ENTRIES:
119
+ return
120
+ for line in lines[:-ADMIN_MAX_ENTRIES]:
121
+ for file_path in _entry_files(line):
122
+ _remove_file(file_path)
123
+ _write_log_lines(lines[-ADMIN_MAX_ENTRIES:])
124
+
125
+
126
+ def delete_admin_log_entry(path):
127
+ if not path:
128
+ return
129
+ lines = _read_log_lines()
130
+ if lines is None:
131
+ return
132
+ kept = [line for line in lines if _entry_path(line) != path]
133
+ removed = [line for line in lines if _entry_path(line) == path]
134
+ _write_log_lines(kept)
135
+ for line in removed:
136
+ for file_path in _entry_files(line):
137
+ _remove_file(file_path)
138
+ _remove_file(path)
139
+
140
+
141
+ def clear_all_admin_log():
142
+ lines = _read_log_lines()
143
+ if lines is None:
144
+ return
145
+ for line in lines:
146
+ for file_path in _entry_files(line):
147
+ _remove_file(file_path)
148
+ _write_log_lines([])
149
+
150
+
151
+ def load_admin_gallery(request: gr.Request, limit=200):
152
+ if not is_admin(request):
153
+ return []
154
+ items = []
155
+ for line in reversed(_read_log_lines() or []):
156
+ try:
157
+ e = json.loads(line)
158
+ except Exception:
159
+ continue
160
+ path = e.get("path")
161
+ if not path or not os.path.exists(path):
162
+ continue
163
+ ts = e.get("timestamp")
164
+ when = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(ts)) if ts else "?"
165
+ w, h = e.get("width"), e.get("height")
166
+ res_txt = f"{w}x{h}" if (w and h) else "auto"
167
+ ow, oh = e.get("out_width"), e.get("out_height")
168
+ if ow and oh and (ow, oh) != (w, h):
169
+ res_txt += f" -> {ow}x{oh}"
170
+ caption = (
171
+ f"[{when}] seed={e.get('seed')} | guidance={e.get('guidance')} | "
172
+ f"steps={e.get('steps')} | resolution={res_txt} | {e.get('prompt', '')}"
173
+ )
174
+ inputs = [p for p in (e.get("inputs") or []) if os.path.exists(p)]
175
+ items.append((path, caption, inputs))
176
+ if len(items) >= limit:
177
+ break
178
+ return items
179
+
180
+
181
+ _ADMIN_URL_OK_SESSIONS = set()
182
+ _ADMIN_SESSIONS = set()
183
+
184
+
185
+ _ADMIN_FAIL_TIMES = []
186
+ _ADMIN_FAIL_LIMIT = 8
187
+ _ADMIN_FAIL_WINDOW = 600
188
+
189
+
190
+ def is_admin(request):
191
+ sid = _session_id(request)
192
+ return bool(sid) and sid in _ADMIN_SESSIONS
193
+
194
+
195
+ def _too_many_admin_failures():
196
+ now = time.time()
197
+ _ADMIN_FAIL_TIMES[:] = [t for t in _ADMIN_FAIL_TIMES if now - t < _ADMIN_FAIL_WINDOW]
198
+ return len(_ADMIN_FAIL_TIMES) >= _ADMIN_FAIL_LIMIT
199
+
200
+
201
+ def check_admin_tab_visibility(request: gr.Request):
202
+ sid = _session_id(request)
203
+ if not ADMIN_URL_KEY or not sid:
204
+ return gr.update(visible=False)
205
+ key = request.query_params.get("admin", "") if request else ""
206
+ ok = hmac.compare_digest(str(key).encode("utf-8"), ADMIN_URL_KEY.encode("utf-8"))
207
+ if ok:
208
+ _ADMIN_URL_OK_SESSIONS.add(sid)
209
+ else:
210
+ _ADMIN_URL_OK_SESSIONS.discard(sid)
211
+ _ADMIN_SESSIONS.discard(sid)
212
+ return gr.update(visible=ok)
213
+
214
+
215
+ def check_admin_password(password, request: gr.Request):
216
+ locked = (gr.update(visible=True), gr.update(visible=False))
217
+ if not ADMIN_PASSWORD:
218
+ return (*locked, "⚠️ No ADMIN_PASSWORD secret set on this Space — admin gallery stays locked.")
219
+ sid = _session_id(request)
220
+ if not sid or sid not in _ADMIN_URL_OK_SESSIONS:
221
+ return (*locked, "❌ Wrong password")
222
+ if _too_many_admin_failures():
223
+ return (*locked, "⏳ Too many failed attempts — try again later.")
224
+ if hmac.compare_digest(str(password or "").encode("utf-8"), ADMIN_PASSWORD.encode("utf-8")):
225
+ _ADMIN_SESSIONS.add(sid)
226
+ return gr.update(visible=False), gr.update(visible=True), ""
227
+ _ADMIN_FAIL_TIMES.append(time.time())
228
+ time.sleep(1.0)
229
+ return (*locked, "❌ Wrong password")
230
+
231
+
232
+ APP_PASSWORD = os.environ.get("APP_PASSWORD", "")
233
+ _USER_SESSIONS = set()
234
+ _USER_LOCK = threading.Lock()
235
+ _USER_FAILS = {}
236
+ _USER_FAILS_ALL = []
237
+ _USER_MAX_FAILS = 5
238
+ _USER_MAX_FAILS_ALL = 60
239
+ _USER_WINDOW_S = 600
240
+ _USER_FAILS_MAX_KEYS = 5000
241
+
242
+
243
+ def is_user(request):
244
+ sid = _session_id(request)
245
+ if not sid:
246
+ return False
247
+ with _USER_LOCK:
248
+ return sid in _USER_SESSIONS
249
+
250
+
251
+ def check_user_password(password, request: gr.Request):
252
+ locked = (gr.update(visible=True), gr.update(visible=False))
253
+ if not APP_PASSWORD:
254
+ return (*locked, "⚠️ No APP_PASSWORD secret set on this Space — app stays locked.")
255
+ sid = _session_id(request)
256
+ ckey = _client_key(request)
257
+ now = time.time()
258
+ with _USER_LOCK:
259
+ _USER_FAILS_ALL[:] = [t for t in _USER_FAILS_ALL if now - t < _USER_WINDOW_S]
260
+ count, start = _USER_FAILS.get(ckey, [0, now])
261
+ if now - start > _USER_WINDOW_S:
262
+ count, start = 0, now
263
+ if count >= _USER_MAX_FAILS or len(_USER_FAILS_ALL) >= _USER_MAX_FAILS_ALL:
264
+ return (*locked, "⏳ Too many attempts — try again later.")
265
+ if sid and hmac.compare_digest(str(password or "").encode("utf-8"), APP_PASSWORD.encode("utf-8")):
266
+ with _USER_LOCK:
267
+ _USER_SESSIONS.add(sid)
268
+ _USER_FAILS.pop(ckey, None)
269
+ return gr.update(visible=False), gr.update(visible=True), ""
270
+ with _USER_LOCK:
271
+ _USER_FAILS[ckey] = [count + 1, start]
272
+ _USER_FAILS_ALL.append(now)
273
+ if len(_USER_FAILS) > _USER_FAILS_MAX_KEYS:
274
+ for k in [k for k, v in _USER_FAILS.items() if now - v[1] > _USER_WINDOW_S]:
275
+ _USER_FAILS.pop(k, None)
276
+ time.sleep(1.0)
277
+ return (*locked, "❌ Wrong password")
278
+
279
+
280
+ def end_session(request: gr.Request):
281
+ sid = _session_id(request)
282
+ if not sid:
283
+ return
284
+ with _USER_LOCK:
285
+ _USER_SESSIONS.discard(sid)
286
+ _ADMIN_SESSIONS.discard(sid)
287
+ _ADMIN_URL_OK_SESSIONS.discard(sid)
288
+
289
+
290
+ _BROWSER_SESSIONS = {}
291
+ _BROWSER_SESSIONS_MAX = 20000
292
+
293
+
294
+ def mark_browser_session(request: gr.Request):
295
+ sid = _session_id(request)
296
+ if not sid:
297
+ return
298
+ with _RATE_LOCK:
299
+ _BROWSER_SESSIONS[sid] = time.time()
300
+ while len(_BROWSER_SESSIONS) > _BROWSER_SESSIONS_MAX:
301
+ _BROWSER_SESSIONS.pop(next(iter(_BROWSER_SESSIONS)), None)
302
+
303
+
304
+ RATE_MAX_RUNS = int(os.environ.get("RATE_MAX_RUNS", "20"))
305
+ RATE_WINDOW_S = int(os.environ.get("RATE_WINDOW_S", "600"))
306
+ PENALTY_AFTER_BLOCKED = int(os.environ.get("PENALTY_AFTER_BLOCKED", "3"))
307
+ LOCKOUT_STEPS_S = (300, 1800, 7200, 43200)
308
+ OFFENCE_FORGET_S = 86400
309
+ SPAM_WHILE_LOCKED_N = int(os.environ.get("SPAM_WHILE_LOCKED_N", "3"))
310
+ SPAM_EXTRA_LOCK_S = int(os.environ.get("SPAM_EXTRA_LOCK_S", "300"))
311
+ LOCKOUT_MAX_S = int(os.environ.get("LOCKOUT_MAX_S", "86400"))
312
+ BUSY_FREE = int(os.environ.get("BUSY_FREE", "1"))
313
+ BUSY_WINDOW_S = 60
314
+ _RATE_LOCK = threading.Lock()
315
+ _RATE_HITS = {}
316
+ _RATE_BUSY = set()
317
+ _RATE_PENALTY = {}
318
+ _RATE_MAX_KEYS = 5000
319
+ _RATE_STATS = {
320
+ "allowed": 0,
321
+ "blocked_not_browser": 0,
322
+ "blocked_busy": 0,
323
+ "blocked_rate": 0,
324
+ "blocked_locked": 0,
325
+ "lockouts": 0,
326
+ "lockouts_extended": 0,
327
+ "since": time.time(),
328
+ }
329
+
330
+
331
+ TRUSTED_PROXY_HOPS = max(1, int(os.environ.get("TRUSTED_PROXY_HOPS", "1")))
332
+
333
+
334
+ def _normalize_ip(ip):
335
+ try:
336
+ addr = ipaddress.ip_address(str(ip).strip().strip("[]"))
337
+ except ValueError:
338
+ return ip
339
+ if addr.version == 6:
340
+ if addr.ipv4_mapped:
341
+ return str(addr.ipv4_mapped)
342
+ return str(ipaddress.ip_network(f"{addr}/64", strict=False))
343
+ return str(addr)
344
+
345
+
346
+ def _pick_forwarded(fwd):
347
+ parts = [p.strip() for p in fwd.split(",") if p.strip()]
348
+ if not parts:
349
+ return None
350
+ pick = parts[-TRUSTED_PROXY_HOPS] if len(parts) >= TRUSTED_PROXY_HOPS else parts[0]
351
+ return _normalize_ip(pick)
352
+
353
+
354
+ _IP_DEBUG_LEFT = int(os.environ.get("IP_DEBUG_LOG", "0"))
355
+
356
+
357
+ def _debug_ip(fwd, host, chosen):
358
+ global _IP_DEBUG_LEFT
359
+ if _IP_DEBUG_LEFT <= 0:
360
+ return
361
+ _IP_DEBUG_LEFT -= 1
362
+ print(f"[ip-debug] x-forwarded-for={fwd!r} client.host={host!r} hops={TRUSTED_PROXY_HOPS} -> key={chosen!r}")
363
+
364
+
365
+ def _client_key(request):
366
+ if request is None:
367
+ return "?"
368
+ host = getattr(getattr(request, "client", None), "host", None)
369
+ try:
370
+ fwd = request.headers.get("x-forwarded-for", "")
371
+ ip = _pick_forwarded(fwd)
372
+ if ip:
373
+ _debug_ip(fwd, host, ip)
374
+ return ip
375
+ except Exception:
376
+ pass
377
+ if host:
378
+ return _normalize_ip(host)
379
+ return _session_id(request) or "?"
380
+
381
+
382
+ def _fmt_wait(seconds):
383
+ seconds = max(1, int(seconds))
384
+ if seconds >= 3600:
385
+ return f"{(seconds + 3599) // 3600} h"
386
+ return f"{(seconds + 59) // 60} min"
387
+
388
+
389
+ class BlockMsg(str):
390
+ pass
391
+
392
+
393
+ def _paused_msg(seconds):
394
+ return BlockMsg(f"🚫 Too many requests - this connection is paused. "
395
+ f"Try again in about {_fmt_wait(seconds)}.")
396
+
397
+
398
+ def show_rate_message(msg):
399
+ if isinstance(msg, BlockMsg):
400
+ raise gr.Error(msg)
401
+ gr.Warning(msg)
402
+
403
+
404
+ def rate_acquire(request):
405
+ if is_admin(request):
406
+ return True, None, ""
407
+ key = _client_key(request)
408
+ sid = _session_id(request)
409
+ now = time.time()
410
+ with _RATE_LOCK:
411
+ if not sid or sid not in _BROWSER_SESSIONS:
412
+ _RATE_STATS["blocked_not_browser"] += 1
413
+ return False, None, "Please refresh the page and try again."
414
+
415
+ pen = _RATE_PENALTY.get(key)
416
+ if pen is not None and pen["level"] and now - pen["last"] > OFFENCE_FORGET_S and now >= pen["until"]:
417
+ _RATE_PENALTY.pop(key, None)
418
+ pen = None
419
+
420
+ if pen is not None and now < pen["until"]:
421
+ _RATE_STATS["blocked_locked"] += 1
422
+
423
+
424
+ pen["spam"] = pen.get("spam", 0) + 1
425
+ pen["last"] = now
426
+ if pen["spam"] >= SPAM_WHILE_LOCKED_N:
427
+ pen["spam"] = 0
428
+ pen["until"] = min(pen["until"] + SPAM_EXTRA_LOCK_S, now + LOCKOUT_MAX_S)
429
+ _RATE_STATS["lockouts_extended"] += 1
430
+ print(f"[rate] lock extended by {SPAM_EXTRA_LOCK_S}s on {key} (spam while locked)")
431
+ return False, None, _paused_msg(pen["until"] - now)
432
+
433
+ hits = [t for t in _RATE_HITS.get(key, []) if now - t < RATE_WINDOW_S]
434
+
435
+ if key in _RATE_BUSY:
436
+ _RATE_HITS[key] = hits
437
+ _RATE_STATS["blocked_busy"] += 1
438
+ pen = _RATE_PENALTY.setdefault(key, {"blocked": 0, "level": 0, "until": 0.0, "last": now})
439
+ busy = [t for t in pen.get("busy", []) if now - t < BUSY_WINDOW_S]
440
+ busy.append(now)
441
+ pen["busy"] = busy
442
+ if len(busy) > BUSY_FREE:
443
+ duration = LOCKOUT_STEPS_S[min(pen["level"], len(LOCKOUT_STEPS_S) - 1)]
444
+ pen["level"] += 1
445
+ pen["blocked"] = 0
446
+ pen["busy"] = []
447
+ pen["until"] = now + duration
448
+ pen["spam"] = 0
449
+ pen["last"] = now
450
+ _RATE_STATS["lockouts"] += 1
451
+ print(f"[rate] lockout #{pen['level']} for {duration}s on {key} (busy spam)")
452
+ return False, None, _paused_msg(duration)
453
+ return False, None, ("⚠️ You already have a run in progress - please wait for it to finish. "
454
+ "Warning: pressing Run again will pause this connection.")
455
+
456
+ if len(hits) >= RATE_MAX_RUNS:
457
+ _RATE_HITS[key] = hits
458
+ _RATE_STATS["blocked_rate"] += 1
459
+ pen = _RATE_PENALTY.setdefault(key, {"blocked": 0, "level": 0, "until": 0.0, "last": now})
460
+ pen["blocked"] += 1
461
+ if pen["blocked"] >= PENALTY_AFTER_BLOCKED:
462
+ duration = LOCKOUT_STEPS_S[min(pen["level"], len(LOCKOUT_STEPS_S) - 1)]
463
+ pen["level"] += 1
464
+ pen["blocked"] = 0
465
+ pen["until"] = now + duration
466
+ pen["spam"] = 0
467
+ pen["last"] = now
468
+ _RATE_STATS["lockouts"] += 1
469
+ print(f"[rate] lockout #{pen['level']} for {duration}s on {key}")
470
+ return False, None, _paused_msg(duration)
471
+ wait_min = max(1, int(RATE_WINDOW_S - (now - hits[0])) // 60 + 1)
472
+ return False, None, (f"⚠️ Rate limit reached ({RATE_MAX_RUNS} runs per "
473
+ f"{max(1, RATE_WINDOW_S // 60)} min). Try again in about {wait_min} min. "
474
+ f"Warning {pen['blocked']}/{PENALTY_AFTER_BLOCKED - 1}: "
475
+ f"pressing Run again will pause this connection.")
476
+
477
+ hits.append(now)
478
+ _RATE_HITS[key] = hits
479
+ _RATE_BUSY.add(key)
480
+ _RATE_STATS["allowed"] += 1
481
+ if len(_RATE_HITS) > _RATE_MAX_KEYS:
482
+ for k in [k for k, v in _RATE_HITS.items() if not v or now - v[-1] >= RATE_WINDOW_S]:
483
+ _RATE_HITS.pop(k, None)
484
+ if len(_RATE_PENALTY) > _RATE_MAX_KEYS:
485
+ for k in [k for k, v in _RATE_PENALTY.items()
486
+ if now >= v["until"] and now - v["last"] > OFFENCE_FORGET_S]:
487
+ _RATE_PENALTY.pop(k, None)
488
+ return True, key, ""
489
+
490
+
491
+ def rate_release(key, refund=False):
492
+ if key is None:
493
+ return
494
+ with _RATE_LOCK:
495
+ _RATE_BUSY.discard(key)
496
+ if refund:
497
+ hits = _RATE_HITS.get(key)
498
+ if hits:
499
+ hits.pop()
500
+ _RATE_STATS["allowed"] = max(0, _RATE_STATS["allowed"] - 1)
501
+
502
+
503
+ def is_quota_error(exc):
504
+ return "quota" in str(exc).lower()
505
+
506
+
507
+ FLOOD_MAX_JOINS = int(os.environ.get("FLOOD_MAX_JOINS", "8"))
508
+ FLOOD_MAX_UPLOADS = int(os.environ.get("FLOOD_MAX_UPLOADS", "4"))
509
+ FLOOD_WINDOW_S = int(os.environ.get("FLOOD_WINDOW_S", "10"))
510
+ FLOOD_BAN_STEPS = (60, 300, 1800, 7200, 43200)
511
+ FLOOD_STRIKE_FORGET_S = 86400
512
+ FLOOD_ALLOW_IPS = {_normalize_ip(x.strip()) for x in os.environ.get("FLOOD_ALLOW_IPS", "").split(",") if x.strip()}
513
+ _FLOOD_JOIN_PATHS = ("/queue/join", "/call/", "/run/")
514
+ _FLOOD_UPLOAD_PATHS = ("/upload",)
515
+ _FLOOD_LOCK = threading.Lock()
516
+ _FLOOD_HITS = {}
517
+ _FLOOD_BANNED = {}
518
+ _FLOOD_STRIKES = {}
519
+ _FLOOD_SPAM = {}
520
+ _FLOOD_STATS = {"bans": 0, "extended": 0}
521
+
522
+
523
+ def _scope_ip(scope):
524
+ try:
525
+ fwd = next((v.decode("latin-1") for k, v in scope.get("headers") or []
526
+ if k == b"x-forwarded-for"), "")
527
+ ip = _pick_forwarded(fwd)
528
+ if ip:
529
+ return ip
530
+ except Exception:
531
+ pass
532
+ client = scope.get("client")
533
+ return _normalize_ip(client[0]) if client else "?"
534
+
535
+
536
+ def _flood_retry_after(ip, bucket="join", limit=None):
537
+ if ip in FLOOD_ALLOW_IPS:
538
+ return 0
539
+ if limit is None:
540
+ limit = FLOOD_MAX_JOINS
541
+ now = time.time()
542
+ with _FLOOD_LOCK:
543
+ until = _FLOOD_BANNED.get(ip, 0.0)
544
+ if now < until:
545
+ spam = _FLOOD_SPAM.get(ip, 0) + 1
546
+ if spam >= SPAM_WHILE_LOCKED_N:
547
+ spam = 0
548
+ until = min(until + SPAM_EXTRA_LOCK_S, now + LOCKOUT_MAX_S)
549
+ _FLOOD_BANNED[ip] = until
550
+ _FLOOD_STATS["extended"] += 1
551
+ print(f"[flood] {ip} kept sending while banned - ban extended by {SPAM_EXTRA_LOCK_S}s")
552
+ _FLOOD_SPAM[ip] = spam
553
+ return int(until - now) + 1
554
+ if until:
555
+ _FLOOD_BANNED.pop(ip, None)
556
+ _FLOOD_SPAM.pop(ip, None)
557
+ hkey = (bucket, ip)
558
+ hits = [t for t in _FLOOD_HITS.get(hkey, []) if now - t < FLOOD_WINDOW_S]
559
+ hits.append(now)
560
+ if len(hits) > limit:
561
+ strikes, last = _FLOOD_STRIKES.get(ip, (0, 0.0))
562
+ if now - last > FLOOD_STRIKE_FORGET_S:
563
+ strikes = 0
564
+ ban = FLOOD_BAN_STEPS[min(strikes, len(FLOOD_BAN_STEPS) - 1)]
565
+ _FLOOD_STRIKES[ip] = (strikes + 1, now)
566
+ _FLOOD_BANNED[ip] = now + ban
567
+ _FLOOD_SPAM.pop(ip, None)
568
+ _FLOOD_STATS["bans"] += 1
569
+ _FLOOD_HITS.pop(hkey, None)
570
+ print(f"[flood] {ip} sent {len(hits)} {bucket} requests in {FLOOD_WINDOW_S}s - "
571
+ f"blocked for {ban}s (offence #{strikes + 1})")
572
+ return ban
573
+ _FLOOD_HITS[hkey] = hits
574
+ if len(_FLOOD_HITS) > _RATE_MAX_KEYS:
575
+ for k in [k for k, v in _FLOOD_HITS.items() if not v or now - v[-1] >= FLOOD_WINDOW_S]:
576
+ _FLOOD_HITS.pop(k, None)
577
+ if len(_FLOOD_BANNED) > _RATE_MAX_KEYS:
578
+ for k in [k for k, v in _FLOOD_BANNED.items() if now >= v]:
579
+ _FLOOD_BANNED.pop(k, None)
580
+ for k in [k for k in _FLOOD_SPAM if k not in _FLOOD_BANNED]:
581
+ _FLOOD_SPAM.pop(k, None)
582
+ if len(_FLOOD_STRIKES) > _RATE_MAX_KEYS:
583
+ for k in [k for k, v in _FLOOD_STRIKES.items() if now - v[1] > FLOOD_STRIKE_FORGET_S]:
584
+ _FLOOD_STRIKES.pop(k, None)
585
+ return 0
586
+
587
+
588
+ class FloodGuard:
589
+ def __init__(self, app):
590
+ self.app = app
591
+
592
+ async def __call__(self, scope, receive, send):
593
+ bucket, limit = None, 0
594
+ if scope.get("type") == "http" and scope.get("method") == "POST":
595
+ path = scope.get("path", "")
596
+ if any(p in path for p in _FLOOD_UPLOAD_PATHS):
597
+ bucket, limit = "upload", FLOOD_MAX_UPLOADS
598
+ elif any(p in path for p in _FLOOD_JOIN_PATHS):
599
+ bucket, limit = "join", FLOOD_MAX_JOINS
600
+ if bucket:
601
+ try:
602
+ wait = _flood_retry_after(_scope_ip(scope), bucket, limit)
603
+ except Exception:
604
+ wait = 0
605
+ if wait:
606
+ body = json.dumps({"error": f"Too many requests. Try again in {wait} s."}).encode()
607
+ await send({"type": "http.response.start", "status": 429, "headers": [
608
+ (b"content-type", b"application/json"),
609
+ (b"retry-after", str(wait).encode()),
610
+ (b"content-length", str(len(body)).encode()),
611
+ ]})
612
+ await send({"type": "http.response.body", "body": body})
613
+ return
614
+ await self.app(scope, receive, send)
615
+
616
+
617
+ def _mask_key(key):
618
+ parts = str(key).split(".")
619
+ if len(parts) == 4:
620
+ return f"{parts[0]}.{parts[1]}.*.*"
621
+ return str(key)[:9] + "..."
622
+
623
+
624
+ def load_rate_stats(request: gr.Request):
625
+ if not is_admin(request):
626
+ return ""
627
+ now = time.time()
628
+ with _RATE_LOCK:
629
+ st = dict(_RATE_STATS)
630
+ locked = [(k, v["until"] - now, v["level"]) for k, v in _RATE_PENALTY.items() if v["until"] > now]
631
+ sessions = len(_BROWSER_SESSIONS)
632
+ with _FLOOD_LOCK:
633
+ flood_bans = _FLOOD_STATS["bans"]
634
+ flood_ext = _FLOOD_STATS["extended"]
635
+ flood_now = sum(1 for v in _FLOOD_BANNED.values() if v > now)
636
+ hours = max(0.01, (now - st["since"]) / 3600)
637
+ lines = [
638
+ f"**Protection stats** (since last restart, {hours:.1f} h)",
639
+ "",
640
+ f"- Runs allowed: {st['allowed']}",
641
+ f"- Blocked - not a browser session: {st['blocked_not_browser']}",
642
+ f"- Blocked - over run limit: {st['blocked_rate']}",
643
+ f"- Blocked - run already in progress: {st['blocked_busy']}",
644
+ f"- Blocked - locked-out IP: {st['blocked_locked']}",
645
+ f"- Lockouts issued: {st['lockouts']}",
646
+ f"- Lockouts extended (spam while locked): {st['lockouts_extended']}",
647
+ f"- Browser sessions tracked: {sessions}",
648
+ f"- IPs locked right now: {len(locked)}",
649
+ f"- Flood-guard bans issued: {flood_bans}",
650
+ f"- Flood-guard bans extended (spam while banned): {flood_ext}",
651
+ f"- IPs flood-banned right now: {flood_now}",
652
+ ]
653
+ for k, remaining, level in sorted(locked, key=lambda x: -x[1])[:10]:
654
+ lines.append(f" - `{_mask_key(k)}` - level {level}, {_fmt_wait(remaining)} left")
655
+ return "\n".join(lines)