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 +1 -1
- package/scripts/ui.py +41 -0
- package/skills/inject-screenshot/SKILL.md +1 -1
- package/ui/index.html +61 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "screengraft",
|
|
3
|
-
"version": "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.
|
|
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>×</button>
|
|
901
913
|
<span class="tb-arrow">→</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>×</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.
|