screengraft 0.17.0 → 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 +33 -6
- package/package.json +5 -2
- package/scripts/grade.py +53 -26
- package/scripts/preflight.py +48 -0
- package/scripts/requirements.txt +1 -0
- package/scripts/ui.py +146 -5
- package/scripts/warp.py +288 -71
- package/skills/inject-screenshot/SKILL.md +27 -10
- package/ui/index.html +138 -78
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
|

|
|
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,10 @@ 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
|
+
- **Video, not just stills.** The screen source can be an `mp4`/`mov`/`webm`.
|
|
33
|
+
You match the edges on one frame and every frame gets that same geometry — the
|
|
34
|
+
photograph is still, so there is nothing to track and nothing to drift. Output
|
|
35
|
+
is H.264 at CRF 16 or ProRes 422 HQ.
|
|
29
36
|
- **You confirm every fit.** Detection is advisory and says so; you drag the four
|
|
30
37
|
edges onto the glass with a magnified loupe. A silent misdetection producing a
|
|
31
38
|
confident, wrong result is the one failure this tool refuses to have.
|
|
@@ -36,6 +43,10 @@ same output, every time.
|
|
|
36
43
|
without it. The installer provisions an isolated venv at `~/.screengraft/venv`
|
|
37
44
|
and never touches your system Python.
|
|
38
45
|
|
|
46
|
+
Video rendering also uses **ffmpeg**, which arrives as a wheel (`imageio-ffmpeg`)
|
|
47
|
+
into that same venv — nothing is installed system-wide. It is optional: without
|
|
48
|
+
it, stills work exactly as before.
|
|
49
|
+
|
|
39
50
|
## Install as a Claude Code / Cowork plugin
|
|
40
51
|
|
|
41
52
|
```
|
|
@@ -104,15 +115,31 @@ outside the screen mask. It never touches the pixels you designed.
|
|
|
104
115
|
3. **Realism pass** *(optional)* — white balance and exposure toward the
|
|
105
116
|
surrounding light, grain matched to the photo's own noise floor, real
|
|
106
117
|
speculars lifted from a screen-off reference.
|
|
118
|
+
4. **Video**, when the source is a clip — everything a fixed photo and a fixed
|
|
119
|
+
quad make constant is computed once, and only the frame changes. Three
|
|
120
|
+
consequences worth naming, because each is a way video normally goes wrong:
|
|
121
|
+
the light match is measured **once** from the frame you fitted on, so the
|
|
122
|
+
screen cannot pulse as your UI scrolls from dark to light; the grain stays
|
|
123
|
+
frozen, because the photograph's own noise does not move; and the screen's
|
|
124
|
+
antialiased **edge is pixel-identical in every frame**, so there is no edge
|
|
125
|
+
crawl. Frame 0 of a render is byte-identical to the still composite — the
|
|
126
|
+
test suite asserts it, because that is what stops the two paths drifting.
|
|
107
127
|
|
|
108
128
|
## Roadmap
|
|
109
129
|
|
|
110
|
-
Done: manual warp, advisory detectors, the fitting workbench, the realism pass
|
|
130
|
+
Done: manual warp, advisory detectors, the fitting workbench, the realism pass,
|
|
131
|
+
video into a still photo.
|
|
132
|
+
|
|
133
|
+
Open: **camera-motion tracking** — the photograph itself must currently be a
|
|
134
|
+
still, so a clip of a moving phone is out of scope; **SAM 2 auto-detect** (built
|
|
135
|
+
and measured in a separate repo; it segments the phone body rather than the
|
|
136
|
+
glass, so it is not shipped); **occluder matte**, so a finger in front of the
|
|
137
|
+
screen stays in front.
|
|
111
138
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
139
|
+
Known limits worth stating plainly: detection abstains rather than guessing when
|
|
140
|
+
the background is itself neutral (a pale tiled floor, a plain wall) — you place
|
|
141
|
+
the edges by hand there. And a prototype recording has no motion blur, so a very
|
|
142
|
+
fast scroll will strobe; that is a property of the source, not of the composite.
|
|
116
143
|
|
|
117
144
|
## Contributing
|
|
118
145
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "screengraft",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Put a UI screenshot onto a photographed device screen with the perspective exactly right
|
|
3
|
+
"version": "0.20.4",
|
|
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
|
|
65
|
-
|
|
66
|
-
"""
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
the
|
|
73
|
-
|
|
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
|
|
78
|
+
return None
|
|
77
79
|
ring = surround_ring(mask)
|
|
78
|
-
if int(ring.sum()) < 500:
|
|
79
|
-
return
|
|
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
|
|
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
|
-
|
|
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
|
|
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({
|
|
@@ -350,9 +428,72 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
350
428
|
res["type_guess"] = _guess_type(res["corners"])
|
|
351
429
|
return self._json(res)
|
|
352
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
|
+
|
|
353
494
|
if u.path in ("/api/preview", "/api/save"):
|
|
354
495
|
photo, ppath = _read_image(SESSION.state["photo"])
|
|
355
|
-
shot, spath =
|
|
496
|
+
shot, spath, _meta = _read_source(SESSION.state["screenshot"])
|
|
356
497
|
corners = b["corners"]
|
|
357
498
|
frac = float(b.get("radius_frac") or 0.0)
|
|
358
499
|
radius_px = frac * shot.shape[1]
|