ImageStudio Maintainer Claude Opus 4.8 (1M context) commited on
Commit
74e9ec6
·
1 Parent(s): c46762a

feat: watermark assets + skip R2 upload for web-UI generations

Browse files

Add a brand watermark (logo + domain) compositor applied to generated images
when the caller passes a valid JSON spec via the wm cookie, and stop uploading
ad-hoc Gradio web-UI generations to R2 (only API/generator calls, detected via
the uid cookie / /call REST path, populate the asset store).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files changed (3) hide show
  1. app.py +15 -0
  2. r2_uploader.py +68 -0
  3. watermark.py +311 -0
app.py CHANGED
@@ -48,6 +48,7 @@ def _progress(stage, p, step=0, total=0, label=""):
48
  }
49
 
50
  import r2_uploader
 
51
 
52
  # Per-Space namespace embedded in every uploaded object key. Deliberately opaque
53
  # (not the readable Space name) but stable so the owner can tell assets apart.
@@ -1194,6 +1195,20 @@ def generate_and_upload(
1194
  else:
1195
  _, image, used, moderation, effective_prompt = ev
1196
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1197
  yield None, used, None, _progress("image", 0.97, label="Uploading")
1198
  uid = r2_uploader.uid_from_request(request)
1199
  buf = io.BytesIO()
 
48
  }
49
 
50
  import r2_uploader
51
+ import watermark as watermark_module
52
 
53
  # Per-Space namespace embedded in every uploaded object key. Deliberately opaque
54
  # (not the readable Space name) but stable so the owner can tell assets apart.
 
1195
  else:
1196
  _, image, used, moderation, effective_prompt = ev
1197
 
1198
+ # Stamp the brand watermark (logo + domain) onto the asset when the caller
1199
+ # supplied a valid ``wm`` cookie spec — applied after moderation so it never
1200
+ # affects the safety rating, and to the same image we display and upload.
1201
+ watermark = r2_uploader.watermark_from_request(request)
1202
+ if watermark_module.is_valid(watermark):
1203
+ image = watermark_module.apply_to_image(image, watermark)
1204
+
1205
+ # Direct Gradio web-UI generations are not uploaded to R2 — only API calls
1206
+ # (the generator, which forwards a uid cookie) populate the asset store.
1207
+ if not r2_uploader.request_is_api(request):
1208
+ status = {"r2_skipped": "web-ui generation (not uploaded)", "moderation": moderation}
1209
+ yield image, used, status, _progress("done", 1.0, label="Done")
1210
+ return
1211
+
1212
  yield None, used, None, _progress("image", 0.97, label="Uploading")
1213
  uid = r2_uploader.uid_from_request(request)
1214
  buf = io.BytesIO()
r2_uploader.py CHANGED
@@ -72,6 +72,74 @@ def uid_from_request(request, cookie_name: str = "uid") -> str:
72
  return ""
73
 
74
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75
  def _client(cfg: dict):
76
  import boto3
77
  from botocore.config import Config
 
72
  return ""
73
 
74
 
75
+ def watermark_from_request(request, cookie_name: str = "wm") -> str:
76
+ """Best-effort read of the watermark JSON spec from the ``wm`` cookie.
77
+
78
+ The generator forwards the brand watermark spec (a JSON string such as
79
+ ``{"logo":"https://.../logo.png","domain":"nsfwailab.com"}``) as the ``wm``
80
+ cookie on the Space call — the same channel as the ``uid`` cookie — so no
81
+ Gradio input arity changes (which would break older callers). The value is
82
+ percent-encoded (encodeURIComponent) by the caller and decoded here. Returns
83
+ "" when absent; the web UI never sets it, so UI generations stay un-marked.
84
+ """
85
+ if request is None:
86
+ return ""
87
+ from urllib.parse import unquote
88
+
89
+ try:
90
+ raw = ""
91
+ cookies = getattr(request, "cookies", None)
92
+ if isinstance(cookies, dict) and cookies.get(cookie_name):
93
+ raw = str(cookies[cookie_name])
94
+ else:
95
+ headers = getattr(request, "headers", None)
96
+ header_cookie = ""
97
+ if headers is not None:
98
+ header_cookie = (headers.get("cookie") if hasattr(headers, "get") else "") or ""
99
+ for part in header_cookie.split(";"):
100
+ k, _, v = part.strip().partition("=")
101
+ if k == cookie_name and v:
102
+ raw = v
103
+ break
104
+ if not raw:
105
+ return ""
106
+ decoded = unquote(raw)
107
+ # Tolerate accidental double-encoding (value still looks percent-encoded).
108
+ if "%7B" in decoded or "%22" in decoded:
109
+ decoded = unquote(decoded)
110
+ return decoded
111
+ except Exception: # noqa: BLE001 - watermark is best-effort, never fatal
112
+ return ""
113
+
114
+ def request_is_api(request) -> bool:
115
+ """True when a generation call arrived through the REST API (the generator),
116
+ False for a direct Gradio web-UI interaction.
117
+
118
+ Used to skip the R2 upload for ad-hoc web-UI generations: the generator
119
+ always forwards the requester's ``uid`` cookie and reaches the Space through
120
+ the ``/gradio_api/call/<api>`` REST endpoint, whereas a browser on the public
121
+ Gradio UI does neither (it uses the queue path with no uid cookie).
122
+ """
123
+ if request is None:
124
+ return False
125
+ if uid_from_request(request):
126
+ return True
127
+ try:
128
+ path = ""
129
+ url = getattr(request, "url", None)
130
+ if url is not None:
131
+ path = getattr(url, "path", "") or str(url)
132
+ if not path:
133
+ raw = getattr(request, "request", None)
134
+ url = getattr(raw, "url", None) if raw is not None else None
135
+ path = getattr(url, "path", "") if url is not None else ""
136
+ if "/call/" in path or path.endswith("/call"):
137
+ return True
138
+ except Exception: # noqa: BLE001 - detection is best-effort
139
+ pass
140
+ return False
141
+
142
+
143
  def _client(cfg: dict):
144
  import boto3
145
  from botocore.config import Config
watermark.py ADDED
@@ -0,0 +1,311 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Brand watermark overlay for generated images and videos.
2
+
3
+ A watermark is requested by passing a JSON spec (a string or an already-parsed
4
+ dict) to a generation endpoint via the ``watermark`` parameter. When the spec is
5
+ valid, a small badge — the brand logo with the site domain rendered beneath it —
6
+ is composited onto the generated image (or every frame of the generated video,
7
+ via a single ffmpeg overlay pass). An empty or invalid spec is a no-op, so any
8
+ caller that omits the parameter is completely unaffected.
9
+
10
+ Spec fields (JSON object; at least one of ``logo`` / ``domain`` must be present
11
+ for the spec to count as "valid"):
12
+
13
+ logo str logo image as an http(s) URL, a ``data:`` URI, or raw
14
+ base64. Optional — a domain-only watermark is allowed.
15
+ domain str text drawn under the logo, e.g. "nsfwailab.com". Optional.
16
+ position str bottom-right (default), bottom-left, top-right, top-left,
17
+ bottom-center or top-center.
18
+ scale float badge width as a fraction of the media width (default 0.18,
19
+ clamped to 0.05..0.6).
20
+ opacity float overall badge opacity 0..1 (default 0.9).
21
+ margin float badge margin from the edge as a fraction of media width
22
+ (default 0.03).
23
+
24
+ Every public entry point is wrapped so a watermarking failure degrades to
25
+ "asset returned un-watermarked" rather than breaking generation.
26
+ """
27
+ from __future__ import annotations
28
+
29
+ import base64
30
+ import json
31
+ import os
32
+ import subprocess
33
+ import tempfile
34
+ import urllib.request
35
+
36
+ _VIDEO_REF_W = 1024 # fallback media width when a video's size can't be probed.
37
+
38
+ # Cache decoded logos so a per-frame / per-call render doesn't refetch or
39
+ # re-decode the same image. Keyed on the raw logo string.
40
+ _logo_cache: dict[str, object] = {}
41
+
42
+ _POSITIONS = {
43
+ "bottom-right", "bottom-left", "top-right", "top-left",
44
+ "bottom-center", "top-center",
45
+ }
46
+
47
+
48
+ def parse_spec(spec) -> dict | None:
49
+ """Validate/normalise a watermark spec; return a clean dict or None.
50
+
51
+ Accepts a JSON string or a dict. Returns None (a no-op signal) when the spec
52
+ is empty, unparseable, or carries neither a logo nor a domain.
53
+ """
54
+ if not spec:
55
+ return None
56
+ data = spec
57
+ if isinstance(spec, str):
58
+ s = spec.strip()
59
+ if not s:
60
+ return None
61
+ try:
62
+ data = json.loads(s)
63
+ except (ValueError, TypeError):
64
+ return None
65
+ if not isinstance(data, dict):
66
+ return None
67
+
68
+ logo = data.get("logo") or data.get("logo_url") or ""
69
+ domain = (data.get("domain") or data.get("text") or "").strip()
70
+ if not (logo or domain):
71
+ return None
72
+
73
+ def _f(key, default, lo, hi):
74
+ try:
75
+ return max(lo, min(hi, float(data.get(key, default))))
76
+ except (TypeError, ValueError):
77
+ return default
78
+
79
+ position = str(data.get("position", "bottom-right")).strip().lower()
80
+ if position not in _POSITIONS:
81
+ position = "bottom-right"
82
+
83
+ return {
84
+ "logo": logo if isinstance(logo, str) else "",
85
+ "domain": domain,
86
+ "position": position,
87
+ "scale": _f("scale", 0.18, 0.05, 0.6),
88
+ "opacity": _f("opacity", 0.9, 0.1, 1.0),
89
+ "margin": _f("margin", 0.03, 0.0, 0.2),
90
+ }
91
+
92
+
93
+ def is_valid(spec) -> bool:
94
+ """True when ``spec`` would produce a watermark."""
95
+ return parse_spec(spec) is not None
96
+
97
+
98
+ def _load_logo(logo: str):
99
+ """Decode a logo string (URL / data-URI / base64) into an RGBA PIL image."""
100
+ if not logo:
101
+ return None
102
+ if logo in _logo_cache:
103
+ return _logo_cache[logo]
104
+ from PIL import Image
105
+ import io
106
+
107
+ img = None
108
+ try:
109
+ raw = None
110
+ if logo.startswith("data:"):
111
+ _, _, b64 = logo.partition(",")
112
+ raw = base64.b64decode(b64)
113
+ elif logo.startswith("http://") or logo.startswith("https://"):
114
+ req = urllib.request.Request(logo, headers={"User-Agent": "watermark/1.0"})
115
+ with urllib.request.urlopen(req, timeout=10) as resp:
116
+ raw = resp.read()
117
+ else:
118
+ raw = base64.b64decode(logo)
119
+ if raw:
120
+ img = Image.open(io.BytesIO(raw)).convert("RGBA")
121
+ except Exception as exc: # noqa: BLE001 - logo is best-effort
122
+ print(f"[watermark] logo decode failed: {exc}")
123
+ img = None
124
+ _logo_cache[logo] = img
125
+ return img
126
+
127
+
128
+ _FONT_CANDIDATES = [
129
+ "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
130
+ "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
131
+ "/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf",
132
+ "DejaVuSans-Bold.ttf",
133
+ ]
134
+
135
+
136
+ def _font(size: int):
137
+ from PIL import ImageFont
138
+
139
+ for path in _FONT_CANDIDATES:
140
+ try:
141
+ return ImageFont.truetype(path, size)
142
+ except Exception: # noqa: BLE001
143
+ continue
144
+ return ImageFont.load_default()
145
+
146
+
147
+ def _render_badge(cfg: dict, media_w: int):
148
+ """Build the watermark badge (logo + domain on a translucent plate)."""
149
+ from PIL import Image, ImageDraw
150
+
151
+ badge_w = max(72, int(media_w * cfg["scale"]))
152
+ pad = max(6, badge_w // 16)
153
+
154
+ logo = _load_logo(cfg["logo"])
155
+ logo_img = None
156
+ logo_h = 0
157
+ if logo is not None and logo.width > 0:
158
+ lw = max(1, badge_w - 2 * pad)
159
+ lh = max(1, int(logo.height * lw / logo.width))
160
+ logo_img = logo.resize((lw, lh), Image.LANCZOS)
161
+ logo_h = lh
162
+
163
+ domain = cfg["domain"]
164
+ font = None
165
+ text_w = text_h = 0
166
+ bbox = (0, 0, 0, 0)
167
+ if domain:
168
+ measure = ImageDraw.Draw(Image.new("RGBA", (1, 1)))
169
+ fs = max(11, int(badge_w * 0.14))
170
+ font = _font(fs)
171
+ bbox = measure.textbbox((0, 0), domain, font=font)
172
+ text_w, text_h = bbox[2] - bbox[0], bbox[3] - bbox[1]
173
+ max_tw = badge_w - 2 * pad
174
+ if text_w > max_tw and text_w > 0:
175
+ fs = max(8, int(fs * max_tw / text_w))
176
+ font = _font(fs)
177
+ bbox = measure.textbbox((0, 0), domain, font=font)
178
+ text_w, text_h = bbox[2] - bbox[0], bbox[3] - bbox[1]
179
+
180
+ gap = pad if (logo_img is not None and domain) else 0
181
+ badge_h = logo_h + gap + text_h + 2 * pad
182
+
183
+ badge = Image.new("RGBA", (badge_w, badge_h), (0, 0, 0, 0))
184
+ draw = ImageDraw.Draw(badge)
185
+ radius = max(8, badge_w // 12)
186
+ draw.rounded_rectangle([0, 0, badge_w - 1, badge_h - 1], radius=radius,
187
+ fill=(0, 0, 0, 110))
188
+
189
+ y = pad
190
+ if logo_img is not None:
191
+ badge.alpha_composite(logo_img, ((badge_w - logo_img.width) // 2, y))
192
+ y += logo_h + gap
193
+ if domain:
194
+ tx = (badge_w - text_w) // 2 - bbox[0]
195
+ draw.text((tx, y - bbox[1]), domain, font=font, fill=(255, 255, 255, 235))
196
+
197
+ opacity = cfg["opacity"]
198
+ if opacity < 1.0:
199
+ alpha = badge.split()[3].point(lambda v: int(v * opacity))
200
+ badge.putalpha(alpha)
201
+ return badge
202
+
203
+
204
+ def _offset(position: str, media_w: int, media_h: int, bw: int, bh: int, margin: int):
205
+ """Top-left pixel offset for the badge given a named position."""
206
+ if "right" in position:
207
+ x = media_w - bw - margin
208
+ elif "left" in position:
209
+ x = margin
210
+ else: # center
211
+ x = (media_w - bw) // 2
212
+ y = margin if position.startswith("top") else media_h - bh - margin
213
+ return max(0, x), max(0, y)
214
+
215
+
216
+ def apply_to_image(img, spec):
217
+ """Return ``img`` with the watermark composited on, or unchanged on no-op."""
218
+ cfg = parse_spec(spec)
219
+ if cfg is None:
220
+ return img
221
+ try:
222
+ base = img.convert("RGBA")
223
+ badge = _render_badge(cfg, base.width)
224
+ margin = int(cfg["margin"] * base.width)
225
+ x, y = _offset(cfg["position"], base.width, base.height,
226
+ badge.width, badge.height, margin)
227
+ base.alpha_composite(badge, (x, y))
228
+ return base.convert("RGB")
229
+ except Exception as exc: # noqa: BLE001 - never break generation
230
+ print(f"[watermark] image overlay failed: {exc}")
231
+ return img
232
+
233
+
234
+ def _video_dims(path: str):
235
+ try:
236
+ import cv2
237
+
238
+ cap = cv2.VideoCapture(path)
239
+ w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
240
+ h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
241
+ cap.release()
242
+ if w > 0 and h > 0:
243
+ return w, h
244
+ except Exception: # noqa: BLE001
245
+ pass
246
+ # Fallback when OpenCV is unavailable: parse the resolution out of ffmpeg's
247
+ # probe output so the badge is still sized/placed against the real frame.
248
+ try:
249
+ import re
250
+
251
+ import imageio_ffmpeg
252
+
253
+ ff = imageio_ffmpeg.get_ffmpeg_exe()
254
+ err = subprocess.run([ff, "-i", path], capture_output=True, text=True).stderr
255
+ m = re.search(r"Video:.*?(\d{2,5})x(\d{2,5})", err)
256
+ if m:
257
+ return int(m.group(1)), int(m.group(2))
258
+ except Exception: # noqa: BLE001
259
+ pass
260
+ return None
261
+
262
+
263
+ def apply_to_video(in_path: str, spec, out_path: str | None = None) -> str:
264
+ """Overlay the watermark on a video via one ffmpeg pass.
265
+
266
+ Returns the path to the watermarked file, or the original ``in_path`` when
267
+ the spec is a no-op or anything fails (so upload still proceeds).
268
+ """
269
+ cfg = parse_spec(spec)
270
+ if cfg is None:
271
+ return in_path
272
+ badge_png = None
273
+ try:
274
+ import imageio_ffmpeg
275
+
276
+ ffmpeg = imageio_ffmpeg.get_ffmpeg_exe()
277
+ dims = _video_dims(in_path)
278
+ media_w, media_h = dims if dims else (_VIDEO_REF_W, _VIDEO_REF_W)
279
+
280
+ badge = _render_badge(cfg, media_w)
281
+ margin = int(cfg["margin"] * media_w)
282
+ x, y = _offset(cfg["position"], media_w, media_h,
283
+ badge.width, badge.height, margin)
284
+
285
+ fd, badge_png = tempfile.mkstemp(suffix=".png")
286
+ os.close(fd)
287
+ badge.save(badge_png)
288
+
289
+ if out_path is None:
290
+ fd, out_path = tempfile.mkstemp(suffix=".mp4")
291
+ os.close(fd)
292
+
293
+ cmd = [
294
+ ffmpeg, "-y", "-i", in_path, "-i", badge_png,
295
+ "-filter_complex", f"overlay={x}:{y}:format=auto",
296
+ "-c:a", "copy", "-c:v", "libx264", "-pix_fmt", "yuv420p",
297
+ "-crf", "18", "-preset", "veryfast", "-movflags", "+faststart",
298
+ out_path,
299
+ ]
300
+ subprocess.run(cmd, check=True, capture_output=True)
301
+ return out_path
302
+ except Exception as exc: # noqa: BLE001 - never break generation
303
+ detail = exc.stderr.decode("utf-8", "ignore")[-400:] if isinstance(exc, subprocess.CalledProcessError) else exc
304
+ print(f"[watermark] video overlay failed: {detail}")
305
+ return in_path
306
+ finally:
307
+ if badge_png and os.path.exists(badge_png):
308
+ try:
309
+ os.unlink(badge_png)
310
+ except OSError:
311
+ pass