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>

Files changed (2) hide show
  1. __pycache__/app.cpython-312.pyc +0 -0
  2. 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 thread feeding
384
- a queue so the main thread can yield progress as each diffusion step lands.
 
 
 
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
- yield ("image", result["image"], result["seed"])
 
 
 
 
 
 
 
 
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 on success or
609
- the error on failure. The caller's unique id (``uid`` cookie) is recorded in
610
- the uploaded object's metadata.
 
 
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="PNG")
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=".png",
649
- content_type="image/png",
650
  uid=uid,
651
  )
652
  if result.get("ok"):
653
- status = {"r2_filekey": result["filekey"], "r2_bucket": result["bucket"]}
 
 
 
 
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), ``r2_filekey``/``r2_bucket`` (for callers
731
- that resolve their own public URL), ``first_frame_prompt`` (the intermediate
732
- image prompt) and ``seed``. Has no UI (registered via ``gr.api``).
 
 
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="PNG")
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=".png", content_type="image/png", uid=uid,
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="png",
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"],