screengraft 0.54.7 → 0.55.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/README.md +4 -2
- package/package.json +1 -1
- package/scripts/dof.py +1 -1
- package/scripts/grade.py +9 -0
- package/scripts/ui.py +28 -5
- package/scripts/warp.py +15 -6
- package/skills/inject-screenshot/SKILL.md +1 -1
- package/ui/index.html +44 -5
package/README.md
CHANGED
|
@@ -138,8 +138,10 @@ outside the screen mask. It never touches the pixels you designed.
|
|
|
138
138
|
warp never area-averages, so warping a 1206×2622 screenshot into a 226×454
|
|
139
139
|
quad without it turns body text into noise.
|
|
140
140
|
3. **Realism pass** *(optional)* — white balance and exposure toward the
|
|
141
|
-
surrounding light, grain
|
|
142
|
-
|
|
141
|
+
surrounding light, grain measured from the photo's own noise floor and
|
|
142
|
+
laid on the screen at 0.7× of it (a lit screen sits in the highlights, where
|
|
143
|
+
8-bit noise is lower than on the body around it), real speculars lifted
|
|
144
|
+
from a screen-off reference.
|
|
143
145
|
4. **Video**, when the source is a clip — everything a fixed photo and a fixed
|
|
144
146
|
quad make constant is computed once, and only the frame changes. Three
|
|
145
147
|
consequences worth naming, because each is a way video normally goes wrong:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "screengraft",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.55.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
|
@@ -281,7 +281,7 @@ def measure(photo: np.ndarray, corners) -> dict:
|
|
|
281
281
|
out.update({"angle": 0.0, "strength": 0.0, "flat": True})
|
|
282
282
|
return out
|
|
283
283
|
Amat = np.column_stack([mids[:, 0], mids[:, 1], np.ones(len(mids))])
|
|
284
|
-
(a, b,
|
|
284
|
+
(a, b, _c), *_ = np.linalg.lstsq(Amat, sig, rcond=None)
|
|
285
285
|
angle = math.degrees(math.atan2(b, a)) % 360.0
|
|
286
286
|
strength = float(np.clip((sig.max() - lo) / max(sigma_max(q, 1.0), 1e-6), 0.0, 1.0))
|
|
287
287
|
out.update({"angle": round(angle, 1), "strength": round(strength, 3), "flat": False})
|
package/scripts/grade.py
CHANGED
|
@@ -131,6 +131,15 @@ def match_light(photo: np.ndarray, warped: np.ndarray, mask: np.ndarray,
|
|
|
131
131
|
|
|
132
132
|
|
|
133
133
|
GRAIN_GATE = 20.0 # grey levels: above this a residual is an edge, not grain
|
|
134
|
+
# How much of the SURROUND's noise floor a lit screen should carry. The sigma is
|
|
135
|
+
# measured on the ring around the screen -- bezel and body, usually the darkest
|
|
136
|
+
# thing near it -- but the injected screen is usually the brightest thing in
|
|
137
|
+
# the frame, and after the sRGB curve a highlight carries less noise in grey
|
|
138
|
+
# levels than a shadow does. Measured on the 38-photo corpus (13 Sep 2026):
|
|
139
|
+
# the 192-255 band's floor is a median 0.67x the 0-63 band's. A designer's
|
|
140
|
+
# eye on a full-resolution save said the same thing first: "a bit smaller".
|
|
141
|
+
# 1.0 is the pre-v0.55 behaviour and is what an old sidecar replays with.
|
|
142
|
+
SCREEN_GRAIN_GAIN = 0.7
|
|
134
143
|
_MEDIAN_HP_GAIN = 0.909 # a 3x3 median high-pass absorbs this much of iid noise
|
|
135
144
|
# (measured, 5 seeds x sigma 1-5, spread < 0.3%)
|
|
136
145
|
|
package/scripts/ui.py
CHANGED
|
@@ -50,6 +50,7 @@ import detect as D # noqa: E402
|
|
|
50
50
|
import fitfile as FF # noqa: E402
|
|
51
51
|
import fits as FIT # noqa: E402
|
|
52
52
|
import dof as DOF # noqa: E402
|
|
53
|
+
import grade as _grade # noqa: E402
|
|
53
54
|
import scan as S # noqa: E402
|
|
54
55
|
import warp as W # noqa: E402
|
|
55
56
|
|
|
@@ -578,7 +579,8 @@ PREVIEW_SECONDS = 6.0
|
|
|
578
579
|
|
|
579
580
|
def _render_worker(photo, video_path, corners, dest, radius_px, gr, grain, preset, fit_frame,
|
|
580
581
|
blend="replace", reflection=None, result=None, kind="render",
|
|
581
|
-
start_frame=0, max_frames=None, *, smoothing=0.0, dof=None
|
|
582
|
+
start_frame=0, max_frames=None, *, smoothing=0.0, dof=None,
|
|
583
|
+
grain_gain=1.0):
|
|
582
584
|
"""Encode the clip, and only if that SUCCEEDS publish what it produced.
|
|
583
585
|
|
|
584
586
|
`result` is the sidecar this render would write. It is handed to the worker
|
|
@@ -598,7 +600,7 @@ def _render_worker(photo, video_path, corners, dest, radius_px, gr, grain, prese
|
|
|
598
600
|
try:
|
|
599
601
|
info = W.compose_video(photo, video_path, corners, dest,
|
|
600
602
|
corner_radius=radius_px, corner_smoothing=smoothing,
|
|
601
|
-
grade=gr, grain=grain,
|
|
603
|
+
grade=gr, grain=grain, grain_gain=grain_gain,
|
|
602
604
|
preset=preset, fit_frame=fit_frame, progress=progress,
|
|
603
605
|
blend=blend,
|
|
604
606
|
reflection=(W.DEFAULT_REFLECTION if reflection is None
|
|
@@ -666,6 +668,22 @@ def _blend_args(b):
|
|
|
666
668
|
return "emissive", float(max(0.0, min(1.0, float(r))))
|
|
667
669
|
|
|
668
670
|
|
|
671
|
+
def _grain_gain(b) -> float:
|
|
672
|
+
"""How much of the surround's noise floor the screen carries.
|
|
673
|
+
|
|
674
|
+
The page does not set this; fresh renders get grade.SCREEN_GRAIN_GAIN. It
|
|
675
|
+
is read from the body so a sidecar replayed through the API keeps its own
|
|
676
|
+
value -- a sidecar from before the gain existed carries none and passes
|
|
677
|
+
1.0 explicitly at the replay site, never here.
|
|
678
|
+
"""
|
|
679
|
+
try:
|
|
680
|
+
g = float(b.get("grain_gain")) if b.get("grain_gain") is not None \
|
|
681
|
+
else _grade.SCREEN_GRAIN_GAIN
|
|
682
|
+
except (TypeError, ValueError):
|
|
683
|
+
g = _grade.SCREEN_GRAIN_GAIN
|
|
684
|
+
return float(min(max(g, 0.0), 2.0))
|
|
685
|
+
|
|
686
|
+
|
|
669
687
|
def _dof_args(b):
|
|
670
688
|
"""Depth-of-field kwargs from the page, as a dict compose() takes directly.
|
|
671
689
|
|
|
@@ -1194,7 +1212,8 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1194
1212
|
blend, reflection, None, "preview",
|
|
1195
1213
|
fit_frame, max_frames),
|
|
1196
1214
|
kwargs={"smoothing": _smoothing(b),
|
|
1197
|
-
"dof": _dof_args(b)
|
|
1215
|
+
"dof": _dof_args(b),
|
|
1216
|
+
"grain_gain": _grain_gain(b)}).start()
|
|
1198
1217
|
except BaseException:
|
|
1199
1218
|
with RENDER_LOCK:
|
|
1200
1219
|
RENDER.update(state="error", message="could not start the preview")
|
|
@@ -1247,7 +1266,7 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1247
1266
|
result = {"output": dest, "photo": ppath, "screenshot": spath,
|
|
1248
1267
|
"corners": corners, "radius_frac": frac, "radius_px": radius_px,
|
|
1249
1268
|
"device": b.get("device"), "corner_smoothing": _smoothing(b),
|
|
1250
|
-
"grade": gr, "grain": grain,
|
|
1269
|
+
"grade": gr, "grain": grain, "grain_gain": _grain_gain(b),
|
|
1251
1270
|
"video": True, "preset": preset, "fit_frame": fit_frame,
|
|
1252
1271
|
"blend": blend, "reflection": reflection,
|
|
1253
1272
|
"dof_angle": dof["dof_angle"], "dof_strength": dof["dof_strength"],
|
|
@@ -1280,7 +1299,8 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1280
1299
|
gr, grain, preset, fit_frame,
|
|
1281
1300
|
blend, reflection, result),
|
|
1282
1301
|
kwargs={"smoothing": _smoothing(b),
|
|
1283
|
-
"dof": dof
|
|
1302
|
+
"dof": dof,
|
|
1303
|
+
"grain_gain": result["grain_gain"]}).start()
|
|
1284
1304
|
except BaseException:
|
|
1285
1305
|
# If the thread cannot even be created, the flag must not
|
|
1286
1306
|
# outlive the request.
|
|
@@ -1304,9 +1324,11 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1304
1324
|
blend, reflection = _blend_args(b)
|
|
1305
1325
|
smoothing = _smoothing(b)
|
|
1306
1326
|
dof = _dof_args(b)
|
|
1327
|
+
grain_gain = _grain_gain(b)
|
|
1307
1328
|
out = W.compose(photo, shot, corners, radius_px,
|
|
1308
1329
|
corner_smoothing=smoothing,
|
|
1309
1330
|
grade=gr, grain=bool(b.get("grain", gr > 0)),
|
|
1331
|
+
grain_gain=grain_gain,
|
|
1310
1332
|
blend=blend, reflection=reflection, **dof)
|
|
1311
1333
|
SESSION.update(corners=corners, radius_frac=frac, device=b.get("device"),
|
|
1312
1334
|
grade=gr)
|
|
@@ -1342,6 +1364,7 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1342
1364
|
"radius_frac": frac, "radius_px": radius_px, "device": b.get("device"),
|
|
1343
1365
|
"corner_smoothing": smoothing,
|
|
1344
1366
|
"grade": gr, "grain": bool(b.get("grain", gr > 0)),
|
|
1367
|
+
"grain_gain": grain_gain,
|
|
1345
1368
|
"blend": blend, "reflection": reflection,
|
|
1346
1369
|
"dof_angle": dof["dof_angle"], "dof_strength": dof["dof_strength"],
|
|
1347
1370
|
"dof_start": dof["dof_start"], "dof_end": dof["dof_end"],
|
package/scripts/warp.py
CHANGED
|
@@ -270,7 +270,8 @@ class Plan:
|
|
|
270
270
|
blend: str = "replace", reflection: float = DEFAULT_REFLECTION,
|
|
271
271
|
corner_smoothing: float = 0.0,
|
|
272
272
|
dof_angle: float = 0.0, dof_strength: float = 0.0, dof_start: float = 0.0,
|
|
273
|
-
dof_end: float = 1.0, dof_space: str = "photo", dof_end2=None
|
|
273
|
+
dof_end: float = 1.0, dof_space: str = "photo", dof_end2=None,
|
|
274
|
+
grain_gain: float = 1.0):
|
|
274
275
|
dst_quad = np.array(corners, dtype=np.float32)
|
|
275
276
|
if shoelace_area(dst_quad) < 1.0:
|
|
276
277
|
raise ValueError("degenerate quad (near-zero area) — check corner order TL,TR,BR,BL")
|
|
@@ -310,8 +311,12 @@ class Plan:
|
|
|
310
311
|
self.corner_smoothing)
|
|
311
312
|
self.warped_mask = _warp_mask_antialiased(src_mask, self.H, pw, ph, dst_quad)
|
|
312
313
|
self.mask3 = cv2.merge([self.warped_mask] * 3).astype(np.float32) / 255.0
|
|
314
|
+
# The floor is the surround's; the screen carries `grain_gain` of it
|
|
315
|
+
# (see grade.SCREEN_GRAIN_GAIN). Defaults to 1.0 so a sidecar written
|
|
316
|
+
# before the gain existed reproduces its save byte for byte.
|
|
317
|
+
self.grain_gain = float(max(grain_gain, 0.0))
|
|
313
318
|
self.grain_sigma = (_grade.measure_grain(photo, _grade.surround_ring(self.warped_mask))
|
|
314
|
-
if grain else 0.0)
|
|
319
|
+
* self.grain_gain if grain else 0.0)
|
|
315
320
|
self.grade_params = None
|
|
316
321
|
# Integer bbox of the quad, clamped to the canvas and padded by a pixel
|
|
317
322
|
# so the antialiased edge is never clipped.
|
|
@@ -444,7 +449,8 @@ def compose(photo: np.ndarray, screenshot: np.ndarray, corners, corner_radius: f
|
|
|
444
449
|
reflection: float = DEFAULT_REFLECTION,
|
|
445
450
|
dof_angle: float = 0.0, dof_strength: float = 0.0,
|
|
446
451
|
dof_start: float = 0.0, dof_end: float = 1.0,
|
|
447
|
-
dof_space: str = "photo", dof_end2=None
|
|
452
|
+
dof_space: str = "photo", dof_end2=None,
|
|
453
|
+
grain_gain: float = 1.0) -> np.ndarray:
|
|
448
454
|
"""Warp `screenshot` into the quad `corners` (TL,TR,BR,BL, photo pixels) on `photo`.
|
|
449
455
|
|
|
450
456
|
Single resampling pass at the photo's resolution; deterministic. This is the
|
|
@@ -459,7 +465,8 @@ def compose(photo: np.ndarray, screenshot: np.ndarray, corners, corner_radius: f
|
|
|
459
465
|
corner_smoothing=corner_smoothing,
|
|
460
466
|
blend=blend, reflection=reflection,
|
|
461
467
|
dof_angle=dof_angle, dof_strength=dof_strength, dof_start=dof_start,
|
|
462
|
-
dof_end=dof_end, dof_space=dof_space, dof_end2=dof_end2
|
|
468
|
+
dof_end=dof_end, dof_space=dof_space, dof_end2=dof_end2,
|
|
469
|
+
grain_gain=grain_gain)
|
|
463
470
|
plan.bind_grade(screenshot, grade)
|
|
464
471
|
return plan.render(screenshot, screen_off=screen_off, specular=specular)
|
|
465
472
|
|
|
@@ -549,7 +556,8 @@ def compose_video(photo: np.ndarray, video_path: str, corners, output: str,
|
|
|
549
556
|
start_frame: int = 0, max_frames: int = None,
|
|
550
557
|
dof_angle: float = 0.0, dof_strength: float = 0.0,
|
|
551
558
|
dof_start: float = 0.0, dof_end: float = 1.0,
|
|
552
|
-
dof_space: str = "photo", dof_end2=None
|
|
559
|
+
dof_space: str = "photo", dof_end2=None,
|
|
560
|
+
grain_gain: float = 1.0) -> dict:
|
|
553
561
|
"""Inject a VIDEO into a still photo. The photo does not move, so there is
|
|
554
562
|
exactly one homography and the whole of Plan is computed once.
|
|
555
563
|
|
|
@@ -578,7 +586,8 @@ def compose_video(photo: np.ndarray, video_path: str, corners, output: str,
|
|
|
578
586
|
corner_smoothing=corner_smoothing,
|
|
579
587
|
blend=blend, reflection=reflection,
|
|
580
588
|
dof_angle=dof_angle, dof_strength=dof_strength, dof_start=dof_start,
|
|
581
|
-
dof_end=dof_end, dof_space=dof_space, dof_end2=dof_end2
|
|
589
|
+
dof_end=dof_end, dof_space=dof_space, dof_end2=dof_end2,
|
|
590
|
+
grain_gain=grain_gain)
|
|
582
591
|
plan.bind_grade(first, grade)
|
|
583
592
|
|
|
584
593
|
ph, pw = photo.shape[:2]
|
|
@@ -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.55):** 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
|
@@ -2920,9 +2920,38 @@ function swingText(mode){
|
|
|
2920
2920
|
if (mode === 'rotB') return 'the left end swings, pivot on the right';
|
|
2921
2921
|
return 'flat on the rule means aligned';
|
|
2922
2922
|
}
|
|
2923
|
+
/* A corner under the pointer gets a plain magnified view instead of the
|
|
2924
|
+
rectified strip: the strip straightens ONE edge, and a corner is where two
|
|
2925
|
+
meet, so straightening either would bend the other. Same magnification as
|
|
2926
|
+
the strip (H / 2·stripHalf, so the ± buttons work here too), centred on the
|
|
2927
|
+
corner, unrotated, with both edges drawn through it and the ring at the
|
|
2928
|
+
centre. Requested 13 Sep 2026; edges keep the strip as it is. */
|
|
2929
|
+
function paintCorner(c, W, H, i){
|
|
2930
|
+
c.setTransform(1,0,0,1,0,0);
|
|
2931
|
+
c.fillStyle = '#0c0c0b'; c.fillRect(0,0,W,H);
|
|
2932
|
+
const P = st.corners[i], M = H / (2*stripHalf);
|
|
2933
|
+
c.save();
|
|
2934
|
+
c.setTransform(M, 0, 0, M, W/2 - P[0]*M, H/2 - P[1]*M);
|
|
2935
|
+
c.imageSmoothingEnabled = true;
|
|
2936
|
+
c.drawImage(img, 0, 0);
|
|
2937
|
+
c.restore();
|
|
2938
|
+
if (stripHC) stretchContrast(c, W, H);
|
|
2939
|
+
const toS = q => [W/2 + (q[0]-P[0])*M, H/2 + (q[1]-P[1])*M];
|
|
2940
|
+
const prev = st.corners[(i+3)%4], next = st.corners[(i+1)%4];
|
|
2941
|
+
for (const q of [prev, next]){
|
|
2942
|
+
const e = toS(q), d = unit(sub(e, [W/2, H/2]));
|
|
2943
|
+
const s0 = add([W/2, H/2], mul(d, HANDLE_GAP_S)), s1 = add([W/2, H/2], mul(d, 4000));
|
|
2944
|
+
cased(c, () => { c.beginPath(); c.moveTo(s0[0], s0[1]); c.lineTo(s1[0], s1[1]); }, QUAD_W, CORE_QUAD);
|
|
2945
|
+
}
|
|
2946
|
+
casedRing(c, W/2, H/2, 4, CORE_QUAD);
|
|
2947
|
+
c.font = '600 11px system-ui'; c.textBaseline = 'middle';
|
|
2948
|
+
casedText(c, LBL[i], W/2 + 12, H/2 - 12, 'rgba(255,255,255,.95)');
|
|
2949
|
+
}
|
|
2950
|
+
const HANDLE_GAP_S = 4 + 1 + CASE_PAD/2 + 1; // the same break the canvas leaves around a ring
|
|
2923
2951
|
function drawStrip(){
|
|
2924
2952
|
const a = drag || hover || pick;
|
|
2925
|
-
const has = !!(st.corners && a && a.kind === 'edge' && img.naturalWidth);
|
|
2953
|
+
const has = !!(st.corners && a && (a.kind === 'edge' || a.kind === 'corner') && img.naturalWidth);
|
|
2954
|
+
const corner = has && a.kind === 'corner';
|
|
2926
2955
|
$('#stripZ').textContent = '±' + stripHalf + 'px';
|
|
2927
2956
|
if (loupeMode === 'dock'){
|
|
2928
2957
|
$('#floatLoupe').classList.remove('on');
|
|
@@ -2932,13 +2961,23 @@ function drawStrip(){
|
|
|
2932
2961
|
$('#stripSt').textContent = 'Hover or drag an edge — this straightens it, so misalignment shows as a wedge.';
|
|
2933
2962
|
return;
|
|
2934
2963
|
}
|
|
2935
|
-
|
|
2936
|
-
|
|
2964
|
+
if (corner){
|
|
2965
|
+
paintCorner(sctx, W, H, a.i);
|
|
2966
|
+
$('#stripSt').textContent = `${LBL[a.i]} corner · ${(H/(2*stripHalf)).toFixed(1)}× — where the two edges meet`;
|
|
2967
|
+
} else {
|
|
2968
|
+
paintStrip(sctx, W, H, a.i, a.mode);
|
|
2969
|
+
$('#stripSt').textContent = `${EDGE_LBL[a.i]} edge · ±${stripHalf}px · ${(H/(2*stripHalf)).toFixed(1)}× across the boundary — ${swingText(a.mode)}`;
|
|
2970
|
+
}
|
|
2937
2971
|
} else {
|
|
2938
2972
|
const fl = $('#floatLoupe');
|
|
2939
2973
|
if (!has){ fl.classList.remove('on'); return; }
|
|
2940
|
-
|
|
2941
|
-
|
|
2974
|
+
if (corner){
|
|
2975
|
+
paintCorner(fctx, stripF.width, stripF.height, a.i);
|
|
2976
|
+
$('#stripFCap').textContent = `${LBL[a.i]} corner · ${(stripF.height/(2*stripHalf)).toFixed(1)}× — where the two edges meet`;
|
|
2977
|
+
} else {
|
|
2978
|
+
paintStrip(fctx, stripF.width, stripF.height, a.i, a.mode);
|
|
2979
|
+
$('#stripFCap').textContent = `${EDGE_LBL[a.i]} edge · ±${stripHalf}px — ${swingText(a.mode)}`;
|
|
2980
|
+
}
|
|
2942
2981
|
fl.classList.add('on');
|
|
2943
2982
|
const w = fl.offsetWidth || 356, h = fl.offsetHeight || 130;
|
|
2944
2983
|
let x = lastPointer[0] + 26, y = lastPointer[1] - h - 18;
|