ainotate 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ainotate/__init__.py +2 -0
- ainotate/__main__.py +6 -0
- ainotate/beautify.py +361 -0
- ainotate/beautify_color.py +238 -0
- ainotate/capture/__init__.py +0 -0
- ainotate/capture/clipboard.py +91 -0
- ainotate/capture/desktop.py +447 -0
- ainotate/capture/desktop_win.py +155 -0
- ainotate/capture/neo.py +71 -0
- ainotate/capture/web.py +367 -0
- ainotate/capture/web_cdp.py +151 -0
- ainotate/capture/web_geom.py +270 -0
- ainotate/capture/web_js.py +131 -0
- ainotate/capture/web_targets.py +266 -0
- ainotate/cli.py +499 -0
- ainotate/doctor.py +327 -0
- ainotate/export.py +473 -0
- ainotate/export_html.py +149 -0
- ainotate/fonts/Inter-Bold.ttf +0 -0
- ainotate/fonts/OFL.txt +92 -0
- ainotate/geometry.py +205 -0
- ainotate/label_plan.py +96 -0
- ainotate/marks.py +382 -0
- ainotate/mcp_docs.py +308 -0
- ainotate/mcp_server.py +446 -0
- ainotate/ocr.py +316 -0
- ainotate/ocr_engines.py +432 -0
- ainotate/output.py +156 -0
- ainotate/placement.py +474 -0
- ainotate/privacy.py +499 -0
- ainotate/privacy_js.py +233 -0
- ainotate/render.py +408 -0
- ainotate/shoot.py +284 -0
- ainotate/spec.py +204 -0
- ainotate/style.py +117 -0
- ainotate/tools.py +77 -0
- ainotate-0.1.0.dist-info/METADATA +251 -0
- ainotate-0.1.0.dist-info/RECORD +42 -0
- ainotate-0.1.0.dist-info/WHEEL +4 -0
- ainotate-0.1.0.dist-info/entry_points.txt +2 -0
- ainotate-0.1.0.dist-info/licenses/LICENSE +661 -0
- ainotate-0.1.0.dist-info/licenses/NOTICE +118 -0
ainotate/__init__.py
ADDED
ainotate/__main__.py
ADDED
ainotate/beautify.py
ADDED
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
"""Frame an annotated screenshot so it looks finished (Xnapper / CleanShot "Background tool"):
|
|
2
|
+
a soft gradient backdrop, padding, rounded corners, a macOS-like window shadow, optional
|
|
3
|
+
minimal browser/window chrome and a fixed aspect ratio. Pure: no printing, no file writes.
|
|
4
|
+
|
|
5
|
+
apply_frame(img, frame, unit) -> Image; validate_frame(frame) -> list of error strings.
|
|
6
|
+
All sizes scale with `unit` (the render's mark unit), so a retina capture gets a retina frame.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import math
|
|
11
|
+
import os
|
|
12
|
+
import random
|
|
13
|
+
from functools import lru_cache
|
|
14
|
+
|
|
15
|
+
from PIL import Image, ImageChops, ImageColor, ImageDraw, ImageFilter, ImageFont
|
|
16
|
+
|
|
17
|
+
from .beautify_color import ( # noqa: F401 (re-export: callers keep importing these from ainotate.beautify)
|
|
18
|
+
PRESETS, _MARK_COLORS, _edge_lightness, _gam, _grain, _grain_tile, _lin, _oklab_lin, _pixels, _thumb,
|
|
19
|
+
auto_hues, auto_stops, bg_stops, dominant_hue, from_oklab, gradient, luminance, oklch, shadow_color, to_oklab,
|
|
20
|
+
)
|
|
21
|
+
from .style import COLORS, LABEL_FILL, load_font # noqa: F401
|
|
22
|
+
|
|
23
|
+
FRAME_DEFAULTS = {
|
|
24
|
+
"bg": "auto", # "auto" | preset | "#hex" | ["#hex", "#hex", ...]
|
|
25
|
+
"pad": None, # px around the window; None = 6% of the longer side
|
|
26
|
+
"radius": None, # px corner radius of the window; None = 1.0 x unit
|
|
27
|
+
"shadow": True, # True | False | 0..1 opacity of the ambient shadow
|
|
28
|
+
"chrome": None, # None | "browser" | "window"
|
|
29
|
+
"url": None, # text in the browser address pill
|
|
30
|
+
"theme": "auto", # chrome colors: "auto" (from the screenshot's top edge) | "light" | "dark"
|
|
31
|
+
"aspect": None, # None | "16:9" | "4:3" | "1:1" | "twitter" | "linkedin" | "W:H"
|
|
32
|
+
"balance": False, # shift the window so off-center content looks centered
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
ASPECTS = {"16:9": 16 / 9, "4:3": 4 / 3, "1:1": 1.0, "twitter": 16 / 9, "linkedin": 1200 / 627}
|
|
36
|
+
|
|
37
|
+
# Real browser-ish chrome colors (Safari/Chrome minimal toolbars)
|
|
38
|
+
CHROME = {
|
|
39
|
+
"light": {"bar": "#F2F2F4", "line": "#D8D8DC", "pill": "#E3E3E8", "text": "#6E6E73"},
|
|
40
|
+
"dark": {"bar": "#2B2B2E", "line": "#141416", "pill": "#3C3C40", "text": "#A1A1A6"},
|
|
41
|
+
}
|
|
42
|
+
LIGHTS = [("#FF5F57", "#E2463F"), ("#FEBC2E", "#E1A116"), ("#28C840", "#1AAB29")]
|
|
43
|
+
# traffic lights (x unit): radius, first center from the left edge, center-to-center step.
|
|
44
|
+
# macOS proportions: 12 pt lights on a 20 pt pitch, so diameter / step = 0.6
|
|
45
|
+
LIGHT_R, LIGHT_X0, LIGHT_STEP = 0.44, 1.4, 1.47
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ---------- validation ----------
|
|
50
|
+
|
|
51
|
+
def _num(v):
|
|
52
|
+
return isinstance(v, (int, float)) and not isinstance(v, bool) and math.isfinite(v)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _color_ok(c):
|
|
56
|
+
if not isinstance(c, str) or not c:
|
|
57
|
+
return False
|
|
58
|
+
try:
|
|
59
|
+
ImageColor.getrgb(c)
|
|
60
|
+
return True
|
|
61
|
+
except ValueError:
|
|
62
|
+
return False
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _aspect_ratio(a):
|
|
66
|
+
if not isinstance(a, str):
|
|
67
|
+
return None
|
|
68
|
+
if a in ASPECTS:
|
|
69
|
+
return ASPECTS[a]
|
|
70
|
+
if a.count(":") == 1:
|
|
71
|
+
x, y = a.split(":")
|
|
72
|
+
try:
|
|
73
|
+
x, y = float(x), float(y)
|
|
74
|
+
except ValueError:
|
|
75
|
+
return None
|
|
76
|
+
if x > 0 and y > 0 and math.isfinite(x) and math.isfinite(y) and math.isfinite(x / y) and x / y > 0:
|
|
77
|
+
return x / y
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _check_bg(bg, errs):
|
|
82
|
+
if isinstance(bg, str) and (bg in ("auto", "transparent") or bg in PRESETS):
|
|
83
|
+
return
|
|
84
|
+
if isinstance(bg, list):
|
|
85
|
+
if not 2 <= len(bg) <= 5 or not all(_color_ok(c) for c in bg):
|
|
86
|
+
errs.append("spec.frame: 'bg' gradient must be a list of 2 to 5 colors (#hex or css)")
|
|
87
|
+
return
|
|
88
|
+
if not _color_ok(bg):
|
|
89
|
+
errs.append(f"spec.frame: bad bg {bg!r}; use auto, one of {', '.join(PRESETS)}, transparent, "
|
|
90
|
+
"#hex or [\"#hex\", \"#hex\"]")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def validate_frame(frame) -> list[str]:
|
|
94
|
+
"""Every problem with a `frame` value, as spec.py-style messages (empty list = valid)."""
|
|
95
|
+
if frame is None or isinstance(frame, bool):
|
|
96
|
+
return []
|
|
97
|
+
errs: list[str] = []
|
|
98
|
+
if isinstance(frame, (str, list)): # shorthand: just the background
|
|
99
|
+
_check_bg(frame, errs)
|
|
100
|
+
return errs
|
|
101
|
+
if not isinstance(frame, dict):
|
|
102
|
+
return ["spec.frame: must be true, a background (name, color, [colors]) or an object"]
|
|
103
|
+
for k in frame:
|
|
104
|
+
if k not in FRAME_DEFAULTS:
|
|
105
|
+
errs.append(f"spec.frame: unknown key {k!r}; use {', '.join(FRAME_DEFAULTS)}")
|
|
106
|
+
if "bg" in frame:
|
|
107
|
+
_check_bg(frame["bg"], errs)
|
|
108
|
+
for k in ("pad", "radius"):
|
|
109
|
+
if frame.get(k) is not None and not (_num(frame[k]) and frame[k] >= 0):
|
|
110
|
+
errs.append(f"spec.frame: '{k}' must be a number >= 0")
|
|
111
|
+
s = frame.get("shadow", True)
|
|
112
|
+
if not (isinstance(s, bool) or (_num(s) and 0 <= s <= 1)):
|
|
113
|
+
errs.append("spec.frame: 'shadow' must be true, false or a number from 0 to 1")
|
|
114
|
+
if frame.get("chrome") not in (None, "browser", "window"):
|
|
115
|
+
errs.append("spec.frame: 'chrome' must be browser, window or null")
|
|
116
|
+
if frame.get("url") is not None:
|
|
117
|
+
if not isinstance(frame["url"], str):
|
|
118
|
+
errs.append("spec.frame: 'url' must be a string")
|
|
119
|
+
elif frame.get("chrome") != "browser":
|
|
120
|
+
errs.append("spec.frame: 'url' needs chrome 'browser'")
|
|
121
|
+
if frame.get("theme", "auto") not in ("auto", "light", "dark"):
|
|
122
|
+
errs.append("spec.frame: 'theme' must be auto, light or dark")
|
|
123
|
+
if frame.get("aspect") is not None and _aspect_ratio(frame["aspect"]) is None:
|
|
124
|
+
errs.append("spec.frame: 'aspect' must be 16:9, 4:3, 1:1, twitter, linkedin or W:H")
|
|
125
|
+
if not isinstance(frame.get("balance", False), bool):
|
|
126
|
+
errs.append("spec.frame: 'balance' must be true or false")
|
|
127
|
+
return errs
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def normalize_frame(frame) -> dict | None:
|
|
131
|
+
"""Frame value -> full options dict (None when framing is off). ValueError if invalid."""
|
|
132
|
+
if frame is None or frame is False:
|
|
133
|
+
return None
|
|
134
|
+
errs = validate_frame(frame)
|
|
135
|
+
if errs:
|
|
136
|
+
raise ValueError("invalid frame:\n " + "\n ".join(errs))
|
|
137
|
+
opts = dict(FRAME_DEFAULTS)
|
|
138
|
+
if isinstance(frame, (str, list)):
|
|
139
|
+
opts["bg"] = frame
|
|
140
|
+
elif isinstance(frame, dict):
|
|
141
|
+
opts.update(frame)
|
|
142
|
+
return opts
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# ---------- window: chrome bar, corners, hairline ----------
|
|
146
|
+
|
|
147
|
+
def _corner(r, S=4):
|
|
148
|
+
tile = Image.new("L", (r * S, r * S), 0)
|
|
149
|
+
ImageDraw.Draw(tile).ellipse([0, 0, 2 * r * S - 1, 2 * r * S - 1], fill=255)
|
|
150
|
+
return tile.resize((r, r), Image.LANCZOS)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def rounded_mask(size, r):
|
|
154
|
+
"""Opaque L mask with anti-aliased rounded corners (only the corners are supersampled)."""
|
|
155
|
+
w, h = size
|
|
156
|
+
m = Image.new("L", size, 255)
|
|
157
|
+
r = int(min(r, w // 2, h // 2))
|
|
158
|
+
if r <= 0:
|
|
159
|
+
return m
|
|
160
|
+
c = _corner(r)
|
|
161
|
+
m.paste(c, (0, 0))
|
|
162
|
+
m.paste(c.transpose(Image.FLIP_LEFT_RIGHT), (w - r, 0))
|
|
163
|
+
m.paste(c.transpose(Image.FLIP_TOP_BOTTOM), (0, h - r))
|
|
164
|
+
m.paste(c.transpose(Image.ROTATE_180), (w - r, h - r))
|
|
165
|
+
return m
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
_UI_FONTS = ["/System/Library/Fonts/SFNS.ttf", "/System/Library/Fonts/HelveticaNeue.ttc",
|
|
169
|
+
"C:\\Windows\\Fonts\\segoeui.ttf", "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"]
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def ui_font(size):
|
|
173
|
+
"""A regular-weight UI font for the address bar; the bundled Inter Bold as a fallback."""
|
|
174
|
+
if not os.environ.get("AINOTATE_FONT"):
|
|
175
|
+
for path in _UI_FONTS:
|
|
176
|
+
if os.path.isfile(path):
|
|
177
|
+
try:
|
|
178
|
+
return ImageFont.truetype(path, size)
|
|
179
|
+
except OSError:
|
|
180
|
+
pass
|
|
181
|
+
return load_font(size)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def chrome_theme(img, theme="auto"):
|
|
185
|
+
"""light/dark chrome: matches the top edge of the screenshot, like the real browser would."""
|
|
186
|
+
if theme in ("light", "dark"):
|
|
187
|
+
return theme
|
|
188
|
+
band = max(2, round(img.height * 0.04))
|
|
189
|
+
return "light" if luminance(img, (0, 0, img.width, band)) >= 0.55 else "dark"
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def chrome_height(kind, u):
|
|
193
|
+
return 0 if not kind else round((3.3 if kind == "browser" else 2.4) * u)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def draw_chrome(width, kind, theme, u, url=None, S=4):
|
|
197
|
+
"""The bar above the screenshot, drawn at S x and downsampled."""
|
|
198
|
+
H = chrome_height(kind, u)
|
|
199
|
+
pal = CHROME[theme]
|
|
200
|
+
W4, H4 = width * S, H * S
|
|
201
|
+
bar = Image.new("RGB", (W4, H4), pal["bar"])
|
|
202
|
+
d = ImageDraw.Draw(bar)
|
|
203
|
+
d.rectangle([0, H4 - max(S, round(0.07 * u * S)), W4, H4], fill=pal["line"])
|
|
204
|
+
cy = (H4 - S) / 2
|
|
205
|
+
rad = LIGHT_R * u * S
|
|
206
|
+
x = LIGHT_X0 * u * S
|
|
207
|
+
rim = 0.06 * u * S
|
|
208
|
+
for fill, ring in LIGHTS:
|
|
209
|
+
d.ellipse([x - rad, cy - rad, x + rad, cy + rad], fill=ring)
|
|
210
|
+
d.ellipse([x - rad + rim, cy - rad + rim, x + rad - rim, cy + rad - rim], fill=fill)
|
|
211
|
+
x += LIGHT_STEP * u * S
|
|
212
|
+
lights_end = x - LIGHT_STEP * u * S + rad
|
|
213
|
+
if kind == "browser":
|
|
214
|
+
ph = 2.05 * u * S
|
|
215
|
+
pw = min(max(22 * u * S, 0.42 * W4), W4 - 2 * (lights_end + 1.5 * u * S))
|
|
216
|
+
if pw > 6 * u * S:
|
|
217
|
+
x0 = (W4 - pw) / 2
|
|
218
|
+
d.rounded_rectangle([x0, cy - ph / 2, x0 + pw, cy + ph / 2], radius=0.55 * u * S, fill=pal["pill"])
|
|
219
|
+
text = (url or "").strip()
|
|
220
|
+
font = ui_font(max(8, round(0.98 * u * S)))
|
|
221
|
+
lock_w = 0.95 * u * S
|
|
222
|
+
maxw = pw - 3 * u * S - lock_w
|
|
223
|
+
while text and font.getlength(text) > maxw:
|
|
224
|
+
text = text[:-2] + "…" if len(text) > 2 else ""
|
|
225
|
+
tw = font.getlength(text) if text else 0
|
|
226
|
+
gx = W4 / 2 - (tw + lock_w + (0.45 * u * S if text else 0)) / 2
|
|
227
|
+
_lock(d, gx, cy, u * S, pal["text"])
|
|
228
|
+
if text:
|
|
229
|
+
d.text((gx + lock_w + 0.45 * u * S, cy), text, fill=pal["text"], font=font, anchor="lm")
|
|
230
|
+
return bar.resize((width, H), Image.LANCZOS)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _lock(d, x, cy, U, color):
|
|
234
|
+
"""Small padlock glyph (body + shackle), left edge at x, centered on cy."""
|
|
235
|
+
bw, bh = 0.78 * U, 0.6 * U
|
|
236
|
+
top = cy - bh / 2 + 0.2 * U
|
|
237
|
+
d.rounded_rectangle([x, top, x + bw, top + bh], radius=0.12 * U, fill=color)
|
|
238
|
+
sw = 0.5 * U
|
|
239
|
+
sx = x + (bw - sw) / 2
|
|
240
|
+
d.arc([sx, top - 0.5 * U, sx + sw, top + 0.35 * U], 180, 360, fill=color, width=max(1, round(0.11 * U)))
|
|
241
|
+
d.line([sx + 0.055 * U, top - 0.07 * U, sx + 0.055 * U, top], fill=color, width=max(1, round(0.11 * U)))
|
|
242
|
+
d.line([sx + sw - 0.055 * U, top - 0.07 * U, sx + sw - 0.055 * U, top], fill=color,
|
|
243
|
+
width=max(1, round(0.11 * U)))
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
# ---------- layout ----------
|
|
247
|
+
|
|
248
|
+
def canvas_size(win, pad, aspect):
|
|
249
|
+
"""Window + padding, grown (never cropped) to the aspect ratio."""
|
|
250
|
+
W, H = win[0] + 2 * pad, win[1] + 2 * pad
|
|
251
|
+
r = _aspect_ratio(aspect) if aspect else None
|
|
252
|
+
if r:
|
|
253
|
+
if W / H < r:
|
|
254
|
+
W = math.ceil(H * r)
|
|
255
|
+
else:
|
|
256
|
+
H = math.ceil(W / r)
|
|
257
|
+
return int(W), int(H)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def content_box(img):
|
|
261
|
+
"""Bounding box of what differs from the screenshot's edge color (None if all flat)."""
|
|
262
|
+
t = img.convert("RGB")
|
|
263
|
+
corners = [t.getpixel(p) for p in ((0, 0), (t.width - 1, 0), (0, t.height - 1), (t.width - 1, t.height - 1))]
|
|
264
|
+
bgc = max(set(corners), key=corners.count)
|
|
265
|
+
diff = ImageChops.difference(t, Image.new("RGB", t.size, bgc)).convert("L").point(lambda v: 255 if v > 24 else 0)
|
|
266
|
+
return diff.getbbox()
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def balance_shift(img, top_extra, pad):
|
|
270
|
+
"""(dx, dy) that moves the window so its content, not its border, sits in the middle."""
|
|
271
|
+
box = content_box(img)
|
|
272
|
+
if not box:
|
|
273
|
+
return 0, 0
|
|
274
|
+
w, h = img.size
|
|
275
|
+
lim = 0.6 * pad
|
|
276
|
+
dx = (w - box[2] - box[0]) / 2
|
|
277
|
+
dy = (h - box[3] - box[1]) / 2
|
|
278
|
+
if top_extra: # a chrome bar already weighs the top: correct half as much vertically
|
|
279
|
+
dy /= 2
|
|
280
|
+
return max(-lim, min(lim, dx)), max(-lim, min(lim, dy))
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
# ---------- main ----------
|
|
284
|
+
|
|
285
|
+
def apply_frame(img: Image.Image, frame, unit: float, source: Image.Image | None = None) -> Image.Image:
|
|
286
|
+
"""Put the screenshot on a backdrop. Returns RGB, or RGBA when bg is "transparent".
|
|
287
|
+
frame: True (defaults), a bg name/color/[colors], or an options dict (see FRAME_DEFAULTS).
|
|
288
|
+
source: the same screenshot before any mark was drawn. When given, the auto background and the
|
|
289
|
+
chrome theme are read from it, so a big green label cannot turn the backdrop green."""
|
|
290
|
+
opts = normalize_frame(frame)
|
|
291
|
+
if opts is None:
|
|
292
|
+
return img
|
|
293
|
+
u = float(unit)
|
|
294
|
+
shot = img.convert("RGB")
|
|
295
|
+
ref = shot if source is None else source.convert("RGB")
|
|
296
|
+
w, h = shot.size
|
|
297
|
+
|
|
298
|
+
kind = opts["chrome"]
|
|
299
|
+
theme = chrome_theme(ref, opts["theme"]) if kind else None
|
|
300
|
+
hb = chrome_height(kind, u)
|
|
301
|
+
win = Image.new("RGB", (w, h + hb))
|
|
302
|
+
if kind:
|
|
303
|
+
win.paste(draw_chrome(w, kind, theme, u, opts["url"]), (0, 0))
|
|
304
|
+
win.paste(shot, (0, hb))
|
|
305
|
+
ww, wh = win.size
|
|
306
|
+
|
|
307
|
+
pad = round(0.06 * max(w, h)) if opts["pad"] is None else round(opts["pad"])
|
|
308
|
+
radius = round(1.0 * u if opts["radius"] is None else opts["radius"])
|
|
309
|
+
W, H = canvas_size((ww, wh), pad, opts["aspect"])
|
|
310
|
+
x0, y0 = (W - ww) / 2, (H - wh) / 2
|
|
311
|
+
if opts["balance"]:
|
|
312
|
+
dx, dy = balance_shift(shot, hb, pad)
|
|
313
|
+
x0, y0 = x0 + dx, y0 + dy
|
|
314
|
+
x0, y0 = round(x0), round(y0)
|
|
315
|
+
|
|
316
|
+
transparent = opts["bg"] == "transparent"
|
|
317
|
+
stops = None if transparent else bg_stops(opts["bg"], ref)
|
|
318
|
+
canvas = Image.new("RGBA", (W, H), (0, 0, 0, 0)) if transparent else gradient((W, H), stops).convert("RGBA")
|
|
319
|
+
|
|
320
|
+
mask = rounded_mask((ww, wh), radius)
|
|
321
|
+
strength = 0.0 if opts["shadow"] is False else (0.40 if opts["shadow"] is True else float(opts["shadow"]))
|
|
322
|
+
if strength > 0 and pad > 0:
|
|
323
|
+
tint = (0, 0, 0) if transparent else shadow_color(stops) # cast light: a deeper shade of the backdrop
|
|
324
|
+
canvas = Image.alpha_composite(canvas, _shadow((W, H), mask, (x0, y0), strength, u, pad, tint))
|
|
325
|
+
|
|
326
|
+
# window edge: a hairline that separates it from the backdrop, like a macOS window border
|
|
327
|
+
edge_light = _edge_lightness(win) > 0.5
|
|
328
|
+
t = max(1, round(u / 16))
|
|
329
|
+
inner = Image.new("L", (ww, wh), 0)
|
|
330
|
+
if ww > 2 * t and wh > 2 * t:
|
|
331
|
+
inner.paste(rounded_mask((ww - 2 * t, wh - 2 * t), max(0, radius - t)), (t, t))
|
|
332
|
+
ring = ImageChops.subtract(mask, inner)
|
|
333
|
+
win_rgba = win.convert("RGBA")
|
|
334
|
+
win_rgba.putalpha(mask)
|
|
335
|
+
line = (0, 0, 0, 30) if edge_light else (255, 255, 255, 34)
|
|
336
|
+
hair = Image.new("RGBA", (ww, wh), line[:3] + (0,))
|
|
337
|
+
hair.putalpha(ring.point(lambda v: v * line[3] // 255))
|
|
338
|
+
win_rgba = Image.alpha_composite(win_rgba, hair)
|
|
339
|
+
canvas.alpha_composite(win_rgba, (x0, y0))
|
|
340
|
+
return canvas if transparent else canvas.convert("RGB")
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _shadow(size, mask, pos, strength, u, pad, color=(0, 0, 0)):
|
|
344
|
+
"""Two-layer macOS-like window shadow: a wide, soft, low ambient shadow pushed down a little,
|
|
345
|
+
plus a tight contact shadow that anchors the edges. Blurred at 1/4 size for speed."""
|
|
346
|
+
W, H = size
|
|
347
|
+
out = Image.new("RGBA", size, (0, 0, 0, 0))
|
|
348
|
+
k = 4
|
|
349
|
+
layers = ((min(2.0 * u, pad / 2.6), 0.9 * u, strength), (min(0.35 * u, pad / 3), 0.12 * u, strength * 0.55))
|
|
350
|
+
for blur, dy, op in layers:
|
|
351
|
+
sk = k if blur > 6 else 1
|
|
352
|
+
a = Image.new("L", (math.ceil(W / sk), math.ceil(H / sk)), 0)
|
|
353
|
+
m = mask.resize((max(1, round(mask.width / sk)), max(1, round(mask.height / sk))), Image.BOX)
|
|
354
|
+
a.paste(m, (round(pos[0] / sk), round((pos[1] + dy) / sk)))
|
|
355
|
+
a = a.filter(ImageFilter.GaussianBlur(max(0.5, blur / sk)))
|
|
356
|
+
if sk > 1:
|
|
357
|
+
a = a.resize(size, Image.BICUBIC)
|
|
358
|
+
layer = Image.new("RGBA", size, tuple(color) + (0,))
|
|
359
|
+
layer.putalpha(a.point(lambda v: round(v * op)))
|
|
360
|
+
out = Image.alpha_composite(out, layer)
|
|
361
|
+
return out
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""Color and gradient math for beautify: OKLab conversions, auto backgrounds, gradients, grain.
|
|
2
|
+
Re-exported by beautify."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import random
|
|
7
|
+
from functools import lru_cache
|
|
8
|
+
|
|
9
|
+
from PIL import Image, ImageChops, ImageColor, ImageDraw, ImageFilter
|
|
10
|
+
|
|
11
|
+
from .style import COLORS, LABEL_FILL
|
|
12
|
+
|
|
13
|
+
_MARK_COLORS = list(COLORS.values()) + list(LABEL_FILL.values())
|
|
14
|
+
|
|
15
|
+
# Diagonal gradients, top-left -> bottom-right. Soft and low-chroma so the screenshot stays the
|
|
16
|
+
# subject; each pair stays in one hue family (no muddy midpoints).
|
|
17
|
+
PRESETS = {
|
|
18
|
+
"sunset": ["#FFD9B8", "#F9B5A7", "#E9A1C2"],
|
|
19
|
+
"ocean": ["#B9E3F6", "#8DB8F2", "#7C8FE6"],
|
|
20
|
+
"slate": ["#56647A", "#2C3546", "#1A202C"],
|
|
21
|
+
"mint": ["#DDF7E8", "#A8E6CF", "#83CDC6"],
|
|
22
|
+
"lavender": ["#EEE6FD", "#D2C8F6", "#B3BDF0"],
|
|
23
|
+
"graphite": ["#48484C", "#2A2A2D", "#161618"],
|
|
24
|
+
"white": ["#FBFBFD", "#F0F0F3", "#E4E4E9"],
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ---------- color (OKLab: perceptually even gradients and lightness) ----------
|
|
29
|
+
|
|
30
|
+
def _lin(c):
|
|
31
|
+
c /= 255
|
|
32
|
+
return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _gam(c):
|
|
36
|
+
c = 12.92 * c if c <= 0.0031308 else 1.055 * c ** (1 / 2.4) - 0.055
|
|
37
|
+
return c * 255
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def to_oklab(rgb):
|
|
41
|
+
r, g, b = (_lin(v) for v in rgb[:3])
|
|
42
|
+
l = (0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b) ** (1 / 3)
|
|
43
|
+
m = (0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b) ** (1 / 3)
|
|
44
|
+
s = (0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b) ** (1 / 3)
|
|
45
|
+
return (0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s,
|
|
46
|
+
1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s,
|
|
47
|
+
0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _oklab_lin(L, a, b):
|
|
51
|
+
l = (L + 0.3963377774 * a + 0.2158037573 * b) ** 3
|
|
52
|
+
m = (L - 0.1055613458 * a - 0.0638541728 * b) ** 3
|
|
53
|
+
s = (L - 0.0894841775 * a - 1.2914855480 * b) ** 3
|
|
54
|
+
return (4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
|
|
55
|
+
-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
|
|
56
|
+
-0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def from_oklab(lab):
|
|
60
|
+
return tuple(max(0, min(255, round(_gam(max(0.0, min(1.0, v)))))) for v in _oklab_lin(*lab))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def oklch(L, C, h_deg):
|
|
64
|
+
"""In-gamut OKLab for (L, C, h): chroma shrinks until the color fits sRGB."""
|
|
65
|
+
h = math.radians(h_deg)
|
|
66
|
+
fits = lambda c: all(-1e-4 <= v <= 1.0001 for v in _oklab_lin(L, c * math.cos(h), c * math.sin(h)))
|
|
67
|
+
lo, hi = 0.0, C
|
|
68
|
+
if not fits(C):
|
|
69
|
+
for _ in range(16):
|
|
70
|
+
mid = (lo + hi) / 2
|
|
71
|
+
lo, hi = (mid, hi) if fits(mid) else (lo, mid)
|
|
72
|
+
C = lo
|
|
73
|
+
return (L, C * math.cos(h), C * math.sin(h))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def luminance(img, box=None):
|
|
77
|
+
"""Mean perceptual lightness (OKLab L, 0..1) of an image region."""
|
|
78
|
+
region = img.crop(box) if box else img
|
|
79
|
+
small = region.convert("RGB").resize((max(1, min(64, region.width)), max(1, min(16, region.height))),
|
|
80
|
+
Image.BOX)
|
|
81
|
+
px = list(_pixels(small))
|
|
82
|
+
return sum(to_oklab(p)[0] for p in px) / len(px)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# ---------- auto background ----------
|
|
86
|
+
|
|
87
|
+
def _pixels(im):
|
|
88
|
+
"""Pixel values; Pillow 12.3 deprecates getdata() in favor of get_flattened_data()."""
|
|
89
|
+
return im.get_flattened_data() if hasattr(im, "get_flattened_data") else im.getdata()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _thumb(img, side=72, resample=Image.BOX):
|
|
93
|
+
w, h = img.size
|
|
94
|
+
k = side / max(w, h)
|
|
95
|
+
return img.convert("RGB").resize((max(4, round(w * k)), max(4, round(h * k))), resample)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def dominant_hue(img):
|
|
99
|
+
"""(hue in degrees or None, colorfulness 0..1). Annotation colors are ignored: they say
|
|
100
|
+
nothing about the product in the screenshot."""
|
|
101
|
+
marks = [to_oklab(ImageColor.getrgb(c)) for c in _MARK_COLORS]
|
|
102
|
+
bins = [0.0] * 24
|
|
103
|
+
pts = []
|
|
104
|
+
# NEAREST: pixels stay either page colors or exact mark colors (no blends that dodge the filter)
|
|
105
|
+
thumb = _thumb(img, 120, Image.NEAREST)
|
|
106
|
+
for p in _pixels(thumb):
|
|
107
|
+
L, a, b = to_oklab(p)
|
|
108
|
+
C = math.hypot(a, b)
|
|
109
|
+
if C < 0.045 or not 0.25 < L < 0.97:
|
|
110
|
+
continue
|
|
111
|
+
if any(math.dist((L, a, b), m) < 0.035 for m in marks):
|
|
112
|
+
continue
|
|
113
|
+
h = math.degrees(math.atan2(b, a)) % 360
|
|
114
|
+
bins[int(h // 15) % 24] += C
|
|
115
|
+
pts.append((h, C, a, b))
|
|
116
|
+
n = thumb.width * thumb.height
|
|
117
|
+
total = sum(bins)
|
|
118
|
+
if not pts or total / n < 0.0004:
|
|
119
|
+
return None, total / n
|
|
120
|
+
peak = max(range(24), key=lambda i: bins[i - 1] + bins[i] + bins[(i + 1) % 24])
|
|
121
|
+
center = peak * 15 + 7.5
|
|
122
|
+
sa = sum(a for h, C, a, b in pts if abs((h - center + 180) % 360 - 180) <= 30)
|
|
123
|
+
sb = sum(b for h, C, a, b in pts if abs((h - center + 180) % 360 - 180) <= 30)
|
|
124
|
+
return math.degrees(math.atan2(sb, sa)) % 360, total / n
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def auto_stops(img):
|
|
128
|
+
"""A soft three-stop gradient in the screenshot's own hue family (analogous hues, never
|
|
129
|
+
the complement), lighter behind light screenshots and deeper behind dark ones."""
|
|
130
|
+
hue, _ = dominant_hue(img)
|
|
131
|
+
chroma = 0.10
|
|
132
|
+
if hue is None: # grayscale UI: a calm blue-violet
|
|
133
|
+
hue, chroma = 262, 0.06
|
|
134
|
+
dark = _edge_lightness(img) <= 0.6
|
|
135
|
+
Ls = (0.64, 0.52, 0.42) if dark else (0.90, 0.81, 0.73)
|
|
136
|
+
chroma *= 1.15 if dark else 1.0
|
|
137
|
+
return [oklch(L, chroma * f, h) for L, f, h in zip(Ls, (0.75, 1.0, 0.95), auto_hues(hue, dark))]
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def auto_hues(h, dark=False):
|
|
141
|
+
"""Three analogous hues around h that never pass through the olive/khaki band (~75-130
|
|
142
|
+
in OKLab), where soft gradients turn muddy: warm hues drift toward rose, yellow-greens
|
|
143
|
+
become mint."""
|
|
144
|
+
if h < 20 or h >= 330:
|
|
145
|
+
return h + 25, h, h - 30
|
|
146
|
+
if h < 100: # orange / amber -> peach, coral, rose
|
|
147
|
+
h = min(h, 62)
|
|
148
|
+
return (h - 15, h - 45, h - 75) if dark else (h + 8, h - 20, h - 48)
|
|
149
|
+
if h < 175: # yellow-green / green -> mint, teal
|
|
150
|
+
h = max(h, 160)
|
|
151
|
+
return h - 10, h + 12, h + 38
|
|
152
|
+
return h - 28, h, h + 30
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _edge_lightness(img):
|
|
156
|
+
t = _thumb(img)
|
|
157
|
+
w, h = t.size
|
|
158
|
+
px = t.load()
|
|
159
|
+
ring = [px[x, y] for x in range(w) for y in (0, 1, h - 2, h - 1)] + \
|
|
160
|
+
[px[x, y] for y in range(h) for x in (0, 1, w - 2, w - 1)]
|
|
161
|
+
return sum(to_oklab(p)[0] for p in ring) / len(ring)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def bg_stops(bg, img):
|
|
165
|
+
if bg == "auto":
|
|
166
|
+
return auto_stops(img)
|
|
167
|
+
if isinstance(bg, str) and bg in PRESETS:
|
|
168
|
+
bg = PRESETS[bg]
|
|
169
|
+
if isinstance(bg, str):
|
|
170
|
+
bg = [bg]
|
|
171
|
+
return [to_oklab(ImageColor.getrgb(c)) for c in bg]
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def shadow_color(stops):
|
|
175
|
+
"""Window shadow tint: the backdrop's deepest stop, much darker and a bit less saturated.
|
|
176
|
+
A colored shadow reads as light falling on the backdrop; neutral black turns pastels gray."""
|
|
177
|
+
L, a, b = min(stops, key=lambda s: s[0])
|
|
178
|
+
return from_oklab((min(0.24, 0.4 * L), 0.8 * a, 0.8 * b))
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
# ---------- background painting ----------
|
|
182
|
+
|
|
183
|
+
@lru_cache(maxsize=4)
|
|
184
|
+
def _grain_tile(sigma=1.4, size=128):
|
|
185
|
+
rnd = random.Random(20261007)
|
|
186
|
+
tile = Image.new("L", (size, size))
|
|
187
|
+
tile.putdata([max(0, min(255, round(128 + rnd.gauss(0, sigma)))) for _ in range(size * size)])
|
|
188
|
+
return tile
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _grain(img):
|
|
192
|
+
"""Fixed, invisible film grain: kills banding in 8-bit gradients. Deterministic."""
|
|
193
|
+
tile = _grain_tile()
|
|
194
|
+
noise = Image.new("L", img.size)
|
|
195
|
+
for y in range(0, img.height, tile.height):
|
|
196
|
+
for x in range(0, img.width, tile.width):
|
|
197
|
+
noise.paste(tile, (x, y))
|
|
198
|
+
n3 = Image.merge("RGB", (noise, noise, noise))
|
|
199
|
+
return ImageChops.add(img, n3, 1.0, -128)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def gradient(size, stops):
|
|
203
|
+
"""Diagonal (top-left -> bottom-right) gradient through OKLab stops, plus a faint light
|
|
204
|
+
bloom in the top-left corner so a flat gradient reads as depth."""
|
|
205
|
+
W, H = size
|
|
206
|
+
if len(stops) == 1:
|
|
207
|
+
return Image.new("RGB", size, from_oklab(stops[0]))
|
|
208
|
+
lut = []
|
|
209
|
+
seg = len(stops) - 1
|
|
210
|
+
for i in range(512):
|
|
211
|
+
t = i / 511 * seg
|
|
212
|
+
k = min(seg - 1, int(t))
|
|
213
|
+
f = t - k
|
|
214
|
+
lab = tuple(stops[k][j] + (stops[k + 1][j] - stops[k][j]) * f for j in range(3))
|
|
215
|
+
lut.append(from_oklab(lab))
|
|
216
|
+
sw = max(2, min(W, 360))
|
|
217
|
+
sh = max(2, round(H * sw / W))
|
|
218
|
+
# the gradient runs along the diagonal, perpendicular lines share a color (CSS 135deg)
|
|
219
|
+
dx, dy = W, H
|
|
220
|
+
norm = dx * dx + dy * dy
|
|
221
|
+
data = []
|
|
222
|
+
for y in range(sh):
|
|
223
|
+
Y = (y + 0.5) / sh * H
|
|
224
|
+
for x in range(sw):
|
|
225
|
+
X = (x + 0.5) / sw * W
|
|
226
|
+
t = (X * dx + Y * dy) / norm
|
|
227
|
+
data.append(lut[max(0, min(511, round(t * 511)))])
|
|
228
|
+
small = Image.new("RGB", (sw, sh))
|
|
229
|
+
small.putdata(data)
|
|
230
|
+
img = small.resize(size, Image.BICUBIC)
|
|
231
|
+
# soft bloom: a big blurred ellipse of a lighter first stop, at the top-left
|
|
232
|
+
L0, a0, b0 = stops[0]
|
|
233
|
+
glow = from_oklab((min(1.0, L0 + 0.12), a0 * 0.85, b0 * 0.85))
|
|
234
|
+
bloom = Image.new("L", (sw, sh), 0)
|
|
235
|
+
ImageDraw.Draw(bloom).ellipse([-0.35 * sw, -0.6 * sh, 0.55 * sw, 0.7 * sh], fill=255)
|
|
236
|
+
bloom = bloom.filter(ImageFilter.GaussianBlur(0.18 * max(sw, sh))).resize(size, Image.BICUBIC)
|
|
237
|
+
img = Image.composite(Image.new("RGB", size, glow), img, bloom.point(lambda v: int(v * 0.45)))
|
|
238
|
+
return _grain(img)
|
|
File without changes
|