@creator-notes/cnotes 0.33.1 → 0.35.1

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.
Files changed (36) hide show
  1. package/README.md +8 -0
  2. package/dist/commands/canvas.d.ts.map +1 -1
  3. package/dist/commands/canvas.js +194 -24
  4. package/dist/commands/canvas.js.map +1 -1
  5. package/dist/commands/notes.d.ts.map +1 -1
  6. package/dist/commands/notes.js +42 -10
  7. package/dist/commands/notes.js.map +1 -1
  8. package/dist/commands/skills.d.ts.map +1 -1
  9. package/dist/commands/skills.js +7 -2
  10. package/dist/commands/skills.js.map +1 -1
  11. package/dist/commands/versions.d.ts.map +1 -1
  12. package/dist/commands/versions.js +27 -5
  13. package/dist/commands/versions.js.map +1 -1
  14. package/dist/lib/api-client.d.ts.map +1 -1
  15. package/dist/lib/api-client.js +6 -1
  16. package/dist/lib/api-client.js.map +1 -1
  17. package/dist/lib/build-schema.d.ts +3 -3
  18. package/dist/lib/build-schema.d.ts.map +1 -1
  19. package/dist/lib/build-schema.js +4 -3
  20. package/dist/lib/build-schema.js.map +1 -1
  21. package/dist/lib/install-skill.d.ts +2 -1
  22. package/dist/lib/install-skill.d.ts.map +1 -1
  23. package/dist/lib/install-skill.js +13 -0
  24. package/dist/lib/install-skill.js.map +1 -1
  25. package/dist/lib/mention-lint.d.ts +42 -0
  26. package/dist/lib/mention-lint.d.ts.map +1 -1
  27. package/dist/lib/mention-lint.js +175 -0
  28. package/dist/lib/mention-lint.js.map +1 -1
  29. package/dist/mcp-server.js +27 -4
  30. package/dist/mcp-server.js.map +1 -1
  31. package/package.json +1 -1
  32. package/skills/cnotes/SKILL.md +37 -7
  33. package/skills/cnotes/references/humanizer.md +7 -1
  34. package/skills/howto-filmstrips/SKILL.md +214 -0
  35. package/skills/howto-filmstrips/scripts/inject-highlight.js +198 -0
  36. package/skills/howto-filmstrips/scripts/soft-spotlight.py +75 -0
@@ -197,7 +197,10 @@ humanization corrupts a note:
197
197
  Do not flatten a checklist into a paragraph.
198
198
  - **Relationship mentions** — titles and links intact. `[NOTE-12: Title](relationship:type)`
199
199
  must keep both the display ID and a readable title; never strip a mention to
200
- bare text or drop its link.
200
+ bare text or drop its link. And the TYPE must carry the verb: if the prose
201
+ narrates "blocks" / "depends on" around a `references` chip or untyped
202
+ `[[NOTE-12]]`, retype the chip (`relationship:blocks`, `[[blocks::NOTE-12]]`)
203
+ instead of leaving the verb in prose the graph can't see.
201
204
  - **Tables and layout** — tables, columns, and canvas placement survive.
202
205
  - **Voice as evidence** — a customer quote or raw memo keeps its original wording.
203
206
 
@@ -246,6 +249,9 @@ These are objective and never need judgment. Enforce them on every write:
246
249
  - **Every note starts with a `# h1`** heading — it becomes the title.
247
250
  - **Relationship mentions include a readable title**: `[NOTE-123: Title](relationship:type)`,
248
251
  never bare `NOTE-123`.
252
+ - **Relationship type matches the narrated verb** — prose "Blocks X" around a
253
+ `references`/untyped chip is a graph bug, not a style choice: retype to
254
+ `relationship:blocks` (or `[[blocks::NOTE-123]]`) and drop the redundant verb.
249
255
  - **No mention syntax inside canvas richtext or list descriptions** — it corrupts
250
256
  the card. Reference notes there as plain display IDs ("see RISK-64").
251
257
  - **Richtext stays card-sized** — at most ~3 sentences, one heading; distinct
@@ -0,0 +1,214 @@
1
+ ---
2
+ name: howto-filmstrips
3
+ description: >-
4
+ Capture a real product UI how-to as a horizontal filmstrip on a CreatorNotes
5
+ canvas: live CSS highlight on the click target, soft radial spotlight, then
6
+ screenshot → upload → place. Use when the user wants a screenshot storyboard,
7
+ click-path how-to, JTBD walkthrough, multi-path UI guide, or "show me how to
8
+ do X in the app" with accurate click markers (not fat-marker sketches).
9
+ ---
10
+
11
+ # How-to filmstrips (live UI capture on a canvas)
12
+
13
+ Turn a real product flow into a left-to-right filmstrip of screenshots on a CreatorNotes canvas. Each frame shows the full UI with a clear click (or type) target: **crimson highlight on the real element box** + **soft radial spotlight** dimming the rest.
14
+
15
+ This is the **pixel-accurate sibling** of `fat-marker-flows`. Use fat-marker for proposed / Shape Up sketches; use this skill for “how do I do this in the live product?”
16
+
17
+ ---
18
+
19
+ ## Design lock (do not invent a new look)
20
+
21
+ | Element | Spec |
22
+ |---------|------|
23
+ | Highlight colour | Classy crimson `#C43036` (outline, soft wash, CLICK/TYPE pill) |
24
+ | Spotlight | Soft **radial** fade, max opacity **~40%** (up to **~55%** on dark forms), long fade (`fade_mult ≈ 5.5`) |
25
+ | Done frame | Green `#2F9E6A` outline + `DONE` pill; lighter spotlight (~30%) |
26
+ | Captions | Canvas **richtext** under the image (same node), not Notes |
27
+ | Titles | Middle dot `·` as separator; **no em dashes** |
28
+ | Layout | One path = one horizontal row; multiple paths = stacked sections |
29
+ | Language | Match the product UI; prefer UK English in captions unless the product is US-only |
30
+
31
+ **Do not** use orange bullseyes, yellow highlighters, cyan brackets, or hard rectangular `box-shadow: 0 0 0 9999px` spotlights. The hard box-shadow gets clipped by parent `overflow` and often never appears in the PNG.
32
+
33
+ ---
34
+
35
+ ## When to use / not use
36
+
37
+ **Use when:** documenting a live click path, comparing entry points (Path A/B/C), workshop how-tos, JTBD storyboards from a real app.
38
+
39
+ **Do not use when:** the UI does not exist yet (use `fat-marker-flows`), or the user wants a polished Figma mock (use a design tool).
40
+
41
+ ---
42
+
43
+ ## Prerequisites
44
+
45
+ 1. Browser automation available (Cursor browser MCP / CDP `Runtime.evaluate` + screenshot).
46
+ 2. `cnotes` authenticated with a writable workspace (`cnotes auth status`, `cnotes workspace current`).
47
+ 3. This skill folder installed (`cnotes skills install howto-filmstrips`) so scripts resolve under the agent skills dir.
48
+
49
+ ---
50
+
51
+ ## Step 0 — Scope the paths
52
+
53
+ 1. Name the job (e.g. “Create a new contact”).
54
+ 2. List each **entry path** separately (Path A, Path B…). One path = one row.
55
+ 3. For each path, list 4–7 frames: each frame is **one action** (click or type) or a final Done state.
56
+ 4. Write captions before capture: `N · Click …` / `N · Type …` / `N · Done · …`.
57
+
58
+ ---
59
+
60
+ ## Step 1 — Inject highlight helpers (before any click)
61
+
62
+ Read `scripts/inject-highlight.js` and evaluate its **entire file contents** in the page via CDP `Runtime.evaluate` (or equivalent). That installs:
63
+
64
+ - `window.__cnHowto.highlight(el, { label: 'CLICK'|'TYPE'|'DONE' })`
65
+ - `window.__cnHowto.clear()`
66
+ - `window.__cnHowto.findExact(text, opts)` — visible, sized matches; prefer menu-row / control size
67
+
68
+ The injector uses a **fixed full-viewport radial overlay** (not element `box-shadow`), so the spotlight survives screenshot and parent clipping.
69
+
70
+ **Calibrate visually:** after first inject, highlight one control and screenshot. Check that the crimson outline hugs the element box (not an inner span), the CLICK pill sits just above it and is legible, and the spotlight is a soft radial fade centred on the control — never a hard-edged rectangle. Adjust opacity only if the product chrome is unusually light or dark.
71
+
72
+ ---
73
+
74
+ ## Step 2 — Capture loop (per frame)
75
+
76
+ For each click/type frame:
77
+
78
+ 1. **Find** the target with `__cnHowto.findExact` (exact label text) or a tight selector. Prefer the **largest visible** control that matches (menu row over inner text span).
79
+ 2. **Highlight** with the right label (`CLICK` / `TYPE`).
80
+ 3. **Screenshot** the viewport → save as `A1.png`, `A2.png`, …
81
+ 4. **Clear** highlight.
82
+ 5. **Perform** the action (click / type), wait for UI settle.
83
+ 6. Repeat.
84
+
85
+ For the **Done** frame: land on the success state, highlight the result (e.g. contact name) with `{ label: 'DONE' }`, screenshot, clear.
86
+
87
+ ### Accuracy rules
88
+
89
+ - Capture highlights **live** from element `getBoundingClientRect`. Do not guess pixel rings on old PNGs.
90
+ - If a frame teaches picking an option that is already selected, switch to another option first, then highlight the target and shoot — so the frame shows the switch, not a no-op.
91
+ - Retina / DPR: screenshots are often 2× CSS pixels. The injector uses CSS pixels for the overlay; if you fall back to `soft-spotlight.py`, pass `--dpr` (usually `2`).
92
+
93
+ ### Fallback if the in-page spotlight is weak
94
+
95
+ On very dark UIs the radial overlay can look faint. After capture, run:
96
+
97
+ ```bash
98
+ python3 "<skillDir>/scripts/soft-spotlight.py" \
99
+ --in A3.png --out A3.png \
100
+ --cx 276 --cy 219 --dpr 2 \
101
+ --opacity 0.55
102
+ ```
103
+
104
+ `--cx/--cy` are **CSS-pixel** centres from `getBoundingClientRect` (the script multiplies by `--dpr`).
105
+
106
+ ---
107
+
108
+ ## Step 3 — Upload and place on the canvas
109
+
110
+ ```bash
111
+ WS=<workspaceId>
112
+ CANVAS=<canvasId>
113
+ OUT=/tmp/howto-filmstrip
114
+ mkdir -p "$OUT"
115
+
116
+ cnotes operations begin -w "$WS" --prompt "How-to filmstrip: <job>" --json >/dev/null
117
+
118
+ # Upload in frame order; collect storageIds
119
+ > "$OUT/ids.txt"
120
+ for f in A1 A2 A3 A4 A5 A6; do
121
+ sid=$(cnotes files upload "$OUT/$f.png" -w "$WS" --json \
122
+ | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('storageId') or d.get('id') or '')")
123
+ echo "$sid" >> "$OUT/ids.txt"
124
+ done
125
+ ```
126
+
127
+ Build a place spec: header + one **section per path**, each with a **grid** of richtext cells (image markdown + caption). Prefer `columns: N` matching frame count (e.g. 6). Use `size: "medium"` for frames.
128
+
129
+ Image URL form inside richtext:
130
+
131
+ ```markdown
132
+ ![Short alt](/api/images/convex/<storageId>)
133
+
134
+ 1 · Click Create new +
135
+ ```
136
+
137
+ Clear existing storyboard nodes if rebuilding the same canvas:
138
+
139
+ ```bash
140
+ cnotes canvas get "$CANVAS" -w "$WS" --json | python3 -c "
141
+ import sys,json
142
+ d=json.load(sys.stdin)
143
+ ids=[n['id'] for n in (d.get('allNodes') or [])]
144
+ for s in d.get('sectionNodes') or []:
145
+ if s['id'] not in ids: ids.append(s['id'])
146
+ open('/tmp/howto-remove.json','w').write(json.dumps(ids))
147
+ "
148
+ cnotes canvas agent-run wrap --canvas "$CANVAS" --prompt "Clear before how-to rebuild" -- \
149
+ cnotes canvas bulk-remove "$CANVAS" --nodes "$(cat /tmp/howto-remove.json)" -w "$WS" --json
150
+ ```
151
+
152
+ Place:
153
+
154
+ ```bash
155
+ cnotes canvas agent-run wrap --canvas "$CANVAS" --prompt "Place how-to filmstrip" -- \
156
+ cnotes canvas place "$CANVAS" --spec "$OUT/unified.json" -w "$WS" --json
157
+ cnotes operations end -w "$WS" --json
158
+ ```
159
+
160
+ ### Multi-path layout sketch
161
+
162
+ ```json
163
+ {
164
+ "root": {
165
+ "kind": "stack",
166
+ "axis": "vertical",
167
+ "gap": "spacious",
168
+ "align": "start",
169
+ "items": [
170
+ { "kind": "item", "type": "richtext", "size": "large",
171
+ "content": "# How to …\n\nSame job, several entry points. Read each row left to right." },
172
+ {
173
+ "kind": "section",
174
+ "name": "Path A · …",
175
+ "child": {
176
+ "kind": "stack",
177
+ "axis": "vertical",
178
+ "gap": "tight",
179
+ "align": "start",
180
+ "items": [
181
+ { "kind": "item", "type": "richtext", "size": "small",
182
+ "content": "**Path A** · …" },
183
+ { "kind": "grid", "columns": 6, "gap": "medium", "items": [/* frames */] }
184
+ ]
185
+ }
186
+ }
187
+ ]
188
+ }
189
+ }
190
+ ```
191
+
192
+ ---
193
+
194
+ ## Gotchas (learned the hard way)
195
+
196
+ - **`box-shadow: 9999px` spotlights get clipped** and vanish from screenshots. Always use the fixed radial overlay from `inject-highlight.js`.
197
+ - **Wrong element:** loose regexes match sibling labels (`/save/i` also matches “Save and close”). Prefer exact text + min width/height + prefer larger hit targets.
198
+ - **CLICK pill vs outline mismatch:** place the pill from the highlighted element’s box after layout; re-highlight after fixing helpers if the first shot drifted.
199
+ - **Dark forms:** bump spotlight opacity toward 0.55 so the fade reads.
200
+ - **DPR:** post-process centres must be CSS × devicePixelRatio.
201
+ - **`place` appends below existing content** unless you bulk-remove first when rebuilding.
202
+ - **Verify images by node count / positions**, not by grepping `![` in stored content (TipTap converts markdown images).
203
+ - **Do not commit secrets** from the product under test; use throwaway trial data in screenshots.
204
+
205
+ ---
206
+
207
+ ## Pairing with fat-marker-flows
208
+
209
+ | Skill | Fidelity | Source |
210
+ |-------|----------|--------|
211
+ | `fat-marker-flows` | Rough Shape Up sketches | Image model |
212
+ | `howto-filmstrips` | Real product UI | Browser capture + CSS |
213
+
214
+ Same canvas can hold a fat-marker “proposed” row and a how-to “as shipped” row — keep the visual language of each skill intact so readers never confuse sketch with screenshot.
@@ -0,0 +1,198 @@
1
+ /**
2
+ * How-to filmstrip highlight helpers for the page under test.
3
+ *
4
+ * Evaluate this entire file in the page (CDP Runtime.evaluate / browser console).
5
+ * Then call:
6
+ * __cnHowto.highlight(el, { label: 'CLICK' | 'TYPE' | 'DONE' })
7
+ * __cnHowto.clear()
8
+ * __cnHowto.findExact('Contact', { minW: 100, minH: 36, preferX: 1100 })
9
+ *
10
+ * Uses a fixed full-viewport radial overlay so the spotlight is not clipped by
11
+ * parent overflow (unlike element box-shadow: 0 0 0 9999px).
12
+ *
13
+ * Accent colours are intentional for overlays on third-party product UIs
14
+ * (not CreatorNotes app chrome): crimson #C43036, done green #2F9E6A.
15
+ */
16
+ (() => {
17
+ const CRIMSON = "#C43036";
18
+ const GREEN = "#2F9E6A";
19
+ const LABEL_FG = "#ffffff";
20
+ const LABEL_SHADOW = "0 2px 8px rgba(0,0,0,0.35)";
21
+ const STYLE_ID = "cn-howto-filmstrip-style";
22
+ const OVERLAY_ID = "cn-howto-filmstrip-overlay";
23
+ const LABEL_ID = "cn-howto-filmstrip-label";
24
+ const HL_CLASS = "cn-howto-filmstrip-hl";
25
+
26
+ function ensureStyle() {
27
+ if (document.getElementById(STYLE_ID)) return;
28
+ const s = document.createElement("style");
29
+ s.id = STYLE_ID;
30
+ s.textContent = `
31
+ .${HL_CLASS} {
32
+ outline: 3px solid var(--cn-howto-accent, ${CRIMSON}) !important;
33
+ outline-offset: 3px !important;
34
+ border-radius: 6px !important;
35
+ box-shadow: 0 0 0 6px color-mix(in srgb, var(--cn-howto-accent, ${CRIMSON}) 18%, transparent) !important;
36
+ background-color: color-mix(in srgb, var(--cn-howto-accent, ${CRIMSON}) 12%, transparent) !important;
37
+ position: relative !important;
38
+ z-index: 2147483646 !important;
39
+ }
40
+ #${OVERLAY_ID} {
41
+ position: fixed;
42
+ inset: 0;
43
+ pointer-events: none;
44
+ z-index: 2147483645;
45
+ }
46
+ #${LABEL_ID} {
47
+ position: fixed;
48
+ z-index: 2147483647;
49
+ color: ${LABEL_FG};
50
+ font: 700 11px/1 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
51
+ letter-spacing: 0.08em;
52
+ padding: 5px 8px;
53
+ border-radius: 4px;
54
+ pointer-events: none;
55
+ box-shadow: ${LABEL_SHADOW};
56
+ }
57
+ `;
58
+ document.documentElement.appendChild(s);
59
+ }
60
+
61
+ function clear() {
62
+ document.querySelectorAll("." + HL_CLASS).forEach((el) => {
63
+ el.classList.remove(HL_CLASS);
64
+ el.style.removeProperty("--cn-howto-accent");
65
+ });
66
+ document.getElementById(OVERLAY_ID)?.remove();
67
+ document.getElementById(LABEL_ID)?.remove();
68
+ }
69
+
70
+ function placeSpotlight(cx, cy, { maxOpacity = 0.4, holeR = 100, fadeMult = 5.5 } = {}) {
71
+ let overlay = document.getElementById(OVERLAY_ID);
72
+ if (!overlay) {
73
+ overlay = document.createElement("div");
74
+ overlay.id = OVERLAY_ID;
75
+ document.documentElement.appendChild(overlay);
76
+ }
77
+ const rampEnd = holeR * fadeMult;
78
+ overlay.style.background = `radial-gradient(
79
+ circle at ${cx}px ${cy}px,
80
+ rgba(0,0,0,0) 0px,
81
+ rgba(0,0,0,0) ${holeR}px,
82
+ rgba(0,0,0,${maxOpacity * 0.35}) ${holeR + (rampEnd - holeR) * 0.35}px,
83
+ rgba(0,0,0,${maxOpacity}) ${rampEnd}px,
84
+ rgba(0,0,0,${maxOpacity}) 100%
85
+ )`;
86
+ }
87
+
88
+ function placeLabel(r, text, accent) {
89
+ document.getElementById(LABEL_ID)?.remove();
90
+ const label = document.createElement("div");
91
+ label.id = LABEL_ID;
92
+ label.textContent = text;
93
+ label.style.background = accent;
94
+ document.documentElement.appendChild(label);
95
+ const lw = label.offsetWidth;
96
+ const lh = label.offsetHeight;
97
+ let left = r.left + (r.width - lw) / 2;
98
+ let top = r.top - lh - 8;
99
+ if (top < 8) top = r.bottom + 8;
100
+ left = Math.max(8, Math.min(left, window.innerWidth - lw - 8));
101
+ label.style.left = left + "px";
102
+ label.style.top = top + "px";
103
+ }
104
+
105
+ function highlight(el, opts = {}) {
106
+ if (!el) return { ok: false, reason: "no element" };
107
+ ensureStyle();
108
+ clear();
109
+
110
+ const label = (opts.label || "CLICK").toUpperCase();
111
+ const isDone = label === "DONE";
112
+ const accent = isDone ? GREEN : CRIMSON;
113
+ const maxOpacity = opts.maxOpacity ?? (isDone ? 0.3 : 0.4);
114
+
115
+ el.style.setProperty("--cn-howto-accent", accent);
116
+ el.classList.add(HL_CLASS);
117
+ el.scrollIntoView({ block: "nearest", inline: "nearest" });
118
+
119
+ const r = el.getBoundingClientRect();
120
+ const cx = r.left + r.width / 2;
121
+ const cy = r.top + r.height / 2;
122
+ const holeR = opts.holeR ?? Math.max(80, Math.max(r.width, r.height) * 0.9);
123
+
124
+ placeSpotlight(cx, cy, { maxOpacity, holeR, fadeMult: opts.fadeMult ?? 5.5 });
125
+ placeLabel(r, label, accent);
126
+
127
+ return {
128
+ ok: true,
129
+ tag: el.tagName,
130
+ text: (el.getAttribute("aria-label") || el.textContent || "")
131
+ .replace(/\s+/g, " ")
132
+ .trim()
133
+ .slice(0, 80),
134
+ box: { x: r.x, y: r.y, w: r.width, h: r.height },
135
+ center: { x: cx, y: cy },
136
+ label,
137
+ };
138
+ }
139
+
140
+ function findExact(exact, opts = {}) {
141
+ const minW = opts.minW ?? 20;
142
+ const minH = opts.minH ?? 20;
143
+ const maxH = opts.maxH ?? 120;
144
+ const selectors =
145
+ 'button, a, [role="button"], [role="menuitem"], [role="tab"], [role="radio"], label, input, div, span, li';
146
+
147
+ const candidates = [...document.querySelectorAll(selectors)]
148
+ .map((el) => {
149
+ const r = el.getBoundingClientRect();
150
+ const text = (el.getAttribute("aria-label") || el.textContent || "")
151
+ .replace(/\s+/g, " ")
152
+ .trim();
153
+ const style = getComputedStyle(el);
154
+ return {
155
+ el,
156
+ text,
157
+ r,
158
+ area: r.width * r.height,
159
+ visible:
160
+ r.width >= minW &&
161
+ r.height >= minH &&
162
+ r.height <= maxH &&
163
+ style.visibility !== "hidden" &&
164
+ style.display !== "none",
165
+ };
166
+ })
167
+ .filter((c) => c.text === exact && c.visible);
168
+
169
+ if (!candidates.length) return null;
170
+
171
+ candidates.sort((a, b) => {
172
+ let scoreA = a.area;
173
+ let scoreB = b.area;
174
+ if (opts.preferLarge !== false) {
175
+ scoreA = -a.area;
176
+ scoreB = -b.area;
177
+ }
178
+ if (opts.preferX != null) {
179
+ scoreA += Math.abs(a.r.x - opts.preferX) * 8;
180
+ scoreB += Math.abs(b.r.x - opts.preferX) * 8;
181
+ }
182
+ if (opts.preferY != null) {
183
+ scoreA += Math.abs(a.r.y - opts.preferY) * 8;
184
+ scoreB += Math.abs(b.r.y - opts.preferY) * 8;
185
+ }
186
+ return scoreA - scoreB;
187
+ });
188
+
189
+ return candidates[0].el;
190
+ }
191
+
192
+ window.__cnHowto = { highlight, clear, findExact, ensureStyle };
193
+ window.__cnHighlight = (el, opts) => highlight(el, opts);
194
+ window.__cnClearHighlight = clear;
195
+ window.__cnFindExact = findExact;
196
+
197
+ return { ok: true, api: ["highlight", "clear", "findExact"] };
198
+ })();
@@ -0,0 +1,75 @@
1
+ #!/usr/bin/env python3
2
+ """Soft radial spotlight post-process for how-to filmstrip frames.
3
+
4
+ Use when the in-page CSS overlay was weak or missing. Centres are CSS pixels
5
+ from getBoundingClientRect; pass --dpr for retina screenshots (often 2).
6
+
7
+ Example:
8
+ python3 soft-spotlight.py --in A3.png --out A3.png --cx 276 --cy 219 --dpr 2 --opacity 0.55
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ from pathlib import Path
15
+
16
+ import numpy as np
17
+ from PIL import Image
18
+
19
+
20
+ def soft_spotlight(
21
+ im: Image.Image,
22
+ cx: float,
23
+ cy: float,
24
+ hole_r: float = 180,
25
+ max_opacity: float = 0.4,
26
+ fade_mult: float = 5.5,
27
+ ) -> Image.Image:
28
+ w, h = im.size
29
+ ys = np.arange(h)[:, None]
30
+ xs = np.arange(w)[None, :]
31
+ dist = np.sqrt((xs - cx) ** 2 + (ys - cy) ** 2)
32
+ ramp_end = hole_r * fade_mult
33
+ t = np.clip((dist - hole_r) / max(ramp_end - hole_r, 1), 0, 1)
34
+ t = t * t * (3 - 2 * t) # smoothstep
35
+ alpha = (t * max_opacity * 255).astype(np.uint8)
36
+ overlay = np.zeros((h, w, 4), dtype=np.uint8)
37
+ overlay[..., 3] = alpha
38
+ base = im.convert("RGBA")
39
+ return Image.alpha_composite(base, Image.fromarray(overlay, "RGBA"))
40
+
41
+
42
+ def main() -> None:
43
+ p = argparse.ArgumentParser(description=__doc__)
44
+ p.add_argument("--in", dest="src", required=True, help="Input PNG")
45
+ p.add_argument("--out", dest="dst", required=True, help="Output PNG")
46
+ p.add_argument("--cx", type=float, required=True, help="Centre X in CSS pixels")
47
+ p.add_argument("--cy", type=float, required=True, help="Centre Y in CSS pixels")
48
+ p.add_argument("--dpr", type=float, default=1.0, help="Device pixel ratio (default 1)")
49
+ p.add_argument("--hole", type=float, default=None, help="Clear hole radius in device pixels")
50
+ p.add_argument("--opacity", type=float, default=0.4, help="Max dim opacity 0–1")
51
+ p.add_argument("--fade-mult", type=float, default=5.5, help="Fade length as multiple of hole")
52
+ args = p.parse_args()
53
+
54
+ src = Path(args.src)
55
+ dst = Path(args.dst)
56
+ im = Image.open(src)
57
+ cx = args.cx * args.dpr
58
+ cy = args.cy * args.dpr
59
+ hole = args.hole if args.hole is not None else max(160.0, 90.0 * args.dpr)
60
+
61
+ out = soft_spotlight(
62
+ im,
63
+ cx,
64
+ cy,
65
+ hole_r=hole,
66
+ max_opacity=args.opacity,
67
+ fade_mult=args.fade_mult,
68
+ )
69
+ dst.parent.mkdir(parents=True, exist_ok=True)
70
+ out.convert("RGB").save(dst, quality=92)
71
+ print(f"wrote {dst} center=({cx:.0f},{cy:.0f}) hole={hole:.0f} opacity={args.opacity}")
72
+
73
+
74
+ if __name__ == "__main__":
75
+ main()