Spaces:
Running on Zero
Running on Zero
nsfwalex Claude Opus 4.8 (1M context) commited on
Commit ·
0510568
1
Parent(s): bae8329
feat: per-image moderation check + WebP upload pipeline
Browse files- Run the VLM image-context (safety-rating JSON) check on every generated
image inside the same @spaces.GPU window, parse it best-effort, and return
it alongside the image, seed and R2 URL (generate tab + prompt_to_video_assets).
- Encode assets as WebP before R2 upload and return WebP files/presigned URLs;
downstream now only ever sees WebP. All 3 Spaces decode WebP input via PIL.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- __pycache__/app.cpython-312.pyc +0 -0
- app.py +120 -21
__pycache__/app.cpython-312.pyc
CHANGED
|
Binary files a/__pycache__/app.cpython-312.pyc and b/__pycache__/app.cpython-312.pyc differ
|
|
|
app.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
import inspect
|
| 2 |
import io
|
|
|
|
| 3 |
import os
|
| 4 |
import queue
|
| 5 |
import random
|
|
@@ -151,6 +152,82 @@ vlm_model.to("cuda").eval()
|
|
| 151 |
print("Assistant loaded!")
|
| 152 |
|
| 153 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
# =============================================================================
|
| 155 |
# Compel-based prompt weighting helpers (ported from the NoobXL11 reference
|
| 156 |
# space so long prompts and (weight:1.2) syntax work for the SDXL model).
|
|
@@ -380,8 +457,11 @@ def generate_image(
|
|
| 380 |
|
| 381 |
This is a generator (so ZeroGPU streams its yields back over SSE). It yields
|
| 382 |
``("progress", step, total)`` tuples during sampling and a final
|
| 383 |
-
``("image", image, seed)`` tuple. The sampler runs in a worker
|
| 384 |
-
a queue so the main thread can yield progress as each diffusion
|
|
|
|
|
|
|
|
|
|
| 385 |
"""
|
| 386 |
_gpu_start = time.time()
|
| 387 |
total_steps = int(num_inference_steps)
|
|
@@ -422,7 +502,15 @@ def generate_image(
|
|
| 422 |
)
|
| 423 |
if "error" in result:
|
| 424 |
raise result["error"]
|
| 425 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 426 |
|
| 427 |
|
| 428 |
def _generate_image_inner(
|
|
@@ -605,11 +693,13 @@ def generate_and_upload(
|
|
| 605 |
Generator yielding ``(image, seed, r2_status, progress)``. Streams per-step
|
| 606 |
sampling progress (image still None) and finishes with the real image, seed
|
| 607 |
and R2 status once the upload completes. The image is always the original
|
| 608 |
-
HF-generated asset; ``r2_status`` reports the uploaded filekey
|
| 609 |
-
|
| 610 |
-
|
|
|
|
|
|
|
| 611 |
"""
|
| 612 |
-
image, used = None, None
|
| 613 |
for ev in generate_image(
|
| 614 |
model_name, prompt, negative_prompt, use_negative_prompt,
|
| 615 |
height, width, num_inference_steps, guidance_scale, seed, randomize_seed,
|
|
@@ -623,12 +713,12 @@ def generate_and_upload(
|
|
| 623 |
"image", frac, step, total, f"Sampling {step}/{total}"
|
| 624 |
)
|
| 625 |
else:
|
| 626 |
-
_, image, used = ev
|
| 627 |
|
| 628 |
yield None, used, None, _progress("image", 0.97, label="Uploading")
|
| 629 |
uid = r2_uploader.uid_from_request(request)
|
| 630 |
buf = io.BytesIO()
|
| 631 |
-
image.save(buf, format="
|
| 632 |
params = {
|
| 633 |
"model": model_name,
|
| 634 |
"prompt": prompt,
|
|
@@ -645,14 +735,19 @@ def generate_and_upload(
|
|
| 645 |
prompt=prompt,
|
| 646 |
params=params,
|
| 647 |
data=buf.getvalue(),
|
| 648 |
-
ext=".
|
| 649 |
-
content_type="image/
|
| 650 |
uid=uid,
|
| 651 |
)
|
| 652 |
if result.get("ok"):
|
| 653 |
-
status = {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 654 |
else:
|
| 655 |
status = {"r2_error": result.get("error", "unknown error")}
|
|
|
|
| 656 |
yield image, used, status, _progress("done", 1.0, label="Done")
|
| 657 |
|
| 658 |
|
|
@@ -727,9 +822,11 @@ def prompt_to_video_assets(
|
|
| 727 |
"""Text -> first-frame image (uploaded to R2) + video generation prompt.
|
| 728 |
|
| 729 |
Streams progress and returns a dict with ``video_prompt``, ``first_frame_url``
|
| 730 |
-
(a presigned GET, usable directly),
|
| 731 |
-
that resolve their own public URL),
|
| 732 |
-
|
|
|
|
|
|
|
| 733 |
"""
|
| 734 |
state = {
|
| 735 |
"stage": "frame_prompt", "p": 0.0, "step": 0, "total": 0, "label": "",
|
|
@@ -738,6 +835,7 @@ def prompt_to_video_assets(
|
|
| 738 |
"r2_filekey": None,
|
| 739 |
"r2_bucket": None,
|
| 740 |
"video_prompt": None,
|
|
|
|
| 741 |
"seed": None,
|
| 742 |
"done": False,
|
| 743 |
"error": None,
|
|
@@ -778,7 +876,7 @@ def prompt_to_video_assets(
|
|
| 778 |
|
| 779 |
# --- Stage 2: image model renders the first frame -------------------------
|
| 780 |
base += _P2V_W_FRAME_PROMPT
|
| 781 |
-
image, used_seed = None, seed
|
| 782 |
use_negative_prompt = (model_name == MODEL_NOOBXL)
|
| 783 |
for ev in generate_image(
|
| 784 |
model_name, frame_prompt, NOOBXL_NEGATIVE, use_negative_prompt,
|
|
@@ -790,15 +888,16 @@ def prompt_to_video_assets(
|
|
| 790 |
frac=step / max(total, 1), step=step, total=total,
|
| 791 |
label=f"Rendering first frame {step}/{total}")
|
| 792 |
else:
|
| 793 |
-
_, image, used_seed = ev
|
| 794 |
state["seed"] = int(used_seed)
|
|
|
|
| 795 |
|
| 796 |
-
# --- Stage 3: upload the first frame to R2 -------------------------
|
| 797 |
base += _P2V_W_IMAGE
|
| 798 |
yield frame("upload", base, _P2V_W_UPLOAD, frac=0.1, label="Uploading first frame")
|
| 799 |
uid = r2_uploader.uid_from_request(request)
|
| 800 |
buf = io.BytesIO()
|
| 801 |
-
image.save(buf, format="
|
| 802 |
params = {
|
| 803 |
"model": model_name,
|
| 804 |
"prompt": frame_prompt,
|
|
@@ -814,7 +913,7 @@ def prompt_to_video_assets(
|
|
| 814 |
}
|
| 815 |
up = r2_uploader.upload_asset(
|
| 816 |
namespace=R2_NAMESPACE, prompt=frame_prompt, params=params,
|
| 817 |
-
data=buf.getvalue(), ext=".
|
| 818 |
)
|
| 819 |
if up.get("ok"):
|
| 820 |
state["r2_filekey"] = up["filekey"]
|
|
@@ -1015,7 +1114,7 @@ with gr.Blocks(fill_height=True) as demo:
|
|
| 1015 |
output_image = gr.Image(
|
| 1016 |
label="Generated Image",
|
| 1017 |
type="pil",
|
| 1018 |
-
format="
|
| 1019 |
show_label=False,
|
| 1020 |
height=600,
|
| 1021 |
buttons=["download", "share"],
|
|
|
|
| 1 |
import inspect
|
| 2 |
import io
|
| 3 |
+
import json
|
| 4 |
import os
|
| 5 |
import queue
|
| 6 |
import random
|
|
|
|
| 152 |
print("Assistant loaded!")
|
| 153 |
|
| 154 |
|
| 155 |
+
# =============================================================================
|
| 156 |
+
# Image-context (moderation) check
|
| 157 |
+
# =============================================================================
|
| 158 |
+
# Run on every freshly generated image, *inside the same @spaces.GPU window that
|
| 159 |
+
# produced it* — so it shares that GPU allocation rather than paying for a second
|
| 160 |
+
# ZeroGPU acquisition. The VLM fills a fixed safety-rating JSON; the reply is
|
| 161 |
+
# parsed best-effort and returned alongside the image and its R2 URL.
|
| 162 |
+
MODERATION_PROMPT = """fill the following json based on the image, do not output anything else:
|
| 163 |
+
{
|
| 164 |
+
"rating": "0_SAFE | 1_MILD_SUGGESTIVE | 2_STRONG_SUGGESTIVE | 3_NON_EXPLICIT_NUDITY | 4_EXPLICIT_SEXUAL | 5_PROHIBITED",
|
| 165 |
+
"confidence": 0.0,
|
| 166 |
+
"flags": {
|
| 167 |
+
"minor_or_young_looking": false,
|
| 168 |
+
"sexualized_minor_risk": false,
|
| 169 |
+
"explicit_genitals": false,
|
| 170 |
+
"sexual_act": false,
|
| 171 |
+
"non_consensual_or_abuse": false,
|
| 172 |
+
"bestiality": false,
|
| 173 |
+
"violence_or_gore": false,
|
| 174 |
+
"anime_or_drawn": false
|
| 175 |
+
}
|
| 176 |
+
}"""
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
def _parse_moderation_json(text):
|
| 180 |
+
"""Best-effort parse of the moderation JSON the VLM returns.
|
| 181 |
+
|
| 182 |
+
The model is asked for JSON only, but a reasoning finetune can still wrap it
|
| 183 |
+
in <think> tags or stray prose, so we strip those and slice the outermost
|
| 184 |
+
``{...}`` before ``json.loads``. Returns a dict with the parsed ``rating`` /
|
| 185 |
+
``confidence`` / ``flags`` keys (None when absent) plus ``ok`` and the
|
| 186 |
+
original ``raw`` text, so nothing is lost even when parsing fails.
|
| 187 |
+
"""
|
| 188 |
+
raw = (text or "").strip()
|
| 189 |
+
cleaned = raw.split("</think>")[-1].strip() if "</think>" in raw else raw
|
| 190 |
+
start, end = cleaned.find("{"), cleaned.rfind("}")
|
| 191 |
+
parsed = None
|
| 192 |
+
if start != -1 and end > start:
|
| 193 |
+
try:
|
| 194 |
+
parsed = json.loads(cleaned[start:end + 1])
|
| 195 |
+
except (ValueError, TypeError):
|
| 196 |
+
parsed = None
|
| 197 |
+
if isinstance(parsed, dict):
|
| 198 |
+
return {
|
| 199 |
+
"ok": True,
|
| 200 |
+
"rating": parsed.get("rating"),
|
| 201 |
+
"confidence": parsed.get("confidence"),
|
| 202 |
+
"flags": parsed.get("flags"),
|
| 203 |
+
"raw": raw,
|
| 204 |
+
}
|
| 205 |
+
return {"ok": False, "rating": None, "confidence": None, "flags": None, "raw": raw}
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
def _moderate_image_inner(image, max_new_tokens=320):
|
| 209 |
+
"""Synchronously run the VLM moderation check on a PIL image (GPU-side)."""
|
| 210 |
+
messages = [{
|
| 211 |
+
"role": "user",
|
| 212 |
+
"content": [
|
| 213 |
+
{"type": "image", "image": image},
|
| 214 |
+
{"type": "text", "text": MODERATION_PROMPT},
|
| 215 |
+
],
|
| 216 |
+
}]
|
| 217 |
+
inputs = vlm_processor.apply_chat_template(
|
| 218 |
+
messages, tokenize=True, add_generation_prompt=True,
|
| 219 |
+
return_dict=True, return_tensors="pt", enable_thinking=False,
|
| 220 |
+
).to(vlm_model.device)
|
| 221 |
+
with torch.inference_mode():
|
| 222 |
+
generated = vlm_model.generate(
|
| 223 |
+
**inputs, max_new_tokens=int(max_new_tokens), do_sample=False,
|
| 224 |
+
)
|
| 225 |
+
trimmed = generated[0][inputs["input_ids"].shape[-1]:]
|
| 226 |
+
tokenizer = getattr(vlm_processor, "tokenizer", vlm_processor)
|
| 227 |
+
text = tokenizer.decode(trimmed, skip_special_tokens=True)
|
| 228 |
+
return _parse_moderation_json(text)
|
| 229 |
+
|
| 230 |
+
|
| 231 |
# =============================================================================
|
| 232 |
# Compel-based prompt weighting helpers (ported from the NoobXL11 reference
|
| 233 |
# space so long prompts and (weight:1.2) syntax work for the SDXL model).
|
|
|
|
| 457 |
|
| 458 |
This is a generator (so ZeroGPU streams its yields back over SSE). It yields
|
| 459 |
``("progress", step, total)`` tuples during sampling and a final
|
| 460 |
+
``("image", image, seed, moderation)`` tuple. The sampler runs in a worker
|
| 461 |
+
thread feeding a queue so the main thread can yield progress as each diffusion
|
| 462 |
+
step lands. Once the image is ready, the VLM image-context (moderation) check
|
| 463 |
+
runs in this same GPU window and its best-effort-parsed result rides along on
|
| 464 |
+
the final tuple.
|
| 465 |
"""
|
| 466 |
_gpu_start = time.time()
|
| 467 |
total_steps = int(num_inference_steps)
|
|
|
|
| 502 |
)
|
| 503 |
if "error" in result:
|
| 504 |
raise result["error"]
|
| 505 |
+
image = result["image"]
|
| 506 |
+
# Same GPU window: run the image-context (moderation) check on the freshly
|
| 507 |
+
# generated image before the GPU allocation is released.
|
| 508 |
+
try:
|
| 509 |
+
moderation = _moderate_image_inner(image)
|
| 510 |
+
except Exception as exc: # noqa: BLE001 - never let moderation break generation
|
| 511 |
+
moderation = {"ok": False, "rating": None, "confidence": None,
|
| 512 |
+
"flags": None, "raw": "", "error": f"{type(exc).__name__}: {exc}"}
|
| 513 |
+
yield ("image", image, result["seed"], moderation)
|
| 514 |
|
| 515 |
|
| 516 |
def _generate_image_inner(
|
|
|
|
| 693 |
Generator yielding ``(image, seed, r2_status, progress)``. Streams per-step
|
| 694 |
sampling progress (image still None) and finishes with the real image, seed
|
| 695 |
and R2 status once the upload completes. The image is always the original
|
| 696 |
+
HF-generated asset; ``r2_status`` reports the uploaded filekey, bucket and a
|
| 697 |
+
presigned ``r2_url`` on success (or the error on failure) plus the VLM
|
| 698 |
+
``moderation`` result. The asset is encoded as **WebP** before upload (and
|
| 699 |
+
the returned file is WebP too) so downstream consumers only ever see WebP.
|
| 700 |
+
The caller's unique id (``uid`` cookie) is recorded in the object's metadata.
|
| 701 |
"""
|
| 702 |
+
image, used, moderation = None, None, None
|
| 703 |
for ev in generate_image(
|
| 704 |
model_name, prompt, negative_prompt, use_negative_prompt,
|
| 705 |
height, width, num_inference_steps, guidance_scale, seed, randomize_seed,
|
|
|
|
| 713 |
"image", frac, step, total, f"Sampling {step}/{total}"
|
| 714 |
)
|
| 715 |
else:
|
| 716 |
+
_, image, used, moderation = ev
|
| 717 |
|
| 718 |
yield None, used, None, _progress("image", 0.97, label="Uploading")
|
| 719 |
uid = r2_uploader.uid_from_request(request)
|
| 720 |
buf = io.BytesIO()
|
| 721 |
+
image.save(buf, format="WEBP", quality=95, method=6)
|
| 722 |
params = {
|
| 723 |
"model": model_name,
|
| 724 |
"prompt": prompt,
|
|
|
|
| 735 |
prompt=prompt,
|
| 736 |
params=params,
|
| 737 |
data=buf.getvalue(),
|
| 738 |
+
ext=".webp",
|
| 739 |
+
content_type="image/webp",
|
| 740 |
uid=uid,
|
| 741 |
)
|
| 742 |
if result.get("ok"):
|
| 743 |
+
status = {
|
| 744 |
+
"r2_filekey": result["filekey"],
|
| 745 |
+
"r2_bucket": result["bucket"],
|
| 746 |
+
"r2_url": r2_uploader.presign_get_url(result["filekey"], result["bucket"]),
|
| 747 |
+
}
|
| 748 |
else:
|
| 749 |
status = {"r2_error": result.get("error", "unknown error")}
|
| 750 |
+
status["moderation"] = moderation
|
| 751 |
yield image, used, status, _progress("done", 1.0, label="Done")
|
| 752 |
|
| 753 |
|
|
|
|
| 822 |
"""Text -> first-frame image (uploaded to R2) + video generation prompt.
|
| 823 |
|
| 824 |
Streams progress and returns a dict with ``video_prompt``, ``first_frame_url``
|
| 825 |
+
(a presigned GET to the **WebP** first frame, usable directly),
|
| 826 |
+
``r2_filekey``/``r2_bucket`` (for callers that resolve their own public URL),
|
| 827 |
+
``first_frame_prompt`` (the intermediate image prompt), ``moderation`` (the
|
| 828 |
+
VLM image-context check on the rendered frame) and ``seed``. Has no UI
|
| 829 |
+
(registered via ``gr.api``).
|
| 830 |
"""
|
| 831 |
state = {
|
| 832 |
"stage": "frame_prompt", "p": 0.0, "step": 0, "total": 0, "label": "",
|
|
|
|
| 835 |
"r2_filekey": None,
|
| 836 |
"r2_bucket": None,
|
| 837 |
"video_prompt": None,
|
| 838 |
+
"moderation": None,
|
| 839 |
"seed": None,
|
| 840 |
"done": False,
|
| 841 |
"error": None,
|
|
|
|
| 876 |
|
| 877 |
# --- Stage 2: image model renders the first frame -------------------------
|
| 878 |
base += _P2V_W_FRAME_PROMPT
|
| 879 |
+
image, used_seed, moderation = None, seed, None
|
| 880 |
use_negative_prompt = (model_name == MODEL_NOOBXL)
|
| 881 |
for ev in generate_image(
|
| 882 |
model_name, frame_prompt, NOOBXL_NEGATIVE, use_negative_prompt,
|
|
|
|
| 888 |
frac=step / max(total, 1), step=step, total=total,
|
| 889 |
label=f"Rendering first frame {step}/{total}")
|
| 890 |
else:
|
| 891 |
+
_, image, used_seed, moderation = ev
|
| 892 |
state["seed"] = int(used_seed)
|
| 893 |
+
state["moderation"] = moderation
|
| 894 |
|
| 895 |
+
# --- Stage 3: upload the first frame to R2 (WebP) -------------------------
|
| 896 |
base += _P2V_W_IMAGE
|
| 897 |
yield frame("upload", base, _P2V_W_UPLOAD, frac=0.1, label="Uploading first frame")
|
| 898 |
uid = r2_uploader.uid_from_request(request)
|
| 899 |
buf = io.BytesIO()
|
| 900 |
+
image.save(buf, format="WEBP", quality=95, method=6)
|
| 901 |
params = {
|
| 902 |
"model": model_name,
|
| 903 |
"prompt": frame_prompt,
|
|
|
|
| 913 |
}
|
| 914 |
up = r2_uploader.upload_asset(
|
| 915 |
namespace=R2_NAMESPACE, prompt=frame_prompt, params=params,
|
| 916 |
+
data=buf.getvalue(), ext=".webp", content_type="image/webp", uid=uid,
|
| 917 |
)
|
| 918 |
if up.get("ok"):
|
| 919 |
state["r2_filekey"] = up["filekey"]
|
|
|
|
| 1114 |
output_image = gr.Image(
|
| 1115 |
label="Generated Image",
|
| 1116 |
type="pil",
|
| 1117 |
+
format="webp",
|
| 1118 |
show_label=False,
|
| 1119 |
height=600,
|
| 1120 |
buttons=["download", "share"],
|