screengraft 0.13.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.
package/scripts/ui.py ADDED
@@ -0,0 +1,503 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ screengraft — the local UI. One browser tab, the whole job.
4
+
5
+ python3 scripts/ui.py [--port 0] [--no-open] [--session DIR]
6
+
7
+ Serves ui/index.html on 127.0.0.1 and opens it in the default browser. The
8
+ page walks the designer through: pick a photo (recent Desktop/Downloads
9
+ thumbnails, drag-drop, browse, or paste a path) -> pick the screenshot (same,
10
+ or paste a Figma frame link) -> device/radius -> drag the four corners over the
11
+ photo -> Preview -> Save to ~/Desktop/screengraft/.
12
+
13
+ Everything geometric happens here in Python (detect.py, warp.py). The page
14
+ only collects intent and shows results.
15
+
16
+ The one thing the page can't do is talk to Figma. For a Figma link it writes
17
+ <session>/job.json {"type":"figma_export"|"present", ..., "status":"pending"}
18
+ This is an OUTBOX, not a mailbox the agent happens to check: the agent cannot
19
+ poll between turns, so it parks in the screengraft MCP server's `wait_for_job`,
20
+ which watches this file and returns within ~150ms of a button press. The agent
21
+ answers with `complete_job`, which rewrites the file with status done|error.
22
+ The page polls /api/job for that. Errors: {"status":"error","message":...}.
23
+
24
+ When the user saves, <session>/result.json is written — the agent reads it to
25
+ know what was produced (and to verify the output image before claiming done).
26
+
27
+ stdlib only on the server side; OpenCV via detect/warp.
28
+ """
29
+ import argparse
30
+ import atexit
31
+ import json
32
+ import mimetypes
33
+ import os
34
+ import signal
35
+ import socket
36
+ import subprocess
37
+ import sys
38
+ import threading
39
+ import time
40
+ import urllib.parse
41
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
42
+
43
+ HERE = os.path.dirname(os.path.abspath(__file__))
44
+ ROOT = os.path.dirname(HERE)
45
+ sys.path.insert(0, HERE)
46
+
47
+ import cv2 # noqa: E402
48
+ import numpy as np # noqa: E402
49
+ import detect as D # noqa: E402
50
+ import scan as S # noqa: E402
51
+ import warp as W # noqa: E402
52
+
53
+ HOME = os.path.expanduser("~")
54
+ # Overridden by --out-dir. The skill passes <project>/mockups so that saves land
55
+ # inside the folder the designer is working in: present_files refuses anything
56
+ # outside a connected folder, so a Desktop path cannot be shown in chat at all.
57
+ OUT_DIR = os.path.join(HOME, "Desktop", "screengraft")
58
+ UI_HTML = os.path.join(ROOT, "ui", "index.html")
59
+
60
+ # Pointer to the UI instance the agent should talk to. The MCP server reads this
61
+ # to find the session, and checks the pid so a pointer left by a crashed UI is
62
+ # treated as no UI at all rather than one that never answers.
63
+ CURRENT = os.path.join(HOME, ".screengraft", "current.json")
64
+
65
+
66
+ def _write_json_atomic(path, obj):
67
+ """Write via tmp + rename.
68
+
69
+ The MCP server reads job.json in a 150ms poll loop, so a plain truncating
70
+ write is a real chance to be read half-formed. os.replace is atomic on the
71
+ same filesystem.
72
+ """
73
+ tmp = f"{path}.tmp"
74
+ with open(tmp, "w") as f:
75
+ json.dump(obj, f, indent=1)
76
+ os.replace(tmp, path)
77
+
78
+ # Corner radius as a fraction of the SCREEN'S WIDTH, per device preset.
79
+ # Approximations from public specs (pt): iPhone 15/16 393pt wide, ~55pt radius;
80
+ # Pro Max 430pt; iPads ~18pt on 744-1024pt; MacBook display corners ~12px on
81
+ # ~1500pt; monitors square. Good enough to start a drag from; measure beats these.
82
+ PRESETS = [
83
+ {"id": "phone-iphone", "type": "phone", "label": "iPhone 15 / 16 / Pro", "frac": 0.140},
84
+ {"id": "phone-iphone-max", "type": "phone", "label": "iPhone Plus / Pro Max", "frac": 0.128},
85
+ {"id": "phone-android", "type": "phone", "label": "Android (typical)", "frac": 0.090},
86
+ {"id": "tablet-ipad-pro-11", "type": "tablet", "label": "iPad Pro 11 / Air", "frac": 0.022},
87
+ {"id": "tablet-ipad-pro-13", "type": "tablet", "label": "iPad Pro 13", "frac": 0.018},
88
+ {"id": "tablet-ipad-mini", "type": "tablet", "label": "iPad mini", "frac": 0.024},
89
+ {"id": "laptop-macbook", "type": "laptop", "label": "MacBook Air / Pro", "frac": 0.008},
90
+ {"id": "laptop-other", "type": "laptop", "label": "Other laptop (square)", "frac": 0.0},
91
+ {"id": "desktop", "type": "desktop", "label": "Desktop monitor (square)", "frac": 0.0},
92
+ ]
93
+
94
+
95
+ class BusyError(Exception):
96
+ """A job is already in flight; enqueueing another would discard it."""
97
+
98
+
99
+ class Session:
100
+ def __init__(self, path):
101
+ self.dir = path
102
+ os.makedirs(os.path.join(path, "thumbs"), exist_ok=True)
103
+ self.state_path = os.path.join(path, "state.json")
104
+ self.job_path = os.path.join(path, "job.json")
105
+ self.result_path = os.path.join(path, "result.json")
106
+ self.state = {"photo": None, "screenshot": None, "corners": None,
107
+ "radius_frac": None, "device": None, "output": None}
108
+ self._save()
109
+
110
+ def _save(self):
111
+ _write_json_atomic(self.state_path, self.state)
112
+
113
+ def read_job(self):
114
+ """Tolerant read — a torn file reads as absent rather than raising."""
115
+ try:
116
+ with open(self.job_path) as f:
117
+ return json.load(f)
118
+ except (OSError, ValueError):
119
+ return None
120
+
121
+ def enqueue(self, job):
122
+ """Put a request in the outbox for the agent's blocking wait to pick up.
123
+
124
+ There is one job slot. Overwriting a pending job would silently discard
125
+ a request the agent may already be working on — press Import while a
126
+ Figma export is in flight and the export result would be dropped with
127
+ no sign of it. Refuse instead, and let the page say why.
128
+ """
129
+ current = self.read_job()
130
+ if current and current.get("status") == "pending":
131
+ raise BusyError("Claude is still working on the previous request. "
132
+ "Wait for it to finish, or reload the page to start over.")
133
+ job.setdefault("id", f"{int(time.time() * 1000)}")
134
+ job["status"] = "pending"
135
+ job["requested"] = time.time()
136
+ _write_json_atomic(self.job_path, job)
137
+ return job
138
+
139
+ def update(self, **kw):
140
+ self.state.update(kw)
141
+ self._save()
142
+
143
+
144
+ SESSION: Session = None
145
+
146
+
147
+ def _safe_local_path(p: str) -> str:
148
+ """Only serve files under the user's home (the UI is local, but still)."""
149
+ p = os.path.realpath(os.path.expanduser(p))
150
+ if not p.startswith(os.path.realpath(HOME) + os.sep):
151
+ raise PermissionError("outside home")
152
+ if not os.path.isfile(p):
153
+ raise FileNotFoundError(p)
154
+ return p
155
+
156
+
157
+ def _read_image(path: str):
158
+ p = _safe_local_path(path)
159
+ im = cv2.imread(p, cv2.IMREAD_COLOR)
160
+ if im is None and p.lower().endswith((".heic", ".heif")) and sys.platform == "darwin":
161
+ conv = os.path.join(SESSION.dir, os.path.splitext(os.path.basename(p))[0] + ".jpg")
162
+ subprocess.run(["sips", "-s", "format", "jpeg", p, "--out", conv], capture_output=True)
163
+ im = cv2.imread(conv, cv2.IMREAD_COLOR)
164
+ if im is not None:
165
+ return im, conv
166
+ if im is None:
167
+ raise ValueError(f"could not read image: {p}")
168
+ return im, p
169
+
170
+
171
+ def _guess_type(corners):
172
+ c = np.array(corners, dtype=float)
173
+ w = (np.linalg.norm(c[1] - c[0]) + np.linalg.norm(c[2] - c[3])) / 2
174
+ h = (np.linalg.norm(c[3] - c[0]) + np.linalg.norm(c[2] - c[1])) / 2
175
+ if w <= 0 or h <= 0:
176
+ return None
177
+ r = h / w
178
+ if r > 1.6:
179
+ return "phone"
180
+ if r > 1.1:
181
+ return "tablet"
182
+ if r > 0.5:
183
+ return "laptop"
184
+ return "desktop"
185
+
186
+
187
+ class Handler(BaseHTTPRequestHandler):
188
+ server_version = "screengraft/0.3"
189
+
190
+ def log_message(self, fmt, *args): # quiet
191
+ pass
192
+
193
+ # ---- helpers ----
194
+ def _json(self, obj, code=200):
195
+ body = json.dumps(obj).encode()
196
+ self.send_response(code)
197
+ self.send_header("Content-Type", "application/json")
198
+ self.send_header("Content-Length", str(len(body)))
199
+ self.end_headers()
200
+ self.wfile.write(body)
201
+
202
+ def _file(self, path, ctype=None):
203
+ try:
204
+ with open(path, "rb") as f:
205
+ data = f.read()
206
+ except OSError:
207
+ return self._json({"error": "not found"}, 404)
208
+ self.send_response(200)
209
+ self.send_header("Content-Type", ctype or mimetypes.guess_type(path)[0] or "application/octet-stream")
210
+ self.send_header("Content-Length", str(len(data)))
211
+ self.send_header("Cache-Control", "no-store")
212
+ self.end_headers()
213
+ self.wfile.write(data)
214
+
215
+ def _body(self):
216
+ n = int(self.headers.get("Content-Length") or 0)
217
+ return self.rfile.read(n) if n else b""
218
+
219
+ def _jbody(self):
220
+ raw = self._body()
221
+ return json.loads(raw.decode() or "{}")
222
+
223
+ # ---- GET ----
224
+ def do_GET(self):
225
+ u = urllib.parse.urlparse(self.path)
226
+ q = urllib.parse.parse_qs(u.query)
227
+ try:
228
+ if u.path == "/":
229
+ return self._file(UI_HTML, "text/html; charset=utf-8")
230
+ if u.path == "/api/state":
231
+ return self._json({**SESSION.state, "session": SESSION.dir, "out_dir": OUT_DIR,
232
+ "home": HOME, "presets": PRESETS})
233
+ if u.path == "/api/recent":
234
+ items = S.scan(days=int(q.get("days", ["14"])[0]), limit=int(q.get("limit", ["40"])[0]))
235
+ for it in items:
236
+ it["thumb"] = S.thumb(it, os.path.join(SESSION.dir, "thumbs"))
237
+ return self._json({"items": items})
238
+ if u.path == "/file":
239
+ return self._file(_safe_local_path(q["path"][0]))
240
+ if u.path == "/api/job":
241
+ return self._json(SESSION.read_job() or {"status": "none"})
242
+
243
+ if u.path == "/api/job/wait":
244
+ # LONG POLL, not a timer. setInterval is throttled to roughly
245
+ # once a minute in a hidden tab, and the tab is hidden for the
246
+ # exact workflow this exists for: paste a Figma link, switch to
247
+ # Figma. The agent would answer in 200ms and the page would sit
248
+ # there for up to a minute. A pending fetch is not throttled, so
249
+ # the answer lands as soon as it exists. (ThreadingHTTPServer,
250
+ # so a blocked request does not hold up the rest of the page.)
251
+ timeout = min(float(q.get("timeout", ["25"])[0]), 60.0)
252
+ deadline = time.time() + timeout
253
+ while True:
254
+ job = SESSION.read_job()
255
+ if not job:
256
+ return self._json({"status": "none"})
257
+ if job.get("status") != "pending":
258
+ return self._json(job)
259
+ if time.time() >= deadline:
260
+ return self._json({"status": "pending", "waited": True})
261
+ time.sleep(0.15)
262
+ return self._json({"error": "no such route"}, 404)
263
+ except (PermissionError, FileNotFoundError, KeyError, ValueError) as e:
264
+ return self._json({"error": str(e)}, 400)
265
+
266
+ # ---- POST ----
267
+ def do_POST(self):
268
+ u = urllib.parse.urlparse(self.path)
269
+ try:
270
+ if u.path == "/api/upload":
271
+ # raw bytes + X-Filename + X-Role (photo|screenshot); no multipart, no cgi module.
272
+ name = os.path.basename(urllib.parse.unquote(self.headers.get("X-Filename") or "upload.png"))
273
+ role = self.headers.get("X-Role") or "photo"
274
+ dest = os.path.join(SESSION.dir, f"{role}-{int(time.time())}-{name}")
275
+ with open(dest, "wb") as f:
276
+ f.write(self._body())
277
+ im, real = _read_image(dest)
278
+ SESSION.update(**{role: real})
279
+ return self._json({"path": real, "size": [im.shape[1], im.shape[0]]})
280
+
281
+ b = self._jbody()
282
+
283
+ if u.path == "/api/use":
284
+ role = b["role"]
285
+ im, real = _read_image(b["path"])
286
+ SESSION.update(**{role: real})
287
+ return self._json({"path": real, "size": [im.shape[1], im.shape[0]]})
288
+
289
+ if u.path == "/api/figma":
290
+ return self._json(SESSION.enqueue({
291
+ "type": "figma_export", "url": b["url"],
292
+ "save_to": os.path.join(SESSION.dir, "figma-export.png"),
293
+ "instructions": "Export this node as PNG at 3x via the Figma MCP "
294
+ "(download_assets), save it to save_to, then call "
295
+ "complete_job with status=done and path=<saved file>.",
296
+ }))
297
+
298
+ if u.path == "/api/import":
299
+ # "Import to Claude". The page cannot put an image in the chat
300
+ # panel — only the agent can, via present_files — so this is a
301
+ # request, not an action. The file is already in OUT_DIR, which
302
+ # the skill points at the project folder precisely so that
303
+ # present_files will accept it.
304
+ out = SESSION.state.get("output")
305
+ if not out or not os.path.isfile(out):
306
+ return self._json({"error": "nothing saved yet"}, 400)
307
+ return self._json(SESSION.enqueue({
308
+ "type": "present", "paths": [out],
309
+ "instructions": "Show these files to the user with present_files, say what "
310
+ "you checked in the composite, then call complete_job "
311
+ "with status=done.",
312
+ }))
313
+
314
+ if u.path == "/api/job/adopt":
315
+ # page calls this once job.status == done, to make the export the screenshot
316
+ with open(SESSION.job_path) as f:
317
+ job = json.load(f)
318
+ im, real = _read_image(job["path"])
319
+ SESSION.update(screenshot=real)
320
+ return self._json({"path": real, "size": [im.shape[1], im.shape[0]]})
321
+
322
+ if u.path == "/api/detect":
323
+ photo, _ = _read_image(SESSION.state["photo"])
324
+ gray = cv2.cvtColor(photo, cv2.COLOR_BGR2GRAY)
325
+ res = D.detect(gray, None)
326
+ if res is None:
327
+ return self._json({"found": False,
328
+ "message": "Neither detector could find a screen here "
329
+ "(nothing separable by tone, no screen-shaped "
330
+ "boundary). Place the four corners by hand."})
331
+ res.pop("_corners_np", None)
332
+ res["found"] = True
333
+ # How much the page should trust this. Both detectors agreeing is
334
+ # the only case worth stating plainly; everything else is a guess
335
+ # the human has to check, and must not be shown as a success.
336
+ res["confidence"] = ("corroborated" if res.get("agreement", {}).get("agree")
337
+ else "unconfirmed")
338
+ res["type_guess"] = _guess_type(res["corners"])
339
+ return self._json(res)
340
+
341
+ if u.path in ("/api/preview", "/api/save"):
342
+ photo, ppath = _read_image(SESSION.state["photo"])
343
+ shot, spath = _read_image(SESSION.state["screenshot"])
344
+ corners = b["corners"]
345
+ frac = float(b.get("radius_frac") or 0.0)
346
+ radius_px = frac * shot.shape[1]
347
+ # M2 realism pass. Off is a real option, not a fallback: a flat
348
+ # composite is the right output when the screenshot's own colour
349
+ # is the point (a brand review), and the grade is the right one
350
+ # when the photograph is (a portfolio shot).
351
+ gr = float(b.get("grade") if b.get("grade") is not None else 0.0)
352
+ out = W.compose(photo, shot, corners, radius_px,
353
+ grade=gr, grain=bool(b.get("grain", gr > 0)))
354
+ SESSION.update(corners=corners, radius_frac=frac, device=b.get("device"),
355
+ grade=gr)
356
+ if u.path == "/api/preview":
357
+ dest = os.path.join(SESSION.dir, "preview.png")
358
+ # preview at <=1600px wide for speed; Save renders full-res
359
+ h, w = out.shape[:2]
360
+ if w > 1600:
361
+ s = 1600 / w
362
+ out = cv2.resize(out, (1600, int(h * s)), interpolation=cv2.INTER_AREA)
363
+ cv2.imwrite(dest, out, [cv2.IMWRITE_PNG_COMPRESSION, 3])
364
+ return self._json({"path": dest, "radius_px": round(radius_px, 1)})
365
+ os.makedirs(OUT_DIR, exist_ok=True)
366
+ stem = f"{os.path.splitext(os.path.basename(ppath))[0]}__{os.path.splitext(os.path.basename(spath))[0]}"
367
+ dest = os.path.join(OUT_DIR, stem + ".png")
368
+ i = 2
369
+ while os.path.exists(dest):
370
+ dest = os.path.join(OUT_DIR, f"{stem}-{i}.png"); i += 1
371
+ cv2.imwrite(dest, out, [cv2.IMWRITE_PNG_COMPRESSION, 9])
372
+ # radius_px is NOT rounded here. It was, and re-running
373
+ # compose from the sidecar then reproduced neither the old nor
374
+ # the new code path — 144.7 vs the actual 144.72 was enough to
375
+ # move boundary pixels. A sidecar that cannot reproduce its own
376
+ # output undercuts the determinism claim; round for display only.
377
+ # EVERY argument that changes the output belongs here. `grade` and
378
+ # `grain` were added to compose() and not to this dict, so a save
379
+ # made with the realism pass on could not be reproduced from its
380
+ # own sidecar — the same defect fixed earlier for radius_px, in a new
381
+ # field, with the warning above it. test_sidecar.py now compares
382
+ # these keys against compose()'s signature so the next parameter
383
+ # cannot be forgotten the same way.
384
+ result = {"output": dest, "photo": ppath, "screenshot": spath, "corners": corners,
385
+ "radius_frac": frac, "radius_px": radius_px, "device": b.get("device"),
386
+ "grade": gr, "grain": bool(b.get("grain", gr > 0)),
387
+ "saved": time.time()}
388
+ _write_json_atomic(SESSION.result_path, result)
389
+ SESSION.update(output=dest)
390
+ return self._json(result)
391
+
392
+ return self._json({"error": "no such route"}, 404)
393
+ except BusyError as e:
394
+ return self._json({"error": str(e)}, 409)
395
+ except (PermissionError, FileNotFoundError, KeyError, ValueError) as e:
396
+ return self._json({"error": str(e)}, 400)
397
+
398
+
399
+ def free_port():
400
+ s = socket.socket()
401
+ s.bind(("127.0.0.1", 0))
402
+ p = s.getsockname()[1]
403
+ s.close()
404
+ return p
405
+
406
+
407
+ def _daemonise(log_path):
408
+ """Detach fully: double-fork, setsid, reopen stdio.
409
+
410
+ A plain `nohup ... &` was tried on 3 Sep 2026 and the server died silently
411
+ between turns, twice — the launching shell's session teardown took the
412
+ merely-backgrounded child with it. The fix then was a Terminal window,
413
+ which survives but leaves a dead "[Process completed]" window behind after
414
+ every session. setsid puts this process in its own session with no
415
+ controlling terminal, so it survives the parent AND leaves nothing to
416
+ clean up.
417
+
418
+ The first fork lets the parent exit so the shell gets its prompt back; the
419
+ second stops the daemon from ever acquiring a controlling terminal.
420
+ """
421
+ if os.fork() > 0:
422
+ os._exit(0)
423
+ os.setsid()
424
+ if os.fork() > 0:
425
+ os._exit(0)
426
+ log = open(log_path or os.devnull, "a", buffering=1)
427
+ os.dup2(log.fileno(), sys.stdout.fileno())
428
+ os.dup2(log.fileno(), sys.stderr.fileno())
429
+ devnull = open(os.devnull, "r")
430
+ os.dup2(devnull.fileno(), sys.stdin.fileno())
431
+
432
+
433
+ def _publish_current(payload):
434
+ """Announce this UI instance to the MCP server, and clear it on the way out.
435
+
436
+ The pointer carries our pid so a stale file from a crashed UI reads as
437
+ 'no UI' rather than as one that never answers — the agent would otherwise
438
+ block for a full timeout against a session nobody is looking at.
439
+ """
440
+ os.makedirs(os.path.dirname(CURRENT), exist_ok=True)
441
+ _write_json_atomic(CURRENT, payload)
442
+
443
+ def clear():
444
+ cur = None
445
+ try:
446
+ with open(CURRENT) as f:
447
+ cur = json.load(f)
448
+ except (OSError, ValueError):
449
+ return
450
+ # Only remove our own pointer: a newer UI may have replaced it, and
451
+ # deleting that one would strand the session the user is actually in.
452
+ if cur.get("pid") == os.getpid():
453
+ try:
454
+ os.remove(CURRENT)
455
+ except OSError:
456
+ pass
457
+
458
+ atexit.register(clear)
459
+ for sig in (signal.SIGTERM, signal.SIGINT):
460
+ signal.signal(sig, lambda *_: sys.exit(0)) # sys.exit runs atexit; kill -9 cannot be caught, which is why the pid check exists
461
+
462
+
463
+ def main():
464
+ global SESSION, OUT_DIR
465
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
466
+ ap.add_argument("--port", type=int, default=0, help="0 = pick a free port")
467
+ ap.add_argument("--no-open", action="store_true", help="Don't open the browser")
468
+ ap.add_argument("--session", help="Session dir (default ~/.screengraft/sessions/<timestamp>)")
469
+ ap.add_argument("--out-dir", help="Where Save writes (default ~/Desktop/screengraft). "
470
+ "The skill passes <project>/mockups so saves land in the "
471
+ "folder the designer is working in.")
472
+ ap.add_argument("--daemon", action="store_true",
473
+ help="Detach into the background (double-fork + setsid) instead of running "
474
+ "in a Terminal window. Survives the launching shell being torn down, "
475
+ "which a plain background job does not — see an earlier finding.")
476
+ ap.add_argument("--log", help="With --daemon: where stdout/stderr go.")
477
+ args = ap.parse_args()
478
+
479
+ if args.daemon:
480
+ _daemonise(args.log)
481
+
482
+ if args.out_dir:
483
+ OUT_DIR = os.path.abspath(os.path.expanduser(args.out_dir))
484
+ sdir = args.session or os.path.join(HOME, ".screengraft", "sessions", time.strftime("%Y%m%d-%H%M%S"))
485
+ SESSION = Session(sdir)
486
+ port = args.port or free_port()
487
+ url = f"http://127.0.0.1:{port}/"
488
+ srv = ThreadingHTTPServer(("127.0.0.1", port), Handler)
489
+ _publish_current({"session": sdir, "url": url, "pid": os.getpid(),
490
+ "out_dir": OUT_DIR, "started": time.time()})
491
+ print(json.dumps({"url": url, "session": sdir, "job": SESSION.job_path,
492
+ "result": SESSION.result_path, "out_dir": OUT_DIR}), flush=True)
493
+ if not args.no_open:
494
+ opener = "open" if sys.platform == "darwin" else "xdg-open"
495
+ threading.Timer(0.3, lambda: subprocess.Popen([opener, url])).start()
496
+ try:
497
+ srv.serve_forever()
498
+ except KeyboardInterrupt:
499
+ pass
500
+
501
+
502
+ if __name__ == "__main__":
503
+ main()