screengraft 0.51.0 → 0.53.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 +50 -6
- package/scripts/ui.py +30 -18
- package/scripts/warp.py +19 -6
- package/skills/inject-screenshot/SKILL.md +2 -2
- package/ui/index.html +149 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "screengraft",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.53.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
|
|
|
@@ -34,17 +44,23 @@ def sigma_max(corners, strength: float) -> float:
|
|
|
34
44
|
return float(np.clip(strength, 0.0, 1.0)) * DOF_MAX_FRAC * float(side)
|
|
35
45
|
|
|
36
46
|
|
|
37
|
-
def ramp(corners, angle_deg: float, x0: int, y0: int, w: int, h: int
|
|
38
|
-
|
|
47
|
+
def ramp(corners, angle_deg: float, x0: int, y0: int, w: int, h: int,
|
|
48
|
+
start: float = 0.0) -> np.ndarray:
|
|
49
|
+
"""Per-pixel 0..1 blur fraction along `angle` across the quad, over a window.
|
|
39
50
|
|
|
40
|
-
0
|
|
41
|
-
|
|
51
|
+
`start` (0..1) is where focus ends and the blur begins, as a fraction of
|
|
52
|
+
the quad's extent along the direction: 0 at the nearest extent (the whole
|
|
53
|
+
screen ramps, the default), 0.5 sharp across the near half then ramping.
|
|
54
|
+
1 at the farthest extent; pixels outside the quad clamp. The window is
|
|
55
|
+
(x0, y0, w, h) in photo pixels.
|
|
42
56
|
"""
|
|
43
57
|
a = math.radians(angle_deg)
|
|
44
58
|
d = np.array([math.cos(a), math.sin(a)], dtype=np.float64)
|
|
45
59
|
q = np.asarray(corners, dtype=np.float64)
|
|
46
60
|
proj = q @ d
|
|
47
61
|
lo, hi = float(proj.min()), float(proj.max())
|
|
62
|
+
start = float(np.clip(start, 0.0, 0.95))
|
|
63
|
+
lo = lo + start * (hi - lo)
|
|
48
64
|
if hi - lo < 1e-6:
|
|
49
65
|
return np.zeros((h, w), dtype=np.float32)
|
|
50
66
|
xs = np.arange(x0, x0 + w, dtype=np.float64)[None, :]
|
|
@@ -74,17 +90,45 @@ def _blur(img: np.ndarray, sigma: float) -> np.ndarray:
|
|
|
74
90
|
return cv2.GaussianBlur(img, (k, k), sigma, borderType=cv2.BORDER_REPLICATE)
|
|
75
91
|
|
|
76
92
|
|
|
93
|
+
def screen_ramp(src_w: int, src_h: int, angle_deg: float, start: float, end: float) -> np.ndarray:
|
|
94
|
+
"""The ramp in SCREENSHOT space: 0 up to `start`, 1 from `end`, linear
|
|
95
|
+
between, along `angle` (0 = toward +x, 90 = toward +y of the screenshot),
|
|
96
|
+
both as fractions of the screenshot's extent along that direction."""
|
|
97
|
+
a = math.radians(angle_deg)
|
|
98
|
+
d = np.array([math.cos(a), math.sin(a)], dtype=np.float64)
|
|
99
|
+
q = np.array([[0, 0], [src_w, 0], [src_w, src_h], [0, src_h]], dtype=np.float64)
|
|
100
|
+
proj = q @ d
|
|
101
|
+
lo, hi = float(proj.min()), float(proj.max())
|
|
102
|
+
start = float(np.clip(start, 0.0, 0.95))
|
|
103
|
+
end = float(np.clip(end, start + 0.05, 1.5))
|
|
104
|
+
s0, s1 = lo + start * (hi - lo), lo + end * (hi - lo)
|
|
105
|
+
xs = np.arange(src_w, dtype=np.float64)[None, :] + 0.5
|
|
106
|
+
ys = np.arange(src_h, dtype=np.float64)[:, None] + 0.5
|
|
107
|
+
t = (xs * d[0] + ys * d[1] - s0) / max(s1 - s0, 1e-6)
|
|
108
|
+
return np.clip(t, 0.0, 1.0).astype(np.float32)
|
|
109
|
+
|
|
110
|
+
|
|
77
111
|
class Field:
|
|
78
112
|
"""Everything that does not change per frame: the ramp, the weights, the
|
|
79
113
|
blurred masks. `blur_layer` then costs `levels - 1` blurs of the colour."""
|
|
80
114
|
|
|
81
115
|
def __init__(self, corners, angle_deg: float, strength: float, mask: np.ndarray,
|
|
82
|
-
x0: int, y0: int
|
|
116
|
+
x0: int, y0: int, start: float = 0.0, end: float = 1.0,
|
|
117
|
+
space: str = "photo", H=None, src_size=None):
|
|
83
118
|
h, w = mask.shape[:2]
|
|
84
119
|
self.x0, self.y0 = x0, y0
|
|
85
120
|
self.smax = sigma_max(corners, strength)
|
|
86
121
|
self.sigmas = [self.smax * k / (DOF_LEVELS - 1) for k in range(DOF_LEVELS)]
|
|
87
|
-
|
|
122
|
+
if space == "screen" and H is not None and src_size is not None:
|
|
123
|
+
sw, sh = src_size
|
|
124
|
+
src = screen_ramp(sw, sh, angle_deg, start, end)
|
|
125
|
+
T = np.array([[1, 0, -x0], [0, 1, -y0], [0, 0, 1]], dtype=np.float64)
|
|
126
|
+
t = cv2.warpPerspective(src, T @ np.asarray(H, dtype=np.float64), (w, h),
|
|
127
|
+
flags=cv2.INTER_LINEAR, borderMode=cv2.BORDER_REPLICATE)
|
|
128
|
+
t = np.clip(t, 0.0, 1.0).astype(np.float32)
|
|
129
|
+
else:
|
|
130
|
+
t = ramp(corners, angle_deg, x0, y0, w, h, start)
|
|
131
|
+
self.t = t # kept for tests and the trace
|
|
88
132
|
self.W = level_weights(t) # (L, h, w)
|
|
89
133
|
m = mask.astype(np.float32) / 255.0
|
|
90
134
|
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):
|
|
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,7 +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
|
-
|
|
607
|
+
**(dof or {}))
|
|
609
608
|
if kind == "preview":
|
|
610
609
|
# A preview publishes NOTHING. It is not a save: no sidecar, no fit
|
|
611
610
|
# file, and above all not the session output -- /api/import reads
|
|
@@ -668,15 +667,27 @@ def _blend_args(b):
|
|
|
668
667
|
|
|
669
668
|
|
|
670
669
|
def _dof_args(b):
|
|
671
|
-
"""
|
|
672
|
-
|
|
673
|
-
|
|
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
|
+
"""
|
|
674
679
|
try:
|
|
675
680
|
strength = float(b.get("dof_strength") or 0.0)
|
|
676
681
|
angle = float(b.get("dof_angle") or 0.0)
|
|
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)
|
|
677
684
|
except (TypeError, ValueError):
|
|
678
|
-
|
|
679
|
-
|
|
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
|
+
return {"dof_angle": angle % 360.0, "dof_strength": float(min(max(strength, 0.0), 1.0)),
|
|
689
|
+
"dof_start": start, "dof_end": float(min(max(end, start + 0.05), 1.5)),
|
|
690
|
+
"dof_space": space}
|
|
680
691
|
|
|
681
692
|
|
|
682
693
|
ROLES = ("photo", "screenshot")
|
|
@@ -1177,8 +1188,7 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1177
1188
|
blend, reflection, None, "preview",
|
|
1178
1189
|
fit_frame, max_frames),
|
|
1179
1190
|
kwargs={"smoothing": _smoothing(b),
|
|
1180
|
-
"
|
|
1181
|
-
"dof_strength": _dof_args(b)[1]}).start()
|
|
1191
|
+
"dof": _dof_args(b)}).start()
|
|
1182
1192
|
except BaseException:
|
|
1183
1193
|
with RENDER_LOCK:
|
|
1184
1194
|
RENDER.update(state="error", message="could not start the preview")
|
|
@@ -1212,7 +1222,7 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1212
1222
|
gr = float(b.get("grade") if b.get("grade") is not None else 0.0)
|
|
1213
1223
|
grain = bool(b.get("grain", gr > 0))
|
|
1214
1224
|
blend, reflection = _blend_args(b)
|
|
1215
|
-
|
|
1225
|
+
dof = _dof_args(b)
|
|
1216
1226
|
preset = "prores" if b.get("preset") == "prores" else "web"
|
|
1217
1227
|
ext = ".mov" if preset == "prores" else ".mp4"
|
|
1218
1228
|
os.makedirs(OUT_DIR, exist_ok=True)
|
|
@@ -1234,7 +1244,9 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1234
1244
|
"grade": gr, "grain": grain,
|
|
1235
1245
|
"video": True, "preset": preset, "fit_frame": fit_frame,
|
|
1236
1246
|
"blend": blend, "reflection": reflection,
|
|
1237
|
-
"dof_angle": dof_angle, "dof_strength": dof_strength,
|
|
1247
|
+
"dof_angle": dof["dof_angle"], "dof_strength": dof["dof_strength"],
|
|
1248
|
+
"dof_start": dof["dof_start"], "dof_end": dof["dof_end"],
|
|
1249
|
+
"dof_space": dof["dof_space"],
|
|
1238
1250
|
# A render is always the whole clip; only the preview
|
|
1239
1251
|
# passes a segment. Recorded anyway, because the
|
|
1240
1252
|
# sidecar's promise is EVERY argument that changes the
|
|
@@ -1262,8 +1274,7 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1262
1274
|
gr, grain, preset, fit_frame,
|
|
1263
1275
|
blend, reflection, result),
|
|
1264
1276
|
kwargs={"smoothing": _smoothing(b),
|
|
1265
|
-
"
|
|
1266
|
-
"dof_strength": dof_strength}).start()
|
|
1277
|
+
"dof": dof}).start()
|
|
1267
1278
|
except BaseException:
|
|
1268
1279
|
# If the thread cannot even be created, the flag must not
|
|
1269
1280
|
# outlive the request.
|
|
@@ -1286,12 +1297,11 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1286
1297
|
gr = float(b.get("grade") if b.get("grade") is not None else 0.0)
|
|
1287
1298
|
blend, reflection = _blend_args(b)
|
|
1288
1299
|
smoothing = _smoothing(b)
|
|
1289
|
-
|
|
1300
|
+
dof = _dof_args(b)
|
|
1290
1301
|
out = W.compose(photo, shot, corners, radius_px,
|
|
1291
1302
|
corner_smoothing=smoothing,
|
|
1292
1303
|
grade=gr, grain=bool(b.get("grain", gr > 0)),
|
|
1293
|
-
blend=blend, reflection=reflection,
|
|
1294
|
-
dof_angle=dof_angle, dof_strength=dof_strength)
|
|
1304
|
+
blend=blend, reflection=reflection, **dof)
|
|
1295
1305
|
SESSION.update(corners=corners, radius_frac=frac, device=b.get("device"),
|
|
1296
1306
|
grade=gr)
|
|
1297
1307
|
if u.path == "/api/preview":
|
|
@@ -1327,7 +1337,9 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1327
1337
|
"corner_smoothing": smoothing,
|
|
1328
1338
|
"grade": gr, "grain": bool(b.get("grain", gr > 0)),
|
|
1329
1339
|
"blend": blend, "reflection": reflection,
|
|
1330
|
-
"dof_angle": dof_angle, "dof_strength": dof_strength,
|
|
1340
|
+
"dof_angle": dof["dof_angle"], "dof_strength": dof["dof_strength"],
|
|
1341
|
+
"dof_start": dof["dof_start"], "dof_end": dof["dof_end"],
|
|
1342
|
+
"dof_space": dof["dof_space"],
|
|
1331
1343
|
"saved": time.time()}
|
|
1332
1344
|
# A fit is remembered when it PRODUCED something, not while it
|
|
1333
1345
|
# 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
|
|
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"):
|
|
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")
|
|
@@ -320,6 +321,9 @@ class Plan:
|
|
|
320
321
|
# exactly where the blur was meant to remove one.
|
|
321
322
|
self.dof_strength = float(np.clip(dof_strength, 0.0, 1.0))
|
|
322
323
|
self.dof_angle = float(dof_angle)
|
|
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"
|
|
323
327
|
reach = int(np.ceil(3 * _dof.sigma_max(dst_quad, self.dof_strength))) if self.dof_strength > 0 else 0
|
|
324
328
|
bx0, by0 = max(0, int(np.floor(xs.min())) - 1 - reach), max(0, int(np.floor(ys.min())) - 1 - reach)
|
|
325
329
|
bx1, by1 = min(pw, int(np.ceil(xs.max())) + 2 + reach), min(ph, int(np.ceil(ys.max())) + 2 + reach)
|
|
@@ -330,7 +334,10 @@ class Plan:
|
|
|
330
334
|
self.dof = None
|
|
331
335
|
if self.dof_strength > 0 and self.bbox is not None:
|
|
332
336
|
self.dof = _dof.Field(dst_quad, self.dof_angle, self.dof_strength,
|
|
333
|
-
self.warped_mask[by0:by1, bx0:bx1], bx0, by0
|
|
337
|
+
self.warped_mask[by0:by1, bx0:bx1], bx0, by0,
|
|
338
|
+
start=self.dof_start, end=self.dof_end,
|
|
339
|
+
space=self.dof_space, H=self.H,
|
|
340
|
+
src_size=(self.new_w, self.new_h))
|
|
334
341
|
|
|
335
342
|
def _prep(self, frame: np.ndarray, bbox=None) -> np.ndarray:
|
|
336
343
|
"""Warp one frame. With `bbox`, warp only that window of the canvas.
|
|
@@ -434,7 +441,9 @@ def compose(photo: np.ndarray, screenshot: np.ndarray, corners, corner_radius: f
|
|
|
434
441
|
grade: float = 0.0, grain: bool = False, screen_off: np.ndarray = None,
|
|
435
442
|
specular: float = 0.75, blend: str = "replace",
|
|
436
443
|
reflection: float = DEFAULT_REFLECTION,
|
|
437
|
-
dof_angle: float = 0.0, dof_strength: float = 0.0
|
|
444
|
+
dof_angle: float = 0.0, dof_strength: float = 0.0,
|
|
445
|
+
dof_start: float = 0.0, dof_end: float = 1.0,
|
|
446
|
+
dof_space: str = "photo") -> np.ndarray:
|
|
438
447
|
"""Warp `screenshot` into the quad `corners` (TL,TR,BR,BL, photo pixels) on `photo`.
|
|
439
448
|
|
|
440
449
|
Single resampling pass at the photo's resolution; deterministic. This is the
|
|
@@ -448,7 +457,8 @@ def compose(photo: np.ndarray, screenshot: np.ndarray, corners, corner_radius: f
|
|
|
448
457
|
plan = Plan(photo, screenshot.shape, corners, corner_radius, grain=grain,
|
|
449
458
|
corner_smoothing=corner_smoothing,
|
|
450
459
|
blend=blend, reflection=reflection,
|
|
451
|
-
dof_angle=dof_angle, dof_strength=dof_strength
|
|
460
|
+
dof_angle=dof_angle, dof_strength=dof_strength, dof_start=dof_start,
|
|
461
|
+
dof_end=dof_end, dof_space=dof_space)
|
|
452
462
|
plan.bind_grade(screenshot, grade)
|
|
453
463
|
return plan.render(screenshot, screen_off=screen_off, specular=specular)
|
|
454
464
|
|
|
@@ -536,7 +546,9 @@ def compose_video(photo: np.ndarray, video_path: str, corners, output: str,
|
|
|
536
546
|
frames_dir: str = None, progress=None, blend: str = "replace",
|
|
537
547
|
reflection: float = DEFAULT_REFLECTION,
|
|
538
548
|
start_frame: int = 0, max_frames: int = None,
|
|
539
|
-
dof_angle: float = 0.0, dof_strength: float = 0.0
|
|
549
|
+
dof_angle: float = 0.0, dof_strength: float = 0.0,
|
|
550
|
+
dof_start: float = 0.0, dof_end: float = 1.0,
|
|
551
|
+
dof_space: str = "photo") -> dict:
|
|
540
552
|
"""Inject a VIDEO into a still photo. The photo does not move, so there is
|
|
541
553
|
exactly one homography and the whole of Plan is computed once.
|
|
542
554
|
|
|
@@ -564,7 +576,8 @@ def compose_video(photo: np.ndarray, video_path: str, corners, output: str,
|
|
|
564
576
|
plan = Plan(photo, first.shape, corners, corner_radius, grain=grain,
|
|
565
577
|
corner_smoothing=corner_smoothing,
|
|
566
578
|
blend=blend, reflection=reflection,
|
|
567
|
-
dof_angle=dof_angle, dof_strength=dof_strength
|
|
579
|
+
dof_angle=dof_angle, dof_strength=dof_strength, dof_start=dof_start,
|
|
580
|
+
dof_end=dof_end, dof_space=dof_space)
|
|
568
581
|
plan.bind_grade(first, grade)
|
|
569
582
|
|
|
570
583
|
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.53):** 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. 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.
|
|
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). 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
|
@@ -625,6 +625,23 @@
|
|
|
625
625
|
|
|
626
626
|
#outPane{display:grid}
|
|
627
627
|
#outWrap{overflow:auto;background:var(--well);display:grid;box-shadow:inset 0 1px 3px rgba(0,0,0,.5)}
|
|
628
|
+
/* The depth-of-field gizmo: an SVG laid exactly over the composite, drawn
|
|
629
|
+
in PHOTO pixels (its viewBox is the photo) so every coordinate is the
|
|
630
|
+
engine's own. Faint until the pointer is over the pane, so it never sits
|
|
631
|
+
on the composite being judged; solid while a handle is being dragged. */
|
|
632
|
+
#outWrap{position:relative}
|
|
633
|
+
#dofGizmo{position:absolute;pointer-events:none;opacity:.4;transition:opacity .15s;overflow:visible}
|
|
634
|
+
#outWrap:hover #dofGizmo,#dofGizmo.drag{opacity:1}
|
|
635
|
+
#dofGizmo .case{stroke:rgba(0,0,0,.6);fill:none;vector-effect:non-scaling-stroke}
|
|
636
|
+
#dofGizmo .focus{stroke:#f5623d;stroke-width:1.5;fill:none;vector-effect:non-scaling-stroke}
|
|
637
|
+
#dofGizmo .far{stroke:#f5623d;stroke-width:1.25;stroke-dasharray:6 6;fill:none;vector-effect:non-scaling-stroke}
|
|
638
|
+
#dofGizmo .far.out{stroke-dasharray:3 7;opacity:.75}
|
|
639
|
+
#dofGizmo .h{fill:#f5623d;stroke:#fff;stroke-width:1.5;vector-effect:non-scaling-stroke;pointer-events:auto;cursor:grab}
|
|
640
|
+
#dofGizmo .h.hollow{fill:#1a1a1a}
|
|
641
|
+
#dofGizmo .h.pip{fill:#f5623d}
|
|
642
|
+
#dofGizmo .h.thumb{fill:#fff;stroke:#f5623d;stroke-width:2}
|
|
643
|
+
#dofGizmo .h:active{cursor:grabbing}
|
|
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}
|
|
628
645
|
#outImg,#outVid{display:block;margin:auto}
|
|
629
646
|
/* The live layer stacks the clip on the photo. `position:relative` on the
|
|
630
647
|
wrapper and `transform-origin:0 0` on the video are what make the matrix3d
|
|
@@ -1023,7 +1040,7 @@
|
|
|
1023
1040
|
<span class="spacer"></span>
|
|
1024
1041
|
<span class="sm" style="color:var(--mute)">shares zoom & pan</span>
|
|
1025
1042
|
</div>
|
|
1026
|
-
<div id="outWrap"><span class="empty" id="outEmpty">The composite appears here and updates as you drag.</span><img id="outImg" alt="" hidden><video id="outVid" hidden loop muted playsinline controls></video><!--
|
|
1043
|
+
<div id="outWrap"><span class="empty" id="outEmpty">The composite appears here and updates as you drag.</span><img id="outImg" alt="" hidden><video id="outVid" hidden loop muted playsinline controls></video><svg id="dofGizmo" hidden aria-label="Depth of field: drag the solid line to place focus, its ends to turn it, the dashed line for strength"></svg><!--
|
|
1027
1044
|
The live layer: the PHOTOGRAPH with the source clip warped onto the
|
|
1028
1045
|
fitted quad by the browser. Not the composite — the composite already
|
|
1029
1046
|
has a frame burned into it, so playing over that would show two screens.
|
|
@@ -1126,7 +1143,7 @@
|
|
|
1126
1143
|
<div class="sect-body"><div class="sect-body-in">
|
|
1127
1144
|
<div style="display:grid;gap:var(--s2)">
|
|
1128
1145
|
<div style="display:flex;align-items:center;justify-content:space-between;gap:var(--s2)">
|
|
1129
|
-
<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>
|
|
1130
1147
|
<span class="sm" id="dofDirVal" style="font-variant-numeric:tabular-nums"></span>
|
|
1131
1148
|
</div>
|
|
1132
1149
|
<input type="range" id="dofDir" min="0" max="355" step="5" value="90" aria-label="Direction the blur grows in">
|
|
@@ -2541,8 +2558,10 @@ $('#emisAmt').onchange = e => { setEmis(true, parseFloat(e.target.value)); autoP
|
|
|
2541
2558
|
let dofOn = recall('dof','0') === '1';
|
|
2542
2559
|
let dofAng = parseFloat(recall('dofAng','90')) || 0;
|
|
2543
2560
|
let dofStr = parseFloat(recall('dofStr','0.3')); if (!(dofStr >= 0)) dofStr = 0.3;
|
|
2561
|
+
let dofStart = parseFloat(recall('dofStart','0')); if (!(dofStart >= 0)) dofStart = 0;
|
|
2544
2562
|
function dofAngle(){ return dofOn ? dofAng : 0; }
|
|
2545
2563
|
function dofStrength(){ return dofOn ? dofStr : 0; }
|
|
2564
|
+
function dofStartV(){ return dofOn ? dofStart : 0; }
|
|
2546
2565
|
function dofWord(a){
|
|
2547
2566
|
const names = ['the right','the bottom-right','the bottom','the bottom-left','the left','the top-left','the top','the top-right'];
|
|
2548
2567
|
return names[Math.round(((a % 360) + 360) % 360 / 45) % 8];
|
|
@@ -2558,9 +2577,10 @@ function setDof(on, ang, str){
|
|
|
2558
2577
|
dofOn = on;
|
|
2559
2578
|
if (ang !== undefined) dofAng = ((ang % 360) + 360) % 360;
|
|
2560
2579
|
if (str !== undefined) dofStr = Math.max(0, Math.min(1, str));
|
|
2561
|
-
remember('dof', on ? '1' : '0'); remember('dofAng', String(dofAng)); remember('dofStr', String(dofStr));
|
|
2580
|
+
remember('dof', on ? '1' : '0'); remember('dofAng', String(dofAng)); remember('dofStr', String(dofStr)); remember('dofStart', String(dofStart)); remember('dofEnd', String(dofEnd));
|
|
2562
2581
|
setSectionOpen($('#secDof'), on);
|
|
2563
2582
|
paintDof();
|
|
2583
|
+
paintGizmo();
|
|
2564
2584
|
}
|
|
2565
2585
|
let dofMeasuredFor = null; // photo path the last measurement was for
|
|
2566
2586
|
async function measureDof(){
|
|
@@ -2576,7 +2596,22 @@ async function measureDof(){
|
|
|
2576
2596
|
autoPreview(); // still on, at the slider's own numbers
|
|
2577
2597
|
return;
|
|
2578
2598
|
}
|
|
2579
|
-
|
|
2599
|
+
// The estimator's angle is in PHOTO space; the gizmo and the engine work in
|
|
2600
|
+
// the screen's own plane. Map a short step from the quad's centre through
|
|
2601
|
+
// the inverse homography and take its direction there.
|
|
2602
|
+
let ang = r.angle;
|
|
2603
|
+
if (st.shotSize){
|
|
2604
|
+
const [sw, sh] = st.shotSize, rect = [[0,0],[sw,0],[sw,sh],[0,sh]];
|
|
2605
|
+
const Hi = solveHomography(st.corners, rect);
|
|
2606
|
+
if (Hi){
|
|
2607
|
+
const c = st.corners.reduce((acc,cc)=>[acc[0]+cc[0]/4, acc[1]+cc[1]/4],[0,0]);
|
|
2608
|
+
const a = r.angle * Math.PI / 180, step = 40;
|
|
2609
|
+
const p0 = applyH(Hi, c), p1 = applyH(Hi, [c[0] + Math.cos(a)*step, c[1] + Math.sin(a)*step]);
|
|
2610
|
+
ang = (Math.atan2(p1[1]-p0[1], p1[0]-p0[0]) * 180 / Math.PI + 360) % 360;
|
|
2611
|
+
}
|
|
2612
|
+
}
|
|
2613
|
+
dofStart = 0; dofEnd = 1; setDof(true, ang, Math.max(r.strength, 0.05));
|
|
2614
|
+
|
|
2580
2615
|
s.className = 'status sm ok';
|
|
2581
2616
|
s.textContent = `Measured: grows toward ${dofWord(r.angle)}. Strength is a floor — raise it if the far end looks softer.`;
|
|
2582
2617
|
s.title = `Edge blur ${r.sigma.map(v => v.toFixed(1)).join(' / ')}px, spread ${r.spread}×`;
|
|
@@ -2595,6 +2630,110 @@ $('#dofStr').oninput = e => { dofStr = parseFloat(e.target.value); paintDof();
|
|
|
2595
2630
|
$('#dofStr').onchange = e => { setDof(true, undefined, parseFloat(e.target.value)); autoPreview(); };
|
|
2596
2631
|
$('#dofMeasure').onclick = measureDof;
|
|
2597
2632
|
|
|
2633
|
+
/* ===== the depth-of-field gizmo ============================================
|
|
2634
|
+
Two lines over the composite, the graduated-filter idiom (Lightroom's
|
|
2635
|
+
gradient, Photoshop's tilt-shift) -- defined IN THE SCREEN'S OWN PLANE and
|
|
2636
|
+
projected through the fit's homography. The plane of focus cuts the screen
|
|
2637
|
+
along a line and blur grows with depth along the screen, so iso-blur lines
|
|
2638
|
+
are parallel on the screen and converge in the photograph like the phone's
|
|
2639
|
+
own edges; drawn in photo space they were parallel on the picture and wrong
|
|
2640
|
+
on any steep fit (reported 13 Sep 2026). The SOLID line is where focus
|
|
2641
|
+
ends; the DASHED line is where the blur reaches its full amount, and it can
|
|
2642
|
+
sit anywhere on the screen or a little past it. Handles:
|
|
2643
|
+
solid line centre -- move focus (start)
|
|
2644
|
+
dashed line centre -- move where the ramp ends (end); spacing = ramp length
|
|
2645
|
+
dashed line pips -- turn both lines (direction), ⇧ snaps to 15°
|
|
2646
|
+
diamond on the dashed line -- slide along it for how much blur (strength)
|
|
2647
|
+
Everything is computed in screenshot pixels and mapped to the photo with H;
|
|
2648
|
+
the SVG's viewBox is the photo so it stays aligned at every zoom for free. */
|
|
2649
|
+
const gz = $('#dofGizmo');
|
|
2650
|
+
const SIGMA_FULL_FRAC = 0.02; // mirrors dof.DOF_MAX_FRAC -- keep in step
|
|
2651
|
+
let dofEnd = parseFloat(recall('dofEnd','1')); if (!(dofEnd > 0)) dofEnd = 1;
|
|
2652
|
+
function dofEndV(){ return dofOn ? dofEnd : 1; }
|
|
2653
|
+
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]; }
|
|
2654
|
+
function gizmoGeom(){
|
|
2655
|
+
const [sw, sh] = st.shotSize, a = dofAng * Math.PI / 180;
|
|
2656
|
+
const rect = [[0,0],[sw,0],[sw,sh],[0,sh]];
|
|
2657
|
+
const H = solveHomography(rect, st.corners), Hi = solveHomography(st.corners, rect);
|
|
2658
|
+
const d = [Math.cos(a), Math.sin(a)], u = [-d[1], d[0]];
|
|
2659
|
+
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]);
|
|
2660
|
+
const C = st.corners;
|
|
2661
|
+
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])));
|
|
2662
|
+
return {H, Hi, d, u, lo: Math.min(...pd), hi: Math.max(...pd), ulo: Math.min(...pu), uhi: Math.max(...pu), side};
|
|
2663
|
+
}
|
|
2664
|
+
// a screen-space point at fraction f along d and fraction k across u
|
|
2665
|
+
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]]; }
|
|
2666
|
+
function paintGizmo(){
|
|
2667
|
+
const im = $('#outImg');
|
|
2668
|
+
const show = dofOn && st.corners && st.shotSize && !im.hidden && im.naturalWidth && $('#liveWrap').hidden;
|
|
2669
|
+
gz.toggleAttribute('hidden', !show);
|
|
2670
|
+
if (!show) return;
|
|
2671
|
+
const W = im.naturalWidth, Hh = im.naturalHeight;
|
|
2672
|
+
gz.setAttribute('viewBox', `0 0 ${W} ${Hh}`);
|
|
2673
|
+
gz.style.left = im.offsetLeft + 'px'; gz.style.top = im.offsetTop + 'px';
|
|
2674
|
+
gz.style.width = im.clientWidth + 'px'; gz.style.height = im.clientHeight + 'px';
|
|
2675
|
+
const k = W / Math.max(im.clientWidth, 1); // photo px per screen px
|
|
2676
|
+
const g = gizmoGeom(); if (!g.H || !g.Hi) return;
|
|
2677
|
+
const P = (f, q) => applyH(g.H, gzPt(g, f, q));
|
|
2678
|
+
const a0 = P(dofStart, -0.06), a1 = P(dofStart, 1.06), ac = P(dofStart, 0.5);
|
|
2679
|
+
const b0 = P(dofEnd, -0.06), b1 = P(dofEnd, 1.06), bc = P(dofEnd, 0.5);
|
|
2680
|
+
const th = P(dofEnd, 0.08 + 0.84 * dofStr); // the strength thumb slides along the dashed line
|
|
2681
|
+
const sigma = dofStr * SIGMA_FULL_FRAC * g.side;
|
|
2682
|
+
const r = 6 * k, rp = 4 * k, fs11 = 11 * k, off = 14 * k;
|
|
2683
|
+
const L = (x, y, cls, extra='') => `<line x1="${x[0]}" y1="${x[1]}" x2="${y[0]}" y2="${y[1]}" class="${cls}" ${extra}/>`;
|
|
2684
|
+
const T = (p, txt, dy) => `<text x="${p[0]}" y="${p[1] + dy}" text-anchor="middle" font-size="${fs11}">${txt}</text>`;
|
|
2685
|
+
const away = unit(sub(bc, ac)); // label offsets: away from the other line
|
|
2686
|
+
const beyond = dofEnd > 1.0001;
|
|
2687
|
+
gz.innerHTML =
|
|
2688
|
+
L(a0, a1, 'case', 'stroke-width="4"') + L(a0, a1, 'focus') +
|
|
2689
|
+
L(b0, b1, 'case', 'stroke-width="3.5"') + L(b0, b1, 'far' + (beyond ? ' out' : '')) +
|
|
2690
|
+
`<circle cx="${ac[0]}" cy="${ac[1]}" r="${r}" class="h" data-h="start"/>` +
|
|
2691
|
+
`<circle cx="${b0[0]}" cy="${b0[1]}" r="${rp}" class="h pip" data-h="rotA"/>` +
|
|
2692
|
+
`<circle cx="${b1[0]}" cy="${b1[1]}" r="${rp}" class="h pip" data-h="rotB"/>` +
|
|
2693
|
+
`<circle cx="${bc[0]}" cy="${bc[1]}" r="${r}" class="h hollow" data-h="end"/>` +
|
|
2694
|
+
`<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"/>` +
|
|
2695
|
+
T([ac[0] - away[0]*off, ac[1] - away[1]*off], 'sharp', 4*k) +
|
|
2696
|
+
T([bc[0] + away[0]*off, bc[1] + away[1]*off], `σ ${sigma.toFixed(0)}px from here${beyond ? ' · past the screen' : ''}`, 4*k) +
|
|
2697
|
+
T([th[0] + away[0]*off*1.6, th[1] + away[1]*off*1.6], `${Math.round(dofStr*100)}%`, 4*k);
|
|
2698
|
+
}
|
|
2699
|
+
// pointer position in SCREENSHOT pixels
|
|
2700
|
+
function gizmoPos(e, g){
|
|
2701
|
+
const rc = gz.getBoundingClientRect();
|
|
2702
|
+
const W = $('#outImg').naturalWidth, Hh = $('#outImg').naturalHeight;
|
|
2703
|
+
const photo = [(e.clientX - rc.left) * W / rc.width, (e.clientY - rc.top) * Hh / rc.height];
|
|
2704
|
+
return applyH(g.Hi, photo);
|
|
2705
|
+
}
|
|
2706
|
+
let gzDrag = null;
|
|
2707
|
+
gz.addEventListener('pointerdown', e => {
|
|
2708
|
+
const h = e.target.dataset && e.target.dataset.h; if (!h) return;
|
|
2709
|
+
e.preventDefault(); gz.setPointerCapture(e.pointerId);
|
|
2710
|
+
gzDrag = {h}; gz.classList.add('drag');
|
|
2711
|
+
});
|
|
2712
|
+
gz.addEventListener('pointermove', e => {
|
|
2713
|
+
if (!gzDrag) return;
|
|
2714
|
+
const g = gizmoGeom(); if (!g.Hi) return;
|
|
2715
|
+
const p = gizmoPos(e, g);
|
|
2716
|
+
const f = (p[0]*g.d[0] + p[1]*g.d[1] - g.lo) / (g.hi - g.lo);
|
|
2717
|
+
const q = (p[0]*g.u[0] + p[1]*g.u[1] - g.ulo) / (g.uhi - g.ulo);
|
|
2718
|
+
if (gzDrag.h === 'start'){
|
|
2719
|
+
dofStart = Math.max(0, Math.min(0.95, Math.min(f, dofEnd - 0.05)));
|
|
2720
|
+
} else if (gzDrag.h === 'end'){
|
|
2721
|
+
dofEnd = Math.max(dofStart + 0.05, Math.min(1.5, f));
|
|
2722
|
+
} else if (gzDrag.h === 'str'){
|
|
2723
|
+
dofStr = Math.max(0.05, Math.min(1, (q - 0.08) / 0.84));
|
|
2724
|
+
} else {
|
|
2725
|
+
// Turn about the dashed line's centre; the pip under the pointer follows.
|
|
2726
|
+
const c = gzPt(g, dofEnd, 0.5);
|
|
2727
|
+
const v = [p[0]-c[0], p[1]-c[1]];
|
|
2728
|
+
const sign = gzDrag.h === 'rotB' ? 1 : -1; // rotB sits at +u, rotA at -u
|
|
2729
|
+
dofAng = ((Math.atan2(v[1]*sign, v[0]*sign) * 180 / Math.PI - 90) % 360 + 360) % 360;
|
|
2730
|
+
if (e.shiftKey) dofAng = Math.round(dofAng / 15) * 15;
|
|
2731
|
+
}
|
|
2732
|
+
paintDof(); paintGizmo(); autoPreview();
|
|
2733
|
+
});
|
|
2734
|
+
const gzUp = e => { if (!gzDrag) return; gzDrag = null; gz.classList.remove('drag'); setDof(true); autoPreview(); };
|
|
2735
|
+
gz.addEventListener('pointerup', gzUp); gz.addEventListener('pointercancel', gzUp);
|
|
2736
|
+
|
|
2598
2737
|
$('#edgeBtn').onclick = () => setLoupeMode(loupeMode === 'float' ? 'dock' : 'float');
|
|
2599
2738
|
|
|
2600
2739
|
/* Contrast for the strip only. A dark screen on a dark frame puts the
|
|
@@ -2800,6 +2939,7 @@ function syncOut(){
|
|
|
2800
2939
|
const a = $('#scroller'), b = $('#outWrap');
|
|
2801
2940
|
b.scrollLeft = a.scrollLeft; b.scrollTop = a.scrollTop;
|
|
2802
2941
|
}
|
|
2942
|
+
paintGizmo();
|
|
2803
2943
|
}
|
|
2804
2944
|
$('#scroller').addEventListener('scroll', () => {
|
|
2805
2945
|
syncOut();
|
|
@@ -2822,6 +2962,7 @@ function autoPreview(){
|
|
|
2822
2962
|
it. */
|
|
2823
2963
|
function showResult(what){
|
|
2824
2964
|
$('#outImg').hidden = what !== 'still';
|
|
2965
|
+
if (what !== 'still') $('#dofGizmo').setAttribute('hidden', '');
|
|
2825
2966
|
const v = $('#outVid');
|
|
2826
2967
|
if (what !== 'clip' && !v.hidden){ try { v.pause(); } catch(e){} }
|
|
2827
2968
|
v.hidden = what !== 'clip';
|
|
@@ -2990,7 +3131,7 @@ async function playClip(){
|
|
|
2990
3131
|
// a preview the user did not ask for, and the wait goes silent again.
|
|
2991
3132
|
building = true;
|
|
2992
3133
|
try{
|
|
2993
|
-
await api('/api/preview_video', {corners: st.corners, radius_frac: radiusValue(), smoothing: smoothingValue(), dof_angle: dofAngle(), dof_strength: dofStrength(),
|
|
3134
|
+
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_space: 'screen',
|
|
2994
3135
|
device: st.type, grade: gradeValue(),
|
|
2995
3136
|
reflection: emisValue(), fit_frame: +$('#vframe').value});
|
|
2996
3137
|
}catch(e){
|
|
@@ -3049,7 +3190,7 @@ async function renderPreview(){
|
|
|
3049
3190
|
const say = t => { if (!building && $('#liveWrap').hidden) s.textContent = t; };
|
|
3050
3191
|
say('Rendering…');
|
|
3051
3192
|
try{
|
|
3052
|
-
const r = await api('/api/preview', {corners: st.corners, radius_frac: radiusValue(), smoothing: smoothingValue(), dof_angle: dofAngle(), dof_strength: dofStrength(), device: st.type, grade: gradeValue(), reflection: emisValue()});
|
|
3193
|
+
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_space: 'screen', device: st.type, grade: gradeValue(), reflection: emisValue()});
|
|
3053
3194
|
const im = $('#outImg');
|
|
3054
3195
|
im.onload = () => {
|
|
3055
3196
|
outNat = im.naturalWidth;
|
|
@@ -3179,7 +3320,7 @@ async function renderVideo(){
|
|
|
3179
3320
|
b.disabled = true;
|
|
3180
3321
|
setRenderProgress(0);
|
|
3181
3322
|
try{
|
|
3182
|
-
await api('/api/render', {corners: st.corners, radius_frac: radiusValue(), smoothing: smoothingValue(), dof_angle: dofAngle(), dof_strength: dofStrength(), device: st.type,
|
|
3323
|
+
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_space: 'screen', device: st.type,
|
|
3183
3324
|
grade: gradeValue(), reflection: emisValue(), preset, fit_frame: +$('#vframe').value});
|
|
3184
3325
|
}catch(e){
|
|
3185
3326
|
setRenderProgress(null);
|
|
@@ -3225,7 +3366,7 @@ $('#save').onclick = async () => {
|
|
|
3225
3366
|
if (st.video) return renderVideo();
|
|
3226
3367
|
const b = $('#save'); b.textContent = 'Saving…'; b.disabled = true;
|
|
3227
3368
|
try{
|
|
3228
|
-
const r = await api('/api/save', {corners: st.corners, radius_frac: radiusValue(), smoothing: smoothingValue(), dof_angle: dofAngle(), dof_strength: dofStrength(), device: st.type, grade: gradeValue(), reflection: emisValue()});
|
|
3369
|
+
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_space: 'screen', device: st.type, grade: gradeValue(), reflection: emisValue()});
|
|
3229
3370
|
b.textContent = saveLabel();
|
|
3230
3371
|
// The real destination, not a hardcoded one: --out-dir means saves usually
|
|
3231
3372
|
// land in the project folder now, and telling the user "~/Desktop" when
|