screengraft 0.24.1 → 0.25.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/README.md CHANGED
@@ -76,7 +76,7 @@ npx screengraft --out-dir ./mockups
76
76
  ```
77
77
 
78
78
  npm is a delivery mechanism here, not a claim about the language: the tool is
79
- Python and OpenCV, and `bin/screengraft.js` is a launcher. It installs nothing
79
+ Python and OpenCV, and `cli/screengraft.js` is a launcher. It installs nothing
80
80
  behind your back — if the engine is missing it prints the one command that
81
81
  builds it (`npx screengraft --install`) and exits.
82
82
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "screengraft",
3
- "version": "0.24.1",
3
+ "version": "0.25.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",
@@ -33,10 +33,10 @@
33
33
  },
34
34
  "type": "commonjs",
35
35
  "bin": {
36
- "screengraft": "bin/screengraft.js"
36
+ "screengraft": "cli/screengraft.js"
37
37
  },
38
38
  "files": [
39
- "bin/",
39
+ "cli/",
40
40
  "scripts/",
41
41
  "ui/",
42
42
  "mcp/",
@@ -61,6 +61,6 @@
61
61
  "linux"
62
62
  ],
63
63
  "scripts": {
64
- "start": "node bin/screengraft.js"
64
+ "start": "node cli/screengraft.js"
65
65
  }
66
66
  }
package/scripts/grade.py CHANGED
@@ -130,21 +130,46 @@ def match_light(photo: np.ndarray, warped: np.ndarray, mask: np.ndarray,
130
130
  return apply_light(warped, light_params(photo, warped, mask, strength))
131
131
 
132
132
 
133
+ GRAIN_GATE = 20.0 # grey levels: above this a residual is an edge, not grain
134
+ _MEDIAN_HP_GAIN = 0.909 # a 3x3 median high-pass absorbs this much of iid noise
135
+ # (measured, 5 seeds x sigma 1-5, spread < 0.3%)
136
+
137
+
133
138
  def measure_grain(photo: np.ndarray, ring: np.ndarray) -> float:
134
139
  """The photo's noise floor, in grey levels, measured where the screen isn't.
135
140
 
136
- High-pass with a 3x3 median (cheap, edge-preserving) and take the MEDIAN
137
- absolute deviation of the residual rather than its standard deviation: a
138
- bezel edge or a highlight inside the ring is a huge outlier, and a mean-based
139
- estimate would read the edge as noise and dump visible grain on the screen.
140
- 0.6745 converts MAD to a sigma for a normal distribution.
141
+ High-pass with a 3x3 median (cheap, edge-preserving), gate the outliers off,
142
+ then take the MEAN absolute deviation of what is left.
143
+
144
+ Why the mean and not the median: `photo` is uint8 and so is the
145
+ median blur, so the residual is integer-valued, and a median of integers is
146
+ an integer or a half. The old MAD/0.6745 could therefore only ever return
147
+ multiples of 1.4826 — and under one grey level it returned a flat 0, so a
148
+ lightly-noisy photograph got no grain at all. Two of the three surviving
149
+ reference photos measured exactly 0.0 that way while actually carrying 0.39
150
+ and 1.46. A mean over the same integers resolves continuously.
151
+
152
+ Robustness moves from the statistic to the GATE. A 3x3 median is
153
+ edge-preserving, so a clean step edge leaves a residual of exactly 0 and was
154
+ never the danger the old docstring guarded against; what does leak is fine
155
+ repeating texture and specks, whose residuals are large. Discarding
156
+ |resid| > GRAIN_GATE drops those and leaves the noise floor untouched:
157
+ identical for any gate in 8..40, unbiased out to sigma 4, and 5x below the
158
+ smallest texture residual that breaks it — a 9px-pitch line pattern reads
159
+ 27.0 ungated against a true 1.0.
160
+
161
+ 0.7979 is E|X|/sigma for a normal; _MEDIAN_HP_GAIN undoes the noise the
162
+ median filter itself absorbs.
141
163
  """
142
164
  g = cv2.cvtColor(photo, cv2.COLOR_BGR2GRAY)
143
165
  resid = g.astype(np.float32) - cv2.medianBlur(g, 3).astype(np.float32)
144
166
  px = resid[ring.astype(bool)]
145
167
  if px.size < 500:
146
168
  return 0.0
147
- return float(np.median(np.abs(px - np.median(px))) / 0.6745)
169
+ px = px[np.abs(px) <= GRAIN_GATE]
170
+ if px.size < 500:
171
+ return 0.0
172
+ return float(np.mean(np.abs(px - px.mean())) / 0.7979 / _MEDIAN_HP_GAIN)
148
173
 
149
174
 
150
175
  def add_grain(img: np.ndarray, mask: np.ndarray, sigma: float, seed: int = 0) -> np.ndarray:
@@ -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.24):** 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 — 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.25):** 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 — 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.
11
11
 
package/ui/index.html CHANGED
@@ -101,12 +101,27 @@
101
101
 
102
102
  /* The shell IS the viewport. Nothing scrolls except the canvas, so the
103
103
  fitting surface can never be pushed off-screen by finished work. */
104
- .app{height:100%;display:grid;grid-template-rows:auto minmax(0,1fr) auto;min-height:0;
104
+ /* The ROWS were given minmax(0,1fr) and the column was left implicit,
105
+ which means `auto` — and an auto track is floored at its content's
106
+ min-content width, so the whole app grew to 896px inside a 742px viewport
107
+ and the page scrolled sideways. Same discipline, same axis: a track that
108
+ may not exceed its share needs the 0 minimum stated. This is the root of
109
+ the overflow; the topbar and #stage reported with it were victims, sized by
110
+ a container that had already grown. */
111
+ .app{height:100%;display:grid;grid-template-rows:auto minmax(0,1fr) auto;
112
+ grid-template-columns:minmax(0,1fr);min-height:0;
105
113
  padding-bottom:32px;background:var(--bg)}
106
114
 
107
115
  .topbar{display:flex;align-items:center;gap:8px;padding:0 12px 0 var(--s4);height:56px;
108
116
  background:var(--card);border-bottom:1px solid var(--line);
109
117
  box-shadow:inset 0 1px 0 rgba(255,255,255,.04), 0 1px 0 rgba(0,0,0,.25)}
118
+ /* A nowrap flex row whose children keep min-width:auto cannot shrink
119
+ below their content, so at 742px Save and Send to Claude sat at x=750..885
120
+ — outside the viewport and unclickable. The chips carry a filename and are
121
+ the part that should give; the actions are fixed-size and must not. */
122
+ .topbar > *{min-width:0}
123
+ .tb-chips{overflow:hidden}
124
+ .tb-actions{flex:none}
110
125
  .tb-left{display:flex;align-items:center;gap:var(--s9)}
111
126
  .tb-chips{display:flex;align-items:center;gap:var(--s2)}
112
127
  .tb-arrow{color:var(--mute)}
@@ -302,7 +317,12 @@
302
317
  .stage > .rail{grid-column:2;grid-row:1 / span 2}
303
318
  }
304
319
  #types{flex-wrap:nowrap}
320
+ /* The same omission one level down: rows constrained, column implicit.
321
+ min-width:0 lets the PANE shrink; it does nothing for the auto track
322
+ inside it, which was still sizing to the header's min-content — 798px
323
+ wide in a 398px column. */
305
324
  .pane{min-width:0;min-height:0;display:grid;grid-template-rows:auto minmax(0,1fr);
325
+ grid-template-columns:minmax(0,1fr);
306
326
  position:relative; /* the frame the corner bar is anchored to */
307
327
  border-right:1px solid var(--line)}
308
328
  .panehead{display:flex;align-items:center;gap:var(--s2);padding:var(--s2) var(--s4);background:var(--card);
File without changes