screengraft 0.21.0 → 0.21.1
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 +16 -0
- package/package.json +8 -4
- package/scripts/ui.py +33 -8
- package/ui/index.html +22 -9
package/README.md
CHANGED
|
@@ -146,6 +146,22 @@ the background is itself neutral (a pale tiled floor, a plain wall) — you plac
|
|
|
146
146
|
the edges by hand there. And a prototype recording has no motion blur, so a very
|
|
147
147
|
fast scroll will strobe; that is a property of the source, not of the composite.
|
|
148
148
|
|
|
149
|
+
## Changelog
|
|
150
|
+
|
|
151
|
+
Every release is described in [CHANGELOG.md](CHANGELOG.md), with the
|
|
152
|
+
measurements that drove it.
|
|
153
|
+
|
|
154
|
+
## Support
|
|
155
|
+
|
|
156
|
+
Bugs and photographs that defeat the detector belong in
|
|
157
|
+
[Issues](https://github.com/seq000/screengraft/issues) — the bug template asks
|
|
158
|
+
first for the `result.json` sidecar, because it reproduces any composite
|
|
159
|
+
exactly. Ideas and "can it do X" go in
|
|
160
|
+
[Discussions](https://github.com/seq000/screengraft/discussions).
|
|
161
|
+
|
|
162
|
+
If the photograph or the UI is confidential — a client shot, something
|
|
163
|
+
unreleased — email **screengraft@fraczyk.design** instead of posting it.
|
|
164
|
+
|
|
149
165
|
## Contributing
|
|
150
166
|
|
|
151
167
|
See [CONTRIBUTING.md](CONTRIBUTING.md). The short version: there are tests, they
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "screengraft",
|
|
3
|
-
"version": "0.21.
|
|
4
|
-
"description": "Put a UI screenshot or screen recording onto a photographed device screen with the perspective exactly right
|
|
3
|
+
"version": "0.21.1",
|
|
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",
|
|
7
7
|
"device-frame",
|
|
@@ -17,7 +17,10 @@
|
|
|
17
17
|
"figma"
|
|
18
18
|
],
|
|
19
19
|
"homepage": "https://github.com/seq000/screengraft#readme",
|
|
20
|
-
"bugs":
|
|
20
|
+
"bugs": {
|
|
21
|
+
"url": "https://github.com/seq000/screengraft/issues",
|
|
22
|
+
"email": "screengraft@fraczyk.design"
|
|
23
|
+
},
|
|
21
24
|
"repository": {
|
|
22
25
|
"type": "git",
|
|
23
26
|
"url": "git+https://github.com/seq000/screengraft.git"
|
|
@@ -25,7 +28,8 @@
|
|
|
25
28
|
"license": "MIT",
|
|
26
29
|
"author": {
|
|
27
30
|
"name": "Dariusz Fraczyk",
|
|
28
|
-
"url": "https://fraczyk.design"
|
|
31
|
+
"url": "https://fraczyk.design",
|
|
32
|
+
"email": "screengraft@fraczyk.design"
|
|
29
33
|
},
|
|
30
34
|
"type": "commonjs",
|
|
31
35
|
"bin": {
|
package/scripts/ui.py
CHANGED
|
@@ -183,6 +183,13 @@ def _is_video(path: str) -> bool:
|
|
|
183
183
|
return str(path).lower().endswith(VIDEO_EXT)
|
|
184
184
|
|
|
185
185
|
|
|
186
|
+
def _fit_frame() -> int:
|
|
187
|
+
try:
|
|
188
|
+
return int(SESSION.state.get("fit_frame") or 0)
|
|
189
|
+
except (TypeError, ValueError):
|
|
190
|
+
return 0
|
|
191
|
+
|
|
192
|
+
|
|
186
193
|
def _read_source(path: str):
|
|
187
194
|
"""Read the screen source, which may be a still OR a video.
|
|
188
195
|
|
|
@@ -197,13 +204,27 @@ def _read_source(path: str):
|
|
|
197
204
|
im, rp = _read_image(real)
|
|
198
205
|
return im, rp, {"video": False}
|
|
199
206
|
n, fps, vw, vh = W.probe_video(real)
|
|
200
|
-
frame
|
|
207
|
+
# The frame the designer scrubbed to, NOT frame 0. Preview and Save read the
|
|
208
|
+
# source through here, so reading frame 0 unconditionally made the scrubber
|
|
209
|
+
# look decorative: it moved the thumbnail and the composite never changed
|
|
210
|
+
# (reported 9 Sep 2026). The fitted frame is session state for exactly this
|
|
211
|
+
# reason — more than one route needs it.
|
|
212
|
+
frame = W.read_frame_at(real, _fit_frame())
|
|
201
213
|
# Report the encoder's absence HERE, when the clip is chosen, rather than
|
|
202
214
|
# letting the render fail at the end of the job. Someone who installed
|
|
203
215
|
# screengraft before video existed has a working venv with no ffmpeg in it,
|
|
204
216
|
# and nothing else would tell them until they had done all the fitting.
|
|
217
|
+
# A poster for the chip. The chip used to be handed the .mov path directly,
|
|
218
|
+
# and an <img> cannot render a video, so the thumbnail was silently blank for
|
|
219
|
+
# every clip. It is always FRAME 0 and never follows the scrubber: at chip
|
|
220
|
+
# size one frame looks like any other, so redrawing it would be movement
|
|
221
|
+
# without information.
|
|
222
|
+
poster = os.path.join(SESSION.dir,
|
|
223
|
+
"poster-" + os.path.splitext(os.path.basename(real))[0] + ".jpg")
|
|
224
|
+
if not os.path.exists(poster):
|
|
225
|
+
cv2.imwrite(poster, W.read_frame_at(real, 0), [cv2.IMWRITE_JPEG_QUALITY, 82])
|
|
205
226
|
return frame, real, {"video": True, "frames": n, "fps": fps, "size": [vw, vh],
|
|
206
|
-
"ffmpeg": _have_ffmpeg()}
|
|
227
|
+
"ffmpeg": _have_ffmpeg(), "poster": poster}
|
|
207
228
|
|
|
208
229
|
|
|
209
230
|
# Render progress, read by /api/render_status. A ten-second clip is a few
|
|
@@ -364,6 +385,7 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
364
385
|
f.write(self._body())
|
|
365
386
|
# A video is only ever a screen source; a photo must be a still.
|
|
366
387
|
if role == "screenshot":
|
|
388
|
+
SESSION.update(fit_frame=0)
|
|
367
389
|
im, real, meta = _read_source(dest)
|
|
368
390
|
else:
|
|
369
391
|
im, real = _read_image(dest)
|
|
@@ -376,6 +398,7 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
376
398
|
if u.path == "/api/use":
|
|
377
399
|
role = b["role"]
|
|
378
400
|
if role == "screenshot":
|
|
401
|
+
SESSION.update(fit_frame=0)
|
|
379
402
|
im, real, meta = _read_source(b["path"])
|
|
380
403
|
else:
|
|
381
404
|
im, real = _read_image(b["path"])
|
|
@@ -456,12 +479,13 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
456
479
|
spath = _safe_local_path(SESSION.state["screenshot"])
|
|
457
480
|
if not _is_video(spath):
|
|
458
481
|
return self._json({"error": "the screen source is not a video"}, 400)
|
|
482
|
+
# Records which frame the fit is judged on. Nothing is written:
|
|
483
|
+
# the page re-renders the COMPOSITE from it, and the chip stays
|
|
484
|
+
# on frame 0 deliberately, so a per-step PNG would be disk churn
|
|
485
|
+
# nobody looks at.
|
|
459
486
|
idx = int(b.get("index") or 0)
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
cv2.imwrite(dest, frame, [cv2.IMWRITE_PNG_COMPRESSION, 1])
|
|
463
|
-
return self._json({"path": dest, "index": idx,
|
|
464
|
-
"size": [frame.shape[1], frame.shape[0]]})
|
|
487
|
+
SESSION.update(fit_frame=idx)
|
|
488
|
+
return self._json({"index": idx})
|
|
465
489
|
|
|
466
490
|
if u.path == "/api/render":
|
|
467
491
|
# Video: same fit, same geometry, N frames instead of one.
|
|
@@ -479,7 +503,8 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
479
503
|
output=None, message=None)
|
|
480
504
|
corners = b["corners"]
|
|
481
505
|
frac = float(b.get("radius_frac") or 0.0)
|
|
482
|
-
fit_frame = int(b.get("fit_frame")
|
|
506
|
+
fit_frame = int(b.get("fit_frame") if b.get("fit_frame") is not None
|
|
507
|
+
else _fit_frame())
|
|
483
508
|
first = W.read_frame_at(spath, fit_frame)
|
|
484
509
|
radius_px = frac * first.shape[1]
|
|
485
510
|
gr = float(b.get("grade") if b.get("grade") is not None else 0.0)
|
package/ui/index.html
CHANGED
|
@@ -221,6 +221,12 @@
|
|
|
221
221
|
buttons beside them. Only the corner radii and the shared middle border
|
|
222
222
|
are special. */
|
|
223
223
|
.stepper{display:inline-flex;align-items:center}
|
|
224
|
+
/* A segmented control has to show which segment is on. `.sel` was only ever
|
|
225
|
+
styled for the thumbnail picker (.th.sel), so the web/ProRes pair rendered
|
|
226
|
+
with no selected state at all — reported 9 Sep 2026. Same step as the
|
|
227
|
+
device chips: selected sits on --raise-hi with the brighter border. */
|
|
228
|
+
.stepper button.sel{background:var(--raise-hi);border-color:var(--edge-hi);color:var(--ink)}
|
|
229
|
+
.stepper button.sel:hover{background:var(--raise-highest);border-color:var(--edge-hi)}
|
|
224
230
|
.stepper button{border-radius:0}
|
|
225
231
|
.stepper button:first-child{border-top-left-radius:var(--r-sm);border-bottom-left-radius:var(--r-sm)}
|
|
226
232
|
.stepper button:last-child{border-top-right-radius:var(--r-sm);border-bottom-right-radius:var(--r-sm)}
|
|
@@ -758,8 +764,8 @@
|
|
|
758
764
|
<input type="range" id="vframe" min="0" max="0" value="0" step="1"
|
|
759
765
|
aria-label="Which frame of the clip to match the edges on">
|
|
760
766
|
<span class="sm" id="vframeLbl" style="color:var(--mute);min-width:9ch">0</span>
|
|
761
|
-
<span class="stepper" role="
|
|
762
|
-
<button class="sm sel" data-preset="web" title="H.264 at CRF 16 — near-visually-lossless, plays anywhere.">Web</button><button class="sm" data-preset="prores" title="ProRes 422 HQ — bigger file, no chroma subsampling. For a case study or further editing.">ProRes</button>
|
|
767
|
+
<span class="stepper" role="radiogroup" aria-label="Output format">
|
|
768
|
+
<button class="sm sel" data-preset="web" role="radio" aria-checked="true" title="H.264 at CRF 16 — near-visually-lossless, plays anywhere.">Web</button><button class="sm" data-preset="prores" role="radio" aria-checked="false" title="ProRes 422 HQ — bigger file, no chroma subsampling. For a case study or further editing.">ProRes</button>
|
|
763
769
|
</span>
|
|
764
770
|
</span>
|
|
765
771
|
</div>
|
|
@@ -1147,12 +1153,14 @@ function setChosen(role, path, size, el, meta){
|
|
|
1147
1153
|
const n = role==='photo'?1:2;
|
|
1148
1154
|
document.querySelectorAll('#recent'+n+' .th').forEach(x=>x.classList.remove('sel'));
|
|
1149
1155
|
if (el) el.classList.add('sel');
|
|
1150
|
-
|
|
1156
|
+
// A video cannot be shown by <img>, so a clip gets its poster frame instead.
|
|
1157
|
+
const thumb = (meta && meta.poster) ? meta.poster : path;
|
|
1158
|
+
$('#pv'+n).innerHTML = `<img src="${fileURL(thumb)}" alt="">`;
|
|
1151
1159
|
const chip = $('#chip'+n);
|
|
1152
1160
|
chip.classList.remove('is-empty');
|
|
1153
1161
|
chip.querySelector('.nm').textContent = `${path.split('/').pop()} · ${size[0]}×${size[1]}`
|
|
1154
1162
|
+ (meta && meta.video ? ` · ${meta.frames} frames @ ${Math.round(meta.fps)}fps` : '');
|
|
1155
|
-
$('#sw'+n).style.background = `center/cover no-repeat url("${fileURL(
|
|
1163
|
+
$('#sw'+n).style.background = `center/cover no-repeat url("${fileURL(thumb)}")`;
|
|
1156
1164
|
if (role==='photo'){ st.photo = path; st.corners = null; }
|
|
1157
1165
|
else { st.shot = path; st.video = meta && meta.video ? meta : null; syncVideoUI(); }
|
|
1158
1166
|
closePop();
|
|
@@ -1846,7 +1854,11 @@ let preset = 'web';
|
|
|
1846
1854
|
document.querySelectorAll('[data-preset]').forEach(btn => {
|
|
1847
1855
|
btn.onclick = () => {
|
|
1848
1856
|
preset = btn.dataset.preset;
|
|
1849
|
-
document.querySelectorAll('[data-preset]').forEach(o =>
|
|
1857
|
+
document.querySelectorAll('[data-preset]').forEach(o => {
|
|
1858
|
+
const on = o === btn;
|
|
1859
|
+
o.classList.toggle('sel', on);
|
|
1860
|
+
o.setAttribute('aria-checked', on ? 'true' : 'false');
|
|
1861
|
+
});
|
|
1850
1862
|
};
|
|
1851
1863
|
});
|
|
1852
1864
|
|
|
@@ -1879,10 +1891,11 @@ $('#vframe').onchange = async () => {
|
|
|
1879
1891
|
// Swapping the poster frame re-runs preview through the same path a still
|
|
1880
1892
|
// uses, so what you match the edges against is the frame you chose.
|
|
1881
1893
|
try{
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1894
|
+
await api('/api/frame', {index: +$('#vframe').value});
|
|
1895
|
+
// Only the composite changes. The chip keeps frame 0 on purpose — at that
|
|
1896
|
+
// size one frame looks like any other, so updating it would be movement
|
|
1897
|
+
// without meaning.
|
|
1898
|
+
if (st.corners) renderPreview();
|
|
1886
1899
|
}catch(e){ toast('err', 'Could not read that frame: ' + e.message); }
|
|
1887
1900
|
};
|
|
1888
1901
|
|