screengraft 0.25.1 → 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.
package/scripts/ui.py CHANGED
@@ -47,6 +47,8 @@ sys.path.insert(0, HERE)
47
47
  import cv2 # noqa: E402
48
48
  import numpy as np # noqa: E402
49
49
  import detect as D # noqa: E402
50
+ import fitfile as FF # noqa: E402
51
+ import fits as FIT # noqa: E402
50
52
  import scan as S # noqa: E402
51
53
  import warp as W # noqa: E402
52
54
 
@@ -76,17 +78,104 @@ def _write_json_atomic(path, obj):
76
78
  os.replace(tmp, path)
77
79
 
78
80
  # Corner radius as a fraction of the SCREEN'S WIDTH, per device preset.
79
- # Approximations from public specs (pt): iPhone 15/16 393pt wide, ~55pt radius;
80
- # Pro Max 430pt; iPads ~18pt on 744-1024pt; MacBook display corners ~12px on
81
- # ~1500pt; monitors square. Good enough to start a drag from; measure beats these.
81
+ #
82
+ # The iPhone values are DERIVED, not estimated: radius in points from Apple's
83
+ # private `UIScreen._displayCornerRadius`, as collected by kylebshr/ScreenCorners,
84
+ # divided by the model's logical width in points. Both are exact, so the fraction
85
+ # is too — the only approximation left is that a photographed screen is not
86
+ # always exactly its logical aspect.
87
+ #
88
+ # There is no single "iPhone radius" and the spread is large: 39pt on an iPhone X
89
+ # against 62pt on a 17 Pro, and because width also changes, two models with the
90
+ # SAME radius land on different fractions (55pt is 14.0% of a 393pt iPhone 16 and
91
+ # 12.8% of a 430pt 16 Plus). A single preset could not have covered these.
92
+ #
93
+ # group radius width frac
94
+ # iPhone 17 Pro / 17 / 16 Pro 62.0 402 0.154
95
+ # iPhone Air 62.0 420 0.148
96
+ # iPhone 17 Pro Max / 16 Pro Max 62.0 440 0.141
97
+ # iPhone 16 / 15 Pro / 15 / 14 Pro 55.0 393 0.140
98
+ # iPhone 16 Plus / 15 Pro Max / 15 Plus /
99
+ # 14 Pro Max 55.0 430 0.128
100
+ # iPhone 14 Plus / 13 Pro Max / 12 Pro Max 53.33 428 0.125
101
+ # iPhone 16e / 14 / 13 Pro / 13 / 12 Pro / 12 47.33 390 0.121
102
+ # iPhone 13 mini / 12 mini 44.0 375 0.117
103
+ # iPhone 11 / XR 41.5 414 0.100
104
+ # iPhone 11 Pro / XS / X 39.0 375 0.104
105
+ # iPhone 11 Pro Max / XS Max 39.0 414 0.094
106
+ # iPhone SE / 8 / 7 0 - 0
107
+ #
108
+ # Two caveats worth knowing before trusting a preset over a measurement:
109
+ #
110
+ # 1. ScreenCorners lists the plain **iPhone 13** nowhere, while listing the 13
111
+ # Pro at 47.33. The 13 and 13 Pro share a 390x844 display, so 47.33 is the
112
+ # inference here rather than a reported value. It is the only entry below
113
+ # that is not directly attested.
114
+ # 2. Apple's display corners are a **continuous curve**, not a circular arc, and
115
+ # compose() applies a circular radius. Matched by number they are not matched
116
+ # by shape — a continuous corner reads slightly tighter at the diagonal. The
117
+ # preset is a starting position; the measured radius, when the photograph
118
+ # gives one, beats it.
119
+ #
120
+ # `label` is what fits the closed dropdown at its real width. `full` is the
121
+ # exact membership and the numbers it came from, and the page puts it in the
122
+ # CAPTION under the slider — not in a tooltip on the <option>, which a native
123
+ # macOS select popup does not reliably render. The short label must never be
124
+ # the only place the truth lives.
125
+ #
126
+ # `smoothing` is Figma's corner smoothing for that device: Apple's display
127
+ # corners are a continuous curve, not a circular arc, so every Apple preset
128
+ # carries 0.6 — the value Figma labels "iOS". Android and the square entries
129
+ # carry nothing and stay circular. It is a property of the DEVICE, which is why
130
+ # it lives here rather than in a control: a photograph of an iPhone does not
131
+ # have a smoothing preference, it has a shape.
132
+ #
133
+ # iPads: 18pt on every rounded model, over 744pt (mini) / 834pt (11" / Air) /
134
+ # 1024pt (12.9"). MacBook display corners ~12px on ~1500pt. Monitors square.
82
135
  PRESETS = [
83
- {"id": "phone-iphone", "type": "phone", "label": "iPhone 15 / 16 / Pro", "frac": 0.140},
84
- {"id": "phone-iphone-max", "type": "phone", "label": "iPhone Plus / Pro Max", "frac": 0.128},
136
+ # Newest first — that is the order a photograph is likely to be of.
137
+ {"id": "phone-iphone-17pro", "type": "phone", "smoothing": 0.6, "frac": 0.154,
138
+ "label": "iPhone 17 Pro / 17 / 16 Pro",
139
+ "full": "iPhone 17 Pro, iPhone 17, iPhone 16 Pro \u2014 62pt over 402pt"},
140
+ {"id": "phone-iphone-air", "type": "phone", "smoothing": 0.6, "frac": 0.148,
141
+ "label": "iPhone Air",
142
+ "full": "iPhone Air \u2014 62pt over 420pt"},
143
+ {"id": "phone-iphone-17promax", "type": "phone", "smoothing": 0.6, "frac": 0.141,
144
+ "label": "iPhone 17 Pro Max / 16 Pro Max",
145
+ "full": "iPhone 17 Pro Max, iPhone 16 Pro Max \u2014 62pt over 440pt"},
146
+ # Keeps its original id: this group is what "iPhone 15 / 16 / Pro" meant.
147
+ {"id": "phone-iphone", "type": "phone", "smoothing": 0.6, "frac": 0.140,
148
+ "label": "iPhone 16 / 15 / 15 Pro / 14 Pro",
149
+ "full": "iPhone 16, 15, 15 Pro, 14 Pro \u2014 55pt over 393pt"},
150
+ {"id": "phone-iphone-max", "type": "phone", "smoothing": 0.6, "frac": 0.128,
151
+ "label": "iPhone 15\u201316 Plus, 14\u201315 Pro Max",
152
+ "full": "iPhone 16 Plus, 15 Plus, 15 Pro Max, 14 Pro Max \u2014 55pt over 430pt"},
153
+ {"id": "phone-iphone-14plus", "type": "phone", "smoothing": 0.6, "frac": 0.125,
154
+ "label": "iPhone 14 Plus / 12\u201313 Pro Max",
155
+ "full": "iPhone 14 Plus, 13 Pro Max, 12 Pro Max \u2014 53.33pt over 428pt"},
156
+ {"id": "phone-iphone-12", "type": "phone", "smoothing": 0.6, "frac": 0.121,
157
+ "label": "iPhone 12\u201314 / 12\u201313 Pro / 16e",
158
+ "full": "iPhone 14, 13, 13 Pro, 12, 12 Pro, 16e \u2014 47.33pt over 390pt. The plain 13 is inferred: it shares the 390pt display with the 13 Pro."},
159
+ {"id": "phone-iphone-mini", "type": "phone", "smoothing": 0.6, "frac": 0.117,
160
+ "label": "iPhone 13 mini / 12 mini",
161
+ "full": "iPhone 13 mini, 12 mini \u2014 44pt over 375pt"},
162
+ {"id": "phone-iphone-x", "type": "phone", "smoothing": 0.6, "frac": 0.104,
163
+ "label": "iPhone 11 Pro / XS / X",
164
+ "full": "iPhone 11 Pro, XS, X \u2014 39pt over 375pt"},
165
+ {"id": "phone-iphone-xr", "type": "phone", "smoothing": 0.6, "frac": 0.100,
166
+ "label": "iPhone 11 / XR",
167
+ "full": "iPhone 11, XR \u2014 41.5pt over 414pt"},
168
+ {"id": "phone-iphone-xsmax", "type": "phone", "smoothing": 0.6, "frac": 0.094,
169
+ "label": "iPhone 11 Pro Max / XS Max",
170
+ "full": "iPhone 11 Pro Max, XS Max \u2014 39pt over 414pt"},
171
+ {"id": "phone-iphone-se", "type": "phone", "frac": 0.0,
172
+ "label": "iPhone SE / 8 / 7",
173
+ "full": "iPhone SE (2nd/3rd gen), 8, 7 \u2014 square display corners, no radius"},
85
174
  {"id": "phone-android", "type": "phone", "label": "Android (typical)", "frac": 0.090},
86
- {"id": "tablet-ipad-pro-11", "type": "tablet", "label": "iPad Pro 11 / Air", "frac": 0.022},
87
- {"id": "tablet-ipad-pro-13", "type": "tablet", "label": "iPad Pro 13", "frac": 0.018},
88
- {"id": "tablet-ipad-mini", "type": "tablet", "label": "iPad mini", "frac": 0.024},
89
- {"id": "laptop-macbook", "type": "laptop", "label": "MacBook Air / Pro", "frac": 0.008},
175
+ {"id": "tablet-ipad-pro-11", "type": "tablet", "smoothing": 0.6, "label": "iPad Pro 11 / Air", "frac": 0.022},
176
+ {"id": "tablet-ipad-pro-13", "type": "tablet", "smoothing": 0.6, "label": "iPad Pro 13", "frac": 0.018},
177
+ {"id": "tablet-ipad-mini", "type": "tablet", "smoothing": 0.6, "label": "iPad mini", "frac": 0.024},
178
+ {"id": "laptop-macbook", "type": "laptop", "smoothing": 0.6, "label": "MacBook Air / Pro", "frac": 0.008},
90
179
  {"id": "laptop-other", "type": "laptop", "label": "Other laptop (square)", "frac": 0.0},
91
180
  {"id": "desktop", "type": "desktop", "label": "Desktop monitor (square)", "frac": 0.0},
92
181
  ]
@@ -152,17 +241,57 @@ SESSION: Session = None
152
241
  _RESIDUE_PREFIXES = ("photo-", "screenshot-", "poster-", "frame-")
153
242
  # figma-export.png is a copy too -- the agent fetches the frame and drops it
154
243
  # here -- and re-exporting is one MCP round trip, so it is residue like the rest.
155
- _RESIDUE_NAMES = ("preview.png", "figma-export.png")
244
+ _RESIDUE_NAMES = ("preview.png", "preview.mp4", "figma-export.png")
245
+
246
+
247
+ def _sidecar_sources(d):
248
+ """Absolute paths inside `d` that this session's result.json still names.
249
+
250
+ v0.23.0 swept these too, and the sidecar's whole promise is that a fit can
251
+ be re-run from it. That promise held for a source picked by PATH, which
252
+ was never copied -- and quietly broke for a drag-drop or browse, where the
253
+ browser hands over bytes with no origin and the copy in the session IS the
254
+ original as far as the sidecar is concerned. Measured after the first sweep:
255
+ 9 of 9 such sidecars pointed at a deleted file.
256
+
257
+ So the rule is now: a session that produced output keeps what its sidecar
258
+ names. A session that produced nothing keeps nothing -- there is no recipe
259
+ to protect, which is the common case and where the volume is.
260
+
261
+ Only paths INSIDE the session are returned. A path-picked source lives in
262
+ the user's own folders and was never ours to keep or delete.
263
+
264
+ Note the sidecar is rewritten on every save, so it names the LAST fit. An
265
+ earlier source replaced within the same session is not protected: the record
266
+ is what survives, and the record says what it says.
267
+ """
268
+ try:
269
+ with open(os.path.join(d, "result.json")) as f:
270
+ res = json.load(f)
271
+ except (OSError, ValueError):
272
+ return set()
273
+ root = os.path.realpath(d)
274
+ keep = set()
275
+ for key in ("photo", "screenshot"):
276
+ p = res.get(key)
277
+ if not p:
278
+ continue
279
+ rp = os.path.realpath(p)
280
+ if rp == root or rp.startswith(root + os.sep):
281
+ keep.add(rp)
282
+ return keep
156
283
 
157
284
 
158
285
  def _sweep_session(d):
159
286
  """Delete a session's copied and derived media. Returns bytes reclaimed.
160
287
 
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.
288
+ Never touches *.json, never touches OUT_DIR -- the actual outputs live in
289
+ the project folder and are the point of the whole exercise -- and never
290
+ touches a source the session's own result.json still names (see above).
163
291
  """
164
292
  freed = 0
165
293
  thumbs = os.path.join(d, "thumbs")
294
+ protected = _sidecar_sources(d)
166
295
  for base, _, files in os.walk(d):
167
296
  for f in files:
168
297
  keep = f.endswith(".json")
@@ -171,6 +300,8 @@ def _sweep_session(d):
171
300
  if keep or not residue:
172
301
  continue
173
302
  fp = os.path.join(base, f)
303
+ if os.path.realpath(fp) in protected:
304
+ continue
174
305
  try:
175
306
  freed += os.path.getsize(fp)
176
307
  os.remove(fp)
@@ -179,6 +310,39 @@ def _sweep_session(d):
179
310
  return freed
180
311
 
181
312
 
313
+ def _mark_unreproducible(d):
314
+ """Stamp a sidecar whose named source no longer exists.
315
+
316
+ For the sessions v0.23.0 already swept, nothing can be recovered -- the
317
+ bytes are gone and the browser never said where they came from. What can be
318
+ fixed is the claim: a sidecar that names a deleted file reads exactly like
319
+ one that works, and the difference only shows up when someone tries to
320
+ re-run it. `source_retained: false` says so up front.
321
+
322
+ Idempotent, and it never touches a sidecar whose files are intact.
323
+ """
324
+ path = os.path.join(d, "result.json")
325
+ try:
326
+ with open(path) as f:
327
+ res = json.load(f)
328
+ except (OSError, ValueError):
329
+ return False
330
+ if "source_retained" in res:
331
+ return False
332
+ named = [res.get(k) for k in ("photo", "screenshot")]
333
+ if not any(named) or all(p and os.path.exists(p) for p in named if p):
334
+ return False
335
+ res["source_retained"] = False
336
+ tmp = path + ".tmp"
337
+ try:
338
+ with open(tmp, "w") as f:
339
+ json.dump(res, f, indent=1)
340
+ os.replace(tmp, path)
341
+ except OSError:
342
+ return False
343
+ return True
344
+
345
+
182
346
  def _prune_sessions(keep):
183
347
  """Sweep every session but the live one, at launch.
184
348
 
@@ -191,7 +355,10 @@ def _prune_sessions(keep):
191
355
  /api/use records the path and reads through it. Only a drag-drop or a browse
192
356
  has to be copied, because the browser hands over bytes and will not say
193
357
  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.
358
+ cannot avoid being copied does not outlive the run that needed it --
359
+ UNLESS the run produced something, in which case its sidecar names the
360
+ source and _sweep_session keeps it. See _sidecar_sources: reproducibility
361
+ beats disk exactly where a fit actually happened, and nowhere else.
195
362
  """
196
363
  root = os.path.dirname(keep)
197
364
  freed = 0
@@ -204,6 +371,10 @@ def _prune_sessions(keep):
204
371
  if d == keep or not os.path.isdir(d):
205
372
  continue
206
373
  freed += _sweep_session(d)
374
+ # After sweeping, not before: a sidecar is only unreproducible once its
375
+ # source is actually gone, and from here on the sweep leaves it alone.
376
+ # This is for the sessions the previous release already emptied.
377
+ _mark_unreproducible(d)
207
378
  return freed
208
379
 
209
380
 
@@ -385,12 +556,28 @@ def _read_source(path: str):
385
556
  # an HTTP request open — so the render runs on its own thread and the page
386
557
  # polls. ThreadingHTTPServer is already the server class, so this needs no
387
558
  # other machinery.
388
- RENDER = {"state": "idle", "done": 0, "total": 0, "output": None, "message": None}
559
+ RENDER = {"state": "idle", "done": 0, "total": 0, "output": None, "message": None,
560
+ "kind": "render"}
389
561
  RENDER_LOCK = threading.Lock()
390
562
 
391
563
 
564
+ # How wide a preview proxy is composited. Small enough that a clip can be
565
+ # watched in seconds rather than minutes, large enough that the two things a
566
+ # preview exists to judge -- the light match holding across the clip, and the
567
+ # emissive blend against changing content -- are visible. It is a PROXY: it
568
+ # never reaches --out-dir and never becomes the session output.
569
+ PREVIEW_WIDTH = 720
570
+ # ...and how much of the clip. A 2460-frame recording takes about as long to
571
+ # composite as the render it is meant to save you from, and a preview you wait a
572
+ # minute for is a render with a worse output. Six seconds from the frame you
573
+ # fitted on is enough to watch the light match hold and the emissive blend move
574
+ # with the content; the scrubber picks a different moment if you want one.
575
+ PREVIEW_SECONDS = 6.0
576
+
577
+
392
578
  def _render_worker(photo, video_path, corners, dest, radius_px, gr, grain, preset, fit_frame,
393
- blend="replace", reflection=None, result=None):
579
+ blend="replace", reflection=None, result=None, kind="render",
580
+ start_frame=0, max_frames=None, *, smoothing=0.0):
394
581
  """Encode the clip, and only if that SUCCEEDS publish what it produced.
395
582
 
396
583
  `result` is the sidecar this render would write. It is handed to the worker
@@ -409,24 +596,57 @@ def _render_worker(photo, video_path, corners, dest, radius_px, gr, grain, prese
409
596
  RENDER["done"], RENDER["total"] = done, total
410
597
  try:
411
598
  info = W.compose_video(photo, video_path, corners, dest,
412
- corner_radius=radius_px, grade=gr, grain=grain,
599
+ corner_radius=radius_px, corner_smoothing=smoothing,
600
+ grade=gr, grain=grain,
413
601
  preset=preset, fit_frame=fit_frame, progress=progress,
414
602
  blend=blend,
415
603
  reflection=(W.DEFAULT_REFLECTION if reflection is None
416
- else reflection))
604
+ else reflection),
605
+ start_frame=start_frame, max_frames=max_frames)
606
+ if kind == "preview":
607
+ # A preview publishes NOTHING. It is not a save: no sidecar, no fit
608
+ # file, and above all not the session output -- /api/import reads
609
+ # that pointer, and handing Claude a 720px proxy instead of the
610
+ # mockup would be the render-output defect of 9 Sep, inverted.
611
+ with RENDER_LOCK:
612
+ RENDER.update(state="done", output=dest, info=info, kind=kind,
613
+ done=info["frames"], total=info["frames"], message=None)
614
+ return
417
615
  if result is not None:
418
616
  _write_json_atomic(SESSION.result_path, {**result, "saved": time.time()})
617
+ # Same rule as /api/save, and for the same reason it lives after the
618
+ # encode: the fit is remembered by the run that produced a file.
619
+ key = FIT.key_for(photo)
620
+ FIT.remember(key, corners, result.get("radius_frac"),
621
+ result.get("device"), result.get("photo"),
622
+ (photo.shape[1], photo.shape[0]))
623
+ FF.write(dest, FF.build(corners, result.get("radius_frac"),
624
+ result.get("device"), result.get("photo"),
625
+ (photo.shape[1], photo.shape[0]), key))
419
626
  # The still path has always done this (see /api/save); the render path
420
627
  # never did, so /api/import -- which reads SESSION.state["output"] --
421
628
  # either found nothing or, worse, silently handed over the PREVIOUS
422
629
  # still image after a successful render.
423
630
  SESSION.update(output=dest)
424
631
  with RENDER_LOCK:
425
- RENDER.update(state="done", output=dest, info=info,
632
+ RENDER.update(state="done", output=dest, info=info, kind=kind,
426
633
  done=info["frames"], total=info["frames"], message=None)
427
634
  except Exception as e: # noqa: BLE001 - surfaced to the page
428
635
  with RENDER_LOCK:
429
- RENDER.update(state="error", message=str(e))
636
+ RENDER.update(state="error", message=str(e), kind=kind)
637
+
638
+
639
+ def _smoothing(b) -> float:
640
+ """Figma corner smoothing for this request, 0-1.
641
+
642
+ Absent means 0, which is a circular arc — so a client that predates
643
+ smoothing, and a sidecar replayed through the CLI, both get the shape they
644
+ got before. That default is the compatibility guarantee, not a convenience.
645
+ """
646
+ try:
647
+ return float(min(max(float(b.get("smoothing") or 0.0), 0.0), 1.0))
648
+ except (TypeError, ValueError):
649
+ return 0.0
430
650
 
431
651
 
432
652
  def _blend_args(b):
@@ -444,6 +664,44 @@ def _blend_args(b):
444
664
  return "emissive", float(max(0.0, min(1.0, float(r))))
445
665
 
446
666
 
667
+ ROLES = ("photo", "screenshot")
668
+
669
+
670
+ def _adopt(role, path):
671
+ """Make a chosen source the session's, and answer what the page needs.
672
+
673
+ /api/use and /api/upload differ only in where the bytes came from --
674
+ everything after that (the video probe, the session state, and now the
675
+ remembered fit) has to be identical for a drag-drop and a path pick, or the
676
+ feature works one way in and not the other. It was already written twice;
677
+ the fit lookup would have made it three, and a set of writers that
678
+ disagree is a defect this project has already shipped once.
679
+ """
680
+ # The role names a session-state key, and the line below writes it, so an
681
+ # unchecked role is a write primitive: role="output" sets the pointer
682
+ # /api/import reads, and the agent is then asked to show whatever file that
683
+ # names. Nothing on this port authenticates, so the caller is not
684
+ # necessarily the page. Two roles exist; anything else is a 400.
685
+ if role not in ROLES:
686
+ raise ValueError(f"role must be one of {', '.join(ROLES)}")
687
+ if role == "screenshot":
688
+ SESSION.update(fit_frame=0)
689
+ im, real, meta = _read_source(path)
690
+ else:
691
+ im, real = _read_image(path)
692
+ meta = {"video": False}
693
+ SESSION.update(**{role: real})
694
+ if role == "photo":
695
+ # The quad is a property of the PHOTOGRAPH, so a fit saved on an earlier
696
+ # run is a better starting position than any detector -- and a stronger
697
+ # claim, which is why the page states where the quad came from rather
698
+ # than presenting a memory as a detection.
699
+ e = FIT.recall(FIT.key_for(im))
700
+ if e:
701
+ meta["remembered"] = e
702
+ return {"path": real, "size": [im.shape[1], im.shape[0]], **meta}
703
+
704
+
447
705
  def _guess_type(corners):
448
706
  c = np.array(corners, dtype=float)
449
707
  w = (np.linalg.norm(c[1] - c[0]) + np.linalg.norm(c[2] - c[3])) / 2
@@ -476,13 +734,54 @@ class Handler(BaseHTTPRequestHandler):
476
734
  self.wfile.write(body)
477
735
 
478
736
  def _file(self, path, ctype=None):
737
+ """Serve a local file, answering a Range request when one is made.
738
+
739
+ A browser cannot SEEK in a video the server will only hand over whole:
740
+ it plays from the start and every jump snaps back to zero. That is not a
741
+ detail here -- the live view exists to be parked on the frame the edges
742
+ were matched against, and the frame scrubber is supposed to move it.
743
+ Measured before fixing: setting currentTime to 9.0s read back as 0.0.
744
+
745
+ So: advertise `Accept-Ranges`, and answer a single `bytes=a-b` with a
746
+ 206. Multi-range is not implemented and is not needed -- media players
747
+ ask for one range at a time -- and anything unparseable falls through to
748
+ the whole file, which is what the spec asks for.
749
+ """
479
750
  try:
480
- with open(path, "rb") as f:
481
- data = f.read()
751
+ size = os.path.getsize(path)
752
+ f = open(path, "rb")
482
753
  except OSError:
483
754
  return self._json({"error": "not found"}, 404)
484
- self.send_response(200)
485
- self.send_header("Content-Type", ctype or mimetypes.guess_type(path)[0] or "application/octet-stream")
755
+ ctype = ctype or mimetypes.guess_type(path)[0] or "application/octet-stream"
756
+ start, end = 0, size - 1
757
+ partial = False
758
+ rng = self.headers.get("Range") or ""
759
+ if rng.startswith("bytes=") and "," not in rng:
760
+ a, _, b = rng[6:].partition("-")
761
+ try:
762
+ if a:
763
+ start = int(a)
764
+ end = int(b) if b else size - 1
765
+ elif b: # bytes=-N: the LAST n bytes
766
+ start = max(0, size - int(b))
767
+ if 0 <= start <= end < size:
768
+ partial = True
769
+ else:
770
+ start, end = 0, size - 1
771
+ except ValueError:
772
+ start, end = 0, size - 1
773
+ try:
774
+ with f:
775
+ if partial:
776
+ f.seek(start)
777
+ data = f.read(end - start + 1) if partial else f.read()
778
+ except OSError:
779
+ return self._json({"error": "not readable"}, 404)
780
+ self.send_response(206 if partial else 200)
781
+ self.send_header("Content-Type", ctype)
782
+ self.send_header("Accept-Ranges", "bytes")
783
+ if partial:
784
+ self.send_header("Content-Range", f"bytes {start}-{end}/{size}")
486
785
  self.send_header("Content-Length", str(len(data)))
487
786
  self.send_header("Cache-Control", "no-store")
488
787
  self.end_headers()
@@ -566,27 +865,12 @@ class Handler(BaseHTTPRequestHandler):
566
865
  with open(dest, "wb") as f:
567
866
  f.write(self._body())
568
867
  # A video is only ever a screen source; a photo must be a still.
569
- if role == "screenshot":
570
- SESSION.update(fit_frame=0)
571
- im, real, meta = _read_source(dest)
572
- else:
573
- im, real = _read_image(dest)
574
- meta = {"video": False}
575
- SESSION.update(**{role: real})
576
- return self._json({"path": real, "size": [im.shape[1], im.shape[0]], **meta})
868
+ return self._json(_adopt(role, dest))
577
869
 
578
870
  b = self._jbody()
579
871
 
580
872
  if u.path == "/api/use":
581
- role = b["role"]
582
- if role == "screenshot":
583
- SESSION.update(fit_frame=0)
584
- im, real, meta = _read_source(b["path"])
585
- else:
586
- im, real = _read_image(b["path"])
587
- meta = {"video": False}
588
- SESSION.update(**{role: real})
589
- return self._json({"path": real, "size": [im.shape[1], im.shape[0]], **meta})
873
+ return self._json(_adopt(b["role"], b["path"]))
590
874
 
591
875
  if u.path == "/api/figma":
592
876
  return self._json(SESSION.enqueue({
@@ -621,6 +905,29 @@ class Handler(BaseHTTPRequestHandler):
621
905
  SESSION.update(screenshot=real)
622
906
  return self._json({"path": real, "size": [im.shape[1], im.shape[0]]})
623
907
 
908
+ if u.path == "/api/fit":
909
+ # A fit file dropped onto the page. The page reads the bytes and
910
+ # posts the parsed document; only the server can judge it,
911
+ # because judging it means knowing what the OPEN photograph is.
912
+ #
913
+ # It never applies silently. Corners are meaningless on the wrong
914
+ # image and *plausible but wrong* on a crop of the right one,
915
+ # which is the more dangerous of the two — so every answer says
916
+ # which of four situations it is and the page says it out loud.
917
+ if not SESSION.state.get("photo"):
918
+ raise ValueError("choose the photo first, then drop the fit onto it")
919
+ doc, err = FF.parse(b.get("fit"))
920
+ if err:
921
+ return self._json({"error": err}, 400)
922
+ photo, _p = _read_image(SESSION.state["photo"])
923
+ corners, match, message = FF.apply_to(
924
+ doc, (photo.shape[1], photo.shape[0]), FIT.key_for(photo))
925
+ return self._json({"corners": corners, "match": match,
926
+ "message": message,
927
+ "radius_frac": doc.get("radius_frac"),
928
+ "device": doc.get("device"),
929
+ "from": doc.get("photo", {}).get("name")})
930
+
624
931
  if u.path == "/api/detect":
625
932
  if not SESSION.state.get("photo"):
626
933
  raise ValueError("choose a photo first")
@@ -643,9 +950,23 @@ class Handler(BaseHTTPRequestHandler):
643
950
  raise ValueError("the click is outside the photograph")
644
951
  # `color` gives detect() the saturation detector — devices are
645
952
  # neutral, furniture is not, and grayscale throws that away.
646
- res = D.detect(gray, None, color=photo, click=click)
953
+ # The instrument, off unless asked for: the full
954
+ # candidate list, with each quad's score and what became of it,
955
+ # written beside the session. The page never shows it -- it is
956
+ # for the next person diagnosing a detection failure, and for a
957
+ # benchmark that needs to tell "never proposed" from "proposed
958
+ # and beaten". The response carries the counts and the path, not
959
+ # several hundred quads nobody asked the page to render.
960
+ trace = [] if (isinstance(b, dict) and b.get("trace")) else None
961
+ res = D.detect(gray, None, color=photo, click=click, trace=trace)
962
+ if trace is not None:
963
+ tpath = os.path.join(SESSION.dir, "candidates.json")
964
+ counts = D.write_trace(tpath, SESSION.state["photo"], photo.shape,
965
+ click, res, trace)
966
+ trace_info = {"path": tpath, "candidates": len(trace), **counts}
647
967
  if res is None:
648
968
  return self._json({"found": False, "clicked": click is not None,
969
+ **({"trace": trace_info} if trace is not None else {}),
649
970
  "message": ("Nothing screen-shaped was found around that "
650
971
  "point — try clicking nearer the middle of the "
651
972
  "screen." if click is not None else
@@ -661,6 +982,7 @@ class Handler(BaseHTTPRequestHandler):
661
982
  return self._json({"found": False,
662
983
  "message": res["abstain_reason"],
663
984
  "abstained": True,
985
+ **({"trace": trace_info} if trace is not None else {}),
664
986
  "inspect": res["corners"]})
665
987
  res.pop("_corners_np", None)
666
988
  res["found"] = True
@@ -670,6 +992,8 @@ class Handler(BaseHTTPRequestHandler):
670
992
  res["confidence"] = ("corroborated" if res.get("agreement", {}).get("agree")
671
993
  else "unconfirmed")
672
994
  res["type_guess"] = _guess_type(res["corners"])
995
+ if trace is not None:
996
+ res["trace"] = trace_info
673
997
  return self._json(res)
674
998
 
675
999
  if u.path == "/api/frame":
@@ -689,6 +1013,88 @@ class Handler(BaseHTTPRequestHandler):
689
1013
  SESSION.update(fit_frame=idx)
690
1014
  return self._json({"index": idx})
691
1015
 
1016
+ if u.path == "/api/preview_video":
1017
+ # Watch the composite move before committing to a render.
1018
+ #
1019
+ # The one-frame preview cannot answer the two questions a CLIP
1020
+ # raises, and they are the two settings most likely to misbehave
1021
+ # over time: the light match is bound ONCE from the fitted frame
1022
+ # (deliberately -- measuring per frame makes the screen pulse as
1023
+ # the UI scrolls), so a badly chosen frame is wrong for the whole
1024
+ # clip; and the emissive blend mixes the screenshot with the
1025
+ # glass beneath it, so its effect changes as the content's own
1026
+ # brightness does. Neither shows in a still.
1027
+ #
1028
+ # So this is a real composite through the same pipeline, at a
1029
+ # smaller size -- not a CSS transform over a <video>, which would
1030
+ # show the geometry moving and NONE of the grade, grain or blend,
1031
+ # which is to say none of what it is for.
1032
+ _need_sources()
1033
+ photo, ppath = _read_image(SESSION.state["photo"])
1034
+ spath = _safe_local_path(SESSION.state["screenshot"])
1035
+ if not _is_video(spath):
1036
+ return self._json({"error": "the screen source is not a video"}, 400)
1037
+ if not _have_ffmpeg():
1038
+ return self._json({"error": "ffmpeg is not installed",
1039
+ "needs_ffmpeg": True}, 400)
1040
+ with RENDER_LOCK:
1041
+ if RENDER["state"] == "running":
1042
+ return self._json({"error": "a render is already running"}, 409)
1043
+ corners = _quad(b["corners"])
1044
+ frac = float(b.get("radius_frac") or 0.0)
1045
+ fit_frame = int(b.get("fit_frame") if b.get("fit_frame") is not None
1046
+ else _fit_frame())
1047
+ first = W.read_frame_at(spath, fit_frame)
1048
+ radius_px = frac * first.shape[1]
1049
+ gr = float(b.get("grade") if b.get("grade") is not None else 0.0)
1050
+ grain = bool(b.get("grain", gr > 0))
1051
+ blend, reflection = _blend_args(b)
1052
+ # Downscale the PHOTO and scale the quad with it, rather than
1053
+ # teaching compose_video about proxies: the same code path then
1054
+ # produces the preview, so what is watched is what will render.
1055
+ # The corner radius is in SCREENSHOT pixels and does not move.
1056
+ scale = min(1.0, PREVIEW_WIDTH / float(photo.shape[1]))
1057
+ if scale < 1.0:
1058
+ # EVEN dimensions, both of them. H.264 with yuv420p refuses an
1059
+ # odd width or height, and ffmpeg's way of refusing is to die
1060
+ # mid-stream -- which reaches Python as a BrokenPipeError on
1061
+ # the frame pipe, with the real complaint nowhere in sight.
1062
+ # Found by measuring rather than reading: 720 x 1536/2752
1063
+ # rounds to 401, and the whole preview vanished.
1064
+ def _even(v):
1065
+ return max(2, int(round(v / 2.0)) * 2)
1066
+ nw, nh = _even(photo.shape[1] * scale), _even(photo.shape[0] * scale)
1067
+ # Scale the quad by what the resize ACTUALLY did, not by the
1068
+ # ratio that was asked for: the evening moves it by up to a
1069
+ # pixel, and a quad scaled by the wrong factor is a fit that
1070
+ # does not match the preview it is shown in.
1071
+ sx, sy = nw / float(photo.shape[1]), nh / float(photo.shape[0])
1072
+ photo = cv2.resize(photo, (nw, nh), interpolation=cv2.INTER_AREA)
1073
+ corners = [[x * sx, y * sy] for x, y in corners]
1074
+ _n, _fps, _vw, _vh = W.probe_video(spath)
1075
+ max_frames = max(1, int(round(PREVIEW_SECONDS * (_fps or 30))))
1076
+ dest = os.path.join(SESSION.dir, "preview.mp4")
1077
+ with RENDER_LOCK:
1078
+ if RENDER["state"] == "running":
1079
+ return self._json({"error": "a render is already running"}, 409)
1080
+ RENDER.update(state="running", done=0, total=0, kind="preview",
1081
+ output=None, message=None)
1082
+ try:
1083
+ threading.Thread(target=_render_worker, daemon=True,
1084
+ args=(photo, spath, corners, dest, radius_px,
1085
+ gr, grain, "web", fit_frame,
1086
+ blend, reflection, None, "preview",
1087
+ fit_frame, max_frames),
1088
+ kwargs={"smoothing": _smoothing(b)}).start()
1089
+ except BaseException:
1090
+ with RENDER_LOCK:
1091
+ RENDER.update(state="error", message="could not start the preview")
1092
+ raise
1093
+ return self._json({"started": True, "preview": dest,
1094
+ "scale": round(scale, 4),
1095
+ "seconds": PREVIEW_SECONDS,
1096
+ "from_frame": fit_frame})
1097
+
692
1098
  if u.path == "/api/render":
693
1099
  # Video: same fit, same geometry, N frames instead of one.
694
1100
  _need_sources()
@@ -730,9 +1136,18 @@ class Handler(BaseHTTPRequestHandler):
730
1136
  # undercuts the determinism claim.
731
1137
  result = {"output": dest, "photo": ppath, "screenshot": spath,
732
1138
  "corners": corners, "radius_frac": frac, "radius_px": radius_px,
733
- "device": b.get("device"), "grade": gr, "grain": grain,
1139
+ "device": b.get("device"), "corner_smoothing": _smoothing(b),
1140
+ "grade": gr, "grain": grain,
734
1141
  "video": True, "preset": preset, "fit_frame": fit_frame,
735
- "blend": blend, "reflection": reflection}
1142
+ "blend": blend, "reflection": reflection,
1143
+ # A render is always the whole clip; only the preview
1144
+ # passes a segment. Recorded anyway, because the
1145
+ # sidecar's promise is EVERY argument that changes the
1146
+ # output — and if rendering a segment ever ships, the
1147
+ # recipe already carries it rather than silently
1148
+ # reproducing something else. test_sidecar.py caught
1149
+ # their absence the moment they were added.
1150
+ "start_frame": 0, "max_frames": None}
736
1151
  # `state="running"` means "a thread is running", so it is set
737
1152
  # here -- after every line that can raise, immediately before the
738
1153
  # thread exists. It used to be set at the top of this route, so a
@@ -744,13 +1159,14 @@ class Handler(BaseHTTPRequestHandler):
744
1159
  with RENDER_LOCK:
745
1160
  if RENDER["state"] == "running":
746
1161
  return self._json({"error": "a render is already running"}, 409)
747
- RENDER.update(state="running", done=0, total=0,
1162
+ RENDER.update(state="running", done=0, total=0, kind="render",
748
1163
  output=None, message=None)
749
1164
  try:
750
1165
  threading.Thread(target=_render_worker, daemon=True,
751
1166
  args=(photo, spath, corners, dest, radius_px,
752
1167
  gr, grain, preset, fit_frame,
753
- blend, reflection, result)).start()
1168
+ blend, reflection, result),
1169
+ kwargs={"smoothing": _smoothing(b)}).start()
754
1170
  except BaseException:
755
1171
  # If the thread cannot even be created, the flag must not
756
1172
  # outlive the request.
@@ -772,7 +1188,9 @@ class Handler(BaseHTTPRequestHandler):
772
1188
  # when the photograph is (a portfolio shot).
773
1189
  gr = float(b.get("grade") if b.get("grade") is not None else 0.0)
774
1190
  blend, reflection = _blend_args(b)
1191
+ smoothing = _smoothing(b)
775
1192
  out = W.compose(photo, shot, corners, radius_px,
1193
+ corner_smoothing=smoothing,
776
1194
  grade=gr, grain=bool(b.get("grain", gr > 0)),
777
1195
  blend=blend, reflection=reflection)
778
1196
  SESSION.update(corners=corners, radius_frac=frac, device=b.get("device"),
@@ -807,9 +1225,29 @@ class Handler(BaseHTTPRequestHandler):
807
1225
  # cannot be forgotten the same way.
808
1226
  result = {"output": dest, "photo": ppath, "screenshot": spath, "corners": corners,
809
1227
  "radius_frac": frac, "radius_px": radius_px, "device": b.get("device"),
1228
+ "corner_smoothing": smoothing,
810
1229
  "grade": gr, "grain": bool(b.get("grain", gr > 0)),
811
1230
  "blend": blend, "reflection": reflection,
812
1231
  "saved": time.time()}
1232
+ # A fit is remembered when it PRODUCED something, not while it
1233
+ # is being dragged: a quad on the canvas is a work in progress,
1234
+ # a quad that made an output is one the person looked at and
1235
+ # kept. Next run on this photograph starts from it.
1236
+ key = FIT.key_for(photo)
1237
+ FIT.remember(key, corners, frac, b.get("device"),
1238
+ ppath, (photo.shape[1], photo.shape[0]))
1239
+ # ...and the portable half: a file beside the mockup, in the
1240
+ # folder the user already chose. The store above is invisible and
1241
+ # keyed on this machine's copy of the pixels; this one can be
1242
+ # found in Finder, kept with the project, sent to someone, and
1243
+ # dragged back in against a re-export the key would miss.
1244
+ result["fit_file"] = FF.write(dest, FF.build(
1245
+ corners, frac, b.get("device"), ppath,
1246
+ (photo.shape[1], photo.shape[0]), key))
1247
+ # Sidecar first, THEN the session output: /api/import reads that
1248
+ # pointer and may be called the moment this returns, and the
1249
+ # bug template asks for the sidecar first. Same publication order
1250
+ # the render worker uses, and for the same reason.
813
1251
  _write_json_atomic(SESSION.result_path, result)
814
1252
  SESSION.update(output=dest)
815
1253
  return self._json(result)