-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathmake-screenshots.py
More file actions
261 lines (208 loc) · 9.47 KB
/
Copy pathmake-screenshots.py
File metadata and controls
261 lines (208 loc) · 9.47 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
#!/usr/bin/env python3
"""
Composite the marketing screenshot frames used by the website.
Input : raw window captures (screencapture -R of the HelloNotes window)
Output : website/src/assets/screens/{light,dark}_0N.png
Each frame is the brand gradient, a caption, and the window shot with rounded
corners and a soft drop shadow — the same treatment in both appearances, so the
only thing that changes between the light and dark sets is the app itself.
Capturing the raw input is a manual step; see docs/website.md § Screenshots.
Needs Pillow: python3 -m venv venv && ./venv/bin/pip install Pillow
./venv/bin/python scripts/make-screenshots.py <raw-dir> <out-dir>
**The raw inputs are copied into assets/screenshots-raw/ and committed.** They
used to be shot into a session scratchpad and thrown away, because this file
called capturing them "a manual step" and left it there. On 2026-08-29 the App
Store needed undecorated Mac screenshots and there were none: the composites
below are lossy derivatives (gradient, caption, rounded corners, drop shadow —
none of it reversible), the scratchpad was long gone, and the only surviving
copies were 1999px previews inside session transcripts, too small for the
2560x1600 the store requires. Re-shooting means launching the app on someone's
machine and opening their vault. An asset that costs that much to make is an
asset you keep.
"""
import sys
from pathlib import Path
from PIL import Image, ImageDraw, ImageFilter, ImageFont
# Canvas
W, H = 2560, 1600
# The site's brand gradient (global.css @utility brand-gradient), 115°.
STOPS = [(0.00, (0x7C, 0x3A, 0xED)), (0.55, (0xEC, 0x48, 0x99)), (1.00, (0xF5, 0x9E, 0x0B))]
ANGLE_DEG = 115
CAPTION_FONT = "/System/Library/Fonts/SFNSRounded.ttf"
CAPTION_FALLBACK = "/System/Library/Fonts/Supplemental/Arial Rounded Bold.ttf"
CAPTION_SIZE = 68
CAPTION_TOP = 74
# Window plate
PLATE_TOP = 250
PLATE_MARGIN_X = 150
CORNER_RADIUS = 26
SHADOW_BLUR = 40
SHADOW_OFFSET = 26
SHADOW_ALPHA = 115
SCENES = [
("01", "Your notes. Your files. Local and private."),
("02", "LaTeX maths & Mermaid diagrams, inline"),
("03", "Callouts, properties & rich Markdown"),
("04", "See how your thinking connects"),
("05", "Ask your library — answers with citations"),
]
def _sample(stops, t):
"""Colour at position t (0..1) along the stop list."""
t = min(1.0, max(0.0, t))
for i in range(len(stops) - 1):
p0, c0 = stops[i]
p1, c1 = stops[i + 1]
if t <= p1 or i == len(stops) - 2:
f = 0.0 if p1 == p0 else (t - p0) / (p1 - p0)
f = min(1.0, max(0.0, f))
return tuple(round(a + (b - a) * f) for a, b in zip(c0, c1))
return stops[-1][1]
def gradient(size, angle_deg, stops):
"""
Linear gradient following the CSS convention: 0deg points up and angles run
clockwise, so 115deg runs from the top-left down towards the bottom-right.
The gradient line passes through the centre of the box and its length is the
projection of the box onto that line — get either wrong and the first stop
(the violet) falls outside the canvas and never appears.
Built by rendering a 1-D ramp and projecting it, which is ~500x faster than
evaluating the interpolation per pixel.
"""
import math
w, h = size
rad = math.radians(angle_deg - 90)
dx, dy = math.cos(rad), math.sin(rad)
length = abs(w * dx) + abs(h * dy)
cx, cy = w / 2, h / 2
# A 1-D ramp along the gradient line, then one lookup per pixel via a
# separable projection: t(x,y) = tx(x) + ty(y), so build both axes once.
steps = 1024
ramp = [_sample(stops, i / (steps - 1)) for i in range(steps)]
tx = [((x - cx) * dx) / length for x in range(w)]
ty = [((y - cy) * dy) / length + 0.5 for y in range(h)]
img = Image.new("RGB", size)
px = img.load()
for y in range(h):
base = ty[y]
for x in range(w):
i = int((base + tx[x]) * (steps - 1))
px[x, y] = ramp[0 if i < 0 else steps - 1 if i >= steps else i]
return img
def rounded_mask(size, radius):
mask = Image.new("L", size, 0)
ImageDraw.Draw(mask).rounded_rectangle([0, 0, size[0] - 1, size[1] - 1], radius, fill=255)
return mask
def load_font(size):
for path in (CAPTION_FONT, CAPTION_FALLBACK):
try:
return ImageFont.truetype(path, size)
except OSError:
continue
return ImageFont.load_default()
def compose(shot_path, caption, out_path, bg):
canvas = bg.convert("RGBA")
# Caption, centred.
draw = ImageDraw.Draw(canvas)
font = load_font(CAPTION_SIZE)
tw = draw.textbbox((0, 0), caption, font=font)[2]
draw.text(((W - tw) / 2, CAPTION_TOP), caption, font=font, fill=(255, 255, 255, 255))
# Window plate: scale to fit the area below the caption.
shot = Image.open(shot_path).convert("RGBA")
avail_w = W - 2 * PLATE_MARGIN_X
avail_h = H - PLATE_TOP - 120
scale = min(avail_w / shot.width, avail_h / shot.height)
pw, ph = round(shot.width * scale), round(shot.height * scale)
shot = shot.resize((pw, ph), Image.LANCZOS)
shot.putalpha(rounded_mask((pw, ph), CORNER_RADIUS))
px = (W - pw) // 2
py = PLATE_TOP + (avail_h - ph) // 2
# Soft drop shadow behind the plate. Inset horizontally and pushed down, so
# the blur reads as light from above rather than haloing every edge.
inset = SHADOW_BLUR // 2
shadow = Image.new("RGBA", (W, H), (0, 0, 0, 0))
ImageDraw.Draw(shadow).rounded_rectangle(
[px + inset, py + SHADOW_OFFSET, px + pw - inset, py + ph + SHADOW_OFFSET],
CORNER_RADIUS,
fill=(0, 0, 0, SHADOW_ALPHA),
)
canvas = Image.alpha_composite(canvas, shadow.filter(ImageFilter.GaussianBlur(SHADOW_BLUR)))
canvas.alpha_composite(shot, (px, py))
canvas.convert("RGB").save(out_path, "PNG", optimize=True)
return out_path
# --- Keeping the originals ------------------------------------------------
def keep_raw_inputs(raw):
"""Copy the raw captures into the repo so they cannot be lost again.
Compositing is one-way. Every derived frame carries a gradient, a caption
and rounded corners over the window, so there is no cropping back to a
clean screenshot — which is exactly the position the 1.3.2 submission was
in. The inputs are small, they are the expensive half of the job, and they
are the only thing a future submission can be rebuilt from.
"""
import shutil
# macOS/ — the Mac raws are the ones that cost the author's machine and
# their vault to reshoot, so they get a named home beside the two device
# folders (iPhone-6.5/, iPad-13/) rather than sitting loose in the root
# where a later `*.png` glob would sweep all three together.
keep = Path("assets/screenshots-raw/macOS")
keep.mkdir(parents=True, exist_ok=True)
copied = 0
for src in sorted(raw.glob("*.png")):
dst = keep / src.name
if not dst.exists() or dst.read_bytes() != src.read_bytes():
shutil.copy2(src, dst)
copied += 1
print(f"kept {copied} raw capture(s) in {keep}/ — commit them")
return keep
# --- Open Graph card -------------------------------------------------------
#
# Social crawlers want one stable, unhashed URL, so this writes straight to
# website/public/assets/og.png rather than going through astro:assets.
OG_W, OG_H = 1200, 630
def make_og(icon_path, out_path):
bg = gradient((OG_W, OG_H), ANGLE_DEG, STOPS)
canvas = bg.convert("RGBA")
draw = ImageDraw.Draw(canvas)
icon = Image.open(icon_path).convert("RGBA").resize((188, 188), Image.LANCZOS)
canvas.alpha_composite(icon, ((OG_W - 188) // 2, 92))
title = load_font(84)
tag = load_font(35)
for text, font, y in (
("HelloNotes", title, 310),
("A private, local-first Markdown", tag, 424),
("knowledge base for your Mac", tag, 476),
):
w = draw.textbbox((0, 0), text, font=font)[2]
draw.text(((OG_W - w) / 2, y), text, font=font, fill=(255, 255, 255, 255))
foot = load_font(26)
label = "hellotham.com/hellonotes"
w = draw.textbbox((0, 0), label, font=foot)[2]
draw.text(((OG_W - w) / 2, 548), label, font=foot, fill=(255, 255, 255, 205))
canvas.convert("RGB").save(out_path, "PNG", optimize=True)
return out_path
def main():
if len(sys.argv) != 3:
sys.exit(__doc__)
raw, out = Path(sys.argv[1]), Path(sys.argv[2])
keep_raw_inputs(raw)
out.mkdir(parents=True, exist_ok=True)
bg = gradient((W, H), ANGLE_DEG, STOPS)
for mode in ("light", "dark"):
for num, caption in SCENES:
# Accept both `dark_1.png` and `dark_01.png`. The output is always
# zero-padded, so a capture named after the *output* — the obvious
# thing to do — used to be silently skipped with one "! missing"
# line among ten, which reads like a warning rather than a failure.
src = next((c for c in (raw / f"{mode}_{int(num)}.png",
raw / f"{mode}_{num}.png") if c.exists()), None)
if src is None:
print(f" ! missing {raw}/{mode}_{num}.png")
continue
dst = out / f"{mode}_{num}.png"
compose(src, caption, dst, bg)
kb = dst.stat().st_size // 1024
print(f" {dst.name} {kb} KB")
icon = Path("website/public/assets/icon.png")
if icon.exists():
og = make_og(icon, Path("website/public/assets/og.png"))
print(f" {og} {og.stat().st_size // 1024} KB")
if __name__ == "__main__":
main()