screengraft 0.54.7 → 0.55.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -138,8 +138,10 @@ outside the screen mask. It never touches the pixels you designed.
138
138
  warp never area-averages, so warping a 1206×2622 screenshot into a 226×454
139
139
  quad without it turns body text into noise.
140
140
  3. **Realism pass** *(optional)* — white balance and exposure toward the
141
- surrounding light, grain matched to the photo's own noise floor, real
142
- speculars lifted from a screen-off reference.
141
+ surrounding light, grain measured from the photo's own noise floor and
142
+ laid on the screen at 0.7× of it (a lit screen sits in the highlights, where
143
+ 8-bit noise is lower than on the body around it), real speculars lifted
144
+ from a screen-off reference.
143
145
  4. **Video**, when the source is a clip — everything a fixed photo and a fixed
144
146
  quad make constant is computed once, and only the frame changes. Three
145
147
  consequences worth naming, because each is a way video normally goes wrong:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "screengraft",
3
- "version": "0.54.7",
3
+ "version": "0.55.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
@@ -281,7 +281,7 @@ def measure(photo: np.ndarray, corners) -> dict:
281
281
  out.update({"angle": 0.0, "strength": 0.0, "flat": True})
282
282
  return out
283
283
  Amat = np.column_stack([mids[:, 0], mids[:, 1], np.ones(len(mids))])
284
- (a, b, c), *_ = np.linalg.lstsq(Amat, sig, rcond=None)
284
+ (a, b, _c), *_ = np.linalg.lstsq(Amat, sig, rcond=None)
285
285
  angle = math.degrees(math.atan2(b, a)) % 360.0
286
286
  strength = float(np.clip((sig.max() - lo) / max(sigma_max(q, 1.0), 1e-6), 0.0, 1.0))
287
287
  out.update({"angle": round(angle, 1), "strength": round(strength, 3), "flat": False})
package/scripts/grade.py CHANGED
@@ -131,6 +131,15 @@ def match_light(photo: np.ndarray, warped: np.ndarray, mask: np.ndarray,
131
131
 
132
132
 
133
133
  GRAIN_GATE = 20.0 # grey levels: above this a residual is an edge, not grain
134
+ # How much of the SURROUND's noise floor a lit screen should carry. The sigma is
135
+ # measured on the ring around the screen -- bezel and body, usually the darkest
136
+ # thing near it -- but the injected screen is usually the brightest thing in
137
+ # the frame, and after the sRGB curve a highlight carries less noise in grey
138
+ # levels than a shadow does. Measured on the 38-photo corpus (13 Sep 2026):
139
+ # the 192-255 band's floor is a median 0.67x the 0-63 band's. A designer's
140
+ # eye on a full-resolution save said the same thing first: "a bit smaller".
141
+ # 1.0 is the pre-v0.55 behaviour and is what an old sidecar replays with.
142
+ SCREEN_GRAIN_GAIN = 0.7
134
143
  _MEDIAN_HP_GAIN = 0.909 # a 3x3 median high-pass absorbs this much of iid noise
135
144
  # (measured, 5 seeds x sigma 1-5, spread < 0.3%)
136
145
 
package/scripts/ui.py CHANGED
@@ -50,6 +50,7 @@ import detect as D # noqa: E402
50
50
  import fitfile as FF # noqa: E402
51
51
  import fits as FIT # noqa: E402
52
52
  import dof as DOF # noqa: E402
53
+ import grade as _grade # noqa: E402
53
54
  import scan as S # noqa: E402
54
55
  import warp as W # noqa: E402
55
56
 
@@ -578,7 +579,8 @@ PREVIEW_SECONDS = 6.0
578
579
 
579
580
  def _render_worker(photo, video_path, corners, dest, radius_px, gr, grain, preset, fit_frame,
580
581
  blend="replace", reflection=None, result=None, kind="render",
581
- start_frame=0, max_frames=None, *, smoothing=0.0, dof=None):
582
+ start_frame=0, max_frames=None, *, smoothing=0.0, dof=None,
583
+ grain_gain=1.0):
582
584
  """Encode the clip, and only if that SUCCEEDS publish what it produced.
583
585
 
584
586
  `result` is the sidecar this render would write. It is handed to the worker
@@ -598,7 +600,7 @@ def _render_worker(photo, video_path, corners, dest, radius_px, gr, grain, prese
598
600
  try:
599
601
  info = W.compose_video(photo, video_path, corners, dest,
600
602
  corner_radius=radius_px, corner_smoothing=smoothing,
601
- grade=gr, grain=grain,
603
+ grade=gr, grain=grain, grain_gain=grain_gain,
602
604
  preset=preset, fit_frame=fit_frame, progress=progress,
603
605
  blend=blend,
604
606
  reflection=(W.DEFAULT_REFLECTION if reflection is None
@@ -666,6 +668,22 @@ def _blend_args(b):
666
668
  return "emissive", float(max(0.0, min(1.0, float(r))))
667
669
 
668
670
 
671
+ def _grain_gain(b) -> float:
672
+ """How much of the surround's noise floor the screen carries.
673
+
674
+ The page does not set this; fresh renders get grade.SCREEN_GRAIN_GAIN. It
675
+ is read from the body so a sidecar replayed through the API keeps its own
676
+ value -- a sidecar from before the gain existed carries none and passes
677
+ 1.0 explicitly at the replay site, never here.
678
+ """
679
+ try:
680
+ g = float(b.get("grain_gain")) if b.get("grain_gain") is not None \
681
+ else _grade.SCREEN_GRAIN_GAIN
682
+ except (TypeError, ValueError):
683
+ g = _grade.SCREEN_GRAIN_GAIN
684
+ return float(min(max(g, 0.0), 2.0))
685
+
686
+
669
687
  def _dof_args(b):
670
688
  """Depth-of-field kwargs from the page, as a dict compose() takes directly.
671
689
 
@@ -1194,7 +1212,8 @@ class Handler(BaseHTTPRequestHandler):
1194
1212
  blend, reflection, None, "preview",
1195
1213
  fit_frame, max_frames),
1196
1214
  kwargs={"smoothing": _smoothing(b),
1197
- "dof": _dof_args(b)}).start()
1215
+ "dof": _dof_args(b),
1216
+ "grain_gain": _grain_gain(b)}).start()
1198
1217
  except BaseException:
1199
1218
  with RENDER_LOCK:
1200
1219
  RENDER.update(state="error", message="could not start the preview")
@@ -1247,7 +1266,7 @@ class Handler(BaseHTTPRequestHandler):
1247
1266
  result = {"output": dest, "photo": ppath, "screenshot": spath,
1248
1267
  "corners": corners, "radius_frac": frac, "radius_px": radius_px,
1249
1268
  "device": b.get("device"), "corner_smoothing": _smoothing(b),
1250
- "grade": gr, "grain": grain,
1269
+ "grade": gr, "grain": grain, "grain_gain": _grain_gain(b),
1251
1270
  "video": True, "preset": preset, "fit_frame": fit_frame,
1252
1271
  "blend": blend, "reflection": reflection,
1253
1272
  "dof_angle": dof["dof_angle"], "dof_strength": dof["dof_strength"],
@@ -1280,7 +1299,8 @@ class Handler(BaseHTTPRequestHandler):
1280
1299
  gr, grain, preset, fit_frame,
1281
1300
  blend, reflection, result),
1282
1301
  kwargs={"smoothing": _smoothing(b),
1283
- "dof": dof}).start()
1302
+ "dof": dof,
1303
+ "grain_gain": result["grain_gain"]}).start()
1284
1304
  except BaseException:
1285
1305
  # If the thread cannot even be created, the flag must not
1286
1306
  # outlive the request.
@@ -1304,9 +1324,11 @@ class Handler(BaseHTTPRequestHandler):
1304
1324
  blend, reflection = _blend_args(b)
1305
1325
  smoothing = _smoothing(b)
1306
1326
  dof = _dof_args(b)
1327
+ grain_gain = _grain_gain(b)
1307
1328
  out = W.compose(photo, shot, corners, radius_px,
1308
1329
  corner_smoothing=smoothing,
1309
1330
  grade=gr, grain=bool(b.get("grain", gr > 0)),
1331
+ grain_gain=grain_gain,
1310
1332
  blend=blend, reflection=reflection, **dof)
1311
1333
  SESSION.update(corners=corners, radius_frac=frac, device=b.get("device"),
1312
1334
  grade=gr)
@@ -1342,6 +1364,7 @@ class Handler(BaseHTTPRequestHandler):
1342
1364
  "radius_frac": frac, "radius_px": radius_px, "device": b.get("device"),
1343
1365
  "corner_smoothing": smoothing,
1344
1366
  "grade": gr, "grain": bool(b.get("grain", gr > 0)),
1367
+ "grain_gain": grain_gain,
1345
1368
  "blend": blend, "reflection": reflection,
1346
1369
  "dof_angle": dof["dof_angle"], "dof_strength": dof["dof_strength"],
1347
1370
  "dof_start": dof["dof_start"], "dof_end": dof["dof_end"],
package/scripts/warp.py CHANGED
@@ -270,7 +270,8 @@ class Plan:
270
270
  blend: str = "replace", reflection: float = DEFAULT_REFLECTION,
271
271
  corner_smoothing: float = 0.0,
272
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", dof_end2=None):
273
+ dof_end: float = 1.0, dof_space: str = "photo", dof_end2=None,
274
+ grain_gain: float = 1.0):
274
275
  dst_quad = np.array(corners, dtype=np.float32)
275
276
  if shoelace_area(dst_quad) < 1.0:
276
277
  raise ValueError("degenerate quad (near-zero area) — check corner order TL,TR,BR,BL")
@@ -310,8 +311,12 @@ class Plan:
310
311
  self.corner_smoothing)
311
312
  self.warped_mask = _warp_mask_antialiased(src_mask, self.H, pw, ph, dst_quad)
312
313
  self.mask3 = cv2.merge([self.warped_mask] * 3).astype(np.float32) / 255.0
314
+ # The floor is the surround's; the screen carries `grain_gain` of it
315
+ # (see grade.SCREEN_GRAIN_GAIN). Defaults to 1.0 so a sidecar written
316
+ # before the gain existed reproduces its save byte for byte.
317
+ self.grain_gain = float(max(grain_gain, 0.0))
313
318
  self.grain_sigma = (_grade.measure_grain(photo, _grade.surround_ring(self.warped_mask))
314
- if grain else 0.0)
319
+ * self.grain_gain if grain else 0.0)
315
320
  self.grade_params = None
316
321
  # Integer bbox of the quad, clamped to the canvas and padded by a pixel
317
322
  # so the antialiased edge is never clipped.
@@ -444,7 +449,8 @@ def compose(photo: np.ndarray, screenshot: np.ndarray, corners, corner_radius: f
444
449
  reflection: float = DEFAULT_REFLECTION,
445
450
  dof_angle: float = 0.0, dof_strength: float = 0.0,
446
451
  dof_start: float = 0.0, dof_end: float = 1.0,
447
- dof_space: str = "photo", dof_end2=None) -> np.ndarray:
452
+ dof_space: str = "photo", dof_end2=None,
453
+ grain_gain: float = 1.0) -> np.ndarray:
448
454
  """Warp `screenshot` into the quad `corners` (TL,TR,BR,BL, photo pixels) on `photo`.
449
455
 
450
456
  Single resampling pass at the photo's resolution; deterministic. This is the
@@ -459,7 +465,8 @@ def compose(photo: np.ndarray, screenshot: np.ndarray, corners, corner_radius: f
459
465
  corner_smoothing=corner_smoothing,
460
466
  blend=blend, reflection=reflection,
461
467
  dof_angle=dof_angle, dof_strength=dof_strength, dof_start=dof_start,
462
- dof_end=dof_end, dof_space=dof_space, dof_end2=dof_end2)
468
+ dof_end=dof_end, dof_space=dof_space, dof_end2=dof_end2,
469
+ grain_gain=grain_gain)
463
470
  plan.bind_grade(screenshot, grade)
464
471
  return plan.render(screenshot, screen_off=screen_off, specular=specular)
465
472
 
@@ -549,7 +556,8 @@ def compose_video(photo: np.ndarray, video_path: str, corners, output: str,
549
556
  start_frame: int = 0, max_frames: int = None,
550
557
  dof_angle: float = 0.0, dof_strength: float = 0.0,
551
558
  dof_start: float = 0.0, dof_end: float = 1.0,
552
- dof_space: str = "photo", dof_end2=None) -> dict:
559
+ dof_space: str = "photo", dof_end2=None,
560
+ grain_gain: float = 1.0) -> dict:
553
561
  """Inject a VIDEO into a still photo. The photo does not move, so there is
554
562
  exactly one homography and the whole of Plan is computed once.
555
563
 
@@ -578,7 +586,8 @@ def compose_video(photo: np.ndarray, video_path: str, corners, output: str,
578
586
  corner_smoothing=corner_smoothing,
579
587
  blend=blend, reflection=reflection,
580
588
  dof_angle=dof_angle, dof_strength=dof_strength, dof_start=dof_start,
581
- dof_end=dof_end, dof_space=dof_space, dof_end2=dof_end2)
589
+ dof_end=dof_end, dof_space=dof_space, dof_end2=dof_end2,
590
+ grain_gain=grain_gain)
582
591
  plan.bind_grade(first, grade)
583
592
 
584
593
  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.54):** 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.55):** 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
 
package/ui/index.html CHANGED
@@ -2920,9 +2920,38 @@ function swingText(mode){
2920
2920
  if (mode === 'rotB') return 'the left end swings, pivot on the right';
2921
2921
  return 'flat on the rule means aligned';
2922
2922
  }
2923
+ /* A corner under the pointer gets a plain magnified view instead of the
2924
+ rectified strip: the strip straightens ONE edge, and a corner is where two
2925
+ meet, so straightening either would bend the other. Same magnification as
2926
+ the strip (H / 2·stripHalf, so the ± buttons work here too), centred on the
2927
+ corner, unrotated, with both edges drawn through it and the ring at the
2928
+ centre. Requested 13 Sep 2026; edges keep the strip as it is. */
2929
+ function paintCorner(c, W, H, i){
2930
+ c.setTransform(1,0,0,1,0,0);
2931
+ c.fillStyle = '#0c0c0b'; c.fillRect(0,0,W,H);
2932
+ const P = st.corners[i], M = H / (2*stripHalf);
2933
+ c.save();
2934
+ c.setTransform(M, 0, 0, M, W/2 - P[0]*M, H/2 - P[1]*M);
2935
+ c.imageSmoothingEnabled = true;
2936
+ c.drawImage(img, 0, 0);
2937
+ c.restore();
2938
+ if (stripHC) stretchContrast(c, W, H);
2939
+ const toS = q => [W/2 + (q[0]-P[0])*M, H/2 + (q[1]-P[1])*M];
2940
+ const prev = st.corners[(i+3)%4], next = st.corners[(i+1)%4];
2941
+ for (const q of [prev, next]){
2942
+ const e = toS(q), d = unit(sub(e, [W/2, H/2]));
2943
+ const s0 = add([W/2, H/2], mul(d, HANDLE_GAP_S)), s1 = add([W/2, H/2], mul(d, 4000));
2944
+ cased(c, () => { c.beginPath(); c.moveTo(s0[0], s0[1]); c.lineTo(s1[0], s1[1]); }, QUAD_W, CORE_QUAD);
2945
+ }
2946
+ casedRing(c, W/2, H/2, 4, CORE_QUAD);
2947
+ c.font = '600 11px system-ui'; c.textBaseline = 'middle';
2948
+ casedText(c, LBL[i], W/2 + 12, H/2 - 12, 'rgba(255,255,255,.95)');
2949
+ }
2950
+ const HANDLE_GAP_S = 4 + 1 + CASE_PAD/2 + 1; // the same break the canvas leaves around a ring
2923
2951
  function drawStrip(){
2924
2952
  const a = drag || hover || pick;
2925
- const has = !!(st.corners && a && a.kind === 'edge' && img.naturalWidth);
2953
+ const has = !!(st.corners && a && (a.kind === 'edge' || a.kind === 'corner') && img.naturalWidth);
2954
+ const corner = has && a.kind === 'corner';
2926
2955
  $('#stripZ').textContent = '±' + stripHalf + 'px';
2927
2956
  if (loupeMode === 'dock'){
2928
2957
  $('#floatLoupe').classList.remove('on');
@@ -2932,13 +2961,23 @@ function drawStrip(){
2932
2961
  $('#stripSt').textContent = 'Hover or drag an edge — this straightens it, so misalignment shows as a wedge.';
2933
2962
  return;
2934
2963
  }
2935
- paintStrip(sctx, W, H, a.i, a.mode);
2936
- $('#stripSt').textContent = `${EDGE_LBL[a.i]} edge · ±${stripHalf}px · ${(H/(2*stripHalf)).toFixed(1)}× across the boundary — ${swingText(a.mode)}`;
2964
+ if (corner){
2965
+ paintCorner(sctx, W, H, a.i);
2966
+ $('#stripSt').textContent = `${LBL[a.i]} corner · ${(H/(2*stripHalf)).toFixed(1)}× — where the two edges meet`;
2967
+ } else {
2968
+ paintStrip(sctx, W, H, a.i, a.mode);
2969
+ $('#stripSt').textContent = `${EDGE_LBL[a.i]} edge · ±${stripHalf}px · ${(H/(2*stripHalf)).toFixed(1)}× across the boundary — ${swingText(a.mode)}`;
2970
+ }
2937
2971
  } else {
2938
2972
  const fl = $('#floatLoupe');
2939
2973
  if (!has){ fl.classList.remove('on'); return; }
2940
- paintStrip(fctx, stripF.width, stripF.height, a.i, a.mode);
2941
- $('#stripFCap').textContent = `${EDGE_LBL[a.i]} edge · ±${stripHalf}px — ${swingText(a.mode)}`;
2974
+ if (corner){
2975
+ paintCorner(fctx, stripF.width, stripF.height, a.i);
2976
+ $('#stripFCap').textContent = `${LBL[a.i]} corner · ${(stripF.height/(2*stripHalf)).toFixed(1)}× — where the two edges meet`;
2977
+ } else {
2978
+ paintStrip(fctx, stripF.width, stripF.height, a.i, a.mode);
2979
+ $('#stripFCap').textContent = `${EDGE_LBL[a.i]} edge · ±${stripHalf}px — ${swingText(a.mode)}`;
2980
+ }
2942
2981
  fl.classList.add('on');
2943
2982
  const w = fl.offsetWidth || 356, h = fl.offsetHeight || 130;
2944
2983
  let x = lastPointer[0] + 26, y = lastPointer[1] - h - 18;