screengraft 0.63.9 → 0.64.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/skills/inject-screenshot/SKILL.md +1 -1
- package/ui/index.html +42 -16
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "screengraft",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.64.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",
|
|
@@ -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
|
|
@@ -3084,11 +3105,16 @@ function paintGizmo(){
|
|
|
3084
3105
|
const show = dofOn && st.corners && st.shotSize && !im.hidden && im.naturalWidth && $('#liveWrap').hidden;
|
|
3085
3106
|
gz.toggleAttribute('hidden', !show);
|
|
3086
3107
|
if (!show) return;
|
|
3087
|
-
|
|
3108
|
+
// PHOTO pixels, not the preview's: the corners and every gizmo coordinate
|
|
3109
|
+
// are in the photograph's own pixels, and the preview is a resample of the
|
|
3110
|
+
// whole photograph at <=1600px wide. Sizing the viewBox to the preview put
|
|
3111
|
+
// the lines off the picture on any photo wider than 1600px (14 Sep 2026).
|
|
3112
|
+
const W = img.naturalWidth, Hh = img.naturalHeight;
|
|
3088
3113
|
gz.setAttribute('viewBox', `0 0 ${W} ${Hh}`);
|
|
3089
3114
|
gz.style.left = im.offsetLeft + 'px'; gz.style.top = im.offsetTop + 'px';
|
|
3090
3115
|
gz.style.width = im.clientWidth + 'px'; gz.style.height = im.clientHeight + 'px';
|
|
3091
3116
|
const k = W / Math.max(im.clientWidth, 1); // photo px per screen px
|
|
3117
|
+
gz.style.setProperty('--gk', k); // every stroke width in the CSS is screen px * this
|
|
3092
3118
|
const g = gizmoGeom(); if (!g.H || !g.Hi) return;
|
|
3093
3119
|
const P = (f, q) => applyH(g.H, gzPt(g, f, q));
|
|
3094
3120
|
const a0 = P(dofStart, -0.06), a1 = P(dofStart, 1.06), ac = P(dofStart, dofK);
|
|
@@ -3115,9 +3141,9 @@ function paintGizmo(){
|
|
|
3115
3141
|
const split = (p0, p1, h) => { const v = unit(sub(p1, p0)); return [[p0, sub(h, mul(v, brk))], [add(h, mul(v, brk)), p1]]; };
|
|
3116
3142
|
const LL = (p0, p1, h, cls, extra='') => split(p0, p1, h).map(([x, y]) => L(x, y, cls, extra)).join('');
|
|
3117
3143
|
gz.innerHTML =
|
|
3118
|
-
LL(a0, a1, ac, 'case'
|
|
3119
|
-
LL(b0, b1, bc, 'case',
|
|
3120
|
-
(two ? LL(c0, c1, cc, 'case',
|
|
3144
|
+
LL(a0, a1, ac, 'case') + LL(a0, a1, ac, 'focus') +
|
|
3145
|
+
LL(b0, b1, bc, 'case', dash(beyond)) + LL(b0, b1, bc, 'far' + (beyond ? ' out' : ''), dash(beyond)) +
|
|
3146
|
+
(two ? LL(c0, c1, cc, 'case', dash(beyond2)) + LL(c0, c1, cc, 'far' + (beyond2 ? ' out' : ''), dash(beyond2)) : '') +
|
|
3121
3147
|
hit(a0, 'rotA', 'pipHit') + `<circle cx="${a0[0]}" cy="${a0[1]}" r="${rp}" class="h pip" data-h="rotA"/>` +
|
|
3122
3148
|
hit(a1, 'rotB', 'pipHit') + `<circle cx="${a1[0]}" cy="${a1[1]}" r="${rp}" class="h pip" data-h="rotB"/>` +
|
|
3123
3149
|
ring(ac, 'start') + ring(bc, 'end') + (two ? ring(cc, 'end2') : '') +
|
|
@@ -3146,7 +3172,7 @@ function moveFocus(f){
|
|
|
3146
3172
|
// pointer position in SCREENSHOT pixels
|
|
3147
3173
|
function gizmoPos(e, g){
|
|
3148
3174
|
const rc = gz.getBoundingClientRect();
|
|
3149
|
-
const W =
|
|
3175
|
+
const W = img.naturalWidth, Hh = img.naturalHeight; // photo pixels, as above
|
|
3150
3176
|
const photo = [(e.clientX - rc.left) * W / rc.width, (e.clientY - rc.top) * Hh / rc.height];
|
|
3151
3177
|
return applyH(g.Hi, photo);
|
|
3152
3178
|
}
|