screengraft 0.40.0 → 0.41.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.40.0",
3
+ "version": "0.41.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/ui.py CHANGED
@@ -702,6 +702,30 @@ def _adopt(role, path):
702
702
  return {"path": real, "size": [im.shape[1], im.shape[0]], **meta}
703
703
 
704
704
 
705
+ def _clear(role):
706
+ """Un-choose a source. The mirror of _adopt(), and the only way to start
707
+ over: picking a different file was the one exit, and a reload restores the
708
+ session's fit, so "start again" was not reachable from the page at all.
709
+
710
+ What goes with it is decided by what the thing IS, not by convenience:
711
+ photo -> the corners. The quad is a property of the photograph. A
712
+ fit that was SAVED comes back on re-pick (fits.py), so
713
+ this is recoverable; an unsaved drag is not, and the page
714
+ says which.
715
+ screenshot -> the fitted frame. The corners stay: they are on the photo.
716
+ either -> the session output. A composite made from a source that
717
+ is no longer loaded is a stale artefact, and Send to
718
+ Claude must not be able to hand it over.
719
+ """
720
+ if role not in ROLES:
721
+ raise ValueError(f"role must be one of {', '.join(ROLES)}")
722
+ if role == "photo":
723
+ SESSION.update(photo=None, corners=None, output=None)
724
+ else:
725
+ SESSION.update(screenshot=None, fit_frame=0, output=None)
726
+ return {"cleared": role}
727
+
728
+
705
729
  def _guess_type(corners):
706
730
  c = np.array(corners, dtype=float)
707
731
  w = (np.linalg.norm(c[1] - c[0]) + np.linalg.norm(c[2] - c[3])) / 2
@@ -872,6 +896,9 @@ class Handler(BaseHTTPRequestHandler):
872
896
  if u.path == "/api/use":
873
897
  return self._json(_adopt(b["role"], b["path"]))
874
898
 
899
+ if u.path == "/api/clear":
900
+ return self._json(_clear(b.get("role")))
901
+
875
902
  if u.path == "/api/figma":
876
903
  return self._json(SESSION.enqueue({
877
904
  "type": "figma_export", "url": b["url"],
@@ -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.40):** 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.41):** 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
@@ -393,6 +393,17 @@
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
407
 
397
408
  /* Status carries meaning in THREE channels: glyph, weight, luminance —
398
409
  never hue alone. */
@@ -898,8 +909,10 @@
898
909
  <span class="brand">Screengraft</span><span id="sess" hidden></span>
899
910
  <div class="tb-chips">
900
911
  <button class="inputchip is-empty" id="chip1"><span class="sw" id="sw1"></span><span class="nm">Choose a photo…</span></button>
912
+ <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>
901
913
  <span class="tb-arrow">&rarr;</span>
902
914
  <button class="inputchip is-empty" id="chip2"><span class="sw" id="sw2"></span><span class="nm">Choose a screenshot…</span></button>
915
+ <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>
903
916
  </div>
904
917
  </div>
905
918
  <span class="spacer"></span>
@@ -1453,6 +1466,44 @@ async function loadRecent(){
1453
1466
  if (!items.length) g.innerHTML = '<span class="sm" style="color:var(--mute)">No recent images on Desktop/Downloads (last 14 days).</span>';
1454
1467
  }
1455
1468
  }
1469
+ /* Un-choose a source. The mirror of setChosen(): every visual the fill put up
1470
+ comes down, and the state the source owned goes with it -- the photo owns
1471
+ the corners, the screenshot owns the video state and the fitted frame,
1472
+ either owns the result. The server does the same to the session
1473
+ (/api/clear), so a reload agrees with what is on screen. */
1474
+ async function clearSource(role){
1475
+ try { await api('/api/clear', {role}); }
1476
+ catch(e){ alert(e.message); return; }
1477
+ const n = role==='photo'?1:2;
1478
+ const chip = $('#chip'+n);
1479
+ chip.classList.add('is-empty');
1480
+ chip.querySelector('.nm').textContent = role==='photo' ? 'Choose a photo…' : 'Choose a screenshot…';
1481
+ $('#sw'+n).style.background = '';
1482
+ $('#pv'+n).innerHTML = '<span class="empty">Select a recent image</span>';
1483
+ $('#clear'+n).hidden = true;
1484
+ document.querySelectorAll('#recent'+n+' .th').forEach(x=>x.classList.remove('sel'));
1485
+ stopLive();
1486
+ showResult('empty');
1487
+ if (role==='photo'){
1488
+ st.photo = null; st.corners = null; st.remembered = null; st.quadFrom = null;
1489
+ st.measured = null;
1490
+ // Nothing to draw on: the canvas goes back to its empty state rather
1491
+ // than showing the last photograph with no way to act on it.
1492
+ const c = $('#c'); c.getContext('2d').clearRect(0, 0, c.width, c.height);
1493
+ $('#cvbar').hidden = true;
1494
+ $('#detSt').className = 'status';
1495
+ $('#detSt').textContent = 'Photo removed. A fit you saved for it comes back when you pick it again.';
1496
+ } else {
1497
+ st.shot = null; st.video = null; syncVideoUI();
1498
+ $('#detSt').className = 'status';
1499
+ $('#detSt').textContent = st.photo
1500
+ ? 'Screenshot removed — the fit on the photo is kept. Choose another screenshot.'
1501
+ : 'Screenshot removed.';
1502
+ }
1503
+ markStale();
1504
+ }
1505
+ document.querySelectorAll('.chipclear').forEach(b => { b.onclick = () => clearSource(b.dataset.role); });
1506
+
1456
1507
  async function choose(role, path, el){
1457
1508
  try{ const r = await api('/api/use', {role, path}); setChosen(role, r.path, r.size, el, r); }
1458
1509
  catch(e){ alert(e.message); }
@@ -1469,6 +1520,7 @@ function setChosen(role, path, size, el, meta){
1469
1520
  chip.querySelector('.nm').textContent = `${path.split('/').pop()} · ${size[0]}×${size[1]}`
1470
1521
  + (meta && meta.video ? ` · ${meta.frames} frames @ ${Math.round(meta.fps)}fps` : '');
1471
1522
  $('#sw'+n).style.background = `center/cover no-repeat url("${fileURL(thumb)}")`;
1523
+ $('#clear'+n).hidden = false;
1472
1524
  // A fit the server recognised from this photograph's own pixels. Held
1473
1525
  // rather than applied here: the quad only means anything once there is a
1474
1526
  // screenshot to put inside it, which is what maybeStart() waits for.