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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "screengraft",
3
- "version": "0.63.9",
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.63):** 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.
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
- #dofGizmo .case{stroke:rgba(0,0,0,.35);fill:none;vector-effect:non-scaling-stroke}
817
- #dofGizmo .focus{stroke:#f5623d;stroke-width:1;fill:none;vector-effect:non-scaling-stroke}
818
- #dofGizmo .far{stroke:#f5623d;stroke-width:0.9;fill:none;vector-effect:non-scaling-stroke}
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 (SVG dashes
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:2;vector-effect:non-scaling-stroke;pointer-events:all;cursor:move}
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:rgba(0,0,0,.35);stroke-width:4;vector-effect:non-scaling-stroke;pointer-events:none}
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.2}
837
- #dofGizmo .h.thumb{fill:#fff;stroke:#f5623d;stroke-width:2}
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
- const CASE_A = 'rgba(0,0,0,.35)', CORE_IDLE = 'rgba(255,255,255,.92)';
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
- const W = im.naturalWidth, Hh = im.naturalHeight;
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', 'stroke-width="3"') + LL(a0, a1, ac, 'focus') +
3119
- LL(b0, b1, bc, 'case', `stroke-width="2.9" ${dash(beyond)}`) + LL(b0, b1, bc, 'far' + (beyond ? ' out' : ''), dash(beyond)) +
3120
- (two ? LL(c0, c1, cc, 'case', `stroke-width="2.9" ${dash(beyond2)}`) + LL(c0, c1, cc, 'far' + (beyond2 ? ' out' : ''), dash(beyond2)) : '') +
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 = $('#outImg').naturalWidth, Hh = $('#outImg').naturalHeight;
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
  }