managed-deepagents 0.9.6-dev.19 → 0.9.6-dev.20
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/README.md +1075 -37
- package/dist/agent-auth.d.ts +1 -1
- package/dist/agent-auth.d.ts.map +1 -1
- package/dist/agent-auth.js +36 -12
- package/dist/agent-auth.js.map +1 -1
- package/dist/browser.d.ts +37 -0
- package/dist/browser.d.ts.map +1 -0
- package/dist/browser.js +31 -0
- package/dist/browser.js.map +1 -0
- package/dist/channels/actor.d.ts +57 -0
- package/dist/channels/actor.d.ts.map +1 -0
- package/dist/channels/actor.js +24 -0
- package/dist/channels/actor.js.map +1 -0
- package/dist/channels/assistant-text.d.ts +29 -0
- package/dist/channels/assistant-text.d.ts.map +1 -1
- package/dist/channels/assistant-text.js +72 -1
- package/dist/channels/assistant-text.js.map +1 -1
- package/dist/channels/binding.d.ts +20 -1
- package/dist/channels/binding.d.ts.map +1 -1
- package/dist/channels/binding.js +44 -11
- package/dist/channels/binding.js.map +1 -1
- package/dist/channels/delivery.d.ts +51 -0
- package/dist/channels/delivery.d.ts.map +1 -0
- package/dist/channels/delivery.js +424 -0
- package/dist/channels/delivery.js.map +1 -0
- package/dist/channels/github/app-setup.d.ts +32 -0
- package/dist/channels/github/app-setup.d.ts.map +1 -0
- package/dist/channels/github/app-setup.js +146 -0
- package/dist/channels/github/app-setup.js.map +1 -0
- package/dist/channels/github/connections.d.ts +11 -0
- package/dist/channels/github/connections.d.ts.map +1 -0
- package/dist/channels/github/connections.js +14 -0
- package/dist/channels/github/connections.js.map +1 -0
- package/dist/channels/github/index.d.ts +12 -0
- package/dist/channels/github/index.d.ts.map +1 -0
- package/dist/channels/github/index.js +266 -0
- package/dist/channels/github/index.js.map +1 -0
- package/dist/channels/github/mentions.d.ts +12 -0
- package/dist/channels/github/mentions.d.ts.map +1 -0
- package/dist/channels/github/mentions.js +185 -0
- package/dist/channels/github/mentions.js.map +1 -0
- package/dist/channels/github/schema.d.ts +4165 -0
- package/dist/channels/github/schema.d.ts.map +1 -0
- package/dist/channels/github/schema.js +221 -0
- package/dist/channels/github/schema.js.map +1 -0
- package/dist/channels/github/types.d.ts +170 -0
- package/dist/channels/github/types.d.ts.map +1 -0
- package/dist/channels/github/types.js +2 -0
- package/dist/channels/github/types.js.map +1 -0
- package/dist/channels/http/endpoint.d.ts +137 -0
- package/dist/channels/http/endpoint.d.ts.map +1 -0
- package/dist/channels/http/endpoint.js +286 -0
- package/dist/channels/http/endpoint.js.map +1 -0
- package/dist/channels/http/http.d.ts.map +1 -1
- package/dist/channels/http/http.js +439 -65
- package/dist/channels/http/http.js.map +1 -1
- package/dist/channels/http/index.d.ts +8 -1
- package/dist/channels/http/index.d.ts.map +1 -1
- package/dist/channels/http/index.js +35 -16
- package/dist/channels/http/index.js.map +1 -1
- package/dist/channels/http/ingress.d.ts +67 -5
- package/dist/channels/http/ingress.d.ts.map +1 -1
- package/dist/channels/http/ingress.js +155 -43
- package/dist/channels/http/ingress.js.map +1 -1
- package/dist/channels/http/provider.d.ts +125 -0
- package/dist/channels/http/provider.d.ts.map +1 -0
- package/dist/channels/http/provider.js +227 -0
- package/dist/channels/http/provider.js.map +1 -0
- package/dist/channels/http/studio.d.ts +24 -0
- package/dist/channels/http/studio.d.ts.map +1 -0
- package/dist/channels/http/studio.js +95 -0
- package/dist/channels/http/studio.js.map +1 -0
- package/dist/channels/http/threads.d.ts +7 -0
- package/dist/channels/http/threads.d.ts.map +1 -0
- package/dist/channels/http/threads.js +10 -0
- package/dist/channels/http/threads.js.map +1 -0
- package/dist/channels/http/types.d.ts +166 -2
- package/dist/channels/http/types.d.ts.map +1 -1
- package/dist/channels/linear/app-setup.d.ts +29 -0
- package/dist/channels/linear/app-setup.d.ts.map +1 -0
- package/dist/channels/linear/app-setup.js +102 -0
- package/dist/channels/linear/app-setup.js.map +1 -0
- package/dist/channels/linear/connections.d.ts +11 -0
- package/dist/channels/linear/connections.d.ts.map +1 -0
- package/dist/channels/linear/connections.js +14 -0
- package/dist/channels/linear/connections.js.map +1 -0
- package/dist/channels/linear/graphql.d.ts +57 -0
- package/dist/channels/linear/graphql.d.ts.map +1 -0
- package/dist/channels/linear/graphql.js +167 -0
- package/dist/channels/linear/graphql.js.map +1 -0
- package/dist/channels/linear/index.d.ts +13 -0
- package/dist/channels/linear/index.d.ts.map +1 -0
- package/dist/channels/linear/index.js +457 -0
- package/dist/channels/linear/index.js.map +1 -0
- package/dist/channels/linear/schema.d.ts +1050 -0
- package/dist/channels/linear/schema.d.ts.map +1 -0
- package/dist/channels/linear/schema.js +255 -0
- package/dist/channels/linear/schema.js.map +1 -0
- package/dist/channels/linear/types.d.ts +169 -0
- package/dist/channels/linear/types.d.ts.map +1 -0
- package/dist/channels/linear/types.js +2 -0
- package/dist/channels/linear/types.js.map +1 -0
- package/dist/channels/message-schema.d.ts +2 -0
- package/dist/channels/message-schema.d.ts.map +1 -1
- package/dist/channels/message-schema.js +4 -0
- package/dist/channels/message-schema.js.map +1 -1
- package/dist/channels/messaging-registry.d.ts +16 -0
- package/dist/channels/messaging-registry.d.ts.map +1 -1
- package/dist/channels/messaging-registry.js +29 -0
- package/dist/channels/messaging-registry.js.map +1 -1
- package/dist/channels/reserved-connections.d.ts +12 -0
- package/dist/channels/reserved-connections.d.ts.map +1 -0
- package/dist/channels/reserved-connections.js +55 -0
- package/dist/channels/reserved-connections.js.map +1 -0
- package/dist/channels/resolve-user-endpoint.d.ts +34 -0
- package/dist/channels/resolve-user-endpoint.d.ts.map +1 -0
- package/dist/channels/resolve-user-endpoint.js +51 -0
- package/dist/channels/resolve-user-endpoint.js.map +1 -0
- package/dist/channels/resolve-user.d.ts +95 -0
- package/dist/channels/resolve-user.d.ts.map +1 -0
- package/dist/channels/resolve-user.js +143 -0
- package/dist/channels/resolve-user.js.map +1 -0
- package/dist/channels/run-input.d.ts +88 -0
- package/dist/channels/run-input.d.ts.map +1 -0
- package/dist/channels/run-input.js +177 -0
- package/dist/channels/run-input.js.map +1 -0
- package/dist/channels/runtime.d.ts +5 -2
- package/dist/channels/runtime.d.ts.map +1 -1
- package/dist/channels/runtime.js +19 -10
- package/dist/channels/runtime.js.map +1 -1
- package/dist/channels/schedule-delivery.d.ts +2 -13
- package/dist/channels/schedule-delivery.d.ts.map +1 -1
- package/dist/channels/schedule-delivery.js +6 -73
- package/dist/channels/schedule-delivery.js.map +1 -1
- package/dist/channels/slack/api.d.ts +2 -0
- package/dist/channels/slack/api.d.ts.map +1 -1
- package/dist/channels/slack/api.js +6 -0
- package/dist/channels/slack/api.js.map +1 -1
- package/dist/channels/slack/connections.d.ts +6 -0
- package/dist/channels/slack/connections.d.ts.map +1 -0
- package/dist/channels/slack/connections.js +6 -0
- package/dist/channels/slack/connections.js.map +1 -0
- package/dist/channels/slack/events.d.ts +278 -0
- package/dist/channels/slack/events.d.ts.map +1 -0
- package/dist/channels/slack/events.js +235 -0
- package/dist/channels/slack/events.js.map +1 -0
- package/dist/channels/slack/files.d.ts.map +1 -1
- package/dist/channels/slack/files.js +2 -3
- package/dist/channels/slack/files.js.map +1 -1
- package/dist/channels/slack/index.d.ts +90 -2
- package/dist/channels/slack/index.d.ts.map +1 -1
- package/dist/channels/slack/index.js +188 -6
- package/dist/channels/slack/index.js.map +1 -1
- package/dist/channels/slack/message-content.d.ts.map +1 -1
- package/dist/channels/slack/message-content.js +23 -28
- package/dist/channels/slack/message-content.js.map +1 -1
- package/dist/channels/slack/status.d.ts +76 -0
- package/dist/channels/slack/status.d.ts.map +1 -0
- package/dist/channels/slack/status.js +427 -0
- package/dist/channels/slack/status.js.map +1 -0
- package/dist/channels/source-providers.js +1 -1
- package/dist/channels/source-providers.js.map +1 -1
- package/dist/channels/trigger/client.d.ts.map +1 -1
- package/dist/channels/trigger/client.js +6 -9
- package/dist/channels/trigger/client.js.map +1 -1
- package/dist/channels/trigger/conversation.d.ts +11 -0
- package/dist/channels/trigger/conversation.d.ts.map +1 -0
- package/dist/channels/trigger/conversation.js +72 -0
- package/dist/channels/trigger/conversation.js.map +1 -0
- package/dist/channels/trigger/http.d.ts +130 -4
- package/dist/channels/trigger/http.d.ts.map +1 -1
- package/dist/channels/trigger/http.js +408 -78
- package/dist/channels/trigger/http.js.map +1 -1
- package/dist/channels/trigger/ingress.d.ts +70 -1
- package/dist/channels/trigger/ingress.d.ts.map +1 -1
- package/dist/channels/trigger/ingress.js +130 -19
- package/dist/channels/trigger/ingress.js.map +1 -1
- package/dist/channels/trigger/transport.d.ts +10 -0
- package/dist/channels/trigger/transport.d.ts.map +1 -1
- package/dist/channels/trigger/transport.js +25 -9
- package/dist/channels/trigger/transport.js.map +1 -1
- package/dist/channels/trigger/types.d.ts +92 -3
- package/dist/channels/trigger/types.d.ts.map +1 -1
- package/dist/channels/trigger/types.js +37 -0
- package/dist/channels/trigger/types.js.map +1 -1
- package/dist/channels/types.d.ts +53 -4
- package/dist/channels/types.d.ts.map +1 -1
- package/dist/channels/types.js +2 -0
- package/dist/channels/types.js.map +1 -1
- package/dist/connections.d.ts +35 -1
- package/dist/connections.d.ts.map +1 -1
- package/dist/connections.js +121 -10
- package/dist/connections.js.map +1 -1
- package/dist/credential-gate.d.ts +2 -0
- package/dist/credential-gate.d.ts.map +1 -1
- package/dist/credential-gate.js +37 -3
- package/dist/credential-gate.js.map +1 -1
- package/dist/define-deep-agent.d.ts +2 -2
- package/dist/define-deep-agent.d.ts.map +1 -1
- package/dist/define-deep-agent.js +53 -2
- package/dist/define-deep-agent.js.map +1 -1
- package/dist/identity/index.d.ts.map +1 -1
- package/dist/identity/index.js +3 -1
- package/dist/identity/index.js.map +1 -1
- package/dist/identity/options.d.ts +9 -0
- package/dist/identity/options.d.ts.map +1 -1
- package/dist/identity/options.js +1 -1
- package/dist/identity/options.js.map +1 -1
- package/dist/identity/types.d.ts +3 -0
- package/dist/identity/types.d.ts.map +1 -1
- package/dist/index.d.ts +19 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/ingress/loopback.d.ts +1 -0
- package/dist/ingress/loopback.d.ts.map +1 -1
- package/dist/ingress/loopback.js +4 -1
- package/dist/ingress/loopback.js.map +1 -1
- package/dist/ingress/runs.d.ts +74 -1
- package/dist/ingress/runs.d.ts.map +1 -1
- package/dist/ingress/runs.js +184 -9
- package/dist/ingress/runs.js.map +1 -1
- package/dist/managed-composition.d.ts +3 -0
- package/dist/managed-composition.d.ts.map +1 -1
- package/dist/managed-composition.js +29 -13
- package/dist/managed-composition.js.map +1 -1
- package/dist/namespaces.d.ts +25 -21
- package/dist/namespaces.d.ts.map +1 -1
- package/dist/namespaces.js +4 -29
- package/dist/namespaces.js.map +1 -1
- package/dist/runtime/async-subagents.d.ts +27 -0
- package/dist/runtime/async-subagents.d.ts.map +1 -0
- package/dist/runtime/async-subagents.js +139 -0
- package/dist/runtime/async-subagents.js.map +1 -0
- package/dist/runtime/auth.d.ts.map +1 -1
- package/dist/runtime/auth.js +177 -8
- package/dist/runtime/auth.js.map +1 -1
- package/dist/runtime/backend.d.ts +7 -0
- package/dist/runtime/backend.d.ts.map +1 -1
- package/dist/runtime/backend.js +32 -7
- package/dist/runtime/backend.js.map +1 -1
- package/dist/runtime/browser-facade.d.ts +10 -0
- package/dist/runtime/browser-facade.d.ts.map +1 -0
- package/dist/runtime/browser-facade.js +23 -0
- package/dist/runtime/browser-facade.js.map +1 -0
- package/dist/runtime/browser-langsmith.d.ts +143 -0
- package/dist/runtime/browser-langsmith.d.ts.map +1 -0
- package/dist/runtime/browser-langsmith.js +508 -0
- package/dist/runtime/browser-langsmith.js.map +1 -0
- package/dist/runtime/browser-recorder.py +831 -0
- package/dist/runtime/browser-selection.d.ts +10 -0
- package/dist/runtime/browser-selection.d.ts.map +1 -0
- package/dist/runtime/browser-selection.js +21 -0
- package/dist/runtime/browser-selection.js.map +1 -0
- package/dist/runtime/browser.d.ts +128 -0
- package/dist/runtime/browser.d.ts.map +1 -0
- package/dist/runtime/browser.js +795 -0
- package/dist/runtime/browser.js.map +1 -0
- package/dist/runtime/channel-icon.d.ts +22 -0
- package/dist/runtime/channel-icon.d.ts.map +1 -0
- package/dist/runtime/channel-icon.js +81 -0
- package/dist/runtime/channel-icon.js.map +1 -0
- package/dist/runtime/channel.d.ts +11 -0
- package/dist/runtime/channel.d.ts.map +1 -1
- package/dist/runtime/channel.js.map +1 -1
- package/dist/runtime/identity-http.d.ts +2 -0
- package/dist/runtime/identity-http.d.ts.map +1 -1
- package/dist/runtime/identity-http.js +38 -2
- package/dist/runtime/identity-http.js.map +1 -1
- package/dist/runtime/identity-runtime.d.ts +10 -0
- package/dist/runtime/identity-runtime.d.ts.map +1 -1
- package/dist/runtime/identity-runtime.js +22 -0
- package/dist/runtime/identity-runtime.js.map +1 -1
- package/dist/runtime/index.d.ts +38 -1
- package/dist/runtime/index.d.ts.map +1 -1
- package/dist/runtime/index.js +76 -34
- package/dist/runtime/index.js.map +1 -1
- package/dist/runtime/managed-thread.d.ts +61 -0
- package/dist/runtime/managed-thread.d.ts.map +1 -0
- package/dist/runtime/managed-thread.js +76 -0
- package/dist/runtime/managed-thread.js.map +1 -0
- package/dist/runtime/managed-tools.d.ts +10 -2
- package/dist/runtime/managed-tools.d.ts.map +1 -1
- package/dist/runtime/managed-tools.js +10 -8
- package/dist/runtime/managed-tools.js.map +1 -1
- package/dist/runtime/memory/policy.d.ts.map +1 -1
- package/dist/runtime/memory/policy.js +1 -3
- package/dist/runtime/memory/policy.js.map +1 -1
- package/dist/runtime/parent-run.d.ts +33 -0
- package/dist/runtime/parent-run.d.ts.map +1 -0
- package/dist/runtime/parent-run.js +114 -0
- package/dist/runtime/parent-run.js.map +1 -0
- package/dist/runtime/run-input.d.ts +14 -1
- package/dist/runtime/run-input.d.ts.map +1 -1
- package/dist/runtime/run-input.js +100 -7
- package/dist/runtime/run-input.js.map +1 -1
- package/dist/runtime/sandbox-manager.d.ts +15 -2
- package/dist/runtime/sandbox-manager.d.ts.map +1 -1
- package/dist/runtime/sandbox-manager.js +37 -12
- package/dist/runtime/sandbox-manager.js.map +1 -1
- package/dist/runtime/schedule-auth.d.ts +79 -0
- package/dist/runtime/schedule-auth.d.ts.map +1 -1
- package/dist/runtime/schedule-auth.js +163 -6
- package/dist/runtime/schedule-auth.js.map +1 -1
- package/dist/runtime/static-entry.d.ts.map +1 -1
- package/dist/runtime/static-entry.js +1 -0
- package/dist/runtime/static-entry.js.map +1 -1
- package/dist/runtime/thread-record.d.ts +88 -0
- package/dist/runtime/thread-record.d.ts.map +1 -0
- package/dist/runtime/thread-record.js +213 -0
- package/dist/runtime/thread-record.js.map +1 -0
- package/dist/runtime/types.d.ts +12 -1
- package/dist/runtime/types.d.ts.map +1 -1
- package/dist/runtime-schedules.d.ts +15 -0
- package/dist/runtime-schedules.d.ts.map +1 -1
- package/dist/runtime-schedules.js +83 -20
- package/dist/runtime-schedules.js.map +1 -1
- package/dist/sandbox.d.ts +0 -6
- package/dist/sandbox.d.ts.map +1 -1
- package/dist/sandbox.js +3 -7
- package/dist/sandbox.js.map +1 -1
- package/dist/schedule-channel.d.ts +5 -2
- package/dist/schedule-channel.d.ts.map +1 -1
- package/dist/schedule-channel.js +13 -12
- package/dist/schedule-channel.js.map +1 -1
- package/dist/types.d.ts +19 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/values.d.ts +15 -0
- package/dist/values.d.ts.map +1 -0
- package/dist/values.js +31 -0
- package/dist/values.js.map +1 -0
- package/package.json +23 -9
|
@@ -0,0 +1,831 @@
|
|
|
1
|
+
"""Recordings of a browser window's tab: its screencast, encoded as an H.264 mp4.
|
|
2
|
+
|
|
3
|
+
A program for the browser's own sandbox. ``browser.py`` uploads it there and runs it
|
|
4
|
+
beside Chrome, on the window's local CDP port, so the screencast never crosses the
|
|
5
|
+
network and a take outlives the agent's client. It runs with PyAV, Pillow and
|
|
6
|
+
websockets, which ``browser.py`` installs there; the agent server never imports it.
|
|
7
|
+
|
|
8
|
+
python3 _browser_recording.py start FOLDER CDP_URL TAB # returns once recording
|
|
9
|
+
python3 _browser_recording.py stop FOLDER # returns once FOLDER holds the take
|
|
10
|
+
|
|
11
|
+
The recorder holds its own CDP connection to the window, beside Stagehand's, and
|
|
12
|
+
follows the window's newest tab, where the agent's tools act. It pins the viewport, so
|
|
13
|
+
takes match, and notes the pointer, clicks and typing in an isolated world, which the
|
|
14
|
+
page's own scripts cannot see. The cursor, clicks and keys are drawn onto the frames
|
|
15
|
+
when encoding, so nothing is drawn in the page. Typing into password, one-time-code
|
|
16
|
+
and card fields is masked as it is noted, and URLs lose their query and fragment.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import asyncio
|
|
22
|
+
import base64
|
|
23
|
+
import bisect
|
|
24
|
+
import contextlib
|
|
25
|
+
import functools
|
|
26
|
+
import io
|
|
27
|
+
import itertools
|
|
28
|
+
import json
|
|
29
|
+
import math
|
|
30
|
+
import os
|
|
31
|
+
import signal
|
|
32
|
+
import subprocess
|
|
33
|
+
import sys
|
|
34
|
+
import tempfile
|
|
35
|
+
import time
|
|
36
|
+
from dataclasses import dataclass, field
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
39
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
40
|
+
|
|
41
|
+
import av
|
|
42
|
+
import websockets
|
|
43
|
+
from PIL import Image, ImageDraw, ImageFont, ImageOps
|
|
44
|
+
|
|
45
|
+
if TYPE_CHECKING:
|
|
46
|
+
from collections.abc import Iterator
|
|
47
|
+
|
|
48
|
+
VIEWPORT = (1280, 800)
|
|
49
|
+
FPS = 15
|
|
50
|
+
MAX_HOLD_S = 1.5 # a pause, such as the model thinking between calls, plays this long at most
|
|
51
|
+
END_HOLD_S = 1.0 # the last frame, so the result is readable
|
|
52
|
+
MAX_S = 300 # of real time
|
|
53
|
+
_MAX_EVENTS = 20_000
|
|
54
|
+
# What the kept frames may hold in memory, in the browser's sandbox beside Chrome: a page
|
|
55
|
+
# that keeps changing reaches it before MAX_S.
|
|
56
|
+
_MAX_FRAME_BYTES = 1_000_000_000
|
|
57
|
+
_TIMEOUT_S = 30
|
|
58
|
+
_QUALITY = 90
|
|
59
|
+
# Frames keep up to this many device pixels per CSS pixel: a HiDPI browser paints two,
|
|
60
|
+
# and text scaled down to the viewport's CSS size blurs.
|
|
61
|
+
_DEVICE_SCALE = 2
|
|
62
|
+
_URL_CHARS = 200
|
|
63
|
+
_MASK = "*" * 8 # typing in a sensitive field, whatever its length; the font has "*"
|
|
64
|
+
_SKEW_S = 5.0 # page and frame times further off than this are not on the box's clock
|
|
65
|
+
_WORLD = "mda-recording"
|
|
66
|
+
_BINDING = "__mdaRecordEvent"
|
|
67
|
+
# Notes input in the page's top frame, in CSS pixels. Clicks and keys name their element
|
|
68
|
+
# by tag, id and label, never by value; sensitive fields' typing is masked here.
|
|
69
|
+
_SCRIPT = f"""
|
|
70
|
+
if (self === top && !globalThis.{_BINDING}Installed) {{
|
|
71
|
+
globalThis.{_BINDING}Installed = true;
|
|
72
|
+
// When it happened, on the box's clock, which Chrome's frame times share.
|
|
73
|
+
const send = (e, event) => globalThis.{_BINDING}(JSON.stringify(
|
|
74
|
+
{{...event, at: performance.timeOrigin + e.timeStamp}}));
|
|
75
|
+
const secret = (el) => !!el && (el.type === "password" ||
|
|
76
|
+
/one-time-code|cc-/.test(el.getAttribute?.("autocomplete") || ""));
|
|
77
|
+
const describe = (el) => {{
|
|
78
|
+
if (!(el instanceof Element)) return "";
|
|
79
|
+
const t = el.closest("a,button,input,textarea,select,summary,label,[role]") || el;
|
|
80
|
+
const name = (t.getAttribute("aria-label") || (/^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName)
|
|
81
|
+
? "" : t.innerText) || t.getAttribute("placeholder") || t.getAttribute("title") ||
|
|
82
|
+
t.getAttribute("name") || "").trim().replace(/\\s+/g, " ").slice(0, 60);
|
|
83
|
+
return t.tagName.toLowerCase() + (t.id ? "#" + t.id : "") + (name ? ` "${{name}}"` : "");
|
|
84
|
+
}};
|
|
85
|
+
let moved = 0;
|
|
86
|
+
addEventListener("pointermove", (e) => {{
|
|
87
|
+
if (e.timeStamp - moved < 40) return;
|
|
88
|
+
moved = e.timeStamp;
|
|
89
|
+
send(e, {{type: "move", x: e.clientX, y: e.clientY}});
|
|
90
|
+
}}, true);
|
|
91
|
+
addEventListener("pointerdown", (e) => {{
|
|
92
|
+
send(e, {{type: "click", x: e.clientX, y: e.clientY, target: describe(e.target)}});
|
|
93
|
+
}}, true);
|
|
94
|
+
addEventListener("keydown", (e) => {{
|
|
95
|
+
const chord = e.ctrlKey || e.metaKey || e.altKey;
|
|
96
|
+
if ((e.key.length === 1 && !chord) || /^(Shift|Control|Alt|Meta)$/.test(e.key)) return;
|
|
97
|
+
const key = (e.metaKey ? "Cmd+" : "") + (e.ctrlKey ? "Ctrl+" : "") +
|
|
98
|
+
(e.altKey ? "Alt+" : "") + (e.key === " " ? "Space" : e.key);
|
|
99
|
+
send(e, {{type: "key", text: key, target: describe(document.activeElement)}});
|
|
100
|
+
}}, true);
|
|
101
|
+
addEventListener("input", (e) => {{
|
|
102
|
+
if (!e.inputType?.startsWith("insert")) return;
|
|
103
|
+
const text = e.data ?? e.target?.value ?? "";
|
|
104
|
+
const typed = secret(e.target) ? "{_MASK}" : text;
|
|
105
|
+
send(e, {{type: "input", text: typed, target: describe(e.target)}});
|
|
106
|
+
}}, true);
|
|
107
|
+
}}
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
EventKind = Literal["move", "click", "key", "input", "navigate"]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@dataclass
|
|
114
|
+
class Frame:
|
|
115
|
+
at: float # when Chrome painted it, in epoch seconds
|
|
116
|
+
jpeg: bytes
|
|
117
|
+
width: int # the page's CSS size, which events are measured in
|
|
118
|
+
height: int
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@dataclass
|
|
122
|
+
class Event:
|
|
123
|
+
at: float # when it happened in the page, in epoch seconds
|
|
124
|
+
kind: EventKind
|
|
125
|
+
x: float = 0.0
|
|
126
|
+
y: float = 0.0
|
|
127
|
+
text: str = "" # a key, typed text, or a url
|
|
128
|
+
target: str = "" # the element, e.g. `button "Save"`
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@dataclass
|
|
132
|
+
class Take:
|
|
133
|
+
"""What a recording captured."""
|
|
134
|
+
|
|
135
|
+
frames: list[Frame] = field(default_factory=list)
|
|
136
|
+
events: list[Event] = field(default_factory=list)
|
|
137
|
+
capped: bool = False # hit MAX_S or a limit, so it ends early
|
|
138
|
+
closed: bool = False # the browser went away, so it ends early
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class Recorder:
|
|
142
|
+
"""Records the window at ``cdp_url`` from one tab on, following each newer tab."""
|
|
143
|
+
|
|
144
|
+
def __init__(self, cdp_url: str) -> None:
|
|
145
|
+
self.cdp_url = cdp_url # the window's local port: no credentials
|
|
146
|
+
self.take = Take()
|
|
147
|
+
self.target: str | None = None
|
|
148
|
+
self.done = asyncio.Event() # told to stop, or the take hit a limit
|
|
149
|
+
self.reader: asyncio.Task[None] | None = None
|
|
150
|
+
self._session: str | None = None
|
|
151
|
+
self._ws: websockets.ClientConnection | None = None
|
|
152
|
+
self._ids = itertools.count(1)
|
|
153
|
+
self._replies: dict[int, asyncio.Future[dict[str, Any]]] = {}
|
|
154
|
+
self._tabs: list[str] = [] # the window's tabs, oldest first
|
|
155
|
+
self._following = asyncio.Lock()
|
|
156
|
+
self._tasks: set[asyncio.Task[None]] = set() # held until done
|
|
157
|
+
self._acted = -math.inf # when the last click, key, typing or navigation was noted
|
|
158
|
+
self._frame_bytes = 0 # the kept frames' JPEGs
|
|
159
|
+
|
|
160
|
+
async def start(self, target_id: str) -> None:
|
|
161
|
+
self._ws = await websockets.connect(self.cdp_url, max_size=2**25) # in 10 s or fails
|
|
162
|
+
self.reader = asyncio.create_task(self._read(self._ws))
|
|
163
|
+
targets = (await self._send("Target.getTargets"))["targetInfos"]
|
|
164
|
+
self._tabs = [t["targetId"] for t in targets if t["type"] == "page"]
|
|
165
|
+
await self._send("Target.setDiscoverTargets", discover=True) # tabs opened later
|
|
166
|
+
await self.follow(target_id)
|
|
167
|
+
|
|
168
|
+
async def follow(self, target_id: str) -> None:
|
|
169
|
+
"""Record ``target_id`` from now on, if it is not the tab being recorded."""
|
|
170
|
+
async with self._following:
|
|
171
|
+
if target_id == self.target:
|
|
172
|
+
return
|
|
173
|
+
attached = await self._send("Target.attachToTarget", targetId=target_id, flatten=True)
|
|
174
|
+
left, self._session, self.target = self._session, attached["sessionId"], target_id
|
|
175
|
+
if left:
|
|
176
|
+
with contextlib.suppress(Exception): # the tab may have closed
|
|
177
|
+
await self._send("Target.detachFromTarget", sessionId=left)
|
|
178
|
+
width, height = VIEWPORT
|
|
179
|
+
on = self._on_tab
|
|
180
|
+
await on(
|
|
181
|
+
"Emulation.setDeviceMetricsOverride",
|
|
182
|
+
width=width,
|
|
183
|
+
height=height,
|
|
184
|
+
deviceScaleFactor=0,
|
|
185
|
+
mobile=False,
|
|
186
|
+
)
|
|
187
|
+
await on("Emulation.setFocusEmulationEnabled", enabled=True) # paints unfocused tabs
|
|
188
|
+
await on("Page.enable") # so the script runs in each new document
|
|
189
|
+
await on("Runtime.enable")
|
|
190
|
+
await on("Runtime.addBinding", name=_BINDING, executionContextName=_WORLD)
|
|
191
|
+
await on("Page.addScriptToEvaluateOnNewDocument", source=_SCRIPT, worldName=_WORLD)
|
|
192
|
+
tree = (await on("Page.getFrameTree"))["frameTree"]["frame"]
|
|
193
|
+
self._navigated(time.time(), tree.get("url", ""))
|
|
194
|
+
world = await on("Page.createIsolatedWorld", frameId=tree["id"], worldName=_WORLD)
|
|
195
|
+
await on("Runtime.evaluate", expression=_SCRIPT, contextId=world["executionContextId"])
|
|
196
|
+
await on(
|
|
197
|
+
"Page.startScreencast",
|
|
198
|
+
format="jpeg",
|
|
199
|
+
quality=_QUALITY,
|
|
200
|
+
maxWidth=width * _DEVICE_SCALE,
|
|
201
|
+
maxHeight=height * _DEVICE_SCALE,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
async def stop(self) -> Take:
|
|
205
|
+
"""Stop recording, ending on how the tab looks now: the last action's result may
|
|
206
|
+
not have reached a screencast frame. Closing drops the viewport and focus overrides.
|
|
207
|
+
"""
|
|
208
|
+
frames = self.take.frames
|
|
209
|
+
if self._session and not self.take.capped:
|
|
210
|
+
with contextlib.suppress(Exception): # the tab may have closed
|
|
211
|
+
shot = await self._on_tab("Page.captureScreenshot", format="jpeg", quality=_QUALITY)
|
|
212
|
+
width, height = (frames[-1].width, frames[-1].height) if frames else VIEWPORT
|
|
213
|
+
frames.append(Frame(time.time(), base64.b64decode(shot["data"]), width, height))
|
|
214
|
+
if self.reader:
|
|
215
|
+
self.reader.cancel()
|
|
216
|
+
with contextlib.suppress(asyncio.CancelledError, Exception):
|
|
217
|
+
await self.reader # so a window that went away is not logged as a crash
|
|
218
|
+
if self._ws:
|
|
219
|
+
with contextlib.suppress(Exception):
|
|
220
|
+
await self._ws.close()
|
|
221
|
+
return self.take
|
|
222
|
+
|
|
223
|
+
async def _on_tab(self, method: str, **params: object) -> dict[str, Any]:
|
|
224
|
+
return await self._send(method, session=self._session, **params)
|
|
225
|
+
|
|
226
|
+
async def _send(
|
|
227
|
+
self, method: str, session: str | None = None, **params: object
|
|
228
|
+
) -> dict[str, Any]:
|
|
229
|
+
if self._ws is None:
|
|
230
|
+
msg = "the recording stopped"
|
|
231
|
+
raise RuntimeError(msg)
|
|
232
|
+
id_ = next(self._ids)
|
|
233
|
+
message: dict[str, object] = {"id": id_, "method": method, "params": params}
|
|
234
|
+
if session:
|
|
235
|
+
message["sessionId"] = session
|
|
236
|
+
self._replies[id_] = reply = asyncio.get_running_loop().create_future()
|
|
237
|
+
try:
|
|
238
|
+
await self._ws.send(json.dumps(message))
|
|
239
|
+
result = await asyncio.wait_for(reply, _TIMEOUT_S)
|
|
240
|
+
finally:
|
|
241
|
+
self._replies.pop(id_, None)
|
|
242
|
+
if "error" in result:
|
|
243
|
+
msg = f"CDP {method} failed: {result['error']}"
|
|
244
|
+
raise RuntimeError(msg)
|
|
245
|
+
return result.get("result", {})
|
|
246
|
+
|
|
247
|
+
async def _read(self, ws: websockets.ClientConnection) -> None:
|
|
248
|
+
async for raw in ws:
|
|
249
|
+
message = json.loads(raw)
|
|
250
|
+
if (id_ := message.get("id")) is not None:
|
|
251
|
+
if (reply := self._replies.get(id_)) and not reply.done():
|
|
252
|
+
reply.set_result(message)
|
|
253
|
+
continue
|
|
254
|
+
method, params, now = message.get("method"), message.get("params", {}), time.time()
|
|
255
|
+
if method == "Target.targetCreated":
|
|
256
|
+
self._opened(params["targetInfo"])
|
|
257
|
+
elif method == "Target.targetDestroyed":
|
|
258
|
+
self._closed(params["targetId"])
|
|
259
|
+
elif message.get("sessionId") != self._session:
|
|
260
|
+
continue # a tab we stopped following
|
|
261
|
+
elif method == "Page.screencastFrame":
|
|
262
|
+
await self._frame(ws, params, now)
|
|
263
|
+
elif method == "Runtime.bindingCalled" and params["name"] == _BINDING:
|
|
264
|
+
with contextlib.suppress(ValueError, KeyError, TypeError):
|
|
265
|
+
self._note(_event(now, json.loads(params["payload"])))
|
|
266
|
+
elif method == "Page.frameNavigated" and not params["frame"].get("parentId"):
|
|
267
|
+
self._navigated(now, params["frame"]["url"])
|
|
268
|
+
|
|
269
|
+
def _opened(self, target: dict[str, Any]) -> None:
|
|
270
|
+
"""A tab opened: follow it, as the agent's tools act in the newest tab."""
|
|
271
|
+
if target.get("type") == "page" and target["targetId"] not in self._tabs:
|
|
272
|
+
self._tabs.append(target["targetId"])
|
|
273
|
+
self._spawn(self._follow_newest())
|
|
274
|
+
|
|
275
|
+
def _closed(self, target_id: str) -> None:
|
|
276
|
+
with contextlib.suppress(ValueError):
|
|
277
|
+
self._tabs.remove(target_id)
|
|
278
|
+
if target_id == self.target:
|
|
279
|
+
self._spawn(self._follow_newest())
|
|
280
|
+
|
|
281
|
+
async def _follow_newest(self) -> None:
|
|
282
|
+
if self._tabs:
|
|
283
|
+
with contextlib.suppress(Exception): # it keeps the tab it had
|
|
284
|
+
await self.follow(self._tabs[-1])
|
|
285
|
+
|
|
286
|
+
def _spawn(self, work: Any) -> None:
|
|
287
|
+
task = asyncio.create_task(work)
|
|
288
|
+
self._tasks.add(task)
|
|
289
|
+
task.add_done_callback(self._tasks.discard)
|
|
290
|
+
|
|
291
|
+
async def _frame(
|
|
292
|
+
self, ws: websockets.ClientConnection, params: dict[str, Any], now: float
|
|
293
|
+
) -> None:
|
|
294
|
+
if self.take.capped:
|
|
295
|
+
return # not acked, so Chrome sends no more
|
|
296
|
+
meta = params["metadata"]
|
|
297
|
+
at = _on_clock(meta.get("timestamp"), now) # painted, which can be a while before
|
|
298
|
+
jpeg = base64.b64decode(params["data"])
|
|
299
|
+
frame = Frame(at, jpeg, meta["deviceWidth"], meta["deviceHeight"])
|
|
300
|
+
frames = self.take.frames
|
|
301
|
+
last = frames[-1] if frames else None
|
|
302
|
+
# The video shows a frame per tick: the newest, if nothing happened since the last.
|
|
303
|
+
if last and int(last.at * FPS) == int(at * FPS) and self._acted <= last.at <= at:
|
|
304
|
+
self._frame_bytes -= len(frames.pop().jpeg)
|
|
305
|
+
frames.append(frame)
|
|
306
|
+
self._frame_bytes += len(jpeg)
|
|
307
|
+
if self._frame_bytes >= _MAX_FRAME_BYTES:
|
|
308
|
+
self.take.capped = True
|
|
309
|
+
self.done.set()
|
|
310
|
+
return
|
|
311
|
+
ack = {"sessionId": params["sessionId"]}
|
|
312
|
+
message = {"id": next(self._ids), "method": "Page.screencastFrameAck", "params": ack}
|
|
313
|
+
await ws.send(json.dumps({**message, "sessionId": self._session}))
|
|
314
|
+
|
|
315
|
+
def _navigated(self, at: float, url: str) -> None:
|
|
316
|
+
if url and url != "about:blank": # a tab before its first page
|
|
317
|
+
self._note(Event(at, "navigate", text=_bare(url)))
|
|
318
|
+
|
|
319
|
+
def _note(self, event: Event) -> None:
|
|
320
|
+
if not self.take.capped and len(self.take.events) < _MAX_EVENTS:
|
|
321
|
+
self.take.events.append(event)
|
|
322
|
+
if event.kind != "move":
|
|
323
|
+
self._acted = event.at
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _event(now: float, payload: dict[str, Any]) -> Event:
|
|
327
|
+
at = payload.get("at")
|
|
328
|
+
return Event(
|
|
329
|
+
_on_clock(at / 1000 if isinstance(at, int | float) else None, now),
|
|
330
|
+
payload["type"],
|
|
331
|
+
float(payload.get("x", 0)),
|
|
332
|
+
float(payload.get("y", 0)),
|
|
333
|
+
str(payload.get("text", ""))[:200],
|
|
334
|
+
str(payload.get("target", ""))[:120],
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _on_clock(at: object, now: float) -> float:
|
|
339
|
+
"""When something happened by Chrome's own word, if that is on the box's clock.
|
|
340
|
+
|
|
341
|
+
Its frames arrive a while after they are painted, and the page's events a little
|
|
342
|
+
after they happen, so arrival would put the frame that shows typing after the click
|
|
343
|
+
that follows it.
|
|
344
|
+
"""
|
|
345
|
+
if isinstance(at, int | float) and abs(at - now) < _SKEW_S:
|
|
346
|
+
return float(at)
|
|
347
|
+
return now
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _bare(url: str) -> str:
|
|
351
|
+
"""``url`` without what can carry a secret: its query, fragment and credentials."""
|
|
352
|
+
parts = urlsplit(url)
|
|
353
|
+
if parts.scheme == "data": # the page itself is in the url
|
|
354
|
+
return f"data:{parts.path.partition(',')[0]},..."
|
|
355
|
+
netloc = parts.netloc.rpartition("@")[2]
|
|
356
|
+
return urlunsplit((parts.scheme, netloc, parts.path, "", ""))[:_URL_CHARS]
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
# --- the video's timeline ------------------------------------------------------------
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
# How long the video gives each input to read, however fast the agent was: an
|
|
363
|
+
# agent often clicks, types and submits in one call, within milliseconds.
|
|
364
|
+
_READ_S = {"click": 0.7, "key": 0.35}
|
|
365
|
+
_CHAR_READ_S = 0.07
|
|
366
|
+
_TYPING_READ_S = (0.1, 1.2) # one burst of typed text: shortest, longest
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
class Clock:
|
|
370
|
+
"""The video's clock: real time with pauses cut and bursts of input spread out.
|
|
371
|
+
|
|
372
|
+
A pause, such as the model thinking between calls, plays for at most
|
|
373
|
+
``MAX_HOLD_S``. Each click, key or bit of typing gets time to read before the
|
|
374
|
+
next thing happens, so a login done in one call plays as steps. Typing gets it
|
|
375
|
+
once the frame that shows it is up, so its caption keeps pace with the page.
|
|
376
|
+
"""
|
|
377
|
+
|
|
378
|
+
def __init__(self, take: Take) -> None:
|
|
379
|
+
moments: list[tuple[float, int, Frame | Event]] = sorted(
|
|
380
|
+
[(f.at, 0, f) for f in take.frames]
|
|
381
|
+
+ [(e.at, 1, e) for e in take.events if e.kind != "move"],
|
|
382
|
+
key=lambda moment: moment[:2],
|
|
383
|
+
)
|
|
384
|
+
self.reals: list[float] = []
|
|
385
|
+
self.videos: list[float] = []
|
|
386
|
+
self._placed: dict[int, float] = {} # each frame's and event's own time
|
|
387
|
+
video, need, typing = 0.0, 0.0, 0.0
|
|
388
|
+
for at, _, moment in moments:
|
|
389
|
+
shows_typing = isinstance(moment, Frame) and typing
|
|
390
|
+
if self.reals:
|
|
391
|
+
video += max(min(at - self.reals[-1], MAX_HOLD_S), 0.0 if shows_typing else need)
|
|
392
|
+
self.reals.append(at)
|
|
393
|
+
self.videos.append(video)
|
|
394
|
+
self._placed[id(moment)] = video
|
|
395
|
+
if isinstance(moment, Frame):
|
|
396
|
+
need, typing = typing, 0.0
|
|
397
|
+
else:
|
|
398
|
+
need = _read_time(moment)
|
|
399
|
+
typing = need if moment.kind == "input" else 0.0
|
|
400
|
+
self.length = video + max(END_HOLD_S, need)
|
|
401
|
+
self.frame_starts = [self._placed[id(f)] for f in take.frames]
|
|
402
|
+
|
|
403
|
+
def of(self, event: Event) -> float:
|
|
404
|
+
"""Where ``event`` lands in the video."""
|
|
405
|
+
if (placed := self._placed.get(id(event))) is not None:
|
|
406
|
+
return placed
|
|
407
|
+
return self.at(event.at) # a pointer move, which takes no time of its own
|
|
408
|
+
|
|
409
|
+
def at(self, real: float) -> float:
|
|
410
|
+
"""Where a moment of real time lands in the video."""
|
|
411
|
+
index = bisect.bisect_right(self.reals, real) - 1
|
|
412
|
+
if index < 0:
|
|
413
|
+
return 0.0
|
|
414
|
+
span = self.videos[index + 1] if index + 1 < len(self.videos) else self.length
|
|
415
|
+
return self.videos[index] + min(real - self.reals[index], span - self.videos[index])
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _read_time(event: Event) -> float:
|
|
419
|
+
if event.kind == "input":
|
|
420
|
+
low, high = _TYPING_READ_S
|
|
421
|
+
return min(max(len(event.text) * _CHAR_READ_S, low), high)
|
|
422
|
+
return _READ_S.get(event.kind, 0.0)
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
_TYPING_GAP_S = 1.5 # typing further apart than this is another burst
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def events(take: Take) -> list[dict[str, object]]:
|
|
429
|
+
"""What happened, at video time ``t``: for captions, chapters or a PR's notes.
|
|
430
|
+
|
|
431
|
+
Clicks keep their CSS point in the pinned viewport; a burst of typing into one
|
|
432
|
+
field is one ``type`` event; pointer moves are left out.
|
|
433
|
+
"""
|
|
434
|
+
clock = Clock(take)
|
|
435
|
+
out: list[dict[str, object]] = []
|
|
436
|
+
typed: dict[str, object] | None = None
|
|
437
|
+
last_typed: Event | None = None
|
|
438
|
+
last_url = None
|
|
439
|
+
for event in take.events:
|
|
440
|
+
t = round(clock.of(event), 2)
|
|
441
|
+
if event.kind == "input" and typed and last_typed and _one_burst(last_typed, event):
|
|
442
|
+
typed["text"] = _typed(str(typed["text"]), event.text)
|
|
443
|
+
elif event.kind == "input":
|
|
444
|
+
typed = {"t": t, "type": "type", "text": event.text, "target": event.target}
|
|
445
|
+
out.append(typed)
|
|
446
|
+
elif event.kind == "click":
|
|
447
|
+
out.append(
|
|
448
|
+
{
|
|
449
|
+
"t": t,
|
|
450
|
+
"type": "click",
|
|
451
|
+
"x": round(event.x),
|
|
452
|
+
"y": round(event.y),
|
|
453
|
+
"target": event.target,
|
|
454
|
+
}
|
|
455
|
+
)
|
|
456
|
+
elif event.kind == "key":
|
|
457
|
+
out.append({"t": t, "type": "key", "key": event.text, "target": event.target})
|
|
458
|
+
elif event.kind == "navigate" and event.text != last_url:
|
|
459
|
+
out.append({"t": t, "type": "navigate", "url": event.text})
|
|
460
|
+
last_url = event.text
|
|
461
|
+
if event.kind == "input":
|
|
462
|
+
last_typed = event
|
|
463
|
+
elif event.kind != "move":
|
|
464
|
+
typed = None
|
|
465
|
+
return out
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def summary(happened: list[dict[str, object]], limit: int = 30) -> str:
|
|
469
|
+
"""A line per event, for the model to caption or describe the video with."""
|
|
470
|
+
|
|
471
|
+
def line(event: dict[str, object]) -> str:
|
|
472
|
+
at = f"{event['t']:>6.1f}s"
|
|
473
|
+
if event["type"] == "navigate":
|
|
474
|
+
return f"{at} opened {event['url']}"
|
|
475
|
+
if event["type"] == "click":
|
|
476
|
+
return f"{at} clicked {event['target'] or 'the page'} at ({event['x']}, {event['y']})"
|
|
477
|
+
if event["type"] == "type":
|
|
478
|
+
return f"{at} typed {event['text']!r} into {event['target'] or 'the page'}"
|
|
479
|
+
return f"{at} pressed {event['key']}"
|
|
480
|
+
|
|
481
|
+
lines = [line(event) for event in happened[:limit]]
|
|
482
|
+
if len(happened) > limit:
|
|
483
|
+
lines.append(f"... and {len(happened) - limit} more in the JSON.")
|
|
484
|
+
return "\n".join(lines) or "(nothing)"
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
# --- encoding ------------------------------------------------------------------------
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def encode(take: Take) -> bytes:
|
|
491
|
+
"""An H.264 mp4 of ``take``, with its cursor, clicks and typing drawn on."""
|
|
492
|
+
if not take.frames:
|
|
493
|
+
msg = "the recording has no frames"
|
|
494
|
+
raise ValueError(msg)
|
|
495
|
+
size = _size(take)
|
|
496
|
+
overlay = _Overlay(take, size)
|
|
497
|
+
# On disk: `faststart`, which lets a page play it before it has all loaded, reopens
|
|
498
|
+
# the file to move its index to the front.
|
|
499
|
+
with tempfile.TemporaryDirectory(prefix="mda-recording-") as tmp:
|
|
500
|
+
out = Path(tmp, "out.mp4")
|
|
501
|
+
with av.open(str(out), mode="w", options={"movflags": "+faststart"}) as container:
|
|
502
|
+
stream = container.add_stream(
|
|
503
|
+
"libx264", rate=FPS, options={"crf": "20", "preset": "veryfast"}
|
|
504
|
+
)
|
|
505
|
+
stream.width, stream.height = size
|
|
506
|
+
stream.pix_fmt = "yuv420p"
|
|
507
|
+
for image in overlay.frames():
|
|
508
|
+
for packet in stream.encode(av.VideoFrame.from_image(image)):
|
|
509
|
+
container.mux(packet)
|
|
510
|
+
for packet in stream.encode():
|
|
511
|
+
container.mux(packet)
|
|
512
|
+
return out.read_bytes()
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def _size(take: Take) -> tuple[int, int]:
|
|
516
|
+
"""The video's size: its first frame's, with even sides, which yuv420p needs."""
|
|
517
|
+
first = Image.open(io.BytesIO(take.frames[0].jpeg))
|
|
518
|
+
return first.width // 2 * 2, first.height // 2 * 2
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
_GLIDE_S = (0.25, 0.6) # how long the cursor takes to reach a point: near, far
|
|
522
|
+
_SAME_POINT_S = 0.1 # a move and the click it leads to are one point
|
|
523
|
+
_CLICK_S = 0.6 # how long a click's ripple shows
|
|
524
|
+
_PRESS_S = 0.15 # how long the cursor shrinks as it clicks
|
|
525
|
+
_CAPTION_HOLD_S = 1.2 # how long typing stays up after the last key
|
|
526
|
+
_CAPTION_CHARS = 48
|
|
527
|
+
_ACCENT = (37, 99, 235)
|
|
528
|
+
# A classic arrow, tip at (0, 0), in CSS pixels.
|
|
529
|
+
_ARROW = [(0, 0), (0, 21), (5, 16), (9, 25), (13, 23), (9, 15), (16, 15)]
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
@dataclass
|
|
533
|
+
class _Point:
|
|
534
|
+
t: float # video time it arrives
|
|
535
|
+
x: float
|
|
536
|
+
y: float
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
class _Overlay:
|
|
540
|
+
"""Draws ``take`` frame by frame: the cursor gliding between points, clicks, typing."""
|
|
541
|
+
|
|
542
|
+
def __init__(self, take: Take, size: tuple[int, int]) -> None:
|
|
543
|
+
self.take = take
|
|
544
|
+
self.size = size
|
|
545
|
+
self.clock = Clock(take)
|
|
546
|
+
frame = take.frames[0]
|
|
547
|
+
self.scale = size[0] / frame.width if frame.width else 1.0 # video pixels per CSS pixel
|
|
548
|
+
self.font = ImageFont.load_default(size=22 * self.scale)
|
|
549
|
+
timed = [(self.clock.of(e), e) for e in take.events]
|
|
550
|
+
self.clicks = [(t, e) for t, e in timed if e.kind == "click"]
|
|
551
|
+
self.points = _points(timed, frame.width, frame.height)
|
|
552
|
+
self.typing = [(t, e) for t, e in timed if e.kind in ("key", "input")]
|
|
553
|
+
|
|
554
|
+
def frames(self) -> Iterator[Image.Image]:
|
|
555
|
+
fitted = functools.lru_cache(maxsize=1)(lambda i: _fit(self.take.frames[i].jpeg, self.size))
|
|
556
|
+
for n in range(math.ceil(self.clock.length * FPS)):
|
|
557
|
+
now = n / FPS
|
|
558
|
+
image = fitted(max(bisect.bisect_right(self.clock.frame_starts, now) - 1, 0)).copy()
|
|
559
|
+
draw = ImageDraw.Draw(image, "RGBA")
|
|
560
|
+
self._draw_clicks(draw, now)
|
|
561
|
+
self._draw_cursor(draw, now)
|
|
562
|
+
self._draw_typing(draw, now)
|
|
563
|
+
yield image
|
|
564
|
+
|
|
565
|
+
def _draw_clicks(self, draw: ImageDraw.ImageDraw, now: float) -> None:
|
|
566
|
+
for shown, click in self.clicks:
|
|
567
|
+
if not 0 <= (age := now - shown) < _CLICK_S:
|
|
568
|
+
continue
|
|
569
|
+
s = self.scale
|
|
570
|
+
x, y, fade = click.x * s, click.y * s, 1 - age / _CLICK_S
|
|
571
|
+
radius = (8 + 26 * (1 - fade**3)) * s # eases out
|
|
572
|
+
box = (x - radius, y - radius, x + radius, y + radius)
|
|
573
|
+
draw.ellipse(box, outline=(255, 255, 255, int(230 * fade)), width=round(7 * s))
|
|
574
|
+
draw.ellipse(box, outline=(*_ACCENT, int(255 * fade)), width=round(3 * s))
|
|
575
|
+
dot = 9 * s
|
|
576
|
+
draw.ellipse((x - dot, y - dot, x + dot, y + dot), fill=(*_ACCENT, int(90 * fade)))
|
|
577
|
+
|
|
578
|
+
def _draw_cursor(self, draw: ImageDraw.ImageDraw, now: float) -> None:
|
|
579
|
+
x, y = (v * self.scale for v in _cursor_at(self.points, now))
|
|
580
|
+
pressed = any(0 <= now - shown < _PRESS_S for shown, _ in self.clicks)
|
|
581
|
+
s = self.scale
|
|
582
|
+
size = s * (0.85 if pressed else 1.0)
|
|
583
|
+
arrow = [(x + px * size, y + py * size) for px, py in _ARROW]
|
|
584
|
+
draw.polygon([(px + 2 * s, py + 3 * s) for px, py in arrow], fill=(0, 0, 0, 70)) # shadow
|
|
585
|
+
outline = (20, 20, 20, 255)
|
|
586
|
+
draw.polygon(arrow, fill=(255, 255, 255, 255), outline=outline, width=round(2 * s))
|
|
587
|
+
|
|
588
|
+
def _draw_typing(self, draw: ImageDraw.ImageDraw, now: float) -> None:
|
|
589
|
+
text = self._caption(now)
|
|
590
|
+
if not text:
|
|
591
|
+
return
|
|
592
|
+
s = self.scale
|
|
593
|
+
left, top, right, bottom = draw.textbbox((0, 0), text, font=self.font)
|
|
594
|
+
pad_x, pad_y = 18 * s, 12 * s
|
|
595
|
+
width, height = right - left + 2 * pad_x, bottom - top + 2 * pad_y
|
|
596
|
+
x0, y0 = (self.size[0] - width) / 2, self.size[1] - height - 36 * s
|
|
597
|
+
draw.rounded_rectangle(
|
|
598
|
+
(x0, y0, x0 + width, y0 + height), radius=height / 2, fill=(17, 17, 17, 210)
|
|
599
|
+
)
|
|
600
|
+
draw.text((x0 + pad_x - left, y0 + pad_y - top), text, font=self.font, fill="white")
|
|
601
|
+
|
|
602
|
+
def _caption(self, now: float) -> str:
|
|
603
|
+
"""The burst of typing that ``now`` falls in, as typed so far."""
|
|
604
|
+
shown = [(t, e) for t, e in self.typing if t <= now]
|
|
605
|
+
if not shown or now - shown[-1][0] > _CAPTION_HOLD_S:
|
|
606
|
+
return ""
|
|
607
|
+
first = len(shown) - 1
|
|
608
|
+
while first and _one_burst(shown[first - 1][1], shown[first][1]):
|
|
609
|
+
first -= 1
|
|
610
|
+
text = ""
|
|
611
|
+
for _, event in shown[first:]:
|
|
612
|
+
text = _typed(text, event.text) if event.kind == "input" else f"{text} [{event.text}] "
|
|
613
|
+
text = text.strip()
|
|
614
|
+
return text if len(text) <= _CAPTION_CHARS else "..." + text[-_CAPTION_CHARS:]
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def _one_burst(before: Event, after: Event) -> bool:
|
|
618
|
+
"""Typing close together, into one field."""
|
|
619
|
+
return after.at - before.at <= _TYPING_GAP_S and after.target == before.target
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def _typed(text: str, more: str) -> str:
|
|
623
|
+
"""``more`` typed after ``text``; a masked field shows one mask, however long."""
|
|
624
|
+
return text if more == _MASK and text.endswith(_MASK) else text + more
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def _points(timed: list[tuple[float, Event]], width: int, height: int) -> list[_Point]:
|
|
628
|
+
"""Where the cursor is headed, and by when: the pointer's moves and clicks."""
|
|
629
|
+
points = [_Point(0.0, width / 2, height / 2)]
|
|
630
|
+
for t, event in timed:
|
|
631
|
+
if event.kind not in ("move", "click"):
|
|
632
|
+
continue
|
|
633
|
+
if t - points[-1].t < _SAME_POINT_S and len(points) > 1:
|
|
634
|
+
points[-1] = _Point(points[-1].t, event.x, event.y) # the same gesture
|
|
635
|
+
else:
|
|
636
|
+
points.append(_Point(t, event.x, event.y))
|
|
637
|
+
return points
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def _cursor_at(points: list[_Point], now: float) -> tuple[float, float]:
|
|
641
|
+
"""The cursor at ``now``, gliding into each point along a slight curve.
|
|
642
|
+
|
|
643
|
+
The glide ends as the point is reached, so the page's pauses make room for it
|
|
644
|
+
and the video does not run long.
|
|
645
|
+
"""
|
|
646
|
+
index = bisect.bisect_right([p.t for p in points], now)
|
|
647
|
+
if index >= len(points):
|
|
648
|
+
return points[-1].x, points[-1].y
|
|
649
|
+
before, to = points[index - 1], points[index]
|
|
650
|
+
distance = math.hypot(to.x - before.x, to.y - before.y)
|
|
651
|
+
glide = min(_GLIDE_S[0] + distance / 2500, _GLIDE_S[1], to.t - before.t)
|
|
652
|
+
if glide <= 0 or now < to.t - glide:
|
|
653
|
+
return before.x, before.y
|
|
654
|
+
k = _ease_in_out((now - (to.t - glide)) / glide)
|
|
655
|
+
# Bow the path to one side, alternating, and settle with a slight wobble.
|
|
656
|
+
bend = (0.12 if index % 2 else -0.12) * distance
|
|
657
|
+
nx, ny = (-(to.y - before.y), to.x - before.x) if distance else (0.0, 0.0)
|
|
658
|
+
norm = distance or 1.0
|
|
659
|
+
arc = 4 * k * (1 - k) * bend + 2.5 * math.sin(k * math.pi * 3) * (1 - k)
|
|
660
|
+
x = before.x + (to.x - before.x) * k + nx / norm * arc
|
|
661
|
+
y = before.y + (to.y - before.y) * k + ny / norm * arc
|
|
662
|
+
return x, y
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def _ease_in_out(k: float) -> float:
|
|
666
|
+
return 4 * k**3 if k < 1 / 2 else 1 - (-2 * k + 2) ** 3 / 2
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def _fit(jpeg: bytes, size: tuple[int, int]) -> Image.Image:
|
|
670
|
+
return ImageOps.pad(Image.open(io.BytesIO(jpeg)).convert("RGB"), size, color="black")
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
def _still(jpeg: bytes, size: tuple[int, int]) -> bytes:
|
|
674
|
+
"""``jpeg`` at ``size``, for the model to look at."""
|
|
675
|
+
if Image.open(io.BytesIO(jpeg)).size == size:
|
|
676
|
+
return jpeg
|
|
677
|
+
out = io.BytesIO()
|
|
678
|
+
_fit(jpeg, size).save(out, "JPEG", quality=85)
|
|
679
|
+
return out.getvalue()
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
# --- the program, in the browser's sandbox ---------------------------------------------
|
|
683
|
+
|
|
684
|
+
_RESULT = "result.json" # written last: how long the video is, or why there is none
|
|
685
|
+
_SAVE_TIMEOUT_S = 600 # to stop and encode a take
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
def main(argv: list[str]) -> int:
|
|
689
|
+
"""``start``, ``stop`` or, in the background, ``record`` a take in a folder.
|
|
690
|
+
|
|
691
|
+
``start`` and ``stop`` print one JSON object, with ``error`` if they failed.
|
|
692
|
+
"""
|
|
693
|
+
match argv:
|
|
694
|
+
case ["start", folder, cdp_url, tab]:
|
|
695
|
+
return _start(Path(folder), cdp_url, tab)
|
|
696
|
+
case ["stop", folder]:
|
|
697
|
+
return _stop(Path(folder))
|
|
698
|
+
case ["record", folder, cdp_url, tab]:
|
|
699
|
+
asyncio.run(_record(Path(folder), cdp_url, tab))
|
|
700
|
+
return 0
|
|
701
|
+
sys.stderr.write(__doc__ or "")
|
|
702
|
+
return 2
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
def _start(folder: Path, cdp_url: str, tab: str) -> int:
|
|
706
|
+
"""Record in the background, returning once the screencast has begun."""
|
|
707
|
+
folder.mkdir(parents=True, exist_ok=True) # browser.py runs this program from it
|
|
708
|
+
with (folder / "log").open("wb") as log:
|
|
709
|
+
child = subprocess.Popen( # noqa: S603 - this program, with the caller's arguments
|
|
710
|
+
[sys.executable, __file__, "record", str(folder), cdp_url, tab],
|
|
711
|
+
stdin=subprocess.DEVNULL,
|
|
712
|
+
stdout=log,
|
|
713
|
+
stderr=subprocess.STDOUT,
|
|
714
|
+
start_new_session=True, # outlives this command
|
|
715
|
+
)
|
|
716
|
+
(folder / "pid").write_text(str(child.pid))
|
|
717
|
+
deadline = time.monotonic() + _TIMEOUT_S
|
|
718
|
+
while not (folder / "ready").exists():
|
|
719
|
+
if child.poll() is not None or time.monotonic() > deadline:
|
|
720
|
+
child.kill()
|
|
721
|
+
return _reply({"error": _failure(folder, "the recorder did not start")})
|
|
722
|
+
time.sleep(0.05)
|
|
723
|
+
return _reply({"recording": True})
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
def _stop(folder: Path) -> int:
|
|
727
|
+
"""Stop the take, unless it already ended, and wait until it is saved."""
|
|
728
|
+
result = folder / _RESULT
|
|
729
|
+
pid = int((folder / "pid").read_text())
|
|
730
|
+
if not result.exists():
|
|
731
|
+
with contextlib.suppress(ProcessLookupError):
|
|
732
|
+
os.kill(pid, signal.SIGTERM)
|
|
733
|
+
deadline = time.monotonic() + _SAVE_TIMEOUT_S
|
|
734
|
+
while not result.exists():
|
|
735
|
+
if not _alive(pid) or time.monotonic() > deadline:
|
|
736
|
+
return _reply({"error": _failure(folder, "the recorder stopped without saving")})
|
|
737
|
+
time.sleep(0.1)
|
|
738
|
+
sys.stdout.write(result.read_text() + "\n")
|
|
739
|
+
return 0
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
async def _record(folder: Path, cdp_url: str, tab: str) -> None:
|
|
743
|
+
"""Record until told to stop or a limit, then save the take into ``folder``."""
|
|
744
|
+
recorder = Recorder(cdp_url)
|
|
745
|
+
asyncio.get_running_loop().add_signal_handler(signal.SIGTERM, recorder.done.set)
|
|
746
|
+
try:
|
|
747
|
+
await recorder.start(tab)
|
|
748
|
+
except Exception as err: # noqa: BLE001 - reported to the agent
|
|
749
|
+
_result(folder, {"error": f"could not record the tab: {type(err).__name__}: {err}"})
|
|
750
|
+
return
|
|
751
|
+
(folder / "ready").touch()
|
|
752
|
+
done = asyncio.create_task(recorder.done.wait())
|
|
753
|
+
ends = [done, recorder.reader] if recorder.reader else [done] # or the window went away
|
|
754
|
+
ended, _ = await asyncio.wait(ends, timeout=MAX_S, return_when=asyncio.FIRST_COMPLETED)
|
|
755
|
+
if not ended:
|
|
756
|
+
recorder.take.capped = True # MAX_S
|
|
757
|
+
elif not recorder.done.is_set():
|
|
758
|
+
recorder.take.closed = True
|
|
759
|
+
done.cancel()
|
|
760
|
+
take = await recorder.stop()
|
|
761
|
+
try:
|
|
762
|
+
_result(folder, _save(folder, take))
|
|
763
|
+
except Exception as err: # noqa: BLE001 - reported to the agent
|
|
764
|
+
_result(folder, {"error": f"could not save the recording: {type(err).__name__}: {err}"})
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
def _save(folder: Path, take: Take) -> dict[str, object]:
|
|
768
|
+
"""Encode ``take`` beside its timeline and its first and last frames."""
|
|
769
|
+
if not take.frames:
|
|
770
|
+
return {"error": "the recording has no frames: the tab never painted."}
|
|
771
|
+
take.frames.sort(key=lambda frame: frame.at) # they can arrive out of the order painted
|
|
772
|
+
(folder / "take.mp4").write_bytes(encode(take))
|
|
773
|
+
happened = events(take)
|
|
774
|
+
(folder / "take.json").write_text(json.dumps(happened, indent=1))
|
|
775
|
+
# At the page's CSS size: the model reads them, and they stay in the thread.
|
|
776
|
+
first = take.frames[0]
|
|
777
|
+
page = (first.width, first.height) if first.width and first.height else _size(take)
|
|
778
|
+
(folder / "first.jpg").write_bytes(_still(first.jpeg, page))
|
|
779
|
+
(folder / "last.jpg").write_bytes(_still(take.frames[-1].jpeg, page))
|
|
780
|
+
return {
|
|
781
|
+
"seconds": round(Clock(take).length, 2),
|
|
782
|
+
"early": _early(take),
|
|
783
|
+
"summary": summary(happened),
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
def _early(take: Take) -> str:
|
|
788
|
+
"""Why ``take`` ended before it was told to stop, for the model; empty if it did not."""
|
|
789
|
+
if take.closed:
|
|
790
|
+
return "It stopped early, when the browser closed."
|
|
791
|
+
if take.capped:
|
|
792
|
+
return (
|
|
793
|
+
f"It stopped early, at the recorder's limit: {MAX_S // 60} minutes, or less"
|
|
794
|
+
" on a page that kept changing."
|
|
795
|
+
)
|
|
796
|
+
return ""
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
def _result(folder: Path, result: dict[str, object]) -> None:
|
|
800
|
+
partial = folder / f"{_RESULT}.partial"
|
|
801
|
+
partial.write_text(json.dumps(result))
|
|
802
|
+
partial.replace(folder / _RESULT) # whole, or not there
|
|
803
|
+
|
|
804
|
+
|
|
805
|
+
def _reply(reply: dict[str, object]) -> int:
|
|
806
|
+
sys.stdout.write(json.dumps(reply) + "\n")
|
|
807
|
+
return 1 if "error" in reply else 0
|
|
808
|
+
|
|
809
|
+
|
|
810
|
+
def _failure(folder: Path, otherwise: str) -> str:
|
|
811
|
+
"""Why the recorder failed: its own word, or the last line it logged."""
|
|
812
|
+
with contextlib.suppress(OSError, ValueError, KeyError, TypeError):
|
|
813
|
+
return str(json.loads((folder / _RESULT).read_text())["error"])
|
|
814
|
+
with contextlib.suppress(OSError, IndexError):
|
|
815
|
+
last = (folder / "log").read_text(errors="replace").strip().splitlines()[-1]
|
|
816
|
+
return f"{otherwise}: {last[-300:]}"
|
|
817
|
+
return otherwise
|
|
818
|
+
|
|
819
|
+
|
|
820
|
+
def _alive(pid: int) -> bool:
|
|
821
|
+
try:
|
|
822
|
+
os.kill(pid, 0)
|
|
823
|
+
except ProcessLookupError:
|
|
824
|
+
return False
|
|
825
|
+
with contextlib.suppress(OSError, IndexError): # Linux: a zombie no one reaped
|
|
826
|
+
return Path(f"/proc/{pid}/stat").read_text().rpartition(") ")[2][:1] != "Z"
|
|
827
|
+
return True
|
|
828
|
+
|
|
829
|
+
|
|
830
|
+
if __name__ == "__main__":
|
|
831
|
+
sys.exit(main(sys.argv[1:]))
|