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/LICENSE +21 -0
- package/README.md +127 -0
- package/bin/screengraft.js +82 -0
- package/mcp/server.py +322 -0
- package/package.json +59 -0
- package/scripts/detect.py +677 -0
- package/scripts/grade.py +180 -0
- package/scripts/launch.sh +69 -0
- package/scripts/preflight.py +118 -0
- package/scripts/requirements.txt +2 -0
- package/scripts/scan.py +92 -0
- package/scripts/stop.sh +21 -0
- package/scripts/ui.py +503 -0
- package/scripts/warp.py +247 -0
- package/skills/inject-screenshot/SKILL.md +130 -0
- package/ui/fonts/OFL.txt +93 -0
- package/ui/fonts/mona-sans-wordmark.woff2 +0 -0
- package/ui/index.html +1902 -0
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()
|