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/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
|
+

|
|
6
|
+
|
|
7
|
+

|
|
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
|
+
}
|