screengraft 0.51.0 → 0.52.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/scripts/dof.py +13 -7
- package/scripts/ui.py +20 -14
- package/scripts/warp.py +10 -6
- package/skills/inject-screenshot/SKILL.md +2 -2
- package/ui/index.html +134 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "screengraft",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.52.0",
|
|
4
4
|
"description": "Put a UI screenshot or screen recording onto a photographed device screen with the perspective exactly right \u2014 a homography you confirm by hand, not a generative guess.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"mockup",
|
package/scripts/dof.py
CHANGED
|
@@ -34,17 +34,23 @@ def sigma_max(corners, strength: float) -> float:
|
|
|
34
34
|
return float(np.clip(strength, 0.0, 1.0)) * DOF_MAX_FRAC * float(side)
|
|
35
35
|
|
|
36
36
|
|
|
37
|
-
def ramp(corners, angle_deg: float, x0: int, y0: int, w: int, h: int
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
37
|
+
def ramp(corners, angle_deg: float, x0: int, y0: int, w: int, h: int,
|
|
38
|
+
start: float = 0.0) -> np.ndarray:
|
|
39
|
+
"""Per-pixel 0..1 blur fraction along `angle` across the quad, over a window.
|
|
40
|
+
|
|
41
|
+
`start` (0..1) is where focus ends and the blur begins, as a fraction of
|
|
42
|
+
the quad's extent along the direction: 0 at the nearest extent (the whole
|
|
43
|
+
screen ramps, the default), 0.5 sharp across the near half then ramping.
|
|
44
|
+
1 at the farthest extent; pixels outside the quad clamp. The window is
|
|
45
|
+
(x0, y0, w, h) in photo pixels.
|
|
42
46
|
"""
|
|
43
47
|
a = math.radians(angle_deg)
|
|
44
48
|
d = np.array([math.cos(a), math.sin(a)], dtype=np.float64)
|
|
45
49
|
q = np.asarray(corners, dtype=np.float64)
|
|
46
50
|
proj = q @ d
|
|
47
51
|
lo, hi = float(proj.min()), float(proj.max())
|
|
52
|
+
start = float(np.clip(start, 0.0, 0.95))
|
|
53
|
+
lo = lo + start * (hi - lo)
|
|
48
54
|
if hi - lo < 1e-6:
|
|
49
55
|
return np.zeros((h, w), dtype=np.float32)
|
|
50
56
|
xs = np.arange(x0, x0 + w, dtype=np.float64)[None, :]
|
|
@@ -79,12 +85,12 @@ class Field:
|
|
|
79
85
|
blurred masks. `blur_layer` then costs `levels - 1` blurs of the colour."""
|
|
80
86
|
|
|
81
87
|
def __init__(self, corners, angle_deg: float, strength: float, mask: np.ndarray,
|
|
82
|
-
x0: int, y0: int):
|
|
88
|
+
x0: int, y0: int, start: float = 0.0):
|
|
83
89
|
h, w = mask.shape[:2]
|
|
84
90
|
self.x0, self.y0 = x0, y0
|
|
85
91
|
self.smax = sigma_max(corners, strength)
|
|
86
92
|
self.sigmas = [self.smax * k / (DOF_LEVELS - 1) for k in range(DOF_LEVELS)]
|
|
87
|
-
t = ramp(corners, angle_deg, x0, y0, w, h)
|
|
93
|
+
t = ramp(corners, angle_deg, x0, y0, w, h, start)
|
|
88
94
|
self.W = level_weights(t) # (L, h, w)
|
|
89
95
|
m = mask.astype(np.float32) / 255.0
|
|
90
96
|
self.masks = [_blur(m, s) for s in self.sigmas] # each (h, w)
|
package/scripts/ui.py
CHANGED
|
@@ -579,7 +579,7 @@ PREVIEW_SECONDS = 6.0
|
|
|
579
579
|
def _render_worker(photo, video_path, corners, dest, radius_px, gr, grain, preset, fit_frame,
|
|
580
580
|
blend="replace", reflection=None, result=None, kind="render",
|
|
581
581
|
start_frame=0, max_frames=None, *, smoothing=0.0,
|
|
582
|
-
dof_angle=0.0, dof_strength=0.0):
|
|
582
|
+
dof_angle=0.0, dof_strength=0.0, dof_start=0.0):
|
|
583
583
|
"""Encode the clip, and only if that SUCCEEDS publish what it produced.
|
|
584
584
|
|
|
585
585
|
`result` is the sidecar this render would write. It is handed to the worker
|
|
@@ -605,7 +605,8 @@ def _render_worker(photo, video_path, corners, dest, radius_px, gr, grain, prese
|
|
|
605
605
|
reflection=(W.DEFAULT_REFLECTION if reflection is None
|
|
606
606
|
else reflection),
|
|
607
607
|
start_frame=start_frame, max_frames=max_frames,
|
|
608
|
-
dof_angle=dof_angle, dof_strength=dof_strength
|
|
608
|
+
dof_angle=dof_angle, dof_strength=dof_strength,
|
|
609
|
+
dof_start=dof_start)
|
|
609
610
|
if kind == "preview":
|
|
610
611
|
# A preview publishes NOTHING. It is not a save: no sidecar, no fit
|
|
611
612
|
# file, and above all not the session output -- /api/import reads
|
|
@@ -668,15 +669,17 @@ def _blend_args(b):
|
|
|
668
669
|
|
|
669
670
|
|
|
670
671
|
def _dof_args(b):
|
|
671
|
-
"""(dof_angle, dof_strength) from the page. Absent or null
|
|
672
|
-
0 -- no field, and every path in Plan untouched -- so a
|
|
673
|
-
depth of field and a sidecar replayed through the CLI
|
|
672
|
+
"""(dof_angle, dof_strength, dof_start) from the page. Absent or null
|
|
673
|
+
strength means 0 -- no field, and every path in Plan untouched -- so a
|
|
674
|
+
client that predates depth of field and a sidecar replayed through the CLI
|
|
675
|
+
both reproduce. `start` absent means 0: the whole screen ramps."""
|
|
674
676
|
try:
|
|
675
677
|
strength = float(b.get("dof_strength") or 0.0)
|
|
676
678
|
angle = float(b.get("dof_angle") or 0.0)
|
|
679
|
+
start = float(b.get("dof_start") or 0.0)
|
|
677
680
|
except (TypeError, ValueError):
|
|
678
|
-
return 0.0, 0.0
|
|
679
|
-
return angle % 360.0, float(min(max(strength, 0.0), 1.0))
|
|
681
|
+
return 0.0, 0.0, 0.0
|
|
682
|
+
return angle % 360.0, float(min(max(strength, 0.0), 1.0)), float(min(max(start, 0.0), 0.95))
|
|
680
683
|
|
|
681
684
|
|
|
682
685
|
ROLES = ("photo", "screenshot")
|
|
@@ -1178,7 +1181,8 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1178
1181
|
fit_frame, max_frames),
|
|
1179
1182
|
kwargs={"smoothing": _smoothing(b),
|
|
1180
1183
|
"dof_angle": _dof_args(b)[0],
|
|
1181
|
-
"dof_strength": _dof_args(b)[1]
|
|
1184
|
+
"dof_strength": _dof_args(b)[1],
|
|
1185
|
+
"dof_start": _dof_args(b)[2]}).start()
|
|
1182
1186
|
except BaseException:
|
|
1183
1187
|
with RENDER_LOCK:
|
|
1184
1188
|
RENDER.update(state="error", message="could not start the preview")
|
|
@@ -1212,7 +1216,7 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1212
1216
|
gr = float(b.get("grade") if b.get("grade") is not None else 0.0)
|
|
1213
1217
|
grain = bool(b.get("grain", gr > 0))
|
|
1214
1218
|
blend, reflection = _blend_args(b)
|
|
1215
|
-
dof_angle, dof_strength = _dof_args(b)
|
|
1219
|
+
dof_angle, dof_strength, dof_start = _dof_args(b)
|
|
1216
1220
|
preset = "prores" if b.get("preset") == "prores" else "web"
|
|
1217
1221
|
ext = ".mov" if preset == "prores" else ".mp4"
|
|
1218
1222
|
os.makedirs(OUT_DIR, exist_ok=True)
|
|
@@ -1234,7 +1238,7 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1234
1238
|
"grade": gr, "grain": grain,
|
|
1235
1239
|
"video": True, "preset": preset, "fit_frame": fit_frame,
|
|
1236
1240
|
"blend": blend, "reflection": reflection,
|
|
1237
|
-
"dof_angle": dof_angle, "dof_strength": dof_strength,
|
|
1241
|
+
"dof_angle": dof_angle, "dof_strength": dof_strength, "dof_start": dof_start,
|
|
1238
1242
|
# A render is always the whole clip; only the preview
|
|
1239
1243
|
# passes a segment. Recorded anyway, because the
|
|
1240
1244
|
# sidecar's promise is EVERY argument that changes the
|
|
@@ -1263,7 +1267,8 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1263
1267
|
blend, reflection, result),
|
|
1264
1268
|
kwargs={"smoothing": _smoothing(b),
|
|
1265
1269
|
"dof_angle": dof_angle,
|
|
1266
|
-
"dof_strength": dof_strength
|
|
1270
|
+
"dof_strength": dof_strength,
|
|
1271
|
+
"dof_start": dof_start}).start()
|
|
1267
1272
|
except BaseException:
|
|
1268
1273
|
# If the thread cannot even be created, the flag must not
|
|
1269
1274
|
# outlive the request.
|
|
@@ -1286,12 +1291,13 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1286
1291
|
gr = float(b.get("grade") if b.get("grade") is not None else 0.0)
|
|
1287
1292
|
blend, reflection = _blend_args(b)
|
|
1288
1293
|
smoothing = _smoothing(b)
|
|
1289
|
-
dof_angle, dof_strength = _dof_args(b)
|
|
1294
|
+
dof_angle, dof_strength, dof_start = _dof_args(b)
|
|
1290
1295
|
out = W.compose(photo, shot, corners, radius_px,
|
|
1291
1296
|
corner_smoothing=smoothing,
|
|
1292
1297
|
grade=gr, grain=bool(b.get("grain", gr > 0)),
|
|
1293
1298
|
blend=blend, reflection=reflection,
|
|
1294
|
-
dof_angle=dof_angle, dof_strength=dof_strength
|
|
1299
|
+
dof_angle=dof_angle, dof_strength=dof_strength,
|
|
1300
|
+
dof_start=dof_start)
|
|
1295
1301
|
SESSION.update(corners=corners, radius_frac=frac, device=b.get("device"),
|
|
1296
1302
|
grade=gr)
|
|
1297
1303
|
if u.path == "/api/preview":
|
|
@@ -1327,7 +1333,7 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1327
1333
|
"corner_smoothing": smoothing,
|
|
1328
1334
|
"grade": gr, "grain": bool(b.get("grain", gr > 0)),
|
|
1329
1335
|
"blend": blend, "reflection": reflection,
|
|
1330
|
-
"dof_angle": dof_angle, "dof_strength": dof_strength,
|
|
1336
|
+
"dof_angle": dof_angle, "dof_strength": dof_strength, "dof_start": dof_start,
|
|
1331
1337
|
"saved": time.time()}
|
|
1332
1338
|
# A fit is remembered when it PRODUCED something, not while it
|
|
1333
1339
|
# is being dragged: a quad on the canvas is a work in progress,
|
package/scripts/warp.py
CHANGED
|
@@ -269,7 +269,7 @@ class Plan:
|
|
|
269
269
|
corner_radius: float = 0.0, grain: bool = False,
|
|
270
270
|
blend: str = "replace", reflection: float = DEFAULT_REFLECTION,
|
|
271
271
|
corner_smoothing: float = 0.0,
|
|
272
|
-
dof_angle: float = 0.0, dof_strength: float = 0.0):
|
|
272
|
+
dof_angle: float = 0.0, dof_strength: float = 0.0, dof_start: float = 0.0):
|
|
273
273
|
dst_quad = np.array(corners, dtype=np.float32)
|
|
274
274
|
if shoelace_area(dst_quad) < 1.0:
|
|
275
275
|
raise ValueError("degenerate quad (near-zero area) — check corner order TL,TR,BR,BL")
|
|
@@ -320,6 +320,7 @@ class Plan:
|
|
|
320
320
|
# exactly where the blur was meant to remove one.
|
|
321
321
|
self.dof_strength = float(np.clip(dof_strength, 0.0, 1.0))
|
|
322
322
|
self.dof_angle = float(dof_angle)
|
|
323
|
+
self.dof_start = float(np.clip(dof_start, 0.0, 0.95))
|
|
323
324
|
reach = int(np.ceil(3 * _dof.sigma_max(dst_quad, self.dof_strength))) if self.dof_strength > 0 else 0
|
|
324
325
|
bx0, by0 = max(0, int(np.floor(xs.min())) - 1 - reach), max(0, int(np.floor(ys.min())) - 1 - reach)
|
|
325
326
|
bx1, by1 = min(pw, int(np.ceil(xs.max())) + 2 + reach), min(ph, int(np.ceil(ys.max())) + 2 + reach)
|
|
@@ -330,7 +331,8 @@ class Plan:
|
|
|
330
331
|
self.dof = None
|
|
331
332
|
if self.dof_strength > 0 and self.bbox is not None:
|
|
332
333
|
self.dof = _dof.Field(dst_quad, self.dof_angle, self.dof_strength,
|
|
333
|
-
self.warped_mask[by0:by1, bx0:bx1], bx0, by0
|
|
334
|
+
self.warped_mask[by0:by1, bx0:bx1], bx0, by0,
|
|
335
|
+
start=self.dof_start)
|
|
334
336
|
|
|
335
337
|
def _prep(self, frame: np.ndarray, bbox=None) -> np.ndarray:
|
|
336
338
|
"""Warp one frame. With `bbox`, warp only that window of the canvas.
|
|
@@ -434,7 +436,8 @@ def compose(photo: np.ndarray, screenshot: np.ndarray, corners, corner_radius: f
|
|
|
434
436
|
grade: float = 0.0, grain: bool = False, screen_off: np.ndarray = None,
|
|
435
437
|
specular: float = 0.75, blend: str = "replace",
|
|
436
438
|
reflection: float = DEFAULT_REFLECTION,
|
|
437
|
-
dof_angle: float = 0.0, dof_strength: float = 0.0
|
|
439
|
+
dof_angle: float = 0.0, dof_strength: float = 0.0,
|
|
440
|
+
dof_start: float = 0.0) -> np.ndarray:
|
|
438
441
|
"""Warp `screenshot` into the quad `corners` (TL,TR,BR,BL, photo pixels) on `photo`.
|
|
439
442
|
|
|
440
443
|
Single resampling pass at the photo's resolution; deterministic. This is the
|
|
@@ -448,7 +451,7 @@ def compose(photo: np.ndarray, screenshot: np.ndarray, corners, corner_radius: f
|
|
|
448
451
|
plan = Plan(photo, screenshot.shape, corners, corner_radius, grain=grain,
|
|
449
452
|
corner_smoothing=corner_smoothing,
|
|
450
453
|
blend=blend, reflection=reflection,
|
|
451
|
-
dof_angle=dof_angle, dof_strength=dof_strength)
|
|
454
|
+
dof_angle=dof_angle, dof_strength=dof_strength, dof_start=dof_start)
|
|
452
455
|
plan.bind_grade(screenshot, grade)
|
|
453
456
|
return plan.render(screenshot, screen_off=screen_off, specular=specular)
|
|
454
457
|
|
|
@@ -536,7 +539,8 @@ def compose_video(photo: np.ndarray, video_path: str, corners, output: str,
|
|
|
536
539
|
frames_dir: str = None, progress=None, blend: str = "replace",
|
|
537
540
|
reflection: float = DEFAULT_REFLECTION,
|
|
538
541
|
start_frame: int = 0, max_frames: int = None,
|
|
539
|
-
dof_angle: float = 0.0, dof_strength: float = 0.0
|
|
542
|
+
dof_angle: float = 0.0, dof_strength: float = 0.0,
|
|
543
|
+
dof_start: float = 0.0) -> dict:
|
|
540
544
|
"""Inject a VIDEO into a still photo. The photo does not move, so there is
|
|
541
545
|
exactly one homography and the whole of Plan is computed once.
|
|
542
546
|
|
|
@@ -564,7 +568,7 @@ def compose_video(photo: np.ndarray, video_path: str, corners, output: str,
|
|
|
564
568
|
plan = Plan(photo, first.shape, corners, corner_radius, grain=grain,
|
|
565
569
|
corner_smoothing=corner_smoothing,
|
|
566
570
|
blend=blend, reflection=reflection,
|
|
567
|
-
dof_angle=dof_angle, dof_strength=dof_strength)
|
|
571
|
+
dof_angle=dof_angle, dof_strength=dof_strength, dof_start=dof_start)
|
|
568
572
|
plan.bind_grade(first, grade)
|
|
569
573
|
|
|
570
574
|
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.52):** a local browser UI (`scripts/ui.py`) that walks the designer through the whole job — pick the photo and the screen source, which may be an image **or a video** (recent Desktop/Downloads images, drag-drop, browse, path, or a **Figma frame link**), auto-detect the screen as a starting position — and when detection cannot tell which region is a screen, **Point at screen**: one click inside it and the detector uses that point — or, if this photograph has been fitted before, **the fit it was saved with comes back** as the starting position instead of a detection, recognised by the photo's own pixels so a rename or a drag-drop still match — and every save also writes a **portable `.fit.json` beside the mockup** that can be dropped back onto the page later, which is how a fit survives a re-export, another machine, or someone else's hands — then **match the four edges** (drag an edge's middle to slide it, near an end to pivot; corners still draggable) with canvas navigation that follows the usual conventions — **hold ⌘ and scroll to zoom to the pointer, hold space and drag to pan** — and a rectified strip loupe. The fit and the composite sit **side by side and always have** — the result pane re-renders as you drag, which is how a corner gets judged, so it is the layout rather than a mode you can switch off. Then an on-by-default realism pass that colour-matches the source to the photo's light, **Save** (or **Render**, for a video) into the project folder (`--out-dir`), and a **Send to Claude** button that reaches you through the plugin's own MCP server. The UI is a hand port of the project's Figma design file — dark only.
|
|
9
9
|
|
|
10
10
|
The geometry is exact (`warp.py`); the detection is advisory (`detect.py`) and the human corrects it. **When a detection is wrong and you want to know why**, ask for the candidate list: `POST /api/detect {"trace": true}` writes `<session>/candidates.json`, or run `python3 scripts/detect.py --photo P --out-corners /tmp/c.json --trace /tmp/t.json` (add `--click X,Y`). Every candidate quad is in there with its score and whether it was accepted, rejected, never reached, or filtered out by the click — which is what separates "the screen was never proposed" from "it was proposed and something else won".
|
|
11
11
|
|
|
@@ -13,7 +13,7 @@ The geometry is exact (`warp.py`); the detection is advisory (`detect.py`) and t
|
|
|
13
13
|
|
|
14
14
|
**The realism pass ships and is ON by default** (`grade.py`): it matches the injected screen's white balance and grain to the light around it, at a strength the designer sets in the rail. It can also lift the device's real specular highlights from a screen-off reference frame, though the UI cannot supply one yet. Off is a first-class choice and keeps the screenshot's colour exactly — say so if the user is reviewing brand colour.
|
|
15
15
|
|
|
16
|
-
**Depth of field** *(off by default)*: a phone shot at an angle is a plane receding from the camera, so its far end is softer than its near end, and a screenshot pasted pin-sharp across all of it gives the fake away. On, the screenshot blurs across the screen in one direction — **direction** (where the blur grows toward) and **strength** — and the glass edge softens with it. **Measure from photo** reads both off the photograph's own screen boundary; on a real photograph that works, on a *mockup template* the device is usually rendered sharp with the blur only on the background, so it answers "flat" and the designer sets it by eye. The measured strength is a floor (the estimator saturates around 5px of blur), never a ceiling. Suggest it when the photo has visible bokeh — a blurred hand, table edge or background — and the composite's screen looks pasted on. The live in-place playback cannot show it; the composite and Render preview do.
|
|
16
|
+
**Depth of field** *(off by default)*: a phone shot at an angle is a plane receding from the camera, so its far end is softer than its near end, and a screenshot pasted pin-sharp across all of it gives the fake away. On, the screenshot blurs across the screen in one direction — **direction** (where the blur grows toward) and **strength** — and the glass edge softens with it. **Measure from photo** reads both off the photograph's own screen boundary; on a real photograph that works, on a *mockup template* the device is usually rendered sharp with the blur only on the background, so it answers "flat" and the designer sets it by eye. The measured strength is a floor (the estimator saturates around 5px of blur), never a ceiling. **The gizmo on the result** is the primary control: a solid line over the screen where focus ends (drag to move, its end pips to turn), a dashed line where the blur reaches ~6px (drag closer for stronger, further for gentler); it fades when the pointer leaves the pane. The sliders and the gizmo are one model. Suggest it when the photo has visible bokeh — a blurred hand, table edge or background — and the composite's screen looks pasted on. The live in-place playback cannot show it; the composite and Render preview do.
|
|
17
17
|
|
|
18
18
|
**Video ships too.** The screen source can be a video (mp4/mov/webm) as well as a still — pick it exactly like a screenshot, choose which frame to match the edges on, **press Play and the clip runs on the photo immediately** — the browser warps it onto the same four corners with the same corner radius and approximates the emissive blend, so placement and motion can be judged with no wait; **Render preview** composites a few seconds through the real pipeline when the grade, grain and true blend are what you need to see — and the primary button becomes **Render**. The photo does not move, so there is one homography and every frame gets the same geometry; the light match is measured once from the frame you fitted on, so the screen cannot pulse as the UI scrolls. Output is H.264 at CRF 16 (near-visually-lossless) or ProRes 422 HQ. This is what pairs with a prototype recording: record the prototype, then inject the recording into a real photograph.
|
|
19
19
|
|
package/ui/index.html
CHANGED
|
@@ -625,6 +625,22 @@
|
|
|
625
625
|
|
|
626
626
|
#outPane{display:grid}
|
|
627
627
|
#outWrap{overflow:auto;background:var(--well);display:grid;box-shadow:inset 0 1px 3px rgba(0,0,0,.5)}
|
|
628
|
+
/* The depth-of-field gizmo: an SVG laid exactly over the composite, drawn
|
|
629
|
+
in PHOTO pixels (its viewBox is the photo) so every coordinate is the
|
|
630
|
+
engine's own. Faint until the pointer is over the pane, so it never sits
|
|
631
|
+
on the composite being judged; solid while a handle is being dragged. */
|
|
632
|
+
#outWrap{position:relative}
|
|
633
|
+
#dofGizmo{position:absolute;pointer-events:none;opacity:.4;transition:opacity .15s;overflow:visible}
|
|
634
|
+
#outWrap:hover #dofGizmo,#dofGizmo.drag{opacity:1}
|
|
635
|
+
#dofGizmo .case{stroke:rgba(0,0,0,.6);fill:none;vector-effect:non-scaling-stroke}
|
|
636
|
+
#dofGizmo .focus{stroke:#f5623d;stroke-width:1.5;fill:none;vector-effect:non-scaling-stroke}
|
|
637
|
+
#dofGizmo .far{stroke:#f5623d;stroke-width:1.25;stroke-dasharray:6 6;fill:none;vector-effect:non-scaling-stroke}
|
|
638
|
+
#dofGizmo .far.out{stroke-dasharray:3 7;opacity:.75}
|
|
639
|
+
#dofGizmo .h{fill:#f5623d;stroke:#fff;stroke-width:1.5;vector-effect:non-scaling-stroke;pointer-events:auto;cursor:grab}
|
|
640
|
+
#dofGizmo .h.hollow{fill:#1a1a1a}
|
|
641
|
+
#dofGizmo .h.pip{fill:#f5623d}
|
|
642
|
+
#dofGizmo .h:active{cursor:grabbing}
|
|
643
|
+
#dofGizmo text{font:600 11px system-ui,-apple-system,sans-serif;fill:#fff;paint-order:stroke;stroke:rgba(0,0,0,.75);stroke-width:3px;stroke-linejoin:round;pointer-events:none}
|
|
628
644
|
#outImg,#outVid{display:block;margin:auto}
|
|
629
645
|
/* The live layer stacks the clip on the photo. `position:relative` on the
|
|
630
646
|
wrapper and `transform-origin:0 0` on the video are what make the matrix3d
|
|
@@ -1023,7 +1039,7 @@
|
|
|
1023
1039
|
<span class="spacer"></span>
|
|
1024
1040
|
<span class="sm" style="color:var(--mute)">shares zoom & pan</span>
|
|
1025
1041
|
</div>
|
|
1026
|
-
<div id="outWrap"><span class="empty" id="outEmpty">The composite appears here and updates as you drag.</span><img id="outImg" alt="" hidden><video id="outVid" hidden loop muted playsinline controls></video><!--
|
|
1042
|
+
<div id="outWrap"><span class="empty" id="outEmpty">The composite appears here and updates as you drag.</span><img id="outImg" alt="" hidden><video id="outVid" hidden loop muted playsinline controls></video><svg id="dofGizmo" hidden aria-label="Depth of field: drag the solid line to place focus, its ends to turn it, the dashed line for strength"></svg><!--
|
|
1027
1043
|
The live layer: the PHOTOGRAPH with the source clip warped onto the
|
|
1028
1044
|
fitted quad by the browser. Not the composite — the composite already
|
|
1029
1045
|
has a frame burned into it, so playing over that would show two screens.
|
|
@@ -2541,8 +2557,10 @@ $('#emisAmt').onchange = e => { setEmis(true, parseFloat(e.target.value)); autoP
|
|
|
2541
2557
|
let dofOn = recall('dof','0') === '1';
|
|
2542
2558
|
let dofAng = parseFloat(recall('dofAng','90')) || 0;
|
|
2543
2559
|
let dofStr = parseFloat(recall('dofStr','0.3')); if (!(dofStr >= 0)) dofStr = 0.3;
|
|
2560
|
+
let dofStart = parseFloat(recall('dofStart','0')); if (!(dofStart >= 0)) dofStart = 0;
|
|
2544
2561
|
function dofAngle(){ return dofOn ? dofAng : 0; }
|
|
2545
2562
|
function dofStrength(){ return dofOn ? dofStr : 0; }
|
|
2563
|
+
function dofStartV(){ return dofOn ? dofStart : 0; }
|
|
2546
2564
|
function dofWord(a){
|
|
2547
2565
|
const names = ['the right','the bottom-right','the bottom','the bottom-left','the left','the top-left','the top','the top-right'];
|
|
2548
2566
|
return names[Math.round(((a % 360) + 360) % 360 / 45) % 8];
|
|
@@ -2558,9 +2576,10 @@ function setDof(on, ang, str){
|
|
|
2558
2576
|
dofOn = on;
|
|
2559
2577
|
if (ang !== undefined) dofAng = ((ang % 360) + 360) % 360;
|
|
2560
2578
|
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));
|
|
2579
|
+
remember('dof', on ? '1' : '0'); remember('dofAng', String(dofAng)); remember('dofStr', String(dofStr)); remember('dofStart', String(dofStart));
|
|
2562
2580
|
setSectionOpen($('#secDof'), on);
|
|
2563
2581
|
paintDof();
|
|
2582
|
+
paintGizmo();
|
|
2564
2583
|
}
|
|
2565
2584
|
let dofMeasuredFor = null; // photo path the last measurement was for
|
|
2566
2585
|
async function measureDof(){
|
|
@@ -2576,7 +2595,7 @@ async function measureDof(){
|
|
|
2576
2595
|
autoPreview(); // still on, at the slider's own numbers
|
|
2577
2596
|
return;
|
|
2578
2597
|
}
|
|
2579
|
-
setDof(true, r.angle, Math.max(r.strength, 0.05));
|
|
2598
|
+
dofStart = 0; setDof(true, r.angle, Math.max(r.strength, 0.05));
|
|
2580
2599
|
s.className = 'status sm ok';
|
|
2581
2600
|
s.textContent = `Measured: grows toward ${dofWord(r.angle)}. Strength is a floor — raise it if the far end looks softer.`;
|
|
2582
2601
|
s.title = `Edge blur ${r.sigma.map(v => v.toFixed(1)).join(' / ')}px, spread ${r.spread}×`;
|
|
@@ -2595,6 +2614,112 @@ $('#dofStr').oninput = e => { dofStr = parseFloat(e.target.value); paintDof();
|
|
|
2595
2614
|
$('#dofStr').onchange = e => { setDof(true, undefined, parseFloat(e.target.value)); autoPreview(); };
|
|
2596
2615
|
$('#dofMeasure').onclick = measureDof;
|
|
2597
2616
|
|
|
2617
|
+
/* ===== the depth-of-field gizmo ============================================
|
|
2618
|
+
Two lines over the composite, the graduated-filter idiom (Lightroom's
|
|
2619
|
+
gradient, Photoshop's tilt-shift). The SOLID line is where focus ends and
|
|
2620
|
+
the blur begins; the DASHED line is where the blur reaches a REFERENCE
|
|
2621
|
+
softness, GIZMO_REF of the engine's full blur (sigma_full = DOF_MAX_FRAC of
|
|
2622
|
+
the screen's longer side) -- 30%, about 6px on a phone, the point where
|
|
2623
|
+
text stops being readable. Not full blur: at 50% strength the full-blur
|
|
2624
|
+
line sat three quarters of a screen above the phone, off the photograph
|
|
2625
|
+
and out of reach. Drag the solid line's centre to move focus, either end
|
|
2626
|
+
pip to turn both lines (direction), the dashed line's centre to set how
|
|
2627
|
+
fast the blur grows (strength). The dashed line may still sit past the far
|
|
2628
|
+
edge for a gentle falloff, drawn out over the photograph and capped at
|
|
2629
|
+
GIZMO_FAR_MAX extents; gentler than that is the slider's job.
|
|
2630
|
+
Mapping, so the gizmo and the sliders are one model:
|
|
2631
|
+
start = solid line's position, 0..0.95 of the quad's extent along the direction
|
|
2632
|
+
strength = GIZMO_REF * (1 - start) / (far - start) -- far = dashed line's position
|
|
2633
|
+
Everything is drawn in PHOTO pixels: the SVG's viewBox is the photo, so no
|
|
2634
|
+
coordinate here is converted, and it stays aligned at every zoom for free. */
|
|
2635
|
+
const gz = $('#dofGizmo');
|
|
2636
|
+
const SIGMA_FULL_FRAC = 0.02; // mirrors dof.DOF_MAX_FRAC -- keep in step
|
|
2637
|
+
const GIZMO_REF = 0.3; // the dashed line marks this fraction of full blur
|
|
2638
|
+
const GIZMO_FAR_MAX = 1.6; // the dashed line is drawn no further than this many extents
|
|
2639
|
+
function gizmoGeom(){
|
|
2640
|
+
const C = st.corners, a = dofAng * Math.PI / 180;
|
|
2641
|
+
const d = [Math.cos(a), Math.sin(a)], u = [-d[1], d[0]];
|
|
2642
|
+
const pd = C.map(c => c[0]*d[0] + c[1]*d[1]), pu = C.map(c => c[0]*u[0] + c[1]*u[1]);
|
|
2643
|
+
const lo = Math.min(...pd), hi = Math.max(...pd), ulo = Math.min(...pu), uhi = Math.max(...pu);
|
|
2644
|
+
const side = Math.max(vlen(sub(C[1],C[0])), vlen(sub(C[2],C[1])), vlen(sub(C[3],C[2])), vlen(sub(C[0],C[3])));
|
|
2645
|
+
return {d, u, lo, hi, ulo, uhi, side};
|
|
2646
|
+
}
|
|
2647
|
+
// a line at fraction f along d, spanning the quad's extent along u (padded)
|
|
2648
|
+
function gizmoLine(g, f, pad){
|
|
2649
|
+
const c = g.lo + f * (g.hi - g.lo);
|
|
2650
|
+
const p0 = [c*g.d[0] + (g.ulo-pad)*g.u[0], c*g.d[1] + (g.ulo-pad)*g.u[1]];
|
|
2651
|
+
const p1 = [c*g.d[0] + (g.uhi+pad)*g.u[0], c*g.d[1] + (g.uhi+pad)*g.u[1]];
|
|
2652
|
+
return [p0, p1, [(p0[0]+p1[0])/2, (p0[1]+p1[1])/2]];
|
|
2653
|
+
}
|
|
2654
|
+
function paintGizmo(){
|
|
2655
|
+
const im = $('#outImg');
|
|
2656
|
+
const show = dofOn && st.corners && !im.hidden && im.naturalWidth && $('#liveWrap').hidden;
|
|
2657
|
+
// An SVG element has no `.hidden` property (that is HTMLElement's), so
|
|
2658
|
+
// assigning it makes a JS expando and leaves the attribute where it was.
|
|
2659
|
+
gz.toggleAttribute('hidden', !show);
|
|
2660
|
+
if (!show) return;
|
|
2661
|
+
const W = im.naturalWidth, H = im.naturalHeight;
|
|
2662
|
+
gz.setAttribute('viewBox', `0 0 ${W} ${H}`);
|
|
2663
|
+
gz.style.left = im.offsetLeft + 'px'; gz.style.top = im.offsetTop + 'px';
|
|
2664
|
+
gz.style.width = im.clientWidth + 'px'; gz.style.height = im.clientHeight + 'px';
|
|
2665
|
+
const k = W / Math.max(im.clientWidth, 1); // photo px per screen px
|
|
2666
|
+
const g = gizmoGeom();
|
|
2667
|
+
const fs = dofStart, fe = Math.min(GIZMO_FAR_MAX, fs + GIZMO_REF * (1 - fs) / Math.max(dofStr, 0.05));
|
|
2668
|
+
const pinned = fs + GIZMO_REF * (1 - fs) / Math.max(dofStr, 0.05) > GIZMO_FAR_MAX;
|
|
2669
|
+
const [a0, a1, ac] = gizmoLine(g, fs, 12 * k);
|
|
2670
|
+
const [b0, b1, bc] = gizmoLine(g, fe, 12 * k);
|
|
2671
|
+
const sigmaFull = SIGMA_FULL_FRAC * g.side;
|
|
2672
|
+
const r = 6 * k, rp = 4 * k, fs11 = 11 * k;
|
|
2673
|
+
const off = 16 * k; // label offset along d
|
|
2674
|
+
const L = (x, y, cls, extra='') => `<line x1="${x[0]}" y1="${x[1]}" x2="${y[0]}" y2="${y[1]}" class="${cls}" ${extra}/>`;
|
|
2675
|
+
const T = (p, txt, dy) => `<text x="${p[0]}" y="${p[1] + dy}" text-anchor="middle" font-size="${fs11}">${txt}</text>`;
|
|
2676
|
+
const beyond = fe > 1.0001;
|
|
2677
|
+
gz.innerHTML =
|
|
2678
|
+
L(a0, a1, 'case', 'stroke-width="4"') + L(a0, a1, 'focus') +
|
|
2679
|
+
L(b0, b1, 'case', 'stroke-width="3.5"') + L(b0, b1, 'far' + (beyond ? ' out' : '')) +
|
|
2680
|
+
`<circle cx="${a0[0]}" cy="${a0[1]}" r="${rp}" class="h pip" data-h="rotA"/>` +
|
|
2681
|
+
`<circle cx="${a1[0]}" cy="${a1[1]}" r="${rp}" class="h pip" data-h="rotB"/>` +
|
|
2682
|
+
`<circle cx="${ac[0]}" cy="${ac[1]}" r="${r}" class="h" data-h="start"/>` +
|
|
2683
|
+
`<circle cx="${bc[0]}" cy="${bc[1]}" r="${r}" class="h hollow" data-h="far"/>` +
|
|
2684
|
+
T([ac[0] - g.d[0]*off, ac[1] - g.d[1]*off], 'sharp', 4*k) +
|
|
2685
|
+
T([bc[0] + g.d[0]*off, bc[1] + g.d[1]*off], pinned ? `gentler than this: use the slider` : `σ ${(GIZMO_REF * sigmaFull).toFixed(0)}px here${beyond ? ' · past the screen' : ''}`, 4*k);
|
|
2686
|
+
}
|
|
2687
|
+
// pointer position in PHOTO pixels
|
|
2688
|
+
function gizmoPos(e){
|
|
2689
|
+
const rc = gz.getBoundingClientRect();
|
|
2690
|
+
const W = $('#outImg').naturalWidth, H = $('#outImg').naturalHeight;
|
|
2691
|
+
return [(e.clientX - rc.left) * W / rc.width, (e.clientY - rc.top) * H / rc.height];
|
|
2692
|
+
}
|
|
2693
|
+
let gzDrag = null;
|
|
2694
|
+
gz.addEventListener('pointerdown', e => {
|
|
2695
|
+
const h = e.target.dataset && e.target.dataset.h; if (!h) return;
|
|
2696
|
+
e.preventDefault(); gz.setPointerCapture(e.pointerId);
|
|
2697
|
+
gzDrag = {h}; gz.classList.add('drag');
|
|
2698
|
+
});
|
|
2699
|
+
gz.addEventListener('pointermove', e => {
|
|
2700
|
+
if (!gzDrag) return;
|
|
2701
|
+
const p = gizmoPos(e), g = gizmoGeom();
|
|
2702
|
+
const f = (p[0]*g.d[0] + p[1]*g.d[1] - g.lo) / (g.hi - g.lo);
|
|
2703
|
+
if (gzDrag.h === 'start'){
|
|
2704
|
+
dofStart = Math.max(0, Math.min(0.95, f));
|
|
2705
|
+
} else if (gzDrag.h === 'far'){
|
|
2706
|
+
// strength from the spacing; the far line never comes inside the screen
|
|
2707
|
+
// (strength is capped at 1) and never so far that the blur vanishes.
|
|
2708
|
+
const fe = Math.min(GIZMO_FAR_MAX, Math.max(f, dofStart + 0.05));
|
|
2709
|
+
dofStr = Math.max(0.05, Math.min(1, GIZMO_REF * (1 - dofStart) / Math.max(fe - dofStart, 1e-3)));
|
|
2710
|
+
} else {
|
|
2711
|
+
// Turn about the focus line's centre: the pip under the pointer follows it.
|
|
2712
|
+
const [, , ac] = gizmoLine(g, dofStart, 0);
|
|
2713
|
+
const v = [p[0]-ac[0], p[1]-ac[1]];
|
|
2714
|
+
const sign = gzDrag.h === 'rotB' ? 1 : -1; // rotB sits at +u, rotA at -u
|
|
2715
|
+
dofAng = ((Math.atan2(v[1]*sign, v[0]*sign) * 180 / Math.PI - 90) % 360 + 360) % 360;
|
|
2716
|
+
if (e.shiftKey) dofAng = Math.round(dofAng / 15) * 15;
|
|
2717
|
+
}
|
|
2718
|
+
paintDof(); paintGizmo(); autoPreview();
|
|
2719
|
+
});
|
|
2720
|
+
const gzUp = e => { if (!gzDrag) return; gzDrag = null; gz.classList.remove('drag'); setDof(true); autoPreview(); };
|
|
2721
|
+
gz.addEventListener('pointerup', gzUp); gz.addEventListener('pointercancel', gzUp);
|
|
2722
|
+
|
|
2598
2723
|
$('#edgeBtn').onclick = () => setLoupeMode(loupeMode === 'float' ? 'dock' : 'float');
|
|
2599
2724
|
|
|
2600
2725
|
/* Contrast for the strip only. A dark screen on a dark frame puts the
|
|
@@ -2800,6 +2925,7 @@ function syncOut(){
|
|
|
2800
2925
|
const a = $('#scroller'), b = $('#outWrap');
|
|
2801
2926
|
b.scrollLeft = a.scrollLeft; b.scrollTop = a.scrollTop;
|
|
2802
2927
|
}
|
|
2928
|
+
paintGizmo();
|
|
2803
2929
|
}
|
|
2804
2930
|
$('#scroller').addEventListener('scroll', () => {
|
|
2805
2931
|
syncOut();
|
|
@@ -2822,6 +2948,7 @@ function autoPreview(){
|
|
|
2822
2948
|
it. */
|
|
2823
2949
|
function showResult(what){
|
|
2824
2950
|
$('#outImg').hidden = what !== 'still';
|
|
2951
|
+
if (what !== 'still') $('#dofGizmo').setAttribute('hidden', '');
|
|
2825
2952
|
const v = $('#outVid');
|
|
2826
2953
|
if (what !== 'clip' && !v.hidden){ try { v.pause(); } catch(e){} }
|
|
2827
2954
|
v.hidden = what !== 'clip';
|
|
@@ -2990,7 +3117,7 @@ async function playClip(){
|
|
|
2990
3117
|
// a preview the user did not ask for, and the wait goes silent again.
|
|
2991
3118
|
building = true;
|
|
2992
3119
|
try{
|
|
2993
|
-
await api('/api/preview_video', {corners: st.corners, radius_frac: radiusValue(), smoothing: smoothingValue(), dof_angle: dofAngle(), dof_strength: dofStrength(),
|
|
3120
|
+
await api('/api/preview_video', {corners: st.corners, radius_frac: radiusValue(), smoothing: smoothingValue(), dof_angle: dofAngle(), dof_strength: dofStrength(), dof_start: dofStartV(),
|
|
2994
3121
|
device: st.type, grade: gradeValue(),
|
|
2995
3122
|
reflection: emisValue(), fit_frame: +$('#vframe').value});
|
|
2996
3123
|
}catch(e){
|
|
@@ -3049,7 +3176,7 @@ async function renderPreview(){
|
|
|
3049
3176
|
const say = t => { if (!building && $('#liveWrap').hidden) s.textContent = t; };
|
|
3050
3177
|
say('Rendering…');
|
|
3051
3178
|
try{
|
|
3052
|
-
const r = await api('/api/preview', {corners: st.corners, radius_frac: radiusValue(), smoothing: smoothingValue(), dof_angle: dofAngle(), dof_strength: dofStrength(), device: st.type, grade: gradeValue(), reflection: emisValue()});
|
|
3179
|
+
const r = await api('/api/preview', {corners: st.corners, radius_frac: radiusValue(), smoothing: smoothingValue(), dof_angle: dofAngle(), dof_strength: dofStrength(), dof_start: dofStartV(), device: st.type, grade: gradeValue(), reflection: emisValue()});
|
|
3053
3180
|
const im = $('#outImg');
|
|
3054
3181
|
im.onload = () => {
|
|
3055
3182
|
outNat = im.naturalWidth;
|
|
@@ -3179,7 +3306,7 @@ async function renderVideo(){
|
|
|
3179
3306
|
b.disabled = true;
|
|
3180
3307
|
setRenderProgress(0);
|
|
3181
3308
|
try{
|
|
3182
|
-
await api('/api/render', {corners: st.corners, radius_frac: radiusValue(), smoothing: smoothingValue(), dof_angle: dofAngle(), dof_strength: dofStrength(), device: st.type,
|
|
3309
|
+
await api('/api/render', {corners: st.corners, radius_frac: radiusValue(), smoothing: smoothingValue(), dof_angle: dofAngle(), dof_strength: dofStrength(), dof_start: dofStartV(), device: st.type,
|
|
3183
3310
|
grade: gradeValue(), reflection: emisValue(), preset, fit_frame: +$('#vframe').value});
|
|
3184
3311
|
}catch(e){
|
|
3185
3312
|
setRenderProgress(null);
|
|
@@ -3225,7 +3352,7 @@ $('#save').onclick = async () => {
|
|
|
3225
3352
|
if (st.video) return renderVideo();
|
|
3226
3353
|
const b = $('#save'); b.textContent = 'Saving…'; b.disabled = true;
|
|
3227
3354
|
try{
|
|
3228
|
-
const r = await api('/api/save', {corners: st.corners, radius_frac: radiusValue(), smoothing: smoothingValue(), dof_angle: dofAngle(), dof_strength: dofStrength(), device: st.type, grade: gradeValue(), reflection: emisValue()});
|
|
3355
|
+
const r = await api('/api/save', {corners: st.corners, radius_frac: radiusValue(), smoothing: smoothingValue(), dof_angle: dofAngle(), dof_strength: dofStrength(), dof_start: dofStartV(), device: st.type, grade: gradeValue(), reflection: emisValue()});
|
|
3229
3356
|
b.textContent = saveLabel();
|
|
3230
3357
|
// The real destination, not a hardcoded one: --out-dir means saves usually
|
|
3231
3358
|
// land in the project folder now, and telling the user "~/Desktop" when
|