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 +5 -0
- package/package.json +1 -1
- package/scripts/ui.py +29 -4
- package/scripts/warp.py +50 -10
- package/skills/inject-screenshot/SKILL.md +3 -1
- package/ui/index.html +46 -3
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
242
|
-
|
|
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
|
|
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
|
|
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.
|
|
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
|