screengraft 0.54.4 → 0.54.6
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/package.json +1 -1
- package/skills/inject-screenshot/SKILL.md +1 -1
- package/ui/index.html +20 -16
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "screengraft",
|
|
3
|
-
"version": "0.54.
|
|
3
|
+
"version": "0.54.6",
|
|
4
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",
|
|
@@ -13,7 +13,7 @@ The geometry is exact (`warp.py`); the detection is advisory (`detect.py`) and t
|
|
|
13
13
|
|
|
14
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.
|
|
15
15
|
|
|
16
|
-
**Depth of field** *(off by default)*: a phone shot at an angle is a plane receding from the camera, so its far end is softer than its near end, and a screenshot pasted pin-sharp across all of it gives the fake away. On, the screenshot blurs across the screen in one direction — **direction** (where the blur grows toward) and **strength** — and the glass edge softens with it. **Measure from photo** reads both off the photograph's own screen boundary; on a real photograph that works, on a *mockup template* the device is usually rendered sharp with the blur only on the background, so it answers "flat" and the designer sets it by eye. The measured strength is a floor (the estimator saturates around 5px of blur), never a ceiling. **The gizmo on the result** is the primary control, defined in the screen's own plane and projected through the fit (so the two lines converge with the phone's edges on a steep shot, as real iso-blur lines do): a solid line where focus ends (drag its centre), a dashed line where the blur reaches its full amount (drag its centre to move it, its end pips to turn both, and slide the diamond along it for how much blur). **Both sides** adds a near limit — a second dashed line behind the focus line — for a phone whose middle is sharp and both ends soft (the depth of field's near and far limits).
|
|
16
|
+
**Depth of field** *(off by default)*: a phone shot at an angle is a plane receding from the camera, so its far end is softer than its near end, and a screenshot pasted pin-sharp across all of it gives the fake away. On, the screenshot blurs across the screen in one direction — **direction** (where the blur grows toward) and **strength** — and the glass edge softens with it. **Measure from photo** reads both off the photograph's own screen boundary; on a real photograph that works, on a *mockup template* the device is usually rendered sharp with the blur only on the background, so it answers "flat" and the designer sets it by eye. The measured strength is a floor (the estimator saturates around 5px of blur), never a ceiling. **The gizmo on the result** is the primary control, defined in the screen's own plane and projected through the fit (so the two lines converge with the phone's edges on a steep shot, as real iso-blur lines do): a solid line where focus ends (drag its centre), a dashed line where the blur reaches its full amount (drag its centre to move it, its end pips to turn both, and slide the diamond along it for how much blur). **Both sides** adds a near limit — a second dashed line behind the focus line — for a phone whose middle is sharp and both ends soft (the depth of field's near and far limits). The sliders and the gizmo are one model. Suggest it when the photo has visible bokeh — a blurred hand, table edge or background — and the composite's screen looks pasted on. The live in-place playback cannot show it; the composite and Render preview do.
|
|
17
17
|
|
|
18
18
|
**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, **press Play and the clip runs on the photo immediately** — the browser warps it onto the same four corners with the same corner radius and approximates the emissive blend, so placement and motion can be judged with no wait; **Render preview** composites a few seconds through the real pipeline when the grade, grain and true blend are what you need to see — 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.
|
|
19
19
|
|
package/ui/index.html
CHANGED
|
@@ -629,18 +629,20 @@
|
|
|
629
629
|
#outWrap{overflow:auto;background:var(--well);display:grid;box-shadow:inset 0 1px 3px rgba(0,0,0,.5)}
|
|
630
630
|
/* The depth-of-field gizmo: an SVG laid exactly over the composite, drawn
|
|
631
631
|
in PHOTO pixels (its viewBox is the photo) so every coordinate is the
|
|
632
|
-
engine's own.
|
|
633
|
-
on
|
|
632
|
+
engine's own. Always at full opacity: the fade-when-idle was tried and
|
|
633
|
+
removed on request (13 Sep 2026). */
|
|
634
634
|
#outWrap{position:relative}
|
|
635
|
-
#dofGizmo{position:absolute;pointer-events:none;
|
|
636
|
-
#outWrap:hover #dofGizmo,#dofGizmo.drag{opacity:1}
|
|
635
|
+
#dofGizmo{position:absolute;pointer-events:none;overflow:visible}
|
|
637
636
|
/* Same rules as the fit overlay (v0.54.3): thin core, a light casing
|
|
638
|
-
underneath at
|
|
637
|
+
underneath at 35% black, dashes 6/5. The casing is what survives a
|
|
639
638
|
photograph; the thinness is the request. */
|
|
640
|
-
#dofGizmo .case{stroke:rgba(0,0,0,.
|
|
639
|
+
#dofGizmo .case{stroke:rgba(0,0,0,.35);fill:none;vector-effect:non-scaling-stroke}
|
|
641
640
|
#dofGizmo .focus{stroke:#f5623d;stroke-width:1;fill:none;vector-effect:non-scaling-stroke}
|
|
642
|
-
#dofGizmo .far{stroke:#f5623d;stroke-width:0.
|
|
643
|
-
#dofGizmo .far.out{
|
|
641
|
+
#dofGizmo .far{stroke:#f5623d;stroke-width:0.9;fill:none;vector-effect:non-scaling-stroke}
|
|
642
|
+
#dofGizmo .far.out{opacity:.75}
|
|
643
|
+
/* Dash lengths are set inline in photo px scaled by the zoom (SVG dashes
|
|
644
|
+
are viewBox units; non-scaling-stroke does not cover them), so they read
|
|
645
|
+
as 6/5 on screen at any zoom -- the fit overlay's GUIDE_DASH. */
|
|
644
646
|
#dofGizmo .h{fill:#f5623d;stroke:#fff;stroke-width:1.2;vector-effect:non-scaling-stroke;pointer-events:auto;cursor:move}
|
|
645
647
|
#dofGizmo .h.pip{cursor:url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='22' height='22' viewBox='0 0 22 22'%3E%3Cpath d='M17.5 11a6.5 6.5 0 1 1-2.2-4.9' fill='none' stroke='rgba(0,0,0,.85)' stroke-width='4.2' stroke-linecap='round'/%3E%3Cpath d='M17.5 11a6.5 6.5 0 1 1-2.2-4.9' fill='none' stroke='%23fff' stroke-width='2' stroke-linecap='round'/%3E%3Cpath d='M15.6 2.6l0.4 4.2-4.2 0.3' fill='none' stroke='rgba(0,0,0,.85)' stroke-width='4.2' stroke-linecap='round' stroke-linejoin='round'/%3E%3Cpath d='M15.6 2.6l0.4 4.2-4.2 0.3' fill='none' stroke='%23fff' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") 11 11, crosshair}
|
|
646
648
|
#dofGizmo .h.thumb{cursor:ew-resize}
|
|
@@ -1477,12 +1479,12 @@ const recall = (k,d) => { try{ const v = localStorage.getItem('sg.'+k); return v
|
|
|
1477
1479
|
The dark casing underneath is NOT optional and stays: flat accent measures
|
|
1478
1480
|
~1.06:1 against mid-tone photography, which is the failure the casing was
|
|
1479
1481
|
introduced to fix. CORE_IDLE stays white for the strip's idle rule. */
|
|
1480
|
-
const CASE_A = 'rgba(0,0,0,.
|
|
1482
|
+
const CASE_A = 'rgba(0,0,0,.35)', CORE_IDLE = 'rgba(255,255,255,.92)';
|
|
1481
1483
|
const CORE_ACTIVE = '#f5623d';
|
|
1482
1484
|
const CORE_QUAD = '#f23b0d';
|
|
1483
1485
|
// Line weights for the fit overlay, in one place. Thinned 13 Sep 2026 on
|
|
1484
1486
|
// request: quad 1 -> 0.8, active edge 2 -> 1.5, casing +2.5 -> +1.6 at 45%
|
|
1485
|
-
// black instead of 62
|
|
1487
|
+
// black instead of 62%; then 0.9 / +2.0 at 35% on a second look. The casing is still there -- it is the part that
|
|
1486
1488
|
// survives an arbitrary photograph -- just lighter.
|
|
1487
1489
|
// There is no native rotate cursor; this is a 22px circular arrow, white
|
|
1488
1490
|
// with a dark outline so it reads on any photograph, hotspot at its centre.
|
|
@@ -1493,7 +1495,7 @@ const ROTATE_CURSOR = 'url("data:image/svg+xml;utf8,' + encodeURIComponent(
|
|
|
1493
1495
|
'<path d="M15.6 2.6l0.4 4.2-4.2 0.3" fill="none" stroke="rgba(0,0,0,.85)" stroke-width="4.2" stroke-linecap="round" stroke-linejoin="round"/>' +
|
|
1494
1496
|
'<path d="M15.6 2.6l0.4 4.2-4.2 0.3" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>' +
|
|
1495
1497
|
'</svg>') + '") 11 11, crosshair';
|
|
1496
|
-
const QUAD_W = 0.
|
|
1498
|
+
const QUAD_W = 0.9, QUAD_ACTIVE_W = 1.5, GUIDE_W = 0.9, CASE_PAD = 2.0;
|
|
1497
1499
|
const GUIDE_DASH = [6, 5];
|
|
1498
1500
|
function cased(c, path, coreW, coreColor, dash){
|
|
1499
1501
|
c.save();
|
|
@@ -1508,8 +1510,8 @@ function cased(c, path, coreW, coreColor, dash){
|
|
|
1508
1510
|
readability guarantee that makes markers survive an arbitrary photograph. */
|
|
1509
1511
|
function casedDisc(c, x, y, r, coreColor){
|
|
1510
1512
|
c.save();
|
|
1511
|
-
c.strokeStyle = CASE_A; c.lineWidth =
|
|
1512
|
-
c.beginPath(); c.arc(x, y, r + 0.
|
|
1513
|
+
c.strokeStyle = CASE_A; c.lineWidth = 2.0;
|
|
1514
|
+
c.beginPath(); c.arc(x, y, r + 0.7, 0, Math.PI*2); c.stroke();
|
|
1513
1515
|
c.fillStyle = coreColor;
|
|
1514
1516
|
c.beginPath(); c.arc(x, y, r, 0, Math.PI*2); c.fill();
|
|
1515
1517
|
c.restore();
|
|
@@ -2728,13 +2730,15 @@ function paintGizmo(){
|
|
|
2728
2730
|
const r = 6 * k, rp = 4 * k, fs11 = 11 * k, off = 14 * k;
|
|
2729
2731
|
const L = (x, y, cls, extra='') => `<line x1="${x[0]}" y1="${x[1]}" x2="${y[0]}" y2="${y[1]}" class="${cls}" ${extra}/>`;
|
|
2730
2732
|
const T = (p, txt, dy) => `<text x="${p[0]}" y="${p[1] + dy}" text-anchor="middle" font-size="${fs11}">${txt}</text>`;
|
|
2733
|
+
// GUIDE_DASH in screen px, expressed in photo px; a line past the screen gets a sparser dash
|
|
2734
|
+
const dash = out => `stroke-dasharray="${(out ? 3 : GUIDE_DASH[0]) * k} ${(out ? 6 : GUIDE_DASH[1]) * k}"`;
|
|
2731
2735
|
const away = unit(sub(bc, ac)); // label offsets: away from the other line
|
|
2732
2736
|
const beyond = dofEnd > 1.0001;
|
|
2733
2737
|
const beyond2 = two && dofEnd2 < -0.0001;
|
|
2734
2738
|
gz.innerHTML =
|
|
2735
|
-
L(a0, a1, 'case', 'stroke-width="
|
|
2736
|
-
L(b0, b1, 'case',
|
|
2737
|
-
(two ? L(c0, c1, 'case',
|
|
2739
|
+
L(a0, a1, 'case', 'stroke-width="3"') + L(a0, a1, 'focus') +
|
|
2740
|
+
L(b0, b1, 'case', `stroke-width="2.9" ${dash(beyond)}`) + L(b0, b1, 'far' + (beyond ? ' out' : ''), dash(beyond)) +
|
|
2741
|
+
(two ? L(c0, c1, 'case', `stroke-width="2.9" ${dash(beyond2)}`) + L(c0, c1, 'far' + (beyond2 ? ' out' : ''), dash(beyond2)) : '') +
|
|
2738
2742
|
`<circle cx="${a0[0]}" cy="${a0[1]}" r="${rp}" class="h pip" data-h="rotA"/>` +
|
|
2739
2743
|
`<circle cx="${a1[0]}" cy="${a1[1]}" r="${rp}" class="h pip" data-h="rotB"/>` +
|
|
2740
2744
|
`<circle cx="${ac[0]}" cy="${ac[1]}" r="${r}" class="h" data-h="start"/>` +
|