android-midscene-automation 0.1.38 → 0.1.40

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.
Files changed (28) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +11 -0
  3. package/package.json +12 -7
  4. package/server/appium-recorder/appium-runner.ts +61 -16
  5. package/server/appium-recorder/image-check.ts +10 -0
  6. package/server/appium-recorder/replay-mp4.ts +72 -0
  7. package/server/appium-recorder/replay-video-segments.ts +77 -0
  8. package/server/appium-recorder/replay-video.ts +107 -0
  9. package/server/appium-recorder/report.ts +98 -6
  10. package/server/appium-recorder/routes.ts +16 -17
  11. package/server/appium-recorder/run-history.ts +9 -1
  12. package/server/appium-recorder/scrcpy-recording-source.ts +80 -0
  13. package/server/appium-recorder/video-response.ts +36 -0
  14. package/src/appium-recorder/AppiumPage.vue +24 -41
  15. package/src/appium-recorder/RunHistoryDialog.vue +13 -3
  16. package/src/appium-recorder/action-descriptions.ts +1 -1
  17. package/src/appium-recorder/api.ts +3 -10
  18. package/src/appium-recorder/components/FlowNodeCard.vue +2 -3
  19. package/src/appium-recorder/components/FlowStepEditor.vue +0 -6
  20. package/src/appium-recorder/components/ImageCheckDialog.vue +28 -5
  21. package/src/appium-recorder/components/NestedConditionBranches.vue +0 -1
  22. package/src/appium-recorder/components/RecordedSteps.vue +0 -1
  23. package/src/appium-recorder/flow-graph.ts +2 -2
  24. package/src/appium-recorder/flow-labels.ts +2 -2
  25. package/src/appium-recorder/image-check.ts +3 -1
  26. package/src/appium-recorder/run-history.ts +3 -0
  27. package/src/components/config/AppiumConfigPanel.vue +0 -35
  28. package/src/appium-recorder/components/AiRecognitionTestDialog.vue +0 -71
package/CHANGELOG.md CHANGED
@@ -1,4 +1,15 @@
1
1
  # 更新记录
2
+ ## v0.1.40
3
+ - 回放录屏支持按连接脚本分段保存 MP4,返回父脚本继续执行时另存片段;历史结果可选择片段播放,HTML 报告按节点时间自动切换视频,删除历史结果时一并清理全部片段。
4
+
5
+ ## v0.1.39
6
+ - Midscene 相关模块统一升级至 1.12.7,
7
+ - scrcpy 视频帧完整性修复:可靠传输视频包、修正关键帧标记,并在缓冲溢出后等待完整关键帧恢复,降低设备预览花屏风险。
8
+ - 回放按钮右侧下拉设置新增默认关闭的“录制回放视频”,远程设备暂不支持。
9
+ - 图像模板判断改为“预期匹配结果”,匹配严格度以百分比配置,100% 时提示细微像素差异风险;节点显示预期结果,日志和 HTML 报告分别展示实际匹配、预期匹配、得分与条件结论,保留原检测算法及已有阈值。
10
+ - 移除 AI 识别操作、测试入口及 Appium 参数中的 AI 模型配置,保留流程背景色设置;旧脚本中的 AI 节点提示替换,回放前阻止执行,不自动删除或改变分支。
11
+
12
+
2
13
  ## v0.1.38
3
14
  - 移除节点类型切换;新节点及未配置的判断等待超时统一为 3000ms,已有自定义时间和延时、长按等操作时长保持不变。
4
15
  - 取消 Activity 不匹配时的脚本编辑锁定,不在脚本绑定页面也可编辑节点;页面差异仅作提示,保留录制操作及回放期间的编辑保护。
package/README.md CHANGED
@@ -1229,6 +1229,17 @@ Linux: ~/.local/share/android-midscene-automation
1229
1229
 
1230
1230
  # 项目更新记录
1231
1231
 
1232
+ ## v0.1.40
1233
+ - 回放录屏支持按连接脚本分段保存 MP4,返回父脚本继续执行时另存片段;历史结果可选择片段播放,HTML 报告按节点时间自动切换视频,删除历史结果时一并清理全部片段。
1234
+
1235
+ ## v0.1.39
1236
+ - Midscene 相关模块统一升级至 1.12.7,
1237
+ - scrcpy 视频帧完整性修复:可靠传输视频包、修正关键帧标记,并在缓冲溢出后等待完整关键帧恢复,降低设备预览花屏风险。
1238
+ - 回放按钮右侧下拉设置新增默认关闭的“录制回放视频”,远程设备暂不支持。
1239
+ - 图像模板判断改为“预期匹配结果”,匹配严格度以百分比配置,100% 时提示细微像素差异风险;节点显示预期结果,日志和 HTML 报告分别展示实际匹配、预期匹配、得分与条件结论,保留原检测算法及已有阈值。
1240
+ - 移除 AI 识别操作、测试入口及 Appium 参数中的 AI 模型配置,保留流程背景色设置;旧脚本中的 AI 节点提示替换,回放前阻止执行,不自动删除或改变分支。
1241
+
1242
+
1232
1243
  ## v0.1.38
1233
1244
  - 移除节点类型切换;新节点及未配置的判断等待超时统一为 3000ms,已有自定义时间和延时、长按等操作时长保持不变。
1234
1245
  - 取消 Activity 不匹配时的脚本编辑锁定,不在脚本绑定页面也可编辑节点;页面差异仅作提示,保留录制操作及回放期间的编辑保护。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "android-midscene-automation",
3
- "version": "0.1.38",
3
+ "version": "0.1.40",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/oooooooko/android-midscene-automation.git"
@@ -36,14 +36,19 @@
36
36
  "test:e2e": "playwright test"
37
37
  },
38
38
  "dependencies": {
39
+ "@yume-chan/adb": "2.6.3",
40
+ "@yume-chan/adb-scrcpy": "2.3.2",
41
+ "@yume-chan/adb-server-node-tcp": "2.5.2",
42
+ "@yume-chan/scrcpy": "2.3.0",
43
+ "mediabunny": "1.58.0",
39
44
  "@dagrejs/dagre": "1.1.5",
40
45
  "@element-plus/icons-vue": "2.3.2",
41
- "@midscene/android": "https://registry.npmjs.org/@midscene/android/-/android-1.12.6.tgz",
42
- "@midscene/android-playground": "https://registry.npmjs.org/@midscene/android-playground/-/android-playground-1.12.6.tgz",
43
- "@midscene/core": "https://registry.npmjs.org/@midscene/core/-/core-1.12.6.tgz",
44
- "@midscene/playground": "https://registry.npmjs.org/@midscene/playground/-/playground-1.12.6.tgz",
45
- "@midscene/shared": "https://registry.npmjs.org/@midscene/shared/-/shared-1.12.6.tgz",
46
- "@midscene/web": "https://registry.npmjs.org/@midscene/web/-/web-1.12.6.tgz",
46
+ "@midscene/android": "https://registry.npmjs.org/@midscene/android/-/android-1.12.7.tgz",
47
+ "@midscene/android-playground": "https://registry.npmjs.org/@midscene/android-playground/-/android-playground-1.12.7.tgz",
48
+ "@midscene/core": "https://registry.npmjs.org/@midscene/core/-/core-1.12.7.tgz",
49
+ "@midscene/playground": "https://registry.npmjs.org/@midscene/playground/-/playground-1.12.7.tgz",
50
+ "@midscene/shared": "https://registry.npmjs.org/@midscene/shared/-/shared-1.12.7.tgz",
51
+ "@midscene/web": "https://registry.npmjs.org/@midscene/web/-/web-1.12.7.tgz",
47
52
  "@techstark/opencv-js": "5.0.0-release.1",
48
53
  "@vitejs/plugin-vue": "6.0.7",
49
54
  "@vue-flow/core": "1.48.2",
@@ -6,13 +6,13 @@ import { captureHistoryFrames, readAppVersion } from './history-capture';
6
6
  import { BoundedLoopTraversal } from './bounded-loop';
7
7
  import { validateLoopSteps } from '../../src/appium-recorder/bounded-loop';
8
8
  import { adbScreenshotBase64 } from './screenshot';
9
- import { recognizeDeviceScreen } from './ai-recognition';
10
9
  import { checkImage } from './image-check';
11
10
  import { IMAGE_CHECK_MODES, type ImageCheckResult } from '../../src/appium-recorder/image-check';
12
11
  import { formatStageLog } from '../../src/appium-recorder/stage-log';
13
12
  import { openGalleryOnDevice } from './open-gallery';
14
13
  import { stopAppOnDevice } from './stop-app';
15
14
  import { DEFAULT_NODE_TIMEOUT_MS } from '../../src/appium-recorder/node-timeout';
15
+ import { startReplayVideo, type ReplayVideo } from './replay-video';
16
16
  import { ConditionTimeoutError, AppiumServiceError } from './condition-timeout';
17
17
  import { AppiumRequestTimeoutError, timedAppiumFetch, withElementDeadline } from './request-timeout';
18
18
  import { textClickSelector } from '../../src/appium-recorder/text-click';
@@ -77,6 +77,7 @@ const replayContext = new AsyncLocalStorage<{
77
77
  visualStartOffsets: Map<string, number>;
78
78
  softFailureCount: number;
79
79
  flowEnded?: boolean;
80
+ selectVideoScript?: (id: string, name: string, force?: boolean) => Promise<void>;
80
81
  }>();
81
82
 
82
83
  const appiumServerUrl = () => replayContext.getStore()?.serverUrl || configuredAppiumServerUrl();
@@ -353,10 +354,11 @@ async function captureReplayFrame(
353
354
  ) {
354
355
  const context = replayContext.getStore();
355
356
  if (!context || !context.deviceId) return;
357
+ const capturedAt = new Date().toISOString();
356
358
  // 执行记录独立于截图,设备截图失败不能让失败节点从统计中消失。
357
359
  context.historyEvents.push({ sequence: context.historyEvents.length + 1, scriptName, nodeId: step.id,
358
360
  nodeNumber, nodeLabel: step.label, nodeType: step.type, note: step.note || '',
359
- selector: replayFrameSelector(step), phase, status, capturedAt: new Date().toISOString(), imageBase64: '' });
361
+ selector: replayFrameSelector(step), phase, status, capturedAt, imageBase64: '' });
360
362
  const appendFrame = (imageBase64: string) => {
361
363
  context.frames.push({
362
364
  sequence: context.frames.length + 1,
@@ -370,7 +372,7 @@ async function captureReplayFrame(
370
372
  selector: replayFrameSelector(step),
371
373
  phase,
372
374
  status,
373
- capturedAt: new Date().toISOString(),
375
+ capturedAt,
374
376
  imageBase64,
375
377
  });
376
378
  };
@@ -386,6 +388,8 @@ async function captureReplayFrame(
386
388
  appendFrame(imageBase64 || await adbScreenshotBase64(context.deviceId));
387
389
  } catch (error) {
388
390
  context.appiumLog(`[节点 ${nodeNumber}] 截图失败:${errorDetail(error)}`);
391
+ // 截图失败仍保留时间锚点,报告可定位到该节点的录像。
392
+ appendFrame('');
389
393
  }
390
394
  }
391
395
 
@@ -1137,6 +1141,8 @@ async function replayLinkedScript(
1137
1141
  }
1138
1142
  }
1139
1143
 
1144
+ // 每次调用独立分段,即使连续两次连接的是同一个脚本。
1145
+ await replayContext.getStore()?.selectVideoScript?.(linkedScript.id, linkedScript.name, true);
1140
1146
  lines.push(`连接脚本开始:${linkedScript.name}`);
1141
1147
  const nextStack = [...stack, linkedScript.id];
1142
1148
  const parent = variableContext.getStore()!;
@@ -1177,6 +1183,7 @@ async function replayLinearSteps(
1177
1183
  lines.push(`[节点 ${index + 1}] 跳过:${step.label}(${linkedScriptInitSkipReason(step)})`);
1178
1184
  continue;
1179
1185
  }
1186
+ await replayContext.getStore()?.selectVideoScript?.(stack.at(-1) || '', options.scriptName || '');
1180
1187
  lines.push(`[节点 ${index + 1}] 开始:${step.label}`);
1181
1188
  await captureReplayFrame(sessionId, step, index + 1, options.scriptName || '', 'before', '执行前');
1182
1189
  try {
@@ -1234,6 +1241,7 @@ async function replayFlowSteps(
1234
1241
  index = nextIndexAfterStep(index, step);
1235
1242
  continue;
1236
1243
  }
1244
+ await replayContext.getStore()?.selectVideoScript?.(stack.at(-1) || '', options.scriptName || '');
1237
1245
  visitedPath.push(step.label);
1238
1246
  const nodeKind = defaultFlowKind(step);
1239
1247
  lines.push(`[节点 ${index + 1}] 开始:${frameStep.label}`);
@@ -1258,13 +1266,7 @@ async function replayFlowSteps(
1258
1266
  }
1259
1267
  continue;
1260
1268
  }
1261
- // AI 节点使用本次执行前截图,并将同一张图片和识别依据写入结果帧。
1262
- const beforeFrame = replayContext.getStore()?.frames.at(-1);
1263
- const recognition = step.type === 'aiRecognition' ? await recognizeDeviceScreen({
1264
- deviceId, prompt: step.value, timeoutMs: step.timeoutMs,
1265
- signal: replayContext.getStore()?.signal,
1266
- imageBase64: beforeFrame?.nodeId === step.id && beforeFrame.phase === 'before' ? beforeFrame.imageBase64 : undefined,
1267
- }) : undefined;
1269
+ if (step.type === 'aiRecognition') throw new Error('AI 识别操作已移除,请替换此节点后回放');
1268
1270
  let imageResult: ImageCheckResult | undefined;
1269
1271
  if (step.type === 'imageCheck') {
1270
1272
  const resolved = resolveVariableStep(step);
@@ -1273,14 +1275,22 @@ async function replayFlowSteps(
1273
1275
  resolveRegion: () => imageCheckRegion(sessionId, resolved), wait,
1274
1276
  signal: replayContext.getStore()?.signal, saveImages: !variableContext.getStore()?.privacy.enabled });
1275
1277
  const config = resolved.imageCheck;
1276
- const settings = config ? `${IMAGE_CHECK_MODES[config.mode]};条件 ${config.expectation};模板阈值 ${config.threshold};最小得分差 ${config.minScoreGap};RGB/亮度容差 ${config.tolerance};目标色 ${config.color};像素占比 ${config.ratio}%;观察 ${config.durationMs}ms;间隔 ${config.intervalMs}ms;连续 ${config.consecutive} 帧` : '缺少配置';
1278
+ const settings = config ? [
1279
+ IMAGE_CHECK_MODES[config.mode],
1280
+ config.mode === 'template' ? `预期匹配结果:${config.expectation === 'present' ? '匹配到模板' : '未匹配到模板'}` : '',
1281
+ ['template', 'state'].includes(config.mode) ? `匹配严格度 ${config.threshold * 100}%` : '',
1282
+ config.mode === 'state' ? `最小得分差 ${config.minScoreGap}` : '',
1283
+ ['black', 'color', 'change'].includes(config.mode) ? `RGB/亮度容差 ${config.tolerance};像素占比 ${config.ratio}%` : '',
1284
+ config.mode === 'color' ? `目标色 ${config.color}` : '',
1285
+ config.mode === 'change' ? `预期:${config.expectation === 'present' ? '画面有变化' : '持续无明显变化'}` : '',
1286
+ config.mode !== 'state' ? `观察 ${config.durationMs}ms;间隔 ${config.intervalMs}ms;连续 ${config.consecutive} 帧` : '',
1287
+ ].filter(Boolean).join(';') : '缺少配置';
1277
1288
  replayContext.getStore()?.imageChecks.push({ ...imageResult, nodeId: step.id, nodeNumber: index + 1, nodeLabel: frameStep.label, scriptName: options.scriptName || '', settings });
1278
1289
  lines.push(`[节点 ${index + 1}] 图像判断:${imageResult.result === null ? '无法判定' : imageResult.result},${imageResult.message};${settings};采样 ${imageResult.sampleCount} 帧,耗时 ${imageResult.durationMs}ms;指标 ${JSON.stringify(imageResult.metrics)}`);
1279
1290
  if (imageResult.result === null) throw new Error(imageResult.message);
1280
1291
  if (imageResult.timedOut) throw new ConditionTimeoutError(imageResult.message);
1281
1292
  }
1282
- const matched = imageResult ? imageResult.result! : recognition ? recognition.result : await evaluateCondition(sessionId, deviceId, step);
1283
- if (recognition) lines.push(`[节点 ${index + 1}] AI 识别:${recognition.result},耗时 ${recognition.durationMs}ms${recognition.reason ? `,依据:${recognition.reason}` : ''}`);
1293
+ const matched = imageResult ? imageResult.result! : await evaluateCondition(sessionId, deviceId, step);
1284
1294
  const branchTargetId = matched ? step.flow?.yesTargetId : step.flow?.noTargetId;
1285
1295
  const next = traversal.branch(step, matched);
1286
1296
  const targetId = branchTargetId || (next === undefined ? '' : steps[next]?.id) || '';
@@ -1292,8 +1302,7 @@ async function replayFlowSteps(
1292
1302
  index + 1,
1293
1303
  options.scriptName || '',
1294
1304
  'after',
1295
- `判断:${branchLabel}${recognition?.reason ? `;${recognition.reason}` : ''}`,
1296
- recognition?.imageBase64,
1305
+ `判断:${branchLabel}`,
1297
1306
  );
1298
1307
  index = next;
1299
1308
  } catch (error) {
@@ -1432,7 +1441,7 @@ export async function replayAppiumScript(
1432
1441
  deviceId: string,
1433
1442
  onOutput?: (line: string) => void,
1434
1443
  signal?: AbortSignal,
1435
- runOptions: { parameters?: TestVariable[]; globalVariables?: TestVariable[]; linkedScripts?: AppiumRecordedScriptRecord[] } = {},
1444
+ runOptions: { recordVideo?: boolean; parameters?: TestVariable[]; globalVariables?: TestVariable[]; linkedScripts?: AppiumRecordedScriptRecord[] } = {},
1436
1445
  ) {
1437
1446
  const targetDeviceId = deviceId || script.deviceId;
1438
1447
  if (!targetDeviceId) throw new Error('未检测到可用设备');
@@ -1440,6 +1449,12 @@ export async function replayAppiumScript(
1440
1449
 
1441
1450
  const scope = new VariableScope(runOptions.globalVariables ?? getPresetVariables(), script.variables || []);
1442
1451
  const scripts = new Map((runOptions.linkedScripts ?? linkedScriptSnapshot(script)).map(item => [item.id, item]));
1452
+ // 旧节点保留图结构供用户替换,但必须在任何设备操作之前阻止回放。
1453
+ for (const candidate of [script, ...scripts.values()]) {
1454
+ if (candidate.steps.some(step => step.type === 'aiRecognition')) {
1455
+ throw new Error(`脚本“${candidate.name}”包含已移除的 AI 识别操作,请改用图像判断或原生组件判断后回放`);
1456
+ }
1457
+ }
1443
1458
  for (const item of validateVariables(runOptions.parameters)) scope.set(item);
1444
1459
  // 提前登记子脚本敏感预设,避免进入子脚本前的原始日志或截图泄露。
1445
1460
  const visited = new Set<string>();
@@ -1492,6 +1507,8 @@ export async function replayAppiumScript(
1492
1507
  let sessionId = '';
1493
1508
  let success = false;
1494
1509
  let stopped = false;
1510
+ let recording: Awaited<ReturnType<typeof startReplayVideo>> | undefined;
1511
+ let video: ReplayVideo | undefined;
1495
1512
  let managedAppium: Awaited<ReturnType<typeof startManagedAppiumServer>> | null = null;
1496
1513
  try {
1497
1514
  validateLoopSteps(script.steps);
@@ -1516,6 +1533,14 @@ export async function replayAppiumScript(
1516
1533
  sessionId = await createSession(targetDeviceId);
1517
1534
  }
1518
1535
  lines.push(`Appium session 已创建:${sessionId}`);
1536
+ if (runOptions.recordVideo) {
1537
+ if (scope.privacy.enabled) throw new Error('本次运行包含敏感变量,为避免泄露已禁止录屏,请关闭录制回放视频');
1538
+ lines.push('正在启动后台录屏...');
1539
+ recording = await startReplayVideo(targetDeviceId, signal);
1540
+ replayContext.getStore()!.selectVideoScript = recording.selectScript;
1541
+ await recording.selectScript(script.id, script.name);
1542
+ lines.push('后台录屏已启动(MP4 / H.264 / 15fps / 2Mbps,无音频)');
1543
+ }
1519
1544
  if (hasFlowSteps(script.steps)) lines.push('按流程图路径回放...');
1520
1545
  await replayScriptSteps(sessionId, targetDeviceId, script.steps, lines, [script.id], {
1521
1546
  scriptName: script.name,
@@ -1543,6 +1568,23 @@ export async function replayAppiumScript(
1543
1568
  lines.push(`回放终止:${detail}`);
1544
1569
  }
1545
1570
  } finally {
1571
+ if (recording) {
1572
+ try {
1573
+ video = await recording.stop();
1574
+ if (scope.privacy.enabled) {
1575
+ for (const segment of video.segments || [video]) {
1576
+ await import('node:fs/promises').then(fs => fs.rm(segment.filePath, { force: true }));
1577
+ }
1578
+ video = undefined;
1579
+ lines.push('敏感变量保护:已删除回放视频');
1580
+ } else {
1581
+ for (const [index, segment] of (video.segments || [video]).entries()) {
1582
+ lines.push(`回放视频 ${index + 1}(${segment.scriptName || script.name})已保存:${segment.filePath}`);
1583
+ }
1584
+ if (video.warning) lines.push(`录屏警告:${video.warning}`);
1585
+ }
1586
+ } catch (error) { lines.push(`回放视频保存失败:${errorDetail(error)}`); }
1587
+ }
1546
1588
  let managedAppiumStopped = false;
1547
1589
  if (signal?.aborted && managedAppium) {
1548
1590
  await managedAppium.stop();
@@ -1581,6 +1623,7 @@ export async function replayAppiumScript(
1581
1623
  success,
1582
1624
  stopped,
1583
1625
  output: scope.redact(lines.join('\n')),
1626
+ video,
1584
1627
  startedAt,
1585
1628
  completedAt,
1586
1629
  frames: safeFrames,
@@ -1605,9 +1648,11 @@ export async function replayAppiumScript(
1605
1648
  reportId,
1606
1649
  logPath,
1607
1650
  htmlReportPath,
1651
+ videoPath: video?.filePath,
1608
1652
  softFailureCount: context.softFailureCount,
1609
1653
  history: {
1610
1654
  scriptId: script.id, scriptName: script.name, appPackage: script.appPackage,
1655
+ video,
1611
1656
  appVersion, deviceId: targetDeviceId, startedAt: startedAt.toISOString(),
1612
1657
  durationMs: completedAt.getTime() - startedAt.getTime(),
1613
1658
  status: stopped ? 'stopped' as const : success ? 'passed' as const : 'failed' as const,
@@ -128,6 +128,10 @@ export async function checkImage(input: {
128
128
  if (!baseline) { baseline = current; observedAt = now(); keep('首帧', current); }
129
129
  const measurement = await measure(current, baseline, templates, config);
130
130
  result.metrics = { ...measurement.metrics };
131
+ if (config.mode === 'template') {
132
+ const score = result.metrics['模板得分']!;
133
+ result.templateMatch = { matched: score >= config.threshold, expected: config.expectation, score, threshold: config.threshold };
134
+ }
131
135
  input.signal?.throwIfAborted();
132
136
  if (config.mode === 'state') {
133
137
  if (measurement.matched === null) throw new Error('双模板均未达到阈值或得分过于接近');
@@ -153,6 +157,12 @@ export async function checkImage(input: {
153
157
  }
154
158
  if (last) keep('判定帧', last);
155
159
  result.message = result.timedOut ? '观察超时,未在规定时间内满足条件' : result.result ? '条件成立' : '条件不成立';
160
+ if (result.templateMatch) {
161
+ const match = result.templateMatch;
162
+ const actual = match.matched ? '匹配到模板' : '未匹配到模板';
163
+ const expected = match.expected === 'present' ? '匹配到模板' : '未匹配到模板';
164
+ result.message = `实际${actual}:得分 ${(match.score * 100).toFixed(4)}%,${match.matched ? '达到' : '低于'}匹配严格度 ${(match.threshold * 100).toFixed(4)}%;预期${expected};${result.message}${result.timedOut ? '' : ` → ${result.result} 分支`}`;
165
+ }
156
166
  } catch (error) {
157
167
  if (input.signal?.aborted) throw error;
158
168
  if (error instanceof AppiumRequestTimeoutError || error instanceof AppiumServiceError) throw error;
@@ -0,0 +1,72 @@
1
+ import { EncodedPacket, EncodedVideoPacketSource, FilePathTarget, Mp4OutputFormat, Output } from 'mediabunny';
2
+ import { h264ParseConfiguration, type ScrcpyMediaStreamPacket } from '@yume-chan/scrcpy';
3
+
4
+ // 仅封装 H.264,不解码或重新编码;最多保留最后一帧以计算静止画面的持续时间。
5
+ export class ReplayMp4Writer {
6
+ private source = new EncodedVideoPacketSource('avc');
7
+ private output: Output;
8
+ private config?: ReturnType<typeof h264ParseConfiguration>;
9
+ private configBytes?: Uint8Array;
10
+ private origin?: bigint;
11
+ private pending?: { data: Uint8Array; key: boolean; timestamp: number };
12
+ private first = true;
13
+ startedAt = 0;
14
+ private startedMonotonic = 0;
15
+ private endedMonotonic?: number;
16
+
17
+ constructor(path: string, private anchor?: number) {
18
+ this.output = new Output({ target: new FilePathTarget(path, { chunkSize: 1024 * 1024 }), format: new Mp4OutputFormat({ fastStart: 'fragmented' }) });
19
+ this.output.addVideoTrack(this.source);
20
+ }
21
+
22
+ async start() { await this.output.start(); }
23
+
24
+ async add(packet: ScrcpyMediaStreamPacket) {
25
+ if (packet.type === 'configuration') {
26
+ const config = h264ParseConfiguration(packet.data);
27
+ if (this.configBytes && !Buffer.from(this.configBytes).equals(Buffer.from(packet.data))) {
28
+ throw new Error('录屏编码配置发生变化,已保留变化前的视频');
29
+ }
30
+ this.config = config;
31
+ this.configBytes = packet.data.slice();
32
+ return;
33
+ }
34
+ if (!this.config || !this.configBytes) throw new Error('录屏缺少 H.264 配置信息');
35
+ if (packet.pts === undefined) throw new Error('录屏视频包缺少时间戳');
36
+ if (typeof packet.keyframe !== 'boolean') throw new Error('录屏视频包缺少关键帧标记');
37
+ if (this.origin === undefined) {
38
+ if (!packet.keyframe) return;
39
+ this.origin = packet.pts;
40
+ this.startedAt = this.anchor ?? Date.now();
41
+ this.startedMonotonic = performance.now() - (Date.now() - this.startedAt);
42
+ }
43
+ const timestamp = Number(packet.pts - this.origin) / 1000000;
44
+ if (!Number.isFinite(timestamp) || timestamp < 0 || (this.pending && timestamp < this.pending.timestamp)) throw new Error('录屏时间戳异常');
45
+ if (this.pending) await this.flush(Math.max(0.000001, timestamp - this.pending.timestamp));
46
+ const data = this.first ? Buffer.concat([this.configBytes, packet.data]) : packet.data.slice();
47
+ this.pending = { data, key: packet.keyframe, timestamp };
48
+ }
49
+
50
+ private async flush(duration: number) {
51
+ if (!this.pending || !this.config) return;
52
+ const { data, key, timestamp } = this.pending;
53
+ const c = this.config;
54
+ const codec = `avc1.${[c.profileIndex, c.constraintSet, c.levelIndex].map(v => v.toString(16).padStart(2, '0')).join('')}`;
55
+ await this.source.add(new EncodedPacket(data, key ? 'key' : 'delta', timestamp, duration), this.first ? {
56
+ decoderConfig: { codec, codedWidth: c.croppedWidth, codedHeight: c.croppedHeight },
57
+ } : undefined);
58
+ this.first = false;
59
+ this.pending = undefined;
60
+ }
61
+
62
+ end() { this.endedMonotonic ??= performance.now(); }
63
+
64
+ async finish() {
65
+ if (!this.startedAt) throw new Error('未收到可录制的关键帧');
66
+ await this.flush(Math.max(1 / 15, ((this.endedMonotonic ?? performance.now()) - this.startedMonotonic) / 1000 - (this.pending?.timestamp || 0)));
67
+ this.source.close();
68
+ await this.output.finalize();
69
+ }
70
+
71
+ async cancel() { await this.output.cancel(); }
72
+ }
@@ -0,0 +1,77 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { dirname, join } from 'node:path';
3
+ import { rm } from 'node:fs/promises';
4
+ import type { ScrcpyMediaStreamPacket } from '@yume-chan/scrcpy';
5
+ import { ReplayMp4Writer } from './replay-mp4';
6
+ import type { ReplayVideo } from './replay-video';
7
+
8
+ // 缓存最近一个 GOP,使切换脚本时无需重启编码器,也不会丢失首个操作。
9
+ export class ReplayVideoSegments {
10
+ private writer: ReplayMp4Writer;
11
+ private current: ReplayVideo;
12
+ readonly segments: ReplayVideo[] = [];
13
+ private config?: ScrcpyMediaStreamPacket;
14
+ private gop: ScrcpyMediaStreamPacket[] = [];
15
+ private bytes = 0;
16
+ private origin?: bigint;
17
+ private epoch = 0;
18
+ private keyTime = 0;
19
+ private queue: Promise<unknown> = Promise.resolve();
20
+ constructor(filePath: string, fileName: string) {
21
+ this.current = { filePath, fileName, startedAt: '' };
22
+ this.writer = new ReplayMp4Writer(filePath);
23
+ }
24
+ get startedAt() { return this.writer.startedAt; }
25
+ private serialize<T>(task: () => Promise<T>) {
26
+ const result = this.queue.then(task);
27
+ this.queue = result.catch(() => undefined);
28
+ return result;
29
+ }
30
+ start() { return this.writer.start(); }
31
+ add(packet: ScrcpyMediaStreamPacket) {
32
+ return this.serialize(async () => {
33
+ await this.writer.add(packet);
34
+ if (packet.type === 'configuration') { this.config = packet; return; }
35
+ if (packet.keyframe && packet.pts !== undefined) {
36
+ if (this.origin === undefined) { this.origin = packet.pts; this.epoch = this.writer.startedAt; }
37
+ this.keyTime = this.epoch + Number(packet.pts - this.origin) / 1000;
38
+ this.gop = []; this.bytes = 0;
39
+ }
40
+ if (this.keyTime) {
41
+ this.bytes += packet.data.byteLength;
42
+ // 编码器长期不发关键帧时停止缓存,避免无限占用内存。
43
+ if (this.bytes > 32 * 1024 * 1024) { this.gop = []; this.keyTime = 0; }
44
+ else this.gop.push(packet);
45
+ }
46
+ });
47
+ }
48
+ selectScript(scriptId: string, scriptName: string, force = false) {
49
+ return this.serialize(async () => {
50
+ if (!force && this.current.scriptId === scriptId) return;
51
+ const boundary = new Date().toISOString();
52
+ if (this.current.scriptId !== undefined) {
53
+ if (!this.config || !this.gop.length) throw new Error('录屏分段缺少完整关键帧,请重新运行');
54
+ await this.finishCurrent();
55
+ const fileName = `replay-${Date.now()}-${randomUUID()}.mp4`;
56
+ this.current = { filePath: join(dirname(this.current.filePath), fileName), fileName, startedAt: '' };
57
+ this.writer = new ReplayMp4Writer(this.current.filePath, this.keyTime);
58
+ await this.writer.start();
59
+ await this.writer.add(this.config);
60
+ for (const packet of this.gop) await this.writer.add(packet);
61
+ }
62
+ Object.assign(this.current, { scriptId, scriptName, boundaryAt: boundary });
63
+ });
64
+ }
65
+ end() { this.writer.end(); }
66
+ private async finishCurrent() {
67
+ this.writer.end();
68
+ await this.writer.finish();
69
+ this.current.startedAt = new Date(this.writer.startedAt).toISOString();
70
+ this.segments.push(this.current);
71
+ }
72
+ finish() { return this.serialize(() => this.finishCurrent()); }
73
+ async cancel() {
74
+ await this.writer.cancel().catch(() => undefined);
75
+ await rm(this.current.filePath, { force: true });
76
+ }
77
+ }
@@ -0,0 +1,107 @@
1
+ import { mkdir, open, rm } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { loadConfig } from '../config';
5
+ import { appDataPath } from '../paths';
6
+ import { openRecordingSource, waitForVideoTask } from './scrcpy-recording-source';
7
+ import { ReplayVideoSegments } from './replay-video-segments';
8
+
9
+ export type ReplayVideo = { filePath: string; fileName: string; startedAt: string; warning?: string; scriptId?: string; scriptName?: string; boundaryAt?: string; segments?: ReplayVideo[] };
10
+
11
+ // 检查 MP4 的索引和媒体块,不将大视频整体读入内存。
12
+ export async function isFinalizedMp4(path: string) {
13
+ const file = await open(path, 'r');
14
+ try {
15
+ const { size } = await file.stat();
16
+ let offset = 0, media = false, index = false;
17
+ const header = Buffer.alloc(16);
18
+ while (offset + 8 <= size) {
19
+ const { bytesRead } = await file.read(header, 0, 16, offset);
20
+ const type = header.toString('ascii', 4, 8);
21
+ let length = header.readUInt32BE(0);
22
+ if (length === 1) {
23
+ if (bytesRead < 16) return false;
24
+ length = Number(header.readBigUInt64BE(8));
25
+ }
26
+ if (!length) length = size - offset;
27
+ if (length < 8 || !Number.isSafeInteger(length) || offset + length > size) return false;
28
+ if (type === 'mdat' && length > 8) media = true;
29
+ if (type === 'moov' && length > 8) index = true;
30
+ offset += length;
31
+ }
32
+ return media && index && offset === size;
33
+ } finally { await file.close(); }
34
+ }
35
+
36
+ export async function startReplayVideo(deviceId: string, signal?: AbortSignal) {
37
+ signal?.throwIfAborted();
38
+ const dir = appDataPath(loadConfig().runtime.reportOutputPath.trim() || 'output');
39
+ await mkdir(dir, { recursive: true });
40
+ const fileName = `replay-${Date.now()}-${randomUUID()}.mp4`;
41
+ const filePath = join(dir, fileName);
42
+ const startupSignal = AbortSignal.any([...(signal ? [signal] : []), AbortSignal.timeout(30000)]);
43
+ const source = await openRecordingSource(deviceId, startupSignal);
44
+ return recordVideoStream(source, filePath, fileName, startupSignal);
45
+ }
46
+
47
+ // 独立于连接层,便于用真实 H.264 数据验证封装、断流和终止收尾。
48
+ export async function recordVideoStream(source: Awaited<ReturnType<typeof openRecordingSource>>, filePath: string, fileName: string, startupSignal: AbortSignal) {
49
+ const writer = new ReplayVideoSegments(filePath, fileName);
50
+ const reader = source.stream.getReader();
51
+ let stopping = false;
52
+ let warning: string | undefined;
53
+ let readyResolve!: () => void;
54
+ let readyReject!: (error: unknown) => void;
55
+ const ready = new Promise<void>((resolve, reject) => { readyResolve = resolve; readyReject = reject; });
56
+ void ready.catch(() => undefined);
57
+ let pump: Promise<void> = Promise.resolve();
58
+ let stopTask: Promise<ReplayVideo> | undefined;
59
+ const stop = () => stopTask ??= (async () => {
60
+ stopping = true;
61
+ await Promise.allSettled([reader.cancel(), source.close()]);
62
+ await pump;
63
+ try {
64
+ await writer.finish();
65
+ for (const segment of writer.segments) {
66
+ if (!await isFinalizedMp4(segment.filePath)) throw new Error('录屏未生成完整 MP4');
67
+ }
68
+ return { ...writer.segments[0]!, segments: writer.segments, warning };
69
+ } catch (error) {
70
+ await writer.cancel().catch(() => undefined);
71
+ if (writer.segments.length) return { ...writer.segments[0]!, segments: writer.segments, warning: `部分录像保存失败:${String(error)}` };
72
+ throw error;
73
+ }
74
+ })();
75
+ try {
76
+ await writer.start();
77
+ pump = (async () => {
78
+ try {
79
+ while (!stopping) {
80
+ const { done, value } = await reader.read();
81
+ if (done) {
82
+ if (!stopping) throw new Error(`录屏视频流提前结束 ${source.diagnostics()}`);
83
+ break;
84
+ }
85
+ await writer.add(value);
86
+ if (writer.startedAt) readyResolve();
87
+ }
88
+ } catch (error) {
89
+ warning = `录像可能不完整:${error instanceof Error ? error.message : String(error)}`;
90
+ readyReject(error);
91
+ void source.close();
92
+ } finally { writer.end(); reader.releaseLock(); }
93
+ })();
94
+ await waitForVideoTask(ready, startupSignal);
95
+ return {
96
+ startedAt: new Date(writer.startedAt).toISOString(), stop,
97
+ selectScript: (id: string, name: string, force = false) => {
98
+ if (stopping || warning) return Promise.reject(new Error(warning || '录屏已停止'));
99
+ return writer.selectScript(id, name, force);
100
+ },
101
+ };
102
+ } catch (error) {
103
+ await stop().catch(() => undefined);
104
+ await rm(filePath, { force: true });
105
+ throw error;
106
+ }
107
+ }