screengraft 0.25.2 → 0.38.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/scripts/warp.py CHANGED
@@ -28,6 +28,7 @@ screen as it appears in the photo — order matters, it defines the mapping).
28
28
 
29
29
  import argparse
30
30
  import json
31
+ import math
31
32
  import os
32
33
  import subprocess
33
34
  import sys
@@ -47,7 +48,114 @@ MASK_SS = 4 # destination-space supersampling for the screen's edge
47
48
  DEFAULT_REFLECTION = 0.35
48
49
 
49
50
 
50
- def rounded_mask(w: int, h: int, radius: float) -> np.ndarray:
51
+ # Apple's display corners are not circular arcs. They are a "squircle": the
52
+ # curvature ramps in continuously instead of jumping from zero to 1/r at the
53
+ # tangent point, so there is no visible seam where the straight edge ends. Figma
54
+ # exposes the same thing as "corner smoothing", 0-100%, and marks 60% as iOS.
55
+ #
56
+ # The construction below is Figma's, from their own write-up and MartinRGB's
57
+ # derivation of it: each corner is cubic - circular arc - cubic, and `smoothing`
58
+ # decides how much of the corner the two cubics take from the arc. At 0 the
59
+ # cubics collapse (a = b = c = d = 0, p = r) and it IS a circular arc again,
60
+ # which the tests assert rather than assume.
61
+ #
62
+ # https://www.figma.com/blog/desperately-seeking-squircles/
63
+ # https://github.com/MartinRGB/Figma_Squircles_Approximation
64
+ SMOOTH_SS = 8 # corner-only supersampling for the squircle rasteriser
65
+ SMOOTH_SS_BIG = 4 # ... dropped for a very large corner, to bound the buffer
66
+ IOS_SMOOTHING = 0.6 # what Figma labels "iOS" on its smoothing slider
67
+
68
+
69
+ def _corner_params(r: float, smoothing: float, budget: float):
70
+ """Figma's per-corner geometry. Returns (a, b, c, d, p, arc_section)."""
71
+ p = (1.0 + smoothing) * r
72
+ # Figma's own behaviour when the corner runs out of room: cap the smoothing
73
+ # rather than distort the curve. (figma-squircle calls the other choice
74
+ # `preserveSmoothing`; matching Figma matters more here than preserving it.)
75
+ smoothing = min(smoothing, budget / r - 1.0)
76
+ p = min(p, budget)
77
+ arc_measure = 90.0 * (1.0 - smoothing)
78
+ arc_section = math.sin(math.radians(arc_measure / 2.0)) * r * math.sqrt(2.0)
79
+ alpha = (90.0 - arc_measure) / 2.0
80
+ beta = 45.0 * smoothing
81
+ c = r * math.tan(math.radians(alpha / 2.0)) * math.cos(math.radians(beta))
82
+ d = c * math.tan(math.radians(beta))
83
+ b = (p - arc_section - c - d) / 3.0
84
+ return 2.0 * b, b, c, d, p, arc_section
85
+
86
+
87
+ def _bezier(p0, p1, p2, p3, n):
88
+ t = np.linspace(0.0, 1.0, n)[:, None]
89
+ return (((1 - t) ** 3) * p0 + 3 * ((1 - t) ** 2) * t * p1
90
+ + 3 * (1 - t) * (t ** 2) * p2 + (t ** 3) * p3)
91
+
92
+
93
+ def squircle_corner(r: float, smoothing: float, budget: float, n: int = 192):
94
+ """The top-left corner curve, from (0, p) to (p, 0), corner of the rect at 0,0.
95
+
96
+ All four corners are this one mirrored, which is also why only one is ever
97
+ rasterised.
98
+ """
99
+ a, b, c, d, p, arc = _corner_params(r, smoothing, budget)
100
+ p0 = np.array([0.0, p])
101
+ p3 = np.array([d, p - a - b - c])
102
+ p4 = np.array([d + arc, p - a - b - c - arc])
103
+ p5 = np.array([p, 0.0])
104
+ parts = [_bezier(p0, np.array([0.0, p - a]), np.array([0.0, p - a - b]), p3, n)]
105
+ if arc > 1e-9:
106
+ mid = (p3 + p4) / 2.0
107
+ half = float(np.linalg.norm(p4 - p3)) / 2.0
108
+ off = math.sqrt(max(r * r - half * half, 0.0))
109
+ nrm = np.array([-(p4 - p3)[1], (p4 - p3)[0]])
110
+ nrm = nrm / (float(np.linalg.norm(nrm)) or 1.0)
111
+ # Two centres solve the chord; the arc bulges INTO the corner, so the
112
+ # centre is the one further from it. Choosing by distance rather than by
113
+ # unpicking SVG's sweep flag is the same answer with less to get wrong.
114
+ centre = max([mid + nrm * off, mid - nrm * off],
115
+ key=lambda q: float(np.hypot(*q)))
116
+ a0 = math.atan2(*(p3 - centre)[::-1])
117
+ a1 = math.atan2(*(p4 - centre)[::-1])
118
+ while a1 - a0 > math.pi:
119
+ a1 -= 2 * math.pi
120
+ while a1 - a0 < -math.pi:
121
+ a1 += 2 * math.pi
122
+ th = np.linspace(a0, a1, n)
123
+ parts.append(np.stack([centre[0] + r * np.cos(th),
124
+ centre[1] + r * np.sin(th)], 1))
125
+ parts.append(_bezier(p4, p4 + np.array([c, -d]), p4 + np.array([b + c, -d]), p5, n))
126
+ return np.concatenate(parts), p
127
+
128
+
129
+ def _squircle_mask(w: int, h: int, r: float, smoothing: float) -> np.ndarray:
130
+ """Coverage mask for a rounded rectangle with Figma corner smoothing.
131
+
132
+ Rasterised rather than solved: a squircle has no closed-form distance field
133
+ to take a coverage ramp from, the way the circular case does. One corner is
134
+ filled at SMOOTH_SS x and INTER_AREA'd down -- which is measuring coverage,
135
+ the same thing the analytic path computes and the same thing the destination
136
+ warp already does -- then mirrored into the other three.
137
+ """
138
+ budget = min(w, h) / 2.0
139
+ curve, p = squircle_corner(r, smoothing, budget)
140
+ box = int(math.ceil(p))
141
+ ss = SMOOTH_SS if box <= 1024 else SMOOTH_SS_BIG
142
+ # Edge coords -> supersampled pixel centres: subpixel i covers [i/ss,(i+1)/ss)
143
+ # and fillPoly fills by centre, so the polygon shifts by half a subpixel.
144
+ poly = np.concatenate([curve, np.array([[box, 0.0], [box, box], [0.0, box]])])
145
+ big = np.zeros((box * ss, box * ss), dtype=np.uint8)
146
+ cv2.fillPoly(big, [np.rint(poly * ss - 0.5).astype(np.int32)], 255, cv2.LINE_8)
147
+ corner = cv2.resize(big, (box, box), interpolation=cv2.INTER_AREA)
148
+ m = np.full((h, w), 255, dtype=np.uint8)
149
+ bw, bh = min(box, w), min(box, h)
150
+ tl = corner[:bh, :bw]
151
+ m[:bh, :bw] = np.minimum(m[:bh, :bw], tl)
152
+ m[:bh, w - bw:] = np.minimum(m[:bh, w - bw:], tl[:, ::-1])
153
+ m[h - bh:, :bw] = np.minimum(m[h - bh:, :bw], tl[::-1, :])
154
+ m[h - bh:, w - bw:] = np.minimum(m[h - bh:, w - bw:], tl[::-1, ::-1])
155
+ return m
156
+
157
+
158
+ def rounded_mask(w: int, h: int, radius: float, smoothing: float = 0.0) -> np.ndarray:
51
159
  """White-on-black mask, full frame minus rounded corners cut to black.
52
160
 
53
161
  Computed analytically rather than drawn, for two reasons.
@@ -74,6 +182,11 @@ def rounded_mask(w: int, h: int, radius: float) -> np.ndarray:
74
182
  if radius <= 0:
75
183
  return np.full((h, w), 255, dtype=np.uint8)
76
184
  r = float(max(0.0, min(float(radius), w / 2.0, h / 2.0)))
185
+ # `smoothing` defaults to 0 so a sidecar written before smoothing existed
186
+ # re-composes to the same pixels it did then. That is the sidecar's whole
187
+ # contract and it outranks making old saves consistent with new ones.
188
+ if smoothing > 0.0:
189
+ return _squircle_mask(w, h, r, float(min(smoothing, 1.0)))
77
190
  xs = np.arange(w, dtype=np.float64) + 0.5 # pixel centres, edge coords
78
191
  ys = np.arange(h, dtype=np.float64) + 0.5
79
192
  # Per-axis distance past the arc-centre rail: zero everywhere except the
@@ -153,7 +266,8 @@ class Plan:
153
266
 
154
267
  def __init__(self, photo: np.ndarray, frame_shape, corners,
155
268
  corner_radius: float = 0.0, grain: bool = False,
156
- blend: str = "replace", reflection: float = DEFAULT_REFLECTION):
269
+ blend: str = "replace", reflection: float = DEFAULT_REFLECTION,
270
+ corner_smoothing: float = 0.0):
157
271
  dst_quad = np.array(corners, dtype=np.float32)
158
272
  if shoelace_area(dst_quad) < 1.0:
159
273
  raise ValueError("degenerate quad (near-zero area) — check corner order TL,TR,BR,BL")
@@ -161,6 +275,10 @@ class Plan:
161
275
  self.dst_quad = dst_quad
162
276
  self.grain = grain
163
277
  self.blend = blend if blend in ("replace", "emissive") else "replace"
278
+ # Unitless 0-1, Figma's corner smoothing. 0 is a circular arc and is the
279
+ # default everywhere, so anything that does not pass it gets the shape it
280
+ # got before smoothing existed.
281
+ self.corner_smoothing = float(np.clip(corner_smoothing, 0.0, 1.0))
164
282
  self.reflection = float(np.clip(reflection, 0.0, 1.0))
165
283
 
166
284
  top = float(np.linalg.norm(dst_quad[1] - dst_quad[0]))
@@ -185,7 +303,8 @@ class Plan:
185
303
  self.H = cv2.getPerspectiveTransform(src_rect, dst_quad)
186
304
  ph, pw = photo.shape[:2]
187
305
  self.size = (pw, ph)
188
- src_mask = rounded_mask(self.new_w, self.new_h, float(self.radius))
306
+ src_mask = rounded_mask(self.new_w, self.new_h, float(self.radius),
307
+ self.corner_smoothing)
189
308
  self.warped_mask = _warp_mask_antialiased(src_mask, self.H, pw, ph, dst_quad)
190
309
  self.mask3 = cv2.merge([self.warped_mask] * 3).astype(np.float32) / 255.0
191
310
  self.grain_sigma = (_grade.measure_grain(photo, _grade.surround_ring(self.warped_mask))
@@ -288,6 +407,7 @@ class Plan:
288
407
 
289
408
 
290
409
  def compose(photo: np.ndarray, screenshot: np.ndarray, corners, corner_radius: float = 0.0,
410
+ corner_smoothing: float = 0.0,
291
411
  grade: float = 0.0, grain: bool = False, screen_off: np.ndarray = None,
292
412
  specular: float = 0.75, blend: str = "replace",
293
413
  reflection: float = DEFAULT_REFLECTION) -> np.ndarray:
@@ -302,6 +422,7 @@ def compose(photo: np.ndarray, screenshot: np.ndarray, corners, corner_radius: f
302
422
  # code and cannot drift apart. See test_video.py: frame 0 of a render is
303
423
  # asserted byte-identical to this function's output.
304
424
  plan = Plan(photo, screenshot.shape, corners, corner_radius, grain=grain,
425
+ corner_smoothing=corner_smoothing,
305
426
  blend=blend, reflection=reflection)
306
427
  plan.bind_grade(screenshot, grade)
307
428
  return plan.render(screenshot, screen_off=screen_off, specular=specular)
@@ -384,10 +505,12 @@ def read_frame_at(path: str, index: int = 0):
384
505
 
385
506
 
386
507
  def compose_video(photo: np.ndarray, video_path: str, corners, output: str,
387
- corner_radius: float = 0.0, grade: float = 0.0, grain: bool = False,
508
+ corner_radius: float = 0.0, corner_smoothing: float = 0.0,
509
+ grade: float = 0.0, grain: bool = False,
388
510
  preset: str = "web", fit_frame: int = 0, audio: bool = True,
389
511
  frames_dir: str = None, progress=None, blend: str = "replace",
390
- reflection: float = DEFAULT_REFLECTION) -> dict:
512
+ reflection: float = DEFAULT_REFLECTION,
513
+ start_frame: int = 0, max_frames: int = None) -> dict:
391
514
  """Inject a VIDEO into a still photo. The photo does not move, so there is
392
515
  exactly one homography and the whole of Plan is computed once.
393
516
 
@@ -396,6 +519,13 @@ def compose_video(photo: np.ndarray, video_path: str, corners, output: str,
396
519
  intermediate PNGs, for no benefit. `frames_dir` still dumps them when a
397
520
  test or a human needs to look at individual frames.
398
521
 
522
+ `start_frame` and `max_frames` render a SEGMENT rather than the whole clip.
523
+ They exist for the preview: compositing a 2460-frame recording to look at it
524
+ takes about as long as the render it is meant to save you from, and a
525
+ preview you wait a minute for is a render with a worse output. Nothing else
526
+ passes them, so the full-clip contract -- frame 0 of a render equals the
527
+ still composite, byte for byte -- is untouched.
528
+
399
529
  Time is deliberately NOT resampled. The output runs at the source's own
400
530
  frame rate; converting fps by dropping or duplicating frames is judder, and
401
531
  doing it properly means blending, which is a different feature. Note also
@@ -406,6 +536,7 @@ def compose_video(photo: np.ndarray, video_path: str, corners, output: str,
406
536
  n_hint, fps, vw, vh = probe_video(video_path)
407
537
  first = read_frame_at(video_path, fit_frame)
408
538
  plan = Plan(photo, first.shape, corners, corner_radius, grain=grain,
539
+ corner_smoothing=corner_smoothing,
409
540
  blend=blend, reflection=reflection)
410
541
  plan.bind_grade(first, grade)
411
542
 
@@ -424,10 +555,26 @@ def compose_video(photo: np.ndarray, video_path: str, corners, output: str,
424
555
  if frames_dir:
425
556
  os.makedirs(frames_dir, exist_ok=True)
426
557
  cap = cv2.VideoCapture(video_path)
558
+ if start_frame > 0:
559
+ # Same fallback read_frame_at uses: seeking is unreliable on some
560
+ # containers, and a segment that silently began somewhere else would be
561
+ # a preview of the wrong moment.
562
+ cap.set(cv2.CAP_PROP_POS_FRAMES, start_frame)
563
+ if not cap.grab():
564
+ cap.release()
565
+ cap = cv2.VideoCapture(video_path)
566
+ for _ in range(start_frame):
567
+ if not cap.grab():
568
+ break
569
+ else:
570
+ cap.set(cv2.CAP_PROP_POS_FRAMES, start_frame)
427
571
  proc = subprocess.Popen(cmd, stdin=subprocess.PIPE, stderr=subprocess.PIPE)
428
572
  count = 0
573
+ total_hint = n_hint if max_frames is None else min(n_hint or max_frames, max_frames)
429
574
  try:
430
575
  while True:
576
+ if max_frames is not None and count >= max_frames:
577
+ break
431
578
  ok, frame = cap.read()
432
579
  if not ok:
433
580
  break
@@ -438,7 +585,7 @@ def compose_video(photo: np.ndarray, video_path: str, corners, output: str,
438
585
  proc.stdin.write(out.tobytes())
439
586
  count += 1
440
587
  if progress and count % 10 == 0:
441
- progress(count, n_hint)
588
+ progress(count, total_hint)
442
589
  finally:
443
590
  cap.release()
444
591
  try:
@@ -453,7 +600,8 @@ def compose_video(photo: np.ndarray, video_path: str, corners, output: str,
453
600
  raise RuntimeError(f"no frames could be read from {video_path}")
454
601
  return {"frames": count, "fps": fps, "source_size": [vw, vh],
455
602
  "output_size": [pw, ph], "preset": preset, "fit_frame": fit_frame,
456
- "blend": blend, "reflection": plan.reflection}
603
+ "blend": blend, "reflection": plan.reflection,
604
+ "start_frame": start_frame}
457
605
 
458
606
 
459
607
  def main() -> None:
@@ -5,15 +5,15 @@ 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.25):** 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 — 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.
8
+ **What ships (v0.38):** 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
- The geometry is exact (`warp.py`); the detection is advisory (`detect.py`) and the human corrects it.
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
 
12
12
  **Emissive screen** *(off by default)*: a real display shows its own light **plus** the room reflecting off its glass — which is why a switched-off phone reads dark grey and never black. The default `replace` treats the screenshot as paint and discards the device's own screen surface, so a **true-black UI lands as a hole cut in the photo**, and the specular streak running across a dashboard or a phone stops dead at the screen edge. Turn Emissive on and the screenshot is composited *over* the glass instead: blacks become the device's own surface, and the photo's reflections carry across the screen. The strength sets how much glass shows through — **25–50% is the usable band**, past 75% the content loses contrast. Suggest it whenever the user's UI is dark and the composite reads as a flat inset; the realism pass cannot fix that, because it can only lift uniformly and the surface carrying the gradient has already been thrown away.
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
- **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, 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.
16
+ **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
17
 
18
18
  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.
19
19