screengraft 0.58.0 → 0.59.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/dof.py +4 -1
- package/scripts/ui.py +2 -1
- package/skills/inject-screenshot/SKILL.md +1 -1
- package/ui/index.html +38 -10
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "screengraft",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.59.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/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; }
|
|
@@ -2780,7 +2789,7 @@ $('#dofMeasure').onclick = measureDof;
|
|
|
2780
2789
|
on any steep fit (reported 13 Sep 2026). The SOLID line is where focus
|
|
2781
2790
|
ends; the DASHED line is where the blur reaches its full amount, and it can
|
|
2782
2791
|
sit anywhere on the screen or a little past it. Handles:
|
|
2783
|
-
solid line centre -- move focus
|
|
2792
|
+
solid line centre -- move the plane of focus; the limits come with it
|
|
2784
2793
|
dashed line centre -- move where the ramp ends (end); spacing = ramp length
|
|
2785
2794
|
sharp line pips -- turn all lines about the focus line (direction), ⇧ snaps to 15°
|
|
2786
2795
|
second dashed line -- the NEAR limit, when "Both sides" is on: blur grows
|
|
@@ -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,15 +2872,31 @@ 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);
|
|
2871
2883
|
}
|
|
2884
|
+
// Moving the SHARP line moves the plane of focus -- and the limits go with
|
|
2885
|
+
// it, each keeping its distance, the way a tilt-shift band moves as one thing.
|
|
2886
|
+
// It used to stop at the far line (start <= end - 0.05), so with the dashed
|
|
2887
|
+
// line sitting on the phone's near edge the sharp line could not be placed
|
|
2888
|
+
// over the phone at all: it hit the dashed line and stayed (Darek's recording,
|
|
2889
|
+
// 14 Sep 2026). The dashed lines are what change the ramp's length; the sharp
|
|
2890
|
+
// line only ever changes where the ramp is. Reach: -0.5 .. 0.95 along the
|
|
2891
|
+
// screen, the same distance past the glass the far line has -- on a steep,
|
|
2892
|
+
// flat phone the plane of focus is on the table in front of the screen.
|
|
2893
|
+
function moveFocus(f){
|
|
2894
|
+
const was = dofStart;
|
|
2895
|
+
dofStart = Math.max(-0.5, Math.min(0.95, f));
|
|
2896
|
+
const shift = dofStart - was;
|
|
2897
|
+
dofEnd = Math.max(dofStart + 0.05, Math.min(1.5, dofEnd + shift));
|
|
2898
|
+
if (dofEnd2 !== null) dofEnd2 = Math.max(-0.5, Math.min(dofStart - 0.05, dofEnd2 + shift));
|
|
2899
|
+
}
|
|
2872
2900
|
// pointer position in SCREENSHOT pixels
|
|
2873
2901
|
function gizmoPos(e, g){
|
|
2874
2902
|
const rc = gz.getBoundingClientRect();
|
|
@@ -2901,7 +2929,7 @@ gz.addEventListener('pointermove', e => {
|
|
|
2901
2929
|
const f = (p[0]*g.d[0] + p[1]*g.d[1] - g.lo) / (g.hi - g.lo);
|
|
2902
2930
|
const q = (p[0]*g.u[0] + p[1]*g.u[1] - g.ulo) / (g.uhi - g.ulo);
|
|
2903
2931
|
if (gzDrag.h === 'start'){
|
|
2904
|
-
|
|
2932
|
+
moveFocus(f);
|
|
2905
2933
|
} else if (gzDrag.h === 'end'){
|
|
2906
2934
|
dofEnd = Math.max(dofStart + 0.05, Math.min(1.5, f));
|
|
2907
2935
|
} else if (gzDrag.h === 'end2'){
|
|
@@ -2927,7 +2955,7 @@ gz.addEventListener('pointermove', e => {
|
|
|
2927
2955
|
// Re-express the lines against the new extent so nothing moves but the angle.
|
|
2928
2956
|
const g2 = gizmoGeom();
|
|
2929
2957
|
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));
|
|
2958
|
+
dofStart = Math.max(-0.5, Math.min(0.95, f0));
|
|
2931
2959
|
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
2960
|
dofEnd = Math.max(dofStart + 0.05, Math.min(1.5, dofStart + gzDrag.dFar / (g2.hi - g2.lo)));
|
|
2933
2961
|
if (dofEnd2 !== null) dofEnd2 = Math.max(-0.5, Math.min(dofStart - 0.05, dofStart - gzDrag.dNear / (g2.hi - g2.lo)));
|