screengraft 0.63.10 → 0.64.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/skills/inject-screenshot/SKILL.md +1 -1
- package/ui/index.html +58 -22
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "screengraft",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.64.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",
|
|
@@ -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.64):** 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** (the images you have used before, 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
|
@@ -813,12 +813,25 @@
|
|
|
813
813
|
/* Same rules as the fit overlay (v0.54.3): thin core, a light casing
|
|
814
814
|
underneath at 35% black, dashes 6/5. The casing is what survives a
|
|
815
815
|
photograph; the thinness is the request. */
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
816
|
+
/* The casing colour is one token so the canvas overlay and the SVG gizmo
|
|
817
|
+
agree. `html.lightcase` is an EXPERIMENT (14 Sep 2026): a white casing
|
|
818
|
+
instead of black, to be judged on real photographs. Flip it with
|
|
819
|
+
?case=light in the URL or the toggle in Overlay; nothing else changes. */
|
|
820
|
+
:root{--overlay-case:rgba(0,0,0,.35)}
|
|
821
|
+
html.lightcase{--overlay-case:rgba(255,255,255,.55)}
|
|
822
|
+
/* Every gizmo width is a SCREEN px times --gk (photo px per screen px, set
|
|
823
|
+
by paintGizmo on each zoom) -- not vector-effect:non-scaling-stroke. WebKit
|
|
824
|
+
applies non-scaling-stroke to the dash pattern as well as the width, so
|
|
825
|
+
dashes set in photo px came out scaled twice and grew with every zoom out
|
|
826
|
+
(reported 14 Sep 2026); Chrome scaled only the width. Doing the maths
|
|
827
|
+
ourselves makes the two agree, and matches the canvas overlay, which has
|
|
828
|
+
always drawn in screen px. Lines are 1px, casings +2px, as on the canvas. */
|
|
829
|
+
#dofGizmo{--gk:1}
|
|
830
|
+
#dofGizmo .case{stroke:var(--overlay-case);fill:none;stroke-width:calc(3px * var(--gk))}
|
|
831
|
+
#dofGizmo .focus{stroke:#f5623d;stroke-width:calc(1px * var(--gk));fill:none}
|
|
832
|
+
#dofGizmo .far{stroke:#f5623d;stroke-width:calc(1px * var(--gk));fill:none}
|
|
819
833
|
#dofGizmo .far.out{opacity:.75}
|
|
820
|
-
/* Dash lengths are set inline in photo px scaled by the zoom
|
|
821
|
-
are viewBox units; non-scaling-stroke does not cover them), so they read
|
|
834
|
+
/* Dash lengths are set inline in photo px scaled by the zoom, so they read
|
|
822
835
|
as 6/5 on screen at any zoom -- the fit overlay's GUIDE_DASH. */
|
|
823
836
|
/* pointer-events:ALL, not auto: the rings are hollow (v0.54.7), and an
|
|
824
837
|
unfilled SVG shape under `auto` is hit only on its stroke — the centre of
|
|
@@ -826,17 +839,17 @@
|
|
|
826
839
|
could not be grabbed at all on a steep fit (reported 14 Sep 2026). `all`
|
|
827
840
|
hits the interior whatever the fill. Each handle also carries a .hit disc,
|
|
828
841
|
a 10px-radius invisible target, because a 4px ring is not a grab area. */
|
|
829
|
-
#dofGizmo .h{fill:none;stroke:#f5623d;stroke-width:
|
|
842
|
+
#dofGizmo .h{fill:none;stroke:#f5623d;stroke-width:calc(2px * var(--gk));pointer-events:all;cursor:move}
|
|
830
843
|
#dofGizmo .hit{fill:transparent;stroke:none;pointer-events:all;cursor:move}
|
|
831
844
|
#dofGizmo .hit.pipHit{cursor:inherit}
|
|
832
845
|
#dofGizmo .hit[data-h="str"]{cursor:ew-resize}
|
|
833
|
-
#dofGizmo .hcase{fill:none;stroke:
|
|
846
|
+
#dofGizmo .hcase{fill:none;stroke:var(--overlay-case);stroke-width:calc(4px * var(--gk));pointer-events:none}
|
|
834
847
|
#dofGizmo .h.pip{cursor:url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='22' height='22' viewBox='0 0 22 22'%3E%3Cpath d='M17.5 11a6.5 6.5 0 1 1-2.2-4.9' fill='none' stroke='rgba(0,0,0,.85)' stroke-width='4.2' stroke-linecap='round'/%3E%3Cpath d='M17.5 11a6.5 6.5 0 1 1-2.2-4.9' fill='none' stroke='%23fff' stroke-width='2' stroke-linecap='round'/%3E%3Cpath d='M15.6 2.6l0.4 4.2-4.2 0.3' fill='none' stroke='rgba(0,0,0,.85)' stroke-width='4.2' stroke-linecap='round' stroke-linejoin='round'/%3E%3Cpath d='M15.6 2.6l0.4 4.2-4.2 0.3' fill='none' stroke='%23fff' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") 11 11, crosshair}
|
|
835
848
|
#dofGizmo .h.thumb{cursor:ew-resize}
|
|
836
|
-
#dofGizmo .h.pip{fill:#f5623d;stroke:#fff;stroke-width:1.
|
|
837
|
-
#dofGizmo .h.thumb{fill:#fff;stroke:#f5623d;stroke-width:
|
|
849
|
+
#dofGizmo .h.pip{fill:#f5623d;stroke:#fff;stroke-width:calc(1.2px * var(--gk))}
|
|
850
|
+
#dofGizmo .h.thumb{fill:#fff;stroke:#f5623d;stroke-width:calc(2px * var(--gk))}
|
|
838
851
|
#dofGizmo .h:active{cursor:grabbing}
|
|
839
|
-
#dofGizmo text{font:600 11px system-ui,-apple-system,sans-serif;fill:#fff;paint-order:stroke;stroke:rgba(0,0,0,.75);stroke-width:3px;stroke-linejoin:round;pointer-events:none}
|
|
852
|
+
#dofGizmo text{font:600 11px system-ui,-apple-system,sans-serif;fill:#fff;paint-order:stroke;stroke:rgba(0,0,0,.75);stroke-width:calc(3px * var(--gk));stroke-linejoin:round;pointer-events:none}
|
|
840
853
|
#outImg,#outVid{display:block;margin:auto}
|
|
841
854
|
/* The live layer stacks the clip on the photo. `position:relative` on the
|
|
842
855
|
wrapper and `transform-origin:0 0` on the video are what make the matrix3d
|
|
@@ -1705,7 +1718,15 @@ const recall = (k,d) => { try{ const v = localStorage.getItem('sg.'+k); return v
|
|
|
1705
1718
|
The dark casing underneath is NOT optional and stays: flat accent measures
|
|
1706
1719
|
~1.06:1 against mid-tone photography, which is the failure the casing was
|
|
1707
1720
|
introduced to fix. CORE_IDLE stays white for the strip's idle rule. */
|
|
1708
|
-
|
|
1721
|
+
let CASE_A = 'rgba(0,0,0,.35)'; const CORE_IDLE = 'rgba(255,255,255,.92)';
|
|
1722
|
+
// Casing follows the --overlay-case token (see the CSS), so the light-casing
|
|
1723
|
+
// experiment reaches the canvas overlay too. Read once per toggle, not per draw.
|
|
1724
|
+
function setLightCase(on){
|
|
1725
|
+
document.documentElement.classList.toggle('lightcase', !!on);
|
|
1726
|
+
CASE_A = getComputedStyle(document.documentElement).getPropertyValue('--overlay-case').trim() || CASE_A;
|
|
1727
|
+
try { if (img.naturalWidth) draw(); } catch (e) {} // before the image exists there is nothing to redraw
|
|
1728
|
+
}
|
|
1729
|
+
addEventListener('DOMContentLoaded', () => { if (new URLSearchParams(location.search).get('case') === 'light') setLightCase(true); });
|
|
1709
1730
|
const CORE_ACTIVE = '#f5623d';
|
|
1710
1731
|
const CORE_QUAD = '#f23b0d';
|
|
1711
1732
|
// Line weights for the fit overlay, in one place. Thinned 13 Sep 2026 on
|
|
@@ -1726,7 +1747,10 @@ const GUIDE_DASH = [6, 5];
|
|
|
1726
1747
|
function cased(c, path, coreW, coreColor, dash){
|
|
1727
1748
|
c.save();
|
|
1728
1749
|
if (dash) c.setLineDash(dash);
|
|
1729
|
-
|
|
1750
|
+
// Butt caps on a dashed stroke: a round cap grows every dash by the line
|
|
1751
|
+
// width at both ends, so the 6/5 guide read as ~9/2 under its 2.9px casing
|
|
1752
|
+
// while the gizmo (SVG, butt caps) showed a true 6/5 (14 Sep 2026).
|
|
1753
|
+
c.lineCap = dash ? 'butt' : 'round'; c.lineJoin = 'round';
|
|
1730
1754
|
c.strokeStyle = CASE_A; c.lineWidth = coreW + CASE_PAD; path(); c.stroke();
|
|
1731
1755
|
c.strokeStyle = coreColor; c.lineWidth = coreW; path(); c.stroke();
|
|
1732
1756
|
c.restore();
|
|
@@ -2127,6 +2151,7 @@ $('#figmaBtn').onclick = async () => {
|
|
|
2127
2151
|
with the two UNCHANGED adjacent lines. Corner handles stay for edges that
|
|
2128
2152
|
run off-frame or sit behind an occluder. */
|
|
2129
2153
|
const cv = $('#c'), ctx = cv.getContext('2d');
|
|
2154
|
+
let cvW = 0, cvH = 0, cvDpr = 1; // the canvas's CSS size and the device-pixel factor of its store
|
|
2130
2155
|
const strip = $('#strip'), sctx = strip.getContext('2d');
|
|
2131
2156
|
const stripF = $('#stripF'), fctx = stripF.getContext('2d');
|
|
2132
2157
|
const img = new Image();
|
|
@@ -2202,8 +2227,17 @@ function computeFit(){
|
|
|
2202
2227
|
function setCanvasZoom(z, focus){
|
|
2203
2228
|
const prev = scale;
|
|
2204
2229
|
scale = Math.max(0.05, Math.min(4, z));
|
|
2205
|
-
|
|
2206
|
-
|
|
2230
|
+
// The backing store is sized in DEVICE pixels and the element in CSS pixels,
|
|
2231
|
+
// so on a Retina display the photo and the overlay are drawn at 2x and the
|
|
2232
|
+
// overlay's 1px lines are as sharp as the SVG gizmo's in the result pane.
|
|
2233
|
+
// Until 14 Sep 2026 the store was CSS-sized and the browser upsampled it --
|
|
2234
|
+
// every line in the fit pane was softer than the same line one pane over.
|
|
2235
|
+
// draw() undoes the factor with setTransform, so everything below still
|
|
2236
|
+
// thinks in CSS px: cvW/cvH are the CSS size, cv.width/height the store.
|
|
2237
|
+
cvW = Math.round(img.naturalWidth * scale); cvH = Math.round(img.naturalHeight * scale);
|
|
2238
|
+
cvDpr = Math.max(1, window.devicePixelRatio || 1);
|
|
2239
|
+
cv.width = Math.round(cvW * cvDpr); cv.height = Math.round(cvH * cvDpr);
|
|
2240
|
+
cv.style.width = cvW + 'px'; cv.style.height = cvH + 'px';
|
|
2207
2241
|
$('#czSt').textContent = Math.round(scale*100) + '%';
|
|
2208
2242
|
draw();
|
|
2209
2243
|
const sc = $('#scroller');
|
|
@@ -2243,8 +2277,8 @@ function zoomAt(z, clientX, clientY){
|
|
|
2243
2277
|
const before = pos({clientX, clientY});
|
|
2244
2278
|
setCanvasZoom(z);
|
|
2245
2279
|
const sc = $('#scroller'), r = cv.getBoundingClientRect();
|
|
2246
|
-
sc.scrollLeft += r.left + before[0] * scale * (r.width /
|
|
2247
|
-
sc.scrollTop += r.top + before[1] * scale * (r.height /
|
|
2280
|
+
sc.scrollLeft += r.left + before[0] * scale * (r.width / cvW) - clientX;
|
|
2281
|
+
sc.scrollTop += r.top + before[1] * scale * (r.height / cvH) - clientY;
|
|
2248
2282
|
}
|
|
2249
2283
|
|
|
2250
2284
|
/* A trackpad pinch arrives as a wheel event with ctrlKey synthesised true, and
|
|
@@ -2640,9 +2674,10 @@ function draw(){
|
|
|
2640
2674
|
// fit, so leaving it on the old corners would make the two panes disagree
|
|
2641
2675
|
// mid-drag — which is the one thing the side-by-side exists to prevent.
|
|
2642
2676
|
if (!$('#liveWrap').hidden) placeLiveVideo();
|
|
2643
|
-
ctx.
|
|
2677
|
+
ctx.setTransform(cvDpr, 0, 0, cvDpr, 0, 0); // CSS px from here on; the store is cvDpr times larger
|
|
2678
|
+
ctx.clearRect(0,0,cvW,cvH);
|
|
2644
2679
|
if (!img.naturalWidth) return;
|
|
2645
|
-
ctx.drawImage(img, 0,0,
|
|
2680
|
+
ctx.drawImage(img, 0,0, cvW, cvH);
|
|
2646
2681
|
if (!st.corners) return;
|
|
2647
2682
|
const P = st.corners.map(([x,y]) => [x*scale, y*scale]);
|
|
2648
2683
|
const active = drag || hover || pick;
|
|
@@ -2721,7 +2756,7 @@ function draw(){
|
|
|
2721
2756
|
|
|
2722
2757
|
function pos(e){
|
|
2723
2758
|
const r = cv.getBoundingClientRect();
|
|
2724
|
-
return [(e.clientX-r.left)*
|
|
2759
|
+
return [(e.clientX-r.left)*cvW/r.width/scale, (e.clientY-r.top)*cvH/r.height/scale];
|
|
2725
2760
|
}
|
|
2726
2761
|
function hitTest(p){
|
|
2727
2762
|
if (!st.corners) return null;
|
|
@@ -3093,6 +3128,7 @@ function paintGizmo(){
|
|
|
3093
3128
|
gz.style.left = im.offsetLeft + 'px'; gz.style.top = im.offsetTop + 'px';
|
|
3094
3129
|
gz.style.width = im.clientWidth + 'px'; gz.style.height = im.clientHeight + 'px';
|
|
3095
3130
|
const k = W / Math.max(im.clientWidth, 1); // photo px per screen px
|
|
3131
|
+
gz.style.setProperty('--gk', k); // every stroke width in the CSS is screen px * this
|
|
3096
3132
|
const g = gizmoGeom(); if (!g.H || !g.Hi) return;
|
|
3097
3133
|
const P = (f, q) => applyH(g.H, gzPt(g, f, q));
|
|
3098
3134
|
const a0 = P(dofStart, -0.06), a1 = P(dofStart, 1.06), ac = P(dofStart, dofK);
|
|
@@ -3119,9 +3155,9 @@ function paintGizmo(){
|
|
|
3119
3155
|
const split = (p0, p1, h) => { const v = unit(sub(p1, p0)); return [[p0, sub(h, mul(v, brk))], [add(h, mul(v, brk)), p1]]; };
|
|
3120
3156
|
const LL = (p0, p1, h, cls, extra='') => split(p0, p1, h).map(([x, y]) => L(x, y, cls, extra)).join('');
|
|
3121
3157
|
gz.innerHTML =
|
|
3122
|
-
LL(a0, a1, ac, 'case'
|
|
3123
|
-
LL(b0, b1, bc, 'case',
|
|
3124
|
-
(two ? LL(c0, c1, cc, 'case',
|
|
3158
|
+
LL(a0, a1, ac, 'case') + LL(a0, a1, ac, 'focus') +
|
|
3159
|
+
LL(b0, b1, bc, 'case', dash(beyond)) + LL(b0, b1, bc, 'far' + (beyond ? ' out' : ''), dash(beyond)) +
|
|
3160
|
+
(two ? LL(c0, c1, cc, 'case', dash(beyond2)) + LL(c0, c1, cc, 'far' + (beyond2 ? ' out' : ''), dash(beyond2)) : '') +
|
|
3125
3161
|
hit(a0, 'rotA', 'pipHit') + `<circle cx="${a0[0]}" cy="${a0[1]}" r="${rp}" class="h pip" data-h="rotA"/>` +
|
|
3126
3162
|
hit(a1, 'rotB', 'pipHit') + `<circle cx="${a1[0]}" cy="${a1[1]}" r="${rp}" class="h pip" data-h="rotB"/>` +
|
|
3127
3163
|
ring(ac, 'start') + ring(bc, 'end') + (two ? ring(cc, 'end2') : '') +
|