screengraft 0.13.1 → 0.20.4

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.
@@ -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,69 @@ 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
+ def progress(done, total):
220
+ with RENDER_LOCK:
221
+ RENDER["done"], RENDER["total"] = done, total
222
+ try:
223
+ info = W.compose_video(photo, video_path, corners, dest,
224
+ corner_radius=radius_px, grade=gr, grain=grain,
225
+ preset=preset, fit_frame=fit_frame, progress=progress)
226
+ with RENDER_LOCK:
227
+ RENDER.update(state="done", output=dest, info=info,
228
+ done=info["frames"], total=info["frames"], message=None)
229
+ except Exception as e: # noqa: BLE001 - surfaced to the page
230
+ with RENDER_LOCK:
231
+ RENDER.update(state="error", message=str(e))
232
+
233
+
171
234
  def _guess_type(corners):
172
235
  c = np.array(corners, dtype=float)
173
236
  w = (np.linalg.norm(c[1] - c[0]) + np.linalg.norm(c[2] - c[3])) / 2
@@ -259,6 +322,12 @@ class Handler(BaseHTTPRequestHandler):
259
322
  if time.time() >= deadline:
260
323
  return self._json({"status": "pending", "waited": True})
261
324
  time.sleep(0.15)
325
+ if u.path == "/api/render_status":
326
+ # A poll, so a GET: no body, safe to repeat, and the page hits
327
+ # it once a second while a render runs.
328
+ with RENDER_LOCK:
329
+ return self._json(dict(RENDER))
330
+
262
331
  return self._json({"error": "no such route"}, 404)
263
332
  except (PermissionError, FileNotFoundError, KeyError, ValueError) as e:
264
333
  return self._json({"error": str(e)}, 400)
@@ -274,17 +343,26 @@ class Handler(BaseHTTPRequestHandler):
274
343
  dest = os.path.join(SESSION.dir, f"{role}-{int(time.time())}-{name}")
275
344
  with open(dest, "wb") as f:
276
345
  f.write(self._body())
277
- im, real = _read_image(dest)
346
+ # A video is only ever a screen source; a photo must be a still.
347
+ if role == "screenshot":
348
+ im, real, meta = _read_source(dest)
349
+ else:
350
+ im, real = _read_image(dest)
351
+ meta = {"video": False}
278
352
  SESSION.update(**{role: real})
279
- return self._json({"path": real, "size": [im.shape[1], im.shape[0]]})
353
+ return self._json({"path": real, "size": [im.shape[1], im.shape[0]], **meta})
280
354
 
281
355
  b = self._jbody()
282
356
 
283
357
  if u.path == "/api/use":
284
358
  role = b["role"]
285
- im, real = _read_image(b["path"])
359
+ if role == "screenshot":
360
+ im, real, meta = _read_source(b["path"])
361
+ else:
362
+ im, real = _read_image(b["path"])
363
+ meta = {"video": False}
286
364
  SESSION.update(**{role: real})
287
- return self._json({"path": real, "size": [im.shape[1], im.shape[0]]})
365
+ return self._json({"path": real, "size": [im.shape[1], im.shape[0]], **meta})
288
366
 
289
367
  if u.path == "/api/figma":
290
368
  return self._json(SESSION.enqueue({
@@ -322,12 +400,24 @@ class Handler(BaseHTTPRequestHandler):
322
400
  if u.path == "/api/detect":
323
401
  photo, _ = _read_image(SESSION.state["photo"])
324
402
  gray = cv2.cvtColor(photo, cv2.COLOR_BGR2GRAY)
325
- res = D.detect(gray, None)
403
+ # `color` gives detect() the saturation detector — devices are
404
+ # neutral, furniture is not, and grayscale throws that away.
405
+ res = D.detect(gray, None, color=photo)
326
406
  if res is None:
327
407
  return self._json({"found": False,
328
408
  "message": "Neither detector could find a screen here "
329
409
  "(nothing separable by tone, no screen-shaped "
330
410
  "boundary). Place the four corners by hand."})
411
+ # An abstention is a miss, and must reach the page as one. On
412
+ # 7 Sep 2026 a quad on a table was shown as a checkable guess
413
+ # with two corners off the canvas, which cannot be dragged back
414
+ # — worse than no guess at all. detect() keeps the
415
+ # quad for inspection; the page gets the default rectangle.
416
+ if res.get("abstained"):
417
+ return self._json({"found": False,
418
+ "message": res["abstain_reason"],
419
+ "abstained": True,
420
+ "inspect": res["corners"]})
331
421
  res.pop("_corners_np", None)
332
422
  res["found"] = True
333
423
  # How much the page should trust this. Both detectors agreeing is
@@ -338,9 +428,72 @@ class Handler(BaseHTTPRequestHandler):
338
428
  res["type_guess"] = _guess_type(res["corners"])
339
429
  return self._json(res)
340
430
 
431
+ if u.path == "/api/frame":
432
+ # One frame of the source video as a PNG the page can show — the
433
+ # poster, or whichever frame the scrubber is on. The fit, the
434
+ # loupe and the compare view all work on this exactly as they
435
+ # work on a screenshot, which is why none of them needed
436
+ # changing for video.
437
+ spath = _safe_local_path(SESSION.state["screenshot"])
438
+ if not _is_video(spath):
439
+ return self._json({"error": "the screen source is not a video"}, 400)
440
+ idx = int(b.get("index") or 0)
441
+ frame = W.read_frame_at(spath, idx)
442
+ dest = os.path.join(SESSION.dir, f"frame-{idx:06d}.png")
443
+ cv2.imwrite(dest, frame, [cv2.IMWRITE_PNG_COMPRESSION, 1])
444
+ return self._json({"path": dest, "index": idx,
445
+ "size": [frame.shape[1], frame.shape[0]]})
446
+
447
+ if u.path == "/api/render":
448
+ # Video: same fit, same geometry, N frames instead of one.
449
+ photo, ppath = _read_image(SESSION.state["photo"])
450
+ spath = _safe_local_path(SESSION.state["screenshot"])
451
+ if not _is_video(spath):
452
+ return self._json({"error": "the screen source is not a video"}, 400)
453
+ if not _have_ffmpeg():
454
+ return self._json({"error": "ffmpeg is not installed",
455
+ "needs_ffmpeg": True}, 400)
456
+ with RENDER_LOCK:
457
+ if RENDER["state"] == "running":
458
+ return self._json({"error": "a render is already running"}, 409)
459
+ RENDER.update(state="running", done=0, total=0,
460
+ output=None, message=None)
461
+ corners = b["corners"]
462
+ frac = float(b.get("radius_frac") or 0.0)
463
+ fit_frame = int(b.get("fit_frame") or 0)
464
+ first = W.read_frame_at(spath, fit_frame)
465
+ radius_px = frac * first.shape[1]
466
+ gr = float(b.get("grade") if b.get("grade") is not None else 0.0)
467
+ grain = bool(b.get("grain", gr > 0))
468
+ preset = "prores" if b.get("preset") == "prores" else "web"
469
+ ext = ".mov" if preset == "prores" else ".mp4"
470
+ os.makedirs(OUT_DIR, exist_ok=True)
471
+ stem = (f"{os.path.splitext(os.path.basename(ppath))[0]}__"
472
+ f"{os.path.splitext(os.path.basename(spath))[0]}")
473
+ dest = os.path.join(OUT_DIR, stem + ext)
474
+ i = 2
475
+ while os.path.exists(dest):
476
+ dest = os.path.join(OUT_DIR, f"{stem}-{i}{ext}"); i += 1
477
+ SESSION.update(corners=corners, radius_frac=frac,
478
+ device=b.get("device"), grade=gr)
479
+ # Everything that changes the output goes in the sidecar, for the
480
+ # third time of asking (radius_px, then grade/grain, now the video
481
+ # fields). A render that cannot be reproduced from its own sidecar
482
+ # undercuts the determinism claim.
483
+ result = {"output": dest, "photo": ppath, "screenshot": spath,
484
+ "corners": corners, "radius_frac": frac, "radius_px": radius_px,
485
+ "device": b.get("device"), "grade": gr, "grain": grain,
486
+ "video": True, "preset": preset, "fit_frame": fit_frame,
487
+ "saved": time.time()}
488
+ _write_json_atomic(SESSION.result_path, result)
489
+ threading.Thread(target=_render_worker, daemon=True,
490
+ args=(photo, spath, corners, dest, radius_px,
491
+ gr, grain, preset, fit_frame)).start()
492
+ return self._json({"started": True, "output": dest, "preset": preset})
493
+
341
494
  if u.path in ("/api/preview", "/api/save"):
342
495
  photo, ppath = _read_image(SESSION.state["photo"])
343
- shot, spath = _read_image(SESSION.state["screenshot"])
496
+ shot, spath, _meta = _read_source(SESSION.state["screenshot"])
344
497
  corners = b["corners"]
345
498
  frac = float(b.get("radius_frac") or 0.0)
346
499
  radius_px = frac * shot.shape[1]