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/README.md +54 -9
- package/package.json +1 -1
- package/scripts/detect.py +277 -31
- package/scripts/fitfile.py +140 -0
- package/scripts/fits.py +151 -0
- package/scripts/ui.py +483 -45
- package/scripts/warp.py +155 -7
- package/skills/inject-screenshot/SKILL.md +3 -3
- package/ui/index.html +723 -67
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
|
-
#
|
|
80
|
-
#
|
|
81
|
-
#
|
|
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
|
-
|
|
84
|
-
{"id": "phone-iphone-
|
|
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,
|
|
162
|
-
|
|
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,
|
|
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
|
-
|
|
481
|
-
|
|
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
|
-
|
|
485
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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"), "
|
|
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)
|
|
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)
|