android-midscene-automation 0.1.26 → 0.1.27

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.
@@ -19,6 +19,32 @@ export type AppiumReplayFrame = {
19
19
  imageBase64: string;
20
20
  };
21
21
 
22
+ export type AppiumReplayVisualCheck = {
23
+ nodeId: string;
24
+ nodeNumber: number;
25
+ nodeLabel: string;
26
+ startNodeId?: string;
27
+ startNodeLabel?: string;
28
+ endNodeId?: string;
29
+ endNodeLabel?: string;
30
+ status: 'passed' | 'failed';
31
+ message: string;
32
+ region: {
33
+ x: number;
34
+ y: number;
35
+ width: number;
36
+ height: number;
37
+ };
38
+ durationMs: number;
39
+ intervalMs: number;
40
+ sampleCount: number;
41
+ changeRatioThreshold: number;
42
+ maxChangeRatio: number;
43
+ baselineBase64: string;
44
+ comparisonBase64: string;
45
+ diffBase64: string;
46
+ };
47
+
22
48
  function pad(value: number, length = 2) {
23
49
  return String(value).padStart(length, '0');
24
50
  }
@@ -80,7 +106,42 @@ function executionStatus(lines: string[]) {
80
106
  return '中断';
81
107
  }
82
108
 
83
- function stepSection(step: AppiumRecordedStepRecord, index: number, outputLines: string[]) {
109
+ function visualCheckStatusLabel(status: AppiumReplayVisualCheck['status']) {
110
+ return status === 'passed' ? '有变化' : '无明显变化';
111
+ }
112
+
113
+ function visualCheckSection(check?: AppiumReplayVisualCheck) {
114
+ if (!check) return '';
115
+ const statusLabel = visualCheckStatusLabel(check.status);
116
+ const region = `${check.region.x}, ${check.region.y}, ${check.region.width} x ${check.region.height}`;
117
+ return [
118
+ '#### 画面变化检测',
119
+ '',
120
+ '| 项目 | 内容 |',
121
+ '| --- | --- |',
122
+ `| 检测结果 | **${statusLabel}** |`,
123
+ `| 开始节点 | ${markdownValue(check.startNodeLabel || check.startNodeId)} |`,
124
+ `| 结束节点 | ${markdownValue(check.endNodeLabel || check.endNodeId)} |`,
125
+ `| 检测区域 | ${markdownValue(region)} |`,
126
+ `| 对比方式 | ${check.durationMs ? markdownValue(`${check.durationMs}ms / 每 ${check.intervalMs}ms`) : '起止节点截图对比'} |`,
127
+ `| 采样次数 | ${check.sampleCount} |`,
128
+ `| 变化阈值 | ${check.changeRatioThreshold}% |`,
129
+ `| 最大变化比例 | ${check.maxChangeRatio.toFixed(2)}% |`,
130
+ `| 说明 | ${markdownValue(check.message)} |`,
131
+ '',
132
+ '| 基准帧 | 对比帧 | 差异图 |',
133
+ '| --- | --- | --- |',
134
+ `| ![基准帧](data:image/png;base64,${check.baselineBase64}) | ![对比帧](data:image/png;base64,${check.comparisonBase64}) | ![差异图](data:image/png;base64,${check.diffBase64}) |`,
135
+ '',
136
+ ].join('\n');
137
+ }
138
+
139
+ function stepSection(
140
+ step: AppiumRecordedStepRecord,
141
+ index: number,
142
+ outputLines: string[],
143
+ visualCheck?: AppiumReplayVisualCheck,
144
+ ) {
84
145
  const executionLines = stepExecutionLines(outputLines, index);
85
146
  const config = JSON.stringify(step, null, 2);
86
147
  return [
@@ -108,6 +169,7 @@ function stepSection(step: AppiumRecordedStepRecord, index: number, outputLines:
108
169
  executionLines.length ? executionLines.join('\n') : '该节点未进入执行路径,或在前序失败后未执行。',
109
170
  executionLines.length ? '```' : '',
110
171
  '',
172
+ visualCheckSection(visualCheck),
111
173
  '<details>',
112
174
  '<summary>完整节点配置</summary>',
113
175
  '',
@@ -127,6 +189,7 @@ function createReplayHtml(input: {
127
189
  startedAt: Date;
128
190
  completedAt: Date;
129
191
  frames: AppiumReplayFrame[];
192
+ visualChecks: AppiumReplayVisualCheck[];
130
193
  output: string;
131
194
  }) {
132
195
  const payload = jsonForHtml({
@@ -137,6 +200,15 @@ function createReplayHtml(input: {
137
200
  imageUrl: `data:image/png;base64,${frame.imageBase64}`,
138
201
  imageBase64: undefined,
139
202
  })),
203
+ visualChecks: input.visualChecks.map((check) => ({
204
+ ...check,
205
+ baselineImageUrl: `data:image/png;base64,${check.baselineBase64}`,
206
+ comparisonImageUrl: `data:image/png;base64,${check.comparisonBase64}`,
207
+ diffImageUrl: `data:image/png;base64,${check.diffBase64}`,
208
+ baselineBase64: undefined,
209
+ comparisonBase64: undefined,
210
+ diffBase64: undefined,
211
+ })),
140
212
  output: input.output,
141
213
  });
142
214
  const resultClass = input.resultText === '成功' ? 'success' : input.resultText === '已终止' ? 'stopped' : 'failed';
@@ -199,6 +271,25 @@ function createReplayHtml(input: {
199
271
  details { margin: 4px 10px 16px; border: 1px solid #dfe3e8; border-radius: 6px; background: #fff; }
200
272
  summary { padding: 12px 14px; cursor: pointer; font-weight: 600; }
201
273
  pre { max-height: 360px; margin: 0; overflow: auto; padding: 14px; border-top: 1px solid #e5e8ec; white-space: pre; font: 11px/1.6 ui-monospace, SFMono-Regular, Consolas, monospace; }
274
+ .visual-check-list { display: grid; gap: 12px; padding: 0 12px 12px; border-top: 1px solid #e5e8ec; }
275
+ .visual-check { padding: 12px 0 0; border-top: 1px solid #eef1f5; }
276
+ .visual-check:first-child { border-top: 0; }
277
+ .visual-check h3 { margin: 0 0 8px; font-size: 13px; overflow-wrap: anywhere; }
278
+ .visual-check dl { display: grid; grid-template-columns: 72px minmax(0, 1fr); gap: 5px 8px; margin: 0 0 10px; font-size: 12px; }
279
+ .visual-check dt { color: #8a9099; }
280
+ .visual-check dd { margin: 0; overflow-wrap: anywhere; }
281
+ .visual-check .passed { color: #237804; font-weight: 700; }
282
+ .visual-check .failed { color: #cf1322; font-weight: 700; }
283
+ .visual-images { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; }
284
+ .visual-image span { display: block; margin-bottom: 4px; color: #646a73; font-size: 11px; text-align: center; }
285
+ .visual-image img { display: block; width: 100%; max-height: 120px; object-fit: contain; border: 1px solid #dfe3e8; border-radius: 3px; background: #101827; cursor: zoom-in; }
286
+ .visual-image img:hover { border-color: #1677ff; }
287
+ .image-preview { position: fixed; inset: 0; z-index: 20; display: grid; grid-template-rows: auto minmax(0, 1fr); gap: 12px; padding: 20px; background: rgba(16, 24, 39, .88); }
288
+ .image-preview[hidden] { display: none; }
289
+ .image-preview__bar { display: flex; align-items: center; justify-content: space-between; gap: 16px; color: #fff; }
290
+ .image-preview__title { min-width: 0; overflow: hidden; font-size: 14px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
291
+ .image-preview__close { flex: 0 0 auto; border-color: rgba(255, 255, 255, .28); color: #fff; background: rgba(255, 255, 255, .1); }
292
+ .image-preview__image { place-self: center; max-width: 100%; max-height: calc(100vh - 88px); object-fit: contain; border-radius: 4px; background: #101827; box-shadow: 0 20px 60px rgba(0, 0, 0, .35); }
202
293
  @media (max-width: 860px) {
203
294
  body { overflow: auto; }
204
295
  main { grid-template-columns: 1fr; height: auto; }
@@ -237,6 +328,7 @@ function createReplayHtml(input: {
237
328
  </dl>
238
329
  </section>
239
330
  <nav id="step-list" class="step-list" aria-label="执行步骤"></nav>
331
+ <details><summary>视觉变化检测</summary><div id="visual-checks" class="visual-check-list"></div></details>
240
332
  <details><summary>完整回放日志</summary><pre id="log"></pre></details>
241
333
  </aside>
242
334
  <section class="workspace">
@@ -259,16 +351,37 @@ function createReplayHtml(input: {
259
351
  </section>
260
352
  </section>
261
353
  </main>
354
+ <div id="image-preview" class="image-preview" hidden>
355
+ <div class="image-preview__bar">
356
+ <span id="image-preview-title" class="image-preview__title"></span>
357
+ <button id="image-preview-close" class="image-preview__close" type="button">关闭</button>
358
+ </div>
359
+ <img id="image-preview-image" class="image-preview__image" alt="" />
360
+ </div>
262
361
  <script id="replay-data" type="application/json">${payload}</script>
263
362
  <script>
264
363
  const data = JSON.parse(document.querySelector('#replay-data').textContent || '{}');
364
+ const visualChecks = Array.isArray(data.visualChecks) ? data.visualChecks : [];
265
365
  const reportStartedAt = Date.parse(data.startedAt) || 0;
266
366
  const frames = (Array.isArray(data.frames) ? data.frames : []).map((frame) => ({
267
367
  ...frame,
268
368
  offsetMs: Math.max(0, (Date.parse(frame.capturedAt) || reportStartedAt) - reportStartedAt),
269
369
  }));
270
370
  const durationMs = Math.max(1000, Number(data.durationMs) || 0, frames.at(-1)?.offsetMs || 0);
271
- const elements = Object.fromEntries(['screen','empty','previous','play','next','seek','clock','caption','title','script','node','phase','frame-status','note','selector','time','step-list','timeline-track','ruler','playhead','log'].map((id) => [id, document.querySelector('#' + id)]));
371
+ const elements = Object.fromEntries(['screen','empty','previous','play','next','seek','clock','caption','title','script','node','phase','frame-status','note','selector','time','step-list','timeline-track','ruler','playhead','visual-checks','log'].map((id) => [id, document.querySelector('#' + id)]));
372
+ const imagePreview = document.querySelector('#image-preview');
373
+ const imagePreviewImage = document.querySelector('#image-preview-image');
374
+ const imagePreviewTitle = document.querySelector('#image-preview-title');
375
+ const closeImagePreview = () => {
376
+ imagePreview.hidden = true;
377
+ imagePreviewImage.removeAttribute('src');
378
+ };
379
+ const openImagePreview = (url, title) => {
380
+ imagePreviewImage.src = url;
381
+ imagePreviewImage.alt = title;
382
+ imagePreviewTitle.textContent = title;
383
+ imagePreview.hidden = false;
384
+ };
272
385
  let currentFrame = frames.length ? 0 : -1;
273
386
  let currentMs = 0;
274
387
  let animationFrame = 0;
@@ -376,6 +489,62 @@ function createReplayHtml(input: {
376
489
  thumbnail.addEventListener('click', (event) => { event.stopPropagation(); pause(); seekTo(frame.offsetMs); });
377
490
  elements['timeline-track'].append(thumbnail);
378
491
  });
492
+ if (!visualChecks.length) {
493
+ elements['visual-checks'].textContent = '本次没有视觉变化检测节点';
494
+ }
495
+ visualChecks.forEach((check) => {
496
+ const article = document.createElement('article');
497
+ article.className = 'visual-check';
498
+ const title = document.createElement('h3');
499
+ title.textContent = check.nodeNumber + '. ' + check.nodeLabel;
500
+ const detail = document.createElement('dl');
501
+ [
502
+ ['结果', visualCheckStatusLabel(check.status), check.status],
503
+ ['开始节点', check.startNodeLabel || check.startNodeId || '-'],
504
+ ['结束节点', check.endNodeLabel || check.endNodeId || '-'],
505
+ ['区域', check.region.x + ', ' + check.region.y + ', ' + check.region.width + ' x ' + check.region.height],
506
+ ['对比', check.durationMs ? check.sampleCount + ' 次 / 每 ' + check.intervalMs + 'ms' : '起止节点截图对比'],
507
+ ['阈值', check.changeRatioThreshold + '%'],
508
+ ['最大变化', Number(check.maxChangeRatio || 0).toFixed(2) + '%'],
509
+ ['说明', check.message || '-'],
510
+ ].forEach(([label, value, className]) => {
511
+ const dt = document.createElement('dt');
512
+ const dd = document.createElement('dd');
513
+ dt.textContent = label;
514
+ dd.textContent = value;
515
+ if (className) dd.className = className;
516
+ detail.append(dt, dd);
517
+ });
518
+ const images = document.createElement('div');
519
+ images.className = 'visual-images';
520
+ [
521
+ ['基准帧', check.baselineImageUrl],
522
+ ['对比帧', check.comparisonImageUrl],
523
+ ['差异图', check.diffImageUrl],
524
+ ].forEach(([label, url]) => {
525
+ const figure = document.createElement('div');
526
+ figure.className = 'visual-image';
527
+ const caption = document.createElement('span');
528
+ caption.textContent = label;
529
+ const img = document.createElement('img');
530
+ img.src = url;
531
+ img.alt = label;
532
+ img.title = '点击放大 ' + label;
533
+ img.tabIndex = 0;
534
+ img.addEventListener('click', () => openImagePreview(url, check.nodeNumber + '. ' + check.nodeLabel + ' · ' + label));
535
+ img.addEventListener('keydown', (event) => {
536
+ if (event.key === 'Enter' || event.key === ' ') {
537
+ event.preventDefault();
538
+ event.stopPropagation();
539
+ openImagePreview(url, check.nodeNumber + '. ' + check.nodeLabel + ' · ' + label);
540
+ }
541
+ });
542
+ figure.append(caption, img);
543
+ images.append(figure);
544
+ });
545
+ article.append(title, detail, images);
546
+ elements['visual-checks'].append(article);
547
+ });
379
548
  elements.previous.addEventListener('click', () => { pause(); seekTo(frames[Math.max(0, currentFrame - 1)]?.offsetMs || 0); });
380
549
  elements.next.addEventListener('click', () => { pause(); seekTo(frames[Math.min(frames.length - 1, currentFrame + 1)]?.offsetMs || durationMs); });
381
550
  elements.play.addEventListener('click', () => {
@@ -393,10 +562,18 @@ function createReplayHtml(input: {
393
562
  seekTo((event.clientX - bounds.left) / bounds.width * durationMs);
394
563
  });
395
564
  document.addEventListener('keydown', (event) => {
565
+ if (!imagePreview.hidden) {
566
+ if (event.key === 'Escape') closeImagePreview();
567
+ return;
568
+ }
396
569
  if (event.key === 'ArrowLeft') elements.previous.click();
397
570
  if (event.key === 'ArrowRight') elements.next.click();
398
571
  if (event.key === ' ') { event.preventDefault(); elements.play.click(); }
399
572
  });
573
+ imagePreview.addEventListener('click', (event) => {
574
+ if (event.target === imagePreview) closeImagePreview();
575
+ });
576
+ document.querySelector('#image-preview-close').addEventListener('click', closeImagePreview);
400
577
  elements.log.textContent = data.output || '';
401
578
  seekTo(frames[0]?.offsetMs || 0, false);
402
579
  </script>
@@ -413,6 +590,7 @@ export async function createAppiumReplayReport(input: {
413
590
  startedAt: Date;
414
591
  completedAt: Date;
415
592
  frames?: AppiumReplayFrame[];
593
+ visualChecks?: AppiumReplayVisualCheck[];
416
594
  }) {
417
595
  const configuredOutputPath = loadConfig().runtime.reportOutputPath.trim();
418
596
  const outputDir = appDataPath(configuredOutputPath || 'output');
@@ -433,6 +611,9 @@ export async function createAppiumReplayReport(input: {
433
611
  const outputLines = persistedOutput.split(/\r?\n/);
434
612
  const durationMs = input.completedAt.getTime() - input.startedAt.getTime();
435
613
  const resultText = input.stopped ? '已终止' : input.success ? '成功' : '失败';
614
+ const visualChecks = input.visualChecks || [];
615
+ const visualCheckByStepId = new Map(visualChecks.map((check) => [check.nodeId, check]));
616
+ const visualFailureCount = visualChecks.filter((check) => check.status === 'failed').length;
436
617
  const html = createReplayHtml({
437
618
  script: input.script,
438
619
  deviceId: input.deviceId,
@@ -441,6 +622,7 @@ export async function createAppiumReplayReport(input: {
441
622
  startedAt: input.startedAt,
442
623
  completedAt: input.completedAt,
443
624
  frames: input.frames || [],
625
+ visualChecks,
444
626
  output: persistedOutput,
445
627
  });
446
628
  const markdown = [
@@ -459,11 +641,12 @@ export async function createAppiumReplayReport(input: {
459
641
  `| 执行结果 | **${resultText}** |`,
460
642
  `| 节点数量 | ${input.script.steps.length} |`,
461
643
  `| 截图帧数 | ${(input.frames || []).length} |`,
644
+ `| 视觉检测未达预期 | ${visualFailureCount} |`,
462
645
  `| 截图回放 | ${markdownValue(htmlReportPath)} |`,
463
646
  '',
464
647
  '## 节点明细',
465
648
  '',
466
- ...input.script.steps.map((step, index) => stepSection(step, index, outputLines)),
649
+ ...input.script.steps.map((step, index) => stepSection(step, index, outputLines, visualCheckByStepId.get(step.id))),
467
650
  '## 完整回放日志',
468
651
  '',
469
652
  '```text',
@@ -6,6 +6,7 @@ import {
6
6
  sqlNullableString,
7
7
  sqlString,
8
8
  } from '../storage/sqlite';
9
+ import { normalizeLegacyNestedConditionBranches } from '../../src/appium-recorder/flow-normalize';
9
10
 
10
11
  export type AppiumRecordedStepRecord = {
11
12
  id: string;
@@ -31,7 +32,9 @@ export type AppiumRecordedStepRecord = {
31
32
  | 'assertText'
32
33
  | 'longPress'
33
34
  | 'pinch'
34
- | 'runScript';
35
+ | 'runScript'
36
+ | 'noop'
37
+ | 'visualChange';
35
38
  label: string;
36
39
  note?: string;
37
40
  selector?: {
@@ -110,6 +113,24 @@ export type AppiumRecordedStepRecord = {
110
113
  centerY: number;
111
114
  percent: number;
112
115
  };
116
+ visualChange?: {
117
+ mode: 'selectedElement' | 'region';
118
+ region: {
119
+ x: number;
120
+ y: number;
121
+ width: number;
122
+ height: number;
123
+ };
124
+ role?: 'start' | 'end';
125
+ pairId?: string;
126
+ pairLabel?: string;
127
+ startStepId?: string;
128
+ endStepId?: string;
129
+ durationMs: number;
130
+ intervalMs: number;
131
+ changeRatioThreshold: number;
132
+ pixelmatchThreshold: number;
133
+ };
113
134
  snapshot?: {
114
135
  text: string;
115
136
  resourceId: string;
@@ -245,7 +266,7 @@ function normalizeScriptActivity(
245
266
  }
246
267
 
247
268
  function rowToRecord(row: AppiumRecordedScriptRow): AppiumRecordedScriptRecord {
248
- const steps = parseStepList(row.flow_json);
269
+ const steps = normalizeLegacyNestedConditionBranches(parseStepList(row.flow_json));
249
270
  return {
250
271
  id: row.id,
251
272
  name: row.name,
@@ -307,7 +328,7 @@ export function saveAppiumRecordedScript(input: {
307
328
  const id = input.id || createId('appium_script');
308
329
  const name = input.name.trim();
309
330
  const appPackage = input.appPackage.trim();
310
- const steps = input.steps || [];
331
+ const steps = normalizeLegacyNestedConditionBranches(input.steps || []);
311
332
  const appActivity = normalizeScriptActivity(appPackage, input.appActivity, steps);
312
333
 
313
334
  if (!name) throw new Error('脚本名称不能为空');
@@ -0,0 +1,184 @@
1
+ import { PNG } from 'pngjs';
2
+ import pixelmatch from 'pixelmatch';
3
+
4
+ export type VisualChangeRegion = {
5
+ x: number;
6
+ y: number;
7
+ width: number;
8
+ height: number;
9
+ };
10
+
11
+ export type VisualChangeCheckInput = {
12
+ captureScreenshot: () => Promise<Buffer>;
13
+ wait: (ms: number) => Promise<void>;
14
+ region: VisualChangeRegion;
15
+ durationMs: number;
16
+ intervalMs: number;
17
+ changeRatioThreshold: number;
18
+ pixelmatchThreshold: number;
19
+ };
20
+
21
+ export type VisualChangeFrameCompareInput = {
22
+ baselineScreenshot: Buffer;
23
+ comparisonScreenshot: Buffer;
24
+ region: VisualChangeRegion;
25
+ changeRatioThreshold: number;
26
+ pixelmatchThreshold: number;
27
+ };
28
+
29
+ export type VisualChangeCheckResult = {
30
+ passed: boolean;
31
+ region: VisualChangeRegion;
32
+ durationMs: number;
33
+ intervalMs: number;
34
+ sampleCount: number;
35
+ changeRatioThreshold: number;
36
+ maxChangeRatio: number;
37
+ baselineBase64: string;
38
+ comparisonBase64: string;
39
+ diffBase64: string;
40
+ message: string;
41
+ };
42
+
43
+ function clampInteger(value: number, min: number, max: number) {
44
+ return Math.max(min, Math.min(max, Math.round(value)));
45
+ }
46
+
47
+ function normalizeRegion(region: VisualChangeRegion, imageWidth: number, imageHeight: number) {
48
+ const x = clampInteger(region.x, 0, Math.max(0, imageWidth - 1));
49
+ const y = clampInteger(region.y, 0, Math.max(0, imageHeight - 1));
50
+ const width = clampInteger(region.width, 1, imageWidth - x);
51
+ const height = clampInteger(region.height, 1, imageHeight - y);
52
+ return { x, y, width, height };
53
+ }
54
+
55
+ function readPng(buffer: Buffer) {
56
+ try {
57
+ return PNG.sync.read(buffer);
58
+ } catch (error) {
59
+ throw new Error('截图不是有效的 PNG 数据', { cause: error });
60
+ }
61
+ }
62
+
63
+ function cropPng(source: PNG, region: VisualChangeRegion) {
64
+ const crop = new PNG({ width: region.width, height: region.height });
65
+ for (let row = 0; row < region.height; row += 1) {
66
+ const sourceStart = ((region.y + row) * source.width + region.x) * 4;
67
+ const sourceEnd = sourceStart + region.width * 4;
68
+ const targetStart = row * region.width * 4;
69
+ source.data.copy(crop.data, targetStart, sourceStart, sourceEnd);
70
+ }
71
+ return crop;
72
+ }
73
+
74
+ function pngToBase64(png: PNG) {
75
+ return PNG.sync.write(png).toString('base64');
76
+ }
77
+
78
+ function compareCroppedImages(
79
+ baseline: PNG,
80
+ current: PNG,
81
+ pixelmatchThreshold: number,
82
+ ) {
83
+ const diff = new PNG({ width: baseline.width, height: baseline.height });
84
+ const differentPixels = pixelmatch(
85
+ baseline.data,
86
+ current.data,
87
+ diff.data,
88
+ baseline.width,
89
+ baseline.height,
90
+ {
91
+ threshold: pixelmatchThreshold,
92
+ includeAA: true,
93
+ },
94
+ );
95
+ const totalPixels = baseline.width * baseline.height;
96
+ return {
97
+ diff,
98
+ changedRatio: totalPixels ? (differentPixels / totalPixels) * 100 : 0,
99
+ };
100
+ }
101
+
102
+ // Screenshots can contain tiny encoding or antialiasing differences, so the
103
+ // caller provides a ratio threshold instead of expecting exact image equality.
104
+ export function compareVisualChangeFrames(input: VisualChangeFrameCompareInput): VisualChangeCheckResult {
105
+ const baselineScreenshot = readPng(input.baselineScreenshot);
106
+ const comparisonScreenshot = readPng(input.comparisonScreenshot);
107
+ const region = normalizeRegion(
108
+ input.region,
109
+ Math.min(baselineScreenshot.width, comparisonScreenshot.width),
110
+ Math.min(baselineScreenshot.height, comparisonScreenshot.height),
111
+ );
112
+ const baseline = cropPng(baselineScreenshot, region);
113
+ const comparison = cropPng(comparisonScreenshot, region);
114
+ const diff = compareCroppedImages(baseline, comparison, input.pixelmatchThreshold);
115
+ const passed = diff.changedRatio >= input.changeRatioThreshold;
116
+ return {
117
+ passed,
118
+ region,
119
+ durationMs: 0,
120
+ intervalMs: 0,
121
+ sampleCount: 2,
122
+ changeRatioThreshold: input.changeRatioThreshold,
123
+ maxChangeRatio: diff.changedRatio,
124
+ baselineBase64: pngToBase64(baseline),
125
+ comparisonBase64: pngToBase64(comparison),
126
+ diffBase64: pngToBase64(diff.diff),
127
+ message: passed
128
+ ? `起止节点截图发生变化,变化比例 ${diff.changedRatio.toFixed(2)}%,达到阈值 ${input.changeRatioThreshold}%`
129
+ : `起止节点截图未发生明显变化,变化比例 ${diff.changedRatio.toFixed(2)}%,低于阈值 ${input.changeRatioThreshold}%`,
130
+ };
131
+ }
132
+
133
+ export async function detectVisualChange(input: VisualChangeCheckInput): Promise<VisualChangeCheckResult> {
134
+ const firstScreenshot = readPng(await input.captureScreenshot());
135
+ const region = normalizeRegion(input.region, firstScreenshot.width, firstScreenshot.height);
136
+ const baseline = cropPng(firstScreenshot, region);
137
+ let sampleCount = 0;
138
+ let maxChangeRatio = 0;
139
+ let comparison = baseline;
140
+ let diffImage = new PNG({ width: baseline.width, height: baseline.height });
141
+ const startedAt = Date.now();
142
+ const deadline = startedAt + Math.max(1000, input.durationMs);
143
+ const intervalMs = Math.max(200, input.intervalMs);
144
+
145
+ while (Date.now() < deadline) {
146
+ await input.wait(Math.min(intervalMs, Math.max(0, deadline - Date.now())));
147
+ const currentScreenshot = readPng(await input.captureScreenshot());
148
+ const current = cropPng(currentScreenshot, region);
149
+ const diff = compareCroppedImages(baseline, current, input.pixelmatchThreshold);
150
+ sampleCount += 1;
151
+ comparison = current;
152
+ diffImage = diff.diff;
153
+ maxChangeRatio = Math.max(maxChangeRatio, diff.changedRatio);
154
+ if (diff.changedRatio >= input.changeRatioThreshold) {
155
+ return {
156
+ passed: true,
157
+ region,
158
+ durationMs: input.durationMs,
159
+ intervalMs,
160
+ sampleCount,
161
+ changeRatioThreshold: input.changeRatioThreshold,
162
+ maxChangeRatio,
163
+ baselineBase64: pngToBase64(baseline),
164
+ comparisonBase64: pngToBase64(comparison),
165
+ diffBase64: pngToBase64(diffImage),
166
+ message: `检测到画面变化,最大变化比例 ${maxChangeRatio.toFixed(2)}%,达到阈值 ${input.changeRatioThreshold}%`,
167
+ };
168
+ }
169
+ }
170
+
171
+ return {
172
+ passed: false,
173
+ region,
174
+ durationMs: input.durationMs,
175
+ intervalMs,
176
+ sampleCount,
177
+ changeRatioThreshold: input.changeRatioThreshold,
178
+ maxChangeRatio,
179
+ baselineBase64: pngToBase64(baseline),
180
+ comparisonBase64: pngToBase64(comparison),
181
+ diffBase64: pngToBase64(diffImage),
182
+ message: `${input.durationMs}ms 内目标区域未发生明显变化,最大变化比例 ${maxChangeRatio.toFixed(2)}%,低于阈值 ${input.changeRatioThreshold}%`,
183
+ };
184
+ }
@@ -106,10 +106,9 @@ const START_ANDROID_PLAYGROUND_WITHOUT_BROWSER = [
106
106
  "const { androidPlaygroundPlatform, ScrcpyServer } = require('@midscene/android-playground');",
107
107
  "const { launchPreparedPlaygroundPlatform } = require('@midscene/playground');",
108
108
  'const STABLE_PREVIEW_OPTIONS = {',
109
- ' maxFps: 15,',
110
- ' maxSize: 1024,',
111
- ' videoBitRate: 3000000,',
112
- " videoCodecOptions: 'i-frame-interval=0',",
109
+ ' maxFps: 10,',
110
+ ' maxSize: 800,',
111
+ ' videoBitRate: 1000000,',
113
112
  '};',
114
113
  'async function main() {',
115
114
  ' const scrcpyServer = new ScrcpyServer();',