screengraft 0.49.0 → 0.51.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/package.json +1 -1
- package/scripts/dof.py +231 -0
- package/scripts/scan.py +7 -1
- package/scripts/ui.py +61 -5
- package/scripts/warp.py +35 -8
- package/skills/inject-screenshot/SKILL.md +4 -2
- package/ui/index.html +223 -30
package/README.md
CHANGED
|
@@ -29,6 +29,10 @@ prototype, then put the recording inside a real photograph.
|
|
|
29
29
|
- **Realism pass** *(optional)* — matches the screen's white balance and grain to
|
|
30
30
|
the light in the room, and can lift the device's real reflections from a
|
|
31
31
|
screen-off frame of the same shot.
|
|
32
|
+
- **Depth of field** *(optional)* — a blur that grows across the screen in one
|
|
33
|
+
direction, glass edge included, so a screenshot on a phone shot at an angle
|
|
34
|
+
goes soft where the phone does. Direction and strength are yours; *Measure
|
|
35
|
+
from photo* proposes them from the photograph's own screen boundary.
|
|
32
36
|
- **Emissive screens** *(optional)* — a display emits light *and* reflects the
|
|
33
37
|
room, which is why a switched-off phone looks dark grey rather than black.
|
|
34
38
|
Paint a true-black UI on flat and it reads as a hole cut in the photo. Turn
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "screengraft",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.51.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
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""Depth of field across the screen — a blur that grows in one direction.
|
|
2
|
+
|
|
3
|
+
A phone photographed at an angle is a plane receding from the camera, so
|
|
4
|
+
focus falls off *linearly across the quad*: sharp at the near edge, softer
|
|
5
|
+
toward the far one. Two numbers describe it — the direction the blur grows in
|
|
6
|
+
(`angle`, degrees, photo space, 0 = toward +x, 90 = toward +y) and how fast
|
|
7
|
+
(`strength`, 0..1) — and both can be read off the photograph, because the
|
|
8
|
+
bezel around the screen already carries the camera's own defocus.
|
|
9
|
+
|
|
10
|
+
Blur is applied in PHOTO space, after the warp, to the premultiplied screen
|
|
11
|
+
layer and its mask together (`blur_layer`). Blurring the layer alone would
|
|
12
|
+
pull the black outside the quad into its edge and leave a sharp alpha edge
|
|
13
|
+
inside a soft bezel — the tell in a bad mockup. Premultiplied, the edge
|
|
14
|
+
softens exactly as the colour does.
|
|
15
|
+
|
|
16
|
+
Spatially varying Gaussian: DOF_LEVELS blur levels, per pixel a linear blend
|
|
17
|
+
of the two nearest. Standard, cheap (five separable blurs on the quad's
|
|
18
|
+
window), and byte-identical to no blur at strength 0.
|
|
19
|
+
"""
|
|
20
|
+
import math
|
|
21
|
+
|
|
22
|
+
import cv2
|
|
23
|
+
import numpy as np
|
|
24
|
+
|
|
25
|
+
DOF_LEVELS = 5 # sigma steps between 0 and sigma_max (0 is the identity)
|
|
26
|
+
DOF_MAX_FRAC = 0.02 # strength 1.0 = sigma of 2% of the quad's longer side (16px on an 800px screen)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def sigma_max(corners, strength: float) -> float:
|
|
30
|
+
"""Blur at the far edge, in photo pixels, for a given strength."""
|
|
31
|
+
q = np.asarray(corners, dtype=np.float64)
|
|
32
|
+
side = max(np.linalg.norm(q[1] - q[0]), np.linalg.norm(q[2] - q[1]),
|
|
33
|
+
np.linalg.norm(q[3] - q[2]), np.linalg.norm(q[0] - q[3]))
|
|
34
|
+
return float(np.clip(strength, 0.0, 1.0)) * DOF_MAX_FRAC * float(side)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def ramp(corners, angle_deg: float, x0: int, y0: int, w: int, h: int) -> np.ndarray:
|
|
38
|
+
"""Per-pixel 0..1 distance along `angle` across the quad, over a window.
|
|
39
|
+
|
|
40
|
+
0 at the quad's nearest extent in that direction, 1 at its farthest; pixels
|
|
41
|
+
outside the quad clamp. The window is (x0, y0, w, h) in photo pixels.
|
|
42
|
+
"""
|
|
43
|
+
a = math.radians(angle_deg)
|
|
44
|
+
d = np.array([math.cos(a), math.sin(a)], dtype=np.float64)
|
|
45
|
+
q = np.asarray(corners, dtype=np.float64)
|
|
46
|
+
proj = q @ d
|
|
47
|
+
lo, hi = float(proj.min()), float(proj.max())
|
|
48
|
+
if hi - lo < 1e-6:
|
|
49
|
+
return np.zeros((h, w), dtype=np.float32)
|
|
50
|
+
xs = np.arange(x0, x0 + w, dtype=np.float64)[None, :]
|
|
51
|
+
ys = np.arange(y0, y0 + h, dtype=np.float64)[:, None]
|
|
52
|
+
t = (xs * d[0] + ys * d[1] - lo) / (hi - lo)
|
|
53
|
+
return np.clip(t, 0.0, 1.0).astype(np.float32)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def level_weights(t: np.ndarray, levels: int = DOF_LEVELS):
|
|
57
|
+
"""Blend weights (levels, h, w): each pixel splits between its two nearest
|
|
58
|
+
sigma steps. Sums to 1 everywhere."""
|
|
59
|
+
pos = t * (levels - 1)
|
|
60
|
+
lo = np.floor(pos).astype(np.int32)
|
|
61
|
+
lo = np.clip(lo, 0, levels - 2)
|
|
62
|
+
f = (pos - lo).astype(np.float32)
|
|
63
|
+
W = np.zeros((levels,) + t.shape, dtype=np.float32)
|
|
64
|
+
rows, cols = np.indices(t.shape)
|
|
65
|
+
W[lo, rows, cols] = 1.0 - f
|
|
66
|
+
W[lo + 1, rows, cols] = f
|
|
67
|
+
return W
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _blur(img: np.ndarray, sigma: float) -> np.ndarray:
|
|
71
|
+
if sigma <= 0.0:
|
|
72
|
+
return img
|
|
73
|
+
k = int(2 * math.ceil(3 * sigma) + 1)
|
|
74
|
+
return cv2.GaussianBlur(img, (k, k), sigma, borderType=cv2.BORDER_REPLICATE)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class Field:
|
|
78
|
+
"""Everything that does not change per frame: the ramp, the weights, the
|
|
79
|
+
blurred masks. `blur_layer` then costs `levels - 1` blurs of the colour."""
|
|
80
|
+
|
|
81
|
+
def __init__(self, corners, angle_deg: float, strength: float, mask: np.ndarray,
|
|
82
|
+
x0: int, y0: int):
|
|
83
|
+
h, w = mask.shape[:2]
|
|
84
|
+
self.x0, self.y0 = x0, y0
|
|
85
|
+
self.smax = sigma_max(corners, strength)
|
|
86
|
+
self.sigmas = [self.smax * k / (DOF_LEVELS - 1) for k in range(DOF_LEVELS)]
|
|
87
|
+
t = ramp(corners, angle_deg, x0, y0, w, h)
|
|
88
|
+
self.W = level_weights(t) # (L, h, w)
|
|
89
|
+
m = mask.astype(np.float32) / 255.0
|
|
90
|
+
self.masks = [_blur(m, s) for s in self.sigmas] # each (h, w)
|
|
91
|
+
self.alpha = np.zeros_like(m)
|
|
92
|
+
for k in range(DOF_LEVELS):
|
|
93
|
+
self.alpha += self.W[k] * self.masks[k]
|
|
94
|
+
self.alpha3 = np.repeat(self.alpha[:, :, None], 3, axis=2)
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def pad(self) -> int:
|
|
98
|
+
"""How far the soft edge reaches outside the sharp mask."""
|
|
99
|
+
return int(math.ceil(3 * self.smax))
|
|
100
|
+
|
|
101
|
+
def blur_layer(self, colour: np.ndarray) -> np.ndarray:
|
|
102
|
+
"""Blur a warped screen window (float32 BGR, h×w×3) by the field.
|
|
103
|
+
|
|
104
|
+
Premultiplied by the sharp mask, blurred per level, blended by the
|
|
105
|
+
weights, then un-premultiplied by the blended mask — so the colour at
|
|
106
|
+
a soft edge is the screen's own, not the screen mixed with whatever
|
|
107
|
+
the warp put outside the quad. (The warp replicates the edge outward,
|
|
108
|
+
so in practice that is nearly the screen's own colour already; the
|
|
109
|
+
premultiplication is what keeps it exact rather than nearly.)
|
|
110
|
+
"""
|
|
111
|
+
m0 = self.masks[0][:, :, None]
|
|
112
|
+
pm = colour * m0
|
|
113
|
+
num = np.zeros_like(colour)
|
|
114
|
+
for k in range(DOF_LEVELS):
|
|
115
|
+
bk = pm if self.sigmas[k] <= 0.0 else _blur(pm, self.sigmas[k])
|
|
116
|
+
num += self.W[k][:, :, None] * bk
|
|
117
|
+
den = np.maximum(self.alpha3, 1e-4)
|
|
118
|
+
out = num / den
|
|
119
|
+
return np.where(self.alpha3 > 1e-4, out, colour)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# ---------------------------------------------------------------- measure --
|
|
123
|
+
|
|
124
|
+
DOF_PROFILE_HALF = 14 # px either side of the screen boundary to fit the step
|
|
125
|
+
DOF_MIN_SPREAD = 1.6 # far-edge sigma / near-edge sigma below this = flat
|
|
126
|
+
DOF_MIN_SIGMA = 0.9 # a step narrower than this is the render's own antialiasing
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _edge_profiles(gray: np.ndarray, A, B, n, half: int, trim: float = 0.12):
|
|
130
|
+
"""Intensity profiles ACROSS edge A→B, one column per sample point along
|
|
131
|
+
it, from `half` px inside the screen to `half` px outside, along the
|
|
132
|
+
outward unit normal `n`. Rectified with remap, so a tilted edge reads as
|
|
133
|
+
a straight step."""
|
|
134
|
+
A = np.asarray(A, dtype=np.float64); B = np.asarray(B, dtype=np.float64)
|
|
135
|
+
u = B - A
|
|
136
|
+
L = float(np.linalg.norm(u))
|
|
137
|
+
if L < 8:
|
|
138
|
+
return None
|
|
139
|
+
u /= L
|
|
140
|
+
s = np.linspace(trim * L, (1 - trim) * L, max(8, int((1 - 2 * trim) * L / 2)))
|
|
141
|
+
d = np.arange(-half, half + 1, dtype=np.float64)
|
|
142
|
+
xs = A[0] + s[None, :] * u[0] + d[:, None] * n[0]
|
|
143
|
+
ys = A[1] + s[None, :] * u[1] + d[:, None] * n[1]
|
|
144
|
+
return cv2.remap(gray.astype(np.float32), xs.astype(np.float32), ys.astype(np.float32),
|
|
145
|
+
cv2.INTER_LINEAR, borderMode=cv2.BORDER_REFLECT)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def step_sigma(profiles: np.ndarray) -> float:
|
|
149
|
+
"""Blur of a step edge, in pixels, from profiles across it (rows = across,
|
|
150
|
+
columns = samples along the edge).
|
|
151
|
+
|
|
152
|
+
A step of amplitude A blurred by a Gaussian of sigma has a peak derivative
|
|
153
|
+
of A / (sigma * sqrt(2*pi)), so sigma = A / (sqrt(2*pi) * peak). Only the
|
|
154
|
+
DOMINANT transition in each column is read: the strongest gradient near
|
|
155
|
+
the boundary, with A taken between the plateaus 3 sigma-ish either side of
|
|
156
|
+
it. The second-moment estimate was tried first and saturated at ~5px on
|
|
157
|
+
sharp renders, because a ±14px profile crosses glass edge, bezel and body
|
|
158
|
+
and its derivative is spread over all three. Reduced by the median across
|
|
159
|
+
columns so a corner arc or a reflection on a few samples does not move it;
|
|
160
|
+
columns with no real step are left out.
|
|
161
|
+
"""
|
|
162
|
+
smooth = cv2.GaussianBlur(profiles, (1, 3), 0.6) # tame sensor noise along the profile
|
|
163
|
+
g = np.diff(smooth, axis=0) # (2*half, N)
|
|
164
|
+
ag = np.abs(g)
|
|
165
|
+
n_across, N = ag.shape
|
|
166
|
+
half = n_across // 2
|
|
167
|
+
lo_i, hi_i = max(1, half - 6), min(n_across - 1, half + 6) # the boundary is near the centre
|
|
168
|
+
peak = lo_i + ag[lo_i:hi_i].argmax(axis=0)
|
|
169
|
+
out = []
|
|
170
|
+
for j in range(N):
|
|
171
|
+
pk = int(peak[j]); gm = float(ag[pk, j])
|
|
172
|
+
if gm < 1.5:
|
|
173
|
+
continue
|
|
174
|
+
# plateaus: 4..9 px either side of the peak, clamped to the profile
|
|
175
|
+
a0, a1 = max(0, pk - 9), max(0, pk - 4)
|
|
176
|
+
b0, b1 = min(n_across, pk + 5), min(n_across, pk + 10)
|
|
177
|
+
if a1 <= a0 or b1 <= b0:
|
|
178
|
+
continue
|
|
179
|
+
A = abs(float(smooth[b0:b1, j].mean()) - float(smooth[a0:a1, j].mean()))
|
|
180
|
+
if A < 12.0: # no step worth reading
|
|
181
|
+
continue
|
|
182
|
+
out.append(A / (math.sqrt(2 * math.pi) * gm))
|
|
183
|
+
if len(out) < 4:
|
|
184
|
+
return 0.0
|
|
185
|
+
return float(np.median(out))
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def measure(photo: np.ndarray, corners) -> dict:
|
|
189
|
+
"""Direction and strength of defocus from the screen's own boundary.
|
|
190
|
+
|
|
191
|
+
Four blur widths, one per edge (sigma in pixels, `step_sigma`), the
|
|
192
|
+
edge midpoints as positions → a plane sigma(x, y) = a·x + b·y + c by least
|
|
193
|
+
squares. The blur grows along the plane's gradient: angle = atan2(b, a).
|
|
194
|
+
Strength is the far edge's sigma against DOF_MAX_FRAC of the longer side,
|
|
195
|
+
which is what the blur uses, so a measured strength reproduces the measured
|
|
196
|
+
blur. Below DOF_MIN_SPREAD between the softest and sharpest edge the photo
|
|
197
|
+
is in focus across the screen (or uniformly soft, which is not depth of
|
|
198
|
+
field) and the answer is `flat`.
|
|
199
|
+
"""
|
|
200
|
+
gray = cv2.cvtColor(photo, cv2.COLOR_BGR2GRAY) if photo.ndim == 3 else photo
|
|
201
|
+
q = np.asarray(corners, dtype=np.float64)
|
|
202
|
+
centre = q.mean(axis=0)
|
|
203
|
+
sig, mids = [], []
|
|
204
|
+
for i in range(4):
|
|
205
|
+
A, B = q[i], q[(i + 1) % 4]
|
|
206
|
+
u = B - A
|
|
207
|
+
n = np.array([-u[1], u[0]]) / max(np.linalg.norm(u), 1e-6)
|
|
208
|
+
mid = (A + B) / 2
|
|
209
|
+
if np.dot(n, centre - mid) > 0: # pointing in: flip to outward
|
|
210
|
+
n = -n
|
|
211
|
+
prof = _edge_profiles(gray, A, B, n, DOF_PROFILE_HALF)
|
|
212
|
+
if prof is None:
|
|
213
|
+
continue
|
|
214
|
+
sg = step_sigma(prof)
|
|
215
|
+
if sg > 0.0:
|
|
216
|
+
sig.append(sg); mids.append(mid)
|
|
217
|
+
if len(sig) < 3:
|
|
218
|
+
return {"angle": 0.0, "strength": 0.0, "flat": True, "sigma": [round(v, 2) for v in sig]}
|
|
219
|
+
sig = np.array(sig); mids = np.array(mids)
|
|
220
|
+
lo = max(float(sig.min()), DOF_MIN_SIGMA)
|
|
221
|
+
spread = float(sig.max() / lo)
|
|
222
|
+
out = {"sigma": sig.round(2).tolist(), "spread": round(spread, 2)}
|
|
223
|
+
if spread < DOF_MIN_SPREAD:
|
|
224
|
+
out.update({"angle": 0.0, "strength": 0.0, "flat": True})
|
|
225
|
+
return out
|
|
226
|
+
Amat = np.column_stack([mids[:, 0], mids[:, 1], np.ones(len(mids))])
|
|
227
|
+
(a, b, c), *_ = np.linalg.lstsq(Amat, sig, rcond=None)
|
|
228
|
+
angle = math.degrees(math.atan2(b, a)) % 360.0
|
|
229
|
+
strength = float(np.clip((sig.max() - lo) / max(sigma_max(q, 1.0), 1e-6), 0.0, 1.0))
|
|
230
|
+
out.update({"angle": round(angle, 1), "strength": round(strength, 3), "flat": False})
|
|
231
|
+
return out
|
package/scripts/scan.py
CHANGED
|
@@ -53,7 +53,13 @@ def thumb(item, out_dir: str, size: int = 320):
|
|
|
53
53
|
"""Write a small JPEG thumbnail; returns its path or None."""
|
|
54
54
|
os.makedirs(out_dir, exist_ok=True)
|
|
55
55
|
base = os.path.splitext(item["name"])[0]
|
|
56
|
-
|
|
56
|
+
# The mtime is part of the name: a file overwritten in place gets a new
|
|
57
|
+
# thumbnail instead of the one made from its old bytes (12 Sep 2026).
|
|
58
|
+
try:
|
|
59
|
+
stamp = int(os.path.getmtime(item["path"]))
|
|
60
|
+
except OSError:
|
|
61
|
+
stamp = 0
|
|
62
|
+
out = os.path.join(out_dir, f"{abs(hash(item['path']))}_{stamp}_{base[:40]}.jpg")
|
|
57
63
|
if os.path.exists(out):
|
|
58
64
|
return out
|
|
59
65
|
if sys.platform == "darwin":
|
package/scripts/ui.py
CHANGED
|
@@ -49,6 +49,7 @@ import numpy as np # noqa: E402
|
|
|
49
49
|
import detect as D # noqa: E402
|
|
50
50
|
import fitfile as FF # noqa: E402
|
|
51
51
|
import fits as FIT # noqa: E402
|
|
52
|
+
import dof as DOF # noqa: E402
|
|
52
53
|
import scan as S # noqa: E402
|
|
53
54
|
import warp as W # noqa: E402
|
|
54
55
|
|
|
@@ -577,7 +578,8 @@ PREVIEW_SECONDS = 6.0
|
|
|
577
578
|
|
|
578
579
|
def _render_worker(photo, video_path, corners, dest, radius_px, gr, grain, preset, fit_frame,
|
|
579
580
|
blend="replace", reflection=None, result=None, kind="render",
|
|
580
|
-
start_frame=0, max_frames=None, *, smoothing=0.0
|
|
581
|
+
start_frame=0, max_frames=None, *, smoothing=0.0,
|
|
582
|
+
dof_angle=0.0, dof_strength=0.0):
|
|
581
583
|
"""Encode the clip, and only if that SUCCEEDS publish what it produced.
|
|
582
584
|
|
|
583
585
|
`result` is the sidecar this render would write. It is handed to the worker
|
|
@@ -602,7 +604,8 @@ def _render_worker(photo, video_path, corners, dest, radius_px, gr, grain, prese
|
|
|
602
604
|
blend=blend,
|
|
603
605
|
reflection=(W.DEFAULT_REFLECTION if reflection is None
|
|
604
606
|
else reflection),
|
|
605
|
-
start_frame=start_frame, max_frames=max_frames
|
|
607
|
+
start_frame=start_frame, max_frames=max_frames,
|
|
608
|
+
dof_angle=dof_angle, dof_strength=dof_strength)
|
|
606
609
|
if kind == "preview":
|
|
607
610
|
# A preview publishes NOTHING. It is not a save: no sidecar, no fit
|
|
608
611
|
# file, and above all not the session output -- /api/import reads
|
|
@@ -664,6 +667,18 @@ def _blend_args(b):
|
|
|
664
667
|
return "emissive", float(max(0.0, min(1.0, float(r))))
|
|
665
668
|
|
|
666
669
|
|
|
670
|
+
def _dof_args(b):
|
|
671
|
+
"""(dof_angle, dof_strength) from the page. Absent or null strength means
|
|
672
|
+
0 -- no field, and every path in Plan untouched -- so a client that predates
|
|
673
|
+
depth of field and a sidecar replayed through the CLI both reproduce."""
|
|
674
|
+
try:
|
|
675
|
+
strength = float(b.get("dof_strength") or 0.0)
|
|
676
|
+
angle = float(b.get("dof_angle") or 0.0)
|
|
677
|
+
except (TypeError, ValueError):
|
|
678
|
+
return 0.0, 0.0
|
|
679
|
+
return angle % 360.0, float(min(max(strength, 0.0), 1.0))
|
|
680
|
+
|
|
681
|
+
|
|
667
682
|
ROLES = ("photo", "screenshot")
|
|
668
683
|
|
|
669
684
|
|
|
@@ -699,6 +714,15 @@ def _adopt(role, path):
|
|
|
699
714
|
e = FIT.recall(FIT.key_for(im))
|
|
700
715
|
if e:
|
|
701
716
|
meta["remembered"] = e
|
|
717
|
+
# mtime rides along as the page's cache-buster: the same path with new
|
|
718
|
+
# bytes (imported, cleared, overwritten on disk, imported again) used to
|
|
719
|
+
# come back as the OLD picture in every <img> the browser had cached --
|
|
720
|
+
# chip swatch, popover preview, the canvas itself for a photo -- while the
|
|
721
|
+
# composite, which the server renders from disk, was already new.
|
|
722
|
+
try:
|
|
723
|
+
meta["mtime"] = int(os.path.getmtime(real))
|
|
724
|
+
except OSError:
|
|
725
|
+
meta["mtime"] = 0
|
|
702
726
|
return {"path": real, "size": [im.shape[1], im.shape[0]], **meta}
|
|
703
727
|
|
|
704
728
|
|
|
@@ -941,6 +965,29 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
941
965
|
"with status=done.",
|
|
942
966
|
}))
|
|
943
967
|
|
|
968
|
+
if u.path == "/api/dof":
|
|
969
|
+
# Depth of field read off the photograph's own screen boundary:
|
|
970
|
+
# the direction the blur grows in and how far, from the blur
|
|
971
|
+
# width of each edge of the quad. `flat` means the bezel is
|
|
972
|
+
# sharp all round and the feature has nothing to match.
|
|
973
|
+
ppath = SESSION.state.get("photo")
|
|
974
|
+
if not ppath:
|
|
975
|
+
return self._json({"error": "no photo chosen"}, 400)
|
|
976
|
+
photo, _ = _read_image(ppath)
|
|
977
|
+
corners = _quad(b["corners"])
|
|
978
|
+
return self._json(DOF.measure(photo, corners))
|
|
979
|
+
|
|
980
|
+
if u.path == "/api/job/cancel":
|
|
981
|
+
# The page gave up on a pending job. Marked, not deleted: the
|
|
982
|
+
# agent's complete_job then answers "already cancelled" instead
|
|
983
|
+
# of finding no job and guessing, and wait_for_job skips it.
|
|
984
|
+
job = SESSION.read_job()
|
|
985
|
+
if job and job.get("status") == "pending":
|
|
986
|
+
job["status"] = "cancelled"
|
|
987
|
+
job["completed"] = time.time()
|
|
988
|
+
_write_json_atomic(SESSION.job_path, job)
|
|
989
|
+
return self._json(job or {"status": "none"})
|
|
990
|
+
|
|
944
991
|
if u.path == "/api/job/adopt":
|
|
945
992
|
# page calls this once job.status == done, to make the export the screenshot
|
|
946
993
|
with open(SESSION.job_path) as f:
|
|
@@ -1129,7 +1176,9 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1129
1176
|
gr, grain, "web", fit_frame,
|
|
1130
1177
|
blend, reflection, None, "preview",
|
|
1131
1178
|
fit_frame, max_frames),
|
|
1132
|
-
kwargs={"smoothing": _smoothing(b)
|
|
1179
|
+
kwargs={"smoothing": _smoothing(b),
|
|
1180
|
+
"dof_angle": _dof_args(b)[0],
|
|
1181
|
+
"dof_strength": _dof_args(b)[1]}).start()
|
|
1133
1182
|
except BaseException:
|
|
1134
1183
|
with RENDER_LOCK:
|
|
1135
1184
|
RENDER.update(state="error", message="could not start the preview")
|
|
@@ -1163,6 +1212,7 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1163
1212
|
gr = float(b.get("grade") if b.get("grade") is not None else 0.0)
|
|
1164
1213
|
grain = bool(b.get("grain", gr > 0))
|
|
1165
1214
|
blend, reflection = _blend_args(b)
|
|
1215
|
+
dof_angle, dof_strength = _dof_args(b)
|
|
1166
1216
|
preset = "prores" if b.get("preset") == "prores" else "web"
|
|
1167
1217
|
ext = ".mov" if preset == "prores" else ".mp4"
|
|
1168
1218
|
os.makedirs(OUT_DIR, exist_ok=True)
|
|
@@ -1184,6 +1234,7 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1184
1234
|
"grade": gr, "grain": grain,
|
|
1185
1235
|
"video": True, "preset": preset, "fit_frame": fit_frame,
|
|
1186
1236
|
"blend": blend, "reflection": reflection,
|
|
1237
|
+
"dof_angle": dof_angle, "dof_strength": dof_strength,
|
|
1187
1238
|
# A render is always the whole clip; only the preview
|
|
1188
1239
|
# passes a segment. Recorded anyway, because the
|
|
1189
1240
|
# sidecar's promise is EVERY argument that changes the
|
|
@@ -1210,7 +1261,9 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1210
1261
|
args=(photo, spath, corners, dest, radius_px,
|
|
1211
1262
|
gr, grain, preset, fit_frame,
|
|
1212
1263
|
blend, reflection, result),
|
|
1213
|
-
kwargs={"smoothing": _smoothing(b)
|
|
1264
|
+
kwargs={"smoothing": _smoothing(b),
|
|
1265
|
+
"dof_angle": dof_angle,
|
|
1266
|
+
"dof_strength": dof_strength}).start()
|
|
1214
1267
|
except BaseException:
|
|
1215
1268
|
# If the thread cannot even be created, the flag must not
|
|
1216
1269
|
# outlive the request.
|
|
@@ -1233,10 +1286,12 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1233
1286
|
gr = float(b.get("grade") if b.get("grade") is not None else 0.0)
|
|
1234
1287
|
blend, reflection = _blend_args(b)
|
|
1235
1288
|
smoothing = _smoothing(b)
|
|
1289
|
+
dof_angle, dof_strength = _dof_args(b)
|
|
1236
1290
|
out = W.compose(photo, shot, corners, radius_px,
|
|
1237
1291
|
corner_smoothing=smoothing,
|
|
1238
1292
|
grade=gr, grain=bool(b.get("grain", gr > 0)),
|
|
1239
|
-
blend=blend, reflection=reflection
|
|
1293
|
+
blend=blend, reflection=reflection,
|
|
1294
|
+
dof_angle=dof_angle, dof_strength=dof_strength)
|
|
1240
1295
|
SESSION.update(corners=corners, radius_frac=frac, device=b.get("device"),
|
|
1241
1296
|
grade=gr)
|
|
1242
1297
|
if u.path == "/api/preview":
|
|
@@ -1272,6 +1327,7 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1272
1327
|
"corner_smoothing": smoothing,
|
|
1273
1328
|
"grade": gr, "grain": bool(b.get("grain", gr > 0)),
|
|
1274
1329
|
"blend": blend, "reflection": reflection,
|
|
1330
|
+
"dof_angle": dof_angle, "dof_strength": dof_strength,
|
|
1275
1331
|
"saved": time.time()}
|
|
1276
1332
|
# A fit is remembered when it PRODUCED something, not while it
|
|
1277
1333
|
# is being dragged: a quad on the canvas is a work in progress,
|
package/scripts/warp.py
CHANGED
|
@@ -37,6 +37,7 @@ import cv2
|
|
|
37
37
|
import numpy as np
|
|
38
38
|
|
|
39
39
|
import grade as _grade # M2: the realism pass
|
|
40
|
+
import dof as _dof # depth of field across the screen
|
|
40
41
|
|
|
41
42
|
|
|
42
43
|
MASK_SS = 4 # destination-space supersampling for the screen's edge
|
|
@@ -267,7 +268,8 @@ class Plan:
|
|
|
267
268
|
def __init__(self, photo: np.ndarray, frame_shape, corners,
|
|
268
269
|
corner_radius: float = 0.0, grain: bool = False,
|
|
269
270
|
blend: str = "replace", reflection: float = DEFAULT_REFLECTION,
|
|
270
|
-
corner_smoothing: float = 0.0
|
|
271
|
+
corner_smoothing: float = 0.0,
|
|
272
|
+
dof_angle: float = 0.0, dof_strength: float = 0.0):
|
|
271
273
|
dst_quad = np.array(corners, dtype=np.float32)
|
|
272
274
|
if shoelace_area(dst_quad) < 1.0:
|
|
273
275
|
raise ValueError("degenerate quad (near-zero area) — check corner order TL,TR,BR,BL")
|
|
@@ -313,9 +315,22 @@ class Plan:
|
|
|
313
315
|
# Integer bbox of the quad, clamped to the canvas and padded by a pixel
|
|
314
316
|
# so the antialiased edge is never clipped.
|
|
315
317
|
xs, ys = dst_quad[:, 0], dst_quad[:, 1]
|
|
316
|
-
|
|
317
|
-
|
|
318
|
+
# Depth of field widens the window: the soft edge reaches 3 sigma past
|
|
319
|
+
# the sharp mask, and a window that clipped it would leave a hard line
|
|
320
|
+
# exactly where the blur was meant to remove one.
|
|
321
|
+
self.dof_strength = float(np.clip(dof_strength, 0.0, 1.0))
|
|
322
|
+
self.dof_angle = float(dof_angle)
|
|
323
|
+
reach = int(np.ceil(3 * _dof.sigma_max(dst_quad, self.dof_strength))) if self.dof_strength > 0 else 0
|
|
324
|
+
bx0, by0 = max(0, int(np.floor(xs.min())) - 1 - reach), max(0, int(np.floor(ys.min())) - 1 - reach)
|
|
325
|
+
bx1, by1 = min(pw, int(np.ceil(xs.max())) + 2 + reach), min(ph, int(np.ceil(ys.max())) + 2 + reach)
|
|
318
326
|
self.bbox = (bx0, by0, bx1, by1) if bx1 > bx0 and by1 > by0 else None
|
|
327
|
+
# The field is built once: ramp, level weights, the blurred masks.
|
|
328
|
+
# At strength 0 there is no field and every path below is untouched,
|
|
329
|
+
# which is what keeps every earlier sidecar reproducing byte for byte.
|
|
330
|
+
self.dof = None
|
|
331
|
+
if self.dof_strength > 0 and self.bbox is not None:
|
|
332
|
+
self.dof = _dof.Field(dst_quad, self.dof_angle, self.dof_strength,
|
|
333
|
+
self.warped_mask[by0:by1, bx0:bx1], bx0, by0)
|
|
319
334
|
|
|
320
335
|
def _prep(self, frame: np.ndarray, bbox=None) -> np.ndarray:
|
|
321
336
|
"""Warp one frame. With `bbox`, warp only that window of the canvas.
|
|
@@ -377,13 +392,21 @@ class Plan:
|
|
|
377
392
|
simplest code is the one to trust, and on for video renders. Both
|
|
378
393
|
produce identical bytes; test_video.py asserts it rather than assuming.
|
|
379
394
|
"""
|
|
380
|
-
|
|
395
|
+
# Depth of field always takes the windowed path: the field is built on
|
|
396
|
+
# the window, and the still and video renders must share one code
|
|
397
|
+
# path here for the same reason they share Plan.
|
|
398
|
+
if (fast or self.dof is not None) and self.bbox is not None:
|
|
381
399
|
x0, y0, x1, y1 = self.bbox
|
|
382
400
|
warped_screen = self._prep(frame, self.bbox)
|
|
383
401
|
if self.grade_params is not None:
|
|
384
402
|
warped_screen = _grade.apply_light(warped_screen, self.grade_params)
|
|
385
403
|
out = self.photo.copy()
|
|
386
404
|
win = self.mask3[y0:y1, x0:x1]
|
|
405
|
+
if self.dof is not None:
|
|
406
|
+
# Blur colour and mask together, premultiplied, so the glass
|
|
407
|
+
# edge goes soft exactly as the far end of the bezel already is.
|
|
408
|
+
warped_screen = self.dof.blur_layer(warped_screen.astype(np.float32))
|
|
409
|
+
win = self.dof.alpha3
|
|
387
410
|
pw = self.photo[y0:y1, x0:x1]
|
|
388
411
|
src = self._blend(pw, warped_screen)
|
|
389
412
|
out[y0:y1, x0:x1] = np.clip(
|
|
@@ -410,7 +433,8 @@ def compose(photo: np.ndarray, screenshot: np.ndarray, corners, corner_radius: f
|
|
|
410
433
|
corner_smoothing: float = 0.0,
|
|
411
434
|
grade: float = 0.0, grain: bool = False, screen_off: np.ndarray = None,
|
|
412
435
|
specular: float = 0.75, blend: str = "replace",
|
|
413
|
-
reflection: float = DEFAULT_REFLECTION
|
|
436
|
+
reflection: float = DEFAULT_REFLECTION,
|
|
437
|
+
dof_angle: float = 0.0, dof_strength: float = 0.0) -> np.ndarray:
|
|
414
438
|
"""Warp `screenshot` into the quad `corners` (TL,TR,BR,BL, photo pixels) on `photo`.
|
|
415
439
|
|
|
416
440
|
Single resampling pass at the photo's resolution; deterministic. This is the
|
|
@@ -423,7 +447,8 @@ def compose(photo: np.ndarray, screenshot: np.ndarray, corners, corner_radius: f
|
|
|
423
447
|
# asserted byte-identical to this function's output.
|
|
424
448
|
plan = Plan(photo, screenshot.shape, corners, corner_radius, grain=grain,
|
|
425
449
|
corner_smoothing=corner_smoothing,
|
|
426
|
-
blend=blend, reflection=reflection
|
|
450
|
+
blend=blend, reflection=reflection,
|
|
451
|
+
dof_angle=dof_angle, dof_strength=dof_strength)
|
|
427
452
|
plan.bind_grade(screenshot, grade)
|
|
428
453
|
return plan.render(screenshot, screen_off=screen_off, specular=specular)
|
|
429
454
|
|
|
@@ -510,7 +535,8 @@ def compose_video(photo: np.ndarray, video_path: str, corners, output: str,
|
|
|
510
535
|
preset: str = "web", fit_frame: int = 0, audio: bool = True,
|
|
511
536
|
frames_dir: str = None, progress=None, blend: str = "replace",
|
|
512
537
|
reflection: float = DEFAULT_REFLECTION,
|
|
513
|
-
start_frame: int = 0, max_frames: int = None
|
|
538
|
+
start_frame: int = 0, max_frames: int = None,
|
|
539
|
+
dof_angle: float = 0.0, dof_strength: float = 0.0) -> dict:
|
|
514
540
|
"""Inject a VIDEO into a still photo. The photo does not move, so there is
|
|
515
541
|
exactly one homography and the whole of Plan is computed once.
|
|
516
542
|
|
|
@@ -537,7 +563,8 @@ def compose_video(photo: np.ndarray, video_path: str, corners, output: str,
|
|
|
537
563
|
first = read_frame_at(video_path, fit_frame)
|
|
538
564
|
plan = Plan(photo, first.shape, corners, corner_radius, grain=grain,
|
|
539
565
|
corner_smoothing=corner_smoothing,
|
|
540
|
-
blend=blend, reflection=reflection
|
|
566
|
+
blend=blend, reflection=reflection,
|
|
567
|
+
dof_angle=dof_angle, dof_strength=dof_strength)
|
|
541
568
|
plan.bind_grade(first, grade)
|
|
542
569
|
|
|
543
570
|
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.51):** 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,6 +13,8 @@ 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.
|
|
17
|
+
|
|
16
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.
|
|
17
19
|
|
|
18
20
|
Still missing: **no ML detection** (measured, and it segments the phone body rather than the glass, so it is not shipped), **no occluder handling** — a finger or glare in front of the screen gets painted over — and **no camera motion**: the photo must be a still, so a clip of a moving phone is not this. Also, detection **abstains** rather than guessing when the background is itself neutral (a pale tiled floor, a plain wall); the edges get placed by hand there, which is normal, not a failure. Say so if any of it matters for the photo.
|
|
@@ -94,7 +96,7 @@ It parks until the user presses a button and returns within ~150 ms of the press
|
|
|
94
96
|
|
|
95
97
|
| result | what to do |
|
|
96
98
|
|---|---|
|
|
97
|
-
| `status=job`, `job.type="figma_export"` | Extract `fileKey` and `nodeId` from `job.url` (`1-2` → `1:2`), export via the Figma MCP (`download_assets`, PNG, scale 3), save to `job.save_to`, then `complete_job(status="done", path=<saved file>)`. Re-arm. |
|
|
99
|
+
| `status=job`, `job.type="figma_export"` | Extract `fileKey` and `nodeId` from `job.url` (`1-2` → `1:2`), export via the Figma MCP (`download_assets`, PNG, scale 3), save to `job.save_to`, then `complete_job(status="done", path=<saved file>)`. If `complete_job` answers that the job is already **cancelled**, the user pressed Cancel in the page — say so in one line and re-arm; do not retry. Re-arm. |
|
|
98
100
|
| `status=job`, `job.type="present"` | `present_files` on `job.paths`, report what you checked in the composite (§3), then `complete_job(status="done")`. Re-arm. |
|
|
99
101
|
| `status=timeout` | Nothing pressed yet. Call again to keep waiting. Re-arm two or three times, then stop and say you've stopped waiting — do not loop forever burning turns. |
|
|
100
102
|
| `status=ui_closed` | The UI exited. Stop; say so. |
|
package/ui/index.html
CHANGED
|
@@ -384,6 +384,11 @@
|
|
|
384
384
|
.seg button.sel{background:var(--raise);color:var(--ink);font-weight:600}
|
|
385
385
|
.seg button.sel:hover{background:var(--raise-mid)}
|
|
386
386
|
.seg button.sel:active{background:var(--raise-low);transform:none}
|
|
387
|
+
/* A toggle in the strip head. On = the raised surface every selected thing
|
|
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}
|
|
387
392
|
/* Input chip (Figma 5:14 / 5:20). Surface and shadow are declared here rather
|
|
388
393
|
than inherited from the base button rule, so the filled state is explicit and
|
|
389
394
|
the .is-empty override below has something definite to override.
|
|
@@ -1044,7 +1049,7 @@
|
|
|
1044
1049
|
end. Icon-only, so each carries an aria-label — a title alone
|
|
1045
1050
|
names it for a mouse and not for a screen reader. -->
|
|
1046
1051
|
<button class="sm icon" id="playlive" aria-label="Play the clip on the photo"
|
|
1047
|
-
title="Play the clip on the photo, right now. The browser applies the same four corners and corner radius, and approximates emissive with screen blending; the colour grade and
|
|
1052
|
+
title="Play the clip on the photo, right now. The browser applies the same four corners and corner radius, and approximates emissive with screen blending; the colour grade, grain and depth of field are not in this view.">
|
|
1048
1053
|
<svg viewBox="0 0 12 12" width="11" height="11" aria-hidden="true" focusable="false"><path d="M3 1.5 10 6 3 10.5Z" fill="currentColor"/></svg>
|
|
1049
1054
|
</button>
|
|
1050
1055
|
<button class="sm" id="playclip"
|
|
@@ -1108,6 +1113,35 @@
|
|
|
1108
1113
|
</div>
|
|
1109
1114
|
</div></div>
|
|
1110
1115
|
</div>
|
|
1116
|
+
<div class="sect" id="secDof" data-on="0">
|
|
1117
|
+
<div class="sect-top">
|
|
1118
|
+
<div class="sect-left">
|
|
1119
|
+
<h4>Depth of field</h4>
|
|
1120
|
+
<button class="info" type="button" aria-label="About depth of field"><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">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, and the glass edge softens with it. Measure reads the direction and a starting strength off your photo's own bezel; the strength it finds is a floor, so raise it if the far end of the phone looks softer than the screen.</span></button>
|
|
1121
|
+
</div>
|
|
1122
|
+
<button class="switch" id="dofBtn" role="switch" aria-checked="false" aria-label="Depth of field">
|
|
1123
|
+
<span class="sw-track"><span class="sw-handle"></span><span class="sw-grip"></span></span>
|
|
1124
|
+
</button>
|
|
1125
|
+
</div>
|
|
1126
|
+
<div class="sect-body"><div class="sect-body-in">
|
|
1127
|
+
<div style="display:grid;gap:var(--s2)">
|
|
1128
|
+
<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>
|
|
1130
|
+
<span class="sm" id="dofDirVal" style="font-variant-numeric:tabular-nums"></span>
|
|
1131
|
+
</div>
|
|
1132
|
+
<input type="range" id="dofDir" min="0" max="355" step="5" value="90" aria-label="Direction the blur grows in">
|
|
1133
|
+
<div style="display:flex;align-items:center;justify-content:space-between;gap:var(--s2)">
|
|
1134
|
+
<span class="sm" style="color:var(--mute)">Strength</span>
|
|
1135
|
+
<span class="sm" id="dofStrVal" style="font-variant-numeric:tabular-nums"></span>
|
|
1136
|
+
</div>
|
|
1137
|
+
<input type="range" id="dofStr" min="0" max="1" step="0.05" value="0.3" aria-label="Blur strength at the far edge">
|
|
1138
|
+
<div style="display:flex;align-items:center;gap:var(--s2)">
|
|
1139
|
+
<button class="sm" id="dofMeasure" title="Read the direction and a starting strength off the photo's own bezel">Measure from photo</button>
|
|
1140
|
+
<span class="status sm" id="dofSt" style="min-width:0"></span>
|
|
1141
|
+
</div>
|
|
1142
|
+
</div>
|
|
1143
|
+
</div></div>
|
|
1144
|
+
</div>
|
|
1111
1145
|
<div class="sect" id="secEdge" data-on="1">
|
|
1112
1146
|
<div class="sect-top">
|
|
1113
1147
|
<div class="sect-left">
|
|
@@ -1136,6 +1170,7 @@
|
|
|
1136
1170
|
<span class="lbl">Edge view</span>
|
|
1137
1171
|
<span class="stepper dock-step"><button class="sm" id="stripOut">−</button><button class="sm" id="stripIn">+</button></span>
|
|
1138
1172
|
<span class="sm" id="stripZ" style="color:var(--mute);font-variant-numeric:tabular-nums"></span>
|
|
1173
|
+
<button class="sm" id="stripHC" aria-pressed="false" title="Stretch the strip's own tonal range to full contrast. For a dark screen on a dark frame, where the boundary is a few levels apart. The strip only — the composite is untouched.">Contrast</button>
|
|
1139
1174
|
<span class="spacer"></span>
|
|
1140
1175
|
<span class="sm" id="stripSt" style="color:var(--mute)"></span>
|
|
1141
1176
|
</div>
|
|
@@ -1174,8 +1209,8 @@
|
|
|
1174
1209
|
|
|
1175
1210
|
<div class="pop" id="pop2">
|
|
1176
1211
|
<h3>Screenshot to put on the screen</h3>
|
|
1177
|
-
<p class="sub">A PNG/JPG of the UI — or paste a Figma frame link and Claude will
|
|
1178
|
-
<div class="row" style="margin-bottom:8px"><input type="text" id="figma" placeholder="https://www.figma.com/design/…?node-id=…" style="flex:1"><button id="figmaBtn">
|
|
1212
|
+
<p class="sub">A PNG/JPG of the UI — or paste a Figma frame link and Claude will import it for you.</p>
|
|
1213
|
+
<div class="row" style="margin-bottom:8px"><input type="text" id="figma" placeholder="https://www.figma.com/design/…?node-id=…" style="flex:1"><button id="figmaBtn">Import from Figma</button></div>
|
|
1179
1214
|
<div class="status sm" id="figmaSt" style="margin-bottom:10px"></div>
|
|
1180
1215
|
<div class="hintline" id="figmaHint" hidden style="margin-bottom:10px"></div>
|
|
1181
1216
|
<div class="picker">
|
|
@@ -1387,6 +1422,9 @@ const api = async (p, body, raw) => {
|
|
|
1387
1422
|
const j = await r.json(); if (!r.ok) throw new Error(j.error || r.statusText); return j;
|
|
1388
1423
|
};
|
|
1389
1424
|
const fileURL = p => '/file?path=' + encodeURIComponent(p);
|
|
1425
|
+
// A source's URL carries the file's mtime, so new bytes at an old path are
|
|
1426
|
+
// fetched rather than served from the browser's cache (see _adopt in ui.py).
|
|
1427
|
+
const srcURL = (p, v) => fileURL(p) + (v ? '&v=' + v : '');
|
|
1390
1428
|
const st = {photo:null, shot:null, corners:null, frac:0, type:null, preset:null, measured:null,
|
|
1391
1429
|
// Where the quad on screen came from: 'detected' | 'remembered' | 'restored'
|
|
1392
1430
|
// | 'default'. The tool's contract is that it never presents a guess as a
|
|
@@ -1591,7 +1629,8 @@ function setChosen(role, path, size, el, meta){
|
|
|
1591
1629
|
if (el) el.classList.add('sel');
|
|
1592
1630
|
// A video cannot be shown by <img>, so a clip gets its poster frame instead.
|
|
1593
1631
|
const thumb = (meta && meta.poster) ? meta.poster : path;
|
|
1594
|
-
|
|
1632
|
+
const ver = (meta && meta.mtime) || Date.now();
|
|
1633
|
+
$('#pv'+n).innerHTML = `<img src="${srcURL(thumb, ver)}" alt="">`;
|
|
1595
1634
|
const chip = $('#chip'+n);
|
|
1596
1635
|
chip.classList.remove('is-empty');
|
|
1597
1636
|
// The file's own name and nothing else. The size and, for a clip, the frame
|
|
@@ -1607,14 +1646,16 @@ function setChosen(role, path, size, el, meta){
|
|
|
1607
1646
|
if ((role==='photo' ? st.photo : st.shot) !== path) dropResult();
|
|
1608
1647
|
chip.title = `${homePath(path)}\n${size[0]}×${size[1]}`
|
|
1609
1648
|
+ (meta && meta.video ? ` · ${meta.frames} frames @ ${Math.round(meta.fps)}fps` : '');
|
|
1610
|
-
$('#sw'+n).style.background = `center/cover no-repeat url("${
|
|
1649
|
+
$('#sw'+n).style.background = `center/cover no-repeat url("${srcURL(thumb, ver)}")`;
|
|
1611
1650
|
$('#clear'+n).hidden = false;
|
|
1612
1651
|
// A fit the server recognised from this photograph's own pixels. Held
|
|
1613
1652
|
// rather than applied here: the quad only means anything once there is a
|
|
1614
1653
|
// screenshot to put inside it, which is what maybeStart() waits for.
|
|
1615
|
-
if (role==='photo'){ st.photo = path; st.corners = null;
|
|
1654
|
+
if (role==='photo'){ st.photo = path; st.photoV = ver; st.corners = null;
|
|
1616
1655
|
st.remembered = (meta && meta.remembered) || null; }
|
|
1617
|
-
else { st.shot = path; st.shotSize = size; st.video = meta && meta.video ? meta : null; syncVideoUI();
|
|
1656
|
+
else { st.shot = path; st.shotV = ver; st.shotSize = size; st.video = meta && meta.video ? meta : null; syncVideoUI();
|
|
1657
|
+
// The status line may still be narrating the clear ("Screenshot removed…").
|
|
1658
|
+
if (st.corners){ $('#detSt').className = 'status ok'; $('#detSt').textContent = 'Screenshot changed — the fit on the photo is kept'; }
|
|
1618
1659
|
// A detected quad is re-oriented for the new screenshot; a remembered or
|
|
1619
1660
|
// hand-placed one is somebody's decision and is left alone.
|
|
1620
1661
|
if (st.corners && st.quadFrom === 'detected' && orientQuad()){ draw(); drawStrip(); autoPreview(); } }
|
|
@@ -1687,6 +1728,7 @@ async function awaitJob({onDone, onError, cancelled}) {
|
|
|
1687
1728
|
if (cancelled()) return;
|
|
1688
1729
|
if (j.status === 'done') return onDone(j);
|
|
1689
1730
|
if (j.status === 'error') return onError(j);
|
|
1731
|
+
if (j.status === 'cancelled') return; // this page cancelled it (or another tab did)
|
|
1690
1732
|
if (j.status === 'none') {
|
|
1691
1733
|
// The job file is gone — a wiped session, or a restarted server. The
|
|
1692
1734
|
// server answers 'none' instantly, so looping on it would spin as fast
|
|
@@ -1696,16 +1738,37 @@ async function awaitJob({onDone, onError, cancelled}) {
|
|
|
1696
1738
|
// 'pending' with waited:true — the long poll timed out server-side; go again.
|
|
1697
1739
|
}
|
|
1698
1740
|
}
|
|
1741
|
+
/* Import from Figma. One button, two jobs: it starts the import, and while
|
|
1742
|
+
the import is pending it reads "Cancel" and stops it — before this the only
|
|
1743
|
+
way out of a pending import was to reload the page (12 Sep 2026). Cancelling
|
|
1744
|
+
tells the server to mark the job cancelled, so an agent that is still
|
|
1745
|
+
exporting gets "already cancelled" from complete_job instead of landing a
|
|
1746
|
+
file on a request nobody is waiting for. */
|
|
1747
|
+
let figmaInFlight = null; // {stop} while an import is pending
|
|
1699
1748
|
$('#figmaBtn').onclick = async () => {
|
|
1749
|
+
const s = $('#figmaSt'), hint = $('#figmaHint'), btn = $('#figmaBtn');
|
|
1750
|
+
if (figmaInFlight){
|
|
1751
|
+
const inflight = figmaInFlight;
|
|
1752
|
+
try { await api('/api/job/cancel', {}); } catch (e) { /* already finished — the poll will say */ }
|
|
1753
|
+
inflight.stop('', 'Import cancelled.');
|
|
1754
|
+
return;
|
|
1755
|
+
}
|
|
1700
1756
|
const url = $('#figma').value.trim(); if (!url) return;
|
|
1701
|
-
|
|
1702
|
-
|
|
1757
|
+
// Only a Figma frame link can be imported, and the job is answered by the
|
|
1758
|
+
// agent, so a wrong URL used to cost a full round trip before anyone saw it
|
|
1759
|
+
// (12 Sep 2026: the workbench's own address was pasted and went out as a job).
|
|
1760
|
+
if (!/^https:\/\/(www\.)?figma\.com\/(design|board|slides|file)\/[^/?#]+/.test(url) || !/node-id=\d+[-:]\d+/.test(url)){
|
|
1761
|
+
s.className = 'status sm err';
|
|
1762
|
+
s.textContent = 'That is not a Figma frame link. Copy the link from Figma (Share → Copy link, or right-click the frame → Copy link) — it needs node-id=… in it.';
|
|
1763
|
+
return;
|
|
1764
|
+
}
|
|
1703
1765
|
const t0 = Date.now();
|
|
1704
1766
|
let finished = false;
|
|
1767
|
+
btn.textContent = 'Cancel'; btn.title = 'Stop waiting for this import';
|
|
1705
1768
|
const paint = () => {
|
|
1706
1769
|
const el = Math.round((Date.now() - t0) / 1000);
|
|
1707
1770
|
s.className = 'status sm busy';
|
|
1708
|
-
s.innerHTML = `
|
|
1771
|
+
s.innerHTML = `Importing from Figma <span class="el">${el}s</span>`;
|
|
1709
1772
|
if (el >= NUDGE_AFTER && hint.hidden) {
|
|
1710
1773
|
hint.hidden = false;
|
|
1711
1774
|
hint.innerHTML = 'Taking longer than usual. Claude picks this up automatically while it\'s ' +
|
|
@@ -1720,26 +1783,33 @@ $('#figmaBtn').onclick = async () => {
|
|
|
1720
1783
|
// return so the number is right the moment the designer looks at it again.
|
|
1721
1784
|
document.addEventListener('visibilitychange', paint);
|
|
1722
1785
|
const stop = (cls, text) => {
|
|
1723
|
-
finished
|
|
1786
|
+
if (finished) return;
|
|
1787
|
+
finished = true; figmaInFlight = null;
|
|
1724
1788
|
clearInterval(tick);
|
|
1725
1789
|
document.removeEventListener('visibilitychange', paint);
|
|
1726
|
-
btn.
|
|
1790
|
+
btn.textContent = 'Import from Figma'; btn.title = '';
|
|
1791
|
+
hint.hidden = true;
|
|
1727
1792
|
s.className = 'status sm ' + cls; s.textContent = text;
|
|
1728
|
-
//
|
|
1729
|
-
|
|
1793
|
+
// ONE channel. While the picker is open the line under the field is in
|
|
1794
|
+
// view, and a toast on top of it said the same sentence twice, overlapping
|
|
1795
|
+
// the popover (12 Sep 2026). A toast only when the popover is closed and
|
|
1796
|
+
// the line would go unread.
|
|
1797
|
+
if (!$('#pop2').classList.contains('on') && cls)
|
|
1798
|
+
toast(cls === 'ok' ? 'ok' : 'err', cls === 'ok' ? 'Screenshot imported from Figma.' : text);
|
|
1730
1799
|
};
|
|
1800
|
+
figmaInFlight = {stop};
|
|
1731
1801
|
try { await api('/api/figma', {url}); }
|
|
1732
|
-
catch (e) { return stop('err', 'Could not
|
|
1802
|
+
catch (e) { return stop('err', 'Could not start the import: ' + e.message); }
|
|
1733
1803
|
await awaitJob({
|
|
1734
1804
|
cancelled: () => finished,
|
|
1735
1805
|
onDone: async () => {
|
|
1736
1806
|
try {
|
|
1737
1807
|
const r = await api('/api/job/adopt', {});
|
|
1738
|
-
stop('ok', '
|
|
1808
|
+
stop('ok', 'Imported');
|
|
1739
1809
|
setChosen('screenshot', r.path, r.size, null, r);
|
|
1740
|
-
} catch (e) { stop('err', '
|
|
1810
|
+
} catch (e) { stop('err', 'Imported, but the file could not be read: ' + e.message); }
|
|
1741
1811
|
},
|
|
1742
|
-
onError: (j) => stop('err', '
|
|
1812
|
+
onError: (j) => stop('err', 'Import failed: ' + (j.message || '')),
|
|
1743
1813
|
});
|
|
1744
1814
|
};
|
|
1745
1815
|
|
|
@@ -1814,7 +1884,7 @@ async function maybeStart(){
|
|
|
1814
1884
|
else if (st.remembered) applyRemembered();
|
|
1815
1885
|
else detect();
|
|
1816
1886
|
};
|
|
1817
|
-
img.src =
|
|
1887
|
+
img.src = srcURL(st.photo, st.photoV);
|
|
1818
1888
|
}
|
|
1819
1889
|
function computeFit(){
|
|
1820
1890
|
const box = $('#scroller');
|
|
@@ -2461,9 +2531,104 @@ $('#emisAmt').oninput = e => { emisAmt = parseFloat(e.target.value); paintEmis(
|
|
|
2461
2531
|
$('#emisAmt').onchange = e => { setEmis(true, parseFloat(e.target.value)); autoPreview(); };
|
|
2462
2532
|
|
|
2463
2533
|
|
|
2534
|
+
/* Depth of field: a blur that grows across the screen in one direction, and
|
|
2535
|
+
the glass edge softening with it (dof.py). Off sends nothing, so the engine
|
|
2536
|
+
takes the byte-identical path it took before the feature existed. The
|
|
2537
|
+
direction and strength are the designer's numbers; Measure proposes them
|
|
2538
|
+
from the photograph's own bezel and says how far it can be trusted — the
|
|
2539
|
+
measured strength is a floor (the estimator saturates around 5px of blur),
|
|
2540
|
+
never a ceiling. The live layer cannot show it; the composite does. */
|
|
2541
|
+
let dofOn = recall('dof','0') === '1';
|
|
2542
|
+
let dofAng = parseFloat(recall('dofAng','90')) || 0;
|
|
2543
|
+
let dofStr = parseFloat(recall('dofStr','0.3')); if (!(dofStr >= 0)) dofStr = 0.3;
|
|
2544
|
+
function dofAngle(){ return dofOn ? dofAng : 0; }
|
|
2545
|
+
function dofStrength(){ return dofOn ? dofStr : 0; }
|
|
2546
|
+
function dofWord(a){
|
|
2547
|
+
const names = ['the right','the bottom-right','the bottom','the bottom-left','the left','the top-left','the top','the top-right'];
|
|
2548
|
+
return names[Math.round(((a % 360) + 360) % 360 / 45) % 8];
|
|
2549
|
+
}
|
|
2550
|
+
function paintDof(){
|
|
2551
|
+
setSwitch($('#dofBtn'), dofOn);
|
|
2552
|
+
$('#dofDirVal').textContent = `${dofWord(dofAng)} · ${Math.round(dofAng)}°`;
|
|
2553
|
+
$('#dofStrVal').textContent = Math.round(dofStr * 100) + '%';
|
|
2554
|
+
const d = $('#dofDir'); d.value = Math.round(dofAng / 5) * 5; d.style.setProperty('--p', (dofAng % 360) / 355);
|
|
2555
|
+
const st = $('#dofStr'); st.value = dofStr; st.style.setProperty('--p', dofStr);
|
|
2556
|
+
}
|
|
2557
|
+
function setDof(on, ang, str){
|
|
2558
|
+
dofOn = on;
|
|
2559
|
+
if (ang !== undefined) dofAng = ((ang % 360) + 360) % 360;
|
|
2560
|
+
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));
|
|
2562
|
+
setSectionOpen($('#secDof'), on);
|
|
2563
|
+
paintDof();
|
|
2564
|
+
}
|
|
2565
|
+
let dofMeasuredFor = null; // photo path the last measurement was for
|
|
2566
|
+
async function measureDof(){
|
|
2567
|
+
const s = $('#dofSt');
|
|
2568
|
+
if (!(st.photo && st.corners)){ s.className = 'status sm'; s.textContent = 'Fit the screen first.'; return; }
|
|
2569
|
+
s.className = 'status sm busy'; s.textContent = 'Measuring…';
|
|
2570
|
+
try {
|
|
2571
|
+
const r = await api('/api/dof', {corners: st.corners});
|
|
2572
|
+
dofMeasuredFor = st.photo;
|
|
2573
|
+
if (r.flat){
|
|
2574
|
+
s.className = 'status sm'; s.textContent = 'The bezel is sharp all round — nothing to match. Set by eye if you want it anyway.';
|
|
2575
|
+
s.title = `Edge blur ${(r.sigma || []).map(v => v.toFixed(1)).join(' / ')}px`;
|
|
2576
|
+
autoPreview(); // still on, at the slider's own numbers
|
|
2577
|
+
return;
|
|
2578
|
+
}
|
|
2579
|
+
setDof(true, r.angle, Math.max(r.strength, 0.05));
|
|
2580
|
+
s.className = 'status sm ok';
|
|
2581
|
+
s.textContent = `Measured: grows toward ${dofWord(r.angle)}. Strength is a floor — raise it if the far end looks softer.`;
|
|
2582
|
+
s.title = `Edge blur ${r.sigma.map(v => v.toFixed(1)).join(' / ')}px, spread ${r.spread}×`;
|
|
2583
|
+
autoPreview();
|
|
2584
|
+
} catch (e) { s.className = 'status sm err'; s.textContent = 'Could not measure: ' + e.message; }
|
|
2585
|
+
}
|
|
2586
|
+
$('#dofBtn').onclick = () => {
|
|
2587
|
+
setDof(!dofOn);
|
|
2588
|
+
// First time on for this photograph: propose from the photo rather than
|
|
2589
|
+
// from a remembered slider position that belongs to another one.
|
|
2590
|
+
if (dofOn && dofMeasuredFor !== st.photo && st.corners) measureDof(); else autoPreview();
|
|
2591
|
+
};
|
|
2592
|
+
$('#dofDir').oninput = e => { dofAng = parseFloat(e.target.value); paintDof(); };
|
|
2593
|
+
$('#dofDir').onchange = e => { setDof(true, parseFloat(e.target.value)); autoPreview(); };
|
|
2594
|
+
$('#dofStr').oninput = e => { dofStr = parseFloat(e.target.value); paintDof(); };
|
|
2595
|
+
$('#dofStr').onchange = e => { setDof(true, undefined, parseFloat(e.target.value)); autoPreview(); };
|
|
2596
|
+
$('#dofMeasure').onclick = measureDof;
|
|
2597
|
+
|
|
2464
2598
|
$('#edgeBtn').onclick = () => setLoupeMode(loupeMode === 'float' ? 'dock' : 'float');
|
|
2465
2599
|
|
|
2466
|
-
|
|
2600
|
+
/* Contrast for the strip only. A dark screen on a dark frame puts the
|
|
2601
|
+
boundary a few levels apart — 8 against 20 — which is invisible at any
|
|
2602
|
+
zoom, and no amount of magnification adds contrast. This takes the strip's
|
|
2603
|
+
own 1st–99th percentile of luminance and stretches it to 0–255, the same
|
|
2604
|
+
gain on every channel so hue is kept. It is a viewing aid: the composite,
|
|
2605
|
+
the fit and the saved file never see it. Per strip, per frame — 1280×130
|
|
2606
|
+
is 166k pixels and this runs on every hover. */
|
|
2607
|
+
let stripHC = recall('stripHC','0') === '1';
|
|
2608
|
+
function stretchContrast(c, W, H){
|
|
2609
|
+
const im = c.getImageData(0, 0, W, H), d = im.data, n = W*H;
|
|
2610
|
+
const hist = new Uint32Array(256);
|
|
2611
|
+
for (let k = 0; k < n; k++){
|
|
2612
|
+
const o = k*4;
|
|
2613
|
+
hist[(d[o]*77 + d[o+1]*151 + d[o+2]*28) >> 8]++;
|
|
2614
|
+
}
|
|
2615
|
+
let lo = 0, hi = 255, acc = 0;
|
|
2616
|
+
for (let v = 0; v < 256; v++){ acc += hist[v]; if (acc > n*0.01){ lo = v; break; } }
|
|
2617
|
+
acc = 0;
|
|
2618
|
+
for (let v = 255; v >= 0; v--){ acc += hist[v]; if (acc > n*0.01){ hi = v; break; } }
|
|
2619
|
+
if (hi - lo < 2) return;
|
|
2620
|
+
const g = 255 / (hi - lo);
|
|
2621
|
+
const lut = new Uint8ClampedArray(256);
|
|
2622
|
+
for (let v = 0; v < 256; v++) lut[v] = Math.max(0, Math.min(255, (v - lo) * g));
|
|
2623
|
+
for (let o = 0; o < n*4; o += 4){ d[o] = lut[d[o]]; d[o+1] = lut[d[o+1]]; d[o+2] = lut[d[o+2]]; }
|
|
2624
|
+
c.putImageData(im, 0, 0);
|
|
2625
|
+
}
|
|
2626
|
+
function setStripHC(on){
|
|
2627
|
+
stripHC = on; remember('stripHC', on ? '1' : '0');
|
|
2628
|
+
$('#stripHC').setAttribute('aria-pressed', on ? 'true' : 'false');
|
|
2629
|
+
}
|
|
2630
|
+
$('#stripHC').onclick = () => { setStripHC(!stripHC); drawStrip(); };
|
|
2631
|
+
function paintStrip(c, W, H, i, mode){
|
|
2467
2632
|
c.setTransform(1,0,0,1,0,0);
|
|
2468
2633
|
c.fillStyle = '#0c0c0b'; c.fillRect(0,0,W,H);
|
|
2469
2634
|
const A = st.corners[i], B = st.corners[(i+1)%4];
|
|
@@ -2478,7 +2643,25 @@ function paintStrip(c, W, H, i){
|
|
|
2478
2643
|
c.imageSmoothingEnabled = true;
|
|
2479
2644
|
c.drawImage(img, 0, 0);
|
|
2480
2645
|
c.restore();
|
|
2646
|
+
if (stripHC) stretchContrast(c, W, H);
|
|
2481
2647
|
cased(c, () => { c.beginPath(); c.moveTo(0, H/2); c.lineTo(W, H/2); }, 1.25, CORE_ACTIVE);
|
|
2648
|
+
// Which end is about to swing. The canvas shows this with the pivot pips,
|
|
2649
|
+
// but the eye is on the strip while an edge is being placed, and "rotA" /
|
|
2650
|
+
// "rotB" are meaningless there without a mark. A grab near one end pivots
|
|
2651
|
+
// on the OTHER: the pivot end gets the same filled pip the canvas uses, the
|
|
2652
|
+
// swinging end a double arrow across the rule. Translate marks nothing —
|
|
2653
|
+
// both ends move. Left in the strip is A, the edge's first corner.
|
|
2654
|
+
if (mode === 'rotA' || mode === 'rotB'){
|
|
2655
|
+
// At 0.16 / 0.84 of the edge: where the canvas draws its pips, and clear
|
|
2656
|
+
// of the screen/outside labels at the left edge.
|
|
2657
|
+
const pivotX = mode === 'rotA' ? W*0.16 : W*0.84, swingX = mode === 'rotA' ? W*0.84 : W*0.16;
|
|
2658
|
+
casedDisc(c, pivotX, H/2, 3.5, CORE_ACTIVE);
|
|
2659
|
+
cased(c, () => { c.beginPath();
|
|
2660
|
+
c.moveTo(swingX, H/2 - 22); c.lineTo(swingX, H/2 + 22);
|
|
2661
|
+
c.moveTo(swingX - 5, H/2 - 16); c.lineTo(swingX, H/2 - 22); c.lineTo(swingX + 5, H/2 - 16);
|
|
2662
|
+
c.moveTo(swingX - 5, H/2 + 16); c.lineTo(swingX, H/2 + 22); c.lineTo(swingX + 5, H/2 + 16); },
|
|
2663
|
+
1.5, CORE_ACTIVE);
|
|
2664
|
+
}
|
|
2482
2665
|
cased(c, () => { c.beginPath();
|
|
2483
2666
|
for (const f of [0.25,0.5,0.75]){ c.moveTo(W*f, H/2-9); c.lineTo(W*f, H/2+9); } },
|
|
2484
2667
|
1, 'rgba(255,255,255,.7)');
|
|
@@ -2488,6 +2671,11 @@ function paintStrip(c, W, H, i){
|
|
|
2488
2671
|
casedText(c, 'screen', 9, inwardIsDown ? H/2 + 15 : H/2 - 15, 'rgba(255,255,255,.95)');
|
|
2489
2672
|
casedText(c, 'outside', 9, inwardIsDown ? H/2 - 15 : H/2 + 15, 'rgba(255,255,255,.62)');
|
|
2490
2673
|
}
|
|
2674
|
+
function swingText(mode){
|
|
2675
|
+
if (mode === 'rotA') return 'the right end swings, pivot on the left';
|
|
2676
|
+
if (mode === 'rotB') return 'the left end swings, pivot on the right';
|
|
2677
|
+
return 'flat on the rule means aligned';
|
|
2678
|
+
}
|
|
2491
2679
|
function drawStrip(){
|
|
2492
2680
|
const a = drag || hover || pick;
|
|
2493
2681
|
const has = !!(st.corners && a && a.kind === 'edge' && img.naturalWidth);
|
|
@@ -2500,13 +2688,13 @@ function drawStrip(){
|
|
|
2500
2688
|
$('#stripSt').textContent = 'Hover or drag an edge — this straightens it, so misalignment shows as a wedge.';
|
|
2501
2689
|
return;
|
|
2502
2690
|
}
|
|
2503
|
-
paintStrip(sctx, W, H, a.i);
|
|
2504
|
-
$('#stripSt').textContent = `${EDGE_LBL[a.i]} edge · ±${stripHalf}px · ${(H/(2*stripHalf)).toFixed(1)}× across the boundary —
|
|
2691
|
+
paintStrip(sctx, W, H, a.i, a.mode);
|
|
2692
|
+
$('#stripSt').textContent = `${EDGE_LBL[a.i]} edge · ±${stripHalf}px · ${(H/(2*stripHalf)).toFixed(1)}× across the boundary — ${swingText(a.mode)}`;
|
|
2505
2693
|
} else {
|
|
2506
2694
|
const fl = $('#floatLoupe');
|
|
2507
2695
|
if (!has){ fl.classList.remove('on'); return; }
|
|
2508
|
-
paintStrip(fctx, stripF.width, stripF.height, a.i);
|
|
2509
|
-
$('#stripFCap').textContent = `${EDGE_LBL[a.i]} edge · ±${stripHalf}px —
|
|
2696
|
+
paintStrip(fctx, stripF.width, stripF.height, a.i, a.mode);
|
|
2697
|
+
$('#stripFCap').textContent = `${EDGE_LBL[a.i]} edge · ±${stripHalf}px — ${swingText(a.mode)}`;
|
|
2510
2698
|
fl.classList.add('on');
|
|
2511
2699
|
const w = fl.offsetWidth || 356, h = fl.offsetHeight || 130;
|
|
2512
2700
|
let x = lastPointer[0] + 26, y = lastPointer[1] - h - 18;
|
|
@@ -2653,6 +2841,9 @@ function showResult(what){
|
|
|
2653
2841
|
geometry — exact, the same four corners the render uses
|
|
2654
2842
|
corner radius — applied in the video's own pixel space, before the warp,
|
|
2655
2843
|
which is where compose() applies it
|
|
2844
|
+
depth of field — ABSENT. A blur that grows across the screen has no
|
|
2845
|
+
CSS equivalent that follows a homography; the composite
|
|
2846
|
+
is the only view that shows it.
|
|
2656
2847
|
emissive — APPROXIMATED with `screen` blending. Same idea (the
|
|
2657
2848
|
screen's light plus the glass beneath it), not the same
|
|
2658
2849
|
arithmetic as compose()'s emissive.
|
|
@@ -2749,10 +2940,10 @@ async function playLive(){
|
|
|
2749
2940
|
if (!(st.photo && st.shot && st.corners && st.video)) return;
|
|
2750
2941
|
if (!$('#liveWrap').hidden && !$('#liveVid').paused) return stopLive();
|
|
2751
2942
|
const v = $('#liveVid'), s = $('#outSt');
|
|
2752
|
-
$('#liveBg').src =
|
|
2943
|
+
$('#liveBg').src = srcURL(st.photo, st.photoV);
|
|
2753
2944
|
if (!v.src || v.dataset.src !== st.shot){
|
|
2754
2945
|
v.dataset.src = st.shot;
|
|
2755
|
-
v.src =
|
|
2946
|
+
v.src = srcURL(st.shot, st.shotV);
|
|
2756
2947
|
}
|
|
2757
2948
|
showResult('live');
|
|
2758
2949
|
await new Promise(r => {
|
|
@@ -2799,7 +2990,7 @@ async function playClip(){
|
|
|
2799
2990
|
// a preview the user did not ask for, and the wait goes silent again.
|
|
2800
2991
|
building = true;
|
|
2801
2992
|
try{
|
|
2802
|
-
await api('/api/preview_video', {corners: st.corners, radius_frac: radiusValue(), smoothing: smoothingValue(),
|
|
2993
|
+
await api('/api/preview_video', {corners: st.corners, radius_frac: radiusValue(), smoothing: smoothingValue(), dof_angle: dofAngle(), dof_strength: dofStrength(),
|
|
2803
2994
|
device: st.type, grade: gradeValue(),
|
|
2804
2995
|
reflection: emisValue(), fit_frame: +$('#vframe').value});
|
|
2805
2996
|
}catch(e){
|
|
@@ -2858,7 +3049,7 @@ async function renderPreview(){
|
|
|
2858
3049
|
const say = t => { if (!building && $('#liveWrap').hidden) s.textContent = t; };
|
|
2859
3050
|
say('Rendering…');
|
|
2860
3051
|
try{
|
|
2861
|
-
const r = await api('/api/preview', {corners: st.corners, radius_frac: radiusValue(), smoothing: smoothingValue(), device: st.type, grade: gradeValue(), reflection: emisValue()});
|
|
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()});
|
|
2862
3053
|
const im = $('#outImg');
|
|
2863
3054
|
im.onload = () => {
|
|
2864
3055
|
outNat = im.naturalWidth;
|
|
@@ -2988,7 +3179,7 @@ async function renderVideo(){
|
|
|
2988
3179
|
b.disabled = true;
|
|
2989
3180
|
setRenderProgress(0);
|
|
2990
3181
|
try{
|
|
2991
|
-
await api('/api/render', {corners: st.corners, radius_frac: radiusValue(), smoothing: smoothingValue(), device: st.type,
|
|
3182
|
+
await api('/api/render', {corners: st.corners, radius_frac: radiusValue(), smoothing: smoothingValue(), dof_angle: dofAngle(), dof_strength: dofStrength(), device: st.type,
|
|
2992
3183
|
grade: gradeValue(), reflection: emisValue(), preset, fit_frame: +$('#vframe').value});
|
|
2993
3184
|
}catch(e){
|
|
2994
3185
|
setRenderProgress(null);
|
|
@@ -3034,7 +3225,7 @@ $('#save').onclick = async () => {
|
|
|
3034
3225
|
if (st.video) return renderVideo();
|
|
3035
3226
|
const b = $('#save'); b.textContent = 'Saving…'; b.disabled = true;
|
|
3036
3227
|
try{
|
|
3037
|
-
const r = await api('/api/save', {corners: st.corners, radius_frac: radiusValue(), smoothing: smoothingValue(), device: st.type, grade: gradeValue(), reflection: emisValue()});
|
|
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()});
|
|
3038
3229
|
b.textContent = saveLabel();
|
|
3039
3230
|
// The real destination, not a hardcoded one: --out-dir means saves usually
|
|
3040
3231
|
// land in the project folder now, and telling the user "~/Desktop" when
|
|
@@ -3110,8 +3301,10 @@ $('#imp').onclick = async () => {
|
|
|
3110
3301
|
b.onclick=()=>{setType(t); applyPreset(); autoPreview();}; types.appendChild(b);
|
|
3111
3302
|
}
|
|
3112
3303
|
setLoupeMode(loupeMode);
|
|
3304
|
+
setStripHC(stripHC);
|
|
3113
3305
|
setGrade(gradeOn, gradeAmt);
|
|
3114
3306
|
setEmis(emisOn, emisAmt);
|
|
3307
|
+
setDof(dofOn, dofAng, dofStr);
|
|
3115
3308
|
setRadiusOn(radiusOn);
|
|
3116
3309
|
sectionsBooted = true; // animate from here on, not during the first paint
|
|
3117
3310
|
// Compare is the DEFAULT view: judging a fit means comparing it with the
|