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.
- package/README.md +34 -9
- package/package.json +5 -2
- package/scripts/detect.py +289 -30
- package/scripts/grade.py +53 -26
- package/scripts/preflight.py +48 -0
- package/scripts/requirements.txt +1 -0
- package/scripts/ui.py +159 -6
- package/scripts/warp.py +288 -71
- package/skills/inject-screenshot/SKILL.md +27 -10
- package/ui/index.html +163 -81
package/scripts/preflight.py
CHANGED
|
@@ -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)
|
package/scripts/requirements.txt
CHANGED
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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]
|