screengraft 0.45.0 → 0.47.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.45.0",
3
+ "version": "0.47.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
 
package/scripts/ui.py CHANGED
@@ -900,7 +900,10 @@ class Handler(BaseHTTPRequestHandler):
900
900
  with open(dest, "wb") as f:
901
901
  f.write(self._body())
902
902
  # A video is only ever a screen source; a photo must be a still.
903
- return self._json(_adopt(role, dest))
903
+ # The chip shows the file's OWN name: the session copy is
904
+ # "<role>-<epoch>-<name>", and a chip reading "photo-1789…" told
905
+ # the person nothing about which photo was loaded (12 Sep 2026).
906
+ return self._json({**_adopt(role, dest), "name": name})
904
907
 
905
908
  b = self._jbody()
906
909
 
@@ -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.45):** 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.47):** 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
@@ -381,7 +381,7 @@
381
381
  NOTE the modifier is `is-empty`, not `empty`: `.empty` is already the
382
382
  centred placeholder-paragraph class (padding:24px), and the chip was
383
383
  silently inheriting it and rendering 50px tall instead of 32. */
384
- .inputchip{display:inline-flex;align-items:center;gap:var(--s2);max-width:300px;
384
+ .inputchip{display:inline-flex;align-items:center;gap:var(--s2);max-width:220px;
385
385
  height:32px;padding:0 12px 0 6px;border-radius:999px;
386
386
  background:var(--raise);border:1px solid var(--edge);box-shadow:var(--hi)}
387
387
  .inputchip .sw{width:22px;height:22px;border-radius:999px;flex:none;
@@ -393,17 +393,23 @@
393
393
  .inputchip.is-empty{background:var(--card);border-style:dashed;
394
394
  color:var(--mute);box-shadow:none}
395
395
  .inputchip.is-empty .sw{box-shadow:inset 0 0 0 1px var(--line)}
396
- /* The clear control is a SIBLING of the chip, not a child: the chip is one
397
- <button>, and a button inside a button is invalid HTML that browsers
398
- repair unpredictably. Beside it, it is its own keyboard stop with its own
399
- name ("Remove photo"), which is also what makes it distinct from the fit
400
- bar's Reset -- that resets the QUAD; this un-chooses a SOURCE. A small
401
- round Sm button, so it reads as belonging to the chip without competing
402
- with it. Only present while the chip is filled. */
403
- .chipclear{width:24px;height:24px;padding:0;border-radius:999px;flex:none;
404
- margin-left:calc(-1 * var(--s1));font-size:15px;line-height:1;
405
- color:var(--mute)}
406
- .chipclear:hover{color:var(--ink)}
396
+ /* The clear control is a SIBLING of the chip in the markup, not a child:
397
+ the chip is one <button>, and a button inside a button is invalid HTML
398
+ that browsers repair unpredictably. It is drawn INSIDE the pill though --
399
+ .chipwrap positions it over the chip's right end and the filled chip
400
+ reserves room for it -- because beside the chip it read as a stray
401
+ character and nobody found it ("place clear buttons inside of chip and
402
+ make it visible", 12 Sep 2026). Its own keyboard stop with its own name
403
+ ("Remove photo"), distinct from the fit bar's Reset -- that resets the
404
+ QUAD; this un-chooses a SOURCE. Only present while the chip is filled. */
405
+ .chipwrap{position:relative;display:inline-flex;min-width:0}
406
+ .chipwrap .inputchip:not(.is-empty){padding-right:30px}
407
+ .chipclear{position:absolute;right:5px;top:50%;transform:translateY(-50%);
408
+ width:22px;height:22px;padding:0;border-radius:999px;flex:none;
409
+ font-size:15px;line-height:1;color:var(--ink);
410
+ background:var(--raise-hi);border:1px solid var(--edge)}
411
+ .chipclear:hover{background:var(--raise-highest);border-color:var(--edge-hi)}
412
+ .chipclear:active{background:var(--raise-mid);transform:translateY(-50%)}
407
413
 
408
414
  /* Status carries meaning in THREE channels: glyph, weight, luminance —
409
415
  never hue alone. */
@@ -922,11 +928,9 @@
922
928
  <div class="tb-left">
923
929
  <span class="brand">Screengraft</span><span id="sess" hidden></span>
924
930
  <div class="tb-chips">
925
- <button class="inputchip is-empty" id="chip1"><span class="sw" id="sw1"></span><span class="nm">Choose a photo…</span></button>
926
- <button class="chipclear" id="clear1" data-role="photo" aria-label="Remove photo" title="Remove the photo and start over. A fit you saved for it comes back when you pick it again." hidden>&times;</button>
931
+ <span class="chipwrap"><button class="inputchip is-empty" id="chip1"><span class="sw" id="sw1"></span><span class="nm">Choose a photo…</span></button><button class="chipclear" id="clear1" data-role="photo" aria-label="Remove photo" title="Remove the photo and start over. A fit you saved for it comes back when you pick it again." hidden>&times;</button></span>
927
932
  <span class="tb-arrow">&rarr;</span>
928
- <button class="inputchip is-empty" id="chip2"><span class="sw" id="sw2"></span><span class="nm">Choose a screenshot…</span></button>
929
- <button class="chipclear" id="clear2" data-role="screenshot" aria-label="Remove screenshot" title="Remove the screenshot. The fit on the photo is kept." hidden>&times;</button>
933
+ <span class="chipwrap"><button class="inputchip is-empty" id="chip2"><span class="sw" id="sw2"></span><span class="nm">Choose a screenshot…</span></button><button class="chipclear" id="clear2" data-role="screenshot" aria-label="Remove screenshot" title="Remove the screenshot. The fit on the photo is kept." hidden>&times;</button></span>
930
934
  </div>
931
935
  </div>
932
936
  <span class="spacer"></span>
@@ -984,6 +988,7 @@
984
988
  <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
989
  <button class="sm" id="redetect">Re-detect</button>
986
990
  <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>
991
+ <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
992
  <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
993
  </span>
989
994
  </div>
@@ -1511,6 +1516,7 @@ async function clearSource(role){
1511
1516
  const chip = $('#chip'+n);
1512
1517
  chip.classList.add('is-empty');
1513
1518
  chip.querySelector('.nm').textContent = role==='photo' ? 'Choose a photo…' : 'Choose a screenshot…';
1519
+ chip.title = '';
1514
1520
  $('#sw'+n).style.background = '';
1515
1521
  $('#pv'+n).innerHTML = '<span class="empty">Select a recent image</span>';
1516
1522
  $('#clear'+n).hidden = true;
@@ -1559,7 +1565,14 @@ function setChosen(role, path, size, el, meta){
1559
1565
  $('#pv'+n).innerHTML = `<img src="${fileURL(thumb)}" alt="">`;
1560
1566
  const chip = $('#chip'+n);
1561
1567
  chip.classList.remove('is-empty');
1562
- chip.querySelector('.nm').textContent = `${path.split('/').pop()} · ${size[0]}×${size[1]}`
1568
+ // The file's own name and nothing else. The size and, for a clip, the frame
1569
+ // count used to follow it in the same 12px, which pushed the one thing that
1570
+ // identifies the file into an ellipsis; they live on hover now, with the
1571
+ // full path. An upload's session copy is "<role>-<epoch>-<name>", so the
1572
+ // server sends the original name back as `name`.
1573
+ const shown = (meta && meta.name) || path.split('/').pop();
1574
+ chip.querySelector('.nm').textContent = shown;
1575
+ chip.title = `${homePath(path)}\n${size[0]}×${size[1]}`
1563
1576
  + (meta && meta.video ? ` · ${meta.frames} frames @ ${Math.round(meta.fps)}fps` : '');
1564
1577
  $('#sw'+n).style.background = `center/cover no-repeat url("${fileURL(thumb)}")`;
1565
1578
  $('#clear'+n).hidden = false;
@@ -1568,7 +1581,10 @@ function setChosen(role, path, size, el, meta){
1568
1581
  // screenshot to put inside it, which is what maybeStart() waits for.
1569
1582
  if (role==='photo'){ st.photo = path; st.corners = null;
1570
1583
  st.remembered = (meta && meta.remembered) || null; }
1571
- else { st.shot = path; st.video = meta && meta.video ? meta : null; syncVideoUI(); }
1584
+ else { st.shot = path; st.shotSize = size; st.video = meta && meta.video ? meta : null; syncVideoUI();
1585
+ // A detected quad is re-oriented for the new screenshot; a remembered or
1586
+ // hand-placed one is somebody's decision and is left alone.
1587
+ if (st.corners && st.quadFrom === 'detected' && orientQuad()){ draw(); drawStrip(); autoPreview(); } }
1572
1588
  closePop();
1573
1589
  maybeStart();
1574
1590
  }
@@ -1931,6 +1947,7 @@ async function pointAt(x, y){
1931
1947
  const r = await api('/api/detect', {click: [x, y]});
1932
1948
  if (r.found){
1933
1949
  st.corners = r.corners; st.measured = r.corner_radius; st.quadFrom = 'detected';
1950
+ orientQuad();
1934
1951
  s.className = 'status ok';
1935
1952
  s.textContent = `Screen found from your click (${r.method})`;
1936
1953
  if (!st.type) setType(r.type_guess || 'phone');
@@ -1961,6 +1978,7 @@ async function detect(){
1961
1978
  const r = await api('/api/detect', {});
1962
1979
  if (r.found){
1963
1980
  st.corners = r.corners; st.measured = r.corner_radius; st.quadFrom = 'detected';
1981
+ orientQuad();
1964
1982
  const corroborated = r.confidence === 'corroborated';
1965
1983
  s.className = 'status ' + (corroborated ? 'ok' : 'warn');
1966
1984
  s.textContent = corroborated ? `Both detectors agree (${r.method})`
@@ -2029,6 +2047,54 @@ function defaultQuad(){
2029
2047
  return [[w*.3,h*.2],[w*.7,h*.2],[w*.7,h*.8],[w*.3,h*.8]];
2030
2048
  }
2031
2049
  $('#redetect').onclick = detect;
2050
+ // Rotate: the same four points, the list started one place later. Corner 0 is
2051
+ // where the screenshot's top-left goes, so this turns the screenshot a quarter
2052
+ // turn clockwise inside an unchanged quad. Needed because detection orders
2053
+ // corners by where they sit in the PHOTO (nearest the image's top-left first),
2054
+ // and on a phone lying at 45° that is the screen's bottom-left — the quad was
2055
+ // right to 1% and the screenshot would have landed sideways (12 Sep 2026).
2056
+ // Dragging four corners to their neighbours' places was the only way.
2057
+ // The saved fit and the sidecar carry the corners in order, so a rotated fit
2058
+ // comes back rotated; nothing else changes.
2059
+ function turnQuad(){
2060
+ st.corners = [1, 2, 3, 0].map(i => st.corners[i].slice());
2061
+ if (pick && pick.kind === 'corner') pick = {kind: 'corner', i: (pick.i + 3) % 4};
2062
+ else if (pick && pick.kind === 'edge') pick = {kind: 'edge', i: (pick.i + 3) % 4};
2063
+ }
2064
+ function rotateQuad(){
2065
+ if (!st.corners) return;
2066
+ turnQuad();
2067
+ draw(); drawStrip(); autoPreview();
2068
+ }
2069
+ $('#rotatequad').onclick = rotateQuad;
2070
+ // The automatic half. Detection orders corners geometrically and cannot know
2071
+ // which edge is the top: that depends on the SCREENSHOT. A portrait screenshot
2072
+ // goes on the quad the long way, a landscape one the wide way. So once both
2073
+ // are known, if the quad's top edge is clearly the wrong kind for the
2074
+ // screenshot, turn the order until it is the right kind — the higher of the
2075
+ // two candidate top edges, so an upright photo is unchanged. A rule of this
2076
+ // shape was first put in detect.py's order_quad and was wrong for every
2077
+ // landscape screen; the aspect of the screenshot is the missing fact and only
2078
+ // this page has it. Near-square quads (under 1.25) are left alone — perspective
2079
+ // can make either kind of edge the longer one. Returns whether it turned.
2080
+ const ORIENT_ASPECT = 1.25;
2081
+ function orientQuad(){
2082
+ if (!st.corners || !st.shotSize) return false;
2083
+ const C = st.corners;
2084
+ const len = i => Math.hypot(C[(i+1)%4][0]-C[i][0], C[(i+1)%4][1]-C[i][1]);
2085
+ const a = (len(0) + len(2)) / 2, b = (len(1) + len(3)) / 2; // edges 0/2 vs 1/3
2086
+ if (Math.max(a, b) < ORIENT_ASPECT * Math.max(Math.min(a, b), 1e-6)) return false;
2087
+ const shotPortrait = st.shotSize[1] > st.shotSize[0];
2088
+ const topShouldBeShort = shotPortrait;
2089
+ const topIsShort = a < b;
2090
+ if (topIsShort === topShouldBeShort) return false;
2091
+ // Two candidate top edges (1 and 3, the other pair); the higher one in the
2092
+ // image is the top. Turn once to make edge 1 the top, twice more for edge 3.
2093
+ const midY = i => (C[i][1] + C[(i+1)%4][1]) / 2;
2094
+ const turns = midY(1) <= midY(3) ? 1 : 3;
2095
+ for (let k = 0; k < turns; k++) turnQuad();
2096
+ return true;
2097
+ }
2032
2098
  $('#resetquad').onclick = () => {
2033
2099
  if (!img.naturalWidth) return;
2034
2100
  st.corners = defaultQuad(); st.quadFrom = 'default';
@@ -2764,12 +2830,6 @@ function homePath(p){
2764
2830
  const home = st.home || '';
2765
2831
  return (home && p.startsWith(home)) ? '~' + p.slice(home.length) : p;
2766
2832
  }
2767
- function prettyDir(p){
2768
- // The short form, for places that cannot wrap: the last two segments.
2769
- const seg = homePath(p).split('/');
2770
- return seg.length > 4 ? '…/' + seg.slice(-2).join('/') : homePath(p);
2771
- }
2772
-
2773
2833
  let preset = 'web';
2774
2834
  document.querySelectorAll('[data-preset]').forEach(btn => {
2775
2835
  btn.onclick = () => {