@remcp/runtime 0.2.99 → 0.2.100
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/CHANGELOG.md +7 -0
- package/package.json +1 -1
- package/src/extended/desktop-linux.mjs +87 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.100 — 2026-09-22
|
|
4
|
+
|
|
5
|
+
- Wayland portal-backed region screenshots clip compositor window bounds to the visible virtual desktop before FFmpeg cropping.
|
|
6
|
+
- Portal crop geometry translates negative virtual-desktop origins and scales logical coordinates to the captured PNG dimensions.
|
|
7
|
+
- Partially visible windows return the visible pixels with an explicit clipping summary; fully invisible regions fail closed.
|
|
8
|
+
- Added regressions for oversized/off-screen windows and multi-monitor scaling, plus a real GNOME Wayland 931×910 → 931×900 capture proof.
|
|
9
|
+
|
|
3
10
|
## 0.2.99 — 2026-09-22
|
|
4
11
|
|
|
5
12
|
- Lockstep runtime republish for the release-pipeline catalog synchronization fix.
|
package/package.json
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import process from 'node:process';
|
|
3
|
+
import { open } from 'node:fs/promises';
|
|
3
4
|
|
|
4
5
|
import { image, multi, text } from '../util.mjs';
|
|
5
6
|
import { capturePortalScreenshot, isWaylandSession } from '../screenshot-portal.mjs';
|
|
@@ -1302,6 +1303,79 @@ export async function displayInventory() {
|
|
|
1302
1303
|
return jsonResult(rows);
|
|
1303
1304
|
}
|
|
1304
1305
|
|
|
1306
|
+
async function pngDimensions(file) {
|
|
1307
|
+
const handle = await open(file, 'r');
|
|
1308
|
+
try {
|
|
1309
|
+
const buffer = Buffer.alloc(24);
|
|
1310
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
|
|
1311
|
+
if (bytesRead < buffer.length || buffer.subarray(0, 8).toString('hex') !== '89504e470d0a1a0a') return null;
|
|
1312
|
+
const width = buffer.readUInt32BE(16);
|
|
1313
|
+
const height = buffer.readUInt32BE(20);
|
|
1314
|
+
if (!width || !height) return null;
|
|
1315
|
+
return { width, height };
|
|
1316
|
+
} finally {
|
|
1317
|
+
await handle.close();
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
export function portalCropGeometry(rect, displays = [], bitmap = null) {
|
|
1322
|
+
const requested = {
|
|
1323
|
+
x: Math.trunc(Number(rect?.x)),
|
|
1324
|
+
y: Math.trunc(Number(rect?.y)),
|
|
1325
|
+
width: Math.trunc(Number(rect?.width)),
|
|
1326
|
+
height: Math.trunc(Number(rect?.height)),
|
|
1327
|
+
};
|
|
1328
|
+
if (![requested.x,requested.y,requested.width,requested.height].every(Number.isFinite)
|
|
1329
|
+
|| requested.width <= 0 || requested.height <= 0) {
|
|
1330
|
+
throw new Error('portal crop requires a finite positive rectangle');
|
|
1331
|
+
}
|
|
1332
|
+
const visibleDisplays = Array.isArray(displays) ? displays.filter(item => {
|
|
1333
|
+
const x = Number(item?.x), y = Number(item?.y), width = Number(item?.width), height = Number(item?.height);
|
|
1334
|
+
return [x,y,width,height].every(Number.isFinite) && width > 0 && height > 0;
|
|
1335
|
+
}) : [];
|
|
1336
|
+
const bitmapWidth = Math.trunc(Number(bitmap?.width));
|
|
1337
|
+
const bitmapHeight = Math.trunc(Number(bitmap?.height));
|
|
1338
|
+
const hasBitmap = Number.isFinite(bitmapWidth) && bitmapWidth > 0 && Number.isFinite(bitmapHeight) && bitmapHeight > 0;
|
|
1339
|
+
|
|
1340
|
+
let minX = 0, minY = 0;
|
|
1341
|
+
let maxX = hasBitmap ? bitmapWidth : requested.x + requested.width;
|
|
1342
|
+
let maxY = hasBitmap ? bitmapHeight : requested.y + requested.height;
|
|
1343
|
+
if (visibleDisplays.length) {
|
|
1344
|
+
minX = Math.min(...visibleDisplays.map(item => Number(item.x)));
|
|
1345
|
+
minY = Math.min(...visibleDisplays.map(item => Number(item.y)));
|
|
1346
|
+
maxX = Math.max(...visibleDisplays.map(item => Number(item.x) + Number(item.width)));
|
|
1347
|
+
maxY = Math.max(...visibleDisplays.map(item => Number(item.y) + Number(item.height)));
|
|
1348
|
+
}
|
|
1349
|
+
const left = Math.max(requested.x, minX);
|
|
1350
|
+
const top = Math.max(requested.y, minY);
|
|
1351
|
+
const right = Math.min(requested.x + requested.width, maxX);
|
|
1352
|
+
const bottom = Math.min(requested.y + requested.height, maxY);
|
|
1353
|
+
if (right <= left || bottom <= top) throw new Error('screenshot region is outside visible desktop bounds');
|
|
1354
|
+
|
|
1355
|
+
const virtualWidth = maxX - minX;
|
|
1356
|
+
const virtualHeight = maxY - minY;
|
|
1357
|
+
const scaleX = hasBitmap && virtualWidth > 0 ? bitmapWidth / virtualWidth : 1;
|
|
1358
|
+
const scaleY = hasBitmap && virtualHeight > 0 ? bitmapHeight / virtualHeight : 1;
|
|
1359
|
+
const x = Math.max(0, Math.floor((left - minX) * scaleX));
|
|
1360
|
+
const y = Math.max(0, Math.floor((top - minY) * scaleY));
|
|
1361
|
+
const sourceRight = hasBitmap
|
|
1362
|
+
? Math.min(bitmapWidth, Math.ceil((right - minX) * scaleX))
|
|
1363
|
+
: Math.ceil((right - minX) * scaleX);
|
|
1364
|
+
const sourceBottom = hasBitmap
|
|
1365
|
+
? Math.min(bitmapHeight, Math.ceil((bottom - minY) * scaleY))
|
|
1366
|
+
: Math.ceil((bottom - minY) * scaleY);
|
|
1367
|
+
const width = sourceRight - x;
|
|
1368
|
+
const height = sourceBottom - y;
|
|
1369
|
+
if (width <= 0 || height <= 0) throw new Error('screenshot region has no visible portal pixels');
|
|
1370
|
+
|
|
1371
|
+
return {
|
|
1372
|
+
x, y, width, height,
|
|
1373
|
+
visible: { x:left, y:top, width:right-left, height:bottom-top },
|
|
1374
|
+
clipped: left !== requested.x || top !== requested.y
|
|
1375
|
+
|| right !== requested.x + requested.width || bottom !== requested.y + requested.height,
|
|
1376
|
+
};
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1305
1379
|
export async function screenshotRegion(args = {}) {
|
|
1306
1380
|
const x=Math.trunc(Number(args.x)), y=Math.trunc(Number(args.y)), width=Math.trunc(Number(args.width)), height=Math.trunc(Number(args.height));
|
|
1307
1381
|
if (![x,y,width,height].every(Number.isFinite) || width<=0 || height<=0) throw new Error('x, y, width and height are required; width/height must be positive');
|
|
@@ -1309,19 +1383,30 @@ export async function screenshotRegion(args = {}) {
|
|
|
1309
1383
|
const dir = await tempDir('remcp-region-');
|
|
1310
1384
|
const target = path.join(dir, 'region.png');
|
|
1311
1385
|
const full = path.join(dir, 'full.png');
|
|
1386
|
+
let captured = { x, y, width, height };
|
|
1387
|
+
let clipped = false;
|
|
1312
1388
|
try {
|
|
1313
1389
|
if (commandExists('grim')) {
|
|
1314
1390
|
await runFile('grim', ['-g',`${x},${y} ${width}x${height}`,target], { label:'screenshot region' });
|
|
1315
1391
|
} else if (isWaylandSession() && commandExists('ffmpeg')) {
|
|
1316
1392
|
await capturePortalScreenshot(full);
|
|
1317
|
-
|
|
1393
|
+
const inventory = await displayInventory().catch(() => null);
|
|
1394
|
+
const displays = Array.isArray(inventory?.structuredContent?.data) ? inventory.structuredContent.data : [];
|
|
1395
|
+
const bitmap = await pngDimensions(full);
|
|
1396
|
+
const crop = portalCropGeometry({ x, y, width, height }, displays, bitmap);
|
|
1397
|
+
captured = crop.visible;
|
|
1398
|
+
clipped = crop.clipped;
|
|
1399
|
+
await runFile('ffmpeg', ['-hide_banner','-loglevel','error','-y','-i',full,'-vf',`crop=${crop.width}:${crop.height}:${crop.x}:${crop.y}`,'-frames:v','1',target], { label:'crop portal screenshot', timeout:30_000, maxBuffer:4*1024*1024 });
|
|
1318
1400
|
} else if (commandExists('import')) {
|
|
1319
1401
|
await runFile('import', ['-window','root','-crop',`${width}x${height}+${x}+${y}`,target], { label:'screenshot region' });
|
|
1320
1402
|
} else {
|
|
1321
1403
|
unavailable('Region screenshots', isWaylandSession() ? 'the XDG Desktop Portal plus ffmpeg, or grim, is required' : 'install ImageMagick import');
|
|
1322
1404
|
}
|
|
1323
1405
|
const {data} = await readPrivateTempFile(target, 4 * 1024 * 1024);
|
|
1324
|
-
|
|
1406
|
+
const summary = clipped
|
|
1407
|
+
? `Captured visible ${captured.width}x${captured.height} at ${captured.x},${captured.y}; clipped from requested ${width}x${height} at ${x},${y}.`
|
|
1408
|
+
: `Captured ${width}x${height} at ${x},${y}.`;
|
|
1409
|
+
return multi([{type:'text',text:summary}, image(data.toString('base64'),'image/png')]);
|
|
1325
1410
|
} finally { await removeTemp(dir); }
|
|
1326
1411
|
}
|
|
1327
1412
|
|