screengraft 0.25.2 → 0.38.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 +35 -0
- package/package.json +1 -1
- package/scripts/detect.py +335 -36
- package/scripts/fitfile.py +140 -0
- package/scripts/fits.py +151 -0
- package/scripts/ui.py +398 -42
- package/scripts/warp.py +155 -7
- package/skills/inject-screenshot/SKILL.md +3 -3
- package/ui/index.html +723 -67
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""The fit as a portable file — written beside the mockup, loaded by dropping it in.
|
|
3
|
+
|
|
4
|
+
`fits.py` already remembers a fit automatically, keyed by the photograph's own
|
|
5
|
+
pixels. That covers "same photo, same machine, later" and nothing else. Three
|
|
6
|
+
things it cannot do, and all three are in the request this exists for:
|
|
7
|
+
|
|
8
|
+
* there is no artefact to find, name, keep beside the project, or send to
|
|
9
|
+
someone;
|
|
10
|
+
* a re-export misses. The key is the decoded pixels, so the same scene saved
|
|
11
|
+
again from Figma at a different quality is a different photograph -- which is
|
|
12
|
+
exactly the "come back and improve it later" case;
|
|
13
|
+
* nothing travels to another machine.
|
|
14
|
+
|
|
15
|
+
So: a small JSON file written next to the output it produced, and a load path
|
|
16
|
+
that says plainly which photograph it was made for. No OS dialog is involved --
|
|
17
|
+
the file lands in the folder the user already chose, Finder finds it, and the
|
|
18
|
+
page takes it by drag-and-drop the same way it takes a photograph.
|
|
19
|
+
|
|
20
|
+
**Geometry only.** Corners, corner radius, device. Not the grade, blend or
|
|
21
|
+
grain: those belong to a *composite* and are recorded in `result.json`, which is
|
|
22
|
+
the recipe for reproducing one exactly. A fit is the part that is expensive to
|
|
23
|
+
make by hand and is worth carrying between runs; keeping the two apart stops one
|
|
24
|
+
file quietly becoming a worse copy of the other.
|
|
25
|
+
"""
|
|
26
|
+
import json
|
|
27
|
+
import os
|
|
28
|
+
|
|
29
|
+
KIND = "screengraft-fit"
|
|
30
|
+
VERSION = 1
|
|
31
|
+
SUFFIX = ".fit.json"
|
|
32
|
+
|
|
33
|
+
# How far the aspect ratio may drift before a scaled fit stops being trustworthy.
|
|
34
|
+
# A re-export at another size keeps its aspect to within rounding; a CROP does
|
|
35
|
+
# not, and a crop is where scaled corners land somewhere plausible and wrong.
|
|
36
|
+
ASPECT_TOLERANCE = 0.01
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def path_for(output_path):
|
|
40
|
+
"""Beside the mockup, sharing its name: photo__shot.png -> photo__shot.fit.json.
|
|
41
|
+
|
|
42
|
+
Next to the output rather than in a store of its own, because the whole
|
|
43
|
+
point is that a person can find it later without being told where to look.
|
|
44
|
+
"""
|
|
45
|
+
stem = os.path.splitext(output_path)[0]
|
|
46
|
+
return stem + SUFFIX
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def build(corners, radius_frac, device, photo_path, photo_size, key):
|
|
50
|
+
return {
|
|
51
|
+
"kind": KIND,
|
|
52
|
+
"version": VERSION,
|
|
53
|
+
"corners": [[round(float(x), 2), round(float(y), 2)] for x, y in corners],
|
|
54
|
+
"radius_frac": float(radius_frac or 0.0),
|
|
55
|
+
"device": device,
|
|
56
|
+
"photo": {
|
|
57
|
+
"name": os.path.basename(photo_path or ""),
|
|
58
|
+
"size": [int(photo_size[0]), int(photo_size[1])],
|
|
59
|
+
# The pixel key, so a load can tell "this is that photograph" from
|
|
60
|
+
# "this is something else the same size". Not a path: the file is
|
|
61
|
+
# meant to be shared, and a path would be both useless elsewhere and
|
|
62
|
+
# a private detail.
|
|
63
|
+
"key": key,
|
|
64
|
+
},
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def write(output_path, doc):
|
|
69
|
+
p = path_for(output_path)
|
|
70
|
+
tmp = p + ".tmp"
|
|
71
|
+
with open(tmp, "w") as f:
|
|
72
|
+
json.dump(doc, f, indent=1)
|
|
73
|
+
os.replace(tmp, p)
|
|
74
|
+
return p
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def parse(raw):
|
|
78
|
+
"""Validate an untrusted document. Returns (doc, error).
|
|
79
|
+
|
|
80
|
+
Anything can be dropped onto a page. A fit that is not a fit has to be
|
|
81
|
+
refused with a sentence, not applied as four numbers that happen to parse.
|
|
82
|
+
"""
|
|
83
|
+
if isinstance(raw, (str, bytes)):
|
|
84
|
+
try:
|
|
85
|
+
raw = json.loads(raw)
|
|
86
|
+
except ValueError as e:
|
|
87
|
+
return None, f"that file is not JSON ({e})"
|
|
88
|
+
if not isinstance(raw, dict):
|
|
89
|
+
return None, "that file does not contain a fit"
|
|
90
|
+
if raw.get("kind") != KIND:
|
|
91
|
+
return None, ("that file is not a screengraft fit — a fit is the "
|
|
92
|
+
f"{SUFFIX} written next to a mockup you saved")
|
|
93
|
+
if int(raw.get("version", 0)) > VERSION:
|
|
94
|
+
return None, ("that fit was written by a newer version of screengraft "
|
|
95
|
+
"than this one")
|
|
96
|
+
c = raw.get("corners")
|
|
97
|
+
if (not isinstance(c, list) or len(c) != 4
|
|
98
|
+
or not all(isinstance(p, list) and len(p) == 2 for p in c)):
|
|
99
|
+
return None, "that fit does not carry four corners"
|
|
100
|
+
try:
|
|
101
|
+
raw["corners"] = [[float(x), float(y)] for x, y in c]
|
|
102
|
+
except (TypeError, ValueError):
|
|
103
|
+
return None, "that fit's corners are not numbers"
|
|
104
|
+
return raw, None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def apply_to(doc, photo_size, key):
|
|
108
|
+
"""Fit a loaded document to the photograph currently open.
|
|
109
|
+
|
|
110
|
+
Corners are meaningless on the wrong image and *plausible but wrong* on a
|
|
111
|
+
crop of the right one, which is worse. So this never silently applies: every
|
|
112
|
+
return says which of four situations it is, and the page states it.
|
|
113
|
+
"""
|
|
114
|
+
fw, fh = doc.get("photo", {}).get("size", [0, 0]) or [0, 0]
|
|
115
|
+
now_w, now_h = int(photo_size[0]), int(photo_size[1])
|
|
116
|
+
corners = doc["corners"]
|
|
117
|
+
name = doc.get("photo", {}).get("name") or "another photograph"
|
|
118
|
+
|
|
119
|
+
if key and doc.get("photo", {}).get("key") == key:
|
|
120
|
+
return corners, "exact", "This is the photograph the fit was made for."
|
|
121
|
+
if not fw or not fh:
|
|
122
|
+
return corners, "unknown", ("This fit does not say which photograph it "
|
|
123
|
+
"came from — check all four corners.")
|
|
124
|
+
if (fw, fh) == (now_w, now_h):
|
|
125
|
+
# The case this feature exists for: same scene, exported again, so the
|
|
126
|
+
# pixels differ and the size does not.
|
|
127
|
+
return corners, "same-size", (
|
|
128
|
+
"Different pixels, same dimensions as %s — most likely the same "
|
|
129
|
+
"photograph exported again. Corners applied unchanged." % name)
|
|
130
|
+
|
|
131
|
+
sx, sy = now_w / float(fw), now_h / float(fh)
|
|
132
|
+
scaled = [[x * sx, y * sy] for x, y in corners]
|
|
133
|
+
if abs(sx - sy) / max(sx, sy) > ASPECT_TOLERANCE:
|
|
134
|
+
return scaled, "reshaped", (
|
|
135
|
+
"%s was %dx%d and this is %dx%d — a different SHAPE, not just a "
|
|
136
|
+
"different size, so this was probably cropped. The corners are "
|
|
137
|
+
"stretched to fit and will need correcting." % (name, fw, fh, now_w, now_h))
|
|
138
|
+
return scaled, "scaled", (
|
|
139
|
+
"%s was %dx%d and this is %dx%d — corners scaled by %.3f. Worth checking "
|
|
140
|
+
"at a corner." % (name, fw, fh, now_w, now_h, sx))
|
package/scripts/fits.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Remembered fits — the four corners, keyed by the photograph's own pixels.
|
|
3
|
+
|
|
4
|
+
Matching the edges is the only part of the job that costs real attention, and
|
|
5
|
+
it belongs to the PHOTOGRAPH, not to the screenshot: put a second screenshot
|
|
6
|
+
into the same photo and the quad is identical. Until now it was thrown away at
|
|
7
|
+
the end of every run, so the second screenshot meant doing the interview again.
|
|
8
|
+
|
|
9
|
+
Two decisions worth stating, because both had a cheaper wrong version:
|
|
10
|
+
|
|
11
|
+
**The key is the decoded pixels, not the path.** A photograph is re-exported,
|
|
12
|
+
renamed, downloaded twice and dragged in from a different folder constantly, and
|
|
13
|
+
a path key would miss every one of those. It would also miss the case this tool
|
|
14
|
+
creates itself: /api/upload copies the bytes into the session, so the same image
|
|
15
|
+
arriving by drag-drop has a path that never existed before and will not exist
|
|
16
|
+
again. Hashing what the file DECODES to costs 4ms on a 12MP photo and 18ms on
|
|
17
|
+
48MP (measured), which is nothing beside the read that produced the array.
|
|
18
|
+
|
|
19
|
+
**A fit is remembered when it is SAVED, not while it is being dragged.** A quad
|
|
20
|
+
on the canvas is a work in progress; a quad that produced an output is one the
|
|
21
|
+
person looked at and kept. That also keeps the store small enough never to need
|
|
22
|
+
thinking about — a few hundred bytes per entry, against the hundreds of
|
|
23
|
+
megabytes of source copies the session sweep exists to remove.
|
|
24
|
+
|
|
25
|
+
This file is the recipe, never the ingredients: numbers and a basename for
|
|
26
|
+
display, no image data and no absolute paths.
|
|
27
|
+
"""
|
|
28
|
+
import hashlib
|
|
29
|
+
import json
|
|
30
|
+
import os
|
|
31
|
+
import sys
|
|
32
|
+
import threading
|
|
33
|
+
import time
|
|
34
|
+
|
|
35
|
+
# Enough that nobody working normally reaches it — an entry is ~200 bytes, so
|
|
36
|
+
# the whole store stays under 100 KB — and bounded so it cannot grow without
|
|
37
|
+
# limit on a machine that fits mockups all day.
|
|
38
|
+
MAX_FITS = 500
|
|
39
|
+
VERSION = 1
|
|
40
|
+
|
|
41
|
+
# The server is threaded, and remembering is read-modify-write: load the store,
|
|
42
|
+
# add one entry, write it back. os.replace stops the FILE from tearing and does
|
|
43
|
+
# nothing about a lost update -- a render worker finishing while a save runs
|
|
44
|
+
# would drop whichever entry was written first. The whole sequence is short and
|
|
45
|
+
# uncontended, so one lock around it costs nothing worth measuring.
|
|
46
|
+
_LOCK = threading.Lock()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def store_path():
|
|
50
|
+
"""Beside current.json, NOT inside a session directory.
|
|
51
|
+
|
|
52
|
+
A session's contents are swept when the run ends — that is the retention
|
|
53
|
+
policy — and a remembered fit has to outlive the run that made it or it has
|
|
54
|
+
remembered nothing.
|
|
55
|
+
"""
|
|
56
|
+
return os.path.join(os.path.expanduser("~"), ".screengraft", "fits.json")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def key_for(image):
|
|
60
|
+
"""A stable id for a photograph's CONTENT.
|
|
61
|
+
|
|
62
|
+
The shape goes into the digest as well as the bytes: two arrays sharing a
|
|
63
|
+
buffer at different dimensions are different photographs, and hashing the
|
|
64
|
+
flat bytes alone would call them equal.
|
|
65
|
+
"""
|
|
66
|
+
h = hashlib.sha256()
|
|
67
|
+
h.update(("%dx%dx%d|" % (image.shape[0], image.shape[1],
|
|
68
|
+
image.shape[2] if image.ndim > 2 else 1)).encode())
|
|
69
|
+
h.update(memoryview(image.tobytes()))
|
|
70
|
+
return h.hexdigest()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _load():
|
|
74
|
+
"""Tolerant, exactly like Session.read_job: a torn or hand-edited file reads
|
|
75
|
+
as no memory at all rather than taking the request down with it. Losing a
|
|
76
|
+
remembered fit costs one re-fit; refusing to serve the page costs the run.
|
|
77
|
+
"""
|
|
78
|
+
try:
|
|
79
|
+
with open(store_path()) as f:
|
|
80
|
+
d = json.load(f)
|
|
81
|
+
if isinstance(d, dict) and isinstance(d.get("fits"), dict):
|
|
82
|
+
return d["fits"]
|
|
83
|
+
except (OSError, ValueError):
|
|
84
|
+
pass
|
|
85
|
+
return {}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _save(fits):
|
|
89
|
+
path = store_path()
|
|
90
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
91
|
+
tmp = path + ".tmp"
|
|
92
|
+
with open(tmp, "w") as f:
|
|
93
|
+
json.dump({"version": VERSION, "fits": fits}, f, indent=1)
|
|
94
|
+
os.replace(tmp, path)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def remember(key, corners, radius_frac, device, photo_path=None, size=None):
|
|
98
|
+
"""Record the fit for this photograph, replacing any earlier one.
|
|
99
|
+
|
|
100
|
+
The last fit wins deliberately. A photograph has one right answer for where
|
|
101
|
+
its screen is; a history of near-misses would be a list to choose from,
|
|
102
|
+
which is the bookkeeping this feature exists to avoid.
|
|
103
|
+
"""
|
|
104
|
+
if not key or not corners:
|
|
105
|
+
return None
|
|
106
|
+
with _LOCK:
|
|
107
|
+
return _remember_locked(key, corners, radius_frac, device, photo_path, size)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _remember_locked(key, corners, radius_frac, device, photo_path, size):
|
|
111
|
+
fits = _load()
|
|
112
|
+
entry = {"corners": [[float(x), float(y)] for x, y in corners],
|
|
113
|
+
"radius_frac": float(radius_frac or 0.0),
|
|
114
|
+
"device": device,
|
|
115
|
+
"saved": time.time()}
|
|
116
|
+
if photo_path:
|
|
117
|
+
# Basename only. It is there to say "the fit from birch-table.jpg" and
|
|
118
|
+
# nothing reads it back, so a full path would be a private detail kept
|
|
119
|
+
# for no purpose.
|
|
120
|
+
entry["photo"] = os.path.basename(photo_path)
|
|
121
|
+
if size:
|
|
122
|
+
entry["size"] = [int(size[0]), int(size[1])]
|
|
123
|
+
fits[key] = entry
|
|
124
|
+
if len(fits) > MAX_FITS:
|
|
125
|
+
oldest = sorted(fits.items(), key=lambda kv: kv[1].get("saved", 0))
|
|
126
|
+
for k, _ in oldest[:len(fits) - MAX_FITS]:
|
|
127
|
+
fits.pop(k, None)
|
|
128
|
+
try:
|
|
129
|
+
_save(fits)
|
|
130
|
+
except OSError as e:
|
|
131
|
+
# Remembering is a convenience wrapped around the thing the run was
|
|
132
|
+
# actually for. An unwritable home must cost the next fit, not this
|
|
133
|
+
# save -- but it is not allowed to be SILENT either, so it goes to the
|
|
134
|
+
# log the daemon already writes.
|
|
135
|
+
print(f"screengraft: could not remember this fit ({e})", file=sys.stderr)
|
|
136
|
+
return None
|
|
137
|
+
return entry
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def recall(key):
|
|
141
|
+
"""The fit for this photograph, or None. Never raises."""
|
|
142
|
+
if not key:
|
|
143
|
+
return None
|
|
144
|
+
e = _load().get(key)
|
|
145
|
+
if not isinstance(e, dict) or not e.get("corners"):
|
|
146
|
+
return None
|
|
147
|
+
try:
|
|
148
|
+
e["corners"] = [[float(x), float(y)] for x, y in e["corners"]]
|
|
149
|
+
except (TypeError, ValueError):
|
|
150
|
+
return None
|
|
151
|
+
return e
|