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 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.0",
4
- "description": "Put a UI screenshot or screen recording onto a photographed device screen with the perspective exactly right a homography you confirm by hand, not a generative guess.",
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": "https://github.com/seq000/screengraft/issues",
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 = W.read_frame_at(real, 0)
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
- frame = W.read_frame_at(spath, idx)
461
- dest = os.path.join(SESSION.dir, f"frame-{idx:06d}.png")
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") or 0)
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="group" aria-label="Output format">
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
- $('#pv'+n).innerHTML = `<img src="${fileURL(path)}" alt="">`;
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(path)}")`;
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 => o.classList.toggle('sel', o === btn));
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
- const r = await api('/api/frame', {index: +$('#vframe').value});
1883
- $('#pv2').innerHTML = `<img src="${fileURL(r.path)}" alt="">`;
1884
- $('#sw2').style.background = `center/cover no-repeat url("${fileURL(r.path)}")`;
1885
- autoPreview();
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