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/README.md +35 -0
- package/package.json +1 -1
- package/scripts/detect.py +335 -36
- package/scripts/fitfile.py +140 -0
- package/scripts/fits.py +151 -0
- package/scripts/ui.py +398 -42
- package/scripts/warp.py +155 -7
- package/skills/inject-screenshot/SKILL.md +3 -3
- package/ui/index.html +723 -67
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
|
-
|
|
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,
|
|
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
|
|
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,
|
|
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.
|
|
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
|
|