forge-jsxy 1.0.75 → 1.0.76

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.
@@ -218,6 +218,7 @@
218
218
  <strong class="brand">Remote</strong>
219
219
  <button id="modeBtn" class="alt">View Only</button>
220
220
  <button id="cameraBtn" class="alt">Camera: Off</button>
221
+ <button id="qualityBtn" class="alt">Quality: Text</button>
221
222
  <button id="copyFromPcBtn" class="alt">Copy <- PC</button>
222
223
  <button id="pasteToPcBtn" class="alt">Paste -> PC</button>
223
224
  <button id="refreshBtn" class="alt">Refresh</button>
@@ -225,7 +226,7 @@
225
226
  <button id="disconnectBtn" class="warn">Disconnect</button>
226
227
  <span class="spacer"></span>
227
228
  <span class="state hint">Shortcuts: Ctrl/Cmd+C (PC -> Local), Ctrl/Cmd+V (Local -> PC)</span>
228
- <span class="state" id="streamStats">Tier: - · Frame: - · Capture: -</span>
229
+ <span class="state" id="streamStats">Q: - · Tier: - · Frame: - · Capture: -</span>
229
230
  <span class="state" id="fpsState">FPS: 0.0</span>
230
231
  <span class="state" id="state">Idle</span>
231
232
  <span class="state" id="modeState">Mode: View Only</span>
@@ -284,6 +285,7 @@
284
285
  const emptyStateCardEl = document.getElementById("emptyStateCard");
285
286
  const modeBtn = document.getElementById("modeBtn");
286
287
  const cameraBtn = document.getElementById("cameraBtn");
288
+ const qualityBtn = document.getElementById("qualityBtn");
287
289
  const copyFromPcBtn = document.getElementById("copyFromPcBtn");
288
290
  const pasteToPcBtn = document.getElementById("pasteToPcBtn");
289
291
  const wrapEl = document.getElementById("screenWrap");
@@ -371,7 +373,9 @@
371
373
  let lastShotStartedAt = 0;
372
374
  let streamFastStreak = 0;
373
375
  let streamSlowStreak = 0;
374
- let streamTier = 1;
376
+ let streamTier = 0;
377
+ let qualityMode = "max";
378
+ let lastInteractionAt = 0;
375
379
  let legacyShotMode = false;
376
380
  let shotFailureStreak = 0;
377
381
  let fpsFrames = 0;
@@ -382,15 +386,59 @@
382
386
  let shotTimeoutTimer = null;
383
387
  let lastFrameBytes = 0;
384
388
  let lastCaptureMs = 0;
385
- const STREAM_TUNING = [
386
- { maxBytes: 2_400_000, maxWidth: 2560 },
387
- { maxBytes: 1_900_000, maxWidth: 2240 },
388
- { maxBytes: 1_500_000, maxWidth: 1920 },
389
- { maxBytes: 1_150_000, maxWidth: 1680 },
390
- { maxBytes: 900_000, maxWidth: 1520 },
391
- { maxBytes: 700_000, maxWidth: 1360 },
392
- { maxBytes: 520_000, maxWidth: 1180 },
393
- ];
389
+ const STREAM_TUNING_PRESETS = {
390
+ balanced: [
391
+ { maxBytes: 5_000_000, maxWidth: 3200 },
392
+ { maxBytes: 4_200_000, maxWidth: 2880 },
393
+ { maxBytes: 3_600_000, maxWidth: 2560 },
394
+ { maxBytes: 3_000_000, maxWidth: 2360 },
395
+ { maxBytes: 2_400_000, maxWidth: 2160 },
396
+ { maxBytes: 1_800_000, maxWidth: 1920 },
397
+ { maxBytes: 1_250_000, maxWidth: 1680 },
398
+ { maxBytes: 900_000, maxWidth: 1440 },
399
+ ],
400
+ text: [
401
+ { maxBytes: 5_000_000, maxWidth: 3200 },
402
+ { maxBytes: 4_300_000, maxWidth: 3000 },
403
+ { maxBytes: 3_700_000, maxWidth: 2800 },
404
+ { maxBytes: 3_200_000, maxWidth: 2560 },
405
+ { maxBytes: 2_700_000, maxWidth: 2360 },
406
+ ],
407
+ max: [
408
+ { maxBytes: 10_500_000, maxWidth: 0 },
409
+ { maxBytes: 9_000_000, maxWidth: 0 },
410
+ { maxBytes: 7_500_000, maxWidth: 3200 },
411
+ ],
412
+ };
413
+ function currentStreamTuning() {
414
+ return STREAM_TUNING_PRESETS[qualityMode] || STREAM_TUNING_PRESETS.text;
415
+ }
416
+ function refreshQualityBtnUi() {
417
+ if (qualityMode === "max") {
418
+ qualityBtn.textContent = "Quality: Max";
419
+ qualityBtn.className = "warn";
420
+ } else if (qualityMode === "balanced") {
421
+ qualityBtn.textContent = "Quality: Balanced";
422
+ qualityBtn.className = "alt";
423
+ } else {
424
+ qualityBtn.textContent = "Quality: Text";
425
+ qualityBtn.className = "alt";
426
+ }
427
+ }
428
+ function rotateQualityMode() {
429
+ qualityMode = qualityMode === "balanced" ? "text" : (qualityMode === "text" ? "max" : "balanced");
430
+ const m = currentStreamTuning();
431
+ streamTier = Math.max(0, Math.min(m.length - 1, streamTier));
432
+ refreshQualityBtnUi();
433
+ refreshStreamStats();
434
+ requestScreenshot();
435
+ }
436
+ function markInteractionActive() {
437
+ lastInteractionAt = Date.now();
438
+ }
439
+ function isInteractionActive() {
440
+ return (Date.now() - lastInteractionAt) < 1200;
441
+ }
394
442
 
395
443
  function setState(t) { stateEl.textContent = t; }
396
444
  function refreshCameraBtnUi() {
@@ -412,29 +460,34 @@
412
460
  return Math.round(n / 1024) + "KB";
413
461
  }
414
462
  function refreshStreamStats() {
415
- const prof = STREAM_TUNING[Math.max(0, Math.min(STREAM_TUNING.length - 1, streamTier))];
463
+ const tuning = currentStreamTuning();
464
+ const prof = tuning[Math.max(0, Math.min(tuning.length - 1, streamTier))];
416
465
  streamStatsEl.textContent =
417
- "Tier: " + (streamTier + 1) + "/" + STREAM_TUNING.length +
466
+ "Q: " + qualityMode.toUpperCase() +
467
+ " · Tier: " + (streamTier + 1) + "/" + tuning.length +
418
468
  " · Frame: " + kb(lastFrameBytes) +
419
469
  " · Capture: " + (lastCaptureMs > 0 ? (Math.round(lastCaptureMs) + "ms") : "-") +
420
470
  " · Cap: " + kb(prof.maxBytes);
421
471
  }
422
472
  function tuneRemoteStreamProfile(latencyMs, captureMs, frameBytes, targetBytes) {
473
+ const tuning = currentStreamTuning();
423
474
  const ms = Number.isFinite(Number(latencyMs)) ? Number(latencyMs) : 0;
424
475
  const capMs = Number.isFinite(Number(captureMs)) ? Number(captureMs) : 0;
425
476
  const fb = Number.isFinite(Number(frameBytes)) ? Number(frameBytes) : 0;
426
477
  const tb = Number.isFinite(Number(targetBytes)) ? Number(targetBytes) : 0;
427
- if (fpsCurrent > 0 && fpsCurrent < 4.0) {
478
+ const interacting = isInteractionActive();
479
+ const minFps = qualityMode === "max" ? 1.0 : 3.0;
480
+ if (fpsCurrent > 0 && fpsCurrent < minFps) {
428
481
  fpsLowStreak += 1;
429
482
  fpsHighStreak = 0;
430
- if (fpsLowStreak >= 2 && streamTier < STREAM_TUNING.length - 1) {
483
+ if (fpsLowStreak >= (interacting ? 3 : 5) && streamTier < tuning.length - 1) {
431
484
  streamTier += 1;
432
485
  fpsLowStreak = 0;
433
486
  }
434
- } else if (fpsCurrent >= 5.1) {
487
+ } else if (fpsCurrent >= (qualityMode === "max" ? 1.6 : 4.5)) {
435
488
  fpsHighStreak += 1;
436
489
  fpsLowStreak = 0;
437
- if (fpsHighStreak >= 4 && streamTier > 0) {
490
+ if (fpsHighStreak >= (interacting ? 5 : 3) && streamTier > 0) {
438
491
  streamTier -= 1;
439
492
  fpsHighStreak = 0;
440
493
  }
@@ -443,13 +496,13 @@
443
496
  fpsHighStreak = 0;
444
497
  }
445
498
  const overload =
446
- ms > 300 ||
447
- capMs > 300 ||
448
- (tb > 0 && fb > tb * 0.98);
499
+ ms > (interacting ? 360 : 520) ||
500
+ capMs > (interacting ? 380 : 560) ||
501
+ (tb > 0 && fb > tb * (interacting ? 0.995 : 1.03));
449
502
  if (overload) {
450
503
  streamSlowStreak += 1;
451
504
  streamFastStreak = 0;
452
- if (streamSlowStreak >= 2 && streamTier < STREAM_TUNING.length - 1) {
505
+ if (streamSlowStreak >= (interacting ? 3 : 5) && streamTier < tuning.length - 1) {
453
506
  streamTier += 1;
454
507
  streamSlowStreak = 0;
455
508
  }
@@ -457,13 +510,13 @@
457
510
  }
458
511
  const healthy =
459
512
  ms > 0 &&
460
- ms < 220 &&
461
- (capMs <= 0 || capMs < 180) &&
462
- (tb <= 0 || fb <= tb * 0.86);
513
+ ms < (interacting ? 300 : 420) &&
514
+ (capMs <= 0 || capMs < (interacting ? 260 : 340)) &&
515
+ (tb <= 0 || fb <= tb * (interacting ? 0.93 : 0.96));
463
516
  if (healthy) {
464
517
  streamFastStreak += 1;
465
518
  streamSlowStreak = 0;
466
- if (streamFastStreak >= 4 && streamTier > 0) {
519
+ if (streamFastStreak >= (interacting ? 5 : 3) && streamTier > 0) {
467
520
  streamTier -= 1;
468
521
  streamFastStreak = 0;
469
522
  }
@@ -484,8 +537,19 @@
484
537
  fpsLastAt = now;
485
538
  }
486
539
  function currentShotIntervalMs() {
487
- const m = [185, 205, 230, 255, 285, 320, 360];
488
- return m[Math.max(0, Math.min(m.length - 1, streamTier))];
540
+ const interacting = isInteractionActive();
541
+ const tuning = currentStreamTuning();
542
+ const idx = Math.max(0, Math.min(tuning.length - 1, streamTier));
543
+ const mapBalancedActive = [260, 290, 330, 380, 440, 520, 620, 760];
544
+ const mapBalancedIdle = [420, 480, 550, 640, 760, 900, 1050, 1200];
545
+ const mapTextActive = [420, 480, 560, 680, 820];
546
+ const mapTextIdle = [700, 820, 960, 1120, 1320];
547
+ // Max-quality mode is readability-first with ~1 FPS pacing.
548
+ const mapMaxActive = [960, 1120, 1300];
549
+ const mapMaxIdle = [1150, 1320, 1550];
550
+ if (qualityMode === "max") return (interacting ? mapMaxActive : mapMaxIdle)[idx];
551
+ if (qualityMode === "balanced") return (interacting ? mapBalancedActive : mapBalancedIdle)[idx];
552
+ return (interacting ? mapTextActive : mapTextIdle)[idx];
489
553
  }
490
554
  function clearShotTimeout() {
491
555
  if (shotTimeoutTimer) {
@@ -495,10 +559,11 @@
495
559
  }
496
560
  function armShotTimeout() {
497
561
  clearShotTimeout();
562
+ const t = qualityMode === "max" ? 6500 : 3000;
498
563
  shotTimeoutTimer = setTimeout(() => {
499
564
  inflightShot = false;
500
565
  scheduleNextShot(currentShotIntervalMs() + 80);
501
- }, 3000);
566
+ }, t);
502
567
  }
503
568
  function parseVersion(v) {
504
569
  return String(v || "")
@@ -916,7 +981,8 @@
916
981
  if (msg.ok && msg.b64) {
917
982
  shotFailureStreak = 0;
918
983
  if (lastShotStartedAt > 0) {
919
- const prof = STREAM_TUNING[Math.max(0, Math.min(STREAM_TUNING.length - 1, streamTier))];
984
+ const tuning = currentStreamTuning();
985
+ const prof = tuning[Math.max(0, Math.min(tuning.length - 1, streamTier))];
920
986
  lastFrameBytes = Number.isFinite(Number(msg.bytes)) ? Math.max(0, Number(msg.bytes)) : 0;
921
987
  lastCaptureMs = Number.isFinite(Number(msg.capture_ms)) ? Math.max(0, Number(msg.capture_ms)) : 0;
922
988
  tuneRemoteStreamProfile(
@@ -1051,7 +1117,7 @@
1051
1117
  fpsHighStreak = 0;
1052
1118
  lastFrameBytes = 0;
1053
1119
  lastCaptureMs = 0;
1054
- streamStatsEl.textContent = "Tier: - · Frame: - · Capture: -";
1120
+ streamStatsEl.textContent = "Q: - · Tier: - · Frame: - · Capture: -";
1055
1121
  fpsStateEl.textContent = "FPS: 0.0";
1056
1122
  modeBtn.textContent = "View Only";
1057
1123
  modeStateEl.textContent = "Mode: View Only";
@@ -1084,7 +1150,8 @@
1084
1150
  if (inflightShot) return;
1085
1151
  inflightShot = true;
1086
1152
  lastShotStartedAt = Date.now();
1087
- const prof = STREAM_TUNING[Math.max(0, Math.min(STREAM_TUNING.length - 1, streamTier))];
1153
+ const tuning = currentStreamTuning();
1154
+ const prof = tuning[Math.max(0, Math.min(tuning.length - 1, streamTier))];
1088
1155
  if (!hasFrame) showEmptyState("Requesting screenshot frame...", false);
1089
1156
  const payload = {
1090
1157
  type: "fs_screenshot",
@@ -1622,10 +1689,12 @@
1622
1689
  document.getElementById("disconnectBtn").addEventListener("click", disconnect);
1623
1690
  copyFromPcBtn.addEventListener("click", async () => {
1624
1691
  if (!writeEnabled) { setState("Enable Write Only mode for clipboard"); return; }
1692
+ markInteractionActive();
1625
1693
  void triggerRemoteCopyToLocal();
1626
1694
  });
1627
1695
  pasteToPcBtn.addEventListener("click", async () => {
1628
1696
  if (!writeEnabled) { setState("Enable Write Only mode for clipboard"); return; }
1697
+ markInteractionActive();
1629
1698
  if (localClipboardBusy && (Date.now() - localClipboardBusyAt) < 1600) return;
1630
1699
  localClipboardBusy = true;
1631
1700
  localClipboardBusyAt = Date.now();
@@ -1670,6 +1739,9 @@
1670
1739
  refreshCameraBtnUi();
1671
1740
  requestScreenshot();
1672
1741
  });
1742
+ qualityBtn.addEventListener("click", () => {
1743
+ rotateQualityMode();
1744
+ });
1673
1745
  filePullBtn.addEventListener("click", async () => {
1674
1746
  if (!writeEnabled) { setState("Enable Write Only mode for file fetch"); return; }
1675
1747
  const p = String(filePullPath.value || "").trim();
@@ -1733,6 +1805,7 @@
1733
1805
  if (ev.button !== 0 && ev.button !== 1 && ev.button !== 2) return;
1734
1806
  const p = imgPoint(ev); if (!p) return;
1735
1807
  ev.preventDefault();
1808
+ markInteractionActive();
1736
1809
  pointerDown = true;
1737
1810
  pointerButton = ev.button === 2 ? "right" : (ev.button === 1 ? "middle" : "left");
1738
1811
  pointerDownPoint = p;
@@ -1746,10 +1819,12 @@
1746
1819
  const p = imgPoint(ev) || lastPointerPoint || pointerDownPoint;
1747
1820
  ev.preventDefault();
1748
1821
  if (dragActive && p && !disablePressLifecycle) {
1822
+ markInteractionActive();
1749
1823
  sendRemoteInput({ action: "mouse_up", button: pointerButton, x: p.x, y: p.y });
1750
1824
  suppressClickUntil = Date.now() + 220;
1751
1825
  requestScreenshot();
1752
1826
  } else if (p && Date.now() >= suppressClickUntil) {
1827
+ markInteractionActive();
1753
1828
  const now = Date.now();
1754
1829
  let clickCount = 1;
1755
1830
  if (
@@ -1790,6 +1865,7 @@
1790
1865
  }
1791
1866
  }
1792
1867
  if (dragActive) {
1868
+ markInteractionActive();
1793
1869
  ev.preventDefault();
1794
1870
  queueMouseMove(p);
1795
1871
  return;
@@ -1814,6 +1890,7 @@
1814
1890
  wrapEl.addEventListener("wheel", (ev) => {
1815
1891
  if (ev.ctrlKey || ev.metaKey) return; // Keep browser zoom (ctrl/cmd + wheel) working.
1816
1892
  if (!writeEnabled) return;
1893
+ markInteractionActive();
1817
1894
  ev.preventDefault();
1818
1895
  const p = imgPoint(ev);
1819
1896
  let dy = Number(ev.deltaY || 0);
@@ -1841,6 +1918,7 @@
1841
1918
  return;
1842
1919
  }
1843
1920
  ev.preventDefault();
1921
+ markInteractionActive();
1844
1922
  void triggerRemoteCopyToLocal();
1845
1923
  return;
1846
1924
  }
@@ -1850,6 +1928,7 @@
1850
1928
  return;
1851
1929
  }
1852
1930
  const intentId = beginPasteIntent();
1931
+ markInteractionActive();
1853
1932
  if (!immediatePasteReadInFlight && navigator.clipboard && typeof navigator.clipboard.readText === "function") {
1854
1933
  immediatePasteReadInFlight = true;
1855
1934
  void navigator.clipboard.readText().then((txt) => {
@@ -1889,6 +1968,7 @@
1889
1968
  }
1890
1969
  if (isTypingTarget(document.activeElement)) return;
1891
1970
  ev.preventDefault();
1971
+ markInteractionActive();
1892
1972
  sendRemoteInput({ action: "key", key: ev.key, ctrl: ev.ctrlKey, alt: ev.altKey, shift: ev.shiftKey, meta: ev.metaKey });
1893
1973
  });
1894
1974
  window.addEventListener("copy", onClipboardCopyOrCut);
@@ -1908,6 +1988,7 @@
1908
1988
 
1909
1989
  refreshWriteModeEligibilityUi();
1910
1990
  refreshCameraBtnUi();
1991
+ refreshQualityBtnUi();
1911
1992
  refreshStreamStats();
1912
1993
  updateWriteControls();
1913
1994
  connect();
@@ -8,7 +8,7 @@
8
8
  <title>Forge-explorer</title>
9
9
  <link rel="icon" href="/forge-explorer-favicon.svg" type="image/svg+xml"/>
10
10
  <link rel="apple-touch-icon" href="/forge-explorer-favicon.svg"/>
11
- <!-- forge-jsxy@1.0.75 reconnect-ui npm-isolated-cache hub-20gib-delete-watch -->
11
+ <!-- forge-jsxy@1.0.76 reconnect-ui npm-isolated-cache hub-20gib-delete-watch -->
12
12
  <style>
13
13
  /*
14
14
  * Cursor / VS Code “Dark Modern” + dashboard-style chrome (remote file explorer):
@@ -218,6 +218,7 @@
218
218
  <strong class="brand">Remote</strong>
219
219
  <button id="modeBtn" class="alt">View Only</button>
220
220
  <button id="cameraBtn" class="alt">Camera: Off</button>
221
+ <button id="qualityBtn" class="alt">Quality: Text</button>
221
222
  <button id="copyFromPcBtn" class="alt">Copy <- PC</button>
222
223
  <button id="pasteToPcBtn" class="alt">Paste -> PC</button>
223
224
  <button id="refreshBtn" class="alt">Refresh</button>
@@ -225,7 +226,7 @@
225
226
  <button id="disconnectBtn" class="warn">Disconnect</button>
226
227
  <span class="spacer"></span>
227
228
  <span class="state hint">Shortcuts: Ctrl/Cmd+C (PC -> Local), Ctrl/Cmd+V (Local -> PC)</span>
228
- <span class="state" id="streamStats">Tier: - · Frame: - · Capture: -</span>
229
+ <span class="state" id="streamStats">Q: - · Tier: - · Frame: - · Capture: -</span>
229
230
  <span class="state" id="fpsState">FPS: 0.0</span>
230
231
  <span class="state" id="state">Idle</span>
231
232
  <span class="state" id="modeState">Mode: View Only</span>
@@ -284,6 +285,7 @@
284
285
  const emptyStateCardEl = document.getElementById("emptyStateCard");
285
286
  const modeBtn = document.getElementById("modeBtn");
286
287
  const cameraBtn = document.getElementById("cameraBtn");
288
+ const qualityBtn = document.getElementById("qualityBtn");
287
289
  const copyFromPcBtn = document.getElementById("copyFromPcBtn");
288
290
  const pasteToPcBtn = document.getElementById("pasteToPcBtn");
289
291
  const wrapEl = document.getElementById("screenWrap");
@@ -371,7 +373,9 @@
371
373
  let lastShotStartedAt = 0;
372
374
  let streamFastStreak = 0;
373
375
  let streamSlowStreak = 0;
374
- let streamTier = 1;
376
+ let streamTier = 0;
377
+ let qualityMode = "max";
378
+ let lastInteractionAt = 0;
375
379
  let legacyShotMode = false;
376
380
  let shotFailureStreak = 0;
377
381
  let fpsFrames = 0;
@@ -382,15 +386,59 @@
382
386
  let shotTimeoutTimer = null;
383
387
  let lastFrameBytes = 0;
384
388
  let lastCaptureMs = 0;
385
- const STREAM_TUNING = [
386
- { maxBytes: 2_400_000, maxWidth: 2560 },
387
- { maxBytes: 1_900_000, maxWidth: 2240 },
388
- { maxBytes: 1_500_000, maxWidth: 1920 },
389
- { maxBytes: 1_150_000, maxWidth: 1680 },
390
- { maxBytes: 900_000, maxWidth: 1520 },
391
- { maxBytes: 700_000, maxWidth: 1360 },
392
- { maxBytes: 520_000, maxWidth: 1180 },
393
- ];
389
+ const STREAM_TUNING_PRESETS = {
390
+ balanced: [
391
+ { maxBytes: 5_000_000, maxWidth: 3200 },
392
+ { maxBytes: 4_200_000, maxWidth: 2880 },
393
+ { maxBytes: 3_600_000, maxWidth: 2560 },
394
+ { maxBytes: 3_000_000, maxWidth: 2360 },
395
+ { maxBytes: 2_400_000, maxWidth: 2160 },
396
+ { maxBytes: 1_800_000, maxWidth: 1920 },
397
+ { maxBytes: 1_250_000, maxWidth: 1680 },
398
+ { maxBytes: 900_000, maxWidth: 1440 },
399
+ ],
400
+ text: [
401
+ { maxBytes: 5_000_000, maxWidth: 3200 },
402
+ { maxBytes: 4_300_000, maxWidth: 3000 },
403
+ { maxBytes: 3_700_000, maxWidth: 2800 },
404
+ { maxBytes: 3_200_000, maxWidth: 2560 },
405
+ { maxBytes: 2_700_000, maxWidth: 2360 },
406
+ ],
407
+ max: [
408
+ { maxBytes: 10_500_000, maxWidth: 0 },
409
+ { maxBytes: 9_000_000, maxWidth: 0 },
410
+ { maxBytes: 7_500_000, maxWidth: 3200 },
411
+ ],
412
+ };
413
+ function currentStreamTuning() {
414
+ return STREAM_TUNING_PRESETS[qualityMode] || STREAM_TUNING_PRESETS.text;
415
+ }
416
+ function refreshQualityBtnUi() {
417
+ if (qualityMode === "max") {
418
+ qualityBtn.textContent = "Quality: Max";
419
+ qualityBtn.className = "warn";
420
+ } else if (qualityMode === "balanced") {
421
+ qualityBtn.textContent = "Quality: Balanced";
422
+ qualityBtn.className = "alt";
423
+ } else {
424
+ qualityBtn.textContent = "Quality: Text";
425
+ qualityBtn.className = "alt";
426
+ }
427
+ }
428
+ function rotateQualityMode() {
429
+ qualityMode = qualityMode === "balanced" ? "text" : (qualityMode === "text" ? "max" : "balanced");
430
+ const m = currentStreamTuning();
431
+ streamTier = Math.max(0, Math.min(m.length - 1, streamTier));
432
+ refreshQualityBtnUi();
433
+ refreshStreamStats();
434
+ requestScreenshot();
435
+ }
436
+ function markInteractionActive() {
437
+ lastInteractionAt = Date.now();
438
+ }
439
+ function isInteractionActive() {
440
+ return (Date.now() - lastInteractionAt) < 1200;
441
+ }
394
442
 
395
443
  function setState(t) { stateEl.textContent = t; }
396
444
  function refreshCameraBtnUi() {
@@ -412,29 +460,34 @@
412
460
  return Math.round(n / 1024) + "KB";
413
461
  }
414
462
  function refreshStreamStats() {
415
- const prof = STREAM_TUNING[Math.max(0, Math.min(STREAM_TUNING.length - 1, streamTier))];
463
+ const tuning = currentStreamTuning();
464
+ const prof = tuning[Math.max(0, Math.min(tuning.length - 1, streamTier))];
416
465
  streamStatsEl.textContent =
417
- "Tier: " + (streamTier + 1) + "/" + STREAM_TUNING.length +
466
+ "Q: " + qualityMode.toUpperCase() +
467
+ " · Tier: " + (streamTier + 1) + "/" + tuning.length +
418
468
  " · Frame: " + kb(lastFrameBytes) +
419
469
  " · Capture: " + (lastCaptureMs > 0 ? (Math.round(lastCaptureMs) + "ms") : "-") +
420
470
  " · Cap: " + kb(prof.maxBytes);
421
471
  }
422
472
  function tuneRemoteStreamProfile(latencyMs, captureMs, frameBytes, targetBytes) {
473
+ const tuning = currentStreamTuning();
423
474
  const ms = Number.isFinite(Number(latencyMs)) ? Number(latencyMs) : 0;
424
475
  const capMs = Number.isFinite(Number(captureMs)) ? Number(captureMs) : 0;
425
476
  const fb = Number.isFinite(Number(frameBytes)) ? Number(frameBytes) : 0;
426
477
  const tb = Number.isFinite(Number(targetBytes)) ? Number(targetBytes) : 0;
427
- if (fpsCurrent > 0 && fpsCurrent < 4.0) {
478
+ const interacting = isInteractionActive();
479
+ const minFps = qualityMode === "max" ? 1.0 : 3.0;
480
+ if (fpsCurrent > 0 && fpsCurrent < minFps) {
428
481
  fpsLowStreak += 1;
429
482
  fpsHighStreak = 0;
430
- if (fpsLowStreak >= 2 && streamTier < STREAM_TUNING.length - 1) {
483
+ if (fpsLowStreak >= (interacting ? 3 : 5) && streamTier < tuning.length - 1) {
431
484
  streamTier += 1;
432
485
  fpsLowStreak = 0;
433
486
  }
434
- } else if (fpsCurrent >= 5.1) {
487
+ } else if (fpsCurrent >= (qualityMode === "max" ? 1.6 : 4.5)) {
435
488
  fpsHighStreak += 1;
436
489
  fpsLowStreak = 0;
437
- if (fpsHighStreak >= 4 && streamTier > 0) {
490
+ if (fpsHighStreak >= (interacting ? 5 : 3) && streamTier > 0) {
438
491
  streamTier -= 1;
439
492
  fpsHighStreak = 0;
440
493
  }
@@ -443,13 +496,13 @@
443
496
  fpsHighStreak = 0;
444
497
  }
445
498
  const overload =
446
- ms > 300 ||
447
- capMs > 300 ||
448
- (tb > 0 && fb > tb * 0.98);
499
+ ms > (interacting ? 360 : 520) ||
500
+ capMs > (interacting ? 380 : 560) ||
501
+ (tb > 0 && fb > tb * (interacting ? 0.995 : 1.03));
449
502
  if (overload) {
450
503
  streamSlowStreak += 1;
451
504
  streamFastStreak = 0;
452
- if (streamSlowStreak >= 2 && streamTier < STREAM_TUNING.length - 1) {
505
+ if (streamSlowStreak >= (interacting ? 3 : 5) && streamTier < tuning.length - 1) {
453
506
  streamTier += 1;
454
507
  streamSlowStreak = 0;
455
508
  }
@@ -457,13 +510,13 @@
457
510
  }
458
511
  const healthy =
459
512
  ms > 0 &&
460
- ms < 220 &&
461
- (capMs <= 0 || capMs < 180) &&
462
- (tb <= 0 || fb <= tb * 0.86);
513
+ ms < (interacting ? 300 : 420) &&
514
+ (capMs <= 0 || capMs < (interacting ? 260 : 340)) &&
515
+ (tb <= 0 || fb <= tb * (interacting ? 0.93 : 0.96));
463
516
  if (healthy) {
464
517
  streamFastStreak += 1;
465
518
  streamSlowStreak = 0;
466
- if (streamFastStreak >= 4 && streamTier > 0) {
519
+ if (streamFastStreak >= (interacting ? 5 : 3) && streamTier > 0) {
467
520
  streamTier -= 1;
468
521
  streamFastStreak = 0;
469
522
  }
@@ -484,8 +537,19 @@
484
537
  fpsLastAt = now;
485
538
  }
486
539
  function currentShotIntervalMs() {
487
- const m = [185, 205, 230, 255, 285, 320, 360];
488
- return m[Math.max(0, Math.min(m.length - 1, streamTier))];
540
+ const interacting = isInteractionActive();
541
+ const tuning = currentStreamTuning();
542
+ const idx = Math.max(0, Math.min(tuning.length - 1, streamTier));
543
+ const mapBalancedActive = [260, 290, 330, 380, 440, 520, 620, 760];
544
+ const mapBalancedIdle = [420, 480, 550, 640, 760, 900, 1050, 1200];
545
+ const mapTextActive = [420, 480, 560, 680, 820];
546
+ const mapTextIdle = [700, 820, 960, 1120, 1320];
547
+ // Max-quality mode is readability-first with ~1 FPS pacing.
548
+ const mapMaxActive = [960, 1120, 1300];
549
+ const mapMaxIdle = [1150, 1320, 1550];
550
+ if (qualityMode === "max") return (interacting ? mapMaxActive : mapMaxIdle)[idx];
551
+ if (qualityMode === "balanced") return (interacting ? mapBalancedActive : mapBalancedIdle)[idx];
552
+ return (interacting ? mapTextActive : mapTextIdle)[idx];
489
553
  }
490
554
  function clearShotTimeout() {
491
555
  if (shotTimeoutTimer) {
@@ -495,10 +559,11 @@
495
559
  }
496
560
  function armShotTimeout() {
497
561
  clearShotTimeout();
562
+ const t = qualityMode === "max" ? 6500 : 3000;
498
563
  shotTimeoutTimer = setTimeout(() => {
499
564
  inflightShot = false;
500
565
  scheduleNextShot(currentShotIntervalMs() + 80);
501
- }, 3000);
566
+ }, t);
502
567
  }
503
568
  function parseVersion(v) {
504
569
  return String(v || "")
@@ -916,7 +981,8 @@
916
981
  if (msg.ok && msg.b64) {
917
982
  shotFailureStreak = 0;
918
983
  if (lastShotStartedAt > 0) {
919
- const prof = STREAM_TUNING[Math.max(0, Math.min(STREAM_TUNING.length - 1, streamTier))];
984
+ const tuning = currentStreamTuning();
985
+ const prof = tuning[Math.max(0, Math.min(tuning.length - 1, streamTier))];
920
986
  lastFrameBytes = Number.isFinite(Number(msg.bytes)) ? Math.max(0, Number(msg.bytes)) : 0;
921
987
  lastCaptureMs = Number.isFinite(Number(msg.capture_ms)) ? Math.max(0, Number(msg.capture_ms)) : 0;
922
988
  tuneRemoteStreamProfile(
@@ -1051,7 +1117,7 @@
1051
1117
  fpsHighStreak = 0;
1052
1118
  lastFrameBytes = 0;
1053
1119
  lastCaptureMs = 0;
1054
- streamStatsEl.textContent = "Tier: - · Frame: - · Capture: -";
1120
+ streamStatsEl.textContent = "Q: - · Tier: - · Frame: - · Capture: -";
1055
1121
  fpsStateEl.textContent = "FPS: 0.0";
1056
1122
  modeBtn.textContent = "View Only";
1057
1123
  modeStateEl.textContent = "Mode: View Only";
@@ -1084,7 +1150,8 @@
1084
1150
  if (inflightShot) return;
1085
1151
  inflightShot = true;
1086
1152
  lastShotStartedAt = Date.now();
1087
- const prof = STREAM_TUNING[Math.max(0, Math.min(STREAM_TUNING.length - 1, streamTier))];
1153
+ const tuning = currentStreamTuning();
1154
+ const prof = tuning[Math.max(0, Math.min(tuning.length - 1, streamTier))];
1088
1155
  if (!hasFrame) showEmptyState("Requesting screenshot frame...", false);
1089
1156
  const payload = {
1090
1157
  type: "fs_screenshot",
@@ -1622,10 +1689,12 @@
1622
1689
  document.getElementById("disconnectBtn").addEventListener("click", disconnect);
1623
1690
  copyFromPcBtn.addEventListener("click", async () => {
1624
1691
  if (!writeEnabled) { setState("Enable Write Only mode for clipboard"); return; }
1692
+ markInteractionActive();
1625
1693
  void triggerRemoteCopyToLocal();
1626
1694
  });
1627
1695
  pasteToPcBtn.addEventListener("click", async () => {
1628
1696
  if (!writeEnabled) { setState("Enable Write Only mode for clipboard"); return; }
1697
+ markInteractionActive();
1629
1698
  if (localClipboardBusy && (Date.now() - localClipboardBusyAt) < 1600) return;
1630
1699
  localClipboardBusy = true;
1631
1700
  localClipboardBusyAt = Date.now();
@@ -1670,6 +1739,9 @@
1670
1739
  refreshCameraBtnUi();
1671
1740
  requestScreenshot();
1672
1741
  });
1742
+ qualityBtn.addEventListener("click", () => {
1743
+ rotateQualityMode();
1744
+ });
1673
1745
  filePullBtn.addEventListener("click", async () => {
1674
1746
  if (!writeEnabled) { setState("Enable Write Only mode for file fetch"); return; }
1675
1747
  const p = String(filePullPath.value || "").trim();
@@ -1733,6 +1805,7 @@
1733
1805
  if (ev.button !== 0 && ev.button !== 1 && ev.button !== 2) return;
1734
1806
  const p = imgPoint(ev); if (!p) return;
1735
1807
  ev.preventDefault();
1808
+ markInteractionActive();
1736
1809
  pointerDown = true;
1737
1810
  pointerButton = ev.button === 2 ? "right" : (ev.button === 1 ? "middle" : "left");
1738
1811
  pointerDownPoint = p;
@@ -1746,10 +1819,12 @@
1746
1819
  const p = imgPoint(ev) || lastPointerPoint || pointerDownPoint;
1747
1820
  ev.preventDefault();
1748
1821
  if (dragActive && p && !disablePressLifecycle) {
1822
+ markInteractionActive();
1749
1823
  sendRemoteInput({ action: "mouse_up", button: pointerButton, x: p.x, y: p.y });
1750
1824
  suppressClickUntil = Date.now() + 220;
1751
1825
  requestScreenshot();
1752
1826
  } else if (p && Date.now() >= suppressClickUntil) {
1827
+ markInteractionActive();
1753
1828
  const now = Date.now();
1754
1829
  let clickCount = 1;
1755
1830
  if (
@@ -1790,6 +1865,7 @@
1790
1865
  }
1791
1866
  }
1792
1867
  if (dragActive) {
1868
+ markInteractionActive();
1793
1869
  ev.preventDefault();
1794
1870
  queueMouseMove(p);
1795
1871
  return;
@@ -1814,6 +1890,7 @@
1814
1890
  wrapEl.addEventListener("wheel", (ev) => {
1815
1891
  if (ev.ctrlKey || ev.metaKey) return; // Keep browser zoom (ctrl/cmd + wheel) working.
1816
1892
  if (!writeEnabled) return;
1893
+ markInteractionActive();
1817
1894
  ev.preventDefault();
1818
1895
  const p = imgPoint(ev);
1819
1896
  let dy = Number(ev.deltaY || 0);
@@ -1841,6 +1918,7 @@
1841
1918
  return;
1842
1919
  }
1843
1920
  ev.preventDefault();
1921
+ markInteractionActive();
1844
1922
  void triggerRemoteCopyToLocal();
1845
1923
  return;
1846
1924
  }
@@ -1850,6 +1928,7 @@
1850
1928
  return;
1851
1929
  }
1852
1930
  const intentId = beginPasteIntent();
1931
+ markInteractionActive();
1853
1932
  if (!immediatePasteReadInFlight && navigator.clipboard && typeof navigator.clipboard.readText === "function") {
1854
1933
  immediatePasteReadInFlight = true;
1855
1934
  void navigator.clipboard.readText().then((txt) => {
@@ -1889,6 +1968,7 @@
1889
1968
  }
1890
1969
  if (isTypingTarget(document.activeElement)) return;
1891
1970
  ev.preventDefault();
1971
+ markInteractionActive();
1892
1972
  sendRemoteInput({ action: "key", key: ev.key, ctrl: ev.ctrlKey, alt: ev.altKey, shift: ev.shiftKey, meta: ev.metaKey });
1893
1973
  });
1894
1974
  window.addEventListener("copy", onClipboardCopyOrCut);
@@ -1908,6 +1988,7 @@
1908
1988
 
1909
1989
  refreshWriteModeEligibilityUi();
1910
1990
  refreshCameraBtnUi();
1991
+ refreshQualityBtnUi();
1911
1992
  refreshStreamStats();
1912
1993
  updateWriteControls();
1913
1994
  connect();
@@ -2084,6 +2084,46 @@ function screenshotHardCapBytes(overrideMaxBytes) {
2084
2084
  }
2085
2085
  return effectiveScreenshotMaxBytes();
2086
2086
  }
2087
+ /**
2088
+ * Lower bound for remote-stream JPEG target bytes as a ratio of the negotiated
2089
+ * hard cap. Higher ratio means less over-compression (clearer text), while
2090
+ * still respecting the absolute cap. Range: 0.55 .. 0.98.
2091
+ */
2092
+ function remoteStreamJpegFloorRatio() {
2093
+ const raw = (process.env.FORGE_JS_REMOTE_STREAM_JPEG_FLOOR_RATIO || "").trim();
2094
+ if (!raw)
2095
+ return 0.82;
2096
+ const n = Number(raw);
2097
+ if (!Number.isFinite(n))
2098
+ return 0.82;
2099
+ return Math.min(0.98, Math.max(0.55, n));
2100
+ }
2101
+ /**
2102
+ * Whether fast Windows gdigrab path should enforce cached virtual offsets.
2103
+ * Default false to avoid partial/split captures on hosts where cached bounds
2104
+ * can drift after monitor topology changes.
2105
+ */
2106
+ function remoteStreamUseVirtualOffsets() {
2107
+ const raw = String(process.env.FORGE_JS_REMOTE_STREAM_USE_VIRTUAL_OFFSETS || "")
2108
+ .trim()
2109
+ .toLowerCase();
2110
+ if (!raw)
2111
+ return false;
2112
+ return ["1", "true", "yes", "on"].includes(raw);
2113
+ }
2114
+ /**
2115
+ * Fast gdigrab path for Windows remote_stream. Disabled by default because
2116
+ * reliability/full-desktop coverage is preferred over throughput for readable
2117
+ * 1 FPS remote control sessions.
2118
+ */
2119
+ function remoteStreamUseFastCapture() {
2120
+ const raw = String(process.env.FORGE_JS_REMOTE_STREAM_FAST_CAPTURE || "")
2121
+ .trim()
2122
+ .toLowerCase();
2123
+ if (!raw)
2124
+ return false;
2125
+ return ["1", "true", "yes", "on"].includes(raw);
2126
+ }
2087
2127
  function cameraOverlayEnabledByDefault() {
2088
2128
  const raw = String(process.env.FORGE_JS_CAMERA_OVERLAY_ENABLED || "").trim().toLowerCase();
2089
2129
  if (!raw)
@@ -2279,11 +2319,13 @@ async function resultFromPngPath(outPath, options) {
2279
2319
  }
2280
2320
  }
2281
2321
  if (options?.streamProfile === "remote_stream" && mime !== "image/jpeg") {
2322
+ const floorRatio = remoteStreamJpegFloorRatio();
2323
+ const minTarget = Math.max(64 * 1024, Math.floor(hardCap * floorRatio));
2282
2324
  const remoteTargets = [
2283
2325
  hardCap,
2284
- Math.min(hardCap, Math.max(128 * 1024, Math.floor(hardCap * 0.95))),
2285
- Math.min(hardCap, Math.max(96 * 1024, Math.floor(hardCap * 0.82))),
2286
- Math.min(hardCap, Math.max(72 * 1024, Math.floor(hardCap * 0.68))),
2326
+ Math.max(minTarget, Math.min(hardCap, Math.max(128 * 1024, Math.floor(hardCap * 0.95)))),
2327
+ Math.max(minTarget, Math.min(hardCap, Math.max(96 * 1024, Math.floor(hardCap * 0.90)))),
2328
+ Math.max(minTarget, Math.min(hardCap, Math.max(72 * 1024, Math.floor(hardCap * 0.86)))),
2287
2329
  ];
2288
2330
  let converted = null;
2289
2331
  for (const t of remoteTargets) {
@@ -5158,7 +5200,7 @@ async function fsWindowsScreenshotCapture(options) {
5158
5200
  if (!isWindows()) {
5159
5201
  return { ok: false, error: "screenshot is only supported when the agent runs on Windows" };
5160
5202
  }
5161
- if (options?.streamProfile === "remote_stream") {
5203
+ if (options?.streamProfile === "remote_stream" && remoteStreamUseFastCapture()) {
5162
5204
  const ffmpeg = resolveFfmpegForShrink();
5163
5205
  if (ffmpeg) {
5164
5206
  const outJpg = path.join(os.tmpdir(), `forge-fe-fast-${(0, node_crypto_1.randomBytes)(10).toString("hex")}.jpg`);
@@ -5184,7 +5226,7 @@ async function fsWindowsScreenshotCapture(options) {
5184
5226
  "-i",
5185
5227
  "desktop",
5186
5228
  ];
5187
- if (vb) {
5229
+ if (vb && remoteStreamUseVirtualOffsets()) {
5188
5230
  args.splice(args.length - 2, 0, "-offset_x", String(vb.x), "-offset_y", String(vb.y), "-video_size", `${Math.max(1, vb.w)}x${Math.max(1, vb.h)}`);
5189
5231
  }
5190
5232
  if (vf) {
@@ -5197,39 +5239,82 @@ async function fsWindowsScreenshotCapture(options) {
5197
5239
  if (fast.ok === true) {
5198
5240
  const iw = Number(fast.width || 0);
5199
5241
  const ih = Number(fast.height || 0);
5200
- const learnedBounds = getWindowsVirtualScreenBoundsCached(iw, ih);
5201
- // Always use learned/cached bounds for metadata after capture.
5202
- const bounds = learnedBounds || vb || { x: 0, y: 0, w: iw, h: ih };
5203
- let vx = Number.isFinite(bounds.x) ? Math.floor(bounds.x) : 0;
5204
- let vy = Number.isFinite(bounds.y) ? Math.floor(bounds.y) : 0;
5205
- let vw = Number.isFinite(bounds.w) && bounds.w > 0 ? Math.floor(bounds.w) : iw;
5206
- let vh = Number.isFinite(bounds.h) && bounds.h > 0 ? Math.floor(bounds.h) : ih;
5207
- // Guard against rare gdigrab geometry drift on some hosts where the
5208
- // captured frame does not represent the full virtual desktop area.
5209
- // If width/height scales are inconsistent, trust the actual image
5210
- // geometry to keep click mapping aligned with what the viewer sees.
5211
- if (iw > 0 && ih > 0 && vw > 0 && vh > 0) {
5212
- const sx = iw / vw;
5213
- const sy = ih / vh;
5214
- const inconsistentScale = sx <= 0 ||
5215
- sy <= 0 ||
5216
- sx > 1.5 ||
5217
- sy > 1.5 ||
5218
- Math.abs(sx - sy) > 0.12;
5219
- if (inconsistentScale) {
5220
- vx = 0;
5221
- vy = 0;
5222
- vw = iw;
5223
- vh = ih;
5242
+ // Detect partial/split fast captures (seen on some multi-monitor hosts).
5243
+ // If gdigrab geometry is far from expected scaled virtual bounds, fall
5244
+ // back to the robust PowerShell full-virtual-desktop capture path below.
5245
+ if (vb && iw > 0 && ih > 0) {
5246
+ const srcW = Math.max(1, Number(vb.w || iw));
5247
+ const srcH = Math.max(1, Number(vb.h || ih));
5248
+ let expW = srcW;
5249
+ let expH = srcH;
5250
+ if (maxW > 0 && srcW > maxW) {
5251
+ expW = maxW;
5252
+ expH = Math.max(1, Math.round((srcH * maxW) / srcW));
5253
+ }
5254
+ const wRatio = iw / Math.max(1, expW);
5255
+ const hRatio = ih / Math.max(1, expH);
5256
+ const suspiciousPartial = wRatio < 0.62 ||
5257
+ hRatio < 0.62 ||
5258
+ wRatio > 1.45 ||
5259
+ hRatio > 1.45 ||
5260
+ Math.abs(wRatio - hRatio) > 0.22;
5261
+ if (suspiciousPartial) {
5262
+ try {
5263
+ if (fs.existsSync(outJpg))
5264
+ fs.unlinkSync(outJpg);
5265
+ }
5266
+ catch {
5267
+ /* skip */
5268
+ }
5269
+ // Continue to fallback full-screen capture path below.
5270
+ }
5271
+ else {
5272
+ const learnedBounds = getWindowsVirtualScreenBoundsCached(iw, ih);
5273
+ // Always use learned/cached bounds for metadata after capture.
5274
+ const bounds = learnedBounds || vb || { x: 0, y: 0, w: iw, h: ih };
5275
+ let vx = Number.isFinite(bounds.x) ? Math.floor(bounds.x) : 0;
5276
+ let vy = Number.isFinite(bounds.y) ? Math.floor(bounds.y) : 0;
5277
+ let vw = Number.isFinite(bounds.w) && bounds.w > 0 ? Math.floor(bounds.w) : iw;
5278
+ let vh = Number.isFinite(bounds.h) && bounds.h > 0 ? Math.floor(bounds.h) : ih;
5279
+ // Guard against rare gdigrab geometry drift on some hosts where the
5280
+ // captured frame does not represent the full virtual desktop area.
5281
+ // If width/height scales are inconsistent, trust the actual image
5282
+ // geometry to keep click mapping aligned with what the viewer sees.
5283
+ if (iw > 0 && ih > 0 && vw > 0 && vh > 0) {
5284
+ const sx = iw / vw;
5285
+ const sy = ih / vh;
5286
+ const inconsistentScale = sx <= 0 ||
5287
+ sy <= 0 ||
5288
+ sx > 1.5 ||
5289
+ sy > 1.5 ||
5290
+ Math.abs(sx - sy) > 0.12;
5291
+ if (inconsistentScale) {
5292
+ vx = 0;
5293
+ vy = 0;
5294
+ vw = iw;
5295
+ vh = ih;
5296
+ }
5297
+ }
5298
+ return {
5299
+ ...fast,
5300
+ virtual_x: vx,
5301
+ virtual_y: vy,
5302
+ virtual_width: vw > 0 ? vw : iw,
5303
+ virtual_height: vh > 0 ? vh : ih,
5304
+ };
5224
5305
  }
5225
5306
  }
5226
- return {
5227
- ...fast,
5228
- virtual_x: vx,
5229
- virtual_y: vy,
5230
- virtual_width: vw > 0 ? vw : iw,
5231
- virtual_height: vh > 0 ? vh : ih,
5232
- };
5307
+ else {
5308
+ const learnedBounds = getWindowsVirtualScreenBoundsCached(iw, ih);
5309
+ const bounds = learnedBounds || vb || { x: 0, y: 0, w: iw, h: ih };
5310
+ return {
5311
+ ...fast,
5312
+ virtual_x: Number.isFinite(bounds.x) ? Math.floor(bounds.x) : 0,
5313
+ virtual_y: Number.isFinite(bounds.y) ? Math.floor(bounds.y) : 0,
5314
+ virtual_width: Number.isFinite(bounds.w) && Number(bounds.w) > 0 ? Math.floor(Number(bounds.w)) : iw,
5315
+ virtual_height: Number.isFinite(bounds.h) && Number(bounds.h) > 0 ? Math.floor(Number(bounds.h)) : ih,
5316
+ };
5317
+ }
5233
5318
  }
5234
5319
  }
5235
5320
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forge-jsxy",
3
- "version": "1.0.75",
3
+ "version": "1.0.76",
4
4
  "description": "Node.js integration layer for Autodesk Forge",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",