android-midscene-automation 0.1.39 → 0.1.41

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/CHANGELOG.md CHANGED
@@ -1,8 +1,16 @@
1
1
  # 更新记录
2
+ ## v0.1.41
3
+ - 修复部分华为设备录屏启动时报编码器错误的问题:使用设备默认编码参数,视频流启动失败时降低分辨率和码率重试,并输出各次尝试的诊断日志。
4
+
5
+ ## v0.1.40
6
+ - 开启录屏时,在 Appium 服务端日志结束后汇总所有录像片段的脚本名称和保存位置;未生成视频时明确提示。
7
+ - 录屏启动失败、录像不完整或保存失败时,日志明确提示对流程执行的影响并提供排查建议,保留原始错误信息。
8
+ - 回放录屏支持按连接脚本分段保存 MP4,返回父脚本继续执行时另存片段;历史结果可选择片段播放,HTML 报告按节点时间自动切换视频,删除历史结果时一并清理全部片段。
9
+
2
10
  ## v0.1.39
3
11
  - Midscene 相关模块统一升级至 1.12.7,
4
12
  - scrcpy 视频帧完整性修复:可靠传输视频包、修正关键帧标记,并在缓冲溢出后等待完整关键帧恢复,降低设备预览花屏风险。
5
- - 回放按钮右侧下拉设置新增默认关闭的“录制回放视频”,使用内置服务端后台接收视频流并封装 MP4,无需安装 scrcpy 或 FFmpeg;远程设备暂不支持。
13
+ - 回放按钮右侧下拉设置新增默认关闭的“录制回放视频”,远程设备暂不支持。
6
14
  - 图像模板判断改为“预期匹配结果”,匹配严格度以百分比配置,100% 时提示细微像素差异风险;节点显示预期结果,日志和 HTML 报告分别展示实际匹配、预期匹配、得分与条件结论,保留原检测算法及已有阈值。
7
15
  - 移除 AI 识别操作、测试入口及 Appium 参数中的 AI 模型配置,保留流程背景色设置;旧脚本中的 AI 节点提示替换,回放前阻止执行,不自动删除或改变分支。
8
16
 
package/README.md CHANGED
@@ -1229,10 +1229,18 @@ Linux: ~/.local/share/android-midscene-automation
1229
1229
 
1230
1230
  # 项目更新记录
1231
1231
 
1232
+ ## v0.1.41
1233
+ - 修复部分华为设备录屏启动时报编码器错误的问题:使用设备默认编码参数,视频流启动失败时降低分辨率和码率重试,并输出各次尝试的诊断日志。
1234
+
1235
+ ## v0.1.40
1236
+ - 开启录屏时,在 Appium 服务端日志结束后汇总所有录像片段的脚本名称和保存位置;未生成视频时明确提示。
1237
+ - 录屏启动失败、录像不完整或保存失败时,日志明确提示对流程执行的影响并提供排查建议,保留原始错误信息。
1238
+ - 回放录屏支持按连接脚本分段保存 MP4,返回父脚本继续执行时另存片段;历史结果可选择片段播放,HTML 报告按节点时间自动切换视频,删除历史结果时一并清理全部片段。
1239
+
1232
1240
  ## v0.1.39
1233
1241
  - Midscene 相关模块统一升级至 1.12.7,
1234
1242
  - scrcpy 视频帧完整性修复:可靠传输视频包、修正关键帧标记,并在缓冲溢出后等待完整关键帧恢复,降低设备预览花屏风险。
1235
- - 回放按钮右侧下拉设置新增默认关闭的“录制回放视频”,使用内置服务端后台接收视频流并封装 MP4,无需安装 scrcpy 或 FFmpeg;远程设备暂不支持。
1243
+ - 回放按钮右侧下拉设置新增默认关闭的“录制回放视频”,远程设备暂不支持。
1236
1244
  - 图像模板判断改为“预期匹配结果”,匹配严格度以百分比配置,100% 时提示细微像素差异风险;节点显示预期结果,日志和 HTML 报告分别展示实际匹配、预期匹配、得分与条件结论,保留原检测算法及已有阈值。
1237
1245
  - 移除 AI 识别操作、测试入口及 Appium 参数中的 AI 模型配置,保留流程背景色设置;旧脚本中的 AI 节点提示替换,回放前阻止执行,不自动删除或改变分支。
1238
1246
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "android-midscene-automation",
3
- "version": "0.1.39",
3
+ "version": "0.1.41",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/oooooooko/android-midscene-automation.git"
@@ -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();
@@ -1140,6 +1141,8 @@ async function replayLinkedScript(
1140
1141
  }
1141
1142
  }
1142
1143
 
1144
+ // 每次调用独立分段,即使连续两次连接的是同一个脚本。
1145
+ await replayContext.getStore()?.selectVideoScript?.(linkedScript.id, linkedScript.name, true);
1143
1146
  lines.push(`连接脚本开始:${linkedScript.name}`);
1144
1147
  const nextStack = [...stack, linkedScript.id];
1145
1148
  const parent = variableContext.getStore()!;
@@ -1180,6 +1183,7 @@ async function replayLinearSteps(
1180
1183
  lines.push(`[节点 ${index + 1}] 跳过:${step.label}(${linkedScriptInitSkipReason(step)})`);
1181
1184
  continue;
1182
1185
  }
1186
+ await replayContext.getStore()?.selectVideoScript?.(stack.at(-1) || '', options.scriptName || '');
1183
1187
  lines.push(`[节点 ${index + 1}] 开始:${step.label}`);
1184
1188
  await captureReplayFrame(sessionId, step, index + 1, options.scriptName || '', 'before', '执行前');
1185
1189
  try {
@@ -1237,6 +1241,7 @@ async function replayFlowSteps(
1237
1241
  index = nextIndexAfterStep(index, step);
1238
1242
  continue;
1239
1243
  }
1244
+ await replayContext.getStore()?.selectVideoScript?.(stack.at(-1) || '', options.scriptName || '');
1240
1245
  visitedPath.push(step.label);
1241
1246
  const nodeKind = defaultFlowKind(step);
1242
1247
  lines.push(`[节点 ${index + 1}] 开始:${frameStep.label}`);
@@ -1531,8 +1536,17 @@ export async function replayAppiumScript(
1531
1536
  if (runOptions.recordVideo) {
1532
1537
  if (scope.privacy.enabled) throw new Error('本次运行包含敏感变量,为避免泄露已禁止录屏,请关闭录制回放视频');
1533
1538
  lines.push('正在启动后台录屏...');
1534
- recording = await startReplayVideo(targetDeviceId, signal);
1535
- lines.push('后台录屏已启动(MP4 / H.264 / 15fps / 2Mbps,无音频)');
1539
+ try {
1540
+ recording = await startReplayVideo(targetDeviceId, signal, message => lines.push(message));
1541
+ replayContext.getStore()!.selectVideoScript = recording.selectScript;
1542
+ await recording.selectScript(script.id, script.name);
1543
+ } catch (error) {
1544
+ if (!isReplayStopped(error)) {
1545
+ lines.push('[录屏提示] 录屏启动失败,尚未执行任何流程节点。请检查设备连接、其他录屏或投屏占用,以及报告目录的写入权限和磁盘空间;也可关闭“录制回放视频”后重新回放。详细原因见下方错误日志。');
1546
+ }
1547
+ throw error;
1548
+ }
1549
+ lines.push('后台录屏已启动(MP4 / H.264 / 最高15fps,无音频)');
1536
1550
  }
1537
1551
  if (hasFlowSteps(script.steps)) lines.push('按流程图路径回放...');
1538
1552
  await replayScriptSteps(sessionId, targetDeviceId, script.steps, lines, [script.id], {
@@ -1565,14 +1579,24 @@ export async function replayAppiumScript(
1565
1579
  try {
1566
1580
  video = await recording.stop();
1567
1581
  if (scope.privacy.enabled) {
1568
- await import('node:fs/promises').then(fs => fs.rm(video!.filePath, { force: true }));
1582
+ for (const segment of video.segments || [video]) {
1583
+ await import('node:fs/promises').then(fs => fs.rm(segment.filePath, { force: true }));
1584
+ }
1569
1585
  video = undefined;
1570
1586
  lines.push('敏感变量保护:已删除回放视频');
1571
1587
  } else {
1572
- lines.push(`回放视频已保存:${video.filePath}`);
1573
- if (video.warning) lines.push(`录屏警告:${video.warning}`);
1588
+ for (const [index, segment] of (video.segments || [video]).entries()) {
1589
+ lines.push(`回放视频 ${index + 1}(${segment.scriptName || script.name})已保存:${segment.filePath}`);
1590
+ }
1591
+ if (video.warning) {
1592
+ lines.push(`录屏警告:${video.warning}`);
1593
+ lines.push('[录屏提示] 本次录像可能不完整,请结合节点日志和截图确认执行结果;需要完整录像时,请检查设备连接和录屏占用后重新回放。');
1594
+ }
1574
1595
  }
1575
- } catch (error) { lines.push(`回放视频保存失败:${errorDetail(error)}`); }
1596
+ } catch (error) {
1597
+ lines.push(`回放视频保存失败:${errorDetail(error)}`);
1598
+ lines.push('[录屏提示] 本次录像未能完整保存。请检查报告目录的写入权限、磁盘空间及设备连接;录像保存失败不等同于流程节点失败,请以节点执行日志为准。');
1599
+ }
1576
1600
  }
1577
1601
  let managedAppiumStopped = false;
1578
1602
  if (signal?.aborted && managedAppium) {
@@ -1589,6 +1613,17 @@ export async function replayAppiumScript(
1589
1613
  if (!managedAppiumStopped) await managedAppium.stop();
1590
1614
  lines.push('----- Appium 服务端原始日志结束 -----');
1591
1615
  }
1616
+ if (runOptions.recordVideo) {
1617
+ if (video) {
1618
+ for (const [index, segment] of (video.segments || [video]).entries()) {
1619
+ lines.push(`录屏位置 ${index + 1}(${segment.scriptName || script.name}):${segment.filePath}`);
1620
+ }
1621
+ } else {
1622
+ lines.push(scope.privacy.enabled
1623
+ ? '录屏位置:无(敏感变量保护,本次不保留录像)'
1624
+ : '录屏位置:无(未生成可用视频,请查看上方录屏提示)');
1625
+ }
1626
+ }
1592
1627
  }
1593
1628
 
1594
1629
  if (signal?.aborted && !stopped) {
@@ -14,7 +14,7 @@ export class ReplayMp4Writer {
14
14
  private startedMonotonic = 0;
15
15
  private endedMonotonic?: number;
16
16
 
17
- constructor(path: string) {
17
+ constructor(path: string, private anchor?: number) {
18
18
  this.output = new Output({ target: new FilePathTarget(path, { chunkSize: 1024 * 1024 }), format: new Mp4OutputFormat({ fastStart: 'fragmented' }) });
19
19
  this.output.addVideoTrack(this.source);
20
20
  }
@@ -37,8 +37,8 @@ export class ReplayMp4Writer {
37
37
  if (this.origin === undefined) {
38
38
  if (!packet.keyframe) return;
39
39
  this.origin = packet.pts;
40
- this.startedAt = Date.now();
41
- this.startedMonotonic = performance.now();
40
+ this.startedAt = this.anchor ?? Date.now();
41
+ this.startedMonotonic = performance.now() - (Date.now() - this.startedAt);
42
42
  }
43
43
  const timestamp = Number(packet.pts - this.origin) / 1000000;
44
44
  if (!Number.isFinite(timestamp) || timestamp < 0 || (this.pending && timestamp < this.pending.timestamp)) throw new Error('录屏时间戳异常');
@@ -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
+ }
@@ -4,9 +4,9 @@ import { randomUUID } from 'node:crypto';
4
4
  import { loadConfig } from '../config';
5
5
  import { appDataPath } from '../paths';
6
6
  import { openRecordingSource, waitForVideoTask } from './scrcpy-recording-source';
7
- import { ReplayMp4Writer } from './replay-mp4';
7
+ import { ReplayVideoSegments } from './replay-video-segments';
8
8
 
9
- export type ReplayVideo = { filePath: string; fileName: string; startedAt: string; warning?: string };
9
+ export type ReplayVideo = { filePath: string; fileName: string; startedAt: string; warning?: string; scriptId?: string; scriptName?: string; boundaryAt?: string; segments?: ReplayVideo[] };
10
10
 
11
11
  // 检查 MP4 的索引和媒体块,不将大视频整体读入内存。
12
12
  export async function isFinalizedMp4(path: string) {
@@ -33,20 +33,38 @@ export async function isFinalizedMp4(path: string) {
33
33
  } finally { await file.close(); }
34
34
  }
35
35
 
36
- export async function startReplayVideo(deviceId: string, signal?: AbortSignal) {
36
+ export async function startReplayVideo(deviceId: string, signal?: AbortSignal, onLog?: (message: string) => void) {
37
37
  signal?.throwIfAborted();
38
38
  const dir = appDataPath(loadConfig().runtime.reportOutputPath.trim() || 'output');
39
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);
40
+ const failures: string[] = [];
41
+ for (const profile of [
42
+ { label: '标准(1280 / 2Mbps)', maxSize: 1280, videoBitRate: 2000000 },
43
+ { label: '兼容(800 / 1Mbps)', maxSize: 800, videoBitRate: 1000000 },
44
+ ]) {
45
+ signal?.throwIfAborted();
46
+ const fileName = `replay-${Date.now()}-${randomUUID()}.mp4`;
47
+ const filePath = join(dir, fileName);
48
+ const startupSignal = AbortSignal.any([...(signal ? [signal] : []), AbortSignal.timeout(15000)]);
49
+ try {
50
+ onLog?.(`录屏启动:${profile.label},使用设备默认编码参数`);
51
+ const source = await openRecordingSource(deviceId, startupSignal, profile);
52
+ return await recordVideoStream(source, filePath, fileName, startupSignal);
53
+ } catch (error) {
54
+ signal?.throwIfAborted();
55
+ const detail = error instanceof Error ? error.message : String(error);
56
+ failures.push(`${profile.label}:${detail}`);
57
+ // 仅编码/视频流启动异常才降级,ADB、磁盘、服务端版本错误直接保留原错误。
58
+ if (!/录屏视频流|关键帧|codec|encod|capture|timeout|timed out/i.test(detail)) throw error;
59
+ onLog?.(`录屏启动失败:${failures.at(-1)}`);
60
+ }
61
+ }
62
+ throw new Error(`录屏兼容重试失败,尚未执行流程节点。请检查设备编码器或其他录屏占用。\n${failures.join('\n')}`);
45
63
  }
46
64
 
47
65
  // 独立于连接层,便于用真实 H.264 数据验证封装、断流和终止收尾。
48
66
  export async function recordVideoStream(source: Awaited<ReturnType<typeof openRecordingSource>>, filePath: string, fileName: string, startupSignal: AbortSignal) {
49
- const writer = new ReplayMp4Writer(filePath);
67
+ const writer = new ReplayVideoSegments(filePath, fileName);
50
68
  const reader = source.stream.getReader();
51
69
  let stopping = false;
52
70
  let warning: string | undefined;
@@ -62,11 +80,13 @@ export async function recordVideoStream(source: Awaited<ReturnType<typeof openRe
62
80
  await pump;
63
81
  try {
64
82
  await writer.finish();
65
- if (!await isFinalizedMp4(filePath)) throw new Error('录屏未生成完整 MP4');
66
- return { filePath, fileName, startedAt: new Date(writer.startedAt).toISOString(), warning };
83
+ for (const segment of writer.segments) {
84
+ if (!await isFinalizedMp4(segment.filePath)) throw new Error('录屏未生成完整 MP4');
85
+ }
86
+ return { ...writer.segments[0]!, segments: writer.segments, warning };
67
87
  } catch (error) {
68
88
  await writer.cancel().catch(() => undefined);
69
- await rm(filePath, { force: true });
89
+ if (writer.segments.length) return { ...writer.segments[0]!, segments: writer.segments, warning: `部分录像保存失败:${String(error)}` };
70
90
  throw error;
71
91
  }
72
92
  })();
@@ -90,7 +110,13 @@ export async function recordVideoStream(source: Awaited<ReturnType<typeof openRe
90
110
  } finally { writer.end(); reader.releaseLock(); }
91
111
  })();
92
112
  await waitForVideoTask(ready, startupSignal);
93
- return { startedAt: new Date(writer.startedAt).toISOString(), stop };
113
+ return {
114
+ startedAt: new Date(writer.startedAt).toISOString(), stop,
115
+ selectScript: (id: string, name: string, force = false) => {
116
+ if (stopping || warning) return Promise.reject(new Error(warning || '录屏已停止'));
117
+ return writer.selectScript(id, name, force);
118
+ },
119
+ };
94
120
  } catch (error) {
95
121
  await stop().catch(() => undefined);
96
122
  await rm(filePath, { force: true });
@@ -241,7 +241,7 @@ function stepSection(
241
241
  }
242
242
 
243
243
  function createReplayHtml(input: {
244
- video?: { url: string; startedAt: string };
244
+ video?: { url: string; startedAt: string; segments?: Array<{ url: string; startedAt: string; boundaryAt?: string; scriptName?: string }> };
245
245
  script: AppiumRecordedScriptRecord;
246
246
  deviceId: string;
247
247
  resultText: string;
@@ -418,6 +418,7 @@ function createReplayHtml(input: {
418
418
  <div id="caption" class="caption">暂无截图</div>
419
419
  <div class="controls">
420
420
  <button id="toggle-recording" hidden>查看节点截图</button>
421
+ <select id="video-segment" aria-label="视频片段" hidden style="max-width:100%;min-width:0"></select>
421
422
  <button id="previous" type="button" title="上一帧">上一帧</button>
422
423
  <button id="play" type="button">播放</button>
423
424
  <button id="next" type="button" title="下一帧">下一帧</button>
@@ -441,7 +442,16 @@ function createReplayHtml(input: {
441
442
  const reportStartedAt = Date.parse(data.startedAt) || 0;
442
443
  const recording = document.querySelector('#recording');
443
444
  const toggleRecording = document.querySelector('#toggle-recording');
444
- const videoOffset = Math.max(0, (Date.parse(data.video?.startedAt) || reportStartedAt) - reportStartedAt);
445
+ const videos = data.video ? (data.video.segments || [data.video]) : [];
446
+ const segmentSelect = document.querySelector('#video-segment');
447
+ let activeVideo = 0;
448
+ let videoOffset = Math.max(0, (Date.parse(videos[0]?.startedAt) || reportStartedAt) - reportStartedAt);
449
+ videos.forEach((video, index) => {
450
+ const option = document.createElement('option');
451
+ option.value = String(index); option.textContent = (index + 1) + '. ' + (video.scriptName || data.scriptName || '回放视频');
452
+ segmentSelect.appendChild(option);
453
+ });
454
+ segmentSelect.hidden = videos.length < 2;
445
455
  let videoMode = Boolean(data.video);
446
456
  let pendingVideoTime = 0;
447
457
  if (data.video) { recording.src = data.video.url; toggleRecording.hidden = false; }
@@ -539,11 +549,32 @@ function createReplayHtml(input: {
539
549
  };
540
550
  const seekTo = (milliseconds, scrollStep = true) => {
541
551
  currentMs = Math.max(0, Math.min(durationMs, milliseconds));
552
+ let index = 0;
553
+ for (let i = 1; i < videos.length; i++) {
554
+ if (Date.parse(videos[i].boundaryAt || videos[i].startedAt) - reportStartedAt <= currentMs) index = i;
555
+ }
556
+ if (index !== activeVideo) {
557
+ activeVideo = index; segmentSelect.value = String(index);
558
+ videoOffset = Math.max(0, Date.parse(videos[index].startedAt) - reportStartedAt);
559
+ recording.src = videos[index].url;
560
+ }
542
561
  pendingVideoTime = Math.max(0, (currentMs - videoOffset) / 1000);
543
562
  if (data.video && recording.readyState >= 1) recording.currentTime = Math.min(pendingVideoTime, recording.duration || pendingVideoTime);
544
563
  render(scrollStep);
545
564
  };
546
565
  recording.addEventListener('loadedmetadata', () => { recording.currentTime = Math.min(pendingVideoTime, recording.duration); });
566
+ segmentSelect.addEventListener('change', () => {
567
+ pause();
568
+ const video = videos[Number(segmentSelect.value)];
569
+ seekTo(Date.parse(video.boundaryAt || video.startedAt) - reportStartedAt);
570
+ });
571
+ recording.addEventListener('ended', () => {
572
+ if (videoMode && activeVideo + 1 < videos.length) {
573
+ const next = videos[activeVideo + 1];
574
+ seekTo(Date.parse(next.boundaryAt || next.startedAt) - reportStartedAt);
575
+ recording.play().catch(() => undefined);
576
+ }
577
+ });
547
578
  recording.addEventListener('timeupdate', () => {
548
579
  if (!videoMode || recording.paused) return;
549
580
  currentMs = recording.currentTime * 1000 + videoOffset;
@@ -806,7 +837,14 @@ export async function createAppiumReplayReport(input: {
806
837
  const visualCheckByStepId = new Map(visualChecks.map((check) => [check.nodeId, check]));
807
838
  const visualFailureCount = visualChecks.filter((check) => check.status === 'failed').length;
808
839
  const html = createReplayHtml({
809
- video: input.video ? { startedAt: input.video.startedAt, url: relative(outputDir, input.video.filePath).split(/[/\\\\]/).map(encodeURIComponent).join('/') } : undefined,
840
+ video: input.video ? {
841
+ startedAt: input.video.startedAt,
842
+ url: relative(outputDir, input.video.filePath).split(/[/\\\\]/).map(encodeURIComponent).join('/'),
843
+ segments: (input.video.segments || [input.video]).map(segment => ({
844
+ startedAt: segment.startedAt, boundaryAt: segment.boundaryAt, scriptName: segment.scriptName,
845
+ url: relative(outputDir, segment.filePath).split(/[/\\\\]/).map(encodeURIComponent).join('/'),
846
+ })),
847
+ } : undefined,
810
848
  script: input.script,
811
849
  deviceId: input.deviceId,
812
850
  resultText,
@@ -337,7 +337,10 @@ export async function handleAppiumRecorderRequest(
337
337
  const run = getRunHistory(decodeURIComponent(videoMatch[1]), decodeURIComponent(videoMatch[2]));
338
338
  if (!run?.video) { sendJson(res, { error: '本次运行没有视频' }, 404); return true; }
339
339
  assertDeviceAllowed(run.deviceId);
340
- await sendReplayVideo(req, res, run.video.filePath);
340
+ const index = Number(new URL(req.url || '', 'http://localhost').searchParams.get('segment') || 0);
341
+ const segment = Number.isSafeInteger(index) && index >= 0 ? (run.video.segments || [run.video])[index] : undefined;
342
+ if (!segment) { sendJson(res, { error: '视频片段不存在' }, 404); return true; }
343
+ await sendReplayVideo(req, res, segment.filePath);
341
344
  return true;
342
345
  }
343
346
  const historyMatch = pathname.match(/^\/api\/appium-recorder\/scripts\/([^/]+)\/history(?:\/([^/]+))?$/);
@@ -33,8 +33,10 @@ export function deleteRunHistory(scriptId: string, id: string) {
33
33
  ensureTable();
34
34
  const video = getRunHistory(scriptId, id)?.video;
35
35
  // 只清理服务端生成并关联到该记录的录像,不接受客户端文件路径。
36
- if (video && basename(video.filePath) === video.fileName && /^replay-[\d]+-[\da-f-]+\.mp4$/.test(video.fileName)) {
37
- rmSync(video.filePath, { force: true });
36
+ for (const segment of video?.segments || (video ? [video] : [])) {
37
+ if (basename(segment.filePath) === segment.fileName && /^replay-[\d]+-[\da-f-]+\.mp4$/.test(segment.fileName)) {
38
+ rmSync(segment.filePath, { force: true });
39
+ }
38
40
  }
39
41
  runSql(`PRAGMA secure_delete=ON; DELETE FROM appium_run_history WHERE script_id=${sqlString(scriptId)} AND id=${sqlString(id)};`);
40
42
  }
@@ -24,7 +24,11 @@ function adbCommand(args: string[], signal?: AbortSignal) {
24
24
  });
25
25
  }
26
26
 
27
- export async function openRecordingSource(deviceId: string, signal: AbortSignal) {
27
+ export type RecordingProfile = { label: string; maxSize: number; videoBitRate: number };
28
+
29
+ export async function openRecordingSource(deviceId: string, signal: AbortSignal, profile: RecordingProfile = {
30
+ label: '标准', maxSize: 1280, videoBitRate: 2000000,
31
+ }) {
28
32
  const require = createRequire(import.meta.url);
29
33
  const bin = join(dirname(require.resolve('@midscene/android-playground/package.json')), 'bin');
30
34
  const version = (await readFile(join(bin, 'scrcpy-server.version'), 'utf8')).trim();
@@ -52,8 +56,9 @@ export async function openRecordingSource(deviceId: string, signal: AbortSignal)
52
56
  const options = new AdbScrcpyOptions3_3_3({
53
57
  scid: Math.floor(Math.random() * 0x7fffffff).toString(16).padStart(8, '0'),
54
58
  audio: false, control: false, videoCodec: 'h264', sendFrameMeta: true,
55
- maxSize: 1280, maxFps: 15, videoBitRate: 2000000,
56
- captureOrientation: '@', videoCodecOptions: 'profile=1,max-bframes=0,i-frame-interval=2',
59
+ maxSize: profile.maxSize, maxFps: 15, videoBitRate: profile.videoBitRate,
60
+ // 不强制 profile/max-bframes 等厂商兼容性差的参数,交给设备选择。
61
+ captureOrientation: '@',
57
62
  });
58
63
  client = await waitForVideoTask(AdbScrcpyClient.start(adb, remotePath, options).then(value => {
59
64
  if (closing || signal.aborted) { void value.close(); throw signal.reason || new Error('录屏已取消'); }
@@ -75,6 +80,6 @@ export async function openRecordingSource(deviceId: string, signal: AbortSignal)
75
80
  return { stream: video.stream, close, diagnostics: () => logs };
76
81
  } catch (error) {
77
82
  await close();
78
- throw error;
83
+ throw new Error(`${error instanceof Error ? error.message : String(error)}${logs}`, { cause: error });
79
84
  }
80
85
  }
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import { computed, onMounted, ref } from 'vue';
2
+ import { computed, onMounted, ref, watch } from 'vue';
3
3
  import { Delete, Refresh, VideoPlay } from '@element-plus/icons-vue';
4
4
  import { ElMessage, ElMessageBox } from 'element-plus';
5
5
  import { historyStatistics, type RunSummary, type RunDetail } from './run-history';
@@ -17,6 +17,8 @@ const error = ref('');
17
17
  const nodeKey = ref('');
18
18
  const logQuery = ref('');
19
19
  const videoRun = ref<RunSummary>();
20
+ const videoSegment = ref(0);
21
+ watch(videoRun, () => { videoSegment.value = 0; }, { flush: 'sync' });
20
22
  const base = `/api/appium-recorder/scripts/${encodeURIComponent(props.scriptId)}/history`;
21
23
  const labels = { passed: '通过', failed: '失败', stopped: '已终止' };
22
24
  const versions = computed(() => [...new Set(runs.value.map(run => run.appVersion || '未知'))]);
@@ -132,7 +134,10 @@ onMounted(load);
132
134
  </section>
133
135
  </div>
134
136
  <el-dialog :model-value="Boolean(videoRun)" title="回放视频" width="min(800px, 90vw)" align-center append-to-body destroy-on-close @close="videoRun = undefined">
135
- <video v-if="videoRun" :src="`${base}/${encodeURIComponent(videoRun.id)}/video`" controls preload="metadata" style="width:100%;max-height:70vh" />
137
+ <el-select v-if="videoRun?.video?.segments?.length" v-model="videoSegment" aria-label="视频片段" style="width:100%;margin-bottom:12px">
138
+ <el-option v-for="(segment, index) in videoRun.video.segments" :key="segment.fileName" :label="`${index + 1}. ${segment.scriptName || videoRun.scriptName}`" :value="index" />
139
+ </el-select>
140
+ <video v-if="videoRun" :src="`${base}/${encodeURIComponent(videoRun.id)}/video?segment=${videoSegment}`" controls preload="metadata" style="width:100%;max-height:70vh" />
136
141
  </el-dialog>
137
142
  </el-dialog>
138
143
  </template>
@@ -5,8 +5,10 @@ export type HistoryNode = {
5
5
  durationMs: number | null;
6
6
  };
7
7
 
8
+ export type RunVideo = { filePath: string; fileName: string; startedAt: string; warning?: string; scriptId?: string; scriptName?: string; boundaryAt?: string; segments?: RunVideo[] };
9
+
8
10
  export type RunSummary = {
9
- video?: { filePath: string; fileName: string; startedAt: string; warning?: string };
11
+ video?: RunVideo;
10
12
  id: string;
11
13
  scriptId: string;
12
14
  scriptName: string;