screengraft 0.49.0 → 0.50.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/package.json +1 -1
- package/skills/inject-screenshot/SKILL.md +1 -1
- package/ui/index.html +66 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "screengraft",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.50.0",
|
|
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",
|
|
@@ -5,7 +5,7 @@ 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.50):** 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 — and when detection cannot tell which region is a screen, **Point at screen**: one click inside it and the detector uses that point — or, if this photograph has been fitted before, **the fit it was saved with comes back** as the starting position instead of a detection, recognised by the photo's own pixels so a rename or a drag-drop still match — and every save also writes a **portable `.fit.json` beside the mockup** that can be dropped back onto the page later, which is how a fit survives a re-export, another machine, or someone else's hands — then **match the four edges** (drag an edge's middle to slide it, near an end to pivot; corners still draggable) with canvas navigation that follows the usual conventions — **hold ⌘ and scroll to zoom to the pointer, hold space and drag to 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. **When a detection is wrong and you want to know why**, ask for the candidate list: `POST /api/detect {"trace": true}` writes `<session>/candidates.json`, or run `python3 scripts/detect.py --photo P --out-corners /tmp/c.json --trace /tmp/t.json` (add `--click X,Y`). Every candidate quad is in there with its score and whether it was accepted, rejected, never reached, or filtered out by the click — which is what separates "the screen was never proposed" from "it was proposed and something else won".
|
|
11
11
|
|
package/ui/index.html
CHANGED
|
@@ -384,6 +384,11 @@
|
|
|
384
384
|
.seg button.sel{background:var(--raise);color:var(--ink);font-weight:600}
|
|
385
385
|
.seg button.sel:hover{background:var(--raise-mid)}
|
|
386
386
|
.seg button.sel:active{background:var(--raise-low);transform:none}
|
|
387
|
+
/* A toggle in the strip head. On = the raised surface every selected thing
|
|
388
|
+
on this page has (the segmented thumb, the device chip), off = a label. */
|
|
389
|
+
.striphead button[aria-pressed]{background:transparent;border-color:transparent;box-shadow:none;color:var(--mute)}
|
|
390
|
+
.striphead button[aria-pressed]:hover{color:var(--ink)}
|
|
391
|
+
.striphead button[aria-pressed="true"]{background:var(--raise);border-color:var(--edge);color:var(--ink);font-weight:600}
|
|
387
392
|
/* Input chip (Figma 5:14 / 5:20). Surface and shadow are declared here rather
|
|
388
393
|
than inherited from the base button rule, so the filled state is explicit and
|
|
389
394
|
the .is-empty override below has something definite to override.
|
|
@@ -1136,6 +1141,7 @@
|
|
|
1136
1141
|
<span class="lbl">Edge view</span>
|
|
1137
1142
|
<span class="stepper dock-step"><button class="sm" id="stripOut">−</button><button class="sm" id="stripIn">+</button></span>
|
|
1138
1143
|
<span class="sm" id="stripZ" style="color:var(--mute);font-variant-numeric:tabular-nums"></span>
|
|
1144
|
+
<button class="sm" id="stripHC" aria-pressed="false" title="Stretch the strip's own tonal range to full contrast. For a dark screen on a dark frame, where the boundary is a few levels apart. The strip only — the composite is untouched.">Contrast</button>
|
|
1139
1145
|
<span class="spacer"></span>
|
|
1140
1146
|
<span class="sm" id="stripSt" style="color:var(--mute)"></span>
|
|
1141
1147
|
</div>
|
|
@@ -2463,7 +2469,38 @@ $('#emisAmt').onchange = e => { setEmis(true, parseFloat(e.target.value)); autoP
|
|
|
2463
2469
|
|
|
2464
2470
|
$('#edgeBtn').onclick = () => setLoupeMode(loupeMode === 'float' ? 'dock' : 'float');
|
|
2465
2471
|
|
|
2466
|
-
|
|
2472
|
+
/* Contrast for the strip only. A dark screen on a dark frame puts the
|
|
2473
|
+
boundary a few levels apart — 8 against 20 — which is invisible at any
|
|
2474
|
+
zoom, and no amount of magnification adds contrast. This takes the strip's
|
|
2475
|
+
own 1st–99th percentile of luminance and stretches it to 0–255, the same
|
|
2476
|
+
gain on every channel so hue is kept. It is a viewing aid: the composite,
|
|
2477
|
+
the fit and the saved file never see it. Per strip, per frame — 1280×130
|
|
2478
|
+
is 166k pixels and this runs on every hover. */
|
|
2479
|
+
let stripHC = recall('stripHC','0') === '1';
|
|
2480
|
+
function stretchContrast(c, W, H){
|
|
2481
|
+
const im = c.getImageData(0, 0, W, H), d = im.data, n = W*H;
|
|
2482
|
+
const hist = new Uint32Array(256);
|
|
2483
|
+
for (let k = 0; k < n; k++){
|
|
2484
|
+
const o = k*4;
|
|
2485
|
+
hist[(d[o]*77 + d[o+1]*151 + d[o+2]*28) >> 8]++;
|
|
2486
|
+
}
|
|
2487
|
+
let lo = 0, hi = 255, acc = 0;
|
|
2488
|
+
for (let v = 0; v < 256; v++){ acc += hist[v]; if (acc > n*0.01){ lo = v; break; } }
|
|
2489
|
+
acc = 0;
|
|
2490
|
+
for (let v = 255; v >= 0; v--){ acc += hist[v]; if (acc > n*0.01){ hi = v; break; } }
|
|
2491
|
+
if (hi - lo < 2) return;
|
|
2492
|
+
const g = 255 / (hi - lo);
|
|
2493
|
+
const lut = new Uint8ClampedArray(256);
|
|
2494
|
+
for (let v = 0; v < 256; v++) lut[v] = Math.max(0, Math.min(255, (v - lo) * g));
|
|
2495
|
+
for (let o = 0; o < n*4; o += 4){ d[o] = lut[d[o]]; d[o+1] = lut[d[o+1]]; d[o+2] = lut[d[o+2]]; }
|
|
2496
|
+
c.putImageData(im, 0, 0);
|
|
2497
|
+
}
|
|
2498
|
+
function setStripHC(on){
|
|
2499
|
+
stripHC = on; remember('stripHC', on ? '1' : '0');
|
|
2500
|
+
$('#stripHC').setAttribute('aria-pressed', on ? 'true' : 'false');
|
|
2501
|
+
}
|
|
2502
|
+
$('#stripHC').onclick = () => { setStripHC(!stripHC); drawStrip(); };
|
|
2503
|
+
function paintStrip(c, W, H, i, mode){
|
|
2467
2504
|
c.setTransform(1,0,0,1,0,0);
|
|
2468
2505
|
c.fillStyle = '#0c0c0b'; c.fillRect(0,0,W,H);
|
|
2469
2506
|
const A = st.corners[i], B = st.corners[(i+1)%4];
|
|
@@ -2478,7 +2515,25 @@ function paintStrip(c, W, H, i){
|
|
|
2478
2515
|
c.imageSmoothingEnabled = true;
|
|
2479
2516
|
c.drawImage(img, 0, 0);
|
|
2480
2517
|
c.restore();
|
|
2518
|
+
if (stripHC) stretchContrast(c, W, H);
|
|
2481
2519
|
cased(c, () => { c.beginPath(); c.moveTo(0, H/2); c.lineTo(W, H/2); }, 1.25, CORE_ACTIVE);
|
|
2520
|
+
// Which end is about to swing. The canvas shows this with the pivot pips,
|
|
2521
|
+
// but the eye is on the strip while an edge is being placed, and "rotA" /
|
|
2522
|
+
// "rotB" are meaningless there without a mark. A grab near one end pivots
|
|
2523
|
+
// on the OTHER: the pivot end gets the same filled pip the canvas uses, the
|
|
2524
|
+
// swinging end a double arrow across the rule. Translate marks nothing —
|
|
2525
|
+
// both ends move. Left in the strip is A, the edge's first corner.
|
|
2526
|
+
if (mode === 'rotA' || mode === 'rotB'){
|
|
2527
|
+
// At 0.16 / 0.84 of the edge: where the canvas draws its pips, and clear
|
|
2528
|
+
// of the screen/outside labels at the left edge.
|
|
2529
|
+
const pivotX = mode === 'rotA' ? W*0.16 : W*0.84, swingX = mode === 'rotA' ? W*0.84 : W*0.16;
|
|
2530
|
+
casedDisc(c, pivotX, H/2, 3.5, CORE_ACTIVE);
|
|
2531
|
+
cased(c, () => { c.beginPath();
|
|
2532
|
+
c.moveTo(swingX, H/2 - 22); c.lineTo(swingX, H/2 + 22);
|
|
2533
|
+
c.moveTo(swingX - 5, H/2 - 16); c.lineTo(swingX, H/2 - 22); c.lineTo(swingX + 5, H/2 - 16);
|
|
2534
|
+
c.moveTo(swingX - 5, H/2 + 16); c.lineTo(swingX, H/2 + 22); c.lineTo(swingX + 5, H/2 + 16); },
|
|
2535
|
+
1.5, CORE_ACTIVE);
|
|
2536
|
+
}
|
|
2482
2537
|
cased(c, () => { c.beginPath();
|
|
2483
2538
|
for (const f of [0.25,0.5,0.75]){ c.moveTo(W*f, H/2-9); c.lineTo(W*f, H/2+9); } },
|
|
2484
2539
|
1, 'rgba(255,255,255,.7)');
|
|
@@ -2488,6 +2543,11 @@ function paintStrip(c, W, H, i){
|
|
|
2488
2543
|
casedText(c, 'screen', 9, inwardIsDown ? H/2 + 15 : H/2 - 15, 'rgba(255,255,255,.95)');
|
|
2489
2544
|
casedText(c, 'outside', 9, inwardIsDown ? H/2 - 15 : H/2 + 15, 'rgba(255,255,255,.62)');
|
|
2490
2545
|
}
|
|
2546
|
+
function swingText(mode){
|
|
2547
|
+
if (mode === 'rotA') return 'the right end swings, pivot on the left';
|
|
2548
|
+
if (mode === 'rotB') return 'the left end swings, pivot on the right';
|
|
2549
|
+
return 'flat on the rule means aligned';
|
|
2550
|
+
}
|
|
2491
2551
|
function drawStrip(){
|
|
2492
2552
|
const a = drag || hover || pick;
|
|
2493
2553
|
const has = !!(st.corners && a && a.kind === 'edge' && img.naturalWidth);
|
|
@@ -2500,13 +2560,13 @@ function drawStrip(){
|
|
|
2500
2560
|
$('#stripSt').textContent = 'Hover or drag an edge — this straightens it, so misalignment shows as a wedge.';
|
|
2501
2561
|
return;
|
|
2502
2562
|
}
|
|
2503
|
-
paintStrip(sctx, W, H, a.i);
|
|
2504
|
-
$('#stripSt').textContent = `${EDGE_LBL[a.i]} edge · ±${stripHalf}px · ${(H/(2*stripHalf)).toFixed(1)}× across the boundary —
|
|
2563
|
+
paintStrip(sctx, W, H, a.i, a.mode);
|
|
2564
|
+
$('#stripSt').textContent = `${EDGE_LBL[a.i]} edge · ±${stripHalf}px · ${(H/(2*stripHalf)).toFixed(1)}× across the boundary — ${swingText(a.mode)}`;
|
|
2505
2565
|
} else {
|
|
2506
2566
|
const fl = $('#floatLoupe');
|
|
2507
2567
|
if (!has){ fl.classList.remove('on'); return; }
|
|
2508
|
-
paintStrip(fctx, stripF.width, stripF.height, a.i);
|
|
2509
|
-
$('#stripFCap').textContent = `${EDGE_LBL[a.i]} edge · ±${stripHalf}px —
|
|
2568
|
+
paintStrip(fctx, stripF.width, stripF.height, a.i, a.mode);
|
|
2569
|
+
$('#stripFCap').textContent = `${EDGE_LBL[a.i]} edge · ±${stripHalf}px — ${swingText(a.mode)}`;
|
|
2510
2570
|
fl.classList.add('on');
|
|
2511
2571
|
const w = fl.offsetWidth || 356, h = fl.offsetHeight || 130;
|
|
2512
2572
|
let x = lastPointer[0] + 26, y = lastPointer[1] - h - 18;
|
|
@@ -3110,6 +3170,7 @@ $('#imp').onclick = async () => {
|
|
|
3110
3170
|
b.onclick=()=>{setType(t); applyPreset(); autoPreview();}; types.appendChild(b);
|
|
3111
3171
|
}
|
|
3112
3172
|
setLoupeMode(loupeMode);
|
|
3173
|
+
setStripHC(stripHC);
|
|
3113
3174
|
setGrade(gradeOn, gradeAmt);
|
|
3114
3175
|
setEmis(emisOn, emisAmt);
|
|
3115
3176
|
setRadiusOn(radiusOn);
|