@midscene/android 1.12.0 → 1.12.1-beta-20260824081858.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/es/cli.mjs CHANGED
@@ -13,7 +13,7 @@ import { execFile } from "node:child_process";
13
13
  import { createDefaultMobileActions, defineAction } from "@midscene/core/device";
14
14
  import { getTmpFile, sleep } from "@midscene/core/utils";
15
15
  import { MIDSCENE_ADB_PATH, MIDSCENE_ADB_REMOTE_HOST, MIDSCENE_ADB_REMOTE_PORT, MIDSCENE_ANDROID_IME_STRATEGY, MIDSCENE_ANDROID_SCREENSHOT_STRATEGY, globalConfigManager } from "@midscene/shared/env";
16
- import { createImgBase64ByFormat, validateScreenshotBuffer } from "@midscene/shared/img";
16
+ import { createImgBase64ByFormat, imageInfoOfBase64, resizeBase64ImageToJpeg, validateScreenshotBuffer } from "@midscene/shared/img";
17
17
  import { ADB, getSdkRootFromEnv } from "appium-adb";
18
18
  var __webpack_modules__ = {
19
19
  "./src/resource-path.ts" (__unused_rspack_module, __webpack_exports__, __webpack_require__) {
@@ -30,7 +30,6 @@ var __webpack_modules__ = {
30
30
  __webpack_require__.d(__webpack_exports__, {
31
31
  GJ: ()=>isScrcpyFreshFrameUnavailableError,
32
32
  ScrcpyScreenshotManager: ()=>ScrcpyScreenshotManager,
33
- Xr: ()=>SCRCPY_VIDEO_BIT_RATE_NETWORK_HINT,
34
33
  ov: ()=>DEFAULT_SCRCPY_CONFIG
35
34
  });
36
35
  var node_fs__rspack_import_0 = __webpack_require__("node:fs");
@@ -64,7 +63,6 @@ var __webpack_modules__ = {
64
63
  const FRESH_FRAME_TIMEOUT_MS = 300;
65
64
  const KEYFRAME_POLL_INTERVAL_MS = 200;
66
65
  const MAX_SCAN_BYTES = 1000;
67
- const CONNECTION_WAIT_MS = 1000;
68
66
  const MAX_SERVER_OUTPUT_LINES = 100;
69
67
  const SERVER_OUTPUT_DRAIN_TIMEOUT_MS = 500;
70
68
  const MAX_FRAME_AGE_US = 500000n;
@@ -74,7 +72,12 @@ var __webpack_modules__ = {
74
72
  'dumpsys',
75
73
  'power'
76
74
  ];
77
- const SCRCPY_VIDEO_BIT_RATE_NETWORK_HINT = 'The appropriate scrcpy video bitrate depends on network conditions. For constrained remote links, consider setting scrcpyConfig.videoBitRate to 4_000_000 (4 Mbps) as a starting point, and lower it further if backlog persists.';
75
+ const SCRCPY_VIDEO_BIT_RATE_NETWORK_HINT = 'The appropriate scrcpy video bitrate depends on network conditions. For constrained remote links, pass --scrcpy-video-bit-rate 4000000 in the Android CLI, or set scrcpyConfig.videoBitRate to 4_000_000 (4 Mbps) in SDK/YAML configuration. Lower it further if backlog persists.';
76
+ const CONSTRAINED_LINK_STARTING_VIDEO_BIT_RATE = 4000000;
77
+ function getScrcpyVideoBitRateNetworkHint(currentVideoBitRate) {
78
+ if (currentVideoBitRate <= CONSTRAINED_LINK_STARTING_VIDEO_BIT_RATE) return 'The current scrcpy video bitrate is already at or below 4 Mbps. Lowering it further is unlikely to help on a local USB connection; check whether the screen was static or the device encoder emitted no new frame.';
79
+ return SCRCPY_VIDEO_BIT_RATE_NETWORK_HINT;
80
+ }
78
81
  const BUSY_LOOP_WINDOW_MS = 1000;
79
82
  const BUSY_LOOP_MAX_READS = 500;
80
83
  const BUSY_LOOP_COOLDOWN_MS = 50;
@@ -88,8 +91,9 @@ var __webpack_modules__ = {
88
91
  const SCRCPY_FRESH_FRAME_UNAVAILABLE_ERROR_CODE = 'ERR_SCRCPY_FRESH_FRAME_UNAVAILABLE';
89
92
  class ScrcpyFreshFrameUnavailableError extends Error {
90
93
  constructor(message, options){
91
- super(message, options), _define_property(this, "code", SCRCPY_FRESH_FRAME_UNAVAILABLE_ERROR_CODE);
94
+ super(message, options), _define_property(this, "code", SCRCPY_FRESH_FRAME_UNAVAILABLE_ERROR_CODE), _define_property(this, "diagnosticMessage", void 0);
92
95
  this.name = 'ScrcpyFreshFrameUnavailableError';
96
+ this.diagnosticMessage = options?.diagnosticMessage;
93
97
  }
94
98
  }
95
99
  function isScrcpyFreshFrameUnavailableError(error) {
@@ -122,26 +126,30 @@ var __webpack_modules__ = {
122
126
  await this.ensureFfmpegAvailable();
123
127
  }
124
128
  async ensureConnected() {
129
+ if (this.disposed) throw new Error('Scrcpy manager has been disposed');
125
130
  if (this.scrcpyClient && this.videoStream) {
126
131
  debugScrcpy('Scrcpy already connected');
127
132
  await this.ensureFrameClockCalibration();
128
133
  this.resetIdleTimer();
129
134
  return;
130
135
  }
131
- if (this.isConnecting) {
132
- debugScrcpy('Connection already in progress, waiting...');
133
- await new Promise((resolve)=>setTimeout(resolve, CONNECTION_WAIT_MS));
134
- if (this.scrcpyClient && this.videoStream) {
135
- await this.ensureFrameClockCalibration();
136
- this.resetIdleTimer();
137
- return;
138
- }
139
- throw new Error('Scrcpy connection failed: another connection attempt did not complete in time');
136
+ if (this.connectionPromise) {
137
+ debugScrcpy('Connection already in progress, sharing the same attempt...');
138
+ await this.connectionPromise;
139
+ return;
140
140
  }
141
+ const connectionPromise = this.connectScrcpy();
142
+ this.connectionPromise = connectionPromise;
143
+ try {
144
+ await connectionPromise;
145
+ } finally{
146
+ if (this.connectionPromise === connectionPromise) this.connectionPromise = null;
147
+ }
148
+ }
149
+ async connectScrcpy() {
141
150
  const serverOutput = [];
142
151
  let serverOutputTask = null;
143
152
  try {
144
- this.isConnecting = true;
145
153
  debugScrcpy('Starting scrcpy connection...');
146
154
  const { AdbScrcpyClient } = await import("@yume-chan/adb-scrcpy");
147
155
  const { ReadableStream } = await import("@yume-chan/stream-extra");
@@ -161,6 +169,8 @@ var __webpack_modules__ = {
161
169
  height
162
170
  };
163
171
  await this.ensureFrameClockCalibration();
172
+ this.streamBaselineFramePending = true;
173
+ this.streamBaselineFrameDeadlineAt = Date.now() + MAX_KEYFRAME_WAIT_MS;
164
174
  this.startFrameConsumer();
165
175
  this.resetIdleTimer();
166
176
  this.isInitialized = true;
@@ -173,8 +183,6 @@ var __webpack_modules__ = {
173
183
  new Promise((resolve)=>setTimeout(resolve, SERVER_OUTPUT_DRAIN_TIMEOUT_MS))
174
184
  ]);
175
185
  throw this.createConnectionError(error, serverOutput);
176
- } finally{
177
- this.isConnecting = false;
178
186
  }
179
187
  }
180
188
  async createScrcpyOptions() {
@@ -359,24 +367,30 @@ var __webpack_modules__ = {
359
367
  if (this.deviceClockCalibrationPromise === calibrationPromise) this.deviceClockCalibrationPromise = null;
360
368
  }
361
369
  }
362
- async setFreshnessBarrier(reason) {
370
+ async setFreshnessBarrier(reason, options = {}) {
371
+ const cachedFrame = this.getCachedKeyframeCandidate();
363
372
  const generation = ++this.frameFreshnessBarrierGeneration;
364
373
  this.frameFreshnessBarrierPending = true;
374
+ this.frameFreshnessBarrierAllowsOverAgeForNextCapture = false;
365
375
  this.clearFrameCache();
366
376
  try {
367
377
  const calibration = this.deviceClockCalibration;
368
378
  if (!calibration) throw new Error('Scrcpy frame clock is not calibrated for the current stream epoch');
369
- const hostMonotonicUs = this.monotonicTimeUs();
379
+ const hostMonotonicUs = options.hostMonotonicUs ?? this.monotonicTimeUs();
370
380
  const estimatedDeviceNowUs = this.estimateDeviceTimeUs(hostMonotonicUs, calibration);
371
381
  const conservativeDeviceNowUs = estimatedDeviceNowUs + this.getCalibrationUncertaintyUs(calibration);
372
382
  const barrierPtsUs = (conservativeDeviceNowUs / 1000n + 1n) * 1000n;
373
383
  if (generation !== this.frameFreshnessBarrierGeneration) return this.frameFreshnessBarrierPtsUs ?? barrierPtsUs;
374
384
  this.frameFreshnessBarrierPtsUs = null === this.frameFreshnessBarrierPtsUs || barrierPtsUs > this.frameFreshnessBarrierPtsUs ? barrierPtsUs : this.frameFreshnessBarrierPtsUs;
375
385
  this.frameFreshnessBarrierReason = reason;
386
+ this.frameFreshnessBarrierAllowsOverAgeForNextCapture = options.allowOverAgeForNextCapture ?? false;
376
387
  this.frameFreshnessBarrierPending = false;
377
388
  this.frameFreshnessError = null;
378
389
  this.lastFramePtsUs = null;
379
- this.clearFrameCache();
390
+ if (cachedFrame?.ptsUs !== void 0 && cachedFrame.ptsUs >= this.frameFreshnessBarrierPtsUs) {
391
+ this.restoreFrameCache(cachedFrame);
392
+ debugScrcpy(`Preserved cached frame PTS ${cachedFrame.ptsUs}µs because it crosses the newly armed ${reason} barrier`);
393
+ } else this.clearFrameCache();
380
394
  debugScrcpy(`Armed frame freshness barrier at PTS ${this.frameFreshnessBarrierPtsUs}µs (${reason}, projected from stream-epoch clock anchor, uncertainty<=${Number(this.getCalibrationUncertaintyUs(calibration)) / 1000}ms)`);
381
395
  return this.frameFreshnessBarrierPtsUs;
382
396
  } catch (error) {
@@ -397,6 +411,7 @@ var __webpack_modules__ = {
397
411
  this.frameFreshnessBarrierPending = true;
398
412
  this.frameFreshnessBarrierPtsUs = null;
399
413
  this.frameFreshnessBarrierReason = null;
414
+ this.frameFreshnessBarrierAllowsOverAgeForNextCapture = false;
400
415
  this.deviceClockCalibration = null;
401
416
  this.clearFrameCache();
402
417
  this.warnFrameFreshness();
@@ -418,7 +433,7 @@ var __webpack_modules__ = {
418
433
  const behindBarrierUs = this.frameFreshnessBarrierPtsUs - packetPtsUs;
419
434
  this.frameFreshnessError = new Error(`Scrcpy frame predates the ${this.frameFreshnessBarrierReason ?? 'active'} freshness barrier by ${Number(behindBarrierUs) / 1000}ms; refusing to use it`);
420
435
  this.clearFrameCache();
421
- this.warnFrameFreshness();
436
+ debugScrcpy(this.frameFreshnessError.message);
422
437
  return false;
423
438
  }
424
439
  estimateFrameAgeUs(packetPtsUs, hostMonotonicUs = this.monotonicTimeUs()) {
@@ -468,13 +483,18 @@ var __webpack_modules__ = {
468
483
  this.lastFrameFreshnessWarningAt = now;
469
484
  }
470
485
  }
471
- warnTransportBacklog(error) {
472
- const now = Date.now();
473
- if (now - this.lastTransportBacklogWarningAt < TRANSPORT_BACKLOG_WARN_INTERVAL_MS) return;
486
+ transportBacklogMessage(error) {
474
487
  const cause = this.frameFreshnessError ?? error;
475
488
  const causeMessage = cause instanceof Error ? cause.message : String(cause);
489
+ if (this.streamBaselineFramePending && null === this.frameFreshnessBarrierPtsUs) return `The new scrcpy stream did not produce a usable baseline frame within its ${MAX_KEYFRAME_WAIT_MS}ms startup window; closing the stream epoch and falling back to ADB screenshot. This is a stream startup or encoder-readiness failure, not evidence that the configured video bitrate is too high.\nError: ${causeMessage}`;
476
490
  const currentBitRateMbps = this.options.videoBitRate / 1000000;
477
- warnScrcpy(`No usable scrcpy frame crossed the active freshness target within ${FRESH_FRAME_TIMEOUT_MS}ms; closing the stale stream epoch and falling back to ADB screenshot. This may indicate transport backlog or a static screen that emitted no new frame. ${SCRCPY_VIDEO_BIT_RATE_NETWORK_HINT} Current videoBitRate: ${this.options.videoBitRate} bps (${currentBitRateMbps} Mbps).\nError: ${causeMessage}`);
491
+ const networkHint = getScrcpyVideoBitRateNetworkHint(this.options.videoBitRate);
492
+ return `No usable scrcpy frame crossed the active freshness target within ${FRESH_FRAME_TIMEOUT_MS}ms; closing the stale stream epoch and falling back to ADB screenshot. This may indicate transport backlog or a static screen that emitted no new frame. ${networkHint} Current videoBitRate: ${this.options.videoBitRate} bps (${currentBitRateMbps} Mbps).\nError: ${causeMessage}`;
493
+ }
494
+ warnTransportBacklog(error) {
495
+ const now = Date.now();
496
+ if (now - this.lastTransportBacklogWarningAt < TRANSPORT_BACKLOG_WARN_INTERVAL_MS) return;
497
+ warnScrcpy(this.transportBacklogMessage(error));
478
498
  this.lastTransportBacklogWarningAt = now;
479
499
  }
480
500
  estimateFrameTiming(packetPtsUs, receivedAtUs) {
@@ -496,12 +516,21 @@ var __webpack_modules__ = {
496
516
  this.lastRawKeyframePtsUs = void 0;
497
517
  this.lastRawKeyframeEstimatedAgeMs = void 0;
498
518
  }
519
+ restoreFrameCache(frame) {
520
+ this.lastRawKeyframe = frame.data;
521
+ this.lastRawKeyframeAt = frame.capturedAt;
522
+ this.lastRawKeyframePtsUs = frame.ptsUs;
523
+ this.lastRawKeyframeEstimatedAgeMs = frame.estimatedAgeMs;
524
+ }
499
525
  monotonicTimeUs() {
500
526
  return process.hrtime.bigint() / 1000n;
501
527
  }
502
528
  resetFrameFreshnessState() {
503
529
  this.frameFreshnessBarrierPtsUs = null;
504
530
  this.frameFreshnessBarrierReason = null;
531
+ this.frameFreshnessBarrierAllowsOverAgeForNextCapture = false;
532
+ this.streamBaselineFramePending = false;
533
+ this.streamBaselineFrameDeadlineAt = 0;
505
534
  this.frameFreshnessBarrierPending = false;
506
535
  this.frameFreshnessBarrierGeneration = 0;
507
536
  this.deviceClockCalibration = null;
@@ -533,35 +562,52 @@ var __webpack_modules__ = {
533
562
  capturedAt: this.lastRawKeyframeAt
534
563
  };
535
564
  }
565
+ canReuseFrameAcrossActionWait(frame) {
566
+ return this.frameFreshnessBarrierAllowsOverAgeForNextCapture && null !== this.frameFreshnessBarrierPtsUs && void 0 !== frame.ptsUs && frame.ptsUs >= this.frameFreshnessBarrierPtsUs;
567
+ }
568
+ canUseStreamBaseline(frame) {
569
+ return this.streamBaselineFramePending && !this.frameFreshnessBarrierPending && null === this.frameFreshnessBarrierPtsUs && Date.now() <= this.streamBaselineFrameDeadlineAt && void 0 !== frame.ptsUs;
570
+ }
571
+ consumeActionFreshnessBarrier(frame) {
572
+ if (!this.canReuseFrameAcrossActionWait(frame)) return;
573
+ this.frameFreshnessBarrierPtsUs = null;
574
+ this.frameFreshnessBarrierReason = null;
575
+ this.frameFreshnessBarrierAllowsOverAgeForNextCapture = false;
576
+ this.frameFreshnessError = null;
577
+ }
536
578
  async decodeRawKeyframeToJpeg(frame) {
537
579
  return this.decodeH264ToJpeg(Buffer.concat([
538
580
  frame.header,
539
581
  frame.data
540
582
  ]));
541
583
  }
542
- async waitForUsableKeyframe(timeoutMs) {
543
- const deadline = Date.now() + timeoutMs;
544
- let candidate = this.getCachedKeyframeCandidate();
545
- while(true){
546
- if (candidate && this.isFrameAgeAcceptable(candidate.ptsUs)) return candidate;
547
- const remainingMs = deadline - Date.now();
548
- if (remainingMs <= 0) throw new Error(`No fresh keyframe received within ${timeoutMs}ms`);
549
- candidate = await this.waitForNextKeyframe(remainingMs);
550
- }
551
- }
552
584
  async prepareFreshFrame() {
553
585
  await this.ensureConnected();
554
586
  await this.ensureFrameClockCalibration();
555
587
  await this.waitForKeyframe();
556
- await this.waitForUsableKeyframe(MAX_KEYFRAME_WAIT_MS);
588
+ await this.waitForPlanningFrame();
557
589
  }
558
590
  async waitForPlanningFrame() {
559
591
  let planningBarrierArmed = false;
560
- let deadline = Date.now() + FRESH_FRAME_TIMEOUT_MS;
592
+ const planningStartedAt = Date.now();
593
+ const startupWindowRemainingMs = this.streamBaselineFramePending ? this.streamBaselineFrameDeadlineAt - planningStartedAt : 0;
594
+ if (this.streamBaselineFramePending && startupWindowRemainingMs <= 0) {
595
+ this.streamBaselineFramePending = false;
596
+ this.streamBaselineFrameDeadlineAt = 0;
597
+ }
598
+ let deadline = planningStartedAt + (startupWindowRemainingMs > 0 ? startupWindowRemainingMs : FRESH_FRAME_TIMEOUT_MS);
561
599
  let candidate = this.getCachedKeyframeCandidate();
562
600
  while(true){
563
601
  if (candidate) {
564
602
  if (void 0 === candidate.ptsUs) throw new Error('Scrcpy frame has no PTS metadata; cannot prove planning freshness');
603
+ if (this.canReuseFrameAcrossActionWait(candidate)) {
604
+ debugScrcpy(`Using frame PTS ${candidate.ptsUs}µs that crossed the active input-action barrier; preserving the settled frame across wait-after-action`);
605
+ return candidate;
606
+ }
607
+ if (this.canUseStreamBaseline(candidate)) {
608
+ debugScrcpy(`Using frame PTS ${candidate.ptsUs}µs as the first planning baseline for the new scrcpy stream epoch`);
609
+ return candidate;
610
+ }
565
611
  const age = this.estimateFrameAge(candidate.ptsUs);
566
612
  if (null === age) throw new Error('Scrcpy frame clock is not calibrated; cannot prove planning freshness');
567
613
  if (age.upperBoundUs <= MAX_FRAME_AGE_US) return candidate;
@@ -579,11 +625,12 @@ var __webpack_modules__ = {
579
625
  }
580
626
  }
581
627
  async closeStaleStreamAndCreateFallbackError(error) {
582
- this.warnTransportBacklog(error);
628
+ const diagnosticMessage = this.transportBacklogMessage(error);
583
629
  const causeMessage = error instanceof Error ? error.message : String(error);
584
630
  await this.disconnect();
585
- return new ScrcpyFreshFrameUnavailableError(`Unable to obtain a fresh scrcpy frame; the stale stream epoch was closed so the caller can use ADB screenshot fallback. ${causeMessage}`, {
586
- cause: error
631
+ return new ScrcpyFreshFrameUnavailableError(`Unable to obtain a fresh scrcpy frame; the stale stream epoch was closed so the caller can restart it or use ADB screenshot fallback. ${causeMessage}`, {
632
+ cause: error,
633
+ diagnosticMessage
587
634
  });
588
635
  }
589
636
  async getScreenshotJpeg() {
@@ -610,6 +657,9 @@ var __webpack_modules__ = {
610
657
  const t5 = Date.now();
611
658
  const result = await this.decodeH264ToJpeg(keyframeBuffer);
612
659
  const decodeTime = Date.now() - t5;
660
+ this.consumeActionFreshnessBarrier(frame);
661
+ this.streamBaselineFramePending = false;
662
+ this.streamBaselineFrameDeadlineAt = 0;
613
663
  const totalTime = Date.now() - perfStart;
614
664
  debugScrcpy(`Performance: total=${totalTime}ms (connect=${connectTime}ms, frameWait=${frameWaitTime}ms, decode=${decodeTime}ms)`);
615
665
  return result;
@@ -766,6 +816,13 @@ var __webpack_modules__ = {
766
816
  }
767
817
  debugScrcpy('Scrcpy disconnected');
768
818
  }
819
+ async dispose() {
820
+ if (this.disposed) return;
821
+ this.disposed = true;
822
+ if (this.connectionPromise) await this.connectionPromise.catch(()=>{});
823
+ await this.disconnect();
824
+ await this.adb.close();
825
+ }
769
826
  isConnected() {
770
827
  return this.isInitialized && null !== this.scrcpyClient;
771
828
  }
@@ -775,7 +832,8 @@ var __webpack_modules__ = {
775
832
  _define_property(this, "videoStream", null);
776
833
  _define_property(this, "spsHeader", null);
777
834
  _define_property(this, "idleTimer", null);
778
- _define_property(this, "isConnecting", false);
835
+ _define_property(this, "connectionPromise", null);
836
+ _define_property(this, "disposed", false);
779
837
  _define_property(this, "isInitialized", false);
780
838
  _define_property(this, "options", void 0);
781
839
  _define_property(this, "ffmpegAvailable", null);
@@ -789,6 +847,9 @@ var __webpack_modules__ = {
789
847
  _define_property(this, "streamReader", null);
790
848
  _define_property(this, "frameFreshnessBarrierPtsUs", null);
791
849
  _define_property(this, "frameFreshnessBarrierReason", null);
850
+ _define_property(this, "frameFreshnessBarrierAllowsOverAgeForNextCapture", false);
851
+ _define_property(this, "streamBaselineFramePending", false);
852
+ _define_property(this, "streamBaselineFrameDeadlineAt", 0);
792
853
  _define_property(this, "frameFreshnessBarrierPending", false);
793
854
  _define_property(this, "frameFreshnessBarrierGeneration", 0);
794
855
  _define_property(this, "deviceClockCalibration", null);
@@ -1024,6 +1085,9 @@ function _define_property(obj, key, value) {
1024
1085
  return obj;
1025
1086
  }
1026
1087
  const debugAdapter = (0, logger_.getDebug)('android:scrcpy-adapter');
1088
+ const warnAdapter = (0, logger_.getDebug)('android:scrcpy-adapter', {
1089
+ console: true
1090
+ });
1027
1091
  const SCRCPY_RETRY_COOLDOWN_MS = 5000;
1028
1092
  const DEFAULT_ADB_SERVER_ENDPOINT = {
1029
1093
  host: '127.0.0.1',
@@ -1077,10 +1141,11 @@ class ScrcpyDeviceAdapter {
1077
1141
  if (null === this.retryAfter || Date.now() >= this.retryAfter) return;
1078
1142
  throw new Error(`scrcpy retry is cooling down until ${new Date(this.retryAfter).toISOString()}. Last error: ${this.lastError}`);
1079
1143
  }
1080
- resolveConfig(deviceInfo) {
1144
+ resolveConfig(_deviceInfo) {
1081
1145
  if (this.resolvedConfig) return this.resolvedConfig;
1082
1146
  const config = this.scrcpyConfig;
1083
1147
  const maxSize = config?.maxSize ?? scrcpy_manager.ov.maxSize;
1148
+ if (!Number.isInteger(maxSize) || maxSize < 0) throw new Error(`Invalid scrcpyConfig.maxSize: expected a non-negative integer, received ${maxSize}`);
1084
1149
  const videoBitRate = config?.videoBitRate ?? scrcpy_manager.ov.videoBitRate;
1085
1150
  this.resolvedConfig = {
1086
1151
  enabled: this.isConfigured(),
@@ -1090,31 +1155,68 @@ class ScrcpyDeviceAdapter {
1090
1155
  };
1091
1156
  return this.resolvedConfig;
1092
1157
  }
1158
+ async prepareFallbackScreenshot(screenshotBase64) {
1159
+ if (!this.isConfigured()) return screenshotBase64;
1160
+ const { maxSize } = this.resolveConfig();
1161
+ if (0 === maxSize) return screenshotBase64;
1162
+ const sourceSize = await imageInfoOfBase64(screenshotBase64);
1163
+ const largestDimension = Math.max(sourceSize.width, sourceSize.height);
1164
+ if (largestDimension <= maxSize) return screenshotBase64;
1165
+ const scale = maxSize / largestDimension;
1166
+ const targetSize = {
1167
+ width: Math.max(1, Math.round(sourceSize.width * scale)),
1168
+ height: Math.max(1, Math.round(sourceSize.height * scale))
1169
+ };
1170
+ debugAdapter(`Applying scrcpy maxSize to fallback screenshot: ${sourceSize.width}x${sourceSize.height} -> ${targetSize.width}x${targetSize.height}`);
1171
+ return resizeBase64ImageToJpeg(screenshotBase64, {
1172
+ sourceSize,
1173
+ targetSize
1174
+ });
1175
+ }
1093
1176
  async ensureManager(deviceInfo) {
1094
1177
  if (this.manager) return this.manager;
1178
+ if (this.managerPromise) return this.managerPromise;
1095
1179
  debugAdapter('Initializing Scrcpy manager...');
1180
+ const generation = this.lifecycleGeneration;
1181
+ const managerPromise = (async ()=>{
1182
+ let adb = null;
1183
+ let manager = null;
1184
+ try {
1185
+ const { Adb, AdbServerClient } = await import("@yume-chan/adb");
1186
+ const { AdbServerNodeTcpConnector } = await import("@yume-chan/adb-server-node-tcp");
1187
+ const { ScrcpyScreenshotManager: ScrcpyManager } = await Promise.resolve().then(__webpack_require__.bind(__webpack_require__, "./src/scrcpy-manager.ts"));
1188
+ const adbServerEndpoint = await this.resolveAdbServerEndpoint();
1189
+ const adbClient = new AdbServerClient(new AdbServerNodeTcpConnector(adbServerEndpoint));
1190
+ adb = new Adb(await adbClient.createTransport({
1191
+ serial: this.deviceId
1192
+ }));
1193
+ const config = this.resolveConfig(deviceInfo);
1194
+ manager = new ScrcpyManager(adb, {
1195
+ maxSize: config.maxSize,
1196
+ videoBitRate: config.videoBitRate,
1197
+ idleTimeoutMs: config.idleTimeoutMs
1198
+ });
1199
+ await manager.validateEnvironment();
1200
+ if (generation !== this.lifecycleGeneration) throw new Error('Scrcpy manager initialization was superseded by device cleanup');
1201
+ this.manager = manager;
1202
+ debugAdapter('Scrcpy manager initialized');
1203
+ return manager;
1204
+ } catch (error) {
1205
+ try {
1206
+ if (manager) await manager.dispose();
1207
+ else await adb?.close();
1208
+ } catch (cleanupError) {
1209
+ debugAdapter(`Failed to clean up Scrcpy manager initialization: ${cleanupError}`);
1210
+ }
1211
+ debugAdapter(`Failed to initialize Scrcpy manager: ${error}`);
1212
+ throw new Error(`Failed to initialize Scrcpy for device ${this.deviceId}. Ensure ADB server is running and device is connected. Error: ${error}`);
1213
+ }
1214
+ })();
1215
+ this.managerPromise = managerPromise;
1096
1216
  try {
1097
- const { Adb, AdbServerClient } = await import("@yume-chan/adb");
1098
- const { AdbServerNodeTcpConnector } = await import("@yume-chan/adb-server-node-tcp");
1099
- const { ScrcpyScreenshotManager: ScrcpyManager } = await Promise.resolve().then(__webpack_require__.bind(__webpack_require__, "./src/scrcpy-manager.ts"));
1100
- const adbServerEndpoint = await this.resolveAdbServerEndpoint();
1101
- const adbClient = new AdbServerClient(new AdbServerNodeTcpConnector(adbServerEndpoint));
1102
- const adb = new Adb(await adbClient.createTransport({
1103
- serial: this.deviceId
1104
- }));
1105
- const config = this.resolveConfig(deviceInfo);
1106
- const manager = new ScrcpyManager(adb, {
1107
- maxSize: config.maxSize,
1108
- videoBitRate: config.videoBitRate,
1109
- idleTimeoutMs: config.idleTimeoutMs
1110
- });
1111
- await manager.validateEnvironment();
1112
- this.manager = manager;
1113
- debugAdapter('Scrcpy manager initialized');
1114
- return this.manager;
1115
- } catch (error) {
1116
- debugAdapter(`Failed to initialize Scrcpy manager: ${error}`);
1117
- throw new Error(`Failed to initialize Scrcpy for device ${this.deviceId}. Ensure ADB server is running and device is connected. Error: ${error}`);
1217
+ return await managerPromise;
1218
+ } finally{
1219
+ if (this.managerPromise === managerPromise) this.managerPromise = null;
1118
1220
  }
1119
1221
  }
1120
1222
  async screenshotBase64(deviceInfo) {
@@ -1126,16 +1228,54 @@ class ScrcpyDeviceAdapter {
1126
1228
  await this.applyPendingActionBarrier(manager);
1127
1229
  const screenshotBuffer = await manager.getScreenshotJpeg();
1128
1230
  this.clearFailure();
1129
- return createImgBase64ByFormat('jpeg', screenshotBuffer.toString('base64'));
1231
+ return this.jpegBufferToBase64(screenshotBuffer);
1130
1232
  } catch (error) {
1131
- if ((0, scrcpy_manager.GJ)(error)) {
1132
- this.markFreshnessRecoveryPending(manager, error);
1233
+ if (!(0, scrcpy_manager.GJ)(error)) {
1234
+ this.recordFailure(error);
1133
1235
  throw error;
1134
1236
  }
1135
- this.recordFailure(error);
1136
- throw error;
1237
+ this.markFreshnessRecoveryPending(error);
1238
+ debugAdapter(`Scrcpy freshness target was unavailable; restarting the stream once before ADB fallback: ${error}`);
1239
+ try {
1240
+ const screenshotBuffer = await this.restartAndCaptureOnce(deviceInfo);
1241
+ manager = this.manager;
1242
+ if (!manager) throw new Error('Scrcpy manager disappeared after freshness recovery');
1243
+ this.attachKeyframeListeners(manager);
1244
+ this.clearFailure();
1245
+ debugAdapter('Scrcpy screenshot recovered on a new stream epoch');
1246
+ return this.jpegBufferToBase64(screenshotBuffer);
1247
+ } catch (retryError) {
1248
+ if ((0, scrcpy_manager.GJ)(retryError)) this.markFreshnessRecoveryPending(retryError);
1249
+ else this.recordFailure(retryError);
1250
+ this.warnFreshnessFallback(error, retryError);
1251
+ throw retryError;
1252
+ }
1137
1253
  }
1138
1254
  }
1255
+ async restartAndCaptureOnce(deviceInfo) {
1256
+ if (this.freshnessRestartPromise) return this.freshnessRestartPromise;
1257
+ const generation = this.lifecycleGeneration;
1258
+ const restartPromise = (async ()=>{
1259
+ const manager = await this.ensureManager(deviceInfo);
1260
+ await manager.ensureConnected();
1261
+ await this.applyPendingActionBarrier(manager);
1262
+ if (generation !== this.lifecycleGeneration) throw new Error('Scrcpy freshness restart was cancelled by cleanup');
1263
+ return manager.getScreenshotJpeg();
1264
+ })();
1265
+ this.freshnessRestartPromise = restartPromise;
1266
+ try {
1267
+ return await restartPromise;
1268
+ } finally{
1269
+ if (this.freshnessRestartPromise === restartPromise) this.freshnessRestartPromise = null;
1270
+ }
1271
+ }
1272
+ jpegBufferToBase64(screenshotBuffer) {
1273
+ return createImgBase64ByFormat('jpeg', screenshotBuffer.toString('base64'));
1274
+ }
1275
+ warnFreshnessFallback(firstError, retryError) {
1276
+ const retryDiagnostic = (0, scrcpy_manager.GJ)(retryError) ? retryError.diagnosticMessage : void 0;
1277
+ warnAdapter(retryDiagnostic ?? (firstError.diagnosticMessage ? `${firstError.diagnosticMessage}\nScrcpy stream restart error: ${retryError}` : void 0) ?? `Scrcpy stream restart failed; falling back to ADB screenshot. Error: ${retryError}`);
1278
+ }
1139
1279
  async subscribeKeyframes(deviceInfo, listener) {
1140
1280
  this.ensureRetryReady();
1141
1281
  this.keyframeListeners.add(listener);
@@ -1168,17 +1308,26 @@ class ScrcpyDeviceAdapter {
1168
1308
  this.keyframeUnsubscribers.clear();
1169
1309
  for (const listener of this.keyframeListeners)this.attachKeyframeListener(manager, listener);
1170
1310
  }
1171
- markFreshnessRecoveryPending(manager, error) {
1311
+ markFreshnessRecoveryPending(error) {
1172
1312
  this.lastError = error.message;
1173
1313
  this.retryAfter = null;
1174
1314
  this.freshnessRecoveryPending = true;
1175
1315
  this.keyframeUnsubscribers.clear();
1176
- if (manager && this.manager === manager) this.manager = null;
1177
1316
  }
1178
1317
  async applyPendingActionBarrier(manager) {
1179
- if (!this.pendingActionBarrier) return;
1180
- await manager.setFreshnessBarrier('completed input action while scrcpy was unavailable');
1181
- this.pendingActionBarrier = false;
1318
+ const actionCompletedAtHostUs = this.pendingActionBarrierAtHostUs;
1319
+ if (null === actionCompletedAtHostUs) return;
1320
+ await manager.setFreshnessBarrier('completed input action while scrcpy was unavailable', {
1321
+ allowOverAgeForNextCapture: true,
1322
+ hostMonotonicUs: actionCompletedAtHostUs
1323
+ });
1324
+ if (this.pendingActionBarrierAtHostUs === actionCompletedAtHostUs) this.pendingActionBarrierAtHostUs = null;
1325
+ }
1326
+ monotonicTimeUs() {
1327
+ return process.hrtime.bigint() / 1000n;
1328
+ }
1329
+ deferActionBarrier(actionCompletedAtHostUs) {
1330
+ if (null === this.pendingActionBarrierAtHostUs || actionCompletedAtHostUs > this.pendingActionBarrierAtHostUs) this.pendingActionBarrierAtHostUs = actionCompletedAtHostUs;
1182
1331
  }
1183
1332
  recoverAfterAdbScreenshot(deviceInfo) {
1184
1333
  if (!this.freshnessRecoveryPending || this.recoveryPromise) return;
@@ -1191,7 +1340,7 @@ class ScrcpyDeviceAdapter {
1191
1340
  await this.applyPendingActionBarrier(manager);
1192
1341
  await manager.prepareFreshFrame();
1193
1342
  if (generation !== this.lifecycleGeneration) {
1194
- await manager.disconnect();
1343
+ await manager.dispose();
1195
1344
  if (this.manager === manager) this.manager = null;
1196
1345
  return;
1197
1346
  }
@@ -1200,7 +1349,7 @@ class ScrcpyDeviceAdapter {
1200
1349
  debugAdapter('Scrcpy freshness recovery completed in background');
1201
1350
  } catch (error) {
1202
1351
  if (manager) {
1203
- await manager.disconnect();
1352
+ await manager.dispose();
1204
1353
  if (this.manager === manager) this.manager = null;
1205
1354
  }
1206
1355
  this.freshnessRecoveryPending = false;
@@ -1215,21 +1364,22 @@ class ScrcpyDeviceAdapter {
1215
1364
  });
1216
1365
  }
1217
1366
  async markActionBarrier() {
1367
+ const actionCompletedAtHostUs = this.monotonicTimeUs();
1218
1368
  const manager = this.manager;
1219
- if (!manager?.isConnected()) {
1220
- this.pendingActionBarrier = true;
1221
- return;
1222
- }
1369
+ if (!manager?.isConnected()) return void this.deferActionBarrier(actionCompletedAtHostUs);
1223
1370
  try {
1224
- await manager.setFreshnessBarrier('completed input action');
1225
- this.pendingActionBarrier = false;
1371
+ await manager.setFreshnessBarrier('completed input action', {
1372
+ allowOverAgeForNextCapture: true,
1373
+ hostMonotonicUs: actionCompletedAtHostUs
1374
+ });
1375
+ if (null !== this.pendingActionBarrierAtHostUs && this.pendingActionBarrierAtHostUs <= actionCompletedAtHostUs) this.pendingActionBarrierAtHostUs = null;
1226
1376
  this.clearFailure();
1227
1377
  } catch (error) {
1228
- this.pendingActionBarrier = true;
1378
+ this.deferActionBarrier(actionCompletedAtHostUs);
1229
1379
  this.recordFailure(error);
1230
1380
  debugAdapter(`Unable to mark scrcpy action barrier; disabling this stream: ${error}`);
1231
1381
  try {
1232
- await manager.disconnect();
1382
+ await manager.dispose();
1233
1383
  } catch (disconnectError) {
1234
1384
  debugAdapter(`Error disconnecting scrcpy after barrier failure: ${disconnectError}`);
1235
1385
  } finally{
@@ -1262,14 +1412,16 @@ class ScrcpyDeviceAdapter {
1262
1412
  async disconnect() {
1263
1413
  this.lifecycleGeneration += 1;
1264
1414
  this.freshnessRecoveryPending = false;
1265
- this.pendingActionBarrier = false;
1415
+ this.pendingActionBarrierAtHostUs = null;
1266
1416
  for (const unsubscribe of this.keyframeUnsubscribers.values())unsubscribe();
1267
1417
  this.keyframeUnsubscribers.clear();
1268
1418
  this.keyframeListeners.clear();
1269
1419
  if (this.recoveryPromise) await this.recoveryPromise.catch(()=>{});
1420
+ if (this.freshnessRestartPromise) await this.freshnessRestartPromise.catch(()=>{});
1421
+ if (this.managerPromise) await this.managerPromise.catch(()=>{});
1270
1422
  if (this.manager) {
1271
1423
  try {
1272
- await this.manager.disconnect();
1424
+ await this.manager.dispose();
1273
1425
  } catch (error) {
1274
1426
  debugAdapter(`Error disconnecting scrcpy: ${error}`);
1275
1427
  }
@@ -1283,26 +1435,30 @@ class ScrcpyDeviceAdapter {
1283
1435
  _define_property(this, "scrcpyConfig", void 0);
1284
1436
  _define_property(this, "resolveAdbServerEndpoint", void 0);
1285
1437
  _define_property(this, "manager", void 0);
1438
+ _define_property(this, "managerPromise", void 0);
1286
1439
  _define_property(this, "resolvedConfig", void 0);
1287
1440
  _define_property(this, "lastError", void 0);
1288
1441
  _define_property(this, "retryAfter", void 0);
1289
1442
  _define_property(this, "freshnessRecoveryPending", void 0);
1443
+ _define_property(this, "freshnessRestartPromise", void 0);
1290
1444
  _define_property(this, "recoveryPromise", void 0);
1291
1445
  _define_property(this, "lifecycleGeneration", void 0);
1292
- _define_property(this, "pendingActionBarrier", void 0);
1446
+ _define_property(this, "pendingActionBarrierAtHostUs", void 0);
1293
1447
  _define_property(this, "keyframeListeners", void 0);
1294
1448
  _define_property(this, "keyframeUnsubscribers", void 0);
1295
1449
  this.deviceId = deviceId;
1296
1450
  this.scrcpyConfig = scrcpyConfig;
1297
1451
  this.resolveAdbServerEndpoint = resolveAdbServerEndpoint;
1298
1452
  this.manager = null;
1453
+ this.managerPromise = null;
1299
1454
  this.resolvedConfig = null;
1300
1455
  this.lastError = null;
1301
1456
  this.retryAfter = null;
1302
1457
  this.freshnessRecoveryPending = false;
1458
+ this.freshnessRestartPromise = null;
1303
1459
  this.recoveryPromise = null;
1304
1460
  this.lifecycleGeneration = 0;
1305
- this.pendingActionBarrier = false;
1461
+ this.pendingActionBarrierAtHostUs = null;
1306
1462
  this.keyframeListeners = new Set();
1307
1463
  this.keyframeUnsubscribers = new Map();
1308
1464
  }
@@ -2153,9 +2309,9 @@ ${Object.keys(size).filter((key)=>size[key]).map((key)=>` ${key} size: ${size[k
2153
2309
  }
2154
2310
  async screenshotBase64() {
2155
2311
  debugDevice('screenshotBase64 begin');
2156
- const screenshotStrategy = this.options?.screenshotStrategy || globalConfigManager.getEnvConfigValue(MIDSCENE_ANDROID_SCREENSHOT_STRATEGY) || SCREENSHOT_STRATEGY_AUTO;
2157
- if (screenshotStrategy === SCREENSHOT_STRATEGY_ALWAYS_YADB) return this.screenshotBase64ViaYadb();
2158
2312
  const adapter = this.getScrcpyAdapter();
2313
+ const screenshotStrategy = this.options?.screenshotStrategy || globalConfigManager.getEnvConfigValue(MIDSCENE_ANDROID_SCREENSHOT_STRATEGY) || SCREENSHOT_STRATEGY_AUTO;
2314
+ if (screenshotStrategy === SCREENSHOT_STRATEGY_ALWAYS_YADB) return adapter.prepareFallbackScreenshot(await this.screenshotBase64ViaYadb());
2159
2315
  let scrcpyDeviceInfo = null;
2160
2316
  if (adapter.isEnabled()) try {
2161
2317
  debugDevice('Attempting scrcpy screenshot...');
@@ -2164,7 +2320,7 @@ ${Object.keys(size).filter((key)=>size[key]).map((key)=>` ${key} size: ${size[k
2164
2320
  debugDevice('screenshotBase64 end (scrcpy mode)');
2165
2321
  return result;
2166
2322
  } catch (error) {
2167
- warnDevice(`Scrcpy screenshot failed, falling back to standard ADB method. This may be caused by transport backlog. ${scrcpy_manager.Xr}\nError: ${error}`);
2323
+ warnDevice(`Scrcpy screenshot failed, falling back to standard ADB method.\nError: ${error}`);
2168
2324
  }
2169
2325
  const adb = await this.getAdb();
2170
2326
  let screenshotBuffer;
@@ -2206,14 +2362,14 @@ ${Object.keys(size).filter((key)=>size[key]).map((key)=>` ${key} size: ${size[k
2206
2362
  });
2207
2363
  debugDevice('screenshotBase64 end (fallback)');
2208
2364
  if (scrcpyDeviceInfo) adapter.recoverAfterAdbScreenshot(scrcpyDeviceInfo);
2209
- return result;
2365
+ return adapter.prepareFallbackScreenshot(result);
2210
2366
  }
2211
2367
  if (!screenshotBuffer) throw new Error('Failed to capture screenshot: all methods failed');
2212
2368
  debugDevice('Converting to base64');
2213
2369
  const result = createImgBase64ByFormat('png', screenshotBuffer.toString('base64'));
2214
2370
  debugDevice('screenshotBase64 end');
2215
2371
  if (scrcpyDeviceInfo) adapter.recoverAfterAdbScreenshot(scrcpyDeviceInfo);
2216
- return result;
2372
+ return adapter.prepareFallbackScreenshot(result);
2217
2373
  }
2218
2374
  async captureScreenshotBase64FromDeviceFile(label, capture) {
2219
2375
  const adb = await this.getAdb();
@@ -3067,12 +3223,18 @@ function agent_tools_define_property(obj, key, value) {
3067
3223
  const debug = (0, logger_.getDebug)('agent-tools:android');
3068
3224
  function adaptAndroidInitArgs(extracted) {
3069
3225
  if (!extracted) return;
3226
+ const scrcpyVideoBitRate = 'number' == typeof extracted.scrcpyVideoBitRate ? extracted.scrcpyVideoBitRate : void 0;
3227
+ const requestedUseScrcpy = 'boolean' == typeof extracted.useScrcpy ? extracted.useScrcpy : void 0;
3228
+ const useScrcpy = void 0 !== scrcpyVideoBitRate ? true : requestedUseScrcpy;
3070
3229
  const initArgs = {
3071
3230
  ...'string' == typeof extracted.deviceId ? {
3072
3231
  deviceId: extracted.deviceId
3073
3232
  } : {},
3074
- ...'boolean' == typeof extracted.useScrcpy ? {
3075
- useScrcpy: extracted.useScrcpy
3233
+ ...void 0 !== useScrcpy ? {
3234
+ useScrcpy
3235
+ } : {},
3236
+ ...void 0 !== scrcpyVideoBitRate ? {
3237
+ scrcpyVideoBitRate
3076
3238
  } : {},
3077
3239
  ...extractAgentBehaviorInitArgs(extracted) ?? {}
3078
3240
  };
@@ -3104,7 +3266,10 @@ class AndroidMidsceneTools extends BaseMidsceneTools {
3104
3266
  ...extractAgentBehaviorInitArgs(initArgs) ?? {},
3105
3267
  ...initArgs?.useScrcpy ? {
3106
3268
  scrcpyConfig: {
3107
- enabled: true
3269
+ enabled: true,
3270
+ ...void 0 !== initArgs.scrcpyVideoBitRate ? {
3271
+ videoBitRate: initArgs.scrcpyVideoBitRate
3272
+ } : {}
3108
3273
  }
3109
3274
  } : {},
3110
3275
  ...reportOptions ?? {}
@@ -3162,6 +3327,7 @@ class AndroidMidsceneTools extends BaseMidsceneTools {
3162
3327
  shape: {
3163
3328
  deviceId: z.string().optional().describe('Android device ID (from adb devices)'),
3164
3329
  useScrcpy: z.boolean().optional().describe('Enable scrcpy accelerated screenshots'),
3330
+ scrcpyVideoBitRate: z.number().int().positive().optional().describe('scrcpy H.264 video bitrate in bits per second; providing this option also enables scrcpy. Default with --use-scrcpy: 100000000 (100 Mbps). For constrained remote links, start with 4000000 (4 Mbps).'),
3165
3331
  ...agentBehaviorInitArgShape
3166
3332
  },
3167
3333
  cli: {
@@ -3174,7 +3340,7 @@ class AndroidMidsceneTools extends BaseMidsceneTools {
3174
3340
  const tools = new AndroidMidsceneTools();
3175
3341
  runToolsCLI(tools, 'midscene-android', {
3176
3342
  stripPrefix: 'android_',
3177
- version: "1.12.0",
3343
+ version: "1.12.1-beta-20260824081858.0",
3178
3344
  extraCommands: createReportCliCommands()
3179
3345
  }).catch((e)=>{
3180
3346
  process.exit(reportCLIError(e));