screengraft 0.53.0 → 0.54.1
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 +18 -5
- package/scripts/ui.py +9 -3
- package/scripts/warp.py +7 -6
- package/skills/inject-screenshot/SKILL.md +2 -2
- package/ui/index.html +48 -19
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "screengraft",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.54.1",
|
|
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
|
@@ -90,10 +90,18 @@ def _blur(img: np.ndarray, sigma: float) -> np.ndarray:
|
|
|
90
90
|
return cv2.GaussianBlur(img, (k, k), sigma, borderType=cv2.BORDER_REPLICATE)
|
|
91
91
|
|
|
92
92
|
|
|
93
|
-
def screen_ramp(src_w: int, src_h: int, angle_deg: float, start: float, end: float
|
|
93
|
+
def screen_ramp(src_w: int, src_h: int, angle_deg: float, start: float, end: float,
|
|
94
|
+
end2=None) -> np.ndarray:
|
|
94
95
|
"""The ramp in SCREENSHOT space: 0 up to `start`, 1 from `end`, linear
|
|
95
96
|
between, along `angle` (0 = toward +x, 90 = toward +y of the screenshot),
|
|
96
|
-
|
|
97
|
+
all as fractions of the screenshot's extent along that direction.
|
|
98
|
+
|
|
99
|
+
`end2`, when given, is the NEAR limit: a second ramp running the other way
|
|
100
|
+
from `start`, reaching 1 at `end2` (< start). The plane of focus then sits
|
|
101
|
+
inside the screen with blur growing on both sides of it — the depth of
|
|
102
|
+
field's near and far limits, Photoshop's tilt-shift band. None keeps the
|
|
103
|
+
one-sided ramp, which is the plane of focus at the screen's edge.
|
|
104
|
+
"""
|
|
97
105
|
a = math.radians(angle_deg)
|
|
98
106
|
d = np.array([math.cos(a), math.sin(a)], dtype=np.float64)
|
|
99
107
|
q = np.array([[0, 0], [src_w, 0], [src_w, src_h], [0, src_h]], dtype=np.float64)
|
|
@@ -104,7 +112,12 @@ def screen_ramp(src_w: int, src_h: int, angle_deg: float, start: float, end: flo
|
|
|
104
112
|
s0, s1 = lo + start * (hi - lo), lo + end * (hi - lo)
|
|
105
113
|
xs = np.arange(src_w, dtype=np.float64)[None, :] + 0.5
|
|
106
114
|
ys = np.arange(src_h, dtype=np.float64)[:, None] + 0.5
|
|
107
|
-
|
|
115
|
+
pr = xs * d[0] + ys * d[1]
|
|
116
|
+
t = (pr - s0) / max(s1 - s0, 1e-6)
|
|
117
|
+
if end2 is not None:
|
|
118
|
+
e2 = float(np.clip(end2, -0.5, start - 0.05))
|
|
119
|
+
s2 = lo + e2 * (hi - lo)
|
|
120
|
+
t = np.maximum(t, (s0 - pr) / max(s0 - s2, 1e-6))
|
|
108
121
|
return np.clip(t, 0.0, 1.0).astype(np.float32)
|
|
109
122
|
|
|
110
123
|
|
|
@@ -114,14 +127,14 @@ class Field:
|
|
|
114
127
|
|
|
115
128
|
def __init__(self, corners, angle_deg: float, strength: float, mask: np.ndarray,
|
|
116
129
|
x0: int, y0: int, start: float = 0.0, end: float = 1.0,
|
|
117
|
-
space: str = "photo", H=None, src_size=None):
|
|
130
|
+
space: str = "photo", H=None, src_size=None, end2=None):
|
|
118
131
|
h, w = mask.shape[:2]
|
|
119
132
|
self.x0, self.y0 = x0, y0
|
|
120
133
|
self.smax = sigma_max(corners, strength)
|
|
121
134
|
self.sigmas = [self.smax * k / (DOF_LEVELS - 1) for k in range(DOF_LEVELS)]
|
|
122
135
|
if space == "screen" and H is not None and src_size is not None:
|
|
123
136
|
sw, sh = src_size
|
|
124
|
-
src = screen_ramp(sw, sh, angle_deg, start, end)
|
|
137
|
+
src = screen_ramp(sw, sh, angle_deg, start, end, end2)
|
|
125
138
|
T = np.array([[1, 0, -x0], [0, 1, -y0], [0, 0, 1]], dtype=np.float64)
|
|
126
139
|
t = cv2.warpPerspective(src, T @ np.asarray(H, dtype=np.float64), (w, h),
|
|
127
140
|
flags=cv2.INTER_LINEAR, borderMode=cv2.BORDER_REPLICATE)
|
package/scripts/ui.py
CHANGED
|
@@ -685,9 +685,15 @@ def _dof_args(b):
|
|
|
685
685
|
strength, angle, start, end = 0.0, 0.0, 0.0, 1.0
|
|
686
686
|
space = "screen" if b.get("dof_space") == "screen" else "photo"
|
|
687
687
|
start = float(min(max(start, 0.0), 0.95))
|
|
688
|
+
# The near limit is optional: absent or null means one-sided.
|
|
689
|
+
end2 = b.get("dof_end2")
|
|
690
|
+
try:
|
|
691
|
+
end2 = None if end2 is None else float(min(max(float(end2), -0.5), start - 0.05))
|
|
692
|
+
except (TypeError, ValueError):
|
|
693
|
+
end2 = None
|
|
688
694
|
return {"dof_angle": angle % 360.0, "dof_strength": float(min(max(strength, 0.0), 1.0)),
|
|
689
695
|
"dof_start": start, "dof_end": float(min(max(end, start + 0.05), 1.5)),
|
|
690
|
-
"dof_space": space}
|
|
696
|
+
"dof_space": space, "dof_end2": end2}
|
|
691
697
|
|
|
692
698
|
|
|
693
699
|
ROLES = ("photo", "screenshot")
|
|
@@ -1246,7 +1252,7 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1246
1252
|
"blend": blend, "reflection": reflection,
|
|
1247
1253
|
"dof_angle": dof["dof_angle"], "dof_strength": dof["dof_strength"],
|
|
1248
1254
|
"dof_start": dof["dof_start"], "dof_end": dof["dof_end"],
|
|
1249
|
-
"dof_space": dof["dof_space"],
|
|
1255
|
+
"dof_space": dof["dof_space"], "dof_end2": dof["dof_end2"],
|
|
1250
1256
|
# A render is always the whole clip; only the preview
|
|
1251
1257
|
# passes a segment. Recorded anyway, because the
|
|
1252
1258
|
# sidecar's promise is EVERY argument that changes the
|
|
@@ -1339,7 +1345,7 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1339
1345
|
"blend": blend, "reflection": reflection,
|
|
1340
1346
|
"dof_angle": dof["dof_angle"], "dof_strength": dof["dof_strength"],
|
|
1341
1347
|
"dof_start": dof["dof_start"], "dof_end": dof["dof_end"],
|
|
1342
|
-
"dof_space": dof["dof_space"],
|
|
1348
|
+
"dof_space": dof["dof_space"], "dof_end2": dof["dof_end2"],
|
|
1343
1349
|
"saved": time.time()}
|
|
1344
1350
|
# A fit is remembered when it PRODUCED something, not while it
|
|
1345
1351
|
# is being dragged: a quad on the canvas is a work in progress,
|
package/scripts/warp.py
CHANGED
|
@@ -270,7 +270,7 @@ class Plan:
|
|
|
270
270
|
blend: str = "replace", reflection: float = DEFAULT_REFLECTION,
|
|
271
271
|
corner_smoothing: float = 0.0,
|
|
272
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
|
+
dof_end: float = 1.0, dof_space: str = "photo", dof_end2=None):
|
|
274
274
|
dst_quad = np.array(corners, dtype=np.float32)
|
|
275
275
|
if shoelace_area(dst_quad) < 1.0:
|
|
276
276
|
raise ValueError("degenerate quad (near-zero area) — check corner order TL,TR,BR,BL")
|
|
@@ -324,6 +324,7 @@ class Plan:
|
|
|
324
324
|
self.dof_start = float(np.clip(dof_start, 0.0, 0.95))
|
|
325
325
|
self.dof_end = float(np.clip(dof_end, self.dof_start + 0.05, 1.5))
|
|
326
326
|
self.dof_space = "screen" if dof_space == "screen" else "photo"
|
|
327
|
+
self.dof_end2 = None if dof_end2 is None else float(np.clip(dof_end2, -0.5, self.dof_start - 0.05))
|
|
327
328
|
reach = int(np.ceil(3 * _dof.sigma_max(dst_quad, self.dof_strength))) if self.dof_strength > 0 else 0
|
|
328
329
|
bx0, by0 = max(0, int(np.floor(xs.min())) - 1 - reach), max(0, int(np.floor(ys.min())) - 1 - reach)
|
|
329
330
|
bx1, by1 = min(pw, int(np.ceil(xs.max())) + 2 + reach), min(ph, int(np.ceil(ys.max())) + 2 + reach)
|
|
@@ -337,7 +338,7 @@ class Plan:
|
|
|
337
338
|
self.warped_mask[by0:by1, bx0:bx1], bx0, by0,
|
|
338
339
|
start=self.dof_start, end=self.dof_end,
|
|
339
340
|
space=self.dof_space, H=self.H,
|
|
340
|
-
src_size=(self.new_w, self.new_h))
|
|
341
|
+
src_size=(self.new_w, self.new_h), end2=self.dof_end2)
|
|
341
342
|
|
|
342
343
|
def _prep(self, frame: np.ndarray, bbox=None) -> np.ndarray:
|
|
343
344
|
"""Warp one frame. With `bbox`, warp only that window of the canvas.
|
|
@@ -443,7 +444,7 @@ def compose(photo: np.ndarray, screenshot: np.ndarray, corners, corner_radius: f
|
|
|
443
444
|
reflection: float = DEFAULT_REFLECTION,
|
|
444
445
|
dof_angle: float = 0.0, dof_strength: float = 0.0,
|
|
445
446
|
dof_start: float = 0.0, dof_end: float = 1.0,
|
|
446
|
-
dof_space: str = "photo") -> np.ndarray:
|
|
447
|
+
dof_space: str = "photo", dof_end2=None) -> np.ndarray:
|
|
447
448
|
"""Warp `screenshot` into the quad `corners` (TL,TR,BR,BL, photo pixels) on `photo`.
|
|
448
449
|
|
|
449
450
|
Single resampling pass at the photo's resolution; deterministic. This is the
|
|
@@ -458,7 +459,7 @@ def compose(photo: np.ndarray, screenshot: np.ndarray, corners, corner_radius: f
|
|
|
458
459
|
corner_smoothing=corner_smoothing,
|
|
459
460
|
blend=blend, reflection=reflection,
|
|
460
461
|
dof_angle=dof_angle, dof_strength=dof_strength, dof_start=dof_start,
|
|
461
|
-
dof_end=dof_end, dof_space=dof_space)
|
|
462
|
+
dof_end=dof_end, dof_space=dof_space, dof_end2=dof_end2)
|
|
462
463
|
plan.bind_grade(screenshot, grade)
|
|
463
464
|
return plan.render(screenshot, screen_off=screen_off, specular=specular)
|
|
464
465
|
|
|
@@ -548,7 +549,7 @@ def compose_video(photo: np.ndarray, video_path: str, corners, output: str,
|
|
|
548
549
|
start_frame: int = 0, max_frames: int = None,
|
|
549
550
|
dof_angle: float = 0.0, dof_strength: float = 0.0,
|
|
550
551
|
dof_start: float = 0.0, dof_end: float = 1.0,
|
|
551
|
-
dof_space: str = "photo") -> dict:
|
|
552
|
+
dof_space: str = "photo", dof_end2=None) -> dict:
|
|
552
553
|
"""Inject a VIDEO into a still photo. The photo does not move, so there is
|
|
553
554
|
exactly one homography and the whole of Plan is computed once.
|
|
554
555
|
|
|
@@ -577,7 +578,7 @@ def compose_video(photo: np.ndarray, video_path: str, corners, output: str,
|
|
|
577
578
|
corner_smoothing=corner_smoothing,
|
|
578
579
|
blend=blend, reflection=reflection,
|
|
579
580
|
dof_angle=dof_angle, dof_strength=dof_strength, dof_start=dof_start,
|
|
580
|
-
dof_end=dof_end, dof_space=dof_space)
|
|
581
|
+
dof_end=dof_end, dof_space=dof_space, dof_end2=dof_end2)
|
|
581
582
|
plan.bind_grade(first, grade)
|
|
582
583
|
|
|
583
584
|
ph, pw = photo.shape[:2]
|
|
@@ -5,7 +5,7 @@ description: Injects a UI screenshot OR a screen recording onto a photographed d
|
|
|
5
5
|
|
|
6
6
|
# Inject a screenshot onto a photographed device
|
|
7
7
|
|
|
8
|
-
**What ships (v0.
|
|
8
|
+
**What ships (v0.54):** a local browser UI (`scripts/ui.py`) that walks the designer through the whole job — pick the photo and the screen source, which may be an image **or a video** (recent Desktop/Downloads images, drag-drop, browse, path, or a **Figma frame link**), auto-detect the screen as a starting position — and when detection cannot tell which region is a screen, **Point at screen**: one click inside it and the detector uses that point — or, if this photograph has been fitted before, **the fit it was saved with comes back** as the starting position instead of a detection, recognised by the photo's own pixels so a rename or a drag-drop still match — and every save also writes a **portable `.fit.json` beside the mockup** that can be dropped back onto the page later, which is how a fit survives a re-export, another machine, or someone else's hands — then **match the four edges** (drag an edge's middle to slide it, near an end to pivot; corners still draggable) with canvas navigation that follows the usual conventions — **hold ⌘ and scroll to zoom to the pointer, hold space and drag to pan** — and a rectified strip loupe. The fit and the composite sit **side by side and always have** — the result pane re-renders as you drag, which is how a corner gets judged, so it is the layout rather than a mode you can switch off. Then an on-by-default realism pass that colour-matches the source to the photo's light, **Save** (or **Render**, for a video) into the project folder (`--out-dir`), and a **Send to Claude** button that reaches you through the plugin's own MCP server. The UI is a hand port of the project's Figma design file — dark only.
|
|
9
9
|
|
|
10
10
|
The geometry is exact (`warp.py`); the detection is advisory (`detect.py`) and the human corrects it. **When a detection is wrong and you want to know why**, ask for the candidate list: `POST /api/detect {"trace": true}` writes `<session>/candidates.json`, or run `python3 scripts/detect.py --photo P --out-corners /tmp/c.json --trace /tmp/t.json` (add `--click X,Y`). Every candidate quad is in there with its score and whether it was accepted, rejected, never reached, or filtered out by the click — which is what separates "the screen was never proposed" from "it was proposed and something else won".
|
|
11
11
|
|
|
@@ -13,7 +13,7 @@ The geometry is exact (`warp.py`); the detection is advisory (`detect.py`) and t
|
|
|
13
13
|
|
|
14
14
|
**The realism pass ships and is ON by default** (`grade.py`): it matches the injected screen's white balance and grain to the light around it, at a strength the designer sets in the rail. It can also lift the device's real specular highlights from a screen-off reference frame, though the UI cannot supply one yet. Off is a first-class choice and keeps the screenshot's colour exactly — say so if the user is reviewing brand colour.
|
|
15
15
|
|
|
16
|
-
**Depth of field** *(off by default)*: a phone shot at an angle is a plane receding from the camera, so its far end is softer than its near end, and a screenshot pasted pin-sharp across all of it gives the fake away. On, the screenshot blurs across the screen in one direction — **direction** (where the blur grows toward) and **strength** — and the glass edge softens with it. **Measure from photo** reads both off the photograph's own screen boundary; on a real photograph that works, on a *mockup template* the device is usually rendered sharp with the blur only on the background, so it answers "flat" and the designer sets it by eye. The measured strength is a floor (the estimator saturates around 5px of blur), never a ceiling. **The gizmo on the result** is the primary control, 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.
|
|
16
|
+
**Depth of field** *(off by default)*: a phone shot at an angle is a plane receding from the camera, so its far end is softer than its near end, and a screenshot pasted pin-sharp across all of it gives the fake away. On, the screenshot blurs across the screen in one direction — **direction** (where the blur grows toward) and **strength** — and the glass edge softens with it. **Measure from photo** reads both off the photograph's own screen boundary; on a real photograph that works, on a *mockup template* the device is usually rendered sharp with the blur only on the background, so it answers "flat" and the designer sets it by eye. The measured strength is a floor (the estimator saturates around 5px of blur), never a ceiling. **The gizmo on the result** is the primary control, defined in the screen's own plane and projected through the fit (so the two lines converge with the phone's edges on a steep shot, as real iso-blur lines do): a solid line where focus ends (drag its centre), a dashed line where the blur reaches its full amount (drag its centre to move it, its end pips to turn both, and slide the diamond along it for how much blur). **Both sides** adds a near limit — a second dashed line behind the focus line — for a phone whose middle is sharp and both ends soft (the depth of field's near and far limits). It fades when the pointer leaves the pane. The sliders and the gizmo are one model. Suggest it when the photo has visible bokeh — a blurred hand, table edge or background — and the composite's screen looks pasted on. The live in-place playback cannot show it; the composite and Render preview do.
|
|
17
17
|
|
|
18
18
|
**Video ships too.** The screen source can be a video (mp4/mov/webm) as well as a still — pick it exactly like a screenshot, choose which frame to match the edges on, **press Play and the clip runs on the photo immediately** — the browser warps it onto the same four corners with the same corner radius and approximates the emissive blend, so placement and motion can be judged with no wait; **Render preview** composites a few seconds through the real pipeline when the grade, grain and true blend are what you need to see — and the primary button becomes **Render**. The photo does not move, so there is one homography and every frame gets the same geometry; the light match is measured once from the frame you fitted on, so the screen cannot pulse as the UI scrolls. Output is H.264 at CRF 16 (near-visually-lossless) or ProRes 422 HQ. This is what pairs with a prototype recording: record the prototype, then inject the recording into a real photograph.
|
|
19
19
|
|
package/ui/index.html
CHANGED
|
@@ -386,9 +386,9 @@
|
|
|
386
386
|
.seg button.sel:active{background:var(--raise-low);transform:none}
|
|
387
387
|
/* A toggle in the strip head. On = the raised surface every selected thing
|
|
388
388
|
on this page has (the segmented thumb, the device chip), off = a label. */
|
|
389
|
-
.striphead button[aria-pressed]{background:transparent;border-color:transparent;box-shadow:none;color:var(--mute)}
|
|
390
|
-
.striphead button[aria-pressed]:hover{color:var(--ink)}
|
|
391
|
-
.striphead button[aria-pressed="true"]{background:var(--raise);border-color:var(--edge);color:var(--ink);font-weight:600}
|
|
389
|
+
.striphead button[aria-pressed],#dofBoth[aria-pressed]{background:transparent;border-color:transparent;box-shadow:none;color:var(--mute)}
|
|
390
|
+
.striphead button[aria-pressed]:hover,#dofBoth[aria-pressed]:hover{color:var(--ink)}
|
|
391
|
+
.striphead button[aria-pressed="true"],#dofBoth[aria-pressed="true"]{background:var(--raise);border-color:var(--edge);color:var(--ink);font-weight:600}
|
|
392
392
|
/* Input chip (Figma 5:14 / 5:20). Surface and shadow are declared here rather
|
|
393
393
|
than inherited from the base button rule, so the filled state is explicit and
|
|
394
394
|
the .is-empty override below has something definite to override.
|
|
@@ -584,14 +584,16 @@
|
|
|
584
584
|
button.switch:hover{background:none}
|
|
585
585
|
button.switch:active{background:none;border:none;box-shadow:none;transform:none}
|
|
586
586
|
|
|
587
|
-
|
|
587
|
+
/* Track 48×20 inside a 48×28 hit area (Figma 17:61): the button keeps the
|
|
588
|
+
full 28px for the pointer, the drawn track is 20px. Was 22px. */
|
|
589
|
+
.sw-track{position:relative;display:block;width:48px;height:20px;border-radius:6px;
|
|
588
590
|
background:var(--bg);border:1px solid var(--edge);
|
|
589
591
|
transition:var(--t-all), box-shadow var(--t)}
|
|
590
592
|
.sw-handle{position:absolute;top:1px;bottom:1px;left:1px;width:24px;border-radius:4px;
|
|
591
593
|
background:var(--raise-hi);
|
|
592
594
|
transition:left var(--t), right var(--t), width var(--t), background-color var(--t), opacity var(--t)}
|
|
593
595
|
.sw-grip{position:absolute;border-radius:1px;background:var(--sunk);
|
|
594
|
-
left:11px;width:4px;top:
|
|
596
|
+
left:11px;width:4px;top:7px;bottom:7px;
|
|
595
597
|
transition:left var(--t), width var(--t), top var(--t), bottom var(--t), background-color var(--t)}
|
|
596
598
|
|
|
597
599
|
/* --- OFF ------------------------------------------------------------- */
|
|
@@ -602,7 +604,7 @@
|
|
|
602
604
|
/* --- ON -------------------------------------------------------------- */
|
|
603
605
|
button.switch[aria-checked="true"] .sw-track{background:var(--acc-btn-rest);border-color:var(--acc-stroke)}
|
|
604
606
|
button.switch[aria-checked="true"] .sw-handle{left:auto;right:1px;background:var(--acc-ink)}
|
|
605
|
-
button.switch[aria-checked="true"] .sw-grip{left:32px;width:2px;top:
|
|
607
|
+
button.switch[aria-checked="true"] .sw-grip{left:32px;width:2px;top:4px;bottom:4px;
|
|
606
608
|
background:var(--acc-btn-rest)}
|
|
607
609
|
button.switch[aria-checked="true"]:hover .sw-track{background:var(--acc);border-color:var(--acc-stroke)}
|
|
608
610
|
button.switch[aria-checked="true"]:hover .sw-grip{background:var(--acc)}
|
|
@@ -1153,6 +1155,7 @@
|
|
|
1153
1155
|
</div>
|
|
1154
1156
|
<input type="range" id="dofStr" min="0" max="1" step="0.05" value="0.3" aria-label="Blur strength at the far edge">
|
|
1155
1157
|
<div style="display:flex;align-items:center;gap:var(--s2)">
|
|
1158
|
+
<button class="sm" id="dofBoth" aria-pressed="false" title="Blur on both sides of the focus line — the depth of field's near and far limits, for a phone whose middle is sharp and both ends soft. Off, the plane of focus is at the screen's edge and blur grows one way.">Both sides</button>
|
|
1156
1159
|
<button class="sm" id="dofMeasure" title="Read the direction and a starting strength off the photo's own bezel">Measure from photo</button>
|
|
1157
1160
|
<span class="status sm" id="dofSt" style="min-width:0"></span>
|
|
1158
1161
|
</div>
|
|
@@ -2577,9 +2580,10 @@ function setDof(on, ang, str){
|
|
|
2577
2580
|
dofOn = on;
|
|
2578
2581
|
if (ang !== undefined) dofAng = ((ang % 360) + 360) % 360;
|
|
2579
2582
|
if (str !== undefined) dofStr = Math.max(0, Math.min(1, str));
|
|
2580
|
-
remember('dof', on ? '1' : '0'); remember('dofAng', String(dofAng)); remember('dofStr', String(dofStr)); remember('dofStart', String(dofStart)); remember('dofEnd', String(dofEnd));
|
|
2583
|
+
remember('dof', on ? '1' : '0'); remember('dofAng', String(dofAng)); remember('dofStr', String(dofStr)); remember('dofStart', String(dofStart)); remember('dofEnd', String(dofEnd)); remember('dofEnd2', dofEnd2 === null ? '' : String(dofEnd2));
|
|
2581
2584
|
setSectionOpen($('#secDof'), on);
|
|
2582
2585
|
paintDof();
|
|
2586
|
+
paintBoth();
|
|
2583
2587
|
paintGizmo();
|
|
2584
2588
|
}
|
|
2585
2589
|
let dofMeasuredFor = null; // photo path the last measurement was for
|
|
@@ -2610,7 +2614,7 @@ async function measureDof(){
|
|
|
2610
2614
|
ang = (Math.atan2(p1[1]-p0[1], p1[0]-p0[0]) * 180 / Math.PI + 360) % 360;
|
|
2611
2615
|
}
|
|
2612
2616
|
}
|
|
2613
|
-
dofStart = 0; dofEnd = 1; setDof(true, ang, Math.max(r.strength, 0.05));
|
|
2617
|
+
dofStart = 0; dofEnd = 1; dofEnd2 = null; setDof(true, ang, Math.max(r.strength, 0.05));
|
|
2614
2618
|
|
|
2615
2619
|
s.className = 'status sm ok';
|
|
2616
2620
|
s.textContent = `Measured: grows toward ${dofWord(r.angle)}. Strength is a floor — raise it if the far end looks softer.`;
|
|
@@ -2642,14 +2646,30 @@ $('#dofMeasure').onclick = measureDof;
|
|
|
2642
2646
|
sit anywhere on the screen or a little past it. Handles:
|
|
2643
2647
|
solid line centre -- move focus (start)
|
|
2644
2648
|
dashed line centre -- move where the ramp ends (end); spacing = ramp length
|
|
2645
|
-
|
|
2649
|
+
sharp line pips -- turn all lines about the focus line (direction), ⇧ snaps to 15°
|
|
2650
|
+
second dashed line -- the NEAR limit, when "Both sides" is on: blur grows
|
|
2651
|
+
the other way from the focus line too
|
|
2646
2652
|
diamond on the dashed line -- slide along it for how much blur (strength)
|
|
2647
2653
|
Everything is computed in screenshot pixels and mapped to the photo with H;
|
|
2648
2654
|
the SVG's viewBox is the photo so it stays aligned at every zoom for free. */
|
|
2649
2655
|
const gz = $('#dofGizmo');
|
|
2650
2656
|
const SIGMA_FULL_FRAC = 0.02; // mirrors dof.DOF_MAX_FRAC -- keep in step
|
|
2651
2657
|
let dofEnd = parseFloat(recall('dofEnd','1')); if (!(dofEnd > 0)) dofEnd = 1;
|
|
2658
|
+
// The near limit. null = one-sided (the plane of focus at the screen's edge).
|
|
2659
|
+
let dofEnd2 = recall('dofEnd2','') === '' ? null : parseFloat(recall('dofEnd2','')); if (dofEnd2 !== null && !(dofEnd2 < 1)) dofEnd2 = null;
|
|
2652
2660
|
function dofEndV(){ return dofOn ? dofEnd : 1; }
|
|
2661
|
+
function dofEnd2V(){ return dofOn ? dofEnd2 : null; }
|
|
2662
|
+
function paintBoth(){ $('#dofBoth').setAttribute('aria-pressed', dofEnd2 !== null ? 'true' : 'false'); }
|
|
2663
|
+
$('#dofBoth').onclick = () => {
|
|
2664
|
+
if (dofEnd2 === null){
|
|
2665
|
+
// Turning it on: the focus line is probably at the edge, where a near
|
|
2666
|
+
// limit has nowhere to go. Put the sharp band across the middle with both
|
|
2667
|
+
// limits at the screen's edges -- the "middle sharp, both ends soft" case.
|
|
2668
|
+
if (dofStart < 0.15){ dofStart = 0.5; dofEnd = 1.0; dofEnd2 = 0.0; }
|
|
2669
|
+
else dofEnd2 = Math.max(-0.5, dofStart - (dofEnd - dofStart));
|
|
2670
|
+
} else dofEnd2 = null;
|
|
2671
|
+
setDof(true); autoPreview();
|
|
2672
|
+
};
|
|
2653
2673
|
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
2674
|
function gizmoGeom(){
|
|
2655
2675
|
const [sw, sh] = st.shotSize, a = dofAng * Math.PI / 180;
|
|
@@ -2677,6 +2697,8 @@ function paintGizmo(){
|
|
|
2677
2697
|
const P = (f, q) => applyH(g.H, gzPt(g, f, q));
|
|
2678
2698
|
const a0 = P(dofStart, -0.06), a1 = P(dofStart, 1.06), ac = P(dofStart, 0.5);
|
|
2679
2699
|
const b0 = P(dofEnd, -0.06), b1 = P(dofEnd, 1.06), bc = P(dofEnd, 0.5);
|
|
2700
|
+
const two = dofEnd2 !== null;
|
|
2701
|
+
const c0 = two ? P(dofEnd2, -0.06) : null, c1 = two ? P(dofEnd2, 1.06) : null, cc = two ? P(dofEnd2, 0.5) : null;
|
|
2680
2702
|
const th = P(dofEnd, 0.08 + 0.84 * dofStr); // the strength thumb slides along the dashed line
|
|
2681
2703
|
const sigma = dofStr * SIGMA_FULL_FRAC * g.side;
|
|
2682
2704
|
const r = 6 * k, rp = 4 * k, fs11 = 11 * k, off = 14 * k;
|
|
@@ -2684,16 +2706,20 @@ function paintGizmo(){
|
|
|
2684
2706
|
const T = (p, txt, dy) => `<text x="${p[0]}" y="${p[1] + dy}" text-anchor="middle" font-size="${fs11}">${txt}</text>`;
|
|
2685
2707
|
const away = unit(sub(bc, ac)); // label offsets: away from the other line
|
|
2686
2708
|
const beyond = dofEnd > 1.0001;
|
|
2709
|
+
const beyond2 = two && dofEnd2 < -0.0001;
|
|
2687
2710
|
gz.innerHTML =
|
|
2688
2711
|
L(a0, a1, 'case', 'stroke-width="4"') + L(a0, a1, 'focus') +
|
|
2689
2712
|
L(b0, b1, 'case', 'stroke-width="3.5"') + L(b0, b1, 'far' + (beyond ? ' out' : '')) +
|
|
2713
|
+
(two ? L(c0, c1, 'case', 'stroke-width="3.5"') + L(c0, c1, 'far' + (beyond2 ? ' out' : '')) : '') +
|
|
2714
|
+
`<circle cx="${a0[0]}" cy="${a0[1]}" r="${rp}" class="h pip" data-h="rotA"/>` +
|
|
2715
|
+
`<circle cx="${a1[0]}" cy="${a1[1]}" r="${rp}" class="h pip" data-h="rotB"/>` +
|
|
2690
2716
|
`<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
2717
|
`<circle cx="${bc[0]}" cy="${bc[1]}" r="${r}" class="h hollow" data-h="end"/>` +
|
|
2718
|
+
(two ? `<circle cx="${cc[0]}" cy="${cc[1]}" r="${r}" class="h hollow" data-h="end2"/>` : '') +
|
|
2694
2719
|
`<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) +
|
|
2720
|
+
T([ac[0] - away[0]*off*(two ? 0 : 1), ac[1] - away[1]*off*(two ? 0 : 1) + (two ? -off : 0)], 'sharp', 4*k) +
|
|
2696
2721
|
T([bc[0] + away[0]*off, bc[1] + away[1]*off], `σ ${sigma.toFixed(0)}px from here${beyond ? ' · past the screen' : ''}`, 4*k) +
|
|
2722
|
+
(two ? T([cc[0] - away[0]*off, cc[1] - away[1]*off], `σ ${sigma.toFixed(0)}px from here${beyond2 ? ' · past the screen' : ''}`, 4*k) : '') +
|
|
2697
2723
|
T([th[0] + away[0]*off*1.6, th[1] + away[1]*off*1.6], `${Math.round(dofStr*100)}%`, 4*k);
|
|
2698
2724
|
}
|
|
2699
2725
|
// pointer position in SCREENSHOT pixels
|
|
@@ -2716,14 +2742,17 @@ gz.addEventListener('pointermove', e => {
|
|
|
2716
2742
|
const f = (p[0]*g.d[0] + p[1]*g.d[1] - g.lo) / (g.hi - g.lo);
|
|
2717
2743
|
const q = (p[0]*g.u[0] + p[1]*g.u[1] - g.ulo) / (g.uhi - g.ulo);
|
|
2718
2744
|
if (gzDrag.h === 'start'){
|
|
2719
|
-
dofStart = Math.max(0, Math.min(0.95, Math.min(f, dofEnd - 0.05)));
|
|
2745
|
+
dofStart = Math.max(dofEnd2 === null ? 0 : dofEnd2 + 0.05, Math.min(0.95, Math.min(f, dofEnd - 0.05)));
|
|
2720
2746
|
} else if (gzDrag.h === 'end'){
|
|
2721
2747
|
dofEnd = Math.max(dofStart + 0.05, Math.min(1.5, f));
|
|
2748
|
+
} else if (gzDrag.h === 'end2'){
|
|
2749
|
+
dofEnd2 = Math.max(-0.5, Math.min(dofStart - 0.05, f));
|
|
2722
2750
|
} else if (gzDrag.h === 'str'){
|
|
2723
2751
|
dofStr = Math.max(0.05, Math.min(1, (q - 0.08) / 0.84));
|
|
2724
2752
|
} else {
|
|
2725
|
-
// Turn about the
|
|
2726
|
-
|
|
2753
|
+
// Turn about the SHARP line's centre -- the plane of focus is what turns;
|
|
2754
|
+
// the pip under the pointer follows.
|
|
2755
|
+
const c = gzPt(g, dofStart, 0.5);
|
|
2727
2756
|
const v = [p[0]-c[0], p[1]-c[1]];
|
|
2728
2757
|
const sign = gzDrag.h === 'rotB' ? 1 : -1; // rotB sits at +u, rotA at -u
|
|
2729
2758
|
dofAng = ((Math.atan2(v[1]*sign, v[0]*sign) * 180 / Math.PI - 90) % 360 + 360) % 360;
|
|
@@ -3131,7 +3160,7 @@ async function playClip(){
|
|
|
3131
3160
|
// a preview the user did not ask for, and the wait goes silent again.
|
|
3132
3161
|
building = true;
|
|
3133
3162
|
try{
|
|
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',
|
|
3163
|
+
await api('/api/preview_video', {corners: st.corners, radius_frac: radiusValue(), smoothing: smoothingValue(), dof_angle: dofAngle(), dof_strength: dofStrength(), dof_start: dofStartV(), dof_end: dofEndV(), dof_end2: dofEnd2V(), dof_space: 'screen',
|
|
3135
3164
|
device: st.type, grade: gradeValue(),
|
|
3136
3165
|
reflection: emisValue(), fit_frame: +$('#vframe').value});
|
|
3137
3166
|
}catch(e){
|
|
@@ -3190,7 +3219,7 @@ async function renderPreview(){
|
|
|
3190
3219
|
const say = t => { if (!building && $('#liveWrap').hidden) s.textContent = t; };
|
|
3191
3220
|
say('Rendering…');
|
|
3192
3221
|
try{
|
|
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()});
|
|
3222
|
+
const r = await api('/api/preview', {corners: st.corners, radius_frac: radiusValue(), smoothing: smoothingValue(), dof_angle: dofAngle(), dof_strength: dofStrength(), dof_start: dofStartV(), dof_end: dofEndV(), dof_end2: dofEnd2V(), dof_space: 'screen', device: st.type, grade: gradeValue(), reflection: emisValue()});
|
|
3194
3223
|
const im = $('#outImg');
|
|
3195
3224
|
im.onload = () => {
|
|
3196
3225
|
outNat = im.naturalWidth;
|
|
@@ -3320,7 +3349,7 @@ async function renderVideo(){
|
|
|
3320
3349
|
b.disabled = true;
|
|
3321
3350
|
setRenderProgress(0);
|
|
3322
3351
|
try{
|
|
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,
|
|
3352
|
+
await api('/api/render', {corners: st.corners, radius_frac: radiusValue(), smoothing: smoothingValue(), dof_angle: dofAngle(), dof_strength: dofStrength(), dof_start: dofStartV(), dof_end: dofEndV(), dof_end2: dofEnd2V(), dof_space: 'screen', device: st.type,
|
|
3324
3353
|
grade: gradeValue(), reflection: emisValue(), preset, fit_frame: +$('#vframe').value});
|
|
3325
3354
|
}catch(e){
|
|
3326
3355
|
setRenderProgress(null);
|
|
@@ -3366,7 +3395,7 @@ $('#save').onclick = async () => {
|
|
|
3366
3395
|
if (st.video) return renderVideo();
|
|
3367
3396
|
const b = $('#save'); b.textContent = 'Saving…'; b.disabled = true;
|
|
3368
3397
|
try{
|
|
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()});
|
|
3398
|
+
const r = await api('/api/save', {corners: st.corners, radius_frac: radiusValue(), smoothing: smoothingValue(), dof_angle: dofAngle(), dof_strength: dofStrength(), dof_start: dofStartV(), dof_end: dofEndV(), dof_end2: dofEnd2V(), dof_space: 'screen', device: st.type, grade: gradeValue(), reflection: emisValue()});
|
|
3370
3399
|
b.textContent = saveLabel();
|
|
3371
3400
|
// The real destination, not a hardcoded one: --out-dir means saves usually
|
|
3372
3401
|
// land in the project folder now, and telling the user "~/Desktop" when
|