screengraft 0.66.0 → 0.68.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 +1 -1
- package/scripts/detect.py +133 -5
- package/scripts/grade.py +38 -7
- package/scripts/ui.py +70 -10
- package/scripts/warp.py +36 -8
- package/skills/inject-screenshot/SKILL.md +1 -1
- package/ui/index.html +585 -108
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "screengraft",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.68.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/detect.py
CHANGED
|
@@ -1037,6 +1037,112 @@ def has_rounded_corners(result) -> bool:
|
|
|
1037
1037
|
return spread <= MAX_RADIUS_SPREAD
|
|
1038
1038
|
|
|
1039
1039
|
|
|
1040
|
+
# ---------------------------------------------------------------------------
|
|
1041
|
+
# Working resolution.
|
|
1042
|
+
#
|
|
1043
|
+
# Detection ran at whatever resolution the photograph happened to be, and the
|
|
1044
|
+
# cost is quadratic: a 4000x4000 photo measured **11.24s** on 20 Sep 2026,
|
|
1045
|
+
# against 0.15s for the browser to fetch and decode the same file. Eleven
|
|
1046
|
+
# seconds of a designer's attention, every time they pick a big photo.
|
|
1047
|
+
#
|
|
1048
|
+
# The cap is **2400px on the short side, measured here** -- not the JS port's
|
|
1049
|
+
# 1600. That difference is the whole story of this change, so it is written
|
|
1050
|
+
# down rather than assumed.
|
|
1051
|
+
#
|
|
1052
|
+
# figma/src/engine/detect.js caps at 1600 and benches 27/27/0 there, so 1600
|
|
1053
|
+
# was the obvious number to copy. Swept over the 9 photographs in the corpus
|
|
1054
|
+
# whose short side exceeds the cap, comparing each capped answer against the
|
|
1055
|
+
# same photograph's full-resolution answer:
|
|
1056
|
+
#
|
|
1057
|
+
# cap total s worst quad shift worst radius shift
|
|
1058
|
+
# full 41.2 -- --
|
|
1059
|
+
# 1600 7.9 3.57% 19.3%
|
|
1060
|
+
# 2000 12.6 3.43% 18.6%
|
|
1061
|
+
# 2400 15.1 3.58% 5.0%
|
|
1062
|
+
# 3000 24.2 0.02% 0.4%
|
|
1063
|
+
#
|
|
1064
|
+
# 1600 moves the MEASURED CORNER RADIUS by 19% on both 4000x4000 photographs,
|
|
1065
|
+
# and the radius is not a starting position a person corrects -- it is the
|
|
1066
|
+
# number that shapes the corner mask on the saved composite. 2400 takes that to
|
|
1067
|
+
# 0.2% on those two while keeping 4K detection at ~1.7s against 13.3s.
|
|
1068
|
+
#
|
|
1069
|
+
# The residual 3.58% at 2400 is ONE photograph, iPhone-10_17-pro, which shifts
|
|
1070
|
+
# by ~3.5% at every cap below its own size -- it is scale-sensitive rather than
|
|
1071
|
+
# cap-sensitive, so there is no cap that fixes it short of not downscaling. It
|
|
1072
|
+
# stays inside the bench's 5%-of-screen-width band, and the quad is a starting
|
|
1073
|
+
# position the fit pane exists to correct.
|
|
1074
|
+
#
|
|
1075
|
+
# Why not the JS port's 1600, then? Because the two are not measuring the same
|
|
1076
|
+
# thing: that bench scores quads against human labels, and a 19% radius error
|
|
1077
|
+
# does not show up in a quad score at all. Worth re-checking the JS side.
|
|
1078
|
+
#
|
|
1079
|
+
# What this does NOT touch: the composite. Everything downstream -- the warp,
|
|
1080
|
+
# the grade, the corner mask, the saved file -- still works from the full
|
|
1081
|
+
# resolution photograph. This is a cap on the SEARCH, not on the output, which
|
|
1082
|
+
# is the whole point: on request, 21 Sep 2026 -- downscale for detection, show
|
|
1083
|
+
# full resolution in the preview.
|
|
1084
|
+
#
|
|
1085
|
+
# The pipeline runs entirely in working coordinates so that every internal
|
|
1086
|
+
# threshold, area ratio and diagonal stays self-consistent; only the geometry
|
|
1087
|
+
# that leaves detect() is scaled back. Radii come back too -- `photo_px` and
|
|
1088
|
+
# `per_corner_px` are pixel counts and must mean full-resolution pixels to
|
|
1089
|
+
# their caller -- while `frac_of_width` is a ratio and is already correct at
|
|
1090
|
+
# any scale, which is the reason the UI prefers it.
|
|
1091
|
+
#
|
|
1092
|
+
# INTER_AREA, not the default: downscaling with bilinear aliases, and an
|
|
1093
|
+
# aliased edge is precisely what an edge detector is looking at.
|
|
1094
|
+
# Overridable so the A/B above can be re-run without editing this file:
|
|
1095
|
+
# SCREENGRAFT_DETECT_MAX=0 detect at full resolution (the old behaviour)
|
|
1096
|
+
# SCREENGRAFT_DETECT_MAX=1600 the JS port's cap
|
|
1097
|
+
# Unset is the shipped 2400.
|
|
1098
|
+
WORK_MAX_SHORT = int(os.environ.get("SCREENGRAFT_DETECT_MAX", "2400") or 0) or None
|
|
1099
|
+
|
|
1100
|
+
|
|
1101
|
+
def work_scale(shape) -> float:
|
|
1102
|
+
"""Factor to multiply the photograph by before searching it. 1.0 = as-is."""
|
|
1103
|
+
h, w = shape[:2]
|
|
1104
|
+
short = min(int(h), int(w))
|
|
1105
|
+
# A 10% deadband, so a photograph a little over the cap is left alone. A
|
|
1106
|
+
# 1603px short side would otherwise be resampled to 1600 -- a 0.2% saving,
|
|
1107
|
+
# paid for with a resize and a different set of pixels under the detector,
|
|
1108
|
+
# which is the worst trade available: all of the risk of a change and none
|
|
1109
|
+
# of the benefit.
|
|
1110
|
+
if not WORK_MAX_SHORT or short <= WORK_MAX_SHORT * 1.1:
|
|
1111
|
+
return 1.0
|
|
1112
|
+
return float(WORK_MAX_SHORT) / float(short)
|
|
1113
|
+
|
|
1114
|
+
|
|
1115
|
+
def _shrink(img, s):
|
|
1116
|
+
if img is None or s >= 1.0:
|
|
1117
|
+
return img
|
|
1118
|
+
return cv2.resize(img, None, fx=s, fy=s, interpolation=cv2.INTER_AREA)
|
|
1119
|
+
|
|
1120
|
+
|
|
1121
|
+
def _scale_quad(quad, k):
|
|
1122
|
+
return [[round(float(x) * k, 1), round(float(y) * k, 1)] for x, y in quad]
|
|
1123
|
+
|
|
1124
|
+
|
|
1125
|
+
def _rescale_result(r, k):
|
|
1126
|
+
"""Put a result found in working coordinates back into photograph pixels."""
|
|
1127
|
+
if r is None or k == 1.0:
|
|
1128
|
+
return r
|
|
1129
|
+
r["corners"] = _scale_quad(r["corners"], k)
|
|
1130
|
+
if r.get("_corners_np") is not None:
|
|
1131
|
+
r["_corners_np"] = np.asarray(r["_corners_np"], dtype=float) * k
|
|
1132
|
+
cr = r.get("corner_radius")
|
|
1133
|
+
if cr:
|
|
1134
|
+
# frac_of_width is deliberately NOT touched: it is r/width, and both
|
|
1135
|
+
# scaled by the same factor, so it survived the downscale unchanged.
|
|
1136
|
+
if cr.get("photo_px") is not None:
|
|
1137
|
+
cr["photo_px"] = round(float(cr["photo_px"]) * k, 1)
|
|
1138
|
+
if cr.get("per_corner_px"):
|
|
1139
|
+
cr["per_corner_px"] = [round(float(v) * k, 1) for v in cr["per_corner_px"]]
|
|
1140
|
+
for o in (r.get("other") or []):
|
|
1141
|
+
if o.get("corners"):
|
|
1142
|
+
o["corners"] = _scale_quad(o["corners"], k)
|
|
1143
|
+
return r
|
|
1144
|
+
|
|
1145
|
+
|
|
1040
1146
|
def detect(gray: np.ndarray, tone=None, method="auto", color=None, click=None,
|
|
1041
1147
|
trace=None):
|
|
1042
1148
|
"""Run both detectors; arbitrate on how the two quads nest.
|
|
@@ -1067,23 +1173,45 @@ def detect(gray: np.ndarray, tone=None, method="auto", color=None, click=None,
|
|
|
1067
1173
|
# channel generated. It is an OBSERVER: nothing downstream reads it,
|
|
1068
1174
|
# and the answer is identical with and without -- which is asserted, because
|
|
1069
1175
|
# an instrument that perturbs what it measures is worse than none.
|
|
1176
|
+
# Search a capped copy; see WORK_MAX_SHORT above. Everything below this
|
|
1177
|
+
# point -- including the click, the shape handed to arbitrate(), and every
|
|
1178
|
+
# trace row -- is in WORKING coordinates, and the single conversion back
|
|
1179
|
+
# happens on the way out.
|
|
1180
|
+
s = work_scale(gray.shape)
|
|
1181
|
+
g = _shrink(gray, s)
|
|
1182
|
+
col = _shrink(color, s) if color is not None else None
|
|
1183
|
+
clk = [click[0] * s, click[1] * s] if (click is not None and s < 1.0) else click
|
|
1184
|
+
|
|
1070
1185
|
results = []
|
|
1071
1186
|
if method in ("auto", "tone"):
|
|
1072
|
-
r = detect_tone(
|
|
1187
|
+
r = detect_tone(g, tone, click=clk, trace=trace)
|
|
1073
1188
|
if r:
|
|
1074
1189
|
results.append(r)
|
|
1075
1190
|
if method in ("auto", "edge") and tone is None:
|
|
1076
|
-
r = detect_edges(
|
|
1191
|
+
r = detect_edges(g, click=clk, trace=trace)
|
|
1077
1192
|
if r:
|
|
1078
1193
|
results.append(r)
|
|
1079
|
-
if method in ("auto", "saturation") and tone is None and
|
|
1080
|
-
r = detect_saturation(
|
|
1194
|
+
if method in ("auto", "saturation") and tone is None and col is not None:
|
|
1195
|
+
r = detect_saturation(col, click=clk, trace=trace)
|
|
1081
1196
|
if r:
|
|
1082
1197
|
results.append(r)
|
|
1083
1198
|
|
|
1199
|
+
k = 1.0 / s if s < 1.0 else 1.0
|
|
1200
|
+
if trace is not None and k != 1.0:
|
|
1201
|
+
# The trace is an observer and nothing downstream reads it, but a
|
|
1202
|
+
# debugging artefact in a coordinate space the photograph does not use
|
|
1203
|
+
# is a trap for whoever opens it next.
|
|
1204
|
+
for row in trace:
|
|
1205
|
+
if row.get("quad"):
|
|
1206
|
+
row["quad"] = _scale_quad(row["quad"], k)
|
|
1207
|
+
|
|
1084
1208
|
if not results:
|
|
1085
1209
|
return None
|
|
1086
|
-
|
|
1210
|
+
out = arbitrate(results, g.shape[:2], clk)
|
|
1211
|
+
if out is not None:
|
|
1212
|
+
out["work_scale"] = round(s, 4)
|
|
1213
|
+
_rescale_result(out, k)
|
|
1214
|
+
return out
|
|
1087
1215
|
|
|
1088
1216
|
|
|
1089
1217
|
def arbitrate(results, shape, click=None):
|
package/scripts/grade.py
CHANGED
|
@@ -62,7 +62,8 @@ def _stats(lab: np.ndarray, sel: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
|
|
62
62
|
|
|
63
63
|
|
|
64
64
|
def light_params(photo: np.ndarray, warped: np.ndarray, mask: np.ndarray,
|
|
65
|
-
strength: float = DEFAULT_STRENGTH
|
|
65
|
+
strength: float = DEFAULT_STRENGTH,
|
|
66
|
+
light: float = None, colour: float = None):
|
|
66
67
|
"""Measure the correction ONCE, so it can be applied to many frames.
|
|
67
68
|
|
|
68
69
|
Split out of match_light for video. The correction depends on the
|
|
@@ -74,7 +75,30 @@ def light_params(photo: np.ndarray, warped: np.ndarray, mask: np.ndarray,
|
|
|
74
75
|
Returns None when there is too little context to measure honestly, which
|
|
75
76
|
the caller must treat as "leave the frame alone".
|
|
76
77
|
"""
|
|
77
|
-
|
|
78
|
+
# LIGHT and COLOUR are the same measurement, weighted separately.
|
|
79
|
+
#
|
|
80
|
+
# Split on request, 21 Sep 2026, from a real finding: with realism on, a
|
|
81
|
+
# brand orange visibly shifted toward coral and a pure-black status bar
|
|
82
|
+
# lifted to slate. Both are CORRECT -- nothing on a real phone renders #000
|
|
83
|
+
# -- but one control was doing two jobs, and the audience for this tool
|
|
84
|
+
# cares about exact hex. What moved the orange is the chroma match, not the
|
|
85
|
+
# exposure shift, so the useful cut is exactly the one this function
|
|
86
|
+
# already makes internally:
|
|
87
|
+
#
|
|
88
|
+
# light -> L only: a bounded mean shift. Sits the screen in the scene's
|
|
89
|
+
# exposure and leaves hue alone.
|
|
90
|
+
# colour -> a,b only: mean AND spread. The white-balance and saturation
|
|
91
|
+
# match, and the one that moved the orange.
|
|
92
|
+
#
|
|
93
|
+
# Nothing about the arithmetic changed; only who scales which half.
|
|
94
|
+
#
|
|
95
|
+
# THE COMPATIBILITY CONTRACT: `strength` alone still works and still means
|
|
96
|
+
# what it meant. When light/colour are not given they ARE strength, so an
|
|
97
|
+
# old sidecar -- which carries `grade` and knows nothing of this split --
|
|
98
|
+
# replays through identical multiplications. test_sidecar.py pins it.
|
|
99
|
+
wL = float(strength if light is None else light)
|
|
100
|
+
wC = float(strength if colour is None else colour)
|
|
101
|
+
if wL <= 0 and wC <= 0:
|
|
78
102
|
return None
|
|
79
103
|
ring = surround_ring(mask)
|
|
80
104
|
if int(ring.sum()) < 500:
|
|
@@ -88,10 +112,13 @@ def light_params(photo: np.ndarray, warped: np.ndarray, mask: np.ndarray,
|
|
|
88
112
|
m_in, s_in = _stats(lab_warp, inside)
|
|
89
113
|
return {
|
|
90
114
|
"m_in": m_in, "s_in": s_in, "m_out": m_out, "s_out": s_out,
|
|
91
|
-
|
|
115
|
+
# Kept under its old name and still the chroma weight, so apply_light()
|
|
116
|
+
# on a params dict from anywhere behaves as it always did.
|
|
117
|
+
"strength": wC,
|
|
118
|
+
"light": wL, "colour": wC,
|
|
92
119
|
# Same clamp as match_light: a screen is emissive and may be brighter
|
|
93
120
|
# than the room, so L moves by a bounded mean shift only.
|
|
94
|
-
"dL": float(np.clip(m_out[0] - m_in[0], -12.0, 12.0)) *
|
|
121
|
+
"dL": float(np.clip(m_out[0] - m_in[0], -12.0, 12.0)) * wL,
|
|
95
122
|
}
|
|
96
123
|
|
|
97
124
|
|
|
@@ -102,7 +129,9 @@ def apply_light(warped: np.ndarray, params) -> np.ndarray:
|
|
|
102
129
|
lab_warp = cv2.cvtColor(warped, cv2.COLOR_BGR2LAB).astype(np.float64)
|
|
103
130
|
m_in, s_in = params["m_in"], params["s_in"]
|
|
104
131
|
m_out, s_out = params["m_out"], params["s_out"]
|
|
105
|
-
|
|
132
|
+
# The chroma weight. `colour` when the params came from the split path,
|
|
133
|
+
# `strength` for any dict built before it existed.
|
|
134
|
+
strength = float(params.get("colour", params["strength"]))
|
|
106
135
|
out = lab_warp.copy()
|
|
107
136
|
for c in (1, 2):
|
|
108
137
|
moved = (lab_warp[:, :, c] - m_in[c]) * float(s_out[c] / s_in[c]) + m_out[c]
|
|
@@ -114,7 +143,8 @@ def apply_light(warped: np.ndarray, params) -> np.ndarray:
|
|
|
114
143
|
|
|
115
144
|
|
|
116
145
|
def match_light(photo: np.ndarray, warped: np.ndarray, mask: np.ndarray,
|
|
117
|
-
strength: float = DEFAULT_STRENGTH
|
|
146
|
+
strength: float = DEFAULT_STRENGTH,
|
|
147
|
+
light: float = None, colour: float = None) -> np.ndarray:
|
|
118
148
|
"""Move the injected screen's cast and exposure toward the surrounding light.
|
|
119
149
|
|
|
120
150
|
Chroma (a,b) is matched on mean AND spread — a cast is exactly a chroma mean
|
|
@@ -127,7 +157,8 @@ def match_light(photo: np.ndarray, warped: np.ndarray, mask: np.ndarray,
|
|
|
127
157
|
# One implementation, two entry points: measuring and applying are the same
|
|
128
158
|
# arithmetic whether it runs on a still or on frame 900 of a clip. Keeping a
|
|
129
159
|
# second copy here is how the two paths would drift.
|
|
130
|
-
return apply_light(warped, light_params(photo, warped, mask, strength
|
|
160
|
+
return apply_light(warped, light_params(photo, warped, mask, strength,
|
|
161
|
+
light=light, colour=colour))
|
|
131
162
|
|
|
132
163
|
|
|
133
164
|
GRAIN_GATE = 20.0 # grey levels: above this a residual is an edge, not grain
|
package/scripts/ui.py
CHANGED
|
@@ -587,10 +587,32 @@ PREVIEW_WIDTH = 720
|
|
|
587
587
|
PREVIEW_SECONDS = 6.0
|
|
588
588
|
|
|
589
589
|
|
|
590
|
+
def _grade_args(b):
|
|
591
|
+
"""The realism weights: one legacy value, and the two halves that split it.
|
|
592
|
+
|
|
593
|
+
`grade` stays the single master, and it is what every sidecar written
|
|
594
|
+
before 21 Sep 2026 carries. `grade_light` and `grade_colour` weight the two
|
|
595
|
+
halves separately when the page sends them; ABSENT means "same as grade",
|
|
596
|
+
which is precisely what makes an old sidecar replay byte for byte rather
|
|
597
|
+
than merely closely (grade.light_params does the same defaulting, and
|
|
598
|
+
test_grade.py pins the identity at four strengths).
|
|
599
|
+
|
|
600
|
+
The page sends all three: `grade` as max(light, colour), so every
|
|
601
|
+
downstream default keyed on `grade > 0` -- grain, chiefly -- still means
|
|
602
|
+
"is the realism pass doing anything at all".
|
|
603
|
+
"""
|
|
604
|
+
gr = float(b.get("grade") if b.get("grade") is not None else 0.0)
|
|
605
|
+
gl = b.get("grade_light")
|
|
606
|
+
gc = b.get("grade_colour")
|
|
607
|
+
return (gr,
|
|
608
|
+
None if gl is None else float(gl),
|
|
609
|
+
None if gc is None else float(gc))
|
|
610
|
+
|
|
611
|
+
|
|
590
612
|
def _render_worker(photo, video_path, corners, dest, radius_px, gr, grain, preset, fit_frame,
|
|
591
613
|
blend="replace", reflection=None, result=None, kind="render",
|
|
592
614
|
start_frame=0, max_frames=None, *, smoothing=0.0, dof=None,
|
|
593
|
-
grain_gain=1.0):
|
|
615
|
+
grain_gain=1.0, grade_light=None, grade_colour=None):
|
|
594
616
|
"""Encode the clip, and only if that SUCCEEDS publish what it produced.
|
|
595
617
|
|
|
596
618
|
`result` is the sidecar this render would write. It is handed to the worker
|
|
@@ -610,7 +632,9 @@ def _render_worker(photo, video_path, corners, dest, radius_px, gr, grain, prese
|
|
|
610
632
|
try:
|
|
611
633
|
info = W.compose_video(photo, video_path, corners, dest,
|
|
612
634
|
corner_radius=radius_px, corner_smoothing=smoothing,
|
|
613
|
-
grade=gr,
|
|
635
|
+
grade=gr, grade_light=grade_light,
|
|
636
|
+
grade_colour=grade_colour,
|
|
637
|
+
grain=grain, grain_gain=grain_gain,
|
|
614
638
|
preset=preset, fit_frame=fit_frame, progress=progress,
|
|
615
639
|
blend=blend,
|
|
616
640
|
reflection=(W.DEFAULT_REFLECTION if reflection is None
|
|
@@ -1077,6 +1101,35 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1077
1101
|
except RenderBusy as exc:
|
|
1078
1102
|
return self._json({"error": str(exc)}, 409)
|
|
1079
1103
|
|
|
1104
|
+
if u.path == "/api/reveal":
|
|
1105
|
+
# Show a saved file in the desktop's file manager. The PAGE
|
|
1106
|
+
# cannot do this -- a browser will not open a Finder window
|
|
1107
|
+
# from an http origin -- and this server is the half of
|
|
1108
|
+
# screengraft that runs on the user's machine, so it is the
|
|
1109
|
+
# only thing that can.
|
|
1110
|
+
#
|
|
1111
|
+
# _safe_local_path is the whole security story and it already
|
|
1112
|
+
# guards /file: it expands ~, resolves symlinks, refuses any
|
|
1113
|
+
# path outside HOME, and requires a file that exists. The
|
|
1114
|
+
# command is an argument LIST handed to the OS, never a string
|
|
1115
|
+
# through a shell, so a path cannot become an argument or a
|
|
1116
|
+
# second command however it is spelled.
|
|
1117
|
+
try:
|
|
1118
|
+
p = _safe_local_path(b.get("path") or "")
|
|
1119
|
+
except (PermissionError, FileNotFoundError, TypeError) as exc:
|
|
1120
|
+
return self._json({"error": str(exc)}, 400)
|
|
1121
|
+
if sys.platform == "darwin":
|
|
1122
|
+
cmd = ["open", "-R", p] # reveals AND selects it
|
|
1123
|
+
elif os.name == "nt":
|
|
1124
|
+
cmd = ["explorer", "/select,", p] # exits 1 even on success
|
|
1125
|
+
else:
|
|
1126
|
+
cmd = ["xdg-open", os.path.dirname(p)]
|
|
1127
|
+
try:
|
|
1128
|
+
subprocess.run(cmd, capture_output=True, timeout=10, check=False)
|
|
1129
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
1130
|
+
return self._json({"error": f"could not open the folder: {exc}"}, 500)
|
|
1131
|
+
return self._json({"ok": True})
|
|
1132
|
+
|
|
1080
1133
|
if u.path == "/api/figma":
|
|
1081
1134
|
return self._json(SESSION.enqueue({
|
|
1082
1135
|
"type": "figma_export", "url": b["url"],
|
|
@@ -1274,7 +1327,7 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1274
1327
|
else _fit_frame())
|
|
1275
1328
|
first = W.read_frame_at(spath, fit_frame)
|
|
1276
1329
|
radius_px = frac * first.shape[1]
|
|
1277
|
-
gr =
|
|
1330
|
+
gr, gl, gc = _grade_args(b)
|
|
1278
1331
|
grain = bool(b.get("grain", gr > 0))
|
|
1279
1332
|
blend, reflection = _blend_args(b)
|
|
1280
1333
|
# Downscale the PHOTO and scale the quad with it, rather than
|
|
@@ -1315,7 +1368,9 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1315
1368
|
fit_frame, max_frames),
|
|
1316
1369
|
kwargs={"smoothing": _smoothing(b),
|
|
1317
1370
|
"dof": _dof_args(b),
|
|
1318
|
-
"grain_gain": _grain_gain(b)
|
|
1371
|
+
"grain_gain": _grain_gain(b),
|
|
1372
|
+
"grade_light": gl,
|
|
1373
|
+
"grade_colour": gc}).start()
|
|
1319
1374
|
except BaseException:
|
|
1320
1375
|
with RENDER_LOCK:
|
|
1321
1376
|
RENDER.update(state="error", message="could not start the preview")
|
|
@@ -1346,7 +1401,7 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1346
1401
|
else _fit_frame())
|
|
1347
1402
|
first = W.read_frame_at(spath, fit_frame)
|
|
1348
1403
|
radius_px = frac * first.shape[1]
|
|
1349
|
-
gr =
|
|
1404
|
+
gr, gl, gc = _grade_args(b)
|
|
1350
1405
|
grain = bool(b.get("grain", gr > 0))
|
|
1351
1406
|
blend, reflection = _blend_args(b)
|
|
1352
1407
|
dof = _dof_args(b)
|
|
@@ -1368,7 +1423,8 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1368
1423
|
result = {"output": dest, "photo": ppath, "screenshot": spath,
|
|
1369
1424
|
"corners": corners, "radius_frac": frac, "radius_px": radius_px,
|
|
1370
1425
|
"device": b.get("device"), "corner_smoothing": _smoothing(b),
|
|
1371
|
-
"grade": gr, "
|
|
1426
|
+
"grade": gr, "grade_light": gl, "grade_colour": gc,
|
|
1427
|
+
"grain": grain, "grain_gain": _grain_gain(b),
|
|
1372
1428
|
"video": True, "preset": preset, "fit_frame": fit_frame,
|
|
1373
1429
|
"blend": blend, "reflection": reflection,
|
|
1374
1430
|
"dof_angle": dof["dof_angle"], "dof_strength": dof["dof_strength"],
|
|
@@ -1402,7 +1458,9 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1402
1458
|
blend, reflection, result),
|
|
1403
1459
|
kwargs={"smoothing": _smoothing(b),
|
|
1404
1460
|
"dof": dof,
|
|
1405
|
-
"grain_gain": result["grain_gain"]
|
|
1461
|
+
"grain_gain": result["grain_gain"],
|
|
1462
|
+
"grade_light": result["grade_light"],
|
|
1463
|
+
"grade_colour": result["grade_colour"]}).start()
|
|
1406
1464
|
except BaseException:
|
|
1407
1465
|
# If the thread cannot even be created, the flag must not
|
|
1408
1466
|
# outlive the request.
|
|
@@ -1422,14 +1480,15 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1422
1480
|
# composite is the right output when the screenshot's own colour
|
|
1423
1481
|
# is the point (a brand review), and the grade is the right one
|
|
1424
1482
|
# when the photograph is (a portfolio shot).
|
|
1425
|
-
gr =
|
|
1483
|
+
gr, gl, gc = _grade_args(b)
|
|
1426
1484
|
blend, reflection = _blend_args(b)
|
|
1427
1485
|
smoothing = _smoothing(b)
|
|
1428
1486
|
dof = _dof_args(b)
|
|
1429
1487
|
grain_gain = _grain_gain(b)
|
|
1430
1488
|
out = W.compose(photo, shot, corners, radius_px,
|
|
1431
1489
|
corner_smoothing=smoothing,
|
|
1432
|
-
grade=gr,
|
|
1490
|
+
grade=gr, grade_light=gl, grade_colour=gc,
|
|
1491
|
+
grain=bool(b.get("grain", gr > 0)),
|
|
1433
1492
|
grain_gain=grain_gain,
|
|
1434
1493
|
blend=blend, reflection=reflection, **dof)
|
|
1435
1494
|
SESSION.update(corners=corners, radius_frac=frac, device=b.get("device"),
|
|
@@ -1465,7 +1524,8 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1465
1524
|
result = {"output": dest, "photo": ppath, "screenshot": spath, "corners": corners,
|
|
1466
1525
|
"radius_frac": frac, "radius_px": radius_px, "device": b.get("device"),
|
|
1467
1526
|
"corner_smoothing": smoothing,
|
|
1468
|
-
"grade": gr, "
|
|
1527
|
+
"grade": gr, "grade_light": gl, "grade_colour": gc,
|
|
1528
|
+
"grain": bool(b.get("grain", gr > 0)),
|
|
1469
1529
|
"grain_gain": grain_gain,
|
|
1470
1530
|
"blend": blend, "reflection": reflection,
|
|
1471
1531
|
"dof_angle": dof["dof_angle"], "dof_strength": dof["dof_strength"],
|
package/scripts/warp.py
CHANGED
|
@@ -366,10 +366,19 @@ class Plan:
|
|
|
366
366
|
flags=cv2.INTER_LANCZOS4,
|
|
367
367
|
borderMode=cv2.BORDER_REPLICATE)
|
|
368
368
|
|
|
369
|
-
def bind_grade(self, frame: np.ndarray, strength: float
|
|
370
|
-
|
|
369
|
+
def bind_grade(self, frame: np.ndarray, strength: float,
|
|
370
|
+
light: float = None, colour: float = None) -> None:
|
|
371
|
+
"""Measure the light correction once, from the frame the user fitted on.
|
|
372
|
+
|
|
373
|
+
`light` and `colour` weight the two halves separately (see
|
|
374
|
+
grade.light_params). Left unset they both fall back to `strength`,
|
|
375
|
+
which is what every sidecar written before the split carries.
|
|
376
|
+
"""
|
|
377
|
+
wL = strength if light is None else light
|
|
378
|
+
wC = strength if colour is None else colour
|
|
371
379
|
self.grade_params = _grade.light_params(
|
|
372
|
-
self.photo, self._prep(frame), self.warped_mask, strength
|
|
380
|
+
self.photo, self._prep(frame), self.warped_mask, strength,
|
|
381
|
+
light=light, colour=colour) if (wL > 0 or wC > 0) else None
|
|
373
382
|
|
|
374
383
|
def _blend(self, photo_win, warped_win):
|
|
375
384
|
"""Emitted light over reflected light, or a plain replace.
|
|
@@ -444,7 +453,8 @@ class Plan:
|
|
|
444
453
|
|
|
445
454
|
def compose(photo: np.ndarray, screenshot: np.ndarray, corners, corner_radius: float = 0.0,
|
|
446
455
|
corner_smoothing: float = 0.0,
|
|
447
|
-
grade: float = 0.0,
|
|
456
|
+
grade: float = 0.0, grade_light: float = None, grade_colour: float = None,
|
|
457
|
+
grain: bool = False, screen_off: np.ndarray = None,
|
|
448
458
|
specular: float = 0.75, blend: str = "replace",
|
|
449
459
|
reflection: float = DEFAULT_REFLECTION,
|
|
450
460
|
dof_angle: float = 0.0, dof_strength: float = 0.0,
|
|
@@ -467,7 +477,7 @@ def compose(photo: np.ndarray, screenshot: np.ndarray, corners, corner_radius: f
|
|
|
467
477
|
dof_angle=dof_angle, dof_strength=dof_strength, dof_start=dof_start,
|
|
468
478
|
dof_end=dof_end, dof_space=dof_space, dof_end2=dof_end2,
|
|
469
479
|
grain_gain=grain_gain)
|
|
470
|
-
plan.bind_grade(screenshot, grade)
|
|
480
|
+
plan.bind_grade(screenshot, grade, light=grade_light, colour=grade_colour)
|
|
471
481
|
return plan.render(screenshot, screen_off=screen_off, specular=specular)
|
|
472
482
|
|
|
473
483
|
|
|
@@ -549,7 +559,8 @@ def read_frame_at(path: str, index: int = 0):
|
|
|
549
559
|
|
|
550
560
|
def compose_video(photo: np.ndarray, video_path: str, corners, output: str,
|
|
551
561
|
corner_radius: float = 0.0, corner_smoothing: float = 0.0,
|
|
552
|
-
grade: float = 0.0,
|
|
562
|
+
grade: float = 0.0, grade_light: float = None,
|
|
563
|
+
grade_colour: float = None, grain: bool = False,
|
|
553
564
|
preset: str = "web", fit_frame: int = 0, audio: bool = True,
|
|
554
565
|
frames_dir: str = None, progress=None, blend: str = "replace",
|
|
555
566
|
reflection: float = DEFAULT_REFLECTION,
|
|
@@ -588,9 +599,19 @@ def compose_video(photo: np.ndarray, video_path: str, corners, output: str,
|
|
|
588
599
|
dof_angle=dof_angle, dof_strength=dof_strength, dof_start=dof_start,
|
|
589
600
|
dof_end=dof_end, dof_space=dof_space, dof_end2=dof_end2,
|
|
590
601
|
grain_gain=grain_gain)
|
|
591
|
-
plan.bind_grade(first, grade)
|
|
602
|
+
plan.bind_grade(first, grade, light=grade_light, colour=grade_colour)
|
|
592
603
|
|
|
593
604
|
ph, pw = photo.shape[:2]
|
|
605
|
+
# H.264/yuv420p (and ProRes 422) subsample chroma 2x horizontally, and
|
|
606
|
+
# 4:2:0 vertically too, so an odd width or height is refused -- and ffmpeg
|
|
607
|
+
# refuses by dying mid-stream, which reaches us as "[Errno 32] Broken pipe"
|
|
608
|
+
# on the first frame write. The preview route evened its own proxy size
|
|
609
|
+
# (ui.py) but a full render runs at the PHOTO's size, so any photograph
|
|
610
|
+
# with an odd dimension failed to render at all (1424x879, 25 Sep 2026).
|
|
611
|
+
# Pad by replicating the last row/column: one duplicated edge pixel is
|
|
612
|
+
# invisible, whereas cropping would drop a row of the photograph.
|
|
613
|
+
pad_b, pad_r = ph % 2, pw % 2
|
|
614
|
+
ph, pw = ph + pad_b, pw + pad_r
|
|
594
615
|
cmd = [ffmpeg_exe(), "-y", "-loglevel", "error",
|
|
595
616
|
"-f", "rawvideo", "-pix_fmt", "bgr24", "-s", f"{pw}x{ph}",
|
|
596
617
|
"-r", f"{fps}", "-i", "-"]
|
|
@@ -632,7 +653,14 @@ def compose_video(photo: np.ndarray, video_path: str, corners, output: str,
|
|
|
632
653
|
if frames_dir:
|
|
633
654
|
cv2.imwrite(os.path.join(frames_dir, f"{count:06d}.png"), out,
|
|
634
655
|
[cv2.IMWRITE_PNG_COMPRESSION, 1])
|
|
635
|
-
|
|
656
|
+
if pad_b or pad_r:
|
|
657
|
+
out = cv2.copyMakeBorder(out, 0, pad_b, 0, pad_r, cv2.BORDER_REPLICATE)
|
|
658
|
+
try:
|
|
659
|
+
proc.stdin.write(out.tobytes())
|
|
660
|
+
except BrokenPipeError:
|
|
661
|
+
# ffmpeg has exited; its stderr, read below, says why. Raising
|
|
662
|
+
# the bare pipe error here would hide the one useful sentence.
|
|
663
|
+
break
|
|
636
664
|
count += 1
|
|
637
665
|
if progress and count % 10 == 0:
|
|
638
666
|
progress(count, total_hint)
|
|
@@ -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.68):** 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 matches the source to the photo's light, with **Light** (exposure only, hue untouched) and **Colour** (white balance and saturation) set separately, **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
|
|