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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "screengraft",
3
- "version": "0.58.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 = 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; }
@@ -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 (start)
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 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,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
- dofStart = Math.max(dofEnd2 === null ? 0 : dofEnd2 + 0.05, Math.min(0.95, Math.min(f, dofEnd - 0.05)));
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)));