screengraft 0.47.0 → 0.49.0
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 +3 -0
- package/package.json +1 -1
- package/scripts/dev-root.sh +23 -0
- package/scripts/launch.sh +20 -0
- package/skills/inject-screenshot/SKILL.md +4 -1
- package/ui/index.html +70 -17
package/README.md
CHANGED
|
@@ -232,6 +232,9 @@ See [CONTRIBUTING.md](CONTRIBUTING.md). The short version: there are tests, they
|
|
|
232
232
|
run in CI, and a change to the compositing engine needs a measurement, not an
|
|
233
233
|
opinion.
|
|
234
234
|
|
|
235
|
+
|
|
236
|
+
**Testing a change without reinstalling.** The desktop app snapshots an installed plugin per session and only refreshes it after *Check for updates*, so release → check → new session is a slow loop. Instead, run `scripts/dev-root.sh` once in your checkout: it writes `~/.screengraft/dev-root`, and from then on every session's `launch.sh` — including the installed copy's — runs the UI from your tree. The badge reads `dev <sha>+` while it does. `scripts/dev-root.sh off` restores the installed copy. Only `SKILL.md` and `mcp/server.py` changes still need a real update, because the app loads those itself.
|
|
237
|
+
|
|
235
238
|
## Licence
|
|
236
239
|
|
|
237
240
|
MIT. The bundled Mona Sans subset is SIL OFL — see `ui/fonts/OFL.txt`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "screengraft",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.49.0",
|
|
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",
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
#!/usr/bin/env zsh
|
|
2
|
+
# Point the installed plugin at a checkout, or stop doing so.
|
|
3
|
+
#
|
|
4
|
+
# scripts/dev-root.sh -> use THIS checkout (the one the script is in)
|
|
5
|
+
# scripts/dev-root.sh /some/tree -> use that one
|
|
6
|
+
# scripts/dev-root.sh off -> back to the installed copy
|
|
7
|
+
# scripts/dev-root.sh status -> say which
|
|
8
|
+
#
|
|
9
|
+
# Writes ~/.screengraft/dev-root, which launch.sh reads. See launch.sh for why.
|
|
10
|
+
set -euo pipefail
|
|
11
|
+
FILE="$HOME/.screengraft/dev-root"
|
|
12
|
+
case "${1:-}" in
|
|
13
|
+
off) rm -f "$FILE"; echo "dev root off — launch.sh uses the installed copy" ;;
|
|
14
|
+
status) if [ -s "$FILE" ]; then echo "dev root: $(head -n1 "$FILE")"; else echo "dev root off"; fi ;;
|
|
15
|
+
*)
|
|
16
|
+
TREE="${1:-$(cd "$(dirname "$0")/.." && pwd)}"
|
|
17
|
+
TREE="$(cd "$TREE" && pwd)"
|
|
18
|
+
[ -f "$TREE/scripts/ui.py" ] || { echo "error: $TREE has no scripts/ui.py" >&2; exit 2; }
|
|
19
|
+
mkdir -p "$(dirname "$FILE")"
|
|
20
|
+
printf '%s\n' "$TREE" > "$FILE"
|
|
21
|
+
echo "dev root: $TREE — every session's launch.sh now runs this tree; the badge will read 'dev <sha>'"
|
|
22
|
+
;;
|
|
23
|
+
esac
|
package/scripts/launch.sh
CHANGED
|
@@ -18,6 +18,26 @@
|
|
|
18
18
|
# the same reason a Terminal window did, and there is no window to clean up.
|
|
19
19
|
set -euo pipefail
|
|
20
20
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
|
21
|
+
# Dev root: run the UI from a checkout instead of the installed copy.
|
|
22
|
+
# `~/.screengraft/dev-root` holds one line, the path to a screengraft tree.
|
|
23
|
+
# The desktop app snapshots every installed plugin per session and only
|
|
24
|
+
# refreshes that snapshot after "Check for updates", so testing a change used
|
|
25
|
+
# to mean release -> check -> new session. With the file in place any session,
|
|
26
|
+
# including one already open, runs whatever is in the tree right now; the
|
|
27
|
+
# badge reads "dev <sha>+" so it can never pass for a release. Opt-in by the
|
|
28
|
+
# file's existence; a path that is not a tree is ignored with a warning, so
|
|
29
|
+
# a stale file cannot silently break a launch. `scripts/dev-root.sh` writes it.
|
|
30
|
+
DEV_ROOT_FILE="$HOME/.screengraft/dev-root"
|
|
31
|
+
if [ -s "$DEV_ROOT_FILE" ]; then
|
|
32
|
+
DEV_ROOT="$(head -n1 "$DEV_ROOT_FILE")"
|
|
33
|
+
DEV_ROOT="${DEV_ROOT/#\~/$HOME}"
|
|
34
|
+
if [ -f "$DEV_ROOT/scripts/ui.py" ]; then
|
|
35
|
+
ROOT="$DEV_ROOT"
|
|
36
|
+
echo "dev root: running from $ROOT (remove $DEV_ROOT_FILE to use the installed copy)" >&2
|
|
37
|
+
else
|
|
38
|
+
echo "warning: $DEV_ROOT_FILE points at $DEV_ROOT, which has no scripts/ui.py — using the installed copy" >&2
|
|
39
|
+
fi
|
|
40
|
+
fi
|
|
21
41
|
PORT=0
|
|
22
42
|
OUT_DIR=""
|
|
23
43
|
|
|
@@ -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.49):** 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 — or, if this photograph has been fitted before, **the fit it was saved with comes back** as the starting position instead of a detection, recognised by the photo's own pixels so a rename or a drag-drop still match — and every save also writes a **portable `.fit.json` beside the mockup** that can be dropped back onto the page later, which is how a fit survives a re-export, another machine, or someone else's hands — 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. **When a detection is wrong and you want to know why**, ask for the candidate list: `POST /api/detect {"trace": true}` writes `<session>/candidates.json`, or run `python3 scripts/detect.py --photo P --out-corners /tmp/c.json --trace /tmp/t.json` (add `--click X,Y`). Every candidate quad is in there with its score and whether it was accepted, rejected, never reached, or filtered out by the click — which is what separates "the screen was never proposed" from "it was proposed and something else won".
|
|
11
11
|
|
|
@@ -54,6 +54,8 @@ scripts/launch.sh --out-dir "<the user's current project folder>/mockups"
|
|
|
54
54
|
|
|
55
55
|
**Do not launch `ui.py` directly with `nohup ... &` from your own shell.** That was tried on 3 Sep 2026 and the server died silently between turns, twice, losing whatever the user had already entered — the shell session that launches it can be torn down out from under a merely-backgrounded child. `launch.sh` starts it with `--daemon`, which double-forks and calls `setsid`, putting the server in its own session with no controlling terminal; it survives the launching shell and opens no window. It self-checks (waits for startup output, then confirms the server responds) before returning.
|
|
56
56
|
|
|
57
|
+
**If `launch.sh` prints `dev root: running from <path>`**, the UI is running from a source checkout, not from this installed copy — the developer has opted in with `~/.screengraft/dev-root` (see `scripts/dev-root.sh`). Say so in one line, and treat the badge's `dev <sha>+` as expected. Anything the user reports is then about that tree, not about the release.
|
|
58
|
+
|
|
57
59
|
A Terminal.app window was the previous fix for the same problem. It worked but left a dead "[Process completed]" window behind after every session, and closing those from AppleScript proved unreliable — Terminal reports stale ttys for dead windows and leaves zero-tab husks that `close` reports success on. The daemon removes the window entirely, so there is nothing to clean up.
|
|
58
60
|
|
|
59
61
|
It prints one JSON line — `url`, `session`, `job`, `result`, `out_dir` — and opens the browser tab itself. If it exits non-zero, read the error and the log path it names before telling the user anything is ready.
|
|
@@ -140,6 +142,7 @@ The server is a detached daemon with no window. **Stop it with `scripts/stop.sh`
|
|
|
140
142
|
| `scripts/preflight.py` | Dependency report; `--install` builds the venv |
|
|
141
143
|
| `scripts/launch.sh` | Starts `ui.py` as a detached daemon (no window) and verifies it's alive — use this, not `ui.py` directly |
|
|
142
144
|
| `scripts/stop.sh` | Stops the UI with SIGTERM so the session pointer is cleared — use this instead of `pkill` |
|
|
145
|
+
| `scripts/dev-root.sh` | Developer opt-in: point every session's `launch.sh` at a checkout (`on` by default, `off`, `status`). Testing a change then needs no release and no plugin update. |
|
|
143
146
|
| `scripts/ui.py` | Local server + browser UI; calls the two below |
|
|
144
147
|
| `scripts/detect.py` | Advisory screen-quad + corner-radius detection (no ML) |
|
|
145
148
|
| `scripts/warp.py` | The engine: `compose()` and a CLI for scripted use |
|
package/ui/index.html
CHANGED
|
@@ -366,7 +366,16 @@
|
|
|
366
366
|
until it is selected: no border, no shadow, no lift on press. */
|
|
367
367
|
.seg button{height:22px;padding:0 11px;font-size:12px;border-radius:var(--r-sm);
|
|
368
368
|
background:transparent;border:0;box-shadow:none;color:var(--mute);
|
|
369
|
-
font-weight:400
|
|
369
|
+
font-weight:400;display:inline-flex;flex-direction:column;
|
|
370
|
+
align-items:center;justify-content:center}
|
|
371
|
+
/* Each segment is as wide as its label at the SELECTED weight, whichever
|
|
372
|
+
weight it is showing. Without this the control reflowed on every switch:
|
|
373
|
+
600 is wider than 400, so selecting "ProRes" grew that segment, and the
|
|
374
|
+
whole control — left edge included — moved (reported 12 Sep 2026).
|
|
375
|
+
A zero-height ghost of the label at 600 sits under the visible one and
|
|
376
|
+
sets the width; `data-label` carries the text. Not padding. */
|
|
377
|
+
.seg button::after{content:attr(data-label);font-weight:600;height:0;
|
|
378
|
+
overflow:hidden;visibility:hidden;pointer-events:none}
|
|
370
379
|
.seg button:hover{background:transparent;color:var(--ink)}
|
|
371
380
|
.seg button:active{background:transparent;transform:none}
|
|
372
381
|
/* The thumb. Its three surfaces are the Default button's own, which is the
|
|
@@ -406,8 +415,15 @@
|
|
|
406
415
|
.chipwrap .inputchip:not(.is-empty){padding-right:30px}
|
|
407
416
|
.chipclear{position:absolute;right:5px;top:50%;transform:translateY(-50%);
|
|
408
417
|
width:22px;height:22px;padding:0;border-radius:999px;flex:none;
|
|
409
|
-
|
|
410
|
-
background:var(--raise-hi);border:1px solid var(--edge)}
|
|
418
|
+
display:inline-flex;align-items:center;justify-content:center;
|
|
419
|
+
color:var(--ink);background:var(--raise-hi);border:1px solid var(--edge)}
|
|
420
|
+
/* The cross is drawn, not typed. As the × character at 15px it sat
|
|
421
|
+
above centre in its disc -- a glyph carries its own ascent and side
|
|
422
|
+
bearings and no line-height setting centres it in a 22px circle ("X icon
|
|
423
|
+
is not fully centered", 12 Sep 2026). A 10px SVG in a flex
|
|
424
|
+
centre is centred by construction, and it is the same route the toast
|
|
425
|
+
glyphs took in v0.42.0. */
|
|
426
|
+
.chipclear svg{display:block;width:10px;height:10px}
|
|
411
427
|
.chipclear:hover{background:var(--raise-highest);border-color:var(--edge-hi)}
|
|
412
428
|
.chipclear:active{background:var(--raise-mid);transform:translateY(-50%)}
|
|
413
429
|
|
|
@@ -928,9 +944,9 @@
|
|
|
928
944
|
<div class="tb-left">
|
|
929
945
|
<span class="brand">Screengraft</span><span id="sess" hidden></span>
|
|
930
946
|
<div class="tb-chips">
|
|
931
|
-
<span class="chipwrap"><button class="inputchip is-empty" id="chip1"><span class="sw" id="sw1"></span><span class="nm">Choose a photo…</span></button><button class="chipclear" id="clear1" data-role="photo" aria-label="Remove photo" title="Remove the photo and start over. A fit you saved for it comes back when you pick it again." hidden
|
|
947
|
+
<span class="chipwrap"><button class="inputchip is-empty" id="chip1"><span class="sw" id="sw1"></span><span class="nm">Choose a photo…</span></button><button class="chipclear" id="clear1" data-role="photo" aria-label="Remove photo" title="Remove the photo and start over. A fit you saved for it comes back when you pick it again." hidden><svg aria-hidden="true" viewBox="0 0 10 10"><path d="M1.5 1.5l7 7M8.5 1.5l-7 7" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></button></span>
|
|
932
948
|
<span class="tb-arrow">→</span>
|
|
933
|
-
<span class="chipwrap"><button class="inputchip is-empty" id="chip2"><span class="sw" id="sw2"></span><span class="nm">Choose a screenshot…</span></button><button class="chipclear" id="clear2" data-role="screenshot" aria-label="Remove screenshot" title="Remove the screenshot. The fit on the photo is kept." hidden
|
|
949
|
+
<span class="chipwrap"><button class="inputchip is-empty" id="chip2"><span class="sw" id="sw2"></span><span class="nm">Choose a screenshot…</span></button><button class="chipclear" id="clear2" data-role="screenshot" aria-label="Remove screenshot" title="Remove the screenshot. The fit on the photo is kept." hidden><svg aria-hidden="true" viewBox="0 0 10 10"><path d="M1.5 1.5l7 7M8.5 1.5l-7 7" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></button></span>
|
|
934
950
|
</div>
|
|
935
951
|
</div>
|
|
936
952
|
<span class="spacer"></span>
|
|
@@ -945,7 +961,7 @@
|
|
|
945
961
|
the one-accent-on-screen rule is untouched. -->
|
|
946
962
|
<div class="tb-actions">
|
|
947
963
|
<span class="seg" id="fmt" role="radiogroup" aria-label="Output format" hidden>
|
|
948
|
-
<button class="sel" data-preset="web" role="radio" aria-checked="true" title="H.264 at CRF 16 — near-visually-lossless, plays anywhere.">Web</button><button 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>
|
|
964
|
+
<button class="sel" data-preset="web" data-label="Web" role="radio" aria-checked="true" title="H.264 at CRF 16 — near-visually-lossless, plays anywhere.">Web</button><button data-preset="prores" data-label="ProRes" role="radio" aria-checked="false" title="ProRes 422 HQ — bigger file, no chroma subsampling. For a case study or further editing.">ProRes</button>
|
|
949
965
|
</span>
|
|
950
966
|
<button id="save" class="accent" disabled>Preview first</button>
|
|
951
967
|
<button id="imp" class="accent" disabled title="Send the saved file to Claude so it appears in the chat">Send to Claude</button>
|
|
@@ -988,7 +1004,7 @@
|
|
|
988
1004
|
<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>
|
|
989
1005
|
<button class="sm" id="redetect">Re-detect</button>
|
|
990
1006
|
<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>
|
|
991
|
-
<button class="sm" id="rotatequad" title="Turn the screenshot
|
|
1007
|
+
<button class="sm" id="rotatequad" title="Turn the screenshot inside the same four edges. The edges stay where they are; only which one is the top changes. A portrait screenshot fits a phone only two ways, so on a phone this is a half turn; a quarter turn where the screen is near-square or the screenshot fits the other way.">Rotate</button>
|
|
992
1008
|
<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>
|
|
993
1009
|
</span>
|
|
994
1010
|
</div>
|
|
@@ -1521,8 +1537,7 @@ async function clearSource(role){
|
|
|
1521
1537
|
$('#pv'+n).innerHTML = '<span class="empty">Select a recent image</span>';
|
|
1522
1538
|
$('#clear'+n).hidden = true;
|
|
1523
1539
|
document.querySelectorAll('#recent'+n+' .th').forEach(x=>x.classList.remove('sel'));
|
|
1524
|
-
|
|
1525
|
-
showResult('empty');
|
|
1540
|
+
dropResult();
|
|
1526
1541
|
if (role==='photo'){
|
|
1527
1542
|
st.photo = null; st.corners = null; st.remembered = null; st.quadFrom = null;
|
|
1528
1543
|
st.measured = null;
|
|
@@ -1544,13 +1559,27 @@ async function clearSource(role){
|
|
|
1544
1559
|
? 'Screenshot removed — the fit on the photo is kept. Choose another screenshot.'
|
|
1545
1560
|
: 'Screenshot removed.';
|
|
1546
1561
|
}
|
|
1547
|
-
markStale();
|
|
1548
1562
|
// The control that had focus has just hidden itself; without this, focus
|
|
1549
1563
|
// drops to <body> and a keyboard user is nowhere. The empty chip is the
|
|
1550
1564
|
// natural next action.
|
|
1551
1565
|
chip.focus();
|
|
1552
1566
|
}
|
|
1553
1567
|
document.querySelectorAll('.chipclear').forEach(b => { b.onclick = () => clearSource(b.dataset.role); });
|
|
1568
|
+
/* A source has changed, so whatever the Result pane shows was made from
|
|
1569
|
+
something that is no longer chosen. One place for the three things that
|
|
1570
|
+
must happen together: stop the live clip (its <video> still holds the OLD
|
|
1571
|
+
file and would keep playing it), empty the pane, and un-light Save. Before
|
|
1572
|
+
this, swapping a clip for a screenshot — or one photo for another with a
|
|
1573
|
+
fit already in hand — left the previous composite on screen, and the live
|
|
1574
|
+
layer kept the previous clip running, because setChosen() only redrew the
|
|
1575
|
+
canvas and renderPreview() will not take the pane away from a playing clip
|
|
1576
|
+
(reported 12 Sep 2026: "the preview is not being updated"). */
|
|
1577
|
+
function dropResult(){
|
|
1578
|
+
stopLive();
|
|
1579
|
+
showResult('empty');
|
|
1580
|
+
$('#outSt').textContent = '';
|
|
1581
|
+
markStale();
|
|
1582
|
+
}
|
|
1554
1583
|
|
|
1555
1584
|
async function choose(role, path, el){
|
|
1556
1585
|
try{ const r = await api('/api/use', {role, path}); setChosen(role, r.path, r.size, el, r); }
|
|
@@ -1572,6 +1601,10 @@ function setChosen(role, path, size, el, meta){
|
|
|
1572
1601
|
// server sends the original name back as `name`.
|
|
1573
1602
|
const shown = (meta && meta.name) || path.split('/').pop();
|
|
1574
1603
|
chip.querySelector('.nm').textContent = shown;
|
|
1604
|
+
// A different file in either role: the composite on screen is of the old
|
|
1605
|
+
// one. Dropped here, before the state changes, so nothing downstream can
|
|
1606
|
+
// narrate over it; maybeStart() re-renders once the fit is ready.
|
|
1607
|
+
if ((role==='photo' ? st.photo : st.shot) !== path) dropResult();
|
|
1575
1608
|
chip.title = `${homePath(path)}\n${size[0]}×${size[1]}`
|
|
1576
1609
|
+ (meta && meta.video ? ` · ${meta.frames} frames @ ${Math.round(meta.fps)}fps` : '');
|
|
1577
1610
|
$('#sw'+n).style.background = `center/cover no-repeat url("${fileURL(thumb)}")`;
|
|
@@ -1760,7 +1793,7 @@ async function maybeStart(){
|
|
|
1760
1793
|
}
|
|
1761
1794
|
$('#cvbar').hidden = false;
|
|
1762
1795
|
if (st.corners){
|
|
1763
|
-
draw(); drawStrip();
|
|
1796
|
+
draw(); drawStrip(); autoPreview();
|
|
1764
1797
|
// A reload arrives here with the session's corners already in hand, and
|
|
1765
1798
|
// the only thing on the status line is the "now choose the screenshot"
|
|
1766
1799
|
// text from the photo-only step above — a message about a step already
|
|
@@ -2061,10 +2094,22 @@ function turnQuad(){
|
|
|
2061
2094
|
if (pick && pick.kind === 'corner') pick = {kind: 'corner', i: (pick.i + 3) % 4};
|
|
2062
2095
|
else if (pick && pick.kind === 'edge') pick = {kind: 'edge', i: (pick.i + 3) % 4};
|
|
2063
2096
|
}
|
|
2097
|
+
// A quarter turn is only offered where the screenshot still FITS. A portrait
|
|
2098
|
+
// screenshot on a phone fits two ways, top on either short edge; turned onto a
|
|
2099
|
+
// long edge it is mapped anyway — 804px across a 890px edge and 1748px down a
|
|
2100
|
+
// 441px one — and the 4:1 squash also flattens the corner rounding into an
|
|
2101
|
+
// ellipse, which is what "the corners rotate too and no longer fit the frame"
|
|
2102
|
+
// looked like (reported 12 Sep 2026). So Rotate steps to the NEXT
|
|
2103
|
+
// orientation the screenshot fits: a half turn on a phone, a quarter turn on a
|
|
2104
|
+
// near-square screen or when the screenshot's aspect suits the other edges.
|
|
2064
2105
|
function rotateQuad(){
|
|
2065
2106
|
if (!st.corners) return;
|
|
2066
2107
|
turnQuad();
|
|
2108
|
+
let turns = 1;
|
|
2109
|
+
if (!orientationFits()){ turnQuad(); turns = 2; }
|
|
2067
2110
|
draw(); drawStrip(); autoPreview();
|
|
2111
|
+
toast('info', turns === 1 ? 'Turned the screenshot a quarter turn.'
|
|
2112
|
+
: 'Turned the screenshot a half turn — a portrait screenshot fits this screen only two ways.');
|
|
2068
2113
|
}
|
|
2069
2114
|
$('#rotatequad').onclick = rotateQuad;
|
|
2070
2115
|
// The automatic half. Detection orders corners geometrically and cannot know
|
|
@@ -2078,16 +2123,24 @@ $('#rotatequad').onclick = rotateQuad;
|
|
|
2078
2123
|
// this page has it. Near-square quads (under 1.25) are left alone — perspective
|
|
2079
2124
|
// can make either kind of edge the longer one. Returns whether it turned.
|
|
2080
2125
|
const ORIENT_ASPECT = 1.25;
|
|
2081
|
-
|
|
2082
|
-
|
|
2126
|
+
// Does the screenshot fit the quad the way it is ordered now? True when the
|
|
2127
|
+
// quad is near-square (either kind of edge can be the top), when there is no
|
|
2128
|
+
// screenshot yet, or when the top edge is the kind the screenshot's aspect
|
|
2129
|
+
// wants — short for portrait, long for landscape. Shared by the automatic
|
|
2130
|
+
// orientation below and by the Rotate button.
|
|
2131
|
+
function orientationFits(){
|
|
2132
|
+
if (!st.corners || !st.shotSize) return true;
|
|
2083
2133
|
const C = st.corners;
|
|
2084
2134
|
const len = i => Math.hypot(C[(i+1)%4][0]-C[i][0], C[(i+1)%4][1]-C[i][1]);
|
|
2085
2135
|
const a = (len(0) + len(2)) / 2, b = (len(1) + len(3)) / 2; // edges 0/2 vs 1/3
|
|
2086
|
-
if (Math.max(a, b) < ORIENT_ASPECT * Math.max(Math.min(a, b), 1e-6)) return
|
|
2136
|
+
if (Math.max(a, b) < ORIENT_ASPECT * Math.max(Math.min(a, b), 1e-6)) return true;
|
|
2087
2137
|
const shotPortrait = st.shotSize[1] > st.shotSize[0];
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2138
|
+
return (a < b) === shotPortrait;
|
|
2139
|
+
}
|
|
2140
|
+
function orientQuad(){
|
|
2141
|
+
if (!st.corners || !st.shotSize) return false;
|
|
2142
|
+
if (orientationFits()) return false;
|
|
2143
|
+
const C = st.corners;
|
|
2091
2144
|
// Two candidate top edges (1 and 3, the other pair); the higher one in the
|
|
2092
2145
|
// image is the top. Turn once to make edge 1 the top, twice more for edge 3.
|
|
2093
2146
|
const midY = i => (C[i][1] + C[(i+1)%4][1]) / 2;
|