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 files

Add 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>

Files changed (3) hide show
  1. app.py +72 -2
  2. r2_uploader.py +185 -0
  3. 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=generate_image, inputs=gen_inputs, outputs=[output_image, used_seed],
 
527
  )
528
  prompt.submit(
529
- fn=generate_image, inputs=gen_inputs, outputs=[output_image, used_seed],
 
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