screengraft 0.21.0 → 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,
@@ -146,6 +165,22 @@ the background is itself neutral (a pale tiled floor, a plain wall) — you plac
146
165
  the edges by hand there. And a prototype recording has no motion blur, so a very
147
166
  fast scroll will strobe; that is a property of the source, not of the composite.
148
167
 
168
+ ## Changelog
169
+
170
+ Every release is described in [CHANGELOG.md](CHANGELOG.md), with the
171
+ measurements that drove it.
172
+
173
+ ## Support
174
+
175
+ Bugs and photographs that defeat the detector belong in
176
+ [Issues](https://github.com/seq000/screengraft/issues) — the bug template asks
177
+ first for the `result.json` sidecar, because it reproduces any composite
178
+ exactly. Ideas and "can it do X" go in
179
+ [Discussions](https://github.com/seq000/screengraft/discussions).
180
+
181
+ If the photograph or the UI is confidential — a client shot, something
182
+ unreleased — email **screengraft@fraczyk.design** instead of posting it.
183
+
149
184
  ## Contributing
150
185
 
151
186
  See [CONTRIBUTING.md](CONTRIBUTING.md). The short version: there are tests, they
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "screengraft",
3
- "version": "0.21.0",
4
- "description": "Put a UI screenshot or screen recording onto a photographed device screen with the perspective exactly right a homography you confirm by hand, not a generative guess.",
3
+ "version": "0.24.1",
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",
7
7
  "device-frame",
@@ -17,7 +17,10 @@
17
17
  "figma"
18
18
  ],
19
19
  "homepage": "https://github.com/seq000/screengraft#readme",
20
- "bugs": "https://github.com/seq000/screengraft/issues",
20
+ "bugs": {
21
+ "url": "https://github.com/seq000/screengraft/issues",
22
+ "email": "screengraft@fraczyk.design"
23
+ },
21
24
  "repository": {
22
25
  "type": "git",
23
26
  "url": "git+https://github.com/seq000/screengraft.git"
@@ -25,7 +28,8 @@
25
28
  "license": "MIT",
26
29
  "author": {
27
30
  "name": "Dariusz Fraczyk",
28
- "url": "https://fraczyk.design"
31
+ "url": "https://fraczyk.design",
32
+ "email": "screengraft@fraczyk.design"
29
33
  },
30
34
  "type": "commonjs",
31
35
  "bin": {
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))
@@ -183,6 +336,13 @@ def _is_video(path: str) -> bool:
183
336
  return str(path).lower().endswith(VIDEO_EXT)
184
337
 
185
338
 
339
+ def _fit_frame() -> int:
340
+ try:
341
+ return int(SESSION.state.get("fit_frame") or 0)
342
+ except (TypeError, ValueError):
343
+ return 0
344
+
345
+
186
346
  def _read_source(path: str):
187
347
  """Read the screen source, which may be a still OR a video.
188
348
 
@@ -197,13 +357,27 @@ def _read_source(path: str):
197
357
  im, rp = _read_image(real)
198
358
  return im, rp, {"video": False}
199
359
  n, fps, vw, vh = W.probe_video(real)
200
- frame = W.read_frame_at(real, 0)
360
+ # The frame the designer scrubbed to, NOT frame 0. Preview and Save read the
361
+ # source through here, so reading frame 0 unconditionally made the scrubber
362
+ # look decorative: it moved the thumbnail and the composite never changed
363
+ # (reported 9 Sep 2026). The fitted frame is session state for exactly this
364
+ # reason — more than one route needs it.
365
+ frame = W.read_frame_at(real, _fit_frame())
201
366
  # Report the encoder's absence HERE, when the clip is chosen, rather than
202
367
  # letting the render fail at the end of the job. Someone who installed
203
368
  # screengraft before video existed has a working venv with no ffmpeg in it,
204
369
  # and nothing else would tell them until they had done all the fitting.
370
+ # A poster for the chip. The chip used to be handed the .mov path directly,
371
+ # and an <img> cannot render a video, so the thumbnail was silently blank for
372
+ # every clip. It is always FRAME 0 and never follows the scrubber: at chip
373
+ # size one frame looks like any other, so redrawing it would be movement
374
+ # without information.
375
+ poster = os.path.join(SESSION.dir,
376
+ "poster-" + os.path.splitext(os.path.basename(real))[0] + ".jpg")
377
+ if not os.path.exists(poster):
378
+ cv2.imwrite(poster, W.read_frame_at(real, 0), [cv2.IMWRITE_JPEG_QUALITY, 82])
205
379
  return frame, real, {"video": True, "frames": n, "fps": fps, "size": [vw, vh],
206
- "ffmpeg": _have_ffmpeg()}
380
+ "ffmpeg": _have_ffmpeg(), "poster": poster}
207
381
 
208
382
 
209
383
  # Render progress, read by /api/render_status. A ten-second clip is a few
@@ -216,7 +390,20 @@ RENDER_LOCK = threading.Lock()
216
390
 
217
391
 
218
392
  def _render_worker(photo, video_path, corners, dest, radius_px, gr, grain, preset, fit_frame,
219
- 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
+ """
220
407
  def progress(done, total):
221
408
  with RENDER_LOCK:
222
409
  RENDER["done"], RENDER["total"] = done, total
@@ -227,6 +414,13 @@ def _render_worker(photo, video_path, corners, dest, radius_px, gr, grain, prese
227
414
  blend=blend,
228
415
  reflection=(W.DEFAULT_REFLECTION if reflection is None
229
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)
230
424
  with RENDER_LOCK:
231
425
  RENDER.update(state="done", output=dest, info=info,
232
426
  done=info["frames"], total=info["frames"], message=None)
@@ -311,7 +505,8 @@ class Handler(BaseHTTPRequestHandler):
311
505
  return self._file(UI_HTML, "text/html; charset=utf-8")
312
506
  if u.path == "/api/state":
313
507
  return self._json({**SESSION.state, "session": SESSION.dir, "out_dir": OUT_DIR,
314
- "home": HOME, "presets": PRESETS})
508
+ "home": HOME, "presets": PRESETS, "version": VERSION,
509
+ "build": BUILD})
315
510
  if u.path == "/api/recent":
316
511
  items = S.scan(days=int(q.get("days", ["14"])[0]), limit=int(q.get("limit", ["40"])[0]))
317
512
  for it in items:
@@ -350,6 +545,14 @@ class Handler(BaseHTTPRequestHandler):
350
545
  return self._json({"error": "no such route"}, 404)
351
546
  except (PermissionError, FileNotFoundError, KeyError, ValueError) as e:
352
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)
353
556
 
354
557
  # ---- POST ----
355
558
  def do_POST(self):
@@ -364,6 +567,7 @@ class Handler(BaseHTTPRequestHandler):
364
567
  f.write(self._body())
365
568
  # A video is only ever a screen source; a photo must be a still.
366
569
  if role == "screenshot":
570
+ SESSION.update(fit_frame=0)
367
571
  im, real, meta = _read_source(dest)
368
572
  else:
369
573
  im, real = _read_image(dest)
@@ -376,6 +580,7 @@ class Handler(BaseHTTPRequestHandler):
376
580
  if u.path == "/api/use":
377
581
  role = b["role"]
378
582
  if role == "screenshot":
583
+ SESSION.update(fit_frame=0)
379
584
  im, real, meta = _read_source(b["path"])
380
585
  else:
381
586
  im, real = _read_image(b["path"])
@@ -417,16 +622,36 @@ class Handler(BaseHTTPRequestHandler):
417
622
  return self._json({"path": real, "size": [im.shape[1], im.shape[0]]})
418
623
 
419
624
  if u.path == "/api/detect":
625
+ if not SESSION.state.get("photo"):
626
+ raise ValueError("choose a photo first")
420
627
  photo, _ = _read_image(SESSION.state["photo"])
421
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")
422
644
  # `color` gives detect() the saturation detector — devices are
423
645
  # neutral, furniture is not, and grayscale throws that away.
424
- res = D.detect(gray, None, color=photo)
646
+ res = D.detect(gray, None, color=photo, click=click)
425
647
  if res is None:
426
- return self._json({"found": False,
427
- "message": "Neither detector could find a screen here "
428
- "(nothing separable by tone, no screen-shaped "
429
- "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.")})
430
655
  # An abstention is a miss, and must reach the page as one. On
431
656
  # 7 Sep 2026 a quad on a table was shown as a checkable guess
432
657
  # with two corners off the canvas, which cannot be dragged back
@@ -456,15 +681,17 @@ class Handler(BaseHTTPRequestHandler):
456
681
  spath = _safe_local_path(SESSION.state["screenshot"])
457
682
  if not _is_video(spath):
458
683
  return self._json({"error": "the screen source is not a video"}, 400)
684
+ # Records which frame the fit is judged on. Nothing is written:
685
+ # the page re-renders the COMPOSITE from it, and the chip stays
686
+ # on frame 0 deliberately, so a per-step PNG would be disk churn
687
+ # nobody looks at.
459
688
  idx = int(b.get("index") or 0)
460
- frame = W.read_frame_at(spath, idx)
461
- dest = os.path.join(SESSION.dir, f"frame-{idx:06d}.png")
462
- cv2.imwrite(dest, frame, [cv2.IMWRITE_PNG_COMPRESSION, 1])
463
- return self._json({"path": dest, "index": idx,
464
- "size": [frame.shape[1], frame.shape[0]]})
689
+ SESSION.update(fit_frame=idx)
690
+ return self._json({"index": idx})
465
691
 
466
692
  if u.path == "/api/render":
467
693
  # Video: same fit, same geometry, N frames instead of one.
694
+ _need_sources()
468
695
  photo, ppath = _read_image(SESSION.state["photo"])
469
696
  spath = _safe_local_path(SESSION.state["screenshot"])
470
697
  if not _is_video(spath):
@@ -472,14 +699,15 @@ class Handler(BaseHTTPRequestHandler):
472
699
  if not _have_ffmpeg():
473
700
  return self._json({"error": "ffmpeg is not installed",
474
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.
475
704
  with RENDER_LOCK:
476
705
  if RENDER["state"] == "running":
477
706
  return self._json({"error": "a render is already running"}, 409)
478
- RENDER.update(state="running", done=0, total=0,
479
- output=None, message=None)
480
- corners = b["corners"]
707
+ corners = _quad(b["corners"])
481
708
  frac = float(b.get("radius_frac") or 0.0)
482
- fit_frame = int(b.get("fit_frame") or 0)
709
+ fit_frame = int(b.get("fit_frame") if b.get("fit_frame") is not None
710
+ else _fit_frame())
483
711
  first = W.read_frame_at(spath, fit_frame)
484
712
  radius_px = frac * first.shape[1]
485
713
  gr = float(b.get("grade") if b.get("grade") is not None else 0.0)
@@ -504,19 +732,38 @@ class Handler(BaseHTTPRequestHandler):
504
732
  "corners": corners, "radius_frac": frac, "radius_px": radius_px,
505
733
  "device": b.get("device"), "grade": gr, "grain": grain,
506
734
  "video": True, "preset": preset, "fit_frame": fit_frame,
507
- "blend": blend, "reflection": reflection,
508
- "saved": time.time()}
509
- _write_json_atomic(SESSION.result_path, result)
510
- threading.Thread(target=_render_worker, daemon=True,
511
- args=(photo, spath, corners, dest, radius_px,
512
- gr, grain, preset, fit_frame,
513
- 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
514
760
  return self._json({"started": True, "output": dest, "preset": preset})
515
761
 
516
762
  if u.path in ("/api/preview", "/api/save"):
763
+ _need_sources()
517
764
  photo, ppath = _read_image(SESSION.state["photo"])
518
765
  shot, spath, _meta = _read_source(SESSION.state["screenshot"])
519
- corners = b["corners"]
766
+ corners = _quad(b["corners"])
520
767
  frac = float(b.get("radius_frac") or 0.0)
521
768
  radius_px = frac * shot.shape[1]
522
769
  # M2 realism pass. Off is a real option, not a fallback: a flat
@@ -572,6 +819,14 @@ class Handler(BaseHTTPRequestHandler):
572
819
  return self._json({"error": str(e)}, 409)
573
820
  except (PermissionError, FileNotFoundError, KeyError, ValueError) as e:
574
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)
575
830
 
576
831
 
577
832
  def free_port():
@@ -639,7 +894,7 @@ def _publish_current(payload):
639
894
 
640
895
 
641
896
  def main():
642
- global SESSION, OUT_DIR
897
+ global SESSION, OUT_DIR, VERSION, BUILD
643
898
  ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
644
899
  ap.add_argument("--port", type=int, default=0, help="0 = pick a free port")
645
900
  ap.add_argument("--no-open", action="store_true", help="Don't open the browser")
@@ -657,6 +912,8 @@ def main():
657
912
  if args.daemon:
658
913
  _daemonise(args.log)
659
914
 
915
+ VERSION = _version()
916
+ BUILD = _build_label()
660
917
  if args.out_dir:
661
918
  OUT_DIR = os.path.abspath(os.path.expanduser(args.out_dir))
662
919
  sdir = args.session or os.path.join(HOME, ".screengraft", "sessions", time.strftime("%Y%m%d-%H%M%S"))
@@ -664,10 +921,17 @@ def main():
664
921
  port = args.port or free_port()
665
922
  url = f"http://127.0.0.1:{port}/"
666
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))
667
930
  _publish_current({"session": sdir, "url": url, "pid": os.getpid(),
668
931
  "out_dir": OUT_DIR, "started": time.time()})
669
932
  print(json.dumps({"url": url, "session": sdir, "job": SESSION.job_path,
670
- "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)
671
935
  if not args.no_open:
672
936
  opener = "open" if sys.platform == "darwin" else "xdg-open"
673
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
@@ -221,6 +221,12 @@
221
221
  buttons beside them. Only the corner radii and the shared middle border
222
222
  are special. */
223
223
  .stepper{display:inline-flex;align-items:center}
224
+ /* A segmented control has to show which segment is on. `.sel` was only ever
225
+ styled for the thumbnail picker (.th.sel), so the web/ProRes pair rendered
226
+ with no selected state at all — reported 9 Sep 2026. Same step as the
227
+ device chips: selected sits on --raise-hi with the brighter border. */
228
+ .stepper button.sel{background:var(--raise-hi);border-color:var(--edge-hi);color:var(--ink)}
229
+ .stepper button.sel:hover{background:var(--raise-highest);border-color:var(--edge-hi)}
224
230
  .stepper button{border-radius:0}
225
231
  .stepper button:first-child{border-top-left-radius:var(--r-sm);border-bottom-left-radius:var(--r-sm)}
226
232
  .stepper button:last-child{border-top-right-radius:var(--r-sm);border-bottom-right-radius:var(--r-sm)}
@@ -322,7 +328,11 @@
322
328
  .scroller{overflow:auto;background:var(--well);display:grid;
323
329
  min-height:0;min-width:0;
324
330
  box-shadow:inset 0 1px 3px rgba(0,0,0,.5)}
325
- 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}
326
336
 
327
337
  /* Corner-jump bar floating over the canvas (Figma 16:121). position:sticky
328
338
  inside the scroller would drift with pan; fixed-to-the-pane absolute
@@ -555,6 +565,34 @@
555
565
  pixel to the band's height: 16+28+8+130+16 = 198 exactly. */
556
566
  box-shadow:inset 0 1px 0 var(--line), inset 0 2px 0 rgba(255,255,255,.04)}
557
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}
558
596
  /* Figma 8:29 is a 28px header holding 24px buttons — the header does not
559
597
  shrink to hug them. */
560
598
  .striphead{display:flex;align-items:center;gap:8px;font-size:12px;min-height:28px}
@@ -713,6 +751,7 @@
713
751
  <span class="lbl">Fit</span>
714
752
  <span class="stepper"><button id="czOut">&minus;</button><button id="czIn">+</button></span>
715
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>
716
755
  </div>
717
756
  <div class="ph-left-2">
718
757
  <button id="czFit">Fit</button>
@@ -722,7 +761,7 @@
722
761
  <span class="status" id="detSt"></span>
723
762
  </div>
724
763
  <div class="scroller" id="scroller">
725
- <canvas id="c"></canvas>
764
+ <canvas id="c" tabindex="-1"></canvas>
726
765
  </div>
727
766
  <!-- Corner jump. It is a SIBLING of the scroller, not a child: anything
728
767
  positioned inside a scroll container scrolls with the content, so
@@ -734,6 +773,7 @@
734
773
  <span class="cvbar-actions">
735
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>
736
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>
737
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>
738
778
  </span>
739
779
  </div>
@@ -758,8 +798,8 @@
758
798
  <input type="range" id="vframe" min="0" max="0" value="0" step="1"
759
799
  aria-label="Which frame of the clip to match the edges on">
760
800
  <span class="sm" id="vframeLbl" style="color:var(--mute);min-width:9ch">0</span>
761
- <span class="stepper" role="group" aria-label="Output format">
762
- <button class="sm sel" data-preset="web" title="H.264 at CRF 16 — near-visually-lossless, plays anywhere.">Web</button><button class="sm" data-preset="prores" title="ProRes 422 HQ — bigger file, no chroma subsampling. For a case study or further editing.">ProRes</button>
801
+ <span class="stepper" role="radiogroup" aria-label="Output format">
802
+ <button class="sm sel" data-preset="web" role="radio" aria-checked="true" title="H.264 at CRF 16 — near-visually-lossless, plays anywhere.">Web</button><button class="sm" data-preset="prores" role="radio" aria-checked="false" title="ProRes 422 HQ — bigger file, no chroma subsampling. For a case study or further editing.">ProRes</button>
763
803
  </span>
764
804
  </span>
765
805
  </div>
@@ -790,7 +830,7 @@
790
830
  <div class="sect-top">
791
831
  <div class="sect-left">
792
832
  <h4>Realism</h4>
793
- <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>
794
834
  </div>
795
835
  <button class="switch" id="gradeBtn" role="switch" aria-checked="true" aria-label="Realism">
796
836
  <span class="sw-track"><span class="sw-handle"></span><span class="sw-grip"></span></span>
@@ -833,7 +873,12 @@
833
873
  </div>
834
874
  <div class="sect" id="secKeys" data-on="1">
835
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. -->
836
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>
837
882
  </div>
838
883
  </aside>
839
884
  </div>
@@ -850,6 +895,8 @@
850
895
  </footer>
851
896
  </div>
852
897
 
898
+ <div class="ver" id="ver"></div>
899
+
853
900
  <div class="floatloupe" id="floatLoupe">
854
901
  <canvas id="stripF" width="680" height="208"></canvas>
855
902
  <div class="cap" id="stripFCap"></div>
@@ -1147,12 +1194,14 @@ function setChosen(role, path, size, el, meta){
1147
1194
  const n = role==='photo'?1:2;
1148
1195
  document.querySelectorAll('#recent'+n+' .th').forEach(x=>x.classList.remove('sel'));
1149
1196
  if (el) el.classList.add('sel');
1150
- $('#pv'+n).innerHTML = `<img src="${fileURL(path)}" alt="">`;
1197
+ // A video cannot be shown by <img>, so a clip gets its poster frame instead.
1198
+ const thumb = (meta && meta.poster) ? meta.poster : path;
1199
+ $('#pv'+n).innerHTML = `<img src="${fileURL(thumb)}" alt="">`;
1151
1200
  const chip = $('#chip'+n);
1152
1201
  chip.classList.remove('is-empty');
1153
1202
  chip.querySelector('.nm').textContent = `${path.split('/').pop()} · ${size[0]}×${size[1]}`
1154
1203
  + (meta && meta.video ? ` · ${meta.frames} frames @ ${Math.round(meta.fps)}fps` : '');
1155
- $('#sw'+n).style.background = `center/cover no-repeat url("${fileURL(path)}")`;
1204
+ $('#sw'+n).style.background = `center/cover no-repeat url("${fileURL(thumb)}")`;
1156
1205
  if (role==='photo'){ st.photo = path; st.corners = null; }
1157
1206
  else { st.shot = path; st.video = meta && meta.video ? meta : null; syncVideoUI(); }
1158
1207
  closePop();
@@ -1356,6 +1405,163 @@ $('#czFit').onclick = () => { computeFit(); setCanvasZoom(fitScale); };
1356
1405
  $('#cz100').onclick = () => setCanvasZoom(1);
1357
1406
  $('#czIn').onclick = () => setCanvasZoom(scale*1.5);
1358
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
+
1359
1565
  document.querySelectorAll('[data-jump]').forEach(b => {
1360
1566
  b.onclick = () => { if (!st.corners) return; const i=+b.dataset.jump;
1361
1567
  pick = {kind:'corner', i}; setCanvasZoom(Math.max(1, scale), st.corners[i]); drawStrip(); };
@@ -1376,8 +1582,13 @@ async function detect(){
1376
1582
  } else {
1377
1583
  st.corners = defaultQuad();
1378
1584
  s.className='status warn';
1379
- s.textContent = r.abstained ? 'Detector abstained place the edges by hand'
1380
- : '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);
1381
1592
  // The full reason is long and belongs on hover, not in a 40-character chip.
1382
1593
  s.title = r.message || '';
1383
1594
  if (!st.type) setType('phone'); applyPreset();
@@ -1519,6 +1730,8 @@ function moveEdge(C, i, mode, delta){
1519
1730
 
1520
1731
  cv.onpointerdown = e => {
1521
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);
1522
1735
  if (!h){ pick = null; draw(); drawStrip(); return; }
1523
1736
  drag = {...h, grab: p, start: st.corners.map(c => c.slice())};
1524
1737
  pick = {kind:h.kind, i:h.i};
@@ -1790,6 +2003,21 @@ function syncSlider(){ const r=$('#radius'); r.style.setProperty('--p', (r.value
1790
2003
 
1791
2004
  /* ===== preview / compare / save ===== */
1792
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
+ }
1793
2021
  function markStale(){
1794
2022
  const b = $('#save'); b.disabled = true; b.textContent = 'Preview first';
1795
2023
  // Exactly one lit action at a time — the Figma Button component says the
@@ -1826,7 +2054,7 @@ async function renderPreview(){
1826
2054
  im.onload = () => { outNat = im.naturalWidth; im.hidden = false; $('#outEmpty').style.display='none'; syncOut(); };
1827
2055
  im.src = `${fileURL(r.path)}&t=${Date.now()}`;
1828
2056
  s.textContent = `radius ${r.radius_px}px on the screenshot`;
1829
- const b = $('#save'); b.disabled = false; b.textContent = st.video ? 'Render' : 'Save';
2057
+ const b = $('#save'); b.disabled = false; b.textContent = saveLabel();
1830
2058
  b.classList.add('primary'); // Save is now the next action
1831
2059
  if (!previewHinted){ previewHinted = true;
1832
2060
  toast('info', 'Check the corners in Result, then <b>Save</b> — Save renders at full resolution.');
@@ -1846,7 +2074,11 @@ let preset = 'web';
1846
2074
  document.querySelectorAll('[data-preset]').forEach(btn => {
1847
2075
  btn.onclick = () => {
1848
2076
  preset = btn.dataset.preset;
1849
- document.querySelectorAll('[data-preset]').forEach(o => o.classList.toggle('sel', o === btn));
2077
+ document.querySelectorAll('[data-preset]').forEach(o => {
2078
+ const on = o === btn;
2079
+ o.classList.toggle('sel', on);
2080
+ o.setAttribute('aria-checked', on ? 'true' : 'false');
2081
+ });
1850
2082
  };
1851
2083
  });
1852
2084
 
@@ -1871,18 +2103,21 @@ function syncVideoUI(){
1871
2103
  sl.max = Math.max(0, (v.frames || 1) - 1);
1872
2104
  sl.value = 0;
1873
2105
  $('#vframeLbl').textContent = `0 / ${Math.max(0,(v.frames||1)-1)}`;
1874
- if (b.textContent === 'Save') b.textContent = 'Render';
1875
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();
1876
2110
  }
1877
2111
  $('#vframe').oninput = () => { $('#vframeLbl').textContent = `${$('#vframe').value} / ${$('#vframe').max}`; };
1878
2112
  $('#vframe').onchange = async () => {
1879
2113
  // Swapping the poster frame re-runs preview through the same path a still
1880
2114
  // uses, so what you match the edges against is the frame you chose.
1881
2115
  try{
1882
- const r = await api('/api/frame', {index: +$('#vframe').value});
1883
- $('#pv2').innerHTML = `<img src="${fileURL(r.path)}" alt="">`;
1884
- $('#sw2').style.background = `center/cover no-repeat url("${fileURL(r.path)}")`;
1885
- autoPreview();
2116
+ await api('/api/frame', {index: +$('#vframe').value});
2117
+ // Only the composite changes. The chip keeps frame 0 on purpose — at that
2118
+ // size one frame looks like any other, so updating it would be movement
2119
+ // without meaning.
2120
+ if (st.corners) renderPreview();
1886
2121
  }catch(e){ toast('err', 'Could not read that frame: ' + e.message); }
1887
2122
  };
1888
2123
 
@@ -1893,7 +2128,7 @@ async function renderVideo(){
1893
2128
  try{
1894
2129
  await api('/api/render', {corners: st.corners, radius_frac: radiusValue(), device: st.type,
1895
2130
  grade: gradeValue(), reflection: emisValue(), preset, fit_frame: +$('#vframe').value});
1896
- }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; }
1897
2132
  // Poll rather than hold a request open: a clip is hundreds of frames and a
1898
2133
  // browser would time the request out long before the render finished.
1899
2134
  const tick = setInterval(async () => {
@@ -1906,13 +2141,13 @@ async function renderVideo(){
1906
2141
  if (d.total) $('#vframeLbl').textContent = `${d.done} / ${d.total} frames`;
1907
2142
  } else if (d.state === 'done'){
1908
2143
  clearInterval(tick);
1909
- b.textContent = 'Render'; b.disabled = false; b.classList.remove('primary');
2144
+ b.textContent = saveLabel(); b.disabled = false; b.classList.remove('primary');
1910
2145
  $('#vframeLbl').textContent = `${$('#vframe').value} / ${$('#vframe').max}`;
1911
2146
  toast('ok', `Rendered <code>${(d.output||'').split('/').pop()}</code> · ${d.done} frames to <code>${prettyDir(st.outDir||'')}</code>`);
1912
2147
  const imp = $('#imp'); imp.disabled = false; imp.textContent = 'Send to Claude'; imp.classList.add('primary');
1913
2148
  } else if (d.state === 'error'){
1914
2149
  clearInterval(tick);
1915
- b.textContent = 'Render'; b.disabled = false;
2150
+ b.textContent = saveLabel(); b.disabled = false;
1916
2151
  $('#vframeLbl').textContent = `${$('#vframe').value} / ${$('#vframe').max}`;
1917
2152
  toast('err', 'Render failed: ' + (d.message || 'unknown'));
1918
2153
  }
@@ -1924,7 +2159,7 @@ $('#save').onclick = async () => {
1924
2159
  const b = $('#save'); b.textContent = 'Saving…'; b.disabled = true;
1925
2160
  try{
1926
2161
  const r = await api('/api/save', {corners: st.corners, radius_frac: radiusValue(), device: st.type, grade: gradeValue(), reflection: emisValue()});
1927
- b.textContent = 'Save';
2162
+ b.textContent = saveLabel();
1928
2163
  // The real destination, not a hardcoded one: --out-dir means saves usually
1929
2164
  // land in the project folder now, and telling the user "~/Desktop" when
1930
2165
  // they aren't there is how you lose a file.
@@ -1933,7 +2168,7 @@ $('#save').onclick = async () => {
1933
2168
  imp.disabled = false; imp.textContent = 'Send to Claude';
1934
2169
  // Hand the accent on: the file exists, so sending it is what is next.
1935
2170
  imp.classList.add('primary'); b.classList.remove('primary');
1936
- }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(); }
1937
2172
  finally { b.disabled = false; }
1938
2173
  };
1939
2174
 
@@ -1972,6 +2207,18 @@ $('#imp').onclick = async () => {
1972
2207
  const s = await api('/api/state');
1973
2208
  st.presets = s.presets; $('#sess').textContent = s.session.split('/').pop();
1974
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
+ }
1975
2222
  // Say up front where Save will put things. With --out-dir this is usually the
1976
2223
  // project folder, and a designer should not have to press Save to find out.
1977
2224
  toast('info', `Fit the four edges, then Preview. Saves go to <code>${prettyDir(s.out_dir||'')}</code>.`,