screengraft 0.54.5 → 0.54.7

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "screengraft",
3
- "version": "0.54.5",
3
+ "version": "0.54.7",
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). It fades when the pointer leaves the pane. 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.
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,26 +629,25 @@
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. Faint until the pointer is over the pane, so it never sits
633
- on the composite being judged; solid while a handle is being dragged. */
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;opacity:.4;transition:opacity .15s;overflow:visible}
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 45% black, dashes 6/5. The casing is what survives a
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,.45);fill:none;vector-effect:non-scaling-stroke}
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.8;fill:none;vector-effect:non-scaling-stroke}
641
+ #dofGizmo .far{stroke:#f5623d;stroke-width:0.9;fill:none;vector-effect:non-scaling-stroke}
643
642
  #dofGizmo .far.out{opacity:.75}
644
643
  /* Dash lengths are set inline in photo px scaled by the zoom (SVG dashes
645
644
  are viewBox units; non-scaling-stroke does not cover them), so they read
646
645
  as 6/5 on screen at any zoom -- the fit overlay's GUIDE_DASH. */
647
- #dofGizmo .h{fill:#f5623d;stroke:#fff;stroke-width:1.2;vector-effect:non-scaling-stroke;pointer-events:auto;cursor:move}
646
+ #dofGizmo .h{fill:none;stroke:#f5623d;stroke-width:2;vector-effect:non-scaling-stroke;pointer-events:auto;cursor:move}
647
+ #dofGizmo .hcase{fill:none;stroke:rgba(0,0,0,.35);stroke-width:4;vector-effect:non-scaling-stroke;pointer-events:none}
648
648
  #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}
649
649
  #dofGizmo .h.thumb{cursor:ew-resize}
650
- #dofGizmo .h.hollow{fill:#1a1a1a}
651
- #dofGizmo .h.pip{fill:#f5623d}
650
+ #dofGizmo .h.pip{fill:#f5623d;stroke:#fff;stroke-width:1.2}
652
651
  #dofGizmo .h.thumb{fill:#fff;stroke:#f5623d;stroke-width:2}
653
652
  #dofGizmo .h:active{cursor:grabbing}
654
653
  #dofGizmo text{font:600 11px system-ui,-apple-system,sans-serif;fill:#fff;paint-order:stroke;stroke:rgba(0,0,0,.75);stroke-width:3px;stroke-linejoin:round;pointer-events:none}
@@ -1480,12 +1479,12 @@ const recall = (k,d) => { try{ const v = localStorage.getItem('sg.'+k); return v
1480
1479
  The dark casing underneath is NOT optional and stays: flat accent measures
1481
1480
  ~1.06:1 against mid-tone photography, which is the failure the casing was
1482
1481
  introduced to fix. CORE_IDLE stays white for the strip's idle rule. */
1483
- const CASE_A = 'rgba(0,0,0,.45)', CORE_IDLE = 'rgba(255,255,255,.92)';
1482
+ const CASE_A = 'rgba(0,0,0,.35)', CORE_IDLE = 'rgba(255,255,255,.92)';
1484
1483
  const CORE_ACTIVE = '#f5623d';
1485
1484
  const CORE_QUAD = '#f23b0d';
1486
1485
  // Line weights for the fit overlay, in one place. Thinned 13 Sep 2026 on
1487
1486
  // request: quad 1 -> 0.8, active edge 2 -> 1.5, casing +2.5 -> +1.6 at 45%
1488
- // black instead of 62%. The casing is still there -- it is the part that
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
1489
1488
  // survives an arbitrary photograph -- just lighter.
1490
1489
  // There is no native rotate cursor; this is a 22px circular arrow, white
1491
1490
  // with a dark outline so it reads on any photograph, hotspot at its centre.
@@ -1496,7 +1495,7 @@ const ROTATE_CURSOR = 'url("data:image/svg+xml;utf8,' + encodeURIComponent(
1496
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"/>' +
1497
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"/>' +
1498
1497
  '</svg>') + '") 11 11, crosshair';
1499
- const QUAD_W = 0.8, QUAD_ACTIVE_W = 1.5, GUIDE_W = 0.8, CASE_PAD = 1.6;
1498
+ const QUAD_W = 0.9, QUAD_ACTIVE_W = 1.5, GUIDE_W = 0.9, CASE_PAD = 2.0;
1500
1499
  const GUIDE_DASH = [6, 5];
1501
1500
  function cased(c, path, coreW, coreColor, dash){
1502
1501
  c.save();
@@ -1509,10 +1508,21 @@ function cased(c, path, coreW, coreColor, dash){
1509
1508
  /* A FILLED handle, still cased. cased() strokes twice; a disc has to fill the
1510
1509
  core and put the dark casing around its rim, or the handle loses the
1511
1510
  readability guarantee that makes markers survive an arbitrary photograph. */
1511
+ /* A HOLLOW handle: 8px circle, 2px stroke, nothing inside, so the pixel
1512
+ under the handle stays visible; the casing sits outside the ring so the
1513
+ ring itself reads on any photograph (13 Sep 2026, on request). */
1514
+ function casedRing(c, x, y, r, coreColor){
1515
+ c.save();
1516
+ c.strokeStyle = CASE_A; c.lineWidth = 2 + CASE_PAD;
1517
+ c.beginPath(); c.arc(x, y, r, 0, Math.PI*2); c.stroke();
1518
+ c.strokeStyle = coreColor; c.lineWidth = 2;
1519
+ c.beginPath(); c.arc(x, y, r, 0, Math.PI*2); c.stroke();
1520
+ c.restore();
1521
+ }
1512
1522
  function casedDisc(c, x, y, r, coreColor){
1513
1523
  c.save();
1514
- c.strokeStyle = CASE_A; c.lineWidth = 1.8;
1515
- c.beginPath(); c.arc(x, y, r + 0.6, 0, Math.PI*2); c.stroke();
1524
+ c.strokeStyle = CASE_A; c.lineWidth = 2.0;
1525
+ c.beginPath(); c.arc(x, y, r + 0.7, 0, Math.PI*2); c.stroke();
1516
1526
  c.fillStyle = coreColor;
1517
1527
  c.beginPath(); c.arc(x, y, r, 0, Math.PI*2); c.fill();
1518
1528
  c.restore();
@@ -2300,7 +2310,12 @@ function draw(){
2300
2310
  // hollow; the pivot pips went 2.5 -> 3.5 and are filled too, staying visibly
2301
2311
  // smaller than a corner so the two never read as the same control. The gap the
2302
2312
  // edge leaves for each corner grows with it, or the disc sits on the line.
2303
- const HANDLE_R = 5.5, PIVOT_R = 3.5, HANDLE_GAP = 7.5;
2313
+ // Corner handle: an 8px ring (r to the stroke centre = 4) with a 2px stroke
2314
+ // and the casing outside it, so its visible outer edge is at r + 1 + CASE_PAD/2;
2315
+ // the edge breaks 1px short of that, which is the gap that reads as
2316
+ // "handle, then line" rather than a bead on a wire.
2317
+ const HANDLE_R = 4, PIVOT_R = 3.5;
2318
+ const HANDLE_GAP = HANDLE_R + 1 + CASE_PAD / 2 + 1;
2304
2319
 
2305
2320
  // Guide: the active edge extended to infinity, so you can see the line it
2306
2321
  // is really on. Thinner and dashed — it is a reference, not a handle.
@@ -2346,7 +2361,7 @@ function draw(){
2346
2361
  ctx.font='600 11px system-ui'; ctx.textBaseline='alphabetic';
2347
2362
  P.forEach(([x,y],i) => {
2348
2363
  const on = active && active.kind==='corner' && active.i===i;
2349
- casedDisc(ctx, x, y, on ? HANDLE_R + 1 : HANDLE_R, CORE_QUAD);
2364
+ casedRing(ctx, x, y, on ? HANDLE_R + 1 : HANDLE_R, CORE_QUAD);
2350
2365
  casedText(ctx, LBL[i], x+13, y-11, 'rgba(255,255,255,.95)');
2351
2366
  });
2352
2367
  }
@@ -2677,6 +2692,7 @@ $('#dofMeasure').onclick = measureDof;
2677
2692
  the SVG's viewBox is the photo so it stays aligned at every zoom for free. */
2678
2693
  const gz = $('#dofGizmo');
2679
2694
  const SIGMA_FULL_FRAC = 0.02; // mirrors dof.DOF_MAX_FRAC -- keep in step
2695
+ const HANDLE_BREAK = 4 + 1 + 2 + 1; // ring radius + half its stroke + casing + the 1px gap, screen px
2680
2696
  let dofEnd = parseFloat(recall('dofEnd','1')); if (!(dofEnd > 0)) dofEnd = 1;
2681
2697
  // Where along the lines the handles sit (0..1 across the screen's extent).
2682
2698
  // Page-only: it changes nothing in the render. Turning re-derives it so the
@@ -2728,7 +2744,8 @@ function paintGizmo(){
2728
2744
  const c0 = two ? P(dofEnd2, -0.06) : null, c1 = two ? P(dofEnd2, 1.06) : null, cc = two ? P(dofEnd2, dofK) : null;
2729
2745
  const th = P(dofEnd, 0.08 + 0.84 * dofStr); // the strength thumb slides along the dashed line
2730
2746
  const sigma = dofStr * SIGMA_FULL_FRAC * g.side;
2731
- const r = 6 * k, rp = 4 * k, fs11 = 11 * k, off = 14 * k;
2747
+ const r = 4 * k, rp = 3.5 * k, fs11 = 11 * k, off = 14 * k;
2748
+ const ring = (p, h, cls='') => `<circle cx="${p[0]}" cy="${p[1]}" r="${r}" class="hcase"/><circle cx="${p[0]}" cy="${p[1]}" r="${r}" class="h ${cls}" data-h="${h}"/>`;
2732
2749
  const L = (x, y, cls, extra='') => `<line x1="${x[0]}" y1="${x[1]}" x2="${y[0]}" y2="${y[1]}" class="${cls}" ${extra}/>`;
2733
2750
  const T = (p, txt, dy) => `<text x="${p[0]}" y="${p[1] + dy}" text-anchor="middle" font-size="${fs11}">${txt}</text>`;
2734
2751
  // GUIDE_DASH in screen px, expressed in photo px; a line past the screen gets a sparser dash
@@ -2736,15 +2753,18 @@ function paintGizmo(){
2736
2753
  const away = unit(sub(bc, ac)); // label offsets: away from the other line
2737
2754
  const beyond = dofEnd > 1.0001;
2738
2755
  const beyond2 = two && dofEnd2 < -0.0001;
2756
+ // Each line breaks around its ring handle: the ring is hollow, so a line
2757
+ // running through it would show inside. Break = ring outer edge + 1px.
2758
+ const brk = (HANDLE_BREAK) * k;
2759
+ const split = (p0, p1, h) => { const v = unit(sub(p1, p0)); return [[p0, sub(h, mul(v, brk))], [add(h, mul(v, brk)), p1]]; };
2760
+ const LL = (p0, p1, h, cls, extra='') => split(p0, p1, h).map(([x, y]) => L(x, y, cls, extra)).join('');
2739
2761
  gz.innerHTML =
2740
- L(a0, a1, 'case', 'stroke-width="2.6"') + L(a0, a1, 'focus') +
2741
- L(b0, b1, 'case', `stroke-width="2.4" ${dash(beyond)}`) + L(b0, b1, 'far' + (beyond ? ' out' : ''), dash(beyond)) +
2742
- (two ? L(c0, c1, 'case', `stroke-width="2.4" ${dash(beyond2)}`) + L(c0, c1, 'far' + (beyond2 ? ' out' : ''), dash(beyond2)) : '') +
2762
+ LL(a0, a1, ac, 'case', 'stroke-width="3"') + LL(a0, a1, ac, 'focus') +
2763
+ LL(b0, b1, bc, 'case', `stroke-width="2.9" ${dash(beyond)}`) + LL(b0, b1, bc, 'far' + (beyond ? ' out' : ''), dash(beyond)) +
2764
+ (two ? LL(c0, c1, cc, 'case', `stroke-width="2.9" ${dash(beyond2)}`) + LL(c0, c1, cc, 'far' + (beyond2 ? ' out' : ''), dash(beyond2)) : '') +
2743
2765
  `<circle cx="${a0[0]}" cy="${a0[1]}" r="${rp}" class="h pip" data-h="rotA"/>` +
2744
2766
  `<circle cx="${a1[0]}" cy="${a1[1]}" r="${rp}" class="h pip" data-h="rotB"/>` +
2745
- `<circle cx="${ac[0]}" cy="${ac[1]}" r="${r}" class="h" data-h="start"/>` +
2746
- `<circle cx="${bc[0]}" cy="${bc[1]}" r="${r}" class="h hollow" data-h="end"/>` +
2747
- (two ? `<circle cx="${cc[0]}" cy="${cc[1]}" r="${r}" class="h hollow" data-h="end2"/>` : '') +
2767
+ ring(ac, 'start') + ring(bc, 'end') + (two ? ring(cc, 'end2') : '') +
2748
2768
  `<rect x="${th[0]-r}" y="${th[1]-r}" width="${2*r}" height="${2*r}" transform="rotate(45 ${th[0]} ${th[1]})" class="h thumb" data-h="str"/>` +
2749
2769
  T([ac[0] - away[0]*off*(two ? 0 : 1), ac[1] - away[1]*off*(two ? 0 : 1) + (two ? -off : 0)], 'sharp', 4*k) +
2750
2770
  T([bc[0] + away[0]*off, bc[1] + away[1]*off], `σ ${sigma.toFixed(0)}px from here${beyond ? ' · past the screen' : ''}`, 4*k) +