screengraft 0.67.0 → 0.68.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.67.0",
3
+ "version": "0.68.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/detect.py CHANGED
@@ -1037,6 +1037,112 @@ def has_rounded_corners(result) -> bool:
1037
1037
  return spread <= MAX_RADIUS_SPREAD
1038
1038
 
1039
1039
 
1040
+ # ---------------------------------------------------------------------------
1041
+ # Working resolution.
1042
+ #
1043
+ # Detection ran at whatever resolution the photograph happened to be, and the
1044
+ # cost is quadratic: a 4000x4000 photo measured **11.24s** on 20 Sep 2026,
1045
+ # against 0.15s for the browser to fetch and decode the same file. Eleven
1046
+ # seconds of a designer's attention, every time they pick a big photo.
1047
+ #
1048
+ # The cap is **2400px on the short side, measured here** -- not the JS port's
1049
+ # 1600. That difference is the whole story of this change, so it is written
1050
+ # down rather than assumed.
1051
+ #
1052
+ # figma/src/engine/detect.js caps at 1600 and benches 27/27/0 there, so 1600
1053
+ # was the obvious number to copy. Swept over the 9 photographs in the corpus
1054
+ # whose short side exceeds the cap, comparing each capped answer against the
1055
+ # same photograph's full-resolution answer:
1056
+ #
1057
+ # cap total s worst quad shift worst radius shift
1058
+ # full 41.2 -- --
1059
+ # 1600 7.9 3.57% 19.3%
1060
+ # 2000 12.6 3.43% 18.6%
1061
+ # 2400 15.1 3.58% 5.0%
1062
+ # 3000 24.2 0.02% 0.4%
1063
+ #
1064
+ # 1600 moves the MEASURED CORNER RADIUS by 19% on both 4000x4000 photographs,
1065
+ # and the radius is not a starting position a person corrects -- it is the
1066
+ # number that shapes the corner mask on the saved composite. 2400 takes that to
1067
+ # 0.2% on those two while keeping 4K detection at ~1.7s against 13.3s.
1068
+ #
1069
+ # The residual 3.58% at 2400 is ONE photograph, iPhone-10_17-pro, which shifts
1070
+ # by ~3.5% at every cap below its own size -- it is scale-sensitive rather than
1071
+ # cap-sensitive, so there is no cap that fixes it short of not downscaling. It
1072
+ # stays inside the bench's 5%-of-screen-width band, and the quad is a starting
1073
+ # position the fit pane exists to correct.
1074
+ #
1075
+ # Why not the JS port's 1600, then? Because the two are not measuring the same
1076
+ # thing: that bench scores quads against human labels, and a 19% radius error
1077
+ # does not show up in a quad score at all. Worth re-checking the JS side.
1078
+ #
1079
+ # What this does NOT touch: the composite. Everything downstream -- the warp,
1080
+ # the grade, the corner mask, the saved file -- still works from the full
1081
+ # resolution photograph. This is a cap on the SEARCH, not on the output, which
1082
+ # is the whole point: on request, 21 Sep 2026 -- downscale for detection, show
1083
+ # full resolution in the preview.
1084
+ #
1085
+ # The pipeline runs entirely in working coordinates so that every internal
1086
+ # threshold, area ratio and diagonal stays self-consistent; only the geometry
1087
+ # that leaves detect() is scaled back. Radii come back too -- `photo_px` and
1088
+ # `per_corner_px` are pixel counts and must mean full-resolution pixels to
1089
+ # their caller -- while `frac_of_width` is a ratio and is already correct at
1090
+ # any scale, which is the reason the UI prefers it.
1091
+ #
1092
+ # INTER_AREA, not the default: downscaling with bilinear aliases, and an
1093
+ # aliased edge is precisely what an edge detector is looking at.
1094
+ # Overridable so the A/B above can be re-run without editing this file:
1095
+ # SCREENGRAFT_DETECT_MAX=0 detect at full resolution (the old behaviour)
1096
+ # SCREENGRAFT_DETECT_MAX=1600 the JS port's cap
1097
+ # Unset is the shipped 2400.
1098
+ WORK_MAX_SHORT = int(os.environ.get("SCREENGRAFT_DETECT_MAX", "2400") or 0) or None
1099
+
1100
+
1101
+ def work_scale(shape) -> float:
1102
+ """Factor to multiply the photograph by before searching it. 1.0 = as-is."""
1103
+ h, w = shape[:2]
1104
+ short = min(int(h), int(w))
1105
+ # A 10% deadband, so a photograph a little over the cap is left alone. A
1106
+ # 1603px short side would otherwise be resampled to 1600 -- a 0.2% saving,
1107
+ # paid for with a resize and a different set of pixels under the detector,
1108
+ # which is the worst trade available: all of the risk of a change and none
1109
+ # of the benefit.
1110
+ if not WORK_MAX_SHORT or short <= WORK_MAX_SHORT * 1.1:
1111
+ return 1.0
1112
+ return float(WORK_MAX_SHORT) / float(short)
1113
+
1114
+
1115
+ def _shrink(img, s):
1116
+ if img is None or s >= 1.0:
1117
+ return img
1118
+ return cv2.resize(img, None, fx=s, fy=s, interpolation=cv2.INTER_AREA)
1119
+
1120
+
1121
+ def _scale_quad(quad, k):
1122
+ return [[round(float(x) * k, 1), round(float(y) * k, 1)] for x, y in quad]
1123
+
1124
+
1125
+ def _rescale_result(r, k):
1126
+ """Put a result found in working coordinates back into photograph pixels."""
1127
+ if r is None or k == 1.0:
1128
+ return r
1129
+ r["corners"] = _scale_quad(r["corners"], k)
1130
+ if r.get("_corners_np") is not None:
1131
+ r["_corners_np"] = np.asarray(r["_corners_np"], dtype=float) * k
1132
+ cr = r.get("corner_radius")
1133
+ if cr:
1134
+ # frac_of_width is deliberately NOT touched: it is r/width, and both
1135
+ # scaled by the same factor, so it survived the downscale unchanged.
1136
+ if cr.get("photo_px") is not None:
1137
+ cr["photo_px"] = round(float(cr["photo_px"]) * k, 1)
1138
+ if cr.get("per_corner_px"):
1139
+ cr["per_corner_px"] = [round(float(v) * k, 1) for v in cr["per_corner_px"]]
1140
+ for o in (r.get("other") or []):
1141
+ if o.get("corners"):
1142
+ o["corners"] = _scale_quad(o["corners"], k)
1143
+ return r
1144
+
1145
+
1040
1146
  def detect(gray: np.ndarray, tone=None, method="auto", color=None, click=None,
1041
1147
  trace=None):
1042
1148
  """Run both detectors; arbitrate on how the two quads nest.
@@ -1067,23 +1173,45 @@ def detect(gray: np.ndarray, tone=None, method="auto", color=None, click=None,
1067
1173
  # channel generated. It is an OBSERVER: nothing downstream reads it,
1068
1174
  # and the answer is identical with and without -- which is asserted, because
1069
1175
  # an instrument that perturbs what it measures is worse than none.
1176
+ # Search a capped copy; see WORK_MAX_SHORT above. Everything below this
1177
+ # point -- including the click, the shape handed to arbitrate(), and every
1178
+ # trace row -- is in WORKING coordinates, and the single conversion back
1179
+ # happens on the way out.
1180
+ s = work_scale(gray.shape)
1181
+ g = _shrink(gray, s)
1182
+ col = _shrink(color, s) if color is not None else None
1183
+ clk = [click[0] * s, click[1] * s] if (click is not None and s < 1.0) else click
1184
+
1070
1185
  results = []
1071
1186
  if method in ("auto", "tone"):
1072
- r = detect_tone(gray, tone, click=click, trace=trace)
1187
+ r = detect_tone(g, tone, click=clk, trace=trace)
1073
1188
  if r:
1074
1189
  results.append(r)
1075
1190
  if method in ("auto", "edge") and tone is None:
1076
- r = detect_edges(gray, click=click, trace=trace)
1191
+ r = detect_edges(g, click=clk, trace=trace)
1077
1192
  if r:
1078
1193
  results.append(r)
1079
- if method in ("auto", "saturation") and tone is None and color is not None:
1080
- r = detect_saturation(color, click=click, trace=trace)
1194
+ if method in ("auto", "saturation") and tone is None and col is not None:
1195
+ r = detect_saturation(col, click=clk, trace=trace)
1081
1196
  if r:
1082
1197
  results.append(r)
1083
1198
 
1199
+ k = 1.0 / s if s < 1.0 else 1.0
1200
+ if trace is not None and k != 1.0:
1201
+ # The trace is an observer and nothing downstream reads it, but a
1202
+ # debugging artefact in a coordinate space the photograph does not use
1203
+ # is a trap for whoever opens it next.
1204
+ for row in trace:
1205
+ if row.get("quad"):
1206
+ row["quad"] = _scale_quad(row["quad"], k)
1207
+
1084
1208
  if not results:
1085
1209
  return None
1086
- return arbitrate(results, gray.shape[:2], click)
1210
+ out = arbitrate(results, g.shape[:2], clk)
1211
+ if out is not None:
1212
+ out["work_scale"] = round(s, 4)
1213
+ _rescale_result(out, k)
1214
+ return out
1087
1215
 
1088
1216
 
1089
1217
  def arbitrate(results, shape, click=None):
package/scripts/grade.py CHANGED
@@ -62,7 +62,8 @@ def _stats(lab: np.ndarray, sel: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
62
62
 
63
63
 
64
64
  def light_params(photo: np.ndarray, warped: np.ndarray, mask: np.ndarray,
65
- strength: float = DEFAULT_STRENGTH):
65
+ strength: float = DEFAULT_STRENGTH,
66
+ light: float = None, colour: float = None):
66
67
  """Measure the correction ONCE, so it can be applied to many frames.
67
68
 
68
69
  Split out of match_light for video. The correction depends on the
@@ -74,7 +75,30 @@ def light_params(photo: np.ndarray, warped: np.ndarray, mask: np.ndarray,
74
75
  Returns None when there is too little context to measure honestly, which
75
76
  the caller must treat as "leave the frame alone".
76
77
  """
77
- if strength <= 0:
78
+ # LIGHT and COLOUR are the same measurement, weighted separately.
79
+ #
80
+ # Split on request, 21 Sep 2026, from a real finding: with realism on, a
81
+ # brand orange visibly shifted toward coral and a pure-black status bar
82
+ # lifted to slate. Both are CORRECT -- nothing on a real phone renders #000
83
+ # -- but one control was doing two jobs, and the audience for this tool
84
+ # cares about exact hex. What moved the orange is the chroma match, not the
85
+ # exposure shift, so the useful cut is exactly the one this function
86
+ # already makes internally:
87
+ #
88
+ # light -> L only: a bounded mean shift. Sits the screen in the scene's
89
+ # exposure and leaves hue alone.
90
+ # colour -> a,b only: mean AND spread. The white-balance and saturation
91
+ # match, and the one that moved the orange.
92
+ #
93
+ # Nothing about the arithmetic changed; only who scales which half.
94
+ #
95
+ # THE COMPATIBILITY CONTRACT: `strength` alone still works and still means
96
+ # what it meant. When light/colour are not given they ARE strength, so an
97
+ # old sidecar -- which carries `grade` and knows nothing of this split --
98
+ # replays through identical multiplications. test_sidecar.py pins it.
99
+ wL = float(strength if light is None else light)
100
+ wC = float(strength if colour is None else colour)
101
+ if wL <= 0 and wC <= 0:
78
102
  return None
79
103
  ring = surround_ring(mask)
80
104
  if int(ring.sum()) < 500:
@@ -88,10 +112,13 @@ def light_params(photo: np.ndarray, warped: np.ndarray, mask: np.ndarray,
88
112
  m_in, s_in = _stats(lab_warp, inside)
89
113
  return {
90
114
  "m_in": m_in, "s_in": s_in, "m_out": m_out, "s_out": s_out,
91
- "strength": float(strength),
115
+ # Kept under its old name and still the chroma weight, so apply_light()
116
+ # on a params dict from anywhere behaves as it always did.
117
+ "strength": wC,
118
+ "light": wL, "colour": wC,
92
119
  # Same clamp as match_light: a screen is emissive and may be brighter
93
120
  # than the room, so L moves by a bounded mean shift only.
94
- "dL": float(np.clip(m_out[0] - m_in[0], -12.0, 12.0)) * float(strength),
121
+ "dL": float(np.clip(m_out[0] - m_in[0], -12.0, 12.0)) * wL,
95
122
  }
96
123
 
97
124
 
@@ -102,7 +129,9 @@ def apply_light(warped: np.ndarray, params) -> np.ndarray:
102
129
  lab_warp = cv2.cvtColor(warped, cv2.COLOR_BGR2LAB).astype(np.float64)
103
130
  m_in, s_in = params["m_in"], params["s_in"]
104
131
  m_out, s_out = params["m_out"], params["s_out"]
105
- strength = params["strength"]
132
+ # The chroma weight. `colour` when the params came from the split path,
133
+ # `strength` for any dict built before it existed.
134
+ strength = float(params.get("colour", params["strength"]))
106
135
  out = lab_warp.copy()
107
136
  for c in (1, 2):
108
137
  moved = (lab_warp[:, :, c] - m_in[c]) * float(s_out[c] / s_in[c]) + m_out[c]
@@ -114,7 +143,8 @@ def apply_light(warped: np.ndarray, params) -> np.ndarray:
114
143
 
115
144
 
116
145
  def match_light(photo: np.ndarray, warped: np.ndarray, mask: np.ndarray,
117
- strength: float = DEFAULT_STRENGTH) -> np.ndarray:
146
+ strength: float = DEFAULT_STRENGTH,
147
+ light: float = None, colour: float = None) -> np.ndarray:
118
148
  """Move the injected screen's cast and exposure toward the surrounding light.
119
149
 
120
150
  Chroma (a,b) is matched on mean AND spread — a cast is exactly a chroma mean
@@ -127,7 +157,8 @@ def match_light(photo: np.ndarray, warped: np.ndarray, mask: np.ndarray,
127
157
  # One implementation, two entry points: measuring and applying are the same
128
158
  # arithmetic whether it runs on a still or on frame 900 of a clip. Keeping a
129
159
  # second copy here is how the two paths would drift.
130
- return apply_light(warped, light_params(photo, warped, mask, strength))
160
+ return apply_light(warped, light_params(photo, warped, mask, strength,
161
+ light=light, colour=colour))
131
162
 
132
163
 
133
164
  GRAIN_GATE = 20.0 # grey levels: above this a residual is an edge, not grain
package/scripts/ui.py CHANGED
@@ -587,10 +587,32 @@ PREVIEW_WIDTH = 720
587
587
  PREVIEW_SECONDS = 6.0
588
588
 
589
589
 
590
+ def _grade_args(b):
591
+ """The realism weights: one legacy value, and the two halves that split it.
592
+
593
+ `grade` stays the single master, and it is what every sidecar written
594
+ before 21 Sep 2026 carries. `grade_light` and `grade_colour` weight the two
595
+ halves separately when the page sends them; ABSENT means "same as grade",
596
+ which is precisely what makes an old sidecar replay byte for byte rather
597
+ than merely closely (grade.light_params does the same defaulting, and
598
+ test_grade.py pins the identity at four strengths).
599
+
600
+ The page sends all three: `grade` as max(light, colour), so every
601
+ downstream default keyed on `grade > 0` -- grain, chiefly -- still means
602
+ "is the realism pass doing anything at all".
603
+ """
604
+ gr = float(b.get("grade") if b.get("grade") is not None else 0.0)
605
+ gl = b.get("grade_light")
606
+ gc = b.get("grade_colour")
607
+ return (gr,
608
+ None if gl is None else float(gl),
609
+ None if gc is None else float(gc))
610
+
611
+
590
612
  def _render_worker(photo, video_path, corners, dest, radius_px, gr, grain, preset, fit_frame,
591
613
  blend="replace", reflection=None, result=None, kind="render",
592
614
  start_frame=0, max_frames=None, *, smoothing=0.0, dof=None,
593
- grain_gain=1.0):
615
+ grain_gain=1.0, grade_light=None, grade_colour=None):
594
616
  """Encode the clip, and only if that SUCCEEDS publish what it produced.
595
617
 
596
618
  `result` is the sidecar this render would write. It is handed to the worker
@@ -610,7 +632,9 @@ def _render_worker(photo, video_path, corners, dest, radius_px, gr, grain, prese
610
632
  try:
611
633
  info = W.compose_video(photo, video_path, corners, dest,
612
634
  corner_radius=radius_px, corner_smoothing=smoothing,
613
- grade=gr, grain=grain, grain_gain=grain_gain,
635
+ grade=gr, grade_light=grade_light,
636
+ grade_colour=grade_colour,
637
+ grain=grain, grain_gain=grain_gain,
614
638
  preset=preset, fit_frame=fit_frame, progress=progress,
615
639
  blend=blend,
616
640
  reflection=(W.DEFAULT_REFLECTION if reflection is None
@@ -1303,7 +1327,7 @@ class Handler(BaseHTTPRequestHandler):
1303
1327
  else _fit_frame())
1304
1328
  first = W.read_frame_at(spath, fit_frame)
1305
1329
  radius_px = frac * first.shape[1]
1306
- gr = float(b.get("grade") if b.get("grade") is not None else 0.0)
1330
+ gr, gl, gc = _grade_args(b)
1307
1331
  grain = bool(b.get("grain", gr > 0))
1308
1332
  blend, reflection = _blend_args(b)
1309
1333
  # Downscale the PHOTO and scale the quad with it, rather than
@@ -1344,7 +1368,9 @@ class Handler(BaseHTTPRequestHandler):
1344
1368
  fit_frame, max_frames),
1345
1369
  kwargs={"smoothing": _smoothing(b),
1346
1370
  "dof": _dof_args(b),
1347
- "grain_gain": _grain_gain(b)}).start()
1371
+ "grain_gain": _grain_gain(b),
1372
+ "grade_light": gl,
1373
+ "grade_colour": gc}).start()
1348
1374
  except BaseException:
1349
1375
  with RENDER_LOCK:
1350
1376
  RENDER.update(state="error", message="could not start the preview")
@@ -1375,7 +1401,7 @@ class Handler(BaseHTTPRequestHandler):
1375
1401
  else _fit_frame())
1376
1402
  first = W.read_frame_at(spath, fit_frame)
1377
1403
  radius_px = frac * first.shape[1]
1378
- gr = float(b.get("grade") if b.get("grade") is not None else 0.0)
1404
+ gr, gl, gc = _grade_args(b)
1379
1405
  grain = bool(b.get("grain", gr > 0))
1380
1406
  blend, reflection = _blend_args(b)
1381
1407
  dof = _dof_args(b)
@@ -1397,7 +1423,8 @@ class Handler(BaseHTTPRequestHandler):
1397
1423
  result = {"output": dest, "photo": ppath, "screenshot": spath,
1398
1424
  "corners": corners, "radius_frac": frac, "radius_px": radius_px,
1399
1425
  "device": b.get("device"), "corner_smoothing": _smoothing(b),
1400
- "grade": gr, "grain": grain, "grain_gain": _grain_gain(b),
1426
+ "grade": gr, "grade_light": gl, "grade_colour": gc,
1427
+ "grain": grain, "grain_gain": _grain_gain(b),
1401
1428
  "video": True, "preset": preset, "fit_frame": fit_frame,
1402
1429
  "blend": blend, "reflection": reflection,
1403
1430
  "dof_angle": dof["dof_angle"], "dof_strength": dof["dof_strength"],
@@ -1431,7 +1458,9 @@ class Handler(BaseHTTPRequestHandler):
1431
1458
  blend, reflection, result),
1432
1459
  kwargs={"smoothing": _smoothing(b),
1433
1460
  "dof": dof,
1434
- "grain_gain": result["grain_gain"]}).start()
1461
+ "grain_gain": result["grain_gain"],
1462
+ "grade_light": result["grade_light"],
1463
+ "grade_colour": result["grade_colour"]}).start()
1435
1464
  except BaseException:
1436
1465
  # If the thread cannot even be created, the flag must not
1437
1466
  # outlive the request.
@@ -1451,14 +1480,15 @@ class Handler(BaseHTTPRequestHandler):
1451
1480
  # composite is the right output when the screenshot's own colour
1452
1481
  # is the point (a brand review), and the grade is the right one
1453
1482
  # when the photograph is (a portfolio shot).
1454
- gr = float(b.get("grade") if b.get("grade") is not None else 0.0)
1483
+ gr, gl, gc = _grade_args(b)
1455
1484
  blend, reflection = _blend_args(b)
1456
1485
  smoothing = _smoothing(b)
1457
1486
  dof = _dof_args(b)
1458
1487
  grain_gain = _grain_gain(b)
1459
1488
  out = W.compose(photo, shot, corners, radius_px,
1460
1489
  corner_smoothing=smoothing,
1461
- grade=gr, grain=bool(b.get("grain", gr > 0)),
1490
+ grade=gr, grade_light=gl, grade_colour=gc,
1491
+ grain=bool(b.get("grain", gr > 0)),
1462
1492
  grain_gain=grain_gain,
1463
1493
  blend=blend, reflection=reflection, **dof)
1464
1494
  SESSION.update(corners=corners, radius_frac=frac, device=b.get("device"),
@@ -1494,7 +1524,8 @@ class Handler(BaseHTTPRequestHandler):
1494
1524
  result = {"output": dest, "photo": ppath, "screenshot": spath, "corners": corners,
1495
1525
  "radius_frac": frac, "radius_px": radius_px, "device": b.get("device"),
1496
1526
  "corner_smoothing": smoothing,
1497
- "grade": gr, "grain": bool(b.get("grain", gr > 0)),
1527
+ "grade": gr, "grade_light": gl, "grade_colour": gc,
1528
+ "grain": bool(b.get("grain", gr > 0)),
1498
1529
  "grain_gain": grain_gain,
1499
1530
  "blend": blend, "reflection": reflection,
1500
1531
  "dof_angle": dof["dof_angle"], "dof_strength": dof["dof_strength"],
package/scripts/warp.py CHANGED
@@ -366,10 +366,19 @@ class Plan:
366
366
  flags=cv2.INTER_LANCZOS4,
367
367
  borderMode=cv2.BORDER_REPLICATE)
368
368
 
369
- def bind_grade(self, frame: np.ndarray, strength: float) -> None:
370
- """Measure the light correction once, from the frame the user fitted on."""
369
+ def bind_grade(self, frame: np.ndarray, strength: float,
370
+ light: float = None, colour: float = None) -> None:
371
+ """Measure the light correction once, from the frame the user fitted on.
372
+
373
+ `light` and `colour` weight the two halves separately (see
374
+ grade.light_params). Left unset they both fall back to `strength`,
375
+ which is what every sidecar written before the split carries.
376
+ """
377
+ wL = strength if light is None else light
378
+ wC = strength if colour is None else colour
371
379
  self.grade_params = _grade.light_params(
372
- self.photo, self._prep(frame), self.warped_mask, strength) if strength > 0 else None
380
+ self.photo, self._prep(frame), self.warped_mask, strength,
381
+ light=light, colour=colour) if (wL > 0 or wC > 0) else None
373
382
 
374
383
  def _blend(self, photo_win, warped_win):
375
384
  """Emitted light over reflected light, or a plain replace.
@@ -444,7 +453,8 @@ class Plan:
444
453
 
445
454
  def compose(photo: np.ndarray, screenshot: np.ndarray, corners, corner_radius: float = 0.0,
446
455
  corner_smoothing: float = 0.0,
447
- grade: float = 0.0, grain: bool = False, screen_off: np.ndarray = None,
456
+ grade: float = 0.0, grade_light: float = None, grade_colour: float = None,
457
+ grain: bool = False, screen_off: np.ndarray = None,
448
458
  specular: float = 0.75, blend: str = "replace",
449
459
  reflection: float = DEFAULT_REFLECTION,
450
460
  dof_angle: float = 0.0, dof_strength: float = 0.0,
@@ -467,7 +477,7 @@ def compose(photo: np.ndarray, screenshot: np.ndarray, corners, corner_radius: f
467
477
  dof_angle=dof_angle, dof_strength=dof_strength, dof_start=dof_start,
468
478
  dof_end=dof_end, dof_space=dof_space, dof_end2=dof_end2,
469
479
  grain_gain=grain_gain)
470
- plan.bind_grade(screenshot, grade)
480
+ plan.bind_grade(screenshot, grade, light=grade_light, colour=grade_colour)
471
481
  return plan.render(screenshot, screen_off=screen_off, specular=specular)
472
482
 
473
483
 
@@ -549,7 +559,8 @@ def read_frame_at(path: str, index: int = 0):
549
559
 
550
560
  def compose_video(photo: np.ndarray, video_path: str, corners, output: str,
551
561
  corner_radius: float = 0.0, corner_smoothing: float = 0.0,
552
- grade: float = 0.0, grain: bool = False,
562
+ grade: float = 0.0, grade_light: float = None,
563
+ grade_colour: float = None, grain: bool = False,
553
564
  preset: str = "web", fit_frame: int = 0, audio: bool = True,
554
565
  frames_dir: str = None, progress=None, blend: str = "replace",
555
566
  reflection: float = DEFAULT_REFLECTION,
@@ -588,9 +599,19 @@ def compose_video(photo: np.ndarray, video_path: str, corners, output: str,
588
599
  dof_angle=dof_angle, dof_strength=dof_strength, dof_start=dof_start,
589
600
  dof_end=dof_end, dof_space=dof_space, dof_end2=dof_end2,
590
601
  grain_gain=grain_gain)
591
- plan.bind_grade(first, grade)
602
+ plan.bind_grade(first, grade, light=grade_light, colour=grade_colour)
592
603
 
593
604
  ph, pw = photo.shape[:2]
605
+ # H.264/yuv420p (and ProRes 422) subsample chroma 2x horizontally, and
606
+ # 4:2:0 vertically too, so an odd width or height is refused -- and ffmpeg
607
+ # refuses by dying mid-stream, which reaches us as "[Errno 32] Broken pipe"
608
+ # on the first frame write. The preview route evened its own proxy size
609
+ # (ui.py) but a full render runs at the PHOTO's size, so any photograph
610
+ # with an odd dimension failed to render at all (1424x879, 25 Sep 2026).
611
+ # Pad by replicating the last row/column: one duplicated edge pixel is
612
+ # invisible, whereas cropping would drop a row of the photograph.
613
+ pad_b, pad_r = ph % 2, pw % 2
614
+ ph, pw = ph + pad_b, pw + pad_r
594
615
  cmd = [ffmpeg_exe(), "-y", "-loglevel", "error",
595
616
  "-f", "rawvideo", "-pix_fmt", "bgr24", "-s", f"{pw}x{ph}",
596
617
  "-r", f"{fps}", "-i", "-"]
@@ -632,7 +653,14 @@ def compose_video(photo: np.ndarray, video_path: str, corners, output: str,
632
653
  if frames_dir:
633
654
  cv2.imwrite(os.path.join(frames_dir, f"{count:06d}.png"), out,
634
655
  [cv2.IMWRITE_PNG_COMPRESSION, 1])
635
- proc.stdin.write(out.tobytes())
656
+ if pad_b or pad_r:
657
+ out = cv2.copyMakeBorder(out, 0, pad_b, 0, pad_r, cv2.BORDER_REPLICATE)
658
+ try:
659
+ proc.stdin.write(out.tobytes())
660
+ except BrokenPipeError:
661
+ # ffmpeg has exited; its stderr, read below, says why. Raising
662
+ # the bare pipe error here would hide the one useful sentence.
663
+ break
636
664
  count += 1
637
665
  if progress and count % 10 == 0:
638
666
  progress(count, total_hint)
@@ -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.67):** 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** (the images you have used before, 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.68):** 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** (the images you have used before, 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 matches the source to the photo's light, with **Light** (exposure only, hue untouched) and **Colour** (white balance and saturation) set separately, **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
 
package/ui/index.html CHANGED
@@ -1527,6 +1527,14 @@
1527
1527
  /* The Keys legend. max-content on the first column sizes it to the widest
1528
1528
  cap, so every description starts on the same x and the caps form a single
1529
1529
  column the eye can run down. */
1530
+ /* Realism's two rows. label | slider | value, so the two read as a pair of
1531
+ the same control rather than two stacked blocks -- and it is shorter than
1532
+ the single stacked slider it replaces, on a rail that has no room. */
1533
+ .gr-row{display:grid;grid-template-columns:max-content 1fr max-content;
1534
+ align-items:center;gap:var(--s2)}
1535
+ .gr-row .gr-lbl{color:var(--mute)}
1536
+ .gr-row output{color:var(--ink);font-variant-numeric:tabular-nums;min-width:34px;
1537
+ text-align:right;font-size:12px}
1530
1538
  dl.keys{display:grid;grid-template-columns:max-content 1fr;gap:6px 10px;
1531
1539
  margin:8px 0 0;align-items:baseline}
1532
1540
  dl.keys dt{color:var(--faint);font-size:12px;white-space:nowrap}
@@ -1702,16 +1710,30 @@
1702
1710
  <div class="sect-top">
1703
1711
  <div class="sect-left">
1704
1712
  <h4>Realism</h4>
1705
- <button class="info" type="button" aria-label="About realism"><svg viewBox="0 0 16 16" fill="none" aria-hidden="true"><path fill="currentColor" d="M8.36 1.01C12.06 1.2 15 4.26 15 8l-.01.36C14.8 12.06 11.74 15 8 15l-.36-.01C4.06 14.81 1.19 11.94 1.01 8.36L1 8c0-3.87 3.13-7 7-7l.36.01ZM8 2C4.69 2 2 4.69 2 8c0 3.31 2.69 6 6 6 3.31 0 6-2.69 6-6 0-3.31-2.69-6-6-6Zm.5 8l.5 0v1L7 11v-1h.5V7H7V6h1.5v4ZM7.9 4c.33 0 .6.27.6.6a.6.6 0 1 1-1.21 0c0-.33.28-.6.61-.6Z"/></svg><span class="tip" role="tooltip">Matches the screen's white balance and grain to the light around it, so it stops reading as pasted. Off keeps the screenshot's colour exactly — right when the UI's own colour is what you're reviewing. Grain is judged here at the preview's scale: the save carries about twice as much, which is what you get if you view the file at 100%.</span></button>
1713
+ <button class="info" type="button" aria-label="About realism"><svg viewBox="0 0 16 16" fill="none" aria-hidden="true"><path fill="currentColor" d="M8.36 1.01C12.06 1.2 15 4.26 15 8l-.01.36C14.8 12.06 11.74 15 8 15l-.36-.01C4.06 14.81 1.19 11.94 1.01 8.36L1 8c0-3.87 3.13-7 7-7l.36.01ZM8 2C4.69 2 2 4.69 2 8c0 3.31 2.69 6 6 6 3.31 0 6-2.69 6-6 0-3.31-2.69-6-6-6Zm.5 8l.5 0v1L7 11v-1h.5V7H7V6h1.5v4ZM7.9 4c.33 0 .6.27.6.6a.6.6 0 1 1-1.21 0c0-.33.28-.6.61-.6Z"/></svg><span class="tip" role="tooltip">Sits the screen in the light around it, so it stops reading as pasted. <b>Light</b> matches the scene's exposure and leaves hue alone. <b>Colour</b> matches its white balance — this is the one that warms a brand colour, so set it to 0 when an exact hex is the point and keep Light. Off does both. Grain is judged here at the preview's scale: the save carries about twice as much, which is what you get if you view the file at 100%.</span></button>
1706
1714
  </div>
1707
1715
  <button class="switch" id="gradeBtn" role="switch" aria-checked="true" aria-label="Realism">
1708
1716
  <span class="sw-track"><span class="sw-handle"></span><span class="sw-grip"></span></span>
1709
1717
  </button>
1710
1718
  </div>
1711
1719
  <div class="sect-body"><div class="sect-body-in">
1712
- <div style="display:grid;gap:var(--s2);justify-items:end">
1713
- <span class="sm" id="gradeVal" style="font-variant-numeric:tabular-nums"></span>
1714
- <input type="range" id="gradeAmt" min="0.1" max="1" step="0.05" value="0.35" aria-label="Realism strength">
1720
+ <!-- Two halves of one measurement, not two features. See
1721
+ grade.light_params: Light is a bounded L shift, Colour is the
1722
+ chroma match. Colour starts at 0 in neither -- both inherit
1723
+ whatever single strength was remembered before the split. -->
1724
+ <div style="display:grid;gap:var(--s3)">
1725
+ <div class="gr-row">
1726
+ <span class="sm gr-lbl">Light</span>
1727
+ <input type="range" id="gradeLight" min="0" max="1" step="0.05" value="0.35"
1728
+ aria-label="Light — match the scene's exposure">
1729
+ <output for="gradeLight" id="gradeLightVal"></output>
1730
+ </div>
1731
+ <div class="gr-row">
1732
+ <span class="sm gr-lbl">Colour</span>
1733
+ <input type="range" id="gradeColour" min="0" max="1" step="0.05" value="0.35"
1734
+ aria-label="Colour — match the scene's white balance">
1735
+ <output for="gradeColour" id="gradeColourVal"></output>
1736
+ </div>
1715
1737
  </div>
1716
1738
  </div></div>
1717
1739
  </div>
@@ -2306,15 +2328,26 @@ async function loadFitFile(file){
2306
2328
  }
2307
2329
  // Capture phase, so the per-role drop zones never see a fit file and try to
2308
2330
  // decode it as an image.
2309
- addEventListener('dragover', e => {
2310
- if ([...(e.dataTransfer?.items || [])].some(i => i.kind === 'file')) e.preventDefault();
2311
- }, true);
2331
+ // Is a file being dragged? Ask `types`, not `items`: Safari leaves `items`
2332
+ // empty during dragover, so an items-only test never cancelled the drag there,
2333
+ // the drop event never fired, and Safari navigated away to the dropped .json --
2334
+ // taking the whole workbench with it.
2335
+ function draggingFiles(e){
2336
+ const dt = e.dataTransfer; if (!dt) return false;
2337
+ if ([...(dt.types || [])].includes('Files')) return true;
2338
+ return [...(dt.items || [])].some(i => i.kind === 'file');
2339
+ }
2340
+ addEventListener('dragover', e => { if (draggingFiles(e)) e.preventDefault(); }, true);
2312
2341
  addEventListener('drop', e => {
2313
2342
  const f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0];
2314
2343
  if (!f || !/\.json$/i.test(f.name)) return; // not ours: let the zones have it
2315
2344
  e.preventDefault(); e.stopPropagation();
2316
2345
  loadFitFile(f);
2317
2346
  }, true);
2347
+ // A file dropped where nothing takes it must not become a navigation: the page
2348
+ // is the session, and leaving it loses the fit. Bubble phase, so the zones and
2349
+ // the fit loader above have already had their turn.
2350
+ addEventListener('drop', e => { if (draggingFiles(e)) e.preventDefault(); });
2318
2351
 
2319
2352
  /* ===== pickers (popovers over the canvas) ===== */
2320
2353
  // "Used 3 h ago", "used yesterday" -- when a source was last chosen.
@@ -2511,7 +2544,7 @@ for (const [n, role] of [[1,'photo'],[2,'screenshot']]){
2511
2544
  // onto "Choose a photo" and it lands in that role without opening the
2512
2545
  // popover. Same upload path as the popover's drop zone.
2513
2546
  const chip = $('#chip'+n);
2514
- chip.ondragover = e => { if ([...(e.dataTransfer?.items || [])].some(i => i.kind === 'file')) { e.preventDefault(); chip.classList.add('over'); } };
2547
+ chip.ondragover = e => { if (draggingFiles(e)) { e.preventDefault(); chip.classList.add('over'); } };
2515
2548
  // Leaving with a drag only un-lights the chip if its popover is not the
2516
2549
  // reason it is lit.
2517
2550
  const popOpen = () => $('#pop'+n).classList.contains('on');
@@ -3531,27 +3564,55 @@ function setSectionOpen(sect, on){
3531
3564
  colour exactly, which is what you want when the UI's colour is the thing under
3532
3565
  review. Strength is a slider because the right amount is a judgement about the
3533
3566
  photograph, not a constant. */
3534
- let gradeOn = recall('grade','1') === '1';
3535
- let gradeAmt = parseFloat(recall('gradeAmt','0.35')) || 0.35;
3536
- function gradeValue(){ return gradeOn ? gradeAmt : 0; }
3567
+ let gradeOn = recall('grade','1') === '1';
3568
+ /* One strength became two on 21 Sep 2026, and the reason is a real report: with
3569
+ realism on, a brand orange visibly moved toward coral and a pure-black status
3570
+ bar lifted to slate. Both are CORRECT -- nothing on a real phone renders #000
3571
+ -- but one control was doing two jobs and the people using this check hex
3572
+ values. The cut follows what grade.light_params already computes separately:
3573
+
3574
+ Light -> a bounded mean shift on L. Sits the screen in the scene's
3575
+ exposure and leaves hue alone (measured: d(a,b) 0.002).
3576
+ Colour -> the chroma match, mean and spread. The half that warmed the
3577
+ orange (measured: 12.98 against 0.00 with it off).
3578
+
3579
+ Anyone who had a strength saved keeps it: both halves inherit it, so the
3580
+ first render after this ships is the one they had. */
3581
+ const _oldAmt = parseFloat(recall('gradeAmt','0.35')) || 0.35;
3582
+ let gLight = parseFloat(recall('gradeLight', String(_oldAmt)));
3583
+ let gColour = parseFloat(recall('gradeColour', String(_oldAmt)));
3584
+ if (!(gLight >= 0)) gLight = _oldAmt;
3585
+ if (!(gColour >= 0)) gColour = _oldAmt;
3586
+ /* The master value. Everything server-side that asks "is the realism pass doing
3587
+ anything at all" -- grain, chiefly -- is keyed on `grade`, so it is the
3588
+ larger of the two rather than either one. */
3589
+ function gradeValue(){ return gradeOn ? Math.max(gLight, gColour) : 0; }
3590
+ function gradeLightValue(){ return gradeOn ? gLight : 0; }
3591
+ function gradeColourValue(){ return gradeOn ? gColour : 0; }
3537
3592
  function paintGrade(){
3538
- setSwitch($('#gradeBtn'), gradeOn); // hides the slider when off
3539
- $('#gradeVal').textContent = Math.round(gradeAmt * 100) + '%';
3540
- const sl = $('#gradeAmt');
3541
- sl.value = gradeAmt;
3542
- sl.style.setProperty('--p', (gradeAmt - 0.1) / 0.9);
3593
+ setSwitch($('#gradeBtn'), gradeOn); // hides the sliders when off
3594
+ for (const [id, v] of [['gradeLight', gLight], ['gradeColour', gColour]]){
3595
+ const sl = $('#' + id);
3596
+ sl.value = v;
3597
+ sl.style.setProperty('--p', v); // span is 0-1 now, so no rescale
3598
+ $('#' + id + 'Val').textContent = Math.round(v * 100) + '%';
3599
+ }
3543
3600
  }
3544
- function setGrade(on, amt){
3601
+ function setGrade(on){
3545
3602
  gradeOn = on;
3546
- if (amt !== undefined) gradeAmt = amt;
3547
- remember('grade', on ? '1' : '0'); remember('gradeAmt', String(gradeAmt));
3603
+ remember('grade', on ? '1' : '0');
3604
+ remember('gradeLight', String(gLight));
3605
+ remember('gradeColour', String(gColour));
3548
3606
  paintGrade();
3549
3607
  }
3550
3608
  $('#gradeBtn').onclick = () => { setGrade(!gradeOn); autoPreview(); };
3551
3609
  // Repaint live while dragging, re-render only on release: a full compose per
3552
3610
  // input event would queue renders faster than they finish.
3553
- $('#gradeAmt').oninput = e => { gradeAmt = parseFloat(e.target.value); paintGrade(); };
3554
- $('#gradeAmt').onchange = e => { setGrade(true, parseFloat(e.target.value)); autoPreview(); };
3611
+ for (const [id, set] of [['gradeLight', v => gLight = v],
3612
+ ['gradeColour', v => gColour = v]]){
3613
+ $('#' + id).oninput = e => { set(parseFloat(e.target.value)); paintGrade(); };
3614
+ $('#' + id).onchange = e => { set(parseFloat(e.target.value)); setGrade(true); autoPreview(); };
3615
+ }
3555
3616
 
3556
3617
  /* Emissive screen. A display emits light AND reflects the room; `replace` models
3557
3618
  only the first, which is why a true-black UI lands as a hole. Off by default so
@@ -4334,6 +4395,8 @@ async function playClip(){
4334
4395
  try{
4335
4396
  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_end2: dofEnd2V(), dof_space: 'screen',
4336
4397
  device: st.type, grade: gradeValue(),
4398
+ grade_light: gradeLightValue(),
4399
+ grade_colour: gradeColourValue(),
4337
4400
  reflection: emisValue(), fit_frame: +$('#vframe').value});
4338
4401
  }catch(e){
4339
4402
  building = false;
@@ -4391,7 +4454,7 @@ async function renderPreview(){
4391
4454
  const say = t => { if (!building && $('#liveWrap').hidden) s.textContent = t; };
4392
4455
  say('Rendering…');
4393
4456
  try{
4394
- 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_end2: dofEnd2V(), dof_space: 'screen', device: st.type, grade: gradeValue(), reflection: emisValue()});
4457
+ 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_end2: dofEnd2V(), dof_space: 'screen', device: st.type, grade: gradeValue(), grade_light: gradeLightValue(), grade_colour: gradeColourValue(), reflection: emisValue()});
4395
4458
  const im = $('#outImg');
4396
4459
  im.onload = () => {
4397
4460
  outNat = im.naturalWidth;
@@ -4524,7 +4587,9 @@ async function renderVideo(){
4524
4587
  setRenderProgress(0);
4525
4588
  try{
4526
4589
  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_end2: dofEnd2V(), dof_space: 'screen', device: st.type,
4527
- grade: gradeValue(), reflection: emisValue(), preset, fit_frame: +$('#vframe').value});
4590
+ grade: gradeValue(), grade_light: gradeLightValue(),
4591
+ grade_colour: gradeColourValue(),
4592
+ reflection: emisValue(), preset, fit_frame: +$('#vframe').value});
4528
4593
  }catch(e){
4529
4594
  setRenderProgress(null);
4530
4595
  toast('err','Could not start the render: '+e.message);
@@ -4569,7 +4634,7 @@ $('#save').onclick = async () => {
4569
4634
  if (st.video) return renderVideo();
4570
4635
  const b = $('#save'); b.textContent = 'Saving…'; b.disabled = true;
4571
4636
  try{
4572
- 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_end2: dofEnd2V(), dof_space: 'screen', device: st.type, grade: gradeValue(), reflection: emisValue()});
4637
+ 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_end2: dofEnd2V(), dof_space: 'screen', device: st.type, grade: gradeValue(), grade_light: gradeLightValue(), grade_colour: gradeColourValue(), reflection: emisValue()});
4573
4638
  b.textContent = saveLabel();
4574
4639
  // The real destination, not a hardcoded one: --out-dir means saves usually
4575
4640
  // land in the project folder now, and telling the user "~/Desktop" when
@@ -4658,7 +4723,10 @@ $('#imp').onclick = async () => {
4658
4723
  }
4659
4724
  setLoupeMode(loupeMode);
4660
4725
  setStripHC(stripHC);
4661
- setGrade(gradeOn, gradeAmt);
4726
+ // One argument since the Light/Colour split: the two amounts are module
4727
+ // state, already recalled above. Passing the old `gradeAmt` here threw a
4728
+ // ReferenceError that killed the rest of boot.
4729
+ setGrade(gradeOn);
4662
4730
  setEmis(emisOn, emisAmt);
4663
4731
  setDof(dofOn, dofAng, dofStr);
4664
4732
  setRadiusOn(radiusOn);