screengraft 0.54.8 → 0.56.0
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/README.md +4 -2
- package/package.json +1 -1
- package/scripts/dof.py +1 -1
- package/scripts/grade.py +9 -0
- package/scripts/launch.sh +4 -2
- package/scripts/ui.py +96 -12
- package/scripts/warp.py +15 -6
- package/skills/inject-screenshot/SKILL.md +3 -3
- package/ui/index.html +11 -4
package/README.md
CHANGED
|
@@ -138,8 +138,10 @@ outside the screen mask. It never touches the pixels you designed.
|
|
|
138
138
|
warp never area-averages, so warping a 1206×2622 screenshot into a 226×454
|
|
139
139
|
quad without it turns body text into noise.
|
|
140
140
|
3. **Realism pass** *(optional)* — white balance and exposure toward the
|
|
141
|
-
surrounding light, grain
|
|
142
|
-
|
|
141
|
+
surrounding light, grain measured from the photo's own noise floor and
|
|
142
|
+
laid on the screen at 0.7× of it (a lit screen sits in the highlights, where
|
|
143
|
+
8-bit noise is lower than on the body around it), real speculars lifted
|
|
144
|
+
from a screen-off reference.
|
|
143
145
|
4. **Video**, when the source is a clip — everything a fixed photo and a fixed
|
|
144
146
|
quad make constant is computed once, and only the frame changes. Three
|
|
145
147
|
consequences worth naming, because each is a way video normally goes wrong:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "screengraft",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.56.0",
|
|
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/dof.py
CHANGED
|
@@ -281,7 +281,7 @@ def measure(photo: np.ndarray, corners) -> dict:
|
|
|
281
281
|
out.update({"angle": 0.0, "strength": 0.0, "flat": True})
|
|
282
282
|
return out
|
|
283
283
|
Amat = np.column_stack([mids[:, 0], mids[:, 1], np.ones(len(mids))])
|
|
284
|
-
(a, b,
|
|
284
|
+
(a, b, _c), *_ = np.linalg.lstsq(Amat, sig, rcond=None)
|
|
285
285
|
angle = math.degrees(math.atan2(b, a)) % 360.0
|
|
286
286
|
strength = float(np.clip((sig.max() - lo) / max(sigma_max(q, 1.0), 1e-6), 0.0, 1.0))
|
|
287
287
|
out.update({"angle": round(angle, 1), "strength": round(strength, 3), "flat": False})
|
package/scripts/grade.py
CHANGED
|
@@ -131,6 +131,15 @@ def match_light(photo: np.ndarray, warped: np.ndarray, mask: np.ndarray,
|
|
|
131
131
|
|
|
132
132
|
|
|
133
133
|
GRAIN_GATE = 20.0 # grey levels: above this a residual is an edge, not grain
|
|
134
|
+
# How much of the SURROUND's noise floor a lit screen should carry. The sigma is
|
|
135
|
+
# measured on the ring around the screen -- bezel and body, usually the darkest
|
|
136
|
+
# thing near it -- but the injected screen is usually the brightest thing in
|
|
137
|
+
# the frame, and after the sRGB curve a highlight carries less noise in grey
|
|
138
|
+
# levels than a shadow does. Measured on the 38-photo corpus (13 Sep 2026):
|
|
139
|
+
# the 192-255 band's floor is a median 0.67x the 0-63 band's. A designer's
|
|
140
|
+
# eye on a full-resolution save said the same thing first: "a bit smaller".
|
|
141
|
+
# 1.0 is the pre-v0.55 behaviour and is what an old sidecar replays with.
|
|
142
|
+
SCREEN_GRAIN_GAIN = 0.7
|
|
134
143
|
_MEDIAN_HP_GAIN = 0.909 # a 3x3 median high-pass absorbs this much of iid noise
|
|
135
144
|
# (measured, 5 seeds x sigma 1-5, spread < 0.3%)
|
|
136
145
|
|
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
|
|
@@ -50,6 +51,7 @@ import detect as D # noqa: E402
|
|
|
50
51
|
import fitfile as FF # noqa: E402
|
|
51
52
|
import fits as FIT # noqa: E402
|
|
52
53
|
import dof as DOF # noqa: E402
|
|
54
|
+
import grade as _grade # noqa: E402
|
|
53
55
|
import scan as S # noqa: E402
|
|
54
56
|
import warp as W # noqa: E402
|
|
55
57
|
|
|
@@ -64,6 +66,13 @@ UI_HTML = os.path.join(ROOT, "ui", "index.html")
|
|
|
64
66
|
# to find the session, and checks the pid so a pointer left by a crashed UI is
|
|
65
67
|
# treated as no UI at all rather than one that never answers.
|
|
66
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
|
|
67
76
|
|
|
68
77
|
|
|
69
78
|
def _write_json_atomic(path, obj):
|
|
@@ -578,7 +587,8 @@ PREVIEW_SECONDS = 6.0
|
|
|
578
587
|
|
|
579
588
|
def _render_worker(photo, video_path, corners, dest, radius_px, gr, grain, preset, fit_frame,
|
|
580
589
|
blend="replace", reflection=None, result=None, kind="render",
|
|
581
|
-
start_frame=0, max_frames=None, *, smoothing=0.0, dof=None
|
|
590
|
+
start_frame=0, max_frames=None, *, smoothing=0.0, dof=None,
|
|
591
|
+
grain_gain=1.0):
|
|
582
592
|
"""Encode the clip, and only if that SUCCEEDS publish what it produced.
|
|
583
593
|
|
|
584
594
|
`result` is the sidecar this render would write. It is handed to the worker
|
|
@@ -598,7 +608,7 @@ def _render_worker(photo, video_path, corners, dest, radius_px, gr, grain, prese
|
|
|
598
608
|
try:
|
|
599
609
|
info = W.compose_video(photo, video_path, corners, dest,
|
|
600
610
|
corner_radius=radius_px, corner_smoothing=smoothing,
|
|
601
|
-
grade=gr, grain=grain,
|
|
611
|
+
grade=gr, grain=grain, grain_gain=grain_gain,
|
|
602
612
|
preset=preset, fit_frame=fit_frame, progress=progress,
|
|
603
613
|
blend=blend,
|
|
604
614
|
reflection=(W.DEFAULT_REFLECTION if reflection is None
|
|
@@ -666,6 +676,22 @@ def _blend_args(b):
|
|
|
666
676
|
return "emissive", float(max(0.0, min(1.0, float(r))))
|
|
667
677
|
|
|
668
678
|
|
|
679
|
+
def _grain_gain(b) -> float:
|
|
680
|
+
"""How much of the surround's noise floor the screen carries.
|
|
681
|
+
|
|
682
|
+
The page does not set this; fresh renders get grade.SCREEN_GRAIN_GAIN. It
|
|
683
|
+
is read from the body so a sidecar replayed through the API keeps its own
|
|
684
|
+
value -- a sidecar from before the gain existed carries none and passes
|
|
685
|
+
1.0 explicitly at the replay site, never here.
|
|
686
|
+
"""
|
|
687
|
+
try:
|
|
688
|
+
g = float(b.get("grain_gain")) if b.get("grain_gain") is not None \
|
|
689
|
+
else _grade.SCREEN_GRAIN_GAIN
|
|
690
|
+
except (TypeError, ValueError):
|
|
691
|
+
g = _grade.SCREEN_GRAIN_GAIN
|
|
692
|
+
return float(min(max(g, 0.0), 2.0))
|
|
693
|
+
|
|
694
|
+
|
|
669
695
|
def _dof_args(b):
|
|
670
696
|
"""Depth-of-field kwargs from the page, as a dict compose() takes directly.
|
|
671
697
|
|
|
@@ -801,6 +827,40 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
801
827
|
pass
|
|
802
828
|
|
|
803
829
|
# ---- helpers ----
|
|
830
|
+
def _authorised(self, q) -> bool:
|
|
831
|
+
"""Two checks, both cheap, closing two different doors.
|
|
832
|
+
|
|
833
|
+
1. `Sec-Fetch-Site`: a browser stamps every request with where it came
|
|
834
|
+
from. `same-origin` is our own page, `none` is the address bar or a
|
|
835
|
+
bookmark. Anything else -- `cross-site`, `same-site` -- is another
|
|
836
|
+
page on another origin driving this server, and is refused whether
|
|
837
|
+
or not it somehow holds the token (the URL can end up in a
|
|
838
|
+
screenshot). curl and urllib send no such header and pass this
|
|
839
|
+
check; that is what the token is for.
|
|
840
|
+
2. The token, as the `X-Screengraft-Token` header (the page's fetches)
|
|
841
|
+
or the `t` query parameter (the page itself, <img>/<video> sources,
|
|
842
|
+
which cannot set headers). Constant-time compare, as a habit.
|
|
843
|
+
"""
|
|
844
|
+
site = self.headers.get("Sec-Fetch-Site")
|
|
845
|
+
if site and site not in ("same-origin", "none"):
|
|
846
|
+
return False
|
|
847
|
+
tok = self.headers.get("X-Screengraft-Token") or (q.get("t") or [""])[0]
|
|
848
|
+
return bool(tok) and bool(TOKEN) and secrets.compare_digest(tok, TOKEN)
|
|
849
|
+
|
|
850
|
+
def _refuse(self, u):
|
|
851
|
+
if u.path == "/":
|
|
852
|
+
body = (b"<!doctype html><meta charset=utf-8><title>screengraft</title>"
|
|
853
|
+
b"<p style='font:14px system-ui;margin:2em'>This page needs the link the "
|
|
854
|
+
b"launcher printed — it carries a one-session token. "
|
|
855
|
+
b"Relaunch with <code>scripts/launch.sh</code> and open the URL it prints.")
|
|
856
|
+
self.send_response(403)
|
|
857
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
858
|
+
self.send_header("Content-Length", str(len(body)))
|
|
859
|
+
self.end_headers()
|
|
860
|
+
self.wfile.write(body)
|
|
861
|
+
return
|
|
862
|
+
self._json({"error": "missing or wrong session token"}, 403)
|
|
863
|
+
|
|
804
864
|
def _json(self, obj, code=200):
|
|
805
865
|
body = json.dumps(obj).encode()
|
|
806
866
|
self.send_response(code)
|
|
@@ -875,6 +935,13 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
875
935
|
def do_GET(self):
|
|
876
936
|
u = urllib.parse.urlparse(self.path)
|
|
877
937
|
q = urllib.parse.parse_qs(u.query)
|
|
938
|
+
# The one open route: liveness. Says nothing but that a screengraft is
|
|
939
|
+
# here and which version -- launch.sh and the skill probe it before
|
|
940
|
+
# telling anyone the UI is ready, with no token in hand.
|
|
941
|
+
if u.path == "/api/ping":
|
|
942
|
+
return self._json({"ok": True, "version": VERSION})
|
|
943
|
+
if not self._authorised(q):
|
|
944
|
+
return self._refuse(u)
|
|
878
945
|
try:
|
|
879
946
|
if u.path == "/":
|
|
880
947
|
return self._file(UI_HTML, "text/html; charset=utf-8")
|
|
@@ -932,6 +999,14 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
932
999
|
# ---- POST ----
|
|
933
1000
|
def do_POST(self):
|
|
934
1001
|
u = urllib.parse.urlparse(self.path)
|
|
1002
|
+
if not self._authorised(urllib.parse.parse_qs(u.query)):
|
|
1003
|
+
# Read the body off the wire first (bounded): refusing while the
|
|
1004
|
+
# client is still sending turns a clean 403 into a broken pipe on
|
|
1005
|
+
# its side, which reads as "the server died", not "you were refused".
|
|
1006
|
+
n = min(int(self.headers.get("Content-Length") or 0), 64 << 20)
|
|
1007
|
+
while n > 0:
|
|
1008
|
+
n -= len(self.rfile.read(min(n, 1 << 20)) or b"\0")
|
|
1009
|
+
return self._refuse(u)
|
|
935
1010
|
try:
|
|
936
1011
|
if u.path == "/api/upload":
|
|
937
1012
|
# raw bytes + X-Filename + X-Role (photo|screenshot); no multipart, no cgi module.
|
|
@@ -1194,7 +1269,8 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1194
1269
|
blend, reflection, None, "preview",
|
|
1195
1270
|
fit_frame, max_frames),
|
|
1196
1271
|
kwargs={"smoothing": _smoothing(b),
|
|
1197
|
-
"dof": _dof_args(b)
|
|
1272
|
+
"dof": _dof_args(b),
|
|
1273
|
+
"grain_gain": _grain_gain(b)}).start()
|
|
1198
1274
|
except BaseException:
|
|
1199
1275
|
with RENDER_LOCK:
|
|
1200
1276
|
RENDER.update(state="error", message="could not start the preview")
|
|
@@ -1247,7 +1323,7 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1247
1323
|
result = {"output": dest, "photo": ppath, "screenshot": spath,
|
|
1248
1324
|
"corners": corners, "radius_frac": frac, "radius_px": radius_px,
|
|
1249
1325
|
"device": b.get("device"), "corner_smoothing": _smoothing(b),
|
|
1250
|
-
"grade": gr, "grain": grain,
|
|
1326
|
+
"grade": gr, "grain": grain, "grain_gain": _grain_gain(b),
|
|
1251
1327
|
"video": True, "preset": preset, "fit_frame": fit_frame,
|
|
1252
1328
|
"blend": blend, "reflection": reflection,
|
|
1253
1329
|
"dof_angle": dof["dof_angle"], "dof_strength": dof["dof_strength"],
|
|
@@ -1280,7 +1356,8 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1280
1356
|
gr, grain, preset, fit_frame,
|
|
1281
1357
|
blend, reflection, result),
|
|
1282
1358
|
kwargs={"smoothing": _smoothing(b),
|
|
1283
|
-
"dof": dof
|
|
1359
|
+
"dof": dof,
|
|
1360
|
+
"grain_gain": result["grain_gain"]}).start()
|
|
1284
1361
|
except BaseException:
|
|
1285
1362
|
# If the thread cannot even be created, the flag must not
|
|
1286
1363
|
# outlive the request.
|
|
@@ -1304,9 +1381,11 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1304
1381
|
blend, reflection = _blend_args(b)
|
|
1305
1382
|
smoothing = _smoothing(b)
|
|
1306
1383
|
dof = _dof_args(b)
|
|
1384
|
+
grain_gain = _grain_gain(b)
|
|
1307
1385
|
out = W.compose(photo, shot, corners, radius_px,
|
|
1308
1386
|
corner_smoothing=smoothing,
|
|
1309
1387
|
grade=gr, grain=bool(b.get("grain", gr > 0)),
|
|
1388
|
+
grain_gain=grain_gain,
|
|
1310
1389
|
blend=blend, reflection=reflection, **dof)
|
|
1311
1390
|
SESSION.update(corners=corners, radius_frac=frac, device=b.get("device"),
|
|
1312
1391
|
grade=gr)
|
|
@@ -1342,6 +1421,7 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1342
1421
|
"radius_frac": frac, "radius_px": radius_px, "device": b.get("device"),
|
|
1343
1422
|
"corner_smoothing": smoothing,
|
|
1344
1423
|
"grade": gr, "grain": bool(b.get("grain", gr > 0)),
|
|
1424
|
+
"grain_gain": grain_gain,
|
|
1345
1425
|
"blend": blend, "reflection": reflection,
|
|
1346
1426
|
"dof_angle": dof["dof_angle"], "dof_strength": dof["dof_strength"],
|
|
1347
1427
|
"dof_start": dof["dof_start"], "dof_end": dof["dof_end"],
|
|
@@ -1450,7 +1530,7 @@ def _publish_current(payload):
|
|
|
1450
1530
|
|
|
1451
1531
|
|
|
1452
1532
|
def main():
|
|
1453
|
-
global SESSION, OUT_DIR, VERSION, BUILD
|
|
1533
|
+
global SESSION, OUT_DIR, VERSION, BUILD, TOKEN
|
|
1454
1534
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
1455
1535
|
ap.add_argument("--port", type=int, default=0, help="0 = pick a free port")
|
|
1456
1536
|
ap.add_argument("--no-open", action="store_true", help="Don't open the browser")
|
|
@@ -1475,7 +1555,11 @@ def main():
|
|
|
1475
1555
|
sdir = args.session or os.path.join(HOME, ".screengraft", "sessions", time.strftime("%Y%m%d-%H%M%S"))
|
|
1476
1556
|
SESSION = Session(sdir)
|
|
1477
1557
|
port = args.port or free_port()
|
|
1478
|
-
|
|
1558
|
+
TOKEN = secrets.token_urlsafe(18)
|
|
1559
|
+
base = f"http://127.0.0.1:{port}/"
|
|
1560
|
+
# `url` is what a person opens; `base` + the token header is what a tool
|
|
1561
|
+
# uses. Kept apart so nobody has to strip a query string off a URL.
|
|
1562
|
+
url = f"{base}?t={TOKEN}"
|
|
1479
1563
|
srv = ThreadingHTTPServer(("127.0.0.1", port), Handler)
|
|
1480
1564
|
freed = _prune_sessions(sdir)
|
|
1481
1565
|
# The live session's own copies go when this process does. Registered before
|
|
@@ -1483,11 +1567,11 @@ def main():
|
|
|
1483
1567
|
# scripts/stop.sh sends SIGTERM for exactly this reason. A kill -9 cannot be
|
|
1484
1568
|
# caught, which is what the launch-time sweep above is for.
|
|
1485
1569
|
atexit.register(lambda: _sweep_session(sdir))
|
|
1486
|
-
_publish_current({"session": sdir, "url": url, "
|
|
1487
|
-
"out_dir": OUT_DIR, "started": time.time()})
|
|
1488
|
-
print(json.dumps({"url": url, "
|
|
1489
|
-
"
|
|
1490
|
-
"reclaimed_mb": round(freed / 1e6, 1)}), flush=True)
|
|
1570
|
+
_publish_current({"session": sdir, "url": url, "base": base, "token": TOKEN,
|
|
1571
|
+
"pid": os.getpid(), "out_dir": OUT_DIR, "started": time.time()})
|
|
1572
|
+
print(json.dumps({"url": url, "base": base, "token": TOKEN, "session": sdir,
|
|
1573
|
+
"job": SESSION.job_path, "result": SESSION.result_path,
|
|
1574
|
+
"out_dir": OUT_DIR, "reclaimed_mb": round(freed / 1e6, 1)}), flush=True)
|
|
1491
1575
|
if not args.no_open:
|
|
1492
1576
|
opener = "open" if sys.platform == "darwin" else "xdg-open"
|
|
1493
1577
|
threading.Timer(0.3, lambda: subprocess.Popen([opener, url])).start()
|
package/scripts/warp.py
CHANGED
|
@@ -270,7 +270,8 @@ class Plan:
|
|
|
270
270
|
blend: str = "replace", reflection: float = DEFAULT_REFLECTION,
|
|
271
271
|
corner_smoothing: float = 0.0,
|
|
272
272
|
dof_angle: float = 0.0, dof_strength: float = 0.0, dof_start: float = 0.0,
|
|
273
|
-
dof_end: float = 1.0, dof_space: str = "photo", dof_end2=None
|
|
273
|
+
dof_end: float = 1.0, dof_space: str = "photo", dof_end2=None,
|
|
274
|
+
grain_gain: float = 1.0):
|
|
274
275
|
dst_quad = np.array(corners, dtype=np.float32)
|
|
275
276
|
if shoelace_area(dst_quad) < 1.0:
|
|
276
277
|
raise ValueError("degenerate quad (near-zero area) — check corner order TL,TR,BR,BL")
|
|
@@ -310,8 +311,12 @@ class Plan:
|
|
|
310
311
|
self.corner_smoothing)
|
|
311
312
|
self.warped_mask = _warp_mask_antialiased(src_mask, self.H, pw, ph, dst_quad)
|
|
312
313
|
self.mask3 = cv2.merge([self.warped_mask] * 3).astype(np.float32) / 255.0
|
|
314
|
+
# The floor is the surround's; the screen carries `grain_gain` of it
|
|
315
|
+
# (see grade.SCREEN_GRAIN_GAIN). Defaults to 1.0 so a sidecar written
|
|
316
|
+
# before the gain existed reproduces its save byte for byte.
|
|
317
|
+
self.grain_gain = float(max(grain_gain, 0.0))
|
|
313
318
|
self.grain_sigma = (_grade.measure_grain(photo, _grade.surround_ring(self.warped_mask))
|
|
314
|
-
if grain else 0.0)
|
|
319
|
+
* self.grain_gain if grain else 0.0)
|
|
315
320
|
self.grade_params = None
|
|
316
321
|
# Integer bbox of the quad, clamped to the canvas and padded by a pixel
|
|
317
322
|
# so the antialiased edge is never clipped.
|
|
@@ -444,7 +449,8 @@ def compose(photo: np.ndarray, screenshot: np.ndarray, corners, corner_radius: f
|
|
|
444
449
|
reflection: float = DEFAULT_REFLECTION,
|
|
445
450
|
dof_angle: float = 0.0, dof_strength: float = 0.0,
|
|
446
451
|
dof_start: float = 0.0, dof_end: float = 1.0,
|
|
447
|
-
dof_space: str = "photo", dof_end2=None
|
|
452
|
+
dof_space: str = "photo", dof_end2=None,
|
|
453
|
+
grain_gain: float = 1.0) -> np.ndarray:
|
|
448
454
|
"""Warp `screenshot` into the quad `corners` (TL,TR,BR,BL, photo pixels) on `photo`.
|
|
449
455
|
|
|
450
456
|
Single resampling pass at the photo's resolution; deterministic. This is the
|
|
@@ -459,7 +465,8 @@ def compose(photo: np.ndarray, screenshot: np.ndarray, corners, corner_radius: f
|
|
|
459
465
|
corner_smoothing=corner_smoothing,
|
|
460
466
|
blend=blend, reflection=reflection,
|
|
461
467
|
dof_angle=dof_angle, dof_strength=dof_strength, dof_start=dof_start,
|
|
462
|
-
dof_end=dof_end, dof_space=dof_space, dof_end2=dof_end2
|
|
468
|
+
dof_end=dof_end, dof_space=dof_space, dof_end2=dof_end2,
|
|
469
|
+
grain_gain=grain_gain)
|
|
463
470
|
plan.bind_grade(screenshot, grade)
|
|
464
471
|
return plan.render(screenshot, screen_off=screen_off, specular=specular)
|
|
465
472
|
|
|
@@ -549,7 +556,8 @@ def compose_video(photo: np.ndarray, video_path: str, corners, output: str,
|
|
|
549
556
|
start_frame: int = 0, max_frames: int = None,
|
|
550
557
|
dof_angle: float = 0.0, dof_strength: float = 0.0,
|
|
551
558
|
dof_start: float = 0.0, dof_end: float = 1.0,
|
|
552
|
-
dof_space: str = "photo", dof_end2=None
|
|
559
|
+
dof_space: str = "photo", dof_end2=None,
|
|
560
|
+
grain_gain: float = 1.0) -> dict:
|
|
553
561
|
"""Inject a VIDEO into a still photo. The photo does not move, so there is
|
|
554
562
|
exactly one homography and the whole of Plan is computed once.
|
|
555
563
|
|
|
@@ -578,7 +586,8 @@ def compose_video(photo: np.ndarray, video_path: str, corners, output: str,
|
|
|
578
586
|
corner_smoothing=corner_smoothing,
|
|
579
587
|
blend=blend, reflection=reflection,
|
|
580
588
|
dof_angle=dof_angle, dof_strength=dof_strength, dof_start=dof_start,
|
|
581
|
-
dof_end=dof_end, dof_space=dof_space, dof_end2=dof_end2
|
|
589
|
+
dof_end=dof_end, dof_space=dof_space, dof_end2=dof_end2,
|
|
590
|
+
grain_gain=grain_gain)
|
|
582
591
|
plan.bind_grade(first, grade)
|
|
583
592
|
|
|
584
593
|
ph, pw = photo.shape[:2]
|
|
@@ -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
|