screengraft 0.24.1 → 0.25.2
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 +20 -10
- package/package.json +4 -4
- package/scripts/grade.py +31 -6
- package/scripts/ui.py +85 -3
- package/skills/inject-screenshot/SKILL.md +1 -1
- package/ui/index.html +21 -1
- /package/{bin → cli}/screengraft.js +0 -0
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 `
|
|
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
|
|
|
@@ -138,15 +138,25 @@ you, and nothing below ever touches it.
|
|
|
138
138
|
|
|
139
139
|
**Working files** live in `~/.screengraft/sessions/<timestamp>/` — one directory
|
|
140
140
|
per run. A source you pick by path is never copied: screengraft reads it where it
|
|
141
|
-
is. A source you drag in or browse
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
141
|
+
is, and screengraft never deletes a file of yours. A source you drag in or browse
|
|
142
|
+
to has to be copied, because a browser hands over bytes and will not say where
|
|
143
|
+
they came from.
|
|
144
|
+
|
|
145
|
+
What happens to that copy depends on whether the run produced anything:
|
|
146
|
+
|
|
147
|
+
- **A run that saved a mockup keeps its source.** The `result.json` sidecar names
|
|
148
|
+
it, and a recipe naming a file that no longer exists is not a recipe.
|
|
149
|
+
- **A run that produced nothing keeps nothing.** That is the common case and
|
|
150
|
+
where the disk goes — previews, thumbnails, poster frames and abandoned
|
|
151
|
+
uploads are all swept when the run ends, or at the next launch after a crash.
|
|
152
|
+
|
|
153
|
+
What survives either way is the `result.json` sidecar: a few hundred bytes
|
|
154
|
+
recording the corners, radius, grade and blend of that fit. It reproduces a
|
|
155
|
+
composite exactly, and it is the first thing a bug report should include.
|
|
156
|
+
|
|
157
|
+
Sidecars written by v0.23.0–v0.25.1 may name a dragged-in source that release
|
|
158
|
+
deleted. Those cannot be repaired — the bytes are gone — but screengraft now
|
|
159
|
+
marks them `"source_retained": false` rather than leaving them looking valid.
|
|
150
160
|
|
|
151
161
|
|
|
152
162
|
## Roadmap
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "screengraft",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.2",
|
|
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": "
|
|
36
|
+
"screengraft": "cli/screengraft.js"
|
|
37
37
|
},
|
|
38
38
|
"files": [
|
|
39
|
-
"
|
|
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
|
|
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)
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
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:
|
package/scripts/ui.py
CHANGED
|
@@ -155,14 +155,54 @@ _RESIDUE_PREFIXES = ("photo-", "screenshot-", "poster-", "frame-")
|
|
|
155
155
|
_RESIDUE_NAMES = ("preview.png", "figma-export.png")
|
|
156
156
|
|
|
157
157
|
|
|
158
|
+
def _sidecar_sources(d):
|
|
159
|
+
"""Absolute paths inside `d` that this session's result.json still names.
|
|
160
|
+
|
|
161
|
+
v0.23.0 swept these too, and the sidecar's whole promise is that a fit can
|
|
162
|
+
be re-run from it. That promise held for a source picked by PATH, which
|
|
163
|
+
was never copied -- and quietly broke for a drag-drop or browse, where the
|
|
164
|
+
browser hands over bytes with no origin and the copy in the session IS the
|
|
165
|
+
original as far as the sidecar is concerned. Measured after the first sweep:
|
|
166
|
+
9 of 9 such sidecars pointed at a deleted file.
|
|
167
|
+
|
|
168
|
+
So the rule is now: a session that produced output keeps what its sidecar
|
|
169
|
+
names. A session that produced nothing keeps nothing -- there is no recipe
|
|
170
|
+
to protect, which is the common case and where the volume is.
|
|
171
|
+
|
|
172
|
+
Only paths INSIDE the session are returned. A path-picked source lives in
|
|
173
|
+
the user's own folders and was never ours to keep or delete.
|
|
174
|
+
|
|
175
|
+
Note the sidecar is rewritten on every save, so it names the LAST fit. An
|
|
176
|
+
earlier source replaced within the same session is not protected: the record
|
|
177
|
+
is what survives, and the record says what it says.
|
|
178
|
+
"""
|
|
179
|
+
try:
|
|
180
|
+
with open(os.path.join(d, "result.json")) as f:
|
|
181
|
+
res = json.load(f)
|
|
182
|
+
except (OSError, ValueError):
|
|
183
|
+
return set()
|
|
184
|
+
root = os.path.realpath(d)
|
|
185
|
+
keep = set()
|
|
186
|
+
for key in ("photo", "screenshot"):
|
|
187
|
+
p = res.get(key)
|
|
188
|
+
if not p:
|
|
189
|
+
continue
|
|
190
|
+
rp = os.path.realpath(p)
|
|
191
|
+
if rp == root or rp.startswith(root + os.sep):
|
|
192
|
+
keep.add(rp)
|
|
193
|
+
return keep
|
|
194
|
+
|
|
195
|
+
|
|
158
196
|
def _sweep_session(d):
|
|
159
197
|
"""Delete a session's copied and derived media. Returns bytes reclaimed.
|
|
160
198
|
|
|
161
|
-
Never touches *.json,
|
|
162
|
-
|
|
199
|
+
Never touches *.json, never touches OUT_DIR -- the actual outputs live in
|
|
200
|
+
the project folder and are the point of the whole exercise -- and never
|
|
201
|
+
touches a source the session's own result.json still names (see above).
|
|
163
202
|
"""
|
|
164
203
|
freed = 0
|
|
165
204
|
thumbs = os.path.join(d, "thumbs")
|
|
205
|
+
protected = _sidecar_sources(d)
|
|
166
206
|
for base, _, files in os.walk(d):
|
|
167
207
|
for f in files:
|
|
168
208
|
keep = f.endswith(".json")
|
|
@@ -171,6 +211,8 @@ def _sweep_session(d):
|
|
|
171
211
|
if keep or not residue:
|
|
172
212
|
continue
|
|
173
213
|
fp = os.path.join(base, f)
|
|
214
|
+
if os.path.realpath(fp) in protected:
|
|
215
|
+
continue
|
|
174
216
|
try:
|
|
175
217
|
freed += os.path.getsize(fp)
|
|
176
218
|
os.remove(fp)
|
|
@@ -179,6 +221,39 @@ def _sweep_session(d):
|
|
|
179
221
|
return freed
|
|
180
222
|
|
|
181
223
|
|
|
224
|
+
def _mark_unreproducible(d):
|
|
225
|
+
"""Stamp a sidecar whose named source no longer exists.
|
|
226
|
+
|
|
227
|
+
For the sessions v0.23.0 already swept, nothing can be recovered -- the
|
|
228
|
+
bytes are gone and the browser never said where they came from. What can be
|
|
229
|
+
fixed is the claim: a sidecar that names a deleted file reads exactly like
|
|
230
|
+
one that works, and the difference only shows up when someone tries to
|
|
231
|
+
re-run it. `source_retained: false` says so up front.
|
|
232
|
+
|
|
233
|
+
Idempotent, and it never touches a sidecar whose files are intact.
|
|
234
|
+
"""
|
|
235
|
+
path = os.path.join(d, "result.json")
|
|
236
|
+
try:
|
|
237
|
+
with open(path) as f:
|
|
238
|
+
res = json.load(f)
|
|
239
|
+
except (OSError, ValueError):
|
|
240
|
+
return False
|
|
241
|
+
if "source_retained" in res:
|
|
242
|
+
return False
|
|
243
|
+
named = [res.get(k) for k in ("photo", "screenshot")]
|
|
244
|
+
if not any(named) or all(p and os.path.exists(p) for p in named if p):
|
|
245
|
+
return False
|
|
246
|
+
res["source_retained"] = False
|
|
247
|
+
tmp = path + ".tmp"
|
|
248
|
+
try:
|
|
249
|
+
with open(tmp, "w") as f:
|
|
250
|
+
json.dump(res, f, indent=1)
|
|
251
|
+
os.replace(tmp, path)
|
|
252
|
+
except OSError:
|
|
253
|
+
return False
|
|
254
|
+
return True
|
|
255
|
+
|
|
256
|
+
|
|
182
257
|
def _prune_sessions(keep):
|
|
183
258
|
"""Sweep every session but the live one, at launch.
|
|
184
259
|
|
|
@@ -191,7 +266,10 @@ def _prune_sessions(keep):
|
|
|
191
266
|
/api/use records the path and reads through it. Only a drag-drop or a browse
|
|
192
267
|
has to be copied, because the browser hands over bytes and will not say
|
|
193
268
|
where they came from. So this is the other half of the same policy: what
|
|
194
|
-
cannot avoid being copied does not outlive the run that needed it
|
|
269
|
+
cannot avoid being copied does not outlive the run that needed it --
|
|
270
|
+
UNLESS the run produced something, in which case its sidecar names the
|
|
271
|
+
source and _sweep_session keeps it. See _sidecar_sources: reproducibility
|
|
272
|
+
beats disk exactly where a fit actually happened, and nowhere else.
|
|
195
273
|
"""
|
|
196
274
|
root = os.path.dirname(keep)
|
|
197
275
|
freed = 0
|
|
@@ -204,6 +282,10 @@ def _prune_sessions(keep):
|
|
|
204
282
|
if d == keep or not os.path.isdir(d):
|
|
205
283
|
continue
|
|
206
284
|
freed += _sweep_session(d)
|
|
285
|
+
# After sweeping, not before: a sidecar is only unreproducible once its
|
|
286
|
+
# source is actually gone, and from here on the sweep leaves it alone.
|
|
287
|
+
# This is for the sessions the previous release already emptied.
|
|
288
|
+
_mark_unreproducible(d)
|
|
207
289
|
return freed
|
|
208
290
|
|
|
209
291
|
|
|
@@ -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.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
|
-
|
|
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
|