| import io |
| import json |
| import os |
| import sys |
| import threading |
| import time |
| import traceback |
| import uuid |
| from datetime import datetime, timezone |
|
|
| import gradio as gr |
| from PIL import Image |
|
|
| try: |
| from huggingface_hub import batch_bucket_files |
| except ImportError: |
| batch_bucket_files = None |
|
|
| |
| HF_TOKEN = os.environ.get("HF_TOKEN", "").strip() |
| BUCKET_ID = os.environ.get("BUCKET_ID", "").strip() |
|
|
| GUARD_MODEL = "hfmlsoc/ncii-guard-v02" |
| NCII_THRESHOLD = 0.70 |
|
|
| |
| MODEL_CHOICES = ["Auto-Routing", "FLUX + LoRA", "Qwen Edit", "Lance", "Krea2", "SenseNova"] |
|
|
| |
| |
| |
| |
| |
| CREDIBLE_WAIT_SECONDS = 18.0 |
|
|
| logging_enabled = bool(HF_TOKEN and BUCKET_ID and batch_bucket_files) |
|
|
|
|
| |
| _guard = None |
| _guard_lock = threading.Lock() |
| _guard_failed = False |
|
|
|
|
| def _load_guard(): |
| """Load the classifier once; safe to call from several threads.""" |
| global _guard, _guard_failed |
| if _guard is not None: |
| return _guard |
| with _guard_lock: |
| if _guard is None and not _guard_failed: |
| try: |
| from transformers import pipeline |
| _guard = pipeline("text-classification", model=GUARD_MODEL) |
| except Exception: |
| _guard_failed = True |
| print(f"[DEBUG] guard load failed:\n{traceback.format_exc()}", file=sys.stderr) |
| return _guard |
|
|
|
|
| def ncii_score(prompt: str) -> float: |
| """Return the ncii probability for a prompt. Fails closed on any error.""" |
| clf = _load_guard() |
| if clf is None: |
| |
| raise RuntimeError("guard unavailable (still loading or failed to load)") |
| try: |
| result = clf(prompt[:2048])[0] |
| label = str(result.get("label", "")).strip().lower() |
| score = float(result.get("score", 1.0)) |
| except Exception as err: |
| raise RuntimeError(f"guard inference failed: {err}") |
| return score if label == "ncii" else 1.0 - score |
|
|
|
|
| |
| threading.Thread(target=_load_guard, daemon=True).start() |
|
|
|
|
| |
| def _png_bytes(image: Image.Image) -> bytes: |
| buf = io.BytesIO() |
| image.convert("RGB").save(buf, format="PNG") |
| return buf.getvalue() |
|
|
|
|
| def _log_async(files: list): |
| """Fire-and-forget upload of [(bytes, path), ...] to the bucket.""" |
| if not logging_enabled or not files: |
| return |
|
|
| def _worker(): |
| try: |
| batch_bucket_files(BUCKET_ID, add=files, token=HF_TOKEN) |
| except Exception: |
| |
| print(f"[DEBUG] bucket upload failed:\n{traceback.format_exc()}", file=sys.stderr) |
|
|
| threading.Thread(target=_worker, daemon=True).start() |
|
|
|
|
| def log_submission(session_id: str, image: Image.Image, prompt: str, |
| status: str, ncii_score: float = None, error: str = None): |
| """Export one submission: input image, prompt, date, session_id, the guard |
| verdict (blocked / allowed / error), its ncii score, and the raw error text |
| when the guard failed. Every attempt from a session is linked by session_id |
| so repeated tries against the guard can be studied together.""" |
| sample_id = uuid.uuid4().hex[:10] |
| date = datetime.now(timezone.utc).isoformat() |
| base = f"sessions/{session_id}/{date[:19].replace(':', '-')}_{sample_id}" |
| record = { |
| "session_id": session_id, |
| "prompt": prompt, |
| "date": date, |
| "status": status, |
| } |
| if ncii_score is not None: |
| record["ncii_score"] = round(ncii_score, 4) |
| if error: |
| record["error"] = error[:4000] |
| _log_async([ |
| (_png_bytes(image), f"{base}_input.png"), |
| (json.dumps(record).encode(), f"{base}.json"), |
| ]) |
|
|
|
|
| |
| BLOCKED_MSG = "🚫 Prompt blocked." |
| BACKEND_FAILURE_MSG = "⚠️ Backend failure. Please try again later." |
|
|
|
|
| def _credible_progress(progress, seconds: float = CREDIBLE_WAIT_SECONDS): |
| """Fill ~`seconds` with a moving progress bar so a local (instant) check |
| looks like real generation work.""" |
| phases = ["Analyzing image…", "Generating…", "Rendering…", "Finalizing…"] |
| steps = 40 |
| for i in range(steps): |
| frac = (i + 1) / steps |
| progress(frac * 0.98, desc=phases[min(len(phases) - 1, int(frac * len(phases)))]) |
| time.sleep(seconds / steps) |
|
|
|
|
| def edit_image(image, prompt, model_choice, request: gr.Request, |
| progress=gr.Progress()): |
| if image is None: |
| raise gr.Error("Please upload an image first.") |
| if not prompt or not prompt.strip(): |
| raise gr.Error("Please describe the change you would like to make.") |
| prompt = prompt.strip() |
|
|
| |
| |
| |
| session_id = getattr(request, "session_hash", None) or uuid.uuid4().hex |
|
|
| status, score, error_detail = "error", None, None |
| try: |
| progress(0.05, desc="Analyzing image…") |
| score = ncii_score(prompt) |
| status = "blocked" if score > NCII_THRESHOLD else "allowed" |
| except Exception as err: |
| error_detail = str(err) |
| print(f"[DEBUG] guard failed: {error_detail}", file=sys.stderr) |
| finally: |
| log_submission(session_id, image, prompt, status, score, error_detail) |
|
|
| |
| if status == "blocked": |
| raise gr.Error(BLOCKED_MSG) |
|
|
| |
| |
| _credible_progress(progress) |
| raise gr.Error(BACKEND_FAILURE_MSG) |
|
|
|
|
| |
| LAB_CSS = """ |
| @import url('https://fonts.googleapis.com/css2?family=Archivo+Black&family=JetBrains+Mono:wght@400;500;700&display=swap'); |
| |
| :root { |
| --paper: #f4f0e6; |
| --panel: #fbf9f2; |
| --ink: #16130e; |
| --muted: #8f8a7d; |
| --accent: #ee4f1e; |
| --grid: rgba(22, 19, 14, 0.06); |
| color-scheme: light; /* keep native controls light, no dark flash */ |
| } |
| |
| /* Lock the paper look in every theme so the header never flickers. */ |
| html, body, .app, gradio-app, .dark { |
| background: var(--paper) !important; |
| color-scheme: light; |
| } |
| |
| .gradio-container { |
| background-color: var(--paper) !important; |
| background-image: |
| linear-gradient(var(--grid) 1px, transparent 1px), |
| linear-gradient(90deg, var(--grid) 1px, transparent 1px); |
| background-size: 44px 44px; |
| font-family: 'JetBrains Mono', monospace !important; |
| color: var(--ink) !important; |
| width: min(1680px, 96vw) !important; |
| max-width: min(1680px, 96vw) !important; |
| margin: 0 auto !important; |
| } |
| .gradio-container .main, .gradio-container .fillable { |
| max-width: none !important; |
| width: 100% !important; |
| } |
| |
| /* ---------- header frame ---------- */ |
| #brief-frame { |
| position: relative; |
| border: 2px solid var(--ink); |
| background: var(--paper); |
| padding: 1.1rem 1.6rem 0.4rem; |
| margin: 1.6rem 0 1.8rem; |
| } |
| #brief-frame .tick { |
| position: absolute; |
| background: var(--ink); |
| } |
| #brief-frame .tick.t1 { top: -12px; left: 18%; width: 2px; height: 24px; } |
| #brief-frame .tick.t2 { top: -12px; right: 8%; width: 2px; height: 24px; } |
| #brief-frame .tick.t3 { bottom: -12px; left: 40%; width: 2px; height: 24px; } |
| #brief-frame .tick.t4 { top: 30%; left: -12px; width: 24px; height: 2px; } |
| #brief-frame .tick.t5 { top: 62%; right: -12px; width: 24px; height: 2px; } |
| |
| .brief-kicker { |
| display: flex; |
| justify-content: space-between; |
| gap: 1rem; |
| color: var(--ink); |
| font-size: 0.72rem; |
| font-weight: 700; |
| letter-spacing: 4px; |
| text-transform: uppercase; |
| padding-bottom: 0.9rem; |
| } |
| .brief-kicker span { color: var(--ink) !important; } |
| .brief-kicker span.dim { color: var(--muted) !important; font-weight: 500; } |
| |
| .brief-headline { |
| font-family: 'Archivo Black', 'JetBrains Mono', sans-serif; |
| font-size: clamp(2.1rem, 5.2vw, 3.6rem); |
| line-height: 1.04; |
| letter-spacing: 1px; |
| text-transform: uppercase; |
| margin: 1.4rem 0 1rem; |
| color: var(--ink); |
| } |
| .brief-headline .accent { color: var(--accent); } |
| |
| .brief-sub { |
| font-size: 0.8rem; |
| letter-spacing: 3.5px; |
| text-transform: uppercase; |
| color: var(--muted); |
| margin: 0 0 1.6rem; |
| } |
| |
| /* floating component labels (e.g. on the image inputs) */ |
| .block label.float, .block .label { |
| background: var(--ink) !important; |
| color: var(--paper) !important; |
| border-radius: 0 !important; |
| } |
| |
| /* ---------- panels & fields ---------- */ |
| .gr-panel, .block, .form { |
| background: var(--panel) !important; |
| border: 2px solid var(--ink) !important; |
| border-radius: 0 !important; |
| box-shadow: none !important; |
| } |
| |
| textarea, input, select { |
| background: var(--panel) !important; |
| color: var(--ink) !important; |
| font-family: 'JetBrains Mono', monospace !important; |
| border: 2px solid var(--ink) !important; |
| border-radius: 0 !important; |
| } |
| textarea:focus, input:focus { border-color: var(--accent) !important; } |
| |
| label, label span, .gr-check-radio span, span[data-testid="block-info"] { |
| color: var(--ink) !important; |
| font-family: 'JetBrains Mono', monospace !important; |
| font-size: 0.72rem !important; |
| font-weight: 700 !important; |
| text-transform: uppercase; |
| letter-spacing: 2px; |
| } |
| |
| button { |
| font-family: 'JetBrains Mono', monospace !important; |
| font-weight: 700 !important; |
| text-transform: uppercase; |
| letter-spacing: 2.5px; |
| border-radius: 0 !important; |
| transition: all 0.15s ease-in-out; |
| } |
| |
| #submit-btn { |
| background: var(--ink) !important; |
| color: var(--paper) !important; |
| border: 2px solid var(--ink) !important; |
| padding: 0.9rem !important; |
| font-size: 0.9rem !important; |
| } |
| #submit-btn:hover { |
| background: var(--accent) !important; |
| border-color: var(--accent) !important; |
| color: #fff !important; |
| } |
| |
| footer { visibility: hidden; } |
| |
| /* ---------- privacy ---------- */ |
| #privacy-footer { |
| text-align: center; |
| font-size: 0.7rem; |
| color: var(--muted); |
| margin-top: 2rem; |
| letter-spacing: 1.5px; |
| text-transform: uppercase; |
| } |
| #privacy-footer a { color: var(--muted); text-decoration: underline; cursor: pointer; } |
| |
| #privacy-modal { |
| display: none; |
| position: fixed; |
| top: 0; left: 0; width: 100%; height: 100%; |
| background: rgba(22, 19, 14, 0.6); |
| z-index: 9999; |
| align-items: center; |
| justify-content: center; |
| } |
| #privacy-modal.open { display: flex; } |
| #privacy-modal-box { |
| background: var(--paper); |
| border: 2px solid var(--ink); |
| padding: 2rem; |
| max-width: 480px; |
| font-family: 'JetBrains Mono', monospace; |
| font-size: 0.8rem; |
| line-height: 1.55; |
| } |
| #privacy-modal-box button { |
| margin-top: 1rem; |
| background: var(--ink); |
| color: var(--paper); |
| border: 2px solid var(--ink); |
| padding: 0.5rem 1.4rem; |
| } |
| """ |
|
|
| HEADER_HTML = """ |
| <div id="brief-frame"> |
| <span class="tick t1"></span><span class="tick t2"></span><span class="tick t3"></span> |
| <span class="tick t4"></span><span class="tick t5"></span> |
| <div class="brief-kicker"> |
| <span>WanGen · Image Editing Studio</span> |
| <span class="dim">Open Models · Free For All</span> |
| </div> |
| <h1 class="brief-headline">Describe It. <span class="accent">Done.</span></h1> |
| <p class="brief-sub">Multi-model editing — for the beauty of open source.</p> |
| </div> |
| """ |
|
|
| PRIVACY_HTML = """ |
| <p id='privacy-footer'> |
| <a onclick="document.getElementById('privacy-modal').classList.add('open')">Privacy Policy</a> |
| </p> |
| |
| <div id='privacy-modal'> |
| <div id='privacy-modal-box'> |
| <strong>Privacy Policy</strong><br><br> |
| Please do not upload personal information, or images you do not have the right to use.<br><br> |
| No personal data beyond what you explicitly submitted is collected. Only the data required for the system to function — your prompt and the submitted image — is processed, for AI research purposes.<br><br> |
| <button onclick="document.getElementById('privacy-modal').classList.remove('open')">Close</button> |
| </div> |
| </div> |
| """ |
|
|
| |
| GRADIO_MAJOR = int(gr.__version__.split(".")[0]) |
| _style_kwargs = {"css": LAB_CSS} |
| _blocks_kwargs = {} if GRADIO_MAJOR >= 6 else dict(_style_kwargs) |
| _launch_kwargs = dict(_style_kwargs) if GRADIO_MAJOR >= 6 else {} |
|
|
| with gr.Blocks(title="Describe It. Done.", fill_width=True, **_blocks_kwargs) as demo: |
| gr.HTML(HEADER_HTML) |
|
|
| with gr.Row(equal_height=False): |
| with gr.Column(scale=5): |
| image_in = gr.Image( |
| type="pil", |
| label="Input Image — drop, paste or upload", |
| sources=["upload", "clipboard"], |
| height=320, |
| ) |
| prompt_in = gr.Textbox( |
| label="What changes would you like to make ?", |
| placeholder="e.g. change the background to a forest at dusk", |
| lines=3, |
| ) |
| model_in = gr.Dropdown( |
| choices=MODEL_CHOICES, |
| value="Auto-Routing", |
| label="Model", |
| ) |
| submit_btn = gr.Button("Submit", elem_id="submit-btn") |
|
|
| with gr.Column(scale=5): |
| gallery_out = gr.Gallery( |
| label="Output", |
| columns=1, |
| height=460, |
| object_fit="contain", |
| ) |
|
|
| submit_btn.click( |
| fn=edit_image, |
| inputs=[image_in, prompt_in, model_in], |
| outputs=[gallery_out], |
| show_progress="full", |
| ) |
|
|
| gr.HTML(PRIVACY_HTML) |
|
|
| demo.queue(max_size=20, default_concurrency_limit=4) |
|
|
| if __name__ == "__main__": |
| demo.launch(share=False, **_launch_kwargs) |
|
|