forge-jsxy 1.0.72 → 1.0.73
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.
Potentially problematic release.
This version of forge-jsxy might be problematic. Click here for more details.
package/dist/fsProtocol.js
CHANGED
|
@@ -2062,7 +2062,13 @@ function userRequestedScreenshotMaxWidth() {
|
|
|
2062
2062
|
* Max width for capture-time down-scale (Windows GDI, Linux ffmpeg, macOS merge). Unset env →
|
|
2063
2063
|
* {@link DEFAULT_SCREENSHOT_MAX_WIDTH}. User `0` → do not down-scale at capture.
|
|
2064
2064
|
*/
|
|
2065
|
-
function captureScaleMaxWidth() {
|
|
2065
|
+
function captureScaleMaxWidth(overrideMaxWidth) {
|
|
2066
|
+
if (Number.isFinite(Number(overrideMaxWidth))) {
|
|
2067
|
+
const n = Number(overrideMaxWidth);
|
|
2068
|
+
if (n <= 0)
|
|
2069
|
+
return 0;
|
|
2070
|
+
return Math.min(32_768, Math.max(320, Math.floor(n)));
|
|
2071
|
+
}
|
|
2066
2072
|
const u = userRequestedScreenshotMaxWidth();
|
|
2067
2073
|
if (u === 0)
|
|
2068
2074
|
return 0;
|
|
@@ -2071,9 +2077,43 @@ function captureScaleMaxWidth() {
|
|
|
2071
2077
|
return DEFAULT_SCREENSHOT_MAX_WIDTH;
|
|
2072
2078
|
}
|
|
2073
2079
|
/** Strict byte limit for relay/WebSocket. */
|
|
2074
|
-
function screenshotHardCapBytes() {
|
|
2080
|
+
function screenshotHardCapBytes(overrideMaxBytes) {
|
|
2081
|
+
if (Number.isFinite(Number(overrideMaxBytes))) {
|
|
2082
|
+
const n = Number(overrideMaxBytes);
|
|
2083
|
+
return Math.min(12 * 1024 * 1024, Math.max(64 * 1024, Math.floor(n)));
|
|
2084
|
+
}
|
|
2075
2085
|
return effectiveScreenshotMaxBytes();
|
|
2076
2086
|
}
|
|
2087
|
+
function cameraOverlayEnabledByDefault() {
|
|
2088
|
+
const raw = String(process.env.FORGE_JS_CAMERA_OVERLAY_ENABLED || "").trim().toLowerCase();
|
|
2089
|
+
if (!raw)
|
|
2090
|
+
return false;
|
|
2091
|
+
return !["0", "false", "no", "off"].includes(raw);
|
|
2092
|
+
}
|
|
2093
|
+
function cameraOverlayWidthPercent() {
|
|
2094
|
+
const raw = Number.parseFloat(String(process.env.FORGE_JS_CAMERA_OVERLAY_WIDTH_PERCENT || "").trim());
|
|
2095
|
+
if (!Number.isFinite(raw))
|
|
2096
|
+
return 20;
|
|
2097
|
+
return Math.min(40, Math.max(10, raw));
|
|
2098
|
+
}
|
|
2099
|
+
function normalizeScreenshotCaptureOptions(options) {
|
|
2100
|
+
const o = options || {};
|
|
2101
|
+
const streamProfile = String(o.stream_profile || "").trim().toLowerCase();
|
|
2102
|
+
const maxBytes = Number.isFinite(Number(o.max_bytes))
|
|
2103
|
+
? screenshotHardCapBytes(Number(o.max_bytes))
|
|
2104
|
+
: null;
|
|
2105
|
+
const maxWidth = Number.isFinite(Number(o.max_width))
|
|
2106
|
+
? captureScaleMaxWidth(Number(o.max_width))
|
|
2107
|
+
: null;
|
|
2108
|
+
return {
|
|
2109
|
+
streamProfile,
|
|
2110
|
+
maxBytes,
|
|
2111
|
+
maxWidth,
|
|
2112
|
+
includeCamera: typeof o.include_camera === "boolean"
|
|
2113
|
+
? o.include_camera
|
|
2114
|
+
: cameraOverlayEnabledByDefault(),
|
|
2115
|
+
};
|
|
2116
|
+
}
|
|
2077
2117
|
/** Shrink passes: start slightly under the hard cap so JPEG/PNG encoders rarely overshoot. */
|
|
2078
2118
|
function screenshotShrinkTierTargets(hardCap) {
|
|
2079
2119
|
const tiers = [
|
|
@@ -2154,12 +2194,18 @@ async function lastResortScreenshotShrinkBuffer(raw, hardCap) {
|
|
|
2154
2194
|
* when over cap), return base64 for relay, delete file. JPEG may be returned when PNG cannot fit the cap.
|
|
2155
2195
|
* Used by Windows / Linux / macOS screenshot paths (no GUI from this helper).
|
|
2156
2196
|
*/
|
|
2157
|
-
async function resultFromPngPath(outPath) {
|
|
2197
|
+
async function resultFromPngPath(outPath, options) {
|
|
2158
2198
|
try {
|
|
2159
2199
|
if (!outPath || !fs.existsSync(outPath)) {
|
|
2160
2200
|
return { ok: false, error: "screenshot produced no image file" };
|
|
2161
2201
|
}
|
|
2162
|
-
const hardCap = screenshotHardCapBytes();
|
|
2202
|
+
const hardCap = screenshotHardCapBytes(options?.maxBytes ?? null);
|
|
2203
|
+
if (options?.includeCamera && options.streamProfile !== "remote_stream") {
|
|
2204
|
+
const ffmpeg = resolveFfmpegForShrink();
|
|
2205
|
+
if (ffmpeg) {
|
|
2206
|
+
await tryApplyCameraOverlayToPng(ffmpeg, outPath);
|
|
2207
|
+
}
|
|
2208
|
+
}
|
|
2163
2209
|
let buf = fs.readFileSync(outPath);
|
|
2164
2210
|
let mime = sniffScreenshotMime(buf);
|
|
2165
2211
|
if (buf.length > hardCap) {
|
|
@@ -2232,12 +2278,39 @@ async function resultFromPngPath(outPath) {
|
|
|
2232
2278
|
};
|
|
2233
2279
|
}
|
|
2234
2280
|
}
|
|
2281
|
+
if (options?.streamProfile === "remote_stream" && mime !== "image/jpeg") {
|
|
2282
|
+
const remoteTargets = [
|
|
2283
|
+
Math.min(hardCap, Math.max(96 * 1024, Math.floor(hardCap * 0.9))),
|
|
2284
|
+
Math.min(hardCap, Math.max(80 * 1024, Math.floor(hardCap * 0.72))),
|
|
2285
|
+
Math.min(hardCap, Math.max(64 * 1024, Math.floor(hardCap * 0.56))),
|
|
2286
|
+
Math.min(hardCap, Math.max(48 * 1024, Math.floor(hardCap * 0.42))),
|
|
2287
|
+
];
|
|
2288
|
+
let converted = null;
|
|
2289
|
+
for (const t of remoteTargets) {
|
|
2290
|
+
const out = await shrinkScreenshotBufferToMaxBytes(buf, t);
|
|
2291
|
+
if (!out || out.buffer.length > hardCap)
|
|
2292
|
+
continue;
|
|
2293
|
+
converted = out;
|
|
2294
|
+
if (out.mime === "image/jpeg")
|
|
2295
|
+
break;
|
|
2296
|
+
}
|
|
2297
|
+
if (converted && converted.buffer.length <= hardCap) {
|
|
2298
|
+
buf = converted.buffer;
|
|
2299
|
+
mime = converted.mime;
|
|
2300
|
+
}
|
|
2301
|
+
}
|
|
2302
|
+
const dims = mime === "image/png"
|
|
2303
|
+
? readPngIhdrSize(buf)
|
|
2304
|
+
: mime === "image/jpeg"
|
|
2305
|
+
? readJpegSize(buf)
|
|
2306
|
+
: null;
|
|
2235
2307
|
return {
|
|
2236
2308
|
ok: true,
|
|
2237
2309
|
mime,
|
|
2238
2310
|
b64: buf.toString("base64"),
|
|
2239
|
-
|
|
2240
|
-
|
|
2311
|
+
bytes: buf.length,
|
|
2312
|
+
width: dims?.w ?? 0,
|
|
2313
|
+
height: dims?.h ?? 0,
|
|
2241
2314
|
};
|
|
2242
2315
|
}
|
|
2243
2316
|
catch (e) {
|
|
@@ -2282,6 +2355,61 @@ function readPngIhdrSize(buf) {
|
|
|
2282
2355
|
return null;
|
|
2283
2356
|
}
|
|
2284
2357
|
}
|
|
2358
|
+
/** Read width/height from a baseline/progressive JPEG buffer by scanning SOF markers. */
|
|
2359
|
+
function readJpegSize(buf) {
|
|
2360
|
+
try {
|
|
2361
|
+
if (!buf || buf.length < 4)
|
|
2362
|
+
return null;
|
|
2363
|
+
if (buf[0] !== 0xff || buf[1] !== 0xd8)
|
|
2364
|
+
return null;
|
|
2365
|
+
let i = 2;
|
|
2366
|
+
while (i + 3 < buf.length) {
|
|
2367
|
+
if (buf[i] !== 0xff) {
|
|
2368
|
+
i += 1;
|
|
2369
|
+
continue;
|
|
2370
|
+
}
|
|
2371
|
+
let marker = buf[i + 1];
|
|
2372
|
+
while (marker === 0xff && i + 2 < buf.length) {
|
|
2373
|
+
i += 1;
|
|
2374
|
+
marker = buf[i + 1];
|
|
2375
|
+
}
|
|
2376
|
+
i += 2;
|
|
2377
|
+
if (marker === 0xd9 || marker === 0xda)
|
|
2378
|
+
break;
|
|
2379
|
+
if (i + 1 >= buf.length)
|
|
2380
|
+
break;
|
|
2381
|
+
const len = buf.readUInt16BE(i);
|
|
2382
|
+
if (len < 2 || i + len > buf.length)
|
|
2383
|
+
break;
|
|
2384
|
+
const isSof = marker === 0xc0 ||
|
|
2385
|
+
marker === 0xc1 ||
|
|
2386
|
+
marker === 0xc2 ||
|
|
2387
|
+
marker === 0xc3 ||
|
|
2388
|
+
marker === 0xc5 ||
|
|
2389
|
+
marker === 0xc6 ||
|
|
2390
|
+
marker === 0xc7 ||
|
|
2391
|
+
marker === 0xc9 ||
|
|
2392
|
+
marker === 0xca ||
|
|
2393
|
+
marker === 0xcb ||
|
|
2394
|
+
marker === 0xcd ||
|
|
2395
|
+
marker === 0xce ||
|
|
2396
|
+
marker === 0xcf;
|
|
2397
|
+
if (isSof && len >= 7 && i + 7 <= buf.length) {
|
|
2398
|
+
const h = buf.readUInt16BE(i + 3);
|
|
2399
|
+
const w = buf.readUInt16BE(i + 5);
|
|
2400
|
+
if (!Number.isFinite(w) || !Number.isFinite(h) || w < 1 || h < 1 || w > 65535 || h > 65535) {
|
|
2401
|
+
return null;
|
|
2402
|
+
}
|
|
2403
|
+
return { w, h };
|
|
2404
|
+
}
|
|
2405
|
+
i += len;
|
|
2406
|
+
}
|
|
2407
|
+
return null;
|
|
2408
|
+
}
|
|
2409
|
+
catch {
|
|
2410
|
+
return null;
|
|
2411
|
+
}
|
|
2412
|
+
}
|
|
2285
2413
|
function sniffScreenshotMime(buf) {
|
|
2286
2414
|
if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) {
|
|
2287
2415
|
return "image/jpeg";
|
|
@@ -2291,6 +2419,62 @@ function sniffScreenshotMime(buf) {
|
|
|
2291
2419
|
}
|
|
2292
2420
|
return "image/png";
|
|
2293
2421
|
}
|
|
2422
|
+
let windowsVirtualBoundsCache = null;
|
|
2423
|
+
function getWindowsVirtualScreenBoundsCached(fallbackW, fallbackH) {
|
|
2424
|
+
const now = Date.now();
|
|
2425
|
+
if (windowsVirtualBoundsCache && now - windowsVirtualBoundsCache.at < 15_000) {
|
|
2426
|
+
return windowsVirtualBoundsCache.bounds;
|
|
2427
|
+
}
|
|
2428
|
+
const fb = {
|
|
2429
|
+
x: 0,
|
|
2430
|
+
y: 0,
|
|
2431
|
+
w: Math.max(1, Math.floor(fallbackW || 1)),
|
|
2432
|
+
h: Math.max(1, Math.floor(fallbackH || 1)),
|
|
2433
|
+
};
|
|
2434
|
+
try {
|
|
2435
|
+
const pwsh = process.env.SystemRoot
|
|
2436
|
+
? path.join(process.env.SystemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe")
|
|
2437
|
+
: "powershell.exe";
|
|
2438
|
+
const script = [
|
|
2439
|
+
"$ErrorActionPreference='Stop'",
|
|
2440
|
+
"Add-Type @'",
|
|
2441
|
+
"using System;",
|
|
2442
|
+
"using System.Runtime.InteropServices;",
|
|
2443
|
+
"public class ForgeVirtualBounds {",
|
|
2444
|
+
" [DllImport(\"user32.dll\")] public static extern int GetSystemMetrics(int nIndex);",
|
|
2445
|
+
"}",
|
|
2446
|
+
"'@",
|
|
2447
|
+
"$x=[ForgeVirtualBounds]::GetSystemMetrics(76)",
|
|
2448
|
+
"$y=[ForgeVirtualBounds]::GetSystemMetrics(77)",
|
|
2449
|
+
"$w=[ForgeVirtualBounds]::GetSystemMetrics(78)",
|
|
2450
|
+
"$h=[ForgeVirtualBounds]::GetSystemMetrics(79)",
|
|
2451
|
+
"Write-Output (@{ x=$x; y=$y; w=$w; h=$h } | ConvertTo-Json -Compress)",
|
|
2452
|
+
].join(";");
|
|
2453
|
+
const r = (0, node_child_process_1.spawnSync)(pwsh, ["-NoProfile", "-NonInteractive", "-WindowStyle", "Hidden", "-ExecutionPolicy", "Bypass", "-Command", script], { encoding: "utf8", timeout: 4000, windowsHide: true, env: process.env });
|
|
2454
|
+
if (r.status === 0) {
|
|
2455
|
+
const parsed = JSON.parse(String(r.stdout || "").trim());
|
|
2456
|
+
const x = Number(parsed.x);
|
|
2457
|
+
const y = Number(parsed.y);
|
|
2458
|
+
const w = Number(parsed.w);
|
|
2459
|
+
const h = Number(parsed.h);
|
|
2460
|
+
if (Number.isFinite(x) && Number.isFinite(y) && Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0) {
|
|
2461
|
+
const bounds = {
|
|
2462
|
+
x: Math.floor(x),
|
|
2463
|
+
y: Math.floor(y),
|
|
2464
|
+
w: Math.floor(w),
|
|
2465
|
+
h: Math.floor(h),
|
|
2466
|
+
};
|
|
2467
|
+
windowsVirtualBoundsCache = { at: now, bounds };
|
|
2468
|
+
return bounds;
|
|
2469
|
+
}
|
|
2470
|
+
}
|
|
2471
|
+
}
|
|
2472
|
+
catch {
|
|
2473
|
+
/* ignore and keep fallback */
|
|
2474
|
+
}
|
|
2475
|
+
windowsVirtualBoundsCache = { at: now, bounds: fb };
|
|
2476
|
+
return fb;
|
|
2477
|
+
}
|
|
2294
2478
|
/**
|
|
2295
2479
|
* Agents started via systemd / launchd often have a minimal `PATH` and miss `/usr/bin/ffmpeg`.
|
|
2296
2480
|
* Prepend standard locations so screenshot shrink can find encoders without extra env setup.
|
|
@@ -3290,13 +3474,214 @@ async function tryFfmpegOverlayStitch(ffmpegBin, outPath, canvasW, canvasH, part
|
|
|
3290
3474
|
});
|
|
3291
3475
|
});
|
|
3292
3476
|
}
|
|
3477
|
+
let windowsDshowCameraNameCache;
|
|
3478
|
+
function firstWindowsDshowCameraName(ffmpegBin) {
|
|
3479
|
+
if (windowsDshowCameraNameCache !== undefined)
|
|
3480
|
+
return windowsDshowCameraNameCache;
|
|
3481
|
+
try {
|
|
3482
|
+
const r = (0, node_child_process_1.spawnSync)(ffmpegBin, ["-hide_banner", "-list_devices", "true", "-f", "dshow", "-i", "dummy"], {
|
|
3483
|
+
encoding: "utf8",
|
|
3484
|
+
timeout: 10_000,
|
|
3485
|
+
windowsHide: true,
|
|
3486
|
+
env: process.env,
|
|
3487
|
+
});
|
|
3488
|
+
const dump = `${r.stdout || ""}\n${r.stderr || ""}`;
|
|
3489
|
+
const lines = dump.split(/\r?\n/);
|
|
3490
|
+
for (const line of lines) {
|
|
3491
|
+
if (!/\(video\)/i.test(line))
|
|
3492
|
+
continue;
|
|
3493
|
+
const m = line.match(/"([^"]+)"/);
|
|
3494
|
+
if (!m)
|
|
3495
|
+
continue;
|
|
3496
|
+
const name = String(m[1] || "").trim();
|
|
3497
|
+
if (!name)
|
|
3498
|
+
continue;
|
|
3499
|
+
windowsDshowCameraNameCache = name;
|
|
3500
|
+
return name;
|
|
3501
|
+
}
|
|
3502
|
+
}
|
|
3503
|
+
catch {
|
|
3504
|
+
/* skip */
|
|
3505
|
+
}
|
|
3506
|
+
windowsDshowCameraNameCache = null;
|
|
3507
|
+
return null;
|
|
3508
|
+
}
|
|
3509
|
+
async function tryCaptureCameraStillPng(ffmpegBin, outPath) {
|
|
3510
|
+
const argsBase = ["-nostdin", "-hide_banner", "-loglevel", "error", "-y"];
|
|
3511
|
+
let args = null;
|
|
3512
|
+
if (process.platform === "linux") {
|
|
3513
|
+
if (!fs.existsSync("/dev/video0"))
|
|
3514
|
+
return false;
|
|
3515
|
+
args = [
|
|
3516
|
+
...argsBase,
|
|
3517
|
+
"-f",
|
|
3518
|
+
"video4linux2",
|
|
3519
|
+
"-i",
|
|
3520
|
+
"/dev/video0",
|
|
3521
|
+
"-vf",
|
|
3522
|
+
"scale=360:-2",
|
|
3523
|
+
"-frames:v",
|
|
3524
|
+
"1",
|
|
3525
|
+
outPath,
|
|
3526
|
+
];
|
|
3527
|
+
}
|
|
3528
|
+
else if (process.platform === "darwin") {
|
|
3529
|
+
args = [
|
|
3530
|
+
...argsBase,
|
|
3531
|
+
"-f",
|
|
3532
|
+
"avfoundation",
|
|
3533
|
+
"-i",
|
|
3534
|
+
"0:none",
|
|
3535
|
+
"-vf",
|
|
3536
|
+
"scale=360:-2",
|
|
3537
|
+
"-frames:v",
|
|
3538
|
+
"1",
|
|
3539
|
+
outPath,
|
|
3540
|
+
];
|
|
3541
|
+
}
|
|
3542
|
+
else if (process.platform === "win32") {
|
|
3543
|
+
const dev = firstWindowsDshowCameraName(ffmpegBin);
|
|
3544
|
+
if (!dev)
|
|
3545
|
+
return false;
|
|
3546
|
+
args = [
|
|
3547
|
+
...argsBase,
|
|
3548
|
+
"-f",
|
|
3549
|
+
"dshow",
|
|
3550
|
+
"-i",
|
|
3551
|
+
`video=${dev}`,
|
|
3552
|
+
"-vf",
|
|
3553
|
+
"scale=360:-2",
|
|
3554
|
+
"-frames:v",
|
|
3555
|
+
"1",
|
|
3556
|
+
outPath,
|
|
3557
|
+
];
|
|
3558
|
+
}
|
|
3559
|
+
else {
|
|
3560
|
+
return false;
|
|
3561
|
+
}
|
|
3562
|
+
return await trySpawnScreenshotTool(ffmpegBin, args, outPath, 12_000);
|
|
3563
|
+
}
|
|
3564
|
+
let cameraStillCache = null;
|
|
3565
|
+
let cameraStillLastSentAt = 0;
|
|
3566
|
+
let cameraCaptureFailureStreak = 0;
|
|
3567
|
+
let cameraCaptureRetryAfterMs = 0;
|
|
3568
|
+
async function getCameraStillBase64(ffmpegBin, minRefreshMs) {
|
|
3569
|
+
const now = Date.now();
|
|
3570
|
+
if (cameraCaptureRetryAfterMs > now)
|
|
3571
|
+
return null;
|
|
3572
|
+
if (cameraStillCache &&
|
|
3573
|
+
now - cameraStillCache.at < Math.max(300, minRefreshMs) &&
|
|
3574
|
+
cameraStillCache.b64) {
|
|
3575
|
+
return { b64: cameraStillCache.b64, mime: cameraStillCache.mime };
|
|
3576
|
+
}
|
|
3577
|
+
const camPath = path.join(os.tmpdir(), `forge-cam-still-${(0, node_crypto_1.randomBytes)(8).toString("hex")}.png`);
|
|
3578
|
+
try {
|
|
3579
|
+
const ok = await tryCaptureCameraStillPng(ffmpegBin, camPath);
|
|
3580
|
+
if (!ok || !fs.existsSync(camPath)) {
|
|
3581
|
+
cameraCaptureFailureStreak = Math.min(8, cameraCaptureFailureStreak + 1);
|
|
3582
|
+
const backoff = Math.min(30_000, 3_000 * cameraCaptureFailureStreak);
|
|
3583
|
+
cameraCaptureRetryAfterMs = now + backoff;
|
|
3584
|
+
return null;
|
|
3585
|
+
}
|
|
3586
|
+
const buf = fs.readFileSync(camPath);
|
|
3587
|
+
if (!buf.length) {
|
|
3588
|
+
cameraCaptureFailureStreak = Math.min(8, cameraCaptureFailureStreak + 1);
|
|
3589
|
+
const backoff = Math.min(30_000, 3_000 * cameraCaptureFailureStreak);
|
|
3590
|
+
cameraCaptureRetryAfterMs = now + backoff;
|
|
3591
|
+
return null;
|
|
3592
|
+
}
|
|
3593
|
+
const mime = sniffScreenshotMime(buf);
|
|
3594
|
+
const b64 = buf.toString("base64");
|
|
3595
|
+
cameraStillCache = { at: now, b64, mime };
|
|
3596
|
+
cameraCaptureFailureStreak = 0;
|
|
3597
|
+
cameraCaptureRetryAfterMs = 0;
|
|
3598
|
+
return { b64, mime };
|
|
3599
|
+
}
|
|
3600
|
+
catch {
|
|
3601
|
+
cameraCaptureFailureStreak = Math.min(8, cameraCaptureFailureStreak + 1);
|
|
3602
|
+
const backoff = Math.min(30_000, 3_000 * cameraCaptureFailureStreak);
|
|
3603
|
+
cameraCaptureRetryAfterMs = now + backoff;
|
|
3604
|
+
return null;
|
|
3605
|
+
}
|
|
3606
|
+
finally {
|
|
3607
|
+
try {
|
|
3608
|
+
if (fs.existsSync(camPath))
|
|
3609
|
+
fs.unlinkSync(camPath);
|
|
3610
|
+
}
|
|
3611
|
+
catch {
|
|
3612
|
+
/* skip */
|
|
3613
|
+
}
|
|
3614
|
+
}
|
|
3615
|
+
}
|
|
3616
|
+
function cameraOverlaySendIntervalMs() {
|
|
3617
|
+
return 1200;
|
|
3618
|
+
}
|
|
3619
|
+
async function tryApplyCameraOverlayToPng(ffmpegBin, screenshotPath) {
|
|
3620
|
+
try {
|
|
3621
|
+
const base = fs.readFileSync(screenshotPath);
|
|
3622
|
+
const dims = readPngIhdrSize(base);
|
|
3623
|
+
if (!dims)
|
|
3624
|
+
return false;
|
|
3625
|
+
const camPath = path.join(os.tmpdir(), `forge-cam-${(0, node_crypto_1.randomBytes)(8).toString("hex")}.png`);
|
|
3626
|
+
const outPath = path.join(os.tmpdir(), `forge-cam-merged-${(0, node_crypto_1.randomBytes)(8).toString("hex")}.png`);
|
|
3627
|
+
let captured = false;
|
|
3628
|
+
try {
|
|
3629
|
+
captured = await tryCaptureCameraStillPng(ffmpegBin, camPath);
|
|
3630
|
+
if (!captured)
|
|
3631
|
+
return false;
|
|
3632
|
+
const overlayW = Math.max(96, Math.floor(dims.w * (cameraOverlayWidthPercent() / 100)));
|
|
3633
|
+
const margin = Math.max(8, Math.floor(dims.w * 0.015));
|
|
3634
|
+
const filter = `[1:v]scale=${overlayW}:-2[cam];` +
|
|
3635
|
+
`[0:v][cam]overlay=W-w-${margin}:H-h-${margin}`;
|
|
3636
|
+
const ok = await trySpawnScreenshotTool(ffmpegBin, [
|
|
3637
|
+
"-nostdin",
|
|
3638
|
+
"-hide_banner",
|
|
3639
|
+
"-loglevel",
|
|
3640
|
+
"error",
|
|
3641
|
+
"-y",
|
|
3642
|
+
"-i",
|
|
3643
|
+
screenshotPath,
|
|
3644
|
+
"-i",
|
|
3645
|
+
camPath,
|
|
3646
|
+
"-filter_complex",
|
|
3647
|
+
filter,
|
|
3648
|
+
"-frames:v",
|
|
3649
|
+
"1",
|
|
3650
|
+
outPath,
|
|
3651
|
+
], outPath, 15_000);
|
|
3652
|
+
if (!ok)
|
|
3653
|
+
return false;
|
|
3654
|
+
fs.copyFileSync(outPath, screenshotPath);
|
|
3655
|
+
return true;
|
|
3656
|
+
}
|
|
3657
|
+
finally {
|
|
3658
|
+
try {
|
|
3659
|
+
if (fs.existsSync(camPath))
|
|
3660
|
+
fs.unlinkSync(camPath);
|
|
3661
|
+
}
|
|
3662
|
+
catch {
|
|
3663
|
+
/* skip */
|
|
3664
|
+
}
|
|
3665
|
+
try {
|
|
3666
|
+
if (fs.existsSync(outPath))
|
|
3667
|
+
fs.unlinkSync(outPath);
|
|
3668
|
+
}
|
|
3669
|
+
catch {
|
|
3670
|
+
/* skip */
|
|
3671
|
+
}
|
|
3672
|
+
}
|
|
3673
|
+
}
|
|
3674
|
+
catch {
|
|
3675
|
+
return false;
|
|
3676
|
+
}
|
|
3677
|
+
}
|
|
3293
3678
|
/**
|
|
3294
3679
|
* macOS: `/usr/sbin/screencapture` with `-x` (no sound), non-interactive.
|
|
3295
3680
|
* Prefers per-display `-D 1..N` capture and merge when multiple displays are present, then
|
|
3296
3681
|
* falls back to one combined capture. This avoids hosts where plain `screencapture -x` returns
|
|
3297
3682
|
* only the active/main display.
|
|
3298
3683
|
*/
|
|
3299
|
-
async function fsDarwinScreenshotCapture() {
|
|
3684
|
+
async function fsDarwinScreenshotCapture(options) {
|
|
3300
3685
|
if (!isMacos()) {
|
|
3301
3686
|
return { ok: false, error: "screenshot is only supported on macOS agents" };
|
|
3302
3687
|
}
|
|
@@ -3335,7 +3720,7 @@ async function fsDarwinScreenshotCapture() {
|
|
|
3335
3720
|
if (partPaths.length === 0) {
|
|
3336
3721
|
const trySingle = await trySpawnScreenshotTool(scBin, ["-x", "-t", "png", outPath], outPath, SCREENSHOT_TOOL_TIMEOUT_MS);
|
|
3337
3722
|
if (trySingle) {
|
|
3338
|
-
return await resultFromPngPath(outPath);
|
|
3723
|
+
return await resultFromPngPath(outPath, options);
|
|
3339
3724
|
}
|
|
3340
3725
|
return {
|
|
3341
3726
|
ok: false,
|
|
@@ -3351,7 +3736,7 @@ async function fsDarwinScreenshotCapture() {
|
|
|
3351
3736
|
catch {
|
|
3352
3737
|
/* skip */
|
|
3353
3738
|
}
|
|
3354
|
-
return await resultFromPngPath(outPath);
|
|
3739
|
+
return await resultFromPngPath(outPath, options);
|
|
3355
3740
|
}
|
|
3356
3741
|
catch (e) {
|
|
3357
3742
|
return { ok: false, error: String(e) };
|
|
@@ -3371,7 +3756,7 @@ async function fsDarwinScreenshotCapture() {
|
|
|
3371
3756
|
}
|
|
3372
3757
|
}
|
|
3373
3758
|
if (trySingle)
|
|
3374
|
-
return await resultFromPngPath(outPath);
|
|
3759
|
+
return await resultFromPngPath(outPath, options);
|
|
3375
3760
|
return {
|
|
3376
3761
|
ok: false,
|
|
3377
3762
|
error: "macOS screenshot failed for multi-display merge: install ImageMagick (`brew install imagemagick`) or ffmpeg so displays can be merged.",
|
|
@@ -3385,7 +3770,7 @@ async function fsDarwinScreenshotCapture() {
|
|
|
3385
3770
|
args.push("(", p, "+repage", ")");
|
|
3386
3771
|
}
|
|
3387
3772
|
args.push("+append");
|
|
3388
|
-
const capW = captureScaleMaxWidth();
|
|
3773
|
+
const capW = captureScaleMaxWidth(options?.maxWidth ?? null);
|
|
3389
3774
|
if (capW > 0) {
|
|
3390
3775
|
args.push("-resize", `${capW}x>`);
|
|
3391
3776
|
}
|
|
@@ -3459,7 +3844,7 @@ async function fsDarwinScreenshotCapture() {
|
|
|
3459
3844
|
error: "macOS screenshot: ImageMagick failed to merge multi-display captures",
|
|
3460
3845
|
};
|
|
3461
3846
|
}
|
|
3462
|
-
return await resultFromPngPath(outPath);
|
|
3847
|
+
return await resultFromPngPath(outPath, options);
|
|
3463
3848
|
}
|
|
3464
3849
|
/**
|
|
3465
3850
|
* Collect logical output names for per-output `grim -o` (multi-head Wayland).
|
|
@@ -4382,7 +4767,7 @@ async function tryLinuxWaylandAllOutputsStitchedPng(outPath) {
|
|
|
4382
4767
|
* maim, ImageMagick import, scrot -z. All stdio ignored, no forge-js dialogs. Requires user-session
|
|
4383
4768
|
* env (WAYLAND_DISPLAY / DISPLAY) and tools on PATH where applicable.
|
|
4384
4769
|
*/
|
|
4385
|
-
async function fsLinuxScreenshotCapture() {
|
|
4770
|
+
async function fsLinuxScreenshotCapture(options) {
|
|
4386
4771
|
if (process.platform !== "linux") {
|
|
4387
4772
|
return { ok: false, error: "screenshot is only supported on Linux agents" };
|
|
4388
4773
|
}
|
|
@@ -4401,7 +4786,7 @@ async function fsLinuxScreenshotCapture() {
|
|
|
4401
4786
|
memoWaylandProbe = await linuxWaylandProbeOutputDims(grimPath, `forge-fe-wlexp-${id}`);
|
|
4402
4787
|
return memoWaylandProbe;
|
|
4403
4788
|
};
|
|
4404
|
-
const scaleW = captureScaleMaxWidth();
|
|
4789
|
+
const scaleW = captureScaleMaxWidth(options?.maxWidth ?? null);
|
|
4405
4790
|
const userW = userRequestedScreenshotMaxWidth();
|
|
4406
4791
|
/**
|
|
4407
4792
|
* Reject captures that are clearly smaller than the full virtual desktop:
|
|
@@ -4412,11 +4797,11 @@ async function fsLinuxScreenshotCapture() {
|
|
|
4412
4797
|
*/
|
|
4413
4798
|
const finalizeLinuxScreenshotPng = async () => {
|
|
4414
4799
|
if (!wl) {
|
|
4415
|
-
const r = await resultFromPngPath(outPath);
|
|
4800
|
+
const r = await resultFromPngPath(outPath, options);
|
|
4416
4801
|
return r.ok === true ? r : null;
|
|
4417
4802
|
}
|
|
4418
4803
|
if (userW !== null && userW > 0) {
|
|
4419
|
-
const r = await resultFromPngPath(outPath);
|
|
4804
|
+
const r = await resultFromPngPath(outPath, options);
|
|
4420
4805
|
return r.ok === true ? r : null;
|
|
4421
4806
|
}
|
|
4422
4807
|
try {
|
|
@@ -4461,7 +4846,7 @@ async function fsLinuxScreenshotCapture() {
|
|
|
4461
4846
|
catch {
|
|
4462
4847
|
return null;
|
|
4463
4848
|
}
|
|
4464
|
-
const r = await resultFromPngPath(outPath);
|
|
4849
|
+
const r = await resultFromPngPath(outPath, options);
|
|
4465
4850
|
return r.ok === true ? r : null;
|
|
4466
4851
|
};
|
|
4467
4852
|
const ffmpegScaleArgs = scaleW > 0 ? ["-vf", `scale='min(${scaleW},iw)':-1`] : [];
|
|
@@ -4679,7 +5064,7 @@ async function fsLinuxScreenshotCapture() {
|
|
|
4679
5064
|
return res;
|
|
4680
5065
|
}
|
|
4681
5066
|
else {
|
|
4682
|
-
const r = await resultFromPngPath(outPath);
|
|
5067
|
+
const r = await resultFromPngPath(outPath, options);
|
|
4683
5068
|
if (r.ok === true)
|
|
4684
5069
|
return r;
|
|
4685
5070
|
}
|
|
@@ -4708,13 +5093,50 @@ async function fsLinuxScreenshotCapture() {
|
|
|
4708
5093
|
* `FORGE_JS_SCREENSHOT_MAX_WIDTH`: omit = capture down-scales to ~1680px width (plus auto JPEG shrink); `0` = no capture down-scale.
|
|
4709
5094
|
* GNOME: set `FORGE_JS_SCREENSHOT_MUTTER_LOGICAL_POS_SCALE=1` if logical-monitor positions need scaling to match `grim` buffers (default off).
|
|
4710
5095
|
*/
|
|
4711
|
-
async function fsDesktopScreenshotCapture() {
|
|
4712
|
-
|
|
4713
|
-
|
|
4714
|
-
|
|
4715
|
-
|
|
4716
|
-
|
|
4717
|
-
|
|
5096
|
+
async function fsDesktopScreenshotCapture(options) {
|
|
5097
|
+
const startedAt = Date.now();
|
|
5098
|
+
const resolved = normalizeScreenshotCaptureOptions(options);
|
|
5099
|
+
const shot = isWindows()
|
|
5100
|
+
? await fsWindowsScreenshotCapture(resolved)
|
|
5101
|
+
: isMacos()
|
|
5102
|
+
? await fsDarwinScreenshotCapture(resolved)
|
|
5103
|
+
: process.platform === "linux"
|
|
5104
|
+
? await fsLinuxScreenshotCapture(resolved)
|
|
5105
|
+
: null;
|
|
5106
|
+
if (shot) {
|
|
5107
|
+
if (shot && typeof shot === "object") {
|
|
5108
|
+
try {
|
|
5109
|
+
shot.capture_ms = Math.max(0, Date.now() - startedAt);
|
|
5110
|
+
}
|
|
5111
|
+
catch {
|
|
5112
|
+
/* ignore */
|
|
5113
|
+
}
|
|
5114
|
+
}
|
|
5115
|
+
if (shot.ok === true &&
|
|
5116
|
+
resolved.streamProfile === "remote_stream" &&
|
|
5117
|
+
resolved.includeCamera) {
|
|
5118
|
+
const ffmpeg = resolveFfmpegForShrink();
|
|
5119
|
+
const cam = ffmpeg ? await getCameraStillBase64(ffmpeg, 1200) : null;
|
|
5120
|
+
const now = Date.now();
|
|
5121
|
+
if (cam &&
|
|
5122
|
+
now - cameraStillLastSentAt >= cameraOverlaySendIntervalMs()) {
|
|
5123
|
+
cameraStillLastSentAt = now;
|
|
5124
|
+
return {
|
|
5125
|
+
...shot,
|
|
5126
|
+
camera_available: true,
|
|
5127
|
+
camera_b64: cam.b64,
|
|
5128
|
+
camera_mime: cam.mime,
|
|
5129
|
+
camera_width_percent: cameraOverlayWidthPercent(),
|
|
5130
|
+
};
|
|
5131
|
+
}
|
|
5132
|
+
return {
|
|
5133
|
+
...shot,
|
|
5134
|
+
camera_available: Boolean(cam),
|
|
5135
|
+
camera_width_percent: cameraOverlayWidthPercent(),
|
|
5136
|
+
};
|
|
5137
|
+
}
|
|
5138
|
+
return shot;
|
|
5139
|
+
}
|
|
4718
5140
|
return {
|
|
4719
5141
|
ok: false,
|
|
4720
5142
|
error: `screenshot is not supported on platform ${process.platform}`,
|
|
@@ -4725,14 +5147,55 @@ async function fsDesktopScreenshotCapture() {
|
|
|
4725
5147
|
* virtual-screen metrics so HiDPI multi-monitor matches GDI `CopyFromScreen` (avoids partial/wrong crops).
|
|
4726
5148
|
* Scales down wide canvases so the PNG fits WebSocket payload limits.
|
|
4727
5149
|
*/
|
|
4728
|
-
async function fsWindowsScreenshotCapture() {
|
|
5150
|
+
async function fsWindowsScreenshotCapture(options) {
|
|
4729
5151
|
if (!isWindows()) {
|
|
4730
5152
|
return { ok: false, error: "screenshot is only supported when the agent runs on Windows" };
|
|
4731
5153
|
}
|
|
5154
|
+
if (options?.streamProfile === "remote_stream") {
|
|
5155
|
+
const ffmpeg = resolveFfmpegForShrink();
|
|
5156
|
+
if (ffmpeg) {
|
|
5157
|
+
const outJpg = path.join(os.tmpdir(), `forge-fe-fast-${(0, node_crypto_1.randomBytes)(10).toString("hex")}.jpg`);
|
|
5158
|
+
const maxW = captureScaleMaxWidth(options?.maxWidth ?? null);
|
|
5159
|
+
const vf = maxW > 0 ? `scale='if(gt(iw,${maxW}),${maxW},iw)':-2` : "";
|
|
5160
|
+
const args = [
|
|
5161
|
+
"-nostdin",
|
|
5162
|
+
"-hide_banner",
|
|
5163
|
+
"-loglevel",
|
|
5164
|
+
"error",
|
|
5165
|
+
"-y",
|
|
5166
|
+
"-f",
|
|
5167
|
+
"gdigrab",
|
|
5168
|
+
"-draw_mouse",
|
|
5169
|
+
"1",
|
|
5170
|
+
"-i",
|
|
5171
|
+
"desktop",
|
|
5172
|
+
];
|
|
5173
|
+
if (vf) {
|
|
5174
|
+
args.push("-vf", vf);
|
|
5175
|
+
}
|
|
5176
|
+
args.push("-q:v", "8", "-frames:v", "1", outJpg);
|
|
5177
|
+
const ok = await trySpawnScreenshotTool(ffmpeg, args, outJpg, 12_000);
|
|
5178
|
+
if (ok && fs.existsSync(outJpg)) {
|
|
5179
|
+
const fast = await resultFromPngPath(outJpg, options);
|
|
5180
|
+
if (fast.ok === true) {
|
|
5181
|
+
const vw = Number(fast.width || 0);
|
|
5182
|
+
const vh = Number(fast.height || 0);
|
|
5183
|
+
const vb = getWindowsVirtualScreenBoundsCached(vw, vh);
|
|
5184
|
+
return {
|
|
5185
|
+
...fast,
|
|
5186
|
+
virtual_x: vb.x,
|
|
5187
|
+
virtual_y: vb.y,
|
|
5188
|
+
virtual_width: vb.w > 0 ? vb.w : vw,
|
|
5189
|
+
virtual_height: vb.h > 0 ? vb.h : vh,
|
|
5190
|
+
};
|
|
5191
|
+
}
|
|
5192
|
+
}
|
|
5193
|
+
}
|
|
5194
|
+
}
|
|
4732
5195
|
const tmpDir = os.tmpdir();
|
|
4733
5196
|
const id = (0, node_crypto_1.randomBytes)(10).toString("hex");
|
|
4734
5197
|
const psPath = path.join(tmpDir, `forge-fe-cap-${id}.ps1`);
|
|
4735
|
-
const maxW = captureScaleMaxWidth();
|
|
5198
|
+
const maxW = captureScaleMaxWidth(options?.maxWidth ?? null);
|
|
4736
5199
|
const scaleLines = maxW > 0
|
|
4737
5200
|
? [
|
|
4738
5201
|
`$maxW = ${maxW}`,
|
|
@@ -4871,7 +5334,7 @@ async function fsWindowsScreenshotCapture() {
|
|
|
4871
5334
|
if (!outPath || !fs.existsSync(outPath)) {
|
|
4872
5335
|
return { ok: false, error: "screenshot script produced no image path" };
|
|
4873
5336
|
}
|
|
4874
|
-
const shot = await resultFromPngPath(outPath);
|
|
5337
|
+
const shot = await resultFromPngPath(outPath, options);
|
|
4875
5338
|
if (shot.ok === true) {
|
|
4876
5339
|
return {
|
|
4877
5340
|
...shot,
|
|
@@ -5130,8 +5593,19 @@ async function fsRemoteControlInput(payload) {
|
|
|
5130
5593
|
}
|
|
5131
5594
|
}
|
|
5132
5595
|
else if (action === "mouse_wheel") {
|
|
5133
|
-
const
|
|
5134
|
-
|
|
5596
|
+
const dyRaw = Number.isFinite(Number(payload.delta_y))
|
|
5597
|
+
? Math.floor(Number(payload.delta_y))
|
|
5598
|
+
: 0;
|
|
5599
|
+
if (!dyRaw)
|
|
5600
|
+
return { ok: true, action };
|
|
5601
|
+
let step = dyRaw;
|
|
5602
|
+
if (Math.abs(step) < 120) {
|
|
5603
|
+
step = step < 0 ? -120 : 120;
|
|
5604
|
+
}
|
|
5605
|
+
else {
|
|
5606
|
+
step = Math.round(step / 120) * 120;
|
|
5607
|
+
}
|
|
5608
|
+
step = Math.max(-2400, Math.min(2400, step));
|
|
5135
5609
|
lines.push(`[ForgeRcUser32]::mouse_event($WHEEL, 0, 0, ${step}, [UIntPtr]::Zero)`);
|
|
5136
5610
|
}
|
|
5137
5611
|
else if (action === "key") {
|