screengraft 0.21.1 → 0.24.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/README.md CHANGED
@@ -130,6 +130,25 @@ outside the screen mask. It never touches the pixels you designed.
130
130
  crawl. Frame 0 of a render is byte-identical to the still composite — the
131
131
  test suite asserts it, because that is what stops the two paths drifting.
132
132
 
133
+ ## Where screengraft keeps things
134
+
135
+ **Your output** goes to the folder you launched with (`--out-dir`), which the
136
+ Claude skill points at your project. That is the only place anything is kept for
137
+ you, and nothing below ever touches it.
138
+
139
+ **Working files** live in `~/.screengraft/sessions/<timestamp>/` — one directory
140
+ per run. A source you pick by path is never copied: screengraft reads it where it
141
+ is. A source you drag in or browse to has to be copied, because a browser hands
142
+ over bytes and will not say where they came from — and that copy, along with the
143
+ preview and thumbnails, is **deleted when the run ends**. Anything left behind by
144
+ a crash is swept the next time you launch.
145
+
146
+ What survives is the `result.json` sidecar: a few hundred bytes recording the
147
+ corners, radius, grade and blend of that fit, referencing your original files by
148
+ path. It reproduces a composite exactly, and it is the first thing a bug report
149
+ should include. Keep the recipe, not the ingredients.
150
+
151
+
133
152
  ## Roadmap
134
153
 
135
154
  Done: manual warp, advisory detectors, the fitting workbench, the realism pass,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "screengraft",
3
- "version": "0.21.1",
3
+ "version": "0.24.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/detect.py CHANGED
@@ -490,7 +490,22 @@ def validate_quad(corners: np.ndarray, contour, img_area: float, img_shape=None)
490
490
  return True, ""
491
491
 
492
492
 
493
- def _finalize(candidates, img_area: float, refine: bool = True, img_shape=None):
493
+ def contains(quad, point) -> bool:
494
+ """Is `point` inside this quad? The whole of the click feature, in one line.
495
+
496
+ A click cannot make a bad candidate good, and it is not asked to. It answers
497
+ the one question the pixels cannot: WHICH of the regions we already found is
498
+ the screen. Measured on the two photos that defeated every detector, the
499
+ correct quad was already in the candidate list both times -- 23 of 93
500
+ candidates contained the click on one, 16 of 118 on the other -- so the
501
+ failure was never detection, it was selection.
502
+ """
503
+ return cv2.pointPolygonTest(np.asarray(quad, np.float32),
504
+ (float(point[0]), float(point[1])), False) >= 0
505
+
506
+
507
+ def _finalize(candidates, img_area: float, refine: bool = True, img_shape=None,
508
+ click=None):
494
509
  """Best candidate that survives refinement AND validation.
495
510
 
496
511
  Walks candidates best-score-first rather than trusting the top one: a
@@ -506,6 +521,12 @@ def _finalize(candidates, img_area: float, refine: bool = True, img_shape=None):
506
521
  78px off an otherwise correct quad . The polygon approximation of a
507
522
  Canny boundary is already on the edge, so there is nothing to recover.
508
523
  """
524
+ if click is not None:
525
+ # Before ranking, not after: the point of the click is to shrink the
526
+ # field to the things the user actually pointed at, and let the existing
527
+ # score decide among those. Ranking first and filtering after would just
528
+ # re-confirm whichever candidate already won.
529
+ candidates = [c for c in candidates if contains(c[1], click)]
509
530
  rejected = []
510
531
  for cand in sorted(candidates, key=lambda c: c[0], reverse=True):
511
532
  score, quad, contour, tag = pick_innermost(candidates, cand)
@@ -529,7 +550,7 @@ def _finalize(candidates, img_area: float, refine: bool = True, img_shape=None):
529
550
  return None
530
551
 
531
552
 
532
- def detect_tone(gray: np.ndarray, tone=None):
553
+ def detect_tone(gray: np.ndarray, tone=None, click=None):
533
554
  """Tone-band segmentation. Assumes the screen sits in a narrow tone band."""
534
555
  h, w = gray.shape[:2]
535
556
  img_area = float(h * w)
@@ -560,7 +581,7 @@ def detect_tone(gray: np.ndarray, tone=None):
560
581
  score *= (16.0 / (hi - lo + 1)) ** 0.25
561
582
  candidates.append((score, order_quad(quad), contour, (lo, hi)))
562
583
 
563
- res = _finalize(candidates, img_area, img_shape=gray.shape[:2])
584
+ res = _finalize(candidates, img_area, img_shape=gray.shape[:2], click=click)
564
585
  if res is None:
565
586
  return None
566
587
  band = res.pop("_tag")
@@ -569,7 +590,7 @@ def detect_tone(gray: np.ndarray, tone=None):
569
590
  return res
570
591
 
571
592
 
572
- def detect_edges(gray: np.ndarray):
593
+ def detect_edges(gray: np.ndarray, click=None):
573
594
  """Canny-and-quad detection — the document-scanner path.
574
595
 
575
596
  Tone banding assumes a near-uniform screen, which breaks the moment the
@@ -608,7 +629,7 @@ def detect_edges(gray: np.ndarray):
608
629
  if score > 0 and quad is not None:
609
630
  candidates.append((score, order_quad(quad), c, (lo, hi)))
610
631
 
611
- res = _finalize(candidates, img_area, refine=False, img_shape=gray.shape[:2])
632
+ res = _finalize(candidates, img_area, refine=False, img_shape=gray.shape[:2], click=click)
612
633
  if res is None:
613
634
  return None
614
635
  thr = res.pop("_tag")
@@ -617,7 +638,7 @@ def detect_edges(gray: np.ndarray):
617
638
  return res
618
639
 
619
640
 
620
- def detect_saturation(bgr: np.ndarray):
641
+ def detect_saturation(bgr: np.ndarray, click=None):
621
642
  """Neutral-region segmentation — the third detector, and the only one that
622
643
  looks at colour.
623
644
 
@@ -654,7 +675,7 @@ def detect_saturation(bgr: np.ndarray):
654
675
  candidates.append((score * (32.0 / max(thr, 1)) ** 0.25,
655
676
  order_quad(quad), contour, thr))
656
677
 
657
- res = _finalize(candidates, img_area, img_shape=sat.shape[:2])
678
+ res = _finalize(candidates, img_area, img_shape=sat.shape[:2], click=click)
658
679
  if res is None:
659
680
  return None
660
681
  res.pop("_tag")
@@ -681,7 +702,7 @@ def has_rounded_corners(result) -> bool:
681
702
  return spread <= MAX_RADIUS_SPREAD
682
703
 
683
704
 
684
- def detect(gray: np.ndarray, tone=None, method="auto", color=None):
705
+ def detect(gray: np.ndarray, tone=None, method="auto", color=None, click=None):
685
706
  """Run both detectors; arbitrate on how the two quads nest.
686
707
 
687
708
  The two fail on opposite things. Tone banding needs a tonally uniform
@@ -707,15 +728,15 @@ def detect(gray: np.ndarray, tone=None, method="auto", color=None):
707
728
  """
708
729
  results = []
709
730
  if method in ("auto", "tone"):
710
- r = detect_tone(gray, tone)
731
+ r = detect_tone(gray, tone, click=click)
711
732
  if r:
712
733
  results.append(r)
713
734
  if method in ("auto", "edge") and tone is None:
714
- r = detect_edges(gray)
735
+ r = detect_edges(gray, click=click)
715
736
  if r:
716
737
  results.append(r)
717
738
  if method in ("auto", "saturation") and tone is None and color is not None:
718
- r = detect_saturation(color)
739
+ r = detect_saturation(color, click=click)
719
740
  if r:
720
741
  results.append(r)
721
742
 
@@ -842,7 +863,14 @@ def detect(gray: np.ndarray, tone=None, method="auto", color=None):
842
863
  # the thing found is shaped like a screen, which is what abstention exists
843
864
  # to doubt. Without this a good saturation result on a hard photo would be
844
865
  # thrown away for want of a second opinion.
845
- uncorroborated = bool(not ag.get("agree")
866
+ # A click IS corroboration, and the strongest kind available: a person
867
+ # looked at the photograph and said "the screen is here". Abstaining for
868
+ # want of a second algorithm after that would be refusing the only evidence
869
+ # that outranks the algorithms. Gross disagreement between two credible
870
+ # peers still counts -- that says the click landed somewhere ambiguous, and
871
+ # is worth reporting -- but being alone no longer does.
872
+ uncorroborated = bool(click is None
873
+ and not ag.get("agree")
846
874
  and not best["corner_radius"]["confident"]
847
875
  and not has_rounded_corners(best))
848
876
  if gross or uncorroborated:
package/scripts/ui.py CHANGED
@@ -144,6 +144,159 @@ class Session:
144
144
  SESSION: Session = None
145
145
 
146
146
 
147
+ # Media a session COPIES rather than produces for keeps. Everything here is
148
+ # either a duplicate of a file the user already has, or something regenerable
149
+ # from the sidecar in seconds. The sidecar and state are not in the list: they
150
+ # are a few hundred bytes and they are the record of what was fitted, with the
151
+ # ORIGINAL paths in them. Keep the recipe, stop keeping the ingredients.
152
+ _RESIDUE_PREFIXES = ("photo-", "screenshot-", "poster-", "frame-")
153
+ # figma-export.png is a copy too -- the agent fetches the frame and drops it
154
+ # here -- and re-exporting is one MCP round trip, so it is residue like the rest.
155
+ _RESIDUE_NAMES = ("preview.png", "figma-export.png")
156
+
157
+
158
+ def _sweep_session(d):
159
+ """Delete a session's copied and derived media. Returns bytes reclaimed.
160
+
161
+ Never touches *.json, and never touches OUT_DIR -- the actual outputs live
162
+ in the project folder and are the point of the whole exercise.
163
+ """
164
+ freed = 0
165
+ thumbs = os.path.join(d, "thumbs")
166
+ for base, _, files in os.walk(d):
167
+ for f in files:
168
+ keep = f.endswith(".json")
169
+ residue = (f.startswith(_RESIDUE_PREFIXES) or f in _RESIDUE_NAMES
170
+ or base == thumbs)
171
+ if keep or not residue:
172
+ continue
173
+ fp = os.path.join(base, f)
174
+ try:
175
+ freed += os.path.getsize(fp)
176
+ os.remove(fp)
177
+ except OSError:
178
+ pass
179
+ return freed
180
+
181
+
182
+ def _prune_sessions(keep):
183
+ """Sweep every session but the live one, at launch.
184
+
185
+ Sessions are per-run scratch that nothing reads back, and a session keeps a
186
+ full copy of every uploaded input -- a 21s clip is ~80 MB. Measured before
187
+ this existed: 492 MB across 90 directories in six days, 64% of it duplicates
188
+ of files the user already had, and nothing ever deleted any of it.
189
+
190
+ A file chosen by PATH (the recent list, or typing one) was never copied --
191
+ /api/use records the path and reads through it. Only a drag-drop or a browse
192
+ has to be copied, because the browser hands over bytes and will not say
193
+ where they came from. So this is the other half of the same policy: what
194
+ cannot avoid being copied does not outlive the run that needed it.
195
+ """
196
+ root = os.path.dirname(keep)
197
+ freed = 0
198
+ try:
199
+ names = os.listdir(root)
200
+ except OSError:
201
+ return 0
202
+ for n in names:
203
+ d = os.path.join(root, n)
204
+ if d == keep or not os.path.isdir(d):
205
+ continue
206
+ freed += _sweep_session(d)
207
+ return freed
208
+
209
+
210
+ def _version() -> str:
211
+ """The shipped version, read from plugin.json rather than restated here.
212
+
213
+ A second place to write a version is a second place for it to go stale --
214
+ which is why check_package.py exists at all, after SKILL.md claimed the
215
+ wrong one for three releases. The page asks the server; the server reads the
216
+ manifest it was packaged with. Falls back to package.json, then to empty,
217
+ because a missing badge is a far better failure than a confidently wrong
218
+ one: a wrong version in a bug report costs more than no version.
219
+ """
220
+ here = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
221
+ for rel in (os.path.join(".claude-plugin", "plugin.json"), "package.json"):
222
+ try:
223
+ with open(os.path.join(here, rel)) as f:
224
+ v = json.load(f).get("version")
225
+ if v:
226
+ return str(v)
227
+ except (OSError, ValueError, AttributeError):
228
+ continue
229
+ return ""
230
+
231
+
232
+ def _build_label() -> str:
233
+ """Where this code came from: "" when installed, "dev <sha>[+]" from a tree.
234
+
235
+ The distinction the badge exists for. A session materialises its own private
236
+ copy of every installed plugin at start and keeps that snapshot for its whole
237
+ life, so the installed copy and the tree you are editing drift apart within
238
+ minutes -- and the symptom is a feature that is "not there", which reads
239
+ exactly like a bug in the feature. That has cost two debugging sessions.
240
+
241
+ `.git` is the discriminator because the packager excludes it: a tree has one,
242
+ an unpacked .plugin never does. The commit and the dirty marker are here
243
+ because on a day with four releases a bare "dev" is not enough to say WHICH
244
+ dev, and a sha without a `+` when the tree is dirty would be a confident lie
245
+ -- the failure mode this badge is supposed to prevent.
246
+ """
247
+ root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
248
+ if not os.path.isdir(os.path.join(root, ".git")):
249
+ return "" # an unpacked .plugin: just the version
250
+ def git(*a):
251
+ return subprocess.run(("git", "-C", root) + a, capture_output=True,
252
+ text=True, timeout=5)
253
+ try:
254
+ r = git("rev-parse", "--short", "HEAD")
255
+ sha = r.stdout.strip() if r.returncode == 0 else ""
256
+ d = git("status", "--porcelain")
257
+ dirty = "+" if (d.returncode == 0 and d.stdout.strip()) else ""
258
+ except (OSError, subprocess.SubprocessError):
259
+ return "dev" # a tree, and that is the part that matters
260
+ return f"dev {sha}{dirty}".strip() if sha else "dev"
261
+
262
+
263
+ VERSION = None # resolved once, in main()
264
+ BUILD = "" # ditto -- no per-request subprocess
265
+
266
+
267
+ def _quad(raw):
268
+ """Four corners of two finite numbers, or a ValueError naming the problem.
269
+
270
+ The compositing routes used to hand whatever arrived straight to the engine,
271
+ so a malformed quad became a failure deep inside a worker thread -- or, on
272
+ the render path, a job that was accepted, started, and then died somewhere
273
+ the user could not see. The shape of the request is the route's business.
274
+ """
275
+ try:
276
+ pts = [[float(x), float(y)] for x, y in raw]
277
+ except (TypeError, ValueError) as e:
278
+ raise ValueError(f"corners must be four [x, y] pairs ({e})") from e
279
+ if len(pts) != 4:
280
+ raise ValueError(f"corners must be four points, got {len(pts)}")
281
+ if not all(v == v and abs(v) != float("inf") for pt in pts for v in pt):
282
+ raise ValueError("corners contain a non-finite value")
283
+ return pts
284
+
285
+
286
+ def _need_sources():
287
+ """Both sources chosen, or a clean 400 saying which one is missing.
288
+
289
+ Without this the compositing routes indexed straight into the session and
290
+ handed `None` to `os.path.expanduser`, which raises TypeError -- a type the
291
+ handler does not catch, so the worker thread died and the browser saw the
292
+ connection drop with no status and no message at all. A request that cannot
293
+ be served should be answered, not hung up on.
294
+ """
295
+ missing = [k for k in ("photo", "screenshot") if not SESSION.state.get(k)]
296
+ if missing:
297
+ raise ValueError("choose a " + " and a ".join(missing) + " first")
298
+
299
+
147
300
  def _safe_local_path(p: str) -> str:
148
301
  """Only serve files under the user's home (the UI is local, but still)."""
149
302
  p = os.path.realpath(os.path.expanduser(p))
@@ -237,7 +390,20 @@ RENDER_LOCK = threading.Lock()
237
390
 
238
391
 
239
392
  def _render_worker(photo, video_path, corners, dest, radius_px, gr, grain, preset, fit_frame,
240
- blend="replace", reflection=None):
393
+ blend="replace", reflection=None, result=None):
394
+ """Encode the clip, and only if that SUCCEEDS publish what it produced.
395
+
396
+ `result` is the sidecar this render would write. It is handed to the worker
397
+ rather than written by the route, because a sidecar written before the
398
+ encode asserts `saved` for a file that may never exist -- and the sidecar is
399
+ the first thing the bug template asks for, so a misleading one sends the
400
+ next investigation the wrong way.
401
+
402
+ Publication order matters: the sidecar and the session output are written
403
+ BEFORE the state flips to "done". The page polls for "done" and may ask to
404
+ send the file to Claude the moment it sees it, so the artefacts have to be
405
+ in place first or that request races the worker.
406
+ """
241
407
  def progress(done, total):
242
408
  with RENDER_LOCK:
243
409
  RENDER["done"], RENDER["total"] = done, total
@@ -248,6 +414,13 @@ def _render_worker(photo, video_path, corners, dest, radius_px, gr, grain, prese
248
414
  blend=blend,
249
415
  reflection=(W.DEFAULT_REFLECTION if reflection is None
250
416
  else reflection))
417
+ if result is not None:
418
+ _write_json_atomic(SESSION.result_path, {**result, "saved": time.time()})
419
+ # The still path has always done this (see /api/save); the render path
420
+ # never did, so /api/import -- which reads SESSION.state["output"] --
421
+ # either found nothing or, worse, silently handed over the PREVIOUS
422
+ # still image after a successful render.
423
+ SESSION.update(output=dest)
251
424
  with RENDER_LOCK:
252
425
  RENDER.update(state="done", output=dest, info=info,
253
426
  done=info["frames"], total=info["frames"], message=None)
@@ -332,7 +505,8 @@ class Handler(BaseHTTPRequestHandler):
332
505
  return self._file(UI_HTML, "text/html; charset=utf-8")
333
506
  if u.path == "/api/state":
334
507
  return self._json({**SESSION.state, "session": SESSION.dir, "out_dir": OUT_DIR,
335
- "home": HOME, "presets": PRESETS})
508
+ "home": HOME, "presets": PRESETS, "version": VERSION,
509
+ "build": BUILD})
336
510
  if u.path == "/api/recent":
337
511
  items = S.scan(days=int(q.get("days", ["14"])[0]), limit=int(q.get("limit", ["40"])[0]))
338
512
  for it in items:
@@ -371,6 +545,14 @@ class Handler(BaseHTTPRequestHandler):
371
545
  return self._json({"error": "no such route"}, 404)
372
546
  except (PermissionError, FileNotFoundError, KeyError, ValueError) as e:
373
547
  return self._json({"error": str(e)}, 400)
548
+ except Exception as e: # noqa: BLE001 - the last resort
549
+ # A type nobody enumerated must still produce a RESPONSE. Without
550
+ # this the handler thread dies and the browser sees the connection
551
+ # drop with no status and no message -- indistinguishable from the
552
+ # server being gone, and impossible to report usefully. Twice in one
553
+ # afternoon a bad input did exactly that: a None photo path reaching
554
+ # expanduser, and a truncated clip whose frame read came back empty.
555
+ return self._json({"error": f"{type(e).__name__}: {e}"}, 500)
374
556
 
375
557
  # ---- POST ----
376
558
  def do_POST(self):
@@ -440,16 +622,36 @@ class Handler(BaseHTTPRequestHandler):
440
622
  return self._json({"path": real, "size": [im.shape[1], im.shape[0]]})
441
623
 
442
624
  if u.path == "/api/detect":
625
+ if not SESSION.state.get("photo"):
626
+ raise ValueError("choose a photo first")
443
627
  photo, _ = _read_image(SESSION.state["photo"])
444
628
  gray = cv2.cvtColor(photo, cv2.COLOR_BGR2GRAY)
629
+ # An optional seed point, in PHOTO pixels. The detectors already
630
+ # find the screen on these photographs -- 23 of 93 candidates
631
+ # contained the click on the iPad that used to abstain -- they
632
+ # just cannot tell which region is a screen. That is the one
633
+ # question a person answers instantly, so the click filters the
634
+ # candidate list and the existing score ranks what is left.
635
+ click = b.get("click") if isinstance(b, dict) else None
636
+ if click is not None:
637
+ try:
638
+ click = (float(click[0]), float(click[1]))
639
+ except (TypeError, ValueError, IndexError) as e:
640
+ raise ValueError(f"click must be [x, y] in photo pixels ({e})") from e
641
+ h, w = photo.shape[:2]
642
+ if not (0 <= click[0] < w and 0 <= click[1] < h):
643
+ raise ValueError("the click is outside the photograph")
445
644
  # `color` gives detect() the saturation detector — devices are
446
645
  # neutral, furniture is not, and grayscale throws that away.
447
- res = D.detect(gray, None, color=photo)
646
+ res = D.detect(gray, None, color=photo, click=click)
448
647
  if res is None:
449
- return self._json({"found": False,
450
- "message": "Neither detector could find a screen here "
451
- "(nothing separable by tone, no screen-shaped "
452
- "boundary). Place the four corners by hand."})
648
+ return self._json({"found": False, "clicked": click is not None,
649
+ "message": ("Nothing screen-shaped was found around that "
650
+ "point try clicking nearer the middle of the "
651
+ "screen." if click is not None else
652
+ "Neither detector could find a screen here "
653
+ "(nothing separable by tone, no screen-shaped "
654
+ "boundary). Place the four corners by hand.")})
453
655
  # An abstention is a miss, and must reach the page as one. On
454
656
  # 7 Sep 2026 a quad on a table was shown as a checkable guess
455
657
  # with two corners off the canvas, which cannot be dragged back
@@ -489,6 +691,7 @@ class Handler(BaseHTTPRequestHandler):
489
691
 
490
692
  if u.path == "/api/render":
491
693
  # Video: same fit, same geometry, N frames instead of one.
694
+ _need_sources()
492
695
  photo, ppath = _read_image(SESSION.state["photo"])
493
696
  spath = _safe_local_path(SESSION.state["screenshot"])
494
697
  if not _is_video(spath):
@@ -496,12 +699,12 @@ class Handler(BaseHTTPRequestHandler):
496
699
  if not _have_ffmpeg():
497
700
  return self._json({"error": "ffmpeg is not installed",
498
701
  "needs_ffmpeg": True}, 400)
702
+ # Cheap early out. The flag that actually reserves the render
703
+ # is set further down, once nothing is left that can throw.
499
704
  with RENDER_LOCK:
500
705
  if RENDER["state"] == "running":
501
706
  return self._json({"error": "a render is already running"}, 409)
502
- RENDER.update(state="running", done=0, total=0,
503
- output=None, message=None)
504
- corners = b["corners"]
707
+ corners = _quad(b["corners"])
505
708
  frac = float(b.get("radius_frac") or 0.0)
506
709
  fit_frame = int(b.get("fit_frame") if b.get("fit_frame") is not None
507
710
  else _fit_frame())
@@ -529,19 +732,38 @@ class Handler(BaseHTTPRequestHandler):
529
732
  "corners": corners, "radius_frac": frac, "radius_px": radius_px,
530
733
  "device": b.get("device"), "grade": gr, "grain": grain,
531
734
  "video": True, "preset": preset, "fit_frame": fit_frame,
532
- "blend": blend, "reflection": reflection,
533
- "saved": time.time()}
534
- _write_json_atomic(SESSION.result_path, result)
535
- threading.Thread(target=_render_worker, daemon=True,
536
- args=(photo, spath, corners, dest, radius_px,
537
- gr, grain, preset, fit_frame,
538
- blend, reflection)).start()
735
+ "blend": blend, "reflection": reflection}
736
+ # `state="running"` means "a thread is running", so it is set
737
+ # here -- after every line that can raise, immediately before the
738
+ # thread exists. It used to be set at the top of this route, so a
739
+ # KeyError on corners, a bad radius, an unreadable frame or an
740
+ # unwritable out_dir left the flag stuck ON with no worker to
741
+ # clear it, and every later render answered 409 for the rest of
742
+ # the session. The only recovery was restarting the server, and
743
+ # nothing said so.
744
+ with RENDER_LOCK:
745
+ if RENDER["state"] == "running":
746
+ return self._json({"error": "a render is already running"}, 409)
747
+ RENDER.update(state="running", done=0, total=0,
748
+ output=None, message=None)
749
+ try:
750
+ threading.Thread(target=_render_worker, daemon=True,
751
+ args=(photo, spath, corners, dest, radius_px,
752
+ gr, grain, preset, fit_frame,
753
+ blend, reflection, result)).start()
754
+ except BaseException:
755
+ # If the thread cannot even be created, the flag must not
756
+ # outlive the request.
757
+ with RENDER_LOCK:
758
+ RENDER.update(state="error", message="could not start the render")
759
+ raise
539
760
  return self._json({"started": True, "output": dest, "preset": preset})
540
761
 
541
762
  if u.path in ("/api/preview", "/api/save"):
763
+ _need_sources()
542
764
  photo, ppath = _read_image(SESSION.state["photo"])
543
765
  shot, spath, _meta = _read_source(SESSION.state["screenshot"])
544
- corners = b["corners"]
766
+ corners = _quad(b["corners"])
545
767
  frac = float(b.get("radius_frac") or 0.0)
546
768
  radius_px = frac * shot.shape[1]
547
769
  # M2 realism pass. Off is a real option, not a fallback: a flat
@@ -597,6 +819,14 @@ class Handler(BaseHTTPRequestHandler):
597
819
  return self._json({"error": str(e)}, 409)
598
820
  except (PermissionError, FileNotFoundError, KeyError, ValueError) as e:
599
821
  return self._json({"error": str(e)}, 400)
822
+ except Exception as e: # noqa: BLE001 - the last resort
823
+ # A type nobody enumerated must still produce a RESPONSE. Without
824
+ # this the handler thread dies and the browser sees the connection
825
+ # drop with no status and no message -- indistinguishable from the
826
+ # server being gone, and impossible to report usefully. Twice in one
827
+ # afternoon a bad input did exactly that: a None photo path reaching
828
+ # expanduser, and a truncated clip whose frame read came back empty.
829
+ return self._json({"error": f"{type(e).__name__}: {e}"}, 500)
600
830
 
601
831
 
602
832
  def free_port():
@@ -664,7 +894,7 @@ def _publish_current(payload):
664
894
 
665
895
 
666
896
  def main():
667
- global SESSION, OUT_DIR
897
+ global SESSION, OUT_DIR, VERSION, BUILD
668
898
  ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
669
899
  ap.add_argument("--port", type=int, default=0, help="0 = pick a free port")
670
900
  ap.add_argument("--no-open", action="store_true", help="Don't open the browser")
@@ -682,6 +912,8 @@ def main():
682
912
  if args.daemon:
683
913
  _daemonise(args.log)
684
914
 
915
+ VERSION = _version()
916
+ BUILD = _build_label()
685
917
  if args.out_dir:
686
918
  OUT_DIR = os.path.abspath(os.path.expanduser(args.out_dir))
687
919
  sdir = args.session or os.path.join(HOME, ".screengraft", "sessions", time.strftime("%Y%m%d-%H%M%S"))
@@ -689,10 +921,17 @@ def main():
689
921
  port = args.port or free_port()
690
922
  url = f"http://127.0.0.1:{port}/"
691
923
  srv = ThreadingHTTPServer(("127.0.0.1", port), Handler)
924
+ freed = _prune_sessions(sdir)
925
+ # The live session's own copies go when this process does. Registered before
926
+ # serve_forever, and the SIGTERM handler exits via sys.exit so atexit runs --
927
+ # scripts/stop.sh sends SIGTERM for exactly this reason. A kill -9 cannot be
928
+ # caught, which is what the launch-time sweep above is for.
929
+ atexit.register(lambda: _sweep_session(sdir))
692
930
  _publish_current({"session": sdir, "url": url, "pid": os.getpid(),
693
931
  "out_dir": OUT_DIR, "started": time.time()})
694
932
  print(json.dumps({"url": url, "session": sdir, "job": SESSION.job_path,
695
- "result": SESSION.result_path, "out_dir": OUT_DIR}), flush=True)
933
+ "result": SESSION.result_path, "out_dir": OUT_DIR,
934
+ "reclaimed_mb": round(freed / 1e6, 1)}), flush=True)
696
935
  if not args.no_open:
697
936
  opener = "open" if sys.platform == "darwin" else "xdg-open"
698
937
  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.21):** 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, then **match the four edges** (drag an edge's middle to slide it, near an end to pivot; corners still draggable) with canvas zoom/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.24):** 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 — 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.
11
11
 
package/ui/index.html CHANGED
@@ -328,7 +328,11 @@
328
328
  .scroller{overflow:auto;background:var(--well);display:grid;
329
329
  min-height:0;min-width:0;
330
330
  box-shadow:inset 0 1px 3px rgba(0,0,0,.5)}
331
- canvas#c{display:block;touch-action:none;cursor:crosshair;margin:auto}
331
+ /* tabindex -1, not 0: the canvas is not a tab stop — it takes focus only when
332
+ you actually click it, which is what makes space mean PAN rather than
333
+ re-pressing whichever rail button you last used. No ring, because the
334
+ focus is a side effect of clicking, never of tabbing. */
335
+ canvas#c{display:block;touch-action:none;cursor:crosshair;margin:auto;outline:none}
332
336
 
333
337
  /* Corner-jump bar floating over the canvas (Figma 16:121). position:sticky
334
338
  inside the scroller would drift with pan; fixed-to-the-pane absolute
@@ -561,6 +565,34 @@
561
565
  pixel to the band's height: 16+28+8+130+16 = 198 exactly. */
562
566
  box-shadow:inset 0 1px 0 var(--line), inset 0 2px 0 rgba(255,255,255,.04)}
563
567
  .dock.hidden{display:none}
568
+ /* Version badge. Bottom-right, fixed, and deliberately the quietest thing on
569
+ screen: --faint is the captions token (measured 3.50:1, the 1.4.3 floor for
570
+ incidental text), 11px with the same tracking as every other label.
571
+ pointer-events:none so it can never eat a click meant for what is under it,
572
+ and it is under no circumstances the accent -- that means "the next action"
573
+ and this is not an action at all.
574
+ Sits in the dock's own 16px padding band rather than over the rectified
575
+ strip: the work surface keeps zero decoration, which is the one rule the
576
+ canvas has. */
577
+ .ver{position:fixed;right:var(--s3);bottom:2px;z-index:5;pointer-events:none;
578
+ font-size:11px;letter-spacing:.06em;color:var(--faint);
579
+ font-variant-numeric:tabular-nums}
580
+ /* A dev build steps up from --faint to --mute (5.86:1). Not the accent, which
581
+ means "the next action" -- this is a fact about what you are running, not
582
+ something to do. Brighter than the plain version because "you are not
583
+ testing what you think you are testing" is worth noticing. */
584
+ .ver.dev{color:var(--mute)}
585
+ /* Armed: the canvas is waiting for one click. The button holds the accent
586
+ because it IS the next action while armed, which is the accent's one job. */
587
+ /* Suggested (detection just failed) and armed (waiting for your click).
588
+ Deliberately NOT the accent: Save carries that, and the visual language
589
+ allows exactly one accent-filled control on screen — measured and violated
590
+ by a first version of this, which put a second one here while Save was
591
+ already live. Emphasis without colour is what the raise ladder is for, and
592
+ it is the same treatment a selected chip gets. */
593
+ #pointat.suggest{background:var(--raise-hi);border-color:var(--edge-hi);color:var(--ink)}
594
+ #pointat.armed{background:var(--raise-highest);border-color:var(--edge-hi);color:var(--ink)}
595
+ .scroller.pointing canvas#c{cursor:crosshair}
564
596
  /* Figma 8:29 is a 28px header holding 24px buttons — the header does not
565
597
  shrink to hug them. */
566
598
  .striphead{display:flex;align-items:center;gap:8px;font-size:12px;min-height:28px}
@@ -719,6 +751,7 @@
719
751
  <span class="lbl">Fit</span>
720
752
  <span class="stepper"><button id="czOut">&minus;</button><button id="czIn">+</button></span>
721
753
  <span id="czSt" class="zoomval"></span>
754
+ <button class="info" type="button" aria-label="About canvas navigation"><svg viewBox="0 0 16 16" fill="none" aria-hidden="true"><path fill="currentColor" d="M8.36 1.01C12.06 1.2 15 4.26 15 8l-.01.36C14.8 12.06 11.74 15 8 15l-.36-.01C4.06 14.81 1.19 11.94 1.01 8.36L1 8c0-3.87 3.13-7 7-7l.36.01ZM8 2C4.69 2 2 4.69 2 8c0 3.31 2.69 6 6 6 3.31 0 6-2.69 6-6 0-3.31-2.69-6-6-6Zm.5 8l.5 0v1L7 11v-1h.5V7H7V6h1.5v4ZM7.9 4c.33 0 .6.27.6.6a.6.6 0 1 1-1.21 0c0-.33.28-.6.61-.6Z"/></svg><span class="tip" role="tooltip">Hold ⌘ and scroll to zoom to the pointer — the pixel under the cursor stays under the cursor, which is how you keep a corner in view while you magnify it. Hold space and drag to pan. Scrolling on its own pans too; the Result pane follows either way.</span></button>
722
755
  </div>
723
756
  <div class="ph-left-2">
724
757
  <button id="czFit">Fit</button>
@@ -728,7 +761,7 @@
728
761
  <span class="status" id="detSt"></span>
729
762
  </div>
730
763
  <div class="scroller" id="scroller">
731
- <canvas id="c"></canvas>
764
+ <canvas id="c" tabindex="-1"></canvas>
732
765
  </div>
733
766
  <!-- Corner jump. It is a SIBLING of the scroller, not a child: anything
734
767
  positioned inside a scroll container scrolls with the content, so
@@ -740,6 +773,7 @@
740
773
  <span class="cvbar-actions">
741
774
  <span class="stepper"><button class="sm" data-jump="0">TL</button><button class="sm" data-jump="1">TR</button><button class="sm" data-jump="2">BR</button><button class="sm" data-jump="3">BL</button></span>
742
775
  <button class="sm" id="redetect">Re-detect</button>
776
+ <button class="sm" id="pointat" title="Click once inside the screen and the detector will use that point. The detectors usually do find the screen — they just cannot tell which region IS one, and that is the part you can answer instantly.">Point at screen</button>
743
777
  <button class="sm" id="resetquad" title="Put the four edges back to a rectangle in the middle of the photo. Use this if a corner has ended up off the picture where you cannot grab it.">Reset</button>
744
778
  </span>
745
779
  </div>
@@ -796,7 +830,7 @@
796
830
  <div class="sect-top">
797
831
  <div class="sect-left">
798
832
  <h4>Realism</h4>
799
- <button class="info" type="button" aria-label="About realism"><svg viewBox="0 0 16 16" fill="none" aria-hidden="true"><path fill="currentColor" d="M8.36 1.01C12.06 1.2 15 4.26 15 8l-.01.36C14.8 12.06 11.74 15 8 15l-.36-.01C4.06 14.81 1.19 11.94 1.01 8.36L1 8c0-3.87 3.13-7 7-7l.36.01ZM8 2C4.69 2 2 4.69 2 8c0 3.31 2.69 6 6 6 3.31 0 6-2.69 6-6 0-3.31-2.69-6-6-6Zm.5 8l.5 0v1L7 11v-1h.5V7H7V6h1.5v4ZM7.9 4c.33 0 .6.27.6.6a.6.6 0 1 1-1.21 0c0-.33.28-.6.61-.6Z"/></svg><span class="tip" role="tooltip">Matches the screen's white balance and grain to the light around it, so it stops reading as pasted. Off keeps the screenshot's colour exactly — right when the UI's own colour is what you're reviewing.</span></button>
833
+ <button class="info" type="button" aria-label="About realism"><svg viewBox="0 0 16 16" fill="none" aria-hidden="true"><path fill="currentColor" d="M8.36 1.01C12.06 1.2 15 4.26 15 8l-.01.36C14.8 12.06 11.74 15 8 15l-.36-.01C4.06 14.81 1.19 11.94 1.01 8.36L1 8c0-3.87 3.13-7 7-7l.36.01ZM8 2C4.69 2 2 4.69 2 8c0 3.31 2.69 6 6 6 3.31 0 6-2.69 6-6 0-3.31-2.69-6-6-6Zm.5 8l.5 0v1L7 11v-1h.5V7H7V6h1.5v4ZM7.9 4c.33 0 .6.27.6.6a.6.6 0 1 1-1.21 0c0-.33.28-.6.61-.6Z"/></svg><span class="tip" role="tooltip">Matches the screen's white balance and grain to the light around it, so it stops reading as pasted. Off keeps the screenshot's colour exactly — right when the UI's own colour is what you're reviewing. Grain is judged here at the preview's scale: the save carries about twice as much, which is what you get if you view the file at 100%.</span></button>
800
834
  </div>
801
835
  <button class="switch" id="gradeBtn" role="switch" aria-checked="true" aria-label="Realism">
802
836
  <span class="sw-track"><span class="sw-handle"></span><span class="sw-grip"></span></span>
@@ -839,7 +873,12 @@
839
873
  </div>
840
874
  <div class="sect" id="secKeys" data-on="1">
841
875
  <div class="sect-top"><div class="sect-left"><h4>Keys</h4></div></div>
876
+ <!-- Two lines, split by what they act on: the QUAD you are fitting, and
877
+ the VIEW you are fitting it in. Grouping them by that rather than
878
+ by modifier is what makes the list scannable while you are mid-fit
879
+ and looking for one of them. -->
842
880
  <p class="hint"><kbd>&larr;&uarr;&rarr;&darr;</kbd> nudge 1px · <kbd>&#8679;</kbd>+arrow 10px · <kbd>Tab</kbd> next edge</p>
881
+ <p class="hint"><kbd>&#8984;</kbd>+scroll zoom to pointer · <kbd>Space</kbd>+drag pan · scroll pans</p>
843
882
  </div>
844
883
  </aside>
845
884
  </div>
@@ -856,6 +895,8 @@
856
895
  </footer>
857
896
  </div>
858
897
 
898
+ <div class="ver" id="ver"></div>
899
+
859
900
  <div class="floatloupe" id="floatLoupe">
860
901
  <canvas id="stripF" width="680" height="208"></canvas>
861
902
  <div class="cap" id="stripFCap"></div>
@@ -1364,6 +1405,163 @@ $('#czFit').onclick = () => { computeFit(); setCanvasZoom(fitScale); };
1364
1405
  $('#cz100').onclick = () => setCanvasZoom(1);
1365
1406
  $('#czIn').onclick = () => setCanvasZoom(scale*1.5);
1366
1407
  $('#czOut').onclick = () => setCanvasZoom(scale/1.5);
1408
+ /* ===== canvas navigation: zoom to the pointer, space-drag to pan ==========
1409
+ The conventions every graphics tool already uses, so not having them means
1410
+ the muscle memory misfires against a canvas that otherwise behaves like one.
1411
+
1412
+ Panning IS scrolling here — the canvas is sized in real pixels inside
1413
+ #scroller — which is why the Result pane keeps up for free: it already
1414
+ mirrors this element's scroll. */
1415
+
1416
+ /* Zoom about a screen point rather than the canvas centre.
1417
+
1418
+ Measured rather than computed: after the resize, ask the DOM where the
1419
+ pinned image point actually landed and scroll by the difference. The canvas
1420
+ is `margin:auto` in a grid, so while it is smaller than the pane it sits
1421
+ centred with a margin that changes as it grows — arithmetic that predicts
1422
+ the scroll offset has to model that margin, and measuring does not. When the
1423
+ whole image fits there is nothing to scroll and the pin cannot hold, which
1424
+ is correct: nothing is moving off screen. */
1425
+ function zoomAt(z, clientX, clientY){
1426
+ if (!img.naturalWidth) return;
1427
+ const before = pos({clientX, clientY});
1428
+ setCanvasZoom(z);
1429
+ const sc = $('#scroller'), r = cv.getBoundingClientRect();
1430
+ sc.scrollLeft += r.left + before[0] * scale * (r.width / cv.width) - clientX;
1431
+ sc.scrollTop += r.top + before[1] * scale * (r.height / cv.height) - clientY;
1432
+ }
1433
+
1434
+ /* A trackpad pinch arrives as a wheel event with ctrlKey synthesised true, and
1435
+ a real cmd+scroll arrives with metaKey — both mean "zoom", so both are taken.
1436
+ The listener MUST be {passive:false} or preventDefault is ignored and the
1437
+ browser zooms the whole page instead of the picture. deltaMode is normalised
1438
+ because a wheel mouse reports lines (1) or pages (2), not pixels. */
1439
+ $('#scroller').addEventListener('wheel', e => {
1440
+ if (!(e.metaKey || e.ctrlKey)) return; // plain scroll still pans
1441
+ e.preventDefault();
1442
+ const dy = e.deltaY * (e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? 400 : 1);
1443
+ zoomAt(scale * Math.exp(-dy * 0.0025), e.clientX, e.clientY);
1444
+ }, {passive: false});
1445
+
1446
+ /* Space-drag. Space is ALSO how a keyboard user activates the focused button
1447
+ or switch, so it is only a pan modifier when focus is not on a control —
1448
+ otherwise adding this gesture would quietly break every rail control for
1449
+ anyone not using a mouse. */
1450
+ let spaceHeld = false, panning = null;
1451
+ const onAControl = () => {
1452
+ const a = document.activeElement;
1453
+ return !!a && a !== document.body && a !== cv &&
1454
+ a.matches('input, textarea, select, button, [role="switch"], [contenteditable]');
1455
+ };
1456
+ function setPanCursor(){
1457
+ cv.style.cursor = panning ? 'grabbing' : (spaceHeld ? 'grab' : '');
1458
+ }
1459
+ addEventListener('keydown', e => {
1460
+ if (e.code !== 'Space' || e.repeat || spaceHeld || onAControl()) return;
1461
+ e.preventDefault(); // or the page scrolls
1462
+ spaceHeld = true; setPanCursor();
1463
+ });
1464
+ addEventListener('keyup', e => {
1465
+ if (e.code !== 'Space') return;
1466
+ spaceHeld = false; endPan();
1467
+ });
1468
+ // Held space survives a cmd-tab away and would come back stuck on.
1469
+ addEventListener('blur', () => { spaceHeld = false; endPan(); });
1470
+
1471
+ function endPan(){
1472
+ if (panning && cv.hasPointerCapture && panning.id != null){
1473
+ try { $('#scroller').releasePointerCapture(panning.id); } catch (err) {}
1474
+ }
1475
+ panning = null; setPanCursor();
1476
+ }
1477
+
1478
+ /* Capture phase on the SCROLLER, which is an ancestor of the canvas: it runs
1479
+ before the canvas's own handlers, so stopPropagation keeps a pan from ever
1480
+ reaching hitTest and grabbing a corner. Registering capture on the canvas
1481
+ itself would race its onpointerdown property handler instead. */
1482
+ const sc0 = $('#scroller');
1483
+ /* Touching the canvas moves focus to it. Space is both "pan" and "activate the
1484
+ focused control", and after clicking any rail button focus STAYS on that
1485
+ button — so without this, holding space over the picture re-presses Fit or
1486
+ toggles Realism instead of panning. Focus follows the thing you last used,
1487
+ which is also what a keyboard user expects: they never clicked the canvas, so
1488
+ space keeps activating their focused control. */
1489
+ sc0.addEventListener('pointerdown', () => {
1490
+ if (document.activeElement && document.activeElement !== cv) cv.focus({preventScroll: true});
1491
+ }, true);
1492
+ sc0.addEventListener('pointerdown', e => {
1493
+ if (!spaceHeld || e.button !== 0) return;
1494
+ e.preventDefault(); e.stopPropagation();
1495
+ panning = {id: e.pointerId, x: e.clientX, y: e.clientY,
1496
+ sl: sc0.scrollLeft, st: sc0.scrollTop};
1497
+ try { sc0.setPointerCapture(e.pointerId); } catch (err) {}
1498
+ setPanCursor();
1499
+ }, true);
1500
+ sc0.addEventListener('pointermove', e => {
1501
+ if (!panning) { if (spaceHeld) e.stopPropagation(); return; }
1502
+ e.preventDefault(); e.stopPropagation();
1503
+ sc0.scrollLeft = panning.sl - (e.clientX - panning.x);
1504
+ sc0.scrollTop = panning.st - (e.clientY - panning.y);
1505
+ }, true);
1506
+ const stopPan = e => { if (!panning) return; e.stopPropagation(); endPan(); };
1507
+ sc0.addEventListener('pointerup', stopPan, true);
1508
+ sc0.addEventListener('pointercancel', stopPan, true);
1509
+
1510
+ /* ===== point at the screen =====================================================
1511
+ The detectors nearly always FIND the screen; what they cannot do is tell
1512
+ which of the regions they found is one. Measured on the two photographs that
1513
+ defeated every detector: the correct quad was already among the candidates
1514
+ both times. So this sends a single point and the server filters the candidate
1515
+ list by it — no new segmentation, and every existing guard (validation,
1516
+ corner refinement, the radius measurement) still applies to whatever wins. */
1517
+ let pointing = false;
1518
+ function setPointing(on){
1519
+ pointing = on;
1520
+ if (on) suggestPointing(false); // taken up; stop nagging
1521
+ $('#pointat').classList.toggle('armed', on);
1522
+ $('#scroller').classList.toggle('pointing', on);
1523
+ $('#pointat').textContent = on ? 'Click the screen…' : 'Point at screen';
1524
+ if (on){
1525
+ const s = $('#detSt'); s.className = 'status'; s.textContent = 'Click once inside the screen';
1526
+ }
1527
+ }
1528
+ function suggestPointing(on){ $('#pointat').classList.toggle('suggest', !!on); }
1529
+ $('#pointat').onclick = () => setPointing(!pointing);
1530
+ // Capture phase on the scroller, exactly as the space-pan is, and for the same
1531
+ // reason: this must never reach hitTest and start dragging a corner.
1532
+ $('#scroller').addEventListener('pointerdown', e => {
1533
+ if (!pointing || e.button !== 0 || spaceHeld) return;
1534
+ e.preventDefault(); e.stopPropagation();
1535
+ const p = pos(e);
1536
+ setPointing(false);
1537
+ pointAt(p[0], p[1]);
1538
+ }, true);
1539
+ async function pointAt(x, y){
1540
+ const s = $('#detSt'); s.className = 'status busy'; s.textContent = 'Reading that point…';
1541
+ try {
1542
+ const r = await api('/api/detect', {click: [x, y]});
1543
+ if (r.found){
1544
+ st.corners = r.corners; st.measured = r.corner_radius;
1545
+ s.className = 'status ok';
1546
+ s.textContent = `Screen found from your click (${r.method})`;
1547
+ if (!st.type) setType(r.type_guess || 'phone');
1548
+ if (r.corner_radius && r.corner_radius.confident) setFrac(r.corner_radius.frac_of_width, 'measured from the photo');
1549
+ else { applyPreset(); $('#radSt').textContent = 'Radius not measurable here — using the device preset.'; }
1550
+ markStale();
1551
+ } else {
1552
+ s.className = 'status warn';
1553
+ s.textContent = 'Nothing screen-shaped at that point — try again or place the edges by hand';
1554
+ s.title = r.message || '';
1555
+ }
1556
+ } catch (err) {
1557
+ s.className = 'status warn'; s.textContent = 'Could not read that point: ' + err.message;
1558
+ }
1559
+ pick = {kind:'edge', i:0};
1560
+ draw(); drawStrip(); autoPreview();
1561
+ }
1562
+ // Escape disarms, like every other transient mode on this page.
1563
+ addEventListener('keydown', e => { if (e.key === 'Escape' && pointing) setPointing(false); });
1564
+
1367
1565
  document.querySelectorAll('[data-jump]').forEach(b => {
1368
1566
  b.onclick = () => { if (!st.corners) return; const i=+b.dataset.jump;
1369
1567
  pick = {kind:'corner', i}; setCanvasZoom(Math.max(1, scale), st.corners[i]); drawStrip(); };
@@ -1384,8 +1582,13 @@ async function detect(){
1384
1582
  } else {
1385
1583
  st.corners = defaultQuad();
1386
1584
  s.className='status warn';
1387
- s.textContent = r.abstained ? 'Detector abstained place the edges by hand'
1388
- : 'No screen found place the edges by hand';
1585
+ // Naming the control is the whole fix. The page knows the detector just
1586
+ // failed, which is exactly the moment "Point at screen" is worth doing —
1587
+ // and the old wording sent people to the slowest remaining option without
1588
+ // mentioning the fast one. Reported 10 Sep: "I haven't noticed new button".
1589
+ s.textContent = r.abstained ? 'Detector abstained — try Point at screen, or place the edges by hand'
1590
+ : 'No screen found — try Point at screen, or place the edges by hand';
1591
+ suggestPointing(true);
1389
1592
  // The full reason is long and belongs on hover, not in a 40-character chip.
1390
1593
  s.title = r.message || '';
1391
1594
  if (!st.type) setType('phone'); applyPreset();
@@ -1527,6 +1730,8 @@ function moveEdge(C, i, mode, delta){
1527
1730
 
1528
1731
  cv.onpointerdown = e => {
1529
1732
  const p = pos(e), h = hitTest(p);
1733
+ // You have started placing edges by hand, so stop advertising the shortcut.
1734
+ if (h) suggestPointing(false);
1530
1735
  if (!h){ pick = null; draw(); drawStrip(); return; }
1531
1736
  drag = {...h, grab: p, start: st.corners.map(c => c.slice())};
1532
1737
  pick = {kind:h.kind, i:h.i};
@@ -1798,6 +2003,21 @@ function syncSlider(){ const r=$('#radius'); r.style.setProperty('--p', (r.value
1798
2003
 
1799
2004
  /* ===== preview / compare / save ===== */
1800
2005
  let outNat = 0, autoTimer = null;
2006
+ /* What the primary action is CALLED, derived from the source rather than
2007
+ patched. Six places used to set this label and they did not agree: one
2008
+ computed it from st.video, one upgraded 'Save' to 'Render' with no inverse,
2009
+ and the save handler reset it to 'Save' unconditionally -- so a video source
2010
+ could leave the button reading either word regardless of what clicking it
2011
+ would do. On a tool whose whole pitch is that the output is exact, a label
2012
+ that disagrees with its action is not a small thing. */
2013
+ function saveLabel(){ return st.video ? 'Render' : 'Save'; }
2014
+ /* The labels that mean "there is something to do". markStale's 'Preview first'
2015
+ and the in-flight 'Saving…' / progress text must survive a source change, so
2016
+ relabelling only touches a button that is currently offering the action. */
2017
+ function relabelSave(){
2018
+ const b = $('#save');
2019
+ if (b.textContent === 'Save' || b.textContent === 'Render') b.textContent = saveLabel();
2020
+ }
1801
2021
  function markStale(){
1802
2022
  const b = $('#save'); b.disabled = true; b.textContent = 'Preview first';
1803
2023
  // Exactly one lit action at a time — the Figma Button component says the
@@ -1834,7 +2054,7 @@ async function renderPreview(){
1834
2054
  im.onload = () => { outNat = im.naturalWidth; im.hidden = false; $('#outEmpty').style.display='none'; syncOut(); };
1835
2055
  im.src = `${fileURL(r.path)}&t=${Date.now()}`;
1836
2056
  s.textContent = `radius ${r.radius_px}px on the screenshot`;
1837
- const b = $('#save'); b.disabled = false; b.textContent = st.video ? 'Render' : 'Save';
2057
+ const b = $('#save'); b.disabled = false; b.textContent = saveLabel();
1838
2058
  b.classList.add('primary'); // Save is now the next action
1839
2059
  if (!previewHinted){ previewHinted = true;
1840
2060
  toast('info', 'Check the corners in Result, then <b>Save</b> — Save renders at full resolution.');
@@ -1883,8 +2103,10 @@ function syncVideoUI(){
1883
2103
  sl.max = Math.max(0, (v.frames || 1) - 1);
1884
2104
  sl.value = 0;
1885
2105
  $('#vframeLbl').textContent = `0 / ${Math.max(0,(v.frames||1)-1)}`;
1886
- if (b.textContent === 'Save') b.textContent = 'Render';
1887
2106
  }
2107
+ // Outside the `if (v)`: switching a video source back to a still has to put
2108
+ // the label back, and the one-way version above is why it did not.
2109
+ relabelSave();
1888
2110
  }
1889
2111
  $('#vframe').oninput = () => { $('#vframeLbl').textContent = `${$('#vframe').value} / ${$('#vframe').max}`; };
1890
2112
  $('#vframe').onchange = async () => {
@@ -1906,7 +2128,7 @@ async function renderVideo(){
1906
2128
  try{
1907
2129
  await api('/api/render', {corners: st.corners, radius_frac: radiusValue(), device: st.type,
1908
2130
  grade: gradeValue(), reflection: emisValue(), preset, fit_frame: +$('#vframe').value});
1909
- }catch(e){ toast('err','Could not start the render: '+e.message); b.disabled=false; b.textContent='Render'; return; }
2131
+ }catch(e){ toast('err','Could not start the render: '+e.message); b.disabled=false; b.textContent = saveLabel(); return; }
1910
2132
  // Poll rather than hold a request open: a clip is hundreds of frames and a
1911
2133
  // browser would time the request out long before the render finished.
1912
2134
  const tick = setInterval(async () => {
@@ -1919,13 +2141,13 @@ async function renderVideo(){
1919
2141
  if (d.total) $('#vframeLbl').textContent = `${d.done} / ${d.total} frames`;
1920
2142
  } else if (d.state === 'done'){
1921
2143
  clearInterval(tick);
1922
- b.textContent = 'Render'; b.disabled = false; b.classList.remove('primary');
2144
+ b.textContent = saveLabel(); b.disabled = false; b.classList.remove('primary');
1923
2145
  $('#vframeLbl').textContent = `${$('#vframe').value} / ${$('#vframe').max}`;
1924
2146
  toast('ok', `Rendered <code>${(d.output||'').split('/').pop()}</code> · ${d.done} frames to <code>${prettyDir(st.outDir||'')}</code>`);
1925
2147
  const imp = $('#imp'); imp.disabled = false; imp.textContent = 'Send to Claude'; imp.classList.add('primary');
1926
2148
  } else if (d.state === 'error'){
1927
2149
  clearInterval(tick);
1928
- b.textContent = 'Render'; b.disabled = false;
2150
+ b.textContent = saveLabel(); b.disabled = false;
1929
2151
  $('#vframeLbl').textContent = `${$('#vframe').value} / ${$('#vframe').max}`;
1930
2152
  toast('err', 'Render failed: ' + (d.message || 'unknown'));
1931
2153
  }
@@ -1937,7 +2159,7 @@ $('#save').onclick = async () => {
1937
2159
  const b = $('#save'); b.textContent = 'Saving…'; b.disabled = true;
1938
2160
  try{
1939
2161
  const r = await api('/api/save', {corners: st.corners, radius_frac: radiusValue(), device: st.type, grade: gradeValue(), reflection: emisValue()});
1940
- b.textContent = 'Save';
2162
+ b.textContent = saveLabel();
1941
2163
  // The real destination, not a hardcoded one: --out-dir means saves usually
1942
2164
  // land in the project folder now, and telling the user "~/Desktop" when
1943
2165
  // they aren't there is how you lose a file.
@@ -1946,7 +2168,7 @@ $('#save').onclick = async () => {
1946
2168
  imp.disabled = false; imp.textContent = 'Send to Claude';
1947
2169
  // Hand the accent on: the file exists, so sending it is what is next.
1948
2170
  imp.classList.add('primary'); b.classList.remove('primary');
1949
- }catch(e){ toast('err', 'Could not save: ' + e.message); b.textContent='Save'; }
2171
+ }catch(e){ toast('err', 'Could not save: ' + e.message); b.textContent = saveLabel(); }
1950
2172
  finally { b.disabled = false; }
1951
2173
  };
1952
2174
 
@@ -1985,6 +2207,18 @@ $('#imp').onclick = async () => {
1985
2207
  const s = await api('/api/state');
1986
2208
  st.presets = s.presets; $('#sess').textContent = s.session.split('/').pop();
1987
2209
  st.outDir = s.out_dir; st.home = s.home;
2210
+ // Empty stays empty: the server returns "" if it could not read the manifest,
2211
+ // and no badge is better than a wrong one in a bug report.
2212
+ // "v1.2.3" from an installed plugin; "v1.2.3 · dev a1b2c3d+" from a working
2213
+ // tree. (Deliberately a made-up number: a test asserts the CURRENT version
2214
+ // string appears nowhere in this file, and an example using the real one
2215
+ // would trip it -- as it just did.) The suffix is the whole point: a session snapshots its installed copy
2216
+ // at start and keeps it for life, so "the feature is missing" is far more
2217
+ // often the wrong build than a broken feature.
2218
+ if (s.version){
2219
+ $('#ver').textContent = 'v' + s.version + (s.build ? ' · ' + s.build : '');
2220
+ $('#ver').classList.toggle('dev', !!s.build);
2221
+ }
1988
2222
  // Say up front where Save will put things. With --out-dir this is usually the
1989
2223
  // project folder, and a designer should not have to press Save to find out.
1990
2224
  toast('info', `Fit the four edges, then Preview. Saves go to <code>${prettyDir(s.out_dir||'')}</code>.`,