screengraft 0.52.0 → 0.53.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "screengraft",
3
- "version": "0.52.0",
3
+ "version": "0.53.0",
4
4
  "description": "Put a UI screenshot or screen recording onto a photographed device screen with the perspective exactly right \u2014 a homography you confirm by hand, not a generative guess.",
5
5
  "keywords": [
6
6
  "mockup",
package/scripts/dof.py CHANGED
@@ -16,6 +16,16 @@ softens exactly as the colour does.
16
16
  Spatially varying Gaussian: DOF_LEVELS blur levels, per pixel a linear blend
17
17
  of the two nearest. Standard, cheap (five separable blurs on the quad's
18
18
  window), and byte-identical to no blur at strength 0.
19
+
20
+ WHICH SPACE THE RAMP LIVES IN (13 Sep 2026). The plane of focus cuts the
21
+ screen along a line, and blur grows with depth *along the screen*, so
22
+ iso-blur lines are parallel ON THE SCREEN PLANE — and parallel lines on a
23
+ receding plane converge in the photograph, like the phone's own edges. The
24
+ first version ramped linearly in photo pixels, which is only right for a
25
+ screen seen square-on; the mismatch was felt on a steep fit ("top and
26
+ bottom are not perpendicular"). `space="screen"` builds the ramp in the
27
+ screenshot's own coordinates and projects it through the fit's homography;
28
+ `space="photo"` is kept so the sidecars written by v0.51–v0.52 reproduce.
19
29
  """
20
30
  import math
21
31
 
@@ -80,17 +90,45 @@ def _blur(img: np.ndarray, sigma: float) -> np.ndarray:
80
90
  return cv2.GaussianBlur(img, (k, k), sigma, borderType=cv2.BORDER_REPLICATE)
81
91
 
82
92
 
93
+ def screen_ramp(src_w: int, src_h: int, angle_deg: float, start: float, end: float) -> np.ndarray:
94
+ """The ramp in SCREENSHOT space: 0 up to `start`, 1 from `end`, linear
95
+ between, along `angle` (0 = toward +x, 90 = toward +y of the screenshot),
96
+ both as fractions of the screenshot's extent along that direction."""
97
+ a = math.radians(angle_deg)
98
+ d = np.array([math.cos(a), math.sin(a)], dtype=np.float64)
99
+ q = np.array([[0, 0], [src_w, 0], [src_w, src_h], [0, src_h]], dtype=np.float64)
100
+ proj = q @ d
101
+ lo, hi = float(proj.min()), float(proj.max())
102
+ start = float(np.clip(start, 0.0, 0.95))
103
+ end = float(np.clip(end, start + 0.05, 1.5))
104
+ s0, s1 = lo + start * (hi - lo), lo + end * (hi - lo)
105
+ xs = np.arange(src_w, dtype=np.float64)[None, :] + 0.5
106
+ ys = np.arange(src_h, dtype=np.float64)[:, None] + 0.5
107
+ t = (xs * d[0] + ys * d[1] - s0) / max(s1 - s0, 1e-6)
108
+ return np.clip(t, 0.0, 1.0).astype(np.float32)
109
+
110
+
83
111
  class Field:
84
112
  """Everything that does not change per frame: the ramp, the weights, the
85
113
  blurred masks. `blur_layer` then costs `levels - 1` blurs of the colour."""
86
114
 
87
115
  def __init__(self, corners, angle_deg: float, strength: float, mask: np.ndarray,
88
- x0: int, y0: int, start: float = 0.0):
116
+ x0: int, y0: int, start: float = 0.0, end: float = 1.0,
117
+ space: str = "photo", H=None, src_size=None):
89
118
  h, w = mask.shape[:2]
90
119
  self.x0, self.y0 = x0, y0
91
120
  self.smax = sigma_max(corners, strength)
92
121
  self.sigmas = [self.smax * k / (DOF_LEVELS - 1) for k in range(DOF_LEVELS)]
93
- t = ramp(corners, angle_deg, x0, y0, w, h, start)
122
+ if space == "screen" and H is not None and src_size is not None:
123
+ sw, sh = src_size
124
+ src = screen_ramp(sw, sh, angle_deg, start, end)
125
+ T = np.array([[1, 0, -x0], [0, 1, -y0], [0, 0, 1]], dtype=np.float64)
126
+ t = cv2.warpPerspective(src, T @ np.asarray(H, dtype=np.float64), (w, h),
127
+ flags=cv2.INTER_LINEAR, borderMode=cv2.BORDER_REPLICATE)
128
+ t = np.clip(t, 0.0, 1.0).astype(np.float32)
129
+ else:
130
+ t = ramp(corners, angle_deg, x0, y0, w, h, start)
131
+ self.t = t # kept for tests and the trace
94
132
  self.W = level_weights(t) # (L, h, w)
95
133
  m = mask.astype(np.float32) / 255.0
96
134
  self.masks = [_blur(m, s) for s in self.sigmas] # each (h, w)
package/scripts/ui.py CHANGED
@@ -578,8 +578,7 @@ PREVIEW_SECONDS = 6.0
578
578
 
579
579
  def _render_worker(photo, video_path, corners, dest, radius_px, gr, grain, preset, fit_frame,
580
580
  blend="replace", reflection=None, result=None, kind="render",
581
- start_frame=0, max_frames=None, *, smoothing=0.0,
582
- dof_angle=0.0, dof_strength=0.0, dof_start=0.0):
581
+ start_frame=0, max_frames=None, *, smoothing=0.0, dof=None):
583
582
  """Encode the clip, and only if that SUCCEEDS publish what it produced.
584
583
 
585
584
  `result` is the sidecar this render would write. It is handed to the worker
@@ -605,8 +604,7 @@ def _render_worker(photo, video_path, corners, dest, radius_px, gr, grain, prese
605
604
  reflection=(W.DEFAULT_REFLECTION if reflection is None
606
605
  else reflection),
607
606
  start_frame=start_frame, max_frames=max_frames,
608
- dof_angle=dof_angle, dof_strength=dof_strength,
609
- dof_start=dof_start)
607
+ **(dof or {}))
610
608
  if kind == "preview":
611
609
  # A preview publishes NOTHING. It is not a save: no sidecar, no fit
612
610
  # file, and above all not the session output -- /api/import reads
@@ -669,17 +667,27 @@ def _blend_args(b):
669
667
 
670
668
 
671
669
  def _dof_args(b):
672
- """(dof_angle, dof_strength, dof_start) from the page. Absent or null
673
- strength means 0 -- no field, and every path in Plan untouched -- so a
674
- client that predates depth of field and a sidecar replayed through the CLI
675
- both reproduce. `start` absent means 0: the whole screen ramps."""
670
+ """Depth-of-field kwargs from the page, as a dict compose() takes directly.
671
+
672
+ Absent or null strength means 0 -- no field, every path in Plan untouched
673
+ -- so a client that predates depth of field and a sidecar replayed
674
+ through the CLI both reproduce. `start` absent means 0 (the whole screen
675
+ ramps), `end` absent means 1 (to the far edge), `space` absent means
676
+ "photo": the v0.51–v0.52 model, so those sidecars reproduce too; the page
677
+ sends "screen" now (see dof.py's module docstring for why).
678
+ """
676
679
  try:
677
680
  strength = float(b.get("dof_strength") or 0.0)
678
681
  angle = float(b.get("dof_angle") or 0.0)
679
682
  start = float(b.get("dof_start") or 0.0)
683
+ end = float(b.get("dof_end") if b.get("dof_end") is not None else 1.0)
680
684
  except (TypeError, ValueError):
681
- return 0.0, 0.0, 0.0
682
- return angle % 360.0, float(min(max(strength, 0.0), 1.0)), float(min(max(start, 0.0), 0.95))
685
+ strength, angle, start, end = 0.0, 0.0, 0.0, 1.0
686
+ space = "screen" if b.get("dof_space") == "screen" else "photo"
687
+ start = float(min(max(start, 0.0), 0.95))
688
+ return {"dof_angle": angle % 360.0, "dof_strength": float(min(max(strength, 0.0), 1.0)),
689
+ "dof_start": start, "dof_end": float(min(max(end, start + 0.05), 1.5)),
690
+ "dof_space": space}
683
691
 
684
692
 
685
693
  ROLES = ("photo", "screenshot")
@@ -1180,9 +1188,7 @@ class Handler(BaseHTTPRequestHandler):
1180
1188
  blend, reflection, None, "preview",
1181
1189
  fit_frame, max_frames),
1182
1190
  kwargs={"smoothing": _smoothing(b),
1183
- "dof_angle": _dof_args(b)[0],
1184
- "dof_strength": _dof_args(b)[1],
1185
- "dof_start": _dof_args(b)[2]}).start()
1191
+ "dof": _dof_args(b)}).start()
1186
1192
  except BaseException:
1187
1193
  with RENDER_LOCK:
1188
1194
  RENDER.update(state="error", message="could not start the preview")
@@ -1216,7 +1222,7 @@ class Handler(BaseHTTPRequestHandler):
1216
1222
  gr = float(b.get("grade") if b.get("grade") is not None else 0.0)
1217
1223
  grain = bool(b.get("grain", gr > 0))
1218
1224
  blend, reflection = _blend_args(b)
1219
- dof_angle, dof_strength, dof_start = _dof_args(b)
1225
+ dof = _dof_args(b)
1220
1226
  preset = "prores" if b.get("preset") == "prores" else "web"
1221
1227
  ext = ".mov" if preset == "prores" else ".mp4"
1222
1228
  os.makedirs(OUT_DIR, exist_ok=True)
@@ -1238,7 +1244,9 @@ class Handler(BaseHTTPRequestHandler):
1238
1244
  "grade": gr, "grain": grain,
1239
1245
  "video": True, "preset": preset, "fit_frame": fit_frame,
1240
1246
  "blend": blend, "reflection": reflection,
1241
- "dof_angle": dof_angle, "dof_strength": dof_strength, "dof_start": dof_start,
1247
+ "dof_angle": dof["dof_angle"], "dof_strength": dof["dof_strength"],
1248
+ "dof_start": dof["dof_start"], "dof_end": dof["dof_end"],
1249
+ "dof_space": dof["dof_space"],
1242
1250
  # A render is always the whole clip; only the preview
1243
1251
  # passes a segment. Recorded anyway, because the
1244
1252
  # sidecar's promise is EVERY argument that changes the
@@ -1266,9 +1274,7 @@ class Handler(BaseHTTPRequestHandler):
1266
1274
  gr, grain, preset, fit_frame,
1267
1275
  blend, reflection, result),
1268
1276
  kwargs={"smoothing": _smoothing(b),
1269
- "dof_angle": dof_angle,
1270
- "dof_strength": dof_strength,
1271
- "dof_start": dof_start}).start()
1277
+ "dof": dof}).start()
1272
1278
  except BaseException:
1273
1279
  # If the thread cannot even be created, the flag must not
1274
1280
  # outlive the request.
@@ -1291,13 +1297,11 @@ class Handler(BaseHTTPRequestHandler):
1291
1297
  gr = float(b.get("grade") if b.get("grade") is not None else 0.0)
1292
1298
  blend, reflection = _blend_args(b)
1293
1299
  smoothing = _smoothing(b)
1294
- dof_angle, dof_strength, dof_start = _dof_args(b)
1300
+ dof = _dof_args(b)
1295
1301
  out = W.compose(photo, shot, corners, radius_px,
1296
1302
  corner_smoothing=smoothing,
1297
1303
  grade=gr, grain=bool(b.get("grain", gr > 0)),
1298
- blend=blend, reflection=reflection,
1299
- dof_angle=dof_angle, dof_strength=dof_strength,
1300
- dof_start=dof_start)
1304
+ blend=blend, reflection=reflection, **dof)
1301
1305
  SESSION.update(corners=corners, radius_frac=frac, device=b.get("device"),
1302
1306
  grade=gr)
1303
1307
  if u.path == "/api/preview":
@@ -1333,7 +1337,9 @@ class Handler(BaseHTTPRequestHandler):
1333
1337
  "corner_smoothing": smoothing,
1334
1338
  "grade": gr, "grain": bool(b.get("grain", gr > 0)),
1335
1339
  "blend": blend, "reflection": reflection,
1336
- "dof_angle": dof_angle, "dof_strength": dof_strength, "dof_start": dof_start,
1340
+ "dof_angle": dof["dof_angle"], "dof_strength": dof["dof_strength"],
1341
+ "dof_start": dof["dof_start"], "dof_end": dof["dof_end"],
1342
+ "dof_space": dof["dof_space"],
1337
1343
  "saved": time.time()}
1338
1344
  # A fit is remembered when it PRODUCED something, not while it
1339
1345
  # is being dragged: a quad on the canvas is a work in progress,
package/scripts/warp.py CHANGED
@@ -269,7 +269,8 @@ class Plan:
269
269
  corner_radius: float = 0.0, grain: bool = False,
270
270
  blend: str = "replace", reflection: float = DEFAULT_REFLECTION,
271
271
  corner_smoothing: float = 0.0,
272
- dof_angle: float = 0.0, dof_strength: float = 0.0, dof_start: float = 0.0):
272
+ dof_angle: float = 0.0, dof_strength: float = 0.0, dof_start: float = 0.0,
273
+ dof_end: float = 1.0, dof_space: str = "photo"):
273
274
  dst_quad = np.array(corners, dtype=np.float32)
274
275
  if shoelace_area(dst_quad) < 1.0:
275
276
  raise ValueError("degenerate quad (near-zero area) — check corner order TL,TR,BR,BL")
@@ -321,6 +322,8 @@ class Plan:
321
322
  self.dof_strength = float(np.clip(dof_strength, 0.0, 1.0))
322
323
  self.dof_angle = float(dof_angle)
323
324
  self.dof_start = float(np.clip(dof_start, 0.0, 0.95))
325
+ self.dof_end = float(np.clip(dof_end, self.dof_start + 0.05, 1.5))
326
+ self.dof_space = "screen" if dof_space == "screen" else "photo"
324
327
  reach = int(np.ceil(3 * _dof.sigma_max(dst_quad, self.dof_strength))) if self.dof_strength > 0 else 0
325
328
  bx0, by0 = max(0, int(np.floor(xs.min())) - 1 - reach), max(0, int(np.floor(ys.min())) - 1 - reach)
326
329
  bx1, by1 = min(pw, int(np.ceil(xs.max())) + 2 + reach), min(ph, int(np.ceil(ys.max())) + 2 + reach)
@@ -332,7 +335,9 @@ class Plan:
332
335
  if self.dof_strength > 0 and self.bbox is not None:
333
336
  self.dof = _dof.Field(dst_quad, self.dof_angle, self.dof_strength,
334
337
  self.warped_mask[by0:by1, bx0:bx1], bx0, by0,
335
- start=self.dof_start)
338
+ start=self.dof_start, end=self.dof_end,
339
+ space=self.dof_space, H=self.H,
340
+ src_size=(self.new_w, self.new_h))
336
341
 
337
342
  def _prep(self, frame: np.ndarray, bbox=None) -> np.ndarray:
338
343
  """Warp one frame. With `bbox`, warp only that window of the canvas.
@@ -437,7 +442,8 @@ def compose(photo: np.ndarray, screenshot: np.ndarray, corners, corner_radius: f
437
442
  specular: float = 0.75, blend: str = "replace",
438
443
  reflection: float = DEFAULT_REFLECTION,
439
444
  dof_angle: float = 0.0, dof_strength: float = 0.0,
440
- dof_start: float = 0.0) -> np.ndarray:
445
+ dof_start: float = 0.0, dof_end: float = 1.0,
446
+ dof_space: str = "photo") -> np.ndarray:
441
447
  """Warp `screenshot` into the quad `corners` (TL,TR,BR,BL, photo pixels) on `photo`.
442
448
 
443
449
  Single resampling pass at the photo's resolution; deterministic. This is the
@@ -451,7 +457,8 @@ def compose(photo: np.ndarray, screenshot: np.ndarray, corners, corner_radius: f
451
457
  plan = Plan(photo, screenshot.shape, corners, corner_radius, grain=grain,
452
458
  corner_smoothing=corner_smoothing,
453
459
  blend=blend, reflection=reflection,
454
- dof_angle=dof_angle, dof_strength=dof_strength, dof_start=dof_start)
460
+ dof_angle=dof_angle, dof_strength=dof_strength, dof_start=dof_start,
461
+ dof_end=dof_end, dof_space=dof_space)
455
462
  plan.bind_grade(screenshot, grade)
456
463
  return plan.render(screenshot, screen_off=screen_off, specular=specular)
457
464
 
@@ -540,7 +547,8 @@ def compose_video(photo: np.ndarray, video_path: str, corners, output: str,
540
547
  reflection: float = DEFAULT_REFLECTION,
541
548
  start_frame: int = 0, max_frames: int = None,
542
549
  dof_angle: float = 0.0, dof_strength: float = 0.0,
543
- dof_start: float = 0.0) -> dict:
550
+ dof_start: float = 0.0, dof_end: float = 1.0,
551
+ dof_space: str = "photo") -> dict:
544
552
  """Inject a VIDEO into a still photo. The photo does not move, so there is
545
553
  exactly one homography and the whole of Plan is computed once.
546
554
 
@@ -568,7 +576,8 @@ def compose_video(photo: np.ndarray, video_path: str, corners, output: str,
568
576
  plan = Plan(photo, first.shape, corners, corner_radius, grain=grain,
569
577
  corner_smoothing=corner_smoothing,
570
578
  blend=blend, reflection=reflection,
571
- dof_angle=dof_angle, dof_strength=dof_strength, dof_start=dof_start)
579
+ dof_angle=dof_angle, dof_strength=dof_strength, dof_start=dof_start,
580
+ dof_end=dof_end, dof_space=dof_space)
572
581
  plan.bind_grade(first, grade)
573
582
 
574
583
  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.52):** 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.53):** 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,7 +13,7 @@ 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. **The gizmo on the result** is the primary control: a solid line over the screen where focus ends (drag to move, its end pips to turn), a dashed line where the blur reaches ~6px (drag closer for stronger, further for gentler); it fades when the pointer leaves the pane. The sliders and the gizmo are one model. 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.
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. **The gizmo on the result** is the primary control, defined in the screen's own plane and projected through the fit (so the two lines converge with the phone's edges on a steep shot, as real iso-blur lines do): a solid line where focus ends (drag its centre), a dashed line where the blur reaches its full amount (drag its centre to move it, its end pips to turn both, and slide the diamond along it for how much blur). It fades when the pointer leaves the pane. The sliders and the gizmo are one model. 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
17
 
18
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.
19
19
 
package/ui/index.html CHANGED
@@ -639,6 +639,7 @@
639
639
  #dofGizmo .h{fill:#f5623d;stroke:#fff;stroke-width:1.5;vector-effect:non-scaling-stroke;pointer-events:auto;cursor:grab}
640
640
  #dofGizmo .h.hollow{fill:#1a1a1a}
641
641
  #dofGizmo .h.pip{fill:#f5623d}
642
+ #dofGizmo .h.thumb{fill:#fff;stroke:#f5623d;stroke-width:2}
642
643
  #dofGizmo .h:active{cursor:grabbing}
643
644
  #dofGizmo text{font:600 11px system-ui,-apple-system,sans-serif;fill:#fff;paint-order:stroke;stroke:rgba(0,0,0,.75);stroke-width:3px;stroke-linejoin:round;pointer-events:none}
644
645
  #outImg,#outVid{display:block;margin:auto}
@@ -1142,7 +1143,7 @@
1142
1143
  <div class="sect-body"><div class="sect-body-in">
1143
1144
  <div style="display:grid;gap:var(--s2)">
1144
1145
  <div style="display:flex;align-items:center;justify-content:space-between;gap:var(--s2)">
1145
- <span class="sm" style="color:var(--mute)">Blur grows toward</span>
1146
+ <span class="sm" style="color:var(--mute)">Blur grows toward (of the screen)</span>
1146
1147
  <span class="sm" id="dofDirVal" style="font-variant-numeric:tabular-nums"></span>
1147
1148
  </div>
1148
1149
  <input type="range" id="dofDir" min="0" max="355" step="5" value="90" aria-label="Direction the blur grows in">
@@ -2576,7 +2577,7 @@ function setDof(on, ang, str){
2576
2577
  dofOn = on;
2577
2578
  if (ang !== undefined) dofAng = ((ang % 360) + 360) % 360;
2578
2579
  if (str !== undefined) dofStr = Math.max(0, Math.min(1, str));
2579
- remember('dof', on ? '1' : '0'); remember('dofAng', String(dofAng)); remember('dofStr', String(dofStr)); remember('dofStart', String(dofStart));
2580
+ remember('dof', on ? '1' : '0'); remember('dofAng', String(dofAng)); remember('dofStr', String(dofStr)); remember('dofStart', String(dofStart)); remember('dofEnd', String(dofEnd));
2580
2581
  setSectionOpen($('#secDof'), on);
2581
2582
  paintDof();
2582
2583
  paintGizmo();
@@ -2595,7 +2596,22 @@ async function measureDof(){
2595
2596
  autoPreview(); // still on, at the slider's own numbers
2596
2597
  return;
2597
2598
  }
2598
- dofStart = 0; setDof(true, r.angle, Math.max(r.strength, 0.05));
2599
+ // The estimator's angle is in PHOTO space; the gizmo and the engine work in
2600
+ // the screen's own plane. Map a short step from the quad's centre through
2601
+ // the inverse homography and take its direction there.
2602
+ let ang = r.angle;
2603
+ if (st.shotSize){
2604
+ const [sw, sh] = st.shotSize, rect = [[0,0],[sw,0],[sw,sh],[0,sh]];
2605
+ const Hi = solveHomography(st.corners, rect);
2606
+ if (Hi){
2607
+ const c = st.corners.reduce((acc,cc)=>[acc[0]+cc[0]/4, acc[1]+cc[1]/4],[0,0]);
2608
+ const a = r.angle * Math.PI / 180, step = 40;
2609
+ const p0 = applyH(Hi, c), p1 = applyH(Hi, [c[0] + Math.cos(a)*step, c[1] + Math.sin(a)*step]);
2610
+ ang = (Math.atan2(p1[1]-p0[1], p1[0]-p0[0]) * 180 / Math.PI + 360) % 360;
2611
+ }
2612
+ }
2613
+ dofStart = 0; dofEnd = 1; setDof(true, ang, Math.max(r.strength, 0.05));
2614
+
2599
2615
  s.className = 'status sm ok';
2600
2616
  s.textContent = `Measured: grows toward ${dofWord(r.angle)}. Strength is a floor — raise it if the far end looks softer.`;
2601
2617
  s.title = `Edge blur ${r.sigma.map(v => v.toFixed(1)).join(' / ')}px, spread ${r.spread}×`;
@@ -2616,79 +2632,76 @@ $('#dofMeasure').onclick = measureDof;
2616
2632
 
2617
2633
  /* ===== the depth-of-field gizmo ============================================
2618
2634
  Two lines over the composite, the graduated-filter idiom (Lightroom's
2619
- gradient, Photoshop's tilt-shift). The SOLID line is where focus ends and
2620
- the blur begins; the DASHED line is where the blur reaches a REFERENCE
2621
- softness, GIZMO_REF of the engine's full blur (sigma_full = DOF_MAX_FRAC of
2622
- the screen's longer side) -- 30%, about 6px on a phone, the point where
2623
- text stops being readable. Not full blur: at 50% strength the full-blur
2624
- line sat three quarters of a screen above the phone, off the photograph
2625
- and out of reach. Drag the solid line's centre to move focus, either end
2626
- pip to turn both lines (direction), the dashed line's centre to set how
2627
- fast the blur grows (strength). The dashed line may still sit past the far
2628
- edge for a gentle falloff, drawn out over the photograph and capped at
2629
- GIZMO_FAR_MAX extents; gentler than that is the slider's job.
2630
- Mapping, so the gizmo and the sliders are one model:
2631
- start = solid line's position, 0..0.95 of the quad's extent along the direction
2632
- strength = GIZMO_REF * (1 - start) / (far - start) -- far = dashed line's position
2633
- Everything is drawn in PHOTO pixels: the SVG's viewBox is the photo, so no
2634
- coordinate here is converted, and it stays aligned at every zoom for free. */
2635
+ gradient, Photoshop's tilt-shift) -- defined IN THE SCREEN'S OWN PLANE and
2636
+ projected through the fit's homography. The plane of focus cuts the screen
2637
+ along a line and blur grows with depth along the screen, so iso-blur lines
2638
+ are parallel on the screen and converge in the photograph like the phone's
2639
+ own edges; drawn in photo space they were parallel on the picture and wrong
2640
+ on any steep fit (reported 13 Sep 2026). The SOLID line is where focus
2641
+ ends; the DASHED line is where the blur reaches its full amount, and it can
2642
+ sit anywhere on the screen or a little past it. Handles:
2643
+ solid line centre -- move focus (start)
2644
+ dashed line centre -- move where the ramp ends (end); spacing = ramp length
2645
+ dashed line pips -- turn both lines (direction), ⇧ snaps to 15°
2646
+ diamond on the dashed line -- slide along it for how much blur (strength)
2647
+ Everything is computed in screenshot pixels and mapped to the photo with H;
2648
+ the SVG's viewBox is the photo so it stays aligned at every zoom for free. */
2635
2649
  const gz = $('#dofGizmo');
2636
2650
  const SIGMA_FULL_FRAC = 0.02; // mirrors dof.DOF_MAX_FRAC -- keep in step
2637
- const GIZMO_REF = 0.3; // the dashed line marks this fraction of full blur
2638
- const GIZMO_FAR_MAX = 1.6; // the dashed line is drawn no further than this many extents
2651
+ let dofEnd = parseFloat(recall('dofEnd','1')); if (!(dofEnd > 0)) dofEnd = 1;
2652
+ function dofEndV(){ return dofOn ? dofEnd : 1; }
2653
+ function applyH(Hm, p){ const w = Hm[6]*p[0] + Hm[7]*p[1] + Hm[8]; return [(Hm[0]*p[0] + Hm[1]*p[1] + Hm[2]) / w, (Hm[3]*p[0] + Hm[4]*p[1] + Hm[5]) / w]; }
2639
2654
  function gizmoGeom(){
2640
- const C = st.corners, a = dofAng * Math.PI / 180;
2655
+ const [sw, sh] = st.shotSize, a = dofAng * Math.PI / 180;
2656
+ const rect = [[0,0],[sw,0],[sw,sh],[0,sh]];
2657
+ const H = solveHomography(rect, st.corners), Hi = solveHomography(st.corners, rect);
2641
2658
  const d = [Math.cos(a), Math.sin(a)], u = [-d[1], d[0]];
2642
- const pd = C.map(c => c[0]*d[0] + c[1]*d[1]), pu = C.map(c => c[0]*u[0] + c[1]*u[1]);
2643
- const lo = Math.min(...pd), hi = Math.max(...pd), ulo = Math.min(...pu), uhi = Math.max(...pu);
2659
+ const pd = rect.map(c => c[0]*d[0] + c[1]*d[1]), pu = rect.map(c => c[0]*u[0] + c[1]*u[1]);
2660
+ const C = st.corners;
2644
2661
  const side = Math.max(vlen(sub(C[1],C[0])), vlen(sub(C[2],C[1])), vlen(sub(C[3],C[2])), vlen(sub(C[0],C[3])));
2645
- return {d, u, lo, hi, ulo, uhi, side};
2646
- }
2647
- // a line at fraction f along d, spanning the quad's extent along u (padded)
2648
- function gizmoLine(g, f, pad){
2649
- const c = g.lo + f * (g.hi - g.lo);
2650
- const p0 = [c*g.d[0] + (g.ulo-pad)*g.u[0], c*g.d[1] + (g.ulo-pad)*g.u[1]];
2651
- const p1 = [c*g.d[0] + (g.uhi+pad)*g.u[0], c*g.d[1] + (g.uhi+pad)*g.u[1]];
2652
- return [p0, p1, [(p0[0]+p1[0])/2, (p0[1]+p1[1])/2]];
2662
+ return {H, Hi, d, u, lo: Math.min(...pd), hi: Math.max(...pd), ulo: Math.min(...pu), uhi: Math.max(...pu), side};
2653
2663
  }
2664
+ // a screen-space point at fraction f along d and fraction k across u
2665
+ function gzPt(g, f, k){ const c = g.lo + f*(g.hi - g.lo), w = g.ulo + k*(g.uhi - g.ulo); return [c*g.d[0] + w*g.u[0], c*g.d[1] + w*g.u[1]]; }
2654
2666
  function paintGizmo(){
2655
2667
  const im = $('#outImg');
2656
- const show = dofOn && st.corners && !im.hidden && im.naturalWidth && $('#liveWrap').hidden;
2657
- // An SVG element has no `.hidden` property (that is HTMLElement's), so
2658
- // assigning it makes a JS expando and leaves the attribute where it was.
2668
+ const show = dofOn && st.corners && st.shotSize && !im.hidden && im.naturalWidth && $('#liveWrap').hidden;
2659
2669
  gz.toggleAttribute('hidden', !show);
2660
2670
  if (!show) return;
2661
- const W = im.naturalWidth, H = im.naturalHeight;
2662
- gz.setAttribute('viewBox', `0 0 ${W} ${H}`);
2671
+ const W = im.naturalWidth, Hh = im.naturalHeight;
2672
+ gz.setAttribute('viewBox', `0 0 ${W} ${Hh}`);
2663
2673
  gz.style.left = im.offsetLeft + 'px'; gz.style.top = im.offsetTop + 'px';
2664
2674
  gz.style.width = im.clientWidth + 'px'; gz.style.height = im.clientHeight + 'px';
2665
2675
  const k = W / Math.max(im.clientWidth, 1); // photo px per screen px
2666
- const g = gizmoGeom();
2667
- const fs = dofStart, fe = Math.min(GIZMO_FAR_MAX, fs + GIZMO_REF * (1 - fs) / Math.max(dofStr, 0.05));
2668
- const pinned = fs + GIZMO_REF * (1 - fs) / Math.max(dofStr, 0.05) > GIZMO_FAR_MAX;
2669
- const [a0, a1, ac] = gizmoLine(g, fs, 12 * k);
2670
- const [b0, b1, bc] = gizmoLine(g, fe, 12 * k);
2671
- const sigmaFull = SIGMA_FULL_FRAC * g.side;
2672
- const r = 6 * k, rp = 4 * k, fs11 = 11 * k;
2673
- const off = 16 * k; // label offset along d
2676
+ const g = gizmoGeom(); if (!g.H || !g.Hi) return;
2677
+ const P = (f, q) => applyH(g.H, gzPt(g, f, q));
2678
+ const a0 = P(dofStart, -0.06), a1 = P(dofStart, 1.06), ac = P(dofStart, 0.5);
2679
+ const b0 = P(dofEnd, -0.06), b1 = P(dofEnd, 1.06), bc = P(dofEnd, 0.5);
2680
+ const th = P(dofEnd, 0.08 + 0.84 * dofStr); // the strength thumb slides along the dashed line
2681
+ const sigma = dofStr * SIGMA_FULL_FRAC * g.side;
2682
+ const r = 6 * k, rp = 4 * k, fs11 = 11 * k, off = 14 * k;
2674
2683
  const L = (x, y, cls, extra='') => `<line x1="${x[0]}" y1="${x[1]}" x2="${y[0]}" y2="${y[1]}" class="${cls}" ${extra}/>`;
2675
2684
  const T = (p, txt, dy) => `<text x="${p[0]}" y="${p[1] + dy}" text-anchor="middle" font-size="${fs11}">${txt}</text>`;
2676
- const beyond = fe > 1.0001;
2685
+ const away = unit(sub(bc, ac)); // label offsets: away from the other line
2686
+ const beyond = dofEnd > 1.0001;
2677
2687
  gz.innerHTML =
2678
2688
  L(a0, a1, 'case', 'stroke-width="4"') + L(a0, a1, 'focus') +
2679
2689
  L(b0, b1, 'case', 'stroke-width="3.5"') + L(b0, b1, 'far' + (beyond ? ' out' : '')) +
2680
- `<circle cx="${a0[0]}" cy="${a0[1]}" r="${rp}" class="h pip" data-h="rotA"/>` +
2681
- `<circle cx="${a1[0]}" cy="${a1[1]}" r="${rp}" class="h pip" data-h="rotB"/>` +
2682
2690
  `<circle cx="${ac[0]}" cy="${ac[1]}" r="${r}" class="h" data-h="start"/>` +
2683
- `<circle cx="${bc[0]}" cy="${bc[1]}" r="${r}" class="h hollow" data-h="far"/>` +
2684
- T([ac[0] - g.d[0]*off, ac[1] - g.d[1]*off], 'sharp', 4*k) +
2685
- T([bc[0] + g.d[0]*off, bc[1] + g.d[1]*off], pinned ? `gentler than this: use the slider` : `σ ${(GIZMO_REF * sigmaFull).toFixed(0)}px here${beyond ? ' · past the screen' : ''}`, 4*k);
2686
- }
2687
- // pointer position in PHOTO pixels
2688
- function gizmoPos(e){
2691
+ `<circle cx="${b0[0]}" cy="${b0[1]}" r="${rp}" class="h pip" data-h="rotA"/>` +
2692
+ `<circle cx="${b1[0]}" cy="${b1[1]}" r="${rp}" class="h pip" data-h="rotB"/>` +
2693
+ `<circle cx="${bc[0]}" cy="${bc[1]}" r="${r}" class="h hollow" data-h="end"/>` +
2694
+ `<rect x="${th[0]-r}" y="${th[1]-r}" width="${2*r}" height="${2*r}" transform="rotate(45 ${th[0]} ${th[1]})" class="h thumb" data-h="str"/>` +
2695
+ T([ac[0] - away[0]*off, ac[1] - away[1]*off], 'sharp', 4*k) +
2696
+ T([bc[0] + away[0]*off, bc[1] + away[1]*off], `σ ${sigma.toFixed(0)}px from here${beyond ? ' · past the screen' : ''}`, 4*k) +
2697
+ T([th[0] + away[0]*off*1.6, th[1] + away[1]*off*1.6], `${Math.round(dofStr*100)}%`, 4*k);
2698
+ }
2699
+ // pointer position in SCREENSHOT pixels
2700
+ function gizmoPos(e, g){
2689
2701
  const rc = gz.getBoundingClientRect();
2690
- const W = $('#outImg').naturalWidth, H = $('#outImg').naturalHeight;
2691
- return [(e.clientX - rc.left) * W / rc.width, (e.clientY - rc.top) * H / rc.height];
2702
+ const W = $('#outImg').naturalWidth, Hh = $('#outImg').naturalHeight;
2703
+ const photo = [(e.clientX - rc.left) * W / rc.width, (e.clientY - rc.top) * Hh / rc.height];
2704
+ return applyH(g.Hi, photo);
2692
2705
  }
2693
2706
  let gzDrag = null;
2694
2707
  gz.addEventListener('pointerdown', e => {
@@ -2698,19 +2711,20 @@ gz.addEventListener('pointerdown', e => {
2698
2711
  });
2699
2712
  gz.addEventListener('pointermove', e => {
2700
2713
  if (!gzDrag) return;
2701
- const p = gizmoPos(e), g = gizmoGeom();
2714
+ const g = gizmoGeom(); if (!g.Hi) return;
2715
+ const p = gizmoPos(e, g);
2702
2716
  const f = (p[0]*g.d[0] + p[1]*g.d[1] - g.lo) / (g.hi - g.lo);
2717
+ const q = (p[0]*g.u[0] + p[1]*g.u[1] - g.ulo) / (g.uhi - g.ulo);
2703
2718
  if (gzDrag.h === 'start'){
2704
- dofStart = Math.max(0, Math.min(0.95, f));
2705
- } else if (gzDrag.h === 'far'){
2706
- // strength from the spacing; the far line never comes inside the screen
2707
- // (strength is capped at 1) and never so far that the blur vanishes.
2708
- const fe = Math.min(GIZMO_FAR_MAX, Math.max(f, dofStart + 0.05));
2709
- dofStr = Math.max(0.05, Math.min(1, GIZMO_REF * (1 - dofStart) / Math.max(fe - dofStart, 1e-3)));
2719
+ dofStart = Math.max(0, Math.min(0.95, Math.min(f, dofEnd - 0.05)));
2720
+ } else if (gzDrag.h === 'end'){
2721
+ dofEnd = Math.max(dofStart + 0.05, Math.min(1.5, f));
2722
+ } else if (gzDrag.h === 'str'){
2723
+ dofStr = Math.max(0.05, Math.min(1, (q - 0.08) / 0.84));
2710
2724
  } else {
2711
- // Turn about the focus line's centre: the pip under the pointer follows it.
2712
- const [, , ac] = gizmoLine(g, dofStart, 0);
2713
- const v = [p[0]-ac[0], p[1]-ac[1]];
2725
+ // Turn about the dashed line's centre; the pip under the pointer follows.
2726
+ const c = gzPt(g, dofEnd, 0.5);
2727
+ const v = [p[0]-c[0], p[1]-c[1]];
2714
2728
  const sign = gzDrag.h === 'rotB' ? 1 : -1; // rotB sits at +u, rotA at -u
2715
2729
  dofAng = ((Math.atan2(v[1]*sign, v[0]*sign) * 180 / Math.PI - 90) % 360 + 360) % 360;
2716
2730
  if (e.shiftKey) dofAng = Math.round(dofAng / 15) * 15;
@@ -3117,7 +3131,7 @@ async function playClip(){
3117
3131
  // a preview the user did not ask for, and the wait goes silent again.
3118
3132
  building = true;
3119
3133
  try{
3120
- await api('/api/preview_video', {corners: st.corners, radius_frac: radiusValue(), smoothing: smoothingValue(), dof_angle: dofAngle(), dof_strength: dofStrength(), dof_start: dofStartV(),
3134
+ await api('/api/preview_video', {corners: st.corners, radius_frac: radiusValue(), smoothing: smoothingValue(), dof_angle: dofAngle(), dof_strength: dofStrength(), dof_start: dofStartV(), dof_end: dofEndV(), dof_space: 'screen',
3121
3135
  device: st.type, grade: gradeValue(),
3122
3136
  reflection: emisValue(), fit_frame: +$('#vframe').value});
3123
3137
  }catch(e){
@@ -3176,7 +3190,7 @@ async function renderPreview(){
3176
3190
  const say = t => { if (!building && $('#liveWrap').hidden) s.textContent = t; };
3177
3191
  say('Rendering…');
3178
3192
  try{
3179
- const r = await api('/api/preview', {corners: st.corners, radius_frac: radiusValue(), smoothing: smoothingValue(), dof_angle: dofAngle(), dof_strength: dofStrength(), dof_start: dofStartV(), device: st.type, grade: gradeValue(), reflection: emisValue()});
3193
+ const r = await api('/api/preview', {corners: st.corners, radius_frac: radiusValue(), smoothing: smoothingValue(), dof_angle: dofAngle(), dof_strength: dofStrength(), dof_start: dofStartV(), dof_end: dofEndV(), dof_space: 'screen', device: st.type, grade: gradeValue(), reflection: emisValue()});
3180
3194
  const im = $('#outImg');
3181
3195
  im.onload = () => {
3182
3196
  outNat = im.naturalWidth;
@@ -3306,7 +3320,7 @@ async function renderVideo(){
3306
3320
  b.disabled = true;
3307
3321
  setRenderProgress(0);
3308
3322
  try{
3309
- await api('/api/render', {corners: st.corners, radius_frac: radiusValue(), smoothing: smoothingValue(), dof_angle: dofAngle(), dof_strength: dofStrength(), dof_start: dofStartV(), device: st.type,
3323
+ await api('/api/render', {corners: st.corners, radius_frac: radiusValue(), smoothing: smoothingValue(), dof_angle: dofAngle(), dof_strength: dofStrength(), dof_start: dofStartV(), dof_end: dofEndV(), dof_space: 'screen', device: st.type,
3310
3324
  grade: gradeValue(), reflection: emisValue(), preset, fit_frame: +$('#vframe').value});
3311
3325
  }catch(e){
3312
3326
  setRenderProgress(null);
@@ -3352,7 +3366,7 @@ $('#save').onclick = async () => {
3352
3366
  if (st.video) return renderVideo();
3353
3367
  const b = $('#save'); b.textContent = 'Saving…'; b.disabled = true;
3354
3368
  try{
3355
- const r = await api('/api/save', {corners: st.corners, radius_frac: radiusValue(), smoothing: smoothingValue(), dof_angle: dofAngle(), dof_strength: dofStrength(), dof_start: dofStartV(), device: st.type, grade: gradeValue(), reflection: emisValue()});
3369
+ const r = await api('/api/save', {corners: st.corners, radius_frac: radiusValue(), smoothing: smoothingValue(), dof_angle: dofAngle(), dof_strength: dofStrength(), dof_start: dofStartV(), dof_end: dofEndV(), dof_space: 'screen', device: st.type, grade: gradeValue(), reflection: emisValue()});
3356
3370
  b.textContent = saveLabel();
3357
3371
  // The real destination, not a hardcoded one: --out-dir means saves usually
3358
3372
  // land in the project folder now, and telling the user "~/Desktop" when