android-midscene-automation 0.1.37 → 0.1.39
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 +13 -0
- package/README.md +13 -0
- package/package.json +12 -7
- package/server/appium-recorder/ai-recognition.ts +4 -2
- package/server/appium-recorder/appium-runner.ts +90 -37
- package/server/appium-recorder/condition-timeout.ts +4 -0
- package/server/appium-recorder/image-check.ts +16 -2
- package/server/appium-recorder/replay-mp4.ts +72 -0
- package/server/appium-recorder/replay-video.ts +99 -0
- package/server/appium-recorder/report.ts +60 -6
- package/server/appium-recorder/repository.ts +1 -0
- package/server/appium-recorder/routes.ts +13 -17
- package/server/appium-recorder/run-history.ts +7 -1
- package/server/appium-recorder/scrcpy-recording-source.ts +80 -0
- package/server/appium-recorder/video-response.ts +36 -0
- package/src/appium-recorder/AppiumPage.vue +42 -98
- package/src/appium-recorder/RunHistoryDialog.vue +7 -2
- package/src/appium-recorder/action-descriptions.ts +1 -1
- package/src/appium-recorder/api.ts +3 -10
- package/src/appium-recorder/components/BranchTimeoutSettings.vue +23 -0
- package/src/appium-recorder/components/FlowNodeCard.vue +2 -3
- package/src/appium-recorder/components/FlowStepEditor.vue +6 -21
- package/src/appium-recorder/components/ImageCheckDialog.vue +38 -9
- package/src/appium-recorder/components/NestedConditionBranches.vue +0 -1
- package/src/appium-recorder/components/RecordedSteps.vue +0 -1
- package/src/appium-recorder/flow-graph.ts +2 -2
- package/src/appium-recorder/flow-labels.ts +2 -2
- package/src/appium-recorder/image-check.ts +6 -2
- package/src/appium-recorder/node-timeout.ts +1 -0
- package/src/appium-recorder/run-history.ts +1 -0
- package/src/appium-recorder/types.ts +1 -0
- package/src/components/config/AppiumConfigPanel.vue +0 -35
- package/src/appium-recorder/components/AiRecognitionTestDialog.vue +0 -71
package/CHANGELOG.md
CHANGED
|
@@ -1,4 +1,17 @@
|
|
|
1
1
|
# 更新记录
|
|
2
|
+
## v0.1.39
|
|
3
|
+
- Midscene 相关模块统一升级至 1.12.7,
|
|
4
|
+
- scrcpy 视频帧完整性修复:可靠传输视频包、修正关键帧标记,并在缓冲溢出后等待完整关键帧恢复,降低设备预览花屏风险。
|
|
5
|
+
- 回放按钮右侧下拉设置新增默认关闭的“录制回放视频”,使用内置服务端后台接收视频流并封装 MP4,无需安装 scrcpy 或 FFmpeg;远程设备暂不支持。
|
|
6
|
+
- 图像模板判断改为“预期匹配结果”,匹配严格度以百分比配置,100% 时提示细微像素差异风险;节点显示预期结果,日志和 HTML 报告分别展示实际匹配、预期匹配、得分与条件结论,保留原检测算法及已有阈值。
|
|
7
|
+
- 移除 AI 识别操作、测试入口及 Appium 参数中的 AI 模型配置,保留流程背景色设置;旧脚本中的 AI 节点提示替换,回放前阻止执行,不自动删除或改变分支。
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
## v0.1.38
|
|
11
|
+
- 移除节点类型切换;新节点及未配置的判断等待超时统一为 3000ms,已有自定义时间和延时、长按等操作时长保持不变。
|
|
12
|
+
- 取消 Activity 不匹配时的脚本编辑锁定,不在脚本绑定页面也可编辑节点;页面差异仅作提示,保留录制操作及回放期间的编辑保护。
|
|
13
|
+
- 所有分支节点新增“超时后处理”,可预设终止流程、进入左侧或右侧分支,默认终止;覆盖元素等待、文字匹配、勾选控件等待、AI 识别及图像观察超时,日志和 HTML 报告标明超时分支。正常 false、循环次数上限与超时区分处理,设备断开及 Appium 无响应仍终止。
|
|
14
|
+
|
|
2
15
|
## v0.1.37
|
|
3
16
|
- 修复元素查找超时未覆盖 Appium 请求的问题,驱动无响应时及时中止并明确报错,不再误判为找不到元素;唯一原生定位优先使用 ID 或 Accessibility ID。
|
|
4
17
|
- 设备预览新增设备信息,展示设备名称、品牌、型号、处理器、Android 版本、物理分辨率及当前分辨率,支持远程设备;问号位于标题右侧,样式与全局变量帮助图标一致。
|
package/README.md
CHANGED
|
@@ -1229,6 +1229,19 @@ Linux: ~/.local/share/android-midscene-automation
|
|
|
1229
1229
|
|
|
1230
1230
|
# 项目更新记录
|
|
1231
1231
|
|
|
1232
|
+
## v0.1.39
|
|
1233
|
+
- Midscene 相关模块统一升级至 1.12.7,
|
|
1234
|
+
- scrcpy 视频帧完整性修复:可靠传输视频包、修正关键帧标记,并在缓冲溢出后等待完整关键帧恢复,降低设备预览花屏风险。
|
|
1235
|
+
- 回放按钮右侧下拉设置新增默认关闭的“录制回放视频”,使用内置服务端后台接收视频流并封装 MP4,无需安装 scrcpy 或 FFmpeg;远程设备暂不支持。
|
|
1236
|
+
- 图像模板判断改为“预期匹配结果”,匹配严格度以百分比配置,100% 时提示细微像素差异风险;节点显示预期结果,日志和 HTML 报告分别展示实际匹配、预期匹配、得分与条件结论,保留原检测算法及已有阈值。
|
|
1237
|
+
- 移除 AI 识别操作、测试入口及 Appium 参数中的 AI 模型配置,保留流程背景色设置;旧脚本中的 AI 节点提示替换,回放前阻止执行,不自动删除或改变分支。
|
|
1238
|
+
|
|
1239
|
+
|
|
1240
|
+
## v0.1.38
|
|
1241
|
+
- 移除节点类型切换;新节点及未配置的判断等待超时统一为 3000ms,已有自定义时间和延时、长按等操作时长保持不变。
|
|
1242
|
+
- 取消 Activity 不匹配时的脚本编辑锁定,不在脚本绑定页面也可编辑节点;页面差异仅作提示,保留录制操作及回放期间的编辑保护。
|
|
1243
|
+
- 所有分支节点新增“超时后处理”,可预设终止流程、进入左侧或右侧分支,默认终止;覆盖元素等待、文字匹配、勾选控件等待、AI 识别及图像观察超时,日志和 HTML 报告标明超时分支。正常 false、循环次数上限与超时区分处理,设备断开及 Appium 无响应仍终止。
|
|
1244
|
+
|
|
1232
1245
|
## v0.1.37
|
|
1233
1246
|
- 修复元素查找超时未覆盖 Appium 请求的问题,驱动无响应时及时中止并明确报错,不再误判为找不到元素;唯一原生定位优先使用 ID 或 Accessibility ID。
|
|
1234
1247
|
- 设备预览新增设备信息,展示设备名称、品牌、型号、处理器、Android 版本、物理分辨率及当前分辨率,支持远程设备;问号位于标题右侧,样式与全局变量帮助图标一致。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "android-midscene-automation",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.39",
|
|
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.
|
|
42
|
-
"@midscene/android-playground": "https://registry.npmjs.org/@midscene/android-playground/-/android-playground-1.12.
|
|
43
|
-
"@midscene/core": "https://registry.npmjs.org/@midscene/core/-/core-1.12.
|
|
44
|
-
"@midscene/playground": "https://registry.npmjs.org/@midscene/playground/-/playground-1.12.
|
|
45
|
-
"@midscene/shared": "https://registry.npmjs.org/@midscene/shared/-/shared-1.12.
|
|
46
|
-
"@midscene/web": "https://registry.npmjs.org/@midscene/web/-/web-1.12.
|
|
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",
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import OpenAI from 'openai';
|
|
2
|
+
import { DEFAULT_NODE_TIMEOUT_MS } from '../../src/appium-recorder/node-timeout';
|
|
3
|
+
import { ConditionTimeoutError } from './condition-timeout';
|
|
2
4
|
import { loadConfig } from '../config';
|
|
3
5
|
import { adbScreenshotBase64 } from './screenshot';
|
|
4
6
|
import {
|
|
@@ -35,7 +37,7 @@ export async function recognizeDeviceScreen(input: {
|
|
|
35
37
|
if (!['http:', 'https:'].includes(baseURL.protocol)) throw new Error('AI 识别模型 Base URL 必须为 HTTP(S) 地址');
|
|
36
38
|
const startedAt = Date.now();
|
|
37
39
|
const timeoutMs = Number.isFinite(input.timeoutMs) && input.timeoutMs! > 0
|
|
38
|
-
? Math.min(300000, Math.max(1000, input.timeoutMs!)) :
|
|
40
|
+
? Math.min(300000, Math.max(1000, input.timeoutMs!)) : DEFAULT_NODE_TIMEOUT_MS;
|
|
39
41
|
const deadline = AbortSignal.timeout(timeoutMs);
|
|
40
42
|
const signal = input.signal ? AbortSignal.any([input.signal, deadline]) : deadline;
|
|
41
43
|
signal.throwIfAborted();
|
|
@@ -59,7 +61,7 @@ export async function recognizeDeviceScreen(input: {
|
|
|
59
61
|
return { ...parseAiRecognitionResult(choice.message.content), durationMs: Date.now() - startedAt, imageBase64 };
|
|
60
62
|
} catch (error) {
|
|
61
63
|
if (input.signal?.aborted) throw input.signal.reason;
|
|
62
|
-
if (deadline.aborted) throw new
|
|
64
|
+
if (deadline.aborted) throw new ConditionTimeoutError(`AI 识别超时(${timeoutMs}ms)`);
|
|
63
65
|
// 不透传模型服务的原始响应,避免 API Key 或敏感请求信息进入报告。
|
|
64
66
|
if (error instanceof OpenAI.APIError) throw new Error(`AI 识别模型请求失败(HTTP ${error.status || '连接异常'}),请检查模型配置及图片输入支持`);
|
|
65
67
|
throw error;
|
|
@@ -6,12 +6,14 @@ 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';
|
|
14
|
+
import { DEFAULT_NODE_TIMEOUT_MS } from '../../src/appium-recorder/node-timeout';
|
|
15
|
+
import { startReplayVideo, type ReplayVideo } from './replay-video';
|
|
16
|
+
import { ConditionTimeoutError, AppiumServiceError } from './condition-timeout';
|
|
15
17
|
import { AppiumRequestTimeoutError, timedAppiumFetch, withElementDeadline } from './request-timeout';
|
|
16
18
|
import { textClickSelector } from '../../src/appium-recorder/text-click';
|
|
17
19
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
@@ -86,7 +88,7 @@ function isReplayStopped(error?: unknown) {
|
|
|
86
88
|
function throwIfReplayStopped(error?: unknown) {
|
|
87
89
|
if (isReplayStopped(error)) throw new ReplayStoppedError();
|
|
88
90
|
// 驱动失去响应不是“元素不存在”,不能被备用定位或可选步骤吞掉。
|
|
89
|
-
if (error instanceof AppiumRequestTimeoutError) throw error;
|
|
91
|
+
if (error instanceof AppiumRequestTimeoutError || error instanceof AppiumServiceError) throw error;
|
|
90
92
|
}
|
|
91
93
|
|
|
92
94
|
function errorDetail(error: unknown) {
|
|
@@ -209,16 +211,17 @@ function adbSwipe(deviceId: string, step: Required<AppiumRecordedStepRecord>['sw
|
|
|
209
211
|
});
|
|
210
212
|
}
|
|
211
213
|
|
|
212
|
-
function adbText(deviceId: string, args: string[]) {
|
|
213
|
-
return new Promise<string>((resolve) => {
|
|
214
|
-
execFile(getAdbCommand(), ['-s', deviceId, ...args], { maxBuffer: 4 * 1024 * 1024 }, (
|
|
214
|
+
function adbText(deviceId: string, args: string[], strict = false) {
|
|
215
|
+
return new Promise<string>((resolve, reject) => {
|
|
216
|
+
execFile(getAdbCommand(), ['-s', deviceId, ...args], { maxBuffer: 4 * 1024 * 1024, timeout: 10000, signal: replayContext.getStore()?.signal }, (error, stdout, stderr) => {
|
|
217
|
+
if (strict && error) { reject(new AppiumServiceError(`读取设备状态失败:${stderr || error.message}`)); return; }
|
|
215
218
|
resolve((stdout || stderr || '').trim());
|
|
216
219
|
});
|
|
217
220
|
});
|
|
218
221
|
}
|
|
219
222
|
|
|
220
223
|
async function getCurrentActivity(deviceId: string) {
|
|
221
|
-
const output = await adbText(deviceId, ['shell', 'dumpsys', 'activity', 'activities']);
|
|
224
|
+
const output = await adbText(deviceId, ['shell', 'dumpsys', 'activity', 'activities'], true);
|
|
222
225
|
const resumedLine = output
|
|
223
226
|
.split(/\r?\n/)
|
|
224
227
|
.find((line) => /(?:topResumedActivity|ResumedActivity|mResumedActivity)/.test(line));
|
|
@@ -235,7 +238,7 @@ async function waitForActivity(deviceId: string, step: AppiumRecordedStepRecord)
|
|
|
235
238
|
const expectedActivity = step.value || '';
|
|
236
239
|
if (!expectedActivity) throw new Error(`${step.label} 缺少目标 Activity`);
|
|
237
240
|
const startedAt = Date.now();
|
|
238
|
-
const timeoutMs = step.timeoutMs
|
|
241
|
+
const timeoutMs = step.timeoutMs ?? DEFAULT_NODE_TIMEOUT_MS;
|
|
239
242
|
let currentActivity = '';
|
|
240
243
|
while (Date.now() - startedAt <= timeoutMs) {
|
|
241
244
|
throwIfReplayStopped();
|
|
@@ -243,7 +246,7 @@ async function waitForActivity(deviceId: string, step: AppiumRecordedStepRecord)
|
|
|
243
246
|
if (currentActivity === expectedActivity) return;
|
|
244
247
|
await wait(500);
|
|
245
248
|
}
|
|
246
|
-
throw new
|
|
249
|
+
throw new ConditionTimeoutError(`${step.label} 等待超时,当前 Activity:${currentActivity || '-'}`);
|
|
247
250
|
}
|
|
248
251
|
|
|
249
252
|
async function appendSettingsDiagnostics(lines: string[], deviceId: string) {
|
|
@@ -300,9 +303,9 @@ async function appiumRequest<T>(
|
|
|
300
303
|
context?.appiumLog(`[Appium] 异常:${method} ${path}(${errorDetail(error)})`);
|
|
301
304
|
if (error instanceof AppiumRequestTimeoutError) throw error;
|
|
302
305
|
if (isTimeoutError(error)) {
|
|
303
|
-
throw new
|
|
306
|
+
throw new AppiumServiceError(`Appium 请求超时 ${requestUrl},${errorDetail(error)}`);
|
|
304
307
|
}
|
|
305
|
-
throw new
|
|
308
|
+
throw new AppiumServiceError(`无法连接 Appium 服务 ${requestUrl},${errorDetail(error)}`);
|
|
306
309
|
}
|
|
307
310
|
|
|
308
311
|
context?.appiumLog(`[Appium] 响应:HTTP ${response.status} ${method} ${path}(${Date.now() - startedAt}ms)`);
|
|
@@ -325,7 +328,8 @@ async function appiumRequest<T>(
|
|
|
325
328
|
payload.value?.stacktrace || payload.stacktrace ? `堆栈信息:\n${payload.value?.stacktrace || payload.stacktrace}` : '',
|
|
326
329
|
responseText ? `原始响应:\n${responseText}` : '',
|
|
327
330
|
].filter(Boolean).join('\n');
|
|
328
|
-
|
|
331
|
+
const ErrorType = ['no such element', 'stale element reference'].includes(payload.value?.error || payload.error || '') ? Error : AppiumServiceError;
|
|
332
|
+
throw new ErrorType(`Appium ${method} ${path} 失败(HTTP ${response.status}):\n${detail || '未返回错误详情'}`);
|
|
329
333
|
}
|
|
330
334
|
return payload;
|
|
331
335
|
}
|
|
@@ -349,10 +353,11 @@ async function captureReplayFrame(
|
|
|
349
353
|
) {
|
|
350
354
|
const context = replayContext.getStore();
|
|
351
355
|
if (!context || !context.deviceId) return;
|
|
356
|
+
const capturedAt = new Date().toISOString();
|
|
352
357
|
// 执行记录独立于截图,设备截图失败不能让失败节点从统计中消失。
|
|
353
358
|
context.historyEvents.push({ sequence: context.historyEvents.length + 1, scriptName, nodeId: step.id,
|
|
354
359
|
nodeNumber, nodeLabel: step.label, nodeType: step.type, note: step.note || '',
|
|
355
|
-
selector: replayFrameSelector(step), phase, status, capturedAt
|
|
360
|
+
selector: replayFrameSelector(step), phase, status, capturedAt, imageBase64: '' });
|
|
356
361
|
const appendFrame = (imageBase64: string) => {
|
|
357
362
|
context.frames.push({
|
|
358
363
|
sequence: context.frames.length + 1,
|
|
@@ -366,7 +371,7 @@ async function captureReplayFrame(
|
|
|
366
371
|
selector: replayFrameSelector(step),
|
|
367
372
|
phase,
|
|
368
373
|
status,
|
|
369
|
-
capturedAt
|
|
374
|
+
capturedAt,
|
|
370
375
|
imageBase64,
|
|
371
376
|
});
|
|
372
377
|
};
|
|
@@ -382,6 +387,8 @@ async function captureReplayFrame(
|
|
|
382
387
|
appendFrame(imageBase64 || await adbScreenshotBase64(context.deviceId));
|
|
383
388
|
} catch (error) {
|
|
384
389
|
context.appiumLog(`[节点 ${nodeNumber}] 截图失败:${errorDetail(error)}`);
|
|
390
|
+
// 截图失败仍保留时间锚点,报告可定位到该节点的录像。
|
|
391
|
+
appendFrame('');
|
|
385
392
|
}
|
|
386
393
|
}
|
|
387
394
|
|
|
@@ -444,7 +451,7 @@ async function findElementBySelector(
|
|
|
444
451
|
return elementId;
|
|
445
452
|
}
|
|
446
453
|
|
|
447
|
-
async function findElement(sessionId: string, step: AppiumRecordedStepRecord, timeoutMs = step.timeoutMs ??
|
|
454
|
+
async function findElement(sessionId: string, step: AppiumRecordedStepRecord, timeoutMs = step.timeoutMs ?? DEFAULT_NODE_TIMEOUT_MS) {
|
|
448
455
|
return withElementDeadline(timeoutMs, () => findElementWithinDeadline(sessionId, step));
|
|
449
456
|
}
|
|
450
457
|
|
|
@@ -513,7 +520,7 @@ async function tapFallback(deviceId: string, step: AppiumRecordedStepRecord) {
|
|
|
513
520
|
|
|
514
521
|
async function waitForElement(sessionId: string, step: AppiumRecordedStepRecord) {
|
|
515
522
|
const startedAt = Date.now();
|
|
516
|
-
const timeoutMs = step.timeoutMs
|
|
523
|
+
const timeoutMs = step.timeoutMs ?? DEFAULT_NODE_TIMEOUT_MS;
|
|
517
524
|
let lastError: unknown;
|
|
518
525
|
while (Date.now() - startedAt <= timeoutMs) {
|
|
519
526
|
try {
|
|
@@ -524,12 +531,12 @@ async function waitForElement(sessionId: string, step: AppiumRecordedStepRecord)
|
|
|
524
531
|
await wait(500);
|
|
525
532
|
}
|
|
526
533
|
}
|
|
527
|
-
throw
|
|
534
|
+
throw new ConditionTimeoutError(`${step.label} 等待元素超时(${timeoutMs}ms):${errorDetail(lastError)}`);
|
|
528
535
|
}
|
|
529
536
|
|
|
530
537
|
async function findOptionalElement(sessionId: string, step: AppiumRecordedStepRecord) {
|
|
531
538
|
const startedAt = Date.now();
|
|
532
|
-
const timeoutMs = step.timeoutMs ??
|
|
539
|
+
const timeoutMs = step.timeoutMs ?? DEFAULT_NODE_TIMEOUT_MS;
|
|
533
540
|
while (Date.now() - startedAt <= timeoutMs) {
|
|
534
541
|
try {
|
|
535
542
|
return await findElement(sessionId, step, Math.max(1, timeoutMs - (Date.now() - startedAt)));
|
|
@@ -543,7 +550,7 @@ async function findOptionalElement(sessionId: string, step: AppiumRecordedStepRe
|
|
|
543
550
|
|
|
544
551
|
async function waitForElementGone(sessionId: string, step: AppiumRecordedStepRecord) {
|
|
545
552
|
const startedAt = Date.now();
|
|
546
|
-
const timeoutMs = step.timeoutMs
|
|
553
|
+
const timeoutMs = step.timeoutMs ?? DEFAULT_NODE_TIMEOUT_MS;
|
|
547
554
|
while (Date.now() - startedAt <= timeoutMs) {
|
|
548
555
|
try {
|
|
549
556
|
await findElement(sessionId, step, Math.max(1, timeoutMs - (Date.now() - startedAt)));
|
|
@@ -553,7 +560,7 @@ async function waitForElementGone(sessionId: string, step: AppiumRecordedStepRec
|
|
|
553
560
|
}
|
|
554
561
|
await wait(500);
|
|
555
562
|
}
|
|
556
|
-
throw new
|
|
563
|
+
throw new ConditionTimeoutError(`${step.label} 等待消失超时`);
|
|
557
564
|
}
|
|
558
565
|
|
|
559
566
|
async function saveScreenshot(deviceId: string) {
|
|
@@ -998,7 +1005,7 @@ async function evaluateCondition(sessionId: string, deviceId: string, step: Appi
|
|
|
998
1005
|
}
|
|
999
1006
|
if (step.type === 'textClick') {
|
|
1000
1007
|
const selector = textClickSelector(step);
|
|
1001
|
-
const deadline = Date.now() + Math.max(0, step.timeoutMs ??
|
|
1008
|
+
const deadline = Date.now() + Math.max(0, step.timeoutMs ?? DEFAULT_NODE_TIMEOUT_MS);
|
|
1002
1009
|
// 查询空结果才是未匹配;通信错误、重复匹配和点击失败走现有异常处理。
|
|
1003
1010
|
do {
|
|
1004
1011
|
const payload = await appiumRequest<AppiumValueResponse<Record<string, string>[]>>(`/session/${sessionId}/elements`, {
|
|
@@ -1013,7 +1020,10 @@ async function evaluateCondition(sessionId: string, deviceId: string, step: Appi
|
|
|
1013
1020
|
await appiumRequest(`/session/${sessionId}/element/${id}/click`, { method: 'POST', body: '{}' });
|
|
1014
1021
|
return true;
|
|
1015
1022
|
}
|
|
1016
|
-
if (Date.now() >= deadline)
|
|
1023
|
+
if (Date.now() >= deadline) {
|
|
1024
|
+
if (step.timeoutMs === 0) return false;
|
|
1025
|
+
throw new ConditionTimeoutError(`文字点击等待匹配超时(${step.timeoutMs ?? DEFAULT_NODE_TIMEOUT_MS}ms)`);
|
|
1026
|
+
}
|
|
1017
1027
|
await wait(Math.max(0, Math.min(500, deadline - Date.now())));
|
|
1018
1028
|
} while (true);
|
|
1019
1029
|
}
|
|
@@ -1029,7 +1039,7 @@ async function evaluateCondition(sessionId: string, deviceId: string, step: Appi
|
|
|
1029
1039
|
return true;
|
|
1030
1040
|
}
|
|
1031
1041
|
if (step.type === 'assertText' || (step.type === 'assertExists' && step.value)) {
|
|
1032
|
-
const elementId = await
|
|
1042
|
+
const elementId = await waitForElement(sessionId, step);
|
|
1033
1043
|
const payload = await appiumRequest<AppiumValueResponse<string>>(`/session/${sessionId}/element/${elementId}/text`);
|
|
1034
1044
|
const actualText = payload.value || '';
|
|
1035
1045
|
const expectedText = step.value || '';
|
|
@@ -1041,7 +1051,7 @@ async function evaluateCondition(sessionId: string, deviceId: string, step: Appi
|
|
|
1041
1051
|
return true;
|
|
1042
1052
|
} catch (error) {
|
|
1043
1053
|
throwIfReplayStopped(error);
|
|
1044
|
-
|
|
1054
|
+
throw error;
|
|
1045
1055
|
}
|
|
1046
1056
|
}
|
|
1047
1057
|
|
|
@@ -1117,7 +1127,7 @@ async function replayLinkedScript(
|
|
|
1117
1127
|
type: 'waitActivity',
|
|
1118
1128
|
label: step.label,
|
|
1119
1129
|
value: linkedScript.appActivity,
|
|
1120
|
-
timeoutMs:
|
|
1130
|
+
timeoutMs: DEFAULT_NODE_TIMEOUT_MS,
|
|
1121
1131
|
});
|
|
1122
1132
|
} catch (error) {
|
|
1123
1133
|
throwIfReplayStopped(error);
|
|
@@ -1251,13 +1261,7 @@ async function replayFlowSteps(
|
|
|
1251
1261
|
}
|
|
1252
1262
|
continue;
|
|
1253
1263
|
}
|
|
1254
|
-
|
|
1255
|
-
const beforeFrame = replayContext.getStore()?.frames.at(-1);
|
|
1256
|
-
const recognition = step.type === 'aiRecognition' ? await recognizeDeviceScreen({
|
|
1257
|
-
deviceId, prompt: step.value, timeoutMs: step.timeoutMs,
|
|
1258
|
-
signal: replayContext.getStore()?.signal,
|
|
1259
|
-
imageBase64: beforeFrame?.nodeId === step.id && beforeFrame.phase === 'before' ? beforeFrame.imageBase64 : undefined,
|
|
1260
|
-
}) : undefined;
|
|
1264
|
+
if (step.type === 'aiRecognition') throw new Error('AI 识别操作已移除,请替换此节点后回放');
|
|
1261
1265
|
let imageResult: ImageCheckResult | undefined;
|
|
1262
1266
|
if (step.type === 'imageCheck') {
|
|
1263
1267
|
const resolved = resolveVariableStep(step);
|
|
@@ -1266,13 +1270,22 @@ async function replayFlowSteps(
|
|
|
1266
1270
|
resolveRegion: () => imageCheckRegion(sessionId, resolved), wait,
|
|
1267
1271
|
signal: replayContext.getStore()?.signal, saveImages: !variableContext.getStore()?.privacy.enabled });
|
|
1268
1272
|
const config = resolved.imageCheck;
|
|
1269
|
-
const settings = config ?
|
|
1273
|
+
const settings = config ? [
|
|
1274
|
+
IMAGE_CHECK_MODES[config.mode],
|
|
1275
|
+
config.mode === 'template' ? `预期匹配结果:${config.expectation === 'present' ? '匹配到模板' : '未匹配到模板'}` : '',
|
|
1276
|
+
['template', 'state'].includes(config.mode) ? `匹配严格度 ${config.threshold * 100}%` : '',
|
|
1277
|
+
config.mode === 'state' ? `最小得分差 ${config.minScoreGap}` : '',
|
|
1278
|
+
['black', 'color', 'change'].includes(config.mode) ? `RGB/亮度容差 ${config.tolerance};像素占比 ${config.ratio}%` : '',
|
|
1279
|
+
config.mode === 'color' ? `目标色 ${config.color}` : '',
|
|
1280
|
+
config.mode === 'change' ? `预期:${config.expectation === 'present' ? '画面有变化' : '持续无明显变化'}` : '',
|
|
1281
|
+
config.mode !== 'state' ? `观察 ${config.durationMs}ms;间隔 ${config.intervalMs}ms;连续 ${config.consecutive} 帧` : '',
|
|
1282
|
+
].filter(Boolean).join(';') : '缺少配置';
|
|
1270
1283
|
replayContext.getStore()?.imageChecks.push({ ...imageResult, nodeId: step.id, nodeNumber: index + 1, nodeLabel: frameStep.label, scriptName: options.scriptName || '', settings });
|
|
1271
1284
|
lines.push(`[节点 ${index + 1}] 图像判断:${imageResult.result === null ? '无法判定' : imageResult.result},${imageResult.message};${settings};采样 ${imageResult.sampleCount} 帧,耗时 ${imageResult.durationMs}ms;指标 ${JSON.stringify(imageResult.metrics)}`);
|
|
1272
1285
|
if (imageResult.result === null) throw new Error(imageResult.message);
|
|
1286
|
+
if (imageResult.timedOut) throw new ConditionTimeoutError(imageResult.message);
|
|
1273
1287
|
}
|
|
1274
|
-
const matched = imageResult ? imageResult.result! :
|
|
1275
|
-
if (recognition) lines.push(`[节点 ${index + 1}] AI 识别:${recognition.result},耗时 ${recognition.durationMs}ms${recognition.reason ? `,依据:${recognition.reason}` : ''}`);
|
|
1288
|
+
const matched = imageResult ? imageResult.result! : await evaluateCondition(sessionId, deviceId, step);
|
|
1276
1289
|
const branchTargetId = matched ? step.flow?.yesTargetId : step.flow?.noTargetId;
|
|
1277
1290
|
const next = traversal.branch(step, matched);
|
|
1278
1291
|
const targetId = branchTargetId || (next === undefined ? '' : steps[next]?.id) || '';
|
|
@@ -1284,8 +1297,7 @@ async function replayFlowSteps(
|
|
|
1284
1297
|
index + 1,
|
|
1285
1298
|
options.scriptName || '',
|
|
1286
1299
|
'after',
|
|
1287
|
-
`判断:${branchLabel}
|
|
1288
|
-
recognition?.imageBase64,
|
|
1300
|
+
`判断:${branchLabel}`,
|
|
1289
1301
|
);
|
|
1290
1302
|
index = next;
|
|
1291
1303
|
} catch (error) {
|
|
@@ -1294,6 +1306,17 @@ async function replayFlowSteps(
|
|
|
1294
1306
|
await captureReplayFrame(sessionId, step, index + 1, options.scriptName || '', 'stopped', '已终止');
|
|
1295
1307
|
throw new ReplayStoppedError();
|
|
1296
1308
|
}
|
|
1309
|
+
if (error instanceof ConditionTimeoutError && (step.timeoutBranch === 'yes' || step.timeoutBranch === 'no')) {
|
|
1310
|
+
const branch = step.timeoutBranch;
|
|
1311
|
+
const status = `判断超时,按配置进入${branch === 'yes' ? '左' : '右'}侧分支(${flowBranchLabel(step, branch)})`;
|
|
1312
|
+
lines.push(`[节点 ${index + 1}] ${status};${error.message}`);
|
|
1313
|
+
await captureReplayFrame(sessionId, frameStep, index + 1, options.scriptName || '', 'after', status);
|
|
1314
|
+
// 循环体必须经过 enter,才能正确记轮次及受最大次数保护。
|
|
1315
|
+
index = step.type === 'loop'
|
|
1316
|
+
? branch === 'yes' ? traversal.enter(step).next : traversal.exit(step)
|
|
1317
|
+
: traversal.branch(step, branch === 'yes');
|
|
1318
|
+
continue;
|
|
1319
|
+
}
|
|
1297
1320
|
lines.push(`[节点 ${index + 1}] 失败:${errorDetail(error)}`);
|
|
1298
1321
|
await captureReplayFrame(sessionId, step, index + 1, options.scriptName || '', 'error', '失败');
|
|
1299
1322
|
await appendStepDiagnostics(lines, deviceId, step);
|
|
@@ -1413,7 +1436,7 @@ export async function replayAppiumScript(
|
|
|
1413
1436
|
deviceId: string,
|
|
1414
1437
|
onOutput?: (line: string) => void,
|
|
1415
1438
|
signal?: AbortSignal,
|
|
1416
|
-
runOptions: { parameters?: TestVariable[]; globalVariables?: TestVariable[]; linkedScripts?: AppiumRecordedScriptRecord[] } = {},
|
|
1439
|
+
runOptions: { recordVideo?: boolean; parameters?: TestVariable[]; globalVariables?: TestVariable[]; linkedScripts?: AppiumRecordedScriptRecord[] } = {},
|
|
1417
1440
|
) {
|
|
1418
1441
|
const targetDeviceId = deviceId || script.deviceId;
|
|
1419
1442
|
if (!targetDeviceId) throw new Error('未检测到可用设备');
|
|
@@ -1421,6 +1444,12 @@ export async function replayAppiumScript(
|
|
|
1421
1444
|
|
|
1422
1445
|
const scope = new VariableScope(runOptions.globalVariables ?? getPresetVariables(), script.variables || []);
|
|
1423
1446
|
const scripts = new Map((runOptions.linkedScripts ?? linkedScriptSnapshot(script)).map(item => [item.id, item]));
|
|
1447
|
+
// 旧节点保留图结构供用户替换,但必须在任何设备操作之前阻止回放。
|
|
1448
|
+
for (const candidate of [script, ...scripts.values()]) {
|
|
1449
|
+
if (candidate.steps.some(step => step.type === 'aiRecognition')) {
|
|
1450
|
+
throw new Error(`脚本“${candidate.name}”包含已移除的 AI 识别操作,请改用图像判断或原生组件判断后回放`);
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1424
1453
|
for (const item of validateVariables(runOptions.parameters)) scope.set(item);
|
|
1425
1454
|
// 提前登记子脚本敏感预设,避免进入子脚本前的原始日志或截图泄露。
|
|
1426
1455
|
const visited = new Set<string>();
|
|
@@ -1473,6 +1502,8 @@ export async function replayAppiumScript(
|
|
|
1473
1502
|
let sessionId = '';
|
|
1474
1503
|
let success = false;
|
|
1475
1504
|
let stopped = false;
|
|
1505
|
+
let recording: Awaited<ReturnType<typeof startReplayVideo>> | undefined;
|
|
1506
|
+
let video: ReplayVideo | undefined;
|
|
1476
1507
|
let managedAppium: Awaited<ReturnType<typeof startManagedAppiumServer>> | null = null;
|
|
1477
1508
|
try {
|
|
1478
1509
|
validateLoopSteps(script.steps);
|
|
@@ -1497,6 +1528,12 @@ export async function replayAppiumScript(
|
|
|
1497
1528
|
sessionId = await createSession(targetDeviceId);
|
|
1498
1529
|
}
|
|
1499
1530
|
lines.push(`Appium session 已创建:${sessionId}`);
|
|
1531
|
+
if (runOptions.recordVideo) {
|
|
1532
|
+
if (scope.privacy.enabled) throw new Error('本次运行包含敏感变量,为避免泄露已禁止录屏,请关闭录制回放视频');
|
|
1533
|
+
lines.push('正在启动后台录屏...');
|
|
1534
|
+
recording = await startReplayVideo(targetDeviceId, signal);
|
|
1535
|
+
lines.push('后台录屏已启动(MP4 / H.264 / 15fps / 2Mbps,无音频)');
|
|
1536
|
+
}
|
|
1500
1537
|
if (hasFlowSteps(script.steps)) lines.push('按流程图路径回放...');
|
|
1501
1538
|
await replayScriptSteps(sessionId, targetDeviceId, script.steps, lines, [script.id], {
|
|
1502
1539
|
scriptName: script.name,
|
|
@@ -1524,6 +1561,19 @@ export async function replayAppiumScript(
|
|
|
1524
1561
|
lines.push(`回放终止:${detail}`);
|
|
1525
1562
|
}
|
|
1526
1563
|
} finally {
|
|
1564
|
+
if (recording) {
|
|
1565
|
+
try {
|
|
1566
|
+
video = await recording.stop();
|
|
1567
|
+
if (scope.privacy.enabled) {
|
|
1568
|
+
await import('node:fs/promises').then(fs => fs.rm(video!.filePath, { force: true }));
|
|
1569
|
+
video = undefined;
|
|
1570
|
+
lines.push('敏感变量保护:已删除回放视频');
|
|
1571
|
+
} else {
|
|
1572
|
+
lines.push(`回放视频已保存:${video.filePath}`);
|
|
1573
|
+
if (video.warning) lines.push(`录屏警告:${video.warning}`);
|
|
1574
|
+
}
|
|
1575
|
+
} catch (error) { lines.push(`回放视频保存失败:${errorDetail(error)}`); }
|
|
1576
|
+
}
|
|
1527
1577
|
let managedAppiumStopped = false;
|
|
1528
1578
|
if (signal?.aborted && managedAppium) {
|
|
1529
1579
|
await managedAppium.stop();
|
|
@@ -1562,6 +1612,7 @@ export async function replayAppiumScript(
|
|
|
1562
1612
|
success,
|
|
1563
1613
|
stopped,
|
|
1564
1614
|
output: scope.redact(lines.join('\n')),
|
|
1615
|
+
video,
|
|
1565
1616
|
startedAt,
|
|
1566
1617
|
completedAt,
|
|
1567
1618
|
frames: safeFrames,
|
|
@@ -1586,9 +1637,11 @@ export async function replayAppiumScript(
|
|
|
1586
1637
|
reportId,
|
|
1587
1638
|
logPath,
|
|
1588
1639
|
htmlReportPath,
|
|
1640
|
+
videoPath: video?.filePath,
|
|
1589
1641
|
softFailureCount: context.softFailureCount,
|
|
1590
1642
|
history: {
|
|
1591
1643
|
scriptId: script.id, scriptName: script.name, appPackage: script.appPackage,
|
|
1644
|
+
video,
|
|
1592
1645
|
appVersion, deviceId: targetDeviceId, startedAt: startedAt.toISOString(),
|
|
1593
1646
|
durationMs: completedAt.getTime() - startedAt.getTime(),
|
|
1594
1647
|
status: stopped ? 'stopped' as const : success ? 'passed' as const : 'failed' as const,
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { PNG } from 'pngjs';
|
|
2
|
+
import { AppiumRequestTimeoutError } from './request-timeout';
|
|
3
|
+
import { AppiumServiceError } from './condition-timeout';
|
|
2
4
|
import { validateImageCheck, type ImageCheckConfig, type ImageCheckResult } from '../../src/appium-recorder/image-check';
|
|
3
5
|
import type { AppiumVisualChangeRegion } from '../../src/appium-recorder/types';
|
|
4
6
|
|
|
@@ -126,6 +128,10 @@ export async function checkImage(input: {
|
|
|
126
128
|
if (!baseline) { baseline = current; observedAt = now(); keep('首帧', current); }
|
|
127
129
|
const measurement = await measure(current, baseline, templates, config);
|
|
128
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
|
+
}
|
|
129
135
|
input.signal?.throwIfAborted();
|
|
130
136
|
if (config.mode === 'state') {
|
|
131
137
|
if (measurement.matched === null) throw new Error('双模板均未达到阈值或得分过于接近');
|
|
@@ -138,20 +144,28 @@ export async function checkImage(input: {
|
|
|
138
144
|
const changed = maximum >= config.ratio;
|
|
139
145
|
if (changed || (result.sampleCount >= 2 && now() - observedAt >= config.durationMs)) {
|
|
140
146
|
result.result = config.expectation === 'present' ? changed : !changed;
|
|
147
|
+
result.timedOut = !changed && config.expectation === 'present' && config.durationMs > 0;
|
|
141
148
|
break;
|
|
142
149
|
}
|
|
143
150
|
} else {
|
|
144
151
|
consecutive = measurement.matched ? consecutive + 1 : 0;
|
|
145
152
|
result.metrics['连续满足帧数'] = consecutive;
|
|
146
153
|
if (consecutive >= config.consecutive) { result.result = true; break; }
|
|
147
|
-
if (now() - observedAt >= config.durationMs) { result.result = false; break; }
|
|
154
|
+
if (now() - observedAt >= config.durationMs) { result.result = false; result.timedOut = config.durationMs > 0; break; }
|
|
148
155
|
}
|
|
149
156
|
await input.wait(Math.min(config.intervalMs, Math.max(1, config.durationMs - (now() - observedAt))));
|
|
150
157
|
}
|
|
151
158
|
if (last) keep('判定帧', last);
|
|
152
|
-
result.message = result.result ? '条件成立' : '条件不成立';
|
|
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
|
+
}
|
|
153
166
|
} catch (error) {
|
|
154
167
|
if (input.signal?.aborted) throw error;
|
|
168
|
+
if (error instanceof AppiumRequestTimeoutError || error instanceof AppiumServiceError) throw error;
|
|
155
169
|
result.message = `无法判定:${error instanceof Error ? error.message : '图像检测异常'}`;
|
|
156
170
|
result.result = null;
|
|
157
171
|
}
|
|
@@ -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) {
|
|
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 = Date.now();
|
|
41
|
+
this.startedMonotonic = performance.now();
|
|
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
|
+
}
|