screengraft 0.40.0 → 0.41.1

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.1",
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,41 @@ def _adopt(role, path):
702
702
  return {"path": real, "size": [im.shape[1], im.shape[0]], **meta}
703
703
 
704
704
 
705
+ class RenderBusy(Exception):
706
+ """A request that must not run while a render is in flight."""
707
+
708
+
709
+ def _clear(role):
710
+ """Un-choose a source. The mirror of _adopt(), and the only way to start
711
+ over: picking a different file was the one exit, and a reload restores the
712
+ session's fit, so "start again" was not reachable from the page at all.
713
+
714
+ What goes with it is decided by what the thing IS, not by convenience:
715
+ photo -> the corners. The quad is a property of the photograph. A
716
+ fit that was SAVED comes back on re-pick (fits.py), so
717
+ this is recoverable; an unsaved drag is not, and the page
718
+ says which.
719
+ screenshot -> the fitted frame. The corners stay: they are on the photo.
720
+ either -> the session output. A composite made from a source that
721
+ is no longer loaded is a stale artefact, and Send to
722
+ Claude must not be able to hand it over.
723
+ """
724
+ if role not in ROLES:
725
+ raise ValueError(f"role must be one of {', '.join(ROLES)}")
726
+ # A render in flight would publish its output and sidecar AFTER this
727
+ # cleared them, from a source that is no longer loaded -- the exact stale
728
+ # artefact this exists to prevent. Refused, the way the render route
729
+ # refuses a second render; the page shows the message.
730
+ with RENDER_LOCK:
731
+ if RENDER["state"] == "running":
732
+ raise RenderBusy("a render is running — wait for it, then remove the source")
733
+ if role == "photo":
734
+ SESSION.update(photo=None, corners=None, output=None)
735
+ else:
736
+ SESSION.update(screenshot=None, fit_frame=0, output=None)
737
+ return {"cleared": role}
738
+
739
+
705
740
  def _guess_type(corners):
706
741
  c = np.array(corners, dtype=float)
707
742
  w = (np.linalg.norm(c[1] - c[0]) + np.linalg.norm(c[2] - c[3])) / 2
@@ -872,6 +907,12 @@ class Handler(BaseHTTPRequestHandler):
872
907
  if u.path == "/api/use":
873
908
  return self._json(_adopt(b["role"], b["path"]))
874
909
 
910
+ if u.path == "/api/clear":
911
+ try:
912
+ return self._json(_clear(b.get("role")))
913
+ except RenderBusy as exc:
914
+ return self._json({"error": str(exc)}, 409)
915
+
875
916
  if u.path == "/api/figma":
876
917
  return self._json(SESSION.enqueue({
877
918
  "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,53 @@ 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
+ // ... and the edge view, which would otherwise keep showing a strip of a
1494
+ // photograph that is no longer here.
1495
+ for (const id of ['strip', 'stripF']) {
1496
+ const k = $('#' + id); if (k) k.getContext('2d').clearRect(0, 0, k.width, k.height);
1497
+ }
1498
+ $('#cvbar').hidden = true;
1499
+ $('#detSt').className = 'status';
1500
+ $('#detSt').textContent = 'Photo removed. A fit you saved for it comes back when you pick it again.';
1501
+ } else {
1502
+ st.shot = null; st.video = null; syncVideoUI();
1503
+ $('#detSt').className = 'status';
1504
+ $('#detSt').textContent = st.photo
1505
+ ? 'Screenshot removed — the fit on the photo is kept. Choose another screenshot.'
1506
+ : 'Screenshot removed.';
1507
+ }
1508
+ markStale();
1509
+ // The control that had focus has just hidden itself; without this, focus
1510
+ // drops to <body> and a keyboard user is nowhere. The empty chip is the
1511
+ // natural next action.
1512
+ chip.focus();
1513
+ }
1514
+ document.querySelectorAll('.chipclear').forEach(b => { b.onclick = () => clearSource(b.dataset.role); });
1515
+
1456
1516
  async function choose(role, path, el){
1457
1517
  try{ const r = await api('/api/use', {role, path}); setChosen(role, r.path, r.size, el, r); }
1458
1518
  catch(e){ alert(e.message); }
@@ -1469,6 +1529,7 @@ function setChosen(role, path, size, el, meta){
1469
1529
  chip.querySelector('.nm').textContent = `${path.split('/').pop()} · ${size[0]}×${size[1]}`
1470
1530
  + (meta && meta.video ? ` · ${meta.frames} frames @ ${Math.round(meta.fps)}fps` : '');
1471
1531
  $('#sw'+n).style.background = `center/cover no-repeat url("${fileURL(thumb)}")`;
1532
+ $('#clear'+n).hidden = false;
1472
1533
  // A fit the server recognised from this photograph's own pixels. Held
1473
1534
  // rather than applied here: the quad only means anything once there is a
1474
1535
  // screenshot to put inside it, which is what maybeStart() waits for.