feat(任务): 录制手势改成**纯录制回放**(真手指轨迹)+ 手机端 getevent 录制

用户反馈:录制出来的滑动又被套上滑动那套逻辑(方向/幅度/拟人重新生成),
"导致滑动还是不顺畅"。录制就该是录制——按录下的路径与时间原样重放。

- 新步骤 `gesture`「录制手势」:存完整轨迹点列 `[[x,y,t_ms],…]`,
  回放时原样交给设备(`d.swipe_points(points, duration)`),不做任何加工。
  与 `swipe` 是两套东西(滑动是参数化的,录制是点列)。
- core/gesture.py(新):
  · 手机端录制 `PhoneRecorder`:`getevent` 读**真触屏**设备(自动挑 fts_ts 这类、
    排除 uinput/vitural-sar 等合成节点),解析两套协议(BTN_TOUCH / ABS_MT_TRACKING_ID);
    **合成的注入事件不会出现在真触屏节点上**(实测),所以录到的只有人手的动作;
  · 点列清洗:按 ≥16ms 抽稀但**末点必留**(快划时不能把收尾丢了);
  · 回放**一次调用**而不是逐点注入:实测这台设备单次触摸 RPC ≈190ms,
    逐点回放 20 点要 3.8 秒——只能让设备自己插值,"时间"由点密度还原。
- 接口:POST /api/gesture/record/{start,stop}(需设备权限;重复开始返回 409)。
- 「录制手势」步骤卡片:录到就回填(手机上录 / 网页上录),显示点数/时长;
  **滑动步骤上的「录制手势」按钮已移除**(按用户要求,录制不再走滑动逻辑)。
- 录制前自动唤醒设备:**息屏时 u2 抓 UI 树会从 3 秒退化到 65 秒**(实测),
  截图也是黑的——这是本次排查最耗时的坑,已写进文档。

自测:假设备单测(点列/时长/抽稀/失败退回直线,10 项);
无头浏览器 + CDP 真模拟拖动 → 录到 14 点/605ms 并按实际轨迹回填;
手机录制接口起停/重复拦截;
真机回放:用录下的轨迹跑任务 → 步骤 `gesture ok`。
⚠️ 手机端"真手指轨迹"这一段需要人真的去划才能端到端验证(我没有手指)。
文档:TASK_DEV §3.2(两套东西对比 + 三个坑)、步骤表 21 种、API、README。
This commit is contained in:
2026-09-20 15:04:21 +08:00
parent f62bc8da28
commit 73895f7233
11 changed files with 569 additions and 76 deletions
+92 -49
View File
@@ -1,12 +1,17 @@
// ================== 动作录制器 ==================
// 在设备截图上操作(点/划/按键/输入),自动翻译成**步骤**:
// · 点 → 命中元素就记「点击元素」(选择器),抓不到就退化成「点击坐标」(百分比)
// · 划 → 记「滑动」:方向 + 幅度(屏占比) + 实际时长
// · 划 → 记**「录制手势」**:完整轨迹点列 [[x,y,t_ms],…],**纯回放**,不再套滑动逻辑
// · 停顿 → 可选记成「等待」(按真实间隔 ±15%)
//
// 两种用法(同一个录窗口):
// 两个录制来源:
// · 网页上录:在这个窗口里按住鼠标拖(采样路径 + 时间)
// · 手机上录:点「手机上录」后**用手指在真机上划**——后端读 getevent 抓真触屏,
// 录到的是人手的真实轨迹(比在网页上模拟更真)
//
// 两种用法(同一个窗口):
// mode='action' —— 录一段,存成「自定义动作」(进动作库,能拖进任何任务)
// mode='gesture' —— 只取最后一个手势,回填当前滑动步骤的参数
// mode='gesture' —— 录到的手势直接回填给调用方(步骤编辑器里的「录制手势」步骤)
//
// 元素树用 /api/uiauto/snapshot 一次取齐(截图 + 元素),**每次操作后自动刷新**,
// 这样下一次点击就能用最新的树翻译选择器(抖音这种没有 id 的界面靠 text/desc)。
@@ -26,6 +31,7 @@ async function openRecorder(opts) {
elements: [],
devW: 0, devH: 0, imgURL: '',
lastTs: 0, dragFrom: null, busy: false, keepPause: true,
samples: [], phone: null, autoPhone: !!opts.autoStartPhone,
shots: 0, unstable: false,
};
const ov = document.createElement('div');
@@ -66,6 +72,7 @@ async function openRecorder(opts) {
document.body.appendChild(ov);
await recLoadDevices();
recRender();
if (_rec.autoPhone && _rec.serial) recStartPhone();
}
function closeRecorder() {
@@ -88,15 +95,25 @@ async function recLoadDevices() {
});
if (_rec.serial) { sel.value = _rec.serial; }
else if ((r.items || []).length) { sel.value = r.items[0].serial; _rec.serial = r.items[0].serial; }
if (_rec.serial) recRefresh();
if (_rec.serial) { await recWake(); recRefresh(); }
}
function recPickDevice() {
async function recPickDevice() {
const sel = document.getElementById('rec-device');
_rec.serial = sel ? sel.value : '';
_rec.elements = [];
recRender();
if (_rec.serial) recRefresh();
if (_rec.serial) { await recWake(); recRefresh(); }
}
// 录制前先唤醒设备:**息屏时 u2 抓 UI 树会退化到几十秒**(实测 65s,唤醒后 3.3s),
// 截图也是一片黑。唤醒是幂等的,多调一次没有副作用。
async function recWake() {
if (!_rec || !_rec.serial) return;
try {
const r = await apiPost('/api/device/screen_all', { mode: 'on', serials: [_rec.serial] });
if (r && r.ok) await new Promise(function (z) { setTimeout(z, 800); });
} catch (e) { /* 唤醒失败也继续,用户自己会看到黑屏 */ }
}
// 取一张快照:截图 + 元素树(同一个连接背靠背取,避免框错位)
@@ -168,6 +185,8 @@ function recDown(e) {
const p = _recPos(e);
if (!p) return;
_rec.dragFrom = p; _rec.dragTo = null; _rec.dragT0 = Date.now();
// 采样:拖动过程中把每个点连时间一起记下来(这就是"轨迹")
_rec.samples = [[p.x, p.y, 0]];
recPaintOverlay();
}
@@ -176,6 +195,11 @@ function recMove(e) {
const p = _recPos(e);
if (!p) return;
_rec.dragTo = p;
const t = Date.now() - _rec.dragT0;
const last = _rec.samples[_rec.samples.length - 1];
if (!last || t - last[2] >= 8 || Math.abs(p.x - last[0]) + Math.abs(p.y - last[1]) >= 3) {
if (_rec.samples.length < 400) _rec.samples.push([p.x, p.y, t]);
}
recPaintOverlay();
}
@@ -188,12 +212,60 @@ async function recUp(e) {
recPaintOverlay();
const dist = Math.hypot(b.x - a.x, b.y - a.y);
if (dist < 12 || durMs < 60) { // 视为点击
_rec.samples = [];
await recTap(a.x, a.y);
} else {
await recSwipe(a, b, durMs);
const pts = _rec.samples.slice();
pts.push([b.x, b.y, durMs]); // 末点补上(决定手势收尾位置)
_rec.samples = [];
recPushGesture(pts, 'web');
await recRefresh();
}
}
// 一条录到的轨迹 → 「录制手势」步骤(**纯回放**:点列+时长原样存下,不再套滑动逻辑)
function recPushGesture(points, src) {
if (!points || points.length < 2) { showToast('没录到有效轨迹(太短了)', 'error'); return; }
if (_rec.mode === 'gesture') { // 回填模式:直接交给调用方,收工
const cb = _rec.onDone;
closeRecorder();
if (cb) cb({ points: points, speed: 1.0 }, src);
return;
}
const dur = points[points.length - 1][2] || 0;
_recPush({ type: 'gesture',
label: '录制手势 ' + points.length + '点/' + (dur / 1000).toFixed(1) + 's',
params: { points: points, speed: 1.0 } });
}
// ---------- 手机端录制(getevent 抓真手指) ----------
async function recStartPhone() {
if (!_rec.serial) { showToast('先选设备', 'error'); return; }
const r = await apiPost('/api/gesture/record/start', { serial: _rec.serial });
if (!r || !r.ok) { showToast((r && r.error) || '开始失败', 'error'); return; }
_rec.phone = { token: r.token, t0: Date.now() };
showToast('开始录制——现在用手指在手机上划', 'success');
recRenderFoot();
}
async function recStopPhone() {
if (!_rec.phone) return;
const token = _rec.phone.token;
_rec.phone = null;
const r = await apiPost('/api/gesture/record/stop', { token: token });
if (!r || !r.ok) { showToast((r && r.error) || '停止失败', 'error'); recRenderFoot(); return; }
const gs = r.gestures || [];
if (!gs.length) {
showToast('没录到手势——请在手机屏幕上用指划一下(不是点)', 'error');
recRenderFoot(); return;
}
if (_rec.mode === 'gesture') { recPushGesture(gs[gs.length - 1], 'phone'); return; }
gs.forEach(function (g) { recPushGesture(g, 'phone'); });
showToast('录到 ' + gs.length + ' 个手势', 'success');
recRenderFoot();
recRefresh();
}
// ---------- 翻译:点击 → 步骤 ----------
function _recFindElement(x, y) {
// 取包含该点的最小元素;优先可点击的(和 /api/screen/tap 的 snap 同一口径)
@@ -237,33 +309,6 @@ function _recShort(el) {
return s ? ('「' + String(s).slice(0, 12) + '」') : '';
}
// ---------- 翻译:滑动 → 步骤 ----------
async function recSwipe(a, b, durMs) {
if (!_rec || _rec.busy) return;
_rec.busy = true;
try {
const dx = b.x - a.x, dy = b.y - a.y;
const vertical = Math.abs(dy) >= Math.abs(dx);
const direction = vertical ? (dy < 0 ? 'up' : 'down') : (dx < 0 ? 'left' : 'right');
const span = Math.abs(vertical ? dy : dx);
const base = vertical ? _rec.devH : _rec.devW;
const ratio = Math.max(0.05, Math.min(0.9, span / base));
const dur = Math.max(0.08, durMs / 1000);
const step = { type: 'swipe', label: '滑动 ' + direction,
params: { direction: direction,
duration_min: +(dur * 0.9).toFixed(2),
duration_max: +(dur * 1.15).toFixed(2),
distance_ratio: +ratio.toFixed(2),
jitter: 0.15, humanize: true } };
_recPush(step);
const r = await apiPost('/api/screen/swipe',
{ serial: _rec.serial, x1: a.x, y1: a.y, x2: b.x, y2: b.y,
duration: Math.round(durMs) / 1000 });
if (r && !r.ok) showToast((r.error || '滑动失败'), 'error');
await recRefresh();
} finally { _rec.busy = false; }
}
async function recKey(key) {
if (!_rec || !_rec.serial) { showToast('先选设备', 'error'); return; }
_recPush({ type: 'key_event', label: '按键 ' + key, params: { key: key } });
@@ -338,26 +383,24 @@ function recRenderFoot() {
if (!foot) return;
const steps = _rec.steps;
if (_rec.mode === 'gesture') {
const sw = steps.filter(function (s) { return s.type === 'swipe'; });
foot.innerHTML = sw.length
? '<button class="btn btn-primary" onclick="recApplyGesture()">用这个手势(共录到 ' + sw.length + ' 个滑动,取最后一个)</button>' +
'<div class="help">把方向/幅度/时长回填到当前滑动步骤</div>'
: '<div class="text-muted" style="font-size:12px">在画面上划一下(按住拖动),然后点这里</div>';
// 回填模式:录到就立刻填回那个步骤,不用再点确认
foot.innerHTML = _rec.phone
? '<button class="btn btn-danger" onclick="recStopPhone()">■ 停止并填入</button>'
: '<button class="btn btn-primary" onclick="recStartPhone()">● 手机上录</button>' +
'<div class="help">或在左边画面上按住鼠标拖一下——录到就立刻填进那一步</div>';
return;
}
const phoneBtn = _rec.phone
? '<button class="btn btn-danger" onclick="recStopPhone()">■ 停止手机录制</button>'
: '<button class="btn btn-primary" onclick="recStartPhone()">● 手机上录</button>';
foot.innerHTML =
'<div class="rec-row" style="margin-bottom:6px">' + phoneBtn +
'<span class="help" style="margin:0">' + (_rec.phone
? '正在录:请用手指在手机屏幕上划,划完点「停止」'
: '手机上用手指划 = 录真手指轨迹;在左边画面上拖 = 网页录制') + '</span></div>' +
'<div class="rec-row"><input id="rec-name" class="form-control" placeholder="动作名,如「抖音-进评论」">' +
'<button class="btn btn-primary" onclick="recSaveAsAction()">存成动作</button></div>' +
'<div class="help">存进「自定义动作」库,之后能拖进任何任务的步骤画布</div>';
}
function recApplyGesture() {
const sw = _rec.steps.filter(function (s) { return s.type === 'swipe'; });
if (!sw.length) return;
const p = sw[sw.length - 1].params;
const cb = _rec.onDone;
closeRecorder();
if (cb) cb(p);
'<div class="help">划出来的每一段都是一条「录制手势」步骤(按原路径与时长回放,不再套滑动逻辑)</div>';
}
async function recSaveAsAction() {