screengraft 0.47.0 → 0.48.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.47.0",
3
+ "version": "0.48.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",
@@ -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.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.
8
+ **What ships (v0.48):** 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
@@ -366,7 +366,16 @@
366
366
  until it is selected: no border, no shadow, no lift on press. */
367
367
  .seg button{height:22px;padding:0 11px;font-size:12px;border-radius:var(--r-sm);
368
368
  background:transparent;border:0;box-shadow:none;color:var(--mute);
369
- font-weight:400}
369
+ font-weight:400;display:inline-flex;flex-direction:column;
370
+ align-items:center;justify-content:center}
371
+ /* Each segment is as wide as its label at the SELECTED weight, whichever
372
+ weight it is showing. Without this the control reflowed on every switch:
373
+ 600 is wider than 400, so selecting "ProRes" grew that segment, and the
374
+ whole control — left edge included — moved (reported 12 Sep 2026).
375
+ A zero-height ghost of the label at 600 sits under the visible one and
376
+ sets the width; `data-label` carries the text. Not padding. */
377
+ .seg button::after{content:attr(data-label);font-weight:600;height:0;
378
+ overflow:hidden;visibility:hidden;pointer-events:none}
370
379
  .seg button:hover{background:transparent;color:var(--ink)}
371
380
  .seg button:active{background:transparent;transform:none}
372
381
  /* The thumb. Its three surfaces are the Default button's own, which is the
@@ -406,8 +415,15 @@
406
415
  .chipwrap .inputchip:not(.is-empty){padding-right:30px}
407
416
  .chipclear{position:absolute;right:5px;top:50%;transform:translateY(-50%);
408
417
  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)}
418
+ display:inline-flex;align-items:center;justify-content:center;
419
+ color:var(--ink);background:var(--raise-hi);border:1px solid var(--edge)}
420
+ /* The cross is drawn, not typed. As the &times; character at 15px it sat
421
+ above centre in its disc -- a glyph carries its own ascent and side
422
+ bearings and no line-height setting centres it in a 22px circle ("X icon
423
+ is not fully centered", 12 Sep 2026). A 10px SVG in a flex
424
+ centre is centred by construction, and it is the same route the toast
425
+ glyphs took in v0.42.0. */
426
+ .chipclear svg{display:block;width:10px;height:10px}
411
427
  .chipclear:hover{background:var(--raise-highest);border-color:var(--edge-hi)}
412
428
  .chipclear:active{background:var(--raise-mid);transform:translateY(-50%)}
413
429
 
@@ -928,9 +944,9 @@
928
944
  <div class="tb-left">
929
945
  <span class="brand">Screengraft</span><span id="sess" hidden></span>
930
946
  <div class="tb-chips">
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>
947
+ <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><svg aria-hidden="true" viewBox="0 0 10 10"><path d="M1.5 1.5l7 7M8.5 1.5l-7 7" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></button></span>
932
948
  <span class="tb-arrow">&rarr;</span>
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>
949
+ <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><svg aria-hidden="true" viewBox="0 0 10 10"><path d="M1.5 1.5l7 7M8.5 1.5l-7 7" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></button></span>
934
950
  </div>
935
951
  </div>
936
952
  <span class="spacer"></span>
@@ -945,7 +961,7 @@
945
961
  the one-accent-on-screen rule is untouched. -->
946
962
  <div class="tb-actions">
947
963
  <span class="seg" id="fmt" role="radiogroup" aria-label="Output format" hidden>
948
- <button class="sel" data-preset="web" role="radio" aria-checked="true" title="H.264 at CRF 16 — near-visually-lossless, plays anywhere.">Web</button><button data-preset="prores" role="radio" aria-checked="false" title="ProRes 422 HQ — bigger file, no chroma subsampling. For a case study or further editing.">ProRes</button>
964
+ <button class="sel" data-preset="web" data-label="Web" role="radio" aria-checked="true" title="H.264 at CRF 16 — near-visually-lossless, plays anywhere.">Web</button><button data-preset="prores" data-label="ProRes" role="radio" aria-checked="false" title="ProRes 422 HQ — bigger file, no chroma subsampling. For a case study or further editing.">ProRes</button>
949
965
  </span>
950
966
  <button id="save" class="accent" disabled>Preview first</button>
951
967
  <button id="imp" class="accent" disabled title="Send the saved file to Claude so it appears in the chat">Send to Claude</button>
@@ -988,7 +1004,7 @@
988
1004
  <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>
989
1005
  <button class="sm" id="redetect">Re-detect</button>
990
1006
  <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>
1007
+ <button class="sm" id="rotatequad" title="Turn the screenshot inside the same four edges. The edges stay where they are; only which one is the top changes. A portrait screenshot fits a phone only two ways, so on a phone this is a half turn; a quarter turn where the screen is near-square or the screenshot fits the other way.">Rotate</button>
992
1008
  <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>
993
1009
  </span>
994
1010
  </div>
@@ -1521,8 +1537,7 @@ async function clearSource(role){
1521
1537
  $('#pv'+n).innerHTML = '<span class="empty">Select a recent image</span>';
1522
1538
  $('#clear'+n).hidden = true;
1523
1539
  document.querySelectorAll('#recent'+n+' .th').forEach(x=>x.classList.remove('sel'));
1524
- stopLive();
1525
- showResult('empty');
1540
+ dropResult();
1526
1541
  if (role==='photo'){
1527
1542
  st.photo = null; st.corners = null; st.remembered = null; st.quadFrom = null;
1528
1543
  st.measured = null;
@@ -1544,13 +1559,27 @@ async function clearSource(role){
1544
1559
  ? 'Screenshot removed — the fit on the photo is kept. Choose another screenshot.'
1545
1560
  : 'Screenshot removed.';
1546
1561
  }
1547
- markStale();
1548
1562
  // The control that had focus has just hidden itself; without this, focus
1549
1563
  // drops to <body> and a keyboard user is nowhere. The empty chip is the
1550
1564
  // natural next action.
1551
1565
  chip.focus();
1552
1566
  }
1553
1567
  document.querySelectorAll('.chipclear').forEach(b => { b.onclick = () => clearSource(b.dataset.role); });
1568
+ /* A source has changed, so whatever the Result pane shows was made from
1569
+ something that is no longer chosen. One place for the three things that
1570
+ must happen together: stop the live clip (its <video> still holds the OLD
1571
+ file and would keep playing it), empty the pane, and un-light Save. Before
1572
+ this, swapping a clip for a screenshot — or one photo for another with a
1573
+ fit already in hand — left the previous composite on screen, and the live
1574
+ layer kept the previous clip running, because setChosen() only redrew the
1575
+ canvas and renderPreview() will not take the pane away from a playing clip
1576
+ (reported 12 Sep 2026: "the preview is not being updated"). */
1577
+ function dropResult(){
1578
+ stopLive();
1579
+ showResult('empty');
1580
+ $('#outSt').textContent = '';
1581
+ markStale();
1582
+ }
1554
1583
 
1555
1584
  async function choose(role, path, el){
1556
1585
  try{ const r = await api('/api/use', {role, path}); setChosen(role, r.path, r.size, el, r); }
@@ -1572,6 +1601,10 @@ function setChosen(role, path, size, el, meta){
1572
1601
  // server sends the original name back as `name`.
1573
1602
  const shown = (meta && meta.name) || path.split('/').pop();
1574
1603
  chip.querySelector('.nm').textContent = shown;
1604
+ // A different file in either role: the composite on screen is of the old
1605
+ // one. Dropped here, before the state changes, so nothing downstream can
1606
+ // narrate over it; maybeStart() re-renders once the fit is ready.
1607
+ if ((role==='photo' ? st.photo : st.shot) !== path) dropResult();
1575
1608
  chip.title = `${homePath(path)}\n${size[0]}×${size[1]}`
1576
1609
  + (meta && meta.video ? ` · ${meta.frames} frames @ ${Math.round(meta.fps)}fps` : '');
1577
1610
  $('#sw'+n).style.background = `center/cover no-repeat url("${fileURL(thumb)}")`;
@@ -1760,7 +1793,7 @@ async function maybeStart(){
1760
1793
  }
1761
1794
  $('#cvbar').hidden = false;
1762
1795
  if (st.corners){
1763
- draw(); drawStrip();
1796
+ draw(); drawStrip(); autoPreview();
1764
1797
  // A reload arrives here with the session's corners already in hand, and
1765
1798
  // the only thing on the status line is the "now choose the screenshot"
1766
1799
  // text from the photo-only step above — a message about a step already
@@ -2061,10 +2094,22 @@ function turnQuad(){
2061
2094
  if (pick && pick.kind === 'corner') pick = {kind: 'corner', i: (pick.i + 3) % 4};
2062
2095
  else if (pick && pick.kind === 'edge') pick = {kind: 'edge', i: (pick.i + 3) % 4};
2063
2096
  }
2097
+ // A quarter turn is only offered where the screenshot still FITS. A portrait
2098
+ // screenshot on a phone fits two ways, top on either short edge; turned onto a
2099
+ // long edge it is mapped anyway — 804px across a 890px edge and 1748px down a
2100
+ // 441px one — and the 4:1 squash also flattens the corner rounding into an
2101
+ // ellipse, which is what "the corners rotate too and no longer fit the frame"
2102
+ // looked like (reported 12 Sep 2026). So Rotate steps to the NEXT
2103
+ // orientation the screenshot fits: a half turn on a phone, a quarter turn on a
2104
+ // near-square screen or when the screenshot's aspect suits the other edges.
2064
2105
  function rotateQuad(){
2065
2106
  if (!st.corners) return;
2066
2107
  turnQuad();
2108
+ let turns = 1;
2109
+ if (!orientationFits()){ turnQuad(); turns = 2; }
2067
2110
  draw(); drawStrip(); autoPreview();
2111
+ toast('info', turns === 1 ? 'Turned the screenshot a quarter turn.'
2112
+ : 'Turned the screenshot a half turn — a portrait screenshot fits this screen only two ways.');
2068
2113
  }
2069
2114
  $('#rotatequad').onclick = rotateQuad;
2070
2115
  // The automatic half. Detection orders corners geometrically and cannot know
@@ -2078,16 +2123,24 @@ $('#rotatequad').onclick = rotateQuad;
2078
2123
  // this page has it. Near-square quads (under 1.25) are left alone — perspective
2079
2124
  // can make either kind of edge the longer one. Returns whether it turned.
2080
2125
  const ORIENT_ASPECT = 1.25;
2081
- function orientQuad(){
2082
- if (!st.corners || !st.shotSize) return false;
2126
+ // Does the screenshot fit the quad the way it is ordered now? True when the
2127
+ // quad is near-square (either kind of edge can be the top), when there is no
2128
+ // screenshot yet, or when the top edge is the kind the screenshot's aspect
2129
+ // wants — short for portrait, long for landscape. Shared by the automatic
2130
+ // orientation below and by the Rotate button.
2131
+ function orientationFits(){
2132
+ if (!st.corners || !st.shotSize) return true;
2083
2133
  const C = st.corners;
2084
2134
  const len = i => Math.hypot(C[(i+1)%4][0]-C[i][0], C[(i+1)%4][1]-C[i][1]);
2085
2135
  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;
2136
+ if (Math.max(a, b) < ORIENT_ASPECT * Math.max(Math.min(a, b), 1e-6)) return true;
2087
2137
  const shotPortrait = st.shotSize[1] > st.shotSize[0];
2088
- const topShouldBeShort = shotPortrait;
2089
- const topIsShort = a < b;
2090
- if (topIsShort === topShouldBeShort) return false;
2138
+ return (a < b) === shotPortrait;
2139
+ }
2140
+ function orientQuad(){
2141
+ if (!st.corners || !st.shotSize) return false;
2142
+ if (orientationFits()) return false;
2143
+ const C = st.corners;
2091
2144
  // Two candidate top edges (1 and 3, the other pair); the higher one in the
2092
2145
  // image is the top. Turn once to make edge 1 the top, twice more for edge 3.
2093
2146
  const midY = i => (C[i][1] + C[(i+1)%4][1]) / 2;