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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "screengraft",
3
- "version": "0.58.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 = float(np.clip(start, 0.0, 0.95))
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
- start = float(min(max(start, 0.0), 0.95))
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.58):** 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.
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
- #dofGizmo .h{fill:none;stroke:#f5623d;stroke-width:2;vector-effect:non-scaling-stroke;pointer-events:auto;cursor:move}
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 ring = (p, h, cls='') => `<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}"/>`;
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
- dofStart = Math.max(dofEnd2 === null ? 0 : dofEnd2 + 0.05, Math.min(0.95, Math.min(f, dofEnd - 0.05)));
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)));