Spaces:
Running on Zero
Running on Zero
ImageStudio Maintainer Claude Opus 4.8 (1M context) commited on
Commit ·
daf5e70
1
Parent(s): d6b6688
feat: upload generated images to R2 with prompt/params + requester uid in metadata
Browse filesAdd r2_uploader helper that pushes each generated image to the
free-generated-assets R2 bucket (key yyyymmddhhmmss-s01-<rand>.png) with the
prompt, request params and the caller's uid cookie recorded as object metadata.
The generate handler now returns the uploaded filekey alongside the original
image, or an error plus the original image on failure.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- app.py +72 -2
- r2_uploader.py +185 -0
- requirements.txt +1 -0
app.py
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
|
|
| 1 |
import random
|
| 2 |
import re
|
| 3 |
|
|
@@ -8,6 +9,13 @@ import gradio as gr
|
|
| 8 |
from diffusers import DiffusionPipeline, AutoPipelineForText2Image, AutoencoderKL
|
| 9 |
from compel import Compel, ReturnedEmbeddingsType
|
| 10 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
# =============================================================================
|
| 12 |
# Model registry
|
| 13 |
# =============================================================================
|
|
@@ -318,6 +326,63 @@ def generate_image(
|
|
| 318 |
return image, seed
|
| 319 |
|
| 320 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 321 |
# Recommended defaults per model: (steps, guidance, height, width)
|
| 322 |
MODEL_DEFAULTS = {
|
| 323 |
MODEL_ZIMAGE: dict(steps=9, guidance=0.0, height=1024, width=1024),
|
|
@@ -495,6 +560,7 @@ with gr.Blocks(fill_height=True) as demo:
|
|
| 495 |
used_seed = gr.Number(
|
| 496 |
label="🎲 Seed Used", interactive=False, container=True,
|
| 497 |
)
|
|
|
|
| 498 |
|
| 499 |
gr.Markdown(
|
| 500 |
"""
|
|
@@ -522,11 +588,15 @@ with gr.Blocks(fill_height=True) as demo:
|
|
| 522 |
height, width, num_inference_steps, guidance_scale, seed, randomize_seed,
|
| 523 |
]
|
| 524 |
|
|
|
|
|
|
|
| 525 |
generate_btn.click(
|
| 526 |
-
fn=
|
|
|
|
| 527 |
)
|
| 528 |
prompt.submit(
|
| 529 |
-
fn=
|
|
|
|
| 530 |
)
|
| 531 |
|
| 532 |
if __name__ == "__main__":
|
|
|
|
| 1 |
+
import io
|
| 2 |
import random
|
| 3 |
import re
|
| 4 |
|
|
|
|
| 9 |
from diffusers import DiffusionPipeline, AutoPipelineForText2Image, AutoencoderKL
|
| 10 |
from compel import Compel, ReturnedEmbeddingsType
|
| 11 |
|
| 12 |
+
import r2_uploader
|
| 13 |
+
|
| 14 |
+
# Per-Space namespace embedded in every uploaded object key. Deliberately opaque
|
| 15 |
+
# (not the readable Space name) but stable so the owner can tell assets apart.
|
| 16 |
+
# Legend: s01=ImageStudio, s02=LTX2.3-Studio, s03=wan2-2-fp8da-aoti-preview-2.
|
| 17 |
+
R2_NAMESPACE = "s01"
|
| 18 |
+
|
| 19 |
# =============================================================================
|
| 20 |
# Model registry
|
| 21 |
# =============================================================================
|
|
|
|
| 326 |
return image, seed
|
| 327 |
|
| 328 |
|
| 329 |
+
def generate_and_upload(
|
| 330 |
+
model_name,
|
| 331 |
+
prompt,
|
| 332 |
+
negative_prompt,
|
| 333 |
+
use_negative_prompt,
|
| 334 |
+
height,
|
| 335 |
+
width,
|
| 336 |
+
num_inference_steps,
|
| 337 |
+
guidance_scale,
|
| 338 |
+
seed,
|
| 339 |
+
randomize_seed,
|
| 340 |
+
request: gr.Request = None,
|
| 341 |
+
progress=gr.Progress(track_tqdm=True),
|
| 342 |
+
):
|
| 343 |
+
"""Generate, then upload the result to R2 outside the GPU window.
|
| 344 |
+
|
| 345 |
+
Returns ``(image, seed, r2_status)``. The image is always the original
|
| 346 |
+
HF-generated asset; ``r2_status`` reports the uploaded filekey on success or
|
| 347 |
+
the error on failure. The caller's unique id (``uid`` cookie) is recorded in
|
| 348 |
+
the uploaded object's metadata.
|
| 349 |
+
"""
|
| 350 |
+
image, used = generate_image(
|
| 351 |
+
model_name, prompt, negative_prompt, use_negative_prompt,
|
| 352 |
+
height, width, num_inference_steps, guidance_scale, seed, randomize_seed,
|
| 353 |
+
progress=progress,
|
| 354 |
+
)
|
| 355 |
+
|
| 356 |
+
uid = r2_uploader.uid_from_request(request)
|
| 357 |
+
buf = io.BytesIO()
|
| 358 |
+
image.save(buf, format="PNG")
|
| 359 |
+
params = {
|
| 360 |
+
"model": model_name,
|
| 361 |
+
"prompt": prompt,
|
| 362 |
+
"negative_prompt": (negative_prompt if use_negative_prompt else ""),
|
| 363 |
+
"height": int(height),
|
| 364 |
+
"width": int(width),
|
| 365 |
+
"num_inference_steps": int(num_inference_steps),
|
| 366 |
+
"guidance_scale": float(guidance_scale),
|
| 367 |
+
"seed": int(used),
|
| 368 |
+
"uid": uid,
|
| 369 |
+
}
|
| 370 |
+
result = r2_uploader.upload_asset(
|
| 371 |
+
namespace=R2_NAMESPACE,
|
| 372 |
+
prompt=prompt,
|
| 373 |
+
params=params,
|
| 374 |
+
data=buf.getvalue(),
|
| 375 |
+
ext=".png",
|
| 376 |
+
content_type="image/png",
|
| 377 |
+
uid=uid,
|
| 378 |
+
)
|
| 379 |
+
if result.get("ok"):
|
| 380 |
+
status = {"r2_filekey": result["filekey"], "r2_bucket": result["bucket"]}
|
| 381 |
+
else:
|
| 382 |
+
status = {"r2_error": result.get("error", "unknown error")}
|
| 383 |
+
return image, used, status
|
| 384 |
+
|
| 385 |
+
|
| 386 |
# Recommended defaults per model: (steps, guidance, height, width)
|
| 387 |
MODEL_DEFAULTS = {
|
| 388 |
MODEL_ZIMAGE: dict(steps=9, guidance=0.0, height=1024, width=1024),
|
|
|
|
| 560 |
used_seed = gr.Number(
|
| 561 |
label="🎲 Seed Used", interactive=False, container=True,
|
| 562 |
)
|
| 563 |
+
r2_status = gr.JSON(label="☁️ R2 Upload")
|
| 564 |
|
| 565 |
gr.Markdown(
|
| 566 |
"""
|
|
|
|
| 588 |
height, width, num_inference_steps, guidance_scale, seed, randomize_seed,
|
| 589 |
]
|
| 590 |
|
| 591 |
+
# api_name pinned so the generator's "/generate_image" endpoint keeps
|
| 592 |
+
# resolving even though the click now runs the upload wrapper.
|
| 593 |
generate_btn.click(
|
| 594 |
+
fn=generate_and_upload, inputs=gen_inputs,
|
| 595 |
+
outputs=[output_image, used_seed, r2_status], api_name="generate_image",
|
| 596 |
)
|
| 597 |
prompt.submit(
|
| 598 |
+
fn=generate_and_upload, inputs=gen_inputs,
|
| 599 |
+
outputs=[output_image, used_seed, r2_status], show_api=False,
|
| 600 |
)
|
| 601 |
|
| 602 |
if __name__ == "__main__":
|
r2_uploader.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Cloudflare R2 asset uploader.
|
| 2 |
+
|
| 3 |
+
Reads R2 credentials from the ``SCONFIG`` Space secret (a JSON blob) and uploads
|
| 4 |
+
generated assets to a preset bucket. Object keys follow
|
| 5 |
+
``<yyyymmddhhmmss>-<namespace>-<rand>.<ext>``; the prompt and request params are
|
| 6 |
+
attached as JSON object metadata so the owner can trace any asset back to the
|
| 7 |
+
request that produced it.
|
| 8 |
+
|
| 9 |
+
The ``SCONFIG`` secret is JSON shaped like::
|
| 10 |
+
|
| 11 |
+
{
|
| 12 |
+
"account_id": "<cloudflare account id>",
|
| 13 |
+
"akey": "<R2 access key id>",
|
| 14 |
+
"skey": "<R2 secret access key>",
|
| 15 |
+
"token": "<cloudflare api token value>", # optional, not used for S3
|
| 16 |
+
"bucket": "free-generated-assets" # optional, this is the default
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
``upload_asset`` never raises: callers always also return the original
|
| 20 |
+
HF-generated asset, so an R2 failure degrades to "asset shown, upload errored"
|
| 21 |
+
rather than breaking generation.
|
| 22 |
+
"""
|
| 23 |
+
from __future__ import annotations
|
| 24 |
+
|
| 25 |
+
import datetime
|
| 26 |
+
import json
|
| 27 |
+
import mimetypes
|
| 28 |
+
import os
|
| 29 |
+
import threading
|
| 30 |
+
import uuid
|
| 31 |
+
|
| 32 |
+
CONFIG_ENV = "SCONFIG"
|
| 33 |
+
DEFAULT_BUCKET = "free-generated-assets"
|
| 34 |
+
_MAX_META_BYTES = 2000 # R2/S3 cap user metadata; stay well under header limits.
|
| 35 |
+
|
| 36 |
+
_ensured_buckets: set[str] = set()
|
| 37 |
+
_ensure_lock = threading.Lock()
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _load_cfg() -> dict:
|
| 41 |
+
raw = (os.environ.get(CONFIG_ENV) or "").strip()
|
| 42 |
+
if not raw:
|
| 43 |
+
raise RuntimeError(f"{CONFIG_ENV} secret is not set")
|
| 44 |
+
return json.loads(raw)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def uid_from_request(request, cookie_name: str = "uid") -> str:
|
| 48 |
+
"""Best-effort read of the caller's unique id from a gr.Request cookie.
|
| 49 |
+
|
| 50 |
+
The web app forwards the per-browser anonymous id and the generator sets it
|
| 51 |
+
as the ``uid`` cookie on the Space call; we record it in object metadata so
|
| 52 |
+
every asset traces back to the requester. Returns "" if absent.
|
| 53 |
+
"""
|
| 54 |
+
if request is None:
|
| 55 |
+
return ""
|
| 56 |
+
try:
|
| 57 |
+
cookies = getattr(request, "cookies", None)
|
| 58 |
+
if isinstance(cookies, dict) and cookies.get(cookie_name):
|
| 59 |
+
return str(cookies[cookie_name])
|
| 60 |
+
headers = getattr(request, "headers", None)
|
| 61 |
+
raw = ""
|
| 62 |
+
if headers is not None:
|
| 63 |
+
raw = (headers.get("cookie") if hasattr(headers, "get") else "") or ""
|
| 64 |
+
for part in raw.split(";"):
|
| 65 |
+
k, _, v = part.strip().partition("=")
|
| 66 |
+
if k == cookie_name and v:
|
| 67 |
+
from urllib.parse import unquote
|
| 68 |
+
|
| 69 |
+
return unquote(v)
|
| 70 |
+
except Exception: # noqa: BLE001 - identity is best-effort, never fatal
|
| 71 |
+
return ""
|
| 72 |
+
return ""
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def _client(cfg: dict):
|
| 76 |
+
import boto3
|
| 77 |
+
from botocore.config import Config
|
| 78 |
+
|
| 79 |
+
return boto3.client(
|
| 80 |
+
"s3",
|
| 81 |
+
endpoint_url=f"https://{cfg['account_id']}.r2.cloudflarestorage.com",
|
| 82 |
+
aws_access_key_id=cfg["akey"],
|
| 83 |
+
aws_secret_access_key=cfg["skey"],
|
| 84 |
+
region_name="auto",
|
| 85 |
+
config=Config(signature_version="s3v4", retries={"max_attempts": 3}),
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def _now_stamp() -> str:
|
| 90 |
+
return datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%d%H%M%S")
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def _meta_blob(prompt, params, uid: str = "") -> str:
|
| 94 |
+
"""Compact JSON of uid + prompt + params, truncated to fit R2 metadata limits."""
|
| 95 |
+
blob = json.dumps({"uid": uid, "prompt": prompt, "params": params}, ensure_ascii=True, default=str)
|
| 96 |
+
if len(blob.encode("utf-8")) <= _MAX_META_BYTES:
|
| 97 |
+
return blob
|
| 98 |
+
keep = max(0, _MAX_META_BYTES - len(json.dumps({"uid": uid, "prompt": "", "params": params}, default=str)) - 16)
|
| 99 |
+
short = {"uid": uid, "prompt": str(prompt)[:keep] + "...[truncated]", "params": params}
|
| 100 |
+
return json.dumps(short, ensure_ascii=True, default=str)[:_MAX_META_BYTES]
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def _ensure_bucket(s3, bucket: str) -> None:
|
| 104 |
+
"""Create the bucket on first use if it does not already exist (best effort)."""
|
| 105 |
+
if bucket in _ensured_buckets:
|
| 106 |
+
return
|
| 107 |
+
with _ensure_lock:
|
| 108 |
+
if bucket in _ensured_buckets:
|
| 109 |
+
return
|
| 110 |
+
from botocore.exceptions import ClientError
|
| 111 |
+
|
| 112 |
+
try:
|
| 113 |
+
s3.head_bucket(Bucket=bucket)
|
| 114 |
+
except ClientError as exc:
|
| 115 |
+
code = str(exc.response.get("Error", {}).get("Code", ""))
|
| 116 |
+
if code in ("404", "NoSuchBucket", "NotFound"):
|
| 117 |
+
# Best effort: object-scoped tokens can't create buckets. If this
|
| 118 |
+
# fails, let the subsequent put_object surface the real error
|
| 119 |
+
# (NoSuchBucket) rather than masking it with an AccessDenied here.
|
| 120 |
+
try:
|
| 121 |
+
s3.create_bucket(Bucket=bucket)
|
| 122 |
+
except ClientError:
|
| 123 |
+
pass
|
| 124 |
+
# Other codes (e.g. 403 with object-only tokens) are ignored; the
|
| 125 |
+
# subsequent put_object is the real test.
|
| 126 |
+
_ensured_buckets.add(bucket)
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def upload_asset(
|
| 130 |
+
*,
|
| 131 |
+
namespace: str,
|
| 132 |
+
prompt,
|
| 133 |
+
params: dict,
|
| 134 |
+
data: bytes | None = None,
|
| 135 |
+
path: str | None = None,
|
| 136 |
+
ext: str | None = None,
|
| 137 |
+
content_type: str | None = None,
|
| 138 |
+
uid: str = "",
|
| 139 |
+
) -> dict:
|
| 140 |
+
"""Upload one asset to R2.
|
| 141 |
+
|
| 142 |
+
Provide either ``data`` (raw bytes) or ``path`` (a local file). ``uid`` is
|
| 143 |
+
the caller's unique id (see :func:`uid_from_request`) and is recorded both as
|
| 144 |
+
a dedicated object-metadata field and inside the generation JSON. Returns
|
| 145 |
+
``{"ok": True, "filekey": ..., "bucket": ...}`` on success or
|
| 146 |
+
``{"ok": False, "error": ...}`` on failure. Never raises.
|
| 147 |
+
"""
|
| 148 |
+
try:
|
| 149 |
+
if data is None and path is None:
|
| 150 |
+
raise ValueError("upload_asset requires either data or path")
|
| 151 |
+
|
| 152 |
+
cfg = _load_cfg()
|
| 153 |
+
bucket = cfg.get("bucket") or DEFAULT_BUCKET
|
| 154 |
+
|
| 155 |
+
if ext is None and path is not None:
|
| 156 |
+
ext = os.path.splitext(path)[1]
|
| 157 |
+
ext = ext or ""
|
| 158 |
+
if ext and not ext.startswith("."):
|
| 159 |
+
ext = "." + ext
|
| 160 |
+
|
| 161 |
+
if content_type is None:
|
| 162 |
+
guessed = mimetypes.guess_type("x" + ext)[0] if ext else None
|
| 163 |
+
content_type = guessed or "application/octet-stream"
|
| 164 |
+
|
| 165 |
+
if data is None:
|
| 166 |
+
with open(path, "rb") as fh:
|
| 167 |
+
data = fh.read()
|
| 168 |
+
|
| 169 |
+
filekey = f"{_now_stamp()}-{namespace}-{uuid.uuid4().hex[:8]}{ext}"
|
| 170 |
+
|
| 171 |
+
s3 = _client(cfg)
|
| 172 |
+
_ensure_bucket(s3, bucket)
|
| 173 |
+
metadata = {"generation": _meta_blob(prompt, params, uid)}
|
| 174 |
+
if uid:
|
| 175 |
+
metadata["uid"] = uid
|
| 176 |
+
s3.put_object(
|
| 177 |
+
Bucket=bucket,
|
| 178 |
+
Key=filekey,
|
| 179 |
+
Body=data,
|
| 180 |
+
ContentType=content_type,
|
| 181 |
+
Metadata=metadata,
|
| 182 |
+
)
|
| 183 |
+
return {"ok": True, "filekey": filekey, "bucket": bucket}
|
| 184 |
+
except Exception as exc: # noqa: BLE001 - report any failure to the caller
|
| 185 |
+
return {"ok": False, "error": f"{type(exc).__name__}: {exc}"}
|
requirements.txt
CHANGED
|
@@ -5,3 +5,4 @@ accelerate
|
|
| 5 |
compel
|
| 6 |
sentencepiece
|
| 7 |
numpy
|
|
|
|
|
|
| 5 |
compel
|
| 6 |
sentencepiece
|
| 7 |
numpy
|
| 8 |
+
boto3
|