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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dariusz Fraczyk
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,127 @@
1
+ # screengraft
2
+
3
+ **Put a UI screenshot onto a photographed screen so the perspective is exactly right.**
4
+
5
+ ![A Figma screen composited onto a photographed iPhone](docs/hero.png)
6
+
7
+ ![The fitting workbench: photo with the screen quad on the left, live composite on the right, and a magnified strip across the edge below](docs/social-preview.png)
8
+
9
+ Every device mockup is a compromise. Templates give you three angles and someone
10
+ else's lighting. Generative tools give you a screen that looks *like* your design
11
+ without being it — text reflowed, a button moved, a logo subtly wrong.
12
+
13
+ screengraft takes your photograph and your screenshot and computes the projective
14
+ transform between them. The screenshot lands on the glass because the geometry
15
+ says it must, not because a model thought it looked about right. Same inputs,
16
+ same output, every time.
17
+
18
+ ---
19
+
20
+ ## What it does
21
+
22
+ - **Any angle.** A homography handles arbitrary perspective — a phone leaning on
23
+ a wall, a laptop half-turned, a tablet held at 40°.
24
+ - **Your pixels, unaltered.** The screenshot is resampled once and warped once.
25
+ 9px legal copy stays legible.
26
+ - **Rounded corners that actually follow the bezel**, measured from the photo or
27
+ taken from a device preset.
28
+ - **Realism pass** *(optional)* — matches the screen's white balance and grain to
29
+ the light in the room, and can lift the device's real reflections from a
30
+ screen-off frame of the same shot.
31
+ - **You confirm every fit.** Detection is advisory and says so; you drag the four
32
+ edges onto the glass with a magnified loupe. A silent misdetection producing a
33
+ confident, wrong result is the one failure this tool refuses to have.
34
+
35
+ ## Requirements
36
+
37
+ `python3` with **OpenCV** and **numpy**. OpenCV is the engine — nothing runs
38
+ without it. The installer provisions an isolated venv at `~/.screengraft/venv`
39
+ and never touches your system Python.
40
+
41
+ ## Install as a Claude Code / Cowork plugin
42
+
43
+ ```
44
+ /plugin marketplace add seq000/screengraft
45
+ /plugin install screengraft@fraczyk-tools
46
+ ```
47
+
48
+ That route tracks versions and updates itself. If you would rather not add a
49
+ marketplace, download `screengraft-<version>.plugin` from
50
+ [the latest release](https://github.com/seq000/screengraft/releases/latest) and
51
+ open it, or clone this repo and point Claude Code at the folder.
52
+
53
+ Then ask Claude to inject a screenshot onto a photo. It opens a local page in
54
+ your browser, you fit the edges, press Save, and the composite lands in your
55
+ project folder. Nothing is uploaded anywhere; the page is served from
56
+ `127.0.0.1`.
57
+
58
+ ## Or use it without Claude
59
+
60
+ ```bash
61
+ npx screengraft --out-dir ./mockups
62
+ ```
63
+
64
+ npm is a delivery mechanism here, not a claim about the language: the tool is
65
+ Python and OpenCV, and `bin/screengraft.js` is a launcher. It installs nothing
66
+ behind your back — if the engine is missing it prints the one command that
67
+ builds it (`npx screengraft --install`) and exits.
68
+
69
+ From a clone:
70
+
71
+ ```bash
72
+ python3 scripts/preflight.py --install # one-time: creates the venv
73
+ python3 scripts/ui.py --out-dir ./mockups # opens the fitting page
74
+ ```
75
+
76
+ Headless, if you already know the corners:
77
+
78
+ ```bash
79
+ python3 scripts/warp.py --photo shot.jpg --screenshot ui.png \
80
+ --corners "945,504 1310,475 1501,1408 1135,1459" \
81
+ --radius-frac 0.14 --out composite.png
82
+ ```
83
+
84
+ Corners are `TL TR BR BL` in photo pixels.
85
+
86
+ ## Why not just use AI
87
+
88
+ A diffusion model cannot guarantee the screenshot lands on the screen's four
89
+ corners, because nothing in it is solving for that. A projective transform can,
90
+ by construction — it is the same maths a document scanner uses to flatten a page.
91
+ So the pipeline is computer vision and projective geometry end to end, and it is
92
+ deterministic: re-run it and you get a byte-identical file.
93
+
94
+ Generative AI has exactly one optional job in the design, and it is strictly
95
+ outside the screen mask. It never touches the pixels you designed.
96
+
97
+ ## How it works
98
+
99
+ 1. **Corner acquisition** — an advisory detector proposes a quad; you correct it
100
+ by dragging *edges* (a rounded corner has no point to aim at; the straight
101
+ edges either side are unambiguous), with a rectified strip loupe at ~5×.
102
+ 2. **Warp** — the screenshot is area-averaged down to its destination footprint,
103
+ then warped once at the photo's resolution. Prefiltering matters: OpenCV's
104
+ warp never area-averages, so warping a 1206×2622 screenshot into a 226×454
105
+ quad without it turns body text into noise.
106
+ 3. **Realism pass** *(optional)* — white balance and exposure toward the
107
+ surrounding light, grain matched to the photo's own noise floor, real
108
+ speculars lifted from a screen-off reference.
109
+
110
+ ## Roadmap
111
+
112
+ Done: manual warp, advisory detectors, the fitting workbench, the realism pass.
113
+
114
+ Open: video tracking (M3), SAM 2 auto-detect (M4 — built and measured in a
115
+ separate repo; it currently segments the phone body rather than the glass, so it
116
+ is not shipped), occluder matte (M5), so a finger in front of the screen stays in
117
+ front.
118
+
119
+ ## Contributing
120
+
121
+ See [CONTRIBUTING.md](CONTRIBUTING.md). The short version: there are tests, they
122
+ run in CI, and a change to the compositing engine needs a measurement, not an
123
+ opinion.
124
+
125
+ ## Licence
126
+
127
+ MIT. The bundled Mona Sans subset is SIL OFL — see `ui/fonts/OFL.txt`.
@@ -0,0 +1,82 @@
1
+ #!/usr/bin/env node
2
+ /*
3
+ * screengraft — launcher.
4
+ *
5
+ * This package is a delivery mechanism, not a JavaScript library. The tool is
6
+ * Python and OpenCV; npm is here because `npx screengraft` is the shortest path
7
+ * from "I have a photo" to a fitting page, with nothing installed permanently.
8
+ * It says so out loud rather than pretending otherwise, and it never installs
9
+ * anything behind your back: if the Python side is missing you get the exact
10
+ * command to run, and a non-zero exit.
11
+ */
12
+ 'use strict';
13
+
14
+ const { spawn, spawnSync } = require('node:child_process');
15
+ const path = require('node:path');
16
+ const process = require('node:process');
17
+
18
+ const ROOT = path.join(__dirname, '..');
19
+ const PREFLIGHT = path.join(ROOT, 'scripts', 'preflight.py');
20
+ const UI = path.join(ROOT, 'scripts', 'ui.py');
21
+
22
+ const argv = process.argv.slice(2);
23
+ if (argv.includes('-h') || argv.includes('--help')) {
24
+ process.stdout.write(
25
+ 'screengraft — put a UI screenshot onto a photographed device screen.\n\n' +
26
+ 'Usage: npx screengraft [--out-dir DIR] [--port N] [--no-open]\n\n' +
27
+ ' --out-dir DIR where Save writes (default ~/Desktop/screengraft)\n' +
28
+ ' --port N 0 picks a free port (default)\n' +
29
+ ' --no-open do not open a browser tab\n' +
30
+ ' --install build the Python venv this needs, then exit\n\n' +
31
+ 'Needs python3 with OpenCV and numpy. The first run offers to build an\n' +
32
+ 'isolated venv at ~/.screengraft/venv (~60 MB); your system Python is left\n' +
33
+ 'alone. Docs: https://github.com/seq000/screengraft\n');
34
+ process.exit(0);
35
+ }
36
+
37
+ function python() {
38
+ for (const exe of ['python3', 'python']) {
39
+ const r = spawnSync(exe, ['--version'], { stdio: 'ignore' });
40
+ if (!r.error && r.status === 0) return exe;
41
+ }
42
+ return null;
43
+ }
44
+
45
+ const py = python();
46
+ if (!py) {
47
+ process.stderr.write(
48
+ 'screengraft needs python3, and none was found on PATH.\n' +
49
+ 'Install Python 3.10 or newer, then run this again.\n');
50
+ process.exit(1);
51
+ }
52
+
53
+ if (argv.includes('--install')) {
54
+ process.exit(spawnSync(py, [PREFLIGHT, '--install'], { stdio: 'inherit' }).status ?? 1);
55
+ }
56
+
57
+ // Preflight reports which interpreter actually has OpenCV — the venv's, usually,
58
+ // not the one running this check.
59
+ const pre = spawnSync(py, [PREFLIGHT], { encoding: 'utf8' });
60
+ let report;
61
+ try {
62
+ report = JSON.parse(pre.stdout);
63
+ } catch {
64
+ process.stderr.write('screengraft: preflight did not report cleanly.\n' + (pre.stderr || pre.stdout || ''));
65
+ process.exit(1);
66
+ }
67
+
68
+ if (!report.ready) {
69
+ process.stderr.write(
70
+ 'screengraft needs OpenCV, and it is not installed.\n\n' +
71
+ 'OpenCV is the engine here, not an enhancement: without it there is no\n' +
72
+ 'degraded mode, there is no mode. Missing: ' + (report.missing || []).join(', ') + '\n\n' +
73
+ 'To build an isolated venv at ~/.screengraft/venv (about 60 MB, and it\n' +
74
+ 'touches nothing else on your machine):\n\n' +
75
+ ' npx screengraft --install\n\n');
76
+ process.exit(1);
77
+ }
78
+
79
+ // report.python is the interpreter that has the engine.
80
+ const child = spawn(report.python, [UI, ...argv], { stdio: 'inherit' });
81
+ child.on('exit', (code, signal) => process.exit(signal ? 1 : (code ?? 0)));
82
+ for (const sig of ['SIGINT', 'SIGTERM']) process.on(sig, () => child.kill(sig));
package/mcp/server.py ADDED
@@ -0,0 +1,322 @@
1
+ #!/usr/bin/env python3
2
+ """screengraft MCP server — lets the browser UI reach the agent.
3
+
4
+ WHY THIS EXISTS
5
+ ---------------
6
+ The UI is a local web page. When the designer pastes a Figma link or presses
7
+ "Import to Claude", something has to happen *in the agent*. The agent cannot
8
+ poll: it runs in turns, and between turns nothing of it is executing. The old
9
+ SKILL.md told it to "poll job.json every few seconds", which was never
10
+ achievable, and the page told the user "(it watches this job)", which was
11
+ false. The user sat waiting for an agent that was not running.
12
+
13
+ The fix is to invert it. The agent cannot poll, but it CAN block: a tool call
14
+ may take as long as the host allows (measured 4 Sep 2026: 94s returns cleanly;
15
+ the bash cap on the same host is 600s). So the page gets an outbox and the
16
+ agent gets a blocking inbox. `wait_for_job` parks until the user presses a
17
+ button and returns within ~150ms of the press.
18
+
19
+ DESIGN CONSTRAINTS
20
+ ------------------
21
+ 1. **Stdlib only, system python3.** A plugin's MCP server starts when the
22
+ plugin is enabled — which is before preflight has necessarily built
23
+ ~/.screengraft/venv. Importing cv2, numpy or the `mcp` package here would
24
+ make the server fail to load on a fresh install, and the tools would simply
25
+ be missing with no good error. So: no third-party imports in this file.
26
+ 2. **Threaded dispatch.** Blocking inside the stdin read loop would stall the
27
+ host's own traffic (ping, tools/list) for the length of the wait, and a host
28
+ that gets no reply may treat the server as dead. Each request is handled on
29
+ its own thread; stdout writes are serialised by a lock.
30
+ 3. **No state of its own.** The session dir on disk is the only truth. The
31
+ server can be restarted mid-job and lose nothing.
32
+ """
33
+
34
+ import json
35
+ import os
36
+ import sys
37
+ import threading
38
+ import time
39
+
40
+ HOME = os.path.expanduser("~")
41
+ ROOT = os.path.join(HOME, ".screengraft")
42
+ CURRENT = os.path.join(ROOT, "current.json")
43
+
44
+ POLL_S = 0.15 # how often the wait loop looks at the outbox
45
+ DEFAULT_TIMEOUT = 90 # measured safe; also bounds how long a chat message waits
46
+ # The host kills an MCP tool call well before the shell tool's own cap. Measured
47
+ # 4 Sep 2026: a bash call blocked 94s cleanly and the bash cap is 600s, so 300
48
+ # looked safe here -- but a real wait_for_job(300) was killed by the host at
49
+ # exactly 180s. The orphaned call loses no data (an unanswered job stays pending
50
+ # and the next wait picks it up) but the agent misses the wake it was parked
51
+ # for, which is the one thing this server exists to deliver. So: stay clearly
52
+ # under the observed ceiling rather than at it, and clamp rather than trust the
53
+ # caller, because the caller is an agent reading a description.
54
+ HOST_CALL_CEILING = 180
55
+ MAX_TIMEOUT = 150
56
+
57
+ _out_lock = threading.Lock()
58
+
59
+
60
+ # ---------------------------------------------------------------- transport
61
+
62
+ def _send(msg):
63
+ """One JSON-RPC message per line on stdout, serialised across threads."""
64
+ with _out_lock:
65
+ sys.stdout.write(json.dumps(msg) + "\n")
66
+ sys.stdout.flush()
67
+
68
+
69
+ def _result(req_id, payload):
70
+ _send({"jsonrpc": "2.0", "id": req_id, "result": payload})
71
+
72
+
73
+ def _error(req_id, code, message):
74
+ _send({"jsonrpc": "2.0", "id": req_id, "error": {"code": code, "message": message}})
75
+
76
+
77
+ def _text(payload):
78
+ """MCP tool results are content blocks; we always return one JSON block."""
79
+ return {"content": [{"type": "text", "text": json.dumps(payload, indent=1)}]}
80
+
81
+
82
+ # ------------------------------------------------------------------ session
83
+
84
+ def _read_json(path):
85
+ """Tolerant read: a torn file is treated as absent, not as an error.
86
+
87
+ ui.py writes atomically (tmp + os.replace) so this shouldn't happen, but a
88
+ half-read here would surface as a crashed tool call rather than a retry,
89
+ and the retry is free — we're in a poll loop.
90
+ """
91
+ try:
92
+ with open(path) as f:
93
+ return json.load(f)
94
+ except (OSError, ValueError):
95
+ return None
96
+
97
+
98
+ def _pid_alive(pid):
99
+ try:
100
+ os.kill(int(pid), 0)
101
+ except (OSError, TypeError, ValueError):
102
+ return False
103
+ return True
104
+
105
+
106
+ def current_session():
107
+ """The UI instance the agent should be talking to, or None.
108
+
109
+ A pointer file left behind by a crashed UI is worse than no pointer: the
110
+ agent would wait out a full timeout against a session nobody is looking at.
111
+ So the pid is checked on every read and a dead pointer is reported as gone.
112
+ """
113
+ cur = _read_json(CURRENT)
114
+ if not cur:
115
+ return None
116
+ if not _pid_alive(cur.get("pid")):
117
+ return None
118
+ if not os.path.isdir(cur.get("session") or ""):
119
+ return None
120
+ return cur
121
+
122
+
123
+ # -------------------------------------------------------------------- tools
124
+
125
+ TOOLS = [
126
+ {
127
+ "name": "wait_for_job",
128
+ "description": (
129
+ "Block until the screengraft UI asks the agent to do something, then return "
130
+ "that request. Call this immediately after launching the UI and again after "
131
+ "each completed job, so a button press is picked up without the user having "
132
+ "to type anything in chat.\n\n"
133
+ "Returns one of:\n"
134
+ " status=job — a request to handle. `job.type` is 'figma_export' "
135
+ "(export job.url via the Figma MCP at 3x PNG, save to job.save_to, then call "
136
+ "complete_job) or 'present' (show job.paths to the user with present_files, "
137
+ "then call complete_job).\n"
138
+ " status=timeout — nothing happened within timeout_s. The UI is still up; "
139
+ "call again to keep waiting.\n"
140
+ " status=ui_closed — the UI exited. Stop waiting and tell the user.\n"
141
+ " status=no_ui — no UI is running. Launch it first.\n\n"
142
+ "Blocking is the point: the agent cannot poll between turns, so this call is "
143
+ "how a local web page reaches it."
144
+ ),
145
+ "inputSchema": {
146
+ "type": "object",
147
+ "properties": {
148
+ "timeout_s": {
149
+ "type": "number",
150
+ "description": f"Seconds to block before returning status=timeout "
151
+ f"(default {DEFAULT_TIMEOUT}, max {MAX_TIMEOUT} \u2014 the host kills a tool call at "
152
+ f"~{HOST_CALL_CEILING}s, so anything larger is clamped). Shorter "
153
+ f"timeouts return control sooner so the user's own chat "
154
+ f"messages are not delayed behind the wait.",
155
+ }
156
+ },
157
+ },
158
+ },
159
+ {
160
+ "name": "complete_job",
161
+ "description": (
162
+ "Report the outcome of the job most recently returned by wait_for_job. The page "
163
+ "is polling for this and will not move on until it arrives — always call it, "
164
+ "including on failure, or the user is left watching a spinner."
165
+ ),
166
+ "inputSchema": {
167
+ "type": "object",
168
+ "properties": {
169
+ "status": {"type": "string", "enum": ["done", "error"]},
170
+ "path": {"type": "string", "description": "For figma_export: the saved PNG."},
171
+ "message": {"type": "string", "description": "For status=error: what went wrong, in a sentence the designer can act on."},
172
+ "job_id": {"type": "string",
173
+ "description": "The `id` from the job wait_for_job returned. Pass it: it "
174
+ "stops a late completion from landing on a different job "
175
+ "the user has since started."},
176
+ },
177
+ "required": ["status"],
178
+ },
179
+ },
180
+ ]
181
+
182
+
183
+ def tool_wait_for_job(args):
184
+ timeout = args.get("timeout_s")
185
+ try:
186
+ timeout = float(timeout) if timeout is not None else DEFAULT_TIMEOUT
187
+ except (TypeError, ValueError):
188
+ timeout = DEFAULT_TIMEOUT
189
+ timeout = max(1.0, min(timeout, MAX_TIMEOUT))
190
+
191
+ cur = current_session()
192
+ if not cur:
193
+ return {"status": "no_ui",
194
+ "hint": "No screengraft UI is running. Launch it with scripts/launch.sh."}
195
+
196
+ job_path = os.path.join(cur["session"], "job.json")
197
+ deadline = time.time() + timeout
198
+ while True:
199
+ job = _read_json(job_path)
200
+ if job and job.get("status") == "pending":
201
+ return {"status": "job", "job": job, "session": cur["session"],
202
+ "out_dir": cur.get("out_dir")}
203
+ if not current_session():
204
+ return {"status": "ui_closed",
205
+ "hint": "The UI exited. Anything not yet saved is gone; say so rather than waiting."}
206
+ if time.time() >= deadline:
207
+ return {"status": "timeout", "waited_s": round(timeout, 1),
208
+ "hint": "Still up, nothing pressed. Call wait_for_job again to keep waiting."}
209
+ time.sleep(POLL_S)
210
+
211
+
212
+ def tool_complete_job(args):
213
+ cur = current_session()
214
+ if not cur:
215
+ return {"ok": False, "error": "no UI running"}
216
+ job_path = os.path.join(cur["session"], "job.json")
217
+ job = _read_json(job_path)
218
+ if not job:
219
+ return {"ok": False, "error": "no job to complete"}
220
+
221
+ status = args.get("status")
222
+ if status not in ("done", "error"):
223
+ return {"ok": False, "error": "status must be 'done' or 'error'"}
224
+
225
+ # Guard against completing something other than what was handed out. The
226
+ # page refuses to enqueue over a pending job, so this only bites when the
227
+ # agent is slow and the user has reloaded and started again — but silently
228
+ # marking the new job done would leave them waiting on a spinner forever.
229
+ want = args.get("job_id")
230
+ if want and str(job.get("id")) != str(want):
231
+ return {"ok": False,
232
+ "error": f"job {want} is no longer current (the session is on {job.get('id')}). "
233
+ f"The user has started something else; do not complete this one."}
234
+ if job.get("status") != "pending":
235
+ return {"ok": False,
236
+ "error": f"this job is already {job.get('status')}, nothing to complete"}
237
+
238
+ job["status"] = status
239
+ job["completed"] = time.time()
240
+ if status == "done":
241
+ path = args.get("path")
242
+ # figma_export is the only job whose completion carries a file; for a
243
+ # 'present' job there is nothing to hand back and path is meaningless.
244
+ if job.get("type") == "figma_export":
245
+ if not path or not os.path.isfile(path):
246
+ return {"ok": False,
247
+ "error": f"figma_export needs `path` to be a file that exists (got {path!r})"}
248
+ job["path"] = path
249
+ else:
250
+ job["message"] = args.get("message") or "The agent could not complete this."
251
+
252
+ tmp = job_path + ".tmp"
253
+ with open(tmp, "w") as f:
254
+ json.dump(job, f, indent=1)
255
+ os.replace(tmp, job_path)
256
+ return {"ok": True, "status": status}
257
+
258
+
259
+ DISPATCH = {"wait_for_job": tool_wait_for_job, "complete_job": tool_complete_job}
260
+
261
+
262
+ # ----------------------------------------------------------------- protocol
263
+
264
+ def handle(req):
265
+ method = req.get("method")
266
+ req_id = req.get("id")
267
+
268
+ # Notifications carry no id and must never be answered.
269
+ if req_id is None:
270
+ return
271
+
272
+ if method == "initialize":
273
+ params = req.get("params") or {}
274
+ # Echo the client's protocol version rather than pinning one: this
275
+ # server uses nothing version-specific, and echoing avoids a mismatch
276
+ # rejection when the host moves forward.
277
+ version = (params.get("protocolVersion")
278
+ if isinstance(params.get("protocolVersion"), str) else "2025-06-18")
279
+ return _result(req_id, {
280
+ "protocolVersion": version,
281
+ "capabilities": {"tools": {}},
282
+ "serverInfo": {"name": "screengraft", "version": "0.7.0"},
283
+ })
284
+
285
+ if method == "ping":
286
+ return _result(req_id, {})
287
+
288
+ if method == "tools/list":
289
+ return _result(req_id, {"tools": TOOLS})
290
+
291
+ if method == "tools/call":
292
+ params = req.get("params") or {}
293
+ name = params.get("name")
294
+ fn = DISPATCH.get(name)
295
+ if not fn:
296
+ return _error(req_id, -32602, f"unknown tool: {name}")
297
+ try:
298
+ return _result(req_id, _text(fn(params.get("arguments") or {})))
299
+ except Exception as e: # noqa: BLE001
300
+ # A tool that raises should report a failed tool call, not kill the
301
+ # server — the UI may still be mid-job.
302
+ return _result(req_id, {**_text({"error": str(e)}), "isError": True})
303
+
304
+ return _error(req_id, -32601, f"method not found: {method}")
305
+
306
+
307
+ def main():
308
+ for line in sys.stdin:
309
+ line = line.strip()
310
+ if not line:
311
+ continue
312
+ try:
313
+ req = json.loads(line)
314
+ except ValueError:
315
+ continue
316
+ # One thread per request so a 90s wait_for_job cannot stall ping or
317
+ # tools/list behind it.
318
+ threading.Thread(target=handle, args=(req,), daemon=True).start()
319
+
320
+
321
+ if __name__ == "__main__":
322
+ main()
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "screengraft",
3
+ "version": "0.13.1",
4
+ "description": "Put a UI screenshot onto a photographed device screen with the perspective exactly right \u2014 a homography you confirm by hand, not a generative guess.",
5
+ "keywords": [
6
+ "mockup",
7
+ "device-frame",
8
+ "homography",
9
+ "perspective",
10
+ "compositing",
11
+ "screenshot",
12
+ "design",
13
+ "portfolio",
14
+ "figma"
15
+ ],
16
+ "homepage": "https://github.com/seq000/screengraft#readme",
17
+ "bugs": "https://github.com/seq000/screengraft/issues",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/seq000/screengraft.git"
21
+ },
22
+ "license": "MIT",
23
+ "author": {
24
+ "name": "Dariusz Fraczyk",
25
+ "url": "https://fraczyk.design"
26
+ },
27
+ "type": "commonjs",
28
+ "bin": {
29
+ "screengraft": "bin/screengraft.js"
30
+ },
31
+ "files": [
32
+ "bin/",
33
+ "scripts/",
34
+ "ui/",
35
+ "mcp/",
36
+ "skills/",
37
+ "README.md",
38
+ "LICENSE",
39
+ "!scripts/build-plugin.sh",
40
+ "!scripts/deadcode.py",
41
+ "!scripts/check_leaks.py",
42
+ "!scripts/check_marketplace.py",
43
+ "!scripts/check_package.py",
44
+ "!scripts/leak-patterns.local",
45
+ "!scripts/contrast_audit.py",
46
+ "!**/__pycache__/**",
47
+ "!**/*.pyc"
48
+ ],
49
+ "engines": {
50
+ "node": ">=18"
51
+ },
52
+ "os": [
53
+ "darwin",
54
+ "linux"
55
+ ],
56
+ "scripts": {
57
+ "start": "node bin/screengraft.js"
58
+ }
59
+ }