screengraft 0.44.0 → 0.46.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.44.0",
3
+ "version": "0.46.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
@@ -260,10 +260,22 @@ def approx_quad(contour):
260
260
 
261
261
 
262
262
  def order_quad(pts: np.ndarray) -> np.ndarray:
263
- """Order 4 points TL, TR, BR, BL as they appear in the image."""
263
+ """Order 4 points TL, TR, BR, BL as they appear in the IMAGE.
264
+
265
+ Corner 0 is where the screenshot's top-left lands, so this decides the
266
+ screenshot's orientation -- and this function cannot decide it well: on a
267
+ phone photographed at ~45 degrees the corner nearest the image's top-left
268
+ is the screen's physical bottom-left, so a quad right to 1% put the
269
+ screenshot a quarter turn out (12 Sep 2026). A rule "the top edge of
270
+ a portrait quad is its higher short edge" was tried here and is wrong for
271
+ every landscape screen, because which edge is the top depends on the
272
+ SCREENSHOT's aspect, which only the workbench knows. So this stays
273
+ geometric and the workbench turns the order to match the screenshot
274
+ (`orientQuad` in ui/index.html, plus a Rotate 90 degrees control).
275
+ """
264
276
  c = pts.mean(axis=0)
265
277
  ang = np.arctan2(pts[:, 1] - c[1], pts[:, 0] - c[0])
266
- pts = pts[np.argsort(ang)] # counter-clockwise in image coords
278
+ pts = pts[np.argsort(ang)] # clockwise as seen on screen (y down)
267
279
  start = int(np.argmin(pts.sum(axis=1))) # closest to the image's top-left
268
280
  return np.roll(pts, -start, axis=0)
269
281
 
@@ -427,7 +439,7 @@ def overlap_frac(inner: np.ndarray, outer: np.ndarray) -> float:
427
439
  return float(cv2.contourArea(region.astype(np.float32))) / a
428
440
 
429
441
 
430
- def pick_innermost(candidates, best):
442
+ def pick_innermost(candidates, best, quad_of=None):
431
443
  """
432
444
  A device photo offers more than one screen-shaped region: the glass screen,
433
445
  and the bezel or body it sits in. They nest, and the outer one wins on
@@ -442,20 +454,33 @@ def pick_innermost(candidates, best):
442
454
  old 0.35 floor a wave-shaped gradient at 49% of its screen was eligible;
443
455
  at 0.55 it isn't (an earlier finding, 3 Sep 2026).
444
456
  """
457
+ # `quad_of` maps a candidate to the quad its nesting is judged on. The
458
+ # walk passes the REFINED corners; the default is the raw polygon
459
+ # approximation. Raw vertices sit on the corner arcs, not at the corners,
460
+ # and on a phone photographed at ~45 degrees the glass's raw vertex landed
461
+ # ON the body's raw edge line -- `quad_contains` was false, and the body
462
+ # (tier 2, 13% off) shipped with the glass (tier 2, 1%, 87% of its area)
463
+ # one step behind it (12 Sep 2026, a two-phone mockup). Judged on the
464
+ # refined corners the glass is 10px inside the body all round. Replacing
465
+ # the containment test with area overlap was tried first and stepped into
466
+ # wrong inner quads on five photographs -- the strict test is doing work.
467
+ if quad_of is None:
468
+ quad_of = lambda c: c[1] # noqa: E731
445
469
  current = best
446
470
  for _ in range(4): # screen inside bezel inside body: a few steps is plenty
447
- c_area = cv2.contourArea(current[1].astype(np.float32))
471
+ cq = quad_of(current)
472
+ c_area = cv2.contourArea(cq.astype(np.float32))
448
473
  inner = [
449
474
  c for c in candidates
450
475
  if c is not current
451
476
  and c[0] >= 0.25 * current[0]
452
- and quad_contains(current[1], c[1])
453
- and NEST_FLOOR * c_area <= cv2.contourArea(c[1].astype(np.float32)) < c_area
477
+ and quad_contains(cq, quad_of(c))
478
+ and NEST_FLOOR * c_area <= cv2.contourArea(quad_of(c).astype(np.float32)) < c_area
454
479
  ]
455
480
  if not inner:
456
481
  return current
457
482
  # Largest of the nested ones: the screen, not a panel drawn on it.
458
- current = max(inner, key=lambda c: cv2.contourArea(c[1].astype(np.float32)))
483
+ current = max(inner, key=lambda c: cv2.contourArea(quad_of(c).astype(np.float32)))
459
484
  return current
460
485
 
461
486
 
@@ -668,10 +693,20 @@ def _finalize(candidates, img_area: float, refine: bool = True, img_shape=None,
668
693
  # Deliberately NOT tier-first ordering of the whole list: that was tried
669
694
  # on 11 Sep 2026 and broke three photographs, because a tier-2 WRONG
670
695
  # candidate sits deeper in the list on each.
696
+ # Refined corners, once per candidate: the walk order reads tiers off them
697
+ # and pick_innermost judges nesting on them (raw polygon vertices sit on
698
+ # the arcs and can land on a neighbouring quad's edge line -- see there).
699
+ shapes = {}
700
+
671
701
  def _shape_of(cand):
672
- _score, quad, contour, _tag = cand
673
- q = order_quad(refine_corners(contour, quad, rail_band)[0]) if refine else quad
674
- return q, shape_tier({"corner_radius": measure_corner_radius(contour, q)})
702
+ if id(cand) not in shapes:
703
+ _score, quad, contour, _tag = cand
704
+ q = order_quad(refine_corners(contour, quad, rail_band)[0]) if refine else quad
705
+ shapes[id(cand)] = (q, shape_tier({"corner_radius": measure_corner_radius(contour, q)}))
706
+ return shapes[id(cand)]
707
+
708
+ def _refined(cand):
709
+ return _shape_of(cand)[0]
675
710
 
676
711
  order = sorted(candidates, key=lambda c: c[0], reverse=True)
677
712
  if order:
@@ -691,7 +726,7 @@ def _finalize(candidates, img_area: float, refine: bool = True, img_shape=None,
691
726
  # NOT necessarily `cand`: pick_innermost steps inward from it while a
692
727
  # comparably screen-like quad nests inside, so the quad that gets
693
728
  # validated -- and returned -- can belong to a different candidate.
694
- picked = pick_innermost(candidates, cand)
729
+ picked = pick_innermost(candidates, cand, quad_of=_refined)
695
730
  score, quad, contour, tag = picked
696
731
  if refine:
697
732
  refined, did_refine = refine_corners(contour, quad, rail_band)
@@ -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.44):** 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.46):** 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
@@ -984,6 +984,7 @@
984
984
  <span class="stepper"><button class="sm" data-jump="0">TL</button><button class="sm" data-jump="1">TR</button><button class="sm" data-jump="2">BR</button><button class="sm" data-jump="3">BL</button></span>
985
985
  <button class="sm" id="redetect">Re-detect</button>
986
986
  <button class="sm" id="pointat" title="Click once inside the screen and the detector will use that point. The detectors usually do find the screen — they just cannot tell which region IS one, and that is the part you can answer instantly.">Point at screen</button>
987
+ <button class="sm" id="rotatequad" title="Turn the screenshot a quarter turn inside the same four edges. The edges stay where they are; only which one is the top changes. Use this when the phone lies on its side and the screenshot lands sideways.">Rotate 90°</button>
987
988
  <button class="sm" id="resetquad" title="Put the four edges back to a rectangle in the middle of the photo. Use this if a corner has ended up off the picture where you cannot grab it.">Reset</button>
988
989
  </span>
989
990
  </div>
@@ -1568,7 +1569,10 @@ function setChosen(role, path, size, el, meta){
1568
1569
  // screenshot to put inside it, which is what maybeStart() waits for.
1569
1570
  if (role==='photo'){ st.photo = path; st.corners = null;
1570
1571
  st.remembered = (meta && meta.remembered) || null; }
1571
- else { st.shot = path; st.video = meta && meta.video ? meta : null; syncVideoUI(); }
1572
+ else { st.shot = path; st.shotSize = size; st.video = meta && meta.video ? meta : null; syncVideoUI();
1573
+ // A detected quad is re-oriented for the new screenshot; a remembered or
1574
+ // hand-placed one is somebody's decision and is left alone.
1575
+ if (st.corners && st.quadFrom === 'detected' && orientQuad()){ draw(); drawStrip(); autoPreview(); } }
1572
1576
  closePop();
1573
1577
  maybeStart();
1574
1578
  }
@@ -1931,6 +1935,7 @@ async function pointAt(x, y){
1931
1935
  const r = await api('/api/detect', {click: [x, y]});
1932
1936
  if (r.found){
1933
1937
  st.corners = r.corners; st.measured = r.corner_radius; st.quadFrom = 'detected';
1938
+ orientQuad();
1934
1939
  s.className = 'status ok';
1935
1940
  s.textContent = `Screen found from your click (${r.method})`;
1936
1941
  if (!st.type) setType(r.type_guess || 'phone');
@@ -1961,6 +1966,7 @@ async function detect(){
1961
1966
  const r = await api('/api/detect', {});
1962
1967
  if (r.found){
1963
1968
  st.corners = r.corners; st.measured = r.corner_radius; st.quadFrom = 'detected';
1969
+ orientQuad();
1964
1970
  const corroborated = r.confidence === 'corroborated';
1965
1971
  s.className = 'status ' + (corroborated ? 'ok' : 'warn');
1966
1972
  s.textContent = corroborated ? `Both detectors agree (${r.method})`
@@ -2029,6 +2035,54 @@ function defaultQuad(){
2029
2035
  return [[w*.3,h*.2],[w*.7,h*.2],[w*.7,h*.8],[w*.3,h*.8]];
2030
2036
  }
2031
2037
  $('#redetect').onclick = detect;
2038
+ // Rotate: the same four points, the list started one place later. Corner 0 is
2039
+ // where the screenshot's top-left goes, so this turns the screenshot a quarter
2040
+ // turn clockwise inside an unchanged quad. Needed because detection orders
2041
+ // corners by where they sit in the PHOTO (nearest the image's top-left first),
2042
+ // and on a phone lying at 45° that is the screen's bottom-left — the quad was
2043
+ // right to 1% and the screenshot would have landed sideways (12 Sep 2026).
2044
+ // Dragging four corners to their neighbours' places was the only way.
2045
+ // The saved fit and the sidecar carry the corners in order, so a rotated fit
2046
+ // comes back rotated; nothing else changes.
2047
+ function turnQuad(){
2048
+ st.corners = [1, 2, 3, 0].map(i => st.corners[i].slice());
2049
+ if (pick && pick.kind === 'corner') pick = {kind: 'corner', i: (pick.i + 3) % 4};
2050
+ else if (pick && pick.kind === 'edge') pick = {kind: 'edge', i: (pick.i + 3) % 4};
2051
+ }
2052
+ function rotateQuad(){
2053
+ if (!st.corners) return;
2054
+ turnQuad();
2055
+ draw(); drawStrip(); autoPreview();
2056
+ }
2057
+ $('#rotatequad').onclick = rotateQuad;
2058
+ // The automatic half. Detection orders corners geometrically and cannot know
2059
+ // which edge is the top: that depends on the SCREENSHOT. A portrait screenshot
2060
+ // goes on the quad the long way, a landscape one the wide way. So once both
2061
+ // are known, if the quad's top edge is clearly the wrong kind for the
2062
+ // screenshot, turn the order until it is the right kind — the higher of the
2063
+ // two candidate top edges, so an upright photo is unchanged. A rule of this
2064
+ // shape was first put in detect.py's order_quad and was wrong for every
2065
+ // landscape screen; the aspect of the screenshot is the missing fact and only
2066
+ // this page has it. Near-square quads (under 1.25) are left alone — perspective
2067
+ // can make either kind of edge the longer one. Returns whether it turned.
2068
+ const ORIENT_ASPECT = 1.25;
2069
+ function orientQuad(){
2070
+ if (!st.corners || !st.shotSize) return false;
2071
+ const C = st.corners;
2072
+ const len = i => Math.hypot(C[(i+1)%4][0]-C[i][0], C[(i+1)%4][1]-C[i][1]);
2073
+ const a = (len(0) + len(2)) / 2, b = (len(1) + len(3)) / 2; // edges 0/2 vs 1/3
2074
+ if (Math.max(a, b) < ORIENT_ASPECT * Math.max(Math.min(a, b), 1e-6)) return false;
2075
+ const shotPortrait = st.shotSize[1] > st.shotSize[0];
2076
+ const topShouldBeShort = shotPortrait;
2077
+ const topIsShort = a < b;
2078
+ if (topIsShort === topShouldBeShort) return false;
2079
+ // Two candidate top edges (1 and 3, the other pair); the higher one in the
2080
+ // image is the top. Turn once to make edge 1 the top, twice more for edge 3.
2081
+ const midY = i => (C[i][1] + C[(i+1)%4][1]) / 2;
2082
+ const turns = midY(1) <= midY(3) ? 1 : 3;
2083
+ for (let k = 0; k < turns; k++) turnQuad();
2084
+ return true;
2085
+ }
2032
2086
  $('#resetquad').onclick = () => {
2033
2087
  if (!img.naturalWidth) return;
2034
2088
  st.corners = defaultQuad(); st.quadFrom = 'default';