screengraft 0.21.1 → 0.25.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
@@ -76,7 +76,7 @@ npx screengraft --out-dir ./mockups
76
76
  ```
77
77
 
78
78
  npm is a delivery mechanism here, not a claim about the language: the tool is
79
- Python and OpenCV, and `bin/screengraft.js` is a launcher. It installs nothing
79
+ Python and OpenCV, and `cli/screengraft.js` is a launcher. It installs nothing
80
80
  behind your back — if the engine is missing it prints the one command that
81
81
  builds it (`npx screengraft --install`) and exits.
82
82
 
@@ -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.25.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",
@@ -33,10 +33,10 @@
33
33
  },
34
34
  "type": "commonjs",
35
35
  "bin": {
36
- "screengraft": "bin/screengraft.js"
36
+ "screengraft": "cli/screengraft.js"
37
37
  },
38
38
  "files": [
39
- "bin/",
39
+ "cli/",
40
40
  "scripts/",
41
41
  "ui/",
42
42
  "mcp/",
@@ -61,6 +61,6 @@
61
61
  "linux"
62
62
  ],
63
63
  "scripts": {
64
- "start": "node bin/screengraft.js"
64
+ "start": "node cli/screengraft.js"
65
65
  }
66
66
  }
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/grade.py CHANGED
@@ -130,21 +130,46 @@ def match_light(photo: np.ndarray, warped: np.ndarray, mask: np.ndarray,
130
130
  return apply_light(warped, light_params(photo, warped, mask, strength))
131
131
 
132
132
 
133
+ GRAIN_GATE = 20.0 # grey levels: above this a residual is an edge, not grain
134
+ _MEDIAN_HP_GAIN = 0.909 # a 3x3 median high-pass absorbs this much of iid noise
135
+ # (measured, 5 seeds x sigma 1-5, spread < 0.3%)
136
+
137
+
133
138
  def measure_grain(photo: np.ndarray, ring: np.ndarray) -> float:
134
139
  """The photo's noise floor, in grey levels, measured where the screen isn't.
135
140
 
136
- High-pass with a 3x3 median (cheap, edge-preserving) and take the MEDIAN
137
- absolute deviation of the residual rather than its standard deviation: a
138
- bezel edge or a highlight inside the ring is a huge outlier, and a mean-based
139
- estimate would read the edge as noise and dump visible grain on the screen.
140
- 0.6745 converts MAD to a sigma for a normal distribution.
141
+ High-pass with a 3x3 median (cheap, edge-preserving), gate the outliers off,
142
+ then take the MEAN absolute deviation of what is left.
143
+
144
+ Why the mean and not the median: `photo` is uint8 and so is the
145
+ median blur, so the residual is integer-valued, and a median of integers is
146
+ an integer or a half. The old MAD/0.6745 could therefore only ever return
147
+ multiples of 1.4826 — and under one grey level it returned a flat 0, so a
148
+ lightly-noisy photograph got no grain at all. Two of the three surviving
149
+ reference photos measured exactly 0.0 that way while actually carrying 0.39
150
+ and 1.46. A mean over the same integers resolves continuously.
151
+
152
+ Robustness moves from the statistic to the GATE. A 3x3 median is
153
+ edge-preserving, so a clean step edge leaves a residual of exactly 0 and was
154
+ never the danger the old docstring guarded against; what does leak is fine
155
+ repeating texture and specks, whose residuals are large. Discarding
156
+ |resid| > GRAIN_GATE drops those and leaves the noise floor untouched:
157
+ identical for any gate in 8..40, unbiased out to sigma 4, and 5x below the
158
+ smallest texture residual that breaks it — a 9px-pitch line pattern reads
159
+ 27.0 ungated against a true 1.0.
160
+
161
+ 0.7979 is E|X|/sigma for a normal; _MEDIAN_HP_GAIN undoes the noise the
162
+ median filter itself absorbs.
141
163
  """
142
164
  g = cv2.cvtColor(photo, cv2.COLOR_BGR2GRAY)
143
165
  resid = g.astype(np.float32) - cv2.medianBlur(g, 3).astype(np.float32)
144
166
  px = resid[ring.astype(bool)]
145
167
  if px.size < 500:
146
168
  return 0.0
147
- return float(np.median(np.abs(px - np.median(px))) / 0.6745)
169
+ px = px[np.abs(px) <= GRAIN_GATE]
170
+ if px.size < 500:
171
+ return 0.0
172
+ return float(np.mean(np.abs(px - px.mean())) / 0.7979 / _MEDIAN_HP_GAIN)
148
173
 
149
174
 
150
175
  def add_grain(img: np.ndarray, mask: np.ndarray, sigma: float, seed: int = 0) -> np.ndarray:
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.25):** 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
@@ -101,12 +101,27 @@
101
101
 
102
102
  /* The shell IS the viewport. Nothing scrolls except the canvas, so the
103
103
  fitting surface can never be pushed off-screen by finished work. */
104
- .app{height:100%;display:grid;grid-template-rows:auto minmax(0,1fr) auto;min-height:0;
104
+ /* The ROWS were given minmax(0,1fr) and the column was left implicit,
105
+ which means `auto` — and an auto track is floored at its content's
106
+ min-content width, so the whole app grew to 896px inside a 742px viewport
107
+ and the page scrolled sideways. Same discipline, same axis: a track that
108
+ may not exceed its share needs the 0 minimum stated. This is the root of
109
+ the overflow; the topbar and #stage reported with it were victims, sized by
110
+ a container that had already grown. */
111
+ .app{height:100%;display:grid;grid-template-rows:auto minmax(0,1fr) auto;
112
+ grid-template-columns:minmax(0,1fr);min-height:0;
105
113
  padding-bottom:32px;background:var(--bg)}
106
114
 
107
115
  .topbar{display:flex;align-items:center;gap:8px;padding:0 12px 0 var(--s4);height:56px;
108
116
  background:var(--card);border-bottom:1px solid var(--line);
109
117
  box-shadow:inset 0 1px 0 rgba(255,255,255,.04), 0 1px 0 rgba(0,0,0,.25)}
118
+ /* A nowrap flex row whose children keep min-width:auto cannot shrink
119
+ below their content, so at 742px Save and Send to Claude sat at x=750..885
120
+ — outside the viewport and unclickable. The chips carry a filename and are
121
+ the part that should give; the actions are fixed-size and must not. */
122
+ .topbar > *{min-width:0}
123
+ .tb-chips{overflow:hidden}
124
+ .tb-actions{flex:none}
110
125
  .tb-left{display:flex;align-items:center;gap:var(--s9)}
111
126
  .tb-chips{display:flex;align-items:center;gap:var(--s2)}
112
127
  .tb-arrow{color:var(--mute)}
@@ -302,7 +317,12 @@
302
317
  .stage > .rail{grid-column:2;grid-row:1 / span 2}
303
318
  }
304
319
  #types{flex-wrap:nowrap}
320
+ /* The same omission one level down: rows constrained, column implicit.
321
+ min-width:0 lets the PANE shrink; it does nothing for the auto track
322
+ inside it, which was still sizing to the header's min-content — 798px
323
+ wide in a 398px column. */
305
324
  .pane{min-width:0;min-height:0;display:grid;grid-template-rows:auto minmax(0,1fr);
325
+ grid-template-columns:minmax(0,1fr);
306
326
  position:relative; /* the frame the corner bar is anchored to */
307
327
  border-right:1px solid var(--line)}
308
328
  .panehead{display:flex;align-items:center;gap:var(--s2);padding:var(--s2) var(--s4);background:var(--card);
@@ -328,7 +348,11 @@
328
348
  .scroller{overflow:auto;background:var(--well);display:grid;
329
349
  min-height:0;min-width:0;
330
350
  box-shadow:inset 0 1px 3px rgba(0,0,0,.5)}
331
- canvas#c{display:block;touch-action:none;cursor:crosshair;margin:auto}
351
+ /* tabindex -1, not 0: the canvas is not a tab stop — it takes focus only when
352
+ you actually click it, which is what makes space mean PAN rather than
353
+ re-pressing whichever rail button you last used. No ring, because the
354
+ focus is a side effect of clicking, never of tabbing. */
355
+ canvas#c{display:block;touch-action:none;cursor:crosshair;margin:auto;outline:none}
332
356
 
333
357
  /* Corner-jump bar floating over the canvas (Figma 16:121). position:sticky
334
358
  inside the scroller would drift with pan; fixed-to-the-pane absolute
@@ -561,6 +585,34 @@
561
585
  pixel to the band's height: 16+28+8+130+16 = 198 exactly. */
562
586
  box-shadow:inset 0 1px 0 var(--line), inset 0 2px 0 rgba(255,255,255,.04)}
563
587
  .dock.hidden{display:none}
588
+ /* Version badge. Bottom-right, fixed, and deliberately the quietest thing on
589
+ screen: --faint is the captions token (measured 3.50:1, the 1.4.3 floor for
590
+ incidental text), 11px with the same tracking as every other label.
591
+ pointer-events:none so it can never eat a click meant for what is under it,
592
+ and it is under no circumstances the accent -- that means "the next action"
593
+ and this is not an action at all.
594
+ Sits in the dock's own 16px padding band rather than over the rectified
595
+ strip: the work surface keeps zero decoration, which is the one rule the
596
+ canvas has. */
597
+ .ver{position:fixed;right:var(--s3);bottom:2px;z-index:5;pointer-events:none;
598
+ font-size:11px;letter-spacing:.06em;color:var(--faint);
599
+ font-variant-numeric:tabular-nums}
600
+ /* A dev build steps up from --faint to --mute (5.86:1). Not the accent, which
601
+ means "the next action" -- this is a fact about what you are running, not
602
+ something to do. Brighter than the plain version because "you are not
603
+ testing what you think you are testing" is worth noticing. */
604
+ .ver.dev{color:var(--mute)}
605
+ /* Armed: the canvas is waiting for one click. The button holds the accent
606
+ because it IS the next action while armed, which is the accent's one job. */
607
+ /* Suggested (detection just failed) and armed (waiting for your click).
608
+ Deliberately NOT the accent: Save carries that, and the visual language
609
+ allows exactly one accent-filled control on screen — measured and violated
610
+ by a first version of this, which put a second one here while Save was
611
+ already live. Emphasis without colour is what the raise ladder is for, and
612
+ it is the same treatment a selected chip gets. */
613
+ #pointat.suggest{background:var(--raise-hi);border-color:var(--edge-hi);color:var(--ink)}
614
+ #pointat.armed{background:var(--raise-highest);border-color:var(--edge-hi);color:var(--ink)}
615
+ .scroller.pointing canvas#c{cursor:crosshair}
564
616
  /* Figma 8:29 is a 28px header holding 24px buttons — the header does not
565
617
  shrink to hug them. */
566
618
  .striphead{display:flex;align-items:center;gap:8px;font-size:12px;min-height:28px}
@@ -719,6 +771,7 @@
719
771
  <span class="lbl">Fit</span>
720
772
  <span class="stepper"><button id="czOut">&minus;</button><button id="czIn">+</button></span>
721
773
  <span id="czSt" class="zoomval"></span>
774
+ <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
775
  </div>
723
776
  <div class="ph-left-2">
724
777
  <button id="czFit">Fit</button>
@@ -728,7 +781,7 @@
728
781
  <span class="status" id="detSt"></span>
729
782
  </div>
730
783
  <div class="scroller" id="scroller">
731
- <canvas id="c"></canvas>
784
+ <canvas id="c" tabindex="-1"></canvas>
732
785
  </div>
733
786
  <!-- Corner jump. It is a SIBLING of the scroller, not a child: anything
734
787
  positioned inside a scroll container scrolls with the content, so
@@ -740,6 +793,7 @@
740
793
  <span class="cvbar-actions">
741
794
  <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
795
  <button class="sm" id="redetect">Re-detect</button>
796
+ <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
797
  <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
798
  </span>
745
799
  </div>
@@ -796,7 +850,7 @@
796
850
  <div class="sect-top">
797
851
  <div class="sect-left">
798
852
  <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>
853
+ <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
854
  </div>
801
855
  <button class="switch" id="gradeBtn" role="switch" aria-checked="true" aria-label="Realism">
802
856
  <span class="sw-track"><span class="sw-handle"></span><span class="sw-grip"></span></span>
@@ -839,7 +893,12 @@
839
893
  </div>
840
894
  <div class="sect" id="secKeys" data-on="1">
841
895
  <div class="sect-top"><div class="sect-left"><h4>Keys</h4></div></div>
896
+ <!-- Two lines, split by what they act on: the QUAD you are fitting, and
897
+ the VIEW you are fitting it in. Grouping them by that rather than
898
+ by modifier is what makes the list scannable while you are mid-fit
899
+ and looking for one of them. -->
842
900
  <p class="hint"><kbd>&larr;&uarr;&rarr;&darr;</kbd> nudge 1px · <kbd>&#8679;</kbd>+arrow 10px · <kbd>Tab</kbd> next edge</p>
901
+ <p class="hint"><kbd>&#8984;</kbd>+scroll zoom to pointer · <kbd>Space</kbd>+drag pan · scroll pans</p>
843
902
  </div>
844
903
  </aside>
845
904
  </div>
@@ -856,6 +915,8 @@
856
915
  </footer>
857
916
  </div>
858
917
 
918
+ <div class="ver" id="ver"></div>
919
+
859
920
  <div class="floatloupe" id="floatLoupe">
860
921
  <canvas id="stripF" width="680" height="208"></canvas>
861
922
  <div class="cap" id="stripFCap"></div>
@@ -1364,6 +1425,163 @@ $('#czFit').onclick = () => { computeFit(); setCanvasZoom(fitScale); };
1364
1425
  $('#cz100').onclick = () => setCanvasZoom(1);
1365
1426
  $('#czIn').onclick = () => setCanvasZoom(scale*1.5);
1366
1427
  $('#czOut').onclick = () => setCanvasZoom(scale/1.5);
1428
+ /* ===== canvas navigation: zoom to the pointer, space-drag to pan ==========
1429
+ The conventions every graphics tool already uses, so not having them means
1430
+ the muscle memory misfires against a canvas that otherwise behaves like one.
1431
+
1432
+ Panning IS scrolling here — the canvas is sized in real pixels inside
1433
+ #scroller — which is why the Result pane keeps up for free: it already
1434
+ mirrors this element's scroll. */
1435
+
1436
+ /* Zoom about a screen point rather than the canvas centre.
1437
+
1438
+ Measured rather than computed: after the resize, ask the DOM where the
1439
+ pinned image point actually landed and scroll by the difference. The canvas
1440
+ is `margin:auto` in a grid, so while it is smaller than the pane it sits
1441
+ centred with a margin that changes as it grows — arithmetic that predicts
1442
+ the scroll offset has to model that margin, and measuring does not. When the
1443
+ whole image fits there is nothing to scroll and the pin cannot hold, which
1444
+ is correct: nothing is moving off screen. */
1445
+ function zoomAt(z, clientX, clientY){
1446
+ if (!img.naturalWidth) return;
1447
+ const before = pos({clientX, clientY});
1448
+ setCanvasZoom(z);
1449
+ const sc = $('#scroller'), r = cv.getBoundingClientRect();
1450
+ sc.scrollLeft += r.left + before[0] * scale * (r.width / cv.width) - clientX;
1451
+ sc.scrollTop += r.top + before[1] * scale * (r.height / cv.height) - clientY;
1452
+ }
1453
+
1454
+ /* A trackpad pinch arrives as a wheel event with ctrlKey synthesised true, and
1455
+ a real cmd+scroll arrives with metaKey — both mean "zoom", so both are taken.
1456
+ The listener MUST be {passive:false} or preventDefault is ignored and the
1457
+ browser zooms the whole page instead of the picture. deltaMode is normalised
1458
+ because a wheel mouse reports lines (1) or pages (2), not pixels. */
1459
+ $('#scroller').addEventListener('wheel', e => {
1460
+ if (!(e.metaKey || e.ctrlKey)) return; // plain scroll still pans
1461
+ e.preventDefault();
1462
+ const dy = e.deltaY * (e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? 400 : 1);
1463
+ zoomAt(scale * Math.exp(-dy * 0.0025), e.clientX, e.clientY);
1464
+ }, {passive: false});
1465
+
1466
+ /* Space-drag. Space is ALSO how a keyboard user activates the focused button
1467
+ or switch, so it is only a pan modifier when focus is not on a control —
1468
+ otherwise adding this gesture would quietly break every rail control for
1469
+ anyone not using a mouse. */
1470
+ let spaceHeld = false, panning = null;
1471
+ const onAControl = () => {
1472
+ const a = document.activeElement;
1473
+ return !!a && a !== document.body && a !== cv &&
1474
+ a.matches('input, textarea, select, button, [role="switch"], [contenteditable]');
1475
+ };
1476
+ function setPanCursor(){
1477
+ cv.style.cursor = panning ? 'grabbing' : (spaceHeld ? 'grab' : '');
1478
+ }
1479
+ addEventListener('keydown', e => {
1480
+ if (e.code !== 'Space' || e.repeat || spaceHeld || onAControl()) return;
1481
+ e.preventDefault(); // or the page scrolls
1482
+ spaceHeld = true; setPanCursor();
1483
+ });
1484
+ addEventListener('keyup', e => {
1485
+ if (e.code !== 'Space') return;
1486
+ spaceHeld = false; endPan();
1487
+ });
1488
+ // Held space survives a cmd-tab away and would come back stuck on.
1489
+ addEventListener('blur', () => { spaceHeld = false; endPan(); });
1490
+
1491
+ function endPan(){
1492
+ if (panning && cv.hasPointerCapture && panning.id != null){
1493
+ try { $('#scroller').releasePointerCapture(panning.id); } catch (err) {}
1494
+ }
1495
+ panning = null; setPanCursor();
1496
+ }
1497
+
1498
+ /* Capture phase on the SCROLLER, which is an ancestor of the canvas: it runs
1499
+ before the canvas's own handlers, so stopPropagation keeps a pan from ever
1500
+ reaching hitTest and grabbing a corner. Registering capture on the canvas
1501
+ itself would race its onpointerdown property handler instead. */
1502
+ const sc0 = $('#scroller');
1503
+ /* Touching the canvas moves focus to it. Space is both "pan" and "activate the
1504
+ focused control", and after clicking any rail button focus STAYS on that
1505
+ button — so without this, holding space over the picture re-presses Fit or
1506
+ toggles Realism instead of panning. Focus follows the thing you last used,
1507
+ which is also what a keyboard user expects: they never clicked the canvas, so
1508
+ space keeps activating their focused control. */
1509
+ sc0.addEventListener('pointerdown', () => {
1510
+ if (document.activeElement && document.activeElement !== cv) cv.focus({preventScroll: true});
1511
+ }, true);
1512
+ sc0.addEventListener('pointerdown', e => {
1513
+ if (!spaceHeld || e.button !== 0) return;
1514
+ e.preventDefault(); e.stopPropagation();
1515
+ panning = {id: e.pointerId, x: e.clientX, y: e.clientY,
1516
+ sl: sc0.scrollLeft, st: sc0.scrollTop};
1517
+ try { sc0.setPointerCapture(e.pointerId); } catch (err) {}
1518
+ setPanCursor();
1519
+ }, true);
1520
+ sc0.addEventListener('pointermove', e => {
1521
+ if (!panning) { if (spaceHeld) e.stopPropagation(); return; }
1522
+ e.preventDefault(); e.stopPropagation();
1523
+ sc0.scrollLeft = panning.sl - (e.clientX - panning.x);
1524
+ sc0.scrollTop = panning.st - (e.clientY - panning.y);
1525
+ }, true);
1526
+ const stopPan = e => { if (!panning) return; e.stopPropagation(); endPan(); };
1527
+ sc0.addEventListener('pointerup', stopPan, true);
1528
+ sc0.addEventListener('pointercancel', stopPan, true);
1529
+
1530
+ /* ===== point at the screen =====================================================
1531
+ The detectors nearly always FIND the screen; what they cannot do is tell
1532
+ which of the regions they found is one. Measured on the two photographs that
1533
+ defeated every detector: the correct quad was already among the candidates
1534
+ both times. So this sends a single point and the server filters the candidate
1535
+ list by it — no new segmentation, and every existing guard (validation,
1536
+ corner refinement, the radius measurement) still applies to whatever wins. */
1537
+ let pointing = false;
1538
+ function setPointing(on){
1539
+ pointing = on;
1540
+ if (on) suggestPointing(false); // taken up; stop nagging
1541
+ $('#pointat').classList.toggle('armed', on);
1542
+ $('#scroller').classList.toggle('pointing', on);
1543
+ $('#pointat').textContent = on ? 'Click the screen…' : 'Point at screen';
1544
+ if (on){
1545
+ const s = $('#detSt'); s.className = 'status'; s.textContent = 'Click once inside the screen';
1546
+ }
1547
+ }
1548
+ function suggestPointing(on){ $('#pointat').classList.toggle('suggest', !!on); }
1549
+ $('#pointat').onclick = () => setPointing(!pointing);
1550
+ // Capture phase on the scroller, exactly as the space-pan is, and for the same
1551
+ // reason: this must never reach hitTest and start dragging a corner.
1552
+ $('#scroller').addEventListener('pointerdown', e => {
1553
+ if (!pointing || e.button !== 0 || spaceHeld) return;
1554
+ e.preventDefault(); e.stopPropagation();
1555
+ const p = pos(e);
1556
+ setPointing(false);
1557
+ pointAt(p[0], p[1]);
1558
+ }, true);
1559
+ async function pointAt(x, y){
1560
+ const s = $('#detSt'); s.className = 'status busy'; s.textContent = 'Reading that point…';
1561
+ try {
1562
+ const r = await api('/api/detect', {click: [x, y]});
1563
+ if (r.found){
1564
+ st.corners = r.corners; st.measured = r.corner_radius;
1565
+ s.className = 'status ok';
1566
+ s.textContent = `Screen found from your click (${r.method})`;
1567
+ if (!st.type) setType(r.type_guess || 'phone');
1568
+ if (r.corner_radius && r.corner_radius.confident) setFrac(r.corner_radius.frac_of_width, 'measured from the photo');
1569
+ else { applyPreset(); $('#radSt').textContent = 'Radius not measurable here — using the device preset.'; }
1570
+ markStale();
1571
+ } else {
1572
+ s.className = 'status warn';
1573
+ s.textContent = 'Nothing screen-shaped at that point — try again or place the edges by hand';
1574
+ s.title = r.message || '';
1575
+ }
1576
+ } catch (err) {
1577
+ s.className = 'status warn'; s.textContent = 'Could not read that point: ' + err.message;
1578
+ }
1579
+ pick = {kind:'edge', i:0};
1580
+ draw(); drawStrip(); autoPreview();
1581
+ }
1582
+ // Escape disarms, like every other transient mode on this page.
1583
+ addEventListener('keydown', e => { if (e.key === 'Escape' && pointing) setPointing(false); });
1584
+
1367
1585
  document.querySelectorAll('[data-jump]').forEach(b => {
1368
1586
  b.onclick = () => { if (!st.corners) return; const i=+b.dataset.jump;
1369
1587
  pick = {kind:'corner', i}; setCanvasZoom(Math.max(1, scale), st.corners[i]); drawStrip(); };
@@ -1384,8 +1602,13 @@ async function detect(){
1384
1602
  } else {
1385
1603
  st.corners = defaultQuad();
1386
1604
  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';
1605
+ // Naming the control is the whole fix. The page knows the detector just
1606
+ // failed, which is exactly the moment "Point at screen" is worth doing —
1607
+ // and the old wording sent people to the slowest remaining option without
1608
+ // mentioning the fast one. Reported 10 Sep: "I haven't noticed new button".
1609
+ s.textContent = r.abstained ? 'Detector abstained — try Point at screen, or place the edges by hand'
1610
+ : 'No screen found — try Point at screen, or place the edges by hand';
1611
+ suggestPointing(true);
1389
1612
  // The full reason is long and belongs on hover, not in a 40-character chip.
1390
1613
  s.title = r.message || '';
1391
1614
  if (!st.type) setType('phone'); applyPreset();
@@ -1527,6 +1750,8 @@ function moveEdge(C, i, mode, delta){
1527
1750
 
1528
1751
  cv.onpointerdown = e => {
1529
1752
  const p = pos(e), h = hitTest(p);
1753
+ // You have started placing edges by hand, so stop advertising the shortcut.
1754
+ if (h) suggestPointing(false);
1530
1755
  if (!h){ pick = null; draw(); drawStrip(); return; }
1531
1756
  drag = {...h, grab: p, start: st.corners.map(c => c.slice())};
1532
1757
  pick = {kind:h.kind, i:h.i};
@@ -1798,6 +2023,21 @@ function syncSlider(){ const r=$('#radius'); r.style.setProperty('--p', (r.value
1798
2023
 
1799
2024
  /* ===== preview / compare / save ===== */
1800
2025
  let outNat = 0, autoTimer = null;
2026
+ /* What the primary action is CALLED, derived from the source rather than
2027
+ patched. Six places used to set this label and they did not agree: one
2028
+ computed it from st.video, one upgraded 'Save' to 'Render' with no inverse,
2029
+ and the save handler reset it to 'Save' unconditionally -- so a video source
2030
+ could leave the button reading either word regardless of what clicking it
2031
+ would do. On a tool whose whole pitch is that the output is exact, a label
2032
+ that disagrees with its action is not a small thing. */
2033
+ function saveLabel(){ return st.video ? 'Render' : 'Save'; }
2034
+ /* The labels that mean "there is something to do". markStale's 'Preview first'
2035
+ and the in-flight 'Saving…' / progress text must survive a source change, so
2036
+ relabelling only touches a button that is currently offering the action. */
2037
+ function relabelSave(){
2038
+ const b = $('#save');
2039
+ if (b.textContent === 'Save' || b.textContent === 'Render') b.textContent = saveLabel();
2040
+ }
1801
2041
  function markStale(){
1802
2042
  const b = $('#save'); b.disabled = true; b.textContent = 'Preview first';
1803
2043
  // Exactly one lit action at a time — the Figma Button component says the
@@ -1834,7 +2074,7 @@ async function renderPreview(){
1834
2074
  im.onload = () => { outNat = im.naturalWidth; im.hidden = false; $('#outEmpty').style.display='none'; syncOut(); };
1835
2075
  im.src = `${fileURL(r.path)}&t=${Date.now()}`;
1836
2076
  s.textContent = `radius ${r.radius_px}px on the screenshot`;
1837
- const b = $('#save'); b.disabled = false; b.textContent = st.video ? 'Render' : 'Save';
2077
+ const b = $('#save'); b.disabled = false; b.textContent = saveLabel();
1838
2078
  b.classList.add('primary'); // Save is now the next action
1839
2079
  if (!previewHinted){ previewHinted = true;
1840
2080
  toast('info', 'Check the corners in Result, then <b>Save</b> — Save renders at full resolution.');
@@ -1883,8 +2123,10 @@ function syncVideoUI(){
1883
2123
  sl.max = Math.max(0, (v.frames || 1) - 1);
1884
2124
  sl.value = 0;
1885
2125
  $('#vframeLbl').textContent = `0 / ${Math.max(0,(v.frames||1)-1)}`;
1886
- if (b.textContent === 'Save') b.textContent = 'Render';
1887
2126
  }
2127
+ // Outside the `if (v)`: switching a video source back to a still has to put
2128
+ // the label back, and the one-way version above is why it did not.
2129
+ relabelSave();
1888
2130
  }
1889
2131
  $('#vframe').oninput = () => { $('#vframeLbl').textContent = `${$('#vframe').value} / ${$('#vframe').max}`; };
1890
2132
  $('#vframe').onchange = async () => {
@@ -1906,7 +2148,7 @@ async function renderVideo(){
1906
2148
  try{
1907
2149
  await api('/api/render', {corners: st.corners, radius_frac: radiusValue(), device: st.type,
1908
2150
  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; }
2151
+ }catch(e){ toast('err','Could not start the render: '+e.message); b.disabled=false; b.textContent = saveLabel(); return; }
1910
2152
  // Poll rather than hold a request open: a clip is hundreds of frames and a
1911
2153
  // browser would time the request out long before the render finished.
1912
2154
  const tick = setInterval(async () => {
@@ -1919,13 +2161,13 @@ async function renderVideo(){
1919
2161
  if (d.total) $('#vframeLbl').textContent = `${d.done} / ${d.total} frames`;
1920
2162
  } else if (d.state === 'done'){
1921
2163
  clearInterval(tick);
1922
- b.textContent = 'Render'; b.disabled = false; b.classList.remove('primary');
2164
+ b.textContent = saveLabel(); b.disabled = false; b.classList.remove('primary');
1923
2165
  $('#vframeLbl').textContent = `${$('#vframe').value} / ${$('#vframe').max}`;
1924
2166
  toast('ok', `Rendered <code>${(d.output||'').split('/').pop()}</code> · ${d.done} frames to <code>${prettyDir(st.outDir||'')}</code>`);
1925
2167
  const imp = $('#imp'); imp.disabled = false; imp.textContent = 'Send to Claude'; imp.classList.add('primary');
1926
2168
  } else if (d.state === 'error'){
1927
2169
  clearInterval(tick);
1928
- b.textContent = 'Render'; b.disabled = false;
2170
+ b.textContent = saveLabel(); b.disabled = false;
1929
2171
  $('#vframeLbl').textContent = `${$('#vframe').value} / ${$('#vframe').max}`;
1930
2172
  toast('err', 'Render failed: ' + (d.message || 'unknown'));
1931
2173
  }
@@ -1937,7 +2179,7 @@ $('#save').onclick = async () => {
1937
2179
  const b = $('#save'); b.textContent = 'Saving…'; b.disabled = true;
1938
2180
  try{
1939
2181
  const r = await api('/api/save', {corners: st.corners, radius_frac: radiusValue(), device: st.type, grade: gradeValue(), reflection: emisValue()});
1940
- b.textContent = 'Save';
2182
+ b.textContent = saveLabel();
1941
2183
  // The real destination, not a hardcoded one: --out-dir means saves usually
1942
2184
  // land in the project folder now, and telling the user "~/Desktop" when
1943
2185
  // they aren't there is how you lose a file.
@@ -1946,7 +2188,7 @@ $('#save').onclick = async () => {
1946
2188
  imp.disabled = false; imp.textContent = 'Send to Claude';
1947
2189
  // Hand the accent on: the file exists, so sending it is what is next.
1948
2190
  imp.classList.add('primary'); b.classList.remove('primary');
1949
- }catch(e){ toast('err', 'Could not save: ' + e.message); b.textContent='Save'; }
2191
+ }catch(e){ toast('err', 'Could not save: ' + e.message); b.textContent = saveLabel(); }
1950
2192
  finally { b.disabled = false; }
1951
2193
  };
1952
2194
 
@@ -1985,6 +2227,18 @@ $('#imp').onclick = async () => {
1985
2227
  const s = await api('/api/state');
1986
2228
  st.presets = s.presets; $('#sess').textContent = s.session.split('/').pop();
1987
2229
  st.outDir = s.out_dir; st.home = s.home;
2230
+ // Empty stays empty: the server returns "" if it could not read the manifest,
2231
+ // and no badge is better than a wrong one in a bug report.
2232
+ // "v1.2.3" from an installed plugin; "v1.2.3 · dev a1b2c3d+" from a working
2233
+ // tree. (Deliberately a made-up number: a test asserts the CURRENT version
2234
+ // string appears nowhere in this file, and an example using the real one
2235
+ // would trip it -- as it just did.) The suffix is the whole point: a session snapshots its installed copy
2236
+ // at start and keeps it for life, so "the feature is missing" is far more
2237
+ // often the wrong build than a broken feature.
2238
+ if (s.version){
2239
+ $('#ver').textContent = 'v' + s.version + (s.build ? ' · ' + s.build : '');
2240
+ $('#ver').classList.toggle('dev', !!s.build);
2241
+ }
1988
2242
  // Say up front where Save will put things. With --out-dir this is usually the
1989
2243
  // project folder, and a designer should not have to press Save to find out.
1990
2244
  toast('info', `Fit the four edges, then Preview. Saves go to <code>${prettyDir(s.out_dir||'')}</code>.`,
File without changes