screengraft 0.20.4 → 0.21.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 CHANGED
@@ -29,6 +29,11 @@ prototype, then put the recording inside a real photograph.
29
29
  - **Realism pass** *(optional)* — matches the screen's white balance and grain to
30
30
  the light in the room, and can lift the device's real reflections from a
31
31
  screen-off frame of the same shot.
32
+ - **Emissive screens** *(optional)* — a display emits light *and* reflects the
33
+ room, which is why a switched-off phone looks dark grey rather than black.
34
+ Paint a true-black UI on flat and it reads as a hole cut in the photo. Turn
35
+ this on and the screenshot composites over the device's own glass, so the
36
+ photo's highlights carry across the screen.
32
37
  - **Video, not just stills.** The screen source can be an `mp4`/`mov`/`webm`.
33
38
  You match the edges on one frame and every frame gets that same geometry — the
34
39
  photograph is still, so there is nothing to track and nothing to drift. Output
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "screengraft",
3
- "version": "0.20.4",
3
+ "version": "0.21.0",
4
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.",
5
5
  "keywords": [
6
6
  "mockup",
package/scripts/ui.py CHANGED
@@ -215,14 +215,18 @@ RENDER = {"state": "idle", "done": 0, "total": 0, "output": None, "message": Non
215
215
  RENDER_LOCK = threading.Lock()
216
216
 
217
217
 
218
- def _render_worker(photo, video_path, corners, dest, radius_px, gr, grain, preset, fit_frame):
218
+ def _render_worker(photo, video_path, corners, dest, radius_px, gr, grain, preset, fit_frame,
219
+ blend="replace", reflection=None):
219
220
  def progress(done, total):
220
221
  with RENDER_LOCK:
221
222
  RENDER["done"], RENDER["total"] = done, total
222
223
  try:
223
224
  info = W.compose_video(photo, video_path, corners, dest,
224
225
  corner_radius=radius_px, grade=gr, grain=grain,
225
- preset=preset, fit_frame=fit_frame, progress=progress)
226
+ preset=preset, fit_frame=fit_frame, progress=progress,
227
+ blend=blend,
228
+ reflection=(W.DEFAULT_REFLECTION if reflection is None
229
+ else reflection))
226
230
  with RENDER_LOCK:
227
231
  RENDER.update(state="done", output=dest, info=info,
228
232
  done=info["frames"], total=info["frames"], message=None)
@@ -231,6 +235,21 @@ def _render_worker(photo, video_path, corners, dest, radius_px, gr, grain, prese
231
235
  RENDER.update(state="error", message=str(e))
232
236
 
233
237
 
238
+ def _blend_args(b):
239
+ """(blend, reflection) from the page's single `reflection` field.
240
+
241
+ One field, not two: the page sends a number when the switch is on and null
242
+ when it is off, so there is no way to express the contradictory state
243
+ "emissive with no strength" — which is just `replace` under a different
244
+ name. compose() still takes both, because the engine should not have to
245
+ infer intent from a null.
246
+ """
247
+ r = b.get("reflection")
248
+ if r is None:
249
+ return "replace", W.DEFAULT_REFLECTION
250
+ return "emissive", float(max(0.0, min(1.0, float(r))))
251
+
252
+
234
253
  def _guess_type(corners):
235
254
  c = np.array(corners, dtype=float)
236
255
  w = (np.linalg.norm(c[1] - c[0]) + np.linalg.norm(c[2] - c[3])) / 2
@@ -465,6 +484,7 @@ class Handler(BaseHTTPRequestHandler):
465
484
  radius_px = frac * first.shape[1]
466
485
  gr = float(b.get("grade") if b.get("grade") is not None else 0.0)
467
486
  grain = bool(b.get("grain", gr > 0))
487
+ blend, reflection = _blend_args(b)
468
488
  preset = "prores" if b.get("preset") == "prores" else "web"
469
489
  ext = ".mov" if preset == "prores" else ".mp4"
470
490
  os.makedirs(OUT_DIR, exist_ok=True)
@@ -484,11 +504,13 @@ class Handler(BaseHTTPRequestHandler):
484
504
  "corners": corners, "radius_frac": frac, "radius_px": radius_px,
485
505
  "device": b.get("device"), "grade": gr, "grain": grain,
486
506
  "video": True, "preset": preset, "fit_frame": fit_frame,
507
+ "blend": blend, "reflection": reflection,
487
508
  "saved": time.time()}
488
509
  _write_json_atomic(SESSION.result_path, result)
489
510
  threading.Thread(target=_render_worker, daemon=True,
490
511
  args=(photo, spath, corners, dest, radius_px,
491
- gr, grain, preset, fit_frame)).start()
512
+ gr, grain, preset, fit_frame,
513
+ blend, reflection)).start()
492
514
  return self._json({"started": True, "output": dest, "preset": preset})
493
515
 
494
516
  if u.path in ("/api/preview", "/api/save"):
@@ -502,8 +524,10 @@ class Handler(BaseHTTPRequestHandler):
502
524
  # is the point (a brand review), and the grade is the right one
503
525
  # when the photograph is (a portfolio shot).
504
526
  gr = float(b.get("grade") if b.get("grade") is not None else 0.0)
527
+ blend, reflection = _blend_args(b)
505
528
  out = W.compose(photo, shot, corners, radius_px,
506
- grade=gr, grain=bool(b.get("grain", gr > 0)))
529
+ grade=gr, grain=bool(b.get("grain", gr > 0)),
530
+ blend=blend, reflection=reflection)
507
531
  SESSION.update(corners=corners, radius_frac=frac, device=b.get("device"),
508
532
  grade=gr)
509
533
  if u.path == "/api/preview":
@@ -537,6 +561,7 @@ class Handler(BaseHTTPRequestHandler):
537
561
  result = {"output": dest, "photo": ppath, "screenshot": spath, "corners": corners,
538
562
  "radius_frac": frac, "radius_px": radius_px, "device": b.get("device"),
539
563
  "grade": gr, "grain": bool(b.get("grain", gr > 0)),
564
+ "blend": blend, "reflection": reflection,
540
565
  "saved": time.time()}
541
566
  _write_json_atomic(SESSION.result_path, result)
542
567
  SESSION.update(output=dest)
package/scripts/warp.py CHANGED
@@ -40,6 +40,12 @@ import grade as _grade # M2: the realism pass
40
40
 
41
41
  MASK_SS = 4 # destination-space supersampling for the screen's edge
42
42
 
43
+ # How much of the device's own glass shows through under an emissive screen.
44
+ # Measured 9 Sep 2026 on an automotive render whose UI is 56% true black: at 0
45
+ # the screen is a hole, by 25% it sits in the scene, 50% reads clearly as glass,
46
+ # and past 75% the content loses contrast. 0.35 is the middle of the usable band.
47
+ DEFAULT_REFLECTION = 0.35
48
+
43
49
 
44
50
  def rounded_mask(w: int, h: int, radius: float) -> np.ndarray:
45
51
  """White-on-black mask, full frame minus rounded corners cut to black.
@@ -146,13 +152,16 @@ class Plan:
146
152
  """
147
153
 
148
154
  def __init__(self, photo: np.ndarray, frame_shape, corners,
149
- corner_radius: float = 0.0, grain: bool = False):
155
+ corner_radius: float = 0.0, grain: bool = False,
156
+ blend: str = "replace", reflection: float = DEFAULT_REFLECTION):
150
157
  dst_quad = np.array(corners, dtype=np.float32)
151
158
  if shoelace_area(dst_quad) < 1.0:
152
159
  raise ValueError("degenerate quad (near-zero area) — check corner order TL,TR,BR,BL")
153
160
  self.photo = photo
154
161
  self.dst_quad = dst_quad
155
162
  self.grain = grain
163
+ self.blend = blend if blend in ("replace", "emissive") else "replace"
164
+ self.reflection = float(np.clip(reflection, 0.0, 1.0))
156
165
 
157
166
  top = float(np.linalg.norm(dst_quad[1] - dst_quad[0]))
158
167
  bottom = float(np.linalg.norm(dst_quad[2] - dst_quad[3]))
@@ -215,6 +224,31 @@ class Plan:
215
224
  self.grade_params = _grade.light_params(
216
225
  self.photo, self._prep(frame), self.warped_mask, strength) if strength > 0 else None
217
226
 
227
+ def _blend(self, photo_win, warped_win):
228
+ """Emitted light over reflected light, or a plain replace.
229
+
230
+ `replace` treats the screenshot as paint: the device's own screen
231
+ surface is discarded. That is right for a reflective surface and wrong
232
+ for an emissive one — a real display shows EMISSION PLUS the room
233
+ reflecting off its glass, which is why a switched-off phone reads dark
234
+ grey and never black. Paint true black onto a lit dashboard and it
235
+ reads as a hole cut in the render (reported 9 Sep 2026 from an
236
+ automotive UI that is 56% #000).
237
+
238
+ `emissive` composites the screenshot OVER the surface instead, with a
239
+ screen blend so highlights cannot blow out, and `reflection` scaling how
240
+ much of the glass survives underneath. The payoff is not only the black
241
+ level: the specular streak running across the dashboard continues
242
+ across the screen, and that continuity is what stops a composite
243
+ reading as an inset panel. No amount of colour-matching can add it,
244
+ because the surface carrying it has already been thrown away.
245
+ """
246
+ if self.blend != "emissive":
247
+ return warped_win
248
+ P = photo_win.astype(np.float32) * float(self.reflection)
249
+ U = warped_win.astype(np.float32)
250
+ return 255.0 - (255.0 - P) * (255.0 - U) / 255.0
251
+
218
252
  def render(self, frame: np.ndarray, screen_off: np.ndarray = None,
219
253
  specular: float = 0.75, fast: bool = False) -> np.ndarray:
220
254
  """Composite one frame onto the photo.
@@ -231,15 +265,16 @@ class Plan:
231
265
  warped_screen = _grade.apply_light(warped_screen, self.grade_params)
232
266
  out = self.photo.copy()
233
267
  win = self.mask3[y0:y1, x0:x1]
268
+ pw = self.photo[y0:y1, x0:x1]
269
+ src = self._blend(pw, warped_screen)
234
270
  out[y0:y1, x0:x1] = np.clip(
235
- self.photo[y0:y1, x0:x1].astype(np.float32) * (1 - win)
236
- + warped_screen.astype(np.float32) * win, 0, 255).astype(np.uint8)
271
+ pw.astype(np.float32) * (1 - win) + src * win, 0, 255).astype(np.uint8)
237
272
  else:
238
273
  warped_screen = self._prep(frame)
239
274
  if self.grade_params is not None:
240
275
  warped_screen = _grade.apply_light(warped_screen, self.grade_params)
241
- out = (self.photo.astype(np.float32) * (1 - self.mask3)
242
- + warped_screen.astype(np.float32) * self.mask3)
276
+ src = self._blend(self.photo, warped_screen)
277
+ out = (self.photo.astype(np.float32) * (1 - self.mask3) + src * self.mask3)
243
278
  out = np.clip(out, 0, 255).astype(np.uint8)
244
279
  if self.grain:
245
280
  # Seeded, so the grain is IDENTICAL in every frame. Over a still
@@ -254,7 +289,8 @@ class Plan:
254
289
 
255
290
  def compose(photo: np.ndarray, screenshot: np.ndarray, corners, corner_radius: float = 0.0,
256
291
  grade: float = 0.0, grain: bool = False, screen_off: np.ndarray = None,
257
- specular: float = 0.75) -> np.ndarray:
292
+ specular: float = 0.75, blend: str = "replace",
293
+ reflection: float = DEFAULT_REFLECTION) -> np.ndarray:
258
294
  """Warp `screenshot` into the quad `corners` (TL,TR,BR,BL, photo pixels) on `photo`.
259
295
 
260
296
  Single resampling pass at the photo's resolution; deterministic. This is the
@@ -265,7 +301,8 @@ def compose(photo: np.ndarray, screenshot: np.ndarray, corners, corner_radius: f
265
301
  # specular) now lives in Plan, so the still and video paths run the SAME
266
302
  # code and cannot drift apart. See test_video.py: frame 0 of a render is
267
303
  # asserted byte-identical to this function's output.
268
- plan = Plan(photo, screenshot.shape, corners, corner_radius, grain=grain)
304
+ plan = Plan(photo, screenshot.shape, corners, corner_radius, grain=grain,
305
+ blend=blend, reflection=reflection)
269
306
  plan.bind_grade(screenshot, grade)
270
307
  return plan.render(screenshot, screen_off=screen_off, specular=specular)
271
308
 
@@ -349,7 +386,8 @@ def read_frame_at(path: str, index: int = 0):
349
386
  def compose_video(photo: np.ndarray, video_path: str, corners, output: str,
350
387
  corner_radius: float = 0.0, grade: float = 0.0, grain: bool = False,
351
388
  preset: str = "web", fit_frame: int = 0, audio: bool = True,
352
- frames_dir: str = None, progress=None) -> dict:
389
+ frames_dir: str = None, progress=None, blend: str = "replace",
390
+ reflection: float = DEFAULT_REFLECTION) -> dict:
353
391
  """Inject a VIDEO into a still photo. The photo does not move, so there is
354
392
  exactly one homography and the whole of Plan is computed once.
355
393
 
@@ -367,7 +405,8 @@ def compose_video(photo: np.ndarray, video_path: str, corners, output: str,
367
405
  """
368
406
  n_hint, fps, vw, vh = probe_video(video_path)
369
407
  first = read_frame_at(video_path, fit_frame)
370
- plan = Plan(photo, first.shape, corners, corner_radius, grain=grain)
408
+ plan = Plan(photo, first.shape, corners, corner_radius, grain=grain,
409
+ blend=blend, reflection=reflection)
371
410
  plan.bind_grade(first, grade)
372
411
 
373
412
  ph, pw = photo.shape[:2]
@@ -413,7 +452,8 @@ def compose_video(photo: np.ndarray, video_path: str, corners, output: str,
413
452
  if count == 0:
414
453
  raise RuntimeError(f"no frames could be read from {video_path}")
415
454
  return {"frames": count, "fps": fps, "source_size": [vw, vh],
416
- "output_size": [pw, ph], "preset": preset, "fit_frame": fit_frame}
455
+ "output_size": [pw, ph], "preset": preset, "fit_frame": fit_frame,
456
+ "blend": blend, "reflection": plan.reflection}
417
457
 
418
458
 
419
459
  def main() -> None:
@@ -5,10 +5,12 @@ description: Injects a UI screenshot OR a screen recording onto a photographed d
5
5
 
6
6
  # Inject a screenshot onto a photographed device
7
7
 
8
- **What ships (v0.20):** a local browser UI (`scripts/ui.py`) that walks the designer through the whole job — pick the photo and the screen source, which may be an image **or a video** (recent Desktop/Downloads images, drag-drop, browse, path, or a **Figma frame link**), auto-detect the screen as a starting position, then **match the four edges** (drag an edge's middle to slide it, near an end to pivot; corners still draggable) with canvas zoom/pan and a rectified strip loupe. The fit and the composite sit **side by side and always have** — the result pane re-renders as you drag, which is how a corner gets judged, so it is the layout rather than a mode you can switch off. Then an on-by-default realism pass that colour-matches the source to the photo's light, **Save** (or **Render**, for a video) into the project folder (`--out-dir`), and a **Send to Claude** button that reaches you through the plugin's own MCP server. The UI is a hand port of the project's Figma design file — dark only.
8
+ **What ships (v0.21):** a local browser UI (`scripts/ui.py`) that walks the designer through the whole job — pick the photo and the screen source, which may be an image **or a video** (recent Desktop/Downloads images, drag-drop, browse, path, or a **Figma frame link**), auto-detect the screen as a starting position, then **match the four edges** (drag an edge's middle to slide it, near an end to pivot; corners still draggable) with canvas zoom/pan and a rectified strip loupe. The fit and the composite sit **side by side and always have** — the result pane re-renders as you drag, which is how a corner gets judged, so it is the layout rather than a mode you can switch off. Then an on-by-default realism pass that colour-matches the source to the photo's light, **Save** (or **Render**, for a video) into the project folder (`--out-dir`), and a **Send to Claude** button that reaches you through the plugin's own MCP server. The UI is a hand port of the project's Figma design file — dark only.
9
9
 
10
10
  The geometry is exact (`warp.py`); the detection is advisory (`detect.py`) and the human corrects it.
11
11
 
12
+ **Emissive screen** *(off by default)*: a real display shows its own light **plus** the room reflecting off its glass — which is why a switched-off phone reads dark grey and never black. The default `replace` treats the screenshot as paint and discards the device's own screen surface, so a **true-black UI lands as a hole cut in the photo**, and the specular streak running across a dashboard or a phone stops dead at the screen edge. Turn Emissive on and the screenshot is composited *over* the glass instead: blacks become the device's own surface, and the photo's reflections carry across the screen. The strength sets how much glass shows through — **25–50% is the usable band**, past 75% the content loses contrast. Suggest it whenever the user's UI is dark and the composite reads as a flat inset; the realism pass cannot fix that, because it can only lift uniformly and the surface carrying the gradient has already been thrown away.
13
+
12
14
  **The realism pass ships and is ON by default** (`grade.py`): it matches the injected screen's white balance and grain to the light around it, at a strength the designer sets in the rail. It can also lift the device's real specular highlights from a screen-off reference frame, though the UI cannot supply one yet. Off is a first-class choice and keeps the screenshot's colour exactly — say so if the user is reviewing brand colour.
13
15
 
14
16
  **Video ships too.** The screen source can be a video (mp4/mov/webm) as well as a still — pick it exactly like a screenshot, choose which frame to match the edges on, and the primary button becomes **Render**. The photo does not move, so there is one homography and every frame gets the same geometry; the light match is measured once from the frame you fitted on, so the screen cannot pulse as the UI scrolls. Output is H.264 at CRF 16 (near-visually-lossless) or ProRes 422 HQ. This is what pairs with a prototype recording: record the prototype, then inject the recording into a real photograph.
package/ui/index.html CHANGED
@@ -803,6 +803,23 @@
803
803
  </div>
804
804
  </div></div>
805
805
  </div>
806
+ <div class="sect" id="secEmissive" data-on="0">
807
+ <div class="sect-top">
808
+ <div class="sect-left">
809
+ <h4>Emissive screen</h4>
810
+ <button class="info" type="button" aria-label="About emissive screen"><svg viewBox="0 0 16 16" fill="none" aria-hidden="true"><path fill="currentColor" d="M8.36 1.01C12.06 1.2 15 4.26 15 8l-.01.36C14.8 12.06 11.74 15 8 15l-.36-.01C4.06 14.81 1.19 11.94 1.01 8.36L1 8c0-3.87 3.13-7 7-7l.36.01ZM8 2C4.69 2 2 4.69 2 8c0 3.31 2.69 6 6 6 3.31 0 6-2.69 6-6 0-3.31-2.69-6-6-6Zm.5 8l.5 0v1L7 11v-1h.5V7H7V6h1.5v4ZM7.9 4c.33 0 .6.27.6.6a.6.6 0 1 1-1.21 0c0-.33.28-.6.61-.6Z"/></svg><span class="tip" role="tooltip">A real display shows its own light PLUS the room reflecting off its glass — which is why a switched-off phone looks dark grey, never black. Off paints the screenshot on flat, so a true-black UI reads as a hole cut in the photo. On composites it over the glass, so the reflections and highlights already in your photo carry across the screen. Strength sets how much glass shows through.</span></button>
811
+ </div>
812
+ <button class="switch" id="emisBtn" role="switch" aria-checked="false" aria-label="Emissive screen">
813
+ <span class="sw-track"><span class="sw-handle"></span><span class="sw-grip"></span></span>
814
+ </button>
815
+ </div>
816
+ <div class="sect-body"><div class="sect-body-in">
817
+ <div style="display:grid;gap:var(--s2);justify-items:end">
818
+ <span class="sm" id="emisVal" style="font-variant-numeric:tabular-nums"></span>
819
+ <input type="range" id="emisAmt" min="0" max="1" step="0.05" value="0.35" aria-label="Reflection strength">
820
+ </div>
821
+ </div></div>
822
+ </div>
806
823
  <div class="sect" id="secEdge" data-on="1">
807
824
  <div class="sect-top">
808
825
  <div class="sect-left">
@@ -1660,6 +1677,31 @@ $('#gradeBtn').onclick = () => { setGrade(!gradeOn); autoPreview(); };
1660
1677
  $('#gradeAmt').oninput = e => { gradeAmt = parseFloat(e.target.value); paintGrade(); };
1661
1678
  $('#gradeAmt').onchange = e => { setGrade(true, parseFloat(e.target.value)); autoPreview(); };
1662
1679
 
1680
+ /* Emissive screen. A display emits light AND reflects the room; `replace` models
1681
+ only the first, which is why a true-black UI lands as a hole. Off by default so
1682
+ nothing about an existing fit changes. */
1683
+ let emisOn = recall('emis','0') === '1';
1684
+ let emisAmt = parseFloat(recall('emisAmt','0.35')) || 0.35;
1685
+ function emisValue(){ return emisOn ? emisAmt : null; }
1686
+ function paintEmis(){
1687
+ setSwitch($('#emisBtn'), emisOn);
1688
+ $('#emisVal').textContent = Math.round(emisAmt * 100) + '%';
1689
+ const sl = $('#emisAmt');
1690
+ sl.value = emisAmt;
1691
+ sl.style.setProperty('--p', emisAmt);
1692
+ }
1693
+ function setEmis(on, amt){
1694
+ emisOn = on;
1695
+ if (amt !== undefined) emisAmt = amt;
1696
+ remember('emis', on ? '1' : '0'); remember('emisAmt', String(emisAmt));
1697
+ setSectionOpen($('#secEmissive'), on);
1698
+ paintEmis();
1699
+ }
1700
+ $('#emisBtn').onclick = () => { setEmis(!emisOn); autoPreview(); };
1701
+ $('#emisAmt').oninput = e => { emisAmt = parseFloat(e.target.value); paintEmis(); };
1702
+ $('#emisAmt').onchange = e => { setEmis(true, parseFloat(e.target.value)); autoPreview(); };
1703
+
1704
+
1663
1705
  $('#edgeBtn').onclick = () => setLoupeMode(loupeMode === 'float' ? 'dock' : 'float');
1664
1706
 
1665
1707
  function paintStrip(c, W, H, i){
@@ -1779,7 +1821,7 @@ async function renderPreview(){
1779
1821
  if (!(st.photo && st.shot && st.corners)) return;
1780
1822
  const s = $('#outSt'); s.textContent = 'Rendering…';
1781
1823
  try{
1782
- const r = await api('/api/preview', {corners: st.corners, radius_frac: radiusValue(), device: st.type, grade: gradeValue()});
1824
+ const r = await api('/api/preview', {corners: st.corners, radius_frac: radiusValue(), device: st.type, grade: gradeValue(), reflection: emisValue()});
1783
1825
  const im = $('#outImg');
1784
1826
  im.onload = () => { outNat = im.naturalWidth; im.hidden = false; $('#outEmpty').style.display='none'; syncOut(); };
1785
1827
  im.src = `${fileURL(r.path)}&t=${Date.now()}`;
@@ -1850,7 +1892,7 @@ async function renderVideo(){
1850
1892
  b.innerHTML = 'Rendering<span class="spin" aria-hidden="true"></span>';
1851
1893
  try{
1852
1894
  await api('/api/render', {corners: st.corners, radius_frac: radiusValue(), device: st.type,
1853
- grade: gradeValue(), preset, fit_frame: +$('#vframe').value});
1895
+ grade: gradeValue(), reflection: emisValue(), preset, fit_frame: +$('#vframe').value});
1854
1896
  }catch(e){ toast('err','Could not start the render: '+e.message); b.disabled=false; b.textContent='Render'; return; }
1855
1897
  // Poll rather than hold a request open: a clip is hundreds of frames and a
1856
1898
  // browser would time the request out long before the render finished.
@@ -1881,7 +1923,7 @@ $('#save').onclick = async () => {
1881
1923
  if (st.video) return renderVideo();
1882
1924
  const b = $('#save'); b.textContent = 'Saving…'; b.disabled = true;
1883
1925
  try{
1884
- const r = await api('/api/save', {corners: st.corners, radius_frac: radiusValue(), device: st.type, grade: gradeValue()});
1926
+ const r = await api('/api/save', {corners: st.corners, radius_frac: radiusValue(), device: st.type, grade: gradeValue(), reflection: emisValue()});
1885
1927
  b.textContent = 'Save';
1886
1928
  // The real destination, not a hardcoded one: --out-dir means saves usually
1887
1929
  // land in the project folder now, and telling the user "~/Desktop" when
@@ -1941,6 +1983,7 @@ $('#imp').onclick = async () => {
1941
1983
  }
1942
1984
  setLoupeMode(loupeMode);
1943
1985
  setGrade(gradeOn, gradeAmt);
1986
+ setEmis(emisOn, emisAmt);
1944
1987
  setRadiusOn(radiusOn);
1945
1988
  sectionsBooted = true; // animate from here on, not during the first paint
1946
1989
  // Compare is the DEFAULT view: judging a fit means comparing it with the