screengraft 0.25.1 → 0.25.2

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.
Files changed (3) hide show
  1. package/README.md +19 -9
  2. package/package.json +1 -1
  3. package/scripts/ui.py +85 -3
package/README.md CHANGED
@@ -138,15 +138,25 @@ you, and nothing below ever touches it.
138
138
 
139
139
  **Working files** live in `~/.screengraft/sessions/<timestamp>/` — one directory
140
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.
141
+ is, and screengraft never deletes a file of yours. A source you drag in or browse
142
+ to has to be copied, because a browser hands over bytes and will not say where
143
+ they came from.
144
+
145
+ What happens to that copy depends on whether the run produced anything:
146
+
147
+ - **A run that saved a mockup keeps its source.** The `result.json` sidecar names
148
+ it, and a recipe naming a file that no longer exists is not a recipe.
149
+ - **A run that produced nothing keeps nothing.** That is the common case and
150
+ where the disk goes — previews, thumbnails, poster frames and abandoned
151
+ uploads are all swept when the run ends, or at the next launch after a crash.
152
+
153
+ What survives either way is the `result.json` sidecar: a few hundred bytes
154
+ recording the corners, radius, grade and blend of that fit. It reproduces a
155
+ composite exactly, and it is the first thing a bug report should include.
156
+
157
+ Sidecars written by v0.23.0–v0.25.1 may name a dragged-in source that release
158
+ deleted. Those cannot be repaired — the bytes are gone — but screengraft now
159
+ marks them `"source_retained": false` rather than leaving them looking valid.
150
160
 
151
161
 
152
162
  ## Roadmap
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "screengraft",
3
- "version": "0.25.1",
3
+ "version": "0.25.2",
4
4
  "description": "Put a UI screenshot or screen recording onto a photographed device screen with the perspective exactly right \u2014 a homography you confirm by hand, not a generative guess.",
5
5
  "keywords": [
6
6
  "mockup",
package/scripts/ui.py CHANGED
@@ -155,14 +155,54 @@ _RESIDUE_PREFIXES = ("photo-", "screenshot-", "poster-", "frame-")
155
155
  _RESIDUE_NAMES = ("preview.png", "figma-export.png")
156
156
 
157
157
 
158
+ def _sidecar_sources(d):
159
+ """Absolute paths inside `d` that this session's result.json still names.
160
+
161
+ v0.23.0 swept these too, and the sidecar's whole promise is that a fit can
162
+ be re-run from it. That promise held for a source picked by PATH, which
163
+ was never copied -- and quietly broke for a drag-drop or browse, where the
164
+ browser hands over bytes with no origin and the copy in the session IS the
165
+ original as far as the sidecar is concerned. Measured after the first sweep:
166
+ 9 of 9 such sidecars pointed at a deleted file.
167
+
168
+ So the rule is now: a session that produced output keeps what its sidecar
169
+ names. A session that produced nothing keeps nothing -- there is no recipe
170
+ to protect, which is the common case and where the volume is.
171
+
172
+ Only paths INSIDE the session are returned. A path-picked source lives in
173
+ the user's own folders and was never ours to keep or delete.
174
+
175
+ Note the sidecar is rewritten on every save, so it names the LAST fit. An
176
+ earlier source replaced within the same session is not protected: the record
177
+ is what survives, and the record says what it says.
178
+ """
179
+ try:
180
+ with open(os.path.join(d, "result.json")) as f:
181
+ res = json.load(f)
182
+ except (OSError, ValueError):
183
+ return set()
184
+ root = os.path.realpath(d)
185
+ keep = set()
186
+ for key in ("photo", "screenshot"):
187
+ p = res.get(key)
188
+ if not p:
189
+ continue
190
+ rp = os.path.realpath(p)
191
+ if rp == root or rp.startswith(root + os.sep):
192
+ keep.add(rp)
193
+ return keep
194
+
195
+
158
196
  def _sweep_session(d):
159
197
  """Delete a session's copied and derived media. Returns bytes reclaimed.
160
198
 
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.
199
+ Never touches *.json, never touches OUT_DIR -- the actual outputs live in
200
+ the project folder and are the point of the whole exercise -- and never
201
+ touches a source the session's own result.json still names (see above).
163
202
  """
164
203
  freed = 0
165
204
  thumbs = os.path.join(d, "thumbs")
205
+ protected = _sidecar_sources(d)
166
206
  for base, _, files in os.walk(d):
167
207
  for f in files:
168
208
  keep = f.endswith(".json")
@@ -171,6 +211,8 @@ def _sweep_session(d):
171
211
  if keep or not residue:
172
212
  continue
173
213
  fp = os.path.join(base, f)
214
+ if os.path.realpath(fp) in protected:
215
+ continue
174
216
  try:
175
217
  freed += os.path.getsize(fp)
176
218
  os.remove(fp)
@@ -179,6 +221,39 @@ def _sweep_session(d):
179
221
  return freed
180
222
 
181
223
 
224
+ def _mark_unreproducible(d):
225
+ """Stamp a sidecar whose named source no longer exists.
226
+
227
+ For the sessions v0.23.0 already swept, nothing can be recovered -- the
228
+ bytes are gone and the browser never said where they came from. What can be
229
+ fixed is the claim: a sidecar that names a deleted file reads exactly like
230
+ one that works, and the difference only shows up when someone tries to
231
+ re-run it. `source_retained: false` says so up front.
232
+
233
+ Idempotent, and it never touches a sidecar whose files are intact.
234
+ """
235
+ path = os.path.join(d, "result.json")
236
+ try:
237
+ with open(path) as f:
238
+ res = json.load(f)
239
+ except (OSError, ValueError):
240
+ return False
241
+ if "source_retained" in res:
242
+ return False
243
+ named = [res.get(k) for k in ("photo", "screenshot")]
244
+ if not any(named) or all(p and os.path.exists(p) for p in named if p):
245
+ return False
246
+ res["source_retained"] = False
247
+ tmp = path + ".tmp"
248
+ try:
249
+ with open(tmp, "w") as f:
250
+ json.dump(res, f, indent=1)
251
+ os.replace(tmp, path)
252
+ except OSError:
253
+ return False
254
+ return True
255
+
256
+
182
257
  def _prune_sessions(keep):
183
258
  """Sweep every session but the live one, at launch.
184
259
 
@@ -191,7 +266,10 @@ def _prune_sessions(keep):
191
266
  /api/use records the path and reads through it. Only a drag-drop or a browse
192
267
  has to be copied, because the browser hands over bytes and will not say
193
268
  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.
269
+ cannot avoid being copied does not outlive the run that needed it --
270
+ UNLESS the run produced something, in which case its sidecar names the
271
+ source and _sweep_session keeps it. See _sidecar_sources: reproducibility
272
+ beats disk exactly where a fit actually happened, and nowhere else.
195
273
  """
196
274
  root = os.path.dirname(keep)
197
275
  freed = 0
@@ -204,6 +282,10 @@ def _prune_sessions(keep):
204
282
  if d == keep or not os.path.isdir(d):
205
283
  continue
206
284
  freed += _sweep_session(d)
285
+ # After sweeping, not before: a sidecar is only unreproducible once its
286
+ # source is actually gone, and from here on the sweep leaves it alone.
287
+ # This is for the sessions the previous release already emptied.
288
+ _mark_unreproducible(d)
207
289
  return freed
208
290
 
209
291