screengraft 0.52.0 → 0.54.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/dof.py +53 -2
- package/scripts/ui.py +35 -23
- package/scripts/warp.py +16 -6
- package/skills/inject-screenshot/SKILL.md +2 -2
- package/ui/index.html +112 -71
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "screengraft",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.54.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
|
@@ -16,6 +16,16 @@ softens exactly as the colour does.
|
|
|
16
16
|
Spatially varying Gaussian: DOF_LEVELS blur levels, per pixel a linear blend
|
|
17
17
|
of the two nearest. Standard, cheap (five separable blurs on the quad's
|
|
18
18
|
window), and byte-identical to no blur at strength 0.
|
|
19
|
+
|
|
20
|
+
WHICH SPACE THE RAMP LIVES IN (13 Sep 2026). The plane of focus cuts the
|
|
21
|
+
screen along a line, and blur grows with depth *along the screen*, so
|
|
22
|
+
iso-blur lines are parallel ON THE SCREEN PLANE — and parallel lines on a
|
|
23
|
+
receding plane converge in the photograph, like the phone's own edges. The
|
|
24
|
+
first version ramped linearly in photo pixels, which is only right for a
|
|
25
|
+
screen seen square-on; the mismatch was felt on a steep fit ("top and
|
|
26
|
+
bottom are not perpendicular"). `space="screen"` builds the ramp in the
|
|
27
|
+
screenshot's own coordinates and projects it through the fit's homography;
|
|
28
|
+
`space="photo"` is kept so the sidecars written by v0.51–v0.52 reproduce.
|
|
19
29
|
"""
|
|
20
30
|
import math
|
|
21
31
|
|
|
@@ -80,17 +90,58 @@ def _blur(img: np.ndarray, sigma: float) -> np.ndarray:
|
|
|
80
90
|
return cv2.GaussianBlur(img, (k, k), sigma, borderType=cv2.BORDER_REPLICATE)
|
|
81
91
|
|
|
82
92
|
|
|
93
|
+
def screen_ramp(src_w: int, src_h: int, angle_deg: float, start: float, end: float,
|
|
94
|
+
end2=None) -> np.ndarray:
|
|
95
|
+
"""The ramp in SCREENSHOT space: 0 up to `start`, 1 from `end`, linear
|
|
96
|
+
between, along `angle` (0 = toward +x, 90 = toward +y of the screenshot),
|
|
97
|
+
all as fractions of the screenshot's extent along that direction.
|
|
98
|
+
|
|
99
|
+
`end2`, when given, is the NEAR limit: a second ramp running the other way
|
|
100
|
+
from `start`, reaching 1 at `end2` (< start). The plane of focus then sits
|
|
101
|
+
inside the screen with blur growing on both sides of it — the depth of
|
|
102
|
+
field's near and far limits, Photoshop's tilt-shift band. None keeps the
|
|
103
|
+
one-sided ramp, which is the plane of focus at the screen's edge.
|
|
104
|
+
"""
|
|
105
|
+
a = math.radians(angle_deg)
|
|
106
|
+
d = np.array([math.cos(a), math.sin(a)], dtype=np.float64)
|
|
107
|
+
q = np.array([[0, 0], [src_w, 0], [src_w, src_h], [0, src_h]], dtype=np.float64)
|
|
108
|
+
proj = q @ d
|
|
109
|
+
lo, hi = float(proj.min()), float(proj.max())
|
|
110
|
+
start = float(np.clip(start, 0.0, 0.95))
|
|
111
|
+
end = float(np.clip(end, start + 0.05, 1.5))
|
|
112
|
+
s0, s1 = lo + start * (hi - lo), lo + end * (hi - lo)
|
|
113
|
+
xs = np.arange(src_w, dtype=np.float64)[None, :] + 0.5
|
|
114
|
+
ys = np.arange(src_h, dtype=np.float64)[:, None] + 0.5
|
|
115
|
+
pr = xs * d[0] + ys * d[1]
|
|
116
|
+
t = (pr - s0) / max(s1 - s0, 1e-6)
|
|
117
|
+
if end2 is not None:
|
|
118
|
+
e2 = float(np.clip(end2, -0.5, start - 0.05))
|
|
119
|
+
s2 = lo + e2 * (hi - lo)
|
|
120
|
+
t = np.maximum(t, (s0 - pr) / max(s0 - s2, 1e-6))
|
|
121
|
+
return np.clip(t, 0.0, 1.0).astype(np.float32)
|
|
122
|
+
|
|
123
|
+
|
|
83
124
|
class Field:
|
|
84
125
|
"""Everything that does not change per frame: the ramp, the weights, the
|
|
85
126
|
blurred masks. `blur_layer` then costs `levels - 1` blurs of the colour."""
|
|
86
127
|
|
|
87
128
|
def __init__(self, corners, angle_deg: float, strength: float, mask: np.ndarray,
|
|
88
|
-
x0: int, y0: int, start: float = 0.0
|
|
129
|
+
x0: int, y0: int, start: float = 0.0, end: float = 1.0,
|
|
130
|
+
space: str = "photo", H=None, src_size=None, end2=None):
|
|
89
131
|
h, w = mask.shape[:2]
|
|
90
132
|
self.x0, self.y0 = x0, y0
|
|
91
133
|
self.smax = sigma_max(corners, strength)
|
|
92
134
|
self.sigmas = [self.smax * k / (DOF_LEVELS - 1) for k in range(DOF_LEVELS)]
|
|
93
|
-
|
|
135
|
+
if space == "screen" and H is not None and src_size is not None:
|
|
136
|
+
sw, sh = src_size
|
|
137
|
+
src = screen_ramp(sw, sh, angle_deg, start, end, end2)
|
|
138
|
+
T = np.array([[1, 0, -x0], [0, 1, -y0], [0, 0, 1]], dtype=np.float64)
|
|
139
|
+
t = cv2.warpPerspective(src, T @ np.asarray(H, dtype=np.float64), (w, h),
|
|
140
|
+
flags=cv2.INTER_LINEAR, borderMode=cv2.BORDER_REPLICATE)
|
|
141
|
+
t = np.clip(t, 0.0, 1.0).astype(np.float32)
|
|
142
|
+
else:
|
|
143
|
+
t = ramp(corners, angle_deg, x0, y0, w, h, start)
|
|
144
|
+
self.t = t # kept for tests and the trace
|
|
94
145
|
self.W = level_weights(t) # (L, h, w)
|
|
95
146
|
m = mask.astype(np.float32) / 255.0
|
|
96
147
|
self.masks = [_blur(m, s) for s in self.sigmas] # each (h, w)
|
package/scripts/ui.py
CHANGED
|
@@ -578,8 +578,7 @@ PREVIEW_SECONDS = 6.0
|
|
|
578
578
|
|
|
579
579
|
def _render_worker(photo, video_path, corners, dest, radius_px, gr, grain, preset, fit_frame,
|
|
580
580
|
blend="replace", reflection=None, result=None, kind="render",
|
|
581
|
-
start_frame=0, max_frames=None, *, smoothing=0.0,
|
|
582
|
-
dof_angle=0.0, dof_strength=0.0, dof_start=0.0):
|
|
581
|
+
start_frame=0, max_frames=None, *, smoothing=0.0, dof=None):
|
|
583
582
|
"""Encode the clip, and only if that SUCCEEDS publish what it produced.
|
|
584
583
|
|
|
585
584
|
`result` is the sidecar this render would write. It is handed to the worker
|
|
@@ -605,8 +604,7 @@ def _render_worker(photo, video_path, corners, dest, radius_px, gr, grain, prese
|
|
|
605
604
|
reflection=(W.DEFAULT_REFLECTION if reflection is None
|
|
606
605
|
else reflection),
|
|
607
606
|
start_frame=start_frame, max_frames=max_frames,
|
|
608
|
-
|
|
609
|
-
dof_start=dof_start)
|
|
607
|
+
**(dof or {}))
|
|
610
608
|
if kind == "preview":
|
|
611
609
|
# A preview publishes NOTHING. It is not a save: no sidecar, no fit
|
|
612
610
|
# file, and above all not the session output -- /api/import reads
|
|
@@ -669,17 +667,33 @@ def _blend_args(b):
|
|
|
669
667
|
|
|
670
668
|
|
|
671
669
|
def _dof_args(b):
|
|
672
|
-
"""
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
670
|
+
"""Depth-of-field kwargs from the page, as a dict compose() takes directly.
|
|
671
|
+
|
|
672
|
+
Absent or null strength means 0 -- no field, every path in Plan untouched
|
|
673
|
+
-- so a client that predates depth of field and a sidecar replayed
|
|
674
|
+
through the CLI both reproduce. `start` absent means 0 (the whole screen
|
|
675
|
+
ramps), `end` absent means 1 (to the far edge), `space` absent means
|
|
676
|
+
"photo": the v0.51–v0.52 model, so those sidecars reproduce too; the page
|
|
677
|
+
sends "screen" now (see dof.py's module docstring for why).
|
|
678
|
+
"""
|
|
676
679
|
try:
|
|
677
680
|
strength = float(b.get("dof_strength") or 0.0)
|
|
678
681
|
angle = float(b.get("dof_angle") or 0.0)
|
|
679
682
|
start = float(b.get("dof_start") or 0.0)
|
|
683
|
+
end = float(b.get("dof_end") if b.get("dof_end") is not None else 1.0)
|
|
684
|
+
except (TypeError, ValueError):
|
|
685
|
+
strength, angle, start, end = 0.0, 0.0, 0.0, 1.0
|
|
686
|
+
space = "screen" if b.get("dof_space") == "screen" else "photo"
|
|
687
|
+
start = float(min(max(start, 0.0), 0.95))
|
|
688
|
+
# The near limit is optional: absent or null means one-sided.
|
|
689
|
+
end2 = b.get("dof_end2")
|
|
690
|
+
try:
|
|
691
|
+
end2 = None if end2 is None else float(min(max(float(end2), -0.5), start - 0.05))
|
|
680
692
|
except (TypeError, ValueError):
|
|
681
|
-
|
|
682
|
-
return angle % 360.0, float(min(max(strength, 0.0), 1.0)),
|
|
693
|
+
end2 = None
|
|
694
|
+
return {"dof_angle": angle % 360.0, "dof_strength": float(min(max(strength, 0.0), 1.0)),
|
|
695
|
+
"dof_start": start, "dof_end": float(min(max(end, start + 0.05), 1.5)),
|
|
696
|
+
"dof_space": space, "dof_end2": end2}
|
|
683
697
|
|
|
684
698
|
|
|
685
699
|
ROLES = ("photo", "screenshot")
|
|
@@ -1180,9 +1194,7 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1180
1194
|
blend, reflection, None, "preview",
|
|
1181
1195
|
fit_frame, max_frames),
|
|
1182
1196
|
kwargs={"smoothing": _smoothing(b),
|
|
1183
|
-
"
|
|
1184
|
-
"dof_strength": _dof_args(b)[1],
|
|
1185
|
-
"dof_start": _dof_args(b)[2]}).start()
|
|
1197
|
+
"dof": _dof_args(b)}).start()
|
|
1186
1198
|
except BaseException:
|
|
1187
1199
|
with RENDER_LOCK:
|
|
1188
1200
|
RENDER.update(state="error", message="could not start the preview")
|
|
@@ -1216,7 +1228,7 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1216
1228
|
gr = float(b.get("grade") if b.get("grade") is not None else 0.0)
|
|
1217
1229
|
grain = bool(b.get("grain", gr > 0))
|
|
1218
1230
|
blend, reflection = _blend_args(b)
|
|
1219
|
-
|
|
1231
|
+
dof = _dof_args(b)
|
|
1220
1232
|
preset = "prores" if b.get("preset") == "prores" else "web"
|
|
1221
1233
|
ext = ".mov" if preset == "prores" else ".mp4"
|
|
1222
1234
|
os.makedirs(OUT_DIR, exist_ok=True)
|
|
@@ -1238,7 +1250,9 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1238
1250
|
"grade": gr, "grain": grain,
|
|
1239
1251
|
"video": True, "preset": preset, "fit_frame": fit_frame,
|
|
1240
1252
|
"blend": blend, "reflection": reflection,
|
|
1241
|
-
"dof_angle": dof_angle, "dof_strength": dof_strength
|
|
1253
|
+
"dof_angle": dof["dof_angle"], "dof_strength": dof["dof_strength"],
|
|
1254
|
+
"dof_start": dof["dof_start"], "dof_end": dof["dof_end"],
|
|
1255
|
+
"dof_space": dof["dof_space"], "dof_end2": dof["dof_end2"],
|
|
1242
1256
|
# A render is always the whole clip; only the preview
|
|
1243
1257
|
# passes a segment. Recorded anyway, because the
|
|
1244
1258
|
# sidecar's promise is EVERY argument that changes the
|
|
@@ -1266,9 +1280,7 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1266
1280
|
gr, grain, preset, fit_frame,
|
|
1267
1281
|
blend, reflection, result),
|
|
1268
1282
|
kwargs={"smoothing": _smoothing(b),
|
|
1269
|
-
"
|
|
1270
|
-
"dof_strength": dof_strength,
|
|
1271
|
-
"dof_start": dof_start}).start()
|
|
1283
|
+
"dof": dof}).start()
|
|
1272
1284
|
except BaseException:
|
|
1273
1285
|
# If the thread cannot even be created, the flag must not
|
|
1274
1286
|
# outlive the request.
|
|
@@ -1291,13 +1303,11 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1291
1303
|
gr = float(b.get("grade") if b.get("grade") is not None else 0.0)
|
|
1292
1304
|
blend, reflection = _blend_args(b)
|
|
1293
1305
|
smoothing = _smoothing(b)
|
|
1294
|
-
|
|
1306
|
+
dof = _dof_args(b)
|
|
1295
1307
|
out = W.compose(photo, shot, corners, radius_px,
|
|
1296
1308
|
corner_smoothing=smoothing,
|
|
1297
1309
|
grade=gr, grain=bool(b.get("grain", gr > 0)),
|
|
1298
|
-
blend=blend, reflection=reflection,
|
|
1299
|
-
dof_angle=dof_angle, dof_strength=dof_strength,
|
|
1300
|
-
dof_start=dof_start)
|
|
1310
|
+
blend=blend, reflection=reflection, **dof)
|
|
1301
1311
|
SESSION.update(corners=corners, radius_frac=frac, device=b.get("device"),
|
|
1302
1312
|
grade=gr)
|
|
1303
1313
|
if u.path == "/api/preview":
|
|
@@ -1333,7 +1343,9 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1333
1343
|
"corner_smoothing": smoothing,
|
|
1334
1344
|
"grade": gr, "grain": bool(b.get("grain", gr > 0)),
|
|
1335
1345
|
"blend": blend, "reflection": reflection,
|
|
1336
|
-
"dof_angle": dof_angle, "dof_strength": dof_strength
|
|
1346
|
+
"dof_angle": dof["dof_angle"], "dof_strength": dof["dof_strength"],
|
|
1347
|
+
"dof_start": dof["dof_start"], "dof_end": dof["dof_end"],
|
|
1348
|
+
"dof_space": dof["dof_space"], "dof_end2": dof["dof_end2"],
|
|
1337
1349
|
"saved": time.time()}
|
|
1338
1350
|
# A fit is remembered when it PRODUCED something, not while it
|
|
1339
1351
|
# is being dragged: a quad on the canvas is a work in progress,
|
package/scripts/warp.py
CHANGED
|
@@ -269,7 +269,8 @@ class Plan:
|
|
|
269
269
|
corner_radius: float = 0.0, grain: bool = False,
|
|
270
270
|
blend: str = "replace", reflection: float = DEFAULT_REFLECTION,
|
|
271
271
|
corner_smoothing: float = 0.0,
|
|
272
|
-
dof_angle: float = 0.0, dof_strength: float = 0.0, dof_start: float = 0.0
|
|
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
274
|
dst_quad = np.array(corners, dtype=np.float32)
|
|
274
275
|
if shoelace_area(dst_quad) < 1.0:
|
|
275
276
|
raise ValueError("degenerate quad (near-zero area) — check corner order TL,TR,BR,BL")
|
|
@@ -321,6 +322,9 @@ class Plan:
|
|
|
321
322
|
self.dof_strength = float(np.clip(dof_strength, 0.0, 1.0))
|
|
322
323
|
self.dof_angle = float(dof_angle)
|
|
323
324
|
self.dof_start = float(np.clip(dof_start, 0.0, 0.95))
|
|
325
|
+
self.dof_end = float(np.clip(dof_end, self.dof_start + 0.05, 1.5))
|
|
326
|
+
self.dof_space = "screen" if dof_space == "screen" else "photo"
|
|
327
|
+
self.dof_end2 = None if dof_end2 is None else float(np.clip(dof_end2, -0.5, self.dof_start - 0.05))
|
|
324
328
|
reach = int(np.ceil(3 * _dof.sigma_max(dst_quad, self.dof_strength))) if self.dof_strength > 0 else 0
|
|
325
329
|
bx0, by0 = max(0, int(np.floor(xs.min())) - 1 - reach), max(0, int(np.floor(ys.min())) - 1 - reach)
|
|
326
330
|
bx1, by1 = min(pw, int(np.ceil(xs.max())) + 2 + reach), min(ph, int(np.ceil(ys.max())) + 2 + reach)
|
|
@@ -332,7 +336,9 @@ class Plan:
|
|
|
332
336
|
if self.dof_strength > 0 and self.bbox is not None:
|
|
333
337
|
self.dof = _dof.Field(dst_quad, self.dof_angle, self.dof_strength,
|
|
334
338
|
self.warped_mask[by0:by1, bx0:bx1], bx0, by0,
|
|
335
|
-
start=self.dof_start
|
|
339
|
+
start=self.dof_start, end=self.dof_end,
|
|
340
|
+
space=self.dof_space, H=self.H,
|
|
341
|
+
src_size=(self.new_w, self.new_h), end2=self.dof_end2)
|
|
336
342
|
|
|
337
343
|
def _prep(self, frame: np.ndarray, bbox=None) -> np.ndarray:
|
|
338
344
|
"""Warp one frame. With `bbox`, warp only that window of the canvas.
|
|
@@ -437,7 +443,8 @@ def compose(photo: np.ndarray, screenshot: np.ndarray, corners, corner_radius: f
|
|
|
437
443
|
specular: float = 0.75, blend: str = "replace",
|
|
438
444
|
reflection: float = DEFAULT_REFLECTION,
|
|
439
445
|
dof_angle: float = 0.0, dof_strength: float = 0.0,
|
|
440
|
-
dof_start: float = 0.0
|
|
446
|
+
dof_start: float = 0.0, dof_end: float = 1.0,
|
|
447
|
+
dof_space: str = "photo", dof_end2=None) -> np.ndarray:
|
|
441
448
|
"""Warp `screenshot` into the quad `corners` (TL,TR,BR,BL, photo pixels) on `photo`.
|
|
442
449
|
|
|
443
450
|
Single resampling pass at the photo's resolution; deterministic. This is the
|
|
@@ -451,7 +458,8 @@ def compose(photo: np.ndarray, screenshot: np.ndarray, corners, corner_radius: f
|
|
|
451
458
|
plan = Plan(photo, screenshot.shape, corners, corner_radius, grain=grain,
|
|
452
459
|
corner_smoothing=corner_smoothing,
|
|
453
460
|
blend=blend, reflection=reflection,
|
|
454
|
-
dof_angle=dof_angle, dof_strength=dof_strength, dof_start=dof_start
|
|
461
|
+
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)
|
|
455
463
|
plan.bind_grade(screenshot, grade)
|
|
456
464
|
return plan.render(screenshot, screen_off=screen_off, specular=specular)
|
|
457
465
|
|
|
@@ -540,7 +548,8 @@ def compose_video(photo: np.ndarray, video_path: str, corners, output: str,
|
|
|
540
548
|
reflection: float = DEFAULT_REFLECTION,
|
|
541
549
|
start_frame: int = 0, max_frames: int = None,
|
|
542
550
|
dof_angle: float = 0.0, dof_strength: float = 0.0,
|
|
543
|
-
dof_start: float = 0.0
|
|
551
|
+
dof_start: float = 0.0, dof_end: float = 1.0,
|
|
552
|
+
dof_space: str = "photo", dof_end2=None) -> dict:
|
|
544
553
|
"""Inject a VIDEO into a still photo. The photo does not move, so there is
|
|
545
554
|
exactly one homography and the whole of Plan is computed once.
|
|
546
555
|
|
|
@@ -568,7 +577,8 @@ def compose_video(photo: np.ndarray, video_path: str, corners, output: str,
|
|
|
568
577
|
plan = Plan(photo, first.shape, corners, corner_radius, grain=grain,
|
|
569
578
|
corner_smoothing=corner_smoothing,
|
|
570
579
|
blend=blend, reflection=reflection,
|
|
571
|
-
dof_angle=dof_angle, dof_strength=dof_strength, dof_start=dof_start
|
|
580
|
+
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)
|
|
572
582
|
plan.bind_grade(first, grade)
|
|
573
583
|
|
|
574
584
|
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.54):** 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
|
|
|
@@ -13,7 +13,7 @@ The geometry is exact (`warp.py`); the detection is advisory (`detect.py`) and t
|
|
|
13
13
|
|
|
14
14
|
**The realism pass ships and is ON by default** (`grade.py`): it matches the injected screen's white balance and grain to the light around it, at a strength the designer sets in the rail. It can also lift the device's real specular highlights from a screen-off reference frame, though the UI cannot supply one yet. Off is a first-class choice and keeps the screenshot's colour exactly — say so if the user is reviewing brand colour.
|
|
15
15
|
|
|
16
|
-
**Depth of field** *(off by default)*: a phone shot at an angle is a plane receding from the camera, so its far end is softer than its near end, and a screenshot pasted pin-sharp across all of it gives the fake away. On, the screenshot blurs across the screen in one direction — **direction** (where the blur grows toward) and **strength** — and the glass edge softens with it. **Measure from photo** reads both off the photograph's own screen boundary; on a real photograph that works, on a *mockup template* the device is usually rendered sharp with the blur only on the background, so it answers "flat" and the designer sets it by eye. The measured strength is a floor (the estimator saturates around 5px of blur), never a ceiling. **The gizmo on the result** is the primary control: a solid line
|
|
16
|
+
**Depth of field** *(off by default)*: a phone shot at an angle is a plane receding from the camera, so its far end is softer than its near end, and a screenshot pasted pin-sharp across all of it gives the fake away. On, the screenshot blurs across the screen in one direction — **direction** (where the blur grows toward) and **strength** — and the glass edge softens with it. **Measure from photo** reads both off the photograph's own screen boundary; on a real photograph that works, on a *mockup template* the device is usually rendered sharp with the blur only on the background, so it answers "flat" and the designer sets it by eye. The measured strength is a floor (the estimator saturates around 5px of blur), never a ceiling. **The gizmo on the result** is the primary control, defined in the screen's own plane and projected through the fit (so the two lines converge with the phone's edges on a steep shot, as real iso-blur lines do): a solid line where focus ends (drag its centre), a dashed line where the blur reaches its full amount (drag its centre to move it, its end pips to turn both, and slide the diamond along it for how much blur). **Both sides** adds a near limit — a second dashed line behind the focus line — for a phone whose middle is sharp and both ends soft (the depth of field's near and far limits). It fades when the pointer leaves the pane. The sliders and the gizmo are one model. Suggest it when the photo has visible bokeh — a blurred hand, table edge or background — and the composite's screen looks pasted on. The live in-place playback cannot show it; the composite and Render preview do.
|
|
17
17
|
|
|
18
18
|
**Video ships too.** The screen source can be a video (mp4/mov/webm) as well as a still — pick it exactly like a screenshot, choose which frame to match the edges on, **press Play and the clip runs on the photo immediately** — the browser warps it onto the same four corners with the same corner radius and approximates the emissive blend, so placement and motion can be judged with no wait; **Render preview** composites a few seconds through the real pipeline when the grade, grain and true blend are what you need to see — and the primary button becomes **Render**. The photo does not move, so there is one homography and every frame gets the same geometry; the light match is measured once from the frame you fitted on, so the screen cannot pulse as the UI scrolls. Output is H.264 at CRF 16 (near-visually-lossless) or ProRes 422 HQ. This is what pairs with a prototype recording: record the prototype, then inject the recording into a real photograph.
|
|
19
19
|
|
package/ui/index.html
CHANGED
|
@@ -386,9 +386,9 @@
|
|
|
386
386
|
.seg button.sel:active{background:var(--raise-low);transform:none}
|
|
387
387
|
/* A toggle in the strip head. On = the raised surface every selected thing
|
|
388
388
|
on this page has (the segmented thumb, the device chip), off = a label. */
|
|
389
|
-
.striphead button[aria-pressed]{background:transparent;border-color:transparent;box-shadow:none;color:var(--mute)}
|
|
390
|
-
.striphead button[aria-pressed]:hover{color:var(--ink)}
|
|
391
|
-
.striphead button[aria-pressed="true"]{background:var(--raise);border-color:var(--edge);color:var(--ink);font-weight:600}
|
|
389
|
+
.striphead button[aria-pressed],#dofBoth[aria-pressed]{background:transparent;border-color:transparent;box-shadow:none;color:var(--mute)}
|
|
390
|
+
.striphead button[aria-pressed]:hover,#dofBoth[aria-pressed]:hover{color:var(--ink)}
|
|
391
|
+
.striphead button[aria-pressed="true"],#dofBoth[aria-pressed="true"]{background:var(--raise);border-color:var(--edge);color:var(--ink);font-weight:600}
|
|
392
392
|
/* Input chip (Figma 5:14 / 5:20). Surface and shadow are declared here rather
|
|
393
393
|
than inherited from the base button rule, so the filled state is explicit and
|
|
394
394
|
the .is-empty override below has something definite to override.
|
|
@@ -639,6 +639,7 @@
|
|
|
639
639
|
#dofGizmo .h{fill:#f5623d;stroke:#fff;stroke-width:1.5;vector-effect:non-scaling-stroke;pointer-events:auto;cursor:grab}
|
|
640
640
|
#dofGizmo .h.hollow{fill:#1a1a1a}
|
|
641
641
|
#dofGizmo .h.pip{fill:#f5623d}
|
|
642
|
+
#dofGizmo .h.thumb{fill:#fff;stroke:#f5623d;stroke-width:2}
|
|
642
643
|
#dofGizmo .h:active{cursor:grabbing}
|
|
643
644
|
#dofGizmo text{font:600 11px system-ui,-apple-system,sans-serif;fill:#fff;paint-order:stroke;stroke:rgba(0,0,0,.75);stroke-width:3px;stroke-linejoin:round;pointer-events:none}
|
|
644
645
|
#outImg,#outVid{display:block;margin:auto}
|
|
@@ -1142,7 +1143,7 @@
|
|
|
1142
1143
|
<div class="sect-body"><div class="sect-body-in">
|
|
1143
1144
|
<div style="display:grid;gap:var(--s2)">
|
|
1144
1145
|
<div style="display:flex;align-items:center;justify-content:space-between;gap:var(--s2)">
|
|
1145
|
-
<span class="sm" style="color:var(--mute)">Blur grows toward</span>
|
|
1146
|
+
<span class="sm" style="color:var(--mute)">Blur grows toward (of the screen)</span>
|
|
1146
1147
|
<span class="sm" id="dofDirVal" style="font-variant-numeric:tabular-nums"></span>
|
|
1147
1148
|
</div>
|
|
1148
1149
|
<input type="range" id="dofDir" min="0" max="355" step="5" value="90" aria-label="Direction the blur grows in">
|
|
@@ -1152,6 +1153,7 @@
|
|
|
1152
1153
|
</div>
|
|
1153
1154
|
<input type="range" id="dofStr" min="0" max="1" step="0.05" value="0.3" aria-label="Blur strength at the far edge">
|
|
1154
1155
|
<div style="display:flex;align-items:center;gap:var(--s2)">
|
|
1156
|
+
<button class="sm" id="dofBoth" aria-pressed="false" title="Blur on both sides of the focus line — the depth of field's near and far limits, for a phone whose middle is sharp and both ends soft. Off, the plane of focus is at the screen's edge and blur grows one way.">Both sides</button>
|
|
1155
1157
|
<button class="sm" id="dofMeasure" title="Read the direction and a starting strength off the photo's own bezel">Measure from photo</button>
|
|
1156
1158
|
<span class="status sm" id="dofSt" style="min-width:0"></span>
|
|
1157
1159
|
</div>
|
|
@@ -2576,9 +2578,10 @@ function setDof(on, ang, str){
|
|
|
2576
2578
|
dofOn = on;
|
|
2577
2579
|
if (ang !== undefined) dofAng = ((ang % 360) + 360) % 360;
|
|
2578
2580
|
if (str !== undefined) dofStr = Math.max(0, Math.min(1, str));
|
|
2579
|
-
remember('dof', on ? '1' : '0'); remember('dofAng', String(dofAng)); remember('dofStr', String(dofStr)); remember('dofStart', String(dofStart));
|
|
2581
|
+
remember('dof', on ? '1' : '0'); remember('dofAng', String(dofAng)); remember('dofStr', String(dofStr)); remember('dofStart', String(dofStart)); remember('dofEnd', String(dofEnd)); remember('dofEnd2', dofEnd2 === null ? '' : String(dofEnd2));
|
|
2580
2582
|
setSectionOpen($('#secDof'), on);
|
|
2581
2583
|
paintDof();
|
|
2584
|
+
paintBoth();
|
|
2582
2585
|
paintGizmo();
|
|
2583
2586
|
}
|
|
2584
2587
|
let dofMeasuredFor = null; // photo path the last measurement was for
|
|
@@ -2595,7 +2598,22 @@ async function measureDof(){
|
|
|
2595
2598
|
autoPreview(); // still on, at the slider's own numbers
|
|
2596
2599
|
return;
|
|
2597
2600
|
}
|
|
2598
|
-
|
|
2601
|
+
// The estimator's angle is in PHOTO space; the gizmo and the engine work in
|
|
2602
|
+
// the screen's own plane. Map a short step from the quad's centre through
|
|
2603
|
+
// the inverse homography and take its direction there.
|
|
2604
|
+
let ang = r.angle;
|
|
2605
|
+
if (st.shotSize){
|
|
2606
|
+
const [sw, sh] = st.shotSize, rect = [[0,0],[sw,0],[sw,sh],[0,sh]];
|
|
2607
|
+
const Hi = solveHomography(st.corners, rect);
|
|
2608
|
+
if (Hi){
|
|
2609
|
+
const c = st.corners.reduce((acc,cc)=>[acc[0]+cc[0]/4, acc[1]+cc[1]/4],[0,0]);
|
|
2610
|
+
const a = r.angle * Math.PI / 180, step = 40;
|
|
2611
|
+
const p0 = applyH(Hi, c), p1 = applyH(Hi, [c[0] + Math.cos(a)*step, c[1] + Math.sin(a)*step]);
|
|
2612
|
+
ang = (Math.atan2(p1[1]-p0[1], p1[0]-p0[0]) * 180 / Math.PI + 360) % 360;
|
|
2613
|
+
}
|
|
2614
|
+
}
|
|
2615
|
+
dofStart = 0; dofEnd = 1; dofEnd2 = null; setDof(true, ang, Math.max(r.strength, 0.05));
|
|
2616
|
+
|
|
2599
2617
|
s.className = 'status sm ok';
|
|
2600
2618
|
s.textContent = `Measured: grows toward ${dofWord(r.angle)}. Strength is a floor — raise it if the far end looks softer.`;
|
|
2601
2619
|
s.title = `Edge blur ${r.sigma.map(v => v.toFixed(1)).join(' / ')}px, spread ${r.spread}×`;
|
|
@@ -2616,79 +2634,98 @@ $('#dofMeasure').onclick = measureDof;
|
|
|
2616
2634
|
|
|
2617
2635
|
/* ===== the depth-of-field gizmo ============================================
|
|
2618
2636
|
Two lines over the composite, the graduated-filter idiom (Lightroom's
|
|
2619
|
-
gradient, Photoshop's tilt-shift)
|
|
2620
|
-
|
|
2621
|
-
|
|
2622
|
-
|
|
2623
|
-
|
|
2624
|
-
|
|
2625
|
-
|
|
2626
|
-
|
|
2627
|
-
|
|
2628
|
-
|
|
2629
|
-
|
|
2630
|
-
|
|
2631
|
-
|
|
2632
|
-
|
|
2633
|
-
Everything is
|
|
2634
|
-
|
|
2637
|
+
gradient, Photoshop's tilt-shift) -- defined IN THE SCREEN'S OWN PLANE and
|
|
2638
|
+
projected through the fit's homography. The plane of focus cuts the screen
|
|
2639
|
+
along a line and blur grows with depth along the screen, so iso-blur lines
|
|
2640
|
+
are parallel on the screen and converge in the photograph like the phone's
|
|
2641
|
+
own edges; drawn in photo space they were parallel on the picture and wrong
|
|
2642
|
+
on any steep fit (reported 13 Sep 2026). The SOLID line is where focus
|
|
2643
|
+
ends; the DASHED line is where the blur reaches its full amount, and it can
|
|
2644
|
+
sit anywhere on the screen or a little past it. Handles:
|
|
2645
|
+
solid line centre -- move focus (start)
|
|
2646
|
+
dashed line centre -- move where the ramp ends (end); spacing = ramp length
|
|
2647
|
+
sharp line pips -- turn all lines about the focus line (direction), ⇧ snaps to 15°
|
|
2648
|
+
second dashed line -- the NEAR limit, when "Both sides" is on: blur grows
|
|
2649
|
+
the other way from the focus line too
|
|
2650
|
+
diamond on the dashed line -- slide along it for how much blur (strength)
|
|
2651
|
+
Everything is computed in screenshot pixels and mapped to the photo with H;
|
|
2652
|
+
the SVG's viewBox is the photo so it stays aligned at every zoom for free. */
|
|
2635
2653
|
const gz = $('#dofGizmo');
|
|
2636
2654
|
const SIGMA_FULL_FRAC = 0.02; // mirrors dof.DOF_MAX_FRAC -- keep in step
|
|
2637
|
-
|
|
2638
|
-
|
|
2655
|
+
let dofEnd = parseFloat(recall('dofEnd','1')); if (!(dofEnd > 0)) dofEnd = 1;
|
|
2656
|
+
// The near limit. null = one-sided (the plane of focus at the screen's edge).
|
|
2657
|
+
let dofEnd2 = recall('dofEnd2','') === '' ? null : parseFloat(recall('dofEnd2','')); if (dofEnd2 !== null && !(dofEnd2 < 1)) dofEnd2 = null;
|
|
2658
|
+
function dofEndV(){ return dofOn ? dofEnd : 1; }
|
|
2659
|
+
function dofEnd2V(){ return dofOn ? dofEnd2 : null; }
|
|
2660
|
+
function paintBoth(){ $('#dofBoth').setAttribute('aria-pressed', dofEnd2 !== null ? 'true' : 'false'); }
|
|
2661
|
+
$('#dofBoth').onclick = () => {
|
|
2662
|
+
if (dofEnd2 === null){
|
|
2663
|
+
// Turning it on: the focus line is probably at the edge, where a near
|
|
2664
|
+
// limit has nowhere to go. Put the sharp band across the middle with both
|
|
2665
|
+
// limits at the screen's edges -- the "middle sharp, both ends soft" case.
|
|
2666
|
+
if (dofStart < 0.15){ dofStart = 0.5; dofEnd = 1.0; dofEnd2 = 0.0; }
|
|
2667
|
+
else dofEnd2 = Math.max(-0.5, dofStart - (dofEnd - dofStart));
|
|
2668
|
+
} else dofEnd2 = null;
|
|
2669
|
+
setDof(true); autoPreview();
|
|
2670
|
+
};
|
|
2671
|
+
function applyH(Hm, p){ const w = Hm[6]*p[0] + Hm[7]*p[1] + Hm[8]; return [(Hm[0]*p[0] + Hm[1]*p[1] + Hm[2]) / w, (Hm[3]*p[0] + Hm[4]*p[1] + Hm[5]) / w]; }
|
|
2639
2672
|
function gizmoGeom(){
|
|
2640
|
-
const
|
|
2673
|
+
const [sw, sh] = st.shotSize, a = dofAng * Math.PI / 180;
|
|
2674
|
+
const rect = [[0,0],[sw,0],[sw,sh],[0,sh]];
|
|
2675
|
+
const H = solveHomography(rect, st.corners), Hi = solveHomography(st.corners, rect);
|
|
2641
2676
|
const d = [Math.cos(a), Math.sin(a)], u = [-d[1], d[0]];
|
|
2642
|
-
const pd =
|
|
2643
|
-
const
|
|
2677
|
+
const pd = rect.map(c => c[0]*d[0] + c[1]*d[1]), pu = rect.map(c => c[0]*u[0] + c[1]*u[1]);
|
|
2678
|
+
const C = st.corners;
|
|
2644
2679
|
const side = Math.max(vlen(sub(C[1],C[0])), vlen(sub(C[2],C[1])), vlen(sub(C[3],C[2])), vlen(sub(C[0],C[3])));
|
|
2645
|
-
return {d, u, lo, hi, ulo, uhi, side};
|
|
2646
|
-
}
|
|
2647
|
-
// a line at fraction f along d, spanning the quad's extent along u (padded)
|
|
2648
|
-
function gizmoLine(g, f, pad){
|
|
2649
|
-
const c = g.lo + f * (g.hi - g.lo);
|
|
2650
|
-
const p0 = [c*g.d[0] + (g.ulo-pad)*g.u[0], c*g.d[1] + (g.ulo-pad)*g.u[1]];
|
|
2651
|
-
const p1 = [c*g.d[0] + (g.uhi+pad)*g.u[0], c*g.d[1] + (g.uhi+pad)*g.u[1]];
|
|
2652
|
-
return [p0, p1, [(p0[0]+p1[0])/2, (p0[1]+p1[1])/2]];
|
|
2680
|
+
return {H, Hi, d, u, lo: Math.min(...pd), hi: Math.max(...pd), ulo: Math.min(...pu), uhi: Math.max(...pu), side};
|
|
2653
2681
|
}
|
|
2682
|
+
// a screen-space point at fraction f along d and fraction k across u
|
|
2683
|
+
function gzPt(g, f, k){ const c = g.lo + f*(g.hi - g.lo), w = g.ulo + k*(g.uhi - g.ulo); return [c*g.d[0] + w*g.u[0], c*g.d[1] + w*g.u[1]]; }
|
|
2654
2684
|
function paintGizmo(){
|
|
2655
2685
|
const im = $('#outImg');
|
|
2656
|
-
const show = dofOn && st.corners && !im.hidden && im.naturalWidth && $('#liveWrap').hidden;
|
|
2657
|
-
// An SVG element has no `.hidden` property (that is HTMLElement's), so
|
|
2658
|
-
// assigning it makes a JS expando and leaves the attribute where it was.
|
|
2686
|
+
const show = dofOn && st.corners && st.shotSize && !im.hidden && im.naturalWidth && $('#liveWrap').hidden;
|
|
2659
2687
|
gz.toggleAttribute('hidden', !show);
|
|
2660
2688
|
if (!show) return;
|
|
2661
|
-
const W = im.naturalWidth,
|
|
2662
|
-
gz.setAttribute('viewBox', `0 0 ${W} ${
|
|
2689
|
+
const W = im.naturalWidth, Hh = im.naturalHeight;
|
|
2690
|
+
gz.setAttribute('viewBox', `0 0 ${W} ${Hh}`);
|
|
2663
2691
|
gz.style.left = im.offsetLeft + 'px'; gz.style.top = im.offsetTop + 'px';
|
|
2664
2692
|
gz.style.width = im.clientWidth + 'px'; gz.style.height = im.clientHeight + 'px';
|
|
2665
2693
|
const k = W / Math.max(im.clientWidth, 1); // photo px per screen px
|
|
2666
|
-
const g = gizmoGeom();
|
|
2667
|
-
const
|
|
2668
|
-
const
|
|
2669
|
-
const
|
|
2670
|
-
const
|
|
2671
|
-
const
|
|
2672
|
-
const
|
|
2673
|
-
const
|
|
2694
|
+
const g = gizmoGeom(); if (!g.H || !g.Hi) return;
|
|
2695
|
+
const P = (f, q) => applyH(g.H, gzPt(g, f, q));
|
|
2696
|
+
const a0 = P(dofStart, -0.06), a1 = P(dofStart, 1.06), ac = P(dofStart, 0.5);
|
|
2697
|
+
const b0 = P(dofEnd, -0.06), b1 = P(dofEnd, 1.06), bc = P(dofEnd, 0.5);
|
|
2698
|
+
const two = dofEnd2 !== null;
|
|
2699
|
+
const c0 = two ? P(dofEnd2, -0.06) : null, c1 = two ? P(dofEnd2, 1.06) : null, cc = two ? P(dofEnd2, 0.5) : null;
|
|
2700
|
+
const th = P(dofEnd, 0.08 + 0.84 * dofStr); // the strength thumb slides along the dashed line
|
|
2701
|
+
const sigma = dofStr * SIGMA_FULL_FRAC * g.side;
|
|
2702
|
+
const r = 6 * k, rp = 4 * k, fs11 = 11 * k, off = 14 * k;
|
|
2674
2703
|
const L = (x, y, cls, extra='') => `<line x1="${x[0]}" y1="${x[1]}" x2="${y[0]}" y2="${y[1]}" class="${cls}" ${extra}/>`;
|
|
2675
2704
|
const T = (p, txt, dy) => `<text x="${p[0]}" y="${p[1] + dy}" text-anchor="middle" font-size="${fs11}">${txt}</text>`;
|
|
2676
|
-
const
|
|
2705
|
+
const away = unit(sub(bc, ac)); // label offsets: away from the other line
|
|
2706
|
+
const beyond = dofEnd > 1.0001;
|
|
2707
|
+
const beyond2 = two && dofEnd2 < -0.0001;
|
|
2677
2708
|
gz.innerHTML =
|
|
2678
2709
|
L(a0, a1, 'case', 'stroke-width="4"') + L(a0, a1, 'focus') +
|
|
2679
2710
|
L(b0, b1, 'case', 'stroke-width="3.5"') + L(b0, b1, 'far' + (beyond ? ' out' : '')) +
|
|
2711
|
+
(two ? L(c0, c1, 'case', 'stroke-width="3.5"') + L(c0, c1, 'far' + (beyond2 ? ' out' : '')) : '') +
|
|
2680
2712
|
`<circle cx="${a0[0]}" cy="${a0[1]}" r="${rp}" class="h pip" data-h="rotA"/>` +
|
|
2681
2713
|
`<circle cx="${a1[0]}" cy="${a1[1]}" r="${rp}" class="h pip" data-h="rotB"/>` +
|
|
2682
2714
|
`<circle cx="${ac[0]}" cy="${ac[1]}" r="${r}" class="h" data-h="start"/>` +
|
|
2683
|
-
`<circle cx="${bc[0]}" cy="${bc[1]}" r="${r}" class="h hollow" data-h="
|
|
2684
|
-
|
|
2685
|
-
|
|
2686
|
-
|
|
2687
|
-
|
|
2688
|
-
|
|
2715
|
+
`<circle cx="${bc[0]}" cy="${bc[1]}" r="${r}" class="h hollow" data-h="end"/>` +
|
|
2716
|
+
(two ? `<circle cx="${cc[0]}" cy="${cc[1]}" r="${r}" class="h hollow" data-h="end2"/>` : '') +
|
|
2717
|
+
`<rect x="${th[0]-r}" y="${th[1]-r}" width="${2*r}" height="${2*r}" transform="rotate(45 ${th[0]} ${th[1]})" class="h thumb" data-h="str"/>` +
|
|
2718
|
+
T([ac[0] - away[0]*off*(two ? 0 : 1), ac[1] - away[1]*off*(two ? 0 : 1) + (two ? -off : 0)], 'sharp', 4*k) +
|
|
2719
|
+
T([bc[0] + away[0]*off, bc[1] + away[1]*off], `σ ${sigma.toFixed(0)}px from here${beyond ? ' · past the screen' : ''}`, 4*k) +
|
|
2720
|
+
(two ? T([cc[0] - away[0]*off, cc[1] - away[1]*off], `σ ${sigma.toFixed(0)}px from here${beyond2 ? ' · past the screen' : ''}`, 4*k) : '') +
|
|
2721
|
+
T([th[0] + away[0]*off*1.6, th[1] + away[1]*off*1.6], `${Math.round(dofStr*100)}%`, 4*k);
|
|
2722
|
+
}
|
|
2723
|
+
// pointer position in SCREENSHOT pixels
|
|
2724
|
+
function gizmoPos(e, g){
|
|
2689
2725
|
const rc = gz.getBoundingClientRect();
|
|
2690
|
-
const W = $('#outImg').naturalWidth,
|
|
2691
|
-
|
|
2726
|
+
const W = $('#outImg').naturalWidth, Hh = $('#outImg').naturalHeight;
|
|
2727
|
+
const photo = [(e.clientX - rc.left) * W / rc.width, (e.clientY - rc.top) * Hh / rc.height];
|
|
2728
|
+
return applyH(g.Hi, photo);
|
|
2692
2729
|
}
|
|
2693
2730
|
let gzDrag = null;
|
|
2694
2731
|
gz.addEventListener('pointerdown', e => {
|
|
@@ -2698,19 +2735,23 @@ gz.addEventListener('pointerdown', e => {
|
|
|
2698
2735
|
});
|
|
2699
2736
|
gz.addEventListener('pointermove', e => {
|
|
2700
2737
|
if (!gzDrag) return;
|
|
2701
|
-
const
|
|
2738
|
+
const g = gizmoGeom(); if (!g.Hi) return;
|
|
2739
|
+
const p = gizmoPos(e, g);
|
|
2702
2740
|
const f = (p[0]*g.d[0] + p[1]*g.d[1] - g.lo) / (g.hi - g.lo);
|
|
2741
|
+
const q = (p[0]*g.u[0] + p[1]*g.u[1] - g.ulo) / (g.uhi - g.ulo);
|
|
2703
2742
|
if (gzDrag.h === 'start'){
|
|
2704
|
-
dofStart = Math.max(0, Math.min(0.95, f));
|
|
2705
|
-
} else if (gzDrag.h === '
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
|
|
2709
|
-
|
|
2743
|
+
dofStart = Math.max(dofEnd2 === null ? 0 : dofEnd2 + 0.05, Math.min(0.95, Math.min(f, dofEnd - 0.05)));
|
|
2744
|
+
} else if (gzDrag.h === 'end'){
|
|
2745
|
+
dofEnd = Math.max(dofStart + 0.05, Math.min(1.5, f));
|
|
2746
|
+
} else if (gzDrag.h === 'end2'){
|
|
2747
|
+
dofEnd2 = Math.max(-0.5, Math.min(dofStart - 0.05, f));
|
|
2748
|
+
} else if (gzDrag.h === 'str'){
|
|
2749
|
+
dofStr = Math.max(0.05, Math.min(1, (q - 0.08) / 0.84));
|
|
2710
2750
|
} else {
|
|
2711
|
-
// Turn about the
|
|
2712
|
-
|
|
2713
|
-
const
|
|
2751
|
+
// Turn about the SHARP line's centre -- the plane of focus is what turns;
|
|
2752
|
+
// the pip under the pointer follows.
|
|
2753
|
+
const c = gzPt(g, dofStart, 0.5);
|
|
2754
|
+
const v = [p[0]-c[0], p[1]-c[1]];
|
|
2714
2755
|
const sign = gzDrag.h === 'rotB' ? 1 : -1; // rotB sits at +u, rotA at -u
|
|
2715
2756
|
dofAng = ((Math.atan2(v[1]*sign, v[0]*sign) * 180 / Math.PI - 90) % 360 + 360) % 360;
|
|
2716
2757
|
if (e.shiftKey) dofAng = Math.round(dofAng / 15) * 15;
|
|
@@ -3117,7 +3158,7 @@ async function playClip(){
|
|
|
3117
3158
|
// a preview the user did not ask for, and the wait goes silent again.
|
|
3118
3159
|
building = true;
|
|
3119
3160
|
try{
|
|
3120
|
-
await api('/api/preview_video', {corners: st.corners, radius_frac: radiusValue(), smoothing: smoothingValue(), dof_angle: dofAngle(), dof_strength: dofStrength(), dof_start: dofStartV(),
|
|
3161
|
+
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',
|
|
3121
3162
|
device: st.type, grade: gradeValue(),
|
|
3122
3163
|
reflection: emisValue(), fit_frame: +$('#vframe').value});
|
|
3123
3164
|
}catch(e){
|
|
@@ -3176,7 +3217,7 @@ async function renderPreview(){
|
|
|
3176
3217
|
const say = t => { if (!building && $('#liveWrap').hidden) s.textContent = t; };
|
|
3177
3218
|
say('Rendering…');
|
|
3178
3219
|
try{
|
|
3179
|
-
const r = await api('/api/preview', {corners: st.corners, radius_frac: radiusValue(), smoothing: smoothingValue(), dof_angle: dofAngle(), dof_strength: dofStrength(), dof_start: dofStartV(), device: st.type, grade: gradeValue(), reflection: emisValue()});
|
|
3220
|
+
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()});
|
|
3180
3221
|
const im = $('#outImg');
|
|
3181
3222
|
im.onload = () => {
|
|
3182
3223
|
outNat = im.naturalWidth;
|
|
@@ -3306,7 +3347,7 @@ async function renderVideo(){
|
|
|
3306
3347
|
b.disabled = true;
|
|
3307
3348
|
setRenderProgress(0);
|
|
3308
3349
|
try{
|
|
3309
|
-
await api('/api/render', {corners: st.corners, radius_frac: radiusValue(), smoothing: smoothingValue(), dof_angle: dofAngle(), dof_strength: dofStrength(), dof_start: dofStartV(), device: st.type,
|
|
3350
|
+
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,
|
|
3310
3351
|
grade: gradeValue(), reflection: emisValue(), preset, fit_frame: +$('#vframe').value});
|
|
3311
3352
|
}catch(e){
|
|
3312
3353
|
setRenderProgress(null);
|
|
@@ -3352,7 +3393,7 @@ $('#save').onclick = async () => {
|
|
|
3352
3393
|
if (st.video) return renderVideo();
|
|
3353
3394
|
const b = $('#save'); b.textContent = 'Saving…'; b.disabled = true;
|
|
3354
3395
|
try{
|
|
3355
|
-
const r = await api('/api/save', {corners: st.corners, radius_frac: radiusValue(), smoothing: smoothingValue(), dof_angle: dofAngle(), dof_strength: dofStrength(), dof_start: dofStartV(), device: st.type, grade: gradeValue(), reflection: emisValue()});
|
|
3396
|
+
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()});
|
|
3356
3397
|
b.textContent = saveLabel();
|
|
3357
3398
|
// The real destination, not a hardcoded one: --out-dir means saves usually
|
|
3358
3399
|
// land in the project folder now, and telling the user "~/Desktop" when
|