screengraft 0.46.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 +1 -1
- package/scripts/ui.py +4 -1
- package/skills/inject-screenshot/SKILL.md +1 -1
- package/ui/index.html +95 -36
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "screengraft",
|
|
3
|
-
"version": "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",
|
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
|
-
|
|
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.
|
|
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
|
|
@@ -381,7 +390,7 @@
|
|
|
381
390
|
NOTE the modifier is `is-empty`, not `empty`: `.empty` is already the
|
|
382
391
|
centred placeholder-paragraph class (padding:24px), and the chip was
|
|
383
392
|
silently inheriting it and rendering 50px tall instead of 32. */
|
|
384
|
-
.inputchip{display:inline-flex;align-items:center;gap:var(--s2);max-width:
|
|
393
|
+
.inputchip{display:inline-flex;align-items:center;gap:var(--s2);max-width:220px;
|
|
385
394
|
height:32px;padding:0 12px 0 6px;border-radius:999px;
|
|
386
395
|
background:var(--raise);border:1px solid var(--edge);box-shadow:var(--hi)}
|
|
387
396
|
.inputchip .sw{width:22px;height:22px;border-radius:999px;flex:none;
|
|
@@ -393,17 +402,30 @@
|
|
|
393
402
|
.inputchip.is-empty{background:var(--card);border-style:dashed;
|
|
394
403
|
color:var(--mute);box-shadow:none}
|
|
395
404
|
.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:
|
|
397
|
-
<button>, and a button inside a button is invalid HTML
|
|
398
|
-
repair unpredictably.
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
.
|
|
405
|
+
/* The clear control is a SIBLING of the chip in the markup, not a child:
|
|
406
|
+
the chip is one <button>, and a button inside a button is invalid HTML
|
|
407
|
+
that browsers repair unpredictably. It is drawn INSIDE the pill though --
|
|
408
|
+
.chipwrap positions it over the chip's right end and the filled chip
|
|
409
|
+
reserves room for it -- because beside the chip it read as a stray
|
|
410
|
+
character and nobody found it ("place clear buttons inside of chip and
|
|
411
|
+
make it visible", 12 Sep 2026). Its own keyboard stop with its own name
|
|
412
|
+
("Remove photo"), distinct from the fit bar's Reset -- that resets the
|
|
413
|
+
QUAD; this un-chooses a SOURCE. Only present while the chip is filled. */
|
|
414
|
+
.chipwrap{position:relative;display:inline-flex;min-width:0}
|
|
415
|
+
.chipwrap .inputchip:not(.is-empty){padding-right:30px}
|
|
416
|
+
.chipclear{position:absolute;right:5px;top:50%;transform:translateY(-50%);
|
|
417
|
+
width:22px;height:22px;padding:0;border-radius:999px;flex:none;
|
|
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 × 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}
|
|
427
|
+
.chipclear:hover{background:var(--raise-highest);border-color:var(--edge-hi)}
|
|
428
|
+
.chipclear:active{background:var(--raise-mid);transform:translateY(-50%)}
|
|
407
429
|
|
|
408
430
|
/* Status carries meaning in THREE channels: glyph, weight, luminance —
|
|
409
431
|
never hue alone. */
|
|
@@ -922,11 +944,9 @@
|
|
|
922
944
|
<div class="tb-left">
|
|
923
945
|
<span class="brand">Screengraft</span><span id="sess" hidden></span>
|
|
924
946
|
<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>×</button>
|
|
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>
|
|
927
948
|
<span class="tb-arrow">→</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>×</button>
|
|
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>
|
|
930
950
|
</div>
|
|
931
951
|
</div>
|
|
932
952
|
<span class="spacer"></span>
|
|
@@ -941,7 +961,7 @@
|
|
|
941
961
|
the one-accent-on-screen rule is untouched. -->
|
|
942
962
|
<div class="tb-actions">
|
|
943
963
|
<span class="seg" id="fmt" role="radiogroup" aria-label="Output format" hidden>
|
|
944
|
-
<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>
|
|
945
965
|
</span>
|
|
946
966
|
<button id="save" class="accent" disabled>Preview first</button>
|
|
947
967
|
<button id="imp" class="accent" disabled title="Send the saved file to Claude so it appears in the chat">Send to Claude</button>
|
|
@@ -984,7 +1004,7 @@
|
|
|
984
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>
|
|
985
1005
|
<button class="sm" id="redetect">Re-detect</button>
|
|
986
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>
|
|
987
|
-
<button class="sm" id="rotatequad" title="Turn the screenshot
|
|
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>
|
|
988
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>
|
|
989
1009
|
</span>
|
|
990
1010
|
</div>
|
|
@@ -1512,12 +1532,12 @@ async function clearSource(role){
|
|
|
1512
1532
|
const chip = $('#chip'+n);
|
|
1513
1533
|
chip.classList.add('is-empty');
|
|
1514
1534
|
chip.querySelector('.nm').textContent = role==='photo' ? 'Choose a photo…' : 'Choose a screenshot…';
|
|
1535
|
+
chip.title = '';
|
|
1515
1536
|
$('#sw'+n).style.background = '';
|
|
1516
1537
|
$('#pv'+n).innerHTML = '<span class="empty">Select a recent image</span>';
|
|
1517
1538
|
$('#clear'+n).hidden = true;
|
|
1518
1539
|
document.querySelectorAll('#recent'+n+' .th').forEach(x=>x.classList.remove('sel'));
|
|
1519
|
-
|
|
1520
|
-
showResult('empty');
|
|
1540
|
+
dropResult();
|
|
1521
1541
|
if (role==='photo'){
|
|
1522
1542
|
st.photo = null; st.corners = null; st.remembered = null; st.quadFrom = null;
|
|
1523
1543
|
st.measured = null;
|
|
@@ -1539,13 +1559,27 @@ async function clearSource(role){
|
|
|
1539
1559
|
? 'Screenshot removed — the fit on the photo is kept. Choose another screenshot.'
|
|
1540
1560
|
: 'Screenshot removed.';
|
|
1541
1561
|
}
|
|
1542
|
-
markStale();
|
|
1543
1562
|
// The control that had focus has just hidden itself; without this, focus
|
|
1544
1563
|
// drops to <body> and a keyboard user is nowhere. The empty chip is the
|
|
1545
1564
|
// natural next action.
|
|
1546
1565
|
chip.focus();
|
|
1547
1566
|
}
|
|
1548
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
|
+
}
|
|
1549
1583
|
|
|
1550
1584
|
async function choose(role, path, el){
|
|
1551
1585
|
try{ const r = await api('/api/use', {role, path}); setChosen(role, r.path, r.size, el, r); }
|
|
@@ -1560,7 +1594,18 @@ function setChosen(role, path, size, el, meta){
|
|
|
1560
1594
|
$('#pv'+n).innerHTML = `<img src="${fileURL(thumb)}" alt="">`;
|
|
1561
1595
|
const chip = $('#chip'+n);
|
|
1562
1596
|
chip.classList.remove('is-empty');
|
|
1563
|
-
|
|
1597
|
+
// The file's own name and nothing else. The size and, for a clip, the frame
|
|
1598
|
+
// count used to follow it in the same 12px, which pushed the one thing that
|
|
1599
|
+
// identifies the file into an ellipsis; they live on hover now, with the
|
|
1600
|
+
// full path. An upload's session copy is "<role>-<epoch>-<name>", so the
|
|
1601
|
+
// server sends the original name back as `name`.
|
|
1602
|
+
const shown = (meta && meta.name) || path.split('/').pop();
|
|
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();
|
|
1608
|
+
chip.title = `${homePath(path)}\n${size[0]}×${size[1]}`
|
|
1564
1609
|
+ (meta && meta.video ? ` · ${meta.frames} frames @ ${Math.round(meta.fps)}fps` : '');
|
|
1565
1610
|
$('#sw'+n).style.background = `center/cover no-repeat url("${fileURL(thumb)}")`;
|
|
1566
1611
|
$('#clear'+n).hidden = false;
|
|
@@ -1748,7 +1793,7 @@ async function maybeStart(){
|
|
|
1748
1793
|
}
|
|
1749
1794
|
$('#cvbar').hidden = false;
|
|
1750
1795
|
if (st.corners){
|
|
1751
|
-
draw(); drawStrip();
|
|
1796
|
+
draw(); drawStrip(); autoPreview();
|
|
1752
1797
|
// A reload arrives here with the session's corners already in hand, and
|
|
1753
1798
|
// the only thing on the status line is the "now choose the screenshot"
|
|
1754
1799
|
// text from the photo-only step above — a message about a step already
|
|
@@ -2049,10 +2094,22 @@ function turnQuad(){
|
|
|
2049
2094
|
if (pick && pick.kind === 'corner') pick = {kind: 'corner', i: (pick.i + 3) % 4};
|
|
2050
2095
|
else if (pick && pick.kind === 'edge') pick = {kind: 'edge', i: (pick.i + 3) % 4};
|
|
2051
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.
|
|
2052
2105
|
function rotateQuad(){
|
|
2053
2106
|
if (!st.corners) return;
|
|
2054
2107
|
turnQuad();
|
|
2108
|
+
let turns = 1;
|
|
2109
|
+
if (!orientationFits()){ turnQuad(); turns = 2; }
|
|
2055
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.');
|
|
2056
2113
|
}
|
|
2057
2114
|
$('#rotatequad').onclick = rotateQuad;
|
|
2058
2115
|
// The automatic half. Detection orders corners geometrically and cannot know
|
|
@@ -2066,16 +2123,24 @@ $('#rotatequad').onclick = rotateQuad;
|
|
|
2066
2123
|
// this page has it. Near-square quads (under 1.25) are left alone — perspective
|
|
2067
2124
|
// can make either kind of edge the longer one. Returns whether it turned.
|
|
2068
2125
|
const ORIENT_ASPECT = 1.25;
|
|
2069
|
-
|
|
2070
|
-
|
|
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;
|
|
2071
2133
|
const C = st.corners;
|
|
2072
2134
|
const len = i => Math.hypot(C[(i+1)%4][0]-C[i][0], C[(i+1)%4][1]-C[i][1]);
|
|
2073
2135
|
const a = (len(0) + len(2)) / 2, b = (len(1) + len(3)) / 2; // edges 0/2 vs 1/3
|
|
2074
|
-
if (Math.max(a, b) < ORIENT_ASPECT * Math.max(Math.min(a, b), 1e-6)) return
|
|
2136
|
+
if (Math.max(a, b) < ORIENT_ASPECT * Math.max(Math.min(a, b), 1e-6)) return true;
|
|
2075
2137
|
const shotPortrait = st.shotSize[1] > st.shotSize[0];
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
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;
|
|
2079
2144
|
// Two candidate top edges (1 and 3, the other pair); the higher one in the
|
|
2080
2145
|
// image is the top. Turn once to make edge 1 the top, twice more for edge 3.
|
|
2081
2146
|
const midY = i => (C[i][1] + C[(i+1)%4][1]) / 2;
|
|
@@ -2818,12 +2883,6 @@ function homePath(p){
|
|
|
2818
2883
|
const home = st.home || '';
|
|
2819
2884
|
return (home && p.startsWith(home)) ? '~' + p.slice(home.length) : p;
|
|
2820
2885
|
}
|
|
2821
|
-
function prettyDir(p){
|
|
2822
|
-
// The short form, for places that cannot wrap: the last two segments.
|
|
2823
|
-
const seg = homePath(p).split('/');
|
|
2824
|
-
return seg.length > 4 ? '…/' + seg.slice(-2).join('/') : homePath(p);
|
|
2825
|
-
}
|
|
2826
|
-
|
|
2827
2886
|
let preset = 'web';
|
|
2828
2887
|
document.querySelectorAll('[data-preset]').forEach(btn => {
|
|
2829
2888
|
btn.onclick = () => {
|