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 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 matched to the photo's own noise floor, real
142
- speculars lifted from a screen-off reference.
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.54.8",
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, c), *_ = np.linalg.lstsq(Amat, sig, rcond=None)
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
- if [ -z "$URL" ]; then
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
- if ! curl -sf -o /dev/null "${URL}api/state"; then
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 &mdash; 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)}).start()
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}).start()
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
- url = f"http://127.0.0.1:{port}/"
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, "pid": os.getpid(),
1487
- "out_dir": OUT_DIR, "started": time.time()})
1488
- print(json.dumps({"url": url, "session": sdir, "job": SESSION.job_path,
1489
- "result": SESSION.result_path, "out_dir": OUT_DIR,
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) -> np.ndarray:
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) -> dict:
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.54):** 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.
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 <url>api/state` — 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.
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 fetch(p, body===undefined ? {} : {method:'POST', headers: raw ? raw.headers : {'Content-Type':'application/json'}, body: raw ? raw.body : JSON.stringify(body)});
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 fetch('/api/render_status')).json(); }
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 fetch('/api/render_status')).json(); }
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