screengraft 0.21.0 → 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 +35 -0
- package/package.json +8 -4
- package/scripts/detect.py +40 -12
- package/scripts/ui.py +292 -28
- package/skills/inject-screenshot/SKILL.md +1 -1
- package/ui/index.html +268 -21
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,
|
|
@@ -146,6 +165,22 @@ the background is itself neutral (a pale tiled floor, a plain wall) — you plac
|
|
|
146
165
|
the edges by hand there. And a prototype recording has no motion blur, so a very
|
|
147
166
|
fast scroll will strobe; that is a property of the source, not of the composite.
|
|
148
167
|
|
|
168
|
+
## Changelog
|
|
169
|
+
|
|
170
|
+
Every release is described in [CHANGELOG.md](CHANGELOG.md), with the
|
|
171
|
+
measurements that drove it.
|
|
172
|
+
|
|
173
|
+
## Support
|
|
174
|
+
|
|
175
|
+
Bugs and photographs that defeat the detector belong in
|
|
176
|
+
[Issues](https://github.com/seq000/screengraft/issues) — the bug template asks
|
|
177
|
+
first for the `result.json` sidecar, because it reproduces any composite
|
|
178
|
+
exactly. Ideas and "can it do X" go in
|
|
179
|
+
[Discussions](https://github.com/seq000/screengraft/discussions).
|
|
180
|
+
|
|
181
|
+
If the photograph or the UI is confidential — a client shot, something
|
|
182
|
+
unreleased — email **screengraft@fraczyk.design** instead of posting it.
|
|
183
|
+
|
|
149
184
|
## Contributing
|
|
150
185
|
|
|
151
186
|
See [CONTRIBUTING.md](CONTRIBUTING.md). The short version: there are tests, they
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "screengraft",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Put a UI screenshot or screen recording onto a photographed device screen with the perspective exactly right
|
|
3
|
+
"version": "0.24.1",
|
|
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",
|
|
7
7
|
"device-frame",
|
|
@@ -17,7 +17,10 @@
|
|
|
17
17
|
"figma"
|
|
18
18
|
],
|
|
19
19
|
"homepage": "https://github.com/seq000/screengraft#readme",
|
|
20
|
-
"bugs":
|
|
20
|
+
"bugs": {
|
|
21
|
+
"url": "https://github.com/seq000/screengraft/issues",
|
|
22
|
+
"email": "screengraft@fraczyk.design"
|
|
23
|
+
},
|
|
21
24
|
"repository": {
|
|
22
25
|
"type": "git",
|
|
23
26
|
"url": "git+https://github.com/seq000/screengraft.git"
|
|
@@ -25,7 +28,8 @@
|
|
|
25
28
|
"license": "MIT",
|
|
26
29
|
"author": {
|
|
27
30
|
"name": "Dariusz Fraczyk",
|
|
28
|
-
"url": "https://fraczyk.design"
|
|
31
|
+
"url": "https://fraczyk.design",
|
|
32
|
+
"email": "screengraft@fraczyk.design"
|
|
29
33
|
},
|
|
30
34
|
"type": "commonjs",
|
|
31
35
|
"bin": {
|
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))
|
|
@@ -183,6 +336,13 @@ def _is_video(path: str) -> bool:
|
|
|
183
336
|
return str(path).lower().endswith(VIDEO_EXT)
|
|
184
337
|
|
|
185
338
|
|
|
339
|
+
def _fit_frame() -> int:
|
|
340
|
+
try:
|
|
341
|
+
return int(SESSION.state.get("fit_frame") or 0)
|
|
342
|
+
except (TypeError, ValueError):
|
|
343
|
+
return 0
|
|
344
|
+
|
|
345
|
+
|
|
186
346
|
def _read_source(path: str):
|
|
187
347
|
"""Read the screen source, which may be a still OR a video.
|
|
188
348
|
|
|
@@ -197,13 +357,27 @@ def _read_source(path: str):
|
|
|
197
357
|
im, rp = _read_image(real)
|
|
198
358
|
return im, rp, {"video": False}
|
|
199
359
|
n, fps, vw, vh = W.probe_video(real)
|
|
200
|
-
frame
|
|
360
|
+
# The frame the designer scrubbed to, NOT frame 0. Preview and Save read the
|
|
361
|
+
# source through here, so reading frame 0 unconditionally made the scrubber
|
|
362
|
+
# look decorative: it moved the thumbnail and the composite never changed
|
|
363
|
+
# (reported 9 Sep 2026). The fitted frame is session state for exactly this
|
|
364
|
+
# reason — more than one route needs it.
|
|
365
|
+
frame = W.read_frame_at(real, _fit_frame())
|
|
201
366
|
# Report the encoder's absence HERE, when the clip is chosen, rather than
|
|
202
367
|
# letting the render fail at the end of the job. Someone who installed
|
|
203
368
|
# screengraft before video existed has a working venv with no ffmpeg in it,
|
|
204
369
|
# and nothing else would tell them until they had done all the fitting.
|
|
370
|
+
# A poster for the chip. The chip used to be handed the .mov path directly,
|
|
371
|
+
# and an <img> cannot render a video, so the thumbnail was silently blank for
|
|
372
|
+
# every clip. It is always FRAME 0 and never follows the scrubber: at chip
|
|
373
|
+
# size one frame looks like any other, so redrawing it would be movement
|
|
374
|
+
# without information.
|
|
375
|
+
poster = os.path.join(SESSION.dir,
|
|
376
|
+
"poster-" + os.path.splitext(os.path.basename(real))[0] + ".jpg")
|
|
377
|
+
if not os.path.exists(poster):
|
|
378
|
+
cv2.imwrite(poster, W.read_frame_at(real, 0), [cv2.IMWRITE_JPEG_QUALITY, 82])
|
|
205
379
|
return frame, real, {"video": True, "frames": n, "fps": fps, "size": [vw, vh],
|
|
206
|
-
"ffmpeg": _have_ffmpeg()}
|
|
380
|
+
"ffmpeg": _have_ffmpeg(), "poster": poster}
|
|
207
381
|
|
|
208
382
|
|
|
209
383
|
# Render progress, read by /api/render_status. A ten-second clip is a few
|
|
@@ -216,7 +390,20 @@ RENDER_LOCK = threading.Lock()
|
|
|
216
390
|
|
|
217
391
|
|
|
218
392
|
def _render_worker(photo, video_path, corners, dest, radius_px, gr, grain, preset, fit_frame,
|
|
219
|
-
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
|
+
"""
|
|
220
407
|
def progress(done, total):
|
|
221
408
|
with RENDER_LOCK:
|
|
222
409
|
RENDER["done"], RENDER["total"] = done, total
|
|
@@ -227,6 +414,13 @@ def _render_worker(photo, video_path, corners, dest, radius_px, gr, grain, prese
|
|
|
227
414
|
blend=blend,
|
|
228
415
|
reflection=(W.DEFAULT_REFLECTION if reflection is None
|
|
229
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)
|
|
230
424
|
with RENDER_LOCK:
|
|
231
425
|
RENDER.update(state="done", output=dest, info=info,
|
|
232
426
|
done=info["frames"], total=info["frames"], message=None)
|
|
@@ -311,7 +505,8 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
311
505
|
return self._file(UI_HTML, "text/html; charset=utf-8")
|
|
312
506
|
if u.path == "/api/state":
|
|
313
507
|
return self._json({**SESSION.state, "session": SESSION.dir, "out_dir": OUT_DIR,
|
|
314
|
-
"home": HOME, "presets": PRESETS
|
|
508
|
+
"home": HOME, "presets": PRESETS, "version": VERSION,
|
|
509
|
+
"build": BUILD})
|
|
315
510
|
if u.path == "/api/recent":
|
|
316
511
|
items = S.scan(days=int(q.get("days", ["14"])[0]), limit=int(q.get("limit", ["40"])[0]))
|
|
317
512
|
for it in items:
|
|
@@ -350,6 +545,14 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
350
545
|
return self._json({"error": "no such route"}, 404)
|
|
351
546
|
except (PermissionError, FileNotFoundError, KeyError, ValueError) as e:
|
|
352
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)
|
|
353
556
|
|
|
354
557
|
# ---- POST ----
|
|
355
558
|
def do_POST(self):
|
|
@@ -364,6 +567,7 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
364
567
|
f.write(self._body())
|
|
365
568
|
# A video is only ever a screen source; a photo must be a still.
|
|
366
569
|
if role == "screenshot":
|
|
570
|
+
SESSION.update(fit_frame=0)
|
|
367
571
|
im, real, meta = _read_source(dest)
|
|
368
572
|
else:
|
|
369
573
|
im, real = _read_image(dest)
|
|
@@ -376,6 +580,7 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
376
580
|
if u.path == "/api/use":
|
|
377
581
|
role = b["role"]
|
|
378
582
|
if role == "screenshot":
|
|
583
|
+
SESSION.update(fit_frame=0)
|
|
379
584
|
im, real, meta = _read_source(b["path"])
|
|
380
585
|
else:
|
|
381
586
|
im, real = _read_image(b["path"])
|
|
@@ -417,16 +622,36 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
417
622
|
return self._json({"path": real, "size": [im.shape[1], im.shape[0]]})
|
|
418
623
|
|
|
419
624
|
if u.path == "/api/detect":
|
|
625
|
+
if not SESSION.state.get("photo"):
|
|
626
|
+
raise ValueError("choose a photo first")
|
|
420
627
|
photo, _ = _read_image(SESSION.state["photo"])
|
|
421
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")
|
|
422
644
|
# `color` gives detect() the saturation detector — devices are
|
|
423
645
|
# neutral, furniture is not, and grayscale throws that away.
|
|
424
|
-
res = D.detect(gray, None, color=photo)
|
|
646
|
+
res = D.detect(gray, None, color=photo, click=click)
|
|
425
647
|
if res is None:
|
|
426
|
-
return self._json({"found": False,
|
|
427
|
-
"message": "
|
|
428
|
-
|
|
429
|
-
|
|
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.")})
|
|
430
655
|
# An abstention is a miss, and must reach the page as one. On
|
|
431
656
|
# 7 Sep 2026 a quad on a table was shown as a checkable guess
|
|
432
657
|
# with two corners off the canvas, which cannot be dragged back
|
|
@@ -456,15 +681,17 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
456
681
|
spath = _safe_local_path(SESSION.state["screenshot"])
|
|
457
682
|
if not _is_video(spath):
|
|
458
683
|
return self._json({"error": "the screen source is not a video"}, 400)
|
|
684
|
+
# Records which frame the fit is judged on. Nothing is written:
|
|
685
|
+
# the page re-renders the COMPOSITE from it, and the chip stays
|
|
686
|
+
# on frame 0 deliberately, so a per-step PNG would be disk churn
|
|
687
|
+
# nobody looks at.
|
|
459
688
|
idx = int(b.get("index") or 0)
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
cv2.imwrite(dest, frame, [cv2.IMWRITE_PNG_COMPRESSION, 1])
|
|
463
|
-
return self._json({"path": dest, "index": idx,
|
|
464
|
-
"size": [frame.shape[1], frame.shape[0]]})
|
|
689
|
+
SESSION.update(fit_frame=idx)
|
|
690
|
+
return self._json({"index": idx})
|
|
465
691
|
|
|
466
692
|
if u.path == "/api/render":
|
|
467
693
|
# Video: same fit, same geometry, N frames instead of one.
|
|
694
|
+
_need_sources()
|
|
468
695
|
photo, ppath = _read_image(SESSION.state["photo"])
|
|
469
696
|
spath = _safe_local_path(SESSION.state["screenshot"])
|
|
470
697
|
if not _is_video(spath):
|
|
@@ -472,14 +699,15 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
472
699
|
if not _have_ffmpeg():
|
|
473
700
|
return self._json({"error": "ffmpeg is not installed",
|
|
474
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.
|
|
475
704
|
with RENDER_LOCK:
|
|
476
705
|
if RENDER["state"] == "running":
|
|
477
706
|
return self._json({"error": "a render is already running"}, 409)
|
|
478
|
-
|
|
479
|
-
output=None, message=None)
|
|
480
|
-
corners = b["corners"]
|
|
707
|
+
corners = _quad(b["corners"])
|
|
481
708
|
frac = float(b.get("radius_frac") or 0.0)
|
|
482
|
-
fit_frame = int(b.get("fit_frame")
|
|
709
|
+
fit_frame = int(b.get("fit_frame") if b.get("fit_frame") is not None
|
|
710
|
+
else _fit_frame())
|
|
483
711
|
first = W.read_frame_at(spath, fit_frame)
|
|
484
712
|
radius_px = frac * first.shape[1]
|
|
485
713
|
gr = float(b.get("grade") if b.get("grade") is not None else 0.0)
|
|
@@ -504,19 +732,38 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
504
732
|
"corners": corners, "radius_frac": frac, "radius_px": radius_px,
|
|
505
733
|
"device": b.get("device"), "grade": gr, "grain": grain,
|
|
506
734
|
"video": True, "preset": preset, "fit_frame": fit_frame,
|
|
507
|
-
"blend": blend, "reflection": reflection
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
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
|
|
514
760
|
return self._json({"started": True, "output": dest, "preset": preset})
|
|
515
761
|
|
|
516
762
|
if u.path in ("/api/preview", "/api/save"):
|
|
763
|
+
_need_sources()
|
|
517
764
|
photo, ppath = _read_image(SESSION.state["photo"])
|
|
518
765
|
shot, spath, _meta = _read_source(SESSION.state["screenshot"])
|
|
519
|
-
corners = b["corners"]
|
|
766
|
+
corners = _quad(b["corners"])
|
|
520
767
|
frac = float(b.get("radius_frac") or 0.0)
|
|
521
768
|
radius_px = frac * shot.shape[1]
|
|
522
769
|
# M2 realism pass. Off is a real option, not a fallback: a flat
|
|
@@ -572,6 +819,14 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
572
819
|
return self._json({"error": str(e)}, 409)
|
|
573
820
|
except (PermissionError, FileNotFoundError, KeyError, ValueError) as e:
|
|
574
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)
|
|
575
830
|
|
|
576
831
|
|
|
577
832
|
def free_port():
|
|
@@ -639,7 +894,7 @@ def _publish_current(payload):
|
|
|
639
894
|
|
|
640
895
|
|
|
641
896
|
def main():
|
|
642
|
-
global SESSION, OUT_DIR
|
|
897
|
+
global SESSION, OUT_DIR, VERSION, BUILD
|
|
643
898
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
644
899
|
ap.add_argument("--port", type=int, default=0, help="0 = pick a free port")
|
|
645
900
|
ap.add_argument("--no-open", action="store_true", help="Don't open the browser")
|
|
@@ -657,6 +912,8 @@ def main():
|
|
|
657
912
|
if args.daemon:
|
|
658
913
|
_daemonise(args.log)
|
|
659
914
|
|
|
915
|
+
VERSION = _version()
|
|
916
|
+
BUILD = _build_label()
|
|
660
917
|
if args.out_dir:
|
|
661
918
|
OUT_DIR = os.path.abspath(os.path.expanduser(args.out_dir))
|
|
662
919
|
sdir = args.session or os.path.join(HOME, ".screengraft", "sessions", time.strftime("%Y%m%d-%H%M%S"))
|
|
@@ -664,10 +921,17 @@ def main():
|
|
|
664
921
|
port = args.port or free_port()
|
|
665
922
|
url = f"http://127.0.0.1:{port}/"
|
|
666
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))
|
|
667
930
|
_publish_current({"session": sdir, "url": url, "pid": os.getpid(),
|
|
668
931
|
"out_dir": OUT_DIR, "started": time.time()})
|
|
669
932
|
print(json.dumps({"url": url, "session": sdir, "job": SESSION.job_path,
|
|
670
|
-
"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)
|
|
671
935
|
if not args.no_open:
|
|
672
936
|
opener = "open" if sys.platform == "darwin" else "xdg-open"
|
|
673
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
|
@@ -221,6 +221,12 @@
|
|
|
221
221
|
buttons beside them. Only the corner radii and the shared middle border
|
|
222
222
|
are special. */
|
|
223
223
|
.stepper{display:inline-flex;align-items:center}
|
|
224
|
+
/* A segmented control has to show which segment is on. `.sel` was only ever
|
|
225
|
+
styled for the thumbnail picker (.th.sel), so the web/ProRes pair rendered
|
|
226
|
+
with no selected state at all — reported 9 Sep 2026. Same step as the
|
|
227
|
+
device chips: selected sits on --raise-hi with the brighter border. */
|
|
228
|
+
.stepper button.sel{background:var(--raise-hi);border-color:var(--edge-hi);color:var(--ink)}
|
|
229
|
+
.stepper button.sel:hover{background:var(--raise-highest);border-color:var(--edge-hi)}
|
|
224
230
|
.stepper button{border-radius:0}
|
|
225
231
|
.stepper button:first-child{border-top-left-radius:var(--r-sm);border-bottom-left-radius:var(--r-sm)}
|
|
226
232
|
.stepper button:last-child{border-top-right-radius:var(--r-sm);border-bottom-right-radius:var(--r-sm)}
|
|
@@ -322,7 +328,11 @@
|
|
|
322
328
|
.scroller{overflow:auto;background:var(--well);display:grid;
|
|
323
329
|
min-height:0;min-width:0;
|
|
324
330
|
box-shadow:inset 0 1px 3px rgba(0,0,0,.5)}
|
|
325
|
-
|
|
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}
|
|
326
336
|
|
|
327
337
|
/* Corner-jump bar floating over the canvas (Figma 16:121). position:sticky
|
|
328
338
|
inside the scroller would drift with pan; fixed-to-the-pane absolute
|
|
@@ -555,6 +565,34 @@
|
|
|
555
565
|
pixel to the band's height: 16+28+8+130+16 = 198 exactly. */
|
|
556
566
|
box-shadow:inset 0 1px 0 var(--line), inset 0 2px 0 rgba(255,255,255,.04)}
|
|
557
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}
|
|
558
596
|
/* Figma 8:29 is a 28px header holding 24px buttons — the header does not
|
|
559
597
|
shrink to hug them. */
|
|
560
598
|
.striphead{display:flex;align-items:center;gap:8px;font-size:12px;min-height:28px}
|
|
@@ -713,6 +751,7 @@
|
|
|
713
751
|
<span class="lbl">Fit</span>
|
|
714
752
|
<span class="stepper"><button id="czOut">−</button><button id="czIn">+</button></span>
|
|
715
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>
|
|
716
755
|
</div>
|
|
717
756
|
<div class="ph-left-2">
|
|
718
757
|
<button id="czFit">Fit</button>
|
|
@@ -722,7 +761,7 @@
|
|
|
722
761
|
<span class="status" id="detSt"></span>
|
|
723
762
|
</div>
|
|
724
763
|
<div class="scroller" id="scroller">
|
|
725
|
-
<canvas id="c"></canvas>
|
|
764
|
+
<canvas id="c" tabindex="-1"></canvas>
|
|
726
765
|
</div>
|
|
727
766
|
<!-- Corner jump. It is a SIBLING of the scroller, not a child: anything
|
|
728
767
|
positioned inside a scroll container scrolls with the content, so
|
|
@@ -734,6 +773,7 @@
|
|
|
734
773
|
<span class="cvbar-actions">
|
|
735
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>
|
|
736
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>
|
|
737
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>
|
|
738
778
|
</span>
|
|
739
779
|
</div>
|
|
@@ -758,8 +798,8 @@
|
|
|
758
798
|
<input type="range" id="vframe" min="0" max="0" value="0" step="1"
|
|
759
799
|
aria-label="Which frame of the clip to match the edges on">
|
|
760
800
|
<span class="sm" id="vframeLbl" style="color:var(--mute);min-width:9ch">0</span>
|
|
761
|
-
<span class="stepper" role="
|
|
762
|
-
<button class="sm sel" data-preset="web" title="H.264 at CRF 16 — near-visually-lossless, plays anywhere.">Web</button><button class="sm" data-preset="prores" title="ProRes 422 HQ — bigger file, no chroma subsampling. For a case study or further editing.">ProRes</button>
|
|
801
|
+
<span class="stepper" role="radiogroup" aria-label="Output format">
|
|
802
|
+
<button class="sm sel" data-preset="web" role="radio" aria-checked="true" title="H.264 at CRF 16 — near-visually-lossless, plays anywhere.">Web</button><button class="sm" data-preset="prores" role="radio" aria-checked="false" title="ProRes 422 HQ — bigger file, no chroma subsampling. For a case study or further editing.">ProRes</button>
|
|
763
803
|
</span>
|
|
764
804
|
</span>
|
|
765
805
|
</div>
|
|
@@ -790,7 +830,7 @@
|
|
|
790
830
|
<div class="sect-top">
|
|
791
831
|
<div class="sect-left">
|
|
792
832
|
<h4>Realism</h4>
|
|
793
|
-
<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>
|
|
794
834
|
</div>
|
|
795
835
|
<button class="switch" id="gradeBtn" role="switch" aria-checked="true" aria-label="Realism">
|
|
796
836
|
<span class="sw-track"><span class="sw-handle"></span><span class="sw-grip"></span></span>
|
|
@@ -833,7 +873,12 @@
|
|
|
833
873
|
</div>
|
|
834
874
|
<div class="sect" id="secKeys" data-on="1">
|
|
835
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. -->
|
|
836
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>
|
|
837
882
|
</div>
|
|
838
883
|
</aside>
|
|
839
884
|
</div>
|
|
@@ -850,6 +895,8 @@
|
|
|
850
895
|
</footer>
|
|
851
896
|
</div>
|
|
852
897
|
|
|
898
|
+
<div class="ver" id="ver"></div>
|
|
899
|
+
|
|
853
900
|
<div class="floatloupe" id="floatLoupe">
|
|
854
901
|
<canvas id="stripF" width="680" height="208"></canvas>
|
|
855
902
|
<div class="cap" id="stripFCap"></div>
|
|
@@ -1147,12 +1194,14 @@ function setChosen(role, path, size, el, meta){
|
|
|
1147
1194
|
const n = role==='photo'?1:2;
|
|
1148
1195
|
document.querySelectorAll('#recent'+n+' .th').forEach(x=>x.classList.remove('sel'));
|
|
1149
1196
|
if (el) el.classList.add('sel');
|
|
1150
|
-
|
|
1197
|
+
// A video cannot be shown by <img>, so a clip gets its poster frame instead.
|
|
1198
|
+
const thumb = (meta && meta.poster) ? meta.poster : path;
|
|
1199
|
+
$('#pv'+n).innerHTML = `<img src="${fileURL(thumb)}" alt="">`;
|
|
1151
1200
|
const chip = $('#chip'+n);
|
|
1152
1201
|
chip.classList.remove('is-empty');
|
|
1153
1202
|
chip.querySelector('.nm').textContent = `${path.split('/').pop()} · ${size[0]}×${size[1]}`
|
|
1154
1203
|
+ (meta && meta.video ? ` · ${meta.frames} frames @ ${Math.round(meta.fps)}fps` : '');
|
|
1155
|
-
$('#sw'+n).style.background = `center/cover no-repeat url("${fileURL(
|
|
1204
|
+
$('#sw'+n).style.background = `center/cover no-repeat url("${fileURL(thumb)}")`;
|
|
1156
1205
|
if (role==='photo'){ st.photo = path; st.corners = null; }
|
|
1157
1206
|
else { st.shot = path; st.video = meta && meta.video ? meta : null; syncVideoUI(); }
|
|
1158
1207
|
closePop();
|
|
@@ -1356,6 +1405,163 @@ $('#czFit').onclick = () => { computeFit(); setCanvasZoom(fitScale); };
|
|
|
1356
1405
|
$('#cz100').onclick = () => setCanvasZoom(1);
|
|
1357
1406
|
$('#czIn').onclick = () => setCanvasZoom(scale*1.5);
|
|
1358
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
|
+
|
|
1359
1565
|
document.querySelectorAll('[data-jump]').forEach(b => {
|
|
1360
1566
|
b.onclick = () => { if (!st.corners) return; const i=+b.dataset.jump;
|
|
1361
1567
|
pick = {kind:'corner', i}; setCanvasZoom(Math.max(1, scale), st.corners[i]); drawStrip(); };
|
|
@@ -1376,8 +1582,13 @@ async function detect(){
|
|
|
1376
1582
|
} else {
|
|
1377
1583
|
st.corners = defaultQuad();
|
|
1378
1584
|
s.className='status warn';
|
|
1379
|
-
|
|
1380
|
-
|
|
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);
|
|
1381
1592
|
// The full reason is long and belongs on hover, not in a 40-character chip.
|
|
1382
1593
|
s.title = r.message || '';
|
|
1383
1594
|
if (!st.type) setType('phone'); applyPreset();
|
|
@@ -1519,6 +1730,8 @@ function moveEdge(C, i, mode, delta){
|
|
|
1519
1730
|
|
|
1520
1731
|
cv.onpointerdown = e => {
|
|
1521
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);
|
|
1522
1735
|
if (!h){ pick = null; draw(); drawStrip(); return; }
|
|
1523
1736
|
drag = {...h, grab: p, start: st.corners.map(c => c.slice())};
|
|
1524
1737
|
pick = {kind:h.kind, i:h.i};
|
|
@@ -1790,6 +2003,21 @@ function syncSlider(){ const r=$('#radius'); r.style.setProperty('--p', (r.value
|
|
|
1790
2003
|
|
|
1791
2004
|
/* ===== preview / compare / save ===== */
|
|
1792
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
|
+
}
|
|
1793
2021
|
function markStale(){
|
|
1794
2022
|
const b = $('#save'); b.disabled = true; b.textContent = 'Preview first';
|
|
1795
2023
|
// Exactly one lit action at a time — the Figma Button component says the
|
|
@@ -1826,7 +2054,7 @@ async function renderPreview(){
|
|
|
1826
2054
|
im.onload = () => { outNat = im.naturalWidth; im.hidden = false; $('#outEmpty').style.display='none'; syncOut(); };
|
|
1827
2055
|
im.src = `${fileURL(r.path)}&t=${Date.now()}`;
|
|
1828
2056
|
s.textContent = `radius ${r.radius_px}px on the screenshot`;
|
|
1829
|
-
const b = $('#save'); b.disabled = false; b.textContent =
|
|
2057
|
+
const b = $('#save'); b.disabled = false; b.textContent = saveLabel();
|
|
1830
2058
|
b.classList.add('primary'); // Save is now the next action
|
|
1831
2059
|
if (!previewHinted){ previewHinted = true;
|
|
1832
2060
|
toast('info', 'Check the corners in Result, then <b>Save</b> — Save renders at full resolution.');
|
|
@@ -1846,7 +2074,11 @@ let preset = 'web';
|
|
|
1846
2074
|
document.querySelectorAll('[data-preset]').forEach(btn => {
|
|
1847
2075
|
btn.onclick = () => {
|
|
1848
2076
|
preset = btn.dataset.preset;
|
|
1849
|
-
document.querySelectorAll('[data-preset]').forEach(o =>
|
|
2077
|
+
document.querySelectorAll('[data-preset]').forEach(o => {
|
|
2078
|
+
const on = o === btn;
|
|
2079
|
+
o.classList.toggle('sel', on);
|
|
2080
|
+
o.setAttribute('aria-checked', on ? 'true' : 'false');
|
|
2081
|
+
});
|
|
1850
2082
|
};
|
|
1851
2083
|
});
|
|
1852
2084
|
|
|
@@ -1871,18 +2103,21 @@ function syncVideoUI(){
|
|
|
1871
2103
|
sl.max = Math.max(0, (v.frames || 1) - 1);
|
|
1872
2104
|
sl.value = 0;
|
|
1873
2105
|
$('#vframeLbl').textContent = `0 / ${Math.max(0,(v.frames||1)-1)}`;
|
|
1874
|
-
if (b.textContent === 'Save') b.textContent = 'Render';
|
|
1875
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();
|
|
1876
2110
|
}
|
|
1877
2111
|
$('#vframe').oninput = () => { $('#vframeLbl').textContent = `${$('#vframe').value} / ${$('#vframe').max}`; };
|
|
1878
2112
|
$('#vframe').onchange = async () => {
|
|
1879
2113
|
// Swapping the poster frame re-runs preview through the same path a still
|
|
1880
2114
|
// uses, so what you match the edges against is the frame you chose.
|
|
1881
2115
|
try{
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
2116
|
+
await api('/api/frame', {index: +$('#vframe').value});
|
|
2117
|
+
// Only the composite changes. The chip keeps frame 0 on purpose — at that
|
|
2118
|
+
// size one frame looks like any other, so updating it would be movement
|
|
2119
|
+
// without meaning.
|
|
2120
|
+
if (st.corners) renderPreview();
|
|
1886
2121
|
}catch(e){ toast('err', 'Could not read that frame: ' + e.message); }
|
|
1887
2122
|
};
|
|
1888
2123
|
|
|
@@ -1893,7 +2128,7 @@ async function renderVideo(){
|
|
|
1893
2128
|
try{
|
|
1894
2129
|
await api('/api/render', {corners: st.corners, radius_frac: radiusValue(), device: st.type,
|
|
1895
2130
|
grade: gradeValue(), reflection: emisValue(), preset, fit_frame: +$('#vframe').value});
|
|
1896
|
-
}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; }
|
|
1897
2132
|
// Poll rather than hold a request open: a clip is hundreds of frames and a
|
|
1898
2133
|
// browser would time the request out long before the render finished.
|
|
1899
2134
|
const tick = setInterval(async () => {
|
|
@@ -1906,13 +2141,13 @@ async function renderVideo(){
|
|
|
1906
2141
|
if (d.total) $('#vframeLbl').textContent = `${d.done} / ${d.total} frames`;
|
|
1907
2142
|
} else if (d.state === 'done'){
|
|
1908
2143
|
clearInterval(tick);
|
|
1909
|
-
b.textContent =
|
|
2144
|
+
b.textContent = saveLabel(); b.disabled = false; b.classList.remove('primary');
|
|
1910
2145
|
$('#vframeLbl').textContent = `${$('#vframe').value} / ${$('#vframe').max}`;
|
|
1911
2146
|
toast('ok', `Rendered <code>${(d.output||'').split('/').pop()}</code> · ${d.done} frames to <code>${prettyDir(st.outDir||'')}</code>`);
|
|
1912
2147
|
const imp = $('#imp'); imp.disabled = false; imp.textContent = 'Send to Claude'; imp.classList.add('primary');
|
|
1913
2148
|
} else if (d.state === 'error'){
|
|
1914
2149
|
clearInterval(tick);
|
|
1915
|
-
b.textContent =
|
|
2150
|
+
b.textContent = saveLabel(); b.disabled = false;
|
|
1916
2151
|
$('#vframeLbl').textContent = `${$('#vframe').value} / ${$('#vframe').max}`;
|
|
1917
2152
|
toast('err', 'Render failed: ' + (d.message || 'unknown'));
|
|
1918
2153
|
}
|
|
@@ -1924,7 +2159,7 @@ $('#save').onclick = async () => {
|
|
|
1924
2159
|
const b = $('#save'); b.textContent = 'Saving…'; b.disabled = true;
|
|
1925
2160
|
try{
|
|
1926
2161
|
const r = await api('/api/save', {corners: st.corners, radius_frac: radiusValue(), device: st.type, grade: gradeValue(), reflection: emisValue()});
|
|
1927
|
-
b.textContent =
|
|
2162
|
+
b.textContent = saveLabel();
|
|
1928
2163
|
// The real destination, not a hardcoded one: --out-dir means saves usually
|
|
1929
2164
|
// land in the project folder now, and telling the user "~/Desktop" when
|
|
1930
2165
|
// they aren't there is how you lose a file.
|
|
@@ -1933,7 +2168,7 @@ $('#save').onclick = async () => {
|
|
|
1933
2168
|
imp.disabled = false; imp.textContent = 'Send to Claude';
|
|
1934
2169
|
// Hand the accent on: the file exists, so sending it is what is next.
|
|
1935
2170
|
imp.classList.add('primary'); b.classList.remove('primary');
|
|
1936
|
-
}catch(e){ toast('err', 'Could not save: ' + e.message); b.textContent=
|
|
2171
|
+
}catch(e){ toast('err', 'Could not save: ' + e.message); b.textContent = saveLabel(); }
|
|
1937
2172
|
finally { b.disabled = false; }
|
|
1938
2173
|
};
|
|
1939
2174
|
|
|
@@ -1972,6 +2207,18 @@ $('#imp').onclick = async () => {
|
|
|
1972
2207
|
const s = await api('/api/state');
|
|
1973
2208
|
st.presets = s.presets; $('#sess').textContent = s.session.split('/').pop();
|
|
1974
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
|
+
}
|
|
1975
2222
|
// Say up front where Save will put things. With --out-dir this is usually the
|
|
1976
2223
|
// project folder, and a designer should not have to press Save to find out.
|
|
1977
2224
|
toast('info', `Fit the four edges, then Preview. Saves go to <code>${prettyDir(s.out_dir||'')}</code>.`,
|