screengraft 0.25.2 → 0.37.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.
@@ -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))
@@ -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