screengraft 0.55.0 → 0.56.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/package.json +1 -1
- package/scripts/launch.sh +4 -2
- package/scripts/ui.py +82 -10
- package/skills/inject-screenshot/SKILL.md +3 -3
- package/ui/index.html +11 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "screengraft",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.56.1",
|
|
4
4
|
"description": "Put a UI screenshot or screen recording onto a photographed device screen with the perspective exactly right \u2014 a homography you confirm by hand, not a generative guess.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"mockup",
|
package/scripts/launch.sh
CHANGED
|
@@ -73,13 +73,15 @@ if [ ! -s "$LOG" ]; then
|
|
|
73
73
|
exit 1
|
|
74
74
|
fi
|
|
75
75
|
URL=$(python3 -c "import json,sys; print(json.load(open(sys.argv[1]))['url'])" "$LOG" 2>/dev/null || true)
|
|
76
|
-
|
|
76
|
+
BASE=$(python3 -c "import json,sys; print(json.load(open(sys.argv[1]))['base'])" "$LOG" 2>/dev/null || true)
|
|
77
|
+
if [ -z "$URL" ] || [ -z "$BASE" ]; then
|
|
77
78
|
echo "error: couldn't parse url from $LOG:" >&2
|
|
78
79
|
cat "$LOG" >&2
|
|
79
80
|
exit 1
|
|
80
81
|
fi
|
|
81
82
|
sleep 1
|
|
82
|
-
|
|
83
|
+
# /api/ping is the one route that needs no token: it answers liveness only.
|
|
84
|
+
if ! curl -sf -o /dev/null "${BASE}api/ping"; then
|
|
83
85
|
echo "error: server not responding at $URL after launch. Log: $LOG" >&2
|
|
84
86
|
cat "$LOG" >&2
|
|
85
87
|
exit 1
|
package/scripts/ui.py
CHANGED
|
@@ -31,6 +31,7 @@ import atexit
|
|
|
31
31
|
import json
|
|
32
32
|
import mimetypes
|
|
33
33
|
import os
|
|
34
|
+
import secrets
|
|
34
35
|
import signal
|
|
35
36
|
import socket
|
|
36
37
|
import subprocess
|
|
@@ -65,6 +66,13 @@ UI_HTML = os.path.join(ROOT, "ui", "index.html")
|
|
|
65
66
|
# to find the session, and checks the pid so a pointer left by a crashed UI is
|
|
66
67
|
# treated as no UI at all rather than one that never answers.
|
|
67
68
|
CURRENT = os.path.join(HOME, ".screengraft", "current.json")
|
|
69
|
+
# The session token. Minted once per launch in main(); the page gets it in its
|
|
70
|
+
# URL (`/?t=<token>`) and echoes it on every request. Without it the server
|
|
71
|
+
# binds 127.0.0.1 but authenticates nothing: a page the person happens to have
|
|
72
|
+
# open in the same browser can sweep localhost ports and fire blind POSTs --
|
|
73
|
+
# not readable back (same-origin), but delivered, and one of them enqueues a
|
|
74
|
+
# job that an agent then acts on. See _authorised() for the two checks.
|
|
75
|
+
TOKEN = None
|
|
68
76
|
|
|
69
77
|
|
|
70
78
|
def _write_json_atomic(path, obj):
|
|
@@ -672,9 +680,10 @@ def _grain_gain(b) -> float:
|
|
|
672
680
|
"""How much of the surround's noise floor the screen carries.
|
|
673
681
|
|
|
674
682
|
The page does not set this; fresh renders get grade.SCREEN_GRAIN_GAIN. It
|
|
675
|
-
is read from the body so a
|
|
676
|
-
|
|
677
|
-
|
|
683
|
+
is read from the body so a caller that does send one (a future control, a
|
|
684
|
+
test) is honoured. Absent means the shipped gain, NOT 1.0: the API is for
|
|
685
|
+
new renders. Replaying an old sidecar is done through compose() directly,
|
|
686
|
+
where the parameter's own default (1.0) reproduces the old bytes.
|
|
678
687
|
"""
|
|
679
688
|
try:
|
|
680
689
|
g = float(b.get("grain_gain")) if b.get("grain_gain") is not None \
|
|
@@ -819,6 +828,44 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
819
828
|
pass
|
|
820
829
|
|
|
821
830
|
# ---- helpers ----
|
|
831
|
+
def _authorised(self, q) -> bool:
|
|
832
|
+
"""Two checks, both cheap, closing two different doors.
|
|
833
|
+
|
|
834
|
+
1. `Sec-Fetch-Site`: a browser stamps every request with where it came
|
|
835
|
+
from. `same-origin` is our own page, `none` is the address bar or a
|
|
836
|
+
bookmark. Anything else -- `cross-site`, `same-site` -- is another
|
|
837
|
+
page on another origin driving this server, and is refused whether
|
|
838
|
+
or not it somehow holds the token (the URL can end up in a
|
|
839
|
+
screenshot). curl and urllib send no such header and pass this
|
|
840
|
+
check; that is what the token is for.
|
|
841
|
+
2. The token, as the `X-Screengraft-Token` header (the page's fetches)
|
|
842
|
+
or the `t` query parameter (the page itself, <img>/<video> sources,
|
|
843
|
+
which cannot set headers). Constant-time compare, as a habit.
|
|
844
|
+
"""
|
|
845
|
+
site = self.headers.get("Sec-Fetch-Site")
|
|
846
|
+
if site and site not in ("same-origin", "none"):
|
|
847
|
+
return False
|
|
848
|
+
tok = self.headers.get("X-Screengraft-Token") or (q.get("t") or [""])[0]
|
|
849
|
+
# compare_digest wants ASCII str on both sides; a token is url-safe
|
|
850
|
+
# base64, so anything else is wrong before it is compared.
|
|
851
|
+
if not (tok and TOKEN and tok.isascii()):
|
|
852
|
+
return False
|
|
853
|
+
return secrets.compare_digest(tok, TOKEN)
|
|
854
|
+
|
|
855
|
+
def _refuse(self, u):
|
|
856
|
+
if u.path == "/":
|
|
857
|
+
body = (b"<!doctype html><meta charset=utf-8><title>screengraft</title>"
|
|
858
|
+
b"<p style='font:14px system-ui;margin:2em'>This page needs the link the "
|
|
859
|
+
b"launcher printed — it carries a one-session token. "
|
|
860
|
+
b"Relaunch with <code>scripts/launch.sh</code> and open the URL it prints.")
|
|
861
|
+
self.send_response(403)
|
|
862
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
863
|
+
self.send_header("Content-Length", str(len(body)))
|
|
864
|
+
self.end_headers()
|
|
865
|
+
self.wfile.write(body)
|
|
866
|
+
return
|
|
867
|
+
self._json({"error": "missing or wrong session token"}, 403)
|
|
868
|
+
|
|
822
869
|
def _json(self, obj, code=200):
|
|
823
870
|
body = json.dumps(obj).encode()
|
|
824
871
|
self.send_response(code)
|
|
@@ -893,6 +940,13 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
893
940
|
def do_GET(self):
|
|
894
941
|
u = urllib.parse.urlparse(self.path)
|
|
895
942
|
q = urllib.parse.parse_qs(u.query)
|
|
943
|
+
# The one open route: liveness. Says nothing but that a screengraft is
|
|
944
|
+
# here and which version -- launch.sh and the skill probe it before
|
|
945
|
+
# telling anyone the UI is ready, with no token in hand.
|
|
946
|
+
if u.path == "/api/ping":
|
|
947
|
+
return self._json({"ok": True, "version": VERSION})
|
|
948
|
+
if not self._authorised(q):
|
|
949
|
+
return self._refuse(u)
|
|
896
950
|
try:
|
|
897
951
|
if u.path == "/":
|
|
898
952
|
return self._file(UI_HTML, "text/html; charset=utf-8")
|
|
@@ -950,6 +1004,20 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
950
1004
|
# ---- POST ----
|
|
951
1005
|
def do_POST(self):
|
|
952
1006
|
u = urllib.parse.urlparse(self.path)
|
|
1007
|
+
if not self._authorised(urllib.parse.parse_qs(u.query)):
|
|
1008
|
+
# Read the body off the wire first (bounded): refusing while the
|
|
1009
|
+
# client is still sending turns a clean 403 into a broken pipe on
|
|
1010
|
+
# its side, which reads as "the server died", not "you were refused".
|
|
1011
|
+
try:
|
|
1012
|
+
n = min(int(self.headers.get("Content-Length") or 0), 64 << 20)
|
|
1013
|
+
except ValueError:
|
|
1014
|
+
n = 0
|
|
1015
|
+
while n > 0:
|
|
1016
|
+
chunk = self.rfile.read(min(n, 1 << 20))
|
|
1017
|
+
if not chunk: # client gone; nothing left to drain
|
|
1018
|
+
break
|
|
1019
|
+
n -= len(chunk)
|
|
1020
|
+
return self._refuse(u)
|
|
953
1021
|
try:
|
|
954
1022
|
if u.path == "/api/upload":
|
|
955
1023
|
# raw bytes + X-Filename + X-Role (photo|screenshot); no multipart, no cgi module.
|
|
@@ -1473,7 +1541,7 @@ def _publish_current(payload):
|
|
|
1473
1541
|
|
|
1474
1542
|
|
|
1475
1543
|
def main():
|
|
1476
|
-
global SESSION, OUT_DIR, VERSION, BUILD
|
|
1544
|
+
global SESSION, OUT_DIR, VERSION, BUILD, TOKEN
|
|
1477
1545
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
1478
1546
|
ap.add_argument("--port", type=int, default=0, help="0 = pick a free port")
|
|
1479
1547
|
ap.add_argument("--no-open", action="store_true", help="Don't open the browser")
|
|
@@ -1498,7 +1566,11 @@ def main():
|
|
|
1498
1566
|
sdir = args.session or os.path.join(HOME, ".screengraft", "sessions", time.strftime("%Y%m%d-%H%M%S"))
|
|
1499
1567
|
SESSION = Session(sdir)
|
|
1500
1568
|
port = args.port or free_port()
|
|
1501
|
-
|
|
1569
|
+
TOKEN = secrets.token_urlsafe(18)
|
|
1570
|
+
base = f"http://127.0.0.1:{port}/"
|
|
1571
|
+
# `url` is what a person opens; `base` + the token header is what a tool
|
|
1572
|
+
# uses. Kept apart so nobody has to strip a query string off a URL.
|
|
1573
|
+
url = f"{base}?t={TOKEN}"
|
|
1502
1574
|
srv = ThreadingHTTPServer(("127.0.0.1", port), Handler)
|
|
1503
1575
|
freed = _prune_sessions(sdir)
|
|
1504
1576
|
# The live session's own copies go when this process does. Registered before
|
|
@@ -1506,11 +1578,11 @@ def main():
|
|
|
1506
1578
|
# scripts/stop.sh sends SIGTERM for exactly this reason. A kill -9 cannot be
|
|
1507
1579
|
# caught, which is what the launch-time sweep above is for.
|
|
1508
1580
|
atexit.register(lambda: _sweep_session(sdir))
|
|
1509
|
-
_publish_current({"session": sdir, "url": url, "
|
|
1510
|
-
"out_dir": OUT_DIR, "started": time.time()})
|
|
1511
|
-
print(json.dumps({"url": url, "
|
|
1512
|
-
"
|
|
1513
|
-
"reclaimed_mb": round(freed / 1e6, 1)}), flush=True)
|
|
1581
|
+
_publish_current({"session": sdir, "url": url, "base": base, "token": TOKEN,
|
|
1582
|
+
"pid": os.getpid(), "out_dir": OUT_DIR, "started": time.time()})
|
|
1583
|
+
print(json.dumps({"url": url, "base": base, "token": TOKEN, "session": sdir,
|
|
1584
|
+
"job": SESSION.job_path, "result": SESSION.result_path,
|
|
1585
|
+
"out_dir": OUT_DIR, "reclaimed_mb": round(freed / 1e6, 1)}), flush=True)
|
|
1514
1586
|
if not args.no_open:
|
|
1515
1587
|
opener = "open" if sys.platform == "darwin" else "xdg-open"
|
|
1516
1588
|
threading.Timer(0.3, lambda: subprocess.Popen([opener, url])).start()
|
|
@@ -5,7 +5,7 @@ description: Injects a UI screenshot OR a screen recording onto a photographed d
|
|
|
5
5
|
|
|
6
6
|
# Inject a screenshot onto a photographed device
|
|
7
7
|
|
|
8
|
-
**What ships (v0.
|
|
8
|
+
**What ships (v0.56):** a local browser UI (`scripts/ui.py`) that walks the designer through the whole job — pick the photo and the screen source, which may be an image **or a video** (recent Desktop/Downloads images, drag-drop, browse, path, or a **Figma frame link**), auto-detect the screen as a starting position — and when detection cannot tell which region is a screen, **Point at screen**: one click inside it and the detector uses that point — or, if this photograph has been fitted before, **the fit it was saved with comes back** as the starting position instead of a detection, recognised by the photo's own pixels so a rename or a drag-drop still match — and every save also writes a **portable `.fit.json` beside the mockup** that can be dropped back onto the page later, which is how a fit survives a re-export, another machine, or someone else's hands — then **match the four edges** (drag an edge's middle to slide it, near an end to pivot; corners still draggable) with canvas navigation that follows the usual conventions — **hold ⌘ and scroll to zoom to the pointer, hold space and drag to pan** — and a rectified strip loupe. The fit and the composite sit **side by side and always have** — the result pane re-renders as you drag, which is how a corner gets judged, so it is the layout rather than a mode you can switch off. Then an on-by-default realism pass that colour-matches the source to the photo's light, **Save** (or **Render**, for a video) into the project folder (`--out-dir`), and a **Send to Claude** button that reaches you through the plugin's own MCP server. The UI is a hand port of the project's Figma design file — dark only.
|
|
9
9
|
|
|
10
10
|
The geometry is exact (`warp.py`); the detection is advisory (`detect.py`) and the human corrects it. **When a detection is wrong and you want to know why**, ask for the candidate list: `POST /api/detect {"trace": true}` writes `<session>/candidates.json`, or run `python3 scripts/detect.py --photo P --out-corners /tmp/c.json --trace /tmp/t.json` (add `--click X,Y`). Every candidate quad is in there with its score and whether it was accepted, rejected, never reached, or filtered out by the click — which is what separates "the screen was never proposed" from "it was proposed and something else won".
|
|
11
11
|
|
|
@@ -60,9 +60,9 @@ scripts/launch.sh --out-dir "<the user's current project folder>/mockups"
|
|
|
60
60
|
|
|
61
61
|
A Terminal.app window was the previous fix for the same problem. It worked but left a dead "[Process completed]" window behind after every session, and closing those from AppleScript proved unreliable — Terminal reports stale ttys for dead windows and leaves zero-tab husks that `close` reports success on. The daemon removes the window entirely, so there is nothing to clean up.
|
|
62
62
|
|
|
63
|
-
It prints one JSON line — `url`, `session`, `job`, `result`, `out_dir` — and opens the browser tab itself. If it exits non-zero, read the error and the log path it names before telling the user anything is ready.
|
|
63
|
+
It prints one JSON line — `url`, `base`, `token`, `session`, `job`, `result`, `out_dir` — and opens the browser tab itself. `url` carries a one-session token (`/?t=…`); the server refuses any request without it, so always hand the user `url`, never `base`. If it exits non-zero, read the error and the log path it names before telling the user anything is ready.
|
|
64
64
|
|
|
65
|
-
**Verify liveness again right before telling the user to interact with it** — `curl -sf <
|
|
65
|
+
**Verify liveness again right before telling the user to interact with it** — `curl -sf <base>api/ping` (the one route that needs no token) — especially if any time has passed since launch. If that fails, the server died; say so, relaunch, and have the user redo their last action rather than assuming it's still there.
|
|
66
66
|
|
|
67
67
|
**Then explain the job in chat.** The page deliberately carries no onboarding — the explanation belongs here, where the user already is. Say this, in your own words but keeping all of it:
|
|
68
68
|
|
package/ui/index.html
CHANGED
|
@@ -1444,11 +1444,18 @@ function toast(kind, html, opts = {}) {
|
|
|
1444
1444
|
return item;
|
|
1445
1445
|
}
|
|
1446
1446
|
|
|
1447
|
+
// The session token rides in the page's own URL (`/?t=...`, minted per launch)
|
|
1448
|
+
// and goes back on every request: as a header from fetch, as a query
|
|
1449
|
+
// parameter on <img>/<video> sources, which cannot set headers. The server
|
|
1450
|
+
// refuses anything without it, and anything cross-site with it.
|
|
1451
|
+
const TOKEN = new URLSearchParams(location.search).get('t') || '';
|
|
1452
|
+
const authFetch = (p, init = {}) =>
|
|
1453
|
+
fetch(p, {...init, headers: {...(init.headers || {}), 'X-Screengraft-Token': TOKEN}});
|
|
1447
1454
|
const api = async (p, body, raw) => {
|
|
1448
|
-
const r = await
|
|
1455
|
+
const r = await authFetch(p, body===undefined ? {} : {method:'POST', headers: raw ? raw.headers : {'Content-Type':'application/json'}, body: raw ? raw.body : JSON.stringify(body)});
|
|
1449
1456
|
const j = await r.json(); if (!r.ok) throw new Error(j.error || r.statusText); return j;
|
|
1450
1457
|
};
|
|
1451
|
-
const fileURL = p => '/file?path=' + encodeURIComponent(p);
|
|
1458
|
+
const fileURL = p => '/file?t=' + encodeURIComponent(TOKEN) + '&path=' + encodeURIComponent(p);
|
|
1452
1459
|
// A source's URL carries the file's mtime, so new bytes at an old path are
|
|
1453
1460
|
// fetched rather than served from the browser's cache (see _adopt in ui.py).
|
|
1454
1461
|
const srcURL = (p, v) => fileURL(p) + (v ? '&v=' + v : '');
|
|
@@ -3291,7 +3298,7 @@ async function playClip(){
|
|
|
3291
3298
|
// that FINISHED left the button on "Rendering…" for ever, with no toast and
|
|
3292
3299
|
// Send to Claude never enabled. It looks exactly like a hung encode, which
|
|
3293
3300
|
// is why it was reported as "no progress" rather than as a broken poll.
|
|
3294
|
-
let d; try { d = await (await
|
|
3301
|
+
let d; try { d = await (await authFetch('/api/render_status')).json(); }
|
|
3295
3302
|
catch(e){ return; }
|
|
3296
3303
|
if (d.state === 'running'){
|
|
3297
3304
|
// The wait is real — tens of seconds on a long clip — so it counts frames
|
|
@@ -3481,7 +3488,7 @@ async function renderVideo(){
|
|
|
3481
3488
|
// that FINISHED left the button on "Rendering…" for ever, with no toast and
|
|
3482
3489
|
// Send to Claude never enabled. It looks exactly like a hung encode, which
|
|
3483
3490
|
// is why it was reported as "no progress" rather than as a broken poll.
|
|
3484
|
-
let d; try { d = await (await
|
|
3491
|
+
let d; try { d = await (await authFetch('/api/render_status')).json(); }
|
|
3485
3492
|
catch(e){ return; }
|
|
3486
3493
|
if (d.state === 'running'){
|
|
3487
3494
|
// The button IS the progress bar now. A spinner cannot be told apart
|