screengraft 0.21.1 → 0.24.1
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 +19 -0
- package/package.json +1 -1
- package/scripts/detect.py +40 -12
- package/scripts/ui.py +259 -20
- package/skills/inject-screenshot/SKILL.md +1 -1
- package/ui/index.html +246 -12
package/README.md
CHANGED
|
@@ -130,6 +130,25 @@ outside the screen mask. It never touches the pixels you designed.
|
|
|
130
130
|
crawl. Frame 0 of a render is byte-identical to the still composite — the
|
|
131
131
|
test suite asserts it, because that is what stops the two paths drifting.
|
|
132
132
|
|
|
133
|
+
## Where screengraft keeps things
|
|
134
|
+
|
|
135
|
+
**Your output** goes to the folder you launched with (`--out-dir`), which the
|
|
136
|
+
Claude skill points at your project. That is the only place anything is kept for
|
|
137
|
+
you, and nothing below ever touches it.
|
|
138
|
+
|
|
139
|
+
**Working files** live in `~/.screengraft/sessions/<timestamp>/` — one directory
|
|
140
|
+
per run. A source you pick by path is never copied: screengraft reads it where it
|
|
141
|
+
is. A source you drag in or browse to has to be copied, because a browser hands
|
|
142
|
+
over bytes and will not say where they came from — and that copy, along with the
|
|
143
|
+
preview and thumbnails, is **deleted when the run ends**. Anything left behind by
|
|
144
|
+
a crash is swept the next time you launch.
|
|
145
|
+
|
|
146
|
+
What survives is the `result.json` sidecar: a few hundred bytes recording the
|
|
147
|
+
corners, radius, grade and blend of that fit, referencing your original files by
|
|
148
|
+
path. It reproduces a composite exactly, and it is the first thing a bug report
|
|
149
|
+
should include. Keep the recipe, not the ingredients.
|
|
150
|
+
|
|
151
|
+
|
|
133
152
|
## Roadmap
|
|
134
153
|
|
|
135
154
|
Done: manual warp, advisory detectors, the fitting workbench, the realism pass,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "screengraft",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.1",
|
|
4
4
|
"description": "Put a UI screenshot or screen recording onto a photographed device screen with the perspective exactly right \u2014 a homography you confirm by hand, not a generative guess.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"mockup",
|
package/scripts/detect.py
CHANGED
|
@@ -490,7 +490,22 @@ def validate_quad(corners: np.ndarray, contour, img_area: float, img_shape=None)
|
|
|
490
490
|
return True, ""
|
|
491
491
|
|
|
492
492
|
|
|
493
|
-
def
|
|
493
|
+
def contains(quad, point) -> bool:
|
|
494
|
+
"""Is `point` inside this quad? The whole of the click feature, in one line.
|
|
495
|
+
|
|
496
|
+
A click cannot make a bad candidate good, and it is not asked to. It answers
|
|
497
|
+
the one question the pixels cannot: WHICH of the regions we already found is
|
|
498
|
+
the screen. Measured on the two photos that defeated every detector, the
|
|
499
|
+
correct quad was already in the candidate list both times -- 23 of 93
|
|
500
|
+
candidates contained the click on one, 16 of 118 on the other -- so the
|
|
501
|
+
failure was never detection, it was selection.
|
|
502
|
+
"""
|
|
503
|
+
return cv2.pointPolygonTest(np.asarray(quad, np.float32),
|
|
504
|
+
(float(point[0]), float(point[1])), False) >= 0
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _finalize(candidates, img_area: float, refine: bool = True, img_shape=None,
|
|
508
|
+
click=None):
|
|
494
509
|
"""Best candidate that survives refinement AND validation.
|
|
495
510
|
|
|
496
511
|
Walks candidates best-score-first rather than trusting the top one: a
|
|
@@ -506,6 +521,12 @@ def _finalize(candidates, img_area: float, refine: bool = True, img_shape=None):
|
|
|
506
521
|
78px off an otherwise correct quad . The polygon approximation of a
|
|
507
522
|
Canny boundary is already on the edge, so there is nothing to recover.
|
|
508
523
|
"""
|
|
524
|
+
if click is not None:
|
|
525
|
+
# Before ranking, not after: the point of the click is to shrink the
|
|
526
|
+
# field to the things the user actually pointed at, and let the existing
|
|
527
|
+
# score decide among those. Ranking first and filtering after would just
|
|
528
|
+
# re-confirm whichever candidate already won.
|
|
529
|
+
candidates = [c for c in candidates if contains(c[1], click)]
|
|
509
530
|
rejected = []
|
|
510
531
|
for cand in sorted(candidates, key=lambda c: c[0], reverse=True):
|
|
511
532
|
score, quad, contour, tag = pick_innermost(candidates, cand)
|
|
@@ -529,7 +550,7 @@ def _finalize(candidates, img_area: float, refine: bool = True, img_shape=None):
|
|
|
529
550
|
return None
|
|
530
551
|
|
|
531
552
|
|
|
532
|
-
def detect_tone(gray: np.ndarray, tone=None):
|
|
553
|
+
def detect_tone(gray: np.ndarray, tone=None, click=None):
|
|
533
554
|
"""Tone-band segmentation. Assumes the screen sits in a narrow tone band."""
|
|
534
555
|
h, w = gray.shape[:2]
|
|
535
556
|
img_area = float(h * w)
|
|
@@ -560,7 +581,7 @@ def detect_tone(gray: np.ndarray, tone=None):
|
|
|
560
581
|
score *= (16.0 / (hi - lo + 1)) ** 0.25
|
|
561
582
|
candidates.append((score, order_quad(quad), contour, (lo, hi)))
|
|
562
583
|
|
|
563
|
-
res = _finalize(candidates, img_area, img_shape=gray.shape[:2])
|
|
584
|
+
res = _finalize(candidates, img_area, img_shape=gray.shape[:2], click=click)
|
|
564
585
|
if res is None:
|
|
565
586
|
return None
|
|
566
587
|
band = res.pop("_tag")
|
|
@@ -569,7 +590,7 @@ def detect_tone(gray: np.ndarray, tone=None):
|
|
|
569
590
|
return res
|
|
570
591
|
|
|
571
592
|
|
|
572
|
-
def detect_edges(gray: np.ndarray):
|
|
593
|
+
def detect_edges(gray: np.ndarray, click=None):
|
|
573
594
|
"""Canny-and-quad detection — the document-scanner path.
|
|
574
595
|
|
|
575
596
|
Tone banding assumes a near-uniform screen, which breaks the moment the
|
|
@@ -608,7 +629,7 @@ def detect_edges(gray: np.ndarray):
|
|
|
608
629
|
if score > 0 and quad is not None:
|
|
609
630
|
candidates.append((score, order_quad(quad), c, (lo, hi)))
|
|
610
631
|
|
|
611
|
-
res = _finalize(candidates, img_area, refine=False, img_shape=gray.shape[:2])
|
|
632
|
+
res = _finalize(candidates, img_area, refine=False, img_shape=gray.shape[:2], click=click)
|
|
612
633
|
if res is None:
|
|
613
634
|
return None
|
|
614
635
|
thr = res.pop("_tag")
|
|
@@ -617,7 +638,7 @@ def detect_edges(gray: np.ndarray):
|
|
|
617
638
|
return res
|
|
618
639
|
|
|
619
640
|
|
|
620
|
-
def detect_saturation(bgr: np.ndarray):
|
|
641
|
+
def detect_saturation(bgr: np.ndarray, click=None):
|
|
621
642
|
"""Neutral-region segmentation — the third detector, and the only one that
|
|
622
643
|
looks at colour.
|
|
623
644
|
|
|
@@ -654,7 +675,7 @@ def detect_saturation(bgr: np.ndarray):
|
|
|
654
675
|
candidates.append((score * (32.0 / max(thr, 1)) ** 0.25,
|
|
655
676
|
order_quad(quad), contour, thr))
|
|
656
677
|
|
|
657
|
-
res = _finalize(candidates, img_area, img_shape=sat.shape[:2])
|
|
678
|
+
res = _finalize(candidates, img_area, img_shape=sat.shape[:2], click=click)
|
|
658
679
|
if res is None:
|
|
659
680
|
return None
|
|
660
681
|
res.pop("_tag")
|
|
@@ -681,7 +702,7 @@ def has_rounded_corners(result) -> bool:
|
|
|
681
702
|
return spread <= MAX_RADIUS_SPREAD
|
|
682
703
|
|
|
683
704
|
|
|
684
|
-
def detect(gray: np.ndarray, tone=None, method="auto", color=None):
|
|
705
|
+
def detect(gray: np.ndarray, tone=None, method="auto", color=None, click=None):
|
|
685
706
|
"""Run both detectors; arbitrate on how the two quads nest.
|
|
686
707
|
|
|
687
708
|
The two fail on opposite things. Tone banding needs a tonally uniform
|
|
@@ -707,15 +728,15 @@ def detect(gray: np.ndarray, tone=None, method="auto", color=None):
|
|
|
707
728
|
"""
|
|
708
729
|
results = []
|
|
709
730
|
if method in ("auto", "tone"):
|
|
710
|
-
r = detect_tone(gray, tone)
|
|
731
|
+
r = detect_tone(gray, tone, click=click)
|
|
711
732
|
if r:
|
|
712
733
|
results.append(r)
|
|
713
734
|
if method in ("auto", "edge") and tone is None:
|
|
714
|
-
r = detect_edges(gray)
|
|
735
|
+
r = detect_edges(gray, click=click)
|
|
715
736
|
if r:
|
|
716
737
|
results.append(r)
|
|
717
738
|
if method in ("auto", "saturation") and tone is None and color is not None:
|
|
718
|
-
r = detect_saturation(color)
|
|
739
|
+
r = detect_saturation(color, click=click)
|
|
719
740
|
if r:
|
|
720
741
|
results.append(r)
|
|
721
742
|
|
|
@@ -842,7 +863,14 @@ def detect(gray: np.ndarray, tone=None, method="auto", color=None):
|
|
|
842
863
|
# the thing found is shaped like a screen, which is what abstention exists
|
|
843
864
|
# to doubt. Without this a good saturation result on a hard photo would be
|
|
844
865
|
# thrown away for want of a second opinion.
|
|
845
|
-
|
|
866
|
+
# A click IS corroboration, and the strongest kind available: a person
|
|
867
|
+
# looked at the photograph and said "the screen is here". Abstaining for
|
|
868
|
+
# want of a second algorithm after that would be refusing the only evidence
|
|
869
|
+
# that outranks the algorithms. Gross disagreement between two credible
|
|
870
|
+
# peers still counts -- that says the click landed somewhere ambiguous, and
|
|
871
|
+
# is worth reporting -- but being alone no longer does.
|
|
872
|
+
uncorroborated = bool(click is None
|
|
873
|
+
and not ag.get("agree")
|
|
846
874
|
and not best["corner_radius"]["confident"]
|
|
847
875
|
and not has_rounded_corners(best))
|
|
848
876
|
if gross or uncorroborated:
|
package/scripts/ui.py
CHANGED
|
@@ -144,6 +144,159 @@ class Session:
|
|
|
144
144
|
SESSION: Session = None
|
|
145
145
|
|
|
146
146
|
|
|
147
|
+
# Media a session COPIES rather than produces for keeps. Everything here is
|
|
148
|
+
# either a duplicate of a file the user already has, or something regenerable
|
|
149
|
+
# from the sidecar in seconds. The sidecar and state are not in the list: they
|
|
150
|
+
# are a few hundred bytes and they are the record of what was fitted, with the
|
|
151
|
+
# ORIGINAL paths in them. Keep the recipe, stop keeping the ingredients.
|
|
152
|
+
_RESIDUE_PREFIXES = ("photo-", "screenshot-", "poster-", "frame-")
|
|
153
|
+
# figma-export.png is a copy too -- the agent fetches the frame and drops it
|
|
154
|
+
# here -- and re-exporting is one MCP round trip, so it is residue like the rest.
|
|
155
|
+
_RESIDUE_NAMES = ("preview.png", "figma-export.png")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _sweep_session(d):
|
|
159
|
+
"""Delete a session's copied and derived media. Returns bytes reclaimed.
|
|
160
|
+
|
|
161
|
+
Never touches *.json, and never touches OUT_DIR -- the actual outputs live
|
|
162
|
+
in the project folder and are the point of the whole exercise.
|
|
163
|
+
"""
|
|
164
|
+
freed = 0
|
|
165
|
+
thumbs = os.path.join(d, "thumbs")
|
|
166
|
+
for base, _, files in os.walk(d):
|
|
167
|
+
for f in files:
|
|
168
|
+
keep = f.endswith(".json")
|
|
169
|
+
residue = (f.startswith(_RESIDUE_PREFIXES) or f in _RESIDUE_NAMES
|
|
170
|
+
or base == thumbs)
|
|
171
|
+
if keep or not residue:
|
|
172
|
+
continue
|
|
173
|
+
fp = os.path.join(base, f)
|
|
174
|
+
try:
|
|
175
|
+
freed += os.path.getsize(fp)
|
|
176
|
+
os.remove(fp)
|
|
177
|
+
except OSError:
|
|
178
|
+
pass
|
|
179
|
+
return freed
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _prune_sessions(keep):
|
|
183
|
+
"""Sweep every session but the live one, at launch.
|
|
184
|
+
|
|
185
|
+
Sessions are per-run scratch that nothing reads back, and a session keeps a
|
|
186
|
+
full copy of every uploaded input -- a 21s clip is ~80 MB. Measured before
|
|
187
|
+
this existed: 492 MB across 90 directories in six days, 64% of it duplicates
|
|
188
|
+
of files the user already had, and nothing ever deleted any of it.
|
|
189
|
+
|
|
190
|
+
A file chosen by PATH (the recent list, or typing one) was never copied --
|
|
191
|
+
/api/use records the path and reads through it. Only a drag-drop or a browse
|
|
192
|
+
has to be copied, because the browser hands over bytes and will not say
|
|
193
|
+
where they came from. So this is the other half of the same policy: what
|
|
194
|
+
cannot avoid being copied does not outlive the run that needed it.
|
|
195
|
+
"""
|
|
196
|
+
root = os.path.dirname(keep)
|
|
197
|
+
freed = 0
|
|
198
|
+
try:
|
|
199
|
+
names = os.listdir(root)
|
|
200
|
+
except OSError:
|
|
201
|
+
return 0
|
|
202
|
+
for n in names:
|
|
203
|
+
d = os.path.join(root, n)
|
|
204
|
+
if d == keep or not os.path.isdir(d):
|
|
205
|
+
continue
|
|
206
|
+
freed += _sweep_session(d)
|
|
207
|
+
return freed
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _version() -> str:
|
|
211
|
+
"""The shipped version, read from plugin.json rather than restated here.
|
|
212
|
+
|
|
213
|
+
A second place to write a version is a second place for it to go stale --
|
|
214
|
+
which is why check_package.py exists at all, after SKILL.md claimed the
|
|
215
|
+
wrong one for three releases. The page asks the server; the server reads the
|
|
216
|
+
manifest it was packaged with. Falls back to package.json, then to empty,
|
|
217
|
+
because a missing badge is a far better failure than a confidently wrong
|
|
218
|
+
one: a wrong version in a bug report costs more than no version.
|
|
219
|
+
"""
|
|
220
|
+
here = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
221
|
+
for rel in (os.path.join(".claude-plugin", "plugin.json"), "package.json"):
|
|
222
|
+
try:
|
|
223
|
+
with open(os.path.join(here, rel)) as f:
|
|
224
|
+
v = json.load(f).get("version")
|
|
225
|
+
if v:
|
|
226
|
+
return str(v)
|
|
227
|
+
except (OSError, ValueError, AttributeError):
|
|
228
|
+
continue
|
|
229
|
+
return ""
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _build_label() -> str:
|
|
233
|
+
"""Where this code came from: "" when installed, "dev <sha>[+]" from a tree.
|
|
234
|
+
|
|
235
|
+
The distinction the badge exists for. A session materialises its own private
|
|
236
|
+
copy of every installed plugin at start and keeps that snapshot for its whole
|
|
237
|
+
life, so the installed copy and the tree you are editing drift apart within
|
|
238
|
+
minutes -- and the symptom is a feature that is "not there", which reads
|
|
239
|
+
exactly like a bug in the feature. That has cost two debugging sessions.
|
|
240
|
+
|
|
241
|
+
`.git` is the discriminator because the packager excludes it: a tree has one,
|
|
242
|
+
an unpacked .plugin never does. The commit and the dirty marker are here
|
|
243
|
+
because on a day with four releases a bare "dev" is not enough to say WHICH
|
|
244
|
+
dev, and a sha without a `+` when the tree is dirty would be a confident lie
|
|
245
|
+
-- the failure mode this badge is supposed to prevent.
|
|
246
|
+
"""
|
|
247
|
+
root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
248
|
+
if not os.path.isdir(os.path.join(root, ".git")):
|
|
249
|
+
return "" # an unpacked .plugin: just the version
|
|
250
|
+
def git(*a):
|
|
251
|
+
return subprocess.run(("git", "-C", root) + a, capture_output=True,
|
|
252
|
+
text=True, timeout=5)
|
|
253
|
+
try:
|
|
254
|
+
r = git("rev-parse", "--short", "HEAD")
|
|
255
|
+
sha = r.stdout.strip() if r.returncode == 0 else ""
|
|
256
|
+
d = git("status", "--porcelain")
|
|
257
|
+
dirty = "+" if (d.returncode == 0 and d.stdout.strip()) else ""
|
|
258
|
+
except (OSError, subprocess.SubprocessError):
|
|
259
|
+
return "dev" # a tree, and that is the part that matters
|
|
260
|
+
return f"dev {sha}{dirty}".strip() if sha else "dev"
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
VERSION = None # resolved once, in main()
|
|
264
|
+
BUILD = "" # ditto -- no per-request subprocess
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _quad(raw):
|
|
268
|
+
"""Four corners of two finite numbers, or a ValueError naming the problem.
|
|
269
|
+
|
|
270
|
+
The compositing routes used to hand whatever arrived straight to the engine,
|
|
271
|
+
so a malformed quad became a failure deep inside a worker thread -- or, on
|
|
272
|
+
the render path, a job that was accepted, started, and then died somewhere
|
|
273
|
+
the user could not see. The shape of the request is the route's business.
|
|
274
|
+
"""
|
|
275
|
+
try:
|
|
276
|
+
pts = [[float(x), float(y)] for x, y in raw]
|
|
277
|
+
except (TypeError, ValueError) as e:
|
|
278
|
+
raise ValueError(f"corners must be four [x, y] pairs ({e})") from e
|
|
279
|
+
if len(pts) != 4:
|
|
280
|
+
raise ValueError(f"corners must be four points, got {len(pts)}")
|
|
281
|
+
if not all(v == v and abs(v) != float("inf") for pt in pts for v in pt):
|
|
282
|
+
raise ValueError("corners contain a non-finite value")
|
|
283
|
+
return pts
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _need_sources():
|
|
287
|
+
"""Both sources chosen, or a clean 400 saying which one is missing.
|
|
288
|
+
|
|
289
|
+
Without this the compositing routes indexed straight into the session and
|
|
290
|
+
handed `None` to `os.path.expanduser`, which raises TypeError -- a type the
|
|
291
|
+
handler does not catch, so the worker thread died and the browser saw the
|
|
292
|
+
connection drop with no status and no message at all. A request that cannot
|
|
293
|
+
be served should be answered, not hung up on.
|
|
294
|
+
"""
|
|
295
|
+
missing = [k for k in ("photo", "screenshot") if not SESSION.state.get(k)]
|
|
296
|
+
if missing:
|
|
297
|
+
raise ValueError("choose a " + " and a ".join(missing) + " first")
|
|
298
|
+
|
|
299
|
+
|
|
147
300
|
def _safe_local_path(p: str) -> str:
|
|
148
301
|
"""Only serve files under the user's home (the UI is local, but still)."""
|
|
149
302
|
p = os.path.realpath(os.path.expanduser(p))
|
|
@@ -237,7 +390,20 @@ RENDER_LOCK = threading.Lock()
|
|
|
237
390
|
|
|
238
391
|
|
|
239
392
|
def _render_worker(photo, video_path, corners, dest, radius_px, gr, grain, preset, fit_frame,
|
|
240
|
-
blend="replace", reflection=None):
|
|
393
|
+
blend="replace", reflection=None, result=None):
|
|
394
|
+
"""Encode the clip, and only if that SUCCEEDS publish what it produced.
|
|
395
|
+
|
|
396
|
+
`result` is the sidecar this render would write. It is handed to the worker
|
|
397
|
+
rather than written by the route, because a sidecar written before the
|
|
398
|
+
encode asserts `saved` for a file that may never exist -- and the sidecar is
|
|
399
|
+
the first thing the bug template asks for, so a misleading one sends the
|
|
400
|
+
next investigation the wrong way.
|
|
401
|
+
|
|
402
|
+
Publication order matters: the sidecar and the session output are written
|
|
403
|
+
BEFORE the state flips to "done". The page polls for "done" and may ask to
|
|
404
|
+
send the file to Claude the moment it sees it, so the artefacts have to be
|
|
405
|
+
in place first or that request races the worker.
|
|
406
|
+
"""
|
|
241
407
|
def progress(done, total):
|
|
242
408
|
with RENDER_LOCK:
|
|
243
409
|
RENDER["done"], RENDER["total"] = done, total
|
|
@@ -248,6 +414,13 @@ def _render_worker(photo, video_path, corners, dest, radius_px, gr, grain, prese
|
|
|
248
414
|
blend=blend,
|
|
249
415
|
reflection=(W.DEFAULT_REFLECTION if reflection is None
|
|
250
416
|
else reflection))
|
|
417
|
+
if result is not None:
|
|
418
|
+
_write_json_atomic(SESSION.result_path, {**result, "saved": time.time()})
|
|
419
|
+
# The still path has always done this (see /api/save); the render path
|
|
420
|
+
# never did, so /api/import -- which reads SESSION.state["output"] --
|
|
421
|
+
# either found nothing or, worse, silently handed over the PREVIOUS
|
|
422
|
+
# still image after a successful render.
|
|
423
|
+
SESSION.update(output=dest)
|
|
251
424
|
with RENDER_LOCK:
|
|
252
425
|
RENDER.update(state="done", output=dest, info=info,
|
|
253
426
|
done=info["frames"], total=info["frames"], message=None)
|
|
@@ -332,7 +505,8 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
332
505
|
return self._file(UI_HTML, "text/html; charset=utf-8")
|
|
333
506
|
if u.path == "/api/state":
|
|
334
507
|
return self._json({**SESSION.state, "session": SESSION.dir, "out_dir": OUT_DIR,
|
|
335
|
-
"home": HOME, "presets": PRESETS
|
|
508
|
+
"home": HOME, "presets": PRESETS, "version": VERSION,
|
|
509
|
+
"build": BUILD})
|
|
336
510
|
if u.path == "/api/recent":
|
|
337
511
|
items = S.scan(days=int(q.get("days", ["14"])[0]), limit=int(q.get("limit", ["40"])[0]))
|
|
338
512
|
for it in items:
|
|
@@ -371,6 +545,14 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
371
545
|
return self._json({"error": "no such route"}, 404)
|
|
372
546
|
except (PermissionError, FileNotFoundError, KeyError, ValueError) as e:
|
|
373
547
|
return self._json({"error": str(e)}, 400)
|
|
548
|
+
except Exception as e: # noqa: BLE001 - the last resort
|
|
549
|
+
# A type nobody enumerated must still produce a RESPONSE. Without
|
|
550
|
+
# this the handler thread dies and the browser sees the connection
|
|
551
|
+
# drop with no status and no message -- indistinguishable from the
|
|
552
|
+
# server being gone, and impossible to report usefully. Twice in one
|
|
553
|
+
# afternoon a bad input did exactly that: a None photo path reaching
|
|
554
|
+
# expanduser, and a truncated clip whose frame read came back empty.
|
|
555
|
+
return self._json({"error": f"{type(e).__name__}: {e}"}, 500)
|
|
374
556
|
|
|
375
557
|
# ---- POST ----
|
|
376
558
|
def do_POST(self):
|
|
@@ -440,16 +622,36 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
440
622
|
return self._json({"path": real, "size": [im.shape[1], im.shape[0]]})
|
|
441
623
|
|
|
442
624
|
if u.path == "/api/detect":
|
|
625
|
+
if not SESSION.state.get("photo"):
|
|
626
|
+
raise ValueError("choose a photo first")
|
|
443
627
|
photo, _ = _read_image(SESSION.state["photo"])
|
|
444
628
|
gray = cv2.cvtColor(photo, cv2.COLOR_BGR2GRAY)
|
|
629
|
+
# An optional seed point, in PHOTO pixels. The detectors already
|
|
630
|
+
# find the screen on these photographs -- 23 of 93 candidates
|
|
631
|
+
# contained the click on the iPad that used to abstain -- they
|
|
632
|
+
# just cannot tell which region is a screen. That is the one
|
|
633
|
+
# question a person answers instantly, so the click filters the
|
|
634
|
+
# candidate list and the existing score ranks what is left.
|
|
635
|
+
click = b.get("click") if isinstance(b, dict) else None
|
|
636
|
+
if click is not None:
|
|
637
|
+
try:
|
|
638
|
+
click = (float(click[0]), float(click[1]))
|
|
639
|
+
except (TypeError, ValueError, IndexError) as e:
|
|
640
|
+
raise ValueError(f"click must be [x, y] in photo pixels ({e})") from e
|
|
641
|
+
h, w = photo.shape[:2]
|
|
642
|
+
if not (0 <= click[0] < w and 0 <= click[1] < h):
|
|
643
|
+
raise ValueError("the click is outside the photograph")
|
|
445
644
|
# `color` gives detect() the saturation detector — devices are
|
|
446
645
|
# neutral, furniture is not, and grayscale throws that away.
|
|
447
|
-
res = D.detect(gray, None, color=photo)
|
|
646
|
+
res = D.detect(gray, None, color=photo, click=click)
|
|
448
647
|
if res is None:
|
|
449
|
-
return self._json({"found": False,
|
|
450
|
-
"message": "
|
|
451
|
-
|
|
452
|
-
|
|
648
|
+
return self._json({"found": False, "clicked": click is not None,
|
|
649
|
+
"message": ("Nothing screen-shaped was found around that "
|
|
650
|
+
"point — try clicking nearer the middle of the "
|
|
651
|
+
"screen." if click is not None else
|
|
652
|
+
"Neither detector could find a screen here "
|
|
653
|
+
"(nothing separable by tone, no screen-shaped "
|
|
654
|
+
"boundary). Place the four corners by hand.")})
|
|
453
655
|
# An abstention is a miss, and must reach the page as one. On
|
|
454
656
|
# 7 Sep 2026 a quad on a table was shown as a checkable guess
|
|
455
657
|
# with two corners off the canvas, which cannot be dragged back
|
|
@@ -489,6 +691,7 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
489
691
|
|
|
490
692
|
if u.path == "/api/render":
|
|
491
693
|
# Video: same fit, same geometry, N frames instead of one.
|
|
694
|
+
_need_sources()
|
|
492
695
|
photo, ppath = _read_image(SESSION.state["photo"])
|
|
493
696
|
spath = _safe_local_path(SESSION.state["screenshot"])
|
|
494
697
|
if not _is_video(spath):
|
|
@@ -496,12 +699,12 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
496
699
|
if not _have_ffmpeg():
|
|
497
700
|
return self._json({"error": "ffmpeg is not installed",
|
|
498
701
|
"needs_ffmpeg": True}, 400)
|
|
702
|
+
# Cheap early out. The flag that actually reserves the render
|
|
703
|
+
# is set further down, once nothing is left that can throw.
|
|
499
704
|
with RENDER_LOCK:
|
|
500
705
|
if RENDER["state"] == "running":
|
|
501
706
|
return self._json({"error": "a render is already running"}, 409)
|
|
502
|
-
|
|
503
|
-
output=None, message=None)
|
|
504
|
-
corners = b["corners"]
|
|
707
|
+
corners = _quad(b["corners"])
|
|
505
708
|
frac = float(b.get("radius_frac") or 0.0)
|
|
506
709
|
fit_frame = int(b.get("fit_frame") if b.get("fit_frame") is not None
|
|
507
710
|
else _fit_frame())
|
|
@@ -529,19 +732,38 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
529
732
|
"corners": corners, "radius_frac": frac, "radius_px": radius_px,
|
|
530
733
|
"device": b.get("device"), "grade": gr, "grain": grain,
|
|
531
734
|
"video": True, "preset": preset, "fit_frame": fit_frame,
|
|
532
|
-
"blend": blend, "reflection": reflection
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
735
|
+
"blend": blend, "reflection": reflection}
|
|
736
|
+
# `state="running"` means "a thread is running", so it is set
|
|
737
|
+
# here -- after every line that can raise, immediately before the
|
|
738
|
+
# thread exists. It used to be set at the top of this route, so a
|
|
739
|
+
# KeyError on corners, a bad radius, an unreadable frame or an
|
|
740
|
+
# unwritable out_dir left the flag stuck ON with no worker to
|
|
741
|
+
# clear it, and every later render answered 409 for the rest of
|
|
742
|
+
# the session. The only recovery was restarting the server, and
|
|
743
|
+
# nothing said so.
|
|
744
|
+
with RENDER_LOCK:
|
|
745
|
+
if RENDER["state"] == "running":
|
|
746
|
+
return self._json({"error": "a render is already running"}, 409)
|
|
747
|
+
RENDER.update(state="running", done=0, total=0,
|
|
748
|
+
output=None, message=None)
|
|
749
|
+
try:
|
|
750
|
+
threading.Thread(target=_render_worker, daemon=True,
|
|
751
|
+
args=(photo, spath, corners, dest, radius_px,
|
|
752
|
+
gr, grain, preset, fit_frame,
|
|
753
|
+
blend, reflection, result)).start()
|
|
754
|
+
except BaseException:
|
|
755
|
+
# If the thread cannot even be created, the flag must not
|
|
756
|
+
# outlive the request.
|
|
757
|
+
with RENDER_LOCK:
|
|
758
|
+
RENDER.update(state="error", message="could not start the render")
|
|
759
|
+
raise
|
|
539
760
|
return self._json({"started": True, "output": dest, "preset": preset})
|
|
540
761
|
|
|
541
762
|
if u.path in ("/api/preview", "/api/save"):
|
|
763
|
+
_need_sources()
|
|
542
764
|
photo, ppath = _read_image(SESSION.state["photo"])
|
|
543
765
|
shot, spath, _meta = _read_source(SESSION.state["screenshot"])
|
|
544
|
-
corners = b["corners"]
|
|
766
|
+
corners = _quad(b["corners"])
|
|
545
767
|
frac = float(b.get("radius_frac") or 0.0)
|
|
546
768
|
radius_px = frac * shot.shape[1]
|
|
547
769
|
# M2 realism pass. Off is a real option, not a fallback: a flat
|
|
@@ -597,6 +819,14 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
597
819
|
return self._json({"error": str(e)}, 409)
|
|
598
820
|
except (PermissionError, FileNotFoundError, KeyError, ValueError) as e:
|
|
599
821
|
return self._json({"error": str(e)}, 400)
|
|
822
|
+
except Exception as e: # noqa: BLE001 - the last resort
|
|
823
|
+
# A type nobody enumerated must still produce a RESPONSE. Without
|
|
824
|
+
# this the handler thread dies and the browser sees the connection
|
|
825
|
+
# drop with no status and no message -- indistinguishable from the
|
|
826
|
+
# server being gone, and impossible to report usefully. Twice in one
|
|
827
|
+
# afternoon a bad input did exactly that: a None photo path reaching
|
|
828
|
+
# expanduser, and a truncated clip whose frame read came back empty.
|
|
829
|
+
return self._json({"error": f"{type(e).__name__}: {e}"}, 500)
|
|
600
830
|
|
|
601
831
|
|
|
602
832
|
def free_port():
|
|
@@ -664,7 +894,7 @@ def _publish_current(payload):
|
|
|
664
894
|
|
|
665
895
|
|
|
666
896
|
def main():
|
|
667
|
-
global SESSION, OUT_DIR
|
|
897
|
+
global SESSION, OUT_DIR, VERSION, BUILD
|
|
668
898
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
669
899
|
ap.add_argument("--port", type=int, default=0, help="0 = pick a free port")
|
|
670
900
|
ap.add_argument("--no-open", action="store_true", help="Don't open the browser")
|
|
@@ -682,6 +912,8 @@ def main():
|
|
|
682
912
|
if args.daemon:
|
|
683
913
|
_daemonise(args.log)
|
|
684
914
|
|
|
915
|
+
VERSION = _version()
|
|
916
|
+
BUILD = _build_label()
|
|
685
917
|
if args.out_dir:
|
|
686
918
|
OUT_DIR = os.path.abspath(os.path.expanduser(args.out_dir))
|
|
687
919
|
sdir = args.session or os.path.join(HOME, ".screengraft", "sessions", time.strftime("%Y%m%d-%H%M%S"))
|
|
@@ -689,10 +921,17 @@ def main():
|
|
|
689
921
|
port = args.port or free_port()
|
|
690
922
|
url = f"http://127.0.0.1:{port}/"
|
|
691
923
|
srv = ThreadingHTTPServer(("127.0.0.1", port), Handler)
|
|
924
|
+
freed = _prune_sessions(sdir)
|
|
925
|
+
# The live session's own copies go when this process does. Registered before
|
|
926
|
+
# serve_forever, and the SIGTERM handler exits via sys.exit so atexit runs --
|
|
927
|
+
# scripts/stop.sh sends SIGTERM for exactly this reason. A kill -9 cannot be
|
|
928
|
+
# caught, which is what the launch-time sweep above is for.
|
|
929
|
+
atexit.register(lambda: _sweep_session(sdir))
|
|
692
930
|
_publish_current({"session": sdir, "url": url, "pid": os.getpid(),
|
|
693
931
|
"out_dir": OUT_DIR, "started": time.time()})
|
|
694
932
|
print(json.dumps({"url": url, "session": sdir, "job": SESSION.job_path,
|
|
695
|
-
"result": SESSION.result_path, "out_dir": OUT_DIR
|
|
933
|
+
"result": SESSION.result_path, "out_dir": OUT_DIR,
|
|
934
|
+
"reclaimed_mb": round(freed / 1e6, 1)}), flush=True)
|
|
696
935
|
if not args.no_open:
|
|
697
936
|
opener = "open" if sys.platform == "darwin" else "xdg-open"
|
|
698
937
|
threading.Timer(0.3, lambda: subprocess.Popen([opener, url])).start()
|
|
@@ -5,7 +5,7 @@ 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.24):** 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 — 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
10
|
The geometry is exact (`warp.py`); the detection is advisory (`detect.py`) and the human corrects it.
|
|
11
11
|
|
package/ui/index.html
CHANGED
|
@@ -328,7 +328,11 @@
|
|
|
328
328
|
.scroller{overflow:auto;background:var(--well);display:grid;
|
|
329
329
|
min-height:0;min-width:0;
|
|
330
330
|
box-shadow:inset 0 1px 3px rgba(0,0,0,.5)}
|
|
331
|
-
|
|
331
|
+
/* tabindex -1, not 0: the canvas is not a tab stop — it takes focus only when
|
|
332
|
+
you actually click it, which is what makes space mean PAN rather than
|
|
333
|
+
re-pressing whichever rail button you last used. No ring, because the
|
|
334
|
+
focus is a side effect of clicking, never of tabbing. */
|
|
335
|
+
canvas#c{display:block;touch-action:none;cursor:crosshair;margin:auto;outline:none}
|
|
332
336
|
|
|
333
337
|
/* Corner-jump bar floating over the canvas (Figma 16:121). position:sticky
|
|
334
338
|
inside the scroller would drift with pan; fixed-to-the-pane absolute
|
|
@@ -561,6 +565,34 @@
|
|
|
561
565
|
pixel to the band's height: 16+28+8+130+16 = 198 exactly. */
|
|
562
566
|
box-shadow:inset 0 1px 0 var(--line), inset 0 2px 0 rgba(255,255,255,.04)}
|
|
563
567
|
.dock.hidden{display:none}
|
|
568
|
+
/* Version badge. Bottom-right, fixed, and deliberately the quietest thing on
|
|
569
|
+
screen: --faint is the captions token (measured 3.50:1, the 1.4.3 floor for
|
|
570
|
+
incidental text), 11px with the same tracking as every other label.
|
|
571
|
+
pointer-events:none so it can never eat a click meant for what is under it,
|
|
572
|
+
and it is under no circumstances the accent -- that means "the next action"
|
|
573
|
+
and this is not an action at all.
|
|
574
|
+
Sits in the dock's own 16px padding band rather than over the rectified
|
|
575
|
+
strip: the work surface keeps zero decoration, which is the one rule the
|
|
576
|
+
canvas has. */
|
|
577
|
+
.ver{position:fixed;right:var(--s3);bottom:2px;z-index:5;pointer-events:none;
|
|
578
|
+
font-size:11px;letter-spacing:.06em;color:var(--faint);
|
|
579
|
+
font-variant-numeric:tabular-nums}
|
|
580
|
+
/* A dev build steps up from --faint to --mute (5.86:1). Not the accent, which
|
|
581
|
+
means "the next action" -- this is a fact about what you are running, not
|
|
582
|
+
something to do. Brighter than the plain version because "you are not
|
|
583
|
+
testing what you think you are testing" is worth noticing. */
|
|
584
|
+
.ver.dev{color:var(--mute)}
|
|
585
|
+
/* Armed: the canvas is waiting for one click. The button holds the accent
|
|
586
|
+
because it IS the next action while armed, which is the accent's one job. */
|
|
587
|
+
/* Suggested (detection just failed) and armed (waiting for your click).
|
|
588
|
+
Deliberately NOT the accent: Save carries that, and the visual language
|
|
589
|
+
allows exactly one accent-filled control on screen — measured and violated
|
|
590
|
+
by a first version of this, which put a second one here while Save was
|
|
591
|
+
already live. Emphasis without colour is what the raise ladder is for, and
|
|
592
|
+
it is the same treatment a selected chip gets. */
|
|
593
|
+
#pointat.suggest{background:var(--raise-hi);border-color:var(--edge-hi);color:var(--ink)}
|
|
594
|
+
#pointat.armed{background:var(--raise-highest);border-color:var(--edge-hi);color:var(--ink)}
|
|
595
|
+
.scroller.pointing canvas#c{cursor:crosshair}
|
|
564
596
|
/* Figma 8:29 is a 28px header holding 24px buttons — the header does not
|
|
565
597
|
shrink to hug them. */
|
|
566
598
|
.striphead{display:flex;align-items:center;gap:8px;font-size:12px;min-height:28px}
|
|
@@ -719,6 +751,7 @@
|
|
|
719
751
|
<span class="lbl">Fit</span>
|
|
720
752
|
<span class="stepper"><button id="czOut">−</button><button id="czIn">+</button></span>
|
|
721
753
|
<span id="czSt" class="zoomval"></span>
|
|
754
|
+
<button class="info" type="button" aria-label="About canvas navigation"><svg viewBox="0 0 16 16" fill="none" aria-hidden="true"><path fill="currentColor" d="M8.36 1.01C12.06 1.2 15 4.26 15 8l-.01.36C14.8 12.06 11.74 15 8 15l-.36-.01C4.06 14.81 1.19 11.94 1.01 8.36L1 8c0-3.87 3.13-7 7-7l.36.01ZM8 2C4.69 2 2 4.69 2 8c0 3.31 2.69 6 6 6 3.31 0 6-2.69 6-6 0-3.31-2.69-6-6-6Zm.5 8l.5 0v1L7 11v-1h.5V7H7V6h1.5v4ZM7.9 4c.33 0 .6.27.6.6a.6.6 0 1 1-1.21 0c0-.33.28-.6.61-.6Z"/></svg><span class="tip" role="tooltip">Hold ⌘ and scroll to zoom to the pointer — the pixel under the cursor stays under the cursor, which is how you keep a corner in view while you magnify it. Hold space and drag to pan. Scrolling on its own pans too; the Result pane follows either way.</span></button>
|
|
722
755
|
</div>
|
|
723
756
|
<div class="ph-left-2">
|
|
724
757
|
<button id="czFit">Fit</button>
|
|
@@ -728,7 +761,7 @@
|
|
|
728
761
|
<span class="status" id="detSt"></span>
|
|
729
762
|
</div>
|
|
730
763
|
<div class="scroller" id="scroller">
|
|
731
|
-
<canvas id="c"></canvas>
|
|
764
|
+
<canvas id="c" tabindex="-1"></canvas>
|
|
732
765
|
</div>
|
|
733
766
|
<!-- Corner jump. It is a SIBLING of the scroller, not a child: anything
|
|
734
767
|
positioned inside a scroll container scrolls with the content, so
|
|
@@ -740,6 +773,7 @@
|
|
|
740
773
|
<span class="cvbar-actions">
|
|
741
774
|
<span class="stepper"><button class="sm" data-jump="0">TL</button><button class="sm" data-jump="1">TR</button><button class="sm" data-jump="2">BR</button><button class="sm" data-jump="3">BL</button></span>
|
|
742
775
|
<button class="sm" id="redetect">Re-detect</button>
|
|
776
|
+
<button class="sm" id="pointat" title="Click once inside the screen and the detector will use that point. The detectors usually do find the screen — they just cannot tell which region IS one, and that is the part you can answer instantly.">Point at screen</button>
|
|
743
777
|
<button class="sm" id="resetquad" title="Put the four edges back to a rectangle in the middle of the photo. Use this if a corner has ended up off the picture where you cannot grab it.">Reset</button>
|
|
744
778
|
</span>
|
|
745
779
|
</div>
|
|
@@ -796,7 +830,7 @@
|
|
|
796
830
|
<div class="sect-top">
|
|
797
831
|
<div class="sect-left">
|
|
798
832
|
<h4>Realism</h4>
|
|
799
|
-
<button class="info" type="button" aria-label="About realism"><svg viewBox="0 0 16 16" fill="none" aria-hidden="true"><path fill="currentColor" d="M8.36 1.01C12.06 1.2 15 4.26 15 8l-.01.36C14.8 12.06 11.74 15 8 15l-.36-.01C4.06 14.81 1.19 11.94 1.01 8.36L1 8c0-3.87 3.13-7 7-7l.36.01ZM8 2C4.69 2 2 4.69 2 8c0 3.31 2.69 6 6 6 3.31 0 6-2.69 6-6 0-3.31-2.69-6-6-6Zm.5 8l.5 0v1L7 11v-1h.5V7H7V6h1.5v4ZM7.9 4c.33 0 .6.27.6.6a.6.6 0 1 1-1.21 0c0-.33.28-.6.61-.6Z"/></svg><span class="tip" role="tooltip">Matches the screen's white balance and grain to the light around it, so it stops reading as pasted. Off keeps the screenshot's colour exactly — right when the UI's own colour is what you're reviewing
|
|
833
|
+
<button class="info" type="button" aria-label="About realism"><svg viewBox="0 0 16 16" fill="none" aria-hidden="true"><path fill="currentColor" d="M8.36 1.01C12.06 1.2 15 4.26 15 8l-.01.36C14.8 12.06 11.74 15 8 15l-.36-.01C4.06 14.81 1.19 11.94 1.01 8.36L1 8c0-3.87 3.13-7 7-7l.36.01ZM8 2C4.69 2 2 4.69 2 8c0 3.31 2.69 6 6 6 3.31 0 6-2.69 6-6 0-3.31-2.69-6-6-6Zm.5 8l.5 0v1L7 11v-1h.5V7H7V6h1.5v4ZM7.9 4c.33 0 .6.27.6.6a.6.6 0 1 1-1.21 0c0-.33.28-.6.61-.6Z"/></svg><span class="tip" role="tooltip">Matches the screen's white balance and grain to the light around it, so it stops reading as pasted. Off keeps the screenshot's colour exactly — right when the UI's own colour is what you're reviewing. Grain is judged here at the preview's scale: the save carries about twice as much, which is what you get if you view the file at 100%.</span></button>
|
|
800
834
|
</div>
|
|
801
835
|
<button class="switch" id="gradeBtn" role="switch" aria-checked="true" aria-label="Realism">
|
|
802
836
|
<span class="sw-track"><span class="sw-handle"></span><span class="sw-grip"></span></span>
|
|
@@ -839,7 +873,12 @@
|
|
|
839
873
|
</div>
|
|
840
874
|
<div class="sect" id="secKeys" data-on="1">
|
|
841
875
|
<div class="sect-top"><div class="sect-left"><h4>Keys</h4></div></div>
|
|
876
|
+
<!-- Two lines, split by what they act on: the QUAD you are fitting, and
|
|
877
|
+
the VIEW you are fitting it in. Grouping them by that rather than
|
|
878
|
+
by modifier is what makes the list scannable while you are mid-fit
|
|
879
|
+
and looking for one of them. -->
|
|
842
880
|
<p class="hint"><kbd>←↑→↓</kbd> nudge 1px · <kbd>⇧</kbd>+arrow 10px · <kbd>Tab</kbd> next edge</p>
|
|
881
|
+
<p class="hint"><kbd>⌘</kbd>+scroll zoom to pointer · <kbd>Space</kbd>+drag pan · scroll pans</p>
|
|
843
882
|
</div>
|
|
844
883
|
</aside>
|
|
845
884
|
</div>
|
|
@@ -856,6 +895,8 @@
|
|
|
856
895
|
</footer>
|
|
857
896
|
</div>
|
|
858
897
|
|
|
898
|
+
<div class="ver" id="ver"></div>
|
|
899
|
+
|
|
859
900
|
<div class="floatloupe" id="floatLoupe">
|
|
860
901
|
<canvas id="stripF" width="680" height="208"></canvas>
|
|
861
902
|
<div class="cap" id="stripFCap"></div>
|
|
@@ -1364,6 +1405,163 @@ $('#czFit').onclick = () => { computeFit(); setCanvasZoom(fitScale); };
|
|
|
1364
1405
|
$('#cz100').onclick = () => setCanvasZoom(1);
|
|
1365
1406
|
$('#czIn').onclick = () => setCanvasZoom(scale*1.5);
|
|
1366
1407
|
$('#czOut').onclick = () => setCanvasZoom(scale/1.5);
|
|
1408
|
+
/* ===== canvas navigation: zoom to the pointer, space-drag to pan ==========
|
|
1409
|
+
The conventions every graphics tool already uses, so not having them means
|
|
1410
|
+
the muscle memory misfires against a canvas that otherwise behaves like one.
|
|
1411
|
+
|
|
1412
|
+
Panning IS scrolling here — the canvas is sized in real pixels inside
|
|
1413
|
+
#scroller — which is why the Result pane keeps up for free: it already
|
|
1414
|
+
mirrors this element's scroll. */
|
|
1415
|
+
|
|
1416
|
+
/* Zoom about a screen point rather than the canvas centre.
|
|
1417
|
+
|
|
1418
|
+
Measured rather than computed: after the resize, ask the DOM where the
|
|
1419
|
+
pinned image point actually landed and scroll by the difference. The canvas
|
|
1420
|
+
is `margin:auto` in a grid, so while it is smaller than the pane it sits
|
|
1421
|
+
centred with a margin that changes as it grows — arithmetic that predicts
|
|
1422
|
+
the scroll offset has to model that margin, and measuring does not. When the
|
|
1423
|
+
whole image fits there is nothing to scroll and the pin cannot hold, which
|
|
1424
|
+
is correct: nothing is moving off screen. */
|
|
1425
|
+
function zoomAt(z, clientX, clientY){
|
|
1426
|
+
if (!img.naturalWidth) return;
|
|
1427
|
+
const before = pos({clientX, clientY});
|
|
1428
|
+
setCanvasZoom(z);
|
|
1429
|
+
const sc = $('#scroller'), r = cv.getBoundingClientRect();
|
|
1430
|
+
sc.scrollLeft += r.left + before[0] * scale * (r.width / cv.width) - clientX;
|
|
1431
|
+
sc.scrollTop += r.top + before[1] * scale * (r.height / cv.height) - clientY;
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
/* A trackpad pinch arrives as a wheel event with ctrlKey synthesised true, and
|
|
1435
|
+
a real cmd+scroll arrives with metaKey — both mean "zoom", so both are taken.
|
|
1436
|
+
The listener MUST be {passive:false} or preventDefault is ignored and the
|
|
1437
|
+
browser zooms the whole page instead of the picture. deltaMode is normalised
|
|
1438
|
+
because a wheel mouse reports lines (1) or pages (2), not pixels. */
|
|
1439
|
+
$('#scroller').addEventListener('wheel', e => {
|
|
1440
|
+
if (!(e.metaKey || e.ctrlKey)) return; // plain scroll still pans
|
|
1441
|
+
e.preventDefault();
|
|
1442
|
+
const dy = e.deltaY * (e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? 400 : 1);
|
|
1443
|
+
zoomAt(scale * Math.exp(-dy * 0.0025), e.clientX, e.clientY);
|
|
1444
|
+
}, {passive: false});
|
|
1445
|
+
|
|
1446
|
+
/* Space-drag. Space is ALSO how a keyboard user activates the focused button
|
|
1447
|
+
or switch, so it is only a pan modifier when focus is not on a control —
|
|
1448
|
+
otherwise adding this gesture would quietly break every rail control for
|
|
1449
|
+
anyone not using a mouse. */
|
|
1450
|
+
let spaceHeld = false, panning = null;
|
|
1451
|
+
const onAControl = () => {
|
|
1452
|
+
const a = document.activeElement;
|
|
1453
|
+
return !!a && a !== document.body && a !== cv &&
|
|
1454
|
+
a.matches('input, textarea, select, button, [role="switch"], [contenteditable]');
|
|
1455
|
+
};
|
|
1456
|
+
function setPanCursor(){
|
|
1457
|
+
cv.style.cursor = panning ? 'grabbing' : (spaceHeld ? 'grab' : '');
|
|
1458
|
+
}
|
|
1459
|
+
addEventListener('keydown', e => {
|
|
1460
|
+
if (e.code !== 'Space' || e.repeat || spaceHeld || onAControl()) return;
|
|
1461
|
+
e.preventDefault(); // or the page scrolls
|
|
1462
|
+
spaceHeld = true; setPanCursor();
|
|
1463
|
+
});
|
|
1464
|
+
addEventListener('keyup', e => {
|
|
1465
|
+
if (e.code !== 'Space') return;
|
|
1466
|
+
spaceHeld = false; endPan();
|
|
1467
|
+
});
|
|
1468
|
+
// Held space survives a cmd-tab away and would come back stuck on.
|
|
1469
|
+
addEventListener('blur', () => { spaceHeld = false; endPan(); });
|
|
1470
|
+
|
|
1471
|
+
function endPan(){
|
|
1472
|
+
if (panning && cv.hasPointerCapture && panning.id != null){
|
|
1473
|
+
try { $('#scroller').releasePointerCapture(panning.id); } catch (err) {}
|
|
1474
|
+
}
|
|
1475
|
+
panning = null; setPanCursor();
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
/* Capture phase on the SCROLLER, which is an ancestor of the canvas: it runs
|
|
1479
|
+
before the canvas's own handlers, so stopPropagation keeps a pan from ever
|
|
1480
|
+
reaching hitTest and grabbing a corner. Registering capture on the canvas
|
|
1481
|
+
itself would race its onpointerdown property handler instead. */
|
|
1482
|
+
const sc0 = $('#scroller');
|
|
1483
|
+
/* Touching the canvas moves focus to it. Space is both "pan" and "activate the
|
|
1484
|
+
focused control", and after clicking any rail button focus STAYS on that
|
|
1485
|
+
button — so without this, holding space over the picture re-presses Fit or
|
|
1486
|
+
toggles Realism instead of panning. Focus follows the thing you last used,
|
|
1487
|
+
which is also what a keyboard user expects: they never clicked the canvas, so
|
|
1488
|
+
space keeps activating their focused control. */
|
|
1489
|
+
sc0.addEventListener('pointerdown', () => {
|
|
1490
|
+
if (document.activeElement && document.activeElement !== cv) cv.focus({preventScroll: true});
|
|
1491
|
+
}, true);
|
|
1492
|
+
sc0.addEventListener('pointerdown', e => {
|
|
1493
|
+
if (!spaceHeld || e.button !== 0) return;
|
|
1494
|
+
e.preventDefault(); e.stopPropagation();
|
|
1495
|
+
panning = {id: e.pointerId, x: e.clientX, y: e.clientY,
|
|
1496
|
+
sl: sc0.scrollLeft, st: sc0.scrollTop};
|
|
1497
|
+
try { sc0.setPointerCapture(e.pointerId); } catch (err) {}
|
|
1498
|
+
setPanCursor();
|
|
1499
|
+
}, true);
|
|
1500
|
+
sc0.addEventListener('pointermove', e => {
|
|
1501
|
+
if (!panning) { if (spaceHeld) e.stopPropagation(); return; }
|
|
1502
|
+
e.preventDefault(); e.stopPropagation();
|
|
1503
|
+
sc0.scrollLeft = panning.sl - (e.clientX - panning.x);
|
|
1504
|
+
sc0.scrollTop = panning.st - (e.clientY - panning.y);
|
|
1505
|
+
}, true);
|
|
1506
|
+
const stopPan = e => { if (!panning) return; e.stopPropagation(); endPan(); };
|
|
1507
|
+
sc0.addEventListener('pointerup', stopPan, true);
|
|
1508
|
+
sc0.addEventListener('pointercancel', stopPan, true);
|
|
1509
|
+
|
|
1510
|
+
/* ===== point at the screen =====================================================
|
|
1511
|
+
The detectors nearly always FIND the screen; what they cannot do is tell
|
|
1512
|
+
which of the regions they found is one. Measured on the two photographs that
|
|
1513
|
+
defeated every detector: the correct quad was already among the candidates
|
|
1514
|
+
both times. So this sends a single point and the server filters the candidate
|
|
1515
|
+
list by it — no new segmentation, and every existing guard (validation,
|
|
1516
|
+
corner refinement, the radius measurement) still applies to whatever wins. */
|
|
1517
|
+
let pointing = false;
|
|
1518
|
+
function setPointing(on){
|
|
1519
|
+
pointing = on;
|
|
1520
|
+
if (on) suggestPointing(false); // taken up; stop nagging
|
|
1521
|
+
$('#pointat').classList.toggle('armed', on);
|
|
1522
|
+
$('#scroller').classList.toggle('pointing', on);
|
|
1523
|
+
$('#pointat').textContent = on ? 'Click the screen…' : 'Point at screen';
|
|
1524
|
+
if (on){
|
|
1525
|
+
const s = $('#detSt'); s.className = 'status'; s.textContent = 'Click once inside the screen';
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
function suggestPointing(on){ $('#pointat').classList.toggle('suggest', !!on); }
|
|
1529
|
+
$('#pointat').onclick = () => setPointing(!pointing);
|
|
1530
|
+
// Capture phase on the scroller, exactly as the space-pan is, and for the same
|
|
1531
|
+
// reason: this must never reach hitTest and start dragging a corner.
|
|
1532
|
+
$('#scroller').addEventListener('pointerdown', e => {
|
|
1533
|
+
if (!pointing || e.button !== 0 || spaceHeld) return;
|
|
1534
|
+
e.preventDefault(); e.stopPropagation();
|
|
1535
|
+
const p = pos(e);
|
|
1536
|
+
setPointing(false);
|
|
1537
|
+
pointAt(p[0], p[1]);
|
|
1538
|
+
}, true);
|
|
1539
|
+
async function pointAt(x, y){
|
|
1540
|
+
const s = $('#detSt'); s.className = 'status busy'; s.textContent = 'Reading that point…';
|
|
1541
|
+
try {
|
|
1542
|
+
const r = await api('/api/detect', {click: [x, y]});
|
|
1543
|
+
if (r.found){
|
|
1544
|
+
st.corners = r.corners; st.measured = r.corner_radius;
|
|
1545
|
+
s.className = 'status ok';
|
|
1546
|
+
s.textContent = `Screen found from your click (${r.method})`;
|
|
1547
|
+
if (!st.type) setType(r.type_guess || 'phone');
|
|
1548
|
+
if (r.corner_radius && r.corner_radius.confident) setFrac(r.corner_radius.frac_of_width, 'measured from the photo');
|
|
1549
|
+
else { applyPreset(); $('#radSt').textContent = 'Radius not measurable here — using the device preset.'; }
|
|
1550
|
+
markStale();
|
|
1551
|
+
} else {
|
|
1552
|
+
s.className = 'status warn';
|
|
1553
|
+
s.textContent = 'Nothing screen-shaped at that point — try again or place the edges by hand';
|
|
1554
|
+
s.title = r.message || '';
|
|
1555
|
+
}
|
|
1556
|
+
} catch (err) {
|
|
1557
|
+
s.className = 'status warn'; s.textContent = 'Could not read that point: ' + err.message;
|
|
1558
|
+
}
|
|
1559
|
+
pick = {kind:'edge', i:0};
|
|
1560
|
+
draw(); drawStrip(); autoPreview();
|
|
1561
|
+
}
|
|
1562
|
+
// Escape disarms, like every other transient mode on this page.
|
|
1563
|
+
addEventListener('keydown', e => { if (e.key === 'Escape' && pointing) setPointing(false); });
|
|
1564
|
+
|
|
1367
1565
|
document.querySelectorAll('[data-jump]').forEach(b => {
|
|
1368
1566
|
b.onclick = () => { if (!st.corners) return; const i=+b.dataset.jump;
|
|
1369
1567
|
pick = {kind:'corner', i}; setCanvasZoom(Math.max(1, scale), st.corners[i]); drawStrip(); };
|
|
@@ -1384,8 +1582,13 @@ async function detect(){
|
|
|
1384
1582
|
} else {
|
|
1385
1583
|
st.corners = defaultQuad();
|
|
1386
1584
|
s.className='status warn';
|
|
1387
|
-
|
|
1388
|
-
|
|
1585
|
+
// Naming the control is the whole fix. The page knows the detector just
|
|
1586
|
+
// failed, which is exactly the moment "Point at screen" is worth doing —
|
|
1587
|
+
// and the old wording sent people to the slowest remaining option without
|
|
1588
|
+
// mentioning the fast one. Reported 10 Sep: "I haven't noticed new button".
|
|
1589
|
+
s.textContent = r.abstained ? 'Detector abstained — try Point at screen, or place the edges by hand'
|
|
1590
|
+
: 'No screen found — try Point at screen, or place the edges by hand';
|
|
1591
|
+
suggestPointing(true);
|
|
1389
1592
|
// The full reason is long and belongs on hover, not in a 40-character chip.
|
|
1390
1593
|
s.title = r.message || '';
|
|
1391
1594
|
if (!st.type) setType('phone'); applyPreset();
|
|
@@ -1527,6 +1730,8 @@ function moveEdge(C, i, mode, delta){
|
|
|
1527
1730
|
|
|
1528
1731
|
cv.onpointerdown = e => {
|
|
1529
1732
|
const p = pos(e), h = hitTest(p);
|
|
1733
|
+
// You have started placing edges by hand, so stop advertising the shortcut.
|
|
1734
|
+
if (h) suggestPointing(false);
|
|
1530
1735
|
if (!h){ pick = null; draw(); drawStrip(); return; }
|
|
1531
1736
|
drag = {...h, grab: p, start: st.corners.map(c => c.slice())};
|
|
1532
1737
|
pick = {kind:h.kind, i:h.i};
|
|
@@ -1798,6 +2003,21 @@ function syncSlider(){ const r=$('#radius'); r.style.setProperty('--p', (r.value
|
|
|
1798
2003
|
|
|
1799
2004
|
/* ===== preview / compare / save ===== */
|
|
1800
2005
|
let outNat = 0, autoTimer = null;
|
|
2006
|
+
/* What the primary action is CALLED, derived from the source rather than
|
|
2007
|
+
patched. Six places used to set this label and they did not agree: one
|
|
2008
|
+
computed it from st.video, one upgraded 'Save' to 'Render' with no inverse,
|
|
2009
|
+
and the save handler reset it to 'Save' unconditionally -- so a video source
|
|
2010
|
+
could leave the button reading either word regardless of what clicking it
|
|
2011
|
+
would do. On a tool whose whole pitch is that the output is exact, a label
|
|
2012
|
+
that disagrees with its action is not a small thing. */
|
|
2013
|
+
function saveLabel(){ return st.video ? 'Render' : 'Save'; }
|
|
2014
|
+
/* The labels that mean "there is something to do". markStale's 'Preview first'
|
|
2015
|
+
and the in-flight 'Saving…' / progress text must survive a source change, so
|
|
2016
|
+
relabelling only touches a button that is currently offering the action. */
|
|
2017
|
+
function relabelSave(){
|
|
2018
|
+
const b = $('#save');
|
|
2019
|
+
if (b.textContent === 'Save' || b.textContent === 'Render') b.textContent = saveLabel();
|
|
2020
|
+
}
|
|
1801
2021
|
function markStale(){
|
|
1802
2022
|
const b = $('#save'); b.disabled = true; b.textContent = 'Preview first';
|
|
1803
2023
|
// Exactly one lit action at a time — the Figma Button component says the
|
|
@@ -1834,7 +2054,7 @@ async function renderPreview(){
|
|
|
1834
2054
|
im.onload = () => { outNat = im.naturalWidth; im.hidden = false; $('#outEmpty').style.display='none'; syncOut(); };
|
|
1835
2055
|
im.src = `${fileURL(r.path)}&t=${Date.now()}`;
|
|
1836
2056
|
s.textContent = `radius ${r.radius_px}px on the screenshot`;
|
|
1837
|
-
const b = $('#save'); b.disabled = false; b.textContent =
|
|
2057
|
+
const b = $('#save'); b.disabled = false; b.textContent = saveLabel();
|
|
1838
2058
|
b.classList.add('primary'); // Save is now the next action
|
|
1839
2059
|
if (!previewHinted){ previewHinted = true;
|
|
1840
2060
|
toast('info', 'Check the corners in Result, then <b>Save</b> — Save renders at full resolution.');
|
|
@@ -1883,8 +2103,10 @@ function syncVideoUI(){
|
|
|
1883
2103
|
sl.max = Math.max(0, (v.frames || 1) - 1);
|
|
1884
2104
|
sl.value = 0;
|
|
1885
2105
|
$('#vframeLbl').textContent = `0 / ${Math.max(0,(v.frames||1)-1)}`;
|
|
1886
|
-
if (b.textContent === 'Save') b.textContent = 'Render';
|
|
1887
2106
|
}
|
|
2107
|
+
// Outside the `if (v)`: switching a video source back to a still has to put
|
|
2108
|
+
// the label back, and the one-way version above is why it did not.
|
|
2109
|
+
relabelSave();
|
|
1888
2110
|
}
|
|
1889
2111
|
$('#vframe').oninput = () => { $('#vframeLbl').textContent = `${$('#vframe').value} / ${$('#vframe').max}`; };
|
|
1890
2112
|
$('#vframe').onchange = async () => {
|
|
@@ -1906,7 +2128,7 @@ async function renderVideo(){
|
|
|
1906
2128
|
try{
|
|
1907
2129
|
await api('/api/render', {corners: st.corners, radius_frac: radiusValue(), device: st.type,
|
|
1908
2130
|
grade: gradeValue(), reflection: emisValue(), preset, fit_frame: +$('#vframe').value});
|
|
1909
|
-
}catch(e){ toast('err','Could not start the render: '+e.message); b.disabled=false; b.textContent=
|
|
2131
|
+
}catch(e){ toast('err','Could not start the render: '+e.message); b.disabled=false; b.textContent = saveLabel(); return; }
|
|
1910
2132
|
// Poll rather than hold a request open: a clip is hundreds of frames and a
|
|
1911
2133
|
// browser would time the request out long before the render finished.
|
|
1912
2134
|
const tick = setInterval(async () => {
|
|
@@ -1919,13 +2141,13 @@ async function renderVideo(){
|
|
|
1919
2141
|
if (d.total) $('#vframeLbl').textContent = `${d.done} / ${d.total} frames`;
|
|
1920
2142
|
} else if (d.state === 'done'){
|
|
1921
2143
|
clearInterval(tick);
|
|
1922
|
-
b.textContent =
|
|
2144
|
+
b.textContent = saveLabel(); b.disabled = false; b.classList.remove('primary');
|
|
1923
2145
|
$('#vframeLbl').textContent = `${$('#vframe').value} / ${$('#vframe').max}`;
|
|
1924
2146
|
toast('ok', `Rendered <code>${(d.output||'').split('/').pop()}</code> · ${d.done} frames to <code>${prettyDir(st.outDir||'')}</code>`);
|
|
1925
2147
|
const imp = $('#imp'); imp.disabled = false; imp.textContent = 'Send to Claude'; imp.classList.add('primary');
|
|
1926
2148
|
} else if (d.state === 'error'){
|
|
1927
2149
|
clearInterval(tick);
|
|
1928
|
-
b.textContent =
|
|
2150
|
+
b.textContent = saveLabel(); b.disabled = false;
|
|
1929
2151
|
$('#vframeLbl').textContent = `${$('#vframe').value} / ${$('#vframe').max}`;
|
|
1930
2152
|
toast('err', 'Render failed: ' + (d.message || 'unknown'));
|
|
1931
2153
|
}
|
|
@@ -1937,7 +2159,7 @@ $('#save').onclick = async () => {
|
|
|
1937
2159
|
const b = $('#save'); b.textContent = 'Saving…'; b.disabled = true;
|
|
1938
2160
|
try{
|
|
1939
2161
|
const r = await api('/api/save', {corners: st.corners, radius_frac: radiusValue(), device: st.type, grade: gradeValue(), reflection: emisValue()});
|
|
1940
|
-
b.textContent =
|
|
2162
|
+
b.textContent = saveLabel();
|
|
1941
2163
|
// The real destination, not a hardcoded one: --out-dir means saves usually
|
|
1942
2164
|
// land in the project folder now, and telling the user "~/Desktop" when
|
|
1943
2165
|
// they aren't there is how you lose a file.
|
|
@@ -1946,7 +2168,7 @@ $('#save').onclick = async () => {
|
|
|
1946
2168
|
imp.disabled = false; imp.textContent = 'Send to Claude';
|
|
1947
2169
|
// Hand the accent on: the file exists, so sending it is what is next.
|
|
1948
2170
|
imp.classList.add('primary'); b.classList.remove('primary');
|
|
1949
|
-
}catch(e){ toast('err', 'Could not save: ' + e.message); b.textContent=
|
|
2171
|
+
}catch(e){ toast('err', 'Could not save: ' + e.message); b.textContent = saveLabel(); }
|
|
1950
2172
|
finally { b.disabled = false; }
|
|
1951
2173
|
};
|
|
1952
2174
|
|
|
@@ -1985,6 +2207,18 @@ $('#imp').onclick = async () => {
|
|
|
1985
2207
|
const s = await api('/api/state');
|
|
1986
2208
|
st.presets = s.presets; $('#sess').textContent = s.session.split('/').pop();
|
|
1987
2209
|
st.outDir = s.out_dir; st.home = s.home;
|
|
2210
|
+
// Empty stays empty: the server returns "" if it could not read the manifest,
|
|
2211
|
+
// and no badge is better than a wrong one in a bug report.
|
|
2212
|
+
// "v1.2.3" from an installed plugin; "v1.2.3 · dev a1b2c3d+" from a working
|
|
2213
|
+
// tree. (Deliberately a made-up number: a test asserts the CURRENT version
|
|
2214
|
+
// string appears nowhere in this file, and an example using the real one
|
|
2215
|
+
// would trip it -- as it just did.) The suffix is the whole point: a session snapshots its installed copy
|
|
2216
|
+
// at start and keeps it for life, so "the feature is missing" is far more
|
|
2217
|
+
// often the wrong build than a broken feature.
|
|
2218
|
+
if (s.version){
|
|
2219
|
+
$('#ver').textContent = 'v' + s.version + (s.build ? ' · ' + s.build : '');
|
|
2220
|
+
$('#ver').classList.toggle('dev', !!s.build);
|
|
2221
|
+
}
|
|
1988
2222
|
// Say up front where Save will put things. With --out-dir this is usually the
|
|
1989
2223
|
// project folder, and a designer should not have to press Save to find out.
|
|
1990
2224
|
toast('info', `Fit the four edges, then Preview. Saves go to <code>${prettyDir(s.out_dir||'')}</code>.`,
|