screengraft 0.17.0 → 0.21.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # screengraft
2
2
 
3
- **Put a UI screenshot onto a photographed screen so the perspective is exactly right.**
3
+ **Put a UI screenshot — or a screen recording — onto a photographed screen so the perspective is exactly right.**
4
4
 
5
5
  ![The fitting workbench: photo with the screen quad on the left, live composite on the right, and a magnified strip across the edge below](docs/workbench.png)
6
6
 
@@ -13,6 +13,9 @@ transform between them. The screenshot lands on the glass because the geometry
13
13
  says it must, not because a model thought it looked about right. Same inputs,
14
14
  same output, every time.
15
15
 
16
+ Point it at a **video** instead and the same fit renders every frame: record a
17
+ prototype, then put the recording inside a real photograph.
18
+
16
19
  ---
17
20
 
18
21
  ## What it does
@@ -26,6 +29,15 @@ same output, every time.
26
29
  - **Realism pass** *(optional)* — matches the screen's white balance and grain to
27
30
  the light in the room, and can lift the device's real reflections from a
28
31
  screen-off frame of the same shot.
32
+ - **Emissive screens** *(optional)* — a display emits light *and* reflects the
33
+ room, which is why a switched-off phone looks dark grey rather than black.
34
+ Paint a true-black UI on flat and it reads as a hole cut in the photo. Turn
35
+ this on and the screenshot composites over the device's own glass, so the
36
+ photo's highlights carry across the screen.
37
+ - **Video, not just stills.** The screen source can be an `mp4`/`mov`/`webm`.
38
+ You match the edges on one frame and every frame gets that same geometry — the
39
+ photograph is still, so there is nothing to track and nothing to drift. Output
40
+ is H.264 at CRF 16 or ProRes 422 HQ.
29
41
  - **You confirm every fit.** Detection is advisory and says so; you drag the four
30
42
  edges onto the glass with a magnified loupe. A silent misdetection producing a
31
43
  confident, wrong result is the one failure this tool refuses to have.
@@ -36,6 +48,10 @@ same output, every time.
36
48
  without it. The installer provisions an isolated venv at `~/.screengraft/venv`
37
49
  and never touches your system Python.
38
50
 
51
+ Video rendering also uses **ffmpeg**, which arrives as a wheel (`imageio-ffmpeg`)
52
+ into that same venv — nothing is installed system-wide. It is optional: without
53
+ it, stills work exactly as before.
54
+
39
55
  ## Install as a Claude Code / Cowork plugin
40
56
 
41
57
  ```
@@ -104,15 +120,31 @@ outside the screen mask. It never touches the pixels you designed.
104
120
  3. **Realism pass** *(optional)* — white balance and exposure toward the
105
121
  surrounding light, grain matched to the photo's own noise floor, real
106
122
  speculars lifted from a screen-off reference.
123
+ 4. **Video**, when the source is a clip — everything a fixed photo and a fixed
124
+ quad make constant is computed once, and only the frame changes. Three
125
+ consequences worth naming, because each is a way video normally goes wrong:
126
+ the light match is measured **once** from the frame you fitted on, so the
127
+ screen cannot pulse as your UI scrolls from dark to light; the grain stays
128
+ frozen, because the photograph's own noise does not move; and the screen's
129
+ antialiased **edge is pixel-identical in every frame**, so there is no edge
130
+ crawl. Frame 0 of a render is byte-identical to the still composite — the
131
+ test suite asserts it, because that is what stops the two paths drifting.
107
132
 
108
133
  ## Roadmap
109
134
 
110
- Done: manual warp, advisory detectors, the fitting workbench, the realism pass.
135
+ Done: manual warp, advisory detectors, the fitting workbench, the realism pass,
136
+ video into a still photo.
137
+
138
+ Open: **camera-motion tracking** — the photograph itself must currently be a
139
+ still, so a clip of a moving phone is out of scope; **SAM 2 auto-detect** (built
140
+ and measured in a separate repo; it segments the phone body rather than the
141
+ glass, so it is not shipped); **occluder matte**, so a finger in front of the
142
+ screen stays in front.
111
143
 
112
- Open: video tracking (M3), SAM 2 auto-detect (M4 built and measured in a
113
- separate repo; it currently segments the phone body rather than the glass, so it
114
- is not shipped), occluder matte (M5), so a finger in front of the screen stays in
115
- front.
144
+ Known limits worth stating plainly: detection abstains rather than guessing when
145
+ the background is itself neutral (a pale tiled floor, a plain wall) you place
146
+ the edges by hand there. And a prototype recording has no motion blur, so a very
147
+ fast scroll will strobe; that is a property of the source, not of the composite.
116
148
 
117
149
  ## Contributing
118
150
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "screengraft",
3
- "version": "0.17.0",
4
- "description": "Put a UI screenshot onto a photographed device screen with the perspective exactly right \u2014 a homography you confirm by hand, not a generative guess.",
3
+ "version": "0.21.0",
4
+ "description": "Put a UI screenshot or screen recording onto a photographed device screen with the perspective exactly right a homography you confirm by hand, not a generative guess.",
5
5
  "keywords": [
6
6
  "mockup",
7
7
  "device-frame",
@@ -9,6 +9,9 @@
9
9
  "perspective",
10
10
  "compositing",
11
11
  "screenshot",
12
+ "prototype",
13
+ "screen-recording",
14
+ "video",
12
15
  "design",
13
16
  "portfolio",
14
17
  "figma"
package/scripts/grade.py CHANGED
@@ -61,48 +61,75 @@ def _stats(lab: np.ndarray, sel: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
61
61
  return px.mean(axis=0), px.std(axis=0) + 1e-6
62
62
 
63
63
 
64
- def match_light(photo: np.ndarray, warped: np.ndarray, mask: np.ndarray,
65
- strength: float = DEFAULT_STRENGTH) -> np.ndarray:
66
- """Move the injected screen's cast and exposure toward the surrounding light.
67
-
68
- Chroma (a,b) is matched on mean AND spread a cast is exactly a chroma mean
69
- offset, and a room with weak colour should not receive a saturated screen.
70
- Luminance is matched on MEAN ONLY, and bounded: a screen is emissive and is
71
- allowed to be brighter than its surroundings, so rescaling its L spread to
72
- the bezel's would crush the UI's own contrast. That asymmetry is the whole
73
- design of this function.
64
+ def light_params(photo: np.ndarray, warped: np.ndarray, mask: np.ndarray,
65
+ strength: float = DEFAULT_STRENGTH):
66
+ """Measure the correction ONCE, so it can be applied to many frames.
67
+
68
+ Split out of match_light for video. The correction depends on the
69
+ screen's own content through `m_in`/`s_in`, so measuring it per frame makes
70
+ it drift as the UI scrolls from a dark screen to a light one — the injected
71
+ screen would visibly pulse. Deriving the parameters from one frame and
72
+ applying the identical transform to all of them is the whole fix.
73
+
74
+ Returns None when there is too little context to measure honestly, which
75
+ the caller must treat as "leave the frame alone".
74
76
  """
75
77
  if strength <= 0:
76
- return warped
78
+ return None
77
79
  ring = surround_ring(mask)
78
- if int(ring.sum()) < 500: # too little context to measure honestly
79
- return warped
80
-
81
- lab_photo = cv2.cvtColor(photo, cv2.COLOR_BGR2LAB).astype(np.float64)
82
- lab_warp = cv2.cvtColor(warped, cv2.COLOR_BGR2LAB).astype(np.float64)
80
+ if int(ring.sum()) < 500:
81
+ return None
83
82
  inside = (mask > 200).astype(np.uint8)
84
83
  if int(inside.sum()) < 500:
85
- return warped
86
-
84
+ return None
85
+ lab_photo = cv2.cvtColor(photo, cv2.COLOR_BGR2LAB).astype(np.float64)
86
+ lab_warp = cv2.cvtColor(warped, cv2.COLOR_BGR2LAB).astype(np.float64)
87
87
  m_out, s_out = _stats(lab_photo, ring)
88
88
  m_in, s_in = _stats(lab_warp, inside)
89
-
89
+ return {
90
+ "m_in": m_in, "s_in": s_in, "m_out": m_out, "s_out": s_out,
91
+ "strength": float(strength),
92
+ # Same clamp as match_light: a screen is emissive and may be brighter
93
+ # than the room, so L moves by a bounded mean shift only.
94
+ "dL": float(np.clip(m_out[0] - m_in[0], -12.0, 12.0)) * float(strength),
95
+ }
96
+
97
+
98
+ def apply_light(warped: np.ndarray, params) -> np.ndarray:
99
+ """Apply parameters from light_params() to one frame."""
100
+ if params is None:
101
+ return warped
102
+ lab_warp = cv2.cvtColor(warped, cv2.COLOR_BGR2LAB).astype(np.float64)
103
+ m_in, s_in = params["m_in"], params["s_in"]
104
+ m_out, s_out = params["m_out"], params["s_out"]
105
+ strength = params["strength"]
90
106
  out = lab_warp.copy()
91
- # a,b: full Reinhard transfer, scaled by strength.
92
107
  for c in (1, 2):
93
108
  moved = (lab_warp[:, :, c] - m_in[c]) * float(s_out[c] / s_in[c]) + m_out[c]
94
109
  out[:, :, c] = lab_warp[:, :, c] + (moved - lab_warp[:, :, c]) * strength
95
- # L: mean shift only, and capped at +-12 L* so a dark room cannot switch
96
- # the screen off. 12 is about a stop; beyond that it stops reading as the
97
- # same screenshot.
98
- dL = float(np.clip(m_out[0] - m_in[0], -12.0, 12.0)) * strength
99
- out[:, :, 0] = lab_warp[:, :, 0] + dL
100
-
110
+ out[:, :, 0] = lab_warp[:, :, 0] + params["dL"]
101
111
  out[:, :, 0] = np.clip(out[:, :, 0], 0, 255)
102
112
  out[:, :, 1:] = np.clip(out[:, :, 1:], 0, 255)
103
113
  return cv2.cvtColor(out.astype(np.uint8), cv2.COLOR_LAB2BGR)
104
114
 
105
115
 
116
+ def match_light(photo: np.ndarray, warped: np.ndarray, mask: np.ndarray,
117
+ strength: float = DEFAULT_STRENGTH) -> np.ndarray:
118
+ """Move the injected screen's cast and exposure toward the surrounding light.
119
+
120
+ Chroma (a,b) is matched on mean AND spread — a cast is exactly a chroma mean
121
+ offset, and a room with weak colour should not receive a saturated screen.
122
+ Luminance is matched on MEAN ONLY, and bounded: a screen is emissive and is
123
+ allowed to be brighter than its surroundings, so rescaling its L spread to
124
+ the bezel's would crush the UI's own contrast. That asymmetry is the whole
125
+ design of this function.
126
+ """
127
+ # One implementation, two entry points: measuring and applying are the same
128
+ # arithmetic whether it runs on a still or on frame 900 of a clip. Keeping a
129
+ # second copy here is how the two paths would drift.
130
+ return apply_light(warped, light_params(photo, warped, mask, strength))
131
+
132
+
106
133
  def measure_grain(photo: np.ndarray, ring: np.ndarray) -> float:
107
134
  """The photo's noise floor, in grey levels, measured where the screen isn't.
108
135
 
@@ -87,7 +87,19 @@ def report():
87
87
  "install_command": f"{sys.executable} {os.path.abspath(__file__)} --install",
88
88
  "install_does": f"Creates a virtualenv at {VENV} (nothing touches system Python) and pip-installs "
89
89
  f"{', '.join(pkg for _, pkg, _ in REQUIRED)} into it (~60 MB download).",
90
+ # Optional, and deliberately NOT part of `ready`. A missing ffmpeg
91
+ # stops video renders and nothing else; making it a hard requirement
92
+ # would fail preflight for every user who only ever injects a
93
+ # screenshot, which is most of them.
90
94
  "optional": [
95
+ {"name": "ffmpeg (video)", "package": "imageio-ffmpeg",
96
+ "status": "installed" if _have_ffmpeg() else "missing",
97
+ "needed_for": "rendering a video; stills do not use it",
98
+ "install_command": f"{sys.executable} {os.path.abspath(__file__)} --install-ffmpeg",
99
+ "install_size": "~25 MB, a wheel with a static binary; nothing system-wide",
100
+ "why": "Encodes video renders. Ships as a wheel with a static binary, "
101
+ "so it lands in the same venv and nothing is installed "
102
+ "system-wide. Stills do not need it."},
91
103
  {"name": "SAM 2 (M4)", "status": "not yet used by the plugin",
92
104
  "why": "Better screen detection on photos where tone-based detection fails. Optional; results are worse without it, not absent."}
93
105
  ],
@@ -95,6 +107,23 @@ def report():
95
107
  }
96
108
 
97
109
 
110
+ def _have_ffmpeg() -> bool:
111
+ """Is the ffmpeg wheel importable IN THE VENV (not in whatever Python runs this)?"""
112
+ code = ("import json\n"
113
+ "try:\n"
114
+ " import imageio_ffmpeg, os\n"
115
+ " p = imageio_ffmpeg.get_ffmpeg_exe()\n"
116
+ " print(json.dumps(bool(p and os.path.exists(p))))\n"
117
+ "except Exception:\n"
118
+ " print('false')\n")
119
+ try:
120
+ r = subprocess.run([VENV_PY, "-c", code], capture_output=True, text=True,
121
+ timeout=60, check=False)
122
+ return r.stdout.strip() == "true"
123
+ except Exception:
124
+ return False
125
+
126
+
98
127
  def install():
99
128
  os.makedirs(os.path.dirname(VENV), exist_ok=True)
100
129
  if not os.path.exists(VENV_PY):
@@ -103,12 +132,31 @@ def install():
103
132
  subprocess.check_call([VENV_PY, "-m", "pip", "install", "-q", "-r", REQ])
104
133
 
105
134
 
135
+ def install_ffmpeg():
136
+ """Add just the video encoder to an existing venv.
137
+
138
+ A separate entry point because of who needs it: someone who installed
139
+ screengraft before video existed has a perfectly good venv with OpenCV in
140
+ it, `ready` is true, and nothing tells them anything is missing until a
141
+ render fails at the end of the job. This adds the one wheel, so the ask is
142
+ "~25 MB for video" rather than "reinstall everything".
143
+ """
144
+ if not os.path.exists(VENV_PY):
145
+ install()
146
+ return
147
+ subprocess.check_call([VENV_PY, "-m", "pip", "install", "-q", "imageio-ffmpeg"])
148
+
149
+
106
150
  def main():
107
151
  ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
108
152
  ap.add_argument("--install", action="store_true", help="Create the venv and install requirements (ask the user first)")
153
+ ap.add_argument("--install-ffmpeg", action="store_true",
154
+ help="Add just the video encoder to an existing venv (ask the user first)")
109
155
  args = ap.parse_args()
110
156
  if args.install:
111
157
  install()
158
+ elif args.install_ffmpeg:
159
+ install_ffmpeg()
112
160
  rep = report()
113
161
  print(json.dumps(rep, indent=1))
114
162
  sys.exit(0 if rep["ready"] else 1)
@@ -1,2 +1,3 @@
1
1
  opencv-python-headless>=4.9
2
2
  numpy>=1.26
3
+ imageio-ffmpeg>=0.4
package/scripts/ui.py CHANGED
@@ -168,6 +168,88 @@ def _read_image(path: str):
168
168
  return im, p
169
169
 
170
170
 
171
+ VIDEO_EXT = (".mp4", ".mov", ".m4v", ".webm", ".avi", ".mkv")
172
+
173
+
174
+ def _have_ffmpeg() -> bool:
175
+ try:
176
+ W.ffmpeg_exe()
177
+ return True
178
+ except RuntimeError:
179
+ return False
180
+
181
+
182
+ def _is_video(path: str) -> bool:
183
+ return str(path).lower().endswith(VIDEO_EXT)
184
+
185
+
186
+ def _read_source(path: str):
187
+ """Read the screen source, which may be a still OR a video.
188
+
189
+ Returns (frame, real_path, meta). For a video the frame is the poster —
190
+ the frame the designer fits on — and `meta` carries what the page needs to
191
+ show a scrubber. Everything downstream of this point treats that frame
192
+ exactly like a screenshot, which is the point: the fit, the loupe, the
193
+ compare view and the preview are all unchanged by video.
194
+ """
195
+ real = _safe_local_path(path)
196
+ if not _is_video(real):
197
+ im, rp = _read_image(real)
198
+ return im, rp, {"video": False}
199
+ n, fps, vw, vh = W.probe_video(real)
200
+ frame = W.read_frame_at(real, 0)
201
+ # Report the encoder's absence HERE, when the clip is chosen, rather than
202
+ # letting the render fail at the end of the job. Someone who installed
203
+ # screengraft before video existed has a working venv with no ffmpeg in it,
204
+ # and nothing else would tell them until they had done all the fitting.
205
+ return frame, real, {"video": True, "frames": n, "fps": fps, "size": [vw, vh],
206
+ "ffmpeg": _have_ffmpeg()}
207
+
208
+
209
+ # Render progress, read by /api/render_status. A ten-second clip is a few
210
+ # hundred frames and a good few seconds of work, which is far too long to hold
211
+ # an HTTP request open — so the render runs on its own thread and the page
212
+ # polls. ThreadingHTTPServer is already the server class, so this needs no
213
+ # other machinery.
214
+ RENDER = {"state": "idle", "done": 0, "total": 0, "output": None, "message": None}
215
+ RENDER_LOCK = threading.Lock()
216
+
217
+
218
+ def _render_worker(photo, video_path, corners, dest, radius_px, gr, grain, preset, fit_frame,
219
+ blend="replace", reflection=None):
220
+ def progress(done, total):
221
+ with RENDER_LOCK:
222
+ RENDER["done"], RENDER["total"] = done, total
223
+ try:
224
+ info = W.compose_video(photo, video_path, corners, dest,
225
+ corner_radius=radius_px, grade=gr, grain=grain,
226
+ preset=preset, fit_frame=fit_frame, progress=progress,
227
+ blend=blend,
228
+ reflection=(W.DEFAULT_REFLECTION if reflection is None
229
+ else reflection))
230
+ with RENDER_LOCK:
231
+ RENDER.update(state="done", output=dest, info=info,
232
+ done=info["frames"], total=info["frames"], message=None)
233
+ except Exception as e: # noqa: BLE001 - surfaced to the page
234
+ with RENDER_LOCK:
235
+ RENDER.update(state="error", message=str(e))
236
+
237
+
238
+ def _blend_args(b):
239
+ """(blend, reflection) from the page's single `reflection` field.
240
+
241
+ One field, not two: the page sends a number when the switch is on and null
242
+ when it is off, so there is no way to express the contradictory state
243
+ "emissive with no strength" — which is just `replace` under a different
244
+ name. compose() still takes both, because the engine should not have to
245
+ infer intent from a null.
246
+ """
247
+ r = b.get("reflection")
248
+ if r is None:
249
+ return "replace", W.DEFAULT_REFLECTION
250
+ return "emissive", float(max(0.0, min(1.0, float(r))))
251
+
252
+
171
253
  def _guess_type(corners):
172
254
  c = np.array(corners, dtype=float)
173
255
  w = (np.linalg.norm(c[1] - c[0]) + np.linalg.norm(c[2] - c[3])) / 2
@@ -259,6 +341,12 @@ class Handler(BaseHTTPRequestHandler):
259
341
  if time.time() >= deadline:
260
342
  return self._json({"status": "pending", "waited": True})
261
343
  time.sleep(0.15)
344
+ if u.path == "/api/render_status":
345
+ # A poll, so a GET: no body, safe to repeat, and the page hits
346
+ # it once a second while a render runs.
347
+ with RENDER_LOCK:
348
+ return self._json(dict(RENDER))
349
+
262
350
  return self._json({"error": "no such route"}, 404)
263
351
  except (PermissionError, FileNotFoundError, KeyError, ValueError) as e:
264
352
  return self._json({"error": str(e)}, 400)
@@ -274,17 +362,26 @@ class Handler(BaseHTTPRequestHandler):
274
362
  dest = os.path.join(SESSION.dir, f"{role}-{int(time.time())}-{name}")
275
363
  with open(dest, "wb") as f:
276
364
  f.write(self._body())
277
- im, real = _read_image(dest)
365
+ # A video is only ever a screen source; a photo must be a still.
366
+ if role == "screenshot":
367
+ im, real, meta = _read_source(dest)
368
+ else:
369
+ im, real = _read_image(dest)
370
+ meta = {"video": False}
278
371
  SESSION.update(**{role: real})
279
- return self._json({"path": real, "size": [im.shape[1], im.shape[0]]})
372
+ return self._json({"path": real, "size": [im.shape[1], im.shape[0]], **meta})
280
373
 
281
374
  b = self._jbody()
282
375
 
283
376
  if u.path == "/api/use":
284
377
  role = b["role"]
285
- im, real = _read_image(b["path"])
378
+ if role == "screenshot":
379
+ im, real, meta = _read_source(b["path"])
380
+ else:
381
+ im, real = _read_image(b["path"])
382
+ meta = {"video": False}
286
383
  SESSION.update(**{role: real})
287
- return self._json({"path": real, "size": [im.shape[1], im.shape[0]]})
384
+ return self._json({"path": real, "size": [im.shape[1], im.shape[0]], **meta})
288
385
 
289
386
  if u.path == "/api/figma":
290
387
  return self._json(SESSION.enqueue({
@@ -350,9 +447,75 @@ class Handler(BaseHTTPRequestHandler):
350
447
  res["type_guess"] = _guess_type(res["corners"])
351
448
  return self._json(res)
352
449
 
450
+ if u.path == "/api/frame":
451
+ # One frame of the source video as a PNG the page can show — the
452
+ # poster, or whichever frame the scrubber is on. The fit, the
453
+ # loupe and the compare view all work on this exactly as they
454
+ # work on a screenshot, which is why none of them needed
455
+ # changing for video.
456
+ spath = _safe_local_path(SESSION.state["screenshot"])
457
+ if not _is_video(spath):
458
+ return self._json({"error": "the screen source is not a video"}, 400)
459
+ idx = int(b.get("index") or 0)
460
+ frame = W.read_frame_at(spath, idx)
461
+ dest = os.path.join(SESSION.dir, f"frame-{idx:06d}.png")
462
+ cv2.imwrite(dest, frame, [cv2.IMWRITE_PNG_COMPRESSION, 1])
463
+ return self._json({"path": dest, "index": idx,
464
+ "size": [frame.shape[1], frame.shape[0]]})
465
+
466
+ if u.path == "/api/render":
467
+ # Video: same fit, same geometry, N frames instead of one.
468
+ photo, ppath = _read_image(SESSION.state["photo"])
469
+ spath = _safe_local_path(SESSION.state["screenshot"])
470
+ if not _is_video(spath):
471
+ return self._json({"error": "the screen source is not a video"}, 400)
472
+ if not _have_ffmpeg():
473
+ return self._json({"error": "ffmpeg is not installed",
474
+ "needs_ffmpeg": True}, 400)
475
+ with RENDER_LOCK:
476
+ if RENDER["state"] == "running":
477
+ return self._json({"error": "a render is already running"}, 409)
478
+ RENDER.update(state="running", done=0, total=0,
479
+ output=None, message=None)
480
+ corners = b["corners"]
481
+ frac = float(b.get("radius_frac") or 0.0)
482
+ fit_frame = int(b.get("fit_frame") or 0)
483
+ first = W.read_frame_at(spath, fit_frame)
484
+ radius_px = frac * first.shape[1]
485
+ gr = float(b.get("grade") if b.get("grade") is not None else 0.0)
486
+ grain = bool(b.get("grain", gr > 0))
487
+ blend, reflection = _blend_args(b)
488
+ preset = "prores" if b.get("preset") == "prores" else "web"
489
+ ext = ".mov" if preset == "prores" else ".mp4"
490
+ os.makedirs(OUT_DIR, exist_ok=True)
491
+ stem = (f"{os.path.splitext(os.path.basename(ppath))[0]}__"
492
+ f"{os.path.splitext(os.path.basename(spath))[0]}")
493
+ dest = os.path.join(OUT_DIR, stem + ext)
494
+ i = 2
495
+ while os.path.exists(dest):
496
+ dest = os.path.join(OUT_DIR, f"{stem}-{i}{ext}"); i += 1
497
+ SESSION.update(corners=corners, radius_frac=frac,
498
+ device=b.get("device"), grade=gr)
499
+ # Everything that changes the output goes in the sidecar, for the
500
+ # third time of asking (radius_px, then grade/grain, now the video
501
+ # fields). A render that cannot be reproduced from its own sidecar
502
+ # undercuts the determinism claim.
503
+ result = {"output": dest, "photo": ppath, "screenshot": spath,
504
+ "corners": corners, "radius_frac": frac, "radius_px": radius_px,
505
+ "device": b.get("device"), "grade": gr, "grain": grain,
506
+ "video": True, "preset": preset, "fit_frame": fit_frame,
507
+ "blend": blend, "reflection": reflection,
508
+ "saved": time.time()}
509
+ _write_json_atomic(SESSION.result_path, result)
510
+ threading.Thread(target=_render_worker, daemon=True,
511
+ args=(photo, spath, corners, dest, radius_px,
512
+ gr, grain, preset, fit_frame,
513
+ blend, reflection)).start()
514
+ return self._json({"started": True, "output": dest, "preset": preset})
515
+
353
516
  if u.path in ("/api/preview", "/api/save"):
354
517
  photo, ppath = _read_image(SESSION.state["photo"])
355
- shot, spath = _read_image(SESSION.state["screenshot"])
518
+ shot, spath, _meta = _read_source(SESSION.state["screenshot"])
356
519
  corners = b["corners"]
357
520
  frac = float(b.get("radius_frac") or 0.0)
358
521
  radius_px = frac * shot.shape[1]
@@ -361,8 +524,10 @@ class Handler(BaseHTTPRequestHandler):
361
524
  # is the point (a brand review), and the grade is the right one
362
525
  # when the photograph is (a portfolio shot).
363
526
  gr = float(b.get("grade") if b.get("grade") is not None else 0.0)
527
+ blend, reflection = _blend_args(b)
364
528
  out = W.compose(photo, shot, corners, radius_px,
365
- grade=gr, grain=bool(b.get("grain", gr > 0)))
529
+ grade=gr, grain=bool(b.get("grain", gr > 0)),
530
+ blend=blend, reflection=reflection)
366
531
  SESSION.update(corners=corners, radius_frac=frac, device=b.get("device"),
367
532
  grade=gr)
368
533
  if u.path == "/api/preview":
@@ -396,6 +561,7 @@ class Handler(BaseHTTPRequestHandler):
396
561
  result = {"output": dest, "photo": ppath, "screenshot": spath, "corners": corners,
397
562
  "radius_frac": frac, "radius_px": radius_px, "device": b.get("device"),
398
563
  "grade": gr, "grain": bool(b.get("grain", gr > 0)),
564
+ "blend": blend, "reflection": reflection,
399
565
  "saved": time.time()}
400
566
  _write_json_atomic(SESSION.result_path, result)
401
567
  SESSION.update(output=dest)