screengraft 0.50.0 → 0.51.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -29,6 +29,10 @@ prototype, then put the recording inside a real photograph.
29
29
  - **Realism pass** *(optional)* — matches the screen's white balance and grain to
30
30
  the light in the room, and can lift the device's real reflections from a
31
31
  screen-off frame of the same shot.
32
+ - **Depth of field** *(optional)* — a blur that grows across the screen in one
33
+ direction, glass edge included, so a screenshot on a phone shot at an angle
34
+ goes soft where the phone does. Direction and strength are yours; *Measure
35
+ from photo* proposes them from the photograph's own screen boundary.
32
36
  - **Emissive screens** *(optional)* — a display emits light *and* reflects the
33
37
  room, which is why a switched-off phone looks dark grey rather than black.
34
38
  Paint a true-black UI on flat and it reads as a hole cut in the photo. Turn
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "screengraft",
3
- "version": "0.50.0",
3
+ "version": "0.51.0",
4
4
  "description": "Put a UI screenshot or screen recording onto a photographed device screen with the perspective exactly right \u2014 a homography you confirm by hand, not a generative guess.",
5
5
  "keywords": [
6
6
  "mockup",
package/scripts/dof.py ADDED
@@ -0,0 +1,231 @@
1
+ """Depth of field across the screen — a blur that grows in one direction.
2
+
3
+ A phone photographed at an angle is a plane receding from the camera, so
4
+ focus falls off *linearly across the quad*: sharp at the near edge, softer
5
+ toward the far one. Two numbers describe it — the direction the blur grows in
6
+ (`angle`, degrees, photo space, 0 = toward +x, 90 = toward +y) and how fast
7
+ (`strength`, 0..1) — and both can be read off the photograph, because the
8
+ bezel around the screen already carries the camera's own defocus.
9
+
10
+ Blur is applied in PHOTO space, after the warp, to the premultiplied screen
11
+ layer and its mask together (`blur_layer`). Blurring the layer alone would
12
+ pull the black outside the quad into its edge and leave a sharp alpha edge
13
+ inside a soft bezel — the tell in a bad mockup. Premultiplied, the edge
14
+ softens exactly as the colour does.
15
+
16
+ Spatially varying Gaussian: DOF_LEVELS blur levels, per pixel a linear blend
17
+ of the two nearest. Standard, cheap (five separable blurs on the quad's
18
+ window), and byte-identical to no blur at strength 0.
19
+ """
20
+ import math
21
+
22
+ import cv2
23
+ import numpy as np
24
+
25
+ DOF_LEVELS = 5 # sigma steps between 0 and sigma_max (0 is the identity)
26
+ DOF_MAX_FRAC = 0.02 # strength 1.0 = sigma of 2% of the quad's longer side (16px on an 800px screen)
27
+
28
+
29
+ def sigma_max(corners, strength: float) -> float:
30
+ """Blur at the far edge, in photo pixels, for a given strength."""
31
+ q = np.asarray(corners, dtype=np.float64)
32
+ side = max(np.linalg.norm(q[1] - q[0]), np.linalg.norm(q[2] - q[1]),
33
+ np.linalg.norm(q[3] - q[2]), np.linalg.norm(q[0] - q[3]))
34
+ return float(np.clip(strength, 0.0, 1.0)) * DOF_MAX_FRAC * float(side)
35
+
36
+
37
+ def ramp(corners, angle_deg: float, x0: int, y0: int, w: int, h: int) -> np.ndarray:
38
+ """Per-pixel 0..1 distance along `angle` across the quad, over a window.
39
+
40
+ 0 at the quad's nearest extent in that direction, 1 at its farthest; pixels
41
+ outside the quad clamp. The window is (x0, y0, w, h) in photo pixels.
42
+ """
43
+ a = math.radians(angle_deg)
44
+ d = np.array([math.cos(a), math.sin(a)], dtype=np.float64)
45
+ q = np.asarray(corners, dtype=np.float64)
46
+ proj = q @ d
47
+ lo, hi = float(proj.min()), float(proj.max())
48
+ if hi - lo < 1e-6:
49
+ return np.zeros((h, w), dtype=np.float32)
50
+ xs = np.arange(x0, x0 + w, dtype=np.float64)[None, :]
51
+ ys = np.arange(y0, y0 + h, dtype=np.float64)[:, None]
52
+ t = (xs * d[0] + ys * d[1] - lo) / (hi - lo)
53
+ return np.clip(t, 0.0, 1.0).astype(np.float32)
54
+
55
+
56
+ def level_weights(t: np.ndarray, levels: int = DOF_LEVELS):
57
+ """Blend weights (levels, h, w): each pixel splits between its two nearest
58
+ sigma steps. Sums to 1 everywhere."""
59
+ pos = t * (levels - 1)
60
+ lo = np.floor(pos).astype(np.int32)
61
+ lo = np.clip(lo, 0, levels - 2)
62
+ f = (pos - lo).astype(np.float32)
63
+ W = np.zeros((levels,) + t.shape, dtype=np.float32)
64
+ rows, cols = np.indices(t.shape)
65
+ W[lo, rows, cols] = 1.0 - f
66
+ W[lo + 1, rows, cols] = f
67
+ return W
68
+
69
+
70
+ def _blur(img: np.ndarray, sigma: float) -> np.ndarray:
71
+ if sigma <= 0.0:
72
+ return img
73
+ k = int(2 * math.ceil(3 * sigma) + 1)
74
+ return cv2.GaussianBlur(img, (k, k), sigma, borderType=cv2.BORDER_REPLICATE)
75
+
76
+
77
+ class Field:
78
+ """Everything that does not change per frame: the ramp, the weights, the
79
+ blurred masks. `blur_layer` then costs `levels - 1` blurs of the colour."""
80
+
81
+ def __init__(self, corners, angle_deg: float, strength: float, mask: np.ndarray,
82
+ x0: int, y0: int):
83
+ h, w = mask.shape[:2]
84
+ self.x0, self.y0 = x0, y0
85
+ self.smax = sigma_max(corners, strength)
86
+ self.sigmas = [self.smax * k / (DOF_LEVELS - 1) for k in range(DOF_LEVELS)]
87
+ t = ramp(corners, angle_deg, x0, y0, w, h)
88
+ self.W = level_weights(t) # (L, h, w)
89
+ m = mask.astype(np.float32) / 255.0
90
+ self.masks = [_blur(m, s) for s in self.sigmas] # each (h, w)
91
+ self.alpha = np.zeros_like(m)
92
+ for k in range(DOF_LEVELS):
93
+ self.alpha += self.W[k] * self.masks[k]
94
+ self.alpha3 = np.repeat(self.alpha[:, :, None], 3, axis=2)
95
+
96
+ @property
97
+ def pad(self) -> int:
98
+ """How far the soft edge reaches outside the sharp mask."""
99
+ return int(math.ceil(3 * self.smax))
100
+
101
+ def blur_layer(self, colour: np.ndarray) -> np.ndarray:
102
+ """Blur a warped screen window (float32 BGR, h×w×3) by the field.
103
+
104
+ Premultiplied by the sharp mask, blurred per level, blended by the
105
+ weights, then un-premultiplied by the blended mask — so the colour at
106
+ a soft edge is the screen's own, not the screen mixed with whatever
107
+ the warp put outside the quad. (The warp replicates the edge outward,
108
+ so in practice that is nearly the screen's own colour already; the
109
+ premultiplication is what keeps it exact rather than nearly.)
110
+ """
111
+ m0 = self.masks[0][:, :, None]
112
+ pm = colour * m0
113
+ num = np.zeros_like(colour)
114
+ for k in range(DOF_LEVELS):
115
+ bk = pm if self.sigmas[k] <= 0.0 else _blur(pm, self.sigmas[k])
116
+ num += self.W[k][:, :, None] * bk
117
+ den = np.maximum(self.alpha3, 1e-4)
118
+ out = num / den
119
+ return np.where(self.alpha3 > 1e-4, out, colour)
120
+
121
+
122
+ # ---------------------------------------------------------------- measure --
123
+
124
+ DOF_PROFILE_HALF = 14 # px either side of the screen boundary to fit the step
125
+ DOF_MIN_SPREAD = 1.6 # far-edge sigma / near-edge sigma below this = flat
126
+ DOF_MIN_SIGMA = 0.9 # a step narrower than this is the render's own antialiasing
127
+
128
+
129
+ def _edge_profiles(gray: np.ndarray, A, B, n, half: int, trim: float = 0.12):
130
+ """Intensity profiles ACROSS edge A→B, one column per sample point along
131
+ it, from `half` px inside the screen to `half` px outside, along the
132
+ outward unit normal `n`. Rectified with remap, so a tilted edge reads as
133
+ a straight step."""
134
+ A = np.asarray(A, dtype=np.float64); B = np.asarray(B, dtype=np.float64)
135
+ u = B - A
136
+ L = float(np.linalg.norm(u))
137
+ if L < 8:
138
+ return None
139
+ u /= L
140
+ s = np.linspace(trim * L, (1 - trim) * L, max(8, int((1 - 2 * trim) * L / 2)))
141
+ d = np.arange(-half, half + 1, dtype=np.float64)
142
+ xs = A[0] + s[None, :] * u[0] + d[:, None] * n[0]
143
+ ys = A[1] + s[None, :] * u[1] + d[:, None] * n[1]
144
+ return cv2.remap(gray.astype(np.float32), xs.astype(np.float32), ys.astype(np.float32),
145
+ cv2.INTER_LINEAR, borderMode=cv2.BORDER_REFLECT)
146
+
147
+
148
+ def step_sigma(profiles: np.ndarray) -> float:
149
+ """Blur of a step edge, in pixels, from profiles across it (rows = across,
150
+ columns = samples along the edge).
151
+
152
+ A step of amplitude A blurred by a Gaussian of sigma has a peak derivative
153
+ of A / (sigma * sqrt(2*pi)), so sigma = A / (sqrt(2*pi) * peak). Only the
154
+ DOMINANT transition in each column is read: the strongest gradient near
155
+ the boundary, with A taken between the plateaus 3 sigma-ish either side of
156
+ it. The second-moment estimate was tried first and saturated at ~5px on
157
+ sharp renders, because a ±14px profile crosses glass edge, bezel and body
158
+ and its derivative is spread over all three. Reduced by the median across
159
+ columns so a corner arc or a reflection on a few samples does not move it;
160
+ columns with no real step are left out.
161
+ """
162
+ smooth = cv2.GaussianBlur(profiles, (1, 3), 0.6) # tame sensor noise along the profile
163
+ g = np.diff(smooth, axis=0) # (2*half, N)
164
+ ag = np.abs(g)
165
+ n_across, N = ag.shape
166
+ half = n_across // 2
167
+ lo_i, hi_i = max(1, half - 6), min(n_across - 1, half + 6) # the boundary is near the centre
168
+ peak = lo_i + ag[lo_i:hi_i].argmax(axis=0)
169
+ out = []
170
+ for j in range(N):
171
+ pk = int(peak[j]); gm = float(ag[pk, j])
172
+ if gm < 1.5:
173
+ continue
174
+ # plateaus: 4..9 px either side of the peak, clamped to the profile
175
+ a0, a1 = max(0, pk - 9), max(0, pk - 4)
176
+ b0, b1 = min(n_across, pk + 5), min(n_across, pk + 10)
177
+ if a1 <= a0 or b1 <= b0:
178
+ continue
179
+ A = abs(float(smooth[b0:b1, j].mean()) - float(smooth[a0:a1, j].mean()))
180
+ if A < 12.0: # no step worth reading
181
+ continue
182
+ out.append(A / (math.sqrt(2 * math.pi) * gm))
183
+ if len(out) < 4:
184
+ return 0.0
185
+ return float(np.median(out))
186
+
187
+
188
+ def measure(photo: np.ndarray, corners) -> dict:
189
+ """Direction and strength of defocus from the screen's own boundary.
190
+
191
+ Four blur widths, one per edge (sigma in pixels, `step_sigma`), the
192
+ edge midpoints as positions → a plane sigma(x, y) = a·x + b·y + c by least
193
+ squares. The blur grows along the plane's gradient: angle = atan2(b, a).
194
+ Strength is the far edge's sigma against DOF_MAX_FRAC of the longer side,
195
+ which is what the blur uses, so a measured strength reproduces the measured
196
+ blur. Below DOF_MIN_SPREAD between the softest and sharpest edge the photo
197
+ is in focus across the screen (or uniformly soft, which is not depth of
198
+ field) and the answer is `flat`.
199
+ """
200
+ gray = cv2.cvtColor(photo, cv2.COLOR_BGR2GRAY) if photo.ndim == 3 else photo
201
+ q = np.asarray(corners, dtype=np.float64)
202
+ centre = q.mean(axis=0)
203
+ sig, mids = [], []
204
+ for i in range(4):
205
+ A, B = q[i], q[(i + 1) % 4]
206
+ u = B - A
207
+ n = np.array([-u[1], u[0]]) / max(np.linalg.norm(u), 1e-6)
208
+ mid = (A + B) / 2
209
+ if np.dot(n, centre - mid) > 0: # pointing in: flip to outward
210
+ n = -n
211
+ prof = _edge_profiles(gray, A, B, n, DOF_PROFILE_HALF)
212
+ if prof is None:
213
+ continue
214
+ sg = step_sigma(prof)
215
+ if sg > 0.0:
216
+ sig.append(sg); mids.append(mid)
217
+ if len(sig) < 3:
218
+ return {"angle": 0.0, "strength": 0.0, "flat": True, "sigma": [round(v, 2) for v in sig]}
219
+ sig = np.array(sig); mids = np.array(mids)
220
+ lo = max(float(sig.min()), DOF_MIN_SIGMA)
221
+ spread = float(sig.max() / lo)
222
+ out = {"sigma": sig.round(2).tolist(), "spread": round(spread, 2)}
223
+ if spread < DOF_MIN_SPREAD:
224
+ out.update({"angle": 0.0, "strength": 0.0, "flat": True})
225
+ return out
226
+ Amat = np.column_stack([mids[:, 0], mids[:, 1], np.ones(len(mids))])
227
+ (a, b, c), *_ = np.linalg.lstsq(Amat, sig, rcond=None)
228
+ angle = math.degrees(math.atan2(b, a)) % 360.0
229
+ strength = float(np.clip((sig.max() - lo) / max(sigma_max(q, 1.0), 1e-6), 0.0, 1.0))
230
+ out.update({"angle": round(angle, 1), "strength": round(strength, 3), "flat": False})
231
+ return out
package/scripts/scan.py CHANGED
@@ -53,7 +53,13 @@ def thumb(item, out_dir: str, size: int = 320):
53
53
  """Write a small JPEG thumbnail; returns its path or None."""
54
54
  os.makedirs(out_dir, exist_ok=True)
55
55
  base = os.path.splitext(item["name"])[0]
56
- out = os.path.join(out_dir, f"{abs(hash(item['path']))}_{base[:40]}.jpg")
56
+ # The mtime is part of the name: a file overwritten in place gets a new
57
+ # thumbnail instead of the one made from its old bytes (12 Sep 2026).
58
+ try:
59
+ stamp = int(os.path.getmtime(item["path"]))
60
+ except OSError:
61
+ stamp = 0
62
+ out = os.path.join(out_dir, f"{abs(hash(item['path']))}_{stamp}_{base[:40]}.jpg")
57
63
  if os.path.exists(out):
58
64
  return out
59
65
  if sys.platform == "darwin":
package/scripts/ui.py CHANGED
@@ -49,6 +49,7 @@ import numpy as np # noqa: E402
49
49
  import detect as D # noqa: E402
50
50
  import fitfile as FF # noqa: E402
51
51
  import fits as FIT # noqa: E402
52
+ import dof as DOF # noqa: E402
52
53
  import scan as S # noqa: E402
53
54
  import warp as W # noqa: E402
54
55
 
@@ -577,7 +578,8 @@ PREVIEW_SECONDS = 6.0
577
578
 
578
579
  def _render_worker(photo, video_path, corners, dest, radius_px, gr, grain, preset, fit_frame,
579
580
  blend="replace", reflection=None, result=None, kind="render",
580
- start_frame=0, max_frames=None, *, smoothing=0.0):
581
+ start_frame=0, max_frames=None, *, smoothing=0.0,
582
+ dof_angle=0.0, dof_strength=0.0):
581
583
  """Encode the clip, and only if that SUCCEEDS publish what it produced.
582
584
 
583
585
  `result` is the sidecar this render would write. It is handed to the worker
@@ -602,7 +604,8 @@ def _render_worker(photo, video_path, corners, dest, radius_px, gr, grain, prese
602
604
  blend=blend,
603
605
  reflection=(W.DEFAULT_REFLECTION if reflection is None
604
606
  else reflection),
605
- start_frame=start_frame, max_frames=max_frames)
607
+ start_frame=start_frame, max_frames=max_frames,
608
+ dof_angle=dof_angle, dof_strength=dof_strength)
606
609
  if kind == "preview":
607
610
  # A preview publishes NOTHING. It is not a save: no sidecar, no fit
608
611
  # file, and above all not the session output -- /api/import reads
@@ -664,6 +667,18 @@ def _blend_args(b):
664
667
  return "emissive", float(max(0.0, min(1.0, float(r))))
665
668
 
666
669
 
670
+ def _dof_args(b):
671
+ """(dof_angle, dof_strength) from the page. Absent or null strength means
672
+ 0 -- no field, and every path in Plan untouched -- so a client that predates
673
+ depth of field and a sidecar replayed through the CLI both reproduce."""
674
+ try:
675
+ strength = float(b.get("dof_strength") or 0.0)
676
+ angle = float(b.get("dof_angle") or 0.0)
677
+ except (TypeError, ValueError):
678
+ return 0.0, 0.0
679
+ return angle % 360.0, float(min(max(strength, 0.0), 1.0))
680
+
681
+
667
682
  ROLES = ("photo", "screenshot")
668
683
 
669
684
 
@@ -699,6 +714,15 @@ def _adopt(role, path):
699
714
  e = FIT.recall(FIT.key_for(im))
700
715
  if e:
701
716
  meta["remembered"] = e
717
+ # mtime rides along as the page's cache-buster: the same path with new
718
+ # bytes (imported, cleared, overwritten on disk, imported again) used to
719
+ # come back as the OLD picture in every <img> the browser had cached --
720
+ # chip swatch, popover preview, the canvas itself for a photo -- while the
721
+ # composite, which the server renders from disk, was already new.
722
+ try:
723
+ meta["mtime"] = int(os.path.getmtime(real))
724
+ except OSError:
725
+ meta["mtime"] = 0
702
726
  return {"path": real, "size": [im.shape[1], im.shape[0]], **meta}
703
727
 
704
728
 
@@ -941,6 +965,29 @@ class Handler(BaseHTTPRequestHandler):
941
965
  "with status=done.",
942
966
  }))
943
967
 
968
+ if u.path == "/api/dof":
969
+ # Depth of field read off the photograph's own screen boundary:
970
+ # the direction the blur grows in and how far, from the blur
971
+ # width of each edge of the quad. `flat` means the bezel is
972
+ # sharp all round and the feature has nothing to match.
973
+ ppath = SESSION.state.get("photo")
974
+ if not ppath:
975
+ return self._json({"error": "no photo chosen"}, 400)
976
+ photo, _ = _read_image(ppath)
977
+ corners = _quad(b["corners"])
978
+ return self._json(DOF.measure(photo, corners))
979
+
980
+ if u.path == "/api/job/cancel":
981
+ # The page gave up on a pending job. Marked, not deleted: the
982
+ # agent's complete_job then answers "already cancelled" instead
983
+ # of finding no job and guessing, and wait_for_job skips it.
984
+ job = SESSION.read_job()
985
+ if job and job.get("status") == "pending":
986
+ job["status"] = "cancelled"
987
+ job["completed"] = time.time()
988
+ _write_json_atomic(SESSION.job_path, job)
989
+ return self._json(job or {"status": "none"})
990
+
944
991
  if u.path == "/api/job/adopt":
945
992
  # page calls this once job.status == done, to make the export the screenshot
946
993
  with open(SESSION.job_path) as f:
@@ -1129,7 +1176,9 @@ class Handler(BaseHTTPRequestHandler):
1129
1176
  gr, grain, "web", fit_frame,
1130
1177
  blend, reflection, None, "preview",
1131
1178
  fit_frame, max_frames),
1132
- kwargs={"smoothing": _smoothing(b)}).start()
1179
+ kwargs={"smoothing": _smoothing(b),
1180
+ "dof_angle": _dof_args(b)[0],
1181
+ "dof_strength": _dof_args(b)[1]}).start()
1133
1182
  except BaseException:
1134
1183
  with RENDER_LOCK:
1135
1184
  RENDER.update(state="error", message="could not start the preview")
@@ -1163,6 +1212,7 @@ class Handler(BaseHTTPRequestHandler):
1163
1212
  gr = float(b.get("grade") if b.get("grade") is not None else 0.0)
1164
1213
  grain = bool(b.get("grain", gr > 0))
1165
1214
  blend, reflection = _blend_args(b)
1215
+ dof_angle, dof_strength = _dof_args(b)
1166
1216
  preset = "prores" if b.get("preset") == "prores" else "web"
1167
1217
  ext = ".mov" if preset == "prores" else ".mp4"
1168
1218
  os.makedirs(OUT_DIR, exist_ok=True)
@@ -1184,6 +1234,7 @@ class Handler(BaseHTTPRequestHandler):
1184
1234
  "grade": gr, "grain": grain,
1185
1235
  "video": True, "preset": preset, "fit_frame": fit_frame,
1186
1236
  "blend": blend, "reflection": reflection,
1237
+ "dof_angle": dof_angle, "dof_strength": dof_strength,
1187
1238
  # A render is always the whole clip; only the preview
1188
1239
  # passes a segment. Recorded anyway, because the
1189
1240
  # sidecar's promise is EVERY argument that changes the
@@ -1210,7 +1261,9 @@ class Handler(BaseHTTPRequestHandler):
1210
1261
  args=(photo, spath, corners, dest, radius_px,
1211
1262
  gr, grain, preset, fit_frame,
1212
1263
  blend, reflection, result),
1213
- kwargs={"smoothing": _smoothing(b)}).start()
1264
+ kwargs={"smoothing": _smoothing(b),
1265
+ "dof_angle": dof_angle,
1266
+ "dof_strength": dof_strength}).start()
1214
1267
  except BaseException:
1215
1268
  # If the thread cannot even be created, the flag must not
1216
1269
  # outlive the request.
@@ -1233,10 +1286,12 @@ class Handler(BaseHTTPRequestHandler):
1233
1286
  gr = float(b.get("grade") if b.get("grade") is not None else 0.0)
1234
1287
  blend, reflection = _blend_args(b)
1235
1288
  smoothing = _smoothing(b)
1289
+ dof_angle, dof_strength = _dof_args(b)
1236
1290
  out = W.compose(photo, shot, corners, radius_px,
1237
1291
  corner_smoothing=smoothing,
1238
1292
  grade=gr, grain=bool(b.get("grain", gr > 0)),
1239
- blend=blend, reflection=reflection)
1293
+ blend=blend, reflection=reflection,
1294
+ dof_angle=dof_angle, dof_strength=dof_strength)
1240
1295
  SESSION.update(corners=corners, radius_frac=frac, device=b.get("device"),
1241
1296
  grade=gr)
1242
1297
  if u.path == "/api/preview":
@@ -1272,6 +1327,7 @@ class Handler(BaseHTTPRequestHandler):
1272
1327
  "corner_smoothing": smoothing,
1273
1328
  "grade": gr, "grain": bool(b.get("grain", gr > 0)),
1274
1329
  "blend": blend, "reflection": reflection,
1330
+ "dof_angle": dof_angle, "dof_strength": dof_strength,
1275
1331
  "saved": time.time()}
1276
1332
  # A fit is remembered when it PRODUCED something, not while it
1277
1333
  # is being dragged: a quad on the canvas is a work in progress,
package/scripts/warp.py CHANGED
@@ -37,6 +37,7 @@ import cv2
37
37
  import numpy as np
38
38
 
39
39
  import grade as _grade # M2: the realism pass
40
+ import dof as _dof # depth of field across the screen
40
41
 
41
42
 
42
43
  MASK_SS = 4 # destination-space supersampling for the screen's edge
@@ -267,7 +268,8 @@ class Plan:
267
268
  def __init__(self, photo: np.ndarray, frame_shape, corners,
268
269
  corner_radius: float = 0.0, grain: bool = False,
269
270
  blend: str = "replace", reflection: float = DEFAULT_REFLECTION,
270
- corner_smoothing: float = 0.0):
271
+ corner_smoothing: float = 0.0,
272
+ dof_angle: float = 0.0, dof_strength: float = 0.0):
271
273
  dst_quad = np.array(corners, dtype=np.float32)
272
274
  if shoelace_area(dst_quad) < 1.0:
273
275
  raise ValueError("degenerate quad (near-zero area) — check corner order TL,TR,BR,BL")
@@ -313,9 +315,22 @@ class Plan:
313
315
  # Integer bbox of the quad, clamped to the canvas and padded by a pixel
314
316
  # so the antialiased edge is never clipped.
315
317
  xs, ys = dst_quad[:, 0], dst_quad[:, 1]
316
- bx0, by0 = max(0, int(np.floor(xs.min())) - 1), max(0, int(np.floor(ys.min())) - 1)
317
- bx1, by1 = min(pw, int(np.ceil(xs.max())) + 2), min(ph, int(np.ceil(ys.max())) + 2)
318
+ # Depth of field widens the window: the soft edge reaches 3 sigma past
319
+ # the sharp mask, and a window that clipped it would leave a hard line
320
+ # exactly where the blur was meant to remove one.
321
+ self.dof_strength = float(np.clip(dof_strength, 0.0, 1.0))
322
+ self.dof_angle = float(dof_angle)
323
+ reach = int(np.ceil(3 * _dof.sigma_max(dst_quad, self.dof_strength))) if self.dof_strength > 0 else 0
324
+ bx0, by0 = max(0, int(np.floor(xs.min())) - 1 - reach), max(0, int(np.floor(ys.min())) - 1 - reach)
325
+ bx1, by1 = min(pw, int(np.ceil(xs.max())) + 2 + reach), min(ph, int(np.ceil(ys.max())) + 2 + reach)
318
326
  self.bbox = (bx0, by0, bx1, by1) if bx1 > bx0 and by1 > by0 else None
327
+ # The field is built once: ramp, level weights, the blurred masks.
328
+ # At strength 0 there is no field and every path below is untouched,
329
+ # which is what keeps every earlier sidecar reproducing byte for byte.
330
+ self.dof = None
331
+ if self.dof_strength > 0 and self.bbox is not None:
332
+ self.dof = _dof.Field(dst_quad, self.dof_angle, self.dof_strength,
333
+ self.warped_mask[by0:by1, bx0:bx1], bx0, by0)
319
334
 
320
335
  def _prep(self, frame: np.ndarray, bbox=None) -> np.ndarray:
321
336
  """Warp one frame. With `bbox`, warp only that window of the canvas.
@@ -377,13 +392,21 @@ class Plan:
377
392
  simplest code is the one to trust, and on for video renders. Both
378
393
  produce identical bytes; test_video.py asserts it rather than assuming.
379
394
  """
380
- if fast and self.bbox is not None:
395
+ # Depth of field always takes the windowed path: the field is built on
396
+ # the window, and the still and video renders must share one code
397
+ # path here for the same reason they share Plan.
398
+ if (fast or self.dof is not None) and self.bbox is not None:
381
399
  x0, y0, x1, y1 = self.bbox
382
400
  warped_screen = self._prep(frame, self.bbox)
383
401
  if self.grade_params is not None:
384
402
  warped_screen = _grade.apply_light(warped_screen, self.grade_params)
385
403
  out = self.photo.copy()
386
404
  win = self.mask3[y0:y1, x0:x1]
405
+ if self.dof is not None:
406
+ # Blur colour and mask together, premultiplied, so the glass
407
+ # edge goes soft exactly as the far end of the bezel already is.
408
+ warped_screen = self.dof.blur_layer(warped_screen.astype(np.float32))
409
+ win = self.dof.alpha3
387
410
  pw = self.photo[y0:y1, x0:x1]
388
411
  src = self._blend(pw, warped_screen)
389
412
  out[y0:y1, x0:x1] = np.clip(
@@ -410,7 +433,8 @@ def compose(photo: np.ndarray, screenshot: np.ndarray, corners, corner_radius: f
410
433
  corner_smoothing: float = 0.0,
411
434
  grade: float = 0.0, grain: bool = False, screen_off: np.ndarray = None,
412
435
  specular: float = 0.75, blend: str = "replace",
413
- reflection: float = DEFAULT_REFLECTION) -> np.ndarray:
436
+ reflection: float = DEFAULT_REFLECTION,
437
+ dof_angle: float = 0.0, dof_strength: float = 0.0) -> np.ndarray:
414
438
  """Warp `screenshot` into the quad `corners` (TL,TR,BR,BL, photo pixels) on `photo`.
415
439
 
416
440
  Single resampling pass at the photo's resolution; deterministic. This is the
@@ -423,7 +447,8 @@ def compose(photo: np.ndarray, screenshot: np.ndarray, corners, corner_radius: f
423
447
  # asserted byte-identical to this function's output.
424
448
  plan = Plan(photo, screenshot.shape, corners, corner_radius, grain=grain,
425
449
  corner_smoothing=corner_smoothing,
426
- blend=blend, reflection=reflection)
450
+ blend=blend, reflection=reflection,
451
+ dof_angle=dof_angle, dof_strength=dof_strength)
427
452
  plan.bind_grade(screenshot, grade)
428
453
  return plan.render(screenshot, screen_off=screen_off, specular=specular)
429
454
 
@@ -510,7 +535,8 @@ def compose_video(photo: np.ndarray, video_path: str, corners, output: str,
510
535
  preset: str = "web", fit_frame: int = 0, audio: bool = True,
511
536
  frames_dir: str = None, progress=None, blend: str = "replace",
512
537
  reflection: float = DEFAULT_REFLECTION,
513
- start_frame: int = 0, max_frames: int = None) -> dict:
538
+ start_frame: int = 0, max_frames: int = None,
539
+ dof_angle: float = 0.0, dof_strength: float = 0.0) -> dict:
514
540
  """Inject a VIDEO into a still photo. The photo does not move, so there is
515
541
  exactly one homography and the whole of Plan is computed once.
516
542
 
@@ -537,7 +563,8 @@ def compose_video(photo: np.ndarray, video_path: str, corners, output: str,
537
563
  first = read_frame_at(video_path, fit_frame)
538
564
  plan = Plan(photo, first.shape, corners, corner_radius, grain=grain,
539
565
  corner_smoothing=corner_smoothing,
540
- blend=blend, reflection=reflection)
566
+ blend=blend, reflection=reflection,
567
+ dof_angle=dof_angle, dof_strength=dof_strength)
541
568
  plan.bind_grade(first, grade)
542
569
 
543
570
  ph, pw = photo.shape[:2]
@@ -5,7 +5,7 @@ description: Injects a UI screenshot OR a screen recording onto a photographed d
5
5
 
6
6
  # Inject a screenshot onto a photographed device
7
7
 
8
- **What ships (v0.50):** a local browser UI (`scripts/ui.py`) that walks the designer through the whole job — pick the photo and the screen source, which may be an image **or a video** (recent Desktop/Downloads images, drag-drop, browse, path, or a **Figma frame link**), auto-detect the screen as a starting position — and when detection cannot tell which region is a screen, **Point at screen**: one click inside it and the detector uses that point — or, if this photograph has been fitted before, **the fit it was saved with comes back** as the starting position instead of a detection, recognised by the photo's own pixels so a rename or a drag-drop still match — and every save also writes a **portable `.fit.json` beside the mockup** that can be dropped back onto the page later, which is how a fit survives a re-export, another machine, or someone else's hands — then **match the four edges** (drag an edge's middle to slide it, near an end to pivot; corners still draggable) with canvas navigation that follows the usual conventions — **hold ⌘ and scroll to zoom to the pointer, hold space and drag to pan** — and a rectified strip loupe. The fit and the composite sit **side by side and always have** — the result pane re-renders as you drag, which is how a corner gets judged, so it is the layout rather than a mode you can switch off. Then an on-by-default realism pass that colour-matches the source to the photo's light, **Save** (or **Render**, for a video) into the project folder (`--out-dir`), and a **Send to Claude** button that reaches you through the plugin's own MCP server. The UI is a hand port of the project's Figma design file — dark only.
8
+ **What ships (v0.51):** a local browser UI (`scripts/ui.py`) that walks the designer through the whole job — pick the photo and the screen source, which may be an image **or a video** (recent Desktop/Downloads images, drag-drop, browse, path, or a **Figma frame link**), auto-detect the screen as a starting position — and when detection cannot tell which region is a screen, **Point at screen**: one click inside it and the detector uses that point — or, if this photograph has been fitted before, **the fit it was saved with comes back** as the starting position instead of a detection, recognised by the photo's own pixels so a rename or a drag-drop still match — and every save also writes a **portable `.fit.json` beside the mockup** that can be dropped back onto the page later, which is how a fit survives a re-export, another machine, or someone else's hands — then **match the four edges** (drag an edge's middle to slide it, near an end to pivot; corners still draggable) with canvas navigation that follows the usual conventions — **hold ⌘ and scroll to zoom to the pointer, hold space and drag to pan** — and a rectified strip loupe. The fit and the composite sit **side by side and always have** — the result pane re-renders as you drag, which is how a corner gets judged, so it is the layout rather than a mode you can switch off. Then an on-by-default realism pass that colour-matches the source to the photo's light, **Save** (or **Render**, for a video) into the project folder (`--out-dir`), and a **Send to Claude** button that reaches you through the plugin's own MCP server. The UI is a hand port of the project's Figma design file — dark only.
9
9
 
10
10
  The geometry is exact (`warp.py`); the detection is advisory (`detect.py`) and the human corrects it. **When a detection is wrong and you want to know why**, ask for the candidate list: `POST /api/detect {"trace": true}` writes `<session>/candidates.json`, or run `python3 scripts/detect.py --photo P --out-corners /tmp/c.json --trace /tmp/t.json` (add `--click X,Y`). Every candidate quad is in there with its score and whether it was accepted, rejected, never reached, or filtered out by the click — which is what separates "the screen was never proposed" from "it was proposed and something else won".
11
11
 
@@ -13,6 +13,8 @@ The geometry is exact (`warp.py`); the detection is advisory (`detect.py`) and t
13
13
 
14
14
  **The realism pass ships and is ON by default** (`grade.py`): it matches the injected screen's white balance and grain to the light around it, at a strength the designer sets in the rail. It can also lift the device's real specular highlights from a screen-off reference frame, though the UI cannot supply one yet. Off is a first-class choice and keeps the screenshot's colour exactly — say so if the user is reviewing brand colour.
15
15
 
16
+ **Depth of field** *(off by default)*: a phone shot at an angle is a plane receding from the camera, so its far end is softer than its near end, and a screenshot pasted pin-sharp across all of it gives the fake away. On, the screenshot blurs across the screen in one direction — **direction** (where the blur grows toward) and **strength** — and the glass edge softens with it. **Measure from photo** reads both off the photograph's own screen boundary; on a real photograph that works, on a *mockup template* the device is usually rendered sharp with the blur only on the background, so it answers "flat" and the designer sets it by eye. The measured strength is a floor (the estimator saturates around 5px of blur), never a ceiling. Suggest it when the photo has visible bokeh — a blurred hand, table edge or background — and the composite's screen looks pasted on. The live in-place playback cannot show it; the composite and Render preview do.
17
+
16
18
  **Video ships too.** The screen source can be a video (mp4/mov/webm) as well as a still — pick it exactly like a screenshot, choose which frame to match the edges on, **press Play and the clip runs on the photo immediately** — the browser warps it onto the same four corners with the same corner radius and approximates the emissive blend, so placement and motion can be judged with no wait; **Render preview** composites a few seconds through the real pipeline when the grade, grain and true blend are what you need to see — and the primary button becomes **Render**. The photo does not move, so there is one homography and every frame gets the same geometry; the light match is measured once from the frame you fitted on, so the screen cannot pulse as the UI scrolls. Output is H.264 at CRF 16 (near-visually-lossless) or ProRes 422 HQ. This is what pairs with a prototype recording: record the prototype, then inject the recording into a real photograph.
17
19
 
18
20
  Still missing: **no ML detection** (measured, and it segments the phone body rather than the glass, so it is not shipped), **no occluder handling** — a finger or glare in front of the screen gets painted over — and **no camera motion**: the photo must be a still, so a clip of a moving phone is not this. Also, detection **abstains** rather than guessing when the background is itself neutral (a pale tiled floor, a plain wall); the edges get placed by hand there, which is normal, not a failure. Say so if any of it matters for the photo.
@@ -94,7 +96,7 @@ It parks until the user presses a button and returns within ~150 ms of the press
94
96
 
95
97
  | result | what to do |
96
98
  |---|---|
97
- | `status=job`, `job.type="figma_export"` | Extract `fileKey` and `nodeId` from `job.url` (`1-2` → `1:2`), export via the Figma MCP (`download_assets`, PNG, scale 3), save to `job.save_to`, then `complete_job(status="done", path=<saved file>)`. Re-arm. |
99
+ | `status=job`, `job.type="figma_export"` | Extract `fileKey` and `nodeId` from `job.url` (`1-2` → `1:2`), export via the Figma MCP (`download_assets`, PNG, scale 3), save to `job.save_to`, then `complete_job(status="done", path=<saved file>)`. If `complete_job` answers that the job is already **cancelled**, the user pressed Cancel in the page — say so in one line and re-arm; do not retry. Re-arm. |
98
100
  | `status=job`, `job.type="present"` | `present_files` on `job.paths`, report what you checked in the composite (§3), then `complete_job(status="done")`. Re-arm. |
99
101
  | `status=timeout` | Nothing pressed yet. Call again to keep waiting. Re-arm two or three times, then stop and say you've stopped waiting — do not loop forever burning turns. |
100
102
  | `status=ui_closed` | The UI exited. Stop; say so. |
package/ui/index.html CHANGED
@@ -1049,7 +1049,7 @@
1049
1049
  end. Icon-only, so each carries an aria-label — a title alone
1050
1050
  names it for a mouse and not for a screen reader. -->
1051
1051
  <button class="sm icon" id="playlive" aria-label="Play the clip on the photo"
1052
- title="Play the clip on the photo, right now. The browser applies the same four corners and corner radius, and approximates emissive with screen blending; the colour grade and grain are not in this view.">
1052
+ title="Play the clip on the photo, right now. The browser applies the same four corners and corner radius, and approximates emissive with screen blending; the colour grade, grain and depth of field are not in this view.">
1053
1053
  <svg viewBox="0 0 12 12" width="11" height="11" aria-hidden="true" focusable="false"><path d="M3 1.5 10 6 3 10.5Z" fill="currentColor"/></svg>
1054
1054
  </button>
1055
1055
  <button class="sm" id="playclip"
@@ -1113,6 +1113,35 @@
1113
1113
  </div>
1114
1114
  </div></div>
1115
1115
  </div>
1116
+ <div class="sect" id="secDof" data-on="0">
1117
+ <div class="sect-top">
1118
+ <div class="sect-left">
1119
+ <h4>Depth of field</h4>
1120
+ <button class="info" type="button" aria-label="About depth of field"><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">A phone shot at an angle is a plane receding from the camera, so its far end is softer than its near end — and a screenshot pasted pin-sharp across all of it gives the fake away. On, the screenshot blurs across the screen in one direction, and the glass edge softens with it. Measure reads the direction and a starting strength off your photo's own bezel; the strength it finds is a floor, so raise it if the far end of the phone looks softer than the screen.</span></button>
1121
+ </div>
1122
+ <button class="switch" id="dofBtn" role="switch" aria-checked="false" aria-label="Depth of field">
1123
+ <span class="sw-track"><span class="sw-handle"></span><span class="sw-grip"></span></span>
1124
+ </button>
1125
+ </div>
1126
+ <div class="sect-body"><div class="sect-body-in">
1127
+ <div style="display:grid;gap:var(--s2)">
1128
+ <div style="display:flex;align-items:center;justify-content:space-between;gap:var(--s2)">
1129
+ <span class="sm" style="color:var(--mute)">Blur grows toward</span>
1130
+ <span class="sm" id="dofDirVal" style="font-variant-numeric:tabular-nums"></span>
1131
+ </div>
1132
+ <input type="range" id="dofDir" min="0" max="355" step="5" value="90" aria-label="Direction the blur grows in">
1133
+ <div style="display:flex;align-items:center;justify-content:space-between;gap:var(--s2)">
1134
+ <span class="sm" style="color:var(--mute)">Strength</span>
1135
+ <span class="sm" id="dofStrVal" style="font-variant-numeric:tabular-nums"></span>
1136
+ </div>
1137
+ <input type="range" id="dofStr" min="0" max="1" step="0.05" value="0.3" aria-label="Blur strength at the far edge">
1138
+ <div style="display:flex;align-items:center;gap:var(--s2)">
1139
+ <button class="sm" id="dofMeasure" title="Read the direction and a starting strength off the photo's own bezel">Measure from photo</button>
1140
+ <span class="status sm" id="dofSt" style="min-width:0"></span>
1141
+ </div>
1142
+ </div>
1143
+ </div></div>
1144
+ </div>
1116
1145
  <div class="sect" id="secEdge" data-on="1">
1117
1146
  <div class="sect-top">
1118
1147
  <div class="sect-left">
@@ -1180,8 +1209,8 @@
1180
1209
 
1181
1210
  <div class="pop" id="pop2">
1182
1211
  <h3>Screenshot to put on the screen</h3>
1183
- <p class="sub">A PNG/JPG of the UI — or paste a Figma frame link and Claude will export it for you.</p>
1184
- <div class="row" style="margin-bottom:8px"><input type="text" id="figma" placeholder="https://www.figma.com/design/…?node-id=…" style="flex:1"><button id="figmaBtn">Export from Figma</button></div>
1212
+ <p class="sub">A PNG/JPG of the UI — or paste a Figma frame link and Claude will import it for you.</p>
1213
+ <div class="row" style="margin-bottom:8px"><input type="text" id="figma" placeholder="https://www.figma.com/design/…?node-id=…" style="flex:1"><button id="figmaBtn">Import from Figma</button></div>
1185
1214
  <div class="status sm" id="figmaSt" style="margin-bottom:10px"></div>
1186
1215
  <div class="hintline" id="figmaHint" hidden style="margin-bottom:10px"></div>
1187
1216
  <div class="picker">
@@ -1393,6 +1422,9 @@ const api = async (p, body, raw) => {
1393
1422
  const j = await r.json(); if (!r.ok) throw new Error(j.error || r.statusText); return j;
1394
1423
  };
1395
1424
  const fileURL = p => '/file?path=' + encodeURIComponent(p);
1425
+ // A source's URL carries the file's mtime, so new bytes at an old path are
1426
+ // fetched rather than served from the browser's cache (see _adopt in ui.py).
1427
+ const srcURL = (p, v) => fileURL(p) + (v ? '&v=' + v : '');
1396
1428
  const st = {photo:null, shot:null, corners:null, frac:0, type:null, preset:null, measured:null,
1397
1429
  // Where the quad on screen came from: 'detected' | 'remembered' | 'restored'
1398
1430
  // | 'default'. The tool's contract is that it never presents a guess as a
@@ -1597,7 +1629,8 @@ function setChosen(role, path, size, el, meta){
1597
1629
  if (el) el.classList.add('sel');
1598
1630
  // A video cannot be shown by <img>, so a clip gets its poster frame instead.
1599
1631
  const thumb = (meta && meta.poster) ? meta.poster : path;
1600
- $('#pv'+n).innerHTML = `<img src="${fileURL(thumb)}" alt="">`;
1632
+ const ver = (meta && meta.mtime) || Date.now();
1633
+ $('#pv'+n).innerHTML = `<img src="${srcURL(thumb, ver)}" alt="">`;
1601
1634
  const chip = $('#chip'+n);
1602
1635
  chip.classList.remove('is-empty');
1603
1636
  // The file's own name and nothing else. The size and, for a clip, the frame
@@ -1613,14 +1646,16 @@ function setChosen(role, path, size, el, meta){
1613
1646
  if ((role==='photo' ? st.photo : st.shot) !== path) dropResult();
1614
1647
  chip.title = `${homePath(path)}\n${size[0]}×${size[1]}`
1615
1648
  + (meta && meta.video ? ` · ${meta.frames} frames @ ${Math.round(meta.fps)}fps` : '');
1616
- $('#sw'+n).style.background = `center/cover no-repeat url("${fileURL(thumb)}")`;
1649
+ $('#sw'+n).style.background = `center/cover no-repeat url("${srcURL(thumb, ver)}")`;
1617
1650
  $('#clear'+n).hidden = false;
1618
1651
  // A fit the server recognised from this photograph's own pixels. Held
1619
1652
  // rather than applied here: the quad only means anything once there is a
1620
1653
  // screenshot to put inside it, which is what maybeStart() waits for.
1621
- if (role==='photo'){ st.photo = path; st.corners = null;
1654
+ if (role==='photo'){ st.photo = path; st.photoV = ver; st.corners = null;
1622
1655
  st.remembered = (meta && meta.remembered) || null; }
1623
- else { st.shot = path; st.shotSize = size; st.video = meta && meta.video ? meta : null; syncVideoUI();
1656
+ else { st.shot = path; st.shotV = ver; st.shotSize = size; st.video = meta && meta.video ? meta : null; syncVideoUI();
1657
+ // The status line may still be narrating the clear ("Screenshot removed…").
1658
+ if (st.corners){ $('#detSt').className = 'status ok'; $('#detSt').textContent = 'Screenshot changed — the fit on the photo is kept'; }
1624
1659
  // A detected quad is re-oriented for the new screenshot; a remembered or
1625
1660
  // hand-placed one is somebody's decision and is left alone.
1626
1661
  if (st.corners && st.quadFrom === 'detected' && orientQuad()){ draw(); drawStrip(); autoPreview(); } }
@@ -1693,6 +1728,7 @@ async function awaitJob({onDone, onError, cancelled}) {
1693
1728
  if (cancelled()) return;
1694
1729
  if (j.status === 'done') return onDone(j);
1695
1730
  if (j.status === 'error') return onError(j);
1731
+ if (j.status === 'cancelled') return; // this page cancelled it (or another tab did)
1696
1732
  if (j.status === 'none') {
1697
1733
  // The job file is gone — a wiped session, or a restarted server. The
1698
1734
  // server answers 'none' instantly, so looping on it would spin as fast
@@ -1702,16 +1738,37 @@ async function awaitJob({onDone, onError, cancelled}) {
1702
1738
  // 'pending' with waited:true — the long poll timed out server-side; go again.
1703
1739
  }
1704
1740
  }
1741
+ /* Import from Figma. One button, two jobs: it starts the import, and while
1742
+ the import is pending it reads "Cancel" and stops it — before this the only
1743
+ way out of a pending import was to reload the page (12 Sep 2026). Cancelling
1744
+ tells the server to mark the job cancelled, so an agent that is still
1745
+ exporting gets "already cancelled" from complete_job instead of landing a
1746
+ file on a request nobody is waiting for. */
1747
+ let figmaInFlight = null; // {stop} while an import is pending
1705
1748
  $('#figmaBtn').onclick = async () => {
1749
+ const s = $('#figmaSt'), hint = $('#figmaHint'), btn = $('#figmaBtn');
1750
+ if (figmaInFlight){
1751
+ const inflight = figmaInFlight;
1752
+ try { await api('/api/job/cancel', {}); } catch (e) { /* already finished — the poll will say */ }
1753
+ inflight.stop('', 'Import cancelled.');
1754
+ return;
1755
+ }
1706
1756
  const url = $('#figma').value.trim(); if (!url) return;
1707
- const s = $('#figmaSt'), hint = $('#figmaHint');
1708
- const btn = $('#figmaBtn'); btn.disabled = true;
1757
+ // Only a Figma frame link can be imported, and the job is answered by the
1758
+ // agent, so a wrong URL used to cost a full round trip before anyone saw it
1759
+ // (12 Sep 2026: the workbench's own address was pasted and went out as a job).
1760
+ if (!/^https:\/\/(www\.)?figma\.com\/(design|board|slides|file)\/[^/?#]+/.test(url) || !/node-id=\d+[-:]\d+/.test(url)){
1761
+ s.className = 'status sm err';
1762
+ s.textContent = 'That is not a Figma frame link. Copy the link from Figma (Share → Copy link, or right-click the frame → Copy link) — it needs node-id=… in it.';
1763
+ return;
1764
+ }
1709
1765
  const t0 = Date.now();
1710
1766
  let finished = false;
1767
+ btn.textContent = 'Cancel'; btn.title = 'Stop waiting for this import';
1711
1768
  const paint = () => {
1712
1769
  const el = Math.round((Date.now() - t0) / 1000);
1713
1770
  s.className = 'status sm busy';
1714
- s.innerHTML = `Exporting from Figma <span class="el">${el}s</span>`;
1771
+ s.innerHTML = `Importing from Figma <span class="el">${el}s</span>`;
1715
1772
  if (el >= NUDGE_AFTER && hint.hidden) {
1716
1773
  hint.hidden = false;
1717
1774
  hint.innerHTML = 'Taking longer than usual. Claude picks this up automatically while it\'s ' +
@@ -1726,26 +1783,33 @@ $('#figmaBtn').onclick = async () => {
1726
1783
  // return so the number is right the moment the designer looks at it again.
1727
1784
  document.addEventListener('visibilitychange', paint);
1728
1785
  const stop = (cls, text) => {
1729
- finished = true;
1786
+ if (finished) return;
1787
+ finished = true; figmaInFlight = null;
1730
1788
  clearInterval(tick);
1731
1789
  document.removeEventListener('visibilitychange', paint);
1732
- btn.disabled = false; hint.hidden = true;
1790
+ btn.textContent = 'Import from Figma'; btn.title = '';
1791
+ hint.hidden = true;
1733
1792
  s.className = 'status sm ' + cls; s.textContent = text;
1734
- // The popover is usually closed by now — the in-place line would go unread.
1735
- toast(cls === 'ok' ? 'ok' : 'err', cls === 'ok' ? 'Screenshot exported from Figma.' : text);
1793
+ // ONE channel. While the picker is open the line under the field is in
1794
+ // view, and a toast on top of it said the same sentence twice, overlapping
1795
+ // the popover (12 Sep 2026). A toast only when the popover is closed and
1796
+ // the line would go unread.
1797
+ if (!$('#pop2').classList.contains('on') && cls)
1798
+ toast(cls === 'ok' ? 'ok' : 'err', cls === 'ok' ? 'Screenshot imported from Figma.' : text);
1736
1799
  };
1800
+ figmaInFlight = {stop};
1737
1801
  try { await api('/api/figma', {url}); }
1738
- catch (e) { return stop('err', 'Could not queue the export: ' + e.message); }
1802
+ catch (e) { return stop('err', 'Could not start the import: ' + e.message); }
1739
1803
  await awaitJob({
1740
1804
  cancelled: () => finished,
1741
1805
  onDone: async () => {
1742
1806
  try {
1743
1807
  const r = await api('/api/job/adopt', {});
1744
- stop('ok', 'Exported');
1808
+ stop('ok', 'Imported');
1745
1809
  setChosen('screenshot', r.path, r.size, null, r);
1746
- } catch (e) { stop('err', 'Exported, but the file could not be read: ' + e.message); }
1810
+ } catch (e) { stop('err', 'Imported, but the file could not be read: ' + e.message); }
1747
1811
  },
1748
- onError: (j) => stop('err', 'Export failed: ' + (j.message || '')),
1812
+ onError: (j) => stop('err', 'Import failed: ' + (j.message || '')),
1749
1813
  });
1750
1814
  };
1751
1815
 
@@ -1820,7 +1884,7 @@ async function maybeStart(){
1820
1884
  else if (st.remembered) applyRemembered();
1821
1885
  else detect();
1822
1886
  };
1823
- img.src = fileURL(st.photo);
1887
+ img.src = srcURL(st.photo, st.photoV);
1824
1888
  }
1825
1889
  function computeFit(){
1826
1890
  const box = $('#scroller');
@@ -2467,6 +2531,70 @@ $('#emisAmt').oninput = e => { emisAmt = parseFloat(e.target.value); paintEmis(
2467
2531
  $('#emisAmt').onchange = e => { setEmis(true, parseFloat(e.target.value)); autoPreview(); };
2468
2532
 
2469
2533
 
2534
+ /* Depth of field: a blur that grows across the screen in one direction, and
2535
+ the glass edge softening with it (dof.py). Off sends nothing, so the engine
2536
+ takes the byte-identical path it took before the feature existed. The
2537
+ direction and strength are the designer's numbers; Measure proposes them
2538
+ from the photograph's own bezel and says how far it can be trusted — the
2539
+ measured strength is a floor (the estimator saturates around 5px of blur),
2540
+ never a ceiling. The live layer cannot show it; the composite does. */
2541
+ let dofOn = recall('dof','0') === '1';
2542
+ let dofAng = parseFloat(recall('dofAng','90')) || 0;
2543
+ let dofStr = parseFloat(recall('dofStr','0.3')); if (!(dofStr >= 0)) dofStr = 0.3;
2544
+ function dofAngle(){ return dofOn ? dofAng : 0; }
2545
+ function dofStrength(){ return dofOn ? dofStr : 0; }
2546
+ function dofWord(a){
2547
+ const names = ['the right','the bottom-right','the bottom','the bottom-left','the left','the top-left','the top','the top-right'];
2548
+ return names[Math.round(((a % 360) + 360) % 360 / 45) % 8];
2549
+ }
2550
+ function paintDof(){
2551
+ setSwitch($('#dofBtn'), dofOn);
2552
+ $('#dofDirVal').textContent = `${dofWord(dofAng)} · ${Math.round(dofAng)}°`;
2553
+ $('#dofStrVal').textContent = Math.round(dofStr * 100) + '%';
2554
+ const d = $('#dofDir'); d.value = Math.round(dofAng / 5) * 5; d.style.setProperty('--p', (dofAng % 360) / 355);
2555
+ const st = $('#dofStr'); st.value = dofStr; st.style.setProperty('--p', dofStr);
2556
+ }
2557
+ function setDof(on, ang, str){
2558
+ dofOn = on;
2559
+ if (ang !== undefined) dofAng = ((ang % 360) + 360) % 360;
2560
+ if (str !== undefined) dofStr = Math.max(0, Math.min(1, str));
2561
+ remember('dof', on ? '1' : '0'); remember('dofAng', String(dofAng)); remember('dofStr', String(dofStr));
2562
+ setSectionOpen($('#secDof'), on);
2563
+ paintDof();
2564
+ }
2565
+ let dofMeasuredFor = null; // photo path the last measurement was for
2566
+ async function measureDof(){
2567
+ const s = $('#dofSt');
2568
+ if (!(st.photo && st.corners)){ s.className = 'status sm'; s.textContent = 'Fit the screen first.'; return; }
2569
+ s.className = 'status sm busy'; s.textContent = 'Measuring…';
2570
+ try {
2571
+ const r = await api('/api/dof', {corners: st.corners});
2572
+ dofMeasuredFor = st.photo;
2573
+ if (r.flat){
2574
+ s.className = 'status sm'; s.textContent = 'The bezel is sharp all round — nothing to match. Set by eye if you want it anyway.';
2575
+ s.title = `Edge blur ${(r.sigma || []).map(v => v.toFixed(1)).join(' / ')}px`;
2576
+ autoPreview(); // still on, at the slider's own numbers
2577
+ return;
2578
+ }
2579
+ setDof(true, r.angle, Math.max(r.strength, 0.05));
2580
+ s.className = 'status sm ok';
2581
+ s.textContent = `Measured: grows toward ${dofWord(r.angle)}. Strength is a floor — raise it if the far end looks softer.`;
2582
+ s.title = `Edge blur ${r.sigma.map(v => v.toFixed(1)).join(' / ')}px, spread ${r.spread}×`;
2583
+ autoPreview();
2584
+ } catch (e) { s.className = 'status sm err'; s.textContent = 'Could not measure: ' + e.message; }
2585
+ }
2586
+ $('#dofBtn').onclick = () => {
2587
+ setDof(!dofOn);
2588
+ // First time on for this photograph: propose from the photo rather than
2589
+ // from a remembered slider position that belongs to another one.
2590
+ if (dofOn && dofMeasuredFor !== st.photo && st.corners) measureDof(); else autoPreview();
2591
+ };
2592
+ $('#dofDir').oninput = e => { dofAng = parseFloat(e.target.value); paintDof(); };
2593
+ $('#dofDir').onchange = e => { setDof(true, parseFloat(e.target.value)); autoPreview(); };
2594
+ $('#dofStr').oninput = e => { dofStr = parseFloat(e.target.value); paintDof(); };
2595
+ $('#dofStr').onchange = e => { setDof(true, undefined, parseFloat(e.target.value)); autoPreview(); };
2596
+ $('#dofMeasure').onclick = measureDof;
2597
+
2470
2598
  $('#edgeBtn').onclick = () => setLoupeMode(loupeMode === 'float' ? 'dock' : 'float');
2471
2599
 
2472
2600
  /* Contrast for the strip only. A dark screen on a dark frame puts the
@@ -2713,6 +2841,9 @@ function showResult(what){
2713
2841
  geometry — exact, the same four corners the render uses
2714
2842
  corner radius — applied in the video's own pixel space, before the warp,
2715
2843
  which is where compose() applies it
2844
+ depth of field — ABSENT. A blur that grows across the screen has no
2845
+ CSS equivalent that follows a homography; the composite
2846
+ is the only view that shows it.
2716
2847
  emissive — APPROXIMATED with `screen` blending. Same idea (the
2717
2848
  screen's light plus the glass beneath it), not the same
2718
2849
  arithmetic as compose()'s emissive.
@@ -2809,10 +2940,10 @@ async function playLive(){
2809
2940
  if (!(st.photo && st.shot && st.corners && st.video)) return;
2810
2941
  if (!$('#liveWrap').hidden && !$('#liveVid').paused) return stopLive();
2811
2942
  const v = $('#liveVid'), s = $('#outSt');
2812
- $('#liveBg').src = fileURL(st.photo);
2943
+ $('#liveBg').src = srcURL(st.photo, st.photoV);
2813
2944
  if (!v.src || v.dataset.src !== st.shot){
2814
2945
  v.dataset.src = st.shot;
2815
- v.src = fileURL(st.shot);
2946
+ v.src = srcURL(st.shot, st.shotV);
2816
2947
  }
2817
2948
  showResult('live');
2818
2949
  await new Promise(r => {
@@ -2859,7 +2990,7 @@ async function playClip(){
2859
2990
  // a preview the user did not ask for, and the wait goes silent again.
2860
2991
  building = true;
2861
2992
  try{
2862
- await api('/api/preview_video', {corners: st.corners, radius_frac: radiusValue(), smoothing: smoothingValue(),
2993
+ await api('/api/preview_video', {corners: st.corners, radius_frac: radiusValue(), smoothing: smoothingValue(), dof_angle: dofAngle(), dof_strength: dofStrength(),
2863
2994
  device: st.type, grade: gradeValue(),
2864
2995
  reflection: emisValue(), fit_frame: +$('#vframe').value});
2865
2996
  }catch(e){
@@ -2918,7 +3049,7 @@ async function renderPreview(){
2918
3049
  const say = t => { if (!building && $('#liveWrap').hidden) s.textContent = t; };
2919
3050
  say('Rendering…');
2920
3051
  try{
2921
- const r = await api('/api/preview', {corners: st.corners, radius_frac: radiusValue(), smoothing: smoothingValue(), device: st.type, grade: gradeValue(), reflection: emisValue()});
3052
+ const r = await api('/api/preview', {corners: st.corners, radius_frac: radiusValue(), smoothing: smoothingValue(), dof_angle: dofAngle(), dof_strength: dofStrength(), device: st.type, grade: gradeValue(), reflection: emisValue()});
2922
3053
  const im = $('#outImg');
2923
3054
  im.onload = () => {
2924
3055
  outNat = im.naturalWidth;
@@ -3048,7 +3179,7 @@ async function renderVideo(){
3048
3179
  b.disabled = true;
3049
3180
  setRenderProgress(0);
3050
3181
  try{
3051
- await api('/api/render', {corners: st.corners, radius_frac: radiusValue(), smoothing: smoothingValue(), device: st.type,
3182
+ await api('/api/render', {corners: st.corners, radius_frac: radiusValue(), smoothing: smoothingValue(), dof_angle: dofAngle(), dof_strength: dofStrength(), device: st.type,
3052
3183
  grade: gradeValue(), reflection: emisValue(), preset, fit_frame: +$('#vframe').value});
3053
3184
  }catch(e){
3054
3185
  setRenderProgress(null);
@@ -3094,7 +3225,7 @@ $('#save').onclick = async () => {
3094
3225
  if (st.video) return renderVideo();
3095
3226
  const b = $('#save'); b.textContent = 'Saving…'; b.disabled = true;
3096
3227
  try{
3097
- const r = await api('/api/save', {corners: st.corners, radius_frac: radiusValue(), smoothing: smoothingValue(), device: st.type, grade: gradeValue(), reflection: emisValue()});
3228
+ const r = await api('/api/save', {corners: st.corners, radius_frac: radiusValue(), smoothing: smoothingValue(), dof_angle: dofAngle(), dof_strength: dofStrength(), device: st.type, grade: gradeValue(), reflection: emisValue()});
3098
3229
  b.textContent = saveLabel();
3099
3230
  // The real destination, not a hardcoded one: --out-dir means saves usually
3100
3231
  // land in the project folder now, and telling the user "~/Desktop" when
@@ -3173,6 +3304,7 @@ $('#imp').onclick = async () => {
3173
3304
  setStripHC(stripHC);
3174
3305
  setGrade(gradeOn, gradeAmt);
3175
3306
  setEmis(emisOn, emisAmt);
3307
+ setDof(dofOn, dofAng, dofStr);
3176
3308
  setRadiusOn(radiusOn);
3177
3309
  sectionsBooted = true; // animate from here on, not during the first paint
3178
3310
  // Compare is the DEFAULT view: judging a fit means comparing it with the