screengraft 0.67.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 +41 -10
- package/scripts/warp.py +36 -8
- package/skills/inject-screenshot/SKILL.md +1 -1
- package/ui/index.html +93 -25
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
|
|
@@ -1303,7 +1327,7 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1303
1327
|
else _fit_frame())
|
|
1304
1328
|
first = W.read_frame_at(spath, fit_frame)
|
|
1305
1329
|
radius_px = frac * first.shape[1]
|
|
1306
|
-
gr =
|
|
1330
|
+
gr, gl, gc = _grade_args(b)
|
|
1307
1331
|
grain = bool(b.get("grain", gr > 0))
|
|
1308
1332
|
blend, reflection = _blend_args(b)
|
|
1309
1333
|
# Downscale the PHOTO and scale the quad with it, rather than
|
|
@@ -1344,7 +1368,9 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1344
1368
|
fit_frame, max_frames),
|
|
1345
1369
|
kwargs={"smoothing": _smoothing(b),
|
|
1346
1370
|
"dof": _dof_args(b),
|
|
1347
|
-
"grain_gain": _grain_gain(b)
|
|
1371
|
+
"grain_gain": _grain_gain(b),
|
|
1372
|
+
"grade_light": gl,
|
|
1373
|
+
"grade_colour": gc}).start()
|
|
1348
1374
|
except BaseException:
|
|
1349
1375
|
with RENDER_LOCK:
|
|
1350
1376
|
RENDER.update(state="error", message="could not start the preview")
|
|
@@ -1375,7 +1401,7 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1375
1401
|
else _fit_frame())
|
|
1376
1402
|
first = W.read_frame_at(spath, fit_frame)
|
|
1377
1403
|
radius_px = frac * first.shape[1]
|
|
1378
|
-
gr =
|
|
1404
|
+
gr, gl, gc = _grade_args(b)
|
|
1379
1405
|
grain = bool(b.get("grain", gr > 0))
|
|
1380
1406
|
blend, reflection = _blend_args(b)
|
|
1381
1407
|
dof = _dof_args(b)
|
|
@@ -1397,7 +1423,8 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1397
1423
|
result = {"output": dest, "photo": ppath, "screenshot": spath,
|
|
1398
1424
|
"corners": corners, "radius_frac": frac, "radius_px": radius_px,
|
|
1399
1425
|
"device": b.get("device"), "corner_smoothing": _smoothing(b),
|
|
1400
|
-
"grade": gr, "
|
|
1426
|
+
"grade": gr, "grade_light": gl, "grade_colour": gc,
|
|
1427
|
+
"grain": grain, "grain_gain": _grain_gain(b),
|
|
1401
1428
|
"video": True, "preset": preset, "fit_frame": fit_frame,
|
|
1402
1429
|
"blend": blend, "reflection": reflection,
|
|
1403
1430
|
"dof_angle": dof["dof_angle"], "dof_strength": dof["dof_strength"],
|
|
@@ -1431,7 +1458,9 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1431
1458
|
blend, reflection, result),
|
|
1432
1459
|
kwargs={"smoothing": _smoothing(b),
|
|
1433
1460
|
"dof": dof,
|
|
1434
|
-
"grain_gain": result["grain_gain"]
|
|
1461
|
+
"grain_gain": result["grain_gain"],
|
|
1462
|
+
"grade_light": result["grade_light"],
|
|
1463
|
+
"grade_colour": result["grade_colour"]}).start()
|
|
1435
1464
|
except BaseException:
|
|
1436
1465
|
# If the thread cannot even be created, the flag must not
|
|
1437
1466
|
# outlive the request.
|
|
@@ -1451,14 +1480,15 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1451
1480
|
# composite is the right output when the screenshot's own colour
|
|
1452
1481
|
# is the point (a brand review), and the grade is the right one
|
|
1453
1482
|
# when the photograph is (a portfolio shot).
|
|
1454
|
-
gr =
|
|
1483
|
+
gr, gl, gc = _grade_args(b)
|
|
1455
1484
|
blend, reflection = _blend_args(b)
|
|
1456
1485
|
smoothing = _smoothing(b)
|
|
1457
1486
|
dof = _dof_args(b)
|
|
1458
1487
|
grain_gain = _grain_gain(b)
|
|
1459
1488
|
out = W.compose(photo, shot, corners, radius_px,
|
|
1460
1489
|
corner_smoothing=smoothing,
|
|
1461
|
-
grade=gr,
|
|
1490
|
+
grade=gr, grade_light=gl, grade_colour=gc,
|
|
1491
|
+
grain=bool(b.get("grain", gr > 0)),
|
|
1462
1492
|
grain_gain=grain_gain,
|
|
1463
1493
|
blend=blend, reflection=reflection, **dof)
|
|
1464
1494
|
SESSION.update(corners=corners, radius_frac=frac, device=b.get("device"),
|
|
@@ -1494,7 +1524,8 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1494
1524
|
result = {"output": dest, "photo": ppath, "screenshot": spath, "corners": corners,
|
|
1495
1525
|
"radius_frac": frac, "radius_px": radius_px, "device": b.get("device"),
|
|
1496
1526
|
"corner_smoothing": smoothing,
|
|
1497
|
-
"grade": gr, "
|
|
1527
|
+
"grade": gr, "grade_light": gl, "grade_colour": gc,
|
|
1528
|
+
"grain": bool(b.get("grain", gr > 0)),
|
|
1498
1529
|
"grain_gain": grain_gain,
|
|
1499
1530
|
"blend": blend, "reflection": reflection,
|
|
1500
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
|
|
package/ui/index.html
CHANGED
|
@@ -1527,6 +1527,14 @@
|
|
|
1527
1527
|
/* The Keys legend. max-content on the first column sizes it to the widest
|
|
1528
1528
|
cap, so every description starts on the same x and the caps form a single
|
|
1529
1529
|
column the eye can run down. */
|
|
1530
|
+
/* Realism's two rows. label | slider | value, so the two read as a pair of
|
|
1531
|
+
the same control rather than two stacked blocks -- and it is shorter than
|
|
1532
|
+
the single stacked slider it replaces, on a rail that has no room. */
|
|
1533
|
+
.gr-row{display:grid;grid-template-columns:max-content 1fr max-content;
|
|
1534
|
+
align-items:center;gap:var(--s2)}
|
|
1535
|
+
.gr-row .gr-lbl{color:var(--mute)}
|
|
1536
|
+
.gr-row output{color:var(--ink);font-variant-numeric:tabular-nums;min-width:34px;
|
|
1537
|
+
text-align:right;font-size:12px}
|
|
1530
1538
|
dl.keys{display:grid;grid-template-columns:max-content 1fr;gap:6px 10px;
|
|
1531
1539
|
margin:8px 0 0;align-items:baseline}
|
|
1532
1540
|
dl.keys dt{color:var(--faint);font-size:12px;white-space:nowrap}
|
|
@@ -1702,16 +1710,30 @@
|
|
|
1702
1710
|
<div class="sect-top">
|
|
1703
1711
|
<div class="sect-left">
|
|
1704
1712
|
<h4>Realism</h4>
|
|
1705
|
-
<button class="info" type="button" aria-label="About realism"><svg viewBox="0 0 16 16" fill="none" aria-hidden="true"><path fill="currentColor" d="M8.36 1.01C12.06 1.2 15 4.26 15 8l-.01.36C14.8 12.06 11.74 15 8 15l-.36-.01C4.06 14.81 1.19 11.94 1.01 8.36L1 8c0-3.87 3.13-7 7-7l.36.01ZM8 2C4.69 2 2 4.69 2 8c0 3.31 2.69 6 6 6 3.31 0 6-2.69 6-6 0-3.31-2.69-6-6-6Zm.5 8l.5 0v1L7 11v-1h.5V7H7V6h1.5v4ZM7.9 4c.33 0 .6.27.6.6a.6.6 0 1 1-1.21 0c0-.33.28-.6.61-.6Z"/></svg><span class="tip" role="tooltip">
|
|
1713
|
+
<button class="info" type="button" aria-label="About realism"><svg viewBox="0 0 16 16" fill="none" aria-hidden="true"><path fill="currentColor" d="M8.36 1.01C12.06 1.2 15 4.26 15 8l-.01.36C14.8 12.06 11.74 15 8 15l-.36-.01C4.06 14.81 1.19 11.94 1.01 8.36L1 8c0-3.87 3.13-7 7-7l.36.01ZM8 2C4.69 2 2 4.69 2 8c0 3.31 2.69 6 6 6 3.31 0 6-2.69 6-6 0-3.31-2.69-6-6-6Zm.5 8l.5 0v1L7 11v-1h.5V7H7V6h1.5v4ZM7.9 4c.33 0 .6.27.6.6a.6.6 0 1 1-1.21 0c0-.33.28-.6.61-.6Z"/></svg><span class="tip" role="tooltip">Sits the screen in the light around it, so it stops reading as pasted. <b>Light</b> matches the scene's exposure and leaves hue alone. <b>Colour</b> matches its white balance — this is the one that warms a brand colour, so set it to 0 when an exact hex is the point and keep Light. Off does both. Grain is judged here at the preview's scale: the save carries about twice as much, which is what you get if you view the file at 100%.</span></button>
|
|
1706
1714
|
</div>
|
|
1707
1715
|
<button class="switch" id="gradeBtn" role="switch" aria-checked="true" aria-label="Realism">
|
|
1708
1716
|
<span class="sw-track"><span class="sw-handle"></span><span class="sw-grip"></span></span>
|
|
1709
1717
|
</button>
|
|
1710
1718
|
</div>
|
|
1711
1719
|
<div class="sect-body"><div class="sect-body-in">
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1720
|
+
<!-- Two halves of one measurement, not two features. See
|
|
1721
|
+
grade.light_params: Light is a bounded L shift, Colour is the
|
|
1722
|
+
chroma match. Colour starts at 0 in neither -- both inherit
|
|
1723
|
+
whatever single strength was remembered before the split. -->
|
|
1724
|
+
<div style="display:grid;gap:var(--s3)">
|
|
1725
|
+
<div class="gr-row">
|
|
1726
|
+
<span class="sm gr-lbl">Light</span>
|
|
1727
|
+
<input type="range" id="gradeLight" min="0" max="1" step="0.05" value="0.35"
|
|
1728
|
+
aria-label="Light — match the scene's exposure">
|
|
1729
|
+
<output for="gradeLight" id="gradeLightVal"></output>
|
|
1730
|
+
</div>
|
|
1731
|
+
<div class="gr-row">
|
|
1732
|
+
<span class="sm gr-lbl">Colour</span>
|
|
1733
|
+
<input type="range" id="gradeColour" min="0" max="1" step="0.05" value="0.35"
|
|
1734
|
+
aria-label="Colour — match the scene's white balance">
|
|
1735
|
+
<output for="gradeColour" id="gradeColourVal"></output>
|
|
1736
|
+
</div>
|
|
1715
1737
|
</div>
|
|
1716
1738
|
</div></div>
|
|
1717
1739
|
</div>
|
|
@@ -2306,15 +2328,26 @@ async function loadFitFile(file){
|
|
|
2306
2328
|
}
|
|
2307
2329
|
// Capture phase, so the per-role drop zones never see a fit file and try to
|
|
2308
2330
|
// decode it as an image.
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2331
|
+
// Is a file being dragged? Ask `types`, not `items`: Safari leaves `items`
|
|
2332
|
+
// empty during dragover, so an items-only test never cancelled the drag there,
|
|
2333
|
+
// the drop event never fired, and Safari navigated away to the dropped .json --
|
|
2334
|
+
// taking the whole workbench with it.
|
|
2335
|
+
function draggingFiles(e){
|
|
2336
|
+
const dt = e.dataTransfer; if (!dt) return false;
|
|
2337
|
+
if ([...(dt.types || [])].includes('Files')) return true;
|
|
2338
|
+
return [...(dt.items || [])].some(i => i.kind === 'file');
|
|
2339
|
+
}
|
|
2340
|
+
addEventListener('dragover', e => { if (draggingFiles(e)) e.preventDefault(); }, true);
|
|
2312
2341
|
addEventListener('drop', e => {
|
|
2313
2342
|
const f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0];
|
|
2314
2343
|
if (!f || !/\.json$/i.test(f.name)) return; // not ours: let the zones have it
|
|
2315
2344
|
e.preventDefault(); e.stopPropagation();
|
|
2316
2345
|
loadFitFile(f);
|
|
2317
2346
|
}, true);
|
|
2347
|
+
// A file dropped where nothing takes it must not become a navigation: the page
|
|
2348
|
+
// is the session, and leaving it loses the fit. Bubble phase, so the zones and
|
|
2349
|
+
// the fit loader above have already had their turn.
|
|
2350
|
+
addEventListener('drop', e => { if (draggingFiles(e)) e.preventDefault(); });
|
|
2318
2351
|
|
|
2319
2352
|
/* ===== pickers (popovers over the canvas) ===== */
|
|
2320
2353
|
// "Used 3 h ago", "used yesterday" -- when a source was last chosen.
|
|
@@ -2511,7 +2544,7 @@ for (const [n, role] of [[1,'photo'],[2,'screenshot']]){
|
|
|
2511
2544
|
// onto "Choose a photo" and it lands in that role without opening the
|
|
2512
2545
|
// popover. Same upload path as the popover's drop zone.
|
|
2513
2546
|
const chip = $('#chip'+n);
|
|
2514
|
-
chip.ondragover = e => { if (
|
|
2547
|
+
chip.ondragover = e => { if (draggingFiles(e)) { e.preventDefault(); chip.classList.add('over'); } };
|
|
2515
2548
|
// Leaving with a drag only un-lights the chip if its popover is not the
|
|
2516
2549
|
// reason it is lit.
|
|
2517
2550
|
const popOpen = () => $('#pop'+n).classList.contains('on');
|
|
@@ -3531,27 +3564,55 @@ function setSectionOpen(sect, on){
|
|
|
3531
3564
|
colour exactly, which is what you want when the UI's colour is the thing under
|
|
3532
3565
|
review. Strength is a slider because the right amount is a judgement about the
|
|
3533
3566
|
photograph, not a constant. */
|
|
3534
|
-
let gradeOn
|
|
3535
|
-
|
|
3536
|
-
|
|
3567
|
+
let gradeOn = recall('grade','1') === '1';
|
|
3568
|
+
/* One strength became two on 21 Sep 2026, and the reason is a real report: with
|
|
3569
|
+
realism on, a brand orange visibly moved toward coral and a pure-black status
|
|
3570
|
+
bar lifted to slate. Both are CORRECT -- nothing on a real phone renders #000
|
|
3571
|
+
-- but one control was doing two jobs and the people using this check hex
|
|
3572
|
+
values. The cut follows what grade.light_params already computes separately:
|
|
3573
|
+
|
|
3574
|
+
Light -> a bounded mean shift on L. Sits the screen in the scene's
|
|
3575
|
+
exposure and leaves hue alone (measured: d(a,b) 0.002).
|
|
3576
|
+
Colour -> the chroma match, mean and spread. The half that warmed the
|
|
3577
|
+
orange (measured: 12.98 against 0.00 with it off).
|
|
3578
|
+
|
|
3579
|
+
Anyone who had a strength saved keeps it: both halves inherit it, so the
|
|
3580
|
+
first render after this ships is the one they had. */
|
|
3581
|
+
const _oldAmt = parseFloat(recall('gradeAmt','0.35')) || 0.35;
|
|
3582
|
+
let gLight = parseFloat(recall('gradeLight', String(_oldAmt)));
|
|
3583
|
+
let gColour = parseFloat(recall('gradeColour', String(_oldAmt)));
|
|
3584
|
+
if (!(gLight >= 0)) gLight = _oldAmt;
|
|
3585
|
+
if (!(gColour >= 0)) gColour = _oldAmt;
|
|
3586
|
+
/* The master value. Everything server-side that asks "is the realism pass doing
|
|
3587
|
+
anything at all" -- grain, chiefly -- is keyed on `grade`, so it is the
|
|
3588
|
+
larger of the two rather than either one. */
|
|
3589
|
+
function gradeValue(){ return gradeOn ? Math.max(gLight, gColour) : 0; }
|
|
3590
|
+
function gradeLightValue(){ return gradeOn ? gLight : 0; }
|
|
3591
|
+
function gradeColourValue(){ return gradeOn ? gColour : 0; }
|
|
3537
3592
|
function paintGrade(){
|
|
3538
|
-
setSwitch($('#gradeBtn'), gradeOn); // hides the
|
|
3539
|
-
|
|
3540
|
-
|
|
3541
|
-
|
|
3542
|
-
|
|
3593
|
+
setSwitch($('#gradeBtn'), gradeOn); // hides the sliders when off
|
|
3594
|
+
for (const [id, v] of [['gradeLight', gLight], ['gradeColour', gColour]]){
|
|
3595
|
+
const sl = $('#' + id);
|
|
3596
|
+
sl.value = v;
|
|
3597
|
+
sl.style.setProperty('--p', v); // span is 0-1 now, so no rescale
|
|
3598
|
+
$('#' + id + 'Val').textContent = Math.round(v * 100) + '%';
|
|
3599
|
+
}
|
|
3543
3600
|
}
|
|
3544
|
-
function setGrade(on
|
|
3601
|
+
function setGrade(on){
|
|
3545
3602
|
gradeOn = on;
|
|
3546
|
-
|
|
3547
|
-
remember('
|
|
3603
|
+
remember('grade', on ? '1' : '0');
|
|
3604
|
+
remember('gradeLight', String(gLight));
|
|
3605
|
+
remember('gradeColour', String(gColour));
|
|
3548
3606
|
paintGrade();
|
|
3549
3607
|
}
|
|
3550
3608
|
$('#gradeBtn').onclick = () => { setGrade(!gradeOn); autoPreview(); };
|
|
3551
3609
|
// Repaint live while dragging, re-render only on release: a full compose per
|
|
3552
3610
|
// input event would queue renders faster than they finish.
|
|
3553
|
-
|
|
3554
|
-
|
|
3611
|
+
for (const [id, set] of [['gradeLight', v => gLight = v],
|
|
3612
|
+
['gradeColour', v => gColour = v]]){
|
|
3613
|
+
$('#' + id).oninput = e => { set(parseFloat(e.target.value)); paintGrade(); };
|
|
3614
|
+
$('#' + id).onchange = e => { set(parseFloat(e.target.value)); setGrade(true); autoPreview(); };
|
|
3615
|
+
}
|
|
3555
3616
|
|
|
3556
3617
|
/* Emissive screen. A display emits light AND reflects the room; `replace` models
|
|
3557
3618
|
only the first, which is why a true-black UI lands as a hole. Off by default so
|
|
@@ -4334,6 +4395,8 @@ async function playClip(){
|
|
|
4334
4395
|
try{
|
|
4335
4396
|
await api('/api/preview_video', {corners: st.corners, radius_frac: radiusValue(), smoothing: smoothingValue(), dof_angle: dofAngle(), dof_strength: dofStrength(), dof_start: dofStartV(), dof_end: dofEndV(), dof_end2: dofEnd2V(), dof_space: 'screen',
|
|
4336
4397
|
device: st.type, grade: gradeValue(),
|
|
4398
|
+
grade_light: gradeLightValue(),
|
|
4399
|
+
grade_colour: gradeColourValue(),
|
|
4337
4400
|
reflection: emisValue(), fit_frame: +$('#vframe').value});
|
|
4338
4401
|
}catch(e){
|
|
4339
4402
|
building = false;
|
|
@@ -4391,7 +4454,7 @@ async function renderPreview(){
|
|
|
4391
4454
|
const say = t => { if (!building && $('#liveWrap').hidden) s.textContent = t; };
|
|
4392
4455
|
say('Rendering…');
|
|
4393
4456
|
try{
|
|
4394
|
-
const r = await api('/api/preview', {corners: st.corners, radius_frac: radiusValue(), smoothing: smoothingValue(), dof_angle: dofAngle(), dof_strength: dofStrength(), dof_start: dofStartV(), dof_end: dofEndV(), dof_end2: dofEnd2V(), dof_space: 'screen', device: st.type, grade: gradeValue(), reflection: emisValue()});
|
|
4457
|
+
const r = await api('/api/preview', {corners: st.corners, radius_frac: radiusValue(), smoothing: smoothingValue(), dof_angle: dofAngle(), dof_strength: dofStrength(), dof_start: dofStartV(), dof_end: dofEndV(), dof_end2: dofEnd2V(), dof_space: 'screen', device: st.type, grade: gradeValue(), grade_light: gradeLightValue(), grade_colour: gradeColourValue(), reflection: emisValue()});
|
|
4395
4458
|
const im = $('#outImg');
|
|
4396
4459
|
im.onload = () => {
|
|
4397
4460
|
outNat = im.naturalWidth;
|
|
@@ -4524,7 +4587,9 @@ async function renderVideo(){
|
|
|
4524
4587
|
setRenderProgress(0);
|
|
4525
4588
|
try{
|
|
4526
4589
|
await api('/api/render', {corners: st.corners, radius_frac: radiusValue(), smoothing: smoothingValue(), dof_angle: dofAngle(), dof_strength: dofStrength(), dof_start: dofStartV(), dof_end: dofEndV(), dof_end2: dofEnd2V(), dof_space: 'screen', device: st.type,
|
|
4527
|
-
grade: gradeValue(),
|
|
4590
|
+
grade: gradeValue(), grade_light: gradeLightValue(),
|
|
4591
|
+
grade_colour: gradeColourValue(),
|
|
4592
|
+
reflection: emisValue(), preset, fit_frame: +$('#vframe').value});
|
|
4528
4593
|
}catch(e){
|
|
4529
4594
|
setRenderProgress(null);
|
|
4530
4595
|
toast('err','Could not start the render: '+e.message);
|
|
@@ -4569,7 +4634,7 @@ $('#save').onclick = async () => {
|
|
|
4569
4634
|
if (st.video) return renderVideo();
|
|
4570
4635
|
const b = $('#save'); b.textContent = 'Saving…'; b.disabled = true;
|
|
4571
4636
|
try{
|
|
4572
|
-
const r = await api('/api/save', {corners: st.corners, radius_frac: radiusValue(), smoothing: smoothingValue(), dof_angle: dofAngle(), dof_strength: dofStrength(), dof_start: dofStartV(), dof_end: dofEndV(), dof_end2: dofEnd2V(), dof_space: 'screen', device: st.type, grade: gradeValue(), reflection: emisValue()});
|
|
4637
|
+
const r = await api('/api/save', {corners: st.corners, radius_frac: radiusValue(), smoothing: smoothingValue(), dof_angle: dofAngle(), dof_strength: dofStrength(), dof_start: dofStartV(), dof_end: dofEndV(), dof_end2: dofEnd2V(), dof_space: 'screen', device: st.type, grade: gradeValue(), grade_light: gradeLightValue(), grade_colour: gradeColourValue(), reflection: emisValue()});
|
|
4573
4638
|
b.textContent = saveLabel();
|
|
4574
4639
|
// The real destination, not a hardcoded one: --out-dir means saves usually
|
|
4575
4640
|
// land in the project folder now, and telling the user "~/Desktop" when
|
|
@@ -4658,7 +4723,10 @@ $('#imp').onclick = async () => {
|
|
|
4658
4723
|
}
|
|
4659
4724
|
setLoupeMode(loupeMode);
|
|
4660
4725
|
setStripHC(stripHC);
|
|
4661
|
-
|
|
4726
|
+
// One argument since the Light/Colour split: the two amounts are module
|
|
4727
|
+
// state, already recalled above. Passing the old `gradeAmt` here threw a
|
|
4728
|
+
// ReferenceError that killed the rest of boot.
|
|
4729
|
+
setGrade(gradeOn);
|
|
4662
4730
|
setEmis(emisOn, emisAmt);
|
|
4663
4731
|
setDof(dofOn, dofAng, dofStr);
|
|
4664
4732
|
setRadiusOn(radiusOn);
|