数据截至 (上游 commit 2689884a6257)
事件归并与对齐
这一章讲什么: 录完之后拿到的是一坨噪声——几百个鼠标移动、一次双击被拆成四条记录、大量没人引用的截图。这一章讲怎么把它压成一条干净的动作序列,以及一个额外难题:浏览器里的 DOM 事件怎么和操作系统级事件对上号。
入口是 legacy/openadapt/events.py:22(get_events),它被 Recording.processed_action_events 属性懒调用(legacy/openadapt/models.py:106-118)。
1. 它要解决的小问题
人点一下「保存」按钮,数据库里会留下什么?
move(830,415) move(831,417) move(832,419) ... ×80
click(832,419, pressed=True)
click(832,419, pressed=False)
八十多条记录,表达的是一件事。喂给大模型是浪费上下文,喂给回放器是浪费时间。而且如果人是双击,还会变成四条 click,谁也看不出那是双击。
2. 统一框架:reducer + 父事件
思路
所有归并规则长得一样:扫一遍序列,把连续的同类事件攒起来,攒够了交给一个函数压成更少的事件。 所以抽了一个骨架出来。
merge_consecutive_action_events(name, events, is_target_event, get_merged_events)
遍历 events:
is_target_event(e)? ── 是 ──→ 塞进 to_merge 缓冲
└─ 否 ──→ 先把 to_merge 压掉,再原样放行这个 e
收尾:缓冲里还有就再压一次
每个压出来的事件打上标记 merged_event.reducer_names.add(name)
真实实现:merge_consecutive_action_events(legacy/openadapt/events.py:757-795)。reducer_names 是一个「这个事件被哪些规则处理过」的集合,方便调试时回溯。
归并的产物不是删除,是父子树
这是整个设计里最关键的一点:被归并掉的事件不会消失,而是变成新事件的 children。
make_parent_event(events.py:151-189)造出父事件,把 recording/window_event/screenshot/browser_event 这些关联从子事件继承过来。数据库层面靠 ActionEvent.parent_id 自引用外键实现(models.py:165、:221)。
这层设计在回放时立刻兑现价值:play_action_event(legacy/openadapt/playback.py:97-100)遇到有子事件的键盘事件时,回放的是子事件——因为「输入 hello」这个父事件在语义上好读,但真正要注入的是五次按键。
时间戳压缩
每个 reducer 维护一个 state["dt"] 累计量。事件被吃掉时,它占用的时长加进 dt;后续所有事件的时间戳减去 dt(events.py:495、:785)。
这样归并后的序列没有空洞——回放时如果按时间间隔 sleep,不会莫名其妙停顿。
3. 逐个看几条规则
merge_events(events.py:878-982)里的流水线顺序是固定的,顺序本身有讲究:
| 顺序 | 规则 | 干什么 |
|---|---|---|
| 1 | remove_invalid_keyboard_events | 丢掉 pynput 已知 bug 产生的 <0> 键 |
| 2 | remove_redundant_mouse_move_events | 丢掉几乎没动的移动 |
| 3 | merge_consecutive_keyboard_events | 连续按键合成 type 事件 |
| 4 | merge_consecutive_mouse_move_events | 连续移动合成一次移动 |
| 5 | merge_consecutive_mouse_scroll_events | 连续滚动合成一次滚动 |
| 6 | merge_consecutive_mouse_click_events | press/release 合成 singleclick/doubleclick |
| — | remove_move_before_click | 已被注释掉(见下) |
第一条规则的注释很有意思(events.py:514-515):它丢的是 str(event.key) == "<0>" 的事件,并附了 pynput 的 issue 号 #481。上游库的 bug 在这里被硬编码成一条过滤规则。
双击是怎么被认出来的
这是最值得细看的一条。merge_consecutive_mouse_click_events(events.py:378-504)分两步。
第一步,建两张时间戳映射表(get_timestamp_mappings,:405-441):
press_to_press_t : 某次按下 → 紧随其后、且够近够快的下一次按下
press_to_release_t : 某次按下 → 它对应的松开
判定条件同时看时间和距离:
# 真实源码节选,legacy/openadapt/events.py:429-433
if (
dt <= double_click_interval
and dx <= double_click_distance
and dy <= double_click_distance
):
press_to_press_t[prev_pressed_event.timestamp] = event.timestamp
double_click_interval 和 double_click_distance 优先从 Recording 上读(录制那台机器的系统设置),读不到才回退到当前机器的值,并打 warning(get_recording_attr,:391-399)。
第二步,按映射表合成(get_merged_events,:443-497):
某次按下在 press_to_press_t 里 ──→ 造 doubleclick,吃掉 4 个子事件
否则在 press_to_release_t 里 ──→ 造 singleclick,吃掉 2 个子事件
否则 ──→ 原样保留
吃掉的时间戳进 skip_timestamps 集合,循环里遇到就跳过。