screengraft 0.66.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.66.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
@@ -1077,6 +1101,35 @@ class Handler(BaseHTTPRequestHandler):
1077
1101
  except RenderBusy as exc:
1078
1102
  return self._json({"error": str(exc)}, 409)
1079
1103
 
1104
+ if u.path == "/api/reveal":
1105
+ # Show a saved file in the desktop's file manager. The PAGE
1106
+ # cannot do this -- a browser will not open a Finder window
1107
+ # from an http origin -- and this server is the half of
1108
+ # screengraft that runs on the user's machine, so it is the
1109
+ # only thing that can.
1110
+ #
1111
+ # _safe_local_path is the whole security story and it already
1112
+ # guards /file: it expands ~, resolves symlinks, refuses any
1113
+ # path outside HOME, and requires a file that exists. The
1114
+ # command is an argument LIST handed to the OS, never a string
1115
+ # through a shell, so a path cannot become an argument or a
1116
+ # second command however it is spelled.
1117
+ try:
1118
+ p = _safe_local_path(b.get("path") or "")
1119
+ except (PermissionError, FileNotFoundError, TypeError) as exc:
1120
+ return self._json({"error": str(exc)}, 400)
1121
+ if sys.platform == "darwin":
1122
+ cmd = ["open", "-R", p] # reveals AND selects it
1123
+ elif os.name == "nt":
1124
+ cmd = ["explorer", "/select,", p] # exits 1 even on success
1125
+ else:
1126
+ cmd = ["xdg-open", os.path.dirname(p)]
1127
+ try:
1128
+ subprocess.run(cmd, capture_output=True, timeout=10, check=False)
1129
+ except (OSError, subprocess.SubprocessError) as exc:
1130
+ return self._json({"error": f"could not open the folder: {exc}"}, 500)
1131
+ return self._json({"ok": True})
1132
+
1080
1133
  if u.path == "/api/figma":
1081
1134
  return self._json(SESSION.enqueue({
1082
1135
  "type": "figma_export", "url": b["url"],
@@ -1274,7 +1327,7 @@ class Handler(BaseHTTPRequestHandler):
1274
1327
  else _fit_frame())
1275
1328
  first = W.read_frame_at(spath, fit_frame)
1276
1329
  radius_px = frac * first.shape[1]
1277
- gr = float(b.get("grade") if b.get("grade") is not None else 0.0)
1330
+ gr, gl, gc = _grade_args(b)
1278
1331
  grain = bool(b.get("grain", gr > 0))
1279
1332
  blend, reflection = _blend_args(b)
1280
1333
  # Downscale the PHOTO and scale the quad with it, rather than
@@ -1315,7 +1368,9 @@ class Handler(BaseHTTPRequestHandler):
1315
1368
  fit_frame, max_frames),
1316
1369
  kwargs={"smoothing": _smoothing(b),
1317
1370
  "dof": _dof_args(b),
1318
- "grain_gain": _grain_gain(b)}).start()
1371
+ "grain_gain": _grain_gain(b),
1372
+ "grade_light": gl,
1373
+ "grade_colour": gc}).start()
1319
1374
  except BaseException:
1320
1375
  with RENDER_LOCK:
1321
1376
  RENDER.update(state="error", message="could not start the preview")
@@ -1346,7 +1401,7 @@ class Handler(BaseHTTPRequestHandler):
1346
1401
  else _fit_frame())
1347
1402
  first = W.read_frame_at(spath, fit_frame)
1348
1403
  radius_px = frac * first.shape[1]
1349
- gr = float(b.get("grade") if b.get("grade") is not None else 0.0)
1404
+ gr, gl, gc = _grade_args(b)
1350
1405
  grain = bool(b.get("grain", gr > 0))
1351
1406
  blend, reflection = _blend_args(b)
1352
1407
  dof = _dof_args(b)
@@ -1368,7 +1423,8 @@ class Handler(BaseHTTPRequestHandler):
1368
1423
  result = {"output": dest, "photo": ppath, "screenshot": spath,
1369
1424
  "corners": corners, "radius_frac": frac, "radius_px": radius_px,
1370
1425
  "device": b.get("device"), "corner_smoothing": _smoothing(b),
1371
- "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),
1372
1428
  "video": True, "preset": preset, "fit_frame": fit_frame,
1373
1429
  "blend": blend, "reflection": reflection,
1374
1430
  "dof_angle": dof["dof_angle"], "dof_strength": dof["dof_strength"],
@@ -1402,7 +1458,9 @@ class Handler(BaseHTTPRequestHandler):
1402
1458
  blend, reflection, result),
1403
1459
  kwargs={"smoothing": _smoothing(b),
1404
1460
  "dof": dof,
1405
- "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()
1406
1464
  except BaseException:
1407
1465
  # If the thread cannot even be created, the flag must not
1408
1466
  # outlive the request.
@@ -1422,14 +1480,15 @@ class Handler(BaseHTTPRequestHandler):
1422
1480
  # composite is the right output when the screenshot's own colour
1423
1481
  # is the point (a brand review), and the grade is the right one
1424
1482
  # when the photograph is (a portfolio shot).
1425
- gr = float(b.get("grade") if b.get("grade") is not None else 0.0)
1483
+ gr, gl, gc = _grade_args(b)
1426
1484
  blend, reflection = _blend_args(b)
1427
1485
  smoothing = _smoothing(b)
1428
1486
  dof = _dof_args(b)
1429
1487
  grain_gain = _grain_gain(b)
1430
1488
  out = W.compose(photo, shot, corners, radius_px,
1431
1489
  corner_smoothing=smoothing,
1432
- 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)),
1433
1492
  grain_gain=grain_gain,
1434
1493
  blend=blend, reflection=reflection, **dof)
1435
1494
  SESSION.update(corners=corners, radius_frac=frac, device=b.get("device"),
@@ -1465,7 +1524,8 @@ class Handler(BaseHTTPRequestHandler):
1465
1524
  result = {"output": dest, "photo": ppath, "screenshot": spath, "corners": corners,
1466
1525
  "radius_frac": frac, "radius_px": radius_px, "device": b.get("device"),
1467
1526
  "corner_smoothing": smoothing,
1468
- "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)),
1469
1529
  "grain_gain": grain_gain,
1470
1530
  "blend": blend, "reflection": reflection,
1471
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.66):** 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