screengraft 0.58.0 → 0.59.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/dof.py +4 -1
- package/scripts/ui.py +2 -1
- package/skills/inject-screenshot/SKILL.md +1 -1
- package/ui/index.html +24 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "screengraft",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.59.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/dof.py
CHANGED
|
@@ -107,7 +107,10 @@ def screen_ramp(src_w: int, src_h: int, angle_deg: float, start: float, end: flo
|
|
|
107
107
|
q = np.array([[0, 0], [src_w, 0], [src_w, src_h], [0, src_h]], dtype=np.float64)
|
|
108
108
|
proj = q @ d
|
|
109
109
|
lo, hi = float(proj.min()), float(proj.max())
|
|
110
|
-
start
|
|
110
|
+
# start may be negative: the plane of focus in FRONT of the screen, so the
|
|
111
|
+
# near edge is already part-way up the ramp. Same reach past the screen as
|
|
112
|
+
# `end` has on the far side.
|
|
113
|
+
start = float(np.clip(start, -0.5, 0.95))
|
|
111
114
|
end = float(np.clip(end, start + 0.05, 1.5))
|
|
112
115
|
s0, s1 = lo + start * (hi - lo), lo + end * (hi - lo)
|
|
113
116
|
xs = np.arange(src_w, dtype=np.float64)[None, :] + 0.5
|
package/scripts/ui.py
CHANGED
|
@@ -711,7 +711,8 @@ def _dof_args(b):
|
|
|
711
711
|
except (TypeError, ValueError):
|
|
712
712
|
strength, angle, start, end = 0.0, 0.0, 0.0, 1.0
|
|
713
713
|
space = "screen" if b.get("dof_space") == "screen" else "photo"
|
|
714
|
-
|
|
714
|
+
# -0.5 lets the plane of focus sit in front of the glass (14 Sep 2026).
|
|
715
|
+
start = float(min(max(start, -0.5), 0.95))
|
|
715
716
|
# The near limit is optional: absent or null means one-sided.
|
|
716
717
|
end2 = b.get("dof_end2")
|
|
717
718
|
try:
|
|
@@ -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.59):** 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
|
@@ -715,7 +715,16 @@
|
|
|
715
715
|
/* Dash lengths are set inline in photo px scaled by the zoom (SVG dashes
|
|
716
716
|
are viewBox units; non-scaling-stroke does not cover them), so they read
|
|
717
717
|
as 6/5 on screen at any zoom -- the fit overlay's GUIDE_DASH. */
|
|
718
|
-
|
|
718
|
+
/* pointer-events:ALL, not auto: the rings are hollow (v0.54.7), and an
|
|
719
|
+
unfilled SVG shape under `auto` is hit only on its stroke — the centre of
|
|
720
|
+
every handle passed the pointer through to the image, so the sharp line
|
|
721
|
+
could not be grabbed at all on a steep fit (reported 14 Sep 2026). `all`
|
|
722
|
+
hits the interior whatever the fill. Each handle also carries a .hit disc,
|
|
723
|
+
a 10px-radius invisible target, because a 4px ring is not a grab area. */
|
|
724
|
+
#dofGizmo .h{fill:none;stroke:#f5623d;stroke-width:2;vector-effect:non-scaling-stroke;pointer-events:all;cursor:move}
|
|
725
|
+
#dofGizmo .hit{fill:transparent;stroke:none;pointer-events:all;cursor:move}
|
|
726
|
+
#dofGizmo .hit.pipHit{cursor:inherit}
|
|
727
|
+
#dofGizmo .hit[data-h="str"]{cursor:ew-resize}
|
|
719
728
|
#dofGizmo .hcase{fill:none;stroke:rgba(0,0,0,.35);stroke-width:4;vector-effect:non-scaling-stroke;pointer-events:none}
|
|
720
729
|
#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}
|
|
721
730
|
#dofGizmo .h.thumb{cursor:ew-resize}
|
|
@@ -2697,7 +2706,7 @@ $('#emisAmt').onchange = e => { setEmis(true, parseFloat(e.target.value)); autoP
|
|
|
2697
2706
|
let dofOn = recall('dof','0') === '1';
|
|
2698
2707
|
let dofAng = parseFloat(recall('dofAng','90')) || 0;
|
|
2699
2708
|
let dofStr = parseFloat(recall('dofStr','0.3')); if (!(dofStr >= 0)) dofStr = 0.3;
|
|
2700
|
-
let dofStart = parseFloat(recall('dofStart','0')); if (!(dofStart >= 0)) dofStart = 0;
|
|
2709
|
+
let dofStart = parseFloat(recall('dofStart','0')); if (!(dofStart >= -0.5)) dofStart = 0;
|
|
2701
2710
|
function dofAngle(){ return dofOn ? dofAng : 0; }
|
|
2702
2711
|
function dofStrength(){ return dofOn ? dofStr : 0; }
|
|
2703
2712
|
function dofStartV(){ return dofOn ? dofStart : 0; }
|
|
@@ -2843,7 +2852,9 @@ function paintGizmo(){
|
|
|
2843
2852
|
const th = P(dofEnd, 0.08 + 0.84 * dofStr); // the strength thumb slides along the dashed line
|
|
2844
2853
|
const sigma = dofStr * SIGMA_FULL_FRAC * g.side;
|
|
2845
2854
|
const r = 4 * k, rp = 3.5 * k, fs11 = 11 * k, off = 14 * k;
|
|
2846
|
-
const
|
|
2855
|
+
const rh = 10 * k; // hit radius, 10 screen px
|
|
2856
|
+
const hit = (p, h, cls='') => `<circle cx="${p[0]}" cy="${p[1]}" r="${rh}" class="hit ${cls}" data-h="${h}"/>`;
|
|
2857
|
+
const ring = (p, h, cls='') => hit(p, h) + `<circle cx="${p[0]}" cy="${p[1]}" r="${r}" class="hcase"/><circle cx="${p[0]}" cy="${p[1]}" r="${r}" class="h ${cls}" data-h="${h}"/>`;
|
|
2847
2858
|
const L = (x, y, cls, extra='') => `<line x1="${x[0]}" y1="${x[1]}" x2="${y[0]}" y2="${y[1]}" class="${cls}" ${extra}/>`;
|
|
2848
2859
|
const T = (p, txt, dy) => `<text x="${p[0]}" y="${p[1] + dy}" text-anchor="middle" font-size="${fs11}">${txt}</text>`;
|
|
2849
2860
|
// GUIDE_DASH in screen px, expressed in photo px; a line past the screen gets a sparser dash
|
|
@@ -2851,6 +2862,7 @@ function paintGizmo(){
|
|
|
2851
2862
|
const away = unit(sub(bc, ac)); // label offsets: away from the other line
|
|
2852
2863
|
const beyond = dofEnd > 1.0001;
|
|
2853
2864
|
const beyond2 = two && dofEnd2 < -0.0001;
|
|
2865
|
+
const before = dofStart < -0.0001; // focus in front of the glass
|
|
2854
2866
|
// Each line breaks around its ring handle: the ring is hollow, so a line
|
|
2855
2867
|
// running through it would show inside. Break = ring outer edge + 1px.
|
|
2856
2868
|
const brk = (HANDLE_BREAK) * k;
|
|
@@ -2860,11 +2872,11 @@ function paintGizmo(){
|
|
|
2860
2872
|
LL(a0, a1, ac, 'case', 'stroke-width="3"') + LL(a0, a1, ac, 'focus') +
|
|
2861
2873
|
LL(b0, b1, bc, 'case', `stroke-width="2.9" ${dash(beyond)}`) + LL(b0, b1, bc, 'far' + (beyond ? ' out' : ''), dash(beyond)) +
|
|
2862
2874
|
(two ? LL(c0, c1, cc, 'case', `stroke-width="2.9" ${dash(beyond2)}`) + LL(c0, c1, cc, 'far' + (beyond2 ? ' out' : ''), dash(beyond2)) : '') +
|
|
2863
|
-
`<circle cx="${a0[0]}" cy="${a0[1]}" r="${rp}" class="h pip" data-h="rotA"/>` +
|
|
2864
|
-
`<circle cx="${a1[0]}" cy="${a1[1]}" r="${rp}" class="h pip" data-h="rotB"/>` +
|
|
2875
|
+
hit(a0, 'rotA', 'pipHit') + `<circle cx="${a0[0]}" cy="${a0[1]}" r="${rp}" class="h pip" data-h="rotA"/>` +
|
|
2876
|
+
hit(a1, 'rotB', 'pipHit') + `<circle cx="${a1[0]}" cy="${a1[1]}" r="${rp}" class="h pip" data-h="rotB"/>` +
|
|
2865
2877
|
ring(ac, 'start') + ring(bc, 'end') + (two ? ring(cc, 'end2') : '') +
|
|
2866
|
-
`<rect x="${th[0]-r}" y="${th[1]-r}" width="${2*r}" height="${2*r}" transform="rotate(45 ${th[0]} ${th[1]})" class="h thumb" data-h="str"/>` +
|
|
2867
|
-
T([ac[0] - away[0]*off*(two ? 0 : 1), ac[1] - away[1]*off*(two ? 0 : 1) + (two ? -off : 0)], 'sharp', 4*k) +
|
|
2878
|
+
hit(th, 'str') + `<rect x="${th[0]-r}" y="${th[1]-r}" width="${2*r}" height="${2*r}" transform="rotate(45 ${th[0]} ${th[1]})" class="h thumb" data-h="str"/>` +
|
|
2879
|
+
T([ac[0] - away[0]*off*(two ? 0 : 1), ac[1] - away[1]*off*(two ? 0 : 1) + (two ? -off : 0)], 'sharp' + (before ? ' · in front of the screen' : ''), 4*k) +
|
|
2868
2880
|
T([bc[0] + away[0]*off, bc[1] + away[1]*off], `σ ${sigma.toFixed(0)}px from here${beyond ? ' · past the screen' : ''}`, 4*k) +
|
|
2869
2881
|
(two ? T([cc[0] - away[0]*off, cc[1] - away[1]*off], `σ ${sigma.toFixed(0)}px from here${beyond2 ? ' · past the screen' : ''}`, 4*k) : '') +
|
|
2870
2882
|
T([th[0] + away[0]*off*1.6, th[1] + away[1]*off*1.6], `${Math.round(dofStr*100)}%`, 4*k);
|
|
@@ -2901,7 +2913,10 @@ gz.addEventListener('pointermove', e => {
|
|
|
2901
2913
|
const f = (p[0]*g.d[0] + p[1]*g.d[1] - g.lo) / (g.hi - g.lo);
|
|
2902
2914
|
const q = (p[0]*g.u[0] + p[1]*g.u[1] - g.ulo) / (g.uhi - g.ulo);
|
|
2903
2915
|
if (gzDrag.h === 'start'){
|
|
2904
|
-
|
|
2916
|
+
// -0.5 .. 0.95, the same reach past the screen the far line has: on a
|
|
2917
|
+
// steep, flat phone the plane of focus is on the table in front of the
|
|
2918
|
+
// glass, not on it (reported 14 Sep 2026 — "cannot place it on the photo").
|
|
2919
|
+
dofStart = Math.max(dofEnd2 === null ? -0.5 : dofEnd2 + 0.05, Math.min(0.95, Math.min(f, dofEnd - 0.05)));
|
|
2905
2920
|
} else if (gzDrag.h === 'end'){
|
|
2906
2921
|
dofEnd = Math.max(dofStart + 0.05, Math.min(1.5, f));
|
|
2907
2922
|
} else if (gzDrag.h === 'end2'){
|
|
@@ -2927,7 +2942,7 @@ gz.addEventListener('pointermove', e => {
|
|
|
2927
2942
|
// Re-express the lines against the new extent so nothing moves but the angle.
|
|
2928
2943
|
const g2 = gizmoGeom();
|
|
2929
2944
|
const f0 = (c[0]*g2.d[0] + c[1]*g2.d[1] - g2.lo) / (g2.hi - g2.lo);
|
|
2930
|
-
dofStart = Math.max(0, Math.min(0.95, f0));
|
|
2945
|
+
dofStart = Math.max(-0.5, Math.min(0.95, f0));
|
|
2931
2946
|
dofK = Math.max(0.05, Math.min(0.95, (c[0]*g2.u[0] + c[1]*g2.u[1] - g2.ulo) / (g2.uhi - g2.ulo)));
|
|
2932
2947
|
dofEnd = Math.max(dofStart + 0.05, Math.min(1.5, dofStart + gzDrag.dFar / (g2.hi - g2.lo)));
|
|
2933
2948
|
if (dofEnd2 !== null) dofEnd2 = Math.max(-0.5, Math.min(dofStart - 0.05, dofStart - gzDrag.dNear / (g2.hi - g2.lo)));
|